Sfoglia il codice sorgente

refactor(ui-agent-preset): consume the preset Remote

imccyu 3 settimane fa
parent
commit
ef1c812d93

+ 4 - 1
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<void>> {
   const disposers: Array<() => Promise<void>> = []
   try {
     for (const contribution of [
-      commandsRemote, goalsRemote, dynamicRemote, fileReferencesRemote,
+      agentPresetsRemote, commandsRemote, goalsRemote, dynamicRemote, fileReferencesRemote,
       pluginInventoryRemote, messageFeedbackRemote, sessionReferencesRemote,
       sessionRemote, workspaceRemote,
     ]) {

+ 4 - 5
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]
     })

+ 27 - 31
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<IApiClient, 'agentPresets'>,
+    private readonly remote: Pick<ClientRemote, 'agentPresets'>,
     /** 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<void> {
-    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({

+ 29 - 18
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<AgentPresetSectionState> = createSnapshotStore(INITIAL)
 
   constructor(
-    private readonly api: Pick<IApiClient, 'agentPresets' | 'settings'>,
+    private readonly api: Pick<IApiClient, 'agentPresets' | 'settings' | 'host'>,
+    private readonly remote: Pick<ClientRemote, 'agentPresets'>,
     /**
      * 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<void> {
-    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<void> {
     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 })

+ 25 - 40
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<IApiClient, 'agentPresets'>): Promise<RosterRead> {
+export async function readRoster(remote: Pick<ClientRemote, 'agentPresets'>): Promise<RosterRead> {
   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<IApiClient, 'agentPresets'>): 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<S extends { status: string; error: string | null }>(
-  api: Pick<IApiClient, 'agentPresets'>,
+  remote: Pick<ClientRemote, 'agentPresets'>,
   store: SnapshotStore<S>,
-): Promise<RosterValue | undefined> {
+): Promise<AgentPresetRoster | undefined> {
   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<AgentPresetSettingsState> = 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<IApiClient, 'settings'>,
+    private readonly remote: Pick<ClientRemote, 'agentPresets'>,
     private readonly describeFace: SettingsDescribeFace,
   ) {}
 
@@ -212,7 +197,7 @@ export class AgentPresetSettingsController {
    * @returns once the snapshot reflects the host.
    */
   async load(): Promise<void> {
-    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