From 98efd263f7ee444010ecd3a3ed4d107a69e56bd0 Mon Sep 17 00:00:00 2001 From: Harry19081 <20519290+Harry19081@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:55:10 +0800 Subject: [PATCH] fix(orgtrack): add reply preview + end time to Claude placeholders Pre-commit hook ran. Total eslint: 5, total circular: 0 --- .../src/sources/claude_code/history.rs | 85 +++++++++++-- .../src/sources/claude_code/history_tests.rs | 112 +++++++++++++----- 2 files changed, 157 insertions(+), 40 deletions(-) diff --git a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history.rs b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history.rs index 8b13d6b6d..98915b44c 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history.rs @@ -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 { @@ -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 @@ -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)); + } } } }; @@ -384,6 +405,7 @@ fn index_claude_user_turns( start_offset: current_offset, user_chunk, following_line_count: 0, + last_assistant_text_line: None, }); } Ok(turns) @@ -391,16 +413,19 @@ fn index_claude_user_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 = @@ -446,6 +471,32 @@ 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 { + 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, @@ -453,25 +504,36 @@ pub fn load_claude_code_initial_window_for_session( ) -> Result { 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 { + 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 @@ -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, @@ -625,7 +687,8 @@ pub fn load_claude_code_turn_index_for_session( .map(|turn| turn.user_chunk.clone()) .collect::>(); 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) } diff --git a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history_tests.rs b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history_tests.rs index 1d8485bea..8b82d2c9e 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history_tests.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history_tests.rs @@ -169,7 +169,7 @@ fn byte_index_discovers_rounds_without_parsing_tool_result_bodies() { .collect::>(); 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() @@ -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); @@ -401,11 +377,31 @@ fn claude_initial_window_placeholders_advertise_fetchable_bodies() { .filter(|chunk| chunk.chunk_id.starts_with("imported-unloaded-turn-")) .collect::>(); 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); @@ -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!(