diff --git a/apps/web/components/github/pr-detail-panel.tsx b/apps/web/components/github/pr-detail-panel.tsx
index 8119979faa..05a1945f08 100644
--- a/apps/web/components/github/pr-detail-panel.tsx
+++ b/apps/web/components/github/pr-detail-panel.tsx
@@ -60,7 +60,11 @@ export function PRDetailPanelComponent({ panelId }: PRDetailPanelProps) {
);
}
- return ;
+ return (
+
+ );
}
// --- Add PR feedback as chat context ---
@@ -205,13 +209,15 @@ function ApproveButton({
);
}
-function PRDetailContent({ taskPR, sessionId }: { taskPR: TaskPR; sessionId: string }) {
+export function PRDetailContent({ taskPR, sessionId }: { taskPR: TaskPR; sessionId: string }) {
const { feedback, loading, refresh } = usePRFeedback(taskPR.owner, taskPR.repo, taskPR.pr_number);
const { addAsContext } = useAddPRFeedbackAsContext(sessionId, taskPR.pr_number);
const setTaskPR = useAppStore((s) => s.setTaskPR);
// Sync live feedback data back to the store so topbar/other consumers stay up to date.
// Use primitive deps to avoid re-render loops from object reference changes.
+ // Guard: never regress the store to a less-terminal state (e.g. merged → open)
+ // because the feedback fetch may return stale data from before a backend poll update.
const prState = taskPR.state;
const prMergedAt = taskPR.merged_at ?? null;
const prClosedAt = taskPR.closed_at ?? null;
@@ -221,20 +227,29 @@ function PRDetailContent({ taskPR, sessionId }: { taskPR: TaskPR; sessionId: str
useEffect(() => {
if (!feedback) return;
const livePR = feedback.pr;
+ // State priority: merged > closed > open. Never regress to a less-terminal state.
+ const stateRank = (s: string) => {
+ if (s === "merged") return 2;
+ if (s === "closed") return 1;
+ return 0;
+ };
+ const effectiveState = stateRank(livePR.state) >= stateRank(prState) ? livePR.state : prState;
+ const effectiveMergedAt = effectiveState === prState ? prMergedAt : (livePR.merged_at ?? null);
+ const effectiveClosedAt = effectiveState === prState ? prClosedAt : (livePR.closed_at ?? null);
if (
- livePR.state !== prState ||
- (livePR.merged_at ?? null) !== prMergedAt ||
- (livePR.closed_at ?? null) !== prClosedAt ||
+ effectiveState !== prState ||
+ effectiveMergedAt !== prMergedAt ||
+ effectiveClosedAt !== prClosedAt ||
livePR.additions !== prAdditions ||
livePR.deletions !== prDeletions
) {
setTaskPR(prTaskId, {
...taskPR,
- state: livePR.state,
+ state: effectiveState as TaskPR["state"],
additions: livePR.additions,
deletions: livePR.deletions,
- merged_at: livePR.merged_at,
- closed_at: livePR.closed_at,
+ merged_at: effectiveMergedAt,
+ closed_at: effectiveClosedAt,
});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
diff --git a/apps/web/components/task/__tests__/use-auto-pr-panel.test.ts b/apps/web/components/task/__tests__/use-auto-pr-panel.test.ts
new file mode 100644
index 0000000000..961da75dec
--- /dev/null
+++ b/apps/web/components/task/__tests__/use-auto-pr-panel.test.ts
@@ -0,0 +1,42 @@
+import { describe, it, expect } from "vitest";
+import { shouldAutoAddPRPanel } from "../dockview-session-tabs";
+
+describe("shouldAutoAddPRPanel", () => {
+ const base = {
+ hasPR: true,
+ panelExists: false,
+ isRestoringLayout: false,
+ isMaximized: false,
+ };
+
+ it("returns 'add' when task has PR and panel does not exist", () => {
+ expect(shouldAutoAddPRPanel(base)).toBe("add");
+ });
+
+ it("returns 'none' when task has no PR", () => {
+ expect(shouldAutoAddPRPanel({ ...base, hasPR: false })).toBe("none");
+ });
+
+ it("returns 'none' when panel already exists", () => {
+ expect(shouldAutoAddPRPanel({ ...base, panelExists: true })).toBe("none");
+ });
+
+ it("returns 'none' during layout restoration", () => {
+ expect(shouldAutoAddPRPanel({ ...base, isRestoringLayout: true })).toBe("none");
+ });
+
+ it("returns 'none' during maximize state", () => {
+ expect(shouldAutoAddPRPanel({ ...base, isMaximized: true })).toBe("none");
+ });
+
+ it("returns 'add' when all conditions are met", () => {
+ expect(
+ shouldAutoAddPRPanel({
+ hasPR: true,
+ panelExists: false,
+ isRestoringLayout: false,
+ isMaximized: false,
+ }),
+ ).toBe("add");
+ });
+});
diff --git a/apps/web/components/task/dockview-session-tabs.ts b/apps/web/components/task/dockview-session-tabs.ts
index 727cb9e791..da7c1d8374 100644
--- a/apps/web/components/task/dockview-session-tabs.ts
+++ b/apps/web/components/task/dockview-session-tabs.ts
@@ -3,6 +3,7 @@ import type { DockviewReadyEvent, AddPanelOptions } from "dockview-react";
import type { StoreApi } from "zustand";
import type { AppState } from "@/lib/state/store";
import { useDockviewStore } from "@/lib/state/dockview-store";
+import { useAppStore } from "@/components/state-provider";
/**
* Sync `activeSessionId` in the store when the user clicks a session tab.
@@ -75,6 +76,79 @@ export function setupChatPanelSafetyNet(
});
}
+// ---------------------------------------------------------------------------
+// Auto-show PR detail panel
+// ---------------------------------------------------------------------------
+
+/** Pure decision function for whether the PR panel should be auto-added. */
+export function shouldAutoAddPRPanel(params: {
+ hasPR: boolean;
+ panelExists: boolean;
+ isRestoringLayout: boolean;
+ isMaximized: boolean;
+}): "add" | "none" {
+ if (!params.hasPR) return "none";
+ if (params.panelExists) return "none";
+ if (params.isRestoringLayout) return "none";
+ if (params.isMaximized) return "none";
+ return "add";
+}
+
+/**
+ * Auto-add the PR detail panel to the center group when the active task
+ * has an associated pull request. The panel is added as a background tab
+ * (the session/agent tab stays focused).
+ *
+ * Reads task ID and PR state from the app store internally.
+ *
+ * Tracks per-task dismissal: if the user manually closes the PR panel,
+ * it won't be re-added for the same task until they navigate away and back.
+ */
+export function useAutoPRPanel() {
+ const taskId = useAppStore((s) => s.tasks.activeTaskId);
+ const hasPR = useAppStore((s) => {
+ const tid = s.tasks.activeTaskId;
+ return tid ? !!s.taskPRs.byTaskId[tid] : false;
+ });
+ // Subscribe to dockview API readiness so the effect re-runs when API appears.
+ const hasApi = useDockviewStore((s) => !!s.api);
+ // Track which tasks have already been handled to avoid re-adding on every
+ // store update or task switch. Only auto-add once per task visit.
+ const handledRef = useRef>(new Set());
+
+ useEffect(() => {
+ if (!taskId || !hasPR || !hasApi) return;
+ if (handledRef.current.has(taskId)) return;
+
+ // Defer to avoid racing with layout restore on initial load / session switch.
+ requestAnimationFrame(() => {
+ requestAnimationFrame(() => {
+ const api = useDockviewStore.getState().api;
+ if (!api) return;
+ if (useDockviewStore.getState().isRestoringLayout) return;
+ if (useDockviewStore.getState().preMaximizeLayout !== null) return;
+ if (api.getPanel("pr-detail")) {
+ // Panel exists from a restored layout — just mark as handled.
+ handledRef.current.add(taskId);
+ return;
+ }
+
+ const { centerGroupId } = useDockviewStore.getState();
+ const centerGroupExists = centerGroupId && api.groups.some((g) => g.id === centerGroupId);
+
+ api.addPanel({
+ id: "pr-detail",
+ component: "pr-detail",
+ title: "Pull Request",
+ position: centerGroupExists ? { referenceGroup: centerGroupId } : undefined,
+ inactive: true,
+ });
+ handledRef.current.add(taskId);
+ });
+ });
+ }, [taskId, hasPR, hasApi]);
+}
+
/**
* Auto-create a session tab when a session becomes active.
* Replaces the generic "chat" panel with a per-session tab on first use.
diff --git a/apps/web/components/task/file-tab-content.tsx b/apps/web/components/task/file-tab-content.tsx
new file mode 100644
index 0000000000..9c301b810f
--- /dev/null
+++ b/apps/web/components/task/file-tab-content.tsx
@@ -0,0 +1,63 @@
+"use client";
+
+import { TabsContent } from "@kandev/ui/tabs";
+import { FileEditorContent } from "./file-editor-content";
+import { FileImageViewer } from "./file-image-viewer";
+import { FileBinaryViewer } from "./file-binary-viewer";
+import type { OpenFileTab } from "@/lib/types/backend";
+import { getFileCategory } from "@/lib/utils/file-types";
+
+export function FileTabContent({
+ tab,
+ activeSession,
+ activeSessionId,
+ isSaving,
+ onFileChange,
+ onFileSave,
+ onFileDelete,
+}: {
+ tab: OpenFileTab;
+ activeSession: { worktree_path?: string | null } | null;
+ activeSessionId: string | null;
+ isSaving: boolean;
+ onFileChange: (path: string, content: string) => void;
+ onFileSave: (path: string) => void;
+ onFileDelete: (path: string) => void;
+}) {
+ const extCategory = getFileCategory(tab.path);
+ let category: "image" | "binary" | "text" = "text";
+ if (tab.isBinary) category = extCategory === "image" ? "image" : "binary";
+
+ return (
+
+ {category === "image" && (
+
+ )}
+ {category === "binary" && (
+
+ )}
+ {category === "text" && (
+ onFileChange(tab.path, newContent)}
+ onSave={() => onFileSave(tab.path)}
+ onDelete={() => onFileDelete(tab.path)}
+ />
+ )}
+
+ );
+}
diff --git a/apps/web/components/task/task-center-panel.tsx b/apps/web/components/task/task-center-panel.tsx
index dcceba28ab..e442ac287f 100644
--- a/apps/web/components/task/task-center-panel.tsx
+++ b/apps/web/components/task/task-center-panel.tsx
@@ -14,9 +14,7 @@ import {
import { SessionPanel } from "@kandev/ui/pannel-session";
import { TaskChatPanel } from "./task-chat-panel";
import { TaskChangesPanel } from "./task-changes-panel";
-import { FileEditorContent } from "./file-editor-content";
-import { FileImageViewer } from "./file-image-viewer";
-import { FileBinaryViewer } from "./file-binary-viewer";
+import { FileTabContent } from "./file-tab-content";
import { PassthroughTerminal } from "./passthrough-terminal";
import type { OpenFileTab, FileContentResponse } from "@/lib/types/backend";
import { useAppStore } from "@/components/state-provider";
@@ -32,9 +30,10 @@ import {
import { useSessionGitStatus } from "@/hooks/domains/session/use-session-git-status";
import { useSessionCommits } from "@/hooks/domains/session/use-session-commits";
import { calculateHash } from "@/lib/utils/file-diff";
-import { getFileCategory } from "@/lib/utils/file-types";
import { useToast } from "@/components/toast-provider";
import { useFileTabRestoration, useFileSaveDelete } from "./task-center-panel-restoration";
+import { useActiveTaskPR } from "@/hooks/domains/github/use-task-pr";
+import { PRDetailContent } from "@/components/github/pr-detail-panel";
import type { SelectedDiff } from "./task-layout";
@@ -231,11 +230,13 @@ function useCenterPanelTabs(
openFileTabs: OpenFileTab[],
handleCloseFileTab: (path: string) => void,
hasChanges: boolean | undefined,
+ hasPR: boolean,
) {
const tabs: SessionTab[] = useMemo(() => {
const staticTabs: SessionTab[] = [
...(hasChanges ? [{ id: "changes", label: "All changes" }] : []),
{ id: "chat", label: "Chat" },
+ ...(hasPR ? [{ id: "pr", label: "Pull Request" }] : []),
];
const fileTabs: SessionTab[] = openFileTabs.map((tab) => ({
id: `file:${tab.path}`,
@@ -249,11 +250,12 @@ function useCenterPanelTabs(
className: "cursor-pointer group gap-1.5 data-[state=active]:bg-muted",
}));
return [...staticTabs, ...fileTabs];
- }, [openFileTabs, handleCloseFileTab, hasChanges]);
+ }, [openFileTabs, handleCloseFileTab, hasChanges, hasPR]);
const separatorAfterIndex = useMemo(() => {
if (openFileTabs.length === 0) return undefined;
- return hasChanges ? 1 : 0;
- }, [openFileTabs.length, hasChanges]);
+ const staticCount = (hasChanges ? 1 : 0) + 1 + (hasPR ? 1 : 0); // changes + chat + pr
+ return staticCount - 1;
+ }, [openFileTabs.length, hasChanges, hasPR]);
return { tabs, separatorAfterIndex };
}
@@ -277,6 +279,8 @@ function useCenterPanelState(props: TaskCenterPanelProps) {
activeSessionId,
activeTaskId,
);
+ const taskPR = useActiveTaskPR();
+ const hasPR = !!taskPR;
const [openFileTabs, setOpenFileTabs] = useState([]);
const [savingFiles, setSavingFiles] = useState>(new Set());
const [selectedDiff, setSelectedDiff] = useState(null);
@@ -309,6 +313,7 @@ function useCenterPanelState(props: TaskCenterPanelProps) {
openFileTabs,
fileTabOps.handleCloseFileTab,
hasChanges,
+ hasPR,
);
useEffect(() => {
@@ -342,6 +347,7 @@ function useCenterPanelState(props: TaskCenterPanelProps) {
isPassthroughMode,
showApproveButton,
handleApprove,
+ taskPR,
openFileTabs,
savingFiles,
selectedDiff,
@@ -367,6 +373,7 @@ export const TaskCenterPanel = memo(function TaskCenterPanel(props: TaskCenterPa
isPassthroughMode,
showApproveButton,
handleApprove,
+ taskPR,
openFileTabs,
savingFiles,
selectedDiff,
@@ -411,6 +418,11 @@ export const TaskCenterPanel = memo(function TaskCenterPanel(props: TaskCenterPa
onDismissTooltip={() => setShowRequestChangesTooltip(false)}
onOpenFile={handleOpenFileFromChat}
/>
+ {taskPR && activeSessionId && (
+
+
+
+ )}
{openFileTabs.map((tab) => (
);
}
-
-function FileTabContent({
- tab,
- activeSession,
- activeSessionId,
- isSaving,
- onFileChange,
- onFileSave,
- onFileDelete,
-}: {
- tab: OpenFileTab;
- activeSession: { worktree_path?: string | null } | null;
- activeSessionId: string | null;
- isSaving: boolean;
- onFileChange: (path: string, content: string) => void;
- onFileSave: (path: string) => void;
- onFileDelete: (path: string) => void;
-}) {
- const extCategory = getFileCategory(tab.path);
- let category: "image" | "binary" | "text" = "text";
- if (tab.isBinary) category = extCategory === "image" ? "image" : "binary";
-
- return (
-
- {category === "image" && (
-
- )}
- {category === "binary" && (
-
- )}
- {category === "text" && (
- onFileChange(tab.path, newContent)}
- onSave={() => onFileSave(tab.path)}
- onDelete={() => onFileDelete(tab.path)}
- />
- )}
-
- );
-}
diff --git a/apps/web/e2e/pages/session-page.ts b/apps/web/e2e/pages/session-page.ts
index 7bf2228183..8ec86bf62a 100644
--- a/apps/web/e2e/pages/session-page.ts
+++ b/apps/web/e2e/pages/session-page.ts
@@ -281,6 +281,16 @@ export class SessionPage {
return this.page.getByTestId("pr-topbar-button");
}
+ /** PR detail panel (auto-shown when task has an associated PR). */
+ prDetailPanel(): Locator {
+ return this.page.getByTestId("pr-detail-panel");
+ }
+
+ /** Dockview tab for the PR detail panel (title starts as "Pull Request", updated to "PR #N"). */
+ prDetailTab(): Locator {
+ return this.page.locator(".dv-default-tab").filter({ hasText: /^(Pull Request|PR #\d+)$/ });
+ }
+
/** Click a dockview tab by its visible label (e.g. "Changes", "Files", "Terminal"). */
async clickTab(label: string): Promise {
const tab = this.page.locator(`.dv-default-tab:has-text('${label}')`);
diff --git a/apps/web/e2e/tests/pr/pr-detail-auto-show.spec.ts b/apps/web/e2e/tests/pr/pr-detail-auto-show.spec.ts
new file mode 100644
index 0000000000..c19ad5cacf
--- /dev/null
+++ b/apps/web/e2e/tests/pr/pr-detail-auto-show.spec.ts
@@ -0,0 +1,110 @@
+import { test, expect } from "../../fixtures/test-base";
+import { KanbanPage } from "../../pages/kanban-page";
+import { SessionPage } from "../../pages/session-page";
+
+test.describe("PR detail panel", () => {
+ /**
+ * Verifies that the PR detail panel can be opened via the topbar button
+ * when a task has an associated pull request, and renders PR content.
+ *
+ * Setup:
+ * Inbox → Working (auto_start, on_turn_complete → Done) → Done
+ * Task A (with PR #101)
+ */
+ test("opens PR detail panel via topbar button", async ({ testPage, apiClient, seedData }) => {
+ test.setTimeout(120_000);
+
+ // --- Seed workflow ---
+ const workflow = await apiClient.createWorkflow(seedData.workspaceId, "PR Panel Workflow");
+
+ const inboxStep = await apiClient.createWorkflowStep(workflow.id, "Inbox", 0);
+ const workingStep = await apiClient.createWorkflowStep(workflow.id, "Working", 1);
+ const doneStep = await apiClient.createWorkflowStep(workflow.id, "Done", 2);
+
+ await apiClient.updateWorkflowStep(workingStep.id, {
+ prompt: 'e2e:message("done")\n{{task_prompt}}',
+ events: {
+ on_enter: [{ type: "auto_start_agent" }],
+ on_turn_complete: [{ type: "move_to_step", config: { step_id: doneStep.id } }],
+ },
+ });
+
+ await apiClient.saveUserSettings({
+ workspace_id: seedData.workspaceId,
+ workflow_filter_id: workflow.id,
+ enable_preview_on_click: false,
+ });
+
+ // --- Seed mock GitHub data ---
+ await apiClient.mockGitHubReset();
+ await apiClient.mockGitHubSetUser("test-user");
+
+ await apiClient.mockGitHubAddPRs([
+ {
+ number: 101,
+ title: "Fix auth bug",
+ state: "open",
+ head_branch: "fix/auth",
+ base_branch: "main",
+ author_login: "test-user",
+ repo_owner: "testorg",
+ repo_name: "testrepo",
+ additions: 10,
+ deletions: 2,
+ },
+ ]);
+
+ // --- Create task ---
+ const taskA = await apiClient.createTask(seedData.workspaceId, "Auth Fix Task", {
+ workflow_id: workflow.id,
+ workflow_step_id: inboxStep.id,
+ agent_profile_id: seedData.agentProfileId,
+ repository_ids: [seedData.repositoryId],
+ });
+
+ // Navigate to kanban BEFORE moving task
+ const kanban = new KanbanPage(testPage);
+ await kanban.goto();
+
+ // Move task to Working → auto_start → mock agent completes → Done
+ await apiClient.moveTask(taskA.id, workflow.id, workingStep.id);
+
+ // Associate PR with Task A
+ await apiClient.mockGitHubAssociateTaskPR({
+ task_id: taskA.id,
+ owner: "testorg",
+ repo: "testrepo",
+ pr_number: 101,
+ pr_url: "https://github.com/testorg/testrepo/pull/101",
+ pr_title: "Fix auth bug",
+ head_branch: "fix/auth",
+ base_branch: "main",
+ author_login: "test-user",
+ additions: 10,
+ deletions: 2,
+ });
+
+ // Wait for task to reach Done
+ await expect(kanban.taskCardInColumn("Auth Fix Task", doneStep.id)).toBeVisible({
+ timeout: 45_000,
+ });
+
+ // --- Open Task A ---
+ await kanban.taskCardInColumn("Auth Fix Task", doneStep.id).click();
+ await expect(testPage).toHaveURL(/\/t\//, { timeout: 15_000 });
+
+ const session = new SessionPage(testPage);
+ await session.waitForLoad();
+ await session.idleInput().waitFor({ state: "visible", timeout: 30_000 });
+
+ // Verify PR topbar button appears with PR number
+ await expect(session.prTopbarButton()).toBeVisible({ timeout: 15_000 });
+ await expect(session.prTopbarButton()).toContainText("#101");
+
+ // Click PR topbar button to open the PR detail panel
+ await session.prTopbarButton().click();
+
+ // Verify PR detail panel renders with content
+ await expect(session.prDetailPanel()).toBeVisible({ timeout: 10_000 });
+ });
+});