diff --git a/docs/frontend-ui-audit-2026-09-25/SessionSources.md b/docs/frontend-ui-audit-2026-09-25/SessionSources.md new file mode 100644 index 0000000000..97d562cb37 --- /dev/null +++ b/docs/frontend-ui-audit-2026-09-25/SessionSources.md @@ -0,0 +1,17 @@ +# Session Sources UI audit + +| Line | Element | Verdict | Reason | Suggested change | +| ---------------------------- | ------------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------- | ---------------------------------------------------------- | +| SessionSourceRow.tsx | Resource rows | fix | Earlier standalone row styling duplicated sidebar chrome | Resolved with SidebarRow and shared file/model icons | +| SessionToolActivityGroup.tsx | Activity disclosure | fix | Local typography overrides conflicted with detail panel defaults | Resolved with SidebarSectionHeader and useCollapsible | +| SessionToolActivityGroup.tsx | Failure details | fix | Error surface should follow shared feedback appearance | Resolved with PageNotice | +| SessionSourcesView.tsx | Canvas | fix | Generic surface grey differed from work area | Resolved with EDITOR_TAB_CANVAS_BG_CLASS | +| SessionSourcesView.tsx | Category headers / pagination | keep with reason | Shared SidebarSectionHeader and Button own geometry and controls | Preserve shared components | +| SessionSourceThumbnail.tsx | 3px miniature radius and size | keep with reason | Tiny source previews need their icon-sized optical geometry; width follows caller's shared icon scale | No global token change | +| SessionSourcesView.tsx | 8px row / 16px category spacing | keep with reason | Explicit user-approved density uses standard spacing utilities | Preserve local layout | +| SessionSourceRow.tsx | Path tooltip | keep with reason | Shared Tooltip exposes truncated human-readable location; encoded transport refs stay private | Preserve | +| features/SessionSources | Shared presentation/navigation | abstract | Rail preview, source detail rows and complete page need one identity/navigation implementation | Implemented feature module; old rail-only wrappers removed | + +Totals: 4 resolved fixes, 4 keep with reason, 1 implemented abstraction. No raw production buttons, inputs or clickable substitutes introduced. Metadata colors use semantic tokens; shared components own typography/hover/focus. No global design-system sweep. + +Visual evidence in ../verification-2026-09-25/session-sources: final categorized light page, dark 340px page, empty/loading/error states. These render actual feature and shared components using fictional source fixtures; navigation and image loading are mocked. They are not native-app E2E evidence. The user subsequently explicitly requested their original application screenshot; user-screenshot.png is included unchanged as the primary PR illustration. diff --git a/docs/frontend-ui-audit-2026-09-28/ChatPanelCompanionLayout.md b/docs/frontend-ui-audit-2026-09-28/ChatPanelCompanionLayout.md new file mode 100644 index 0000000000..e58f755064 --- /dev/null +++ b/docs/frontend-ui-audit-2026-09-28/ChatPanelCompanionLayout.md @@ -0,0 +1,67 @@ +# ChatPanelCompanionLayout UI audit + +| Line | Element | Verdict | Reason | Suggested change | +| ---------------------------------- | ---------------------------- | ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------- | +| `ChatPanelCompanionLayout.tsx:60` | Companion header and actions | keep with reason | Reuses PanelHeader, PANEL_HEADER_TOKENS, shared Button, existing translated title/close/move labels; no native action elements or custom clickable substitutes added | None | +| `ChatPanelCompanionLayout.tsx:48` | Resizable divider | keep with reason | Reuses VerticalResizeHandle and useColumnResize, including existing drag cancellation/unmount cleanup | None | +| `ChatPanelCompanionLayout.tsx:25` | Reading width constraints | keep with reason | Feature-owned initial/min/max reading widths; a 50% CSS cap reserves at least half the available pane for chat even in narrow layouts | None | +| `ChatPanelCompanionLayout.tsx:135` | Chat/companion composition | keep with reason | Stable chat subtree; companion replaces only the summary rail and unmounts on close or owner change | None | +| `SessionSourcesContent.tsx:10` | Shared source data container | keep with reason | Both the companion and Station renderer consume the same source hook, shared-file scope and SessionSourcesView; category disclosures, navigation, retry and theme tokens remain shared | None | + +Verdict totals: **0 fix**, **5 keep with reason**, **0 abstract**. + +## State and architecture + +The owning state is one transient per-store `ChatPanelCompanion`, identified by type, session ID and owner tab ID. Opening is idempotent and validates the active conversation at the write boundary. The host rejects stale scope immediately, then clears the slot on navigation/unmount. No new persistent setting, tab schema, API, database or wire protocol is introduced. Existing Station sources tabs remain compatible; opening there is explicit. + +Architecture review covered state ownership, type/control flow, default destination, content reuse, entry-point parity and session-scope resolution. Backend/schema/wire and migration layers are unchanged. This is not a general-purpose tab framework: the supported companion type is currently `session-sources`. + +The data path is View all → openSessionSources → openChatPanelSourcesAtom → ChatPanelShell/ChatPanelCompanionLayout → shared SessionSourcesContent → existing source history reader. Loading, empty, error/retry and same-session refresh behavior remain owned by useSessionSourcesState and SessionSourcesView. Closing releases the content; reopening is a fresh view, not a persisted cache. Category state is preserved through same-session refresh but resets after closing. + +## Verification + +- `pnpm test:app src/engines/ChatPanel/ChatPanelCompanionLayout.test.ts src/engines/ChatPanel/sessionSources/openSessionSources.test.ts src/engines/ChatPanel/sessionSources/useSessionSources.test.ts src/features/SessionSources/SessionSourcesView.test.ts src/modules/WorkStation/TabContent/renderers/sessionSources.test.ts src/modules/WorkStation/TabContent/renderers/sessionSources.activities.test.ts` — 41 tests passed in 6 files +- `pnpm typecheck:fast` — passed after adding the concrete HTMLDivElement ref type in the new test +- ESLint on all changed/new production and test files — passed +- Changed production controls inspected: only shared Button actions; no raw button/input or clickable element substitutes introduced +- Native visual automation cannot select the unbundled ORG2 Dev executable; browser-mode app cannot access desktop session IPC. Isolated real-component rendering is used for layout evidence, not claimed as native/data integration verification + +## Performance guard + +| Area | Verdict | Evidence | Change or reason kept | Verification | +| ------------------ | ------- | -------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| Background work | keep | Existing useSessionSourcesState history reads and visibility listener | Only mounted source content owns reads; no new polling; existing in-flight sharing and stale-response guard retained | Existing hook visibility/stale/error tests plus companion disposal tests | +| Memory | keep | One nullable atom slot and one mounted companion | No per-session map or app-lifetime result cache; close/navigation/unmount releases content | Composition tests assert disposal and slot clearing | +| Scope/isolation | keep | Owner tab ID plus session ID at write and render boundaries | Stale open is rejected; scope changes remove old content before effect cleanup | Stale-action and session/tab-switch tests | +| Rendering/hot path | keep | Chat subtree retains position; source content lazy loads; shared resize hook uses DOM writes | Open/repeat/close preserves the same input node and unsent draft | Parent shell composition test | + +Performance verdict: **blocked** for native runtime measurement only. Unit ownership/lifecycle checks passed; native idle CPU/RSS, native window resizing and cloud-session manual validation were not measured. No runtime performance improvement is claimed. + +### Rendered follow-up + +- Real ChatPanelCompanionLayout, shared header/buttons, resizing, SessionSourcesContent and SessionSourcesView rendered in an isolated browser fixture. Only desktop state/API/navigation dependencies and source payloads were fixtures. +- Verified View all opens alongside the chat draft, repeated open retains the view, category collapse works, and close restores the summary. Inspected dark/narrow and light/wide rendering. +- At 1200px, source content measured 420px and chat 779px. Dragging the divider 80px left grew sources to 500px. +- At 600px, sources were capped at 300px. Found that the shared resize hook initially used the uncapped stored width; changed drag initialization to read the actual layout width. Verified dragging 20px right immediately shrinks the pane to 280px. The hook still uses its configured width when no measured width is available. +- `pnpm test:app src/scaffold/Resize/hooks/useColumnResize.test.ts src/engines/ChatPanel/ChatPanelCompanionLayout.test.ts` — 5 passed, including the new constrained-width regression (42 distinct relevant tests passed across both runs). +- Typecheck and lint passed after the resize change; `git diff --check` for the tracked files touched by this change passed. Native runtime measurement remains unverified as stated above. + +### Floating chrome overlap regression + +The initial isolated preview used an in-flow sample header, so it missed the live shell's absolute overlay chrome. The source companion started at y=0 and its header/actions overlapped the global conversation toolbar. ChatPanel now passes its canonical chromeTopInsetPx through ChatPanelShell to the companion and its resize divider, just as the transcript and summary rail already consume it. No independent header-height constants were added. + +- Regression coverage renders real ChatPanelShell and ChatPanelChrome, transitions between collapsed overlay (44px), expanded overlay (80px), and in-flow (0px), and asserts that the source pane, divider and unchanged chat/source mounts follow the shared inset. +- `pnpm test:app src/engines/ChatPanel/ChatPanelCompanionLayout.test.ts src/engines/ChatPanel/header/ChatPanelChrome.test.ts src/engines/ChatPanel/header/chatPanelHeaderLayout.test.ts` — 28 tests passed in 3 files. +- `pnpm typecheck:fast` and ESLint on the four changed production/test files passed; tracked-file diff whitespace checks passed. +- Rendered real ChatPanelChrome and its published-header slot component with the companion: light 1280px collapsed toolbar and dark 600px expanded toolbar. Source header/actions appear below the global toolbar in both. Collapsed companion top measured 44px; narrow expanded screenshot confirms separation and 50% width cap. Closing sources works and retains the chat draft. +- Preview fixtures replace desktop state/platform dependencies, source payloads and navigation; this is rendered component evidence, not native app verification. Native automation limitation described above still applies. + +### Isolated PR branch verification + +After integrating latest `origin/develop` without rewriting reviewed history: + +- `pnpm test src/features/SessionSources src/engines/ChatPanel/sessionSources src/engines/ChatPanel/ChatPanelCompanionLayout.test.ts src/engines/ChatPanel/header/ChatPanelChrome.test.ts src/engines/ChatPanel/header/chatPanelHeaderLayout.test.ts src/scaffold/Resize/hooks/useColumnResize.test.ts src/modules/WorkStation/TabContent/renderers/sessionSources.test.ts src/modules/WorkStation/TabContent/renderers/sessionSources.activities.test.ts` — 98 tests passed in 15 files. +- `pnpm typecheck:fast` — passed on the isolated PR branch. +- ESLint on the 14 changed/new TypeScript files — passed. +- `pnpm check:test-placement` — passed, 636 directories. +- `git diff --cached --check` — passed; staged scope and private-path/debug-output inspection found no unrelated additions. diff --git a/docs/frontend-ui-audit-2026-09-28/SessionSourceCategory.md b/docs/frontend-ui-audit-2026-09-28/SessionSourceCategory.md new file mode 100644 index 0000000000..834e69b8b7 --- /dev/null +++ b/docs/frontend-ui-audit-2026-09-28/SessionSourceCategory.md @@ -0,0 +1,13 @@ +# Session source categories UI audit + +| Line | Element | Verdict | Reason | Suggested change | +| ---------------------------------------------------------- | ------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------ | +| `src/features/SessionSources/SessionSourceCategory.tsx:31` | Category disclosure | keep with reason | Reuses SidebarSectionHeader with expanded/onToggle, matching existing tool activity disclosures. Shared Button supplies native keyboard activation and aria-expanded; DisclosureChevron supplies shared motion and reduced-motion rules. | Keep the existing header family; no new accordion primitive. | +| `src/features/SessionSources/SessionSourceCategory.tsx:40` | Category body | keep with reason | Preserves existing row spacing, tokens and shared source/tool rows. Hidden bodies unmount; category-local page count survives reopening. | Keep shared row composition. | +| `src/features/SessionSources/SessionSourceCategory.tsx:60` | More sources | keep with reason | Existing tertiary small Button and translated label are retained. | No raw controls or new visual tokens. | + +Verdict totals: **0 fix**, **3 keep with reason**, **0 abstract**. + +All four categories share one SessionSourceCategory adapter. This adapter owns only local disclosure and pagination and calls the existing useCollapsible hook; no persistence or generic component API is added. Existing grouping, provenance, navigation and gallery ownership stay in place. + +Verified independent mouse/keyboard collapse, count visibility, refresh/error/retry continuity, retained pagination and session-key remount reset. Chrome component preview uses real UI components, Tailwind, utility styles and light/dark token sheets; native file/image navigation is stubbed in that preview. Light 1000px and dark 420px screenshots are in docs/verification-2026-09-28/source-collapse. No native App or mobile device visual claim is made. diff --git a/docs/org2-performance-guard-2026-09-25/SessionSources.md b/docs/org2-performance-guard-2026-09-25/SessionSources.md new file mode 100644 index 0000000000..8c901176f4 --- /dev/null +++ b/docs/org2-performance-guard-2026-09-25/SessionSources.md @@ -0,0 +1,9 @@ +# Session Sources lifecycle audit + +Source requests are session scoped, single-flight while pending and released by consumer reference count. Pending request ownership is removed at settlement/disposal; no completed-result global cache. Hidden documents pause noncritical refresh; visibility return revalidates. Session-keyed renderer prevents stale list/gallery/page state crossing sessions. Inactive tabs do not read history or resolve images. Old rows survive failed refresh; retry is explicit. + +Reader work is bounded at input-line, reference, pending-call, nested-resource and tool-action boundaries. Native SQL extracts compact visible source messages, not entire session objects. Structured references and completed tool facts are separate; errors never masquerade as successful resources, and edit receipts do not synthesize attachments. + +Category arrays and semantic detail maps live with the mounted view. Four category page counters start at 30 rows each (at most 30 image thumbnail reads); activity details start at 20 unique rows per action type. More content loads by explicit action. Duplicate display actions retain original call IDs and occurrence totals. No new recurring poller, worker or network transport. + +Validation covers coalesced reads, refresh/session races, hidden/inactive lifecycle, retry, page reset, image identity/gallery, activity aggregation and actual provider transcript readers. No native CPU/RSS sampling, large-history benchmark, dual-machine sharing run or complete packaged-app E2E in this PR. Verdict: scoped/bounded lifecycle supported by tests; no measured performance claim. diff --git a/docs/verification-2026-09-25/SessionSources.md b/docs/verification-2026-09-25/SessionSources.md new file mode 100644 index 0000000000..3d1ce4a2a0 --- /dev/null +++ b/docs/verification-2026-09-25/SessionSources.md @@ -0,0 +1,17 @@ +# Categorized conversation Sources + +The focused chat rail opens a stable session-scoped Sources tab. Images, files, links and tool activity appear in fixed category order, newest occurrence first inside each category. Empty categories disappear; each category independently pages 30 rows. Rail previews prioritize real resources before tool groups. View all stays reachable while folded; the duplicate Sources row is omitted from Opened Tabs. Sources hides the editor tree/shared toolbar without changing stored sidebar preferences. + +One read-only source projection serves native storage, Codex transcripts and Claude transcripts. It includes visible user attachments, assistant references and explicit successful structured resources, plus completed tool activity and error summaries. It never scans arbitrary shell/JavaScript/log text for resource paths. Codex FileChange receipts remain activity rather than fabricated artifacts. Repeated identical tool actions aggregate by exact tool/action/query/url/status/error, preserving call identities and total counts. Explicit artifacts/citations still appear. + +Source reference/navigation identity remains canonical; display paths unwrap transcript image envelopes without leaking session IDs. Shared/imported files use sender org/session/endpoint scope; the new develop shared-file tab opener is verified at its navigation boundary rather than the previous overlay seam. + +Compatibility: new optional role/toolName/toolActivity fields extend the existing source-message wire response, with defaults preserving old user-only producers. A new session-sources tab kind participates in persistence/classification. No database migration or write to message history. Older binaries may discard unknown saved tabs; reopening View all recreates this ephemeral view. Rollback is code revert; historical source projections update on reread. + +Architecture reviewed: module ownership, duplicate presentation, types, provider-neutral projection, fallback behavior, wire compatibility, reader entrypoints and scope-aware navigation. No auth or sync protocol changed. Root cause of repeated labels was many distinct calls projected identically, not duplicate persisted messages; no destructive history cleanup. + +## Verification + +Final checks and counts are recorded in the PR description. Frontend suite includes feature, reader hook/extraction, rail, actual renderer, image navigation, sidebar/header tab transitions and tab persistence classification. Rust suites exercise shared projector, Codex/Claude actual transcript readers and native SQL source extraction. Scoped lint and typecheck run from the isolated latest-develop worktree. + +Screenshots are actual production components rendered with fictional fixtures and mocked navigation/image resolution. Light and dark narrow layouts plus empty/loading/error states were inspected. These do not prove native application full-stack behavior. No complete application Rust build, Windows/Linux native verification, remote dual-instance or huge transcript benchmark was run on this isolated PR branch. diff --git a/docs/verification-2026-09-25/session-sources/categories-dark-narrow.png b/docs/verification-2026-09-25/session-sources/categories-dark-narrow.png new file mode 100644 index 0000000000..61d38359b9 Binary files /dev/null and b/docs/verification-2026-09-25/session-sources/categories-dark-narrow.png differ diff --git a/docs/verification-2026-09-25/session-sources/categories-light.png b/docs/verification-2026-09-25/session-sources/categories-light.png new file mode 100644 index 0000000000..7492cfadae Binary files /dev/null and b/docs/verification-2026-09-25/session-sources/categories-light.png differ diff --git a/docs/verification-2026-09-25/session-sources/empty.png b/docs/verification-2026-09-25/session-sources/empty.png new file mode 100644 index 0000000000..b398fb6a24 Binary files /dev/null and b/docs/verification-2026-09-25/session-sources/empty.png differ diff --git a/docs/verification-2026-09-25/session-sources/error.png b/docs/verification-2026-09-25/session-sources/error.png new file mode 100644 index 0000000000..c4e25f4086 Binary files /dev/null and b/docs/verification-2026-09-25/session-sources/error.png differ diff --git a/docs/verification-2026-09-25/session-sources/loading.png b/docs/verification-2026-09-25/session-sources/loading.png new file mode 100644 index 0000000000..df72891a30 Binary files /dev/null and b/docs/verification-2026-09-25/session-sources/loading.png differ diff --git a/docs/verification-2026-09-25/session-sources/user-screenshot.png b/docs/verification-2026-09-25/session-sources/user-screenshot.png new file mode 100644 index 0000000000..4d02df443a Binary files /dev/null and b/docs/verification-2026-09-25/session-sources/user-screenshot.png differ diff --git a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/windows/index.rs b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/windows/index.rs index 401e8c540d..4d611b97d8 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/windows/index.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/windows/index.rs @@ -43,9 +43,6 @@ pub(in crate::sources::claude_code::history) struct ClaudeIndexedTurn { /// full-stream provider derives in `build_initial_window_from_turns` — /// without materializing the whole round body. pub(in crate::sources::claude_code::history) last_assistant_text_line: Option<(u64, usize)>, - /// The user row carries image blocks. `user_chunk` keeps URL refs only - /// (bounded); inline bytes stay in the source row. - pub(in crate::sources::claude_code::history) has_images: bool, } pub(in crate::sources::claude_code::history) fn claude_window_turn_id(start_offset: u64) -> String { @@ -255,7 +252,6 @@ pub(in crate::sources::claude_code::history) fn index_claude_user_turns( user_chunk, following_line_count: 0, last_assistant_text_line: None, - has_images, }); } Ok(turns) diff --git a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/windows/sources.rs b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/windows/sources.rs index 08b8631412..36e2f2658a 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/windows/sources.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history/windows/sources.rs @@ -1,4 +1,4 @@ -//! Claude Code user-message sources, and on-demand reads of the images they +//! Claude Code conversation resource sources, and on-demand reads of the images they //! reference. //! //! Claude stores pasted images inline as base64 in the user row. Sources @@ -7,17 +7,27 @@ //! id), and `load_claude_code_image_from_path` re-reads that one row when a //! thumbnail or the image viewer asks for it. +use std::collections::{HashSet, VecDeque}; use std::fs::File; + +use memchr::memchr; use std::io::{BufRead, BufReader, Read, Seek, SeekFrom}; use std::path::Path; use serde_json::Value; -use crate::sources::imported_history::user_sources::{transcript_image_ref, UserSourceMessage}; +use crate::sources::imported_history::user_sources::{ + transcript_image_ref, SourceMessageRole, UserSourceMessage, +}; -use super::super::replay::claude_image_sources; -use super::super::types::ClaudeJsonlLine; -use super::index::{claude_window_turn_offset, index_claude_user_turns}; +use super::super::replay::{ + claude_image_sources, claude_local_command_input, claude_local_command_output, +}; +use super::super::types::{ + is_claude_compact_summary, is_harness_injected_user_line, ClaudeControlEnvelope, + ClaudeJsonlLine, +}; +use super::index::{claude_window_turn_id, claude_window_turn_offset}; const INLINE_IMAGE_REF_PREFIX: &str = "claude-inline-image:"; const MAX_USER_ROW_BYTES: u64 = 16 * 1024 * 1024; @@ -26,31 +36,147 @@ pub fn load_claude_code_user_source_messages_from_path( session_id: &str, path: &Path, ) -> Result, String> { - let turns = index_claude_user_turns(session_id, path)?; - let mut image_rows: Option = None; + let mut reader = BufReader::with_capacity(1024 * 1024, open_history(path)?); let mut messages = Vec::new(); - for turn in turns { - let turn_id = turn.user_chunk.chunk_id.clone(); - let text = turn - .user_chunk - .result - .pointer("/message/content") - .and_then(Value::as_str) - .unwrap_or_default(); - let images = if turn.has_images { - let file = match image_rows.as_mut() { - Some(file) => file, - None => image_rows.insert(open_history(path)?), - }; - user_row_image_refs(file, turn.start_offset)? - .iter() - .map(|original| transcript_image_ref(session_id, &turn_id, original)) - .collect() + let mut calls: VecDeque<(String, String, Value)> = VecDeque::new(); + let mut control = ClaudeControlEnvelope::default(); + visit_bounded_lines(&mut reader, MAX_USER_ROW_BYTES as usize, |offset, line| { + let Ok(parsed) = serde_json::from_slice::(line) else { + return; + }; + if control.observe(&parsed) + || is_harness_injected_user_line(&parsed) + || is_claude_compact_summary(&parsed) + || parsed.is_api_error_message + { + return; + } + if !matches!(parsed.r#type.as_str(), "user" | "assistant") { + return; + } + let Some(message) = &parsed.message else { + return; + }; + let id = claude_window_turn_id(offset); + let content = &message.content; + let text = if let Some(text) = content.as_str() { + text.to_owned() } else { - Vec::new() + content + .as_array() + .into_iter() + .flatten() + .filter(|part| part.get("type").and_then(Value::as_str) == Some("text")) + .filter_map(|part| part.get("text").and_then(Value::as_str)) + .collect::>() + .join("\n") }; - messages.extend(UserSourceMessage::new(turn_id, text, images)); - } + // Harness command stdout is presented as provider output, not as a + // human attachment; preserve the existing user-turn normalization. + let text = if parsed.r#type == "user" { + let stripped = crate::sources::imported_history::strip_orgii_exec_mode_bridge(&text); + if claude_local_command_output(stripped).is_some() { + return; + } + claude_local_command_input(stripped).unwrap_or_else(|| stripped.to_owned()) + } else { + text + }; + if parsed.r#type == "assistant" { + messages.extend(UserSourceMessage::with_role( + &id, + &text, + Vec::new(), + SourceMessageRole::Assistant, + )); + } else { + let images = + claude_image_sources(content) + .enumerate() + .filter_map(|(position, source)| { + match source.get("type").and_then(Value::as_str) { + Some("url") => { + source.get("url").and_then(Value::as_str).map(str::to_owned) + } + Some("base64") => Some(transcript_image_ref( + session_id, + &id, + &format!("{INLINE_IMAGE_REF_PREFIX}{position}"), + )), + _ => None, + } + }); + messages.extend(UserSourceMessage::new(&id, &text, images)); + } + for part in content.as_array().into_iter().flatten() { + match part.get("type").and_then(Value::as_str) { + Some("tool_use") if parsed.r#type == "assistant" => { + let (Some(call_id), Some(name)) = ( + part.get("id").and_then(Value::as_str), + part.get("name").and_then(Value::as_str), + ) else { + continue; + }; + let arguments = &part["input"]; + if call_id.len() > 512 + || name.len() > 256 + || arguments.to_string().len() > 64 * 1024 + { + continue; + } + calls.retain(|(key, _, _)| key != call_id); + if calls.len() == 256 { + calls.pop_front(); + } + calls.push_back((call_id.to_owned(), name.to_owned(), arguments.clone())); + } + Some("tool_result") if parsed.r#type == "user" => { + let Some(call_id) = part.get("tool_use_id").and_then(Value::as_str) else { + continue; + }; + let Some(index) = calls.iter().position(|(key, _, _)| key == call_id) else { + continue; + }; + let Some((_, name, arguments)) = calls.remove(index) else { + continue; + }; + let success = !part + .get("is_error") + .and_then(Value::as_bool) + .unwrap_or(false); + // Sidecar resources belong to this same logical call, not + // another activity. Never attach a sidecar to ambiguous multi-results. + let mut result = part.clone(); + if let Some(sidecar) = parsed.tool_use_result.as_ref().filter(|_| { + content + .as_array() + .into_iter() + .flatten() + .filter(|part| { + part.get("type").and_then(Value::as_str) == Some("tool_result") + }) + .count() + == 1 + }) { + result["structuredContent"] = sidecar.clone(); + } + messages.extend(UserSourceMessage::from_tool( + format!("claude-call:{call_id}"), + &name, + &arguments, + &result, + success, + )); + } + _ => {} + } + } + }) + .map_err(|err| format!("Failed to read Claude sources: {err}"))?; + let mut seen_calls = HashSet::new(); + messages.retain(|message| { + message.role != SourceMessageRole::Tool || seen_calls.insert(message.id.clone()) + }); Ok(messages) } @@ -98,26 +224,6 @@ fn open_history(path: &Path) -> Result { .map_err(|err| format!("Failed to open Claude history {}: {err}", path.display())) } -/// Web URLs as they are; inline images as positional stand-ins. -fn user_row_image_refs(file: &mut File, offset: u64) -> Result, String> { - let Some(message) = read_user_row(file, offset)?.and_then(|row| row.message) else { - return Ok(Vec::new()); - }; - Ok(claude_image_sources(&message.content) - .enumerate() - .filter_map( - |(position, source)| match source.get("type").and_then(Value::as_str) { - Some("url") => source - .get("url") - .and_then(Value::as_str) - .map(str::to_string), - Some("base64") => Some(format!("{INLINE_IMAGE_REF_PREFIX}{position}")), - _ => None, - }, - ) - .collect()) -} - fn read_user_row(file: &mut File, offset: u64) -> Result, String> { file.seek(SeekFrom::Start(offset)) .map_err(|err| format!("Failed to seek Claude history: {err}"))?; @@ -133,9 +239,52 @@ fn read_user_row(file: &mut File, offset: u64) -> Result .filter(|row| row.r#type == "user")) } +/// Visit each complete line with its starting byte offset. A line longer than +/// `max_line_bytes` is skipped without being buffered. +fn visit_bounded_lines( + reader: &mut impl BufRead, + max_line_bytes: usize, + mut visit: impl FnMut(u64, &[u8]), +) -> std::io::Result<()> { + let mut line = Vec::new(); + let mut offset = 0u64; + let mut line_start = 0u64; + let mut oversized = false; + loop { + let available = reader.fill_buf()?; + if available.is_empty() { + if !oversized && !line.is_empty() { + visit(line_start, &line); + } + return Ok(()); + } + let (consumed, complete) = match memchr(b'\n', available) { + Some(index) => (index + 1, true), + None => (available.len(), false), + }; + if !oversized { + if line.len() + consumed > max_line_bytes { + oversized = true; + line = Vec::new(); + } else { + line.extend_from_slice(&available[..consumed]); + } + } + reader.consume(consumed); + offset += consumed as u64; + if complete { + if !oversized { + visit(line_start, &line); + } + line.clear(); + oversized = false; + line_start = offset; + } + } +} + #[cfg(test)] mod tests { - use super::super::index::claude_window_turn_id; use super::*; #[test] @@ -153,8 +302,15 @@ mod tests { let turn_id = claude_window_turn_id(assistant.len() as u64 + 1); let inline = format!("{INLINE_IMAGE_REF_PREFIX}0"); assert_eq!( - messages, + messages + .iter() + .filter(|message| message.role == SourceMessageRole::User) + .cloned() + .collect::>(), vec![UserSourceMessage { + role: SourceMessageRole::User, + tool_name: None, + tool_activity: None, id: turn_id.clone(), text: "look at https://a.dev/page".to_string(), images: vec![ @@ -173,4 +329,155 @@ mod tests { ); std::fs::remove_dir_all(dir).unwrap(); } + #[test] + fn streams_visible_assistant_and_paired_successful_tool_resources() { + use serde_json::json; + let dir = std::env::temp_dir().join(format!( + "orgii-claude-source-conversation-{}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("session.jsonl"); + let records = [ + json!({"type":"user","isMeta":true,"uuid":"meta","message":{"content":"https://hidden.example/meta"}}), + json!({"type":"user","isCompactSummary":true,"message":{"content":"https://hidden.example/summary"}}), + json!({"type":"assistant","message":{"content":[{"type":"thinking","thinking":"https://hidden.example/thought"},{"type":"text","text":"[Report](/repo/report.md) https://public.example"},{"type":"tool_use","id":"read1","name":"Read","input":{"file_path":"/repo/input.ts"}},{"type":"tool_use","id":"read2","name":"Read","input":{"file_path":"/missing.ts"}},{"type":"tool_use","id":"report","name":"make_report","input":{}}]}}), + json!({"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"read1","content":"https://untrusted.example/file-body"},{"type":"tool_result","tool_use_id":"read2","is_error":true,"content":"Not found"},{"type":"tool_result","tool_use_id":"report","content":[{"type":"resource_link","uri":"https://reports.example/result"},{"type":"text","text":"https://untrusted.example/tool-text"}]}]}}), + json!({"type":"assistant","isApiErrorMessage":true,"message":{"content":[{"type":"text","text":"https://hidden.example/api-error"}]}}), + json!({"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"unknown","content":[{"type":"resource_link","uri":"https://unknown.example"}]}]}}), + ]; + std::fs::write( + &path, + records + .iter() + .map(Value::to_string) + .collect::>() + .join("\n"), + ) + .unwrap(); + let messages = + load_claude_code_user_source_messages_from_path("claudecodeapp-x", &path).unwrap(); + let text = messages + .iter() + .map(|message| message.text.as_str()) + .collect::>() + .join("\n"); + for reference in [ + "/repo/report.md", + "https://public.example", + "/repo/input.ts", + "https://reports.example/result", + ] { + assert!(text.contains(reference), "missing {reference}: {text}"); + } + for excluded in [ + "hidden.example", + "untrusted.example", + "unknown.example", + "/missing.ts", + ] { + assert!(!text.contains(excluded), "unexpected {excluded}: {text}"); + } + assert_eq!( + messages + .iter() + .filter(|message| message.role == SourceMessageRole::Assistant) + .count(), + 1 + ); + assert!(messages + .iter() + .any(|message| message.tool_name.as_deref() == Some("Read"))); + assert!(messages + .iter() + .all(|message| message.role != SourceMessageRole::User)); + use std::io::Write; + let mut file = std::fs::OpenOptions::new() + .append(true) + .open(&path) + .unwrap(); + writeln!(file, "\n{}", json!({"type":"assistant","message":{"content":[{"type":"text","text":"https://appended.example/new"}]}})).unwrap(); + let reread = + load_claude_code_user_source_messages_from_path("claudecodeapp-x", &path).unwrap(); + assert_eq!(reread.len(), messages.len() + 1); + assert!(reread + .last() + .unwrap() + .text + .contains("https://appended.example/new")); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn retains_failed_terminal_and_web_actions_without_double_counting_sidecars_or_replayed_calls() + { + use crate::sources::imported_history::user_sources::{ + ToolActivityKind, ToolActivityStatus, + }; + use serde_json::json; + let dir = std::env::temp_dir().join(format!( + "orgii-claude-source-activities-{}", + std::process::id() + )); + std::fs::create_dir_all(&dir).unwrap(); + let path = dir.join("session.jsonl"); + let calls = json!({"type":"assistant","message":{"content":[ + {"type":"tool_use","id":"terminal","name":"mcp__codex_app__read_thread_terminal","input":{}}, + {"type":"tool_use","id":"web","name":"web.run","input":{"search_query":[{"q":"full query"}],"open":[{"ref_id":"https://docs.example/page"}]}} + ]}}); + let failed = json!({"type":"user","message":{"content":[{"type":"tool_result","tool_use_id":"terminal","is_error":true,"content":"No terminal attached"}]}}); + let web = json!({"type":"user","toolUseResult":{"artifact_path":"/repo/report.md"},"message":{"content":[{"type":"tool_result","tool_use_id":"web","content":"ordinary output"}]}}); + // A replayed provider record must not create another logical tool call. + std::fs::write( + &path, + [&calls, &failed, &web, &calls, &failed, &web] + .iter() + .map(|record| record.to_string()) + .collect::>() + .join("\n"), + ) + .unwrap(); + let messages = + load_claude_code_user_source_messages_from_path("claudecodeapp-x", &path).unwrap(); + let activities: Vec<_> = messages + .iter() + .filter_map(|message| message.tool_activity.as_ref()) + .collect(); + assert_eq!(activities.len(), 2); + let terminal = activities + .iter() + .find(|activity| activity.call_id == "claude-call:terminal") + .unwrap(); + assert_eq!(terminal.status, ToolActivityStatus::Error); + assert_eq!(terminal.error.as_deref(), Some("No terminal attached")); + assert_eq!(terminal.actions[0].kind, ToolActivityKind::ReadTerminal); + let web = activities + .iter() + .find(|activity| activity.call_id == "claude-call:web") + .unwrap(); + assert_eq!(web.actions[0].query.as_deref(), Some("full query")); + assert_eq!( + web.actions[1].url.as_deref(), + Some("https://docs.example/page") + ); + assert!(messages + .iter() + .any(|message| message.text.contains("/repo/report.md"))); + assert_eq!( + load_claude_code_user_source_messages_from_path("claudecodeapp-x", &path).unwrap(), + messages + ); + std::fs::remove_dir_all(dir).unwrap(); + } + + #[test] + fn bounded_stream_skips_oversized_line_and_preserves_next_image_offset() { + let mut reader = std::io::Cursor::new(format!("{}\nnext\n", "x".repeat(100))); + let mut seen = Vec::new(); + visit_bounded_lines(&mut reader, 8, |offset, bytes| { + seen.push((offset, bytes.to_vec())) + }) + .unwrap(); + assert_eq!(seen, vec![(101, b"next\n".to_vec())]); + } } diff --git a/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/sources.rs b/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/sources.rs index e6dbd161dc..4cfe35782f 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/sources.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/codex/app/transcript/sources.rs @@ -1,18 +1,22 @@ -//! Every user message of a Codex rollout, reduced to its sources. +//! Explicit resources from user messages, visible assistant replies and successful tools. //! -//! Unlike the turn catalog this keeps each message's full text and every -//! image reference, but it still parses only lines that can be user -//! messages. Images become transcript refs keyed by the line's byte offset — +//! Lines are streamed under a read ceiling; only compact resource projections +//! and a bounded pending-call window survive each line. User images become +//! transcript refs keyed by the line's byte offset — //! the same turn id the catalog uses — so `load_codex_image_from_path` can //! read the embedded bytes back when a thumbnail asks for them. +use std::collections::{HashMap, VecDeque}; use std::fs::File; use std::io::{BufRead, BufReader}; use std::path::Path; -use memchr::{memchr, memmem}; +use memchr::memchr; +use serde_json::Value; -use crate::sources::imported_history::user_sources::{transcript_image_ref, UserSourceMessage}; +use crate::sources::imported_history::user_sources::{ + transcript_image_ref, SourceMessageRole, ToolActivityStatus, UserSourceMessage, +}; use super::super::CodexJsonlLine; use super::catalog::codex_lazy_turn_id; @@ -22,8 +26,6 @@ use super::messages::user_message_from_line; /// transcript ref stays small; `load_codex_image_from_path` maps it back to /// the row's image at that position. pub(super) const INLINE_IMAGE_REF_PREFIX: &str = "codex-inline-image:"; -const LEGACY_USER_MESSAGE_NEEDLE: &[u8] = b"\"user_message\""; -const PAGINATED_USER_MESSAGE_NEEDLE: &[u8] = b"\"UserMessage\""; /// Same ceiling the image resolver reads a user line with. const MAX_USER_LINE_BYTES: usize = 16 * 1024 * 1024; const READ_BUFFER_BYTES: usize = 1024 * 1024; @@ -36,15 +38,13 @@ pub fn load_codex_user_source_messages_from_path( .map_err(|err| format!("Failed to open Codex history {}: {err}", path.display()))?; let mut reader = BufReader::with_capacity(READ_BUFFER_BYTES, file); let mut messages = Vec::new(); + let mut calls: VecDeque<(String, String, Value)> = VecDeque::new(); visit_bounded_lines(&mut reader, MAX_USER_LINE_BYTES, |offset, line| { - if memmem::find(line, LEGACY_USER_MESSAGE_NEEDLE).is_none() - && memmem::find(line, PAGINATED_USER_MESSAGE_NEEDLE).is_none() - { - return; - } let Ok(parsed) = serde_json::from_slice::(line) else { return; }; + let id = codex_lazy_turn_id(offset); + collect_codex_conversation_sources(&parsed.payload, &id, &mut calls, &mut messages); let Some(message) = user_message_from_line(&parsed) else { return; }; @@ -65,7 +65,285 @@ pub fn load_codex_user_source_messages_from_path( messages.extend(UserSourceMessage::new(turn_id, &message.text, images)); }) .map_err(|err| format!("Failed to read Codex history {}: {err}", path.display()))?; - Ok(messages) + Ok(deduplicate_tool_sources(messages)) +} + +// The model-facing output and UI completion may both describe one call. +// This transient index is bounded by the returned activity rows, not retained +// between reads. Preserve resource text from either projection. +fn deduplicate_tool_sources(messages: Vec) -> Vec { + let mut indexes: HashMap = HashMap::new(); + let mut result: Vec = Vec::new(); + for message in messages { + if message.role == SourceMessageRole::Tool { + if let Some(index) = indexes.get(&message.id).copied() { + let previous = &mut result[index]; + for line in message.text.lines() { + if !previous.text.lines().any(|existing| existing == line) { + if !previous.text.is_empty() { + previous.text.push('\n'); + } + previous.text.push_str(line); + } + } + if let Some(activity) = message.tool_activity { + let replace = previous.tool_activity.as_ref().is_none_or(|old| { + (old.error.is_none() && activity.error.is_some()) + || (old.status == ToolActivityStatus::Success + && activity.status == ToolActivityStatus::Error) + || activity.actions.len() > old.actions.len() + }); + if replace { + previous.tool_activity = Some(activity); + } + } + continue; + } + indexes.insert(message.id.clone(), result.len()); + } + result.push(message); + } + result +} + +fn qualified_tool_name(namespace: Option<&str>, name: &str) -> String { + if name.contains('.') || name.starts_with("mcp__") { + return name.to_owned(); + } + match namespace { + Some(namespace) if !namespace.is_empty() => format!("{namespace}.{name}"), + _ => name.to_owned(), + } +} + +// Pair only a bounded window of pending calls. Keep structured arguments, never +// shell/log output; unknown or evicted calls cannot fabricate accessed resources. +const MAX_PENDING_SOURCE_CALLS: usize = 256; +const MAX_SOURCE_CALL_BYTES: usize = 64 * 1024; + +fn source_text(content: &Value) -> String { + if let Some(text) = content.as_str() { + return text.to_owned(); + } + content + .as_array() + .into_iter() + .flatten() + .filter(|part| { + matches!( + part.get("type").and_then(Value::as_str), + Some("text" | "output_text") + ) + }) + .filter_map(|part| part.get("text").and_then(Value::as_str)) + .collect::>() + .join("\n") +} + +fn collect_codex_conversation_sources( + payload: &Value, + id: &str, + calls: &mut VecDeque<(String, String, Value)>, + messages: &mut Vec, +) { + let kind = payload + .get("type") + .and_then(Value::as_str) + .unwrap_or_default(); + let assistant = match kind { + "message" + if payload.get("role").and_then(Value::as_str) == Some("assistant") + && payload.get("channel").and_then(Value::as_str) != Some("analysis") => + { + Some(source_text(&payload["content"])) + } + "agent_message" if payload.get("message").is_some() => payload + .get("message") + .and_then(Value::as_str) + .map(str::to_owned), + "item_completed" + if payload.pointer("/item/type").and_then(Value::as_str) == Some("AgentMessage") => + { + Some(source_text(&payload["item"]["content"])) + } + _ => None, + }; + if let Some(text) = assistant { + messages.extend(UserSourceMessage::with_role( + id, + &text, + Vec::new(), + SourceMessageRole::Assistant, + )); + } + if kind == "item_completed" { + let item = &payload["item"]; + let status = item + .get("status") + .and_then(Value::as_str) + .unwrap_or_default(); + let completed = matches!(status, "completed" | "failed" | "error" | "cancelled"); + let successful = status == "completed"; + let call_id = item.get("id").and_then(Value::as_str).unwrap_or(id); + let source_id = format!("codex-call:{call_id}"); + match item.get("type").and_then(Value::as_str) { + Some("McpToolCall" | "DynamicToolCall") if completed => { + if let Some(name) = item + .get("tool") + .or_else(|| item.get("name")) + .and_then(Value::as_str) + { + let server = item.get("server").and_then(Value::as_str).map(|server| { + if server.starts_with("mcp__") { + server.to_owned() + } else { + format!("mcp__{server}") + } + }); + let name = qualified_tool_name(server.as_deref(), name); + let mut result = item["result"].clone(); + if result.is_null() { + result = serde_json::json!({"error": item.get("error")}); + } + messages.extend(UserSourceMessage::from_tool( + source_id, + &name, + &item["arguments"], + &result, + successful, + )); + } + } + Some("Extension") if item.get("kind").and_then(Value::as_str) == Some("web.search") => { + // Nested web.run calls publish typed UI metadata even when the + // model-facing call is a functions.exec wrapper. Never parse scripts. + let action = &item["action"]; + let arguments = match action.get("type").and_then(Value::as_str) { + Some("search") => { + let queries: Vec = action + .get("queries") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .take(32) + .map(|query| serde_json::json!({"q": query})) + .collect(); + if queries.is_empty() { + serde_json::json!({"search_query":[{"q":action.get("query").and_then(Value::as_str).or_else(|| item.get("query").and_then(Value::as_str)).unwrap_or_default()}]}) + } else { + serde_json::json!({"search_query":queries}) + } + } + Some("openPage") => { + serde_json::json!({"open":[{"ref_id": action.get("url").and_then(Value::as_str).unwrap_or_default()}]}) + } + _ => Value::Null, + }; + if !arguments.is_null() { + messages.extend(UserSourceMessage::from_tool( + source_id, + "web.run", + &arguments, + &Value::Null, + true, + )); + } + } + Some("FileChange") if completed => { + // A change receipt is activity, not a resource supplied to the conversation. + // Explicit assistant references and attached artifacts are projected separately. + let result = serde_json::json!({"error": if successful { Value::Null } else { item["stderr"].clone() }}); + messages.extend(UserSourceMessage::from_tool( + source_id, + "apply_patch", + &Value::Null, + &result, + successful, + )); + } + _ => {} + } + } + if matches!(kind, "function_call" | "custom_tool_call") { + let (Some(call_id), Some(name)) = ( + payload.get("call_id").and_then(Value::as_str), + payload.get("name").and_then(Value::as_str), + ) else { + return; + }; + if payload.get("namespace").and_then(Value::as_str) == Some("collaboration") + || name.starts_with("collaboration.") + { + return; + } + if call_id.len() > 512 || name.len() > 256 { + return; + } + let input = payload + .get("arguments") + .or_else(|| payload.get("input")) + .unwrap_or(&Value::Null); + if input.to_string().len() > MAX_SOURCE_CALL_BYTES { + return; + } + let arguments = input + .as_str() + .and_then(|text| serde_json::from_str(text).ok()) + .unwrap_or_else(|| input.clone()); + calls.retain(|(key, _, _)| key != call_id); + if calls.len() == MAX_PENDING_SOURCE_CALLS { + calls.pop_front(); + } + calls.push_back(( + call_id.to_owned(), + qualified_tool_name(payload.get("namespace").and_then(Value::as_str), name), + arguments, + )); + } else if matches!(kind, "function_call_output" | "custom_tool_call_output") { + let Some(call_id) = payload.get("call_id").and_then(Value::as_str) else { + return; + }; + let Some(index) = calls.iter().position(|(key, _, _)| key == call_id) else { + return; + }; + let Some((_, name, arguments)) = calls.remove(index) else { + return; + }; + let raw = &payload["output"]; + let result = raw + .as_str() + .and_then(|text| serde_json::from_str(text).ok()) + .unwrap_or_else(|| raw.clone()); + // A terminal output is successful unless the provider explicitly marks failure. + let success = !raw + .as_str() + .is_some_and(|text| text.trim_start().starts_with("Error:")) + && !matches!( + payload.get("status").and_then(Value::as_str), + Some("failed" | "cancelled" | "error") + ) + && !result + .get("isError") + .and_then(Value::as_bool) + .unwrap_or(false) + && !result + .get("is_error") + .and_then(Value::as_bool) + .unwrap_or(false) + && result.get("error").is_none_or(Value::is_null) + && result + .get("exit_code") + .and_then(Value::as_i64) + .is_none_or(|code| code == 0); + messages.extend(UserSourceMessage::from_tool( + format!("codex-call:{call_id}"), + &name, + &arguments, + &result, + success, + )); + } } /// Visit each complete line with its starting byte offset. A line longer than @@ -141,8 +419,15 @@ mod tests { let messages = load_codex_user_source_messages_from_path("codexapp-x", &path).unwrap(); let turn_id = codex_lazy_turn_id(image_record.len() as u64 + 1); assert_eq!( - messages, + messages + .iter() + .filter(|message| message.role == SourceMessageRole::User) + .cloned() + .collect::>(), vec![UserSourceMessage { + role: SourceMessageRole::User, + tool_name: None, + tool_activity: None, id: turn_id.clone(), text: "see https://example.com/a".to_string(), images: vec![transcript_image_ref( @@ -173,6 +458,9 @@ mod tests { assert_eq!( messages, vec![UserSourceMessage { + role: SourceMessageRole::User, + tool_name: None, + tool_activity: None, id: turn_id.clone(), text: String::new(), images: vec![transcript_image_ref("codexapp-x", &turn_id, &inline)], @@ -194,6 +482,191 @@ mod tests { std::fs::remove_dir_all(path.parent().unwrap()).unwrap(); } + #[test] + fn includes_visible_assistant_and_successful_structured_tool_resources_only() { + use serde_json::json; + let records = vec![ + json!({"type":"response_item","payload":{"type":"message","role":"assistant","channel":"analysis","content":[{"type":"output_text","text":"https://hidden.example"}]}}), + json!({"type":"response_item","payload":{"type":"agent_message","author":"/root","recipient":"/root/worker","content":[{"type":"text","text":"https://internal.example"}]}}), + json!({"type":"response_item","payload":{"type":"message","role":"assistant","channel":"final","content":[{"type":"output_text","text":"[Report](/repo/report.md) https://public.example"}]}}), + json!({"type":"event_msg","payload":{"type":"item_completed","item":{"type":"AgentMessage","content":[{"type":"text","text":"[Report](/repo/report.md) https://public.example"}]}}}), + json!({"type":"response_item","payload":{"type":"function_call","call_id":"r1","name":"read_file","arguments":"{\"path\":\"/repo/input.ts\"}"}}), + json!({"type":"response_item","payload":{"type":"function_call_output","call_id":"r1","output":"file body with https://untrusted.example/log"}}), + json!({"type":"response_item","payload":{"type":"function_call","call_id":"r2","name":"read_file","arguments":{"path":"/missing.ts"}}}), + json!({"type":"response_item","payload":{"type":"function_call_output","call_id":"r2","output":{"isError":true}}}), + json!({"type":"event_msg","payload":{"type":"item_completed","item":{"type":"McpToolCall","status":"completed","tool":"create_report","arguments":{},"result":{"content":[{"type":"resource_link","uri":"https://reports.example/result"},{"type":"text","text":"https://untrusted.example/tool-text"}]}}}}), + json!({"type":"event_msg","payload":{"type":"item_completed","item":{"type":"FileChange","status":"completed","changes":{"/repo/output.md":{"type":"add"},"/repo/deleted.md":{"type":"delete"}}}}}), + ]; + let lines: Vec = records.iter().map(Value::to_string).collect(); + let path = write_rollout("conversation", &lines); + let messages = load_codex_user_source_messages_from_path("codexapp-x", &path).unwrap(); + let text = messages + .iter() + .map(|message| message.text.as_str()) + .collect::>() + .join("\n"); + for reference in [ + "/repo/report.md", + "https://public.example", + "/repo/input.ts", + "https://reports.example/result", + ] { + assert!(text.contains(reference), "missing {reference}: {text}"); + } + for excluded in [ + "hidden.example", + "internal.example", + "untrusted.example", + "/missing.ts", + "/repo/deleted.md", + "/repo/output.md", + ] { + assert!(!text.contains(excluded), "unexpected {excluded}: {text}"); + } + assert_eq!( + messages + .iter() + .filter(|message| message.role == SourceMessageRole::Assistant) + .count(), + 2 + ); + assert!(messages + .iter() + .any(|message| message.tool_name.as_deref() == Some("read_file"))); + let patch = messages + .iter() + .find(|message| message.tool_name.as_deref() == Some("apply_patch")) + .unwrap(); + assert!(patch.text.is_empty()); + assert!(patch.tool_activity.is_some()); + // Modern/legacy duplicate visible messages remain compact projections; + // the frontend's source-key projection deduplicates the same resource. + use std::io::Write; + let mut file = std::fs::OpenOptions::new() + .append(true) + .open(&path) + .unwrap(); + writeln!(file, "{}", json!({"type":"event_msg","payload":{"type":"agent_message","message":"https://appended.example/new"}})).unwrap(); + let reread = load_codex_user_source_messages_from_path("codexapp-x", &path).unwrap(); + assert_eq!(reread.len(), messages.len() + 1); + assert!(reread + .last() + .unwrap() + .text + .contains("https://appended.example/new")); + std::fs::remove_dir_all(path.parent().unwrap()).unwrap(); + } + + #[test] + fn counts_failed_app_calls_and_typed_web_actions_once_across_provider_projections() { + use crate::sources::imported_history::user_sources::{ + ToolActivityKind, ToolActivityStatus, + }; + use serde_json::json; + let terminal_error = "No app terminal session is attached to this thread yet."; + let records = vec![ + json!({"type":"response_item","payload":{"type":"function_call","namespace":"mcp__codex_app","name":"read_thread_terminal","call_id":"terminal-1","arguments":"{}"}}), + json!({"type":"event_msg","payload":{"type":"item_completed","item":{"type":"McpToolCall","id":"terminal-1","server":"codex_app","tool":"read_thread_terminal","arguments":{},"status":"failed","result":{"content":[{"type":"text","text":terminal_error}],"isError":true}}}}), + json!({"type":"response_item","payload":{"type":"function_call_output","call_id":"terminal-1","output":{"isError":true,"content":[{"type":"text","text":terminal_error}]}}}), + json!({"type":"response_item","payload":{"type":"function_call","namespace":"web","name":"run","call_id":"web-1","arguments":{"search_query":[{"q":"explicit query"}],"open":[{"ref_id":"https://docs.example/page"}]}}}), + json!({"type":"response_item","payload":{"type":"function_call_output","call_id":"web-1","output":{"content":[{"type":"resource_link","uri":"https://resource.example/report"}]}}}), + json!({"type":"event_msg","payload":{"type":"item_completed","item":{"type":"DynamicToolCall","id":"web-1","name":"web.run","arguments":{"search_query":[{"q":"explicit query"}],"open":[{"ref_id":"https://docs.example/page"}]},"status":"completed","result":{}}}}), + json!({"type":"event_msg","payload":{"type":"item_completed","item":{"type":"Extension","kind":"web.search","id":"nested-search","query":"shortened...","action":{"type":"search","query":null,"queries":["first full query","second full query"]},"results":[]}}}), + json!({"type":"event_msg","payload":{"type":"item_completed","item":{"type":"Extension","kind":"web.search","id":"nested-open","query":"https://learn.example/docs","action":{"type":"openPage","url":"https://learn.example/docs"},"results":[]}}}), + json!({"type":"response_item","payload":{"type":"function_call","namespace":"collaboration","name":"send_message","call_id":"internal","arguments":{}}}), + json!({"type":"response_item","payload":{"type":"function_call_output","call_id":"internal","output":{}}}), + ]; + let path = write_rollout( + "activities", + &records.iter().map(Value::to_string).collect::>(), + ); + let messages = load_codex_user_source_messages_from_path("codexapp-x", &path).unwrap(); + let activities: Vec<_> = messages + .iter() + .filter_map(|message| message.tool_activity.as_ref()) + .collect(); + assert_eq!(activities.len(), 4); + assert!(messages + .iter() + .any(|message| message.text.contains("https://resource.example/report"))); + let terminal = activities + .iter() + .find(|activity| activity.group == "codex-app") + .unwrap(); + assert_eq!(terminal.call_id, "codex-call:terminal-1"); + assert_eq!(terminal.status, ToolActivityStatus::Error); + assert_eq!(terminal.error.as_deref(), Some(terminal_error)); + assert_eq!(terminal.actions[0].kind, ToolActivityKind::ReadTerminal); + let web = activities + .iter() + .find(|activity| activity.call_id == "codex-call:web-1") + .unwrap(); + assert_eq!(web.group, "web"); + assert_eq!(web.actions[0].query.as_deref(), Some("explicit query")); + assert_eq!( + web.actions[1].url.as_deref(), + Some("https://docs.example/page") + ); + let nested = activities + .iter() + .find(|activity| activity.call_id == "codex-call:nested-search") + .unwrap(); + assert_eq!(nested.actions.len(), 2); + assert_eq!(nested.actions[0].query.as_deref(), Some("first full query")); + assert_eq!( + activities + .iter() + .find(|activity| activity.call_id == "codex-call:nested-open") + .unwrap() + .actions[0] + .url + .as_deref(), + Some("https://learn.example/docs") + ); + assert_eq!( + load_codex_user_source_messages_from_path("codexapp-x", &path).unwrap(), + messages + ); + std::fs::remove_dir_all(path.parent().unwrap()).unwrap(); + } + + #[test] + fn bounds_pending_tool_calls_and_keeps_failed_activity_without_resources() { + use serde_json::json; + let mut calls = VecDeque::new(); + let mut messages = Vec::new(); + for index in 0..=MAX_PENDING_SOURCE_CALLS { + collect_codex_conversation_sources( + &json!({"type":"function_call","call_id":index.to_string(),"name":"read_file","arguments":{"path":"/repo/file.ts"}}), + "call", + &mut calls, + &mut messages, + ); + } + assert_eq!(calls.len(), MAX_PENDING_SOURCE_CALLS); + collect_codex_conversation_sources( + &json!({"type":"function_call_output","call_id":"0","output":{}}), + "evicted", + &mut calls, + &mut messages, + ); + collect_codex_conversation_sources( + &json!({"type":"function_call_output","call_id":"1","output":"Error: file not found"}), + "failed", + &mut calls, + &mut messages, + ); + assert_eq!(messages.len(), 1); + assert!(messages[0].text.is_empty()); + collect_codex_conversation_sources( + &json!({"type":"function_call_output","call_id":"2","output":{}}), + "ok", + &mut calls, + &mut messages, + ); + assert_eq!(messages.len(), 2); + } + #[test] fn skips_oversized_lines_without_losing_offsets() { let mut reader = std::io::Cursor::new(format!("{}\nab\ncd", "x".repeat(40))); diff --git a/src-tauri/crates/orgtrack-core/src/sources/imported_history/tool_activity.rs b/src-tauri/crates/orgtrack-core/src/sources/imported_history/tool_activity.rs new file mode 100644 index 0000000000..5c6a287ec6 --- /dev/null +++ b/src-tauri/crates/orgtrack-core/src/sources/imported_history/tool_activity.rs @@ -0,0 +1,186 @@ +//! Bounded metadata describing explicit tool operations, never raw tool logs. +use serde::{Deserialize, Serialize}; +use serde_json::Value; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ToolActivity { + pub call_id: String, + pub tool_name: String, + pub group: String, + pub status: ToolActivityStatus, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, + pub actions: Vec, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum ToolActivityStatus { + Success, + Error, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "kebab-case")] +pub enum ToolActivityKind { + Search, + Open, + ReadTerminal, + Generic, +} +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct ToolActivityAction { + pub kind: ToolActivityKind, + #[serde(skip_serializing_if = "Option::is_none")] + pub query: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub url: Option, +} +fn bounded(value: &str, limit: usize) -> String { + value.chars().take(limit).collect() +} +fn error_summary(result: &Value) -> Option { + let explicit = result + .pointer("/error/message") + .or_else(|| result.get("error")) + .and_then(Value::as_str) + .or_else(|| result.get("message").and_then(Value::as_str)); + let mcp_error = result + .get("isError") + .or_else(|| result.get("is_error")) + .and_then(Value::as_bool) + == Some(true); + let text = explicit.or_else(|| { + if !mcp_error { + return None; + } + let content = result.get("content")?; + content.as_str().or_else(|| { + content.as_array()?.iter().find_map(|part| { + (part.get("type")?.as_str()? == "text") + .then(|| part.get("text")?.as_str()) + .flatten() + }) + }) + }); + text.map(|value| bounded(value, 512)) + .filter(|value| !value.is_empty()) +} + +impl ToolActivity { + pub(super) fn project( + call_id: &str, + tool_name: &str, + args: &Value, + result: &Value, + success: bool, + ) -> Self { + let lower = tool_name.to_ascii_lowercase(); + let group = if lower.starts_with("web.") + || lower.starts_with("web__") + || lower.contains("__web__") + || matches!( + lower.as_str(), + "web_search" | "websearch" | "web_fetch" | "webfetch" + ) { + "web".to_string() + } else if lower.contains("codex_app") || lower.contains("chatgpt_app_tools") { + "codex-app".to_string() + } else if let Some(namespace) = lower + .strip_prefix("mcp__") + .and_then(|name| name.split("__").next()) + .and_then(|namespace| namespace.split('.').next()) + { + format!("mcp:{namespace}") + } else { + lower.split('.').next().unwrap_or(&lower).to_string() + }; + let mut actions = Vec::new(); + if group == "web" { + for key in ["search_query", "queries"] { + if let Some(items) = args.get(key).and_then(Value::as_array) { + for item in items.iter().take(32) { + if actions.len() >= 32 { + break; + } + if let Some(query) = item + .get("q") + .or_else(|| item.get("query")) + .and_then(Value::as_str) + .or_else(|| item.as_str()) + { + actions.push(ToolActivityAction { + kind: ToolActivityKind::Search, + query: Some(bounded(query, 2048)), + url: None, + }); + } + } + } + } + if actions.is_empty() { + if let Some(query) = args + .get("query") + .or_else(|| args.get("q")) + .and_then(Value::as_str) + { + actions.push(ToolActivityAction { + kind: ToolActivityKind::Search, + query: Some(bounded(query, 2048)), + url: None, + }); + } + } + if let Some(items) = args.get("open").and_then(Value::as_array) { + for item in items.iter().take(32) { + if actions.len() >= 32 { + break; + } + if let Some(url) = item + .get("ref_id") + .or_else(|| item.get("url")) + .and_then(Value::as_str) + { + actions.push(ToolActivityAction { + kind: ToolActivityKind::Open, + query: None, + url: Some(bounded(url, 2048)), + }); + } + } + } + if actions.is_empty() { + if let Some(url) = args.get("url").and_then(Value::as_str) { + actions.push(ToolActivityAction { + kind: ToolActivityKind::Open, + query: None, + url: Some(bounded(url, 2048)), + }); + } + } + } + if actions.is_empty() { + actions.push(ToolActivityAction { + kind: if lower.ends_with("read_thread_terminal") || lower.ends_with("read_terminal") + { + ToolActivityKind::ReadTerminal + } else { + ToolActivityKind::Generic + }, + query: None, + url: None, + }); + } + Self { + call_id: call_id.to_string(), + tool_name: bounded(tool_name, 256), + group: bounded(&group, 256), + status: if success { + ToolActivityStatus::Success + } else { + ToolActivityStatus::Error + }, + error: if success { None } else { error_summary(result) }, + actions, + } + } +} diff --git a/src-tauri/crates/orgtrack-core/src/sources/imported_history/user_sources.rs b/src-tauri/crates/orgtrack-core/src/sources/imported_history/user_sources.rs index bce92a34ed..703b1d1791 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/imported_history/user_sources.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/imported_history/user_sources.rs @@ -1,17 +1,20 @@ -//! User-message sources: the parts of a session's user messages that a +//! Conversation sources: explicitly referenced resources from visible messages that a //! Sources list reads — lines that can carry a web reference, and image //! references a thumbnail loads on demand. //! -//! Readers hand every user message of a session through -//! [`UserSourceMessage::new`], which keeps the payload small: prose without a -//! `://` never crosses IPC, embedded pill context (terminal output, page +//! Readers hand visible messages and successful structured tool resources through +//! [`UserSourceMessage`], retaining its historical wire name. Prose without a +//! reference never crosses IPC, embedded pill context (terminal output, page //! snapshots) is cut, and inline image bytes are never included. use core_types::activity::ActivityChunk; use serde::{Deserialize, Serialize}; use serde_json::Value; -use super::{strip_generated_prompt_context, FUNCTION_USER_MESSAGE}; +use super::{strip_generated_prompt_context, FUNCTION_ASSISTANT, FUNCTION_USER_MESSAGE}; +#[path = "tool_activity.rs"] +mod tool_activity; +pub use tool_activity::{ToolActivity, ToolActivityAction, ToolActivityKind, ToolActivityStatus}; /// Pills whose `::payload` holds their web destination (a GitHub card's URL); /// every other payload follows a destination that is already in the path. @@ -20,11 +23,27 @@ const TRANSCRIPT_IMAGE_PREFIX: &str = "orgii-transcript-image:"; /// A longer line (minified logs, pasted blobs) keeps only its URL-bearing words. const MAX_REFERENCE_LINE_BYTES: usize = 16 * 1024; +/// Wire provenance, separate from whether an agent actually read a resource. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum SourceMessageRole { + #[default] + User, + Assistant, + Tool, +} + #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct UserSourceMessage { pub id: String, - /// The message's lines that contain `://`, in order. + #[serde(default)] + pub role: SourceMessageRole, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_name: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tool_activity: Option, + /// Reference-bearing lines and explicit attachment envelope entries, in order. pub text: String, /// Paths, web URLs, or transcript refs; never `data:` URLs. pub images: Vec, @@ -37,13 +56,33 @@ impl UserSourceMessage { text: &str, images: impl IntoIterator, ) -> Option { - let text = reference_text(&strip_generated_prompt_context(text)); + Self::with_role(id, text, images, SourceMessageRole::User) + } + + pub fn with_role( + id: impl Into, + text: &str, + images: impl IntoIterator, + role: SourceMessageRole, + ) -> Option { + let text = reference_text_visible( + &strip_generated_prompt_context(text), + role == SourceMessageRole::Assistant, + ); let images: Vec = images .into_iter() - .filter(|image| !image.is_empty() && !image.starts_with("data:")) + .filter(|image| { + !image.is_empty() + && !image.starts_with("data:") + && image.len() <= MAX_REFERENCE_LINE_BYTES + }) + .take(256) .collect(); (!text.is_empty() || !images.is_empty()).then(|| Self { id: id.into(), + role, + tool_name: None, + tool_activity: None, text, images, }) @@ -62,34 +101,347 @@ pub fn transcript_image_ref(session_id: &str, turn_id: &str, original: &str) -> ) } -/// Sources of the canonical user chunks in a replayed chunk stream. +/// Successful tool resources are taken only from explicit structured fields. +/// Text output, shell commands, logs and embedded document contents are never scanned. +impl UserSourceMessage { + pub fn from_tool( + id: impl Into, + tool_name: &str, + args: &Value, + result: &Value, + success: bool, + ) -> Option { + if pending_tool_result(result) { + return None; + } + let id = id.into(); + let success = success && !failed_tool_result(result); + let activity = ToolActivity::project(&id, tool_name, args, result, success); + if !success { + return Some(Self::tool_activity_only(id, tool_name, activity)); + } + let mut refs = Vec::new(); + let mut remaining_nodes = 2048; + collect_tool_resources(result, &mut refs, 0, &mut remaining_nodes); + // Provider wrappers often carry JSON as output; decode JSON only, never prose. + for field in ["output", "observation"] { + if let Some(encoded) = result.get(field).and_then(Value::as_str) { + if encoded.len() <= 256 * 1024 { + if let Ok(value) = serde_json::from_str::(encoded) { + if failed_tool_result(&value) { + let activity = + ToolActivity::project(&id, tool_name, args, &value, false); + return Some(Self::tool_activity_only(id, tool_name, activity)); + } + collect_tool_resources(&value, &mut refs, 0, &mut remaining_nodes); + } + } + } + } + let name = tool_name + .rsplit("__") + .next() + .unwrap_or(tool_name) + .rsplit('.') + .next() + .unwrap_or(tool_name) + .to_ascii_lowercase(); + if matches!( + name.as_str(), + "read" | "read_file" | "view_image" | "open_file" + ) { + for field in ["file_path", "filePath", "path", "filename"] { + if let Some(path) = args.get(field).and_then(Value::as_str) { + push_tool_reference(path, false, &mut refs); + } + } + } + if matches!( + name.as_str(), + "attach_artifact" | "read_resource" | "fetch" | "web_fetch" + ) { + for field in ["url", "uri"] { + if let Some(url) = args.get(field).and_then(Value::as_str) { + push_tool_reference(url, true, &mut refs); + } + } + } + let mut message = Self::tool_activity_only(id, tool_name, activity); + message.text = reference_text(&refs.join("\n")); + Some(message) + } + fn tool_activity_only(id: String, tool_name: &str, activity: ToolActivity) -> Self { + Self { + id, + role: SourceMessageRole::Tool, + tool_name: Some(tool_name.chars().take(256).collect()), + tool_activity: Some(activity), + text: String::new(), + images: Vec::new(), + } + } +} + +fn pending_tool_result(value: &Value) -> bool { + matches!( + value.get("status").and_then(Value::as_str), + Some("pending" | "running" | "in_progress") + ) +} +fn failed_tool_result(value: &Value) -> bool { + value + .get("isError") + .or_else(|| value.get("is_error")) + .and_then(Value::as_bool) + == Some(true) + || value.get("success").and_then(Value::as_bool) == Some(false) + || matches!( + value.get("status").and_then(Value::as_str), + Some("failed" | "error" | "cancelled" | "canceled" | "pending" | "interrupted") + ) +} + +fn push_tool_reference(value: &str, is_url: bool, refs: &mut Vec) { + if refs.len() >= 256 + || value.is_empty() + || value.len() > 8192 + || value.contains(['\n', '\r', '[', ']']) + || value.starts_with("data:") + { + return; + } + let web = value.starts_with("https://") || value.starts_with("http://"); + if is_url && !web && !value.starts_with("file://") { + return; + } + let kind = if web { "link" } else { "file" }; + let label = value + .rsplit(['/', '\\']) + .next() + .filter(|part| !part.is_empty()) + .unwrap_or("resource"); + let line = format!("{label} [{kind}:{value}]"); + if !refs.contains(&line) { + refs.push(line); + } +} + +fn collect_tool_resources( + value: &Value, + refs: &mut Vec, + depth: usize, + remaining_nodes: &mut usize, +) { + if depth > 5 || refs.len() >= 256 || *remaining_nodes == 0 { + return; + } + *remaining_nodes -= 1; + let Some(object) = value.as_object() else { + if let Some(items) = value.as_array() { + for item in items.iter().take(256) { + collect_tool_resources(item, refs, depth + 1, remaining_nodes); + } + } + return; + }; + if failed_tool_result(value) { + return; + } + // Output fields explicitly describe a produced/read resource; generic `path` + // is accepted only inside a typed resource/artifact envelope below. + for field in [ + "output_path", + "outputPath", + "file_path", + "filePath", + "artifact_path", + ] { + if let Some(path) = object.get(field).and_then(Value::as_str) { + push_tool_reference(path, false, refs); + } + } + for field in [ + "url", + "uri", + "artifact_url", + "download_url", + "pr_url", + "prUrl", + ] { + if let Some(url) = object.get(field).and_then(Value::as_str) { + push_tool_reference(url, true, refs); + } + } + if matches!( + object.get("type").and_then(Value::as_str), + Some("resource_link" | "resource" | "artifact" | "file") + ) { + if let Some(path) = object.get("path").and_then(Value::as_str) { + push_tool_reference(path, false, refs); + } + } + for field in [ + "resource", + "resources", + "artifact", + "artifacts", + "structuredContent", + ] { + if let Some(child) = object.get(field) { + if failed_tool_result(child) { + continue; + } + if let Some(path) = child.get("path").and_then(Value::as_str) { + push_tool_reference(path, false, refs); + } + if matches!(field, "artifacts" | "resources") { + if let Some(items) = child.as_array() { + for item in items.iter().take(256) { + if !failed_tool_result(item) { + if let Some(path) = item.get("path").and_then(Value::as_str) { + push_tool_reference(path, false, refs); + } + } + } + } + } + collect_tool_resources(child, refs, depth + 1, remaining_nodes); + } + } + // MCP content text is deliberately excluded; only typed resource blocks count. + if let Some(content) = object.get("content").and_then(Value::as_array) { + for block in content.iter().take(256) { + if matches!( + block.get("type").and_then(Value::as_str), + Some("resource_link" | "resource") + ) { + collect_tool_resources(block, refs, depth + 1, remaining_nodes); + } + } + } +} + +/// Sources from canonical visible messages and completed tools in replay order. +/// Kept under the historical function name to avoid churning provider imports. pub fn user_source_messages_from_chunks(chunks: &[ActivityChunk]) -> Vec { chunks .iter() - .filter(|chunk| chunk.function == FUNCTION_USER_MESSAGE) - .filter_map(|chunk| { - let text = chunk + .filter_map(source_message_from_chunk) + .collect() +} + +pub fn source_message_from_chunk(chunk: &ActivityChunk) -> Option { + if chunk.broadcast_only || chunk.result.get("is_delta").and_then(Value::as_bool) == Some(true) { + return None; + } + let images = chunk + .result + .get("images") + .and_then(Value::as_array) + .into_iter() + .flatten() + .filter_map(Value::as_str) + .map(str::to_string); + if chunk.function == FUNCTION_USER_MESSAGE { + return UserSourceMessage::new( + &chunk.chunk_id, + chunk .result .pointer("/message/content") .and_then(Value::as_str) - .unwrap_or_default(); - let images = chunk - .result - .get("images") - .and_then(Value::as_array) - .into_iter() - .flatten() - .filter_map(Value::as_str) - .map(str::to_string); - UserSourceMessage::new(chunk.chunk_id.clone(), text, images) - }) - .collect() + .unwrap_or_default(), + images, + ); + } + if chunk.function == FUNCTION_ASSISTANT || chunk.action_type == "assistant" { + let text = chunk + .result + .get("content") + .or_else(|| chunk.result.get("observation")) + .or_else(|| chunk.result.pointer("/message/content")) + .and_then(Value::as_str) + .unwrap_or_default(); + return UserSourceMessage::with_role( + &chunk.chunk_id, + text, + images, + SourceMessageRole::Assistant, + ); + } + if chunk.action_type != "tool_call" { + return None; + } + let success = chunk.result.get("success").and_then(Value::as_bool) == Some(true) + || chunk.result.get("status").and_then(Value::as_str) == Some("completed"); + if pending_tool_result(&chunk.result) || (!success && !failed_tool_result(&chunk.result)) { + return None; + } + let tool = chunk + .result + .get("raw_tool_name") + .and_then(Value::as_str) + .unwrap_or(&chunk.function); + UserSourceMessage::from_tool( + chunk + .result + .get("call_id") + .and_then(Value::as_str) + .unwrap_or(&chunk.chunk_id), + tool, + &chunk.args, + &chunk.result, + success, + ) } pub fn reference_text(text: &str) -> String { + reference_text_visible(text, false) +} + +fn reference_text_visible(text: &str, skip_code: bool) -> String { let mut kept: Vec = Vec::new(); + let mut attachment_envelope = false; + let mut fence: Option<(char, usize)> = None; for line in text.lines() { - if !line.contains("://") { + if kept.len() >= 256 { + break; + } + let trimmed = line.trim(); + if skip_code { + let marker = trimmed.chars().next().filter(|c| *c == '`' || *c == '~'); + let count = marker + .map(|c| trimmed.chars().take_while(|v| *v == c).count()) + .unwrap_or(0); + if let Some((kind, length)) = fence { + if marker == Some(kind) && count >= length && trimmed[count..].trim().is_empty() { + fence = None; + } + continue; + } + if count >= 3 { + fence = marker.map(|c| (c, count)); + continue; + } + } + let heading = trimmed.trim_start_matches('#').trim(); + if heading.eq_ignore_ascii_case("Files mentioned by the user:") { + attachment_envelope = true; + kept.push(line.to_string()); + continue; + } + if attachment_envelope + && (heading.eq_ignore_ascii_case("My request:") + || heading.eq_ignore_ascii_case("My request for Codex:")) + { + attachment_envelope = false; + kept.push(line.to_string()); + continue; + } + let file_reference = line.contains("[file:") + || line.contains("[folder:") + || line.contains("](") + || (attachment_envelope && trimmed.starts_with("##") && line.contains(": ")); + if !line.contains("://") && !file_reference { continue; } let line = strip_pill_payloads(line); @@ -100,6 +452,10 @@ pub fn reference_text(text: &str) -> String { let words: Vec<&str> = line .split_whitespace() .filter(|word| word.contains("://") && word.len() <= MAX_REFERENCE_LINE_BYTES) + .scan(0usize, |bytes, word| { + *bytes += word.len() + 1; + (*bytes <= MAX_REFERENCE_LINE_BYTES).then_some(word) + }) .collect(); if !words.is_empty() { kept.push(words.join(" ")); @@ -148,92 +504,5 @@ fn strip_pill_payloads(line: &str) -> String { } #[cfg(test)] -mod tests { - use super::*; - - #[test] - fn keeps_only_reference_lines_and_cuts_context_payloads() { - let text = "please review\nsee pr [link:https://example.com/a] now\n\ - page [browser:browser://https://example.com/b/1726000000000::SGVsbG8=]\n\ - log [terminal:terminal://t1::QUJD] done\n\ - card [pr:pr://org/repo/1::eyJwclVybCI6MX0=]"; - assert_eq!( - reference_text(text), - "see pr [link:https://example.com/a] now\n\ - page [browser:browser://https://example.com/b/1726000000000]\n\ - log [terminal:terminal://t1] done\n\ - card [pr:pr://org/repo/1::eyJwclVybCI6MX0=]" - ); - } - - #[test] - fn drops_urls_inside_generated_prompt_context() { - let message = UserSourceMessage::new( - "m", - "\nURL: https://page.dev/open\n\n\ - read https://example.com/mine", - Vec::new(), - ) - .unwrap(); - assert_eq!(message.text, "read https://example.com/mine"); - } - - #[test] - fn leaves_ipv6_hosts_and_closed_pills_alone() { - let text = "a [link:http://x] then http://[::1]:3000/path"; - assert_eq!(reference_text(text), text); - } - - #[test] - fn reduces_oversized_lines_to_their_urls() { - let text = format!("{} https://example.com/deep end", "x".repeat(20_000)); - assert_eq!(reference_text(&text), "https://example.com/deep"); - } - - #[test] - fn drops_inline_image_bytes_and_empty_messages() { - assert_eq!( - UserSourceMessage::new("m", "no links here", Vec::new()), - None - ); - let message = UserSourceMessage::new( - "m", - "", - vec![ - "data:image/png;base64,AAAA".to_string(), - "/tmp/shot.png".to_string(), - ], - ) - .unwrap(); - assert_eq!(message.images, vec!["/tmp/shot.png"]); - assert_eq!(message.text, ""); - } - - #[test] - fn wraps_local_image_refs_for_lazy_reads() { - assert_eq!( - transcript_image_ref("s", "t", "/tmp/a.png"), - r#"orgii-transcript-image:["s","t","/tmp/a.png"]"# - ); - assert_eq!( - transcript_image_ref("s", "t", "https://example.com/a.png"), - "https://example.com/a.png" - ); - } - - #[test] - fn reads_user_chunks_only() { - let mut user = super::super::user_message_chunk("s", "p", 0, "now", "https://a.dev/x"); - user.result["images"] = serde_json::json!(["/tmp/u.png", "data:image/png;base64,A"]); - let assistant = - super::super::assistant_message_chunk("s", "p", 1, "now", "https://b.dev/y"); - assert_eq!( - user_source_messages_from_chunks(&[user, assistant]), - vec![UserSourceMessage { - id: "p-user-0".to_string(), - text: "https://a.dev/x".to_string(), - images: vec!["/tmp/u.png".to_string()], - }] - ); - } -} +#[path = "user_sources_tests.rs"] +mod tests; diff --git a/src-tauri/crates/orgtrack-core/src/sources/imported_history/user_sources_tests.rs b/src-tauri/crates/orgtrack-core/src/sources/imported_history/user_sources_tests.rs new file mode 100644 index 0000000000..88cb50306c --- /dev/null +++ b/src-tauri/crates/orgtrack-core/src/sources/imported_history/user_sources_tests.rs @@ -0,0 +1,345 @@ +use super::*; + +#[test] +fn structured_tool_resources_and_successful_file_operations_are_sources() { + let result = serde_json::json!({"content":[ + {"type":"resource_link","uri":"https://example.com/report"}, + {"type":"resource","resource":{"uri":"file:///tmp/report.md","text":"private body"}}, + {"type":"text","text":"log https://not-a-source.example"}], + "artifacts":[{"path":"/tmp/output.pdf"}]}); + let message = + UserSourceMessage::from_tool("t", "mcp__report", &serde_json::json!({}), &result, true) + .unwrap(); + assert_eq!(message.role, SourceMessageRole::Tool); + assert_eq!(message.tool_name.as_deref(), Some("mcp__report")); + assert!(message.text.contains("https://example.com/report")); + assert!(message.text.contains("/tmp/output.pdf")); + assert!(message.text.contains("file:///tmp/report.md")); + assert!(!message.text.contains("not-a-source")); + let args = serde_json::json!({"file_path":"/tmp/read.md"}); + assert!( + UserSourceMessage::from_tool("read", "Read", &args, &serde_json::json!({}), true) + .unwrap() + .text + .contains("/tmp/read.md") + ); + assert!( + UserSourceMessage::from_tool("read", "Read", &args, &result, false) + .unwrap() + .text + .is_empty() + ); +} + +#[test] +fn shell_logs_failures_and_oversized_json_never_create_sources() { + let args = serde_json::json!({"cmd":"cat /tmp/not-a-source.md; curl https://hidden.example"}); + let result = serde_json::json!({"output":"read /tmp/not-a-source.md https://hidden.example"}); + assert!( + UserSourceMessage::from_tool("t", "exec_command", &args, &result, true) + .unwrap() + .text + .is_empty() + ); + let failure = serde_json::json!({"isError":true,"url":"https://failure.example"}); + assert!( + UserSourceMessage::from_tool("t", "Read", &args, &failure, true) + .unwrap() + .text + .is_empty() + ); + let large = serde_json::json!({"output":format!("{{\"url\":\"https://hidden.example\",\"body\":\"{}\"}}", "x".repeat(300_000))}); + assert!( + UserSourceMessage::from_tool("t", "report", &args, &large, true) + .unwrap() + .text + .is_empty() + ); + let refs = "https://example.com/ref\n".repeat(1000); + assert_eq!(reference_text(&refs).lines().count(), 256); + assert!( + reference_text(&"https://example.com/ref ".repeat(20_000)).len() + <= MAX_REFERENCE_LINE_BYTES + ); +} + +#[test] +fn assistant_files_prs_and_attached_artifact_args_have_truthful_roles() { + let assistant = super::super::assistant_message_chunk( + "s", + "p", + 0, + "now", + "[PR](https://github.com/example/repo/pull/1) [file](/tmp/report.md)", + ); + let projected = source_message_from_chunk(&assistant).unwrap(); + assert_eq!(projected.role, SourceMessageRole::Assistant); + assert!(projected.text.contains("[file](/tmp/report.md)")); + let args = serde_json::json!({"artifact_type":"pull_request","url":"https://github.com/example/repo/pull/1"}); + assert!(UserSourceMessage::from_tool( + "t", + "mcp__codex_app__attach_artifact", + &args, + &serde_json::json!({"success":true}), + true + ) + .unwrap() + .text + .contains("/pull/1")); + assert!(UserSourceMessage::from_tool( + "t", + "mcp__codex_app__attach_artifact", + &args, + &serde_json::json!({}), + false + ) + .unwrap() + .text + .is_empty()); + let mut hidden = assistant; + hidden.action_type = "thinking".into(); + hidden.function = "thinking".into(); + assert!(source_message_from_chunk(&hidden).is_none()); +} + +#[test] +fn nested_failed_artifacts_and_top_level_failure_cannot_leak_paths() { + let args = serde_json::json!({"path":"/tmp/args.md"}); + for failure in [ + serde_json::json!({"status":"failed"}), + serde_json::json!({"isError":true}), + serde_json::json!({"success":false}), + ] { + assert!( + UserSourceMessage::from_tool("t", "write_file", &args, &failure, true) + .unwrap() + .text + .is_empty() + ); + let mut failed = failure.clone(); + failed["path"] = serde_json::json!("/tmp/failed.md"); + let result = + serde_json::json!({"artifact":failed,"resources":[failed],"artifacts":[failed]}); + assert!( + UserSourceMessage::from_tool("t", "report", &serde_json::json!({}), &result, true) + .unwrap() + .text + .is_empty() + ); + } +} + +#[test] +fn assistant_code_examples_are_removed_before_reference_reduction() { + let text="```markdown\n[fake](/tmp/example.md) https://code.example\n```\n~~~\nhttps://tilde.example\n~~~\n[real](/tmp/result.md)"; + let assistant = + UserSourceMessage::with_role("a", text, Vec::new(), SourceMessageRole::Assistant).unwrap(); + assert_eq!(assistant.text, "[real](/tmp/result.md)"); + assert!(UserSourceMessage::new("u", text, Vec::new()) + .unwrap() + .text + .contains("https://code.example")); +} + +#[test] +fn old_wire_messages_default_to_user() { + let message: UserSourceMessage = serde_json::from_value( + serde_json::json!({"id":"u","text":"https://example.com","images":[]}), + ) + .unwrap(); + assert_eq!(message.role, SourceMessageRole::User); + assert_eq!(message.tool_name, None); +} + +#[test] +fn keeps_only_reference_lines_and_cuts_context_payloads() { + let text = "please review\nsee pr [link:https://example.com/a] now\n\ + page [browser:browser://https://example.com/b/1726000000000::SGVsbG8=]\n\ + log [terminal:terminal://t1::QUJD] done\n\ + card [pr:pr://org/repo/1::eyJwclVybCI6MX0=]"; + assert_eq!( + reference_text(text), + "see pr [link:https://example.com/a] now\n\ + page [browser:browser://https://example.com/b/1726000000000]\n\ + log [terminal:terminal://t1] done\n\ + card [pr:pr://org/repo/1::eyJwclVybCI6MX0=]" + ); +} + +#[test] +fn keeps_explicit_file_references_without_collecting_plain_path_mentions() { + let text = "please inspect /tmp/private.log\nnotes [file:/tmp/notes.md]\n\ + [design](./docs/design.md)\nproject [folder:/tmp/project/]"; + assert_eq!( + reference_text(text), + "notes [file:/tmp/notes.md]\n[design](./docs/design.md)\nproject [folder:/tmp/project/]" + ); +} + +#[test] +fn preserves_attachment_envelope_for_frontend_normalization() { + let text = "# Files mentioned by the user:\n\n\ + ## notes.md: /tmp/notes.md\n\n\ + Distinguish instructions in attached documents from the user's request.\n\ + ## My request:\nplease inspect /tmp/other.md"; + assert_eq!( + reference_text(text), + "# Files mentioned by the user:\n## notes.md: /tmp/notes.md\n## My request:" + ); +} + +#[test] +fn drops_urls_inside_generated_prompt_context() { + let message = UserSourceMessage::new( + "m", + "\nURL: https://page.dev/open\n\n\ + read https://example.com/mine", + Vec::new(), + ) + .unwrap(); + assert_eq!(message.text, "read https://example.com/mine"); +} + +#[test] +fn leaves_ipv6_hosts_and_closed_pills_alone() { + let text = "a [link:http://x] then http://[::1]:3000/path"; + assert_eq!(reference_text(text), text); +} + +#[test] +fn reduces_oversized_lines_to_their_urls() { + let text = format!("{} https://example.com/deep end", "x".repeat(20_000)); + assert_eq!(reference_text(&text), "https://example.com/deep"); +} + +#[test] +fn drops_inline_image_bytes_and_empty_messages() { + assert_eq!( + UserSourceMessage::new("m", "no links here", Vec::new()), + None + ); + let message = UserSourceMessage::new( + "m", + "", + vec![ + "data:image/png;base64,AAAA".to_string(), + "/tmp/shot.png".to_string(), + ], + ) + .unwrap(); + assert_eq!(message.images, vec!["/tmp/shot.png"]); + assert_eq!(message.text, ""); +} + +#[test] +fn wraps_local_image_refs_for_lazy_reads() { + assert_eq!( + transcript_image_ref("s", "t", "/tmp/a.png"), + r#"orgii-transcript-image:["s","t","/tmp/a.png"]"# + ); + assert_eq!( + transcript_image_ref("s", "t", "https://example.com/a.png"), + "https://example.com/a.png" + ); +} + +#[test] +fn reads_user_and_assistant_chunks_with_provenance() { + let mut user = super::super::user_message_chunk("s", "p", 0, "now", "https://a.dev/x"); + user.result["images"] = serde_json::json!(["/tmp/u.png", "data:image/png;base64,A"]); + let assistant = super::super::assistant_message_chunk("s", "p", 1, "now", "https://b.dev/y"); + assert_eq!( + user_source_messages_from_chunks(&[user, assistant]), + vec![ + UserSourceMessage::new( + "p-user-0", + "https://a.dev/x", + vec!["/tmp/u.png".to_string()] + ) + .unwrap(), + UserSourceMessage::with_role( + "p-asst-1", + "https://b.dev/y", + Vec::new(), + SourceMessageRole::Assistant + ) + .unwrap(), + ] + ); +} + +#[test] +fn failed_terminal_activity_and_web_actions_are_bounded_and_explicit() { + let failed=UserSourceMessage::from_tool("call-1","mcp__codex_app.read_thread_terminal",&serde_json::json!({}), + &serde_json::json!({"isError":true,"content":[{"type":"text","text":"No app terminal session is attached to this thread yet."}]}),false).unwrap(); + assert!(failed.text.is_empty() && failed.images.is_empty()); + let activity = failed.tool_activity.unwrap(); + assert_eq!(activity.group, "codex-app"); + assert_eq!(activity.call_id, "call-1"); + assert_eq!(activity.status, ToolActivityStatus::Error); + assert_eq!( + activity.error.as_deref(), + Some("No app terminal session is attached to this thread yet.") + ); + assert_eq!(activity.actions[0].kind, ToolActivityKind::ReadTerminal); + let web=UserSourceMessage::from_tool("web-1","web.run",&serde_json::json!({"search_query":[{"q":"search topic"}],"open":[{"ref_id":"https://example.com"}]}),&serde_json::json!({}),true).unwrap(); + let activity = web.tool_activity.unwrap(); + assert_eq!(activity.group, "web"); + assert_eq!(activity.actions.len(), 2); + assert_eq!(activity.actions[0].query.as_deref(), Some("search topic")); + assert_eq!( + activity.actions[1].url.as_deref(), + Some("https://example.com") + ); + let many = UserSourceMessage::from_tool( + "web-many", + "web.run", + &serde_json::json!({"search_query":vec![serde_json::json!({"q":"x".repeat(6000)});100]}), + &serde_json::json!({"error":{"message":"e".repeat(10000)}}), + false, + ) + .unwrap() + .tool_activity + .unwrap(); + assert_eq!(many.actions.len(), 32); + assert_eq!(many.actions[0].query.as_ref().unwrap().len(), 2048); + assert_eq!(many.error.unwrap().len(), 512); +} + +#[test] +fn unresolved_tools_are_not_completed_activity() { + let mut chunk = core_types::activity::ActivityChunk::new("s", "tool_call", "read_file"); + chunk.args = serde_json::json!({"path":"/tmp/file.md"}); + chunk.result = serde_json::json!({"status":"pending","success":false}); + assert!(source_message_from_chunk(&chunk).is_none()); + chunk.result = serde_json::json!({"status":"failed","success":false,"call_id":"stable-call"}); + let activity = source_message_from_chunk(&chunk) + .unwrap() + .tool_activity + .unwrap(); + assert_eq!(activity.call_id, "stable-call"); +} + +#[test] +fn file_mutation_arguments_are_not_conversation_resources() { + for tool in [ + "Write", + "Edit", + "MultiEdit", + "write_file", + "edit_file", + "edit_file_by_replace", + "apply_patch", + ] { + let message = UserSourceMessage::from_tool( + "change", + tool, + &serde_json::json!({"file_path":"/repo/changed.ts", "path":"/repo/changed.ts"}), + &serde_json::json!({"success":true}), + true, + ) + .unwrap(); + assert!(message.text.is_empty(), "{tool}"); + assert!(message.tool_activity.is_some(), "{tool}"); + } +} diff --git a/src-tauri/crates/session-persistence/src/lib.rs b/src-tauri/crates/session-persistence/src/lib.rs index 718dcfaa8f..687bab2010 100644 --- a/src-tauri/crates/session-persistence/src/lib.rs +++ b/src-tauri/crates/session-persistence/src/lib.rs @@ -84,3 +84,6 @@ pub use commands::{ pub(crate) static ORGII_HOME_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); mod auxiliary_usage; + +mod source_messages; +pub use source_messages::load_stored_source_messages; diff --git a/src-tauri/crates/session-persistence/src/source_messages.rs b/src-tauri/crates/session-persistence/src/source_messages.rs new file mode 100644 index 0000000000..3a3f3293fa --- /dev/null +++ b/src-tauri/crates/session-persistence/src/source_messages.rs @@ -0,0 +1,275 @@ +//! Source projection for native sessions. Uses the same canonical user filter +//! as the turn index, plus visible assistant messages and finalized tool activity. +use std::collections::HashMap; + +use core_types::activity::ActivityChunk; +use orgtrack_core::sources::imported_history::user_sources::{ + source_message_from_chunk, UserSourceMessage, +}; +use rusqlite::{params, Connection, Result}; +use serde_json::{json, Value}; + +use crate::turn_index::{load_stored_user_messages, StoredUserMessage}; + +pub fn load_stored_source_messages(session_id: &str) -> Result> { + // Retain the turn index's backfill and synthetic/internal user exclusions. + let users = load_stored_user_messages(session_id)?; + let conn = crate::connection::get_connection()?; + stored_source_messages(&conn, session_id, users) +} + +fn stored_source_messages( + conn: &Connection, + session_id: &str, + users: Vec, +) -> Result> { + let mut users: HashMap<_, _> = users + .into_iter() + .map(|user| (user.id.clone(), user)) + .collect(); + // Keep structured JSON bounded before crossing SQLite -> Rust; inline blobs + // and arbitrary huge tool logs have no place in the source registry. + let mut stmt = conn.prepare_cached( + "SELECT id, event_type, function_name, + CASE WHEN length(args_json) <= 262144 THEN args_json ELSE '{}' END, + CASE WHEN length(result_json) <= 262144 THEN result_json ELSE '{}' END, + CASE WHEN length(content) <= 262144 THEN content ELSE '' END, + CASE WHEN length(meta_json) <= 262144 THEN meta_json ELSE '{}' END, created_at + FROM events WHERE session_id = ?1 + ORDER BY history_sequence ASC, created_at ASC, id ASC", + )?; + let mut rows = stmt.query(params![session_id])?; + let mut sources = Vec::new(); + while let Some(row) = rows.next()? { + let id: String = row.get(0)?; + if let Some(user) = users.remove(&id) { + if let Some(source) = UserSourceMessage::new(user.id, &user.text, user.images) { + sources.push(source); + } + continue; + } + let event_type: String = row.get(1)?; + let function: Option = row.get(2)?; + let args: String = row.get(3)?; + let result: String = row.get(4)?; + let content: String = row.get(5)?; + let meta: Option = row.get(6)?; + let meta: Value = meta + .as_deref() + .and_then(|s| serde_json::from_str(s).ok()) + .unwrap_or(Value::Null); + if core_types::session_event::is_internal_lifecycle_action_type(&event_type) + || matches!( + function.as_deref(), + Some("thinking" | "reasoning" | "system" | "developer") + ) + || meta.get("isDelta").and_then(Value::as_bool) == Some(true) + || matches!( + meta.get("source").and_then(Value::as_str), + Some("system" | "developer") + ) + { + continue; + } + let is_assistant = event_type == "assistant" + || matches!( + function.as_deref(), + Some("assistant" | "assistant_message" | "agent_message" | "message") + ); + let is_tool = event_type == "tool_call"; + if !is_assistant && !is_tool { + continue; + } + if is_assistant + && (meta + .get("displayVariant") + .and_then(Value::as_str) + .is_some_and(|s| s != "message") + || meta + .get("displayStatus") + .and_then(Value::as_str) + .is_some_and(|s| s != "completed")) + { + continue; + } + if is_tool + && meta + .get("displayStatus") + .and_then(Value::as_str) + .is_some_and(|status| !matches!(status, "completed" | "failed" | "cancelled")) + { + continue; + } + let mut chunk = ActivityChunk::new( + session_id, + if is_assistant { + "assistant" + } else { + "tool_call" + }, + function.as_deref().unwrap_or_default(), + ); + chunk.chunk_id = id; + chunk.args = serde_json::from_str(&args).unwrap_or(Value::Null); + chunk.result = serde_json::from_str(&result).unwrap_or_else(|_| json!({})); + if is_tool { + if !chunk.result.is_object() { + chunk.result = json!({}); + } + if let Some(status) = meta.get("displayStatus").and_then(Value::as_str) { + if matches!(status, "failed" | "cancelled") { + chunk.result["success"] = json!(false); + } else if status == "completed" && chunk.result.get("success").is_none() { + chunk.result["success"] = json!(true); + } + } + if let Some(call_id) = meta.get("callId").and_then(Value::as_str) { + chunk.result["call_id"] = json!(call_id); + } + } + if is_assistant + && chunk + .result + .get("content") + .and_then(Value::as_str) + .is_none() + && !content.is_empty() + { + if !chunk.result.is_object() { + chunk.result = json!({}); + } + chunk.result["content"] = json!(content); + } + if let Some(source) = source_message_from_chunk(&chunk) { + sources.push(source); + } + } + Ok(sources) +} + +#[cfg(test)] +mod tests { + use super::*; + use orgtrack_core::sources::imported_history::user_sources::SourceMessageRole; + + #[test] + fn native_messages_and_successful_tool_resources_keep_order_and_provenance() { + let conn = Connection::open_in_memory().unwrap(); + conn.execute_batch("CREATE TABLE events(id TEXT,session_id TEXT,event_type TEXT,function_name TEXT,args_json TEXT,result_json TEXT,content TEXT,meta_json TEXT,created_at TEXT,history_sequence INTEGER);").unwrap(); + let records = [ + ("u", "raw", "user_message", "{}", "{}", "", "{}"), + ( + "a", + "assistant", + "assistant_message", + "{}", + "{}", + "[PR](https://github.com/example/repo/pull/1) [plan](/tmp/plan.md)", + "{}", + ), + ( + "t", + "tool_call", + "write_file", + r#"{"path":"/tmp/result.md"}"#, + r#"{"success":true}"#, + "", + "{}", + ), + ( + "f", + "tool_call", + "write_file", + r#"{"path":"/tmp/failed.md"}"#, + r#"{"success":false}"#, + "", + "{}", + ), + ( + "terminal", + "tool_call", + "mcp__codex_app.read_thread_terminal", + "{}", + r#"{"isError":true,"content":[{"type":"text","text":"No terminal session attached"}]}"#, + "", + r#"{"displayStatus":"failed","callId":"terminal-call"}"#, + ), + ( + "pending", + "tool_call", + "read_file", + r#"{"path":"/tmp/pending.md"}"#, + r#"{"success":false,"status":"pending"}"#, + "", + r#"{"displayStatus":"running"}"#, + ), + ( + "s", + "system", + "system", + "{}", + "{}", + "https://internal.example", + "{}", + ), + ( + "thinking", + "assistant", + "assistant_message", + "{}", + "{}", + "https://thought.example", + r#"{"displayVariant":"thinking"}"#, + ), + ]; + for (index, (id, kind, function, args, result, content, meta)) in + records.into_iter().enumerate() + { + conn.execute( + "INSERT INTO events VALUES(?1,'s',?2,?3,?4,?5,?6,?7,'now',?8)", + params![id, kind, function, args, result, content, meta, index], + ) + .unwrap(); + } + let sources = stored_source_messages( + &conn, + "s", + vec![StoredUserMessage { + id: "u".into(), + text: "https://input.example".into(), + images: vec![], + }], + ) + .unwrap(); + assert_eq!( + sources + .iter() + .map(|s| (s.id.as_str(), s.role)) + .collect::>(), + vec![ + ("u", SourceMessageRole::User), + ("a", SourceMessageRole::Assistant), + ("t", SourceMessageRole::Tool), + ("f", SourceMessageRole::Tool), + ("terminal-call", SourceMessageRole::Tool) + ] + ); + assert!(sources[1].text.contains("/tmp/plan.md")); + // Successful edit receipts contribute activity, not a resource row. + assert!(sources[2].text.is_empty()); + assert!(sources[2].tool_activity.is_some()); + assert!(sources[3].text.is_empty()); + assert_eq!( + sources[4].tool_activity.as_ref().unwrap().error.as_deref(), + Some("No terminal session attached") + ); + assert_eq!( + sources[4].tool_activity.as_ref().unwrap().call_id, + "terminal-call" + ); + assert_eq!( + sources[3].tool_activity.as_ref().unwrap().status, + orgtrack_core::sources::imported_history::user_sources::ToolActivityStatus::Error + ); + } +} diff --git a/src-tauri/src/orgtrack/history_commands/session_sources.rs b/src-tauri/src/orgtrack/history_commands/session_sources.rs index 0ddb878901..06bcdf4157 100644 --- a/src-tauri/src/orgtrack/history_commands/session_sources.rs +++ b/src-tauri/src/orgtrack/history_commands/session_sources.rs @@ -1,4 +1,4 @@ -//! `session_source_messages`: every user message of one session, reduced to +//! `session_source_messages`: explicit resources of one conversation, reduced to //! what the workstation trail's Sources list reads (reference lines and //! lazily loadable image refs). //! @@ -64,10 +64,6 @@ fn load_session_source_messages(session_id: &str) -> Result; +} + +/** One resource-bearing message as `session_source_messages` returns it. */ export interface SessionSourceMessage { id: string; text: string; images?: readonly string[]; + /** Older backends omit this and represent user messages only. */ + role?: "user" | "assistant" | "tool"; + /** Only successful structured tool references are projected by the backend. */ + toolName?: string; + toolActivity?: SessionToolActivity; } /** - * Every user message of a session, reduced by Rust to the lines that can - * carry a web reference and to image references a thumbnail loads on demand + * Resource-bearing messages of a session, reduced by Rust to the lines that can + * carry an explicit file/web reference and image references a thumbnail loads on demand * (never inline bytes). Reads the session's own history store, so turns that * are not loaded in the chat are included. */ diff --git a/src/engines/ChatPanel/ChatPanelCompanionLayout.test.ts b/src/engines/ChatPanel/ChatPanelCompanionLayout.test.ts new file mode 100644 index 0000000000..00ca3aa1d1 --- /dev/null +++ b/src/engines/ChatPanel/ChatPanelCompanionLayout.test.ts @@ -0,0 +1,227 @@ +// @vitest-environment jsdom +import { Provider } from "jotai"; +import React, { act, useEffect } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import { chatPanelCompanionAtom } from "@src/store/chatPanel/chatPanelCompanionAtom"; +import { createSessionTab } from "@src/store/chatPanel/chatPanelTabFactories"; +import type { ChatPanelTab } from "@src/store/chatPanel/chatPanelTabsModel"; +import { chatPanelTabsAtom } from "@src/store/chatPanel/chatPanelTabsState"; +import { + createInstrumentedStore, + resetInstrumentedStore, +} from "@src/util/core/state/instrumentedStore"; + +import { ChatPanelShell } from "./ChatPanelShell"; +import { ChatPanelChrome } from "./header/ChatPanelChrome"; +import { resolveChatPanelChromeTopInsetPx } from "./header/chatPanelHeaderLayout"; +import { openSessionSources } from "./sessionSources/openSessionSources"; + +const mounts = vi.hoisted(() => ({ chat: 0, sources: 0, dispose: 0 })); +vi.mock("./TabContent/UnifiedChatPanelTabContent", () => ({ + UnifiedChatPanelTabContent: ({ + chatColumn, + }: { + chatColumn: React.ReactNode; + }) => chatColumn, +})); +vi.mock("@src/features/SessionSources/SessionSourcesContent", () => ({ + SessionSourcesContent: ({ sessionId }: { sessionId: string }) => { + useEffect(() => { + mounts.sources++; + return () => { + mounts.dispose++; + }; + }, []); + return React.createElement( + "div", + { "data-source-session": sessionId }, + "Sources body" + ); + }, +})); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +function Chat() { + useEffect(() => { + mounts.chat++; + }, []); + return React.createElement("input", { + "aria-label": "Draft", + defaultValue: "unsent message", + }); +} +let root: Root; +let host: HTMLDivElement; +let store: ReturnType; +let chat: ChatPanelTab; +async function render( + activeTab: ChatPanelTab = chat, + chrome?: { overlay: boolean; collapsed: boolean } +) { + await act(async () => + root.render( + React.createElement( + Provider, + { store }, + React.createElement(ChatPanelShell, { + activeTab, + chatColumn: React.createElement(Chat), + focusedWorkstationRail: React.createElement( + "div", + { "data-rail": true }, + "Summary" + ), + borderClasses: "", + chatPanelOpacityStyle: {}, + chatWidth: 1000, + chatWidthStyleValue: "100%", + fullScreen: true, + hasTabBar: false, + chromeTopInset: chrome + ? resolveChatPanelChromeTopInsetPx(chrome.overlay, chrome.collapsed) + : 0, + headerSection: chrome + ? React.createElement(ChatPanelChrome, { + tabStrip: "Session tab", + toolbar: "Chat actions", + publishedHeaderSlots: { content: "Conversation" }, + overlayPublishedHeader: chrome.overlay, + tabRowCollapsed: chrome.collapsed, + }) + : null, + isDragging: false, + isLeftPosition: true, + isTerminalTabActive: false, + onResizeMouseDown: () => {}, + panelRef: React.createRef(), + resizeTooltipLabel: "Resize", + resizeTooltipShortcut: "", + sessionModals: null, + showResizeHandle: false, + terminalTabs: [], + useExternalWidth: true, + }) + ) + ) + ); +} +async function open() { + await act(async () => { + openSessionSources(chat.sessionId!, "Sources"); + await new Promise((resolve) => setTimeout(resolve, 0)); + }); +} +beforeEach(() => { + Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true); + resetInstrumentedStore(); + store = createInstrumentedStore(); + chat = createSessionTab({ sessionId: "a" }); + store.set(chatPanelTabsAtom, { tabs: [chat], activeTabId: chat.id }); + mounts.chat = mounts.sources = mounts.dispose = 0; + host = document.createElement("div"); + document.body.append(host); + root = createRoot(host); +}); +afterEach(() => { + act(() => root.unmount()); + host.remove(); + resetInstrumentedStore(); + Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT"); +}); +describe("ChatPanel companion composition", () => { + it("reserves floating chrome space through collapsed, expanded and in-flow headers", async () => { + await render(chat, { overlay: true, collapsed: true }); + const input = host.querySelector("input"); + await open(); + const pane = host.querySelector( + '[data-testid="chat-panel-companion"]' + )!; + for (const [overlay, collapsed, inset] of [ + [true, true, 44], + [true, false, 80], + [false, false, 0], + [true, true, 44], + ] as const) { + await render(chat, { overlay, collapsed }); + expect( + host.querySelector('[data-testid="chat-panel-header-surface"]') + ).not.toBeNull(); + expect(pane.style.marginTop).toBe(`${inset}px`); + expect((pane.previousElementSibling as HTMLElement).style.marginTop).toBe( + `${inset}px` + ); + expect(host.querySelector('[data-testid="chat-panel-companion"]')).toBe( + pane + ); + expect(host.querySelector("input")).toBe(input); + } + expect(mounts.chat).toBe(1); + expect(mounts.sources).toBe(1); + expect(mounts.dispose).toBe(0); + }); + it("keeps the chat DOM and draft mounted through open, repeat-open and close", async () => { + await render(); + const input = host.querySelector("input")!; + input.value = "still composing"; + await open(); + expect(host.querySelector('[data-source-session="a"]')).not.toBeNull(); + expect(host.querySelector("[data-rail]")).toBeNull(); + expect(host.querySelector("input")).toBe(input); + expect(input.value).toBe("still composing"); + await open(); + expect(mounts.sources).toBe(1); + act(() => + host + .querySelector( + '[aria-label="common:actions.close"]' + )! + .click() + ); + expect( + host.querySelector('[data-testid="chat-panel-companion"]') + ).toBeNull(); + expect(host.querySelector("[data-rail]")).not.toBeNull(); + expect(host.querySelector("input")).toBe(input); + expect(mounts.chat).toBe(1); + expect(mounts.dispose).toBe(1); + }); + it("closes on session switch and never shows the previous session on return", async () => { + await render(); + await open(); + await render({ ...chat, sessionId: "b" }); + expect(host.querySelector("[data-source-session]")).toBeNull(); + expect(store.get(chatPanelCompanionAtom)).toBeNull(); + expect(mounts.dispose).toBe(1); + await render(); + expect(host.querySelector("[data-source-session]")).toBeNull(); + }); + it("closes when switching to another tab or unmounting the shell", async () => { + await render(); + await open(); + await render({ ...chat, id: "other", type: "start-page" }); + expect(store.get(chatPanelCompanionAtom)).toBeNull(); + await render(); + await open(); + act(() => root.render(null)); + expect(store.get(chatPanelCompanionAtom)).toBeNull(); + expect(mounts.dispose).toBe(2); + }); + it("opens the same content in Station only through the explicit action", async () => { + await render(); + await open(); + act(() => + host + .querySelector( + '[aria-label="sessions:chat.moveToWorkstation"]' + )! + .click() + ); + expect(store.get(chatPanelCompanionAtom)).toBeNull(); + expect(host.querySelector("[data-source-session]")).toBeNull(); + expect(host.querySelector("input")).not.toBeNull(); + }); +}); diff --git a/src/engines/ChatPanel/ChatPanelCompanionLayout.tsx b/src/engines/ChatPanel/ChatPanelCompanionLayout.tsx new file mode 100644 index 0000000000..9d600e08ca --- /dev/null +++ b/src/engines/ChatPanel/ChatPanelCompanionLayout.tsx @@ -0,0 +1,157 @@ +import { useAtom } from "jotai"; +import React, { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import Button from "@src/components/Button"; +import PanelHeader, { + PANEL_HEADER_TOKENS, +} from "@src/components/layout/blocks/PanelHeader"; +import { Cancel01Icon, ComputerIcon, HugeiconsIcon } from "@src/icons"; +import { VerticalResizeHandle, useColumnResize } from "@src/scaffold/Resize"; +import { chatPanelCompanionAtom } from "@src/store/chatPanel/chatPanelCompanionAtom"; +import type { ChatPanelTab } from "@src/store/chatPanel/chatPanelTabsModel"; + +import { openSessionSources } from "./sessionSources/openSessionSources"; + +const SessionSourcesContent = React.lazy(() => + import("@src/features/SessionSources/SessionSourcesContent").then( + (module) => ({ + default: module.SessionSourcesContent, + }) + ) +); + +// Preferred reading width; the CSS cap always leaves at least half for chat. +const COMPANION_WIDTH = { initial: 420, min: 240, max: 720 }; + +function SourcesCompanion({ + topInset, + sessionId, + onClose, +}: { + topInset: number; + sessionId: string; + onClose: () => void; +}) { + const { t } = useTranslation(); + const [width, setWidth] = useState(COMPANION_WIDTH.initial); + const { columnRef, handleMouseDown, isResizing } = useColumnResize({ + width, + setWidth, + min: COMPANION_WIDTH.min, + max: COMPANION_WIDTH.max, + inverted: true, + }); + const title = t("common:git.rail.sources"); + const closeLabel = t("common:actions.close"); + const stationLabel = t("sessions:chat.moveToWorkstation"); + return ( + <> +
+ +
+
+ +
+ + ); +} + +/** Keep the chat subtree stable while swapping its summary rail for companion content. */ +export function ChatPanelCompanionLayout({ + activeTab, + topInset = 0, + children, + rail, +}: React.PropsWithChildren<{ + activeTab: ChatPanelTab | null; + topInset?: number; + rail?: React.ReactNode; +}>) { + const [companion, setCompanion] = useAtom(chatPanelCompanionAtom); + const visible = + companion !== null && + activeTab?.type === "session" && + activeTab.id === companion.ownerTabId && + activeTab.sessionId === companion.sessionId; + + // Navigation closes transient content. The render gate hides stale content + // immediately, before this cleanup runs; late source reads cannot cross owners. + useEffect(() => { + if (companion && !visible) setCompanion(null); + }, [companion, visible, setCompanion]); + useEffect(() => () => setCompanion(null), [setCompanion]); + + return ( +
+
+ {children} +
+ {visible ? ( + setCompanion(null)} + /> + ) : ( + rail + )} +
+ ); +} diff --git a/src/engines/ChatPanel/ChatPanelShell.tsx b/src/engines/ChatPanel/ChatPanelShell.tsx index 874aefbd20..c982a21241 100644 --- a/src/engines/ChatPanel/ChatPanelShell.tsx +++ b/src/engines/ChatPanel/ChatPanelShell.tsx @@ -5,6 +5,7 @@ import { VerticalResizeHandle } from "@src/scaffold/Resize"; import { GUIDE_TARGETS } from "@src/scaffold/Tutorials/guideTargets"; import type { ChatPanelTab } from "@src/store/chatPanel/chatPanelTabsModel"; +import { ChatPanelCompanionLayout } from "./ChatPanelCompanionLayout"; import { UnifiedChatPanelTabContent } from "./TabContent/UnifiedChatPanelTabContent"; import { ChatPanelFullScreenContext } from "./chatPanelFullScreenContext"; @@ -17,6 +18,8 @@ interface ChatPanelShellProps { chatPanelOpacityStyle: ChatPanelShellStyle; chatWidth: number; chatWidthStyleValue: string | number; + /** Space occupied by floating pane chrome, shared with transcript and rail. */ + chromeTopInset?: number; focusedWorkstationRail?: React.ReactNode; /** The pane fills the app window; hosted tab content may use compact chrome. */ fullScreen: boolean; @@ -47,6 +50,7 @@ export function ChatPanelShell({ chatPanelOpacityStyle, chatWidth, chatWidthStyleValue, + chromeTopInset = 0, focusedWorkstationRail, fullScreen, hasTabBar, @@ -105,20 +109,21 @@ export function ChatPanelShell({ }} > {headerSection} -
-
- - - -
- {focusedWorkstationRail} -
+ + + + + {panelOverlay} ); diff --git a/src/engines/ChatPanel/components/SessionWorkstationRail.tsx b/src/engines/ChatPanel/components/SessionWorkstationRail.tsx index a72b62a524..b7597c4f78 100644 --- a/src/engines/ChatPanel/components/SessionWorkstationRail.tsx +++ b/src/engines/ChatPanel/components/SessionWorkstationRail.tsx @@ -6,6 +6,8 @@ import { IMPORTED_HISTORY_SOURCE_DESCRIPTORS } from "@src/api/tauri/externalHist import { formatAgentType } from "@src/assets/providers"; import { getIconProviderFromType } from "@src/components/ModelIcon/config"; import { resolveAgentIcon } from "@src/config/agentIcons"; +import { SessionSourcesFileScope } from "@src/engines/ChatPanel/sessionSources/SessionSourcesFileScope"; +import { openSessionSources } from "@src/engines/ChatPanel/sessionSources/openSessionSources"; import { useSessionSources } from "@src/engines/ChatPanel/sessionSources/useSessionSources"; import { useSubagentSessions } from "@src/engines/Simulator/hooks/useSubagentSessions"; import { useChannelWorkItem } from "@src/features/DiscussionChannels/ChannelPanelView/useChannelWorkItem"; @@ -198,6 +200,7 @@ interface ConnectedSessionWorkstationRailProps extends Omit< /** Session scope shared with the unlinked rail; `workItem` is replaced. */ sessionContext: FocusedChatSessionContext; sources: FocusedChatRailSource[]; + onOpenSources?: () => void; subagentIcon: FocusedChatRailIcon; subagents: FocusedChatRailSubagent[]; workItemId: string; @@ -209,6 +212,7 @@ const ConnectedSessionWorkstationRail: React.FC< compactMenuHost, conversationMinimapHostRef, orgId, + onOpenSources, projectSlug, sessionContext: baseSessionContext, sources, @@ -255,6 +259,7 @@ const ConnectedSessionWorkstationRail: React.FC< conversationMinimapHostRef={conversationMinimapHostRef} sessionContext={sessionContext} sources={sources} + onOpenSources={onOpenSources} subagentIcon={subagentIcon} subagents={subagents} topInset={topInset} @@ -262,7 +267,7 @@ const ConnectedSessionWorkstationRail: React.FC< ); }; -const SessionWorkstationRail: React.FC = ({ +const SessionWorkstationRailContent: React.FC = ({ compactMenuHost, conversationMinimapHostRef, session, @@ -280,6 +285,9 @@ const SessionWorkstationRail: React.FC = ({ ); // Same freshness signal: a new user message advances the session row. const sources = useSessionSources(sessionId, session?.updated_at); + const handleOpenSources = useCallback(() => { + if (sessionId) openSessionSources(sessionId, t("common:git.rail.sources")); + }, [sessionId, t]); // A subagent runs on its parent's harness, so the parent's mark identifies // every child row — resolved through the same projection the sidebar and // chat tab use, which means a Codex session's subagents carry the Codex @@ -325,12 +333,14 @@ const SessionWorkstationRail: React.FC = ({ if (context.workItemId) { return ( = ({ return ( = ({ ); }; -export default SessionWorkstationRail; +export default function SessionWorkstationRail( + props: SessionWorkstationRailProps +) { + return ( + + + + ); +} diff --git a/src/engines/ChatPanel/index.tsx b/src/engines/ChatPanel/index.tsx index 0959991718..e385a64bb8 100644 --- a/src/engines/ChatPanel/index.tsx +++ b/src/engines/ChatPanel/index.tsx @@ -463,6 +463,7 @@ const ChatPanel: React.FC = memo( chatPanelOpacityStyle={chatPanelOpacityStyle} chatWidth={chatWidth} chatWidthStyleValue={chatWidthStyleValue} + chromeTopInset={chromeTopInsetPx} fullScreen={isChatFocus} focusedWorkstationRail={ showFocusedWorkstationControls ? ( diff --git a/src/engines/ChatPanel/sessionSources/SessionSourcesFileScope.tsx b/src/engines/ChatPanel/sessionSources/SessionSourcesFileScope.tsx new file mode 100644 index 0000000000..af94354873 --- /dev/null +++ b/src/engines/ChatPanel/sessionSources/SessionSourcesFileScope.tsx @@ -0,0 +1,30 @@ +import React from "react"; + +import { SharedSessionFilesProvider } from "@src/features/Org2Cloud/SharedSessionFilesContext"; +import type { Session } from "@src/store/session"; + +/** Sources also live outside the transcript's shared-file provider. */ +export function SessionSourcesFileScope({ + session, + children, +}: React.PropsWithChildren<{ session: Session | null | undefined }>) { + return ( + + {children} + + ); +} diff --git a/src/engines/ChatPanel/sessionSources/extractSessionSources.test.ts b/src/engines/ChatPanel/sessionSources/extractSessionSources.test.ts index d71add8710..1530357b10 100644 --- a/src/engines/ChatPanel/sessionSources/extractSessionSources.test.ts +++ b/src/engines/ChatPanel/sessionSources/extractSessionSources.test.ts @@ -3,64 +3,69 @@ import { describe, expect, it } from "vitest"; import { extractSessionSources } from "./extractSessionSources"; describe("extractSessionSources", () => { - it("lists images and links newest message first", () => { + it("lists sources newest message first with honest provision provenance", () => { const sources = extractSessionSources([ { id: "u1", text: "look at https://example.com/docs.", - images: ["/Users/me/.orgii/session-images/abc.png"], + images: ["/tmp/abc.png"], }, { id: "u2", text: "and this example.org/page [link:https://example.org/page]", }, ]); - expect(sources).toEqual([ { kind: "link", key: "link:https://example.org/page", url: "https://example.org/page", label: "example.org/page", + messageId: "u2", + origin: "provided-link", }, { kind: "image", - key: "image:/Users/me/.orgii/session-images/abc.png", - ref: "/Users/me/.orgii/session-images/abc.png", + key: "image:/tmp/abc.png", + ref: "/tmp/abc.png", fileName: "abc.png", + messageId: "u1", + origin: "attachment", }, { kind: "link", key: "link:https://example.com/docs", url: "https://example.com/docs", label: "example.com/docs", + messageId: "u1", + origin: "provided-link", }, ]); }); - it("keeps a repeated source only at its most recent position", () => { + it("keeps a repeated source at its most recent position and message", () => { const sources = extractSessionSources([ { id: "u1", text: "https://example.com/a/", images: ["/tmp/shot.png"] }, { id: "u2", text: "again https://example.com/a#intro" }, { id: "u3", text: "", images: ["/tmp/shot.png"] }, ]); - - expect(sources.map((source) => source.key)).toEqual([ - "image:/tmp/shot.png", - "link:https://example.com/a", + expect(sources.map(({ key, messageId }) => [key, messageId])).toEqual([ + ["image:/tmp/shot.png", "u3"], + ["link:https://example.com/a", "u2"], ]); }); - it("reads Codex attachment envelopes and Markdown links like the bubble does", () => { - const shot = "/var/folders/T/Screenshot 2026-09-16 at 10.35.47 PM.png"; + it("projects Codex attachment envelopes, explicit files and folders without duplicating images", () => { + const shot = "/var/folders/T/Screenshot with spaces.png"; const sources = extractSessionSources([ { id: "codex", text: [ "# Files mentioned by the user:", "", - `## Screenshot 2026-09-16 at 10.35.47 PM.png: ${shot}`, - "## notes.md: /Users/me/notes.md", + `## Screenshot with spaces.png: ${shot}`, + "## notes.md: /tmp/notes.md", + "## docs: /tmp/docs/", "", "## My request:", "should we merge [PR 602](https://github.com/org2AI/ORG2/pull/602)", @@ -68,18 +73,49 @@ describe("extractSessionSources", () => { images: [`orgii-transcript-image:${JSON.stringify(["s", "t", shot])}`], }, ]); - expect(sources).toEqual([ expect.objectContaining({ kind: "image", - fileName: "Screenshot 2026-09-16 at 10.35.47 PM.png", + fileName: "Screenshot with spaces.png", + messageId: "codex", }), { + kind: "file", + key: "file:/tmp/notes.md", + path: "/tmp/notes.md", + fileName: "notes.md", + isDirectory: false, + messageId: "codex", + origin: "provided-file", + }, + { + kind: "file", + key: "file:/tmp/docs/", + path: "/tmp/docs/", + fileName: "docs", + isDirectory: true, + messageId: "codex", + origin: "provided-file", + }, + expect.objectContaining({ kind: "link", - key: "link:https://github.com/org2AI/ORG2/pull/602", url: "https://github.com/org2AI/ORG2/pull/602", label: "org2AI/ORG2#602", + }), + ]); + }); + + it("recognizes persisted file pills and Markdown references but never plain path mentions", () => { + const sources = extractSessionSources([ + { + id: "u1", + text: "review /tmp/secret.md and src/config.ts\n[Plan](./docs/plan.md)\nnotes [file:/tmp/notes.md]", }, + { id: "u2", text: "[Notes](/tmp/notes.md)" }, + ]); + expect(sources.map(({ key, messageId }) => [key, messageId])).toEqual([ + ["file:/tmp/notes.md", "u2"], + ["file:./docs/plan.md", "u1"], ]); }); @@ -89,38 +125,252 @@ describe("extractSessionSources", () => { { id: "u1", text: "", images: [inline] }, { id: "u2", text: "", images: [inline] }, ]); - expect(sources).toEqual([ - { kind: "image", key: "image:u2:0", ref: inline, fileName: null }, - { kind: "image", key: "image:u1:0", ref: inline, fileName: null }, + { + kind: "image", + key: "image:u2:0", + ref: inline, + fileName: null, + messageId: "u2", + origin: "attachment", + }, + { + kind: "image", + key: "image:u1:0", + ref: inline, + fileName: null, + messageId: "u1", + origin: "attachment", + }, ]); }); - it("ignores non-web references, credentials, and URLs inside other words", () => { + it("ignores credentials, non-web text, session references and generated context", () => { + expect( + extractSessionSources([ + { + id: "u1", + text: [ + "https://ambient.dev/", + "ftp://example.com/file", + "https://user:secret@example.com/private", + "(https://example.com/wrapped)", + "see-https://example.com/joined", + "Earlier-work [session:sde-12345678-1234-1234-1234-123456789abc]", + ].join("\n"), + }, + ]) + ).toEqual([]); + }); + it("includes assistant PRs, titled documents and local image references with honest provenance", () => { const sources = extractSessionSources([ + { id: "user", text: "please review", images: ["/tmp/input.png"] }, { - id: "u1", - text: [ - "notes.md [file:/Users/me/notes.md]", - "ftp://example.com/file", - "https://user:secret@example.com/private", - "(https://example.com/wrapped)", - "see-https://example.com/joined", - ].join(" "), + id: "assistant", + role: "assistant", + text: "PR: [fix(chat): stabilize actions](https://github.com/org/repo/pull/42)\n[Implementation report](/tmp/implementation.md)\n![Screenshot](/tmp/preview.png)", }, ]); + expect(sources).toEqual([ + expect.objectContaining({ + kind: "link", + label: "fix(chat): stabilize actions", + url: "https://github.com/org/repo/pull/42", + origin: "assistant-reference", + messageId: "assistant", + }), + expect.objectContaining({ + kind: "file", + path: "/tmp/implementation.md", + title: "Implementation report", + fileName: "implementation.md", + origin: "assistant-reference", + }), + expect.objectContaining({ + kind: "image", + ref: "/tmp/preview.png", + origin: "assistant-reference", + }), + expect.objectContaining({ + kind: "image", + ref: "/tmp/input.png", + origin: "attachment", + }), + ]); + expect(sources.filter((source) => source.kind !== "image")).toHaveLength(2); + }); - expect(sources).toEqual([]); + it("includes explicit structured tool references and names their origin without claiming generation", () => { + expect( + extractSessionSources([ + { + id: "tool-1", + role: "tool", + toolName: "read_file", + text: "report.md [file:/tmp/report.md]", + }, + { + id: "tool-2", + role: "tool", + toolName: "web_search", + text: "resource [link:https://example.com/result]", + }, + ]) + ).toEqual([ + expect.objectContaining({ + kind: "link", + url: "https://example.com/result", + origin: "tool-result", + toolName: "web_search", + }), + expect.objectContaining({ + kind: "file", + path: "/tmp/report.md", + origin: "tool-result", + toolName: "read_file", + }), + ]); }); - it("does not count session references as web links", () => { + it("deduplicates across roles while retaining distinct provenance and latest assistant title", () => { const sources = extractSessionSources([ + { id: "u", text: "https://github.com/org/repo/pull/42" }, { - id: "u1", - text: "Earlier-work [session:sde-12345678-1234-1234-1234-123456789abc]", + id: "t", + role: "tool", + toolName: "github", + text: "resource [link:https://github.com/org/repo/pull/42]", + }, + { + id: "a", + role: "assistant", + text: "[Fix source navigation](https://github.com/org/repo/pull/42)", + }, + ]); + expect(sources).toHaveLength(1); + expect(sources[0]).toMatchObject({ + label: "Fix source navigation", + messageId: "a", + origin: "assistant-reference", + origins: ["assistant-reference", "tool-result", "provided-link"], + }); + }); + + it("does not treat assistant code examples, free-form paths or shell transcripts as offered files", () => { + expect( + extractSessionSources([ + { + id: "a", + role: "assistant", + text: "Mention /tmp/not-offered.txt.\n`https://inline.example`\n```sh\ncurl https://example-code.dev\necho '[file](/tmp/example.txt)'\n```", + }, + ]) + ).toEqual([]); + }); + it("accepts the backend's bare structured tool pills including relative paths", () => { + expect( + extractSessionSources([ + { + id: "tool", + role: "tool", + toolName: "read_file", + text: "[file:src/main.ts]\n[link:https://example.com/result]", + }, + ]) + ).toEqual([ + expect.objectContaining({ + kind: "file", + path: "src/main.ts", + fileName: "main.ts", + origin: "tool-result", + toolName: "read_file", + }), + expect.objectContaining({ + kind: "link", + url: "https://example.com/result", + origin: "tool-result", + }), + ]); + }); + it("groups tool activity including failures and deduplicates stable call ids without fake resources", () => { + const sources = extractSessionSources([ + { + id: "old", + role: "tool", + text: "[file:/tmp/stale.md]", + toolActivity: { + callId: "same", + toolName: "web.search", + group: "web", + status: "success", + actions: [{ kind: "search", query: "old query" }], + }, + }, + { + id: "terminal", + role: "tool", + text: "", + toolActivity: { + callId: "terminal", + toolName: "read_thread_terminal", + group: "codex-app", + status: "success", + actions: [{ kind: "read-terminal" }], + }, + }, + { + id: "failed", + role: "tool", + text: "[file:/tmp/never-created.md]", + images: ["/tmp/never-created.png"], + toolActivity: { + callId: "same", + toolName: "web.search", + group: "web", + status: "error", + error: "No results found", + actions: [{ kind: "search", query: "latest query" }], + }, }, ]); + expect(sources).toHaveLength(2); + expect(sources[0]).toMatchObject({ + kind: "tool-group", + group: "web", + operations: [ + { + callId: "same", + status: "error", + error: "No results found", + actions: [{ kind: "search", query: "latest query" }], + }, + ], + }); + expect(sources[1]).toMatchObject({ + kind: "tool-group", + group: "codex-app", + }); + expect(sources.every((source) => source.kind === "tool-group")).toBe(true); + }); - expect(sources).toEqual([]); + it("keeps successful referenced resources alongside their tool activity group", () => { + const sources = extractSessionSources([ + { + id: "tool", + role: "tool", + text: "[link:https://example.com]", + toolActivity: { + callId: "open", + toolName: "web.open", + group: "web", + status: "success", + actions: [{ kind: "open", url: "https://example.com" }], + }, + }, + ]); + expect(sources.map((source) => source.kind)).toEqual([ + "tool-group", + "link", + ]); }); }); diff --git a/src/engines/ChatPanel/sessionSources/extractSessionSources.ts b/src/engines/ChatPanel/sessionSources/extractSessionSources.ts index 37a5b2ae31..9f04a8d6b4 100644 --- a/src/engines/ChatPanel/sessionSources/extractSessionSources.ts +++ b/src/engines/ChatPanel/sessionSources/extractSessionSources.ts @@ -1,15 +1,19 @@ /** - * Session sources — the images and web links a user sent to the agent in - * one session, projected from that session's user messages. + * Session resources explicitly supplied by users, referenced in assistant + * responses, or projected from successful structured tool results, plus grouped + * completed tool activity (including failures). * * Pure text layer behind the workstation trail's Sources section. Links are * read the same way the sent bubble reads them (Codex attachment envelope * stripped, Markdown references and serialized pills parsed, session - * references lifted out), so a row here is always something the bubble shows - * as a link. Bare URLs count only as their own whitespace-delimited word, + * references lifted out). Tool activity comes from explicit backend metadata, + * independently of reference rows. Bare URLs count as a whitespace-delimited word, * matching the composer's typed-URL rule. */ -import type { SessionSourceMessage } from "@src/api/tauri/session/sessionSources"; +import type { + SessionSourceMessage, + SessionToolActivity, +} from "@src/api/tauri/session/sessionSources"; import { parseHttpUrlPill } from "@src/components/ComposerInput/httpUrl"; import { resolvePostedReferenceHref } from "@src/components/ComposerInput/postedReferenceHref"; import { @@ -22,21 +26,55 @@ import { imageRefToRustPath } from "@src/util/file/imageRefs"; export type { SessionSourceMessage }; -export type SessionSource = - | { - kind: "image"; - key: string; - /** Reference as stored on the message (path, asset/data URL, transcript ref). */ - ref: string; - /** File name, or null for an inline image that never had one. */ - fileName: string | null; - } - | { - kind: "link"; - key: string; - url: string; - label: string; - }; +export type SessionSourceOrigin = + | "attachment" + | "provided-link" + | "provided-file" + | "assistant-reference" + | "tool-result"; + +interface SessionSourceProvenance { + /** Latest message explicitly referencing this resource. */ + messageId?: string; + /** Describes provision, never evidence that the agent read the resource. */ + origin?: SessionSourceOrigin; + /** Distinct earlier provenance retained when roles reference the same resource. */ + origins?: SessionSourceOrigin[]; + toolName?: string; +} + +export type SessionSource = SessionSourceProvenance & + ( + | { + kind: "image"; + key: string; + /** Reference as stored on the message (path, asset/data URL, transcript ref). */ + ref: string; + /** File name, or null for an inline image that never had one. */ + fileName: string | null; + } + | { + kind: "link"; + key: string; + url: string; + label: string; + } + | { + kind: "tool-group"; + key: string; + group: string; + operations: SessionToolActivity[]; + } + | { + kind: "file"; + key: string; + path: string; + fileName: string; + /** Human-readable assistant link title, distinct from the filename. */ + title?: string; + isDirectory: boolean; + } + ); const TRAILING_SENTENCE_PUNCTUATION = /[.,;:!?]+$/u; @@ -84,17 +122,25 @@ function bareLinks(text: string): Array<{ url: string; label: string }> { return links; } -function messageLinks( - message: SessionSourceMessage -): Array<{ url: string; label: string }> { +function serializedReferences(message: SessionSourceMessage): SessionSource[] { const normalized = normalizeMarkdownReferencePills( normalizeUserMessageText(message.text, message.images) ); const { text } = projectMarkdownSessionReferences(normalized); - const links: Array<{ url: string; label: string }> = []; + const references: SessionSource[] = []; for (const segment of parseNormalizedUserMessage(text)) { if (segment.kind === "text") { - links.push(...bareLinks(segment.text)); + references.push( + ...bareLinks(segment.text).map( + (link): SessionSource => ({ + kind: "link", + key: linkKey(link.url), + ...link, + messageId: message.id, + origin: "provided-link", + }) + ) + ); continue; } if (segment.kind !== "pill") continue; @@ -105,9 +151,113 @@ function messageLinks( segment.terminalText ) ); - if (url) links.push({ url, label: segment.displayName || url }); + if (url) { + references.push({ + kind: "link", + key: linkKey(url), + url, + label: segment.displayName || url, + messageId: message.id, + origin: "provided-link", + }); + } else if (segment.pillType === "file" || segment.pillType === "folder") { + const path = segment.path; + // Only explicit file references become rows; free-form path mentions do not. + references.push({ + kind: "file", + key: `file:${path}`, + path, + fileName: + path + .replace(/[\\/]$/u, "") + .split(/[\\/]/u) + .pop() || + segment.displayName || + path, + isDirectory: segment.pillType === "folder", + messageId: message.id, + origin: "provided-file", + }); + } } - return links; + return references; +} + +function provenance( + message: SessionSourceMessage, + origin: SessionSourceOrigin +): SessionSourceProvenance { + return { + messageId: message.id, + origin: + message.role === "assistant" + ? "assistant-reference" + : message.role === "tool" + ? "tool-result" + : origin, + ...(message.role === "tool" && message.toolName + ? { toolName: message.toolName } + : {}), + }; +} + +/** Assistant Markdown carries meaningful titles that composer pills intentionally shorten. */ +function assistantReferences(message: SessionSourceMessage): SessionSource[] { + // Example code is not an offered resource. Never mine shell/log text for paths. + const text = message.text + .replace(/```[^\n]*\n[\s\S]*?(?:```|$)/gu, "") + .replace(/`[^`\n]*`/gu, ""); + const references: SessionSource[] = []; + const rest = text.replace( + /(!?)\[([^\]\r\n]+)\]\(([^)\r\n]+)\)/gu, + ( + match, + image: string, + label: string, + rawDestination: string, + offset: number + ) => { + if (offset > 0 && text[offset - 1] === "\\") return match; + const destination = rawDestination.trim().replace(/^<|>$/gu, ""); + if (image) { + // Remote image links remain browser links; local image refs use the established gallery. + if (webHref(destination)) { + references.push({ + kind: "link", + key: linkKey(destination), + url: destination, + label, + }); + } else { + const parsed = serializedReferences({ + ...message, + text: `[${label}](${destination})`, + }); + const file = parsed.find((source) => source.kind === "file"); + if (file?.kind === "file") + references.push({ + kind: "image", + key: imageKey(file.path, message.id, references.length), + ref: file.path, + fileName: imageFileName(file.path), + }); + } + return " "; + } + const parsed = serializedReferences({ ...message, text: match }); + for (const source of parsed) { + references.push( + source.kind === "link" + ? { ...source, label: label.trim() || source.label } + : source.kind === "file" + ? { ...source, title: label.trim() || undefined } + : source + ); + } + return parsed.length ? " " : match; + } + ); + return [...references, ...serializedReferences({ ...message, text: rest })]; } /** @@ -118,21 +268,72 @@ export function extractSessionSources( messages: readonly SessionSourceMessage[] ): SessionSource[] { const sources: SessionSource[] = []; - const seen = new Set(); + const byKey = new Map(); + const seenCalls = new Set(); + const add = (source: SessionSource) => { + const previous = byKey.get(source.key); + if (previous) { + const origins = new Set([ + ...(previous.origins ?? (previous.origin ? [previous.origin] : [])), + ...(source.origin ? [source.origin] : []), + ]); + if (origins.size > 1) previous.origins = [...origins]; + return; + } + byKey.set(source.key, source); + sources.push(source); + }; for (let index = messages.length - 1; index >= 0; index -= 1) { const message = messages[index]; + const activity = message.toolActivity; + if (activity?.callId && seenCalls.has(activity.callId)) continue; + if (activity && activity.callId) { + seenCalls.add(activity.callId); + const key = `tool-group:${activity.group}`; + const group = byKey.get(key); + if (group?.kind === "tool-group") group.operations.push(activity); + else + add({ + kind: "tool-group", + key, + group: activity.group, + operations: [activity], + }); + } + // Errors are activity only, even if an upstream producer accidentally includes references. + if (activity?.status === "error") continue; (message.images ?? []).forEach((ref, imageIndex) => { - const key = imageKey(ref, message.id, imageIndex); - if (seen.has(key)) return; - seen.add(key); - sources.push({ kind: "image", key, ref, fileName: imageFileName(ref) }); + add({ + kind: "image", + key: imageKey(ref, message.id, imageIndex), + ref, + fileName: imageFileName(ref), + ...provenance(message, "attachment"), + }); }); if (!message.text) continue; - for (const link of messageLinks(message)) { - const key = linkKey(link.url); - if (seen.has(key)) continue; - seen.add(key); - sources.push({ kind: "link", key, url: link.url, label: link.label }); + const references = + message.role === "assistant" + ? assistantReferences(message) + : serializedReferences( + message.role === "tool" + ? { + ...message, + // Structured tools may emit a bare pill without a composer label. + // The read-side pill parser requires a label before the token. + text: message.text.replace( + /(^|\n)\[(file|folder|link):([^\]\n]+)\]/gu, + (_match, boundary: string, kind: string, path: string) => + `${boundary}${kind === "link" ? (parseHttpUrlPill(path)?.displayName ?? "Resource") : "Resource"} [${kind}:${path}]` + ), + } + : message + ); + for (const reference of references) { + add({ + ...reference, + ...provenance(message, reference.origin ?? "provided-link"), + }); } } return sources; diff --git a/src/engines/ChatPanel/sessionSources/openSessionSources.test.ts b/src/engines/ChatPanel/sessionSources/openSessionSources.test.ts new file mode 100644 index 0000000000..8a8ef7ee6c --- /dev/null +++ b/src/engines/ChatPanel/sessionSources/openSessionSources.test.ts @@ -0,0 +1,115 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; + +import { ROUTES } from "@src/config/routes"; +import { EditorTabService } from "@src/services/workStation/EditorTabService"; +import { chatPanelCompanionAtom } from "@src/store/chatPanel/chatPanelCompanionAtom"; +import { createSessionTab } from "@src/store/chatPanel/chatPanelTabFactories"; +import { chatPanelTabsAtom } from "@src/store/chatPanel/chatPanelTabsState"; +import { chatPanelMaximizedAtom } from "@src/store/ui/chatPanel/surfaceAtoms"; +import { stationModeAtom } from "@src/store/ui/simulatorAtom"; +import { + createInstrumentedStore, + resetInstrumentedStore, +} from "@src/util/core/state/instrumentedStore"; + +import { openSessionSources } from "./openSessionSources"; + +describe("openSessionSources", () => { + let root: Root; + let host: HTMLDivElement; + + beforeEach(() => { + localStorage.clear(); + resetInstrumentedStore(); + Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true); + window.history.replaceState({}, "", ROUTES.workStation.code.path); + host = document.createElement("div"); + document.body.appendChild(host); + root = createRoot(host); + }); + + afterEach(() => { + act(() => root.unmount()); + host.remove(); + resetInstrumentedStore(); + Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT"); + }); + + it("opens and refocuses the real session-bound tab only with an explicit Station destination", () => { + const store = createInstrumentedStore(); + const chat = createSessionTab({ sessionId: "session-a" }); + store.set(chatPanelTabsAtom, { tabs: [chat], activeTabId: chat.id }); + store.set(stationModeAtom, "agent-station"); + store.set(chatPanelMaximizedAtom, true); + act(() => + root.render( + React.createElement( + "button", + { + onClick: () => + openSessionSources("session-a", "Sources", "my-station"), + }, + "View all" + ) + ) + ); + act(() => host.querySelector("button")!.click()); + + expect(store.get(stationModeAtom)).toBe("my-station"); + expect(store.get(chatPanelMaximizedAtom)).toBe(false); + const opened = EditorTabService.getActiveTab(); + expect(opened).toMatchObject({ + type: "session-sources", + data: { sessionId: "session-a" }, + }); + act(() => host.querySelector("button")!.click()); + expect(EditorTabService.getActiveTabId()).toBe(opened!.id); + expect( + EditorTabService.getTabs().filter((tab) => tab.type === "session-sources") + ).toHaveLength(1); + }); + + it("defaults to the conversation companion without creating or activating a tab", () => { + const store = createInstrumentedStore(); + const chat = createSessionTab({ sessionId: "session-a" }); + store.set(chatPanelTabsAtom, { tabs: [chat], activeTabId: chat.id }); + store.set(stationModeAtom, "agent-station"); + store.set(chatPanelMaximizedAtom, true); + const before = EditorTabService.getTabs(); + openSessionSources("session-a", "Sources"); + const opened = store.get(chatPanelCompanionAtom); + expect(opened).toEqual({ + type: "session-sources", + sessionId: "session-a", + ownerTabId: chat.id, + }); + openSessionSources("session-a", "Sources"); + expect(store.get(chatPanelCompanionAtom)).toBe(opened); + expect(store.get(chatPanelTabsAtom).activeTabId).toBe(chat.id); + expect(EditorTabService.getTabs()).toEqual(before); + expect(store.get(stationModeAtom)).toBe("agent-station"); + expect(store.get(chatPanelMaximizedAtom)).toBe(true); + }); + + it("rejects a stale action belonging to another conversation", () => { + const store = createInstrumentedStore(); + const chat = createSessionTab({ sessionId: "session-b" }); + store.set(chatPanelTabsAtom, { tabs: [chat], activeTabId: chat.id }); + openSessionSources("session-a", "Sources"); + expect(store.get(chatPanelCompanionAtom)).toBeNull(); + }); + + it("does not open a tab or reveal the workstation for an empty session reference", () => { + const store = createInstrumentedStore(); + store.set(stationModeAtom, "agent-station"); + store.set(chatPanelMaximizedAtom, true); + const initialTabs = EditorTabService.getTabs(); + openSessionSources(" ", "Sources"); + expect(EditorTabService.getTabs()).toEqual(initialTabs); + expect(store.get(stationModeAtom)).toBe("agent-station"); + expect(store.get(chatPanelMaximizedAtom)).toBe(true); + }); +}); diff --git a/src/engines/ChatPanel/sessionSources/openSessionSources.ts b/src/engines/ChatPanel/sessionSources/openSessionSources.ts new file mode 100644 index 0000000000..4d13b72c23 --- /dev/null +++ b/src/engines/ChatPanel/sessionSources/openSessionSources.ts @@ -0,0 +1,20 @@ +import { EditorTabService } from "@src/services/workStation/EditorTabService"; +import { openChatPanelSourcesAtom } from "@src/store/chatPanel/chatPanelCompanionAtom"; +import { createSessionSourcesTab } from "@src/store/workstation/tabs/factories/sessionSources"; +import { getInstrumentedStore } from "@src/util/core/state/instrumentedStore"; +import { revealMyStation } from "@src/util/ui/revealMyStation"; + +/** Sources open beside the conversation unless the user explicitly chooses Station. */ +export function openSessionSources( + sessionId: string, + title: string, + destination: "chat-panel" | "my-station" = "chat-panel" +): void { + if (!sessionId.trim()) return; + if (destination === "chat-panel") { + getInstrumentedStore().set(openChatPanelSourcesAtom, sessionId); + return; + } + EditorTabService.openTab(createSessionSourcesTab(sessionId, title)); + revealMyStation(); +} diff --git a/src/engines/ChatPanel/sessionSources/useSessionSources.test.ts b/src/engines/ChatPanel/sessionSources/useSessionSources.test.ts index a89a7401e5..c94ca2d898 100644 --- a/src/engines/ChatPanel/sessionSources/useSessionSources.test.ts +++ b/src/engines/ChatPanel/sessionSources/useSessionSources.test.ts @@ -4,7 +4,7 @@ import { type Root, createRoot } from "react-dom/client"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { SessionSource } from "./extractSessionSources"; -import { useSessionSources } from "./useSessionSources"; +import { useSessionSourcesState } from "./useSessionSources"; const api = vi.hoisted(() => ({ readSessionSourceMessages: vi.fn() })); @@ -23,7 +23,10 @@ function deferred() { describe("useSessionSources", () => { let root: Root; let container: HTMLDivElement; - const captured: { current: SessionSource[] } = { current: [] }; + const captured: { + current: SessionSource[]; + state: ReturnType | null; + } = { current: [], state: null }; function Probe({ sessionId, @@ -32,9 +35,10 @@ describe("useSessionSources", () => { sessionId: string | null; reloadKey?: string; }) { - const sources = useSessionSources(sessionId, reloadKey); + const state = useSessionSourcesState(sessionId, reloadKey); useEffect(() => { - captured.current = sources; + captured.current = state.sources; + captured.state = state; }); return null; } @@ -48,6 +52,10 @@ describe("useSessionSources", () => { beforeEach(() => { Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true); api.readSessionSourceMessages.mockReset(); + Object.defineProperty(document, "hidden", { + configurable: true, + value: false, + }); container = document.createElement("div"); root = createRoot(container); captured.current = []; @@ -56,6 +64,7 @@ describe("useSessionSources", () => { afterEach(() => { act(() => root.unmount()); Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT"); + Reflect.deleteProperty(document, "hidden"); }); it("never shows one session's sources for another", async () => { @@ -120,6 +129,220 @@ describe("useSessionSources", () => { expect(api.readSessionSourceMessages).toHaveBeenCalledTimes(3); }); + it("shares concurrent equivalent reads and releases the registry after unmount", async () => { + const first = deferred(); + api.readSessionSourceMessages + .mockReturnValueOnce(first.promise) + .mockResolvedValueOnce([]); + act(() => + root.render( + React.createElement( + React.Fragment, + null, + React.createElement(Probe, { + sessionId: "session-a", + reloadKey: "v1", + }), + React.createElement(Probe, { + sessionId: "session-a", + reloadKey: "v1", + }) + ) + ) + ); + expect(api.readSessionSourceMessages).toHaveBeenCalledTimes(1); + act(() => root.render(null)); + render("session-a", "v1"); + await act(async () => {}); + expect(api.readSessionSourceMessages).toHaveBeenCalledTimes(2); + await act(async () => first.resolve([])); + }); + + it("starts a fresh read for invalidation while the old read is pending", async () => { + const old = deferred(); + const fresh = deferred(); + api.readSessionSourceMessages + .mockReturnValueOnce(old.promise) + .mockReturnValueOnce(fresh.promise); + render("session-a", "v1"); + render("session-a", "v2"); + expect(api.readSessionSourceMessages).toHaveBeenCalledTimes(2); + await act(async () => + fresh.resolve([{ id: "new", text: "https://new.dev/" }]) + ); + await act(async () => + old.resolve([{ id: "old", text: "https://old.dev/" }]) + ); + expect(captured.current[0]).toMatchObject({ messageId: "new" }); + }); + + it("exposes loading and errors, retries, and updates a fragment URL with the same key", async () => { + const first = deferred(); + api.readSessionSourceMessages.mockReturnValueOnce(first.promise); + render("session-a"); + expect(captured.state).toMatchObject({ loading: true, error: false }); + await act(async () => first.reject(new Error("offline"))); + expect(captured.state).toMatchObject({ loading: false, error: true }); + api.readSessionSourceMessages.mockResolvedValueOnce([ + { id: "one", text: "https://a.dev/doc#first" }, + ]); + await act(async () => captured.state?.retry()); + expect(captured.state).toMatchObject({ loading: false, error: false }); + const firstList = captured.current; + api.readSessionSourceMessages.mockResolvedValueOnce([ + { id: "one", text: "https://a.dev/doc#second" }, + ]); + await act(async () => captured.state?.retry()); + expect(captured.current).not.toBe(firstList); + expect(captured.current[0]).toMatchObject({ + url: "https://a.dev/doc#second", + }); + }); + + it("refreshes provenance and document titles when the resource identity stays unchanged", async () => { + api.readSessionSourceMessages.mockResolvedValueOnce([ + { + id: "tool", + role: "tool", + toolName: "read_file", + text: "[file:/tmp/report.md]", + }, + ]); + render("session-a", "v1"); + await act(async () => {}); + const first = captured.current; + expect(first[0]).toMatchObject({ + origin: "tool-result", + toolName: "read_file", + }); + api.readSessionSourceMessages.mockResolvedValueOnce([ + { + id: "assistant", + role: "assistant", + text: "[Implementation report](/tmp/report.md)", + }, + ]); + render("session-a", "v2"); + await act(async () => {}); + expect(captured.current).not.toBe(first); + expect(captured.current[0]).toMatchObject({ + origin: "assistant-reference", + title: "Implementation report", + }); + }); + + it("updates tool activity details without changing stable group identity", async () => { + const activity = { + callId: "search", + toolName: "web.search", + group: "web", + status: "success", + actions: [{ kind: "search", query: "original" }], + }; + api.readSessionSourceMessages.mockResolvedValueOnce([ + { id: "tool", role: "tool", text: "", toolActivity: activity }, + ]); + render("session-a", "v1"); + await act(async () => {}); + const first = captured.current; + expect(first[0]).toMatchObject({ + kind: "tool-group", + key: "tool-group:web", + }); + api.readSessionSourceMessages.mockResolvedValueOnce([ + { + id: "tool", + role: "tool", + text: "", + toolActivity: { + ...activity, + status: "error", + error: "No results", + actions: [{ kind: "search", query: "updated" }], + }, + }, + ]); + render("session-a", "v2"); + await act(async () => {}); + expect(captured.current).not.toBe(first); + expect(captured.current[0]).toMatchObject({ + key: "tool-group:web", + operations: [ + { + status: "error", + error: "No results", + actions: [{ query: "updated" }], + }, + ], + }); + }); + + function setHidden(hidden: boolean) { + act(() => { + Object.defineProperty(document, "hidden", { + configurable: true, + value: hidden, + }); + document.dispatchEvent(new Event("visibilitychange")); + }); + } + + it("defers a hidden initial mount until visibility returns without a hidden spinner", async () => { + Object.defineProperty(document, "hidden", { + configurable: true, + value: true, + }); + api.readSessionSourceMessages.mockResolvedValue([]); + render("session-a", "v1"); + expect(api.readSessionSourceMessages).not.toHaveBeenCalled(); + expect(captured.state).toMatchObject({ loading: false, error: false }); + setHidden(false); + await act(async () => {}); + expect(api.readSessionSourceMessages).toHaveBeenCalledTimes(1); + }); + + it("coalesces hidden history updates into one latest read and retains previous rows", async () => { + api.readSessionSourceMessages.mockResolvedValueOnce([ + { id: "old", text: "https://old.dev" }, + ]); + render("session-a", "v1"); + await act(async () => {}); + const previous = captured.current; + setHidden(true); + render("session-a", "v2"); + render("session-a", "v3"); + expect(api.readSessionSourceMessages).toHaveBeenCalledTimes(1); + expect(captured.current).toBe(previous); + expect(captured.state?.loading).toBe(false); + api.readSessionSourceMessages.mockResolvedValueOnce([ + { id: "latest", text: "https://latest.dev" }, + ]); + setHidden(false); + await act(async () => {}); + expect(api.readSessionSourceMessages).toHaveBeenCalledTimes(2); + expect(captured.current[0]).toMatchObject({ messageId: "latest" }); + render("session-a", "v3"); + await act(async () => {}); + expect(api.readSessionSourceMessages).toHaveBeenCalledTimes(2); + }); + + it("preserves errors while hidden and revalidates unchanged history on return", async () => { + api.readSessionSourceMessages.mockRejectedValueOnce(new Error("offline")); + render("session-a", "v1"); + await act(async () => {}); + setHidden(true); + render("session-a", "v2"); + expect(captured.state).toMatchObject({ loading: false, error: true }); + api.readSessionSourceMessages.mockResolvedValue([]); + setHidden(false); + await act(async () => {}); + expect(captured.state).toMatchObject({ loading: false, error: false }); + setHidden(true); + setHidden(false); + await act(async () => {}); + expect(api.readSessionSourceMessages).toHaveBeenCalledTimes(3); + }); + it("reads nothing without a session", () => { render(null); expect(api.readSessionSourceMessages).not.toHaveBeenCalled(); diff --git a/src/engines/ChatPanel/sessionSources/useSessionSources.ts b/src/engines/ChatPanel/sessionSources/useSessionSources.ts index 0710eeadb7..26da231f1f 100644 --- a/src/engines/ChatPanel/sessionSources/useSessionSources.ts +++ b/src/engines/ChatPanel/sessionSources/useSessionSources.ts @@ -1,15 +1,15 @@ /** - * useSessionSources — the images and links the user sent in one session. + * useSessionSources — explicit resources and completed tool activity in one session. * * Read from the session's stored history, not from the event store: the * store keeps only previews of turns that are not on screen (imported text is - * cut, image lists are capped), so it cannot list everything a long session - * was given. The result is component state tagged with the session it was - * read for — nothing is cached per session, so it goes away with the rail + * cut, image lists are capped), so it cannot represent all resources and activity in a long + * session. The result is component state tagged with the session it was + * read for — only pending reads are shared, so loaded data goes away with its view * and never outlives the session's own data. `reloadKey` re-reads the same * session and keeps the last list until the fresh one lands. */ -import { useEffect, useState } from "react"; +import { useCallback, useEffect, useState } from "react"; import { readSessionSourceMessages } from "@src/api/tauri/session/sessionSources"; import { createLogger } from "@src/hooks/logger"; @@ -26,6 +26,8 @@ const EMPTY_SOURCES: SessionSource[] = []; interface LoadedSources { sessionId: string; sources: SessionSource[]; + requestKey: string; + error: boolean; } function sameSources( @@ -37,23 +39,137 @@ function sameSources( left.every((source, index) => { const other = right[index]; if (source.key !== other.key || source.kind !== other.kind) return false; - return source.kind === "link" - ? other.kind === "link" && source.label === other.label - : other.kind === "image" && source.ref === other.ref; + if ( + source.messageId !== other.messageId || + source.origin !== other.origin || + source.toolName !== other.toolName || + source.origins?.join() !== other.origins?.join() + ) + return false; + if (source.kind === "tool-group") + return ( + other.kind === "tool-group" && + source.group === other.group && + JSON.stringify(source.operations) === JSON.stringify(other.operations) + ); + if (source.kind === "link") { + return ( + other.kind === "link" && + source.label === other.label && + source.url === other.url + ); + } + if (source.kind === "file") { + return ( + other.kind === "file" && + source.path === other.path && + source.fileName === other.fileName && + source.title === other.title && + source.isDirectory === other.isDirectory + ); + } + return ( + other.kind === "image" && + source.ref === other.ref && + source.fileName === other.fileName + ); }) ); } -export function useSessionSources( +// Only in-flight reads are shared. Settled results belong to mounted consumers, +// and the map deletes both successful and failed reads immediately. +const pendingReads = new Map< + string, + { + promise: ReturnType; + consumers: number; + } +>(); +function acquireSources( + sessionId: string, + reloadKey: string | undefined, + retryKey: number, + visibilityVersion: number +) { + const requestKey = JSON.stringify([ + sessionId, + reloadKey ?? null, + retryKey, + visibilityVersion, + ]); + let entry = pendingReads.get(requestKey); + if (!entry) { + entry = { promise: readSessionSourceMessages(sessionId), consumers: 0 }; + pendingReads.set(requestKey, entry); + const current = entry; + void entry.promise + .finally(() => { + if (pendingReads.get(requestKey) === current) + pendingReads.delete(requestKey); + }) + .catch(() => {}); + } + entry.consumers += 1; + const current = entry; + return { + promise: current.promise, + release: () => { + current.consumers -= 1; + // Unmounted sessions retain no registry entries, even if IPC is still pending. + if (current.consumers === 0 && pendingReads.get(requestKey) === current) { + pendingReads.delete(requestKey); + } + }, + }; +} + +export function useSessionSourcesState( sessionId: string | null | undefined, reloadKey?: string -): SessionSource[] { +) { + // No scan starts in a hidden window. Coalesce every hidden invalidation + // into one read of the latest session/reload key when visibility returns. + const [visibility, setVisibility] = useState(() => ({ + visible: typeof document === "undefined" || !document.hidden, + version: 0, + })); + useEffect(() => { + const onVisibilityChange = () => { + const visible = !document.hidden; + setVisibility((previous) => + previous.visible === visible + ? previous + : { + visible, + version: previous.version + (visible ? 1 : 0), + } + ); + }; + document.addEventListener("visibilitychange", onVisibilityChange); + return () => + document.removeEventListener("visibilitychange", onVisibilityChange); + }, []); + const [retryKey, setRetryKey] = useState(0); + const retry = useCallback(() => setRetryKey((value) => value + 1), []); const [loaded, setLoaded] = useState(null); + const requestKey = JSON.stringify([ + sessionId, + reloadKey ?? null, + retryKey, + visibility.version, + ]); useEffect(() => { - if (!sessionId) return; + if (!sessionId || !visibility.visible) return; let cancelled = false; - readSessionSourceMessages(sessionId) + const read = acquireSources( + sessionId, + reloadKey, + retryKey, + visibility.version + ); + read.promise .then((messages) => { if (cancelled) return; const sources = extractSessionSources(messages); @@ -62,8 +178,8 @@ export function useSessionSources( setLoaded((previous) => previous?.sessionId === sessionId && sameSources(previous.sources, sources) - ? previous - : { sessionId, sources } + ? { ...previous, requestKey, error: false } + : { sessionId, sources, requestKey, error: false } ); }) .catch((error: unknown) => { @@ -72,17 +188,41 @@ export function useSessionSources( // A failed re-read keeps this session's last good list. setLoaded((previous) => previous?.sessionId === sessionId - ? previous - : { sessionId, sources: EMPTY_SOURCES } + ? { ...previous, requestKey, error: true } + : { sessionId, sources: EMPTY_SOURCES, requestKey, error: true } ); }); return () => { cancelled = true; + read.release(); }; // reloadKey is a pure refetch signal for the same session. - }, [sessionId, reloadKey]); + }, [ + sessionId, + reloadKey, + retryKey, + requestKey, + visibility.visible, + visibility.version, + ]); + + const current = loaded?.sessionId === sessionId ? loaded : null; + return { + sources: current?.sources ?? EMPTY_SOURCES, + loading: + visibility.visible && !!sessionId && current?.requestKey !== requestKey, + error: + !!current && + (!visibility.visible || current.requestKey === requestKey) && + current.error, + retry, + }; +} - return loaded && loaded.sessionId === sessionId - ? loaded.sources - : EMPTY_SOURCES; +/** Rail callers that only need rows retain their existing API. */ +export function useSessionSources( + sessionId: string | null | undefined, + reloadKey?: string +): SessionSource[] { + return useSessionSourcesState(sessionId, reloadKey).sources; } diff --git a/src/features/SessionSources/SessionSourceCategory.tsx b/src/features/SessionSources/SessionSourceCategory.tsx new file mode 100644 index 0000000000..ff065df0a0 --- /dev/null +++ b/src/features/SessionSources/SessionSourceCategory.tsx @@ -0,0 +1,77 @@ +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import Button from "@src/components/Button"; +import { SidebarSectionHeader } from "@src/components/SidebarSectionHeader"; +import type { SessionSource } from "@src/engines/ChatPanel/sessionSources/extractSessionSources"; +import { useCollapsible } from "@src/hooks/ui/useCollapsible"; + +import { SessionSourceRow } from "./SessionSourceRow"; +import { SessionToolActivityGroup } from "./SessionToolActivityGroup"; + +const PAGE_SIZE = 30; + +/** Category-local intent survives refresh and collapse; the session-keyed parent + * owns remount/reset. Collapsed rows unmount so thumbnails do no hidden work. */ +export function SessionSourceCategory({ + kind, + title, + items, + onOpenSource, +}: { + kind: SessionSource["kind"]; + title: string; + items: SessionSource[]; + onOpenSource: (source: SessionSource) => void; +}) { + const { t } = useTranslation(); + const { isOpen, toggle } = useCollapsible(); + const [visibleCount, setVisibleCount] = useState(PAGE_SIZE); + return ( +
+ {items.length} + } + /> + {isOpen ? ( + <> +
    + {items.slice(0, visibleCount).map((source) => ( +
  • + {source.kind === "tool-group" ? ( + + ) : ( + + )} +
  • + ))} +
+ {items.length > visibleCount ? ( +
+ +
+ ) : null} + + ) : null} +
+ ); +} diff --git a/src/features/SessionSources/SessionSourceIcon.tsx b/src/features/SessionSources/SessionSourceIcon.tsx new file mode 100644 index 0000000000..91d778a214 --- /dev/null +++ b/src/features/SessionSources/SessionSourceIcon.tsx @@ -0,0 +1,29 @@ +import AnyIcon from "@src/components/AnyIcon"; +import FileTypeIcon from "@src/components/FileTypeIcon"; +import ModelIcon from "@src/components/ModelIcon"; +import type { SessionSource } from "@src/engines/ChatPanel/sessionSources/extractSessionSources"; + +import { SessionSourceThumbnail } from "./SessionSourceThumbnail"; +import { sourceIcon } from "./presentation"; + +/** Resource identity is shared by compact rows and tool-group headings. */ +export function SessionSourceIcon({ + source, + size = 14, +}: { + source: SessionSource; + size?: number; +}) { + if (source.kind === "image") + return ; + if (source.kind === "file" && !source.isDirectory) + return ( + + ); + if (source.kind === "tool-group" && source.group === "codex-app") + return ; + return ; +} diff --git a/src/scaffold/AppLayout/FocusedChatWorkstationRail/WorkstationSourceImagePreview.tsx b/src/features/SessionSources/SessionSourceImagePreview.tsx similarity index 69% rename from src/scaffold/AppLayout/FocusedChatWorkstationRail/WorkstationSourceImagePreview.tsx rename to src/features/SessionSources/SessionSourceImagePreview.tsx index 2fdbcc6d91..51edf9f2a9 100644 --- a/src/scaffold/AppLayout/FocusedChatWorkstationRail/WorkstationSourceImagePreview.tsx +++ b/src/features/SessionSources/SessionSourceImagePreview.tsx @@ -1,5 +1,5 @@ /** - * WorkstationSourceImagePreview — the chat's image viewer, opened from a + * SessionSourceImagePreview — the chat's image viewer, opened from a * Sources row with every image of the session as its gallery. * * The opened image is read here rather than borrowed from the row's @@ -7,27 +7,30 @@ * while the viewer is still showing it. */ import { useEffect, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import Message from "@src/components/Message"; import { resolveImageSrc, useResolvedImageSrc, } from "@src/engines/ChatPanel/ChatImageThumbnail"; import ImagePreviewOverlay from "@src/scaffold/ImagePreviewOverlay"; -export interface WorkstationSourceImage { +export interface SessionSourceImage { ref: string; fileName: string | null; } -export function WorkstationSourceImagePreview({ +export function SessionSourceImagePreview({ images, index, onClose, }: { - images: readonly WorkstationSourceImage[]; + images: readonly SessionSourceImage[]; index: number; onClose: () => void; }) { + const { t } = useTranslation(); const { src, failed } = useResolvedImageSrc(images[index].ref); const gallery = useMemo( () => @@ -39,8 +42,11 @@ export function WorkstationSourceImagePreview({ ); useEffect(() => { - if (failed) onClose(); - }, [failed, onClose]); + if (failed) { + Message.error(t("common:git.rail.sourceImageUnavailable")); + onClose(); + } + }, [failed, onClose, t]); if (!src) return null; return ( diff --git a/src/features/SessionSources/SessionSourceRow.tsx b/src/features/SessionSources/SessionSourceRow.tsx new file mode 100644 index 0000000000..25feaef5eb --- /dev/null +++ b/src/features/SessionSources/SessionSourceRow.tsx @@ -0,0 +1,34 @@ +import { useTranslation } from "react-i18next"; + +import { SidebarRow } from "@src/components/SidebarRow"; +import Tooltip from "@src/components/Tooltip"; +import type { SessionSource } from "@src/engines/ChatPanel/sessionSources/extractSessionSources"; + +import { SessionSourceIcon } from "./SessionSourceIcon"; +import { sourceLabel, sourceLocation, sourceProvenance } from "./presentation"; + +/** Domain adapter only: SidebarRow owns the compact three-line interaction. */ +export function SessionSourceRow({ + source, + onOpenSource, +}: { + source: Exclude; + onOpenSource: (source: SessionSource) => void; +}) { + const { t } = useTranslation(); + const label = sourceLabel(t, source); + const location = sourceLocation(source); + return ( + + } + aria-label={label} + onClick={() => onOpenSource(source)} + > + {sourceProvenance(t, source)} + + + ); +} diff --git a/src/scaffold/AppLayout/FocusedChatWorkstationRail/RailImageThumbnail.tsx b/src/features/SessionSources/SessionSourceThumbnail.tsx similarity index 79% rename from src/scaffold/AppLayout/FocusedChatWorkstationRail/RailImageThumbnail.tsx rename to src/features/SessionSources/SessionSourceThumbnail.tsx index fefbf1cec4..d0b5288a17 100644 --- a/src/scaffold/AppLayout/FocusedChatWorkstationRail/RailImageThumbnail.tsx +++ b/src/features/SessionSources/SessionSourceThumbnail.tsx @@ -1,15 +1,15 @@ /** - * RailImageThumbnail — an attached image drawn in a rail row's icon slot. + * SessionSourceThumbnail — the lazy image shared by source summaries and rows. * * The bytes are read only while the row is mounted and released with it, so - * a folded Sources section or a closed submenu holds no image data. + * a folded Sources section or an inactive full view holds no image data. */ import { memo } from "react"; import { useResolvedImageSrc } from "@src/engines/ChatPanel/ChatImageThumbnail"; import { HugeiconsIcon, Image01Icon, ImageNotFound01Icon } from "@src/icons"; -export const RailImageThumbnail = memo(function RailImageThumbnail({ +export const SessionSourceThumbnail = memo(function SessionSourceThumbnail({ imageRef, size, }: { diff --git a/src/features/SessionSources/SessionSourcesContent.tsx b/src/features/SessionSources/SessionSourcesContent.tsx new file mode 100644 index 0000000000..aefde94bdd --- /dev/null +++ b/src/features/SessionSources/SessionSourcesContent.tsx @@ -0,0 +1,32 @@ +import { useAtomValue } from "jotai"; +import React from "react"; + +import { SessionSourcesFileScope } from "@src/engines/ChatPanel/sessionSources/SessionSourcesFileScope"; +import { useSessionSourcesState } from "@src/engines/ChatPanel/sessionSources/useSessionSources"; +import { sessionByIdAtom } from "@src/store/session"; + +import { SessionSourcesView } from "./SessionSourcesView"; + +export function SessionSourcesContent({ sessionId }: { sessionId: string }) { + const session = useAtomValue(sessionByIdAtom(sessionId)); + const { sources, loading, error, retry } = useSessionSourcesState( + sessionId, + session?.updated_at + ); + const basePath = + session?.repoRootPath ?? + (session?.importedFrom + ? undefined + : (session?.worktreePath ?? session?.repoPath)); + return ( + + + + ); +} diff --git a/src/features/SessionSources/SessionSourcesView.images.test.ts b/src/features/SessionSources/SessionSourcesView.images.test.ts new file mode 100644 index 0000000000..052069eebc --- /dev/null +++ b/src/features/SessionSources/SessionSourcesView.images.test.ts @@ -0,0 +1,112 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { afterEach, beforeEach, expect, it, vi } from "vitest"; + +import { extractSessionSources } from "@src/engines/ChatPanel/sessionSources/extractSessionSources"; + +import { SessionSourcesView } from "./SessionSourcesView"; + +const imageRuntime = vi.hoisted(() => ({ + mountedRef: vi.fn(), + resolve: vi.fn(async () => "data:image/png;base64,RESOLVED"), + overlay: vi.fn(), +})); +vi.mock("@src/engines/ChatPanel/ChatImageThumbnail", () => ({ + useResolvedImageSrc: (ref: string) => { + imageRuntime.mountedRef(ref); + return { src: "data:image/png;base64,THUMBNAIL", failed: false }; + }, + resolveImageSrc: imageRuntime.resolve, +})); +vi.mock("@src/scaffold/ImagePreviewOverlay", () => ({ + default: (props: unknown) => { + imageRuntime.overlay(props); + return React.createElement("div", { "data-testid": "image-gallery" }); + }, +})); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +let root: Root; +let host: HTMLDivElement; +beforeEach(() => { + vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true); + vi.clearAllMocks(); + host = document.createElement("div"); + document.body.appendChild(host); + root = createRoot(host); +}); +afterEach(() => { + act(() => root.unmount()); + host.remove(); + vi.unstubAllGlobals(); +}); + +it("shows readable image locations while preserving transcript identity through thumbnails and gallery navigation", async () => { + const path = "/tmp/Readable Screenshot.png"; + const firstRef = `orgii-transcript-image:${JSON.stringify([ + "private-session-marker", + "private-turn-marker", + path, + ])}`; + const inlineRef = `orgii-transcript-image:${JSON.stringify([ + "private-session-marker", + "other-turn-marker", + "codex-inline-image:0", + ])}`; + const sources = extractSessionSources([ + { id: "user-message", text: "", images: [firstRef, inlineRef] }, + ]); + await act(async () => { + root.render( + React.createElement(SessionSourcesView, { + sources, + onRetry: vi.fn(), + }) + ); + }); + + expect(host.textContent).toContain("Readable Screenshot.png"); + const visibleAndTooltipText = [ + host.textContent, + ...[...host.querySelectorAll("[title], [aria-label]")].map( + (element) => + `${element.getAttribute("title")} ${element.getAttribute("aria-label")}` + ), + ].join(" "); + for (const internal of [ + "orgii-transcript-image:", + "private-session-marker", + "private-turn-marker", + "codex-inline-image:", + ]) { + expect(visibleAndTooltipText).not.toContain(internal); + } + expect(imageRuntime.mountedRef).toHaveBeenCalledWith(firstRef); + expect(imageRuntime.mountedRef).toHaveBeenCalledWith(inlineRef); + + await act(async () => { + host + .querySelector( + 'button[aria-label="Readable Screenshot.png"]' + )! + .click(); + }); + + expect(host.querySelector('[data-testid="image-gallery"]')).not.toBeNull(); + const overlay = imageRuntime.overlay.mock.lastCall![0] as { + images: Array<{ src: string }>; + initialIndex: number; + resolveImage: (ref: string) => Promise; + }; + expect(overlay.images.map((image) => image.src)).toEqual([ + firstRef, + inlineRef, + ]); + expect(overlay.initialIndex).toBe(0); + await overlay.resolveImage(overlay.images[1].src); + expect(imageRuntime.resolve).toHaveBeenCalledWith(inlineRef); + expect(sources[0]).toMatchObject({ kind: "image", ref: firstRef }); +}); diff --git a/src/features/SessionSources/SessionSourcesView.test.ts b/src/features/SessionSources/SessionSourcesView.test.ts new file mode 100644 index 0000000000..8b3269b2e0 --- /dev/null +++ b/src/features/SessionSources/SessionSourcesView.test.ts @@ -0,0 +1,370 @@ +// @vitest-environment jsdom +import React, { act } from "react"; +import { type Root, createRoot } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +import type { SessionSource } from "@src/engines/ChatPanel/sessionSources/extractSessionSources"; + +import { SessionSourcesView } from "./SessionSourcesView"; + +const navigation = vi.hoisted(() => ({ + openSource: vi.fn(), + closeImagePreview: vi.fn(), + imagePreview: null, +})); +vi.mock("./useSessionSourceNavigation", () => ({ + useSessionSourceNavigation: () => navigation, +})); +vi.mock("./SessionSourceThumbnail", () => ({ + SessionSourceThumbnail: () => + React.createElement("span", { "data-thumbnail": true }), +})); +vi.mock("./SessionSourceImagePreview", () => ({ + SessionSourceImagePreview: () => null, +})); +vi.mock("@src/components/Select", () => ({ + default: ({ + value, + onChange, + options, + ariaLabel, + }: { + value: string; + onChange: (value: string) => void; + options: { value: string; label: string }[]; + ariaLabel: string; + }) => + React.createElement( + "select", + { + value, + "aria-label": ariaLabel, + onChange: (event: React.ChangeEvent) => + onChange(event.target.value), + }, + options.map((option) => + React.createElement( + "option", + { key: option.value, value: option.value }, + option.label + ) + ) + ), +})); +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ t: (key: string) => key }), +})); + +let container: HTMLDivElement; +let root: Root; +const retry = vi.fn(); +const sources: SessionSource[] = Array.from({ length: 65 }, (_, index) => ({ + kind: "image", + key: `image:${index}`, + ref: `/tmp/shot-${index}.png`, + fileName: `shot-${index}.png`, +})); +async function render( + props: Partial> = {}, + key = "session-a" +) { + await act(async () => { + root.render( + React.createElement(SessionSourcesView, { + key, + sources, + onRetry: retry, + ...props, + }) + ); + }); +} +function click(text: string) { + act(() => + [...container.querySelectorAll("button")] + .find((button) => button.textContent?.includes(text))! + .click() + ); +} +beforeEach(() => { + Reflect.set(globalThis, "IS_REACT_ACT_ENVIRONMENT", true); + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + vi.clearAllMocks(); +}); +afterEach(() => { + act(() => root.unmount()); + container.remove(); + Reflect.deleteProperty(globalThis, "IS_REACT_ACT_ENVIRONMENT"); +}); + +describe("SessionSourcesView", () => { + it("sorts provided sources first within each category and resets for another session", async () => { + const mixed: SessionSource[] = [ + { + kind: "file", + key: "assistant-file", + path: "/tmp/new.md", + fileName: "new.md", + isDirectory: false, + origin: "assistant-reference", + }, + { + kind: "file", + key: "provided-file", + path: "/tmp/old.md", + fileName: "old.md", + isDirectory: false, + origin: "assistant-reference", + origins: ["assistant-reference", "provided-file"], + }, + { + kind: "link", + key: "assistant-link", + url: "https://example.com/new", + label: "New link", + origin: "assistant-reference", + }, + { + kind: "link", + key: "provided-link", + url: "https://example.com/old", + label: "Old link", + origin: "provided-link", + }, + ]; + await render({ sources: mixed }); + const fileRows = () => + [...container.querySelectorAll('[data-source-category="file"] li')].map( + (row) => row.textContent + ); + expect(fileRows()[0]).toContain("new.md"); + const select = container.querySelector( + 'select[aria-label="common:git.rail.sourceSort"]' + )! as HTMLSelectElement; + await act(async () => { + select.value = "provided-first"; + select.dispatchEvent(new Event("change", { bubbles: true })); + }); + expect(fileRows()[0]).toContain("old.md"); + expect( + container.querySelector('[data-source-category="link"] li')?.textContent + ).toContain("Old link"); + expect(mixed[0].key).toBe("assistant-file"); + await render({ sources: mixed }, "session-b"); + expect(fileRows()[0]).toContain("new.md"); + }); + it("bounds image reads, opens source rows and reveals further pages on request", async () => { + await render(); + expect(container.querySelectorAll("[data-thumbnail]")).toHaveLength(30); + click("shot-2.png"); + expect(navigation.openSource).toHaveBeenCalledWith(sources[2]); + click("loadMoreSources"); + expect(container.querySelectorAll("[data-thumbnail]")).toHaveLength(60); + await render({}, "session-b"); + expect(container.querySelectorAll("[data-thumbnail]")).toHaveLength(30); + }); + it("shows sources directly without search or refresh chrome", async () => { + await render(); + expect(container.querySelector("input")).toBeNull(); + expect( + container.querySelector('[aria-label="common:actions.refresh"]') + ).toBeNull(); + expect(container.querySelectorAll("li")).toHaveLength(30); + }); + it("renders and opens non-image resources with distinct assistant and tool provenance", async () => { + const mixed: SessionSource[] = [ + { + kind: "link", + key: "pr", + url: "https://github.com/org/repo/pull/42", + label: "fix(chat): source navigation", + origin: "assistant-reference", + origins: ["assistant-reference", "provided-link"], + }, + { + kind: "file", + key: "report", + path: "/tmp/report.md", + fileName: "report.md", + title: "Implementation report", + isDirectory: false, + origin: "assistant-reference", + }, + { + kind: "file", + key: "tool", + path: "/tmp/data.csv", + fileName: "data.csv", + isDirectory: false, + origin: "tool-result", + toolName: "read_file", + }, + ]; + await render({ sources: mixed }); + expect(container.querySelectorAll("li")).toHaveLength(3); + expect(container.querySelectorAll("[data-thumbnail]")).toHaveLength(0); + expect(container.textContent).toContain("sourceAssistantReference"); + expect(container.textContent).toContain("sourceLinkProvided"); + expect(container.textContent).toContain("sourceToolResultNamed"); + click("Implementation report"); + expect(navigation.openSource).toHaveBeenCalledWith(mixed[1]); + click("fix(chat): source navigation"); + expect(navigation.openSource).toHaveBeenCalledWith(mixed[0]); + }); + it("groups by type, preserves recency within each group and pages images independently", async () => { + const link: SessionSource = { + kind: "link", + key: "link", + url: "https://example.com", + label: "Reference", + }; + const tool: SessionSource = { + kind: "tool-group", + key: "tool", + group: "exec", + operations: [], + }; + const file: SessionSource = { + kind: "file", + key: "file", + path: "/tmp/report.md", + fileName: "report.md", + isDirectory: false, + }; + await render({ + sources: [tool, sources[0], link, file, ...sources.slice(1)], + }); + expect( + [...container.querySelectorAll("[data-source-category]")].map((element) => + element.getAttribute("data-source-category") + ) + ).toEqual(["image", "file", "link", "tool-group"]); + const images = container.querySelector('[data-source-category="image"]')!; + expect(images.querySelectorAll("li")).toHaveLength(30); + expect(images.querySelector("li")?.textContent).toContain("shot-0.png"); + expect( + container.querySelector('[data-source-category="link"]')?.textContent + ).toContain("Reference"); + expect( + container.querySelector('[data-source-category="file"]')?.textContent + ).toContain("report.md"); + click("loadMoreSources"); + expect(images.querySelectorAll("li")).toHaveLength(60); + await render({ sources: [link] }, "session-b"); + expect(container.querySelectorAll("[data-source-category]")).toHaveLength( + 1 + ); + expect( + container.querySelector('[data-source-category="image"]') + ).toBeNull(); + }); + it.each(["image", "file", "link", "tool-group"] as const)( + "collapses %s independently while retaining its header and count", + async (kind) => { + const mixed: SessionSource[] = [ + sources[0], + { + kind: "file", + key: "file", + path: "/tmp/report.md", + fileName: "report.md", + isDirectory: false, + }, + { + kind: "link", + key: "link", + url: "https://example.com", + label: "Reference", + }, + { kind: "tool-group", key: "tool", group: "exec", operations: [] }, + ]; + await render({ sources: mixed }); + const category = container.querySelector( + `[data-source-category="${kind}"]` + )!; + const header = category.querySelector( + "button[aria-expanded]" + )!; + expect(header.getAttribute("aria-expanded")).toBe("true"); + expect(header.textContent).toContain("1"); + act(() => { + header.focus(); + header.click(); + }); + expect(header.getAttribute("aria-expanded")).toBe("false"); + expect(document.activeElement).toBe(header); + expect(category.querySelector("ul")).toBeNull(); + for (const other of container.querySelectorAll( + "[data-source-category]" + )) { + if (other !== category) + expect(other.querySelector("ul")).not.toBeNull(); + } + act(() => header.click()); + expect(header.getAttribute("aria-expanded")).toBe("true"); + expect(category.querySelector("ul")).not.toBeNull(); + expect(navigation.openSource).not.toHaveBeenCalled(); + } + ); + it("retains collapse and pagination across refresh and retry, resetting for a new session", async () => { + await render(); + click("loadMoreSources"); + click("sourceCategoryImages"); + expect(container.querySelectorAll("[data-thumbnail]")).toHaveLength(0); + expect(container.textContent).not.toContain("loadMoreSources"); + const updated = [ + ...sources, + { ...sources[0], key: "new-image", fileName: "new.png" }, + ]; + await render({ sources: updated, error: true }); + expect(container.querySelectorAll("[data-thumbnail]")).toHaveLength(0); + expect( + container.querySelector("button[aria-expanded]")?.textContent + ).toContain("66"); + click("actions.retry"); + expect(retry).toHaveBeenCalledOnce(); + await render({ sources: updated, loading: true }); + expect(container.querySelectorAll("[data-thumbnail]")).toHaveLength(0); + click("sourceCategoryImages"); + expect(container.querySelectorAll("[data-thumbnail]")).toHaveLength(60); + click("shot-2.png"); + expect(navigation.openSource).toHaveBeenCalledWith(sources[2]); + click("sourceCategoryImages"); + await render({}, "session-b"); + expect(container.querySelectorAll("[data-thumbnail]")).toHaveLength(30); + expect( + container + .querySelector("button[aria-expanded]") + ?.getAttribute("aria-expanded") + ).toBe("true"); + }); + it("preserves successful rows during failed refresh and offers retry", async () => { + await render({ error: true }); + expect(container.querySelector('[role="alert"]')?.textContent).toContain( + "sourcesLoadFailed" + ); + expect(container.querySelectorAll("li")).toHaveLength(30); + click("actions.retry"); + expect(retry).toHaveBeenCalledOnce(); + await render({ loading: true }); + expect(container.querySelector('[aria-busy="true"]')).not.toBeNull(); + expect(container.querySelectorAll("li")).toHaveLength(30); + expect(container.querySelector('[role="alert"]')).toBeNull(); + }); + it("distinguishes loading, empty and unavailable lists", async () => { + await render({ sources: [], loading: true }); + expect(container.querySelector('[role="status"]')?.textContent).toContain( + "status.loading" + ); + await render({ sources: [] }); + expect(container.querySelector('[role="status"]')?.textContent).toContain( + "noSources" + ); + await render({ sources: [], error: true }); + expect(container.querySelector('[role="status"]')?.textContent).toContain( + "sourcesUnavailable" + ); + }); +}); diff --git a/src/features/SessionSources/SessionSourcesView.tsx b/src/features/SessionSources/SessionSourcesView.tsx new file mode 100644 index 0000000000..06e97a4ade --- /dev/null +++ b/src/features/SessionSources/SessionSourcesView.tsx @@ -0,0 +1,132 @@ +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import PageNotice from "@src/components/PageNotice"; +import Select from "@src/components/Select"; +import { ListPanelScrollArea } from "@src/components/layout/blocks"; +import { EDITOR_TAB_CANVAS_BG_CLASS } from "@src/config/workstation/tokens"; +import type { SessionSource } from "@src/engines/ChatPanel/sessionSources/extractSessionSources"; + +import { SessionSourceCategory } from "./SessionSourceCategory"; +import { SessionSourceImagePreview } from "./SessionSourceImagePreview"; +import { + SOURCE_SORTS, + type SourceSort, + sortSessionSources, +} from "./sortSessionSources"; +import { useSessionSourceNavigation } from "./useSessionSourceNavigation"; + +const CATEGORIES = [ + { kind: "image", label: "sourceCategoryImages" }, + { kind: "file", label: "sourceCategoryFiles" }, + { kind: "link", label: "sourceCategoryLinks" }, + { kind: "tool-group", label: "sourceCategoryTools" }, +] as const; + +/** Parent keys this view by session ID: pagination and gallery never cross sessions. */ +export function SessionSourcesView({ + sources, + basePath, + loading = false, + error = false, + onRetry, +}: { + sources: SessionSource[]; + basePath?: string; + loading?: boolean; + error?: boolean; + onRetry: () => void; +}) { + const { t } = useTranslation(); + const [sort, setSort] = useState("newest"); + const orderedSources = useMemo( + () => sortSessionSources(sources, sort), + [sources, sort] + ); + const categories = useMemo( + () => + CATEGORIES.map((category) => ({ + ...category, + items: orderedSources.filter((source) => source.kind === category.kind), + })).filter((category) => category.items.length > 0), + [orderedSources] + ); + const { openSource, imagePreview, closeImagePreview } = + useSessionSourceNavigation(orderedSources, basePath); + + return ( +
+ {sources.length > 0 ? ( +
+