From 45fd81d6cecacb38c753d01bcc8fbb3b05233afa Mon Sep 17 00:00:00 2001 From: kaceper11 <22818077+kaceper11@users.noreply.github.com> Date: Thu, 1 Oct 2026 11:11:20 +0200 Subject: [PATCH] feat(tasks): per-member PR/CI rows + update-all for multi-repo tasks Multi-repo tasks polled PR state for the host worktree only; member repos' PRs were invisible everywhere. Member lookups now ride inside the existing pr.refresh() cadence (same triggers, allSettled so one dead remote can't freeze the other leg), keyed by each member's live branch in its own checkout - repo_root members track live HEAD instead of a frozen recorded branch. Rows render under the Git tab PR card and as board-card mini-rows, including main-checkout tasks where the host card stays hidden; a shared on_disk_members helper keeps status/member-PR/update-all enumeration consistent (existing paths, never the host twice). The branch-chip dropdown gains an "All repos" section: pull / merge base / rebase base across host + members in one shot via task_git_update_all, each repo on its own base branch, failures surfaced per repo without aborting the batch. Generated with [Devin](https://devin.ai) Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- src-tauri/src/lib.rs | 288 +++++++++++++++++++++------ src/components/task/GitPanel.tsx | 67 ++++++- src/components/task/PrCard.tsx | 74 ++++++- src/components/task/RightPanel.tsx | 4 +- src/components/task/TerminalPane.tsx | 9 +- src/components/views/BoardView.tsx | 109 ++++++++-- src/lib/ipc.ts | 15 +- src/lib/types.ts | 18 ++ src/locales/en/chrome.ts | 1 + src/locales/en/panels.ts | 8 + src/locales/zh-CN/chrome.ts | 1 + src/locales/zh-CN/panels.ts | 8 + src/store/pr.test.ts | 167 +++++++++++++++- src/store/pr.ts | 131 +++++++++--- 14 files changed, 777 insertions(+), 123 deletions(-) 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; }