diff --git a/src/workspaces/officerdev/src/apps/Chat/components/MessageList.tsx b/src/workspaces/officerdev/src/apps/Chat/components/MessageList.tsx index 796ad5f1..a441c3c9 100644 --- a/src/workspaces/officerdev/src/apps/Chat/components/MessageList.tsx +++ b/src/workspaces/officerdev/src/apps/Chat/components/MessageList.tsx @@ -1,24 +1,14 @@ -import { useLayoutEffect, useRef } from 'react'; +import { useLayoutEffect, useMemo, useRef, useState } from 'react'; import { ArrowDown, Loader2 } from 'lucide-react'; import { useVirtualizer } from '@tanstack/react-virtual'; import { measureHeight } from 'helpers/measure-zoomed'; import type { UseEmbeddableChatType } from '../EmbeddableChat/useEmbeddableChat'; -import type { ChatMessage } from '../types'; import { MessageBubble, StreamingBubble } from './MessageBubble'; +import { buildRows } from './turn-rows'; +import { TurnEnd, TurnFold } from './TurnFold'; const OVERSCAN = 5; -// A cheap identity for the first message, to tell a top-prepend (lazy-loaded older window) apart from a -// bottom-append (new turn). Only messages[0] changing while the list grows means older were prepended. -function firstMessageKey(m: ChatMessage | undefined): string { - if (!m) return ''; - if (m.role === 'assistant') return `a:${m.id ?? m.text.slice(0, 40)}`; - if (m.role === 'tool') return `t:${m.toolCallId}`; - if (m.role === 'task') return `k:${m.taskId}`; - if (m.role === 'divider') return `d:${m.sessionId}`; - return `${m.role}:${'text' in m ? m.text.slice(0, 40) : ''}`; -} - type MessageListProps = { manager: UseEmbeddableChatType; }; @@ -36,8 +26,21 @@ export const MessageList = ({ manager }: MessageListProps) => { isLoadingOlder, } = manager; + // The live turn stays open; everything before it is folded. See `turn-rows.ts`. + const rows = useMemo(() => buildRows(messages, isGenerating), [messages, isGenerating]); + + // Which folds you have opened. Lifted out of the fold itself because rows are virtualised: state held + // inside one would be thrown away the moment it scrolled past the overscan window and silently snap shut. + const [openFolds, setOpenFolds] = useState>(() => new Set()); + const toggleFold = (key: string) => + setOpenFolds((prev) => { + const next = new Set(prev); + if (!next.delete(key)) next.add(key); + return next; + }); + const virtualizer = useVirtualizer({ - count: messages.length, + count: rows.length, getScrollElement: () => scrollViewportRef.current, estimateSize: () => 150, overscan: OVERSCAN, @@ -46,18 +49,20 @@ export const MessageList = ({ manager }: MessageListProps) => { measureElement: measureHeight, }); - // Keep the viewport anchored when older messages are prepended: the message that used to be first - // (now shifted down by the number prepended) is pinned back to the top, so the content doesn't jump. - const prevLenRef = useRef(messages.length); + // Keep the viewport anchored when older messages are prepended: the row that used to be first (now + // shifted down by however many rows arrived above it) is pinned back to the top, so content doesn't jump. + // Counted in rows rather than messages, because a prepended turn folds down to fewer rows than it has + // messages and scrolling by the message count would overshoot. + const prevLenRef = useRef(rows.length); const prevFirstKeyRef = useRef(null); useLayoutEffect(() => { - const grew = messages.length - prevLenRef.current; - const firstKey = firstMessageKey(messages[0]); + const grew = rows.length - prevLenRef.current; + const firstKey = rows[0]?.key ?? ''; const prepended = grew > 0 && prevFirstKeyRef.current !== null && firstKey !== prevFirstKeyRef.current; if (prepended) virtualizer.scrollToIndex(grew, { align: 'start' }); - prevLenRef.current = messages.length; + prevLenRef.current = rows.length; prevFirstKeyRef.current = firstKey; - }, [messages, virtualizer]); + }, [rows, virtualizer]); return (
@@ -67,15 +72,15 @@ export const MessageList = ({ manager }: MessageListProps) => {
)}
- {messages.length === 0 && !isGenerating && ( + {rows.length === 0 && !isGenerating && (
Send a message to start
)} - {messages.length > 0 && ( + {rows.length > 0 && (
{virtualizer.getVirtualItems().map((virtualRow) => { - const msg = messages[virtualRow.index]!; + const row = rows[virtualRow.index]!; return (
{ }} >
- sendPrompt(text)} - onRetry={(prompt) => sendPrompt(prompt)} - /> + {row.kind === 'message' && ( + sendPrompt(text)} + onRetry={(prompt) => sendPrompt(prompt)} + // Every unfolded tool row belongs to the live turn by construction — older turns' + // work only ever appears inside a fold — so generating is the whole condition. + live={isGenerating} + /> + )} + {row.kind === 'fold' && ( + toggleFold(row.key)} + /> + )} + {row.kind === 'turn-end' && }
); diff --git a/src/workspaces/officerdev/src/apps/Chat/components/TurnFold.tsx b/src/workspaces/officerdev/src/apps/Chat/components/TurnFold.tsx new file mode 100644 index 00000000..ca4cf725 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Chat/components/TurnFold.tsx @@ -0,0 +1,61 @@ +import { ChevronRight, CheckCircle2 } from 'lucide-react'; +import type { ChatMessage } from '../types'; +import type { FoldCounts } from './turn-rows'; +import { foldLabel } from './turn-rows'; +import { MessageBubble } from './MessageBubble'; + +type TurnFoldProps = { + messages: ChatMessage[]; + counts: FoldCounts; + open: boolean; + onToggle: () => void; +}; + +/** + * One turn's work, collapsed to a line. + * + * The failure count is on the summary rather than only inside, because a red row you have to open to find + * is a red row you never find. It is also the shared handle: "the one where the tsgo call went red" has to + * be sayable without either of us expanding anything. + */ +export const TurnFold = ({ messages, counts, open, onToggle }: TurnFoldProps) => ( +
+ + + {open && ( +
+ {messages.map((message, i) => ( + + ))} +
+ )} +
+); + +/** + * A turn that ended without a closing message. Written as a seam, never as prose — see `turn-rows.ts`. + */ +export const TurnEnd = () => ( +
+
+ + + Turn finished · no closing message + +
+
+); diff --git a/src/workspaces/officerdev/src/apps/Chat/components/turn-rows.test.ts b/src/workspaces/officerdev/src/apps/Chat/components/turn-rows.test.ts new file mode 100644 index 00000000..9647826e --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Chat/components/turn-rows.test.ts @@ -0,0 +1,155 @@ +import { describe, expect, test } from 'bun:test'; +import type { ChatMessage } from '../types'; +import { buildRows, foldLabel } from './turn-rows'; + +const user = (text: string): ChatMessage => ({ role: 'user', text }); +const say = (text: string): ChatMessage => ({ role: 'assistant', id: text, text }); +const tool = (id: string, isError?: boolean): ChatMessage => ({ + role: 'tool', + toolName: 'Bash', + toolInput: {}, + toolCallId: id, + output: 'ok', + ...(isError ? { isError: true } : {}), +}); + +/** The shape under test is the row sequence, not the keys. */ +const shape = (messages: ChatMessage[], isGenerating = false) => + buildRows(messages, isGenerating).map((r) => (r.kind === 'message' ? r.message.role : r.kind)); + +describe('buildRows', () => { + test('the live turn is untouched — every row stays where it was', () => { + expect(shape([user('go'), say('one'), tool('a'), say('two')], true)).toEqual([ + 'user', + 'assistant', + 'tool', + 'assistant', + ]); + }); + + test('an older turn folds to question, summary, answer', () => { + const messages = [user('first'), say('thinking'), tool('a'), tool('b'), say('answer'), user('second')]; + expect(shape(messages)).toEqual(['user', 'fold', 'assistant', 'user']); + }); + + test('the fold counts what it hid, failures included', () => { + const messages = [user('first'), say('note'), tool('a'), tool('b', true), say('answer'), user('second')]; + const fold = buildRows(messages, false).find((r) => r.kind === 'fold'); + expect(fold?.kind === 'fold' && fold.counts).toEqual({ tools: 2, notes: 1, tasks: 0, failed: 1 }); + }); + + test('a fold opens onto exactly the messages it replaced, in order', () => { + const messages = [user('first'), say('note'), tool('a'), say('answer'), user('second')]; + const fold = buildRows(messages, false).find((r) => r.kind === 'fold'); + expect(fold?.kind === 'fold' && fold.messages).toEqual([say('note'), tool('a')]); + }); + + test('a divider is never swallowed by a fold — it splits it', () => { + const messages = [ + user('first'), + tool('a'), + { role: 'divider', sessionId: 's1' } as ChatMessage, + tool('b'), + say('answer'), + user('second'), + ]; + expect(shape(messages)).toEqual(['user', 'fold', 'divider', 'fold', 'assistant', 'user']); + }); + + test('a compaction seam survives folding too', () => { + const messages = [ + user('first'), + tool('a'), + { role: 'compact', trigger: 'auto', done: true } as ChatMessage, + say('answer'), + user('second'), + ]; + expect(shape(messages)).toEqual(['user', 'fold', 'compact', 'assistant', 'user']); + }); + + test('a turn that ends on a tool call is marked finished rather than left hanging', () => { + expect(shape([user('go'), tool('a')], false)).toEqual(['user', 'tool', 'turn-end']); + }); + + test('a turn still running is not marked finished', () => { + expect(shape([user('go'), tool('a')], true)).toEqual(['user', 'tool']); + }); + + test('a turn that failed keeps its own row and gets no marker', () => { + expect(shape([user('go'), tool('a'), { role: 'error', text: 'boom' }], false)).toEqual(['user', 'tool', 'error']); + }); + + test('an interrupted turn gets no marker either', () => { + expect(shape([user('go'), tool('a'), { role: 'interrupted' }], false)).toEqual(['user', 'tool', 'interrupted']); + }); + + test('a cost row alone is not an answer — the marker still appears, above it', () => { + const messages: ChatMessage[] = [ + user('go'), + tool('a'), + { role: 'result', cost: { inputTokens: 1, outputTokens: 1, totalUSD: 0 } }, + ]; + expect(shape(messages, false)).toEqual(['user', 'tool', 'turn-end', 'result']); + }); + + test('an empty assistant block does not count as having answered', () => { + expect(shape([user('go'), tool('a'), say(' ')], false)).toEqual(['user', 'tool', 'turn-end', 'assistant']); + }); + + test('your own message is never folded', () => { + const rows = buildRows([user('first'), tool('a'), say('answer'), user('second')], false); + expect(rows[0]?.kind === 'message' && rows[0].message).toEqual(user('first')); + }); + + test('a window with no user message at all folds nothing', () => { + expect(shape([say('note'), tool('a'), say('answer')], false)).toEqual(['assistant', 'tool', 'assistant']); + }); + + test('every older turn folds, not just the previous one', () => { + const messages = [ + user('1'), + tool('a'), + say('one'), + user('2'), + tool('b'), + say('two'), + user('3'), + tool('c'), + say('three'), + ]; + expect(shape(messages, false)).toEqual([ + 'user', + 'fold', + 'assistant', + 'user', + 'fold', + 'assistant', + 'user', + 'tool', + 'assistant', + ]); + }); + + test('an empty transcript produces nothing', () => { + expect(buildRows([], false)).toEqual([]); + }); + + test('a bare question with no work yet produces no marker and no fold', () => { + expect(shape([user('go')], true)).toEqual(['user']); + }); +}); + +describe('foldLabel', () => { + test('reads as a sentence and singularises', () => { + expect(foldLabel({ tools: 1, notes: 1, tasks: 0, failed: 0 })).toBe('1 tool call · 1 message'); + expect(foldLabel({ tools: 5, notes: 3, tasks: 0, failed: 0 })).toBe('5 tool calls · 3 messages'); + }); + + test('failures are named on the summary, not left inside', () => { + expect(foldLabel({ tools: 5, notes: 0, tasks: 0, failed: 2 })).toBe('5 tool calls · 2 failed'); + }); + + test('background tasks are counted apart from tool calls', () => { + expect(foldLabel({ tools: 0, notes: 0, tasks: 1, failed: 0 })).toBe('1 background task'); + }); +}); diff --git a/src/workspaces/officerdev/src/apps/Chat/components/turn-rows.ts b/src/workspaces/officerdev/src/apps/Chat/components/turn-rows.ts new file mode 100644 index 00000000..135b07a9 --- /dev/null +++ b/src/workspaces/officerdev/src/apps/Chat/components/turn-rows.ts @@ -0,0 +1,188 @@ +import type { ChatMessage } from '../types'; + +/** + * A conversation is a chess game: your message, my work, my answer, repeat. Only the turn you are in is + * worth seeing in full — the moment you send the next message, the tool calls and the running commentary + * that produced the previous answer stop being the thing you are reading and start being the thing you are + * scrolling past. So every turn but the live one folds to three parts: what you asked, one summary row, and + * what I concluded. + * + * This is a pure derivation over the message list, which is what makes a reload render identically to a + * live session: there is no fold *state* anywhere, on the wire or on disk, only a grouping that both paths + * compute from the same array. + */ + +/** Rows that are landmarks about the conversation rather than work done inside a turn. These never fold. */ +function isStructural(message: ChatMessage): boolean { + return message.role === 'divider' || message.role === 'compact'; +} + +/** The work of a turn: everything that belongs behind the summary row. */ +function isWork(message: ChatMessage): boolean { + return message.role === 'tool' || message.role === 'task'; +} + +/** Prose I actually produced. An empty assistant block renders as nothing and must not count as an answer. */ +function isAnswer(message: ChatMessage): boolean { + return message.role === 'assistant' && message.text.trim().length > 0; +} + +/** Ways a turn can end other than by answering. Each has its own row already and rules out the marker. */ +function isFailure(message: ChatMessage): boolean { + return message.role === 'error' || message.role === 'interrupted' || message.role === 'cutoff'; +} + +export type FoldCounts = { + tools: number; + notes: number; + tasks: number; + failed: number; +}; + +export type ChatRow = + | { kind: 'message'; key: string; message: ChatMessage } + /** A collapsed run of one turn's work. `messages` is the whole of it, in order, for when it is opened. */ + | { kind: 'fold'; key: string; messages: ChatMessage[]; counts: FoldCounts } + /** + * A turn that finished cleanly without saying anything. Rare, and disproportionately confusing: the + * composer re-enables and nothing appears, which is indistinguishable from a turn that died. Deliberately + * synthesised as a seam rather than as prose — words in my voice that I did not write are a lie, and the + * next time it happened you would not know which kind of row you were reading. + */ + | { kind: 'turn-end'; key: string }; + +/** Stable enough to survive a prepend of older messages, which shifts every index in the list. */ +function keyOf(message: ChatMessage, fallback: number): string { + switch (message.role) { + case 'tool': + return `t:${message.toolCallId || fallback}`; + case 'task': + return `k:${message.taskId || fallback}`; + case 'assistant': + return `a:${message.id ?? `${fallback}:${message.text.slice(0, 24)}`}`; + case 'divider': + return `d:${message.sessionId}`; + case 'user': + return `u:${fallback}:${message.text.slice(0, 24)}`; + default: + return `${message.role}:${fallback}`; + } +} + +type TurnParams = { + segment: ChatMessage[]; + /** Index of `segment[0]` in the full list, so keys stay unique across turns. */ + offset: number; + /** False for the live turn: its work stays on screen. */ + fold: boolean; + /** Whether the turn has finished — the live turn has not, while a response is still streaming. */ + complete: boolean; +}; + +function buildTurn({ segment, offset, fold, complete }: TurnParams): ChatRow[] { + const rows: ChatRow[] = []; + const push = (message: ChatMessage, i: number) => + rows.push({ kind: 'message', key: keyOf(message, offset + i), message }); + + // Your message is never folded — it is the question the whole turn is an answer to. + let start = 0; + if (segment[0]?.role === 'user') { + push(segment[0], 0); + start = 1; + } + + // The exposed tail is the trailing run that isn't work: my final prose, and whatever settled the turn. + let tailStart = segment.length; + while (tailStart > start && !isWork(segment[tailStart - 1]!)) tailStart--; + + const head = segment.slice(start, tailStart); + const tail = segment.slice(tailStart); + + if (!fold) { + head.forEach((message, i) => push(message, start + i)); + } else { + // Structural rows split the run rather than disappearing into it: "5 tool calls" hiding a `/clear` + // would misreport what happened to the conversation, not just to the work. + let run: ChatMessage[] = []; + let runAt = start; + const flush = () => { + if (!run.length) return; + const counts: FoldCounts = { + tools: run.filter((m) => m.role === 'tool').length, + notes: run.filter(isAnswer).length, + tasks: run.filter((m) => m.role === 'task').length, + failed: run.filter((m) => m.role === 'tool' && m.isError === true).length, + }; + rows.push({ kind: 'fold', key: `f:${keyOf(run[0]!, runAt)}`, messages: run, counts }); + run = []; + }; + head.forEach((message, i) => { + if (isStructural(message)) { + flush(); + push(message, start + i); + runAt = start + i + 1; + return; + } + if (!run.length) runAt = start + i; + run.push(message); + }); + flush(); + } + + // Nothing was said and nothing went wrong — say so, rather than ending on a collapsed tool call. + if (complete && !tail.some(isAnswer) && !tail.some(isFailure) && (head.length > 0 || tail.length > 0)) { + rows.push({ kind: 'turn-end', key: `e:${offset}` }); + } + + tail.forEach((message, i) => push(message, tailStart + i)); + return rows; +} + +/** + * Group a flat transcript into display rows. Only the turn after your last message stays open. + */ +export function buildRows(messages: ChatMessage[], isGenerating: boolean): ChatRow[] { + if (!messages.length) return []; + + const userAt: number[] = []; + messages.forEach((m, i) => { + if (m.role === 'user') userAt.push(i); + }); + + // Everything from your most recent message onward is live. A transcript that opens mid-conversation may + // have no user message at all in its window; then nothing has been superseded and nothing folds. + const liveStart = userAt.length ? userAt[userAt.length - 1]! : 0; + + const bounds = [0, ...userAt.filter((i) => i > 0 && i < liveStart), liveStart].filter( + (v, i, a) => i === 0 || v !== a[i - 1], + ); + + const rows: ChatRow[] = []; + for (let i = 0; i < bounds.length - 1; i++) { + const from = bounds[i]!; + const to = bounds[i + 1]!; + rows.push(...buildTurn({ segment: messages.slice(from, to), offset: from, fold: true, complete: true })); + } + rows.push( + ...buildTurn({ + segment: messages.slice(liveStart), + offset: liveStart, + fold: false, + complete: !isGenerating, + }), + ); + + return rows; +} + +/** "5 tool calls · 2 messages · 1 failed" — what you gave up by folding, so you can tell if you want it back. */ +export function foldLabel(counts: FoldCounts): string { + const plural = (n: number, word: string) => `${n} ${word}${n === 1 ? '' : 's'}`; + const parts: string[] = []; + if (counts.tools) parts.push(plural(counts.tools, 'tool call')); + if (counts.notes) parts.push(plural(counts.notes, 'message')); + if (counts.tasks) parts.push(plural(counts.tasks, 'background task')); + if (!parts.length) parts.push('nothing to show'); + if (counts.failed) parts.push(`${counts.failed} failed`); + return parts.join(' · '); +}