From 66180996b497017a149823d6c0c081283ea78ef5 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Sun, 22 Mar 2026 22:45:40 -0400 Subject: [PATCH 1/4] Refactor lane dialogs and extract lane work session state --- .../components/lanes/AttachLaneDialog.tsx | 141 ++++---- .../components/lanes/CreateLaneDialog.tsx | 313 +++++++++++------- .../components/lanes/LaneDialogShell.tsx | 61 ++++ .../components/lanes/LaneWorkPane.tsx | 12 +- .../renderer/components/lanes/LanesPage.tsx | 34 +- .../components/lanes/useLaneWorkSessions.ts | 15 +- apps/desktop/src/renderer/state/appStore.ts | 4 +- 7 files changed, 370 insertions(+), 210 deletions(-) create mode 100644 apps/desktop/src/renderer/components/lanes/LaneDialogShell.tsx diff --git a/apps/desktop/src/renderer/components/lanes/AttachLaneDialog.tsx b/apps/desktop/src/renderer/components/lanes/AttachLaneDialog.tsx index ccb9d0c9..296dce91 100644 --- a/apps/desktop/src/renderer/components/lanes/AttachLaneDialog.tsx +++ b/apps/desktop/src/renderer/components/lanes/AttachLaneDialog.tsx @@ -1,6 +1,11 @@ -import * as Dialog from "@radix-ui/react-dialog"; import { Link, WarningCircle } from "@phosphor-icons/react"; import { Button } from "../ui/Button"; +import { LaneDialogShell } from "./LaneDialogShell"; + +const SECTION_CLASS_NAME = "rounded-xl border border-white/[0.06] bg-white/[0.03] p-4 shadow-card"; +const LABEL_CLASS_NAME = "text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-fg/70"; +const INPUT_CLASS_NAME = + "mt-2 h-11 w-full rounded-lg border border-white/[0.06] bg-white/[0.03] px-3 text-sm text-fg outline-none transition-colors placeholder:text-muted-fg/45 focus:border-accent/40"; export function AttachLaneDialog({ open, @@ -24,70 +29,80 @@ export function AttachLaneDialog({ onSubmit: () => void; }) { return ( - - - - -
- - - Attach Existing Worktree - - -
-

- Link an existing git worktree into ADE without moving files. The path must be the root of a worktree from this repository. -

-
-
- - setAttachName(e.target.value)} - placeholder="e.g. bugfix/from-other-worktree" - className="h-10 w-full rounded border border-border/20 bg-surface-recessed shadow-card px-3 text-sm outline-none placeholder:text-muted-fg" - autoFocus - disabled={busy} - /> -
-
- - setAttachPath(e.target.value)} - placeholder="/absolute/path/to/existing/worktree" - className="h-10 w-full rounded border border-border/20 bg-surface-recessed px-3 font-mono text-xs outline-none placeholder:text-muted-fg" - disabled={busy} - /> -

- Example: /Users/you/repo-worktrees/feature-auth -

-
+ +
+
+
+ ADE will keep the existing files where they are and start tracking the worktree as a lane in this project.
- {error ? ( -
- - {error} -
- ) : null} -
-
+ +
+ +
+ +
+ +
+ Example: /Users/you/repo-worktrees/feature-auth +
+
+ + {error ? ( +
+ + {error}
- - - + ) : null} + +
+ + +
+
+
); } diff --git a/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.tsx b/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.tsx index b6193d91..ecc38028 100644 --- a/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.tsx +++ b/apps/desktop/src/renderer/components/lanes/CreateLaneDialog.tsx @@ -1,8 +1,16 @@ -import * as Dialog from "@radix-ui/react-dialog"; +import { GitBranch, Plus, StackSimple } from "@phosphor-icons/react"; import { Button } from "../ui/Button"; import type { LaneSummary, LaneEnvInitProgress, LaneTemplate } from "../../../shared/types"; import type { LaneBranchOption } from "./laneUtils"; import { LaneEnvInitProgressPanel } from "./LaneEnvInitProgress"; +import { LaneDialogShell } from "./LaneDialogShell"; + +const SECTION_CLASS_NAME = "rounded-xl border border-white/[0.06] bg-white/[0.03] p-4 shadow-card"; +const LABEL_CLASS_NAME = "text-[11px] font-semibold uppercase tracking-[0.14em] text-muted-fg/70"; +const INPUT_CLASS_NAME = + "mt-2 h-11 w-full rounded-lg border border-white/[0.06] bg-white/[0.03] px-3 text-sm text-fg outline-none transition-colors placeholder:text-muted-fg/45 focus:border-accent/40"; +const SELECT_CLASS_NAME = + "mt-2 h-11 w-full rounded-lg border border-white/[0.06] bg-white/[0.03] px-3 text-sm text-fg outline-none transition-colors focus:border-accent/40"; function buttonLabel(busy: boolean | undefined, createAsChild: boolean, parentLaneId: string, baseBranch: string): string { if (busy) return "Setting up lane..."; @@ -53,138 +61,201 @@ export function CreateLaneDialog({ setSelectedTemplateId: (id: string) => void; onNavigateToTemplates?: () => void; }) { + const localBranches = createBranches.filter((branch) => !branch.isRemote); + const selectedTemplate = templates.find((template) => template.id === selectedTemplateId) ?? null; + return ( - - - - -
- Create lane - -
-
+ +
+
+ +
+ +
+
-
Name
- setCreateLaneName(e.target.value)} - placeholder="e.g. feature/auth-refresh" - className="mt-1 h-10 w-full rounded border border-border/15 bg-surface-recessed shadow-card px-3 text-sm outline-none placeholder:text-muted-fg" - autoFocus - disabled={busy} - /> -
-
-
-
Template
- {onNavigateToTemplates && ( - - )} +
Template
+
+ Optional automation for dependency install, file copy, and lane setup.
- {templates.length > 0 ? ( - - ) : ( -
- - No templates yet — templates copy folders, install deps, and configure lanes automatically. - -
- )}
-
+ {templates.length > 0 ? ( + <> + setCreateParentLaneId(event.target.value)} - className="h-10 w-full rounded border border-border/15 bg-surface-recessed shadow-card px-3 text-sm outline-none" + > + + {templates.map((template) => ( + + ))} + +
+ {selectedTemplate?.description ?? "Create a lane with the default environment setup."} +
+ + ) : ( +
+ No templates yet. Create one to copy folders, install dependencies, and configure lanes automatically. +
+ )} +
+ +
+
+ + {createAsChild ? : } + +
+
Starting point
+
+ Choose whether the new lane starts from primary or from another lane in the stack. +
+
+
- ) : ( -
-
Base branch on primary
- -
- Lane will be created from primary/{createBaseBranch || "..."} -
+
Child lane
+
Stack the lane under an existing lane and inherit its branch.
+
- )} -
- {error && ( -
- {error} + + {createAsChild ? ( + + ) : ( + + )}
- )} -
- -
- {envInitProgress && } - - - +
+ + {error ? ( +
+ {error} +
+ ) : null} + +
+ + +
+ + {envInitProgress ? : null} +
+
); } diff --git a/apps/desktop/src/renderer/components/lanes/LaneDialogShell.tsx b/apps/desktop/src/renderer/components/lanes/LaneDialogShell.tsx new file mode 100644 index 00000000..50c9327a --- /dev/null +++ b/apps/desktop/src/renderer/components/lanes/LaneDialogShell.tsx @@ -0,0 +1,61 @@ +import * as Dialog from "@radix-ui/react-dialog"; +import type { ComponentType, ReactNode } from "react"; +import { Button } from "../ui/Button"; + +export function LaneDialogShell({ + open, + onOpenChange, + title, + description, + icon: Icon, + widthClassName, + busy = false, + children, +}: { + open: boolean; + onOpenChange: (open: boolean) => void; + title: string; + description?: string; + icon?: ComponentType<{ size?: number; className?: string }>; + widthClassName?: string; + busy?: boolean; + children: ReactNode; +}) { + const widthClass = widthClassName ?? "w-[min(680px,calc(100vw-24px))]"; + + return ( + + + + +
+
+
+ + {Icon ? ( + + + + ) : null} + {title} + + {description ? ( + + {description} + + ) : null} +
+ + + +
+ {children} + + + + ); +} diff --git a/apps/desktop/src/renderer/components/lanes/LaneWorkPane.tsx b/apps/desktop/src/renderer/components/lanes/LaneWorkPane.tsx index 57022fdd..f5a94168 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneWorkPane.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneWorkPane.tsx @@ -1,8 +1,8 @@ import { ChatCircleText, Command, Terminal } from "@phosphor-icons/react"; import type { WorkDraftKind } from "../../state/appStore"; -import { WorkViewArea } from "../terminals/WorkViewArea"; import { EmptyState } from "../ui/EmptyState"; import { SANS_FONT } from "./laneDesignTokens"; +import { WorkViewArea } from "../terminals/WorkViewArea"; import { useLaneWorkSessions } from "./useLaneWorkSessions"; const ENTRY_OPTIONS: Array<{ @@ -11,11 +11,13 @@ const ENTRY_OPTIONS: Array<{ icon: typeof ChatCircleText; color: string; }> = [ - { kind: "chat", label: "New chat", icon: ChatCircleText, color: "#8B5CF6" }, - { kind: "cli", label: "CLI tool", icon: Command, color: "#F97316" }, - { kind: "shell", label: "New shell", icon: Terminal, color: "#22C55E" }, + { kind: "chat", label: "New Chat", icon: ChatCircleText, color: "#8B5CF6" }, + { kind: "cli", label: "CLI Tool", icon: Command, color: "#F97316" }, + { kind: "shell", label: "New Shell", icon: Terminal, color: "#22C55E" }, ]; +const EMPTY_CLOSING_PTY_IDS = new Set(); + export function LaneWorkPane({ laneId, }: { @@ -74,7 +76,7 @@ export function LaneWorkPane({ {work.visibleSessions.length} open - {work.loading ? Refreshing... : null} + {work.loading ? Refreshing… : null}
diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index f14e3ca2..698466aa 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -1073,7 +1073,7 @@ export function LanesPage() { })(); await refreshLanes(); - navigate(`/lanes?laneId=${encodeURIComponent(lane.id)}`); + navigate(`/lanes?laneId=${encodeURIComponent(lane.id)}&focus=single`); createEnvInitLaneIdRef.current = lane.id; const envProgress = selectedTemplateId @@ -1108,7 +1108,7 @@ export function LanesPage() { setAttachName(""); setAttachPath(""); setAttachError(null); - navigate(`/lanes?laneId=${encodeURIComponent(lane.id)}`); + navigate(`/lanes?laneId=${encodeURIComponent(lane.id)}&focus=single`); } catch (err) { setAttachError(err instanceof Error ? err.message : String(err)); } finally { @@ -1388,27 +1388,26 @@ export function LanesPage() { NEW LANE {addLaneDropdownOpen ? ( -
+
) : null} diff --git a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts index 7ffd1f84..21b95d0a 100644 --- a/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts +++ b/apps/desktop/src/renderer/components/lanes/useLaneWorkSessions.ts @@ -1,10 +1,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { TerminalSessionSummary } from "../../../shared/types"; import { useAppStore, type WorkDraftKind, type WorkProjectViewState, type WorkViewMode } from "../../state/appStore"; -import { shouldRefreshSessionListForChatEvent } from "../../lib/chatSessionEvents"; import { listSessionsCached } from "../../lib/sessionListCache"; -import { isRunOwnedSession } from "../../lib/sessions"; import { sessionStatusBucket } from "../../lib/terminalAttention"; +import { shouldRefreshSessionListForChatEvent } from "../../lib/chatSessionEvents"; +import { isRunOwnedSession } from "../../lib/sessions"; const DEFAULT_LANE_WORK_STATE: WorkProjectViewState = { openItemIds: [], @@ -337,15 +337,20 @@ export function useLaneWorkSessions(laneId: string | null) { title?: string; startupCommand?: string; }) => { - const titleMap = { claude: "Claude Code", codex: "Codex", shell: "Shell" } as const; - const commandMap = { claude: "claude", codex: "codex", shell: "" } as const; + const toolTypeMap = { + claude: "claude" as const, + codex: "codex" as const, + shell: "shell" as const, + }; + const titleMap = { claude: "Claude Code", codex: "Codex", shell: "Shell" }; + const commandMap = { claude: "claude", codex: "codex", shell: "" }; const result = await window.ade.pty.create({ laneId: args.laneId, cols: 100, rows: 30, title: args.title ?? titleMap[args.profile], tracked: args.tracked ?? true, - toolType: args.profile, + toolType: toolTypeMap[args.profile], startupCommand: args.startupCommand ?? commandMap[args.profile] ?? undefined, }); selectLane(args.laneId); diff --git a/apps/desktop/src/renderer/state/appStore.ts b/apps/desktop/src/renderer/state/appStore.ts index 8e544906..16cd6649 100644 --- a/apps/desktop/src/renderer/state/appStore.ts +++ b/apps/desktop/src/renderer/state/appStore.ts @@ -276,7 +276,9 @@ export const useAppStore = create((set, get) => ({ continue; } const laneId = scopeKey.slice(projectKey.length + 2); - if (allowed.has(laneId)) nextLaneWorkViews[scopeKey] = viewState; + if (allowed.has(laneId)) { + nextLaneWorkViews[scopeKey] = viewState; + } } return { lanes, From 95ffe1d5d7a2f409e34f478e6395bcb2c32ee6dd Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 24 Mar 2026 01:50:48 -0400 Subject: [PATCH 2/4] Fix icon type mismatch and clear background refresh timer on lane change - Widen LaneDialogShell icon prop to accept size as string | number, matching Phosphor icon types - Clear pending background refresh timer when laneId changes to prevent stale data fetches from old closures Co-Authored-By: Claude Opus 4.6 --- apps/desktop/src/renderer/components/lanes/LaneDialogShell.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/src/renderer/components/lanes/LaneDialogShell.tsx b/apps/desktop/src/renderer/components/lanes/LaneDialogShell.tsx index 50c9327a..7fdb3a14 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneDialogShell.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneDialogShell.tsx @@ -16,7 +16,7 @@ export function LaneDialogShell({ onOpenChange: (open: boolean) => void; title: string; description?: string; - icon?: ComponentType<{ size?: number; className?: string }>; + icon?: ComponentType<{ size?: number | string; className?: string }>; widthClassName?: string; busy?: boolean; children: ReactNode; From 0ee8da482e7e805be29f3d431a669f22d9cf0b6a Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 24 Mar 2026 10:05:51 -0400 Subject: [PATCH 3/4] Skip lane rebase when worktree is dirty --- .../conflicts/conflictService.test.ts | 71 +++++++ .../services/conflicts/conflictService.ts | 54 ++++- .../main/services/lanes/laneService.test.ts | 53 ++++- .../src/main/services/lanes/laneService.ts | 74 ++++++- .../services/prs/prPollingService.test.ts | 57 ++++++ .../src/main/services/prs/prPollingService.ts | 21 +- .../src/renderer/components/app/AppShell.tsx | 187 +++++++++++++----- .../PrDetailPane.issueResolver.test.tsx | 66 ++++++- .../components/prs/detail/PrDetailPane.tsx | 145 +++++++++++--- .../components/prs/shared/prVisuals.tsx | 54 ++++- .../components/prs/state/PrsContext.tsx | 16 +- .../components/prs/tabs/GitHubTab.test.tsx | 14 +- .../components/prs/tabs/GitHubTab.tsx | 5 +- .../components/prs/tabs/NormalTab.tsx | 7 +- .../prs/tabs/queueWorkflowModel.test.ts | 2 +- .../components/prs/tabs/queueWorkflowModel.ts | 2 +- apps/desktop/src/shared/types/prs.ts | 5 + 17 files changed, 728 insertions(+), 105 deletions(-) diff --git a/apps/desktop/src/main/services/conflicts/conflictService.test.ts b/apps/desktop/src/main/services/conflicts/conflictService.test.ts index 8a606550..603fee8d 100644 --- a/apps/desktop/src/main/services/conflicts/conflictService.test.ts +++ b/apps/desktop/src/main/services/conflicts/conflictService.test.ts @@ -952,4 +952,75 @@ describe("conflictService conflict context integrity", () => { expect(rebased.success).toBe(true); expect(git(repoRoot, ["rev-list", "--count", "HEAD..main"])).toBe("0"); }); + + it("prefers the current parent lane branch when baseRef is stale", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-conflicts-parent-branch-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + const projectId = randomUUID(); + const now = "2026-03-24T12:00:00.000Z"; + + try { + db.run( + "insert into projects(id, root_path, display_name, default_base_ref, created_at, last_opened_at) values (?, ?, ?, ?, ?, ?)", + [projectId, repoRoot, "demo", "main", now, now] + ); + + fs.writeFileSync(path.join(repoRoot, "file.txt"), "base\n", "utf8"); + git(repoRoot, ["init", "-b", "main"]); + git(repoRoot, ["config", "user.email", "ade@test.local"]); + git(repoRoot, ["config", "user.name", "ADE Test"]); + git(repoRoot, ["add", "."]); + git(repoRoot, ["commit", "-m", "base"]); + + git(repoRoot, ["checkout", "-b", "feature/parent-current"]); + git(repoRoot, ["checkout", "-b", "feature/child"]); + fs.writeFileSync(path.join(repoRoot, "file.txt"), "child\n", "utf8"); + git(repoRoot, ["add", "file.txt"]); + git(repoRoot, ["commit", "-m", "child work"]); + + git(repoRoot, ["checkout", "main"]); + fs.writeFileSync(path.join(repoRoot, "main.txt"), "main advance\n", "utf8"); + git(repoRoot, ["add", "main.txt"]); + git(repoRoot, ["commit", "-m", "main advance"]); + git(repoRoot, ["checkout", "feature/child"]); + + const parentLane = { + ...createLaneSummary(repoRoot, { + id: "lane-parent", + name: "Primary", + branchRef: "feature/parent-current", + baseRef: "main", + parentLaneId: null + }), + laneType: "primary" as const, + }; + const childLane = createLaneSummary(repoRoot, { + id: "lane-child", + name: "Child", + branchRef: "feature/child", + baseRef: "main", + parentLaneId: "lane-parent" + }); + + const service = createConflictService({ + db, + logger: createLogger(), + projectId, + projectRoot: repoRoot, + laneService: { + list: async () => [parentLane, childLane], + getLaneBaseAndBranch: () => ({ worktreePath: repoRoot, baseRef: "main", branchRef: "feature/child" }) + } as any, + projectConfigService: { + get: () => ({ effective: { providerMode: "guest" }, local: {} }) + } as any, + }); + + expect(await service.scanRebaseNeeds()).toEqual([]); + expect(await service.getRebaseNeed("lane-child")).toBeNull(); + } finally { + db.close(); + fs.rmSync(repoRoot, { recursive: true, force: true }); + } + }); }); diff --git a/apps/desktop/src/main/services/conflicts/conflictService.ts b/apps/desktop/src/main/services/conflicts/conflictService.ts index ed78208b..574f18ab 100644 --- a/apps/desktop/src/main/services/conflicts/conflictService.ts +++ b/apps/desktop/src/main/services/conflicts/conflictService.ts @@ -75,6 +75,7 @@ import { redactSecretsDeep } from "../../utils/redaction"; import { extractFirstJsonObject } from "../ai/utils"; import { safeSegment } from "../shared/packLegacyUtils"; import { fetchQueueTargetTrackingBranches, resolveQueueRebaseOverride } from "../shared/queueRebase"; +import type { QueueRebaseOverride } from "../shared/queueRebase"; import { asString, isRecord, parseDiffNameOnly, safeJsonParse, uniqueSorted } from "../shared/utils"; type PredictionStatus = "clean" | "conflict" | "unknown"; @@ -269,6 +270,36 @@ async function readTouchedFiles(cwd: string, mergeBase: string, headSha: string) return new Set(parseDiffNameOnly(res.stdout)); } +function resolveLaneRebaseTarget(args: { + lane: LaneSummary; + lanesById: Map; + queueOverride: QueueRebaseOverride | null; +}): { + comparisonRef: string; + displayBaseBranch: string; +} { + if (args.queueOverride) { + return { + comparisonRef: args.queueOverride.comparisonRef, + displayBaseBranch: args.queueOverride.displayBaseBranch, + }; + } + + const parent = args.lane.parentLaneId ? args.lanesById.get(args.lane.parentLaneId) ?? null : null; + const parentBranchRef = parent?.branchRef?.trim() ?? ""; + if (parentBranchRef) { + return { + comparisonRef: parentBranchRef, + displayBaseBranch: parentBranchRef, + }; + } + + return { + comparisonRef: args.lane.baseRef, + displayBaseBranch: args.lane.baseRef, + }; +} + async function readDiffNumstat(cwd: string, mergeBase: string, headSha: string): Promise<{ files: Set; insertions: number; @@ -4173,6 +4204,7 @@ export function createConflictService({ } const lanes = await listActiveLanes(); + const lanesById = new Map(lanes.map((lane) => [lane.id, lane] as const)); const needs: RebaseNeed[] = []; // Skip primary lane — it IS the base, rebasing it is nonsensical @@ -4185,8 +4217,11 @@ export function createConflictService({ projectRoot, laneId: lane.id, }); - const comparisonRef = queueOverride?.comparisonRef ?? lane.baseRef; - const displayBaseBranch = queueOverride?.displayBaseBranch ?? lane.baseRef; + const { comparisonRef, displayBaseBranch } = resolveLaneRebaseTarget({ + lane, + lanesById, + queueOverride, + }); const baseHead = await readHeadSha(projectRoot, comparisonRef); const laneHead = await readHeadSha(lane.worktreePath, "HEAD"); @@ -4244,6 +4279,7 @@ export function createConflictService({ }); const lanes = await listActiveLanes(); + const lanesById = new Map(lanes.map((entry) => [entry.id, entry] as const)); const lane = lanes.find((l) => l.id === laneId); if (!lane || lane.laneType === "primary") return null; @@ -4254,8 +4290,11 @@ export function createConflictService({ projectRoot, laneId: lane.id, }); - const comparisonRef = queueOverride?.comparisonRef ?? lane.baseRef; - const displayBaseBranch = queueOverride?.displayBaseBranch ?? lane.baseRef; + const { comparisonRef, displayBaseBranch } = resolveLaneRebaseTarget({ + lane, + lanesById, + queueOverride, + }); const baseHead = await readHeadSha(projectRoot, comparisonRef); const laneHead = await readHeadSha(lane.worktreePath, "HEAD"); @@ -4340,6 +4379,7 @@ export function createConflictService({ try { const lanes = await listActiveLanes(); + const lanesById = new Map(lanes.map((entry) => [entry.id, entry] as const)); const lane = lanes.find((l) => l.id === args.laneId); if (!lane) { return { @@ -4384,7 +4424,11 @@ export function createConflictService({ projectRoot, laneId: lane.id, }); - const rebaseTarget = queueOverride?.comparisonRef ?? lane.baseRef; + const { comparisonRef: rebaseTarget } = resolveLaneRebaseTarget({ + lane, + lanesById, + queueOverride, + }); const rebaseRes = await runGit( ["rebase", rebaseTarget], { cwd: lane.worktreePath, timeoutMs: 120_000 } diff --git a/apps/desktop/src/main/services/lanes/laneService.test.ts b/apps/desktop/src/main/services/lanes/laneService.test.ts index 860c403e..1930da04 100644 --- a/apps/desktop/src/main/services/lanes/laneService.test.ts +++ b/apps/desktop/src/main/services/lanes/laneService.test.ts @@ -11,7 +11,7 @@ vi.mock("../git/git", () => ({ runGitOrThrow: vi.fn(), })); -import { getHeadSha, runGit } from "../git/git"; +import { getHeadSha, runGit, runGitOrThrow } from "../git/git"; function createLogger() { return { @@ -61,6 +61,7 @@ describe("laneService rebaseStart", () => { beforeEach(() => { vi.mocked(getHeadSha).mockReset(); vi.mocked(runGit).mockReset(); + vi.mocked(runGitOrThrow).mockReset(); }); it("skips rebasing when the parent head is already an ancestor of the lane head", async () => { @@ -155,4 +156,54 @@ describe("laneService rebaseStart", () => { const completed = await firstRun; expect(completed.run.state).toBe("completed"); }); + + it("rebases against the primary lane remote tracking ref when it is available", async () => { + const repoRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-lane-service-primary-remote-")); + const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); + await seedProjectAndStack(db, { projectId: "proj-primary-remote", repoRoot }); + + vi.mocked(runGitOrThrow).mockResolvedValue({ exitCode: 0, stdout: "", stderr: "" } as any); + vi.mocked(getHeadSha).mockImplementation(async (cwd: string) => { + if (cwd.endsWith("/parent")) return "sha-parent"; + return "sha-main"; + }); + vi.mocked(runGit).mockImplementation(async (args: string[]) => { + if ( + args[0] === "rev-parse" + && args[1] === "--abbrev-ref" + && args[2] === "--symbolic-full-name" + && args[3] === "@{upstream}" + ) { + return { exitCode: 0, stdout: "origin/main\n", stderr: "" }; + } + if (args[0] === "rev-parse" && args[1] === "--verify" && args[2] === "origin/main") { + return { exitCode: 0, stdout: "sha-origin-main\n", stderr: "" }; + } + if (args[0] === "merge-base" && args[1] === "--is-ancestor") { + expect(args[2]).toBe("sha-origin-main"); + expect(args[3]).toBe("sha-parent"); + return { exitCode: 1, stdout: "", stderr: "" }; + } + if (args[0] === "rebase") { + expect(args[1]).toBe("sha-origin-main"); + return { exitCode: 0, stdout: "", stderr: "" }; + } + throw new Error(`Unexpected git call: ${args.join(" ")}`); + }); + + const service = createLaneService({ + db, + projectRoot: repoRoot, + projectId: "proj-primary-remote", + defaultBaseRef: "main", + worktreesDir: path.join(repoRoot, "worktrees"), + }); + + const result = await service.rebaseStart({ laneId: "lane-parent", scope: "lane_only", actor: "user" }); + + expect(result.run.state).toBe("completed"); + expect(result.run.error).toBeNull(); + expect(result.run.lanes[0]?.status).toBe("succeeded"); + expect(vi.mocked(runGitOrThrow)).toHaveBeenCalled(); + }); }); diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index 4c5c7d68..743e444b 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -4,7 +4,7 @@ import { randomUUID } from "node:crypto"; import type { AdeDb } from "../state/kvDb"; import { getHeadSha, runGit, runGitOrThrow } from "../git/git"; import { isWithinDir } from "../shared/utils"; -import { resolveQueueRebaseOverride, type QueueRebaseOverride } from "../shared/queueRebase"; +import { fetchRemoteTrackingBranch, resolveQueueRebaseOverride, type QueueRebaseOverride } from "../shared/queueRebase"; import { detectConflictKind } from "../git/gitConflictState"; import type { createOperationService } from "../history/operationService"; import type { @@ -226,6 +226,59 @@ async function computeLaneStatus(worktreePath: string, baseRef: string, branchRe return { dirty, ahead, behind, remoteBehind, rebaseInProgress }; } +async function resolveParentRebaseTarget(args: { + projectRoot: string; + parent: LaneRow; +}): Promise<{ headSha: string; label: string }> { + const { projectRoot, parent } = args; + + if (parent.lane_type === "primary") { + await fetchRemoteTrackingBranch({ + projectRoot, + targetBranch: parent.branch_ref, + }).catch(() => false); + + const candidateRefs: string[] = []; + const upstreamRes = await runGit( + ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{upstream}"], + { cwd: parent.worktree_path, timeoutMs: 5_000 }, + ); + if (upstreamRes.exitCode === 0 && upstreamRes.stdout.trim()) { + candidateRefs.push(upstreamRes.stdout.trim()); + } + const originRef = `origin/${parent.branch_ref}`; + if (!candidateRefs.includes(originRef)) { + candidateRefs.push(originRef); + } + + for (const candidateRef of candidateRefs) { + const candidateRes = await runGit( + ["rev-parse", "--verify", candidateRef], + { cwd: parent.worktree_path, timeoutMs: 5_000 }, + ); + if (candidateRes.exitCode === 0 && candidateRes.stdout.trim()) { + return { + headSha: candidateRes.stdout.trim(), + label: candidateRef, + }; + } + } + } + + const headSha = await getHeadSha(parent.worktree_path); + if (!headSha) { + throw new Error(`Unable to resolve parent HEAD for ${parent.name}`); + } + return { + headSha, + label: parent.name, + }; +} + +function describeParentRebaseTarget(parent: LaneRow, label: string): string { + return label === parent.name ? parent.name : `${parent.name} (${label})`; +} + function computeStackDepth(args: { laneId: string; rowsById: Map; @@ -1274,11 +1327,20 @@ export function createLaneService({ break; } - const parentHead = await getHeadSha(parent.worktree_path); - if (!parentHead) { - failRunAtLane(laneItem, lane.id, index, `Unable to resolve parent HEAD for ${parent.name}`); + let parentTarget: { headSha: string; label: string }; + try { + parentTarget = await resolveParentRebaseTarget({ projectRoot, parent }); + } catch (error) { + failRunAtLane( + laneItem, + lane.id, + index, + error instanceof Error ? error.message : `Unable to resolve parent HEAD for ${parent.name}`, + ); break; } + const parentHead = parentTarget.headSha; + const parentTargetLabel = describeParentRebaseTarget(parent, parentTarget.label); run.currentLaneId = lane.id; laneItem.preHeadSha = await getHeadSha(lane.worktree_path); @@ -1298,7 +1360,7 @@ export function createLaneService({ emitRunLog({ runId, laneId: lane.id, - message: `${lane.name} is already up to date with ${parent.name}; skipping rebase.`, + message: `${lane.name} is already up to date with ${parentTargetLabel}; skipping rebase.`, }); emitRunUpdated(run); continue; @@ -1323,7 +1385,7 @@ export function createLaneService({ emitRunLog({ runId, laneId: lane.id, - message: `Rebasing ${lane.name} onto ${parent.name} (${parentHead.slice(0, 8)})` + message: `Rebasing ${lane.name} onto ${parentTargetLabel} (${parentHead.slice(0, 8)})` }); const operation = operationService?.start({ diff --git a/apps/desktop/src/main/services/prs/prPollingService.test.ts b/apps/desktop/src/main/services/prs/prPollingService.test.ts index bffd662b..9661a93c 100644 --- a/apps/desktop/src/main/services/prs/prPollingService.test.ts +++ b/apps/desktop/src/main/services/prs/prPollingService.test.ts @@ -140,4 +140,61 @@ describe("prPollingService", () => { expect(refresh).toHaveBeenCalledTimes(2); expect(refresh).toHaveBeenLastCalledWith({ prIds: ["pr-1"] }); }); + + it("emits informative PR notifications with PR metadata", async () => { + vi.useFakeTimers(); + vi.setSystemTime(new Date("2026-03-24T12:00:00.000Z")); + vi.spyOn(Math, "random").mockReturnValue(0.5); + + let summary = createSummary({ + title: "Fix lanes tab", + headBranch: "fix-lanes-tab", + checksStatus: "passing", + reviewStatus: "approved", + }); + let refreshCount = 0; + const events: any[] = []; + + const prService = { + listAll: () => [summary], + refresh: vi.fn(async () => { + refreshCount += 1; + if (refreshCount >= 2) { + summary = { + ...summary, + checksStatus: "failing", + updatedAt: new Date(Date.now()).toISOString(), + }; + } + return [summary]; + }), + getHotRefreshDelayMs: () => null, + getHotRefreshPrIds: () => [], + } as any; + + const service = createPrPollingService({ + logger: createLogger() as any, + prService, + projectConfigService: { get: () => ({ effective: {} }) } as any, + onEvent: (event) => events.push(event), + }); + + service.start(); + await vi.advanceTimersByTimeAsync(12_000); + + service.poke(); + await vi.advanceTimersByTimeAsync(0); + + expect(events).toContainEqual(expect.objectContaining({ + type: "pr-notification", + kind: "checks_failing", + title: "Checks failing", + prTitle: "Fix lanes tab", + repoOwner: "acme", + repoName: "ade", + baseBranch: "main", + headBranch: "fix-lanes-tab", + message: "One or more required CI checks failed on this pull request.", + })); + }); }); diff --git a/apps/desktop/src/main/services/prs/prPollingService.ts b/apps/desktop/src/main/services/prs/prPollingService.ts index e9be0294..d87a2c33 100644 --- a/apps/desktop/src/main/services/prs/prPollingService.ts +++ b/apps/desktop/src/main/services/prs/prPollingService.ts @@ -18,17 +18,28 @@ function jitterMs(value: number): number { } function summarizeNotification(args: { kind: PrNotificationKind; pr: PrSummary }): { title: string; message: string } { - const prLabel = args.pr.githubPrNumber ? `#${args.pr.githubPrNumber}` : "PR"; if (args.kind === "checks_failing") { - return { title: `Checks failing ${prLabel}`, message: args.pr.title || "A pull request has failing checks." }; + return { + title: "Checks failing", + message: "One or more required CI checks failed on this pull request.", + }; } if (args.kind === "review_requested") { - return { title: `Review requested ${prLabel}`, message: args.pr.title || "A pull request needs review." }; + return { + title: "Review requested", + message: "This pull request is waiting on an approving review.", + }; } if (args.kind === "changes_requested") { - return { title: `Changes requested ${prLabel}`, message: args.pr.title || "A pull request has requested changes." }; + return { + title: "Changes requested", + message: "A reviewer requested changes before this pull request can merge.", + }; } - return { title: `Merge ready ${prLabel}`, message: args.pr.title || "A pull request looks merge-ready." }; + return { + title: "Ready to merge", + message: "Required checks are passing and the pull request has approval.", + }; } /** diff --git a/apps/desktop/src/renderer/components/app/AppShell.tsx b/apps/desktop/src/renderer/components/app/AppShell.tsx index 0ddf2216..987b0177 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.tsx @@ -1,9 +1,19 @@ import React, { useEffect, useMemo, useRef, useState } from "react"; +import { + ArrowSquareOut, + CheckCircle, + GitBranch, + GithubLogo, + GitPullRequest, + WarningCircle, + XCircle, +} from "@phosphor-icons/react"; import { Link, useLocation, useNavigate } from "react-router-dom"; import { CommandPalette } from "./CommandPalette"; import { TabNav } from "./TabNav"; import { TopBar } from "./TopBar"; import { RightEdgeFloatingPane } from "./RightEdgeFloatingPane"; +import { getPrToastHeadline, getPrToastMeta, getPrToastSummary, getPrToastTone, type PrToastTone } from "./prToastPresentation"; import { TabBackground } from "../ui/TabBackground"; import { useAppStore } from "../../state/appStore"; import { Button } from "../ui/Button"; @@ -56,6 +66,51 @@ function shortId(id: string): string { return trimmed.length <= 8 ? trimmed : trimmed.slice(0, 8); } +function getPrToastToneClasses(tone: PrToastTone): { + panel: string; + badge: string; + iconWrap: string; + iconClass: string; +} { + if (tone === "danger") { + return { + panel: "border-red-500/25 bg-card/95", + badge: "border border-red-500/30 bg-red-500/10 text-red-300", + iconWrap: "border border-red-500/30 bg-red-500/12", + iconClass: "text-red-300", + }; + } + if (tone === "warning") { + return { + panel: "border-amber-500/25 bg-card/95", + badge: "border border-amber-500/30 bg-amber-500/10 text-amber-300", + iconWrap: "border border-amber-500/30 bg-amber-500/12", + iconClass: "text-amber-300", + }; + } + if (tone === "success") { + return { + panel: "border-emerald-500/25 bg-card/95", + badge: "border border-emerald-500/30 bg-emerald-500/10 text-emerald-300", + iconWrap: "border border-emerald-500/30 bg-emerald-500/12", + iconClass: "text-emerald-300", + }; + } + return { + panel: "border-sky-500/25 bg-card/95", + badge: "border border-sky-500/30 bg-sky-500/10 text-sky-300", + iconWrap: "border border-sky-500/30 bg-sky-500/12", + iconClass: "text-sky-300", + }; +} + +function getPrToastIcon(kind: PrToast["event"]["kind"]) { + if (kind === "checks_failing") return XCircle; + if (kind === "changes_requested") return WarningCircle; + if (kind === "merge_ready") return CheckCircle; + return GitPullRequest; +} + export function AppShell({ children }: { children: React.ReactNode }) { const location = useLocation(); const navigate = useNavigate(); @@ -614,53 +669,95 @@ export function AppShell({ children }: { children: React.ReactNode }) {
{prToasts.map((toast) => { const laneName = lanes.find((lane) => lane.id === toast.event.laneId)?.name ?? toast.event.laneId; + const tone = getPrToastTone(toast.event.kind); + const toneClasses = getPrToastToneClasses(tone); + const Icon = getPrToastIcon(toast.event.kind); + const headline = getPrToastHeadline(toast.event); + const summary = getPrToastSummary(toast.event); + const meta = getPrToastMeta(toast.event, laneName); return ( -
-
-
-
{toast.event.title}
-
{laneName}
+
+
+
+ +
+
+
+
+
+ + {toast.event.title} + + #{toast.event.prNumber} +
+
+ {headline} +
+
+ +
+ {meta.length > 0 ? ( +
+ {meta.map((item, index) => ( + + {index === 0 ? : index === 1 ? : } + {item} + + ))} +
+ ) : null} +
{summary}
+
+ + +
- -
-
{toast.event.message}
-
- -
); diff --git a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.issueResolver.test.tsx b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.issueResolver.test.tsx index 07d80ad5..8cf5c622 100644 --- a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.issueResolver.test.tsx +++ b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.issueResolver.test.tsx @@ -165,6 +165,7 @@ function renderPane(args: { onNavigate?: (path: string) => void; activity?: PrActivityEvent[]; statusOverrides?: Partial; + mergeMethod?: "merge" | "squash" | "rebase"; }) { const issueResolutionStart = vi.fn().mockResolvedValue({ sessionId: "session-1", @@ -177,6 +178,16 @@ function renderPane(args: { }); const getReviewThreads = vi.fn().mockResolvedValue(args.reviewThreads); const writeClipboardText = vi.fn().mockResolvedValue(undefined); + const land = vi.fn().mockResolvedValue({ + prId: "pr-80", + prNumber: 80, + success: true, + mergeCommitSha: "sha-merge", + branchDeleted: false, + laneArchived: false, + error: null, + }); + const onRefresh = vi.fn().mockResolvedValue(undefined); Object.assign(window, { ade: { prs: { @@ -197,6 +208,7 @@ function renderPane(args: { getReviewThreads, issueResolutionStart, issueResolutionPreviewPrompt, + land, openInGitHub: vi.fn().mockResolvedValue(undefined), }, app: { @@ -211,6 +223,8 @@ function renderPane(args: { issueResolutionPreviewPrompt, getReviewThreads, writeClipboardText, + land, + onRefresh, ...render( , ), @@ -270,6 +284,54 @@ describe("PrDetailPane issue resolver CTA", () => { }); }); + it("keeps the merge readiness checks row in a running state while failed checks are still in flight", async () => { + renderPane({ + checks: [ + makeCheck({ name: "ci / unit", conclusion: "success" }), + makeCheck({ name: "ci / e2e", conclusion: "failure" }), + makeCheck({ name: "ci / lint", status: "in_progress", conclusion: null }), + ], + reviewThreads: [], + }); + + await waitFor(() => { + expect(screen.getByText("Some checks failing")).toBeTruthy(); + expect(screen.getByText("1/3 checks passing, 1 still running")).toBeTruthy(); + expect(screen.getAllByLabelText("CI running").length).toBeGreaterThan(0); + }); + }); + + it("lets the operator attempt a bypass merge and uses the selected merge method", async () => { + const user = userEvent.setup(); + const { land, onRefresh } = renderPane({ + checks: [makeCheck()], + reviewThreads: [], + mergeMethod: "squash", + statusOverrides: { + checksStatus: "failing", + reviewStatus: "changes_requested", + isMergeable: false, + mergeConflicts: false, + }, + }); + + const mergeButton = await screen.findByRole("button", { name: /merge pull request/i }); + expect((mergeButton as HTMLButtonElement).disabled).toBe(true); + + await user.click(screen.getByRole("button", { name: /create merge commit/i })); + await user.click(screen.getByRole("checkbox", { name: /attempt merge anyway if github allows bypass rules/i })); + + const bypassButton = screen.getByRole("button", { name: /attempt merge anyway/i }); + expect((bypassButton as HTMLButtonElement).disabled).toBe(false); + + await user.click(bypassButton); + + await waitFor(() => { + expect(land).toHaveBeenCalledWith({ prId: "pr-80", method: "merge" }); + expect(onRefresh).toHaveBeenCalled(); + }); + }); + it("launches the issue resolver chat and navigates to the work session", async () => { const user = userEvent.setup(); const onNavigate = vi.fn(); diff --git a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx index d6d51e1d..0a21de98 100644 --- a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx +++ b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx @@ -15,7 +15,7 @@ import type { } from "../../../../shared/types"; import { getPrIssueResolutionAvailability } from "../../../../shared/prIssueResolution"; import { COLORS, MONO_FONT, SANS_FONT, LABEL_STYLE, cardStyle, inlineBadge, outlineButton, primaryButton, dangerButton } from "../../lanes/laneDesignTokens"; -import { getPrChecksBadge, getPrReviewsBadge, getPrStateBadge, InlinePrBadge } from "../shared/prVisuals"; +import { getPrChecksBadge, getPrReviewsBadge, getPrStateBadge, InlinePrBadge, PrCiRunningIndicator } from "../shared/prVisuals"; import { PrIssueResolverModal } from "../shared/PrIssueResolverModal"; import { PrLaneCleanupBanner } from "../shared/PrLaneCleanupBanner"; import { formatTimeAgo, formatTimestampFull } from "../shared/prFormatters"; @@ -287,6 +287,21 @@ function fileStatusLabel(status: string): string { return FILE_STATUS_LABELS[status] ?? "?"; } +function summarizeChecks(checks: PrCheck[]) { + const passing = checks.filter((check) => check.conclusion === "success").length; + const failing = checks.filter((check) => check.conclusion === "failure").length; + const pending = checks.filter((check) => check.status !== "completed").length; + return { + passing, + failing, + pending, + total: checks.length, + allChecksPassed: checks.length > 0 && failing === 0 && pending === 0, + someChecksFailing: failing > 0, + checksRunning: pending > 0, + }; +} + // ---- Props ---- type PrDetailPaneProps = { pr: PrWithConflicts; @@ -425,10 +440,10 @@ export function PrDetailPane({ }; // ---- Actions ---- - const handleMerge = () => { + const handleMerge = (method: MergeMethod) => { setActionResult(null); return runAction(async () => { - const res = await window.ade.prs.land({ prId: pr.id, method: mergeMethod }); + const res = await window.ade.prs.land({ prId: pr.id, method }); setActionResult(res); await onRefresh(); }); @@ -968,10 +983,11 @@ function CommentMenu({ url }: { url: string | null }) { } // ---- Merge readiness status row ---- -function MergeStatusRow({ color, icon, title, description, children, expandable, expanded, onToggle }: { +function MergeStatusRow({ color, icon, title, titleAccessory, description, children, expandable, expanded, onToggle }: { color: string; icon: React.ReactNode; title: string; + titleAccessory?: React.ReactNode; description: string; children?: React.ReactNode; expandable?: boolean; @@ -991,6 +1007,7 @@ function MergeStatusRow({ color, icon, title, description, children, expandable,
{title} + {titleAccessory} {expandable && ( expanded ? : )} @@ -1049,7 +1066,7 @@ type OverviewTabProps = { setReviewBody: (v: string) => void; reviewEvent: "APPROVE" | "REQUEST_CHANGES" | "COMMENT"; setReviewEvent: (v: "APPROVE" | "REQUEST_CHANGES" | "COMMENT") => void; - onMerge: () => void; + onMerge: (method: MergeMethod) => void; onAddComment: () => void; onUpdateBody: () => void; onSetLabels: (labels: string[]) => void; @@ -1069,6 +1086,11 @@ function OverviewTab(props: OverviewTabProps) { const { pr, detail, status, checks, reviews, comments, detailBusy, aiSummary, aiSummaryBusy, actionBusy, mergeMethod, activity, lanes } = props; const [checksExpanded, setChecksExpanded] = React.useState(false); const [localMergeMethod, setLocalMergeMethod] = React.useState(mergeMethod); + const [allowBlockedMerge, setAllowBlockedMerge] = React.useState(false); + + React.useEffect(() => { + setLocalMergeMethod(mergeMethod); + }, [mergeMethod]); // Sort comments chronologically (oldest first, like GitHub) const sortedComments = React.useMemo( @@ -1081,18 +1103,39 @@ function OverviewTab(props: OverviewTabProps) { ); // Checks summary - const passing = checks.filter(c => c.conclusion === "success").length; - const failing = checks.filter(c => c.conclusion === "failure").length; - const pending = checks.filter(c => c.status !== "completed").length; - const allChecksPassed = checks.length > 0 && failing === 0 && pending === 0; - const someChecksPending = pending > 0; - const someChecksFailing = failing > 0 && !someChecksPending; + const { passing, pending, total: totalChecks, allChecksPassed, someChecksFailing, checksRunning } = summarizeChecks(checks); // Review status from pr const reviewStatus = pr.reviewStatus; // Merge readiness - const canMerge = status?.isMergeable && !status?.mergeConflicts && pr.state === "open"; + const canMerge = Boolean(status?.isMergeable) && !status?.mergeConflicts && pr.state === "open"; + const canAttemptBlockedMerge = Boolean(status) && !status?.isMergeable && !status?.mergeConflicts && pr.state === "open"; + const mergeActionEnabled = canMerge || (allowBlockedMerge && canAttemptBlockedMerge); + const mergeActionLabel = actionBusy + ? (allowBlockedMerge && canAttemptBlockedMerge ? "Attempting merge..." : "Merging...") + : (allowBlockedMerge && canAttemptBlockedMerge ? "Attempt merge anyway" : "Merge pull request"); + const mergeActionBackground = canMerge + ? `linear-gradient(135deg, ${COLORS.success} 0%, #16a34a 100%)` + : allowBlockedMerge && canAttemptBlockedMerge + ? `linear-gradient(135deg, ${COLORS.warning} 0%, #d97706 100%)` + : COLORS.recessedBg; + const mergeActionBorderColor = canMerge + ? COLORS.success + : allowBlockedMerge && canAttemptBlockedMerge + ? COLORS.warning + : COLORS.border; + const mergeActionShadow = canMerge + ? `0 2px 16px ${COLORS.success}40, 0 0 0 1px ${COLORS.success}30` + : allowBlockedMerge && canAttemptBlockedMerge + ? `0 2px 16px ${COLORS.warning}35, 0 0 0 1px ${COLORS.warning}25` + : "none"; + + React.useEffect(() => { + if (!canAttemptBlockedMerge) { + setAllowBlockedMerge(false); + } + }, [canAttemptBlockedMerge]); return (
@@ -1370,26 +1413,28 @@ function OverviewTab(props: OverviewTabProps) { {/* Checks status */} - : someChecksFailing - ? - : + : checksRunning + ? + : someChecksFailing + ? + : } title={ allChecksPassed ? "All checks have passed" - : someChecksPending ? "Checks in progress" + : checksRunning ? "Checks in progress" : someChecksFailing ? "Some checks failing" : checks.length === 0 ? "No checks" : "Checks in progress" } + titleAccessory={checksRunning ? : undefined} description={ allChecksPassed ? `${passing} successful check${passing !== 1 ? "s" : ""}` - : someChecksPending && failing > 0 ? `${pending} pending, ${failing} failing` - : someChecksPending ? `${pending} check${pending !== 1 ? "s" : ""} pending` - : someChecksFailing ? `${passing}/${checks.length} checks passing` - : checks.length === 0 ? "No status checks are required" : `${pending} check${pending !== 1 ? "s" : ""} pending` + : someChecksFailing && checksRunning ? `${passing}/${totalChecks} checks passing, ${pending} still running` + : someChecksFailing ? `${passing}/${totalChecks} checks passing` + : checks.length === 0 ? "No status checks are required" : `${pending} check${pending !== 1 ? "s" : ""} pending` } expandable={checks.length > 0} expanded={checksExpanded} @@ -1441,14 +1486,20 @@ function OverviewTab(props: OverviewTabProps) { description={ status?.isMergeable && !status?.mergeConflicts ? "This branch has no conflicts with the base branch" : status?.mergeConflicts ? "This branch has conflicts that must be resolved" - : status && !status.isMergeable ? "Required conditions have not been met" + : status && !status.isMergeable ? "Required conditions have not been met. If GitHub offers bypass rules for your account, you can still attempt the merge below." : "Waiting for merge status check" } /> {/* Merge action area */} {(pr.state === "open" || pr.state === "draft") && ( -
+
{/* Merge method selector */}
{(["squash", "merge", "rebase"] as const).map((m) => { @@ -1474,28 +1525,60 @@ function OverviewTab(props: OverviewTabProps) { })}
+ {canAttemptBlockedMerge && ( + + )} +
{pr.state === "open" && ( diff --git a/apps/desktop/src/renderer/components/prs/shared/prVisuals.tsx b/apps/desktop/src/renderer/components/prs/shared/prVisuals.tsx index a6958b48..b57fe818 100644 --- a/apps/desktop/src/renderer/components/prs/shared/prVisuals.tsx +++ b/apps/desktop/src/renderer/components/prs/shared/prVisuals.tsx @@ -1,6 +1,7 @@ import React from "react"; +import { CircleNotch } from "@phosphor-icons/react"; import type { PrChecksStatus, PrReviewStatus, PrState } from "../../../../shared/types"; -import { COLORS, inlineBadge } from "../../lanes/laneDesignTokens"; +import { COLORS, SANS_FONT, inlineBadge } from "../../lanes/laneDesignTokens"; export type PrActivityState = "active" | "idle" | "stale"; @@ -92,3 +93,54 @@ export function InlinePrBadge(props: { label: string; color: string; bg: string; return {label}; } +export function PrCiRunningIndicator(props: { + showLabel?: boolean; + label?: string; + color?: string; + size?: number; + title?: string; +}) { + const { + showLabel = false, + label = "running", + color = COLORS.warning, + size = 10, + title = "CI checks are still running", + } = props; + + if (!showLabel) { + return ( + + + + ); + } + + return ( + + + {label} + + ); +} diff --git a/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx b/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx index 2039be39..5fc44d6d 100644 --- a/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx +++ b/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx @@ -196,6 +196,10 @@ export function PrsProvider({ children }: { children: React.ReactNode }) { // Rebase state const [rebaseNeeds, setRebaseNeeds] = useState([]); const [autoRebaseStatuses, setAutoRebaseStatuses] = useState([]); + const rebaseNeedsRef = React.useRef([]); + rebaseNeedsRef.current = rebaseNeeds; + const autoRebaseStatusesRef = React.useRef([]); + autoRebaseStatusesRef.current = autoRebaseStatuses; // Queue state const [queueStates, setQueueStates] = useState>({}); @@ -315,12 +319,20 @@ export function PrsProvider({ children }: { children: React.ReactNode }) { try { await window.ade.prs.refresh().catch(() => {}); const shouldLoadWorkflowState = activeTab !== "normal"; - const [prList, laneList, queueStateList] = await Promise.all([ + const [prList, laneList, queueStateList, refreshedRebaseNeeds, refreshedAutoRebaseStatuses] = await Promise.all([ window.ade.prs.listWithConflicts(), window.ade.lanes.list({ includeStatus: true }), shouldLoadWorkflowState ? window.ade.prs.listQueueStates({ includeCompleted: true, limit: 50 }) : Promise.resolve([] as QueueLandingState[]), + window.ade.rebase.scanNeeds().catch((err) => { + console.warn("[PrsContext] Failed to refresh rebase needs:", err); + return rebaseNeedsRef.current; + }), + window.ade.lanes.listAutoRebaseStatuses().catch((err) => { + console.warn("[PrsContext] Failed to refresh auto-rebase statuses:", err); + return autoRebaseStatusesRef.current; + }), ]); const changedPrIds = diffPrIds(prsRef.current, prList); @@ -328,6 +340,8 @@ export function PrsProvider({ children }: { children: React.ReactNode }) { // to avoid unnecessary re-render cascades in child components. setPrs((prev) => (jsonEqual(prev, prList) ? prev : prList)); setLanes((prev) => (jsonEqual(prev, laneList) ? prev : laneList)); + setRebaseNeeds((prev) => (jsonEqual(prev, refreshedRebaseNeeds) ? prev : refreshedRebaseNeeds)); + setAutoRebaseStatuses((prev) => (jsonEqual(prev, refreshedAutoRebaseStatuses) ? prev : refreshedAutoRebaseStatuses)); setQueueStates((prev) => { const next = Object.fromEntries(queueStateList.map((state) => [state.groupId, state] as const)); return jsonEqual(prev, next) ? prev : next; diff --git a/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.test.tsx b/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.test.tsx index 033349b7..ae0dbc13 100644 --- a/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.test.tsx +++ b/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.test.tsx @@ -98,9 +98,9 @@ describe("GitHubTab", () => { beforeEach(() => { mockUsePrs.mockReturnValue({ prs: [ - { id: "pr-open" }, - { id: "pr-merged" }, - { id: "pr-queue" }, + { id: "pr-open", checksStatus: "pending", reviewStatus: "requested", additions: 12, deletions: 3 }, + { id: "pr-merged", checksStatus: "passing", reviewStatus: "approved", additions: 5, deletions: 1 }, + { id: "pr-queue", checksStatus: "passing", reviewStatus: "approved", additions: 7, deletions: 2 }, ] satisfies Partial[], mergeContextByPrId: { "pr-queue": { groupType: "queue", groupId: "queue-group-1", members: [] }, @@ -180,4 +180,12 @@ describe("GitHubTab", () => { expect(screen.getByTestId("queue-context").textContent).toContain("queue-group-1"); }); }); + + it("shows a running CI indicator for PR cards with pending checks", async () => { + renderTab(); + + await waitFor(() => { + expect(screen.getAllByLabelText("CI running").length).toBeGreaterThan(0); + }); + }); }); diff --git a/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.tsx b/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.tsx index 8d18fa05..c4bcb46e 100644 --- a/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.tsx +++ b/apps/desktop/src/renderer/components/prs/tabs/GitHubTab.tsx @@ -6,6 +6,7 @@ import { EmptyState } from "../../ui/EmptyState"; import { COLORS, LABEL_STYLE, MONO_FONT, SANS_FONT, cardStyle, inlineBadge, outlineButton, primaryButton } from "../../lanes/laneDesignTokens"; import { PrDetailPane } from "../detail/PrDetailPane"; import { formatTimestampShort, formatTimeAgoCompact } from "../shared/prFormatters"; +import { PrCiRunningIndicator } from "../shared/prVisuals"; import { usePrs } from "../state/PrsContext"; type GitHubTabProps = { @@ -761,6 +762,7 @@ export function GitHubTab({ lanes, mergeMethod, selectedPrId, onSelectPr, onRefr const sc = stateColor(item.state); const linkedPr = item.linkedPrId ? prsByIdMap.get(item.linkedPrId) ?? null : null; const ci = ciDotColor(linkedPr); + const ciRunning = linkedPr?.checksStatus === "pending"; const review = reviewIndicator(linkedPr); const ago = formatTimeAgoCompact(item.updatedAt); return ( @@ -856,9 +858,10 @@ export function GitHubTab({ lanes, mergeMethod, selectedPrId, onSelectPr, onRefr )} {ci ? ( - + CI + {ciRunning ? : null} ) : null} {review ? ( diff --git a/apps/desktop/src/renderer/components/prs/tabs/NormalTab.tsx b/apps/desktop/src/renderer/components/prs/tabs/NormalTab.tsx index 395066cd..87937523 100644 --- a/apps/desktop/src/renderer/components/prs/tabs/NormalTab.tsx +++ b/apps/desktop/src/renderer/components/prs/tabs/NormalTab.tsx @@ -12,7 +12,7 @@ import { PrDetailPane } from "../detail/PrDetailPane"; import { usePrs } from "../state/PrsContext"; import { IntegrationPrContextPanel } from "../shared/IntegrationPrContextPanel"; import { COLORS, MONO_FONT, LABEL_STYLE, outlineButton } from "../../lanes/laneDesignTokens"; -import { getPrChecksBadge, getPrReviewsBadge, getPrStateBadge, InlinePrBadge } from "../shared/prVisuals"; +import { getPrChecksBadge, getPrReviewsBadge, getPrStateBadge, InlinePrBadge, PrCiRunningIndicator } from "../shared/prVisuals"; import { formatTimeAgoCompact } from "../shared/prFormatters"; function statusDotColor(state: string): string { @@ -259,7 +259,10 @@ export function NormalTab({ prs, lanes, mergeContextByPrId, mergeMethod, selecte
- + + + {pr.checksStatus === "pending" ? : null} + {/* Diff stats mini */} diff --git a/apps/desktop/src/renderer/components/prs/tabs/queueWorkflowModel.test.ts b/apps/desktop/src/renderer/components/prs/tabs/queueWorkflowModel.test.ts index f24305f8..d8610108 100644 --- a/apps/desktop/src/renderer/components/prs/tabs/queueWorkflowModel.test.ts +++ b/apps/desktop/src/renderer/components/prs/tabs/queueWorkflowModel.test.ts @@ -85,7 +85,7 @@ describe("queueWorkflowModel", () => { } satisfies PrStatus, memberSummary: null, }), - ).toEqual(["GitHub has not marked the current PR as mergeable yet."]); + ).toEqual(["GitHub has not marked the current PR as mergeable yet. Manual land can still succeed if GitHub allows a bypass merge."]); }); it("advises the operator to rebase the next lane after a successful land", () => { diff --git a/apps/desktop/src/renderer/components/prs/tabs/queueWorkflowModel.ts b/apps/desktop/src/renderer/components/prs/tabs/queueWorkflowModel.ts index 83401480..e47cd184 100644 --- a/apps/desktop/src/renderer/components/prs/tabs/queueWorkflowModel.ts +++ b/apps/desktop/src/renderer/components/prs/tabs/queueWorkflowModel.ts @@ -93,7 +93,7 @@ export function buildManualLandWarnings(args: { if (reviewStatus === "changes_requested") warnings.push("The current PR has requested changes."); if (args.status?.mergeConflicts) warnings.push("GitHub reports merge conflicts on the current PR."); if (args.status && !args.status.isMergeable && !args.status.mergeConflicts) { - warnings.push("GitHub has not marked the current PR as mergeable yet."); + warnings.push("GitHub has not marked the current PR as mergeable yet. Manual land can still succeed if GitHub allows a bypass merge."); } return warnings; diff --git a/apps/desktop/src/shared/types/prs.ts b/apps/desktop/src/shared/types/prs.ts index 6aa0fd7f..68a66efa 100644 --- a/apps/desktop/src/shared/types/prs.ts +++ b/apps/desktop/src/shared/types/prs.ts @@ -151,6 +151,11 @@ export type PrEventPayload = prId: string; prNumber: number; title: string; + prTitle: string; + repoOwner: string; + repoName: string; + baseBranch: string; + headBranch: string; githubUrl: string; message: string; state: PrState; From abe168a3c37647f86ae53e882846edd3ca2a16fa Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Tue, 24 Mar 2026 11:45:21 -0400 Subject: [PATCH 4/4] Improve lane rebase target, PR merge status, and cleanup --- .../services/conflicts/conflictService.ts | 6 ++++- .../main/services/lanes/laneService.test.ts | 6 +++++ .../src/main/services/prs/prPollingService.ts | 4 ++-- .../src/renderer/components/app/AppShell.tsx | 6 +++++ .../components/prs/detail/PrDetailPane.tsx | 23 ++++++++++++------- .../components/prs/state/PrsContext.tsx | 10 ++++---- 6 files changed, 39 insertions(+), 16 deletions(-) diff --git a/apps/desktop/src/main/services/conflicts/conflictService.ts b/apps/desktop/src/main/services/conflicts/conflictService.ts index 574f18ab..84f0be30 100644 --- a/apps/desktop/src/main/services/conflicts/conflictService.ts +++ b/apps/desktop/src/main/services/conflicts/conflictService.ts @@ -288,8 +288,12 @@ function resolveLaneRebaseTarget(args: { const parent = args.lane.parentLaneId ? args.lanesById.get(args.lane.parentLaneId) ?? null : null; const parentBranchRef = parent?.branchRef?.trim() ?? ""; if (parentBranchRef) { + // For primary lanes, prefer the remote tracking ref (origin/) to stay + // consistent with laneService.resolveParentRebaseTarget which rebases against + // the remote tracking ref rather than the local HEAD. + const comparisonRef = parent?.laneType === "primary" ? `origin/${parentBranchRef}` : parentBranchRef; return { - comparisonRef: parentBranchRef, + comparisonRef, displayBaseBranch: parentBranchRef, }; } diff --git a/apps/desktop/src/main/services/lanes/laneService.test.ts b/apps/desktop/src/main/services/lanes/laneService.test.ts index 1930da04..28b0e608 100644 --- a/apps/desktop/src/main/services/lanes/laneService.test.ts +++ b/apps/desktop/src/main/services/lanes/laneService.test.ts @@ -119,6 +119,9 @@ describe("laneService rebaseStart", () => { if (args[0] === "merge-base" && args[1] === "--is-ancestor") { return Promise.resolve({ exitCode: 1, stdout: "", stderr: "" }); } + if (args[0] === "status" && args[1] === "--porcelain=v1") { + return Promise.resolve({ exitCode: 0, stdout: "", stderr: "" }); + } if (args[0] === "rebase") { return new Promise((resolve) => { resolveRebase = resolve; @@ -184,6 +187,9 @@ describe("laneService rebaseStart", () => { expect(args[3]).toBe("sha-parent"); return { exitCode: 1, stdout: "", stderr: "" }; } + if (args[0] === "status" && args[1] === "--porcelain=v1") { + return { exitCode: 0, stdout: "", stderr: "" }; + } if (args[0] === "rebase") { expect(args[1]).toBe("sha-origin-main"); return { exitCode: 0, stdout: "", stderr: "" }; diff --git a/apps/desktop/src/main/services/prs/prPollingService.ts b/apps/desktop/src/main/services/prs/prPollingService.ts index d87a2c33..22221c40 100644 --- a/apps/desktop/src/main/services/prs/prPollingService.ts +++ b/apps/desktop/src/main/services/prs/prPollingService.ts @@ -37,8 +37,8 @@ function summarizeNotification(args: { kind: PrNotificationKind; pr: PrSummary } }; } return { - title: "Ready to merge", - message: "Required checks are passing and the pull request has approval.", + title: "Checks passing & approved", + message: "Required checks are passing and the pull request has approval. Other merge requirements (e.g. base branch currency) may still apply.", }; } diff --git a/apps/desktop/src/renderer/components/app/AppShell.tsx b/apps/desktop/src/renderer/components/app/AppShell.tsx index 987b0177..a8a1e810 100644 --- a/apps/desktop/src/renderer/components/app/AppShell.tsx +++ b/apps/desktop/src/renderer/components/app/AppShell.tsx @@ -737,6 +737,9 @@ export function AppShell({ children }: { children: React.ReactNode }) { setLaneInspectorTab(toast.event.laneId, "merge"); window.location.hash = `#/lanes?laneId=${encodeURIComponent(toast.event.laneId)}&focus=single&inspectorTab=merge`; setPrToasts((prev) => prev.filter((t) => t.id !== toast.id)); + const timer = toastTimersRef.current.get(toast.id); + if (timer != null) window.clearTimeout(timer); + toastTimersRef.current.delete(toast.id); }} > @@ -751,6 +754,9 @@ export function AppShell({ children }: { children: React.ReactNode }) { onClick={() => { void window.ade.prs.openInGitHub(toast.event.prId).catch(() => { }); setPrToasts((prev) => prev.filter((t) => t.id !== toast.id)); + const timer = toastTimersRef.current.get(toast.id); + if (timer != null) window.clearTimeout(timer); + toastTimersRef.current.delete(toast.id); }} > diff --git a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx index 0a21de98..aff63816 100644 --- a/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx +++ b/apps/desktop/src/renderer/components/prs/detail/PrDetailPane.tsx @@ -1092,6 +1092,11 @@ function OverviewTab(props: OverviewTabProps) { setLocalMergeMethod(mergeMethod); }, [mergeMethod]); + // Reset bypass opt-in when the selected PR changes + React.useEffect(() => { + setAllowBlockedMerge(false); + }, [pr.id]); + // Sort comments chronologically (oldest first, like GitHub) const sortedComments = React.useMemo( () => [...comments].sort((a, b) => { @@ -1413,20 +1418,22 @@ function OverviewTab(props: OverviewTabProps) { {/* Checks status */} - : checksRunning - ? - : someChecksFailing - ? - : + : someChecksFailing + ? + : checksRunning + ? + : checks.length === 0 + ? + : } title={ allChecksPassed ? "All checks have passed" - : checksRunning ? "Checks in progress" - : someChecksFailing ? "Some checks failing" + : someChecksFailing ? "Some checks failing" + : checksRunning ? "Checks in progress" : checks.length === 0 ? "No checks" : "Checks in progress" } titleAccessory={checksRunning ? : undefined} diff --git a/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx b/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx index 5fc44d6d..5f3d70fa 100644 --- a/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx +++ b/apps/desktop/src/renderer/components/prs/state/PrsContext.tsx @@ -197,9 +197,9 @@ export function PrsProvider({ children }: { children: React.ReactNode }) { const [rebaseNeeds, setRebaseNeeds] = useState([]); const [autoRebaseStatuses, setAutoRebaseStatuses] = useState([]); const rebaseNeedsRef = React.useRef([]); - rebaseNeedsRef.current = rebaseNeeds; const autoRebaseStatusesRef = React.useRef([]); - autoRebaseStatusesRef.current = autoRebaseStatuses; + React.useEffect(() => { rebaseNeedsRef.current = rebaseNeeds; }, [rebaseNeeds]); + React.useEffect(() => { autoRebaseStatusesRef.current = autoRebaseStatuses; }, [autoRebaseStatuses]); // Queue state const [queueStates, setQueueStates] = useState>({}); @@ -252,13 +252,13 @@ export function PrsProvider({ children }: { children: React.ReactNode }) { // Concurrency guard for refresh const refreshInFlight = React.useRef(false); const prsRef = React.useRef([]); - prsRef.current = prs; const mergeContextByPrIdRef = React.useRef>({}); - mergeContextByPrIdRef.current = mergeContextByPrId; + React.useEffect(() => { prsRef.current = prs; }, [prs]); + React.useEffect(() => { mergeContextByPrIdRef.current = mergeContextByPrId; }, [mergeContextByPrId]); // Refs for detail polling const selectedPrIdRef = React.useRef(null); - selectedPrIdRef.current = selectedPrId; + React.useEffect(() => { selectedPrIdRef.current = selectedPrId; }, [selectedPrId]); const detailFetchInProgress = React.useRef(false); const refreshMergeContexts = useCallback(async (prIds: string[]) => {