"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 { PassthroughToolbar } from "./passthrough-toolbar"; 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 { isPassthroughSession } from "@/lib/session/is-passthrough-session"; 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(() => isPassthroughSession(activeSession), [activeSession]); 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>; setSavingFiles: React.Dispatch>>; 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 ? : 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([]); const [savingFiles, setSavingFiles] = useState>(new Set()); const [selectedDiff, setSelectedDiff] = useState(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 ? ( ) : undefined; return ( setSelectedDiff(null)} onOpenFile={handleOpenFileFromChat} /> setShowRequestChangesTooltip(false)} onOpenFile={handleOpenFileFromChat} /> {taskPR && activeSessionId && ( )} {openFileTabs.map((tab) => ( ))} ); }); // --- Extracted sub-components --- function ApproveButtonGroup({ onApprove, onRequestChanges, }: { onApprove: () => void; onRequestChanges: () => void; }) { return (
Approve and continue Request changes
); } 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 (
No task selected
); } if (isPassthroughMode) { return (
); } return ( ); }