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

@@ -1,6 +1,6 @@
// @vitest-environment jsdom
// TerminalBlock: the prompt label's cwd shortening, the running/empty/settled
// arms, the exit-status pill, the head/tail height cap and its expand control,
// arms, the prompt line's run-state dot, the exit-status pill, the head/tail height cap and its expand control,
// and the copy control writing the raw output on both the accepted and the
// refused clipboard paths. writeClipboard's own return contract is pinned here
// too, since it is the seam both copy controls in this package share; the
@@ -25,6 +25,15 @@ function outputLines(container: HTMLElement): string[] {
return [...container.querySelectorAll('[class^="_line_"]')].map(row => row.textContent ?? '')
}
/** The prompt line's run-state dot: its StateDot state plus the hidden text label beside it. */
function runStateOf(container: HTMLElement): { state: string | null; label: string | undefined } {
const dot = container.querySelector('[class*="_runState_"][data-state]')
return {
state: dot?.getAttribute('data-state') ?? null,
label: container.querySelector('[class^="_runStateLabel_"]')?.textContent ?? undefined,
}
}
/** `count` numbered output lines, without the terminating newline. */
function body(count: number): string {
return Array.from({ length: count }, (_value, index) => `line ${index + 1}`).join('\n')
@@ -128,7 +137,8 @@ describe('TerminalBlock states', () => {
it('renders ANSI runs as styled spans and plain text bare', () => {
const view = render(<TerminalBlock command="ls" output={`${ESC}[31mbad${ESC}[39m ok`} />)
const span = view.container.querySelector('span[style]')
// Scoped to a line: the prompt line's run-state dot is a styled span too.
const span = view.container.querySelector('[class^="_line_"] span[style]')
expect(span?.textContent).toBe('bad')
expect(span?.getAttribute('style')).toContain('--dsw-alias-state-error-primary')
expect(outputLines(view.container)).toEqual(['bad ok'])
@@ -163,6 +173,46 @@ describe('TerminalBlock status pill', () => {
})
})
describe('TerminalBlock run-state dot', () => {
it('shows the spinning ring and its running label while the command runs', () => {
const view = render(<TerminalBlock command="sleep 5" running />)
expect(runStateOf(view.container)).toEqual({ state: 'ongoing', label: '运行中' })
})
it('shows the done dot for a clean settled exit', () => {
const view = render(<TerminalBlock command="true" output="a" exitCode={0} />)
expect(runStateOf(view.container)).toEqual({ state: 'done', label: '已完成' })
})
it('counts a settled command with no exit status as a clean settle', () => {
const view = render(<TerminalBlock command="ls" output="a" />)
expect(runStateOf(view.container)).toEqual({ state: 'done', label: '已完成' })
})
it('shows the error dot for a non-zero exit', () => {
const view = render(<TerminalBlock command="false" output="a" exitCode={1} />)
expect(runStateOf(view.container)).toEqual({ state: 'error', label: '失败' })
})
it('shows the error dot for a signal, whatever the exit code says', () => {
const view = render(<TerminalBlock command="sleep 9" output="a" exitCode={0} signal="SIGKILL" />)
expect(runStateOf(view.container)).toEqual({ state: 'error', label: '失败' })
})
// The dot precedes the prompt label, which is what makes it read as the
// state OF this command rather than of the card's chrome.
it('places the dot ahead of the prompt label and the command', () => {
const view = render(<TerminalBlock command="ls" cwd="/srv/app" output="a" />)
const prompt = view.container.querySelector('[class^="_prompt_"]')
expect([...prompt!.children].map(node => node.textContent)).toEqual(['', '已完成', 'app', 'ls'])
})
it('keeps the running dot even while a settled-looking status pill is supplied', () => {
const view = render(<TerminalBlock command="sleep 5" running signal="SIGINT" />)
expect(runStateOf(view.container)).toEqual({ state: 'ongoing', label: '运行中' })
})
})
describe('TerminalBlock height cap', () => {
it('renders every line and no expand control under the cap', () => {
const view = render(<TerminalBlock command="ls" output={body(4)} maxLines={4} />)