"use client"; import { useCallback, useEffect, useRef, useState } from "react"; import { DockviewDefaultTab, type IDockviewPanelHeaderProps } from "dockview-react"; import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuSeparator, ContextMenuTrigger, } from "@kandev/ui/context-menu"; import { useAppStore } from "@/components/state-provider"; import { destroyUserShell, renameUserShell } from "@/lib/api/domains/user-shell-api"; import { shouldConfirmTerminalClose } from "@/lib/terminal/terminal-busy-registry"; import { CloseTerminalConfirmDialog } from "./close-terminal-confirm-dialog"; import { markTerminalPanelTerminateClose } from "./dockview-layout-setup"; /** * Custom dockview tab for terminal panels. * * Mirrors the session-tab badge behaviour: the `#N` pill only renders when * there's more than one ordinary terminal in the active task — a single * terminal needs no disambiguation. * * The tab exposes a right-click context menu with Rename / Terminate; * choosing Rename swaps the title in place for an editable input. */ type StampedParams = { terminalId: string; taskID: string | undefined; environmentId: string | undefined; }; function extractParams(props: IDockviewPanelHeaderProps): StampedParams { const panelParams = (props.params ?? {}) as Record; return { terminalId: (panelParams.terminalId as string | undefined) ?? props.api.id, taskID: panelParams.taskID as string | undefined, environmentId: panelParams.environmentId as string | undefined, }; } /** * Tab title text — intentionally drops the backend's "Terminal {seq}" * suffix so the title reads "Terminal" and the seq lives only in the * sibling badge (mirroring session-tab's pattern where the agent name is * the title and the seq is a separate pill before it). * * Custom names override the default; legacy passthrough shells keep * their server-supplied label (e.g. "Script", "Dev Server"). */ function pickDisplayName( shell: { kind?: string; customName?: string | null; label?: string } | null, fallback: string, ): string { if (shell?.customName && shell.customName !== "") return shell.customName; if (shell?.kind === "ordinary") return "Terminal"; if (shell?.label) return shell.label; return fallback; } function useTerminalTabState(stampedEnv: string | undefined, terminalId: string, apiTitle: string) { const shell = useAppStore((s) => { if (!stampedEnv) return null; const list = s.userShells.byEnvironmentId[stampedEnv] ?? []; return list.find((it) => it.terminalId === terminalId) ?? null; }); const ordinaryCount = useAppStore((s) => { if (!stampedEnv) return 0; const list = s.userShells.byEnvironmentId[stampedEnv] ?? []; return list.filter((it) => it.kind === "ordinary").length; }); const isOrdinary = shell?.kind === "ordinary"; const seq = shell?.seq; const showBadge = isOrdinary && ordinaryCount > 1 && typeof seq === "number"; const displayName = pickDisplayName(shell, apiTitle); const closable = shell?.closable ?? true; return { shell, isOrdinary, seq, showBadge, displayName, closable }; } function useTerminalTabClose({ terminalId, taskID, stampedEnv, shell, closable, panelId, closePanel, }: { terminalId: string; taskID: string | null; stampedEnv: string | undefined; shell: { kind?: string } | null; closable: boolean; panelId: string; closePanel: () => void; }) { const removeUserShellStore = useAppStore((s) => s.removeUserShell); const [confirmClose, setConfirmClose] = useState(false); const destroyAndClosePanel = useCallback(async () => { if (!stampedEnv) { closePanel(); return; } try { await destroyUserShell(stampedEnv, terminalId, taskID ?? undefined); removeUserShellStore(stampedEnv, terminalId); markTerminalPanelTerminateClose(panelId); setConfirmClose(false); closePanel(); } catch (error) { console.error("close terminal:", error); } }, [stampedEnv, terminalId, taskID, removeUserShellStore, panelId, closePanel]); const handleCloseTab = useCallback(() => { if (!closable) return; if ( shouldConfirmTerminalClose(terminalId, { kind: shell?.kind, }) ) { setConfirmClose(true); return; } void destroyAndClosePanel(); }, [closable, terminalId, shell, destroyAndClosePanel]); return { confirmClose, setConfirmClose, handleCloseTab, destroyAndClosePanel }; } export function TerminalTab(props: IDockviewPanelHeaderProps) { const { terminalId, taskID: stampedTaskID, environmentId: stampedEnv } = extractParams(props); const activeTaskID = useAppStore((s) => s.tasks?.activeTaskId ?? null); const taskID = stampedTaskID ?? activeTaskID ?? null; const { shell, isOrdinary, seq, showBadge, displayName, closable } = useTerminalTabState( stampedEnv, terminalId, props.api.title ?? "Terminal", ); // DockviewDefaultTab reads the title from `api.title` directly and // ignores prop overrides — push the corrected title onto the api so // the default-tab body re-renders the right text. useEffect(() => { if (props.api.title !== displayName) props.api.setTitle(displayName); }, [props.api, displayName]); const [isRenaming, setIsRenaming] = useState(false); const { confirmClose, setConfirmClose, handleCloseTab, destroyAndClosePanel } = useTerminalTabClose({ terminalId, taskID, stampedEnv, shell, closable, panelId: props.api.id, closePanel: () => props.api.close(), }); const handleCommitRename = useRenameCommitter({ isOrdinary, stampedEnv, terminalId, taskID, currentCustomName: shell?.customName ?? null, onDone: () => setIsRenaming(false), }); const renameInitial = shell?.customName && shell.customName !== "" ? shell.customName : displayName; const seqBadgeForInput = showBadge && typeof seq === "number" ? seq : null; return ( <> {isRenaming ? ( setIsRenaming(false)} /> ) : ( )} setIsRenaming(true)} onClosePanel={() => props.api.close()} onTerminatePanel={() => { markTerminalPanelTerminateClose(props.api.id); props.api.close(); }} /> { if (!open) setConfirmClose(false); }} onConfirm={() => void destroyAndClosePanel()} /> ); } function useRenameCommitter({ isOrdinary, stampedEnv, terminalId, taskID, currentCustomName, onDone, }: { isOrdinary: boolean; stampedEnv: string | undefined; terminalId: string; taskID: string | null; currentCustomName: string | null; onDone: () => void; }) { const updateUserShell = useAppStore((s) => s.updateUserShell); return useCallback( async (next: string) => { onDone(); if (!isOrdinary || !stampedEnv) return; const normalized = next.trim() === "" ? null : next.trim(); if (currentCustomName === normalized) return; try { await renameUserShell(terminalId, normalized, taskID ?? undefined); updateUserShell(stampedEnv, terminalId, { customName: normalized }); } catch (error) { console.error("rename terminal:", error); } }, [isOrdinary, stampedEnv, terminalId, taskID, currentCustomName, updateUserShell, onDone], ); } function TerminalTabBody({ showBadge, seq, closable, terminalId, onCloseTab, // `displayName` is computed in the parent but consumed via the // api.setTitle effect — drop it here so it doesn't leak into the // DOM via the {...props} spread below (React warning otherwise). displayName: _displayName, ...props }: IDockviewPanelHeaderProps & { showBadge: boolean; seq: number | undefined; displayName: string; closable: boolean; onCloseTab: () => void; terminalId: string; }) { void _displayName; const tabContentRef = useRef(null); useEffect(() => { if (!closable) return; const closeAction = tabContentRef.current?.querySelector(".dv-default-tab-action"); if (!closeAction) return; closeAction.setAttribute("data-testid", `terminal-tab-close-${terminalId}`); return () => closeAction.removeAttribute("data-testid"); }, [closable, terminalId, props.api.id]); return (
{showBadge && ( {seq} )}
); } function TerminalTabRenameInput({ initial, seqBadge, onCommit, onCancel, }: { initial: string; seqBadge: number | null; onCommit: (next: string) => void; onCancel: () => void; }) { const [value, setValue] = useState(initial); const inputRef = useRef(null); useEffect(() => { inputRef.current?.focus(); inputRef.current?.select(); }, []); return (
e.stopPropagation()} onMouseDown={(e) => e.stopPropagation()} > {seqBadge != null && ( {seqBadge} )} setValue(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") { e.preventDefault(); onCommit(value); } else if (e.key === "Escape") { e.preventDefault(); onCancel(); } // Don't let dockview see typed keys as shortcuts. e.stopPropagation(); }} onBlur={() => onCommit(value)} data-testid="terminal-tab-rename-input" className="h-5 min-w-[6rem] max-w-[14rem] rounded border border-input bg-background px-1 text-xs outline-none focus:ring-1 focus:ring-ring" />
); } function TerminalTabMenu({ terminalId, taskID, environmentId, canMutate, onStartRename, onClosePanel, onTerminatePanel, }: { terminalId: string; taskID: string | null; environmentId: string | null; canMutate: boolean; onStartRename: () => void; onClosePanel: () => void; onTerminatePanel: () => void; }) { const removeUserShellStore = useAppStore((s) => s.removeUserShell); const handleTerminate = useCallback(async () => { if (!environmentId) return; if (!canMutate) { onClosePanel(); return; } try { await destroyUserShell(environmentId, terminalId, taskID ?? undefined); removeUserShellStore(environmentId, terminalId); onTerminatePanel(); } catch (error) { console.error("terminate terminal:", error); } }, [ canMutate, environmentId, terminalId, taskID, removeUserShellStore, onClosePanel, onTerminatePanel, ]); return ( {canMutate && ( <> Rename… )} Terminate ); }