From 969b2f376293944c834f8f7cbc3c64d980bd20a2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Andr=C3=A9=20Padez?= Date: Fri, 7 Aug 2026 05:10:22 +0000 Subject: [PATCH] show compaction as it happens and open tool calls while they run MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/apps/officer-web/styles/prose.css | 74 +++++- src/servers/api/chat/claude-sessions.ts | 22 +- src/servers/api/chat/types.ts | 18 +- src/servers/sidecar/claude/claude-manager.ts | 35 ++- .../sidecar/claude/stream-parser.test.ts | 19 ++ src/servers/sidecar/claude/stream-parser.ts | 9 + .../sidecar/claude/turn-stream.test.ts | 13 + src/servers/sidecar/claude/turn-stream.ts | 18 ++ .../Chat/components/BackgroundTaskTray.tsx | 14 +- .../src/apps/Chat/components/CodeBlock.tsx | 71 ++--- .../src/apps/Chat/components/CodeSurface.tsx | 183 +++++++++++++ .../apps/Chat/components/MessageBubble.tsx | 74 +++++- .../src/apps/Chat/components/ToolActivity.tsx | 248 ++++++++++++------ .../officerdev/src/apps/Chat/types.ts | 16 +- .../officerdev/src/hooks/useChat.ts | 27 ++ 15 files changed, 694 insertions(+), 147 deletions(-) create mode 100644 src/workspaces/officerdev/src/apps/Chat/components/CodeSurface.tsx diff --git a/src/apps/officer-web/styles/prose.css b/src/apps/officer-web/styles/prose.css index 31797bdf..821b9a42 100644 --- a/src/apps/officer-web/styles/prose.css +++ b/src/apps/officer-web/styles/prose.css @@ -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; diff --git a/src/servers/api/chat/claude-sessions.ts b/src/servers/api/chat/claude-sessions.ts index 8d08745b..cded3340 100644 --- a/src/servers/api/chat/claude-sessions.ts +++ b/src/servers/api/chat/claude-sessions.ts @@ -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)); diff --git a/src/servers/api/chat/types.ts b/src/servers/api/chat/types.ts index 405db5d6..c125186d 100644 --- a/src/servers/api/chat/types.ts +++ b/src/servers/api/chat/types.ts @@ -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 & { 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; diff --git a/src/servers/sidecar/claude/claude-manager.ts b/src/servers/sidecar/claude/claude-manager.ts index d2259619..27a97cd5 100644 --- a/src/servers/sidecar/claude/claude-manager.ts +++ b/src/servers/sidecar/claude/claude-manager.ts @@ -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; }; @@ -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); diff --git a/src/servers/sidecar/claude/stream-parser.test.ts b/src/servers/sidecar/claude/stream-parser.test.ts index f2d78e16..24a7266d 100644 --- a/src/servers/sidecar/claude/stream-parser.test.ts +++ b/src/servers/sidecar/claude/stream-parser.test.ts @@ -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'); diff --git a/src/servers/sidecar/claude/stream-parser.ts b/src/servers/sidecar/claude/stream-parser.ts index fbf04fd9..6588aa1f 100644 --- a/src/servers/sidecar/claude/stream-parser.ts +++ b/src/servers/sidecar/claude/stream-parser.ts @@ -163,6 +163,15 @@ function handleSystem(msg: Record, 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; + callbacks.onEvent({ + type: 'compact:done', + trigger: meta.trigger === 'manual' ? 'manual' : 'auto', + preTokens: typeof meta.pre_tokens === 'number' ? meta.pre_tokens : 0, + }); } } diff --git a/src/servers/sidecar/claude/turn-stream.test.ts b/src/servers/sidecar/claude/turn-stream.test.ts index 905d1a6c..536b4c8d 100644 --- a/src/servers/sidecar/claude/turn-stream.test.ts +++ b/src/servers/sidecar/claude/turn-stream.test.ts @@ -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'); diff --git a/src/servers/sidecar/claude/turn-stream.ts b/src/servers/sidecar/claude/turn-stream.ts index b3813372..820b117f 100644 --- a/src/servers/sidecar/claude/turn-stream.ts +++ b/src/servers/sidecar/claude/turn-stream.ts @@ -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, + }, + ]; } } diff --git a/src/workspaces/officerdev/src/apps/Chat/components/BackgroundTaskTray.tsx b/src/workspaces/officerdev/src/apps/Chat/components/BackgroundTaskTray.tsx index e08aafb7..c0c00ae9 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/BackgroundTaskTray.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/BackgroundTaskTray.tsx @@ -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 ; if (detail.kind === 'log') { + if (!detail.text) return ; return ( <> {detail.truncated &&
… earlier output trimmed
} - {/* The terminal look is deliberate and stays; break-words rather than break-all, which split - words mid-character and made paths unreadable. */} -
-          {detail.text || '(no output yet)'}
-        
+ {/* 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. */} + ); } diff --git a/src/workspaces/officerdev/src/apps/Chat/components/CodeBlock.tsx b/src/workspaces/officerdev/src/apps/Chat/components/CodeBlock.tsx index 867c5049..44ac7b3b 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/CodeBlock.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/CodeBlock.tsx @@ -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 `
` 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
- * `` 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 `
` that arrived without a `` inside — markdown does not produce one, but raw html
+ * surviving the sanitiser might.
  */
