Bladeren bron

fix(workspace): 工作区归属上云 + 输入中指示器归位到消息流末尾

问题一:在工作区里新建对话,结果掉进「日常对话」
根因两层:
1. ChatSession.workspaceId 从未上云,hydrateFromCloud 重建列表时靠本地缓存捡,
   捡不到就回落 null(日常)—— 换设备/清缓存/刚新建未落盘都会触发
2. ownerOf() 写成 workspaceSvc.byId(id) ? id : null,
   工作区列表晚于会话到达时会被静默改判成日常

修复:
- 云端 FmodeChatThread 新增 workspaceId 字段(云函数 v0.1.4 已热更并端到端验证 5/5)
- ChatThreadUpsert / ChatThreadRecord / patchChatThread 全链路补齐
- toUpsert 带归属;hydrateFromCloud 改为云端优先,云端为空才回落本地(兼容旧数据)
- moveTo 同步 patch 到云端,用 '' 表示日常(空串是合法值,不能用真值判断)
- ownerOf 直接返回 workspaceId,不再因工作区存在性改判
- 新增 orphanSessions 兜底:归属指向已删工作区的会话单独展示,不允许消失

问题二:容器「正在输入」动画位置不对
- 从输入框上方的独立槽位移入消息流末尾,出现在下一条消息的位置
- 修滚动:effect 改用双层 rAF,等 DOM 插入后贴底,否则指示器落在视口外

验证:tsc 0 错误;单测 1074/1074(新增 3 条工作区回归);构建通过;verify:ui 12/12

Co-Authored-By: Claude Code <noreply@anthropic.com>
Huccct 1 dag geleden
bovenliggende
commit
f01d177809

+ 324 - 0
cloud/backup/oR5O67i1Cm-fmodeagent-chatthreads-v0.1.3.js

@@ -0,0 +1,324 @@
+// FmodeAgent 会话目录云函数(2026-10-31 建,2026-11-01 v0.1.3 修复重复记录)
+//
+// 目的:会话目录(标题/归档/排序)此前只存在客户端 localStorage,
+//       换浏览器或清站点数据就全部丢失。这里把它落到服务端,成为**唯一真相源**。
+//
+// 调用:POST https://server.fmode.cn/api/functions
+//       头:X-Parse-Application-Id / X-Parse-Session-Token
+//       body:{ id: "<本函数 objectId>", params: {...} }
+//
+// params.op:
+//   list   { agentId }                                  → { code:200, threads:[...] }
+//   upsert { items:[{agentId,roomId,threadRoot,title,messageCount,lastMessageAt,lastPreview,revive}] }
+//                                                       → { code:200, created, updated }
+//   patch  { threadRoot, title?, archived? }            → { code:200, thread }
+//   remove { threadRoot }                               → { code:200, removed }
+//
+// 平台约定(与 fmodeagent-matrix-provision 一致,实测):
+//   · 入口 async function handler(request, response);入参在**全局 params**;
+//   · 身份:优先 request.user(平台按 sessionToken 注入),否则按 token 查 _Session;
+//   · 🔴 输出必须 response.status(200).json(...) **显式写出**;直接 return 对象平台不回包(请求挂起)。
+//
+// 安全约定:
+//   1. 归属一律取服务端解析出的 user,忽略客户端传入的任何 user / company 参数;
+//   2. 会话只归用户本人(不写 company),不会进公司共享视角;
+//   3. 删除是软删(isDeleted=true),与平台「删除用软删」铁律一致;
+//   4. 标题一旦被用户手动改过(titleLocked=true),upsert 不再覆盖。
+//
+// 表:FmodeChatThread(首次写入自动建表)
+//   user Pointer<_User> / agentId String / roomId String / threadRoot String
+//   title String / titleLocked Boolean / messageCount Number / lastMessageAt Date
+//   archived Boolean / isDeleted Boolean / lastPreview String
+//   唯一语义:(user, roomId, threadRoot) —— upsert 前查重保证
+
+var CLASS_NAME = 'FmodeChatThread';
+var MAX_ITEMS = 200;
+var TITLE_MAX = 60;
+var PREVIEW_MAX = 120;
+
+function _ok(data) {
+  response.status(200).json(Object.assign({ code: 200 }, data || {}));
+  return true;
+}
+function _fail(code, mess) {
+  response.status(200).json({ code: code, mess: mess });
+  return false;
+}
+
+function _str(v, max) {
+  var s = v == null ? '' : String(v);
+  return max && s.length > max ? s.slice(0, max) : s;
+}
+
+function _date(v) {
+  if (!v) return new Date();
+  var d = v instanceof Date ? v : new Date(v);
+  return isNaN(d.getTime()) ? new Date() : d;
+}
+
+/**
+ * token → _User(唯一权威解析)。
+ *
+ * 🔴 加固(2026-11-01,**不是**重复记录的根因,属正确性改进):
+ *    上一版先判 `request.user && request.user.id` 就直接 `return request.user`。
+ *    平台注入的 `request.user` 实测不一定带出 `objectId`,拿它当
+ *    `equalTo('user', ...)` 的条件会匹配不到记录。
+ *    现在以 token 查 `_Session` 得到的真实 `_User` 为准,
+ *    `request.user` 只在**确实带出 objectId** 时作为兜底。
+ *
+ * ⚠️ 重复记录的真根因在 `_findAllByRoot`(查重键含 roomId),见其注释。
+ */
+async function _getUser(t) {
+  if (!t) return null;
+  try {
+    var q = new Parse.Query(Parse.Session);
+    q.equalTo('sessionToken', t);
+    q.include('user');
+    var s = await q.first({ useMasterKey: true });
+    if (s) {
+      var u = s.get('user');
+      if (u) return u;
+    }
+  } catch (e) {
+    /* 落到 request.user 兜底 */
+  }
+  // 兜底:平台注入了**完整**用户对象时使用(残缺 Pointer 一律不用)
+  if (request && request.user && request.user.id) return request.user;
+  return null;
+}
+
+/** 取当前会话 token(与平台其他函数同一约定) */
+/**
+ * 取当前会话 token。
+ *
+ * 🔴 实测(2026-10-31,加 __debug 探测得出):
+ *    · 平台把 X-Parse-Session-Token 请求头**原样保留**在 request.headers;
+ *    · request.body 只剩 {id, params},body 里的 token 被平台剥离;
+ *    · request.user **没有**被注入。
+ *    ⇒ 身份的唯一可靠来源是 x-parse-session-token 请求头(兜底 params.sessionToken)。
+ */
+function _sessionToken() {
+  var h = request && request.headers ? request.headers : {};
+  if (h['x-parse-session-token']) return h['x-parse-session-token'];
+  if (request && request.body && request.body.token) return request.body.token;
+  if (params && params.sessionToken) return params.sessionToken;
+  return null;
+}
+
+/**
+ * 查某用户**所有房间**下同一线程根的全部记录(2026-11-01)。
+ *
+ * 为什么按 `threadRoot` 而不是 `(roomId, threadRoot)` 查:
+ *   · `threadRoot` 是客户端生成的全局唯一 ID,一个会话只可能属于一个线程;
+ *   · 房间可能变(agent 重建、换绑定),同一会话被写进新房间后,
+ *     旧房间那条会变成孤儿 —— 必须收敛掉,不能各留一条。
+ */
+function _findAllByRoot(userPtr, threadRoot) {
+  var q = new Parse.Query(CLASS_NAME);
+  q.equalTo('user', userPtr);
+  q.equalTo('threadRoot', threadRoot);
+  q.limit(50);
+  return q.find({ useMasterKey: true });
+}
+
+async function opList(userPtr, p) {
+  var q = new Parse.Query(CLASS_NAME);
+  q.equalTo('user', userPtr);
+  /*
+   * includeDeleted:1 → 只返回已软删的记录(客户端据此建立「不允许重建」黑名单)。
+   * 跨设备有效:换浏览器/重新登录后仍能挡住「删了又被扫描建回来」。
+   */
+  if (p.includeDeleted) q.equalTo('isDeleted', true);
+  else q.notEqualTo('isDeleted', true);
+  if (p.agentId) q.equalTo('agentId', String(p.agentId));
+  if (p.roomId) q.equalTo('roomId', String(p.roomId));
+  q.descending('lastMessageAt');
+  q.limit(500);
+  var rows = await q.find({ useMasterKey: true });
+  return _ok({
+    threads: rows.map(function (r) {
+      return {
+        objectId: r.id,
+        agentId: r.get('agentId') || '',
+        roomId: r.get('roomId') || '',
+        threadRoot: r.get('threadRoot') || '',
+        title: r.get('title') || '',
+        titleLocked: !!r.get('titleLocked'),
+        messageCount: r.get('messageCount') || 0,
+        lastMessageAt: (r.get('lastMessageAt') || r.updatedAt || r.createdAt).getTime(),
+        archived: !!r.get('archived'),
+        lastPreview: r.get('lastPreview') || '',
+      };
+    }),
+  });
+}
+
+async function opUpsert(userPtr, p) {
+  var items = Array.isArray(p.items) ? p.items.slice(0, MAX_ITEMS) : [];
+  if (items.length === 0) return _fail(400, 'items 不能为空');
+  var ptr = { __type: 'Pointer', className: '_User', objectId: userPtr.id };
+  var created = 0;
+  var updated = 0;
+  var collapsed = 0;
+  for (var i = 0; i < items.length; i++) {
+    var it = items[i] || {};
+    var roomId = _str(it.roomId, 200);
+    var threadRoot = _str(it.threadRoot, 200);
+    if (!roomId || !threadRoot) continue;
+    /*
+     * 🔴 按 threadRoot 查全部记录(不只是当前房间)。
+     *    房间可能变化;同时兜住历史脏数据里「同一线程根在多条记录」的情况 ——
+     *    取最新一条作为主记录,其余物理删除(索引重复没有任何保留价值)。
+     */
+    var rows = await _findAllByRoot(ptr, threadRoot);
+    var rec = null;
+    for (var j = 0; j < rows.length; j++) {
+      if (!rec || rows[j].updatedAt >= rec.updatedAt) rec = rows[j];
+    }
+    for (var k = 0; k < rows.length; k++) {
+      if (rec && rows[k].id !== rec.id) {
+        try {
+          await rows[k].destroy({ useMasterKey: true });
+          collapsed++;
+        } catch (e) {
+          /* 单条失败不影响其余 */
+        }
+      }
+    }
+    if (!rec) {
+      rec = new Parse.Object(CLASS_NAME);
+      rec.set('user', ptr);
+      rec.set('roomId', roomId);
+      rec.set('threadRoot', threadRoot);
+      rec.set('messageCount', 0);
+      rec.set('archived', false);
+      rec.set('isDeleted', false);
+      rec.set('titleLocked', false);
+      created++;
+    } else {
+      updated++;
+    }
+    // 标题:用户手改过就不覆盖;否则仅在为空时写入(避免把容器标题降级成首句)
+    var incomingTitle = _str(it.title, TITLE_MAX);
+    if (incomingTitle && !rec.get('titleLocked') && !rec.get('title')) {
+      rec.set('title', incomingTitle);
+    }
+    // 房间以本次上报为准(会话跟着房间走)
+    rec.set('roomId', roomId);
+    if (it.agentId) rec.set('agentId', _str(it.agentId, 120));
+    if (typeof it.messageCount === 'number' && it.messageCount >= 0) {
+      rec.set('messageCount', it.messageCount);
+    }
+    if (it.lastMessageAt) rec.set('lastMessageAt', _date(it.lastMessageAt));
+    if (it.lastPreview) rec.set('lastPreview', _str(it.lastPreview, PREVIEW_MAX));
+    // 🔴 upsert 表示「这条会话现在存在」→ 永远解除墓碑。
+    //    上一版只有传 revive:true 才解除,客户端漏传就会出现
+    //    「本地有、云端是墓碑」→ 下次拉取被当已删除跳过 → 会话消失(实测)。
+    rec.set('isDeleted', false);
+    await rec.save(null, { useMasterKey: true });
+  }
+  return _ok({ created: created, updated: updated, collapsed: collapsed });
+}
+
+async function opPatch(userPtr, p) {
+  var threadRoot = _str(p.threadRoot, 200);
+  if (!threadRoot) return _fail(400, 'threadRoot 不能为空');
+  var q = new Parse.Query(CLASS_NAME);
+  q.equalTo('user', userPtr);
+  q.equalTo('threadRoot', threadRoot);
+  q.notEqualTo('isDeleted', true);
+  q.descending('updatedAt');
+  var rec = await q.first({ useMasterKey: true });
+  if (!rec) return _fail(404, '会话不存在');
+  if (p.title != null) {
+    var t = _str(p.title, TITLE_MAX);
+    if (!t) return _fail(400, '标题不能为空');
+    rec.set('title', t);
+    rec.set('titleLocked', true);
+  }
+  if (p.archived != null) rec.set('archived', !!p.archived);
+  await rec.save(null, { useMasterKey: true });
+  return _ok({ thread: { objectId: rec.id, threadRoot: threadRoot } });
+}
+
+/**
+ * remove:**物理删除**云端索引(2026-11-01 用户裁决)。
+ *
+ * 语义:删除后不可恢复 —— 云端记录直接 `destroy()`,列表查询不会再返回。
+ * Matrix 房间里的消息**不动**(会话正文仍在服务器,只是不再作为会话展示)。
+ * 同时写入一条墓碑记录(isDeleted=true 的极简行),用于跨设备阻止扫描重建。
+ */
+async function opRemove(userPtr, p) {
+  var threadRoot = _str(p.threadRoot, 200);
+  if (!threadRoot) return _fail(400, 'threadRoot 不能为空');
+
+  /*
+   * 🔴 用 objectId 构造标准 Pointer 再查(2026-11-01):
+   *    平台注入的 userPtr 有时是不可枚举的代理对象,直接 equalTo('user', userPtr)
+   *    匹配不到已有记录(实测:记录明明存在,find() 返回 0 条)。
+   *    用 Pointer 显式指定 className + objectId 最稳。
+   */
+  var ptr = { __type: 'Pointer', className: '_User', objectId: userPtr.id };
+
+  /* ① 查全部同线程记录(含已软删) */
+  var q = new Parse.Query(CLASS_NAME);
+  q.equalTo('user', ptr);
+  q.equalTo('threadRoot', threadRoot);
+  q.limit(100);
+  var rows = await q.find({ useMasterKey: true });
+  console.warn('[REMOVE] found=' + rows.length + ' ids=' + rows.map(function (r) { return r.id; }).join(','));
+
+  /* ② 保留/创建一条墓碑(isDeleted=true),其余物理删除 */
+  var tomb = null;
+  for (var i = 0; i < rows.length; i++) {
+    if (rows[i].get('isDeleted') === true && !tomb) {
+      tomb = rows[i];
+      continue;
+    }
+    try {
+      await rows[i].destroy({ useMasterKey: true });
+    } catch (e) {
+      /* 单条删除失败不影响其余 */
+    }
+  }
+  if (!tomb) {
+    tomb = new Parse.Object(CLASS_NAME);
+    tomb.set('user', ptr);
+    tomb.set('threadRoot', threadRoot);
+    tomb.set('roomId', _str(p.roomId, 200) || '');
+    tomb.set('agentId', _str(p.agentId, 120) || '');
+    tomb.set('title', '');
+    tomb.set('messageCount', 0);
+    tomb.set('isDeleted', true);
+    await tomb.save(null, { useMasterKey: true });
+  }
+  return _ok({ removed: rows.length - (tomb ? 1 : 0), tomb: true });
+}
+
+async function handler(request, response) {
+  try {
+    /*
+     * 🔴 body 里的 token 由平台放在 **request.body.token**,不会合并进全局 params
+     *    (实测:只管 params.sessionToken 会一直 401,而 token 本身是有效的)。
+     *    业务参数在全局 params;用户身份两种来源都兼容。
+     */
+    params = (params && typeof params === 'object') ? params : (request && request.body && request.body.params) || {};
+    var user = await _getUser(_sessionToken());
+    if (!user) return _fail(401, 'sessionToken 无效或已过期,请重新登录 App');
+
+    switch (params.op) {
+      case 'list':
+        return await opList(user, params);
+      case 'upsert':
+        return await opUpsert(user, params);
+      case 'patch':
+        return await opPatch(user, params);
+      case 'remove':
+        return await opRemove(user, params);
+      default:
+        return _fail(400, '未知 op: ' + params.op);
+    }
+  } catch (e) {
+    return _fail(500, '会话目录服务异常: ' + ((e && e.message) || String(e)));
+  }
+}

