fix(web): polish work detail interactions
This commit is contained in:
1 parent
5f90ac119e
commit
ae1cf3106b
42 files changed
+356
-127
No files matched your search
@@ -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 .agents/notes/implemented/feature/2026-09-14-step-process-disclosures.md
|
||||
2026-09-14-step-process-disclosures.md: b0a06e851dc5bac4a24d042693cd3d285b8ff37c
|
||||
2026-09-14-step-process-disclosures.zh.md: d3f2e50d80d7f20043e95b6c7aef5303983d7104
|
||||
2026-09-14-step-process-disclosures.md: cf2a6d4a3017d3a8e25180b482a0262569190f26
|
||||
2026-09-14-step-process-disclosures.zh.md: c5380c56acff7a098e72530ede625d35ca7a8739
|
||||
@@ -12,7 +12,7 @@ Whole-turn folding only shortens completed turns. Long ongoing turns interleave
|
||||
|
||||
The [Chat renderer](../../../../packages/client/ui-chat/README.md#turn-process-folding) groups consecutive process material between assistant responses. Reasoning and response blocks within one assistant node have separate renderer seats so streaming text closes the preceding process range without hiding the response. Group disclosure state survives updates under its first member key; the existing whole-turn disclosure remains the outer visibility owner. Its title reports elapsed time between the recorded turn boundaries; absent timestamps omit the duration.
|
||||
|
||||
Titles summarize tool categories, not individual tool names or infrastructure records. Every distinct call contributes once, including repeated paths. Count ranks the top three categories but stays out of the title; ties retain first appearance order. Two categories use “and”, three use commas, and only more than three add “etc.” Chinese uses “并”, “,” and “等”, sharing “已” only when both of two phrases start with it. English continuation phrases start lowercase. Running groups describe the newest running tool category, otherwise analysis activity, and animate that text with a repeating shimmer until the range closes. Each displayed live title remains for at least 150ms; updates during that interval coalesce to the latest title instead of queuing intermediate tools; a completed group without tools uses the analysis fallback. Retry rows remain independent of secondary process groups. Each secondary title starts with the matching existing Tool icon; commands use Bash's `IconApiOutline14`. Hover or keyboard focus replaces that leading icon in place with a down chevron while collapsed or an up chevron while expanded, and no trailing chevron remains; hovering whole-Turn and secondary disclosure titles darkens their text. A collapsed secondary title has no vertical padding and sits 12px from adjacent Assistant messages. An expanded title has 12px of space before its first process row, while adjacent process rows use 8px. Expanded content has a viewport-relative height cap and its own scroll area. Each scrollable edge carries a 24px fade that disappears when the reader reaches that edge.
|
||||
Compact and completed titles summarize tool categories, not individual tool names or infrastructure records. Every distinct call contributes once, including repeated paths. Count ranks the top three categories but stays out of the title; ties retain first appearance order. Two categories use “and”, three use commas, and only more than three add “etc.” Chinese uses “并”, “,” and “等”, sharing “已” only when both of two phrases start with it. English continuation phrases start lowercase. Running groups describe the newest running tool category, otherwise analysis activity, and animate that text with a repeating ease-in-out shimmer whose one-second pass is followed by a 500ms pause until the range closes. Detailed adds a concise current-call detail to the category's right, preferring authored task text and common command, query, URL, or path fields before falling back to the exact tool name. While no tool is running, the detail comes from the latest live reasoning paragraph. Details are whitespace-normalized and capped at 160 characters. Each displayed live title remains for at least 150ms; updates during that interval coalesce to the latest title instead of queuing intermediate tools; a completed group without tools uses the analysis fallback. Retry rows remain independent of secondary process groups. Each secondary title starts with the matching existing Tool icon; commands use Bash's `IconApiOutline14`. Hover or keyboard focus replaces that leading icon in place with a down chevron while collapsed; an expanded title keeps an up chevron visible, and no trailing chevron remains. Hovering whole-Turn and secondary disclosure titles darkens their text. A collapsed secondary title has no vertical padding and sits 16px from adjacent Assistant messages. An expanded title has 16px of space before its first process row, while adjacent process rows use 8px. Expanded content has a viewport-relative height cap and its own scroll area. Each scrollable edge carries a 24px fade that disappears when the reader reaches that edge.
|
||||
|
||||
Whole-turn folding checks each Turn independently: while history is paginated, its start must be loaded. Requiring all Session history would disable folding throughout long goal and job sessions. Loading a missing start enables that Turn without waiting for unrelated older Turns.
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ Status: implemented
|
||||
|
||||
[Chat 渲染器](../../../../packages/client/ui-chat/README.zh.md#turn-process-folding) 将 Assistant 回复之间的连续过程内容分组。同一 Assistant 节点中的推理与回复块使用独立的渲染位置,因此流式正文会闭合前面的过程组,同时保持回复外露。分组以首个成员的键保持更新前后的展开状态,现有整轮折叠仍负责外层可见性。其标题展示记录的轮次起止时间之间的用时,时间戳缺失时省略时长。
|
||||
|
||||
标题概括工具类别,不展示各工具名或基础设施记录。每个不同调用计数一次,同一路径的多次调用分别计数。次数仅用于选取前三类,不显示在标题中;并列时按首次出现顺序排列。两类用「并」,三类用「,」,仅超过三类才加「等」;两项均以「已」开头时共享首个「已」。英文对应 “and”、逗号和 “etc.”,后续短语首字母小写。进行中的组显示最新运行工具的类别,否则显示分析阶段,并在该组关闭前循环显示文字扫光。每个已显示的进行中标题至少保留 150ms;期间的更新只保留最新标题,不排队补播中间工具;完成组没有工具时使用分析完成的回退文案。重试行独立展示,不归入二级过程组。每个二级标题默认以前置的现有工具图标表示类别,命令类别使用 Bash 的 `IconApiOutline14`。悬停或键盘聚焦时,该前置图标原位切换为箭头:收起时朝下,展开时朝上,标题尾部不显示箭头;悬停整轮或二级折叠标题时,文字颜色加深。收起的二级标题不带垂直内边距,与相邻 Assistant 消息相隔 12px。展开标题与首个过程行相隔 12px,相邻过程行相隔 8px。展开内容按视口限制高度,并独立滚动;每个仍可滚动的方向显示 24px 渐隐遮罩,到达对应边缘时消失。
|
||||
简洁模式与完成后的标题只概括工具类别,不展示各工具名或基础设施记录。每个不同调用计数一次,同一路径的多次调用分别计数。次数仅用于选取前三类,不显示在标题中;并列时按首次出现顺序排列。两类用「并」,三类用「,」,仅超过三类才加「等」;两项均以「已」开头时共享首个「已」。英文对应 “and”、逗号和 “etc.”,后续短语首字母小写。进行中的组显示最新运行工具的类别,否则显示分析阶段,并在该组关闭前循环显示采用 ease-in-out 曲线、每次移动一秒并随后停顿 500ms 的文字扫光。详细模式在类别右侧加入简短的当前调用详情,优先采用调用方给出的任务文字及常见命令、查询词、URL 或路径字段,没有可用详情时回退到原始工具名;没有工具运行时,详情来自最新的实时思考段落。详情会合并空白并截断到 160 个字符。每个已显示的进行中标题至少保留 150ms;期间的更新只保留最新标题,不排队补播中间工具;完成组没有工具时使用分析完成的回退文案。重试行独立展示,不归入二级过程组。每个二级标题默认以前置的现有工具图标表示类别,命令类别使用 Bash 的 `IconApiOutline14`。收起时,悬停或键盘聚焦会把前置图标原位替换为向下箭头;展开后持续显示向上箭头,标题尾部不显示箭头。悬停整轮或二级折叠标题时,文字颜色加深。收起的二级标题不带垂直内边距,与相邻 Assistant 消息相隔 16px。展开标题与首个过程行相隔 16px,相邻过程行相隔 8px。展开内容按视口限制高度,并独立滚动;每个仍可滚动的方向显示 24px 渐隐遮罩,到达对应边缘时消失。
|
||||
|
||||
整轮折叠按轮次独立判断:历史分页未加载完时,该轮起点必须已加载。要求整个 Session 历史加载完整会让长时间运行的 goal 和 job 会话无法折叠。补齐起点即可启用该轮折叠,无需等待无关的更早轮次。
|
||||
|
||||
|
||||
@@ -1,13 +1,5 @@
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Good response":
|
||||
- img
|
||||
- button "Bad response":
|
||||
- img
|
||||
- button "Branch into a new conversation" [disabled]:
|
||||
- img
|
||||
- text: Available only on the last message of a completed turn
|
||||
- button "Ran for 0s":
|
||||
- img
|
||||
- text: Ran for 0s
|
||||
- text: HH:mm
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
- button "Bad response"
|
||||
- button "Branch into a new conversation" [disabled]
|
||||
- text: Available only on the last message of a completed turn HH:mm
|
||||
@@ -1,4 +1 @@
|
||||
- button "Think":
|
||||
- img
|
||||
- img
|
||||
- text: Think
|
||||
- button "Think"
|
||||
@@ -1,4 +1,3 @@
|
||||
- button "Background task completed HH:mm":
|
||||
- text: Background task completed
|
||||
- time: HH:mm
|
||||
- img
|
||||
@@ -1,6 +1,5 @@
|
||||
- button "Background task completed HH:mm" [expanded]:
|
||||
- text: Background task completed
|
||||
- time: HH:mm
|
||||
- img
|
||||
- paragraph: This notification triggered this response.
|
||||
- text: "background job bash-39 (bash: PR merge check) finished [status: completed, exit code 0]. Read its output with job_output."
|
||||
@@ -1,3 +1 @@
|
||||
- button "Took 1s":
|
||||
- text: Took 1s
|
||||
- img
|
||||
- button "Took 1s"
|
||||
@@ -128,6 +128,8 @@ describe('web e2e: PTC mode round renders nested sub-calls', () => {
|
||||
await expect.poll(() => row.getAttribute('aria-expanded')).toBe('false')
|
||||
await row.click()
|
||||
await expect.poll(() => row.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(await row.locator('svg path').first().getAttribute('d'))
|
||||
.toBe('M12 10L8.70711 6.70711C8.31658 6.31658 7.68342 6.31658 7.29289 6.70711L4 10')
|
||||
const terminal = row.locator('xpath=..').locator('[data-terminal]')
|
||||
await terminal.waitFor()
|
||||
await terminal.getByText('echo CODE_ROUND_OK', { exact: true }).waitFor()
|
||||
|
||||
@@ -68,13 +68,17 @@ it('keeps completed step work collapsed inside an expanded turn and bounds its s
|
||||
expect(await group.evaluate((element) => {
|
||||
const next = element.nextElementSibling
|
||||
return next === null ? null : next.getBoundingClientRect().top - element.getBoundingClientRect().bottom
|
||||
})).toBe(12)
|
||||
})).toBe(16)
|
||||
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/step-process/collapsed.md', import.meta.url)),
|
||||
await group.ariaSnapshot(), webSnapshotMode())
|
||||
await toggle.click()
|
||||
const body = group.locator('[data-step-process-body]')
|
||||
expect(await body.getAttribute('hidden')).toBeNull()
|
||||
expect(await toggle.evaluate(element => getComputedStyle(element).paddingBottom)).toBe('12px')
|
||||
await expect.poll(() => activityIcon.evaluate(element => getComputedStyle(element).opacity)).toBe('0')
|
||||
await expect.poll(() => chevron.evaluate(element => getComputedStyle(element).opacity)).toBe('1')
|
||||
expect(await chevron.locator('path').getAttribute('d'))
|
||||
.toBe('M12 10L8.70711 6.70711C8.31658 6.31658 7.68342 6.31658 7.29289 6.70711L4 10')
|
||||
expect(await toggle.evaluate(element => getComputedStyle(element).paddingBottom)).toBe('16px')
|
||||
const leafSpacing = await body.evaluate((element) => {
|
||||
const rows = [...element.children].filter(row => !row.hasAttribute('hidden'))
|
||||
return rows.slice(1).map((row, index) => ({
|
||||
@@ -85,6 +89,10 @@ it('keeps completed step work collapsed inside an expanded turn and bounds its s
|
||||
expect(leafSpacing).toEqual([{ gap: 8, shrink: '0' }, { gap: 8, shrink: '0' }])
|
||||
// Expand the individual tool cards so their output exceeds the process viewport.
|
||||
for (const row of await body.locator('[data-sample="bash"]').all()) await row.click()
|
||||
await body.evaluate((element) => {
|
||||
element.scrollTop = 0
|
||||
element.dispatchEvent(new Event('scroll'))
|
||||
})
|
||||
await expect.poll(() => body.getAttribute('data-scroll-up')).toBeNull()
|
||||
await expect.poll(() => body.getAttribute('data-scroll-down')).toBe('true')
|
||||
await page.screenshot({ path: '/tmp/dsh-step-process-expanded.png' })
|
||||
@@ -123,6 +131,108 @@ it('keeps completed step work collapsed inside an expanded turn and bounds its s
|
||||
}
|
||||
})
|
||||
|
||||
it('chains wheel scrolling from a secondary process range to the conversation', async () => {
|
||||
const fixture = createChatScrollFixture({
|
||||
markerPrefix: 'STEP_PROCESS_SCROLL', title: 'Step process scrolling', turns: 96,
|
||||
})
|
||||
const scaffold = await launchWebScaffold({})
|
||||
try {
|
||||
await seedSession(scaffold, fixture.log, 'step-process-scroll-e2e')
|
||||
const browser = await chromium.launch()
|
||||
try {
|
||||
const page = await newEnglishPage(browser, 900)
|
||||
await page.goto(scaffold.authenticatedUrl)
|
||||
await page.getByText('Ungrouped', { exact: true }).waitFor()
|
||||
await page.getByRole('button', { name: 'Search sessions' }).click()
|
||||
await page.getByRole('textbox', { name: 'Search sessions...', exact: true }).fill(fixture.markers.user(1))
|
||||
const results = page.getByRole('tree', { name: 'Search results' }).getByRole('treeitem')
|
||||
await expect.poll(() => results.count(), { timeout: 60_000 }).toBe(1)
|
||||
await results.click()
|
||||
|
||||
const outer = page.locator('[data-turn-process="88"]')
|
||||
await outer.click()
|
||||
const group = page.locator('[data-step-process][data-chat-turn="88"]').first()
|
||||
const toggle = group.getByRole('button', { name: 'Ran commands', exact: true })
|
||||
await toggle.click()
|
||||
const body = group.locator('[data-step-process-body]')
|
||||
const host = page.locator('[data-conversation-scroll]')
|
||||
const wheelBody = async (deltaY: number): Promise<void> => {
|
||||
const box = await body.boundingBox()
|
||||
if (box === null) throw new Error('step process body has no layout box')
|
||||
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
|
||||
await page.mouse.wheel(0, deltaY)
|
||||
}
|
||||
|
||||
const shortGeometry = await body.evaluate((element) => {
|
||||
const scrollHost = element.closest<HTMLElement>('[data-conversation-scroll]')
|
||||
if (scrollHost === null) throw new Error('step process body has no conversation scrollport')
|
||||
return {
|
||||
hostMax: scrollHost.scrollHeight - scrollHost.clientHeight,
|
||||
hostTop: scrollHost.scrollTop,
|
||||
scrollable: element.scrollHeight > element.clientHeight,
|
||||
}
|
||||
})
|
||||
expect(shortGeometry.scrollable).toBe(false)
|
||||
expect(shortGeometry.hostTop).toBeGreaterThan(120)
|
||||
expect(shortGeometry.hostMax - shortGeometry.hostTop).toBeGreaterThan(120)
|
||||
await wheelBody(-120)
|
||||
await expect.poll(() => host.evaluate(element => element.scrollTop)).toBeLessThan(shortGeometry.hostTop)
|
||||
const shortHostAfterUp = await host.evaluate(element => element.scrollTop)
|
||||
await wheelBody(120)
|
||||
await expect.poll(() => host.evaluate(element => element.scrollTop)).toBeGreaterThan(shortHostAfterUp)
|
||||
|
||||
for (const row of await body.locator('[data-sample="bash"]').all()) await row.click()
|
||||
await body.hover()
|
||||
const middle = await body.evaluate((element) => {
|
||||
const scrollHost = element.closest<HTMLElement>('[data-conversation-scroll]')
|
||||
if (scrollHost === null) throw new Error('step process body has no conversation scrollport')
|
||||
element.scrollTop = (element.scrollHeight - element.clientHeight) / 2
|
||||
element.dispatchEvent(new Event('scroll'))
|
||||
return {
|
||||
bodyTop: element.scrollTop,
|
||||
hostTop: scrollHost.scrollTop,
|
||||
scrollable: element.scrollHeight > element.clientHeight,
|
||||
}
|
||||
})
|
||||
expect(middle.scrollable).toBe(true)
|
||||
await wheelBody(80)
|
||||
await expect.poll(() => body.evaluate(element => element.scrollTop)).toBeGreaterThan(middle.bodyTop)
|
||||
expect(await host.evaluate(element => element.scrollTop)).toBe(middle.hostTop)
|
||||
|
||||
await body.evaluate((element) => {
|
||||
element.scrollTop = 0
|
||||
element.dispatchEvent(new Event('scroll'))
|
||||
})
|
||||
await body.hover()
|
||||
const hostBeforeTopChain = await host.evaluate(element => element.scrollTop)
|
||||
await wheelBody(-120)
|
||||
await expect.poll(() => host.evaluate(element => element.scrollTop)).toBeLessThan(hostBeforeTopChain)
|
||||
expect(await body.evaluate(element => element.scrollTop)).toBe(0)
|
||||
|
||||
await body.evaluate((element) => {
|
||||
element.scrollTop = element.scrollHeight
|
||||
element.dispatchEvent(new Event('scroll'))
|
||||
})
|
||||
await body.hover()
|
||||
const bottomEdge = await body.evaluate((element) => {
|
||||
const scrollHost = element.closest<HTMLElement>('[data-conversation-scroll]')
|
||||
if (scrollHost === null) throw new Error('step process body has no conversation scrollport')
|
||||
return {
|
||||
hostTop: scrollHost.scrollTop,
|
||||
maxBodyTop: element.scrollHeight - element.clientHeight,
|
||||
}
|
||||
})
|
||||
await wheelBody(120)
|
||||
await expect.poll(() => host.evaluate(element => element.scrollTop)).toBeGreaterThan(bottomEdge.hostTop)
|
||||
expect(await body.evaluate(element => element.scrollTop)).toBe(bottomEdge.maxBodyTop)
|
||||
} finally {
|
||||
await browser.close()
|
||||
}
|
||||
} finally {
|
||||
await scaffold.close()
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a waking notice above and independent of the turn disclosure', async () => {
|
||||
const fixture = createChatScrollFixture({ markerPrefix: 'TURN_TRIGGER', title: 'Background task notice', turns: 1,
|
||||
wakingInput: { source: { kind: 'plugin', plugin: 'tool-jobs', form: 'notice', summary: 'PR merge check completed' },
|
||||
|
||||
@@ -69,9 +69,9 @@ export async function expandTurnProcesses(page: Page): Promise<void> {
|
||||
}
|
||||
|
||||
/**
|
||||
* Expand the Turn-process group containing one possibly hidden descendant.
|
||||
* Expand the Turn and secondary process groups containing a possibly hidden descendant.
|
||||
* @param page - page containing the Chat view.
|
||||
* @param target - descendant whose owning Turn process should open.
|
||||
* @param target - descendant whose owning process groups should open.
|
||||
*/
|
||||
export async function expandOwningTurnProcess(page: Page, target: Locator): Promise<void> {
|
||||
const turn = await target.evaluate(element => element.closest<HTMLElement>('[data-chat-turn]')?.dataset.chatTurn)
|
||||
@@ -79,6 +79,11 @@ export async function expandOwningTurnProcess(page: Page, target: Locator): Prom
|
||||
const control = page.locator(`[data-turn-process="${turn}"]`)
|
||||
await control.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
if (await control.getAttribute('aria-expanded') !== 'true') await control.click()
|
||||
if (await target.isVisible()) return
|
||||
const group = target.locator('xpath=ancestor::*[@data-step-process][1]')
|
||||
if (await group.count() === 0) return
|
||||
const stepControl = group.locator(':scope > button').first()
|
||||
if (await stepControl.getAttribute('aria-expanded') !== 'true') await stepControl.click()
|
||||
}
|
||||
|
||||
/** Fail loud on a stale checkout instead of testing yesterday's bundle. */
|
||||
|
||||
@@ -244,14 +244,14 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
|
||||
|
||||
await page.getByRole('button', { name: 'Settings', exact: true }).click()
|
||||
const dialog = page.getByRole('dialog', { name: 'Settings' })
|
||||
await dialog.getByRole('button', { name: 'Compact', exact: true }).click()
|
||||
await dialog.getByText('Work details', { exact: true }).waitFor()
|
||||
const workDetailsRow = dialog.getByText('Work details', { exact: true }).locator('xpath=../..')
|
||||
await workDetailsRow.getByRole('button', { name: 'Compact', exact: true }).click()
|
||||
await page.getByRole('menuitem', { name: 'Detailed', exact: true }).click()
|
||||
await page.keyboard.press('Escape')
|
||||
await expect.poll(() => process.count(), { timeout: 10_000 }).toBe(1)
|
||||
expect(await tool.isVisible()).toBe(false)
|
||||
await page.getByRole('button', { name: 'Settings', exact: true }).click()
|
||||
await dialog.getByRole('button', { name: 'Detailed', exact: true }).click()
|
||||
await workDetailsRow.getByRole('button', { name: 'Detailed', exact: true }).click()
|
||||
await page.getByRole('menuitem', { name: 'Expanded', exact: true }).click()
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
|
||||
@@ -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-chat/README.md
|
||||
README.md: 2619f78308189e223cad414b09afba7c35c6dc7a
|
||||
README.zh.md: 2cde88182c21131b1db00ad7822d5781d55f2bd4
|
||||
README.md: 429f4d43b834e42a6f43c61edbc7f2a022ed7fa5
|
||||
README.zh.md: 68f0f1f55c5616846c8e4edea5dbeed08728b048
|
||||
@@ -57,7 +57,7 @@ The completed-turn action footer sits at the bottom of its Turn, 20px below the
|
||||
|
||||
Each reasoning row starts collapsed, including during streaming and in reasoning-only replies. Clicking the row opens or closes its complete Markdown; incoming answer text, Tool calls, and stream completion preserve that choice. Expanded reasoning uses compact secondary typography: headings add bold weight without changing text size, line height, or color. During streaming, the collapsed preview advances only after a paragraph's first line completes; an unfinished first line does not replace the current preview. The preview starts at its left edge and fades under a right-edge mask when it exceeds the available width. After settlement, Compact shows only `Think`, while Detailed and Expanded preview the first line.
|
||||
|
||||
Settings → General exposes the persisted `Work details` preference in the `ui-chat` namespace: `Compact` (default), `Detailed`, and `Expanded`. All three modes retain the whole-Turn disclosure and default completed Turns to collapsed. Detailed defaults step groups to collapsed and allows manual toggles. Expanded renders steps directly inside the whole-Turn disclosure, without secondary titles, disclosure controls, or bounded group scroll areas. Each secondary title starts with its matching existing Tool icon; the command category uses Bash's `IconApiOutlineRegular`. Hover or keyboard focus replaces that leading icon in place with a down chevron while collapsed or an up chevron while expanded, with no trailing chevron; hovering an enabled whole-Turn or secondary disclosure title darkens its text. A collapsed secondary title has no vertical padding and sits 12px from adjacent Assistant messages; an expanded title sits 12px above its first process row, and adjacent process rows are 8px apart. The saved `transcriptView` field accepts legacy `normal`, which the client reads as `expanded` without rewriting storage; explicit choices save only the three current values. System prompts, ordinary context injections, and permission commands have no Chat row in any Work details mode. Non-human turn-trigger notices remain visible above the whole-Turn control; hidden metadata does not create process groups or collapse arrows. In Compact and Detailed, consecutive process rows between Assistant responses default to collapsed, including reasoning preceding response text in the same node. The live title reports the latest running tool category, otherwise analysis activity, and carries a repeating text shimmer until the process range closes. Each displayed live title remains for at least 150ms; changes during that interval coalesce to the latest title instead of queuing intermediate tools. Detailed appends its command, search queries, URL, file path, or description after a space; reasoning text is excluded and completed titles omit these details. Retry rows remain independent of secondary process groups. A following Assistant response closes the range as soon as visible reply content starts. Completed titles aggregate distinct calls by category, including repeated paths, and show the three most frequent categories without counts; ties retain first appearance order. Two categories use “and” (Chinese “并”, sharing an initial “已” only when both phrases have it); three use commas, and more than three add “etc.” (Chinese “等”). English continuation phrases start lowercase. With no tool statistics, completion uses the analysis fallback. System prompts, context injection, and permission metadata never contribute categories. Secondary expanded ranges scroll within the smaller of 400px and half the viewport height; a 24px fade marks each direction with hidden overflow and disappears independently at the corresponding scroll edge. At `turn/end`, its latest Step becomes the final-answer boundary only when it contains non-blank text, an image, or an unknown visible block—and no Tool-call block; preceding reasoning, earlier Assistant material, Tool rows, and Retry rows then collapse by default. The control appears at `turn/start` and stays expanded and disabled during execution. It displays `Deep diving for …` (Chinese `深度求索中,用时…`) with a minimum of 1s and no leading icon. Running, completed, stopped, and failed whole-Turn labels all use tertiary text at one pixel below the normal status size. The live clock refreshes once per second, shows whole seconds without padding single-digit seconds, and changes from minutes to hours at exactly 60 minutes. Compact, Detailed, and Expanded use this whole-Turn control as the only turn-level running label. Normal completion fixes the duration at a minimum of 1s, displays `Took …` (Chinese `用时…`), and defaults to collapsed. Cancellation displays `Stopped` (Chinese `已停止`); failure displays `Failed` (Chinese `处理失败`). Running, cancelled, and failed Turns stay expanded with a disabled control and no chevron. Missing start timestamps omit the duration. A Turn with no process rows or inline reasoning keeps the elapsed label but hides its chevron and disables disclosure. A full-width divider below the summary separates it from the answer or expanded process rows. User and steering messages, error, max-token, and turn-tail rows stay outside, and a closed Turn with no final answer folds all process rows while keeping independent rows visible. A newly available process control is inserted without changing the relative order of existing rows: opening human input precedes the control and process rows from their first projection; System prompt rows are not rendered. While older history remains available through Load earlier, closed Turns whose `turn/start` is loaded can fold independently, including Turns awakened by context injection. A Turn misLine truncated
|
||||
Settings → General exposes the persisted `Work details` preference in the `ui-chat` namespace: `Compact` (default), `Detailed`, and `Expanded`. All three modes retain the whole-Turn disclosure and default completed Turns to collapsed. Detailed defaults step groups to collapsed and allows manual toggles. Expanded renders steps directly inside the whole-Turn disclosure, without secondary titles, disclosure controls, or bounded group scroll areas. Each secondary title starts with its matching existing Tool icon; the command category uses Bash's `IconApiOutlineRegular`. Hover or keyboard focus replaces that leading icon in place with a down chevron while collapsed; an expanded title keeps an up chevron visible, with no trailing chevron. Hovering an enabled whole-Turn or secondary disclosure title darkens its text. A collapsed secondary title has no vertical padding and sits 16px from adjacent Assistant messages; an expanded title sits 16px above its first process row, and adjacent process rows are 8px apart. The saved `transcriptView` field accepts legacy `normal`, which the client reads as `expanded` without rewriting storage; explicit choices save only the three current values. System prompts, ordinary context injections, and permission commands have no Chat row in any Work details mode. Non-human turn-trigger notices remain visible above the whole-Turn control; hidden metadata does not create process groups or collapse arrows. In Compact and Detailed, consecutive process rows between Assistant responses default to collapsed, including reasoning preceding response text in the same node. The live title reports the latest running tool category, otherwise analysis activity, and carries a repeating ease-in-out text shimmer with a 500ms pause after each pass until the process range closes. Each displayed live title remains for at least 150ms; changes during that interval coalesce to the latest title instead of queuing intermediate tools. Detailed places a concise current-call detail to the right of the category, preferring an authored title, description, objective, task, question, command, query, URL, or path and falling back to the exact tool name. While no tool is running, it shows the latest live reasoning paragraph instead. Details are whitespace-normalized and capped at 160 characters; completed titles omit them. Retry rows remain independent of secondary process groups. A following Assistant response closes the range as soon as visible reply content starts. Completed titles aggregate distinct calls by category, including repeated paths, and show the three most frequent categories without counts; ties retain first appearance order. Two categories use “and” (Chinese “并”, sharing an initial “已” only when both phrases have it); three use commas, and more than three add “etc.” (Chinese “等”). English continuation phrases start lowercase. With no tool statistics, completion uses the analysis fallback. System prompts, context injection, and permission metadata never contribute categories. Secondary expanded ranges scroll within the smaller of 400px and half the viewport height; a 24px fade marks each direction with hidden overflow and disappears independently at the corresponding scroll edge. Wheel and touchpad scrolling stays with the range while it can move, then continues the conversation when the range is too short to scroll or reaches either edge. At `turn/end`, its latest Step becomes the final-answer boundary only when it contains non-blank text, an image, or an unknown visible block—and no Tool-call block; preceding reasoning, earlier Assistant material, Tool rows, and Retry rows then collapse by default. The control appears at `turn/start` and stays expanded and disabled during execution. It displays `Deep diving for …` (Chinese `深度求索中,用时…`) with a minimum of 1s and no leading icon. Running, completed, stopped, and failed whole-Turn labels all use tertiary text at one pixel below the normal status size. The live clock refreshes once per second, shows whole seconds without padding single-digit seconds, and changes from minutes to hours at exactly 60 minutes. Compact, Detailed, and Expanded use this whole-Turn control as the only turn-level running label. Normal completion fixes the duration at a minimum of 1s, displays `Took …` (Chinese `用时…`), and defaults to collapsed. Cancellation displays `Stopped` (Chinese `已停止`); failure displays `Failed` (Chinese `处理失败`). Running, cancelled, and failed Turns stay expanded with a disabled control and no chevron. Missing start timestamps omit the duration. A Turn with no process rows or inline reasoning keeps the elapsed label but hides its chevron and disables disclosure. A full-width divider below the summary separates it from the answer or expanded process rows. User and steering messages, error, max-token, and turn-tail rows stay outside, and a closed Turn with no final answer folds all processLine truncated
|
||||
|
||||
-----
|
||||
|
||||
@@ -108,4 +108,4 @@ None.
|
||||
|
||||
**Runtime invariant:** No companion is published. Conversation and Slot registration enforce Chat target consistency.
|
||||
|
||||
Non-human input claimed from the next-turn Inbox appears as an independent trigger notice above the Turn control. A first-step next-step claim also qualifies when that Turn has no next-turn claim or human input in its next-step batch. Ordinary pre-step injections remain context rows. The compact outlined header shows a source-family icon, title, and subdued time without repeating a subject, and the whole notice highlights on hover. The notice stays visible in every work-details mode, defaults to collapsed, and expands independently of process folding to show a short explanation and the original model-facing content without source metadata. Titles recognize schedules, jobs, goals, agent/team messages, webhooks, and Cordis runner outcomes; each recognized source family uses its matching primitive icon, and unknown sources or status formats retain the context-injection fallback. User-attributed SDK and delegated inputs remain user messages because the log does not distinguish their caller.
|
||||
Non-human input claimed from the next-turn Inbox appears as an independent trigger notice above the Turn control. A first-step next-step claim also qualifies when that Turn has no next-turn claim or human input in its next-step batch. Ordinary pre-step injections remain context rows. The compact outlined header shows a source-family icon, title, and subdued time without repeating a subject, and the whole notice highlights on hover. The notice stays visible in every work-details mode, defaults to collapsed, and expands independently of process folding to show a short explanation and the original model-facing content without source metadata. Titles recognize schedules, jobs, goals, agent/team messages, webhooks, and Cordis runner outcomes; task messages use the paper-plane icon, each other recognized source family uses its matching primitive icon, and unknown sources or status formats retain the context-injection fallback. User-attributed SDK and delegated inputs remain user messages because the log does not distinguish their caller.
|
||||
@@ -59,7 +59,7 @@ kind: "package-reference"
|
||||
|
||||
每个思考行初始都折叠,包括流式输出期间和仅含思考的回复。点击该行可展开或收起完整 Markdown;后续回答文本、工具调用和流结束都保留用户的选择。展开的思考采用紧凑的次级排版:标题只加粗,不改变字号、行高或颜色。流式生成期间,收起预览仅在某段第一行结束后切换;尚未结束的第一行不会替换当前预览。预览从左侧起始,超出可用宽度的内容在右缘渐隐。结算后,简洁模式只显示「思考」,详细和完全展开模式显示首行预览。
|
||||
|
||||
「设置 → 通用设置」提供持久化到 `ui-chat` 命名空间的「工作过程展示」偏好:简洁(默认)、详细、完全展开。三种模式均保留一级整轮折叠,已完成轮次默认收起。详细默认收起步骤组,允许手动切换;完全展开在一级折叠内直接平铺步骤,不显示二级标题、折叠控件或限高的组内滚动区。每个二级标题默认以前置的现有工具图标表示类别,其中命令类别使用 Bash 的 `IconApiOutlineRegular`。悬停或键盘聚焦时,该前置图标原位切换为箭头:收起时朝下,展开时朝上,标题尾部不再显示箭头;悬停可用的整轮或二级折叠标题时,文字颜色加深。收起的二级标题不带垂直内边距,与相邻 Assistant 消息相隔 12px;展开标题与首个过程行相隔 12px,相邻过程行相隔 8px。持久化字段 `transcriptView` 接受旧值 `normal`,客户端将其读取为 `expanded`,不主动改写存储;用户选择后仅保存三个新值。所有工作过程展示模式均不显示系统提示词、普通上下文注入和 permission 命令行。非人工轮次发起提示仍独立显示在一级折叠上方;隐藏的元数据不产生过程组或折叠箭头。简洁与详细模式下,Assistant 回复之间的连续过程行都默认收起,同一节点内正文之前的推理也归入过程组。进行中的标题显示最新运行工具的类别,没有运行工具时显示分析阶段,并在过程组关闭前循环显示文字扫光。每个已显示的进行中标题至少保留 150ms;这段时间内的变化只保留最新标题,不排队补播中间工具。详细模式在类别后用空格追加该工具的命令、搜索词、URL、文件路径或描述,不包含思考正文,完成后不再显示这些细节。重试行独立展示,不归入二级过程组。下一条 Assistant 回复开始输出可见正文时,该组转为完成。完成标题按类别统计不同调用,同一路径的多次调用分别计数,显示调用最多的前三类但不显示数量;次数相同按首次出现顺序排列。两类用「并」(英文为 “and”)连接,仅当两项均以「已」开头时共享首个「已」;三类用逗号连接,超过三类追加「等」(英文为 “etc.”)。英文后续动作短语首字母小写。没有工具统计时,使用分析完成的回退文案。系统提示词、上下文注入与权限元数据不计入类别。二级展开区在 400px 与视口高度的一半之中取较小值作为最大高度;存在隐藏内容的方向显示 24px 渐隐遮罩,到达对应滚动边缘时该侧遮罩消失。到 `turn/end` 时,最后一个步骤只有在包含非空文本、图片或未知可见块且不含工具调用块时才成为最终答案边界;边界之前的推理、较早 Assistant 内容、工具行与重试行随后默认收起。控件在 `turn/start` 时出现,执行期间始终展开且不可点击。标题文案为「深度求索中,用时…」(英文为 `Deep diving for …`),最少显示 1 秒且不带前置图标。运行、完成、停止和失败的整轮状态文案均使用 tertiary 灰度,字号比普通状态小 1px。运行时钟每秒更新,显示整数秒且个位秒不补零,满 60 分钟切换为小时。简洁、详细和完全展开均只使用该整轮控件显示轮次运行状态。正常完成后固定时长,最少显示 1 秒,显示「用时…」(英文为 `Took …`),默认收起。取消后显示「已停止」(英文为 `Stopped`);失败后显示「处理失败」(英文为 `Failed`)。进行中、取消或失败的轮次始终展开,控件不可点击且不显示箭头。缺少起点时间戳时省略时长。没有过程行或内联推理的轮次保留用时标题,但隐藏箭头并禁用折叠。摘要下方的通栏分隔线将其与正文或展开后的过程行隔开。用户与 steering 消息、错误、最大 token 与 turn-tail 行留在过程组外;关闭时没有最终答案的轮次会收起所有过程行,独立行保持可见。新的过程控件插入时不会改变既有行的相对顺序:开场人工输入从首次投影起便位于控件和过程行之前,系统提示词行不显示。仍可通过「加载更早」获取历史时,已加载 `turn/start` 的已关闭轮次可以独立折叠,包括由上下文注入唤起的轮次。缺少起点的轮次会等待分页补齐起点或加载完历史;其他尚未加载的轮次不会阻止折叠。稳定 Chat Node Seat 保持行的位置,隐藏成员不产生消息流间距;只有中间没有独立输入时,收起控件才与正文相隔 8px。完成后的收起不依赖是否跟随尾部,因此正在上方阅读的用户可能看到 transcript 重排。若自动收起会隐藏当前键盘焦点,则过程组保持展开且焦点留在原处;手动收起会先把焦点移到过程Line truncated
|
||||
「设置 → 通用设置」提供持久化到 `ui-chat` 命名空间的「工作过程展示」偏好:简洁(默认)、详细、完全展开。三种模式均保留一级整轮折叠,已完成轮次默认收起。详细默认收起步骤组,允许手动切换;完全展开在一级折叠内直接平铺步骤,不显示二级标题、折叠控件或限高的组内滚动区。每个二级标题默认以前置的现有工具图标表示类别,其中命令类别使用 Bash 的 `IconApiOutlineRegular`。收起时,悬停或键盘聚焦会把前置图标原位替换为向下箭头;展开后持续显示向上箭头,标题尾部不再显示箭头。悬停可用的整轮或二级折叠标题时,文字颜色加深。收起的二级标题不带垂直内边距,与相邻 Assistant 消息相隔 16px;展开标题与首个过程行相隔 16px,相邻过程行相隔 8px。持久化字段 `transcriptView` 接受旧值 `normal`,客户端将其读取为 `expanded`,不主动改写存储;用户选择后仅保存三个新值。所有工作过程展示模式均不显示系统提示词、普通上下文注入和 permission 命令行。非人工轮次发起提示仍独立显示在一级折叠上方;隐藏的元数据不产生过程组或折叠箭头。简洁与详细模式下,Assistant 回复之间的连续过程行都默认收起,同一节点内正文之前的推理也归入过程组。进行中的标题显示最新运行工具的类别,没有运行工具时显示分析阶段,并在过程组关闭前循环显示采用 ease-in-out 曲线、每轮结束后停顿 500ms 的文字扫光。每个已显示的进行中标题至少保留 150ms;这段时间内的变化只保留最新标题,不排队补播中间工具。详细模式在类别右侧显示简短的当前调用详情,优先采用调用方给出的标题、描述、目标、任务、问题、命令、查询词、URL 或路径,没有可用详情时回退到原始工具名;没有工具运行时改为显示最新的实时思考段落。详情会合并空白并截断到 160 个字符;完成标题不保留进行中详情。重试行独立展示,不归入二级过程组。下一条 Assistant 回复开始输出可见正文时,该组转为完成。完成标题按类别统计不同调用,同一路径的多次调用分别计数,显示调用最多的前三类但不显示数量;次数相同按首次出现顺序排列。两类用「并」(英文为 “and”)连接,仅当两项均以「已」开头时共享首个「已」;三类用逗号连接,超过三类追加「等」(英文为 “etc.”)。英文后续动作短语首字母小写。没有工具统计时,使用分析完成的回退文案。系统提示词、上下文注入与权限元数据不计入类别。二级展开区在 400px 与视口高度的一半之中取较小值作为最大高度;存在隐藏内容的方向显示 24px 渐隐遮罩,到达对应滚动边缘时该侧遮罩消失。滚轮和触控板滚动会在区内仍可滚动时留在该区域;区域无需滚动或到达任一边界后,滚动继续作用于整个对话。到 `turn/end` 时,最后一个步骤只有在包含非空文本、图片或未知可见块且不含工具调用块时才成为最终答案边界;边界之前的推理、较早 Assistant 内容、工具行与重试行随后默认收起。控件在 `turn/start` 时出现,执行期间始终展开且不可点击。标题文案为「深度求索中,用时…」(英文为 `Deep diving for …`),最少显示 1 秒且不带前置图标。运行、完成、停止和失败的整轮状态文案均使用 tertiary 灰度,字号比普通状态小 1px。运行时钟每秒更新,显示整数秒且个位秒不补零,满 60 分钟切换为小时。简洁、详细和完全展开均只使用该整轮控件显示轮次运行状态。正常完成后固定时长,最少显示 1 秒,显示「用时…」(英文为 `Took …`),默认收起。取消后显示「已停止」(英文为 `Stopped`);失败后显示「处理失败」(英文为 `Failed`)。进行中、取消或失败的轮次始终展开,控件不可点击且不显示箭头。缺少起点时间戳时省略时长。没有过程行或内联推理的轮次保留用时标题,但隐藏箭头并禁用折叠。摘要下方的通栏分隔线将其与正文或展开后的过程行隔开。用户与 steering 消息、错误、最大 token 与 turn-tail 行留在过程组外;关闭时没有最终答案的轮次会收起所有过程行,独立行保持可见。新的过程控件插入时不会改变既有行的相对顺序:开场人工输入从首次投影起便位于控件和过程行之前,系统提示词行不显示。仍可通过「加载更早」获取历史时,已加载 `turn/start` 的已关闭轮次可以独立折叠,包括由上下文注入唤起的轮次。缺少起点的轮次会等待分页补齐起点或加载完历史;其他尚未Line truncated
|
||||
|
||||
-----
|
||||
|
||||
@@ -110,4 +110,4 @@ Chat 会在历史前插与 renderer 重新挂载时恢复语义锚点。没有
|
||||
|
||||
**运行时不变式:** 不发布伴生入口。Conversation 与 slot 注册已经强制 Chat target 一致。
|
||||
|
||||
从 next-turn Inbox 领取的非人工输入在轮次控件上方显示独立触发提示。第一步领取的 next-step 输入在本轮没有 next-turn 领取、且同批没有人工输入时也显示提示。普通 pre-step 注入仍保留为上下文行。紧凑的浅描边标题行只显示来源类别图标、标题与弱化时间,不重复摘要;悬停时整条提示高亮。提示在所有工作过程展示模式下均可见,默认收起,并独立于过程折叠显示简短解释及模型实际收到的原始内容,不显示来源元数据。标题识别定时任务、后台任务、目标、任务或团队消息、Webhook 与 Cordis runner 结果;每类已识别来源使用对应的 primitive 图标,未知来源或状态格式使用上下文注入图标作为回退。归因为 user 的 SDK 或委派输入仍作为用户消息展示,因为日志无法区分其调用方。
|
||||
从 next-turn Inbox 领取的非人工输入在轮次控件上方显示独立触发提示。第一步领取的 next-step 输入在本轮没有 next-turn 领取、且同批没有人工输入时也显示提示。普通 pre-step 注入仍保留为上下文行。紧凑的浅描边标题行只显示来源类别图标、标题与弱化时间,不重复摘要;悬停时整条提示高亮。提示在所有工作过程展示模式下均可见,默认收起,并独立于过程折叠显示简短解释及模型实际收到的原始内容,不显示来源元数据。标题识别定时任务、后台任务、目标、任务或团队消息、Webhook 与 Cordis runner 结果;任务消息使用纸飞机图标,其他已识别来源使用对应的 primitive 图标,未知来源或状态格式使用上下文注入图标作为回退。归因为 user 的 SDK 或委派输入仍作为用户消息展示,因为日志无法区分其调用方。
|
||||
@@ -53,12 +53,12 @@
|
||||
|
||||
.column > [data-step-process]:not([hidden])
|
||||
+ [data-chat-flow-kind='assistant-step']:not([hidden]) {
|
||||
--dsh-chat-flow-gap: 12px;
|
||||
--dsh-chat-flow-gap: 16px;
|
||||
}
|
||||
|
||||
.column > [data-chat-flow-kind='assistant-step']:not([hidden])
|
||||
+ [data-step-process]:not([hidden]) {
|
||||
--dsh-chat-flow-gap: 12px;
|
||||
--dsh-chat-flow-gap: 16px;
|
||||
}
|
||||
|
||||
/* Capped process scrollports retain the same row spacing without shrinking leaves. */
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
// An enclosing `[data-conversation-scroll]` owns scrolling when present;
|
||||
// otherwise this view owns it. Each row subscribes to one stable node key.
|
||||
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type {
|
||||
ConversationTimelineSnapshot, RenderMessageImages,
|
||||
} from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import { useCallback, useMemo, useRef, useState } from 'react'
|
||||
import type { RenderMessageImages } from '@deepseek-ai/dsh-client-ui-conversation/client'
|
||||
import type { InboxState } from '@deepseek-ai/dsh-agent/types'
|
||||
import {
|
||||
Button, IconChevronDownOutlineRegular, MarkdownDelegateProvider, Modal,
|
||||
@@ -16,7 +14,6 @@ import { ChatNodeList } from './StepProcessList.tsx'
|
||||
import { TurnNavigator } from './TurnNavigator.tsx'
|
||||
import { mergeTurnRailItems } from './turn-rail-items.ts'
|
||||
import { useChatScroll } from './use-chat-scroll.ts'
|
||||
import { formatRunDuration } from './message-chrome.ts'
|
||||
import css from './ChatView.module.css'
|
||||
|
||||
/** Host/OS refusal text for the file-open dialog; empty throws keep a locale fallback. */
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: opacity 100ms ease, transform 100ms ease;
|
||||
transition: opacity 100ms ease;
|
||||
}
|
||||
|
||||
.activityIcon {
|
||||
@@ -59,19 +59,23 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.title[aria-expanded="true"] .activityIcon {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.title[aria-expanded="true"] .chevron {
|
||||
transform: rotate(180deg);
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.title[aria-expanded="true"] {
|
||||
padding-bottom: 12px;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
|
||||
.body {
|
||||
--dsh-chat-flow-gap: 8px;
|
||||
max-height: min(400px, 50vh);
|
||||
overflow-y: auto;
|
||||
overscroll-behavior-y: contain;
|
||||
overscroll-behavior-y: auto;
|
||||
scrollbar-gutter: stable;
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
} from 'react'
|
||||
import {
|
||||
IconAgentPresetOutlineRegular, IconApiOutlineRegular, IconBrowseOutlineRegular, IconChevronDownOutlineRegular,
|
||||
IconChevronUpOutlineRegular,
|
||||
IconCodeOutlineRegular, IconEditOutlineRegular, IconGlobeOutlineRegular, IconPlanOutlineRegular,
|
||||
IconQuestionOutlineRegular, IconSearchOutlineRegular, IconSparkleRegular, IconThinkOutlineRegular, TextShimmer,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
@@ -160,7 +161,7 @@ function StepProcess({ range, nodes, ...seatProps }: SeatProps & {
|
||||
const detail = !seatProps.compactTranscript && !range.closed
|
||||
? liveTitle.detail
|
||||
: ''
|
||||
const title = detail === '' ? label : `${label} ${detail}`
|
||||
const title = detail === '' ? label : `${label}${t('message.turnProcess.separator')}${detail}`
|
||||
const activity = range.closed ? summary.counts[0]?.kind ?? 'thinking' : liveTitle.activity
|
||||
const bodyClasses = [css.body, flowCss.processBody]
|
||||
if (scrollEdges.canScrollUp) bodyClasses.push(css.fadeTop)
|
||||
@@ -173,7 +174,9 @@ function StepProcess({ range, nodes, ...seatProps }: SeatProps & {
|
||||
onClick={(event) => { event.currentTarget.focus(); setOpen(!open) }}>
|
||||
<span className={css.leading} aria-hidden="true">
|
||||
<span className={css.activityIcon} data-step-process-icon>{PROCESS_ICONS[activity]}</span>
|
||||
<span className={css.chevron} data-step-process-chevron><IconChevronDownOutlineRegular /></span>
|
||||
<span className={css.chevron} data-step-process-chevron>
|
||||
{open ? <IconChevronUpOutlineRegular /> : <IconChevronDownOutlineRegular />}
|
||||
</span>
|
||||
</span>
|
||||
{range.closed
|
||||
? <span className={css.label}>{title}</span>
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useId, useState, type ComponentType } from 'react'
|
||||
import {
|
||||
IconAgentPresetOutlineRegular, IconAlarmClockOutlineRegular, IconBranchOutlineRegular,
|
||||
IconChevronDownOutlineRegular, IconContextInjectionOutlineRegular, IconCordisPluginOutlineRegular,
|
||||
IconGoalOutlineRegular, IconGlobeOutlineRegular, IconQueueOutlineRegular, IconSendOutlineRegular,
|
||||
IconGoalOutlineRegular, IconGlobeOutlineRegular, IconPaperPlaneOutlineRegular, IconQueueOutlineRegular,
|
||||
type IconProps,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatNodeViewProps } from '../contract/slots.ts'
|
||||
@@ -14,7 +14,7 @@ import css from './TurnTriggerNodeView.module.css'
|
||||
const TRIGGER_ICONS: Record<TurnTriggerIcon, ComponentType<IconProps>> = {
|
||||
request: IconContextInjectionOutlineRegular,
|
||||
goal: IconGoalOutlineRegular,
|
||||
agent: IconSendOutlineRegular,
|
||||
agent: IconPaperPlaneOutlineRegular,
|
||||
team: IconAgentPresetOutlineRegular,
|
||||
subagent: IconAgentPresetOutlineRegular,
|
||||
github: IconBranchOutlineRegular,
|
||||
|
||||
@@ -88,32 +88,76 @@ function activity(name: string): ProcessActivity {
|
||||
return 'tools'
|
||||
}
|
||||
|
||||
function liveToolDetail(argsRaw: string): string {
|
||||
let args: unknown
|
||||
try {
|
||||
args = JSON.parse(argsRaw)
|
||||
} catch {
|
||||
// Partial argument JSON has no complete detail to preview.
|
||||
return ''
|
||||
const LIVE_TOOL_DETAIL_MAX_CHARS = 160
|
||||
const LIVE_TOOL_DETAIL_SEGMENTER = new Intl.Segmenter(undefined, { granularity: 'grapheme' })
|
||||
const LIVE_TOOL_DETAIL_KEYS = [
|
||||
'title', 'description', 'objective', 'task', 'task_name', 'name', 'question', 'questions', 'prompt', 'message',
|
||||
'command', 'cmd', 'queries', 'query', 'pattern', 'url', 'uri', 'file_path', 'path', 'target', 'action', 'status',
|
||||
] as const
|
||||
|
||||
function normalizeLiveToolDetail(value: unknown): string {
|
||||
const text = typeof value === 'string'
|
||||
? value
|
||||
: Array.isArray(value) && value.every(item => typeof item === 'string')
|
||||
? value.join(', ')
|
||||
: ''
|
||||
const normalized = text.replace(/\s+/g, ' ').trim()
|
||||
const chars = Array.from(LIVE_TOOL_DETAIL_SEGMENTER.segment(normalized), part => part.segment)
|
||||
return chars.length <= LIVE_TOOL_DETAIL_MAX_CHARS
|
||||
? normalized
|
||||
: `${chars.slice(0, LIVE_TOOL_DETAIL_MAX_CHARS - 1).join('').trimEnd()}…`
|
||||
}
|
||||
|
||||
function questionDetail(value: unknown): string {
|
||||
if (!Array.isArray(value)) return ''
|
||||
for (const item of value) {
|
||||
if (item === null || typeof item !== 'object') continue
|
||||
const detail = normalizeLiveToolDetail(Reflect.get(item, 'question'))
|
||||
if (detail !== '') return detail
|
||||
}
|
||||
if (args === null || typeof args !== 'object') return ''
|
||||
for (const key of ['command', 'cmd', 'queries', 'query', 'pattern', 'url', 'file_path', 'path', 'description']) {
|
||||
if (key in args) {
|
||||
const value: unknown = Reflect.get(args, key)
|
||||
if (Array.isArray(value) && value.every(item => typeof item === 'string')) {
|
||||
const detail = value.join(', ').replace(/\s+/g, ' ').trim()
|
||||
return ''
|
||||
}
|
||||
|
||||
function liveReasoningDetail(nodes: readonly ChatNode[]): string {
|
||||
for (let nodeIndex = nodes.length - 1; nodeIndex >= 0; nodeIndex--) {
|
||||
const node = nodes[nodeIndex]
|
||||
if (node?.kind !== 'assistant-step' || node.data.status !== 'running') continue
|
||||
for (let blockIndex = node.data.blocks.length - 1; blockIndex >= 0; blockIndex--) {
|
||||
const block = node.data.blocks[blockIndex]
|
||||
if (block?.kind !== 'reasoning') continue
|
||||
const paragraphs = block.text.split(/\r?\n[\t ]*\r?\n/)
|
||||
for (let paragraphIndex = paragraphs.length - 1; paragraphIndex >= 0; paragraphIndex--) {
|
||||
const detail = normalizeLiveToolDetail(paragraphs[paragraphIndex]?.replaceAll('**', ''))
|
||||
if (detail !== '') return detail
|
||||
}
|
||||
if (typeof value === 'string' && value.trim() !== '') return value.replace(/\s+/g, ' ').trim()
|
||||
}
|
||||
}
|
||||
return ''
|
||||
}
|
||||
|
||||
function liveToolDetail(name: string, argsRaw: string): string {
|
||||
let args: unknown
|
||||
try {
|
||||
args = JSON.parse(argsRaw)
|
||||
} catch {
|
||||
// Partial or free-form arguments have no safe one-line task detail.
|
||||
return normalizeLiveToolDetail(name)
|
||||
}
|
||||
if (args === null || typeof args !== 'object') return normalizeLiveToolDetail(name)
|
||||
for (const key of LIVE_TOOL_DETAIL_KEYS) {
|
||||
if (key in args) {
|
||||
const value: unknown = Reflect.get(args, key)
|
||||
const detail = key === 'questions' ? questionDetail(value) : normalizeLiveToolDetail(value)
|
||||
if (detail !== '') return detail
|
||||
}
|
||||
}
|
||||
return normalizeLiveToolDetail(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Rank categories by distinct call count, breaking ties by first appearance.
|
||||
* @param nodes - process members, including recursive tools.
|
||||
* @returns all ranked categories and the latest running tool category and argument preview.
|
||||
* @returns all ranked categories and the latest running tool category and bounded task detail.
|
||||
*/
|
||||
export function processActivity(nodes: readonly ChatNode[]): {
|
||||
counts: readonly { kind: ProcessActivity; count: number }[]
|
||||
@@ -133,7 +177,7 @@ export function processActivity(nodes: readonly ChatNode[]): {
|
||||
const kind = activity(call.name)
|
||||
if (isRunningTool(tool) && tool.time >= runningTime) {
|
||||
running = kind
|
||||
runningDetail = liveToolDetail(tool.argsRaw)
|
||||
runningDetail = liveToolDetail(tool.name, tool.argsRaw)
|
||||
runningTime = tool.time
|
||||
}
|
||||
counts.set(kind, (counts.get(kind) ?? 0) + 1)
|
||||
@@ -143,6 +187,7 @@ export function processActivity(nodes: readonly ChatNode[]): {
|
||||
for (const node of nodes) {
|
||||
if (node.kind === 'tool-call') visit(node.data.root)
|
||||
}
|
||||
if (running === undefined) runningDetail = liveReasoningDetail(nodes)
|
||||
return {
|
||||
counts: [...counts].map(([kind, count]) => ({ kind, count })).sort((a, b) => b.count - a.count),
|
||||
running,
|
||||
|
||||
@@ -667,10 +667,11 @@ describe('ChatView', () => {
|
||||
})
|
||||
|
||||
it.each([
|
||||
['bash', { command: 'git status', description: 'Inspect changes' }, '正在运行命令 git status'],
|
||||
['web_search', { queries: ['Cordis plugins', 'DSH tools'] }, '正在搜索网页 Cordis plugins, DSH tools'],
|
||||
['web_fetch', { url: 'https://example.com/docs' }, '正在访问网页 https://example.com/docs'],
|
||||
['read', { file_path: '/src/main.ts' }, '正在读取文件 /src/main.ts'],
|
||||
['bash', { command: 'git status', description: '检查当前改动' }, '正在运行命令 · 检查当前改动'],
|
||||
['web_search', { queries: ['Cordis plugins', 'DSH tools'] }, '正在搜索网页 · Cordis plugins, DSH tools'],
|
||||
['web_fetch', { url: 'https://example.com/docs' }, '正在访问网页 · https://example.com/docs'],
|
||||
['read', { file_path: '/src/main.ts' }, '正在读取文件 · /src/main.ts'],
|
||||
['custom_tool', {}, '正在调用工具 · custom_tool'],
|
||||
])('shows live %s details only in Detailed secondary titles', (name, args, expected) => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(10_000)
|
||||
@@ -698,7 +699,7 @@ describe('ChatView', () => {
|
||||
})
|
||||
expect(title()).toBe(expected)
|
||||
act(() => { vi.advanceTimersByTime(150) })
|
||||
expect(title()).toBe('正在分析请求')
|
||||
expect(title()).toBe('正在分析请求 · private reasoning')
|
||||
} finally {
|
||||
cleanup()
|
||||
vi.useRealTimers()
|
||||
@@ -765,6 +766,8 @@ describe('ChatView', () => {
|
||||
expect(toggle.textContent).not.toMatch(/context|上下文|permission|系统提示/)
|
||||
fireEvent.click(toggle)
|
||||
expect(body.hasAttribute('hidden')).toBe(false)
|
||||
expect(toggle.querySelector('[data-step-process-chevron] path')?.getAttribute('d'))
|
||||
.toBe('M12 10L8.70711 6.70711C8.31658 6.31658 7.68342 6.31658 7.29289 6.70711L4 10')
|
||||
act(() => { h.setChat({
|
||||
nodes: [userInTurn(1, 'inspect', 2), context(2, 'private runtime context', 2), reasoningAssistant(2.5, 'initial reasoning', 2), toolResult(3, 'work')],
|
||||
runningCalls: [],
|
||||
@@ -1758,6 +1761,7 @@ describe('ChatView', () => {
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
const toggle = view.getByRole('button', { name: 'Deep diving for 1s' })
|
||||
expect(view.queryByRole('status')).toBeNull()
|
||||
expect(toggle.querySelector('[data-text-shimmer]')).toBeNull()
|
||||
expect(toggle.getAttribute('aria-expanded')).toBeNull()
|
||||
act(() => { vi.advanceTimersByTime(2_000) })
|
||||
expect(toggle.textContent).toBe('Deep diving for 2s')
|
||||
@@ -2229,9 +2233,10 @@ describe('ChatView', () => {
|
||||
expect(dialog.textContent).toContain('缓存命中49.4%')
|
||||
expect(dialog.textContent).toContain('未缓存输入5,060 tok')
|
||||
fireEvent.keyDown(document, { key: 'Escape' })
|
||||
expect(view.queryByRole('button', { name: /用时/ })).toBeNull()
|
||||
expect(turnProcessControl(view.container)?.textContent).toBe('用时 19秒')
|
||||
act(() => { h.setPerformanceUsage('compact') })
|
||||
expect(view.queryByRole('button', { name: /用量/ })).toBeNull()
|
||||
expect(turnProcessControl(view.container)?.textContent).toBe('用时 19秒')
|
||||
act(() => { h.setPerformanceUsage('detailed') })
|
||||
expect(view.getByRole('button', { name: /用量/ })).toBeTruthy()
|
||||
})
|
||||
@@ -2248,7 +2253,7 @@ describe('ChatView', () => {
|
||||
turnEnds: new Map([[1, 20]]),
|
||||
})
|
||||
const view = render(<h.ChatView {...h.props} />)
|
||||
expect(view.queryByRole('button', { name: /用时/ })).toBeNull()
|
||||
expect(turnProcessControl(view.container)?.textContent).toBe('用时 19秒')
|
||||
expect(view.queryByRole('button', { name: /用量/ })).toBeNull()
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
/** CSS checks for the secondary process title's surrounding vertical rhythm. */
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const read = (name: string): string =>
|
||||
readFileSync(fileURLToPath(new URL(`../src/client/chat/${name}`, import.meta.url)), 'utf8')
|
||||
|
||||
describe('secondary process spacing', () => {
|
||||
it('keeps 16px around the title and 8px between expanded rows', () => {
|
||||
const flow = read('ChatView.module.css')
|
||||
expect(flow.match(/--dsh-chat-flow-gap:\s*16px/g)).toHaveLength(2)
|
||||
|
||||
const process = read('StepProcessList.module.css')
|
||||
expect(process).toMatch(/\.title\[aria-expanded="true"\]\s*\{[^}]*padding-bottom:\s*16px/s)
|
||||
expect(process).toMatch(/\.body\s*\{[^}]*--dsh-chat-flow-gap:\s*8px/s)
|
||||
})
|
||||
})
|
||||
@@ -42,7 +42,31 @@ describe('process activity summary', () => {
|
||||
const root = { ...call('code', 'run_code'), subCalls: [{ ...call('read', 'read'), time: 3, argsRaw: '{' }] }
|
||||
const summary = processActivity(nodes([root, { ...call('bash', 'bash'), time: 2 }]))
|
||||
expect(summary.running).toBe('read')
|
||||
expect(summary.runningDetail).toBe('')
|
||||
expect(summary.runningDetail).toBe('read')
|
||||
})
|
||||
|
||||
it('prefers concise task fields, reads question arrays, caps long details, and falls back to the tool name', () => {
|
||||
const detail = (name: string, args: unknown): string => processActivity(nodes([
|
||||
{ ...call('current', name), argsRaw: typeof args === 'string' ? args : JSON.stringify(args) },
|
||||
])).runningDetail
|
||||
expect(detail('exec_command', { command: 'git status', description: 'Inspect current changes' }))
|
||||
.toBe('Inspect current changes')
|
||||
expect(detail('request_user_input', { questions: [{ question: 'Which layout should the card use?' }] }))
|
||||
.toBe('Which layout should the card use?')
|
||||
expect(detail('spawn_agent', { task_name: 'audit_ui', message: 'Inspect the complete UI implementation.' }))
|
||||
.toBe('audit_ui')
|
||||
expect(detail('custom_tool', {})).toBe('custom_tool')
|
||||
expect(detail('freeform_tool', 'not-json')).toBe('freeform_tool')
|
||||
expect(detail('custom_tool', { title: 'x'.repeat(200) })).toBe(`${'x'.repeat(159)}…`)
|
||||
})
|
||||
|
||||
it('uses the latest live reasoning paragraph while no tool is running', () => {
|
||||
const snapshot = chatSnapshotFixture({ partial: { turn: 1, step: 1, blocks: [
|
||||
{ kind: 'reasoning', text: '**Inspect the request**\ncarefully\n\nCompare the current tool output' },
|
||||
] } })
|
||||
const summary = processActivity([...snapshot.nodes.values()] as ChatNode[])
|
||||
expect(summary.running).toBeUndefined()
|
||||
expect(summary.runningDetail).toBe('Compare the current tool output')
|
||||
})
|
||||
|
||||
it.each([
|
||||
|
||||
@@ -5,7 +5,7 @@ import { cleanup, fireEvent, render } from '@testing-library/react'
|
||||
import {
|
||||
IconAgentPresetOutlineRegular, IconAlarmClockOutlineRegular, IconBranchOutlineRegular,
|
||||
IconContextInjectionOutlineRegular, IconCordisPluginOutlineRegular, IconGoalOutlineRegular,
|
||||
IconGlobeOutlineRegular, IconQueueOutlineRegular, IconSendOutlineRegular,
|
||||
IconGlobeOutlineRegular, IconPaperPlaneOutlineRegular, IconQueueOutlineRegular,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { ChatNodeViewProps } from '../src/client/contract/slots.ts'
|
||||
import { TurnTriggerNodeView } from '../src/client/chat/TurnTriggerNodeView.tsx'
|
||||
@@ -43,7 +43,7 @@ function props(source: unknown, text = 'trigger content'): ChatNodeViewProps<'tu
|
||||
it.each([
|
||||
[{ kind: 'custom-extension' }, '', IconContextInjectionOutlineRegular],
|
||||
[{ kind: 'goal' }, '', IconGoalOutlineRegular],
|
||||
[{ kind: 'agent-message' }, '', IconSendOutlineRegular],
|
||||
[{ kind: 'agent-message' }, '', IconPaperPlaneOutlineRegular],
|
||||
[{ kind: 'team-message' }, '', IconAgentPresetOutlineRegular],
|
||||
[{ kind: 'subagent-settled' }, '', IconAgentPresetOutlineRegular],
|
||||
[{ kind: 'webhook', provider: 'github' }, '', IconBranchOutlineRegular],
|
||||
|
||||
@@ -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: 5f262cdf221b5ddebe714a9c92f99c8f8e4a950b
|
||||
README.zh.md: 7ee35ec40715cfd24cc07d8f16dbe77fed79edfd
|
||||
README.md: 6a04a6450f2b3a4ed4a5e4c88d911c8305da4324
|
||||
README.zh.md: 270a9a5d770058ca9e3dbded543fd716442b1e3f
|
||||
@@ -37,7 +37,7 @@ Check this table before writing a control in a feature package. A plugin cannot
|
||||
| Export | What it is |
|
||||
|---|---|
|
||||
| `Button` | Clickable action; `variant` selects `primary`, `ghost`, `outline`, or `toolbar`. |
|
||||
| `Switch` | Two-state toggle, 36×20. `label` is required, so the control cannot ship unnamed. |
|
||||
| `Switch` | Two-state toggle, 36×20. Its thumb is white while off and uses the active track's foreground color while on. `label` is required, so the control cannot ship unnamed. |
|
||||
| `Checkbox` | Labeled native checkbox with controlled state, keyboard interaction, and disabled styling; the caller supplies localized `label` text. |
|
||||
| `Input` | Single-line text entry for search boxes and inline forms. |
|
||||
| `Menu` | Dropdown of items, separators, and group labels, with nested submenus. 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. |
|
||||
@@ -45,8 +45,8 @@ Check this table before writing a control in a feature package. A plugin cannot
|
||||
| `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. |
|
||||
| `DisclosureRow` | 24px compact disclosure that lays title and content side by side. |
|
||||
| `TextShimmer` | Animated text highlight with configurable duration, character-relative spread, base color, and highlight color; reduced-motion mode keeps its static base. |
|
||||
| `DisclosureRow` | 24px compact disclosure that lays title and content side by side; collapsed rows preview a down chevron on hover, and expanded rows keep an up chevron visible. |
|
||||
| `TextShimmer` | Animated text shimmer whose default 1.5-second cycle moves a lighter band with ease-in-out timing for one second, then pauses for 500ms; cycle duration, character-relative spread, base color, and shimmer color are configurable, and reduced-motion mode keeps the static base. |
|
||||
| `Modal` | Centered dialog over a page mask. |
|
||||
| `RiskConfirmation` | Sensitive action gated behind an explicit checkbox. |
|
||||
| `OnboardingSurface` | First-run stage that holds the application root inert. |
|
||||
|
||||
@@ -37,7 +37,7 @@ kind: "package-library"
|
||||
| 导出 | 是什么 |
|
||||
|---|---|
|
||||
| `Button` | 可点击操作;`variant` 选择 `primary`、`ghost`、`outline` 或 `toolbar`。 |
|
||||
| `Switch` | 36×20 的双态开关。`label` 必填,控件不可能在没有名称的情况下发布。 |
|
||||
| `Switch` | 36×20 的双态开关。关闭时圆点为白色,开启时使用激活轨道的前景色。`label` 必填,控件不可能在没有名称的情况下发布。 |
|
||||
| `Checkbox` | 带标签的原生复选框,支持受控状态、键盘交互和禁用样式;调用方提供本地化的 `label` 文本。 |
|
||||
| `Input` | 单行文本输入,用于搜索框与行内表单。 |
|
||||
| `Menu` | 由条目、分隔线与分组标题构成的下拉菜单,支持嵌套子菜单。打开期间 `↑`/`↓`(以及 Home、End)在列表中走位,Tab 选定聚焦行,Escape 或 Shift+Tab 关闭并把焦点还给锚点;选定一行同样把键盘还给锚点——除非拥有者自己移动了焦点。只拦截位于锚点或列表内的键盘,`autoFocus` 仅决定打开时是否聚焦首行。 |
|
||||
@@ -45,8 +45,8 @@ kind: "package-library"
|
||||
| `Tag` | 只读胶囊徽章;`tone` 选择八种配色之一。 |
|
||||
| `StateDot` | 10px 槽内的绿色 `done`、琥珀色 `warning`、红色 `error`、中性灰色 `idle` 圆点,以及 tertiary 灰色 14px 旋转 `ongoing` loading。它是 `aria-hidden` 的,名称由渲染点提供。 |
|
||||
| `ConnectionIndicator` | 行内连接恢复控件,覆盖断线、重试与已恢复三种状态。 |
|
||||
| `DisclosureRow` | 24px 紧凑折叠行,标题与内容左右排列。 |
|
||||
| `TextShimmer` | 可配置周期、按字符长度计算的扫光宽度、底色与高亮色的文字扫光;减少动态效果模式保留静态底色。 |
|
||||
| `DisclosureRow` | 24px 紧凑折叠行,标题与内容左右排列;收起时悬停预览向下箭头,展开后持续显示向上箭头。 |
|
||||
| `TextShimmer` | 默认底色与结束态继承颜色一致;默认 1.5 秒周期中,较浅色带以 ease-in-out 曲线移动一秒,再停顿 500ms。周期、按字符长度计算的扫光宽度、底色与扫光色均可配置,减少动态效果模式保留静态底色。 |
|
||||
| `Modal` | 页面遮罩之上的居中对话框。 |
|
||||
| `RiskConfirmation` | 以显式复选框把关的敏感操作确认。 |
|
||||
| `OnboardingSurface` | 首次运行的引导舞台,期间保持应用根节点 inert。 |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
|
||||
import clsx from 'clsx'
|
||||
import { IconChevronDownOutlineRegular } from './icons/index.tsx'
|
||||
import { IconChevronDownOutlineRegular, IconChevronUpOutlineRegular } from './icons/index.tsx'
|
||||
import css from './DisclosureRow.module.css'
|
||||
|
||||
/** Shared 24px disclosure chrome for compact flow rows. */
|
||||
@@ -12,7 +12,7 @@ export interface DisclosureRowProps {
|
||||
onToggle: () => void
|
||||
/** Makes the complete title row the disclosure target. */
|
||||
expandOnRowClick?: boolean | undefined
|
||||
/** Replaces the collapsed icon with a chevron while the row is hovered. */
|
||||
/** Replaces the collapsed icon with a down chevron while the row is hovered. */
|
||||
previewChevron?: boolean | undefined
|
||||
/** Keeps `collapsedContent` inline while open. */
|
||||
keepContentWhenOpen?: boolean | undefined
|
||||
@@ -66,7 +66,7 @@ export function DisclosureRow({
|
||||
)
|
||||
: icon
|
||||
const leading = open
|
||||
? <IconChevronDownOutlineRegular className={chevronClassName} />
|
||||
? <IconChevronUpOutlineRegular className={chevronClassName} />
|
||||
: collapsedLeading
|
||||
|
||||
return (
|
||||
|
||||
@@ -41,10 +41,12 @@
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
corner-shape: round;
|
||||
background: var(--dsw-alias-label-primary-foreground);
|
||||
/* The resting thumb stays white against the neutral track. */
|
||||
background: var(--dsw-static-neutral-00);
|
||||
transition: transform 120ms ease;
|
||||
}
|
||||
|
||||
.switch[aria-checked='true'] .thumb {
|
||||
background: var(--dsw-alias-label-primary-foreground);
|
||||
transform: translateX(16px);
|
||||
}
|
||||
@@ -5,7 +5,7 @@ import clsx from 'clsx'
|
||||
import css from './Switch.module.css'
|
||||
|
||||
/**
|
||||
* Render a toggle switch.
|
||||
* Render a toggle switch with a white resting thumb and an active-track foreground thumb.
|
||||
* @param props.checked - the current state; the control is fully controlled.
|
||||
* @param props.onChange - called with the state the click asks for.
|
||||
* @param props.label - localized accessible name, owned by the render site.
|
||||
|
||||
@@ -4,30 +4,33 @@
|
||||
background-image:
|
||||
linear-gradient(
|
||||
90deg,
|
||||
transparent calc(50% - var(--dsh-text-shimmer-spread)),
|
||||
var(--dsh-text-shimmer-base-color) calc(50% - var(--dsh-text-shimmer-spread)),
|
||||
var(--dsh-text-shimmer-color),
|
||||
transparent calc(50% + var(--dsh-text-shimmer-spread))
|
||||
),
|
||||
linear-gradient(var(--dsh-text-shimmer-base-color), var(--dsh-text-shimmer-base-color));
|
||||
background-position: 100% center, 0 0;
|
||||
background-repeat: no-repeat, no-repeat;
|
||||
background-size: 250% 100%, auto;
|
||||
var(--dsh-text-shimmer-base-color) calc(50% + var(--dsh-text-shimmer-spread))
|
||||
);
|
||||
background-position: 100% center;
|
||||
background-repeat: no-repeat;
|
||||
background-size: 250% 100%;
|
||||
background-clip: text;
|
||||
-webkit-background-clip: text;
|
||||
-webkit-text-fill-color: transparent;
|
||||
animation: dsh-text-shimmer var(--dsh-text-shimmer-duration) linear infinite;
|
||||
animation-name: dsh-text-shimmer;
|
||||
animation-duration: var(--dsh-text-shimmer-duration);
|
||||
animation-timing-function: cubic-bezier(0.33, 0, 0.67, 1);
|
||||
animation-iteration-count: infinite;
|
||||
}
|
||||
|
||||
@keyframes dsh-text-shimmer {
|
||||
to {
|
||||
background-position: 0% center, 0 0;
|
||||
66.6667%,
|
||||
100% {
|
||||
background-position: 0% center;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.root {
|
||||
background-image: linear-gradient(var(--dsh-text-shimmer-base-color), var(--dsh-text-shimmer-base-color));
|
||||
background-size: auto;
|
||||
background-size: 100% 100%;
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
@@ -10,13 +10,13 @@ export interface TextShimmerProps {
|
||||
as?: ElementType | undefined
|
||||
/** Additional class applied to the wrapper. */
|
||||
className?: string | undefined
|
||||
/** One animation cycle in seconds. */
|
||||
/** Complete animation cycle in seconds; defaults to 1.5 seconds. */
|
||||
duration?: number | undefined
|
||||
/** Highlight width multiplier in pixels per character. */
|
||||
/** Moving-band width multiplier in pixels per character; defaults to eight. */
|
||||
spread?: number | undefined
|
||||
/** Base text color behind the moving highlight. */
|
||||
/** Text color outside the moving band; defaults to the inherited color. */
|
||||
baseColor?: string | undefined
|
||||
/** Moving highlight color. */
|
||||
/** Moving band color; defaults to a translucent form of the inherited color. */
|
||||
shimmerColor?: string | undefined
|
||||
/** Additional inline styles. */
|
||||
style?: CSSProperties | undefined
|
||||
@@ -26,8 +26,8 @@ function TextShimmerComponent({
|
||||
children,
|
||||
as: Component = 'span',
|
||||
className,
|
||||
duration = 2,
|
||||
spread = 3,
|
||||
duration = 1.5,
|
||||
spread = 8,
|
||||
baseColor,
|
||||
shimmerColor,
|
||||
style,
|
||||
@@ -37,8 +37,8 @@ function TextShimmerComponent({
|
||||
...style,
|
||||
'--dsh-text-shimmer-duration': `${duration}s`,
|
||||
'--dsh-text-shimmer-spread': `${dynamicSpread}px`,
|
||||
'--dsh-text-shimmer-base-color': baseColor ?? 'color-mix(in oklab, currentColor 65%, transparent)',
|
||||
'--dsh-text-shimmer-color': shimmerColor ?? 'currentColor',
|
||||
'--dsh-text-shimmer-base-color': baseColor ?? 'currentColor',
|
||||
'--dsh-text-shimmer-color': shimmerColor ?? 'color-mix(in oklab, currentColor 50%, transparent)',
|
||||
} as CSSProperties
|
||||
return (
|
||||
<Component className={clsx(css.root, className)} style={shimmerStyle} data-text-shimmer="">
|
||||
@@ -47,5 +47,5 @@ function TextShimmerComponent({
|
||||
)
|
||||
}
|
||||
|
||||
/** Memoized text shimmer with a duration and highlight width derived from its props. */
|
||||
/** Memoized text shimmer with a duration and band width derived from its props. */
|
||||
export const TextShimmer = memo(TextShimmerComponent)
|
||||
@@ -0,0 +1,15 @@
|
||||
/** Switch palette-independent thumb color as CSS text. */
|
||||
import { readFileSync } from 'node:fs'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
const css = readFileSync(fileURLToPath(new URL('../src/Switch.module.css', import.meta.url)), 'utf8')
|
||||
|
||||
describe('Switch.module.css', () => {
|
||||
it('uses a white resting thumb and the active-track foreground while checked', () => {
|
||||
const restingThumb = /(?:^|\})\s*\.thumb\s*\{([^}]*)\}/.exec(css)?.[1]
|
||||
const checkedThumb = /\.switch\[aria-checked='true'\] \.thumb\s*\{([^}]*)\}/.exec(css)?.[1]
|
||||
expect(restingThumb).toContain('background: var(--dsw-static-neutral-00)')
|
||||
expect(checkedThumb).toContain('background: var(--dsw-alias-label-primary-foreground)')
|
||||
})
|
||||
})
|
||||
@@ -5,7 +5,16 @@ import { describe, expect, it } from 'vitest'
|
||||
import { TextShimmer } from '../src/TextShimmer.tsx'
|
||||
|
||||
describe('TextShimmer', () => {
|
||||
it('derives the highlight width and exposes configurable animation colors', () => {
|
||||
it('keeps the settled text color while a lighter band moves across it', () => {
|
||||
const view = render(<TextShimmer>Thinking</TextShimmer>)
|
||||
const shimmer = view.getByText('Thinking')
|
||||
expect(shimmer.style.getPropertyValue('--dsh-text-shimmer-duration')).toBe('1.5s')
|
||||
expect(shimmer.style.getPropertyValue('--dsh-text-shimmer-spread')).toBe('64px')
|
||||
expect(shimmer.style.getPropertyValue('--dsh-text-shimmer-base-color')).toBe('currentColor')
|
||||
expect(shimmer.style.getPropertyValue('--dsh-text-shimmer-color')).toBe('color-mix(in oklab, currentColor 50%, transparent)')
|
||||
})
|
||||
|
||||
it('derives the band width and exposes configurable animation colors', () => {
|
||||
const view = render(
|
||||
<TextShimmer as="strong" duration={3} spread={4} baseColor="gray" shimmerColor="white">
|
||||
Working
|
||||
|
||||
@@ -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-tool/README.md
|
||||
README.md: fd46ba0a73671c6f535368146b5ad1864b8024c8
|
||||
README.zh.md: 7d6d6385f9daae81975b62960209526126dbdeeb
|
||||
README.md: 76de046803450fdb300824872c898853e6ccf167
|
||||
README.zh.md: 3b58b2b538014a75c14a2f33ad43f08786550f16
|
||||
@@ -43,7 +43,7 @@ The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the frozen `blo
|
||||
|
||||
### Built-in views
|
||||
|
||||
This package owns the generic fallback and the built-in shell/pwsh, read, read_image, write/edit, running `str_replace_editor` `create`/`str_replace`, grep/glob, web, todo, question, and Code Dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall` and `presentResult` values never enter the Client. Running atomic rows animate their title and summary with the shared text shimmer, and hover darkens that text; settled rows remain static. Running and settled foreground standard `bash`/`pwsh` and `terminal_send` calls use terminal cards at the root and in Code Dispatch children, subject to the same argument, result, and error checks. Persistent `bash`/`pwsh` calls use terminal cards only while running. Shell output ending in a recognized spill-policy notice uses expandable generic output in shell rows and generic output in Details; a displaced or omitted exit marker cannot establish success. Settled persistent-shell results stay generic because reset and partial-output diagnostics do not always describe one process exit status; root persistent results are expandable, while background acknowledgements remain collapsed. A native or Code Dispatch failure carrying `AUTO_REVIEW_DENIED` shows the Auto review verdict in its collapsed row and one normalized not-executed reason when expanded; a missing or whitespace-only reason uses localized fallback text. A successful question row pairs call questions with result answers by their stable ids and shows readable question/answer lines when expanded. A cancelled or interrupted row shows its verdict and original questions without inventing answers. Unsupported, malformed, or ambiguous inputs fall back to flattened Tool input/result text. `ui-skill` demonstrates a business-owned registration for `skill`.
|
||||
This package owns the generic fallback and the built-in shell/pwsh, read, read_image, write/edit, running `str_replace_editor` `create`/`str_replace`, grep/glob, web, todo, question, and Code Dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall` and `presentResult` values never enter the Client. Running atomic rows animate their title and summary with the shared text shimmer, and hover darkens that text; settled rows remain static. Expandable rows preview a down chevron while collapsed and keep an up chevron visible while open. Running and settled foreground standard `bash`/`pwsh` and `terminal_send` calls use terminal cards at the root and in Code Dispatch children, subject to the same argument, result, and error checks. Persistent `bash`/`pwsh` calls use terminal cards only while running. Shell output ending in a recognized spill-policy notice uses expandable generic output in shell rows and generic output in Details; a displaced or omitted exit marker cannot establish success. Settled persistent-shell results stay generic because reset and partial-output diagnostics do not always describe one process exit status; root persistent results are expandable, while background acknowledgements remain collapsed. A native or Code Dispatch failure carrying `AUTO_REVIEW_DENIED` shows the Auto review verdict in its collapsed row and one normalized not-executed reason when expanded; a missing or whitespace-only reason uses localized fallback text. A successful question row pairs call questions with result answers by their stable ids and shows readable question/answer lines when expanded. A cancelled or interrupted row shows its verdict and original questions without inventing answers. Unsupported, malformed, or ambiguous inputs fall back to flattened Tool input/result text. `ui-skill` demonstrates a business-owned registration for `skill`.
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、冻结的 `block`
|
||||
|
||||
### 内置视图
|
||||
|
||||
本包拥有 generic fallback,以及 shell/pwsh、read、read_image、write/edit、运行中的 `str_replace_editor` `create`/`str_replace`、grep/glob、web、todo、question 与 Code Dispatch 的内置展示。结构化卡片直接从第一方原始事件字段派生;Host `presentCall` 与 `presentResult` 值不会进入 Client。运行中的原子工具行使用共享文字扫光动画显示标题与摘要,悬停时文字颜色加深;已完成行保持静态。运行中与已完成的前台标准 `bash`/`pwsh` 和 `terminal_send` 调用,无论位于根还是 Code Dispatch 子调用中,都在通过相同的参数、结果和错误检查后使用 terminal 卡片。持久 `bash`/`pwsh` 调用仅在运行中使用 terminal 卡片。以已识别的 spill 策略提示结尾的 shell 输出,在 shell 行中使用可展开的 generic 输出,在 Details 中使用 generic 输出;位置被改变或被省略的退出标记无法证明成功。已完成的持久 shell 结果保持 generic 展示,因为 reset 与部分输出诊断不一定描述单个进程的退出状态;根调用的持久 shell 结果可展开,后台启动回执则保持折叠。带有 `AUTO_REVIEW_DENIED` 的原生或 Code Dispatch 失败会在折叠行显示 Auto review 裁决,展开时显示一行归一化后的“未执行”原因;原因缺失或只有空白时使用本地化 fallback 文案。成功的问题行按稳定 id 配对调用中的问题与结果中的回答,展开后显示可读的问答行。已取消或已中断的问题行显示其裁决与原始问题,不虚构回答。不受支持、格式错误或含糊的输入回退为压平的工具输入/结果文本。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。
|
||||
本包拥有 generic fallback,以及 shell/pwsh、read、read_image、write/edit、运行中的 `str_replace_editor` `create`/`str_replace`、grep/glob、web、todo、question 与 Code Dispatch 的内置展示。结构化卡片直接从第一方原始事件字段派生;Host `presentCall` 与 `presentResult` 值不会进入 Client。运行中的原子工具行使用共享文字扫光动画显示标题与摘要,悬停时文字颜色加深;已完成行保持静态。可展开行在收起时预览向下箭头,展开后持续显示向上箭头。运行中与已完成的前台标准 `bash`/`pwsh` 和 `terminal_send` 调用,无论位于根还是 Code Dispatch 子调用中,都在通过相同的参数、结果和错误检查后使用 terminal 卡片。持久 `bash`/`pwsh` 调用仅在运行中使用 terminal 卡片。以已识别的 spill 策略提示结尾的 shell 输出,在 shell 行中使用可展开的 generic 输出,在 Details 中使用 generic 输出;位置被改变或被省略的退出标记无法证明成功。已完成的持久 shell 结果保持 generic 展示,因为 reset 与部分输出诊断不一定描述单个进程的退出状态;根调用的持久 shell 结果可展开,后台启动回执则保持折叠。带有 `AUTO_REVIEW_DENIED` 的原生或 Code Dispatch 失败会在折叠行显示 Auto review 裁决,展开时显示一行归一化后的“未执行”原因;原因缺失或只有空白时使用本地化 fallback 文案。成功的问题行按稳定 id 配对调用中的问题与结果中的回答,展开后显示可读的问答行。已取消或已中断的问题行显示其裁决与原始问题,不虚构回答。不受支持、格式错误或含糊的输入回退为压平的工具输入/结果文本。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。
|
||||
|
||||
-----
|
||||
|
||||
|
||||
@@ -2,8 +2,8 @@ import { useMemo, useState, type KeyboardEvent } from 'react'
|
||||
import type { Context } from '@deepseek-ai/cordis'
|
||||
import clsx from 'clsx'
|
||||
import {
|
||||
IconApiOutlineRegular, IconChevronDownOutlineRegular, IconInspectOutlineRegular, TerminalBlock,
|
||||
TextShimmer,
|
||||
IconApiOutlineRegular, IconChevronDownOutlineRegular, IconChevronUpOutlineRegular, IconInspectOutlineRegular,
|
||||
TerminalBlock, TextShimmer,
|
||||
} from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
|
||||
import type { ToolCallViewProps } from '../../contract/slots.ts'
|
||||
@@ -78,7 +78,7 @@ export function BashRow({ toolName, block, sessionId, useSessions, inspect, t }:
|
||||
}
|
||||
const businessIcon = <IconApiOutlineRegular size={14} />
|
||||
const leading = open
|
||||
? <IconChevronDownOutlineRegular className={css.chevron} />
|
||||
? <IconChevronUpOutlineRegular className={css.chevron} />
|
||||
: expandable
|
||||
? (
|
||||
<>
|
||||
|
||||
@@ -471,6 +471,8 @@ describe('BashRow terminal card', () => {
|
||||
fireEvent.click(row)
|
||||
|
||||
expect(row.getAttribute('aria-expanded')).toBe('true')
|
||||
expect(row.querySelector('svg path')?.getAttribute('d'))
|
||||
.toBe('M12 10L8.70711 6.70711C8.31658 6.31658 7.68342 6.31658 7.29289 6.70711L4 10')
|
||||
expect(view.getByText('输入')).toBeTruthy()
|
||||
expect(view.getByText('输出')).toBeTruthy()
|
||||
expect(view.getByText(/"command": "ls -la"/)).toBeTruthy()
|
||||
|
||||
@@ -302,7 +302,8 @@ describe('ToolRow', () => {
|
||||
const view = render(<ToolRow {...rowProps} />)
|
||||
fireEvent.click(view.getByRole('button'))
|
||||
expect(view.queryByTestId('tool-icon')).toBeNull()
|
||||
expect(view.container.querySelector('svg')).not.toBeNull()
|
||||
expect(view.container.querySelector('svg path')?.getAttribute('d'))
|
||||
.toBe('M12 10L8.70711 6.70711C8.31658 6.31658 7.68342 6.31658 7.29289 6.70711L4 10')
|
||||
expect(view.getByText('List files')).toBeTruthy()
|
||||
expect(view.getByText(/"a": 1/)).toBeTruthy()
|
||||
expect(view.container.querySelector('[class*="ioCard"]')).not.toBeNull()
|
||||
|
||||
@@ -7,12 +7,11 @@
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- button "System prompt"
|
||||
- text: "Using ONE run_code program: run bash `echo CODE_ROUND_OK`, then read the file missing.txt catching its error in the program. Return an object with both outcomes. Then reply DONE and stop. {{clock}}"
|
||||
- button "Copy"
|
||||
- button "1 tool call" [expanded]
|
||||
- button "Context injection @deepseek-ai/dsh-system-prompt"
|
||||
- 'button "Think The user wants me to write a single `run_code` program that:"'
|
||||
- button "Took {{duration}}" [expanded]
|
||||
- button "Ran code, ran commands, read files" [expanded]
|
||||
- button "Think"
|
||||
- button "Code Run bash echo and catch missing file read"
|
||||
- button "Bash Echo CODE_ROUND_OK" [expanded]
|
||||
- text: Done workspace echo CODE_ROUND_OK
|
||||
@@ -21,7 +20,7 @@
|
||||
- button "Inspect"
|
||||
- text: Failed
|
||||
- 'button "Read Error: cannot read \"{{cwd}}/workspace/missing.txt\": not found"'
|
||||
- button "Think The program ran successfully. Let me now reply DONE as instructed."
|
||||
- button "Think"
|
||||
- paragraph: DONE
|
||||
- button "Copy"
|
||||
- button "Good response"
|
||||
|
||||
Reference in new issue
Block a user