"use client"; import type { ReactNode, MouseEvent, RefObject } from "react"; import type { Layout } from "react-resizable-panels"; import { memo, useEffect, useCallback, useState, useMemo, useRef } from "react"; import { SessionPanel } from "@kandev/ui/pannel-session"; import { Group, Panel } from "react-resizable-panels"; import { getLocalStorage, setLocalStorage, getSessionStorage, setSessionStorage, } from "@/lib/local-storage"; import { useAppStore } from "@/components/state-provider"; import { useLayoutStore } from "@/lib/state/layout-store"; import { useDefaultLayout } from "@/lib/layout/use-default-layout"; import { SessionTabs, type SessionTab } from "@/components/session-tabs"; import { useRepositoryScripts } from "@/hooks/domains/workspace/use-repository-scripts"; import { useTerminals } from "@/hooks/domains/session/use-terminals"; import { ParkedTerminalsMenu } from "@/components/task/parked-terminals-menu"; import { CloseTerminalConfirmPopover } from "@/components/task/close-terminal-confirm-popover"; import { CommandsTabContent, TerminalTabContents, } from "@/components/task/task-right-panel-tab-contents"; import { TerminalTabMenu } from "@/components/task/terminal-tab"; import { TabRenameInput } from "@/components/task/tab-rename-input"; import type { RepositoryScript } from "@/lib/types/http"; import type { Terminal } from "@/hooks/domains/session/use-terminals"; import { useTranslation } from "react-i18next"; type TaskRightPanelProps = { topPanel: ReactNode; sessionId?: string | null; repositoryId?: string | null; initialScripts?: RepositoryScript[]; initialTerminals?: Terminal[]; }; const DEFAULT_RIGHT_LAYOUT: Record = { top: 55, bottom: 45 }; type TerminalTabsBuilderOpts = { terminals: Terminal[]; handleCloseDevTab: (event: MouseEvent) => void; handleCloseTab: (event: MouseEvent, id: string) => void; renderContextMenu?: ( terminal: Terminal, closeAnchorRef: RefObject, ) => ReactNode; renderContent?: (terminal: Terminal) => ReactNode; onDoubleClick?: (event: MouseEvent, terminal: Terminal) => void; }; /** * Builds the SessionTab[] for the right-panel strip. * * Ordinary terminals get a `#N` badge (sequence number from the DB row) and * a context-menu hook for rename/destroy. Dev-server and script terminals * preserve their existing label/closable semantics. */ function buildTerminalTabs({ terminals, handleCloseDevTab, handleCloseTab, renderContextMenu, renderContent, onDoubleClick, }: TerminalTabsBuilderOpts): SessionTab[] { return terminals.map((terminal) => { const isOrdinary = terminal.kind === "ordinary"; const isDev = terminal.type === "dev-server"; return { id: terminal.id, label: terminal.label, badge: isOrdinary && terminal.seq ? `#${terminal.seq}` : undefined, truncate: !isOrdinary, className: terminal.closable ? "group flex items-center cursor-pointer" : "cursor-pointer", closable: terminal.closable, onClose: isDev ? handleCloseDevTab : (e: MouseEvent) => handleCloseTab(e, terminal.id), renderContextMenu: isOrdinary && renderContextMenu ? (closeAnchorRef) => renderContextMenu(terminal, closeAnchorRef) : undefined, content: isOrdinary ? renderContent?.(terminal) : undefined, onDoubleClick: isOrdinary && onDoubleClick ? (e: MouseEvent) => onDoubleClick(e, terminal) : undefined, testId: `terminal-tab-${terminal.id}`, closeTestId: `terminal-tab-close-${terminal.id}`, }; }); } function useRightPanelScripts(repositoryId: string | null, initialScripts: RepositoryScript[]) { const { scripts: storeScripts, isLoaded: scriptsLoaded } = useRepositoryScripts(repositoryId); const scripts = !scriptsLoaded && initialScripts.length > 0 ? initialScripts : storeScripts; return { scripts, hasScripts: scripts.length > 0 }; } function useRightPanelPersistence({ sessionId, hasScripts, activeTab, isBottomCollapsed, setRightPanelActiveTab, }: { sessionId: string | null; hasScripts: boolean; activeTab: string | undefined; isBottomCollapsed: boolean; setRightPanelActiveTab: (sessionId: string, tabId: string) => void; }) { useEffect(() => { if (!sessionId || !hasScripts) return; const savedTab = getSessionStorage(`rightPanel-tab-${sessionId}`, null); if (savedTab === "commands") setRightPanelActiveTab(sessionId, savedTab); }, [sessionId, hasScripts, setRightPanelActiveTab]); useEffect(() => { if (!sessionId || !activeTab) return; setSessionStorage(`rightPanel-tab-${sessionId}`, activeTab); }, [sessionId, activeTab]); useEffect(() => { setLocalStorage("task-right-panel-collapsed", isBottomCollapsed); }, [isBottomCollapsed]); } function useRightPanelTabChange( sessionId: string | null, setRightPanelActiveTab: (sessionId: string, tabId: string) => void, ) { return useCallback( (value: string) => { if (sessionId) setRightPanelActiveTab(sessionId, value); }, [sessionId, setRightPanelActiveTab], ); } function useRightPanelTabs({ hasScripts, terminals, handleCloseDevTab, handleCloseTab, renameTerminal, requestCloseFromContext, sessionId, setRightPanelActiveTab, }: { hasScripts: boolean; terminals: Terminal[]; handleCloseDevTab: (event: MouseEvent) => void; handleCloseTab: (event: MouseEvent, terminalId: string) => void; renameTerminal: (id: string, name: string | null) => Promise | void; requestCloseFromContext: (id: string, closeAnchor: HTMLElement | null) => void; sessionId: string | null; setRightPanelActiveTab: (sessionId: string, tabId: string) => void; }) { const { t } = useTranslation(); const [renamingTerminalId, setRenamingTerminalId] = useState(null); const handleStartRename = useCallback((terminalId: string) => { setRenamingTerminalId(terminalId); }, []); const handleRenameCommit = useCallback( async (terminalId: string, next: string) => { setRenamingTerminalId(null); const trimmed = next.trim(); try { await renameTerminal(terminalId, trimmed === "" ? null : trimmed); } catch (error) { console.error(error); } }, [renameTerminal], ); const handleRenameCancel = useCallback(() => { setRenamingTerminalId(null); }, []); const renderTerminalContextMenu = useCallback( (terminal: Terminal, closeAnchorRef: RefObject) => ( handleStartRename(terminal.id)} onClosePanel={() => undefined} onTerminatePanel={() => requestCloseFromContext(terminal.id, closeAnchorRef.current)} /> ), [handleStartRename, requestCloseFromContext], ); const renderTerminalContent = useCallback( (terminal: Terminal) => { if (renamingTerminalId !== terminal.id) return undefined; let initial = terminal.label; if (terminal.seq) initial = t("task:terminalWithSeq", { seq: terminal.seq }); if (terminal.customName && terminal.customName !== "") initial = terminal.customName; return ( void handleRenameCommit(terminal.id, next)} onCancel={handleRenameCancel} testId="terminal-tab-rename-input" /> ); }, [handleRenameCancel, handleRenameCommit, renamingTerminalId, t], ); const tabs: SessionTab[] = useMemo(() => { const commandsTabs: SessionTab[] = hasScripts ? [{ id: "commands", label: t("common:commandGroupCommands") }] : []; return [ ...commandsTabs, ...buildTerminalTabs({ terminals, handleCloseDevTab, handleCloseTab, renderContextMenu: renderTerminalContextMenu, renderContent: renderTerminalContent, onDoubleClick: (_event, terminal) => handleStartRename(terminal.id), }), ]; }, [ hasScripts, terminals, handleCloseDevTab, handleCloseTab, renderTerminalContextMenu, renderTerminalContent, handleStartRename, t, ]); const handleTabChange = useRightPanelTabChange(sessionId, setRightPanelActiveTab); return { tabs, handleTabChange }; } /** Gate every destructive terminal close behind a control-anchored confirmation. */ function useConfirmableTerminalClose({ terminals, handleCloseTab, destroyTerminal, }: { terminals: Terminal[]; handleCloseTab: (event: MouseEvent, terminalId: string) => void; destroyTerminal: (id: string) => Promise; }) { const [pendingClose, setPendingClose] = useState(null); const closeAnchorRef = useRef(null); const requestClose = useCallback( (terminalId: string, anchor: HTMLElement | null) => { const terminal = terminals.find((t) => t.id === terminalId); if (!terminal || !anchor) return false; closeAnchorRef.current = anchor; setPendingClose(terminal); return true; }, [terminals], ); const handleAskCloseTab = useCallback( (event: MouseEvent, terminalId: string) => { if (requestClose(terminalId, event.currentTarget as HTMLElement)) { event.preventDefault(); event.stopPropagation(); return; } handleCloseTab(event, terminalId); }, [requestClose, handleCloseTab], ); const handleConfirmClose = useCallback(async () => { if (!pendingClose) return; await destroyTerminal(pendingClose.id); }, [pendingClose, destroyTerminal]); return { pendingClose, setPendingClose, closeAnchorRef, handleAskCloseTab, requestClose, handleConfirmClose, }; } type CollapsedRightPanelProps = { topPanel: ReactNode; tabs: SessionTab[]; terminalTabValue: string; handleTabChange: (value: string) => void; addTerminal: () => void; onExpand: () => void; }; function CollapsedRightPanel({ topPanel, tabs, terminalTabValue, handleTabChange, addTerminal, onExpand, }: CollapsedRightPanelProps) { return (
{topPanel}
); } type RightPanelContentProps = { isBottomCollapsed: boolean; topPanel: ReactNode; tabs: SessionTab[]; terminalTabValue: string; handleTabChange: (value: string) => void; addTerminal: () => void; setIsBottomCollapsed: (v: boolean) => void; rightLayoutKey: string; rightLayout: Layout | undefined; onRightLayoutChange: (layout: Layout) => void; scripts: RepositoryScript[]; handleRunCommand: (script: RepositoryScript) => void; terminals: Terminal[]; parkedTerminals: Terminal[]; resumeTerminal: (id: string) => Promise | void; destroyTerminal: (id: string) => Promise | void; environmentId: string | null; devProcessId: string | null | undefined; devOutput: string | undefined; isStoppingDev: boolean; }; function RightPanelContent({ isBottomCollapsed, topPanel, tabs, terminalTabValue, handleTabChange, addTerminal, setIsBottomCollapsed, rightLayoutKey, rightLayout, onRightLayoutChange, scripts, handleRunCommand, terminals, parkedTerminals, resumeTerminal, destroyTerminal, environmentId, devProcessId, devOutput, isStoppingDev, }: RightPanelContentProps) { if (isBottomCollapsed) { return ( setIsBottomCollapsed(false)} /> ); } return ( {topPanel} setIsBottomCollapsed(true)} rightContent={ parkedTerminals.length > 0 ? ( { void destroyTerminal(terminalId); }} /> ) : undefined } className="flex-1 min-h-0" > ); } function useTaskRightPanel({ sessionId, repositoryId, initialScripts = [], initialTerminals, }: Required> & Pick) { const rightLayoutKey = "task-layout-right-v2"; const { defaultLayout: rightLayout, onLayoutChanged: onRightLayoutChange } = useDefaultLayout({ id: rightLayoutKey, panelIds: ["top", "bottom"], baseLayout: DEFAULT_RIGHT_LAYOUT, }); const [isBottomCollapsed, setIsBottomCollapsed] = useState(() => getLocalStorage("task-right-panel-collapsed", false), ); const setRightPanelActiveTab = useAppStore((state) => state.setRightPanelActiveTab); const environmentId = useAppStore((state) => sessionId ? (state.environmentIdBySessionId[sessionId] ?? null) : null, ); const closeLayoutPreview = useLayoutStore((state) => state.closePreview); // Use the terminals hook — env-keyed for shell ops, session-keyed for tab UX const terminalsApi = useTerminals({ sessionId, environmentId, initialTerminals }); const { terminals, activeTab, handleCloseDevTab: baseHandleCloseDevTab, handleCloseTab, renameTerminal, destroyTerminal, } = terminalsApi; // Wrap handleCloseDevTab to also close the layout preview const handleCloseDevTab = useCallback( async (event: MouseEvent) => { await baseHandleCloseDevTab(event); if (sessionId) { closeLayoutPreview(sessionId); } }, [baseHandleCloseDevTab, sessionId, closeLayoutPreview], ); const { scripts, hasScripts } = useRightPanelScripts(repositoryId, initialScripts); useRightPanelPersistence({ sessionId, hasScripts, activeTab, isBottomCollapsed, setRightPanelActiveTab, }); const closeConfirm = useConfirmableTerminalClose({ terminals, handleCloseTab, destroyTerminal }); const { tabs, handleTabChange } = useRightPanelTabs({ hasScripts, terminals, handleCloseDevTab, handleCloseTab: closeConfirm.handleAskCloseTab, renameTerminal, requestCloseFromContext: closeConfirm.requestClose, sessionId, setRightPanelActiveTab, }); return { ...terminalsApi, rightLayoutKey, rightLayout, onRightLayoutChange, isBottomCollapsed, setIsBottomCollapsed, environmentId, scripts, tabs, handleTabChange, closeConfirm, }; } const TaskRightPanel = memo(function TaskRightPanel({ topPanel, sessionId = null, repositoryId = null, initialScripts = [], initialTerminals, }: TaskRightPanelProps) { const { t } = useTranslation(); const { terminals, parkedTerminals, terminalTabValue, addTerminal, handleRunCommand, resumeTerminal, destroyTerminal, isStoppingDev, devProcessId, devOutput, rightLayoutKey, rightLayout, onRightLayoutChange, isBottomCollapsed, setIsBottomCollapsed, environmentId, scripts, tabs, handleTabChange, closeConfirm, } = useTaskRightPanel({ sessionId, repositoryId, initialScripts, initialTerminals }); const { pendingClose, setPendingClose, closeAnchorRef, handleConfirmClose } = closeConfirm; return ( <> { if (!open) setPendingClose(null); }} onConfirm={handleConfirmClose} /> ); }); export { TaskRightPanel };