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.
This commit is contained in:
parent
a4f7193d24
commit
f9e8fc8f8a
14 changed files with 53 additions and 44 deletions
|
|
@ -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']> {
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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) })
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue