+
+ {onSelectPR && (
+
+ )}
-
-
-
-
- Toggle word wrap
-
-
-
-
-
-
- {splitView ? "Switch to unified view" : "Switch to split view"}
-
-
+
{commentCount > 0 && (
state.tasks.activeTaskId);
@@ -154,8 +160,88 @@ function usePerRepoCallbacks(
);
}
-function useChangesPanelPRData(repositoryNames: string[]) {
+type ChangesPanelPRBuildInput = {
+ prs: TaskPR[];
+ filesByPRKey: Record;
+ repoNameById: Record;
+ taskHasMultipleRepos: boolean;
+ selectedPRId: string | undefined;
+ selectedPRRepositoryName: string | undefined;
+ useRepositoryKeys: boolean;
+};
+
+function countPRsByRepository(prs: TaskPR[]): Map {
+ const counts = new Map();
+ for (const pr of prs) {
+ const repositoryId = pr.repository_id ?? "";
+ counts.set(repositoryId, (counts.get(repositoryId) ?? 0) + 1);
+ }
+ return counts;
+}
+
+function repositoryNameForPR(pr: TaskPR, repoNameById: Record): string {
+ if (!pr.repository_id) return "";
+ return repoNameById[pr.repository_id] ?? "";
+}
+
+function progressRepositoryName(
+ pr: TaskPR,
+ selectedPRId: string | undefined,
+ selectedPRRepositoryName: string | undefined,
+ workspaceRepositoryName: string,
+): string {
+ if (pr.id === selectedPRId && selectedPRRepositoryName) return selectedPRRepositoryName;
+ return resolvePRReviewRepositoryName(pr, workspaceRepositoryName || undefined) ?? pr.repo;
+}
+
+function buildChangesPanelPRData({
+ prs,
+ filesByPRKey,
+ repoNameById,
+ taskHasMultipleRepos,
+ selectedPRId,
+ selectedPRRepositoryName,
+ useRepositoryKeys,
+}: ChangesPanelPRBuildInput): { prFiles: PRChangedFile[]; prDiffFiles: PRDiffFile[] } {
+ const merged: PRChangedFile[] = [];
+ const progressSources = new Map();
+ const prCounts = countPRsByRepository(prs);
+ const anyRepoMultiPR = Array.from(prCounts.values()).some((count) => count > 1);
+ const needsStamp = prs.length > 1;
+
+ for (const pr of prs) {
+ const files = filesByPRKey[prFetchKey(pr)] ?? [];
+ const repositoryName = repositoryNameForPR(pr, repoNameById);
+ const stamp = computePRGroupStamp({
+ needsStamp,
+ taskHasMultipleRepos,
+ anyRepoMultiPR,
+ repoName: repositoryName || `${pr.owner}/${pr.repo}`,
+ branch: pr.head_branch ?? "",
+ prNumber: pr.pr_number,
+ });
+ merged.push(...mapPRFilesToChangedFiles(files, stamp, prTaskKey(pr)));
+ progressSources.set(pr.id, {
+ repositoryName: progressRepositoryName(
+ pr,
+ selectedPRId,
+ selectedPRRepositoryName,
+ repositoryName,
+ ),
+ files,
+ });
+ }
+
+ return {
+ prFiles: merged,
+ prDiffFiles: selectPRFilesForReviewProgress(progressSources, selectedPRId, useRepositoryKeys),
+ };
+}
+
+function useChangesPanelPRData(repositoryNames: string[], sessionId: string | null) {
const { prs, filesByPRKey } = useActiveTaskPRsWithFiles();
+ const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
+ const { selectedPR: taskPR } = useReviewPRSelection(activeTaskId);
const reposByWorkspace = useAppStore((s) => s.repositories.itemsByWorkspaceId);
const repoNameById = useMemo(() => buildRepoNameById(reposByWorkspace), [reposByWorkspace]);
const taskRepositoryCount = useAppStore((s) => {
@@ -166,7 +252,7 @@ function useChangesPanelPRData(repositoryNames: string[]) {
});
const taskHasMultipleRepos = taskRepositoryCount > 1;
const useRepositoryKeys = isReviewMultiRepo(taskRepositoryCount, repositoryNames);
- const taskPR = useActiveTaskPR();
+ const selectedPRRepositoryName = usePRReviewRepositoryIdentity(activeTaskId, sessionId, taskPR);
const refreshKey = taskPR?.last_synced_at ?? null;
const { commits: prCommitsList } = usePRCommits(
taskPR?.owner ?? null,
@@ -174,52 +260,27 @@ function useChangesPanelPRData(repositoryNames: string[]) {
taskPR?.pr_number ?? null,
refreshKey,
);
- const { prFiles, prDiffFiles } = useMemo(() => {
- const merged: PRChangedFile[] = [];
- const progressSources = new Map();
- // Multi-branch tasks open multiple PRs on the same repo — files must
- // split by PR so the agent's two branches don't get conflated under one
- // "kandev" header. We stamp each row with a label unique per PR.
- //
- // - 1 PR total → no stamp (single section header is enough)
- // - Multi-repo, 1 PR/repo → stamp = repo name (legacy behavior)
- // - Multi-PR per repo → stamp = " · " so the
- // group label still names the repo but
- // disambiguates the branch the PR is on
- // - Single repo + N PRs → stamp = head_branch (repo is redundant)
- const prCountByRepoID = new Map();
- for (const pr of prs) {
- const key = pr.repository_id ?? "";
- prCountByRepoID.set(key, (prCountByRepoID.get(key) ?? 0) + 1);
- }
- const anyRepoMultiPR = Array.from(prCountByRepoID.values()).some((n) => n > 1);
- const needsStamp = prs.length > 1;
- for (const pr of prs) {
- const key = `${pr.owner}/${pr.repo}/${pr.pr_number}/${pr.last_synced_at ?? ""}`;
- const files = filesByPRKey[key] ?? [];
- const repoName = pr.repository_id ? (repoNameById[pr.repository_id] ?? "") : "";
- const branch = pr.head_branch ?? "";
- const stamp = computePRGroupStamp({
- needsStamp,
+ const { prFiles, prDiffFiles } = useMemo(
+ () =>
+ buildChangesPanelPRData({
+ prs,
+ filesByPRKey,
+ repoNameById,
taskHasMultipleRepos,
- anyRepoMultiPR,
- repoName: repoName || `${pr.owner}/${pr.repo}`,
- branch,
- prNumber: pr.pr_number,
- });
- merged.push(...mapPRFilesToChangedFiles(files, stamp));
- progressSources.set(pr.id, {
- repositoryName: resolvePRReviewRepositoryName(pr, repoName || undefined) ?? pr.repo,
- files,
- });
- }
- const progressFiles = selectPRFilesForReviewProgress(
- progressSources,
+ selectedPRId: taskPR?.id,
+ selectedPRRepositoryName,
+ useRepositoryKeys,
+ }),
+ [
+ prs,
+ filesByPRKey,
+ repoNameById,
+ taskHasMultipleRepos,
taskPR?.id,
+ selectedPRRepositoryName,
useRepositoryKeys,
- );
- return { prFiles: merged, prDiffFiles: progressFiles };
- }, [prs, filesByPRKey, repoNameById, taskHasMultipleRepos, taskPR?.id, useRepositoryKeys]);
+ ],
+ );
const hasPRFiles = prFiles.length > 0;
const hasPRCommits = prCommitsList.length > 0;
return {
@@ -246,7 +307,7 @@ export function useChangesPanelData() {
() => [...git.repoNames, ...getCumulativeReviewRepositoryNames(git.cumulativeDiff?.files)],
[git.repoNames, git.cumulativeDiff],
);
- const prData = useChangesPanelPRData(reviewRepositoryNames);
+ const prData = useChangesPanelPRData(reviewRepositoryNames, activeSessionId);
const vcsDialogs = useVcsDialogs();
const baseBranchDisplay = useMemo(() => getBaseBranchDisplay(baseBranch), [baseBranch]);
const unstagedFiles = useMemo(() => mapToChangedFiles(git.unstagedFiles), [git.unstagedFiles]);
diff --git a/apps/web/components/task/changes-panel-helpers.test.ts b/apps/web/components/task/changes-panel-helpers.test.ts
index 72bbbc713c..237c2acfa3 100644
--- a/apps/web/components/task/changes-panel-helpers.test.ts
+++ b/apps/web/components/task/changes-panel-helpers.test.ts
@@ -93,6 +93,16 @@ describe("mapPRFilesToChangedFiles", () => {
expect(out.every((f) => f.repository_name === "frontend")).toBe(true);
});
+ it("stamps the exact PR key on every row", () => {
+ const [out] = mapPRFilesToChangedFiles(
+ [diffFile({ filename: "README.md" })],
+ "frontend",
+ "acme/widgets/42",
+ );
+
+ expect(out.prKey).toBe("acme/widgets/42");
+ });
+
it("defaults repository_name to '' when caller omits it (single-repo path)", () => {
// Empty string is meaningful: PRFilesGroupedList treats one group with
// empty name as the single-repo case and skips per-repo sub-headers.
diff --git a/apps/web/components/task/changes-panel-helpers.ts b/apps/web/components/task/changes-panel-helpers.ts
index 5d86b64c59..b1614b3180 100644
--- a/apps/web/components/task/changes-panel-helpers.ts
+++ b/apps/web/components/task/changes-panel-helpers.ts
@@ -207,9 +207,11 @@ export function computeStagedStats(stagedFiles: FileInfo[]) {
export function mapPRFilesToChangedFiles(
files: PRDiffFile[],
repositoryName?: string,
+ prKey?: string,
): PRChangedFile[] {
return files.map((file) => ({
path: file.filename,
+ prKey,
status: normalizeFileChangeStatus(file.status),
plus: file.additions,
minus: file.deletions,
diff --git a/apps/web/components/task/changes-panel-pr-files.test.tsx b/apps/web/components/task/changes-panel-pr-files.test.tsx
index aba469af6d..fcc9ae6ba1 100644
--- a/apps/web/components/task/changes-panel-pr-files.test.tsx
+++ b/apps/web/components/task/changes-panel-pr-files.test.tsx
@@ -17,6 +17,7 @@ describe("PRFilesGroupedList", () => {
minus: 0,
oldPath: undefined,
repository_name: "backend",
+ prKey: "acme/backend/42",
},
]}
onOpenDiff={onOpenDiff}
@@ -27,6 +28,7 @@ describe("PRFilesGroupedList", () => {
expect(onOpenDiff).toHaveBeenCalledWith("README.md", {
source: "pr",
repositoryName: "backend",
+ prKey: "acme/backend/42",
});
});
diff --git a/apps/web/components/task/changes-panel-pr-files.tsx b/apps/web/components/task/changes-panel-pr-files.tsx
index 684fe7c722..c9caffeba7 100644
--- a/apps/web/components/task/changes-panel-pr-files.tsx
+++ b/apps/web/components/task/changes-panel-pr-files.tsx
@@ -120,6 +120,7 @@ function PRFileRow({
return (
diff --git a/apps/web/components/task/changes-panel-timeline.tsx b/apps/web/components/task/changes-panel-timeline.tsx
index 2655bea8c3..02279e43c9 100644
--- a/apps/web/components/task/changes-panel-timeline.tsx
+++ b/apps/web/components/task/changes-panel-timeline.tsx
@@ -519,6 +519,8 @@ export function FileListSection(props: FileListSectionProps) {
export type PRChangedFile = {
path: string;
+ /** Stable owner/repo/number identity for the PR that produced this row. */
+ prKey?: string;
status: FileInfo["status"];
plus: number | undefined;
minus: number | undefined;
diff --git a/apps/web/components/task/changes-top-bar.tsx b/apps/web/components/task/changes-top-bar.tsx
index 8ca807bbd0..0b24a10cd5 100644
--- a/apps/web/components/task/changes-top-bar.tsx
+++ b/apps/web/components/task/changes-top-bar.tsx
@@ -18,7 +18,9 @@ import {
DropdownMenuTrigger,
} from "@kandev/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip";
-import { PanelHeaderBarSplit } from "./panel-primitives";
+import { ReviewPRSelector } from "@/components/review/review-pr-selector";
+import type { TaskPR } from "@/lib/types/github";
+import { PanelHeaderBar, PanelHeaderBarSplit } from "./panel-primitives";
export type ChangesTopBarProps = {
autoMarkOnScroll: boolean;
@@ -34,6 +36,10 @@ export type ChangesTopBarProps = {
handleFixComments: () => void;
handleRequestWalkthrough?: () => void;
requestWalkthroughDisabled?: boolean;
+ prs: TaskPR[];
+ selectedPR: TaskPR | null;
+ prDiffLoading: boolean;
+ onSelectPR: (pr: TaskPR) => void;
};
function ChangesTopBarLeft({
@@ -220,7 +226,53 @@ export function ChangesTopBar({
handleFixComments,
handleRequestWalkthrough,
requestWalkthroughDisabled,
+ prs,
+ selectedPR,
+ prDiffLoading,
+ onSelectPR,
}: ChangesTopBarProps) {
+ const selector = (
+
+ );
+
+ if (prs.length > 1 && selectedPR) {
+ return (
+
+ {selector}
+
+
+
+
+
+
+
+
+ );
+ }
+
return (
(effectiveEnvId);
const hasDevScript = Boolean(repository?.dev_script?.trim());
- const review = useReviewDialog(effectiveSessionId);
-
const userDefaultLayout = useSyncUserDefaultLayout();
useLspFileOpener();
useEditorKeybinds();
@@ -436,12 +432,7 @@ export const DockviewDesktopLayout = memo(function DockviewDesktopLayout({
/>
-
-
+
);
});
diff --git a/apps/web/components/task/dockview-panel-content.diff.test.tsx b/apps/web/components/task/dockview-panel-content.diff.test.tsx
index c86cde5d32..520c67b682 100644
--- a/apps/web/components/task/dockview-panel-content.diff.test.tsx
+++ b/apps/web/components/task/dockview-panel-content.diff.test.tsx
@@ -44,6 +44,7 @@ vi.mock("@/components/state-provider", () => ({
}));
import { renderPanel } from "./dockview-panel-content";
+import { renderPanel as renderSharedPanel } from "./dockview-shared";
describe("dockview diff panel content", () => {
it("passes repositoryName from panel params into the file-mode diff view", () => {
@@ -54,6 +55,7 @@ describe("dockview diff panel content", () => {
path: "README.md",
source: "uncommitted",
repositoryName: "frontend",
+ prKey: "acme/frontend/42",
})}
>,
);
@@ -63,6 +65,28 @@ describe("dockview diff panel content", () => {
filePath: "README.md",
sourceFilter: "uncommitted",
fileRepositoryName: "frontend",
+ prKey: "acme/frontend/42",
+ });
+ });
+
+ it("preserves the PR source in the shared dockview renderer", () => {
+ render(
+ <>
+ {renderSharedPanel("preview:file-diff", "diff-viewer", {
+ kind: "file",
+ path: "README.md",
+ source: "pr",
+ repositoryName: "widgets · feat/second",
+ prKey: "acme/widgets/42",
+ })}
+ >,
+ );
+
+ expect(taskChangesPanel.props).toMatchObject({
+ mode: "file",
+ filePath: "README.md",
+ sourceFilter: "pr",
+ prKey: "acme/widgets/42",
});
});
});
diff --git a/apps/web/components/task/dockview-panel-content.tsx b/apps/web/components/task/dockview-panel-content.tsx
index 8c3a3998ec..354add6128 100644
--- a/apps/web/components/task/dockview-panel-content.tsx
+++ b/apps/web/components/task/dockview-panel-content.tsx
@@ -95,6 +95,7 @@ function DiffViewerContent({
const selectedPath = panelKind === "file" ? (params?.path as string) : undefined;
const selectedRepositoryName =
panelKind === "file" ? (params?.repositoryName as string | undefined) : undefined;
+ const selectedPRKey = panelKind === "file" ? (params?.prKey as string | undefined) : undefined;
const sourceFilter = ((params?.source as string) || "all") as "all" | ReviewSource;
const panelSelectedDiff = panelKind === "all" ? selectedDiff : null;
const handleClosePanel = useCallback(() => {
@@ -108,6 +109,7 @@ function DiffViewerContent({
mode={panelKind as "all" | "file"}
filePath={selectedPath}
fileRepositoryName={selectedRepositoryName}
+ prKey={selectedPRKey}
sourceFilter={sourceFilter}
selectedDiff={panelSelectedDiff}
onClearSelected={() => setSelectedDiff(null)}
@@ -139,7 +141,11 @@ function ChangesContent({ panelId }: { panelId: string }) {
);
const handleOpenDiffFile = useCallback(
(path: string, options?: OpenDiffOptions) =>
- addFileDiffPanel(path, { source: options?.source, repositoryName: options?.repositoryName }),
+ addFileDiffPanel(path, {
+ source: options?.source,
+ repositoryName: options?.repositoryName,
+ prKey: options?.prKey,
+ }),
[addFileDiffPanel],
);
const handleOpenCommitDetail = useCallback(
diff --git a/apps/web/components/task/dockview-review-dialog.tsx b/apps/web/components/task/dockview-review-dialog.tsx
index 3ba70ad1fb..7d19bb7738 100644
--- a/apps/web/components/task/dockview-review-dialog.tsx
+++ b/apps/web/components/task/dockview-review-dialog.tsx
@@ -1,31 +1,53 @@
"use client";
import { ReviewDialog } from "@/components/review/review-dialog";
-import type { useReviewDialog } from "./use-review-dialog";
+import { WalkthroughOverlay } from "@/components/review/walkthrough-overlay";
+import { useReviewDialog } from "./use-review-dialog";
-type DockviewReviewDialogProps = {
+type TaskReviewDialogMountProps = {
sessionId: string | null;
- review: ReturnType
;
+ taskId: string | null;
+ onSelectWalkthroughFile?: (path: string, repo?: string) => void;
};
-export function DockviewReviewDialog({ sessionId, review }: DockviewReviewDialogProps) {
+export function TaskReviewDialogMount({
+ sessionId,
+ taskId,
+ onSelectWalkthroughFile,
+}: TaskReviewDialogMountProps) {
+ const review = useReviewDialog(sessionId);
+
if (!sessionId) {
return null;
}
return (
-
+ <>
+
+
+ >
);
}
diff --git a/apps/web/components/task/dockview-session-tabs.ts b/apps/web/components/task/dockview-session-tabs.ts
index 822b83e769..1e8f4bed27 100644
--- a/apps/web/components/task/dockview-session-tabs.ts
+++ b/apps/web/components/task/dockview-session-tabs.ts
@@ -16,7 +16,7 @@ import { createDebugLogger, isDebug } from "@/lib/debug/log";
import type { TaskSession } from "@/lib/types/http";
import type { TaskPR } from "@/lib/types/github";
import { getPrimaryTaskPR } from "@/hooks/domains/github/use-task-pr";
-import { prTaskKey } from "@/components/github/pr-detail-panel";
+import { prTaskKey } from "@/components/github/pr-utils";
import {
shouldActivateSessionPanel,
shouldPreserveActivePanel,
diff --git a/apps/web/components/task/dockview-shared.tsx b/apps/web/components/task/dockview-shared.tsx
index b28cd02ce2..311eb21920 100644
--- a/apps/web/components/task/dockview-shared.tsx
+++ b/apps/web/components/task/dockview-shared.tsx
@@ -12,6 +12,7 @@ import { useFileEditors } from "@/hooks/use-file-editors";
import { useSessionGitStatus } from "@/hooks/domains/session/use-session-git-status";
import { useSessionCommits } from "@/hooks/domains/session/use-session-commits";
import { useEnvironmentSessionId } from "@/hooks/use-environment-session-id";
+import type { ReviewSource } from "@/hooks/domains/session/use-review-sources";
// Panel components (rendered via portals, not directly by dockview)
import { TaskChatPanel } from "./task-chat-panel";
@@ -262,6 +263,8 @@ function DiffViewerContent({
const selectedPath = panelKind === "file" ? (params?.path as string) : undefined;
const selectedRepositoryName =
panelKind === "file" ? (params?.repositoryName as string | undefined) : undefined;
+ const selectedPRKey = panelKind === "file" ? (params?.prKey as string | undefined) : undefined;
+ const sourceFilter = ((params?.source as string) || "all") as "all" | ReviewSource;
const panelSelectedDiff = panelKind === "all" ? selectedDiff : null;
useResyncGitStatusOnTabActivate(panelId, activeSessionId);
const handleClosePanel = useCallback(() => {
@@ -275,6 +278,8 @@ function DiffViewerContent({
mode={panelKind as "all" | "file"}
filePath={selectedPath}
fileRepositoryName={selectedRepositoryName}
+ prKey={selectedPRKey}
+ sourceFilter={sourceFilter}
selectedDiff={panelSelectedDiff}
onClearSelected={() => setSelectedDiff(null)}
onOpenFile={openFile}
@@ -308,7 +313,11 @@ function ChangesContent({ panelId }: { panelId: string }) {
);
const handleOpenDiffFile = useCallback(
(path: string, options?: OpenDiffOptions) =>
- addFileDiffPanel(path, { source: options?.source, repositoryName: options?.repositoryName }),
+ addFileDiffPanel(path, {
+ source: options?.source,
+ repositoryName: options?.repositoryName,
+ prKey: options?.prKey,
+ }),
[addFileDiffPanel],
);
const handleOpenCommitDetail = useCallback(
diff --git a/apps/web/components/task/mobile/mobile-changes-panel.tsx b/apps/web/components/task/mobile/mobile-changes-panel.tsx
index 95c12e2960..9c12ccadfd 100644
--- a/apps/web/components/task/mobile/mobile-changes-panel.tsx
+++ b/apps/web/components/task/mobile/mobile-changes-panel.tsx
@@ -75,6 +75,7 @@ export const MobileChangesPanel = memo(function MobileChangesPanel({
path,
sourceFilter: options?.source ?? "all",
repositoryName: options?.repositoryName || undefined,
+ prKey: options?.prKey,
});
}, []);
diff --git a/apps/web/components/task/mobile/mobile-diff-sheet.tsx b/apps/web/components/task/mobile/mobile-diff-sheet.tsx
index b757b39524..3f8311bd50 100644
--- a/apps/web/components/task/mobile/mobile-diff-sheet.tsx
+++ b/apps/web/components/task/mobile/mobile-diff-sheet.tsx
@@ -166,12 +166,14 @@ function renderPanel(
const panelMode = mode.kind;
const filePath = mode.kind === "file" ? mode.path : undefined;
const fileRepositoryName = mode.kind === "file" ? mode.repositoryName : undefined;
+ const prKey = mode.kind === "file" ? mode.prKey : undefined;
const effectiveSourceFilter = mode.kind === "all" ? activeSource : (mode.sourceFilter ?? "all");
return (
;
- activeTaskId: string | null;
- onSelectWalkthroughFile: (path: string, repo?: string) => void;
-}) {
- if (!sessionId) return null;
- return (
- <>
-
-
- >
- );
-}
-
export function useMobilePanelHandlers({
effectiveSessionId,
handlePanelChange,
@@ -432,7 +394,6 @@ export const SessionMobileLayout = memo(function SessionMobileLayout(
const { selectedFile, handleOpenFileFromChat, handleOpenFile, handlePanelChangeAndClearSheet } =
useMobilePanelHandlers({ effectiveSessionId, handlePanelChange });
- const review = useReviewDialog(effectiveSessionId);
const mobileMR = useMobileMRSelection(
activeTaskId,
effectiveSessionId,
@@ -490,10 +451,9 @@ export const SessionMobileLayout = memo(function SessionMobileLayout(
presentation="drawer"
/>
-
diff --git a/apps/web/components/task/mobile/session-tablet-layout.tsx b/apps/web/components/task/mobile/session-tablet-layout.tsx
index 547c32cc61..1fa9b9f8df 100644
--- a/apps/web/components/task/mobile/session-tablet-layout.tsx
+++ b/apps/web/components/task/mobile/session-tablet-layout.tsx
@@ -14,6 +14,7 @@ import { useSessionLayoutState } from "@/hooks/use-session-layout-state";
import type { Repository } from "@/lib/types/http";
import type { Layout } from "react-resizable-panels";
import type { OpenFileTab } from "@/lib/types/backend";
+import { TaskReviewDialogMount } from "../dockview-review-dialog";
const DEFAULT_TABLET_LAYOUT: Record