fix(client): preserve state across Session reference updates

This commit is contained in:
Tianyi Cui committed 2026-09-17 09:38:24 +08:00
1 parent 5c25efba91
commit fa3e5cbaf6
26 files changed
+248 -43

No files matched your search

@@ -34,6 +34,7 @@ export interface SessionSummary {
parentId?: SessionId
/** Coarse durable origin for navigation filtering; not a continuation capability. */
origin?: 'subagent'
/** Host running state for `ids` members; a display fallback for other rows. */
running: boolean
/** Local ownership counts; Host metadata refreshes cannot overwrite them. */
readonly retainedBy: SessionRetainInfo['retainedBy']
@@ -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-agent-preset/README.md
README.md: 476d0a351896835b4f92372c4a62854f2162a6e1
README.zh.md: fa5a81b4af6299ae2253cf43809f07478d66885c
README.md: a95676e4bd38c16f1e5723f921527bd1c24a55ea
README.zh.md: e51f53711147bd64e86ff8d04f62d564e865ea5d
@@ -45,6 +45,8 @@ When the roster carries the self-referential `cordis` preset, its dashed add-car
The settings section writes the Host's existing `agent-presets` namespace through `settings.update`. Its visibility switch sets only `modeSelectionEnabled`, and its make-default action writes `default` only while the picker is shown. After either write, the Host roster supplies the effective default, and the chip controller's `agentPresets/select` path carries it to the same still-blank session; that path is the only session-mutation API these surfaces use. Display options and Host-effective visibility come from `agentPresets/list` — the roster already marks the effective Host default and carries `modeSelectionEnabled`, so a non-loopback read-only client stays consistent without introspecting the settings schema. The settings section queries `settings.canOpenAgentPresetDirectory()` when it first loads and joins that result with the roster; a failed query removes only the native-open affordance. The new-session chip renders only while `modeSelectionEnabled` is true; hiding it drops a pending stage and local menu or refusal state, while the header label remains registered and reads each session's recorded preset. The stage is applied when a session arrives (covering both the session a workspace connect created and the blank one it reused) and dropped on refusal. A refusal announces itself as a transient banner over the composer column, because the chip's label has already reverted and a preset the Host refuses to mount is one discovery reported healthy — its roster card carries no reason to go back and read. Only a pick a person just made is announced; the applier that runs when a session becomes current is not. [`dsh-client-connection`](../connection/README.md) authenticates `agentPresets/read`, `agentPresets/copy`, `settings/openAgentPresetDirectory`, `agentPresets/deletePreset`, `agentPresets/list`, and every other Host API method with the same browser session. A composition still names the plugins a session runs, so reading one is reconnaissance, while copy, delete, and the settings-owned directory opener manage the roster and drive the Host desktop. The section re-reads on its own actions, `settings/document-updated`, and `connection/reset`, because composition files are edited outside the browser and nothing on the wire announces a file change.
The chip follows the preset projection of its bound Session while its plugin and Provider generation remain active. A late projection or another client's preset update refreshes the display without submitting a selection; list notifications do not repeat an in-flight selection.
</details>
-----
@@ -45,6 +45,8 @@ kind: "package-reference"
设置分区通过现有的 `settings.update` 写入宿主的 `agent-presets` 命名空间。可见性开关只设置 `modeSelectionEnabled`;仅当选择器显示时,设为默认动作才会写入 `default`。两种写入之后,都由宿主名单给出当前生效的默认值,再由 chip controller 的 `agentPresets/select` 链路把它带到同一个仍为空白的会话;这些界面只使用这一条会话修改 API。展示选项与宿主的生效可见性来自 `agentPresets/list`——名单本身已标记宿主当前生效的默认值并携带 `modeSelectionEnabled`,因此非 loopback 的只读客户端无需内省 settings schema 也能保持一致。设置分区首次加载时查询 `settings.canOpenAgentPresetDirectory()`,并把结果与名单合并;查询失败只会移除原生打开动作。新建会话 chip 仅在 `modeSelectionEnabled` 为 `true` 时渲染;隐藏它会丢弃待处理的暂存选择及本地菜单或失败横幅状态,而标题标签保持注册并读取每个会话已记录的 preset。暂存值在会话到达时应用(既覆盖工作区连接新建的会话,也覆盖它复用的空白会话),被拒绝时丢弃。系统会通过 composer 列上方的瞬时横幅提示拒绝结果,因为 chip 的标签此时已经恢复原值,而被宿主拒绝挂载的 preset 正是发现过程报告为健康的那一种——它的名单卡片上没有任何原因可供回头查看。只有用户刚做出的选择会触发提示;会话成为当前会话时触发的应用器不会。[`dsh-client-connection`](../connection/README.zh.md) 使用同一浏览器会话认证 `agentPresets/read`、`agentPresets/copy`、`settings/openAgentPresetDirectory`、`agentPresets/deletePreset`、`agentPresets/list` 及其他所有宿主 API 方法。组装仍会指明一个会话所运行的插件,因此读取属于侦察,而复制、删除与设置模块拥有的目录打开操作负责管理名单并驱动宿主桌面。分区在自身操作、`settings/document-updated` 与 `connection/reset` 时重读,因为组装文件在浏览器之外编辑,协议链路不会通知文件变动。
只要插件及 Provider generation 仍有效,选择芯片就会跟随其绑定 Session 的预设投影。延后到达的投影或其他客户端的预设更新会刷新显示,而不会提交选择;列表通知不会重复仍在进行中的选择操作。
</details>
-----
@@ -70,9 +70,9 @@ export function apply(ctx: ClientContext): void {
const seats = new WeakMapWithValues<SessionBinding, AgentPresetSeatController>()
const unboundSeat = new AgentPresetSeatController(ctx, () => undefined, staged)
const seatFor = (scope: ClientContext, binding: SessionBinding): AgentPresetSeatController => {
let seat = seats.get(binding)
if (seat !== undefined) return seat
seat = new AgentPresetSeatController(scope, () => {
const existing = seats.get(binding)
if (existing !== undefined) return existing
const seat = new AgentPresetSeatController(scope, () => {
if (scope.sessions.binding(binding.sessionId) !== binding) return undefined
const summary = scope.sessions.list.getSnapshot().byId[binding.sessionId]
return summary !== undefined
@@ -81,9 +81,13 @@ export function apply(ctx: ClientContext): void {
: undefined
}, staged)
seats.set(binding, seat)
binding.ctx.effect(() => () => {
seats.delete(binding)
}, 'ui-agent-preset: Provider binding')
scope.effect(() => binding.ctx.effect(() => {
const stop = scope.sessions.list.subscribe(() => { void seat.apply() })
return () => {
stop()
seats.delete(binding)
}
}, 'ui-agent-preset: Provider binding'), 'ui-agent-preset: bound selection')
return seat
}
const section = new AgentPresetSectionController(ctx, () => {
@@ -188,9 +188,11 @@ export class AgentPresetSeatController {
*
* Called both by `select()` and by whoever observes the current session
* changing, because the session may appear either before or after the pick.
* @returns once the switch settled, or immediately when there is nothing to do.
* List updates do not repeat a selection while its response is pending.
* @returns once a new switch settles, or immediately when no switch starts.
*/
async apply(): Promise<void> {
if (this.store.getSnapshot().busy) return
const staged = this.staged.id
const session = this.currentSession()
if (staged === undefined) {
@@ -74,6 +74,7 @@ const ROSTER_HIDDEN = {
async function bench(options: {
failSettingsUpdate?: boolean
selectGate?: Promise<void>
} = {}) {
const ctx = new Context()
// The host's answer, mutable so a spec can move the default the way the
@@ -141,7 +142,7 @@ async function bench(options: {
deletePreset: () => Promise.resolve({ ok: true as const, value: undefined }),
select: (_agentId: SessionId, agentPreset: string) => {
calls.push(`select:${agentPreset}`)
return Promise.resolve({ ok: true as const, value: agentPreset })
return Promise.resolve(options.selectGate).then(() => ({ ok: true as const, value: agentPreset }))
},
}
ctx.provide('remote.agentPresets', agentPresets as never)
@@ -473,6 +474,58 @@ describe('ui-agent-preset apply', () => {
await ctx.fiber.dispose()
})
it.each(['feature', 'binding'] as const)('tracks bound preset changes until its %s owner stops', async (disposedOwner) => {
const selection = Promise.withResolvers<undefined>()
const { ctx, slots, calls } = await bench({ selectGate: selection.promise })
try {
declareRoot(slots)
declareConversation(slots)
ctx.provide('conversation', {} as never)
const state = {
current: 's1',
byId: { s1: { id: 's1', blank: true, projectionValues: {} as { agentPreset?: string } } },
}
const sessions = sessionsDouble(ctx, state)
const bindingOwner = ctx.plugin({ apply() {} })
await bindingOwner.await()
sessions.binding('s1')!.ctx = bindingOwner.ctx
ctx.provide('sessions', sessions as never)
ctx.provide('uiWorkspace', uiWorkspaceDouble() as never)
const feature = ctx.plugin({ inject: [...inject, 'conversation', 'sessions', 'uiWorkspace'], apply })
await feature.await()
const seat = (slots.entries('conversation.hero.agentPreset')[0]!
.inject as unknown as (sessionId: SessionId) => AgentPresetSeatInjected)(SessionId('s1'))
await seat.load()
expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('')
state.byId.s1.projectionValues.agentPreset = 'standard'
sessions.notify()
expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('standard')
state.byId.s1.projectionValues.agentPreset = 'minimal'
sessions.notify()
expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('minimal')
expect(calls.filter(call => call.startsWith('select:'))).toEqual([])
const selecting = seat.select('standard')
expect(seat.hooks.agentPresetSeat.getSnapshot().busy).toBe(true)
sessions.notify()
sessions.notify()
expect(calls.filter(call => call.startsWith('select:'))).toEqual(['select:standard'])
selection.resolve(undefined)
await selecting
state.byId.s1.projectionValues.agentPreset = 'standard'
sessions.notify()
await (disposedOwner === 'feature' ? feature : bindingOwner).dispose()
state.byId.s1.projectionValues.agentPreset = 'minimal'
sessions.notify()
expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('standard')
} finally {
selection.resolve(undefined)
await ctx.fiber.dispose()
}
})
it('does not sync a blank Session that is not retained by the main view', async () => {
const { ctx, slots, calls } = await bench()
declareRoot(slots)
+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/ui-session/README.md
README.md: 4fff368e58d7b422ba6948be78e543c31f5cef59
README.zh.md: db37539b59231bfef50f3bdcc33aeae94e881a44
README.md: 22ece0c11eee6b2a59acbbd8c5a9650b593a7da4
README.zh.md: 4b52bef28ad065a7ff10f468056723c4d301a0a0
+2
View File
@@ -10,6 +10,8 @@ English | [中文](README.zh.md)
Use this package to expose Session catalog, retain information, and unified UI status through standard Slot hooks. It materializes per-`SessionBinding` hooks and props, while `SessionProvider` can inherit an outer binding or bind an explicit `SessionReference`. It owns process-local pending-interaction and completion-reminder policy without owning Controller transport, history, or references.
Running status comes from Host list baselines or status events. Catalog rows and local fallback rows do not establish running status; main view references still acknowledge completions.
## Table of Contents
- [Model Experience](#model-experience)
+2
View File
@@ -10,6 +10,8 @@ kind: "package-reference"
使用本包可通过标准 Slot 钩子公开 Session catalog、retain 信息与统一 UI 状态。它按 `SessionBinding` 物化钩子和 prop,而 `SessionProvider` 可以继承外围 binding 或绑定显式 `SessionReference`。它拥有进程本地的 pending-interaction 与完成提醒策略,但不拥有 Controller transport、历史或 reference。
运行状态来自 Host 列表基线或状态事件。目录和保留代际的 fallback 行不会建立运行状态;主视图引用仍会确认完成提醒。
## 目录
- [模型体验](#model-experience)
+1
View File
@@ -48,6 +48,7 @@
"@deepseek-ai/dsh-api-remotes": "workspace:^",
"@deepseek-ai/dsh-api-session-controller": "workspace:^",
"@deepseek-ai/dsh-client-store": "workspace:^",
"@deepseek-ai/dsh-client-test-runtime": "workspace:^",
"@deepseek-ai/dsh-client-ui-renderer": "workspace:^",
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
"@deepseek-ai/dsh-session": "workspace:^",
@@ -492,13 +492,16 @@ export class UiSession extends Service {
private reconcileStatus(): void {
const list = this.sessions.list.getSnapshot()
const present = new Set(Object.keys(list.byId) as SessionId[])
for (const id of present) {
// Rows from catalogs and retained Client bindings do not establish Host running state.
for (const id of list.ids) {
const row = list.byId[id]
if (row === undefined) continue
const previous = this.running.get(id)
if (previous === undefined) this.running.set(id, row.running)
else if (previous !== row.running) this.observeRunning(id, row.running)
if ((row.retainedBy.mainView ?? 0) > 0) this.completionUnread.delete(id)
}
for (const id of present) {
if (this.isMain(id)) this.completionUnread.delete(id)
}
if (list.phase === 'ready') {
for (const id of this.running.keys()) {
@@ -0,0 +1,64 @@
/** Running-state ownership across the real Controller catalog and UI status source. */
import { expect, vi } from 'vitest'
import { ok } from '@deepseek-ai/dsh-remote-mock'
import { createClientTest, webApp } from '@deepseek-ai/dsh-client-test-runtime/src/assembly/index.ts'
import { SESSION_FORMAT_VERSION, type SessionId } from '@deepseek-ai/dsh-session/types'
import type { SessionFollowFrame } from '@deepseek-ai/dsh-api-session-controller/types'
const it = createClientTest({ roster: webApp.closure(['@deepseek-ai/dsh-client-ui-session']) })
const parentId = 'status-parent' as SessionId
const childId = 'status-child' as SessionId
it('preserves unlisted child status through metadata updates and main view acknowledgement', async ({ mock, start }) => {
mock.remote.session.list.mockResolvedValue(ok({ items: [{
sessionId: parentId, updatedAt: 1, running: false, blank: false, agentAvailable: true,
}] }))
mock.remote.session.projections.mockResolvedValue(ok({
asOfSeq: 0,
values: { subagentCatalog: [{ id: childId, createdAt: 1, mode: 'continuable', label: 'Child' }] },
}))
mock.stream('session/follow', (_request, stream) => {
stream.push({
type: 'snapshot',
header: { version: SESSION_FORMAT_VERSION, id: childId, createdAt: 1, isSeeded: false,
parentSession: parentId, origin: 'subagent' },
cursor: -1, records: [], hasMore: false,
projections: { asOfSeq: -1, values: {} }, assistantStream: { revision: 0 },
} satisfies SessionFollowFrame)
})
const client = await start()
const sessions = client.ctx.sessions
const ui = client.ctx.uiSession
await sessions.refresh()
await sessions.refreshProjections(parentId)
await client.flush()
expect(sessions.list.getSnapshot().ids).toEqual([parentId])
expect(sessions.binding(childId)).toBeUndefined()
expect(ui.sessionStatus.getSnapshot().get(childId)).toMatchObject({ running: undefined, completionUnread: false })
mock.streams.push('$events', { type: 'emit', event: 'api-session/status', args: [childId, true] })
await mock.streams.drained('$events')
await client.flush()
expect(ui.sessionStatus.getSnapshot().get(childId)).toMatchObject({ running: true, completionUnread: false })
mock.streams.push('$events', { type: 'emit', event: 'api-session/activity', args: [parentId, 2] })
await mock.streams.drained('$events')
await client.flush()
expect(sessions.list.getSnapshot().byId[parentId]?.updatedAt).toBe(2)
expect(ui.sessionStatus.getSnapshot().get(childId)).toMatchObject({ running: true, completionUnread: false })
mock.streams.push('$events', { type: 'emit', event: 'api-session/status', args: [childId, false] })
await mock.streams.drained('$events')
await client.flush()
expect(ui.sessionStatus.getSnapshot().get(childId)).toMatchObject({ running: false, completionUnread: true })
using reference = sessions.retain(childId, { source: 'mainView' })
await reference.ready
expect(sessions.list.getSnapshot().ids).not.toContain(childId)
await vi.waitFor(() => {
expect(ui.sessionStatus.getSnapshot().get(childId)).toMatchObject({ running: false, completionUnread: false })
})
reference.release()
await client.flush()
expect(ui.sessionStatus.getSnapshot().get(childId)?.completionUnread).toBe(false)
})
@@ -522,6 +522,27 @@ describe('UiSession bindings', () => {
})
describe('UiSession status', () => {
it('keeps synthetic-row status unknown until an event or Host baseline establishes it', () => {
const ctx = new Context()
const bench = createSessionsBench(ctx)
const id = sessionId('catalog-only')
bench.list.update((draft) => {
draft.byId[id] = { id, displayTitle: id, running: false, retainedBy: {}, blank: false, updatedAt: 0 }
})
const service = createUiSession(ctx, bench)
expect(service.sessionStatus.getSnapshot().get(id)?.running).toBeUndefined()
expect(service.sessionStatus.getSnapshot().get(id)?.completionUnread).toBe(false)
bench.emitStatus(id, true)
bench.list.update((draft) => { draft.byId[id]!.displayTitle = 'Renamed child' })
expect(service.sessionStatus.getSnapshot().get(id)?.running).toBe(true)
expect(service.sessionStatus.getSnapshot().get(id)?.completionUnread).toBe(false)
bench.list.update((draft) => { draft.ids.push(id) })
expect(service.sessionStatus.getSnapshot().get(id)?.running).toBe(false)
expect(service.sessionStatus.getSnapshot().get(id)?.completionUnread).toBe(true)
})
it('records non-main completions and lets main-view activity acknowledge them', () => {
const ctx = new Context()
const bench = createSessionsBench(ctx)
+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/ui-subagent/README.md
README.md: d77e517b9e43f92dd2ef6da36bb55df83005a73c
README.zh.md: 75c807c8e4033b398050b4d6e86f5a365fa0a4d8
README.md: 70269b9a5ce5823740e0a83c1f473a51a355c09b
README.zh.md: 05ab093cf3dd9a7752b7e95dbc5228c7393e450a
+1 -1
View File
@@ -51,7 +51,7 @@ The catalog and composer behavior are specified by the [Web subagent conversatio
### Catalog derivation
The header lineage renderer reads `projectionsBySession` through the standard `useSessions` hook. The renderer selects `subagentCatalog` from each Session’s shared values for membership, disclosure, and counts; Session summaries supply activity, titles, and usage. Expanding a row loads its initial catalog when needed. Live projection frames update every loaded level without menu subscriptions or repeated membership queries. A row remains expandable while its catalog is absent, loading, or failed, and becomes a known leaf after a ready empty catalog.
The header lineage renderer reads `projectionsBySession` through the standard `useSessions` hook. The renderer selects `subagentCatalog` from each Session’s shared values for membership, disclosure, and counts; Activity prefers the unified UI status and falls back to Session summaries; summaries supply titles and usage. Expanding a row loads its initial catalog when needed. Live projection frames update every loaded level without menu subscriptions or repeated membership queries. A row remains expandable while its catalog is absent, loading, or failed, and becomes a known leaf after a ready empty catalog.
Breadcrumb addresses derive from the Provider-bound Session address and loaded parent catalogs, including never-selected ancestors.
+1 -1
View File
@@ -51,7 +51,7 @@ kind: "package-reference"
### 目录派生
页头谱系 renderer 通过标准 `useSessions` 钩子读取 `projectionsBySession`。renderer 从每个 Session 的共享值中选择 `subagentCatalog`,用于成员关系、展开控件与数量;Session 摘要提供活动状态、标题与用量。展开行会按需加载初始目录。实时 projection 帧更新所有已加载层级,无需菜单订阅或重复成员查询。行在目录缺席、加载中或失败时保持可展开,并在目录就绪且为空后成为已知叶子。
页头谱系 renderer 通过标准 `useSessions` 钩子读取 `projectionsBySession`。renderer 从每个 Session 的共享值中选择 `subagentCatalog`,用于成员关系、展开控件与数量;活动状态优先使用统一 UI status,缺少时使用 Session 摘要;摘要提供标题与用量。展开行会按需加载初始目录。实时 projection 帧更新所有已加载层级,无需菜单订阅或重复成员查询。行在目录缺席、加载中或失败时保持可展开,并在目录就绪且为空后成为已知叶子。
面包屑地址从 Provider 所绑定的 Session 地址和已加载的 parent 目录推导,也包括从未选中过的祖先。
@@ -374,6 +374,7 @@ interface CatalogDropdownSharedProps extends SubagentCatalogInjected {
/** Whether an ordinary title needs a breadcrumb separator before its count. */
separator?: boolean
useSessions: SubagentHeaderLineageProps['useSessions']
useSessionStatus: SubagentHeaderLineageProps['useSessionStatus']
t: TranslateNS<typeof NS>
}
@@ -415,20 +416,21 @@ function catalogMenuPosition(trigger: HTMLButtonElement): CSSProperties {
/** One trigger-plus-tree dropdown over the catalog rooted at `rootSessionId`. */
function CatalogDropdown({
rootSessionId, currentSessionId, displayTitle, openTitle, variant, separator = false,
useSessions, openChild, refresh, t,
useSessions, useSessionStatus, openChild, refresh, t,
}: CatalogDropdownProps) {
const ancestorSwitcher = variant === 'switcher' && openTitle !== undefined
const projections = useSessions(state => state.projectionsBySession)
const summaries = useSessions(state => state.byId)
const statuses = useSessionStatus(value => value)
const catalogs = useMemo<Catalogs>(() => Object.fromEntries(Object.entries(projections).map(([id, snapshot]) => [id, {
state: snapshot.state === 'idle'
? snapshot.values.subagentCatalog === undefined ? 'loading' : 'ready'
: snapshot.state,
error: snapshot.error,
entries: (snapshot.values.subagentCatalog ?? []).map(entry => ({
...entry, activity: summaries[entry.id]?.running === true ? 'running' as const : 'inactive' as const,
...entry, activity: (statuses.get(entry.id)?.running ?? summaries[entry.id]?.running) === true ? 'running' as const : 'inactive' as const,
})),
}])), [projections, summaries])
}])), [projections, summaries, statuses])
const catalog = catalogs[rootSessionId]
const [open, setOpen] = useState(false)
const [menuPosition, setMenuPosition] = useState<CSSProperties>()
@@ -696,7 +698,7 @@ function CatalogDropdown({
*/
export function SubagentHeaderLineage({
lineageSessionId, displayTitle, openTitle,
useSessions, useSession, openChild, refresh, t,
useSessions, useSession, useSessionStatus, openChild, refresh, t,
}: SubagentHeaderLineageProps) {
const address = useSession(session => session.subagent?.address)
const parentId = useSessions((state) => {
@@ -706,7 +708,7 @@ export function SubagentHeaderLineage({
}
return undefined
})
const shared = { useSessions, openChild, refresh, t }
const shared = { useSessions, useSessionStatus, openChild, refresh, t }
if (parentId === undefined) {
return (
<CatalogDropdown
@@ -7,6 +7,7 @@ import type {
} from '@deepseek-ai/dsh-api-session-controller/client'
import type { SubagentAddress, SubagentCatalogRow } from '@deepseek-ai/dsh-subagent/client'
import type { SessionId } from '@deepseek-ai/dsh-session/types'
import type { SessionStatusSnapshot } from '@deepseek-ai/dsh-client-ui-session/client'
import {
SubagentHeaderLineage, type SubagentHeaderLineageProps,
} from '../src/client/SubagentHeaderLineage.tsx'
@@ -52,6 +53,7 @@ function props(
boundAddress?: SubagentAddress,
) {
const catalogs = value === undefined ? nested : { [PARENT]: value, ...nested }
const statuses: SessionStatusSnapshot = new Map()
const state = {
ids: [CHILD],
byId: summaries ?? {
@@ -81,6 +83,7 @@ function props(
return {
sessionId: PARENT,
useSessions,
useSessionStatus: <T,>(select: (snapshot: SessionStatusSnapshot) => T): T => select(statuses),
useSession: <T,>(select: (snapshot: SessionSnapshot) => T): T => select({
subagent: boundAddress === undefined ? undefined : { address: boundAddress },
} as SessionSnapshot),
@@ -112,6 +115,19 @@ function hoverCatalog(trigger: HTMLElement): void {
}
describe('SubagentHeaderLineage', () => {
it('shows current catalog-only child status before Host list discovery', () => {
const input = props(catalog({ entries: [{ id: CHILD, mode: 'continuable', label: 'worker', activity: 'inactive' }] }))
const initial = input.useSessions(state => state)
const statuses: SessionStatusSnapshot = new Map([[CHILD, {
running: true, completionUnread: false, pendingInteraction: undefined,
}]])
render(<SubagentHeaderLineage {...input}
useSessions={select => select({ ...initial, ids: [] })}
useSessionStatus={select => select(statuses)}
/>)
expect(screen.getByRole('button', { name: '1 个子代理,正在运行' })).toBeTruthy()
})
it('shows retained children while an idle projection awaits refresh', () => {
const injected = props(catalog())
const initial = injected.useSessions(state => state)
@@ -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: 7cea227f20b32d965a9a08ff20324d75932b26db
README.zh.md: c7a76d7671df3fcd0fb3e684f21fa4f38c180a38
README.md: 9a5f6939b3a2c66c83f9570bdeb74563d2c51ad0
README.zh.md: 9c21256fdfdbd85859f12dc3f155a5c5c59805d1
+1 -1
View File
@@ -57,7 +57,7 @@ The value is intentionally best effort for cold Sessions. An identity-matching u
-----
`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?)` and `forkSession(id)` open their result only if no later navigation has superseded the request; New Session uses `openWorkspace`. 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. 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.
`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?)` and `forkSession(id)` open their result only if no later navigation has superseded the request; New Session uses `openWorkspace`. 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. 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.
<a id="understand-the-implementation"></a>
## Understand the implementation
+1 -1
View File
@@ -57,7 +57,7 @@ Session 行渲染运行时的实时 `pendingInteraction` 分类:审批显示**
-----
`ctx.uiWorkspace.openSession(target)` 会同步替换其拥有的 `mainView` reference,并让主区域返回 Conversation,而不等待 `reference.ready`,因此历史加载会显示在已经选中的 Session 视图内。目标可以是已知 Session id,也可以是持久的直接父子 subagent 地址;显式地址不要求预先加载 parent catalog。`openWorkspace(id, beforeOpen?)` 和 `forkSession(id)` 仅在请求未被后续导航替代时打开结果;新会话使用 `openWorkspace`。可选的同步准备回调在目标被 retain 后执行,并且仅对仍有效的 Workspace 请求执行,因此过期请求不会搬移 composer 草稿。后续导航或 owner 释放会阻止晚到的 UI 提交,但不取消底层 Session 创建。归档主 Session 会释放其 reference 并清除主选择。选择失败时保留当前全局面板。Session 行读取 `usePanelInfo`,在全局面板活跃时不显示 Session 选中样式;仅把焦点移到搜索框或目录选择器不会离开该面板。
`ctx.uiWorkspace.openSession(target)` 会同步替换其拥有的 `mainView` reference,并让主区域返回 Conversation,而不等待 `reference.ready`,因此历史加载会显示在已经选中的 Session 视图内。目标可以是已知 Session id,也可以是持久的直接父子 subagent 地址;显式地址不要求预先加载 parent catalog。`openWorkspace(id, beforeOpen?)` 和 `forkSession(id)` 仅在请求未被后续导航替代时打开结果;新会话使用 `openWorkspace`。可选的同步准备回调在目标被 retain 后执行,并且仅对仍有效的 Workspace 请求执行,因此过期请求不会搬移 composer 草稿。后续导航或 owner 释放会阻止晚到的 UI 提交,但不取消底层 Session 创建。启动恢复会 retain 主 reference,不改变已选面板,也不取消后续导航。归档主 Session 会释放其 reference 并清除主选择。选择失败时保留当前全局面板。Session 行读取 `usePanelInfo`,在全局面板活跃时不显示 Session 选中样式;仅把焦点移到搜索框或目录选择器不会离开该面板。
<a id="understand-the-implementation"></a>
## 理解实现
@@ -159,20 +159,20 @@ class UiWorkspaceService extends Service implements UiWorkspace {
}
openSession(target: SessionTarget): void {
this.replaceMain(target, this.lifetime.signal)
this.replaceMain(target, this.lifetime.signal, 'reveal')
}
async openWorkspace(workspaceId: WorkspaceId, beforeOpen?: (sessionId: SessionId) => void): Promise<void> {
const navigation = AbortSignal.any([this.ctx.layout.beginNavigation(), this.lifetime.signal])
const sessionId = await this.connectWorkspace(workspaceId)
if (navigation.aborted) return
this.replaceMain(sessionId, navigation, beforeOpen)
this.replaceMain(sessionId, navigation, 'reveal', beforeOpen)
}
async forkSession(sessionId: SessionId): Promise<void> {
const navigation = AbortSignal.any([this.ctx.layout.beginNavigation(), this.lifetime.signal])
const childId = await this.sessions.fork({ sessionId, increaseTitle: true })
if (!navigation.aborted) this.replaceMain(childId, navigation)
if (!navigation.aborted) this.replaceMain(childId, navigation, 'reveal')
}
startSession(workspaceId?: WorkspaceId): void {
@@ -246,7 +246,7 @@ class UiWorkspaceService extends Service implements UiWorkspace {
if (saved.subagentAddress !== undefined) {
void this.sessions.refreshProjections(saved.subagentAddress.parentSessionId)
}
this.openSession(savedTarget)
this.replaceMain(savedTarget, this.lifetime.signal, 'preserve')
initial = 'done'
} catch (reason: unknown) {
initial = 'waiting'
@@ -262,7 +262,7 @@ class UiWorkspaceService extends Service implements UiWorkspace {
initial = 'connecting'
void this.connectWorkspace(target).then(
(sessionId) => {
if (this.mainReference === undefined) this.openSession(sessionId)
if (this.mainReference === undefined) this.replaceMain(sessionId, this.lifetime.signal, 'preserve')
},
).then(
() => { initial = 'done' },
@@ -303,6 +303,7 @@ class UiWorkspaceService extends Service implements UiWorkspace {
private replaceMain(
target: SessionTarget,
signal: AbortSignal,
panel: 'reveal' | 'preserve',
beforeOpen?: (sessionId: SessionId) => void,
): void {
signal.throwIfAborted()
@@ -329,7 +330,7 @@ class UiWorkspaceService extends Service implements UiWorkspace {
this.mainReference = reference
previous?.release()
void this.sessions.refreshProjections(reference.sessionId)
this.ctx.layout.selectPanel(null)
if (panel === 'reveal') this.ctx.layout.selectPanel(null)
}
}
@@ -24,11 +24,9 @@ const list = (...items: SessionSummary[]): SessionListState => ({
byId: Object.fromEntries(items.map(item => [item.id, item])),
phase: 'ready', projectionsBySession: {}, jobsBySession: {},
})
const catalog = (
...entries: Array<{ id: string; activity: 'running' | 'inactive' }>
): SessionProjectionSnapshot => ({
values: { subagentCatalog: entries.map(entry => ({
id: sid(entry.id), mode: 'continuable', label: entry.id,
const catalog = (...ids: string[]): SessionProjectionSnapshot => ({
values: { subagentCatalog: ids.map(id => ({
id: sid(id), mode: 'continuable', label: id,
createdAt: 1,
})) },
state: 'ready',
@@ -251,11 +249,11 @@ describe('deriveGroups', () => {
...withMain(list(parent, fork, subagent, grandchild, forkChild), subagent.id),
projectionsBySession: {
[parent.id]: catalog(
{ id: 'subagent', activity: 'running' },
{ id: 'inactive-child', activity: 'inactive' },
'subagent',
'inactive-child',
),
[fork.id]: catalog({ id: 'fork-child', activity: 'running' }),
[subagent.id]: catalog({ id: 'grandchild', activity: 'running' }),
[fork.id]: catalog('fork-child'),
[subagent.id]: catalog('grandchild'),
},
}
const groups = deriveGroups(
@@ -288,7 +286,7 @@ describe('deriveGroups', () => {
const sessions = {
...list(parent, child, stopped, unrelated),
projectionsBySession: {
[parent.id]: catalog({ id: 'child', activity: 'inactive' }, { id: 'stopped', activity: 'running' }),
[parent.id]: catalog('child', 'stopped'),
},
}
const statuses = new Map<SessionId, SessionStatus>([
@@ -499,6 +499,34 @@ describe('UiWorkspaceService', () => {
expect(b.sessions.retain.mock.calls.map(([target]) => target)).toEqual([sid('chosen')])
})
it('keeps a chosen panel and pending navigation when initial connection finishes', async () => {
const created = Promise.withResolvers<SessionId>()
const b = bench({
workspaces: workspaceState([workspace('a')]),
sessions: sessionState(),
configureSessions: (sessions) => { sessions.create.mockReturnValueOnce(created.promise) },
})
const panel = 'other-panel' as MainPanelId
b.layout.selectPanel(panel)
const navigation = b.layout.beginNavigation()
created.resolve(sid('restored'))
await expect.poll(() => b.sessions.retained.length).toBe(1)
expect(b.sessions.retained[0]!.reference.sessionId).toBe(sid('restored'))
expect(b.selectPanel.mock.calls).toEqual([[panel]])
expect(navigation.aborted).toBe(false)
})
it('keeps a chosen panel when the saved target becomes discoverable', () => {
const saved = sid('saved')
persistSelection({ sessionId: saved })
const b = bench({ workspaces: workspaceState(), sessions: sessionState([], 'pending') })
const panel = 'other-panel' as MainPanelId
b.layout.selectPanel(panel)
b.sessions.list.set(sessionState([summary('saved')]))
expect(b.sessions.retained[0]!.reference.sessionId).toBe(saved)
expect(b.selectPanel.mock.calls).toEqual([[panel]])
})
it('restores a persisted subagent address without a parent catalog', () => {
const address: SubagentAddress = {
parentSessionId: sid('parent'),
+3
View File
@@ -3920,6 +3920,9 @@ importers:
'@deepseek-ai/dsh-client-store':
specifier: workspace:^
version: link:../store
'@deepseek-ai/dsh-client-test-runtime':
specifier: workspace:^
version: link:../../test-support/client-runtime
'@deepseek-ai/dsh-client-ui-renderer':
specifier: workspace:^
version: link:../ui-renderer