From baa50b9e1d06cde015e60bfd38bb19d21c324fd3 Mon Sep 17 00:00:00 2001 From: Harry19081 <20519290+Harry19081@users.noreply.github.com> Date: Wed, 29 Jul 2026 04:12:22 +0800 Subject: [PATCH 1/8] feat(orgtrack): plan native-CLI resume for imported external sessions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New orgtrack_core::sources::cli_resume maps an imported session (claude_code / codex_app / cursor_cli) to the invocation its owning CLI accepts: claude --resume (original cwd required — Claude keys session storage on the project path), codex resume (extracted from the rollout stem), cursor-agent --resume . Cursor IDE composers are deliberately unsupported: their ids share no space with cursor-agent's chat store. The desktop app exposes it as external_history_cli_resume_plan, folding in the host-only freshness checks (workspace dir still exists, source store still on disk). --- .../orgtrack-core/src/sources/cli_resume.rs | 322 ++++++++++++++++++ .../crates/orgtrack-core/src/sources/mod.rs | 1 + src-tauri/src/commands/handler_list.inc | 1 + src-tauri/src/orgtrack/history_commands.rs | 47 +++ 4 files changed, 371 insertions(+) create mode 100644 src-tauri/crates/orgtrack-core/src/sources/cli_resume.rs diff --git a/src-tauri/crates/orgtrack-core/src/sources/cli_resume.rs b/src-tauri/crates/orgtrack-core/src/sources/cli_resume.rs new file mode 100644 index 0000000000..d20dc962f5 --- /dev/null +++ b/src-tauri/crates/orgtrack-core/src/sources/cli_resume.rs @@ -0,0 +1,322 @@ +//! Native-CLI resume planning for imported external sessions. +//! +//! An imported transcript is read-only inside ORGII, but the CLI that wrote +//! it can usually reopen the very same conversation (`claude --resume`, +//! `codex resume`, `cursor-agent --resume`). This module owns the mapping +//! from an imported-history cache row to that invocation, so the desktop +//! app (chat-panel TUI terminal) and the `orgtrack` CLI agree on which +//! sources are resumable and how the command line is spelled. +//! +//! Only sources whose CLI has a session-id resume entry point belong here. +//! Notably absent: `cursor_ide` — IDE composers live in `state.vscdb` and +//! share no id space with `cursor-agent`'s `~/.cursor/chats` store (verified +//! empirically: zero overlap), so no CLI can reopen them. + +use rusqlite::Connection; +use serde::Serialize; + +use super::imported_history::cache::{ + query_cached_session_by_session_id_from_conn, ImportedHistoryCachedSession, +}; +use super::imported_history::metadata::{ + SOURCE_CLAUDE_CODE, SOURCE_CODEX_APP, SOURCE_CURSOR_CLI, +}; + +/// How to hand an imported external session back to the CLI that owns it. +#[derive(Debug, Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct CliResumePlan { + /// Imported-history source id the plan derives from. + pub source: &'static str, + /// `code_sessions.cli_agent_type` value of the owning CLI, so hosts can + /// reuse managed-session infrastructure (launch profiles, TUI rows, + /// managed-mirror dedup) unchanged. + pub cli_agent_type: &'static str, + /// Bare binary to launch when no launch-profile override applies. + pub default_binary: &'static str, + /// Arguments appended after the binary to reopen the session. + pub resume_args: Vec, + /// The session id the CLI itself accepts (bare thread uuid / chat id). + pub native_session_id: String, + /// Working directory the resume should run in — the session's recorded + /// workspace. `None` when the source never recorded one. + pub cwd: Option, + /// Whether the CLI can only locate the session from its original + /// working directory (Claude Code keys session storage on the project + /// path; Codex and cursor-agent look sessions up globally). + pub requires_cwd: bool, +} + +impl CliResumePlan { + /// The full resume invocation as one display string, shell-quoted the + /// same way the chat-panel TUI launcher quotes commands. + pub fn display_command(&self) -> String { + std::iter::once(self.default_binary.to_string()) + .chain(self.resume_args.iter().cloned()) + .map(|part| shell_quote(&part)) + .collect::>() + .join(" ") + } +} + +/// POSIX single-quote escaping for display command lines. Safe-charset +/// values pass through unquoted so the common `claude --resume ` +/// stays copy-paste clean. +pub fn shell_quote(value: &str) -> String { + if !value.is_empty() + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || b"_./:@%+=,-".contains(&byte)) + { + return value.to_string(); + } + format!("'{}'", value.replace('\'', r"'\''")) +} + +/// Build the resume plan for one imported session, or `None` when the +/// source has no CLI resume path (or the id shape rules it out). +pub fn cli_resume_plan( + source: &str, + source_session_id: &str, + repo_path: Option<&str>, +) -> Option { + let cwd = repo_path + .map(str::trim) + .filter(|path| !path.is_empty()) + .map(str::to_string); + match source { + // Claude Code sessions are `.jsonl` under the project slug; + // the file stem IS the id `--resume` accepts. Non-uuid stems + // (fixtures, sidecars) are not resumable. + SOURCE_CLAUDE_CODE => { + if !is_uuid_like(source_session_id) { + return None; + } + Some(CliResumePlan { + source: SOURCE_CLAUDE_CODE, + cli_agent_type: "claude_code", + default_binary: "claude", + resume_args: vec!["--resume".to_string(), source_session_id.to_string()], + native_session_id: source_session_id.to_string(), + cwd, + requires_cwd: true, + }) + } + // Codex imports key on the rollout file stem + // (`rollout--`), while `codex resume` takes + // the bare thread uuid — same suffix extraction the managed-mirror + // dedup uses. + SOURCE_CODEX_APP => { + let thread_uuid = codex_thread_uuid_from_stem(source_session_id)?; + Some(CliResumePlan { + source: SOURCE_CODEX_APP, + cli_agent_type: "codex", + default_binary: "codex", + resume_args: vec!["resume".to_string(), thread_uuid.to_string()], + native_session_id: thread_uuid.to_string(), + cwd, + requires_cwd: false, + }) + } + // cursor-agent stores one `store.db` per chat uuid; `--resume ` + // reopens it from anywhere. + SOURCE_CURSOR_CLI => { + if !is_uuid_like(source_session_id) { + return None; + } + Some(CliResumePlan { + source: SOURCE_CURSOR_CLI, + cli_agent_type: "cursor_cli", + default_binary: "cursor-agent", + resume_args: vec!["--resume".to_string(), source_session_id.to_string()], + native_session_id: source_session_id.to_string(), + cwd, + requires_cwd: false, + }) + } + _ => None, + } +} + +/// Resolve a canonical (prefixed) session id against the imported-history +/// cache and plan its CLI resume. Returns the cache row alongside the plan +/// so hosts can run freshness/availability checks (`source_path` on disk, +/// cwd existence) without a second query. Subagent rows resolve to `None`: +/// their transcripts are children of a conversation the CLI resumes as a +/// whole. +pub fn cli_resume_plan_for_cached_session( + conn: &Connection, + session_id: &str, +) -> Result, String> { + let Some((source, session)) = query_cached_session_by_session_id_from_conn(conn, session_id)? + else { + return Ok(None); + }; + if session.parent_session_id.is_some() { + return Ok(None); + } + let plan = cli_resume_plan( + &source, + &session.source_session_id, + session.repo_path.as_deref(), + ); + Ok(plan.map(|plan| (plan, session))) +} + +/// `rollout--` → ``. Accepts a bare +/// uuid too (runner bindings and older imports carry that form). +fn codex_thread_uuid_from_stem(stem: &str) -> Option<&str> { + if is_uuid_like(stem) { + return Some(stem); + } + if stem.len() > 37 { + let (head, tail) = stem.split_at(stem.len() - 36); + if head.ends_with('-') && is_uuid_like(tail) { + return Some(tail); + } + } + None +} + +fn is_uuid_like(value: &str) -> bool { + let bytes = value.as_bytes(); + if bytes.len() != 36 { + return false; + } + bytes.iter().enumerate().all(|(index, byte)| { + if matches!(index, 8 | 13 | 18 | 23) { + *byte == b'-' + } else { + byte.is_ascii_hexdigit() + } + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + const UUID: &str = "019f6e88-3bc8-77b3-9f21-30af8dd9a1cd"; + + #[test] + fn claude_plan_uses_resume_flag_and_requires_cwd() { + let plan = cli_resume_plan(SOURCE_CLAUDE_CODE, UUID, Some("/tmp/project")).expect("plan"); + assert_eq!(plan.cli_agent_type, "claude_code"); + assert_eq!(plan.default_binary, "claude"); + assert_eq!(plan.resume_args, vec!["--resume", UUID]); + assert_eq!(plan.native_session_id, UUID); + assert_eq!(plan.cwd.as_deref(), Some("/tmp/project")); + assert!(plan.requires_cwd); + assert_eq!(plan.display_command(), format!("claude --resume {UUID}")); + } + + #[test] + fn claude_rejects_non_uuid_stems() { + assert!(cli_resume_plan(SOURCE_CLAUDE_CODE, "claude-meta", None).is_none()); + } + + #[test] + fn codex_plan_extracts_thread_uuid_from_rollout_stem() { + let stem = format!("rollout-2026-07-17T13-24-09-{UUID}"); + let plan = cli_resume_plan(SOURCE_CODEX_APP, &stem, None).expect("plan"); + assert_eq!(plan.cli_agent_type, "codex"); + assert_eq!(plan.resume_args, vec!["resume", UUID]); + assert_eq!(plan.native_session_id, UUID); + assert!(!plan.requires_cwd); + assert!(plan.cwd.is_none()); + } + + #[test] + fn codex_plan_accepts_bare_uuid_and_rejects_boundaryless_suffix() { + assert!(cli_resume_plan(SOURCE_CODEX_APP, UUID, None).is_some()); + // 36 hex-ish tail without a '-' boundary before it must not match. + let boundaryless = format!("rollout{UUID}"); + assert!(cli_resume_plan(SOURCE_CODEX_APP, &boundaryless, None).is_none()); + } + + #[test] + fn cursor_cli_plan_uses_resume_flag_globally() { + let plan = cli_resume_plan(SOURCE_CURSOR_CLI, UUID, Some(" ")).expect("plan"); + assert_eq!(plan.default_binary, "cursor-agent"); + assert_eq!(plan.resume_args, vec!["--resume", UUID]); + // Blank repo paths normalize away instead of producing a " " cwd. + assert!(plan.cwd.is_none()); + assert!(!plan.requires_cwd); + } + + #[test] + fn unsupported_sources_yield_no_plan() { + for source in ["cursor_ide", "opencode", "windsurf", "definitely_not"] { + assert!(cli_resume_plan(source, UUID, None).is_none(), "{source}"); + } + } + + #[test] + fn display_command_quotes_unsafe_arguments() { + let mut plan = cli_resume_plan(SOURCE_CLAUDE_CODE, UUID, None).expect("plan"); + plan.resume_args = vec!["--resume".to_string(), "a b'c".to_string()]; + assert_eq!(plan.display_command(), r#"claude --resume 'a b'\''c'"#); + } + + #[test] + fn cached_lookup_plans_only_resumable_rows() { + use crate::sources::imported_history::metadata::{ + ImportedHistoryCacheInput, ImportedHistoryImpactStats, + }; + + let mut conn = Connection::open_in_memory().expect("open"); + crate::store::sqlite::SqliteRecordStore::init_tables(&conn).expect("init core tables"); + crate::store::sqlite::SqliteRecordStore::init_source_cache_tables(&conn) + .expect("init source cache tables"); + + let input = |source: &'static str, source_session_id: &str, session_id: &str| { + ImportedHistoryCacheInput { + source, + source_session_id: source_session_id.to_string(), + session_id: session_id.to_string(), + source_path: "/tmp/source".to_string(), + source_record_key: source_session_id.to_string(), + source_mtime_ms: 1, + source_size_bytes: 1, + source_fingerprint: "fp".to_string(), + parser_version: 1, + name: "session".to_string(), + created_at_ms: 1, + updated_at_ms: 2, + model: None, + input_tokens: 0, + output_tokens: 0, + cache_read_tokens: 0, + cache_write_tokens: 0, + repo_path: Some("/tmp/project".to_string()), + branch: None, + impact: ImportedHistoryImpactStats::default(), + listable: true, + source_metadata_json: None, + parent_session_id: None, + } + }; + let claude_session_id = format!("claudecodeapp-{UUID}"); + crate::sources::imported_history::cache::upsert_imported_session_cache_from_conn( + &mut conn, + &[ + input(SOURCE_CLAUDE_CODE, UUID, &claude_session_id), + input("opencode", "ses_123", "opencodeapp-ses_123"), + ], + ) + .expect("upsert"); + + let (plan, session) = cli_resume_plan_for_cached_session(&conn, &claude_session_id) + .expect("query") + .expect("plan"); + assert_eq!(plan.native_session_id, UUID); + assert_eq!(session.repo_path.as_deref(), Some("/tmp/project")); + + assert!(cli_resume_plan_for_cached_session(&conn, "opencodeapp-ses_123") + .expect("query") + .is_none()); + assert!(cli_resume_plan_for_cached_session(&conn, "unknown-id") + .expect("query") + .is_none()); + } +} diff --git a/src-tauri/crates/orgtrack-core/src/sources/mod.rs b/src-tauri/crates/orgtrack-core/src/sources/mod.rs index c1929a1c6a..251c7c17a9 100644 --- a/src-tauri/crates/orgtrack-core/src/sources/mod.rs +++ b/src-tauri/crates/orgtrack-core/src/sources/mod.rs @@ -39,6 +39,7 @@ pub trait SourceAdapter { pub mod activity; pub mod anthropic_jsonl; pub mod claude_code; +pub mod cli_resume; pub mod cline; pub mod codex; pub mod cursor_cli; diff --git a/src-tauri/src/commands/handler_list.inc b/src-tauri/src/commands/handler_list.inc index 7663db9c86..98b30e080e 100644 --- a/src-tauri/src/commands/handler_list.inc +++ b/src-tauri/src/commands/handler_list.inc @@ -1055,6 +1055,7 @@ orgtrack::history_commands::external_cli_sources_detect, orgtrack::history_commands::external_cli_source_probe, orgtrack::history_commands::external_history_rescan_source, orgtrack::history_commands::external_history_rescan_sources, +orgtrack::history_commands::external_history_cli_resume_plan, orgtrack::history_commands::external_history_auto_import_recent_paths, orgtrack::history_commands::windsurf_history_chunks, orgtrack::history_commands::windsurf_recent_paths, diff --git a/src-tauri/src/orgtrack/history_commands.rs b/src-tauri/src/orgtrack/history_commands.rs index 8fb43be859..d23225b81c 100644 --- a/src-tauri/src/orgtrack/history_commands.rs +++ b/src-tauri/src/orgtrack/history_commands.rs @@ -537,6 +537,53 @@ pub async fn external_history_rescan_sources( .map_err(|err| format!("Task join error: {err}"))? } +/// [`orgtrack_core::sources::cli_resume::CliResumePlan`] plus the two +/// freshness checks only the desktop host can answer: whether the recorded +/// workspace directory and the source transcript/store are still on disk. +#[derive(Debug, Clone, serde::Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ExternalHistoryCliResumePlanWire { + #[serde(flatten)] + pub plan: orgtrack_core::sources::cli_resume::CliResumePlan, + pub display_command: String, + pub cwd_exists: bool, + pub source_available: bool, +} + +/// Plan how to reopen an imported external session in its own CLI. +/// `Ok(None)` when the session is unknown, a subagent child, or its source +/// has no CLI resume entry point (e.g. Cursor IDE composers). +#[tauri::command] +pub async fn external_history_cli_resume_plan( + session_id: String, +) -> Result, String> { + tokio::task::spawn_blocking(move || { + let conn = open_cache_conn()?; + let Some((plan, session)) = + orgtrack_core::sources::cli_resume::cli_resume_plan_for_cached_session( + &conn, + &session_id, + )? + else { + return Ok(None); + }; + let cwd_exists = plan + .cwd + .as_deref() + .is_some_and(|path| Path::new(path).is_dir()); + let source_available = + !session.source_path.is_empty() && Path::new(&session.source_path).exists(); + Ok(Some(ExternalHistoryCliResumePlanWire { + display_command: plan.display_command(), + plan, + cwd_exists, + source_available, + })) + }) + .await + .map_err(|err| format!("Task join error: {err}"))? +} + #[tauri::command] pub async fn orgtrack_get_cursor_sessions( start_date: String, From 89cf6efcdfa505583b8ddd1bcc0a6eda2d19931f Mon Sep 17 00:00:00 2001 From: Harry19081 <20519290+Harry19081@users.noreply.github.com> Date: Wed, 29 Jul 2026 04:12:34 +0800 Subject: [PATCH 2/8] feat(chat): continue imported sessions in their own CLI terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Imported Claude Code / Codex / Cursor CLI sessions get a 'Continue in ' header action next to the fork button. It reuses the existing chat-panel TUI launch flow end to end: launch-profile command resolution, a managed code_sessions row (runner='tui') for ORGII_SESSION_ID lifecycle attribution and managed-mirror dedup, and the session's recorded workspace as cwd. Disabled states explain exactly why (CLI not installed, original folder gone for Claude's cwd-scoped resume, source store deleted). Sources with no CLI resume path (Cursor IDE composers and the rest) simply don't render the button — the backend plan is the single source of truth. --- .../tauri/agent/cliTerminalSession.test.ts | 25 +- src/api/tauri/agent/cliTerminalSession.ts | 13 + src/api/tauri/externalHistory/index.ts | 4 + src/api/tauri/externalHistory/resume.ts | 44 ++++ .../SessionContinueCliHeaderExtras.tsx | 227 ++++++++++++++++++ src/engines/ChatPanel/index.tsx | 6 + src/i18n/locales/de/navigation.json | 8 + src/i18n/locales/en/navigation.json | 8 + src/i18n/locales/es/navigation.json | 8 + src/i18n/locales/fr/navigation.json | 8 + src/i18n/locales/ja/navigation.json | 8 + src/i18n/locales/ko/navigation.json | 8 + src/i18n/locales/pl/navigation.json | 8 + src/i18n/locales/pt/navigation.json | 8 + src/i18n/locales/ru/navigation.json | 8 + src/i18n/locales/tr/navigation.json | 8 + src/i18n/locales/vi/navigation.json | 8 + src/i18n/locales/zh-Hant/navigation.json | 8 + src/i18n/locales/zh/navigation.json | 8 + 19 files changed, 422 insertions(+), 1 deletion(-) create mode 100644 src/api/tauri/externalHistory/resume.ts create mode 100644 src/engines/ChatPanel/SessionContinueCliHeaderExtras.tsx diff --git a/src/api/tauri/agent/cliTerminalSession.test.ts b/src/api/tauri/agent/cliTerminalSession.test.ts index b3de3fd65e..257f7a5cc0 100644 --- a/src/api/tauri/agent/cliTerminalSession.test.ts +++ b/src/api/tauri/agent/cliTerminalSession.test.ts @@ -2,7 +2,10 @@ import { describe, expect, it } from "vitest"; import type { CliLaunchProfileView } from "@src/api/tauri/rpc/schemas/agentOrgs"; -import { formatCliTuiCommand } from "./cliTerminalSession"; +import { + appendCliCommandArgs, + formatCliTuiCommand, +} from "./cliTerminalSession"; function profile( overrides: Partial = {} @@ -65,3 +68,23 @@ describe("formatCliTuiCommand", () => { ).toBe("'/Applications/Trae Agent/trae-cli' interactive 'two words'"); }); }); + +describe("appendCliCommandArgs", () => { + it("appends resume arguments after the resolved profile command", () => { + expect( + appendCliCommandArgs("claude --permission-mode plan", [ + "--resume", + "b52f4220-8b0b-46c5-8ee6-001ebf91c6ed", + ]) + ).toBe( + "claude --permission-mode plan --resume b52f4220-8b0b-46c5-8ee6-001ebf91c6ed" + ); + }); + + it("quotes unsafe arguments and drops blank ones", () => { + expect(appendCliCommandArgs("codex", ["resume", " ", "two words"])).toBe( + "codex resume 'two words'" + ); + expect(appendCliCommandArgs("codex", [])).toBe("codex"); + }); +}); diff --git a/src/api/tauri/agent/cliTerminalSession.ts b/src/api/tauri/agent/cliTerminalSession.ts index 70d14e6c87..5ffd9ba888 100644 --- a/src/api/tauri/agent/cliTerminalSession.ts +++ b/src/api/tauri/agent/cliTerminalSession.ts @@ -38,6 +38,19 @@ function quotePosixShellArg(value: string): string { return `'${value.replace(/'/g, `'\\''`)}'`; } +/** + * Append extra arguments (e.g. a resume flag + session id) to an + * already-resolved terminal command, quoting each the same way the launch + * profile formatter does. + */ +export function appendCliCommandArgs(command: string, args: string[]): string { + const suffix = args + .filter((arg) => arg.trim().length > 0) + .map(quotePosixShellArg) + .join(" "); + return suffix.length > 0 ? `${command} ${suffix}` : command; +} + export function formatCliTuiCommand( profile: CliLaunchProfileView, detectedCommand: string diff --git a/src/api/tauri/externalHistory/index.ts b/src/api/tauri/externalHistory/index.ts index b78bb6deb3..4cf2198e81 100644 --- a/src/api/tauri/externalHistory/index.ts +++ b/src/api/tauri/externalHistory/index.ts @@ -12,6 +12,10 @@ export { externalHistoryRescanSources, type ExternalHistoryScanResult, } from "./rescan"; +export { + externalHistoryCliResumePlan, + type ExternalHistoryCliResumePlan, +} from "./resume"; export { fetchExternalSourceStats, fetchExternalSourceStatsBatch, diff --git a/src/api/tauri/externalHistory/resume.ts b/src/api/tauri/externalHistory/resume.ts new file mode 100644 index 0000000000..0fce7dc3bf --- /dev/null +++ b/src/api/tauri/externalHistory/resume.ts @@ -0,0 +1,44 @@ +import { invoke } from "@tauri-apps/api/core"; + +/** + * Backend plan for reopening an imported external session in the CLI that + * owns it (`claude --resume`, `codex resume`, `cursor-agent --resume`). + * Mirrors `ExternalHistoryCliResumePlanWire` in + * `src-tauri/src/orgtrack/history_commands.rs` (camelCase JSON). + */ +export interface ExternalHistoryCliResumePlan { + /** Imported-history source id (`claude_code` / `codex_app` / `cursor_cli`). */ + source: string; + /** `code_sessions.cli_agent_type` of the owning CLI (launch-profile key). */ + cliAgentType: string; + /** Bare binary to run when the CLI registry has no detected command. */ + defaultBinary: string; + /** Arguments appended after the binary to reopen the session. */ + resumeArgs: string[]; + /** The session id the CLI itself accepts. */ + nativeSessionId: string; + /** Recorded workspace directory of the session, when known. */ + cwd: string | null; + /** Whether the CLI can only locate the session from its original cwd. */ + requiresCwd: boolean; + /** Human-readable `binary args…` string for tooltips/copy. */ + displayCommand: string; + /** Whether `cwd` still exists as a directory on this machine. */ + cwdExists: boolean; + /** Whether the source transcript/store behind the import is still on disk. */ + sourceAvailable: boolean; +} + +/** + * `null` when the session is unknown to the imported-history cache, is a + * subagent child, or its source has no CLI resume path (e.g. Cursor IDE + * composers — no CLI can reopen those). + */ +export async function externalHistoryCliResumePlan( + sessionId: string +): Promise { + return invoke( + "external_history_cli_resume_plan", + { sessionId } + ); +} diff --git a/src/engines/ChatPanel/SessionContinueCliHeaderExtras.tsx b/src/engines/ChatPanel/SessionContinueCliHeaderExtras.tsx new file mode 100644 index 0000000000..d25fbc1416 --- /dev/null +++ b/src/engines/ChatPanel/SessionContinueCliHeaderExtras.tsx @@ -0,0 +1,227 @@ +import { TerminalSquare } from "lucide-react"; +import React, { useCallback, useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { loadAvailableAgents } from "@src/api/services/availableAgents"; +import { + appendCliCommandArgs, + cliAgentCreateTuiSession, + resolveCliTuiCommand, +} from "@src/api/tauri/agent/cliTerminalSession"; +import { + type ExternalHistoryCliResumePlan, + externalHistoryCliResumePlan, +} from "@src/api/tauri/externalHistory"; +import type { CliAgentType } from "@src/api/types/keys"; +import Button from "@src/components/Button"; +import Message from "@src/components/Message"; +import Tooltip from "@src/components/Tooltip"; +import type { AvailableAgent } from "@src/config/cliAgents"; +import type { ChatPanelCliTerminalLaunchOptions } from "@src/engines/ChatPanel/types"; +import { createLogger } from "@src/hooks/logger"; +import type { Session } from "@src/store/session/sessionAtom/types"; +import { isImportedHistorySession } from "@src/util/session/sessionDispatch"; + +const log = createLogger("ChatPanel"); + +/** Header fallbacks when the CLI registry has not answered (or errored). */ +const AGENT_DISPLAY_FALLBACKS: Record = { + claude_code: "Claude Code", + codex: "Codex", + cursor_cli: "Cursor CLI", +}; + +function deriveExpectedProcess(command: string): string | undefined { + const [binary] = command.trim().split(/\s+/); + return binary || undefined; +} + +export interface SessionContinueCliHeaderExtrasProps { + session: Session | null; + sessionId: string | null; + onOpenCliTerminal?: (options: ChatPanelCliTerminalLaunchOptions) => void; +} + +/** + * "Continue in " header action for imported external sessions. + * + * The imported transcript stays read-only inside ORGII, but the CLI that + * wrote it can reopen the very conversation (`claude --resume`, + * `codex resume`, `cursor-agent --resume`). This button hands the session + * back to that CLI inside a chat-panel terminal tab, backed by a managed + * TUI session row so worktree cwd, live status, and the managed-mirror + * dedup behave exactly like a GUI-launched CLI session. + */ +const SessionContinueCliHeaderExtras: React.FC< + SessionContinueCliHeaderExtrasProps +> = ({ session, sessionId, onOpenCliTerminal }) => { + const { t } = useTranslation("navigation"); + const [plan, setPlan] = useState(null); + const [agent, setAgent] = useState(undefined); + const [agentKnown, setAgentKnown] = useState(false); + const [launching, setLaunching] = useState(false); + + const isImported = Boolean(sessionId && isImportedHistorySession(sessionId)); + + useEffect(() => { + setPlan(null); + if (!sessionId || !isImported) return undefined; + let cancelled = false; + externalHistoryCliResumePlan(sessionId) + .then((result) => { + if (!cancelled) setPlan(result); + }) + .catch((error) => { + log.warn("external CLI resume plan failed", error); + }); + return () => { + cancelled = true; + }; + }, [sessionId, isImported]); + + useEffect(() => { + setAgent(undefined); + setAgentKnown(false); + if (!plan) return undefined; + let cancelled = false; + loadAvailableAgents() + .then((agents) => { + if (cancelled) return; + setAgent(agents.find((entry) => entry.name === plan.cliAgentType)); + setAgentKnown(true); + }) + .catch((error) => { + // Unknown availability degrades to "try it": the terminal itself + // surfaces a command-not-found, which is more actionable than a + // silently missing button. + log.warn("CLI registry load failed", error); + }); + return () => { + cancelled = true; + }; + }, [plan]); + + const agentDisplayName = useMemo(() => { + if (!plan) return ""; + return ( + agent?.displayName ?? + AGENT_DISPLAY_FALLBACKS[plan.cliAgentType] ?? + plan.defaultBinary + ); + }, [agent, plan]); + + const disabledReason = useMemo(() => { + if (!plan) return null; + if (agentKnown && agent && !agent.installed) { + return t("collaboration.continueCli.notInstalled", { + agent: agentDisplayName, + }); + } + if (!plan.sourceAvailable) { + return t("collaboration.continueCli.sourceMissing"); + } + if (plan.requiresCwd && (!plan.cwd || !plan.cwdExists)) { + return t("collaboration.continueCli.missingWorkspace", { + agent: agentDisplayName, + }); + } + return null; + }, [agent, agentDisplayName, agentKnown, plan, t]); + + const handleContinue = useCallback(async (): Promise => { + if (!plan || !onOpenCliTerminal || launching) return; + setLaunching(true); + try { + const cliAgentType = plan.cliAgentType as CliAgentType; + const detectedCommand = agent?.command.trim() || plan.defaultBinary; + const baseCommand = await resolveCliTuiCommand( + cliAgentType, + detectedCommand + ); + const command = appendCliCommandArgs(baseCommand, plan.resumeArgs); + const cwd = plan.cwd && plan.cwdExists ? plan.cwd : undefined; + const title = session?.name || agentDisplayName; + // Back the terminal with a managed session row so lifecycle hooks can + // attribute status/transcripts via ORGII_SESSION_ID and the imported + // twin dedupes through the managed mirror. Creation failure degrades + // to an unbound terminal rather than blocking the resume. + let agentSessionId: string | undefined; + try { + const created = await cliAgentCreateTuiSession({ + platform: cliAgentType, + name: title, + repoPath: cwd, + }); + agentSessionId = created.sessionId; + } catch (error) { + log.warn( + "TUI session create failed; opening unbound resume terminal", + error + ); + } + onOpenCliTerminal({ + cliAgentType, + command, + title, + cwd, + agentSessionId, + expectedProcess: deriveExpectedProcess(baseCommand), + }); + } catch (error) { + log.error("failed to continue imported session in its CLI", error); + Message.error( + t("collaboration.continueCli.launchFailed", { + agent: agentDisplayName, + }) + ); + } finally { + setLaunching(false); + } + }, [ + agent, + agentDisplayName, + launching, + onOpenCliTerminal, + plan, + session?.name, + t, + ]); + + if (!isImported || !plan || !onOpenCliTerminal) return null; + + const continueLabel = t("collaboration.continueCli.headerButton", { + agent: agentDisplayName, + }); + const tooltipContent = + disabledReason ?? + t("collaboration.continueCli.headerTooltip", { + agent: agentDisplayName, + command: plan.displayCommand, + }); + + return ( + + +