@@ -6,8 +6,12 @@ import {
66 computeChatScrollMaxOffset ,
77 renderChatSelectableRowTexts ,
88 renderChatTranscriptPlainText ,
9+ renderChatVisibleSelectionRows ,
910 selectedTextFromChatRows ,
11+ workGroupExpandKey ,
1012} from "../components/ChatView" ;
13+ import { aggregateChatBlocks } from "../aggregate" ;
14+ import { chatEventLineId } from "../format" ;
1115import { buildSubagentTranscriptEvents } from "../subagentPane" ;
1216import {
1317 parseAssistantMarkdown ,
@@ -34,16 +38,34 @@ function stripAnsi(value: string): string {
3438 return value . replace ( / \[ [ 0 - 9 ; ] * m / g, "" ) ;
3539}
3640
41+ // Tool-call / file-change groups collapse to a single header row by default.
42+ // Tests that assert per-entry rendering (every call/file, glyphs, durations,
43+ // badges) pass `expanded: true` to open every work group.
44+ function expandAllWorkGroups (
45+ events : AgentChatEventEnvelope [ ] ,
46+ activeSession : AgentChatSessionSummary | null ,
47+ ) : Set < string > {
48+ const blocks = aggregateChatBlocks ( { events, notices : [ ] , activeSession } ) ;
49+ const ids = new Set < string > ( ) ;
50+ for ( const block of blocks ) {
51+ if ( block . kind === "tool-calls-group" || block . kind === "files-changed-group" ) {
52+ ids . add ( workGroupExpandKey ( block . id ) ) ;
53+ }
54+ }
55+ return ids ;
56+ }
57+
3758function renderEvents (
3859 events : AgentChatEventEnvelope [ ] ,
39- options : { maxRows ?: number ; scrollOffsetRows ?: number ; width ?: number ; streaming ?: boolean ; interrupted ?: boolean ; provider ?: AdeCodeProvider ; olderHistory ?: "loading" | "available" | "exhausted" | null } = { } ,
60+ options : { maxRows ?: number ; scrollOffsetRows ?: number ; width ?: number ; streaming ?: boolean ; interrupted ?: boolean ; provider ?: AdeCodeProvider ; olderHistory ?: "loading" | "available" | "exhausted" | null ; expanded ?: boolean } = { } ,
4061) : string {
4162 const provider = options . provider ?? "codex" ;
63+ const activeSession = { ...session , provider } ;
4264 const result = render (
4365 < ChatView
4466 events = { events }
4567 notices = { [ ] }
46- activeSession = { { ... session , provider } }
68+ activeSession = { activeSession }
4769 projectName = "ADE"
4870 laneName = "Primary"
4971 provider = { provider }
@@ -53,6 +75,7 @@ function renderEvents(
5375 scrollOffsetRows = { options . scrollOffsetRows }
5476 olderHistory = { options . olderHistory }
5577 width = { options . width }
78+ expandedLineIds = { options . expanded ? expandAllWorkGroups ( events , activeSession ) : undefined }
5679 /> ,
5780 ) ;
5881 return stripAnsi ( result . lastFrame ( ) ?? "" ) ;
@@ -283,6 +306,20 @@ describe("ChatView", () => {
283306 expect ( frame ) . not . toContain ( "waiting for runtime events" ) ;
284307 } ) ;
285308
309+ it ( "renders a generic context_compact begin (Claude/OpenCode) as an active state" , ( ) => {
310+ const frame = renderEvents ( [
311+ {
312+ sessionId : "s1" ,
313+ timestamp : "2026-01-01T12:00:00.000Z" ,
314+ sequence : 1 ,
315+ event : { type : "context_compact" , state : "started" , trigger : "auto" , turnId : "turn-active" } ,
316+ } ,
317+ ] , { width : 80 } ) ;
318+
319+ expect ( frame ) . toContain ( "compacting context" ) ;
320+ expect ( frame ) . not . toContain ( "model working" ) ;
321+ } ) ;
322+
286323 it ( "renders queued steer messages as staged instead of normal sent bubbles" , ( ) => {
287324 const frame = renderEvents ( [
288325 {
@@ -416,10 +453,13 @@ describe("ChatView", () => {
416453 } ) ;
417454
418455 expect ( frame ) . not . toContain ( "Runtime" ) ;
419- // Headerless: the per-call lines stack directly, no "Tool calls (N)" rows.
420- expect ( frame ) . not . toContain ( "Tool calls" ) ;
421- expect ( frame ) . toContain ( "grep" ) ;
456+ expect ( frame ) . not . toContain ( "Processing tool input" ) ;
457+ // The two real tool calls collapse to a single header row; the latest call
458+ // (read) previews, the earlier one (grep) hides behind the collapsed group.
459+ expect ( frame ) . toContain ( "Tool calls" ) ;
460+ expect ( frame ) . toContain ( "(2)" ) ;
422461 expect ( frame ) . toContain ( "read" ) ;
462+ expect ( frame ) . not . toContain ( "grep" ) ;
423463 expect ( frame ) . toContain ( "Let me look at the sendMessage flow more carefully and what events are emitted when a session is resumed." ) ;
424464 } ) ;
425465
@@ -635,16 +675,17 @@ describe("ChatView", () => {
635675 expect ( transcriptLines ( frame ) . at ( - 1 ) ) . toContain ( "↓ newer messages" ) ;
636676 } ) ;
637677
638- it ( "renders a command as a headerless stacked tool line with shell label and command " , ( ) => {
678+ it ( "renders an expanded command as a shell tool line with label, command, and duration " , ( ) => {
639679 const frame = renderEvents ( [
640680 {
641681 sessionId : "s1" ,
642682 timestamp : "2026-01-01T12:00:00.000Z" ,
643683 sequence : 1 ,
644684 event : { type : "command" , command : "git branch" , cwd : "/repo" , output : "main" , itemId : "cmd-1" , status : "completed" , exitCode : 0 , durationMs : 12 } ,
645685 } ,
646- ] , { width : 100 } ) ;
647- expect ( frame ) . not . toContain ( "Tool calls" ) ;
686+ ] , { width : 100 , expanded : true } ) ;
687+ expect ( frame ) . toContain ( "Tool calls" ) ;
688+ expect ( frame ) . toContain ( "(1)" ) ;
648689 expect ( frame ) . toMatch ( / ✓ s h e l l \s + g i t b r a n c h \s + 1 2 m s / ) ;
649690 } ) ;
650691
@@ -671,14 +712,15 @@ describe("ChatView", () => {
671712 } ,
672713 ] ;
673714 const frame = renderEvents ( events , { width : 100 } ) ;
674- // Headerless groups: the tool lines and the badge/stats file row stack
675- // directly, in event order, without "Tool calls"/"files changed" rows.
676- expect ( frame ) . not . toContain ( "Tool calls" ) ;
677- expect ( frame ) . not . toContain ( "file changed" ) ;
715+ // Typed split: the command group and the file-change group each get their
716+ // own collapsible header (in event order). Each single-entry group previews
717+ // its call/file inline, so the collapsed headers still carry the signal.
718+ expect ( frame ) . toContain ( "Tool calls" ) ;
719+ expect ( frame ) . toContain ( "Files changed" ) ;
678720 expect ( frame ) . toContain ( "npm test" ) ;
679721 expect ( frame ) . toContain ( "npm run typecheck" ) ;
680722 expect ( frame ) . toContain ( "auth.ts" ) ;
681- // File rows keep their badge + diff stats format.
723+ // The collapsed file header keeps the badge + diff stats format.
682724 expect ( frame ) . toContain ( "TS" ) ;
683725 expect ( frame ) . toContain ( "+2 −1" ) ;
684726 } ) ;
@@ -763,7 +805,9 @@ describe("ChatView", () => {
763805 } ,
764806 ] , { width : 100 } ) ;
765807
766- expect ( frame ) . not . toContain ( "Tool calls" ) ;
808+ // The top-level spawn collapses into a single "Tool calls" header that
809+ // previews it; the subagent's own child tool chatter stays suppressed.
810+ expect ( frame ) . toContain ( "Tool calls" ) ;
767811 expect ( frame ) . toContain ( "spawn_agent" ) ;
768812 expect ( frame ) . toContain ( "Explore renderer" ) ;
769813 expect ( frame ) . not . toContain ( "child launch spam" ) ;
@@ -834,7 +878,7 @@ describe("ChatView", () => {
834878 expect ( transcriptBody ) . not . toContain ( "unrelated agent result" ) ;
835879 } ) ;
836880
837- it ( "collapses tool-calls-group on done with failed and ok summary " , ( ) => {
881+ it ( "renders per-call ok/failed glyphs for an expanded finished tool group " , ( ) => {
838882 const turnId = "turn-done" ;
839883 const events : AgentChatEventEnvelope [ ] = [
840884 {
@@ -868,12 +912,13 @@ describe("ChatView", () => {
868912 event : { type : "done" , turnId, status : "completed" , usage : { inputTokens : 4000 , outputTokens : 2200 } , costUsd : 0.31 } ,
869913 } ,
870914 ] ;
871- const frame = renderEvents ( events , { width : 100 } ) ;
872- // Headerless: ok/failed status lives on each line's glyph, not a summary row.
873- expect ( frame ) . not . toContain ( "Tool calls" ) ;
915+ const frame = renderEvents ( events , { width : 100 , expanded : true } ) ;
916+ // Expanded group: ok/failed status lives on each call's glyph.
917+ expect ( frame ) . toContain ( "Tool calls" ) ;
918+ expect ( frame ) . toContain ( "(4)" ) ;
874919 expect ( frame . match ( / ✓ / g) ) . toHaveLength ( 3 ) ;
875920 expect ( frame . match ( / ✗ / g) ) . toHaveLength ( 1 ) ;
876- // Most recent shell commands visible.
921+ // Every shell command is visible when expanded .
877922 expect ( frame ) . toContain ( "npm test" ) ;
878923 expect ( frame ) . toContain ( "echo two" ) ;
879924 expect ( frame ) . not . toContain ( "8.3s" ) ;
@@ -902,7 +947,7 @@ describe("ChatView", () => {
902947 } ,
903948 ] ;
904949
905- const frame = renderEvents ( events , { width : 100 } ) ;
950+ const frame = renderEvents ( events , { width : 100 , expanded : true } ) ;
906951 expect ( frame ) . toContain ( "instant" ) ;
907952 expect ( frame ) . toContain ( "measured" ) ;
908953 expect ( frame ) . toContain ( "12ms" ) ;
@@ -988,7 +1033,7 @@ describe("ChatView", () => {
9881033 expect ( text ) . not . toContain ( "gpt" ) ;
9891034 } ) ;
9901035
991- it ( "stacks every tool call as its own line like the desktop work log " , ( ) => {
1036+ it ( "collapses many tool calls to one header row and expands to stack every call " , ( ) => {
9921037 const turnId = "turn-many" ;
9931038 const events : AgentChatEventEnvelope [ ] = Array . from ( { length : 12 } , ( _ , index ) : AgentChatEventEnvelope => ( {
9941039 sessionId : "s1" ,
@@ -1006,13 +1051,22 @@ describe("ChatView", () => {
10061051 turnId,
10071052 } ,
10081053 } ) ) ;
1009- const frame = renderEvents ( events , { width : 120 , maxRows : 40 } ) ;
1010- expect ( frame ) . not . toContain ( "Tool calls" ) ;
1011- expect ( frame ) . not . toContain ( "more" ) ;
1012- // Every consecutive call stacks as its own single line (desktop parity).
1013- expect ( frame ) . toContain ( "cmd-1" ) ;
1014- expect ( frame ) . toContain ( "cmd-5" ) ;
1015- expect ( frame ) . toContain ( "cmd-12" ) ;
1054+
1055+ // Collapsed (default): one header row with the count + the latest call's
1056+ // preview; the earlier calls are hidden behind the collapsed group.
1057+ const collapsed = renderEvents ( events , { width : 120 , maxRows : 40 } ) ;
1058+ expect ( collapsed ) . toContain ( "Tool calls" ) ;
1059+ expect ( collapsed ) . toContain ( "(12)" ) ;
1060+ expect ( collapsed ) . toContain ( "cmd-12" ) ;
1061+ expect ( collapsed ) . not . toContain ( "cmd-1 " ) ;
1062+ expect ( collapsed ) . not . toContain ( "cmd-5" ) ;
1063+
1064+ // Expanded: the header stays, and every consecutive call stacks one per line.
1065+ const expanded = renderEvents ( events , { width : 120 , maxRows : 40 , expanded : true } ) ;
1066+ expect ( expanded ) . toContain ( "Tool calls" ) ;
1067+ expect ( expanded ) . toContain ( "cmd-1" ) ;
1068+ expect ( expanded ) . toContain ( "cmd-5" ) ;
1069+ expect ( expanded ) . toContain ( "cmd-12" ) ;
10161070 } ) ;
10171071
10181072 it ( "strips the /bin/zsh -lc launcher wrapper from shell commands" , ( ) => {
@@ -1048,7 +1102,7 @@ describe("ChatView", () => {
10481102 } ,
10491103 } ,
10501104 ] ;
1051- const frame = renderEvents ( events , { width : 120 } ) ;
1105+ const frame = renderEvents ( events , { width : 120 , expanded : true } ) ;
10521106 expect ( frame ) . toContain ( "git status --short" ) ;
10531107 expect ( frame ) . toContain ( "npm test" ) ;
10541108 // Launcher prefix is gone.
@@ -1077,15 +1131,83 @@ describe("ChatView", () => {
10771131 event : { type : "file_change" , path : "docs/notes.md" , diff : "+line1" , kind : "create" , itemId : "f3" , status : "completed" , turnId : "t1" } ,
10781132 } ,
10791133 ] ;
1080- const frame = renderEvents ( events , { width : 120 } ) ;
1081- expect ( frame ) . not . toContain ( "files changed" ) ;
1134+ const frame = renderEvents ( events , { width : 120 , expanded : true } ) ;
1135+ expect ( frame ) . toContain ( "Files changed" ) ;
1136+ expect ( frame ) . toContain ( "(3)" ) ;
10821137 expect ( frame ) . toContain ( "TSX" ) ;
10831138 expect ( frame ) . toContain ( "JS" ) ;
10841139 expect ( frame ) . toContain ( "MD" ) ;
10851140 expect ( frame ) . toContain ( "Component.tsx" ) ;
10861141 expect ( frame ) . toContain ( "Deleted" ) ;
10871142 } ) ;
10881143
1144+ it ( "collapses file changes to one header row by default, previewing the latest edit" , ( ) => {
1145+ const events : AgentChatEventEnvelope [ ] = [
1146+ {
1147+ sessionId : "s1" ,
1148+ timestamp : "2026-01-01T12:00:00.000Z" ,
1149+ sequence : 1 ,
1150+ event : { type : "file_change" , path : "src/early.ts" , diff : "+a\n+b\n-c" , kind : "modify" , itemId : "f1" , status : "completed" , turnId : "t1" } ,
1151+ } ,
1152+ {
1153+ sessionId : "s1" ,
1154+ timestamp : "2026-01-01T12:00:01.000Z" ,
1155+ sequence : 2 ,
1156+ event : { type : "file_change" , path : "src/recent.ts" , diff : "+x" , kind : "modify" , itemId : "f2" , status : "completed" , turnId : "t1" } ,
1157+ } ,
1158+ ] ;
1159+ const collapsed = renderEvents ( events , { width : 120 } ) ;
1160+ expect ( collapsed ) . toContain ( "Files changed" ) ;
1161+ expect ( collapsed ) . toContain ( "(2)" ) ;
1162+ // Latest edit previews; the earlier file hides behind the collapsed group.
1163+ expect ( collapsed ) . toContain ( "recent.ts" ) ;
1164+ expect ( collapsed ) . not . toContain ( "early.ts" ) ;
1165+
1166+ const expanded = renderEvents ( events , { width : 120 , expanded : true } ) ;
1167+ expect ( expanded ) . toContain ( "early.ts" ) ;
1168+ expect ( expanded ) . toContain ( "recent.ts" ) ;
1169+ } ) ;
1170+
1171+ it ( "tags a collapsed work-group header with an expandable click-target id" , ( ) => {
1172+ const events : AgentChatEventEnvelope [ ] = [
1173+ {
1174+ sessionId : "s1" ,
1175+ timestamp : "2026-01-01T12:00:00.000Z" ,
1176+ sequence : 1 ,
1177+ event : { type : "command" , command : "alpha" , cwd : "/repo" , output : "" , itemId : "c1" , status : "completed" , exitCode : 0 , durationMs : 10 , turnId : "t1" } ,
1178+ } ,
1179+ {
1180+ sessionId : "s1" ,
1181+ timestamp : "2026-01-01T12:00:01.000Z" ,
1182+ sequence : 2 ,
1183+ event : { type : "command" , command : "beta" , cwd : "/repo" , output : "" , itemId : "c2" , status : "completed" , exitCode : 0 , durationMs : 10 , turnId : "t1" } ,
1184+ } ,
1185+ ] ;
1186+ const expectedKey = workGroupExpandKey ( chatEventLineId ( events [ 0 ] ! , 0 ) ) ;
1187+ const rows = renderChatVisibleSelectionRows ( {
1188+ events,
1189+ notices : [ ] ,
1190+ activeSession : session ,
1191+ width : 120 ,
1192+ } ) ;
1193+ const headerRow = rows . find ( ( row ) => row . expandableId != null ) ;
1194+ // The collapsed group renders exactly one clickable header carrying the
1195+ // expand key the transcript click handler toggles in expandedLineIds.
1196+ expect ( headerRow ?. expandableId ) . toBe ( expectedKey ) ;
1197+ expect ( rows . filter ( ( row ) => row . expandableId != null ) ) . toHaveLength ( 1 ) ;
1198+
1199+ const expandedRows = renderChatVisibleSelectionRows ( {
1200+ events,
1201+ notices : [ ] ,
1202+ activeSession : session ,
1203+ expandedLineIds : new Set ( [ expectedKey ] ) ,
1204+ width : 120 ,
1205+ } ) ;
1206+ // Still exactly one clickable header (now ▾) plus the stacked call rows.
1207+ expect ( expandedRows . filter ( ( row ) => row . expandableId != null ) ) . toHaveLength ( 1 ) ;
1208+ expect ( expandedRows . length ) . toBeGreaterThan ( rows . length ) ;
1209+ } ) ;
1210+
10891211 it ( "renders fenced code with highlight.js-derived per-line tokens" , ( ) => {
10901212 const events : AgentChatEventEnvelope [ ] = [
10911213 {
0 commit comments