feat: auto-show PR details panel when task has associated PR (#517)

This commit is contained in:
Carlos Florêncio
2026-04-02 00:08:35 +01:00
committed by GitHub
parent 1c6506911e
commit dbe778e4e8
7 changed files with 341 additions and 70 deletions
@@ -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");
});
});
@@ -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<Set<string>>(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.
@@ -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 (
<TabsContent value={`file:${tab.path}`} className="flex-1 min-h-0">
{category === "image" && (
<FileImageViewer
path={tab.path}
content={tab.content}
worktreePath={activeSession?.worktree_path ?? undefined}
/>
)}
{category === "binary" && (
<FileBinaryViewer
path={tab.path}
worktreePath={activeSession?.worktree_path ?? undefined}
/>
)}
{category === "text" && (
<FileEditorContent
path={tab.path}
content={tab.content}
originalContent={tab.originalContent}
isDirty={tab.isDirty}
isSaving={isSaving}
sessionId={activeSessionId || undefined}
worktreePath={activeSession?.worktree_path ?? undefined}
enableComments={!!activeSessionId}
onChange={(newContent) => onFileChange(tab.path, newContent)}
onSave={() => onFileSave(tab.path)}
onDelete={() => onFileDelete(tab.path)}
/>
)}
</TabsContent>
);
}
+19 -62
View File
@@ -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<OpenFileTab[]>([]);
const [savingFiles, setSavingFiles] = useState<Set<string>>(new Set());
const [selectedDiff, setSelectedDiff] = useState<SelectedDiff | null>(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 && (
<TabsContent value="pr" className="flex-1 min-h-0" data-testid="pr-detail-panel">
<PRDetailContent taskPR={taskPR} sessionId={activeSessionId} />
</TabsContent>
)}
{openFileTabs.map((tab) => (
<FileTabContent
key={tab.path}
@@ -534,58 +546,3 @@ function ChatTabContent({
</TabsContent>
);
}
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 (
<TabsContent value={`file:${tab.path}`} className="flex-1 min-h-0">
{category === "image" && (
<FileImageViewer
path={tab.path}
content={tab.content}
worktreePath={activeSession?.worktree_path ?? undefined}
/>
)}
{category === "binary" && (
<FileBinaryViewer
path={tab.path}
worktreePath={activeSession?.worktree_path ?? undefined}
/>
)}
{category === "text" && (
<FileEditorContent
path={tab.path}
content={tab.content}
originalContent={tab.originalContent}
isDirty={tab.isDirty}
isSaving={isSaving}
sessionId={activeSessionId || undefined}
worktreePath={activeSession?.worktree_path ?? undefined}
enableComments={!!activeSessionId}
onChange={(newContent) => onFileChange(tab.path, newContent)}
onSave={() => onFileSave(tab.path)}
onDelete={() => onFileDelete(tab.path)}
/>
)}
</TabsContent>
);
}