feat(web): state the run state on the terminal card's prompt line

The terminal card showed no run state: a running command and a settled
command that produced no output rendered the same prompt line, so whether
a command was still running had to be inferred from the absence of output.

Lead the prompt line with a StateDot in three of its states — the spinning
ring while running, red for the same exit status that renders the status
pill, green for a clean settle. That is the same indicator a tool row's
leading icon carries, so a row and its own card cannot disagree about one
command; the row/card agreement is pinned in the ui-conversation spec.
StateDot is aria-hidden, so a visually hidden text label rides beside it,
which is what the refreshed aria goldens now record.

The e2e adds what jsdom cannot compute: the dot's color resolves to the
green success token through the real theme stylesheet, and the dot precedes
the prompt label in document order.
This commit is contained in:
Chinesezjc
2026-07-28 16:41:04 +08:00
parent 540cb59585
commit f4c243c75f
18 changed files with 184 additions and 27 deletions

View File

@@ -207,6 +207,34 @@ describe('web e2e: navigation & panes over a rich seeded session', () => {
return { whiteSpace: getComputedStyle(row).whiteSpace, overflowX: getComputedStyle(pane).overflowX, ...squeezed }
})
expect(layout).toEqual({ whiteSpace: 'pre', overflowX: 'auto', wrapped: false, scrollsSideways: true })
// The run-state dot's color is the whole point of it and is the one thing
// jsdom cannot report: --dsw-* tokens resolve only against the real theme
// stylesheet. This command settled cleanly, so the dot must be the green
// success token — a red one here would read as a failed command.
const dot = await card.locator('[class*="_runState_"][data-state]').first().evaluate((node) => {
// The token lives on body, so the probe must sit in the same cascade.
const probe = document.createElement('span')
probe.style.color = 'var(--dsw-alias-state-success-primary)'
document.body.appendChild(probe)
const success = getComputedStyle(probe).color
probe.remove()
return {
state: node.getAttribute('data-state'),
color: getComputedStyle(node as HTMLElement).color,
success,
label: node.parentElement?.querySelector('[class*="_runStateLabel_"]')?.textContent ?? null,
// The dot precedes the prompt label in document order, which is what
// puts it to the left of the `$`.
beforePrompt: node.compareDocumentPosition(node.parentElement!.querySelector('[class*="_cwd_"]')!)
=== Node.DOCUMENT_POSITION_FOLLOWING,
}
})
expect(dot.state).toBe('done')
expect(dot.label).toBe('已完成')
expect(dot.beforePrompt).toBe(true)
// Resolved through the theme token, not a literal hex in the component.
expect(dot.success).toMatch(/^rgb/)
expect(dot.color).toBe(dot.success)
// Golden of the card at rest — captured before the copy click, whose
// confirmation label self-reverts on a timer and would not hold still.
const snapshot = (await captureStableAria(page, '[data-terminal]', scaffold.workspaceCwd))

View File

@@ -3,6 +3,6 @@
- text: Input json
- button "复制"
- code: "{ \"command\": \"echo NAVIGATION_OK\", \"description\": \"Print NAVIGATION_OK\" }"
- text: Output $ echo NAVIGATION_OK
- text: Output 已完成 $ echo NAVIGATION_OK
- button "复制"
- text: NAVIGATION_OK

View File

@@ -1,3 +1,3 @@
- text: $ echo NAVIGATION_OK
- text: 已完成 $ echo NAVIGATION_OK
- button "复制"
- text: NAVIGATION_OK

View File

@@ -120,9 +120,14 @@ function readCard(card: Element) {
text: expander.textContent,
expanded: expander.getAttribute('aria-expanded'),
},
// The run-state dot at the head of the prompt line, by its StateDot state.
runState: card.querySelector('[class*="_runState_"][data-state]')?.getAttribute('data-state') ?? null,
runStateLabel: card.querySelector('[class*="_runStateLabel_"]')?.textContent ?? null,
// Every color the ANSI parser emits resolves through a --dsw-* token, so
// the card follows the theme instead of painting literal terminal rgb.
colors: [...new Set([...card.querySelectorAll('span[style]')]
// Scoped to the output lines: the run-state dot is an inline-styled span
// too, and its geometry is not an ANSI-resolved color.
colors: [...new Set([...card.querySelectorAll('[class*="_line_"] span[style]')]
.map(span => span.getAttribute('style')))],
}
}
@@ -198,6 +203,8 @@ it('renders the keyed bash row with a resident terminal card', async () => {
"[exit code: 1]",
],
"prompt": "nested pnpm run check",
"runState": "error",
"runStateLabel": "失败",
"status": "退出码 1",
}
`)
@@ -229,6 +236,8 @@ it('the fallback row reaches the same card through its expand control', async ()
"-rw-r--r-- demo.txt",
],
"prompt": "fixture ls -la",
"runState": "done",
"runStateLabel": "已完成",
"status": null,
}
`)
@@ -318,6 +327,8 @@ it('the details panel Output section renders the same call at full height', asyn
],
"panelLines": 16,
"prompt": "nested pnpm run check",
"runState": "error",
"runStateLabel": "失败",
"status": "退出码 1",
}
`)