Просмотр исходного кода

fix(client): fixture rename via requireSession, unchanged-title pin gesture

The fixture route shares the requireSession guard, reads the accepted seq off
the appended event, and gains the error-arm spec the workspace.rename case
set as precedent; WorkspaceBrowser drops the unchanged-title block — confirming
the current automatic title IS the pin gesture — and both touched client
READMEs document the new dialog and the unary-settle rule bilingually.
imccyu 2 месяцев назад
Родитель
Сommit
acd9716634

+ 5 - 11
packages/client/connection/src/client/fixture.ts

@@ -944,20 +944,14 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
         return ok(request, { sessionId: created.sessionId })
       },
       rename: (request) => {
+        const missing = requireSession(request)
+        if (missing !== undefined) return missing
         const { sessionId, title } = request.payload
-        const source = summaryOf(sessionId)
-        if (source === undefined) {
-          return err(request, {
-            code: 'session-not-found',
-            message: `no session ${sessionId}`,
-            details: { sessionId },
-          })
-        }
         const normalized = title.trim().replace(/\s+/g, ' ')
         if (normalized.length === 0) {
           return err(request, {
             code: 'title-invalid',
-            message: `rename rejected for session ${sessionId}: empty title`,
+            message: 'session title must contain visible characters',
             details: { sessionId },
           })
         }
@@ -967,8 +961,8 @@ export function createFixtureApi(options: FixtureOptions = {}): ApiProxy {
           type: 'session/title',
           data: { title: normalized, messageSeqs: [], source: { kind: 'user' } },
         })
