style(run-stages): copy affordance, collapsed long output, sticky header

- extract a shared CopyButton into components/ui.tsx and drop the
  install wizard's local duplicate
- sticky stage header at the top of the turn stream so users always
  know which stage they're reading as they scroll
- copy-to-clipboard button on System, Assistant, and Command blocks;
  revealed on hover/focus
- stdout/stderr longer than 20 lines collapse to the last 20 with a
  "Show N earlier lines" expander
- bump the [10px] labels in tool-use.tsx to [11px] for readability

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-04-19 17:27:25 -04:00
parent 54867d29ed
commit 9b5bc8ede4
No known key found for this signature in database
7 changed files with 247 additions and 178 deletions

View file

@ -30,11 +30,11 @@ export function ToolRow({ tool }: { tool: ToolUse }) {
{open && (
<div className="space-y-px bg-overlay px-2.5 pb-2 pt-1">
<div className="rounded bg-overlay px-2.5 py-2">
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-fg-muted">Input</div>
<div className="mb-1 text-[11px] font-medium uppercase tracking-wider text-fg-muted">Input</div>
<pre className="whitespace-pre-wrap font-mono text-xs leading-relaxed text-fg-3">{tool.input}</pre>
</div>
<div className="rounded bg-overlay px-2.5 py-2">
<div className="mb-1 text-[10px] font-medium uppercase tracking-wider text-fg-muted">Result</div>
<div className="mb-1 text-[11px] font-medium uppercase tracking-wider text-fg-muted">Result</div>
<pre className={`whitespace-pre-wrap font-mono text-xs leading-relaxed ${tool.isError ? "text-coral" : "text-fg-3"}`}>{tool.result}</pre>
</div>
</div>

View file

@ -1,6 +1,12 @@
// Shared UI primitives. The install wizard set the visual baseline; this file
// exposes the primary button, secondary button, input, and inline error
// treatments so the auth and in-app surfaces can match.
// exposes the primary button, secondary button, input, error message, and
// copy button so the auth and in-app surfaces can match.
import { useState } from "react";
import {
ClipboardDocumentCheckIcon,
ClipboardIcon,
} from "@heroicons/react/16/solid";
export const INPUT_CLASS =
"block w-full rounded-lg bg-panel-alt px-3.5 py-2.5 text-base text-fg outline-1 -outline-offset-1 outline-white/10 placeholder:text-fg-muted focus:outline-2 focus:-outline-offset-1 focus:outline-teal-500 sm:text-sm";
@ -21,3 +27,38 @@ export function ErrorMessage({ message }: { message: string }) {
</p>
);
}
export function CopyButton({
value,
label,
className = "",
}: {
value: string;
label: string;
className?: string;
}) {
const [copied, setCopied] = useState(false);
return (
<button
type="button"
onClick={async () => {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch {
// Clipboard may be blocked; leave state unchanged.
}
}}
className={`inline-flex size-6 shrink-0 items-center justify-center rounded text-fg-muted outline-teal-500 hover:bg-overlay hover:text-fg-2 focus-visible:outline-2 focus-visible:outline-offset-1 ${className}`}
aria-label={copied ? "Copied" : label}
title={copied ? "Copied" : label}
>
{copied ? (
<ClipboardDocumentCheckIcon className="size-4 text-mint" />
) : (
<ClipboardIcon className="size-4" />
)}
</button>
);
}

View file

@ -38,6 +38,7 @@ import {
shouldConsumeInstallGithubErrorForPath,
} from "./mode";
import {
CopyButton,
ErrorMessage,
INPUT_CLASS,
PRIMARY_BUTTON_CLASS,
@ -909,7 +910,7 @@ function ReviewScreen({
label="Server URL"
value={serverUrl}
mono
action={<InlineCopyButton value={serverUrl} label="Copy server URL" />}
action={<CopyButton value={serverUrl} label="Copy server URL" />}
/>
{renderGithubSummaryRows(session?.github)}
</dl>
@ -1314,38 +1315,6 @@ function CopyableToken({ token }: { token: string }) {
);
}
function InlineCopyButton({
value,
label,
}: {
value: string;
label: string;
}) {
const [copied, setCopied] = useState(false);
return (
<button
type="button"
onClick={async () => {
try {
await navigator.clipboard.writeText(value);
setCopied(true);
window.setTimeout(() => setCopied(false), 1500);
} catch {
// Clipboard may be blocked; leave state unchanged.
}
}}
className="inline-flex size-6 shrink-0 items-center justify-center rounded text-fg-muted outline-teal-500 hover:bg-overlay hover:text-fg-2 focus-visible:outline-2 focus-visible:outline-offset-1"
aria-label={copied ? "Copied" : label}
>
{copied ? (
<ClipboardDocumentCheckIcon className="size-4 text-mint" />
) : (
<ClipboardIcon className="size-4" />
)}
</button>
);
}
function HelpDisclosure({
summary,
children,

View file

@ -8,6 +8,7 @@ import { StageSidebar, statusConfig } from "../components/stage-sidebar";
import type { Stage } from "../components/stage-sidebar";
import { EmptyState } from "../components/state";
import { apiJson, apiJsonOrNull } from "../api";
import { CopyButton } from "../components/ui";
import { isVisibleStage } from "../data/runs";
import { formatDurationSecs } from "../lib/format";
import type { PaginatedRunStageList, StageTurn as ApiStageTurn, PaginatedStageTurnList, PaginatedEventList } from "@qltysh/fabro-api-client";
@ -177,10 +178,13 @@ function Markdown({ content }: { content: string }) {
function SystemBlock({ content }: { content: string }) {
return (
<section className="border-l-2 border-amber/50 pl-4">
<section className="group relative border-l-2 border-amber/50 pl-4">
<header className="mb-1.5 flex items-center gap-2">
<CommandLineIcon className="size-4 shrink-0 text-amber" />
<span className="text-xs font-medium text-fg-3">System prompt</span>
<div className="ml-auto opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
<CopyButton value={content} label="Copy system prompt" />
</div>
</header>
<Markdown content={content} />
</section>
@ -189,10 +193,13 @@ function SystemBlock({ content }: { content: string }) {
function AssistantBlock({ content }: { content: string }) {
return (
<section className="border-l-2 border-teal-500/50 pl-4">
<section className="group relative border-l-2 border-teal-500/50 pl-4">
<header className="mb-1.5 flex items-center gap-2">
<ChatBubbleLeftIcon className="size-4 shrink-0 text-teal-500" />
<span className="text-xs font-medium text-fg-3">Assistant</span>
<div className="ml-auto opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
<CopyButton value={content} label="Copy assistant message" />
</div>
</header>
<Markdown content={content} />
</section>
@ -219,21 +226,68 @@ function StatusPill({
);
}
const COLLAPSE_AFTER_LINES = 20;
function StreamLabel({ label }: { label: string }) {
return (
<div className="mb-1 font-mono text-[11px] uppercase tracking-wider text-fg-muted">
<div className="font-mono text-[11px] uppercase tracking-wider text-fg-muted">
{label}
</div>
);
}
function OutputStream({
label,
content,
tone = "normal",
}: {
label: string;
content: string;
tone?: "normal" | "error";
}) {
const lines = content.split("\n");
const isLong = lines.length > COLLAPSE_AFTER_LINES;
const [expanded, setExpanded] = useState(false);
const visible = isLong && !expanded
? lines.slice(-COLLAPSE_AFTER_LINES).join("\n")
: content;
const hiddenLines = isLong && !expanded ? lines.length - COLLAPSE_AFTER_LINES : 0;
const preClass =
tone === "error"
? "whitespace-pre-wrap font-mono text-sm leading-relaxed text-coral sm:text-xs"
: "whitespace-pre-wrap font-mono text-sm leading-relaxed text-fg-3 sm:text-xs";
return (
<div>
<div className="mb-1 flex items-center gap-2">
<StreamLabel label={label} />
<CopyButton
value={content}
label={`Copy ${label}`}
className="-my-1"
/>
</div>
{isLong && !expanded ? (
<button
type="button"
onClick={() => setExpanded(true)}
className="mb-2 text-[11px] font-medium text-teal-500 hover:text-teal-300 focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-teal-500 rounded"
>
Show {hiddenLines} earlier lines
</button>
) : null}
<pre className={preClass}>{visible}</pre>
</div>
);
}
function CommandBlock({ turn }: { turn: Extract<TurnType, { kind: "command" }> }) {
const failed = !turn.running && turn.exitCode !== 0;
const borderColor = turn.running ? "border-teal-500/20" : failed ? "border-coral/15" : "border-mint/15";
const bgColor = turn.running ? "bg-teal-500/5" : failed ? "bg-coral/5" : "bg-mint/5";
return (
<div className={`rounded-md border ${borderColor} ${bgColor} overflow-hidden`}>
<div className={`group rounded-md border ${borderColor} ${bgColor} overflow-hidden`}>
{/* Header */}
<div className="flex items-center gap-2 px-3 py-2">
<PlayIcon className={`size-4 shrink-0 ${turn.running ? "text-teal-500 animate-pulse" : failed ? "text-coral" : "text-mint"}`} />
@ -259,6 +313,11 @@ function CommandBlock({ turn }: { turn: Extract<TurnType, { kind: "command" }> }
)}
</>
)}
{turn.script ? (
<div className="opacity-0 transition-opacity group-hover:opacity-100 focus-within:opacity-100">
<CopyButton value={turn.script} label="Copy script" />
</div>
) : null}
</div>
</div>
@ -272,16 +331,14 @@ function CommandBlock({ turn }: { turn: Extract<TurnType, { kind: "command" }> }
{/* stdout */}
{turn.stdout && (
<div className="border-t border-line px-3 py-2.5">
<StreamLabel label="stdout" />
<pre className="whitespace-pre-wrap font-mono text-sm leading-relaxed text-fg-3 sm:text-xs">{turn.stdout}</pre>
<OutputStream label="stdout" content={turn.stdout} />
</div>
)}
{/* stderr */}
{turn.stderr && (
<div className="border-t border-line px-3 py-2.5">
<StreamLabel label="stderr" />
<pre className="whitespace-pre-wrap font-mono text-sm leading-relaxed text-coral sm:text-xs">{turn.stderr}</pre>
<OutputStream label="stderr" content={turn.stderr} tone="error" />
</div>
)}
</div>
@ -335,7 +392,7 @@ export default function RunStages({ loaderData }: any) {
<StageSidebar stages={stages} runId={id!} selectedStageId={selectedStage.id} />
<div className="min-w-0 flex-1 space-y-3">
<div className="flex items-center gap-2">
<div className="sticky top-0 z-10 -mx-2 flex items-center gap-2 bg-page/85 px-2 py-2 backdrop-blur">
<SelectedIcon className={`size-5 ${selectedConfig.color} ${isRunning ? "animate-spin" : ""}`} />
<h3 className="text-base font-semibold text-fg">{selectedStage.name}</h3>
<span className="font-mono text-xs tabular-nums text-fg-muted">{headerDuration}</span>

File diff suppressed because one or more lines are too long

View file

@ -61,7 +61,7 @@
<script type="module" src="/assets/chunk-sadshphz.js"></script>
<script type="module" src="/assets/chunk-pmthkscp.js"></script>
<script type="module" src="/assets/chunk-v61ks9f7.js"></script>
<script type="module" src="/assets/entry-gv0e8m65.js"></script>
<script type="module" src="/assets/entry-nbrqa8t3.js"></script>
<script type="module" src="/assets/chunk-n1k68xa8.js"></script>
<script type="module" src="/assets/chunk-rsph5pvm.js"></script>
<script type="module" src="/assets/chunk-9t57pdty.js"></script>