fix(ui-workspace): name every branch and open it on request

`increaseTitle` renames the child, not the source, and whoever may fork a Session also holds `session/rename` on the child it creates; the source's access answer therefore never decides the child's title. Remove the source-rename probe, the listed-branch wait, and its refresh-failure notice: `selected` opens the child immediately and `listed` leaves the selection alone. Update the public READMEs and the client API catalog.
This commit is contained in:
mektpoy committed 2026-10-22 21:44:25 +08:00
1 parent 0b72a81411
commit 58785b975e
13 files changed
+62 -327

No files matched your search

+2 -2
View File
@@ -39,8 +39,8 @@
en: 97a4d98a72c34263
zh: 9df3d197d9986a54
/deepseek-ai-dsh-client-ui-chat/write-actions:
en: a9d59501699d0e57
zh: c4764d2b776c562a
en: e18fccec7be6b240
zh: c40747bf36c443b2
/deepseek-ai-dsh-client-ui-chat/model-experience:
en: c17b4dd43a61c569
zh: f7414309810c3a08
+1 -1
View File
@@ -178,7 +178,7 @@ Active-Turn highlighting is approximate: `readVisibleTurn` binary-searches the f
<a id="write-actions"></a>
## Write actions
The Turn Tail branch action reads the Session-scope `useAccess('session/fork')` Hook and disappears while the calling Peer's access snapshot excludes that endpoint; the view always receives the `forkAt` handler. A branch names its child only while the snapshot includes `session/rename` for the source Session; otherwise the child keeps the Host's title and opens once the Session list carries it. The Session tree's branch verb shares the rule for naming and opening a branch. These answers never replace Host authorization.
The Turn Tail branch action reads the Session-scope `useAccess('session/fork')` Hook and disappears while the calling Peer's access snapshot excludes that endpoint; the view always receives the `forkAt` handler. A branch always names its child, because whoever may fork also holds `session/rename` on the child it creates, and it opens that child immediately. The Session tree's branch verb uses the same naming rule but leaves the selection alone. These answers never replace Host authorization.
-----
+1 -1
View File
@@ -178,7 +178,7 @@ Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点,并且
<a id="write-actions"></a>
## 写操作
轮次末尾的分支操作读取会话作用域的 `useAccess('session/fork')` 钩子,调用方 Peer 的访问快照不含该端点时不显示;视图始终收到 `forkAt` 处理函数。只有当快照对源 Session 含 `session/rename` 时,分支才为子会话命名;否则子会话保留 Host 给的标题,并在会话列表出现它之后打开。会话树的分支动词共用同一套命名与打开分支的规则。这些界面判断不替代 Host 授权。
轮次末尾的分支操作读取会话作用域的 `useAccess('session/fork')` 钩子,调用方 Peer 的访问快照不含该端点时不显示;视图始终收到 `forkAt` 处理函数。分支总会为子会话命名,因为能 fork 的页面同时拥有对其创建的子会话的 `session/rename`,并会立即打开该子会话。会话树的分支动词使用同一命名规则,但不改变当前选中。这些界面判断不替代 Host 授权。
-----
+2 -2
View File
@@ -297,8 +297,8 @@ export function apply(ctx: Context): void {
read: () => chatScrollPositions.get(sessionId) ?? null,
},
forkAt: (seq) => {
// The Workspace navigation owns the branch policy: whether this page may name the child and
// when the child becomes presentable. This page supplies the branch point and its analytics.
// The Workspace navigation owns the branch policy: it names the child and opens it
// immediately for `selected`. This page supplies the branch point and its analytics.
const turn = [...chat.getSnapshot().timeline.turns.values()].find(turn => turn.end?.seq === seq)
const messageId = turn?.data.get('turn-tail')?.closing?.finalNode.messageId
void ctx.uiWorkspace.forkSession(sessionId, (childId) => {
@@ -33,8 +33,8 @@
en: 9fb88260380bf093
zh: 3bace2b96ecd4a82
/deepseek-ai-dsh-client-ui-workspace/use-this-package/active-schedule-markers:
en: 8eac6eb8dfc3664c
zh: 2a39e34a35093890
en: 0701b99279062282
zh: 29255dc290eb177b
/deepseek-ai-dsh-client-ui-workspace/understand-the-implementation:
en: 96a37e74555a36b7
zh: d03941523509a5cf
+2 -2
View File
@@ -73,9 +73,9 @@ Grouped and flat Session rows show a clock mark when the Session has active sche
-----
`ctx.uiWorkspace.openSession(target)` synchronously replaces the owned `mainView` reference and returns the main area to Conversation without waiting for `reference.ready`, so history loading renders inside the selected Session view. The target may be a known Session id or a durable direct-parent subagent address; an explicit address does not require a preloaded parent catalog. `openWorkspace(id, beforeOpen?, options?)` opens a Workspace blank only if no later navigation has superseded the request; New Session opens the same blank as Workspace reopening, so it returns to that blank's unsent draft. `forkSession(id, onCreated?, options?)` creates the child and applies `ForkSessionOptions`: `atSeq` sets the branch boundary, `allowMigration` carries the caller's migration permission, and `present` selects the landing. A page whose access snapshot includes `session/rename` may name the child — `selected` opens it immediately while `listed` leaves the selection alone. A page without that answer cannot name the child: the child keeps the Host's title, one navigation begins for the branch, and the child opens once the Host lists its summary, so the source view stays until then. The optional synchronous preparation callback runs after the target is retained and only for a current Workspace request, so superseded requests do not move composer drafts. Navigation or owner disposal suppresses the late UI commit, not the underlying Session creation.
`ctx.uiWorkspace.openSession(target)` synchronously replaces the owned `mainView` reference and returns the main area to Conversation without waiting for `reference.ready`, so history loading renders inside the selected Session view. The target may be a known Session id or a durable direct-parent subagent address; an explicit address does not require a preloaded parent catalog. `openWorkspace(id, beforeOpen?, options?)` opens a Workspace blank only if no later navigation has superseded the request; New Session opens the same blank as Workspace reopening, so it returns to that blank's unsent draft. `forkSession(id, onCreated?, options?)` creates the child and applies `ForkSessionOptions`: `atSeq` sets the branch boundary, `allowMigration` carries the caller's migration permission, and `present` selects the landing. A branch always names its child: whoever may fork a Session also holds `session/rename` on the child it creates. `selected` opens the child immediately, while `listed` (the default) leaves the selection alone. The optional synchronous preparation callback runs after the target is retained and only for a current Workspace request, so superseded requests do not move composer drafts. Navigation or owner disposal suppresses the late UI commit, not the underlying Session creation.
Session and Workspace rows derive their affordances from the browser root's access Hooks: the global `useRemoteAccess('session/create')` gates every New Session entry, `useWorkspaceAccess` gates a Workspace's create, rename, delete, and reorder verbs, and `useSessionAccess` gates a Session row's rename, fork, drag, pin, archive, and unarchive verbs plus the search result's restore control. A refused verb is not rendered, while every Host request remains authoritative; `ctx.uiSession.access(sessionId, action)` answers the same Session question for JS callers outside render. Startup restoration retains its main reference without changing the selected panel or cancelling a later navigation. Archiving the main Session releases its reference and clears the main selection. Selection failure leaves a global panel visible. Session rows read `usePanelInfo` to suppress their selected appearance while a global panel is active; search and directory-picker focus alone do not leave that panel.
Session and Workspace rows derive their affordances from the browser root's access Hooks: the global `useRemoteAccess('session/create')` gates every New Session entry, `useWorkspaceAccess` gates a Workspace's create, rename, delete, and reorder verbs, and `useSessionAccess` gates a Session row's rename, fork, drag, pin, archive, and unarchive verbs plus the search result's restore control. A refused verb is not rendered, while every Host request remains authoritative. Startup restoration retains its main reference without changing the selected panel or cancelling a later navigation. Archiving the main Session releases its reference and clears the main selection. Selection failure leaves a global panel visible. Session rows read `usePanelInfo` to suppress their selected appearance while a global panel is active; search and directory-picker focus alone do not leave that panel.
Navigation and startup restoration obtain the selected Session's projections from its `follow`; neither issues a separate projection refresh for that Session or its parent, except that restoring a saved blank first reloads that Session's projections, because listed values are cached hints that can lag its log after an unclean Host exit.
+2 -2
View File
@@ -73,9 +73,9 @@ Session 行渲染运行时的实时 `pendingInteraction` 分类:审批显示**
-----
`ctx.uiWorkspace.openSession(target)` 会同步替换其拥有的 `mainView` reference,并让主区域返回 Conversation,而不等待 `reference.ready`,因此历史加载会显示在已经选中的 Session 视图内。目标可以是已知 Session id,也可以是持久的直接父子 subagent 地址;显式地址不要求预先加载 parent catalog。`openWorkspace(id, beforeOpen?, options?)` 仅在请求未被后续导航替代时打开工作区空白 Session;新会话与重新打开工作区打开同一个空白会话,因此会回到该空白会话中未发送的草稿。`forkSession(id, onCreated?, options?)` 创建子会话并应用 `ForkSessionOptions`:`atSeq` 指定分支点,`allowMigration` 携带调用方的迁移许可,`present` 选择落地方式。访问快照包含 `session/rename` 的页面可以为子会话命名——`selected` 立即打开子会话,`listed` 保持当前选中;没有该答案的页面无法为子会话命名:子会话保留 Host 给的标题,服务为分支开始一次导航,并在 Host 列出其摘要后打开,因此在此之前源视图保持不变。可选的同步准备回调在目标被 retain 后执行,并且仅对仍有效的 Workspace 请求执行,因此过期请求不会搬移 composer 草稿。后续导航或 owner 释放会阻止晚到的 UI 提交,但不取消底层 Session 创建。
`ctx.uiWorkspace.openSession(target)` 会同步替换其拥有的 `mainView` reference,并让主区域返回 Conversation,而不等待 `reference.ready`,因此历史加载会显示在已经选中的 Session 视图内。目标可以是已知 Session id,也可以是持久的直接父子 subagent 地址;显式地址不要求预先加载 parent catalog。`openWorkspace(id, beforeOpen?, options?)` 仅在请求未被后续导航替代时打开工作区空白 Session;新会话与重新打开工作区打开同一个空白会话,因此会回到该空白会话中未发送的草稿。`forkSession(id, onCreated?, options?)` 创建子会话并应用 `ForkSessionOptions`:`atSeq` 指定分支点,`allowMigration` 携带调用方的迁移许可,`present` 选择落地方式。分支总会为子会话命名:能 fork 的页面同时拥有对其创建的子会话的 `session/rename`。`selected` 立即打开子会话,`listed`(默认)保持当前选中。可选的同步准备回调在目标被 retain 后执行,并且仅对仍有效的 Workspace 请求执行,因此过期请求不会搬移 composer 草稿。后续导航或 owner 释放会阻止晚到的 UI 提交,但不取消底层 Session 创建。
Session 行与 Workspace 行的可用操作来自浏览器根的访问 Hook:全局 `useRemoteAccess('session/create')` 门控所有新建会话入口,`useWorkspaceAccess` 门控 Workspace 的新建、重命名、删除与排序动作,`useSessionAccess` 门控 Session 行的重命名、分叉、拖拽、置顶、归档与取消归档动作,以及搜索结果的恢复控件。被拒绝的动作不渲染,但每个发往 Host 的请求仍以其授权为准;`ctx.uiSession.access(sessionId, action)` 为渲染外的 JS 调用方回答同一个 Session 问题。启动恢复会 retain 主 reference,不改变已选面板,也不取消后续导航。归档主 Session 会释放其 reference 并清除主选择。选择失败时保留当前全局面板。Session 行读取 `usePanelInfo`,在全局面板活跃时不显示 Session 选中样式;仅把焦点移到搜索框或目录选择器不会离开该面板。
Session 行与 Workspace 行的可用操作来自浏览器根的访问 Hook:全局 `useRemoteAccess('session/create')` 门控所有新建会话入口,`useWorkspaceAccess` 门控 Workspace 的新建、重命名、删除与排序动作,`useSessionAccess` 门控 Session 行的重命名、分叉、拖拽、置顶、归档与取消归档动作,以及搜索结果的恢复控件。被拒绝的动作不渲染,但每个发往 Host 的请求仍以其授权为准。启动恢复会 retain 主 reference,不改变已选面板,也不取消后续导航。归档主 Session 会释放其 reference 并清除主选择。选择失败时保留当前全局面板。Session 行读取 `usePanelInfo`,在全局面板活跃时不显示 Session 选中样式;仅把焦点移到搜索框或目录选择器不会离开该面板。
导航和启动恢复通过所选 Session 的 `follow` 获取投影,不会另行刷新该 Session 或其父会话的投影;例外是恢复已保存的空白会话时先重新加载该 Session 的投影,因为列表中的值是缓存提示,Host 非正常退出后可能落后于其日志。
@@ -323,11 +323,6 @@ export type RowToast =
* a report can be searched by it — and any other failure's own message.
*/
| { kind: 'createFailed'; message: string }
/**
* A branch was created but the Session list refresh that would reveal it failed. `message` is the
* untranslated failure; the branch itself exists, so the copy names the failed refresh, not the fork.
*/
| { kind: 'branchListRefreshFailed'; message: string }
/** The notice on display; `seq` keys remounts so a repeated notice restarts its hold. */
export type RowToastState = RowToast & { seq: number }
@@ -92,7 +92,6 @@ export const zh = {
'toast.pinFailed': '置顶失败,请稍后重试',
'toast.unpinFailed': '取消置顶失败,请稍后重试',
'toast.createFailed': '新建会话失败:{message}',
'toast.branchListRefreshFailed': '分支已创建,但刷新会话列表失败:{message}',
'toast.forkRequiresOpen': '请先打开原会话完成迁移,再创建分支',
'toast.openForkSource': '打开原会话',
'sessions.count.one': '{n} 个会话',
@@ -219,7 +218,6 @@ export const en = {
'toast.pinFailed': 'Pin failed. Try again later.',
'toast.unpinFailed': 'Unpin failed. Try again later.',
'toast.createFailed': 'New session failed: {message}',
'toast.branchListRefreshFailed': 'Branch created, but refreshing the session list failed: {message}',
'toast.forkRequiresOpen': 'Open the original session to complete migration before creating a branch.',
'toast.openForkSource': 'Open original session',
'sessions.count.one': '{n} session',
@@ -343,7 +341,6 @@ export const ja = {
'toast.pinFailed': 'ピン留めに失敗しました。後でもう一度お試しください。',
'toast.unpinFailed': 'ピン留めの解除に失敗しました。後でもう一度お試しください。',
'toast.createFailed': '新しいセッションの作成に失敗しました: {message}',
'toast.branchListRefreshFailed': 'ブランチは作成されましたが、セッション一覧の更新に失敗しました: {message}',
'toast.forkRequiresOpen': 'ブランチを作成する前に、移行を完了するため元のセッションを開いてください。',
'toast.openForkSource': '元のセッションを開く',
'sessions.count.one': 'セッション {n} 件',
@@ -59,15 +59,13 @@ export interface UiWorkspace {
options?: Pick<StartSessionOptions, 'agentPreset'>,
): Promise<void>
/**
* Fork a Session and apply its branch presentation policy. Pages whose access snapshot includes
* renaming the source increment the child title; other pages leave the title to the Host and open
* the child only once its summary is listed. In the latter case the call begins one navigation for
* that listing, so a pending navigation may be superseded.
* Fork a Session and apply its branch presentation policy. The child always receives the
* incremented title: whoever may fork a Session also holds `session/rename` on the child it
* creates, so the source's own rename answer does not matter.
* @param sessionId - source Session.
* @param onCreated - observer after creation, before the optional child-title update.
* @param onCreated - observer after creation, before the child-title update.
* @param options - branch boundary, migration permission, and how the child is presented.
* @returns the child SessionId after creation and any permitted inherited-title increment; kept
* for upper layers that need the id while the listed-open wait is still pending.
* @returns the child SessionId after creation and inherited-title increment.
* @throws when the Host refuses or fails the fork; the caller keeps its current view and decides
* whether to report the failure.
*/
@@ -158,101 +156,15 @@ declare module '@deepseek-ai/cordis' {
}
}
/** What one branch fork asks of the Workspace navigation service. */
/** What one branch fork asks of the Workspace navigation service. Every fork names its child. */
export interface ForkSessionOptions extends Pick<Parameters<ISessions['fork']>[0], 'atSeq' | 'allowMigration'> {
/**
* How the caller presents the child, by whether the page may rename the source Session
* (`session/rename`) and by the caller's landing choice:
*
* | may rename `session/rename` | `selected` | `listed` (default) |
* | --- | --- | --- |
* | yes | selects the child immediately | leaves the selection alone |
* | no | reveals the child once the Host lists it | reveals the child once the Host lists it |
*
* The row-action path uses `listed`; a branch surface asks for `selected`.
* How the caller presents the child: `selected` opens it immediately, while `listed` (the
* default, the row-action path) leaves the selection alone.
*/
readonly present?: 'selected' | 'listed'
}
/** Where one wait for a listed branch reveals it, and where a failed list refresh is reported. */
interface ListedBranchNavigation {
/** Supersession signal of the page navigation this open belongs to. */
readonly signal: AbortSignal
/**
* Reveal one listed Session as the main selection.
* @param sessionId - Session the Host listed.
* @param signal - the navigation signal; a superseded navigation must not reveal.
*/
reveal(sessionId: SessionId, signal: AbortSignal): void
/**
* Report a failure to reveal a child the Host did list.
* @param error - the reveal rejection.
*/
fail(error: unknown): void
/**
* Report a failed refresh that may have kept the child out of the list. The fork itself succeeded,
* so this failure must not reuse the creation notice.
* @param error - the refresh rejection.
*/
refreshFail(error: unknown): void
}
/**
* Open a created Session once the Host lists it.
*
* A page that may not rename the Session it branched presents the child only once the Host's own
* summary of it is on the list; opening sooner assembles a view of a branch it cannot describe. The
* wait ends on a list change, a completed refresh, supersession, or disposal; a failed refresh is
* reported through the navigation's failure seat and leaves the selection as it was.
* @param navigation - Supersession signal plus the reveal and failure seats.
* @param sessions - Session controller holding the list and refresh.
* @param childId - Session the Host created.
*/
function openListedBranch(
navigation: ListedBranchNavigation,
sessions: ISessions,
childId: SessionId,
): void {
const { signal } = navigation
if (signal.aborted) return
let stop = (): void => {}
let settled = false
const finish = (): void => {
settled = true
stop()
signal.removeEventListener('abort', finish)
}
const open = (): void => {
if (settled || signal.aborted || sessions.list.getSnapshot().byId[childId] === undefined) return
finish()
try {
navigation.reveal(childId, signal)
} catch (error: unknown) {
// oxlint-disable-next-line typescript/no-unnecessary-condition -- reveal may synchronously abort navigation before throwing.
if (!signal.aborted) navigation.fail(error)
}
}
stop = sessions.list.subscribe(open)
signal.addEventListener('abort', finish, { once: true })
// Registration may synchronously publish the row; always recheck after the disposer is owned.
// oxlint-disable-next-line typescript/no-unnecessary-condition -- subscribe may synchronously call open and finish.
if (settled) {
stop()
signal.removeEventListener('abort', finish)
return
}
open()
// oxlint-disable-next-line typescript/no-unnecessary-condition -- open synchronously settles a listed child.
if (settled) return
void sessions.refresh().then(() => {
open()
finish()
}, (error: unknown) => {
if (!settled && !signal.aborted) navigation.refreshFail(error)
finish()
})
}
/** Structured directory failure exposed to directory UI consumers. */
export class DirectoryBrowseError extends Error {
override readonly name = 'DirectoryBrowseError'
@@ -427,30 +339,16 @@ class UiWorkspaceService extends Service implements UiWorkspace {
onCreated?: (childId: SessionId) => void,
options: ForkSessionOptions = {},
): Promise<SessionId> {
// Naming a branch renames the child. A page whose access snapshot excludes renaming the source
// Session leaves the name to the Host — asking for it would fail the fork it just made.
const namesBranch = this.ctx.uiSession.access(sessionId, 'session/rename')
// `increaseTitle` renames the child, not the source: whoever may fork a Session also holds
// `session/rename` on the Session it creates, so the source's own access answer does not matter.
const childId = await this.sessions.fork({
sessionId,
...options.atSeq === undefined ? {} : { atSeq: options.atSeq },
increaseTitle: namesBranch,
increaseTitle: true,
...onCreated === undefined ? {} : { onCreated },
...options.allowMigration === undefined ? {} : { allowMigration: options.allowMigration },
})
if (namesBranch) {
// A branch surface asks to land on the child its page could describe; the row action leaves the
// selection alone.
if (options.present === 'selected') this.openSession(childId)
return childId
}
// A page that may not rename the Session it branched presents the child only once the Host's own
// summary of it is on the list; opening sooner assembles a view of a branch it cannot describe.
openListedBranch({
signal: AbortSignal.any([this.ctx.layout.beginNavigation(), this.lifetime.signal]),
reveal: (listedId, signal) => { this.replaceMain(listedId, signal, 'reveal') },
fail: (error) => { this.notify({ kind: 'createFailed', message: creationFailureMessage(error) }) },
refreshFail: (error) => { this.notify({ kind: 'branchListRefreshFailed', message: creationFailureMessage(error) }) },
}, this.sessions, childId)
if (options.present === 'selected') this.openSession(childId)
return childId
}
@@ -71,14 +71,6 @@ export function registerToasts(
display: (notice, t) => ({ text: t('toast.createFailed', { message: notice.message }), tone: 'warning', holdMs: 6000 }),
}),
}, ToastEntry<PropsRuntime<'workspace.toast', 'createFailed'>>)
yield ctx.slots.register({
name: 'workspace.toast', key: 'branchListRefreshFailed', locale: 'workspace',
inject: (): ToastDisplayInjected<'branchListRefreshFailed'> => ({
display: (notice, t) => ({
text: t('toast.branchListRefreshFailed', { message: notice.message }), tone: 'warning', holdMs: 6000,
}),
}),
}, ToastEntry<PropsRuntime<'workspace.toast', 'branchListRefreshFailed'>>)
yield ctx.slots.register({
name: 'workspace.toast', key: 'forkRequiresOpen', locale: 'workspace',
inject: (): ToastDisplayInjected<'forkRequiresOpen'> => ({
@@ -4,7 +4,6 @@ import { describe, expect, it, onTestFinished, vi } from 'vitest'
import type {
ISessions, SessionListState, SessionReference, SessionSnapshot, SessionSummary,
} from '@deepseek-ai/dsh-api-session-controller/client'
import type { SessionProjectionValues } from '@deepseek-ai/dsh-api-session-controller/types'
import { SessionForkError } from '@deepseek-ai/dsh-api-session-controller/client'
import type { WorkspaceId, WorkspaceSnapshot, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
@@ -55,15 +54,6 @@ const workspaceState = (
items, archivedSessionIds, pinnedSessionIds, state: 'idle', phase: 'ready', error: null,
})
/**
* Branch facts the Host publishes for a child a reader page created. The `forkLineage` key belongs
* to the Liveshare package, which this program does not name, so the value is built through the open
* key space the wire allows.
*/
const branchFacts: SessionProjectionValues = {
forkLineage: { origin: 'fork', sourceSessionId: sid('session'), authoredByViewer: true, workspaceReadOnly: true },
}
const ALLOWED: HostObservable<boolean> = createSnapshotStore(true)
async function bench(access: 'fake' | 'static' = 'fake') {
@@ -132,7 +122,6 @@ async function bench(access: 'fake' | 'static' = 'fake') {
operation: (reference: SessionReference) => unknown,
) => await operation(retain(target)))
const fork = vi.fn<ISessions['fork']>(async () => sid('forked'))
const refresh = vi.fn(async () => undefined)
const pinSession = vi.fn(async () => undefined)
const unpinSession = vi.fn(async () => undefined)
// The Host snapshots the injected hooks derive from and a pin's order write
@@ -173,7 +162,6 @@ async function bench(access: 'fake' | 'static' = 'fake') {
create: vi.fn(async () => 'created' as never),
retain,
using,
refresh,
search,
searchResultLimit: 20,
binding,
@@ -196,7 +184,7 @@ async function bench(access: 'fake' | 'static' = 'fake') {
return {
ctx, slots: ctx.get('slots') as SlotRegistry, locale, create, rename, commands, accessSource, whenResourceAccess, stopGuard,
retain, using, selectPanel, search, renameSession, binding, fork, pickDirectory, pinSession, unpinSession,
workspacesSubscribe, refresh, setSummary, initializeDefault, history, scope, sessionOf,
workspacesSubscribe, setSummary, initializeDefault, history, scope, sessionOf,
setWorkspaces: (snapshot: WorkspaceSnapshot): void => { workspaceSnapshot = snapshot },
setSessions: (snapshot: SessionListState): void => { sessionSnapshot = snapshot },
}
@@ -361,7 +349,7 @@ describe('ui-workspace apply', () => {
expect(rowToastEntry.store).toBeUndefined()
expect(b.slots.entries('workspace.toast').map(row => row.options.key)).toEqual([
'sessionIdCopied', 'sessionIdCopyFailed', 'pinFailed', 'unpinFailed', 'archivedNotOpenable',
'defaultWorkspaceFailed', 'shortcutForkFailed', 'createFailed', 'branchListRefreshFailed', 'forkRequiresOpen',
'defaultWorkspaceFailed', 'shortcutForkFailed', 'createFailed', 'forkRequiresOpen',
'archived', 'stoppedAndArchived',
])
for (const registration of b.slots.entries('workspace.toast')) {
@@ -889,7 +877,7 @@ describe('ui-workspace apply', () => {
/**
* Answer as a standalone reader page's access snapshot does: it may branch the one Session the
* share grants and may not rename it.
* share grants and may not rename that source Session.
* @param b - Benchmark whose access source answers.
*/
function provideReaderAnswers(b: Awaited<ReturnType<typeof bench>>): void {
@@ -897,50 +885,52 @@ describe('ui-workspace apply', () => {
createSnapshotStore(endpoint === 'session/fork' && id === 'session'))
}
it('leaves a reader\'s branch unnamed and opens it once the Host lists it', async () => {
it('names a reader page\'s branch and opens it immediately when the caller selects it', async () => {
const b = await bench()
onTestFinished(() => b.ctx.fiber.dispose())
provideReaderAnswers(b)
b.refresh.mockImplementationOnce(() => new Promise<undefined>(() => {}))
declare(b.slots, 'sidebar.workspaces')
await b.ctx.plugin({ inject: [...inject], apply }).await()
const forkSession = (faceOf(entry(b.slots, MENU_ITEM, 'fork')) as ForkSessionInjected).forkSession
forkSession('session' as never)
// Naming a branch renames the child, a write a reader page does not hold: asking for it would
// fail the fork after the Host already made the child.
await vi.waitFor(() => {
expect(b.fork).toHaveBeenCalledWith({
sessionId: 'session', increaseTitle: false, allowMigration: false,
onCreated: expect.any(Function) as (childId: SessionId) => void,
})
})
// The child's own list item is what says the branch is this page's own, so the reader page waits
// for it rather than assembling a view of a child it cannot yet describe.
await vi.waitFor(() => { expect(b.refresh).toHaveBeenCalled() })
expect(b.retain).not.toHaveBeenCalledWith('forked', { source: 'mainView' })
b.setSummary('forked', { projectionValues: branchFacts })
await vi.waitFor(() => { expect(b.retain).toHaveBeenCalledWith('forked', { source: 'mainView' }) })
await b.ctx.uiWorkspace.forkSession(sid('session'), undefined, { present: 'selected' })
// The child inherits a title from the fork itself; the reader's answer for the source says nothing
// about the child it now owns.
expect(b.fork).toHaveBeenCalledExactlyOnceWith({ sessionId: sid('session'), increaseTitle: true })
expect(b.retain).toHaveBeenCalledExactlyOnceWith('forked', { source: 'mainView' })
expect(b.selectPanel).toHaveBeenCalledExactlyOnceWith(null)
})
it('forks the reader main Session by shortcut without requesting a title write and waits for its row', async () => {
it('leaves the selection alone when the caller presents the branch as listed', async () => {
const b = await bench()
onTestFinished(() => b.ctx.fiber.dispose())
provideReaderAnswers(b)
await b.ctx.plugin({ inject: [...inject], apply }).await()
b.ctx.uiWorkspace.openSession(sid('source'))
b.retain.mockClear()
b.selectPanel.mockClear()
await b.ctx.uiWorkspace.forkSession(sid('session'), undefined, { present: 'listed' })
expect(b.fork).toHaveBeenCalledExactlyOnceWith({ sessionId: sid('session'), increaseTitle: true })
expect(b.retain).not.toHaveBeenCalled()
expect(b.selectPanel).not.toHaveBeenCalled()
})
it('forks the reader main Session by shortcut with a child title and leaves the selection alone', async () => {
const b = await bench()
onTestFinished(() => b.ctx.fiber.dispose())
provideReaderAnswers(b)
b.setSessions(sessionState([{ ...summary('session', 1), retainedBy: { mainView: 1 } }]))
const refreshed = Promise.withResolvers<undefined>()
b.refresh.mockImplementationOnce(() => refreshed.promise)
onTestFinished(() => { refreshed.resolve(undefined) })
await b.ctx.plugin({ inject: [...inject], apply }).await()
const resolution = b.commands.get('session.fork')!.resolve({ region: 'page', modal: null, target: null })
if (resolution.status !== 'handled') throw new Error('the reader may branch its main Session')
resolution.run()
await vi.waitFor(() => { expect(b.refresh).toHaveBeenCalledOnce() })
expect(b.fork).toHaveBeenCalledExactlyOnceWith({ sessionId: sid('session'), increaseTitle: false })
expect(b.retain).not.toHaveBeenCalledWith('forked', { source: 'mainView' })
b.setSummary('forked', { projectionValues: branchFacts })
expect(b.retain).toHaveBeenCalledWith('forked', { source: 'mainView' })
await vi.waitFor(() => {
expect(b.fork).toHaveBeenCalledExactlyOnceWith({ sessionId: sid('session'), increaseTitle: true })
})
expect(b.retain).not.toHaveBeenCalled()
expect(b.selectPanel).not.toHaveBeenCalled()
expect(b.renameSession).not.toHaveBeenCalled()
refreshed.resolve(undefined)
})
it('does not open or retry a reader fork refused for migration until its source notice is used', async () => {
@@ -962,10 +952,9 @@ describe('ui-workspace apply', () => {
})
expect(b.fork).toHaveBeenCalledExactlyOnceWith({
sessionId: 'session', increaseTitle: false, allowMigration: false, onCreated: b.fork.mock.calls[0]?.[0].onCreated,
sessionId: 'session', increaseTitle: true, allowMigration: false, onCreated: b.fork.mock.calls[0]?.[0].onCreated,
})
expect(b.fork.mock.calls[0]?.[0].onCreated).toBeTypeOf('function')
expect(b.refresh).not.toHaveBeenCalled()
expect(b.retain).not.toHaveBeenCalled()
expect(b.renameSession).not.toHaveBeenCalled()
expect(b.selectPanel).not.toHaveBeenCalled()
@@ -977,142 +966,6 @@ describe('ui-workspace apply', () => {
expect(b.renameSession).not.toHaveBeenCalled()
})
it('opens a branch the list already reports as this page\'s own', async () => {
const b = await bench()
provideReaderAnswers(b)
// The Host's own summary of the child is already on the list, so the page can describe it now.
b.setSummary('forked', { projectionValues: branchFacts })
declare(b.slots, 'sidebar.workspaces')
await b.ctx.plugin({ inject: [...inject], apply }).await()
const forkSession = (faceOf(entry(b.slots, MENU_ITEM, 'fork')) as ForkSessionInjected).forkSession
forkSession('session' as never)
await vi.waitFor(() => { expect(b.retain).toHaveBeenCalledWith('forked', { source: 'mainView' }) })
// Nothing is asked of the Host and nothing is watched: the facts are already here.
expect(b.refresh).not.toHaveBeenCalled()
})
it('keeps waiting through list changes that say nothing about the child', async () => {
const b = await bench()
provideReaderAnswers(b)
b.refresh.mockImplementationOnce(() => new Promise<undefined>(() => {}))
declare(b.slots, 'sidebar.workspaces')
await b.ctx.plugin({ inject: [...inject], apply }).await()
const forkSession = (faceOf(entry(b.slots, MENU_ITEM, 'fork')) as ForkSessionInjected).forkSession
forkSession('session' as never)
await vi.waitFor(() => { expect(b.refresh).toHaveBeenCalled() })
// A list change about another branch is not this child's own item: the page stays on the wait.
b.setSummary('another', { projectionValues: branchFacts })
expect(b.retain).not.toHaveBeenCalledWith('forked', { source: 'mainView' })
b.setSummary('forked', { projectionValues: branchFacts })
await vi.waitFor(() => { expect(b.retain).toHaveBeenCalledWith('forked', { source: 'mainView' }) })
})
/** Fork as a branch surface does on a page that may branch but not rename the source Session. */
async function forkReaderBranch(b: Awaited<ReturnType<typeof bench>>, childId: SessionId): Promise<void> {
provideReaderAnswers(b)
b.fork.mockResolvedValueOnce(childId)
await b.ctx.uiWorkspace.forkSession(sid('session'), undefined, { present: 'selected' })
}
it('opens a row published synchronously during subscription and detaches the wait', async () => {
const b = await bench()
await b.ctx.plugin({ inject: [...inject], apply }).await()
const source = b.ctx.sessions.list
const original = source.subscribe.bind(source)
Object.assign(source, { subscribe: (listener: () => void) => {
const stop = original(listener)
b.setSummary('raced', { id: sid('raced') })
return stop
} })
await forkReaderBranch(b, sid('raced'))
expect(b.retain).toHaveBeenCalledWith('raced', { source: 'mainView' })
b.setSummary('raced', { title: 'later' })
expect(b.retain.mock.calls.filter(([id]) => id === 'raced')).toHaveLength(1)
expect(b.refresh).not.toHaveBeenCalled()
})
it('cancels listed navigation on later user navigation and disposal', async () => {
const b = await bench()
const navigation = new AbortController()
Object.assign(b.ctx.layout, { beginNavigation: () => navigation.signal })
b.refresh.mockImplementation(() => new Promise<undefined>(() => {}))
const fiber = b.ctx.plugin({ inject: [...inject], apply })
await fiber.await()
await forkReaderBranch(b, sid('late'))
navigation.abort()
b.setSummary('late', { id: sid('late') })
expect(b.retain).not.toHaveBeenCalledWith('late', { source: 'mainView' })
Object.assign(b.ctx.layout, { beginNavigation: () => new AbortController().signal })
await forkReaderBranch(b, sid('later'))
await fiber.dispose()
b.setSummary('later', { id: sid('later') })
expect(b.retain).not.toHaveBeenCalledWith('later', { source: 'mainView' })
})
it('settles a completed refresh without the created id and reports refresh rejection', async () => {
const b = await bench()
declare(b.slots, 'shell.overlay')
await b.ctx.plugin({ inject: [...inject], apply }).await()
await forkReaderBranch(b, sid('missing'))
await Promise.resolve()
b.setSummary('missing', { id: sid('missing') })
expect(b.retain).not.toHaveBeenCalledWith('missing', { source: 'mainView' })
b.refresh.mockRejectedValueOnce(new Error('list refused'))
await forkReaderBranch(b, sid('refused'))
await Promise.resolve()
const toast = faceOf(entry(b.slots, 'shell.overlay', 'workspace.row-toast')) as RowToastInjected
expect(toast.hooks.toast.getSnapshot()).toMatchObject({ kind: 'branchListRefreshFailed', message: 'list refused' })
})
it('does not subscribe or refresh a branch when navigation is already cancelled', async () => {
const b = await bench()
await b.ctx.plugin({ inject: [...inject], apply }).await()
const navigation = new AbortController()
navigation.abort()
Object.assign(b.ctx.layout, { beginNavigation: () => navigation.signal })
await forkReaderBranch(b, sid('cancelled'))
expect(b.refresh).not.toHaveBeenCalled()
expect(b.retain).not.toHaveBeenCalledWith('cancelled', { source: 'mainView' })
})
it.each([false, true])('reports a failed branch opening only while navigation is active (cancel=%s)', async (cancel) => {
const b = await bench()
declare(b.slots, 'shell.overlay')
await b.ctx.plugin({ inject: [...inject], apply }).await()
const navigation = new AbortController()
Object.assign(b.ctx.layout, { beginNavigation: () => navigation.signal })
b.setSummary('broken', { id: sid('broken') })
b.retain.mockImplementationOnce(() => {
if (cancel) navigation.abort()
throw new Error('branch opening refused')
})
await forkReaderBranch(b, sid('broken'))
const toast = faceOf(entry(b.slots, 'shell.overlay', 'workspace.row-toast')) as RowToastInjected
expect(toast.hooks.toast.getSnapshot()).toEqual(cancel ? null
: { kind: 'createFailed', message: 'branch opening refused', seq: 1 })
expect(b.refresh).not.toHaveBeenCalled()
})
it.each(['opened', 'cancelled'] as const)('ignores a refresh failure after branch navigation is %s', async (end) => {
const b = await bench()
declare(b.slots, 'shell.overlay')
await b.ctx.plugin({ inject: [...inject], apply }).await()
const navigation = new AbortController()
Object.assign(b.ctx.layout, { beginNavigation: () => navigation.signal })
const refreshed = Promise.withResolvers<undefined>()
b.refresh.mockImplementationOnce(() => refreshed.promise)
await forkReaderBranch(b, sid('late-refresh'))
if (end === 'opened') b.setSummary('late-refresh', { id: sid('late-refresh') })
else navigation.abort()
refreshed.reject(new Error('late refresh refused'))
await Promise.resolve()
const toast = faceOf(entry(b.slots, 'shell.overlay', 'workspace.row-toast')) as RowToastInjected
expect(toast.hooks.toast.getSnapshot()).toBeNull()
expect(b.retain.mock.calls.filter(([id]) => id === 'late-refresh')).toHaveLength(end === 'opened' ? 1 : 0)
})
it('declares the browser child slots and reports directory-flow occupancy per surface', async () => {
const b = await bench()
declare(b.slots, 'sidebar.workspaces', 'conversation.hero.workspace')
@@ -349,9 +349,9 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [
},
{
signature: 'forkSession( sessionId: SessionId, onCreated?: (childId: SessionId) => void, options?: ForkSessionOptions, ): Promise<SessionId>',
description: 'Fork a Session and apply its branch presentation policy. Pages whose access snapshot includes renaming the source increment the child title; other pages leave the title to the Host and open the child only once its summary is listed. In the latter case the call begins one navigation for that listing, so a pending navigation may be superseded.',
parameters: [{ name: 'sessionId', description: 'source Session.' }, { name: 'onCreated', description: 'observer after creation, before the optional child-title update.' }, { name: 'options', description: 'branch boundary, migration permission, and how the child is presented.' }],
returns: 'the child SessionId after creation and any permitted inherited-title increment; kept for upper layers that need the id while the listed-open wait is still pending.',
description: 'Fork a Session and apply its branch presentation policy. The child always receives the incremented title: whoever may fork a Session also holds `session/rename` on the child it creates, so the source\'s own rename answer does not matter.',
parameters: [{ name: 'sessionId', description: 'source Session.' }, { name: 'onCreated', description: 'observer after creation, before the child-title update.' }, { name: 'options', description: 'branch boundary, migration permission, and how the child is presented.' }],
returns: 'the child SessionId after creation and inherited-title increment.',
throws: ['when the Host refuses or fails the fork; the caller keeps its current view and decides whether to report the failure.'],
},
{