From b5b38fa29ceb2093378cb9b195819d4d18b6a6fb Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sun, 18 Jan 2026 15:43:45 +0530 Subject: [PATCH 01/24] backend: add clone workspace command --- src-tauri/src/lib.rs | 1 + src-tauri/src/types.rs | 33 ++++++- src-tauri/src/workspaces.rs | 182 +++++++++++++++++++++++++++++++++++- 3 files changed, 214 insertions(+), 2 deletions(-) diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 435f14de4..50e8e8737 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -144,6 +144,7 @@ pub fn run() { codex::codex_doctor, workspaces::list_workspaces, workspaces::add_workspace, + workspaces::add_clone, workspaces::add_worktree, workspaces::remove_workspace, workspaces::remove_worktree, diff --git a/src-tauri/src/types.rs b/src-tauri/src/types.rs index 5f21b9d83..fe7f7a423 100644 --- a/src-tauri/src/types.rs +++ b/src-tauri/src/types.rs @@ -158,6 +158,8 @@ pub(crate) struct WorkspaceGroup { pub(crate) name: String, #[serde(default, rename = "sortOrder")] pub(crate) sort_order: Option, + #[serde(default, rename = "copiesFolder")] + pub(crate) copies_folder: Option, } #[derive(Debug, Serialize, Deserialize, Clone, Default)] @@ -305,7 +307,9 @@ impl Default for AppSettings { #[cfg(test)] mod tests { - use super::{AppSettings, BackendMode, WorkspaceEntry, WorkspaceKind, WorkspaceSettings}; + use super::{ + AppSettings, BackendMode, WorkspaceEntry, WorkspaceGroup, WorkspaceKind, WorkspaceSettings, + }; #[test] fn app_settings_defaults_from_empty_json() { @@ -325,6 +329,33 @@ mod tests { assert!(settings.workspace_groups.is_empty()); } + #[test] + fn workspace_group_defaults_from_minimal_json() { + let group: WorkspaceGroup = + serde_json::from_str(r#"{"id":"g1","name":"Group"}"#).expect("group deserialize"); + assert!(group.sort_order.is_none()); + assert!(group.copies_folder.is_none()); + } + + #[test] + fn app_settings_round_trip_preserves_workspace_group_copies_folder() { + let mut settings = AppSettings::default(); + settings.workspace_groups = vec![WorkspaceGroup { + id: "g1".to_string(), + name: "Group".to_string(), + sort_order: Some(2), + copies_folder: Some("/tmp/group-copies".to_string()), + }]; + + let json = serde_json::to_string(&settings).expect("serialize settings"); + let decoded: AppSettings = serde_json::from_str(&json).expect("deserialize settings"); + assert_eq!(decoded.workspace_groups.len(), 1); + assert_eq!( + decoded.workspace_groups[0].copies_folder.as_deref(), + Some("/tmp/group-copies") + ); + } + #[test] fn workspace_entry_defaults_from_minimal_json() { let entry: WorkspaceEntry = serde_json::from_str( diff --git a/src-tauri/src/workspaces.rs b/src-tauri/src/workspaces.rs index 3b1e662d0..544380892 100644 --- a/src-tauri/src/workspaces.rs +++ b/src-tauri/src/workspaces.rs @@ -54,6 +54,23 @@ fn sanitize_worktree_name(branch: &str) -> String { } } +fn sanitize_clone_dir_name(name: &str) -> String { + let mut result = String::new(); + for ch in name.chars() { + if ch.is_ascii_alphanumeric() || matches!(ch, '-' | '_' | '.') { + result.push(ch); + } else { + result.push('-'); + } + } + let trimmed = result.trim_matches('-').to_string(); + if trimmed.is_empty() { + "copy".to_string() + } else { + trimmed + } +} + fn list_workspace_files_inner(root: &PathBuf, max_files: usize) -> Vec { let mut results = Vec::new(); let walker = WalkBuilder::new(root) @@ -158,6 +175,13 @@ async fn git_branch_exists(repo_path: &PathBuf, branch: &str) -> Result Option { + match run_git_command(repo_path, &["config", "--get", "remote.origin.url"]).await { + Ok(url) if !url.trim().is_empty() => Some(url), + _ => None, + } +} + fn unique_worktree_path(base_dir: &PathBuf, name: &str) -> PathBuf { let mut candidate = base_dir.join(name); if !candidate.exists() { @@ -173,6 +197,11 @@ fn unique_worktree_path(base_dir: &PathBuf, name: &str) -> PathBuf { candidate } +fn build_clone_destination_path(copies_folder: &PathBuf, copy_name: &str) -> PathBuf { + let safe_name = sanitize_clone_dir_name(copy_name); + unique_worktree_path(copies_folder, &safe_name) +} + #[tauri::command] pub(crate) async fn list_workspaces( state: State<'_, AppState>, @@ -251,6 +280,116 @@ pub(crate) async fn add_workspace( }) } +#[tauri::command] +pub(crate) async fn add_clone( + source_workspace_id: String, + copy_name: String, + copies_folder: String, + state: State<'_, AppState>, + app: AppHandle, +) -> Result { + let copy_name = copy_name.trim().to_string(); + if copy_name.is_empty() { + return Err("Copy name is required.".to_string()); + } + + let copies_folder = copies_folder.trim().to_string(); + if copies_folder.is_empty() { + return Err("Copies folder is required.".to_string()); + } + let copies_folder_path = PathBuf::from(&copies_folder); + std::fs::create_dir_all(&copies_folder_path) + .map_err(|e| format!("Failed to create copies folder: {e}"))?; + if !copies_folder_path.is_dir() { + return Err("Copies folder must be a directory.".to_string()); + } + + let (source_entry, inherited_group_id) = { + let workspaces = state.workspaces.lock().await; + let source_entry = workspaces + .get(&source_workspace_id) + .cloned() + .ok_or("source workspace not found")?; + let inherited_group_id = if source_entry.kind.is_worktree() { + source_entry + .parent_id + .as_ref() + .and_then(|parent_id| workspaces.get(parent_id)) + .and_then(|parent| parent.settings.group_id.clone()) + } else { + source_entry.settings.group_id.clone() + }; + (source_entry, inherited_group_id) + }; + + let destination_path = build_clone_destination_path(&copies_folder_path, ©_name); + let destination_path_string = destination_path.to_string_lossy().to_string(); + + if let Err(error) = run_git_command( + &copies_folder_path, + &["clone", &source_entry.path, &destination_path_string], + ) + .await + { + if destination_path.exists() { + let _ = std::fs::remove_dir_all(&destination_path); + } + return Err(error); + } + + if let Some(origin_url) = git_get_origin_url(&PathBuf::from(&source_entry.path)).await { + let _ = run_git_command( + &destination_path, + &["remote", "set-url", "origin", &origin_url], + ) + .await; + } + + let entry = WorkspaceEntry { + id: Uuid::new_v4().to_string(), + name: copy_name.clone(), + path: destination_path_string, + codex_bin: source_entry.codex_bin.clone(), + kind: WorkspaceKind::Main, + parent_id: None, + worktree: None, + settings: WorkspaceSettings { + group_id: inherited_group_id, + ..WorkspaceSettings::default() + }, + }; + + let default_bin = { + let settings = state.app_settings.lock().await; + settings.codex_bin.clone() + }; + let codex_home = resolve_codex_home(&entry, None); + let session = spawn_workspace_session(entry.clone(), default_bin, app, codex_home).await?; + { + let mut workspaces = state.workspaces.lock().await; + workspaces.insert(entry.id.clone(), entry.clone()); + let list: Vec<_> = workspaces.values().cloned().collect(); + write_workspaces(&state.storage_path, &list)?; + } + state + .sessions + .lock() + .await + .insert(entry.id.clone(), session); + + Ok(WorkspaceInfo { + id: entry.id, + name: entry.name, + path: entry.path, + codex_bin: entry.codex_bin, + connected: true, + kind: entry.kind, + parent_id: entry.parent_id, + worktree: entry.worktree, + settings: entry.settings, + }) +} + #[tauri::command] pub(crate) async fn add_worktree( parent_id: String, @@ -585,7 +724,10 @@ mod tests { use std::collections::HashMap; use std::path::PathBuf; - use super::{apply_workspace_settings_update, sanitize_worktree_name, sort_workspaces}; + use super::{ + apply_workspace_settings_update, build_clone_destination_path, sanitize_clone_dir_name, + sanitize_worktree_name, sort_workspaces, + }; use crate::storage::{read_workspaces, write_workspaces}; use crate::types::{WorktreeInfo, WorkspaceEntry, WorkspaceInfo, WorkspaceKind, WorkspaceSettings}; use uuid::Uuid; @@ -641,6 +783,44 @@ mod tests { assert_eq!(sanitize_worktree_name("feature--x"), "feature--x"); } + #[test] + fn sanitize_clone_dir_name_rewrites_specials() { + assert_eq!(sanitize_clone_dir_name("feature/new-thing"), "feature-new-thing"); + assert_eq!(sanitize_clone_dir_name("///"), "copy"); + assert_eq!(sanitize_clone_dir_name("--name--"), "name"); + } + + #[test] + fn sanitize_clone_dir_name_allows_safe_chars() { + assert_eq!(sanitize_clone_dir_name("release_1.2.3"), "release_1.2.3"); + assert_eq!(sanitize_clone_dir_name("feature--x"), "feature--x"); + } + + #[test] + fn build_clone_destination_path_sanitizes_and_uniquifies() { + let temp_dir = + std::env::temp_dir().join(format!("codex-monitor-test-{}", Uuid::new_v4())); + let copies_folder = temp_dir.join("copies"); + std::fs::create_dir_all(&copies_folder).expect("create copies folder"); + + let first = build_clone_destination_path(&copies_folder, "feature/new-thing"); + assert!(first.starts_with(&copies_folder)); + assert_eq!( + first.file_name().and_then(|name| name.to_str()), + Some("feature-new-thing") + ); + + std::fs::create_dir_all(&first).expect("create first clone folder"); + + let second = build_clone_destination_path(&copies_folder, "feature/new-thing"); + assert!(second.starts_with(&copies_folder)); + assert_ne!(first, second); + assert_eq!( + second.file_name().and_then(|name| name.to_str()), + Some("feature-new-thing-2") + ); + } + #[test] fn sort_workspaces_orders_by_sort_then_name() { let mut items = vec![ From 8c3723a1a8b24bd16ab9ac51839d9f22cfbd9807 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sun, 18 Jan 2026 15:43:51 +0530 Subject: [PATCH 02/24] frontend: add clone IPC + copiesFolder field --- .../workspaces/hooks/useWorkspaces.ts | 44 +++++++++++++++++++ src/services/tauri.ts | 12 +++++ src/types.ts | 1 + 3 files changed, 57 insertions(+) diff --git a/src/features/workspaces/hooks/useWorkspaces.ts b/src/features/workspaces/hooks/useWorkspaces.ts index 0e592dddc..c1738aaeb 100644 --- a/src/features/workspaces/hooks/useWorkspaces.ts +++ b/src/features/workspaces/hooks/useWorkspaces.ts @@ -8,6 +8,7 @@ import type { } from "../../../types"; import { ask } from "@tauri-apps/plugin-dialog"; import { + addClone as addCloneService, addWorkspace as addWorkspaceService, addWorktree as addWorktreeService, connectWorkspace as connectWorkspaceService, @@ -260,6 +261,47 @@ export function useWorkspaces(options: UseWorkspacesOptions = {}) { } } + async function addCloneAgent( + source: WorkspaceInfo, + copyName: string, + copiesFolder: string, + ) { + const trimmedName = copyName.trim(); + if (!trimmedName) { + return null; + } + const trimmedFolder = copiesFolder.trim(); + if (!trimmedFolder) { + throw new Error("Copies folder is required."); + } + onDebug?.({ + id: `${Date.now()}-client-add-clone`, + timestamp: Date.now(), + source: "client", + label: "clone/add", + payload: { + sourceWorkspaceId: source.id, + copyName: trimmedName, + copiesFolder: trimmedFolder, + }, + }); + try { + const workspace = await addCloneService(source.id, trimmedFolder, trimmedName); + setWorkspaces((prev) => [...prev, workspace]); + setActiveWorkspaceId(workspace.id); + return workspace; + } catch (error) { + onDebug?.({ + id: `${Date.now()}-client-add-clone-error`, + timestamp: Date.now(), + source: "error", + label: "clone/add error", + payload: error instanceof Error ? error.message : String(error), + }); + throw error; + } + } + async function connectWorkspace(entry: WorkspaceInfo) { onDebug?.({ id: `${Date.now()}-client-connect-workspace`, @@ -413,6 +455,7 @@ export function useWorkspaces(options: UseWorkspacesOptions = {}) { id: createGroupId(), name: trimmed, sortOrder: nextSortOrder, + copiesFolder: null, }; await updateWorkspaceGroups([...currentGroups, nextGroup]); return nextGroup; @@ -640,6 +683,7 @@ export function useWorkspaces(options: UseWorkspacesOptions = {}) { activeWorkspaceId, setActiveWorkspaceId, addWorkspace, + addCloneAgent, addWorktreeAgent, connectWorkspace, markWorkspaceConnected, diff --git a/src/services/tauri.ts b/src/services/tauri.ts index d7c031d6b..3ec701627 100644 --- a/src/services/tauri.ts +++ b/src/services/tauri.ts @@ -53,6 +53,18 @@ export async function addWorkspace( return invoke("add_workspace", { path, codex_bin }); } +export async function addClone( + sourceWorkspaceId: string, + copiesFolder: string, + copyName: string, +): Promise { + return invoke("add_clone", { + sourceWorkspaceId, + copiesFolder, + copyName, + }); +} + export async function addWorktree( parentId: string, branch: string, diff --git a/src/types.ts b/src/types.ts index 15ee3a8e5..9bee7f891 100644 --- a/src/types.ts +++ b/src/types.ts @@ -9,6 +9,7 @@ export type WorkspaceGroup = { id: string; name: string; sortOrder?: number | null; + copiesFolder?: string | null; }; export type WorkspaceKind = "main" | "worktree"; From a1e399e1cc13b2468ee12ceb0a16597c71206e25 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sun, 18 Jan 2026 15:43:55 +0530 Subject: [PATCH 03/24] settings: add per-project copies folder --- .../settings/components/SettingsView.tsx | 104 +++++++++++++++--- src/styles/settings.css | 45 +++++++- 2 files changed, 130 insertions(+), 19 deletions(-) diff --git a/src/features/settings/components/SettingsView.tsx b/src/features/settings/components/SettingsView.tsx index 4b879ffa7..81ddb6849 100644 --- a/src/features/settings/components/SettingsView.tsx +++ b/src/features/settings/components/SettingsView.tsx @@ -312,6 +312,38 @@ export function SettingsView({ } }; + const updateGroupCopiesFolder = async ( + groupId: string, + copiesFolder: string | null, + ) => { + setGroupError(null); + try { + await onUpdateAppSettings({ + ...appSettings, + workspaceGroups: appSettings.workspaceGroups.map((entry) => + entry.id === groupId ? { ...entry, copiesFolder } : entry, + ), + }); + } catch (error) { + setGroupError(error instanceof Error ? error.message : String(error)); + } + }; + + const handleChooseGroupCopiesFolder = async (group: WorkspaceGroup) => { + const selection = await open({ multiple: false, directory: true }); + if (!selection || Array.isArray(selection)) { + return; + } + await updateGroupCopiesFolder(group.id, selection); + }; + + const handleClearGroupCopiesFolder = async (group: WorkspaceGroup) => { + if (!group.copiesFolder) { + return; + } + await updateGroupCopiesFolder(group.id, null); + }; + const handleDeleteGroup = async (group: WorkspaceGroup) => { const groupProjects = groupedWorkspaces.find((entry) => entry.id === group.id)?.workspaces ?? []; @@ -438,25 +470,61 @@ export function SettingsView({
{workspaceGroups.map((group, index) => (
- - setGroupDrafts((prev) => ({ - ...prev, - [group.id]: event.target.value, - })) - } - onBlur={() => { - void handleRenameGroup(group); - }} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault(); - void handleRenameGroup(group); +
+ + setGroupDrafts((prev) => ({ + ...prev, + [group.id]: event.target.value, + })) } - }} - /> + onBlur={() => { + void handleRenameGroup(group); + }} + onKeyDown={(event) => { + if (event.key === "Enter") { + event.preventDefault(); + void handleRenameGroup(group); + } + }} + /> +
+
+ Copies folder +
+
+
+ {group.copiesFolder ?? "Not set"} +
+ + +
+
+
+
, document.body, )} diff --git a/src/features/layout/hooks/useLayoutNodes.tsx b/src/features/layout/hooks/useLayoutNodes.tsx index d18cfaeb8..eeb60490b 100644 --- a/src/features/layout/hooks/useLayoutNodes.tsx +++ b/src/features/layout/hooks/useLayoutNodes.tsx @@ -90,6 +90,7 @@ type LayoutNodesOptions = { onConnectWorkspace: (workspace: WorkspaceInfo) => Promise; onAddAgent: (workspace: WorkspaceInfo) => Promise; onAddWorktreeAgent: (workspace: WorkspaceInfo) => Promise; + onAddCloneAgent: (workspace: WorkspaceInfo) => Promise; onToggleWorkspaceCollapse: (workspaceId: string, collapsed: boolean) => void; onSelectThread: (workspaceId: string, threadId: string) => void; onDeleteThread: (workspaceId: string, threadId: string) => void; @@ -293,6 +294,7 @@ export function useLayoutNodes(options: LayoutNodesOptions): LayoutNodesResult { onConnectWorkspace={options.onConnectWorkspace} onAddAgent={options.onAddAgent} onAddWorktreeAgent={options.onAddWorktreeAgent} + onAddCloneAgent={options.onAddCloneAgent} onToggleWorkspaceCollapse={options.onToggleWorkspaceCollapse} onSelectThread={options.onSelectThread} onDeleteThread={options.onDeleteThread} diff --git a/src/features/workspaces/components/ClonePrompt.tsx b/src/features/workspaces/components/ClonePrompt.tsx new file mode 100644 index 000000000..d5a4696c0 --- /dev/null +++ b/src/features/workspaces/components/ClonePrompt.tsx @@ -0,0 +1,150 @@ +import { useEffect, useRef } from "react"; + +type ClonePromptProps = { + workspaceName: string; + copyName: string; + copiesFolder: string; + suggestedCopiesFolder?: string | null; + error?: string | null; + onCopyNameChange: (value: string) => void; + onChooseCopiesFolder: () => void; + onUseSuggestedCopiesFolder: () => void; + onClearCopiesFolder: () => void; + onCancel: () => void; + onConfirm: () => void; + isBusy?: boolean; +}; + +export function ClonePrompt({ + workspaceName, + copyName, + copiesFolder, + suggestedCopiesFolder = null, + error = null, + onCopyNameChange, + onChooseCopiesFolder, + onUseSuggestedCopiesFolder, + onClearCopiesFolder, + onCancel, + onConfirm, + isBusy = false, +}: ClonePromptProps) { + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + inputRef.current?.select(); + }, []); + + const canCreate = copyName.trim().length > 0 && copiesFolder.trim().length > 0; + const showSuggested = + Boolean(suggestedCopiesFolder) && copiesFolder.trim().length === 0; + + return ( +
+
+
+
New clone agent
+
+ Create a new working copy of "{workspaceName}". +
+ + onCopyNameChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === "Escape") { + event.preventDefault(); + onCancel(); + } + if (event.key === "Enter" && canCreate) { + event.preventDefault(); + onConfirm(); + } + }} + /> + +
+ { + if (event.key === "Escape") { + event.preventDefault(); + onCancel(); + } + if (event.key === "Enter" && canCreate) { + event.preventDefault(); + onConfirm(); + } + }} + /> + + +
+ {showSuggested && ( +
+
Suggested
+
+
+ {suggestedCopiesFolder} +
+ +
+
+ )} + {error &&
{error}
} +
+ + +
+
+
+ ); +} + diff --git a/src/features/workspaces/hooks/useClonePrompt.ts b/src/features/workspaces/hooks/useClonePrompt.ts new file mode 100644 index 000000000..72693b989 --- /dev/null +++ b/src/features/workspaces/hooks/useClonePrompt.ts @@ -0,0 +1,240 @@ +import { useCallback, useMemo, useState } from "react"; +import type { WorkspaceInfo } from "../../../types"; +import { pickWorkspacePath } from "../../../services/tauri"; + +type ClonePromptState = { + workspace: WorkspaceInfo; + copyName: string; + copiesFolder: string; + initialCopiesFolder: string; + groupId: string | null; + suggestedCopiesFolder: string | null; + isSubmitting: boolean; + error: string | null; +} | null; + +type UseClonePromptOptions = { + addCloneAgent: ( + workspace: WorkspaceInfo, + copyName: string, + copiesFolder: string, + ) => Promise; + connectWorkspace: (workspace: WorkspaceInfo) => Promise; + onSelectWorkspace: (workspaceId: string) => void; + resolveProjectContext: ( + workspace: WorkspaceInfo, + ) => { groupId: string | null; copiesFolder: string | null }; + persistProjectCopiesFolder?: (groupId: string, copiesFolder: string) => Promise; + onCompactActivate?: () => void; + onError?: (message: string) => void; +}; + +type UseClonePromptResult = { + clonePrompt: ClonePromptState; + openPrompt: (workspace: WorkspaceInfo) => void; + confirmPrompt: () => Promise; + cancelPrompt: () => void; + updateCopyName: (value: string) => void; + chooseCopiesFolder: () => Promise; + useSuggestedCopiesFolder: () => void; + clearCopiesFolder: () => void; +}; + +function normalizePathSeparators(path: string) { + return path.replace(/\\/g, "/"); +} + +function dirname(path: string) { + const normalized = normalizePathSeparators(path).replace(/\/+$/, ""); + const index = normalized.lastIndexOf("/"); + if (index === -1) { + return ""; + } + return normalized.slice(0, index); +} + +function basename(path: string) { + const normalized = normalizePathSeparators(path).replace(/\/+$/, ""); + const index = normalized.lastIndexOf("/"); + if (index === -1) { + return normalized; + } + return normalized.slice(index + 1); +} + +function joinPath(parent: string, name: string) { + const normalized = normalizePathSeparators(parent).replace(/\/+$/, ""); + if (!normalized) { + return name; + } + return `${normalized}/${name}`; +} + +function suggestCopiesFolder(workspacePath: string) { + const parent = dirname(workspacePath); + const repoName = basename(workspacePath); + if (!parent || !repoName) { + return null; + } + return joinPath(parent, `${repoName}-copies`); +} + +function defaultCopyName() { + const date = new Date().toISOString().slice(0, 10); + const suffix = Math.random().toString(36).slice(2, 6); + return `copy-${date}-${suffix}`; +} + +export function useClonePrompt({ + addCloneAgent, + connectWorkspace, + onSelectWorkspace, + resolveProjectContext, + persistProjectCopiesFolder, + onCompactActivate, + onError, +}: UseClonePromptOptions): UseClonePromptResult { + const [clonePrompt, setClonePrompt] = useState(null); + + const openPrompt = useCallback( + (workspace: WorkspaceInfo) => { + const { groupId, copiesFolder } = resolveProjectContext(workspace); + setClonePrompt({ + workspace, + copyName: defaultCopyName(), + copiesFolder: copiesFolder ?? "", + initialCopiesFolder: copiesFolder ?? "", + groupId, + suggestedCopiesFolder: suggestCopiesFolder(workspace.path), + isSubmitting: false, + error: null, + }); + }, + [resolveProjectContext], + ); + + const updateCopyName = useCallback((value: string) => { + setClonePrompt((prev) => + prev ? { ...prev, copyName: value, error: null } : prev, + ); + }, []); + + const cancelPrompt = useCallback(() => { + setClonePrompt(null); + }, []); + + const chooseCopiesFolder = useCallback(async () => { + const selection = await pickWorkspacePath(); + if (!selection) { + return; + } + setClonePrompt((prev) => + prev ? { ...prev, copiesFolder: selection, error: null } : prev, + ); + }, []); + + const useSuggestedCopiesFolder = useCallback(() => { + setClonePrompt((prev) => { + if (!prev || !prev.suggestedCopiesFolder) { + return prev; + } + return { ...prev, copiesFolder: prev.suggestedCopiesFolder, error: null }; + }); + }, []); + + const clearCopiesFolder = useCallback(() => { + setClonePrompt((prev) => (prev ? { ...prev, copiesFolder: "", error: null } : prev)); + }, []); + + const canPersistCopiesFolder = useMemo(() => { + if (!clonePrompt) { + return false; + } + if (!clonePrompt.groupId || !persistProjectCopiesFolder) { + return false; + } + return clonePrompt.copiesFolder.trim().length > 0; + }, [clonePrompt, persistProjectCopiesFolder]); + + const confirmPrompt = useCallback(async () => { + if (!clonePrompt || clonePrompt.isSubmitting) { + return; + } + const copyName = clonePrompt.copyName.trim(); + const copiesFolder = clonePrompt.copiesFolder.trim(); + if (!copyName) { + setClonePrompt((prev) => + prev ? { ...prev, error: "Copy name is required." } : prev, + ); + return; + } + if (!copiesFolder) { + setClonePrompt((prev) => + prev ? { ...prev, error: "Copies folder is required." } : prev, + ); + return; + } + + setClonePrompt((prev) => + prev ? { ...prev, isSubmitting: true, error: null } : prev, + ); + try { + const cloneWorkspace = await addCloneAgent( + clonePrompt.workspace, + copyName, + copiesFolder, + ); + if (!cloneWorkspace) { + setClonePrompt(null); + return; + } + onSelectWorkspace(cloneWorkspace.id); + if (!cloneWorkspace.connected) { + await connectWorkspace(cloneWorkspace); + } + + if ( + canPersistCopiesFolder && + clonePrompt.groupId && + copiesFolder !== clonePrompt.initialCopiesFolder + ) { + try { + await persistProjectCopiesFolder?.(clonePrompt.groupId, copiesFolder); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + onError?.(message); + } + } + + onCompactActivate?.(); + setClonePrompt(null); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + setClonePrompt((prev) => + prev ? { ...prev, isSubmitting: false, error: message } : prev, + ); + onError?.(message); + } + }, [ + addCloneAgent, + canPersistCopiesFolder, + clonePrompt, + connectWorkspace, + onCompactActivate, + onError, + onSelectWorkspace, + persistProjectCopiesFolder, + ]); + + return { + clonePrompt, + openPrompt, + confirmPrompt, + cancelPrompt, + updateCopyName, + chooseCopiesFolder, + useSuggestedCopiesFolder, + clearCopiesFolder, + }; +} + diff --git a/src/styles/clone-modal.css b/src/styles/clone-modal.css new file mode 100644 index 000000000..a8fe367ac --- /dev/null +++ b/src/styles/clone-modal.css @@ -0,0 +1,118 @@ +.clone-modal { + position: fixed; + inset: 0; + z-index: 40; +} + +.clone-modal-backdrop { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.65); +} + +.clone-modal-card { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + width: min(520px, calc(100vw - 48px)); + background: rgba(8, 10, 16, 0.96); + border: 1px solid var(--border-subtle); + border-radius: 16px; + padding: 18px 20px; + display: flex; + flex-direction: column; + gap: 12px; + box-shadow: 0 18px 40px rgba(0, 0, 0, 0.35); +} + +.clone-modal-title { + font-size: 16px; + font-weight: 600; + color: var(--text-strong); +} + +.clone-modal-subtitle { + font-size: 13px; + color: var(--text-subtle); +} + +.clone-modal-label { + font-size: 12px; + color: var(--text-faint); +} + +.clone-modal-input { + border-radius: 10px; + border: 1px solid var(--border-subtle); + background: var(--surface-card-muted); + color: var(--text-strong); + padding: 10px 12px; + font-size: 13px; + width: 100%; +} + +.clone-modal-input:focus { + outline: 2px solid rgba(77, 163, 255, 0.35); + outline-offset: 1px; +} + +.clone-modal-folder-row { + display: flex; + gap: 8px; + align-items: center; +} + +.clone-modal-suggested { + display: flex; + flex-direction: column; + gap: 6px; +} + +.clone-modal-suggested-label { + font-size: 11px; + color: var(--text-faint); +} + +.clone-modal-suggested-row { + display: flex; + gap: 8px; + align-items: center; +} + +.clone-modal-suggested-path { + flex: 1; + min-width: 0; + padding: 10px 12px; + border-radius: 10px; + border: 1px solid var(--border-subtle); + background: var(--surface-card-muted); + color: var(--text-subtle); + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.clone-modal-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + margin-top: 4px; +} + +.clone-modal-error { + font-size: 12px; + color: #ff8f8f; + background: rgba(255, 79, 79, 0.12); + border: 1px solid rgba(255, 79, 79, 0.4); + padding: 8px 10px; + border-radius: 10px; +} + +.clone-modal-button { + padding: 6px 12px; + border-radius: 10px; + white-space: nowrap; +} + From 8aedc9ebd593e622b08d1da1163bfea4d9f8c15f Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Sun, 18 Jan 2026 15:50:32 +0530 Subject: [PATCH 05/24] ui: prevent Enter submit while cloning --- .../workspaces/components/ClonePrompt.tsx | 23 +++++++++---------- 1 file changed, 11 insertions(+), 12 deletions(-) diff --git a/src/features/workspaces/components/ClonePrompt.tsx b/src/features/workspaces/components/ClonePrompt.tsx index d5a4696c0..77f45f731 100644 --- a/src/features/workspaces/components/ClonePrompt.tsx +++ b/src/features/workspaces/components/ClonePrompt.tsx @@ -62,7 +62,7 @@ export function ClonePrompt({ event.preventDefault(); onCancel(); } - if (event.key === "Enter" && canCreate) { + if (event.key === "Enter" && canCreate && !isBusy) { event.preventDefault(); onConfirm(); } @@ -79,16 +79,16 @@ export function ClonePrompt({ placeholder="Not set" readOnly onKeyDown={(event) => { - if (event.key === "Escape") { - event.preventDefault(); - onCancel(); - } - if (event.key === "Enter" && canCreate) { - event.preventDefault(); - onConfirm(); - } - }} - /> + if (event.key === "Escape") { + event.preventDefault(); + onCancel(); + } + if (event.key === "Enter" && canCreate && !isBusy) { + event.preventDefault(); + onConfirm(); + } + }} + /> )}
diff --git a/src/features/app/components/WorktreeSection.tsx b/src/features/app/components/WorktreeSection.tsx index 4a670b102..cba561605 100644 --- a/src/features/app/components/WorktreeSection.tsx +++ b/src/features/app/components/WorktreeSection.tsx @@ -89,8 +89,6 @@ export function WorktreeSection({ {worktrees.map((worktree) => { const worktreeThreads = threadsByWorkspace[worktree.id] ?? []; const worktreeCollapsed = worktree.settings.sidebarCollapsed; - const showWorktreeThreads = - !worktreeCollapsed && worktreeThreads.length > 0; const isLoadingWorktreeThreads = threadListLoadingByWorkspace[worktree.id] ?? false; const showWorktreeLoader = @@ -99,6 +97,9 @@ export function WorktreeSection({ worktreeThreads.length === 0; const worktreeNextCursor = threadListCursorByWorkspace[worktree.id] ?? null; + const showWorktreeThreadList = + !worktreeCollapsed && + (worktreeThreads.length > 0 || Boolean(worktreeNextCursor)); const isWorktreePaging = threadListPagingByWorkspace[worktree.id] ?? false; const isWorktreeExpanded = expandedWorkspaces.has(worktree.id); @@ -122,7 +123,7 @@ export function WorktreeSection({ onToggleWorkspaceCollapse={onToggleWorkspaceCollapse} onConnectWorkspace={onConnectWorkspace} > - {showWorktreeThreads && ( + {showWorktreeThreadList && ( = maxPagesWithoutMatch - ) { - cursor = null; - } else { - cursor = nextCursor; + cursor = nextCursor; + if (matchingThreads.length === 0 && pagesFetched >= maxPagesWithoutMatch) { + break; } } while (cursor && matchingThreads.length < targetCount); @@ -1308,8 +1304,11 @@ export function useThreads({ const matchingThreads: Record[] = []; const targetCount = 20; const pageSize = 20; + const maxPagesWithoutMatch = 10; + let pagesFetched = 0; let cursor: string | null = nextCursor; do { + pagesFetched += 1; const response = (await listThreadsService( workspace.id, @@ -1336,6 +1335,9 @@ export function useThreads({ ), ); cursor = next; + if (matchingThreads.length === 0 && pagesFetched >= maxPagesWithoutMatch) { + break; + } } while (cursor && matchingThreads.length < targetCount); const existingIds = new Set(existing.map((thread) => thread.id)); From 349fb340a6015997b0f545c2ab325acb93e05030 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Mon, 19 Jan 2026 21:22:04 +0530 Subject: [PATCH 20/24] fix: avoid double-counting local usage totals --- src-tauri/src/local_usage.rs | 80 ++++++++++++++++++++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/src-tauri/src/local_usage.rs b/src-tauri/src/local_usage.rs index 67c8f4eb4..b529bbb31 100644 --- a/src-tauri/src/local_usage.rs +++ b/src-tauri/src/local_usage.rs @@ -234,6 +234,14 @@ fn scan_file( output: (output - prev.output).max(0), }; previous_totals = Some(UsageTotals { input, cached, output }); + } else { + // Some streams emit `last_token_usage` deltas between `total_token_usage` snapshots. + // Treat those as already-counted to avoid double-counting when the next total arrives. + let mut next = previous_totals.unwrap_or_default(); + next.input += delta.input; + next.cached += delta.cached; + next.output += delta.output; + previous_totals = Some(next); } if delta.input == 0 && delta.cached == 0 && delta.output == 0 { @@ -344,3 +352,75 @@ fn day_dir_for_key(root: &Path, day_key: &str) -> PathBuf { let day = parts.next().unwrap_or("01"); root.join(year).join(month).join(day) } + +#[cfg(test)] +mod tests { + use super::*; + use std::io::Write; + use uuid::Uuid; + + fn write_temp_jsonl(lines: &[&str]) -> PathBuf { + let mut path = std::env::temp_dir(); + path.push(format!( + "codexmonitor-local-usage-test-{}.jsonl", + Uuid::new_v4() + )); + let mut file = File::create(&path).expect("create temp jsonl"); + for line in lines { + writeln!(file, "{line}").expect("write jsonl line"); + } + path + } + + #[test] + fn scan_file_does_not_double_count_last_and_total_usage() { + let day_key = "2026-01-19"; + let path = write_temp_jsonl(&[ + r#"{"payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":10,"cached_input_tokens":0,"output_tokens":5}}}}"#, + r#"{"payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":10,"cached_input_tokens":0,"output_tokens":5}}}}"#, + ]); + + let mut daily: HashMap = HashMap::new(); + let mut model_totals: HashMap = HashMap::new(); + scan_file(&path, day_key, &mut daily, &mut model_totals).expect("scan file"); + + let totals = daily.get(day_key).copied().unwrap_or_default(); + assert_eq!(totals.input, 10); + assert_eq!(totals.output, 5); + } + + #[test] + fn scan_file_counts_last_deltas_before_total_snapshot_once() { + let day_key = "2026-01-19"; + let path = write_temp_jsonl(&[ + r#"{"payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":10,"cached_input_tokens":0,"output_tokens":5}}}}"#, + r#"{"payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":20,"cached_input_tokens":0,"output_tokens":10}}}}"#, + ]); + + let mut daily: HashMap = HashMap::new(); + let mut model_totals: HashMap = HashMap::new(); + scan_file(&path, day_key, &mut daily, &mut model_totals).expect("scan file"); + + let totals = daily.get(day_key).copied().unwrap_or_default(); + assert_eq!(totals.input, 20); + assert_eq!(totals.output, 10); + } + + #[test] + fn scan_file_does_not_double_count_last_between_total_snapshots() { + let day_key = "2026-01-19"; + let path = write_temp_jsonl(&[ + r#"{"payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":10,"cached_input_tokens":0,"output_tokens":5}}}}"#, + r#"{"payload":{"type":"token_count","info":{"last_token_usage":{"input_tokens":2,"cached_input_tokens":0,"output_tokens":1}}}}"#, + r#"{"payload":{"type":"token_count","info":{"total_token_usage":{"input_tokens":12,"cached_input_tokens":0,"output_tokens":6}}}}"#, + ]); + + let mut daily: HashMap = HashMap::new(); + let mut model_totals: HashMap = HashMap::new(); + scan_file(&path, day_key, &mut daily, &mut model_totals).expect("scan file"); + + let totals = daily.get(day_key).copied().unwrap_or_default(); + assert_eq!(totals.input, 12); + assert_eq!(totals.output, 6); + } +} From 84477b9f7fbe3ad348b1a18657a0dd9d1b46a2bd Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Mon, 19 Jan 2026 21:22:19 +0530 Subject: [PATCH 21/24] fix: surface git action errors and refresh --- src/features/git/hooks/useGitActions.ts | 42 +++++++++++++++++++------ 1 file changed, 33 insertions(+), 9 deletions(-) diff --git a/src/features/git/hooks/useGitActions.ts b/src/features/git/hooks/useGitActions.ts index 7fbfc0076..5a191dc59 100644 --- a/src/features/git/hooks/useGitActions.ts +++ b/src/features/git/hooks/useGitActions.ts @@ -54,10 +54,18 @@ export function useGitActions({ if (!workspaceId) { return; } - await stageGitFileService(workspaceId, path); - refreshGitData(); + const actionWorkspaceId = workspaceId; + try { + await stageGitFileService(actionWorkspaceId, path); + } catch (error) { + onError?.(error); + } finally { + if (workspaceIdRef.current === actionWorkspaceId) { + refreshGitData(); + } + } }, - [refreshGitData, workspaceId], + [onError, refreshGitData, workspaceId], ); const unstageGitFile = useCallback( @@ -65,10 +73,18 @@ export function useGitActions({ if (!workspaceId) { return; } - await unstageGitFileService(workspaceId, path); - refreshGitData(); + const actionWorkspaceId = workspaceId; + try { + await unstageGitFileService(actionWorkspaceId, path); + } catch (error) { + onError?.(error); + } finally { + if (workspaceIdRef.current === actionWorkspaceId) { + refreshGitData(); + } + } }, - [refreshGitData, workspaceId], + [onError, refreshGitData, workspaceId], ); const revertGitFile = useCallback( @@ -76,10 +92,18 @@ export function useGitActions({ if (!workspaceId) { return; } - await revertGitFileService(workspaceId, path); - refreshGitData(); + const actionWorkspaceId = workspaceId; + try { + await revertGitFileService(actionWorkspaceId, path); + } catch (error) { + onError?.(error); + } finally { + if (workspaceIdRef.current === actionWorkspaceId) { + refreshGitData(); + } + } }, - [refreshGitData, workspaceId], + [onError, refreshGitData, workspaceId], ); const revertAllGitChanges = useCallback(async () => { From 79db41ba1b1a7c8d3b4d2e5fcda8c0cb73a34a60 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Mon, 19 Jan 2026 21:22:32 +0530 Subject: [PATCH 22/24] fix: avoid /dev/null in worktree apply on Windows --- src-tauri/src/workspaces.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/src-tauri/src/workspaces.rs b/src-tauri/src/workspaces.rs index 3facd34e9..4ae763971 100644 --- a/src-tauri/src/workspaces.rs +++ b/src-tauri/src/workspaces.rs @@ -255,6 +255,14 @@ fn build_clone_destination_path(copies_folder: &PathBuf, copy_name: &str) -> Pat unique_worktree_path(copies_folder, &safe_name) } +fn null_device_path() -> &'static str { + if cfg!(windows) { + "NUL" + } else { + "/dev/null" + } +} + #[tauri::command] pub(crate) async fn list_workspaces( state: State<'_, AppState>, @@ -735,7 +743,7 @@ pub(crate) async fn apply_worktree_changes( "--no-color", "--no-index", "--", - "/dev/null", + null_device_path(), &path, ], ) From 282fa0ca92d180c46946dee5c781a2a5ac5396ae Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Mon, 19 Jan 2026 21:57:04 +0530 Subject: [PATCH 23/24] fix: remove duplicate file-link menu actions --- .../messages/hooks/useFileLinkOpener.ts | 24 +++++++++---------- 1 file changed, 11 insertions(+), 13 deletions(-) diff --git a/src/features/messages/hooks/useFileLinkOpener.ts b/src/features/messages/hooks/useFileLinkOpener.ts index c6e7de0f1..575c4e36c 100644 --- a/src/features/messages/hooks/useFileLinkOpener.ts +++ b/src/features/messages/hooks/useFileLinkOpener.ts @@ -81,7 +81,7 @@ export function useFileLinkOpener(workspacePath?: string | null) { const resolvedPath = resolveFilePath(stripLineSuffix(rawPath), workspacePath); const openLabel = target.id === "finder" - ? "Open in Finder" + ? revealLabel() : target.appName ? `Open in ${target.appName}` : "Open Link"; @@ -92,18 +92,16 @@ export function useFileLinkOpener(workspacePath?: string | null) { await openFileLink(rawPath); }, }), - await MenuItem.new({ - text: "Open Link in New Window", - action: async () => { - await openFileLink(rawPath); - }, - }), - await MenuItem.new({ - text: revealLabel(), - action: async () => { - await revealItemInDir(resolvedPath); - }, - }), + ...(target.id === "finder" + ? [] + : [ + await MenuItem.new({ + text: revealLabel(), + action: async () => { + await revealItemInDir(resolvedPath); + }, + }), + ]), await MenuItem.new({ text: "Download Linked File", enabled: false, From 4849c81443ff49ce7fc20d396d18dfa8b0577a67 Mon Sep 17 00:00:00 2001 From: Ratul Sarna Date: Mon, 19 Jan 2026 21:57:16 +0530 Subject: [PATCH 24/24] fix: guard clipboard write in thread menu --- src/features/app/hooks/useSidebarMenus.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/features/app/hooks/useSidebarMenus.ts b/src/features/app/hooks/useSidebarMenus.ts index 0e489457b..374d42f8d 100644 --- a/src/features/app/hooks/useSidebarMenus.ts +++ b/src/features/app/hooks/useSidebarMenus.ts @@ -44,7 +44,11 @@ export function useSidebarMenus({ const copyItem = await MenuItem.new({ text: "Copy ID", action: async () => { - await navigator.clipboard.writeText(threadId); + try { + await navigator.clipboard.writeText(threadId); + } catch { + // Clipboard failures are non-fatal here. + } }, }); const items = [renameItem];