diff --git a/apps/cli/package.json b/apps/cli/package.json index 0220656cc2..d651ba2537 100644 --- a/apps/cli/package.json +++ b/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:^", diff --git a/docs/config-catalog.md b/docs/config-catalog.md index cf2ebc362a..88a429f14e 100644 --- a/docs/config-catalog.md +++ b/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)) diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 578089d8a8..f3f19a15fb 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/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' diff --git a/packages/client/connection/tests/fake-api.ts b/packages/client/connection/tests/fake-api.ts index e5eb42695d..faa433e67e 100644 --- a/packages/client/connection/tests/fake-api.ts +++ b/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)), } diff --git a/packages/client/runtime/tests/fake-api.ts b/packages/client/runtime/tests/fake-api.ts index e50574d102..9694ea9cfb 100644 --- a/packages/client/runtime/tests/fake-api.ts +++ b/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)), } diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml new file mode 100644 index 0000000000..aee8c88bb2 --- /dev/null +++ b/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 diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md new file mode 100644 index 0000000000..ecfa962cdc --- /dev/null +++ b/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. diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md new file mode 100644 index 0000000000..915777875e --- /dev/null +++ b/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 是文件系统行为;本表层只在名单提供的范围内做选择。 diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json new file mode 100644 index 0000000000..e82613cc0e --- /dev/null +++ b/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" + ] +} diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetRow.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetRow.module.css new file mode 100644 index 0000000000..d0f7134329 --- /dev/null +++ b/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; +} diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetRow.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetRow.tsx new file mode 100644 index 0000000000..492f593134 --- /dev/null +++ b/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 + } + /** Load the roster when the row first renders. */ + load: () => Promise + /** Persist one preset as the default for later sessions. */ + select: (id: string) => Promise +} + +/** Full component props. */ +export type AgentPresetRowProps = + PropsRuntime<'settings.general.item'> + & PropsLocale<'settings.agentPreset'> + & InjectFace + +/** + * 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 ( +
+
+
{t('title')}
+
{description}
+
+ { 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={( + + )} + /> +
+ ) +} + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Agent-preset row copy. */ + 'settings.agentPreset': AgentPresetSettingsKey + } +} diff --git a/packages/client/ui-agent-preset/src/client/index.ts b/packages/client/ui-agent-preset/src/client/index.ts new file mode 100644 index 0000000000..2101e69630 --- /dev/null +++ b/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)) +} diff --git a/packages/client/ui-agent-preset/src/client/locales.ts b/packages/client/ui-agent-preset/src/client/locales.ts new file mode 100644 index 0000000000..6bcc2767d4 --- /dev/null +++ b/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 = { + 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 = { + title: 'Agent preset', + description: '对此后新建的会话生效。运行中的会话保持它开始时的 preset。', + loading: '正在加载 preset…', + error: '无法加载 agent preset。', + userTrust: '本地', +} diff --git a/packages/client/ui-agent-preset/src/client/settings-store.ts b/packages/client/ui-agent-preset/src/client/settings-store.ts new file mode 100644 index 0000000000..fcc092d179 --- /dev/null +++ b/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 = createSnapshotStore(INITIAL) + + constructor(private readonly api: IApiClient) {} + + private set(patch: Partial): 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 { + 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 { + 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), + }) + } + } +} diff --git a/packages/client/ui-agent-preset/src/css-modules.d.ts b/packages/client/ui-agent-preset/src/css-modules.d.ts new file mode 100644 index 0000000000..8811db1264 --- /dev/null +++ b/packages/client/ui-agent-preset/src/css-modules.d.ts @@ -0,0 +1,4 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} diff --git a/packages/client/ui-agent-preset/src/index.ts b/packages/client/ui-agent-preset/src/index.ts new file mode 100644 index 0000000000..c145962f1d --- /dev/null +++ b/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 {} diff --git a/packages/client/ui-agent-preset/src/invariant.ts b/packages/client/ui-agent-preset/src/invariant.ts new file mode 100644 index 0000000000..1794763066 --- /dev/null +++ b/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 */ diff --git a/packages/client/ui-agent-preset/tests/settings-store.spec.ts b/packages/client/ui-agent-preset/tests/settings-store.spec.ts new file mode 100644 index 0000000000..cd6a38ee74 --- /dev/null +++ b/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') + }) +}) diff --git a/packages/client/ui-agent-preset/tsconfig.json b/packages/client/ui-agent-preset/tsconfig.json new file mode 100644 index 0000000000..3d17153642 --- /dev/null +++ b/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" + } + ] +} diff --git a/packages/client/ui-agent-preset/tsdown.config.ts b/packages/client/ui-agent-preset/tsdown.config.ts new file mode 100644 index 0000000000..3ede4df6a8 --- /dev/null +++ b/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']) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1d8a398a9c..ad50acbb2b 100644 --- a/pnpm-lock.yaml +++ b/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: diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 5ef1edc4f9..d36d95e743 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -47,6 +47,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { '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.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index 3c7c2ba1d2..3836fdf250 100644 --- a/tsconfig.base.json +++ b/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"], diff --git a/tsconfig.client.json b/tsconfig.client.json index e1d4088061..d30a42fc43 100644 --- a/tsconfig.client.json +++ b/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" },