feat(web): unify tool-row expand interaction with IN/OUT card and trajectory Inspect

Every expandable tool row shares one interaction (whole-row toggle,
icon-to-chevron hover preview) and one expanded body: an IN/OUT
gutter-labeled card with per-section 150px scroll caps and sticky labels.
toolRowModel derives result output and the error first line, terminalFailed
surfaces a failing exit as the collapsed row's red dot, a hover Inspect
pill jumps to the call's trajectory record through a one-shot store
handoff, and the chat view keeps its scroll offset across view switches.
This commit is contained in:
Yif
2026-07-30 21:21:29 +08:00
parent 86fa88a012
commit 78e4d36214
38 changed files with 1241 additions and 268 deletions

View File

@@ -7,6 +7,10 @@
.block {
--dsl-terminal-radius: 12px;
--dsl-terminal-line-height: 22px;
/* Rebindable by consumers (CodeBlock's --dsl-code-block-content-font
pattern): a surface wanting the smaller code size rebinds this together
with --dsl-terminal-line-height on its own container. */
--dsl-terminal-font: var(--dsw-font-markdown-code-block);
/* The card's own left inset, holding the run-state dot in a column of its own
so it never competes with the commands for horizontal space. */
--dsl-terminal-gutter: 30px;
@@ -22,26 +26,49 @@
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-markdown-code-block);
border-radius: var(--dsl-terminal-radius);
/* Clip the banner to the card's own radius: when a consumer adds a border,
the banner's equal corner radius no longer nests inside it and leaves a
notch at the corner. Nothing inside renders out of the box. */
overflow: hidden;
}
/* Top-aligned: the status pill and copy control stay on the first prompt row
however many command lines the card carries. */
/* The status pill and copy control top-align to the FIRST prompt row (their
heights are capped to the prompt line, so on a multi-line command they sit
with the first command instead of floating mid-banner). */
.header {
display: flex;
align-items: flex-start;
gap: 12px;
/* Pulled back across the card's gutter padding so the banner background and
its top-left radius span the FULL surface, then re-inset by the same amount
so the prompt text and the dot keep their positions. A plain block child
only reaches the content box, which left the gutter column painted in the
body color and drew the card's top-left corner in it — invisible in the
light theme, where banner and body share a token, and visible in the dark
one, where they do not. */
/* Pulled back across the card's gutter padding so the banner spans the FULL
surface, then re-inset by the same amount so the prompt text and the dot
keep their positions. The banner shares the card's own surface (no banner
token): the l2 divider below is the section boundary. */
margin-left: calc(-1 * var(--dsl-terminal-gutter));
padding: 9px 14px 9px var(--dsl-terminal-gutter);
background: var(--dsw-alias-markdown-code-block-banner);
border-top-left-radius: var(--dsl-terminal-radius);
border-top-right-radius: var(--dsl-terminal-radius);
/* A long multi-line command scrolls inside the banner (same cap as the
IN/OUT card's sections) instead of pushing the output off screen. */
max-height: 150px;
overflow-y: auto;
}
/* Banner scrollbar floats off the card edge like the output's. */
.header::-webkit-scrollbar-thumb {
border: 2px solid transparent;
background-clip: padding-box;
border-radius: 6px;
}
.header::-webkit-scrollbar-track {
margin: 6px;
}
/* Full-width l2 hairline between the command banner and the body — the same
divider the IN/OUT card draws between its sections. A running card is
banner-only, so it draws none. */
.block:not([data-running]) .header {
border-bottom: 1px solid var(--dsw-alias-border-l2);
}
/* One row per command line. The prompt column is the only element allowed to
@@ -51,7 +78,7 @@
flex-direction: column;
min-width: 0;
flex: 1;
font: var(--dsw-font-markdown-code-block);
font: var(--dsl-terminal-font);
}
.promptLine {
@@ -100,27 +127,59 @@
white-space: pre;
}
/* Capped to the prompt's line height (Pill's own 24px height would exceed a
smaller-font prompt row and stretch the banner). Sticky against the
banner's own scroll so the pill and the copy control stay in reach while a
long command scrolls underneath. */
.status {
flex: none;
position: sticky;
top: 0;
height: var(--dsl-terminal-line-height);
color: var(--dsw-alias-state-error-primary);
}
.copyButton {
flex: none;
background-color: transparent;
position: sticky;
top: 0;
/* Card surface, not transparent: the control is sticky over the banner's
own scroll, so scrolled command text must not bleed through it. */
background-color: var(--dsw-alias-markdown-code-block);
border: none;
padding: 0;
margin: 0;
color: var(--dsw-alias-label-secondary);
cursor: pointer;
font: var(--dsw-font-xs-13);
line-height: var(--dsl-terminal-line-height);
}
/* Vertical scrolling lives on the OUTPUT, not the card root: a root scroller
would run its scrollbar over the banner (and the copy control), while here
the banner stays pinned and the bar sits inside the output's right padding.
Unset, the max-height is none and the auto overflow never engages. */
.output {
max-height: var(--dsl-terminal-output-max-height, none);
padding: 12px 14px 12px 0;
font: var(--dsw-font-markdown-code-block);
font: var(--dsl-terminal-font);
overflow-x: auto;
overflow-y: hidden;
overflow-y: auto;
}
/* Both output scrollbars (vertical cap, horizontal pre overflow) float 2px
off the card edge: a transparent border clips the thumb inward so it never
hugs the rounded corner. */
.output::-webkit-scrollbar-thumb {
border: 2px solid transparent;
background-clip: padding-box;
border-radius: 6px;
}
/* Track end-margins keep the thumb's travel out of the card's rounded
corners in both directions. */
.output::-webkit-scrollbar-track {
margin: 6px;
}
/* No wrapping, no word-break: alignment is the payload of terminal output. */
@@ -147,6 +206,6 @@
.empty {
padding: 12px 14px 12px 0;
font: var(--dsw-font-markdown-code-block);
font: var(--dsl-terminal-font);
color: var(--dsw-alias-label-tertiary);
}

View File

@@ -35,7 +35,7 @@ export interface TerminalBlockProps {
signal?: string | undefined
/** The command is still running: the block shows the prompt line alone. */
running?: boolean | undefined
/** Height cap in output lines before the middle collapses (default {@link DEFAULT_TERMINAL_MAX_LINES}). */
/** Height cap in output lines before the middle collapses (default {@link DEFAULT_TERMINAL_MAX_LINES}); Infinity disables the cap. */
maxLines?: number | undefined
/** Extra class merged onto the wrapper (callers position; this component draws). */
className?: string | undefined