feat(web): choose the agent preset on the new-session screen
The composer seat spent nearly all its life disabled: a session's composition is fixed once a turn has run. Move the choice to the new-session screen beside the workspace picker, where it still works, and let the session header report what a running session runs. The hero pick is staged rather than applied — that screen precedes the session it belongs to. It lands when a session becomes current and is still blank, which covers both the session a workspace connect creates and the blank one it reuses; riding `sessions.create` would miss the second. It is spent on first use, matching the workspace picker. Fix the durability the header field claimed but never had: `agentPreset` was declared on `SessionHeader` and dropped by the JSONL header line, the SQLite sessions row, the derived query index, and the cold list projection, so every resumed session came back composed from nothing. Add the web e2e lane that would have caught it — the one lane that mounts the shipped roster, which needed `cordis:group` in the scaffold's Loader builtins, as `mountRootInclude` already registers.
This commit is contained in:
@@ -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/README.md
|
||||
README.md: b950772d4cad6d873426f8aee6416fa56afca2ee
|
||||
README.zh.md: 8f1f7f46777b7037e8baa04c9ec16ef74ffd478d
|
||||
README.md: a0888b0d02d3d6ed234489d686ae9ad996e11674
|
||||
README.zh.md: a6e4e42c12c97802897c6d58efdcc7468d45db8c
|
||||
|
||||
@@ -32,6 +32,7 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha
|
||||
| [`ui-permission/`](ui-permission/README.md) | Configures default permissions and switches the current session's access. |
|
||||
| [`ui-plan/`](ui-plan/README.md) | Presents active plan-mode status and its exit control. |
|
||||
| [`ui-question/`](ui-question/README.md) | Presents interactive questions requested by the agent. |
|
||||
| [`ui-agent-preset/`](ui-agent-preset/README.md) | Selects a session's agent preset and authors preset compositions. |
|
||||
| [`ui-settings/`](ui-settings/README.md) | Hosts the settings interface and its extension areas. |
|
||||
| [`ui-settings-general/`](ui-settings-general/README.md) | Provides the general settings section. |
|
||||
| [`ui-models/`](ui-models/README.md) | Provides model-provider configuration and DeepSeek onboarding. |
|
||||
|
||||
@@ -32,6 +32,7 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U
|
||||
| [`ui-permission/`](ui-permission/README.md) | 配置默认权限并切换当前会话的访问模式。 |
|
||||
| [`ui-plan/`](ui-plan/README.md) | 展示生效中的 plan mode 状态及其退出控件。 |
|
||||
| [`ui-question/`](ui-question/README.md) | 展示 agent 请求的交互式问题。 |
|
||||
| [`ui-agent-preset/`](ui-agent-preset/README.md) | 选择会话的 agent 预设,并创作预设组装。 |
|
||||
| [`ui-settings/`](ui-settings/README.md) | 承载设置界面及其扩展区域。 |
|
||||
| [`ui-settings-general/`](ui-settings-general/README.md) | 提供常规设置分区。 |
|
||||
| [`ui-models/`](ui-models/README.md) | 提供模型提供方配置与 DeepSeek 配置引导。 |
|
||||
|
||||
@@ -47,8 +47,8 @@ export interface SessionSummary {
|
||||
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.
|
||||
* deployment composes no presets. The session header labels what the
|
||||
* session actually runs rather than the deployment's current default.
|
||||
*/
|
||||
agentPreset?: string
|
||||
parentId?: SessionId
|
||||
|
||||
@@ -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: be159855010c1a0af7d3f712d9551000826d4820
|
||||
README.zh.md: 73c07751f4e5cf1cea43d2723027162f09da10f6
|
||||
README.md: 3df8e43892ea619155918b28a171d30b6f9d1a3d
|
||||
README.zh.md: 4725517c6f2ebf5b172bd01023cdef0de6f542ac
|
||||
|
||||
@@ -2,17 +2,23 @@
|
||||
|
||||
English | [中文](README.zh.md)
|
||||
|
||||
The agent-preset surfaces: a General-settings row choosing which [preset](../../preset/agent-presets/README.md) new sessions are composed from, a composer seat choosing this session's, and a settings section that authors the compositions themselves.
|
||||
The agent-preset surfaces: a General-settings row choosing which [preset](../../preset/agent-presets/README.md) new sessions are composed from, a chip on the new-session screen choosing the next session's, a read-only label in the session header, and a settings section that authors the compositions themselves.
|
||||
|
||||
## 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.
|
||||
|
||||
## The composer seat
|
||||
## The new-session chip
|
||||
|
||||
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.
|
||||
A second surface, beside the workspace picker on the new-session screen. It sits there rather than in the composer because that is where the choice is still open: a control that spends most of its life disabled belongs on the screen where it still works.
|
||||
|
||||
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.
|
||||
The chip opens on the deployment default and its pick is *staged* — the screen precedes the session it would apply to. The stage reaches a session when one becomes current and is still blank, which covers both the session the workspace connect created and the blank one it reused; riding along on `sessions.create` would miss the second. It is spent on first use, so the next new session opens on the default again, exactly like the workspace picker beside it.
|
||||
|
||||
A session that has started is refused rather than queued: the host answers `agent-preset-locked`, and the stage is dropped instead of waiting for a session that will never accept it.
|
||||
|
||||
## The session-header label
|
||||
|
||||
A third surface, beside the session title: the preset THIS session runs, as static chrome. A control there would promise a switch the host refuses outright. It reads the preset from the session's own summary — a resumed session runs what it was created with, not today's default — and resolves the display name against the same roster the General row reads.
|
||||
|
||||
## What it reads and writes
|
||||
|
||||
@@ -24,7 +30,7 @@ The row re-reads on `settings/changed` for its own namespace and on `connection/
|
||||
|
||||
## The management section
|
||||
|
||||
A third surface, its own settings page (`settings.section` id `agent-presets`, ordered after Models — choosing a model is routine, composing an agent is the deployment-shaping act behind it): the roster as rows, and one composition open in a YAML editor at a time.
|
||||
A fourth surface, its own settings page (`settings.section` id `agent-presets`, ordered after Models — choosing a model is routine, composing an agent is the deployment-shaping act behind it): the roster as rows, and one composition open in a YAML editor at a time.
|
||||
|
||||
A shipped preset opens read-only. It is the known-good composition a local one is written against, so reading it is the point and overwriting it is not — the deployment's copy is what a broken local preset is compared against. Authoring therefore starts by duplicating: **New preset** copies the current default, and **Duplicate** copies any row, because a copy always lands in the local root regardless of where the text came from.
|
||||
|
||||
@@ -38,7 +44,7 @@ Setting the default writes the `agent-presets` settings namespace, which the hos
|
||||
|
||||
## When the surfaces are absent
|
||||
|
||||
A deployment that composes no presets answers with an empty roster, and the row, the seat, and the section all render nothing — every session then shares the host composition, and there is nothing to choose between or manage. A deployment that configures no writable root answers `authorable: false`, and the section stays a read-only browser: the rows still open, but creating is offered nowhere rather than through a button whose save always fails.
|
||||
A deployment that composes no presets answers with an empty roster, and the row, the chip, the label, and the section all render nothing — every session then shares the host composition, and there is nothing to choose between or manage. A deployment that configures no writable root answers `authorable: false`, and the section stays a read-only browser: the rows still open, but creating is offered nowhere rather than through a button whose save always fails.
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -50,6 +56,6 @@ No direct invalidation. Changing the default never touches a running session's p
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **Presets are listed by id** — a preset carries no display metadata, so the menus and rows show directory names.
|
||||
- **A preset without metadata is listed by id** — display text is optional, and a preset that publishes none (every preset authored by duplicating another starts that way) shows its directory name.
|
||||
- **The editor is a plain textarea** — no YAML syntax highlighting, folding, or schema completion; the host's shape check on save is the only validation.
|
||||
- **A saved composition is not mounted** — a preset that parses but names a missing plugin is accepted, and fails at the next session that selects it.
|
||||
|
||||
@@ -2,17 +2,23 @@
|
||||
|
||||
[English](README.md) | 中文
|
||||
|
||||
agent preset 的各个表层:General 设置中的一行,用于选择新建会话据以组装的 [preset](../../preset/agent-presets/README.md);composer 中的一个座位,用于选择**本会话**的 preset;以及一个设置页分区,用于创作组装本身。
|
||||
agent preset 的各个表层:General 设置中的一行,用于选择新建会话据以组装的 [preset](../../preset/agent-presets/README.md);新建会话界面上的一枚 chip,用于选择**下一个会话**的 preset;会话标题旁的一个只读标签;以及一个设置页分区,用于创作组装本身。
|
||||
|
||||
## 为什么它是"新建会话"的偏好设置
|
||||
|
||||
会话的 preset 在创建时即固定——宿主拒绝以不同 preset 接管已存在的会话,因为该会话的历史是在最初那份 preset 的工具下产生的。因此本行不可能是实时切换,它也如实说明了这一点:更改只对此后开启的会话生效,而运行中的会话保持它们开始时的组装。
|
||||
|
||||
## composer 座位
|
||||
## 新建会话 chip
|
||||
|
||||
第二个表层,位于 composer 工具行、模型选择器左侧:**本会话**所运行的 preset。它显示会话自身记录的 preset 而非部署默认值,因为被恢复的会话运行的是它创建时的那一份。
|
||||
第二个表层,位于新建会话界面上、工作区选择器旁边。它落在这里而非 composer,是因为这里才是选择仍然成立的地方:一个大部分时间处于禁用状态的控件,属于它仍然可用的那个界面。
|
||||
|
||||
切换只在对话尚未开始时存在。第一个轮次之后,该座位变为纯文本标签——展示一个禁用的菜单会让人以为这个选择只是暂时不可用,而非已经消失。宿主执行同一条规则并返回 `agent-preset-locked`,因此过期的客户端无法绕过它。
|
||||
chip 以部署默认值打开,其选择是**暂存**的——该界面先于它要应用到的会话存在。暂存值会在某个会话成为当前会话且仍为空白时抵达该会话;这既覆盖工作区连接新建的会话,也覆盖它复用的那个空白会话,而搭 `sessions.create` 的便车会漏掉后者。暂存值一经使用即被清空,因此下一个新会话重新以默认值打开——与它旁边的工作区选择器完全一致。
|
||||
|
||||
已经开始的会话会被直接拒绝而非排队:宿主返回 `agent-preset-locked`,暂存值随之丢弃,而不是去等一个永远不会接受它的会话。
|
||||
|
||||
## 会话标题旁的标签
|
||||
|
||||
第三个表层,位于会话标题旁:**本会话**所运行的 preset,作为静态装饰呈现。在那里放一个控件,等于承诺一次宿主会断然拒绝的切换。它从会话自身的摘要读取 preset——被恢复的会话运行的是它创建时的那一份,而非今天的默认值——并在 General 行所读的同一份名单上解析显示名称。
|
||||
|
||||
## 它读什么、写什么
|
||||
|
||||
@@ -24,7 +30,7 @@ agent preset 的各个表层:General 设置中的一行,用于选择新建
|
||||
|
||||
## 管理分区
|
||||
|
||||
第三个表层,独立的设置页(`settings.section`,id 为 `agent-presets`,排在「模型」之后——选模型是日常操作,而组装 agent 是它背后那件塑造部署形态的事):名单以行呈现,同一时刻有一份组装在 YAML 编辑器中打开。
|
||||
第四个表层,独立的设置页(`settings.section`,id 为 `agent-presets`,排在「模型」之后——选模型是日常操作,而组装 agent 是它背后那件塑造部署形态的事):名单以行呈现,同一时刻有一份组装在 YAML 编辑器中打开。
|
||||
|
||||
随部署提供的 preset 以只读方式打开。它是本地 preset 据以编写的已知良好组装,因此能读到它正是意义所在,而覆写它则不是——部署自带的那一份正是用来对照有问题的本地 preset 的。因此创作从复制开始:**新建 preset** 复制当前默认值,**复制**则复制任意一行;无论文本来自何处,副本总是落在本地根目录,所以副本总是可写的。
|
||||
|
||||
@@ -38,7 +44,7 @@ id 会成为目录名,因此编辑器复刻宿主自身的约束规则(`[a-z
|
||||
|
||||
## 何时不显示这些表层
|
||||
|
||||
未组装任何 preset 的部署返回空名单,本行、座位与分区都不渲染任何内容——此时每个会话共用宿主组装,也就无从选择或管理。未配置可写根目录的部署返回 `authorable: false`,分区随之退化为只读浏览:各行仍可打开,但任何位置都不提供"新建",而不是给出一个保存必然失败的按钮。
|
||||
未组装任何 preset 的部署返回空名单,本行、chip、标签与分区都不渲染任何内容——此时每个会话共用宿主组装,也就无从选择或管理。未配置可写根目录的部署返回 `authorable: false`,分区随之退化为只读浏览:各行仍可打开,但任何位置都不提供"新建",而不是给出一个保存必然失败的按钮。
|
||||
|
||||
## Model Experience
|
||||
|
||||
@@ -50,6 +56,6 @@ Indirectly, through the preset a later session is composed from; [`dsh-agent-pre
|
||||
|
||||
## Known Limitations and Deferred Work
|
||||
|
||||
- **preset 按 id 列出** —— preset 不携带展示用元数据,因此菜单与各行显示的是目录名。
|
||||
- **没有元数据的 preset 按 id 列出** —— 展示文本是可选的,未发布任何展示文本的 preset(每个由复制他人而来的 preset 起初都是如此)显示的是它的目录名。
|
||||
- **编辑器是纯文本域** —— 没有 YAML 语法高亮、折叠或 schema 补全;保存时宿主的形状检查是唯一的校验。
|
||||
- **保存的组装不会被挂载** —— 能解析但引用了缺失插件的 preset 会被接受,并在下一个选择它的会话处失败。
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
/* Session-header agent-preset label: static chrome, never a control. */
|
||||
|
||||
.label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 180px;
|
||||
padding: 0 8px;
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
background: var(--dsw-alias-fill-tsp-secondary);
|
||||
font-size: 12px;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.icon {
|
||||
flex: none;
|
||||
opacity: 0.7;
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
/**
|
||||
* The session header's agent-preset label.
|
||||
*
|
||||
* Read-only by construction: a session's composition is fixed once its
|
||||
* conversation starts, and a header is only worth reading after that. Offering
|
||||
* a control here would promise a switch the host refuses; naming what the
|
||||
* session runs is the honest affordance, and the choice itself lives on the
|
||||
* new-session screen ({@link AgentPresetSeat}).
|
||||
*/
|
||||
|
||||
import { useEffect } 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 { IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
// Type-only: pulls the ui-conversation SlotMap merge (the header actions).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { AgentPresetSettingsState } from './settings-store.ts'
|
||||
import css from './AgentPresetLabel.module.css'
|
||||
|
||||
/** Registration-side business face for the header label. */
|
||||
export interface AgentPresetLabelInjected {
|
||||
hooks: {
|
||||
/** Roster snapshot bound by the renderer as useAgentPresets. */
|
||||
agentPresets: SnapshotStore<AgentPresetSettingsState>
|
||||
}
|
||||
/** Read the roster, so the label can show a name rather than an id. */
|
||||
load: () => Promise<void>
|
||||
}
|
||||
|
||||
/** Full component props. */
|
||||
export type AgentPresetLabelProps =
|
||||
PropsRuntime<'conversation.session.header.actions'>
|
||||
& PropsLocale<'settings.agentPreset'>
|
||||
& InjectFace<AgentPresetLabelInjected>
|
||||
|
||||
/**
|
||||
* Render this session's agent-preset name beside its title.
|
||||
* @param props - composed slot props.
|
||||
* @returns the label, or null when the session records no preset.
|
||||
*/
|
||||
export function AgentPresetLabel({
|
||||
sessionId, useSessions, useAgentPresets, load, t,
|
||||
}: AgentPresetLabelProps) {
|
||||
const preset = useSessions(state => state.byId[sessionId]?.agentPreset)
|
||||
const options = useAgentPresets(state => state.options)
|
||||
|
||||
useEffect(() => {
|
||||
// Deployments that compose no presets never label anything, so the roster
|
||||
// is only worth a request once a session reports one.
|
||||
if (preset !== undefined) void load()
|
||||
}, [preset, load])
|
||||
|
||||
if (preset === undefined) return null
|
||||
|
||||
const option = options.find(entry => entry.id === preset)
|
||||
return (
|
||||
<span className={css.label} title={option?.description ?? t('headerHint')}>
|
||||
<IconThinkOutline16 className={css.icon} />
|
||||
{option?.name ?? preset}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -1,22 +1,29 @@
|
||||
/* Agent-preset seat: a compact selector pill in the composer tool row. */
|
||||
/* Agent-preset chip on the new-session screen, beside the workspace picker.
|
||||
Geometry mirrors HeroShell's .workspace so the two read as one row. */
|
||||
|
||||
.seat {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
height: 28px;
|
||||
padding: 0 6px 0 8px;
|
||||
gap: 4px;
|
||||
max-width: min(100%, 240px);
|
||||
min-height: 28px;
|
||||
padding: 0 8px;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
border-radius: 12px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.seat:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-fill-tsp-secondary);
|
||||
.seat:not(:disabled):hover,
|
||||
.seat[aria-expanded='true'] {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.seat:disabled {
|
||||
@@ -24,11 +31,34 @@
|
||||
color: var(--dsw-alias-label-quaternary);
|
||||
}
|
||||
|
||||
.chevron {
|
||||
.seatIcon {
|
||||
flex: none;
|
||||
opacity: 0.6;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.locked {
|
||||
cursor: default;
|
||||
.chevron {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
}
|
||||
|
||||
/* Menu rows carry the name over its description: the id alone never said what
|
||||
a preset does, which is why the metadata exists. */
|
||||
.item {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
max-width: 280px;
|
||||
}
|
||||
|
||||
.itemName {
|
||||
font-size: 13px;
|
||||
line-height: 20px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.itemDesc {
|
||||
font-size: 12px;
|
||||
line-height: 16px;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
@@ -1,44 +1,50 @@
|
||||
/**
|
||||
* Composer seat for the session's agent preset.
|
||||
* The agent-preset chip on the new-session screen, beside the workspace
|
||||
* picker.
|
||||
*
|
||||
* 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.
|
||||
* It lives here rather than in the composer because the choice is only
|
||||
* available before a conversation starts: once a turn has run, the session's
|
||||
* history was produced under that preset's tools and the host refuses to swap
|
||||
* them. A control that spends most of its life disabled belongs on the screen
|
||||
* where it still works.
|
||||
*
|
||||
* The menu opens on the staged choice, which starts as the deployment default.
|
||||
* Picking stages; the choice reaches a session when one becomes current.
|
||||
*/
|
||||
|
||||
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 { IconChevronDownOutline14, IconThinkOutline16, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
// Type-only: pulls the ui-conversation SlotMap merge (the hero 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. */
|
||||
/** Registration-side business face for the hero chip. */
|
||||
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. */
|
||||
/** Read the roster when the chip first renders. */
|
||||
load: () => Promise<void>
|
||||
/** Switch this session to another preset. */
|
||||
/** Stage one preset for the next session. */
|
||||
select: (id: string) => Promise<void>
|
||||
}
|
||||
|
||||
/** Full component props. */
|
||||
export type AgentPresetSeatProps =
|
||||
PropsRuntime<'conversation.input.agentPreset'>
|
||||
PropsRuntime<'conversation.hero.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.
|
||||
* Render the new-session agent-preset chip.
|
||||
* @param props - composed slot props.
|
||||
* @returns the chip, or null when the deployment composes no presets.
|
||||
*/
|
||||
export function AgentPresetSeat({ load, select, useAgentPresetSeat, locked, t }: AgentPresetSeatProps) {
|
||||
export function AgentPresetSeat({ load, select, useAgentPresetSeat, t }: AgentPresetSeatProps) {
|
||||
const state = useAgentPresetSeat(snapshot => snapshot)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
@@ -46,20 +52,11 @@ export function AgentPresetSeat({ load, select, useAgentPresetSeat, locked, t }:
|
||||
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>
|
||||
}
|
||||
const chosen = state.options.find(option => option.id === state.current)
|
||||
|
||||
return (
|
||||
<Menu
|
||||
@@ -67,14 +64,21 @@ export function AgentPresetSeat({ load, select, useAgentPresetSeat, locked, t }:
|
||||
onClose={() => { setOpen(false) }}
|
||||
items={state.options.map(option => ({
|
||||
id: option.id,
|
||||
label: option.trust === 'user' ? `${option.id} · ${t('userTrust')}` : option.id,
|
||||
// Name and description together: the id alone never said what a
|
||||
// preset does, which is the whole reason the metadata exists.
|
||||
label: (
|
||||
<span className={css.item}>
|
||||
<span className={css.itemName}>{option.name ?? option.id}</span>
|
||||
<span className={css.itemDesc}>{option.description ?? t('noDescription')}</span>
|
||||
</span>
|
||||
),
|
||||
}))}
|
||||
selectedId={state.current}
|
||||
onSelect={(id) => {
|
||||
setOpen(false)
|
||||
void select(id)
|
||||
}}
|
||||
align="end"
|
||||
align="start"
|
||||
portal
|
||||
anchor={(
|
||||
<button
|
||||
@@ -83,10 +87,11 @@ export function AgentPresetSeat({ load, select, useAgentPresetSeat, locked, t }:
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
title={state.error ?? t('seatHint')}
|
||||
disabled={locked || state.busy}
|
||||
disabled={state.busy}
|
||||
onClick={() => { setOpen(value => !value) }}
|
||||
>
|
||||
{state.current}
|
||||
<IconThinkOutline16 className={css.seatIcon} />
|
||||
{chosen?.name ?? state.current}
|
||||
<IconChevronDownOutline14 className={css.chevron} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
/**
|
||||
* Agent-preset surface plugin, browser half — three surfaces over one roster:
|
||||
* a General-settings row for the default preset, a composer seat for the
|
||||
* session about to start, and a settings section that authors the compositions
|
||||
* themselves.
|
||||
* Agent-preset surface plugin, browser half — four surfaces over one roster:
|
||||
* a General-settings row for the default preset, a chip on the new-session
|
||||
* screen for the session about to start, a read-only label in the session
|
||||
* header, and a settings section that authors the compositions themselves.
|
||||
*
|
||||
* A running session keeps the composition it began with (the host refuses to
|
||||
* adopt an existing session under a different preset), so the General row is
|
||||
* a new-session preference rather than a live switch, and the seat stops
|
||||
* offering a choice once the conversation starts.
|
||||
* adopt an existing session under a different preset). That is what splits
|
||||
* the choice from the display: the General row and the hero chip are both
|
||||
* before-the-fact, while the header only reports what a session already runs.
|
||||
*/
|
||||
|
||||
import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client'
|
||||
@@ -15,7 +15,9 @@ import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client
|
||||
import type {} from '@deepseek-ai/dsh-client-locale/client'
|
||||
// Type-only: pulls the settings shell's SlotMap merge (the 'settings.section' entry).
|
||||
import type {} from '@deepseek-ai/dsh-client-ui-settings/client'
|
||||
import type { ClientContext, SessionId } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { AgentPresetLabel } from './AgentPresetLabel.tsx'
|
||||
import type { AgentPresetLabelInjected } from './AgentPresetLabel.tsx'
|
||||
import { AgentPresetRow } from './AgentPresetRow.tsx'
|
||||
import type { AgentPresetRowInjected } from './AgentPresetRow.tsx'
|
||||
import { AgentPresetSeat } from './AgentPresetSeat.tsx'
|
||||
@@ -23,14 +25,16 @@ import type { AgentPresetSeatInjected } from './AgentPresetSeat.tsx'
|
||||
import { AgentPresetSection } from './AgentPresetSection.tsx'
|
||||
import type { AgentPresetSectionInjected } from './AgentPresetSection.tsx'
|
||||
import { AgentPresetSeatController } from './seat-store.ts'
|
||||
import type { SeatSessionSummary } from './seat-store.ts'
|
||||
import { AgentPresetSectionController } from './section-store.ts'
|
||||
import { en, zh } from './locales.ts'
|
||||
import { AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController } from './settings-store.ts'
|
||||
|
||||
export type { AgentPresetLabelInjected, AgentPresetLabelProps } from './AgentPresetLabel.tsx'
|
||||
export type { AgentPresetRowInjected, AgentPresetRowProps } from './AgentPresetRow.tsx'
|
||||
export type { AgentPresetSeatInjected, AgentPresetSeatProps } from './AgentPresetSeat.tsx'
|
||||
export type { AgentPresetSectionInjected, AgentPresetSectionProps } from './AgentPresetSection.tsx'
|
||||
export type { AgentPresetSeatState } from './seat-store.ts'
|
||||
export type { AgentPresetSeatState, SeatSessionSummary } from './seat-store.ts'
|
||||
export {
|
||||
draftBlocker, type AgentPresetSectionState, type PresetDraft, type PresetRow,
|
||||
} from './section-store.ts'
|
||||
@@ -74,35 +78,56 @@ 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.
|
||||
// The new-session chip and the header label: one controller, because the
|
||||
// staged choice belongs to the flow rather than to any one session.
|
||||
ctx.inject(['slots', 'conversation', 'sessions'], (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),
|
||||
const seat = new AgentPresetSeatController(api, (): SeatSessionSummary | undefined => {
|
||||
const state = scope.sessions.list.getSnapshot()
|
||||
const summary = state.current === undefined ? undefined : state.byId[state.current]
|
||||
return summary === undefined
|
||||
? undefined
|
||||
: {
|
||||
id: summary.id,
|
||||
blank: summary.blank,
|
||||
...summary.agentPreset === undefined ? {} : { agentPreset: summary.agentPreset },
|
||||
}
|
||||
},
|
||||
}, AgentPresetSeat), 'ui-agent-preset: composer seat registration')
|
||||
})
|
||||
|
||||
const seatInjected = (): AgentPresetSeatInjected => ({
|
||||
hooks: { agentPresetSeat: seat.store },
|
||||
load: () => seat.load(),
|
||||
select: (id: string) => seat.select(id),
|
||||
})
|
||||
|
||||
const labelInjected = (): AgentPresetLabelInjected => ({
|
||||
hooks: { agentPresets: controller.store },
|
||||
load: () => controller.load(),
|
||||
})
|
||||
|
||||
scope.effect(() => {
|
||||
// Connecting a workspace either creates a blank session or reuses one,
|
||||
// and either way the chip's pick predates it — so the stage is applied
|
||||
// when the session arrives, not when it was made.
|
||||
const stop = scope.sessions.list.subscribe(() => { void seat.apply() })
|
||||
const chip = scope.slots.register({
|
||||
name: 'conversation.hero.agentPreset',
|
||||
locale: 'settings.agentPreset',
|
||||
inject: seatInjected,
|
||||
}, AgentPresetSeat)
|
||||
const label = scope.slots.register({
|
||||
name: 'conversation.session.header.actions',
|
||||
id: 'agent-preset',
|
||||
order: 20,
|
||||
locale: 'settings.agentPreset',
|
||||
inject: labelInjected,
|
||||
}, AgentPresetLabel)
|
||||
return () => {
|
||||
stop()
|
||||
chip()
|
||||
label()
|
||||
}
|
||||
}, 'ui-agent-preset: new-session chip and header label')
|
||||
})
|
||||
|
||||
const sectionInjected = (): AgentPresetSectionInjected => ({
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/** Locale bundles for the agent-preset settings row, composer seat, and management section. */
|
||||
/** Locale bundles for the agent-preset settings row, hero chip, header label, and management section. */
|
||||
|
||||
/** Locale keys these surfaces render. */
|
||||
export type AgentPresetSettingsKey =
|
||||
| 'title' | 'description' | 'loading' | 'error' | 'userTrust' | 'seatHint' | 'lockedHint'
|
||||
| 'title' | 'description' | 'loading' | 'error' | 'userTrust' | 'seatHint' | 'headerHint'
|
||||
| 'nav' | 'sectionIntro' | 'builtIn' | 'defaultBadge' | 'setDefault' | 'edit' | 'view'
|
||||
| 'duplicate' | 'delete' | 'newPreset' | 'presetId' | 'presetIdPlaceholder' | 'copyOf'
|
||||
| 'displayName' | 'displayNamePlaceholder' | 'displayDescription' | 'displayDescriptionPlaceholder'
|
||||
@@ -18,8 +18,8 @@ export const en: Record<AgentPresetSettingsKey, string> = {
|
||||
loading: 'Loading presets…',
|
||||
error: 'Could not load agent presets.',
|
||||
userTrust: 'Custom',
|
||||
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',
|
||||
seatHint: 'Agent preset for the session you are about to start',
|
||||
headerHint: 'The agent preset this session runs, fixed when it started',
|
||||
nav: 'Agent presets',
|
||||
sectionIntro:
|
||||
'A preset is the plugin composition one session\'s agent runs — its tools, prompt, and capabilities. '
|
||||
@@ -68,8 +68,8 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
|
||||
loading: '正在加载预设…',
|
||||
error: '无法加载 Agent 预设。',
|
||||
userTrust: '自定义',
|
||||
seatHint: '本会话的 Agent 预设 —— 发送第一条消息前可切换',
|
||||
lockedHint: '会话开始后,其 Agent 预设即固定',
|
||||
seatHint: '即将开始的这个会话所用的 Agent 预设',
|
||||
headerHint: '本会话运行的 Agent 预设,开始时即固定',
|
||||
nav: 'Agent 预设',
|
||||
sectionIntro: '预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。内置预设只读;复制一份即可改成自己的。',
|
||||
builtIn: '内置',
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
/**
|
||||
* Composer-seat controller: what one session may switch to, and whether it
|
||||
* still may.
|
||||
* Hero-chip controller: which preset the NEXT session gets.
|
||||
*
|
||||
* 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.
|
||||
* The new-session screen has no session, so a pick is staged rather than
|
||||
* applied. It reaches a session when one becomes current and is still blank —
|
||||
* whether the workspace connect created it or reused an existing blank one,
|
||||
* which is why staging cannot simply ride along on `sessions.create`.
|
||||
*
|
||||
* The stage is forgotten once applied: the next new session starts from the
|
||||
* deployment default again, matching the workspace picker beside it.
|
||||
*/
|
||||
|
||||
import type { IApiClient } from '@deepseek-ai/dsh-client-connection/client'
|
||||
@@ -14,33 +17,49 @@ import {
|
||||
import { messageOf } from './settings-store.ts'
|
||||
import type { AgentPresetOption } from './settings-store.ts'
|
||||
|
||||
/** Composer-seat snapshot for one session. */
|
||||
/** Hero-chip snapshot. */
|
||||
export interface AgentPresetSeatState {
|
||||
/** Presets the deployment supplies; empty means the seat renders nothing. */
|
||||
/** Presets the deployment supplies; empty means the chip renders nothing. */
|
||||
options: readonly AgentPresetOption[]
|
||||
/** The preset this session runs, empty until the roster and summary load. */
|
||||
/** The staged choice, empty until the roster loads. */
|
||||
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. */
|
||||
/** A rejected apply's message, cleared by the next attempt. */
|
||||
error: string | null
|
||||
busy: boolean
|
||||
}
|
||||
|
||||
const INITIAL: AgentPresetSeatState = {
|
||||
options: [], current: '', switchable: false, error: null, busy: false,
|
||||
options: [], current: '', error: null, busy: false,
|
||||
}
|
||||
|
||||
/** Reads what one session may switch to and performs the switch. */
|
||||
/** One session's identity and whether it has started. */
|
||||
export interface SeatSessionSummary {
|
||||
/** The session the chip would apply its staged choice to. */
|
||||
id: SessionId
|
||||
/** False once a turn has run — applying is refused from then on. */
|
||||
blank: boolean
|
||||
/** The preset the session already runs, when the summary reports one. */
|
||||
agentPreset?: string
|
||||
}
|
||||
|
||||
/** Stages the next session's preset and applies it when one appears. */
|
||||
export class AgentPresetSeatController {
|
||||
/** Seat snapshot the renderer subscribes to. */
|
||||
/** Chip snapshot the renderer subscribes to. */
|
||||
readonly store: SnapshotStore<AgentPresetSeatState> = createSnapshotStore(INITIAL)
|
||||
|
||||
/**
|
||||
* The deployment default, so a consumed stage can fall back to it without
|
||||
* re-reading the roster.
|
||||
*/
|
||||
private fallback = ''
|
||||
|
||||
/** Set while a pick is waiting for a session; cleared once applied. */
|
||||
private staged: string | undefined
|
||||
|
||||
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 readonly api: Pick<IApiClient, 'agentPresets'>,
|
||||
/** The session the hero is about to hand over to, when there is one. */
|
||||
private readonly currentSession: () => SeatSessionSummary | undefined,
|
||||
) {}
|
||||
|
||||
private set(patch: Partial<AgentPresetSeatState>): void {
|
||||
@@ -48,25 +67,26 @@ export class AgentPresetSeatController {
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the roster and reconcile with this session's own state.
|
||||
* Read the roster and open the chip on the deployment default.
|
||||
* @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
|
||||
const { presets } = response.result.value
|
||||
this.fallback = presets.find(preset => preset.isDefault)?.id ?? presets[0]?.id ?? ''
|
||||
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,
|
||||
options: presets.map(preset => ({
|
||||
id: preset.id,
|
||||
trust: preset.trust,
|
||||
...preset.name === undefined ? {} : { name: preset.name },
|
||||
...preset.description === undefined ? {} : { description: preset.description },
|
||||
})),
|
||||
current: this.staged ?? this.fallback,
|
||||
error: null,
|
||||
})
|
||||
} catch (error) {
|
||||
@@ -75,23 +95,48 @@ export class AgentPresetSeatController {
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
* Stage one preset for the next session, applying it immediately when a
|
||||
* blank session is already current.
|
||||
* @param id - the preset to stage.
|
||||
* @returns once the stage settled, and the apply too when one happened.
|
||||
*/
|
||||
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 })
|
||||
if (this.store.getSnapshot().busy) return
|
||||
this.staged = id
|
||||
this.set({ current: id, error: null })
|
||||
await this.apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* Hand the staged choice to the current session, if there is one to take it.
|
||||
*
|
||||
* Called both by `select()` and by whoever observes the current session
|
||||
* changing, because the session may appear either before or after the pick.
|
||||
* @returns once the switch settled, or immediately when there is nothing to do.
|
||||
*/
|
||||
async apply(): Promise<void> {
|
||||
const staged = this.staged
|
||||
const session = this.currentSession()
|
||||
if (staged === undefined || session === undefined) return
|
||||
// A started session's history was produced under its own composition; the
|
||||
// host refuses the swap, so the stage is no longer meaningful.
|
||||
if (!session.blank || session.agentPreset === staged) {
|
||||
this.staged = undefined
|
||||
return
|
||||
}
|
||||
this.set({ busy: true, error: null })
|
||||
try {
|
||||
const response = await this.api.agentPresets.select({ sessionId: this.sessionId, agentPreset: id })
|
||||
const response = await this.api.agentPresets.select({ sessionId: session.id, agentPreset: staged })
|
||||
this.staged = undefined
|
||||
if (!response.result.ok) {
|
||||
this.set({ busy: false, current: before.current, error: response.result.error.message })
|
||||
this.set({ busy: false, error: response.result.error.message, current: this.fallback })
|
||||
return
|
||||
}
|
||||
// Consumed: the next new session opens on the deployment default again.
|
||||
this.set({ busy: false, current: response.result.value.agentPreset })
|
||||
} catch (error) {
|
||||
this.set({ busy: false, current: before.current, error: messageOf(error) })
|
||||
this.staged = undefined
|
||||
this.set({ busy: false, error: messageOf(error), current: this.fallback })
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -51,10 +51,14 @@ export async function writeDefaultPreset(
|
||||
|
||||
/** One selectable preset. */
|
||||
export interface AgentPresetOption {
|
||||
/** Preset id, written to Settings and shown as the label. */
|
||||
/** Preset id, written to Settings and the label's fallback. */
|
||||
id: string
|
||||
/** Whether the preset ships with the deployment or was authored locally. */
|
||||
trust: 'system' | 'user'
|
||||
/** Display name the preset published, absent when it published none. */
|
||||
name?: string
|
||||
/** One sentence on what the preset is for. */
|
||||
description?: string
|
||||
}
|
||||
|
||||
/** Agent-preset settings-row snapshot. */
|
||||
@@ -109,7 +113,12 @@ export class AgentPresetSettingsController {
|
||||
this.set({
|
||||
status: 'ready',
|
||||
error: null,
|
||||
options: presets.map(preset => ({ id: preset.id, trust: preset.trust })),
|
||||
options: presets.map(preset => ({
|
||||
id: preset.id,
|
||||
trust: preset.trust,
|
||||
...preset.name === undefined ? {} : { name: preset.name },
|
||||
...preset.description === undefined ? {} : { description: preset.description },
|
||||
})),
|
||||
// A roster can mark nothing default: settings can name a preset that
|
||||
// was since deleted, and the picker still has to show something.
|
||||
currentValue: presets.find(preset => preset.isDefault)?.id ?? first.id,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
/**
|
||||
* Registration: the General row, the settings section, and the per-session
|
||||
* composer seat all come from one apply, and each defers until the slot it
|
||||
* fills has been declared. A pushed settings change refreshes the surfaces
|
||||
* Registration: the General row, the settings section, the new-session chip,
|
||||
* and the header label all come from one apply, and each defers until the slot
|
||||
* it fills has been declared. A pushed settings change refreshes the surfaces
|
||||
* that are already showing, so a default set from one converges the other.
|
||||
*/
|
||||
|
||||
@@ -12,6 +12,8 @@ import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { LocaleService } from '@deepseek-ai/dsh-client-locale/client'
|
||||
import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime'
|
||||
import { apply, inject } from '@deepseek-ai/dsh-client-ui-agent-preset/client'
|
||||
import { AgentPresetLabel } from '../src/client/AgentPresetLabel.tsx'
|
||||
import type { AgentPresetLabelInjected } from '../src/client/AgentPresetLabel.tsx'
|
||||
import { AgentPresetRow } from '../src/client/AgentPresetRow.tsx'
|
||||
import type { AgentPresetRowInjected } from '../src/client/AgentPresetRow.tsx'
|
||||
import { AgentPresetSection } from '../src/client/AgentPresetSection.tsx'
|
||||
@@ -44,7 +46,10 @@ async function bench() {
|
||||
}),
|
||||
write: () => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { agentPreset: 'standard' } } }),
|
||||
remove: () => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: {} } }),
|
||||
select: () => Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { agentPreset: 'standard' } } }),
|
||||
select: (payload: { agentPreset: string }) => {
|
||||
calls.push(`select:${payload.agentPreset}`)
|
||||
return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: { agentPreset: payload.agentPreset } } })
|
||||
},
|
||||
},
|
||||
settings: {
|
||||
update: (payload: { patch: unknown }) => { calls.push(`settings:${JSON.stringify(payload.patch)}`); return Promise.resolve({ rpcId: 'r', result: { ok: true as const, value: {} } }) },
|
||||
@@ -65,14 +70,36 @@ function declareRoot(slots: SlotsService): () => void {
|
||||
} as never, () => null)
|
||||
}
|
||||
|
||||
/** The composer's own declaration, which the seat registration waits for. */
|
||||
function declareComposer(slots: SlotsService): () => void {
|
||||
/** The conversation's own declarations, which the chip and label wait for. */
|
||||
function declareConversation(slots: SlotsService): () => void {
|
||||
return slots.register({
|
||||
name: 'conversation',
|
||||
children: { 'conversation.input.agentPreset': { kind: 'single', scope: 'session' } },
|
||||
children: {
|
||||
'conversation.hero.agentPreset': { kind: 'single', scope: 'root' },
|
||||
'conversation.session.header.actions': { kind: 'list', scope: 'session' },
|
||||
},
|
||||
} as never, () => null)
|
||||
}
|
||||
|
||||
/** A sessions double whose list can be moved and whose changes are pushed. */
|
||||
function sessionsDouble(state: {
|
||||
current?: string
|
||||
byId: Record<string, { id: string; blank: boolean; agentPreset?: string }>
|
||||
}) {
|
||||
const listeners = new Set<() => void>()
|
||||
return {
|
||||
list: {
|
||||
getSnapshot: () => state,
|
||||
subscribe: (fn: () => void) => {
|
||||
listeners.add(fn)
|
||||
return () => listeners.delete(fn)
|
||||
},
|
||||
},
|
||||
/** Push a list change the way the runtime's store does. */
|
||||
notify: () => { for (const fn of listeners) fn() },
|
||||
}
|
||||
}
|
||||
|
||||
describe('ui-agent-preset apply', () => {
|
||||
it('declares the services it uses', () => {
|
||||
expect(inject).toEqual(['slots', 'locale', 'connection'])
|
||||
@@ -190,54 +217,121 @@ describe('ui-agent-preset apply', () => {
|
||||
expect(calls.length - before).toBe(1)
|
||||
})
|
||||
|
||||
it('gives each session its own seat controller and drops its registrations on disposal', async () => {
|
||||
it('registers the new-session chip and the header label, and drops both on disposal', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
declareRoot(slots)
|
||||
const conversation = declareComposer(slots)
|
||||
const conversation = declareConversation(slots)
|
||||
ctx.provide('conversation', {} as never)
|
||||
ctx.provide('sessions', { list: { getSnapshot: () => ({ byId: { s1: { blank: true, agentPreset: 'standard' } } }) } } as never)
|
||||
ctx.provide('sessions', sessionsDouble({ byId: {} }) as never)
|
||||
const fiber = ctx.plugin({ inject: [...inject, 'conversation', 'sessions'], apply })
|
||||
await fiber.await()
|
||||
|
||||
const seat = slots.entries('conversation.input.agentPreset')[0]!
|
||||
expect(seat.component).toBe(AgentPresetSeat)
|
||||
const make = seat.inject as unknown as (id: string) => AgentPresetSeatInjected
|
||||
// Same session, same controller; a different session gets its own, because
|
||||
// "may it still switch" is a per-session fact.
|
||||
expect(make('s1').hooks.agentPresetSeat).toBe(make('s1').hooks.agentPresetSeat)
|
||||
expect(make('s2').hooks.agentPresetSeat).not.toBe(make('s1').hooks.agentPresetSeat)
|
||||
const chip = slots.entries('conversation.hero.agentPreset')[0]!
|
||||
expect(chip.component).toBe(AgentPresetSeat)
|
||||
const label = slots.entries('conversation.session.header.actions')[0]!
|
||||
expect(label.component).toBe(AgentPresetLabel)
|
||||
expect(label.options).toMatchObject({ id: 'agent-preset', order: 20 })
|
||||
await fiber.dispose()
|
||||
expect(slots.entries('conversation.input.agentPreset')).toHaveLength(0)
|
||||
expect(slots.entries('conversation.hero.agentPreset')).toHaveLength(0)
|
||||
expect(slots.entries('conversation.session.header.actions')).toHaveLength(0)
|
||||
expect(slots.entries('settings.section')).toHaveLength(0)
|
||||
conversation()
|
||||
})
|
||||
|
||||
it('reads a seat\'s session state through the live session list', async () => {
|
||||
it('applies the staged choice to the blank session the flow lands on', async () => {
|
||||
const { ctx, slots, calls } = await bench()
|
||||
declareRoot(slots)
|
||||
declareConversation(slots)
|
||||
ctx.provide('conversation', {} as never)
|
||||
const state: {
|
||||
current?: string
|
||||
byId: Record<string, { id: string; blank: boolean; agentPreset?: string }>
|
||||
} = { byId: {} }
|
||||
const sessions = sessionsDouble(state)
|
||||
ctx.provide('sessions', sessions as never)
|
||||
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions'], apply }).await()
|
||||
const chip = (slots.entries('conversation.hero.agentPreset')[0]!
|
||||
.inject as unknown as () => AgentPresetSeatInjected)()
|
||||
|
||||
await chip.load()
|
||||
// Picked on the hero screen, where there is no session yet.
|
||||
await chip.select('minimal')
|
||||
expect(calls).not.toContain('select:minimal')
|
||||
|
||||
state.current = 's1'
|
||||
state.byId['s1'] = { id: 's1', blank: true, agentPreset: 'standard' }
|
||||
sessions.notify()
|
||||
|
||||
// Connecting a workspace produced the session; the stage reaches it there.
|
||||
await vi.waitFor(() => { expect(calls).toContain('select:minimal') })
|
||||
})
|
||||
|
||||
it('applies the stage to a session that records no preset of its own', async () => {
|
||||
const { ctx, slots, calls } = await bench()
|
||||
declareRoot(slots)
|
||||
declareConversation(slots)
|
||||
ctx.provide('conversation', {} as never)
|
||||
const sessions = sessionsDouble({
|
||||
current: 's1',
|
||||
byId: { s1: { id: 's1', blank: true } },
|
||||
})
|
||||
ctx.provide('sessions', sessions as never)
|
||||
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions'], apply }).await()
|
||||
const chip = (slots.entries('conversation.hero.agentPreset')[0]!
|
||||
.inject as unknown as () => AgentPresetSeatInjected)()
|
||||
|
||||
await chip.load()
|
||||
await chip.select('minimal')
|
||||
|
||||
// A session created before the deployment composed presets records none;
|
||||
// reading that as "already runs it" would drop the pick on the floor.
|
||||
expect(calls).toContain('select:minimal')
|
||||
})
|
||||
|
||||
it('forgets the stage once it has been spent', async () => {
|
||||
const { ctx, slots, calls } = await bench()
|
||||
declareRoot(slots)
|
||||
declareConversation(slots)
|
||||
ctx.provide('conversation', {} as never)
|
||||
const state = {
|
||||
current: 's1',
|
||||
byId: { s1: { id: 's1', blank: true, agentPreset: 'standard' } },
|
||||
}
|
||||
const sessions = sessionsDouble(state)
|
||||
ctx.provide('sessions', sessions as never)
|
||||
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions'], apply }).await()
|
||||
const chip = (slots.entries('conversation.hero.agentPreset')[0]!
|
||||
.inject as unknown as () => AgentPresetSeatInjected)()
|
||||
|
||||
await chip.load()
|
||||
await chip.select('minimal')
|
||||
const spent = calls.filter(call => call === 'select:minimal').length
|
||||
sessions.notify()
|
||||
sessions.notify()
|
||||
|
||||
// Every later list movement would re-apply a stage that was not cleared,
|
||||
// switching sessions the user never picked for.
|
||||
await Promise.resolve()
|
||||
expect(calls.filter(call => call === 'select:minimal')).toHaveLength(spent)
|
||||
})
|
||||
|
||||
it('gives the header label the same roster the General row reads', async () => {
|
||||
const { ctx, slots } = await bench()
|
||||
declareRoot(slots)
|
||||
declareComposer(slots)
|
||||
declareConversation(slots)
|
||||
ctx.provide('conversation', {} as never)
|
||||
const byId: Record<string, { blank: boolean; agentPreset?: string }> = {}
|
||||
ctx.provide('sessions', { list: { getSnapshot: () => ({ byId }) } } as never)
|
||||
ctx.provide('sessions', sessionsDouble({ byId: {} }) as never)
|
||||
await ctx.plugin({ inject: [...inject, 'conversation', 'sessions'], apply }).await()
|
||||
const make = slots.entries('conversation.input.agentPreset')[0]!
|
||||
.inject as unknown as (id: string) => AgentPresetSeatInjected
|
||||
const label = (slots.entries('conversation.session.header.actions')[0]!
|
||||
.inject as unknown as () => AgentPresetLabelInjected)()
|
||||
const row = (slots.entries('settings.general.item')[0]!
|
||||
.inject as unknown as () => AgentPresetRowInjected)()
|
||||
|
||||
const seat = make('s1')
|
||||
await seat.load()
|
||||
const unknownSession = seat.hooks.agentPresetSeat.getSnapshot().switchable
|
||||
// A session created before presets existed records none; the seat then
|
||||
// shows the roster default rather than an empty control.
|
||||
byId['s1'] = { blank: true }
|
||||
await seat.load()
|
||||
expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('standard')
|
||||
byId['s1'] = { blank: true, agentPreset: 'standard' }
|
||||
await seat.load()
|
||||
await label.load()
|
||||
|
||||
// A session the list has not caught up to offers no switch rather than
|
||||
// guessing that it is blank.
|
||||
expect(unknownSession).toBe(false)
|
||||
expect(seat.hooks.agentPresetSeat.getSnapshot()).toMatchObject({ switchable: true, current: 'standard' })
|
||||
await make('s1').select('standard')
|
||||
// One roster behind both: the label resolves a name the settings row's own
|
||||
// load already fetched, rather than issuing a second read per session.
|
||||
expect(label.hooks.agentPresets).toBe(row.hooks.agentPreset)
|
||||
expect(label.hooks.agentPresets.getSnapshot().options).toEqual([{ id: 'standard', trust: 'system' }])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
// @vitest-environment jsdom
|
||||
/**
|
||||
* The two picker surfaces: the General-settings row that names the default for
|
||||
* later sessions, and the composer seat that names this one's — the seat stops
|
||||
* being a control once the conversation starts, because the history from there
|
||||
* on was produced under the preset's tools.
|
||||
* The three conversation-adjacent surfaces: the General-settings row naming the
|
||||
* default for later sessions, the new-session chip naming the next one's, and
|
||||
* the session header's read-only label. The split is the host's rule — a
|
||||
* session's history is produced under its preset's tools, so the choice is
|
||||
* only ever offered before one starts.
|
||||
*/
|
||||
|
||||
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react'
|
||||
import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client'
|
||||
import { AgentPresetLabel } from '../src/client/AgentPresetLabel.tsx'
|
||||
import type { AgentPresetLabelProps } from '../src/client/AgentPresetLabel.tsx'
|
||||
import { AgentPresetRow } from '../src/client/AgentPresetRow.tsx'
|
||||
import type { AgentPresetRowProps } from '../src/client/AgentPresetRow.tsx'
|
||||
import { AgentPresetSeat } from '../src/client/AgentPresetSeat.tsx'
|
||||
@@ -30,8 +33,10 @@ const ROW_READY: AgentPresetSettingsState = {
|
||||
|
||||
const SEAT_READY: AgentPresetSeatState = {
|
||||
current: 'standard',
|
||||
options: [{ id: 'standard', trust: 'system' }, { id: 'mine', trust: 'user' }],
|
||||
switchable: true,
|
||||
options: [
|
||||
{ id: 'standard', trust: 'system', name: '标准模式', description: '完整的编码 agent。' },
|
||||
{ id: 'mine', trust: 'user' },
|
||||
],
|
||||
busy: false,
|
||||
error: null,
|
||||
}
|
||||
@@ -47,18 +52,37 @@ function renderRow(state: Partial<AgentPresetSettingsState> = {}) {
|
||||
return actions
|
||||
}
|
||||
|
||||
function renderSeat(state: Partial<AgentPresetSeatState> = {}, locked = false) {
|
||||
function renderSeat(state: Partial<AgentPresetSeatState> = {}) {
|
||||
const store = createSnapshotStore<AgentPresetSeatState>({ ...SEAT_READY, ...state })
|
||||
const actions = { load: vi.fn(() => Promise.resolve()), select: vi.fn(() => Promise.resolve()) }
|
||||
render(<AgentPresetSeat {...({
|
||||
...actions,
|
||||
locked,
|
||||
useAgentPresetSeat: bindSnapshotSelector(store),
|
||||
t: (key: keyof typeof en) => en[key],
|
||||
} as unknown as AgentPresetSeatProps)} />)
|
||||
return actions
|
||||
}
|
||||
|
||||
function renderLabel(
|
||||
summary: { blank: boolean; agentPreset?: string } | undefined,
|
||||
roster: Partial<AgentPresetSettingsState> = {},
|
||||
) {
|
||||
// The chip and the label read the same roster, metadata included.
|
||||
const store = createSnapshotStore<AgentPresetSettingsState>({
|
||||
...ROW_READY, options: SEAT_READY.options, ...roster,
|
||||
})
|
||||
const sessions = createSnapshotStore({ byId: summary === undefined ? {} : { s1: summary } })
|
||||
const load = vi.fn(() => Promise.resolve())
|
||||
const view = render(<AgentPresetLabel {...({
|
||||
load,
|
||||
sessionId: 's1',
|
||||
useSessions: bindSnapshotSelector(sessions),
|
||||
useAgentPresets: bindSnapshotSelector(store),
|
||||
t: (key: keyof typeof en) => en[key],
|
||||
} as unknown as AgentPresetLabelProps)} />)
|
||||
return { load, view }
|
||||
}
|
||||
|
||||
describe('the General-settings row', () => {
|
||||
it('reads the roster once and shows the current default', async () => {
|
||||
const actions = renderRow()
|
||||
@@ -140,85 +164,65 @@ describe('the General-settings row', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('the composer seat', () => {
|
||||
it('reads the roster once and offers the session\'s preset', async () => {
|
||||
describe('the new-session chip', () => {
|
||||
it('reads the roster once and shows the staged preset by name', async () => {
|
||||
const actions = renderSeat()
|
||||
|
||||
await waitFor(() => { expect(actions.load).toHaveBeenCalledTimes(1) })
|
||||
expect(screen.getByRole('button').textContent).toContain('standard')
|
||||
expect(screen.getByRole('button').textContent).toContain('标准模式')
|
||||
expect(screen.getByRole('button').getAttribute('title')).toBe(en.seatHint)
|
||||
})
|
||||
|
||||
it('switches the session and closes the menu', () => {
|
||||
it('offers each preset with what it is for', () => {
|
||||
renderSeat()
|
||||
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
// The id alone never said what a preset does; the description is the
|
||||
// whole reason a preset can publish metadata at all.
|
||||
expect(screen.getByText('完整的编码 agent。')).toBeTruthy()
|
||||
// A preset that published none still reads as a row, with its id standing
|
||||
// in for the name.
|
||||
expect(screen.getByText(en.noDescription)).toBeTruthy()
|
||||
expect(screen.getByText('mine')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('falls back to the id when the staged preset published no name', () => {
|
||||
renderSeat({ current: 'mine' })
|
||||
|
||||
expect(screen.getByRole('button').textContent).toContain('mine')
|
||||
})
|
||||
|
||||
it('stages the picked preset and closes the menu', () => {
|
||||
const actions = renderSeat()
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
fireEvent.click(screen.getByText(`mine · ${en.userTrust}`))
|
||||
fireEvent.click(screen.getByText('mine'))
|
||||
|
||||
expect(actions.select).toHaveBeenCalledWith('mine')
|
||||
expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
|
||||
it('becomes a plain label once the conversation has started', () => {
|
||||
renderSeat({ switchable: false })
|
||||
|
||||
// Not a disabled menu: that would suggest the preset could still change.
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
expect(screen.getByTitle(en.lockedHint).textContent).toBe('standard')
|
||||
})
|
||||
|
||||
it('closes an open menu the moment the session stops being blank', () => {
|
||||
const store = createSnapshotStore<AgentPresetSeatState>(SEAT_READY)
|
||||
render(<AgentPresetSeat {...({
|
||||
load: vi.fn(() => Promise.resolve()),
|
||||
select: vi.fn(() => Promise.resolve()),
|
||||
locked: false,
|
||||
useAgentPresetSeat: bindSnapshotSelector(store),
|
||||
t: (key: keyof typeof en) => en[key],
|
||||
} as unknown as AgentPresetSeatProps)} />)
|
||||
fireEvent.click(screen.getByRole('button'))
|
||||
|
||||
act(() => { store.set({ ...SEAT_READY, switchable: false }) })
|
||||
|
||||
expect(screen.getByTitle(en.lockedHint)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('disables the trigger while the composer or a switch is busy', () => {
|
||||
it('disables the trigger while a switch is in flight', () => {
|
||||
renderSeat({ busy: true })
|
||||
expect(screen.getByRole('button')).toHaveProperty('disabled', true)
|
||||
cleanup()
|
||||
|
||||
renderSeat({}, true)
|
||||
expect(screen.getByRole('button')).toHaveProperty('disabled', true)
|
||||
})
|
||||
|
||||
it('shows a failed switch on the trigger', () => {
|
||||
it('shows a refused switch on the trigger', () => {
|
||||
renderSeat({ error: 'session has already started' })
|
||||
|
||||
expect(screen.getByRole('button').getAttribute('title')).toBe('session has already started')
|
||||
})
|
||||
|
||||
it('renders nothing before the roster arrives or when there is none', () => {
|
||||
const empty = render(<AgentPresetSeat {...({
|
||||
load: vi.fn(() => Promise.resolve()),
|
||||
select: vi.fn(() => Promise.resolve()),
|
||||
locked: false,
|
||||
useAgentPresetSeat: bindSnapshotSelector(
|
||||
createSnapshotStore<AgentPresetSeatState>({ ...SEAT_READY, options: [] })),
|
||||
t: (key: keyof typeof en) => en[key],
|
||||
} as unknown as AgentPresetSeatProps)} />)
|
||||
expect(empty.container.firstChild).toBeNull()
|
||||
const empty = renderSeat({ options: [] })
|
||||
expect(empty).toBeTruthy()
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
cleanup()
|
||||
|
||||
const unresolved = render(<AgentPresetSeat {...({
|
||||
load: vi.fn(() => Promise.resolve()),
|
||||
select: vi.fn(() => Promise.resolve()),
|
||||
locked: false,
|
||||
useAgentPresetSeat: bindSnapshotSelector(
|
||||
createSnapshotStore<AgentPresetSeatState>({ ...SEAT_READY, current: '' })),
|
||||
t: (key: keyof typeof en) => en[key],
|
||||
} as unknown as AgentPresetSeatProps)} />)
|
||||
expect(unresolved.container.firstChild).toBeNull()
|
||||
renderSeat({ current: '' })
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('closes on an outside dismissal', () => {
|
||||
@@ -230,3 +234,42 @@ describe('the composer seat', () => {
|
||||
expect(screen.getByRole('button').getAttribute('aria-expanded')).toBe('false')
|
||||
})
|
||||
})
|
||||
|
||||
describe('the session-header label', () => {
|
||||
it('names the preset the session runs, and never offers a switch', async () => {
|
||||
const { load } = renderLabel({ blank: false, agentPreset: 'standard' })
|
||||
|
||||
await waitFor(() => { expect(load).toHaveBeenCalledTimes(1) })
|
||||
// A control here would promise a switch the host refuses outright.
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
expect(screen.getByTitle('完整的编码 agent。').textContent).toBe('标准模式')
|
||||
})
|
||||
|
||||
it('falls back to the id, and to the generic hint, when metadata is absent', () => {
|
||||
renderLabel({ blank: true, agentPreset: 'mine' })
|
||||
|
||||
expect(screen.getByTitle(en.headerHint).textContent).toBe('mine')
|
||||
})
|
||||
|
||||
it('shows the id until the roster resolves it', () => {
|
||||
renderLabel({ blank: false, agentPreset: 'standard' }, { options: [] })
|
||||
|
||||
// The session's own summary is the authority on which preset it runs; the
|
||||
// roster only supplies the display name, and its arrival is a later frame.
|
||||
expect(screen.getByTitle(en.headerHint).textContent).toBe('standard')
|
||||
})
|
||||
|
||||
it('renders nothing, and reads no roster, when the session records no preset', async () => {
|
||||
const absent = renderLabel({ blank: true })
|
||||
expect(absent.view.container.firstChild).toBeNull()
|
||||
cleanup()
|
||||
|
||||
// A session the list has not caught up to is the same answer: a deployment
|
||||
// that composes no presets must not pay for a roster read per header.
|
||||
const unknown = renderLabel(undefined)
|
||||
expect(unknown.view.container.firstChild).toBeNull()
|
||||
await act(async () => { await Promise.resolve() })
|
||||
expect(absent.load).not.toHaveBeenCalled()
|
||||
expect(unknown.load).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
AGENT_PRESET_SETTINGS_NS, AgentPresetSettingsController, messageOf,
|
||||
} from '../src/client/settings-store.ts'
|
||||
import { AgentPresetSeatController } from '../src/client/seat-store.ts'
|
||||
import type { SeatSessionSummary } from '../src/client/seat-store.ts'
|
||||
|
||||
interface Recorded { ns: string; patch: unknown }
|
||||
|
||||
@@ -60,6 +61,20 @@ describe('the agent-preset settings controller', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('carries the display metadata a preset published', async () => {
|
||||
const controller = new AgentPresetSettingsController(fakeApi([
|
||||
{ id: 'standard', trust: 'system', isDefault: true, name: '标准模式', description: '完整的编码 agent。' },
|
||||
] as never))
|
||||
|
||||
await controller.load()
|
||||
|
||||
// Surfaces beyond this row read the same options; the id alone never said
|
||||
// what a preset does.
|
||||
expect(controller.store.getSnapshot().options).toEqual([
|
||||
{ id: 'standard', trust: 'system', name: '标准模式', description: '完整的编码 agent。' },
|
||||
])
|
||||
})
|
||||
|
||||
it('reports an empty roster as unavailable, not as an error', async () => {
|
||||
const controller = new AgentPresetSettingsController(fakeApi([]))
|
||||
|
||||
@@ -121,100 +136,6 @@ describe('the agent-preset settings controller', () => {
|
||||
expect(state.status).toBe('error')
|
||||
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; failList?: string; throwOn?: 'list' | 'select' } = {},
|
||||
): AgentPresetSeatController {
|
||||
const api = {
|
||||
agentPresets: {
|
||||
list: () => {
|
||||
if (options.throwOn === 'list') return Promise.reject(new Error('socket closed'))
|
||||
return 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: {} } } })
|
||||
},
|
||||
select: (payload: { agentPreset: string }) => {
|
||||
if (options.throwOn === 'select') return Promise.reject(new Error('socket closed'))
|
||||
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: 'minimal', trust: 'system', isDefault: false },
|
||||
]
|
||||
|
||||
it('shows what the session runs, not the deployment default', async () => {
|
||||
const controller = seat(ROSTER, { blank: true, agentPreset: 'minimal' })
|
||||
|
||||
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('minimal')
|
||||
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('minimal')
|
||||
|
||||
// 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('minimal')
|
||||
|
||||
expect(writes).toEqual([{ ns: 'select', patch: 'minimal' }])
|
||||
expect(controller.store.getSnapshot().current).toBe('minimal')
|
||||
})
|
||||
|
||||
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('minimal')
|
||||
|
||||
const state = controller.store.getSnapshot()
|
||||
expect(state.current).toBe('standard')
|
||||
expect(state.error).toBe('already started')
|
||||
})
|
||||
|
||||
it('shows the first preset when the roster marks none default', async () => {
|
||||
// Settings can name a preset that was since deleted; the picker still has
|
||||
@@ -269,42 +190,204 @@ describe('the composer seat controller', () => {
|
||||
// showing "mine" would be claiming a default that does not exist.
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ currentValue: 'standard', error: 'socket closed' })
|
||||
})
|
||||
})
|
||||
|
||||
it('reports a refused roster read without emptying the seat', async () => {
|
||||
const controller = seat(ROSTER, { blank: true }, { failList: 'host down' })
|
||||
describe('the new-session chip controller', () => {
|
||||
/** A chip over a current session the test can move. */
|
||||
function chip(
|
||||
presets: { id: string; trust: 'system' | 'user'; isDefault: boolean }[],
|
||||
current: { id: string; blank: boolean; agentPreset?: string } | undefined,
|
||||
options: { writes?: Recorded[]; failSelect?: string; failList?: string; throwOn?: 'list' | 'select' } = {},
|
||||
): AgentPresetSeatController {
|
||||
const api = {
|
||||
agentPresets: {
|
||||
list: () => {
|
||||
if (options.throwOn === 'list') return Promise.reject(new Error('socket closed'))
|
||||
return 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: {} } } })
|
||||
},
|
||||
select: (payload: { agentPreset: string }) => {
|
||||
if (options.throwOn === 'select') return Promise.reject(new Error('socket closed'))
|
||||
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, () => current as SeatSessionSummary | undefined)
|
||||
}
|
||||
|
||||
const ROSTER: { id: string; trust: 'system' | 'user'; isDefault: boolean }[] = [
|
||||
{ id: 'standard', trust: 'system', isDefault: true },
|
||||
{ id: 'minimal', trust: 'system', isDefault: false },
|
||||
]
|
||||
|
||||
it('opens on the deployment default', async () => {
|
||||
const controller = chip(ROSTER, undefined)
|
||||
|
||||
await controller.load()
|
||||
|
||||
// The chip names the session about to start, and nothing about it is
|
||||
// decided yet — the default is the honest opening value.
|
||||
expect(controller.store.getSnapshot().current).toBe('standard')
|
||||
expect(controller.store.getSnapshot().options).toEqual([
|
||||
{ id: 'standard', trust: 'system' },
|
||||
{ id: 'minimal', trust: 'system' },
|
||||
])
|
||||
})
|
||||
|
||||
it('shows the first preset when the roster marks none default', async () => {
|
||||
const controller = chip([{ id: 'minimal', trust: 'system', isDefault: false }], undefined)
|
||||
|
||||
await controller.load()
|
||||
|
||||
// Settings can name a preset that was since deleted; the chip still has
|
||||
// to open on something rather than render nothing.
|
||||
expect(controller.store.getSnapshot().current).toBe('minimal')
|
||||
})
|
||||
|
||||
it('carries the display metadata into the menu rows', async () => {
|
||||
const controller = chip([
|
||||
{ id: 'standard', trust: 'system', isDefault: true, name: '标准模式', description: '完整的编码 agent。' },
|
||||
] as never, undefined)
|
||||
|
||||
await controller.load()
|
||||
|
||||
expect(controller.store.getSnapshot().options).toEqual([
|
||||
{ id: 'standard', trust: 'system', name: '标准模式', description: '完整的编码 agent。' },
|
||||
])
|
||||
})
|
||||
|
||||
it('opens on nothing when the deployment composes no presets', async () => {
|
||||
const controller = chip([], undefined)
|
||||
|
||||
await controller.load()
|
||||
|
||||
// An empty roster is a valid deployment: every session shares the host
|
||||
// composition, and the chip renders nothing rather than an empty control.
|
||||
expect(controller.store.getSnapshot().current).toBe('')
|
||||
})
|
||||
|
||||
it('stages a pick made before any session exists', async () => {
|
||||
const writes: Recorded[] = []
|
||||
const controller = chip(ROSTER, undefined, { writes })
|
||||
await controller.load()
|
||||
|
||||
await controller.select('minimal')
|
||||
|
||||
// Nothing to switch yet: the new-session screen precedes the session.
|
||||
expect(writes).toEqual([])
|
||||
expect(controller.store.getSnapshot().current).toBe('minimal')
|
||||
})
|
||||
|
||||
it('applies the stage to the blank session the flow lands on', async () => {
|
||||
const writes: Recorded[] = []
|
||||
const current = { id: 's1', blank: true, agentPreset: 'standard' }
|
||||
const controller = chip(ROSTER, current, { writes })
|
||||
await controller.load()
|
||||
await controller.select('minimal')
|
||||
|
||||
expect(writes).toEqual([{ ns: 'select', patch: 'minimal' }])
|
||||
expect(controller.store.getSnapshot().current).toBe('minimal')
|
||||
})
|
||||
|
||||
it('spends the stage exactly once', async () => {
|
||||
const writes: Recorded[] = []
|
||||
const controller = chip(ROSTER, { id: 's1', blank: true, agentPreset: 'standard' }, { writes })
|
||||
await controller.load()
|
||||
await controller.select('minimal')
|
||||
|
||||
await controller.apply()
|
||||
await controller.apply()
|
||||
|
||||
// Every later list movement calls apply(); an unspent stage would keep
|
||||
// switching sessions the user never picked for.
|
||||
expect(writes).toEqual([{ ns: 'select', patch: 'minimal' }])
|
||||
})
|
||||
|
||||
it('drops the stage against a session that already started', async () => {
|
||||
const writes: Recorded[] = []
|
||||
const controller = chip(ROSTER, { id: 's1', blank: false, agentPreset: 'standard' }, { writes })
|
||||
await controller.load()
|
||||
|
||||
await controller.select('minimal')
|
||||
|
||||
// The host enforces the same rule; the chip simply never asks.
|
||||
expect(writes).toEqual([])
|
||||
})
|
||||
|
||||
it('drops the stage when the session already runs it', async () => {
|
||||
const writes: Recorded[] = []
|
||||
const controller = chip(ROSTER, { id: 's1', blank: true, agentPreset: 'minimal' }, { writes })
|
||||
await controller.load()
|
||||
|
||||
await controller.select('minimal')
|
||||
|
||||
expect(writes).toEqual([])
|
||||
})
|
||||
|
||||
it('falls back to the default when the host refuses the switch', async () => {
|
||||
const controller = chip(
|
||||
ROSTER, { id: 's1', blank: true, agentPreset: 'standard' }, { failSelect: 'already started' })
|
||||
await controller.load()
|
||||
|
||||
await controller.select('minimal')
|
||||
|
||||
// Showing `minimal` after a refusal would claim a composition the session
|
||||
// never got.
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ current: 'standard', error: 'already started' })
|
||||
})
|
||||
|
||||
it('falls back to the default when the switch never reaches the host', async () => {
|
||||
const controller = chip(
|
||||
ROSTER, { id: 's1', blank: true, agentPreset: 'standard' }, { throwOn: 'select' })
|
||||
await controller.load()
|
||||
|
||||
await controller.select('minimal')
|
||||
|
||||
expect(controller.store.getSnapshot())
|
||||
.toMatchObject({ current: 'standard', busy: false, error: 'socket closed' })
|
||||
})
|
||||
|
||||
it('ignores a pick while a switch is in flight', async () => {
|
||||
const writes: Recorded[] = []
|
||||
const controller = chip(ROSTER, { id: 's1', blank: true, agentPreset: 'standard' }, { writes })
|
||||
await controller.load()
|
||||
|
||||
const first = controller.select('minimal')
|
||||
await controller.select('standard')
|
||||
await first
|
||||
|
||||
expect(writes).toEqual([{ ns: 'select', patch: 'minimal' }])
|
||||
})
|
||||
|
||||
it('keeps a staged pick across a roster refresh', async () => {
|
||||
const controller = chip(ROSTER, undefined)
|
||||
await controller.load()
|
||||
await controller.select('minimal')
|
||||
|
||||
await controller.load()
|
||||
|
||||
// A settings push re-reads the roster; it must not silently discard what
|
||||
// the user picked for the session they are about to start.
|
||||
expect(controller.store.getSnapshot().current).toBe('minimal')
|
||||
})
|
||||
|
||||
it('reports a refused roster read without emptying the chip', async () => {
|
||||
const controller = chip(ROSTER, undefined, { failList: 'host down' })
|
||||
|
||||
await controller.load()
|
||||
|
||||
// The seat keeps whatever it last showed rather than claiming this session
|
||||
// has no preset; the message says why it could not refresh.
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ error: 'host down', options: [] })
|
||||
})
|
||||
|
||||
it('reports a transport that rejects the roster read', async () => {
|
||||
const controller = seat(ROSTER, { blank: true }, { throwOn: 'list' })
|
||||
const controller = chip(ROSTER, undefined, { throwOn: 'list' })
|
||||
|
||||
await controller.load()
|
||||
|
||||
expect(controller.store.getSnapshot().error).toBe('socket closed')
|
||||
})
|
||||
|
||||
it('restores the previous preset when the switch never reaches the host', async () => {
|
||||
const controller = seat(ROSTER, { blank: true, agentPreset: 'standard' }, { throwOn: 'select' })
|
||||
await controller.load()
|
||||
|
||||
await controller.select('minimal')
|
||||
|
||||
// Showing `minimal` after a failed switch would claim a composition the
|
||||
// session never got.
|
||||
expect(controller.store.getSnapshot()).toMatchObject({ current: 'standard', busy: false, error: 'socket closed' })
|
||||
})
|
||||
|
||||
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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -165,6 +165,7 @@ export function apply(ctx: Context): void {
|
||||
'conversation.input.left': { kind: 'list', scope: 'session' },
|
||||
'conversation.input.right': { kind: 'list', scope: 'session' },
|
||||
'conversation.hero.workspace': { kind: 'single', scope: 'root' },
|
||||
'conversation.hero.agentPreset': { kind: 'single', scope: 'root' },
|
||||
},
|
||||
inject: (sessionId: SessionId | undefined): ConversationInjected => ({
|
||||
selectWorkspace: async (workspaceId) => {
|
||||
@@ -228,7 +229,6 @@ 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) {
|
||||
|
||||
@@ -61,6 +61,12 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
* reads the global workspace list.
|
||||
*/
|
||||
'conversation.hero.workspace': { kind: 'single'; scope: 'root'; owner: EmptyWorkspaceOwnerProps }
|
||||
/**
|
||||
* The agent-preset chip beside the workspace picker on the new-session
|
||||
* screen. Root scope: no session exists yet, so the choice is staged for
|
||||
* the next one rather than applied to a current one.
|
||||
*/
|
||||
'conversation.hero.agentPreset': { kind: 'single'; scope: 'root'; owner: HeroAgentPresetOwnerProps }
|
||||
// 'conversation.input.overlay' merges in ui-slash (dedup ruling: the
|
||||
// dependency direction is the hard constraint — ui-slash cannot import
|
||||
// this package, while this package's input contract already imports
|
||||
@@ -102,12 +108,6 @@ 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 }
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -129,6 +129,28 @@ declare module '@deepseek-ai/dsh-client-ui-slots' {
|
||||
}
|
||||
}
|
||||
|
||||
/** Owner share of the hero agent-preset chip: the shell supplies nothing. */
|
||||
export interface HeroAgentPresetOwnerProps {
|
||||
/** Marker field: the chip owns its own roster, staging, and menu state. */
|
||||
children?: never
|
||||
}
|
||||
|
||||
/** Owner share of the strict session content seat. */
|
||||
export interface ConversationSessionOwnerProps {
|
||||
/**
|
||||
* Wrap the view ring in the transcript scrollport that also hosts the
|
||||
* sticky composer seat (whole `'conversation.composer'` chain output).
|
||||
* Supplied for every real session (hero/settling/active) so the composer
|
||||
* keeps one tree seat across the blank → active flip; the header stays
|
||||
* outside that wrapper as ordinary column chrome (`flex: none`), while
|
||||
* active CSS sticks the seat to the bottom of the same scrollport so wheel
|
||||
* over the footer scrolls the flow.
|
||||
* @param view - the session view-ring content (null while blank chrome is hidden).
|
||||
* @returns the scrollport containing `view` and the sticky composer seat.
|
||||
*/
|
||||
wrapActiveBody?: (view: ReactNode) => ReactNode
|
||||
}
|
||||
|
||||
/** Header actions derive their state from the standard session/global kit. */
|
||||
export interface ConversationHeaderActionOwnerProps {}
|
||||
|
||||
@@ -331,7 +353,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' | 'conversation.input.agentPreset'>
|
||||
& PropsRenderSlots<'conversation.input.plan' | 'conversation.input.model'>
|
||||
& InjectFace<ComposerBarInjected>
|
||||
& PropsLocale<'conversation'>
|
||||
|
||||
@@ -361,6 +383,7 @@ export type ConversationSlotProps =
|
||||
| 'conversation.input.dock' | 'conversation.composer.dock'
|
||||
| 'conversation.input.left' | 'conversation.input.right'
|
||||
| 'conversation.hero.workspace'
|
||||
| 'conversation.hero.agentPreset'
|
||||
>
|
||||
& ConversationInjected
|
||||
& PropsLocale<'conversation'>
|
||||
|
||||
@@ -117,6 +117,7 @@ export function ConversationRoot({
|
||||
},
|
||||
onClose: () => { setPickerOpen(false) },
|
||||
})}
|
||||
{renderSlot('conversation.hero.agentPreset', {})}
|
||||
</div>
|
||||
)
|
||||
|
||||
|
||||
@@ -512,7 +512,6 @@ 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="处理中" />} */}
|
||||
|
||||
@@ -85,9 +85,11 @@ describe('apply wiring', () => {
|
||||
expect(conversationHeader?.store).toBe(conversationSession?.store)
|
||||
expect(details?.store).toBe(conversationSession?.store)
|
||||
expect(chatView?.store).toBe(conversationSession?.store)
|
||||
// The hero workspace picker hole rides the conversation entry's children
|
||||
// declaration (the empty-state occupant is gone).
|
||||
// The hero holes ride the conversation entry's children declaration (the
|
||||
// empty-state occupant is gone). Both are root-scoped: the new-session
|
||||
// screen precedes the session either would belong to.
|
||||
expect(b.slots.spec('conversation.hero.workspace')).toEqual({ kind: 'single', scope: 'root' })
|
||||
expect(b.slots.spec('conversation.hero.agentPreset')).toEqual({ kind: 'single', scope: 'root' })
|
||||
expect(b.slots.entries('settings.general.item').map(entry => entry.options.id)).toEqual(['composer-enter'])
|
||||
await b.runtime.dispose()
|
||||
})
|
||||
|
||||
@@ -693,7 +693,7 @@ describe('command launcher chrome and control seats', () => {
|
||||
expect(view.queryByLabelText(/^访问模式/)).toBeNull()
|
||||
// Every seat dispatched, nothing rendered.
|
||||
expect(slotCalls.map(c => c.key)).toEqual([
|
||||
'conversation.input.plan', 'conversation.input.agentPreset', 'conversation.input.model',
|
||||
'conversation.input.plan', 'conversation.input.model',
|
||||
])
|
||||
expect(view.queryByLabelText('Plan mode')).toBeNull()
|
||||
expect(view.queryByLabelText('Model')).toBeNull()
|
||||
|
||||
@@ -407,6 +407,9 @@ describe('ConversationRoot resident composer', () => {
|
||||
const chip = b.view.getByRole('button', { name: '选择工作区' })
|
||||
expect((chip as HTMLButtonElement).disabled).toBe(false)
|
||||
expect(b.slotCalls).toContain('conversation.hero.workspace')
|
||||
// The agent-preset chip sits in the same row, for the same reason: both
|
||||
// choices are only open before the first message.
|
||||
expect(b.slotCalls).toContain('conversation.hero.agentPreset')
|
||||
})
|
||||
|
||||
it('prompt failure renders the promptError strip (ordinary failure, no transaction UI)', () => {
|
||||
|
||||
Reference in New Issue
Block a user