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
85 changes: 74 additions & 11 deletions src-tauri/crates/orgtrack-core/src/sources/claude_code/history.rs
Original file line number Diff line number Diff line change
Expand Up @@ -241,6 +241,13 @@ struct ClaudeIndexedTurn {
/// collapse bar (the only expand affordance when turn pagination is off)
/// never renders and unloaded bodies become unreachable.
following_line_count: usize,
/// Byte range `(offset, length)` of the newest following line that
/// raw-scans as an assistant message carrying a text item. Unloaded
/// rounds parse only this one line so their placeholder can carry the
/// final-reply preview and a real end timestamp — the metadata every
/// full-stream provider derives in `build_initial_window_from_turns` —
/// without materializing the whole round body.
last_assistant_text_line: Option<(u64, usize)>,
}

fn claude_window_turn_id(start_offset: u64) -> String {
Expand Down Expand Up @@ -298,6 +305,17 @@ fn line_is_obvious_tool_result(line: &[u8]) -> bool {
.any(|window| window == b"\"tool_use_id\"")
}

/// Raw prefilter for assistant lines that carry at least one text item
/// (`content: [{"type":"text", ...}]`). Thinking-only and tool_use-only lines
/// fail the second check, matching the preview policy of the full-stream
/// window builder (only `FUNCTION_ASSISTANT` chunks become round previews).
/// False positives (e.g. `"type":"text"` inside a tool input) are filtered by
/// the canonical parser when the line is actually loaded.
fn line_might_be_claude_assistant_text(line: &[u8]) -> bool {
line_might_contain_json_string_field(line, b"type", b"assistant")
&& line_might_contain_json_string_field(line, b"type", b"text")
}

/// Build a byte-offset index by deserializing only likely human-user lines.
///
/// Claude transcripts are dominated by assistant/tool-result payloads. A
Expand Down Expand Up @@ -334,6 +352,9 @@ fn index_claude_user_turns(
if line.iter().any(|byte| !byte.is_ascii_whitespace()) {
if let Some(previous) = turns.last_mut() {
previous.following_line_count += 1;
if line_might_be_claude_assistant_text(&line) {
previous.last_assistant_text_line = Some((current_offset, bytes_read));
}
}
}
};
Expand Down Expand Up @@ -384,23 +405,27 @@ fn index_claude_user_turns(
start_offset: current_offset,
user_chunk,
following_line_count: 0,
last_assistant_text_line: None,
});
}
Ok(turns)
}

