refactor(client): replace host description consumers

This commit is contained in:
imccyu
2026-08-27 22:26:28 +08:00
parent e036aae7c0
commit 40929d6e1a
40 changed files with 283 additions and 198 deletions
+18 -1
View File
@@ -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<void>
/** 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<Config> = 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<void>
private readonly canOpenPath: () => boolean
private readonly promotions = new Set<Promise<void>>()
/**
@@ -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.
@@ -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<Context>()
@@ -45,41 +39,22 @@ afterEach(async () => {
contexts.clear()
})
async function mount(initialHost?: HostDescription): Promise<Bench> {
async function mount(initialGeneration?: ConnectionGeneration): Promise<Bench> {
const ctx = new Context()
contexts.add(ctx)
await ctx.plugin(TypertRegistry)
const api = new FakeApiClient()
const remote = fakeRemote(api)
const listeners = new Map<string, Set<RemoteListener>>()
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<Bench> {
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)
})
@@ -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<RemoteResult<{ opened: true }>> =
() => Promise.resolve(remoteOk({ opened: true as const }))
onDescribe: (payload: unknown) => Promise<RpcResponse<{
version: string
cwd: string
attachedSessions: number
home: string
canOpenPath: boolean
}>> =
() => Promise.resolve(ok({
version: '0-fake', cwd: '/f', attachedSessions: 0, home: '/h', canOpenPath: true,
}))
private readonly followConns = new Map<SessionId, ValueStreamConn<SessionFollowFrame>[]>()
private readonly controlConns: ValueStreamConn<SessionControlFrame>[] = []
private readonly workspaceConns: ValueStreamConn<WorkspaceFollowFrame>[] = []
@@ -191,10 +181,6 @@ export class FakeApiClient implements IApiClient {
onSubagentInterrupt: (payload: unknown) => Promise<RemoteResult<SubagentInterruptReceipt>>
= () => 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<RemoteResult<{ workspace: WorkspaceView; created: boolean }>> =
() => 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,
@@ -15,6 +15,39 @@ async function context(): Promise<Context> {
}
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())
@@ -51,6 +51,7 @@ import type {
/** Direct test face matching the generated `ctx.remote.session` unary methods. */
export interface TestSessionRemote {
canOpenWorkspacePath(): Promise<RemoteResult<boolean>>
list(request: SessionListRequest, signal?: AbortSignal): Promise<RemoteResult<SessionListValue>>
search(request: SessionSearchRequest, signal?: AbortSignal): Promise<RemoteResult<SessionSearchValue>>
create(request: SessionCreateRequest): Promise<RemoteResult<SessionCreateValue>>
@@ -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<void>
readonly openPath?: (path: string, signal: AbortSignal) => Promise<void>
readonly canOpenPath?: () => boolean
}
const installed = new WeakMap<Context, SessionController>()
@@ -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,
@@ -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.
@@ -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' })
})
@@ -197,18 +197,7 @@ async function waitFor(check: () => void): Promise<void> {
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')),
@@ -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()
})
@@ -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<AgentPresetSectionState> = createSnapshotStore(INITIAL)
constructor(
private readonly api: Pick<IApiClient, 'host'>,
private readonly remote: Pick<ClientRemote, 'agentPresets' | 'settings'>,
/**
* 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 })
@@ -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',
])
})
@@ -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<void>
}
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<IApiClient, 'host'> {
return {
host: {
describe: () => (options.throwDescribe === true
? Promise.reject(new Error('socket closed'))
: ok({ canOpenPath: options.hasDocument ?? true })),
},
} as Pick<IApiClient, 'host'>
}
/**
* 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<IApiClient, 'host'>,
{
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')
@@ -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:^",
@@ -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<boolean | undefined>
}
}
@@ -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)
@@ -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<boolean | undefined>(undefined)
let requestedWorkspacePathOpen = false
let capabilityRevision = 0
let pendingCapability: Promise<void> | 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),
)
@@ -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<ProducedFilesProps, 'isLoopback' | 'useHostDescription'> => {
const description = canOpenPath === undefined
? undefined
: { version: 'test', cwd: '/workspace', attachedSessions: 1, home: '/h', canOpenPath }
): Pick<ProducedFilesProps, 'isLoopback' | 'ensureWorkspacePathOpen' | 'useWorkspacePathOpen'> => {
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()
})
})
@@ -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"
},
@@ -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)),
@@ -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()
+1 -1
View File
@@ -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',
@@ -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<ToolHostDescriptionInjected>
& InjectFace<ToolConnectionGenerationInjected>
/** Full props of the selected Tool output renderer in the details panel. */
export type ToolDetailsProps = PropsRuntime<'conversation.details.tool'>
& PropsLocale<'conversation'>
& InjectFace<ToolHostDescriptionInjected>
& InjectFace<ToolConnectionGenerationInjected>
+1 -1
View File
@@ -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'
@@ -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 (
<ToolCallBranch
@@ -21,9 +21,9 @@ import css from './ToolDetails.module.css'
* @returns the details output body.
*/
export function ToolDetails({
block, cwd, useHostDescription, t,
}: Pick<ToolDetailsProps, 'block' | 'cwd' | 'useHostDescription' | 't'>) {
const home = useHostDescription(description => description?.home)
block, cwd, useConnectionGeneration, t,
}: Pick<ToolDetailsProps, 'block' | 'cwd' | 'useConnectionGeneration' | 't'>) {
const home = useConnectionGeneration(generation => generation?.host.home)
const terminalModel = terminalCardModel(block, cwd)
if (terminalModel !== null) {
const terminal = localizeTerminalCardModel(terminalModel, t)
@@ -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
@@ -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(<AskQuestionRow {...rowProps(resultNode(READABLE_ARGS, null,
{ isError: true, error: { name: 'UserQuestionError', code: 'ASK_CANCELLED' } }))} />)
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(<AskQuestionRow {...rowProps(resultNode(READABLE_ARGS, null,
{ isError: true, error: { name: 'UserQuestionError', code: 'ASK_ABORTED' } }))} />)
expect(screen.getByText('已中断')).toBeTruthy()
@@ -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: {
@@ -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)
@@ -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()
})
@@ -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(<ToolCallTree {...props(block, 'w1', {
version: '0', cwd: '/tmp', attachedSessions: 0, home: '/h', canOpenPath: false,
})} />)
const view = render(<ToolCallTree {...props(block, 'w1', { id: 1, host: { home: '/h' } })} />)
expect(view.getByText('~/docs/a.ts')).toBeTruthy()
})
})
@@ -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 <ToolDetails
block={details.block}
cwd={details.cwd}
useHostDescription={selector => selector(description)}
useConnectionGeneration={selector => selector(generation)}
t={t}
/>
}
@@ -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: {
@@ -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<DirectoryPickingInjected['hooks']
export type WorkspaceBrowserInjected = {
hooks: DirectoryPickingInjected['hooks'] & {
/** Current generation's Host description, bound by the slot renderer. */
hostDescription: HostDescriptionSource
connectionGeneration: ConnectionGenerationState
}
/**
* Start a New Session in a Workspace: reuse-or-create its blank session and
@@ -73,7 +73,7 @@ export function apply(ctx: Context): void {
const connection = ctx.get('connection') as ConnectionHandle
const sessions = ctx.get('sessions') as ISessions
const workspaces = ctx.get('workspaces') as IWorkspaces
const hostDescription = connection.hostDescription
const connectionGeneration = connection.generation
const uiWorkspace = new UiWorkspaceService(
ctx, ctx.remote.directoryPicker, workspaces, sessions)
ctx.slots.provideRoot({ hooks: { workspaces: workspaces.list } })
@@ -125,7 +125,7 @@ export function apply(ctx: Context): void {
await workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId)
},
createWorkspace: input => workspaces.create(input),
hooks: { directoryFlow: browserFlowSource, hostDescription },
hooks: { directoryFlow: browserFlowSource, connectionGeneration },
})
const pickerInjected = (): WorkspacePickerInjected => ({
createWorkspace: input => workspaces.create(input),
@@ -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)
@@ -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()
@@ -35,7 +35,7 @@ async function createRuntime(): Promise<SlotTestRuntime> {
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.
@@ -85,7 +85,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
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 ? <div data-testid="directory-flow" /> : 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) })