Remove the model request status line above the stage toolbar

The "Model request · waiting on <model>" readout sat directly above the
Chat/Thread/Debug toolbar and appeared and disappeared as requests opened
and closed, shifting the toolbar underneath it.

Drops the StageInferenceIndicator component and everything that existed
only to feed it: the inference/runSettled prop threading through
RunStages, and StageActivity's watchdogTimedOut field. The watchdog.timeout
event now falls through to the same ignore path it always would have, since
it was never in STAGE_ACTIVITY_EVENT_TYPES.

The run-events invalidations for watchdog.timeout and agent.llm.* stay:
they still refresh stage events for the Debug tab and run state for the
insights sidebar.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bryan Helmkamp 2026-07-25 08:38:46 -04:00
parent db0b6c9b7c
commit 67ed7af026
No known key found for this signature in database
5 changed files with 1 additions and 241 deletions

View file

@ -1,107 +0,0 @@
import { afterEach, beforeEach, describe, expect, test } from "bun:test";
import TestRenderer, { act } from "react-test-renderer";
import { LlmOutputKind } from "@qltysh/fabro-api-client";
import type { StageInferenceProjection } from "@qltysh/fabro-api-client";
import { StageInferenceIndicator } from "./stage-inference-indicator";
const OPENED_AT = new Date(Date.now() - 12_000).toISOString();
function makeInference(
overrides: Partial<StageInferenceProjection> = {},
): StageInferenceProjection {
return {
session_id: "ses_root",
started_at: OPENED_AT,
requested_model: {
provider: "anthropic",
model_id: "claude-fable-5",
},
retries: 0,
...overrides,
};
}
function render(
inference: StageInferenceProjection | null | undefined,
settled = false,
): string {
let renderer!: TestRenderer.ReactTestRenderer;
act(() => {
renderer = TestRenderer.create(
<StageInferenceIndicator inference={inference} settled={settled} />,
);
});
const output = JSON.stringify(renderer.toJSON());
act(() => renderer.unmount());
return output;
}
describe("StageInferenceIndicator", () => {
const actGlobal = globalThis as {
IS_REACT_ACT_ENVIRONMENT?: boolean;
};
const previousActEnvironment = actGlobal.IS_REACT_ACT_ENVIRONMENT;
beforeEach(() => {
actGlobal.IS_REACT_ACT_ENVIRONMENT = true;
});
afterEach(() => {
if (previousActEnvironment === undefined) {
delete actGlobal.IS_REACT_ACT_ENVIRONMENT;
} else {
actGlobal.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
}
});
test("renders nothing without an open bracket", () => {
expect(render(undefined)).toBe("null");
expect(render(null)).toBe("null");
});
test("names the requested model while nothing has come back", () => {
const output = render(makeInference());
expect(output).toContain("Model request");
expect(output).toContain("waiting on claude-fable-5");
expect(output).toContain('"aria-live":"polite"');
expect(output).toContain('"aria-hidden":"true"');
// No completion estimate exists, so none may be shown.
expect(output).not.toContain("%");
});
test("reports the observed first-output kind", () => {
expect(
render(makeInference({ first_output_kind: LlmOutputKind.REASONING })),
).toContain("reasoning");
expect(
render(makeInference({ first_output_kind: LlmOutputKind.TEXT })),
).toContain("writing");
expect(
render(makeInference({ first_output_kind: LlmOutputKind.TOOL_CALL })),
).toContain("calling tools");
});
test("never says thinking for non-reasoning output", () => {
for (const kind of [LlmOutputKind.TEXT, LlmOutputKind.TOOL_CALL]) {
expect(render(makeInference({ first_output_kind: kind }))).not.toContain(
"thinking",
);
}
});
test("counts retries without presenting them as failure", () => {
const output = render(makeInference({ retries: 2 }));
expect(output).toContain("retry 2");
expect(output).not.toContain("failed");
});
test("goes static once the run can no longer advance the bracket", () => {
const output = render(makeInference(), true);
// An open bracket on a settled run means we never learned how the request
// ended — animating it would claim work that may not be happening.
expect(output).toContain("never completed");
expect(output).not.toContain("animate-pulse");
});
});

View file

@ -1,87 +0,0 @@
import { LlmOutputKind } from "@qltysh/fabro-api-client";
import type { StageInferenceProjection } from "@qltysh/fabro-api-client";
import { Tooltip } from "./ui";
import { formatAbsoluteTs, formatDurationSecs } from "../lib/format";
import { elapsedSecsSince, useTickingNow } from "../lib/time";
export interface StageInferenceIndicatorProps {
/** Open inference bracket from the stage projection, if there is one. */
inference: StageInferenceProjection | null | undefined;
/**
* The run can no longer make progress on this bracket: it reached a terminal
* status, or the stall watchdog fired. An open bracket then means *we never
* learned how the request ended*, not *it is still working*, so the readout
* goes static.
*/
settled: boolean;
}
const ACTIVITY_LABEL: Record<LlmOutputKind, string> = {
[LlmOutputKind.REASONING]: "reasoning",
[LlmOutputKind.TEXT]: "writing",
[LlmOutputKind.TOOL_CALL]: "calling tools",
};
/**
* Live readout for an open model request.
*
* Says only what the event log proves. There is no progress bar, percentage,
* or ETA, because no completion estimate exists; the elapsed clock counts
* since the request opened rather than claiming the model is still working;
* and "reasoning" appears only when the provider actually sent reasoning
* output, never as a guess filling a gap in the log.
*/
export function StageInferenceIndicator({
inference,
settled,
}: StageInferenceIndicatorProps) {
// Ticking is what distinguishes "we are still hearing from this request"
// from "this is a record of one that never closed", so it stops the moment
// the bracket can no longer advance.
const now = useTickingNow(Boolean(inference) && !settled);
if (!inference) return null;
if (settled) {
return (
<p className="pb-2 text-xs text-fg-muted">
Model request opened {formatAbsoluteTs(inference.started_at)}, never
completed
</p>
);
}
const elapsedSecs = elapsedSecsSince(inference.started_at, now);
const activity = inference.first_output_kind
? ACTIVITY_LABEL[inference.first_output_kind]
: `waiting on ${inference.requested_model.model_id}`;
const statusParts = ["Model request", activity];
// A retry that later succeeds is normal, so this is a count, not a failure.
if (inference.retries > 0) {
statusParts.push(`retry ${inference.retries}`);
}
return (
<p className="pb-2 text-xs text-fg-muted">
<Tooltip
label={`Model request opened ${formatAbsoluteTs(inference.started_at)}`}
>
<span className="inline-flex items-center gap-1.5">
<span
className="size-1.5 animate-pulse rounded-full bg-teal-500"
aria-hidden="true"
/>
<span aria-live="polite">{statusParts.join(" · ")}</span>
{elapsedSecs !== null && (
<span aria-hidden="true">
{" · "}
{formatDurationSecs(elapsedSecs)}
</span>
)}
</span>
</Tooltip>
</p>
);
}

View file

