Files
kandev/apps/web/components/task/terminal-tab.tsx
T
2026-08-20 17:23:35 +01:00

400 lines
12 KiB
TypeScript

"use client";
import { useCallback, useEffect, useRef, useState, type RefObject } 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 { renameUserShell } from "@/lib/api/domains/user-shell-api";
import { useTerminalDestroy } from "@/hooks/domains/session/use-terminal-destroy";
import { CloseTerminalConfirmPopover } from "./close-terminal-confirm-popover";
import { TabRenameInput } from "./tab-rename-input";
import { markTerminalPanelTerminateClose } from "./dockview-layout-setup";
import { useTranslation } from "react-i18next";
/**
* 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<string, unknown>;
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,
ordinaryLabel: string,
): string {
if (shell?.customName && shell.customName !== "") return shell.customName;
if (shell?.kind === "ordinary") return ordinaryLabel;
if (shell?.label) return shell.label;
return fallback;
}
function useTerminalTabState(
stampedEnv: string | undefined,
terminalId: string,
apiTitle: string,
ordinaryLabel: 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, ordinaryLabel);
const closable = shell?.closable ?? true;
return { shell, isOrdinary, seq, showBadge, displayName, closable };
}
function useTerminalTabClose({
terminalId,
taskID,
stampedEnv,
closable,
panelId,
closePanel,
}: {
terminalId: string;
taskID: string | null;
stampedEnv: string | undefined;
closable: boolean;
panelId: string;
closePanel: () => void;
}) {
const removeUserShellStore = useAppStore((s) => s.removeUserShell);
const [confirmClose, setConfirmClose] = useState(false);
const closeAnchorRef = useRef<HTMLElement>(null);
const handleDestroyed = useCallback(() => {
if (stampedEnv) {
removeUserShellStore(stampedEnv, terminalId);
}
markTerminalPanelTerminateClose(panelId);
closePanel();
}, [stampedEnv, terminalId, removeUserShellStore, panelId, closePanel]);
const { destroyTerminal } = useTerminalDestroy({
environmentId: stampedEnv,
taskId: taskID,
onDestroyed: handleDestroyed,
});
const destroyAndClosePanel = useCallback(async () => {
if (!stampedEnv) {
closePanel();
return true;
}
return destroyTerminal(terminalId);
}, [stampedEnv, terminalId, destroyTerminal, closePanel]);
const handleCloseTab = useCallback(() => {
if (!closable) return;
setConfirmClose(true);
}, [closable]);
return {
confirmClose,
setConfirmClose,
closeAnchorRef,
handleCloseTab,
destroyAndClosePanel,
};
}
export function TerminalTab(props: IDockviewPanelHeaderProps) {
const { t } = useTranslation();
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 ?? t("common:terminal"),
t("task:panelTerminal"),
);
// 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, closeAnchorRef, handleCloseTab, destroyAndClosePanel } =
useTerminalTabClose({
terminalId,
taskID,
stampedEnv,
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 (
<>
<ContextMenu>
<ContextMenuTrigger
className="flex h-full items-center cursor-pointer select-none"
data-testid={`terminal-tab-${terminalId}`}
>
{isRenaming ? (
<TabRenameInput
initial={renameInitial}
seqBadge={seqBadgeForInput}
onCommit={handleCommitRename}
onCancel={() => setIsRenaming(false)}
testId="terminal-tab-rename-input"
/>
) : (
<TerminalTabBody
{...props}
showBadge={showBadge}
seq={seq}
displayName={displayName}
closable={closable}
terminalId={terminalId}
closeLabel={t("task:close2", { label: displayName })}
closeAnchorRef={closeAnchorRef}
onCloseTab={handleCloseTab}
/>
)}
</ContextMenuTrigger>
<TerminalTabMenu
canMutate={isOrdinary}
onStartRename={() => setIsRenaming(true)}
onClosePanel={() => props.api.close()}
onTerminatePanel={handleCloseTab}
/>
</ContextMenu>
<CloseTerminalConfirmPopover
open={confirmClose}
terminalName={displayName}
anchorRef={closeAnchorRef}
onOpenChange={(open) => {
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,
closeLabel,
closeAnchorRef,
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;
closeLabel: string;
closeAnchorRef: RefObject<HTMLElement | null>;
}) {
void _displayName;
const tabContentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!closable) return;
const closeAction = tabContentRef.current?.querySelector(".dv-default-tab-action");
if (!(closeAction instanceof HTMLElement)) return;
closeAnchorRef.current = closeAction;
closeAction.setAttribute("data-testid", `terminal-tab-close-${terminalId}`);
closeAction.setAttribute("aria-label", closeLabel);
const needsKeyboardShim = closeAction.tagName !== "BUTTON";
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
event.stopPropagation();
onCloseTab();
};
if (needsKeyboardShim) {
closeAction.setAttribute("role", "button");
closeAction.setAttribute("tabindex", "0");
closeAction.addEventListener("keydown", handleKeyDown);
}
return () => {
if (closeAnchorRef.current === closeAction) closeAnchorRef.current = null;
closeAction.removeAttribute("data-testid");
closeAction.removeAttribute("aria-label");
if (needsKeyboardShim) {
closeAction.removeAttribute("role");
closeAction.removeAttribute("tabindex");
closeAction.removeEventListener("keydown", handleKeyDown);
}
};
}, [closable, terminalId, props.api.id, closeLabel, closeAnchorRef, onCloseTab]);
return (
<div ref={tabContentRef} className="flex h-full items-center">
{showBadge && (
<span
data-testid={`terminal-tab-seq-${seq}`}
className="ml-2 text-[11px] font-medium leading-none text-muted-foreground bg-foreground/10 rounded px-1.5 py-0.5"
>
{seq}
</span>
)}
<DockviewDefaultTab
{...props}
hideClose={!closable}
closeActionOverride={closable ? onCloseTab : undefined}
/>
</div>
);
}
export function TerminalTabMenu({
canMutate,
onStartRename,
onClosePanel,
onTerminatePanel,
}: {
canMutate: boolean;
onStartRename: () => void;
onClosePanel: () => void;
onTerminatePanel: () => void;
}) {
const { t } = useTranslation();
const pendingActionAfterCloseRef = useRef<"rename" | "terminate" | null>(null);
const handleRename = useCallback(() => {
pendingActionAfterCloseRef.current = "rename";
}, []);
const handleTerminate = useCallback(() => {
if (!canMutate) {
onClosePanel();
return;
}
pendingActionAfterCloseRef.current = "terminate";
}, [canMutate, onClosePanel]);
return (
<ContextMenuContent
onCloseAutoFocus={(event) => {
const pendingAction = pendingActionAfterCloseRef.current;
if (!pendingAction) return;
pendingActionAfterCloseRef.current = null;
event.preventDefault();
if (pendingAction === "rename") {
queueMicrotask(onStartRename);
return;
}
onTerminatePanel();
}}
>
{canMutate && (
<>
<ContextMenuItem onClick={handleRename}>{t("task:rename2")}</ContextMenuItem>
<ContextMenuSeparator />
</>
)}
<ContextMenuItem
onClick={handleTerminate}
className="text-destructive focus:text-destructive"
>
{t("task:terminate")}
</ContextMenuItem>
</ContextMenuContent>
);
}