mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge remote-tracking branch 'origin/master' into feature/workspace-picker-composer
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.md
|
||||
2026-08-10-creator-guidance-introduce-cue.md: 888fee7b3def585ed3098fedcb7bc6169ee26a22
|
||||
2026-08-10-creator-guidance-introduce-cue.zh.md: d80260abd1995df1f95e3f24fefcb265bda64c11
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: Creator guidance lands as an introduce cue on the preset chip
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-08-10-creator-guidance-introduce-cue.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
Authoring a preset happens inside a Creator-mode session, but the settings section gave no path into that fact. The creator entry sat outside the roster groups, the custom group vanished entirely while it had no member, and clicking the entry dropped the user onto the new-session screen with nothing marking what had changed: the staged preset chip rendered exactly as if the user had picked it by hand. Users reported not understanding that the flow had moved, or that the session they were about to start was the place where the preset gets built (#2184).
|
||||
|
||||
## Decision
|
||||
|
||||
The custom group stays on screen while empty — heading plus the creator entry, which lives inside the group as the standing "your preset will appear here" affordance rather than floating below the roster.
|
||||
|
||||
A pick staged from another screen carries a one-shot `introduce` flag through the seat store (`stage(id, introduce)`), and the chip announces it: the preset icon eases in over 150ms, then the name's characters fade up on a stagger the moment the icon lands. The stagger is capped twice — 40ms per tick for short CJK names, and one shared 200ms reveal window (`min(40, 200/(n-1))`) so a long Latin name finishes in the same time as its CJK counterpart instead of dragging the run out per character. CSS owns the motion; the component arms it and acknowledges the cue once the run is over, so the flag never replays on a later mount. `prefers-reduced-motion` and an empty display name acknowledge immediately with no run.
|
||||
|
||||
The cue is pure presentation: it is client-side seat-store state, never a session event, because the model-visible composition is already carried by the staged preset itself.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
**A toast or callout on the new-session screen.** It explains more, but it points at nothing — the chip is the artifact the user must find again later, and a dismissable box teaches the box, not the control. The cue puts the motion on the control itself.
|
||||
|
||||
**A fixed per-character tick.** The first implementation used 60ms per character unconditionally; an English preset name took over three times as long as its four-character Chinese counterpart, reading as lag rather than emphasis. The shared reveal window makes duration a property of the cue, not of the locale.
|
||||
|
||||
**Animating the pick inside the settings dialog before leaving.** The dialog closes as part of the gesture — leaving settings is how the flow says the work happens in the session — so anything played there would be cut off or would delay the navigation it exists to explain.
|
||||
|
||||
## Consequences
|
||||
|
||||
The intro timeline lives in two places that must agree: the component's `INTRO_TEXT_DELAY_MS` and the `.introIcon` CSS animation duration. The component's constants are the source of the character delays and the acknowledgement timeout; the CSS comment names the coupling. The seat store gains one bit of UI state (`introduce`) that every stage decides explicitly, and the section keeps rendering a group with no members — a shape the section golden and unit tests now pin.
|
||||
|
||||
## Testing
|
||||
|
||||
Component tests pin the capped stagger (11-character Latin name at 20ms steps, 4-character CJK name at the 40ms tick, single character with no stagger), the acknowledgement timing, and the reduced-motion and empty-name skips. `apply.spec.ts` drives the cross-screen stage end to end: the creator draft stages with the cue set, one acknowledgement clears it, and a repeat acknowledgement leaves the snapshot untouched. The `agent-preset-authoring` web e2e holds the empty custom group (heading plus creator entry) in its goldens.
|
||||
@@ -0,0 +1,33 @@
|
||||
# Agent Note: 创造模式引导以介绍动效落在预设 chip 上
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-08-10-creator-guidance-introduce-cue.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
预设的创作发生在创造模式 session 内部,但设置分区没有把这条路径讲清楚。创建入口游离在名册分组之外;自定义分组在没有成员时整个消失;点击入口后用户被抛到新会话屏幕,没有任何标记说明发生了什么变化:暂存的预设 chip 渲染得和用户亲手挑选时一模一样。用户反馈看不懂流程已经移动,也不明白即将开始的 session 正是构建预设的地方(#2184)。
|
||||
|
||||
## 决定
|
||||
|
||||
自定义分组在空的时候也常驻屏幕——分组标题加创建入口,入口移入分组内部,作为"你的预设会出现在这里"的常设指引,而不是漂在名册下方。
|
||||
|
||||
从另一屏幕暂存的选择会经由 seat store 携带一次性的 `introduce` 标志(`stage(id, introduce)`),chip 据此自我介绍:预设图标在 150ms 内缓入,落定的瞬间名称逐字符错峰浮现。错峰有两重上限——短的中文名按每字符 40ms 的节拍,同时共享一个 200ms 的整体揭示窗口(`min(40, 200/(n-1))`),让长的拉丁名与中文名在相同时间内完成,而不是按字符数拖长整轮动画。动效由 CSS 负责;组件只负责触发,并在一轮结束后确认该提示,因此标志不会在后续挂载时重放。`prefers-reduced-motion` 与空显示名会立即确认、不播放动画。
|
||||
|
||||
该提示纯属呈现层:它是客户端 seat-store 状态,永远不是 session 事件,因为模型可见的组合已由暂存的预设本身承载。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
**在新会话屏幕上弹 toast 或提示框。** 它能解释更多,但什么也没指向——chip 才是用户之后必须再次找到的对象,可关闭的提示框教会的是提示框本身,不是控件。介绍动效把动作放在控件本体上。
|
||||
|
||||
**固定的每字符节拍。** 第一版实现无条件使用每字符 60ms;英文预设名的时长超过四字中文名的三倍,读起来像卡顿而非强调。共享揭示窗口让时长成为提示的属性,而不是语言的属性。
|
||||
|
||||
**离开前在设置对话框内播放选中动画。** 关闭对话框本身就是这个手势的一部分——离开设置正是流程在表达"工作发生在 session 里"——在那里播放的任何内容要么被截断,要么会拖延它本要解释的跳转。
|
||||
|
||||
## 后果
|
||||
|
||||
介绍时间线存在于两处且必须一致:组件的 `INTRO_TEXT_DELAY_MS` 与 `.introIcon` 的 CSS 动画时长。组件常量是字符延迟与确认超时的来源;CSS 注释点明了这层耦合。seat store 多出一位 UI 状态(`introduce`),每次暂存都显式决定它;分区则会渲染没有成员的分组——这一形态现由分区 golden 与单元测试钉住。
|
||||
|
||||
## 测试
|
||||
|
||||
组件测试钉住带上限的错峰(11 字符拉丁名走 20ms 步进、4 字中文名走 40ms 节拍、单字符无错峰)、确认时机,以及 reduced-motion 与空名的跳过路径。`apply.spec.ts` 端到端驱动跨屏暂存:创造模式草稿携带提示暂存,一次确认将其清除,重复确认让快照原样不动。`agent-preset-authoring` web e2e 在 golden 中保持空自定义分组(标题加创建入口)。
|
||||
@@ -176,8 +176,10 @@ 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, 'my-agent'))).toBe(false)
|
||||
// Custom group gone with its only member; the shipped set stands.
|
||||
expect(await dialog.getByRole('heading', { name: '自定义' }).count()).toBe(0)
|
||||
// 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.getByText('标准模式').count()).toBeGreaterThan(0)
|
||||
}, 60_000)
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
- 'button "复制: 创造模式"':
|
||||
- img
|
||||
- text: 复制
|
||||
- heading "自定义" [level=3]
|
||||
- button "用「创造模式」创作自定义预设":
|
||||
- img
|
||||
- text: 用「创造模式」创作自定义预设
|
||||
|
||||
@@ -395,7 +395,8 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
|
||||
expect([
|
||||
Math.round(clickAreaBox!.x - treeBox!.x),
|
||||
Math.round(treeBox!.x + treeBox!.width - clickAreaBox!.x - clickAreaBox!.width),
|
||||
]).toEqual([5, 5])
|
||||
// Menu padding alone insets the rows now that the border is gone.
|
||||
]).toEqual([4, 4])
|
||||
await compareOrRefreshGolden(
|
||||
BRANCHLESS_EXPECTED,
|
||||
await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd),
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
max-width: 180px;
|
||||
padding: 0 8px;
|
||||
padding: 0 2px 0 0;
|
||||
height: 22px;
|
||||
border-radius: 6px;
|
||||
background: var(--dsw-alias-fill-tsp-secondary);
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
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'
|
||||
import { IconAgentPresetOutline16 } 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'
|
||||
@@ -57,7 +57,7 @@ export function AgentPresetLabel({
|
||||
const text = option === undefined ? undefined : presetDisplayText(option, t)
|
||||
return (
|
||||
<span className={css.label} title={text?.description ?? t('headerHint')}>
|
||||
<IconThinkOutline16 className={css.icon} />
|
||||
<IconAgentPresetOutline16 size={14} className={css.icon} />
|
||||
{text?.name ?? preset}
|
||||
</span>
|
||||
)
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
min-height: 28px;
|
||||
padding: 0 8px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
border-radius: 16px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 13px;
|
||||
@@ -36,6 +36,61 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* Introduce cue: the icon eases in on an overshoot-free expo curve (duration
|
||||
matches INTRO_TEXT_DELAY_MS, so the characters start the moment it lands),
|
||||
then the name's characters fade up on a stagger (delays set inline per
|
||||
character). All chars occupy their width from the start, so nothing
|
||||
reflows mid-run. */
|
||||
.introIcon {
|
||||
animation: seat-icon-in 0.15s cubic-bezier(0.16, 1, 0.3, 1) both;
|
||||
}
|
||||
|
||||
@keyframes seat-icon-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: scale(0.5);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Wraps the staggered characters into one flex item, so the chip's gap
|
||||
applies around the name as a whole rather than between characters. */
|
||||
.introText {
|
||||
display: inline-block;
|
||||
white-space: pre;
|
||||
}
|
||||
|
||||
.introChar {
|
||||
display: inline-block;
|
||||
white-space: pre;
|
||||
opacity: 0;
|
||||
animation: seat-char-in 0.4s ease-out forwards;
|
||||
}
|
||||
|
||||
@keyframes seat-char-in {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(4px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.introIcon,
|
||||
.introChar {
|
||||
animation: none;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
.chevron {
|
||||
flex: none;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
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, IconThinkOutline16, Menu } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { IconAgentPresetOutline16, IconChevronDownOutline14, 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'
|
||||
@@ -32,6 +32,29 @@ export interface AgentPresetSeatInjected {
|
||||
load: () => Promise<void>
|
||||
/** Stage one preset for the next session. */
|
||||
select: (id: string) => Promise<void>
|
||||
/** Clear the one-shot introduce cue once the chip has played it. */
|
||||
introduced: () => void
|
||||
}
|
||||
|
||||
/* Introduce timeline: the icon eases in first (the CSS animation shares this
|
||||
duration); the name's characters start fading up the moment it lands, each
|
||||
taking the fade duration to settle. The cue clears after the last one. The
|
||||
stagger is capped twice: per tick for short CJK names, and by one shared
|
||||
reveal window so a long Latin name finishes in the same time as its CJK
|
||||
counterpart instead of dragging the run out per character. */
|
||||
const INTRO_TEXT_DELAY_MS = 150
|
||||
const INTRO_CHAR_STAGGER_MS = 40
|
||||
const INTRO_TEXT_REVEAL_MS = 200
|
||||
const INTRO_CHAR_FADE_MS = 400
|
||||
|
||||
/**
|
||||
* Per-character start offset for the introduce reveal.
|
||||
* @param count - character count of the shown preset name.
|
||||
* @returns milliseconds between successive character starts.
|
||||
*/
|
||||
function introStaggerMs(count: number): number {
|
||||
if (count <= 1) return 0
|
||||
return Math.min(INTRO_CHAR_STAGGER_MS, INTRO_TEXT_REVEAL_MS / (count - 1))
|
||||
}
|
||||
|
||||
/** Full component props. */
|
||||
@@ -45,7 +68,7 @@ export type AgentPresetSeatProps =
|
||||
* @param props - composed slot props.
|
||||
* @returns the chip, or null when the deployment composes no presets.
|
||||
*/
|
||||
export function AgentPresetSeat({ load, select, useAgentPresetSeat, t }: AgentPresetSeatProps) {
|
||||
export function AgentPresetSeat({ load, select, introduced, useAgentPresetSeat, t }: AgentPresetSeatProps) {
|
||||
const state = useAgentPresetSeat(snapshot => snapshot)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
@@ -53,12 +76,54 @@ export function AgentPresetSeat({ load, select, useAgentPresetSeat, t }: AgentPr
|
||||
void load()
|
||||
}, [load])
|
||||
|
||||
// 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
|
||||
|
||||
const chosen = state.options.find(option => option.id === state.current)
|
||||
const chosenText = chosen === undefined ? undefined : presetDisplayText(chosen, t)
|
||||
const label = chosenText?.name ?? state.current
|
||||
const ready = state.options.length > 0 && state.current !== ''
|
||||
|
||||
// The introduce cue: the pick was staged from another screen (the settings
|
||||
// creator entry), so the chip announces it — the icon eases in and each
|
||||
// character of the name fades up on a stagger (CSS owns the motion; this
|
||||
// effect only arms it and acknowledges the cue once the run is over).
|
||||
const [introducing, setIntroducing] = useState(false)
|
||||
useEffect(() => {
|
||||
if (!state.introduce || !ready) return
|
||||
const characters = Array.from(label)
|
||||
if (characters.length === 0 || window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
introduced()
|
||||
return
|
||||
}
|
||||
setIntroducing(true)
|
||||
const done = window.setTimeout(() => {
|
||||
setIntroducing(false)
|
||||
introduced()
|
||||
}, INTRO_TEXT_DELAY_MS + (characters.length - 1) * introStaggerMs(characters.length) + INTRO_CHAR_FADE_MS)
|
||||
return () => { window.clearTimeout(done) }
|
||||
}, [state.introduce, ready, label, introduced])
|
||||
|
||||
// Nothing to choose between: the deployment composes no presets and every
|
||||
// session shares the host composition.
|
||||
if (!ready) return null
|
||||
|
||||
// One wrapper span: the chip is a flex row with a gap, so loose character
|
||||
// spans would each pick up the gap between them.
|
||||
const characters = Array.from(label)
|
||||
const stagger = introStaggerMs(characters.length)
|
||||
const shownLabel = introducing
|
||||
? (
|
||||
<span className={css.introText}>
|
||||
{characters.map((character, index) => (
|
||||
<span
|
||||
key={index}
|
||||
className={css.introChar}
|
||||
style={{ animationDelay: `${INTRO_TEXT_DELAY_MS + index * stagger}ms` }}
|
||||
>
|
||||
{character}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
: label
|
||||
|
||||
return (
|
||||
<Menu
|
||||
@@ -95,8 +160,8 @@ export function AgentPresetSeat({ load, select, useAgentPresetSeat, t }: AgentPr
|
||||
disabled={state.busy}
|
||||
onClick={() => { setOpen(value => !value) }}
|
||||
>
|
||||
<IconThinkOutline16 className={css.seatIcon} />
|
||||
{chosenText?.name ?? state.current}
|
||||
<IconAgentPresetOutline16 className={introducing ? `${css.seatIcon} ${css.introIcon}` : css.seatIcon} />
|
||||
{shownLabel}
|
||||
<IconChevronDownOutline14 className={css.chevron} />
|
||||
</button>
|
||||
)}
|
||||
|
||||
@@ -26,6 +26,12 @@
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Group-to-group breathing room: the section's 12px gap plus 20px reads the
|
||||
two rosters as separate blocks (32px total). */
|
||||
.group + .group {
|
||||
margin-top: 20px;
|
||||
}
|
||||
|
||||
.groupHead {
|
||||
margin: 0;
|
||||
font-size: 12px;
|
||||
@@ -363,6 +369,7 @@
|
||||
create button vacated. Dashed like the Models page's add affordances: it
|
||||
reads as a place a preset will appear, not a command. */
|
||||
.creatorButton {
|
||||
box-sizing: border-box;
|
||||
align-self: stretch;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -372,17 +379,18 @@
|
||||
border: 1px dashed var(--dsw-alias-border-l3);
|
||||
border-radius: 12px;
|
||||
font: inherit;
|
||||
font-size: 13px;
|
||||
background: none;
|
||||
color: inherit;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.creatorButton:hover:not(:disabled) {
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.creatorButton:disabled {
|
||||
opacity: 0.5;
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
@@ -171,6 +171,30 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
|
||||
)
|
||||
}
|
||||
|
||||
/* The guided alternative to copying: the self-referential preset can
|
||||
read this very composition and author a new one in conversation.
|
||||
Offered only where that preset is actually on the roster and a
|
||||
session can be landed; without a writable root the draft could
|
||||
never be discovered, so the reason rides the disabled button. */
|
||||
const creatorButton = props.startCreatorDraft !== undefined && state.rows.some(row => row.id === 'cordis')
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className={css.creatorButton}
|
||||
disabled={!state.authorable}
|
||||
title={state.authorable ? undefined : t('duplicateUnavailable')}
|
||||
onClick={() => {
|
||||
props.startCreatorDraft?.()
|
||||
props.close()
|
||||
}}
|
||||
>
|
||||
{/* Same glyph as the Models page's add affordances. */}
|
||||
<IconPlusOutline16 size={14} />
|
||||
{t('creatorDraft')}
|
||||
</button>
|
||||
)
|
||||
: null
|
||||
|
||||
return (
|
||||
<div className={css.section}>
|
||||
<h2 className={css.title}>{t('nav')}</h2>
|
||||
@@ -180,147 +204,130 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode {
|
||||
const group = state.rows
|
||||
.filter(row => row.trust === trust)
|
||||
.map(row => ({ row, text: presetDisplayText(row, t) }))
|
||||
if (group.length === 0) return null
|
||||
// The custom group is where a preset of one's own will appear, so it
|
||||
// stays on screen even while empty: heading plus the creator entry.
|
||||
const tail = trust === 'user' ? creatorButton : null
|
||||
if (group.length === 0 && tail === null) return null
|
||||
return (
|
||||
<section key={trust} className={css.group}>
|
||||
<h3 className={css.groupHead}>{heading}</h3>
|
||||
<ul className={css.cards}>
|
||||
{group.map(({ row, text }) => (
|
||||
<li
|
||||
key={row.id}
|
||||
className={row.broken !== undefined
|
||||
? `${css.card} ${css.cardBroken}`
|
||||
: row.isDefault ? `${css.card} ${css.cardActive}` : css.card}
|
||||
>
|
||||
{/* The card body IS the control: picking a preset is the
|
||||
{group.length === 0 ? null : (
|
||||
<ul className={css.cards}>
|
||||
{group.map(({ row, text }) => (
|
||||
<li
|
||||
key={row.id}
|
||||
className={row.broken !== undefined
|
||||
? `${css.card} ${css.cardBroken}`
|
||||
: row.isDefault ? `${css.card} ${css.cardActive}` : css.card}
|
||||
>
|
||||
{/* The card body IS the control: picking a preset is the
|
||||
common act, so it should not hide behind a small button.
|
||||
The action row sits outside it — nesting buttons is
|
||||
invalid, and these act on the card rather than select it.
|
||||
A broken preset cannot compose a session, so its body is
|
||||
disabled and the card says why instead of offering it. */}
|
||||
<button
|
||||
type="button"
|
||||
className={css.cardMain}
|
||||
aria-pressed={row.isDefault}
|
||||
disabled={row.isDefault || row.broken !== undefined}
|
||||
// Without this the name is the whole card read aloud —
|
||||
// title, badge, description, id.
|
||||
aria-label={`${row.broken !== undefined ? t('brokenBadge') : row.isDefault ? t('inUse') : t('setDefault')}: ${text.name}`}
|
||||
title={row.broken ?? (row.isDefault ? t('inUse') : t('setDefault'))}
|
||||
onClick={() => { void props.makeDefault(row.id) }}
|
||||
>
|
||||
<span className={css.cardHead}>
|
||||
<span className={css.cardName}>{text.name}</span>
|
||||
{row.broken !== undefined
|
||||
? <span className={css.brokenBadge}>{t('brokenBadge')}</span>
|
||||
: null}
|
||||
<span className={css.badge}>
|
||||
{row.trust === 'user' ? t('userTrust') : t('builtIn')}
|
||||
<button
|
||||
type="button"
|
||||
className={css.cardMain}
|
||||
aria-pressed={row.isDefault}
|
||||
disabled={row.isDefault || row.broken !== undefined}
|
||||
// Without this the name is the whole card read aloud —
|
||||
// title, badge, description, id.
|
||||
aria-label={`${row.broken !== undefined ? t('brokenBadge') : row.isDefault ? t('inUse') : t('setDefault')}: ${text.name}`}
|
||||
title={row.broken ?? (row.isDefault ? t('inUse') : t('setDefault'))}
|
||||
onClick={() => { void props.makeDefault(row.id) }}
|
||||
>
|
||||
<span className={css.cardHead}>
|
||||
<span className={css.cardName}>{text.name}</span>
|
||||
{row.broken !== undefined
|
||||
? <span className={css.brokenBadge}>{t('brokenBadge')}</span>
|
||||
: null}
|
||||
<span className={css.badge}>
|
||||
{row.trust === 'user' ? t('userTrust') : t('builtIn')}
|
||||
</span>
|
||||
{row.isDefault ? <span className={css.inUse}>{t('inUse')}</span> : null}
|
||||
</span>
|
||||
{row.isDefault ? <span className={css.inUse}>{t('inUse')}</span> : null}
|
||||
</span>
|
||||
<span className={css.cardDesc}>{text.description ?? t('noDescription')}</span>
|
||||
{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}>
|
||||
{/* Shipped presets are the compositions a copy starts
|
||||
<span className={css.cardDesc}>{text.description ?? t('noDescription')}</span>
|
||||
{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}>
|
||||
{/* 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
|
||||
leads to. A broken shipped preset has no readable
|
||||
composition to offer, so its viewer is withheld; a
|
||||
broken custom one keeps the location action — the
|
||||
files are where it gets fixed. */}
|
||||
{row.trust === 'system'
|
||||
? row.broken === undefined
|
||||
? (
|
||||
{row.trust === 'system'
|
||||
? row.broken === undefined
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
data-tip={t('view')}
|
||||
aria-label={`${t('view')}: ${text.name}`}
|
||||
onClick={() => { void props.view(row.id) }}
|
||||
>
|
||||
<IconBrowseOutline16 />
|
||||
</button>
|
||||
)
|
||||
: null
|
||||
: (
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
data-tip={t('view')}
|
||||
aria-label={`${t('view')}: ${text.name}`}
|
||||
onClick={() => { void props.view(row.id) }}
|
||||
data-tip={state.hasDocument ? t('openLocation') : t('showLocation')}
|
||||
aria-label={`${state.hasDocument ? t('openLocation') : t('showLocation')}: ${text.name}`}
|
||||
onClick={() => { void props.openLocation(row.id) }}
|
||||
>
|
||||
<IconBrowseOutline16 />
|
||||
<IconFolderOpenOutline16 />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
disabled={!state.authorable || row.broken !== undefined}
|
||||
data-tip={row.broken !== undefined
|
||||
? t('brokenNoCopy')
|
||||
: state.authorable ? t('duplicate') : t('duplicateUnavailable')}
|
||||
aria-label={`${t('duplicate')}: ${text.name}`}
|
||||
onClick={() => { props.beginCopy(row.id) }}
|
||||
>
|
||||
<IconCopyOutline16 />
|
||||
</button>
|
||||
{row.trust === 'user'
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className={`${css.iconButton} ${css.iconDanger}`}
|
||||
data-tip={t('delete')}
|
||||
aria-label={`${t('delete')}: ${text.name}`}
|
||||
onClick={() => { props.confirmDelete(row.id) }}
|
||||
>
|
||||
<IconTrashOutline16 />
|
||||
</button>
|
||||
)
|
||||
: null
|
||||
: null}
|
||||
</div>
|
||||
{state.revealedPaths[row.id] === undefined
|
||||
? null
|
||||
: (
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
data-tip={state.hasDocument ? t('openLocation') : t('showLocation')}
|
||||
aria-label={`${state.hasDocument ? t('openLocation') : t('showLocation')}: ${text.name}`}
|
||||
onClick={() => { void props.openLocation(row.id) }}
|
||||
>
|
||||
<IconFolderOpenOutline16 />
|
||||
</button>
|
||||
<p className={css.revealedPath}>
|
||||
<span className={css.revealedPathLabel}>{t('revealedPathLabel')}</span>
|
||||
<code>{state.revealedPaths[row.id]}</code>
|
||||
</p>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className={css.iconButton}
|
||||
disabled={!state.authorable || row.broken !== undefined}
|
||||
data-tip={row.broken !== undefined
|
||||
? t('brokenNoCopy')
|
||||
: state.authorable ? t('duplicate') : t('duplicateUnavailable')}
|
||||
aria-label={`${t('duplicate')}: ${text.name}`}
|
||||
onClick={() => { props.beginCopy(row.id) }}
|
||||
>
|
||||
<IconCopyOutline16 />
|
||||
</button>
|
||||
{row.trust === 'user'
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className={`${css.iconButton} ${css.iconDanger}`}
|
||||
data-tip={t('delete')}
|
||||
aria-label={`${t('delete')}: ${text.name}`}
|
||||
onClick={() => { props.confirmDelete(row.id) }}
|
||||
>
|
||||
<IconTrashOutline16 />
|
||||
</button>
|
||||
)
|
||||
: null}
|
||||
</div>
|
||||
{state.revealedPaths[row.id] === undefined
|
||||
? null
|
||||
: (
|
||||
<p className={css.revealedPath}>
|
||||
<span className={css.revealedPathLabel}>{t('revealedPathLabel')}</span>
|
||||
<code>{state.revealedPaths[row.id]}</code>
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{tail}
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
{/* The guided alternative to copying: the self-referential preset can
|
||||
read this very composition and author a new one in conversation.
|
||||
Offered only where that preset is actually on the roster and a
|
||||
session can be landed; without a writable root the draft could
|
||||
never be discovered, so the reason rides the disabled button. */}
|
||||
{props.startCreatorDraft !== undefined && state.rows.some(row => row.id === 'cordis')
|
||||
? (
|
||||
<button
|
||||
type="button"
|
||||
className={css.creatorButton}
|
||||
disabled={!state.authorable}
|
||||
title={state.authorable ? undefined : t('duplicateUnavailable')}
|
||||
onClick={() => {
|
||||
props.startCreatorDraft?.()
|
||||
props.close()
|
||||
}}
|
||||
>
|
||||
{/* Same glyph as the Models page's add affordances. */}
|
||||
<IconPlusOutline16 size={14} />
|
||||
{t('creatorDraft')}
|
||||
</button>
|
||||
)
|
||||
: null}
|
||||
<CopyDialog
|
||||
state={state}
|
||||
t={t}
|
||||
|
||||
@@ -114,6 +114,7 @@ export function apply(ctx: ClientContext): void {
|
||||
hooks: { agentPresetSeat: seat.store },
|
||||
load: () => seat.load(),
|
||||
select: (id: string) => seat.select(id),
|
||||
introduced: () => { seat.introduced() },
|
||||
})
|
||||
|
||||
const labelInjected = (): AgentPresetLabelInjected => ({
|
||||
@@ -146,7 +147,9 @@ export function apply(ctx: ClientContext): void {
|
||||
// on: the chip's list-change applier composes the blank session the
|
||||
// workspace connect produces or reuses.
|
||||
creatorDraft = () => {
|
||||
seat.stage('cordis')
|
||||
// The introduce cue makes the chip announce the pick the user never
|
||||
// made on this screen — the stage happened back in settings.
|
||||
seat.stage('cordis', true)
|
||||
scope.workspaces.startSession()
|
||||
}
|
||||
const chip = scope.slots.register({
|
||||
|
||||
@@ -26,10 +26,16 @@ export interface AgentPresetSeatState {
|
||||
/** A rejected apply's message, cleared by the next attempt. */
|
||||
error: string | null
|
||||
busy: boolean
|
||||
/**
|
||||
* One-shot cue that the chip should introduce itself (the creator-draft
|
||||
* entry staged the pick from another screen, so the user never touched the
|
||||
* chip); the renderer clears it via `introduced()` once played.
|
||||
*/
|
||||
introduce: boolean
|
||||
}
|
||||
|
||||
const INITIAL: AgentPresetSeatState = {
|
||||
options: [], current: '', error: null, busy: false,
|
||||
options: [], current: '', error: null, busy: false, introduce: false,
|
||||
}
|
||||
|
||||
/** One session's identity and whether it has started. */
|
||||
@@ -121,10 +127,18 @@ export class AgentPresetSeatController {
|
||||
* list-change applier, which fires when the started session becomes
|
||||
* current.
|
||||
* @param id - the preset to stage.
|
||||
* @param introduce - true when the stage came from another screen and the
|
||||
* chip should announce itself on the session it lands on.
|
||||
*/
|
||||
stage(id: string): void {
|
||||
stage(id: string, introduce = false): void {
|
||||
this.staged = id
|
||||
this.set({ current: id, error: null })
|
||||
this.set({ current: id, error: null, introduce })
|
||||
}
|
||||
|
||||
/** Acknowledge the introduction cue once the chip has played it. */
|
||||
introduced(): void {
|
||||
if (!this.store.getSnapshot().introduce) return
|
||||
this.set({ introduce: false })
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -496,6 +496,15 @@ describe('ui-agent-preset apply', () => {
|
||||
expect(section.startCreatorDraft).toBeDefined()
|
||||
expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('cordis')
|
||||
expect(workspaces.starts).toHaveLength(1)
|
||||
|
||||
// A cross-screen stage carries the introduce cue; the chip acknowledges
|
||||
// it once, and a repeat acknowledgement leaves the snapshot untouched.
|
||||
expect(seat.hooks.agentPresetSeat.getSnapshot().introduce).toBe(true)
|
||||
seat.introduced()
|
||||
const acknowledged = seat.hooks.agentPresetSeat.getSnapshot()
|
||||
expect(acknowledged.introduce).toBe(false)
|
||||
seat.introduced()
|
||||
expect(seat.hooks.agentPresetSeat.getSnapshot()).toBe(acknowledged)
|
||||
conversation()
|
||||
})
|
||||
|
||||
|
||||
@@ -41,6 +41,7 @@ const SEAT_READY: AgentPresetSeatState = {
|
||||
],
|
||||
busy: false,
|
||||
error: null,
|
||||
introduce: false,
|
||||
}
|
||||
|
||||
function renderRow(state: Partial<AgentPresetSettingsState> = {}) {
|
||||
@@ -56,7 +57,11 @@ function renderRow(state: Partial<AgentPresetSettingsState> = {}) {
|
||||
|
||||
function renderSeat(state: Partial<AgentPresetSeatState> = {}) {
|
||||
const store = createSnapshotStore<AgentPresetSeatState>({ ...SEAT_READY, ...state })
|
||||
const actions = { load: vi.fn(() => Promise.resolve()), select: vi.fn(() => Promise.resolve()) }
|
||||
const actions = {
|
||||
load: vi.fn(() => Promise.resolve()),
|
||||
select: vi.fn(() => Promise.resolve()),
|
||||
introduced: vi.fn(),
|
||||
}
|
||||
render(<AgentPresetSeat {...({
|
||||
...actions,
|
||||
useAgentPresetSeat: bindSnapshotSelector(store),
|
||||
@@ -272,6 +277,94 @@ describe('the new-session chip', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('the chip introduce cue', () => {
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
/** Character spans carry inline animation delays; nothing else does. */
|
||||
function delayedChars(): HTMLElement[] {
|
||||
return Array.from(screen.getByRole('button').querySelectorAll<HTMLElement>('[style]'))
|
||||
}
|
||||
|
||||
it('reveals a long Latin name inside the shared window, then acknowledges', () => {
|
||||
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false })))
|
||||
vi.useFakeTimers()
|
||||
const actions = renderSeat({
|
||||
current: 'creator',
|
||||
options: [{ id: 'creator', trust: 'user', name: 'CreatorMode' }],
|
||||
introduce: true,
|
||||
})
|
||||
|
||||
// Eleven characters split the 200ms window into 20ms steps, where the
|
||||
// fixed 40ms tick would have doubled the run for a Latin name.
|
||||
const chars = delayedChars()
|
||||
expect(chars.map(span => span.textContent).join('')).toBe('CreatorMode')
|
||||
expect(chars[0]!.style.animationDelay).toBe('150ms')
|
||||
expect(chars[1]!.style.animationDelay).toBe('170ms')
|
||||
expect(chars[10]!.style.animationDelay).toBe('350ms')
|
||||
|
||||
// 150 delay + 200 window + 400 fade: acknowledged only once the last
|
||||
// character has settled, and the label is plain text again after.
|
||||
act(() => { vi.advanceTimersByTime(749) })
|
||||
expect(actions.introduced).not.toHaveBeenCalled()
|
||||
act(() => { vi.advanceTimersByTime(1) })
|
||||
expect(actions.introduced).toHaveBeenCalledTimes(1)
|
||||
expect(delayedChars()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('keeps the per-tick cap for a short CJK name', () => {
|
||||
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false })))
|
||||
vi.useFakeTimers()
|
||||
renderSeat({
|
||||
current: 'creator',
|
||||
options: [{ id: 'creator', trust: 'user', name: '创造模式' }],
|
||||
introduce: true,
|
||||
})
|
||||
|
||||
// Four characters fit under the window, so the 40ms tick applies as-is.
|
||||
const chars = delayedChars()
|
||||
expect(chars).toHaveLength(4)
|
||||
expect(chars[1]!.style.animationDelay).toBe('190ms')
|
||||
expect(chars[3]!.style.animationDelay).toBe('270ms')
|
||||
})
|
||||
|
||||
it('starts a one-character name with no stagger at all', () => {
|
||||
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false })))
|
||||
vi.useFakeTimers()
|
||||
const actions = renderSeat({
|
||||
current: 'creator',
|
||||
options: [{ id: 'creator', trust: 'user', name: 'C' }],
|
||||
introduce: true,
|
||||
})
|
||||
|
||||
expect(delayedChars()[0]!.style.animationDelay).toBe('150ms')
|
||||
act(() => { vi.advanceTimersByTime(550) })
|
||||
expect(actions.introduced).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('skips the run under reduced motion and acknowledges at once', () => {
|
||||
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: true })))
|
||||
const actions = renderSeat({ introduce: true })
|
||||
|
||||
expect(actions.introduced).toHaveBeenCalledTimes(1)
|
||||
expect(delayedChars()).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('acknowledges an empty staged name without arming a run', () => {
|
||||
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false })))
|
||||
const actions = renderSeat({
|
||||
current: 'creator',
|
||||
options: [{ id: 'creator', trust: 'user', name: '' }],
|
||||
introduce: true,
|
||||
})
|
||||
|
||||
expect(actions.introduced).toHaveBeenCalledTimes(1)
|
||||
expect(delayedChars()).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
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' })
|
||||
|
||||
@@ -253,6 +253,20 @@ describe('the preset list', () => {
|
||||
expect(actions.close).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('keeps the empty custom group on screen: heading plus the creator entry', () => {
|
||||
renderSection({
|
||||
rows: [
|
||||
{ id: 'standard', trust: 'system', isDefault: true, name: '标准模式' },
|
||||
{ id: 'cordis', trust: 'system', isDefault: false, name: '创造模式' },
|
||||
],
|
||||
})
|
||||
|
||||
// No member yet, but the place where one's own preset will appear stays.
|
||||
expect(screen.getByRole('heading', { name: en.customGroup })).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: en.creatorDraft })).toBeTruthy()
|
||||
expect(screen.queryByText(`· ${en.userTrust}`)).toBeNull()
|
||||
})
|
||||
|
||||
it('hides the creator entry without the flow or the preset, disables it without a root', () => {
|
||||
renderSection()
|
||||
expect(screen.queryByRole('button', { name: en.creatorDraft })).toBeNull()
|
||||
|
||||
@@ -60,7 +60,7 @@ export const zh = {
|
||||
'access.confirm.acknowledge': '我已了解风险,并愿意继续',
|
||||
'access.confirm.cancel': '取消',
|
||||
'access.confirm.enable': '启用 Full access',
|
||||
'hero.headline': '探索未知之境',
|
||||
'hero.headline': '探索未至之境',
|
||||
'hero.preview': '预览版',
|
||||
'hero.chooseWorkspace': '选择工作区',
|
||||
'session.hierarchy': '会话层级',
|
||||
|
||||
@@ -263,8 +263,9 @@
|
||||
.composerHero {
|
||||
position: relative; /* .heroGlow positioning context */
|
||||
align-self: center;
|
||||
/* figma 75:8208: 12 between hero chrome / workspace row / card. */
|
||||
gap: 12px;
|
||||
/* figma 75:8208 drew 12 between all three rows; the workspace row now sits
|
||||
8 above the card (its margin-top restores 12 under the hero chrome). */
|
||||
gap: 8px;
|
||||
/* Foot inside the centered box floats the stack a bit above true center. */
|
||||
padding-bottom: 32px;
|
||||
/* Card cap + both clearances: the hero input card lands at exactly the same
|
||||
@@ -292,7 +293,9 @@
|
||||
.heroWorkspaceRow {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
min-width: 0;
|
||||
margin-top: 4px;
|
||||
/* figma drew px 8; nudged +12 so the chip's folder glyph lines up closer to
|
||||
the card's inner controls below. */
|
||||
padding-left: 20px;
|
||||
|
||||
@@ -105,7 +105,7 @@
|
||||
min-height: 28px;
|
||||
padding: 0 8px;
|
||||
border: none;
|
||||
border-radius: 12px;
|
||||
border-radius: 16px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 13px;
|
||||
|
||||
@@ -51,6 +51,9 @@
|
||||
}
|
||||
|
||||
.chevron {
|
||||
/* inline-flex, not inline: an inline seat reserves baseline descent under
|
||||
the svg and floats the glyph off-center in the 28px trigger. */
|
||||
display: inline-flex;
|
||||
flex: 0 0 auto;
|
||||
color: var(--dsw-alias-label-caption);
|
||||
transition: transform 120ms ease;
|
||||
|
||||
@@ -362,7 +362,7 @@ describe('ConversationRoot resident composer', () => {
|
||||
const header = b.view.container.querySelector('header')
|
||||
expect(host).not.toBeNull()
|
||||
expect(header?.getAttribute('aria-hidden')).toBe('true')
|
||||
expect(b.view.getByText('探索未知之境')).toBeTruthy()
|
||||
expect(b.view.getByText('探索未至之境')).toBeTruthy()
|
||||
expect(b.view.getByText('预览版')).toBeTruthy()
|
||||
expect(b.view.queryByTestId('view-chat')).toBeNull()
|
||||
// The same machine-backed textarea is live in the hero, and the
|
||||
@@ -386,7 +386,7 @@ describe('ConversationRoot resident composer', () => {
|
||||
const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true, openState: 'loading' }))
|
||||
const root = b.view.container.querySelector('[data-phase]')
|
||||
expect(root?.getAttribute('data-phase')).toBe('settling')
|
||||
expect(b.view.queryByText('探索未知之境')).toBeNull()
|
||||
expect(b.view.queryByText('探索未至之境')).toBeNull()
|
||||
})
|
||||
|
||||
it('settling phase: a session the list has no row for settles conservatively', () => {
|
||||
@@ -411,7 +411,7 @@ describe('ConversationRoot resident composer', () => {
|
||||
// blank the column for the history round-trip.
|
||||
const root = b.view.container.querySelector('[data-phase]')
|
||||
expect(root?.getAttribute('data-phase')).toBe('hero')
|
||||
expect(b.view.getByText('探索未知之境')).toBeTruthy()
|
||||
expect(b.view.getByText('探索未至之境')).toBeTruthy()
|
||||
expect(b.view.getByRole('textbox')).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -429,7 +429,7 @@ describe('ConversationRoot resident composer', () => {
|
||||
expect(after.value).toBe('kept across flip')
|
||||
expect(b.chat.store.getSnapshot().draft).toBe('kept across flip')
|
||||
expect(b.view.container.querySelector('[data-conversation-scroll]')?.contains(after)).toBe(true)
|
||||
expect(b.view.queryByText('探索未知之境')).toBeNull()
|
||||
expect(b.view.queryByText('探索未至之境')).toBeNull()
|
||||
expect(b.view.getByTestId('view-chat')).toBeTruthy()
|
||||
})
|
||||
|
||||
|
||||
@@ -349,6 +349,35 @@ export const IconThinkOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** ic_ds_agent_preset_outline_16 (figma extract): node interiors knock out to transparency via mask, so the glyph sits on any fill. */
|
||||
export const IconAgentPresetOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<mask id="mask0_agent_preset_16" maskUnits="userSpaceOnUse" x="0" y="0" width="16" height="16">
|
||||
<rect width="16" height="16" fill="white" />
|
||||
<circle cx="7.9995" cy="3.28319" r="1.712" fill="black" />
|
||||
<circle cx="3.51122" cy="11.3855" r="1.712" fill="black" />
|
||||
<circle cx="12.4878" cy="11.3855" r="1.712" fill="black" />
|
||||
</mask>
|
||||
<path
|
||||
mask="url(#mask0_agent_preset_16)"
|
||||
d="M12.2881 11.0425C12.6002 11.3723 13.0413 11.5786 13.5312 11.5786L13.5342 11.5776C13.1476 12.3233 12.6119 12.9785 11.9639 13.5005C10.9327 14.3309 9.6199 14.8286 8.19336 14.8286C7.29864 14.8285 6.45056 14.6313 5.6875 14.2808C6.08309 14.0281 6.36707 13.6189 6.45215 13.1392C6.99022 13.3561 7.57767 13.476 8.19336 13.4761C9.30019 13.4761 10.3157 13.0915 11.1152 12.4478C11.5935 12.0626 11.9924 11.5848 12.2881 11.0425ZM4.14746 4.36475C4.25569 4.83228 4.55488 5.2247 4.95898 5.4585C4.07956 6.30639 3.53144 7.49605 3.53125 8.81396C3.53125 9.69534 3.77613 10.5202 4.20117 11.2231C3.74959 11.3817 3.38395 11.7232 3.19531 12.1597C2.5541 11.2032 2.17969 10.052 2.17969 8.81396C2.17989 7.05087 2.93868 5.4646 4.14746 4.36475ZM8.19336 2.80029C8.85717 2.80029 9.49784 2.90834 10.0967 3.10791C12.3237 3.85044 13.9725 5.86061 14.1846 8.28369C13.9832 8.20048 13.7627 8.15382 13.5312 8.15381C13.2802 8.15381 13.042 8.20907 12.8271 8.30615C12.6281 6.47264 11.3666 4.95616 9.66895 4.39014C9.2063 4.236 8.70989 4.15186 8.19336 4.15186C7.96112 4.15189 7.7329 4.16981 7.50977 4.20264C7.51947 4.12886 7.52637 4.05348 7.52637 3.97705C7.52628 3.56604 7.3811 3.18914 7.13965 2.89404C7.48183 2.83352 7.83381 2.80033 8.19336 2.80029Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M9.1123 3.28271C9.11205 2.66858 8.61322 2.17041 7.99902 2.17041C7.38504 2.17067 6.88697 2.66874 6.88672 3.28271C6.88672 3.89691 7.38489 4.39574 7.99902 4.396C8.61338 4.396 9.1123 3.89707 9.1123 3.28271ZM10.3115 3.28271C10.3115 4.55981 9.27612 5.59521 7.99902 5.59521C6.72214 5.59496 5.6875 4.55965 5.6875 3.28271C5.68776 2.00599 6.7223 0.971447 7.99902 0.971191C9.27596 0.971191 10.3113 2.00584 10.3115 3.28271Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M4.62402 11.385C4.62377 10.7709 4.12494 10.2727 3.51074 10.2727C2.89676 10.273 2.39869 10.771 2.39844 11.385C2.39844 11.9992 2.89661 12.498 3.51074 12.4983C4.1251 12.4983 4.62402 11.9994 4.62402 11.385ZM5.82324 11.385C5.82324 12.6621 4.78784 13.6975 3.51074 13.6975C2.23386 13.6973 1.19922 12.6619 1.19922 11.385C1.19947 10.1083 2.23402 9.07374 3.51074 9.07349C4.78768 9.07349 5.82299 10.1081 5.82324 11.385Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
<path
|
||||
d="M13.6006 11.385C13.6003 10.7709 13.1015 10.2727 12.4873 10.2727C11.8733 10.273 11.3753 10.771 11.375 11.385C11.375 11.9992 11.8732 12.498 12.4873 12.4983C13.1017 12.4983 13.6006 11.9994 13.6006 11.385ZM14.7998 11.385C14.7998 12.6621 13.7644 13.6975 12.4873 13.6975C11.2104 13.6973 10.1758 12.6619 10.1758 11.385C10.176 10.1083 11.2106 9.07374 12.4873 9.07349C13.7642 9.07349 14.7995 10.1081 14.7998 11.385Z"
|
||||
fill="currentColor"
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
|
||||
/** ic_ds_browse_outline_16 */
|
||||
export const IconBrowseOutline16 = ({ size = 16, className }: IconProps) => (
|
||||
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
|
||||
@@ -16,8 +16,8 @@ const icons = Object.fromEntries(
|
||||
const iconNames = Object.keys(icons)
|
||||
|
||||
describe('ic_ds_ icon set', () => {
|
||||
it('exports the full icon set (46 deepsuite + 18 figma extracts + three product glyphs outside those sets)', () => {
|
||||
expect(iconNames.length).toBe(67)
|
||||
it('exports the full icon set (46 deepsuite + 19 figma extracts + three product glyphs outside those sets)', () => {
|
||||
expect(iconNames.length).toBe(68)
|
||||
})
|
||||
|
||||
it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => {
|
||||
|
||||
@@ -205,11 +205,11 @@
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
/* Options area (figma Options 501:29983): pad (24,0,24,8), scrolls. */
|
||||
/* Options area (figma Options 501:29983): pad (24,0,24,24), scrolls. */
|
||||
.options {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 0 24px 8px;
|
||||
padding: 0 24px 24px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
import { useCallback, useEffect, useId, useRef, useState } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
IconCloseOutline16, IconDataOutline16, IconSettingsOutline16, IconThinkOutline16,
|
||||
IconAgentPresetOutline16, IconCloseOutline16, IconDataOutline16, IconSettingsOutline16,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { SettingsRootComponentProps, SettingsSectionRow } from './contract/slots.ts'
|
||||
import css from './SettingsRoot.module.css'
|
||||
@@ -22,7 +22,7 @@ import css from './SettingsRoot.module.css'
|
||||
/** Nav glyph by section id; unknown ids fall back to the settings gear. */
|
||||
function navIcon(id: string) {
|
||||
if (id === 'models') return <IconDataOutline16 className={css.navIcon} size={16} />
|
||||
if (id === 'agent-presets') return <IconThinkOutline16 className={css.navIcon} size={16} />
|
||||
if (id === 'agent-presets') return <IconAgentPresetOutline16 className={css.navIcon} size={16} />
|
||||
return <IconSettingsOutline16 className={css.navIcon} size={16} />
|
||||
}
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
gap: 8px;
|
||||
height: 60px;
|
||||
padding: 8px 0 8px 4px;
|
||||
margin-bottom: 16px;
|
||||
margin-bottom: 8px;
|
||||
box-sizing: border-box;
|
||||
overflow: hidden;
|
||||
}
|
||||
@@ -157,8 +157,8 @@
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* New Session: 38px capsule (figma 133:7634); collapsed it renders as the
|
||||
rail's plain icon control. */
|
||||
/* New Session: 38px bar, 12px radius (figma 133:7634 geometry, squared-off
|
||||
corners); collapsed it renders as the rail's plain icon control. */
|
||||
.newSession {
|
||||
flex: none;
|
||||
display: flex;
|
||||
@@ -170,7 +170,7 @@
|
||||
margin: 0 2px 20px; /* bottom: former headerBlock padBottom 12 + root gap 8 */
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 24px;
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-alias-button-elevated-fill);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font-size: 14px;
|
||||
|
||||
@@ -54,7 +54,6 @@
|
||||
max-height: min(560px, calc(100vh - 140px));
|
||||
padding: 4px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 12px;
|
||||
background: var(--dsw-specific-menu);
|
||||
--dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2);
|
||||
|
||||
@@ -519,8 +519,14 @@ export function SubagentCatalogAction({
|
||||
observedCatalogs.current.clear()
|
||||
}, [])
|
||||
|
||||
// Visibility needs evidence of children (entries, summary-known descendants,
|
||||
// or a failed load worth retrying). A bare loading catalog is not evidence:
|
||||
// selecting any session schedules a refresh whose loading snapshot would
|
||||
// otherwise flash the action in and out on childless sessions.
|
||||
const visible = presentedCatalog !== undefined
|
||||
&& (presentedCatalog.state !== 'ready' || presentedCatalog.entries.length > 0)
|
||||
&& (presentedCatalog.state === 'error'
|
||||
|| presentedCatalog.entries.length > 0
|
||||
|| descendantCount > 0)
|
||||
useEffect(() => {
|
||||
if (visible || !open) return
|
||||
setOpen(false)
|
||||
|
||||
@@ -522,22 +522,23 @@ describe('SubagentCatalogAction', () => {
|
||||
expect(staleEmpty.openChild).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders empty loading and fallback error states without focusable rows', async () => {
|
||||
it('hides a bare loading catalog and keeps the error fallback without focusable rows', async () => {
|
||||
// Selecting any session schedules a catalog refresh; a loading snapshot
|
||||
// with no other evidence of children must not flash the action in.
|
||||
const loading = props(catalog({ entries: [], state: 'loading' }))
|
||||
const view = render(<SubagentCatalogAction {...loading} />)
|
||||
const trigger = screen.getByRole('button', { name: /0 个子代理/ })
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.getByText('正在加载子代理…')).toBeTruthy()
|
||||
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
|
||||
await Promise.resolve()
|
||||
expect(screen.getByRole('tree')).toBeTruthy()
|
||||
fireEvent.keyDown(screen.getByRole('tree'), { key: 'ArrowUp' })
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
view.unmount()
|
||||
|
||||
const failed = props(catalog({ entries: [], state: 'error', error: null }))
|
||||
render(<SubagentCatalogAction {...failed} />)
|
||||
fireEvent.click(screen.getByRole('button', { name: /0 个子代理/ }))
|
||||
const trigger = screen.getByRole('button', { name: /0 个子代理/ })
|
||||
fireEvent.click(trigger)
|
||||
expect(screen.getByText('无法加载子代理')).toBeTruthy()
|
||||
fireEvent.keyDown(trigger, { key: 'ArrowDown' })
|
||||
await Promise.resolve()
|
||||
expect(screen.getByRole('tree')).toBeTruthy()
|
||||
fireEvent.keyDown(screen.getByRole('tree'), { key: 'ArrowUp' })
|
||||
})
|
||||
|
||||
it('navigates from outside the tree and tolerates a deferred focus after unmount', async () => {
|
||||
|
||||
@@ -64,7 +64,8 @@
|
||||
line-height: 20px;
|
||||
}
|
||||
|
||||
/* Search input: 38px capsule (figma 133:7649); rail state renders it as the
|
||||
/* Search input: 38px bar, 12px radius (figma 133:7649 geometry, squared-off
|
||||
corners); rail state renders it as the
|
||||
region's search control. Upstream binds a dedicated design-system variable
|
||||
(light #F1F3F5 / dark #1B1B1C) matching no shipped alias — a component
|
||||
token pinned to the static scale mirrors it. */
|
||||
@@ -79,7 +80,7 @@
|
||||
padding: 0 14px;
|
||||
box-sizing: border-box;
|
||||
border: 1px solid var(--dsw-alias-border-l2);
|
||||
border-radius: 24px;
|
||||
border-radius: 12px;
|
||||
background: var(--dsh-search-input-fill);
|
||||
color: var(--dsw-alias-label-caption);
|
||||
overflow: hidden;
|
||||
|
||||
Reference in New Issue
Block a user