mirror of
https://github.com/andrewyng/openworker.git
synced 2026-09-03 04:49:26 +00:00
A reviewer deny now renders as a proper card in the transcript - the FULL
reason (the agent only ever got the terse refusal) plus an "Allow anyway"
button - and clicking it mints a ONE-SHOT exact-action approval.
Engine:
- approve_action_once(tool, arguments): human-minted grant keyed on the
exact tool + canonical (sort_keys) JSON arguments, consumed on first
match. Checked in _authorize's needs_user branch AHEAD of the reviewer,
so the approved re-proposal runs without a reviewer call or a card.
Audited as allow_anyway_granted + auto_allowed.
- Deliberately narrow: a re-proposal with even slightly different
arguments does not match and goes back through the normal flow, and the
grant only applies where needs_user is true - it CANNOT unlock a hard
deny (1.2), which is now a test.
Server: WS kind "allow_anyway" {name, arguments} -> engine.approve_action_
once, with input validation. The GUI follows up through the normal
user_message path with a visible "go ahead with it exactly as proposed"
message, so the retry is in the transcript, not magic.
GUI:
- tool items carry reviewerReason/allowAnyway (the event fields were
already broadcast verbatim; updateLastTool now keeps them).
- StepRow renders the deny card: full reason, a note that the agent was
told only THAT it was blocked (not why), and the button - which
collapses into a confirmation after one click (no double-fire).
- SessionSocket.allowAnyway; App.allowAnyway = WS grant + canned retry
message; onAllowAnyway threaded Transcript -> TurnGroup -> StepRow.
Tests: 4 engine (runs once without card/reviewer; consumed not standing;
different action never matches; hard deny stays denied) + 3 component
(card + reason + exact-args callback + one-shot button; no card on
ordinary denies; no button without the callback). 110 backend + 114 GUI
tests green.
578 lines
24 KiB
TypeScript
578 lines
24 KiB
TypeScript
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() + "…"}
|
||
<button
|
||
type="button"
|
||
onClick={() => setOpen((o) => !o)}
|
||
className="block ml-auto mt-1.5 text-[12.5px] font-medium opacity-75 hover:opacity-100"
|
||
>
|
||
{open ? "less…" : "more…"}
|
||
</button>
|
||
</>
|
||
);
|
||
}
|
||
|
||
// 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 (
|
||
<div className="relative h-0 select-none">
|
||
<div
|
||
className={
|
||
"absolute top-1 flex items-center gap-1.5 text-[10.5px] leading-none text-faint whitespace-nowrap opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity " +
|
||
(align === "right" ? "right-0" : "left-0")
|
||
}
|
||
>
|
||
<button
|
||
className="flex items-center cursor-pointer hover:text-muted"
|
||
data-testid="bubble-copy"
|
||
title="Copy message"
|
||
onClick={copy}
|
||
>
|
||
{copied ? "Copied" : <Icon name="copy" size={11} />}
|
||
</button>
|
||
{when && (
|
||
<span data-testid="bubble-ts" title={when.toLocaleString()}>
|
||
{when.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" })}
|
||
</span>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
// 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 (
|
||
<div className="thinking">
|
||
<button
|
||
className="thinking-head"
|
||
onClick={() => setOpen((v) => !v)}
|
||
data-testid="thinking-toggle"
|
||
>
|
||
<Icon name="chevronDown" size={12} className={"thinking-caret" + (open ? " open" : "")} />
|
||
<span className={live ? "thinking-live" : undefined}>
|
||
{live ? "Thinking…" : "Thought process"}
|
||
</span>
|
||
</button>
|
||
{open && (
|
||
<div className="thinking-body" data-testid="thinking-body">
|
||
{text}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
type ToolItem = Extract<Item, { kind: "tool" }>;
|
||
type ApprovalItem = Extract<Item, { kind: "approval" }>;
|
||
type AssistantItem = Extract<Item, { kind: "assistant" }>;
|
||
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<TurnRow, { type: "step" }> | 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<TurnRow, { type: "step" }>
|
||
| 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 <span className="text-[10.5px] px-1.5 rounded-full bg-dangerSoft text-danger shrink-0">✕ declined</span>;
|
||
return (
|
||
<span
|
||
className="text-[10.5px] px-1.5 rounded-full bg-okSoft text-ok shrink-0"
|
||
title={resolved ? `approved · ${resolved.replace(/_/g, " ")}` : "approved"}
|
||
>
|
||
✓ approved
|
||
</span>
|
||
);
|
||
}
|
||
|
||
function LineText({ line }: { line: HumanLine }) {
|
||
return (
|
||
<span className="min-w-0 text-[13px] leading-relaxed">
|
||
<span className="text-muted">{line.pre}</span>
|
||
{line.obj && <span className="text-ink">{line.obj}</span>}
|
||
{line.post && <span className="text-muted">{line.post}</span>}
|
||
</span>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div>
|
||
<div className="group flex items-baseline gap-2 px-2 py-0.5 rounded-lg hover:bg-paper" data-testid="turn-step">
|
||
<span className={"w-3.5 text-center text-[10px] shrink-0 " + (failed ? "text-danger" : running ? "text-accent" : "text-ok")}>
|
||
{running ? <span className="spinner" data-testid="step-running" /> : "●"}
|
||
</span>
|
||
<LineText
|
||
line={
|
||
// A refused load must not read as a success — "Used skill:" is the trust line
|
||
// (SKILLS-SPEC §4.1 #4), so a blocked attempt gets honest wording instead.
|
||
tool.name === "load_skill" && tool.preview?.includes('"error"')
|
||
? { pre: "Tried skill: ", obj: String(tool.args?.name ?? ""), post: " — not available" }
|
||
: humanizeTool(tool.name, tool.args)
|
||
}
|
||
/>
|
||
{approval && approvalChip(approval.resolved)}
|
||
{!!tool.standingRule && (
|
||
<span
|
||
className="text-[10.5px] px-1.5 rounded-full bg-tealSoft text-tealInk shrink-0"
|
||
data-testid="tool-standing-rule"
|
||
title={`Auto-allowed by this automation's standing approval: ${tool.standingRule}. Revoke on its Automations page.`}
|
||
>
|
||
auto-allowed
|
||
</span>
|
||
)}
|
||
{!!tool.hidden && (
|
||
<span
|
||
className="text-[11px] text-warnInk shrink-0"
|
||
data-testid="tool-hidden-count"
|
||
title="Removed by your privacy filters before the agent saw the results — agents get no trace of these."
|
||
>
|
||
{tool.hidden} hidden
|
||
</span>
|
||
)}
|
||
{failed && <span className="text-[11px] text-danger shrink-0">{tool.status}</span>}
|
||
{!running && (
|
||
<button
|
||
className="ml-auto shrink-0 text-[11px] text-faint opacity-0 group-hover:opacity-100 cursor-pointer"
|
||
onClick={() => setRaw((v) => !v)}
|
||
>
|
||
raw
|
||
</button>
|
||
)}
|
||
</div>
|
||
{raw && (
|
||
<pre className="ml-8 mr-2 my-1 px-2.5 py-1.5 rounded-lg border border-line bg-paper font-mono text-[11.5px] leading-relaxed text-muted whitespace-pre-wrap break-words max-h-56 overflow-auto">
|
||
{`${tool.name} ${shortArgs(tool.args)}`}
|
||
{tool.preview ? `\n→ ${tool.preview.length > 1500 ? tool.preview.slice(0, 1500) + "\n…" : tool.preview}` : ""}
|
||
</pre>
|
||
)}
|
||
{tool.status === "denied" && tool.reviewerReason && (
|
||
<div
|
||
className="ml-8 mr-2 my-1 px-3 py-2 rounded-lg border border-line bg-dangerSoft/40"
|
||
data-testid="reviewer-deny-card"
|
||
>
|
||
<div className="text-[11px] font-medium text-danger">Blocked by the reviewer</div>
|
||
<div className="text-[12px] text-ink mt-0.5">{tool.reviewerReason}</div>
|
||
<div className="text-[11px] text-faint mt-1">
|
||
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:
|
||
</div>
|
||
{tool.allowAnyway && onAllowAnyway && !overrideSent && (
|
||
<button
|
||
className="mt-1.5 px-2.5 py-1 rounded-lg border border-line bg-panel text-[12px] text-ink hover:bg-paper"
|
||
data-testid="reviewer-allow-anyway"
|
||
onClick={() => {
|
||
setOverrideSent(true);
|
||
onAllowAnyway(tool.name, tool.args);
|
||
}}
|
||
>
|
||
Allow anyway
|
||
</button>
|
||
)}
|
||
{overrideSent && (
|
||
<div className="mt-1.5 text-[11px] text-ok" data-testid="reviewer-override-sent">
|
||
Approved — the agent will retry this exact action.
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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<boolean | null>(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 (
|
||
<details className="stepgroup" open={open}>
|
||
<summary
|
||
className="stepgroup-head flex items-center gap-2 py-0.5 cursor-pointer select-none text-[12.5px] text-faint hover:text-muted"
|
||
onClick={(e) => {
|
||
e.preventDefault(); // drive open/closed from state, not the native toggle
|
||
setUserToggle(!open);
|
||
}}
|
||
>
|
||
<span className={"chev inline-block transition-transform" + (open ? " rotate-90" : "")}>›</span>
|
||
<span>
|
||
<span>{running ? `Running ${stepsLabel}…` : stepsLabel}</span>
|
||
{declined > 0 && (
|
||
<>
|
||
{" · "}
|
||
<span className="text-danger" data-testid="stepgroup-declined">
|
||
{declined} declined
|
||
</span>
|
||
</>
|
||
)}
|
||
{hiddenTotal > 0 && (
|
||
<>
|
||
{" · "}
|
||
<span className="text-warnInk" data-testid="stepgroup-hidden">
|
||
{hiddenTotal} hidden by your filters
|
||
</span>
|
||
</>
|
||
)}
|
||
</span>
|
||
{running && !open && liveLine && (
|
||
<span className="min-w-0 flex-1 truncate" data-testid="turn-live-line">
|
||
· {liveLine}
|
||
</span>
|
||
)}
|
||
</summary>
|
||
{open && (
|
||
<div className="ml-1.5 mt-1 pl-2 border-l-2 border-line flex flex-col gap-0.5">
|
||
{rows.map((row, i) =>
|
||
row.type === "narr" ? (
|
||
<div className="turn-narr px-2 py-1 text-[13px] text-muted max-w-[60ch]" key={i} data-testid="turn-narration">
|
||
<Markdown text={row.text} />
|
||
</div>
|
||
) : row.type === "ask" ? (
|
||
<div className="flex items-baseline gap-2 px-2 py-0.5" key={i} data-testid="turn-ask">
|
||
<span className={"w-3.5 text-center text-[10px] shrink-0 " + (row.approval.resolved === "deny" ? "text-danger" : "text-ok")}>●</span>
|
||
<LineText line={humanizeAsk(row.approval.name, row.approval.args)} />
|
||
{approvalChip(row.approval.resolved)}
|
||
</div>
|
||
) : (
|
||
<StepRow tool={row.tool} approval={row.approval} onAllowAnyway={onAllowAnyway} key={i} />
|
||
),
|
||
)}
|
||
{streamingText && (
|
||
<div
|
||
className="turn-narr px-2 py-1 text-[13px] text-muted max-w-[60ch]"
|
||
data-testid="turn-live-stream"
|
||
>
|
||
<Markdown text={streamingText} />
|
||
<span className="stream-cursor">▍</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
)}
|
||
</details>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div className="transcript">
|
||
{blocks.map((block, bi) => {
|
||
if ("turn" in block)
|
||
return (
|
||
<TurnGroup
|
||
items={block.turn}
|
||
live={block.live}
|
||
streamingText={block.live && bi === lastTurnIndex ? streamingText : undefined}
|
||
onAllowAnyway={onAllowAnyway}
|
||
key={bi}
|
||
/>
|
||
);
|
||
const { item } = block;
|
||
switch (item.kind) {
|
||
case "connector":
|
||
return <ConnectorMessageCard source={item.source} key={bi} />;
|
||
case "user":
|
||
return (
|
||
<div className="group self-end max-w-[78%] flex flex-col items-end" key={bi}>
|
||
<div className="bubble-user px-3.5 py-2.5 rounded-[14px_14px_4px_14px] bg-solid text-onSolid text-[14.5px] leading-relaxed whitespace-pre-wrap">
|
||
{item.attachments && item.attachments.length > 0 && (
|
||
<div className="bubble-attachments">
|
||
{item.attachments.map((a, i) =>
|
||
a.kind === "image" ? (
|
||
<img key={i} className="msg-img" src={a.data_url} alt={a.name} />
|
||
) : (
|
||
<span key={i} className="msg-file">📄 {a.name}</span>
|
||
),
|
||
)}
|
||
</div>
|
||
)}
|
||
<ClampedUserText text={item.text} />
|
||
</div>
|
||
<BubbleMeta text={item.text} ts={item.ts} align="right" />
|
||
</div>
|
||
);
|
||
case "assistant":
|
||
// Thinking-only item (stopped mid-reasoning): just the disclosure, no bubble.
|
||
if (!item.text && item.reasoning)
|
||
return (
|
||
<div key={bi}>
|
||
<ThinkingBlock text={item.reasoning} />
|
||
</div>
|
||
);
|
||
return (
|
||
<div className="group bubble-assistant" key={bi}>
|
||
<div className="who">assistant</div>
|
||
{item.reasoning && <ThinkingBlock text={item.reasoning} />}
|
||
<Markdown text={item.text} />
|
||
<BubbleMeta text={item.text} ts={item.ts} align="left" />
|
||
</div>
|
||
);
|
||
case "dirreq":
|
||
if (!item.resolved) return null;
|
||
return (
|
||
<div className="approval-inline" key={bi}>
|
||
<span className={"status " + (item.resolved === "granted" ? "ok" : "denied")}>
|
||
{item.resolved === "granted" ? "✓" : "✕"}
|
||
</span>
|
||
<span>{item.resolved === "granted" ? "Granted folder access" : "Declined folder access"}</span>
|
||
{item.path && <span className="dim">{item.path}</span>}
|
||
</div>
|
||
);
|
||
case "planreq":
|
||
if (!item.resolved) return null; // pending plan renders in the composer head
|
||
return (
|
||
<div className="bubble-assistant" key={bi}>
|
||
<div className="who">proposed plan</div>
|
||
<Markdown text={item.plan} />
|
||
<div className="approval-inline">
|
||
<span className={"status " + (item.resolved === "approved" ? "ok" : "denied")}>
|
||
{item.resolved === "approved" ? "✓" : "✕"}
|
||
</span>
|
||
<span>{item.resolved === "approved" ? "Plan approved" : "Sent back with feedback"}</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
case "notice":
|
||
return (
|
||
<div className={"notice " + (item.tone === "warn" ? "warn" : "")} key={bi}>
|
||
{item.text}
|
||
{item.retriable && !running && onRetry && block.i === retryAnchor(items) && (
|
||
<button className="btn ml-2" data-testid="notice-retry" onClick={onRetry}>
|
||
Retry
|
||
</button>
|
||
)}
|
||
</div>
|
||
);
|
||
// §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 (
|
||
<div
|
||
className="notice flex items-center gap-2 text-left"
|
||
data-testid="memory-notice"
|
||
key={bi}
|
||
>
|
||
{item.undone ? (
|
||
<span data-testid="memory-notice-undone">
|
||
{item.previous ? "Okay — put back the way it was." : "Okay — forgotten."}
|
||
</span>
|
||
) : (
|
||
<>
|
||
<span className="min-w-0">
|
||
<span className="font-medium">
|
||
{item.previous ? "I've updated what I remember" : "I'll remember that"}
|
||
</span>
|
||
{item.text ? <span className="text-muted"> — {item.text}</span> : null}
|
||
</span>
|
||
{onUndoMemory && (
|
||
<button
|
||
className="btn ml-auto shrink-0"
|
||
data-testid="memory-notice-undo"
|
||
onClick={() => onUndoMemory(item.id, item.previous)}
|
||
>
|
||
Undo
|
||
</button>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
default:
|
||
return null;
|
||
}
|
||
})}
|
||
</div>
|
||
);
|
||
}
|