From 3f2bb4f4b8a051a1ce784d8d82786208bf142368 Mon Sep 17 00:00:00 2001 From: Release Repro Date: Sat, 25 Jul 2026 11:10:06 -0400 Subject: [PATCH] Simplify stage detail reasoning UI --- .../app/routes/run-stages-chat.test.tsx | 4 + .../app/routes/run-stages-details.test.tsx | 12 +- apps/fabro-web/app/routes/run-stages.test.ts | 17 +- apps/fabro-web/app/routes/run-stages.tsx | 170 +++++++++--------- 4 files changed, 107 insertions(+), 96 deletions(-) diff --git a/apps/fabro-web/app/routes/run-stages-chat.test.tsx b/apps/fabro-web/app/routes/run-stages-chat.test.tsx index be86b89fd..c4307ebbc 100644 --- a/apps/fabro-web/app/routes/run-stages-chat.test.tsx +++ b/apps/fabro-web/app/routes/run-stages-chat.test.tsx @@ -33,6 +33,8 @@ describe("StageChatView", () => { content: "Finished", inputTokens: 0, outputTokens: 0, + toolCallCount: null, + reasoning: null, }, { kind: "tool", @@ -72,6 +74,7 @@ describe("StageChatView", () => { inputTokens: 0, outputTokens: 0, toolCallCount: 1, + reasoning: null, }, { kind: "tool", @@ -89,6 +92,7 @@ describe("StageChatView", () => { inputTokens: 10, outputTokens: 20, toolCallCount: null, + reasoning: null, }, ]} pendingTools={[]} diff --git a/apps/fabro-web/app/routes/run-stages-details.test.tsx b/apps/fabro-web/app/routes/run-stages-details.test.tsx index d2cd6ad84..da18f7e67 100644 --- a/apps/fabro-web/app/routes/run-stages-details.test.tsx +++ b/apps/fabro-web/app/routes/run-stages-details.test.tsx @@ -1,11 +1,13 @@ import { describe, expect, test } from "bun:test"; import { renderToStaticMarkup } from "react-dom/server"; -import { EventDetails, type TurnReasoning } from "./run-stages"; +import type { ReasoningOutput } from "@qltysh/fabro-api-client"; + +import { EventDetails } from "./run-stages"; const RUN_START = "2026-04-09T12:00:00Z"; -function assistantMarkup(reasoning: TurnReasoning | null): string { +function assistantMarkup(reasoning: ReasoningOutput | null): string { return renderToStaticMarkup( { }); test("labels a trace-only response Reasoning, not Reasoning trace", () => { - const html = assistantMarkup({ summary: null, trace: "Considered A." }); + const html = assistantMarkup({ trace: "Considered A." }); expect(html).toContain("Reasoning"); expect(html).not.toContain("Reasoning trace"); @@ -50,7 +52,7 @@ describe("EventDetails reasoning", () => { }); test("renders short reasoning in full, with no disclosure control", () => { - const html = assistantMarkup({ summary: null, trace: "Considered A." }); + const html = assistantMarkup({ trace: "Considered A." }); expect(html).not.toContain("Show all"); expect(html).not.toContain("aria-expanded"); @@ -58,7 +60,7 @@ describe("EventDetails reasoning", () => { test("connects a long trace's expand button to its controlled content", () => { const trace = "x".repeat(281); - const html = assistantMarkup({ summary: null, trace }); + const html = assistantMarkup({ trace }); const controls = html.match(/aria-controls="([^"]+)"/)?.[1]; expect(controls).toBeDefined(); diff --git a/apps/fabro-web/app/routes/run-stages.test.ts b/apps/fabro-web/app/routes/run-stages.test.ts index c77bf73fb..31a46eaef 100644 --- a/apps/fabro-web/app/routes/run-stages.test.ts +++ b/apps/fabro-web/app/routes/run-stages.test.ts @@ -499,9 +499,15 @@ describe("eventsToActivity", () => { ).toEqual({ summary: "Checked the config", trace: "step one…" }); // Anthropic thinking arrives as a trace with no summary. - expect(reasoningOf({ text: "Done.", reasoning: { trace: "step one…" } })).toEqual( - { summary: null, trace: "step one…" }, - ); + expect( + reasoningOf({ text: "Done.", reasoning: { trace: "step one…" } }), + ).toEqual({ trace: "step one…" }); + expect( + reasoningOf({ + text: "Done.", + reasoning: { summary: "Checked the config" }, + }), + ).toEqual({ summary: "Checked the config" }); expect(reasoningOf({ text: "Done." })).toBe(null); // A provider that sends the key but nothing usable reads as "none". @@ -780,6 +786,7 @@ describe("groupConsecutiveTools", () => { inputTokens: 0, outputTokens: 0, toolCallCount: null, + reasoning: null, }; const c = toolTurn({ ts: "2026-04-09T12:00:03Z", toolName: "shell" }); const result = groupConsecutiveTools([ @@ -857,6 +864,8 @@ describe("buildChatItems", () => { content, inputTokens: 0, outputTokens: 0, + toolCallCount: null, + reasoning: null, }; } @@ -1074,6 +1083,7 @@ describe("buildThreadDnaItems", () => { inputTokens: 0, outputTokens: 0, toolCallCount: null, + reasoning: null, }, selection: { kind: "single" as const, turnIndex }, }; @@ -1295,6 +1305,7 @@ describe("tool-call-only agent responses", () => { inputTokens: 0, outputTokens: 0, toolCallCount: 3, + reasoning: null, }; const withOneTool = { ...withTools, toolCallCount: 1 }; const withoutCount = { ...withTools, toolCallCount: null }; diff --git a/apps/fabro-web/app/routes/run-stages.tsx b/apps/fabro-web/app/routes/run-stages.tsx index 9357c17c7..43f4410f1 100644 --- a/apps/fabro-web/app/routes/run-stages.tsx +++ b/apps/fabro-web/app/routes/run-stages.tsx @@ -1,4 +1,10 @@ -import { useId, useMemo, useReducer, useState } from "react"; +import { + useId, + useMemo, + useReducer, + useState, + type ReactNode, +} from "react"; import { Link, useParams } from "react-router"; import { ArrowDownTrayIcon, @@ -88,23 +94,13 @@ import { } from "../lib/unknown"; import type { EventEnvelope, + ReasoningOutput, StageHandler, StageModelUsage, } from "@qltysh/fabro-api-client"; export const handle = { wide: true, fullHeight: true }; -/** - * Readable reasoning a provider disclosed for one model response: `summary` is - * the model's own recap, `trace` its verbatim reasoning. Providers send one, - * the other, or both, and opaque material (signatures, redacted blocks) never - * reaches the wire, so an absent field means nothing was disclosed. - */ -export interface TurnReasoning { - summary: string | null; - trace: string | null; -} - type TurnType = | { kind: "system"; ts: string; content: string } | { kind: "steer"; ts: string; content: string } @@ -118,7 +114,7 @@ type TurnType = inputTokens: number; outputTokens: number; toolCallCount: number | null; - reasoning: TurnReasoning | null; + reasoning: ReasoningOutput | null; } | { kind: "tool"; @@ -294,15 +290,15 @@ interface PendingCommand { script: string; } -function readTurnReasoning(props: UnknownRecord): TurnReasoning | null { +function readTurnReasoning(props: UnknownRecord): ReasoningOutput | null { const reasoning = getObject(props, "reasoning"); if (!reasoning) return null; // getString treats "" as absent, so a provider that sends an empty field // reads the same as one that sends nothing. const summary = getString(reasoning, "summary") ?? null; const trace = getString(reasoning, "trace") ?? null; - if (!summary && !trace) return null; - return { summary, trace }; + if (summary) return trace ? { summary, trace } : { summary }; + return trace ? { trace } : null; } export function buildStageActivity( @@ -1177,39 +1173,60 @@ function ToolGroupRow({ ); } -const REASONING_PREVIEW_CHARS = 280; +const COLLAPSIBLE_PREVIEW_CHARS = 280; /** - * Reasoning is raw model output, not authored Markdown, so it renders as - * preformatted text: a trace's own line breaks are part of what it says, and - * parsing it as Markdown would eat them along with any leading `#` or `-`. + * Shared disclosure for long stage text. By default it preserves raw text; + * callers may supply a full-content renderer for authored formats such as + * Markdown while retaining the same plain-text preview and accessible toggle. */ -function CollapsibleText({ text }: { text: string }) { +function CollapsibleContent({ + text, + className = "", + textClassName = "", + renderFull, +}: { + text: string; + className?: string; + textClassName?: string; + renderFull?: (text: string) => ReactNode; +}) { const [expanded, setExpanded] = useState(false); const contentId = useId(); - - if (text.length <= REASONING_PREVIEW_CHARS) { - return

{text}

; - } + const isLong = text.length > COLLAPSIBLE_PREVIEW_CHARS; + const preview = isLong + ? `${text.slice(0, COLLAPSIBLE_PREVIEW_CHARS).trimEnd()}…` + : text; return ( -
-

- {expanded - ? text - : `${text.slice(0, REASONING_PREVIEW_CHARS).trimEnd()}…`} -

- +
+ {/* + `w-full` is load-bearing for ChatUserCard. Its `w-fit max-w-[85%]` + bubble is measured intrinsically before being clamped, so this wrapper + must fill the resolved width to keep prompt text inside the bubble. + */} +
+ {renderFull && (!isLong || expanded) ? ( + renderFull(text) + ) : ( +

{expanded ? text : preview}

+ )} +
+ {isLong && ( + + )}
); } @@ -1231,6 +1248,9 @@ export function EventDetails({ })(); const assistantContent = turn.kind === "assistant" ? nonBlankAssistantContent(turn) : null; + const reasoning = turn.kind === "assistant" ? turn.reasoning : null; + const reasoningSummary = + reasoning && "summary" in reasoning ? reasoning.summary : null; return (
@@ -1264,16 +1284,22 @@ export function EventDetails({ ran: a trace can be thousands of characters, and leading with one would push the answer the user clicked on below the fold. */} - {turn.reasoning?.summary && ( + {reasoningSummary && ( - + )} - {turn.reasoning?.trace && ( + {reasoning?.trace && ( - + )} {turn.toolCallCount != null && turn.toolCallCount > 0 && ( @@ -1555,49 +1581,17 @@ function ToolGroupChildRow({ ); } -const CHAT_PROMPT_PREVIEW_CHARS = 280; - // User-side bubble. The stage prompt (and any steer / pair-user message over // the preview limit) collapses to a preview with an expand toggle; expanded // content renders as markdown. function ChatUserCard({ content }: { content: string }) { - const [expanded, setExpanded] = useState(false); - const contentId = useId(); - const isLong = content.length > CHAT_PROMPT_PREVIEW_CHARS; return ( -
- {/* - `w-full` is load-bearing. `items-start` keeps the expand button hugging - its label, but it also leaves this wrapper intrinsically sized, and the - bubble's own `w-fit` width is only clamped to `max-w-[85%]` after that - intrinsic pass. The text then keeps the wider pre-clamp measurement and - spills past the bubble. Filling the resolved width sidesteps it. - */} -
- {expanded ? ( - - ) : ( -

- {isLong - ? `${content.slice(0, CHAT_PROMPT_PREVIEW_CHARS).trimEnd()}…` - : content} -

- )} -
- {isLong && ( - - )} -
+ } + /> ); } @@ -1678,7 +1672,7 @@ export function StageChatView({

); case "assistant": { - const hasText = turn.content.trim().length > 0; + const assistantContent = nonBlankAssistantContent(turn); const metric = turnMetric(turn); const isFinal = turnIndex === lastAssistantTurnIndex && !stageActive; @@ -1688,10 +1682,10 @@ export function StageChatView({ // separate chips. It has nothing to show, and an empty node would // still take a slot in this gap-4 column, doubling the space // between the chips on either side of it. - if (!hasText && !showFooter) return null; + if (!assistantContent && !showFooter) return null; return (
- {hasText && } + {assistantContent && } {showFooter && (
{metric && {metric}}