diff --git a/packages/api/gateway/src/client/remote-stream.ts b/packages/api/gateway/src/client/remote-stream.ts index 71f1f546ae..a2b018ae91 100644 --- a/packages/api/gateway/src/client/remote-stream.ts +++ b/packages/api/gateway/src/client/remote-stream.ts @@ -1,5 +1,6 @@ /** Reconnecting lifecycle for one single-consumer Remote stream. */ +import { RemoteError, remoteErrorOf } from '@deepseek-ai/dsh-typert-protocol' import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import { RemoteStreamCarrierError } from './stream-client.ts' @@ -128,7 +129,7 @@ export class RemoteStream implements AsyncIterable> } catch (error) { if (isAborted(this.lifetime.signal)) return if (revision !== this.revision) continue - if (!(error instanceof RemoteStreamCarrierError)) throw error + if (!(error instanceof RemoteStreamCarrierError)) throw terminalStreamFailure(error) this.options.carrierFailed?.(error) if (revision !== this.revision) continue attempt++ @@ -137,7 +138,7 @@ export class RemoteStream implements AsyncIterable> } catch (retryError) { if (isAborted(this.lifetime.signal)) return if (revision !== this.revision) continue - throw retryError + throw terminalStreamFailure(retryError) } } finally { this.generationAbort = undefined @@ -195,6 +196,22 @@ async function waitForRemoteStreamRetry( }) } +/** + * Mark a terminal escape before it crosses the stream boundary: consumers + * discriminate failures by code, so an unmarked throw reads as a local bug. + * Marked failures pass through verbatim. The carrier class never escapes as a + * terminal outcome — it stays the retry-internal signal fed to `carrierFailed` + * and the `ended(true)` retry trigger. + */ +function terminalStreamFailure(error: unknown): Error { + return remoteErrorOf(error) ?? new RemoteError( + 'gateway/internal', + error instanceof Error ? error.message : String(error), + {}, + { cause: error }, + ) +} + function isAborted(signal: AbortSignal): boolean { return signal.aborted } diff --git a/packages/api/gateway/tests/control-retry.client.spec.ts b/packages/api/gateway/tests/control-retry.client.spec.ts index 234251babb..7e127fbff0 100644 --- a/packages/api/gateway/tests/control-retry.client.spec.ts +++ b/packages/api/gateway/tests/control-retry.client.spec.ts @@ -1,4 +1,5 @@ import { describe, expect, it, vi } from 'vitest' +import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import type { ConnectionHandle } from '@deepseek-ai/dsh-api-remotes/client' import { RemoteStreamCarrierError, @@ -106,11 +107,24 @@ describe('RemoteStream', () => { { terminal: repeated }, ], carrierFailed) - await expect(stream[Symbol.asyncIterator]().next()).rejects.toBe(repeated) + await expect(stream[Symbol.asyncIterator]().next()).rejects.toMatchObject({ + isDSHRemoteGatewayError: true, + code: 'gateway/internal', + message: 'isolated retry failed', + details: {}, + cause: repeated, + }) expect(carrierFailed).toHaveBeenNthCalledWith(1, first) expect(carrierFailed).toHaveBeenNthCalledWith(2, repeated) }) + it('passes a marked Remote failure through the terminal boundary verbatim', async () => { + const failure = new RemoteError('gateway/internal', 'host stream failed', {}) + const stream = supervisor(hostSource(true).connection, [{ terminal: failure }]) + + await expect(stream[Symbol.asyncIterator]().next()).rejects.toBe(failure) + }) + it('waits for a replacement Host generation after observing unavailability', async () => { let available = false let listener: (() => void) | undefined diff --git a/packages/api/session-controller/tests/session.client.spec.ts b/packages/api/session-controller/tests/session.client.spec.ts index f59ed3c287..565dcf99bf 100644 --- a/packages/api/session-controller/tests/session.client.spec.ts +++ b/packages/api/session-controller/tests/session.client.spec.ts @@ -3,6 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { SessionId } from '@deepseek-ai/dsh-api-remotes/client' +import { RemoteStreamCarrierError } from '@deepseek-ai/dsh-api-gateway/client' import { RemoteError } from '@deepseek-ai/dsh-typert-protocol' import { Session, type SessionOptions } from '../src/client/sessions/session.ts' import { FakeApiClient, deferred, err, fakeRemote, ok } from './fake-api.client.ts' @@ -85,10 +86,25 @@ describe('Session open', () => { expect(snapshot.openError?.code).toBe('session/not-found') }) - it('propagates a non-Remote throw raised while opening', async () => { + it('lands exhausted carrier retries in openState=error as gateway/internal', async () => { + const { api, session } = makeSession() + // Two consecutive carrier losses before any opening is accepted exhaust the + // Gateway's retry budget; the escaping failure crosses the stream boundary marked. + api.onHistory = () => Promise.reject(new RemoteStreamCarrierError('history carrier down')) + await session.open() + expect(session.getSnapshot().openState).toBe('error') + expect(session.getSnapshot().openError).toMatchObject({ + code: 'gateway/internal', message: 'history carrier down', + }) + expect(api.followStarts).toHaveLength(2) + }) + + it('lands a Gateway-marked stream failure in openState=error', async () => { const { api, session } = makeSession() api.onHistory = () => Promise.reject(new Error('socket died')) - await expect(session.open()).rejects.toThrow('socket died') + await session.open() + expect(session.getSnapshot().openState).toBe('error') + expect(session.getSnapshot().openError).toMatchObject({ code: 'gateway/internal', message: 'socket died' }) }) it('stitches live frames arriving while history is pending, dropping the page overlap', async () => { diff --git a/packages/api/workspace-controller/tests/transport.client.spec.ts b/packages/api/workspace-controller/tests/transport.client.spec.ts index 52d7a21020..fdfbf23105 100644 --- a/packages/api/workspace-controller/tests/transport.client.spec.ts +++ b/packages/api/workspace-controller/tests/transport.client.spec.ts @@ -231,6 +231,27 @@ describe('Workspace Controller Client apply', () => { expect(ctx.get('workspaces')).toBeUndefined() }) + it('publishes exhausted carrier retries as a gateway/internal error state', async () => { + const ctx = new Context() + // Neither generation reaches an accepted baseline, so the retry budget runs + // out and the escaping carrier failure crosses the stream boundary marked. + const remote = new ScriptedWorkspaceRemote([ + { frames: [], error: new RemoteStreamCarrierError('generation lost') }, + { frames: [], error: new RemoteStreamCarrierError('generation lost again') }, + ]) + provideClientServices(ctx, remote) + const fiber = ctx.plugin(WorkspaceClientPlugin) + await fiber + await waitFor(() => { + expect(ctx.workspaces.list.getSnapshot()).toMatchObject({ + state: 'error', + error: { code: 'gateway/internal', message: 'generation lost again' }, + }) + }) + expect(remote.calls).toBe(2) + await fiber.dispose() + }) + it('marks carrier loss while retrying and publishes a later protocol failure', async () => { const ctx = new Context() const remote = new ScriptedWorkspaceRemote([ diff --git a/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx b/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx index de1c20b8bf..b5034b12b4 100644 --- a/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx +++ b/packages/client/ui-settings-models/src/client/CustomProviderCard.tsx @@ -23,7 +23,6 @@ import { useState } from 'react' import type { ReactNode } from 'react' -import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { JsonValue } from '@deepseek-ai/dsh-api-remotes/client' import { apiKeyFailure } from './apiKey.ts' import { EditorFooter } from './EditorFooter.tsx' @@ -31,6 +30,7 @@ import { validateDeepSeekModels } from './DeepSeekModelsEditor.tsx' import { ModelListEditor } from './ModelListEditor.tsx' import type { ModelDraft } from './ModelListEditor.tsx' import { deriveKeyRef } from './store.ts' +import type { ModelsOperations } from './operations.ts' import type { en } from './locales.ts' import styles from './ModelsSection.module.css' @@ -59,8 +59,8 @@ export interface CustomProviderCardProps { * than a silent overwrite of its whole profile. */ revision: number - /** The page plugin's context, whose Remote namespaces carry the write and the endpoint interrogation. */ - ctx: ClientContext + /** The Host operations this card writes and interrogates through. */ + operations: ModelsOperations /** Section copy. */ t: (key: keyof typeof en) => string /** Disable writes (read-only settings provider). */ @@ -75,7 +75,7 @@ export interface CustomProviderCardProps { * @returns the creation card. */ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { - const { taken, protocols, ctx, t } = props + const { taken, protocols, operations, t } = props // The write is checked against the revision on which this draft was opened. const [openedAt] = useState(() => props.revision) const [route, setRoute] = useState('') @@ -147,15 +147,13 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { // `taken` is a snapshot too, so the id check alone cannot see a route // declared after this card opened; the revision makes that race a // `settings-conflict` instead of a write over the other profile. - const response = await ctx.remote.settings.mutate( + const written = await operations.writeSettings( NS, [{ op: 'set', path: ['providers', route], value: profile as JsonValue }], openedAt, ) - if (!response.ok) { - return response.error.code === 'settings/conflict' - ? t('conflict') - : response.error.message + if (written.kind !== 'written') { + return written.kind === 'conflict' ? t('conflict') : written.message } // The provider now exists. A retry after the key write below fails must // not re-run this mutate: the revision it holds is the one this write @@ -164,10 +162,10 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { setCommitted(true) } if (storesKey) { - const stored = await ctx.remote.credentials.set(keyRef, keyValue) + const stored = await operations.storeCredential(keyRef, keyValue) // The profile landed; saying the key did not is the only honest report, // and the retry above now goes straight back to this write. - if (!stored.ok) return stored.error.message + if (stored !== undefined) return stored } return undefined } @@ -274,7 +272,7 @@ export function CustomProviderCard(props: CustomProviderCardProps): ReactNode { ...keyValue.length === 0 ? {} : { apiKey: keyValue }, }} probeBlocked={keyFailure === 'keyBlank' ? 'keyBlankNew' : keyFailure} - ctx={ctx} + operations={operations} t={t} disabled={profileDisabled} /> diff --git a/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx b/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx index 546f89e4a0..3d764a1ed6 100644 --- a/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx +++ b/packages/client/ui-settings-models/src/client/DeepSeekOnboardingDialog.tsx @@ -8,11 +8,11 @@ import { useEffect } from 'react' import type { ReactNode } from 'react' -import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { SnapshotStore } from '@deepseek-ai/dsh-client-store' import type { InjectFace, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { ModelsSettingsState, ModelsSettingsStore } from './store.ts' import { onboardingReadiness } from './store.ts' +import type { ModelsOperations } from './operations.ts' import type { SettingsSchemaOperations } from './schema-operations.ts' import { ProviderEditor } from './ProviderEditor.tsx' import type { en } from './locales.ts' @@ -27,8 +27,8 @@ export interface DeepSeekOnboardingInjected { } /** Shared Models-page join controller. */ controller: ModelsSettingsStore - /** The plugin context the reused Models credential editor writes through. */ - ctx: ClientContext + /** The Host operations the reused Models credential editor writes through. */ + operations: ModelsOperations /** Settings schema and immutable path callbacks. */ schema: SettingsSchemaOperations /** Feature copy. */ @@ -51,7 +51,7 @@ function assertNever(_value: never): never { * @returns the onboarding modal or null when onboarding needs no intervention. */ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): ReactNode { - const { complete, controller, useModels, ctx, schema, t } = props + const { complete, controller, useModels, operations, schema, t } = props const state = useModels(snapshot => snapshot) const readiness = onboardingReadiness(state) @@ -106,7 +106,7 @@ export function DeepSeekOnboardingDialog(props: DeepSeekOnboardingDialogProps): namespace={namespace} schema={schema} settingsPath={row.entry.settingsPath} - ctx={ctx} + operations={operations} t={t} readOnly={false} hideTitle diff --git a/packages/client/ui-settings-models/src/client/ModelListEditor.tsx b/packages/client/ui-settings-models/src/client/ModelListEditor.tsx index 69714486c2..a81dde113d 100644 --- a/packages/client/ui-settings-models/src/client/ModelListEditor.tsx +++ b/packages/client/ui-settings-models/src/client/ModelListEditor.tsx @@ -16,10 +16,10 @@ import { useState } from 'react' import type { ReactNode } from 'react' -import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { LlmDiscoveredModel } from '@deepseek-ai/dsh-api-remotes/client' import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import { formatCapacity, parseCapacity } from './DeepSeekModelsEditor.tsx' +import type { ModelsOperations } from './operations.ts' import type { DeepSeekModelDraft } from './DeepSeekModelsEditor.tsx' import type { en } from './locales.ts' import styles from './ModelsSection.module.css' @@ -79,8 +79,8 @@ export interface ModelListEditorProps { * told what the field already says. */ probeBlocked?: keyof typeof en | undefined - /** The page plugin's context, whose `remote.llm` namespace answers the fetch action. */ - ctx: ClientContext + /** The Host operations whose interrogation answers the fetch action. */ + operations: ModelsOperations /** Section copy. */ t: (key: keyof typeof en) => string /** Disable every control (read-only deployment or a pending write). */ @@ -157,7 +157,7 @@ function adopt(candidate: LlmDiscoveredModel): ModelDraft { * @returns the model-list editor. */ export function ModelListEditor(props: ModelListEditorProps): ReactNode { - const { models, onChange, probe, ctx, t, disabled } = props + const { models, onChange, probe, operations, t, disabled } = props const [busy, setBusy] = useState(false) const [failure, setFailure] = useState(undefined) const [candidates, setCandidates] = useState(undefined) @@ -229,17 +229,17 @@ export function ModelListEditor(props: ModelListEditorProps): ReactNode { setBusy(true) setFailure(undefined) try { - const response = await ctx.remote.llm.discoverModels(probe.settingsNs, { + const answer = await operations.discoverModels(probe.settingsNs, { ...probe.provider === undefined ? {} : { provider: probe.provider }, ...probe.baseURL === undefined || probe.baseURL.length === 0 ? {} : { baseURL: probe.baseURL }, ...probe.api === undefined ? {} : { api: probe.api }, ...probe.apiKey === undefined ? {} : { apiKey: probe.apiKey }, }) - if (!response.ok) { - setFailure(response.error.message) + if (answer.kind === 'refused') { + setFailure(answer.message) return } - const found = response.value + const found = answer.models if (found.length === 0) { setFailure(t('fetchEmpty')) return diff --git a/packages/client/ui-settings-models/src/client/ModelsSection.tsx b/packages/client/ui-settings-models/src/client/ModelsSection.tsx index 4611067f3e..b99767a364 100644 --- a/packages/client/ui-settings-models/src/client/ModelsSection.tsx +++ b/packages/client/ui-settings-models/src/client/ModelsSection.tsx @@ -14,7 +14,6 @@ import { useState } from 'react' import type { ReactNode } from 'react' -import type { Context as ClientContext } from '@deepseek-ai/cordis' import { Button, IconPlusOutline16, Modal } from '@deepseek-ai/dsh-client-ui-primitives' import type { InjectFace, PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' // Type-only: pulls this package's SlotMap merge (the two Models child slots). @@ -22,6 +21,7 @@ import type {} from './slot-contract.ts' import { CustomProviderCard } from './CustomProviderCard.tsx' import { deriveKeyRef, protocolChoices, providerUsable } from './store.ts' import type { ModelsSettingsStore, ProviderRow } from './store.ts' +import type { ModelsOperations } from './operations.ts' import type { SettingsSchemaOperations } from './schema-operations.ts' import { ProviderEditor, type ProviderEditorProps } from './ProviderEditor.tsx' import type { en } from './locales.ts' @@ -35,8 +35,8 @@ export interface ModelsSectionInjected { /** Page snapshot bound by the UI renderer as useSnapshot. */ snapshot: ModelsSettingsStore['store'] } - /** The page plugin's context, whose Remote namespaces the editors write through. */ - ctx: ClientContext + /** The Host operations the section and its cards invoke. */ + operations: ModelsOperations /** Settings schema and immutable path callbacks. */ schema: SettingsSchemaOperations /** Section copy. */ @@ -81,7 +81,7 @@ interface EditorTarget extends ProviderIdentity { /** Values that vary around the shared provider-editor rendering. */ interface ProviderEditorRenderProps extends Pick< ProviderEditorProps, - 'namespace' | 'schema' | 'ctx' | 't' | 'readOnly' | 'onClose' + 'namespace' | 'schema' | 'operations' | 't' | 'readOnly' | 'onClose' > { target: EditorTarget } @@ -105,26 +105,26 @@ function renderProviderEditor({ target, ...props }: ProviderEditorRenderProps): * and the whole operation safely retryable; both unsets are idempotent. * The settings removal names the profile rather than rebuilding its whole * namespace from a partial view. - * @param ctx - the page plugin's context, carrying the settings and credential Remote namespaces. + * @param operations - the page's Host operations. * @param controller - the page store to refresh. * @param target - the provider's settings address and optional managed credential. * @returns the failure message, or undefined once the write and reload landed. */ export async function removeProviderProfile( - ctx: ClientContext, + operations: ModelsOperations, controller: ModelsSettingsStore, target: { settingsNs: string; settingsPath: readonly string[]; credentialRef?: string }, ): Promise { if (target.credentialRef !== undefined) { - const credential = await ctx.remote.credentials.unset(target.credentialRef) - if (!credential.ok) return credential.error.message + const credential = await operations.removeCredential(target.credentialRef) + if (credential !== undefined) return credential } - const response = await ctx.remote.settings.mutate( + const written = await operations.writeSettings( target.settingsNs, [{ op: 'unset', path: [...target.settingsPath] }], undefined, ) - if (!response.ok) return response.error.message + if (written.kind !== 'written') return written.message await controller.load() return undefined } @@ -193,16 +193,16 @@ export function providerCopy(template: string, target: ProviderIdentity): string * @returns the section, or null while the shell has not injected yet. */ export function ModelsSection(props: ModelsSectionProps): ReactNode { - const { controller, useSnapshot, ctx, schema, t, renderSlot } = props + const { controller, useSnapshot, operations, schema, t, renderSlot } = props if ( - controller === undefined || useSnapshot === undefined || ctx === undefined + controller === undefined || useSnapshot === undefined || operations === undefined || schema === undefined || t === undefined ) return null - return + return } function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderSlot: ModelsRenderSlot }): ReactNode { - const { controller, ctx, schema, t } = injected + const { controller, operations, schema, t } = injected const state = injected.useSnapshot(snapshot => snapshot) const [editing, setEditing] = useState(undefined) const [adding, setAdding] = useState(false) @@ -250,7 +250,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS if (deleteTarget === undefined || deleting) return setDeleting(true) setDeleteFailure(undefined) - void removeProviderProfile(ctx, controller, deleteTarget) + void removeProviderProfile(operations, controller, deleteTarget) .then((failure) => { if (failure !== undefined) { setDeleteFailure(failure) @@ -331,7 +331,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS target, namespace, schema, - ctx, + operations, t, readOnly: !state.writable, onClose: (changed) => { closeSetup(changed, target) }, @@ -426,7 +426,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS target, namespace, schema, - ctx, + operations, t, readOnly: !state.writable, onClose: (changed) => { closeEditor(changed, target) }, @@ -466,7 +466,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS namespace={addNamespace} schema={schema} settingsPath={addTarget.settingsPath} - ctx={ctx} + operations={operations} t={t} readOnly={!state.writable} onClose={(changed) => { closeEditor(changed, addTarget) }} @@ -488,7 +488,7 @@ function Loaded({ injected, renderSlot }: { injected: ModelsSectionFace; renderS protocols={protocols} /* v8 ignore next -- the card only opens from a button disabled without this namespace */ revision={state.namespaces.get('llm-pi-ai')?.revision ?? 0} - ctx={ctx} + operations={operations} t={t} readOnly={!state.writable} onClose={(changed) => { diff --git a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx index 3c4cea5aab..b4664ce40f 100644 --- a/packages/client/ui-settings-models/src/client/ProviderEditor.tsx +++ b/packages/client/ui-settings-models/src/client/ProviderEditor.tsx @@ -23,7 +23,6 @@ import { useEffect, useMemo, useState } from 'react' import type { ReactNode } from 'react' -import type { Context as ClientContext } from '@deepseek-ai/cordis' import type { CredentialInfo, JsonValue, SettingsNamespaceView, SettingsPathOpView, } from '@deepseek-ai/dsh-api-remotes/client' @@ -34,6 +33,7 @@ import { apiKeyFailure } from './apiKey.ts' import { EditorFooter } from './EditorFooter.tsx' import { ModelListEditor } from './ModelListEditor.tsx' import { deriveKeyRef, protocolChoices } from './store.ts' +import type { ModelsOperations } from './operations.ts' import type { SettingsSchemaOperations } from './schema-operations.ts' import type { en } from './locales.ts' import styles from './ModelsSection.module.css' @@ -66,8 +66,8 @@ export interface ProviderEditorProps { schema: SettingsSchemaOperations /** Path from the section root to this provider's profile. */ settingsPath: readonly string[] - /** The page plugin's context, whose Remote namespaces carry the writes and the endpoint interrogation. */ - ctx: ClientContext + /** The Host operations this card writes and interrogates through. */ + operations: ModelsOperations /** Section copy. */ t: (key: keyof typeof en) => string /** Disable writes (read-only settings provider). */ @@ -155,7 +155,7 @@ function refFor( * @returns the editor card. */ export function ProviderEditor(props: ProviderEditorProps): ReactNode { - const { namespace, schema, settingsPath, ctx, t } = props + const { namespace, schema, settingsPath, operations, t } = props const [draft, setDraft] = useState>(() => draftAt(schema, namespace, settingsPath)) const [keyDraft, setKeyDraft] = useState('') const [keyState, setKeyState] = useState(undefined) @@ -188,12 +188,12 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { setKeyState(undefined) // The key state is a placeholder hint, not a precondition for editing: a // refused describe leaves the card without the "already configured" hint. - void ctx.remote.credentials.describe([keyRef]).then((response) => { - if (stale || !response.ok) return - setKeyState(response.value[keyRef]) + void operations.describeCredential(keyRef).then((described) => { + if (stale) return + setKeyState(described) }) return () => { stale = true } - }, [ctx, keyRef]) + }, [operations, keyRef]) const stringAt = (source: unknown, key: string): string | undefined => { const value = schema.getPath(source, [key]) @@ -277,19 +277,15 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { ? [{ op: 'set', path: [...settingsPath], value: {} }] : pathOps(settingsPath, committedOriginal, next) if (ops.length > 0) { - const response = await ctx.remote.settings.mutate(ns, ops, expectedRevision) - if (!response.ok) { - return response.error.code === 'settings/conflict' - ? t('conflict') - : response.error.message - } - setCommittedOriginal(schema.getPath(response.value.user, settingsPath)) - setExpectedRevision(response.value.revision) + const written = await operations.writeSettings(ns, ops, expectedRevision) + if (written.kind !== 'written') return written.kind === 'conflict' ? t('conflict') : written.message + setCommittedOriginal(schema.getPath(written.view.user, settingsPath)) + setExpectedRevision(written.view.revision) setDraft(next) } if (keyValue.length > 0) { - const stored = await ctx.remote.credentials.set(keyRef, keyValue) - if (!stored.ok) return stored.error.message + const stored = await operations.storeCredential(keyRef, keyValue) + if (stored !== undefined) return stored } setKeyDraft('') return undefined @@ -464,7 +460,14 @@ export function ProviderEditor(props: ProviderEditorProps): ReactNode { defaultMaxTokens={typeof defaultMaxTokens === 'number' ? defaultMaxTokens : undefined} /> ) - : } + : ( + + )} } diff --git a/packages/client/ui-settings-models/src/client/index.ts b/packages/client/ui-settings-models/src/client/index.ts index 8cb361ead6..721f807092 100644 --- a/packages/client/ui-settings-models/src/client/index.ts +++ b/packages/client/ui-settings-models/src/client/index.ts @@ -23,6 +23,7 @@ import { WelcomeNotice } from './WelcomeNotice.tsx' import type { WelcomeNoticeInjected } from './WelcomeNotice.tsx' import { decodeWelcomeSection, WelcomeNoticeStore } from './welcome-store.ts' import { ModelsSettingsStore } from './store.ts' +import { createModelsOperations } from './operations.ts' import { createSettingsSchemaOperations } from './schema-operations.ts' import { en, zh, type ModelsKey } from './locales.ts' import { WELCOME_NOTICE_SETTINGS_NAMESPACE } from '../onboarding-copy.ts' @@ -43,6 +44,7 @@ const NS = 'settings.models' export type { ModelsSettingsState, ProviderDirectoryEntry, ProviderRow, } from './store.ts' +export type { ModelDiscoveryOutcome, ModelsOperations, SettingsWriteOutcome } from './operations.ts' /** * Refetch the page snapshot only after its first load: an unopened Models @@ -74,6 +76,9 @@ export function apply(ctx: ClientContext): void { ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-settings-models: copy dictionaries') const schema = createSettingsSchemaOperations(ctx.settingsSchema) + // Bound once here, where the Remote namespaces are declared in this plugin's + // own `inject`; the cards receive callbacks and never a context. + const operations = createModelsOperations(ctx) const controller = new ModelsSettingsStore(ctx, schema, ctx.settingsScope.describe()) // Registration-time text (the nav label thunk) and the inject faces share // one bound translate; copy freshness rides the locale revision. @@ -81,14 +86,14 @@ export function apply(ctx: ClientContext): void { const injected = (): ModelsSectionInjected => ({ controller, hooks: { snapshot: controller.store }, - ctx, + operations, schema, t, }) const deepSeekOnboardingInjected = (): DeepSeekOnboardingInjected => ({ controller, hooks: { models: controller.store }, - ctx, + operations, schema, t, }) diff --git a/packages/client/ui-settings-models/src/client/operations.ts b/packages/client/ui-settings-models/src/client/operations.ts new file mode 100644 index 0000000000..8ca6beb798 --- /dev/null +++ b/packages/client/ui-settings-models/src/client/operations.ts @@ -0,0 +1,109 @@ +/** + * The Host reads and writes the Models cards perform, as callbacks built in the + * plugin body. Cards receive these instead of a context: the outcomes name what + * a card renders — a stored view, a stale revision, a refusal message — so the + * failure codes and Remote namespaces stay in the apply world. + */ + +import type { Context as ClientContext } from '@deepseek-ai/cordis' +import type { + CredentialInfo, LlmDiscoveredModel, LlmModelDiscoveryRequest, + SettingsNamespaceView, SettingsPathOpView, +} from '@deepseek-ai/dsh-api-remotes/client' + +/** What one namespace write answered. */ +export type SettingsWriteOutcome = + /** Committed; the view carries the stored user subtree and the new revision. */ + | { readonly kind: 'written'; readonly view: SettingsNamespaceView } + /** + * The stored revision moved after the card read it, so the draft is stale. + * The message stays for callers that report the Host diagnostic as it is. + */ + | { readonly kind: 'conflict'; readonly message: string } + /** Any other refusal, with the Host's own diagnostic. */ + | { readonly kind: 'refused'; readonly message: string } + +/** What one endpoint interrogation answered. */ +export type ModelDiscoveryOutcome = + /** The candidates the provider disclosed, in its own order. */ + | { readonly kind: 'found'; readonly models: readonly LlmDiscoveredModel[] } + /** The interrogation was refused, with the Host's own diagnostic. */ + | { readonly kind: 'refused'; readonly message: string } + +/** The Host operations the Models page and its cards invoke. */ +export interface ModelsOperations { + /** + * Read one credential reference's state. + * @param ref - credential reference name. + * @returns the state, or undefined when the reference is unknown or the read was refused. + */ + describeCredential(ref: string): Promise + /** + * Store one credential literal under its reference. + * @param ref - credential reference name. + * @param value - the literal to store. + * @returns the refusal message, or undefined once stored. + */ + storeCredential(ref: string, value: string): Promise + /** + * Remove one credential reference (idempotent). + * @param ref - credential reference name. + * @returns the refusal message, or undefined once removed. + */ + removeCredential(ref: string): Promise + /** + * Apply path operations to one settings namespace. + * @param ns - settings namespace identity. + * @param ops - ordered path operations against the stored section, as the + * wire takes them (the Remote signature owns the array). + * @param expectedRevision - revision the draft was opened at, or undefined to write unfenced. + * @returns the write outcome the card renders from. + */ + writeSettings( + ns: string, + ops: SettingsPathOpView[], + expectedRevision: number | undefined, + ): Promise + /** + * Ask a provider endpoint what models it serves. + * @param settingsNs - namespace whose adapter family answers. + * @param request - endpoint facts as the form currently shows them. + * @returns the candidates, or the refusal. + */ + discoverModels(settingsNs: string, request: LlmModelDiscoveryRequest): Promise +} + +/** + * Bind the page's Host operations to the plugin's own Remote namespaces. + * @param ctx - the page plugin's context, which declares `remote.credentials`, + * `remote.llm`, and `remote.settings` in its own `inject`. + * @returns the callbacks the section and its cards are injected with. + */ +export function createModelsOperations(ctx: ClientContext): ModelsOperations { + return { + describeCredential: async (ref) => { + const response = await ctx.remote.credentials.describe([ref]) + return response.ok ? response.value[ref] : undefined + }, + storeCredential: async (ref, value) => { + const response = await ctx.remote.credentials.set(ref, value) + return response.ok ? undefined : response.error.message + }, + removeCredential: async (ref) => { + const response = await ctx.remote.credentials.unset(ref) + return response.ok ? undefined : response.error.message + }, + writeSettings: async (ns, ops, expectedRevision) => { + const response = await ctx.remote.settings.mutate(ns, ops, expectedRevision) + if (response.ok) return { kind: 'written', view: response.value } + const { code, message } = response.error + return code === 'settings/conflict' ? { kind: 'conflict', message } : { kind: 'refused', message } + }, + discoverModels: async (settingsNs, request) => { + const response = await ctx.remote.llm.discoverModels(settingsNs, request) + return response.ok + ? { kind: 'found', models: response.value } + : { kind: 'refused', message: response.error.message } + }, + } +} diff --git a/packages/client/ui-settings-models/tests/apply.client.spec.ts b/packages/client/ui-settings-models/tests/apply.client.spec.ts index 1d42d47682..aca0e3bfb7 100644 --- a/packages/client/ui-settings-models/tests/apply.client.spec.ts +++ b/packages/client/ui-settings-models/tests/apply.client.spec.ts @@ -85,7 +85,7 @@ describe('ui-settings-models apply', () => { expect(injected.t('deleteTitle')).toBe('删除 {provider}?') expect(typeof injected.controller.load).toBe('function') expect(injected.hooks.snapshot).toBe(injected.controller.store) - expect(injected.ctx).toBeDefined() + expect(typeof injected.operations.writeSettings).toBe('function') const onboarding = before.slots.entries('settings.onboarding') expect(onboarding).toHaveLength(2) expect(onboarding.find(entry => entry.options.id === 'welcome-notice')).toMatchObject({ @@ -99,7 +99,7 @@ describe('ui-settings-models apply', () => { deepSeek.inject as unknown as () => import('../src/client/DeepSeekOnboardingDialog.tsx').DeepSeekOnboardingInjected )() expect(deepSeekInjected.hooks.models).toBe(injected.controller.store) - expect(deepSeekInjected.ctx).toBeDefined() + expect(typeof deepSeekInjected.operations.storeCredential).toBe('function') const after = await bench() await after.ctx.plugin({ inject: [...inject], apply }).await() diff --git a/packages/client/ui-settings-models/tests/components.client.spec.tsx b/packages/client/ui-settings-models/tests/components.client.spec.tsx index 361e080e32..64b920a81a 100644 --- a/packages/client/ui-settings-models/tests/components.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/components.client.spec.tsx @@ -18,6 +18,8 @@ import { import { apiKeyFailure } from '../src/client/apiKey.ts' import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' import { deriveKeyRef, ModelsSettingsStore } from '../src/client/store.ts' +import { createModelsOperations } from '../src/client/operations.ts' +import type { ModelsOperations } from '../src/client/operations.ts' import type { ProviderRow } from '../src/client/store.ts' import { en } from '../src/client/locales.ts' import { settingsSchema } from './settings-schema.client.ts' @@ -220,6 +222,20 @@ function ctxWith(face: object): PageContext { return ctx } +/** + * The cards' injected Host operations over the same script, bound once per face + * as the plugin body binds them: an editor effect keyed by this face would + * otherwise re-probe on every render. + */ +const operations = new WeakMap() +function operationsWith(face: object): ModelsOperations { + const existing = operations.get(face) + if (existing !== undefined) return existing + const bound = createModelsOperations(ctxWith(face)) + operations.set(face, bound) + return bound +} + /** One recorded child-slot dispatch: seat name, owner share, kind options. */ type RenderSlotCall = [name: string, owner: Record, opts?: { entryKey?: string }] @@ -252,7 +268,7 @@ async function mountFace(scripted: ReturnType) { const injected: ModelsSectionProps = { controller, useSnapshot: bindSnapshotSelector(controller.store), - ctx, + operations: operationsWith(face), schema: settingsSchema, t, renderSlot: renderSlot as unknown as ModelsSectionProps['renderSlot'], @@ -387,7 +403,7 @@ describe('ModelsSection', () => { render( null} @@ -412,7 +428,7 @@ describe('ModelsSection', () => { render( null} @@ -494,7 +510,7 @@ describe('ModelsSection', () => { namespace={wireNamespaces()[0]!} schema={settingsSchema} settingsPath={[]} - ctx={ctxWith(face)} + operations={operationsWith(face)} t={t} readOnly={false} credentialOnly @@ -747,7 +763,7 @@ describe('ModelsSection', () => { namespace={overridden} schema={settingsSchema} settingsPath={[]} - ctx={ctxWith(face)} + operations={operationsWith(face)} t={t} readOnly={false} onClose={() => {}} @@ -977,7 +993,7 @@ describe('ModelsSection', () => { namespace={bare} schema={settingsSchema} settingsPath={[]} - ctx={ctxWith(face)} + operations={operationsWith(face)} t={t} readOnly={false} onClose={() => {}} @@ -1138,7 +1154,7 @@ describe('ModelsSection', () => { render( null} @@ -1272,7 +1288,7 @@ describe('ModelsSection', () => { render( null} @@ -1295,7 +1311,7 @@ describe('ModelsSection', () => { render( null} @@ -1357,7 +1373,7 @@ describe('ModelsSection', () => { render( null} @@ -1370,7 +1386,7 @@ describe('ModelsSection', () => { // would widen the write for no benefit. const { face, mutate, controller } = await mountSection() await removeProviderProfile( - ctxWith(face), + operationsWith(face), controller, { settingsNs: 'llm-plain', settingsPath: ['ghost-profile'] }, ) @@ -1387,7 +1403,7 @@ describe('ModelsSection', () => { }) const before = controller.store.getSnapshot().rows const failure = await removeProviderProfile( - ctxWith(face), + operationsWith(face), controller, { settingsNs: 'llm-pi-ai', settingsPath: ['providers', 'openai'] }, ) @@ -1438,7 +1454,7 @@ describe('ModelsSection', () => { unset: vi.fn(() => Promise.resolve(remoteFail('credential is read-only'))), }) const failure = await removeProviderProfile( - ctxWith(face), + operationsWith(face), controller, { settingsNs: 'llm-pi-ai', diff --git a/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx b/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx index 92d57eb776..59a9bc4cd6 100644 --- a/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/onboarding-dialog.client.spec.tsx @@ -9,6 +9,7 @@ import { DeepSeekOnboardingDialog } from '../src/client/DeepSeekOnboardingDialog import type { DeepSeekOnboardingDialogProps } from '../src/client/DeepSeekOnboardingDialog.tsx' import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' import { ModelsSettingsStore } from '../src/client/store.ts' +import { createModelsOperations } from '../src/client/operations.ts' import { en } from '../src/client/locales.ts' import { settingsSchema } from './settings-schema.client.ts' @@ -130,6 +131,7 @@ function harness(options: { } // The page plugin's context, scripted down to the namespaces it reaches. const ctx = { remote: face } as never + const operations = createModelsOperations(ctx) const controller = new ModelsSettingsStore(ctx, settingsSchema, new SettingsDescribeMirror(ctx)) const openSection = vi.fn() const complete = vi.fn() @@ -143,7 +145,7 @@ function harness(options: { useWorkspaces: unusedHook, controller, useModels: bindSnapshotSelector(controller.store), - ctx, + operations, schema: settingsSchema, t: key => en[key], } diff --git a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx index 96e39a8bfc..1abe0f6664 100644 --- a/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx +++ b/packages/client/ui-settings-models/tests/provider-form.client.spec.tsx @@ -11,6 +11,8 @@ import { CustomProviderCard } from '../src/client/CustomProviderCard.tsx' import { formatCapacity, parseCapacity } from '../src/client/DeepSeekModelsEditor.tsx' import { SettingsDescribeMirror } from '@deepseek-ai/dsh-client-ui-settings/src/client/settings-mirror.ts' import { ModelsSettingsStore, deriveKeyRef, protocolChoices } from '../src/client/store.ts' +import { createModelsOperations } from '../src/client/operations.ts' +import type { ModelsOperations } from '../src/client/operations.ts' import { en } from '../src/client/locales.ts' import { settingsSchema } from './settings-schema.client.ts' @@ -155,6 +157,20 @@ function ctxWith(face: object): PageContext { return ctx } +/** + * The cards' injected Host operations over the same script, bound once per face + * as the plugin body binds them: an editor effect keyed by this face would + * otherwise re-probe on every render. + */ +const operations = new WeakMap() +function operationsWith(face: object): ModelsOperations { + const existing = operations.get(face) + if (existing !== undefined) return existing + const bound = createModelsOperations(ctxWith(face)) + operations.set(face, bound) + return bound +} + /** The settings write one card produced, as the scripted face recorded it. */ interface MutateCall { ns: string @@ -189,7 +205,7 @@ async function mountSection(options: Parameters[0] = {}) { const injected: ModelsSectionProps = { controller, useSnapshot: bindSnapshotSelector(controller.store), - ctx: ctxWith(scripted.face), + operations: operationsWith(scripted.face), schema: settingsSchema, t, renderSlot: () => null, @@ -576,7 +592,7 @@ describe('endpoint interrogation', () => { const scripted = scriptedFace() render( , ) @@ -701,7 +717,7 @@ describe('provider rows', () => { render( null} @@ -725,7 +741,7 @@ describe('hand-declared providers', () => { taken={['openai']} protocols={PROTOCOLS} revision={7} - ctx={ctxWith(scripted.face)} + operations={operationsWith(scripted.face)} t={t} readOnly={false} onClose={onClose} @@ -1152,7 +1168,7 @@ describe('hand-declared providers', () => { it('surfaces a refused write without closing', async () => { const refused = vi.fn(() => Promise.resolve(remoteFail('read-only settings', 'settings/rejected'))) - const { onClose } = mountCard({ ctx: ctxWith(scriptedFace({ mutate: refused }).face) }) + const { onClose } = mountCard({ operations: operationsWith(scriptedFace({ mutate: refused }).face) }) fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } }) fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } }) @@ -1166,7 +1182,7 @@ describe('hand-declared providers', () => { it('translates a create refused by a newer namespace revision', async () => { const conflicting = vi.fn(() => Promise.resolve(remoteFail('changed since it was read', 'settings/conflict'))) - const { onClose } = mountCard({ ctx: ctxWith(scriptedFace({ mutate: conflicting }).face) }) + const { onClose } = mountCard({ operations: operationsWith(scriptedFace({ mutate: conflicting }).face) }) fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } }) fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } }) @@ -1180,7 +1196,7 @@ describe('hand-declared providers', () => { it('reports a stored profile whose key write was refused', async () => { const set = vi.fn(() => Promise.resolve(remoteFail('credential is read-only'))) - const { onClose } = mountCard({ ctx: ctxWith(scriptedFace({ set }).face) }) + const { onClose } = mountCard({ operations: operationsWith(scriptedFace({ set }).face) }) fireEvent.change(screen.getByLabelText(en.customRoute), { target: { value: 'acme' } }) fireEvent.change(screen.getByLabelText(en.baseUrl), { target: { value: 'https://acme.test/v1' } }) diff --git a/packages/client/ui-tool/src/client/apply.ts b/packages/client/ui-tool/src/client/apply.ts index b9ae865929..b0eab4996d 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 { 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' import type {} from '@deepseek-ai/dsh-client-ui-renderer/client' @@ -23,7 +24,14 @@ export const inject = ['slots', 'remote'] * @param ctx - Client root context. */ export function apply(ctx: ClientContext): void { - const toolInject = () => ({ home: ctx.remote.$host.home }) + // 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, + subscribe: listener => ctx.on('connection/reset', listener), + } + const toolInject = () => ({ hooks: { hostHome } }) 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 bf10b4d3b7..fc38496e6d 100644 --- a/packages/client/ui-tool/src/client/contract/slots.ts +++ b/packages/client/ui-tool/src/client/contract/slots.ts @@ -1,5 +1,7 @@ /** Tool UI slot declarations and their composed component props. */ -import type { InjectFace, PropsLocale, PropsRenderSlots, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' +import type { + HostObservable, 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' import type {} from '@deepseek-ai/dsh-client-locale/client' @@ -47,8 +49,15 @@ export type ToolCallViewProps = PropsRuntime<'tool.call.toolview'> /** Injected Host description for POSIX home-path display. */ export type ToolHostHomeInjected = { - /** Host account home, absent until the Connection is ready. */ - home: string | undefined + 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. + */ + hostHome: HostObservable + } } /** Full props of the Tool call-tree renderer registered as a `tool-call` Chat Node. */ diff --git a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx index 1114d29146..7e10afd82c 100644 --- a/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx +++ b/packages/client/ui-tool/src/client/tool/ToolCallTree.tsx @@ -93,8 +93,9 @@ const ToolCallBranch = memo(function ToolCallBranch({ * @returns the Tool call tree. */ export function ToolCallTree({ - renderSlot, node, selectedCallId, cwd, openFile, inspectCall, home, t, + renderSlot, node, selectedCallId, cwd, openFile, inspectCall, useHostHome, t, }: ToolTreeProps) { + const home = useHostHome(value => value) const block = node.data.root return ( ) { + block, cwd, useHostHome, t, +}: Pick) { + const home = useHostHome(value => value) 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 5740154547..26743e3387 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(), - home, + useHostHome: ((selector: (value: string | undefined) => unknown) => selector(home)) as ToolTreeProps['useHostHome'], 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 e301fc4834..756d71877e 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)} 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 d33944a914..7bd9d670f4 100644 --- a/packages/client/ui-workspace/src/client/contract/slots.ts +++ b/packages/client/ui-workspace/src/client/contract/slots.ts @@ -87,9 +87,15 @@ export type DirectoryPickingHooks = PropsHooks + } /** * Start a New Session in a Workspace: reuse-or-create its blank session and * open it; without an explicit workspace, inherit the current Session diff --git a/packages/client/ui-workspace/src/client/index.ts b/packages/client/ui-workspace/src/client/index.ts index ac2e5c6e1a..2f55dd506d 100644 --- a/packages/client/ui-workspace/src/client/index.ts +++ b/packages/client/ui-workspace/src/client/index.ts @@ -89,6 +89,13 @@ 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, + subscribe: listener => ctx.on('connection/reset', listener), + } const pickerFlowSource = flowSource('conversation.hero.workspace.directoryFlow') const browserInjected = (): WorkspaceBrowserInjected => ({ // Explicit group actions keep their target; unscoped New Session inherits @@ -122,8 +129,7 @@ export function apply(ctx: Context): void { await workspaces.insertSessionBefore(workspaceId, sessionId, beforeSessionId) }, createWorkspace: input => workspaces.create(input), - home: ctx.remote.$host.home, - hooks: { directoryFlow: browserFlowSource }, + hooks: { directoryFlow: browserFlowSource, hostHome }, }) 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 a312a9b878..09295168c5 100644 --- a/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx +++ b/packages/client/ui-workspace/src/client/rows/WorkspaceBrowser.tsx @@ -820,10 +820,11 @@ export function WorkspaceBrowser({ searchSessions, searchResultLimit, useDirectoryFlow, - home, + useHostHome, renderSlot, t, }: WorkspaceBrowserProps) { + const home = useHostHome(value => value) 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 511c6c33f9..87536d4646 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.home).toBeUndefined() + expect(browser.hooks.hostHome.getSnapshot()).toBeUndefined() expect(picker.hooks.directoryFlow.getSnapshot()).toBe(false) // A flow occupant flips exactly its own surface, and the source notifies. const notified = vi.fn() diff --git a/packages/client/ui-workspace/tests/host-home-staleness.client.spec.tsx b/packages/client/ui-workspace/tests/host-home-staleness.client.spec.tsx new file mode 100644 index 0000000000..1b3e256c97 --- /dev/null +++ b/packages/client/ui-workspace/tests/host-home-staleness.client.spec.tsx @@ -0,0 +1,91 @@ +// @vitest-environment jsdom +/** + * Host home reaches the browsing region through the assembled renderer, which + * memoizes a root entry's inject result for the whole registration — so a home + * read once at first render would freeze there. This spec drives the real slot + * renderer (not a direct `entry.inject()` call, which bypasses that memo) and + * pins that a home learned after first render reaches the rendered rows. + */ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, screen } from '@testing-library/react' +import type { WorkspaceId } from '@deepseek-ai/dsh-api-workspace-controller/client' +import type { PropsRenderSlots } from '@deepseek-ai/dsh-client-ui-slots' +import { SlotTestRuntime, TestRemote, usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' +import { LocaleRuntime } from '@deepseek-ai/dsh-client-locale/client' +import { apply, inject } from '@deepseek-ai/dsh-client-ui-workspace/client' + +usePinnedBrowserLanguages('zh-CN') + +afterEach(cleanup) +beforeEach(() => { localStorage.clear() }) + +/** Test-owned sidebar shell role: declares and renders the browsing region. */ +type FrameProps = PropsRenderSlots<'sidebar.workspaces'> +function SidebarFrame({ renderSlot }: FrameProps) { + return <>{renderSlot('sidebar.workspaces', { wide: true, expandSidebar: () => {} })} +} + +/** The assembled sidebar over one Workspace inside the POSIX home the Host reports. */ +async function bench() { + const runtime = await SlotTestRuntime.create() + runtime.releaseWorkspaceSource() + const directoryPicker = {} + const remote = new TestRemote(runtime.ctx) + Object.assign(remote, { directoryPicker }) + runtime.ctx.provide('remote.directoryPicker', directoryPicker as never) + const locale = new LocaleRuntime(runtime.ctx) + runtime.ctx.provide('locale', locale) + runtime.slots.installLocale(locale) + await runtime.workspaces.update((draft) => { + draft.items = [{ + workspaceId: 'w1' as WorkspaceId, title: 'Project', path: '/home/u/Documents/project', + sessionIds: [], createdAt: '2026-01-01T00:00:00.000Z', updatedAt: '2026-01-01T00:00:00.000Z', + }] as never + }) + await runtime.root.declare( + { 'sidebar.workspaces': { kind: 'single', scope: 'root' } } as never, + SidebarFrame as never, + ) + await runtime.mount({ inject: [...inject], apply }) + return { runtime, remote } +} + +/** Open the Workspace row's hover card, which is where the home abbreviation shows. */ +function openHoverCard(): void { + const row = screen.getByRole('treeitem').parentElement as HTMLElement + fireEvent.pointerEnter(row) + act(() => { vi.advanceTimersByTime(500) }) +} + +/** Close it again, so the next hover rebuilds the card from current props. */ +function closeHoverCard(): void { + const row = screen.getByRole('treeitem').parentElement as HTMLElement + fireEvent.pointerLeave(row) + act(() => { vi.advanceTimersByTime(500) }) +} + +describe('Host home in the assembled browsing region', () => { + it('abbreviates the path once a home learned after first render reaches the rows', async () => { + // First render precedes the ready frame: the shell mounts while the carrier + // is still handshaking, so the Host reports no home yet. + const { runtime, remote } = await bench() + remote.$host = { home: undefined, isLoopback: true } + runtime.renderRoot() + vi.useFakeTimers() + try { + openHoverCard() + expect(screen.getByText('/home/u/Documents/project')).toBeTruthy() + closeHoverCard() + + // The ready frame lands: `$host.home` now answers, and the generation is + // announced through the reset every consumer already listens to. + remote.$host = { home: '/home/u', isLoopback: true } + act(() => { runtime.ctx.emit('connection/reset') }) + openHoverCard() + + expect(screen.getByText('~/Documents/project')).toBeTruthy() + } finally { + vi.useRealTimers() + } + }) +}) 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 e9933f3ea3..afb973a6df 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: () => () => {} }), - home: undefined, + useHostHome: selector => selector(undefined), 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', }])), - home: '/home/u', + useHostHome: selector => selector('/home/u'), }) fireEvent.pointerEnter(screen.getByRole('treeitem').parentElement as HTMLElement) act(() => { vi.advanceTimersByTime(500) })