diff --git a/packages/api/gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts index 303f311d6a..accf7cafb0 100644 --- a/packages/api/gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -144,6 +144,7 @@ class ClientRemoteService extends Service implements ClientRemote { private readonly ownerCtx: Context private readonly connection: ConnectionHandle private readonly namespaces = new Map() + private hostFacts: RemoteHostFacts | undefined private readonly streams = new RemoteStreamMuxClient() private readonly events: ClientRemoteEvents private mutations = Promise.resolve() @@ -183,10 +184,14 @@ class ClientRemoteService extends Service implements ClientRemote { } get $host(): RemoteHostFacts { - return { - home: this.connection.generation.getSnapshot()?.host.home, - isLoopback: this.connection.isLoopback, + // Identity-stable: readers (useSyncExternalStore snapshots, memo inputs) + // compare by reference, so a fresh object is minted only when the fact + // itself changed. isLoopback is fixed for the page lifetime. + const home = this.connection.generation.getSnapshot()?.host.home + if (this.hostFacts === undefined || this.hostFacts.home !== home) { + this.hostFacts = { home, isLoopback: this.connection.isLoopback } } + return this.hostFacts } async $mount(contribution: TypertRemoteContribution): ReturnType { diff --git a/packages/api/gateway/tests/gateway.client.spec.ts b/packages/api/gateway/tests/gateway.client.spec.ts index b6a1d12414..d0af7669e9 100644 --- a/packages/api/gateway/tests/gateway.client.spec.ts +++ b/packages/api/gateway/tests/gateway.client.spec.ts @@ -569,13 +569,19 @@ describe('Client Remote transport readiness', () => { await client const remote = ctx.remote - expect(remote.$host).toEqual({ home: undefined, isLoopback: true }) + const beforeReady = remote.$host + expect(beforeReady).toEqual({ home: undefined, isLoopback: true }) + expect(remote.$host).toBe(beforeReady) + live.snapshot = { id: 1, host: { home: '/hosts/primary' } } - expect(remote.$host).toEqual({ home: '/hosts/primary', isLoopback: true }) + const afterReady = remote.$host + expect(afterReady).toEqual({ home: '/hosts/primary', isLoopback: true }) + expect(afterReady).not.toBe(beforeReady) + expect(remote.$host).toBe(afterReady) withdraw() expect(ctx.get('connection')).toBeUndefined() - expect(remote.$host).toEqual({ home: '/hosts/primary', isLoopback: true }) + expect(remote.$host).toBe(afterReady) }) it('starts after Loader settlement and stops the owned loop on disposal', async () => { diff --git a/packages/client/ui-tool/src/client/apply.ts b/packages/client/ui-tool/src/client/apply.ts index b0eab4996d..1324e8cbed 100644 --- a/packages/client/ui-tool/src/client/apply.ts +++ b/packages/client/ui-tool/src/client/apply.ts @@ -1,5 +1,6 @@ /** Register the Tool call tree, details renderer, and built-in atomic views. */ import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { RemoteHostFacts } from '@deepseek-ai/dsh-api-remotes/client' import type { HostObservable } from '@deepseek-ai/dsh-client-ui-slots' import type {} from '@deepseek-ai/dsh-api-remotes/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' @@ -24,14 +25,11 @@ export const inject = ['slots', 'remote'] * @param ctx - Client root context. */ export function apply(ctx: ClientContext): void { - // Host facts are plain reads; a reset is what announces the generation that - // published them, so the views re-read on it instead of freezing the value - // the entry's first render saw (inject results are memoized per registration). - const hostHome: HostObservable = { - getSnapshot: () => ctx.remote.$host.home, + const hostInfo: HostObservable = { + getSnapshot: () => ctx.remote.$host, subscribe: listener => ctx.on('connection/reset', listener), } - const toolInject = () => ({ hooks: { hostHome } }) + const toolInject = () => ({ hooks: { hostInfo } }) 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 fc38496e6d..703e102ac4 100644 --- a/packages/client/ui-tool/src/client/contract/slots.ts +++ b/packages/client/ui-tool/src/client/contract/slots.ts @@ -2,6 +2,7 @@ import type { HostObservable, InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime, } from '@deepseek-ai/dsh-client-ui-slots' +import type { RemoteHostFacts } from '@deepseek-ai/dsh-api-remotes/client' import type { ToolCallBlock } from '@deepseek-ai/dsh-client-ui-chat/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type {} from '@deepseek-ai/dsh-client-locale/client' @@ -48,15 +49,15 @@ export interface ToolCallOwnerProps { export type ToolCallViewProps = PropsRuntime<'tool.call.toolview'> /** Injected Host description for POSIX home-path display. */ -export type ToolHostHomeInjected = { +export type ToolHostInfoInjected = { hooks: { /** - * Host account home, absent until the Connection is ready. A hook rather - * than a value: the renderer memoizes an entry's inject result for the - * registration's lifetime, so a home read there would freeze at whatever - * the first render saw. + * Fixed Host facts, reached through a hook rather than injected as values: + * the renderer memoizes an entry's inject result for the registration's + * lifetime, so facts read there would freeze at whatever the first render + * saw. Select the field the view needs (`info => info.home`). */ - hostHome: HostObservable + hostInfo: HostObservable } } @@ -64,9 +65,9 @@ export type ToolHostHomeInjected = { 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 19df19e054..fc65940d0f 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, ToolHostHomeInjected, ToolTreeProps, + ToolCallOwnerProps, ToolCallViewProps, ToolDetailsProps, ToolHostInfoInjected, 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 7e10afd82c..2a11948077 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, useHostHome, t, + renderSlot, node, selectedCallId, cwd, openFile, inspectCall, useHostInfo, t, }: ToolTreeProps) { - const home = useHostHome(value => value) + const home = useHostInfo(info => info.home) const block = node.data.root return ( ) { - const home = useHostHome(value => value) + block, cwd, useHostInfo, t, +}: Pick) { + const home = useHostInfo(info => info.home) const terminalModel = terminalCardModel(block, cwd) if (terminalModel !== null) { const terminal = localizeTerminalCardModel(terminalModel, t) 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 26743e3387..84ac946e1e 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 @@ -49,7 +49,7 @@ function props( inspectCall: vi.fn(), forkAt: vi.fn(), fileMentions: vi.fn(), - useHostHome: ((selector: (value: string | undefined) => unknown) => selector(home)) as ToolTreeProps['useHostHome'], + useHostInfo: ((selector: (info: { home: string | undefined }) => unknown) => selector({ home })) as ToolTreeProps['useHostInfo'], t, } as unknown as ToolTreeProps } 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 756d71877e..43b3286b69 100644 --- a/packages/client/ui-tool/tests/tool-details-render.client.tsx +++ b/packages/client/ui-tool/tests/tool-details-render.client.tsx @@ -157,7 +157,7 @@ export function renderToolDetails( return selector(home)} + useHostInfo={selector => selector({ home, isLoopback: true })} t={t} /> } diff --git a/packages/client/ui-workspace/src/client/contract/slots.ts b/packages/client/ui-workspace/src/client/contract/slots.ts index 7bd9d670f4..df17251c38 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -28,6 +28,7 @@ import type { HostObservable, PropsHooks, PropsLocale, PropsRenderSlots, PropsRu import type {} from '@deepseek-ai/dsh-client-ui-sidebar/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type { SessionSearchResultItem } from '@deepseek-ai/dsh-api-session-controller/client' +import type { RemoteHostFacts } from '@deepseek-ai/dsh-api-remotes/client' import type { WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { createWorkspaceViewStore } from '../stores.ts' @@ -89,12 +90,12 @@ export type DirectoryPickingHooks = PropsHooks info.home`). */ - hostHome: HostObservable + hostInfo: HostObservable } /** * Start a New Session in a Workspace: reuse-or-create its blank session and diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index 2f55dd506d..7c8e1108dc 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -9,6 +9,7 @@ * packages/client/AGENTS.md. */ import type { Context } from '@deepseek-ai/cordis' +import type { RemoteHostFacts } from '@deepseek-ai/dsh-api-remotes/client' import type { ISessions } from '@deepseek-ai/dsh-api-session-controller/client' import type { IWorkspaces, WorkspaceSnapshot } from '@deepseek-ai/dsh-api-workspace-controller/client' import type { HostObservable, SnapshotSelectorHook } from '@deepseek-ai/dsh-client-ui-slots' @@ -89,11 +90,8 @@ export function apply(ctx: Context): void { subscribe: listener => ctx.slots.subscribe(hole, listener), }) const browserFlowSource = flowSource('sidebar.workspaces.directoryFlow') - // Host facts are plain reads; a reset is what announces the generation that - // published them, so the rows re-read on it instead of freezing the value - // the entry's first render saw (inject results are memoized per registration). - const hostHome: HostObservable = { - getSnapshot: () => ctx.remote.$host.home, + const hostInfo: HostObservable = { + getSnapshot: () => ctx.remote.$host, subscribe: listener => ctx.on('connection/reset', listener), } const pickerFlowSource = flowSource('conversation.hero.workspace.directoryFlow') @@ -129,7 +127,7 @@ export function apply(ctx: Context): void { await workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId) }, createWorkspace: input => workspaces.create(input), - hooks: { directoryFlow: browserFlowSource, hostHome }, + hooks: { directoryFlow: browserFlowSource, hostInfo }, }) 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 09295168c5..9d9dd376e4 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, - useHostHome, + useHostInfo, renderSlot, t, }: WorkspaceBrowserProps) { - const home = useHostHome(value => value) + const home = useHostInfo(info => info.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 87536d4646..acc4b1cbc8 100644 --- a/packages/client/ui-workspace/tests/apply.client.spec.ts +++ b/packages/client/ui-workspace/tests/apply.client.spec.ts @@ -159,7 +159,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.hostHome.getSnapshot()).toBeUndefined() + expect(browser.hooks.hostInfo.getSnapshot()).toMatchObject({ home: undefined }) 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/workspace-browser.client.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx index afb973a6df..8ee31d37c9 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx @@ -84,7 +84,7 @@ function mount(overrides: Partial = {}) { insertSessionBefore: vi.fn(async () => {}), createWorkspace: vi.fn(async () => workspace('created', [])), useDirectoryFlow: bindSnapshotSelector({ getSnapshot: () => true, subscribe: () => () => {} }), - useHostHome: selector => selector(undefined), + useHostInfo: selector => selector({ home: undefined, isLoopback: true }), renderSlot: ((_name: string, owner: { open: boolean }) => (owner.open ?
: null)) as never, t, ...overrides, @@ -109,7 +109,7 @@ describe('WorkspaceBrowser', () => { path: '/home/u/Documents/project', title: 'Project', }])), - useHostHome: selector => selector('/home/u'), + useHostInfo: selector => selector({ home: '/home/u', isLoopback: true }), }) fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) act(() => { vi.advanceTimersByTime(500) })