+ 16 - 1
cloud/functions/fmodeagent-chatthreads.js

@@ -29,7 +29,8 @@
 //   user Pointer<_User> / agentId String / roomId String / threadRoot String
 //   title String / titleLocked Boolean / messageCount Number / lastMessageAt Date
 //   archived Boolean / isDeleted Boolean / lastPreview String
-//   唯一语义:(user, roomId, threadRoot) —— upsert 前查重保证
+//   workspaceId String(归属工作区;'' = 日常对话)— v0.1.4 新增
+//   唯一语义:(user, threadRoot) —— upsert 前查重保证
 
 var CLASS_NAME = 'FmodeChatThread';
 var MAX_ITEMS = 200;
@@ -148,6 +149,8 @@ async function opList(userPtr, p) {
         lastMessageAt: (r.get('lastMessageAt') || r.updatedAt || r.createdAt).getTime(),
         archived: !!r.get('archived'),
         lastPreview: r.get('lastPreview') || '',
+        // v0.1.4:工作区归属随列表返回,客户端据此还原层级
+        workspaceId: r.get('workspaceId') || '',
       };
     }),
   });
@@ -211,6 +214,14 @@ async function opUpsert(userPtr, p) {
     }
     if (it.lastMessageAt) rec.set('lastMessageAt', _date(it.lastMessageAt));
     if (it.lastPreview) rec.set('lastPreview', _str(it.lastPreview, PREVIEW_MAX));
+    /*
+     * v0.1.4:工作区归属。
+     * 🔴 用 `!= null` 判定而不是真值判断 —— '' 是合法值(代表「移回日常对话」),
+     *    用 if (it.workspaceId) 会把「移回日常」当成「没传」,归属永远清不掉。
+     */
+    if (it.workspaceId !== undefined && it.workspaceId !== null) {
+      rec.set('workspaceId', _str(it.workspaceId, 120));
+    }
     // 🔴 upsert 表示「这条会话现在存在」→ 永远解除墓碑。
     //    上一版只有传 revive:true 才解除,客户端漏传就会出现
     //    「本地有、云端是墓碑」→ 下次拉取被当已删除跳过 → 会话消失(实测)。
@@ -237,6 +248,10 @@ async function opPatch(userPtr, p) {
     rec.set('titleLocked', true);
   }
   if (p.archived != null) rec.set('archived', !!p.archived);
+  /* v0.1.4:移动工作区单独走 patch,'' = 移回日常对话 */
+  if (p.workspaceId !== undefined && p.workspaceId !== null) {
+    rec.set('workspaceId', _str(p.workspaceId, 120));
+  }
   await rec.save(null, { useMasterKey: true });
   return _ok({ thread: { objectId: rec.id, threadRoot: threadRoot } });
 }

+ 29 - 0
docs/00-tracking/操作追踪.md

@@ -3517,3 +3517,32 @@ messageCount: (s.messageCount ?? 0) + 1,   // 先累加
 - tsc 0 错误;单测 **1071/1071**(新增 3 条回归:首条消息后状态切换 / 打开已有会话退出待建态 / close 清理)
 - 构建通过;verify:ui 12/12;verify:docs 79 文件
 - 移动端无新建流程区(空态为通用「还没有消息」且输入框常驻),不需同步改动
+
+## 2026-11-01 第十一轮:工作区归属上云 + 输入中指示器归位
+
+### 🔴 问题一:在工作区新建对话,结果掉进「日常对话」
+**根因(两层)**
+1. **归属只存本地**:`ChatSession.workspaceId` 从未上云。`hydrateFromCloud` 重建列表时
+   靠 `local?.workspaceId` 捡——本地没这条(换设备/清缓存/刚建还没来得及落盘)就回落 `null`(日常)
+2. **渲染层改写归属**:`ownerOf()` 写成 `workspaceSvc.byId(id) ? id : null`,
+   工作区列表比会话到达得晚时,会话被**静默改判**成日常
+
+**修复**
+- 云端表 `FmodeChatThread` 新增 `workspaceId` 字段(v0.1.4 已热更)
+- `ChatThreadUpsert` / `ChatThreadRecord` / `patchChatThread` 全链路补齐该字段
+- `toUpsert()` 带归属;`hydrateFromCloud` 改为**云端优先**,云端为空才回落本地(兼容旧数据)
+- `moveTo()` 同步 `patch` 到云端;用 `''` 表示日常(空字符串是合法值,不能用真值判断)
+- `ownerOf()` 改为直接返回 `s.workspaceId`,不再因工作区存在性改判
+- 新增 `orphanSessions` 兜底:归属指向已删除工作区的会话单独展示,**不允许从界面消失**
+
+**端到端验证(云端)5/5**:upsert 保存归属 / patch 改归属 / 空串移回日常 / 再设回来 / 清理
+
+### 问题二:容器「正在输入」动画位置不对
+- 原:固定在输入框上方的独立槽位(`.typing-hold`)
+- 现:移入消息流末尾(`.typing-row`),出现在**下一条消息的位置**
+- 同时修滚动:effect 里改用双层 rAF,等 DOM 插入后再贴底,
+  否则按「指示器还没占位」的高度计算终点,指示器会落在视口外
+
+### 验证
+- tsc 0 错误;单测 **1074/1074**(新增 3 条工作区回归);构建通过;verify:ui 12/12;verify:docs 79 文件
+- 云函数 v0.1.4 已热更(备份:`cloud/backup/oR5O67i1Cm-...-v0.1.3.js`)

+ 46 - 3
src/app/components/domain/session-tree/session-tree.ts

@@ -353,6 +353,20 @@ export interface NewSessionRequest {
                 <ng-container *ngTemplateOutlet="row; context: { $implicit: s, owner: null }"></ng-container>
               }
 
