From eff595afde6dcd555590721a1049b183e56de58b Mon Sep 17 00:00:00 2001 From: Yang Liu Date: Mon, 27 Apr 2026 16:05:49 +1200 Subject: [PATCH 1/3] Show pre-compaction messages in session view MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When Claude Code compacts a session it writes a compact_boundary entry (type:"system", subtype:"compact_boundary") with parentUuid:null and logicalParentUuid pointing to the last pre-compact message. This broke the live-chain walk — the backward traversal from the live tip stopped at the boundary because parentUuid was empty, leaving all pre-compact messages invisible in the UI. Changes: entry.rs - Add logical_parent_uuid field (from logicalParentUuid) so compact_boundary entries can carry the pre-compact chain link. - Add is_compact_summary field (from isCompactSummary) to identify the AI-generated summary user entry Claude Code injects after compaction. session.rs / resolve_live_chain_uuids - When the backward walk reaches an entry whose parentUuid is empty but logicalParentUuid is set, follow logicalParentUuid instead of stopping. This makes all pre-compaction messages part of the live chain, so they appear in the session view just before the compaction separator. - Handles multiple compactions correctly — each compact_boundary bridges to the previous chunk of pre-compact history. classify.rs - User entries with isCompactSummary:true are now classified as CompactMsg (rendered as a styled separator with the summary text) instead of appearing as a regular user turn alongside the real pre-compact messages they summarise. Tests added: 3 in entry.rs, 3 in session.rs, 2 in classify.rs --- src-tauri/src/parser/classify.rs | 57 ++++++++++++++++ src-tauri/src/parser/entry.rs | 72 ++++++++++++++++++++ src-tauri/src/parser/session.rs | 113 +++++++++++++++++++++++++++++-- 3 files changed, 237 insertions(+), 5 deletions(-) diff --git a/src-tauri/src/parser/classify.rs b/src-tauri/src/parser/classify.rs index 61cd2fb..f5b705c 100644 --- a/src-tauri/src/parser/classify.rs +++ b/src-tauri/src/parser/classify.rs @@ -407,6 +407,16 @@ pub fn classify(e: Entry) -> Option { } } + // Compact summary: user entries with isCompactSummary:true are injected by Claude Code + // after a compaction event and contain an AI-generated summary of the compacted messages. + // Classify as CompactMsg so they render as a styled separator rather than a user turn. + if e.entry_type == "user" && e.is_compact_summary { + return Some(ClassifiedMsg::Compact(CompactMsg { + timestamp: ts, + text: sanitize_content(&content_str), + })); + } + // User message. if e.entry_type == "user" && !e.is_meta { let trimmed = content_str.trim(); @@ -1739,4 +1749,51 @@ mod tests { other => panic!("Expected UserMsg for document block, got {:?}", other), } } + + // --- compact_boundary / isCompactSummary classification --- + + #[test] + fn classify_compact_summary_user_entry_as_compact_msg() { + // User entries with isCompactSummary:true are injected by Claude Code after compaction. + // They must be classified as CompactMsg so they render as a styled separator, not as + // a regular user turn that would confusingly appear alongside the pre-compact messages. + let e = Entry { + entry_type: "user".to_string(), + uuid: "compact-summary-uuid".to_string(), + timestamp: "2026-04-26T12:21:02Z".to_string(), + is_compact_summary: true, + message: super::super::entry::EntryMessage { + role: "user".to_string(), + content: Some(json!( + "This session is being continued from a previous conversation that ran out of context. The summary below covers the earlier portion of the conversation.\n\nSummary:\n1. Primary Request: ..." + )), + ..Default::default() + }, + ..Default::default() + }; + match classify(e) { + Some(ClassifiedMsg::Compact(c)) => { + assert!( + c.text.contains("This session is being continued"), + "compact summary text must be preserved" + ); + } + other => panic!( + "Expected Compact for isCompactSummary user entry, got {:?}", + other + ), + } + } + + #[test] + fn classify_regular_user_entry_not_affected_by_compact_summary_flag() { + // Regular user entries (isCompactSummary defaults to false) must still produce UserMsg. + let e = make_entry("user", Some(json!("Hello Claude"))); + match classify(e) { + Some(ClassifiedMsg::User(u)) => { + assert!(u.text.contains("Hello Claude")); + } + other => panic!("Expected User, got {:?}", other), + } + } } diff --git a/src-tauri/src/parser/entry.rs b/src-tauri/src/parser/entry.rs index e0e2e04..d832bce 100644 --- a/src-tauri/src/parser/entry.rs +++ b/src-tauri/src/parser/entry.rs @@ -76,6 +76,20 @@ pub struct Entry { // `content`, not inside `message.content`. #[serde(default)] pub content: String, + // Present in type:"system", subtype:"compact_boundary" entries. Claude Code writes this to + // mark where a compaction occurred. parentUuid is null (breaks the chain), but + // logicalParentUuid points to the last message before compaction so we can follow the + // chain back and include pre-compaction messages in the conversation view. + #[serde( + default, + rename = "logicalParentUuid", + deserialize_with = "null_as_default" + )] + pub logical_parent_uuid: String, + // Present in type:"user" entries when Claude Code wrote the AI-generated summary of a + // compacted conversation. We classify these as CompactMsg instead of regular user messages. + #[serde(default, rename = "isCompactSummary")] + pub is_compact_summary: bool, // Present in forked session entries (pre-v2.1.118). When /fork branched a conversation, // each duplicated parent entry carried forkedFrom:{sessionId,messageUuid} to identify // its origin. Entries without this field are newly added in the fork itself. @@ -379,4 +393,62 @@ mod tests { "fork-context-ref with no uuid must return None" ); } + + // --- compact_boundary and isCompactSummary fields --- + + #[test] + fn parse_entry_captures_logical_parent_uuid_for_compact_boundary() { + // compact_boundary entries have parentUuid:null but logicalParentUuid pointing to the + // last pre-compaction message so the live chain can follow back to that message. + let line = json!({ + "type": "system", + "subtype": "compact_boundary", + "uuid": "boundary-uuid-001", + "parentUuid": null, + "logicalParentUuid": "last-pre-compact-uuid", + "timestamp": "2026-04-26T12:21:02Z", + "isMeta": false + }); + let bytes = serde_json::to_vec(&line).unwrap(); + let entry = parse_entry(&bytes).expect("must parse compact_boundary entry"); + assert_eq!(entry.entry_type, "system"); + assert_eq!(entry.subtype, "compact_boundary"); + assert_eq!(entry.parent_uuid, ""); + assert_eq!(entry.logical_parent_uuid, "last-pre-compact-uuid"); + } + + #[test] + fn parse_entry_captures_is_compact_summary_flag() { + // Compact summary user entries have isCompactSummary:true so classify() can + // render them as a CompactMsg separator instead of a regular user message. + let line = json!({ + "type": "user", + "uuid": "compact-summary-uuid-001", + "parentUuid": "boundary-uuid-001", + "isCompactSummary": true, + "timestamp": "2026-04-26T12:21:02Z", + "message": {"role": "user", "content": "This session is being continued..."} + }); + let bytes = serde_json::to_vec(&line).unwrap(); + let entry = parse_entry(&bytes).expect("must parse compact summary entry"); + assert!(entry.is_compact_summary, "isCompactSummary must be true"); + } + + #[test] + fn parse_entry_logical_parent_uuid_defaults_to_empty() { + // Regular entries without logicalParentUuid must have an empty string. + let line = json!({ + "type": "user", + "uuid": "regular-uuid", + "timestamp": "2026-04-26T10:00:00Z", + "message": {"role": "user", "content": "Hello"} + }); + let bytes = serde_json::to_vec(&line).unwrap(); + let entry = parse_entry(&bytes).expect("must parse regular entry"); + assert_eq!( + entry.logical_parent_uuid, "", + "regular entry must have empty logical_parent_uuid" + ); + assert!(!entry.is_compact_summary); + } } diff --git a/src-tauri/src/parser/session.rs b/src-tauri/src/parser/session.rs index 9a3d28e..9df5210 100644 --- a/src-tauri/src/parser/session.rs +++ b/src-tauri/src/parser/session.rs @@ -135,6 +135,8 @@ fn resolve_live_chain_uuids(entries: &[Entry]) -> HashSet { } // Step 3: walk backward from live_tip via parentUuid links. + // When parentUuid is empty but logicalParentUuid is set (compact_boundary entries), + // follow logicalParentUuid instead so that pre-compaction messages are included. let mut live_set: HashSet = HashSet::new(); let mut current = live_tip; loop { @@ -143,12 +145,10 @@ fn resolve_live_chain_uuids(entries: &[Entry]) -> HashSet { } live_set.insert(current.clone()); let parent = match uuid_idx.get(¤t).and_then(|&i| entries.get(i)) { - Some(e) => e.parent_uuid.clone(), - None => break, + Some(e) if !e.parent_uuid.is_empty() => e.parent_uuid.clone(), + Some(e) if !e.logical_parent_uuid.is_empty() => e.logical_parent_uuid.clone(), + _ => break, }; - if parent.is_empty() { - break; - } current = parent; } @@ -1527,6 +1527,109 @@ mod tests { assert!(set.is_empty()); } + // --- compact_boundary / logicalParentUuid chain extension --- + + fn make_compact_boundary(uuid: &str, logical_parent_uuid: &str) -> Entry { + Entry { + uuid: uuid.to_string(), + parent_uuid: String::new(), // null → empty + logical_parent_uuid: logical_parent_uuid.to_string(), + entry_type: "system".to_string(), + subtype: "compact_boundary".to_string(), + ..Default::default() + } + } + + #[test] + fn live_chain_follows_logical_parent_uuid_through_compact_boundary() { + // Pre-compact: A → B → C (C is the last pre-compact message) + // compact_boundary: D (parentUuid=null, logicalParentUuid=C) + // Post-compact: D → E → F (F is the live leaf) + let entries = vec![ + make_entry("A", "", "", false), + make_entry("B", "A", "", false), + make_entry("C", "B", "", false), + make_compact_boundary("D", "C"), + make_entry("E", "D", "", false), + make_entry("F", "E", "", false), // live leaf + ]; + let set = resolve_live_chain_uuids(&entries); + + // Post-compact chain must be present. + assert!(set.contains("F"), "live leaf must be in live set"); + assert!(set.contains("E"), "post-compact entry must be in live set"); + assert!(set.contains("D"), "compact_boundary must be in live set"); + + // Pre-compact chain must also be present (followed via logicalParentUuid). + assert!( + set.contains("C"), + "last pre-compact entry must be in live set" + ); + assert!( + set.contains("B"), + "mid pre-compact entry must be in live set" + ); + assert!( + set.contains("A"), + "first pre-compact entry must be in live set" + ); + assert_eq!(set.len(), 6); + } + + #[test] + fn live_chain_multiple_compactions_includes_all_pre_compact_messages() { + // Two compactions: first compacts A→B→C, second compacts post-compact messages. + // Pre-compact1: A → B → C + // compact_boundary1: D (logicalParentUuid=C) + // Post-compact1 / pre-compact2: D → E → F + // compact_boundary2: G (logicalParentUuid=F) + // Post-compact2: G → H → I (I is the live leaf) + let entries = vec![ + make_entry("A", "", "", false), + make_entry("B", "A", "", false), + make_entry("C", "B", "", false), + make_compact_boundary("D", "C"), + make_entry("E", "D", "", false), + make_entry("F", "E", "", false), + make_compact_boundary("G", "F"), + make_entry("H", "G", "", false), + make_entry("I", "H", "", false), // live leaf + ]; + let set = resolve_live_chain_uuids(&entries); + assert_eq!( + set.len(), + 9, + "all entries across both compactions must be in live set" + ); + for id in &["A", "B", "C", "D", "E", "F", "G", "H", "I"] { + assert!(set.contains(*id), "{id} must be in live set"); + } + } + + #[test] + fn live_chain_compact_boundary_with_dead_end_branch_excluded() { + // Main chain: A → B → compact_boundary(C, logicalParent=B) → D → E (live) + // Dead-end branch: B → X (dead-end) + let entries = vec![ + make_entry("A", "", "", false), + make_entry("B", "A", "", false), + make_entry("X", "B", "", false), // dead-end branch + make_compact_boundary("C", "B"), + make_entry("D", "C", "", false), + make_entry("E", "D", "", false), // live leaf + ]; + let set = resolve_live_chain_uuids(&entries); + assert!(set.contains("E"), "live leaf must be in live set"); + assert!(set.contains("D")); + assert!(set.contains("C"), "compact_boundary must be in live set"); + assert!( + set.contains("B"), + "pre-compact entry must be in live set via logicalParentUuid" + ); + assert!(set.contains("A"), "root must be in live set"); + assert!(!set.contains("X"), "dead-end branch must be excluded"); + } + #[test] fn incremental_read_does_not_advance_past_partial_line() { let tmp = env::temp_dir().join("tail-test-partial-line"); From a675a0db4368ffc069c856958aab2357188d9044 Mon Sep 17 00:00:00 2001 From: Yang Liu Date: Mon, 27 Apr 2026 16:18:36 +1200 Subject: [PATCH 2/3] Fix compact separator UI: show label with collapsible summary MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The compact summary content is a multi-paragraph AI-generated text. Rendering it inline as the separator label produced an unreadable wall of text. Fix: - Show 'Context compacted ▼' as the always-visible label (button) - Full summary text only appears in a scrollable box when the user clicks to expand - Export CompactSeparator from MessageList and import it in MessageDetail to avoid duplication - Add CSS for .compact-separator__label and .compact-separator__summary - Update test: assert label text and add expand/collapse test --- src/components/MessageDetail.tsx | 11 ++--------- src/components/MessageList.test.tsx | 14 ++++++++++++-- src/components/MessageList.tsx | 10 +++++++--- src/styles/global.css | 28 ++++++++++++++++++++++++++++ 4 files changed, 49 insertions(+), 14 deletions(-) diff --git a/src/components/MessageDetail.tsx b/src/components/MessageDetail.tsx index c909487..341b94d 100644 --- a/src/components/MessageDetail.tsx +++ b/src/components/MessageDetail.tsx @@ -13,6 +13,7 @@ import { } from "../lib/format"; import { getModelColor, getTeamColor } from "../lib/theme"; import { MessageItem } from "./MessageItem"; +import { CompactSeparator } from "./MessageList"; import { DetailItem } from "./DetailItem"; import { useToggleSet } from "../hooks/useToggleSet"; import { useScrollToSelected } from "../hooks/useScrollToSelected"; @@ -606,15 +607,7 @@ function AgentListColumn({
{messages.map((msg, i) => { if (msg.role === "compact") { - return ( -
-
- - {msg.content} - -
-
- ); + return ; } const isSelected = i === selectedMsg; const isExpanded = expandedSet.has(i); diff --git a/src/components/MessageList.test.tsx b/src/components/MessageList.test.tsx index 316c419..1fcbdbd 100644 --- a/src/components/MessageList.test.tsx +++ b/src/components/MessageList.test.tsx @@ -67,10 +67,20 @@ describe("MessageList", () => { it("shows compact separator for compact role", () => { const messages = [makeMessage({ role: "compact", content: "--- separator ---" })]; - render(); - expect(screen.getByText("--- separator ---")).toBeInTheDocument(); const { container } = render(); expect(container.querySelector(".compact-separator")).toBeInTheDocument(); + // Label always shows "Context compacted" with expand arrow when content is present. + expect(screen.getByText("Context compacted ▼")).toBeInTheDocument(); + }); + + it("expands compact separator to show content on click", () => { + const messages = [makeMessage({ role: "compact", content: "--- separator ---" })]; + render(); + // Summary hidden initially. + expect(screen.queryByText("--- separator ---")).not.toBeInTheDocument(); + // Click to expand. + fireEvent.click(screen.getByText("Context compacted ▼")); + expect(screen.getByText("--- separator ---")).toBeInTheDocument(); }); it("shows correct role labels for user, claude, system", () => { diff --git a/src/components/MessageList.tsx b/src/components/MessageList.tsx index b3c12d5..ce913c1 100644 --- a/src/components/MessageList.tsx +++ b/src/components/MessageList.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo } from "react"; +import { useCallback, useMemo, useState } from "react"; import { useScrollToSelected } from "../hooks/useScrollToSelected"; import { useAutoScroll } from "../hooks/useAutoScroll"; import { useRegisterViewActions, type ViewActionsRef } from "../hooks/useViewActions"; @@ -84,14 +84,18 @@ export function MessageList({ ); } -function CompactSeparator({ content }: { content: string }) { +export function CompactSeparator({ content }: { content: string }) { + const [expanded, setExpanded] = useState(false); return (
- {content} +
+ {expanded && content &&
{content}
}
); } diff --git a/src/styles/global.css b/src/styles/global.css index c03dda2..72327d0 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -1284,6 +1284,34 @@ body { background: var(--border); } +.compact-separator__label { + background: none; + border: none; + cursor: pointer; + color: var(--text-muted); + font-size: 11px; + padding: 0; + white-space: nowrap; +} + +.compact-separator__label:hover { + color: var(--text-secondary); +} + +.compact-separator__summary { + margin: 6px 0 2px; + padding: 10px 12px; + background: var(--bg-secondary); + border-radius: 4px; + font-size: 11px; + color: var(--text-secondary); + white-space: pre-wrap; + text-align: left; + max-height: 300px; + overflow-y: auto; + line-height: 1.5; +} + /* ---- Message Detail ---- */ .message-detail { From 16a2355afd659219ddae2854fa2eefbe22347f76 Mon Sep 17 00:00:00 2001 From: Yang Liu Date: Mon, 27 Apr 2026 16:33:45 +1200 Subject: [PATCH 3/3] feat: differentiate compact vs recap messages; render as message bubbles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add is_recap field to CompactMsg: true for away_summary entries (session recap on idle return), false for isCompactSummary/summary entries (actual compaction events) - Add ChunkType::Recap variant; convert.rs maps it to role:recap in DisplayMessage, keeping role:compact for actual compaction - Extend DisplayMessage role union to include recap - Replace CompactSeparator horizontal-line UI with message-style bubble rendering: compact shows Compacted Message label, recap shows Session Recap label — same CSS structure as user/claude messages - Remove CompactSeparator component; both compact and recap now go through MessageItem with message--compact CSS class - Update tests: assert is_recap flag in classify tests; replace separator DOM assertions with message bubble assertions --- shared/types.ts | 2 +- src-tauri/src/convert.rs | 8 ++++-- src-tauri/src/parser/chunk.rs | 9 ++++++- src-tauri/src/parser/classify.rs | 8 ++++++ src/components/MessageDetail.tsx | 4 --- src/components/MessageItem.tsx | 40 +++++++++++++++++++---------- src/components/MessageList.test.tsx | 24 ++++++++--------- src/components/MessageList.tsx | 22 +--------------- src/styles/global.css | 4 +++ 9 files changed, 65 insertions(+), 56 deletions(-) diff --git a/shared/types.ts b/shared/types.ts index a1c2402..43d1125 100644 --- a/shared/types.ts +++ b/shared/types.ts @@ -1,7 +1,7 @@ // Types matching the Rust backend serialization export interface DisplayMessage { - role: "user" | "claude" | "system" | "compact"; + role: "user" | "claude" | "system" | "compact" | "recap"; model: string; content: string; timestamp: string; diff --git a/src-tauri/src/convert.rs b/src-tauri/src/convert.rs index db3d9c5..cc0603e 100644 --- a/src-tauri/src/convert.rs +++ b/src-tauri/src/convert.rs @@ -426,9 +426,13 @@ fn chunks_to_messages_inner( subagent_label: String::new(), }); } - ChunkType::Compact => { + ChunkType::Compact | ChunkType::Recap => { msgs.push(DisplayMessage { - role: "compact".to_string(), + role: if c.chunk_type == ChunkType::Recap { + "recap".to_string() + } else { + "compact".to_string() + }, model: String::new(), content: c.output.clone(), timestamp: format_time(&c.timestamp), diff --git a/src-tauri/src/parser/chunk.rs b/src-tauri/src/parser/chunk.rs index e73db9c..2b88f4b 100644 --- a/src-tauri/src/parser/chunk.rs +++ b/src-tauri/src/parser/chunk.rs @@ -88,7 +88,10 @@ pub enum ChunkType { User, AI, System, + /// Actual compaction event (isCompactSummary or legacy summary entry). Compact, + /// Session recap written on idle return (away_summary). + Recap, } /// Chunk is the output of the pipeline. Each chunk represents one visible unit. @@ -215,7 +218,11 @@ pub fn build_chunks(msgs: &[ClassifiedMsg]) -> Vec { ClassifiedMsg::Compact(m) => { flush(&mut ai_buf, &mut chunks); chunks.push(Chunk { - chunk_type: ChunkType::Compact, + chunk_type: if m.is_recap { + ChunkType::Recap + } else { + ChunkType::Compact + }, timestamp: m.timestamp, output: m.text.clone(), ..Default::default() diff --git a/src-tauri/src/parser/classify.rs b/src-tauri/src/parser/classify.rs index f5b705c..f3f8c67 100644 --- a/src-tauri/src/parser/classify.rs +++ b/src-tauri/src/parser/classify.rs @@ -96,6 +96,8 @@ pub struct TeammateMsg { pub struct CompactMsg { pub timestamp: DateTime, pub text: String, + /// True for away_summary (session recap); false for actual compaction events. + pub is_recap: bool, } #[derive(Debug, Clone)] @@ -200,6 +202,7 @@ pub fn classify(e: Entry) -> Option { return Some(ClassifiedMsg::Compact(CompactMsg { timestamp: ts, text: e.content.clone(), + is_recap: true, })); } // stop_hook_summary: written every time Stop hooks run (success or failure). @@ -304,6 +307,7 @@ pub fn classify(e: Entry) -> Option { return Some(ClassifiedMsg::Compact(CompactMsg { timestamp: ts, text: e.summary.clone(), + is_recap: false, })); } @@ -414,6 +418,7 @@ pub fn classify(e: Entry) -> Option { return Some(ClassifiedMsg::Compact(CompactMsg { timestamp: ts, text: sanitize_content(&content_str), + is_recap: false, })); } @@ -1506,6 +1511,7 @@ mod tests { match classify(e) { Some(ClassifiedMsg::Compact(c)) => { assert_eq!(c.text, "Working on a bug fix in entry.rs."); + assert!(c.is_recap, "away_summary must produce is_recap=true"); } other => panic!("Expected Compact for away_summary, got {:?}", other), } @@ -1525,6 +1531,7 @@ mod tests { match classify(e) { Some(ClassifiedMsg::Compact(c)) => { assert_eq!(c.text, ""); + assert!(c.is_recap, "away_summary must produce is_recap=true"); } other => panic!("Expected Compact for empty away_summary, got {:?}", other), } @@ -1777,6 +1784,7 @@ mod tests { c.text.contains("This session is being continued"), "compact summary text must be preserved" ); + assert!(!c.is_recap, "isCompactSummary must produce is_recap=false"); } other => panic!( "Expected Compact for isCompactSummary user entry, got {:?}", diff --git a/src/components/MessageDetail.tsx b/src/components/MessageDetail.tsx index 341b94d..58ecfce 100644 --- a/src/components/MessageDetail.tsx +++ b/src/components/MessageDetail.tsx @@ -13,7 +13,6 @@ import { } from "../lib/format"; import { getModelColor, getTeamColor } from "../lib/theme"; import { MessageItem } from "./MessageItem"; -import { CompactSeparator } from "./MessageList"; import { DetailItem } from "./DetailItem"; import { useToggleSet } from "../hooks/useToggleSet"; import { useScrollToSelected } from "../hooks/useScrollToSelected"; @@ -606,9 +605,6 @@ function AgentListColumn({
{messages.map((msg, i) => { - if (msg.role === "compact") { - return ; - } const isSelected = i === selectedMsg; const isExpanded = expandedSet.has(i); const isLast = i === messages.length - 1; diff --git a/src/components/MessageItem.tsx b/src/components/MessageItem.tsx index e1e1b9f..9b46fd9 100644 --- a/src/components/MessageItem.tsx +++ b/src/components/MessageItem.tsx @@ -17,6 +17,28 @@ interface MessageItemProps { ref?: React.Ref; } +function roleClass(msg: DisplayMessage): string { + if (msg.role === "user") return "message--user"; + if (msg.role === "claude") return "message--claude"; + if (msg.role === "compact" || msg.role === "recap") return "message--compact"; + return msg.is_error ? "message--system-error" : "message--system"; +} + +function roleLabel(msg: DisplayMessage): string { + if (msg.role === "user") return "User"; + if (msg.role === "claude") return "Claude"; + if (msg.role === "compact") return "Compacted Message"; + if (msg.role === "recap") return "Session Recap"; + return "System"; +} + +function roleCssModifier(msg: DisplayMessage): string { + if (msg.role === "user") return "user"; + if (msg.role === "claude") return "claude"; + if (msg.role === "compact" || msg.role === "recap") return "compact"; + return "system"; +} + export function MessageItem({ ref, message: msg, @@ -27,15 +49,7 @@ export function MessageItem({ onOpenDetail, isOngoing, }: MessageItemProps) { - const roleClass = - msg.role === "user" - ? "message--user" - : msg.role === "claude" - ? "message--claude" - : msg.is_error - ? "message--system-error" - : "message--system"; - + const rc = roleClass(msg); const model = msg.model ? shortModel(msg.model) : ""; const modelColor = msg.model ? getModelColor(msg.model) : undefined; const time = formatExactTime(msg.timestamp); @@ -45,7 +59,7 @@ export function MessageItem({ return (
onClick(index)} onDoubleClick={() => onOpenDetail(index)} > @@ -61,10 +75,8 @@ export function MessageItem({ )} - - {msg.role === "user" ? "User" : msg.role === "claude" ? "Claude" : "System"} + + {roleLabel(msg)} {model && ( diff --git a/src/components/MessageList.test.tsx b/src/components/MessageList.test.tsx index 1fcbdbd..a511227 100644 --- a/src/components/MessageList.test.tsx +++ b/src/components/MessageList.test.tsx @@ -65,22 +65,20 @@ describe("MessageList", () => { expect(messageEls[1]).toHaveTextContent(/Second message/); }); - it("shows compact separator for compact role", () => { - const messages = [makeMessage({ role: "compact", content: "--- separator ---" })]; + it("renders compact role as a message item with 'Compacted Message' label", () => { + const messages = [makeMessage({ role: "compact", content: "--- summary ---" })]; const { container } = render(); - expect(container.querySelector(".compact-separator")).toBeInTheDocument(); - // Label always shows "Context compacted" with expand arrow when content is present. - expect(screen.getByText("Context compacted ▼")).toBeInTheDocument(); + expect(container.querySelector(".message")).toBeInTheDocument(); + expect(screen.getByText("Compacted Message")).toBeInTheDocument(); + expect(screen.getByText("--- summary ---")).toBeInTheDocument(); }); - it("expands compact separator to show content on click", () => { - const messages = [makeMessage({ role: "compact", content: "--- separator ---" })]; - render(); - // Summary hidden initially. - expect(screen.queryByText("--- separator ---")).not.toBeInTheDocument(); - // Click to expand. - fireEvent.click(screen.getByText("Context compacted ▼")); - expect(screen.getByText("--- separator ---")).toBeInTheDocument(); + it("renders recap role as a message item with 'Session Recap' label", () => { + const messages = [makeMessage({ role: "recap", content: "recap text" })]; + const { container } = render(); + expect(container.querySelector(".message")).toBeInTheDocument(); + expect(screen.getByText("Session Recap")).toBeInTheDocument(); + expect(screen.getByText("recap text")).toBeInTheDocument(); }); it("shows correct role labels for user, claude, system", () => { diff --git a/src/components/MessageList.tsx b/src/components/MessageList.tsx index ce913c1..101ce20 100644 --- a/src/components/MessageList.tsx +++ b/src/components/MessageList.tsx @@ -1,4 +1,4 @@ -import { useCallback, useMemo, useState } from "react"; +import { useCallback, useMemo } from "react"; import { useScrollToSelected } from "../hooks/useScrollToSelected"; import { useAutoScroll } from "../hooks/useAutoScroll"; import { useRegisterViewActions, type ViewActionsRef } from "../hooks/useViewActions"; @@ -59,10 +59,6 @@ export function MessageList({ {messages.length === 0 &&
No messages loaded
} {ordered.map((i) => { const msg = messages[i]; - if (msg.role === "compact") { - return ; - } - const isSelected = i === selectedIndex; const isLast = i === messages.length - 1; @@ -83,19 +79,3 @@ export function MessageList({
); } - -export function CompactSeparator({ content }: { content: string }) { - const [expanded, setExpanded] = useState(false); - return ( -
-
- - - -
- {expanded && content &&
{content}
} -
- ); -} diff --git a/src/styles/global.css b/src/styles/global.css index 72327d0..f3ec19f 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -1168,6 +1168,10 @@ body { color: var(--text-muted); } +.message__role--compact { + color: var(--text-muted); +} + .message__model { font-size: 11px; font-weight: 600;