From ef1c812d93c30d22151b1cd7a1f6b901d4bb7576 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Wed, 26 Aug 2026 10:55:04 +0800 Subject: [PATCH] refactor(ui-agent-preset): consume the preset Remote --- packages/api/remotes/src/client/index.ts | 5 +- .../ui-agent-preset/src/client/index.ts | 9 ++- .../ui-agent-preset/src/client/seat-store.ts | 58 ++++++++--------- .../src/client/section-store.ts | 47 +++++++++----- .../src/client/settings-store.ts | 65 +++++++------------ 5 files changed, 89 insertions(+), 95 deletions(-) diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index 859230eff7..6956336198 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -1,6 +1,7 @@ /** Platform-neutral assembly of generated Host Remote contributions. */ import type { Context } from '@deepseek-ai/cordis' +import agentPresetsRemote from '@deepseek-ai/dsh-agent-presets/remote' import commandsRemote from '@deepseek-ai/dsh-commands/remote' import goalsRemote from '@deepseek-ai/dsh-goal/remote' import dynamicRemote from '@deepseek-ai/dsh-cordis-host-runner/remote' @@ -14,6 +15,7 @@ import type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' export type { ClientRemote } from '@deepseek-ai/dsh-api-gateway/client' export type { PluginInventorySnapshot } from '@deepseek-ai/dsh-host-plugin-inventory/types' +export type {} from '@deepseek-ai/dsh-agent-presets/remote' export type {} from '@deepseek-ai/dsh-commands/remote' export type {} from '@deepseek-ai/dsh-file-reference/remote' export type {} from '@deepseek-ai/dsh-goal/remote' @@ -106,6 +108,7 @@ export type { SessionReferenceMentionCandidate } from '@deepseek-ai/dsh-session- /** Failure vocabulary exposed by the assembled Client data layer. */ export type ClientFailure = | import('@deepseek-ai/dsh-client-connection/client').RpcError + | import('@deepseek-ai/dsh-agent-presets/types').AgentPresetError | import('@deepseek-ai/dsh-api-session-controller/types').SessionError | import('@deepseek-ai/dsh-api-workspace-controller/types').WorkspaceError @@ -133,7 +136,7 @@ export async function apply(ctx: Context): Promise<() => Promise> { const disposers: Array<() => Promise> = [] try { for (const contribution of [ - commandsRemote, goalsRemote, dynamicRemote, fileReferencesRemote, + agentPresetsRemote, commandsRemote, goalsRemote, dynamicRemote, fileReferencesRemote, pluginInventoryRemote, messageFeedbackRemote, sessionReferencesRemote, sessionRemote, workspaceRemote, ]) { diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts index 28a50c034f..b8bcecdcce 100644 --- a/packages/client/ui-agent-preset/src/client/index.ts +++ b/packages/client/ui-agent-preset/src/client/index.ts @@ -50,7 +50,7 @@ export type { AgentPresetOption, AgentPresetSettingsState } from './settings-sto export { AGENT_PRESET_SETTINGS_NS, writeDefaultPreset } from './settings-store.ts' /** Required services (cordis fiber inject). */ -export const inject = ['slots', 'locale', 'connection', 'remote', 'settingsScope'] +export const inject = ['slots', 'locale', 'connection', 'remote', 'remote.agentPresets', 'settingsScope'] /** * Mount the General-settings row. @@ -58,11 +58,11 @@ export const inject = ['slots', 'locale', 'connection', 'remote', 'settingsScope */ export function apply(ctx: ClientContext): void { const { api } = ctx.get('connection') as ConnectionHandle - const controller = new AgentPresetSettingsController(api, ctx.settingsScope.describe()) + const controller = new AgentPresetSettingsController(api, ctx.remote, ctx.settingsScope.describe()) // One roster, four surfaces. The chip is registered in a later scope, so it // subscribes here rather than being reached from this one. const rosterReaders = new Set<() => void>() - const section = new AgentPresetSectionController(api, () => { + const section = new AgentPresetSectionController(api, ctx.remote, () => { void controller.load() for (const read of rosterReaders) read() }) @@ -104,8 +104,7 @@ export function apply(ctx: ClientContext): void { // The new-session chip and the header label: one controller, because the // staged choice belongs to the flow rather than to any one session. ctx.inject(['slots', 'conversation', 'sessions', 'uiWorkspace'], (scope: ClientContext) => { - const api = (scope.get('connection') as ConnectionHandle).api - const seat = new AgentPresetSeatController(api, () => { + const seat = new AgentPresetSeatController(scope.remote, () => { const state = scope.sessions.list.getSnapshot() return state.current === undefined ? undefined : state.byId[state.current] }) diff --git a/packages/client/ui-agent-preset/src/client/seat-store.ts b/packages/client/ui-agent-preset/src/client/seat-store.ts index b16e803ac5..8e2714bce1 100644 --- a/packages/client/ui-agent-preset/src/client/seat-store.ts +++ b/packages/client/ui-agent-preset/src/client/seat-store.ts @@ -10,11 +10,11 @@ * deployment default again, matching the workspace picker beside it. */ -import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client' +import type { ClientRemote } from '@deepseek-ai/dsh-api-remotes/client' import type { SessionSummary } from '@deepseek-ai/dsh-api-session-controller/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import type {} from '@deepseek-ai/dsh-agent-presets/types' -import { messageOf, presetOptions } from './settings-store.ts' +import { messageOf, presetOptions, readRoster } from './settings-store.ts' import type { AgentPresetOption } from './settings-store.ts' /** Hero-chip snapshot. */ @@ -53,7 +53,7 @@ export class AgentPresetSeatController { private staged: string | undefined constructor( - private readonly api: Pick, + private readonly remote: Pick, /** The session the hero is about to hand over to, when there is one. */ private readonly currentSession: () => Pick< SessionSummary, @@ -67,32 +67,28 @@ export class AgentPresetSeatController { /** * Read the roster and open the chip on the deployment default. - * @returns once the snapshot reflects the host. - */ + * @returns once the snapshot reflects the host. + */ async load(): Promise { - try { - const response = await this.api.agentPresets.list({}) - if (!response.result.ok) { - this.set({ error: response.result.error.message }) - return - } - const { presets } = response.result.value - this.fallback = presets.find(preset => preset.isDefault)?.id ?? presets[0]?.id ?? '' - const session = this.currentSession() - this.set({ - options: presetOptions(presets), - // Staged pick first, then the composition the current session - // already carries, then the deployment default. The middle term is - // what keeps a late-landing load from regressing the display after - // an applied stage was consumed — the chip mounts (and loads) only - // once the flow's session is current, so the reply can arrive after - // apply() already composed it. - current: this.staged ?? (session === undefined ? this.fallback : presetOf(session) ?? ''), - error: null, - }) - } catch (error) { - this.set({ error: messageOf(error) }) + const roster = await readRoster(this.remote) + if (!roster.ok) { + this.set({ error: roster.error }) + return } + const { presets } = roster.value + this.fallback = presets.find(preset => preset.isDefault)?.id ?? presets[0]?.id ?? '' + const session = this.currentSession() + this.set({ + options: presetOptions(presets), + // Staged pick first, then the composition the current session + // already carries, then the deployment default. The middle term is + // what keeps a late-landing load from regressing the display after + // an applied stage was consumed — the chip mounts (and loads) only + // once the flow's session is current, so the reply can arrive after + // apply() already composed it. + current: this.staged ?? (session === undefined ? this.fallback : presetOf(session) ?? ''), + error: null, + }) } /** @@ -153,18 +149,18 @@ export class AgentPresetSeatController { } this.set({ busy: true, error: null }) try { - const response = await this.api.agentPresets.select({ sessionId: session.id, agentPreset: staged }) + const result = await this.remote.agentPresets.select(session.id, staged) this.staged = undefined - if (!response.result.ok) { + if (!result.ok) { this.set({ busy: false, - error: response.result.error.message, + error: result.error.message, current: presetOf(session) ?? '', }) return } // Consumed: the next new session opens on the deployment default again. - this.set({ busy: false, current: response.result.value.agentPreset }) + this.set({ busy: false, current: result.value }) } catch (error) { this.staged = undefined this.set({ diff --git a/packages/client/ui-agent-preset/src/client/section-store.ts b/packages/client/ui-agent-preset/src/client/section-store.ts index a4af289990..0d034cd833 100644 --- a/packages/client/ui-agent-preset/src/client/section-store.ts +++ b/packages/client/ui-agent-preset/src/client/section-store.ts @@ -14,7 +14,7 @@ * more than the row it targeted. */ -import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client' +import type { ClientRemote, IApiClient } from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' import { beginRosterRead, messageOf, writeDefaultPreset } from './settings-store.ts' @@ -133,7 +133,8 @@ export class AgentPresetSectionController { readonly store: SnapshotStore = createSnapshotStore(INITIAL) constructor( - private readonly api: Pick, + private readonly api: Pick, + private readonly remote: Pick, /** * Called after this page changes the roster DIRECTORY, so the other * surfaces reading the same roster re-read it. A settings field moving is @@ -162,9 +163,18 @@ export class AgentPresetSectionController { * @returns once the snapshot reflects the host. */ async load(): Promise { - const roster = await beginRosterRead(this.api, this.store) + // Whether a preset's directory can be opened is the Host's opener + // capability rather than a roster property, so the page joins the two. + // Issued together: one round trip decides the page, and a load that waited + // for them in turn would hold the section in `loading` twice as long, + // where a concurrent reload silently returns instead of refreshing. + const opener = this.api.host.describe({}) + const roster = await beginRosterRead(this.remote, this.store) + // A refused describe leaves the reveal-the-path path, which needs no opener. + const described = await opener.catch(() => undefined) if (roster === undefined) return - const { presets, authorable, hasDocument } = roster + const { presets, authorable } = roster + const hasDocument = described?.result.ok === true && described.result.value.canOpenPath if (presets.length === 0) { // Nothing to manage leaves nothing to keep a dialog open over. this.set({ status: 'unavailable', rows: [], authorable, hasDocument, copy: null, view: null }) @@ -193,12 +203,12 @@ export class AgentPresetSectionController { async view(id: string): Promise { this.set({ error: null }) try { - const response = await this.api.agentPresets.read({ agentPreset: id }) - if (!response.result.ok) { - this.set({ error: response.result.error.message }) + const result = await this.remote.agentPresets.read(id) + if (!result.ok) { + this.set({ error: result.error.message }) return } - const { name, content } = response.result.value + const { name, content } = result.value this.set({ view: { id, title: name ?? id, content } }) } catch (error) { this.set({ error: messageOf(error) }) @@ -256,13 +266,14 @@ export class AgentPresetSectionController { this.patchCopy({ saving: true, error: null }) try { const name = draft.name.trim() - const response = await this.api.agentPresets.copy({ - from: draft.from, - agentPreset: draft.id, - ...name === '' ? {} : { name }, - }) - if (!response.result.ok) { - this.patchCopy({ saving: false, error: response.result.error.message }) + // Every declared parameter is passed even when optional: the Remote face + // checks arity against the declaration and rejects a short call. An + // empty display name goes as `undefined` — absent rather than empty, so + // the host falls back to the id instead of labelling the row with ''. + const result = await this.remote.agentPresets.copy( + draft.from, draft.id, name === '' ? undefined : name) + if (!result.ok) { + this.patchCopy({ saving: false, error: result.error.message }) return } this.set({ copy: null }) @@ -318,9 +329,9 @@ export class AgentPresetSectionController { if (pendingDelete === null || deleting) return this.set({ deleting: true, error: null }) try { - const response = await this.api.agentPresets.remove({ agentPreset: pendingDelete }) - if (!response.result.ok) { - this.set({ deleting: false, pendingDelete: null, error: response.result.error.message }) + const result = await this.remote.agentPresets.deletePreset(pendingDelete) + if (!result.ok) { + this.set({ deleting: false, pendingDelete: null, error: result.error.message }) return } this.set({ deleting: false, pendingDelete: null }) diff --git a/packages/client/ui-agent-preset/src/client/settings-store.ts b/packages/client/ui-agent-preset/src/client/settings-store.ts index 8c7e7444f1..cbae273243 100644 --- a/packages/client/ui-agent-preset/src/client/settings-store.ts +++ b/packages/client/ui-agent-preset/src/client/settings-store.ts @@ -1,14 +1,15 @@ /** * Agent-preset default-settings controller. * - * Options and the current default both come from one `agentPreset.list` call: + * Options and the current default both come from one `agentPresets.list` call: * the roster already reports which id a session with no explicit choice gets, * so the row needs no schema introspection. Writes target the settings * namespace's `default` field, which is what the host resolves at creation. */ -import type { IApiClient } from '@deepseek-ai/dsh-api-remotes/client' +import type { ClientRemote, IApiClient } from '@deepseek-ai/dsh-api-remotes/client' import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-store' +import type { AgentPresetRoster } from '@deepseek-ai/dsh-agent-presets/types' import type { SettingsDescribeFace } from '@deepseek-ai/dsh-client-ui-settings/client' /** The agent-preset settings namespace on the host wire. */ @@ -63,33 +64,12 @@ export interface AgentPresetOption { } /** One roster entry exactly as the host reports it. */ -export interface RosterPreset { - /** Preset id and directory name. */ - id: string - /** Whether the preset ships with the deployment or was authored locally. */ - trust: 'system' | 'user' - /** Whether a session that names no preset gets this one. */ - isDefault: boolean - /** Display name the preset published, absent when it published none. */ - name?: string - /** One sentence on what the preset is for. */ - description?: string - /** Why the preset cannot compose a session, absent when it can. */ - broken?: string -} - -/** The roster the host answered with. */ -export interface RosterValue { - /** Every preset the deployment composes, in the order the host lists them. */ - presets: readonly RosterPreset[] - /** Whether this browser may author presets at all. */ - authorable: boolean - /** Whether the host can open a preset directory on a native desktop. */ - hasDocument: boolean -} +export type RosterPreset = AgentPresetRoster['presets'][number] /** The roster, or the message to show in its place. */ -export type RosterRead = { ok: true; value: RosterValue } | { ok: false; error: string } +export type RosterRead = { ok: true; value: AgentPresetRoster } | { ok: false; error: string } + +const EMPTY_ROSTER: AgentPresetRoster = { presets: [], authorable: false } /** * Read the roster, folding both refusal shapes into one message. @@ -98,15 +78,18 @@ export type RosterRead = { ok: true; value: RosterValue } | { ok: false; error: * `ok: false` envelope — and every surface treats them identically. Folding * them here keeps each store's `load` about what it does with a roster rather * than about how the call can fail. - * @param api - the agent-preset wire face. + * @param remote - the agent-preset Remote namespace. * @returns the roster, or the message to show in its place. */ -export async function readRoster(api: Pick): Promise { +export async function readRoster(remote: Pick): Promise { try { - const response = await api.agentPresets.list({}) - return response.result.ok - ? { ok: true, value: response.result.value } - : { ok: false, error: response.result.error.message } + const result = await remote.agentPresets.list() + if (result.ok) return { ok: true, value: result.value } + // Agent presets are optional: without that service every session uses the + // Host composition, so callers receive the same empty roster as a mounted + // service with no configured roots. + if (result.error.code === 'invocation-unavailable') return { ok: true, value: EMPTY_ROSTER } + return { ok: false, error: result.error.message } } catch (error) { return { ok: false, error: messageOf(error) } } @@ -119,18 +102,18 @@ export async function readRoster(api: Pick): Promise * A surface that gets `undefined` returns without touching its snapshot * further — either another read owns it, or this one already wrote the * failure. What differs between surfaces starts after this. - * @param api - the agent-preset wire face. + * @param remote - the agent-preset Remote namespace. * @param store - the surface's own snapshot store. * @returns the roster, or undefined when the caller should return. */ export async function beginRosterRead( - api: Pick, + remote: Pick, store: SnapshotStore, -): Promise { +): Promise { const before = store.getSnapshot() if (before.status === 'loading') return undefined store.set({ ...before, status: 'loading', error: null }) - const roster = await readRoster(api) + const roster = await readRoster(remote) if (roster.ok) return roster.value store.set({ ...store.getSnapshot(), status: 'error', error: roster.error }) return undefined @@ -193,11 +176,13 @@ export class AgentPresetSettingsController { readonly store: SnapshotStore = createSnapshotStore(INITIAL) /** - * @param api - the agent-preset and settings wire faces (roster and default write). + * @param api - the settings wire face (the default write). + * @param remote - the agent-preset Remote namespace (the roster read). * @param describeFace - the shared mirror's describe face (writability source). */ constructor( - private readonly api: IApiClient, + private readonly api: Pick, + private readonly remote: Pick, private readonly describeFace: SettingsDescribeFace, ) {} @@ -212,7 +197,7 @@ export class AgentPresetSettingsController { * @returns once the snapshot reflects the host. */ async load(): Promise { - const roster = await beginRosterRead(this.api, this.store) + const roster = await beginRosterRead(this.remote, this.store) if (roster === undefined) return const { presets } = roster const [first] = presets