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.
This commit is contained in:
@@ -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
packages/client/ui-agent-preset/src/client/AgentPresetRow.tsx
Normal file
104
packages/client/ui-agent-preset/src/client/AgentPresetRow.tsx
Normal file
@@ -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
packages/client/ui-agent-preset/src/client/index.ts
Normal file
64
packages/client/ui-agent-preset/src/client/index.ts
Normal file
@@ -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
packages/client/ui-agent-preset/src/client/locales.ts
Normal file
23
packages/client/ui-agent-preset/src/client/locales.ts
Normal file
@@ -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
packages/client/ui-agent-preset/src/client/settings-store.ts
Normal file
114
packages/client/ui-agent-preset/src/client/settings-store.ts
Normal file
@@ -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
packages/client/ui-agent-preset/src/css-modules.d.ts
vendored
Normal file
4
packages/client/ui-agent-preset/src/css-modules.d.ts
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
declare module '*.module.css' {
|
||||
const classes: Record<string, string>
|
||||
export default classes
|
||||
}
|
||||
9
packages/client/ui-agent-preset/src/index.ts
Normal file
9
packages/client/ui-agent-preset/src/index.ts
Normal file
@@ -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
packages/client/ui-agent-preset/src/invariant.ts
Normal file
30
packages/client/ui-agent-preset/src/invariant.ts
Normal file
@@ -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 */
|
||||
Reference in New Issue
Block a user