From 3bcf87263ea997a6984267134a2138ff1cffa680 Mon Sep 17 00:00:00 2001 From: Arul Sharma <31745423+arul28@users.noreply.github.com> Date: Fri, 24 Apr 2026 16:57:15 -0400 Subject: [PATCH 1/2] desktop + iOS: PR convergence rails, onboarding tour revamp, terminals session overhaul --- .../src/main/services/ipc/registerIpc.ts | 6 + .../prs/issueInventoryService.test.ts | 102 + .../services/prs/issueInventoryService.ts | 9 +- .../main/services/prs/prIssueResolver.test.ts | 2 + .../src/main/services/prs/prIssueResolver.ts | 4 +- .../src/main/services/prs/prService.ts | 84 + .../src/main/services/prs/resolverUtils.ts | 26 + apps/desktop/src/preload/global.d.ts | 7 + apps/desktop/src/preload/preload.ts | 9 + .../src/renderer/components/app/AppShell.tsx | 151 +- .../src/renderer/components/app/TabNav.tsx | 6 +- .../components/graph/WorkspaceGraphPage.tsx | 38 +- .../renderer/components/lanes/LanesPage.tsx | 95 +- .../components/onboarding/DidYouKnow.tsx | 6 +- .../components/onboarding/HelpMenu.tsx | 11 +- .../components/onboarding/tour/TourHost.tsx | 6 +- .../onboarding/tour/TourOverlay.tsx | 36 +- .../src/renderer/components/prs/PRsPage.tsx | 26 +- .../components/prs/detail/PrDetailPane.tsx | 116 +- .../prs/detail/PrDetailTimelineRails.tsx | 48 +- .../components/prs/prsRouteState.test.ts | 42 +- .../renderer/components/prs/prsRouteState.ts | 47 + .../prs/shared/PrConvergencePanel.tsx | 67 +- .../prs/shared/PrLaneCleanupBanner.tsx | 110 +- .../components/prs/shared/PrTimeline.test.tsx | 39 + .../components/prs/shared/PrTimeline.tsx | 110 +- .../components/prs/tabs/GitHubTab.tsx | 17 +- .../components/terminals/SessionCard.tsx | 40 +- .../terminals/SessionListPane.test.tsx | 50 +- .../components/terminals/SessionListPane.tsx | 103 +- .../components/terminals/TerminalsPage.tsx | 155 +- .../components/terminals/WorkStartSurface.tsx | 8 +- .../renderer/components/ui/SmartTooltip.tsx | 4 +- apps/desktop/src/renderer/index.css | 2 + apps/desktop/src/renderer/lib/sessions.ts | 16 + .../stepBuilders/createLaneDialog.ts | 34 +- .../onboarding/stepBuilders/gitActionsPane.ts | 12 +- .../stepBuilders/manageLaneDialog.ts | 5 +- .../onboarding/tours/automationsTour.ts | 25 +- .../src/renderer/onboarding/tours/ctoTour.ts | 25 +- .../onboarding/tours/firstJourneyTour.ts | 130 +- .../renderer/onboarding/tours/graphTour.ts | 25 +- .../renderer/onboarding/tours/historyTour.ts | 25 +- .../onboarding/tours/laneWorkPaneTour.ts | 16 +- .../renderer/onboarding/tours/lanesTour.ts | 24 +- .../src/renderer/onboarding/tours/prsTour.ts | 34 +- .../src/renderer/onboarding/tours/runTour.ts | 2 +- .../renderer/onboarding/tours/settingsTour.ts | 32 +- .../src/renderer/state/onboardingStore.ts | 7 + apps/desktop/src/shared/ipc.ts | 2 + apps/desktop/src/shared/types/prs.ts | 16 + apps/ios/ADE/Models/RemoteModels.swift | 42 + apps/ios/ADE/Services/SyncService.swift | 119 + .../AttentionDrawerButton.swift | 46 +- .../AttentionDrawerSheet.swift | 104 +- .../Views/Components/ADEDesignSystem.swift | 228 +- apps/ios/ADE/Views/Files/FilesHelpers.swift | 2 +- .../ADE/Views/PRs/CreatePrWizardView.swift | 838 +- .../ADE/Views/PRs/PrAiResolverCtaCard.swift | 148 +- apps/ios/ADE/Views/PRs/PrAiSummaryCard.swift | 135 +- apps/ios/ADE/Views/PRs/PrCommitRailView.swift | 111 +- .../ADE/Views/PRs/PrDetailActivityTab.swift | 265 +- .../ios/ADE/Views/PRs/PrDetailChecksTab.swift | 315 +- apps/ios/ADE/Views/PRs/PrDetailFilesTab.swift | 267 +- .../ADE/Views/PRs/PrDetailOverviewTab.swift | 988 +- apps/ios/ADE/Views/PRs/PrDetailScreen.swift | 1087 +- apps/ios/ADE/Views/PRs/PrFiltersCard.swift | 404 +- apps/ios/ADE/Views/PRs/PrHelpers.swift | 227 +- .../ios/ADE/Views/PRs/PrListRowModifier.swift | 537 +- apps/ios/ADE/Views/PRs/PrMergeGateCard.swift | 249 +- apps/ios/ADE/Views/PRs/PrModels.swift | 20 - apps/ios/ADE/Views/PRs/PrRebaseScreen.swift | 799 +- apps/ios/ADE/Views/PRs/PrRowCard.swift | 150 +- .../ADE/Views/PRs/PrStackDiagramView.swift | 137 +- apps/ios/ADE/Views/PRs/PrStackSheet.swift | 174 +- apps/ios/ADE/Views/PRs/PrWorkflowCards.swift | 124 +- apps/ios/ADE/Views/PRs/PrsRootScreen.swift | 1031 +- .../Settings/NotificationsCenterView.swift | 32 +- .../ADELiveActivityPrimitives.swift | 98 +- .../ios/ADEWidgets/ADELiveActivityViews.swift | 110 +- .../ADEWidgets/ADEWorkspaceWidgetViews.swift | 50 +- .../onboarding-and-settings/README.md | 14 + docs/features/pull-requests/README.md | 83 +- .../sync-and-multi-device/ios-companion.md | 35 +- .../features/terminals-and-sessions/README.md | 17 +- .../terminals-and-sessions/ui-surfaces.md | 41 + untitled.pen | 17764 ++++++++++++++++ 87 files changed, 26797 insertions(+), 2006 deletions(-) create mode 100644 untitled.pen diff --git a/apps/desktop/src/main/services/ipc/registerIpc.ts b/apps/desktop/src/main/services/ipc/registerIpc.ts index dafcf12bf..79b4bdcd4 100644 --- a/apps/desktop/src/main/services/ipc/registerIpc.ts +++ b/apps/desktop/src/main/services/ipc/registerIpc.ts @@ -151,7 +151,10 @@ import type { LandResult, LandStackEnhancedArgs, LandQueueNextArgs, + CleanupPrBranchArgs, + CleanupPrBranchResult, PrCheck, + PrCommit, PrComment, PrReviewThread, PrHealth, @@ -5878,6 +5881,7 @@ export function registerIpc({ ipcMain.handle(IPC.prsGetDetail, (_e, args: { prId: string }) => getCtx().prService.getDetail(args.prId)); ipcMain.handle(IPC.prsGetFiles, (_e, args: { prId: string }) => getCtx().prService.getFiles(args.prId)); + ipcMain.handle(IPC.prsGetCommits, (_e, args: { prId: string }): Promise => getCtx().prService.getCommits(args.prId)); ipcMain.handle(IPC.prsGetActionRuns, (_e, args: { prId: string }) => getCtx().prService.getActionRuns(args.prId)); ipcMain.handle(IPC.prsGetActivity, (_e, args: { prId: string }) => getCtx().prService.getActivity(args.prId)); ipcMain.handle(IPC.prsAddComment, (_e, args) => getCtx().prService.addComment(args)); @@ -5930,6 +5934,8 @@ export function registerIpc({ ); }, ); + ipcMain.handle(IPC.prsCleanupBranch, (_e, args: CleanupPrBranchArgs): Promise => + getCtx().prService.cleanupBranch(args)); // Issue Inventory (PR convergence loop) ipcMain.handle(IPC.prsIssueInventorySync, async (_e, args: { prId: string }): Promise => { diff --git a/apps/desktop/src/main/services/prs/issueInventoryService.test.ts b/apps/desktop/src/main/services/prs/issueInventoryService.test.ts index 8a215522a..e261f3322 100644 --- a/apps/desktop/src/main/services/prs/issueInventoryService.test.ts +++ b/apps/desktop/src/main/services/prs/issueInventoryService.test.ts @@ -288,6 +288,108 @@ describe("issueInventoryService", () => { expect(insertCalls.length).toBe(0); }); + it("treats unresolved threads with a latest resolution acknowledgement as fixed", () => { + const db = makeMockDb(); + db.get.mockReturnValue(makeFakeRow({ + external_id: "thread:thread-ack", + state: "new", + thread_comment_count: 1, + thread_latest_comment_id: "comment-1", + })); + db.all.mockReturnValue([makeFakeRow({ + external_id: "thread:thread-ack", + state: "new", + thread_comment_count: 1, + thread_latest_comment_id: "comment-1", + })]); + + const service = createIssueInventoryService({ db }); + service.syncFromPrData( + PR_ID, + [], + [makeReviewThread({ + id: "thread-ack", + comments: [ + { + id: "comment-1", + author: "reviewer", + authorAvatarUrl: null, + body: "This needs a null check.", + url: null, + createdAt: "2026-03-23T12:00:00.000Z", + updatedAt: "2026-03-23T12:00:00.000Z", + }, + { + id: "comment-2", + author: "arul28", + authorAvatarUrl: null, + body: "Fixed in the latest commit.", + url: null, + createdAt: "2026-03-23T12:10:00.000Z", + updatedAt: "2026-03-23T12:10:00.000Z", + }, + ], + })], + [], + ); + + const updateCalls = db.run.mock.calls.filter( + (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("update pr_issue_inventory"), + ); + expect(updateCalls.some((call: unknown[]) => (call[1] as unknown[])[8] === "fixed")).toBe(true); + }); + + it("does not treat negative resolution wording as fixed", () => { + const db = makeMockDb(); + db.get.mockReturnValue(makeFakeRow({ + external_id: "thread:thread-not-fixed", + state: "new", + thread_comment_count: 1, + thread_latest_comment_id: "comment-1", + })); + db.all.mockReturnValue([makeFakeRow({ + external_id: "thread:thread-not-fixed", + state: "new", + thread_comment_count: 1, + thread_latest_comment_id: "comment-1", + })]); + + const service = createIssueInventoryService({ db }); + service.syncFromPrData( + PR_ID, + [], + [makeReviewThread({ + id: "thread-not-fixed", + comments: [ + { + id: "comment-1", + author: "reviewer", + authorAvatarUrl: null, + body: "Please tighten this logic.", + url: null, + createdAt: "2026-03-23T12:00:00.000Z", + updatedAt: "2026-03-23T12:00:00.000Z", + }, + { + id: "comment-2", + author: "reviewer", + authorAvatarUrl: null, + body: "This is not fixed yet.", + url: null, + createdAt: "2026-03-23T12:10:00.000Z", + updatedAt: "2026-03-23T12:10:00.000Z", + }, + ], + })], + [], + ); + + const updateCalls = db.run.mock.calls.filter( + (call: unknown[]) => typeof call[0] === "string" && (call[0] as string).includes("update pr_issue_inventory"), + ); + expect(updateCalls.some((call: unknown[]) => (call[1] as unknown[])[8] === "fixed")).toBe(false); + }); + it("skips outdated review threads", () => { const db = makeMockDb(); db.get.mockReturnValue(null); diff --git a/apps/desktop/src/main/services/prs/issueInventoryService.ts b/apps/desktop/src/main/services/prs/issueInventoryService.ts index d85e8eea7..4774ce530 100644 --- a/apps/desktop/src/main/services/prs/issueInventoryService.ts +++ b/apps/desktop/src/main/services/prs/issueInventoryService.ts @@ -14,7 +14,7 @@ import type { PrReviewThread, } from "../../../shared/types"; import { DEFAULT_CONVERGENCE_RUNTIME_STATE, DEFAULT_PIPELINE_SETTINGS } from "../../../shared/types"; -import { isNoisyIssueComment } from "./resolverUtils"; +import { isNoisyIssueComment, looksLikeResolutionAck } from "./resolverUtils"; import { nowIso } from "../shared/utils"; // --------------------------------------------------------------------------- @@ -740,7 +740,12 @@ export function createIssueInventoryService(deps: { db: AdeDb }) { threadLatestCommentSource: source, }; - if (thread.isResolved || thread.isOutdated) { + const latestReplyLooksResolved = source !== "coderabbit" + && source !== "copilot" + && source !== "codex" + && looksLikeResolutionAck(body); + + if (thread.isResolved || thread.isOutdated || latestReplyLooksResolved) { if (!existing) continue; upsertItem(prId, externalId, threadData, { state: "fixed", diff --git a/apps/desktop/src/main/services/prs/prIssueResolver.test.ts b/apps/desktop/src/main/services/prs/prIssueResolver.test.ts index 271f814e1..c09889f18 100644 --- a/apps/desktop/src/main/services/prs/prIssueResolver.test.ts +++ b/apps/desktop/src/main/services/prs/prIssueResolver.test.ts @@ -169,6 +169,8 @@ describe("buildPrIssueResolutionPrompt", () => { expect(prompt).toContain("Watch carefully for regressions caused by your fixes."); expect(prompt).toContain("update the test"); expect(prompt).toContain("rerun the complete failing test files or suites locally"); + expect(prompt).toContain("one bounded Path to Merge resolution round"); + expect(prompt).toContain("ADE will poll GitHub"); expect(prompt).toContain("Commit the changes and push the PR branch before you stop."); expect(prompt).toContain("If you cannot safely commit or push the necessary changes"); expect(prompt).toContain("prRefreshIssueInventory"); diff --git a/apps/desktop/src/main/services/prs/prIssueResolver.ts b/apps/desktop/src/main/services/prs/prIssueResolver.ts index 512b4481b..0b90e58a2 100644 --- a/apps/desktop/src/main/services/prs/prIssueResolver.ts +++ b/apps/desktop/src/main/services/prs/prIssueResolver.ts @@ -514,7 +514,9 @@ export function buildPrIssueResolutionPrompt(args: IssueResolutionPromptArgs): s "", "Requirements", "- Fix all valid issues in the selected scope, not just the first one.", + "- Treat this chat as one bounded Path to Merge resolution round. Make one coherent set of fixes for the current inventory, then hand control back to ADE.", "- If you make local code or git changes that should affect the PR, do not finish with local-only state. Commit the changes and push the PR branch before you stop.", + "- After you push, do not wait indefinitely for CI or advisory review bots. ADE will poll GitHub, observe custom post-push comments, and launch the next round if new actionable work appears.", "- If you only resolve stale review threads or other PR metadata with ADE tools and no local git changes are needed, say that clearly in your final note.", "- If you cannot safely commit or push the necessary changes, stop with a concrete blocker instead of exiting as if the round succeeded.", ); @@ -559,7 +561,7 @@ export function buildPrIssueResolutionPrompt(args: IssueResolutionPromptArgs): s "- Before you push, rerun the complete failing test files or suites locally, not just the specific failing test names. Test runners and sharded CI can hide additional failures behind the first error in a file.", "- Treat newly added or heavily modified test files as likely regression hotspots, even if CI only surfaced a different failure first.", "- Watch carefully for regressions caused by your fixes. If a change breaks an existing test because the expected behavior legitimately changed, update the test. Do not change tests just to mask a bug.", - "- Continue iterating until the selected issue set is cleared and CI is green, or stop only with a concrete blocker and explain it clearly.", + "- Stop with a concise final note that lists what changed, what you validated, whether you pushed, and any concrete blocker ADE should surface to the operator.", ); if (isIncremental) { promptSections.push( diff --git a/apps/desktop/src/main/services/prs/prService.ts b/apps/desktop/src/main/services/prs/prService.ts index 7edc0cdc4..d6b063b0f 100644 --- a/apps/desktop/src/main/services/prs/prService.ts +++ b/apps/desktop/src/main/services/prs/prService.ts @@ -13,6 +13,8 @@ import type { CommitIntegrationArgs, CleanupIntegrationWorkflowArgs, CleanupIntegrationWorkflowResult, + CleanupPrBranchArgs, + CleanupPrBranchResult, DeleteIntegrationProposalArgs, DeleteIntegrationProposalResult, DismissIntegrationCleanupArgs, @@ -2615,6 +2617,10 @@ export function createPrService({ const createdAt = nowIso(); const headBranch = asString(pr?.head?.ref) || branchNameFromRef(lane.branchRef); const baseBranch = asString(pr?.base?.ref) || branchNameFromRef(lane.baseRef); + const laneBranch = branchNameFromRef(lane.branchRef); + if (normalizeBranchName(laneBranch) !== normalizeBranchName(headBranch)) { + throw new Error(`Cannot link PR #${locator.number} to lane "${lane.name}" because the PR head branch is "${headBranch}" but the lane branch is "${laneBranch}".`); + } // Backfill creation_strategy for imported PRs that don't have one stored yet. // The wizard defaults to "pr_target" when users create via the UI; mirror @@ -2653,6 +2659,74 @@ export function createPrService({ return await refreshOne(prId); }; + const cleanupBranch = async (args: CleanupPrBranchArgs): Promise => { + const row = getRow(args.prId); + if (!row) throw new Error(`PR not found: ${args.prId}`); + if (row.state !== "merged" && row.state !== "closed") { + throw new Error("Branch cleanup is only available after a PR is merged or closed."); + } + + const branchName = branchNameFromRef(row.head_branch); + if (!branchName || branchName === "HEAD") { + throw new Error("PR head branch is missing."); + } + + const lanes = await laneService.list({ includeArchived: true, includeStatus: false }); + const primaryBranches = new Set( + lanes + .filter((lane) => lane.laneType === "primary") + .map((lane) => normalizeBranchName(branchNameFromRef(lane.branchRef))) + .filter(Boolean), + ); + if (primaryBranches.has(normalizeBranchName(branchName))) { + throw new Error(`Refusing to clean up protected branch "${branchName}".`); + } + + const result: CleanupPrBranchResult = { + prId: args.prId, + branchName, + localDeleted: false, + remoteDeleted: false, + localError: null, + remoteError: null, + }; + + if (args.deleteLocalBranch !== false) { + const refCheck = await runGit(["show-ref", "--verify", "--quiet", `refs/heads/${branchName}`], { + cwd: projectRoot, + timeoutMs: 8_000, + }); + if (refCheck.exitCode === 0) { + const deleted = await runGit(["branch", "-D", branchName], { cwd: projectRoot, timeoutMs: 30_000 }); + if (deleted.exitCode === 0) result.localDeleted = true; + else result.localError = deleted.stderr || deleted.stdout || `Failed to delete local branch ${branchName}`; + } + } + + if (args.deleteRemoteBranch) { + const remote = args.remoteName?.trim() || "origin"; + const remoteCheck = await runGit(["remote", "get-url", remote], { cwd: projectRoot, timeoutMs: 8_000 }); + if (remoteCheck.exitCode !== 0) { + result.remoteError = `Remote '${remote}' is not configured for this repository`; + } else { + const remoteRefCheck = await runGit(["ls-remote", "--heads", remote, branchName], { + cwd: projectRoot, + timeoutMs: 12_000, + }); + if (remoteRefCheck.exitCode === 0 && remoteRefCheck.stdout.trim().length > 0) { + const deleted = await runGit(["push", remote, "--delete", branchName], { cwd: projectRoot, timeoutMs: 45_000 }); + if (deleted.exitCode === 0) result.remoteDeleted = true; + else result.remoteError = deleted.stderr || deleted.stdout || `Failed to delete remote branch ${branchName}`; + } + } + } + + if (result.localError || result.remoteError) { + logger.warn("prs.branch_cleanup_partial_failure", result); + } + return result; + }; + const land = async (args: LandPrArgs): Promise => { const row = getRow(args.prId); if (!row) throw new Error(`PR not found: ${args.prId}`); @@ -5034,6 +5108,10 @@ export function createPrService({ return await linkToLane(args); }, + async cleanupBranch(args: CleanupPrBranchArgs): Promise { + return await cleanupBranch(args); + }, + getForLane(laneId: string): PrSummary | null { const row = getRowForLane(laneId); return row ? rowToSummary(row) : null; @@ -5306,6 +5384,12 @@ export function createPrService({ return files; }, + async getCommits(prId: string): Promise { + const commits = await getCommitsSnapshot(prId); + upsertSnapshotRow({ prId, commits }); + return commits; + }, + async getActionRuns(prId: string): Promise { const row = requireRow(prId); const repo = repoFromRow(row); diff --git a/apps/desktop/src/main/services/prs/resolverUtils.ts b/apps/desktop/src/main/services/prs/resolverUtils.ts index 1253502ab..025000844 100644 --- a/apps/desktop/src/main/services/prs/resolverUtils.ts +++ b/apps/desktop/src/main/services/prs/resolverUtils.ts @@ -16,6 +16,10 @@ const NOISY_BODY_PATTERNS = [ /