-export const CodeBlock = ({ children, ...props }: ComponentPropsWithoutRef<'pre'>) => {
-  const ref = useRef(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 (
-    
-
-        {children}
-      
- -
- ); +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 ; }; diff --git a/src/workspaces/officerdev/src/apps/Chat/components/CodeSurface.tsx b/src/workspaces/officerdev/src/apps/Chat/components/CodeSurface.tsx new file mode 100644 index 00000000..8dfa0d34 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Chat/components/CodeSurface.tsx @@ -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 `
`.
+ */
+
+/**
+ * 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 `
` that arrived without a `` inside it. */
+  fallback?: ReactNode;
+};
+
+export const CodeSurface = ({ code, lang = '', startLine = 1, maxLines, numbered, fallback }: CodeSurfaceProps) => {
+  const [copied, setCopied] = useState(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 (
+    
+ {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. +
+ {lang && ( + + {lang} + + )} + + +
+ )} + + {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. +
+          {code ? (
+            
+              {shown.map((line, i) => (
+                
+                  {i < shown.length - 1 ? `${line}\n` : line}
+                
+              ))}
+            
+          ) : (
+            fallback
+          )}
+        
+ )} + + {hidden > 0 && ( + + )} +
+ ); +}; diff --git a/src/workspaces/officerdev/src/apps/Chat/components/MessageBubble.tsx b/src/workspaces/officerdev/src/apps/Chat/components/MessageBubble.tsx index dc3c642f..4b7f102e 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/MessageBubble.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/MessageBubble.tsx @@ -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 ; } - return ; + return ; case 'result': return ( @@ -214,6 +228,9 @@ export const MessageBubble = ({ message, onAnswer, onRetry }: MessageBubbleProps case 'task': return ; + case 'compact': + return ; + case 'interrupted': return (
@@ -294,6 +311,55 @@ const TaskActivity = ({ message }: { message: Extract { + 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 }) => { + 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 ( +
+
+ + + {done ? `Context compacted${trigger === 'manual' ? '' : ' automatically'}` : 'Compacting the conversation…'} + {detail && · {detail}} + +
+
+ ); +}; + /** * 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 diff --git a/src/workspaces/officerdev/src/apps/Chat/components/ToolActivity.tsx b/src/workspaces/officerdev/src/apps/Chat/components/ToolActivity.tsx index 3761e02c..89be793a 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/ToolActivity.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/ToolActivity.tsx @@ -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; 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(); + +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 = { Read: FileText, Edit: Pencil, @@ -136,13 +179,67 @@ function copyableInput(toolName: string, toolInput: Record): 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 = { 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(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 (
+ {/* 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. */} - )} - +
+ +
); }; diff --git a/src/workspaces/officerdev/src/apps/Chat/types.ts b/src/workspaces/officerdev/src/apps/Chat/types.ts index af242bb3..0d379b75 100644 --- a/src/workspaces/officerdev/src/apps/Chat/types.ts +++ b/src/workspaces/officerdev/src/apps/Chat/types.ts @@ -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; diff --git a/src/workspaces/officerdev/src/hooks/useChat.ts b/src/workspaces/officerdev/src/hooks/useChat.ts index eb741a14..8b8cd022 100644 --- a/src/workspaces/officerdev/src/hooks/useChat.ts +++ b/src/workspaces/officerdev/src/hooks/useChat.ts @@ -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