Merge pull request #1076 from jsom/failure-banner

Say why a failed run stopped, above its tabs
This commit is contained in:
Scott Werner 2026-10-27 18:00:58 -04:00 • committed by GitHub
commit 71abd4658f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 583 additions and 9 deletions

View file

@ -1,4 +1,4 @@
import type { Model } from "@qltysh/fabro-api-client";
import type { Model, Provider } from "@qltysh/fabro-api-client";
type ModelOfferingIdentity = Pick<Model, "provider" | "id">;
@ -6,6 +6,13 @@ export function modelOfferingKey(model: ModelOfferingIdentity): string {
return `${model.provider}\u0000${model.id}`;
}
/** Provider ids to the names the catalog displays, falling back to the id. */
export function providerNamesById(
providers: readonly Pick<Provider, "id" | "display_name">[],
): Map<string, string> {
return new Map(providers.map((p) => [p.id, p.display_name || p.id]));
}
export function modelOfferingTestArgs(
model: ModelOfferingIdentity,
): [id: string, provider: string] {

View file

@ -534,9 +534,10 @@ export function useServerSettings() {
);
}
export function useProviders() {
/** The provider catalog; `enabled: false` skips the request. */
export function useProviders({ enabled = true }: { enabled?: boolean } = {}) {
return useSWR<ProviderList>(
queryKeys.providers.list(),
enabled ? queryKeys.providers.list() : null,
() => apiData(() => modelsApi.listProviders()),
immutableOptions,
);

View file

@ -86,6 +86,7 @@ mock.module("../lib/queries", () => ({
isValidating: false,
mutate: mock(() => Promise.resolve(null)),
}),
useProviders: () => ({ data: null }),
}));
mock.module("../lib/run-events", () => ({

View file

@ -1,5 +1,6 @@
import {
useCallback,
useMemo,
useRef,
useState,
type CSSProperties,
@ -34,7 +35,8 @@ import {
} from "../lib/mutations";
import { useRunEvents } from "../lib/run-events";
import { useRunToasts } from "../hooks/use-run-toasts";
import { useRun, useRunQuestions, useRunState } from "../lib/queries";
import { useProviders, useRun, useRunQuestions, useRunState } from "../lib/queries";
import { providerNamesById } from "../lib/model-offerings";
import {
canApprove,
deleteErrorMessage,
@ -50,6 +52,8 @@ import {
RunDetailAskFabroShell,
RunDetailDockedControls,
} from "./run-detail/docked-controls";
import { buildRunFailureView } from "./run-detail/failure";
import { RunFailureBanner } from "./run-detail/failure-banner";
import { RunDetailHeader } from "./run-detail/header";
import {
createLifecycleToastState,
@ -148,6 +152,16 @@ export default function RunDetail({ params }: { params: { id: string } }) {
useRunEvents(params.id);
useRunToasts(params.id);
// Provider names only matter to a failed run's banner.
const providersQuery = useProviders({ enabled: statusKind === "failed" || statusKind === "dead" });
const providerNames = useMemo(
() => providerNamesById(providersQuery.data?.data ?? []),
[providersQuery.data],
);
const failure = buildRunFailureView(runStateQuery.data, {
providerNames,
lifecycleError: runQuery.data?.lifecycle.error?.message,
});
const handleLifecycleMutationResult = useCallback(
(
@ -382,6 +396,8 @@ export default function RunDetail({ params }: { params: { id: string } }) {
}}
/>
{failure ? <RunFailureBanner failure={failure} /> : null}
{retryDialogOpen && (
<RetryRunDialog
key={params.id}

View file

@ -0,0 +1,62 @@
import { ChevronDownIcon, ExclamationTriangleIcon } from "@heroicons/react/20/solid";
import type { RunFailureView } from "./failure";
export function RunFailureBanner({ failure }: { failure: RunFailureView }) {
return (
<section aria-label="Why this run failed" className="mb-6">
<details className="group rounded-lg bg-panel-alt outline-1 -outline-offset-1 outline-line">
<summary className="flex cursor-pointer list-none items-start gap-3 rounded-lg px-4 py-3.5 transition-colors hover:bg-overlay focus-visible:outline-2 focus-visible:-outline-offset-1 focus-visible:outline-teal-500 [&::-webkit-details-marker]:hidden">
<ExclamationTriangleIcon
className="mt-0.5 size-4 shrink-0 text-coral"
aria-hidden="true"
/>
<span className="min-w-0 flex-1">
<span className="block text-sm/5 font-medium text-fg">
{failure.stage ? (
<>
Stopped at <span className="font-mono text-[0.8125rem]">{failure.stage}</span>
<span className="mx-2 text-fg-muted" aria-hidden="true">
·
</span>
</>
) : null}
{failure.headline}
</span>
<span className="mt-1 block text-sm/5 text-fg-3">{failure.nextStep}</span>
</span>
<span className="inline-flex shrink-0 items-center gap-1 pt-0.5 text-xs font-medium text-fg-muted transition-colors group-hover:text-fg-3">
<span className="group-open:hidden">Details</span>
<span className="hidden group-open:inline">Hide</span>
<ChevronDownIcon
className="size-3.5 shrink-0 transition-transform group-open:-rotate-180"
aria-hidden="true"
/>
</span>
</summary>
<dl className="space-y-3 px-4 pt-0.5 pb-4 pl-11">
<div>
<dt className="mb-1 font-mono text-[0.6875rem] tracking-wide text-fg-muted uppercase">
{failure.provider ? "Provider message" : "Message"}
</dt>
<dd className="max-h-48 overflow-auto rounded-md bg-overlay px-3 py-2 font-mono text-xs/5 wrap-break-word whitespace-pre-wrap text-fg-3">
{failure.message}
</dd>
</div>
{failure.output ? (
<div>
<dt className="mb-1 font-mono text-[0.6875rem] tracking-wide text-fg-muted uppercase">
Output
</dt>
<dd>
<pre className="max-h-48 overflow-auto rounded-md bg-overlay px-3 py-2 font-mono text-xs/5 text-fg-3">
{failure.output}
</pre>
</dd>
</div>
) : null}
</dl>
</details>
</section>
);
}

View file

@ -0,0 +1,218 @@
import { describe, expect, test } from "bun:test";
import type { RunProjection } from "@qltysh/fabro-api-client";
import { buildRunFailureView } from "./failure";
type State = Pick<RunProjection, "status" | "conclusion" | "stages">;
const NAMES = new Map([
["anthropic", "Anthropic"],
["openai", "OpenAI"],
]);
const CREDIT =
"model request failed (invalid_request): provider anthropic Your credit balance is too low to access the Anthropic API. Please go to Plans & Billing to upgrade or purchase credits. [provider anthropic, status 400, code invalid_request_error]";
function stages(...entries: Array<[string, string, string]>): State["stages"] {
return Object.fromEntries(
entries.map(([key, state, started_at]) => [key, { state, started_at } as State["stages"][string]]),
);
}
function failedRun(
detail: Record<string, unknown>,
{ reason = "workflow_error", kind = "failed", runStages = {} as State["stages"] } = {},
): State {
return {
status: (kind === "failed" ? { kind, reason } : { kind }) as State["status"],
conclusion: {
failure: { reason, detail: { category: "deterministic", ...detail } },
} as State["conclusion"],
stages: runStages,
};
}
const view = (state: State) => buildRunFailureView(state, { providerNames: NAMES });
describe("buildRunFailureView", () => {
test("names the step the run stopped at and the provider's real message", () => {
const failure = view(
failedRun({ message: CREDIT }, {
runStages: stages(["pick@1", "succeeded", "2026-10-10T18:05:42.783Z"], ["spec@1", "failed", "2026-10-10T18:41:53.513Z"]),
}),
);
expect(failure).toMatchObject({
stage: "spec",
headline: "Anthropic declined the request for billing reasons",
provider: "anthropic",
message: CREDIT,
});
expect(failure?.nextStep).toBe("Add credit or raise the spend limit with Anthropic, then choose Retry from Actions.");
});
test("reads a spent quota as billing, though the provider answered 429", () => {
const failure = view(
failedRun({
message: "model request failed (quota_exceeded): provider gemini Quota exceeded for metric generate_content_requests [provider gemini, status 429, code RESOURCE_EXHAUSTED]",
}),
);
expect(failure?.headline).toBe("gemini declined the request for billing reasons");
});
test("names an unknown provider by its id", () => {
const failure = view(
failedRun({ message: "model request failed (authentication): provider acme bad key [provider acme, status 401, code unauthorized]" }),
);
expect(failure?.headline).toBe("acme rejected the API key");
});
test("reads auth, rate limits, timeouts and outages from the request's own status and kind", () => {
const headline = (message: string) => view(failedRun({ message }))?.headline;
expect(headline("model request failed (authentication): provider anthropic invalid x-api-key [provider anthropic, status 401, code authentication_error]"))
.toBe("Anthropic rejected the API key");
expect(headline("model request failed (access_denied): provider openai The model is not available to your organization [provider openai, status 403, code model_not_found]"))
.toBe("OpenAI denied access to the request");
expect(headline("model request failed (authentication): provider acme forbidden [provider acme, status 403, code forbidden]"))
.toBe("acme rejected the API key");
expect(headline("model request failed (rate_limit): provider openai Too many requests [provider openai, status 429, code rate_limit_exceeded]"))
.toBe("OpenAI rate-limited the request");
expect(headline("model request failed (server): provider anthropic Overloaded [provider anthropic, status 529, code overloaded_error]"))
.toBe("Anthropic was unavailable");
expect(headline("model request failed (timeout): provider openai request timed out"))
.toBe("The request to OpenAI timed out");
});
test("doesn't blame the provider for failures on Fabro's side", () => {
const failure = (message: string) => view(failedRun({ message }));
expect(failure("model request failed (configuration): provider anthropic is disabled in the catalog")).toMatchObject({
headline: "Fabro couldn't send the request to Anthropic",
nextStep: "Check Anthropic and the step's model under Settings → Models, then choose Retry from Actions.",
});
expect(failure("model request failed (model_selection): provider anthropic has no default model")?.headline)
.toBe("Fabro couldn't send the request to Anthropic");
expect(failure("model request failed (network): provider openai connection refused")?.headline)
.toBe("The request never reached OpenAI");
expect(failure("model request failed (resource_limit): provider openai response exceeded 16 MiB")?.headline)
.toBe("Fabro stopped the request to OpenAI");
});
test("falls back to the provider's status and code", () => {
const failure = view(
failedRun({
message: "model request failed (invalid_request): provider openai Unsupported parameter: 'temperature' is not supported with this model. [provider openai, status 400, code unsupported_parameter]",
}),
);
expect(failure?.headline).toBe("OpenAI returned 400 unsupported parameter");
});
test("never reads a provider, status or billing problem out of a command's own words", () => {
const failure = view(
failedRun({
message: "script exited with status 401",
exec_output_tail: { stdout: "test billing::invoice ... FAILED\nretry the provider list later\n", stderr: null },
}),
);
expect(failure).toMatchObject({
headline: "The step's command exited with status 401",
provider: null,
nextStep: "Its output, under Details, usually says why.",
});
expect(view(failedRun({ message: "Ran 512 tests; line 503 failed." }))?.headline).toBe("Ran 512 tests; line 503 failed.");
});
test("names no step when the run recovered from an earlier failed one", () => {
const failure = view(
failedRun({ message: "push rejected: remote branch moved" }, {
reason: "publish_failed",
runStages: stages(
["toolchain@1", "failed", "2026-10-10T15:20:45Z"],
["fixup@1", "succeeded", "2026-10-10T15:21:00Z"],
["verify@1", "succeeded", "2026-10-10T15:25:00.500Z"],
),
}),
);
expect(failure?.stage).toBeNull();
expect(failure?.headline).toBe("The steps finished, but publishing the result failed");
});
test("labels a looped step with its visit, as the Stages sidebar does", () => {
const runStages = stages(["review@1", "succeeded", "2026-10-10T15:20:00Z"], ["review@3", "failed", "2026-10-10T15:30:00Z"]);
expect(view(failedRun({ message: "script exited with status 1" }, { runStages }))?.stage).toBe("review@3");
});
test("names the failed branch of a fan-out, though a sibling started after it", () => {
const runStages = stages(
["checks@1", "failed", "2026-10-10T15:20:00.000Z"],
["lint@1", "failed", "2026-10-10T15:20:00.010Z"],
["test@1", "cancelled", "2026-10-10T15:20:00.013Z"],
);
runStages["lint@1"]!.parallel_branch_id = "checks@1:0";
runStages["test@1"]!.parallel_branch_id = "checks@1:1";
expect(view(failedRun({ message: "script exited with status 1" }, { runStages }))?.stage).toBe("lint");
runStages["report@1"] = { state: "succeeded", started_at: "2026-10-10T15:21:00Z" } as State["stages"][string];
expect(view(failedRun({ message: "push rejected" }, { reason: "publish_failed", runStages }))?.stage).toBeNull();
});
test("shows nothing for a cancelled run, and says what happened for other endings", () => {
expect(view(failedRun({ message: "Run cancelled before worker launch completed" }, { reason: "cancelled" }))).toBeNull();
expect(view(failedRun({ message: "denied at review" }, { reason: "approval_denied" }))?.headline).toBe("Approval was denied");
expect(view(failedRun({ message: "budget of $5 reached" }, { reason: "budget_exhausted" }))?.headline).toBe("The run used up its budget");
expect(view(failedRun({ message: "sandbox image not found" }, { reason: "sandbox_init_failed" }))?.headline).toBe("The run couldn't start");
expect(view(failedRun({ message: "sandbox connection reset" }, { reason: "transient_infra" }))).toMatchObject({
headline: "The step hit a temporary infrastructure error",
nextStep: "Choose Retry from Actions in a few minutes.",
});
});
test("says why a dead run stopped, though a dead run records no conclusion", () => {
const dead = {
status: { kind: "dead" },
conclusion: null,
stages: stages(["build@1", "failed", "2026-10-10T18:05:42Z"]),
} as unknown as State;
expect(buildRunFailureView(dead, { lifecycleError: "worker exited unexpectedly" })).toMatchObject({
stage: "build",
headline: "Worker exited unexpectedly",
message: "worker exited unexpectedly",
});
expect(buildRunFailureView(dead)?.headline).toBe("The run's worker stopped before the run finished");
});
test("shows the end of a command's output, from whichever stream has it", () => {
const tail = (exec_output_tail: Record<string, unknown>) =>
view(failedRun({ message: "script exited with status 1", exec_output_tail }))?.output;
expect(tail({ stderr: "\n \n", stdout: "real error\n" })).toBe("real error");
expect(tail({ stderr: "line one\r\nline two\r\n", stdout: null })).toBe("line one\nline two");
expect(tail({ stdout: "10%\r50%\r100% done\n" })).toBe("100% done");
const many = Array.from({ length: 30 }, (_, i) => `line ${i + 1}`).join("\n");
expect(tail({ stdout: many })?.split("\n")).toEqual(Array.from({ length: 12 }, (_, i) => `line ${i + 19}`));
});
test("falls back to the output the failed step recorded", () => {
const runStages = stages(["test@1", "failed", "2026-10-10T18:05:42Z"]);
runStages["test@1"]!.output = "running 3 tests\ntest parse ... FAILED\n";
const failure = view(failedRun({ message: "script exited with status 101" }, { runStages }));
expect(failure).toMatchObject({
output: "running 3 tests\ntest parse ... FAILED",
nextStep: "Its output, under Details, usually says why.",
});
runStages["test@1"]!.output = "blob://sha256/abc";
expect(view(failedRun({ message: "script exited with status 101" }, { runStages }))?.output).toBeNull();
runStages["test@1"]!.output = "I will now write the spec.";
expect(view(failedRun({ message: CREDIT }, { runStages }))?.output).toBeNull();
});
test("cuts a long fallback headline without splitting a character", () => {
const headline = view(failedRun({ message: `${"🔥".repeat(200)} and more` }))!.headline;
expect(Array.from(headline)).toHaveLength(111);
expect(headline.endsWith("🔥…")).toBe(true);
});
test("shows nothing for runs that didn't fail", () => {
expect(buildRunFailureView({ status: { kind: "succeeded" }, stages: {} } as State)).toBeNull();
expect(buildRunFailureView(null)).toBeNull();
});
});

View file

@ -0,0 +1,272 @@
import {
FailureCategory,
FailureReason,
type ExecOutputTail,
type RunProjection,
} from "@qltysh/fabro-api-client";
import { formatStageLabel } from "../../lib/stage-sidebar";
/** Lines of a failed command's output shown on the page. */
const OUTPUT_LINES = 12;
/** Fallback headlines are cut to this many characters. */
const HEADLINE_CHARS = 110;
export interface RunFailureView {
/** The step the run stopped at (`spec`, `review@3`), when it stopped at one. */
stage: string | null;
headline: string;
nextStep: string;
/** The recorded failure message, verbatim. */
message: string;
/** Provider id, when the failure is a model request the provider refused. */
provider: string | null;
/**
* Last OUTPUT_LINES of the failed command's stderr, else its stdout, else
* the output the step the run stopped at recorded.
*/
output: string | null;
}
type FailureState = Pick<RunProjection, "status" | "conclusion" | "stages">;
export interface RunFailureContext {
/** Provider ids to display names, from the provider catalog. */
providerNames?: ReadonlyMap<string, string>;
/** The run summary's `lifecycle.error`, the only reason a dead run records. */
lifecycleError?: string | null;
}
/**
* Reads the failure from run state (`conclusion.failure`); the run summary the
* status popover reads doesn't carry the message or the command's output. A
* dead run has no conclusion, so its reason comes from the summary instead.
*/
export function buildRunFailureView(
state: FailureState | null | undefined,
{ providerNames = new Map(), lifecycleError = null }: RunFailureContext = {},
): RunFailureView | null {
const kind = state?.status?.kind;
if (!state || (kind !== "failed" && kind !== "dead")) return null;
const failure = state.conclusion?.failure;
if (!failure) return kind === "dead" ? deadRunView(state, lifecycleError) : null;
if (!failure.detail.message || failure.reason === FailureReason.CANCELLED) return null;
const { message, category } = failure.detail;
const request = parseModelRequest(message);
const stage = stoppedAt(state.stages);
// A refused model request's message is the whole story; the step's
// output would be the agent's own text.
const output =
outputTail(failure.detail.exec_output_tail) ??
(request || !stage ? null : lastLines(state.stages[stage]?.output));
const name = (id: string) => providerNames.get(id) ?? id;
const advice =
reasonAdvice(failure.reason, category) ??
(request
? requestAdvice(request, message, name(request.provider))
: stepAdvice(message, failure.reason, category, output));
return {
stage: stageLabel(stage),
...advice,
message,
provider: request?.provider ?? null,
output,
};
}
/** A run whose worker went away before the run concluded. */
function deadRunView(state: FailureState, lifecycleError: string | null): RunFailureView {
const message = lifecycleError?.trim() || "The run ended without recording a reason.";
return {
stage: stageLabel(stoppedAt(state.stages)),
headline: lifecycleError?.trim() ? firstSentence(message) : "The run's worker stopped before the run finished",
nextStep: `Open the failed step's logs for details. To run it again, ${RETRY}.`,
message,
provider: null,
output: null,
};
}
type Advice = Pick<RunFailureView, "headline" | "nextStep">;
const RETRY = "choose Retry from Actions";
function reasonAdvice(reason: FailureReason, category: FailureCategory): Advice | null {
if (reason === FailureReason.APPROVAL_DENIED) {
return { headline: "Approval was denied", nextStep: `The run stops where it was denied. To run it again, ${RETRY}.` };
}
if (reason === FailureReason.BUDGET_EXHAUSTED || category === FailureCategory.BUDGET_EXHAUSTED) {
return { headline: "The run used up its budget", nextStep: `Raise the run's budget, then ${RETRY}.` };
}
if (reason === FailureReason.TERMINATED) {
return { headline: "The run was terminated", nextStep: `To run it again, ${RETRY}.` };
}
if (reason === FailureReason.PUBLISH_FAILED) {
return {
headline: "The steps finished, but publishing the result failed",
nextStep: "The work is on the run branch. The message under Details says why publishing failed.",
};
}
if (
reason === FailureReason.LAUNCH_FAILED ||
reason === FailureReason.BOOTSTRAP_FAILED ||
reason === FailureReason.SANDBOX_INIT_FAILED
) {
return { headline: "The run couldn't start", nextStep: "The message under Details says why." };
}
return null;
}
interface ModelRequest {
provider: string;
kind: string | null;
status: number | null;
code: string | null;
}
/**
* A refused model request, as the server records it:
* `model request failed (<kind>): provider <id> <text> [provider <id>, status <n>, code <code>]`.
* Only this shape is read, so numbers and words in a command's output can't
* pass for a provider's status or name.
*/
function parseModelRequest(message: string): ModelRequest | null {
const lead = /^model request failed \(([a-z_]+)\): provider (\S+)/i.exec(message);
const tail = /\[provider ([^,\]]+), status (\d{3})(?:, code ([^\]]+))?\]\s*$/i.exec(message);
const provider = tail?.[1] ?? lead?.[2];
if (!provider) return null;
return {
provider: provider.toLowerCase(),
kind: lead?.[1]?.toLowerCase() ?? null,
status: tail ? Number(tail[2]) : null,
code: tail?.[3] ?? null,
};
}
function requestAdvice({ kind, status, code }: ModelRequest, message: string, who: string): Advice {
// These kinds fail on Fabro's side, before or without the provider's say.
if (kind === "configuration" || kind === "model_selection") {
return {
headline: `Fabro couldn't send the request to ${who}`,
nextStep: `Check ${who} and the step's model under Settings → Models, then ${RETRY}.`,
};
}
if (kind === "network") {
return {
headline: `The request never reached ${who}`,
nextStep: "Check that the Fabro server can reach the provider, then choose Retry from Actions.",
};
}
if (kind === "middleware" || kind === "resource_limit") {
return { headline: `Fabro stopped the request to ${who}`, nextStep: "The message, under Details, says why." };
}
if (
kind === "quota_exceeded" ||
status === 402 ||
/credit balance|insufficient[_ ]quota|payment required|billing|spend limit/i.test(message)
) {
return {
headline: `${who} declined the request for billing reasons`,
nextStep: `Add credit or raise the spend limit with ${who}, then ${RETRY}.`,
};
}
if (kind === "authentication" || status === 401) {
return { headline: `${who} rejected the API key`, nextStep: `Check the key under Settings → Secrets, then ${RETRY}.` };
}
if (kind === "access_denied" || status === 403) {
return {
headline: `${who} denied access to the request`,
nextStep: `The key works, but ${who} doesn't allow this account to make the call. ${who}'s message, under Details, says why.`,
};
}
if (kind === "rate_limit" || status === 429) {
return { headline: `${who} rate-limited the request`, nextStep: "This is usually temporary. Choose Retry from Actions in a few minutes." };
}
if (kind === "timeout" || /timed out/i.test(message)) {
return { headline: `The request to ${who} timed out`, nextStep: "This is usually temporary. Choose Retry from Actions in a few minutes." };
}
if ((status !== null && status >= 500) || /overloaded/i.test(message)) {
return { headline: `${who} was unavailable`, nextStep: "This is usually temporary. Choose Retry from Actions in a few minutes." };
}
const what = [status, code?.replace(/_/g, " ")].filter(Boolean).join(" ");
return {
headline: what ? `${who} returned ${what}` : `${who} rejected the request`,
nextStep: `${who}'s message, under Details, says why.`,
};
}
// A transient model request failure keeps requestAdvice's wording, which
// names the provider, so transient infrastructure is checked here.
function stepAdvice(
message: string,
reason: FailureReason,
category: FailureCategory,
output: string | null,
): Advice {
const exit = /exited with (?:status|code) (\d+)/i.exec(message);
if (exit) {
return {
headline: `The step's command exited with status ${exit[1]}`,
nextStep: output ? "Its output, under Details, usually says why." : "Open the step's logs for its output.",
};
}
if (reason === FailureReason.TRANSIENT_INFRA || category === FailureCategory.TRANSIENT_INFRA) {
return { headline: "The step hit a temporary infrastructure error", nextStep: "Choose Retry from Actions in a few minutes." };
}
return { headline: firstSentence(message), nextStep: "Open the failed step's logs for details." };
}
/**
* The key of the failed step the run stopped at: the latest failed step that
* no later step went on from. A step that started later in a sibling branch
* of the same fan-out ran beside it, not after it, so it doesn't count; an
* earlier failure that a later step recovered from isn't named.
*/
function stoppedAt(stages: FailureState["stages"]): string | null {
const started = Object.entries(stages).flatMap(([key, stage]) => {
const at = stage.started_at ? Date.parse(stage.started_at) : NaN;
if (Number.isNaN(at)) return [];
const branch = stage.parallel_branch_id ?? null;
const group = branch && branch.includes(":") ? branch.slice(0, branch.lastIndexOf(":")) : null;
return [{ key, at, branch, group, failed: stage.state === "failed" }];
});
let stop: (typeof started)[number] | null = null;
for (const step of started) {
if (!step.failed) continue;
const wentOn = started.some(
(later) =>
later.at > step.at &&
later.group !== step.key &&
!(step.group !== null && later.group === step.group && later.branch !== step.branch),
);
if (!wentOn && (!stop || step.at > stop.at)) stop = step;
}
return stop?.key ?? null;
}
/** A stage key (`node@visit`) as the Stages sidebar labels it. */
function stageLabel(key: string | null): string | null {
if (!key) return null;
const at = key.lastIndexOf("@");
if (at <= 0) return key;
return formatStageLabel({ name: key.slice(0, at), visit: Number.parseInt(key.slice(at + 1), 10) || 1 });
}
function outputTail(tail: ExecOutputTail | null | undefined): string | null {
return lastLines([tail?.stderr, tail?.stdout].find((text) => text?.trim()));
}
function lastLines(stream: string | null | undefined): string | null {
// An offloaded output is a `blob://` reference, not text to show.
if (!stream?.trim() || stream.startsWith("blob://")) return null;
// Carriage-return progress lines keep only what was drawn last.
const lines = stream.trimEnd().split(/\r?\n/).map((line) => line.slice(line.lastIndexOf("\r") + 1));
return lines.slice(-OUTPUT_LINES).join("\n");
}
function firstSentence(message: string): string {
const sentence = message.split(/(?<=[.!?])\s+/)[0]!.trim().replace(/\s+/g, " ");
const chars = Array.from(sentence.charAt(0).toUpperCase() + sentence.slice(1));
return chars.length > HEADLINE_CHARS ? `${chars.slice(0, HEADLINE_CHARS).join("").trimEnd()}…` : chars.join("");
}

View file

@ -31,6 +31,7 @@ import { formatContextWindow, formatTokensPerSecond } from "../lib/format";
import {
modelOfferingKey,
modelOfferingTestArgs,
providerNamesById,
} from "../lib/model-offerings";
import { useDebouncedValue } from "../hooks/effects";
@ -244,11 +245,7 @@ function ModelsSection({ providers }: { providers: Provider[] }) {
const { data, isLoading } = useModels(providerFilter, debouncedSearch);
const providerNameById = useMemo(() => {
const map = new Map<string, string>();
for (const p of providers) map.set(p.id, p.display_name || p.id);
return map;
}, [providers]);
const providerNameById = useMemo(() => providerNamesById(providers), [providers]);
const rows = useMemo(() => {
const all = (data?.data ?? []).filter((m) => m.configured);