+              <!--
+                孤儿归属兜底(2026-11-01):归属于某个已不存在的工作区。
+                不单独列出的话,这些会话在界面上彻底消失,用户会以为对话丢了。
+                点开仍可正常进入,移动工作区即可修正归属。
+              -->
+              @if (orphanSessions().length > 0) {
+                <p class="sect-hint orphan-hint">
+                  以下 {{ orphanSessions().length }} 条对话所属的工作区已不存在,可移动到其它工作区:
+                </p>
+                @for (s of orphanSessions(); track s.id) {
+                  <ng-container *ngTemplateOutlet="row; context: { $implicit: s, owner: null }"></ng-container>
+                }
+              }
+
               @if (dailyArchived().length > 0) {
                 <button
                   type="button"
@@ -1047,6 +1061,10 @@ export interface NewSessionRequest {
       .sect-hint.inset {
         padding-left: var(--sp-6);
       }
+      .orphan-hint {
+        padding-top: var(--sp-2);
+        border-top: 1px solid var(--edge);
+      }
       /*
         会话目录加载失败(2026-11-01):这是**错误态**,不是空态。
         用 danger 语义色边框区分,并提供重试入口 —— 不允许静默展示旧缓存。
@@ -1977,12 +1995,37 @@ export class SessionTreeComponent {
    * 若归属的工作区已被删除(其它标签页删的),按「日常对话」处理 ——
    * 否则会话会从界面上彻底消失(悬空引用)。
    */
+  /**
+   * 会话归属哪个分组。
+   *
+   * 🔴 2026-11-01:归属**以会话自身的 workspaceId 为准**,
+   *    不再因为「工作区列表里暂时没有这个 id」就把会话改判到日常对话。
+   *
+   * 原实现 `return this.workspaceSvc.byId(id) ? id : null` 的问题是加载时序:
+   * 工作区列表比会话到达得晚(或还没 load 完)时,会话会被静默归到日常,
+   * 用户看到的就是「在工作区里新建对话,结果跑到下面日常里去了」。
+   *
+   * 孤儿归属(工作区已被删除)由 `orphanSessions` 单独兜底展示,
+   * 不让它在渲染层消失。
+   */
   private ownerOf(s: ChatSession): string | null {
-    const id = s.workspaceId ?? null;
-    if (!id) return null;
-    return this.workspaceSvc.byId(id) ? id : null;
+    return s.workspaceId ?? null;
   }
 
+  /**
+   * 归属指向了不存在工作区的会话(孤儿)。
+   *
+   * 正常不应出现:删除工作区时会把其下会话退回日常(`WorkspaceService.remove`)。
+   * 但历史数据 / 云端归属与本地工作区不同步时会碰到 —— 这些会话**必须可见**,
+   * 否则用户会以为对话丢了。
+   */
+  protected readonly orphanSessions = computed(() => {
+    const known = new Set(this.workspaces().map((w) => w.id));
+    return this.sessionsSvc
+      .all()
+      .filter((s) => !s.archived && !!s.workspaceId && !known.has(s.workspaceId));
+  });
+
   /** 某归属下的活跃会话(按更新时间倒序由 service 保证) */
   private activeList(owner: string | null): ChatSession[] {
     return this.sessionsSvc.all().filter((s) => !s.archived && this.ownerOf(s) === owner);

+ 2 - 0
src/app/core/api/fmode-api.ts

@@ -76,6 +76,8 @@ export abstract class FmodeApi {
     threadRoot: string;
     title?: string;
     archived?: boolean;
+    /** 归属工作区;`''` = 移回日常对话(2026-11-01 起上云) */
+    workspaceId?: string;
   }): Promise<void>;
 
   /** 软删会话(isDeleted=true;再次收到消息可 revive) */

+ 2 - 0
src/app/core/api/mock-api.ts

@@ -170,6 +170,7 @@ export class MockApi extends FmodeApi {
     threadRoot: string;
     title?: string;
     archived?: boolean;
+    workspaceId?: string;
   }): Promise<void> {
     await this.delay();
     for (const [key, t] of this.mockThreads) {
@@ -179,6 +180,7 @@ export class MockApi extends FmodeApi {
         title: input.title ?? t.title,
         titleLocked: input.title != null ? true : t.titleLocked,
         archived: input.archived ?? t.archived,
+        workspaceId: input.workspaceId ?? t.workspaceId,
       });
     }
   }

+ 10 - 0
src/app/core/api/models.ts

@@ -603,6 +603,8 @@ export interface ChatThreadRecord {
   lastMessageAt: number;
   archived: boolean;
   lastPreview: string;
+  /** 归属工作区 id(`''` / 缺省 = 日常对话);换设备后仍能还原层级 */
+  workspaceId?: string;
 }
 
 /** upsert 提交项(不含服务端生成字段) */
@@ -615,6 +617,14 @@ export interface ChatThreadUpsert {
   /** 毫秒时间戳 */
   lastMessageAt?: number;
   lastPreview?: string;
+  /**
+   * 归属的工作区 id(`null` / 缺省 = 日常对话)。
+   *
+   * 🔴 必须上云(2026-11-01):此前只存本地 localStorage,导致两个问题:
+   *    ① 每次云端重建列表时归属靠本地缓存「捡」,捡不到就掉回日常对话;
+   *    ② 换浏览器/清缓存后归属永久丢失,用户以为「工作区里的对话没了」。
+   */
+  workspaceId?: string | null;
   /** 重新出现时撤销软删 */
   revive?: boolean;
 }

+ 2 - 0
src/app/core/api/parse-api.ts

@@ -275,6 +275,8 @@ export class ParseApi extends FmodeApi {
     threadRoot: string;
     title?: string;
     archived?: boolean;
+    /** 归属工作区;`''` = 移回日常对话(2026-11-01 起上云,换设备可见) */
+    workspaceId?: string;
   }): Promise<void> {
     const d = await this.callFunction<{ code: number; mess?: string }>(FN.chatThreads, {
       op: 'patch',

+ 64 - 0
src/app/core/chat-session.service.spec.ts

@@ -242,6 +242,70 @@ describe('ChatSessionService', () => {
     });
   });
 
+  describe('工作区归属(2026-11-01 上云)', () => {
+    const ROOM = '!room1:fmode.cn';
+    const AGENT = 'agent-a';
+    const WS = 'ws-1';
+
+    beforeEach(() => localStorage.clear());
+
+    /*
+     * 🔴 回归:用户反馈「在工作区里新建对话,结果跑到下面日常对话里去了」。
+     *
+     * 根因:归属只存本地,`hydrateFromCloud` 重建列表时靠本地缓存「捡」,
+     * 捡不到就回落 null(日常)。现在云端是权威来源。
+     */
+    it('🔴 云端返回的 workspaceId 必须覆盖本地为空的归属', async () => {
+      const svc2 = withApiStub({
+        fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) =>
+          o?.includeDeleted
+            ? []
+            : [
+                { objectId: 'x', agentId: AGENT, roomId: ROOM, threadRoot: '$w', title: 't', titleLocked: false, messageCount: 2, lastMessageAt: 9, archived: false, lastPreview: '', workspaceId: WS },
+              ],
+        upsertChatThreads: async () => undefined,
+      });
+      await svc2.hydrateFromCloud(AGENT);
+      expect(svc2.byId('$w')?.workspaceId).withContext('归属丢了会掉进日常对话').toBe(WS);
+    });
+
+    it('moveTo 会把归属推到云端', async () => {
+      const patch = jasmine.createSpy('patch').and.returnValue(Promise.resolve());
+      const svc2 = withApiStub({
+        fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) =>
+          o?.includeDeleted
+            ? []
+            : [
+                { objectId: 'x', agentId: AGENT, roomId: ROOM, threadRoot: '$w', title: 't', titleLocked: false, messageCount: 2, lastMessageAt: 9, archived: false, lastPreview: '' },
+              ],
+        upsertChatThreads: async () => undefined,
+        patchChatThread: patch,
+      });
+      await svc2.hydrateFromCloud(AGENT);
+      svc2.moveTo('$w', WS);
+      expect(patch).toHaveBeenCalledWith({ threadRoot: '$w', workspaceId: WS });
+      expect(svc2.byId('$w')?.workspaceId).toBe(WS);
+    });
+
+    it('移回日常对话时云端收到空字符串(不是不传)', async () => {
+      const patch = jasmine.createSpy('patch').and.returnValue(Promise.resolve());
+      const svc2 = withApiStub({
+        fetchChatThreads: async (_a: string, o?: { includeDeleted?: boolean }) =>
+          o?.includeDeleted
+            ? []
+            : [
+                { objectId: 'x', agentId: AGENT, roomId: ROOM, threadRoot: '$w', title: 't', titleLocked: false, messageCount: 2, lastMessageAt: 9, archived: false, lastPreview: '', workspaceId: WS },
+              ],
+        upsertChatThreads: async () => undefined,
+        patchChatThread: patch,
+      });
+      await svc2.hydrateFromCloud(AGENT);
+      svc2.moveTo('$w', null);
+      expect(patch).toHaveBeenCalledWith({ threadRoot: '$w', workspaceId: '' });
+      expect(svc2.byId('$w')?.workspaceId).toBeNull();
+    });
+  });
+
   describe('云端会话目录(2026-11-01 单向同步)', () => {
     const ROOM = '!room1:fmode.cn';
     const AGENT = 'agent-a';

+ 21 - 1
src/app/core/chat-session.service.ts

@@ -253,7 +253,15 @@ export class ChatSessionService {
         archived: r.archived,
         messageCount: r.messageCount,
         lastPreview: r.lastPreview || local?.lastPreview,
-        workspaceId: local?.workspaceId ?? null,
+        /*
+         * 工作区归属(2026-11-01 改为云端为准)。
+         *
+         * 🔴 此前只从本地缓存捡(`local?.workspaceId`)—— 云端重建列表时
+         *    本地没这条(换设备/清缓存/刚新建还没落盘)就掉回「日常对话」,
+         *    用户看到的就是「新建到工作区却跑到日常里」。
+         *    现在云端有值就用云端;云端为空才回落到本地(兼容旧数据)。
+         */
+        workspaceId: r.workspaceId || local?.workspaceId || null,
       });
     }
 
