diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index de504250e..677ddef64 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -573,8 +573,9 @@ pub struct Task { /// Multi-repo composition. Empty for single-repo tasks (the /// usual case — `path` already points at the worktree of the one /// project this task belongs to). For tasks created - /// under a `ProjectType::Multi` project this lists the host repo - /// + every member with its resolved on-disk path. The PTY spawn + /// under a `ProjectType::Multi` project this lists every NON-host + /// member with its resolved on-disk path — the host is not a member, + /// `task.path` is already its worktree. The PTY spawn /// + sandbox profile generator iterate this list when populated. #[serde(default)] pub composition: Vec, @@ -809,10 +810,9 @@ impl Task { } } -/// One entry in a multi-repo task's composition. The host repo -/// itself is the first member (its `path` is the task wrapper -/// dir, which IS a git worktree of the host); subsequent entries are -/// the user-picked member repos worktree'd or symlinked inside it. +/// One entry in a multi-repo task's composition: a user-picked member +/// repo worktree'd or symlinked inside the task wrapper dir. The host +/// repo is NOT in this list — the task's own `path` is its worktree. #[derive(Clone, Debug, Serialize, Deserialize, Default)] #[serde(default)] pub struct TaskMember { @@ -7062,8 +7062,8 @@ pub struct CreateMultiArgs { #[derive(Clone, Debug, Serialize, Deserialize)] pub struct CreateMultiMember { - /// Canonical path of the host member this per-task spec applies - /// to — matches an entry in `Project.members[].root_path`. + /// Canonical path of the member repo this per-task spec applies + /// to — matches an entry in the host project's `members[].root_path`. pub root_path: String, /// Dir name inside the wrapper. Defaults to the member's `name` — /// pinned at create time so renames don't break the task layout. @@ -11699,6 +11699,16 @@ fn git_repo_status(name: String, dir_name: String, kind: &str, p: &Path) -> GitR } } +/// The task's member checkouts that are real dirs on disk — the one place +/// the "which members can we operate on" contract lives. Skips empty paths, +/// paths that no longer exist, and a member whose path IS the host's (only +/// legacy records can carry that; composition lists non-host members). +fn on_disk_members(w: &Task) -> impl Iterator { + w.composition + .iter() + .filter(|m| !m.path.is_empty() && m.path != w.path && Path::new(&m.path).exists()) +} + #[tauri::command] async fn task_git_status(id: String) -> Result { tauri::async_runtime::spawn_blocking(move || { @@ -11710,14 +11720,12 @@ async fn task_git_status(id: String) -> Result { .unwrap_or_else(|| w.name.clone()); let mut repos = vec![git_repo_status(host_name, String::new(), "host", Path::new(&w.path))]; - for m in &w.composition { - let member_path = Path::new(&m.path); - if !member_path.exists() { continue; } + for m in on_disk_members(&w) { let kind = match m.mode { MemberMode::Worktree => "worktree", MemberMode::RepoRoot => "repo_root", }; - repos.push(git_repo_status(m.dir_name.clone(), m.dir_name.clone(), kind, member_path)); + repos.push(git_repo_status(m.dir_name.clone(), m.dir_name.clone(), kind, Path::new(&m.path))); } let total_changed = repos.iter().map(|r| r.changed).sum(); @@ -12088,6 +12096,49 @@ async fn task_git_update(id: String, dir_name: String, mode: UpdateMode) -> Resu .map_err(|e| e.to_string())? } +/// Per-repo outcome of `task_git_update_all`. The batch never aborts on one +/// repo's failure — the UI summarizes them all. +#[derive(Serialize)] +struct UpdateAllItem { + /// Display name: project name for the host, dir_name for a member. + name: String, + /// Set when the update ran to a verdict (possibly a conflicted one). + result: Option, + /// Set when the update could not start: no upstream, detached HEAD, + /// missing remote, a merge that never got off the ground. + error: Option, +} + +/// Bulk "Update" for multi-repo tasks: runs the same `git_update_repo` the +/// per-repo menu uses over the host plus every on-disk member — each against +/// its OWN base branch (`repo_base_branch`) — sequentially, collecting every +/// outcome. +#[tauri::command] +async fn task_git_update_all(id: String, mode: UpdateMode) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || -> Result, String> { + let w = load_tasks_all().into_iter().find(|w| w.id == id).ok_or("no task")?; + let projects = load_projects_all(); + let host_name = projects.iter() + .find(|p| p.id == w.project_id) + .map(|p| p.name.clone()) + .unwrap_or_else(|| w.name.clone()); + // The same repo set task_git_status enumerates: host, then members + // whose checkout is on disk. + let mut repos = vec![(String::new(), host_name, PathBuf::from(&w.path))]; + for m in on_disk_members(&w) { + repos.push((m.dir_name.clone(), m.dir_name.clone(), PathBuf::from(&m.path))); + } + Ok(repos.iter().map(|(dir, name, cwd)| { + match git_update_repo(cwd, mode, &repo_base_branch(&w, dir, &projects)) { + Ok(result) => UpdateAllItem { name: name.clone(), result: Some(result), error: None }, + Err(e) => UpdateAllItem { name: name.clone(), result: None, error: Some(e) }, + } + }).collect()) + }) + .await + .map_err(|e| e.to_string())? +} + /// What the Git tab's update menu can offer for this repo. Resolved lazily on /// dropdown-open rather than carried on the 4s-polled status payload — two /// extra subprocesses per repo per poll to serve a menu nobody opened. @@ -13147,30 +13198,38 @@ pub struct PrLookup { pub pr: Option, } -fn pr_lookup_blocking(id: &str) -> Result { - let w = load_tasks_all().into_iter().find(|w| w.id == id).ok_or("no task")?; - let cwd = PathBuf::from(&w.path); - // Cached, no network: a poll on a non-forge repo must cost a hashmap - // read, not a subprocess, because the PR card polls every task the user - // opens the Git tab on. - let (provider, remote_url) = forge::provider_for_repo(&cwd, &detect_default_remote(&cwd)); +/// The shared lookup body: resolve the repo's forge off its default remote, +/// find the branch's PR (falling back to `known_number`), and map every +/// failure onto the status vocabulary the card renders. `known_*` are the +/// stored identity a TASK record carries - member lookups pass None, because +/// a member resolves by branch only (its checkout's HEAD IS the member +/// branch) and nothing persists a member identity anywhere. +fn pr_lookup_at( + cwd: &Path, + known_number: Option, + known_provider: Option<&str>, +) -> PrLookup { + // No network, no forge CLI: provider resolution is a cached lookup, and + // detecting the default remote is one `git remote` subprocess - the + // cheap floor a poll on a non-forge repo should cost. + let (provider, remote_url) = forge::provider_for_repo(cwd, &detect_default_remote(cwd)); if remote_url.is_empty() { - return Ok(PrLookup { + return PrLookup { provider: None, remote_url, status: "no-remote".into(), message: "No git remote configured. Push the repo to GitHub, GitLab, or Azure DevOps first.".into(), pr: None, - }); + }; } let Some(provider) = provider else { - return Ok(PrLookup { + return PrLookup { provider: None, remote_url: remote_url.clone(), status: "unsupported-remote".into(), message: format!("Remote {} is not a GitHub, GitLab, or Azure DevOps host.", forge::remote_for_display(&remote_url)), pr: None, - }); + }; }; // Resolve by BRANCH first, and only fall back to the stored number. // @@ -13189,38 +13248,23 @@ fn pr_lookup_blocking(id: &str) -> Result { // `pr_provider` unset is a legacy task that predates the field - trust // its stored number under the CURRENT provider (a stale cross-forge // number just comes back "not found" and degrades to None). - let known_number = w.pr_number - .filter(|_| w.pr_provider.as_deref().map_or(true, |p| p == provider)); - let by_branch = forge::pr_status(provider, &cwd, None); + let known_number = + known_number.filter(|_| known_provider.map_or(true, |p| p == provider)); + let by_branch = forge::pr_status(provider, cwd, None); let resolved = match by_branch { Ok(Some(pr)) => Ok(Some(pr)), - Ok(None) if known_number.is_some() => forge::pr_status(provider, &cwd, known_number), + Ok(None) if known_number.is_some() => forge::pr_status(provider, cwd, known_number), other => other, }; match resolved { - Ok(pr) => { - // Cache the identity on the task record so future polls - // (and the next app launch) can resolve by number. - if let Some(ref p) = pr { - if w.pr_number != Some(p.number) || w.pr_provider.as_deref() != Some(provider) { - let mut list = load_tasks_all(); - if let Some(wm) = list.iter_mut().find(|x| x.id == id) { - wm.pr_url = Some(p.url.clone()); - wm.pr_number = Some(p.number); - wm.pr_provider = Some(provider.to_string()); - let _ = save_task(wm); - } - } - } - Ok(PrLookup { - provider: Some(provider.to_string()), - remote_url, - status: "ok".into(), - message: String::new(), - pr, - }) - } - Err(forge::ForgeError::CliMissing(cli)) => Ok(PrLookup { + Ok(pr) => PrLookup { + provider: Some(provider.to_string()), + remote_url, + status: "ok".into(), + message: String::new(), + pr, + }, + Err(forge::ForgeError::CliMissing(cli)) => PrLookup { provider: Some(provider.to_string()), remote_url, status: "cli-missing".into(), @@ -13229,24 +13273,83 @@ fn pr_lookup_blocking(id: &str) -> Result { if provider == forge::GITLAB { "merge requests" } else { "pull requests" } )), pr: None, - }), - Err(forge::ForgeError::Auth(msg)) => Ok(PrLookup { + }, + Err(forge::ForgeError::Auth(msg)) => PrLookup { provider: Some(provider.to_string()), remote_url, status: "cli-unauthed".into(), message: msg, pr: None, - }), - Err(forge::ForgeError::Other(msg)) => Ok(PrLookup { + }, + Err(forge::ForgeError::Other(msg)) => PrLookup { provider: Some(provider.to_string()), remote_url, status: "error".into(), message: msg, pr: None, - }), + }, } } +fn pr_lookup_blocking(id: &str) -> Result { + let w = load_tasks_all().into_iter().find(|w| w.id == id).ok_or("no task")?; + let lookup = pr_lookup_at(Path::new(&w.path), w.pr_number, w.pr_provider.as_deref()); + // Cache the identity on the task record so future polls (and the next + // app launch) can resolve by number. + if let (Some(p), Some(provider)) = (&lookup.pr, lookup.provider.as_deref()) { + if w.pr_number != Some(p.number) || w.pr_provider.as_deref() != Some(provider) { + let mut list = load_tasks_all(); + if let Some(wm) = list.iter_mut().find(|x| x.id == id) { + wm.pr_url = Some(p.url.clone()); + wm.pr_number = Some(p.number); + wm.pr_provider = Some(provider.to_string()); + let _ = save_task(wm); + } + } + } + Ok(lookup) +} + +/// A member's PR/MR lookup inside a multi-repo task: the `PrLookup` +/// payload plus which member it belongs to. `composition` holds members +/// only — the host is the task's own `path`, already polled by +/// `task_pr_status` (`m.path != w.path` is kept as a defensive filter for +/// records that predate that contract). +#[derive(Serialize)] +pub struct MemberPrLookup { + pub dir_name: String, + /// The member's LIVE branch (`git branch --show-current`), not the + /// frozen `m.branch`: a `task_git_checkout` moves a worktree's HEAD + /// without updating the record, and repo_root members freeze "". + /// The PR resolves by that same HEAD, so the label must match it. + pub branch: String, + #[serde(flatten)] + pub lookup: PrLookup, +} + +fn member_pr_lookups(id: &str) -> Result, String> { + let w = load_tasks_all().into_iter().find(|w| w.id == id).ok_or("no task")?; + Ok(on_disk_members(&w) + .map(|m| { + let cwd = Path::new(&m.path); + // Frozen `m.branch` is the create-time value; read HEAD like + // GitRepo.branch does so the row can't label a PR with a + // branch the lookup didn't resolve. Falls back to the frozen + // name on detached HEAD / unreadable repo. + let branch = git(&["branch", "--show-current"], cwd) + .ok() + .map(|s| s.trim().to_string()) + .filter(|b| !b.is_empty()) + .unwrap_or_else(|| m.branch.clone()); + MemberPrLookup { + dir_name: m.dir_name.clone(), + branch, + lookup: pr_lookup_at(cwd, None, None), + } + }) + .collect()) +} + /// One issue-list round-trip: provider resolution + the open issues, or /// the exact reason there are none to show. Mirrors `PrLookup`'s shape so /// the New Task dialog can reuse the PR card's explain-yourself copy @@ -13513,6 +13616,16 @@ async fn task_pr_status(id: String) -> Result { .map_err(|e| e.to_string())? } +/// Live PR/MR status for each non-host member of a multi-repo task. Same +/// cadence contract as `task_pr_status` (the pr store folds it into the +/// task's refresh) - one forge CLI call per member with a forge remote. +#[tauri::command] +async fn task_member_pr_status(id: String) -> Result, String> { + tauri::async_runtime::spawn_blocking(move || member_pr_lookups(&id)) + .await + .map_err(|e| e.to_string())? +} + /// Push the branch (setting upstream if needed) and create the PR/MR. /// Returns the fresh lookup so the card can render the new PR without a /// second round-trip. @@ -24409,9 +24522,9 @@ pub fn run() { task_spotlight_start, task_spotlight_stop, task_spotlight_resync, task_spotlight_status, task_diff, task_diff_stat, task_files, desktop_integration_status, desktop_integration_add, desktop_integration_remove, task_list_files_for_finder, task_match_ignored_files, task_send_diff_to_main, - task_changes, task_git_status, task_git_branches, project_git_branches, project_branch_context, task_git_checkout, task_git_update, task_git_update_info, task_stage, task_unstage, task_commit, task_discard, + task_changes, task_git_status, task_git_branches, project_git_branches, project_branch_context, task_git_checkout, task_git_update, task_git_update_all, task_git_update_info, task_stage, task_unstage, task_commit, task_discard, task_git_log, task_git_refs, task_git_push, task_git_commit_files, task_git_compare, task_git_blame, task_git_commit_meta, task_git_commit_offset, - detect_forges, task_pr_status, task_pr_create, task_pr_comments, task_set_pr_watch, task_set_pr_comments_seen, + detect_forges, task_pr_status, task_member_pr_status, task_pr_create, task_pr_comments, task_set_pr_watch, task_set_pr_comments_seen, project_forge_issues, project_forge_provider, project_forge_prs, project_fetch_pr_branch, project_git_checkout, task_file_diff, task_file_diff_sides, task_file_read, file_read_external, clipboard_image_save, clipboard_image_capture, task_file_read_base64, task_file_fp, task_file_write, task_dir_list, task_path_stat, task_path_rename, task_path_delete, task_reveal_path, @@ -30037,6 +30150,67 @@ mod tests { } } + #[test] + fn on_disk_members_skips_absent_host_and_empty_paths() { + let host = tempdir().unwrap(); + let member = tempdir().unwrap(); + let mut ws = task_with_member("api", host.path(), member.path()); + let push = |ws: &mut Task, dir_name: &str, path: String| ws.composition.push(TaskMember { + dir_name: dir_name.into(), + mode: MemberMode::RepoRoot, + path, + ..Default::default() + }); + // A member pointing at the host itself: only legacy records can + // carry this (composition lists non-host members) — it must not be + // operated on twice. + push(&mut ws, "dup", host.path().to_string_lossy().into_owned()); + // A member whose checkout vanished from disk. + push(&mut ws, "gone", member.path().join("nope").to_string_lossy().into_owned()); + // An empty path. + push(&mut ws, "empty", String::new()); + let dirs: Vec<_> = on_disk_members(&ws).map(|m| m.dir_name.as_str()).collect(); + assert_eq!(dirs, vec!["api"]); + } + + #[test] + fn member_pr_lookups_labels_rows_with_the_live_branch() { + with_scratch_data_dir(|_| { + let host = tempdir().unwrap(); + let member_dir = tempdir().unwrap(); + let member = member_dir.path().to_path_buf(); + git_init_with_commit(&member); + git_set_identity(&member); + // The frozen member branch is the create-time value; the row + // must name the branch the lookup actually used (live HEAD). + git(&["checkout", "-q", "-b", "feat-live"], &member).unwrap(); + let mut ws = a_task("t1", ProfileId::Root); + ws.path = host.path().to_string_lossy().into_owned(); + ws.composition = vec![ + TaskMember { + dir_name: "api".into(), + mode: MemberMode::RepoRoot, + path: member.to_string_lossy().into_owned(), + branch: "stale".into(), + ..Default::default() + }, + // A member whose checkout vanished is skipped entirely. + TaskMember { + dir_name: "gone".into(), + mode: MemberMode::RepoRoot, + path: member.join("nope").to_string_lossy().into_owned(), + ..Default::default() + }, + ]; + crate::save_task(&ws).unwrap(); + let rows = member_pr_lookups("t1").unwrap(); + assert_eq!(rows.len(), 1); + assert_eq!(rows[0].dir_name, "api"); + assert_eq!(rows[0].branch, "feat-live"); + assert_eq!(rows[0].lookup.status, "no-remote"); + }); + } + #[test] fn resolve_task_git_path_rejects_bare_member_root_by_default() { // A diff/read needs a FILE inside the member, not the member's own diff --git a/src/components/task/GitPanel.tsx b/src/components/task/GitPanel.tsx index 5b13163c3..fc1a3dc77 100644 --- a/src/components/task/GitPanel.tsx +++ b/src/components/task/GitPanel.tsx @@ -29,7 +29,7 @@ import { ChevronRight, ChevronDown, ArrowDown, ArrowUp, List, ListTree, Rows3, Check, Eye, Search, Trash2, MessageSquare, Loader2, GitBranch, GitMerge, RotateCw, FileText, } from "lucide-react"; import type { Task, GitStatus, GitRepo, GitFile, UpdateMode, UpdateInfo } from "@/lib/types"; -import { taskStage, taskUnstage, taskCommit, taskDiscard, taskGitBranches, taskGitCheckout, taskGitUpdate, taskGitUpdateInfo, taskGitPush } from "@/lib/ipc"; +import { taskStage, taskUnstage, taskCommit, taskDiscard, taskGitBranches, taskGitCheckout, taskGitUpdate, taskGitUpdateAll, taskGitUpdateInfo, taskGitPush } from "@/lib/ipc"; import { useApp } from "@/store/app"; import { useUI } from "@/store/ui"; import { usePrefs } from "@/store/prefs"; @@ -46,7 +46,7 @@ import { CopyPathItems } from "./CopyPathItems"; import { HistoryPanel, ScopePicker } from "./HistoryPanel"; import { ComparePanel } from "./ComparePanel"; import { fileIconUrl, folderIconUrl } from "@/lib/explorer/iconResolver"; -import { PrCard } from "./PrCard"; +import { PrCard, MemberPrRows } from "./PrCard"; import { usePr } from "@/store/pr"; // Per-side status → glyph / fill / ink / label, shared with Compare (GH #208) @@ -476,6 +476,9 @@ export function GitPanel({ task, status, refresh, onOpenDiff, onOpenFile, onDoub return (
{!task.is_main_checkout && } + {/* Members are their own checkouts - they keep their rows even on a + main-checkout task (only the HOST card is wrong there). */} + {!nonGit && }
{nonGit @@ -543,6 +546,11 @@ export function GitPanel({ task, status, refresh, onOpenDiff, onOpenFile, onDoub never a PR/MR whose head is that branch, so there's nothing here worth polling for. */} {!task.is_main_checkout && } + {/* Multi-repo members each carry their own PR/MR - the task card + above only ever polls the host worktree. Members are their own + checkouts, so they keep their rows even on a main-checkout task + (where only the host card is hidden). */} + {/* 0. Repo sub-tabs (wrapping pills). OUTERMOST of the three controls here: which repo you are looking at is what the branch bar and all three sub-tabs below are ABOUT, so it cannot sit inside them. */} @@ -594,6 +602,7 @@ export function GitPanel({ task, status, refresh, onOpenDiff, onOpenFile, onDoub task={task} branch={repo?.branch ?? task.branch} dir={dir} + multi={repos.length > 1} right={
setUpdating(false)); }; + // Same op across the host and every member repo, one summary toast. A repo + // the mode can't serve (no upstream for pull, no base for merge/rebase) + // reports its own reason — the batch does not stop at it. + const runUpdateAll = (mode: UpdateMode) => { + if (updating || switching) return; + setUpdating(true); + taskGitUpdateAll(task.id, mode) + .then(items => { + setBranches(null); // stale after an update - reload on next open + setInfo(null); + useApp.getState().bumpFsRevision(task.id); + const bad = items.filter(i => i.error || i.result?.conflicted || i.result?.stash_conflicted); + if (bad.length === 0) { + const updated = items.filter(i => i.result && !i.result.up_to_date).length; + pushToast(t(updated === 0 + ? "gitPanel.allUpToDate" + : updated === items.length + ? "gitPanel.updatedAll" + : "gitPanel.updatedSome", { updated, count: items.length })); + return; + } + const conflictKey = mode === "rebase" ? "gitPanel.rebaseConflict" : "gitPanel.mergeConflict"; + const detail = bad.map(i => `${i.name}: ${i.error?.replace(/[.;]+$/, "") ?? t( + i.result!.conflicted ? conflictKey : "gitPanel.stashConflict", + { branch: i.result!.branch, target: i.result!.target }, + )}`).join("; "); + pushToast(t("gitPanel.updateAttention", { bad: bad.length, total: items.length, detail }), "error", { ttlMs: 8000 }); + }) + .catch(e => pushToast(String(e), "error")) + .finally(() => setUpdating(false)); + }; + const switchTo = (target: string) => { if (switching || target === branch) return; setSwitching(true); @@ -1023,6 +1066,24 @@ function BranchBar({ task, branch, dir, right }: { ); })()} + {multi && ( + <> + {t("gitPanel.allRepos")} + runUpdateAll("pull")} className="items-center"> + + {t("gitPanel.pullAll")} + + runUpdateAll("merge")} className="items-center"> + + {t("gitPanel.mergeAll")} + + runUpdateAll("rebase")} className="items-center"> + + {t("gitPanel.rebaseAll")} + + + + )} {!branches || branches.length === 0 ? (
{t("gitPanel.noBranches")}
) : ( diff --git a/src/components/task/PrCard.tsx b/src/components/task/PrCard.tsx index 799538ff8..2a7bda5b4 100644 --- a/src/components/task/PrCard.tsx +++ b/src/components/task/PrCard.tsx @@ -16,7 +16,7 @@ import { GitPullRequest, GitPullRequestDraft, GitPullRequestClosed, GitMerge, CircleCheck, CircleX, ExternalLink, RefreshCw, Plus, Bell, BellOff, } from "lucide-react"; -import type { PrStatus, Task } from "@/lib/types"; +import type { MemberPrLookup, PrStatus, Task } from "@/lib/types"; import { azurePatLoginCmd, forgeCli, forgeInstallCmd, forgeLoginCmd, forgeName, prRef } from "@/lib/forge"; import { openPath } from "@/lib/ipc"; import { usePr, watchTickNow } from "@/store/pr"; @@ -292,9 +292,9 @@ export function PrCard({ task }: { task: Task }) { ); } -function Card({ children }: { children: ReactNode }) { +function Card({ children, testId = "pr-card" }: { children: ReactNode; testId?: string }) { return ( -
+
{children}
); @@ -368,3 +368,71 @@ function RefreshBtn({ spinning, onClick }: { spinning: boolean; onClick: () => v ); } + +/** Per-member PR/MR rows for a multi-repo task. The task-level card above + * covers the host worktree (the task's own `path`; `composition` holds + * only NON-host members); these cover the members, each resolved by the + * branch checked out in its own repo. Rendered only for members that + * resolved to a forge-backed result (status "ok"): no-remote/unsupported + * rows are noise, and an error/cli-missing hint would read as "no PR" on + * a compact row - the board card applies the same rule. Renders nothing + * until a refresh landed, so a task whose members were never polled never + * flashes empty rows. A member with no PR keeps the bare name+branch + * row: on a multi-repo task "this repo has no PR yet" is the answer. */ +export function MemberPrRows({ task }: { task: Task }) { + const members = usePr(s => s.byTask[task.id]?.members); + const refresh = usePr(s => s.refresh); + const hasMembers = (task.composition?.length ?? 0) > 0; + // PrCard owns the mount/tick polling while it's mounted. On a + // main-checkout task it never mounts, so member rows tick for + // themselves - refresh() polls only members for such a task. A + // main checkout with NO members has no PR surface at all: skip + // the interval rather than churn a fresh PrEntry every minute. + useEffect(() => { + if (!task.is_main_checkout || !hasMembers) return; + void refresh(task.id); + const t = window.setInterval(() => refresh(task.id, true), POLL_MS); + return () => window.clearInterval(t); + }, [task.id, task.is_main_checkout, hasMembers, refresh]); + if (!members?.length) return null; + const rows = members.filter(m => m.status === "ok"); + if (!rows.length) return null; + return ( + + {rows.map(m => )} + + ); +} + +function MemberPrRow({ m }: { m: MemberPrLookup }) { + const { t } = useTranslation("panels"); + const pr = m.pr; + const st = pr ? STATE[pr.state] : null; + return ( +
+ + {m.dir_name} + {m.branch ? · {m.branch} : null} + + {pr && st ? ( + <> + + + + {prRef(pr.provider, pr.number)} + + + + + + + + ) : null} +
+ ); +} diff --git a/src/components/task/RightPanel.tsx b/src/components/task/RightPanel.tsx index 72bfb9a92..23741bfc9 100644 --- a/src/components/task/RightPanel.tsx +++ b/src/components/task/RightPanel.tsx @@ -15,7 +15,7 @@ import { listen } from "@tauri-apps/api/event"; import { getCurrentWindow } from "@tauri-apps/api/window"; import { useApp, useActiveTask } from "@/store/app"; import { useUI } from "@/store/ui"; -import { usePr, watchTickNow } from "@/store/pr"; +import { usePr, watchTickNow, prRelevant } from "@/store/pr"; import { useAlignedSpin } from "@/hooks/useAlignedSpin"; import { taskGitStatus, taskRunScriptStream, repoConfigLoad, repoConfigLoadAt, @@ -248,7 +248,7 @@ export function RightPanel() { const doRefresh = () => { refreshGit(); setFileTreeReload(n => n + 1); - if (task && !task.is_main_checkout) { + if (task && prRelevant(task)) { usePr.getState().refresh(task.id, true); watchTickNow(); } diff --git a/src/components/task/TerminalPane.tsx b/src/components/task/TerminalPane.tsx index 43b076d73..0da0c7d4a 100644 --- a/src/components/task/TerminalPane.tsx +++ b/src/components/task/TerminalPane.tsx @@ -10,7 +10,7 @@ import { PopoverRoot, PopoverTrigger, PopoverContent } from "@/components/ui/Pop import { useUI } from "@/store/ui"; import { EMPTY_TABS, isUserWatching, useApp } from "@/store/app"; import { logWorkState } from "@/lib/workStateLog"; -import { usePr } from "@/store/pr"; +import { usePr, prRelevant } from "@/store/pr"; import { QUIET_MS, SAMPLE_MS, SCROLLBACK_STABLE_SAMPLES, SETTLE_SAMPLES, } from "@/lib/settleTiming"; @@ -2920,11 +2920,12 @@ const captureArmedRef = useRef(false); // Launching a task is exactly the moment its PR/MR status is worth // knowing, not something to wait on the user opening the Git tab // for - only the primary agent tab counts as "the task launched", - // and only for a task that could ever have one (main checkouts - // never do, see GitPanel). Unforced: the store's own 30s floor still + // and only for a task with any PR surface (a main checkout has no + // host PR of its own, but member repos still can - see GitPanel). + // Unforced: the store's own 30s floor still // applies, so a crash-looping agent respawning repeatedly doesn't // turn this into a hammer. - if (isAgent && isPrimaryTab && !task.is_main_checkout) { + if (isAgent && isPrimaryTab && prRelevant(task)) { usePr.getState().refresh(task.id); } // If the spawn survives RESUME_FAILURE_MS without exiting, we diff --git a/src/components/views/BoardView.tsx b/src/components/views/BoardView.tsx index cdb75a367..1366fd7b8 100644 --- a/src/components/views/BoardView.tsx +++ b/src/components/views/BoardView.tsx @@ -65,9 +65,10 @@ import { groupOf } from "@/lib/projectGroups"; import { accentCss } from "@/lib/accents"; import { confirmAndArchive } from "@/lib/archiveTask"; import { taskReorder } from "@/lib/ipc"; +import { forgeName } from "@/lib/forge"; import { effectiveSandboxMode, isSandboxEnforced } from "@/lib/types"; import { cn } from "@/lib/utils"; -import type { Agent, Project, Tab, Task, TaskDiffStat, TerminalTab } from "@/lib/types"; +import type { Agent, MemberPrLookup, Project, Tab, Task, TaskDiffStat, TerminalTab } from "@/lib/types"; import type { TFunction } from "i18next"; /** Everything a card needs that is the SAME for every card, hoisted so N @@ -977,18 +978,30 @@ const BoardCardFooter = memo(function BoardCardFooter({ task: w }: { task: Task // folds only `state` into its key, and widening that would re-render every // column whenever any task's check count moved. const pr = usePr(s => s.byTask[w.id]?.lookup?.pr ?? null); + const members = usePr(s => s.byTask[w.id]?.members); const stat = useDiffStat(s => s.byTask[w.id]?.stat ?? null); const request = useDiffStat(s => s.request); // Ask for a measurement when this card mounts, and again whenever the card // re-renders past the staleness floor. `request` is a no-op on a fresh // answer, so this is not a poll: a board nobody has open measures nothing. - useEffect(() => { request(w.id); }); + // Multi-repo cards also ask for member PRs - the task lookup covers the + // host worktree only, and `composition` holds members only, so without + // this a board card would wait for someone to open the task's Git tab. + // Unforced: the pr store's floor still caps it. + useEffect(() => { + request(w.id); + if ((w.composition?.length ?? 0) > 0) void usePr.getState().refresh(w.id); + }); const url = pr?.url ?? w.pr_url ?? ""; const num = pr?.number ?? w.pr_number ?? 0; const changed = (stat?.files_changed ?? 0) > 0; - if (!url && !changed) return null; + // Same "forge-backed only" rule as MemberPrRows in the Git tab: a member + // whose remote resolved to nothing termic can query has no PR story to + // tell, and an error/cli-missing row would read as "no PR". + const memberRows = members?.filter(m => m.status === "ok") ?? []; + if (!url && !changed && !memberRows.length) return null; const { color } = prBadgeAppearance(pr?.state ?? null, pr?.checks ?? null); const noun = pr?.provider === "gitlab" ? "MR" : "PR"; @@ -1005,24 +1018,83 @@ const BoardCardFooter = memo(function BoardCardFooter({ task: w }: { task: Task : ""; return ( - // WRAPS, and every child can shrink. A 280px column cannot always hold - // "#18495 - checks failing" and "+356 -21 12 files" on one line, and when - // it could not, the row overflowed and gave the whole COLUMN a horizontal - // scrollbar with the file counts clipped off the right edge. Shipped that - // way in 1.11.2. Wrapping costs a second line on the widest cards and - // nothing on the rest. -
- {url && ( +
+ {(url || changed) && ( + // WRAPS, and every child can shrink. A 280px column cannot always hold + // "#18495 - checks failing" and "+356 -21 12 files" on one line, and when + // it could not, the row overflowed and gave the whole COLUMN a horizontal + // scrollbar with the file counts clipped off the right edge. Shipped that + // way in 1.11.2. Wrapping costs a second line on the widest cards and + // nothing on the rest. +
+ {url && ( + + )} + {changed && } +
+ )} + {/* Multi-repo members, one compact row each (monocode's workstream + rows): member repo + branch on the left, its own PR chip on the + right. Capped like the member list itself - the "+N more" line is + the hint that the task fans out further than the card can say. */} + {memberRows.slice(0, 3).map(m => )} + {memberRows.length > 3 && ( + + {t("board.memberMore", { count: memberRows.length - 3 })} + + )} +
+ ); +}); + +/** One non-host member's row on a board card: `repo · branch` plus the + * member's own PR chip (state colour + check note) when a lookup resolved + * one. A member whose forge answered "no PR" keeps the bare row - on a + * multi-repo task that IS the answer, not missing data. */ +function BoardMemberRow({ m }: { m: MemberPrLookup }) { + const { t } = useTranslation("chrome"); + const pr = m.pr; + const { color } = prBadgeAppearance(pr?.state ?? null, pr?.checks ?? null); + const noun = pr?.provider === "gitlab" ? "MR" : "PR"; + const id = pr ? `${noun === "MR" ? "!" : "#"}${pr.number}` : ""; + const PrIcon = + pr?.state === "merged" ? GitMerge + : pr?.state === "closed" ? GitPullRequestClosed + : pr?.state === "draft" ? GitPullRequestDraft + : GitPullRequest; + const checkNote = + pr?.checks === "failing" ? t("board.prChecksFailing") + : pr?.checks === "pending" ? t("board.prChecksPending") + : ""; + return ( +
+ + {m.dir_name} + {m.branch ? · {m.branch} : null} + + {pr && ( )} - {changed && }
); -}); +} /** `+N -M · k files`, in the same tokens the compare view uses. */ function BoardChurn({ task: w, stat }: { task: Task; stat: TaskDiffStat }) { diff --git a/src/lib/ipc.ts b/src/lib/ipc.ts index dc52d6b97..cabb9b33e 100644 --- a/src/lib/ipc.ts +++ b/src/lib/ipc.ts @@ -7,9 +7,9 @@ import type { AgentUsage, StatusLineOwner } from "@/lib/agentUsage"; import { listen, type UnlistenFn } from "@tauri-apps/api/event"; import type { Project, ProjectMember, Task, CreateTaskArgs, CreateMultiArgs, CreateMultiMember, Settings, DiscoveredRepo, - ImportableWorktree, CliInfo, ChangeFile, Changes, GitStatus, CheckoutResult, UpdateMode, UpdateResult, UpdateInfo, FileEntry, Agent, RepoConfig, + ImportableWorktree, CliInfo, ChangeFile, Changes, GitStatus, CheckoutResult, UpdateMode, UpdateResult, UpdateInfo, UpdateAllItem, FileEntry, Agent, RepoConfig, SandboxMode, TaskDiffSummary, TaskDiffStat, DesktopIntegration, CliInstallStatus, McpStatus, BranchContext, PrPickList, BlameFile, GitCommit, GitCompare, GitFile, GitLogPage, GitRef, - ForgeCliStatus, ForgeProvider, PrLookup, PrComment, IssueLookup, AgentHookStatus, HookPlan, + ForgeCliStatus, ForgeProvider, PrLookup, MemberPrLookup, PrComment, IssueLookup, AgentHookStatus, HookPlan, ProfileView, ProfilesView, ProfileDeletePreview, AgentAccountsView, ExternalAppInfo, } from "./types"; import type { CustomThemeFile } from "./customTheme"; @@ -876,6 +876,10 @@ export const taskGitCheckout = (id: string, dirName: string, branch: string) => * base branch (`merge` / `rebase`). Fetches first; auto-stashes local work. */ export const taskGitUpdate = (id: string, dirName: string, mode: UpdateMode) => invoke("task_git_update", { id, dirName, mode }); +/** Run `task_git_update` across the host and every member repo; never aborts + * on one repo's failure — each item carries its own result or error. */ +export const taskGitUpdateAll = (id: string, mode: UpdateMode) => + invoke("task_git_update_all", { id, mode }); /** Branch + upstream + base for the update menu. Resolved on dropdown-open. */ export const taskGitUpdateInfo = (id: string, dirName: string) => invoke("task_git_update_info", { id, dirName }); @@ -896,6 +900,13 @@ export const detectForges = () => * the forge CLI - poll at a slow cadence (the pr store owns this). */ export const taskPrStatus = (id: string) => invoke("task_pr_status", { id }); +/** Per-member PR/MR lookups for a multi-repo task - one entry per on-disk + * NON-host composition member (the host is `task_pr_status`'s own lookup; + * a member whose checkout vanished produces no entry). Same + * per-member cost as a task lookup, so it rides inside the pr store's + * refresh rather than polling on its own. */ +export const taskMemberPrStatus = (id: string) => + invoke("task_member_pr_status", { id }); /** Which forge a project's repo is hosted on, or null. Cached in Rust and * network-free, so callers may treat it as cheap. */ export const projectForgeProvider = (projectId: string) => diff --git a/src/lib/types.ts b/src/lib/types.ts index 8c1c5f1ce..0a0e8a08e 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1131,6 +1131,15 @@ export interface UpdateInfo { base: string; } +/** Per-repo outcome of `task_git_update_all` — one per host/member repo. */ +export interface UpdateAllItem { + name: string; + /** Set when the update ran to a verdict (possibly a conflicted one). */ + result: UpdateResult | null; + /** Set when the update could not start (no upstream, detached HEAD, …). */ + error: string | null; +} + // ───────────────────────────── forge (PRs / MRs) ───────────────────────────── /** The forges termic talks to. "azure" is Azure DevOps Services (cloud only @@ -1256,6 +1265,15 @@ export interface PrLookup { pr: PrStatus | null; } +/** A multi-repo task member's PR poll: the `PrLookup` payload plus which + * member it belongs to. Only NON-host members are returned - the host's + * worktree is the task's own `path`, covered by `task_pr_status`. Mirrors + * `MemberPrLookup` in src-tauri/src/lib.rs. */ +export interface MemberPrLookup extends PrLookup { + dir_name: string; + branch: string; +} + export interface FileEntry { name: string; is_dir: boolean; diff --git a/src/locales/en/chrome.ts b/src/locales/en/chrome.ts index c1bc577cc..11556cdba 100644 --- a/src/locales/en/chrome.ts +++ b/src/locales/en/chrome.ts @@ -242,6 +242,7 @@ export default { prOpenOn: "Open {{id}} on {{forge}}", prChecksFailing: "checks failing", prChecksPending: "checks running", + memberMore: "+{{count}} more", }, activity: { windowTitle: "Activity", diff --git a/src/locales/en/panels.ts b/src/locales/en/panels.ts index a3d9c9305..119b6a97f 100644 --- a/src/locales/en/panels.ts +++ b/src/locales/en/panels.ts @@ -176,6 +176,14 @@ export default { pullFrom: "Pull from {{upstream}}", mergeInto: "Merge {{base}} into this branch", rebaseOnto: "Rebase onto {{base}}", + allRepos: "All repos", + pullAll: "Pull all repos", + mergeAll: "Merge each repo's base branch", + rebaseAll: "Rebase each repo onto its base", + allUpToDate: "All repos are up to date.", + updatedAll: "Updated all {{count}} repos.", + updatedSome: "Updated {{updated}} of {{count}} repos.", + updateAttention: "{{bad}} of {{total}} repos need attention: {{detail}}", noBranches: "No local branches.", mergeConflict: "Merge of {{branch}} from {{target}} hit conflicts. Resolve them in the terminal, then commit the result.", rebaseConflict: "Rebase of {{branch}} from {{target}} hit conflicts. Resolve them in the terminal, then run git rebase --continue.", diff --git a/src/locales/zh-CN/chrome.ts b/src/locales/zh-CN/chrome.ts index e55d32e80..066159d51 100644 --- a/src/locales/zh-CN/chrome.ts +++ b/src/locales/zh-CN/chrome.ts @@ -242,6 +242,7 @@ export default { prOpenOn: "在 {{forge}} 上打开 {{id}}", prChecksFailing: "检查失败", prChecksPending: "检查进行中", + memberMore: "+{{count}} 个", }, activity: { windowTitle: "活动", diff --git a/src/locales/zh-CN/panels.ts b/src/locales/zh-CN/panels.ts index 2ab627452..1d54b5255 100644 --- a/src/locales/zh-CN/panels.ts +++ b/src/locales/zh-CN/panels.ts @@ -175,6 +175,14 @@ export default { pullFrom: "从 {{upstream}} 拉取", mergeInto: "将 {{base}} 合并到此分支", rebaseOnto: "变基到 {{base}}", + allRepos: "所有仓库", + pullAll: "拉取所有仓库", + mergeAll: "将各仓库的基础分支合并进来", + rebaseAll: "将各仓库变基到其基础分支", + allUpToDate: "所有仓库均已是最新。", + updatedAll: "已更新全部 {{count}} 个仓库。", + updatedSome: "已更新 {{count}} 个仓库中的 {{updated}} 个。", + updateAttention: "{{total}} 个仓库中 {{bad}} 个需要注意:{{detail}}", noBranches: "没有本地分支。", mergeConflict: "将 {{target}} 合并到 {{branch}} 时发生冲突。请在终端中解决,然后提交结果。", rebaseConflict: "将 {{branch}} 基于 {{target}} 变基时发生冲突。请在终端中解决,然后运行 git rebase --continue。", diff --git a/src/store/pr.test.ts b/src/store/pr.test.ts index 4cdd93edd..882b04184 100644 --- a/src/store/pr.test.ts +++ b/src/store/pr.test.ts @@ -5,6 +5,7 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; vi.mock("@/lib/ipc", () => ({ detectForges: vi.fn().mockResolvedValue([]), taskPrStatus: vi.fn(), + taskMemberPrStatus: vi.fn().mockResolvedValue([]), taskPrComments: vi.fn().mockResolvedValue([]), taskSetPrWatch: vi.fn().mockResolvedValue(undefined), taskSetPrCommentsSeen: vi.fn().mockResolvedValue(undefined), @@ -31,13 +32,14 @@ vi.mock("@/lib/archiveTask", () => ({ import { usePr, newCommentsSince, commentPromptFor, watchTickNow, openPrArchiveWarning, pollableTasks, prStatusPassNow, initPrRefreshOnFocus, stopPrRefreshOnFocus, + prFocusEligible, } from "@/store/pr"; import { useApp } from "@/store/app"; import { useUI } from "@/store/ui"; import { usePrefs } from "@/store/prefs"; import * as ipc from "@/lib/ipc"; import { archiveAndRefresh, confirmAndArchive } from "@/lib/archiveTask"; -import type { ForgeProvider, PrLookup, Task, Project } from "@/lib/types"; +import type { ForgeProvider, MemberPrLookup, PrLookup, Task, Project } from "@/lib/types"; const lookupWith = ( state: "open" | "merged" | "closed" | "draft" | null, @@ -100,6 +102,169 @@ describe("usePr.refresh", () => { await usePr.getState().refresh("ws1", true); expect(usePr.getState().byTask["ws1"].lookup?.pr?.state).toBe("open"); }); + + it("polls members inside the same refresh for multi-repo tasks", async () => { + // composition holds NON-host members only - the host is the task's own + // path. A single member is the minimal multi-repo shape and must be + // enough to trigger the member call. + seedApp("off", { + composition: [ + { dir_name: "api", mode: "worktree", branch: "feat", path: "/x/api" }, + ], + }); + vi.mocked(ipc.taskPrStatus).mockResolvedValue(lookupWith("open")); + const member: MemberPrLookup = { ...lookupWith("draft", 3), dir_name: "api", branch: "feat" }; + vi.mocked(ipc.taskMemberPrStatus).mockResolvedValue([member]); + await usePr.getState().refresh("ws1", true); + expect(ipc.taskMemberPrStatus).toHaveBeenCalledWith("ws1"); + expect(usePr.getState().byTask["ws1"].members).toEqual([member]); + }); + + it("skips the member call entirely for single-repo tasks", async () => { + seedApp(); + vi.mocked(ipc.taskPrStatus).mockResolvedValue(lookupWith("open")); + await usePr.getState().refresh("ws1", true); + expect(ipc.taskMemberPrStatus).not.toHaveBeenCalled(); + expect(usePr.getState().byTask["ws1"].members).toBeUndefined(); + }); + + it("keeps the previous member list when the member call fails", async () => { + seedApp("off", { + composition: [ + { dir_name: "api", mode: "worktree", branch: "feat", path: "/x/api" }, + ], + }); + vi.mocked(ipc.taskPrStatus).mockResolvedValue(lookupWith("open")); + const member: MemberPrLookup = { ...lookupWith("open"), dir_name: "api", branch: "feat" }; + vi.mocked(ipc.taskMemberPrStatus).mockResolvedValue([member]); + await usePr.getState().refresh("ws1", true); + vi.mocked(ipc.taskMemberPrStatus).mockRejectedValue(new Error("gone")); + await usePr.getState().refresh("ws1", true); + expect(usePr.getState().byTask["ws1"].members).toEqual([member]); + }); + + it("polls members but never the host PR for a main-checkout task", async () => { + seedApp("off", { + is_main_checkout: true, + composition: [ + { dir_name: "api", mode: "worktree", branch: "feat", path: "/x/api" }, + ], + }); + const member: MemberPrLookup = { ...lookupWith("open"), dir_name: "api", branch: "feat" }; + vi.mocked(ipc.taskMemberPrStatus).mockResolvedValue([member]); + await usePr.getState().refresh("ws1", true); + // The host repo is the user's live checkout - resolving its PR would + // persist a foreign identity and can auto-archive the task on an + // unrelated merge. Members still poll normally. + expect(ipc.taskPrStatus).not.toHaveBeenCalled(); + expect(usePr.getState().byTask["ws1"].members).toEqual([member]); + }); + + it("member rows still land when the host lookup fails", async () => { + seedApp("off", { + composition: [ + { dir_name: "api", mode: "worktree", branch: "feat", path: "/x/api" }, + ], + }); + // Host and members can sit on different forges/CLIs - a dead host + // remote must not freeze the member rows. + vi.mocked(ipc.taskPrStatus).mockRejectedValue(new Error("network")); + const member: MemberPrLookup = { ...lookupWith("open"), dir_name: "api", branch: "feat" }; + vi.mocked(ipc.taskMemberPrStatus).mockResolvedValue([member]); + await usePr.getState().refresh("ws1", true); + expect(usePr.getState().byTask["ws1"].lookup).toBeNull(); + expect(usePr.getState().byTask["ws1"].members).toEqual([member]); + }); + + it("clears member rows when the task's members are removed", async () => { + seedApp("off", { + composition: [ + { dir_name: "api", mode: "worktree", branch: "feat", path: "/x/api" }, + ], + }); + vi.mocked(ipc.taskPrStatus).mockResolvedValue(lookupWith("open")); + vi.mocked(ipc.taskMemberPrStatus).mockResolvedValue([ + { ...lookupWith("open"), dir_name: "api", branch: "feat" }, + ]); + await usePr.getState().refresh("ws1", true); + // Edit Task removed the member - the next refresh must not keep + // showing rows for a repo the task no longer has. + useApp.setState(s => ({ tasks: s.tasks.map(t => ({ ...t, composition: [] })) })); + await usePr.getState().refresh("ws1", true); + expect(ipc.taskMemberPrStatus).toHaveBeenCalledTimes(1); + expect(usePr.getState().byTask["ws1"].members).toBeUndefined(); + }); + + it("setLookup keeps the member list (PR create must not blank member rows)", async () => { + seedApp("off", { + composition: [ + { dir_name: "api", mode: "worktree", branch: "feat", path: "/x/api" }, + ], + }); + const member: MemberPrLookup = { ...lookupWith("open"), dir_name: "api", branch: "feat" }; + vi.mocked(ipc.taskPrStatus).mockResolvedValue(lookupWith("open")); + vi.mocked(ipc.taskMemberPrStatus).mockResolvedValue([member]); + await usePr.getState().refresh("ws1", true); + usePr.getState().setLookup("ws1", lookupWith("draft", 9)); + expect(usePr.getState().byTask["ws1"].members).toEqual([member]); + }); + + it("a setLookup landing mid-refresh wins over the stale snapshot", async () => { + seedApp(); + let resolveStatus!: (v: PrLookup) => void; + vi.mocked(ipc.taskPrStatus).mockImplementation(() => new Promise(r => { resolveStatus = r; })); + const pending = usePr.getState().refresh("ws1", true); + usePr.getState().setLookup("ws1", lookupWith("draft", 9)); + resolveStatus(lookupWith("open", 7)); + await pending; + expect(usePr.getState().byTask["ws1"].lookup?.pr?.number).toBe(9); + }); + + it("a mid-refresh setLookup keeps its lookup AND still gets the member list", async () => { + seedApp("off", { + composition: [ + { dir_name: "api", mode: "worktree", branch: "feat", path: "/x/api" }, + ], + }); + let resolveStatus!: (v: PrLookup) => void; + vi.mocked(ipc.taskPrStatus).mockImplementation(() => new Promise(r => { resolveStatus = r; })); + const member: MemberPrLookup = { ...lookupWith("open"), dir_name: "api", branch: "feat" }; + vi.mocked(ipc.taskMemberPrStatus).mockResolvedValue([member]); + const pending = usePr.getState().refresh("ws1", true); + usePr.getState().setLookup("ws1", lookupWith("draft", 9)); + resolveStatus(lookupWith("open", 7)); + await pending; + expect(usePr.getState().byTask["ws1"].lookup?.pr?.number).toBe(9); + expect(usePr.getState().byTask["ws1"].members).toEqual([member]); + }); + + it("a raced refresh fires no merge/open handlers on the stale snapshot", async () => { + seedApp(); // no on_pr_merge → "ask" mode toasts on open → merged + vi.mocked(ipc.taskPrStatus).mockResolvedValue(lookupWith("open")); + await usePr.getState().refresh("ws1", true); + let resolveStatus!: (v: PrLookup) => void; + vi.mocked(ipc.taskPrStatus).mockImplementation(() => new Promise(r => { resolveStatus = r; })); + const pending = usePr.getState().refresh("ws1", true); + // A write lands mid-flight; the refresh then resolves to "merged". + // The transition handlers must compare the NEXT poll against this + // lookup, not fire on a superseded snapshot. + usePr.getState().setLookup("ws1", lookupWith("open")); + resolveStatus(lookupWith("merged")); + await pending; + expect(useUI.getState().toasts).toHaveLength(0); + }); + + it("prFocusEligible: a main checkout is eligible only via members", () => { + seedApp("off", { + is_main_checkout: true, + composition: [ + { dir_name: "api", mode: "worktree", branch: "feat", path: "/x/api" }, + ], + }); + expect(prFocusEligible("ws1")).toBe(true); + seedApp("off", { is_main_checkout: true }); + expect(prFocusEligible("ws1")).toBe(false); + }); }); describe("merged-PR lifecycle (issue #21)", () => { diff --git a/src/store/pr.ts b/src/store/pr.ts index 84e7af9f2..1391f0551 100644 --- a/src/store/pr.ts +++ b/src/store/pr.ts @@ -38,10 +38,10 @@ // tab nobody is looking at isn't something anyone asked to see. import { create } from "zustand"; -import type { ForgeCliStatus, ForgeProvider, PrComment, PrLookup, QueueItem, TerminalTab, Task } from "@/lib/types"; +import type { ForgeCliStatus, ForgeProvider, MemberPrLookup, PrComment, PrLookup, QueueItem, TerminalTab, Task } from "@/lib/types"; import { azurePrThreadsCommand, forgeName, prLabel, prLabelShort, prNoun, prNounShort, prRef } from "@/lib/forge"; import { - detectForges, notify, openPath, ptyWrite, taskPrComments, taskPrStatus, + detectForges, notify, openPath, ptyWrite, taskMemberPrStatus, taskPrComments, taskPrStatus, taskSetPrCommentsSeen, taskSetPrWatch, projectForgeProvider, } from "@/lib/ipc"; import { workDoneCapable } from "@/lib/agents"; @@ -60,6 +60,10 @@ export interface PrEntry { /** Wall-clock ms of the last completed refresh - drives the cadence * guard so tab-switching doesn't hammer the forge. */ fetchedAt: number; + /** Per-member lookups for a multi-repo task's NON-host members (the host + * is `lookup` itself - its worktree is the task path). Undefined on + * single-repo tasks and until the first refresh lands. */ + members?: MemberPrLookup[]; } const EMPTY: PrEntry = Object.freeze({ lookup: null, loading: false, fetchedAt: 0 }) as PrEntry; @@ -118,35 +122,89 @@ export const usePr = create((set, get) => ({ if (!force && Date.now() - cur.fetchedAt < MIN_REFRESH_MS) return; set(s => ({ byTask: { ...s.byTask, [taskId]: { ...cur, loading: true } } })); try { - const lookup = await taskPrStatus(taskId); + // `composition` lists NON-host members only; the host is the task's + // own path, covered by taskPrStatus. A main checkout never polls a + // host PR (its branch is the user's live work, and persisting a + // found PR could auto-archive the task on an unrelated merge) but + // still gets member rows. + const task = useApp.getState().tasks.find(t => t.id === taskId); + // Independent calls, awaited together: each lands on its own + // spawn_blocking, so serializing them would double the latency. + // By-branch only for members: a member carries no persisted identity. + // A member call that fails keeps the previous list rather than + // blanking the rows for the rest of the cadence window. + const hasMembers = (task?.composition?.length ?? 0) > 0; + const [hostRes, memberRes] = await Promise.allSettled([ + task?.is_main_checkout ? Promise.resolve(cur.lookup) : taskPrStatus(taskId), + hasMembers ? taskMemberPrStatus(taskId) : Promise.resolve(undefined), + ]); + // Each leg keeps what it can: a member failure keeps the previous + // list rather than blanking the rows for the rest of the cadence + // window, and good member data survives a failed HOST call (the two + // can sit on different forges/CLIs). + const members = memberRes.status === "fulfilled" + ? memberRes.value + : (console.error("task_member_pr_status failed:", memberRes.reason), + get().byTask[taskId]?.members); + // Edit Task mid-poll: the fetched list describes the OLD member set + // (Rust enumerated the record it found at call time). Better to drop + // it than show rows for a member the task no longer has - the next + // refresh repopulates from the new composition. + const memberKey = (t?: Task) => JSON.stringify((t?.composition ?? []).map(m => m.dir_name)); + const membersOut = memberKey(useApp.getState().tasks.find(t => t.id === taskId)) === memberKey(task) + ? members + : undefined; + if (hostRes.status === "rejected") { + console.error("task_pr_status failed:", hostRes.reason); + // Keep the stale lookup; record the attempt so a broken setup does + // not retry in a tight loop. Member results still land. + set(s => ({ + byTask: { ...s.byTask, [taskId]: { ...(s.byTask[taskId] ?? EMPTY), members: membersOut, loading: false, fetchedAt: Date.now() } }, + })); + return; + } + const lookup = hostRes.value; const prev = get().byTask[taskId]?.lookup; + // A concurrent setLookup() (e.g. a PR that just finished creating in + // the dialog) lands mid-flight here - keep its lookup rather than + // clobbering it with the snapshot we fetched before it existed. + const raced = prev !== cur.lookup; set(s => ({ - byTask: { ...s.byTask, [taskId]: { lookup, loading: false, fetchedAt: Date.now() } }, + byTask: { ...s.byTask, [taskId]: { + lookup: raced && s.byTask[taskId] ? s.byTask[taskId].lookup : lookup, + members: membersOut, loading: false, fetchedAt: Date.now() } }, })); - maybeHandleMerged(taskId, prev, lookup); - maybeHandleOpened(taskId, prev, lookup); - // Rust persists the PR identity onto the task record the moment this - // lookup resolves one, but the STORE only learned it from loadAll() - // (launch / window focus). `watchedTasks` gates on `pr_number` + - // `pr_provider`, so until that happened a PR discovered in THIS - // session was never watched: the bell rendered as armed and no - // comment ever queued. - // - // Patch the three fields rather than calling loadAll(): a full reload - // from inside a poll is re-entrant (it can kick off more refreshes) - // and replaces every task object, which fans out through every - // mounted task's selectors. Guarded on the transition, so a steady - // poll of an already-known PR writes nothing at all - see - // docs/performance.md bear trap 8 on unchanged store writes. - const found = lookup.pr; - if (found) { - const known = useApp.getState().tasks.find(t => t.id === taskId); - if (known && (known.pr_number !== found.number || known.pr_provider !== found.provider)) { - useApp.setState(st => ({ - tasks: st.tasks.map(t => t.id === taskId - ? { ...t, pr_number: found.number, pr_provider: found.provider, pr_url: found.url } - : t), - })); + // `lookup` is the previous (possibly null) snapshot on the + // main-checkout path - no host poll ran, so the handlers below + // self-neutralize (prev === next). A raced write's lookup wins the + // state too, so its transitions are the NEXT refresh's business, not + // this stale snapshot's. + if (lookup && !raced) { + maybeHandleMerged(taskId, prev, lookup); + maybeHandleOpened(taskId, prev, lookup); + // Rust persists the PR identity onto the task record the moment this + // lookup resolves one, but the STORE only learned it from loadAll() + // (launch / window focus). `watchedTasks` gates on `pr_number` + + // `pr_provider`, so until that happened a PR discovered in THIS + // session was never watched: the bell rendered as armed and no + // comment ever queued. + // + // Patch the three fields rather than calling loadAll(): a full reload + // from inside a poll is re-entrant (it can kick off more refreshes) + // and replaces every task object, which fans out through every + // mounted task's selectors. Guarded on the transition, so a steady + // poll of an already-known PR writes nothing at all - see + // docs/performance.md bear trap 8 on unchanged store writes. + const found = lookup.pr; + if (found) { + const known = useApp.getState().tasks.find(t => t.id === taskId); + if (known && (known.pr_number !== found.number || known.pr_provider !== found.provider)) { + useApp.setState(st => ({ + tasks: st.tasks.map(t => t.id === taskId + ? { ...t, pr_number: found.number, pr_provider: found.provider, pr_url: found.url } + : t), + })); + } } } } catch (err) { @@ -160,7 +218,9 @@ export const usePr = create((set, get) => ({ }, setLookup: (taskId, lookup) => set(s => ({ - byTask: { ...s.byTask, [taskId]: { lookup, loading: false, fetchedAt: Date.now() } }, + // Spread the old entry so a create-seed doesn't drop `members` on a + // multi-repo task until the next refresh window. + byTask: { ...s.byTask, [taskId]: { ...s.byTask[taskId], lookup, loading: false, fetchedAt: Date.now() } }, })), setWatch: async (taskId, watch) => { @@ -293,14 +353,21 @@ async function statusPass() { // however often you switch. Once a lookup finds the PR, `refresh` records // the identity and the background tick keeps it fresh from then on. +/** Does this task have any PR/MR surface worth polling? A normal task's + * host branch; a main checkout has none of its own, but multi-repo MEMBERS + * are real checkouts whose PRs still resolve (refresh() skips the host + * lookup itself, so callers only need this one test). */ +export const prRelevant = (t: Task) => + !t.is_main_checkout || (t.composition?.length ?? 0) > 0; + /** Would a PR lookup mean anything for this task? The background poller's - * own exclusions: a main checkout has no branch of its own, and a plain - * folder has no forge. */ + * own exclusions: a plain folder has no forge, and a repo-less checkout + * (main checkout with no members) has nothing to resolve. */ export function prFocusEligible(taskId: string | null): taskId is string { if (!taskId) return false; const app = useApp.getState(); const t = app.tasks.find(w => w.id === taskId); - if (!t || t.archived || t.is_main_checkout) return false; + if (!t || t.archived || !prRelevant(t)) return false; return !app.projects.find(p => p.id === t.project_id)?.non_git; }