Revert "feat(web): restore the process-row polish on the grouped work details"

This reverts commit 036d53fb612c785a17d9e6068f58b155d87120c1.
This commit is contained in:
imccyu authored and GitHub committed 2026-09-23 20:21:46 +08:00
1 parent b09dd94827
commit 7c8a44ac31
163 files changed
+783 -1280

No files matched your search

-1
View File
@@ -96,7 +96,6 @@ External packages installed for runtime use or distributed inside the prebuilt b
| [`katex`](https://github.com/KaTeX/KaTeX) | MIT |
| [`koffi`](https://github.com/Koromix/koffi) | MIT |
| [`lexical`](https://github.com/facebook/lexical) | MIT |
| [`lottie-web`](https://github.com/airbnb/lottie-web) | MIT |
| [`mdast-util-from-markdown`](https://github.com/syntax-tree/mdast-util-from-markdown) | MIT |
| [`mdast-util-gfm`](https://github.com/syntax-tree/mdast-util-gfm) | MIT |
| [`mdast-util-math`](https://github.com/syntax-tree/mdast-util-math) | MIT |
-15
View File
@@ -218,7 +218,6 @@ export function installAssembledBootEnv(): void {
})
}
let fontsDescriptor: PropertyDescriptor | undefined
let canvasContextDescriptor: PropertyDescriptor | undefined
beforeEach(() => {
fontsDescriptor = Object.getOwnPropertyDescriptor(document, 'fonts')
Object.defineProperty(document, 'fonts', { configurable: true, value: new EventTarget() })
@@ -234,18 +233,6 @@ export function installAssembledBootEnv(): void {
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
setTimeout(() => { callback(0) }, 0) as unknown as number)
vi.stubGlobal('cancelAnimationFrame', (id: number) => { clearTimeout(id) })
vi.stubGlobal('matchMedia', (media: string) => Object.assign(new EventTarget(), {
media, matches: false, onchange: null, addListener: () => {}, removeListener: () => {},
}))
// Lottie's SVG player creates one transparent Canvas placeholder while importing.
canvasContextDescriptor = Object.getOwnPropertyDescriptor(HTMLCanvasElement.prototype, 'getContext')
Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', {
configurable: true,
value: (contextId: string) => {
if (contextId !== '2d') throw new Error(`assembled boot: unsupported Canvas context ${contextId}`)
return { fillStyle: '', fillRect: () => {} }
},
})
})
afterEach(async () => {
@@ -276,8 +263,6 @@ export function installAssembledBootEnv(): void {
delete ownNavigator.languages
delete ownNavigator.language
vi.unstubAllGlobals()
if (canvasContextDescriptor === undefined) Reflect.deleteProperty(HTMLCanvasElement.prototype, 'getContext')
else Object.defineProperty(HTMLCanvasElement.prototype, 'getContext', canvasContextDescriptor)
if (fontsDescriptor === undefined) Reflect.deleteProperty(document, 'fonts')
else Object.defineProperty(document, 'fonts', fontsDescriptor)
if (failures.length > 0) throw new AggregateError(failures, 'assembled boot teardown failed')
+2 -4
View File
@@ -129,8 +129,7 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
const writeRow = mutationRows.find(row => row.getAttribute('data-variant') === 'write'
&& row.textContent?.includes('new-demo.txt'))
if (writeRow === undefined) throw new Error('fixture write row missing')
expect(within(writeRow).getAllByText('+1', { exact: true })).toHaveLength(1)
expect(within(writeRow).getAllByText('-0', { exact: true })).toHaveLength(1)
expect(within(writeRow).getAllByText('+1 -0', { exact: true })).toHaveLength(1)
expect(writeRow.querySelector('[data-diff]')).toBeNull()
for (const row of mutationRows) {
const toggle = row.querySelector('[data-expandable]')
@@ -141,8 +140,7 @@ it('boots the built plugin graph and renders a fixture session end to end', asyn
const writeDiff = writeRow.querySelector<HTMLElement>('[data-diff]')
if (writeDiff === null) throw new Error('expanded fixture write diff missing')
within(writeDiff).getByText('hello fixture', { exact: true })
expect(within(writeRow).getAllByText('+1', { exact: true })).toHaveLength(1)
expect(within(writeRow).getAllByText('-0', { exact: true })).toHaveLength(1)
expect(within(writeRow).getAllByText('+1 -0', { exact: true })).toHaveLength(1)
expect(writeDiff.textContent).not.toContain('+1 -0')
// The web render intent reaches the assembled boot graph: the fixture's
@@ -7,8 +7,8 @@
- tab "Trajectory"
- text: "Assemble the link gallery: write the report and styles, inspect the sources, and summarize. {{clock}}"
- button "Copy"
- status: Completed
- button "Completed in {{duration}}" [expanded]
- status: Worked
- button "Took {{duration}}" [expanded]
- button "Wrote files, called tools, searched code, etc." [expanded]
- button "Write site/report.html +1 -0":
- text: Write
@@ -11,8 +11,8 @@
- button "Jump to turn 3"
- text: "Use only Cordis tools. First call cordis_inspect_self with no arguments. Then call cordis_define with plugin kind \"new\", idPrefix \"snap\", name \"snapshot noop\", purpose \"does nothing, for the snapshot\", code.host exactly \"return { name: \\\"snapshot-noop\\\", apply(ctx) {} }\" and code.client exactly \"return { inject: [\\\"slots\\\"], apply(ctx) { ctx.slots.register({ name: \\\"shell.overlay\\\", id: \\\"snapshot-probe\\\" }, () => React.createElement(\\\"div\\\", { \\\"data-snapshot-probe\\\": \\\"loaded\\\" })) } }\". Read its returned pluginId and packageId, then call cordis_run with those exact IDs and mode \"run\". After the run request returns, reply exactly CORDIS_UI_READY and stop. 9/1 {{clock}}"
- button "Copy"
- status: Completed
- button "Completed in {{duration}}" [expanded]
- status: Worked
- button "Took {{duration}}" [expanded]
- button "Called tools" [expanded]
- button "Think"
- button "Inspect plugins 0 dynamic plugins"
@@ -42,8 +42,8 @@
- button "Plugin status updated 9/1 {{clock}}":
- text: Plugin status updated
- time: 9/1 {{clock}}
- status: Completed
- button "Completed in {{duration}}" [disabled]
- status: Worked
- button "Took {{duration}}" [disabled]
- paragraph: The Cordis Plugin is running.
- button "Copy"
- button "Good response"
@@ -51,8 +51,8 @@
- button "Branch into a new conversation"
- text: 9/1 {{clock}} Use only Cordis tools. Call cordis_stop with pluginId "snap-1". After it succeeds, reply exactly CORDIS_UI_DONE and stop. 9/1 {{clock}}
- button "Copy"
- status: Completed
- button "Completed in {{duration}}" [expanded]
- status: Worked
- button "Took {{duration}}" [expanded]
- button "Called tools" [expanded]
- text: Stop Cordis Plugin snap-1
- button "Inspect"
@@ -1,11 +1,11 @@
- text: "Use the read tool twice in one assistant message: read a.txt and b.txt. Then reply with the single word DONE and stop. {{clock}}"
- button "Copy"
- status: Completed
- button "Completed in {{duration}}"
- status: Worked
- button "Took {{duration}}"
- text: FORK_BRANCH_USER Summarize what this branch knows about the reads. {{clock}}
- button "Copy"
- status: Completed
- button "Completed in {{duration}}" [disabled]
- status: Worked
- button "Took {{duration}}" [disabled]
- paragraph: FORK_BRANCH_FIRST this branch has no read results, so I would check what the parent completed before retrying. FORK_BRANCH_DONE.
- button "Copy"
- button "Good response"
@@ -19,8 +19,8 @@
- button "GitHub event received {{clock}}":
- text: GitHub event received
- time: {{clock}}
- status: Completed
- button "Completed in {{duration}}" [disabled]
- status: Worked
- button "Took {{duration}}" [disabled]
- paragraph: "Review complete: no actionable findings."
- button "Copy"
- button "Good response"
@@ -19,8 +19,8 @@
- button "GitHub event received {{clock}}":
- text: GitHub event received
- time: {{clock}}
- status: Completed
- button "Completed in {{duration}}" [disabled]
- status: Worked
- button "Took {{duration}}" [disabled]
- paragraph: "Review complete: no actionable findings."
- button "Copy"
- button "Good response"
@@ -7,8 +7,8 @@
- tab "Trajectory"
- text: Render adjacent CJK strong emphasis. {{clock}}
- button "Copy"
- status: Completed
- button "Completed in {{duration}}" [disabled]
- status: Worked
- button "Took {{duration}}" [disabled]
- heading "CJK strong emphasis" [level=2]
- paragraph:
- strong: 注意:
@@ -7,8 +7,8 @@
- tab "Trajectory"
- text: Show the Markdown image policy. {{clock}}
- button "Copy"
- status: Completed
- button "Completed in {{duration}}" [disabled]
- status: Worked
- button "Took {{duration}}" [disabled]
- heading "Markdown images" [level=2]
- paragraph:
- 'button "View full image: Remote test image"':
@@ -6,8 +6,8 @@
- tab "Trajectory"
- text: Show the local preview URL. {{clock}}
- button "Copy"
- status: Completed
- button "Completed in {{duration}}" [disabled]
- status: Worked
- button "Took {{duration}}" [disabled]
- heading "Inline code links" [level=2]
- paragraph:
- text: "Preview:"
@@ -7,8 +7,8 @@
- tab "Trajectory"
- text: Render this mathematical proof. {{clock}}
- button "Copy"
- status: Completed
- button "Completed in {{duration}}" [disabled]
- status: Worked
- button "Took {{duration}}" [disabled]
- heading "Math rendering" [level=2]
- paragraph:
- text: Inline dollar
@@ -7,8 +7,8 @@
- tab "Trajectory"
- text: Research notes what changed? Referenced session · Research notes {{clock}}
- button "Copy"
- status: Completed
- button "Completed in {{duration}}" [disabled]
- status: Worked
- button "Took {{duration}}" [disabled]
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Add files or run commands"
- 'button "Access mode, current: Workspace Write"': Workspace Write
@@ -10,8 +10,8 @@
- button "meeting notes-this-is-a-very-long-filename-for-testing-user-message-file-references-and-preview-layout.md"
- text: and confirm the fixture wiring {{clock}}
- button "Copy"
- status: Completed
- button "Completed in {{duration}}" [disabled]
- status: Worked
- button "Took {{duration}}" [disabled]
- paragraph: USER_INVOKE_REPLY acknowledged; following the injected skill.
- button "Copy"
- button "Good response"
@@ -10,8 +10,8 @@
- button "meeting notes-this-is-a-very-long-filename-for-testing-user-message-file-references-and-preview-layout.md"
- text: and confirm the fixture wiring {{clock}}
- button "Copy"
- status: Completed
- button "Completed in {{duration}}" [disabled]
- status: Worked
- button "Took {{duration}}" [disabled]
- paragraph: USER_INVOKE_REPLY acknowledged; following the injected skill.
- button "Copy"
- button "Good response"
@@ -36,8 +36,8 @@
- button "Jump to turn 28"
- text: m1 7/25 {{clock}}
- button "Copy"
- status: Completed
- button "Completed in {{duration}}" [disabled]
- status: Worked
- button "Took {{duration}}" [disabled]
- paragraph: r1
- button "Copy"
- button "Good response"
@@ -45,8 +45,8 @@
- button "Branch into a new conversation"
- text: 7/25 {{clock}} m2 7/25 {{clock}}
- button "Copy"
- status: Completed
- button "Completed in {{duration}}" [disabled]
- status: Worked
- button "Took {{duration}}" [disabled]
- paragraph: r2
- button "Copy"
- button "Good response"
@@ -54,8 +54,8 @@
- button "Branch into a new conversation"
- text: 7/25 {{clock}} m3 7/25 {{clock}}
- button "Copy"
- status: Completed
- button "Completed in {{duration}}" [disabled]
- status: Worked
- button "Took {{duration}}" [disabled]
- paragraph: r3
- button "Copy"
- button "Good response"
@@ -63,8 +63,8 @@
- button "Branch into a new conversation"
- text: 7/25 {{clock}} m4 7/25 {{clock}}
- button "Copy"
- status: Completed
- button "Completed in {{duration}}" [disabled]
- status: Worked
- button "Took {{duration}}" [disabled]
- paragraph: r4
- button "Copy"
- button "Good response"
@@ -72,8 +72,8 @@
- button "Branch into a new conversation"
- text: 7/25 {{clock}} m5 7/25 {{clock}}
- button "Copy"
- status: Completed
- button "Completed in {{duration}}" [disabled]
- status: Worked
- button "Took {{duration}}" [disabled]
- paragraph: r5
- button "Copy"
- button "Good response"
@@ -81,8 +81,8 @@
- button "Branch into a new conversation"
- text: 7/25 {{clock}} m6 7/25 {{clock}}
- button "Copy"
- status: Completed
- button "Completed in {{duration}}" [disabled]
- status: Worked
- button "Took {{duration}}" [disabled]
- paragraph: r6
- button "Copy"
- button "Good response"
@@ -90,8 +90,8 @@
- button "Branch into a new conversation"
- text: 7/25 {{clock}} m7 7/25 {{clock}}
- button "Copy"
- status: Completed
- button "Completed in {{duration}}" [disabled]
- status: Worked
- button "Took {{duration}}" [disabled]
- paragraph: r7
- button "Copy"
- button "Good response"
@@ -99,8 +99,8 @@
- button "Branch into a new conversation"
- text: 7/25 {{clock}} m8 7/25 {{clock}}
- button "Copy"
- status: Completed
- button "Completed in {{duration}}" [disabled]
- status: Worked
- button "Took {{duration}}" [disabled]
- paragraph: r8
- button "Copy"
- button "Good response"
@@ -108,8 +108,8 @@
- button "Branch into a new conversation"
- text: 7/25 {{clock}} m9 7/25 {{clock}}
- button "Copy"
- status: Completed
- button "Completed in {{duration}}" [disabled]
- status: Worked
- button "Took {{duration}}" [disabled]
- paragraph: r9
- button "Copy"
- button "Good response"
@@ -117,8 +117,8 @@
- button "Branch into a new conversation"
- text: 7/25 {{clock}} m10 7/25 {{clock}}
- button "Copy"
- status: Completed
- button "Completed in {{duration}}" [disabled]
- status: Worked
- button "Took {{duration}}" [disabled]
- paragraph: r10
- button "Copy"
- button "Good response"
@@ -126,8 +126,8 @@
- button "Branch into a new conversation"
- text: 7/25 {{clock}} m11 7/25 {{clock}}
- button "Copy"
- status: Completed
- button "Completed in {{duration}}" [disabled]
- status: Worked
- button "Took {{duration}}" [disabled]
- paragraph: r11
- button "Copy"
- button "Good response"
@@ -135,8 +135,8 @@
- button "Branch into a new conversation"
- text: 7/25 {{clock}} m12 7/25 {{clock}}
- button "Copy"
- status: Completed
- button "Completed in {{duration}}" [disabled]
- status: Worked
- button "Took {{duration}}" [disabled]
- paragraph: r12
- button "Copy"
- button "Good response"
@@ -144,8 +144,8 @@
- button "Branch into a new conversation"
- text: 7/25 {{clock}} m13 7/25 {{clock}}
- button "Copy"
- status: Completed
- button "Completed in {{duration}}" [disabled]
- status: Worked
- button "Took {{duration}}" [disabled]
- paragraph: r13
- button "Copy"
- button "Good response"
@@ -153,8 +153,8 @@
- button "Branch into a new conversation"
- text: 7/25 {{clock}} m14 7/25 {{clock}}
- button "Copy"
- status: Completed
- button "Completed in {{duration}}" [disabled]
- status: Worked
- button "Took {{duration}}" [disabled]
- paragraph: r14
- button "Copy"
- button "Good response"
@@ -162,8 +162,8 @@
- button "Branch into a new conversation"
- text: 7/25 {{clock}} m15 7/25 {{clock}}
- button "Copy"
- status: Completed
- button "Completed in {{duration}}" [disabled]
- status: Worked
- button "Took {{duration}}" [disabled]
- paragraph: r15
- button "Copy"
- button "Good response"
@@ -171,8 +171,8 @@
- button "Branch into a new conversation"
- text: 7/25 {{clock}} m16 7/25 {{clock}}
- button "Copy"
- status: Completed
- button "Completed in {{duration}}" [disabled]
- status: Worked
- button "Took {{duration}}" [disabled]
- paragraph: r16
- button "Copy"
- button "Good response"
@@ -180,8 +180,8 @@
- button "Branch into a new conversation"
- text: 7/25 {{clock}} m17 7/25 {{clock}}
- button "Copy"
- status: Completed
- button "Completed in {{duration}}" [disabled]
- status: Worked
- button "Took {{duration}}" [disabled]
- paragraph: r17
- button "Copy"
- button "Good response"
@@ -189,8 +189,8 @@
- button "Branch into a new conversation"
- text: 7/25 {{clock}} m18 7/25 {{clock}}
- button "Copy"
- status: Completed
- button "Completed in {{duration}}" [disabled]
- status: Worked
- button "Took {{duration}}" [disabled]
- paragraph: r18
- button "Copy"
- button "Good response"
@@ -198,8 +198,8 @@
- button "Branch into a new conversation"
- text: 7/25 {{clock}} m19 7/25 {{clock}}
- button "Copy"
- status: Completed
- button "Completed in {{duration}}" [disabled]
- status: Worked
- button "Took {{duration}}" [disabled]
- paragraph: r19
- button "Copy"
- button "Good response"
@@ -207,8 +207,8 @@
- button "Branch into a new conversation"
- text: 7/25 {{clock}} m20 7/25 {{clock}}
- button "Copy"
- status: Completed
- button "Completed in {{duration}}" [disabled]
- status: Worked
- button "Took {{duration}}" [disabled]
- paragraph: r20
- button "Copy"
- button "Good response"
@@ -216,8 +216,8 @@
- button "Branch into a new conversation"
- text: 7/25 {{clock}} m21 7/25 {{clock}}
- button "Copy"
- status: Completed
- button "Completed in {{duration}}" [disabled]
- status: Worked
- button "Took {{duration}}" [disabled]
- paragraph: r21
- button "Copy"
- button "Good response"
@@ -225,8 +225,8 @@
- button "Branch into a new conversation"
- text: 7/25 {{clock}} m22 7/25 {{clock}}
- button "Copy"
- status: Completed
- button "Completed in {{duration}}" [disabled]
- status: Worked
- button "Took {{duration}}" [disabled]
- paragraph: r22
- button "Copy"
- button "Good response"
@@ -234,8 +234,8 @@
- button "Branch into a new conversation"
- text: 7/25 {{clock}} m23 7/25 {{clock}}
- button "Copy"
- status: Completed
- button "Completed in {{duration}}" [disabled]
- status: Worked
- button "Took {{duration}}" [disabled]
- paragraph: r23
- button "Copy"
- button "Good response"
@@ -243,8 +243,8 @@
- button "Branch into a new conversation"
- text: 7/25 {{clock}} m24 7/25 {{clock}}
- button "Copy"
- status: Completed
- button "Completed in {{duration}}" [disabled]
- status: Worked
- button "Took {{duration}}" [disabled]
- paragraph: r24
- button "Copy"
- button "Good response"
@@ -252,8 +252,8 @@
- button "Branch into a new conversation"
- text: 7/25 {{clock}} m25 7/25 {{clock}}
- button "Copy"
- status: Completed
- button "Completed in {{duration}}" [disabled]
- status: Worked
- button "Took {{duration}}" [disabled]
- paragraph: r25
- button "Copy"
- button "Good response"
@@ -261,8 +261,8 @@
- button "Branch into a new conversation"
- text: 7/25 {{clock}} m26 7/25 {{clock}}
- button "Copy"
- status: Completed
- button "Completed in {{duration}}" [disabled]
- status: Worked
- button "Took {{duration}}" [disabled]
- paragraph: r26
- button "Copy"
- button "Good response"
@@ -270,8 +270,8 @@
- button "Branch into a new conversation"
- text: 7/25 {{clock}} m27 7/25 {{clock}}
- button "Copy"
- status: Completed
- button "Completed in {{duration}}" [disabled]
- status: Worked
- button "Took {{duration}}" [disabled]
- paragraph: r27
- button "Copy"
- button "Good response"
@@ -279,8 +279,8 @@
- button "Branch into a new conversation"
- text: 7/25 {{clock}} m28 7/25 {{clock}}
- button "Copy"
- status: Completed
- button "Completed in {{duration}}" [disabled]
- status: Worked
- button "Took {{duration}}" [disabled]
- paragraph: r28
- button "Copy"
- button "Good response"
@@ -8,13 +8,13 @@
- tab "Trajectory"
- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}}
- button "Copy"
- status: Deep diving...
- button "Deep diving for {{duration}}" [disabled] [expanded]
- button "Analyzing the request"
- text: "Interjection: include the word BANANA in your final reply."
- button "Copy"
- text: "Interjection: include the word ORANGE in your final reply."
- button "Copy"
- status: Deep diving
- text: Deep diving for {{duration}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Add files or run commands"
- 'button "Access mode, current: Workspace Write"': Workspace Write
@@ -8,8 +8,8 @@
- tab "Trajectory"
- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}}
- button "Copy"
- status: Completed
- button "Completed in {{duration}}" [disabled]
- status: Worked
- button "Took {{duration}}" [disabled]
- button "Asked questions" [expanded]
- button "Think"
- button "Ask question 1/1 answered"
@@ -8,8 +8,8 @@
- tab "Trajectory"
- text: Use the ask_user_question tool to ask me exactly one question with id "checkpoint", question "Ready to continue?", header "Checkpoint", and options labeled "Yes" and "No". After I answer, reply with one short sentence acknowledging my answer and stop. {{clock}}
- button "Copy"
- status: Completed
- button "Completed in {{duration}}" [disabled]
- status: Worked
- button "Took {{duration}}" [disabled]
- button "Asked questions"
- text: "Interjection: include the word BANANA in your final reply. {{clock}}"
- button "Copy"
@@ -155,32 +155,6 @@ describe('web e2e: Goal keeps one assistant action row per completed turn', () =
expect(sessionEvents.flatMap(event =>
event.type === 'request/header' ? [event.data.reason] : [])).toEqual(['initial', 'series'])
await expect.poll(() => page.locator('[data-turn-process]').count(), { timeout: 15_000 }).toBe(2)
const trigger = page.locator('[data-turn-trigger]').first()
const process = page.locator('[data-turn-process]').first()
const originalFontSize = await page.evaluate(() => document.body.style.getPropertyValue('--dsh-content-font-size'))
await process.click()
try {
for (const fontSize of [14, 17, 24]) {
await scaffold!.ctx.settings.update('ui-theme', { fontSize })
await expect.poll(() => page.evaluate(() => document.body.style.getPropertyValue('--dsh-content-font-size')))
.toBe(`${String(fontSize)}px`)
const group = page.locator('[data-process-activity]:visible').first()
const groupFontSize = await group.evaluate(element => getComputedStyle(element).fontSize)
expect(groupFontSize).toBe(`${String(fontSize)}px`)
expect(await process.evaluate(element => getComputedStyle(element).fontSize)).toBe(groupFontSize)
expect(await trigger.getByText('Continuing goal', { exact: true })
.evaluate(element => getComputedStyle(element).fontSize)).toBe(groupFontSize)
expect(await process.evaluate(element => element.getBoundingClientRect().top))
.toBe(await trigger.evaluate(element => element.getBoundingClientRect().bottom) + 16)
expect(await group.evaluate(element => element.getBoundingClientRect().top))
.toBe(await process.evaluate(element => element.getBoundingClientRect().bottom) + 16)
}
} finally {
await scaffold!.ctx.settings.update('ui-theme', { fontSize: Number.parseFloat(originalFontSize) })
await expect.poll(() => page.evaluate(() => document.body.style.getPropertyValue('--dsh-content-font-size')))
.toBe(originalFontSize)
await process.click()
}
expect(await page.getByRole('button', { name: 'System prompt' }).count()).toBe(0)
expect(await page.locator(
'[data-chat-flow-kind="system-prompt"]',
@@ -189,10 +163,7 @@ describe('web e2e: Goal keeps one assistant action row per completed turn', () =
await expect.poll(() => branchButtons.count(), { timeout: 15_000 }).toBe(2)
expect(await branchButtons.evaluateAll(buttons => buttons.map(button => button.getAttribute('aria-disabled'))))
.toEqual([null, null])
await page.mouse.move(0, 0)
await branchButtons.last().focus()
await page.locator('[data-composer-input]').first().focus()
await expect.poll(() => page.getByRole('tooltip').count()).toBe(0)
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
await compareOrRefreshGolden(UI_EXPECTED, snapshot, MODE)
const expanded = await captureExpandedTurnProcessAria(
@@ -144,10 +144,8 @@ it.skipIf(webSnapshotMode() === 'record').each(['inbox-first', 'transcript-first
if (order === 'transcript-first') {
await expect.poll(() => gate.turnStartDelivery()).toBeDefined()
const start = gate.turnStartDelivery()!
if (start.event?.type !== 'turn/start') throw new Error('expected a buffered turn/start')
gate.releaseThrough(start)
await page.locator(`[data-chat-flow-kind="turn-process"][data-chat-turn="${String(start.event.data.turn)}"]`).waitFor({ state: 'attached' })
gate.releaseThrough(gate.turnStartDelivery()!)
await page.locator('[data-turn-process]').last().filter({ hasText: 'Deep diving' }).waitFor()
await expect.poll(() => placement(page)).toMatchObject({ echo: 1, dock: 0, durable: 0 })
trace.push({ phase: 'turn-start', ...await placement(page) })
await expect.poll(() => gate.admissionDelivery()).toBeDefined()
@@ -168,10 +166,8 @@ it.skipIf(webSnapshotMode() === 'record').each(['inbox-first', 'transcript-first
if (order === 'inbox-first') {
await expect.poll(() => gate.turnStartDelivery()).toBeDefined()
const start = gate.turnStartDelivery()!
if (start.event?.type !== 'turn/start') throw new Error('expected a buffered turn/start')
gate.releaseThrough(start)
await page.locator(`[data-chat-flow-kind="turn-process"][data-chat-turn="${String(start.event.data.turn)}"]`).waitFor({ state: 'attached' })
gate.releaseThrough(gate.turnStartDelivery()!)
await page.locator('[data-turn-process]').last().filter({ hasText: 'Deep diving' }).waitFor()
await expect.poll(() => placement(page)).toMatchObject({ echo: 1, dock: 0, durable: 0 })
trace.push({ phase: 'turn-start', ...await placement(page) })
}
+1 -1
View File
@@ -344,7 +344,7 @@ describe('web e2e: lifecycle & chrome (workspace flow / reload / dark mode)', ()
await reasoningComplete.promise
expect(await thinking.getAttribute('data-preview')).toBeNull()
expect(await thinking.getByRole('button').getAttribute('aria-expanded')).toBe('false')
expect(await thinking.locator('[data-streaming]:not([inert] *)').isVisible()).toBe(false)
expect(await thinking.locator('[data-streaming]').isVisible()).toBe(false)
}
observedReasoning.resolve(undefined)
return await settled
+1 -1
View File
@@ -158,7 +158,7 @@ describe('web e2e: live-turn interactions (cancel / error / retry)', () => {
// hang (prefix chunks delivered to the loop) before the stop click.
await expect.poll(() => existsSync(marker), { timeout: 15_000 }).toBe(true)
await expect.poll(
() => page.getByRole('status').filter({ hasText: 'Deep diving' }).isVisible(),
() => page.getByRole('status').filter({ hasText: 'Deep diving...' }).isVisible(),
{ timeout: 10_000 },
).toBe(true)
await page.locator('[data-streaming="true"]')
+4 -43
View File
@@ -8,7 +8,6 @@ import {
captureStableAria, compareOrRefreshGolden, launchWebScaffold, watchConsole, webSnapshotMode,
} from './scaffold.ts'
import { connectFreshWorkspace, expandOwningTurnProcess, newEnglishPage, writeComposerDraft } from './support.ts'
import { expectSharedShimmer } from './shimmer.ts'
const SUMMARY = `Next paragraph: ${'inspect the loaded context and pending tools '.repeat(8).trim()}`
const DELTAS = ['First paragraph', `\nDetails\n\n\n${SUMMARY}`, '\nMore detail']
@@ -50,7 +49,7 @@ it('shows completed paragraph first lines across blank lines with a right-edge f
const browser = await chromium.launch()
try {
const page = await newEnglishPage(browser)
const pageConsole = watchConsole(page)
const console = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await connectFreshWorkspace(page, scaffold.workspaceCwd)
await page.setViewportSize({ width: 480, height: 1000 })
@@ -69,32 +68,9 @@ it('shows completed paragraph first lines across blank lines with a right-edge f
first.proceed.resolve(undefined)
await second.arrived.promise
const preview = reasoning.locator('[data-streaming]:not([inert] *)')
const preview = reasoning.locator('[data-streaming]')
await expect.poll(() => preview.textContent()).toBe('First paragraph')
expect(await preview.isVisible()).toBe(true)
for (const fontSize of [14, 24]) {
await scaffold.ctx.settings.update('ui-theme', { fontSize })
await expect.poll(() => page.evaluate(() => document.body.style.getPropertyValue('--dsh-content-font-size')))
.toBe(`${String(fontSize)}px`)
const geometry = await preview.evaluate((element) => {
const text = element.firstElementChild
const header = element.closest('[data-disclosure-row]')!
const range = document.createRange()
range.selectNodeContents(text!)
return {
fadeStart: element.getBoundingClientRect().right - 48,
textRight: range.getBoundingClientRect().right,
previewRight: element.getBoundingClientRect().right,
headerRight: header.getBoundingClientRect().right,
headerHeight: header.getBoundingClientRect().height,
}
})
expect(geometry.textRight).toBeLessThanOrEqual(geometry.fadeStart)
expect(geometry.previewRight).toBe(geometry.headerRight)
expect(geometry.headerHeight).toBe(24 + fontSize - 14)
}
await scaffold.ctx.settings.update('ui-theme', { fontSize: 14 })
await expect.poll(() => page.evaluate(() => document.body.style.getPropertyValue('--dsh-content-font-size'))).toBe('14px')
await preview.evaluate((element) => { element.setAttribute('data-retained-preview', 'true') })
second.proceed.resolve(undefined)
@@ -103,30 +79,15 @@ it('shows completed paragraph first lines across blank lines with a right-edge f
expect(await preview.getAttribute('data-retained-preview')).toBe('true')
expect(await preview.evaluate(element => getComputedStyle(element).maskImage)).toContain('linear-gradient')
expect(await preview.evaluate(element => element.scrollWidth > element.clientWidth)).toBe(true)
await expectSharedShimmer(reasoning)
const group = page.locator('[data-process-activity]').filter({ has: page.locator('[data-shimmer="true"]') })
await expectSharedShimmer(group)
await group.hover()
await expectSharedShimmer(group)
expect(await reasoning.getByRole('button').getAttribute('aria-expanded')).toBe('false')
await compareOrRefreshGolden(UI_EXPECTED,
await captureStableAria(page, '[data-variant="think"]', scaffold.workspaceCwd), webSnapshotMode())
await group.click()
expect(await group.getAttribute('aria-expanded')).toBe('false')
await expectSharedShimmer(group)
await page.emulateMedia({ reducedMotion: 'reduce' })
try {
expect(await group.locator('[inert]').evaluate(element => getComputedStyle(element).display)).toBe('none')
expect(await group.innerText()).toBe('Analyzing the request')
} finally {
await page.emulateMedia({ reducedMotion: null })
}
third.proceed.resolve(undefined)
await settled
await page.getByText('Done', { exact: true }).waitFor()
expect(pageConsole.pageErrors).toEqual([])
expect(pageConsole.warnings).toEqual([])
expect(console.pageErrors).toEqual([])
expect(console.warnings).toEqual([])
} finally {
adapter.release()
await browser.close()
+1
View File
@@ -372,6 +372,7 @@ describe('web e2e: seeded history renders through cold resume', () => {
const processBottom = await process.evaluate(element => element.getBoundingClientRect().bottom)
const answerTop = await page.getByText('DONE', { exact: true }).evaluate(element =>
element.getBoundingClientRect().top)
// Collapsed control row keeps its own 8px margin plus the 8px flow gap.
expect(answerTop).toBe(processBottom + 16)
expect(await page.getByText('Context compacted', { exact: true }).count()).toBe(0)
// Tool cards render from logged tool/call + tool/result alone (views are
-66
View File
@@ -1,66 +0,0 @@
/** Browser geometry for one moving mask over text beside retained row icons. */
import type { Locator } from 'playwright'
import { expect } from 'vitest'
/**
* Check glyph alignment and scroll overflow across a running row's shared sweep.
* @param row - visible row containing exactly one active shimmer.
* @returns after restoring the row's animation playback.
*/
export async function expectSharedShimmer(row: Locator): Promise<void> {
const shimmer = row.locator('[data-shimmer="true"]')
expect(await shimmer.count()).toBe(1)
const frames = await shimmer.evaluate((root) => {
const base = root.firstElementChild!
const decoration = root.querySelector(':scope > [inert]')!
const mask = decoration.firstElementChild!
const highlight = mask.firstElementChild!
const animations = root.getAnimations({ subtree: true })
.filter(animation => animation instanceof CSSAnimation && animation.animationName.includes('dsh-row-shimmer-'))
const original = animations.map(animation => ({ animation, time: animation.currentTime, state: animation.playState }))
try {
return [400, 800, 1200, 1301, 1799, 1801].map((time) => {
for (const animation of animations) {
animation.pause()
animation.currentTime = time
}
const originals = [...base.querySelectorAll('svg, span, button')]
const copies = [...highlight.querySelectorAll('svg, span, button')]
return {
animations: animations.length,
mask: getComputedStyle(mask).maskImage,
hidden: decoration.getAttribute('aria-hidden'),
maskLeft: mask.getBoundingClientRect().left,
width: root.getBoundingClientRect().width,
clientWidth: root.clientWidth,
scrollWidth: root.scrollWidth,
icons: base.querySelectorAll('svg').length,
copyCount: copies.length,
baseCount: originals.length,
glyphOffsets: originals.map((element, index) => {
const a = element.getBoundingClientRect()
const b = copies[index]!.getBoundingClientRect()
return [a.left - b.left, a.top - b.top, a.width - b.width, a.height - b.height]
}).flat(),
}
})
} finally {
for (const { animation, time, state } of original) {
animation.currentTime = time
if (state === 'running') animation.play()
}
}
})
for (const frame of frames) {
expect(frame.animations).toBe(2)
expect(frame.mask).toContain('linear-gradient')
expect(frame.hidden).toBe('true')
expect(frame.width).toBeGreaterThan(0)
expect(frame.scrollWidth).toBe(frame.clientWidth)
expect(frame.icons).toBe(0)
expect(frame.copyCount).toBe(frame.baseCount)
expect(frame.glyphOffsets.every(offset => Math.abs(offset) < 0.1)).toBe(true)
}
expect(frames[0]!.maskLeft).toBeLessThan(frames[1]!.maskLeft)
expect(frames[1]!.maskLeft).toBeLessThan(frames[2]!.maskLeft)
}
@@ -8,12 +8,12 @@
- tab "Trajectory"
- text: Stream one TypeScript fence for the highlighting snapshot. {{clock}}
- button "Copy"
- status: Deep diving...
- button "Deep diving for {{duration}}" [disabled] [expanded]
- text: ts
- button "Wrap lines" [pressed]
- button "Copy"
- code: "const first: number = 1 const second = \"two\" let tail"
- status: Deep diving
- text: Deep diving for {{duration}}
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Add files or run commands"
- 'button "Access mode, current: Workspace Write"': Workspace Write
+3 -5
View File
@@ -85,7 +85,7 @@ export async function expandTurnProcesses(page: Page): Promise<void> {
}
/**
* Expand the settled Turn process and secondary group containing a hidden descendant.
* Expand the Turn process and secondary group containing a hidden descendant.
* @param page - page containing the Chat view.
* @param target - descendant whose outer process disclosures should open.
*/
@@ -94,10 +94,8 @@ export async function expandOwningTurnProcess(page: Page, target: Locator): Prom
const turn = await target.evaluate(element => element.closest<HTMLElement>('[data-chat-turn]')?.dataset.chatTurn)
if (turn !== undefined) {
const control = page.locator(`[data-turn-process="${turn}"]`)
if (await control.count() > 0) {
await control.waitFor({ state: 'visible', timeout: 10_000 })
if (await control.getAttribute('aria-expanded') === 'false') await control.click()
}
await control.waitFor({ state: 'visible', timeout: 10_000 })
if (await control.getAttribute('aria-expanded') === 'false') await control.click()
}
const group = target.locator('xpath=ancestor::*[@data-chat-group-key][1]')
const header = group.locator('[data-process-activity]').first()
+5 -87
View File
@@ -13,7 +13,6 @@ import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { expectSharedShimmer } from './shimmer.ts'
import { afterEach, describe, expect, it, onTestFailed, vi } from 'vitest'
import type { ReplayOverrideDoc } from '@deepseek-ai/dsh-llm-replay'
import type { SessionEvent } from '@deepseek-ai/dsh-session'
@@ -145,84 +144,16 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
// so the first step's message and tool result are already durable.
await expect.poll(() => existsSync(marker), { timeout: 20_000 }).toBe(true)
const runningProcess = page.locator('[data-turn-process]')
expect(await runningProcess.count()).toBe(0)
expect(await runningProcess.count()).toBe(1)
expect(await runningProcess.isDisabled()).toBe(true)
expect(await runningProcess.getAttribute('aria-expanded')).toBe('true')
await expect.poll(
() => page.getByRole('status').filter({ hasText: 'Deep diving' }).isVisible(),
() => page.getByRole('status').filter({ hasText: 'Deep diving...' }).isVisible(),
{ timeout: 10_000 },
).toBe(true)
await page.locator('[data-streaming="true"]')
.getByText('partial', { exact: true })
.waitFor({ timeout: 10_000 })
const runningStatus = page.locator('[data-chat-running]')
expect(await runningStatus.innerText()).toMatch(/Deep diving for \d+s/)
await expectSharedShimmer(runningStatus)
expect(await runningStatus.evaluate(element => getComputedStyle(element).fontSize)).toBe('12px')
const contentBottom = await page.locator('[data-streaming="true"]').evaluate(element =>
element.closest<HTMLElement>('[data-chat-flow-kind="assistant-step"]')!.getBoundingClientRect().bottom)
expect(await runningStatus.evaluate(element => element.getBoundingClientRect().top)).toBe(contentBottom + 32)
const originalFontSize = await page.evaluate(() => document.body.style.getPropertyValue('--dsh-content-font-size'))
try {
for (const fontSize of [14, 17, 24]) {
await scaffold!.ctx.settings.update('ui-theme', { fontSize })
await expect.poll(() => page.evaluate(() => document.body.style.getPropertyValue('--dsh-content-font-size')))
.toBe(`${String(fontSize)}px`)
const layout = await runningStatus.evaluate((status) => {
const icon = status.querySelector('svg')!
const label = status.querySelector('[data-shimmer] > span > span')!
const group = document.querySelector('[data-process-activity]')!
const groupIcon = group.querySelector('[data-step-process-icon]')!
return {
iconSize: icon.getBoundingClientRect().width,
groupIconSize: groupIcon.getBoundingClientRect().width,
gap: label.getBoundingClientRect().left - icon.getBoundingClientRect().right,
iconColor: getComputedStyle(icon).color,
textColor: getComputedStyle(label).color,
}
})
expect(layout.iconSize).toBe(layout.groupIconSize - 2)
expect(layout.gap).toBe(6)
expect(layout.iconColor).toBe(layout.textColor)
}
} finally {
await scaffold!.ctx.settings.update('ui-theme', { fontSize: Number.parseFloat(originalFontSize) })
await expect.poll(() => page.evaluate(() => document.body.style.getPropertyValue('--dsh-content-font-size')))
.toBe(originalFontSize)
}
const originalDarkTheme = await page.locator('body').getAttribute('data-ds-dark-theme')
try {
await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') })
const colors = await runningStatus.evaluate((element) => {
const context = document.createElement('canvas').getContext('2d', { willReadFrequently: true })!
const rgb = (color: string) => {
context.fillStyle = color
context.fillRect(0, 0, 1, 1)
return [...context.getImageData(0, 0, 1, 1).data].slice(0, 3).map(channel => channel / 255)
}
const luminance = (channels: number[]) => channels
.map(channel => channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4)
.reduce((sum, channel, index) => sum + channel * [0.2126, 0.7152, 0.0722][index]!, 0)
const base = rgb(getComputedStyle(element).color)
const sweep = rgb(getComputedStyle(element.querySelector('[data-shimmer] > [inert] > span')!).color)
const max = Math.max(...base)
const min = Math.min(...base)
return {
baseLight: luminance(base),
sweepLight: luminance(sweep),
saturation: (max - min) / (1 - Math.abs(max + min - 1)),
blueSweep: sweep[2]! > sweep[1]! && sweep[1]! > sweep[0]!,
}
})
expect(colors.saturation).toBeLessThan(0.7)
expect(colors.baseLight).toBeGreaterThan(0.25)
expect(colors.sweepLight).toBeGreaterThan(colors.baseLight * 1.3)
expect(colors.blueSweep).toBe(true)
await expectSharedShimmer(runningStatus)
} finally {
await page.evaluate((value) => {
if (value === null) document.body.removeAttribute('data-ds-dark-theme')
else document.body.setAttribute('data-ds-dark-theme', value)
}, originalDarkTheme)
}
// Only the user bubble owns a footer (clock + copy; user bubbles carry no
// branch action): the narration is not the answer yet.
const copyButtons = page.getByRole('button', { name: 'Copy' })
@@ -239,19 +170,6 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
await settled
expect(sessionEvents.filter(e => e.type === 'turn/end').map(e => e.data.reason.kind)).toEqual(['aborted'])
await page.locator('[data-turn-process]').waitFor({ timeout: 10_000 })
const stopped = page.getByRole('button', { name: 'Stopped', exact: true })
expect(await stopped.isEnabled()).toBe(true)
expect(await stopped.evaluate(element => getComputedStyle(element).fontSize)).toBe('14px')
expect(await stopped.getAttribute('aria-expanded')).toBe('false')
expect(await runningStatus.count()).toBe(0)
const processGroup = page.locator('[data-process-activity]').first()
expect(await processGroup.isVisible()).toBe(false)
await stopped.click()
expect(await stopped.getAttribute('aria-expanded')).toBe('true')
expect(await processGroup.isVisible()).toBe(true)
await stopped.click()
expect(await stopped.getAttribute('aria-expanded')).toBe('false')
expect(await processGroup.isVisible()).toBe(false)
await expect.poll(() => copyButtons.count(), { timeout: 10_000 }).toBe(2)
await expect.poll(() => page.locator('[data-streaming="true"]').count(), { timeout: 10_000 }).toBe(0)
await copyButtons.last().focus()
@@ -317,7 +235,7 @@ describe('web e2e: assistant IconActions wait for the turn to end', () => {
element.closest<HTMLElement>('[data-chat-flow-kind="turn-process"]')?.getBoundingClientRect().bottom)
const answerTop = await page.getByText('DONE', { exact: true }).evaluate(element =>
element.closest<HTMLElement>('[data-chat-flow-kind="assistant-step"]')?.getBoundingClientRect().top)
expect(answerTop).toBe((processBottom ?? 0) + 16)
expect(answerTop).toBe((processBottom ?? 0) + 8)
await process.focus()
const completed = await captureStableAria(page, '[class*="centerCol"]', scaffold!.workspaceCwd)
await compareOrRefreshGolden(COMPLETED_EXPECTED, completed, MODE)
+1 -7
View File
@@ -134,7 +134,7 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () =
const disclosures = element.querySelectorAll('[data-disclosure-row]')
const runHeader = disclosures[0]
const phaseHeader = disclosures[1]
const phaseTitle = phaseHeader?.querySelector<HTMLElement>('[data-shimmer] > span > span') ?? null
const phaseTitle = phaseHeader?.children.item(1) as HTMLElement | null
const phaseStatus = element.querySelector('[data-phase-status-text]')
const originalPhaseTitle = phaseTitle?.textContent ?? ''
if (phaseTitle !== null) phaseTitle.textContent = 'A phase name long enough to require ellipsis in the narrow layout'
@@ -153,10 +153,6 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () =
phaseHeight: phaseHeader?.getBoundingClientRect().height ?? 0,
phaseTitleRight,
phaseStatusLeft,
phaseStatusRight: phaseStatus?.getBoundingClientRect().right ?? 0,
phaseHeaderRight: phaseHeader?.getBoundingClientRect().right ?? 0,
contentGap: phaseTitle?.parentElement === null || phaseTitle?.parentElement === undefined
? '' : getComputedStyle(phaseTitle.parentElement).columnGap,
}
})
expect(darkNarrow.clientWidth).toBe(356)
@@ -169,8 +165,6 @@ describe.skipIf(MODE === 'record')('web e2e: durable workflow run in Chat', () =
expect(darkNarrow.runHeight).toBe(32)
expect(darkNarrow.phaseHeight).toBe(32)
expect(darkNarrow.phaseTitleRight).toBeLessThanOrEqual(darkNarrow.phaseStatusLeft)
expect(darkNarrow.phaseStatusRight).toBe(darkNarrow.phaseHeaderRight)
expect(darkNarrow.contentGap).toBe('6px')
await page.locator('[data-workflow-run]').evaluate((element) => {
(element as HTMLElement).style.removeProperty('width')
document.body.removeAttribute('data-ds-dark-theme')
+2 -2
View File
@@ -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: 463fd1d0ea3ad97623eabf1c4d57371fd7310927
README.zh.md: 8e3d4455e74f55c872ec5011f0b8ac59935d3b3b
README.md: cca209016220560b7f39831e11ebb677da515ea9
README.zh.md: 0b97e490f5a535c3b871ec7c6344d4299976b683
+1 -3
View File
@@ -40,7 +40,7 @@ Settings → General → Open chat links in selects the destination for ordinary
<a id="system-prompt-row"></a>
## Hidden Chat rows
Chat omits system-prompt, ordinary Context injection, and `permission` command rows in every work-details mode. The filter changes neither recorded Session events nor Trajectory inspection. Non-human Turn triggers remain independent, bordered notices with a contrasting neutral background; other command rows remain in Chat.
Chat omits system-prompt, ordinary Context injection, and `permission` command rows in every work-details mode. The filter changes neither recorded Session events nor Trajectory inspection. Non-human Turn triggers remain independent notices; other command rows remain in Chat.
When an Assistant attempt retires without a visible message, Chat hides its already-published Node instead of removing its key. A retry in the same Step reuses that key when visible content returns. This also applies when the loaded window lacks the Step start.
@@ -99,8 +99,6 @@ Each group owns local `useDisclosure` state that survives mode changes while its
The Chat-node slot injects a reset-bound `useDisclosure` Hook for reasoning and tools. Intermediate renderers forward it without subscribing; each invocation owns independent open state. Source callbacks retain their receiver and stable identity. When an enclosing Turn actually hides a process member, its seat resets those disclosures without replacing component keys or changing the Hook reference. Display-mode changes preserve their open state.
Process rows share one text and icon color in every Work details mode: tertiary at rest and secondary on hover, while error and warning text keeps its semantic color. Running group titles, reasoning previews, tool and command rows, the active retry line, and the bottom running status use the shared [Shimmer](../ui-primitives/README.md#component-catalog). Each row’s text shares one left-to-right highlight, including collapsed group titles; icons and hover chevrons retain their base appearance. Non-response rows sit 8px apart; anything beside an Assistant response is 16px away, and a closed Turn control keeps its answer 16px below the summary. Leading icons, disclosure chevrons, and their boxes scale with the content font-size delta.
-----
<a id="scroll-ownership"></a>
+1 -3
View File
@@ -40,7 +40,7 @@ Chat 在节点列表外通过一个 `MarkdownDelegateProvider` 提供文件及 H
<a id="system-prompt-row"></a>
## Chat 隐藏的行
Chat 在所有工作过程展示模式下都不显示系统提示词行、普通上下文注入和 `permission` 命令行。该过滤不改变已记录的 Session 事件或 Trajectory 查看能力。非人工轮次触发仍作为独立通知显示,使用细边框和与周围有区分度的中性底色,其他命令行仍保留在 Chat 中。
Chat 在所有工作过程展示模式下都不显示系统提示词行、普通上下文注入和 `permission` 命令行。该过滤不改变已记录的 Session 事件或 Trajectory 查看能力。非人工轮次触发仍作为独立通知显示,其他命令行仍保留在 Chat 中。
Assistant 尝试结束且没有可见消息时,Chat 隐藏已发布的 Node,不移除其 key。同一 Step 的重试再次产生可见内容时,复用该 key。已加载窗口缺少 Step 起点时也遵循此规则。
@@ -99,8 +99,6 @@ Chat 通过 `uiConversation.groups` 注册过程 Group Definition。React 通过
Chat 节点 slot 为推理与工具注入绑定重置来源的 `useDisclosure` 钩子。中间 renderer 只透传、不订阅,每次调用拥有独立展开状态。来源回调保留接收对象及稳定引用。外层轮次实际隐藏过程成员时,所在节点重置这些开合状态,不替换组件 key,也不改变钩子引用。展示模式切换保留展开状态。
过程行的文字与图标在所有工作过程展示模式下共用一种颜色:默认 tertiary、悬停 secondary,错误与警告文字保留语义色。运行中的分组标题、思考预览、工具与命令行、当前重试行以及底部运行状态使用共享的 [Shimmer](../ui-primitives/README.zh.md#component-catalog)。每行的文字共用一道从左到右的高光,包括折叠的分组标题;图标和悬停箭头保留底色。非回复条目之间相隔 8px;与 Assistant 回复相邻的条目相隔 16px,收起的整轮控件与其回答之间保持 16px。前置图标、折叠箭头及其容器随正文字号增量缩放。
-----
<a id="scroll-ownership"></a>
+3 -4
View File
@@ -82,18 +82,17 @@
"@deepseek-ai/dsh-session": "workspace:*",
"@deepseek-ai/dsh-session-stats": "workspace:*",
"@deepseek-ai/dsh-session-turn-outline": "workspace:*",
"@deepseek-ai/dsh-settings": "workspace:*",
"@deepseek-ai/dsh-subagent": "workspace:*",
"@deepseek-ai/dsh-token-meter": "workspace:*",
"@deepseek-ai/dsh-tools": "workspace:*",
"@deepseek-ai/dsh-util-values": "workspace:*",
"@deepseek-ai/dsh-util-workspace-path": "workspace:*",
"@deepseek-ai/dsh-util-values": "workspace:*",
"@tanstack/react-virtual": "^3.14.9",
"@types/react": "~18.3.1",
"@types/react-dom": "~18.3.0",
"lottie-web": "^5.13.0",
"react": "^18.2.0",
"react-dom": "^18.2.0"
"react-dom": "^18.2.0",
"@deepseek-ai/dsh-settings": "workspace:*"
},
"dependencies": {
"@deepseek-ai/schemastery": "workspace:~"
@@ -10,7 +10,7 @@
padding: 0;
border: 0;
background: none;
color: var(--dsw-alias-label-tertiary);
color: var(--dsw-alias-label-secondary);
font: inherit;
font-size: var(--dsh-content-font-size, 14px);
text-align: left;
@@ -19,30 +19,18 @@
}
.title:hover {
color: var(--dsw-alias-label-secondary);
color: var(--dsw-alias-label-primary);
}
/* The leading box and its glyph ride the body font-size delta so the icon
keeps its optical share of the row at every Settings size. */
.leading {
position: relative;
flex: none;
display: inline-flex;
align-items: center;
justify-content: center;
width: calc(16px + var(--dsh-content-font-delta, 0px));
height: calc(16px + var(--dsh-content-font-delta, 0px));
color: inherit;
}
.leading svg {
width: calc(14px + var(--dsh-content-font-delta, 0px));
height: calc(14px + var(--dsh-content-font-delta, 0px));
}
.leading svg[width="16"] {
width: calc(16px + var(--dsh-content-font-delta, 0px));
height: calc(16px + var(--dsh-content-font-delta, 0px));
width: 16px;
height: 16px;
color: var(--dsw-alias-label-tertiary);
}
.activityIcon,
@@ -80,7 +68,7 @@
}
.title[aria-expanded="true"] {
padding-bottom: 8px;
padding-bottom: 16px;
}
.body {
@@ -133,6 +121,7 @@
margin-top: var(--dsh-chat-flow-gap, 8px);
}
.expandedBody {
--dsh-chat-flow-gap: 16px;
max-height: none;
overflow: visible;
scrollbar-gutter: auto;
@@ -4,7 +4,7 @@ import {
IconAgentPresetOutlineRegular, IconApiOutlineRegular, IconBrowseOutlineRegular, IconChevronDownOutlineRegular,
IconChevronUpOutlineRegular, IconCodeOutlineRegular, IconEditOutlineRegular, IconGlobeOutlineRegular,
IconPlanOutlineRegular, IconQuestionOutlineRegular, IconSearchOutlineRegular, IconSparkleRegular,
IconThinkOutlineRegular, Shimmer, ShimmerText,
IconThinkOutlineRegular, TextShimmer,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { GroupKey, NodeReference } from '@deepseek-ai/dsh-client-ui-conversation/client'
import type { ProcessActivity } from '../contract/process-groups.ts'
@@ -120,9 +120,7 @@ const ProcessGroupHeader = memo(function ProcessGroupHeader({ groupKey, useChatG
{open ? <IconChevronUpOutlineRegular /> : <IconChevronDownOutlineRegular />}
</span>
</span>
<Shimmer active={!data.closed}>
<ShimmerText className={css.label}>{title}</ShimmerText>
</Shimmer>
<TextShimmer active={!data.closed} className={css.label}>{title}</TextShimmer>
</button>
)
})
@@ -143,7 +141,7 @@ export const ChatGroupSeat = memo(function ChatGroupSeat({ groupKey, useChatGrou
const grouped = props.usePresentation(policy => turnLocation?.status !== 'open' || policy.stepGrouping !== 'none')
const reason = turnLocation?.end?.data.reason.kind
const alwaysOpen = presentation?.turnClosed === false || presentation?.hasInterleavedInput === true
|| reason === 'error'
|| reason === 'aborted' || reason === 'error'
const spec = presentation?.spec
const selectStored = useCallback((state: Readonly<ChatStoreState>) => turn === undefined
? undefined : storedTurnProcessEntry(state, turn), [turn])
@@ -153,7 +153,6 @@ export const ChatNodeSeat = memo(function ChatNodeSeat({
data-chat-node-key={routedNode.key}
data-chat-group-part={groupPart}
data-chat-flow-kind={routedNode.kind}
data-chat-flow-response={groupPart === 'response' || undefined}
data-chat-turn={turn}
data-turn-process-member={processMember || undefined}
data-turn-process-hidden={processHidden || undefined}
@@ -67,17 +67,7 @@
hidden and empty Seats do not contribute spacing. */
.column > :not([hidden]):not(.flowItem:empty)
~ :not([hidden]):not(.flowItem:empty) {
margin-top: var(--dsh-chat-flow-gap, 8px);
}
/* Assistant responses have 16px clearance from adjacent flow items. */
.flowItem[data-chat-flow-response] {
--dsh-chat-flow-gap: 16px;
}
.column > [data-chat-flow-response]:not([hidden]):not(:empty)
+ :not([hidden]):not(.flowItem:empty):not(.running) {
--dsh-chat-flow-gap: 16px;
margin-top: var(--dsh-chat-flow-gap, 16px);
}
/* Settled-flow identity boundary. It is neutral until it becomes the natural
@@ -86,11 +76,10 @@
min-width: 0;
}
/* The Turn header has 16px clearance from its input and following content. */
.flowItem[data-chat-flow-kind="turn-process"],
.flowItem[data-turn-process-answer],
.column > [data-chat-flow-kind="turn-process"] + :not([hidden]):not(.flowItem:empty):not(.running) {
--dsh-chat-flow-gap: 16px;
/* A closed process reads as one summary immediately followed by its answer.
Expanded process rows return to the ordinary 16px rhythm. */
.flowItem[data-turn-process-answer] {
--dsh-chat-flow-gap: 8px;
}
/* Empty Seats retain ordered layout positions for Turn lookup without
@@ -112,56 +101,6 @@
line-height: calc(18px + var(--dsh-content-font-delta-secondary, 0px));
}
.running {
display: flex;
align-items: center;
justify-content: center;
column-gap: 16px;
--dsh-chat-flow-gap: 32px;
--dsw-alias-label-shimmer: var(--dsw-alias-label-deep-diving-shimmer);
color: var(--dsw-alias-label-deep-diving);
font-size: calc(var(--dsh-content-font-size, 14px) - 2px);
line-height: calc(22px + var(--dsh-content-font-delta, 0px));
}
.running::before,
.running::after {
content: '';
flex: 0 0 32px;
border-top: 0.5px solid var(--dsw-alias-border-l3);
}
.scroll:has(.running) {
padding-bottom: 24px;
}
.runningContent {
display: inline-flex;
align-items: center;
min-width: 0;
gap: 6px;
}
.runningText {
min-width: 0;
}
.runningNumber {
font-family: var(--ds-font-family-code);
font-variant-numeric: tabular-nums;
}
.runningIcon {
display: inline-flex;
flex: none;
width: calc(14px + var(--dsh-content-font-delta, 0px));
height: calc(14px + var(--dsh-content-font-delta, 0px));
}
.runningIcon path[stroke] {
stroke: currentColor;
}
.openError {
color: var(--dsw-alias-state-error-primary);
font-size: var(--dsh-content-font-size-secondary, 13px);
@@ -18,7 +18,6 @@ import { ChatGroupSeat } from './ChatGroupSeat.tsx'
import { chatRenderKey } from './render-entry.ts'
import { assertNever } from '@deepseek-ai/dsh-util-values'
import { TurnNavigator } from './TurnNavigator.tsx'
import { RunningStatus } from './RunningStatus.tsx'
import { mergeTurnRailItems } from './turn-rail-items.ts'
import { useChatScroll } from './use-chat-scroll.ts'
import { fileMediaUrl, resolveWorkspacePath } from '@deepseek-ai/dsh-util-workspace-path'
@@ -270,7 +269,6 @@ export function ChatView({
t={t}
/>
</MarkdownDelegateProvider>
{running && <RunningStatus useChat={useChat} t={t} />}
{/* No pending placeholders: questions (ui-user-questions) and approvals
(ApprovalPanel) both take over the composer, so a flow card would
double-render the same wait. */}
@@ -7,8 +7,13 @@
flex-shrink: 0;
}
.chevron {
color: var(--dsw-alias-label-secondary);
}
.title {
font-weight: 400;
transition: color 100ms ease;
}
.separator {
@@ -24,10 +29,17 @@
min-width: 0;
overflow: hidden;
flex: 1 1 auto;
color: var(--dsw-alias-label-tertiary);
font-size: var(--dsh-content-font-size-secondary, 13px);
line-height: calc(24px + var(--dsh-content-font-delta, 0px));
text-overflow: ellipsis;
white-space: nowrap;
transition: color 100ms ease;
}
.row:hover .title,
.row:hover .summary:not([data-error]) {
color: var(--dsw-alias-label-primary);
}
.summary[data-error],
@@ -1,6 +1,6 @@
import { memo, useCallback, useMemo, useState } from 'react'
import type { ChatViewSlotProps, CommandRowOwnerProps } from '../contract/slots.ts'
import { DisclosureRow, IconApiOutlineRegular, ShimmerText } from '@deepseek-ai/dsh-client-ui-primitives'
import { DisclosureRow, IconApiOutlineRegular, TextShimmer } from '@deepseek-ai/dsh-client-ui-primitives'
import a11yCss from './accessibility.module.css'
import css from './GenericCommandCard.module.css'
@@ -42,12 +42,12 @@ export const GenericCommandCard = memo(function GenericCommandCard({ node, t, ru
const toggle = useCallback(() => { setExpanded(value => !value) }, [])
const collapsedContent = useMemo(() => (
<>
<span className={css.separator} data-shimmer-decoration aria-hidden />
<span className={css.separator} aria-hidden />
<span className={css.summary} data-error={state === 'error' || undefined}>
<ShimmerText>{summary}</ShimmerText>
<TextShimmer active={running}>{summary}</TextShimmer>
</span>
</>
), [state, summary])
), [running, state, summary])
const content = useMemo(() => open
? <pre className={css.body} data-error={state === 'error' || undefined}>{body}</pre>
: undefined, [body, open, state])
@@ -56,8 +56,10 @@ export const GenericCommandCard = memo(function GenericCommandCard({ node, t, ru
{state === 'running' && <span className={a11yCss.visuallyHidden}>{t('row.running')}</span>}
{state === 'error' && <span className={a11yCss.visuallyHidden}>{t('row.failed')}</span>}
<DisclosureRow
rowClassName={css.row}
leadingClassName={css.leading}
titleClassName={css.title}
chevronClassName={css.chevron}
icon={COMMAND_ICON}
title={title}
running={running}
@@ -49,8 +49,9 @@
padding: 2px 0;
}
/* Compaction marker: one 24px row with a context icon at rest and a
hover/focus disclosure for the summary body. */
/* Compaction marker: one dim 24px row with a context icon at rest and a
hover/focus disclosure for the summary body. Dimmed title (not
label-primary) — the row is a boundary notice, not conversation content. */
.compactionRow {
/* The toggle's line, and the band a pinned toggle occupies. The button's
height and the offset that holds a summary's code banner below that band
@@ -69,8 +70,7 @@
border: none;
border-radius: 6px;
background: none;
color: var(--dsw-alias-label-tertiary);
transition: color 100ms ease;
color: inherit;
font: inherit;
text-align: left;
}
@@ -114,10 +114,6 @@
cursor: pointer;
}
.compactionButton:hover {
color: var(--dsw-alias-label-secondary);
}
.compactionButton:not(:disabled):hover {
background: var(--dsw-alias-interactive-bg-hover);
}
@@ -129,7 +125,7 @@
width: calc(16px + var(--dsh-content-font-delta, 0px));
height: calc(16px + var(--dsh-content-font-delta, 0px));
margin-right: 6px;
color: inherit;
color: var(--dsw-alias-label-secondary);
}
/* Same glyph-scaling rule as DisclosureRow's leading: the CSS edge overrides
@@ -165,7 +161,7 @@
flex: none;
font-size: var(--dsh-content-font-size-secondary, 13px);
line-height: calc(24px + var(--dsh-content-font-delta, 0px));
color: inherit;
color: var(--dsw-alias-label-primary-dimmed);
}
.compactionSep {
@@ -181,7 +177,7 @@
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
color: inherit;
color: var(--dsw-alias-label-tertiary);
font-size: var(--dsh-content-font-size-secondary, 13px);
line-height: calc(24px + var(--dsh-content-font-delta, 0px));
text-overflow: ellipsis;
@@ -225,6 +221,7 @@
border-right: 1.5px solid currentcolor;
border-bottom: 1.5px solid currentcolor;
content: '';
opacity: 0.8;
transform: rotate(-45deg);
transition: transform 120ms ease;
}
@@ -233,17 +230,32 @@
color: var(--dsw-alias-label-secondary);
}
@media (prefers-reduced-motion: reduce) {
.compactionButton {
transition: none;
}
}
.retrySummary:focus-visible {
outline: 1.5px solid var(--dsw-alias-button-info-fill);
outline-offset: 2px;
}
.retryText {
color: inherit;
}
.retryRow[data-active] .retryText {
background:
linear-gradient(
90deg,
var(--dsw-alias-label-tertiary) 0%,
var(--dsw-alias-label-tertiary) 40%,
var(--dsw-alias-label-secondary) 50%,
var(--dsw-alias-label-tertiary) 60%,
var(--dsw-alias-label-tertiary) 100%
);
background-position: 100% 50%;
background-size: 200% 100%;
background-clip: text;
color: transparent;
animation: retry-shimmer 1.6s ease-in-out infinite;
}
.retryRow[open] .retrySummary::after {
transform: rotate(45deg);
}
@@ -302,6 +314,24 @@
font-weight: 600;
}
@keyframes retry-shimmer {
from {
background-position: 100% 50%;
}
to {
background-position: 0 50%;
}
}
@media (prefers-reduced-motion: reduce) {
.retryRow[data-active] .retryText {
background: none;
color: inherit;
animation: none;
}
}
.attachmentRow {
display: flex;
flex-wrap: wrap;
@@ -2,7 +2,7 @@ import { Fragment, memo, useEffect, useMemo, useState } from 'react'
import type { ReactNode } from 'react'
import type { PendingSubmission } from '@deepseek-ai/dsh-api-session-controller/client'
import type { MessageImageSource } from '@deepseek-ai/dsh-client-ui-conversation/client'
import { fileExtension, FileTypeIcon, fileSizeText, JsonBlock, projectUserText, StateDot, Shimmer, ShimmerText } from '@deepseek-ai/dsh-client-ui-primitives'
import { fileExtension, FileTypeIcon, fileSizeText, JsonBlock, projectUserText, StateDot } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps } from '../contract/slots.ts'
import type { ModelRetryNode, TurnErrorNode, UserMessageNode } from '../contract/snapshot.ts'
import { CompactionItem } from './CompactionItem.tsx'
@@ -99,13 +99,12 @@ function ModelRetryItem({ node, active, t }: {
? t('message.retry.started')
: t('message.retry.scheduled')
const seconds = active ? remainingSeconds : scheduledSeconds
const status = t('message.retry.status', { label, retry: node.retry, maximum, seconds })
return (
<details className={css.retryRow} data-active={active || undefined}>
<summary className={css.retrySummary}>
<span role="status">
<Shimmer active={active}><ShimmerText>{status}</ShimmerText></Shimmer>
<span className={css.retryText} role="status">
{t('message.retry.status', { label, retry: node.retry, maximum, seconds })}
</span>
</summary>
<div className={css.retryDetails}>
@@ -9,6 +9,7 @@
}
.row {
position: relative;
overflow: hidden;
}
@@ -21,10 +22,35 @@
background: var(--dsw-alias-bg-base);
}
.root[data-state='running'] .row::after {
content: '';
position: absolute;
inset-block: 0;
left: 0;
width: 300px;
background: linear-gradient(
90deg,
transparent 0%,
color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
transparent 100%
);
animation: dsh-reasoning-row-sweep 2.6s ease-out infinite;
pointer-events: none;
}
@keyframes dsh-reasoning-row-sweep {
0% { left: -300px; }
90%, 100% { left: 100%; }
}
.leading {
flex-shrink: 0;
}
.chevron {
color: var(--dsw-alias-label-secondary);
}
.title {
font-weight: 400;
}
@@ -45,6 +71,7 @@
min-width: 0;
overflow: hidden;
flex: 1 1 auto;
color: var(--dsw-alias-label-tertiary);
font-size: var(--dsh-content-font-size-secondary, 13px);
line-height: calc(20px + var(--dsh-content-font-delta-secondary, 0px));
white-space: nowrap;
@@ -75,3 +102,9 @@
padding: 4px 0 4px calc(22px + var(--dsh-content-font-delta, 0px));
min-width: 0;
}
@media (prefers-reduced-motion: reduce) {
.root[data-state='running'] .row::after {
animation: none;
}
}
@@ -1,6 +1,6 @@
/** Assistant reasoning disclosure, independent of Tool-call presentation. */
import { memo, useMemo } from 'react'
import { DisclosureRow, IconThinkOutlineRegular, MarkdownText, ShimmerText } from '@deepseek-ai/dsh-client-ui-primitives'
import { DisclosureRow, IconThinkOutlineRegular, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatViewSlotProps, UseDisclosure, UsePresentation } from '../contract/slots.ts'
import { markdownLabels } from '../markdown-labels.ts'
import a11yCss from './accessibility.module.css'
@@ -59,11 +59,9 @@ export const ReasoningRow = memo(function ReasoningRow({ text, running, usePrese
&& (running || policy.settledReasoningPreview))
const collapsedContent = useMemo(() => (
<>
<span className={css.separator} data-shimmer-decoration aria-hidden />
<span className={css.separator} aria-hidden />
<span className={css.summary} data-streaming={running || undefined}>
<span className={css.summaryText}>
<ShimmerText>{summary}</ShimmerText>
</span>
<span className={css.summaryText}>{summary}</span>
</span>
</>
), [running, summary])
@@ -86,9 +84,9 @@ export const ReasoningRow = memo(function ReasoningRow({ text, running, usePrese
rowClassName={css.row}
leadingClassName={css.leading}
titleClassName={css.title}
chevronClassName={css.chevron}
icon={THINK_ICON}
title={t('message.think')}
running={running}
open={expanded}
expandable
expandOnRowClick
@@ -1,82 +0,0 @@
/** Running Turn clock isolated from the transcript's render cycle. */
import { memo, useEffect, useRef, useState } from 'react'
import { Shimmer, ShimmerText } from '@deepseek-ai/dsh-client-ui-primitives'
import lottie from 'lottie-web/build/player/lottie_light.js'
import type { ChatViewSlotProps } from '../contract/slots.ts'
import { formatLiveRunDuration, LIVE_RUN_CLOCK_INTERVAL_MS } from './message-chrome.ts'
import whaleTail from './whale-tail.lottie.json' with { type: 'json' }
import a11yCss from './accessibility.module.css'
import css from './ChatView.module.css'
/**
* Show live elapsed time after the current Turn's content without announcing ticks.
* @param props - Chat timeline selector and localized copy.
* @returns the blue running indicator; mount only while the Session is running.
*/
export const RunningStatus = memo(function RunningStatus({ useChat, t }: Pick<ChatViewSlotProps, 'useChat' | 't'>) {
const startTime = useChat(({ timeline }) => {
const latest = timeline.turnOrder.at(-1)
const turn = latest === undefined ? undefined : timeline.turns.get(latest)
return turn?.status === 'open' ? turn.start?.time : undefined
})
const [now, setNow] = useState(Date.now)
useEffect(() => {
if (startTime === undefined) return
setNow(Date.now())
const timer = setInterval(() => { setNow(Date.now()) }, LIVE_RUN_CLOCK_INTERVAL_MS)
return () => { clearInterval(timer) }
}, [startTime])
const label = startTime === undefined ? t('chat.deepDiving') : t('chat.deepDivingFor', {
duration: formatLiveRunDuration(Math.max(1000, now - startTime), t),
})
return (
<div className={css.running} data-chat-running>
<span className={a11yCss.visuallyHidden} role="status" aria-live="polite" aria-atomic="true">{t('chat.deepDiving')}</span>
<span className={css.runningContent}>
<RunningWhaleTail />
<Shimmer active>
<span className={css.runningText}>
{label.split(/(\d+)/).map((part, index) => (
<ShimmerText key={index} className={/^\d+$/.test(part) ? css.runningNumber : undefined}>{part}</ShimmerText>
))}
</span>
</Shimmer>
</span>
</div>
)
})
/** Loop once per two text sweeps (3s), with a 0.3s delay; hold the initial pose for reduced motion. */
function RunningWhaleTail() {
const container = useRef<HTMLSpanElement>(null)
useEffect(() => {
if (container.current === null) return
const reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)')
const animation = lottie.loadAnimation({
container: container.current,
renderer: 'svg',
loop: true,
autoplay: false,
animationData: structuredClone(whaleTail),
rendererSettings: { focusable: false },
})
let playTimer: number | undefined
const syncPlayback = () => {
window.clearTimeout(playTimer)
animation.goToAndStop(0, true)
if (!reducedMotion.matches) {
playTimer = window.setTimeout(() => { animation.play() }, 300)
}
}
animation.addEventListener('DOMLoaded', syncPlayback)
reducedMotion.addEventListener('change', syncPlayback)
syncPlayback()
return () => {
window.clearTimeout(playTimer)
reducedMotion.removeEventListener('change', syncPlayback)
animation.removeEventListener('DOMLoaded', syncPlayback)
animation.destroy()
}
}, [])
return <span ref={container} className={css.runningIcon} aria-hidden="true" />
}
@@ -10,8 +10,6 @@
border-bottom: 0.5px solid var(--dsw-alias-border-l2);
background: none;
color: var(--dsw-alias-label-tertiary);
font: inherit;
font-size: var(--dsh-content-font-size, 14px);
cursor: pointer;
text-align: left;
transition: color 100ms ease;
@@ -21,8 +19,12 @@
cursor: default;
}
.root:hover:not(:disabled) {
color: var(--dsw-alias-label-secondary);
.root:not(:disabled):hover {
color: var(--dsw-alias-label-primary);
}
.root:not([data-open]) {
margin-bottom: 8px;
}
.chevron {
@@ -30,7 +32,7 @@
width: 14px;
height: 14px;
margin-left: 4px;
color: inherit;
color: var(--dsw-alias-label-caption);
transition: transform 100ms ease;
}
@@ -41,7 +43,7 @@
.label {
min-width: 0;
overflow: hidden;
font-size: inherit;
font-size: var(--dsh-content-font-size-secondary, 13px);
line-height: calc(24px + var(--dsh-content-font-delta, 0px));
text-overflow: ellipsis;
white-space: nowrap;
@@ -1,12 +1,12 @@
import { memo } from 'react'
import { memo, useEffect, useState } from 'react'
import { IconChevronDownOutlineRegular } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ChatNodeViewProps } from '../contract/slots.ts'
import { turnProcessAlwaysOpen } from '../contract/turn-process.ts'
import { formatRunDuration } from './message-chrome.ts'
import { formatLiveRunDuration, formatRunDuration, LIVE_RUN_CLOCK_INTERVAL_MS } from './message-chrome.ts'
import a11yCss from './accessibility.module.css'
import css from './TurnProcessNodeView.module.css'
/** Settled Turn duration and process disclosure above its content. */
/** Turn-level process disclosure controller. */
export const TurnProcessNodeView = memo(function TurnProcessNodeView({
node, turnProcess, t,
}: ChatNodeViewProps<'turn-process'>) {
@@ -15,21 +15,33 @@ export const TurnProcessNodeView = memo(function TurnProcessNodeView({
const turn = node.location.kind === 'turn' || node.location.kind === 'step'
? node.location.turn
: undefined
if (!turnProcess.foldable || turn?.status === 'open') return null
const [now, setNow] = useState(Date.now)
const ticking = turnProcess.foldable && turn?.status === 'open'
useEffect(() => {
if (!ticking) return
setNow(Date.now())
const timer = setInterval(() => { setNow(Date.now()) }, LIVE_RUN_CLOCK_INTERVAL_MS)
return () => { clearInterval(timer) }
}, [ticking])
if (!turnProcess.foldable) return null
const canCollapse = turnProcess.hasContent && !turnProcessAlwaysOpen(node)
const running = turn?.status === 'open'
const reason = turn?.end?.data.reason.kind
const elapsedMs = turn?.start === undefined || turn.end === undefined ? undefined
: Math.max(1000, turn.end.time - turn.start.time)
const elapsedMs = turn?.start === undefined ? undefined
: Math.max(1000, (turn.end?.time ?? now) - turn.start.time)
const duration = elapsedMs === undefined ? undefined
: formatRunDuration(elapsedMs, t)
: running ? formatLiveRunDuration(elapsedMs, t) : formatRunDuration(elapsedMs, t)
// Other end reasons retain elapsed time; only cancellation and failure replace it.
const label = reason === 'aborted' ? t('message.stopped')
: reason === 'error' ? t('message.turnProcess.failed')
: duration === undefined ? t('message.turnProcess.worked')
: t('message.turnProcess.took', { duration })
const announcement = reason === 'aborted' ? t('message.stopped')
: reason === 'error' ? t('message.turnProcess.failed')
: t('message.turnProcess.worked')
const label = running
? duration === undefined ? t('chat.deepDiving') : t('message.turnProcess.deepDivingFor', { duration })
: reason === 'aborted' ? t('message.stopped')
: reason === 'error' ? t('message.turnProcess.failed')
: duration === undefined ? t('message.turnProcess.worked')
: t('message.turnProcess.took', { duration })
const announcement = running ? t('chat.deepDiving')
: reason === 'aborted' ? t('message.stopped')
: reason === 'error' ? t('message.turnProcess.failed')
: t('message.turnProcess.worked')
return (
<>
<span className={a11yCss.visuallyHidden} role="status" aria-live="polite" aria-atomic="true">{announcement}</span>
@@ -2,10 +2,10 @@
min-width: 0;
border: 0.5px solid var(--dsw-alias-border-l1);
border-radius: 16px;
background: var(--dsw-alias-turn-trigger-bg, var(--dsw-alias-markdown-code-block));
background: var(--dsw-alias-markdown-code-block);
transition: background-color 100ms ease;
}
.root:hover { background: var(--dsw-alias-turn-trigger-bg-hover, var(--dsw-alias-interactive-bg-hover)); }
.root:hover { background: var(--dsw-alias-interactive-bg-hover); }
.header {
display: flex;
align-items: center;
@@ -14,22 +14,20 @@
padding: 12px 16px;
border: 0;
background: none;
color: var(--dsw-alias-label-tertiary);
transition: color 100ms ease;
color: var(--dsw-alias-label-primary);
font: inherit;
text-align: left;
cursor: pointer;
}
.header:hover { color: var(--dsw-alias-label-secondary); }
.icon { display: inline-flex; flex: none; color: inherit; }
.title { flex: none; font-size: var(--dsh-content-font-size, 14px); }
.time { flex: none; margin-left: auto; color: inherit; font: var(--dsw-font-xxs-12); }
.chevron, .openChevron { flex: none; color: inherit; }
.icon { display: inline-flex; flex: none; color: var(--dsw-alias-label-tertiary); }
.title { flex: none; font: var(--dsw-font-xs-13); }
.time { flex: none; margin-left: auto; color: var(--dsw-alias-label-caption); font: var(--dsw-font-xxs-12); }
.chevron, .openChevron { flex: none; color: var(--dsw-alias-label-tertiary); }
.openChevron { transform: rotate(180deg); }
.body { padding: 0 16px 12px 40px; }
.explanation { margin: 8px 0; color: var(--dsw-alias-label-secondary); font: var(--dsw-font-xxs-12); }
.content { max-height: 240px; overflow: auto; white-space: pre-wrap; overflow-wrap: anywhere; font: var(--dsw-font-xxs-12); }
@media (prefers-reduced-motion: reduce) {
.root, .header { transition: none; }
.root { transition: none; }
}
@@ -1,6 +0,0 @@
/** The lightweight SVG player shares the main package's public player API. */
declare module 'lottie-web/build/player/lottie_light.js' {
import type { LottiePlayer } from 'lottie-web'
const player: LottiePlayer
export default player
}
@@ -1 +0,0 @@
{"v":"5.9.6","fr":60,"ip":0,"op":180,"w":16,"h":16,"nm":"Whale Tail | Sway A | 2x","ddd":0,"assets":[],"layers":[{"ddd":0,"ind":1,"ty":4,"nm":"stationary-rhythmic-open-tail","sr":1,"ks":{"o":{"a":0,"k":100},"r":{"a":0,"k":0},"p":{"a":0,"k":[6.884815,13.7421,0]},"a":{"a":0,"k":[6.884815,13.7421,0]},"s":{"a":0,"k":[100,100,100]}},"ao":0,"shapes":[{"ty":"gr","nm":"Open SVG contour","it":[{"ty":"sh","ks":{"a":1,"k":[{"t":0.0,"s":[{"c":false,"v":[[8.84413,13.7421],[8.45,9.65],[9.6,8.43],[12.6853,8.10373],[15.0553,5.94531],[14.8293,5.67127],[11.8273,4.97822],[8.93468,4.87573],[8.63618,4.68559],[5.49823,2.23774],[3.34746,1.20192],[3.03015,1.24224],[5.24832,6.11495],[6.57445,7.6368],[6.0615,9.36788],[4.9255,13.7421]],"i":[[0.0,0.0],[0.0,0.75],[-0.72,0.0],[-0.5793,0.17706],[-0.4708,0.82277],[0.1609,-0.03036],[1.0007,0.3976],[0.5561,-0.16904],[0.02155,0.13358],[1.90652,0.37719],[0.40013,0.37174],[0.0578,-0.11725],[-3.08054,-2.29491],[-0.26479,-0.85227],[0.09525,-0.20034],[0.16347,-1.5518]],"o":[[0.12266,-1.4142],[0.0,-0.71],[1.6852,0.0],[1.0247,-0.3132],[0.0813,-0.14207],[-1.0006,0.18886],[-1.1682,-0.46417],[-0.12946,0.03935],[-0.16812,-1.0422],[-0.95859,-0.18961],[-0.09577,-0.08897],[-0.40244,0.81639],[0.572,0.37882],[0.14507,0.46693],[-0.90435,1.90212],[0.0,0.0]]}],"i":{"x":1,"y":1},"o":{"x":0,"y":0}},{"t":1.714286,"s":[{"c":false,"v":[[8.84413,13.7421],[8.45,9.65],[9.6,8.43],[12.6853,8.10373],[15.0553,5.94531],[14.8293,5.67127],[11.8273,4.97822],[8.93468,4.87573],[8.63618,4.68559],[5.49823,2.23774],[3.34746,1.20192],[3.03015,1.24224],[5.24832,6.11495],[6.57445,7.6368],[6.0615,9.36788],[4.9255,13.7421]],"i":[[0.0,0.0],[0.0,0.75],[-0.72,0.0],[-0.5793,0.17706],[-0.4708,0.82277],[0.1609,-0.03036],[1.0007,0.3976],[0.5561,-0.16904],[0.02155,0.13358],[1.90652,0.37719],[0.40013,0.37174],[0.0578,-0.11725],[-3.08054,-2.29491],[-0.26479,-0.85227],[0.09525,-0.20034],[0.16347,-1.5518]],"o":[[0.12266,-1.4142],[0.0,-0.71],[1.6852,0.0],[1.0247,-0.3132],[0.0813,-0.14207],[-1.0006,0.18886],[-1.1682,-0.46417],[-0.12946,0.03935],[-0.16812,-1.0422],[-0.95859,-0.18961],[-0.09577,-0.08897],[-0.40244,0.81639],[0.572,0.37882],[0.14507,0.46693],[-0.90435,1.90212],[0.0,0.0]]}],"i":{"x":1,"y":1},"o":{"x":0,"y":0}},{"t":3.428571,"s":[{"c":false,"v":[[8.84413,13.7421],[8.45,9.65],[9.6,8.43],[12.6853,8.10373],[15.0553,5.94531],[14.8293,5.67127],[11.8273,4.97822],[8.93468,4.87573],[8.63618,4.68559],[5.49823,2.23774],[3.34746,1.20192],[3.03015,1.24224],[5.24832,6.11495],[6.57445,7.6368],[6.0615,9.36788],[4.9255,13.7421]],"i":[[0.0,0.0],[0.0,0.75],[-0.72,0.0],[-0.5793,0.17706],[-0.4708,0.82277],[0.1609,-0.03036],[1.0007,0.3976],[0.5561,-0.16904],[0.02155,0.13358],[1.90652,0.37719],[0.40013,0.37174],[0.0578,-0.11725],[-3.08054,-2.29491],[-0.26479,-0.85227],[0.09525,-0.20034],[0.16347,-1.5518]],"o":[[0.12266,-1.4142],[0.0,-0.71],[1.6852,0.0],[1.0247,-0.3132],[0.0813,-0.14207],[-1.0006,0.18886],[-1.1682,-0.46417],[-0.12946,0.03935],[-0.16812,-1.0422],[-0.95859,-0.18961],[-0.09577,-0.08897],[-0.40244,0.81639],[0.572,0.37882],[0.14507,0.46693],[-0.90435,1.90212],[0.0,0.0]]}],"i":{"x":1,"y":1},"o":{"x":0,"y":0}},{"t":5.142857,"s":[{"c":false,"v":[[8.84413,13.7421],[8.45,9.65],[9.6,8.43],[12.6853,8.10373],[15.0553,5.94531],[14.8293,5.67127],[11.8273,4.97822],[8.93468,4.87573],[8.63618,4.68559],[5.49823,2.23774],[3.34746,1.20192],[3.03015,1.24224],[5.24832,6.11495],[6.57445,7.6368],[6.0615,9.36788],[4.9255,13.7421]],"i":[[0.0,0.0],[0.0,0.75],[-0.72,0.0],[-0.5793,0.17706],[-0.4708,0.82277],[0.1609,-0.03036],[1.0007,0.3976],[0.5561,-0.16904],[0.02155,0.13358],[1.90652,0.37719],[0.40013,0.37174],[0.0578,-0.11725],[-3.08054,-2.29491],[-0.26479,-0.85227],[0.09525,-0.20034],[0.16347,-1.5518]],"o":[[0.12266,-1.4142],[0.0,-0.71],[1.6852,0.0],[1.0247,-0.3132],[0.0813,-0.14207],[-1.0006,0.18886],[-1.1682,-0.46417],[-0.12946,0.03935],[-0.16812,-1.0422],[-0.95859,-0.18961],[-0.09577,-0.08897],[-0.40244,0.81639],[0.572,0.37882],[0.14507,0.46693],[-0.90435,1.90212],[0.0,0.0]]}],"i":{"x":1,"y":1},"o":{"x":0,"y":0}},{"t":6.857143,"s":[{"c":false,"v":[[8.84413,13.7421],[8.45009,9.65009],[9.60017,8.43019],[12.68549,8.10414],[15.05562,5.94572],[14.82964,5.67164],[11.82767,4.9784],[8.93505,4.8758],[8.63656,4.68565],[5.49865,2.23773],[3.34788,1.20192],[3.03057,1.24223],[5.24864,6.11486],[6.57467,7.63678],[6.06161,9.36783],[4.9255,13.7421]],"i":[[0.0,0.0],[-4e-05,0.74998],[-0.72,-5e-05],[-0.57931,0.17702],[-0.47084,0.82283],[0.1609,-0.03036],[1.00069,0.39766],[0.55611,-0.16903],[0.02155,0.13358],[1.90652,0.3772],[0.40013,0.37174],[0.0578,-0.11725],[-3.08042,-2.29502],[-0.26473,-0.85229],[0.09526,-0.20034],[0.16347,-1.5518]],"o":[[0.12266,-1.4142],[4e-05,-0.70998],[1.6852,0.00012],[1.02472,-0.31313],[0.08131,-0.14208],[-1.00061,0.18883],[-1.16818,-0.46425],[-0.12946,0.03935],[-0.16809,-1.04223],[-0.95859,-0.18961],[-0.09577,-0.08897],[-0.40244,0.81639],[0.57198,0.37884],[0.14504,0.46694],[-0.90446,1.90209],[0.0,0.0]]}],"i":{"x":1,"y":1},"o":{"x":0,"y":0}},{"t":8.571429,"s":[{"Line truncated
@@ -62,7 +62,7 @@ export function isSubagentDelegationTool(name: string): boolean {
}
/**
* Keep live and failed Turns open.
* Keep live, stopped, and failed Turns open.
* @param node - Node carrying the owning Turn.
* @returns whether whole-Turn collapse is unavailable.
*/
@@ -70,5 +70,5 @@ export function turnProcessAlwaysOpen(node: ChatNode | undefined): boolean {
const location = node?.location
if (location?.kind !== 'turn' && location?.kind !== 'step') return false
const reason = location.turn.end?.data.reason.kind
return location.turn.status === 'open' || reason === 'error'
return location.turn.status === 'open' || reason === 'aborted' || reason === 'error'
}
@@ -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/src/client/conversation-nodes/README.md
README.md: b4f64a5ff6454f683f52b2e210aa1541f7c0b8ce
README.zh.md: c829d4a983550ebd59e1ecded3de0cd82fa27e3f
README.md: 3b6b82685c763837e706c60d8ea69b896f582ae2
README.zh.md: ae43726417becea973b22e2f2a16871cec116584
@@ -83,9 +83,9 @@ Trigger titles and icons use the recorded `source.kind`: `schedule`, `tool-jobs`
| Turn condition | Current behavior |
|---|---|
| Not closed | Keep process content open; whole-Turn collapse is unavailable. Secondary groups still follow the mode table. |
| Failed | Keep process content open; whole-Turn collapse is unavailable. |
| Stopped or failed | Keep process content open; whole-Turn collapse is unavailable. |
| Visible input after the first process evidence | Whole-Turn collapse is unavailable. Preserve process segments, intermediate replies, and input order; group disclosures remain available. Consecutive opening inputs do not qualify. |
| Other closed Turn with loaded process content, including stopped Turns | Initially collapse the process; allow manual opening and closing. Neither the Turn start nor the rest of Session history must already be loaded. |
| Other closed Turn with loaded process content | Initially collapse the process; allow manual opening and closing. Neither the Turn start nor the rest of Session history must already be loaded. |
| Closed Turn with no final answer | Collapse all process rows when otherwise eligible. |
| End is loaded but start is absent | Apply the closed-Turn rules above to loaded content. Show no elapsed duration; do not use the first loaded tool/message time as the start. |
| Neither start nor end is loaded | Keep process rows visible and withhold the whole-Turn control. |
@@ -99,7 +99,7 @@ Clicking Load older anchors the first visible content item below that button in
Paging adds older content above the retained anchor without jumping to the new top. A capped group absorbs the displacement within its scroll range; the outer transcript absorbs the remainder, including when the group first reaches its cap. If the available scroll range is insufficient, compensation stops at the actual limit without adding bottom space. Later content growth keeps the same anchor until a reading gesture or explicit navigation releases it. Typing or clicking within the composer and non-scrolling transcript keys retain the anchor. If the reader scrolls while a page is loading, scrolling takes priority and its settled reading position becomes the new paging anchor.
The top status and trigger title share the group-title 14px font size and follow the font-size setting. The top status has 16px clearance from the preceding input, and its divider has 16px clearance from the following content. The top status is absent while running. The clock updates in whole seconds, starts at one second, and uses hours from 60 minutes. Completion fixes the duration and shows “Completed in”; cancellation shows “Stopped”, and failure shows its status. While running, “Deep diving for” and the live duration follow the content and disappear on completion. Its text and icon are 2px smaller than the group title and icon, follow the font-size setting, and keep a 32px gap from adjacent replies. The status is centered between fixed 32px-long, 0.5px dividers using the l3 border color. Each divider keeps a 16px gap from the status. A running transcript has 24px of bottom padding before the composer. Duration digits use the code font with tabular numerals. A leading whale-tail Lottie animation sways in its native 3-second loop, once per two text sweeps, shares their 0.3-second initial delay, and keeps a 6px text gap. The icon and text use deep brand blue in the light theme and lighter, less saturated blue in the dark theme. The text sweep uses darker blue in the light theme and brighter blue in the dark theme. Reduced-motion preferences hold the whale tail at its initial pose and disable the text sweep. Lifecycle changes have a polite announcement; clock ticks do not.
A running clock updates in whole seconds, starts at one second, and uses hours from 60 minutes. Completion fixes the duration; cancellation and failure replace it with their status. Lifecycle changes have a polite announcement; clock ticks do not. This control is Chat's only Turn-level running indicator.
Automatic collapse keeps the process open if hiding it would hide keyboard focus. Manual closing focuses the process control before hiding its members. Closing a whole Turn resets its groups and inner reasoning/tool disclosures; it does not reset unrelated renderer state. Browser find can reveal searchable hidden content.
@@ -155,7 +155,7 @@ The labels below describe recorded activity, not successful outcomes. For exampl
English lowercases the initial letter of joined labels after the first. Closing a group immediately selects the completed summary; the 150ms minimum applies to running-title changes, not to delaying completion. Expanded hides group headers in running Turns, including groups ended by a reply or steering before their Turn ends.
Group headers show a category icon, replace it with a down arrow on hover or keyboard focus, and show an up arrow while open. Group bodies use 8px spacing between process rows, including reasoning and skill loading. Manually expanded groups have a `min(400px, 50vh)` height cap and 24px directional fades. Wheel scrolling can continue into the outer transcript at an edge. Expanded mode removes the group-level cap only in running Turns; process row spacing remains 8px, and spacing next to Assistant responses remains 16px.
Group headers show a category icon, replace it with a down arrow on hover or keyboard focus, and show an up arrow while open. Manually expanded group bodies use 8px row spacing, a `min(400px, 50vh)` height cap, and 24px directional fades. Wheel scrolling can continue into the outer transcript at an edge. Expanded mode removes the group-level cap and uses 16px row spacing only in running Turns.
An open capped group follows content growth only while its own scroll position is at the bottom. Scrolling away pauses that group's following; returning to the bottom resumes it, independently of outer transcript following. Manually opening an unclosed group starts at the bottom and follows growth; manually opening a closed group starts at the top with following disabled, even when its initial content fits without scrolling. Closing the group in the data or restoring its height cap through a mode change does not reset an already-open reader's position. Browser find retains its own reveal position.
@@ -83,9 +83,9 @@ description: "Chat 过程分组与活动摘要的明细业务规则。"
| 轮次条件 | 当前行为 |
|---|---|
| 尚未结束 | 过程内容保持展开,不能收起整轮;二级组仍按模式表展示。 |
| 失败 | 过程内容保持展开,不能收起整轮。 |
| 已停止或失败 | 过程内容保持展开,不能收起整轮。 |
| 首个过程证据之后还有可见输入 | 不能收起整轮;保留各段过程、阶段回复及输入的顺序,组级开合仍可用。开场连续输入不属于此情况。 |
| 其他已结束轮次(含已停止),有已加载的过程内容 | 过程默认收起,允许手动开合;不要求已加载到轮次起点,也不要求整个 Session 历史都已加载。 |
| 其他已结束轮次,有已加载的过程内容 | 过程默认收起,允许手动开合;不要求已加载到轮次起点,也不要求整个 Session 历史都已加载。 |
| 已结束但没有最终答案 | 满足其他折叠条件时收起全部过程行。 |
| 已加载结束记录,但缺少起点 | 对已加载内容应用上述已结束轮次规则;不显示耗时,不用最早加载的工具或消息时间冒充起点。 |
| 起点与结束记录均未加载 | 保留过程行可见,不显示整轮控件。 |
@@ -99,7 +99,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。"
分页把更早内容加到保留的锚点上方,不主动跳到新内容顶部。限高组先在自己的滚动范围内吸收位移,剩余部分由外层文本滚动补偿,包括组正文刚达到限高的情况。可滚动范围不足时,补偿停在实际边界,不增加底部空白。后续内容撑高沿用同一个锚点,直到阅读手势或显式导航释放它。在 composer 中打字或点击,以及 transcript 内的非滚动按键,保留锚点。分页请求期间若读者滚动,优先服从滚动,停止后以新的阅读位置作为分页锚点。
顶部状态和唤醒消息标题沿用二级汇总标题的 14px 字号,并跟随字号设置变化。顶部状态与上方输入的间距、其分割线与下方内容的间距均为 16px;运行时不显示顶部状态。时钟按整秒更新,最少显示一秒,满 60 分钟使用小时。完成后固定时长并显示“已完成,用时”,取消显示“已停止”,失败显示对应状态。运行期间,正文下方显示“深度求索中,用时”及实时用时,结束后消失。底部状态的文字和图标比二级汇总标题及其图标各小 2px,跟随字号设置变化,与相邻正文间距为 32px。状态居中,两侧分割线长度固定为 32px、粗细为 0.5px,使用 l3 边框颜色,与状态内容各保持 16px 间距。运行中的正文区域在输入框前保留 24px 底部内边距。用时数字使用代码等宽字体。鲸尾 Lottie 动画位于文字前,按原速每 3 秒循环摆动一次,对应两轮文字扫光,首次延迟同为 0.3 秒,图文间距为 6px。浅色主题下,图标与文字使用偏深的品牌蓝;深色主题下,蓝色降低饱和度并提高明度。文字扫光在浅色主题下使用更深的蓝色,在深色主题下使用更亮的蓝色。减少动态效果偏好会让鲸尾停在初始姿态,并禁用文字扫光。生命周期变化有礼貌播报,时钟跳动不播报。
运行时钟按整秒更新,最少显示一秒,满 60 分钟使用小时。完成后固定时长,取消与失败使用对应状态替代。生命周期变化有礼貌播报,时钟跳动不播报。该控件是 Chat 唯一的轮次级运行指示。
自动收起若会隐藏键盘焦点,则保持过程展开。手动收起先将焦点移到过程控件,再隐藏成员。收起整轮会重置内部组及推理、工具的开合,不重置渲染器的其他状态。浏览器查找可以显示可搜索的隐藏内容。
@@ -155,7 +155,7 @@ description: "Chat 过程分组与活动摘要的明细业务规则。"
英文组合标题将第一项之后各项的首字母转为小写。组结束时立即切换完成摘要;150ms 最短保留时间针对运行中标题变化,不延迟完成状态。Expanded 隐藏运行中轮次的组头,包括被回复或 steering 截断、但所属轮次尚未结束的小组。
组头显示类别图标,悬停或键盘聚焦时替换为向下箭头,展开时显示向上箭头。组内过程行(包括思考和 skill 加载)之间相隔 8px。手动展开的组使用 `min(400px, 50vh)` 限高及 24px 方向渐隐;到达边缘时滚轮可以继续滚动外层文本记录。完全展开模式仅对运行中轮次取消组级限高;过程行间距保持 8px,与 Assistant 回复相邻的间距保持 16px。
组头显示类别图标,悬停或键盘聚焦时替换为向下箭头,展开时显示向上箭头。手动展开的组正文使用 8px 行间距、`min(400px, 50vh)` 限高及 24px 方向渐隐;到达边缘时滚轮可以继续滚动外层文本记录。完全展开模式仅对运行中轮次取消组级限高,使用 16px 行间距。
已展开的限高组仅在自己的滚动位置处于底部时跟随内容增长。滚离底部暂停该组跟随,滚回底部后恢复,与外层文本记录的跟随状态独立。手动展开未关闭的组时定位到底部并跟随增长;手动展开已关闭的组时从顶部开始且不启用跟随,即使初始内容无需滚动即可容纳。数据中的组变为已关闭,或通过模式切换恢复限高时,不重置已展开组的阅读位置。浏览器查找沿用自身的展开定位。
+7 -7
View File
@@ -61,8 +61,9 @@ export const zh = {
'message.trigger.job': '后台任务状态更新',
'message.trigger.plugin': '插件状态更新',
'message.trigger.explanation': '这条通知触发了本轮回复。',
'message.turnProcess.worked': '已完成',
'message.turnProcess.took': '已完成,用时 {duration}',
'message.turnProcess.worked': '已完成工作',
'message.turnProcess.deepDivingFor': '深度求索中,用时{duration}',
'message.turnProcess.took': '用时 {duration}',
'message.turnProcess.failed': '处理失败',
'view.chat': '对话',
'image.open': '查看大图',
@@ -87,7 +88,6 @@ export const zh = {
'chat.loadOlder': '加载更早',
'chat.toBottom': '回到底部',
'chat.deepDiving': '深度求索中',
'chat.deepDivingFor': '深度求索中,用时 {duration}',
'chat.turnNavigation.label': '轮次导航',
'chat.turnNavigation.jump': '跳转到第 {turn} 轮',
'chat.turnNavigation.jumpLoad': '加载并跳转到第 {turn} 轮',
@@ -241,8 +241,9 @@ export const en = {
'message.trigger.job': 'Background task updated',
'message.trigger.plugin': 'Plugin status updated',
'message.trigger.explanation': 'This notification triggered this response.',
'message.turnProcess.worked': 'Completed',
'message.turnProcess.took': 'Completed in {duration}',
'message.turnProcess.worked': 'Worked',
'message.turnProcess.deepDivingFor': 'Deep diving for {duration}',
'message.turnProcess.took': 'Took {duration}',
'message.turnProcess.failed': 'Failed',
'view.chat': 'Chat',
'image.open': 'View full image',
@@ -266,8 +267,7 @@ export const en = {
'chat.loadError': 'Failed to load history: {message} ({code})',
'chat.loadOlder': 'Load earlier',
'chat.toBottom': 'Back to bottom',
'chat.deepDiving': 'Deep diving',
'chat.deepDivingFor': 'Deep diving for {duration}',
'chat.deepDiving': 'Deep diving...',
'chat.turnNavigation.label': 'Turn navigation',
'chat.turnNavigation.jump': 'Jump to turn {turn}',
'chat.turnNavigation.jumpLoad': 'Load and jump to turn {turn}',
@@ -53,19 +53,6 @@ import { chatSnapshotFixture } from './chat-snapshot-fixture.client.ts'
import { installTurnNavigatorObserver } from './turn-navigator-fixture.ts'
import { ConversationGroupStore } from '../../ui-conversation/src/client/conversation/group-store.ts'
// Clock assertions exclude the SVG player's shared animation-frame scheduler; the Web lane exercises playback.
vi.mock('lottie-web/build/player/lottie_light.js', () => ({
default: {
loadAnimation: () => ({
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
goToAndStop: vi.fn(),
play: vi.fn(),
destroy: vi.fn(),
}),
},
}))
function installGroupedSnapshot(
builder: ChatSnapshotBuilder, state: ProcessState, groups: ConversationGroupStore<ProcessGroupData>, source: ChatSnapshot,
): ChatSnapshot {
@@ -93,7 +80,6 @@ afterEach(() => {
beforeEach(() => {
localStorage.clear()
installTurnNavigatorObserver()
vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false, addEventListener: vi.fn(), removeEventListener: vi.fn() })))
})
const SID = 's1' as SessionId
@@ -1623,8 +1609,8 @@ describe('ChatView', () => {
fireEvent.click(within(pendingBubble as HTMLElement).getByRole('button', { name: '复制' }))
expect(writeText).toHaveBeenCalledWith('interrupt now')
expect(within(pendingBubble as HTMLElement).queryByRole('button', { name: '在新对话中分支' })).toBeNull()
expect(turnProcessControl(view.container)).toBeNull()
expect(view.getByText('interrupt now').compareDocumentPosition(view.getByRole('status'))
expect(turnProcessControl(view.container)?.textContent).toBe('深度求索中,用时2秒')
expect(view.getByRole('status').compareDocumentPosition(view.getByText('interrupt now'))
& Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
act(() => {
@@ -2194,7 +2180,7 @@ describe('ChatView', () => {
turnEnds: new Map([[1, 6]]),
})
const view = render(<h.ChatView {...h.props} />)
const toggle = view.getByRole('button', { name: '已完成,用时 4秒' })
const toggle = view.getByRole('button', { name: '用时 4秒' })
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(toggle.getAttribute('data-turn-process-tool-calls')).toBe('1')
expect(toggle.getAttribute('data-turn-process-messages')).toBe('1')
@@ -2220,12 +2206,12 @@ describe('ChatView', () => {
expect(members.map(member => member.getAttribute('hidden'))).toEqual([null, null, null])
act(() => { h.set({ nodes: [user(1, 'question'), first] }) })
expect(view.getByRole('button', { name: '已完成,用时 4秒' }).getAttribute('aria-expanded')).toBe('false')
expect(view.getByRole('button', { name: '用时 4秒' }).getAttribute('aria-expanded')).toBe('false')
expect(members[0]?.getAttribute('hidden')).toBeNull()
act(() => { h.set({
nodes: [user(1, 'question'), first, toolResult(3, 'a'), toolResult(4, 'b', 'subagent'), second],
}) })
const renewedToggle = view.getByRole('button', { name: '已完成,用时 4秒' })
const renewedToggle = view.getByRole('button', { name: '用时 4秒' })
expect(renewedToggle.getAttribute('aria-expanded')).toBe('true')
expect(members[0]?.getAttribute('hidden')).toBeNull()
})
@@ -2266,7 +2252,7 @@ describe('ChatView', () => {
const h = makeHarness({ chat: initial }, { running: true })
const view = render(<h.ChatView {...h.props} />)
expect(renderedFlowKinds(view.container)).toEqual(['user', 'turn-process'])
expect(turnProcessControl(view.container)).toBeNull()
expect(turnProcessControl(view.container)?.textContent).toBe('深度求索中,用时4秒')
expect(view.container.querySelector('[data-chat-flow-kind="system-prompt"]')).toBeNull()
act(() => {
@@ -2326,7 +2312,7 @@ describe('ChatView', () => {
turnEnds: new Map([[1, 4]]),
})
const view = render(<h.ChatView {...h.props} />)
const toggle = view.getByRole('button', { name: '已完成' }) as HTMLButtonElement
const toggle = view.getByRole('button', { name: '已完成工作' }) as HTMLButtonElement
const contextRow = view.container.querySelector<HTMLElement>('[data-chat-flow-kind="context"]')
expect(toggle.disabled).toBe(true)
@@ -2342,7 +2328,7 @@ describe('ChatView', () => {
expect(view.getByText('final answer').closest('[hidden]')).toBeNull()
})
it('keeps ordinary answer spacing when steering interrupts the process', () => {
it('keeps ordinary spacing when steering separates the process control from its answer', () => {
const h = makeHarness({
nodes: [
user(1, 'question'),
@@ -2448,11 +2434,11 @@ describe('ChatView', () => {
turnTimings: new Map([[1, { startTime: 0 }]]),
})
const view = render(<h.ChatView {...h.props} />)
expect(turnProcessControl(view.container)).toBeNull()
const liveToggle = view.getByRole('button', { name: '深度求索中,用时3秒' }) as HTMLButtonElement
expect(liveToggle.disabled).toBe(true)
expect(liveToggle.getAttribute('aria-expanded')).toBe('true')
const processRow = view.getByText('inspect').closest('[data-chat-flow-kind="assistant-step"]') as HTMLElement
const runningStatus = view.getByRole('status')
expect(runningStatus.textContent).toBe('深度求索中')
expect(processRow.compareDocumentPosition(runningStatus) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
fireEvent.click(liveToggle)
expect(processRow.getAttribute('hidden')).toBeNull()
act(() => {
@@ -2464,17 +2450,15 @@ describe('ChatView', () => {
})
})
const toggle = turnProcessControl(view.container)!
expect(view.container.querySelectorAll('[data-turn-process]')).toHaveLength(1)
expect(view.container.querySelector('[data-chat-running]')).toBeNull()
expect(toggle.compareDocumentPosition(processRow) & Node.DOCUMENT_POSITION_FOLLOWING).not.toBe(0)
expect(toggle.textContent).toBe('已完成,用时 5秒')
expect(toggle).toBe(liveToggle)
expect(toggle.textContent).toBe('用时 5秒')
expect(toggle.disabled).toBe(false)
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(processRow.getAttribute('hidden')).toBe('until-found')
fireEvent.click(toggle)
act(() => { vi.advanceTimersByTime(1_000) })
expect(toggle.getAttribute('aria-expanded')).toBe('true')
expect(toggle.textContent).toBe('已完成,用时 5秒')
expect(toggle.textContent).toBe('用时 5秒')
expect(processRow.getAttribute('hidden')).toBeNull()
}))
@@ -2520,7 +2504,7 @@ describe('ChatView', () => {
turnEnds: new Map([[1, 4]]),
})
const view = render(<h.ChatView {...h.props} />)
const toggle = view.getByRole('button', { name: '已完成' })
const toggle = view.getByRole('button', { name: '已完成工作' })
const reasoning = view.container.querySelector<HTMLElement>('[data-turn-process-inline]')
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(reasoning?.getAttribute('hidden')).toBe('until-found')
@@ -2584,7 +2568,7 @@ describe('ChatView', () => {
}) })
const toggle = turnProcessControl(view.container)!
expect(toggle.getAttribute('aria-expanded')).toBe('false')
expect(toggle.textContent).toBe('已完成')
expect(toggle.textContent).toBe('已完成工作')
expect(view.container.querySelector('[data-chat-node-key="fixture:assistant:2"]')).toBe(processRow)
expect(processRow.getAttribute('hidden')).toBe('until-found')
expect(view.getByLabelText('回到底部')).toBeTruthy()
@@ -2833,7 +2817,7 @@ describe('ChatView', () => {
const view = render(<h.ChatView {...h.props} />)
const toggle = turnProcessControl(view.container)!
const processRow = view.getByText('working').closest('[data-chat-flow-kind="assistant-step"]')!
expect(toggle.textContent).toBe('已完成,用时 5秒')
expect(toggle.textContent).toBe('用时 5秒')
expect(toggle.disabled).toBe(true)
expect(toggle.getAttribute('aria-expanded')).toBeNull()
expect(processRow.hasAttribute('hidden')).toBe(false)
@@ -2929,7 +2913,7 @@ describe('ChatView', () => {
})
const view = render(<h.ChatView {...h.props} />)
// The exact turn/end includes trailing tool activity after the final text.
expect(view.container.querySelector('[data-turn-tail="1"]')?.textContent).not.toContain('已完成,用时 19秒')
expect(view.container.querySelector('[data-turn-tail="1"]')?.textContent).not.toContain('用时 19秒')
})
it('the assistant footer omits hour-scale run time', () => {
@@ -2945,7 +2929,7 @@ describe('ChatView', () => {
})
const view = render(<h.ChatView {...h.props} />)
expect(view.container.querySelector('[data-turn-tail="1"]')?.textContent)
.not.toContain('已完成,用时 1小时05分03秒')
.not.toContain('用时 1小时05分03秒')
})
it('the settled footer shows usage only in Detailed mode', () => {
@@ -2985,7 +2969,7 @@ describe('ChatView', () => {
expect(view.queryByRole('dialog')).toBeNull()
const footer = view.container.querySelector<HTMLElement>('[data-turn-tail="1"]')!
expect(within(footer).queryByRole('button', { name: /用时/ })).toBeNull()
expect(turnProcessControl(view.container)?.textContent).toBe('已完成,用时 19秒')
expect(turnProcessControl(view.container)?.textContent).toBe('用时 19秒')
act(() => { h.setPerformanceUsage('compact') })
expect(view.queryByRole('button', { name: /用量/ })).toBeNull()
act(() => { h.setPerformanceUsage('detailed') })
@@ -3006,7 +2990,7 @@ describe('ChatView', () => {
const view = render(<h.ChatView {...h.props} />)
const footer = view.container.querySelector<HTMLElement>('[data-turn-tail="1"]')!
expect(within(footer).queryByRole('button', { name: /用时/ })).toBeNull()
expect(turnProcessControl(view.container)?.textContent).toBe('已完成,用时 19秒')
expect(turnProcessControl(view.container)?.textContent).toBe('用时 19秒')
expect(view.queryByRole('button', { name: /用量/ })).toBeNull()
})
@@ -3201,8 +3185,9 @@ describe('ChatView', () => {
expect(view.getByTestId('tool-seat-r1')).toBeTruthy()
expect(h.toolOwners[0]?.block).toMatchObject({ callId: 'r1', argsRaw: '{"command":"cmd-r1"}' })
expect(view.getByRole('status').textContent).toBe('深度求索中')
expect(turnProcessControl(view.container)).toBeNull()
expect(view.container.querySelector('[data-chat-running] [data-shimmer] > span')?.textContent).toBe('深度求索中,用时 2秒')
const toggle = view.getByRole('button', { name: '深度求索中,用时2秒' }) as HTMLButtonElement
expect(toggle.disabled).toBe(true)
expect(toggle.getAttribute('aria-expanded')).toBe('true')
}))
it('keeps the Tool renderer mounted when a running call settles into log order', () => {
@@ -3261,8 +3246,9 @@ describe('ChatView', () => {
)
const view = render(<h.ChatView {...h.props} />)
const status = view.getByRole('status')
const toggle = view.container.querySelector('[data-chat-running] [data-shimmer] > span')!
expect(turnProcessControl(view.container)).toBeNull()
const toggle = view.getByRole('button', { name: '深度求索中,用时2分5秒' }) as HTMLButtonElement
expect(toggle).toBe(turnProcessControl(view.container))
expect(toggle.disabled).toBe(true)
expect(status.textContent).toBe('深度求索中')
expect(status.getAttribute('aria-live')).toBe('polite')
expect(status.getAttribute('aria-atomic')).toBe('true')
@@ -3276,20 +3262,20 @@ describe('ChatView', () => {
role: 'user', source: { kind: 'user' },
}] } })
})
expect(toggle.textContent).toBe('深度求索中,用时 2分5秒')
expect(toggle.textContent).toBe('深度求索中,用时2分5秒')
act(() => { vi.advanceTimersByTime(2_000) })
expect(toggle.textContent).toBe('深度求索中,用时 2分7秒')
expect(toggle.textContent).toBe('深度求索中,用时2分7秒')
expect(view.getByRole('status')).toBe(status)
expect(status.textContent).toBe('深度求索中')
act(() => {
h.setSession({ testInbox: { 'next-turn': [], 'next-step': [] } })
h.setChat({ nodes: [trigger, { ...steering(2, 'also', 1), time: 128_000 }] })
})
expect(view.container.querySelector('[data-chat-running] [data-shimmer] > span')).toBe(toggle)
expect(toggle.textContent).toBe('深度求索中,用时 2分7秒')
expect(turnProcessControl(view.container)).toBe(toggle)
expect(toggle.textContent).toBe('深度求索中,用时2分7秒')
expect(view.getByText('also').closest('[data-pending-steering]')).toBeNull()
act(() => { vi.advanceTimersByTime(1_000) })
expect(toggle.textContent).toBe('深度求索中,用时 2分8秒')
expect(toggle.textContent).toBe('深度求索中,用时2分8秒')
expect(status.textContent).toBe('深度求索中')
view.unmount()
expect(vi.getTimerCount()).toBe(0)
@@ -3303,12 +3289,11 @@ describe('ChatView', () => {
{ running: true },
)
const view = render(<h.ChatView {...h.props} />)
const toggle = view.container.querySelector('[data-chat-running] [data-shimmer] > span')!
expect(toggle.textContent).toBe('深度求索中,用时 59分59秒')
const toggle = view.getByRole('button', { name: '深度求索中,用时59分59秒' })
act(() => { vi.advanceTimersByTime(1_000) })
expect(toggle.textContent).toBe('深度求索中,用时 1小时00分0秒')
expect(toggle.textContent).toBe('深度求索中,用时1小时00分0秒')
act(() => { vi.advanceTimersByTime(303_000) })
expect(toggle.textContent).toBe('深度求索中,用时 1小时05分3秒')
expect(toggle.textContent).toBe('深度求索中,用时1小时05分3秒')
expect(view.getByRole('status').textContent).toBe('深度求索中')
view.unmount()
expect(vi.getTimerCount()).toBe(0)
@@ -126,7 +126,7 @@ describe('ReasoningRow', () => {
)
expect(view.getByText('运行中')).toBeTruthy()
expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('false')
expect(view.getByText('Newest reasoning tokens').closest('[data-streaming]')?.getAttribute('data-streaming'))
expect(view.getByText('Newest reasoning tokens').parentElement?.getAttribute('data-streaming'))
.toBe('true')
view.rerender(
@@ -138,7 +138,7 @@ describe('ReasoningRow', () => {
renderMessageImages={renderMessageImages}
/>,
)
expect(view.getByText('Newest reasoning tokens').closest('[data-streaming]')
expect(view.getByText('Newest reasoning tokens').parentElement
?.getAttribute('data-streaming')).toBe('true')
expect(view.queryByText('Checking boundaries')).toBeNull()
@@ -167,7 +167,7 @@ describe('ReasoningRow', () => {
const settledSummary = view.getByText('Inspect the session')
expect(view.getByRole('button').getAttribute('aria-expanded')).toBe('false')
expect(view.queryByText('运行中')).toBeNull()
expect(settledSummary.closest('[data-streaming]')).toBeNull()
expect(settledSummary.parentElement?.hasAttribute('data-streaming')).toBe(false)
})
it('expands from either Think or the reasoning summary', () => {
@@ -1,4 +1,4 @@
/** CSS checks for the completed-turn footer's content spacing. */
/** CSS checks for the completed-turn footer's 20px content spacing. */
import { readFileSync } from 'node:fs'
import { fileURLToPath } from 'node:url'
import { describe, expect, it } from 'vitest'
@@ -8,7 +8,7 @@ const read = (name: string): string =>
describe('completed-turn spacing', () => {
it('combines the flow and footer offsets around turn-tail content', () => {
expect(read('ChatView.module.css')).toMatch(/margin-top:\s*var\(--dsh-chat-flow-gap, 8px\)/)
expect(read('ChatView.module.css')).toMatch(/margin-top:\s*var\(--dsh-chat-flow-gap, 16px\)/)
const tail = read('TurnTailNodeView.module.css')
expect(tail).toMatch(/\.root\s*\{[^}]*gap:\s*16px/s)
expect(tail).toMatch(/\.actions\s*\{[^}]*margin-top:\s*4px/s)
+1 -3
View File
@@ -1,14 +1,12 @@
{
"extends": "../../../tsconfig.base.client.json",
"compilerOptions": {
"resolveJsonModule": true,
"rootDir": "src",
"outDir": "lib/types",
"tsBuildInfoFile": "lib/tsconfig.client.tsbuildinfo"
},
"include": [
"src",
"src/**/*.json"
"src"
],
"references": [
{
@@ -1,5 +1,5 @@
/** Present status remains readable beside long workspace paths. */
.summary { margin-left: 8px; display: flex; align-items: center; gap: 8px; min-width: 0; color: inherit; font-size: 12px; }
.summary { margin-left: 8px; display: flex; align-items: center; gap: 8px; min-width: 0; color: var(--dsw-alias-label-secondary); font-size: 12px; }
.summary > :first-child { flex-shrink: 0; }
.paths { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.output { margin: 8px 0; padding: 12px; border-radius: 8px; background: var(--dsw-alias-bg-layer-1); color: var(--dsw-alias-label-secondary); white-space: pre-wrap; overflow-wrap: anywhere; font-size: 12px; }
@@ -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: 1b3eb6f1b0f99deb5cf4360c677b9a3041aacde3
README.zh.md: d43bfab4e9379f5a3f72e49b8bbc954333cf9111
README.md: fbb8ef676e55d1ef2c3c5c8a6c91a12a3c8fe556
README.zh.md: 822ff17c3c4c6b02339cdacd03d9c52c67a738c7
+2 -3
View File
@@ -48,8 +48,7 @@ Check this table before writing a control in a feature package. A plugin cannot
| `PathLabel` | Single-line file path with subdued directories, a primary filename, and the full path on hover. Fitting paths align left; clipped paths preserve their suffix with a left-edge fade that updates on path and size changes. |
| `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 whose animations pin to document time zero so every visible loader rotates in phase. `aria-hidden`, so the render site owns the name. `appearance="step"` shows a filled check for completion and a hollow pending circle. |
| `ConnectionIndicator` | Inline connection-recovery control across outage, retry, and recovered states. |
| `DisclosureRow` | 24px compact disclosure that lays title and content side by side. The header supplies tertiary at rest and secondary on hover; text and icons inherit it regardless of expandability or open state, while semantic status colors remain explicit. Collapsed rows preview a down chevron on hover, and expanded rows keep an up chevron visible. Memoized with shallow prop comparison; keep callbacks and React-node props stable when their content is unchanged. |
| `Shimmer` / `ShimmerText` | One left-to-right highlight across a row’s title, separators, summary, and suffix, with a 300ms initial delay, a one-second sweep in 48 steps, and a 500ms rest. Layout classes can stretch the wrapper and apply the same flex spacing to both content copies. The mask spans the content width, capped by the visible row and tilts 15° from vertical, with a flat peak and soft edges. The moving decoration stays clipped to the row without enlarging its scrollable area. Base text inherits the caller’s color, including hover changes; the theme supplies the translucent highlight. Place icons and chevrons outside `Shimmer` so only text and separators receive the highlight. Supply presentational children without effects or element ids, and wrap text in `ShimmerText`. The inert decorative copy is excluded from interaction, selection, and accessibility; text updates retain the animation, and reduced-motion mode keeps the static base. |
| `DisclosureRow` | 24px compact disclosure that lays title and content side by side. Memoized with shallow prop comparison; keep callbacks and React-node props stable when their content is unchanged. |
| `Modal` | Centered dialog over a page mask. A nested dialog can intercept keys with `onKeyDownCapture` before document Escape handlers. |
| `RiskConfirmation` | Sensitive action gated behind an explicit checkbox. |
| `OnboardingSurface` | First-run stage that holds the application root inert. |
@@ -79,7 +78,7 @@ Writing your own component in your own package is fine when the need is genuinel
### Controls and icons
The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions ([decision](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.md)). Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `IconThinkOutlineRegular`/`Medium` use two ellipses and a center dot. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader beside a label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; `useAnchoredMaxHeight` and portalled `Menu` widen their 12px top viewport margin to the frame's published `--dsh-frame-top-clearance`. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared canLine truncated
The catalog above lists what each export is for; this section covers the behavior that props alone do not show. Product icon names omit artboard sizes and end in `Regular` for the supplied one-pixel artwork or `Medium` for the same geometry at a 1.3px stroke; the `size` prop controls rendered dimensions ([decision](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.md)). Each product, reference, link, and permission glyph intentionally keeps both weight exports even when the current product uses only one, so callers can choose emphasis without adding another API later; fill-only pairs render identically. `IconWarningOutlineRegular`/`Medium` use a circle; `IconWarningTriangleOutlineRegular`/`Medium` use a rounded triangle. `FishLogo` and `BrandWordmark` fill brand slots. `FileTypeIcon` renders the traditional 28px spreadsheet, folder, HTML, image, Markdown, generic, PDF, PPT, video, and Word glyphs, and uses the imported square technology artwork for the established 48 code and configuration categories. The import replaces artwork only: archive-only categories do not extend `CodeFileType`. `classifyFileType` applies exact filename, prefix, suffix, optional project-context, and extension rules in that order; React names win over TypeScript/JavaScript, Angular suffixes win over their base extension, and a Dart file becomes Flutter only when the supplied project files contain a `pubspec.yaml` with `flutter:`. Markdown and SVG remain traditional Markdown and image files. Spreadsheet mappings include CSV, TSV, Excel workbooks and templates, OpenDocument spreadsheets, and Numbers; KEY maps to slides, while RTF/ODT/Pages map to documents. `fileExtension` exposes the same basename and final-dot parsing for adjacent metadata labels. Traditional glyphs use a solid category-colored sheet with a white mark and translucent white corner; the generic file uses a grey sheet and darker grey corner. Callers may override the sheet color through `--dsh-file-type-icon-color`. The full-color technology artwork is the deliberate exception and retains its embedded palette. All glyphs are decorative and carry no label. `LinkIconMedium` is the leading glyph for clickable artifact links — globe, folder, code, image, document, or plain paper, and for a `url` link the mark of a well-known site named by its `href` — the developer sites a transcript usually cites (GitHub, GitLab, npm, PyPI, Stack Overflow, MDN, Wikipedia, Hacker News, YouTube, X, Bilibili, Zhihu, Juejin, CSDN) and mainstream search, video, social, shopping, and reference sites (Google, Baidu, DuckDuckGo, TikTok, Netflix, Spotify, Facebook, Instagram, Reddit, Telegram, WhatsApp, WeChat, QQ, Weibo, Taobao, AliExpress, eBay, Quora, V2EX, Apple) — while `classifyLinkPath` folds the shared file types into that existing six-category vocabulary. `ConnectionIndicator` renders a warning-colored disconnected action whose permanent retry glyph marks the retry action beside the owner-supplied outage label, the shared ongoing loader beside a label whose one-to-three dots advance every 500ms independently of retry timing, or a success-colored recovered status. Clicking either warning state requests an immediate reconnect; no hover interaction changes the copy. The pill fades in on appearance, fades out for 150ms before unmounting, and sizes to its current label. Its owner supplies visibility, the recovery hold, localized labels, and the immediate-reconnect callback; the primitive uses no native title tooltip. `useAnchoredPosition` and `useAnchoredMaxHeight` keep floating panels and bottom-anchored overlays clamped to the viewport and following their anchor; `useAnchoredMaxHeight` and portalled `Menu` widen their 12px top viewport margin to the frame's published `--dsh-frame-top-clearance`. `HoverCard` keeps its portaled preview reachable across the anchor gap and can expose a copy button through the `copyText` prop. Its `preview` variant uses the anchor or `widthAnchorRef` element’s width minus 48px, centers the panel with 24px side insets, and places it above or below the row within the viewport. The panel keeps the frame’s top clearance, caps its height at 420px, and follows content and anchor resizing; Escape or pointer and keyboard activation of the anchor dismisses it. The whole preview fades in and out over 100ms; a closing preview stops receiving pointer input and unmounts after the fade. Re-entering the anchor during the fade restores it. Reduced-motion preferences suppress the transition. Copy labels are required only when copying is enabled. `Toast` uses the owner's `holdMs` for both the fade delay and its hold-and-fade lifetime. Rerenders with unchanged `holdMs` do not restart that lifetime; completion uses the latest callback, and fully faded actions cannot receive input. A new component key restarts the banner. `rankByName` is the `/` menu's shared candidate ranker for the command and skill sources: the query must be a cLine truncated
`Tooltip` reads the anchor on hover or keyboard focus and fits its bubble from `ResizeObserver` border-box sizes. The bubble stays hidden until its first fit, slides inside the horizontal viewport margin, and flips above or below only when the opposite side fits. Label-size and viewport changes reuse the anchor coordinates; fitting does not synchronously measure the bubble or trigger a React render.
+2 -3
View File
@@ -48,8 +48,7 @@ kind: "package-library"
| `PathLabel` | 单行文件路径:目录使用弱化颜色,文件名使用主色,悬停可查看完整路径。空间足够时靠左显示;溢出时保留尾部并在左侧渐隐,路径或尺寸变化时更新。 |
| `StateDot` | 10px 槽内的绿色 `done`、琥珀色 `warning`、红色 `error`、中性灰色 `idle` 圆点,以及 tertiary 灰色 14px 旋转 `ongoing` loading,其动画固定到文档时间零点,所以所有可见 loading 同相旋转。它是 `aria-hidden` 的,名称由渲染点提供。 `appearance="step"` 以实心勾表示完成、空心圆表示等待。 |
| `ConnectionIndicator` | 行内连接恢复控件,覆盖断线、重试与已恢复三种状态。 |
| `DisclosureRow` | 24px 紧凑折叠行,标题与内容左右排列。标题行默认使用 tertiary,悬停时使用 secondary;文字与图标继承该颜色,不受能否展开及开合状态影响,语义状态色保持显式指定。收起时悬停预览向下箭头,展开后持续显示向上箭头。使用浅层 prop 比较进行 memo;内容未变时,保持回调与 React 节点 prop 的引用稳定。 |
| `Shimmer` / `ShimmerText` | 同一道高光从左到右扫过一行的标题、分隔符、摘要与后缀:首次等待 300ms,以 48 个步进扫动一秒,再静止 500ms。布局类可拉伸外层容器,并为两个内容副本设置相同的弹性间距。遮罩覆盖内容宽度,并限制在可见行宽内,相对垂直方向倾斜 15°,峰值区域平坦,两侧柔和渐隐。移动的装饰层始终裁剪在行内,不会扩大可滚动区域。底色继承调用方的颜色,包括悬停变化;主题提供半透明高光。图标和箭头放在 `Shimmer` 外,只有文字和分隔符参与扫光。子节点仅包含无副作用和元素 id 的展示内容,文字使用 `ShimmerText`。惰性装饰副本不参与交互、选区和无障碍访问;文字更新保留动画,减少动态效果模式保留静态底色。 |
| `DisclosureRow` | 24px 紧凑折叠行,标题与内容左右排列。使用浅层 prop 比较进行 memo;内容未变时,保持回调与 React 节点 prop 的引用稳定。 |
| `Modal` | 页面遮罩之上的居中对话框。嵌套对话框可通过 `onKeyDownCapture` 在文档级 Escape 处理器之前拦截按键。 |
| `RiskConfirmation` | 以显式复选框把关的敏感操作确认。 |
| `OnboardingSurface` | 首次运行的引导舞台,期间保持应用根节点 inert。 |
@@ -79,7 +78,7 @@ kind: "package-library"
### 控件与图标
上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸([决定](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.zh.md))。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`IconThinkOutlineRegular`/`Medium` 使用双椭圆和中心点。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;`useAnchoredMaxHeight` 与 portal 模式的 `Menu` 会把 12px 的视口顶部边距加宽到框架发布的 `--dsh-frame-top-clearance`。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区,高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 portal 模式的 `Menu` 列表会在拖动和 CSS 变换期间跟随锚点,并在关闭时停止跟踪。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键Line truncated
上面的目录说明每个导出的用途;本节讲 props 本身看不出来的行为。产品图标名称不含画板尺寸,以 `Regular` 表示原始 1px 图形,以 `Medium` 表示同一几何的 1.3px 描边;`size` prop 控制渲染尺寸([决定](../../../.agents/notes/implemented/architecture/2026-09-16-size-neutral-product-icon-weights.zh.md))。每个产品、引用、链接与权限图形都会有意保留两种线重导出,即使当前产品只使用其中一种,也让调用方无需再次扩展 API 就能选择强调程度;仅填充的成对图形外观相同。`IconWarningOutlineRegular`/`Medium` 使用圆形;`IconWarningTriangleOutlineRegular`/`Medium` 使用圆角三角形。`FishLogo` 与 `BrandWordmark` 填充品牌 slot。`FileTypeIcon` 渲染传统的 28px spreadsheet、folder、HTML、image、Markdown、generic、PDF、PPT、video 与 Word 图形,并为现有 48 个代码和配置类别使用导入的方形技术图形。该导入只替换图形:资源包中额外的类别不会扩展 `CodeFileType`。`classifyFileType` 按完整文件名、前缀、后缀、可选项目上下文、扩展名的顺序匹配;React 文件名优先于 TypeScript/JavaScript,Angular 后缀优先于基础扩展名,只有传入的项目文件包含带 `flutter:` 的 `pubspec.yaml` 时 Dart 文件才使用 Flutter。Markdown 与 SVG 仍分别使用传统 Markdown 与图片图形。表格映射包括 CSV、TSV、Excel 工作簿与模板、OpenDocument 表格和 Numbers;KEY 映射为幻灯片,RTF/ODT/Pages 映射为文档。`fileExtension` 为相邻元数据 label 暴露同一套 basename 与最终点号解析。传统图形使用实色分类底板、白色标记和半透明白色折角;通用文件使用灰色底板与较深灰色折角。调用方可通过 `--dsh-file-type-icon-color` 覆盖底板颜色。全彩技术图形是明确例外,会保留其内嵌调色板。所有图形都是装饰性的,不自带 label。`LinkIconMedium` 是可点击产物链接的前置图形——地球、文件夹、代码、图片、文档或纸张,`url` 链接的 `href` 指向已知站点时则改用该站点自己的标记——转写内容常引用的开发者站点(GitHub、GitLab、npm、PyPI、Stack Overflow、MDN、Wikipedia、Hacker News、YouTube、X、Bilibili、知乎、掘金、CSDN),以及主流搜索、视频、社交、购物与参考资料站点(Google、百度、DuckDuckGo、TikTok、Netflix、Spotify、Facebook、Instagram、Reddit、Telegram、WhatsApp、微信、QQ、微博、淘宝、速卖通、eBay、Quora、V2EX、Apple)——`classifyLinkPath` 把共享文件类型折叠进原有六类词汇。`ConnectionIndicator` 可渲染警告色的断联操作(常驻重试图形指明重试动作,断联文案由持有方提供)、共享 ongoing loading 加一至三个点以独立于 retry 时序的 500ms 节奏推进的连接中状态,或成功色的恢复状态。点击任一警告状态都会请求立即重连;没有任何悬停交互会改变文案。药丸出现时淡入、卸载前淡出 150ms,宽度随当前 label 自适应。它的持有方提供可见性、恢复驻留时间、本地化 label 与立即重连回调;该原语不使用原生 title tooltip。`useAnchoredPosition` 与 `useAnchoredMaxHeight` 让浮动面板与底部锚定浮层始终钳制在视口内并跟随锚点;`useAnchoredMaxHeight` 与 portal 模式的 `Menu` 会把 12px 的视口顶部边距加宽到框架发布的 `--dsh-frame-top-clearance`。`HoverCard` 通过指针离开宽限期让采用 portal 的预览在跨过锚点间隙时仍可触及,并可通过 `copyText` prop 提供复制按钮。其 `preview` 变体使用 anchor 或 `widthAnchorRef` 元素的宽度减去 48px,左右各内缩 24px,并在视口内放置于行的上方或下方。浮层避开框架顶部保留区,高度最多 420px,会跟随内容及 anchor 尺寸变化;Escape 或通过鼠标和键盘激活锚点可将其关闭。整个浮层的淡入和淡出各持续 100ms;关闭中的浮层停止接收指针输入,淡出后卸载。在淡出期间移回锚点可恢复显示。减少动态效果偏好会禁用过渡动画。只有启用复制时才必须提供复制标签。`Toast` 使用调用方的 `holdMs` 同时控制淡出延迟与停留加淡出的总时长。`holdMs` 未变时,父组件重渲染不会重启该生命周期;完成时调用最新回调,完全淡出的操作不能接收输入。新的组件 key 会重新开始横幅周期。`rankByName` 是 `/` 菜单命令源与 skill(技能)源共享的候选排序器:查询必须是名字的不区分大小写的有序子序列;前缀命中排最前,其次按对齐分数,再按来源顺序。 portal 模式的 `Menu` 列表会在拖动和 CSS 变换期间跟随锚点,并在关闭时停止跟踪。`Menu.autoFocus` 聚焦首个启用项,支持上下方向键与 Home/End 导航,并在 Escape 时聚焦 anchor 内的第一Line truncated
`Tooltip` 在悬停或键盘聚焦时读取锚点位置,再根据 `ResizeObserver` 提供的边框盒尺寸调整气泡。首次定位前气泡保持隐藏;横向移入视口留白,仅在另一侧容得下时上下翻转。标签尺寸与视口变化复用锚点坐标;定位不会同步测量气泡,也不会触发 React 渲染。
@@ -20,18 +20,12 @@
align-items: center;
height: calc(24px + var(--dsh-content-font-delta, 0px));
min-width: 0;
color: var(--dsw-alias-label-tertiary);
transition: color 100ms ease;
}
.row[data-expandable] {
cursor: pointer;
}
.row:hover {
color: var(--dsw-alias-label-secondary);
}
.leading {
position: relative;
flex: none;
@@ -44,7 +38,7 @@
padding: 0;
border: none;
background: none;
color: inherit;
color: var(--dsw-alias-label-tertiary);
}
/* Flow-row glyphs render at 14px inside the 16px box; the CSS edge overrides
@@ -82,25 +76,9 @@ button.leading {
opacity: 1;
}
.rowContent {
flex: 1;
width: 0;
column-gap: inherit;
}
.rowContentLayout {
column-gap: inherit;
}
.title {
flex: none;
font-size: var(--dsh-content-font-size-secondary, 13px);
line-height: calc(24px + var(--dsh-content-font-delta, 0px));
color: inherit;
}
@media (prefers-reduced-motion: reduce) {
.row {
transition: none;
}
color: var(--dsw-alias-label-secondary);
}
@@ -1,17 +1,17 @@
import { memo, type KeyboardEvent, type MouseEvent, type ReactNode } from 'react'
import clsx from 'clsx'
import { IconChevronDownOutlineRegular, IconChevronUpOutlineRegular } from './icons/index.tsx'
import { Shimmer, ShimmerText } from './Shimmer.tsx'
import { TextShimmer } from './TextShimmer.tsx'
import css from './DisclosureRow.module.css'
/** Shared 24px process row: tertiary text and icons, secondary on hover. */
/** Shared 24px disclosure chrome for compact flow rows. */
export interface DisclosureRowProps {
icon: ReactNode
title: string
open: boolean
expandable: boolean
onToggle: () => void
/** Animate the complete header while its owning operation is running. */
/** Animate the title while its owning operation is running. */
running?: boolean | undefined
/** Makes the complete title row the disclosure target. */
expandOnRowClick?: boolean | undefined
@@ -100,10 +100,8 @@ export const DisclosureRow = memo(function DisclosureRow({
{leading}
</span>
)}
<Shimmer active={running} className={css.rowContent} contentClassName={css.rowContentLayout}>
<ShimmerText className={clsx(css.title, titleClassName)}>{title}</ShimmerText>
{(keepContentWhenOpen || !open) && collapsedContent}
</Shimmer>
<TextShimmer className={clsx(css.title, titleClassName)} active={running}>{title}</TextShimmer>
{(keepContentWhenOpen || !open) && collapsedContent}
</div>
{open && children}
</div>
@@ -1,88 +0,0 @@
.root {
position: relative;
display: inline-grid;
width: max-content;
max-width: 100%;
min-width: 0;
vertical-align: top;
color: inherit;
}
.content {
display: flex;
align-items: center;
min-width: 0;
}
.text {
color: inherit;
}
.text[data-shimmer-text]::after {
content: attr(data-shimmer-text);
}
.decoration {
position: absolute;
inset: 0;
/* The moving mask must not enlarge any ancestor's scrollable area. */
overflow: clip;
pointer-events: none;
user-select: none;
}
.sweep {
position: absolute;
inset: 0;
overflow: hidden;
color: var(--dsw-alias-label-shimmer);
mask-image: linear-gradient(105deg, transparent 0%, black 40% 60%, transparent 100%);
transform: translateX(-100%);
animation-name: dsh-row-shimmer-sweep;
}
/* Semantic base colors stay on the real content; the overlay supplies one tint. */
.sweep * {
color: inherit !important;
}
.sweep [data-shimmer-decoration] {
background: currentColor;
}
/* Opposite transforms keep every glyph aligned while their shared mask moves. */
.highlight {
width: 100%;
height: 100%;
transform: translateX(100%);
animation-name: dsh-row-shimmer-highlight;
}
.sweep,
.highlight {
animation-duration: 1.5s;
animation-delay: 0.3s;
animation-timing-function: steps(48, end);
animation-iteration-count: infinite;
}
@keyframes dsh-row-shimmer-sweep {
0% { transform: translateX(-100%); }
66.6667%, 100% { transform: translateX(100%); }
}
@keyframes dsh-row-shimmer-highlight {
0% { transform: translateX(100%); }
66.6667%, 100% { transform: translateX(-100%); }
}
@media (prefers-reduced-motion: reduce) {
.decoration {
display: none;
}
.sweep,
.highlight {
animation: none;
}
}
@@ -1,66 +0,0 @@
/** One activity highlight across a row's text and separators. */
import { createContext, memo, useContext, type ReactNode } from 'react'
import clsx from 'clsx'
import css from './Shimmer.module.css'
const DecorativeCopy = createContext(false)
/** Presentational content shared by the visible row and its inert highlight. */
export interface ShimmerProps {
/** Text and layout elements; keep icons outside and use ShimmerText for text. No effects or element ids. */
children: ReactNode
/** Whether the row's owning operation is running. */
active: boolean
/** Additional class applied to the retained wrapper. */
className?: string | undefined
/** Layout class applied equally to the base and decorative content. */
contentClassName?: string | undefined
}
/**
* Sweep one highlight across the visible width of a row's text; place icons outside.
* Children also render in an inert, aria-hidden decoration while active; keep
* state and subscriptions above this component and supply presentational nodes.
* The decoration is clipped to the row without extending its scrollable area.
* @param props - row content, activity, and layout classes.
* @returns the stable base row and optional masked decoration.
*/
export const Shimmer = memo(function Shimmer({ children, active, className, contentClassName }: ShimmerProps) {
return (
<span className={clsx(css.root, className)} data-shimmer={active}>
<span className={clsx(css.content, contentClassName)}>{children}</span>
{/* React 18 emits inert as a string attribute; React 19 requires boolean true. */}
{active && (
<span className={css.decoration} aria-hidden="true" {...{ inert: '' }}>
<span className={css.sweep}>
<DecorativeCopy.Provider value>
<span className={clsx(css.content, css.highlight, contentClassName)}>{children}</span>
</DecorativeCopy.Provider>
</span>
</span>
)}
</span>
)
})
/** Text rendered once for selection and accessibility, with generated decoration. */
export interface ShimmerTextProps {
/** Visible, already-localized text. */
children: string
/** Typography and truncation class applied to both copies. */
className?: string | undefined
}
/**
* Render row text without duplicating its selectable or accessible content.
* @param props - text and owner styling.
* @returns text in the base row or generated text in its decoration.
*/
export const ShimmerText = memo(function ShimmerText({ children, className }: ShimmerTextProps) {
const decorative = useContext(DecorativeCopy)
return (
<span className={clsx(css.text, className)} data-shimmer-text={decorative ? children : undefined}>
{decorative ? null : children}
</span>
)
})
@@ -0,0 +1,27 @@
.root[data-text-shimmer] {
background-image: linear-gradient(
90deg,
currentColor calc(50% - var(--dsh-text-shimmer-spread)),
color-mix(in oklab, currentColor 50%, transparent),
currentColor 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 1.5s cubic-bezier(0.33, 0, 0.67, 1) infinite;
}
@keyframes dsh-text-shimmer {
66.6667%, 100% { background-position: 0% center; }
}
@media (prefers-reduced-motion: reduce) {
.root[data-text-shimmer] {
background-image: none;
-webkit-text-fill-color: currentColor;
animation: none;
}
}
@@ -0,0 +1,23 @@
/** Text-only activity animation with a stable span across lifecycle changes. */
import { memo, useMemo, type CSSProperties } from 'react'
import clsx from 'clsx'
import css from './TextShimmer.module.css'
/** Text and activity supplied by the owning row. */
export interface TextShimmerProps {
children: string
active: boolean
className?: string | undefined
}
/**
* Render text with an optional moving highlight; inactive text keeps the same node.
* @param props - localized text, running state, and owner styling.
* @returns the retained text span.
*/
export const TextShimmer = memo(function TextShimmer({ children, active, className }: TextShimmerProps) {
const style = useMemo(() => ({
'--dsh-text-shimmer-spread': `${children.length * 8}px`,
} as CSSProperties), [children.length])
return <span className={clsx(css.root, className)} style={style} data-text-shimmer={active || undefined}>{children}</span>
})
@@ -449,9 +449,9 @@ export const IconEditOutlineMedium = (props: IconProps) => (
const IconThinkOutlineArtwork = ({ size = 16, className, strokeWidth }: WeightedIconProps) => (
<svg width={size} height={size} className={className} viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg" aria-hidden="true" strokeWidth={strokeWidth}>
<path d="M10.2854 5.71481C12.9673 8.39663 14.1182 11.5938 12.8562 12.8559C11.5942 14.1179 8.39706 12.9669 5.71518 10.2851C3.03333 7.60323 1.88236 4.40608 3.14441 3.14403C4.40644 1.882 7.6036 3.03297 10.2854 5.71481Z" stroke="currentColor" />
<path d="M10.2854 10.2851C7.6036 12.9669 4.40644 14.1179 3.14441 12.8559C1.88236 11.5938 3.03333 8.39663 5.71518 5.71481C8.39706 3.03297 11.5942 1.882 12.8562 3.14403C14.1182 4.40608 12.9673 7.60323 10.2854 10.2851Z" stroke="currentColor" />
<path d="M8.86291 8.0002C8.86291 8.47549 8.47762 8.86087 8.00224 8.86087C7.52694 8.86087 7.1416 8.47549 7.1416 8.0002C7.1416 7.52485 7.52694 7.13953 8.00224 7.13953C8.47762 7.13953 8.86291 7.52485 8.86291 8.0002Z" fill="currentColor" />
<path d="M10.7554 5.24466C13.9891 8.4783 15.3769 12.3333 13.8552 13.8551C12.3335 15.3768 8.4785 13.989 5.24478 10.7553C2.01111 7.52165 0.623307 3.66664 2.14504 2.14491C3.66676 0.623189 7.52178 2.01099 10.7554 5.24466Z" stroke="currentColor" />
<path d="M10.7554 10.7553C7.52178 13.989 3.66676 15.3768 2.14504 13.8551C0.623307 12.3333 2.01111 8.4783 5.24478 5.24466C8.4785 2.01099 12.3335 0.623189 13.8552 2.14491C15.3769 3.66664 13.9891 7.52165 10.7554 10.7553Z" stroke="currentColor" />
<path d="M8.9587 8.00025C8.9587 8.52835 8.5306 8.95655 8.0024 8.95655C7.47429 8.95655 7.04614 8.52835 7.04614 8.00025C7.04614 7.47209 7.47429 7.04395 8.0024 7.04395C8.5306 7.04395 8.9587 7.47209 8.9587 8.00025Z" fill="currentColor" />
</svg>
)
+2 -2
View File
@@ -7,8 +7,8 @@ export { StateDot } from './StateDot.tsx'
export type { StateDotState } from './StateDot.tsx'
export { DisclosureRow } from './DisclosureRow.tsx'
export type { DisclosureRowProps } from './DisclosureRow.tsx'
export { Shimmer, ShimmerText } from './Shimmer.tsx'
export type { ShimmerProps, ShimmerTextProps } from './Shimmer.tsx'
export { TextShimmer } from './TextShimmer.tsx'
export type { TextShimmerProps } from './TextShimmer.tsx'
export { Button } from './Button.tsx'
export type { ButtonVariant } from './Button.tsx'
export { Pill } from './Pill.tsx'
@@ -1,41 +0,0 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { Shimmer, ShimmerText } from '../src/Shimmer.tsx'
afterEach(cleanup)
describe('Shimmer', () => {
it('retains real text and controls while one inert decoration covers icons and text', () => {
const open = vi.fn()
const row = (active: boolean, title: string) => (
<Shimmer active={active}>
<svg aria-hidden="true"><path d="M0 0L8 8" /></svg>
<ShimmerText>{title}</ShimmerText>
<button type="button" onClick={open}><ShimmerText>file.ts</ShimmerText></button>
</Shimmer>
)
const view = render(row(true, 'Read'))
const title = view.getByText('Read')
const button = view.getByRole('button', { name: 'file.ts' })
const decoration = view.container.querySelector('[inert]')!
expect(decoration.getAttribute('aria-hidden')).toBe('true')
expect(decoration.querySelector('svg')).not.toBeNull()
expect([...decoration.querySelectorAll('[data-shimmer-text]')].map(node => node.getAttribute('data-shimmer-text')))
.toEqual(['Read', 'file.ts'])
expect(view.container.textContent).toBe('Readfile.ts')
fireEvent.click(button)
expect(open).toHaveBeenCalledOnce()
view.rerender(row(true, 'Reading'))
expect(view.getByText('Reading')).toBe(title)
expect(view.getByRole('button', { name: 'file.ts' })).toBe(button)
expect(view.container.querySelector('[inert]')).toBe(decoration)
view.rerender(row(false, 'Read'))
expect(view.getByText('Read')).toBe(title)
expect(view.getByRole('button', { name: 'file.ts' })).toBe(button)
expect(view.container.querySelector('[inert]')).toBeNull()
expect(view.container.querySelectorAll('svg')).toHaveLength(1)
})
})
@@ -0,0 +1,16 @@
/** CSS activity and reduced-motion rules that jsdom does not execute. */
import { readFileSync } from 'node:fs'
import { describe, expect, it } from 'vitest'
const css = readFileSync(new URL('../src/TextShimmer.module.css', import.meta.url), 'utf8')
describe('TextShimmer styles', () => {
it('animates active text and disables motion and transparent fill for reduced motion', () => {
expect(css).toMatch(/\.root\[data-text-shimmer\]\s*\{[^}]*animation: dsh-text-shimmer/s)
expect(css).toMatch(/@keyframes dsh-text-shimmer\s*\{[^}]*background-position: 0% center/s)
const reduced = /@media \(prefers-reduced-motion: reduce\)\s*\{\s*\.root\[data-text-shimmer\]\s*\{([^}]+)\}/.exec(css)?.[1]
expect(reduced).toContain('background-image: none;')
expect(reduced).toContain('-webkit-text-fill-color: currentColor;')
expect(reduced).toContain('animation: none;')
})
})
@@ -0,0 +1,29 @@
// @vitest-environment jsdom
import { cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { TextShimmer } from '../src/TextShimmer.tsx'
afterEach(cleanup)
describe('TextShimmer', () => {
it('retains its text span across activity, text, and owner-class changes', () => {
const view = render(<TextShimmer active className="title">Run</TextShimmer>)
const span = view.getByText('Run')
expect(span.tagName).toBe('SPAN')
expect(span.dataset.textShimmer).toBe('true')
expect(span.classList.contains('title')).toBe(true)
expect(span.style.getPropertyValue('--dsh-text-shimmer-spread')).toBe('24px')
view.rerender(<TextShimmer active={false}>End</TextShimmer>)
expect(view.getByText('End')).toBe(span)
expect(span.hasAttribute('data-text-shimmer')).toBe(false)
expect(span.classList.contains('title')).toBe(false)
expect(span.style.getPropertyValue('--dsh-text-shimmer-spread')).toBe('24px')
view.rerender(<TextShimmer active>Running</TextShimmer>)
expect(view.getByText('Running')).toBe(span)
expect(span.dataset.textShimmer).toBe('true')
expect(span.style.getPropertyValue('--dsh-text-shimmer-spread')).toBe('56px')
})
})
+2 -2
View File
@@ -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-skill/README.md
README.md: 9e9bc11f76bce8159e9f63a011c686c62db39776
README.zh.md: 93d0f1ce318c404fcff512168d6092b21b91743e
README.md: 0234ce8fb6c2737ea242ac5590e471c3e4c91834
README.zh.md: 5b039ec1c5bea818e6396f4896c05b8175beb08a
+1 -1
View File
@@ -35,7 +35,7 @@ Ordinary-session candidates come from the `skills/list` Remote; the host serves
The `preparing` stage shows only the skill icon and title, with no requested skill name or expansion. The `start` and `result` stages use the recorded arguments and output described below; preparation does not consult the installed catalog.
A collapsed row renders the skill glyph, `Skill` title, and requested skill name on the same secondary font-size axis, line height, row height, and glyph scaling as ordinary Tool rows. The header follows the shared [process-row text and icon colors](../ui-primitives/README.md#component-catalog). Every lifecycle state retains the ordinary skill glyph; running calls share one left-to-right shimmer across the title, separator, and skill name, while failures replace the name with the first error line and interruptions retain their explicit status text. A settled row expands into a bounded `Instructions` card containing the exact durable tool output, with the standard trajectory `Inspect` affordance when available; this expanded card keeps its own typography. The row derives its name, lifecycle, and body only from the frozen call/result slice supplied by ui-tool, never from the current catalog, so replay stays stable when installed skills or their descriptions change.
A collapsed row renders the skill glyph, `Skill` title, and requested skill name on the same secondary font-size axis, line height, row height, and glyph scaling as ordinary Tool rows. Every lifecycle state retains the ordinary skill glyph; running calls carry the transcript shimmer, while failures replace the name with the first error line and interruptions retain their explicit status text. A settled row expands into a bounded `Instructions` card containing the exact durable tool output, with the standard trajectory `Inspect` affordance when available; this expanded card keeps its own typography. The row derives its name, lifecycle, and body only from the frozen call/result slice supplied by ui-tool, never from the current catalog, so replay stays stable when installed skills or their descriptions change.
Hovering over `/name` highlights the entire reference. Clicking a known skill opens its provider-supplied `SKILL.md` path in the right Sidebar while keeping the token editable. An uncached click shares the per-Session catalog fetch and opens when it completes, retaining the clicked Session address. Preset changes, connection resets, and plugin disposal cancel pending previews; a later click fetches the current catalog again. Skills without a file path remain invocable but have no file preview.
+1 -1
View File
@@ -35,7 +35,7 @@ kind: "package-reference"
`preparing` 阶段只显示技能图标与标题,不显示请求加载的技能名称,也不能展开。`start` 和 `result` 阶段使用下述记录参数与输出;准备阶段不查询已安装目录。
收起的行显示 skill 图标、`Skill` 标题与请求加载的 skill 名称,并与普通工具行使用相同的 secondary 字号轴、行高、行高度和图标缩放。标题行遵循共享的[过程行文字与图标颜色](../ui-primitives/README.zh.md#component-catalog)。所有生命周期状态都保留普通 skill 图标;运行中的调用以同一道高光从左到右扫过标题、分隔符与 skill 名称,失败时以错误首行替换名称,中断则保留明确状态文本。已结算的行展开为一个尺寸受限的 `Instructions` 卡片,其中原样呈现持久化的工具输出;可用时还会提供标准轨迹的 `Inspect` 入口,展开卡片保留自身排版。该行的名称、生命周期与正文只派生自 ui-tool 提供的冻结调用/结果切片,绝不读取当前目录,因此即使已安装的 skill 或其描述发生变化,回放仍保持稳定。
收起的行显示 skill 图标、`Skill` 标题与请求加载的 skill 名称,并与普通工具行使用相同的 secondary 字号轴、行高、行高度和图标缩放。所有生命周期状态都保留普通 skill 图标;运行中的调用带有 transcript(文本记录)的扫光效果,失败时以错误首行替换名称,中断则保留明确状态文本。已结算的行展开为一个尺寸受限的 `Instructions` 卡片,其中原样呈现持久化的工具输出;可用时还会提供标准轨迹的 `Inspect` 入口,展开卡片保留自身排版。该行的名称、生命周期与正文只派生自 ui-tool 提供的冻结调用/结果切片,绝不读取当前目录,因此即使已安装的 skill 或其描述发生变化,回放仍保持稳定。
鼠标移入 `/name` 时,背景覆盖整个引用。点击已知 skill 会在右侧栏打开提供方给出的 `SKILL.md` 路径,同时保留文本的可编辑性。缓存未就绪时,点击复用该 Session 的目录请求,在完成后按点击时的 Session 地址打开预览。切换预设、重置连接和插件释放会取消待处理的预览;后续点击重新获取当前目录。没有文件路径的 skill 仍可调用,但没有文件预览。
@@ -12,18 +12,33 @@
align-items: center;
height: calc(24px + var(--dsh-content-font-delta, 0px));
min-width: 0;
color: var(--dsw-alias-label-tertiary);
transition: color 100ms ease;
}
.row:hover {
color: var(--dsw-alias-label-secondary);
}
.row[data-expandable] {
cursor: pointer;
}
.card[data-state='running'] .row::after,
.card[data-state='preparing'] .row::after {
content: '';
position: absolute;
inset: 0 auto 0 0;
width: 300px;
background: linear-gradient(
90deg,
transparent 0%,
color-mix(in srgb, var(--dsw-alias-bg-base) 60%, transparent) 55%,
transparent 100%
);
animation: dsh-skill-row-sweep 2.6s ease-out infinite;
pointer-events: none;
}
@keyframes dsh-skill-row-sweep {
0% { left: -300px; }
90%, 100% { left: 100%; }
}
.leading {
position: relative;
flex: none;
@@ -33,7 +48,7 @@
align-items: center;
justify-content: center;
margin-right: 6px;
color: inherit;
color: var(--dsw-alias-label-tertiary);
}
.leading svg {
@@ -42,7 +57,7 @@
}
.chevron {
color: inherit;
color: var(--dsw-alias-label-secondary);
}
.iconIdle {
@@ -71,6 +86,7 @@
flex: none;
font-size: var(--dsh-content-font-size-secondary, 13px);
line-height: calc(24px + var(--dsh-content-font-delta, 0px));
color: var(--dsw-alias-label-secondary);
}
.separator {
@@ -90,6 +106,7 @@
white-space: nowrap;
font-size: var(--dsh-content-font-size-secondary, 13px);
line-height: calc(24px + var(--dsh-content-font-delta, 0px));
color: var(--dsw-alias-label-tertiary);
}
.errorSummary {
@@ -193,7 +210,11 @@
}
@media (prefers-reduced-motion: reduce) {
.row,
.card[data-state='running'] .row::after {
animation: none;
display: none;
}
.iconIdle,
.chevronHover,
.inspectButton {
@@ -1,6 +1,6 @@
import { useState, type KeyboardEvent, type ReactNode } from 'react'
import {
IconChevronDownOutlineRegular, IconInspectOutlineRegular, IconSkillOutlineRegular, Shimmer, ShimmerText,
IconChevronDownOutlineRegular, IconInspectOutlineRegular, IconSkillOutlineRegular,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { StartedToolCallViewProps, ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client'
import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots'
@@ -85,7 +85,7 @@ function disclosureLeading(open: boolean, expandable: boolean): ReactNode {
)
}
/** Accessible running and failure status. */
/** Visually hidden state copy for the color-only running sweep and error tone. */
function stateStatus(state: SkillRowState, t: SkillRowProps['t']): string | null {
switch (state) {
case 'running': return t('row.running')
@@ -95,7 +95,7 @@ function stateStatus(state: SkillRowState, t: SkillRowProps['t']): string | null
}
/**
* Render one `skill` tool call as a process row and instructions disclosure.
* Render one `skill` tool call as an accent summary and instructions disclosure.
* @param props - keyed toolview payload plus the skill locale seat.
* @returns the dedicated skill row.
*/
@@ -104,9 +104,7 @@ export function SkillRow(props: SkillRowProps) {
<div className={css.row}>
<span className={css.leading}><IconSkillOutlineRegular size={14} /></span>
<span className={css.visuallyHidden}>{props.t('row.preparing')}</span>
<Shimmer active>
<ShimmerText className={css.title}>{props.t('row.title')}</ShimmerText>
</Shimmer>
<span className={css.title}>{props.t('row.title')}</span>
</div>
</div>
return <StartedSkillRow {...props} />
@@ -118,7 +116,6 @@ function StartedSkillRow({ block, inspect, t }: Exclude<SkillRowProps, { phase:
const expandable = model.output !== null
const open = expanded && expandable
const status = stateStatus(model.state, t)
const running = model.state === 'running'
const summary = model.state === 'stopped' ? t('row.stopped') : model.errorSummary ?? model.name
const toggleExpand = (): void => {
setExpanded(value => !value)
@@ -143,18 +140,16 @@ function StartedSkillRow({ block, inspect, t }: Exclude<SkillRowProps, { phase:
data-expandable={expandable || undefined}
{...disclosureProps}
>
{status !== null ? <span className={css.visuallyHidden}>{status}</span> : null}
<span className={css.leading}>{leading}</span>
<Shimmer active={running}>
<span className={css.title}><ShimmerText>{t('row.title')}</ShimmerText></span>
<span className={css.separator} data-shimmer-decoration aria-hidden />
<span className={`${css.summary}${
model.state === 'error' ? ` ${css.errorSummary}`
: model.state === 'stopped' ? ` ${css.stoppedSummary}` : ''
}`}>
<ShimmerText>{summary}</ShimmerText>
</span>
</Shimmer>
{status !== null ? <span className={css.visuallyHidden}>{status}</span> : null}
<span className={css.title}>{t('row.title')}</span>
<span className={css.separator} aria-hidden />
<span className={`${css.summary}${
model.state === 'error' ? ` ${css.errorSummary}`
: model.state === 'stopped' ? ` ${css.stoppedSummary}` : ''
}`}>
{summary}
</span>
</div>
{open ? (
<div className={css.bodyWrap}>
@@ -110,7 +110,7 @@ describe('SkillRow', () => {
}))} />)
const row = screen.getByRole('button', { name: 'skill 加载失败加载技能SkillError: missing resource' })
expect(view.container.querySelector('[data-tool="skill"]')?.getAttribute('data-state')).toBe('error')
expect(view.container.querySelector('[data-tool="skill"] [role="button"] svg')).not.toBeNull()
expect(view.container.querySelector('[data-tool="skill"] > div > span:first-child svg')).not.toBeNull()
expect(view.container.querySelector('[data-tool="skill"] [data-state]')).toBeNull()
expect(row.textContent).not.toContain('Check SKILL.md.')
fireEvent.click(row)
@@ -124,8 +124,8 @@ describe('SkillRow', () => {
error: { name: 'InterruptedError', code: 'interrupted' },
}))} />)
const stoppedSummary = stoppedView.getByText('skill 加载已中止')
expect(stoppedSummary.parentElement?.className).toContain('stoppedSummary')
expect(stoppedView.container.querySelector('[data-tool="skill"] [role="button"] svg')).not.toBeNull()
expect(stoppedSummary.className).toContain('stoppedSummary')
expect(stoppedView.container.querySelector('[data-tool="skill"] > div > span:first-child svg')).not.toBeNull()
expect(stoppedView.container.querySelector('[data-tool="skill"] [data-state]')).toBeNull()
cleanup()
+2 -2
View File
@@ -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-theme/README.md
README.md: a2938f0c03d7f6b2c0202e67be1b66dff08516f3
README.zh.md: d19649e26722f506c3142a095eb82b0a08235626
README.md: 7248b7230afa28f817bec7c0eb26fd9512fff405
README.zh.md: 6dc1db064dd9a6754f6c6475dde18ccf1a17841e
+3 -5
View File
@@ -9,7 +9,7 @@ English | [中文](README.zh.md)
## Summary
`dsh-client-ui-theme` lets Web GUI users choose `light`, `dark`, or `system` and set conversation content text from 12 to 24 px in Settings. A loopback client stores both values in the `ui-theme` settings namespace, which the local provider persists in `$DSH_HOME/cordis.patch.yml` by default. The plugin resolves `system` through `prefers-color-scheme` and publishes immutable `ThemeSnapshot`s; ui-layout applies each snapshot to the document. The package also ships the `--dsw-*` token stylesheets and injects a synchronous bootstrap so the selected palette and font size apply before the shell loads. Third-party themes can register alias-token overrides through `ctx.theme`.
`dsh-client-ui-theme` lets Web GUI users choose `light`, `dark`, or `system` and set conversation content text from 12 to 17 px in Settings. A loopback client stores both values in the `ui-theme` settings namespace, which the local provider persists in `$DSH_HOME/cordis.patch.yml` by default. The plugin resolves `system` through `prefers-color-scheme` and publishes immutable `ThemeSnapshot`s; ui-layout applies each snapshot to the document. The package also ships the `--dsw-*` token stylesheets and injects a synchronous bootstrap so the selected palette and font size apply before the shell loads. Third-party themes can register alias-token overrides through `ctx.theme`.
## Table of Contents
@@ -29,7 +29,7 @@ Users switch the color scheme and content font size from two rows in Settings (G
### Appearance and font size
The plugin registers Appearance preference cubes and a font-size stepper in the General section. The stepper accepts integer values from 12 through 24 px and defaults to 14 px. It changes conversation headings and base text by the same increment, including the user bubble and composer draft; flow-row titles, summaries, and tables follow one step under the body size, while small text and code keep fixed sizes. Each accepted change writes through the Host settings API. Rapid changes serialize in gesture order with namespace revisions, and a rejected latest write reloads the durable values. Non-loopback pages keep both choices process-local.
The plugin registers Appearance preference cubes and a font-size stepper in the General section. The stepper accepts integer values from 12 through 17 px and defaults to 14 px. It changes conversation headings and base text by the same increment, including the user bubble and composer draft; flow-row titles, summaries, and tables follow one step under the body size, while small text and code keep fixed sizes. Each accepted change writes through the Host settings API. Rapid changes serialize in gesture order with namespace revisions, and a rejected latest write reloads the durable values. Non-loopback pages keep both choices process-local.
### Registering a theme
@@ -51,9 +51,7 @@ The service owns theme and font-size state and publishes snapshots. The ui-layou
### Stylesheets
`src/styles/` holds six sheets imported in order by ui-theme's dynamic client entry: `base.css`, `corner-shape.css`, `design-platform.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. The client bundle compiles and injects them as plugin-owned global styles, so unload and HMR remove them with ui-theme. `scrollbar.css` consumes the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them. Status marks use their own semantic state tokens. `design-platform.css` also owns the code-diff fill aliases and their static alpha palette entries, plus the `--dsw-alias-file-diff-*` code, gutter, and marker palette for file comparisons; `shiki.css` owns syntax colors. `--dsw-alias-label-shimmer` supplies an overlay for the shared text shimmer: black at 40% alpha in the light palette and white at 45% alpha in the dark palette. `--dsw-alias-label-deep-diving` and `--dsw-alias-label-deep-diving-shimmer` supply the blue activity label and sweep; the dark palette uses a lighter, less saturated label with a brighter blue sweep.
The `--dsw-alias-turn-trigger-*` tokens provide separate resting and hover backgrounds for Turn-trigger notices in each palette.
`src/styles/` holds six sheets imported in order by ui-theme's dynamic client entry: `base.css`, `corner-shape.css`, `design-platform.css`, `scrollbar.css`, `gradient-shadow-text.css`, and `shiki.css`. The client bundle compiles and injects them as plugin-owned global styles, so unload and HMR remove them with ui-theme. `scrollbar.css` consumes the `--dsw-alias-scrollbar-*` tokens and must follow `design-platform.css`, which declares them. Status marks use their own semantic state tokens. `design-platform.css` also owns the code-diff fill aliases and their static alpha palette entries, plus the `--dsw-alias-file-diff-*` code, gutter, and marker palette for file comparisons; `shiki.css` owns syntax colors.
`brand-font.css` exports the local Montserrat Light, Regular and Medium faces (normal style, weights 300, 400 and 500), with `montserrat-light.woff2`, `montserrat-regular.woff2`, `montserrat-medium.woff2` and its SIL Open Font License in `lib/styles/`. Desktop bundles the same stylesheet, font and license for offline welcome brand text; ordinary UI keeps its system font stack.
+3 -5
View File
@@ -9,7 +9,7 @@ kind: "package-reference"
## 概述
`dsh-client-ui-theme` 让 Web GUI 用户在设置中选择 `light`、`dark` 或 `system`,并把会话正文字号设为 12 至 24 px。回环客户端把两个值存入 `ui-theme` 设置命名空间,本地提供方默认将其持久化到 `$DSH_HOME/cordis.patch.yml`。插件通过 `prefers-color-scheme` 解析 `system` 并发布不可变的 `ThemeSnapshot`;ui-layout 把每份快照应用到文档。本包还提供 `--dsw-*` token 样式表,并注入同步引导,使所选调色板与字号在外壳加载前生效。第三方主题可通过 `ctx.theme` 注册别名 token 覆盖。
`dsh-client-ui-theme` 让 Web GUI 用户在设置中选择 `light`、`dark` 或 `system`,并把会话正文字号设为 12 至 17 px。回环客户端把两个值存入 `ui-theme` 设置命名空间,本地提供方默认将其持久化到 `$DSH_HOME/cordis.patch.yml`。插件通过 `prefers-color-scheme` 解析 `system` 并发布不可变的 `ThemeSnapshot`;ui-layout 把每份快照应用到文档。本包还提供 `--dsw-*` token 样式表,并注入同步引导,使所选调色板与字号在外壳加载前生效。第三方主题可通过 `ctx.theme` 注册别名 token 覆盖。
## 目录
@@ -29,7 +29,7 @@ kind: "package-reference"
### 外观与字号
插件在「通用」分区注册外观偏好方块与字号步进器。步进器接受 12 至 24 px 的整数,默认值为 14 px。它以相同增量调整会话标题与基础文本,包括用户气泡与 composer 草稿;流内行的标题、摘要与表格跟随比正文低一档的字号,小号文本和代码保持固定字号。每次通过的变更都经 Host settings API 写入。连续快速变更按操作顺序携带命名空间 revision 串行写入,最新写入被拒时重新加载持久值。非 loopback 页面把两个选择都保留在进程内。
插件在「通用」分区注册外观偏好方块与字号步进器。步进器接受 12 至 17 px 的整数,默认值为 14 px。它以相同增量调整会话标题与基础文本,包括用户气泡与 composer 草稿;流内行的标题、摘要与表格跟随比正文低一档的字号,小号文本和代码保持固定字号。每次通过的变更都经 Host settings API 写入。连续快速变更按操作顺序携带命名空间 revision 串行写入,最新写入被拒时重新加载持久值。非 loopback 页面把两个选择都保留在进程内。
### 注册主题
@@ -51,9 +51,7 @@ kind: "package-reference"
### 样式表
`src/styles/` 下有六张样式表,由 ui-theme 的动态客户端 entry 依次导入:`base.css`、`corner-shape.css`、`design-platform.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。客户端 bundle 将其编译并注入为插件持有的全局样式,因此卸载与 HMR(热模块替换)会随 ui-theme 一同移除。`scrollbar.css` 消费 `--dsw-alias-scrollbar-*` token,必须排在声明这些 token 的 `design-platform.css` 之后。状态标记使用各自的语义状态 token。`design-platform.css` 还负责代码差异底色的别名及其静态透明度色阶,以及文件对比所用的 `--dsw-alias-file-diff-*` 代码区、行号区和标记配色;`shiki.css` 负责语法颜色。`--dsw-alias-label-shimmer` 为共享文字扫光提供叠加色:浅色配色使用 40% alpha 的黑色,深色配色使用 45% alpha 的白色。 `--dsw-alias-label-deep-diving` 和 `--dsw-alias-label-deep-diving-shimmer` 提供蓝色活动文本和扫光颜色;深色主题使用更亮、饱和度更低的文字,并以更亮的蓝色扫光。
`--dsw-alias-turn-trigger-*` token 分别定义深浅主题下轮次触发通知的常态和悬停底色。
`src/styles/` 下有六张样式表,由 ui-theme 的动态客户端 entry 依次导入:`base.css`、`corner-shape.css`、`design-platform.css`、`scrollbar.css`、`gradient-shadow-text.css` 与 `shiki.css`。客户端 bundle 将其编译并注入为插件持有的全局样式,因此卸载与 HMR(热模块替换)会随 ui-theme 一同移除。`scrollbar.css` 消费 `--dsw-alias-scrollbar-*` token,必须排在声明这些 token 的 `design-platform.css` 之后。状态标记使用各自的语义状态 token。`design-platform.css` 还负责代码差异底色的别名及其静态透明度色阶,以及文件对比所用的 `--dsw-alias-file-diff-*` 代码区、行号区和标记配色;`shiki.css` 负责语法颜色。
`brand-font.css` 导出本地 Montserrat Light、Regular 和 Medium 字体(正体、字重 300、400 和 500),`lib/styles/` 同时提供 `montserrat-light.woff2`、`montserrat-regular.woff2`、`montserrat-medium.woff2` 及其 SIL Open Font License。Desktop 将同一份样式表、字体和许可证打包,用于欢迎页品牌文字的离线显示;普通界面保留系统字体栈。
@@ -211,8 +211,6 @@ body {
--dsw-alias-interactive-bg-hover-solid: var(--dsw-static-neutral-bluish-75);
--dsw-alias-interactive-bg-hover: rgba(38, 49, 72, 0.06);
--dsw-alias-label-caption: var(--dsw-static-neutral-bluish-400);
--dsw-alias-label-deep-diving: color-mix(in srgb, var(--dsw-static-deepseek-500) 70%, var(--dsw-static-blue-950));
--dsw-alias-label-deep-diving-shimmer: color-mix(in srgb, var(--dsw-static-deepseek-500) 35%, var(--dsw-static-blue-950));
--dsw-alias-label-dimmed: var(--dsw-static-neutral-bluish-200);
--dsw-alias-label-primary-bluish: var(--dsw-static-blue-900);
--dsw-alias-label-primary-dimmed: var(--dsw-static-neutral-bluish-950);
@@ -220,7 +218,6 @@ body {
--dsw-alias-label-primary-inverted: var(--dsw-static-neutral-bluish-00);
--dsw-alias-label-primary: var(--dsw-static-neutral-bluish-1000);
--dsw-alias-label-secondary: var(--dsw-static-neutral-bluish-700);
--dsw-alias-label-shimmer: color-mix(in srgb, var(--dsw-static-neutral-1000) 40%, transparent);
--dsw-alias-label-tertiary: var(--dsw-static-neutral-bluish-600);
--dsw-alias-link: var(--dsw-static-deepseek-500);
--dsw-alias-markdown-citation: var(--dsw-static-neutral-bluish-100);
@@ -257,8 +254,6 @@ body {
--dsw-alias-state-warn-tertiary: var(--dsw-static-amber-100);
--dsw-alias-toast-bg: var(--dsw-static-neutral-bluish-800);
--dsw-alias-tooltip-bg: var(--dsw-static-neutral-bluish-850);
--dsw-alias-turn-trigger-bg: var(--dsw-alias-markdown-code-block);
--dsw-alias-turn-trigger-bg-hover: var(--dsw-alias-interactive-bg-hover);
--dsw-specific-bubble-highlight: var(--dsw-static-deepseek-200);
--dsw-specific-bubble: var(--dsw-static-deepseek-50);
--dsw-specific-input-major: var(--dsw-static-neutral-bluish-00);
@@ -322,8 +317,6 @@ body[data-ds-dark-theme] {
--dsw-alias-interactive-bg-hover-solid: var(--dsw-static-neutral-bluish-800);
--dsw-alias-interactive-bg-hover: rgba(255, 255, 255, 0.08);
--dsw-alias-label-caption: var(--dsw-static-neutral-bluish-600);
--dsw-alias-label-deep-diving: color-mix(in srgb, var(--dsw-static-deepseek-450) 55%, var(--dsw-static-neutral-bluish-400));
--dsw-alias-label-deep-diving-shimmer: color-mix(in srgb, var(--dsw-static-blue-300) 65%, var(--dsw-static-deepseek-400));
--dsw-alias-label-dimmed: var(--dsw-static-neutral-bluish-750);
--dsw-alias-label-primary-bluish: var(--dsw-static-neutral-bluish-50);
--dsw-alias-label-primary-dimmed: var(--dsw-static-neutral-bluish-100);
@@ -331,7 +324,6 @@ body[data-ds-dark-theme] {
--dsw-alias-label-primary-inverted: var(--dsw-static-neutral-bluish-800);
--dsw-alias-label-primary: var(--dsw-static-neutral-bluish-50);
--dsw-alias-label-secondary: var(--dsw-static-neutral-bluish-300);
--dsw-alias-label-shimmer: color-mix(in srgb, var(--dsw-static-neutral-00) 45%, transparent);
--dsw-alias-label-tertiary: var(--dsw-static-neutral-bluish-400);
--dsw-alias-link: var(--dsw-static-deepseek-400);
--dsw-alias-markdown-citation: var(--dsw-static-neutral-bluish-800);
@@ -367,8 +359,6 @@ body[data-ds-dark-theme] {
--dsw-alias-state-warn-secondary: var(--dsw-static-amber-400);
--dsw-alias-state-warn-tertiary: var(--dsw-static-amber-900);
--dsw-alias-toast-bg: var(--dsw-static-neutral-bluish-750);
--dsw-alias-turn-trigger-bg: var(--dsw-alias-interactive-bg-hover);
--dsw-alias-turn-trigger-bg-hover: var(--dsw-alias-interactive-bg-active);
--dsw-alias-tooltip-bg: var(--dsw-static-neutral-bluish-750);
--dsw-specific-bubble-highlight: var(--dsw-static-neutral-bluish-750);
--dsw-specific-bubble: var(--dsw-static-neutral-bluish-850);
@@ -45,11 +45,11 @@ body[data-ds-dark-theme] {
/* Markdown 阶梯整体按 0.875(正文 16 → 14)等比缩放,四舍五入到整数像素;
重新从 Figma 导出时需要保留这一缩放。 */
/* 正文字号调节:--dsh-content-font-size 由主题 bootstrap 与 ThemePresenter
写在 body 上(整数 px,12–24,默认 14)。标题与正文变体按同一像素增量
写在 body 上(整数 px,12–17,默认 14)。标题与正文变体按同一像素增量
(--dsh-content-font-delta)随动,保持层级。低一档文本(think、工具/bash
行的标题与摘要、markdown 表格、流内次级说明等)统一读
--dsh-content-font-size-secondary:设置 ≤14 时为设置值 −1,>14 时为
设置值 −2(12→11、13→12、14→13、15→13、16→14、17→15、24→22)。式中
设置值 −2(12→11、13→12、14→13、15→13、16→14、17→15)。式中
max(13px,·) 的作用是让低设置取 −1 而非 −2,不是结果下限——12 设置下
层级触底 11px。small、code 属于密集次级文本,保持固定。 */
body {
@@ -24,7 +24,7 @@ export const DEFAULT_PREFERENCE: ThemePreference = 'system'
export const FONT_SIZE_MIN = 12
/** Largest accepted content font size (px). */
export const FONT_SIZE_MAX = 24
export const FONT_SIZE_MAX = 17
/** Content font size when the user-settings document has no override (px). */
export const DEFAULT_FONT_SIZE = 14
@@ -77,8 +77,8 @@ describe('theme bootstrap row', () => {
it('writes the durable content font size and defaults it to 14px', () => {
mockSystemDark(false)
executeBootstrap('light', 24)
expect(document.body.style.getPropertyValue('--dsh-content-font-size')).toBe('24px')
executeBootstrap('light', 17)
expect(document.body.style.getPropertyValue('--dsh-content-font-size')).toBe('17px')
executeBootstrap('light')
expect(document.body.style.getPropertyValue('--dsh-content-font-size')).toBe('14px')
})
@@ -28,11 +28,11 @@ describe('ui-theme host', () => {
const configuration = await liveConfig(ctx, { Config, apply })
const { fiber } = configuration
expect(plainConfig(configuration.fiber.config)).toEqual({ preference: DEFAULT_PREFERENCE, fontSize: 14 })
await configuration.update({ preference: 'dark', fontSize: 24 })
expect(plainConfig(configuration.fiber.config)).toEqual({ preference: 'dark', fontSize: 24 })
await configuration.update({ preference: 'dark', fontSize: 16 })
expect(plainConfig(configuration.fiber.config)).toEqual({ preference: 'dark', fontSize: 16 })
await expect(configuration.update({ preference: 'sepia' })).rejects.toThrow()
await expect(configuration.update({ fontSize: 11 })).rejects.toThrow()
await expect(configuration.update({ fontSize: 25 })).rejects.toThrow()
await expect(configuration.update({ fontSize: 18 })).rejects.toThrow()
await fiber.dispose()
})
@@ -51,10 +51,10 @@ describe('ui-theme host', () => {
expect(rowText(rows[0])).toContain('@media(prefers-color-scheme:dark)')
expect(rowText(rows[1])).toContain('const preference = "system"')
expect(rowText(rows[1])).toContain('"14px"')
await configuration.update({ preference: 'dark', fontSize: 24 })
await configuration.update({ preference: 'dark', fontSize: 17 })
expect(rowText(collect(ctx)[0])).toContain('color-scheme:dark')
expect(rowText(collect(ctx)[1])).toContain('const preference = "dark"')
expect(rowText(collect(ctx)[1])).toContain('"24px"')
expect(rowText(collect(ctx)[1])).toContain('"17px"')
await fiber.dispose()
expect(collect(ctx)).toEqual([{ kind: 'script', placement: 'head', text: 'window.afterTheme=true' }])
})
@@ -87,11 +87,7 @@ describe('FontSizeRow', () => {
})
it('disables the outward arrow at each bound', () => {
const b = mount(23)
fireEvent.click(arrow('Increase font size'))
expect(b.setFontSize).toHaveBeenCalledWith(24)
act(() => { b.store.actions.sync(24, 1) })
expect(screen.getByText('24')).toBeDefined()
mount(17)
expect(arrow('Increase font size').disabled).toBe(true)
expect(arrow('Decrease font size').disabled).toBe(false)
cleanup()
@@ -36,10 +36,10 @@ describe('ThemeRuntime', () => {
it('seeds the initial font size from the boot-script body variable, ignoring junk', () => {
// The Host boot script writes the durable size on body before any plugin
// runs; the first snapshot must match it so activation never flashes 14.
document.body.style.setProperty('--dsh-content-font-size', '24px')
document.body.style.setProperty('--dsh-content-font-size', '16px')
try {
expect(make().theme.getTheme().fontSize).toBe(24)
document.body.style.setProperty('--dsh-content-font-size', '25px')
expect(make().theme.getTheme().fontSize).toBe(16)
document.body.style.setProperty('--dsh-content-font-size', '99px')
expect(make().theme.getTheme().fontSize).toBe(14)
} finally {
document.body.style.removeProperty('--dsh-content-font-size')
@@ -48,19 +48,19 @@ describe('ThemeRuntime', () => {
it('setFontSize switches, writes through the scope, and republishes; same value is a no-op', () => {
const { theme, events, host } = make()
theme.setFontSize(24)
expect(theme.getTheme().fontSize).toBe(24)
expect(host.set).toHaveBeenCalledWith('fontSize', 24)
theme.setFontSize(17)
expect(theme.getTheme().fontSize).toBe(17)
expect(host.set).toHaveBeenCalledWith('fontSize', 17)
expect(events).toHaveLength(1)
theme.setFontSize(24)
theme.setFontSize(17)
expect(events).toHaveLength(1)
expect(host.set).toHaveBeenCalledOnce()
})
it('rejects out-of-range and fractional font sizes', () => {
const { theme, events, host } = make()
for (const px of [11, 25, 14.5, Number.NaN]) {
expect(() => { theme.setFontSize(px) }).toThrow('outside 12..24')
for (const px of [11, 18, 14.5, Number.NaN]) {
expect(() => { theme.setFontSize(px) }).toThrow('outside 12..17')
}
expect(events).toHaveLength(0)
expect(host.set).not.toHaveBeenCalled()
+2 -2
View File
@@ -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: 1fcfa9ffda84b5e0bf76a46accbe486cfb9e9d04
README.zh.md: 2806fa47ae4d8e48215bce9ca178984839633421
README.md: a00ed8e8c78dce29144a3ff89d2eb671b5f209fb
README.zh.md: c4a6f6183be082d8c21877b0c1c7052652bc7e2e
+2 -2
View File
@@ -49,7 +49,7 @@ The owner payload is `ToolCallOwnerProps`: `callId`, `toolName`, the `phase` dis
Every registered view receives the explicit `preparing`, `start`, and `result` props declared in [the Tool slot types](src/client/contract/slots.ts). Shared rows use the same `ToolRow` in all stages. Their row model selects the title and combines any generic tool-name prefix with the available argument summary independently of lifecycle state. Tool-owned titles omit the English name. Preparation has no argument summary, and the shared argument parser returns no call without parsing JSON. Custom renderers such as Bash, Skill, and Cordis handle preparation separately; their argument-dependent components accept `StartedToolCallViewProps`.
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 PTC dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall` and `presentResult` values never enter the Client. Tool headers follow the shared [process-row colors](../ui-primitives/README.md#component-catalog) for names, summaries, file paths, ordinary counts, glyphs, and chevrons in every Work details mode, whether details are collapsed, expanded, or unavailable; error and warning summaries keep their semantic colors on hover. Running atomic rows share one left-to-right shimmer across their titles, separators, summaries, and suffixes, while settled rows stay static. Running and settled foreground standard `bash`/`pwsh` and `terminal_send` calls use terminal cards at the root and in PTC 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 PTC 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 PTC dispatch presentations. Structured cards derive directly from first-party raw event fields; Host `presentCall` and `presentResult` values never enter the Client. Running and settled foreground standard `bash`/`pwsh` and `terminal_send` calls use terminal cards at the root and in PTC 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 PTC 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`.
-----
@@ -72,7 +72,7 @@ Tool owner props forward Chat's stable `useDisclosure` Hook through root and nes
Every card is read in place in the call tree; there is no second, full-height presentation of a selected call. Row renderers share one pure card model for each terminal, read, diff, search, and web card, and the image card's gallery renders through the tool-owned `tool.call.images` slot. These models validate raw call arguments, result content, failure state, persisted metadata, PTC dispatch `parentCallId`, and Session path facts. Unsupported or malformed inputs use flattened Tool result text. A file-path summary opens the file through the owner's `openFile`, which the chat view routes to the right Sidebar's text preview; `inspect` opens the trajectory view and is absent when that View is unavailable; cards then omit Inspect. Card-specific limits and fallback rules for the terminal, diff, read, search, and web cards remain in [the ui-primitives README](../ui-primitives/README.md); the image card's model in this package carries its own fallback rules.
Chat diff cards keep nine rows before folding, enough for a file header, one removed/added pair, and three context lines on either side. The tool row shows the primitive's exact or coarse-replacement counts; the expanded card contains the diff body without a totals footer. Hovering the tool header colors its added count green and removed count red; both retain those colors while the card is expanded.
Chat diff cards keep nine rows before folding, enough for a file header, one removed/added pair, and three context lines on either side. The tool row shows the primitive's exact or coarse-replacement counts; the expanded card contains the diff body without a totals footer.
An Auto denial takes precedence over keyed specialized views. Its generic row preserves the call identity, omits raw arguments, and normalizes the stored reason only for display: trim surrounding whitespace and collapse line separators to spaces, with localized fallback for an empty result. Session and SDK error details keep the original reason.
+2 -2
View File
@@ -49,7 +49,7 @@ owner 载荷为 `ToolCallOwnerProps`:`callId`、`toolName`、`phase` 判别字
每个注册视图都接收[工具 slot 类型](src/client/contract/slots.ts)声明的显式 `preparing`、`start` 和 `result` props。通用行在三个阶段使用同一个 `ToolRow`。行模型统一选择标题,并组合通用工具名前缀与已有参数摘要,不按生命周期阶段改变前缀;专用标题不附带英文名。准备阶段没有参数摘要,共享参数解析入口直接返回无调用,不解析 JSON。Bash、Skill、Cordis 等自定义 renderer 分别处理准备态,其依赖参数的组件接收 `StartedToolCallViewProps`。
本包拥有 generic fallback,以及 shell/pwsh、read、read_image、write/edit、运行中的 `str_replace_editor` `create`/`str_replace`、grep/glob、web、todo、question 与 PTC dispatch 的内置展示。结构化卡片直接从第一方原始事件字段派生;Host `presentCall` 与 `presentResult` 值不会进入 Client。工具标题行的名称、摘要、文件路径、普通数量、图标与箭头在所有工作过程展示模式下均遵循共享的[过程行颜色](../ui-primitives/README.zh.md#component-catalog),无论详情是收起、展开还是不可展开;错误和警告摘要在悬停时保留语义色。运行中的原子工具行以同一道高光从左到右扫过标题、分隔符、摘要与后缀;已完成行保持静态。运行中与已完成的前台标准 `bash`/`pwsh` 和 `terminal_send` 调用,无论位于根还是 PTC dispatch 子调用中,都在通过相同的参数、结果和错误检查后使用 terminal 卡片。持久 `bash`/`pwsh` 调用仅在运行中使用 terminal 卡片。以已识别的 spill 策略提示结尾的 shell 输出,在 shell 行中使用可展开的 generic 输出,在 Details 中使用 generic 输出;位置被改变或被省略的退出标记无法证明成功。已完成的持久 shell 结果保持 generic 展示,因为 reset 与部分输出诊断不一定描述单个进程的退出状态;根调用的持久 shell 结果可展开,后台启动回执则保持折叠。带有 `AUTO_REVIEW_DENIED` 的原生或 PTC 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 与 PTC dispatch 的内置展示。结构化卡片直接从第一方原始 event 字段派生;Host `presentCall` 与 `presentResult` 值不会进入 Client。运行中与已完成的前台标准 `bash`/`pwsh` 和 `terminal_send` 调用,无论位于根还是 PTC dispatch 子调用中,都在通过相同的参数、结果和错误检查后使用 terminal 卡片。持久 `bash`/`pwsh` 调用仅在运行中使用 terminal 卡片。以已识别的 spill 策略提示结尾的 shell 输出,在 shell 行中使用可展开的 generic 输出,在 Details 中使用 generic 输出;位置被改变或被省略的退出标记无法证明成功。已完成的持久 shell 结果保持 generic 展示,因为 reset 与部分输出诊断不一定描述单个进程的退出状态;根调用的持久 shell 结果可展开,后台启动回执则保持折叠。带有 `AUTO_REVIEW_DENIED` 的原生或 PTC dispatch 失败会在折叠行显示 Auto review 裁决,展开时显示一行归一化后的“未执行”原因;原因缺失或只有空白时使用本地化 fallback 文案。成功的问题行按稳定 id 配对调用中的问题与结果中的回答,展开后显示可读的问答行。已取消或已中断的问题行显示其裁决与原始问题,不虚构回答。不受支持、格式错误或含糊的输入回退为压平的工具输入/结果文本。`ui-skill` 展示了业务包自行拥有的 `skill` 注册项。
-----
@@ -72,7 +72,7 @@ Tool 所有者属性将 Chat 注入的稳定 `useDisclosure` 钩子传给根调
每张卡片都直接在调用树中查看;选中调用后不会再显示第二个全高视图。行 renderer 为 terminal、read、diff、search 和 web 卡片各复用同一个纯 card model,image 卡片的图库经由工具自有 `tool.call.images` slot 渲染。这些 model 校验原始调用参数、结果内容、失败状态、持久 metadata、PTC dispatch 的 `parentCallId` 与会话路径信息。不受支持或格式错误的输入使用压平的工具结果文本。文件路径摘要经属主的 `openFile` 打开文件,chat 视图把它路由到右侧 Sidebar 的文本预览;`inspect` 打开轨迹视图;该视图不可用时不提供此回调,卡片随之隐藏 Inspect。terminal、diff、read、search 与 web 卡片的上限与 fallback 规则仍由 [ui-primitives README](../ui-primitives/README.zh.md) 负责;image 卡片的 fallback 规则由本包内的 card model 自行承载。
Chat diff 卡片在折叠前保留九行,足以容纳文件标题、一对删除与新增行及两侧各三行上下文。工具行显示原语提供的精确或粗粒度替换统计;展开卡片包含差异正文,不显示底部统计。悬停工具标题行时,新增数量显示绿色、删除数量显示红色;卡片展开期间保留这两种颜色。
Chat diff 卡片在折叠前保留九行,足以容纳文件标题、一对删除与新增行及两侧各三行上下文。工具行显示原语提供的精确或粗粒度替换统计;展开卡片包含差异正文,不显示底部统计。
Auto 拒绝优先于按工具名选择的专门视图。其通用行保留调用身份、省略原始参数,并且只在显示时归一化存储的理由:去除首尾空白,把行分隔符折叠为空格,结果为空时使用本地化通用理由。Session 与 SDK 错误详情保留原始理由。
@@ -15,12 +15,28 @@
flex-shrink: 0;
}
/* Cordis lifecycle tools retain their generic row mechanics while carrying a
shared product accent and tool-owned action title. */
.root[data-tool^='cordis_'] .leading,
.root[data-tool^='cordis_'] .title {
color: var(--dsw-alias-state-business-primary);
}
.root[data-tool^='cordis_'] .title {
font-weight: 500;
}
.root[data-tool^='cordis_'] .sep {
background: var(--dsw-alias-state-business-primary);
}
.chevron {
color: var(--dsw-alias-label-secondary);
}
.title {
font-weight: 400;
transition: color 100ms ease;
}
.sep {
@@ -40,6 +56,8 @@
white-space: nowrap;
font-size: var(--dsh-content-font-size-secondary, 13px);
line-height: calc(24px + var(--dsh-content-font-delta, 0px));
color: var(--dsw-alias-label-tertiary);
transition: color 100ms ease;
}
/* Trailing summary fragment kept out of .summary's ellipsis, for a count whose
@@ -53,29 +71,31 @@
white-space: nowrap;
font-size: var(--dsh-content-font-size-secondary, 13px);
line-height: calc(24px + var(--dsh-content-font-delta, 0px));
color: var(--dsw-alias-label-tertiary);
transition: color 100ms ease;
}
.row:hover .title,
.row:hover .summary:not(.errorSummary):not(.stoppedSummary),
.row:hover .summarySuffix {
color: var(--dsw-alias-label-primary);
}
/* Diff-row +/- totals: the code font, matching the diff body the numbers
summarize; a wider gap keeps the digits from reading as part of the path.
Two px under the secondary tier (still riding the axis): mono digits read
optically larger than the sans path at the same size. The
optically larger than the sans path at the same size. Caption, one step
dimmer than the suffix's tertiary, so the digits stay behind the path. The
half-pixel nudge closes the baseline gap the size difference leaves under
the row's box-centering (a transform, so flex layout is untouched). */
.diffStat {
margin-left: 10px;
font-family: var(--ds-font-family-code);
font-size: calc(var(--dsh-content-font-size-secondary, 13px) - 2px);
color: var(--dsw-alias-label-caption);
transform: translateY(0.5px);
}
.row:is(:hover, [aria-expanded='true']) .diffAdded {
color: var(--dsw-alias-state-success-primary);
}
.row:is(:hover, [aria-expanded='true']) .diffRemoved {
color: var(--dsw-alias-state-error-primary);
}
/* File-tool path: same type as .summary, with a persistent link affordance.
Dotted dimmed underline: visible enough to say "clickable", light enough
that a long path doesn't read as one heavy rule under the row. Shrink-to-fit
@@ -95,12 +115,19 @@
text-align: left;
font-size: var(--dsh-content-font-size-secondary, 13px);
line-height: calc(24px + var(--dsh-content-font-delta, 0px));
color: inherit;
color: var(--dsw-alias-label-secondary);
text-decoration: underline dotted;
text-decoration-color: currentColor;
text-decoration-color: var(--dsw-alias-label-tertiary);
text-decoration-thickness: 1px;
text-underline-offset: 3px;
cursor: pointer;
transition: color 100ms ease;
}
.row:hover .fileLink,
.fileLink:hover {
color: var(--dsw-alias-label-primary);
text-decoration-color: currentColor;
}
/* Error row's collapsed summary: the failure's first line in the error color. */
@@ -2,7 +2,7 @@ import { memo, useCallback, useMemo, type KeyboardEvent, type MouseEvent, type R
import clsx from 'clsx'
import {
CodeBlock, DiffBlock, DisclosureRow, IconInspectOutlineRegular, ReadBlock, SearchBlock,
TerminalBlock, ShimmerText, WebBlock,
TerminalBlock, TextShimmer, WebBlock,
diffTotals,
} from '@deepseek-ai/dsh-client-ui-primitives'
import type { PropsRenderSlots, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots'
@@ -55,7 +55,6 @@ export interface ToolRowProps {
errorSummary?: string | null | undefined
/** Terminal card; card fields are mutually exclusive and replace text sections. */
terminal?: TerminalCardModel | null | undefined
/** Diff card with inline totals colored on header hover and while expanded. */
diff?: DiffCardModel | null | undefined
read?: ReadCardModel | null | undefined
/**
@@ -176,7 +175,11 @@ export const ToolRow = memo(function ToolRow({
const summaryText = failureLine ?? normalSummary
// The tool row keeps the diff's +/- totals visible while its body is collapsed.
// An explicit summarySuffix overrides the diff totals.
const diffStat = useMemo(() => diffBody === null ? null : diffTotals(diffBody.card.diffs), [diffBody])
const diffStat = useMemo(() => {
if (diffBody === null) return null
const { added, removed } = diffTotals(diffBody.card.diffs)
return `+${added} -${removed}`
}, [diffBody])
const settledWithCue = state === 'error' || state === 'stopped'
const suffix = settledWithCue ? null : summarySuffix ?? diffStat
const openFile = useMemo(() => filePath !== undefined && onOpenFile !== undefined && !settledWithCue
@@ -200,7 +203,7 @@ export const ToolRow = memo(function ToolRow({
/* An empty summary drops the separator with it (a row that is only
its title shows no trailing dot). */
<>
<span className={css.sep} data-shimmer-decoration aria-hidden />
<span className={css.sep} aria-hidden />
{openFile !== undefined ? (
<button
type="button"
@@ -208,7 +211,7 @@ export const ToolRow = memo(function ToolRow({
onClick={openFile}
onKeyDown={fileLinkKeyDown}
>
<ShimmerText>{summaryText}</ShimmerText>
<TextShimmer active={running}>{summaryText}</TextShimmer>
</button>
) : (
<span
@@ -218,20 +221,14 @@ export const ToolRow = memo(function ToolRow({
state === 'stopped' && css.stoppedSummary,
)}
>
<ShimmerText>{summaryText}</ShimmerText>
<TextShimmer active={running}>{summaryText}</TextShimmer>
</span>
)}
{suffix !== null && (typeof suffix === 'string'
? <ShimmerText className={css.summarySuffix}>{suffix}</ShimmerText>
: (
<span className={clsx(css.summarySuffix, css.diffStat)}>
<ShimmerText className={css.diffAdded}>{`+${suffix.added}`}</ShimmerText>
{' '}
<ShimmerText className={css.diffRemoved}>{`-${suffix.removed}`}</ShimmerText>
</span>
))}
{suffix !== null && (
<TextShimmer className={clsx(css.summarySuffix, suffix === diffStat && css.diffStat)} active={running}>{suffix}</TextShimmer>
)}
</>
), [fileLinkKeyDown, openFile, state, suffix, summaryText])
), [diffStat, fileLinkKeyDown, openFile, running, state, suffix, summaryText])
const expandedContent = useMemo(() => open ? (
<div className={clsx(css.bodyWrap, detailsBody !== null && css.detailsBodyWrap)}>
{askQuestionBody !== null
@@ -343,6 +340,7 @@ export const ToolRow = memo(function ToolRow({
rowClassName={css.row}
leadingClassName={css.leading}
titleClassName={css.title}
chevronClassName={css.chevron}
icon={icon}
title={title}
running={running}
@@ -89,18 +89,6 @@
align-items: center;
height: calc(24px + var(--dsh-content-font-delta, 0px));
min-width: 0;
color: var(--dsw-alias-label-tertiary);
transition: color 100ms ease;
}
.root:hover {
color: var(--dsw-alias-label-secondary);
}
@media (prefers-reduced-motion: reduce) {
.root {
transition: none;
}
}
.leading {
@@ -112,7 +100,7 @@
align-items: center;
justify-content: center;
margin-right: 6px;
color: inherit;
color: var(--dsw-alias-label-tertiary);
}
/* Same glyph-scaling rule as DisclosureRow's leading: the CSS edge overrides
@@ -127,7 +115,7 @@
}
.chevron {
color: inherit;
color: var(--dsw-alias-label-secondary);
}
/* Hover preview on the expandable row: the idle icon crossfades (100ms) into
@@ -158,6 +146,8 @@
flex: none;
font-size: var(--dsh-content-font-size-secondary, 13px);
line-height: calc(24px + var(--dsh-content-font-delta, 0px));
color: var(--dsw-alias-label-secondary);
transition: color 100ms ease;
}
.sep {
@@ -177,6 +167,13 @@
white-space: nowrap;
font-size: var(--dsh-content-font-size-secondary, 13px);
line-height: calc(24px + var(--dsh-content-font-delta, 0px));
color: var(--dsw-alias-label-tertiary);
transition: color 100ms ease;
}
.root:hover .title,
.root:hover .summary:not(.errorSummary):not(.stoppedSummary) {
color: var(--dsw-alias-label-primary);
}
/* Error row's collapsed summary: the failure's first line in the error color. */
@@ -3,7 +3,7 @@ import type { Context } from '@deepseek-ai/cordis'
import clsx from 'clsx'
import {
IconApiOutlineRegular, IconChevronDownOutlineRegular, IconChevronUpOutlineRegular, IconInspectOutlineRegular,
TerminalBlock, Shimmer, ShimmerText,
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'
@@ -107,19 +107,17 @@ const StartedBashRow = memo(function StartedBashRow({ toolName, block, sessionId
onClick={expandable ? toggleExpand : undefined}
onKeyDown={expandable ? toggleFromKeyboard : undefined}
>
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
<span className={css.leading}>{leading}</span>
<Shimmer active={running}>
<ShimmerText className={css.title}>{t(model.titleKey)}</ShimmerText>
<span className={css.sep} data-shimmer-decoration aria-hidden />
<span className={clsx(
css.summary,
state === 'error' && css.errorSummary,
state === 'stopped' && css.stoppedSummary,
)}>
<ShimmerText>{settlementLine ?? normalSummary}</ShimmerText>
</span>
</Shimmer>
{status !== null && <span className={css.visuallyHidden}>{status}</span>}
<TextShimmer className={css.title} active={running}>{t(model.titleKey)}</TextShimmer>
<span className={css.sep} aria-hidden />
<span className={clsx(
css.summary,
state === 'error' && css.errorSummary,
state === 'stopped' && css.stoppedSummary,
)}>
<TextShimmer active={running}>{settlementLine ?? normalSummary}</TextShimmer>
</span>
</div>
{open && (
<div className={css.bodyWrap}>
@@ -245,10 +245,9 @@ describe('FileMutationRow diff card', () => {
meta: { diffs: [] },
}), 'write')} />)
// The collapsed row already carries the card's +/- totals beside the path.
expect(view.container.querySelector('[data-disclosure-row]')?.textContent).toContain('+1 -0')
expect(view.getByText('+1 -0')).toBeTruthy()
toggleRow(view)
expect(view.getAllByText('+1')).toHaveLength(1)
expect(view.getAllByText('-0')).toHaveLength(1)
expect(view.getAllByText('+1 -0')).toHaveLength(1)
expect(view.getByText('hello fixture')).toBeTruthy()
})
@@ -25,15 +25,9 @@ describe('ToolRow.module.css summary line', () => {
it.each([
['ToolRow', '../src/client/tool/components/ToolRow.module.css'],
['BashRow', '../src/client/tool/toolviews/bash-sample.module.css'],
] as const)('%s leaves row text color to the shared disclosure header', (_name, path) => {
// The header supplies tertiary at rest and secondary on hover; a tool sheet
// that restates a row text color, or brightens the row text on hover, would
// pin one state against the shared process-row colors. The Inspect button
// keeps its own solid hover fill and is not row text.
] as const)('%s keeps stopped and error summaries outside hover darkening', (_name, path) => {
const sheet = readFileSync(fileURLToPath(new URL(path, import.meta.url)), 'utf8')
.replace(/\/\*[\s\S]*?\*\//g, ' ')
expect(sheet).not.toMatch(/\.(title|summary|summarySuffix)\s*\{[^{}]*\bcolor:/)
expect(sheet).not.toMatch(/:hover\s+\.(title|summary|summarySuffix)\b/)
expect(sheet).toContain('.summary:not(.errorSummary):not(.stoppedSummary)')
})
it('keeps the summary suffix on one line and unshrunk', () => {
Loaded 100 of 163 files, more files were not shown because too many files have changed in this diff. Show more