diff --git a/packages/api/session-controller/src/client/sessions/service.ts b/packages/api/session-controller/src/client/sessions/service.ts index f0c0b36f0b..16a282583e 100644 --- a/packages/api/session-controller/src/client/sessions/service.ts +++ b/packages/api/session-controller/src/client/sessions/service.ts @@ -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'] diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index a6ca005fdf..99eefc486f 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-preset/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-agent-preset/README.md -README.md: 476d0a351896835b4f92372c4a62854f2162a6e1 -README.zh.md: fa5a81b4af6299ae2253cf43809f07478d66885c +README.md: a95676e4bd38c16f1e5723f921527bd1c24a55ea +README.zh.md: e51f53711147bd64e86ff8d04f62d564e865ea5d diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index 476d0a3518..a95676e4bd 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -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. + ----- diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index fa5a81b4af..e51f537111 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -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 的预设投影。延后到达的投影或其他客户端的预设更新会刷新显示,而不会提交选择;列表通知不会重复仍在进行中的选择操作。 + ----- diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index fc45aeb3f8..20f5c704a8 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -70,9 +70,9 @@ export function apply(ctx: ClientContext): void { const seats = new WeakMapWithValues() 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, () => { diff --git a/packages/client/ui-agent-preset/src/client/seat-store.ts b/packages/client/ui-agent-preset/src/client/seat-store.ts index ca19a56b69..ddef27e44b 100644 --- a/packages/client/ui-agent-preset/src/client/seat-store.ts +++ b/packages/client/ui-agent-preset/src/client/seat-store.ts @@ -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 { + if (this.store.getSnapshot().busy) return const staged = this.staged.id const session = this.currentSession() if (staged === undefined) { diff --git a/packages/client/ui-agent-preset/tests/apply.client.spec.ts b/packages/client/ui-agent-preset/tests/apply.client.spec.ts index 1f0c6bd36a..e63b7cb5ee 100644 --- a/packages/client/ui-agent-preset/tests/apply.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.client.spec.ts @@ -74,6 +74,7 @@ const ROSTER_HIDDEN = { async function bench(options: { failSettingsUpdate?: boolean + selectGate?: Promise } = {}) { 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() + 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) diff --git a/packages/client/ui-session/README.i18n.yaml b/packages/client/ui-session/README.i18n.yaml index eec5a1a51f..03b356e547 100644 --- a/packages/client/ui-session/README.i18n.yaml +++ b/packages/client/ui-session/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-session/README.md -README.md: 4fff368e58d7b422ba6948be78e543c31f5cef59 -README.zh.md: db37539b59231bfef50f3bdcc33aeae94e881a44 +README.md: 22ece0c11eee6b2a59acbbd8c5a9650b593a7da4 +README.zh.md: 4b52bef28ad065a7ff10f468056723c4d301a0a0 diff --git a/packages/client/ui-session/README.md b/packages/client/ui-session/README.md index 4fff368e58..22ece0c11e 100644 --- a/packages/client/ui-session/README.md +++ b/packages/client/ui-session/README.md @@ -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) diff --git a/packages/client/ui-session/README.zh.md b/packages/client/ui-session/README.zh.md index db37539b59..4b52bef28a 100644 --- a/packages/client/ui-session/README.zh.md +++ b/packages/client/ui-session/README.zh.md @@ -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) diff --git a/packages/client/ui-session/package.json b/packages/client/ui-session/package.json index ab1eba496e..6a1e5a2422 100644 --- a/packages/client/ui-session/package.json +++ b/packages/client/ui-session/package.json @@ -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:^", diff --git a/packages/client/ui-session/src/client/index.ts b/packages/client/ui-session/src/client/index.ts index 5e3285bbd0..905f18e408 100644 --- a/packages/client/ui-session/src/client/index.ts +++ b/packages/client/ui-session/src/client/index.ts @@ -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()) { diff --git a/packages/client/ui-session/tests/status-controller.client.spec.ts b/packages/client/ui-session/tests/status-controller.client.spec.ts new file mode 100644 index 0000000000..1bf3bfec88 --- /dev/null +++ b/packages/client/ui-session/tests/status-controller.client.spec.ts @@ -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) +}) diff --git a/packages/client/ui-session/tests/ui-session.client.spec.ts b/packages/client/ui-session/tests/ui-session.client.spec.ts index 724247db84..e9c80918bd 100644 --- a/packages/client/ui-session/tests/ui-session.client.spec.ts +++ b/packages/client/ui-session/tests/ui-session.client.spec.ts @@ -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) diff --git a/packages/client/ui-subagent/README.i18n.yaml b/packages/client/ui-subagent/README.i18n.yaml index 2accc042b0..c790b0c498 100644 --- a/packages/client/ui-subagent/README.i18n.yaml +++ b/packages/client/ui-subagent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-subagent/README.md -README.md: d77e517b9e43f92dd2ef6da36bb55df83005a73c -README.zh.md: 75c807c8e4033b398050b4d6e86f5a365fa0a4d8 +README.md: 70269b9a5ce5823740e0a83c1f473a51a355c09b +README.zh.md: 05ab093cf3dd9a7752b7e95dbc5228c7393e450a diff --git a/packages/client/ui-subagent/README.md b/packages/client/ui-subagent/README.md index d77e517b9e..70269b9a5c 100644 --- a/packages/client/ui-subagent/README.md +++ b/packages/client/ui-subagent/README.md @@ -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. diff --git a/packages/client/ui-subagent/README.zh.md b/packages/client/ui-subagent/README.zh.md index 75c807c8e4..05ab093cf3 100644 --- a/packages/client/ui-subagent/README.zh.md +++ b/packages/client/ui-subagent/README.zh.md @@ -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 目录推导,也包括从未选中过的祖先。 diff --git a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx index 66d1371df4..6913d11043 100644 --- a/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx +++ b/packages/client/ui-subagent/src/client/SubagentHeaderLineage.tsx @@ -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 } @@ -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(() => 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() @@ -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 ( (select: (snapshot: SessionStatusSnapshot) => T): T => select(statuses), useSession: (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( 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) diff --git a/packages/client/ui-workspace/README.i18n.yaml b/packages/client/ui-workspace/README.i18n.yaml index 5a25bdecee..f4c43dca25 100644 --- a/packages/client/ui-workspace/README.i18n.yaml +++ b/packages/client/ui-workspace/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-workspace/README.md -README.md: 7cea227f20b32d965a9a08ff20324d75932b26db -README.zh.md: c7a76d7671df3fcd0fb3e684f21fa4f38c180a38 +README.md: 9a5f6939b3a2c66c83f9570bdeb74563d2c51ad0 +README.zh.md: 9c21256fdfdbd85859f12dc3f155a5c5c59805d1 diff --git a/packages/client/ui-workspace/README.md b/packages/client/ui-workspace/README.md index 7cea227f20..9a5f6939b3 100644 --- a/packages/client/ui-workspace/README.md +++ b/packages/client/ui-workspace/README.md @@ -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. ## Understand the implementation diff --git a/packages/client/ui-workspace/README.zh.md b/packages/client/ui-workspace/README.zh.md index c7a76d7671..9c21256fdf 100644 --- a/packages/client/ui-workspace/README.zh.md +++ b/packages/client/ui-workspace/README.zh.md @@ -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 选中样式;仅把焦点移到搜索框或目录选择器不会离开该面板。 ## 理解实现 diff --git a/packages/client/ui-workspace/src/client/navigation.ts b/packages/client/ui-workspace/src/client/navigation.ts index 650a7651e2..905b36317b 100644 --- a/packages/client/ui-workspace/src/client/navigation.ts +++ b/packages/client/ui-workspace/src/client/navigation.ts @@ -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 { 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 { 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) } } diff --git a/packages/client/ui-workspace/tests/tree.client.spec.ts b/packages/client/ui-workspace/tests/tree.client.spec.ts index 4f1f0b3097..859802e094 100644 --- a/packages/client/ui-workspace/tests/tree.client.spec.ts +++ b/packages/client/ui-workspace/tests/tree.client.spec.ts @@ -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([ diff --git a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts index 5560b62082..3afa813fd6 100644 --- a/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts +++ b/packages/client/ui-workspace/tests/workspaces-service.client.spec.ts @@ -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() + 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'), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b6094c4927..b6ade6eb11 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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