diff --git a/.orgii/skills/dual-instance-verification/SKILL.md b/.orgii/skills/dual-instance-verification/SKILL.md index 04eb1e491..c6cec9d5e 100644 --- a/.orgii/skills/dual-instance-verification/SKILL.md +++ b/.orgii/skills/dual-instance-verification/SKILL.md @@ -122,7 +122,24 @@ actions in the background, invisible to every existing cell. a local source DB mid-run (hollow read), block the identity endpoint (lookup failure), kill the app mid-transfer (partial persist). The guard under test must defer/refuse — any destructive act under injected fault is - a failure. + a failure. When the change ADDS a fault point (a new read, probe, or IPC + call), inject THAT fault — the rotation list only covers yesterday's + failure modes. +- **Upgrade cell: persisted state must cross the version boundary.** Any + change that reads durable state written by earlier builds (push cursors, + cache metadata, parser output, settings) gets one cell where the OLD build + writes the state and the NEW build operates on it: run a pre-change binary + (a dated `org2-main.exe` or a develop build) through the flow first, then + swap binaries over the SAME homes and continue. Assert the new build rides + the ordinary incremental path — no epoch rewrite, no refuse, no silent + re-derive — and that a second cycle (new build writes, new build reads) + is idempotent. A fresh-anchor test with only the new binary proves nothing + about migration: PR #692's costliest bug (every legacy flat cursor forced + an O(total) epoch rewrite) was invisible to every run that built its state + with the new code. Second-order cycles count too: state the new build + STAMPS must survive the new build's own next scan/rescan before the + invariant is real (PR #693's lineage stamp was erased by the very next + rescan's metadata rewrite). - **Unexplained delta becomes a cell.** The first ledger delta, log line, resource pattern, or store-vs-UI discrepancy without a mechanism-level explanation is promoted to a scenario in the CURRENT run — not noted for @@ -228,6 +245,12 @@ rollover or the window silently truncates. boot, and positional chunk ids turned the shuffle into a fresh hash chain each time. Within one app lifetime everything looked stable; only boot-vs-boot comparison of push decisions could see it. (#608 root cause.) +- **Fresh-state runs cannot see migration bugs**: every cell that builds its + own state with the binary under test samples only the post-change state + space. Bugs that live in the TRANSITION — legacy cursor meets new hash + mode, old parser rows meet new election, stamped metadata meets the next + rescan — need the upgrade cell above. The tell is a verification report + whose every artifact was created during the run itself. - **"Pre-existing" used as a verdict**: a symptom reproduces on baseline, is correctly cleared of THIS PR's authorship, and is then silently cleared of being a bug at all — because the run's attention is scoped to the PR, and diff --git a/.orgii/skills/org2-performance-guard/SKILL.md b/.orgii/skills/org2-performance-guard/SKILL.md index 3cd881768..345c7736f 100644 --- a/.orgii/skills/org2-performance-guard/SKILL.md +++ b/.orgii/skills/org2-performance-guard/SKILL.md @@ -1,6 +1,6 @@ --- name: org2-performance-guard -description: Prevent CPU, RAM, I/O, and background-work regressions in ORG2. Use when adding or reviewing polling, timers, Realtime subscriptions, event listeners, workers, streaming paths, caches, pagination, external-history scans, cloud sync, source-control loading, per-session state, or multi-instance behavior; also use before delivering a performance refactor or any feature that stays alive while the UI is idle or hidden. +description: Prevent CPU, RAM, I/O, background-work, and false-green lifecycle regressions in ORG2. Use when adding or reviewing polling, timers, Realtime subscriptions, event listeners, workers, streaming paths, caches, pagination, provider-owned transcript ingestion or identity/dedupe, external-history scans, cloud sync, source-control loading, per-session state, true-machine verification, or multi-provider/multi-instance behavior; also use before delivering a performance refactor or any feature that stays alive while the UI is idle or hidden. --- # ORG2 Performance Guard @@ -25,6 +25,8 @@ Require all applicable invariants before delivery: - Keep blocking filesystem, database, git, and process work off async executor threads and render-critical paths. - Load large histories, request rounds, diffs, and replay segments on demand; do not eagerly materialize invisible data. - Isolate secondary Tauri identities completely: data home, external-history home, ports, cookies/auth, and app-lifetime caches. +- Treat provider ingestion, local identity/listability, UI hydration, cloud transport, and remote rendering as separate verification boundaries. Passing A-to-B sync does not prove the upstream local lifecycle. +- Test every provider and raw source transition claimed by the change. Do not infer Claude Code compaction coverage from Codex append coverage, or vice versa. - Keep rendered E2E strict. Missing UI must fail with diagnostics; never turn a regression into `console.warn`, catch-and-continue, or a debug-helper bypass. ## Required workflow @@ -63,10 +65,36 @@ For each resource, record the required behavior in these states: | Scope | personal org, cloud org, removed org, revoked share | | Session | unopened, active, inactive, deleted, forked | | Instance | primary, direct-launched secondary, launcher-created secondary | +| Source | discover, append, large append, compact/rewrite, rotate, delete | +| UI | clean load, old row active/open/pinned during refresh, restart | +| Transport | local ingest, upload, remote download, reconnect | Flag any resource whose owner or terminal state is ambiguous. -### 3. Choose the correct pattern +### 3. Separate provider lifecycle from machine topology + +For provider history, session identity, dedupe, or sync work, build a coverage matrix before testing: + +| Axis | Minimum relevant states | +| -------------- | -------------------------------------------------------------------------------- | +| Provider | every changed provider plus every provider explicitly claimed as working | +| Raw transition | create, append, large append, compact/rewrite, rotate, fork/subagent, delete | +| App timing | cold start, source changes while ORG2 is open, rescan, restart | +| UI state | clean roster, previous row active/open/pinned, search/filter/load-more as needed | +| Topology | local ingest, isolated secondary, A upload, B download/reconnect | + +Apply these validity rules: + +- Treat each matrix cell as independent evidence. Two machines exercise topology; they do not create provider compaction, rotation, or lineage transitions automatically. +- Exercise the raw provider artifact or a faithful before/after fixture. Do not seed only normalized cache/database rows when the parser, watermark, identity, lineage, or dedupe contract is under test. +- Derive identity markers from the raw artifact. Do not fabricate identical group keys that merely restate the implementation assumption. +- Include an assumption-breaking fixture for identity logic: for example, a rewritten transcript head with a changed first-message UUID but a preserved ancestry marker. +- Observe local ingest and listability before enabling or asserting cloud upload. Then verify upload cursor/payload and remote rendering separately. +- Keep the previous session active, open, or pinned while applying the source transition when exact-id hydration or force-reveal paths exist. +- Repeat rescan/restart once to prove idempotence and stable row/resource counts. +- Name every unexecuted provider or transition. Never summarize partial coverage as “multi-provider,” “dual-machine,” or “full lifecycle.” + +### 4. Choose the correct pattern Apply the smallest applicable pattern: @@ -80,7 +108,7 @@ Apply the smallest applicable pattern: - **Demand-driven loading:** paginate or fetch details only after expansion/selection; retain only the visible or recently used window. - **Generation guard:** discard late async results after stop, restart, account switch, endpoint switch, or a newer request. -### 4. Sweep equivalent paths +### 5. Sweep equivalent paths After finding one issue, search for every semantic peer. A fix is incomplete if another surface still owns a parallel implementation. @@ -96,7 +124,7 @@ Typical ORG2 sweeps: Unify duplicate resource ownership before tuning individual call sites. -### 5. Protect correctness and privacy +### 6. Protect correctness and privacy Performance changes must not weaken: @@ -110,7 +138,7 @@ Performance changes must not weaken: Capture identity and generation at request start. Before committing a result, confirm the current identity/generation still matches. Do not display a previous identity's cached rows while refreshing. -### 6. Verify proportionally +### 7. Verify proportionally Always run: @@ -121,11 +149,16 @@ Always run: For rendered/background changes, also run the real Tauri surface when available: -1. Observe primary and secondary instances separately. -2. Measure visible idle, hidden idle, active streaming, and post-close/post-delete behavior. -3. Exercise account switch, endpoint switch, and direct secondary launch when relevant. -4. Confirm request/subscription/timer counts stabilize rather than grow after repeated open/close cycles. -5. Confirm strict rendered E2E uses user-visible actions for the behavior under assertion. +1. Isolate primary and secondary data homes, provider roots, auth, ports, and processes. +2. Capture a baseline: raw files, cache rows/listability, active/open/pinned row, cursor/epoch, payload count, process count, CPU, and RSS as applicable. +3. Apply the raw source transition while ORG2 is already open. For compaction/rewrite/rotation, stage or produce the actual before/after artifact instead of pre-populating the final database state. +4. Assert local parsing, identity/lineage, exact row count, listability, timestamp, and sidebar behavior before cloud transport can hide the owning-boundary failure. +5. Keep an old row active/open/pinned, rescan, and assert that hydration does not resurrect a superseded sibling or hide the active row entirely. +6. Verify A upload and B download/reconnect separately, including cursor/epoch and exact appended payload counts when incremental behavior is claimed. +7. Rescan and restart once; confirm data and request/subscription/timer/process counts remain stable. +8. Measure visible idle, hidden idle, active work, and post-close/post-delete behavior. +9. Exercise account switch, endpoint switch, and direct secondary launch when relevant. +10. Confirm strict rendered E2E uses user-visible actions for the behavior under assertion. Do not claim a performance improvement from code shape alone. State the evidence actually collected and any environment blocker. @@ -142,6 +175,11 @@ Reject or revise a change when any applicable answer is unknown or false: - Does one session's update wake unrelated session views? - Does a growing transcript/history/diff require full eager materialization? - Does a direct secondary launch inherit primary external history or auth state? +- Which provider and raw source transition produced the evidence for each compatibility claim? +- Did the test inspect local ingest and identity before testing cloud transport? +- Did it keep the previous row active/open/pinned across rescan or only test a clean roster? +- Were family/identity keys parsed from raw artifacts, or fabricated to match the implementation? +- Did “dual-machine” testing merely replicate an already-normalized final state? - Can a missing rendered element be skipped while the E2E still passes? ## Required delivery output @@ -155,10 +193,18 @@ Report findings and evidence in this compact form: | Scope/isolation | fix / keep | cache/request key | identity/generation guard | switch/revocation test | | Rendering/hot path | fix / keep | subscription/allocation trace | narrowing/coalescing | render or unit evidence | +For provider ingestion, session identity, or sync work, also report: + +| Provider | Raw transition | App/UI state | Topology/boundary | Expected invariant | Observed evidence | +| -------------- | ----------------- | ---------------------------- | --------------------------- | ----------------------------------------- | ---------------------------- | +| exact provider | actual transition | cold/live/active-row/restart | local/A-to-cloud/cloud-to-B | exact rows, identity, cursor, payload, UI | measured result or `not run` | + +Use one row per materially distinct matrix cell. A shared implementation permits shared unit coverage only at the shared boundary; each provider adapter still needs representative raw input before claiming compatibility. + End with: - `Performance verdict: pass` only when every applicable invariant is evidenced. -- `Performance verdict: blocked` when required real measurement or compilation cannot run; name the blocker. +- `Performance verdict: blocked` when required real measurement, provider transition, or compilation cannot run; name the blocker and the uncovered matrix cells. - `Performance verdict: fail` when an unbounded, duplicate, hidden-active, stale-write, or cross-identity path remains. Never promise that a skill can make regressions impossible. Enforce the gates, expose unknowns, and refuse an unsupported green verdict. diff --git a/.orgii/skills/org2-performance-guard/agents/openai.yaml b/.orgii/skills/org2-performance-guard/agents/openai.yaml index c11fe8902..a60f49cd1 100644 --- a/.orgii/skills/org2-performance-guard/agents/openai.yaml +++ b/.orgii/skills/org2-performance-guard/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "ORG2 Performance Guard" - short_description: "Prevent CPU/RAM regressions in ORG2 changes" - default_prompt: "Use $org2-performance-guard to audit this ORG2 change for CPU, RAM, polling, cache, and lifecycle regressions." + short_description: "Guard ORG2 performance and lifecycle coverage" + default_prompt: "Use $org2-performance-guard to audit this ORG2 change across performance, provider lifecycle, and true-machine coverage." diff --git a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history.rs b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history.rs index e379362ed..a0dbefb57 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history.rs @@ -4,7 +4,7 @@ //! converts them into ORGII's canonical `ActivityChunk` shape for read-only //! replay. -use std::collections::{BTreeSet, HashMap, HashSet}; +use std::collections::{BTreeSet, HashMap, HashSet, VecDeque}; use std::fs; use std::io::{BufRead, BufReader, Read, Seek, SeekFrom}; use std::path::{Path, PathBuf}; @@ -38,7 +38,10 @@ const CLAUDE_CODE_PROVIDER_SLUG: &str = "claudecode"; // v10: harness-injected user lines (isMeta, task-notification origin) no // longer open rounds or feed the first-prompt title; user image blocks // surface as data-URL attachments on the user bubble. -const CLAUDE_CODE_METADATA_PARSER_VERSION: i64 = 10; +// v11: capture compact-boundary ancestry markers so continuation families +// survive Claude Code rewriting the first user message during compaction. +const CLAUDE_CODE_METADATA_PARSER_VERSION: i64 = 11; +const MAX_COMPACT_BOUNDARY_MARKERS: usize = imported_cache::MAX_CONTINUATION_MARKERS - 1; pub type ClaudeCodeHistorySessionRow = ImportedHistorySessionRow; pub type ClaudeCodeHistorySessionPage = ImportedHistorySessionPage; @@ -74,6 +77,9 @@ struct ClaudeCodeHistoryMeta { /// field, but message uuids are preserved — so this is a stable group key /// uniting a conversation's continuation siblings for dedupe. first_user_uuid: Option, + /// Compact-boundary uuids retained by continuation rewrites. Together + /// with `first_user_uuid` these form a bounded ancestry marker set. + continuation_markers: Vec, } #[derive(Debug, Deserialize)] @@ -82,6 +88,8 @@ struct ClaudeJsonlLine { #[serde(default)] r#type: String, #[serde(default)] + subtype: String, + #[serde(default)] summary: String, /// `ai-title` records: the auto-generated title shown in the Claude Code app. #[serde(default)] @@ -878,6 +886,9 @@ struct ClaudeSessionMetaState { // same way Codex does, instead of listing it as a top-level session. parent_source_session_id: Option, first_user_uuid: Option, + /// Keep the newest compact boundaries; the first-user marker consumes the + /// remaining slot in the 64-marker cache metadata budget. + compact_boundary_uuids: VecDeque, } impl ClaudeSessionMetaState { @@ -955,6 +966,22 @@ impl ClaudeSessionMetaState { { self.first_user_uuid = Some(parsed.uuid.trim().to_string()); } + if parsed.r#type == "system" + && parsed.subtype == "compact_boundary" + && !parsed.uuid.trim().is_empty() + { + let marker = parsed.uuid.trim(); + if !self + .compact_boundary_uuids + .iter() + .any(|existing| existing == marker) + { + if self.compact_boundary_uuids.len() >= MAX_COMPACT_BOUNDARY_MARKERS { + self.compact_boundary_uuids.pop_front(); + } + self.compact_boundary_uuids.push_back(marker.to_string()); + } + } let harness_injected = is_harness_injected_user_line(&parsed); if let Some(message) = parsed.message { if self.first_prompt.is_empty() && parsed.r#type == "user" && !harness_injected { @@ -1096,6 +1123,7 @@ impl ClaudeSessionMetaState { .parent_source_session_id .map(|uuid| format!("{CLAUDE_CODE_SESSION_PREFIX}{uuid}")), first_user_uuid: self.first_user_uuid, + continuation_markers: self.compact_boundary_uuids.into_iter().collect(), }) } } @@ -1209,8 +1237,9 @@ fn session_meta_to_cache_input(meta: ClaudeCodeHistoryMeta) -> ImportedHistoryCa branch: meta.branch, impact: meta.impact, listable: true, - source_metadata_json: imported_cache::continuation_group_metadata_json( + source_metadata_json: imported_cache::continuation_metadata_json( meta.first_user_uuid.as_deref(), + &meta.continuation_markers, ), parent_session_id: meta.parent_session_id, } diff --git a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history_tests.rs b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history_tests.rs index 0f1143de2..bcda8b41b 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/claude_code/history_tests.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/claude_code/history_tests.rs @@ -857,6 +857,7 @@ fn captures_first_user_uuid_as_continuation_group_key() { // contribute a key. let content = r#"{"type":"custom-title","customTitle":"My convo","sessionId":"d0641111-1111-1111-1111-111111111111"} {"type":"user","uuid":"b7b5ae5f-0000-0000-0000-000000000001","sessionId":"d0641111-1111-1111-1111-111111111111","cwd":"/tmp/project","gitBranch":"main","timestamp":"2026-07-17T10:00:00.000Z","message":{"role":"user","content":"first message"}} +{"type":"system","subtype":"compact_boundary","uuid":"eeb66522-0000-0000-0000-000000000001","sessionId":"d0641111-1111-1111-1111-111111111111","timestamp":"2026-07-17T10:00:30.000Z"} {"type":"user","uuid":"b7b5ae5f-0000-0000-0000-000000000002","sessionId":"d0641111-1111-1111-1111-111111111111","cwd":"/tmp/project","gitBranch":"main","timestamp":"2026-07-17T10:01:00.000Z","message":{"role":"user","content":"second message"}} "#; std::fs::write(&path, content).expect("write fixture"); @@ -879,6 +880,10 @@ fn captures_first_user_uuid_as_continuation_group_key() { meta.first_user_uuid.as_deref(), Some("b7b5ae5f-0000-0000-0000-000000000001") ); + assert_eq!( + meta.continuation_markers, + vec!["eeb66522-0000-0000-0000-000000000001"] + ); let cache_input = session_meta_to_cache_input(meta); let metadata_json = cache_input.source_metadata_json.expect("metadata json"); @@ -889,6 +894,19 @@ fn captures_first_user_uuid_as_continuation_group_key() { .and_then(|value| value.as_str()), Some("b7b5ae5f-0000-0000-0000-000000000001") ); + assert_eq!( + parsed + .get(imported_cache::CONTINUATION_MARKERS_FIELD) + .and_then(Value::as_array) + .expect("continuation markers") + .iter() + .filter_map(Value::as_str) + .collect::>(), + vec![ + "b7b5ae5f-0000-0000-0000-000000000001", + "eeb66522-0000-0000-0000-000000000001" + ] + ); std::fs::remove_file(&path).expect("remove fixture"); std::fs::remove_dir(&temp_dir).expect("remove temp dir"); diff --git a/src-tauri/crates/orgtrack-core/src/sources/imported_history/cache.rs b/src-tauri/crates/orgtrack-core/src/sources/imported_history/cache.rs index ae7548a78..177c48a79 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/imported_history/cache.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/imported_history/cache.rs @@ -127,7 +127,7 @@ pub fn upsert_imported_session_cache_from_conn( let updated_at = Utc::now().to_rfc3339(); { let mut stmt = tx - .prepare( + .prepare(&format!( "INSERT INTO imported_history_session_cache ( source, source_session_id, session_id, source_path, source_record_key, source_mtime_ms, source_size_bytes, source_fingerprint, parser_version, @@ -163,10 +163,22 @@ pub fn upsert_imported_session_cache_from_conn( lines_removed = excluded.lines_removed, touched_files_json = excluded.touched_files_json, listable = excluded.listable, - source_metadata_json = excluded.source_metadata_json, + source_metadata_json = CASE + WHEN json_valid(excluded.source_metadata_json) + AND json_valid(imported_history_session_cache.source_metadata_json) + AND json_extract(imported_history_session_cache.source_metadata_json, + '$.{CONTINUATION_LINEAGE_ID_FIELD}') IS NOT NULL + AND json_extract(excluded.source_metadata_json, + '$.{CONTINUATION_LINEAGE_ID_FIELD}') IS NULL + THEN json_set(excluded.source_metadata_json, + '$.{CONTINUATION_LINEAGE_ID_FIELD}', + json_extract(imported_history_session_cache.source_metadata_json, + '$.{CONTINUATION_LINEAGE_ID_FIELD}')) + ELSE excluded.source_metadata_json + END, parent_session_id = excluded.parent_session_id, updated_at = excluded.updated_at", - ) + )) .map_err(|err| format!("Failed to prepare imported history cache upsert: {err}"))?; for input in inputs { let touched_files_json = serde_json::to_string(&input.impact.touched_files) @@ -406,7 +418,8 @@ pub fn query_imported_sidebar_page_from_conn( "SELECT session_id, name, created_at_ms, updated_at_ms, cache.repo_path, model, files_changed, lines_added, lines_removed, touched_files_json, input_tokens, output_tokens, source_path, - identity.repo_root_path, identity.remote_urls_json, cache.branch + identity.repo_root_path, identity.remote_urls_json, cache.branch, + cache.source_metadata_json FROM imported_history_session_cache cache LEFT JOIN imported_history_repo_identity identity ON identity.working_path = cache.repo_path @@ -443,6 +456,7 @@ pub fn query_imported_sidebar_page_from_conn( // Stored as "" for sources that report no branch (the upsert // coalesces `None`), so normalize back to absent. let branch: String = row.get(15)?; + let source_metadata_json: String = row.get(16)?; Ok(ImportedHistorySidebarRow { session_id: row.get(0)?, name: row.get(1)?, @@ -460,6 +474,9 @@ pub fn query_imported_sidebar_page_from_conn( branch: non_empty_string(branch), storage_path: non_empty_string(source_path), model: non_empty_string(model), + continuation_lineage_id: continuation_lineage_id_from_metadata_json( + &source_metadata_json, + ), total_tokens: input_tokens + output_tokens, files_changed: row.get(6)?, lines_added: row.get(7)?, @@ -867,19 +884,23 @@ fn has_newer_continuation_sibling( if session.parent_session_id.is_some() { return Ok(false); } - let Some(group_key) = session + let Some(metadata) = session .source_metadata_json .as_deref() - .and_then(|json| serde_json::from_str::(json).ok()) - .as_ref() - .and_then(|metadata| metadata.get(CONTINUATION_GROUP_KEY_FIELD)) - .and_then(serde_json::Value::as_str) - .map(str::trim) - .filter(|key| !key.is_empty()) - .map(str::to_string) + .and_then(parse_continuation_metadata) else { return Ok(false); }; + // Normal post-sync lookups use the elected lineage id. The group-key + // fallback preserves the pre-election/legacy behavior for rows written by + // older parsers that have not yet been reindexed. + let (field, family_key) = if let Some(lineage_id) = metadata.lineage_id { + (CONTINUATION_LINEAGE_ID_FIELD, lineage_id) + } else if let Some(group_key) = metadata.group_key { + (CONTINUATION_GROUP_KEY_FIELD, group_key) + } else { + return Ok(false); + }; conn.query_row( &format!( "SELECT EXISTS( @@ -888,7 +909,7 @@ fn has_newer_continuation_sibling( AND source_session_id != ?2 AND COALESCE(parent_session_id, '') = '' AND CASE WHEN json_valid(source_metadata_json) - THEN json_extract(source_metadata_json, '$.{CONTINUATION_GROUP_KEY_FIELD}') + THEN json_extract(source_metadata_json, '$.{field}') END = ?3 AND (updated_at_ms > ?4 OR (updated_at_ms = ?4 AND source_session_id > ?2)) @@ -897,7 +918,7 @@ fn has_newer_continuation_sibling( rusqlite::params![ source, session.source_session_id, - group_key, + family_key, session.updated_at_ms ], |row| Ok(row.get::<_, i64>(0)? != 0), @@ -1044,9 +1065,7 @@ pub fn set_imported_session_pinned_from_conn( } /// The set of imported session ids the user has pinned. -pub fn pinned_imported_session_ids_from_conn( - conn: &Connection, -) -> Result, String> { +pub fn pinned_imported_session_ids_from_conn(conn: &Connection) -> Result, String> { let mut statement = conn .prepare("SELECT session_id FROM imported_history_session_pin") .map_err(|err| format!("Failed to read imported session pins: {err}"))?; @@ -1089,6 +1108,72 @@ fn non_empty_string(value: String) -> Option { /// file with no link field, so readers derive a family key from content that /// the rewrite preserves (Claude: the first user message's uuid). pub const CONTINUATION_GROUP_KEY_FIELD: &str = "continuationGroupKey"; +/// Bounded ancestry markers preserved across Claude Code compact rewrites. +pub const CONTINUATION_MARKERS_FIELD: &str = "continuationMarkers"; +/// Stable component id elected after every source sync. +pub const CONTINUATION_LINEAGE_ID_FIELD: &str = "continuationLineageId"; +/// Hard cap for source-controlled marker arrays read from cache metadata. +pub const MAX_CONTINUATION_MARKERS: usize = 64; + +#[derive(Debug, Clone)] +struct ContinuationMetadata { + value: serde_json::Value, + group_key: Option, + markers: Vec, + lineage_id: Option, +} + +fn metadata_string(value: Option<&serde_json::Value>) -> Option { + value + .and_then(serde_json::Value::as_str) + .map(str::trim) + .filter(|value| !value.is_empty()) + .map(str::to_string) +} + +fn parse_continuation_metadata(metadata_json: &str) -> Option { + let value = serde_json::from_str::(metadata_json).ok()?; + if !value.is_object() { + return None; + } + let group_key = metadata_string(value.get(CONTINUATION_GROUP_KEY_FIELD)); + let lineage_id = metadata_string(value.get(CONTINUATION_LINEAGE_ID_FIELD)); + let mut markers = Vec::with_capacity(MAX_CONTINUATION_MARKERS); + let mut seen = HashSet::new(); + if let Some(group_key) = group_key.as_ref() { + seen.insert(group_key.clone()); + markers.push(group_key.clone()); + } + if let Some(values) = value + .get(CONTINUATION_MARKERS_FIELD) + .and_then(serde_json::Value::as_array) + { + for marker in values { + if markers.len() >= MAX_CONTINUATION_MARKERS { + break; + } + let Some(marker) = metadata_string(Some(marker)) else { + continue; + }; + if seen.insert(marker.clone()) { + markers.push(marker); + } + } + } + if markers.is_empty() { + return None; + } + Some(ContinuationMetadata { + value, + group_key, + markers, + lineage_id, + }) +} + +pub fn continuation_lineage_id_from_metadata_json(metadata_json: &str) -> Option { + parse_continuation_metadata(metadata_json)?.lineage_id +} /// Serialize the continuation group key into `source_metadata_json` shape. pub fn continuation_group_metadata_json(group_key: Option<&str>) -> Option { @@ -1096,8 +1181,51 @@ pub fn continuation_group_metadata_json(group_key: Option<&str>) -> Option, + ancestry_markers: &[String], +) -> Option { + let group_key = group_key + .map(str::trim) + .filter(|key| !key.is_empty()) + .map(str::to_string); + let mut markers = Vec::with_capacity(MAX_CONTINUATION_MARKERS); + let mut seen = HashSet::new(); + if let Some(group_key) = group_key.as_ref() { + seen.insert(group_key.clone()); + markers.push(group_key.clone()); + } + for marker in ancestry_markers { + if markers.len() >= MAX_CONTINUATION_MARKERS { + break; + } + let marker = marker.trim(); + if !marker.is_empty() && seen.insert(marker.to_string()) { + markers.push(marker.to_string()); + } + } + if markers.is_empty() { + return None; + } + let mut value = serde_json::Map::new(); + if let Some(group_key) = group_key { + value.insert( + CONTINUATION_GROUP_KEY_FIELD.to_string(), + serde_json::Value::String(group_key), + ); + } + value.insert( + CONTINUATION_MARKERS_FIELD.to_string(), + serde_json::Value::Array(markers.into_iter().map(serde_json::Value::String).collect()), + ); + Some(serde_json::Value::Object(value).to_string()) +} + /// Demote continuation-superseded sessions: within each group of top-level -/// sessions sharing a continuation group key, only the newest sibling (by +/// sessions whose bounded ancestry markers form a connected component, only +/// the newest sibling (by /// `updated_at_ms`, then `source_session_id`) stays listable; every other /// currently-listable sibling flips to `listable = 0`. /// @@ -1111,7 +1239,7 @@ pub fn demote_superseded_continuations_from_conn( ) -> Result { let mut stmt = conn .prepare( - "SELECT source_session_id, source_metadata_json, updated_at_ms, listable + "SELECT source_session_id, source_metadata_json, created_at_ms, updated_at_ms, listable FROM imported_history_session_cache WHERE source = ?1 AND COALESCE(parent_session_id, '') = '' @@ -1124,54 +1252,160 @@ pub fn demote_superseded_continuations_from_conn( row.get::<_, String>(0)?, row.get::<_, String>(1)?, row.get::<_, i64>(2)?, - row.get::<_, i64>(3)? != 0, + row.get::<_, i64>(3)?, + row.get::<_, i64>(4)? != 0, )) }) .map_err(|err| format!("Failed to query continuation election rows: {err}"))?; - // group key -> (winner-ordering key, listable losers seen so far) - struct Family { - winner: (i64, String), - listable_ids: Vec<(String, (i64, String))>, + struct ElectionRow { + source_session_id: String, + metadata: ContinuationMetadata, + created_at_ms: i64, + updated_at_ms: i64, + listable: bool, + } + + struct DisjointSet { + parent: Vec, + } + + impl DisjointSet { + fn new(len: usize) -> Self { + Self { + parent: (0..len).collect(), + } + } + + fn find(&mut self, index: usize) -> usize { + // Iterative with path compression: a pathological union order can + // chain O(component) parents, and recursing that deep on the sync + // thread is an avoidable stack risk. + let mut root = index; + while self.parent[root] != root { + root = self.parent[root]; + } + let mut current = index; + while self.parent[current] != root { + let next = self.parent[current]; + self.parent[current] = root; + current = next; + } + root + } + + fn union(&mut self, left: usize, right: usize) { + let left_root = self.find(left); + let right_root = self.find(right); + if left_root != right_root { + self.parent[right_root] = left_root; + } + } } - let mut families: HashMap = HashMap::new(); + + let mut election_rows = Vec::new(); for row in rows { - let (source_session_id, metadata_json, updated_at_ms, listable) = + let (source_session_id, metadata_json, created_at_ms, updated_at_ms, listable) = row.map_err(|err| format!("Failed to read continuation election row: {err}"))?; - let Some(group_key) = serde_json::from_str::(&metadata_json) - .ok() - .as_ref() - .and_then(|metadata| metadata.get(CONTINUATION_GROUP_KEY_FIELD)) - .and_then(serde_json::Value::as_str) - .map(str::trim) - .filter(|key| !key.is_empty()) - .map(str::to_string) - else { + let Some(metadata) = parse_continuation_metadata(&metadata_json) else { continue; }; - let ordering = (updated_at_ms, source_session_id.clone()); - let family = families.entry(group_key).or_insert_with(|| Family { - winner: ordering.clone(), - listable_ids: Vec::new(), + election_rows.push(ElectionRow { + source_session_id, + metadata, + created_at_ms, + updated_at_ms, + listable, }); - if ordering > family.winner { - family.winner = ordering.clone(); + } + + let mut sets = DisjointSet::new(election_rows.len()); + let mut marker_owner: HashMap = HashMap::new(); + for (index, row) in election_rows.iter().enumerate() { + // A stamped lineage id joins the connectivity keys alongside the raw + // ancestry markers. Deleting an intermediate transcript can split a + // family's marker graph into disconnected halves AFTER both halves + // were stamped; without this key the election would list both halves' + // winners (the duplicate row returns) while the exact-id lookup keeps + // treating them as one family via the shared lineage. Lineage ids are + // themselves member uuids (a canonical group key), so they share the + // marker namespace without colliding across conversations. + for marker in row + .metadata + .markers + .iter() + .chain(row.metadata.lineage_id.as_ref()) + { + if let Some(owner) = marker_owner.get(marker).copied() { + sets.union(index, owner); + } else { + marker_owner.insert(marker.clone(), index); + } } - if listable { - family.listable_ids.push((source_session_id, ordering)); + } + + let mut families: HashMap> = HashMap::new(); + for index in 0..election_rows.len() { + families.entry(sets.find(index)).or_default().push(index); + } + + let mut losers = Vec::new(); + let mut metadata_updates = Vec::new(); + for member_indices in families.values() { + let winner_index = *member_indices + .iter() + .max_by_key(|index| { + let row = &election_rows[**index]; + (row.updated_at_ms, row.source_session_id.as_str()) + }) + .expect("continuation family has at least one member"); + let canonical_index = *member_indices + .iter() + .min_by_key(|index| { + let row = &election_rows[**index]; + (row.created_at_ms, row.source_session_id.as_str()) + }) + .expect("continuation family has at least one member"); + // Preserve an already-elected id when a new continuation joins the + // component. That keeps a force-revealed row already held by the + // frontend comparable with the newly elected roster winner. A parser + // migration has no elected ids yet, so it falls back to one canonical + // member and stamps the whole component once. + let lineage_id = member_indices + .iter() + .filter_map(|index| election_rows[*index].metadata.lineage_id.as_ref()) + .min() + .cloned() + .or_else(|| election_rows[canonical_index].metadata.group_key.clone()) + .unwrap_or_else(|| election_rows[canonical_index].metadata.markers[0].clone()); + + for index in member_indices { + let row = &election_rows[*index]; + if row.listable && *index != winner_index { + losers.push(row.source_session_id.clone()); + } + if row.metadata.lineage_id.as_deref() != Some(lineage_id.as_str()) { + let mut metadata = row.metadata.value.clone(); + if let Some(object) = metadata.as_object_mut() { + object.insert( + CONTINUATION_LINEAGE_ID_FIELD.to_string(), + serde_json::Value::String(lineage_id.clone()), + ); + metadata_updates.push((row.source_session_id.clone(), metadata.to_string())); + } + } } } - let losers = families - .values() - .flat_map(|family| { - family - .listable_ids - .iter() - .filter(|(_, ordering)| *ordering != family.winner) - .map(|(id, _)| id.clone()) - }) - .collect::>(); + for (source_session_id, metadata_json) in metadata_updates { + conn.execute( + "UPDATE imported_history_session_cache + SET source_metadata_json = ?3 + WHERE source = ?1 AND source_session_id = ?2", + rusqlite::params![source, source_session_id, metadata_json], + ) + .map_err(|err| format!("Failed to stamp continuation lineage: {err}"))?; + } for source_session_id in &losers { conn.execute( "UPDATE imported_history_session_cache diff --git a/src-tauri/crates/orgtrack-core/src/sources/imported_history/cache_tests.rs b/src-tauri/crates/orgtrack-core/src/sources/imported_history/cache_tests.rs index 1108b8973..ee58d0914 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/imported_history/cache_tests.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/imported_history/cache_tests.rs @@ -403,6 +403,177 @@ fn continuation_election_demotes_all_but_newest_sibling() { assert!(listable_of(&conn, SOURCE_CODEX_APP, "keyless")); } +#[test] +fn continuation_election_connects_compaction_epochs_transitively() { + let mut conn = fixture_conn(); + let mut root = input(SOURCE_CODEX_APP, "root", 100); + root.source_metadata_json = + continuation_metadata_json(Some("first-user-root"), &["compact-a".to_string()]); + let mut middle = input(SOURCE_CODEX_APP, "middle", 200); + middle.source_metadata_json = continuation_metadata_json( + Some("first-user-middle"), + &["compact-a".to_string(), "compact-b".to_string()], + ); + let mut newest = input(SOURCE_CODEX_APP, "newest", 300); + newest.source_metadata_json = + continuation_metadata_json(Some("first-user-newest"), &["compact-b".to_string()]); + upsert_imported_session_cache_from_conn(&mut conn, &[root, middle, newest]).expect("upsert"); + + let demoted = + demote_superseded_continuations_from_conn(&conn, SOURCE_CODEX_APP).expect("election"); + + assert_eq!(demoted, 2); + assert!(!listable_of(&conn, SOURCE_CODEX_APP, "root")); + assert!(!listable_of(&conn, SOURCE_CODEX_APP, "middle")); + assert!(listable_of(&conn, SOURCE_CODEX_APP, "newest")); + for source_session_id in ["root", "middle", "newest"] { + let metadata_json: String = conn + .query_row( + "SELECT source_metadata_json FROM imported_history_session_cache + WHERE source = ?1 AND source_session_id = ?2", + rusqlite::params![SOURCE_CODEX_APP, source_session_id], + |row| row.get(0), + ) + .expect("metadata"); + assert_eq!( + continuation_lineage_id_from_metadata_json(&metadata_json).as_deref(), + Some("first-user-root") + ); + } + + let page = query_imported_sidebar_page_from_conn(&conn, SOURCE_CODEX_APP, None, None, 10, 0) + .expect("sidebar page"); + assert_eq!(page.sessions.len(), 1); + assert_eq!(page.sessions[0].session_id, "codex_app-newest"); + assert_eq!( + page.sessions[0].continuation_lineage_id.as_deref(), + Some("first-user-root") + ); + + // A later continuation preserves the elected id even when its own group + // key would sort before the original id. + let mut later = input(SOURCE_CODEX_APP, "later", 400); + later.source_metadata_json = + continuation_metadata_json(Some("000-new-first-user"), &["compact-b".to_string()]); + upsert_imported_session_cache_from_conn(&mut conn, &[later]).expect("upsert later"); + demote_superseded_continuations_from_conn(&conn, SOURCE_CODEX_APP).expect("second election"); + let later_metadata: String = conn + .query_row( + "SELECT source_metadata_json FROM imported_history_session_cache + WHERE source = ?1 AND source_session_id = 'later'", + [SOURCE_CODEX_APP], + |row| row.get(0), + ) + .expect("later metadata"); + assert_eq!( + continuation_lineage_id_from_metadata_json(&later_metadata).as_deref(), + Some("first-user-root") + ); +} + +#[test] +fn continuation_election_survives_a_family_split_after_stamping() { + // Deleting intermediate transcripts can disconnect a family's marker + // graph AFTER the lineage was stamped. A later rescan reinserts an old + // sibling as a fresh listable row with no stamp to inherit; only the + // stamped lineage on the surviving member (whose value is the canonical + // member's group key) reconnects the halves. Without lineage as a + // connectivity key the election would list both halves' winners and the + // duplicate row this feature removes would return. + let mut conn = fixture_conn(); + let mut root = input(SOURCE_CODEX_APP, "root", 100); + root.source_metadata_json = + continuation_metadata_json(Some("first-user-root"), &["compact-a".to_string()]); + let mut middle = input(SOURCE_CODEX_APP, "middle", 200); + middle.source_metadata_json = continuation_metadata_json( + Some("first-user-middle"), + &["compact-a".to_string(), "compact-b".to_string()], + ); + let mut newest = input(SOURCE_CODEX_APP, "newest", 300); + newest.source_metadata_json = + continuation_metadata_json(Some("first-user-newest"), &["compact-b".to_string()]); + upsert_imported_session_cache_from_conn(&mut conn, &[root.clone(), middle, newest]) + .expect("upsert"); + demote_superseded_continuations_from_conn(&conn, SOURCE_CODEX_APP).expect("first election"); + + // The intermediate transcript ages out and the old sibling's row is + // dropped with it; a later rescan reinserts the old sibling from its + // still-present file as a brand-new listable row without any stamp. + for gone in ["middle", "root"] { + conn.execute( + "DELETE FROM imported_history_session_cache + WHERE source = ?1 AND source_session_id = ?2", + rusqlite::params![SOURCE_CODEX_APP, gone], + ) + .expect("drop row"); + } + upsert_imported_session_cache_from_conn(&mut conn, &[root]).expect("reinsert root"); + + demote_superseded_continuations_from_conn(&conn, SOURCE_CODEX_APP).expect("second election"); + + assert!(!listable_of(&conn, SOURCE_CODEX_APP, "root")); + assert!(listable_of(&conn, SOURCE_CODEX_APP, "newest")); + let root_metadata: String = conn + .query_row( + "SELECT source_metadata_json FROM imported_history_session_cache + WHERE source = ?1 AND source_session_id = 'root'", + [SOURCE_CODEX_APP], + |row| row.get(0), + ) + .expect("root metadata"); + assert_eq!( + continuation_lineage_id_from_metadata_json(&root_metadata).as_deref(), + Some("first-user-root") + ); +} + +#[test] +fn rescan_upsert_preserves_a_stamped_lineage_id() { + // A rescan replaces `source_metadata_json` with freshly parsed metadata + // that never carries the elected lineage. The upsert must carry the stamp + // over, or every rescan erodes the id the reveal/dedupe paths compare. + let mut conn = fixture_conn(); + let mut row = input(SOURCE_CODEX_APP, "stamped", 100); + row.source_metadata_json = Some( + serde_json::json!({ + CONTINUATION_GROUP_KEY_FIELD: "first-user-a", + CONTINUATION_MARKERS_FIELD: ["first-user-a", "compact-a"], + CONTINUATION_LINEAGE_ID_FIELD: "elected-lineage", + }) + .to_string(), + ); + upsert_imported_session_cache_from_conn(&mut conn, &[row.clone()]).expect("initial upsert"); + + row.source_metadata_json = + continuation_metadata_json(Some("first-user-a"), &["compact-a".to_string()]); + upsert_imported_session_cache_from_conn(&mut conn, &[row.clone()]).expect("rescan upsert"); + let metadata: String = conn + .query_row( + "SELECT source_metadata_json FROM imported_history_session_cache + WHERE source = ?1 AND source_session_id = 'stamped'", + [SOURCE_CODEX_APP], + |row| row.get(0), + ) + .expect("metadata"); + assert_eq!( + continuation_lineage_id_from_metadata_json(&metadata).as_deref(), + Some("elected-lineage") + ); + + // A rewrite that loses continuation identity drops the stamp with it. + row.source_metadata_json = None; + upsert_imported_session_cache_from_conn(&mut conn, &[row]).expect("keyless upsert"); + let metadata: String = conn + .query_row( + "SELECT source_metadata_json FROM imported_history_session_cache + WHERE source = ?1 AND source_session_id = 'stamped'", + [SOURCE_CODEX_APP], + |row| row.get(0), + ) + .expect("metadata"); + assert_eq!(metadata, ""); +} + #[test] fn continuation_election_never_promotes_and_skips_subagents() { let mut conn = fixture_conn(); @@ -630,6 +801,28 @@ fn continuation_group_metadata_json_shapes() { ); } +#[test] +fn continuation_metadata_bounds_and_deduplicates_markers() { + let markers = (0..100) + .map(|index| format!("marker-{index}")) + .collect::>(); + let json = continuation_metadata_json(Some("marker-0"), &markers).expect("metadata"); + let parsed: serde_json::Value = serde_json::from_str(&json).expect("parse"); + let stored = parsed + .get(CONTINUATION_MARKERS_FIELD) + .and_then(serde_json::Value::as_array) + .expect("markers"); + assert_eq!(stored.len(), MAX_CONTINUATION_MARKERS); + assert_eq!(stored[0].as_str(), Some("marker-0")); + assert_eq!( + stored + .iter() + .filter(|marker| marker.as_str() == Some("marker-0")) + .count(), + 1 + ); +} + fn table_has_column(conn: &Connection, table: &str, column: &str) -> bool { let mut stmt = conn .prepare(&format!("PRAGMA table_info({table})")) diff --git a/src-tauri/crates/orgtrack-core/src/sources/imported_history/mod.rs b/src-tauri/crates/orgtrack-core/src/sources/imported_history/mod.rs index b7c1214a9..ef84a3836 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/imported_history/mod.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/imported_history/mod.rs @@ -282,6 +282,11 @@ pub struct ImportedHistorySidebarRow { pub storage_path: Option, #[serde(skip_serializing_if = "Option::is_none")] pub model: Option, + /// Continuation-family identity elected from source metadata. Sidebar + /// consumers use it only to avoid rendering both a force-revealed active + /// sibling and the family's canonical roster row. + #[serde(skip_serializing_if = "Option::is_none")] + pub continuation_lineage_id: Option, /// ORGII-owned pin state, read from `imported_history_session_pin`. /// A pin belongs to ORGII, not to the source app, so it is stored beside /// the rebuildable cache rather than on it. diff --git a/src/api/tauri/rpc/schemas/sessionAggregate.ts b/src/api/tauri/rpc/schemas/sessionAggregate.ts index ea281a620..fd8b38e2a 100644 --- a/src/api/tauri/rpc/schemas/sessionAggregate.ts +++ b/src/api/tauri/rpc/schemas/sessionAggregate.ts @@ -280,6 +280,9 @@ export const ExternalHistorySidebarRowSchema = z.object({ // copy, so this is their only storage path. storagePath: z.string().optional(), model: z.string().optional(), + // Stable continuation-family identity elected by the imported-history + // cache. Used only for sidebar de-duplication of force-revealed siblings. + continuationLineageId: z.string().optional(), /** ORGII-owned pin state; imported sessions carry no pin from their source. */ pinned: z.boolean().optional(), totalTokens: z.number().int().optional(), diff --git a/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/__tests__/continuationVisibility.test.ts b/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/__tests__/continuationVisibility.test.ts new file mode 100644 index 000000000..230669df7 --- /dev/null +++ b/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/__tests__/continuationVisibility.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from "vitest"; + +import type { Session } from "@src/store/session"; + +import { + continuationLineagesForRevealedSessions, + isRosterSiblingOfRevealedContinuation, +} from "../continuationVisibility"; + +function session(sessionId: string, continuationLineageId?: string): Session { + return { + session_id: sessionId, + status: "completed", + created_at: "2026-08-05T00:00:00.000Z", + updated_at: "2026-08-05T00:00:00.000Z", + continuationLineageId, + }; +} + +describe("continuationLineagesForRevealedSessions", () => { + it("returns only lineages owned by explicitly revealed rows", () => { + expect( + continuationLineagesForRevealedSessions( + [ + session("active-old", "lineage-a"), + session("roster-new", "lineage-a"), + session("unrelated", "lineage-b"), + session("legacy-without-lineage"), + ], + new Set(["active-old", "legacy-without-lineage"]) + ) + ).toEqual(new Set(["lineage-a"])); + }); + + it("hides the roster winner but keeps the explicitly revealed sibling", () => { + const revealedIds = new Set(["active-old"]); + const revealedLineages = new Set(["lineage-a"]); + + expect( + isRosterSiblingOfRevealedContinuation( + session("roster-new", "lineage-a"), + revealedIds, + revealedLineages + ) + ).toBe(true); + expect( + isRosterSiblingOfRevealedContinuation( + session("active-old", "lineage-a"), + revealedIds, + revealedLineages + ) + ).toBe(false); + expect( + isRosterSiblingOfRevealedContinuation( + session("unrelated", "lineage-b"), + revealedIds, + revealedLineages + ) + ).toBe(false); + }); +}); diff --git a/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/continuationVisibility.ts b/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/continuationVisibility.ts new file mode 100644 index 000000000..7644d6167 --- /dev/null +++ b/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/continuationVisibility.ts @@ -0,0 +1,29 @@ +import type { Session } from "@src/store/session"; + +export function continuationLineagesForRevealedSessions( + sessions: readonly Session[], + revealedSessionIds: ReadonlySet +): ReadonlySet { + const lineages = new Set(); + for (const session of sessions) { + if ( + revealedSessionIds.has(session.session_id) && + session.continuationLineageId + ) { + lineages.add(session.continuationLineageId); + } + } + return lineages; +} + +export function isRosterSiblingOfRevealedContinuation( + session: Session, + revealedSessionIds: ReadonlySet, + revealedContinuationLineages: ReadonlySet +): boolean { + return Boolean( + !revealedSessionIds.has(session.session_id) && + session.continuationLineageId && + revealedContinuationLineages.has(session.continuationLineageId) + ); +} diff --git a/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/index.tsx b/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/index.tsx index d5267d64d..2c4bffb32 100644 --- a/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/index.tsx +++ b/src/scaffold/NavigationSidebar/connectors/useSessionMenuItems/index.tsx @@ -21,6 +21,10 @@ import { isImportedHistorySession } from "@src/util/session/sessionDispatch"; import { getSessionSearchText } from "@src/util/session/sessionSearch"; import { isPrimarySessionListSession } from "@src/util/session/sessionVisibility"; +import { + continuationLineagesForRevealedSessions, + isRosterSiblingOfRevealedContinuation, +} from "./continuationVisibility"; import { DEFAULT_GROUP_VISIBLE_COUNT, type DateGroupKey, @@ -243,12 +247,26 @@ export function useSessionMenuItems({ () => createSidebarRosterMatcher(pagination), [pagination] ); + const revealedContinuationLineages = useMemo( + () => + continuationLineagesForRevealedSessions( + sortedSessions, + revealedSessionIds + ), + [revealedSessionIds, sortedSessions] + ); const visibleSessions = useMemo( () => sortedSessions.filter((session) => { const explicitlyRevealed = revealedSessionIds.has(session.session_id); + const hiddenRosterSibling = isRosterSiblingOfRevealedContinuation( + session, + revealedSessionIds, + revealedContinuationLineages + ); return ( + !hiddenRosterSibling && isPrimarySessionListSession(session) && (explicitlyRevealed || (isInSidebarRoster(session) && @@ -269,6 +287,7 @@ export function useSessionMenuItems({ includeExternal, isInSidebarRoster, revealedSessionIds, + revealedContinuationLineages, selectedOrgIds, sortedSessions, ] diff --git a/src/store/session/sessionAtom/__tests__/sidebarLoaders.test.ts b/src/store/session/sessionAtom/__tests__/sidebarLoaders.test.ts index dd588a077..d46fbd330 100644 --- a/src/store/session/sessionAtom/__tests__/sidebarLoaders.test.ts +++ b/src/store/session/sessionAtom/__tests__/sidebarLoaders.test.ts @@ -44,7 +44,11 @@ vi.mock("../persistence", () => ({ persistSessions: mocks.persistSessions, })); -function makeRow(sessionId: string, updatedAt: string) { +function makeRow( + sessionId: string, + updatedAt: string, + continuationLineageId?: string +) { return { sessionId, name: sessionId, @@ -52,6 +56,7 @@ function makeRow(sessionId: string, updatedAt: string) { updatedAt, repoPath: "/tmp/project", storagePath: `/tmp/store/${sessionId}.jsonl`, + continuationLineageId, }; } @@ -86,7 +91,13 @@ describe("loadSidebarSessions", () => { bucket: "older", sessions: source.sourceId === "codex_app" - ? [makeRow("codexapp-healthy", "2026-07-01T00:00:00Z")] + ? [ + makeRow( + "codexapp-healthy", + "2026-07-01T00:00:00Z", + "continuation-root" + ), + ] : [], hasMore: false, }, @@ -105,6 +116,12 @@ describe("loadSidebarSessions", () => { expect(pagination?.["external_history:codex_app"].sessionIds).toEqual([ "codexapp-healthy", ]); + expect( + mocks.store + ?.get(sessionsAtom) + .find((session) => session.session_id === "codexapp-healthy") + ?.continuationLineageId + ).toBe("continuation-root"); }); it("does not publish an authoritative empty page when the whole batch rejects", async () => { diff --git a/src/store/session/sessionAtom/loaders.ts b/src/store/session/sessionAtom/loaders.ts index 1ee779678..439b8c4dd 100644 --- a/src/store/session/sessionAtom/loaders.ts +++ b/src/store/session/sessionAtom/loaders.ts @@ -254,6 +254,7 @@ function importedHistoryPageResult( repoRemoteUrls: row.repoRemoteUrls, branch: row.branch, storagePath: row.storagePath, + continuationLineageId: row.continuationLineageId, agentIconId: source.iconId, agentDisplayName: source.displayName, model: row.model, diff --git a/src/store/session/sessionAtom/types.ts b/src/store/session/sessionAtom/types.ts index 4a0a9c660..b6079d493 100644 --- a/src/store/session/sessionAtom/types.ts +++ b/src/store/session/sessionAtom/types.ts @@ -167,6 +167,8 @@ export interface Session { repoRemoteUrls?: string[]; /** Path to the file or directory where this session's persisted data lives. */ storagePath?: string; + /** Imported-history continuation family used to suppress duplicate sidebar siblings. */ + continuationLineageId?: string; /** Worktree path for isolated parallel sessions */ worktreePath?: string; /** Branch name inside the worktree (e.g. `agent/abc123`) */