diff --git a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts index 7ab2270e6..dfe2d0483 100644 --- a/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts +++ b/apps/desktop/src/main/services/localRuntime/localRuntimeConnectionPool.test.ts @@ -305,7 +305,7 @@ describe("local runtime connection pool", () => { expect(fs.existsSync(tsxLoaderPath)).toBe(true); const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-")); - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-project-")); + const projectRoot = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-project-"))); const socketPath = path.join(adeHome, "sock", "ade.sock"); const originalEnv = { ADE_CLI_JS: process.env.ADE_CLI_JS, @@ -364,7 +364,7 @@ describe("local runtime connection pool", () => { expect(fs.existsSync(tsxLoaderPath)).toBe(true); const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-version-")); - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-version-project-")); + const projectRoot = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-version-project-"))); const socketPath = path.join(adeHome, "sock", "ade.sock"); const originalEnv = { ADE_CLI_JS: process.env.ADE_CLI_JS, @@ -450,7 +450,7 @@ describe("local runtime connection pool", () => { expect(fs.existsSync(tsxLoaderPath)).toBe(true); const adeHome = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-build-")); - const projectRoot = fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-build-project-")); + const projectRoot = fs.realpathSync.native(fs.mkdtempSync(path.join(os.tmpdir(), "ade-local-runtime-build-project-"))); const socketPath = path.join(adeHome, "sock", "ade.sock"); const originalEnv = { ADE_CLI_JS: process.env.ADE_CLI_JS, diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts b/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts index f5052b8ce..e4b36c523 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.test.ts @@ -1,12 +1,16 @@ import { describe, expect, it } from "vitest"; import { + githubPrMatchesCurrentBranch, lanePrMatchesCurrentBranch, resolveLaneDeleteStartSelection, resolveCreateLaneRequest, resolveLaneIdsDeepLinkSelection, + selectGithubLanePrTag, + selectLaneTabPrTag, selectLanePrTag, -} from "./LanesPage"; -import type { LaneSummary, PrSummary } from "../../../shared/types"; + sortLaneListRows, +} from "./lanePageModel"; +import type { GitHubPrListItem, LaneSummary, PrSummary } from "../../../shared/types"; type LanePrTarget = Pick; @@ -45,6 +49,36 @@ function makePr(overrides: Partial = {}): PrSummary { }; } +function makeGitHubPr(overrides: Partial = {}): GitHubPrListItem { + return { + id: "repo-pr-224", + scope: "repo", + repoOwner: "arul28", + repoName: "ADE", + githubPrNumber: 224, + githubUrl: "https://github.com/arul28/ADE/pull/224", + title: "Show merged PR state", + state: "open", + isDraft: false, + baseBranch: "main", + headBranch: "origin/ade/pr-state", + author: "arul", + createdAt: "2026-05-01T00:00:00.000Z", + updatedAt: "2026-05-01T00:00:00.000Z", + linkedPrId: null, + linkedGroupId: null, + linkedLaneId: null, + linkedLaneName: null, + adeKind: null, + workflowDisplayState: null, + cleanupState: null, + labels: [], + isBot: false, + commentCount: 0, + ...overrides, + }; +} + describe("resolveCreateLaneRequest", () => { it("creates an independent lane from the selected primary branch", () => { expect( @@ -235,3 +269,119 @@ describe("selectLanePrTag", () => { ).toBe(false); }); }); + +describe("selectLaneTabPrTag", () => { + it("falls back to repo GitHub PRs by lane branch when no ADE PR row is mapped", () => { + const githubPr = makeGitHubPr({ + state: "merged", + linkedPrId: null, + headBranch: "ade/pr-state", + }); + + expect(githubPrMatchesCurrentBranch(makeLane(), githubPr)).toBe(true); + expect(selectGithubLanePrTag(makeLane(), [githubPr])).toBe(githubPr); + expect(selectLaneTabPrTag(makeLane(), [], [githubPr])).toMatchObject({ + source: "github", + linkedPrId: null, + githubPrNumber: 224, + state: "merged", + }); + }); + + it("prefers an ADE-mapped PR over an unlinked GitHub branch match", () => { + const mappedPr = makePr({ id: "mapped-pr", state: "closed" }); + const githubPr = makeGitHubPr({ id: "github-pr", state: "open" }); + + expect(selectLaneTabPrTag(makeLane(), [mappedPr], [githubPr])).toMatchObject({ + source: "ade", + id: "mapped-pr", + linkedPrId: "mapped-pr", + state: "closed", + }); + }); + + it("labels GitHub-only draft PRs as draft lane tags", () => { + expect(selectLaneTabPrTag(makeLane(), [], [ + makeGitHubPr({ + state: "open", + isDraft: true, + }), + ])).toMatchObject({ + source: "github", + state: "draft", + }); + }); + + it("does not route a branch-matched GitHub PR through a stale linked lane", () => { + const githubPr = makeGitHubPr({ + linkedPrId: "other-pr", + linkedLaneId: "other-lane", + }); + + expect(selectLaneTabPrTag(makeLane(), [], [githubPr])).toMatchObject({ + source: "github", + linkedPrId: null, + githubUrl: "https://github.com/arul28/ADE/pull/224", + }); + }); + + it("does not match repo GitHub PRs for primary while primary is on its base branch", () => { + expect( + githubPrMatchesCurrentBranch( + makeLane({ + laneType: "primary", + branchRef: "main", + baseRef: "main", + }), + makeGitHubPr({ headBranch: "main" }), + ), + ).toBe(false); + }); +}); + +describe("sortLaneListRows", () => { + it("keeps primary first and promotes pinned lanes before runtime buckets", () => { + const lanes = [ + { id: "primary", laneType: "primary" }, + { id: "running", laneType: "worktree" }, + { id: "pinned-ended", laneType: "worktree" }, + { id: "idle", laneType: "worktree" }, + ] as const; + + const result = sortLaneListRows({ + lanes: [...lanes], + laneRuntimeById: new Map([ + ["running", { bucket: "running" }], + ["pinned-ended", { bucket: "ended" }], + ["idle", { bucket: "none" }], + ]), + laneStatusFilter: "all", + laneOrderById: new Map(lanes.map((lane, index) => [lane.id, index])), + pinnedLaneIds: new Set(["pinned-ended"]), + }); + + expect(result.map((lane) => lane.id)).toEqual(["primary", "pinned-ended", "running", "idle"]); + }); + + it("keeps pinned ordering after applying a status filter", () => { + const lanes = [ + { id: "running-a", laneType: "worktree" }, + { id: "running-pinned", laneType: "worktree" }, + { id: "ended", laneType: "worktree" }, + ] as const; + + const result = sortLaneListRows({ + lanes: [...lanes], + laneRuntimeById: new Map([ + ["running-a", { bucket: "running" }], + ["running-pinned", { bucket: "running" }], + ["ended", { bucket: "ended" }], + ]), + laneStatusFilter: "running", + laneOrderById: new Map(lanes.map((lane, index) => [lane.id, index])), + pinnedLaneIds: new Set(["running-pinned"]), + }); + + expect(result.map((lane) => lane.id)).toEqual(["running-pinned", "running-a"]); + }); +}); diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index 021292237..65c62ed81 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -26,6 +26,15 @@ import { LinearIssueBadge } from "./LinearIssueBadge"; import { HelpChip } from "../onboarding/HelpChip"; import { useOnboardingStore } from "../../state/onboardingStore"; import { useDialogBus } from "../../lib/useDialogBus"; +import { + parseLaneIdsParam, + resolveCreateLaneRequest, + resolveLaneDeleteStartSelection, + resolveLaneIdsDeepLinkSelection, + selectLaneTabPrTag, + sortLaneListRows, + type LaneTabPrTag, +} from "./lanePageModel"; import { sortLanesForTabs, sortLanesForStackGraph, @@ -50,13 +59,13 @@ import { buildPrsRouteSearch } from "../prs/prsRouteState"; import { formatPrBadgeLabel } from "../prs/shared/prFormatters"; import { getProjectConfigCached } from "../../lib/projectConfigCache"; import { logRendererDebugEvent } from "../../lib/debugLog"; -import { branchNameFromLaneRef } from "../../../shared/laneBaseResolution"; import { linearIssueBranchName, linearIssueLaneName } from "../../../shared/linearIssueBranch"; import type { BranchPullRequest, ConflictChip, DeleteLaneArgs, GitCommitSummary, + GitHubPrListItem, LaneEnvInitEvent, LaneEnvInitProgress, LaneBranchActiveWorkItem, @@ -98,11 +107,6 @@ function getDevicePresenceTitle(devicesOpen: LaneSummary["devicesOpen"]): string return `Open on ${names.length} devices: ${names.join(", ")}`; } -type CreateLaneRequest = - | { kind: "child"; args: { name: string; parentLaneId: string } } - | { kind: "root"; args: { name: string; baseBranch: string } } - | { kind: "import"; args: { branchRef: string; name: string; baseBranch?: string } }; - function DeferredLanePane({ cacheKey, label, @@ -134,70 +138,6 @@ function DeferredLanePane({ ); } -export function resolveCreateLaneRequest(args: { - name: string; - createMode: CreateLaneMode; - createParentLaneId: string; - createBaseBranch: string; - createImportBranch: string; -}): CreateLaneRequest { - if (args.createMode === "child") { - return { - kind: "child", - args: { - name: args.name, - parentLaneId: args.createParentLaneId, - }, - }; - } - - if (args.createMode === "existing") { - return { - kind: "import", - args: { - branchRef: args.createImportBranch, - name: args.name, - }, - }; - } - - return { - kind: "root", - args: { - name: args.name, - baseBranch: args.createBaseBranch, - }, - }; -} - -function parseLaneIdsParam(value: string | null): string[] { - return (value ?? "") - .split(",") - .map((entry) => entry.trim()) - .filter(Boolean); -} - -function normalizeLanePrBranch(ref: string | null | undefined): string { - return branchNameFromLaneRef(ref).trim(); -} - -function prStateRank(state: PrSummary["state"]): number { - if (state === "open" || state === "draft") return 0; - if (state === "merged") return 1; - return 2; -} - -function compareLanePrTags(a: PrSummary, b: PrSummary): number { - const byState = prStateRank(a.state) - prStateRank(b.state); - if (byState !== 0) return byState; - const aUpdated = Date.parse(a.updatedAt); - const bUpdated = Date.parse(b.updatedAt); - if (!Number.isNaN(aUpdated) && !Number.isNaN(bUpdated) && aUpdated !== bUpdated) { - return bUpdated - aUpdated; - } - return b.githubPrNumber - a.githubPrNumber; -} - function lanePrTagColor(state: PrSummary["state"]): string { if (state === "merged") return COLORS.success; if (state === "closed") return COLORS.danger; @@ -205,41 +145,13 @@ function lanePrTagColor(state: PrSummary["state"]): string { return COLORS.accent; } -export function lanePrMatchesCurrentBranch( - lane: Pick, - pr: Pick, -): boolean { - if (pr.laneId !== lane.id) return false; - const laneBranch = normalizeLanePrBranch(lane.branchRef); - const prHeadBranch = normalizeLanePrBranch(pr.headBranch); - if (!laneBranch || !prHeadBranch || laneBranch !== prHeadBranch) return false; - if (lane.laneType === "primary") { - const baseBranch = normalizeLanePrBranch(lane.baseRef); - if (laneBranch && baseBranch && laneBranch === baseBranch) return false; +function isTrustedGitHubUrl(rawUrl: string): boolean { + try { + const url = new URL(rawUrl); + return url.protocol === "https:" && url.hostname === "github.com"; + } catch { + return false; } - return true; -} - -export function selectLanePrTag(lane: Pick, prs: PrSummary[]): PrSummary | null { - return prs - .filter((pr) => lanePrMatchesCurrentBranch(lane, pr)) - .sort(compareLanePrTags)[0] ?? null; -} - -export function resolveLaneIdsDeepLinkSelection(args: { - laneIdsRaw: string | null; - inspectorTabParam?: string | null; - availableLaneIds: Iterable; - consumedSignature: string | null; -}): { laneIds: string[]; signature: string } | null { - const parsed = parseLaneIdsParam(args.laneIdsRaw); - if (parsed.length === 0) return null; - const signature = `${parsed.join(",")}::${args.inspectorTabParam ?? ""}`; - if (signature === args.consumedSignature) return null; - const available = new Set(args.availableLaneIds); - const laneIds = parsed.filter((laneId) => available.has(laneId)); - if (laneIds.length !== parsed.length) return null; - return { laneIds, signature }; } export function isLaneDeleteProgressActive(progress: LaneDeleteProgress | null | undefined): boolean { @@ -260,35 +172,6 @@ function getLaneDeleteStatusLabel(progress: LaneDeleteProgress | null | undefine return progress?.overallStatus === "completed" ? "Deleted" : "Deleting"; } -export function resolveLaneDeleteStartSelection(args: { - deletingLaneIds: Iterable; - selectedLaneId: string | null; - activeLaneIds: string[]; - pinnedLaneIds: Iterable; - filteredLaneIds: string[]; - sortedLaneIds: string[]; -}): { selectedLaneId: string | null; activeLaneIds: string[]; pinnedLaneIds: Set } { - const deleting = new Set(args.deletingLaneIds); - const isAvailable = (laneId: string | null | undefined): laneId is string => - Boolean(laneId && !deleting.has(laneId)); - const pinnedLaneIds = new Set(Array.from(args.pinnedLaneIds).filter((laneId) => !deleting.has(laneId))); - const nextSelectedLaneId = isAvailable(args.selectedLaneId) - ? args.selectedLaneId - : args.filteredLaneIds.find((laneId) => !deleting.has(laneId)) - ?? args.sortedLaneIds.find((laneId) => !deleting.has(laneId)) - ?? null; - const preservedActiveLaneIds = args.activeLaneIds.filter((laneId) => !deleting.has(laneId) && laneId !== nextSelectedLaneId); - return { - selectedLaneId: nextSelectedLaneId, - activeLaneIds: mergeUnique( - nextSelectedLaneId ? [nextSelectedLaneId] : [], - preservedActiveLaneIds, - Array.from(pinnedLaneIds), - ), - pinnedLaneIds, - }; -} - function laneTilingLayoutIds(laneId: string): string[] { return [ `lanes:tiling:${LANES_TILING_LAYOUT_VERSION}:${laneId}`, @@ -394,6 +277,7 @@ export function LanesPage() { const [conflictChipsByLane, setConflictChipsByLane] = useState>({}); const chipTimersRef = useRef>(new Map()); const lanePrTagsRequestRef = useRef(0); + const laneGithubPrTagsRequestRef = useRef(0); const hasActiveLaneRuntimeRef = useRef(false); const [autoRebaseEnabled, setAutoRebaseEnabled] = useState(false); const [rebaseSuggestionError, setRebaseSuggestionError] = useState(null); @@ -445,6 +329,7 @@ export function LanesPage() { const [expandedGitActionsLaneId, setExpandedGitActionsLaneId] = useState(null); const [integrationProposals, setIntegrationProposals] = useState([]); const [lanePrTags, setLanePrTags] = useState([]); + const [laneGithubPrTags, setLaneGithubPrTags] = useState([]); const [linearIssueChatContextRequest, setLinearIssueChatContextRequest] = useState<{ laneId: string; issue: LaneLinearIssue; @@ -503,13 +388,13 @@ export function LanesPage() { [integrationProposals, lanesById], ); const lanePrByLaneId = useMemo(() => { - const map = new Map(); + const map = new Map(); for (const lane of sortedLanes) { - const pr = selectLanePrTag(lane, lanePrTags); + const pr = selectLaneTabPrTag(lane, lanePrTags, laneGithubPrTags); if (pr) map.set(lane.id, pr); } return map; - }, [sortedLanes, lanePrTags]); + }, [sortedLanes, lanePrTags, laneGithubPrTags]); const laneRuntimeById = useMemo(() => { const summaryByLane = new Map(); @@ -567,27 +452,14 @@ export function LanesPage() { }, []); const filteredLanes = useMemo(() => { - const bucketRank: Record = { - "awaiting-input": 0, - running: 1, - ended: 2, - none: 3, - }; - const base = [...laneFilterMatchedLanes]; - if (laneStatusFilter !== "all") { - return base.filter((lane) => (laneRuntimeById.get(lane.id)?.bucket ?? "none") === laneStatusFilter); - } - return base.sort((a, b) => { - const aPrimary = a.laneType === "primary" ? 0 : 1; - const bPrimary = b.laneType === "primary" ? 0 : 1; - if (aPrimary !== bPrimary) return aPrimary - bPrimary; - const aBucket = laneRuntimeById.get(a.id)?.bucket ?? "none"; - const bBucket = laneRuntimeById.get(b.id)?.bucket ?? "none"; - const byBucket = bucketRank[aBucket] - bucketRank[bBucket]; - if (byBucket !== 0) return byBucket; - return (laneOrderById.get(a.id) ?? 0) - (laneOrderById.get(b.id) ?? 0); + return sortLaneListRows({ + lanes: laneFilterMatchedLanes, + laneRuntimeById, + laneStatusFilter, + laneOrderById, + pinnedLaneIds, }); - }, [laneFilterMatchedLanes, laneRuntimeById, laneStatusFilter, laneOrderById]); + }, [laneFilterMatchedLanes, laneRuntimeById, laneStatusFilter, laneOrderById, pinnedLaneIds]); const stackGraphLanes = useMemo(() => sortLanesForStackGraph(filteredLanes), [filteredLanes]); const filteredLaneIds = useMemo(() => filteredLanes.map((lane) => lane.id), [filteredLanes]); @@ -777,6 +649,21 @@ export function LanesPage() { } }, []); + const refreshLaneGithubPrTags = useCallback(async (options?: { force?: boolean }) => { + const requestId = ++laneGithubPrTagsRequestRef.current; + const startedRoot = useAppStore.getState().project?.rootPath ?? null; + try { + const snapshot = await window.ade.prs.getGitHubSnapshot({ force: options?.force === true }); + if (requestId !== laneGithubPrTagsRequestRef.current) return; + if ((useAppStore.getState().project?.rootPath ?? null) !== startedRoot) return; + setLaneGithubPrTags(snapshot.repoPullRequests); + } catch { + if (requestId !== laneGithubPrTagsRequestRef.current) return; + if ((useAppStore.getState().project?.rootPath ?? null) !== startedRoot) return; + // Keep the last usable GitHub snapshot visible on transient refresh failures. + } + }, []); + const pushConflictChips = useCallback((chips: ConflictChip[]) => { if (chips.length === 0) return; setConflictChipsByLane((prev) => { @@ -949,29 +836,37 @@ export function LanesPage() { }, [refreshIntegrationProposals, project?.rootPath]); useEffect(() => { + lanePrTagsRequestRef.current += 1; + laneGithubPrTagsRequestRef.current += 1; + setLanePrTags([]); + setLaneGithubPrTags([]); if (!project?.rootPath) { - lanePrTagsRequestRef.current += 1; - setLanePrTags([]); return; } const timer = window.setTimeout(() => { void refreshLanePrTags(); + void refreshLaneGithubPrTags(); }, 160); return () => { lanePrTagsRequestRef.current += 1; + laneGithubPrTagsRequestRef.current += 1; window.clearTimeout(timer); }; - }, [refreshLanePrTags, project?.rootPath]); + }, [refreshLanePrTags, refreshLaneGithubPrTags, project?.rootPath]); useEffect(() => { return window.ade.prs.onEvent((event) => { if (event.type === "prs-updated") { + lanePrTagsRequestRef.current += 1; setLanePrTags(event.prs); + // This event already carries ADE rows; use the cached repo snapshot unless a PR notification asks for a forced refresh. + void refreshLaneGithubPrTags(); } else if (event.type === "pr-notification") { void refreshLanePrTags(); + void refreshLaneGithubPrTags({ force: true }); } }); - }, [refreshLanePrTags]); + }, [refreshLanePrTags, refreshLaneGithubPrTags]); useEffect(() => { let timer: ReturnType | null = null; @@ -2964,6 +2859,7 @@ export function LanesPage() { const lanePr = lanePrByLaneId.get(lane.id) ?? null; const deleteProgress = deleteProgressByLaneId[lane.id] ?? null; const isDeleting = isLaneDeleteProgressActive(deleteProgress); + const showMergedManageShortcut = !isDeleting && !isPrimary && lanePr?.state === "merged"; return (
- {/* Tab number */} - {tabNumber} + {/* Tab number / merged-PR manage shortcut */} + + + {tabNumber} + + {showMergedManageShortcut ? ( + + ) : null} + {/* Primary: house icon; Non-primary: conflict status dot */} {isPrimary ? ( @@ -3079,12 +3009,18 @@ export function LanesPage() { title={`${formatPrBadgeLabel(lanePr)}: ${lanePr.title}`} onClick={(event) => { event.stopPropagation(); - navigate(`/prs${buildPrsRouteSearch({ - activeTab: "normal", - selectedPrId: lanePr.id, - selectedQueueGroupId: null, - selectedRebaseItemId: null, - })}`); + if (lanePr.linkedPrId) { + navigate(`/prs${buildPrsRouteSearch({ + activeTab: "normal", + selectedPrId: lanePr.linkedPrId, + selectedQueueGroupId: null, + selectedRebaseItemId: null, + })}`); + return; + } + if (lanePr.githubUrl && isTrustedGitHubUrl(lanePr.githubUrl)) { + void window.ade?.app?.openExternal?.(lanePr.githubUrl); + } }} onMouseDown={(event) => event.stopPropagation()} > @@ -3123,14 +3059,6 @@ export function LanesPage() { ↑{rebaseSuggestion.behindCount} ) : null} - {/* Pinned badge */} - {!isDeleting && !isPrimary && isPinned ? ( - PINNED - ) : null} {/* Auto-rebase status badges */} {!isDeleting && autoRebaseStatus?.state === "autoRebased" ? ( @@ -3170,13 +3098,13 @@ export function LanesPage() { {!isDeleting && !isPrimary ? ( ) : null} {/* Close from split — appears on hover */} diff --git a/apps/desktop/src/renderer/components/lanes/LinearIssueBadge.tsx b/apps/desktop/src/renderer/components/lanes/LinearIssueBadge.tsx index 25283c90b..195114d81 100644 --- a/apps/desktop/src/renderer/components/lanes/LinearIssueBadge.tsx +++ b/apps/desktop/src/renderer/components/lanes/LinearIssueBadge.tsx @@ -110,14 +110,17 @@ export function LinearIssueBadge({ {!compact ? issue.identifier : null} + {/* Linear-branded header strip */} ) : null} + ); diff --git a/apps/desktop/src/renderer/components/lanes/lanePageModel.ts b/apps/desktop/src/renderer/components/lanes/lanePageModel.ts new file mode 100644 index 000000000..58d318886 --- /dev/null +++ b/apps/desktop/src/renderer/components/lanes/lanePageModel.ts @@ -0,0 +1,251 @@ +import { branchNameFromLaneRef } from "../../../shared/laneBaseResolution"; +import type { GitHubPrListItem, LaneListSnapshot, LaneSummary, PrSummary } from "../../../shared/types"; +import type { CreateLaneMode } from "./CreateLaneDialog"; +import { mergeUnique } from "./laneUtils"; + +type CreateLaneRequest = + | { kind: "child"; args: { name: string; parentLaneId: string } } + | { kind: "root"; args: { name: string; baseBranch: string } } + | { kind: "import"; args: { branchRef: string; name: string; baseBranch?: string } }; + +export type LaneTabPrTag = { + source: "ade" | "github"; + id: string; + linkedPrId: string | null; + githubPrNumber: number; + githubUrl: string; + title: string; + state: PrSummary["state"]; +}; + +export function resolveCreateLaneRequest(args: { + name: string; + createMode: CreateLaneMode; + createParentLaneId: string; + createBaseBranch: string; + createImportBranch: string; +}): CreateLaneRequest { + if (args.createMode === "child") { + return { + kind: "child", + args: { + name: args.name, + parentLaneId: args.createParentLaneId, + }, + }; + } + + if (args.createMode === "existing") { + return { + kind: "import", + args: { + branchRef: args.createImportBranch, + name: args.name, + }, + }; + } + + return { + kind: "root", + args: { + name: args.name, + baseBranch: args.createBaseBranch, + }, + }; +} + +export function parseLaneIdsParam(value: string | null): string[] { + return (value ?? "") + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); +} + +export function resolveLaneIdsDeepLinkSelection(args: { + laneIdsRaw: string | null; + inspectorTabParam?: string | null; + availableLaneIds: Iterable; + consumedSignature: string | null; +}): { laneIds: string[]; signature: string } | null { + const parsed = parseLaneIdsParam(args.laneIdsRaw); + if (parsed.length === 0) return null; + const signature = `${parsed.join(",")}::${args.inspectorTabParam ?? ""}`; + if (signature === args.consumedSignature) return null; + const available = new Set(args.availableLaneIds); + const laneIds = parsed.filter((laneId) => available.has(laneId)); + if (laneIds.length !== parsed.length) return null; + return { laneIds, signature }; +} + +function normalizeLanePrBranch(ref: string | null | undefined): string { + return branchNameFromLaneRef(ref).trim(); +} + +function prStateRank(state: PrSummary["state"]): number { + if (state === "open" || state === "draft") return 0; + if (state === "merged") return 1; + return 2; +} + +type PrTagComparable = { + state: PrSummary["state"]; + updatedAt: string; + githubPrNumber: number; +}; + +function comparePrTags(a: PrTagComparable, b: PrTagComparable): number { + const byState = prStateRank(a.state) - prStateRank(b.state); + if (byState !== 0) return byState; + const aUpdated = Date.parse(a.updatedAt); + const bUpdated = Date.parse(b.updatedAt); + if (!Number.isNaN(aUpdated) && !Number.isNaN(bUpdated) && aUpdated !== bUpdated) { + return bUpdated - aUpdated; + } + return b.githubPrNumber - a.githubPrNumber; +} + +export function lanePrMatchesCurrentBranch( + lane: Pick, + pr: Pick, +): boolean { + if (pr.laneId !== lane.id) return false; + const laneBranch = normalizeLanePrBranch(lane.branchRef); + const prHeadBranch = normalizeLanePrBranch(pr.headBranch); + if (!laneBranch || !prHeadBranch || laneBranch !== prHeadBranch) return false; + if (lane.laneType === "primary") { + const baseBranch = normalizeLanePrBranch(lane.baseRef); + if (laneBranch && baseBranch && laneBranch === baseBranch) return false; + } + return true; +} + +export function selectLanePrTag( + lane: Pick, + prs: PrSummary[], +): PrSummary | null { + return prs + .filter((pr) => lanePrMatchesCurrentBranch(lane, pr)) + .sort(comparePrTags)[0] ?? null; +} + +export function githubPrMatchesCurrentBranch( + lane: Pick, + pr: Pick, +): boolean { + const laneBranch = normalizeLanePrBranch(lane.branchRef); + const prHeadBranch = normalizeLanePrBranch(pr.headBranch); + if (!laneBranch || !prHeadBranch || laneBranch !== prHeadBranch) return false; + if (lane.laneType === "primary") { + const baseBranch = normalizeLanePrBranch(lane.baseRef); + if (laneBranch && baseBranch && laneBranch === baseBranch) return false; + } + return true; +} + +export function selectGithubLanePrTag( + lane: Pick, + prs: GitHubPrListItem[], +): GitHubPrListItem | null { + return prs + .filter((pr) => pr.scope === "repo" && githubPrMatchesCurrentBranch(lane, pr)) + .sort(comparePrTags)[0] ?? null; +} + +function toLaneTabPrTagFromPrSummary(pr: PrSummary): LaneTabPrTag { + return { + source: "ade", + id: pr.id, + linkedPrId: pr.id, + githubPrNumber: pr.githubPrNumber, + githubUrl: pr.githubUrl, + title: pr.title, + state: pr.state, + }; +} + +function toLaneTabPrTagFromGithubItem(pr: GitHubPrListItem, laneId: string): LaneTabPrTag { + const linkedPrId = pr.linkedLaneId === laneId ? pr.linkedPrId : null; + return { + source: "github", + id: pr.id, + linkedPrId, + githubPrNumber: pr.githubPrNumber, + githubUrl: pr.githubUrl, + title: pr.title, + state: pr.isDraft ? "draft" : pr.state, + }; +} + +export function selectLaneTabPrTag( + lane: Pick, + prs: PrSummary[], + githubPrs: GitHubPrListItem[], +): LaneTabPrTag | null { + const mappedPr = selectLanePrTag(lane, prs); + if (mappedPr) return toLaneTabPrTagFromPrSummary(mappedPr); + const githubPr = selectGithubLanePrTag(lane, githubPrs); + return githubPr ? toLaneTabPrTagFromGithubItem(githubPr, lane.id) : null; +} + +type LaneRuntimeBucket = LaneListSnapshot["runtime"]["bucket"]; + +export function sortLaneListRows>(args: { + lanes: T[]; + laneRuntimeById: ReadonlyMap>; + laneStatusFilter: LaneRuntimeBucket | "all"; + laneOrderById: ReadonlyMap; + pinnedLaneIds: ReadonlySet; +}): T[] { + const bucketRank: Record = { + "awaiting-input": 0, + running: 1, + ended: 2, + none: 3, + }; + const base = + args.laneStatusFilter === "all" + ? [...args.lanes] + : args.lanes.filter((lane) => (args.laneRuntimeById.get(lane.id)?.bucket ?? "none") === args.laneStatusFilter); + return base.sort((a, b) => { + const aPrimary = a.laneType === "primary" ? 0 : 1; + const bPrimary = b.laneType === "primary" ? 0 : 1; + if (aPrimary !== bPrimary) return aPrimary - bPrimary; + const aPinned = args.pinnedLaneIds.has(a.id) ? 0 : 1; + const bPinned = args.pinnedLaneIds.has(b.id) ? 0 : 1; + if (aPinned !== bPinned) return aPinned - bPinned; + const aBucket = args.laneRuntimeById.get(a.id)?.bucket ?? "none"; + const bBucket = args.laneRuntimeById.get(b.id)?.bucket ?? "none"; + const byBucket = bucketRank[aBucket] - bucketRank[bBucket]; + if (byBucket !== 0) return byBucket; + return (args.laneOrderById.get(a.id) ?? 0) - (args.laneOrderById.get(b.id) ?? 0); + }); +} + +export function resolveLaneDeleteStartSelection(args: { + deletingLaneIds: Iterable; + selectedLaneId: string | null; + activeLaneIds: string[]; + pinnedLaneIds: Iterable; + filteredLaneIds: string[]; + sortedLaneIds: string[]; +}): { selectedLaneId: string | null; activeLaneIds: string[]; pinnedLaneIds: Set } { + const deleting = new Set(args.deletingLaneIds); + const isAvailable = (laneId: string | null | undefined): laneId is string => + Boolean(laneId && !deleting.has(laneId)); + const pinnedLaneIds = new Set(Array.from(args.pinnedLaneIds).filter((laneId) => !deleting.has(laneId))); + const nextSelectedLaneId = isAvailable(args.selectedLaneId) + ? args.selectedLaneId + : args.filteredLaneIds.find((laneId) => !deleting.has(laneId)) + ?? args.sortedLaneIds.find((laneId) => !deleting.has(laneId)) + ?? null; + const preservedActiveLaneIds = args.activeLaneIds.filter((laneId) => !deleting.has(laneId) && laneId !== nextSelectedLaneId); + return { + selectedLaneId: nextSelectedLaneId, + activeLaneIds: mergeUnique( + nextSelectedLaneId ? [nextSelectedLaneId] : [], + preservedActiveLaneIds, + Array.from(pinnedLaneIds), + ), + pinnedLaneIds, + }; +} diff --git a/docs/features/lanes/README.md b/docs/features/lanes/README.md index 2b0842215..de91b030c 100644 --- a/docs/features/lanes/README.md +++ b/docs/features/lanes/README.md @@ -67,7 +67,8 @@ Renderer components: | File | Responsibility | |------|---------------| -| `renderer/components/lanes/LanesPage.tsx` | 3-pane cockpit, tab management, dialog coordination. Each lane row in the lane list optionally renders a state-aware PR tag (`PR #N` / `DRAFT #N` / `MERGED #N` / `CLOSED #N`) when the lane's current branch matches an existing PR; the row uses `selectLanePrTag` (open/draft → merged → closed, then most recent) and falls back through the same branch-equality rules as `prService.getDisplayRowForCurrentLaneBranch`, so the badge stays attached to the lane even after the PR merges. Lane delete kicks off optimistically: the page subscribes to `lanes.delete.event`, tracks per-lane `LaneDeleteProgress` in `deleteProgressByLaneId`, immediately closes the manage dialog, and excludes deleting lanes from the selectable lane id sets used by keyboard navigation (`selectableFilteredLaneIds`, `sortedSelectableLaneIds`). Lane tabs for deleting lanes render a non-interactive overlay with a spinning `CircleNotch` and a `Deleting` / `Deleted` label; selection / pinning / context menu / split / git-actions surfaces are all suppressed for those rows. `resolveLaneDeleteStartSelection` (also used by tests) computes a fallback selection so the user is moved to the next available lane the moment delete starts, and a top-bar "Lane action failed" chip surfaces any failure or cancellation through `laneActionError`. | +| `renderer/components/lanes/LanesPage.tsx` | 3-pane cockpit, tab management, dialog coordination. Each lane row in the lane list optionally renders a state-aware PR tag (`PR #N` / `DRAFT #N` / `MERGED #N` / `CLOSED #N`) when the lane's current branch matches an existing PR. The pure selectors in `lanePageModel.ts` prefer ADE-linked PR rows, then fall back to `prs.getGitHubSnapshot().repoPullRequests` so merged or externally created PRs stay visible by branch match; linked PRs route to the PR workspace, while unlinked GitHub-only matches open externally. Lane delete kicks off optimistically: the page subscribes to `lanes.delete.event`, tracks per-lane `LaneDeleteProgress` in `deleteProgressByLaneId`, immediately closes the manage dialog, and excludes deleting lanes from the selectable lane id sets used by keyboard navigation (`selectableFilteredLaneIds`, `sortedSelectableLaneIds`). Lane tabs for deleting lanes render a non-interactive overlay with a spinning `CircleNotch` and a `Deleting` / `Deleted` label; selection / pinning / context menu / split / git-actions surfaces are all suppressed for those rows. `resolveLaneDeleteStartSelection` (also used by tests) computes a fallback selection so the user is moved to the next available lane the moment delete starts, and a top-bar "Lane action failed" chip surfaces any failure or cancellation through `laneActionError`. | +| `renderer/components/lanes/lanePageModel.ts` | Pure lane-page selectors and URL/deletion helpers used by `LanesPage` and unit tests. Owns lane branch/PR matching, ADE-vs-GitHub PR tag precedence, deep-link lane selection, create-lane request normalization, and delete-start selection fallback. | | `renderer/components/lanes/laneUtils.ts` | Pure lane list/filter helpers plus default pane trees, including the work-focused tiling tree used by parallel chat launch deep links. | | `renderer/components/lanes/laneColorPalette.ts` | Curated 12-swatch lane color palette (`LANE_COLOR_PALETTE`) plus helpers (`getLaneAccent`, `colorsInUse`, `nextAvailableColor`, `laneColorName`). The first 8 hexes form `LANE_FALLBACK_COLORS`, the legacy index-based fallback used for lanes that don't have an explicit color assigned. | | `renderer/components/lanes/LaneAccentDot.tsx` | Tiny accent dot used everywhere a lane is mentioned (lane list, tabs, PR rows, AppShell PR toasts). Resolves color via `getLaneAccent` so a lane without an explicit color falls back to a deterministic fallback hex. |