feat(client): increment forked session titles

This commit is contained in:
imccyu
2026-07-30 21:16:02 +08:00
parent 213de9a737
commit 3b370549a1
23 changed files with 167 additions and 44 deletions
+2 -2
View File
@@ -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
+1 -1
View File
@@ -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
+1 -1
View File
@@ -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` 到达调用方,避免重试创建重复的子会话。
## 会话模型选择
@@ -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<SessionId>
fork(opts: { sessionId: SessionId; atSeq?: number; increaseTitle?: boolean }): Promise<SessionId>
/**
* Register a per-session standard-props provider (hooks become `use<Name>`
* selector hooks on the render side; props spread verbatim).
@@ -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<SessionId> {
const result = await this.manager.fork(opts)
async fork(opts: {
sessionId: SessionId
atSeq?: number
increaseTitle?: boolean
}): Promise<SessionId> {
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
}
/**
@@ -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()
+2 -2
View File
@@ -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<SessionId> {
fork(opts: { sessionId: SessionId; atSeq?: number; increaseTitle?: boolean }): Promise<SessionId> {
this.calls.push({ method: 'fork', args: [opts] })
return Promise.resolve(opts.sessionId)
}
@@ -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()
})
@@ -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
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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 条没有换行或展开入口,完整文本无法在行内读完。
@@ -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.
})
},
}
@@ -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()
})
@@ -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
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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)