show compaction as it happens and open tool calls while they run

Compaction was the one thing the harness does that emitted nothing at all while it
ran, and it can run for minutes — silence that reads as a hung turn, which costs a
server restart to discover it wasn't. The sidecar now reports both ends: the start
from the PreCompact hook, the finish from the compact_boundary message with the
token count, both durable so a reload or a reconnect still sees them.

Tool rows open themselves while they run and hold for five seconds after their
result, so the inputs are on screen at the moment the call is made rather than
after the fact. The clock lives outside React, keyed by tool call id: rows are
virtualised, so unmounting is not the call ending, and a fast call can render its
start and its result together — a row that only opens when it catches the pending
state never opens for exactly the quickest calls. A click outranks the clock for
as long as the row lives. A failure behaves identically and differs only in colour,
so it stays findable by scanning and nameable in conversation.

Shell logs move off the green-on-black pre onto the shared code surface, which is
the one block that had no copy button and the one you most often want to hand to
someone else.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-07 05:10:22 +00:00
co-authored by Claude Opus 5
parent ce7968ac90
commit 969b2f3762
15 changed files with 694 additions and 147 deletions
+62 -12
View File
@@ -130,18 +130,6 @@
font-family: ui-monospace, monospace;
}
/* A fenced block is wrapped so its copy button has something to position against. The wrapper carries
the spacing, or the pre's own margin would collapse through it and defeat the first/last-child reset. */
.chat-md .chat-code {
margin: 0.75em 0;
}
.chat-md .chat-code pre {
margin: 0;
/* Room for the copy button, so a long first line scrolls up to it rather than under it. */
padding-right: 2.75em;
}
.chat-md pre {
margin: 0.75em 0;
padding: 0.75em 1em;
@@ -167,6 +155,68 @@
margin: 1.5em 0;
}
/* ── The shared code surface (`CodeSurface`) ──
Deliberately NOT scoped under `.chat-md`: a tool call renders code too, and it lives outside the prose
container. These rules come after the `.chat-md pre` block above so they win the specificity tie when a
surface does sit inside a chat reply. */
/* Vertical rhythm belongs to the prose that surrounds a block, not to the block. Inside a tool panel the
surface is already boxed and spaced by its container, and a leaked 0.75em reads as a gap nobody chose. */
.chat-md .code-surface {
margin: 0.75em 0;
}
.code-surface pre {
margin: 0;
padding: 0.75em 1em;
/* Room for the two copy buttons, so a long first line scrolls up to them rather than under them. */
padding-right: 5.5em;
border-radius: 0.5rem;
background: #0d1117;
/* Scroll, never wrap: wrapping folds `ls -la`, `git log --oneline` and any other columnar output into
mush, and it breaks the one-line-per-number contract the gutter makes. */
overflow-x: auto;
white-space: pre;
font-size: 0.8125rem;
line-height: 1.6;
}
/* A block that named a language also shows the name, which needs its own room. */
.code-surface[data-lang] pre {
padding-right: 10em;
}
.code-surface pre code {
padding: 0;
border-radius: 0;
background: none;
color: #e6edf3;
font-size: inherit;
font-family: ui-monospace, monospace;
}
/* Line numbers come from a counter rather than from markup, because generated content is not part of
`textContent` — so the copy buttons, and a hand-made selection, still yield the code and nothing else.
Both branches of the block emit `.line` spans, so the numbers survive the swap to highlighted output.
`--line-offset` seeds the counter: a `Read` of lines 400-460 has to say 400, not 1.
They scroll with a long line rather than sticking to the left edge; a sticky gutter would need an
opaque background, and that means hardcoding a colour the theme is entitled to change. */
.code-surface[data-numbered] code {
counter-reset: line var(--line-offset, 0);
}
.code-surface[data-numbered] .line::before {
counter-increment: line;
content: counter(line);
display: inline-block;
width: calc(var(--line-digits, 2) * 1ch);
margin-right: 1.25em;
text-align: right;
color: #6e7681;
user-select: none;
-webkit-user-select: none;
}
.chat-md table {
width: 100%;
border-collapse: collapse;
+21 -1
View File
@@ -104,6 +104,10 @@ type Entry = {
summary?: string;
message?: { role?: string; content?: unknown };
isMeta?: boolean;
/** `system` entries carry their kind here — `compact_boundary` is the one this reader cares about. */
subtype?: string;
/** Present on a `compact_boundary`. camelCase on disk; the live SDK stream uses snake_case. */
compactMetadata?: { trigger?: string; preTokens?: number; durationMs?: number };
};
/**
@@ -346,7 +350,11 @@ export type ClaudeChatMessage =
// its memory.
| { role: 'divider'; sessionId: string }
/** A turn you stopped. See `INTERRUPTION_MARKERS`. */
| { role: 'interrupted' };
| { role: 'interrupted' }
// Where the agent rewrote its own context. Kept for the same reason as `divider`: the conversation
// above it is still yours to read, and the agent's memory of it is a summary. It is also the answer to
// "why did it go quiet for two minutes there", which is only useful if it survives a reload.
| { role: 'compact'; trigger: 'manual' | 'auto'; preTokens?: number; durationMs?: number; done: true };
/**
* Claude records an interrupted turn by writing one of these as the *user's* next message — it is how the
@@ -408,6 +416,18 @@ function parseClaudeTranscript(filePath: string, sessionId: string, fallbackCwd
const content = entry.message?.content;
if (entry.type === 'system' && entry.subtype === 'compact_boundary') {
const meta = entry.compactMetadata ?? {};
messages.push({
role: 'compact',
trigger: meta.trigger === 'manual' ? 'manual' : 'auto',
preTokens: meta.preTokens,
durationMs: meta.durationMs,
done: true,
});
continue;
}
if (entry.type === 'user' && !entry.isMeta) {
if (typeof content === 'string') {
if (content.trim()) messages.push(userOrInterruption(content));
+15 -3
View File
@@ -177,7 +177,13 @@ export type ServerMessage =
type: 'disconnected';
}
| { type: 'task:started'; taskId: string; description: string; taskType?: string }
| { type: 'task:notification'; taskId: string; status: 'completed' | 'failed' | 'stopped'; summary: string };
| { type: 'task:notification'; taskId: string; status: 'completed' | 'failed' | 'stopped'; summary: string }
// Context compaction, start and end. Worth its own pair of messages because compaction is the one thing
// the harness does that produces no output at all while it runs — and it can run for minutes. Without
// these the stream simply stops, which is indistinguishable from a hung turn, a dead socket or a
// crashed agent; the honest response to that is to restart the server, which is what actually happened.
| { type: 'compact:start'; trigger: 'manual' | 'auto' }
| { type: 'compact:done'; trigger: 'manual' | 'auto'; preTokens: number; durationMs?: number };
// The turn-output subset of ServerMessage — everything the agent sidecar produces on its own. The
// remaining members (session:init, sync:messages, disconnected, connection-level errors) are officer's:
@@ -196,7 +202,9 @@ export type TurnMessageType =
| 'error'
| 'stopped'
| 'task:started'
| 'task:notification';
| 'task:notification'
| 'compact:start'
| 'compact:done';
export type TurnMessage = Extract<ServerMessage, { type: TurnMessageType }> & { prevSeq?: number };
@@ -225,7 +233,11 @@ export type ChatEvent =
// Background-task lifecycle (run_in_background / Monitor), delivered in-stream by the persistent
// session — including AFTER the turn's `result`, which is the whole point of the persistent worker.
| { type: 'task:started'; taskId: string; description: string; taskType?: string }
| { type: 'task:notification'; taskId: string; status: 'completed' | 'failed' | 'stopped'; summary: string };
| { type: 'task:notification'; taskId: string; status: 'completed' | 'failed' | 'stopped'; summary: string }
// Compaction, start and end. The start half has no message in the harness's output stream at all — it
// comes from the `PreCompact` hook, which is why it is a sidecar concern and not the parser's.
| { type: 'compact:start'; trigger: 'manual' | 'auto' }
| { type: 'compact:done'; trigger: 'manual' | 'auto'; preTokens: number; durationMs?: number };
export type UserSession = {
sessionId: string;
+34 -1
View File
@@ -179,6 +179,12 @@ type PersistentSession = {
* side that called `interrupt()` knows better, so it says so here.
*/
interrupted: boolean;
/**
* When the `PreCompact` hook fired, so the `compact_boundary` that closes it can carry how long the
* silence lasted. The harness reports the boundary but not the duration, and the duration is the part
* that explains the wait.
*/
compactStartedAt?: number;
idleTimer?: ReturnType<typeof setTimeout>;
};
@@ -259,6 +265,25 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true,
includePartialMessages: true,
// The only warning that compaction is about to happen. Everything else the harness does narrates
// itself through the message stream; compaction goes silent for as long as it takes — 2.5 minutes
// in the worst case on disk here — and the stream resumes with no explanation of the gap. The hook
// returns immediately and never throws: it is a notification, and it must not be able to stall or
// fail the compaction it is announcing.
hooks: {
PreCompact: [
{
hooks: [
async (input) => {
session.compactStartedAt = Date.now();
const trigger = 'trigger' in input && input.trigger === 'manual' ? 'manual' : 'auto';
session.emit({ type: 'compact:start', trigger });
return { continue: true };
},
],
},
],
},
abortController: abort,
pathToClaudeCodeExecutable: CLAUDE_BIN,
settingSources: ['user', 'project', 'local'],
@@ -304,7 +329,15 @@ function createSession(params: ClaudeSpawnStreamingParams, onEvent: (event: Chat
const state = createParseState();
const emit = (raw: ChatEvent) => {
// A turn we interrupted ends in a failed `result`. That is the stop landing, not a fault.
const event: ChatEvent = raw.type === 'error' && session.interrupted ? { type: 'stopped' } : raw;
const event: ChatEvent =
raw.type === 'error' && session.interrupted
? { type: 'stopped' }
: // The boundary knows what it dropped; only this side knows how long it took, because the start
// came from a hook rather than from the stream.
raw.type === 'compact:done' && session.compactStartedAt
? { ...raw, durationMs: Date.now() - session.compactStartedAt }
: raw;
if (event.type === 'compact:done') session.compactStartedAt = undefined;
if (event.type === 'task:started') {
// Work is running — hold off idle-GC until it finishes.
session.pendingTasks.add(event.taskId);
@@ -150,6 +150,25 @@ describe('processLine', () => {
expect(sessionIds).toEqual(['sess_abc123']);
});
test('handles compact_boundary — emits compact:done with what it dropped', () => {
const state = createParseState();
const { events, callbacks } = makeCallbacks();
const line = JSON.stringify({
type: 'system',
subtype: 'compact_boundary',
compact_metadata: { trigger: 'auto', pre_tokens: 168215 },
});
processLine(line, state, callbacks);
expect(events).toEqual([{ type: 'compact:done', trigger: 'auto', preTokens: 168215 }]);
});
test('handles compact_boundary with no metadata — still announces the boundary', () => {
const state = createParseState();
const { events, callbacks } = makeCallbacks();
processLine(JSON.stringify({ type: 'system', subtype: 'compact_boundary' }), state, callbacks);
expect(events).toEqual([{ type: 'compact:done', trigger: 'auto', preTokens: 0 }]);
});
test('handles result — sets gotResult, emits cost', () => {
const state = createParseState();
state.textBuffers.set('', 'trailing');
@@ -163,6 +163,15 @@ function handleSystem(msg: Record<string, unknown>, callbacks: StreamParserCallb
status: (msg.status as 'completed' | 'failed' | 'stopped') ?? 'completed',
summary: (msg.summary as string) ?? '',
});
} else if (subtype === 'compact_boundary') {
// The harness rewrote its own context. This lands *after* the work — the matching start comes from
// the `PreCompact` hook — and is the only place the size of what was dropped is reported.
const meta = (msg.compact_metadata ?? {}) as Record<string, unknown>;
callbacks.onEvent({
type: 'compact:done',
trigger: meta.trigger === 'manual' ? 'manual' : 'auto',
preTokens: typeof meta.pre_tokens === 'number' ? meta.pre_tokens : 0,
});
}
}
@@ -136,6 +136,19 @@ describe('createTurnStream', () => {
]);
});
test('compaction flushes the interrupted sentence before announcing itself', () => {
const { durable } = run([
{ type: 'delta', text: 'mid-thought' },
{ type: 'compact:start', trigger: 'auto' },
{ type: 'compact:done', trigger: 'auto', preTokens: 168215, durationMs: 154054 },
]);
expect(durable).toEqual([
{ type: 'assistant:text', text: 'mid-thought' },
{ type: 'compact:start', trigger: 'auto' },
{ type: 'compact:done', trigger: 'auto', preTokens: 168215, durationMs: 154054 },
]);
});
test('streams are independent', () => {
const a = createTurnStream('a');
const b = createTurnStream('b');
+18
View File
@@ -121,6 +121,24 @@ export function createTurnStream(sessionId: string): TurnStream {
durable: true,
},
];
case 'compact:start':
// Flushed first: whatever the agent had said before compaction began is finished text, and the
// notice belongs after it rather than in the middle of a paragraph it interrupted.
return [...flushAll(), { msg: { type: 'compact:start', trigger: event.trigger }, durable: true }];
case 'compact:done':
return [
{
msg: {
type: 'compact:done',
trigger: event.trigger,
preTokens: event.preTokens,
durationMs: event.durationMs,
},
durable: true,
},
];
}
}
@@ -5,6 +5,7 @@ import { toneText } from '@/components/Data';
import type { ChatMessage } from '../types';
import { useBackgroundTasks, useTaskDetail, type BackgroundTask } from '../useBackgroundTasks';
import { SubagentTrace } from './ToolActivity';
import { CodeSurface } from './CodeSurface';
type BackgroundTaskTrayProps = {
messages: ChatMessage[];
@@ -179,14 +180,17 @@ const TaskBody = ({ detail, isLoading }: TaskBodyProps) => {
if (!detail || detail.kind === 'pending') return <Placeholder text="Nothing written yet." />;
if (detail.kind === 'log') {
if (!detail.text) return <Placeholder text="(no output yet)" />;
return (
<>
{detail.truncated && <div className="mb-1 text-xs text-muted-foreground"> earlier output trimmed</div>}
{/* The terminal look is deliberate and stays; break-words rather than break-all, which split
words mid-character and made paths unreadable. */}
<pre className="whitespace-pre-wrap break-words rounded bg-gray-900 p-2 font-mono text-xs text-green-400">
{detail.text || '(no output yet)'}
</pre>
{/* The green-on-black terminal costume this used to wear was the last of its kind in the chat, and
it bought nothing: a shell log is the one thing you most often want to hand to someone else, and
it was the one block with no copy button. On the shared surface it gets both copy modes.
Unnumbered on purpose — this is a live tail, and `truncated` means it can start mid-file. A
gutter counting from 1 would be stating a line number that is not the line's number. */}
<CodeSurface code={detail.text} numbered={false} />
</>
);
}
@@ -1,54 +1,31 @@
import type { ComponentPropsWithoutRef } from 'react';
import { useRef, useState } from 'react';
import { Copy, Check } from 'lucide-react';
import type { ExtraProps } from 'react-markdown';
import { CodeSurface } from './CodeSurface';
/**
* A fenced code block with its own copy button.
* The `<pre>` react-markdown produces for a fenced block, on the shared code surface.
*
* The bubble already had one, but it copies the entire reply. When the reply is prose ending in a
* command you are meant to run, that is the wrong unit — you end up selecting the line by hand, which is
* exactly the thing a command in a chat exists to save you from.
*
* The text comes from the rendered DOM at click time rather than the markdown AST: `children` here is a
* `<code>` element whose own children are strings, elements or nested arrays depending on which plugins
* ran, and reassembling that is guesswork. `textContent` is precisely what is on screen. Only fenced
* blocks get a button — inline code is short enough to select, and a button per `` `word` `` would be
* noise.
* This file is only the adapter: pull the source and the language back out of the hast node, and hand
* them to `CodeSurface`, which is also what a tool call renders into. `children` is kept as the fallback
* for a `<pre>` that arrived without a `<code>` inside — markdown does not produce one, but raw html
* surviving the sanitiser might.
*/
export const CodeBlock = ({ children, ...props }: ComponentPropsWithoutRef<'pre'>) => {
const ref = useRef<HTMLPreElement>(null);
const [copied, setCopied] = useState(false);
const handleCopy = async () => {
// The trailing newline is part of the fence, not the command — pasting it into a shell runs it.
const text = ref.current?.textContent?.replace(/\n+$/, '') ?? '';
if (!text) return;
try {
await navigator.clipboard.writeText(text);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch {
/* no clipboard permission — the text is still selectable */
}
};
const getNodeText = (node: unknown): string => {
const n = node as { value?: string; children?: unknown[] } | undefined;
if (!n) return '';
if (typeof n.value === 'string') return n.value;
return (n.children ?? []).map(getNodeText).join('');
};
return (
<div className="chat-code relative">
<pre ref={ref} {...props}>
{children}
</pre>
<button
type="button"
onClick={handleCopy}
title="Copy code"
aria-label="Copy code"
// Always visible, not hover-revealed: a control you have to discover by waving the pointer at it
// is a control most people never find, and on touch there is no hover to find it with at all.
// Colours are hardcoded light-on-dark because the block behind it is #0d1117 in both themes.
className="absolute top-1.5 right-1.5 cursor-pointer rounded border border-white/15 bg-white/10 p-1 text-white/70 transition-colors hover:border-white/30 hover:bg-white/20 hover:text-white"
>
{copied ? <Check className="h-3.5 w-3.5 text-green-400" /> : <Copy className="h-3.5 w-3.5" />}
</button>
</div>
);
function readFence(node: ExtraProps['node']): { code: string; lang: string } {
const child = node?.children[0];
if (!child || child.type !== 'element' || child.tagName !== 'code') return { code: '', lang: '' };
const classes = (child.properties?.className ?? []) as string[];
const lang = classes.find((c) => c.startsWith('language-'))?.slice('language-'.length) ?? '';
return { code: getNodeText(child).replace(/\n$/, ''), lang };
}
export const CodeBlock = ({ children, node }: React.ComponentPropsWithoutRef<'pre'> & ExtraProps) => {
const { code, lang } = readFence(node);
return <CodeSurface code={code} lang={lang} fallback={children} />;
};
@@ -0,0 +1,183 @@
import type { CSSProperties, ReactNode } from 'react';
import { useEffect, useState } from 'react';
import { Copy, Check } from 'lucide-react';
import { highlight } from '../../FileViewer/renderers/highlight';
/**
* The one way this app draws a block of code: shiki-highlighted when a language is known, line-numbered,
* horizontally scrolled rather than wrapped, with copy-as-code and copy-as-markdown always visible.
*
* It exists as its own component because a chat reply and a tool call show the same thing — a slab of
* monospace you want to read and then take somewhere — and they had drifted into two different surfaces,
* one of them green-on-black. Anything that renders code should reach for this rather than a bare `<pre>`.
*/
/**
* Re-fence the code so it survives being pasted somewhere that renders markdown — a chat to a colleague,
* an issue, a PR description. The fence has to be longer than the longest backtick run *inside* the code,
* or a snippet that itself contains a fence closes the block early and the rest leaks out as prose.
*/
function asMarkdown(code: string, lang: string): string {
const longestRun = Math.max(0, ...[...code.matchAll(/`+/g)].map((m) => m[0].length));
const fence = '`'.repeat(Math.max(3, longestRun + 1));
return `${fence}${lang}\n${code}\n${fence}`;
}
// Long enough to sit out the gap between streamed chunks, short enough to feel immediate once a message
// lands. Highlighting every chunk would re-tokenise the whole block each time — quadratic in its length,
// for colours nobody reads on text that is still arriving.
const SETTLE_MS = 120;
// Colours are hardcoded light-on-dark because the block behind these sits at #0d1117 in both themes.
// The blur is not decoration: once the controls became sticky they sit over moving text for the whole
// height of a long block, and a 10%-white pill with code sliding under it is unreadable.
const CONTROL =
'flex h-[22px] min-w-[22px] cursor-pointer items-center justify-center rounded border border-white/15 ' +
'bg-white/10 px-1 text-white/70 backdrop-blur-sm transition-colors hover:border-white/30 hover:bg-white/20 ' +
'hover:text-white';
type CopyMode = 'raw' | 'md';
type CodeSurfaceProps = {
code: string;
lang?: string;
/** The real number of the first line. `Read` reports a slice of a file, not the whole of it. */
startLine?: number;
/** Collapse to this many lines behind a show-more. Copy still yields the whole thing. */
maxLines?: number;
/** Off for content that only looks like code — a `key: value` dump has no lines worth citing. */
numbered?: boolean;
/** Shown when there is no code at all, for a `<pre>` that arrived without a `<code>` inside it. */
fallback?: ReactNode;
};
export const CodeSurface = ({ code, lang = '', startLine = 1, maxLines, numbered, fallback }: CodeSurfaceProps) => {
const [copied, setCopied] = useState<CopyMode | null>(null);
const [expanded, setExpanded] = useState(false);
// Keyed by the source it was produced from. While a block is still streaming the stored html belongs to
// a shorter version of the code, and showing it would make the block look like it had stopped growing.
const [rendered, setRendered] = useState<{ code: string; html: string } | null>(null);
const allLines = code ? code.split('\n') : [];
const hidden = maxLines ? allLines.length - maxLines : 0;
const shown = hidden > 0 && !expanded ? allLines.slice(0, maxLines) : allLines;
const visible = shown.join('\n');
useEffect(() => {
if (!lang || !visible) return;
let cancelled = false;
const timer = setTimeout(() => {
highlight(visible, lang).then((html) => {
// A fence naming something shiki has no grammar for resolves null; the plain block is the answer.
if (!cancelled && html) setRendered({ code: visible, html });
});
}, SETTLE_MS);
return () => {
cancelled = true;
clearTimeout(timer);
};
}, [visible, lang]);
const html = rendered?.code === visible ? rendered.html : null;
// A lone "1" beside a one-line block is decoration. Numbers earn their gutter once there is something
// to count, which is also exactly when you want to say "line 12" out loud.
const showNumbers = numbered ?? allLines.length > 1;
const digits = Math.max(2, String(startLine + allLines.length).length);
const handleCopy = async (mode: CopyMode) => {
// Always the whole block, never the truncated view — a copy that silently stops at line 20 is worse
// than no copy at all. The trailing newline goes: pasting it into a shell runs the command.
const text = code.replace(/\n+$/, '');
if (!text) return;
try {
await navigator.clipboard.writeText(mode === 'md' ? asMarkdown(text, lang) : text);
setCopied(mode);
setTimeout(() => setCopied(null), 2000);
} catch {
/* no clipboard permission — the text is still selectable */
}
};
return (
<div
className="code-surface"
data-lang={lang || undefined}
data-numbered={showNumbers || undefined}
style={{ '--line-digits': digits, '--line-offset': startLine - 1 } as CSSProperties}
>
{code && (
// Sticky, not absolute, and first in the flow so it has a flow position to stick to. A block
// taller than its scroll container — a live log tail, a 400-line read — used to carry its
// controls off the top edge, so the output you most want to copy was the one whose copy button
// you had to scroll back up to find. `h-0` keeps the row out of the vertical flow: it overlays
// the top of the block rather than pushing it down, which is what the old `absolute` did.
//
// Always visible, not hover-revealed: a control you have to discover by waving the pointer at it
// is a control most people never find, and on touch there is no hover to find it with at all. Two
// buttons rather than one with a modifier — the destination decides which you want (a terminal, or
// someone else's markdown), and a modifier nobody can see is a feature nobody uses.
<div className="sticky top-1.5 z-10 flex h-0 items-start justify-end gap-1.5 pr-1.5">
{lang && (
<span className="rounded px-1 font-mono text-[10px] tracking-wide text-white/35 backdrop-blur-sm select-none">
{lang}
</span>
)}
<button
type="button"
onClick={() => handleCopy('raw')}
title="Copy the code"
aria-label="Copy the code"
className={CONTROL}
>
{copied === 'raw' ? <Check className="h-3.5 w-3.5 text-green-400" /> : <Copy className="h-3.5 w-3.5" />}
</button>
<button
type="button"
onClick={() => handleCopy('md')}
title="Copy as a markdown block"
aria-label="Copy as a markdown block"
className={CONTROL}
>
{copied === 'md' ? (
<Check className="h-3.5 w-3.5 text-green-400" />
) : (
// The label is what it produces. Backtick glyphs sit near cap height, so the nudge is what
// optically centres them in the box — flex centring alone leaves them riding high.
<span className="translate-y-[2px] font-mono text-[11px] leading-none">```</span>
)}
</button>
</div>
)}
{html ? (
<div dangerouslySetInnerHTML={{ __html: html }} />
) : (
// The plain branch mimics shiki's per-line spans so a block is numbered before it is coloured, and
// nothing shifts sideways when the highlight lands.
<pre>
{code ? (
<code>
{shown.map((line, i) => (
<span className="line" key={i}>
{i < shown.length - 1 ? `${line}\n` : line}
</span>
))}
</code>
) : (
fallback
)}
</pre>
)}
{hidden > 0 && (
<button
onClick={() => setExpanded(!expanded)}
className="mt-1 cursor-pointer text-xs text-duck-teal hover:underline"
>
{expanded ? 'Show less' : `Show more (${hidden} more lines)`}
</button>
)}
</div>
);
};
@@ -1,9 +1,21 @@
import { useState, useRef } from 'react';
import { useState, useRef, useEffect } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import rehypeSanitize, { defaultSchema } from 'rehype-sanitize';
import { Volume2, Loader2, Square, Clock, Check, X, CircleSlash, Eraser, PlugZap, RotateCcw } from 'lucide-react';
import {
Volume2,
Loader2,
Square,
Clock,
Check,
X,
CircleSlash,
Eraser,
PlugZap,
RotateCcw,
FoldVertical,
} from 'lucide-react';
import { toast } from '@/components/ui/sonner';
import type { Tone } from '@/components/Data';
import { toneText } from '@/components/Data';
@@ -127,9 +139,11 @@ type MessageBubbleProps = {
message: ChatMessage;
onAnswer?: (text: string) => void;
onRetry?: (prompt: string) => void;
/** Set only for the turn in progress; tool rows use it to open themselves while they run. */
live?: boolean;
};
export const MessageBubble = ({ message, onAnswer, onRetry }: MessageBubbleProps) => {
export const MessageBubble = ({ message, onAnswer, onRetry, live }: MessageBubbleProps) => {
switch (message.role) {
case 'user': {
const rawText = typeof message.text === 'string' ? message.text : '';
@@ -200,7 +214,7 @@ export const MessageBubble = ({ message, onAnswer, onRetry }: MessageBubbleProps
if (message.toolName === 'question' && onAnswer) {
return <QuestionActivity message={message} onAnswer={onAnswer} />;
}
return <ToolActivity message={message} />;
return <ToolActivity message={message} live={live} />;
case 'result':
return (
@@ -214,6 +228,9 @@ export const MessageBubble = ({ message, onAnswer, onRetry }: MessageBubbleProps
case 'task':
return <TaskActivity message={message} />;
case 'compact':
return <CompactActivity message={message} />;
case 'interrupted':
return (
<div className="flex items-center gap-3 py-2 text-muted-foreground select-none">
@@ -294,6 +311,55 @@ const TaskActivity = ({ message }: { message: Extract<ChatMessage, { role: 'task
);
};
const formatDuration = (ms: number): string => {
const total = Math.round(ms / 1000);
return total < 60 ? `${total}s` : `${Math.floor(total / 60)}m ${String(total % 60).padStart(2, '0')}s`;
};
const formatTokens = (n: number): string => (n >= 1000 ? `${Math.round(n / 1000)}k` : String(n));
/**
* Context compaction, start to finish, in one row.
*
* This is the only thing the harness does that emits nothing at all while it runs, and it can run for
* minutes — long enough that the honest read of the silence is "the agent has hung", which costs a server
* restart and a page reload to discover it hadn't. So the row says what is happening *and* counts, because
* a spinner with no number cannot distinguish a slow operation from a stuck one.
*
* The clock is local and only runs while pending: the start is a hook firing on this connection, so the
* elapsed time is honest for the window that watched it happen, and the settled row carries the duration
* the sidecar measured — which is the one a replay in another tab gets.
*/
const CompactActivity = ({ message }: { message: Extract<ChatMessage, { role: 'compact' }> }) => {
const { done, trigger, preTokens, durationMs } = message;
const startedAt = useRef(Date.now());
const [elapsed, setElapsed] = useState(0);
useEffect(() => {
if (done) return;
const timer = setInterval(() => setElapsed(Date.now() - startedAt.current), 1000);
return () => clearInterval(timer);
}, [done]);
const detail = done
? [preTokens ? `${formatTokens(preTokens)} tokens summarised` : '', durationMs ? formatDuration(durationMs) : '']
.filter(Boolean)
.join(' · ')
: `${formatDuration(elapsed)} — the agent produces no output while this runs`;
return (
<div className="flex items-center gap-3 py-2 text-muted-foreground select-none">
<div className="h-px flex-1 bg-border" />
<span className="flex items-center gap-1.5 text-[11px] font-medium tracking-wide uppercase">
<FoldVertical className={`h-3 w-3 ${done ? '' : 'animate-pulse'}`} />
{done ? `Context compacted${trigger === 'manual' ? '' : ' automatically'}` : 'Compacting the conversation…'}
{detail && <span className="normal-case opacity-70 tabular-nums">· {detail}</span>}
</span>
<div className="h-px flex-1 bg-border" />
</div>
);
};
/**
* The wait between pressing send and the first token — tens of seconds with thinking enabled — used to
* render nothing at all, because the streaming bubble bailed on empty text. The transcript looked frozen
@@ -1,16 +1,59 @@
import { useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { FileText, Terminal, Pencil, Search, Globe, Wrench, ChevronRight, Bot } from 'lucide-react';
import type { ChatMessage } from '../types';
import type { DiffLine } from './line-diff';
import { diffStat, editDiff } from './line-diff';
import { CodeSurface } from './CodeSurface';
import { CopyButton } from './CopyButton';
type ToolMessage = Extract<ChatMessage, { role: 'tool' }>;
type ToolActivityProps = {
message: ToolMessage;
/**
* True only for rows in the turn that is happening right now. Read once, at mount: a row that arrives
* while you are watching opens itself, and a row that merely re-mounts later (scrolled back into a
* virtualised list, or reloaded from the transcript) does not. Auto-expansion is a way of watching work
* happen, not a property of the transcript.
*/
live?: boolean;
};
/**
* How long a finished call stays open before it collapses. Long enough to read what came back, short
* enough that a burst of fast calls doesn't leave six panels stacked on screen. Each row runs its own
* clock from its own result, so parallel calls stagger out naturally instead of needing to be sequenced.
*/
const AUTO_HOLD_MS = 5000;
/** Auto-expanded output is a glance, not a read: the cap keeps a 400-line file from taking the viewport. */
const AUTO_MAX_LINES = 10;
const MANUAL_MAX_LINES = 20;
/**
* When each call was first seen and when it settled, keyed by tool call id and held outside React.
*
* Two things force it out of the component. Rows are virtualised, so a component unmounting is not the
* call ending and remounting is not a new call — state inside the row would make a scroll look like a
* fresh tool run. And a fast call (`git status`, `wc`) can have its start and its result render together,
* so a row that only opens when it *catches* the unresolved state never opens at all: the very calls
* quick enough to batch are the ones that appeared not to work.
*
* Recording the moment instead of the transition makes both cases the same case — the row asks how long
* ago this call finished, not whether it happened to be watching when it did.
*/
const seen = new Map<string, { at: number; resolved?: number }>();
function trackCall(id: string, pending: boolean): { at: number; resolved?: number } {
let entry = seen.get(id);
if (!entry) {
entry = { at: Date.now() };
seen.set(id, entry);
}
if (!pending && entry.resolved === undefined) entry.resolved = Date.now();
return entry;
}
const toolIcons: Record<string, typeof FileText> = {
Read: FileText,
Edit: Pencil,
@@ -136,13 +179,67 @@ function copyableInput(toolName: string, toolInput: Record<string, unknown>): st
.join('\n');
}
export const ToolActivity = ({ message }: ToolActivityProps) => {
const [open, setOpen] = useState(false);
/**
* `Read` hands back its own gutter — `␣␣␣123\tcontent` — which is real file line numbers, not a 1-based
* count, and worth keeping. So the gutter is peeled off the text and fed back to the surface as a starting
* number, rather than left in place to be highlighted as if it were code and then joined by a second
* gutter. Anything that isn't overwhelmingly numbered is passed through untouched.
*/
const NUMBERED = /^\s*(\d+)\t/;
function unnumber(output: string): { code: string; startLine: number } {
const lines = output.split('\n');
const numbered = lines.filter((line) => NUMBERED.test(line));
if (!numbered.length || numbered.length < lines.length * 0.8) return { code: output, startLine: 1 };
return {
code: lines.map((line) => line.replace(NUMBERED, '')).join('\n'),
startLine: Number(NUMBERED.exec(numbered[0]!)![1]),
};
}
// Most extensions are already valid shiki ids; only the aliases it does not carry need naming. An id it
// has no grammar for fails soft inside `highlight`, so a miss here costs colour, never the block.
const EXT_LANG: Record<string, string> = { yml: 'yaml', mjs: 'js', cjs: 'js', h: 'c', hpp: 'cpp', htm: 'html' };
function langForPath(path: unknown): string {
if (typeof path !== 'string') return '';
const name = path.split('/').pop() ?? '';
const ext = name.includes('.') ? name.split('.').pop()!.toLowerCase() : '';
return EXT_LANG[ext] ?? ext;
}
export const ToolActivity = ({ message, live }: ToolActivityProps) => {
const Icon = toolIcons[message.toolName] ?? Wrench;
const { head, tail } = splitSummary(getToolSummary(message.toolName, message.toolInput));
const pending = message.output === undefined;
const isError = message.isError === true;
// Read once: the hold has to survive the turn ending under it, because the last call of a turn settles
// at roughly the moment `live` goes false and has as much right to its five seconds as the rest.
const canAuto = useRef(live === true);
// Written during render, deliberately: the first render is where the decision is made, so a cache
// populated in an effect would always be one render too late for the row that needs it most.
const call = canAuto.current ? trackCall(message.toolCallId, pending) : null;
// null until you touch the row, and never reset: your click outranks the clock for as long as the row
// exists. A control that undoes itself two seconds later is worse than no control.
const [override, setOverride] = useState<boolean | null>(null);
const [, retick] = useState(0);
const settledFor = call?.resolved === undefined ? 0 : Date.now() - call.resolved;
const autoOpen = call !== null && (pending || settledFor < AUTO_HOLD_MS);
// One timer per row, for whatever is left of its own hold — which is what lets parallel calls stagger:
// three greps fired together collapse in the order they came back, not all at once.
useEffect(() => {
if (!call || pending || call.resolved === undefined) return;
const left = call.resolved + AUTO_HOLD_MS - Date.now();
if (left <= 0) return;
const timer = setTimeout(() => retick((n) => n + 1), left);
return () => clearTimeout(timer);
}, [call, pending]);
const open = override ?? autoOpen;
const children = message.children ?? [];
const hint = pending || isError ? null : outputHint(message.toolName, message.output ?? '');
@@ -152,14 +249,19 @@ export const ToolActivity = ({ message }: ToolActivityProps) => {
return (
<div className="my-1">
{/* A failure reads as a failure while collapsed. It behaves like every other row — same clock, same
auto-collapse — and differs only in colour, so a call that went wrong stays findable by scanning
rather than by opening things, and stays nameable in conversation without either of us opening it. */}
<button
onClick={() => setOpen(!open)}
className="flex w-full cursor-pointer items-center gap-2 rounded-md px-3 py-1.5 text-left text-sm transition-colors hover:bg-muted"
onClick={() => setOverride(!open)}
className={`flex w-full cursor-pointer items-center gap-2 rounded-md px-3 py-1.5 text-left text-sm transition-colors ${
isError ? 'bg-destructive/5 hover:bg-destructive/10' : 'hover:bg-muted'
}`}
aria-expanded={open}
>
<ChevronRight className={`h-3 w-3 shrink-0 transition-transform ${open ? 'rotate-90' : ''}`} />
<Icon className="h-4 w-4 shrink-0 text-duck-teal" />
<span className="font-medium text-foreground">{message.toolName}</span>
<Icon className={`h-4 w-4 shrink-0 ${isError ? 'text-destructive' : 'text-duck-teal'}`} />
<span className={`font-medium ${isError ? 'text-destructive' : 'text-foreground'}`}>{message.toolName}</span>
<span className="flex min-w-0 flex-1 items-center font-mono text-xs text-muted-foreground">
<span className="min-w-0 truncate">{head}</span>
{tail && <span className="shrink-0">{tail}</span>}
@@ -186,18 +288,19 @@ export const ToolActivity = ({ message }: ToolActivityProps) => {
{open && (
<div className="ml-7 mt-1 space-y-2 text-xs">
<div className="rounded-md bg-muted/50 p-2 overflow-x-auto group/input">
<div className="rounded-md bg-muted/50 p-2">
<div className="flex items-center justify-between mb-1">
<div className="text-xs uppercase tracking-wider text-muted-foreground">{diff ? 'Diff' : 'Input'}</div>
<CopyButton
text={copyableInput(message.toolName, message.toolInput)}
className="!opacity-0 group-hover/input:!opacity-60 hover:!opacity-100"
/>
{/* The diff and the key/value dump keep their own copy control; a `CodeSurface` brings two. */}
{(diff || message.toolName !== 'Bash') && (
<CopyButton
text={copyableInput(message.toolName, message.toolInput)}
className="!opacity-60 hover:!opacity-100"
/>
)}
</div>
{message.toolName === 'Bash' ? (
<pre className="bg-gray-900 text-green-400 p-2 rounded font-mono whitespace-pre-wrap break-words">
{(message.toolInput.command as string) ?? JSON.stringify(message.toolInput, null, 2)}
</pre>
<CodeSurface code={(message.toolInput.command as string) ?? ''} lang="bash" />
) : diff ? (
<DiffView lines={diff} />
) : (
@@ -217,15 +320,17 @@ export const ToolActivity = ({ message }: ToolActivityProps) => {
)}
{message.output !== undefined && (
<div className="rounded-md bg-muted/50 p-2 overflow-x-auto group/output">
<div className="flex items-center justify-between mb-1">
<div className="text-xs uppercase tracking-wider text-muted-foreground">Output</div>
<CopyButton
text={message.output}
className="!opacity-0 group-hover/output:!opacity-60 hover:!opacity-100"
/>
<div className="rounded-md bg-muted/50 p-2">
<div className="mb-1 text-xs uppercase tracking-wider text-muted-foreground">
{isError ? <span className="text-destructive">Output error</span> : 'Output'}
</div>
<ToolOutput toolName={message.toolName} output={message.output} isError={isError} />
<ToolOutput
toolName={message.toolName}
toolInput={message.toolInput}
output={message.output}
isError={isError}
maxLines={override === null ? AUTO_MAX_LINES : MANUAL_MAX_LINES}
/>
</div>
)}
</div>
@@ -254,33 +359,38 @@ const DiffView = ({ lines }: { lines: DiffLine[] }) => {
return (
<>
<div className="overflow-x-auto rounded font-mono">
{shown.map((line, i) => (
<div
key={i}
className={`flex gap-2 px-1 ${
line.kind === 'add' ? 'bg-success/10' : line.kind === 'del' ? 'bg-destructive/10' : ''
}`}
>
<span
className={`w-3 shrink-0 select-none text-center ${
line.kind === 'add'
? 'text-success'
: line.kind === 'del'
? 'text-destructive'
: 'text-muted-foreground'
{/* `min-w-max` sizes this to the longest line so every row is that wide. Without it each row is
only as wide as its own text, and the add/delete tint stops short of the right edge the moment
you scroll — the stripe would end mid-diff and read as a change in meaning. */}
<div className="min-w-max">
{shown.map((line, i) => (
<div
key={i}
className={`flex gap-2 px-1 ${
line.kind === 'add' ? 'bg-success/10' : line.kind === 'del' ? 'bg-destructive/10' : ''
}`}
>
{line.kind === 'add' ? '+' : line.kind === 'del' ? '' : ' '}
</span>
<span
className={`whitespace-pre-wrap break-words ${
line.kind === 'ctx' ? 'text-muted-foreground' : 'text-foreground/90'
}`}
>
{line.text || ' '}
</span>
</div>
))}
<span
className={`w-3 shrink-0 select-none text-center ${
line.kind === 'add'
? 'text-success'
: line.kind === 'del'
? 'text-destructive'
: 'text-muted-foreground'
}`}
>
{line.kind === 'add' ? '+' : line.kind === 'del' ? '' : ' '}
</span>
{/* Scroll, don't wrap: a wrapped continuation line has no gutter marker, so it reads as
context sitting inside an added block. */}
<span
className={`whitespace-pre ${line.kind === 'ctx' ? 'text-muted-foreground' : 'text-foreground/90'}`}
>
{line.text || ' '}
</span>
</div>
))}
</div>
</div>
{hidden > 0 && (
<button
@@ -314,36 +424,28 @@ export const SubagentTrace = ({ messages }: { messages: ChatMessage[] }) => (
type ToolOutputProps = {
toolName: string;
toolInput: Record<string, unknown>;
output: string;
isError: boolean;
maxLines: number;
};
const ToolOutput = ({ toolName, output, isError }: ToolOutputProps) => {
const [expanded, setExpanded] = useState(false);
const maxLines = 20;
const lines = output.split('\n');
const needsTruncation = lines.length > maxLines;
const displayText = expanded ? output : lines.slice(0, maxLines).join('\n');
const isBash = toolName === 'Bash';
/**
* Every tool's output on the same surface the chat's own code blocks use.
*
* It used to be green-on-black for Bash and whole-text red for a failure, both of which trade legibility
* for decoration — the row above already says which call this was and whether it failed, so the output
* itself only has to be readable. A `Read` is the one output we can colour honestly, because the call
* carries the path that names the language.
*/
const ToolOutput = ({ toolName, toolInput, output, isError, maxLines }: ToolOutputProps) => {
const isRead = toolName === 'Read' && !isError;
const { code, startLine } = isRead ? unnumber(output) : { code: output, startLine: 1 };
const lang = isRead ? langForPath(toolInput.file_path) : '';
return (
<>
<pre
className={`font-mono whitespace-pre-wrap break-words p-2 rounded ${
isBash ? 'bg-gray-900 text-green-400' : isError ? 'bg-destructive/10 text-destructive' : 'text-foreground/80'
}`}
>
{displayText}
</pre>
{needsTruncation && (
<button
onClick={() => setExpanded(!expanded)}
className="text-duck-teal hover:underline text-xs mt-1 cursor-pointer"
>
{expanded ? 'Show less' : `Show more (${lines.length - maxLines} more lines)`}
</button>
)}
</>
<div className={isError ? 'border-l-2 border-destructive pl-2' : undefined}>
<CodeSurface code={code} lang={lang} startLine={startLine} maxLines={maxLines} />
</div>
);
};
@@ -58,6 +58,17 @@ export type ChatMessage =
taskType?: string;
status?: 'completed' | 'failed' | 'stopped';
summary?: string;
}
| {
// The harness rewriting its own context, start to finish, in one row — the same shape as `task`,
// for the same reason: it appears the moment compaction begins and resolves in place when it ends.
// Compaction is the one thing that produces no output at all while it runs, so before this row a
// long one looked exactly like a hung turn.
role: 'compact';
trigger: 'manual' | 'auto';
preTokens?: number;
durationMs?: number;
done?: boolean;
};
export type TaskInfo = {
@@ -90,7 +101,10 @@ export type ServerMessage =
| { type: 'cut-off' }
| { type: 'disconnected' }
| { type: 'task:started'; taskId: string; description: string; taskType?: string }
| { type: 'task:notification'; taskId: string; status: 'completed' | 'failed' | 'stopped'; summary: string };
| { type: 'task:notification'; taskId: string; status: 'completed' | 'failed' | 'stopped'; summary: string }
/** Context compaction beginning and ending. See the `compact` message role. */
| { type: 'compact:start'; trigger: 'manual' | 'auto' }
| { type: 'compact:done'; trigger: 'manual' | 'auto'; preTokens: number; durationMs?: number };
export type Message = {
id: string;
@@ -367,6 +367,33 @@ export function useChat(initialSessionId?: string, initialModel?: string | null,
]);
break;
case 'compact:start':
// Committed first: compaction interrupts the agent mid-turn, and the text it had already streamed
// is finished — leaving it in the streaming bubble would park it under the notice until the turn
// ended, minutes later.
commitStreaming();
setMessages((prev) => [...prev, { role: 'compact', trigger: msg.trigger }]);
break;
case 'compact:done':
setMessages((prev) => {
const at = prev.findLastIndex((m) => m.role === 'compact' && !m.done);
const resolved: ChatMessage = {
role: 'compact',
trigger: msg.trigger,
preTokens: msg.preTokens,
durationMs: msg.durationMs,
done: true,
};
// No pending row: a replay whose window starts after `compact:start`, or a harness that
// compacted without firing the hook. The boundary alone still explains the gap.
if (at < 0) return [...prev, resolved];
const next = [...prev];
next[at] = resolved;
return next;
});
break;
case 'task:notification': {
// A background task's completion, arriving after the turn ended — the whole point of the
// persistent worker. Resolved onto the row that announced it rather than appended as a second