import { useState } from "react"; import type { ApprovalDecision, Item } from "../types"; import { shortArgs } from "./ApprovalCard"; import { humanizeAsk, humanizeTool, type HumanLine } from "../humanize"; import { Markdown } from "./Markdown"; import { ConnectorMessageCard } from "./ConnectorMessageCard"; import { Icon } from "./Icon"; // Long user pastes swallow the transcript (owner ask 2026-07-30): clamp past a generous // threshold with a more…/less… toggle. Normal typed messages never see the control; the // full text still drives copy (BubbleMeta) and is what the model received. const USER_CLAMP_CHARS = 1200; function ClampedUserText({ text }: { text: string }) { const [open, setOpen] = useState(false); if (text.length <= USER_CLAMP_CHARS) return <>{text}; return ( <> {open ? text : text.slice(0, USER_CLAMP_CHARS).trimEnd() + "…"} ); } // Hover affordances for a message bubble (FB-005): copy the raw text + the message's time. // Lives in a ZERO-HEIGHT strip under the bubble (absolute, inside the transcript's 20px gap) // so revealing it on group-hover never shifts the layout. `ts` is unix seconds — canonical // messages carry it, pre-stamp history doesn't, so the time simply omits itself when absent. function BubbleMeta({ text, ts, align }: { text: string; ts?: number; align: "left" | "right" }) { const [copied, setCopied] = useState(false); const when = typeof ts === "number" ? new Date(ts * 1000) : null; const copy = () => { // "Copied" only after the write actually lands — WebKit can reject outside a // trusted gesture, and claiming success on a silent no-op would gaslight the user. navigator.clipboard ?.writeText(text) .then(() => { setCopied(true); window.setTimeout(() => setCopied(false), 1200); }) .catch(() => {}); }; return (
{when && ( {when.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })} )}
); } // Reasoning-model thinking text (model-layer roadmap item 4): a quiet disclosure — // collapsed by default, the trace one click away. `live` = still streaming (pulsing label); // App renders that variant above the transcript, this one rides a finalized assistant item. export function ThinkingBlock({ text, live }: { text: string; live?: boolean }) { const [open, setOpen] = useState(false); return (
{open && (
{text}
)}
); } type ToolItem = Extract; type ApprovalItem = Extract; type AssistantItem = Extract; type TurnItem = ToolItem | ApprovalItem | AssistantItem; // TurnGroup (§33, absorbs §7's StepGroup): the whole user-message → final-answer span collapses // as ONE disclosure — "N steps" — with the agent's narration (assistant text followed by more // activity in the same turn) and humanized one-line steps interleaved inside. The final assistant // text renders as a normal bubble OUTSIDE the group (see the flush logic in Transcript below). // Approvals fold into their tool's row as a chip; an approval with no executed call (typically // declined) keeps its own "Wanted to …" row. Raw args+result stay one click away per row. type TurnRow = | { type: "narr"; text: string } | { type: "step"; tool: ToolItem; approval?: ApprovalItem } | { type: "ask"; approval: ApprovalItem }; function buildRows(items: TurnItem[]): TurnRow[] { // First pass: tool rows in order; then pair each resolved approval with the nearest // same-name tool that doesn't have one yet (approvals may stream before or after their call). const rows: TurnRow[] = items .filter((it): it is ToolItem | AssistantItem => it.kind !== "approval") // Thinking-only assistant items (no text) carry nothing narratable — skip the row. .filter((it) => it.kind !== "assistant" || it.text) .map((it) => it.kind === "assistant" ? { type: "narr" as const, text: it.text } : { type: "step" as const, tool: it }, ); const approvals = items.filter((it): it is ApprovalItem => it.kind === "approval"); for (const ap of approvals) { const at = items.indexOf(ap); let bestRow: Extract | null = null; let bestDist = Infinity; for (let i = 0; i < items.length; i++) { const it = items[i]; if (it.kind !== "tool" || it.name !== ap.name) continue; const row = rows.find((r) => r.type === "step" && r.tool === it) as | Extract | undefined; if (!row || row.approval) continue; const dist = Math.abs(i - at); if (dist < bestDist) { bestRow = row; bestDist = dist; } } if (bestRow && ap.resolved !== "deny") bestRow.approval = ap; else { // No executed call to attach to (or it was declined) — the ask keeps its own row, // placed where the approval sat in the stream. const after = items.slice(0, at).filter((it) => it.kind !== "approval").length; rows.splice(after, 0, { type: "ask", approval: ap }); } } return rows; } function approvalChip(resolved: ApprovalDecision | undefined) { if (resolved === "deny") return ✕ declined; return ( ✓ approved ); } function LineText({ line }: { line: HumanLine }) { return ( {line.pre} {line.obj && {line.obj}} {line.post && {line.post}} ); } function StepRow({ tool, approval, onAllowAnyway, }: { tool: ToolItem; approval?: ApprovalItem; onAllowAnyway?: (name: string, args: any) => void; }) { // A reviewer deny (spec §8.4) renders as a card under the step: the FULL reason (the // agent only got a terse refusal) plus the one-shot "Allow anyway" override. const [overrideSent, setOverrideSent] = useState(false); const [raw, setRaw] = useState(false); const running = tool.status === "…"; const failed = tool.status !== "ok" && !running; return (
{running ? : "●"} {approval && approvalChip(approval.resolved)} {!!tool.standingRule && ( auto-allowed )} {!!tool.hidden && ( {tool.hidden} hidden )} {failed && {tool.status}} {!running && ( )}
{raw && (
          {`${tool.name}  ${shortArgs(tool.args)}`}
          {tool.preview ? `\n→ ${tool.preview.length > 1500 ? tool.preview.slice(0, 1500) + "\n…" : tool.preview}` : ""}
        
)} {tool.status === "denied" && tool.reviewerReason && (
Blocked by the reviewer
{tool.reviewerReason}
The agent was told only that it was blocked — not why — so it can’t argue its way past this. If the action is actually fine, you can run it as proposed:
{tool.allowAnyway && onAllowAnyway && !overrideSent && ( )} {overrideSent && (
Approved — the agent will retry this exact action.
)}
)}
); } function TurnGroup({ items, live, streamingText, onAllowAnyway, }: { items: TurnItem[]; live?: boolean; // Sub-threshold streamed text belongs to THIS group (§33 ref #3): collapsed → it rides // the header as the live line; expanded → the small quiet line under the steps. streamingText?: string; onAllowAnyway?: (name: string, args: any) => void; }) { // Turns start COLLAPSED, running or not (owner call 2026-07-14) — the header's live // line is the pulse; expanding is opt-in. const rows = buildRows(items); const tools = items.filter((it): it is ToolItem => it.kind === "tool"); const running = live || tools.some((t) => t.status === "…"); const [userToggle, setUserToggle] = useState(null); const open = userToggle ?? false; const lastNarr = [...items].reverse().find((it): it is AssistantItem => it.kind === "assistant"); const liveLine = streamingText || lastNarr?.text || ""; const nSteps = rows.filter((r) => r.type !== "narr").length; const declined = items.filter((it) => it.kind === "approval" && it.resolved === "deny").length; const hiddenTotal = tools.reduce((n, t) => n + (t.hidden || 0), 0); const stepsLabel = `${nSteps} step${nSteps === 1 ? "" : "s"}`; return (
{ e.preventDefault(); // drive open/closed from state, not the native toggle setUserToggle(!open); }} > {running ? `Running ${stepsLabel}…` : stepsLabel} {declined > 0 && ( <> {" · "} {declined} declined )} {hiddenTotal > 0 && ( <> {" · "} {hiddenTotal} hidden by your filters )} {running && !open && liveLine && ( · {liveLine} )} {open && (
{rows.map((row, i) => row.type === "narr" ? (
) : row.type === "ask" ? (
{approvalChip(row.approval.resolved)}
) : ( ), )} {streamingText && (
)}
)}
); } interface Props { items: Item[]; onApprove: (decision: ApprovalDecision) => void; // The session's live flag. While true, the FINAL run's trailing assistant text is still // narration (status), not the answer — promoting it early made each line flash as a full // ASSISTANT bubble and then vanish into the group when the next tool call arrived // (owner report 2026-07-13). The answer bubble appears once, when the turn ends. running?: boolean; // Sub-threshold streamed text (streamGate mode "quiet") — handed to the live turn group. streamingText?: string; // Re-run the failed turn (no new user message). Offered only on a retriable notice that // is the transcript tail of an idle session — anywhere else the error is history. onRetry?: () => void; // MEMORY-SPEC §5.1: undo a just-announced write. `previous` (set when the write was // an edit) is the text to restore; without it the memory is deleted. onUndoMemory?: (id: number, previous?: string) => void; // §8.4 "Allow anyway" on a reviewer-denied tool: one-shot exact-action override. onAllowAnyway?: (name: string, args: any) => void; } // The transcript index whose notice gets the Retry button: the tail error notice, looking // through info notices after it (model switches must not consume the retry — switching // models and THEN retrying is the intended recovery path). -1 when the tail is anything else. export function retryAnchor(items: Item[]): number { for (let i = items.length - 1; i >= 0; i--) { const it = items[i]; if (it.kind !== "notice") return -1; if (it.retriable) return i; if (it.tone !== "info") return -1; } return -1; } export function Transcript({ items, running, streamingText, onRetry, onUndoMemory, onAllowAnyway }: Props) { // §33 grouping: a turn = the maximal run of assistant/tool/resolved-approval items between // breakers (user, connector, notices, plan/dir requests…). Trailing assistant texts are the // ANSWER and render as bubbles after the group; interior assistant texts are narration and // stay inside. A run with no activity at all is just bubbles (unchanged chat behavior). const blocks: Array<{ turn: TurnItem[]; live?: boolean } | { item: Item; i: number }> = []; let run: TurnItem[] = []; const flush = (live = false) => { if (!run.length) return; const turn = [...run]; run = []; const answers: AssistantItem[] = []; // A live run with tool activity keeps its trailing text inside as the status line; // a live run with NO activity is a plain streaming reply — bubbles, as ever. const keepTrailing = live && turn.some((it) => it.kind !== "assistant"); if (!keepTrailing) while (turn.length && turn[turn.length - 1].kind === "assistant") answers.unshift(turn.pop() as AssistantItem); if (turn.some((it) => it.kind !== "assistant")) blocks.push({ turn, live }); else turn.forEach((t) => blocks.push({ item: t, i: -1 })); answers.forEach((a) => blocks.push({ item: a, i: -1 })); }; items.forEach((item, i) => { if (item.kind === "tool" || item.kind === "assistant" || (item.kind === "approval" && item.resolved)) run.push(item); else if ( // PENDING interactive items render elsewhere (approval/question → composer head) and // nothing here — if they broke the run, the trailing narration would flash into an // answer bubble exactly while the user is being asked to decide. (item.kind === "approval" || item.kind === "dirreq" || item.kind === "planreq" || item.kind === "question") && !item.resolved ) { return; } else { flush(); blocks.push({ item, i }); } }); flush(!!running); const lastTurnIndex = blocks.reduce((acc, b, i) => ("turn" in b ? i : acc), -1); return (
{blocks.map((block, bi) => { if ("turn" in block) return ( ); const { item } = block; switch (item.kind) { case "connector": return ; case "user": return (
{item.attachments && item.attachments.length > 0 && (
{item.attachments.map((a, i) => a.kind === "image" ? ( {a.name} ) : ( 📄 {a.name} ), )}
)}
); case "assistant": // Thinking-only item (stopped mid-reasoning): just the disclosure, no bubble. if (!item.text && item.reasoning) return (
); return (
assistant
{item.reasoning && }
); case "dirreq": if (!item.resolved) return null; return (
{item.resolved === "granted" ? "✓" : "✕"} {item.resolved === "granted" ? "Granted folder access" : "Declined folder access"} {item.path && {item.path}}
); case "planreq": if (!item.resolved) return null; // pending plan renders in the composer head return (
proposed plan
{item.resolved === "approved" ? "✓" : "✕"} {item.resolved === "approved" ? "Plan approved" : "Sent back with feedback"}
); case "notice": return (
{item.text} {item.retriable && !running && onRetry && block.i === retryAnchor(items) && ( )}
); // §5.1 save notice: quiet, inline, and it STAYS — the user reads it in place // and can undo whenever they get to it. case "memory": return (
{item.undone ? ( {item.previous ? "Okay — put back the way it was." : "Okay — forgotten."} ) : ( <> {item.previous ? "I've updated what I remember" : "I'll remember that"} {item.text ? — {item.text} : null} {onUndoMemory && ( )} )}
); default: return null; } })}
); }