Просмотр исходного кода

fix(api): identity-stable $host facts and a whole-record host info hook

- api/gateway: $host caches its RemoteHostFacts record and mints a new
  one only when home changes, so snapshot readers compare by reference;
  identity pinned in the client spec.
- client/ui-tool, client/ui-workspace: the hooks channel exposes the
  host facts record as hostInfo and components select the field they
  need (useHostInfo(info => info.home)); row-component contracts are
  unchanged.
imccyu 1 месяц назад
Родитель
Сommit
f9e8fc8f8a

+ 8 - 3
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<string, RemoteNamespaceHandle>()
+  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<TypertClientRemote['$mount']> {

+ 9 - 3
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 () => {

+ 4 - 6
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<string | undefined> = {
-    getSnapshot: () => ctx.remote.$host.home,
+  const hostInfo: HostObservable<RemoteHostFacts> = {
+    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',

+ 9 - 8
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<string | undefined>
+    hostInfo: HostObservable<RemoteHostFacts>
   }
 }
 
@@ -64,9 +65,9 @@ export type ToolHostHomeInjected = {
 export type ToolTreeProps = PropsRuntime<'conversation.chat.node', 'tool-call'>
   & PropsRenderSlots<'tool.call.toolview'>
   & PropsLocale<'conversation'>
-  & InjectFace<ToolHostHomeInjected>
+  & InjectFace<ToolHostInfoInjected>
 
 /** Full props of the selected Tool output renderer in the details panel. */
 export type ToolDetailsProps = PropsRuntime<'conversation.details.tool'>
   & PropsLocale<'conversation'>
-  & InjectFace<ToolHostHomeInjected>
+  & InjectFace<ToolHostInfoInjected>

+ 1 - 1
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'

+ 2 - 2
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 (
     <ToolCallBranch

+ 3 - 3
packages/client/ui-tool/src/client/tool/ToolDetails.tsx

@@ -21,9 +21,9 @@ import css from './ToolDetails.module.css'
  * @returns the details output body.
  */
 export function ToolDetails({
-  block, cwd, useHostHome, t,
-}: Pick<ToolDetailsProps, 'block' | 'cwd' | 'useHostHome' | 't'>) {
-  const home = useHostHome(value => value)
+  block, cwd, useHostInfo, t,
+}: Pick<ToolDetailsProps, 'block' | 'cwd' | 'useHostInfo' | 't'>) {
+  const home = useHostInfo(info => info.home)
   const terminalModel = terminalCardModel(block, cwd)
   if (terminalModel !== null) {
     const terminal = localizeTerminalCardModel(terminalModel, t)

+ 1 - 1
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
 }

+ 1 - 1
packages/client/ui-tool/tests/tool-details-render.client.tsx

@@ -157,7 +157,7 @@ export function renderToolDetails(
     return <ToolDetails
       block={details.block}
       cwd={details.cwd}
-      useHostHome={selector => selector(home)}
+      useHostInfo={selector => selector({ home, isLoopback: true })}
       t={t}
     />
   }

+ 6 - 5
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<DirectoryPickingInjected['hooks']
 export type WorkspaceBrowserInjected = {
   hooks: DirectoryPickingInjected['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 surface needs (`info => info.home`).
      */
-    hostHome: HostObservable<string | undefined>
+    hostInfo: HostObservable<RemoteHostFacts>
   }
   /**
    * Start a New Session in a Workspace: reuse-or-create its blank session and

+ 4 - 6
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<string | undefined> = {
-    getSnapshot: () => ctx.remote.$host.home,
+  const hostInfo: HostObservable<RemoteHostFacts> = {
+    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),

+ 2 - 2
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)

+ 1 - 1
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()

+ 2 - 2
packages/client/ui-workspace/tests/workspace-browser.client.spec.tsx

@@ -84,7 +84,7 @@ function mount(overrides: Partial<WorkspaceBrowserProps> = {}) {
     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 ? <div data-testid="directory-flow" /> : 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) })