feat(web): clarify agent presets and add mode guides
This commit is contained in:
1 parent
8d6245bcc8
commit
05a229ccc9
24 files changed
+1090
-184
No files matched your search
@@ -78,30 +78,68 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
|
||||
await dialog.getByRole('heading', { name: 'Agent 预设' }).waitFor({ timeout: 10_000 })
|
||||
// The intro copy also names 标准模式. Wait for the roster's own action so
|
||||
// the snapshot cannot land between the section shell and its cards.
|
||||
await dialog.getByRole('button', { name: '查看: 标准模式', exact: true }).waitFor({ timeout: 10_000 })
|
||||
await dialog.getByRole('button', { name: '查看配置: 标准模式', exact: true }).waitFor({ timeout: 10_000 })
|
||||
|
||||
const snapshot = await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)
|
||||
|
||||
await compareOrRefreshGolden(SECTION_EXPECTED, snapshot, MODE)
|
||||
const toggle = dialog.getByRole('switch', { name: '允许切换agent模式' })
|
||||
const toggle = dialog.getByRole('switch', { name: '新任务可选择模式' })
|
||||
expect(await toggle.getAttribute('aria-checked')).toBe('true')
|
||||
// The intro states the copy path directly, and the shipped rows offer
|
||||
// The authoring entry is visible, and the shipped rows offer
|
||||
// view/copy but never delete or a location — their
|
||||
// install is overwritten by upgrades and is not the user's to manage.
|
||||
expect(snapshot).toContain('或用「创造模式」让 Agent 帮你创建')
|
||||
expect(snapshot).toContain('让 Agent 帮我创建预设模式')
|
||||
expect(snapshot).not.toContain('新建预设')
|
||||
expect(snapshot).toContain('查看: 标准模式')
|
||||
expect(snapshot).toContain('查看配置: 标准模式')
|
||||
expect(snapshot).not.toContain('删除: 标准模式')
|
||||
expect(snapshot).not.toContain('打开目录')
|
||||
// The rest of this scenario exercises the existing default and Creator
|
||||
// actions with the beta picker enabled by default.
|
||||
}, 60_000)
|
||||
|
||||
it('reads technical details and examples without changing the new-task default', async () => {
|
||||
const settings = settingsDialog()
|
||||
const trigger = settings.getByRole('button', { name: '模式说明: PTC 模式', exact: true })
|
||||
await trigger.click()
|
||||
const guide = page.getByRole('dialog', { name: 'PTC 模式', exact: true })
|
||||
await guide.getByRole('heading', { name: '怎样调用工具', exact: true }).waitFor()
|
||||
const beforeSwitch = await guide.boundingBox()
|
||||
expect(await guide.textContent()).toContain('run_code')
|
||||
await guide.getByRole('tab', { name: '如何使用', exact: true }).click()
|
||||
await guide.getByRole('heading', { name: '批量检查配置文件', exact: true }).waitFor()
|
||||
expect(await guide.boundingBox()).toEqual(beforeSwitch)
|
||||
await guide.getByRole('tab', { name: '如何使用', exact: true }).press('ArrowLeft')
|
||||
expect(await guide.getByRole('tab', { name: '模式说明', exact: true }).getAttribute('aria-selected')).toBe('true')
|
||||
await guide.getByRole('tab', { name: '模式说明', exact: true }).press('Escape')
|
||||
await guide.waitFor({ state: 'detached' })
|
||||
expect(await trigger.evaluate(element => document.activeElement === element)).toBe(true)
|
||||
expect(await settings.isVisible()).toBe(true)
|
||||
expect(await settings.getByRole('button', { name: '新任务默认: 标准模式', exact: true }).getAttribute('aria-pressed')).toBe('true')
|
||||
|
||||
await settings.getByRole('button', { name: '如何使用: 创造模式', exact: true }).click()
|
||||
const creator = page.getByRole('dialog', { name: '创造模式', exact: true })
|
||||
await creator.getByRole('heading', { name: '添加一个界面', exact: true }).waitFor()
|
||||
expect(await creator.getByRole('heading', { name: '添加一个工具', exact: true }).isVisible()).toBe(true)
|
||||
const closePosition = await creator.getByRole('button', { name: '关闭', exact: true }).boundingBox()
|
||||
const examples = creator.getByRole('tabpanel', { name: '如何使用', exact: true })
|
||||
const scrolledTo = await examples.evaluate((element) => {
|
||||
element.scrollTop = element.scrollHeight
|
||||
return element.scrollTop
|
||||
})
|
||||
expect(scrolledTo).toBeGreaterThan(0)
|
||||
expect(await creator.getByRole('button', { name: '关闭', exact: true }).boundingBox()).toEqual(closePosition)
|
||||
await creator.getByRole('tab', { name: '模式说明', exact: true }).click()
|
||||
await creator.getByRole('tab', { name: '如何使用', exact: true }).click()
|
||||
expect(await examples.evaluate(element => element.scrollTop)).toBe(scrolledTo)
|
||||
await creator.getByRole('button', { name: '关闭', exact: true }).click()
|
||||
await creator.waitFor({ state: 'detached' })
|
||||
}, 60_000)
|
||||
|
||||
it('views a shipped composition read-only instead of editing it', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-view'))
|
||||
const dialog = settingsDialog()
|
||||
await dialog.getByRole('button', { name: '查看: 标准模式' }).click()
|
||||
const viewer = page.getByRole('dialog', { name: '查看 · 标准模式' })
|
||||
await dialog.getByRole('button', { name: '查看配置: 标准模式' }).click()
|
||||
const viewer = page.getByRole('dialog', { name: '查看配置 · 标准模式' })
|
||||
await viewer.waitFor({ timeout: 10_000 })
|
||||
|
||||
// The real shipped composition, not a golden: the viewer shows whatever
|
||||
@@ -118,7 +156,7 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
|
||||
it('copies 极简模式 whole under a new id and lands in its files', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-preset-authoring-copy'))
|
||||
const dialog = settingsDialog()
|
||||
await dialog.getByRole('button', { name: '复制: 极简模式' }).click()
|
||||
await dialog.getByRole('button', { name: '复制预设: 极简模式' }).click()
|
||||
const copyDialog = page.getByRole('dialog', { name: '复制预设 · 复制自 极简模式' })
|
||||
await copyDialog.waitFor({ timeout: 10_000 })
|
||||
|
||||
@@ -131,7 +169,7 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
|
||||
expect(dialogSnapshot).not.toContain('描述')
|
||||
|
||||
await copyDialog.getByPlaceholder('my-agent').fill('my-agent')
|
||||
await copyDialog.getByPlaceholder('选择器中显示的名字,缺省用标识符').fill('我的模式')
|
||||
await copyDialog.getByPlaceholder('在模式列表中显示的名称,不填则使用标识符').fill('我的模式')
|
||||
await copyDialog.getByRole('button', { name: '创建' }).click()
|
||||
await copyDialog.waitFor({ state: 'detached', timeout: 10_000 })
|
||||
|
||||
@@ -175,7 +213,7 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
|
||||
// The custom group outlives its only member: the heading stays with the
|
||||
// creator entry so the place to author a preset never disappears.
|
||||
expect(await dialog.getByRole('heading', { name: '自定义' }).count()).toBe(1)
|
||||
expect(await dialog.getByRole('button', { name: '用「创造模式」创作自定义预设' }).count()).toBe(1)
|
||||
expect(await dialog.getByRole('button', { name: '让 Agent 帮我创建预设模式' }).count()).toBe(1)
|
||||
expect(await dialog.getByText('标准模式').count()).toBeGreaterThan(0)
|
||||
}, 60_000)
|
||||
|
||||
@@ -205,10 +243,10 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
|
||||
expect(snapshot).toContain('not valid YAML')
|
||||
expect(snapshot).toContain('agent.cordis.yml is missing')
|
||||
expect(await dialog.getByRole('button', { name: '加载失败: broken-yaml' }).isDisabled()).toBe(true)
|
||||
expect(await dialog.getByRole('button', { name: '复制: 幽灵预设' }).isDisabled()).toBe(true)
|
||||
expect(await dialog.getByRole('button', { name: '复制预设: 幽灵预设' }).isDisabled()).toBe(true)
|
||||
// A broken card offers no "set default" affordance at all — the aria name
|
||||
// IS the broken marking, so the picking name must not exist.
|
||||
expect(await dialog.getByRole('button', { name: '设为默认: broken-yaml' }).count()).toBe(0)
|
||||
expect(await dialog.getByRole('button', { name: '设为新任务默认: broken-yaml' }).count()).toBe(0)
|
||||
|
||||
// The ghost's way out is the card's own delete — and the id it blocked
|
||||
// is claimable again immediately afterwards.
|
||||
@@ -220,13 +258,13 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
|
||||
await expect.poll(async () => dialog.getByText('幽灵预设').count(), { timeout: 10_000 }).toBe(0)
|
||||
expect(existsSync(join(userRoot, 'ghost'))).toBe(false)
|
||||
|
||||
await dialog.getByRole('button', { name: '复制: 极简模式' }).click()
|
||||
await dialog.getByRole('button', { name: '复制预设: 极简模式' }).click()
|
||||
const copyDialog = page.getByRole('dialog', { name: '复制预设 · 复制自 极简模式' })
|
||||
await copyDialog.waitFor({ timeout: 10_000 })
|
||||
await copyDialog.getByPlaceholder('my-agent').fill('ghost')
|
||||
await copyDialog.getByRole('button', { name: '创建' }).click()
|
||||
await copyDialog.waitFor({ state: 'detached', timeout: 10_000 })
|
||||
await dialog.getByRole('button', { name: '设为默认: ghost' }).waitFor({ timeout: 10_000 })
|
||||
await dialog.getByRole('button', { name: '设为新任务默认: ghost' }).waitFor({ timeout: 10_000 })
|
||||
|
||||
// Leave the roster as the earlier tests shaped it.
|
||||
await dialog.getByRole('button', { name: '删除: ghost' }).click()
|
||||
@@ -248,7 +286,7 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
|
||||
const dialog = settingsDialog()
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
await dialog.getByRole('button', { name: 'Agent 预设' }).click()
|
||||
await dialog.getByRole('button', { name: '用「创造模式」创作自定义预设' }).click()
|
||||
await dialog.getByRole('button', { name: '让 Agent 帮我创建预设模式' }).click()
|
||||
|
||||
// Leaving settings is part of the gesture: the flow lands on the
|
||||
// new-session screen with the self-referential preset staged, and the
|
||||
|
||||
@@ -265,7 +265,7 @@ describe('web e2e: agent-preset selection', () => {
|
||||
await page.getByRole('button', { name: 'Settings', exact: true }).click()
|
||||
const dialog = page.getByRole('dialog', { name: 'Settings' })
|
||||
await dialog.getByRole('button', { name: 'Agent presets' }).click()
|
||||
const toggle = dialog.getByRole('switch', { name: 'Allow switching Agent modes' })
|
||||
const toggle = dialog.getByRole('switch', { name: 'Choose a mode for new tasks' })
|
||||
await dialog.getByRole('button', { name: 'New task default: Standard mode' }).waitFor({ timeout: 10_000 })
|
||||
expect(await toggle.getAttribute('aria-checked')).toBe('true')
|
||||
await dialog.getByRole('button', { name: 'Close' }).last().click()
|
||||
@@ -365,13 +365,13 @@ describe('web e2e: agent-preset selection', () => {
|
||||
await page.getByRole('button', { name: 'Settings', exact: true }).click()
|
||||
const dialog = page.getByRole('dialog', { name: 'Settings' })
|
||||
await dialog.getByRole('button', { name: 'Agent presets' }).click()
|
||||
await dialog.getByRole('button', { name: 'Set as default: Minimal mode' }).click()
|
||||
await dialog.getByRole('button', { name: 'Set as new task default: Minimal mode' }).click()
|
||||
await dialog.getByRole('button', { name: 'New task default: Minimal mode' }).waitFor({ timeout: 10_000 })
|
||||
await expect.poll(() => livePreset(scaffold), { timeout: 15_000 }).toBe('minimal')
|
||||
const toggle = dialog.getByRole('switch', { name: 'Allow switching Agent modes' })
|
||||
const toggle = dialog.getByRole('switch', { name: 'Choose a mode for new tasks' })
|
||||
await toggle.click()
|
||||
await expect.poll(() => toggle.getAttribute('aria-checked')).toBe('false')
|
||||
await dialog.getByRole('button', { name: 'Default: Standard mode' }).waitFor({ timeout: 10_000 })
|
||||
await dialog.getByRole('button', { name: 'Application default: Standard mode' }).waitFor({ timeout: 10_000 })
|
||||
await dialog.getByRole('button', { name: 'Close' }).last().click()
|
||||
|
||||
await expect.poll(() => page.getByRole('button', { name: / mode$/ }).count()).toBe(0)
|
||||
@@ -382,7 +382,7 @@ describe('web e2e: agent-preset selection', () => {
|
||||
await page.getByRole('button', { name: 'Settings', exact: true }).click()
|
||||
const reopened = page.getByRole('dialog', { name: 'Settings' })
|
||||
await reopened.getByRole('button', { name: 'Agent presets' }).click()
|
||||
const reopenedToggle = reopened.getByRole('switch', { name: 'Allow switching Agent modes' })
|
||||
const reopenedToggle = reopened.getByRole('switch', { name: 'Choose a mode for new tasks' })
|
||||
await reopenedToggle.click()
|
||||
await expect.poll(() => reopenedToggle.getAttribute('aria-checked')).toBe('true')
|
||||
await reopened.getByRole('button', { name: 'New task default: Minimal mode' }).waitFor({ timeout: 10_000 })
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
- dialog "复制预设 · 复制自 极简模式":
|
||||
- heading "复制预设 · 复制自 极简模式" [level=2]
|
||||
- button "关闭"
|
||||
- paragraph: 整个预设会在本机复制一份。标识符将成为目录名,事后无法更改;其余内容之后直接在预设自己的文件里编辑。
|
||||
- paragraph: 复制后会在本机保存一份预设,你可以打开目录修改配置。标识符用作目录名,创建后不能修改。
|
||||
- text: 标识符
|
||||
- textbox "标识符":
|
||||
- /placeholder: my-agent
|
||||
- text: 名称
|
||||
- textbox "名称":
|
||||
- /placeholder: 选择器中显示的名字,缺省用标识符
|
||||
- /placeholder: 在模式列表中显示的名称,不填则使用标识符
|
||||
- alert: 请填写标识符。
|
||||
- button "取消"
|
||||
- button "创建" [disabled]
|
||||
@@ -8,46 +8,59 @@
|
||||
- button "打开配置文件"
|
||||
- button "关闭"
|
||||
- heading "Agent 预设" [level=2]
|
||||
- paragraph: 预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。
|
||||
- text: 允许切换agent模式 beta
|
||||
- paragraph: 开启后,新任务可选择标准、PTC、创造、极简及自定义模式;关闭后统一使用默认模式(默认为标准模式,可自定义)。仅影响新任务。
|
||||
- switch "允许切换agent模式" [checked]
|
||||
- paragraph: 选择 Agent 的工具和工作方式。日常任务用「标准模式」,扩展 DSH 的能力用「创造模式」。
|
||||
- text: 新任务可选择模式 Beta
|
||||
- paragraph: 开启后,可为每个新任务选择模式,并在这里设置默认值。关闭后,新任务使用应用配置的默认预设。已有任务不受影响。
|
||||
- switch "新任务可选择模式" [checked]
|
||||
- heading "内置" [level=3]
|
||||
- list:
|
||||
- listitem:
|
||||
- 'button "新任务默认: 标准模式" [disabled] [pressed]':
|
||||
- text: 标准模式 内置 新任务默认 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。
|
||||
- text: 标准模式 新任务默认
|
||||
- code: standard
|
||||
- 'button "查看: 标准模式"': 查看
|
||||
- 'button "复制: 标准模式"': 复制
|
||||
- text: 处理代码、文件和资料,适合大多数任务。Agent 会按需使用检索、编辑和终端等工具。
|
||||
- 'button "模式说明: 标准模式"': 模式说明
|
||||
- 'button "如何使用: 标准模式"': 如何使用
|
||||
- 'button "查看配置: 标准模式"': 查看配置
|
||||
- 'button "复制预设: 标准模式"': 复制预设
|
||||
- listitem:
|
||||
- 'button "设为默认: PTC 模式"':
|
||||
- text: PTC 模式 内置 功能完整的编码 Agent,但默认不提供 workflow 工具;其他工具通过 PTC 模式 SDK 呈现,让模型用一个 TypeScript 程序组合多步操作。
|
||||
- 'button "设为新任务默认: PTC 模式"':
|
||||
- text: PTC 模式 内置
|
||||
- code: ptc
|
||||
- 'button "查看: PTC 模式"': 查看
|
||||
- 'button "复制: PTC 模式"': 复制
|
||||
- text: 包含标准模式的所有能力,让 Agent 用代码串联多个工具,完成批量处理、筛选和汇总。你只需描述任务。
|
||||
- 'button "模式说明: PTC 模式"': 模式说明
|
||||
- 'button "如何使用: PTC 模式"': 如何使用
|
||||
- 'button "查看配置: PTC 模式"': 查看配置
|
||||
- 'button "复制预设: PTC 模式"': 复制预设
|
||||
- listitem:
|
||||
- 'button "设为默认: 极简模式"':
|
||||
- text: 极简模式 内置 仅提供持久 shell 的单工具编码 Agent。
|
||||
- 'button "设为新任务默认: 极简模式"':
|
||||
- text: 极简模式 内置
|
||||
- code: minimal
|
||||
- 'button "查看: 极简模式"': 查看
|
||||
- 'button "复制: 极简模式"': 复制
|
||||
- text: Agent 仅使用终端工具完成任务,适合测试和对比其基础表现。
|
||||
- 'button "模式说明: 极简模式"': 模式说明
|
||||
- 'button "如何使用: 极简模式"': 如何使用
|
||||
- 'button "查看配置: 极简模式"': 查看配置
|
||||
- 'button "复制预设: 极简模式"': 复制预设
|
||||
- listitem:
|
||||
- 'button "设为默认: 创造模式"':
|
||||
- text: 创造模式 内置 用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、持久化插件管理和 preset 创作指导。
|
||||
- 'button "设为新任务默认: 创造模式"':
|
||||
- text: 创造模式 内置
|
||||
- code: cordis
|
||||
- 'button "查看: 创造模式"': 查看
|
||||
- 'button "复制: 创造模式"': 复制
|
||||
- text: 用对话定制 DSH:让 Agent 编写插件,添加新功能或界面;也能组合工具和提示词,创建自己的模式。
|
||||
- 'button "模式说明: 创造模式"': 模式说明
|
||||
- 'button "如何使用: 创造模式"': 如何使用
|
||||
- 'button "查看配置: 创造模式"': 查看配置
|
||||
- 'button "复制预设: 创造模式"': 复制预设
|
||||
- heading "自定义" [level=3]
|
||||
- list:
|
||||
- listitem:
|
||||
- 'button "设为默认: 我的模式"':
|
||||
- text: 我的模式 自定义 仅提供持久 shell 的单工具编码 Agent。
|
||||
- 'button "设为新任务默认: 我的模式"':
|
||||
- text: 我的模式 自定义
|
||||
- code: my-agent
|
||||
- text: 仅提供持久 shell 的单工具编码 Agent。
|
||||
- 'button "查看路径: 我的模式"': 查看路径
|
||||
- 'button "复制: 我的模式"': 复制
|
||||
- 'button "复制预设: 我的模式"': 复制预设
|
||||
- 'button "删除: 我的模式"': 删除
|
||||
- paragraph:
|
||||
- text: 预设文件:
|
||||
- code: {{presetRoot}}/my-agent
|
||||
- button "用「创造模式」创作自定义预设"
|
||||
- button "让 Agent 帮我创建预设模式"
|
||||
@@ -8,52 +8,66 @@
|
||||
- button "打开配置文件"
|
||||
- button "关闭"
|
||||
- heading "Agent 预设" [level=2]
|
||||
- paragraph: 预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。
|
||||
- text: 允许切换agent模式 beta
|
||||
- paragraph: 开启后,新任务可选择标准、PTC、创造、极简及自定义模式;关闭后统一使用默认模式(默认为标准模式,可自定义)。仅影响新任务。
|
||||
- switch "允许切换agent模式" [checked]
|
||||
- paragraph: 选择 Agent 的工具和工作方式。日常任务用「标准模式」,扩展 DSH 的能力用「创造模式」。
|
||||
- text: 新任务可选择模式 Beta
|
||||
- paragraph: 开启后,可为每个新任务选择模式,并在这里设置默认值。关闭后,新任务使用应用配置的默认预设。已有任务不受影响。
|
||||
- switch "新任务可选择模式" [checked]
|
||||
- heading "内置" [level=3]
|
||||
- list:
|
||||
- listitem:
|
||||
- 'button "新任务默认: 标准模式" [disabled] [pressed]':
|
||||
- text: 标准模式 内置 新任务默认 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。
|
||||
- text: 标准模式 新任务默认
|
||||
- code: standard
|
||||
- 'button "查看: 标准模式"': 查看
|
||||
- 'button "复制: 标准模式"': 复制
|
||||
- text: 处理代码、文件和资料,适合大多数任务。Agent 会按需使用检索、编辑和终端等工具。
|
||||
- 'button "模式说明: 标准模式"': 模式说明
|
||||
- 'button "如何使用: 标准模式"': 如何使用
|
||||
- 'button "查看配置: 标准模式"': 查看配置
|
||||
- 'button "复制预设: 标准模式"': 复制预设
|
||||
- listitem:
|
||||
- 'button "设为默认: PTC 模式"':
|
||||
- text: PTC 模式 内置 功能完整的编码 Agent,但默认不提供 workflow 工具;其他工具通过 PTC 模式 SDK 呈现,让模型用一个 TypeScript 程序组合多步操作。
|
||||
- 'button "设为新任务默认: PTC 模式"':
|
||||
- text: PTC 模式 内置
|
||||
- code: ptc
|
||||
- 'button "查看: PTC 模式"': 查看
|
||||
- 'button "复制: PTC 模式"': 复制
|
||||
- text: 包含标准模式的所有能力,让 Agent 用代码串联多个工具,完成批量处理、筛选和汇总。你只需描述任务。
|
||||
- 'button "模式说明: PTC 模式"': 模式说明
|
||||
- 'button "如何使用: PTC 模式"': 如何使用
|
||||
- 'button "查看配置: PTC 模式"': 查看配置
|
||||
- 'button "复制预设: PTC 模式"': 复制预设
|
||||
- listitem:
|
||||
- 'button "设为默认: 极简模式"':
|
||||
- text: 极简模式 内置 仅提供持久 shell 的单工具编码 Agent。
|
||||
- 'button "设为新任务默认: 极简模式"':
|
||||
- text: 极简模式 内置
|
||||
- code: minimal
|
||||
- 'button "查看: 极简模式"': 查看
|
||||
- 'button "复制: 极简模式"': 复制
|
||||
- text: Agent 仅使用终端工具完成任务,适合测试和对比其基础表现。
|
||||
- 'button "模式说明: 极简模式"': 模式说明
|
||||
- 'button "如何使用: 极简模式"': 如何使用
|
||||
- 'button "查看配置: 极简模式"': 查看配置
|
||||
- 'button "复制预设: 极简模式"': 复制预设
|
||||
- listitem:
|
||||
- 'button "设为默认: 创造模式"':
|
||||
- text: 创造模式 内置 用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、持久化插件管理和 preset 创作指导。
|
||||
- 'button "设为新任务默认: 创造模式"':
|
||||
- text: 创造模式 内置
|
||||
- code: cordis
|
||||
- 'button "查看: 创造模式"': 查看
|
||||
- 'button "复制: 创造模式"': 复制
|
||||
- text: 用对话定制 DSH:让 Agent 编写插件,添加新功能或界面;也能组合工具和提示词,创建自己的模式。
|
||||
- 'button "模式说明: 创造模式"': 模式说明
|
||||
- 'button "如何使用: 创造模式"': 如何使用
|
||||
- 'button "查看配置: 创造模式"': 查看配置
|
||||
- 'button "复制预设: 创造模式"': 复制预设
|
||||
- heading "自定义" [level=3]
|
||||
- list:
|
||||
- listitem:
|
||||
- 'button "加载失败: broken-yaml" [disabled]':
|
||||
- text: broken-yaml 加载失败 自定义 暂无描述。
|
||||
- alert: "the composition is not valid YAML: unexpected end of the stream within a flow collection (3:1)"
|
||||
- text: broken-yaml 加载失败 自定义
|
||||
- code: broken-yaml
|
||||
- text: 暂无描述。
|
||||
- alert: "the composition is not valid YAML: unexpected end of the stream within a flow collection (3:1)"
|
||||
- 'button "查看路径: broken-yaml"': 查看路径
|
||||
- 'button "复制: broken-yaml" [disabled]': 预设加载失败,不能复制
|
||||
- 'button "复制预设: broken-yaml" [disabled]': 预设加载失败,不能复制
|
||||
- 'button "删除: broken-yaml"': 删除
|
||||
- listitem:
|
||||
- 'button "加载失败: 幽灵预设" [disabled]':
|
||||
- text: 幽灵预设 加载失败 自定义 composition 已被手动删除。
|
||||
- alert: the composition file agent.cordis.yml is missing — the directory still occupies the id; delete it or restore the file
|
||||
- text: 幽灵预设 加载失败 自定义
|
||||
- code: ghost
|
||||
- text: composition 已被手动删除。
|
||||
- alert: the composition file agent.cordis.yml is missing — the directory still occupies the id; delete it or restore the file
|
||||
- 'button "查看路径: 幽灵预设"': 查看路径
|
||||
- 'button "复制: 幽灵预设" [disabled]': 预设加载失败,不能复制
|
||||
- 'button "复制预设: 幽灵预设" [disabled]': 预设加载失败,不能复制
|
||||
- 'button "删除: 幽灵预设"': 删除
|
||||
- button "用「创造模式」创作自定义预设"
|
||||
- button "让 Agent 帮我创建预设模式"
|
||||
@@ -8,35 +8,47 @@
|
||||
- button "打开配置文件"
|
||||
- button "关闭"
|
||||
- heading "Agent 预设" [level=2]
|
||||
- paragraph: 预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。
|
||||
- text: 允许切换agent模式 beta
|
||||
- paragraph: 开启后,新任务可选择标准、PTC、创造、极简及自定义模式;关闭后统一使用默认模式(默认为标准模式,可自定义)。仅影响新任务。
|
||||
- switch "允许切换agent模式" [checked]
|
||||
- paragraph: 选择 Agent 的工具和工作方式。日常任务用「标准模式」,扩展 DSH 的能力用「创造模式」。
|
||||
- text: 新任务可选择模式 Beta
|
||||
- paragraph: 开启后,可为每个新任务选择模式,并在这里设置默认值。关闭后,新任务使用应用配置的默认预设。已有任务不受影响。
|
||||
- switch "新任务可选择模式" [checked]
|
||||
- heading "内置" [level=3]
|
||||
- list:
|
||||
- listitem:
|
||||
- 'button "新任务默认: 标准模式" [disabled] [pressed]':
|
||||
- text: 标准模式 内置 新任务默认 功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。
|
||||
- text: 标准模式 新任务默认
|
||||
- code: standard
|
||||
- 'button "查看: 标准模式"': 查看
|
||||
- 'button "复制: 标准模式"': 复制
|
||||
- text: 处理代码、文件和资料,适合大多数任务。Agent 会按需使用检索、编辑和终端等工具。
|
||||
- 'button "模式说明: 标准模式"': 模式说明
|
||||
- 'button "如何使用: 标准模式"': 如何使用
|
||||
- 'button "查看配置: 标准模式"': 查看配置
|
||||
- 'button "复制预设: 标准模式"': 复制预设
|
||||
- listitem:
|
||||
- 'button "设为默认: PTC 模式"':
|
||||
- text: PTC 模式 内置 功能完整的编码 Agent,但默认不提供 workflow 工具;其他工具通过 PTC 模式 SDK 呈现,让模型用一个 TypeScript 程序组合多步操作。
|
||||
- 'button "设为新任务默认: PTC 模式"':
|
||||
- text: PTC 模式 内置
|
||||
- code: ptc
|
||||
- 'button "查看: PTC 模式"': 查看
|
||||
- 'button "复制: PTC 模式"': 复制
|
||||
- text: 包含标准模式的所有能力,让 Agent 用代码串联多个工具,完成批量处理、筛选和汇总。你只需描述任务。
|
||||
- 'button "模式说明: PTC 模式"': 模式说明
|
||||
- 'button "如何使用: PTC 模式"': 如何使用
|
||||
- 'button "查看配置: PTC 模式"': 查看配置
|
||||
- 'button "复制预设: PTC 模式"': 复制预设
|
||||
- listitem:
|
||||
- 'button "设为默认: 极简模式"':
|
||||
- text: 极简模式 内置 仅提供持久 shell 的单工具编码 Agent。
|
||||
- 'button "设为新任务默认: 极简模式"':
|
||||
- text: 极简模式 内置
|
||||
- code: minimal
|
||||
- 'button "查看: 极简模式"': 查看
|
||||
- 'button "复制: 极简模式"': 复制
|
||||
- text: Agent 仅使用终端工具完成任务,适合测试和对比其基础表现。
|
||||
- 'button "模式说明: 极简模式"': 模式说明
|
||||
- 'button "如何使用: 极简模式"': 如何使用
|
||||
- 'button "查看配置: 极简模式"': 查看配置
|
||||
- 'button "复制预设: 极简模式"': 复制预设
|
||||
- listitem:
|
||||
- 'button "设为默认: 创造模式"':
|
||||
- text: 创造模式 内置 用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、持久化插件管理和 preset 创作指导。
|
||||
- 'button "设为新任务默认: 创造模式"':
|
||||
- text: 创造模式 内置
|
||||
- code: cordis
|
||||
- 'button "查看: 创造模式"': 查看
|
||||
- 'button "复制: 创造模式"': 复制
|
||||
- text: 用对话定制 DSH:让 Agent 编写插件,添加新功能或界面;也能组合工具和提示词,创建自己的模式。
|
||||
- 'button "模式说明: 创造模式"': 模式说明
|
||||
- 'button "如何使用: 创造模式"': 如何使用
|
||||
- 'button "查看配置: 创造模式"': 查看配置
|
||||
- 'button "复制预设: 创造模式"': 复制预设
|
||||
- heading "自定义" [level=3]
|
||||
- button "用「创造模式」创作自定义预设"
|
||||
- button "让 Agent 帮我创建预设模式"
|
||||
@@ -1,6 +1,6 @@
|
||||
- menu:
|
||||
- menuitem "Standard mode Full coding agent with file editing, shell, file and web search, skills, planning, goals, subagents, and workflows."
|
||||
- menuitem "PTC mode Full coding agent without the workflow tool; other tools are exposed through the PTC mode SDK so the model can combine multi-step operations in one TypeScript program."
|
||||
- menuitem "Minimal mode Single-tool coding agent with a persistent shell."
|
||||
- menuitem "Creator mode Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, persistent plugin management, and preset-authoring guidance."
|
||||
- menuitem "Standard mode Work with code, files, and information. Suitable for most tasks, with search, editing, terminal commands, and other tools available as needed."
|
||||
- menuitem "PTC mode Includes all Standard mode capabilities, with the agent connecting tools through code for batch processing, filtering, and summaries. Just describe your task."
|
||||
- menuitem "Minimal mode The agent works using only a terminal tool. Useful for testing and comparing its basic performance."
|
||||
- menuitem "Creator mode Customize DSH through conversation. Let the agent write plugins that add features or UI, or combine tools and prompts to create your own mode."
|
||||
- menuitem "Refusing mode Resolves, then refuses to start."
|
||||
@@ -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: 5f2c9acaa5840e71d6ca237c5fc5eccb52724cda
|
||||
README.zh.md: 5c61823c25f145c0e516f3f2a16c2de05c250ef1
|
||||
README.md: e1fb0bff3c35255c610347a8d50c7ff642cad918
|
||||
README.zh.md: 9b462d42f315c8dc9b78949de28df43cc0e5bad0
|
||||
@@ -33,6 +33,8 @@ Mount this plugin alongside the settings and conversation packages; once Develop
|
||||
|
||||
The settings section shows the roster as cards: a copy dialog is the only way a preset is created — the browser edits no composition text — and every custom card keeps a location action that opens the preset's own files. The visibility switch changes only whether the saved user default is active: the Host uses the deployment default while hidden and restores the saved default when the picker is shown again. While the picker is enabled, choosing a healthy non-default card writes a new user default for later sessions; if the current new-task surface already reuses a blank session, that explicit Settings action carries the same preset to that exact blank session through the existing selection path. Started and historical sessions remain unchanged. The switch is disabled while saving, and a failed write keeps the prior preference and shows an error. Hiding the picker disables default selection and the Creator launch but leaves roster viewing, copying, location, and deletion available. Deleting removes the preset directory while sessions already composed from it keep running. A shipped preset opens in a read-only viewer and offers no location or delete. A roster row carrying `broken` renders as a marked card whose body and duplication are disabled, because a copy of a broken preset is another broken preset; broken custom rows keep their location and delete actions so the files can be fixed and ghost directories cleared. The card face still shows the preset's own description — a chooser cannot act on a package specifier there — and the host's reason rides the badge as a tooltip, plus a visually hidden alert that carries it to assistive technology, which a disabled card body cannot.
|
||||
|
||||
Known shipped presets offer mode details and usage examples in a read-only dialog. Its tabs preserve each page's scroll position; closing returns focus to the opening action. Help does not change the new-task default. The default badge replaces the card's source badge, and the preset id appears beside the title. Guide copy and examples belong to this package; the tab control comes from `ui-primitives`.
|
||||
|
||||
### The conversational entry
|
||||
|
||||
When the roster carries the self-referential `cordis` preset, its dashed add-card stays disabled until the picker is enabled. It then stages `cordis` and starts a new session — the section closes the settings panel and the new-session chip's own applier composes the blank session the workspace flow produces.
|
||||
|
||||
@@ -33,6 +33,8 @@ kind: "package-reference"
|
||||
|
||||
设置分区把名单呈现为卡片:复制对话框是创建 preset 的唯一入口——浏览器不编辑任何组装文本——每张自定义卡片都保留一个打开 preset 自身文件的位置动作。可见性开关只决定已保存的用户默认值是否生效:宿主在隐藏期间使用部署默认值,再次显示选择器时恢复已保存的默认值。选择器开启期间,选择健康且非默认的卡片会为后续会话写入新的用户默认值;如果当前新任务页已经复用一个空白会话,这次在设置中的明确选择也会通过既有选择链路把同一 preset 带到这个精确的空白会话。已开始及历史会话保持不变。保存期间开关会被禁用;写入失败时,界面保留先前的偏好并显示错误。隐藏选择器会禁用默认值选择与 Creator 启动,但名单查看、复制、位置和删除仍然可用。删除会移除 preset 目录,而已据其组装的会话继续运行。随附 preset 在只读查看器中打开,不提供位置或删除。名单行携带 `broken` 时渲染为标记卡片,其主体与复制均被禁用,因为损坏 preset 的副本只是另一个损坏 preset;损坏的自定义行保留位置与删除动作,以便修复文件、清掉幽灵目录。卡片正面仍显示 preset 自己的描述——在选择器里,一个包说明符不足以让人采取行动——宿主给出的原因作为工具提示附在徽标上,另有一个视觉隐藏的 alert 将该原因传达给辅助技术,而被禁用的卡片主体无法做到这一点。
|
||||
|
||||
已知的内置预设提供模式说明与使用示例,通过只读弹窗展示。标签切换保留各页的滚动位置,关闭后焦点回到打开入口。查看帮助不会改变新任务默认模式。默认标签替换卡片的来源标签,预设标识显示在标题旁。说明文案和案例由本包维护,标签切换控件来自 `ui-primitives`。
|
||||
|
||||
### 对话式入口
|
||||
|
||||
名单携带自指的 `cordis` preset 时,其虚线添加卡在选择器开启前保持禁用。开启后,它会暂存 `cordis` 并启动新会话——分区关闭设置面板,新建会话 chip 自己的应用器负责组装工作区流程产出的空白会话。
|
||||
|
||||
@@ -84,9 +84,7 @@
|
||||
padding: 0;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(268px, 1fr));
|
||||
/* Every row the same height, so a short description does not make its card
|
||||
shorter than the one beside it. */
|
||||
grid-auto-rows: 1fr;
|
||||
/* Match neighboring cards within a row, without stretching shorter rows. */
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
@@ -214,7 +212,7 @@
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: 12px;
|
||||
padding: 14px 16px 12px;
|
||||
border-radius: 12px 12px 0 0;
|
||||
}
|
||||
@@ -230,38 +228,45 @@
|
||||
|
||||
.cardHead {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
align-items: flex-start;
|
||||
gap: 12px;
|
||||
/* Anchors the broken badge's tooltip: the badge itself stays unpositioned so
|
||||
its `::after` resolves against the card's own width instead of against a
|
||||
the tooltip resolves against the card's own width instead of against a
|
||||
badge that sits partway across it. A tooltip grown from the badge would
|
||||
run past the card, and past the section for a card in the last column. */
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.cardIdentity {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.cardName {
|
||||
min-width: 0;
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* Placement only: the capsule geometry and palette come from `Tag`. */
|
||||
.inUse {
|
||||
margin-left: auto;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Bounded to four lines. A preset publishes its own description, so one long
|
||||
one would otherwise stretch every card in its grid row (`.cards` sizes rows
|
||||
1fr). Clamping is CSS alone: the whole text stays in the DOM for assistive
|
||||
one would otherwise stretch every card in its grid row. Clamping is CSS
|
||||
alone: the whole text stays in the DOM for assistive
|
||||
tech, and the card offers it on hover when it is actually cut off. The
|
||||
description does not grow to fill the card — `-webkit-line-clamp` on a
|
||||
flex-stretched box leaves the clamp height and the box height disagreeing,
|
||||
so `.cardId` takes the free space with an auto margin instead. */
|
||||
so auto margins center the text between the header and footer instead. */
|
||||
.cardDesc {
|
||||
margin-block: auto;
|
||||
font-size: 13px;
|
||||
line-height: 1.55;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
min-height: 42px;
|
||||
display: -webkit-box;
|
||||
-webkit-box-orient: vertical;
|
||||
-webkit-line-clamp: 4;
|
||||
@@ -271,20 +276,47 @@
|
||||
}
|
||||
|
||||
.cardId {
|
||||
margin-top: auto;
|
||||
flex-shrink: 0;
|
||||
max-width: 35%;
|
||||
font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||||
font-size: 11px;
|
||||
line-height: 21px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cardFoot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 2px;
|
||||
padding: 6px 10px;
|
||||
border-top: 0.5px solid var(--dsw-alias-border-l2);
|
||||
}
|
||||
|
||||
.cardHelp {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
margin-right: auto;
|
||||
}
|
||||
|
||||
.helpButton {
|
||||
padding: 5px 6px;
|
||||
min-height: 28px;
|
||||
font-size: 12px;
|
||||
font-weight: 400;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.helpButton:hover,
|
||||
.helpButton:focus-visible {
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* Icon-only actions: the label rides `title` so the row stays quiet until
|
||||
someone reaches for it. */
|
||||
.iconButton {
|
||||
|
||||
@@ -20,6 +20,7 @@ import type { ObservableSnapshot, SnapshotStore } from '@deepseek-ai/dsh-client-
|
||||
import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import { draftBlocker, type AgentPresetSectionState } from './section-store.ts'
|
||||
import { presetDisplayText, type AgentPresetSettingsKey } from './locales.ts'
|
||||
import { PresetGuideDialog, presetGuide, type PresetGuidePage } from './PresetGuideDialog.tsx'
|
||||
import css from './AgentPresetSection.module.css'
|
||||
|
||||
/** Registration-side business face for the management section. */
|
||||
@@ -184,6 +185,10 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
|
||||
const { useAgentPresetSection, t, load } = props
|
||||
const state = useAgentPresetSection(snapshot => snapshot)
|
||||
const developerTools = props.useDeveloperTools(enabled => enabled)
|
||||
const [guide, setGuide] = useState<{
|
||||
content: NonNullable<ReturnType<typeof presetGuide>>
|
||||
page: PresetGuidePage
|
||||
} | null>(null)
|
||||
const viewedId = state.view?.id
|
||||
const viewedRow = viewedId === undefined ? undefined : state.rows.find(row => row.id === viewedId)
|
||||
const viewedTitle = state.view === null
|
||||
@@ -263,6 +268,7 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
|
||||
.map(row => ({
|
||||
row,
|
||||
text: presetDisplayText(row, t),
|
||||
help: presetGuide(row.id, row.trust),
|
||||
selectionAction: row.broken !== undefined
|
||||
? t('brokenBadge')
|
||||
: row.isDefault
|
||||
@@ -278,7 +284,7 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
|
||||
<h3 className={css.groupHead}>{heading}</h3>
|
||||
{group.length === 0 ? null : (
|
||||
<ul className={css.cards}>
|
||||
{group.map(({ row, text, selectionAction }) => (
|
||||
{group.map(({ row, text, selectionAction, help }) => (
|
||||
<li
|
||||
key={row.id}
|
||||
className={[
|
||||
@@ -322,29 +328,27 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
|
||||
}}
|
||||
>
|
||||
<span className={css.cardHead}>
|
||||
<span className={css.cardName}>{text.name}</span>
|
||||
{row.broken !== undefined
|
||||
? (
|
||||
<span className={css.brokenBadge}>
|
||||
{t('brokenBadge')}
|
||||
{/* Pointer-only, hence `aria-hidden`: the same
|
||||
reason reaches assistive technology through the
|
||||
alert below, and a second copy inside the card's
|
||||
own text would be read out twice. */}
|
||||
<span className={css.brokenTip} aria-hidden="true">{row.broken}</span>
|
||||
</span>
|
||||
)
|
||||
: null}
|
||||
<Tag>
|
||||
{row.trust === 'user' ? t('userTrust') : t('builtIn')}
|
||||
</Tag>
|
||||
{row.isDefault
|
||||
? (
|
||||
<Tag tone="solid" className={css.inUse}>
|
||||
{state.showPicker ? t('inUse') : t('selectionOffDefault')}
|
||||
</Tag>
|
||||
)
|
||||
: null}
|
||||
<span className={css.cardIdentity}>
|
||||
<span className={css.cardName} title={text.name}>{text.name}</span>
|
||||
{row.broken !== undefined
|
||||
? (
|
||||
<span className={css.brokenBadge}>
|
||||
{t('brokenBadge')}
|
||||
{/* Pointer-only, hence `aria-hidden`: the same
|
||||
reason reaches assistive technology through the
|
||||
alert below, and a second copy inside the card's
|
||||
own text would be read out twice. */}
|
||||
<span className={css.brokenTip} aria-hidden="true">{row.broken}</span>
|
||||
</span>
|
||||
)
|
||||
: null}
|
||||
<Tag tone={row.isDefault ? 'solid' : 'outline'}>
|
||||
{row.isDefault
|
||||
? state.showPicker ? t('inUse') : t('selectionOffDefault')
|
||||
: row.trust === 'user' ? t('userTrust') : t('builtIn')}
|
||||
</Tag>
|
||||
</span>
|
||||
<code className={css.cardId} title={row.id}>{row.id}</code>
|
||||
</span>
|
||||
<CardDescription text={text.description ?? t('noDescription')} />
|
||||
{/* Visually hidden, deliberately: the pointer path is the
|
||||
@@ -354,9 +358,28 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
|
||||
{row.broken === undefined
|
||||
? null
|
||||
: <span className={css.cardBrokenReason} role="alert">{row.broken}</span>}
|
||||
<code className={css.cardId}>{row.id}</code>
|
||||
</button>
|
||||
<div className={css.cardFoot}>
|
||||
{help === undefined ? null : (
|
||||
<div className={css.cardHelp}>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={css.helpButton}
|
||||
aria-label={`${t('modeExplanation')}: ${text.name}`}
|
||||
onClick={() => { setGuide({ content: help, page: 'explanation' }) }}
|
||||
>
|
||||
{t('modeExplanation')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className={css.helpButton}
|
||||
aria-label={`${t('howToUse')}: ${text.name}`}
|
||||
onClick={() => { setGuide({ content: help, page: 'usage' }) }}
|
||||
>
|
||||
{t('howToUse')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{/* Shipped presets are the compositions a copy starts
|
||||
from, so READING one is the point; a custom preset is
|
||||
edited in its files instead, which the location action
|
||||
@@ -431,6 +454,14 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
{guide === null ? null : (
|
||||
<PresetGuideDialog
|
||||
guide={guide.content}
|
||||
initialPage={guide.page}
|
||||
t={t}
|
||||
onClose={() => { setGuide(null) }}
|
||||
/>
|
||||
)}
|
||||
<CopyDialog
|
||||
state={state}
|
||||
t={t}
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
.guideDialog {
|
||||
box-sizing: border-box;
|
||||
width: min(640px, 100%);
|
||||
height: min(600px, 100%);
|
||||
max-height: 100%;
|
||||
padding: 0;
|
||||
gap: 0;
|
||||
}
|
||||
|
||||
.guideLayout {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.guideHeader {
|
||||
flex: none;
|
||||
padding: 24px 24px 20px 28px;
|
||||
}
|
||||
|
||||
.guideTitleRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.guideTitle {
|
||||
margin: 0;
|
||||
font-size: 20px;
|
||||
line-height: 28px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.guideIntro {
|
||||
margin: 8px 0 0;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.guideClose {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
min-height: 32px;
|
||||
padding: 0;
|
||||
border-radius: 10px;
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.guideTabs {
|
||||
flex: none;
|
||||
margin: 0 28px 22px;
|
||||
}
|
||||
|
||||
.guidePanel {
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
--dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2);
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-gutter: stable;
|
||||
padding: 0 24px 24px 28px;
|
||||
font-size: 16px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.guidePanel:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-brand-primary);
|
||||
outline-offset: -2px;
|
||||
}
|
||||
|
||||
.guidePanel h3 {
|
||||
margin: 28px 0 10px;
|
||||
font-size: 16px;
|
||||
line-height: 24px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.guidePanel h3:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
.guidePanel p {
|
||||
margin: 0 0 16px;
|
||||
font-size: 16px;
|
||||
line-height: 1.625;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.guidePanel blockquote {
|
||||
margin: 10px 0 14px;
|
||||
padding: 14px 16px;
|
||||
border: 0;
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-alias-bg-module-platform);
|
||||
}
|
||||
|
||||
.guidePanel blockquote p {
|
||||
margin: 0;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* Usage pages emphasize the example input, with expected results as supporting copy. */
|
||||
.guidePanel[data-guide-page='usage'] p {
|
||||
margin-bottom: 12px;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
}
|
||||
|
||||
.guidePanel[data-guide-page='usage'] h3 {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.guideExample + .guideExample {
|
||||
margin-top: 28px;
|
||||
}
|
||||
|
||||
.guideExampleHeader {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px 10px;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.guideExampleTag {
|
||||
flex-shrink: 0;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.guidePanel[data-guide-page='usage'] blockquote {
|
||||
margin: 8px 0;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
|
||||
.guidePanel[data-guide-page='usage'] blockquote p {
|
||||
margin: 0;
|
||||
font-size: 16px;
|
||||
line-height: 26px;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
/** Read-only help stays local to Settings and never changes the selected preset. */
|
||||
import { useId, useLayoutEffect, useRef, useState } from 'react'
|
||||
import type { KeyboardEvent, ReactNode } from 'react'
|
||||
import { Button, IconCloseOutlineRegular, IconListPenOutlineRegular, MarkdownText, Modal, SegmentedTabs, Tag } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { AgentPresetSettingsKey } from './locales.ts'
|
||||
import css from './PresetGuideDialog.module.css'
|
||||
|
||||
export type PresetGuidePage = 'explanation' | 'usage'
|
||||
|
||||
/** Locale references for one known, shipped preset. */
|
||||
interface PresetGuide {
|
||||
name: AgentPresetSettingsKey
|
||||
intro: AgentPresetSettingsKey
|
||||
explanation: AgentPresetSettingsKey
|
||||
usage: AgentPresetSettingsKey
|
||||
}
|
||||
|
||||
const guides = new Map<string, PresetGuide>([
|
||||
['standard', { name: 'presetStandardName', intro: 'guideStandardIntro', explanation: 'guideStandardExplanation', usage: 'guideStandardUsage' }],
|
||||
['ptc', { name: 'presetPtcName', intro: 'guidePtcIntro', explanation: 'guidePtcExplanation', usage: 'guidePtcUsage' }],
|
||||
['minimal', { name: 'presetMinimalName', intro: 'guideMinimalIntro', explanation: 'guideMinimalExplanation', usage: 'guideMinimalUsage' }],
|
||||
['cordis', { name: 'presetCordisName', intro: 'guideCordisIntro', explanation: 'guideCordisExplanation', usage: 'guideCordisUsage' }],
|
||||
])
|
||||
|
||||
/**
|
||||
* Look up help only for known, shipped presets.
|
||||
* @param id - preset identifier from the roster.
|
||||
* @param trust - roster source; custom presets own their capability claims.
|
||||
* @returns the shipped guide, or undefined for unknown and custom presets.
|
||||
*/
|
||||
export function presetGuide(id: string, trust: string): PresetGuide | undefined {
|
||||
return trust === 'system' ? guides.get(id) : undefined
|
||||
}
|
||||
|
||||
/** Curated usage dictionaries contain only level-three example sections. */
|
||||
function GuideUsage({ text, t }: {
|
||||
text: string
|
||||
t: (key: AgentPresetSettingsKey) => string
|
||||
}): ReactNode {
|
||||
const labels = {
|
||||
code: { copyLabel: t('guideCopy'), copiedLabel: t('guideCopied') },
|
||||
footnotes: t('guideFootnotes'),
|
||||
}
|
||||
return text.split(/(?=^### )/m).map((section) => {
|
||||
const headingEnd = section.indexOf('\n')
|
||||
const title = section.slice(4, headingEnd)
|
||||
return (
|
||||
<section key={title} className={css.guideExample}>
|
||||
<div className={css.guideExampleHeader}>
|
||||
<h3>{title}</h3>
|
||||
<Tag tone="neutral" className={css.guideExampleTag}>
|
||||
<IconListPenOutlineRegular size={12} />
|
||||
{t('guideExampleTask')}
|
||||
</Tag>
|
||||
</div>
|
||||
<MarkdownText text={section.slice(headingEnd + 1)} labels={labels} />
|
||||
</section>
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Open read-only help without changing the selected preset.
|
||||
* @param props - localized guide, initial page, and close callback.
|
||||
* @returns the modal reader with independent scroll positions for each page.
|
||||
*/
|
||||
export function PresetGuideDialog({ guide, initialPage, t, onClose }: {
|
||||
guide: PresetGuide
|
||||
initialPage: PresetGuidePage
|
||||
t: (key: AgentPresetSettingsKey) => string
|
||||
onClose: () => void
|
||||
}): ReactNode {
|
||||
const [page, setPage] = useState(initialPage)
|
||||
const guideId = useId()
|
||||
const content = useRef<HTMLDivElement>(null)
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const previous = document.activeElement
|
||||
content.current?.querySelector<HTMLButtonElement>('[role="tab"][aria-selected="true"]')?.focus()
|
||||
return () => {
|
||||
if (previous instanceof HTMLElement) previous.focus()
|
||||
}
|
||||
}, [])
|
||||
|
||||
// This reader sits above Settings. Keep keyboard navigation in the reader,
|
||||
// and let Escape dismiss only this layer before returning to its trigger.
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
|
||||
if (event.key === 'Escape') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
onClose()
|
||||
} else if (event.key === 'Tab') {
|
||||
const targets = Array.from(event.currentTarget.querySelectorAll<HTMLElement>(
|
||||
'button:not([disabled]):not([tabindex="-1"]), [tabindex="0"]',
|
||||
)).filter(element => !element.closest('[hidden]'))
|
||||
const first = targets[0]
|
||||
const last = targets[targets.length - 1]
|
||||
if (event.shiftKey && document.activeElement === first) {
|
||||
event.preventDefault()
|
||||
last?.focus()
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault()
|
||||
first?.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal open headless onClose={onClose} title={t(guide.name)} className={css.guideDialog as string}>
|
||||
<div ref={content} className={css.guideLayout} role="presentation" onKeyDownCapture={onKeyDown}>
|
||||
<div className={css.guideHeader}>
|
||||
<div className={css.guideTitleRow}>
|
||||
<h2 className={css.guideTitle}>{t(guide.name)}</h2>
|
||||
<Button variant="ghost" className={css.guideClose} aria-label={t('close')} onClick={onClose}>
|
||||
<IconCloseOutlineRegular size={18} />
|
||||
</Button>
|
||||
</div>
|
||||
<p className={css.guideIntro}>{t(guide.intro)}</p>
|
||||
</div>
|
||||
<SegmentedTabs<PresetGuidePage>
|
||||
className={css.guideTabs}
|
||||
label={t('guideSections')}
|
||||
value={page}
|
||||
onChange={setPage}
|
||||
items={[
|
||||
{ value: 'explanation', label: t('modeExplanation'), id: `${guideId}-explanation-tab`, panelId: `${guideId}-explanation-panel` },
|
||||
{ value: 'usage', label: t('howToUse'), id: `${guideId}-usage-tab`, panelId: `${guideId}-usage-panel` },
|
||||
]}
|
||||
/>
|
||||
{(['explanation', 'usage'] as const).map(section => (
|
||||
<div
|
||||
key={section}
|
||||
id={`${guideId}-${section}-panel`}
|
||||
role="tabpanel"
|
||||
aria-labelledby={`${guideId}-${section}-tab`}
|
||||
className={css.guidePanel}
|
||||
data-guide-page={section}
|
||||
hidden={page !== section}
|
||||
tabIndex={0}
|
||||
>
|
||||
{section === 'usage'
|
||||
? <GuideUsage text={t(guide.usage)} t={t} />
|
||||
: (
|
||||
<MarkdownText
|
||||
text={t(guide.explanation)}
|
||||
labels={{
|
||||
code: { copyLabel: t('guideCopy'), copiedLabel: t('guideCopied') },
|
||||
footnotes: t('guideFootnotes'),
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
/** Curated help for shipped presets, kept separate from their short picker copy. */
|
||||
export const guideEn = {
|
||||
modeExplanation: 'Mode details',
|
||||
howToUse: 'How to use',
|
||||
guideSections: 'Guide sections',
|
||||
guideExampleTask: 'Example task',
|
||||
guideCopy: 'Copy',
|
||||
guideCopied: 'Copied',
|
||||
guideFootnotes: 'Footnotes',
|
||||
guideStandardIntro: 'Choose Standard mode when starting a new task. Describe what you want to accomplish, point to the relevant files, and explain how to check the result.',
|
||||
guideStandardExplanation: [
|
||||
'### How it works',
|
||||
'The agent calls tools directly to read and edit files, search, and run terminal commands. It includes Skills, planning, goals, subagents, workflows, and context compaction.',
|
||||
'### When to choose it',
|
||||
'Start here for everyday coding, file work, and research. Standard mode can also write scripts and process files in batches. PTC changes how tool calls are organized; it is not required for batch tasks.',
|
||||
].join('\n\n'),
|
||||
guideStandardUsage: [
|
||||
'### Fix a bug',
|
||||
'> Find out why submitting the search form twice makes the results disappear. Fix it and run the relevant tests. Explain the cause and what changed.',
|
||||
'Expected output: a code change, the relevant test results, and an explanation of the cause.',
|
||||
'### Organize project notes',
|
||||
'> Read the Markdown notes in this project. Summarize the agreed decisions and open questions, with links to the source files.',
|
||||
'Expected output: a summary with references that you can check against the original notes.',
|
||||
].join('\n\n'),
|
||||
guidePtcIntro: 'Choose PTC mode when starting a new task. Specify the input files, processing rules, and output format. The agent writes the code.',
|
||||
guidePtcExplanation: [
|
||||
'### How tools are called',
|
||||
'PTC means Programmatic Tool Calling. In this built-in preset, the agent uses run_code to write a TypeScript program that calls tools through a generated SDK. The program can use loops, conditions, error handling, and concurrent calls where appropriate.',
|
||||
'### What reaches the model',
|
||||
'Tool results first reach the program, which can filter and combine them. The model receives what the program prints or returns; image results are attached separately. Nested tool calls are still recorded and remain subject to tool permissions.',
|
||||
'### Compared with Standard mode',
|
||||
'Both modes can handle coding and batch tasks. Standard mode exposes individual tools directly; PTC organizes tool calls in code. The current PTC preset leaves the workflow tool disabled. Speed and token use depend on the task and how the program handles its results.',
|
||||
].join('\n\n'),
|
||||
guidePtcUsage: [
|
||||
'### Check a set of configuration files',
|
||||
'> Check all JSON files in configs/. List missing required fields and invalid values against schema.json. Save a CSV with one row per issue. Include unreadable files in the report and keep checking the rest. Leave the original files unchanged.',
|
||||
'Expected output: an issue summary and a CSV report. The program can repeat the same checks, handle individual failures, and collect the results.',
|
||||
'### Summarize error logs',
|
||||
'> Analyze the log files in logs/. Group errors by service and error type. Show the ten most frequent groups and one example from each. Save the full counts to a CSV.',
|
||||
'Expected output: the top error groups and a complete count table. Intermediate data can be aggregated in the program before the summary reaches the model.',
|
||||
].join('\n\n'),
|
||||
guideMinimalIntro: 'Choose Minimal mode for a new task. For a comparison, hold the model, permissions, input, and starting workspace state constant across runs.',
|
||||
guideMinimalExplanation: [
|
||||
'### What is included',
|
||||
'One persistent shell tool and a fixed system prompt. The built-in preset does not load Skills, planning, context compaction, or the standard runtime context.',
|
||||
'### When to choose it',
|
||||
'Use it as a baseline for experiments and comparisons. It can still read files and execute scripts through shell commands, but offers fewer built-in ways to manage a long task. Fewer tools does not necessarily make it easier for a beginner.',
|
||||
].join('\n\n'),
|
||||
guideMinimalUsage: [
|
||||
'### Compare performance on a small bug fix',
|
||||
'> Run the tests for this project, find the cause of the failure, and make the smallest fix. Run the relevant tests again and report the result.',
|
||||
'Run the same task separately in Standard and Minimal modes from the same starting state. Compare task completion, tool calls, and the resulting changes. Minimal mode performs the work through terminal commands.',
|
||||
].join('\n\n'),
|
||||
guideCordisIntro: 'Choose Creator mode for a new task. Describe the capability you want, where it should appear, and how you will verify it.',
|
||||
guideCordisExplanation: [
|
||||
'### What you can create',
|
||||
'Creator mode includes the standard task tools plus runtime inspection, persistent plugin management, and guidance for authoring Cordis plugins and agent presets. It can create a plugin that adds a capability or UI, or a preset that combines tools and prompts for a particular job.',
|
||||
'### Plugins and modes',
|
||||
'A plugin adds capabilities to DSH, such as a tool, a service connection, or a UI entry. A mode is an agent preset that selects tools and defines how the agent works in a task. A plugin can be included in a custom preset.',
|
||||
'### How the result takes effect',
|
||||
'Ask the agent to install and verify the result, not just generate source code. A plugin may load immediately or require a restart, depending on what it changes. A newly created preset is selected when starting a new task.',
|
||||
].join('\n\n'),
|
||||
guideCordisUsage: [
|
||||
'### Add a UI',
|
||||
'> Create a DSH plugin that adds a project notes entry to the sidebar. Let me browse the Markdown files in this workspace and preview a selected note. Install it and verify that the page opens.',
|
||||
'Expected output: an installed plugin with a working entry and preview page, plus any remaining activation steps.',
|
||||
'### Add a tool',
|
||||
'> Create a plugin with a tool that reads this project’s test report and summarizes the failed tests. Register it and verify it with a sample report.',
|
||||
'Expected output: a plugin with a callable tool and a verified sample call.',
|
||||
'### Create my own mode',
|
||||
'> Create a “Code review” mode based on Standard mode. Have it prioritize potential bugs and test gaps, cite file paths and lines, and ask before modifying files. Save it as a selectable preset.',
|
||||
'Expected output: a custom preset for new tasks. These review instructions guide the agent; permission settings determine which actions it can execute.',
|
||||
].join('\n\n'),
|
||||
}
|
||||
|
||||
/** Keys merged into the feature’s ordinary locale seat. */
|
||||
export type PresetGuideKey = keyof typeof guideEn
|
||||
|
||||
/** Simplified Chinese help. Examples describe suggested tasks, not recorded runs. */
|
||||
export const guideZh: Record<PresetGuideKey, string> = {
|
||||
modeExplanation: '模式说明',
|
||||
howToUse: '如何使用',
|
||||
guideSections: '帮助内容',
|
||||
guideExampleTask: '示例任务',
|
||||
guideCopy: '复制',
|
||||
guideCopied: '已复制',
|
||||
guideFootnotes: '脚注',
|
||||
guideStandardIntro: '新建任务时选择「标准模式」,说明要完成什么、相关文件在哪里,以及怎样判断任务完成。',
|
||||
guideStandardExplanation: [
|
||||
'### 工作方式',
|
||||
'Agent 直接调用工具来读写文件、检索资料和执行终端命令。包含 Skills、计划、目标、子 Agent、工作流和上下文压缩等能力。',
|
||||
'### 什么时候选',
|
||||
'日常编程、文件处理和资料整理可以从这里开始。标准模式也能编写脚本、批量处理文件;PTC 改变的是工具调用方式,批量任务并不必须使用 PTC。',
|
||||
].join('\n\n'),
|
||||
guideStandardUsage: [
|
||||
'### 修复一个问题',
|
||||
'> 搜索表单连续提交两次后,结果会消失。请定位原因、修复问题并运行相关测试,最后说明原因和修改内容。',
|
||||
'预期产出:代码修改、相关测试结果,以及问题原因说明。',
|
||||
'### 整理项目资料',
|
||||
'> 阅读项目中的 Markdown 记录,整理已经达成的结论和仍待确认的问题,并附上对应文件链接。',
|
||||
'预期产出:一份带来源引用的总结,方便回到原文核对。',
|
||||
].join('\n\n'),
|
||||
guidePtcIntro: '新建任务时选择「PTC 模式」,说明输入文件、处理规则和输出格式。代码由 Agent 编写。',
|
||||
guidePtcExplanation: [
|
||||
'### 怎样调用工具',
|
||||
'PTC 是 Programmatic Tool Calling,即通过程序调用工具。当前内置预设让 Agent 通过 run_code 编写 TypeScript 程序,使用生成的工具 SDK 发起调用。程序可以组织循环、条件判断、错误处理,以及适合并发执行的调用。',
|
||||
'### 哪些结果交给模型',
|
||||
'工具返回的数据先交给程序,经过筛选、计算或合并,再通过输出或返回值交给模型;图片结果会另行附加。程序中的工具调用仍会被记录,也仍受工具权限约束。',
|
||||
'### 与标准模式的区别',
|
||||
'两种模式都能编程、批量处理文件。标准模式直接向模型提供各个工具;PTC 让模型用代码组织工具调用。当前 PTC 预设未启用 workflow 工具。速度和 token 用量取决于具体任务与结果处理方式。',
|
||||
].join('\n\n'),
|
||||
guidePtcUsage: [
|
||||
'### 批量检查配置文件',
|
||||
'> 检查 configs/ 下所有 JSON 文件,按照 schema.json 找出缺失字段和不合法的值。每个问题写成 CSV 中的一行;读取失败的文件也记入报告,继续检查其余文件。保留原文件。',
|
||||
'预期产出:问题汇总和一份 CSV 报告。程序可以对多份文件执行相同检查,处理单个文件的失败,再汇总结果。',
|
||||
'### 汇总错误日志',
|
||||
'> 分析 logs/ 下的日志,按服务和错误类型统计次数,列出出现最多的十类错误,每类保留一条示例。完整统计另存为 CSV。',
|
||||
'预期产出:高频错误摘要和完整统计表。中间数据可以先在程序中聚合,再把汇总交给模型。',
|
||||
].join('\n\n'),
|
||||
guideMinimalIntro: '新建任务时选择「极简模式」。做对照测试时,保持模型、权限、任务输入和工作区起始状态一致。',
|
||||
guideMinimalExplanation: [
|
||||
'### 保留哪些能力',
|
||||
'仅提供一个持久 Shell 工具,并使用固定系统提示词。内置预设不加载 Skills、计划、上下文压缩,也不注入标准运行时上下文。',
|
||||
'### 什么时候选',
|
||||
'适合作为实验和对照测试的基线。Agent 仍能通过终端命令读写文件、运行脚本,但缺少管理长任务的内置辅助能力。工具少,不代表对新手更容易。',
|
||||
].join('\n\n'),
|
||||
guideMinimalUsage: [
|
||||
'### 对比基础修复表现',
|
||||
'> 运行这个项目的测试,找出失败原因,做最小修复,再运行相关测试并报告结果。',
|
||||
'分别用标准模式和极简模式,从相同的工作区状态执行这条任务,对比完成情况、工具调用和最终修改。极简模式会通过终端命令完成这些操作。',
|
||||
].join('\n\n'),
|
||||
guideCordisIntro: '新建任务时选择「创造模式」,说明希望增加什么能力、从哪里使用,以及怎样验证效果。',
|
||||
guideCordisExplanation: [
|
||||
'### 可以创造什么',
|
||||
'创造模式具备标准任务工具,并增加运行时检查、持久化插件管理,以及 Cordis 插件和 Agent 预设的开发指引。可以编写插件来添加功能或界面,也可以组合工具和提示词,创建适合特定任务的模式。',
|
||||
'### 插件与模式的关系',
|
||||
'插件为 DSH 增加能力,例如工具、服务连接或界面入口。模式是一份 Agent 预设,用来选择任务可用的工具,并约定 Agent 的工作方式。自定义模式中也可以使用自己开发的插件。',
|
||||
'### 怎样让成果生效',
|
||||
'可以要求 Agent 完成安装并验证实际效果。插件可能即时加载,也可能需要重启,取决于修改内容;新建的模式在创建新任务时选择。',
|
||||
].join('\n\n'),
|
||||
guideCordisUsage: [
|
||||
'### 添加一个界面',
|
||||
'> 帮我写一个 DSH 插件,在侧栏增加「项目笔记」入口,列出当前工作区的 Markdown 文件,点击后能预览内容。完成安装并验证页面能打开。',
|
||||
'预期产出:带侧栏入口和预览页的插件,以及仍需完成的生效步骤。',
|
||||
'### 添加一个工具',
|
||||
'> 写一个插件,提供读取项目测试报告、汇总失败用例的工具。注册工具,并用一份示例报告验证调用结果。',
|
||||
'预期产出:可调用的新工具,以及一次示例调用的验证结果。',
|
||||
'### 创建自己的模式',
|
||||
'> 基于标准模式创建「代码审查」模式,优先检查潜在错误和测试缺口,指出文件与行号,修改文件前先询问我。保存成可选择的预设。',
|
||||
'预期产出:可在新任务中选择的自定义模式。审查要求用于指导 Agent,实际可执行的操作仍由权限设置决定。',
|
||||
].join('\n\n'),
|
||||
}
|
||||
@@ -1,7 +1,10 @@
|
||||
/** Locale bundles for the agent-preset hero chip, header label, and management section. */
|
||||
|
||||
import { guideEn, guideZh, type PresetGuideKey } from './guide-locales.ts'
|
||||
|
||||
/** Locale keys these surfaces render. */
|
||||
export type AgentPresetSettingsKey =
|
||||
| PresetGuideKey
|
||||
| 'error' | 'userTrust' | 'seatHint' | 'headerHint'
|
||||
| 'nav' | 'sectionIntro' | 'builtIn' | 'setDefault' | 'view'
|
||||
| 'presetStandardName' | 'presetStandardDescription'
|
||||
@@ -22,38 +25,39 @@ export type AgentPresetSettingsKey =
|
||||
|
||||
/** English copy. */
|
||||
export const en: Record<AgentPresetSettingsKey, string> = {
|
||||
...guideEn,
|
||||
error: 'Could not load agent presets.',
|
||||
userTrust: 'Custom',
|
||||
seatHint: 'Agent preset for the session you are about to start',
|
||||
headerHint: 'The agent preset this session runs, fixed when it started',
|
||||
seatHint: 'Choose the agent preset for your new task',
|
||||
headerHint: 'The agent preset chosen when this task started',
|
||||
nav: 'Agent presets',
|
||||
sectionIntro:
|
||||
'A preset is the plugin composition one session\'s agent runs — its tools, prompt, and capabilities. '
|
||||
+ 'Duplicate an existing one and make it yours, or let the agent draft one for you in Creator mode.',
|
||||
'Choose the agent\'s tools and how it works. Use Standard mode for everyday tasks, '
|
||||
+ 'or Creator mode to add capabilities to DSH.',
|
||||
builtIn: 'Built-in',
|
||||
setDefault: 'Set as default',
|
||||
view: 'View',
|
||||
setDefault: 'Set as new task default',
|
||||
view: 'View configuration',
|
||||
presetStandardName: 'Standard mode',
|
||||
presetStandardDescription:
|
||||
'Full coding agent with file editing, shell, file and web search, skills, planning, goals, subagents, and workflows.',
|
||||
'Work with code, files, and information. Suitable for most tasks, with search, editing, terminal commands, and other tools available as needed.',
|
||||
presetPtcName: 'PTC mode',
|
||||
presetPtcDescription:
|
||||
'Full coding agent without the workflow tool; other tools are exposed through the PTC mode SDK so the model can combine multi-step operations in one TypeScript program.',
|
||||
'Includes all Standard mode capabilities, with the agent connecting tools through code for batch processing, filtering, and summaries. Just describe your task.',
|
||||
presetMinimalName: 'Minimal mode',
|
||||
presetMinimalDescription:
|
||||
'Single-tool coding agent with a persistent shell.',
|
||||
'The agent works using only a terminal tool. Useful for testing and comparing its basic performance.',
|
||||
presetCordisName: 'Creator mode',
|
||||
presetCordisDescription:
|
||||
'Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, persistent plugin management, and preset-authoring guidance.',
|
||||
duplicate: 'Duplicate',
|
||||
'Customize DSH through conversation. Let the agent write plugins that add features or UI, or combine tools and prompts to create your own mode.',
|
||||
duplicate: 'Duplicate preset',
|
||||
duplicateUnavailable: 'This deployment has no writable preset directory',
|
||||
delete: 'Delete',
|
||||
presetId: 'Identifier',
|
||||
presetIdPlaceholder: 'my-agent',
|
||||
displayName: 'Name',
|
||||
displayNamePlaceholder: 'Shown in the picker; defaults to the identifier',
|
||||
displayNamePlaceholder: 'Name shown in the mode menu; leave blank to use the identifier',
|
||||
inUse: 'New task default',
|
||||
selectionOffDefault: 'Default',
|
||||
selectionOffDefault: 'Application default',
|
||||
builtInGroup: 'Built-in',
|
||||
customGroup: 'Custom',
|
||||
noDescription: 'No description.',
|
||||
@@ -61,17 +65,17 @@ export const en: Record<AgentPresetSettingsKey, string> = {
|
||||
brokenNoCopy: 'A preset that failed to load cannot be duplicated',
|
||||
switchRefused: 'Could not switch to {name}: {reason}',
|
||||
copyOf: 'Copied from',
|
||||
composition: 'Composition (agent.cordis.yml)',
|
||||
composition: 'Preset configuration (agent.cordis.yml)',
|
||||
cancel: 'Cancel',
|
||||
close: 'Close',
|
||||
retry: 'Retry',
|
||||
copyTitle: 'Duplicate preset',
|
||||
copyIntro:
|
||||
'The whole preset is copied on this machine. The identifier becomes its directory name and cannot '
|
||||
+ 'be changed later; everything else is edited in the preset\'s own files.',
|
||||
'Save a copy on this machine, then open its folder to edit the configuration. '
|
||||
+ 'The identifier becomes the folder name and cannot be changed after creation.',
|
||||
create: 'Create',
|
||||
creating: 'Creating…',
|
||||
creatorDraft: 'Draft a custom preset with Creator mode',
|
||||
creatorDraft: 'Let the agent help me create a preset',
|
||||
openLocation: 'Open folder',
|
||||
showLocation: 'Show location',
|
||||
revealedPathLabel: 'Preset files:',
|
||||
@@ -83,42 +87,43 @@ export const en: Record<AgentPresetSettingsKey, string> = {
|
||||
'The preset directory is deleted. Sessions already running on it keep working; new sessions cannot select it.',
|
||||
deleteConfirm: 'Delete',
|
||||
deleting: 'Deleting…',
|
||||
showPicker: 'Allow switching Agent modes',
|
||||
showPicker: 'Choose a mode for new tasks',
|
||||
showPickerBeta: 'Beta',
|
||||
showPickerDescription:
|
||||
'When enabled, new tasks can choose Standard, PTC, Creator, Minimal, and custom modes. When disabled, all new tasks use the default mode (Standard by default; configurable). Only affects new tasks.',
|
||||
enablePickerToSetDefault: 'Turn on Agent mode selection to choose a default',
|
||||
enablePickerToCreate: 'Turn on Agent mode selection to start Creator mode',
|
||||
'When enabled, choose a mode for each new task and set a default here. When disabled, new tasks use the application\'s configured default. Existing tasks are unaffected.',
|
||||
enablePickerToSetDefault: 'Enable mode selection for new tasks before setting a default',
|
||||
enablePickerToCreate: 'Enable mode selection for new tasks before starting Creator mode',
|
||||
}
|
||||
|
||||
/** Simplified Chinese copy. */
|
||||
export const zh: Record<AgentPresetSettingsKey, string> = {
|
||||
...guideZh,
|
||||
error: '无法加载 Agent 预设。',
|
||||
userTrust: '自定义',
|
||||
seatHint: '即将开始的这个会话所用的 Agent 预设',
|
||||
headerHint: '本会话运行的 Agent 预设,开始时即固定',
|
||||
seatHint: '选择新任务使用的 Agent 预设',
|
||||
headerHint: '本任务的 Agent 预设,在任务开始时确定',
|
||||
nav: 'Agent 预设',
|
||||
sectionIntro: '预设即一个会话的 Agent 所运行的插件组装 —— 它的工具、提示词与能力。复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。',
|
||||
sectionIntro: '选择 Agent 的工具和工作方式。日常任务用「标准模式」,扩展 DSH 的能力用「创造模式」。',
|
||||
builtIn: '内置',
|
||||
setDefault: '设为默认',
|
||||
view: '查看',
|
||||
setDefault: '设为新任务默认',
|
||||
view: '查看配置',
|
||||
presetStandardName: '标准模式',
|
||||
presetStandardDescription: '功能完整的编码 Agent,支持文件编辑、Shell、文件与网页检索、Skills、计划、目标、子代理和工作流。',
|
||||
presetStandardDescription: '处理代码、文件和资料,适合大多数任务。Agent 会按需使用检索、编辑和终端等工具。',
|
||||
presetPtcName: 'PTC 模式',
|
||||
presetPtcDescription: '功能完整的编码 Agent,但默认不提供 workflow 工具;其他工具通过 PTC 模式 SDK 呈现,让模型用一个 TypeScript 程序组合多步操作。',
|
||||
presetPtcDescription: '包含标准模式的所有能力,让 Agent 用代码串联多个工具,完成批量处理、筛选和汇总。你只需描述任务。',
|
||||
presetMinimalName: '极简模式',
|
||||
presetMinimalDescription: '仅提供持久 shell 的单工具编码 Agent。',
|
||||
presetMinimalDescription: 'Agent 仅使用终端工具完成任务,适合测试和对比其基础表现。',
|
||||
presetCordisName: '创造模式',
|
||||
presetCordisDescription: '用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、持久化插件管理和 preset 创作指导。',
|
||||
duplicate: '复制',
|
||||
presetCordisDescription: '用对话定制 DSH:让 Agent 编写插件,添加新功能或界面;也能组合工具和提示词,创建自己的模式。',
|
||||
duplicate: '复制预设',
|
||||
duplicateUnavailable: '此部署未配置可写的预设目录',
|
||||
delete: '删除',
|
||||
presetId: '标识符',
|
||||
presetIdPlaceholder: 'my-agent',
|
||||
displayName: '名称',
|
||||
displayNamePlaceholder: '选择器中显示的名字,缺省用标识符',
|
||||
displayNamePlaceholder: '在模式列表中显示的名称,不填则使用标识符',
|
||||
inUse: '新任务默认',
|
||||
selectionOffDefault: '默认',
|
||||
selectionOffDefault: '应用默认',
|
||||
builtInGroup: '内置',
|
||||
customGroup: '自定义',
|
||||
noDescription: '暂无描述。',
|
||||
@@ -126,15 +131,15 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
|
||||
brokenNoCopy: '预设加载失败,不能复制',
|
||||
switchRefused: '无法切换到「{name}」:{reason}',
|
||||
copyOf: '复制自',
|
||||
composition: '组装(agent.cordis.yml)',
|
||||
composition: '预设配置(agent.cordis.yml)',
|
||||
cancel: '取消',
|
||||
close: '关闭',
|
||||
retry: '重试',
|
||||
copyTitle: '复制预设',
|
||||
copyIntro: '整个预设会在本机复制一份。标识符将成为目录名,事后无法更改;其余内容之后直接在预设自己的文件里编辑。',
|
||||
copyIntro: '复制后会在本机保存一份预设,你可以打开目录修改配置。标识符用作目录名,创建后不能修改。',
|
||||
create: '创建',
|
||||
creating: '正在创建…',
|
||||
creatorDraft: '用「创造模式」创作自定义预设',
|
||||
creatorDraft: '让 Agent 帮我创建预设模式',
|
||||
openLocation: '打开目录',
|
||||
showLocation: '查看路径',
|
||||
revealedPathLabel: '预设文件:',
|
||||
@@ -145,11 +150,11 @@ export const zh: Record<AgentPresetSettingsKey, string> = {
|
||||
deleteDescription: '预设目录将被删除。已在其上运行的会话不受影响;新会话将无法再选择它。',
|
||||
deleteConfirm: '删除',
|
||||
deleting: '正在删除…',
|
||||
showPicker: '允许切换agent模式',
|
||||
showPickerBeta: 'beta',
|
||||
showPickerDescription: '开启后,新任务可选择标准、PTC、创造、极简及自定义模式;关闭后统一使用默认模式(默认为标准模式,可自定义)。仅影响新任务。',
|
||||
enablePickerToSetDefault: '请先开启 Agent 模式选择,再设置默认模式',
|
||||
enablePickerToCreate: '请先开启 Agent 模式选择,再启动创造模式',
|
||||
showPicker: '新任务可选择模式',
|
||||
showPickerBeta: 'Beta',
|
||||
showPickerDescription: '开启后,可为每个新任务选择模式,并在这里设置默认值。关闭后,新任务使用应用配置的默认预设。已有任务不受影响。',
|
||||
enablePickerToSetDefault: '请先开启新任务模式选择,再设置默认值',
|
||||
enablePickerToCreate: '请先开启新任务模式选择,再启动创造模式',
|
||||
}
|
||||
|
||||
// The resolution itself is the shared fold in `dsh-agent-presets/display`,
|
||||
|
||||
@@ -120,11 +120,11 @@ describe('the preset list', () => {
|
||||
expect(within(mine).getByText(en.noDescription)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('marks trust and the one in use, and offers no "set default" on it', () => {
|
||||
it('replaces the default preset trust tag with its new-task default status', () => {
|
||||
renderSection()
|
||||
|
||||
const standard = rowFor('standard')
|
||||
expect(within(standard).getByText(en.builtIn)).toBeTruthy()
|
||||
expect(within(standard).queryByText(en.builtIn)).toBeNull()
|
||||
expect(within(standard).getByText(en.inUse)).toBeTruthy()
|
||||
expect(within(standard).queryByText(en.setDefault)).toBeNull()
|
||||
expect(within(rowFor('mine')).getByText(en.userTrust)).toBeTruthy()
|
||||
@@ -382,6 +382,108 @@ describe('the preset list', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('preset help', () => {
|
||||
it.each([
|
||||
['standard', en.presetStandardName, 'How it works', 'Fix a bug'],
|
||||
['ptc', en.presetPtcName, 'How tools are called', 'Check a set of configuration files'],
|
||||
['minimal', en.presetMinimalName, 'What is included', 'Compare performance on a small bug fix'],
|
||||
['cordis', en.presetCordisName, 'What you can create', 'Add a UI'],
|
||||
])('opens both help sections for %s without changing the default', (id, name, heading, exampleTitle) => {
|
||||
const actions = renderSection({ rows: [{ id, trust: 'system', isDefault: false }] })
|
||||
const trigger = within(rowFor(id)).getByRole('button', { name: `${en.modeExplanation}: ${name}` })
|
||||
trigger.focus()
|
||||
fireEvent.click(trigger)
|
||||
|
||||
const dialog = screen.getByRole('dialog', { name })
|
||||
expect(within(dialog).getByRole('heading', { name: heading })).toBeTruthy()
|
||||
const usage = within(dialog).getByRole('tab', { name: en.howToUse })
|
||||
fireEvent.click(usage)
|
||||
expect(usage.getAttribute('aria-selected')).toBe('true')
|
||||
expect(within(dialog).getByRole('heading', { name: exampleTitle })).toBeTruthy()
|
||||
expect(within(dialog).getAllByText(en.guideExampleTask).length).toBeGreaterThan(0)
|
||||
fireEvent.click(within(dialog).getByRole('tab', { name: en.modeExplanation }))
|
||||
expect(within(dialog).getByRole('heading', { name: heading })).toBeTruthy()
|
||||
fireEvent.click(within(dialog).getByRole('button', { name: en.close }))
|
||||
expect(document.activeElement).toBe(trigger)
|
||||
|
||||
fireEvent.click(within(rowFor(id)).getByRole('button', { name: `${en.howToUse}: ${name}` }))
|
||||
expect(within(screen.getByRole('dialog')).getByRole('tab', { name: en.howToUse }).getAttribute('aria-selected')).toBe('true')
|
||||
expect(actions.makeDefault).not.toHaveBeenCalled()
|
||||
expect(actions.view).not.toHaveBeenCalled()
|
||||
expect(actions.startCreatorDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('keeps keyboard focus in help and dismisses only the reader on Escape', () => {
|
||||
const actions = renderSection()
|
||||
const trigger = within(rowFor('standard')).getByRole('button', { name: `${en.modeExplanation}: ${en.presetStandardName}` })
|
||||
trigger.focus()
|
||||
fireEvent.click(trigger)
|
||||
const dialog = screen.getByRole('dialog')
|
||||
const details = within(dialog).getByRole('tab', { name: en.modeExplanation })
|
||||
const panel = within(dialog).getByRole('tabpanel', { name: en.modeExplanation })
|
||||
const close = within(dialog).getByRole('button', { name: en.close })
|
||||
expect(document.activeElement).toBe(details)
|
||||
expect(fireEvent.keyDown(details, { key: 'Tab' })).toBe(true)
|
||||
panel.focus()
|
||||
fireEvent.keyDown(panel, { key: 'Tab' })
|
||||
expect(document.activeElement).toBe(close)
|
||||
fireEvent.keyDown(close, { key: 'Tab', shiftKey: true })
|
||||
expect(document.activeElement).toBe(panel)
|
||||
fireEvent.keyDown(panel, { key: 'Escape' })
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
expect(document.activeElement).toBe(trigger)
|
||||
expect(actions.close).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closes even when the browser reports no previously focused element', () => {
|
||||
const activeElement = vi.spyOn(document, 'activeElement', 'get').mockReturnValue(null)
|
||||
try {
|
||||
renderSection()
|
||||
fireEvent.click(within(rowFor('standard')).getByRole('button', { name: `${en.modeExplanation}: ${en.presetStandardName}` }))
|
||||
fireEvent.click(within(screen.getByRole('dialog')).getByRole('button', { name: en.close }))
|
||||
expect(screen.queryByRole('dialog')).toBeNull()
|
||||
} finally {
|
||||
activeElement.mockRestore()
|
||||
}
|
||||
})
|
||||
|
||||
it('connects keyboard selection to the visible guide panel', () => {
|
||||
renderSection()
|
||||
fireEvent.click(within(rowFor('standard')).getByRole('button', { name: `${en.modeExplanation}: ${en.presetStandardName}` }))
|
||||
const dialog = screen.getByRole('dialog')
|
||||
const details = within(dialog).getByRole('tab', { name: en.modeExplanation })
|
||||
const usage = within(dialog).getByRole('tab', { name: en.howToUse })
|
||||
expect(details.tabIndex).toBe(0)
|
||||
expect(usage.tabIndex).toBe(-1)
|
||||
fireEvent.keyDown(details, { key: 'ArrowRight' })
|
||||
const panel = within(dialog).getByRole('tabpanel', { name: en.howToUse })
|
||||
expect(panel.id).toBe(usage.getAttribute('aria-controls'))
|
||||
expect(document.activeElement).toBe(usage)
|
||||
expect(usage.getAttribute('aria-selected')).toBe('true')
|
||||
expect(details.tabIndex).toBe(-1)
|
||||
expect(usage.tabIndex).toBe(0)
|
||||
expect(within(dialog).queryByRole('tabpanel', { name: en.modeExplanation })).toBeNull()
|
||||
})
|
||||
|
||||
it('does not attach built-in claims to custom or unknown presets', () => {
|
||||
renderSection({
|
||||
rows: [
|
||||
{ id: 'ptc', trust: 'user', isDefault: false },
|
||||
{ id: 'third-party', trust: 'system', isDefault: false },
|
||||
],
|
||||
})
|
||||
expect(screen.queryByRole('button', { name: new RegExp(en.modeExplanation) })).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: new RegExp(en.howToUse) })).toBeNull()
|
||||
})
|
||||
|
||||
it('leaves help usable when mode selection is disabled', () => {
|
||||
const actions = renderSection({ showPicker: false })
|
||||
fireEvent.click(within(rowFor('standard')).getByRole('button', { name: `${en.howToUse}: ${en.presetStandardName}` }))
|
||||
expect(screen.getByRole('dialog', { name: en.presetStandardName })).toBeTruthy()
|
||||
expect(actions.setPickerVisible).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('the copy dialog', () => {
|
||||
const draft: CopyDraft = {
|
||||
from: 'standard', fromTitle: '标准模式', id: '', name: '', saving: false, error: null,
|
||||
|
||||
@@ -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-primitives/README.md
|
||||
README.md: f17f80d877a76d61b55b502edb05781f8e05cee4
|
||||
README.zh.md: 8540a82389b4dced4e6b6048980f4278ec5722d7
|
||||
README.md: 09950210eaef57a290362037f3d603366fe00168
|
||||
README.zh.md: aa95cbf1ce7fa79c3d1d5466ca8ed4d6942ad03e
|
||||
@@ -42,6 +42,7 @@ Check this table before writing a control in a feature package. A plugin cannot
|
||||
| `Input` | Single-line text entry for search boxes and inline forms. |
|
||||
| `Menu`, `MenuItemButton` | Dropdown of `items` data rows, separators, and group labels, with nested submenus; `children` adds component rows, each a `MenuItemButton` (`separatorBefore` starts a new group), in the same list. Every row shares the styling, the keyboard walk, and the focus return; closing stays the owner's state change for both kinds. While open, ↑/↓ (with Home and End) walk the list, Tab settles the focused row, and Escape or Shift+Tab close back to the anchor; selecting a row also returns the keyboard to the anchor unless the owner moved it itself. Only a keyboard on the anchor or inside the list is intercepted, and `autoFocus` decides solely whether opening focuses the first row. |
|
||||
| `Pill` | Selectable capsule button for view switchers and filters; takes `active` and `onClick`. |
|
||||
| `SegmentedTabs` | Controlled equal-width tabs with a sliding indicator and Left/Right, Home, and End navigation. The caller supplies labels, tab/panel ids, and panel content. |
|
||||
| `Tag` | Read-only capsule badge; `tone` selects one of eight palettes. |
|
||||
| `StateDot` | Solid green `done`, amber `warning`, red `error`, and neutral-grey `idle` marks in a 10px slot, plus a tertiary-grey 14px rotating `ongoing` loader. `aria-hidden`, so the render site owns the name. |
|
||||
| `ConnectionIndicator` | Inline connection-recovery control across outage, retry, and recovered states. |
|
||||
|
||||
@@ -42,6 +42,7 @@ kind: "package-library"
|
||||
| `Input` | 单行文本输入,用于搜索框与行内表单。 |
|
||||
| `Menu`, `MenuItemButton` | 由 `items` 数据行、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单;`children` 在同一列表中加入组件行,每行一个 `MenuItemButton`(`separatorBefore` 开启新分组)。所有行共享样式、键盘走位与焦点归还;两类行的关闭都是 owner 状态的改变。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Escape 或 Shift+Tab 关闭并把焦点还给锚点;选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。 |
|
||||
| `Pill` | 可选中的胶囊按钮,用于视图切换与筛选器;接受 `active` 与 `onClick`。 |
|
||||
| `SegmentedTabs` | 受控的等宽分段标签,支持滑动指示条及左/右方向键、Home、End 导航。调用方提供文案、标签与面板 id,以及面板内容。 |
|
||||
| `Tag` | 只读胶囊徽章;`tone` 选择八种配色之一。 |
|
||||
| `StateDot` | 10px 槽内的绿色 `done`、琥珀色 `warning`、红色 `error`、中性灰色 `idle` 圆点,以及 tertiary 灰色 14px 旋转 `ongoing` loading。它是 `aria-hidden` 的,名称由渲染点提供。 |
|
||||
| `ConnectionIndicator` | 行内连接恢复控件,覆盖断线、重试与已恢复三种状态。 |
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
.tabs {
|
||||
position: relative;
|
||||
display: grid;
|
||||
padding: 4px;
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-alias-bg-module-platform);
|
||||
}
|
||||
|
||||
.indicator {
|
||||
position: absolute;
|
||||
inset: 4px auto 4px 4px;
|
||||
box-sizing: border-box;
|
||||
border: 0.5px solid var(--dsw-alias-border-l3);
|
||||
border-radius: 8px;
|
||||
background: var(--dsw-alias-bg-layer-3);
|
||||
transition: transform 180ms ease;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tab {
|
||||
position: relative;
|
||||
justify-content: center;
|
||||
height: 34px;
|
||||
padding: 0 12px;
|
||||
border-radius: 8px;
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
transition: color 180ms ease;
|
||||
}
|
||||
|
||||
.tab:hover,
|
||||
.tab[aria-selected='true'] {
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.tab[aria-selected='true'] {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.tab:focus-visible {
|
||||
outline: 2px solid var(--dsw-alias-brand-primary);
|
||||
outline-offset: -1px;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.indicator,
|
||||
.tab {
|
||||
transition: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { KeyboardEvent, ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { Pill } from './Pill.tsx'
|
||||
import css from './SegmentedTabs.module.css'
|
||||
|
||||
/** A tab links its localized label to a caller-owned panel. */
|
||||
export interface SegmentedTab<Value extends string = string> {
|
||||
value: Value
|
||||
label: ReactNode
|
||||
id: string
|
||||
panelId: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Render equal-width, controlled tabs with a sliding selection indicator.
|
||||
* @param props.items - non-empty ordered tabs with unique values and DOM ids.
|
||||
* @param props.value - selected value, which must belong to items.
|
||||
* @param props.onChange - selection requested by click, Left/Right, or Home/End.
|
||||
* Keyboard selection also moves focus; only the selected tab is a tab stop.
|
||||
* @param props.label - localized accessible name for the tab list.
|
||||
* @param props.className - layout placement; panels remain caller-owned.
|
||||
* @returns the tab list, without its panels.
|
||||
*/
|
||||
export function SegmentedTabs<Value extends string>({ items, value, onChange, label, className }: {
|
||||
items: readonly [SegmentedTab<Value>, ...SegmentedTab<Value>[]]
|
||||
value: Value
|
||||
onChange: (value: Value) => void
|
||||
label: string
|
||||
className?: string | undefined
|
||||
}): ReactNode {
|
||||
const selectedIndex = items.findIndex(item => item.value === value)
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number) => {
|
||||
let next: number
|
||||
switch (event.key) {
|
||||
case 'ArrowLeft': next = (index + items.length - 1) % items.length; break
|
||||
case 'ArrowRight': next = (index + 1) % items.length; break
|
||||
case 'Home': next = 0; break
|
||||
case 'End': next = items.length - 1; break
|
||||
default: return
|
||||
}
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
const tablist = event.currentTarget.parentElement
|
||||
const nextItem = items[next]
|
||||
/* v8 ignore next -- the event comes from a mounted direct child and next is bounded by non-empty items. */
|
||||
if (tablist === null || nextItem === undefined) return
|
||||
tablist.querySelectorAll<HTMLButtonElement>('[role="tab"]').item(next).focus()
|
||||
onChange(nextItem.value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
role="tablist"
|
||||
aria-label={label}
|
||||
className={clsx(css.tabs, className)}
|
||||
style={{ gridTemplateColumns: `repeat(${items.length}, minmax(0, 1fr))` }}
|
||||
>
|
||||
<span
|
||||
className={css.indicator}
|
||||
aria-hidden="true"
|
||||
style={{ width: `calc((100% - 8px) / ${items.length})`, transform: `translateX(${selectedIndex * 100}%)` }}
|
||||
/>
|
||||
{items.map((item, index) => (
|
||||
<Pill
|
||||
key={item.value}
|
||||
id={item.id}
|
||||
role="tab"
|
||||
className={css.tab}
|
||||
aria-selected={value === item.value}
|
||||
aria-controls={item.panelId}
|
||||
tabIndex={value === item.value ? 0 : -1}
|
||||
onClick={() => { onChange(item.value) }}
|
||||
onKeyDown={(event) => { onKeyDown(event, index) }}
|
||||
>
|
||||
{item.label}
|
||||
</Pill>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -9,6 +9,8 @@ export type { DisclosureRowProps } from './DisclosureRow.tsx'
|
||||
export { Button } from './Button.tsx'
|
||||
export type { ButtonVariant } from './Button.tsx'
|
||||
export { Pill } from './Pill.tsx'
|
||||
export { SegmentedTabs } from './SegmentedTabs.tsx'
|
||||
export type { SegmentedTab } from './SegmentedTabs.tsx'
|
||||
export { Tag } from './Tag.tsx'
|
||||
export type { TagTone } from './Tag.tsx'
|
||||
export { Switch } from './Switch.tsx'
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// @vitest-environment jsdom
|
||||
import { useState } from 'react'
|
||||
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { SegmentedTabs } from '../src/SegmentedTabs.tsx'
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
const items = [
|
||||
{ value: 'details', label: 'Details', id: 'details-tab', panelId: 'details-panel' },
|
||||
{ value: 'examples', label: 'Examples', id: 'examples-tab', panelId: 'examples-panel' },
|
||||
{ value: 'limits', label: 'Limits', id: 'limits-tab', panelId: 'limits-panel' },
|
||||
] as const
|
||||
|
||||
describe('SegmentedTabs', () => {
|
||||
it('requests a selection while the caller owns its value and panels', () => {
|
||||
const onChange = vi.fn()
|
||||
const { rerender } = render(<SegmentedTabs items={items} value="details" onChange={onChange} label="Help" />)
|
||||
const examples = screen.getByRole('tab', { name: 'Examples' })
|
||||
fireEvent.click(examples)
|
||||
expect(onChange).toHaveBeenCalledWith('examples')
|
||||
expect(screen.getByRole('tab', { selected: true }).textContent).toBe('Details')
|
||||
expect(screen.queryByRole('tabpanel')).toBeNull()
|
||||
|
||||
rerender(<SegmentedTabs items={items} value="examples" onChange={onChange} label="Help" />)
|
||||
expect(examples.getAttribute('aria-selected')).toBe('true')
|
||||
expect(examples.id).toBe('examples-tab')
|
||||
expect(examples.getAttribute('aria-controls')).toBe('examples-panel')
|
||||
expect(examples.tabIndex).toBe(0)
|
||||
expect(screen.getByRole('tab', { name: 'Details' }).tabIndex).toBe(-1)
|
||||
expect(screen.getByRole('tablist', { name: 'Help' })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('moves focus and selection with arrows, Home, and End, and leaves Tab alone', () => {
|
||||
const onKeyDown = vi.fn()
|
||||
function Harness() {
|
||||
const [value, setValue] = useState<string>('details')
|
||||
return <div onKeyDown={onKeyDown}><SegmentedTabs items={items} value={value} onChange={setValue} label="Help" /></div>
|
||||
}
|
||||
render(<Harness />)
|
||||
const details = screen.getByRole('tab', { name: 'Details' })
|
||||
const examples = screen.getByRole('tab', { name: 'Examples' })
|
||||
const limits = screen.getByRole('tab', { name: 'Limits' })
|
||||
details.focus()
|
||||
for (const [from, key, to] of [
|
||||
[details, 'ArrowLeft', limits],
|
||||
[limits, 'ArrowRight', details],
|
||||
[details, 'ArrowRight', examples],
|
||||
[examples, 'End', limits],
|
||||
[limits, 'Home', details],
|
||||
] as const) {
|
||||
fireEvent.keyDown(from, { key })
|
||||
expect(document.activeElement).toBe(to)
|
||||
expect(to.getAttribute('aria-selected')).toBe('true')
|
||||
expect(to.tabIndex).toBe(0)
|
||||
}
|
||||
expect(onKeyDown).not.toHaveBeenCalled()
|
||||
fireEvent.keyDown(details, { key: 'Tab' })
|
||||
expect(onKeyDown).toHaveBeenCalledOnce()
|
||||
expect(screen.getByRole('tab', { selected: true })).toBe(details)
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user