feat(client): increment forked session titles
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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 的插件恢复后重新注册。
|
||||
|
||||
|
||||
@@ -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) },
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user