mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-08 03:10:26 +00:00
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:
parent
54867d29ed
commit
9b5bc8ede4
7 changed files with 247 additions and 178 deletions
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
2
lib/crates/fabro-spa/assets/assets/app.css
generated
2
lib/crates/fabro-spa/assets/assets/app.css
generated
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
2
lib/crates/fabro-spa/assets/index.html
generated
2
lib/crates/fabro-spa/assets/index.html
generated
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue