mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-07 03:00:29 +00:00
feat(web): add Overview summary panel and promote PR to header pill (#299)
## Summary Promotes the most useful per-run state into the Overview tab so users no longer have to tab-hop to read the basics of a run. - **Adds a horizontal summary panel** above the workflow graph (right column only — does not span the stages sidebar) with five columns: **Created by · Changes · Sandbox · Cost · Artifacts**. Quiet-uppercase labels (`text-[10px] uppercase tracking-[0.08em] text-fg-muted`) over regular-weight values. Skeleton loaders while queries are in flight; em dash in muted color for missing/zero data. - **Promotes the PR chip** out of the meta strip into a `SECONDARY_BUTTON_CLASS`-style pill next to the Actions menu, visible on every tab. Pill renders only when a PR exists. - Lifts `formatBytesAsMemory`, `formatCpuCores`, `formatUsdMicros` to `lib/format.ts` so the panel can reuse them. - New `RunSummaryPanel` is split into a smart wrapper (owns the SWR hooks) + a presentational `RunSummaryPanelView` (prop-driven) for clean test seams. - All 7 `Principal` kinds (user / agent / system / slack / webhook / worker / anonymous) map to glyph + label; user kind uses login-initial avatar. ## Screenshots Captured against a real local Fabro server (`fabro server start`) on demo runs — these only exercise the Created-by column (the other cells display em dashes because the demo runs have no PR / diff / billing / artifacts data). The em-dash states **are** the intended empty-state design. ### Overview tab — full page  ### Header + tabs + summary panel close-up  ### Summary panel detail  > The PR pill (mint icon + `#number` next to Actions) is unverified visually because no demo run on this server has an associated PR — but the rendering path is the same `SECONDARY_BUTTON_CLASS` markup as the Actions button and is conditioned on `run.pullRequestUrl && run.number != null`. See the [HTML prototype](https://github.com/fabro-sh/fabro/blob/feat/run-overview-summary-panel/.context/run-overview-options.html) for the locked design. ## Test plan - [x] `cd apps/fabro-web && bun run typecheck` clean (only pre-existing assistant-ui errors) - [x] `bun test` — +12 new passes, no new failures (387 pass / 5 fail / 2 errors vs baseline 375 / 6 / 3) - [x] Manual: load `/runs/<id>` against a real server, confirm panel + em dashes render correctly - [ ] Manual on a run **with** a PR: verify the pill appears next to Actions and opens the PR in a new tab - [ ] Manual on a run **with** rich data (diff / billing / sandbox resources / artifacts): verify each column populates correctly 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
73ebb7d28b
commit
581ab41d28
9 changed files with 441 additions and 39 deletions
164
apps/fabro-web/app/components/run-summary-panel.test.tsx
Normal file
164
apps/fabro-web/app/components/run-summary-panel.test.tsx
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
import { describe, expect, test } from "bun:test";
|
||||
import TestRenderer, { act } from "react-test-renderer";
|
||||
|
||||
import {
|
||||
RunSummaryPanelView,
|
||||
type RunSummaryPanelViewProps,
|
||||
} from "./run-summary-panel";
|
||||
|
||||
function instanceText(instance: TestRenderer.ReactTestInstance): string {
|
||||
const parts: string[] = [];
|
||||
for (const child of instance.children) {
|
||||
if (typeof child === "string") parts.push(child);
|
||||
else parts.push(instanceText(child));
|
||||
}
|
||||
return parts.join("");
|
||||
}
|
||||
|
||||
function render(props: Partial<RunSummaryPanelViewProps> = {}) {
|
||||
const full: RunSummaryPanelViewProps = {
|
||||
run: null,
|
||||
runLoading: false,
|
||||
sandboxResources: null,
|
||||
sandboxLoading: false,
|
||||
artifactsCount: null,
|
||||
artifactsLoading: false,
|
||||
...props,
|
||||
};
|
||||
let tree: TestRenderer.ReactTestRenderer | undefined;
|
||||
act(() => {
|
||||
tree = TestRenderer.create(<RunSummaryPanelView {...full} />);
|
||||
});
|
||||
return tree!;
|
||||
}
|
||||
|
||||
function cellAfterLabel(
|
||||
tree: TestRenderer.ReactTestRenderer,
|
||||
label: string,
|
||||
): TestRenderer.ReactTestInstance {
|
||||
const labelNode = tree.root.find(
|
||||
(node) =>
|
||||
node.type === "div" &&
|
||||
node.children.length === 1 &&
|
||||
typeof node.children[0] === "string" &&
|
||||
node.children[0] === label,
|
||||
);
|
||||
const parent = labelNode.parent;
|
||||
if (!parent) throw new Error(`Could not find parent of label "${label}"`);
|
||||
return parent.children[1] as TestRenderer.ReactTestInstance;
|
||||
}
|
||||
|
||||
function makeRun(overrides: Record<string, any> = {}) {
|
||||
return {
|
||||
id: "run_1",
|
||||
created_by: null,
|
||||
diff: null,
|
||||
billing: null,
|
||||
...overrides,
|
||||
} as any;
|
||||
}
|
||||
|
||||
const EM_DASH = "—";
|
||||
|
||||
describe("RunSummaryPanelView", () => {
|
||||
test("renders all five column labels", () => {
|
||||
const tree = render();
|
||||
const rendered = JSON.stringify(tree.toJSON());
|
||||
for (const label of ["Created by", "Changes", "Sandbox", "Cost", "Artifacts"]) {
|
||||
expect(rendered).toContain(label);
|
||||
}
|
||||
});
|
||||
|
||||
test("shows em dash for missing run fields after load", () => {
|
||||
const tree = render({ run: makeRun() });
|
||||
expect(instanceText(cellAfterLabel(tree, "Created by"))).toBe(EM_DASH);
|
||||
expect(instanceText(cellAfterLabel(tree, "Changes"))).toBe(EM_DASH);
|
||||
expect(instanceText(cellAfterLabel(tree, "Cost"))).toBe(EM_DASH);
|
||||
});
|
||||
|
||||
test("shows em dash when sandbox is absent", () => {
|
||||
const tree = render({ run: makeRun(), sandboxResources: null });
|
||||
expect(instanceText(cellAfterLabel(tree, "Sandbox"))).toBe(EM_DASH);
|
||||
});
|
||||
|
||||
test("shows em dash when artifacts count is zero", () => {
|
||||
const tree = render({ run: makeRun(), artifactsCount: 0 });
|
||||
expect(instanceText(cellAfterLabel(tree, "Artifacts"))).toBe(EM_DASH);
|
||||
});
|
||||
|
||||
test("renders diff additions/deletions/files with correct formatting", () => {
|
||||
const tree = render({
|
||||
run: makeRun({
|
||||
diff: { additions: 124, deletions: 37, files_changed: 7 },
|
||||
}),
|
||||
});
|
||||
expect(instanceText(cellAfterLabel(tree, "Changes"))).toBe(
|
||||
"+124 −37in 7 files",
|
||||
);
|
||||
});
|
||||
|
||||
test("singular 'file' when files_changed is 1", () => {
|
||||
const tree = render({
|
||||
run: makeRun({
|
||||
diff: { additions: 3, deletions: 0, files_changed: 1 },
|
||||
}),
|
||||
});
|
||||
expect(instanceText(cellAfterLabel(tree, "Changes"))).toBe(
|
||||
"+3 −0in 1 file",
|
||||
);
|
||||
});
|
||||
|
||||
test("renders cost from total_usd_micros", () => {
|
||||
const tree = render({
|
||||
run: makeRun({ billing: { total_usd_micros: 840_000 } }),
|
||||
});
|
||||
expect(instanceText(cellAfterLabel(tree, "Cost"))).toBe("$0.84");
|
||||
});
|
||||
|
||||
test("renders sandbox CPU and memory", () => {
|
||||
const tree = render({
|
||||
run: makeRun(),
|
||||
sandboxResources: { cpu_cores: 4, memory_bytes: 8 * 1024 * 1024 * 1024 } as any,
|
||||
});
|
||||
expect(instanceText(cellAfterLabel(tree, "Sandbox"))).toBe("4 CPU · 8 GiB");
|
||||
});
|
||||
|
||||
test("renders artifacts count when positive", () => {
|
||||
const tree = render({ run: makeRun(), artifactsCount: 3 });
|
||||
expect(instanceText(cellAfterLabel(tree, "Artifacts"))).toBe("3");
|
||||
});
|
||||
|
||||
test("renders user actor with login initial", () => {
|
||||
const tree = render({
|
||||
run: makeRun({
|
||||
created_by: {
|
||||
kind: "user",
|
||||
identity: { issuer: "github", subject: "1" },
|
||||
login: "brynary",
|
||||
auth_method: "oauth",
|
||||
},
|
||||
}),
|
||||
});
|
||||
expect(instanceText(cellAfterLabel(tree, "Created by"))).toBe("Bbrynary");
|
||||
});
|
||||
|
||||
test("renders non-user actor with kind label", () => {
|
||||
for (const kind of ["agent", "system", "slack", "webhook", "worker", "anonymous"]) {
|
||||
const tree = render({ run: makeRun({ created_by: { kind } as any }) });
|
||||
expect(instanceText(cellAfterLabel(tree, "Created by"))).toContain(kind);
|
||||
}
|
||||
});
|
||||
|
||||
test("shows skeleton placeholders while queries are loading", () => {
|
||||
const tree = render({
|
||||
runLoading: true,
|
||||
sandboxLoading: true,
|
||||
artifactsLoading: true,
|
||||
});
|
||||
const rendered = JSON.stringify(tree.toJSON());
|
||||
expect(rendered).toContain("animate-pulse");
|
||||
expect(instanceText(cellAfterLabel(tree, "Created by"))).not.toContain(EM_DASH);
|
||||
expect(instanceText(cellAfterLabel(tree, "Sandbox"))).not.toContain(EM_DASH);
|
||||
expect(instanceText(cellAfterLabel(tree, "Artifacts"))).not.toContain(EM_DASH);
|
||||
});
|
||||
});
|
||||
204
apps/fabro-web/app/components/run-summary-panel.tsx
Normal file
204
apps/fabro-web/app/components/run-summary-panel.tsx
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import type { ReactNode } from "react";
|
||||
import {
|
||||
BoltIcon,
|
||||
ChatBubbleLeftEllipsisIcon,
|
||||
Cog6ToothIcon,
|
||||
CpuChipIcon,
|
||||
QuestionMarkCircleIcon,
|
||||
ServerIcon,
|
||||
} from "@heroicons/react/20/solid";
|
||||
import type { Principal, Run, SandboxResources } from "@qltysh/fabro-api-client";
|
||||
|
||||
import {
|
||||
formatBytesAsMemory,
|
||||
formatCpuCores,
|
||||
formatUsdMicros,
|
||||
} from "../lib/format";
|
||||
import { useRun, useRunArtifacts, useRunSandboxDetails } from "../lib/queries";
|
||||
|
||||
const LABEL_CLASS =
|
||||
"text-[10px] font-medium uppercase tracking-[0.08em] text-fg-muted";
|
||||
const VALUE_WRAPPER_CLASS = "mt-1.5";
|
||||
const VALUE_CLASS = "text-sm text-fg";
|
||||
const VALUE_MONO_CLASS = "text-sm text-fg font-mono tabular-nums";
|
||||
const EM_DASH_CLASS = "text-sm text-fg-muted font-mono";
|
||||
|
||||
function EmDash() {
|
||||
return <span className={EM_DASH_CLASS}>—</span>;
|
||||
}
|
||||
|
||||
function Skeleton({ widthClass }: { widthClass: string }) {
|
||||
return (
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={`h-4 ${widthClass} animate-pulse rounded bg-overlay`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Cell({ label, children }: { label: string; children: ReactNode }) {
|
||||
return (
|
||||
<div>
|
||||
<div className={LABEL_CLASS}>{label}</div>
|
||||
<div className={VALUE_WRAPPER_CLASS}>{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface CreatedByDisplay {
|
||||
glyph: ReactNode;
|
||||
label: string;
|
||||
}
|
||||
|
||||
function principalGlyph(icon: ReactNode) {
|
||||
return (
|
||||
<span className="grid size-5 place-items-center rounded-full bg-teal-500/20 text-teal-500">
|
||||
{icon}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function createdByDisplay(actor: Principal): CreatedByDisplay {
|
||||
switch (actor.kind) {
|
||||
case "user": {
|
||||
const initial = actor.login.charAt(0).toUpperCase() || "?";
|
||||
return {
|
||||
glyph: (
|
||||
<span className="grid size-5 place-items-center rounded-full bg-teal-500/20 font-mono text-[10px] font-medium text-teal-500">
|
||||
{initial}
|
||||
</span>
|
||||
),
|
||||
label: actor.login,
|
||||
};
|
||||
}
|
||||
case "agent":
|
||||
return { glyph: principalGlyph(<CpuChipIcon className="size-3" />), label: "agent" };
|
||||
case "system":
|
||||
return { glyph: principalGlyph(<Cog6ToothIcon className="size-3" />), label: "system" };
|
||||
case "slack":
|
||||
return {
|
||||
glyph: principalGlyph(<ChatBubbleLeftEllipsisIcon className="size-3" />),
|
||||
label: "slack",
|
||||
};
|
||||
case "webhook":
|
||||
return { glyph: principalGlyph(<BoltIcon className="size-3" />), label: "webhook" };
|
||||
case "worker":
|
||||
return { glyph: principalGlyph(<ServerIcon className="size-3" />), label: "worker" };
|
||||
case "anonymous":
|
||||
return {
|
||||
glyph: principalGlyph(<QuestionMarkCircleIcon className="size-3" />),
|
||||
label: "anonymous",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface RunSummaryPanelViewProps {
|
||||
run: Run | null;
|
||||
runLoading: boolean;
|
||||
sandboxResources: SandboxResources | null;
|
||||
sandboxLoading: boolean;
|
||||
artifactsCount: number | null;
|
||||
artifactsLoading: boolean;
|
||||
}
|
||||
|
||||
export function RunSummaryPanelView({
|
||||
run,
|
||||
runLoading,
|
||||
sandboxResources,
|
||||
sandboxLoading,
|
||||
artifactsCount,
|
||||
artifactsLoading,
|
||||
}: RunSummaryPanelViewProps) {
|
||||
const created = run?.created_by ? createdByDisplay(run.created_by) : null;
|
||||
const diff = run?.diff ?? null;
|
||||
const cost = formatUsdMicros(run?.billing?.total_usd_micros);
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-line bg-panel/60 px-6 py-4">
|
||||
<div className="flex flex-wrap items-baseline gap-x-14 gap-y-3">
|
||||
<Cell label="Created by">
|
||||
{runLoading ? (
|
||||
<Skeleton widthClass="w-20" />
|
||||
) : created ? (
|
||||
<div className="flex items-center gap-2">
|
||||
{created.glyph}
|
||||
<span className={VALUE_CLASS}>{created.label}</span>
|
||||
</div>
|
||||
) : (
|
||||
<EmDash />
|
||||
)}
|
||||
</Cell>
|
||||
|
||||
<Cell label="Changes">
|
||||
{runLoading ? (
|
||||
<Skeleton widthClass="w-32" />
|
||||
) : diff ? (
|
||||
<div className="flex items-baseline gap-2 text-sm">
|
||||
<span className="font-mono tabular-nums">
|
||||
<span className="text-mint">+{diff.additions}</span>{" "}
|
||||
<span className="text-coral">−{diff.deletions}</span>
|
||||
</span>
|
||||
<span className="text-fg-3">
|
||||
in {diff.files_changed} {diff.files_changed === 1 ? "file" : "files"}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<EmDash />
|
||||
)}
|
||||
</Cell>
|
||||
|
||||
<Cell label="Sandbox">
|
||||
{sandboxLoading ? (
|
||||
<Skeleton widthClass="w-24" />
|
||||
) : sandboxResources &&
|
||||
sandboxResources.cpu_cores != null &&
|
||||
sandboxResources.memory_bytes != null ? (
|
||||
<span className={VALUE_CLASS}>
|
||||
{formatCpuCores(sandboxResources.cpu_cores)} CPU ·{" "}
|
||||
{formatBytesAsMemory(sandboxResources.memory_bytes)}
|
||||
</span>
|
||||
) : (
|
||||
<EmDash />
|
||||
)}
|
||||
</Cell>
|
||||
|
||||
<Cell label="Cost">
|
||||
{runLoading ? (
|
||||
<Skeleton widthClass="w-12" />
|
||||
) : cost != null ? (
|
||||
<span className={VALUE_MONO_CLASS}>{cost}</span>
|
||||
) : (
|
||||
<EmDash />
|
||||
)}
|
||||
</Cell>
|
||||
|
||||
<Cell label="Artifacts">
|
||||
{artifactsLoading ? (
|
||||
<Skeleton widthClass="w-8" />
|
||||
) : artifactsCount != null && artifactsCount > 0 ? (
|
||||
<span className={VALUE_MONO_CLASS}>{artifactsCount}</span>
|
||||
) : (
|
||||
<EmDash />
|
||||
)}
|
||||
</Cell>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function RunSummaryPanel({ runId }: { runId: string }) {
|
||||
const runQuery = useRun(runId);
|
||||
const sandboxQuery = useRunSandboxDetails(runId);
|
||||
const artifactsQuery = useRunArtifacts(runId);
|
||||
|
||||
return (
|
||||
<RunSummaryPanelView
|
||||
run={runQuery.data ?? null}
|
||||
runLoading={runQuery.isLoading && !runQuery.data}
|
||||
sandboxResources={sandboxQuery.data?.resources ?? null}
|
||||
sandboxLoading={sandboxQuery.isLoading && !sandboxQuery.data}
|
||||
artifactsCount={artifactsQuery.data?.data.length ?? null}
|
||||
artifactsLoading={artifactsQuery.isLoading && !artifactsQuery.data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -100,3 +100,37 @@ export function formatTokenCount(
|
|||
if (value < 1_000_000) return `${Math.round(value / 1000)}k`;
|
||||
return `${Math.round(value / 1_000_000)}M`;
|
||||
}
|
||||
|
||||
const BYTES_PER_GIB = 1024 * 1024 * 1024;
|
||||
const BYTES_PER_MIB = 1024 * 1024;
|
||||
|
||||
/**
|
||||
* Format a byte count as a memory/disk size (e.g. "8 GiB", "512 MiB", "1024 B").
|
||||
*/
|
||||
export function formatBytesAsMemory(bytes: number): string {
|
||||
if (bytes >= BYTES_PER_GIB) {
|
||||
const gib = bytes / BYTES_PER_GIB;
|
||||
return `${Number.isInteger(gib) ? gib : gib.toFixed(1)} GiB`;
|
||||
}
|
||||
if (bytes >= BYTES_PER_MIB) {
|
||||
const mib = bytes / BYTES_PER_MIB;
|
||||
return `${Number.isInteger(mib) ? mib : mib.toFixed(1)} MiB`;
|
||||
}
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a CPU-core count for display (whole cores as integer; fractional as 2-decimal).
|
||||
*/
|
||||
export function formatCpuCores(cores: number): string {
|
||||
return Number.isInteger(cores) ? cores.toString() : cores.toFixed(2);
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a USD-micros amount as a dollar string ("$1.23"). Returns null when the
|
||||
* input is null/undefined so callers can render their own empty placeholder.
|
||||
*/
|
||||
export function formatUsdMicros(usdMicros: number | null | undefined): string | null {
|
||||
if (usdMicros == null) return null;
|
||||
return `$${(usdMicros / 1_000_000).toFixed(2)}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,11 @@
|
|||
import { useMemo } from "react";
|
||||
|
||||
import { EmptyState } from "../components/state";
|
||||
import { formatDurationSecs, formatTokenCount } from "../lib/format";
|
||||
import {
|
||||
formatDurationSecs,
|
||||
formatTokenCount,
|
||||
formatUsdMicros,
|
||||
} from "../lib/format";
|
||||
import { useRunBilling } from "../lib/queries";
|
||||
import { IN_FLIGHT_STAGE_STATES } from "../lib/stage-sidebar";
|
||||
import { useTickingNow } from "../lib/time";
|
||||
|
|
@ -18,8 +22,8 @@ function formatTokens(n: number | null | undefined) {
|
|||
return formatTokenCount(n, { compactDecimal: true });
|
||||
}
|
||||
|
||||
function formatUsdMicros(usdMicros?: number | null) {
|
||||
return usdMicros == null ? EMPTY_VALUE : `$${(usdMicros / 1_000_000).toFixed(2)}`;
|
||||
function formatUsdMicrosOrDash(usdMicros?: number | null): string {
|
||||
return formatUsdMicros(usdMicros) ?? EMPTY_VALUE;
|
||||
}
|
||||
|
||||
function formatModelRef(model?: BillingModelRef | null): string | null {
|
||||
|
|
@ -176,7 +180,7 @@ export default function RunBilling({ params }: { params: { id: string } }) {
|
|||
{formatDurationSecs(row.runtimeSecs)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs text-fg-3">
|
||||
{formatUsdMicros(row.totalUsdMicros)}
|
||||
{formatUsdMicrosOrDash(row.totalUsdMicros)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
|
@ -193,7 +197,7 @@ export default function RunBilling({ params }: { params: { id: string } }) {
|
|||
{formatDurationSecs(totalRuntimeSecs)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs font-medium text-fg">
|
||||
{formatUsdMicros(totalUsdMicros)}
|
||||
{formatUsdMicrosOrDash(totalUsdMicros)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
|
|
@ -225,7 +229,7 @@ export default function RunBilling({ params }: { params: { id: string } }) {
|
|||
{formatTokens(row.outputTokens)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs text-fg-3">
|
||||
{formatUsdMicros(row.totalUsdMicros)}
|
||||
{formatUsdMicrosOrDash(row.totalUsdMicros)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
|
@ -241,7 +245,7 @@ export default function RunBilling({ params }: { params: { id: string } }) {
|
|||
{formatTokens(totalOutput)}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-right font-mono text-xs font-medium text-fg">
|
||||
{formatUsdMicros(totalUsdMicros)}
|
||||
{formatUsdMicrosOrDash(totalUsdMicros)}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
|
|
|
|||
|
|
@ -445,7 +445,7 @@ describe("RunDetail full-height child routes", () => {
|
|||
expect(tabCountBadges(renderer)).toHaveLength(0);
|
||||
});
|
||||
|
||||
test("shows a linked pull request chip in the run header", async () => {
|
||||
test("shows a linked pull request pill in the run header", async () => {
|
||||
const renderer = await renderRunDetail({
|
||||
initialEntry: "/runs/run_1",
|
||||
pullRequest: {
|
||||
|
|
@ -464,7 +464,10 @@ describe("RunDetail full-height child routes", () => {
|
|||
|
||||
expect(links).toHaveLength(1);
|
||||
expect(links[0].props.target).toBe("_blank");
|
||||
expect(links[0].children.filter((child) => typeof child !== "object").join("")).toBe("#123");
|
||||
const numberSpan = links[0].findByType("span");
|
||||
expect(
|
||||
numberSpan.children.filter((child) => typeof child !== "object").join(""),
|
||||
).toBe("#123");
|
||||
});
|
||||
|
||||
test("keeps blocked full-height children clear of the interview dock without an h-72 sibling", async () => {
|
||||
|
|
|
|||
|
|
@ -17,8 +17,8 @@ import { Link, Outlet, useLocation, useMatches, useNavigate } from "react-router
|
|||
import { Menu, MenuButton, MenuItem, MenuItems } from "@headlessui/react";
|
||||
|
||||
import { EditableRunTitle } from "../components/editable-run-title";
|
||||
import { GitPullRequestIcon } from "../components/icons";
|
||||
import { InterviewDock } from "../components/interview-dock";
|
||||
import { PullRequestChip } from "../components/pull-request-chip";
|
||||
import { SteerBar, type SteerBarHandle } from "../components/steer-bar";
|
||||
import { ErrorState } from "../components/state";
|
||||
import { useToast } from "../components/toast";
|
||||
|
|
@ -341,18 +341,23 @@ export default function RunDetail({ params }: { params: { id: string } }) {
|
|||
</span>
|
||||
</Tooltip>
|
||||
)}
|
||||
{run.pullRequestUrl && run.number != null && (
|
||||
<PullRequestChip
|
||||
number={run.number}
|
||||
url={run.pullRequestUrl}
|
||||
iconClassName="size-3.5"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{demoMode && <ConnectMenu />}
|
||||
|
||||
{run.pullRequestUrl && run.number != null && (
|
||||
<a
|
||||
href={run.pullRequestUrl}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className={SECONDARY_BUTTON_CLASS}
|
||||
>
|
||||
<GitPullRequestIcon className="size-4 text-mint" />
|
||||
<span className="font-mono">#{run.number}</span>
|
||||
</a>
|
||||
)}
|
||||
|
||||
<ActionsMenu
|
||||
canSendInterrupt={statusKind === "running"}
|
||||
interruptPending={interruptMutation.isMutating}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||
import { useNavigate, useParams } from "react-router";
|
||||
import { graphTheme } from "../lib/graph-theme";
|
||||
import { useRun, useRunGraph, useRunStages } from "../lib/queries";
|
||||
import { RunSummaryPanel } from "../components/run-summary-panel";
|
||||
import { StageSidebar } from "../components/stage-sidebar";
|
||||
import {
|
||||
GRAPH_DEFAULT_ZOOM_INDEX,
|
||||
|
|
@ -192,7 +193,8 @@ export default function RunOverview() {
|
|||
<div className="flex gap-6">
|
||||
<StageSidebar stages={stages} runId={id!} />
|
||||
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="min-w-0 flex-1 space-y-4">
|
||||
<RunSummaryPanel runId={id!} />
|
||||
{graphSvg === undefined && graphQuery.isLoading ? (
|
||||
<div className="py-12" />
|
||||
) : graphSvg ? (
|
||||
|
|
|
|||
|
|
@ -80,8 +80,9 @@ mock.module("@pierre/diffs/react", () => ({
|
|||
),
|
||||
}));
|
||||
|
||||
const { default: RunSandbox, formatBytesAsMemory, normalizeSandboxMode } =
|
||||
const { default: RunSandbox, normalizeSandboxMode } =
|
||||
await import("./run-sandbox");
|
||||
const { formatBytesAsMemory } = await import("../lib/format");
|
||||
mock.restore();
|
||||
|
||||
const mountedRenderers: TestRenderer.ReactTestRenderer[] = [];
|
||||
|
|
|
|||
|
|
@ -4,7 +4,11 @@ import { ArrowTopRightOnSquareIcon } from "@heroicons/react/20/solid";
|
|||
|
||||
import TerminalView, { TERMINAL_DOCK_CLEARANCE_CLASS } from "../components/terminal-view";
|
||||
import { EmptyState, ErrorState } from "../components/state";
|
||||
import { formatAbsoluteTs } from "../lib/format";
|
||||
import {
|
||||
formatAbsoluteTs,
|
||||
formatBytesAsMemory,
|
||||
formatCpuCores,
|
||||
} from "../lib/format";
|
||||
import { useRunSandboxDetails } from "../lib/queries";
|
||||
import type {
|
||||
SandboxDetails,
|
||||
|
|
@ -52,25 +56,6 @@ const STATE_DISPLAY: Record<SandboxState, { label: string; dot: string; text: st
|
|||
error: { label: "Error", dot: "bg-coral", text: "text-coral" },
|
||||
};
|
||||
|
||||
const BYTES_PER_GIB = 1024 * 1024 * 1024;
|
||||
const BYTES_PER_MIB = 1024 * 1024;
|
||||
|
||||
export function formatBytesAsMemory(bytes: number): string {
|
||||
if (bytes >= BYTES_PER_GIB) {
|
||||
const gib = bytes / BYTES_PER_GIB;
|
||||
return `${Number.isInteger(gib) ? gib : gib.toFixed(1)} GiB`;
|
||||
}
|
||||
if (bytes >= BYTES_PER_MIB) {
|
||||
const mib = bytes / BYTES_PER_MIB;
|
||||
return `${Number.isInteger(mib) ? mib : mib.toFixed(1)} MiB`;
|
||||
}
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
function formatCpuCores(cores: number): string {
|
||||
return Number.isInteger(cores) ? cores.toString() : cores.toFixed(2);
|
||||
}
|
||||
|
||||
function nullable(value: string | null | undefined): string {
|
||||
return value && value.length > 0 ? value : EMPTY_VALUE;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue