mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
feat(web): pick a session's agent preset from the composer
A seat in the composer tool row, left of the model select, showing the preset THIS session runs — its own recorded one, not the deployment default, because a resumed session runs what it was created with. `SessionSummary` carries `agentPreset` for that, alongside `cwd` and `origin`. The switch exists only while the conversation has not started. After the first turn the seat becomes a plain label rather than a disabled menu: a greyed control reads as "temporarily unavailable", when in fact the choice is gone for good. The seat never asks in that state, and the host refuses independently with `agent-preset-locked`, so a stale client cannot slip one past it. A rejected switch restores the previous value and surfaces the host's message rather than leaving the seat showing something the session is not running.
This commit is contained in:
@@ -169,6 +169,8 @@ export class FakeApiClient implements IApiClient {
|
||||
|
||||
readonly agentPresets: IApiClient['agentPresets'] = {
|
||||
list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [] }))),
|
||||
select: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.select', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))),
|
||||
}
|
||||
|
||||
readonly skills: IApiClient['skills'] = {
|
||||
|
||||
@@ -25,6 +25,8 @@ export interface SessionListEntry {
|
||||
/** Coarse durable origin for navigation filtering; not a continuation capability. */
|
||||
origin?: 'subagent'
|
||||
cwd?: string
|
||||
/** Agent preset the session's agent was composed from (summary passthrough). */
|
||||
agentPreset?: string
|
||||
/** Current host-computed projection values for list consumers. */
|
||||
projectionValues?: Readonly<Partial<SessionProjectionMap>>
|
||||
/** User interaction currently blocking this session, derived from live mux frames. */
|
||||
|
||||
@@ -45,6 +45,12 @@ export interface SessionSummary {
|
||||
/** Human-facing label: durable title, project basename, then session id. */
|
||||
displayTitle: string
|
||||
cwd?: string
|
||||
/**
|
||||
* Agent preset this session's agent was composed from; absent when the
|
||||
* deployment composes no presets. A composer seat shows what the session
|
||||
* actually runs rather than the deployment's current default.
|
||||
*/
|
||||
agentPreset?: string
|
||||
parentId?: SessionId
|
||||
/** Coarse durable origin for navigation filtering; not a continuation capability. */
|
||||
origin?: 'subagent'
|
||||
@@ -629,6 +635,7 @@ export class SessionsService implements ISessions {
|
||||
...(entry.cwd !== undefined ? { cwd: entry.cwd } : {}),
|
||||
...(entry.parentSessionId !== undefined ? { parentId: entry.parentSessionId } : {}),
|
||||
...(entry.origin !== undefined ? { origin: entry.origin } : {}),
|
||||
...(entry.agentPreset !== undefined ? { agentPreset: entry.agentPreset } : {}),
|
||||
}
|
||||
}
|
||||
if (current !== undefined && currentAddress !== undefined) {
|
||||
|
||||
@@ -204,6 +204,8 @@ export class FakeApiClient implements IApiClient {
|
||||
|
||||
readonly agentPresets: IApiClient['agentPresets'] = {
|
||||
list: (payload: unknown) => this.record('agentPreset.list', payload, Promise.resolve(ok({ presets: [] }))),
|
||||
select: (payload: { agentPreset: string }) =>
|
||||
this.record('agentPreset.select', payload, Promise.resolve(ok({ agentPreset: payload.agentPreset }))),
|
||||
}
|
||||
|
||||
readonly skills: IApiClient['skills'] = {
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# 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
|
||||
README.md: 322fc7c8ba6eb621f27cb09475079e3d5bccf03f
|
||||
README.zh.md: 6eece3248d7f3c3652a30579f907eaf5f888f35f
|
||||
|
||||
@@ -8,6 +8,12 @@ The agent-preset surface: one General-settings row choosing which [preset](../..
|
||||
|
||||
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.
|
||||
|
||||
## The composer seat
|
||||
|
||||
A second surface, in the composer tool row left of the model select: the preset THIS session runs. It shows the session's own recorded preset rather than the deployment default, because a resumed session runs what it was created with.
|
||||
|
||||
The switch exists only while the conversation has not started. After the first turn the seat becomes a plain label — offering a disabled menu would suggest the choice is merely unavailable rather than gone. The host enforces the same rule and answers `agent-preset-locked`, so a stale client cannot slip a switch past it.
|
||||
|
||||
## 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.
|
||||
@@ -30,6 +36,5 @@ No direct invalidation. Changing the default never touches a running session's p
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -8,6 +8,12 @@ agent preset 表层:General 设置中的一行,用于选择新建会话据
|
||||
|
||||
会话的 preset 在创建时即固定——宿主拒绝以不同 preset 接管已存在的会话,因为该会话的历史是在最初那份 preset 的工具下产生的。因此本行不可能是实时切换,它也如实说明了这一点:更改只对此后开启的会话生效,而运行中的会话保持它们开始时的组装。
|
||||
|
||||
## composer 座位
|
||||
|
||||
第二个表层,位于 composer 工具行、模型选择器左侧:**本会话**所运行的 preset。它显示会话自身记录的 preset 而非部署默认值,因为被恢复的会话运行的是它创建时的那一份。
|
||||
|
||||
切换只在对话尚未开始时存在。第一个轮次之后,该座位变为纯文本标签——展示一个禁用的菜单会让人以为这个选择只是暂时不可用,而非已经消失。宿主执行同一条规则并返回 `agent-preset-locked`,因此过期的客户端无法绕过它。
|
||||
|
||||
## 它读什么、写什么
|
||||
|
||||
选项与当前默认值都来自同一次 `agentPreset.list` 调用。名单本身已经报告了"未显式选择的会话会得到哪个 id",因此本行无需对 settings schema 做内省;写入目标是 `agent-presets` settings 命名空间的 `default` 字段,也正是宿主在创建时解析的那个字段。
|
||||
@@ -30,6 +36,5 @@ Indirectly, through the preset a later session is composed from; [`dsh-agent-pre
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **创建时无法逐会话选择** —— 本行只设置默认值。wire 上 `session.create` 已经携带 `agentPreset`,因此会话开启表层可以提供该选择;该表层尚不存在。
|
||||
- **preset 按 id 列出** —— preset 不携带展示用元数据,因此菜单显示的是目录名。
|
||||
- **不提供创作能力** —— 创建、编辑或删除 preset 是文件系统行为;本表层只在名单提供的范围内做选择。
|
||||
|
||||
@@ -26,7 +26,8 @@
|
||||
"inject": [
|
||||
"@deepseek-ai/dsh-client-connection",
|
||||
"@deepseek-ai/dsh-client-locale",
|
||||
"@deepseek-ai/dsh-client-runtime"
|
||||
"@deepseek-ai/dsh-client-runtime",
|
||||
"@deepseek-ai/dsh-client-ui-conversation"
|
||||
],
|
||||
"platform": "web"
|
||||
},
|
||||
@@ -39,6 +40,7 @@
|
||||
"@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-conversation": "^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",
|
||||
@@ -49,6 +51,7 @@
|
||||
"@deepseek-ai/dsh-client-connection": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-locale": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-runtime": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-conversation": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-primitives": "workspace:^",
|
||||
"@deepseek-ai/dsh-client-ui-slots": "workspace:^",
|
||||
"@deepseek-ai/dsh-invariants": "workspace:^",
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
/* Agent-preset seat: a compact selector pill in the composer tool row. */
|
||||
|
||||
.seat {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
height: 28px;
|
||||
padding: 0 6px 0 8px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
background: transparent;
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.seat:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-fill-tsp-secondary);
|
||||
}
|
||||
|
||||
.seat:disabled {
|
||||
cursor: default;
|
||||
color: var(--dsw-alias-label-quaternary);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex: none;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.locked {
|
||||
cursor: default;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* Composer seat for the session's agent preset.
|
||||
*
|
||||
* The switch exists only while the conversation has not started: after the
|
||||
* first turn the session's history was produced under this preset's tools, so
|
||||
* the seat becomes a plain label rather than offering a choice it cannot honor.
|
||||
*/
|
||||
|
||||
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'
|
||||
// Type-only: pulls the ui-conversation SlotMap merge (the agentPreset seat).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { AgentPresetSeatState } from './seat-store.ts'
|
||||
import css from './AgentPresetSeat.module.css'
|
||||
|
||||
/** Registration-side business face for the composer seat. */
|
||||
export interface AgentPresetSeatInjected {
|
||||
hooks: {
|
||||
/** Seat snapshot bound by the renderer as useAgentPresetSeat. */
|
||||
agentPresetSeat: SnapshotStore<AgentPresetSeatState>
|
||||
}
|
||||
/** Load the roster and this session's state when the seat first renders. */
|
||||
load: () => Promise<void>
|
||||
/** Switch this session to another preset. */
|
||||
select: (id: string) => Promise<void>
|
||||
}
|
||||
|
||||
/** Full component props. */
|
||||
export type AgentPresetSeatProps =
|
||||
PropsRuntime<'conversation.input.agentPreset'>
|
||||
& PropsLocale<'settings.agentPreset'>
|
||||
& InjectFace<AgentPresetSeatInjected>
|
||||
|
||||
/**
|
||||
* Render the session's agent-preset seat.
|
||||
* @param props - composed slot props; `locked` is the composer's own busy state.
|
||||
* @returns the seat, or null when the deployment composes no presets.
|
||||
*/
|
||||
export function AgentPresetSeat({ load, select, useAgentPresetSeat, locked, t }: AgentPresetSeatProps) {
|
||||
const state = useAgentPresetSeat(snapshot => snapshot)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
useEffect(() => {
|
||||
if (state.switchable) return
|
||||
setOpen(false)
|
||||
}, [state.switchable])
|
||||
|
||||
// Nothing to choose between: the deployment composes no presets and every
|
||||
// session shares the host composition.
|
||||
if (state.options.length === 0 || state.current === '') return null
|
||||
|
||||
// Past the first turn the preset is a fact about this session, not a
|
||||
// control — showing a disabled menu would suggest it could still be changed.
|
||||
if (!state.switchable) {
|
||||
return <span className={`${css.seat} ${css.locked}`} title={t('lockedHint')}>{state.current}</span>
|
||||
}
|
||||
|
||||
return (
|
||||
<Menu
|
||||
open={open}
|
||||
onClose={() => { setOpen(false) }}
|
||||
items={state.options.map(option => ({
|
||||
id: option.id,
|
||||
label: option.trust === 'user' ? `${option.id} · ${t('userTrust')}` : option.id,
|
||||
}))}
|
||||
selectedId={state.current}
|
||||
onSelect={(id) => {
|
||||
setOpen(false)
|
||||
void select(id)
|
||||
}}
|
||||
align="end"
|
||||
portal
|
||||
anchor={(
|
||||
<button
|
||||
type="button"
|
||||
className={css.seat}
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
title={state.error ?? t('seatHint')}
|
||||
disabled={locked || state.busy}
|
||||
onClick={() => { setOpen(value => !value) }}
|
||||
>
|
||||
{state.current}
|
||||
<IconChevronDownOutline14 className={css.chevron} />
|
||||
</button>
|
||||
)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -12,13 +12,18 @@
|
||||
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 type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { AgentPresetRow } from './AgentPresetRow.tsx'
|
||||
import type { AgentPresetRowInjected } from './AgentPresetRow.tsx'
|
||||
import { AgentPresetSeat } from './AgentPresetSeat.tsx'
|
||||
import type { AgentPresetSeatInjected } from './AgentPresetSeat.tsx'
|
||||
import { AgentPresetSeatController } from './seat-store.ts'
|
||||
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 { AgentPresetSeatInjected, AgentPresetSeatProps } from './AgentPresetSeat.tsx'
|
||||
export type { AgentPresetSeatState } from './seat-store.ts'
|
||||
export type { AgentPresetOption, AgentPresetSettingsState } from './settings-store.ts'
|
||||
export { AGENT_PRESET_SETTINGS_NS } from './settings-store.ts'
|
||||
|
||||
@@ -54,6 +59,37 @@ export function apply(ctx: ClientContext): void {
|
||||
return () => { for (const dispose of disposers) dispose() }
|
||||
}, 'ui-agent-preset: settings refresh')
|
||||
|
||||
// The composer seat: one controller per session, because the switch and the
|
||||
// "may it still switch" bit are both per-session facts.
|
||||
ctx.inject(['slots', 'conversation', 'sessions'], (scope: ClientContext) => {
|
||||
const api = (scope.get('connection') as ConnectionHandle).api
|
||||
const seats = new Map<SessionId, AgentPresetSeatController>()
|
||||
const seatFor = (sessionId: SessionId): AgentPresetSeatController => {
|
||||
const existing = seats.get(sessionId)
|
||||
if (existing !== undefined) return existing
|
||||
const created = new AgentPresetSeatController(api, sessionId, () => {
|
||||
const summary = scope.sessions.list.getSnapshot().byId[sessionId]
|
||||
return summary === undefined
|
||||
? undefined
|
||||
: { blank: summary.blank, ...summary.agentPreset === undefined ? {} : { agentPreset: summary.agentPreset } }
|
||||
})
|
||||
seats.set(sessionId, created)
|
||||
return created
|
||||
}
|
||||
scope.effect(() => scope.slots.register({
|
||||
name: 'conversation.input.agentPreset',
|
||||
locale: 'settings.agentPreset',
|
||||
inject: (sessionId: SessionId): AgentPresetSeatInjected => {
|
||||
const seat = seatFor(sessionId)
|
||||
return {
|
||||
hooks: { agentPresetSeat: seat.store },
|
||||
load: () => seat.load(),
|
||||
select: (id: string) => seat.select(id),
|
||||
}
|
||||
},
|
||||
}, AgentPresetSeat), 'ui-agent-preset: composer seat registration')
|
||||
})
|
||||
|
||||
ctx.slots.inject('settings.general.item', () => ctx.slots.register({
|
||||
name: 'settings.general.item',
|
||||
id: 'agent-preset',
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
/** Locale keys this row renders. */
|
||||
export type AgentPresetSettingsKey =
|
||||
| 'title' | 'description' | 'loading' | 'error' | 'userTrust'
|
||||
| 'title' | 'description' | 'loading' | 'error' | 'userTrust' | 'seatHint' | 'lockedHint'
|
||||
|
||||
/** English copy. */
|
||||
export const en: Record<AgentPresetSettingsKey, string> = {
|
||||
@@ -11,6 +11,8 @@ export const en: Record<AgentPresetSettingsKey, string> = {
|
||||
loading: 'Loading presets…',
|
||||
error: 'Could not load agent presets.',
|
||||
userTrust: 'Local',
|
||||
seatHint: 'Agent preset for this session — switchable until you send the first message',
|
||||
lockedHint: 'This session\'s agent preset is fixed once the conversation starts',
|
||||
}
|
||||
|
||||
/** Simplified Chinese copy. */
|
||||
@@ -20,4 +22,6 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
|
||||
loading: '正在加载 preset…',
|
||||
error: '无法加载 agent preset。',
|
||||
userTrust: '本地',
|
||||
seatHint: '本会话的 agent preset —— 发送第一条消息前可切换',
|
||||
lockedHint: '会话开始后,其 agent preset 即固定',
|
||||
}
|
||||
|
||||
100
packages/client/ui-agent-preset/src/client/seat-store.ts
Normal file
100
packages/client/ui-agent-preset/src/client/seat-store.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* Composer-seat controller: what one session may switch to, and whether it
|
||||
* still may.
|
||||
*
|
||||
* A session's composition is fixed once its conversation starts, so the seat
|
||||
* reads the session's own `blank` bit rather than a local guess — the host
|
||||
* enforces the same rule and answers `agent-preset-locked` to a late attempt.
|
||||
*/
|
||||
|
||||
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import {
|
||||
createSnapshotStore, type SessionId, type SnapshotStore,
|
||||
} from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { AgentPresetOption } from './settings-store.ts'
|
||||
|
||||
/** Composer-seat snapshot for one session. */
|
||||
export interface AgentPresetSeatState {
|
||||
/** Presets the deployment supplies; empty means the seat renders nothing. */
|
||||
options: readonly AgentPresetOption[]
|
||||
/** The preset this session runs, empty until the roster and summary load. */
|
||||
current: string
|
||||
/** False once the conversation has started — the switch is gone for good. */
|
||||
switchable: boolean
|
||||
/** A rejected switch's message, cleared by the next attempt. */
|
||||
error: string | null
|
||||
busy: boolean
|
||||
}
|
||||
|
||||
const INITIAL: AgentPresetSeatState = {
|
||||
options: [], current: '', switchable: false, error: null, busy: false,
|
||||
}
|
||||
|
||||
/** Reads what one session may switch to and performs the switch. */
|
||||
export class AgentPresetSeatController {
|
||||
/** Seat snapshot the renderer subscribes to. */
|
||||
readonly store: SnapshotStore<AgentPresetSeatState> = createSnapshotStore(INITIAL)
|
||||
|
||||
constructor(
|
||||
private readonly api: IApiClient,
|
||||
private readonly sessionId: SessionId,
|
||||
/** Reads this session's blank bit and recorded preset from the session list. */
|
||||
private readonly summary: () => { blank: boolean; agentPreset?: string } | undefined,
|
||||
) {}
|
||||
|
||||
private set(patch: Partial<AgentPresetSeatState>): void {
|
||||
this.store.set({ ...this.store.getSnapshot(), ...patch })
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the roster and reconcile with this session's own state.
|
||||
* @returns once the snapshot reflects the host.
|
||||
*/
|
||||
async load(): Promise<void> {
|
||||
const summary = this.summary()
|
||||
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.presets
|
||||
this.set({
|
||||
options: presets.map(preset => ({ id: preset.id, trust: preset.trust })),
|
||||
// The session's recorded preset wins over the roster default: a
|
||||
// resumed session runs what it was created with, not what the
|
||||
// deployment now prefers.
|
||||
current: summary?.agentPreset ?? presets.find(preset => preset.isDefault)?.id ?? '',
|
||||
switchable: summary?.blank ?? false,
|
||||
error: null,
|
||||
})
|
||||
} catch (error) {
|
||||
this.set({ error: error instanceof Error ? error.message : String(error) })
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch this session to another preset.
|
||||
* @param id - the preset to compose the session's agent from.
|
||||
* @returns once the switch settled; a rejection leaves the previous value.
|
||||
*/
|
||||
async select(id: string): Promise<void> {
|
||||
const before = this.store.getSnapshot()
|
||||
if (before.busy || id === before.current || !before.switchable) return
|
||||
this.set({ busy: true, error: null, current: id })
|
||||
try {
|
||||
const response = await this.api.agentPresets.select({ sessionId: this.sessionId, agentPreset: id })
|
||||
if (!response.result.ok) {
|
||||
this.set({ busy: false, current: before.current, error: response.result.error.message })
|
||||
return
|
||||
}
|
||||
this.set({ busy: false, current: response.result.value.agentPreset })
|
||||
} catch (error) {
|
||||
this.set({
|
||||
busy: false,
|
||||
current: before.current,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
|
||||
import {
|
||||
AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController,
|
||||
} from '../src/client/settings-store.ts'
|
||||
import { AgentPresetSeatController } from '../src/client/seat-store.ts'
|
||||
|
||||
interface Recorded { ns: string; patch: unknown }
|
||||
|
||||
@@ -120,3 +121,100 @@ describe('the agent-preset settings controller', () => {
|
||||
expect(state.error).toBe('host down')
|
||||
})
|
||||
})
|
||||
|
||||
describe('the composer seat controller', () => {
|
||||
/** A seat over a fixed session summary. */
|
||||
function seat(
|
||||
presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[],
|
||||
summary: { blank: boolean; agentPreset?: string } | undefined,
|
||||
options: { writes?: Recorded[]; failSelect?: string } = {},
|
||||
): AgentPresetSeatController {
|
||||
const api = {
|
||||
agentPresets: {
|
||||
list: () => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { presets } } }),
|
||||
select: (payload: { agentPreset: string }) => {
|
||||
options.writes?.push({ ns: 'select', patch: payload.agentPreset })
|
||||
return Promise.resolve(options.failSelect === undefined
|
||||
? { rpcId: 'r', result: { ok: true as const, value: { agentPreset: payload.agentPreset } } }
|
||||
: { rpcId: 'r', result: { ok: false as const, error: { code: 'agent-preset-locked', message: options.failSelect, details: {} } } })
|
||||
},
|
||||
},
|
||||
} as unknown as IApiClient
|
||||
return new AgentPresetSeatController(api, 's1' as never, () => summary)
|
||||
}
|
||||
|
||||
const ROSTER: { id: string; trust: 'system' | 'user'; isDefault: boolean }[] = [
|
||||
{ id: 'standard', trust: 'system', isDefault: true },
|
||||
{ id: 'core-web', trust: 'system', isDefault: false },
|
||||
]
|
||||
|
||||
it('shows what the session runs, not the deployment default', async () => {
|
||||
const controller = seat(ROSTER, { blank: true, agentPreset: 'core-web' })
|
||||
|
||||
await controller.load()
|
||||
|
||||
// A resumed session runs what it was created with; showing `standard`
|
||||
// because it is the current default would be a lie about this session.
|
||||
expect(controller.store.getSnapshot().current).toBe('core-web')
|
||||
expect(controller.store.getSnapshot().switchable).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to the roster default when the session records none', async () => {
|
||||
const controller = seat(ROSTER, { blank: true })
|
||||
|
||||
await controller.load()
|
||||
|
||||
expect(controller.store.getSnapshot().current).toBe('standard')
|
||||
})
|
||||
|
||||
it('is not switchable once the conversation has started', async () => {
|
||||
const controller = seat(ROSTER, { blank: false, agentPreset: 'standard' })
|
||||
|
||||
await controller.load()
|
||||
|
||||
expect(controller.store.getSnapshot().switchable).toBe(false)
|
||||
})
|
||||
|
||||
it('refuses to switch a session that already started', async () => {
|
||||
const writes: Recorded[] = []
|
||||
const controller = seat(ROSTER, { blank: false, agentPreset: 'standard' }, { writes })
|
||||
await controller.load()
|
||||
|
||||
await controller.select('core-web')
|
||||
|
||||
// The host enforces the same rule; the seat simply never asks.
|
||||
expect(writes).toEqual([])
|
||||
expect(controller.store.getSnapshot().current).toBe('standard')
|
||||
})
|
||||
|
||||
it('switches a blank session and keeps the host\'s answer', async () => {
|
||||
const writes: Recorded[] = []
|
||||
const controller = seat(ROSTER, { blank: true, agentPreset: 'standard' }, { writes })
|
||||
await controller.load()
|
||||
|
||||
await controller.select('core-web')
|
||||
|
||||
expect(writes).toEqual([{ ns: 'select', patch: 'core-web' }])
|
||||
expect(controller.store.getSnapshot().current).toBe('core-web')
|
||||
})
|
||||
|
||||
it('restores the previous value when the host rejects the switch', async () => {
|
||||
const controller = seat(ROSTER, { blank: true, agentPreset: 'standard' }, { failSelect: 'already started' })
|
||||
await controller.load()
|
||||
|
||||
await controller.select('core-web')
|
||||
|
||||
const state = controller.store.getSnapshot()
|
||||
expect(state.current).toBe('standard')
|
||||
expect(state.error).toBe('already started')
|
||||
})
|
||||
|
||||
it('reports no options when the session is unknown to the list yet', async () => {
|
||||
const controller = seat([], undefined)
|
||||
|
||||
await controller.load()
|
||||
|
||||
expect(controller.store.getSnapshot().options).toEqual([])
|
||||
expect(controller.store.getSnapshot().switchable).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -20,6 +20,9 @@
|
||||
{
|
||||
"path": "../runtime"
|
||||
},
|
||||
{
|
||||
"path": "../ui-conversation"
|
||||
},
|
||||
{
|
||||
"path": "../ui-primitives"
|
||||
},
|
||||
|
||||
@@ -228,6 +228,7 @@ export function apply(ctx: Context): void {
|
||||
children: {
|
||||
'conversation.input.plan': { kind: 'single', scope: 'session' },
|
||||
'conversation.input.model': { kind: 'single', scope: 'session' },
|
||||
'conversation.input.agentPreset': { kind: 'single', scope: 'session' },
|
||||
},
|
||||
inject: (sessionId: SessionId | undefined): ComposerBarInjected => {
|
||||
if (sessionId === undefined) {
|
||||
|
||||
@@ -102,6 +102,12 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
* empty-until-registered contract as the plan seat.
|
||||
*/
|
||||
'conversation.input.model': { kind: 'single'; scope: 'session'; owner: InputControlOwnerProps }
|
||||
/**
|
||||
* The agent-preset seat in the composer tool row, left of the model.
|
||||
* Same empty-until-registered contract as the other two; its owner
|
||||
* decides on its own whether the session may still switch.
|
||||
*/
|
||||
'conversation.input.agentPreset': { kind: 'single'; scope: 'session'; owner: InputControlOwnerProps }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -325,7 +331,7 @@ export interface InputControlOwnerProps {
|
||||
/** Full composer-bar props: standard kit & owner share & control-seat render share & injected share (hooks bound) & locale seat. */
|
||||
export type ComposerBarProps =
|
||||
PropsRuntime<'conversation.composer.bar'>
|
||||
& PropsRenderSlots<'conversation.input.plan' | 'conversation.input.model'>
|
||||
& PropsRenderSlots<'conversation.input.plan' | 'conversation.input.model' | 'conversation.input.agentPreset'>
|
||||
& InjectFace<ComposerBarInjected>
|
||||
& PropsLocale<'conversation'>
|
||||
|
||||
|
||||
@@ -512,6 +512,7 @@ export function InputBar({
|
||||
</div>
|
||||
<div className={css.trailing}>
|
||||
{rightItems}
|
||||
{renderSlot('conversation.input.agentPreset', { locked })}
|
||||
{renderSlot('conversation.input.model', { locked })}
|
||||
<ContextMeter useProjection={useProjection} t={t} />
|
||||
{/* {machineBusy && <span className={css.pending} data-input-pending aria-label="处理中" />} */}
|
||||
|
||||
@@ -686,13 +686,15 @@ describe('strips and variants', () => {
|
||||
})
|
||||
|
||||
describe('command launcher chrome and control seats', () => {
|
||||
it('renders the command launcher; the Access chip is absent without the permissions projection; plan/model seats render EMPTY without entries (B ruling)', () => {
|
||||
it('renders the command launcher; the Access chip is absent without the permissions projection; the control seats render EMPTY without entries (B ruling)', () => {
|
||||
const { view, slotCalls } = bench()
|
||||
expect(view.getByLabelText('命令')).toBeTruthy()
|
||||
// Capability absent (no projection value): the chip renders nothing.
|
||||
expect(view.queryByLabelText(/^访问模式/)).toBeNull()
|
||||
// Both seats dispatched, nothing rendered.
|
||||
expect(slotCalls.map(c => c.key)).toEqual(['conversation.input.plan', 'conversation.input.model'])
|
||||
// Every seat dispatched, nothing rendered.
|
||||
expect(slotCalls.map(c => c.key)).toEqual([
|
||||
'conversation.input.plan', 'conversation.input.agentPreset', 'conversation.input.model',
|
||||
])
|
||||
expect(view.queryByLabelText('Plan mode')).toBeNull()
|
||||
expect(view.queryByLabelText('Model')).toBeNull()
|
||||
})
|
||||
|
||||
@@ -260,11 +260,13 @@ function sessionListFields(header: SessionHeader): {
|
||||
parentSessionId?: SessionId
|
||||
origin?: 'subagent'
|
||||
cwd?: string
|
||||
agentPreset?: string
|
||||
} {
|
||||
return {
|
||||
...header.parentSession === undefined ? {} : { parentSessionId: header.parentSession },
|
||||
...header.origin === undefined ? {} : { origin: header.origin },
|
||||
...header.cwd === undefined ? {} : { cwd: header.cwd },
|
||||
...header.agentPreset === undefined ? {} : { agentPreset: header.agentPreset },
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -55,6 +55,7 @@ export const sessionSummarySchema = z.object({
|
||||
parentSessionId: sessionIdSchema.optional(),
|
||||
origin: z.literal('subagent').optional(),
|
||||
cwd: z.string().optional(),
|
||||
agentPreset: z.string().optional(),
|
||||
projections: z.lazy(() => sessionProjectionsBlockSchema).optional(),
|
||||
}) as unknown as z.ZodType<Wire<SessionSummary>>
|
||||
|
||||
|
||||
@@ -156,6 +156,13 @@ export interface SessionSummary {
|
||||
origin?: 'subagent'
|
||||
/** Session working directory (header.cwd passthrough); absent when unrecorded. */
|
||||
cwd?: string
|
||||
/**
|
||||
* Agent preset this session's agent was composed from (header passthrough);
|
||||
* absent when the deployment composes no presets. A surface offering a
|
||||
* switch reads this to show what the session actually runs rather than what
|
||||
* the deployment currently defaults to.
|
||||
*/
|
||||
agentPreset?: string
|
||||
/**
|
||||
* Projection baseline for this row, with zero log loads: attached sessions
|
||||
* read the registry's live watermark cut; cold sessions read the persisted
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -1446,6 +1446,9 @@ importers:
|
||||
'@deepseek-ai/dsh-client-runtime':
|
||||
specifier: workspace:^
|
||||
version: link:../runtime
|
||||
'@deepseek-ai/dsh-client-ui-conversation':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-conversation
|
||||
'@deepseek-ai/dsh-client-ui-primitives':
|
||||
specifier: workspace:^
|
||||
version: link:../ui-primitives
|
||||
|
||||
Reference in New Issue
Block a user