Quellcode durchsuchen

feat(web): choose the default agent preset from General settings

One settings row picking which preset new sessions are composed from.

It is deliberately a new-session preference, not a live switch: a session's
preset is fixed at creation and the host refuses to adopt an existing session
under a different one, so the row says "applies to sessions you start from now
on" rather than implying it can retune a running agent.

Options and the current value come from one `agentPreset.list` call — the
roster already reports which id an unspecified session gets, so the row needs
no settings-schema introspection, unlike the permission row it is modelled on.
The write targets only the namespace's `default` field.

The menu marks `user` rows: a locally authored preset is exactly as privileged
as the plugins it names, and presenting it identically to a shipped one would
hide that.

An empty roster reads as `unavailable` and renders nothing, because composing
no presets is a valid deployment rather than a failure — distinct from a
roster call that failed, which surfaces its message.
Yichen Jiang vor 1 Monat
Ursprung
Commit
6758da87ae

+ 1 - 0
apps/cli/package.json

@@ -19,6 +19,7 @@
     "@cordisjs/plugin-timer": "workspace:*",
     "@deepseek-ai/dsh-app-boot": "workspace:^",
     "@deepseek-ai/dsh-base": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^",
     "@deepseek-ai/dsh-headless": "workspace:^",
     "@deepseek-ai/dsh-mcp-client": "workspace:^",
     "@deepseek-ai/dsh-paths": "workspace:^",

+ 1 - 0
docs/config-catalog.md

@@ -2482,6 +2482,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co
 - `@deepseek-ai/dsh-client-locale` ([`packages/client/locale/src/index.ts`](../packages/client/locale/src/index.ts))
 - `@deepseek-ai/dsh-client-modules` — requires `httpServer` · `loader` ([`packages/client/modules/src/index.ts`](../packages/client/modules/src/index.ts))
 - `@deepseek-ai/dsh-client-runtime` ([`packages/client/runtime/src/index.ts`](../packages/client/runtime/src/index.ts))
+- `@deepseek-ai/dsh-client-ui-agent-preset` ([`packages/client/ui-agent-preset/src/index.ts`](../packages/client/ui-agent-preset/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-command` ([`packages/client/ui-command/src/index.ts`](../packages/client/ui-command/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-conversation` ([`packages/client/ui-conversation/src/index.ts`](../packages/client/ui-conversation/src/index.ts))
 - `@deepseek-ai/dsh-client-ui-goal` ([`packages/client/ui-goal/src/index.ts`](../packages/client/ui-goal/src/index.ts))

+ 5 - 0
packages/bundle/web-app/cordis.patch.yml

@@ -180,6 +180,11 @@
     - id: ui-permission
       name: '@deepseek-ai/dsh-client-ui-permission'
 
+    # The agent-preset row in General settings: the default preset for
+    # sessions created later. Absent a roster it renders nothing.
+    - id: ui-agent-preset
+      name: '@deepseek-ai/dsh-client-ui-agent-preset'
+
     # Plan control: the composer plan seat over the plan projection + /plan channel.
     - id: ui-plan
       name: '@deepseek-ai/dsh-client-ui-plan'

+ 4 - 0
packages/client/connection/tests/fake-api.ts

@@ -167,6 +167,10 @@ export class FakeApiClient implements IApiClient {
     execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)),
   }
 
+  readonly agentPresets: IApiClient['agentPresets'] = {
+    list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [] }))),
+  }
+
   readonly skills: IApiClient['skills'] = {
     list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)),
   }

+ 4 - 0
packages/client/runtime/tests/fake-api.ts

@@ -202,6 +202,10 @@ export class FakeApiClient implements IApiClient {
     execute: (payload: unknown) => this.record('command.execute', payload, this.onCommandExecute(payload)),
   }
 
+  readonly agentPresets: IApiClient['agentPresets'] = {
+    list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [] }))),
+  }
+
   readonly skills: IApiClient['skills'] = {
     list: (payload: unknown) => this.record('skill.list', payload, this.onSkillList(payload)),
   }

+ 6 - 0
packages/client/ui-agent-preset/README.i18n.yaml

@@ -0,0 +1,6 @@
+# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
+# side as of the last confirmed-consistent state. Both languages carry equal authority;
+# after editing either side, bring the other along and re-record with:
+#   pnpm run verify-translation-pairing --write packages/client/ui-agent-preset/README.md
+README.md: c22d5a40659bf6300f133ff902fbc3bf8bc75276
+README.zh.md: 6b7e43adcdfe7d6de781511edb5d91a4c45cf11e

+ 35 - 0
packages/client/ui-agent-preset/README.md

@@ -0,0 +1,35 @@
+# dsh-client-ui-agent-preset
+
+English | [中文](README.zh.md)
+
+The agent-preset surface: one General-settings row choosing which [preset](../../preset/agent-presets/README.md) new sessions are composed from.
+
+## Why it is a new-session preference
+
+A session's preset is fixed when the session is created — the host refuses to adopt an existing session under a different one, because that session's history was produced under the first preset's tools. So this row cannot be a live switch, and it says so: changing it applies to sessions started afterwards while running sessions keep the composition they began with.
+
+## What it reads and writes
+
+Options and the current default both come from one `agentPreset.list` call. The roster already reports which id a session with no explicit choice gets, so the row needs no settings-schema introspection; the write targets the `agent-presets` settings namespace's `default` field, which is what the host resolves at creation.
+
+A locally authored preset is exactly as privileged as the plugins it names, so the list marks `user` rows rather than presenting every preset as shipped and vetted.
+
+The row re-reads on `settings/changed` for its own namespace and on `connection/reset`: the roster is a live directory and the default is a settings field, so an external edit or a reconnect can both move it.
+
+## When the row is absent
+
+A deployment that composes no presets answers with an empty roster, and the row renders nothing — every session then shares the host composition, and there is nothing to choose between.
+
+## Model Experience
+
+Indirectly, through the preset a later session is composed from; [`dsh-agent-presets`](../../preset/agent-presets/README.md) owns what that composition puts in front of the model.
+
+#### KV Cache effect
+
+No direct invalidation. Changing the default never touches a running session's prefix; a session created afterwards establishes its own prefix from its own composition.
+
+## Known Limitations and Deferred Work
+
+- **No per-session choice at creation** — this row sets the default only. The wire already carries `agentPreset` on `session.create`, so a session-start surface can offer the choice; that surface does not exist yet.
+- **Presets are listed by id** — a preset carries no display metadata, so the menu shows directory names.
+- **No authoring** — creating, editing, or deleting a preset is a filesystem act; this surface only chooses among what the roster supplies.

+ 35 - 0
packages/client/ui-agent-preset/README.zh.md

@@ -0,0 +1,35 @@
+# dsh-client-ui-agent-preset
+
+[English](README.md) | 中文
+
+agent preset 表层:General 设置中的一行,用于选择新建会话据以组装的 [preset](../../preset/agent-presets/README.md)。
+
+## 为什么它是"新建会话"的偏好设置
+
+会话的 preset 在创建时即固定——宿主拒绝以不同 preset 接管已存在的会话,因为该会话的历史是在最初那份 preset 的工具下产生的。因此本行不可能是实时切换,它也如实说明了这一点:更改只对此后开启的会话生效,而运行中的会话保持它们开始时的组装。
+
+## 它读什么、写什么
+
+选项与当前默认值都来自同一次 `agentPreset.list` 调用。名单本身已经报告了"未显式选择的会话会得到哪个 id",因此本行无需对 settings schema 做内省;写入目标是 `agent-presets` settings 命名空间的 `default` 字段,也正是宿主在创建时解析的那个字段。
+
+本地创作的 preset 的权限恰好等于它所引用的插件,因此列表会标注 `user` 行,而不是把每个 preset 都呈现为随附且已审核的。
+
+本行在自身命名空间的 `settings/changed` 以及 `connection/reset` 时重新读取:名单是一个活动目录,默认值是一项设置,外部编辑与重新连接都可能改变它。
+
+## 何时不显示本行
+
+未组装任何 preset 的部署返回空名单,本行不渲染任何内容——此时每个会话共用宿主组装,也就无从选择。
+
+## Model Experience
+
+Indirectly, through the preset a later session is composed from; [`dsh-agent-presets`](../../preset/agent-presets/README.md) owns what that composition puts in front of the model.
+
+#### KV Cache effect
+
+没有直接的失效影响。更改默认值绝不触及运行中会话的前缀;此后创建的会话依据它自己的组装建立自己的前缀。
+
+## Known Limitations and Deferred Work
+
+- **创建时无法逐会话选择** —— 本行只设置默认值。wire 上 `session.create` 已经携带 `agentPreset`,因此会话开启表层可以提供该选择;该表层尚不存在。
+- **preset 按 id 列出** —— preset 不携带展示用元数据,因此菜单显示的是目录名。
+- **不提供创作能力** —— 创建、编辑或删除 preset 是文件系统行为;本表层只在名单提供的范围内做选择。

+ 67 - 0
packages/client/ui-agent-preset/package.json

@@ -0,0 +1,67 @@
+{
+  "name": "@deepseek-ai/dsh-client-ui-agent-preset",
+  "description": "Agent-preset surface: the default preset for later sessions, in General settings",
+  "version": "0.0.1",
+  "private": true,
+  "type": "module",
+  "main": "lib/index.js",
+  "types": "lib/types/index.d.ts",
+  "exports": {
+    ".": {
+      "types": "./lib/types/index.d.ts",
+      "default": "./lib/index.js"
+    },
+    "./invariant": {
+      "types": "./lib/types/invariant.d.ts",
+      "default": "./lib/invariant.js"
+    },
+    "./client": {
+      "types": "./lib/types/client/index.d.ts",
+      "default": "./lib/client.js"
+    },
+    "./src/*": "./src/*",
+    "./package.json": "./package.json"
+  },
+  "dshClient": {
+    "inject": [
+      "@deepseek-ai/dsh-client-connection",
+      "@deepseek-ai/dsh-client-locale",
+      "@deepseek-ai/dsh-client-runtime"
+    ],
+    "platform": "web"
+  },
+  "scripts": {
+    "bundle": "tsdown",
+    "watch": "tsdown --watch"
+  },
+  "license": "BSD-3-Clause",
+  "peerDependencies": {
+    "@deepseek-ai/dsh-client-connection": "^0.0.1",
+    "@deepseek-ai/dsh-client-locale": "^0.0.1",
+    "@deepseek-ai/dsh-client-runtime": "^0.0.1",
+    "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1",
+    "@deepseek-ai/dsh-client-ui-slots": "^0.0.1",
+    "@deepseek-ai/dsh-invariants": "^0.0.1",
+    "cordis": "^4.0.0-rc.7",
+    "react": "^18.2.0"
+  },
+  "devDependencies": {
+    "@deepseek-ai/dsh-client-connection": "workspace:^",
+    "@deepseek-ai/dsh-client-locale": "workspace:^",
+    "@deepseek-ai/dsh-client-runtime": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
+    "@deepseek-ai/dsh-client-ui-slots": "workspace:^",
+    "@deepseek-ai/dsh-invariants": "workspace:^",
+    "@types/react": "~18.3.1",
+    "cordis": "^4.0.0-rc.7",
+    "react": "^18.2.0"
+  },
+  "files": [
+    "lib/index.js",
+    "lib/invariant.js",
+    "lib/client.js",
+    "lib/types/**/*.d.ts",
+    "lib/types/**/*.d.ts.map",
+    "src"
+  ]
+}

+ 60 - 0
packages/client/ui-agent-preset/src/client/AgentPresetRow.module.css

@@ -0,0 +1,60 @@
+/* Agent-preset row: title/description plus the preset selector pill. */
+
+.row {
+  display: flex;
+  align-items: center;
+  gap: 8px;
+  padding: 16px 0;
+  border-bottom: 1px solid var(--dsw-alias-border-l2);
+}
+
+.rowText {
+  flex: 1;
+  min-width: 0;
+  display: flex;
+  flex-direction: column;
+  gap: 4px;
+  padding-right: 48px;
+}
+
+.title {
+  font-size: 14px;
+  font-weight: 400;
+  line-height: 22px;
+  color: var(--dsw-alias-label-primary);
+}
+
+.desc {
+  font-size: 12px;
+  font-weight: 400;
+  line-height: 18px;
+  color: var(--dsw-alias-label-tertiary);
+}
+
+.selector {
+  display: inline-flex;
+  align-items: center;
+  gap: 12px;
+  height: 36px;
+  padding: 0 14px;
+  border: none;
+  border-radius: 18px;
+  background: var(--dsw-alias-bg-module-platform);
+  font: inherit;
+  font-size: 14px;
+  line-height: 22px;
+  color: var(--dsw-alias-label-primary);
+  cursor: pointer;
+}
+
+.selector:hover:not(:disabled) {
+  background: var(--dsw-alias-interactive-bg-hover);
+}
+
+.selector:disabled {
+  cursor: default;
+}
+
+.chevron {
+  flex: none;
+}

+ 104 - 0
packages/client/ui-agent-preset/src/client/AgentPresetRow.tsx

@@ -0,0 +1,104 @@
+/**
+ * Agent-preset preference row: the preset new sessions are composed from.
+ * A running session keeps the composition it began with, so this row never
+ * disturbs work in progress.
+ */
+
+import { useEffect, useState } from 'react'
+import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
+import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
+import { IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
+import type { AgentPresetSettingsState } from './settings-store.ts'
+import type { AgentPresetSettingsKey } from './locales.ts'
+import css from './AgentPresetRow.module.css'
+
+/** Registration-side business face for the host-backed preference. */
+export interface AgentPresetRowInjected {
+  hooks: {
+    /** Agent-preset settings snapshot bound by the renderer as useAgentPreset. */
+    agentPreset: SnapshotStore<AgentPresetSettingsState>
+  }
+  /** Load the roster when the row first renders. */
+  load: () => Promise<void>
+  /** Persist one preset as the default for later sessions. */
+  select: (id: string) => Promise<void>
+}
+
+/** Full component props. */
+export type AgentPresetRowProps =
+  PropsRuntime<'settings.general.item'>
+  & PropsLocale<'settings.agentPreset'>
+  & InjectFace<AgentPresetRowInjected>
+
+/**
+ * Render the new-session agent-preset selector.
+ * @param props - composed slot props.
+ * @returns the row, or null when the deployment composes no presets.
+ */
+export function AgentPresetRow({ load, select, useAgentPreset, t }: AgentPresetRowProps) {
+  const state = useAgentPreset(snapshot => snapshot)
+  const [open, setOpen] = useState(false)
+
+  useEffect(() => {
+    void load()
+  }, [load])
+
+  useEffect(() => {
+    if (state.writable && state.status !== 'unavailable') return
+    setOpen(false)
+  }, [state.status, state.writable])
+
+  // A deployment that composes no presets has nothing to choose between, and
+  // every session shares the host composition — the row simply does not exist.
+  if (state.status === 'unavailable') return null
+  const busy = state.status === 'loading' || state.status === 'saving'
+  const label = state.currentValue === '' ? t('loading') : state.currentValue
+  const description: string = state.error ?? t('description')
+
+  return (
+    <div className={css.row}>
+      <div className={css.rowText}>
+        <div className={css.title}>{t('title')}</div>
+        <div className={css.desc} role={state.error === null ? undefined : 'alert'}>{description}</div>
+      </div>
+      <Menu
+        open={open}
+        onClose={() => { setOpen(false) }}
+        // A locally authored preset is exactly as privileged as the plugins it
+        // names, so the list says which rows are local rather than presenting
+        // every preset as shipped and vetted.
+        items={state.options.map(option => ({
+          id: option.id,
+          label: option.trust === 'user' ? `${option.id} · ${t('userTrust')}` : option.id,
+        }))}
+        selectedId={state.currentValue}
+        onSelect={(id) => {
+          setOpen(false)
+          void select(id)
+        }}
+        align="end"
+        portal
+        anchor={(
+          <button
+            type="button"
+            className={css.selector}
+            aria-haspopup="menu"
+            aria-expanded={open}
+            disabled={busy || !state.writable || state.options.length === 0}
+            onClick={() => { setOpen(value => !value) }}
+          >
+            {label}
+            <IconChevronDownOutline14 className={css.chevron} />
+          </button>
+        )}
+      />
+    </div>
+  )
+}
+
+declare module '@deepseek-ai/dsh-client-ui-slots' {
+  interface LocaleNamespaceMap {
+    /** Agent-preset row copy. */
+    'settings.agentPreset': AgentPresetSettingsKey
+  }
+}

+ 64 - 0
packages/client/ui-agent-preset/src/client/index.ts

@@ -0,0 +1,64 @@
+/**
+ * Agent-preset surface plugin, browser half — one General-settings row that
+ * writes the default preset for sessions created later.
+ *
+ * A running session keeps the composition it began with (the host refuses to
+ * adopt an existing session under a different preset), so this row is a
+ * new-session preference rather than a live switch. Per-session choice at
+ * creation time belongs to the session-start surface, which reads the same
+ * roster.
+ */
+
+import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
+// Type-only: pulls the locale plugin's Context merge (ctx.locale).
+import type {} from '@deepseek-ai/dsh-client-locale/client'
+import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
+import { AgentPresetRow } from './AgentPresetRow.tsx'
+import type { AgentPresetRowInjected } from './AgentPresetRow.tsx'
+import { en, zh } from './locales.ts'
+import { AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController } from './settings-store.ts'
+
+export type { AgentPresetRowInjected, AgentPresetRowProps } from './AgentPresetRow.tsx'
+export type { AgentPresetOption, AgentPresetSettingsState } from './settings-store.ts'
+export { AGENT_PRESET_SETTINGS_NS } from './settings-store.ts'
+
+/** Required services (cordis fiber inject). */
+export const inject = ['slots', 'locale', 'connection']
+
+/**
+ * Mount the General-settings row.
+ * @param ctx - the browser plugin context.
+ */
+export function apply(ctx: ClientContext): void {
+  const controller = new AgentPresetSettingsController((ctx.get('connection') as ConnectionHandle).api)
+
+  ctx.effect(() => ctx.locale.register('settings.agentPreset', { zh, en }), 'ui-agent-preset: settings row dictionaries')
+
+  const injected = (): AgentPresetRowInjected => ({
+    hooks: { agentPreset: controller.store },
+    load: () => controller.load(),
+    select: (id: string) => controller.select(id),
+  })
+
+  ctx.effect(() => {
+    // The roster is a live directory and the default is a settings field, so
+    // both an external settings edit and a reconnect can move this row.
+    const refresh = (ns?: string): void => {
+      if (ns !== undefined && ns !== AGENT_PRESET_SETTINGS_NS) return
+      void controller.load()
+    }
+    const disposers = [
+      ctx.on('settings/changed', refresh),
+      ctx.on('connection/reset', () => { refresh() }),
+    ]
+    return () => { for (const dispose of disposers) dispose() }
+  }, 'ui-agent-preset: settings refresh')
+
+  ctx.slots.inject('settings.general.item', () => ctx.slots.register({
+    name: 'settings.general.item',
+    id: 'agent-preset',
+    order: -25,
+    locale: 'settings.agentPreset',
+    inject: injected,
+  }, AgentPresetRow))
+}

+ 23 - 0
packages/client/ui-agent-preset/src/client/locales.ts

@@ -0,0 +1,23 @@
+/** Locale bundles for the agent-preset General-settings row. */
+
+/** Locale keys this row renders. */
+export type AgentPresetSettingsKey =
+  | 'title' | 'description' | 'loading' | 'error' | 'userTrust'
+
+/** English copy. */
+export const en: Record<AgentPresetSettingsKey, string> = {
+  title: 'Agent preset',
+  description: 'Applies to sessions you start from now on. Running sessions keep the preset they began with.',
+  loading: 'Loading presets…',
+  error: 'Could not load agent presets.',
+  userTrust: 'Local',
+}
+
+/** Simplified Chinese copy. */
+export const zh: Record<AgentPresetSettingsKey, string> = {
+  title: 'Agent preset',
+  description: '对此后新建的会话生效。运行中的会话保持它开始时的 preset。',
+  loading: '正在加载 preset…',
+  error: '无法加载 agent preset。',
+  userTrust: '本地',
+}

+ 114 - 0
packages/client/ui-agent-preset/src/client/settings-store.ts

@@ -0,0 +1,114 @@
+/**
+ * Agent-preset default-settings controller.
+ *
+ * Options and the current default both come from one `agentPreset.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-client-connection/client'
+import { createSnapshotStore, type SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
+
+/** The agent-preset settings namespace on the host wire. */
+export const AGENT_PRESET_SETTINGS_NS = 'agent-presets'
+
+/** One selectable preset. */
+export interface AgentPresetOption {
+  /** Preset id, written to Settings and shown as the label. */
+  id: string
+  /** Whether the preset ships with the deployment or was authored locally. */
+  trust: 'system' | 'user'
+}
+
+/** Agent-preset settings-row snapshot. */
+export interface AgentPresetSettingsState {
+  status: 'idle' | 'loading' | 'ready' | 'saving' | 'unavailable' | 'error'
+  error: string | null
+  writable: boolean
+  currentValue: string
+  options: readonly AgentPresetOption[]
+}
+
+const INITIAL: AgentPresetSettingsState = {
+  status: 'idle',
+  error: null,
+  writable: true,
+  currentValue: '',
+  options: [],
+}
+
+/** Reads the roster and persists the chosen default. */
+export class AgentPresetSettingsController {
+  /** Row snapshot the renderer subscribes to. */
+  readonly store: SnapshotStore<AgentPresetSettingsState> = createSnapshotStore(INITIAL)
+
+  constructor(private readonly api: IApiClient) {}
+
+  private set(patch: Partial<AgentPresetSettingsState>): void {
+    this.store.set({ ...this.store.getSnapshot(), ...patch })
+  }
+
+  /**
+   * Load the roster. An empty roster means the deployment composes no
+   * presets, which is a valid deployment rather than a failure — the row
+   * reports `unavailable` and renders nothing.
+   * @returns once the snapshot reflects the host.
+   */
+  async load(): Promise<void> {
+    if (this.store.getSnapshot().status === 'loading') return
+    this.set({ status: 'loading', error: null })
+    try {
+      const response = await this.api.agentPresets.list({})
+      if (!response.result.ok) {
+        this.set({ status: 'error', error: response.result.error.message })
+        return
+      }
+      const presets = response.result.value.presets
+      if (presets.length === 0) {
+        this.set({ status: 'unavailable', options: [], currentValue: '' })
+        return
+      }
+      this.set({
+        status: 'ready',
+        error: null,
+        options: presets.map(preset => ({ id: preset.id, trust: preset.trust })),
+        currentValue: presets.find(preset => preset.isDefault)?.id ?? presets[0]?.id ?? '',
+      })
+    } catch (error) {
+      this.set({ status: 'error', error: error instanceof Error ? error.message : String(error) })
+    }
+  }
+
+  /**
+   * Persist one preset as the default for sessions created later. Running
+   * sessions keep the composition they were created with, so this never
+   * disturbs work in progress.
+   * @param id - the preset to make default.
+   * @returns once the write settled and the roster was re-read.
+   */
+  async select(id: string): Promise<void> {
+    const before = this.store.getSnapshot()
+    if (before.status === 'saving' || id === before.currentValue) return
+    this.set({ status: 'saving', error: null, currentValue: id })
+    try {
+      const response = await this.api.settings.update({
+        ns: AGENT_PRESET_SETTINGS_NS,
+        patch: { default: id },
+      })
+      if (!response.result.ok) {
+        this.set({ status: 'ready', currentValue: before.currentValue, error: response.result.error.message })
+        return
+      }
+      // Re-read rather than trust the patch: the host resolves the default
+      // through the same roster the row displays.
+      await this.load()
+    } catch (error) {
+      this.set({
+        status: 'ready',
+        currentValue: before.currentValue,
+        error: error instanceof Error ? error.message : String(error),
+      })
+    }
+  }
+}

+ 4 - 0
packages/client/ui-agent-preset/src/css-modules.d.ts

@@ -0,0 +1,4 @@
+declare module '*.module.css' {
+  const classes: Record<string, string>
+  export default classes
+}

+ 9 - 0
packages/client/ui-agent-preset/src/index.ts

@@ -0,0 +1,9 @@
+/**
+ * Agent-preset surface plugin, node half. The empty apply exists so the plugin
+ * appears in the host cordis.yml / Loader; the browser half ships the
+ * General-settings row through exports["./client"], discovered from the
+ * package.json dshClient declaration.
+ */
+
+/** Host plugin body — no host-side behavior for this surface plugin. */
+export function apply(): void {}

+ 30 - 0
packages/client/ui-agent-preset/src/invariant.ts

@@ -0,0 +1,30 @@
+/**
+ * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-agent-preset`.
+ * @module @deepseek-ai/dsh-client-ui-agent-preset/invariant
+ */
+
+/* jscpd:ignore-start */
+import type { Context } from 'cordis'
+import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
+
+const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-agent-preset'
+
+/** Cordis companion plugin name. */
+export const name = 'client-ui-agent-preset-invariant'
+/** Service required before the companion can reserve package ownership. */
+export const inject = ['invariants']
+
+/**
+ * No runtime invariant: this is a browser-side surface plugin whose node half owns no event stream
+ * or mutable runtime data; the roster and the settings write are host contracts covered there.
+ */
+const install: InvariantInstaller = () => {}
+
+/**
+ * Register this package's invariant companion.
+ * @param ctx - Cordis context carrying the invariant service.
+ * @returns the installed registration's disposer after setup succeeds.
+ */
+export const apply = (ctx: Context): Promise<() => void> =>
+  Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install))
+/* jscpd:ignore-end */

+ 122 - 0
packages/client/ui-agent-preset/tests/settings-store.spec.ts

@@ -0,0 +1,122 @@
+/**
+ * The agent-preset settings controller: it derives both the options and the
+ * current default from one roster call, writes only the `default` field, and
+ * treats an empty roster as "this deployment composes no presets" rather than
+ * as a failure.
+ */
+
+import { describe, expect, it } from 'vitest'
+import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
+import {
+  AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController,
+} from '../src/client/settings-store.ts'
+
+interface Recorded { ns: string; patch: unknown }
+
+/** A client whose roster and write outcome the test controls. */
+function fakeApi(
+  presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[],
+  options: { writes?: Recorded[]; failWrite?: string; failList?: string } = {},
+): IApiClient {
+  return {
+    agentPresets: {
+      list: () => Promise.resolve(options.failList === undefined
+        ? { rpcId: 'r', result: { ok: true as const, value: { presets } } }
+        : { rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message: options.failList, details: {} } } }),
+    },
+    settings: {
+      update: (payload: { ns: string; patch: unknown }) => {
+        options.writes?.push({ ns: payload.ns, patch: payload.patch })
+        if (options.failWrite !== undefined) {
+          return Promise.resolve({ rpcId: 'r', result: { ok: false as const, error: { code: 'internal', message: options.failWrite, details: {} } } })
+        }
+        // A committed write moves the roster's default, exactly as the host does.
+        for (const preset of presets) {
+          preset.isDefault = preset.id === (payload.patch as { default?: string }).default
+        }
+        return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: {} } })
+      },
+    },
+  } as unknown as IApiClient
+}
+
+describe('the agent-preset settings controller', () => {
+  it('derives options and the current default from one roster call', async () => {
+    const controller = new AgentPresetSettingsController(fakeApi([
+      { id: 'standard', trust: 'system', isDefault: true },
+      { id: 'mine', trust: 'user', isDefault: false },
+    ]))
+
+    await controller.load()
+
+    const state = controller.store.getSnapshot()
+    expect(state.status).toBe('ready')
+    expect(state.currentValue).toBe('standard')
+    expect(state.options).toEqual([
+      { id: 'standard', trust: 'system' },
+      { id: 'mine', trust: 'user' },
+    ])
+  })
+
+  it('reports an empty roster as unavailable, not as an error', async () => {
+    const controller = new AgentPresetSettingsController(fakeApi([]))
+
+    await controller.load()
+
+    // A deployment composing no presets is valid: every session shares the
+    // host composition and the row renders nothing.
+    expect(controller.store.getSnapshot().status).toBe('unavailable')
+    expect(controller.store.getSnapshot().error).toBeNull()
+  })
+
+  it('writes only the default field, into the agent-presets namespace', async () => {
+    const writes: Recorded[] = []
+    const controller = new AgentPresetSettingsController(fakeApi([
+      { id: 'standard', trust: 'system', isDefault: true },
+      { id: 'core-web', trust: 'system', isDefault: false },
+    ], { writes }))
+    await controller.load()
+
+    await controller.select('core-web')
+
+    expect(writes).toEqual([{ ns: AGENT_PRESET_SETTINGS_NS, patch: { default: 'core-web' } }])
+    expect(controller.store.getSnapshot().currentValue).toBe('core-web')
+  })
+
+  it('restores the previous value and surfaces the message when the write fails', async () => {
+    const controller = new AgentPresetSettingsController(fakeApi([
+      { id: 'standard', trust: 'system', isDefault: true },
+      { id: 'core-web', trust: 'system', isDefault: false },
+    ], { failWrite: 'read-only settings' }))
+    await controller.load()
+
+    await controller.select('core-web')
+
+    const state = controller.store.getSnapshot()
+    expect(state.currentValue).toBe('standard')
+    expect(state.error).toBe('read-only settings')
+    expect(state.status).toBe('ready')
+  })
+
+  it('ignores a pick that is already the default', async () => {
+    const writes: Recorded[] = []
+    const controller = new AgentPresetSettingsController(fakeApi([
+      { id: 'standard', trust: 'system', isDefault: true },
+    ], { writes }))
+    await controller.load()
+
+    await controller.select('standard')
+
+    expect(writes).toEqual([])
+  })
+
+  it('surfaces a roster failure without claiming the deployment has no presets', async () => {
+    const controller = new AgentPresetSettingsController(fakeApi([], { failList: 'host down' }))
+
+    await controller.load()
+
+    const state = controller.store.getSnapshot()
+    expect(state.status).toBe('error')
+    expect(state.error).toBe('host down')
+  })
+})

+ 33 - 0
packages/client/ui-agent-preset/tsconfig.json

@@ -0,0 +1,33 @@
+{
+  "extends": "../../../tsconfig.base.client.json",
+  "compilerOptions": {
+    "rootDir": "src",
+    "outDir": "lib/types"
+  },
+  "include": [
+    "src"
+  ],
+  "references": [
+    {
+      "path": "../connection"
+    },
+    {
+      "path": "../locale"
+    },
+    {
+      "path": "../../../vendor/cordis"
+    },
+    {
+      "path": "../runtime"
+    },
+    {
+      "path": "../ui-primitives"
+    },
+    {
+      "path": "../ui-slots"
+    },
+    {
+      "path": "../../support/invariants"
+    }
+  ]
+}

+ 3 - 0
packages/client/ui-agent-preset/tsdown.config.ts

@@ -0,0 +1,3 @@
+import { clientBundle } from '../tsdown.client.ts'
+
+export default clientBundle('@deepseek-ai/dsh-client-ui-agent-preset', ['lib/types/index.js', 'lib/types/invariant.js'])

+ 33 - 0
pnpm-lock.yaml

@@ -146,6 +146,9 @@ importers:
       '@deepseek-ai/dsh-base':
         specifier: workspace:^
         version: link:../../packages/bundle/base
+      '@deepseek-ai/dsh-client-ui-agent-preset':
+        specifier: workspace:^
+        version: link:../../packages/client/ui-agent-preset
       '@deepseek-ai/dsh-headless':
         specifier: workspace:^
         version: link:../../packages/bundle/headless
@@ -1432,6 +1435,36 @@ importers:
         specifier: ^18.2.0
         version: 18.3.1(react@18.3.1)
 
+  packages/client/ui-agent-preset:
+    devDependencies:
+      '@deepseek-ai/dsh-client-connection':
+        specifier: workspace:^
+        version: link:../connection
+      '@deepseek-ai/dsh-client-locale':
+        specifier: workspace:^
+        version: link:../locale
+      '@deepseek-ai/dsh-client-runtime':
+        specifier: workspace:^
+        version: link:../runtime
+      '@deepseek-ai/dsh-client-ui-primitives':
+        specifier: workspace:^
+        version: link:../ui-primitives
+      '@deepseek-ai/dsh-client-ui-slots':
+        specifier: workspace:^
+        version: link:../ui-slots
+      '@deepseek-ai/dsh-invariants':
+        specifier: workspace:^
+        version: link:../../support/invariants
+      '@types/react':
+        specifier: ~18.3.1
+        version: 18.3.31
+      cordis:
+        specifier: ^4.0.0-rc.7
+        version: link:../../../vendor/cordis
+      react:
+        specifier: ^18.2.0
+        version: 18.3.1
+
   packages/client/ui-command:
     dependencies:
       clsx:

+ 1 - 0
scripts/verify-package-readme-model-experience.ts

@@ -47,6 +47,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
   'packages/bash/pwsh-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-pwsh.' },
   'packages/code-runtime/code-runtime': { kind: 'indirect', reason: 'The service interface delegates model rendering to Code Mode in dsh-tools.' },
   'packages/code-runtime/code-runtime-worker': { kind: 'indirect', reason: 'The worker backend delegates model rendering to Code Mode in dsh-tools.' },
+  'packages/client/ui-agent-preset': { kind: 'indirect', reason: 'Browser-side settings row; the preset it selects owns every model-facing effect.' },
   'packages/preset/agent-presets': { kind: 'indirect', reason: 'The mount installs a preset\'s own plugins, which own every model-facing registration it makes visible.' },
   'packages/typert/registry': { kind: 'none', reason: 'Runtime type registry; consumers (cordis_inspect, wire faces, gates) own any model-visible projection of registry contents.' },
   'packages/typert/loader': { kind: 'none', reason: 'Loader integration only registers generated artifacts; consumers own any model-visible projection.' },

+ 1 - 0
tsconfig.base.json

@@ -157,6 +157,7 @@
       "@deepseek-ai/dsh-client-ui-command": ["./packages/client/ui-command/src"],
       "@deepseek-ai/dsh-client-ui-model": ["./packages/client/ui-model/src"],
       "@deepseek-ai/dsh-client-ui-goal": ["./packages/client/ui-goal/src"],
+      "@deepseek-ai/dsh-client-ui-agent-preset": ["./packages/client/ui-agent-preset/src"],
       "@deepseek-ai/dsh-client-ui-permission": ["./packages/client/ui-permission/src"],
       "@deepseek-ai/dsh-client-ui-skill": ["./packages/client/ui-skill/src"],
       "@deepseek-ai/dsh-client-ui-subagent": ["./packages/client/ui-subagent/src"],

+ 1 - 0
tsconfig.client.json

@@ -63,6 +63,7 @@
     { "path": "./packages/client/ui-subagent" },
     { "path": "./packages/client/ui-goal" },
     { "path": "./packages/client/ui-model" },
+    { "path": "./packages/client/ui-agent-preset" },
     { "path": "./packages/client/ui-permission" },
     { "path": "./packages/client/ui-plan" },
     { "path": "./packages/client/ui-question" },