From 40929d6e1ad983e8ebeed94e4639e1f4bbf22b64 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Thu, 27 Aug 2026 20:49:26 +0800 Subject: [PATCH] refactor(client): replace host description consumers --- .../tests/agent-preset-authoring.overlay.yml | 6 +- apps/web/tests/produced-files.overlay.yml | 2 +- packages/api/session-controller/src/index.ts | 19 ++++- .../tests/client-apply.client.spec.ts | 55 ++++--------- .../tests/fake-api.client.ts | 21 +---- .../session-open-workspace-path.host.spec.ts | 33 ++++++++ .../session-controller/tests/test-remote.ts | 18 ++++- packages/api/settings-controller/src/index.ts | 9 +++ .../tests/settings-controller.host.spec.ts | 3 + .../tests/transport.client.spec.ts | 11 --- .../ui-agent-preset/src/client/index.ts | 6 +- .../src/client/section-store.ts | 7 +- .../tests/apply.client.spec.ts | 14 +--- .../tests/section-store.client.spec.ts | 40 +++------- packages/client/ui-deliverables/package.json | 4 + .../src/client/ProducedFiles.tsx | 16 ++-- .../ui-deliverables/src/client/index.ts | 35 +++++++- .../tests/produced-files.client.spec.tsx | 80 ++++++++++++++++--- packages/client/ui-deliverables/tsconfig.json | 6 ++ .../client/ui-reference/src/client/index.ts | 2 +- .../tests/browser-plugin.client.spec.ts | 4 +- packages/client/ui-tool/src/client/apply.ts | 2 +- .../ui-tool/src/client/contract/slots.ts | 12 +-- packages/client/ui-tool/src/client/index.ts | 2 +- .../ui-tool/src/client/tool/ToolCallTree.tsx | 4 +- .../ui-tool/src/client/tool/ToolDetails.tsx | 6 +- .../tool/toolviews/ask-question-row.tsx | 2 +- .../tests/ask-question-row.client.spec.tsx | 4 +- .../tests/assembly-surfaces.client.spec.tsx | 3 +- .../tests/chat-code-subcalls.client.spec.tsx | 3 +- .../ui-tool/tests/read-card.client.spec.tsx | 4 +- .../tests/tool-call-tree.client.spec.tsx | 10 +-- .../tests/tool-details-render.client.tsx | 8 +- .../tests/toolview-slot.client.spec.tsx | 6 +- .../ui-workspace/src/client/contract/slots.ts | 4 +- .../client/ui-workspace/src/client/index.ts | 4 +- .../src/client/rows/WorkspaceBrowser.tsx | 4 +- .../ui-workspace/tests/apply.client.spec.ts | 4 +- .../tests/rename-assembly.client.spec.tsx | 2 +- .../tests/workspace-browser.client.spec.tsx | 6 +- 40 files changed, 283 insertions(+), 198 deletions(-) diff --git a/apps/web/tests/agent-preset-authoring.overlay.yml b/apps/web/tests/agent-preset-authoring.overlay.yml index d39b5307ea..3791809a7e 100644 --- a/apps/web/tests/agent-preset-authoring.overlay.yml +++ b/apps/web/tests/agent-preset-authoring.overlay.yml @@ -1,14 +1,12 @@ # The authoring lane drives the location affordance. A real desktop open # would pop a file manager on the machine running the tests and the # capability itself is platform-detected (macOS yes, headless Linux CI no), -# so the gateway is pinned headless: `hasDocument` is false everywhere and +# so both native-open owners are pinned headless: `hasDocument` is false everywhere and # `openDocument` answers the directory as text — the same branch on every # host, and the one whose rendering a golden can hold. A patch replaces the # row's complete config, so the shipped routing defaults ride along. -- id: api-gateway +- id: session-controller config: - provider: deepseek-official - model: deepseek-v4-flash nativeOpen: false - id: settings-controller config: diff --git a/apps/web/tests/produced-files.overlay.yml b/apps/web/tests/produced-files.overlay.yml index ceac487918..4267b6d2cf 100644 --- a/apps/web/tests/produced-files.overlay.yml +++ b/apps/web/tests/produced-files.overlay.yml @@ -1,7 +1,7 @@ # The summary test asserts the native-folder action without launching it. Pin # the capability so headless Linux CI and desktop developer hosts expose the # same UI branch; platform opener behavior belongs to the Host unit tests. -- id: api-gateway +- id: session-controller config: nativeOpen: true - id: settings-controller diff --git a/packages/api/session-controller/src/index.ts b/packages/api/session-controller/src/index.ts index b58cdfcbad..342dd977eb 100644 --- a/packages/api/session-controller/src/index.ts +++ b/packages/api/session-controller/src/index.ts @@ -3,7 +3,7 @@ import { Context } from '@deepseek-ai/cordis' import z from '@deepseek-ai/schemastery' import { errorChain } from '@deepseek-ai/dsh-llm' -import { openNativePath } from '@deepseek-ai/dsh-native-command' +import { canOpenNativePath, openNativePath } from '@deepseek-ai/dsh-native-command' import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionObservation } from '@deepseek-ai/dsh-session-query' import { Remote, TypertRemoteFailure, TypertRemoteService } from '@deepseek-ai/dsh-typert-protocol' @@ -67,12 +67,16 @@ declare module '@deepseek-ai/cordis' { export interface Config { /** Maximum cold Session artifact size eligible for one full projection observation. */ readonly coldBlankProbeMaxBytes?: number + /** Override platform desktop-opener detection. */ + readonly nativeOpen?: boolean } /** Host integrations replaceable by direct unit tests. */ export interface SessionControllerInternals { /** Native default-application handoff. */ readonly openPath?: (path: string, signal: AbortSignal) => Promise + /** Native handoff availability probe. */ + readonly canOpenPath?: () => boolean } /** Host service backing the generated `ctx.remote.session` namespace. */ @@ -91,6 +95,7 @@ export class SessionController extends TypertRemoteService { static Config: z = z.object({ coldBlankProbeMaxBytes: z.natural().default(DEFAULT_COLD_BLANK_PROBE_MAX_BYTES), + nativeOpen: z.boolean(), }) private readonly agents: ApiSessionAgentController @@ -99,6 +104,7 @@ export class SessionController extends TypertRemoteService { private readonly history: SessionHistoryController private readonly listState: ApiSessionList private readonly openPath: (path: string, signal: AbortSignal) => Promise + private readonly canOpenPath: () => boolean private readonly promotions = new Set>() /** @@ -122,6 +128,8 @@ export class SessionController extends TypertRemoteService { config.coldBlankProbeMaxBytes ?? DEFAULT_COLD_BLANK_PROBE_MAX_BYTES, ) this.openPath = internals.openPath ?? openNativePath + this.canOpenPath = internals.canOpenPath + ?? (() => config.nativeOpen ?? (internals.openPath !== undefined || canOpenNativePath())) ctx.plugin(SessionFileReferences) ctx.plugin(SessionSkillCatalog) @@ -242,6 +250,15 @@ export class SessionController extends TypertRemoteService { return buildModelCatalog(this.ctx) } + /** + * Report whether this deployment can hand a Session workspace path to a native desktop. + * @returns true when the matching open operation is available. + */ + @Remote + canOpenWorkspacePath(): boolean { + return this.canOpenPath() + } + /** * Open one path prepared by a Session-aware caller on the Host desktop. * @param request - path after best-effort Session workspace resolution. diff --git a/packages/api/session-controller/tests/client-apply.client.spec.ts b/packages/api/session-controller/tests/client-apply.client.spec.ts index b6c7e40972..2ecaff6804 100644 --- a/packages/api/session-controller/tests/client-apply.client.spec.ts +++ b/packages/api/session-controller/tests/client-apply.client.spec.ts @@ -1,8 +1,8 @@ import { Context } from '@deepseek-ai/cordis' import type { Fiber } from '@deepseek-ai/cordis' import type { + ConnectionGeneration, ConnectionHandle, - HostDescription, } from '@deepseek-ai/dsh-client-connection/client' import { RemoteStreamCarrierError, @@ -16,13 +16,7 @@ import * as SessionClient from '../src/client/index.ts' import { ClientSessions } from '../src/client/sessions/service.ts' import { FakeApiClient, fakeRemote } from './fake-api.client.ts' -const DESCRIPTION: HostDescription = { - version: 'fixture', - cwd: '/fixture', - attachedSessions: 0, - home: '/home/fixture', - canOpenPath: true, -} +const GENERATION: ConnectionGeneration = { id: 1, host: { home: '/home/fixture' } } const sid = (value: string): SessionId => value as SessionId @@ -34,7 +28,7 @@ interface Bench { readonly fiber: Fiber readonly sessions: ClientSessions dispatch(event: string, ...args: unknown[]): void - publishHost(description: HostDescription | undefined): void + publishGeneration(generation: ConnectionGeneration | undefined): void } const contexts = new Set() @@ -45,41 +39,22 @@ afterEach(async () => { contexts.clear() }) -async function mount(initialHost?: HostDescription): Promise { +async function mount(initialGeneration?: ConnectionGeneration): Promise { const ctx = new Context() contexts.add(ctx) await ctx.plugin(TypertRegistry) const api = new FakeApiClient() const remote = fakeRemote(api) const listeners = new Map>() - const hostListeners = new Set<() => void>() - let host = initialHost + const generationListeners = new Set<() => void>() + let generation = initialGeneration const connection: ConnectionHandle = { - api, isLoopback: true, - hostDescription: { - getSnapshot: () => host, - subscribe: (listener) => { - hostListeners.add(listener) - return () => { hostListeners.delete(listener) } - }, - }, generation: { - getSnapshot: () => host === undefined - ? undefined - : { id: 1, host: { home: host.home } }, + getSnapshot: () => generation, subscribe: (listener) => { - hostListeners.add(listener) - return () => { hostListeners.delete(listener) } - }, - }, - generation: { - getSnapshot: () => host === undefined - ? undefined - : { id: 1, host: { home: host.home } }, - subscribe: (listener) => { - hostListeners.add(listener) - return () => { hostListeners.delete(listener) } + generationListeners.add(listener) + return () => { generationListeners.delete(listener) } }, }, rpc: { @@ -115,9 +90,9 @@ async function mount(initialHost?: HostDescription): Promise { dispatch: (event, ...args) => { for (const listener of listeners.get(event) ?? []) listener(...args as never[]) }, - publishHost: (description) => { - host = description - for (const listener of [...hostListeners]) listener() + publishGeneration: (next) => { + generation = next + for (const listener of [...generationListeners]) listener() }, } } @@ -166,7 +141,7 @@ describe('Session Controller Client apply', () => { it('accepts the control baseline, retries a carrier generation, and reports terminal protocol failure', async () => { const accept = vi.spyOn(ClientSessions.prototype, 'handleControlFrame') const logged = vi.spyOn(console, 'error').mockImplementation(() => {}) - const bench = await mount(DESCRIPTION) + const bench = await mount(GENERATION) await flush() expect(accept).toHaveBeenCalledWith({ @@ -198,7 +173,7 @@ describe('Session Controller Client apply', () => { }) it('projects Agent Context identity in both directions and withdraws the adapter on disposal', async () => { - const bench = await mount(DESCRIPTION) + const bench = await mount(GENERATION) await flush() expect(bench.sessions.list.getSnapshot().phase).toBe('ready') @@ -230,7 +205,7 @@ describe('Session Controller Client apply', () => { await flush() expect(accept.mock.calls.filter(([frame]) => frame.type === 'baseline')).toHaveLength(1) - bench.publishHost(DESCRIPTION) + bench.publishGeneration(GENERATION) await flush() expect(accept.mock.calls.filter(([frame]) => frame.type === 'baseline')).toHaveLength(2) }) diff --git a/packages/api/session-controller/tests/fake-api.client.ts b/packages/api/session-controller/tests/fake-api.client.ts index 851ce01030..cb6a635bef 100644 --- a/packages/api/session-controller/tests/fake-api.client.ts +++ b/packages/api/session-controller/tests/fake-api.client.ts @@ -1,8 +1,8 @@ -// Test-local programmable IApiClient fake (NOT the fixture: fixture is a demo +// Test-local programmable Remote fake (NOT the fixture: fixture is a demo // data source on a real clock; behavior tests need per-case responses and // deferred-controlled timing). Session streams are hand pumps: pushFollow/pushControl. import type { - IApiClient, MessageId, + MessageId, RpcError, RpcResponse, SessionId, SessionSearchItem, SubagentCatalog, SubagentInterruptReceipt, SubagentPromptReceipt, WorkspaceId, WorkspaceView, @@ -122,7 +122,7 @@ export function fakeRemote(api = new FakeApiClient()): RuntimeRemotes { return api.sessionRemotes() } -export class FakeApiClient implements IApiClient { +export class FakeApiClient { /** Chronological call record: [method, payload]. */ readonly calls: { method: string; payload: unknown }[] = [] /** Session ids in physical follow-generation opening order. */ @@ -157,16 +157,6 @@ export class FakeApiClient implements IApiClient { onOpenWorkspacePath: (payload: unknown) => Promise> = () => Promise.resolve(remoteOk({ opened: true as const })) - onDescribe: (payload: unknown) => Promise> = - () => Promise.resolve(ok({ - version: '0-fake', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true, - })) private readonly followConns = new Map[]>() private readonly controlConns: ValueStreamConn[] = [] private readonly workspaceConns: ValueStreamConn[] = [] @@ -191,10 +181,6 @@ export class FakeApiClient implements IApiClient { onSubagentInterrupt: (payload: unknown) => Promise> = () => Promise.resolve(remoteOk({ accepted: true as const })) - readonly host: IApiClient['host'] = { - describe: (payload: unknown) => this.record('host.describe', payload, this.onDescribe(payload)), - } - onWorkspaceCreate: (payload: unknown) => Promise> = () => Promise.resolve(remoteOk({ workspace: fakeWorkspace('fk-ws'), created: true })) @@ -223,6 +209,7 @@ export class FakeApiClient implements IApiClient { execute: () => Promise.resolve({ ok: true, value: undefined }), }, session: { + canOpenWorkspacePath: () => Promise.resolve(remoteOk(true)), list: payload => this.remoteResult('session.list', payload, this.onList(payload)), modelCatalog: () => Promise.resolve({ ok: true, diff --git a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts index ee24947227..2c1feb292f 100644 --- a/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts +++ b/packages/api/session-controller/tests/session-open-workspace-path.host.spec.ts @@ -15,6 +15,39 @@ async function context(): Promise { } describe('session/openWorkspacePath', () => { + it('reports the deployment opener capability independently of a Session', async () => { + const ctx = await context() + const remote = createSessionTestRemote(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), + cwd: '/default', + canOpenPath: () => false, + }) + + await expect(remote.canOpenWorkspacePath()).resolves.toEqual({ ok: true, value: false }) + }) + + it('derives opener availability from config, an injected opener, or the platform probe', async () => { + const configured = createSessionTestRemote(await context(), { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), + cwd: '/default', + nativeOpen: false, + }) + await expect(configured.canOpenWorkspacePath()).resolves.toEqual({ ok: true, value: false }) + + const injected = createSessionTestRemote(await context(), { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), + cwd: '/default', + openPath: () => Promise.resolve(), + }) + await expect(injected.canOpenWorkspacePath()).resolves.toEqual({ ok: true, value: true }) + + const detected = createSessionTestRemote(await context(), { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), + cwd: '/default', + }) + await expect(detected.canOpenWorkspacePath()).resolves.toMatchObject({ ok: true }) + }) + it('hands a Client-resolved workspace path to the Host opener unchanged', async () => { const ctx = await context() const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve()) diff --git a/packages/api/session-controller/tests/test-remote.ts b/packages/api/session-controller/tests/test-remote.ts index 77e813b324..e2e6292bce 100644 --- a/packages/api/session-controller/tests/test-remote.ts +++ b/packages/api/session-controller/tests/test-remote.ts @@ -51,6 +51,7 @@ import type { /** Direct test face matching the generated `ctx.remote.session` unary methods. */ export interface TestSessionRemote { + canOpenWorkspacePath(): Promise> list(request: SessionListRequest, signal?: AbortSignal): Promise> search(request: SessionSearchRequest, signal?: AbortSignal): Promise> create(request: SessionCreateRequest): Promise> @@ -76,8 +77,10 @@ export interface TestSessionRemoteDefaults { readonly defaultModelSelection: () => AgentModelSelection readonly cwd: string readonly coldBlankProbeMaxBytes?: number + readonly nativeOpen?: boolean readonly saveDefaultModelSelection?: (selection: AgentModelSelection) => void | Promise readonly openPath?: (path: string, signal: AbortSignal) => Promise + readonly canOpenPath?: () => boolean } const installed = new WeakMap() @@ -185,10 +188,16 @@ function installControllers( try { controller = new SessionController( ctx, - defaults.coldBlankProbeMaxBytes === undefined - ? {} - : { coldBlankProbeMaxBytes: defaults.coldBlankProbeMaxBytes }, - defaults.openPath === undefined ? {} : { openPath: defaults.openPath }, + { + ...defaults.coldBlankProbeMaxBytes === undefined + ? {} + : { coldBlankProbeMaxBytes: defaults.coldBlankProbeMaxBytes }, + ...defaults.nativeOpen === undefined ? {} : { nativeOpen: defaults.nativeOpen }, + }, + { + ...defaults.openPath === undefined ? {} : { openPath: defaults.openPath }, + ...defaults.canOpenPath === undefined ? {} : { canOpenPath: defaults.canOpenPath }, + }, ) } finally { cwd.mockRestore() @@ -233,6 +242,7 @@ export function createSessionTestRemote( ): TestSessionRemote { const direct = createSessionTestController(ctx, defaults) return { + canOpenWorkspacePath: () => remoteResult(() => direct.canOpenWorkspacePath()), list: (request, signal = new AbortController().signal) => remoteResult( () => direct.list(request, signal), signal, diff --git a/packages/api/settings-controller/src/index.ts b/packages/api/settings-controller/src/index.ts index e822d7f489..5fa81d1518 100644 --- a/packages/api/settings-controller/src/index.ts +++ b/packages/api/settings-controller/src/index.ts @@ -128,6 +128,15 @@ export class SettingsController extends TypertRemoteService { } } + /** + * Report whether this deployment can open an authored Agent preset directory natively. + * @returns true when the matching open operation is available. + */ + @Remote + canOpenAgentPresetDirectory(): boolean { + return this.canOpenPath() + } + /** * Merge a patch into one namespace's stored user section. * @param ns - namespace key to write. diff --git a/packages/api/settings-controller/tests/settings-controller.host.spec.ts b/packages/api/settings-controller/tests/settings-controller.host.spec.ts index aa8945b4fc..7195e6650f 100644 --- a/packages/api/settings-controller/tests/settings-controller.host.spec.ts +++ b/packages/api/settings-controller/tests/settings-controller.host.spec.ts @@ -82,6 +82,7 @@ describe('the settings Remote namespace a configuration page calls', () => { expect(controller.typertRemote.namespace).toBe('settings') expect(remoteMethods(controller)).toEqual([ { method: 'describe', invocation: { kind: 'direct' } }, + { method: 'canOpenAgentPresetDirectory', invocation: { kind: 'direct' } }, { method: 'update', invocation: { kind: 'direct' } }, { method: 'replace', invocation: { kind: 'direct' } }, { method: 'mutate', invocation: { kind: 'direct' } }, @@ -348,6 +349,7 @@ describe('the settings Remote namespace a configuration page calls', () => { } as never) const openPath = vi.fn((_path: string, _signal: AbortSignal) => Promise.resolve()) const openable = new SettingsController(ctx, { nativeOpen: true }, { openPath }) + expect(openable.canOpenAgentPresetDirectory()).toBe(true) const signal = new AbortController().signal await expect(openable.openAgentPresetDirectory('mine', signal)) .resolves.toEqual({ opened: true }) @@ -360,6 +362,7 @@ describe('the settings Remote namespace a configuration page calls', () => { }), } as never) const reveal = new SettingsController(headless, { nativeOpen: false }) + expect(reveal.canOpenAgentPresetDirectory()).toBe(false) await expect(reveal.openAgentPresetDirectory('mine', new AbortController().signal)) .resolves.toEqual({ opened: false, path: '/presets/mine' }) }) diff --git a/packages/api/workspace-controller/tests/transport.client.spec.ts b/packages/api/workspace-controller/tests/transport.client.spec.ts index 14cf3a02db..bdd01d33ea 100644 --- a/packages/api/workspace-controller/tests/transport.client.spec.ts +++ b/packages/api/workspace-controller/tests/transport.client.spec.ts @@ -197,18 +197,7 @@ async function waitFor(check: () => void): Promise { function provideClientServices(ctx: Context, remote: WorkspaceRemote): void { const connection: ConnectionHandle = { - api: {} as ConnectionHandle['api'], isLoopback: true, - hostDescription: { - getSnapshot: () => ({ - version: 'fixture', - cwd: '/fixture', - attachedSessions: 0, - home: '/home/fixture', - canOpenPath: true, - }), - subscribe: () => () => {}, - }, generation: AVAILABLE_CONNECTION.generation, rpc: { call: () => Promise.reject(new Error('unexpected generic RPC call')), diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index 4019655d72..4926d7c001 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -11,7 +11,6 @@ * before-the-fact, while the header only reports what a session already runs. */ -import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client' // Type-only: pulls the Session Controller service merge (ctx.sessions). import type {} from '@deepseek-ai/dsh-api-session-controller/client' // Type-only: pulls the locale plugin's Context merge (ctx.locale). @@ -51,7 +50,7 @@ export { AGENT_PRESET_SETTINGS_NS, writeDefaultPreset } from './settings-store.t /** Required services (cordis fiber inject). */ export const inject = [ - 'slots', 'locale', 'connection', 'remote', 'remote.agentPresets', 'remote.settings', 'settingsScope', + 'slots', 'locale', 'remote', 'remote.agentPresets', 'remote.settings', 'settingsScope', ] /** @@ -59,13 +58,12 @@ export const inject = [ * @param ctx - the browser plugin context. */ export function apply(ctx: ClientContext): void { - const { api } = ctx.get('connection') as ConnectionHandle const settingsWire = { settings: ctx.remote.settings } const controller = new AgentPresetSettingsController(settingsWire, ctx.remote, ctx.settingsScope.describe()) // One roster, four surfaces. The chip is registered in a later scope, so it // subscribes here rather than being reached from this one. const rosterReaders = new Set<() => void>() - const section = new AgentPresetSectionController(api, ctx.remote, () => { + const section = new AgentPresetSectionController(ctx.remote, () => { void controller.load() for (const read of rosterReaders) read() }) diff --git a/packages/client/ui-agent-preset/src/client/section-store.ts b/packages/client/ui-agent-preset/src/client/section-store.ts index 70baab32ec..43db65686d 100644 --- a/packages/client/ui-agent-preset/src/client/section-store.ts +++ b/packages/client/ui-agent-preset/src/client/section-store.ts @@ -14,7 +14,7 @@ * more than the row it targeted. */ -import type { ClientRemote, IApiClient } from '@deepseek-ai/dsh-api-remotes/client' +import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import { beginRosterRead, messageOf, writeDefaultPreset } from './settings-store.ts' @@ -133,7 +133,6 @@ export class AgentPresetSectionController { readonly store: SnapshotStore = createSnapshotStore(INITIAL) constructor( - private readonly api: Pick, private readonly remote: Pick, /** * Called after this page changes the roster DIRECTORY, so the other @@ -168,13 +167,13 @@ export class AgentPresetSectionController { // Issued together: one round trip decides the page, and a load that waited // for them in turn would hold the section in `loading` twice as long, // where a concurrent reload silently returns instead of refreshing. - const opener = this.api.host.describe({}) + const opener = this.remote.settings.canOpenAgentPresetDirectory() const roster = await beginRosterRead(this.remote, this.store) // A refused describe leaves the reveal-the-path path, which needs no opener. const described = await opener.catch(() => undefined) if (roster === undefined) return const { presets, authorable } = roster - const hasDocument = described?.result.ok === true && described.result.value.canOpenPath + const hasDocument = described?.ok === true && described.value if (presets.length === 0) { // Nothing to manage leaves nothing to keep a dialog open over. this.set({ status: 'unavailable', rows: [], authorable, hasDocument, copy: null, view: null }) 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 b7e73785e4..c2488de4af 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 @@ async function bench() { // The row reads `describe` to learn whether this browser may write at all, // and its default write is the one op this spec records. const settings = { + canOpenAgentPresetDirectory: () => Promise.resolve({ ok: true as const, value: true }), describe: () => Promise.resolve({ ok: true as const, value: { writable: true, hasDocument: true, namespaces: [] }, @@ -114,16 +115,7 @@ async function bench() { } ctx.provide('remote.agentPresets', agentPresets as never) Object.assign(remote, { agentPresets }) - ctx.provide('connection', { - api: { - host: { - describe: () => Promise.resolve({ - rpcId: 'r', - result: { ok: true as const, value: { canOpenPath: true } }, - }), - }, - }, - } as never) + ctx.provide('connection', { isLoopback: true } as never) await ctx.plugin({ inject: [...settingsInject], apply: settingsApply }).await() return { ctx, slots: ctx.get('slots') as SlotRegistry, calls, moveDefault, remote } } @@ -185,7 +177,7 @@ function sessionsDouble(state: { describe('ui-agent-preset apply', () => { it('declares the services it uses', () => { expect(inject).toEqual([ - 'slots', 'locale', 'connection', 'remote', 'remote.agentPresets', 'remote.settings', 'settingsScope', + 'slots', 'locale', 'remote', 'remote.agentPresets', 'remote.settings', 'settingsScope', ]) }) diff --git a/packages/client/ui-agent-preset/tests/section-store.client.spec.ts b/packages/client/ui-agent-preset/tests/section-store.client.spec.ts index df1ad1e201..0b732c4cf5 100644 --- a/packages/client/ui-agent-preset/tests/section-store.client.spec.ts +++ b/packages/client/ui-agent-preset/tests/section-store.client.spec.ts @@ -7,7 +7,7 @@ */ import { describe, expect, it } from 'vitest' -import type { ClientRemote, IApiClient } from '@deepseek-ai/dsh-api-remotes/client' +import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' import { AgentPresetSectionController, draftBlocker } from '../src/client/section-store.ts' import type { CopyDraft, PresetRow } from '../src/client/section-store.ts' @@ -41,36 +41,16 @@ interface FakeOptions { authorable?: boolean /** Whether the host can open a preset directory on a desktop. */ hasDocument?: boolean - /** Reject `host.describe`, as a dead transport does. */ - throwDescribe?: boolean + /** Reject the opener capability read, as a dead transport does. */ + throwCapability?: boolean /** Hold `remove` until this resolves, to observe the in-flight state. */ holdRemove?: Promise } -const ok = (value: unknown) => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value } }) const remoteOk = (value: unknown) => Promise.resolve({ ok: true as const, value }) const remoteFail = (message: string) => Promise.resolve({ ok: false as const, error: { code: 'internal', message, details: {} } }) -/** - * The carried wire face: the desktop opener, the default write, and the opener - * capability the page joins onto the roster. - * @param defaultId - the preset a session with no choice gets. - * @param options - failure injection and call recording. - * @returns the fake client. - */ -function fakeApi( - options: FakeOptions = {}, -): Pick { - return { - host: { - describe: () => (options.throwDescribe === true - ? Promise.reject(new Error('socket closed')) - : ok({ canOpenPath: options.hasDocument ?? true })), - }, - } as Pick -} - /** * The Remote namespace over an in-memory preset store: copies land, so the * roster the controller re-reads after a copy is the one the copy produced. @@ -143,6 +123,12 @@ function fakeRemote( }, }, settings: { + canOpenAgentPresetDirectory: () => { + record('canOpenAgentPresetDirectory', {}) + return options.throwCapability === true + ? Promise.reject(new Error('socket closed')) + : remoteOk(options.hasDocument ?? true) + }, update: (ns: string, patch: { default?: string }) => { record('settings.update', { ns, patch }) if (options.failSettings !== undefined) return remoteFail(options.failSettings) @@ -176,7 +162,6 @@ function harness(options: FakeOptions = {}) { let rosterChanges = 0 const wired = { ...options, calls: options.calls ?? calls } const controller = new AgentPresetSectionController( - fakeApi(wired), fakeRemote(presets, defaultId, wired), () => { rosterChanges += 1 }, ) @@ -191,11 +176,11 @@ function copyOf(controller: AgentPresetSectionController): CopyDraft { describe('loading the roster', () => { it('still lists the roster when the opener capability cannot be read', async () => { - const { controller } = harness({ throwDescribe: true }) + const { controller } = harness({ throwCapability: true }) await controller.load() - // The two reads are independent: a refused `host.describe` costs the + // The two reads are independent: a refused capability query costs the // open-directory affordance, not the page. const state = controller.store.getSnapshot() expect(state.status).toBe('ready') @@ -572,7 +557,6 @@ describe('deleting', () => { await controller.load() presets.clear() const broken = new AgentPresetSectionController( - { host: {} } as unknown as Pick, { agentPresets: { list: () => Promise.reject(new Error('gone')), @@ -596,7 +580,7 @@ describe('a controller with no roster listener', () => { const presets = seed() const defaultId = { id: 'standard' } const alone = new AgentPresetSectionController( - fakeApi(), fakeRemote(presets, defaultId)) + fakeRemote(presets, defaultId)) await alone.load() alone.confirmDelete('mine') diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index 30b6de4e7f..15dce11815 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -32,6 +32,7 @@ "dsh": { "client": { "inject": [ + "@deepseek-ai/dsh-api-remotes", "@deepseek-ai/dsh-client-connection", "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-ui-chat", @@ -47,6 +48,7 @@ }, "license": "MIT", "peerDependencies": { + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-ui-chat": "workspace:^", @@ -58,8 +60,10 @@ "@deepseek-ai/dsh-session": "workspace:^" }, "devDependencies": { + "@deepseek-ai/dsh-api-remotes": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-store": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-chat": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", diff --git a/packages/client/ui-deliverables/src/client/ProducedFiles.tsx b/packages/client/ui-deliverables/src/client/ProducedFiles.tsx index 6841e734e7..b3f0f04c0a 100644 --- a/packages/client/ui-deliverables/src/client/ProducedFiles.tsx +++ b/packages/client/ui-deliverables/src/client/ProducedFiles.tsx @@ -1,6 +1,5 @@ -import { useLayoutEffect, useRef, useState } from 'react' -import type { HostDescriptionSource } from '@deepseek-ai/dsh-client-connection/client' -import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' +import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import type { HostObservable, InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client' import { basename } from './turn-deliverables.ts' import type { NS } from './locales.ts' @@ -44,9 +43,11 @@ export function fitProducedFiles( export interface ProducedFilesInjected { /** Whether the browser itself is connected over loopback. */ isLoopback: boolean + /** Load the opener capability when this row first reaches the page. */ + ensureWorkspacePathOpen(): void hooks: { - /** Current generation's Host description, bound by the slot renderer. */ - hostDescription: HostDescriptionSource + /** Current generation's Session workspace opener capability. */ + workspacePathOpen: HostObservable } } @@ -65,9 +66,10 @@ function moreLabel(t: ProducedFilesProps['t'], count: number): string { * @returns The produced-files row. */ export function ProducedFiles({ - matched: paths, openFile, isLoopback, useHostDescription, t, + matched: paths, openFile, isLoopback, ensureWorkspacePathOpen, useWorkspacePathOpen, t, }: ProducedFilesProps) { - const hostCanOpenPath = useHostDescription(description => description?.canOpenPath === true) + useEffect(() => { ensureWorkspacePathOpen() }, [ensureWorkspacePathOpen]) + const hostCanOpenPath = useWorkspacePathOpen(available => available === true) const canOpenPath = isLoopback && hostCanOpenPath const limit = Math.min(paths.length, SHOWN_LIMIT) const [shownCount, setShownCount] = useState(limit) diff --git a/packages/client/ui-deliverables/src/client/index.ts b/packages/client/ui-deliverables/src/client/index.ts index 0e8767016e..3be17e7da4 100644 --- a/packages/client/ui-deliverables/src/client/index.ts +++ b/packages/client/ui-deliverables/src/client/index.ts @@ -9,6 +9,8 @@ */ import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/dsh-api-remotes/client' +import { createSnapshotStore } from '@deepseek-ai/dsh-client-store' import type { ChatFileMentions } from '@deepseek-ai/dsh-client-ui-chat/client' import type {} from '@deepseek-ai/dsh-client-locale/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -30,7 +32,7 @@ export { ProducedFiles, type ProducedFilesProps } from './ProducedFiles.tsx' export { producedForClosing } from './turn-deliverables.ts' /** Required services for the tail-slot registration and its dictionaries. */ -export const inject = ['slots', 'locale', 'uiConversation', 'connection'] +export const inject = ['slots', 'locale', 'uiConversation', 'connection', 'remote', 'remote.session'] /** * Client plugin body: register the dictionaries and the turn-tail entry. @@ -38,6 +40,34 @@ export const inject = ['slots', 'locale', 'uiConversation', 'connection'] */ export function apply(ctx: ClientContext): void { const connection = ctx.get('connection') as ConnectionHandle + const workspacePathOpen = createSnapshotStore(undefined) + let requestedWorkspacePathOpen = false + let capabilityRevision = 0 + let pendingCapability: Promise | undefined + const loadWorkspacePathOpen = (): void => { + if (pendingCapability !== undefined) return + const revision = capabilityRevision + const pending = ctx.remote.session.canOpenWorkspacePath() + .then((result) => { + if (revision === capabilityRevision) workspacePathOpen.set(result.ok && result.value) + }, () => { + if (revision === capabilityRevision) workspacePathOpen.set(false) + }) + .finally(() => { + if (pendingCapability === pending) pendingCapability = undefined + }) + pendingCapability = pending + } + const ensureWorkspacePathOpen = (): void => { + requestedWorkspacePathOpen = true + if (workspacePathOpen.getSnapshot() === undefined) loadWorkspacePathOpen() + } + ctx.on('connection/reset', () => { + capabilityRevision++ + pendingCapability = undefined + workspacePathOpen.set(undefined) + if (requestedWorkspacePathOpen) loadWorkspacePathOpen() + }) ctx.uiConversation.events.register(deliverablesDefinition) ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-deliverables: dictionaries') ctx.slots.inject( @@ -48,7 +78,8 @@ export function apply(ctx: ClientContext): void { locale: NS, inject: () => ({ isLoopback: connection.isLoopback, - hooks: { hostDescription: connection.hostDescription }, + ensureWorkspacePathOpen, + hooks: { workspacePathOpen }, }), }, ProducedFiles), ) diff --git a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx index 9117fa3925..5a0043971e 100644 --- a/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.client.spec.tsx @@ -22,7 +22,7 @@ import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-c import type { ChatFileMentions, TurnTailOwnerProps } from '@deepseek-ai/dsh-client-ui-chat/client' import { makeTranslate, stubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import { - fitProducedFiles, ProducedFiles, type ProducedFilesProps, + fitProducedFiles, ProducedFiles, type ProducedFilesInjected, type ProducedFilesProps, } from '../src/client/ProducedFiles.tsx' import { basename, deliverablesDefinition, producedFileMentions, producedForClosing, selectProducedFiles, @@ -403,13 +403,11 @@ describe('ProducedFiles row', () => { const capability = ( canOpenPath: boolean | undefined, isLoopback = true, - ): Pick => { - const description = canOpenPath === undefined - ? undefined - : { version: 'test', cwd: '/workspace', attachedSessions: 1, home: '/h', canOpenPath } + ): Pick => { return { isLoopback, - useHostDescription: selector => selector(description), + ensureWorkspacePathOpen: () => {}, + useWorkspacePathOpen: selector => selector(canOpenPath), } } @@ -580,14 +578,17 @@ describe('plugin registration', () => { name: 'root', children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' } }, } as never, () => null) - const hostDescription = { getSnapshot: () => undefined, subscribe: () => () => {} } + const generation = { getSnapshot: () => undefined, subscribe: () => () => {} } ctx.provide('connection', { - api: { settings: {} }, isLoopback: false, - hostDescription, + generation, } as never) // ui-theme's Appearance row binds a durable scope through these two. - ctx.provide('remote', { $on: () => () => {} } as never) + const session = { + canOpenWorkspacePath: () => Promise.resolve({ ok: true as const, value: true }), + } + ctx.provide('remote', { $on: () => () => {}, session } as never) + ctx.provide('remote.session', session as never) ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) await ctx.plugin({ inject: localeInject, apply: applyLocale }).await() @@ -595,7 +596,16 @@ describe('plugin registration', () => { await fiber.await() const [entry] = ctx.slots.entries('conversation.chat.turnTail') expect(entry).toBeDefined() - expect(entry?.inject?.()).toEqual({ isLoopback: false, hooks: { hostDescription } }) + const injected = entry?.inject?.() as unknown as ProducedFilesInjected + expect(injected.isLoopback).toBe(false) + expect(typeof injected.ensureWorkspacePathOpen).toBe('function') + expect(injected.hooks.workspacePathOpen.getSnapshot()).toBeUndefined() + ctx.emit('connection/reset') + injected.ensureWorkspacePathOpen() + await vi.waitFor(() => { + expect(injected.hooks.workspacePathOpen.getSnapshot()).toBe(true) + }) + injected.ensureWorkspacePathOpen() // The prose face is live while the plugin is: a produced turn yields a // resolver whose matches open through the owner-supplied opener. @@ -617,4 +627,52 @@ describe('plugin registration', () => { // Fiber teardown retracts the service: the consumer's ctx.get sees the off state. expect((ctx as unknown as { get(name: string): unknown }).get('chatFileMentions')).toBeUndefined() }) + + it('queries the workspace opener lazily and replaces stale results after reconnect', async () => { + const ctx = new Context() + await ctx.plugin(SlotRegistry).await() + new UiConversation(ctx, { binding: () => undefined } as never) + ctx.slots.register({ + name: 'root', + children: { 'conversation.chat.turnTail': { kind: 'chain', scope: 'session' } }, + } as never, () => null) + ctx.provide('connection', { + isLoopback: true, + generation: { getSnapshot: () => undefined, subscribe: () => () => {} }, + } as never) + const first = Promise.withResolvers<{ ok: true; value: boolean }>() + const second = Promise.withResolvers<{ ok: true; value: boolean }>() + const staleFailure = Promise.withResolvers<{ ok: true; value: boolean }>() + const capability = vi.fn() + .mockReturnValueOnce(first.promise) + .mockReturnValueOnce(second.promise) + .mockReturnValueOnce(staleFailure.promise) + .mockRejectedValueOnce(new Error('offline')) + const session = { canOpenWorkspacePath: capability } + ctx.provide('remote', { $on: () => () => {}, session } as never) + ctx.provide('remote.session', session as never) + ctx.provide('settingsScope', { bind: () => stubSettingsScope().scope } as never) + await ctx.plugin({ inject: localeInject, apply: applyLocale }).await() + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + const entry = ctx.slots.entries('conversation.chat.turnTail')[0] + const injected = entry?.inject?.() as unknown as ProducedFilesInjected + + injected.ensureWorkspacePathOpen() + injected.ensureWorkspacePathOpen() + expect(capability).toHaveBeenCalledOnce() + ctx.emit('connection/reset') + expect(capability).toHaveBeenCalledTimes(2) + first.resolve({ ok: true, value: false }) + await Promise.resolve() + expect(injected.hooks.workspacePathOpen.getSnapshot()).toBeUndefined() + second.resolve({ ok: true, value: true }) + await vi.waitFor(() => { expect(injected.hooks.workspacePathOpen.getSnapshot()).toBe(true) }) + + ctx.emit('connection/reset') + ctx.emit('connection/reset') + staleFailure.reject(new Error('stale offline')) + await vi.waitFor(() => { expect(injected.hooks.workspacePathOpen.getSnapshot()).toBe(false) }) + await fiber.dispose() + }) }) diff --git a/packages/client/ui-deliverables/tsconfig.json b/packages/client/ui-deliverables/tsconfig.json index 51e6d50b5d..9c29b3efdc 100644 --- a/packages/client/ui-deliverables/tsconfig.json +++ b/packages/client/ui-deliverables/tsconfig.json @@ -8,6 +8,9 @@ "src" ], "references": [ + { + "path": "../../api/remotes/tsconfig.client.json" + }, { "path": "../../../vendor/cordis" }, @@ -17,6 +20,9 @@ { "path": "../locale" }, + { + "path": "../store" + }, { "path": "../ui-conversation" }, diff --git a/packages/client/ui-reference/src/client/index.ts b/packages/client/ui-reference/src/client/index.ts index c82adcdbe4..09ecf5aaa9 100644 --- a/packages/client/ui-reference/src/client/index.ts +++ b/packages/client/ui-reference/src/client/index.ts @@ -64,7 +64,7 @@ export function apply(ctx: ClientContext): void { // when there is no header to carry it. const withLocation = crumbsFor(query, quoted === true, drilled, t) === undefined const now = Date.now() - const home = connection.hostDescription.getSnapshot()?.home + const home = connection.generation.getSnapshot()?.host.home const listed = sessions.list.getSnapshot().byId return [ ...fileItems.flatMap(candidate => fileCandidate(candidate, quoted === true, withLocation, t)), diff --git a/packages/client/ui-reference/tests/browser-plugin.client.spec.ts b/packages/client/ui-reference/tests/browser-plugin.client.spec.ts index 03e19b1b27..4565e170db 100644 --- a/packages/client/ui-reference/tests/browser-plugin.client.spec.ts +++ b/packages/client/ui-reference/tests/browser-plugin.client.spec.ts @@ -92,7 +92,7 @@ async function bench( ctx.provide('remote.fileReferences', { list: files }) ctx.provide('remote.sessionReferenceResolver', { candidates: sessions }) ctx.provide('locale', new LocaleRuntime(ctx)) - ctx.provide('connection', { hostDescription: { getSnapshot: () => ({ home: HOME }) } }) + ctx.provide('connection', { generation: { getSnapshot: () => ({ id: 1, host: { home: HOME } }) } }) ctx.provide('sessions', { list: { getSnapshot: () => ({ byId: listed }) } }) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() @@ -124,7 +124,7 @@ describe('apply', () => { ctx.provide('remote.fileReferences', { list: () => Promise.resolve({ ok: true, value: [] }) }) ctx.provide('remote.sessionReferenceResolver', { candidates: () => Promise.resolve({ ok: true, value: [] }) }) ctx.provide('locale', new LocaleRuntime(ctx)) - ctx.provide('connection', { hostDescription: { getSnapshot: () => undefined } }) + ctx.provide('connection', { generation: { getSnapshot: () => undefined } }) ctx.provide('sessions', { list: { getSnapshot: () => ({ byId: {} }) } }) const ownFiber = ctx.plugin({ inject: [...inject], apply }) await ownFiber.await() diff --git a/packages/client/ui-tool/src/client/apply.ts b/packages/client/ui-tool/src/client/apply.ts index dce00a0f54..385dc58edf 100644 --- a/packages/client/ui-tool/src/client/apply.ts +++ b/packages/client/ui-tool/src/client/apply.ts @@ -24,7 +24,7 @@ export const inject = ['slots', 'connection'] */ export function apply(ctx: ClientContext): void { const connection = ctx.get('connection') as ConnectionHandle - const toolInject = () => ({ hooks: { hostDescription: connection.hostDescription } }) + const toolInject = () => ({ hooks: { connectionGeneration: connection.generation } }) ctx.slots.inject('conversation.chat.node', () => ctx.slots.register({ name: 'conversation.chat.node', key: 'tool-call', diff --git a/packages/client/ui-tool/src/client/contract/slots.ts b/packages/client/ui-tool/src/client/contract/slots.ts index c9206c8cd9..ce39262499 100644 --- a/packages/client/ui-tool/src/client/contract/slots.ts +++ b/packages/client/ui-tool/src/client/contract/slots.ts @@ -1,5 +1,5 @@ /** Tool UI slot declarations and their composed component props. */ -import type { HostDescriptionSource } from '@deepseek-ai/dsh-client-connection/client' +import type { ConnectionGenerationState } from '@deepseek-ai/dsh-client-connection/client' import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallBlock } from '@deepseek-ai/dsh-client-ui-chat/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -47,10 +47,10 @@ export interface ToolCallOwnerProps { export type ToolCallViewProps = PropsRuntime<'tool.call.toolview'> /** Injected Host description for POSIX home-path display. */ -export type ToolHostDescriptionInjected = { +export type ToolConnectionGenerationInjected = { hooks: { - /** Current generation's Host description, bound by the slot renderer. */ - hostDescription: HostDescriptionSource + /** Current Connection generation, bound by the slot renderer. */ + connectionGeneration: ConnectionGenerationState } } @@ -58,9 +58,9 @@ export type ToolHostDescriptionInjected = { export type ToolTreeProps = PropsRuntime<'conversation.chat.node', 'tool-call'> & PropsRenderSlots<'tool.call.toolview'> & PropsLocale<'conversation'> - & InjectFace + & InjectFace /** Full props of the selected Tool output renderer in the details panel. */ export type ToolDetailsProps = PropsRuntime<'conversation.details.tool'> & PropsLocale<'conversation'> - & InjectFace + & InjectFace diff --git a/packages/client/ui-tool/src/client/index.ts b/packages/client/ui-tool/src/client/index.ts index 2079d09a96..e27656cebb 100644 --- a/packages/client/ui-tool/src/client/index.ts +++ b/packages/client/ui-tool/src/client/index.ts @@ -1,5 +1,5 @@ /** Browser Tool plugin: whole-call composition and keyed atomic Tool views. */ export { apply, inject } from './apply.ts' export type { - ToolCallOwnerProps, ToolCallViewProps, ToolDetailsProps, ToolHostDescriptionInjected, ToolTreeProps, + ToolCallOwnerProps, ToolCallViewProps, ToolConnectionGenerationInjected, ToolDetailsProps, ToolTreeProps, } from './contract/slots.ts' diff --git a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx index ebb4578281..6a15997531 100644 --- a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx +++ b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx @@ -93,9 +93,9 @@ const ToolCallBranch = memo(function ToolCallBranch({ * @returns the Tool call tree. */ export function ToolCallTree({ - renderSlot, node, selectedCallId, cwd, openFile, inspectCall, useHostDescription, t, + renderSlot, node, selectedCallId, cwd, openFile, inspectCall, useConnectionGeneration, t, }: ToolTreeProps) { - const home = useHostDescription(description => description?.home) + const home = useConnectionGeneration(generation => generation?.host.home) const block = node.data.root return ( ) { - const home = useHostDescription(description => description?.home) + block, cwd, useConnectionGeneration, t, +}: Pick) { + const home = useConnectionGeneration(generation => generation?.host.home) const terminalModel = terminalCardModel(block, cwd) if (terminalModel !== null) { const terminal = localizeTerminalCardModel(terminalModel, t) diff --git a/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx index 44fca5b9e4..4775686284 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx @@ -140,7 +140,7 @@ type AskQuestionRowProps = ToolCallViewProps & PropsLocale<'conversation'> export function AskQuestionRow({ toolName, block, inspect, t }: AskQuestionRowProps) { const model = toolRowModel(toolName, block) // Composer verdicts settle the call as specific UserQuestionErrors - // (apiproxy ask_user_question handler): 'ASK_CANCELLED' is the user's own + // (ask_user_question handler): 'ASK_CANCELLED' is the user's own // dismissal of the set, 'ASK_ABORTED' is a turn interrupt landing while the // question was pending. Both name their verdict instead of the generic // failed shape, and the abort keeps the shared stopped (amber) semantics of diff --git a/packages/client/ui-tool/tests/ask-question-row.client.spec.tsx b/packages/client/ui-tool/tests/ask-question-row.client.spec.tsx index 22f1e0d5e4..6904b7dfad 100644 --- a/packages/client/ui-tool/tests/ask-question-row.client.spec.tsx +++ b/packages/client/ui-tool/tests/ask-question-row.client.spec.tsx @@ -168,7 +168,7 @@ describe('AskQuestionRow', () => { }) it('user cancellation shows the original questions without raw JSON or an error body', () => { - // ASK_CANCELLED: the apiproxy ask_user_question handler's cancel error. + // ASK_CANCELLED: the ask_user_question handler's cancel error. const view = render() expect(screen.getByText('已取消')).toBeTruthy() @@ -184,7 +184,7 @@ describe('AskQuestionRow', () => { }) it('a turn abort shows the original questions with stopped semantics', () => { - // ASK_ABORTED: the apiproxy ask handler's turn-abort settlement. + // ASK_ABORTED: the ask handler's turn-abort settlement. const view = render() expect(screen.getByText('已中断')).toBeTruthy() diff --git a/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx b/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx index 63cea1a148..960c81f89b 100644 --- a/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx +++ b/packages/client/ui-tool/tests/assembly-surfaces.client.spec.tsx @@ -72,9 +72,8 @@ const LAYOUT_CHILDREN = { async function bench(nodes: ToolResultNode[]) { const runtime = await SlotTestRuntime.create() runtime.ctx.provide('connection', { - api: { settings: {} }, isLoopback: false, - hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, + generation: { getSnapshot: () => undefined, subscribe: () => () => {} }, }) new TestRemote(runtime.ctx, { session: { diff --git a/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx b/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx index a23e003800..bb0b40b74d 100644 --- a/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx +++ b/packages/client/ui-tool/tests/chat-code-subcalls.client.spec.tsx @@ -121,9 +121,8 @@ async function bench(snapshot: ChatSnapshot) { ctx.provide('uiWorkspace', {} as never) new TestRemote(ctx, { session: { openWorkspacePath } }) ctx.provide('connection', { - api: { settings: {} }, isLoopback: false, - hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, + generation: { getSnapshot: () => undefined, subscribe: () => () => {} }, } as never) const locale = new LocaleRuntime(ctx) ctx.provide('locale', locale) diff --git a/packages/client/ui-tool/tests/read-card.client.spec.tsx b/packages/client/ui-tool/tests/read-card.client.spec.tsx index 40b7042f30..d61b1fe6b7 100644 --- a/packages/client/ui-tool/tests/read-card.client.spec.tsx +++ b/packages/client/ui-tool/tests/read-card.client.spec.tsx @@ -361,9 +361,7 @@ describe('DetailsPanel Output section (read)', () => { it('abbreviates a leftover POSIX home path on the read card label', () => { const view = mount(snapshot({ nodes: [settled({ meta: readMeta({ path: '/Users/u/notes.md' }) })], - }), target, '/tmp/ws', { - version: '0', cwd: '/tmp', attachedSessions: 0, home: '/Users/u', canOpenPath: false, - }) + }), target, '/tmp/ws', { id: 1, host: { home: '/Users/u' } }) expect(view.getByText('~/notes.md')).toBeTruthy() }) diff --git a/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx b/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx index 67cf3a7962..852ffd9ef5 100644 --- a/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx +++ b/packages/client/ui-tool/tests/tool-call-tree.client.spec.tsx @@ -2,7 +2,7 @@ /** ToolCallTree-owned root/subcall markers and selection projection. */ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, render } from '@testing-library/react' -import type { HostDescription } from '@deepseek-ai/dsh-client-connection/client' +import type { ConnectionGeneration } from '@deepseek-ai/dsh-client-connection/client' import type { SessionSnapshot } from '@deepseek-ai/dsh-api-session-controller/client' import type { ToolResultNode } from '@deepseek-ai/dsh-client-ui-chat/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' @@ -23,7 +23,7 @@ const root = (callId: string, call: ToolResultNode['call']): ToolResultNode => ( function props( block: ToolResultNode, selectedCallId?: string, - description?: HostDescription, + generation?: ConnectionGeneration, owners?: ToolCallOwnerProps[], ): ToolTreeProps { const snapshot = {} as SessionSnapshot @@ -50,7 +50,7 @@ function props( inspectCall: vi.fn(), forkAt: vi.fn(), fileMentions: vi.fn(), - useHostDescription: (selector => selector(description)) as ToolTreeProps['useHostDescription'], + useConnectionGeneration: (selector => selector(generation)) as ToolTreeProps['useConnectionGeneration'], t, } as unknown as ToolTreeProps } @@ -98,9 +98,7 @@ describe('ToolCallTree', () => { it('abbreviates a POSIX home path in the generic tool summary', () => { const block = root('w1', { name: 'read', argsRaw: '{"path":"/h/docs/a.ts"}' }) - const view = render() + const view = render() expect(view.getByText('~/docs/a.ts')).toBeTruthy() }) }) diff --git a/packages/client/ui-tool/tests/tool-details-render.client.tsx b/packages/client/ui-tool/tests/tool-details-render.client.tsx index 96a08b57ac..b20b689724 100644 --- a/packages/client/ui-tool/tests/tool-details-render.client.tsx +++ b/packages/client/ui-tool/tests/tool-details-render.client.tsx @@ -1,5 +1,5 @@ /** Test adapter for the production conversation.details.tool registration. */ -import type { HostDescription } from '@deepseek-ai/dsh-client-connection/client' +import type { ConnectionGeneration } from '@deepseek-ai/dsh-client-connection/client' import type { SessionLiveEventEntry } from '@deepseek-ai/dsh-api-session-controller/client' import { isJsonValue, type JsonValue } from '@deepseek-ai/dsh-session' import type { @@ -144,12 +144,12 @@ export function toolSessionEvents(nodes: readonly ToolResultNode[]): readonly Se /** * Bind ui-tool's details renderer to the conversation slot callback shape. * @param t - conversation locale seat used by Tool cards. - * @param description - optional Host description so the details card can abbreviate home paths. + * @param generation - optional Connection generation carrying the Host home. * @returns a direct-test renderSlot implementation. */ export function renderToolDetails( t: TranslateNS<'conversation'>, - description?: HostDescription, + generation?: ConnectionGeneration, ): DetailsSlotProps['renderSlot'] { return (_key, owner) => { // PropsRenderSlots keeps its key generic even for this one-key share; @@ -158,7 +158,7 @@ export function renderToolDetails( return selector(description)} + useConnectionGeneration={selector => selector(generation)} t={t} /> } diff --git a/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx b/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx index 719dbafb1a..4a6cedf03e 100644 --- a/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx +++ b/packages/client/ui-tool/tests/toolview-slot.client.spec.tsx @@ -60,9 +60,8 @@ const LAYOUT_CHILDREN = { async function bench(nodes: ToolResultNode[]) { const runtime = await SlotTestRuntime.create() runtime.ctx.provide('connection', { - api: { settings: {} }, isLoopback: false, - hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, + generation: { getSnapshot: () => undefined, subscribe: () => () => {} }, }) const openWorkspacePath = vi.fn(async () => ({ ok: true, value: { opened: true } })) new TestRemote(runtime.ctx, { session: { openWorkspacePath } }) @@ -207,9 +206,8 @@ describe('registrant declaration injection', () => { it('runs a registrant before ui-tool and waits on the actual toolview declaration', async () => { const runtime = await SlotTestRuntime.create() runtime.ctx.provide('connection', { - api: { settings: {} }, isLoopback: false, - hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, + generation: { getSnapshot: () => undefined, subscribe: () => () => {} }, }) new TestRemote(runtime.ctx, { session: { diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index f25270aad3..d7f6be2c8a 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -22,7 +22,7 @@ * and a hole has exactly one declaring entry — they carry the same owner * contract and the same occupant. */ -import type { HostDescriptionSource } from '@deepseek-ai/dsh-client-connection/client' +import type { ConnectionGenerationState } from '@deepseek-ai/dsh-client-connection/client' import type { HostObservable, PropsHooks, PropsLocale, PropsRenderSlots, PropsRuntime, PropsStore } from '@deepseek-ai/dsh-client-ui-slots' // Type-only: pull the owner SlotMap merges into programs that resolve the // runtime shares below. @@ -90,7 +90,7 @@ export type DirectoryPickingHooks = PropsHooks workspaces.create(input), - hooks: { directoryFlow: browserFlowSource, hostDescription }, + hooks: { directoryFlow: browserFlowSource, connectionGeneration }, }) const pickerInjected = (): WorkspacePickerInjected => ({ createWorkspace: input => workspaces.create(input), diff --git a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx index 2b2aba93ce..c8a5a15740 100644 --- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx @@ -820,11 +820,11 @@ export function WorkspaceBrowser({ searchSessions, searchResultLimit, useDirectoryFlow, - useHostDescription, + useConnectionGeneration, renderSlot, t, }: WorkspaceBrowserProps) { - const home = useHostDescription(description => description?.home) + const home = useConnectionGeneration(generation => generation?.host.home) const workspaces = useWorkspaces(state => state.items) const workspacePhase = useWorkspaces(state => state.phase) const archivedSessionIds = useWorkspaces(state => state.archivedSessionIds) diff --git a/packages/client/ui-workspace/tests/apply.client.spec.ts b/packages/client/ui-workspace/tests/apply.client.spec.ts index 3834358288..941c251f56 100644 --- a/packages/client/ui-workspace/tests/apply.client.spec.ts +++ b/packages/client/ui-workspace/tests/apply.client.spec.ts @@ -59,7 +59,7 @@ async function bench() { fork, } as never) ctx.provide('connection', { - hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, + generation: { getSnapshot: () => undefined, subscribe: () => () => {} }, } as never) const pickDirectory = vi.fn(() => Promise.resolve({ ok: true as const, value: '/projects/picked' })) const directoryPicker = { pick: pickDirectory } @@ -162,7 +162,7 @@ describe('ui-workspace apply', () => { const browser = (b.slots.entries('sidebar.workspaces')[0]!.inject as () => WorkspaceBrowserInjected)() const picker = (b.slots.entries('conversation.hero.workspace')[0]!.inject as () => WorkspacePickerInjected)() expect(browser.hooks.directoryFlow.getSnapshot()).toBe(false) - expect(browser.hooks.hostDescription.getSnapshot()).toBeUndefined() + expect(browser.hooks.connectionGeneration.getSnapshot()).toBeUndefined() expect(picker.hooks.directoryFlow.getSnapshot()).toBe(false) // A flow occupant flips exactly its own surface, and the source notifies. const notified = vi.fn() diff --git a/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx b/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx index cc698e7391..ea349efcc5 100644 --- a/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx +++ b/packages/client/ui-workspace/tests/rename-assembly.client.spec.tsx @@ -35,7 +35,7 @@ async function createRuntime(): Promise { const runtime = await SlotTestRuntime.create() runtime.releaseWorkspaceSource() runtime.ctx.provide('connection', { - hostDescription: { getSnapshot: () => undefined, subscribe: () => () => {} }, + generation: { getSnapshot: () => undefined, subscribe: () => () => {} }, }) // The rename flow never picks a directory; the namespace only has to be there // for ui-workspace's inject to settle. diff --git a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx index a623c3334d..22e4062abe 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -85,7 +85,7 @@ function mount(overrides: Partial = {}) { insertSessionBefore: vi.fn(async () => {}), createWorkspace: vi.fn(async () => workspace('created', [])), useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => true, subscribe: () => () => {} }), - useHostDescription: selector => selector(undefined), + useConnectionGeneration: selector => selector(undefined), renderSlot: ((_name: string, owner: { open: boolean }) => (owner.open ?
: null)) as never, t, ...overrides, @@ -110,9 +110,7 @@ describe('WorkspaceBrowser', () => { path: '/home/u/Documents/project', title: 'Project', }])), - useHostDescription: selector => selector({ - version: '0', cwd: '/tmp', attachedSessions: 0, home: '/home/u', canOpenPath: false, - }), + useConnectionGeneration: selector => selector({ id: 1, host: { home: '/home/u' } }), }) fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) act(() => { vi.advanceTimersByTime(500) })