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