diff --git a/package-lock.json b/package-lock.json index c0a6020..7cc0ad4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -22784,6 +22784,111 @@ "peerDependencies": { "react": "^19.2.4" } + }, + "web/node_modules/@next/swc-darwin-arm64": { + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.9.tgz", + "integrity": "sha512-HkfxNYUCmcct0Xsqib5KxqMSHV4AHJq857BNRchyBDs4YS19aHzVfn1kDuBYKqLLQBjXgnkIsjV2Kd4d2wzYhw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "web/node_modules/@next/swc-darwin-x64": { + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.9.tgz", + "integrity": "sha512-7IAtK4MeybpqRV9GRABWEhJ62mOS+rzWOzOTFie4cSEtm12xsoOMJRcECoZx3FHPzFAqN/IJtHqWAFOLfl152w==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "web/node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.9.tgz", + "integrity": "sha512-hBD75iWpUtkL9SmQmcRhmLomn9jgkPzCEkbOcLgHymPEKzv+6ONy13RRiIEz/iEObjkS2Jlb5gYS2XGoS3X4rw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "web/node_modules/@next/swc-linux-arm64-musl": { + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.9.tgz", + "integrity": "sha512-qZTI3pf9SGc/obr8NkQAekBxmp1QK+kVm+VAf3BALLfFAj+1kUhkTxmrWpVos9R/UYIA8AWX2p6cGI5WdwzVUA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "web/node_modules/@next/swc-linux-x64-gnu": { + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.9.tgz", + "integrity": "sha512-xm0HfRNX+UkH4R3c18ynswjj5o5uEj/7iI9p9omdtTSIsRCzQqkGMA+10nzJ4EHnYC3as65IMhbbl5fWRUWHYg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "web/node_modules/@next/swc-linux-x64-musl": { + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.9.tgz", + "integrity": "sha512-QumimHkGEG6vM3PfEDWKyKen03NcqLOkeKB1EfcPe7VxzmEiCa4jNnMyBn/US5zcd/VE1CI+O8Ovb3lfjVHfGw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "web/node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.2.9", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.9.tgz", + "integrity": "sha512-hzQpKZvw8rAwI6A2uQh6SacCSvNAXaIkPNsWwzqqfRiIMiXMfH936skDhz1OO6KpvdKkJrgHHtqQOq5PIXOvdQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } } } } diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index c20e1e6..2e0665a 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -4010,7 +4010,7 @@ dependencies = [ [[package]] name = "tempest" -version = "0.1.3" +version = "0.1.4" dependencies = [ "dashmap", "hephaestus", diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 85ad8a9..86e2b89 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -653,26 +653,549 @@ fn git_ls_files(path: String) -> Result, String> { .collect()) } -#[tauri::command] -fn read_runtime_state(app: tauri::AppHandle) -> Result { +// ── SQLite ─────────────────────────────────────────────────────────────────── + +const DB_SCHEMA: &str = " +PRAGMA journal_mode = WAL; +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS projects ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + path TEXT NOT NULL UNIQUE, + expanded INTEGER NOT NULL DEFAULT 1, + worktree_order TEXT, -- JSON array of worktree paths (user drag order) + atlas_indexed INTEGER NOT NULL DEFAULT 0, -- Token Intelligence index decision + context_tokens INTEGER, -- last known chat input-token count + system_prompt TEXT, -- user's custom chat system prompt + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + last_opened_at TEXT, + archived_at TEXT +); + +CREATE TABLE IF NOT EXISTS branches ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + name TEXT NOT NULL, + path TEXT NOT NULL UNIQUE, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + last_opened_at TEXT, + UNIQUE(project_id, name) +); +CREATE INDEX IF NOT EXISTS idx_branches_project_id ON branches(project_id); + +CREATE TABLE IF NOT EXISTS sessions ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + branch_id TEXT REFERENCES branches(id) ON DELETE CASCADE, + parent_session_id TEXT REFERENCES sessions(id) ON DELETE SET NULL, + name TEXT NOT NULL, + agent TEXT, + conversation_id TEXT, + no_git INTEGER NOT NULL DEFAULT 0, + state TEXT NOT NULL DEFAULT 'ACTIVE' CHECK(state IN ('ACTIVE', 'CLOSED')), + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + last_active_at TEXT, + archived_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_sessions_project_state ON sessions(project_id, state); +CREATE INDEX IF NOT EXISTS idx_sessions_branch_id ON sessions(branch_id); +CREATE INDEX IF NOT EXISTS idx_sessions_parent_id ON sessions(parent_session_id); + +CREATE TABLE IF NOT EXISTS layouts ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL UNIQUE REFERENCES projects(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')), + updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); + +CREATE TABLE IF NOT EXISTS layout_nodes ( + id TEXT PRIMARY KEY, + layout_id TEXT NOT NULL REFERENCES layouts(id) ON DELETE CASCADE, + parent_id TEXT REFERENCES layout_nodes(id) ON DELETE CASCADE, + type TEXT NOT NULL CHECK(type IN ('SPLIT', 'SESSION')), + direction TEXT CHECK(direction IN ('HORIZONTAL', 'VERTICAL')), + session_id TEXT REFERENCES sessions(id) ON DELETE CASCADE, + order_index INTEGER NOT NULL, + size REAL, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); +CREATE INDEX IF NOT EXISTS idx_layout_nodes_layout_id ON layout_nodes(layout_id); +CREATE INDEX IF NOT EXISTS idx_layout_nodes_session_id ON layout_nodes(session_id); + +CREATE TABLE IF NOT EXISTS recents ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + path TEXT NOT NULL UNIQUE, + last_opened TEXT NOT NULL +); + +CREATE TABLE IF NOT EXISTS tabs ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + kind TEXT NOT NULL, -- diff | preview | editor | chat + cwd TEXT NOT NULL DEFAULT '', + name TEXT NOT NULL, + preview_url TEXT, + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); +CREATE INDEX IF NOT EXISTS idx_tabs_project_id ON tabs(project_id); + +CREATE TABLE IF NOT EXISTS chat_messages ( + id TEXT PRIMARY KEY, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + role TEXT NOT NULL, -- user | assistant + parts TEXT NOT NULL, -- JSON MessagePart[] + seq INTEGER NOT NULL, -- order within a project's conversation + created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) +); +CREATE INDEX IF NOT EXISTS idx_chat_messages_project ON chat_messages(project_id, seq); + +CREATE TABLE IF NOT EXISTS app_state ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL -- JSON-encoded preference value +); +"; + +fn init_db(handle: &tauri::AppHandle) -> Result { use tauri::Manager; - let dir = app.path().app_data_dir().map_err(|e| e.to_string())?; - let file = dir.join("runtime-state.json"); - if !file.exists() { - return Ok("{}".to_string()); + let dir = handle.path().app_data_dir().map_err(|e| e.to_string())?; + std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; + let conn = rusqlite::Connection::open(dir.join("tempest.db")).map_err(|e| e.to_string())?; + conn.execute_batch(DB_SCHEMA).map_err(|e| e.to_string())?; + Ok(conn) +} + +// ── SQLite: session persistence commands ───────────────────────────────────── +// Phase 1 of the JSON→SQLite migration (docs/session-migration-to-sql.md). +// The frontend keeps a synchronous in-memory mirror (src/store/sessions.ts) and +// hydrates it once via `db_load`; every mutation writes through these commands. + +#[derive(serde::Serialize, serde::Deserialize, Clone)] +pub struct DbProject { + pub id: String, + pub name: String, + pub path: String, + pub expanded: bool, + #[serde(rename = "worktreeOrder")] + pub worktree_order: Option, // JSON array + #[serde(rename = "atlasIndexed")] + pub atlas_indexed: bool, + #[serde(rename = "contextTokens")] + pub context_tokens: Option, + #[serde(rename = "systemPrompt")] + pub system_prompt: Option, +} + +#[derive(serde::Serialize, serde::Deserialize, Clone)] +pub struct DbBranch { + pub id: String, + #[serde(rename = "projectId")] + pub project_id: String, + pub name: String, + pub path: String, +} + +#[derive(serde::Serialize, serde::Deserialize, Clone)] +pub struct DbSession { + pub id: String, + #[serde(rename = "projectId")] + pub project_id: String, + #[serde(rename = "branchId")] + pub branch_id: Option, + #[serde(rename = "parentSessionId")] + pub parent_session_id: Option, + pub name: String, + pub agent: Option, + #[serde(rename = "conversationId")] + pub conversation_id: Option, + #[serde(rename = "noGit")] + pub no_git: bool, + pub closed: bool, +} + +#[derive(serde::Serialize)] +pub struct DbSnapshot { + pub projects: Vec, + pub branches: Vec, + pub sessions: Vec, +} + +// Read the entire persisted graph for the in-memory mirror. Archived rows are +// excluded (archiving is a future phase; the column exists but is never set yet). +#[tauri::command] +fn db_load(state: tauri::State) -> Result { + let conn = state.0.lock().map_err(|e| e.to_string())?; + + let projects = conn + .prepare( + "SELECT id, name, path, expanded, worktree_order, atlas_indexed, context_tokens, system_prompt \ + FROM projects WHERE archived_at IS NULL", + ) + .and_then(|mut stmt| { + stmt.query_map([], |r| { + Ok(DbProject { + id: r.get(0)?, + name: r.get(1)?, + path: r.get(2)?, + expanded: r.get(3)?, + worktree_order: r.get(4)?, + atlas_indexed: r.get(5)?, + context_tokens: r.get(6)?, + system_prompt: r.get(7)?, + }) + })? + .collect::, _>>() + }) + .map_err(|e| e.to_string())?; + + let branches = conn + .prepare("SELECT id, project_id, name, path FROM branches") + .and_then(|mut stmt| { + stmt.query_map([], |r| { + Ok(DbBranch { id: r.get(0)?, project_id: r.get(1)?, name: r.get(2)?, path: r.get(3)? }) + })? + .collect::, _>>() + }) + .map_err(|e| e.to_string())?; + + let sessions = conn + .prepare( + "SELECT id, project_id, branch_id, parent_session_id, name, agent, conversation_id, no_git, state \ + FROM sessions WHERE archived_at IS NULL", + ) + .and_then(|mut stmt| { + stmt.query_map([], |r| { + Ok(DbSession { + id: r.get(0)?, + project_id: r.get(1)?, + branch_id: r.get(2)?, + parent_session_id: r.get(3)?, + name: r.get(4)?, + agent: r.get(5)?, + conversation_id: r.get(6)?, + no_git: r.get(7)?, + closed: r.get::<_, String>(8)? == "CLOSED", + }) + })? + .collect::, _>>() + }) + .map_err(|e| e.to_string())?; + + Ok(DbSnapshot { projects, branches, sessions }) +} + +// All writes use `ON CONFLICT(id) DO UPDATE` — never `INSERT OR REPLACE`, which +// would delete-then-reinsert the row and fire ON DELETE cascades on children +// (nulling sub-sessions' parent_session_id, or wiping a project's sessions). + +// Create a project row if absent, never clobbering fields owned by the projects +// store (expanded, worktree_order, …). Used by the session write path to +// guarantee the FK parent exists regardless of write ordering. +#[tauri::command] +fn db_ensure_project(state: tauri::State, id: String, name: String, path: String) -> Result<(), String> { + let conn = state.0.lock().map_err(|e| e.to_string())?; + conn.execute( + "INSERT INTO projects (id, name, path) VALUES (?1, ?2, ?3) ON CONFLICT(id) DO NOTHING", + rusqlite::params![id, name, path], + ) + .map_err(|e| e.to_string())?; + Ok(()) +} + +// Full project upsert owned by the projects store. +#[tauri::command] +fn db_upsert_project(state: tauri::State, project: DbProject) -> Result<(), String> { + let conn = state.0.lock().map_err(|e| e.to_string())?; + // atlas_indexed / context_tokens / system_prompt are intentionally NOT written + // here — they are owned by the atlas decision and chat stores respectively, and + // this list-level upsert must not reset them. + conn.execute( + "INSERT INTO projects (id, name, path, expanded, worktree_order, last_opened_at) \ + VALUES (?1, ?2, ?3, ?4, ?5, strftime('%Y-%m-%dT%H:%M:%fZ', 'now')) \ + ON CONFLICT(id) DO UPDATE SET name = excluded.name, path = excluded.path, \ + expanded = excluded.expanded, worktree_order = excluded.worktree_order, \ + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')", + rusqlite::params![ + project.id, project.name, project.path, + project.expanded, project.worktree_order, + ], + ) + .map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +fn db_set_project_atlas_indexed(state: tauri::State, id: String, indexed: bool) -> Result<(), String> { + let conn = state.0.lock().map_err(|e| e.to_string())?; + conn.execute("UPDATE projects SET atlas_indexed = ?2 WHERE id = ?1", rusqlite::params![id, indexed]) + .map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +fn db_set_project_context_tokens(state: tauri::State, id: String, tokens: Option) -> Result<(), String> { + let conn = state.0.lock().map_err(|e| e.to_string())?; + conn.execute("UPDATE projects SET context_tokens = ?2 WHERE id = ?1", rusqlite::params![id, tokens]) + .map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +fn db_set_project_system_prompt(state: tauri::State, id: String, prompt: Option) -> Result<(), String> { + let conn = state.0.lock().map_err(|e| e.to_string())?; + conn.execute("UPDATE projects SET system_prompt = ?2 WHERE id = ?1", rusqlite::params![id, prompt]) + .map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +fn db_upsert_branch(state: tauri::State, branch: DbBranch) -> Result<(), String> { + let conn = state.0.lock().map_err(|e| e.to_string())?; + conn.execute( + "INSERT INTO branches (id, project_id, name, path) VALUES (?1, ?2, ?3, ?4) \ + ON CONFLICT(id) DO UPDATE SET project_id = excluded.project_id, name = excluded.name, \ + path = excluded.path, updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')", + rusqlite::params![branch.id, branch.project_id, branch.name, branch.path], + ) + .map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +fn db_upsert_session(state: tauri::State, session: DbSession) -> Result<(), String> { + let conn = state.0.lock().map_err(|e| e.to_string())?; + let db_state = if session.closed { "CLOSED" } else { "ACTIVE" }; + conn.execute( + "INSERT INTO sessions \ + (id, project_id, branch_id, parent_session_id, name, agent, conversation_id, no_git, state) \ + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) \ + ON CONFLICT(id) DO UPDATE SET \ + project_id = excluded.project_id, branch_id = excluded.branch_id, \ + parent_session_id = excluded.parent_session_id, name = excluded.name, \ + agent = excluded.agent, conversation_id = excluded.conversation_id, \ + no_git = excluded.no_git, state = excluded.state, \ + updated_at = strftime('%Y-%m-%dT%H:%M:%fZ', 'now')", + rusqlite::params![ + session.id, + session.project_id, + session.branch_id, + session.parent_session_id, + session.name, + session.agent, + session.conversation_id, + session.no_git, + db_state, + ], + ) + .map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +fn db_delete_session(state: tauri::State, id: String) -> Result<(), String> { + let conn = state.0.lock().map_err(|e| e.to_string())?; + conn.execute("DELETE FROM sessions WHERE id = ?1", rusqlite::params![id]) + .map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +fn db_delete_branch(state: tauri::State, id: String) -> Result<(), String> { + let conn = state.0.lock().map_err(|e| e.to_string())?; + conn.execute("DELETE FROM branches WHERE id = ?1", rusqlite::params![id]) + .map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +fn db_delete_project(state: tauri::State, id: String) -> Result<(), String> { + let conn = state.0.lock().map_err(|e| e.to_string())?; + conn.execute("DELETE FROM projects WHERE id = ?1", rusqlite::params![id]) + .map_err(|e| e.to_string())?; + Ok(()) +} + +// Delete every session whose id is not in `valid_ids` (the startup orphan sweep). +// An empty list clears all sessions, matching `DELETE FROM sessions`. +#[tauri::command] +fn db_prune_sessions(state: tauri::State, valid_ids: Vec) -> Result<(), String> { + let conn = state.0.lock().map_err(|e| e.to_string())?; + if valid_ids.is_empty() { + conn.execute("DELETE FROM sessions", []).map_err(|e| e.to_string())?; + return Ok(()); } - std::fs::read_to_string(&file).map_err(|e| e.to_string()) + let placeholders = std::iter::repeat("?").take(valid_ids.len()).collect::>().join(","); + let sql = format!("DELETE FROM sessions WHERE id NOT IN ({placeholders})"); + conn.execute( + &sql, + rusqlite::params_from_iter(valid_ids.iter()), + ) + .map_err(|e| e.to_string())?; + Ok(()) +} + +// ── Recents ────────────────────────────────────────────────────────────────── + +#[derive(serde::Serialize, serde::Deserialize)] +pub struct DbRecent { + pub id: String, + pub name: String, + pub path: String, + #[serde(rename = "lastOpened")] + pub last_opened: String, } #[tauri::command] -fn write_runtime_state(app: tauri::AppHandle, data: String) -> Result<(), String> { - use tauri::Manager; - let dir = app.path().app_data_dir().map_err(|e| e.to_string())?; - std::fs::create_dir_all(&dir).map_err(|e| e.to_string())?; - let file = dir.join("runtime-state.json"); - let tmp = dir.join("runtime-state.json.tmp"); - std::fs::write(&tmp, data.as_bytes()).map_err(|e| e.to_string())?; - std::fs::rename(&tmp, &file).map_err(|e| e.to_string())?; +fn db_load_recents(state: tauri::State) -> Result, String> { + let conn = state.0.lock().map_err(|e| e.to_string())?; + conn.prepare("SELECT id, name, path, last_opened FROM recents ORDER BY last_opened DESC") + .and_then(|mut stmt| { + stmt.query_map([], |r| { + Ok(DbRecent { id: r.get(0)?, name: r.get(1)?, path: r.get(2)?, last_opened: r.get(3)? }) + })? + .collect::, _>>() + }) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +fn db_upsert_recent(state: tauri::State, recent: DbRecent) -> Result<(), String> { + let conn = state.0.lock().map_err(|e| e.to_string())?; + conn.execute( + "INSERT INTO recents (id, name, path, last_opened) VALUES (?1, ?2, ?3, ?4) \ + ON CONFLICT(path) DO UPDATE SET name = excluded.name, last_opened = excluded.last_opened", + rusqlite::params![recent.id, recent.name, recent.path, recent.last_opened], + ) + .map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +fn db_delete_recent(state: tauri::State, path: String) -> Result<(), String> { + let conn = state.0.lock().map_err(|e| e.to_string())?; + conn.execute("DELETE FROM recents WHERE path = ?1", rusqlite::params![path]) + .map_err(|e| e.to_string())?; + Ok(()) +} + +// ── Tabs (non-terminal: diff / preview / editor / chat) ────────────────────── + +#[derive(serde::Serialize, serde::Deserialize)] +pub struct DbTab { + pub id: String, + #[serde(rename = "projectId")] + pub project_id: String, + pub kind: String, + pub cwd: String, + pub name: String, + #[serde(rename = "previewUrl")] + pub preview_url: Option, +} + +#[tauri::command] +fn db_load_tabs(state: tauri::State) -> Result, String> { + let conn = state.0.lock().map_err(|e| e.to_string())?; + conn.prepare("SELECT id, project_id, kind, cwd, name, preview_url FROM tabs ORDER BY created_at") + .and_then(|mut stmt| { + stmt.query_map([], |r| { + Ok(DbTab { + id: r.get(0)?, project_id: r.get(1)?, kind: r.get(2)?, + cwd: r.get(3)?, name: r.get(4)?, preview_url: r.get(5)?, + }) + })? + .collect::, _>>() + }) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +fn db_upsert_tab(state: tauri::State, tab: DbTab) -> Result<(), String> { + let conn = state.0.lock().map_err(|e| e.to_string())?; + conn.execute( + "INSERT INTO tabs (id, project_id, kind, cwd, name, preview_url) VALUES (?1, ?2, ?3, ?4, ?5, ?6) \ + ON CONFLICT(id) DO UPDATE SET project_id = excluded.project_id, kind = excluded.kind, \ + cwd = excluded.cwd, name = excluded.name, preview_url = excluded.preview_url", + rusqlite::params![tab.id, tab.project_id, tab.kind, tab.cwd, tab.name, tab.preview_url], + ) + .map_err(|e| e.to_string())?; + Ok(()) +} + +#[tauri::command] +fn db_delete_tab(state: tauri::State, id: String) -> Result<(), String> { + let conn = state.0.lock().map_err(|e| e.to_string())?; + conn.execute("DELETE FROM tabs WHERE id = ?1", rusqlite::params![id]) + .map_err(|e| e.to_string())?; + Ok(()) +} + +// ── App state (key/value preferences) ──────────────────────────────────────── + +#[tauri::command] +fn db_load_app_state(state: tauri::State) -> Result, String> { + let conn = state.0.lock().map_err(|e| e.to_string())?; + conn.prepare("SELECT key, value FROM app_state") + .and_then(|mut stmt| { + stmt.query_map([], |r| Ok((r.get(0)?, r.get(1)?)))? + .collect::, _>>() + }) + .map_err(|e| e.to_string()) +} + +#[tauri::command] +fn db_set_app_state(state: tauri::State, key: String, value: String) -> Result<(), String> { + let conn = state.0.lock().map_err(|e| e.to_string())?; + conn.execute( + "INSERT INTO app_state (key, value) VALUES (?1, ?2) \ + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + rusqlite::params![key, value], + ) + .map_err(|e| e.to_string())?; + Ok(()) +} + +// ── Chat history ───────────────────────────────────────────────────────────── + +#[derive(serde::Serialize, serde::Deserialize)] +pub struct DbChatMessage { + pub id: String, + pub role: String, + pub parts: String, // JSON MessagePart[] +} + +#[tauri::command] +fn db_load_chat(state: tauri::State, project_id: String) -> Result, String> { + let conn = state.0.lock().map_err(|e| e.to_string())?; + conn.prepare("SELECT id, role, parts FROM chat_messages WHERE project_id = ?1 ORDER BY seq") + .and_then(|mut stmt| { + stmt.query_map(rusqlite::params![project_id], |r| { + Ok(DbChatMessage { id: r.get(0)?, role: r.get(1)?, parts: r.get(2)? }) + })? + .collect::, _>>() + }) + .map_err(|e| e.to_string()) +} + +// Replace a project's entire conversation in one transaction (matches the +// save-whole-array semantics of the chat store). +#[tauri::command] +fn db_replace_chat(state: tauri::State, project_id: String, messages: Vec) -> Result<(), String> { + let mut conn = state.0.lock().map_err(|e| e.to_string())?; + let tx = conn.transaction().map_err(|e| e.to_string())?; + tx.execute("DELETE FROM chat_messages WHERE project_id = ?1", rusqlite::params![project_id]) + .map_err(|e| e.to_string())?; + for (seq, m) in messages.iter().enumerate() { + tx.execute( + "INSERT INTO chat_messages (id, project_id, role, parts, seq) VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params![m.id, project_id, m.role, m.parts, seq as i64], + ) + .map_err(|e| e.to_string())?; + } + tx.commit().map_err(|e| e.to_string())?; Ok(()) } @@ -1844,6 +2367,8 @@ pub struct ZenState(pub Mutex>); +pub struct DbState(pub Mutex); + struct McpBridgeProcess { child: std::process::Child, writer: std::io::BufWriter, @@ -2566,6 +3091,9 @@ pub fn run() { if let Some(window) = app.get_webview_window("main") { let _ = window.set_icon(tauri::include_image!("icons/icon.png")); } + let conn = init_db(app.handle()) + .map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))?; + app.manage(DbState(Mutex::new(conn))); Ok(()) }) .invoke_handler(tauri::generate_handler![ @@ -2609,8 +3137,6 @@ pub fn run() { get_ide_panel_url, read_file, write_file, - read_runtime_state, - write_runtime_state, start_atlas_index, get_atlas_graph, atlas_query, @@ -2623,6 +3149,28 @@ pub fn run() { git_ls_files, check_program_available, git_numstat, + db_load, + db_ensure_project, + db_upsert_project, + db_set_project_atlas_indexed, + db_set_project_context_tokens, + db_set_project_system_prompt, + db_upsert_branch, + db_upsert_session, + db_delete_session, + db_delete_branch, + db_delete_project, + db_prune_sessions, + db_load_recents, + db_upsert_recent, + db_delete_recent, + db_load_tabs, + db_upsert_tab, + db_delete_tab, + db_load_app_state, + db_set_app_state, + db_load_chat, + db_replace_chat, ]) .build(tauri::generate_context!()) .expect("error while building tauri application") diff --git a/src/components/ChatPane.tsx b/src/components/ChatPane.tsx index 2fed0e1..f15b036 100644 --- a/src/components/ChatPane.tsx +++ b/src/components/ChatPane.tsx @@ -23,6 +23,10 @@ import { saveChatHistory, buildProjectContext, buildSystemPrompt, + getContextTokens, + setContextTokens as persistContextTokens, + getSystemPrompt, + setSystemPrompt as persistSystemPrompt, } from "../lib/chatHistory"; import tempestChat from "../assets/tempest-chat.png"; import "./ChatPane.css"; @@ -51,14 +55,13 @@ export function ChatPane({ hidden, projectPath, atlasIndexed, onLaunchAgent }: P const [isLoading, setIsLoading] = useState(false); const [provider, setProvider] = useState(() => { - const saved = localStorage.getItem("tempest-chat-provider"); + const saved = getRuntimeState().chatProvider; return CHAT_PROVIDERS.find(p => p.id === saved) ?? CHAT_PROVIDERS[0]; }); const [model, setModel] = useState(() => { - const savedProvider = localStorage.getItem("tempest-chat-provider"); - const savedModel = localStorage.getItem("tempest-chat-model"); - const models = PROVIDER_MODELS[savedProvider ?? "anthropic"] ?? []; - return models.find(m => m.id === savedModel) ?? PROVIDER_MODELS["anthropic"][0]; + const { chatProvider, chatModel } = getRuntimeState(); + const models = PROVIDER_MODELS[chatProvider ?? "anthropic"] ?? []; + return models.find(m => m.id === chatModel) ?? PROVIDER_MODELS["anthropic"][0]; }); const [pickerOpen, setPickerOpen] = useState(false); const [pickerPos, setPickerPos] = useState({ bottom: 0, left: 0 }); @@ -68,7 +71,7 @@ export function ChatPane({ hidden, projectPath, atlasIndexed, onLaunchAgent }: P const [contextTokens, setContextTokens] = useState(() => { const history = loadChatHistory(projectPath); if (history.length === 0) return 0; - return getRuntimeState().chatContextTokens[projectPath ?? ""] ?? 0; + return getContextTokens(projectPath); }); const [ctxPopupOpen, setCtxPopupOpen] = useState(false); @@ -76,9 +79,7 @@ export function ChatPane({ hidden, projectPath, atlasIndexed, onLaunchAgent }: P const projectContextRef = useRef(""); const systemPromptRef = useRef(""); - const [systemPrompt, setSystemPrompt] = useState(() => - projectPath ? getRuntimeState().chatSystemPrompts?.[projectPath] ?? "" : "" - ); + const [systemPrompt, setSystemPrompt] = useState(() => getSystemPrompt(projectPath)); const [settingsOpen, setSettingsOpen] = useState(false); const [settingsPos, setSettingsPos] = useState({ bottom: 0, left: 0 }); const settingsBtnRef = useRef(null); @@ -134,7 +135,7 @@ export function ChatPane({ hidden, projectPath, atlasIndexed, onLaunchAgent }: P }, [projectPath]); useEffect(() => { - setSystemPrompt(projectPath ? getRuntimeState().chatSystemPrompts?.[projectPath] ?? "" : ""); + setSystemPrompt(getSystemPrompt(projectPath)); }, [projectPath]); useEffect(() => { @@ -155,10 +156,7 @@ export function ChatPane({ hidden, projectPath, atlasIndexed, onLaunchAgent }: P setMessages([]); saveChatHistory(projectPath, []); setContextTokens(0); - if (projectPath) { - const st = getRuntimeState(); - setRuntimeState({ chatContextTokens: { ...st.chatContextTokens, [projectPath]: 0 } }); - } + persistContextTokens(projectPath, 0); } const send = useCallback(async (injectText?: string) => { @@ -282,10 +280,7 @@ export function ChatPane({ hidden, projectPath, atlasIndexed, onLaunchAgent }: P case "finish": { const used = event.inputTokens + event.outputTokens; setContextTokens(used); - if (projectPath) { - const st = getRuntimeState(); - setRuntimeState({ chatContextTokens: { ...st.chatContextTokens, [projectPath]: used } }); - } + persistContextTokens(projectPath, used); setIsLoading(false); streamingIdRef.current = null; saveChatHistory(projectPath, [ @@ -343,10 +338,7 @@ export function ChatPane({ hidden, projectPath, atlasIndexed, onLaunchAgent }: P function updateSystemPrompt(value: string) { setSystemPrompt(value); - if (projectPath) { - const st = getRuntimeState(); - setRuntimeState({ chatSystemPrompts: { ...st.chatSystemPrompts, [projectPath]: value } }); - } + persistSystemPrompt(projectPath, value); } function openSettings() { @@ -505,10 +497,10 @@ export function ChatPane({ hidden, projectPath, atlasIndexed, onLaunchAgent }: P const p = CHAT_PROVIDERS.find(cp => cp.id === pickerProvider); if (p) { setProvider(p); - localStorage.setItem("tempest-chat-provider", p.id); + setRuntimeState({ chatProvider: p.id }); } setModel(m); - localStorage.setItem("tempest-chat-model", m.id); + setRuntimeState({ chatModel: m.id }); setPickerOpen(false); } diff --git a/src/components/Tooltip.css b/src/components/Tooltip.css index 69d6e9b..4db6d78 100644 --- a/src/components/Tooltip.css +++ b/src/components/Tooltip.css @@ -9,7 +9,6 @@ } .tt-tip { - position: absolute; z-index: 9999; padding: 4px 8px; background: var(--tempest-fg-default); @@ -21,15 +20,8 @@ white-space: nowrap; pointer-events: none; user-select: none; - opacity: 0; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18); - transition: opacity 100ms ease; - transition-delay: 0ms; -} - -.tt:hover .tt-tip { - opacity: 1; - transition-delay: 420ms; + transition: opacity 80ms ease; } /* Arrow — rotated square clipped to a triangle */ @@ -43,52 +35,28 @@ pointer-events: none; } -/* top */ -[data-placement="top"] .tt-tip { - bottom: calc(100% + 7px); - left: 50%; - transform: translateX(-50%); -} -[data-placement="top"] .tt-tip::after { +.tt-tip[data-placement="top"]::after { top: 100%; left: 50%; transform: translate(-50%, -4px) rotate(45deg); clip-path: polygon(0% 50%, 50% 100%, 100% 50%, 100% 100%, 0% 100%); } -/* bottom */ -[data-placement="bottom"] .tt-tip { - top: calc(100% + 7px); - left: 50%; - transform: translateX(-50%); -} -[data-placement="bottom"] .tt-tip::after { +.tt-tip[data-placement="bottom"]::after { bottom: 100%; left: 50%; transform: translate(-50%, 4px) rotate(45deg); clip-path: polygon(0% 0%, 100% 0%, 100% 50%, 50% 0%, 0% 50%); } -/* left */ -[data-placement="left"] .tt-tip { - right: calc(100% + 7px); - top: 50%; - transform: translateY(-50%); -} -[data-placement="left"] .tt-tip::after { +.tt-tip[data-placement="left"]::after { left: 100%; top: 50%; transform: translate(-4px, -50%) rotate(45deg); clip-path: polygon(50% 0%, 100% 0%, 100% 100%, 50% 100%, 100% 50%); } -/* right */ -[data-placement="right"] .tt-tip { - left: calc(100% + 7px); - top: 50%; - transform: translateY(-50%); -} -[data-placement="right"] .tt-tip::after { +.tt-tip[data-placement="right"]::after { right: 100%; top: 50%; transform: translate(4px, -50%) rotate(45deg); diff --git a/src/components/Tooltip.tsx b/src/components/Tooltip.tsx index 4d5cc76..d1f5b43 100644 --- a/src/components/Tooltip.tsx +++ b/src/components/Tooltip.tsx @@ -1,4 +1,5 @@ -import { ReactNode } from "react"; +import { ReactNode, useRef, useState, useCallback, useLayoutEffect } from "react"; +import { createPortal } from "react-dom"; import "./Tooltip.css"; type Placement = "top" | "bottom" | "left" | "right"; @@ -10,12 +11,73 @@ interface TooltipProps { className?: string; } +function tipStyle(rect: DOMRect, p: Placement): React.CSSProperties { + const gap = 7; + switch (p) { + case "top": return { bottom: window.innerHeight - rect.top + gap, left: rect.left + rect.width / 2, transform: "translateX(-50%)", opacity: 0 }; + case "bottom": return { top: rect.bottom + gap, left: rect.left + rect.width / 2, transform: "translateX(-50%)", opacity: 0 }; + case "left": return { right: window.innerWidth - rect.left + gap, top: rect.top + rect.height / 2, transform: "translateY(-50%)", opacity: 0 }; + case "right": return { left: rect.right + gap, top: rect.top + rect.height / 2, transform: "translateY(-50%)", opacity: 0 }; + } +} + +function Tip({ content, placement, triggerRect }: { content: string; placement: Placement; triggerRect: DOMRect }) { + const ref = useRef(null); + + useLayoutEffect(() => { + const el = ref.current; + if (!el) return; + const t = el.getBoundingClientRect(); + const pad = 8; + let left = t.left; + let top = t.top; + if (t.right > window.innerWidth - pad) left = window.innerWidth - pad - t.width; + if (t.left < pad) left = pad; + if (t.bottom > window.innerHeight - pad) top = window.innerHeight - pad - t.height; + if (t.top < pad) top = pad; + el.style.left = `${left}px`; + el.style.top = `${top}px`; + el.style.right = ""; + el.style.bottom = ""; + el.style.transform = "none"; + el.style.opacity = "1"; + }, [triggerRect]); + + return ( +
+ {content} +
+ ); +} + export function Tooltip({ content, placement = "top", children, className }: TooltipProps) { if (!content) return <>{children}; + + const ref = useRef(null); + const timer = useRef | undefined>(undefined); + const [rect, setRect] = useState(null); + + const show = useCallback(() => { + timer.current = setTimeout(() => { + if (ref.current) setRect(ref.current.getBoundingClientRect()); + }, 420); + }, []); + + const hide = useCallback(() => { + clearTimeout(timer.current); + setRect(null); + }, []); + return ( -
+
{children} -
{content}
+ {rect && createPortal(, document.body)}
); } diff --git a/src/components/WorkspaceView.css b/src/components/WorkspaceView.css index 45cbd6f..f762c41 100644 --- a/src/components/WorkspaceView.css +++ b/src/components/WorkspaceView.css @@ -742,6 +742,16 @@ color: var(--tempest-fg-subtle); } +.projects-empty-box { + margin: 16px 12px; + padding: 20px 14px; + font-size: 13px; + color: var(--tempest-fg-subtle); + text-align: center; + border: 1.5px dashed var(--tempest-border-subtle); + border-radius: 8px; +} + /* Project switcher */ /* ── Bottom icon tray ─────────────────────────────────────────── */ @@ -2318,9 +2328,14 @@ } .overview-recents-empty { + display: block; + margin: 16px 12px; + padding: 20px 14px; font-size: 12px; color: var(--tempest-fg-subtle); - padding: 8px 10px; + text-align: center; + border: 1.5px dashed var(--tempest-border-subtle); + border-radius: 8px; } /* ─── Split pane slots ─────────────────────────────────────────────────────── */ diff --git a/src/components/WorkspaceView.tsx b/src/components/WorkspaceView.tsx index ad362b3..e2b27c9 100644 --- a/src/components/WorkspaceView.tsx +++ b/src/components/WorkspaceView.tsx @@ -7,8 +7,10 @@ import { openUrl } from "@tauri-apps/plugin-opener"; import { createWorktree, gitInit, NotAGitRepoError } from "../lib/worktree"; import { addRecent, getRecents, removeRecent } from "../store/recents"; import { getOpenProjects, saveOpenProjects } from "../store/openProjects"; -import { getWorktreeSession, saveWorktreeSession, removeWorktreeSession, markWorktreeSessionClosed, markWorktreeSessionOpen, pruneOrphanedSessions, dedupeRootSessions, rootSessionKey, rootSessionIdFromKey, getRootSessionsForProject, subSessionKey, subSessionIdFromKey, getSubSessionsForWorktree, termSessionKey, termSessionIdFromKey, getTermSessionsForWorktree, type WorktreeSession } from "../store/sessions"; -import { getRuntimeState, setRuntimeState, type PersistedTab } from "../lib/runtimeState"; +import { getSession, getBranchSessions, getWorktreeAgentSession, getRootSessionsForProject, getAllSessions, getBranchPath, getProjectPath, saveSession, setSessionConversationId, markSessionClosed, markSessionOpen, removeBranchByPath, pruneSessions, type WorktreeSession } from "../store/sessions"; +import { getRuntimeState, setRuntimeState } from "../lib/runtimeState"; +import { getTabs, upsertTab, removeTab, type PersistedTab } from "../store/tabs"; +import { saveChatHistory } from "../lib/chatHistory"; import type { BranchInfo } from "../types/git"; import type { Session, Worktree, Project, NavSection } from "../types/workspace"; import { DeleteWorkspaceDialog, type DeleteDialogState } from "./WorkspaceView/DeleteWorkspaceDialog"; @@ -191,7 +193,7 @@ export function WorkspaceView({ zen, name, path }: Props) { // Zen mode: flat worktree list for the single project const [zenWorktrees, setZenWorktrees] = useState([]); - // Default mode: multi-project state (persisted to localStorage) + // Default mode: multi-project state (persisted to SQLite via the projects store) const [projects, setProjects] = useState(() => zen ? [] : getOpenProjects().map((p) => ({ ...p, worktrees: [] })) ); @@ -225,7 +227,7 @@ export function WorkspaceView({ zen, name, path }: Props) { const [inlineCreateName, setInlineCreateName] = useState(""); - // Persist projects list to localStorage whenever it changes + // Persist projects list to SQLite whenever it changes useEffect(() => { if (zen) return; saveOpenProjects( @@ -257,32 +259,14 @@ export function WorkspaceView({ zen, name, path }: Props) { if (zen) return; async function restoreAll() { - // Phase 1 — discover which paths actually exist on disk across all projects. - const validPaths = new Set(); + // Discover which worktree directories / projects still exist on disk. + const diskWorktreePaths = new Set(); + const openProjectPaths = new Set(); const allProjectData: { project: Project; wts: { name: string; path: string }[] }[] = []; for (const project of projects) { + openProjectPaths.add(project.path); const wts: { name: string; path: string }[] = []; - // The project root is always a valid anchor for a persisted root session - // (agent root ghosts live only in sessions store keyed by project.path — they - // have no disk directory). This MUST be added unconditionally, outside the - // try below: a project that only ever had root sessions has no .tempest/ - // directory, so list_directory throws and — if this lived inside the try — - // project.path would be missing from validPaths and pruneOrphanedSessions - // would wipe the agent root ghost on the next restore cycle. - validPaths.add(project.path); - // One-shot migration: collapse duplicate agent root-session entries left behind - // by the old restore bug. Gated behind a flag so it never runs again once done. - if (!getRuntimeState().migrations["sessions-v2"]) { - dedupeRootSessions(project.path); - setRuntimeState({ migrations: { ...getRuntimeState().migrations, "sessions-v2": true } }); - } - // Root sessions (agent + terminal) live only in the sessions store under unique - // keys (project.path + "::root::" + sessionId) with no disk directory. Mark - // each as valid so pruneOrphanedSessions doesn't wipe them on restore. - for (const { key } of getRootSessionsForProject(project.path)) { - validPaths.add(key); - } try { const entries = await invoke<{ name: string; path: string; is_dir: boolean }[]>( "list_directory", @@ -290,16 +274,7 @@ export function WorkspaceView({ zen, name, path }: Props) { ); entries.filter((e) => e.is_dir && !TEMPEST_INTERNAL_DIRS.has(e.name)).forEach((e) => { wts.push({ name: e.name, path: e.path }); - validPaths.add(e.path); - // Sub-session keys are not disk paths — mark them valid so pruneOrphanedSessions - // doesn't wipe them when it scans for stale entries. - for (const { key } of getSubSessionsForWorktree(e.path)) { - validPaths.add(key); - } - // Worktree terminal keys are synthetic too — same treatment. - for (const { key } of getTermSessionsForWorktree(e.path)) { - validPaths.add(key); - } + diskWorktreePaths.add(e.path); }); } catch { // .tempest/ doesn't exist or project path is gone @@ -318,113 +293,63 @@ export function WorkspaceView({ zen, name, path }: Props) { ); } - // Phase 2 — remove session entries whose paths no longer exist on disk. - pruneOrphanedSessions(validPaths); + // Prune sessions whose branch directory (branch sessions) or project (root + // sessions) no longer exists. The DB is the source of truth for which + // sessions exist; the filesystem scan only confirms which are still anchored. + const validIds = new Set(); + for (const s of getAllSessions()) { + const branchPath = s.branchId ? getBranchPath(s.branchId) : undefined; + const ok = s.branchId + ? (branchPath !== undefined && diskWorktreePaths.has(branchPath)) + : openProjectPaths.has(getProjectPath(s.projectId) ?? ""); + if (ok) validIds.add(s.id); + } + pruneSessions(validIds); - // Phase 3 — collect every item to restore into a single sorted list. - // - // Sessions are opened in saved tab-bar order (sessionOrder) so the tab bar - // looks exactly as the user left it. The saved active session is opened last - // so the final setActiveSessionId call leaves it focused. Non-terminal tabs - // (diff, preview, editor) are included in the same ordered list. - const { sessionOrder, activeInstanceId: savedActiveId, tabs: savedTabs } = getRuntimeState(); + // Collect restore items in saved tab-bar order; active session opened last so + // its setActiveSessionId call wins and it is visually focused on launch. + const { sessionOrder, activeInstanceId: savedActiveId } = getRuntimeState(); + const savedTabs = getTabs(); const orderMap = new Map(sessionOrder.map((id, i) => [id, i])); + const openedIds = new Set(); - type RestoreItem = { - instanceId: string; - isActive: boolean; - sortIndex: number; - open: () => Promise; - }; + type RestoreItem = { id: string; isActive: boolean; sortIndex: number; open: () => Promise }; const items: RestoreItem[] = []; - // Sub-sessions are collected separately and restored after all parents (Phase 5). - type SubRestoreItem = { - projectId: string; - worktreePath: string; - parentMintId: string; - subKey: string; - subSaved: WorktreeSession; - isActive: boolean; - }; - const subItems: SubRestoreItem[] = []; - for (const { project, wts } of allProjectData) { - // Worktree sessions + // Primary (non-sub) sessions in each worktree — the branch's stable id is + // reused as providedSessionId so sub-sessions' parent_session_id resolves. for (const wt of wts) { - const saved = getWorktreeSession(wt.path); - if (!saved || saved.closed === true) continue; - // Pre-mint the session ID so sub-sessions can reference it as parentSessionId - // before React state has flushed the parent's open() call. - const parentMintId = crypto.randomUUID(); - const instanceId = saved.instanceId ?? wt.path; - items.push({ - instanceId, - isActive: instanceId === savedActiveId, - sortIndex: orderMap.get(instanceId) ?? Infinity, - open: () => openSession( - saved.name, wt.path, project.id, saved.agent, - undefined, undefined, - saved.agent ? saved.conversationId : undefined, - undefined, undefined, - true, // dedupe - parentMintId // providedSessionId — stable across this restore run - ).catch(() => {}), - }); - - // Persisted worktree terminals — each restores independently under its own termKey - for (const { key: termKey, session: termSaved } of getTermSessionsForWorktree(wt.path)) { - if (termSaved.closed === true) continue; - const termInstanceId = termSaved.instanceId ?? termKey; - const termId = termSessionIdFromKey(termKey); + for (const saved of getBranchSessions(wt.path)) { + if (saved.closed === true) continue; items.push({ - instanceId: termInstanceId, - isActive: termInstanceId === savedActiveId, - sortIndex: orderMap.get(termInstanceId) ?? Infinity, + id: saved.id, + isActive: saved.id === savedActiveId, + sortIndex: orderMap.get(saved.id) ?? Infinity, open: () => openSession( - termSaved.name, wt.path, project.id, undefined, - undefined, undefined, undefined, + saved.name, wt.path, project.id, saved.agent, undefined, undefined, - true, termId + saved.agent ? saved.conversationId : undefined, + undefined, undefined, + true, saved.id ).catch(() => {}), }); } - - // Collect persisted sub-sessions for Phase 5 - for (const { key: subKey, session: subSaved } of getSubSessionsForWorktree(wt.path)) { - if (subSaved.closed === true) continue; - const subInstanceId = subSaved.instanceId ?? subKey; - subItems.push({ - projectId: project.id, - worktreePath: wt.path, - parentMintId, - subKey, - subSaved, - isActive: subInstanceId === savedActiveId, - }); - } } - // Root sessions (agent + plain terminal in project root) - for (const { key: rootStoreKey, session: rootSaved } of getRootSessionsForProject(project.path)) { - if (rootSaved.closed === true) continue; - // Migrate: write instanceId to entries created before this field was introduced. - if (!rootSaved.instanceId) { - const inferredId = rootSessionIdFromKey(rootStoreKey); - if (inferredId) saveWorktreeSession(rootStoreKey, { ...rootSaved, instanceId: inferredId }); - } - const instanceId = rootSaved.instanceId ?? rootSessionIdFromKey(rootStoreKey); + // Root sessions (project root, no branch). + for (const saved of getRootSessionsForProject(project.path)) { + if (saved.closed === true) continue; items.push({ - instanceId, - isActive: instanceId === savedActiveId, - sortIndex: orderMap.get(instanceId) ?? Infinity, + id: saved.id, + isActive: saved.id === savedActiveId, + sortIndex: orderMap.get(saved.id) ?? Infinity, open: () => openSession( - rootSaved.name, project.path, project.id, rootSaved.agent, + saved.name, project.path, project.id, saved.agent, undefined, undefined, - rootSaved.agent ? rootSaved.conversationId : undefined, - true, rootSaved.noGit, - true, // dedupe - rootSessionIdFromKey(rootStoreKey) + saved.agent ? saved.conversationId : undefined, + true, saved.noGit, + true, saved.id ).catch(() => {}), }); } @@ -438,7 +363,7 @@ export function WorkspaceView({ zen, name, path }: Props) { for (const tab of savedTabs.filter((t) => openProjectIds.has(t.projectId) && t.kind !== "chat")) { const newId = crypto.randomUUID(); items.push({ - instanceId: tab.instanceId, + id: tab.instanceId, isActive: tab.instanceId === savedActiveId, sortIndex: orderMap.get(tab.instanceId) ?? Infinity, open: async () => { @@ -461,36 +386,46 @@ export function WorkspaceView({ zen, name, path }: Props) { }); } - // Sort: non-active sessions in saved order, active session unconditionally last - // so its setActiveSessionId call wins and it is visually focused on launch. items.sort((a, b) => { if (a.isActive !== b.isActive) return a.isActive ? 1 : -1; if (a.sortIndex !== b.sortIndex) return a.sortIndex - b.sortIndex; return 0; }); - // Phase 4 — open all main sessions in order. - for (const item of items) { - await item.open(); + // Open all top-level sessions in order. + for (const item of items) { await item.open(); openedIds.add(item.id); } + + // Sub-sessions: open once their parent is live. Loop to honor arbitrary + // nesting (a sub whose parent is itself a sub). Parents were opened with + // providedSessionId = their stable id, so parent_session_id resolves directly. + const pendingSubs = getAllSessions().filter( + (s) => s.parentSessionId && s.closed !== true && validIds.has(s.id) + ); + let progressed = true; + while (progressed && pendingSubs.length) { + progressed = false; + for (let i = pendingSubs.length - 1; i >= 0; i--) { + const sub = pendingSubs[i]; + if (!openedIds.has(sub.parentSessionId!)) continue; + const cwd = sub.branchId ? (getBranchPath(sub.branchId) ?? "") : (getProjectPath(sub.projectId) ?? ""); + await openSession( + sub.name, cwd, sub.projectId, sub.agent, + undefined, undefined, + sub.agent ? sub.conversationId : undefined, + !sub.branchId, sub.noGit, + false, sub.id, sub.parentSessionId + ).catch(() => {}); + openedIds.add(sub.id); + pendingSubs.splice(i, 1); + progressed = true; + } } - // Phase 5 — restore sub-sessions after all their parents are live. - // Sub-sessions are NOT sorted by sessionOrder: parents must always precede - // children, so we restore them in collection order. The active sub-session - // (if any) is opened last so setActiveSessionId ends on it. - const activeSubItems = subItems.filter((s) => s.isActive); - const inactiveSubItems = subItems.filter((s) => !s.isActive); - for (const { projectId, worktreePath, parentMintId, subKey, subSaved } of [...inactiveSubItems, ...activeSubItems]) { - const subId = subSessionIdFromKey(subKey); - await openSession( - subSaved.name, worktreePath, projectId, subSaved.agent, - undefined, undefined, - subSaved.agent ? subSaved.conversationId : undefined, - undefined, undefined, - false, // sub-sessions don't need dedupe — each has a unique subKey - subId, // providedSessionId — reuses the original UUID so instanceId stays stable - parentMintId // parentSessionId — links to the parent opened in Phase 4 - ).catch(() => {}); + // Opening sub-sessions moves focus; restore it to the saved active session + // when that is a real (persisted) session — tabs are already handled by the + // active-opened-last ordering above. + if (savedActiveId && getSession(savedActiveId) && openedIds.has(savedActiveId)) { + setActiveSessionId(savedActiveId); } } @@ -712,9 +647,9 @@ export function WorkspaceView({ zen, name, path }: Props) { if (projectSessions.some((s) => s.id === activeSessionId)) setActiveSessionId(null); const removedProject = projects.find((p) => p.id === projectId); setProjects((prev) => prev.filter((p) => p.id !== projectId)); - // Remove persisted non-terminal tabs for this project. - const st = getRuntimeState(); - setRuntimeState({ tabs: st.tabs.filter((t) => t.projectId !== projectId) }); + // Remove persisted non-terminal tabs for this project (project delete also + // cascades them in the DB; this keeps the in-memory mirror in sync). + for (const t of getTabs().filter((t) => t.projectId === projectId)) removeTab(t.instanceId); // Stop the atlas file-watcher daemon for this project if one is running. if (removedProject) { invoke("stop_atlas_daemon", { projectPath: removedProject.path }).catch(() => {}); @@ -764,7 +699,7 @@ export function WorkspaceView({ zen, name, path }: Props) { setSessions((prev) => prev.filter((s) => s.id !== sessionId)); if (activeSessionId === sessionId) setActiveSessionId(null); } - removeWorktreeSession(worktree.path); + removeBranchByPath(worktree.path); if (zen) { setZenWorktrees((prev) => prev.filter((w) => w.path !== worktree.path)); } else { @@ -826,38 +761,11 @@ export function WorkspaceView({ zen, name, path }: Props) { try { const sessionId = providedSessionId ?? crypto.randomUUID(); - - // The localStorage key this session persists under. - // Root sessions: per-session unique key (project.path + ::root:: + id) so multiple - // root sessions at the same cwd don't overwrite each other. - // Sub-sessions: unique key (worktree.path + ::sub:: + id) so multiple sub-sessions - // at the same cwd coexist and are restored independently of their parent. - // Main worktree agent sessions: keyed by cwd (stable across restarts). - // Main worktree terminal sessions: not persisted (no agent worth resuming). - const rootKey = isRootSession ? rootSessionKey(cwd, sessionId) : null; - const subKey = parentSessionId ? subSessionKey(cwd, sessionId) : null; - // Worktree terminals get a unique per-session termKey so multiple terminals - // at the same cwd coexist alongside the (cwd-keyed) agent entry — and each - // one persists independently across close/reopen for the sidebar ghost. - const termKey = (!rootKey && !subKey && !agent) ? termSessionKey(cwd, sessionId) : null; - // Worktree agents key by cwd so they persist/restore at a stable identity — but only - // ONE agent can own that slot. A second concurrent agent in the same worktree (branch - // "+" → agent) must NOT reuse cwd, or it clobbers the first's persisted entry and - // collides on instanceId, making the existing session look replaced. Give the extra one - // no store key: instanceId falls back to the unique sessionId and it isn't persisted. - // ponytail: additional worktree agents are session-scoped (no restart restore); add a - // per-session agent bucket like ::term:: if cross-restart persistence is wanted. - const cwdAgentSlotLive = - !!agent && !rootKey && !subKey && sessionsRef.current.some((s) => s.storeKey === cwd); - const agentKey = agent && !rootKey && !subKey && !cwdAgentSlotLive ? cwd : undefined; - const storeKey = rootKey ?? subKey ?? termKey ?? agentKey; - - // Worktree sessions: the store key (path) is the stable identity — reuse it so - // instanceId never changes across restarts. Root sessions have no path anchor, so - // the session UUID (stable via providedSessionId on resume) is the right choice. - const instanceId = isRootSession - ? (providedSessionId ?? sessionId) - : (storeKey ?? sessionId); + // The session id IS the stable cross-restart identity — persisted as the row + // primary key and reused via providedSessionId on resume. With row identity, + // multiple agents/terminals can coexist and persist in the same worktree + // (the old one-agent-per-cwd ceiling in §3.6 is gone). + const instanceId = sessionId; // Assemble the agent's full argument list (session/resume flags + prompt) here in // TypeScript so Rust receives a ready-to-run command. originalId is present only @@ -868,14 +776,12 @@ export function WorkspaceView({ zen, name, path }: Props) { const usesCapturePattern = !!(config?.capturePattern && config.captureResumeArgs); const conversationId = usesCapturePattern && !originalId ? undefined : (originalId ?? sessionId); - // Save metadata immediately after the PTY spawns so it survives tab close. - // Skip during restore (dedupe=true): the entry already exists and is correct. - // Sub-sessions are now included — they save under their unique subKey. - if (storeKey && !dedupe) { - saveWorktreeSession(storeKey, { - name: sessionName, agent, conversationId, instanceId, projectId, - isRootSession, noGit, - ...(parentSessionId ? { parentPath: cwd } : {}), + // Persist metadata immediately after the PTY spawns so it survives tab close. + // Skip during restore (dedupe=true): the row already exists in the mirror. + if (!dedupe) { + saveSession({ + id: sessionId, name: sessionName, agent, conversationId, projectId, + cwd, isRootSession, parentSessionId, noGit, }); } @@ -889,9 +795,7 @@ export function WorkspaceView({ zen, name, path }: Props) { const match = pattern.exec(data); if (match?.[1]) { const capturedId = match[1]; - const captureKey = storeKey ?? cwd; - const stored = getWorktreeSession(captureKey); - if (stored) saveWorktreeSession(captureKey, { ...stored, conversationId: capturedId }); + setSessionConversationId(sessionId, capturedId); setSessions((prev) => prev.map((s) => (s.id === sessionId ? { ...s, conversationId: capturedId } : s)) ); @@ -996,7 +900,6 @@ export function WorkspaceView({ zen, name, path }: Props) { noGit, sandboxed: shouldIsolate ? true : false, parentSessionId, - storeKey, createdAt: new Date().toISOString(), metadata: { resumeCount: 0, hasBeenResumed: false }, }; @@ -1019,8 +922,8 @@ export function WorkspaceView({ zen, name, path }: Props) { if (existing) { setActiveSessionId(existing.id); return; } const sessionId = crypto.randomUUID(); const tab: PersistedTab = { instanceId: sessionId, kind: "diff", projectId, cwd, name: "Diff" }; - const st = getRuntimeState(); - setRuntimeState({ tabs: [...st.tabs.filter((t) => !(t.kind === "diff" && t.cwd === cwd)), tab] }); + for (const t of getTabs().filter((t) => t.kind === "diff" && t.cwd === cwd)) removeTab(t.instanceId); + upsertTab(tab); setSessions((prev) => [...prev, { id: sessionId, instanceId: sessionId, name: "Diff", cwd, projectId, kind: "diff", initialDiffPath, @@ -1066,8 +969,7 @@ export function WorkspaceView({ zen, name, path }: Props) { function openPreviewTab(projectId: string) { const sessionId = crypto.randomUUID(); const tab: PersistedTab = { instanceId: sessionId, kind: "preview", projectId, cwd: "", name: "Live Preview" }; - const st = getRuntimeState(); - setRuntimeState({ tabs: [...st.tabs, tab] }); + upsertTab(tab); setSessions((prev) => [...prev, { id: sessionId, instanceId: sessionId, name: "Live Preview", cwd: "", projectId, kind: "preview", createdAt: new Date().toISOString(), @@ -1082,8 +984,8 @@ export function WorkspaceView({ zen, name, path }: Props) { const sessionId = crypto.randomUUID(); const fileName = filePath.replace(/\\/g, "/").split("/").pop() ?? filePath; const tab: PersistedTab = { instanceId: sessionId, kind: "editor", projectId, cwd: filePath, name: fileName }; - const st = getRuntimeState(); - setRuntimeState({ tabs: [...st.tabs.filter((t) => !(t.kind === "editor" && t.cwd === filePath)), tab] }); + for (const t of getTabs().filter((t) => t.kind === "editor" && t.cwd === filePath)) removeTab(t.instanceId); + upsertTab(tab); setSessions((prev) => [...prev, { id: sessionId, instanceId: sessionId, name: fileName, cwd: filePath, projectId, kind: "editor", createdAt: new Date().toISOString(), @@ -1098,16 +1000,15 @@ export function WorkspaceView({ zen, name, path }: Props) { // Reuse the existing PersistedTab's instanceId when the chat is a ghost (closed but // not removed). Without this, every click on the ghost mints a new UUID and pushes a // second PersistedTab — leading to two tabs restored side-by-side on the next boot. - const st = getRuntimeState(); - const existingChatTabs = st.tabs.filter((t) => t.kind === "chat" && t.projectId === projectId); + const existingChatTabs = getTabs().filter((t) => t.kind === "chat" && t.projectId === projectId); const existingTab = existingChatTabs[0]; const sessionId = existingTab?.instanceId ?? crypto.randomUUID(); if (existingChatTabs.length > 1) { - // Purge accidental duplicates left by the old bug: keep only the first entry. - setRuntimeState({ tabs: st.tabs.filter((t) => !(t.kind === "chat" && t.projectId === projectId && t.instanceId !== sessionId)) }); + // Purge accidental duplicates: keep only the first entry. + for (const t of existingChatTabs.slice(1)) removeTab(t.instanceId); } if (!existingTab) { - setRuntimeState({ tabs: [...st.tabs, { instanceId: sessionId, kind: "chat", projectId, cwd: "", name: "Chat" }] }); + upsertTab({ instanceId: sessionId, kind: "chat", projectId, cwd: "", name: "Chat" }); } setSessions((prev) => [...prev, { id: sessionId, instanceId: sessionId, name: "Chat", cwd: "", projectId, kind: "chat", @@ -1119,12 +1020,7 @@ export function WorkspaceView({ zen, name, path }: Props) { function clearChatHistory(projectId: string, sessionId?: string) { const projectPath = projects.find((p) => p.id === projectId)?.path; - if (projectPath) { - const st = getRuntimeState(); - const next = { ...st.chatHistory }; - delete next[projectPath]; - setRuntimeState({ chatHistory: next }); - } + if (projectPath) saveChatHistory(projectPath, []); if (sessionId) { setChatNonce((prev) => ({ ...prev, [sessionId]: (prev[sessionId] ?? 0) + 1 })); } @@ -1158,8 +1054,8 @@ export function WorkspaceView({ zen, name, path }: Props) { // Persist the updated URL to the tabs slice. const session = sessionsRef.current.find((s) => s.id === sessionId); if (session) { - const st = getRuntimeState(); - setRuntimeState({ tabs: st.tabs.map((t) => t.instanceId === session.instanceId ? { ...t, previewUrl: url } : t) }); + const t = getTabs().find((t) => t.instanceId === session.instanceId); + if (t) upsertTab({ ...t, previewUrl: url }); } } @@ -1397,14 +1293,13 @@ export function WorkspaceView({ zen, name, path }: Props) { // Chat tabs: keep the PersistedTab (for ghost + restart restore) — just remove from live sessions. } else if (closing?.kind !== "diff" && closing?.kind !== "preview" && closing?.kind !== "editor") { invoke("close_pty_session", { sessionId: id }).catch(() => {}); - // Use storeKey (not cwd) so a session that was never persisted (e.g. a plain terminal - // root session sharing cwd with an agent root session) cannot corrupt the agent's entry. - if (closing?.storeKey) markWorktreeSessionClosed(closing.storeKey); + // Mark the persisted row closed (ghost). No-op for session-scoped sessions + // that were never persisted (e.g. one with no resolvable project). + markSessionClosed(id); sessionManager.unregister(id); } else if (closing) { // Remove the persisted tab entry for non-terminal tabs (diff, preview, editor). - const st = getRuntimeState(); - setRuntimeState({ tabs: st.tabs.filter((t) => t.instanceId !== closing.instanceId) }); + removeTab(closing.instanceId); } clearWorkState(id); } @@ -1860,7 +1755,7 @@ export function WorkspaceView({ zen, name, path }: Props) { ) : ( zenSidebarItems.map((item) => { const session = sessions.find((s) => s.cwd === item.path); - const savedMeta = !session ? getWorktreeSession(item.path) : null; + const savedMeta = !session ? getWorktreeAgentSession(item.path) : null; const isAgent = !!(session?.agent || savedMeta?.agent); const isActive = session?.id === activeSessionId; return ( @@ -1871,10 +1766,10 @@ export function WorkspaceView({ zen, name, path }: Props) { if (session) { setActiveSessionId(session.id); } else { - const saved = savedMeta ?? getWorktreeSession(item.path); + const saved = savedMeta ?? getWorktreeAgentSession(item.path); if (saved) { - openSession(saved.name, item.path, "", saved.agent, undefined, undefined, saved.agent ? saved.conversationId : undefined).catch(() => {}); - markWorktreeSessionOpen(item.path); + openSession(saved.name, item.path, saved.projectId, saved.agent, undefined, undefined, saved.agent ? saved.conversationId : undefined, undefined, undefined, false, saved.id).catch(() => {}); + markSessionOpen(saved.id); } else { openSession("Terminal", item.path, "").catch(() => {}); } @@ -1897,7 +1792,7 @@ export function WorkspaceView({ zen, name, path }: Props) { <>
Projects
{projects.length === 0 ? ( -
No projects added
+
No projects added
) : (
{projects.map((project) => { @@ -1906,11 +1801,10 @@ export function WorkspaceView({ zen, name, path }: Props) { // Canonical root session map const liveRootSessions = projectSessions.filter((s) => s.isRootSession && s.kind !== "diff" && !s.parentSessionId); const storedRootEntries = getRootSessionsForProject(project.path); - const canonRoots = new Map(); - for (const s of liveRootSessions) canonRoots.set(s.instanceId, { session: s }); - for (const e of storedRootEntries) { - const id = e.session.instanceId ?? rootSessionIdFromKey(e.key); - if (!canonRoots.has(id)) canonRoots.set(id, { ghost: e }); + const canonRoots = new Map(); + for (const s of liveRootSessions) canonRoots.set(s.id, { session: s }); + for (const g of storedRootEntries) { + if (!canonRoots.has(g.id)) canonRoots.set(g.id, { ghost: g }); } const rootKey = project.path + "::root"; @@ -1922,7 +1816,7 @@ export function WorkspaceView({ zen, name, path }: Props) { const primaryRootAgent = rootAgents[0]; const isGitProject = project.worktrees.length > 0 || liveRootSessions.some((s) => !s.noGit) || - storedRootEntries.some((e) => !e.session.noGit); + storedRootEntries.some((e) => !e.noGit); return (
{rootExpanded && (() => { - const rootAgentsEmpty = rootAgents.length === 0 && !ghostRoots.some((e) => !!e.ghost!.session.agent); - const rootTerminalsEmpty = rootTerminals.length === 0 && !ghostRoots.some((e) => !e.ghost!.session.agent); + const rootAgentsEmpty = rootAgents.length === 0 && !ghostRoots.some((e) => !!e.ghost!.agent); + const rootTerminalsEmpty = rootTerminals.length === 0 && !ghostRoots.some((e) => !e.ghost!.agent); return (
{rootAgentsEmpty && rootTerminalsEmpty ? ( @@ -2039,7 +1933,7 @@ export function WorkspaceView({ zen, name, path }: Props) { key={s.id} className={`sb-dropdown-item${s.id === activeSessionId ? " sb-dropdown-item--active" : ""}`} onClick={() => setActiveSessionId(s.id)} - onContextMenu={(e) => openCtxMenu(e, null, project.path, project.id, s.id, false, true, s.storeKey)} + onContextMenu={(e) => openCtxMenu(e, null, project.path, project.id, s.id, false, true, s.id)} > {s.name} @@ -2049,15 +1943,14 @@ export function WorkspaceView({ zen, name, path }: Props) { ))} - {ghostRoots.filter((e) => !!e.ghost!.session.agent).map((entry) => { - const { key, session: ghost } = entry.ghost!; - const resumeId = ghost.instanceId ?? rootSessionIdFromKey(key); + {ghostRoots.filter((e) => !!e.ghost!.agent).map((entry) => { + const ghost = entry.ghost!; return ( ))} - {ghostRoots.filter((e) => !e.ghost!.session.agent).map((entry) => { - const { key, session: ghost } = entry.ghost!; - const resumeId = ghost.instanceId ?? rootSessionIdFromKey(key); + {ghostRoots.filter((e) => !e.ghost!.agent).map((entry) => { + const ghost = entry.ghost!; return ( ))} - {agentGhosts.map(({ key, session: g }) => ( + {agentGhosts.map((g) => ( ))} - {termGhosts.map(({ key, session: g }) => ( + {termGhosts.map((g) => (