Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion shared/types.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
8 changes: 6 additions & 2 deletions src-tauri/src/convert.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
9 changes: 8 additions & 1 deletion src-tauri/src/parser/chunk.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -215,7 +218,11 @@ pub fn build_chunks(msgs: &[ClassifiedMsg]) -> Vec<Chunk> {
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()
Expand Down
65 changes: 65 additions & 0 deletions src-tauri/src/parser/classify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,8 @@ pub struct TeammateMsg {
pub struct CompactMsg {
pub timestamp: DateTime<Utc>,
pub text: String,
/// True for away_summary (session recap); false for actual compaction events.
pub is_recap: bool,
}

#[derive(Debug, Clone)]
Expand Down Expand Up @@ -200,6 +202,7 @@ pub fn classify(e: Entry) -> Option<ClassifiedMsg> {
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).
Expand Down Expand Up @@ -304,6 +307,7 @@ pub fn classify(e: Entry) -> Option<ClassifiedMsg> {
return Some(ClassifiedMsg::Compact(CompactMsg {
timestamp: ts,
text: e.summary.clone(),
is_recap: false,
}));
}

Expand Down Expand Up @@ -407,6 +411,17 @@ pub fn classify(e: Entry) -> Option<ClassifiedMsg> {
}
}

// 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),
is_recap: false,
}));
}

// User message.
if e.entry_type == "user" && !e.is_meta {
let trimmed = content_str.trim();
Expand Down Expand Up @@ -1496,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),
}
Expand All @@ -1515,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),
}
Expand Down Expand Up @@ -1739,4 +1756,52 @@ 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"
);
assert!(!c.is_recap, "isCompactSummary must produce is_recap=false");
}
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),
}
}
}
72 changes: 72 additions & 0 deletions src-tauri/src/parser/entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
}
}
113 changes: 108 additions & 5 deletions src-tauri/src/parser/session.rs
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,8 @@ fn resolve_live_chain_uuids(entries: &[Entry]) -> HashSet<String> {
}

// 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<String> = HashSet::new();
let mut current = live_tip;
loop {
Expand All @@ -143,12 +145,10 @@ fn resolve_live_chain_uuids(entries: &[Entry]) -> HashSet<String> {
}
live_set.insert(current.clone());
let parent = match uuid_idx.get(&current).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;
}

Expand Down Expand Up @@ -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");
Expand Down
11 changes: 0 additions & 11 deletions src/components/MessageDetail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -605,17 +605,6 @@ function AgentListColumn({
<div className="agent-panel__content">
<div className="agent-panel__list" ref={listRef}>
{messages.map((msg, i) => {
if (msg.role === "compact") {
return (
<div key={`compact-${msg.timestamp}`} className="compact-separator">
<div className="compact-separator__line">
<span className="compact-separator__rule" />
<span>{msg.content}</span>
<span className="compact-separator__rule" />
</div>
</div>
);
}
const isSelected = i === selectedMsg;
const isExpanded = expandedSet.has(i);
const isLast = i === messages.length - 1;
Expand Down
Loading