From 3b370549a1a5d526cdf65a969c45120cb0f7f473 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 30 Jul 2026 21:16:02 +0800 Subject: [PATCH] feat(client): increment forked session titles --- ...6-07-27-web-session-fork-actions.i18n.yaml | 4 +- .../2026-07-27-web-session-fork-actions.md | 6 +- .../2026-07-27-web-session-fork-actions.zh.md | 6 +- apps/web/tests/message-actions.e2e.ts | 4 +- .../message-actions/fork.expected.md | 4 +- packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 2 +- packages/client/runtime/README.zh.md | 2 +- .../runtime/src/client/contract/sessions.ts | 8 ++- .../runtime/src/client/sessions/service.ts | 47 ++++++++++++-- .../runtime/tests/sessions-service.spec.ts | 65 ++++++++++++++++++- packages/client/test-runtime/src/sessions.ts | 4 +- .../test-runtime/tests/runtime.spec.tsx | 6 +- .../client/ui-conversation/README.i18n.yaml | 4 +- packages/client/ui-conversation/README.md | 2 +- packages/client/ui-conversation/README.zh.md | 2 +- .../ui-conversation/src/client/apply.ts | 4 +- .../tests/apply-inject.spec.tsx | 7 ++ packages/client/ui-workspace/README.i18n.yaml | 4 +- packages/client/ui-workspace/README.md | 2 +- packages/client/ui-workspace/README.zh.md | 2 +- .../client/ui-workspace/src/client/index.ts | 4 +- .../client/ui-workspace/tests/apply.spec.ts | 18 ++++- 23 files changed, 167 insertions(+), 44 deletions(-) diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.i18n.yaml index 976dcc1d1d..e4b5cd7778 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.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 .agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.md -2026-07-27-web-session-fork-actions.md: 90d2f9a8085cf1f1d2656bb442eee1e4116bb91a -2026-07-27-web-session-fork-actions.zh.md: f0642d17fdb6030dea16ff1440c730c605fec13d +2026-07-27-web-session-fork-actions.md: b5dc7e820de069a68b38ed87c7d29ffbdb4867bc +2026-07-27-web-session-fork-actions.zh.md: 774cd74d69eb02d43ca01c8ec7ba1cf94c24b1dc diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.md b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.md index 90d2f9a808..b5dc7e820d 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.md @@ -10,7 +10,7 @@ The Session store already provides a fork primitive that creates a child session ## Decision -The Web Session-row menu and message IconActions share the client runtime's `sessions.fork` action. A Session row passes `{ sessionId }`, so it forks at the source session's last completed turn; a user message or settled assistant content message passes `{ sessionId, atSeq: node.seq }`, so it forks at the turn containing that event. On success, the client first adds the child session to its local list and then opens it; on failure, it leaves the source session and current selection unchanged. +The Web Session-row menu and message IconActions share the client runtime's `sessions.fork` action. A Session row passes `{ sessionId, increaseTitle: true }`, so it forks at the source session's last completed turn; a user message or settled assistant content message passes `{ sessionId, atSeq: node.seq, increaseTitle: true }`, so it forks at the turn containing that event. Only the client consumes `increaseTitle`: after adding the child session to its local list, the client increments a trailing `(N)` or `(N)` in the source session's persisted title without changing bracket style, appends ` (1)` to an unnumbered title, and skips the rename when no persisted title exists; the Host fork request still contains only `sessionId` and the optional `atSeq`. The caller opens the child only after the rename succeeds; a fork or rename failure leaves the source session and current selection unchanged, while a child created before a rename failure remains in the list. `forkAt(seq)` touches the session service only in ui-conversation's apply injection layer; message components report only the event `seq`. Session rows likewise initiate the operation only through ui-workspace's injected callback. Neither presentation package owns session mutation state or duplicates the host's boundary evaluation. @@ -28,6 +28,6 @@ Session lineage is not projected into a list hierarchy. WorkSpace mode displays ## Consequences -Users can create forks from Session rows, user messages, or settled assistant content messages; all three entry points ultimately use the same runtime/host operation. Message entry points preserve the exact event boundary, while the list entry point preserves the “latest completed turn” shortcut. Every fork child immediately appears as an ordinary peer row, so the list no longer needs session expansion state, recursive nodes, or twist controls. +Users can create forks from Session rows, user messages, or settled assistant content messages; all three entry points ultimately use the same runtime/host operation. Message entry points preserve the exact event boundary, while the list entry point preserves the “latest completed turn” shortcut. Successive fork titles increment through `(1)`, `(2)`, and so on instead of repeatedly appending `(1)`; titles with fullwidth parentheses retain that style. Every fork child immediately appears as an ordinary peer row, so the list no longer needs session expansion state, recursive nodes, or twist controls. -Fork failures stay silent and preserve the source selection, preventing a derivation action from disrupting the current reading position; this tradeoff also means the UI does not yet expose a failure reason or retry entry point. Package tests separately pin the two message `seq` paths and the peer-list derivation; `apps/web/tests/message-actions.e2e.ts` exercises assistant-message branching and Session-row menu branching through the assembled application. +Fork and child-rename failures stay silent and preserve the source selection, preventing a derivation action from disrupting the current reading position; this tradeoff also means the UI does not yet expose a failure reason or retry entry point. Package tests separately pin the two message `seq` paths, title increments, and the peer-list derivation; `apps/web/tests/message-actions.e2e.ts` exercises assistant-message branching and Session-row menu branching through the assembled application. diff --git a/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.zh.md b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.zh.md index f0642d17fd..774cd74d69 100644 --- a/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-web-session-fork-actions.zh.md @@ -10,7 +10,7 @@ Session store 已提供按完成轮前缀创建子会话的 fork 原语,但 We ## Decision -Web 的 session 行菜单与消息 IconActions 共用 client runtime 的 `sessions.fork` 操作。Session 行传 `{ sessionId }`,因此在源会话最后一个已完成轮次处分支;用户消息与已定稿 assistant 内容消息传 `{ sessionId, atSeq: node.seq }`,因此在包含该事件的轮次处分支。成功后 client 先把子会话纳入本地列表,再打开子会话;失败时保持源会话与当前选择不变。 +Web 的 session 行菜单与消息 IconActions 共用 client runtime 的 `sessions.fork` 操作。Session 行传 `{ sessionId, increaseTitle: true }`,因此在源会话最后一个已完成轮次处分支;用户消息与已定稿 assistant 内容消息传 `{ sessionId, atSeq: node.seq, increaseTitle: true }`,因此在包含该事件的轮次处分支。`increaseTitle` 只由 client 消费:子会话进入本地列表后,client 把源会话持久化标题尾部的 `(N)` 或 `(N)` 递增并保留括号样式,无编号时追加 ` (1)`,没有持久化标题时不改名;Host fork 请求仍只有 `sessionId` 与可选的 `atSeq`。改名成功后调用方才打开子会话;fork 或改名失败时保持源会话与当前选择不变,改名失败时已创建的子会话仍留在列表中。 `forkAt(seq)` 只在 ui-conversation 的 apply 注入层接触 session 服务,消息组件只回传事件 `seq`。Session 行同理只通过 ui-workspace 的注入回调发起操作;两个呈现包都不持有 session mutation 状态,也不复制 host 的边界求值。 @@ -28,6 +28,6 @@ Session lineage 不投影成列表层级。WorkSpace 模式按 `WorkspaceView.se ## Consequences -用户可从 session 行、用户消息或已定稿 assistant 内容消息创建分支,三处最终走同一个 runtime/host 操作;消息点位保留精确事件边界,列表点位保留「最新完成轮」快捷语义。所有 fork 子会话立即作为普通同级行出现,列表不再需要 session 展开状态、递归节点或 twist 控件。 +用户可从 session 行、用户消息或已定稿 assistant 内容消息创建分支,三处最终走同一个 runtime/host 操作;消息点位保留精确事件边界,列表点位保留「最新完成轮」快捷语义。连续 fork 的标题按 `(1)`、`(2)` 递增,而不是重复追加 `(1)`;全角括号标题保持全角样式。所有 fork 子会话立即作为普通同级行出现,列表不再需要 session 展开状态、递归节点或 twist 控件。 -Fork 失败保持静默并保留源选择,避免一个派生操作破坏当前阅读位置;该取舍也意味着 UI 暂不提供失败原因或重试入口。Package tests 分别钉住两种消息 `seq` 与同级列表派生,`apps/web/tests/message-actions.e2e.ts` 通过装配后的应用执行 assistant 消息分支与 session 行菜单分支。 +Fork 与子会话改名失败都保持静默并保留源选择,避免一个派生操作破坏当前阅读位置;该取舍也意味着 UI 暂不提供失败原因或重试入口。Package tests 分别钉住两种消息 `seq`、标题递增与同级列表派生,`apps/web/tests/message-actions.e2e.ts` 通过装配后的应用执行 assistant 消息分支与 session 行菜单分支。 diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index 9b02c5bc73..650c089d32 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/apps/web/tests/message-actions.e2e.ts @@ -106,9 +106,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => { ).toBe(1) // The row action owns a distinct ui-workspace injection from the message // action above, so exercise both through the loaded app before capture. - const sourceRow = page.locator('[role="treeitem"]') - .filter({ has: page.locator('button[aria-label^="Session actions for "]') }) - .last() + const sourceRow = page.locator('[role="treeitem"][aria-selected="true"]') const rowBox = await sourceRow.boundingBox() if (rowBox === null) throw new Error('fork source row has no layout box') const actionButton = sourceRow.locator('button[aria-label^="Session actions for "]') diff --git a/apps/web/tests/snapshots/message-actions/fork.expected.md b/apps/web/tests/snapshots/message-actions/fork.expected.md index 8e07129bfd..d20754711d 100644 --- a/apps/web/tests/snapshots/message-actions/fork.expected.md +++ b/apps/web/tests/snapshots/message-actions/fork.expected.md @@ -2,6 +2,6 @@ - treeitem "Ungrouped 3 sessions" [expanded]: - img - text: Ungrouped 3 sessions - - treeitem "Use the read tool twice now" [selected] - - treeitem "Use the read tool twice now" + - treeitem "Use the read tool twice (2) now" [selected] + - treeitem "Use the read tool twice (1) now" - treeitem "Use the read tool twice 1min" diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 4ef6fd488c..e82da5cc62 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/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: db6388dccee6e065066cc70e141073d99822ce0f -README.zh.md: e1b6902d0d30972bd613553081e0f0e8d5da2a05 +README.md: b85deeec92fd4da1f342b5536757692f594853a5 +README.zh.md: 2dbb66c56ad5687fb299fe030d0abfe451004a62 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index db6388dcce..b85deeec92 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -30,7 +30,7 @@ SlotsService gives the renderer separate bare observables for `useSessions` and ## Session forking -`ISessions.fork({sessionId, atSeq?})` resolves only after the child summary is locally addressable, carrying source lineage and cwd with `blank: false`; callers choose whether to open it. A `workspace-attach-failed` response still identifies a child already published by the Host, so `SessionManager` reconciles that partial success before `SessionForkError` reaches the caller instead of making a retry create a duplicate child. +`ISessions.fork({sessionId, atSeq?, increaseTitle?})` resolves only after the child summary is locally addressable, carrying source lineage and cwd with `blank: false`; callers choose whether to open it. With `increaseTitle: true`, the client renames the child from the source session's persisted title: a trailing `(N)` or `(N)` is incremented without changing bracket style, while any other title gets ` (1)` appended; the rename is skipped when the source has no persisted title, and a rename failure rejects the promise but leaves the created child in place. This option is not sent in the Host fork request. A `workspace-attach-failed` response still identifies a child already published by the Host, so `SessionManager` reconciles that partial success before `SessionForkError` reaches the caller instead of making a retry create a duplicate child. ## Session model selection diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index e1b6902d0d..2dbb66c56a 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -30,7 +30,7 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 ## 会话 fork -`ISessions.fork({sessionId, atSeq?})` 只在子会话摘要已能在本地寻址后才完成;该摘要携带源会话的谱系和 cwd,且 `blank: false`,由调用方决定是否打开。即使响应为 `workspace-attach-failed`,其中仍会标识 Host 已发布的子会话,因此 `SessionManager` 会先将这一部分成功对账,再让 `SessionForkError` 到达调用方,避免重试创建重复的子会话。 +`ISessions.fork({sessionId, atSeq?, increaseTitle?})` 只在子会话摘要已能在本地寻址后才完成;该摘要携带源会话的谱系和 cwd,且 `blank: false`,由调用方决定是否打开。`increaseTitle: true` 会在 client 端把源会话的持久化标题改名到子会话:尾部 `(N)` 或 `(N)` 递增并保留括号样式,其余标题追加 ` (1)`;源会话没有持久化标题时跳过改名,改名失败时拒绝 promise 但保留已创建的子会话。该选项不会进入 Host fork 请求。即使响应为 `workspace-attach-failed`,其中仍会标识 Host 已发布的子会话,因此 `SessionManager` 会先将这一部分成功对账,再让 `SessionForkError` 到达调用方,避免重试创建重复的子会话。 ## 会话模型选择 diff --git a/packages/client/runtime/src/client/contract/sessions.ts b/packages/client/runtime/src/client/contract/sessions.ts index 1b59c3c27c..280bc602eb 100644 --- a/packages/client/runtime/src/client/contract/sessions.ts +++ b/packages/client/runtime/src/client/contract/sessions.ts @@ -32,12 +32,14 @@ export interface ISessions { /** * Fork a session from a completed-turn prefix of the source; on resolution * the child is in the list store and `open()` can target it. - * @param opts - source session id and the optional event seq anchoring the + * @param opts - source session id, the optional event seq anchoring the * cut (the boundary is the first turn/end at or after it; an in-log - * anchor in an open turn is unavailable rather than clipped backward). + * anchor in an open turn is unavailable rather than clipped backward), + * and whether to increment an inherited durable title before resolving. * @returns the child session id. + * @throws when the fork fails, or when a requested child-title rename fails after creation. */ - fork(opts: { sessionId: SessionId; atSeq?: number }): Promise + fork(opts: { sessionId: SessionId; atSeq?: number; increaseTitle?: boolean }): Promise /** * Register a per-session standard-props provider (hooks become `use` * selector hooks on the render side; props spread verbatim). diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 741aba4c24..342fc3b62e 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -137,6 +137,24 @@ function displayTitleOf(title: string | undefined, cwd: string | undefined, id: return id } +/** + * Increment a trailing fork number while preserving its half-width or + * full-width parentheses; an unnumbered title starts with ` (1)`. + * @param title - source session's durable title. + * @returns the title assigned to the fork child. + */ +function increasedForkTitle(title: string): string { + const ascii = /^(.*?)\((\d+)\)$/u.exec(title) + if (ascii?.[1] !== undefined && ascii[2] !== undefined) { + return `${ascii[1]}(${BigInt(ascii[2]) + 1n})` + } + const fullWidth = /^(.*?)((\d+))$/u.exec(title) + if (fullWidth?.[1] !== undefined && fullWidth[2] !== undefined) { + return `${fullWidth[1]}(${BigInt(fullWidth[2]) + 1n})` + } + return `${title} (1)` +} + interface ScopeRecord { fiber: Fiber ctx: Context @@ -337,17 +355,36 @@ export class SessionsService implements ISessions { * Fork a session from a completed-turn prefix of the source (same * synchronous-addressability guarantee as {@link SessionsService.create}: * on resolution the child is in the list store and open() can target it). - * @param opts - source session id and the optional event seq anchoring the + * @param opts - source session id, the optional event seq anchoring the * cut (the boundary is the first turn/end at or after it; an in-log - * anchor in an open turn is unavailable rather than clipped backward). + * anchor in an open turn is unavailable rather than clipped backward), + * and whether to increment an inherited durable title before resolving. * @returns the child session id. * @throws {SessionForkError} with the source id. + * @throws {Error} when a requested child-title rename fails after creation. */ - async fork(opts: { sessionId: SessionId; atSeq?: number }): Promise { - const result = await this.manager.fork(opts) + async fork(opts: { + sessionId: SessionId + atSeq?: number + increaseTitle?: boolean + }): Promise { + const sourceTitle = opts.increaseTitle + ? this.list.getSnapshot().byId[opts.sessionId]?.title + : undefined + const result = await this.manager.fork({ + sessionId: opts.sessionId, + ...(opts.atSeq === undefined ? {} : { atSeq: opts.atSeq }), + }) if (!result.ok) throw new SessionForkError(result.error, opts.sessionId) this.projectList() - return result.value.sessionId + const childId = result.value.sessionId + if (sourceTitle !== undefined) { + const child = this.binding(childId)?.session + if (child === undefined) throw new Error(`fork child "${childId}" is not locally addressable`) + const renamed = await child.rename(increasedForkTitle(sourceTitle)) + if (!renamed.ok) throw new Error(`fork child rename failed: ${renamed.error.code}: ${renamed.error.message}`) + } + return childId } /** diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index 09076bace7..8687f208f0 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/packages/client/runtime/tests/sessions-service.spec.ts @@ -10,7 +10,7 @@ import { Context } from 'cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import { SessionCreateError, SessionsService, scopeOf } from '../src/client/sessions/service.ts' -import { FakeApiClient, deferred, ok } from './fake-api.ts' +import { FakeApiClient, deferred, err, ok } from './fake-api.ts' const sid = (s: string): SessionId => s as SessionId @@ -399,6 +399,69 @@ describe('create', () => { }) }) +describe('fork', () => { + it.each([ + ['Roadmap', 'Roadmap (1)'], + ['Roadmap (1)', 'Roadmap (2)'], + ['计划(1)', '计划(2)'], + ['计划 (9)', '计划 (10)'], + ])('increments the durable title %j after the child is published', async (sourceTitle, childTitle) => { + const b = bench() + b.svc.handleMuxEnvelope({ + rpcId: 'source-title' as never, + payload: { type: 'session/projection', sessionId: sid('source'), key: 'title', value: sourceTitle, seq: 2 } as never, + }) + await feedList(b, [{ id: 'source', cwd: '/work' }]) + b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') })) + b.api.onRename = (payload) => { + const { title } = payload as { title: string } + return Promise.resolve(ok({ title, seq: 3 })) + } + + await expect(b.svc.fork({ + sessionId: sid('source'), atSeq: 7, increaseTitle: true, + })).resolves.toBe('child') + + expect(b.api.callsOf('session.fork')).toEqual([{ sessionId: 'source', atSeq: 7 }]) + expect(b.api.callsOf('session.rename')).toEqual([{ sessionId: 'child', title: childTitle }]) + await Promise.resolve() + expect(b.svc.list.getSnapshot().byId[sid('child')]).toMatchObject({ + title: childTitle, + displayTitle: childTitle, + parentId: 'source', + }) + }) + + it('does not rename without the title policy or a durable source title', async () => { + const b = bench() + await feedList(b, [{ id: 'source', cwd: '/work' }]) + b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') })) + await expect(b.svc.fork({ sessionId: sid('source'), increaseTitle: true })).resolves.toBe('child') + expect(b.api.callsOf('session.rename')).toEqual([]) + + b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child-2') })) + await expect(b.svc.fork({ sessionId: sid('source') })).resolves.toBe('child-2') + expect(b.api.callsOf('session.rename')).toEqual([]) + }) + + it('rejects when child rename fails while keeping the published child addressable', async () => { + const b = bench() + b.svc.handleMuxEnvelope({ + rpcId: 'source-title' as never, + payload: { type: 'session/projection', sessionId: sid('source'), key: 'title', value: 'Roadmap', seq: 2 } as never, + }) + await feedList(b, [{ id: 'source' }]) + b.api.onFork = () => Promise.resolve(ok({ sessionId: sid('child') })) + b.api.onRename = () => Promise.resolve(err({ + code: 'title-invalid', message: 'rejected', details: { sessionId: sid('child') }, + })) + + await expect(b.svc.fork({ sessionId: sid('source'), increaseTitle: true })) + .rejects.toThrow('fork child rename failed: title-invalid: rejected') + expect(b.svc.binding(sid('child'))).toBeDefined() + }) +}) + describe('scope lifecycle rides the list mirror (entity parity: no client-side pre-birth)', () => { it('a session-added frame births the row (blank) and makes the scope resolvable; removal prunes it', async () => { const b = bench() diff --git a/packages/client/test-runtime/src/sessions.ts b/packages/client/test-runtime/src/sessions.ts index f585772b38..f40f9a14a5 100644 --- a/packages/client/test-runtime/src/sessions.ts +++ b/packages/client/test-runtime/src/sessions.ts @@ -395,10 +395,10 @@ export class TestSessions implements ISessions { /** * Recorded fork stub: no child materializes (benches asserting the full * fork flow drive the production service; this face only proves the call). - * @param opts - source session id and optional cut anchor. + * @param opts - source session id, optional cut anchor, and client title policy. * @returns the source id (no child record is created). */ - fork(opts: { sessionId: SessionId; atSeq?: number }): Promise { + fork(opts: { sessionId: SessionId; atSeq?: number; increaseTitle?: boolean }): Promise { this.calls.push({ method: 'fork', args: [opts] }) return Promise.resolve(opts.sessionId) } diff --git a/packages/client/test-runtime/tests/runtime.spec.tsx b/packages/client/test-runtime/tests/runtime.spec.tsx index eddc6efe76..62bab2845a 100644 --- a/packages/client/test-runtime/tests/runtime.spec.tsx +++ b/packages/client/test-runtime/tests/runtime.spec.tsx @@ -211,11 +211,13 @@ describe('sessions', () => { runtime.sessions.clear() await runtime.flush() expect(runtime.sessions.list.getSnapshot().current).toBeUndefined() - await expect(runtime.sessions.fork({ sessionId: 's1' as SessionId, atSeq: 7 })).resolves.toBe('s1') + await expect(runtime.sessions.fork({ + sessionId: 's1' as SessionId, atSeq: 7, increaseTitle: true, + })).resolves.toBe('s1') expect(runtime.sessions.calls).toEqual([ { method: 'open', args: ['s1'] }, { method: 'clear', args: [] }, - { method: 'fork', args: [{ sessionId: 's1', atSeq: 7 }] }, + { method: 'fork', args: [{ sessionId: 's1', atSeq: 7, increaseTitle: true }] }, ]) await runtime.dispose() }) diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index d5d1dcfbfb..086c63f2ad 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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-conversation/README.md -README.md: 02834be207e45bb1ff65b75a0c75a884db3be315 -README.zh.md: 84f9d79660207a68305d75ac0e7deaecdedf00d6 +README.md: 95f336b9bce82cbf6daf0fdb016f52de9b13c8d5 +README.zh.md: 948da361400a1207fcc582b3d874ab7fb98f27de diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 02834be207..95f336b9bc 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -38,7 +38,7 @@ None; this package neither assembles nor sends a provider request. - **Stats-line durations cover the in-window flow only** — LLM and tool wall times fold the snapshot's assistant `timing` and tool call/result pairs, so nodes outside the loaded event window (older history) are not counted. - **Details panel is the minimal form and currently has no entry point** — selected call args/result raw display; the Input/Output/Metadata switch, Prev/Next stepping, and See-in-trajectory deep link are deferred. Tool rows stopped being details-panel click targets and nothing replaced that gesture, so `ChatViewInjected.openDetails` is implemented but uncalled and the panel (including its terminal card) is unreachable in the assembled application; its rendering stays covered by mounting it with a selection directly. -- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / branch / clock) ships under text output only; branch forks through the turn containing that message and opens the child, while a fork failure leaves the source selected. +- **Assistant per-message paging is a reserved slot** — drawn in the design, not implemented. The finalized content IconActions row (copy / branch / clock) ships under text output only; branch forks through the turn containing that message, increments the inherited title on the client, and then opens the child, while a fork or rename failure leaves the source selected. - **The sparkle icon for the others tool row is a hand-drawn approximation** — the design glyph's vector geometry is not exportable locally; promotion into ui-primitives waits on an exact export. - **The approval panel's "Always allow this type" is deferred** — durable grants need a grant-storage design; only allow-once/reject answer today. - **TodoPanel truncates long item text to one ellipsized line** — the figma strip has no wrap or expand affordance; full text is not readable inline. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index 84f9d79660..948da36140 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -38,7 +38,7 @@ todo 两个面就是在该形状上的两个注册项,都是普通注册方插 - **统计行的耗时只覆盖窗口内消息流**:LLM(大语言模型)与工具墙钟时间由快照的 assistant `timing` 与工具 call/result 配对折算,落在已加载事件窗口之外的节点(更早的历史)不计入。 - **详情面板是最小形态,且当前没有入口**:以原始形式显示已选择调用的参数/结果;Input/Output/Metadata 切换、Prev/Next 步进与 See-in-trajectory 深链接暂缓实现。工具行已不再是详情面板的点击目标,且没有任何手势接替它,因此 `ChatViewInjected.openDetails` 虽已实现却无人调用,该面板(含其终端卡片)在组装后的应用中不可达;其渲染仍由直接以选中态挂载它来覆盖。 -- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/分支/时钟)只挂在 text 输出下;分支会 fork 到包含该消息的轮次末尾并打开子会话,而 fork 失败时源会话保持选中。 +- **assistant 逐消息分页是预留 slot**:设计中已有图稿,尚未实现。已定稿的内容 IconActions 行(复制/分支/时钟)只挂在 text 输出下;分支会 fork 到包含该消息的轮次末尾,在 client 端递增继承标题后打开子会话,而 fork 或改名失败时源会话保持选中。 - **others 工具行的闪光图标是手绘近似版本**:无法在本地导出设计字形的矢量几何;等到存在精确导出后再将其提升到 ui-primitives。 - **审批面板的「始终允许此类」暂缓**:持久授权需要授权存储设计;今天只能回答允许一次/拒绝。 - **TodoPanel 将过长条目截成单行省略号**:figma 条没有换行或展开入口,完整文本无法在行内读完。 diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index ca0159ce3d..9c2008f092 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -263,10 +263,10 @@ export function apply(ctx: Context): void { }, loadOlder: () => { void scoped.loadOlder() }, forkAt: (seq) => { - sessions.fork({ sessionId, atSeq: seq }) + sessions.fork({ sessionId, atSeq: seq, increaseTitle: true }) .then((childId) => { sessions.open(childId) }) .catch(() => { - // Fork failure keeps the source view untouched (composer-stop posture). + // Fork or child-rename failure keeps the source view untouched. }) }, } diff --git a/packages/client/ui-conversation/tests/apply-inject.spec.tsx b/packages/client/ui-conversation/tests/apply-inject.spec.tsx index 87ec0dfde7..c9dd4c37f9 100644 --- a/packages/client/ui-conversation/tests/apply-inject.spec.tsx +++ b/packages/client/ui-conversation/tests/apply-inject.spec.tsx @@ -122,6 +122,13 @@ describe('conversation slot inject surface', () => { const chatView = b.chatViewSurface(ROOT) chatView.injected.loadOlder() expect(b.sessionFake.loadOlder).toHaveBeenCalledTimes(1) + chatView.injected.forkAt(17) + await vi.waitFor(() => { + expect(b.runtime.sessions.calls).toContainEqual({ method: 'open', args: [ROOT] }) + }) + expect(b.runtime.sessions.calls).toContainEqual({ + method: 'fork', args: [{ sessionId: ROOT, atSeq: 17, increaseTitle: true }], + }) await b.runtime.dispose() }) diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 1223fcc0f9..a50419efb6 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/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: 2a4e544250756db50b19bc101c19ac79505a3d6c -README.zh.md: 7ae2b0e196a98d1375e9073146bb8119ea8cbe90 +README.md: 860c24b8a25a1e9968261f586c16163579131a1c +README.zh.md: 5a8e88051fc6f4fd46c5f2f6dcdc185eb4559ac6 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 2a4e544250..860c24b8a2 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -6,7 +6,7 @@ Shared Workspace picker plugin. `WorkspaceBrowser` is registered into the sideba The picker lists real Host Workspace entities through the global `useWorkspaces` hook. Selecting a Workspace invokes the slot owner's `onPick` callback to retarget the frontend Session object. Each registration declares a **directory-flow child hole** (`single` kind: `conversation.hero.workspace.directoryFlow` / `sidebar.workspaces.directoryFlow`) that the composed picker package's client half fills with its picking interaction — the [`-native`](../../host/directory-picker-native/README.md) backend's renderless OS-chooser driver today, an in-app browsing dialog under a `-browse` composition. The flat **Open local folder...** action renders only while the surface's hole is occupied (occupancy read per menu render; an empty hole means the composition has no picking affordance — the seam's documented no-flow default). This package owns the trigger and the adoption: the occupant reports one picked path per open through the hole's owner conversation (`open`/`busy`/`onPicked`/`onCancel`/`onError`), and the owner adopts it through the object layer, selecting the committed Workspace only after its list projection has refreshed; cancellation is silent, and errors land in the retryable folder dialog whose **Choose again** reopens the flow. **Create a new workspace** retains the name dialog and disables names already present in that list, while the Host remains authoritative for concurrent or non-UI callers. The runtime Session and Workspace services own materialization. The Workspace row's Delete action opens a confirmation that states the retention boundary, blocks duplicate submission, and keeps failures open; success removes the group while its Sessions remain under Ungrouped. The Session row's Rename action opens the same browser-owned dialog pattern prefilled with the row's display title: no client-side conflict rule exists (the host normalizes and may reject with `title-invalid`, rendered in the dialog alert), and confirming an unchanged title is deliberately allowed — it pins the current automatic title against regeneration. -The Session row's Fork action forks at the source's last completed turn and opens the child; the source and child always appear as peer rows within a workspace group, with lineage retained only as session data. A failure leaves the current selection unchanged. +The Session row's Fork action forks at the source's last completed turn, increments the inherited persisted title on the client, and then opens the child; a trailing ASCII or fullwidth parenthesized number is incremented in the same style, while an unnumbered title gets ` (1)` appended. The source and child always appear as peer rows within a workspace group, with lineage retained only as session data. A fork or rename failure leaves the current selection unchanged; after a rename failure, the created child remains in the list. Both target slots are declared by other plugins, so `apply` registers through declaration-aware deferral and re-registers after a declaring slot is restored. diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index 7ae2b0e196..5a8e88051f 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -6,7 +6,7 @@ 该选择器通过全局 `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` 拒绝,错误渲染在对话框告警区);确认未修改的标题是有意允许的——这正是把当前自动标题钉住、不再被重新生成覆盖的手势。 -Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork 并打开子会话;源会话与子会话在 workspace 组内始终作为同级行展示,谱系只保留为 session 数据。失败不会改变当前选中项。 +Session 行内的 Fork 操作在源会话最后一个已完成轮次处 fork,在 client 端递增继承的持久化标题后再打开子会话;尾部半角或全角括号编号会原样式递增,无编号标题追加 ` (1)`。源会话与子会话在 workspace 组内始终作为同级行展示,谱系只保留为 session 数据。Fork 或改名失败都不会改变当前选中项,改名失败时已创建的子会话仍会留在列表中。 两个目标 slot 都由其他插件声明,因此 `apply` 通过声明感知的延迟机制完成注册,并在声明该 slot 的插件恢复后重新注册。 diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index f7ced8016a..79a275eafa 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -60,10 +60,10 @@ export function apply(ctx: ClientContext): void { if (!result.ok) throw new Error(result.error.message) }, forkSession: (sessionId) => { - ctx.sessions.fork({ sessionId }) + ctx.sessions.fork({ sessionId, increaseTitle: true }) .then((childId) => { ctx.sessions.open(childId) }) .catch(() => { - // Fork failure keeps the list untouched (composer-stop posture). + // Fork or child-rename failure keeps the current selection. }) }, renameWorkspace: async (workspaceId, title) => { await ctx.workspaces.rename(workspaceId, title) }, diff --git a/packages/client/ui-workspace/tests/apply.spec.ts b/packages/client/ui-workspace/tests/apply.spec.ts index 817416663e..257f98a1d6 100644 --- a/packages/client/ui-workspace/tests/apply.spec.ts +++ b/packages/client/ui-workspace/tests/apply.spec.ts @@ -19,11 +19,17 @@ async function bench() { const insertSessionBefore = vi.fn(async () => ({})) const open = vi.fn() const clear = vi.fn() + const renameSession = vi.fn(async (title: string) => ({ ok: true, value: { title, seq: 1 } })) + const binding = vi.fn(() => ({ session: { rename: renameSession } })) + const fork = vi.fn(async () => 'forked' as never) ctx.provide('workspaces', { create, startSession, rename, insertSessionBefore, } as never) - ctx.provide('sessions', { open, clear } as never) - return { ctx, slots: ctx.get('slots') as SlotsService, create, startSession, rename, insertSessionBefore, open, clear } + ctx.provide('sessions', { open, clear, binding, fork } as never) + return { + ctx, slots: ctx.get('slots') as SlotsService, create, startSession, rename, + insertSessionBefore, open, clear, renameSession, binding, fork, + } } type HoleName = 'sidebar.workspaces' | 'conversation.hero.workspace' | 'conversation.empty.workspace' @@ -66,6 +72,14 @@ describe('ui-workspace apply', () => { expect(b.startSession).toHaveBeenLastCalledWith(undefined) browser.open('session' as never) expect(b.open).toHaveBeenCalledWith('session') + await browser.renameSession('session' as never, 'renamed session') + expect(b.binding).toHaveBeenCalledWith('session') + expect(b.renameSession).toHaveBeenCalledWith('renamed session') + browser.forkSession('session' as never) + await vi.waitFor(() => { + expect(b.open).toHaveBeenCalledWith('forked') + }) + expect(b.fork).toHaveBeenCalledWith({ sessionId: 'session', increaseTitle: true }) await browser.renameWorkspace('ws' as never, 'renamed') expect(b.rename).toHaveBeenCalledWith('ws', 'renamed') await browser.insertSessionBefore('ws' as never, 's1' as never, 's2' as never)