@ -150,7 +150,7 @@ describe("queryKeysForRunEvent", () => {
]);
});
test("watchdog timeout refreshes the stage events that settle inference", () => {
test("watchdog timeout refreshes the stage events for that stage", () => {
expect(
queryKeysForRunEvent("run-1", "watchdog.timeout", "code@1"),
).toEqual([queryKeys.runs.stageEvents("run-1", "code@1")]);

View file

@ -884,19 +884,6 @@ describe("buildChatItems", () => {
});
describe("buildStageActivity pending tools", () => {
test("records watchdog settlement only for the selected stage", () => {
const events: EventEnvelope[] = [
envelope(1, {
event: "watchdog.timeout",
stage_id: "plan@1",
node_id: "plan",
}),
];
expect(buildStageActivity(events, "plan@1").watchdogTimedOut).toBe(true);
expect(buildStageActivity(events, "code@1").watchdogTimedOut).toBe(false);
});
test("returns started-but-not-completed calls for the stage", () => {
const events: EventEnvelope[] = [
envelope(1, {

View file

@ -31,7 +31,6 @@ import type {
ThreadDnaSelection,
} from "../components/event-debug";
import { StageContext } from "../components/stage-context";
import { StageInferenceIndicator } from "../components/stage-inference-indicator";
import { StageInsightsSidebar } from "../components/stage-insights-sidebar";
import { StageSidebar } from "../components/stage-sidebar";
import type { Stage } from "../components/stage-sidebar";
@ -73,7 +72,6 @@ import {
useRunStages,
useRunState,
} from "../lib/queries";
import { isTerminalRunStatus } from "../lib/run-actions";
import {
STAGE_ACTIVITY_EVENT_TYPES,
type StageActivityEventType,
@ -86,7 +84,6 @@ import { getNumber, getString, type UnknownRecord } from "../lib/unknown";
import type {
EventEnvelope,
StageHandler,
StageInferenceProjection,
StageModelUsage,
} from "@qltysh/fabro-api-client";
@ -271,7 +268,6 @@ export interface PendingToolCall {
interface StageActivity {
turns: TurnType[];
pendingTools: PendingToolCall[];
watchdogTimedOut: boolean;
}
interface PendingCommand {
@ -287,17 +283,12 @@ export function buildStageActivity(
const pendingTools = new Map<string, PendingTool>();
let pendingCommand: PendingCommand | undefined;
let sawAssistantMessage = false;
let watchdogTimedOut = false;
for (const e of events) {
const eventName = e.event;
if (activityEventStageId(e) !== stageId) {
continue;
}
if (eventName === "watchdog.timeout") {
watchdogTimedOut = true;
continue;
}
if (
!eventName ||
!STAGE_ACTIVITY_EVENT_SET.has(eventName)
@ -452,7 +443,6 @@ export function buildStageActivity(
return {
turns,
watchdogTimedOut,
pendingTools: Array.from(pendingTools, ([toolCallId, tool]) => ({
toolCallId,
toolName: tool.toolName,
@ -2075,8 +2065,6 @@ function RunStageActivityStage({
selectedStage,
stages,
runStart,
inference,
runSettled,
tab,
selectedKinds,
selectedDebugCategories,
@ -2090,8 +2078,6 @@ function RunStageActivityStage({
selectedStage: Stage;
stages: Stage[];
runStart: string | undefined;
inference: StageInferenceProjection | null | undefined;
runSettled: boolean;
tab: EventsTab;
selectedKinds: EventKind[];
selectedDebugCategories: DebugCategory[];
@ -2103,15 +2089,11 @@ function RunStageActivityStage({
}) {
const selectedStageId = selectedStage.id;
const stageEventsQuery = useRunStageEvents(runId, selectedStageId);
// An open bracket on a run that can no longer advance means we never learned
// how the request ended, not that it is still working. The watchdog stays
// the authority on "actually stuck", so its timeout settles the readout too.
const activity = useMemo(
() => buildStageActivity(stageEventsQuery.data ?? [], selectedStageId),
[stageEventsQuery.data, selectedStageId],
);
const { turns } = activity;
const inferenceSettled = runSettled || activity.watchdogTimedOut;
const renderer: StageRenderer = selectStageRenderer(selectedStage.handler);
const debugEvents = useMemo<EventEnvelope[]>(() => {
return (stageEventsQuery.data ?? []).filter(
@ -2259,11 +2241,6 @@ function RunStageActivityStage({
</Link>
</p>
)}
<StageInferenceIndicator
inference={inference}
settled={inferenceSettled}
/>
<EventsToolbar
tab={effectiveTab}
renderer={renderer}
@ -2361,15 +2338,11 @@ function RunStageActivity({
selectedStage,
stages,
runStart,
inference,
runSettled,
}: {
runId: string;
selectedStage: Stage;
stages: Stage[];
runStart: string | undefined;
inference: StageInferenceProjection | null | undefined;
runSettled: boolean;
}) {
const [activityState, dispatchActivity] = useReducer(
stageActivityReducer,
@ -2385,8 +2358,6 @@ function RunStageActivity({
selectedStage={selectedStage}
stages={stages}
runStart={runStart}
inference={inference}
runSettled={runSettled}
tab={tab}
selectedKinds={selectedKinds}
selectedDebugCategories={selectedDebugCategories}
@ -2438,8 +2409,6 @@ export default function RunStages() {
isAgentStage && selectedStageId
? runStateQuery.data?.stages[selectedStageId]
: undefined;
const runStatusKind = runQuery.data?.lifecycle.status.kind;
const runSettled = isTerminalRunStatus(runStatusKind);
if (!id || !selectedStage) {
return (
@ -2491,8 +2460,6 @@ export default function RunStages() {
selectedStage={selectedStage}
stages={stages}
runStart={runStart}
inference={stageProjection?.inference}
runSettled={runSettled}
/>
</div>
);