/// Overlay the index's cheap body-size surrogate onto reduced-stream
/// projections. `projected[i]` must correspond to `indexed[i]` (both are
/// emitted in transcript order); only rounds the reduced stream reports as
/// bodyless are overwritten, so ranges projected from real bodies keep their
/// exact counts. `.max(1)` mirrors Codex: a placeholder must always advertise
/// emitted in transcript order). Rounds before `first_loaded_turn` only
/// contributed their header (plus at most the single parsed preview line), so
/// the index surrogate is always the honest count there; rounds at or past it
/// projected real bodies and keep their exact counts unless the parse came
/// back empty. `.max(1)` mirrors Codex: a placeholder must always advertise
/// a fetchable body, or the flat view renders no expand affordance for it.
fn overlay_indexed_body_counts(
projected: &mut [ProjectedTurnMetadata],
indexed: &[ClaudeIndexedTurn],
first_loaded_turn: usize,
) {
for (turn, index_entry) in projected.iter_mut().zip(indexed) {
if turn.body_event_count > 0 {
for (turn_index, (turn, index_entry)) in projected.iter_mut().zip(indexed).enumerate() {
if turn_index >= first_loaded_turn && turn.body_event_count > 0 {
continue;
}
let body_event_count =
Expand Down Expand Up @@ -446,32 +471,69 @@ fn load_claude_turn_range_with_sequence(
)
}

/// Parse only the indexed final assistant-text line of an unloaded round.
/// The returned chunk is fed to `build_initial_window_from_turns`, which
/// consumes it into the round placeholder's last-reply preview and (via
/// projection) its real end timestamp — the same metadata providers that
/// stream full bodies get for free. Best-effort: any read/parse miss leaves
/// the round preview-less rather than failing the whole window.
fn load_claude_turn_preview_chunk(
file: &mut fs::File,
session_id: &str,
turn: &ClaudeIndexedTurn,
) -> Option<ActivityChunk> {
let (offset, length) = turn.last_assistant_text_line?;
let end_offset = offset.checked_add(length as u64)?;
load_claude_turn_range_with_sequence(
file,
session_id,
offset,
end_offset,
usize::try_from(offset).unwrap_or(usize::MAX),
None,
)
.ok()?
.into_iter()
.rfind(|chunk| chunk.function == imported_history::FUNCTION_ASSISTANT)
}

pub fn load_claude_code_initial_window_for_session(
conn: &Connection,
session_id: &str,
recent_turn_count: usize,
) -> Result<imported_history::window::ImportedHistoryInitialWindow, String> {
let file_stem = claude_file_stem_from_session_id(session_id)?;
let path = resolve_claude_session_path(conn, file_stem)?;
let indexed = index_claude_user_turns(session_id, &path)?;
load_claude_code_initial_window_from_path(session_id, &path, recent_turn_count)
}

fn load_claude_code_initial_window_from_path(
session_id: &str,
path: &Path,
recent_turn_count: usize,
) -> Result<imported_history::window::ImportedHistoryInitialWindow, String> {
let indexed = index_claude_user_turns(session_id, path)?;
if indexed.is_empty() {
return load_claude_code_history_from_path(session_id, &path).map(|chunks| {
return load_claude_code_history_from_path(session_id, path).map(|chunks| {
imported_history::window::build_initial_window(session_id, chunks, recent_turn_count)
});
}

let file_len = fs::metadata(path.as_path())
let file_len = fs::metadata(path)
.map_err(|err| format!("Failed to stat Claude history {}: {err}", path.display()))?
.len();
let first_loaded_turn = indexed
.len()
.saturating_sub(recent_turn_count.max(1).min(indexed.len()));
let mut file = fs::File::open(path.as_path())
let mut file = fs::File::open(path)
.map_err(|err| format!("Failed to open Claude history {}: {err}", path.display()))?;
let mut chunks = Vec::with_capacity(indexed.len().saturating_mul(2));
for (index, turn) in indexed.iter().enumerate() {
if index < first_loaded_turn {
chunks.push(turn.user_chunk.clone());
if let Some(preview) = load_claude_turn_preview_chunk(&mut file, session_id, turn) {
chunks.push(preview);
}
continue;
}
let end_offset = indexed
Expand All @@ -491,7 +553,7 @@ pub fn load_claude_code_initial_window_for_session(
chunks.append(&mut body);
}
let mut projected = project_activity_chunks(&chunks);
overlay_indexed_body_counts(&mut projected, &indexed);
overlay_indexed_body_counts(&mut projected, &indexed, first_loaded_turn);
Ok(imported_history::window::build_initial_window_from_turns(
session_id,
chunks,
Expand Down Expand Up @@ -625,7 +687,8 @@ pub fn load_claude_code_turn_index_for_session(
.map(|turn| turn.user_chunk.clone())
.collect::<Vec<_>>();
let mut projected = project_activity_chunks(&chunks);
overlay_indexed_body_counts(&mut projected, &indexed);
// Every round here is reduced (header-only), so the surrogate always wins.
overlay_indexed_body_counts(&mut projected, &indexed, indexed.len());
Ok(projected)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -169,7 +169,7 @@ fn byte_index_discovers_rounds_without_parsing_tool_result_bodies() {
.collect::<Vec<_>>();
let mut projected = project_activity_chunks(&user_chunks);
assert!(projected.iter().all(|turn| turn.body_event_count == 0));
overlay_indexed_body_counts(&mut projected, &indexed);
overlay_indexed_body_counts(&mut projected, &indexed, indexed.len());
assert_eq!(
projected
.iter()
Expand Down Expand Up @@ -365,33 +365,9 @@ fn claude_initial_window_placeholders_advertise_fetchable_bodies() {
}
std::fs::write(&path, content).expect("write fixture");

let indexed = index_claude_user_turns("claudecodeapp-counts", &path).expect("index user turns");
let file_len = std::fs::metadata(&path).expect("stat fixture").len();
let mut file = std::fs::File::open(&path).expect("open fixture");
let mut chunks = Vec::new();
for (index, turn) in indexed.iter().enumerate() {
if index + 1 < indexed.len() {
chunks.push(turn.user_chunk.clone());
continue;
}
let mut body = load_claude_turn_range(
&mut file,
"claudecodeapp-counts",
turn.start_offset,
file_len,
&turn.user_chunk.chunk_id,
)
.expect("load newest body");
chunks.append(&mut body);
}
let mut projected = project_activity_chunks(&chunks);
overlay_indexed_body_counts(&mut projected, &indexed);
let window = imported_history::window::build_initial_window_from_turns(
"claudecodeapp-counts",
chunks,
1,
projected,
);
let window =
load_claude_code_initial_window_from_path("claudecodeapp-counts", &path, 1)
.expect("load initial window");

assert_eq!(window.total_turn_count, 3);
assert_eq!(window.loaded_turn_count, 1);
Expand All @@ -401,11 +377,31 @@ fn claude_initial_window_placeholders_advertise_fetchable_bodies() {
.filter(|chunk| chunk.chunk_id.starts_with("imported-unloaded-turn-"))
.collect::<Vec<_>>();
assert_eq!(placeholders.len(), 2);
for placeholder in placeholders {
for (round, placeholder) in placeholders.iter().enumerate() {
let round = round + 1;
let body_event_count = placeholder.result["unloadedTurn"]["bodyEventCount"]
.as_i64()
.expect("bodyEventCount");
assert_eq!(body_event_count, 3);
// The unloaded round's placeholder carries the final-reply preview so
// the collapsed turn still shows its closing agent message…
assert_eq!(
placeholder.args.get("turnPreviewOnly"),
Some(&Value::Bool(true))
);
assert_eq!(
placeholder.result.get("observation").and_then(Value::as_str),
Some(format!("round {round} done").as_str())
);
// …and a real end timestamp so the collapse bar shows the round's
// duration and time range instead of "<1min".
let started_at = placeholder.result["unloadedTurn"]["startedAt"]
.as_str()
.expect("startedAt");
let ended_at = placeholder.result["unloadedTurn"]["endedAt"]
.as_str()
.expect("endedAt");
assert!(ended_at > started_at, "{ended_at} must be after {started_at}");
}
// The loaded newest round keeps its exact projected counts (no overlay).
assert_eq!(window.turns[2].body_event_count, 2);
Expand All @@ -414,6 +410,64 @@ fn claude_initial_window_placeholders_advertise_fetchable_bodies() {
std::fs::remove_dir(&temp_dir).expect("remove temp dir");
}

#[test]
fn claude_initial_window_previews_skip_tool_use_only_assistant_lines() {
let temp_dir = std::env::temp_dir().join(format!(
"orgii-claude-window-preview-test-{}",
std::process::id()
));
std::fs::create_dir_all(&temp_dir).expect("create temp dir");
let path = temp_dir.join("claude-window-preview.jsonl");
// Round 1's real reply is followed by a tool_use-only assistant line and
// its tool_result: the preview must come from the newest TEXT line, and
// the trailing unmatched tool_use must not leak a tool-call chunk into
// the placeholder preview.
let content = "\
{\"type\":\"user\",\"timestamp\":\"2026-04-01T07:00:00Z\",\"message\":{\"role\":\"user\",\"content\":\"first\"}}\n\
{\"type\":\"assistant\",\"timestamp\":\"2026-04-01T07:00:01Z\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"thinking\",\"thinking\":\"hmm\"},{\"type\":\"text\",\"text\":\"first reply\"}]}}\n\
{\"type\":\"assistant\",\"timestamp\":\"2026-04-01T07:00:02Z\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"tool_use\",\"id\":\"toolu_1\",\"name\":\"Bash\",\"input\":{\"command\":\"go\"}}]}}\n\
{\"type\":\"user\",\"timestamp\":\"2026-04-01T07:00:03Z\",\"message\":{\"role\":\"user\",\"content\":[{\"type\":\"tool_result\",\"tool_use_id\":\"toolu_1\",\"content\":\"ok\"}]}}\n\
{\"type\":\"user\",\"timestamp\":\"2026-04-01T07:01:00Z\",\"message\":{\"role\":\"user\",\"content\":\"second\"}}\n\
{\"type\":\"assistant\",\"timestamp\":\"2026-04-01T07:01:01Z\",\"message\":{\"role\":\"assistant\",\"content\":[{\"type\":\"text\",\"text\":\"second reply\"}]}}\n";
std::fs::write(&path, content).expect("write fixture");

let indexed =
index_claude_user_turns("claudecodeapp-preview", &path).expect("index user turns");
assert_eq!(indexed.len(), 2);
// The tool_use-only line and the tool_result line after the text reply
// must not displace the text line as the round's preview candidate.
let (offset, _) = indexed[0]
.last_assistant_text_line
.expect("round 1 preview candidate");
assert!(offset > indexed[0].start_offset);

let window = load_claude_code_initial_window_from_path("claudecodeapp-preview", &path, 1)
.expect("load initial window");
let placeholder = window
.chunks
.iter()
.find(|chunk| chunk.chunk_id.starts_with("imported-unloaded-turn-"))
.expect("round 1 placeholder");
assert_eq!(
placeholder.result.get("observation").and_then(Value::as_str),
Some("first reply")
);
// No stray body chunks may survive next to an unloaded round: its user
// header and placeholder are the only wire representation.
assert_eq!(
window
.chunks
.iter()
.filter(|chunk| chunk.function != imported_history::FUNCTION_USER_MESSAGE
&& !chunk.chunk_id.starts_with("imported-unloaded-turn-"))
.count(),
1 // the loaded newest round's single assistant reply
);

std::fs::remove_file(&path).expect("remove fixture");
std::fs::remove_dir(&temp_dir).expect("remove temp dir");
}

#[test]
fn parses_claude_session_metadata() {
let temp_dir = std::env::temp_dir().join(format!(
Expand Down
Loading