-        const log = logOf(sessionId)
-        return ok(request, { title: normalized, seq: log.length - 1 })
+        const appended = logOf(sessionId).at(-1) as SessionEvent
+        return ok(request, { title: normalized, seq: appended.seq })
       },
       history: async (request) => {
         const log = logs.get(request.payload.sessionId) ?? []

+ 41 - 0
packages/client/connection/tests/fixture.spec.ts

@@ -464,6 +464,47 @@ describe('createFixtureApi', () => {
     expect(seen.map(f => f.type)).toEqual(['host/workspace-changed', 'host/workspace-changed'])
   })
 
+  it('session.rename covers not-found, blank title, and the accepted append + title frame', async () => {
+    const api = createFixtureApi()
+    const abort = new AbortController()
+    const framesPromise = (async () => {
+      const frames: MuxFrame[] = []
+      for await (const envelope of api.events.mux(req({}), abort.signal)) {
+        frames.push(envelope.payload)
+        if (frames.some(f => f.type === 'session/projection' && f.key === 'title' && f.value === '重命名')) abort.abort()
+      }
+      return frames
+    })()
+    await new Promise(resolve => setTimeout(resolve, 10))
+
+    const missing = await api.sessions.rename(req({ sessionId: sid('fx-void'), title: 'x' }))
+    expect(missing.result).toMatchObject({ ok: false, error: { code: 'session-not-found', details: { sessionId: 'fx-void' } } })
+
+    const blank = await api.sessions.rename(req({ sessionId: sid('fx-alpha'), title: '   ' }))
+    expect(blank.result).toMatchObject({ ok: false, error: { code: 'title-invalid', details: { sessionId: 'fx-alpha' } } })
+
+    const renamed = await api.sessions.rename(req({ sessionId: sid('fx-alpha'), title: '  重命名  ' }))
+    if (!renamed.result.ok) throw new Error('rename failed')
+    expect(renamed.result.value.title).toBe('重命名')
+    const acceptedSeq = renamed.result.value.seq
+    // The response seq addresses the appended title event (the client plane
+    // has no session/title in its event union — titles ride the projection —
+    // so the event is located by seq and its payload checked structurally).
+    const history = await api.sessions.history(req({ sessionId: sid('fx-alpha'), maxMessages: 100 }))
+    if (!history.result.ok) throw new Error('history failed')
+    const appended = history.result.value.events.find(entry => entry.event.seq === acceptedSeq)
+    expect(appended?.event).toMatchObject({
+      type: 'session/title',
+      data: { title: '重命名', messageSeqs: [], source: { kind: 'user' } },
+    })
+    // Beyond the subscribe-time baseline replay, the append emitted exactly
+    // one title projection frame carrying the new value at the response seq.
+    const frames = await framesPromise
+    const titleFrames = frames.filter(f => f.type === 'session/projection' && f.key === 'title' && f.sessionId === sid('fx-alpha') && f.value === '重命名')
+    expect(titleFrames).toHaveLength(1)
+    expect(titleFrames[0]).toMatchObject({ seq: acceptedSeq })
+  })
+
   it('workspace.insertSessionBefore moves, appends, no-ops, and rejects invalid ids', async () => {
     const api = createFixtureApi()
     const wsid = 'fx-ws-fixture' as WorkspaceId

+ 2 - 2
packages/client/runtime/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/runtime/README.md
-README.md: 25eb60e2c95059ae918669c9f5169b6b8e9c6816
-README.zh.md: e3085f91750503aeaffda41d86c40c62943b4ba9
+README.md: eeeb813d6d0ddddf9c5c718a9ede65b3e222c220
+README.zh.md: 0aba4674d2deb2cfc93712becf1626a0db4241b2

+ 1 - 1
packages/client/runtime/README.md

@@ -22,7 +22,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and
 
 ## Session title projection
 
-`SessionManager` retains the latest validated `session/title` control snapshot independently of list and session-instance arrival. Newer event seqs replace older snapshots, title timestamps contribute to list recency, and a subscription baseline discards any retained title beyond its `lastSeq` before the optional folded title arrives. Explicit session removal also clears the retained title. The client-facing `SessionSummary.title` is therefore only the actual durable title; `displayTitle` is always present and falls back through the cwd basename and session id. A cold persisted session keeps that fallback until opening or resuming it causes the host to fold and project its log-backed title.
+`SessionManager` retains the latest validated `session/title` control snapshot independently of list and session-instance arrival. Newer event seqs replace older snapshots, title timestamps contribute to list recency, and a subscription baseline discards any retained title beyond its `lastSeq` before the optional folded title arrives. Explicit session removal also clears the retained title. The client-facing `SessionSummary.title` is therefore only the actual durable title; `displayTitle` is always present and falls back through the cwd basename and session id. A cold persisted session keeps that fallback until opening or resuming it causes the host to fold and project its log-backed title. `ISession.rename` settles the `title` projection cell directly from the unary response's `{title, seq}` under the same higher-seq-wins rule — the list row and every `useProjection('title')` reader update ahead of the push frame, whose later replay of the same seq is a no-op.
 
 ## Session model selection
 

+ 1 - 1
packages/client/runtime/README.zh.md

@@ -22,7 +22,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸
 
 ## Session 标题投影
 
-`SessionManager` 独立于列表和 Session 实例到达情况,保留最近一次通过验证的 `session/title` 控制快照。seq 更新的事件会替换旧快照,标题时间戳计入列表新近程度;订阅基线会先丢弃 seq 超过其 `lastSeq` 的任何已保留标题,再接收可选的折叠标题。显式移除 Session 也会清除已保留标题。因此,面向客户端的 `SessionSummary.title` 只包含真实的持久标题;`displayTitle` 始终存在,并依次回退到 cwd basename 和 Session id。冷启动的持久会话会保持该回退值,直到打开或恢复会话,促使主机折叠并投影日志支持的标题。
+`SessionManager` 独立于列表和 Session 实例到达情况,保留最近一次通过验证的 `session/title` 控制快照。seq 更新的事件会替换旧快照,标题时间戳计入列表新近程度;订阅基线会先丢弃 seq 超过其 `lastSeq` 的任何已保留标题,再接收可选的折叠标题。显式移除 Session 也会清除已保留标题。因此,面向客户端的 `SessionSummary.title` 只包含真实的持久标题;`displayTitle` 始终存在,并依次回退到 cwd basename 和 Session id。冷启动的持久会话会保持该回退值,直到打开或恢复会话,促使主机折叠并投影日志支持的标题。`ISession.rename` 用 unary 响应中的 `{title, seq}` 直接结算 `title` 投影格,遵循同一 seq 高者胜规则——列表行和所有 `useProjection('title')` 读者在推送帧到达前即更新;推送帧随后重放同一 seq 时为无操作。
 
 ## 会话模型选择
 

+ 2 - 2
packages/client/ui-workspace/README.i18n.yaml

@@ -2,5 +2,5 @@
 # side as of the last confirmed-consistent state. Both languages carry equal authority;
 # after editing either side, bring the other along and re-record with:
 #   pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md
-README.md: 8acf819121b46512d38b39ff858bb2bf797cfe96
-README.zh.md: e97d93f7e38d00af91b43f0df9fb0e3b17ae8ed5
+README.md: a1b58f4abe0925be3b426d10344777e46caa9ba0
+README.zh.md: a472507bc45549c8feb55a75d294cbd7b3138cc5

Разница между файлами не показана из-за своего большого размера
+ 0 - 1
packages/client/ui-workspace/README.md


+ 2 - 2
packages/client/ui-workspace/README.zh.md

@@ -4,7 +4,7 @@
 
 共享 Workspace 选择器插件。`WorkspaceBrowser` 注册到侧边栏的 `sidebar.workspaces` slot,`WorkspacePicker` 注册到页面局部 Session Intent 主视觉区的 `conversation.hero.workspace` slot,因此两个表层使用同一菜单和创建流程。
 
-该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。每个注册各自声明一个**目录流子洞**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **打开本地文件夹…** 操作仅在本表层的洞被占用时渲染(每次菜单渲染读取占用状态;洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。
+该选择器通过全局 `useWorkspaces` hook 列出真实的 Host Workspace 实体。选择 Workspace 会调用 slot owner 的 `onPick` 回调,重新定位前端 Session 对象。每个注册各自声明一个**目录流子洞**(`single` kind:`conversation.hero.workspace.directoryFlow`/`sidebar.workspaces.directoryFlow`),由组合的选择器包 client half 填入其选取交互——今天是 [`-native`](../../host/directory-picker-native/README.md) 后端的无渲染 OS 选择器驱动,`-browse` 组合下则是应用内浏览对话框。平铺显示的 **打开本地文件夹…** 操作仅在本表层的洞被占用时渲染(每次菜单渲染读取占用状态;洞为空意味着该组合没有选目录能力——seam 文档化的无流程默认行为)。本包持有触发与接纳:占用者经洞的 owner 会话(`open`/`busy`/`onPicked`/`onCancel`/`onError`)每次打开上报一个所选路径,owner 通过对象层接纳它,并等待 Workspace 列表投影刷新后才选中已提交的 Workspace;取消操作不会显示提示,错误落入可重试的文件夹对话框,其 **重新选择** 会重新打开流程。**创建新工作区** 操作保留名称对话框,并禁用列表中已有的名称,而 Host 对并发或非 UI 调用方仍具有最终决定权。运行时 Session 与 Workspace 服务负责物化。Workspace 行内的 Delete 操作会打开确认框,说明保留边界、阻止重复提交,并在失败时保持打开;成功后,该分组会被移除,其 Session 则留在 Ungrouped 下。Session 行内的 Rename 操作打开同款浏览器持有的对话框,并以该行的显示标题预填:客户端不设名称冲突规则(host 负责规范化,可能以 `title-invalid` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。
 
 两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。
 
@@ -18,5 +18,5 @@
 
 ## 已知限制与暂缓事项
 
-- **没有 Session 删除控件**:现有 Session 菜单行仍仅提供视觉效果;删除 Workspace 注册记录不会删除 Session。
+- **没有 Session 删除与 fork 控件**:Session 菜单的 Fork 与 Delete 行仍仅提供视觉效果(Rename 已接线);删除 Workspace 注册记录不会删除 Session。
 - **原生文件夹选择依赖本地 Host 载体**:在 `-native` 组合下,仅使用 fixture(测试前置数据)的部署或远程浏览器部署无法打开本地操作系统对话框;模态框会显示平台故障,并允许重试。可远程的选取是 `-browse` 组合的应用内流程。

+ 3 - 2
packages/client/ui-workspace/src/client/WorkspaceBrowser.tsx

@@ -316,13 +316,14 @@ export function WorkspaceBrowser({
 
   // Session rename dialog (same browser-owned pattern as workspace rename;
   // sessions have no client-side name-conflict rule — the host normalizes).
+  // Unlike workspace rename, an unchanged title is NOT blocked: confirming
+  // the current automatic title is the gesture that pins it.
   const [sessionRenameTarget, setSessionRenameTarget] = useState<{ sessionId: SessionNode['id']; currentTitle: string } | null>(null)
   const [sessionRenameDraft, setSessionRenameDraft] = useState('')
   const [sessionRenaming, setSessionRenaming] = useState(false)
   const [sessionRenameError, setSessionRenameError] = useState<string | null>(null)
   const sessionRenameTrimmed = sessionRenameDraft.trim()
-  const sessionRenameBlocked = sessionRenaming || sessionRenameTrimmed === ''
-    || sessionRenameTarget === null || sessionRenameTrimmed === sessionRenameTarget.currentTitle
+  const sessionRenameBlocked = sessionRenaming || sessionRenameTrimmed === '' || sessionRenameTarget === null
   const closeSessionRename = () => {
     if (sessionRenaming) return
     setSessionRenameTarget(null)

Некоторые файлы не были показаны из-за большого количества измененных файлов