mirror of
https://github.com/deepseek-ai/deepseek-harness.git
synced 2026-11-09 20:07:43 +08:00
fix(web): polish work detail interactions
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write .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
|
||||
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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