@@ -453,8 +461,18 @@ export class ChatSessionService {
    * 第 3 轮(2026-10-30)接入:`workspaceId` 由 `WorkspaceService` 的工作区 id 提供,
    * 归属只存本地 —— 删除工作区时由调用方把会话退回日常,不会留下悬空引用。
    */
+  /**
+   * 把会话移动到某个工作区(`null` = 日常对话)。
+   *
+   * 🔴 2026-11-01 起**同步到云端**:此前只写本地 localStorage,
+   *    导致换设备/清缓存后归属丢失,且云端重建列表时会掉回日常对话。
+   *    云端用 `''` 表示日常对话(空字符串是合法值,不能用真值判断)。
+   */
   moveTo(id: string, workspaceId: string | null): void {
     this.update(id, { workspaceId });
+    void this.trySync(() =>
+      this.api!.patchChatThread({ threadRoot: id, workspaceId: workspaceId ?? '' }),
+    );
   }
 
   /** 某工作区下的会话数(不含归档) */
@@ -593,6 +611,8 @@ function toUpsert(s: ChatSession): ChatThreadUpsert {
     messageCount: s.messageCount ?? 0,
     lastMessageAt: s.updatedAt || s.createdAt,
     lastPreview: s.lastPreview,
+    // 归属随 upsert 一起上云('' = 日常对话),否则换设备后层级丢失
+    workspaceId: s.workspaceId ?? '',
   };
 }
 

+ 27 - 23
src/app/desktop/pages/chat/chat.ts

@@ -423,10 +423,16 @@ interface ConversationRow {
                   }
                 }
                 <!--
-                  对方正在输入:已移到消息流**下方固定槽位**(见 .typing-hold)。
-                  放在流里会把内容顶高一截(出现/消失各跳一次),
-                  而且它出现在视口外时用户根本看不到(截图实测)。
+                  对方正在输入(2026-11-01 用户要求:放在**下一条消息的位置**)。
+                  即消息流末尾、紧贴最后一条消息,而不是漂浮在输入框上方。
+                  只在有内容时才渲染 —— 它出现在视口外时不可见,所以下面
+                  的 stickToBottom 要跟着它一起滚到底。
                 -->
+                @if (chat.peerTyping()) {
+                  <div class="typing-row" aria-live="polite">
+                    <app-typing-indicator [name]="chat.agent()?.name ?? '对方'" />
+                  </div>
+                }
               }
             }
           </div>
@@ -436,16 +442,10 @@ interface ConversationRow {
             卡片式输入框才不会被拉到整列宽度(宽屏下单行输入框横跨 1200px 很怪)。
           -->
           <!--
-            正在输入槽位(2026-10-30):固定高度、不参与消息流布局。
-            有内容时显示指示器,没有时留空 —— 出现/消失不改变任何高度。
+            正在输入指示器已移入消息流末尾(2026-11-01 用户要求):
+            它应该出现在「下一条消息」的位置,而不是漂浮在输入框上方。
+            见 .typing-row。
           -->
-          @if (chat.agentId()) {
-            <div class="typing-hold" aria-live="polite">
-              @if (chat.peerTyping()) {
-                <app-typing-indicator [name]="chat.agent()?.name ?? '对方'" />
-              }
-            </div>
-          }
 
           <!--
             🔴 没有选中会话时不显示输入区(2026-11-01 用户要求)。
@@ -1290,13 +1290,15 @@ interface ConversationRow {
         padding: var(--sp-2) 0;
       }
       /* 输入区与消息流同宽:宽屏下居中,窄屏贴边留 16px */
-      .typing-hold {
-        flex-shrink: 0;
-        /* 固定高度:指示器出现/消失都不改变布局 */
-        height: 24px;
+      /*
+        正在输入指示器:作为消息流的**最后一行**出现(2026-11-01)。
+        与消息气泡同样的居中限宽,让它看起来就是「即将到来的这一条」,
+        而不是浮在输入框上方的独立提示。
+      */
+      .typing-row {
         display: flex;
-        align-items: center;
-        padding: 0 max(var(--sp-4), calc((100% - 768px) / 2));
+        align-items: flex-start;
+        margin-bottom: var(--sp-4);
       }
 
       .composer-hold {
@@ -2378,14 +2380,16 @@ export class DkChatPage {
     });
 
     /*
-     * 正在输入指示器出现/消失时也要贴底(2026-10-30 用户反馈)。
+     * 正在输入指示器出现时贴底(2026-11-01:已移入消息流末尾)。
      *
-     * 🔴 它挂在消息列表**之后**,出现时会把列表撑高一截;
-     *    上面那个 effect 只监听 messages,指示器变化不会触发 ——
-     *    表现就是"发送后加载动画在视口下方,看不见"(截图实测)。
+     * 🔴 它现在是消息流的最后一行,出现会让流的高度增加一截。
+     *    用 rAF + 再等一帧:effect 执行时 DOM 还没插入,
+     *    立即 scrollToBottom 会把「指示器还没占位」的高度当作终点,
+     *    结果它出现在视口下方看不见(截图实测)。
      */
     effect(() => {
-      if (this.chat.peerTyping()) this.stickToBottom();
+      if (!this.chat.peerTyping()) return;
+      requestAnimationFrame(() => requestAnimationFrame(() => this.stickToBottom()));
     });
 
     // 窄窗默认收起右栏(<1150)