549 lines
17 KiB
TypeScript
549 lines
17 KiB
TypeScript
"use client";
|
|
|
|
import { memo, useCallback, useState, useEffect, useMemo } from "react";
|
|
import { IconCheck, IconChevronDown, IconX } from "@tabler/icons-react";
|
|
import { TabsContent } from "@kandev/ui/tabs";
|
|
import { Button } from "@kandev/ui/button";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
DropdownMenuSeparator,
|
|
} from "@kandev/ui/dropdown-menu";
|
|
import { SessionPanel } from "@kandev/ui/pannel-session";
|
|
import { TaskChatPanel } from "./task-chat-panel";
|
|
import { TaskChangesPanel } from "./task-changes-panel";
|
|
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";
|
|
import { SessionTabs, type SessionTab } from "@/components/session-tabs";
|
|
import { getWebSocketClient } from "@/lib/ws/connection";
|
|
import { executeApprove } from "@/lib/services/session-approve";
|
|
import { requestFileContent } from "@/lib/ws/workspace-files";
|
|
import {
|
|
setOpenFileTabs as saveOpenFileTabs,
|
|
getActiveTabForSession,
|
|
setActiveTabForSession,
|
|
} from "@/lib/local-storage";
|
|
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 { 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";
|
|
|
|
type TaskCenterPanelProps = {
|
|
selectedDiff: SelectedDiff | null;
|
|
openFileRequest: OpenFileTab | null;
|
|
onDiffHandled: () => void;
|
|
onFileOpenHandled: () => void;
|
|
onActiveFileChange?: (filePath: string | null) => void;
|
|
sessionId?: string | null;
|
|
};
|
|
|
|
function useSessionApprove(activeSessionId: string | null, activeTaskId: string | null) {
|
|
const activeSession = useAppStore((state) =>
|
|
activeSessionId ? (state.taskSessions.items[activeSessionId] ?? null) : null,
|
|
);
|
|
const setTaskSession = useAppStore((state) => state.setTaskSession);
|
|
const isAgentWorking = activeSession?.state === "STARTING" || activeSession?.state === "RUNNING";
|
|
const isPassthroughMode = useMemo(() => {
|
|
if (!activeSession?.agent_profile_snapshot) return false;
|
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
const snapshot = activeSession.agent_profile_snapshot as any;
|
|
return snapshot?.cli_passthrough === true;
|
|
}, [activeSession?.agent_profile_snapshot]);
|
|
const showApproveButton =
|
|
!!activeSession?.review_status && activeSession.review_status !== "approved" && !isAgentWorking;
|
|
const handleApprove = useCallback(async () => {
|
|
if (!activeSessionId || !activeTaskId) return;
|
|
try {
|
|
await executeApprove(activeSessionId, activeTaskId, setTaskSession);
|
|
} catch (error) {
|
|
console.error("Failed to approve session:", error);
|
|
}
|
|
}, [activeSessionId, activeTaskId, setTaskSession]);
|
|
return { activeSession, isPassthroughMode, showApproveButton, handleApprove };
|
|
}
|
|
|
|
function useLeftTabState(
|
|
activeSessionId: string | null,
|
|
hasChanges: boolean | undefined,
|
|
onActiveFileChange?: (filePath: string | null) => void,
|
|
) {
|
|
const [leftTab, setLeftTab] = useState(() => {
|
|
if (typeof window !== "undefined" && activeSessionId) {
|
|
const savedTab = getActiveTabForSession(activeSessionId, "chat");
|
|
if (savedTab === "chat" || savedTab === "changes") return savedTab;
|
|
}
|
|
return "chat";
|
|
});
|
|
const [showRequestChangesTooltip, setShowRequestChangesTooltip] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (leftTab === "changes" && !hasChanges) queueMicrotask(() => setLeftTab("chat"));
|
|
}, [leftTab, hasChanges]);
|
|
useEffect(() => {
|
|
const handler = () => {
|
|
if (hasChanges) setLeftTab("changes");
|
|
};
|
|
window.addEventListener("switch-to-changes-tab", handler);
|
|
return () => window.removeEventListener("switch-to-changes-tab", handler);
|
|
}, [hasChanges]);
|
|
useEffect(() => {
|
|
if (leftTab.startsWith("file:")) onActiveFileChange?.(leftTab.replace("file:", ""));
|
|
else onActiveFileChange?.(null);
|
|
}, [leftTab, onActiveFileChange]);
|
|
|
|
const handleTabChange = useCallback(
|
|
(tab: string) => {
|
|
setLeftTab(tab);
|
|
if (activeSessionId) setActiveTabForSession(activeSessionId, tab);
|
|
},
|
|
[activeSessionId],
|
|
);
|
|
const handleRequestChanges = useCallback(() => {
|
|
setLeftTab("chat");
|
|
setShowRequestChangesTooltip(true);
|
|
setTimeout(() => setShowRequestChangesTooltip(false), 5000);
|
|
}, []);
|
|
return {
|
|
leftTab,
|
|
setLeftTab,
|
|
showRequestChangesTooltip,
|
|
setShowRequestChangesTooltip,
|
|
handleTabChange,
|
|
handleRequestChanges,
|
|
};
|
|
}
|
|
|
|
type FileTabOperationsOptions = {
|
|
activeSessionId: string | null;
|
|
openFileTabs: OpenFileTab[];
|
|
setOpenFileTabs: React.Dispatch<React.SetStateAction<OpenFileTab[]>>;
|
|
setSavingFiles: React.Dispatch<React.SetStateAction<Set<string>>>;
|
|
setLeftTab: (tab: string) => void;
|
|
handleTabChange: (tab: string) => void;
|
|
leftTab: string;
|
|
};
|
|
|
|
function useFileTabOperations({
|
|
activeSessionId,
|
|
openFileTabs,
|
|
setOpenFileTabs,
|
|
setSavingFiles,
|
|
setLeftTab,
|
|
handleTabChange,
|
|
leftTab,
|
|
}: FileTabOperationsOptions) {
|
|
const { toast } = useToast();
|
|
|
|
const addFileTab = useCallback(
|
|
(fileTab: OpenFileTab) => {
|
|
setOpenFileTabs((prev) => {
|
|
if (prev.some((t) => t.path === fileTab.path)) return prev;
|
|
const maxTabs = 4;
|
|
return prev.length >= maxTabs ? [...prev.slice(1), fileTab] : [...prev, fileTab];
|
|
});
|
|
setLeftTab(`file:${fileTab.path}`);
|
|
},
|
|
[setOpenFileTabs, setLeftTab],
|
|
);
|
|
|
|
const handleOpenFileFromChat = useCallback(
|
|
async (filePath: string) => {
|
|
const client = getWebSocketClient();
|
|
if (!client || !activeSessionId) return;
|
|
try {
|
|
const response: FileContentResponse = await requestFileContent(
|
|
client,
|
|
activeSessionId,
|
|
filePath,
|
|
);
|
|
const fileName = filePath.split("/").pop() || filePath;
|
|
const hash = await calculateHash(response.content);
|
|
addFileTab({
|
|
path: filePath,
|
|
name: fileName,
|
|
content: response.content,
|
|
originalContent: response.content,
|
|
originalHash: hash,
|
|
isDirty: false,
|
|
isBinary: response.is_binary,
|
|
});
|
|
} catch (error) {
|
|
toast({
|
|
title: "Failed to open file",
|
|
description: error instanceof Error ? error.message : "Unknown error",
|
|
variant: "error",
|
|
});
|
|
}
|
|
},
|
|
[activeSessionId, toast, addFileTab],
|
|
);
|
|
|
|
const handleCloseFileTab = useCallback(
|
|
(path: string) => {
|
|
setOpenFileTabs((prev) => prev.filter((t) => t.path !== path));
|
|
if (leftTab === `file:${path}`) handleTabChange("chat");
|
|
},
|
|
[leftTab, handleTabChange, setOpenFileTabs],
|
|
);
|
|
|
|
const handleFileChange = useCallback(
|
|
(path: string, newContent: string) => {
|
|
setOpenFileTabs((prev) =>
|
|
prev.map((tab) =>
|
|
tab.path === path
|
|
? { ...tab, content: newContent, isDirty: newContent !== tab.originalContent }
|
|
: tab,
|
|
),
|
|
);
|
|
},
|
|
[setOpenFileTabs],
|
|
);
|
|
|
|
const { handleFileSave, handleFileDelete } = useFileSaveDelete({
|
|
activeSessionId,
|
|
openFileTabs,
|
|
setOpenFileTabs,
|
|
setSavingFiles,
|
|
handleCloseFileTab,
|
|
});
|
|
|
|
return {
|
|
handleOpenFileFromChat,
|
|
handleCloseFileTab,
|
|
handleFileChange,
|
|
handleFileSave,
|
|
handleFileDelete,
|
|
addFileTab,
|
|
};
|
|
}
|
|
|
|
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}`,
|
|
label: tab.isDirty ? `${tab.name} *` : tab.name,
|
|
icon: tab.isDirty ? <span className="h-2 w-2 rounded-full bg-yellow-500" /> : undefined,
|
|
closable: true,
|
|
onClose: (e: React.MouseEvent) => {
|
|
e.stopPropagation();
|
|
handleCloseFileTab(tab.path);
|
|
},
|
|
className: "cursor-pointer group gap-1.5 data-[state=active]:bg-muted",
|
|
}));
|
|
return [...staticTabs, ...fileTabs];
|
|
}, [openFileTabs, handleCloseFileTab, hasChanges, hasPR]);
|
|
const separatorAfterIndex = useMemo(() => {
|
|
if (openFileTabs.length === 0) return undefined;
|
|
const staticCount = (hasChanges ? 1 : 0) + 1 + (hasPR ? 1 : 0); // changes + chat + pr
|
|
return staticCount - 1;
|
|
}, [openFileTabs.length, hasChanges, hasPR]);
|
|
return { tabs, separatorAfterIndex };
|
|
}
|
|
|
|
function useCenterPanelState(props: TaskCenterPanelProps) {
|
|
const {
|
|
selectedDiff: externalSelectedDiff,
|
|
openFileRequest,
|
|
onDiffHandled,
|
|
onFileOpenHandled,
|
|
onActiveFileChange,
|
|
} = props;
|
|
const activeTaskId = useAppStore((state) => state.tasks.activeTaskId);
|
|
const activeSessionId = useAppStore((state) => state.tasks.activeSessionId);
|
|
const gitStatus = useSessionGitStatus(activeSessionId);
|
|
const { commits } = useSessionCommits(activeSessionId);
|
|
const hasChanges = useMemo(() => {
|
|
const hasUncommittedChanges = gitStatus?.files && Object.keys(gitStatus.files).length > 0;
|
|
return (hasUncommittedChanges || (commits && commits.length > 0)) as boolean;
|
|
}, [gitStatus, commits]);
|
|
const { activeSession, isPassthroughMode, showApproveButton, handleApprove } = useSessionApprove(
|
|
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);
|
|
const {
|
|
leftTab,
|
|
setLeftTab,
|
|
showRequestChangesTooltip,
|
|
setShowRequestChangesTooltip,
|
|
handleTabChange,
|
|
handleRequestChanges,
|
|
} = useLeftTabState(activeSessionId, hasChanges, onActiveFileChange);
|
|
useFileTabRestoration({ activeSessionId, leftTab, setLeftTab, setOpenFileTabs });
|
|
useEffect(() => {
|
|
if (!activeSessionId) return;
|
|
saveOpenFileTabs(
|
|
activeSessionId,
|
|
openFileTabs.map(({ path, name }) => ({ path, name })),
|
|
);
|
|
}, [activeSessionId, openFileTabs]);
|
|
const fileTabOps = useFileTabOperations({
|
|
activeSessionId,
|
|
openFileTabs,
|
|
setOpenFileTabs,
|
|
setSavingFiles,
|
|
setLeftTab,
|
|
handleTabChange,
|
|
leftTab,
|
|
});
|
|
const { tabs, separatorAfterIndex } = useCenterPanelTabs(
|
|
openFileTabs,
|
|
fileTabOps.handleCloseFileTab,
|
|
hasChanges,
|
|
hasPR,
|
|
);
|
|
|
|
useEffect(() => {
|
|
if (externalSelectedDiff) {
|
|
queueMicrotask(() => {
|
|
setSelectedDiff(externalSelectedDiff);
|
|
setLeftTab("changes");
|
|
onDiffHandled();
|
|
});
|
|
}
|
|
}, [externalSelectedDiff, onDiffHandled, setLeftTab]);
|
|
|
|
useEffect(() => {
|
|
if (!openFileRequest) return;
|
|
queueMicrotask(async () => {
|
|
const hash = openFileRequest.originalHash || (await calculateHash(openFileRequest.content));
|
|
fileTabOps.addFileTab({
|
|
...openFileRequest,
|
|
originalContent: openFileRequest.originalContent || openFileRequest.content,
|
|
originalHash: hash,
|
|
isDirty: openFileRequest.isDirty ?? false,
|
|
});
|
|
onFileOpenHandled();
|
|
});
|
|
}, [openFileRequest, onFileOpenHandled, fileTabOps.addFileTab]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
|
|
return {
|
|
activeTaskId,
|
|
activeSessionId,
|
|
activeSession,
|
|
isPassthroughMode,
|
|
showApproveButton,
|
|
handleApprove,
|
|
taskPR,
|
|
openFileTabs,
|
|
savingFiles,
|
|
selectedDiff,
|
|
setSelectedDiff,
|
|
leftTab,
|
|
showRequestChangesTooltip,
|
|
setShowRequestChangesTooltip,
|
|
handleTabChange,
|
|
handleRequestChanges,
|
|
fileTabOps,
|
|
tabs,
|
|
separatorAfterIndex,
|
|
};
|
|
}
|
|
|
|
export const TaskCenterPanel = memo(function TaskCenterPanel(props: TaskCenterPanelProps) {
|
|
const { sessionId = null } = props;
|
|
const state = useCenterPanelState(props);
|
|
const {
|
|
activeTaskId,
|
|
activeSessionId,
|
|
activeSession,
|
|
isPassthroughMode,
|
|
showApproveButton,
|
|
handleApprove,
|
|
taskPR,
|
|
openFileTabs,
|
|
savingFiles,
|
|
selectedDiff,
|
|
setSelectedDiff,
|
|
leftTab,
|
|
showRequestChangesTooltip,
|
|
setShowRequestChangesTooltip,
|
|
handleTabChange,
|
|
handleRequestChanges,
|
|
fileTabOps,
|
|
tabs,
|
|
separatorAfterIndex,
|
|
} = state;
|
|
const { handleOpenFileFromChat, handleFileChange, handleFileSave, handleFileDelete } = fileTabOps;
|
|
|
|
const approveContent = showApproveButton ? (
|
|
<ApproveButtonGroup onApprove={handleApprove} onRequestChanges={handleRequestChanges} />
|
|
) : undefined;
|
|
|
|
return (
|
|
<SessionPanel borderSide="right" margin="right">
|
|
<SessionTabs
|
|
tabs={tabs}
|
|
activeTab={leftTab}
|
|
onTabChange={handleTabChange}
|
|
separatorAfterIndex={separatorAfterIndex}
|
|
className="flex-1 min-h-0 flex flex-col gap-2"
|
|
rightContent={approveContent}
|
|
>
|
|
<TabsContent value="changes" className="flex-1 min-h-0">
|
|
<TaskChangesPanel
|
|
selectedDiff={selectedDiff}
|
|
onClearSelected={() => setSelectedDiff(null)}
|
|
onOpenFile={handleOpenFileFromChat}
|
|
/>
|
|
</TabsContent>
|
|
<ChatTabContent
|
|
activeTaskId={activeTaskId}
|
|
isPassthroughMode={isPassthroughMode}
|
|
sessionId={sessionId}
|
|
showRequestChangesTooltip={showRequestChangesTooltip}
|
|
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}
|
|
tab={tab}
|
|
activeSession={activeSession}
|
|
activeSessionId={activeSessionId}
|
|
isSaving={savingFiles.has(tab.path)}
|
|
onFileChange={handleFileChange}
|
|
onFileSave={handleFileSave}
|
|
onFileDelete={handleFileDelete}
|
|
/>
|
|
))}
|
|
</SessionTabs>
|
|
</SessionPanel>
|
|
);
|
|
});
|
|
|
|
// --- Extracted sub-components ---
|
|
|
|
function ApproveButtonGroup({
|
|
onApprove,
|
|
onRequestChanges,
|
|
}: {
|
|
onApprove: () => void;
|
|
onRequestChanges: () => void;
|
|
}) {
|
|
return (
|
|
<div className="flex items-center gap-0.5">
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
className="h-6 gap-1.5 px-2.5 cursor-pointer bg-emerald-600 hover:bg-emerald-700 text-white text-xs font-medium rounded-r-none border-r border-emerald-700/30"
|
|
onClick={onApprove}
|
|
>
|
|
<IconCheck className="h-3.5 w-3.5" />
|
|
Approve
|
|
</Button>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
className="h-6 w-6 p-0 cursor-pointer bg-emerald-600 hover:bg-emerald-700 text-white rounded-l-none"
|
|
>
|
|
<IconChevronDown className="h-3 w-3" />
|
|
</Button>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end" className="w-48">
|
|
<DropdownMenuItem onClick={onApprove} className="cursor-pointer">
|
|
<IconCheck className="h-4 w-4 mr-2" />
|
|
Approve and continue
|
|
</DropdownMenuItem>
|
|
<DropdownMenuSeparator />
|
|
<DropdownMenuItem
|
|
onClick={onRequestChanges}
|
|
className="cursor-pointer text-amber-600 dark:text-amber-500"
|
|
>
|
|
<IconX className="h-4 w-4 mr-2" />
|
|
Request changes
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function ChatTabContent({
|
|
activeTaskId,
|
|
isPassthroughMode,
|
|
sessionId,
|
|
showRequestChangesTooltip,
|
|
onDismissTooltip,
|
|
onOpenFile,
|
|
}: {
|
|
activeTaskId: string | null;
|
|
isPassthroughMode: boolean;
|
|
sessionId: string | null | undefined;
|
|
showRequestChangesTooltip: boolean;
|
|
onDismissTooltip: () => void;
|
|
onOpenFile: (filePath: string) => void;
|
|
}) {
|
|
if (!activeTaskId) {
|
|
return (
|
|
<TabsContent
|
|
value="chat"
|
|
className="flex flex-col min-h-0 flex-1"
|
|
style={{ minHeight: "200px" }}
|
|
>
|
|
<div className="flex items-center justify-center h-full text-muted-foreground">
|
|
No task selected
|
|
</div>
|
|
</TabsContent>
|
|
);
|
|
}
|
|
if (isPassthroughMode) {
|
|
return (
|
|
<TabsContent
|
|
value="chat"
|
|
className="flex flex-col min-h-0 flex-1"
|
|
style={{ minHeight: "200px" }}
|
|
>
|
|
<div className="flex-1 min-h-0 h-full" style={{ minHeight: "150px" }}>
|
|
<PassthroughTerminal key={activeTaskId} sessionId={sessionId} mode="agent" />
|
|
</div>
|
|
</TabsContent>
|
|
);
|
|
}
|
|
return (
|
|
<TabsContent
|
|
value="chat"
|
|
className="flex flex-col min-h-0 flex-1"
|
|
style={{ minHeight: "200px" }}
|
|
>
|
|
<TaskChatPanel
|
|
sessionId={sessionId}
|
|
onOpenFile={onOpenFile}
|
|
showRequestChangesTooltip={showRequestChangesTooltip}
|
|
onRequestChangesTooltipDismiss={onDismissTooltip}
|
|
onOpenFileAtLine={onOpenFile}
|
|
/>
|
|
</TabsContent>
|
|
);
|
|
}
|