mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-10 03:30:59 +00:00
Simplify stage detail reasoning UI
This commit is contained in:
parent
2b46401c71
commit
3f2bb4f4b8
4 changed files with 107 additions and 96 deletions
|
|
@ -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={[]}
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
<EventDetails
|
||||
turn={{
|
||||
|
|
@ -31,7 +33,7 @@ describe("EventDetails reasoning", () => {
|
|||
});
|
||||
|
||||
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();
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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 <p className="break-words whitespace-pre-wrap">{text}</p>;
|
||||
}
|
||||
const isLong = text.length > COLLAPSIBLE_PREVIEW_CHARS;
|
||||
const preview = isLong
|
||||
? `${text.slice(0, COLLAPSIBLE_PREVIEW_CHARS).trimEnd()}…`
|
||||
: text;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-start gap-1.5">
|
||||
<p id={contentId} className="break-words whitespace-pre-wrap">
|
||||
{expanded
|
||||
? text
|
||||
: `${text.slice(0, REASONING_PREVIEW_CHARS).trimEnd()}…`}
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
aria-controls={contentId}
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
className="text-xs text-teal-500 hover:underline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-teal-500"
|
||||
>
|
||||
{expanded
|
||||
? "Collapse"
|
||||
: `Show all (${text.length.toLocaleString()} characters)`}
|
||||
</button>
|
||||
<div
|
||||
className={`flex flex-col items-start gap-1.5 ${className}`.trim()}
|
||||
>
|
||||
{/*
|
||||
`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.
|
||||
*/}
|
||||
<div id={contentId} className="w-full">
|
||||
{renderFull && (!isLong || expanded) ? (
|
||||
renderFull(text)
|
||||
) : (
|
||||
<p className={textClassName}>{expanded ? text : preview}</p>
|
||||
)}
|
||||
</div>
|
||||
{isLong && (
|
||||
<button
|
||||
type="button"
|
||||
aria-controls={contentId}
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
className="text-xs text-teal-500 hover:underline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-teal-500"
|
||||
>
|
||||
{expanded
|
||||
? "Collapse"
|
||||
: `Show all (${text.length.toLocaleString()} characters)`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -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 (
|
||||
<div className="space-y-5">
|
||||
|
|
@ -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 && (
|
||||
<DetailField label="Reasoning">
|
||||
<CollapsibleText text={turn.reasoning.summary} />
|
||||
<CollapsibleContent
|
||||
text={reasoningSummary}
|
||||
textClassName="wrap-break-word whitespace-pre-wrap"
|
||||
/>
|
||||
</DetailField>
|
||||
)}
|
||||
{turn.reasoning?.trace && (
|
||||
{reasoning?.trace && (
|
||||
<DetailField
|
||||
label={turn.reasoning.summary ? "Reasoning trace" : "Reasoning"}
|
||||
label={reasoningSummary ? "Reasoning trace" : "Reasoning"}
|
||||
>
|
||||
<CollapsibleText text={turn.reasoning.trace} />
|
||||
<CollapsibleContent
|
||||
text={reasoning.trace}
|
||||
textClassName="wrap-break-word whitespace-pre-wrap"
|
||||
/>
|
||||
</DetailField>
|
||||
)}
|
||||
{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 (
|
||||
<div className="flex w-fit max-w-[85%] flex-col items-start gap-1.5 self-end rounded-2xl rounded-br-md bg-panel px-4 py-3">
|
||||
{/*
|
||||
`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.
|
||||
*/}
|
||||
<div id={contentId} className="w-full">
|
||||
{expanded ? (
|
||||
<Markdown content={content} />
|
||||
) : (
|
||||
<p className="text-sm break-words whitespace-pre-wrap text-fg-2">
|
||||
{isLong
|
||||
? `${content.slice(0, CHAT_PROMPT_PREVIEW_CHARS).trimEnd()}…`
|
||||
: content}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{isLong && (
|
||||
<button
|
||||
type="button"
|
||||
aria-controls={contentId}
|
||||
aria-expanded={expanded}
|
||||
onClick={() => setExpanded((current) => !current)}
|
||||
className="text-xs text-teal-500 hover:underline focus-visible:outline-2 focus-visible:outline-offset-1 focus-visible:outline-teal-500"
|
||||
>
|
||||
{expanded
|
||||
? "Collapse"
|
||||
: `Show all (${content.length.toLocaleString()} characters)`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<CollapsibleContent
|
||||
text={content}
|
||||
className="w-fit max-w-[85%] self-end rounded-2xl rounded-br-md bg-panel px-4 py-3"
|
||||
textClassName="text-sm wrap-break-word whitespace-pre-wrap text-fg-2"
|
||||
renderFull={(text) => <Markdown content={text} />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -1678,7 +1672,7 @@ export function StageChatView({
|
|||
</p>
|
||||
);
|
||||
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 (
|
||||
<div key={`turn-${turnIndex}`} className="flex flex-col gap-1.5">
|
||||
{hasText && <Markdown content={turn.content} />}
|
||||
{assistantContent && <Markdown content={assistantContent} />}
|
||||
{showFooter && (
|
||||
<div className="flex gap-3 font-mono text-[11px] text-fg-muted tabular-nums">
|
||||
{metric && <span>{metric}</span>}
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue