From b13e1887d7ca66c1b36d65468c7cc28690d09ff0 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Thu, 6 Aug 2026 05:32:49 -0400 Subject: [PATCH 1/2] feat(prs): support multi-PR lane links --- .../services/sync/syncRemoteCommandService.ts | 4 + .../main/services/lanes/laneService.test.ts | 14 +- .../src/main/services/lanes/laneService.ts | 58 +---- .../src/main/services/prs/prAsync.test.ts | 36 +++ .../src/main/services/prs/prChatCards.test.ts | 47 ++++ .../src/main/services/prs/prChatCards.ts | 42 +++- .../prs/prMergeAutoSettlementService.ts | 5 +- .../src/main/services/prs/prService.test.ts | 49 +++- .../src/main/services/prs/prService.ts | 156 ++++++++++++- .../services/prs/pullRequestRowCleanup.ts | 21 ++ apps/desktop/src/main/services/state/kvDb.ts | 25 +++ .../sync/syncRemoteCommandService.test.ts | 2 + .../components/chat/AgentChatPane.tsx | 2 + .../components/chat/ChatGitToolbar.test.tsx | 13 ++ .../components/chat/ChatGitToolbar.tsx | 210 +++++++++++++----- .../chat/ChatPrInlineCreator.test.tsx | 3 +- .../components/chat/ChatPrInlineCreator.tsx | 6 +- .../renderer/components/chat/ChatPrPane.tsx | 27 ++- .../components/lanes/LanePrBadgePopover.tsx | 105 ++++++++- .../components/lanes/LaneWorkPane.tsx | 59 ++++- .../components/lanes/LanesPage.test.ts | 26 ++- .../renderer/components/lanes/LanesPage.tsx | 49 ++-- .../components/lanes/lanePageModel.ts | 123 +++++++--- .../terminals/CliSessionWorkSurfaceHeader.tsx | 3 +- .../components/terminals/LanePrBadge.test.tsx | 68 ++++++ .../components/terminals/LanePrBadge.tsx | 202 ++++++++++++++--- .../components/terminals/SessionCard.tsx | 14 +- .../components/terminals/SessionListPane.tsx | 45 +++- .../components/terminals/useLanePrs.test.ts | 20 +- .../components/terminals/useLanePrs.ts | 33 +-- .../components/work/WorkSurfaceHeader.tsx | 4 + .../src/renderer/lib/lanePrBadge.test.ts | 74 +++++- apps/desktop/src/renderer/lib/lanePrBadge.ts | 84 ++++++- apps/desktop/src/renderer/lib/prChatScope.ts | 17 ++ apps/desktop/src/shared/types/prs.ts | 6 + apps/ios/ADE/Resources/DatabaseBootstrap.sql | 17 ++ apps/ios/ADE/Services/Database.swift | 33 +++ apps/ios/ADE/Services/SyncService.swift | 1 + docs/features/lanes/README.md | 6 +- docs/features/pull-requests/README.md | 90 +++++--- docs/features/sync-and-multi-device/README.md | 11 +- .../sync-and-multi-device/ios-companion.md | 8 +- 42 files changed, 1523 insertions(+), 295 deletions(-) create mode 100644 apps/desktop/src/renderer/components/terminals/LanePrBadge.test.tsx create mode 100644 apps/desktop/src/renderer/lib/prChatScope.ts diff --git a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts index 826eae885..6d2aef927 100644 --- a/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts +++ b/apps/ade-cli/src/services/sync/syncRemoteCommandService.ts @@ -3074,8 +3074,10 @@ function parseCreatePrArgs(value: Record): CreatePrFromLaneArgs if (!laneId || !title) throw new Error("prs.createFromLane requires laneId and title."); const strategy: CreatePrFromLaneArgs["strategy"] = normalizePrCreationStrategy(asTrimmedString(value.strategy)) ?? undefined; + const sessionId = asTrimmedString(value.sessionId); return { laneId, + ...(sessionId ? { sessionId } : {}), title, body, draft: value.draft === true, @@ -3089,9 +3091,11 @@ function parseCreatePrArgs(value: Record): CreatePrFromLaneArgs } function parseLinkPrToLaneArgs(value: Record): LinkPrToLaneArgs { + const sessionId = asTrimmedString(value.sessionId); return { laneId: requireString(value.laneId, "prs.linkToLane requires laneId."), prUrlOrNumber: requireString(value.prUrlOrNumber, "prs.linkToLane requires prUrlOrNumber."), + ...(sessionId ? { sessionId } : {}), }; } diff --git a/apps/desktop/src/main/services/lanes/laneService.test.ts b/apps/desktop/src/main/services/lanes/laneService.test.ts index 082311ef2..e2ca65999 100644 --- a/apps/desktop/src/main/services/lanes/laneService.test.ts +++ b/apps/desktop/src/main/services/lanes/laneService.test.ts @@ -5749,7 +5749,7 @@ describe("laneService - branchSwitch", () => { } }); - it("preserves PR rows whose head_branch matches the new branch and deletes stale ones", async () => { + it("preserves current PR rows and retains previous-branch PR history", async () => { const repoRoot = makeTempRepoRoot("ade-bsw-switch-pr-detach-"); const db = await openKvDb(path.join(repoRoot, "kv.sqlite"), createLogger()); try { @@ -5797,22 +5797,22 @@ describe("laneService - branchSwitch", () => { ); expect(keep?.lane_id).toBe("lane-a"); - // Stale rows are detached, not deleted — the PR happened on this lane even - // though the lane now tracks a different branch. + // Branch switching changes the lane's current role, not the PR's lane + // ownership. The old row remains available as previous-branch history. const stale = db.get<{ lane_id: string | null; detached_at: string | null; detached_lane_name: string | null }>( "select lane_id, detached_at, detached_lane_name from pull_requests where id = ?", ["pr-stale"], ); - expect(stale?.detached_at).toBeTruthy(); + expect(stale?.detached_at).toBeNull(); expect(stale?.lane_id).toBe("lane-a"); - // A detached row must not be treated as live: the kept row is the only one - // still claiming the lane's current branch. + // Both rows still belong to the lane; the renderer derives active vs + // previous from the lane branch and each PR head branch. expect( db.get<{ count: number }>( "select count(1) as count from pull_requests where lane_id = ? and detached_at is null", ["lane-a"], )?.count, - ).toBe(1); + ).toBe(2); } finally { db.close(); fs.rmSync(repoRoot, { recursive: true, force: true }); diff --git a/apps/desktop/src/main/services/lanes/laneService.ts b/apps/desktop/src/main/services/lanes/laneService.ts index c4854cfbc..d15efc898 100644 --- a/apps/desktop/src/main/services/lanes/laneService.ts +++ b/apps/desktop/src/main/services/lanes/laneService.ts @@ -4,7 +4,7 @@ import { AsyncLocalStorage } from "node:async_hooks"; import { randomUUID } from "node:crypto"; import type { AdeDb } from "../state/kvDb"; import { getHeadSha, runGit, runGitOrThrow } from "../git/git"; -import { detachPullRequestRowsByIds, detachPullRequestRowsForLane } from "../prs/pullRequestRowCleanup"; +import { detachPullRequestRowsForLane } from "../prs/pullRequestRowCleanup"; import { isWithinDir, normalizeBranchName, resolvePathWithinRoot } from "../shared/utils"; import { fetchRemoteTrackingBranch } from "../shared/remoteTrackingBranch"; import { pathsEqual } from "../shared/pathCompare"; @@ -4910,11 +4910,9 @@ export function createLaneService({ }; } - // Wrap the profile upsert + lanes update + stale-PR cleanup in a single - // transaction so a partial failure can't leave the lane row referencing - // the new branch while the orphaned PR rows linger (or vice versa), or - // leave the post-checkout profile written without the matching lanes - // row update. + // Wrap the profile upsert + lanes update in a single transaction so a + // partial failure can't leave the lane row referencing the new branch + // while the profile still points at the old one. db.run("begin"); try { if (pendingProfileUpsert) { @@ -4934,30 +4932,6 @@ export function createLaneService({ `, [targetBranchRef, baseRef, parentLaneId, row.id, projectId], ); - // PR rows whose head_branch no longer matches the lane's current branch are - // stale references and must not bleed into PR lookups. Detach rather than - // delete: the PR still happened on this lane, and erasing it is what made - // merged PRs render as `unmapped`. detached_at takes them out of live lookups - // while keeping the history. - const stalePrRows = db.all<{ id: string }>( - ` - select id from pull_requests - where lane_id = ? - and project_id = ? - and head_branch <> ? - `, - [row.id, projectId, targetBranchRef], - ); - if (stalePrRows.length > 0) { - detachPullRequestRowsByIds(db, { - projectId, - laneId: row.id, - laneName: row.name ?? null, - laneColor: row.color ?? null, - detachedAt: new Date().toISOString(), - prIds: stalePrRows.map((r) => r.id), - }); - } db.run("commit"); } catch (err) { try { db.run("rollback"); } catch { /* swallow rollback failures */ } @@ -5128,30 +5102,6 @@ export function createLaneService({ projectId, ], ); - // Same rationale as switchBranch: PR rows whose head branch no longer - // matches the lane are stale references and must not bleed into PR - // lookups now that the lane tracks a different branch. Detached, not deleted, - // so the merged view keeps the record. `row` is the pre-update snapshot, so - // `row.name` is the name these PRs were actually built under. - const stalePrRows = db.all<{ id: string }>( - ` - select id from pull_requests - where lane_id = ? - and project_id = ? - and head_branch <> ? - `, - [row.id, projectId, targetBranchRef], - ); - if (stalePrRows.length > 0) { - detachPullRequestRowsByIds(db, { - projectId, - laneId: row.id, - laneName: row.name ?? null, - laneColor: row.color ?? null, - detachedAt: new Date().toISOString(), - prIds: stalePrRows.map((entry) => entry.id), - }); - } db.run("commit"); } catch (err) { try { db.run("rollback"); } catch { /* swallow rollback failures */ } diff --git a/apps/desktop/src/main/services/prs/prAsync.test.ts b/apps/desktop/src/main/services/prs/prAsync.test.ts index e504664e8..98c34067c 100644 --- a/apps/desktop/src/main/services/prs/prAsync.test.ts +++ b/apps/desktop/src/main/services/prs/prAsync.test.ts @@ -972,6 +972,42 @@ describe("prMergeAutoSettlementService", () => { ); }); + it("settles only the chats explicitly linked to a merged PR", async () => { + const db = createMemoryDb(); + const settleSessionsWithOutcome = vi.fn((ids: string[]) => ids); + const getSettlementBlockers = vi.fn(async () => []); + const service = createPrMergeAutoSettlementService({ + db: db as any, + sessionService: { + list: vi.fn(() => [ + { id: "chat-owned", toolType: "codex-chat", archivedAt: null, settledAt: null }, + { id: "chat-other", toolType: "codex-chat", archivedAt: null, settledAt: null }, + ]), + settleSessionsWithOutcome, + } as any, + agentChatService: { getSettlementBlockers } as any, + emitEvent: vi.fn(), + }); + const openPr = createSummary({ state: "open" }); + await service.processSnapshot({ prs: [openPr], polledAt: "2026-03-24T12:00:00.000Z" }); + + const mergedPr = createSummary({ + state: "merged", + mergedAt: "2026-03-24T12:01:00.000Z", + chatSessionIds: ["chat-owned"], + }); + await service.processSnapshot({ prs: [mergedPr], polledAt: "2026-03-24T12:01:05.000Z" }); + + expect(getSettlementBlockers).toHaveBeenCalledTimes(1); + expect(getSettlementBlockers).toHaveBeenCalledWith("chat-owned", { includeCurrentTurn: true }); + expect(settleSessionsWithOutcome).toHaveBeenCalledWith( + ["chat-owned"], + "PR #101 merged", + "2026-03-24T12:01:05.000Z", + "pr_merge", + ); + }); + it("settles a PR that was already merged when first seen, but announces nothing", async () => { // The machine-switch bug. Point the project tab at another machine and that // machine reconciles, backfilling rows for PRs it had never stored. Every diff --git a/apps/desktop/src/main/services/prs/prChatCards.test.ts b/apps/desktop/src/main/services/prs/prChatCards.test.ts index c5abcc05c..6cb73a1d8 100644 --- a/apps/desktop/src/main/services/prs/prChatCards.test.ts +++ b/apps/desktop/src/main/services/prs/prChatCards.test.ts @@ -12,6 +12,7 @@ import { buildPrReviewCard, emitPrCardsForChange, selectPrCardSession, + selectPrCardSessions, } from "./prChatCards"; function pr(overrides: Partial = {}): PrSummary { @@ -122,6 +123,18 @@ describe("PR chat cards", () => { ])?.sessionId).toBe("newer"); }); + it("selects every explicitly linked Work chat for a PR", () => { + expect(selectPrCardSessions([ + session("older", "2026-07-27T10:00:00.000Z"), + session("newer", "2026-07-27T12:00:00.000Z"), + session("personal", "2026-07-27T13:00:00.000Z", { surface: "personal" }), + session("archived", "2026-07-27T14:00:00.000Z", { archivedAt: "2026-07-27T14:01:00.000Z" }), + ], ["older", "newer", "personal", "archived"]).map((entry) => entry.sessionId)).toEqual([ + "newer", + "older", + ]); + }); + it("builds one live CI episode keyed by head and attempt", () => { const card = buildPrCiCard({ pr: pr(), runs: [run()], checks: [check("external", { appSlug: "vercel" })] }); expect(card).toMatchObject({ @@ -451,6 +464,40 @@ describe("PR chat cards", () => { expect(emitAdeCard.mock.calls.every(([call]) => call.sessionId === "newer")).toBe(true); }); + it("fans a PR transition out to every linked Work chat", async () => { + const emitAdeCard = vi.fn().mockResolvedValue(undefined); + const count = await emitPrCardsForChange({ + change: { + pr: pr({ + chatSessionIds: ["newer", "older"], + checksStatus: "failing", + }), + previousState: "open", + previousChecksStatus: "passing", + previousReviewStatus: "approved", + previousMergeConflicts: false, + previousBehindBaseBy: 0, + }, + dataSource: { + getActionRuns: vi.fn().mockResolvedValue([run()]), + getChecks: vi.fn().mockResolvedValue([]), + getReviews: vi.fn().mockResolvedValue([]), + getReviewThreads: vi.fn().mockResolvedValue([]), + }, + chat: { + listSessions: vi.fn().mockResolvedValue([ + session("older", "2026-07-27T10:00:00.000Z"), + session("newer", "2026-07-27T12:00:00.000Z"), + session("other-lane", "2026-07-27T13:00:00.000Z", { laneId: "lane-2" }), + ]), + emitAdeCard, + }, + }); + + expect(count).toBe(1); + expect(emitAdeCard.mock.calls.map(([call]) => call.sessionId).sort()).toEqual(["newer", "older"]); + }); + it("emits a merge-ready episode once when the prior state was not ready", async () => { const emitAdeCard = vi.fn().mockResolvedValue(undefined); const count = await emitPrCardsForChange({ diff --git a/apps/desktop/src/main/services/prs/prChatCards.ts b/apps/desktop/src/main/services/prs/prChatCards.ts index 85b83b2b8..d3d0e762c 100644 --- a/apps/desktop/src/main/services/prs/prChatCards.ts +++ b/apps/desktop/src/main/services/prs/prChatCards.ts @@ -178,12 +178,31 @@ function formatContextList(contexts: readonly string[], limit = MISSING_REQUIRED export function selectPrCardSession( sessions: AgentChatSessionSummary[], ): AgentChatSessionSummary | null { - return sessions + return selectPrCardSessions(sessions)[0] ?? null; +} + +/** + * Route a PR card to every live chat that explicitly worked on the PR. Older + * PR rows have no edge, so they retain the historic single most-recent-lane + * fallback instead of disappearing from chat altogether. + */ +export function selectPrCardSessions( + sessions: AgentChatSessionSummary[], + chatSessionIds?: readonly string[] | null, +): AgentChatSessionSummary[] { + const eligible = sessions .filter((session) => (session.surface ?? "work") === "work" && session.archivedAt == null) .sort((left, right) => ( timeMs(right.lastActivityAt) - timeMs(left.lastActivityAt) || right.sessionId.localeCompare(left.sessionId) - ))[0] ?? null; + )); + const linkedIds = new Set( + (chatSessionIds ?? []).map((sessionId) => String(sessionId ?? "").trim()).filter(Boolean), + ); + if (linkedIds.size > 0) { + return eligible.filter((session) => linkedIds.has(session.sessionId)); + } + return eligible.slice(0, 1); } /** @@ -470,10 +489,11 @@ export async function emitPrCardsForChange(args: { return 0; } - const session = selectPrCardSession( + const sessions = selectPrCardSessions( await chat.listSessions(pr.laneId, { includeArchived: false }), + pr.chatSessionIds, ); - if (!session) return 0; + if (sessions.length === 0) return 0; const cards: AdeCardPayload[] = []; if (checksChanged) { @@ -512,12 +532,14 @@ export async function emitPrCardsForChange(args: { cards.push(buildPrMergedCard(pr)); } - const results = await Promise.allSettled(cards.map((card) => ( - chat.emitAdeCard({ - sessionId: session.sessionId, - card, - }) - ))); + const results = await Promise.allSettled( + sessions.flatMap((session) => cards.map((card) => ( + chat.emitAdeCard({ + sessionId: session.sessionId, + card, + }) + ))), + ); const failures = results.filter((result) => result.status === "rejected"); if (failures.length > 0) { throw new AggregateError( diff --git a/apps/desktop/src/main/services/prs/prMergeAutoSettlementService.ts b/apps/desktop/src/main/services/prs/prMergeAutoSettlementService.ts index 883383314..137bdc0b2 100644 --- a/apps/desktop/src/main/services/prs/prMergeAutoSettlementService.ts +++ b/apps/desktop/src/main/services/prs/prMergeAutoSettlementService.ts @@ -93,6 +93,9 @@ export function createPrMergeAutoSettlementService(args: { ); for (const pr of candidates) { + const linkedChatSessionIds = new Set( + (pr.chatSessionIds ?? []).map((sessionId) => String(sessionId ?? "").trim()).filter(Boolean), + ); const rows = args.sessionService.list({ laneId: pr.laneId, limit: 500, @@ -100,7 +103,7 @@ export function createPrMergeAutoSettlementService(args: { !session.archivedAt && !session.settledAt && (isChatToolType(session.toolType) || isTrackedAgentCliToolType(session.toolType)), - ); + ).filter((session) => linkedChatSessionIds.size === 0 || linkedChatSessionIds.has(session.id)); const settledSessionIds: string[] = []; for (const session of rows) { diff --git a/apps/desktop/src/main/services/prs/prService.test.ts b/apps/desktop/src/main/services/prs/prService.test.ts index a78f5c72c..adf1f9275 100644 --- a/apps/desktop/src/main/services/prs/prService.test.ts +++ b/apps/desktop/src/main/services/prs/prService.test.ts @@ -571,6 +571,28 @@ describe("prService.getForLane", () => { ]); }); + it("hydrates explicit chat ownership edges onto every listed PR", () => { + const db = makeMockDb(); + const row = makePrRow({ id: "pr-owned-by-chat", lane_id: LANE_ID }); + db.all.mockImplementation((sql: string) => { + const text = String(sql); + if (text.includes("from pull_request_chat_sessions")) { + return [{ pr_id: row.id, session_id: "chat-1" }]; + } + if (text.includes("from pull_requests")) return [row]; + return []; + }); + + const { service } = buildService({ db }); + + expect(service.listAll()).toEqual([ + expect.objectContaining({ + id: row.id, + chatSessionIds: ["chat-1"], + }), + ]); + }); + it("includes native GitHub stack membership in lane and list summaries", async () => { const lane = makeFakeLane({ branchRef: "refs/heads/stack-ui" }); const service = buildGetForLaneService( @@ -659,7 +681,7 @@ describe("prService.getForLane", () => { expect(service.getForLane(lane.id)).toBeNull(); }); - it("ignores stale PR rows whose head branch no longer matches the lane branch", () => { + it("returns a stale PR row as lane history when the lane moved on", () => { const lane = makeFakeLane({ branchRef: "refs/heads/current-feature", }); @@ -671,7 +693,11 @@ describe("prService.getForLane", () => { }), ]); - expect(service.getForLane(lane.id)).toBeNull(); + expect(service.getForLane(lane.id)).toMatchObject({ + id: "pr-row-1", + headBranch: "old-feature", + state: "open", + }); }); it("prefers the PR whose head matches the current lane branch", () => { @@ -728,7 +754,7 @@ describe("prService.getForLane", () => { expect(service.getForLane(lane.id)?.state).toBe("merged"); }); - it("ignores terminal PR rows whose head branch no longer matches the lane branch", () => { + it("returns terminal PR rows as lane history when the lane moved on", () => { const lane = makeFakeLane({ branchRef: "refs/heads/current-feature", }); @@ -740,7 +766,11 @@ describe("prService.getForLane", () => { }), ]); - expect(service.getForLane(lane.id)).toBeNull(); + expect(service.getForLane(lane.id)).toMatchObject({ + id: "pr-row-1", + headBranch: "old-feature", + state: "merged", + }); }); it("allows primary to show an active PR only when checked out to that PR head branch", () => { @@ -3666,15 +3696,24 @@ describe("prService.linkToLane", () => { throw new Error(`Unexpected GitHub API request: ${args.method} ${args.path}`); }), }); + const pullRequestGet = db.get.getMockImplementation(); + db.get.mockImplementation((sql: string, params: unknown[] = []) => { + if (String(sql).includes("from terminal_sessions")) return { id: params[0] }; + return pullRequestGet?.(sql, params) ?? null; + }); const { service } = buildService({ db, githubService }); - await service.linkToLane({ laneId: LANE_ID, prUrlOrNumber: "90" }); + await service.linkToLane({ laneId: LANE_ID, prUrlOrNumber: "90", sessionId: "chat-90" }); const insertCall = db.run.mock.calls.find(([sql]: [unknown]) => String(sql).includes("insert into pull_requests(") ); expect(insertCall?.[1]?.[17]).toBe(githubCreatedAt); expect(insertCall?.[1]?.[18]).toBe("2026-07-16T12:00:00.000Z"); + const chatLinkInsert = db.run.mock.calls.find(([sql]: [unknown]) => + String(sql).includes("insert into pull_request_chat_sessions") + ); + expect(chatLinkInsert?.[1]).toEqual(expect.arrayContaining(["chat-90"])); } finally { vi.useRealTimers(); } diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index 142361c43..df9c56a95 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -1501,6 +1501,131 @@ export function createPrService({ * its provenance back), just not in live lane state. */ const LIVE_PR_ROWS = "detached_at is null"; + type PullRequestChatSessionRow = { pr_id: string; session_id: string }; + + const chatSessionIdsByPrId = (prIds: string[]): Map => { + const ids = [...new Set(prIds.map((id) => String(id ?? "").trim()).filter(Boolean))]; + const result = new Map(); + if (ids.length === 0) return result; + try { + const placeholders = ids.map(() => "?").join(", "); + const rows = db.all( + ` + select pr_id, session_id + from pull_request_chat_sessions + where project_id = ? + and pr_id in (${placeholders}) + order by created_at asc, id asc + `, + [projectId, ...ids], + ); + for (const row of rows) { + const sessionId = String(row.session_id ?? "").trim(); + if (!sessionId) continue; + const current = result.get(row.pr_id) ?? []; + if (!current.includes(sessionId)) current.push(sessionId); + result.set(row.pr_id, current); + } + } catch (error) { + logger.warn("prs.chat_session_links_read_failed", { error: getErrorMessage(error) }); + } + return result; + }; + + const withChatSessionLinks = (summaries: PrSummary[]): PrSummary[] => { + const links = chatSessionIdsByPrId(summaries.map((summary) => summary.id)); + return summaries.map((summary) => { + const sessionIds = links.get(summary.id); + return sessionIds?.length ? { ...summary, chatSessionIds: sessionIds } : summary; + }); + }; + + const removeChatSessionLinksFromOtherLanes = (prId: string, laneId: string): void => { + try { + db.run( + `delete from pull_request_chat_sessions + where project_id = ? and pr_id = ? and lane_id <> ?`, + [projectId, prId, laneId], + ); + } catch { + // Older test/embedded databases may predate the optional edge table. + } + }; + + const linkPrToChatSession = (args: { + prId: string; + laneId: string; + sessionId?: string | null; + }): void => { + const sessionId = String(args.sessionId ?? "").trim(); + if (!sessionId) return; + const pr = db.get<{ id: string; lane_id: string }>( + "select id, lane_id from pull_requests where id = ? and project_id = ? limit 1", + [args.prId, projectId], + ); + if (!pr || pr.lane_id !== args.laneId) return; + + try { + // Chat surfaces use the terminal-session id. The Claude pointer fallback + // keeps imported/older chats addressable when only their provider session + // id was persisted. + const session = db.get<{ id: string }>( + ` + select id + from terminal_sessions + where id = ? and lane_id = ? + union all + select chat_session_id as id + from claude_sessions + where session_id = ? and lane_id = ? and chat_session_id is not null + limit 1 + `, + [sessionId, args.laneId, sessionId, args.laneId], + ); + if (!session) { + logger.warn("prs.chat_session_link_session_missing", { + prId: args.prId, + laneId: args.laneId, + sessionId, + }); + return; + } + const canonicalSessionId = String(session.id ?? "").trim(); + if (!canonicalSessionId) return; + const now = nowIso(); + const existing = db.get<{ id: string }>( + ` + select id + from pull_request_chat_sessions + where project_id = ? and pr_id = ? and session_id = ? + limit 1 + `, + [projectId, args.prId, canonicalSessionId], + ); + if (existing) { + db.run( + "update pull_request_chat_sessions set lane_id = ?, updated_at = ? where id = ? and project_id = ?", + [args.laneId, now, existing.id, projectId], + ); + } else { + db.run( + ` + insert into pull_request_chat_sessions( + id, project_id, pr_id, lane_id, session_id, created_at, updated_at + ) values (?, ?, ?, ?, ?, ?, ?) + `, + [randomUUID(), projectId, args.prId, args.laneId, canonicalSessionId, now, now], + ); + } + } catch (error) { + logger.warn("prs.chat_session_link_write_failed", { + prId: args.prId, + laneId: args.laneId, + sessionId, + error: getErrorMessage(error), + }); + } + }; const GITHUB_PROJECTION_COLUMNS = `project_id, repo_owner, repo_name, github_pr_number, github_node_id, github_url, title, state, is_draft, base_branch, head_branch, head_repo_owner, head_repo_name, head_sha, base_sha, author, labels_json, @@ -6728,6 +6853,8 @@ export function createPrService({ laneId: lane.id, }); markHotRefresh([prId]); + removeChatSessionLinksFromOtherLanes(prId, lane.id); + linkPrToChatSession({ prId, laneId: lane.id, sessionId: args.sessionId }); await publishLinearPrCardsForLane({ lane, @@ -6760,7 +6887,8 @@ export function createPrService({ }); }); - return await refreshOne(prId); + const refreshed = await refreshOne(prId); + return withGithubStackMembership(refreshed) ?? refreshed; }; const linkToLane = async (args: LinkPrToLaneArgs): Promise => { @@ -6861,6 +6989,8 @@ export function createPrService({ laneId: lane.id, }); markHotRefresh([prId]); + removeChatSessionLinksFromOtherLanes(prId, lane.id); + linkPrToChatSession({ prId, laneId: lane.id, sessionId: args.sessionId }); await publishLinearPrCardsForLane({ lane, @@ -6893,7 +7023,8 @@ export function createPrService({ }); }); - return await refreshOne(prId); + const refreshed = await refreshOne(prId); + return withGithubStackMembership(refreshed) ?? refreshed; }; const cleanupBranch = async (args: CleanupPrBranchArgs): Promise => { @@ -9038,7 +9169,7 @@ export function createPrService({ const withGithubStackMemberships = (summaries: PrSummary[]): PrSummary[] => { if (summaries.length === 0) return summaries; const memberships = githubStackStore.membershipsByPr(); - return summaries.map((summary) => ({ + return withChatSessionLinks(summaries).map((summary) => ({ ...summary, stack: memberships.get( repoPrKey(summary.repoOwner, summary.repoName, summary.githubPrNumber), @@ -10697,9 +10828,22 @@ export function createPrService({ }, getForLane(laneId: string): PrSummary | null { - return withGithubStackMembership( - getDisplayCandidateForCurrentLaneBranch(laneId)?.summary ?? null, - ); + const current = getDisplayCandidateForCurrentLaneBranch(laneId)?.summary ?? null; + if (current) return withGithubStackMembership(current); + + // The single-value bridge is still used by older chat/web clients. Keep + // it honest for a lane that moved on: when there is no current-branch PR, + // return the newest retained lane-history row so those clients do not + // silently lose the lane's PR badge altogether. + const lane = getLanePrLookupRow(laneId); + if (!lane || lane.archived_at || !normalizeBranchName(branchNameFromRef(lane.branch_ref ?? ""))) { + return null; + } + const previous = getRowForLane(laneId); + const laneBranch = normalizeBranchName(branchNameFromRef(lane.branch_ref ?? "")); + const previousBranch = normalizeBranchName(branchNameFromRef(previous?.head_branch ?? "")); + if (!previous || !previousBranch || previousBranch === laneBranch) return null; + return withGithubStackMembership(rowToSummary(previous)); }, listAll(args: { laneId?: string } = {}): PrSummary[] { diff --git a/apps/desktop/src/main/services/prs/pullRequestRowCleanup.ts b/apps/desktop/src/main/services/prs/pullRequestRowCleanup.ts index 4f3634cce..8828d323d 100644 --- a/apps/desktop/src/main/services/prs/pullRequestRowCleanup.ts +++ b/apps/desktop/src/main/services/prs/pullRequestRowCleanup.ts @@ -123,6 +123,15 @@ export function deletePullRequestRowsByIds(db: DbLike, projectId: string, prIds: where pr_id in (${scope.selectSql})`, scope.params, ); + try { + db.run( + `delete from pull_request_chat_sessions + where pr_id in (${scope.selectSql})`, + scope.params, + ); + } catch { + // Older test/embedded databases may predate the optional edge table. + } db.run(`delete from pull_requests where id in (${scope.selectSql})`, scope.params); pruneEmptyPrGroups(db, projectId); } @@ -216,6 +225,18 @@ function detachRows( where pr_id in (${scope.selectSql})`, scope.params, ); + // Chat ownership is live routing metadata, not lane-deletion history. Once + // the lane is detached its sessions are deleted, so retaining these edges + // would send future PR cards to a chat that no longer exists. + try { + db.run( + `delete from pull_request_chat_sessions + where pr_id in (${scope.selectSql})`, + scope.params, + ); + } catch { + // Older databases are upgraded before this path is normally reached. + } pruneEmptyPrGroups(db, projectId); } diff --git a/apps/desktop/src/main/services/state/kvDb.ts b/apps/desktop/src/main/services/state/kvDb.ts index 30abec34a..62711ab08 100644 --- a/apps/desktop/src/main/services/state/kvDb.ts +++ b/apps/desktop/src/main/services/state/kvDb.ts @@ -1182,6 +1182,9 @@ function purgeRetiredTerminalSessions(db: DatabaseSyncType): number { if (rawHasTable(db, "session_linear_issues")) { runStatement(db, `delete from session_linear_issues where session_id in (${placeholders})`, retiredSessionIds); } + if (rawHasTable(db, "pull_request_chat_sessions")) { + runStatement(db, `delete from pull_request_chat_sessions where session_id in (${placeholders})`, retiredSessionIds); + } if (rawHasTable(db, "session_deltas")) { runStatement(db, `delete from session_deltas where session_id in (${placeholders})`, retiredSessionIds); } @@ -2413,6 +2416,28 @@ function migrate(db: MigrationDb, rawDb: DatabaseSyncType) { `); db.run("create index if not exists idx_pull_requests_lane_id on pull_requests(lane_id)"); db.run("create index if not exists idx_pull_requests_project_id on pull_requests(project_id)"); + + // A PR can be opened from more than one chat over the lifetime of a lane, + // and a chat can move on to more than one PR. Keep that relationship in a + // separate CRR-friendly table instead of widening `pull_requests`: the latter + // is phone-critical and cannot be rebuilt with a nullable foreign key change. + // Tuple uniqueness is enforced by the service because CRR tables cannot carry + // unique indexes other than their primary key. + db.run(` + create table if not exists pull_request_chat_sessions ( + id text primary key, + project_id text not null, + pr_id text not null, + lane_id text not null, + session_id text not null, + created_at text not null, + updated_at text not null, + foreign key(project_id) references projects(id) on delete cascade + ) + `); + db.run("create index if not exists idx_pull_request_chat_sessions_pr on pull_request_chat_sessions(project_id, pr_id)"); + db.run("create index if not exists idx_pull_request_chat_sessions_session on pull_request_chat_sessions(project_id, session_id)"); + db.run("create index if not exists idx_pull_request_chat_sessions_lane on pull_request_chat_sessions(project_id, lane_id)"); safeAddColumn(db, "alter table pull_requests add column last_polled_at text"); safeAddColumn(db, "alter table pull_requests add column head_sha text"); safeAddColumn(db, "alter table pull_requests add column creation_strategy text"); diff --git a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts index 289f83b22..c5ace8957 100644 --- a/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts +++ b/apps/desktop/src/main/services/sync/syncRemoteCommandService.test.ts @@ -890,6 +890,7 @@ describe("createSyncRemoteCommandService", () => { it("prs.createFromLane parses laneId + title + draft", async () => { await service.execute(makePayload("prs.createFromLane", { laneId: "lane-1", + sessionId: "chat-1", title: "My PR", body: "Description", draft: true, @@ -897,6 +898,7 @@ describe("createSyncRemoteCommandService", () => { })); expect(prService.createFromLane).toHaveBeenCalledWith({ laneId: "lane-1", + sessionId: "chat-1", title: "My PR", body: "Description", draft: true, diff --git a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx index f7d8fc59c..f752c9a21 100644 --- a/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx +++ b/apps/desktop/src/renderer/components/chat/AgentChatPane.tsx @@ -11798,6 +11798,7 @@ export function AgentChatPane({ // lifecycle awareness at all. The composer slot below stays with drift. lifecycleSessionId={selectedSessionId ?? null} showGitToolbar={showWorkspaceChrome} + prSessionId={selectedSessionId} onTogglePrPane={showWorkspaceChrome && laneId ? () => setPrPaneOpen((v) => !v) : undefined} prPaneOpen={prPaneOpen} runtimePin={chatRuntimePin} @@ -13066,6 +13067,7 @@ export function AgentChatPane({ laneId={laneId} branchName={laneGitBranch} sessionTitle={selectedSession?.title ?? null} + sessionId={selectedSessionId} delta={prPaneDelta} onClose={() => setPrPaneOpen(false)} runtimePin={chatRuntimePin} diff --git a/apps/desktop/src/renderer/components/chat/ChatGitToolbar.test.tsx b/apps/desktop/src/renderer/components/chat/ChatGitToolbar.test.tsx index ccced3956..065a2ba08 100644 --- a/apps/desktop/src/renderer/components/chat/ChatGitToolbar.test.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatGitToolbar.test.tsx @@ -6,6 +6,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; import { useAppStore } from "../../state/appStore"; import { clearPrReadInFlightForTest } from "../../lib/prReadCache"; +import { selectPrsForChat } from "../../lib/prChatScope"; +import type { PrSummary } from "../../../shared/types"; import { ChatGitToolbar } from "./ChatGitToolbar"; const originalAde = globalThis.window.ade; @@ -135,6 +137,17 @@ describe("ChatGitToolbar", () => { } }); + it("scopes explicit PR links to the current chat without cross-talk", () => { + const linked = [ + { id: "pr-a", chatSessionIds: ["chat-a"] }, + { id: "pr-b", chatSessionIds: ["chat-b"] }, + ] as unknown as PrSummary[]; + + expect(selectPrsForChat(linked, "chat-a").map((pr) => pr.id)).toEqual(["pr-a"]); + expect(selectPrsForChat(linked, "chat-c")).toEqual([]); + expect(selectPrsForChat([{ id: "legacy" }] as unknown as PrSummary[], "chat-c").map((pr) => pr.id)).toEqual(["legacy"]); + }); + it("opens the PR creation handoff when the current lane has no linked PR", async () => { renderToolbar(); diff --git a/apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx b/apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx index c7132a54a..62c2c9ed5 100644 --- a/apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatGitToolbar.tsx @@ -23,7 +23,13 @@ import { useAppStore } from "../../state/appStore"; import { refreshLinkedPrCoalesced } from "../../lib/prReadCache"; import { rollupPrChecks } from "../../../shared/prChecksRollup"; import type { PrChecksStatus } from "../../../shared/types/prs"; -import { openLanePr } from "../../lib/lanePrBadge"; +import { + lanePrAggregateAttention, + lanePrAttentionColor, + openLanePr, + selectPrimaryLanePr, +} from "../../lib/lanePrBadge"; +import { selectPrsForChat } from "../../lib/prChatScope"; import { GitHubStackBadge } from "../prs/shared/GitHubStackBadge"; // --------------------------------------------------------------------------- @@ -32,6 +38,8 @@ import { GitHubStackBadge } from "../prs/shared/GitHubStackBadge"; type ChatGitToolbarProps = { laneId: string; + /** The chat owning this header; its explicit PR links win over lane fallback. */ + sessionId?: string | null; /** * When provided (ADE chat surfaces), the PR pill/button toggles this instead * of opening the inline slide-out or navigating to the PRs tab. CLI surfaces @@ -75,21 +83,6 @@ function checksIcon(status: PrSummary["checksStatus"], state: PrSummary["state"] } } -function prStateDot(state: PrSummary["state"]) { - switch (state) { - case "open": - return "bg-emerald-400"; - case "draft": - return "bg-amber-400/60"; - case "merged": - return "bg-violet-400"; - case "closed": - return "bg-red-400/60"; - default: - return "bg-fg/20"; - } -} - function formatRelativeTime(iso: string | null | undefined): string | null { if (!iso) return null; const ts = Date.parse(iso); @@ -133,6 +126,7 @@ function summarizeChecks( export const ChatGitToolbar = React.memo(function ChatGitToolbar({ laneId, + sessionId = null, onTogglePrPane, prPaneOpen, runtimePin = null, @@ -141,8 +135,10 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({ const runtime = useLaneGitActionRuntimeState(laneId); const isRemoteProject = useAppStore((s) => s.projectBinding?.kind === "remote"); const projectRoot = useAppStore((s) => s.project?.rootPath ?? s.projectBinding?.rootPath ?? null); + const lanes = useAppStore((s) => s.lanes); const [dirtyCount, setDirtyCount] = useState(0); + const [linkedPrs, setLinkedPrs] = useState([]); const [linkedPr, setLinkedPr] = useState(null); const [prLoaded, setPrLoaded] = useState(false); const [prActionBusy, setPrActionBusy] = useState(false); @@ -187,16 +183,37 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({ refreshPrRequestRef.current = requestId; const requestIsCurrent = () => laneIdRef.current === laneId && refreshPrRequestRef.current === requestId; try { - const pr = await window.ade.prs.getForLane(laneId, runtimePinRef.current); + let lanePrs: PrSummary[]; + if (typeof window.ade.prs.listAll === "function") { + const allPrs = await window.ade.prs.listAll(runtimePinRef.current); + const ownedPrs = allPrs.filter((pr) => pr.laneId === laneId && !pr.detached); + lanePrs = selectPrsForChat(ownedPrs, sessionId); + } else { + // Older web-preview/test bridges only expose the original single-PR + // lookup. Keep that compatibility path while the desktop bridge rolls + // forward to the plural list. + const legacy = await window.ade.prs.getForLane(laneId, runtimePinRef.current); + lanePrs = legacy ? [legacy] : []; + } + const lane = lanes.find((candidate) => candidate.id === laneId) ?? null; + const pr = lane + ? selectPrimaryLanePr(lane, lanePrs) + : lanePrs[0] ?? null; if (!requestIsCurrent()) return null; + setLinkedPrs(lanePrs); setLinkedPr(pr); setPrLoaded(true); if (options.live && pr && !pr.unmapped) { try { const refreshed = await refreshLinkedPrCoalesced(pr, { projectRoot, pin: runtimePinRef.current }); if (!requestIsCurrent()) return null; - setLinkedPr(refreshed); - return refreshed; + if (!refreshed) return pr; + const enriched = refreshed.chatSessionIds || !pr.chatSessionIds + ? refreshed + : { ...refreshed, chatSessionIds: pr.chatSessionIds }; + setLinkedPrs((current) => current.map((candidate) => candidate.id === enriched.id ? enriched : candidate)); + setLinkedPr(enriched); + return enriched; } catch { return pr; } @@ -204,6 +221,7 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({ return pr; } catch { if (requestIsCurrent()) { + setLinkedPrs([]); setLinkedPr(null); setPrLoaded(true); } @@ -213,10 +231,11 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({ // it — but a callback that reads machine A must not be reused as if it reads // machine B, and its identity is what re-runs the effects below. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [laneId, projectRoot, runtimePinKey]); + }, [laneId, lanes, projectRoot, runtimePinKey, sessionId]); useEffect(() => { setDirtyCount(0); + setLinkedPrs([]); setLinkedPr(null); setPrLoaded(false); setPrMenuOpen(false); @@ -264,40 +283,48 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({ return; } if (event.type !== "prs-updated") return; - const eventIncludesLanePr = event.prs.some((pr) => pr.laneId === laneId); + const eventIncludesLanePr = event.prs.some((pr) => ( + pr.laneId === laneId && ( + !sessionId + || !pr.chatSessionIds?.length + || pr.chatSessionIds.includes(sessionId) + ) + )); const eventIncludesLinkedPr = current ? event.prs.some((pr) => pr.id === current.id) : false; if (eventIncludesLanePr || eventIncludesLinkedPr) { void refreshPr(); } else if (current) { // The linked PR vanished from the latest snapshot — clear the pill. + setLinkedPrs([]); setLinkedPr(null); } }, runtimePinRef.current); return () => { unsubscribe(); }; - }, [laneId, refreshPr, runtimePinKey]); + }, [laneId, refreshPr, runtimePinKey, sessionId]); + + const openPr = useCallback((pr: PrSummary) => { + // The PRs tab resolves a PR id against the bound machine only, so a lane + // on another machine goes to GitHub — the one destination that means the + // same thing from either machine. The local branch keeps this surface's + // richer route (it also selects the lane), so it passes its own path. + openLanePr(pr, { + foreign: Boolean(runtimePin), + navigate, + localPath: `/prs${buildPrsRouteSearch({ + activeTab: "normal", + selectedPrId: pr.id, + selectedLaneId: laneId, + selectedRebaseItemId: null, + })}`, + }); + }, [laneId, navigate, runtimePin]); const handlePr = useCallback(async () => { // A PR operation is about to run against this worktree — arm the drift // warning strip so a wrong-branch PR is caught before it is opened. armLaneBranchDriftWarning(laneId); - const openPr = (pr: PrSummary) => { - // The PRs tab resolves a PR id against the bound machine only, so a lane - // on another machine goes to GitHub — the one destination that means the - // same thing from either machine. The local branch keeps this surface's - // richer route (it also selects the lane), so it passes its own path. - openLanePr(pr, { - foreign: Boolean(runtimePin), - navigate, - localPath: `/prs${buildPrsRouteSearch({ - activeTab: "normal", - selectedPrId: pr.id, - selectedLaneId: laneId, - selectedRebaseItemId: null, - })}`, - }); - }; if (linkedPr) { openPr(linkedPr); return; @@ -324,7 +351,7 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({ target: "primary", }); navigate(`/prs?${params.toString()}`); - }, [laneId, linkedPr, navigate, prLoaded, refreshPr, runtimePin]); + }, [laneId, linkedPr, openPr, prLoaded, refreshPr, runtimePin]); const handlePrClick = useCallback(() => { if (prActionBusy) return; @@ -417,31 +444,92 @@ export const ChatGitToolbar = React.memo(function ChatGitToolbar({ const prPillActive = onTogglePrPane ? Boolean(prPaneOpen) : prMenuOpen; const prBadge = useMemo(() => { if (!linkedPr) return null; + const allPrs = linkedPrs.length > 0 ? linkedPrs : [linkedPr]; const label = formatPrBadgeLabel(linkedPr); return ( - +
+ + {allPrs.length > 1 ? ( + + ) : null} + {allPrs.length > 1 ? ( +
+
+
Pull requests · {allPrs.length}
+ {allPrs.map((candidate) => ( + + ))} +
+
+ ) : null} +
); - }, [linkedPr, prPillActive, onTogglePrPane]); + }, [laneId, linkedPr, linkedPrs, navigate, onTogglePrPane, openPr, prPillActive]); // Slide-out panel that appears to the right of the PR badge when toggled. const prMenu = useMemo(() => { diff --git a/apps/desktop/src/renderer/components/chat/ChatPrInlineCreator.test.tsx b/apps/desktop/src/renderer/components/chat/ChatPrInlineCreator.test.tsx index a5823726d..cffaa6d02 100644 --- a/apps/desktop/src/renderer/components/chat/ChatPrInlineCreator.test.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatPrInlineCreator.test.tsx @@ -179,7 +179,7 @@ describe("ChatPrInlineCreator create", () => { const createFromLane = vi.fn().mockResolvedValue({ id: "pr-1" }); installAde(createFromLane); const onCreated = vi.fn(); - renderCreator({ sessionTitle: "Redesign the in-chat PR panel", onCreated }); + renderCreator({ sessionTitle: "Redesign the in-chat PR panel", sessionId: "chat-1", onCreated }); await waitFor(() => expect(titleInput().value).toBe("Redesign the in-chat PR panel")); fireEvent.change(screen.getByLabelText("Pull request description"), { @@ -194,6 +194,7 @@ describe("ChatPrInlineCreator create", () => { body: "Flow layout for the inline creator.", draft: false, baseBranch: "main", + sessionId: "chat-1", }); await waitFor(() => expect(onCreated).toHaveBeenCalledWith({ id: "pr-1" })); }); diff --git a/apps/desktop/src/renderer/components/chat/ChatPrInlineCreator.tsx b/apps/desktop/src/renderer/components/chat/ChatPrInlineCreator.tsx index ec1d039db..2f6e180bd 100644 --- a/apps/desktop/src/renderer/components/chat/ChatPrInlineCreator.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatPrInlineCreator.tsx @@ -47,10 +47,13 @@ export const ChatPrInlineCreator = React.memo(function ChatPrInlineCreator({ laneId, branchName, sessionTitle, + sessionId = null, onCreated, }: { laneId: string; branchName?: string | null; + /** The chat that owns this PR edge; omitted for non-chat creation surfaces. */ + sessionId?: string | null; /** * Title of the chat this creator was opened from. When it's a real title (not * the placeholder "New chat") it seeds the PR title, which is far closer to a @@ -165,6 +168,7 @@ export const ChatPrInlineCreator = React.memo(function ChatPrInlineCreator({ const resolvedTitle = title.trim() || defaultTitle; const created = await window.ade.prs.createFromLane({ laneId, + ...(sessionId ? { sessionId } : {}), title: resolvedTitle, body, draft: false, @@ -186,7 +190,7 @@ export const ChatPrInlineCreator = React.memo(function ChatPrInlineCreator({ setError(cleanError(err)); setBusy(false); } - }, [body, defaultTitle, laneId, linearIssue, onCreated, resolvedBaseBranch, title]); + }, [body, defaultTitle, laneId, linearIssue, onCreated, resolvedBaseBranch, sessionId, title]); // The full integration composer lives in the PRs tab; this hands off with the // lane pre-selected. diff --git a/apps/desktop/src/renderer/components/chat/ChatPrPane.tsx b/apps/desktop/src/renderer/components/chat/ChatPrPane.tsx index 36963c651..f3999e409 100644 --- a/apps/desktop/src/renderer/components/chat/ChatPrPane.tsx +++ b/apps/desktop/src/renderer/components/chat/ChatPrPane.tsx @@ -26,7 +26,8 @@ import { ChatPrInlineCreator } from "./ChatPrInlineCreator"; import { refreshLinkedPrCoalesced } from "../../lib/prReadCache"; import { useAppStore, useRootAppStore } from "../../state/appStore"; import { pipelineStateOf } from "../../../shared/prPipelineState"; -import { openLanePr } from "../../lib/lanePrBadge"; +import { openLanePr, selectPrimaryLanePr } from "../../lib/lanePrBadge"; +import { selectPrsForChat } from "../../lib/prChatScope"; import { GitHubStackBadge } from "../prs/shared/GitHubStackBadge"; import { NO_CI_REASON } from "../../../shared/prChecksRollup"; @@ -416,6 +417,7 @@ export const ChatPrPane = React.memo(function ChatPrPane({ laneId, branchName, sessionTitle = null, + sessionId = null, delta = null, onClose, runtimePin = null, @@ -428,6 +430,8 @@ export const ChatPrPane = React.memo(function ChatPrPane({ * without a session (the Work grid) fall back to the lane → target derivation. */ sessionTitle?: string | null; + /** The chat whose explicit PR links should be shown first. */ + sessionId?: string | null; /** Describes the PR change that triggered this pane's auto-pop (owned by the parent). */ delta?: ChatPrDelta | null; /** Closes the pane — wired to the title bar's ✕ (the header PR pill also toggles it). */ @@ -437,6 +441,7 @@ export const ChatPrPane = React.memo(function ChatPrPane({ }) { const navigate = useNavigate(); const projectRoot = useAppStore((s) => s.project?.rootPath ?? s.projectBinding?.rootPath ?? null); + const lanes = useAppStore((s) => s.lanes); // See `ChatGitToolbar`: a local pin is a fresh object on every cross-machine // merge, so effects key on the stable pin key and read the object via a ref. const runtimePinRef = useRef(runtimePin); @@ -479,7 +484,22 @@ export const ChatPrPane = React.memo(function ChatPrPane({ const requestIsCurrent = () => laneIdRef.current === laneId && refreshRequestRef.current === requestId; let cached: PrSummary | null = null; try { - cached = await window.ade.prs.getForLane(laneId, runtimePinRef.current); + if (typeof window.ade.prs.listAll === "function") { + const allPrs = await window.ade.prs.listAll(runtimePinRef.current); + const ownedPrs = allPrs.filter((candidate) => candidate.laneId === laneId && !candidate.detached); + const scopedPrs = selectPrsForChat(ownedPrs, sessionId); + cached = selectPrimaryLanePr( + lanes.find((candidate) => candidate.id === laneId) ?? { + id: laneId, + laneType: "worktree", + branchRef: branchName ?? "", + baseRef: "", + }, + scopedPrs, + ); + } else { + cached = await window.ade.prs.getForLane(laneId, runtimePinRef.current); + } if (!requestIsCurrent()) return; setCurrentPr(cached); setLoading(false); @@ -496,7 +516,7 @@ export const ChatPrPane = React.memo(function ChatPrPane({ // See ChatGitToolbar: read via ref, but the identity must still follow the // pin so the effects keyed on it re-read from the new machine. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [laneId, projectRoot, runtimePinKey, setCurrentPr]); + }, [branchName, laneId, lanes, projectRoot, runtimePinKey, sessionId, setCurrentPr]); // The inline creator hands us the freshly-created PR the moment createFromLane // resolves — swap to the details view instantly rather than waiting for the @@ -747,6 +767,7 @@ export const ChatPrPane = React.memo(function ChatPrPane({ laneId={laneId} branchName={branchName ?? null} sessionTitle={sessionTitle} + sessionId={sessionId} onCreated={handleCreated} /> )} diff --git a/apps/desktop/src/renderer/components/lanes/LanePrBadgePopover.tsx b/apps/desktop/src/renderer/components/lanes/LanePrBadgePopover.tsx index 7aa818e07..d573962fb 100644 --- a/apps/desktop/src/renderer/components/lanes/LanePrBadgePopover.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanePrBadgePopover.tsx @@ -15,6 +15,7 @@ import { import { formatPrBadgeLabel } from "../prs/shared/prFormatters"; import { GitHubStackBadge } from "../prs/shared/GitHubStackBadge"; import { NO_CI_REASON } from "../../../shared/prChecksRollup"; +import { lanePrAttention, lanePrAttentionColor, lanePrAttentionRank } from "../../lib/lanePrBadge"; /** Caption beneath the state badge: "PR opened / merged / draft / closed". */ function prStateCaption(state: LaneTabPrTag["state"]): string { @@ -78,13 +79,111 @@ function StatusDot({ color }: { color: string }) { } export function LanePrBadgePopover({ - pr, + pr: legacyPr, + prs, onActivate, + onOpenList, }: { - pr: LaneTabPrTag; + pr?: LaneTabPrTag; + prs?: LaneTabPrTag[]; /** Invoked when the badge itself is clicked (navigate to PR / open external). */ - onActivate: (event: React.MouseEvent) => void; + onActivate: (event: React.SyntheticEvent, pr?: LaneTabPrTag) => void; + /** Invoked by the multi-PR counter to show the lane-filtered PR list. */ + onOpenList?: () => void; }) { + const allPrs = prs?.length ? prs : legacyPr ? [legacyPr] : []; + const primaryPr = allPrs.length > 1 + ? allPrs.reduce((best, candidate) => ( + lanePrAttentionRank(candidate) > lanePrAttentionRank(best) ? candidate : best + ), allPrs[0]!) + : allPrs[0] ?? null; + if (!primaryPr) return null; + if (allPrs.length > 1) { + const aggregate = allPrs.reduce((best, candidate) => ( + lanePrAttentionRank(candidate) > lanePrAttentionRank(best) ? candidate : best + ), allPrs[0]!); + const aggregateColor = lanePrAttentionColor(lanePrAttention(aggregate)); + const activate = (event: React.SyntheticEvent, candidate = primaryPr) => { + event.stopPropagation(); + onActivate(event, candidate); + }; + return ( + event.stopPropagation()} + onMouseDown={(event) => event.stopPropagation()} + > + + + + + + Pull requests ({allPrs.length}) + + {allPrs.map((candidate) => ( + activate(event, candidate)} + onKeyDown={(event) => { + if (event.key === "Enter" || event.key === " ") { + event.preventDefault(); + activate(event, candidate); + } + }} + title={candidate.title} + > + + + + #{candidate.githubPrNumber} + {candidate.state} + {candidate.laneRole === "previous" ? previous : null} + + {candidate.title || "Untitled pull request"} + + + + + + + ))} + + + + ); + } + const pr = primaryPr; const stateBadge = getPrStateBadge(pr.state); const hasChecks = pr.checksStatus != null; const hasReviews = pr.reviewStatus != null; diff --git a/apps/desktop/src/renderer/components/lanes/LaneWorkPane.tsx b/apps/desktop/src/renderer/components/lanes/LaneWorkPane.tsx index 2e05552de..acad5ae17 100644 --- a/apps/desktop/src/renderer/components/lanes/LaneWorkPane.tsx +++ b/apps/desktop/src/renderer/components/lanes/LaneWorkPane.tsx @@ -1,17 +1,73 @@ import { useEffect, useMemo } from "react"; -import type { LaneLinearIssue } from "../../../shared/types"; +import { useNavigate } from "react-router-dom"; +import { GitPullRequest } from "@phosphor-icons/react"; +import type { LaneLinearIssue, LaneSummary, PrSummary } from "../../../shared/types"; import { EmptyState } from "../ui/EmptyState"; import { SANS_FONT } from "./laneDesignTokens"; import { WorkViewArea } from "../terminals/WorkViewArea"; import { dispatchWorkSurfaceRevealed } from "../terminals/workSurfaceVisibility"; import { useLaneWorkSessions } from "./useLaneWorkSessions"; +import { buildPrsRouteSearch } from "../prs/prsRouteState"; +import { lanePrStateColor, lanePrStateLabel } from "../../lib/lanePrBadge"; +import { getPrCiDotColor, getPrReviewDotColor } from "../prs/shared/prVisuals"; +import { branchNameFromLaneRef } from "../../../shared/laneBaseResolution"; + +function LanePullRequestsSection({ lane, prs }: { lane: LaneSummary | null; prs: PrSummary[] }) { + const navigate = useNavigate(); + if (!lane || prs.length === 0) return null; + const laneBranch = branchNameFromLaneRef(lane.branchRef); + return ( +
+
+ + Pull requests ({prs.length}) +
+
+ {prs.map((pr) => { + const active = branchNameFromLaneRef(pr.headBranch) === laneBranch; + const stateColor = lanePrStateColor(pr.state); + const ciColor = getPrCiDotColor({ checksStatus: pr.checksStatus }); + const reviewColor = getPrReviewDotColor({ reviewStatus: pr.reviewStatus }); + return ( + + ); + })} +
+
+ ); +} export function LaneWorkPane({ laneId, + lanePrs = [], initialLinearIssueContext = null, onInitialLinearIssueContextConsumed, }: { laneId: string | null; + lanePrs?: PrSummary[]; initialLinearIssueContext?: LaneLinearIssue | null; onInitialLinearIssueContextConsumed?: () => void; }) { @@ -49,6 +105,7 @@ export function LaneWorkPane({ return (
+
{ expect(selectLanePrTag(makeLane(), [mergedPr])).toBe(mergedPr); }); - it("ignores PR rows whose head branch no longer matches the lane branch", () => { + it("retains a PR row as lane history when its head branch moved on", () => { const stalePr = makePr({ state: "merged", headBranch: "ade/old-pr-state", }); - expect(selectLanePrTag(makeLane(), [stalePr])).toBeNull(); + expect(selectLanePrTag(makeLane(), [stalePr])).toBe(stalePr); + }); + + it("orders active PRs before previous history while retaining both", () => { + const previous = makePr({ + id: "previous-pr", + state: "merged", + headBranch: "ade/old-pr-state", + }); + const active = makePr({ + id: "active-pr", + state: "open", + headBranch: "ade/pr-state", + }); + + expect(selectLanePrs(makeLane(), [previous, active]).map((pr) => pr.id)).toEqual([ + "active-pr", + "previous-pr", + ]); + expect(lanePrRole(makeLane(), previous)).toBe("previous"); + expect(lanePrRole(makeLane(), active)).toBe("active"); }); it("ignores PR rows when either side has no branch to compare", () => { diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index 44e4d203a..b35ba41d6 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -51,9 +51,9 @@ import { resolveLaneIdsDeepLinkSelection, resolveVisibleLaneIds, runLaneDeleteBatchWithConcurrency, - selectLanePrTag, + selectLanePrs, selectVisibleLanePrRefreshIds, - selectLaneTabPrTag, + selectLaneTabPrTags, shouldApplyLaneIdsDeepLink, sortLaneListRows, type LaneTabPrTag, @@ -644,11 +644,19 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { const lanePrByLaneId = useMemo(() => { const map = new Map(); for (const lane of sortedLanes) { - const pr = selectLaneTabPrTag(lane, lanePrTags, laneGithubPrTags); + const pr = selectLaneTabPrTags(lane, lanePrTags, laneGithubPrTags)[0] ?? null; if (pr) map.set(lane.id, pr); } return map; }, [sortedLanes, lanePrTags, laneGithubPrTags]); + const lanePrTagsByLaneId = useMemo(() => { + const map = new Map(); + for (const lane of sortedLanes) { + const tags = selectLaneTabPrTags(lane, lanePrTags, laneGithubPrTags); + if (tags.length > 0) map.set(lane.id, tags); + } + return map; + }, [sortedLanes, lanePrTags, laneGithubPrTags]); const laneRuntimeById = useMemo(() => { const summaryByLane = new Map(); @@ -876,9 +884,9 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { setLanePrTags(prs); if (options?.refreshMapped !== true) return; - const matchedPrIds = sortedLanesRef.current - .map((lane) => selectLanePrTag(lane, prs)?.id ?? null) - .filter((prId): prId is string => Boolean(prId)); + const matchedPrIds = [...new Set(sortedLanesRef.current.flatMap((lane) => ( + selectLanePrs(lane, prs).map((pr) => pr.id) + )))]; if (matchedPrIds.length === 0) return; try { @@ -1113,7 +1121,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { const nowMs = Date.now(); const prIds = selectVisibleLanePrRefreshIds({ visibleLaneIds, - lanePrByLaneId, + lanePrByLaneId: lanePrTagsByLaneId, prs: lanePrTags, recentlyRequestedAtByPrId: laneVisiblePrRefreshRequestedAtRef.current, nowMs, @@ -1143,7 +1151,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { getActiveProjectRoot, activeProjectRoot, visibleLaneIds, - lanePrByLaneId, + lanePrTagsByLaneId, lanePrTags, laneVisiblePrRefreshVisibilityToken, ]); @@ -2439,6 +2447,8 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { laneId && linearIssueChatContextRequest?.laneId === laneId ? linearIssueChatContextRequest : null; + const lane = laneId ? lanesById.get(laneId) ?? null : null; + const lanePrs = lane ? selectLanePrs(lane, lanePrTags) : []; const mountGitActionsPane = shouldMountGitActionsPane({ laneId, expandedGitActionsLaneId, @@ -2512,6 +2522,7 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { { - if (lanePr.linkedPrId) { + prs={lanePrs} + onOpenList={() => navigate(`/prs${buildPrsRouteSearch({ + activeTab: "normal", + selectedPrId: null, + selectedLaneId: lane.id, + selectedRebaseItemId: null, + })}`)} + onActivate={(_event, selectedPr) => { + const target = selectedPr ?? lanePr; + if (target.linkedPrId) { navigate(`/prs${buildPrsRouteSearch({ activeTab: "normal", - selectedPrId: lanePr.linkedPrId, + selectedPrId: target.linkedPrId, selectedRebaseItemId: null, })}`); return; } - if (lanePr.githubUrl && isTrustedGitHubUrl(lanePr.githubUrl)) { - void window.ade?.app?.openExternal?.(lanePr.githubUrl); + if (target.githubUrl && isTrustedGitHubUrl(target.githubUrl)) { + void window.ade?.app?.openExternal?.(target.githubUrl); } }} /> diff --git a/apps/desktop/src/renderer/components/lanes/lanePageModel.ts b/apps/desktop/src/renderer/components/lanes/lanePageModel.ts index 97db9604d..533429bb5 100644 --- a/apps/desktop/src/renderer/components/lanes/lanePageModel.ts +++ b/apps/desktop/src/renderer/components/lanes/lanePageModel.ts @@ -32,6 +32,8 @@ export type LaneTabPrTag = { githubUrl: string; title: string; state: PrSummary["state"]; + /** A live PR follows the lane's current branch; previous PRs remain history on the lane. */ + laneRole?: "active" | "previous"; // Optional richer fields used to render the hover popover card. Populated when // available from the mapped PrSummary and/or the GitHub list item; merged in // `selectLaneTabPrTag` so the card gets the richest data across both sources. @@ -240,13 +242,33 @@ export function lanePrMatchesCurrentBranch( return true; } +export function lanePrRole( + lane: Pick, + pr: Pick, +): "active" | "previous" | null { + if (pr.laneId !== lane.id) return null; + if (!normalizeLanePrBranch(lane.branchRef) || !normalizeLanePrBranch(pr.headBranch)) return null; + return lanePrMatchesCurrentBranch(lane, pr) ? "active" : "previous"; +} + +export function selectLanePrs( + lane: Pick, + prs: PrSummary[], +): PrSummary[] { + return prs + .filter((pr) => !pr.detached && lanePrRole(lane, pr) != null) + .sort((a, b) => { + const aRole = lanePrRole(lane, a) === "active" ? 0 : 1; + const bRole = lanePrRole(lane, b) === "active" ? 0 : 1; + return aRole - bRole || comparePrTags(a, b); + }); +} + export function selectLanePrTag( lane: Pick, prs: PrSummary[], ): PrSummary | null { - return prs - .filter((pr) => lanePrMatchesCurrentBranch(lane, pr)) - .sort(comparePrTags)[0] ?? null; + return selectLanePrs(lane, prs)[0] ?? null; } export function githubPrMatchesCurrentBranch( @@ -271,12 +293,19 @@ export function selectGithubLanePrTag( lane: Pick, prs: GitHubPrListItem[], ): GitHubPrListItem | null { + return selectGithubLanePrTags(lane, prs)[0] ?? null; +} + +export function selectGithubLanePrTags( + lane: Pick, + prs: GitHubPrListItem[], +): GitHubPrListItem[] { return prs .filter((pr) => pr.scope === "repo" && githubPrMatchesCurrentBranch(lane, pr)) - .sort(comparePrTags)[0] ?? null; + .sort(comparePrTags); } -function toLaneTabPrTagFromPrSummary(pr: PrSummary): LaneTabPrTag { +function toLaneTabPrTagFromPrSummary(pr: PrSummary, laneRole?: "active" | "previous"): LaneTabPrTag { return { source: "ade", id: pr.id, @@ -285,6 +314,7 @@ function toLaneTabPrTagFromPrSummary(pr: PrSummary): LaneTabPrTag { githubUrl: pr.githubUrl, title: pr.title, state: pr.state, + laneRole, baseBranch: pr.baseBranch, headBranch: pr.headBranch, checksStatus: pr.checksStatus, @@ -299,7 +329,11 @@ function toLaneTabPrTagFromPrSummary(pr: PrSummary): LaneTabPrTag { }; } -function toLaneTabPrTagFromGithubItem(pr: GitHubPrListItem, laneId: string): LaneTabPrTag { +function toLaneTabPrTagFromGithubItem( + pr: GitHubPrListItem, + laneId: string, + laneRole: "active" | "previous" = "active", +): LaneTabPrTag { const linkedPrId = pr.linkedLaneId === laneId ? pr.linkedPrId : null; return { source: "github", @@ -309,6 +343,7 @@ function toLaneTabPrTagFromGithubItem(pr: GitHubPrListItem, laneId: string): Lan githubUrl: pr.githubUrl, title: pr.title, state: pr.isDraft ? "draft" : pr.state, + laneRole, baseBranch: pr.baseBranch, headBranch: pr.headBranch, updatedAt: pr.updatedAt, @@ -344,6 +379,23 @@ function mergeLaneTabPrTags(base: LaneTabPrTag, secondary: LaneTabPrTag | null): }; } +function compareLaneTabPrTags(a: LaneTabPrTag, b: LaneTabPrTag): number { + const aRole = a.laneRole === "active" ? 0 : 1; + const bRole = b.laneRole === "active" ? 0 : 1; + if (aRole !== bRole) return aRole - bRole; + 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; + } + // Preserve ADE's richer mapped tag when an unlinked GitHub projection ties + // it on state and recency. + if (a.source !== b.source) return a.source === "ade" ? -1 : 1; + return b.githubPrNumber - a.githubPrNumber; +} + function githubPrMatchesAdePr(pr: PrSummary, githubPr: GitHubPrListItem): boolean { return ( githubPr.linkedPrId === pr.id || @@ -385,25 +437,41 @@ export function selectLaneTabPrTag( prs: PrSummary[], githubPrs: GitHubPrListItem[], ): LaneTabPrTag | null { - const mappedPr = selectLanePrTag(lane, prs); - const githubPr = selectGithubLanePrTag(lane, githubPrs); - if (mappedPr) { + return selectLaneTabPrTags(lane, prs, githubPrs)[0] ?? null; +} + +export function selectLaneTabPrTags( + lane: Pick, + prs: PrSummary[], + githubPrs: GitHubPrListItem[], +): LaneTabPrTag[] { + const mappedPrs = selectLanePrs(lane, prs); + const laneGithubPrs = selectGithubLanePrTags(lane, githubPrs); + const mappedTags = mappedPrs.map((mappedPr) => { + const githubPr = laneGithubPrs.find((candidate) => githubPrMatchesAdePr(mappedPr, candidate)) ?? null; // The PrSummary carries diff/checks/reviews; the matching GitHub item carries // labels/author. Merge so the popover card has the richest data available. - const githubTag = githubPr ? toLaneTabPrTagFromGithubItem(githubPr, lane.id) : null; + const role = lanePrRole(lane, mappedPr) ?? "previous"; + const githubTag = githubPr ? toLaneTabPrTagFromGithubItem(githubPr, lane.id, role) : null; const terminalGithubPr = selectTerminalGithubUpdateForPr(mappedPr, githubPrs); if (terminalGithubPr) { return mergeLaneTabPrTags( - toLaneTabPrTagFromGithubItem(terminalGithubPr, lane.id), - toLaneTabPrTagFromPrSummary(mappedPr), + toLaneTabPrTagFromGithubItem(terminalGithubPr, lane.id, role), + toLaneTabPrTagFromPrSummary(mappedPr, role), ); } if (githubTag && shouldPreferGithubPrTag(mappedPr, githubPr!)) { - return mergeLaneTabPrTags(githubTag, toLaneTabPrTagFromPrSummary(mappedPr)); + return mergeLaneTabPrTags(githubTag, toLaneTabPrTagFromPrSummary(mappedPr, role)); } - return mergeLaneTabPrTags(toLaneTabPrTagFromPrSummary(mappedPr), githubTag); - } - return githubPr ? toLaneTabPrTagFromGithubItem(githubPr, lane.id) : null; + return mergeLaneTabPrTags(toLaneTabPrTagFromPrSummary(mappedPr, role), githubTag); + }); + const mappedGithubKeys = new Set(mappedPrs.map((mappedPr) => ( + laneGithubPrs.find((candidate) => githubPrMatchesAdePr(mappedPr, candidate))?.id + )).filter((id): id is string => Boolean(id))); + const unmappedTags = laneGithubPrs + .filter((githubPr) => !mappedGithubKeys.has(githubPr.id)) + .map((githubPr) => toLaneTabPrTagFromGithubItem(githubPr, lane.id, "active")); + return [...mappedTags, ...unmappedTags].sort(compareLaneTabPrTags); } function isPrRefreshStale(pr: Pick, nowMs: number, staleMs: number): boolean { @@ -413,7 +481,7 @@ function isPrRefreshStale(pr: Pick, nowMs: number, st export function selectVisibleLanePrRefreshIds(args: { visibleLaneIds: string[]; - lanePrByLaneId: ReadonlyMap; + lanePrByLaneId: ReadonlyMap; prs: PrSummary[]; recentlyRequestedAtByPrId?: ReadonlyMap; nowMs?: number; @@ -430,17 +498,22 @@ export function selectVisibleLanePrRefreshIds(args: { const seen = new Set(); for (const laneId of args.visibleLaneIds) { - const prId = args.lanePrByLaneId.get(laneId)?.linkedPrId; - if (!prId || seen.has(prId)) continue; - seen.add(prId); + const entry = args.lanePrByLaneId.get(laneId); + const tags = Array.isArray(entry) ? entry : entry ? [entry] : []; + for (const tag of tags) { + const prId = tag.linkedPrId; + if (!prId || seen.has(prId)) continue; + seen.add(prId); - const pr = prById.get(prId); - if (!pr || !isPrRefreshStale(pr, nowMs, staleMs)) continue; + const pr = prById.get(prId); + if (!pr || !isPrRefreshStale(pr, nowMs, staleMs)) continue; - const requestedAt = args.recentlyRequestedAtByPrId?.get(prId); - if (requestedAt != null && nowMs - requestedAt < staleMs) continue; + const requestedAt = args.recentlyRequestedAtByPrId?.get(prId); + if (requestedAt != null && nowMs - requestedAt < staleMs) continue; - selected.push(prId); + selected.push(prId); + if (selected.length >= limit) break; + } if (selected.length >= limit) break; } diff --git a/apps/desktop/src/renderer/components/terminals/CliSessionWorkSurfaceHeader.tsx b/apps/desktop/src/renderer/components/terminals/CliSessionWorkSurfaceHeader.tsx index 88dc148c5..d79ff5d51 100644 --- a/apps/desktop/src/renderer/components/terminals/CliSessionWorkSurfaceHeader.tsx +++ b/apps/desktop/src/renderer/components/terminals/CliSessionWorkSurfaceHeader.tsx @@ -202,7 +202,7 @@ export function GridTileSessionHeaderActions({ }) { return (
- {session.laneId ? : null} + {session.laneId ? : null} @@ -286,6 +286,7 @@ export function CliSessionWorkSurfaceHeader({ showCacheBadge={showCache} cacheIdleSinceAt={session.chatIdleSinceAt} showGitToolbar + prSessionId={session.id} onTogglePrPane={onTogglePrPane} prPaneOpen={prPaneOpen} runtimePin={runtimePin} diff --git a/apps/desktop/src/renderer/components/terminals/LanePrBadge.test.tsx b/apps/desktop/src/renderer/components/terminals/LanePrBadge.test.tsx new file mode 100644 index 000000000..04f3804d5 --- /dev/null +++ b/apps/desktop/src/renderer/components/terminals/LanePrBadge.test.tsx @@ -0,0 +1,68 @@ +/* @vitest-environment jsdom */ + +import React from "react"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { describe, expect, it, vi } from "vitest"; +import type { PrSummary } from "../../../shared/types"; +import { LanePrBadge } from "./LanePrBadge"; + +function pr(overrides: Partial = {}): PrSummary { + return { + id: "pr-1", + laneId: "lane-1", + projectId: "project-1", + repoOwner: "ade", + repoName: "desktop", + githubPrNumber: 101, + githubUrl: "https://github.com/ade/desktop/pull/101", + githubNodeId: null, + title: "Current work", + state: "open", + baseBranch: "main", + headBranch: "current", + checksStatus: "passing", + reviewStatus: "approved", + additions: 1, + deletions: 0, + lastSyncedAt: null, + createdAt: "2026-08-01T00:00:00.000Z", + updatedAt: "2026-08-02T00:00:00.000Z", + ...overrides, + }; +} + +describe("LanePrBadge", () => { + it("keeps a single PR as the compact chip", () => { + render(); + + expect(screen.getByRole("button", { name: /Pull request #101/ })).toBeTruthy(); + expect(screen.queryByRole("button", { name: /other pull requests/ })).toBeNull(); + }); + + it("shows a counter and lets the hover list open a specific PR", () => { + const onOpen = vi.fn(); + const onOpenList = vi.fn(); + const previous = pr({ + id: "pr-100", + githubPrNumber: 100, + title: "Previous work", + state: "merged", + headBranch: "previous", + }); + render( + , + ); + + expect(screen.getByRole("button", { name: "Open 2 pull requests for this lane" })).toBeTruthy(); + fireEvent.click(screen.getByTitle("Pull request #100 · Merged · Previous work")); + expect(onOpen).toHaveBeenCalledWith(previous); + + fireEvent.click(screen.getByRole("button", { name: "Open 2 pull requests for this lane" })); + expect(onOpenList).toHaveBeenCalledTimes(1); + }); +}); diff --git a/apps/desktop/src/renderer/components/terminals/LanePrBadge.tsx b/apps/desktop/src/renderer/components/terminals/LanePrBadge.tsx index 74216c8e4..9f0c41247 100644 --- a/apps/desktop/src/renderer/components/terminals/LanePrBadge.tsx +++ b/apps/desktop/src/renderer/components/terminals/LanePrBadge.tsx @@ -1,53 +1,183 @@ import React from "react"; import type { PrSummary } from "../../../shared/types"; -import { lanePrStateColor, lanePrStateLabel } from "../../lib/lanePrBadge"; +import { cn } from "../ui/cn"; +import { + lanePrAggregateAttention, + lanePrAttention, + lanePrAttentionColor, + lanePrStateColor, + lanePrStateLabel, +} from "../../lib/lanePrBadge"; import { GitHubStackBadge } from "../prs/shared/GitHubStackBadge"; +import { getPrCiDotColor, getPrReviewDotColor } from "../prs/shared/prVisuals"; + +function StatusDot({ color, title }: { color: string; title?: string }) { + return ( + + ); +} + +function prTitle(pr: PrSummary): string { + return `Pull request #${pr.githubPrNumber} · ${lanePrStateLabel(pr.state)}${pr.title ? ` · ${pr.title}` : ""}`; +} /** - * Compact PR status chip for a lane in the Work sidebar: state-coloured dot + - * `#` + one-word state. Clicking deep-links into the PRs tab. - * - * Shared deliberately, not copied. "Does this lane have a PR, and where is it - * at" has to survive every place the lane header is minimized or absent — the - * full divider, a collapsed quiet divider, and (with no divider at all) the - * singleton lane's lone card. Three copies of this markup would drift into - * three different PR badges; one component cannot. - * - * `span role="button"`, never a native ` - + {onOpenList ? ( + + ) : ( + + +{allPrs.length - 1} + + )} diff --git a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx index b35ba41d6..01ced55a7 100644 --- a/apps/desktop/src/renderer/components/lanes/LanesPage.tsx +++ b/apps/desktop/src/renderer/components/lanes/LanesPage.tsx @@ -56,6 +56,7 @@ import { selectLaneTabPrTags, shouldApplyLaneIdsDeepLink, sortLaneListRows, + VISIBLE_LANE_PR_REFRESH_LIMIT, type LaneTabPrTag, } from "./lanePageModel"; import { @@ -884,8 +885,14 @@ export function LanesPage({ active = true }: { active?: boolean } = {}) { setLanePrTags(prs); if (options?.refreshMapped !== true) return; + // Lane history is intentionally retained for rendering, but refreshing + // every historical row makes a growing lane fan out into an unbounded + // coalescer request. `selectLanePrs` puts the active PR first, so this + // keeps the current row plus a small, useful history window per lane. const matchedPrIds = [...new Set(sortedLanesRef.current.flatMap((lane) => ( - selectLanePrs(lane, prs).map((pr) => pr.id) + selectLanePrs(lane, prs) + .slice(0, VISIBLE_LANE_PR_REFRESH_LIMIT) + .map((pr) => pr.id) )))]; if (matchedPrIds.length === 0) return; diff --git a/apps/desktop/src/renderer/components/terminals/LanePrBadge.test.tsx b/apps/desktop/src/renderer/components/terminals/LanePrBadge.test.tsx index 04f3804d5..b8de95706 100644 --- a/apps/desktop/src/renderer/components/terminals/LanePrBadge.test.tsx +++ b/apps/desktop/src/renderer/components/terminals/LanePrBadge.test.tsx @@ -5,6 +5,8 @@ import { fireEvent, render, screen } from "@testing-library/react"; import { describe, expect, it, vi } from "vitest"; import type { PrSummary } from "../../../shared/types"; import { LanePrBadge } from "./LanePrBadge"; +import { LanePrBadgePopover } from "../lanes/LanePrBadgePopover"; +import type { LaneTabPrTag } from "../lanes/lanePageModel"; function pr(overrides: Partial = {}): PrSummary { return { @@ -31,6 +33,21 @@ function pr(overrides: Partial = {}): PrSummary { }; } +function tag(overrides: Partial = {}): LaneTabPrTag { + return { + source: "ade", + id: "pr-1", + linkedPrId: "pr-1", + githubPrNumber: 101, + githubUrl: "https://github.com/ade/desktop/pull/101", + title: "Current work", + state: "open", + checksStatus: "passing", + reviewStatus: "approved", + ...overrides, + }; +} + describe("LanePrBadge", () => { it("keeps a single PR as the compact chip", () => { render(); @@ -65,4 +82,29 @@ describe("LanePrBadge", () => { fireEvent.click(screen.getByRole("button", { name: "Open 2 pull requests for this lane" })); expect(onOpenList).toHaveBeenCalledTimes(1); }); + + it("announces each multi-PR row's CI and review status", () => { + render( + , + ); + + expect(screen.getByRole("img", { name: "CI failing; Review changes requested" })).toBeTruthy(); + }); + + it("keeps a popover count non-interactive without a list handler", () => { + render( + , + ); + + const count = screen.getByTitle("Hover to inspect all pull requests for this lane"); + expect(count.tagName).toBe("SPAN"); + expect(count.getAttribute("role")).toBeNull(); + }); }); diff --git a/apps/desktop/src/renderer/components/terminals/LanePrBadge.tsx b/apps/desktop/src/renderer/components/terminals/LanePrBadge.tsx index 9f0c41247..0d045c476 100644 --- a/apps/desktop/src/renderer/components/terminals/LanePrBadge.tsx +++ b/apps/desktop/src/renderer/components/terminals/LanePrBadge.tsx @@ -26,6 +26,25 @@ function prTitle(pr: PrSummary): string { return `Pull request #${pr.githubPrNumber} · ${lanePrStateLabel(pr.state)}${pr.title ? ` · ${pr.title}` : ""}`; } +function checksStatusLabel(status: PrSummary["checksStatus"]): string { + switch (status) { + case "passing": return "CI passing"; + case "failing": return "CI failing"; + case "pending": return "CI running"; + case "not_run": return "CI not run"; + default: return "CI unavailable"; + } +} + +function reviewStatusLabel(status: PrSummary["reviewStatus"]): string { + switch (status) { + case "approved": return "Review approved"; + case "changes_requested": return "Review changes requested"; + case "requested": return "Review requested"; + default: return "Review unavailable"; + } +} + /** * Compact lane PR cluster. The one-PR branch intentionally keeps the existing * chip shape; the multi-PR branch adds only a counter and a hover list, so a @@ -163,7 +182,11 @@ export function LanePrBadge({ {candidate.title || "Untitled pull request"} - + { })); }); + it("joins stack membership by repository and PR number", () => { + const result = buildLanePrsByLaneId({ + lanes: [lane()], + prs: [mappedPr()], + githubPrs: [ + githubPr({ + id: "other-repo-pr-91", + repoOwner: "other-owner", + repoName: "other-repo", + stack: { id: "wrong-stack", number: 99, size: 2, position: 1, baseBranch: "main" }, + }), + githubPr({ + stack: { id: "stack-18", number: 18, size: 3, position: 2, baseBranch: "main" }, + }), + ], + }); + + expect(result.get("lane-1")?.[0]?.stack).toEqual(expect.objectContaining({ number: 18 })); + }); + it("keeps a new GitHub-only PR visible alongside retained lane history", () => { const result = buildLanePrsByLaneId({ lanes: [lane()], diff --git a/apps/desktop/src/renderer/components/terminals/useLanePrs.ts b/apps/desktop/src/renderer/components/terminals/useLanePrs.ts index 64c4c238f..4ae1954a5 100644 --- a/apps/desktop/src/renderer/components/terminals/useLanePrs.ts +++ b/apps/desktop/src/renderer/components/terminals/useLanePrs.ts @@ -74,7 +74,11 @@ export function buildLanePrsByLaneId(args: { const mapped = selectLanePrs(lane, args.prs) .map((pr) => ({ ...pr, - stack: githubPrs.find((githubPr) => githubPr.githubPrNumber === pr.githubPrNumber)?.stack + stack: githubPrs.find((githubPr) => ( + githubPr.githubPrNumber === pr.githubPrNumber + && githubPr.repoOwner.toLowerCase() === pr.repoOwner.toLowerCase() + && githubPr.repoName.toLowerCase() === pr.repoName.toLowerCase() + ))?.stack ?? pr.stack ?? null, })); diff --git a/apps/desktop/src/renderer/lib/prChatScope.ts b/apps/desktop/src/renderer/lib/prChatScope.ts index 22746b3d5..b8fe3b2cd 100644 --- a/apps/desktop/src/renderer/lib/prChatScope.ts +++ b/apps/desktop/src/renderer/lib/prChatScope.ts @@ -3,15 +3,16 @@ import type { PrSummary } from "../../shared/types"; /** * Scope a lane's PR set to one chat without reviving the old lane-wide * cross-talk. Rows with no edge are legacy data and may use the lane fallback; - * once any row has an explicit edge, an unlinked chat must see no PRs rather - * than another chat's work. + * that fallback is decided per PR so one linked row does not hide every older + * row in the same lane. */ export function selectPrsForChat( prs: readonly PrSummary[], sessionId?: string | null, ): PrSummary[] { if (!sessionId) return [...prs]; - const hasExplicitLinks = prs.some((pr) => (pr.chatSessionIds?.length ?? 0) > 0); - if (!hasExplicitLinks) return [...prs]; - return prs.filter((pr) => pr.chatSessionIds?.includes(sessionId) === true); + return prs.filter((pr) => { + const linkedSessionIds = pr.chatSessionIds?.filter(Boolean) ?? []; + return linkedSessionIds.length === 0 || linkedSessionIds.includes(sessionId); + }); } diff --git a/apps/ios/ADE/Services/Database.swift b/apps/ios/ADE/Services/Database.swift index 7882b9233..56ddad903 100644 --- a/apps/ios/ADE/Services/Database.swift +++ b/apps/ios/ADE/Services/Database.swift @@ -2979,20 +2979,6 @@ final class DatabaseService { try ensureColumn(tableName: "integration_proposals", columnName: "merge_into_head_sha", definition: "text") try exec("create index if not exists idx_pull_requests_project_updated on pull_requests(project_id, updated_at desc)") - try exec(""" - create table if not exists pull_request_chat_sessions ( - id text primary key, - project_id text not null, - pr_id text not null, - lane_id text not null, - session_id text not null, - created_at text not null, - updated_at text not null - ) - """) - try exec("create index if not exists idx_pull_request_chat_sessions_pr on pull_request_chat_sessions(project_id, pr_id)") - try exec("create index if not exists idx_pull_request_chat_sessions_session on pull_request_chat_sessions(project_id, session_id)") - try exec("create index if not exists idx_pull_request_chat_sessions_lane on pull_request_chat_sessions(project_id, lane_id)") try ensureColumn(tableName: "worker_agents", columnName: "linear_identity_json", definition: "text not null default '{}'") }