diff --git a/CHANGELOG.md b/CHANGELOG.md index 965572ea..4c99c2cd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added + +- New sessions in a git repository choose where they run: the main checkout, an existing linked worktree, or a worktree created on the spot from a branch name and a start-from ref. The choice is explicit, made before the first turn, and never moves the main checkout's branch — a failed create leaves it exactly as it was. In #2. + +### Changed + +- A session stays in the checkout it was started in across restarts, and only falls back to the main checkout when that worktree is really gone. +- The composer branch picker switches the branch of the working copy its session runs in, so a worktree session no longer moves the main checkout. + ## [0.1.37] - 2026-09-07 ### Fixed diff --git a/src-tauri/src/fs.rs b/src-tauri/src/fs.rs index 3453f429..94a023c9 100644 --- a/src-tauri/src/fs.rs +++ b/src-tauri/src/fs.rs @@ -738,6 +738,73 @@ pub async fn git_stash(cwd: String, message: Option) -> Result<(), Strin .map_err(|e| e.to_string())? } +#[derive(Serialize, Clone, Debug, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct GitWorktreeEntry { + /// Absolute path of this working tree. + pub path: String, + /// Short branch checked out here. Absent while the tree is detached. + pub branch: Option, + /// The repository's primary working tree, the one `git clone` produced. + pub main: bool, + /// Still registered, but its directory is gone from disk. + pub prunable: bool, + /// Held by `git worktree lock`. + pub locked: bool, +} + +#[derive(Serialize, Clone, Debug, Default, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct GitWorktrees { + /// Main checkout first, then linked worktrees. Empty outside a repository. + pub entries: Vec, + /// Folder `git_worktree_create` puts new worktrees in, so the picker can + /// show where a new one would land before the user commits to it. + pub parent: Option, +} + +/// Working trees of this repository: the main checkout plus every linked worktree. +#[tauri::command] +pub async fn git_worktrees(cwd: String) -> Result { + tauri::async_runtime::spawn_blocking(move || Ok(git_worktrees_for(&expand_home(&cwd)))) + .await + .map_err(|e| e.to_string())? +} + +/// Create a linked worktree on a new branch and report where it landed. +/// +/// `git worktree add` writes a new directory and one ref; it never moves the +/// main checkout's HEAD, so a failure here leaves the caller's checkout exactly +/// as it was and the error travels back to the picker untouched. +#[tauri::command] +pub async fn git_worktree_create( + cwd: String, + branch: String, + start_ref: Option, +) -> Result { + tauri::async_runtime::spawn_blocking(move || { + git_worktree_create_for(&expand_home(&cwd), &branch, start_ref.as_deref()) + }) + .await + .map_err(|e| e.to_string())? +} + +/// True when `path` is still a live working tree of the repository at `cwd`. +/// +/// Sessions persist the checkout they were started in, so a restore has to tell +/// "the worktree is still there" from "someone removed it while we were away". +#[tauri::command] +pub async fn git_worktree_verify(cwd: String, path: String) -> Result { + tauri::async_runtime::spawn_blocking(move || { + Ok(git_worktree_verify_for( + &expand_home(&cwd), + &expand_home(&path), + )) + }) + .await + .map_err(|e| e.to_string())? +} + fn git_diff_stats_for(root: &Path) -> GitDiffStats { if !git_is_work_tree(root) { return GitDiffStats::default(); @@ -2756,6 +2823,215 @@ fn git_create_branch_for(root: &Path, name: &str) -> Result { Ok(name) } +/// One `worktree ...` block of `git worktree list --porcelain`. +struct GitWorktreeBlock { + path: PathBuf, + branch: Option, + /// A bare repository entry: registered, but with no files to work in. + bare: bool, + prunable: bool, + locked: bool, +} + +fn git_worktrees_for(root: &Path) -> GitWorktrees { + let Some(blocks) = git_worktree_blocks(root) else { + return GitWorktrees::default(); + }; + // `git worktree list` always prints the primary working tree first, from + // whichever tree the command ran in, so position identifies the main + // checkout without a second `rev-parse`. + let parent = blocks + .first() + .and_then(|block| git_worktree_parent(&block.path)); + let entries = blocks + .into_iter() + .enumerate() + // A bare entry has no files, so it can never host a session. + .filter(|(_, block)| !block.bare) + .map(|(index, block)| GitWorktreeEntry { + path: path_to_js(&block.path), + branch: block.branch, + main: index == 0, + prunable: block.prunable, + locked: block.locked, + }) + .collect(); + GitWorktrees { + entries, + parent: parent.as_deref().map(path_to_js), + } +} + +fn git_worktree_blocks(root: &Path) -> Option> { + if !git_is_work_tree(root) { + return None; + } + let text = git_run(root, &["worktree", "list", "--porcelain"])?; + let mut blocks = Vec::new(); + let mut current: Option = None; + for line in text.lines() { + let line = line.trim_end(); + if let Some(rest) = line.strip_prefix("worktree ") { + if let Some(block) = current.take() { + blocks.push(block); + } + current = Some(GitWorktreeBlock { + path: PathBuf::from(rest), + branch: None, + bare: false, + prunable: false, + locked: false, + }); + continue; + } + let Some(block) = current.as_mut() else { + continue; + }; + if let Some(rest) = line.strip_prefix("branch ") { + block.branch = Some(rest.strip_prefix("refs/heads/").unwrap_or(rest).to_string()); + } else if line == "bare" { + block.bare = true; + } else if line == "locked" || line.starts_with("locked ") { + // `locked` may carry a reason; only the presence of the key matters. + block.locked = true; + } else if line == "prunable" || line.starts_with("prunable ") { + block.prunable = true; + } + } + if let Some(block) = current.take() { + blocks.push(block); + } + Some(blocks) +} + +/// Folder new worktrees are created in: a `-worktrees` sibling of the main +/// checkout. Outside the repository, so linked trees never show up as untracked +/// files, and next to it, so they are easy to find in a file browser. +fn git_worktree_parent(main: &Path) -> Option { + let name = main.file_name()?.to_string_lossy().into_owned(); + Some(main.parent()?.join(format!("{name}-worktrees"))) +} + +fn git_worktree_create_for( + root: &Path, + branch: &str, + start_ref: Option<&str>, +) -> Result { + if !git_is_work_tree(root) { + return Err("Not a git repository".into()); + } + let branch = git_branch_name(root, branch)?; + if git_ref_exists(root, &format!("refs/heads/{branch}")) + || git_head_branch(root).as_deref() == Some(branch.as_str()) + { + return Err(format!("Branch {branch} already exists")); + } + // An empty start point means "branch from where this checkout is now". + let start = start_ref + .map(str::trim) + .filter(|value| !value.is_empty()) + .unwrap_or("HEAD"); + // Resolve the start point up front so a typo fails before any directory + // exists, rather than half way through `git worktree add`. + if start.starts_with('-') || git_worktree_start_commit(root, start).is_none() { + return Err(format!("Start point {start} not found")); + } + let main = git_worktree_blocks(root) + .and_then(|blocks| blocks.into_iter().next()) + .map(|block| block.path) + .unwrap_or_else(|| root.to_path_buf()); + let path = git_worktree_target(&main, &branch)?; + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| e.to_string())?; + } + // Only the leaf directory is left to git, which removes it again if the add + // fails, so a failed create leaves nothing behind to clean up by hand. + let path_arg = path.to_string_lossy().into_owned(); + git_checked(root, &["worktree", "add", "-b", &branch, &path_arg, start])?; + Ok(GitWorktreeEntry { + path: path_to_js(&path.canonicalize().unwrap_or(path)), + branch: Some(branch), + main: false, + prunable: false, + locked: false, + }) +} + +fn git_worktree_start_commit(root: &Path, spec: &str) -> Option { + git_stdout( + root, + &[ + "rev-parse", + "--verify", + "--quiet", + &format!("{spec}^{{commit}}"), + ], + ) +} + +/// Free directory for a new worktree under the repository's worktree parent. +fn git_worktree_target(main: &Path, branch: &str) -> Result { + let parent = git_worktree_parent(main) + .ok_or_else(|| "Could not place a worktree next to this repository".to_string())?; + let slug = git_worktree_slug(branch); + let first = parent.join(&slug); + if !first.exists() { + return Ok(first); + } + for index in 2..64 { + let candidate = parent.join(format!("{slug}-{index}")); + if !candidate.exists() { + return Ok(candidate); + } + } + Err(format!("Could not allocate a worktree folder for {branch}")) +} + +/// Branch names may nest (`feat/thing`) or carry characters that make awkward +/// folder names, so flatten them into one plain segment. +fn git_worktree_slug(branch: &str) -> String { + let mut slug = String::with_capacity(branch.len()); + for ch in branch.chars() { + if ch.is_ascii_alphanumeric() || ch == '-' || ch == '_' { + slug.push(ch); + } else if !slug.ends_with('-') { + slug.push('-'); + } + } + let trimmed = slug.trim_matches('-'); + if trimmed.is_empty() { + "worktree".to_string() + } else { + trimmed.to_string() + } +} + +fn git_worktree_verify_for(root: &Path, path: &Path) -> bool { + if !path.is_dir() || !git_is_work_tree(path) { + return false; + } + // Every working tree of one repository shares a single common dir, so + // comparing it also rejects a folder that survived as an unrelated repo. + match (git_common_dir(root), git_common_dir(path)) { + (Some(a), Some(b)) => match (a.canonicalize(), b.canonicalize()) { + (Ok(a), Ok(b)) => a == b, + _ => false, + }, + _ => false, + } +} + +/// Shared `.git` directory, identical from every working tree of a repository. +fn git_common_dir(root: &Path) -> Option { + let raw = git_stdout(root, &["rev-parse", "--git-common-dir"])?; + let path = PathBuf::from(raw); + if path.is_absolute() { + Some(path) + } else { + Some(root.join(path)) + } +} + fn git_stash_for(root: &Path, message: Option<&str>) -> Result<(), String> { if !git_is_work_tree(root) { return Err("Not a git repository".into()); @@ -5367,4 +5643,182 @@ mod tests { ); assert_eq!(git_head_branch(&repo.0).as_deref(), Some("feature")); } + + #[test] + fn git_worktrees_lists_the_main_checkout_alone_at_first() { + let dir = tmp("git-worktree-list"); + if !init_git_commit(&dir.0, &[("a.txt", "alpha\n")]) { + return; + } + let listed = git_worktrees_for(&dir.0); + assert_eq!(listed.entries.len(), 1); + let main = &listed.entries[0]; + assert!(main.main); + assert!(!main.prunable); + assert!(!main.locked); + assert_eq!(main.branch.as_deref(), Some("main")); + assert_eq!( + PathBuf::from(&main.path).canonicalize().ok(), + dir.0.canonicalize().ok() + ); + // New trees are offered next to the repository, never inside it. + let parent = PathBuf::from(listed.parent.unwrap()); + let repo_name = dir.0.file_name().unwrap().to_string_lossy().into_owned(); + assert_eq!( + parent.file_name().unwrap().to_string_lossy(), + format!("{repo_name}-worktrees") + ); + assert_eq!( + parent.parent().and_then(|path| path.canonicalize().ok()), + dir.0.parent().and_then(|path| path.canonicalize().ok()) + ); + } + + #[test] + fn git_worktrees_are_empty_outside_a_repo() { + let dir = tmp("git-worktree-none"); + assert_eq!(git_worktrees_for(&dir.0), GitWorktrees::default()); + } + + #[test] + fn git_worktree_create_leaves_the_main_checkout_on_its_branch() { + let dir = tmp("git-worktree-create"); + if !init_git_commit(&dir.0, &[("a.txt", "alpha\n")]) { + return; + } + let created = git_worktree_create_for(&dir.0, "feat/picker", None).unwrap(); + // Cleaned up by hand: the tree is a sibling of the repo, so the Tmp + // guard that owns `dir` does not cover it. + let created_path = PathBuf::from(&created.path); + let parent = created_path.parent().unwrap().to_path_buf(); + let _guard = Tmp(parent); + + assert_eq!(created.branch.as_deref(), Some("feat/picker")); + assert!(!created.main); + // The slash in the branch flattens into a single folder segment. + assert_eq!(created_path.file_name().unwrap(), "feat-picker"); + assert!(created_path.join("a.txt").is_file()); + assert_eq!( + git_head_branch(&created_path).as_deref(), + Some("feat/picker") + ); + assert_eq!(git_head_branch(&dir.0).as_deref(), Some("main")); + + let listed = git_worktrees_for(&dir.0); + assert_eq!(listed.entries.len(), 2); + assert!(listed.entries[0].main); + let linked = listed + .entries + .iter() + .find(|entry| entry.branch.as_deref() == Some("feat/picker")) + .unwrap(); + assert!(!linked.main); + assert_eq!( + PathBuf::from(&linked.path).canonicalize().ok(), + created_path.canonicalize().ok() + ); + // The same list is visible from inside the linked tree. + assert_eq!(git_worktrees_for(&created_path).entries.len(), 2); + } + + #[test] + fn git_worktree_create_reports_errors_without_touching_the_checkout() { + let dir = tmp("git-worktree-create-err"); + if !init_git_commit(&dir.0, &[("a.txt", "alpha\n")]) { + return; + } + assert!(git_worktree_create_for(&dir.0, "main", None).is_err()); + assert!(git_worktree_create_for(&dir.0, " ", None).is_err()); + assert!(git_worktree_create_for(&dir.0, "feat", Some("nope")).is_err()); + assert!(git_worktree_create_for(&dir.0, "feat", Some("--force")).is_err()); + // Nothing moved and nothing new was registered. + assert_eq!(git_head_branch(&dir.0).as_deref(), Some("main")); + assert_eq!(git_worktrees_for(&dir.0).entries.len(), 1); + } + + #[test] + fn git_worktree_create_starts_from_the_requested_ref() { + let dir = tmp("git-worktree-start"); + if !init_git_commit(&dir.0, &[("a.txt", "alpha\n")]) { + return; + } + let base = git_stdout(&dir.0, &["rev-parse", "HEAD"]).unwrap(); + std::fs::write(dir.0.join("a.txt"), "beta\n").unwrap(); + if !git(&dir.0, &["commit", "-am", "beta"]) { + return; + } + let created = git_worktree_create_for(&dir.0, "from-base", Some(&base)).unwrap(); + let created_path = PathBuf::from(&created.path); + let _guard = Tmp(created_path.parent().unwrap().to_path_buf()); + + assert_eq!( + std::fs::read_to_string(created_path.join("a.txt")).unwrap(), + "alpha\n" + ); + assert_eq!( + git_stdout(&created_path, &["rev-parse", "HEAD"]).as_deref(), + Some(base.as_str()) + ); + // The main checkout kept its own branch and its newer commit. + assert_eq!(git_head_branch(&dir.0).as_deref(), Some("main")); + assert_eq!( + std::fs::read_to_string(dir.0.join("a.txt")).unwrap(), + "beta\n" + ); + } + + #[test] + fn git_worktree_verify_accepts_only_trees_of_the_same_repo() { + let dir = tmp("git-worktree-verify"); + let other = tmp("git-worktree-verify-other"); + let plain = tmp("git-worktree-verify-plain"); + if !init_git_commit(&dir.0, &[("a.txt", "alpha\n")]) + || !init_git_commit(&other.0, &[("a.txt", "alpha\n")]) + { + return; + } + let created = git_worktree_create_for(&dir.0, "verify-me", None).unwrap(); + let created_path = PathBuf::from(&created.path); + let _guard = Tmp(created_path.parent().unwrap().to_path_buf()); + + assert!(git_worktree_verify_for(&dir.0, &created_path)); + assert!(git_worktree_verify_for(&dir.0, &dir.0)); + assert!(!git_worktree_verify_for(&dir.0, &other.0)); + assert!(!git_worktree_verify_for(&dir.0, &plain.0)); + assert!(!git_worktree_verify_for(&dir.0, &dir.0.join("gone"))); + + // A removed worktree stops verifying, so a restore can fall back. + assert!(git( + &dir.0, + &["worktree", "remove", "--force", &created.path] + )); + assert!(!git_worktree_verify_for(&dir.0, &created_path)); + } + + #[test] + fn git_worktree_slug_flattens_branch_names() { + assert_eq!(git_worktree_slug("feat/thing"), "feat-thing"); + assert_eq!(git_worktree_slug("release/2.1"), "release-2-1"); + assert_eq!(git_worktree_slug("--weird--"), "weird"); + assert_eq!(git_worktree_slug("///"), "worktree"); + } + + #[test] + fn git_worktree_blocks_parse_porcelain_flags() { + let dir = tmp("git-worktree-flags"); + if !init_git_commit(&dir.0, &[("a.txt", "alpha\n")]) { + return; + } + let created = git_worktree_create_for(&dir.0, "locked-tree", None).unwrap(); + let created_path = PathBuf::from(&created.path); + let _guard = Tmp(created_path.parent().unwrap().to_path_buf()); + if !git(&dir.0, &["worktree", "lock", &created.path]) { + return; + } + let listed = git_worktrees_for(&dir.0); + let linked = listed.entries.iter().find(|entry| !entry.main).unwrap(); + assert!(linked.locked); + assert!(!linked.prunable); + assert!(git(&dir.0, &["worktree", "unlock", &created.path])); + } } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 33eb1886..bb7eb6f8 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -242,6 +242,9 @@ pub fn run() { fs::git_checkout, fs::git_create_branch, fs::git_stash, + fs::git_worktrees, + fs::git_worktree_create, + fs::git_worktree_verify, fs::create_path, fs::rename_path, fs::delete_path, diff --git a/src/App.tsx b/src/App.tsx index 9547b975..da90005a 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -243,6 +243,10 @@ import { type Session, type TurnIntent, } from "./lib/session"; +import { + applySessionCheckout, + type SessionCheckout, +} from "./lib/sessionCheckout"; import { canDispatchQueuedHead, @@ -3031,16 +3035,31 @@ export default function App({ (sessionId: string) => { notifyGitChanged(); const current = sessionsRef.current.find((s) => s.id === sessionId); - if (!current || (!current.branch && !current.worktreeCwd)) return; - if (current.worktreeCwd && current.providerSessionId) { + // The picker switches the branch of the working copy this session already + // runs in, so its checkout does not move. Only `branch` — a leftover pin + // from the removed session-branch feature — has to be cleared. + if (!current?.branch) return; + const next = { ...current, branch: undefined }; + setSessions((prev) => prev.map((s) => (s.id === sessionId ? next : s))); + persistSession(next); + notifyReviewChanged(sessionId); + }, + [persistSession], + ); + + /** Bind a fresh session to the main checkout or one of the repo's worktrees. */ + const onCheckoutChange = useCallback( + (sessionId: string, checkout: SessionCheckout) => { + notifyGitChanged(); + const current = sessionsRef.current.find((s) => s.id === sessionId); + if (!current) return; + const next = applySessionCheckout(current, checkout); + if (next === current) return; + // Any child already bound to the old directory has to go, so the first + // turn starts a harness rooted in the checkout the user picked. + if (current.providerSessionId) { void forgetHarnessSession(current.harness, sessionId); } - const next = { - ...current, - branch: undefined, - worktreeCwd: undefined, - ...(current.worktreeCwd ? { providerSessionId: undefined } : {}), - }; setSessions((prev) => prev.map((s) => (s.id === sessionId ? next : s))); persistSession(next); notifyReviewChanged(sessionId); @@ -5051,6 +5070,7 @@ export default function App({ onClose: onClosePane, onCwdChange, onBranchChange, + onCheckoutChange, onModelChange, onModelSettingsChange, onRuntimeModeChange, diff --git a/src/chrome/CheckoutPicker.tsx b/src/chrome/CheckoutPicker.tsx new file mode 100644 index 00000000..7de143ad --- /dev/null +++ b/src/chrome/CheckoutPicker.tsx @@ -0,0 +1,449 @@ +import { Check, FolderOpen, Lock, Plus, Search } from "./icons"; +import { + useCallback, + useEffect, + useMemo, + useRef, + useState, + type KeyboardEvent as ReactKeyboardEvent, +} from "react"; +import { + gitWorktreeCreate, + gitWorktrees, + notifyGitChanged, + subscribeGitChanged, + type GitWorktreeList, +} from "../lib/fs"; +import { prettyCwd } from "../lib/paths"; +import { + checkoutLabel, + checkoutRows, + defaultStartRef, + isSessionCheckout, + type CheckoutRow, + type SessionCheckout, +} from "../lib/sessionCheckout"; +import { useLockOverscroll } from "../hooks/useLockOverscroll"; +import { useProjectBranchesState } from "../hooks/useProjectBranches"; +import { Popover } from "./Popover"; + +type Props = { + /** Repository the session belongs to. */ + cwd: string; + /** Linked worktree the session is pinned to, if any. */ + worktreeCwd?: string; + enabled?: boolean; + onChange: (checkout: SessionCheckout) => void; + onClose?: () => void; +}; + +const MENU_WIDTH = 320; +const MENU_MIN_HEIGHT = 180; +const MENU_MAX_HEIGHT = 300; + +export function CheckoutPicker({ + cwd, + worktreeCwd, + enabled = true, + onChange, + onClose, +}: Props) { + const [open, setOpen] = useState(false); + const [list, setList] = useState(null); + /** First lookup for this folder has answered, repository or not. */ + const [settled, setSettled] = useState(false); + const [query, setQuery] = useState(""); + const [startRef, setStartRef] = useState(""); + const [active, setActive] = useState(0); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const root = useRef(null); + const search = useRef(null); + const onCloseRef = useRef(onClose); + onCloseRef.current = onClose; + const onChangeRef = useRef(onChange); + onChangeRef.current = onChange; + + const inProject = Boolean(cwd) && cwd !== "~"; + const { branches: projectBranches } = useProjectBranchesState(cwd, open); + const session = useMemo(() => ({ cwd, worktreeCwd }), [cwd, worktreeCwd]); + const liveRef = useRef(0); + + const reload = useCallback(() => { + if (!inProject) { + setList(null); + setSettled(true); + return; + } + // Late answers for a folder we have already left must not overwrite the + // list the user is looking at now. + const token = ++liveRef.current; + gitWorktrees(cwd) + .then((next) => { + if (token !== liveRef.current) return; + setList(next); + setSettled(true); + // The start point defaults to wherever the main checkout sits, so a new + // worktree branches from what the user last had in front of them. + setStartRef((current) => current || defaultStartRef(next)); + }) + .catch(() => { + if (token !== liveRef.current) return; + setList(null); + setSettled(true); + }); + }, [cwd, inProject]); + + // Listed up front, not on open, so the control can stay out of the toolbar + // entirely for a folder that is not a git repository. + useEffect(() => { + setSettled(false); + reload(); + return subscribeGitChanged(reload); + }, [reload]); + + const dismiss = (restore: boolean) => { + setOpen(false); + setQuery(""); + setError(null); + setBusy(false); + setActive(0); + if (restore) onCloseRef.current?.(); + }; + + useEffect(() => { + if (open) search.current?.focus(); + }, [open]); + + useEffect(() => { + if (enabled) return; + setOpen(false); + setBusy(false); + setError(null); + }, [enabled]); + + const localBranches = useMemo( + () => + (projectBranches?.branches ?? []) + .filter((entry) => !entry.remote) + .map((entry) => entry.name), + [projectBranches], + ); + const rows = useMemo( + () => checkoutRows(list, query, localBranches), + [list, localBranches, query], + ); + + useEffect(() => { + setActive((i) => (rows.length === 0 ? 0 : Math.min(i, rows.length - 1))); + }, [rows.length]); + + const failMessage = (err: unknown) => + err instanceof Error ? err.message : String(err); + + const select = (checkout: SessionCheckout) => { + onChangeRef.current(checkout); + dismiss(true); + }; + + const create = async (branch: string) => { + if (busy) return; + setBusy(true); + setError(null); + try { + const created = await gitWorktreeCreate(cwd, branch, startRef); + // A new branch landed in the repository even though this checkout never + // moved, so anything reading refs needs to hear about it. + notifyGitChanged(); + select({ kind: "worktree", path: created.path }); + } catch (err) { + // The main checkout is untouched on failure; keep the menu open with the + // git error so the name or start point can be fixed in place. + setError(failMessage(err)); + setBusy(false); + search.current?.focus(); + } + }; + + const pick = (row: CheckoutRow) => { + if (busy) return; + if (row.kind === "create") { + void create(row.branch); + return; + } + if (isSessionCheckout(session, row.entry)) { + dismiss(true); + return; + } + select( + row.entry.main + ? { kind: "main" } + : { kind: "worktree", path: row.entry.path }, + ); + }; + + const onSearchKey = (e: ReactKeyboardEvent) => { + if (e.key === "ArrowDown") { + e.preventDefault(); + if (rows.length > 0) setActive((i) => Math.min(rows.length - 1, i + 1)); + return; + } + if (e.key === "ArrowUp") { + e.preventDefault(); + if (rows.length > 0) setActive((i) => Math.max(0, i - 1)); + return; + } + if (e.key === "Enter") { + e.preventDefault(); + const row = rows[active]; + if (row) pick(row); + } + }; + + const selected = useMemo( + () => (list?.entries ?? []).find((entry) => isSessionCheckout(session, entry)), + [list, session], + ); + // The trigger has to read correctly before the list has loaded, and a pinned + // worktree already knows its own folder name. + const label = selected + ? checkoutLabel(selected) + : worktreeCwd + ? checkoutLabel({ + path: worktreeCwd, + branch: null, + main: false, + prunable: false, + locked: false, + }) + : "Main checkout"; + const creating = rows[active]?.kind === "create"; + + // Nothing to choose between until git has answered, and nothing at all when + // the folder is not a repository — the branch picker already says so. + if (!settled || (list?.entries.length ?? 0) === 0) return null; + + return ( +
+ + {open ? ( + dismiss(reason === "escape")} + role="dialog" + aria-label="Checkout picker" + data-checkout-picker + className="flex flex-col overflow-hidden" + > + + + {creating ? ( + + ) : null} + {creating && list?.parent ? ( +

+ in {prettyCwd(list.parent)} +

+ ) : null} + {error ? ( +

+ {error} +

+ ) : null} +
+ ) : null} +
+ ); +} + +function CheckoutList({ + rows, + active, + busy, + selectedPath, + mainSelected, + emptyLabel, + onActive, + onPick, +}: { + rows: CheckoutRow[]; + active: number; + busy: boolean; + selectedPath?: string; + mainSelected: boolean; + emptyLabel: string; + onActive: (index: number) => void; + onPick: (row: CheckoutRow) => void; +}) { + const lockOverscroll = useLockOverscroll(); + const activeRef = useRef(null); + + useEffect(() => { + activeRef.current?.scrollIntoView({ block: "nearest" }); + }, [active]); + + if (rows.length === 0) { + return ( +
{emptyLabel}
+ ); + } + + return ( +
+ {rows.map((row, index) => { + const highlighted = index === active; + const selected = + row.kind === "checkout" && + (row.entry.main ? mainSelected : row.entry.path === selectedPath); + return ( + + ); + })} +
+ ); +} diff --git a/src/chrome/Composer.tsx b/src/chrome/Composer.tsx index 1e29f3c4..08b3566b 100644 --- a/src/chrome/Composer.tsx +++ b/src/chrome/Composer.tsx @@ -88,7 +88,9 @@ import { ComposerRunner } from "./ComposerRunner"; import { ContextMeter } from "./ContextMeter"; import { AttachmentChip } from "./AttachmentChip"; import { BranchPicker } from "./BranchPicker"; +import { CheckoutPicker } from "./CheckoutPicker"; import { CwdPicker } from "./CwdPicker"; +import type { SessionCheckout } from "../lib/sessionCheckout"; import { FileMentionPicker } from "./FileMentionPicker"; import { FileTypeIcon } from "./FileTypeIcon"; import { InboxMiniCard } from "./InboxMiniCard"; @@ -137,6 +139,11 @@ type Props = { recents?: RecentProject[]; hideProjectPicker?: boolean; hideBranchPicker?: boolean; + /** + * Linked worktree this session runs in. Only meaningful together with + * `onCheckoutChange`, which is what turns the checkout picker on. + */ + worktreeCwd?: string; hideTopBar?: boolean; context?: ContextUsage; compactSupported?: boolean; @@ -153,6 +160,8 @@ type Props = { onFocus: () => void; onCwdChange: (cwd: string) => void; onBranchChange?: () => void; + /** Absent once the session has run a turn: the checkout is chosen once. */ + onCheckoutChange?: (checkout: SessionCheckout) => void; onNewTerminal?: () => void; onModelChange: (harness: HarnessId, model: string) => void; onModelSettingsChange?: (settings: Record) => void; @@ -391,6 +400,7 @@ export function Composer({ recents = [], hideProjectPicker = false, hideBranchPicker = false, + worktreeCwd, hideTopBar = false, context, compactSupported = false, @@ -406,6 +416,7 @@ export function Composer({ onFocus, onCwdChange, onBranchChange, + onCheckoutChange, onNewTerminal, onModelChange, onModelSettingsChange, @@ -1171,9 +1182,20 @@ export function Composer({ onClose={() => ref.current?.focus()} /> )} + {onCheckoutChange ? ( + ref.current?.focus()} + /> + ) : null} {hideBranchPicker ? null : ( ({ invoke: vi.fn() })); +vi.mock("@tauri-apps/api/core", () => ({ + invoke: mocks.invoke, + convertFileSrc: (path: string) => path, +})); +vi.mock("@tauri-apps/plugin-dialog", () => ({ open: vi.fn() })); describe("isCheckoutBlockedByChanges", () => { it("detects git's tracked-file checkout error", () => { @@ -31,3 +38,78 @@ describe("isCheckoutBlockedByChanges", () => { expect(isCheckoutBlockedByChanges("Not a git repository")).toBe(false); }); }); + +describe("restoreSessionCheckout", () => { + const REPO = "/projects/monocode"; + const WORKTREE = "/projects/monocode-worktrees/feat-picker"; + + beforeEach(() => { + mocks.invoke.mockReset(); + }); + + it("leaves an unpinned session untouched without asking git", async () => { + const session = { cwd: REPO, providerSessionId: "acp-1" }; + await expect(restoreSessionCheckout(session)).resolves.toBe(session); + expect(mocks.invoke).not.toHaveBeenCalled(); + }); + + it("keeps the pin while the worktree is still a tree of the repo", async () => { + mocks.invoke.mockResolvedValue(true); + const session = { + cwd: REPO, + worktreeCwd: WORKTREE, + providerSessionId: "acp-1", + }; + await expect(restoreSessionCheckout(session)).resolves.toBe(session); + expect(mocks.invoke).toHaveBeenCalledWith("git_worktree_verify", { + cwd: REPO, + path: WORKTREE, + }); + }); + + it("falls back to the main checkout when the worktree is gone", async () => { + mocks.invoke.mockResolvedValue(false); + const restored = await restoreSessionCheckout({ + cwd: REPO, + worktreeCwd: WORKTREE, + providerSessionId: "acp-1", + }); + expect(restored.worktreeCwd).toBeUndefined(); + expect(restored.providerSessionId).toBeUndefined(); + expect(restored.cwd).toBe(REPO); + }); + + it("falls back when the verify call itself fails", async () => { + mocks.invoke.mockRejectedValue(new Error("Not a git repository")); + const restored = await restoreSessionCheckout({ + cwd: REPO, + worktreeCwd: WORKTREE, + }); + expect(restored.worktreeCwd).toBeUndefined(); + }); + + it("drops the dead branch pin but keeps a live worktree", async () => { + mocks.invoke.mockResolvedValue(true); + const restored = await restoreSessionCheckout({ + cwd: REPO, + branch: "feat/old", + worktreeCwd: WORKTREE, + providerSessionId: "acp-1", + }); + expect(restored.branch).toBeUndefined(); + expect(restored.worktreeCwd).toBe(WORKTREE); + // The directory did not move, so the conversation is still valid. + expect(restored.providerSessionId).toBe("acp-1"); + }); + + it("drops a branch-only pin without touching git", async () => { + const restored = await restoreSessionCheckout({ + cwd: REPO, + branch: "feat/old", + providerSessionId: "acp-1", + }); + expect(restored.branch).toBeUndefined(); + expect(restored.providerSessionId).toBe("acp-1"); + expect(mocks.invoke).not.toHaveBeenCalled(); + }); +}); diff --git a/src/lib/fs.ts b/src/lib/fs.ts index 9bb1cd83..6e9eb774 100644 --- a/src/lib/fs.ts +++ b/src/lib/fs.ts @@ -275,15 +275,79 @@ export function isCheckoutBlockedByChanges(message: string): boolean { ); } -/** Drop leftover session-worktree pins. The composer now switches this folder. */ -export function restoreSessionCheckout< - T extends { cwd: string; branch?: string; worktreeCwd?: string; providerSessionId?: string }, ->(session: T): T { +export type GitWorktreeEntry = { + path: string; + branch: string | null; + /** The repository's primary working tree. */ + main: boolean; + /** Registered, but its directory is gone from disk. */ + prunable: boolean; + locked: boolean; +}; + +export type GitWorktreeList = { + entries: GitWorktreeEntry[]; + /** Folder a new worktree would be created in, or null outside a repo. */ + parent: string | null; +}; + +export function gitWorktrees(cwd: string): Promise { + return invoke("git_worktrees", { cwd }); +} + +/** + * Create a linked worktree on a new branch. Never moves the main checkout's + * HEAD, so a rejected name or start point leaves this folder untouched. + */ +export function gitWorktreeCreate( + cwd: string, + branch: string, + startRef?: string | null, +): Promise { + return invoke("git_worktree_create", { + cwd, + branch, + startRef: startRef ?? null, + }); +} + +/** True while `path` is still a working tree of the repository at `cwd`. */ +export function gitWorktreeVerify(cwd: string, path: string): Promise { + return invoke("git_worktree_verify", { cwd, path }); +} + +/** + * Re-bind a restored session to the checkout it was started in. + * + * A session picks its working directory once, at creation, so the pin has to + * survive restarts. It is only dropped when the worktree is really gone — + * falling back to the main checkout beats resuming a harness in a directory + * that no longer exists. `branch` is a leftover of the removed implicit + * session-branch feature and is always cleared. + */ +export async function restoreSessionCheckout< + T extends { + cwd: string; + branch?: string; + worktreeCwd?: string; + providerSessionId?: string; + }, +>(session: T): Promise { if (!session.branch && !session.worktreeCwd) return session; + const keep = + session.worktreeCwd != null && + (await gitWorktreeVerify(session.cwd, session.worktreeCwd).catch( + () => false, + )); + if (keep) { + return session.branch ? { ...session, branch: undefined } : session; + } return { ...session, branch: undefined, worktreeCwd: undefined, + // The harness child was bound to a directory that is gone; a fresh + // provider session avoids resuming a conversation rooted there. ...(session.worktreeCwd ? { providerSessionId: undefined } : {}), }; } diff --git a/src/lib/session.ts b/src/lib/session.ts index d1cb780e..f62efe34 100644 --- a/src/lib/session.ts +++ b/src/lib/session.ts @@ -234,7 +234,10 @@ export type Session = { * kept so older session records still load. */ branch?: string; - /** Extra git worktree from the old session-branch feature. Unused. */ + /** + * Linked git worktree this session was started in, chosen once at creation. + * Absent for a session that runs in the repository's main checkout. + */ worktreeCwd?: string; /** One-shot composer text when opening a session from Inbox. */ composerSeed?: string; diff --git a/src/lib/sessionCheckout.test.ts b/src/lib/sessionCheckout.test.ts new file mode 100644 index 00000000..02d1cf5a --- /dev/null +++ b/src/lib/sessionCheckout.test.ts @@ -0,0 +1,180 @@ +import { describe, expect, it } from "vitest"; +import type { GitWorktreeEntry, GitWorktreeList } from "./fs"; +import { newSession, sessionWorkCwd, type Session } from "./session"; +import { + applySessionCheckout, + checkoutLabel, + checkoutRows, + defaultStartRef, + isSessionCheckout, + selectableWorktrees, + sessionCheckout, +} from "./sessionCheckout"; + +function entry( + path: string, + branch: string | null, + overrides: Partial = {}, +): GitWorktreeEntry { + return { + path, + branch, + main: false, + prunable: false, + locked: false, + ...overrides, + }; +} + +const REPO = "/projects/monocode"; + +const LIST: GitWorktreeList = { + parent: "/projects/monocode-worktrees", + entries: [ + entry(REPO, "main", { main: true }), + entry("/projects/monocode-worktrees/feat-picker", "feat/picker"), + entry("/projects/monocode-worktrees/gone", "gone", { prunable: true }), + ], +}; + +function session(cwd = REPO): Session { + return newSession("cursor", cwd); +} + +describe("sessionCheckout", () => { + it("reads the main checkout as the absence of a pin", () => { + expect(sessionCheckout(session())).toEqual({ kind: "main" }); + expect( + sessionCheckout({ ...session(), worktreeCwd: "/projects/wt" }), + ).toEqual({ kind: "worktree", path: "/projects/wt" }); + }); +}); + +describe("applySessionCheckout", () => { + it("pins a worktree without moving the session off its project", () => { + const bound = applySessionCheckout(session(), { + kind: "worktree", + path: "/projects/monocode-worktrees/feat-picker", + }); + expect(bound.cwd).toBe(REPO); + expect(bound.worktreeCwd).toBe("/projects/monocode-worktrees/feat-picker"); + expect(sessionWorkCwd(bound)).toBe( + "/projects/monocode-worktrees/feat-picker", + ); + }); + + it("returns to the main checkout by clearing the pin", () => { + const pinned = { + ...session(), + worktreeCwd: "/projects/monocode-worktrees/feat-picker", + }; + const bound = applySessionCheckout(pinned, { kind: "main" }); + expect(bound.worktreeCwd).toBeUndefined(); + expect(sessionWorkCwd(bound)).toBe(REPO); + }); + + it("drops the provider conversation when the directory changes", () => { + const pinned = { ...session(), providerSessionId: "acp-1", branch: "old" }; + const bound = applySessionCheckout(pinned, { + kind: "worktree", + path: "/projects/monocode-worktrees/feat-picker", + }); + expect(bound.providerSessionId).toBeUndefined(); + expect(bound.branch).toBeUndefined(); + }); + + it("leaves the session alone when the checkout did not change", () => { + const pinned = { + ...session(), + worktreeCwd: "/projects/wt", + providerSessionId: "acp-1", + }; + expect(applySessionCheckout(pinned, sessionCheckout(pinned))).toBe(pinned); + const main = { ...session(), providerSessionId: "acp-1" }; + expect(applySessionCheckout(main, { kind: "main" })).toBe(main); + }); +}); + +describe("isSessionCheckout", () => { + it("marks the main entry for an unpinned session", () => { + const unpinned = session(); + expect(isSessionCheckout(unpinned, LIST.entries[0]!)).toBe(true); + expect(isSessionCheckout(unpinned, LIST.entries[1]!)).toBe(false); + }); + + it("marks the pinned path for a worktree session", () => { + const pinned = { + ...session(), + worktreeCwd: "/projects/monocode-worktrees/feat-picker", + }; + expect(isSessionCheckout(pinned, LIST.entries[0]!)).toBe(false); + expect(isSessionCheckout(pinned, LIST.entries[1]!)).toBe(true); + }); +}); + +describe("checkoutRows", () => { + it("lists the main checkout first and hides prunable worktrees", () => { + const rows = checkoutRows(LIST, ""); + expect(rows).toHaveLength(2); + expect(rows[0]).toEqual({ kind: "checkout", entry: LIST.entries[0] }); + expect(rows[1]).toEqual({ kind: "checkout", entry: LIST.entries[1] }); + expect(selectableWorktrees(LIST)).toHaveLength(2); + }); + + it("offers a create row for an unused name, ahead of the matches", () => { + const rows = checkoutRows(LIST, "feat/next", ["main"]); + expect(rows[0]).toEqual({ kind: "create", branch: "feat/next" }); + expect(rows).toHaveLength(1); + }); + + it("withholds create when a branch or worktree already owns the name", () => { + expect(checkoutRows(LIST, "main", ["main"])).toEqual([ + { kind: "checkout", entry: LIST.entries[0] }, + ]); + expect(checkoutRows(LIST, "feat/picker", ["main"])).toEqual([ + { kind: "checkout", entry: LIST.entries[1] }, + ]); + }); + + it("matches on branch, folder, and the main checkout's label", () => { + expect(checkoutRows(LIST, "picker", ["main", "feat/picker"])).toEqual([ + // "picker" is free as a branch name, so creating stays on offer next to + // the tree it partially matches. + { kind: "create", branch: "picker" }, + { kind: "checkout", entry: LIST.entries[1] }, + ]); + expect(checkoutRows(LIST, "monocode-worktrees/feat", ["main"])).toEqual([ + { kind: "create", branch: "monocode-worktrees/feat" }, + { kind: "checkout", entry: LIST.entries[1] }, + ]); + expect(checkoutRows(LIST, "Main checkout", ["main"])).toEqual([ + { kind: "create", branch: "Main checkout" }, + { kind: "checkout", entry: LIST.entries[0] }, + ]); + }); + + it("has no rows at all outside a repository", () => { + expect(checkoutRows(null, "")).toEqual([]); + expect(checkoutRows(null, "feature")).toEqual([ + { kind: "create", branch: "feature" }, + ]); + }); +}); + +describe("checkoutLabel", () => { + it("names the main checkout, the branch, then the folder", () => { + expect(checkoutLabel(LIST.entries[0]!)).toBe("Main checkout"); + expect(checkoutLabel(LIST.entries[1]!)).toBe("feat/picker"); + expect(checkoutLabel(entry("/projects/wt/detached", null))).toBe("detached"); + }); +}); + +describe("defaultStartRef", () => { + it("starts new worktrees from the main checkout's branch", () => { + expect(defaultStartRef(LIST)).toBe("main"); + expect(defaultStartRef(null)).toBe(""); + expect( + defaultStartRef({ parent: null, entries: [entry("/a", null, { main: true })] }), + ).toBe(""); + }); +}); diff --git a/src/lib/sessionCheckout.ts b/src/lib/sessionCheckout.ts new file mode 100644 index 00000000..20d665b0 --- /dev/null +++ b/src/lib/sessionCheckout.ts @@ -0,0 +1,126 @@ +import { basename, type GitWorktreeEntry, type GitWorktreeList } from "./fs"; + +/** + * Where a session runs inside its repository. + * + * Sessions keep the repository root in `cwd` and pin a linked worktree in + * `worktreeCwd`; the main checkout is simply the absence of a pin. The choice + * is made once, explicitly, when the session is created — an earlier version of + * MonoCode moved sessions into worktrees implicitly from the branch picker and + * that surprise is exactly what this avoids. + */ +export type SessionCheckout = + | { kind: "main" } + | { kind: "worktree"; path: string }; + +type CheckoutSession = { + cwd: string; + branch?: string; + worktreeCwd?: string; + providerSessionId?: string; +}; + +export type CheckoutRow = + | { kind: "checkout"; entry: GitWorktreeEntry } + | { kind: "create"; branch: string }; + +/** Checkout a session is bound to right now. */ +export function sessionCheckout(session: CheckoutSession): SessionCheckout { + return session.worktreeCwd + ? { kind: "worktree", path: session.worktreeCwd } + : { kind: "main" }; +} + +/** True when this working tree is the one the session already runs in. */ +export function isSessionCheckout( + session: CheckoutSession, + entry: GitWorktreeEntry, +): boolean { + return session.worktreeCwd ? entry.path === session.worktreeCwd : entry.main; +} + +/** + * Bind a session to a checkout. + * + * Only `worktreeCwd` moves. `cwd` stays the repository the session belongs to, + * so the project rail, recents, and session history keep grouping it with its + * siblings whichever tree it runs in. A provider conversation is rooted in the + * directory it started in, so its id is dropped whenever that directory + * changes and the next turn opens a fresh one. + */ +export function applySessionCheckout( + session: T, + checkout: SessionCheckout, +): T { + const worktreeCwd = checkout.kind === "worktree" ? checkout.path : undefined; + if ((session.worktreeCwd || undefined) === worktreeCwd) return session; + return { + ...session, + worktreeCwd, + // Leftover pin from the removed session-branch feature; never carry it into + // a checkout it was never about. + branch: undefined, + providerSessionId: undefined, + }; +} + +/** Row title: the main checkout, else the branch, else the folder name. */ +export function checkoutLabel(entry: GitWorktreeEntry): string { + if (entry.main) return "Main checkout"; + return entry.branch || basename(entry.path); +} + +/** Everything a picker query is matched against for one working tree. */ +export function checkoutSearchText(entry: GitWorktreeEntry): string { + return `${checkoutLabel(entry)} ${entry.branch ?? ""} ${entry.path}`; +} + +/** + * Picker rows for `query`: a create row when the query names something new, + * then every working tree that matches. + * + * `branches` are the repository's local branch names. Creating reuses the query + * as a branch name, so the row is withheld when a branch already owns it rather + * than letting the user walk into a git error. + */ +export function checkoutRows( + list: GitWorktreeList | null, + query: string, + branches: readonly string[] = [], +): CheckoutRow[] { + const entries = selectableWorktrees(list); + const name = query.trim(); + const needle = name.toLowerCase(); + const matches = needle + ? entries.filter((entry) => + checkoutSearchText(entry).toLowerCase().includes(needle), + ) + : entries; + const taken = + branches.includes(name) || entries.some((entry) => entry.branch === name); + const create: CheckoutRow[] = + name && !taken ? [{ kind: "create", branch: name }] : []; + return [ + ...create, + ...matches.map((entry): CheckoutRow => ({ kind: "checkout", entry })), + ]; +} + +/** + * Working trees a session can actually start in. A prunable entry is registered + * but its folder is gone, so offering it would only produce a failing spawn. + */ +export function selectableWorktrees( + list: GitWorktreeList | null, +): GitWorktreeEntry[] { + return (list?.entries ?? []).filter((entry) => !entry.prunable); +} + +/** + * Start point a new worktree defaults to: the branch the main checkout is on. + * Empty when the repository has no main entry or is detached, in which case the + * backend falls back to HEAD. + */ +export function defaultStartRef(list: GitWorktreeList | null): string { + return list?.entries.find((entry) => entry.main)?.branch ?? ""; +} diff --git a/src/surfaces/PaneTree.tsx b/src/surfaces/PaneTree.tsx index b8bebc96..586a0bee 100644 --- a/src/surfaces/PaneTree.tsx +++ b/src/surfaces/PaneTree.tsx @@ -31,6 +31,7 @@ import { type Session, type TurnIntent, } from "../lib/session"; +import type { SessionCheckout } from "../lib/sessionCheckout"; import { FilePane } from "./FilePane"; import { SessionPane } from "./SessionPane"; @@ -55,6 +56,7 @@ type Shared = { onRatio: (splitId: string, index: number, ratio: number) => void; onCwdChange: (sessionId: string, cwd: string) => void; onBranchChange: (sessionId: string) => void; + onCheckoutChange: (sessionId: string, checkout: SessionCheckout) => void; onModelChange: (sessionId: string, harness: HarnessId, model: string) => void; onModelSettingsChange: ( sessionId: string, @@ -153,6 +155,7 @@ function PaneTreeComponent({ onRatio, onCwdChange, onBranchChange, + onCheckoutChange, onModelChange, onModelSettingsChange, onRuntimeModeChange, @@ -358,6 +361,7 @@ function PaneTreeComponent({ onClose={onClose} onCwdChange={onCwdChange} onBranchChange={onBranchChange} + onCheckoutChange={onCheckoutChange} onModelChange={onModelChange} onModelSettingsChange={onModelSettingsChange} onRuntimeModeChange={onRuntimeModeChange} diff --git a/src/surfaces/SessionPane.tsx b/src/surfaces/SessionPane.tsx index 3bff1dbe..9cca3fe5 100644 --- a/src/surfaces/SessionPane.tsx +++ b/src/surfaces/SessionPane.tsx @@ -28,6 +28,7 @@ import { type Session, type TurnIntent, } from "../lib/session"; +import type { SessionCheckout } from "../lib/sessionCheckout"; import { AgentTranscript } from "./AgentTranscript"; import { EmptySession } from "./EmptySession"; import { MOD } from "../lib/platform"; @@ -57,6 +58,7 @@ type Props = { onClose: (sessionId: string) => void; onCwdChange: (sessionId: string, cwd: string) => void; onBranchChange: (sessionId: string) => void; + onCheckoutChange: (sessionId: string, checkout: SessionCheckout) => void; onModelChange: (sessionId: string, harness: HarnessId, model: string) => void; onModelSettingsChange: ( sessionId: string, @@ -134,6 +136,7 @@ export const SessionPane = memo(function SessionPane({ onClose, onCwdChange, onBranchChange, + onCheckoutChange, onModelChange, onModelSettingsChange, onRuntimeModeChange, @@ -258,6 +261,15 @@ export const SessionPane = memo(function SessionPane({ (hideProjectPicker ? !showDeckProjectPicker : false) } hideBranchPicker={!!session.inboxAsk} + worktreeCwd={session.worktreeCwd} + // The checkout is a session-creation choice: once a turn has run, the + // harness child and its transcript are rooted in that directory and the + // picker goes away rather than silently moving them. + onCheckoutChange={ + isEmpty && !session.inboxAsk && looksLikeProject(session.cwd) + ? (checkout) => onCheckoutChange(session.id, checkout) + : undefined + } hideTopBar={!!session.inboxAsk} context={session.context} quoteRequest={quoteRequest}