Skip to content
Merged
55 changes: 55 additions & 0 deletions apps/web/components/task/close-terminal-confirm-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
"use client";

import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@kandev/ui/alert-dialog";

/**
* Shared "Close terminal?" confirmation. Rendered before a destroy-on-close
* when the terminal looks busy (a command is running) or is a script terminal.
* Used by the dockview tab, the right-panel strip, and the mobile picker so
* all three close paths warn consistently.
*/
export function CloseTerminalConfirmDialog({
open,
terminalName,
onOpenChange,
onConfirm,
}: {
open: boolean;
terminalName: string;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
}) {
return (
<AlertDialog open={open} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Close terminal?</AlertDialogTitle>
<AlertDialogDescription>
{`This stops the “${terminalName}” shell and any command it's running.`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="cursor-pointer">Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
Comment thread
carlosflorencio marked this conversation as resolved.
Outdated
onOpenChange(false);
onConfirm();
}}
className="cursor-pointer bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Close terminal
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
22 changes: 6 additions & 16 deletions apps/web/components/task/dockview-layout-setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ import {
import { setEnvLayout, setGlobalSidebarWidth } from "@/lib/local-storage";
import { panelPortalManager } from "@/lib/layout/panel-portal-manager";
import { stopVscode } from "@/lib/api/domains/vscode-api";
import { parkUserShell, stopUserShell } from "@/lib/api/domains/user-shell-api";
import { stopUserShell } from "@/lib/api/domains/user-shell-api";
import { createDebugLogger, IS_DEBUG } from "@/lib/debug/log";
import { snapshotColumnWidths, formatWidthsSnapshot } from "@/lib/state/dockview-widths-debug";
import { enforcePinnedTargets, setSashDragging } from "@/lib/state/dockview-pinned-enforce";
Expand Down Expand Up @@ -347,8 +347,8 @@ function resolveSessionForEntry(
return match?.[0] ?? active;
}

/** Tab close → ordinary terminals park (PTY + DB row survive, reappear in
* the "+" menu); scripts/bottom-panel/legacy passthrough still destroy. */
/** Tab close → destroy the shell (PTY stopped, DB row removed). When the tab
* component already destroyed the shell it marks the panel id so we skip. */
function handleTerminalPanelClosed(
appStore: StoreApi<AppState>,
panelId: string,
Expand All @@ -364,19 +364,9 @@ function handleTerminalPanelClosed(
const fallbackEnv = active ? (state.environmentIdBySessionId[active] ?? null) : null;
const envForTerminal = stampedEnv || fallbackEnv;
if (!envForTerminal) return;
const shell = state.userShells.byEnvironmentId[envForTerminal]?.find(
(s) => s.terminalId === terminalId,
);
if (shell?.kind === "ordinary") {
parkUserShell(terminalId, stampedTaskID).then(
() => state.updateUserShell(envForTerminal, terminalId, { state: "parked" }),
(err: unknown) => console.error("park terminal on tab close:", err),
);
} else {
stopUserShell(envForTerminal, terminalId, stampedTaskID).catch((err: unknown) =>
console.warn("stop terminal on tab close:", err),
);
}
stopUserShell(envForTerminal, terminalId, stampedTaskID)
.then(() => state.removeUserShell(envForTerminal, terminalId))
.catch((err: unknown) => console.warn("stop terminal on tab close:", err));
}

export function setupPortalCleanup(
Expand Down
137 changes: 47 additions & 90 deletions apps/web/components/task/mobile/mobile-terminals-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,20 +3,12 @@
import { memo, useCallback, useState } from "react";
import { IconPlus, IconTerminal2, IconX } from "@tabler/icons-react";
import { Button } from "@kandev/ui/button";
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@kandev/ui/alert-dialog";
import { useAppStore } from "@/components/state-provider";
import { stopUserShell } from "@/lib/api/domains/user-shell-api";
import { isTerminalBusy } from "@/lib/terminal/terminal-busy-registry";
import { useUserShells } from "@/hooks/domains/session/use-user-shells";
import { releaseAutoCreatedEnvironment } from "@/hooks/domains/session/use-mobile-terminals";
import { CloseTerminalConfirmDialog } from "../close-terminal-confirm-dialog";
import { MobilePillButton } from "./mobile-pill-button";
import { MobilePickerSheet } from "./mobile-picker-sheet";
import { useMobileTerminalsContext } from "./mobile-terminals-context";
Expand Down Expand Up @@ -76,41 +68,6 @@ function TerminalRow({
);
}

function CloseTerminalConfirmDialog({
terminal,
onOpenChange,
onConfirm,
}: {
terminal: Terminal | null;
onOpenChange: (open: boolean) => void;
onConfirm: () => void;
}) {
return (
<AlertDialog open={terminal !== null} onOpenChange={onOpenChange}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>Close terminal?</AlertDialogTitle>
<AlertDialogDescription>
{`This stops the “${terminal?.label ?? ""}” shell and any process it's running.`}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel className="cursor-pointer">Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => {
onOpenChange(false);
onConfirm();
}}
className="cursor-pointer bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
Close terminal
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}

type CloseHandlerArgs = {
sessionId: string | null;
environmentId: string | null;
Expand All @@ -130,42 +87,33 @@ function useTerminalCloseHandler({
}: CloseHandlerArgs) {
const [pendingClose, setPendingClose] = useState<Terminal | null>(null);

const handleConfirmClose = useCallback(async () => {
const t = pendingClose;
if (!t || !sessionId) return;
try {
// Stop the remote shell first; only mutate UI on success so a failed
// stop doesn't orphan the shell server-side while the picker hides it.
if (environmentId) await stopUserShell(environmentId, t.id);
// If the closed terminal was active, advance the active tab to a
// remaining one so the picker doesn't leave terminalTabValue pointing
// at a deleted id (which would render every row as inactive).
if (terminalTabValue === t.id) {
const next = terminals.find((row) => row.id !== t.id);
if (next) setRightPanelActiveTab(sessionId, next.id);
}
removeTerminal(t.id);
// If this was the last terminal, release the auto-create guard so the
// pane recreates a default shell on next render instead of getting
// stuck on the "Starting terminal…" placeholder forever.
if (environmentId && terminals.length <= 1) {
releaseAutoCreatedEnvironment(environmentId);
const closeTerminal = useCallback(
async (t: Terminal) => {
if (!sessionId) return;
try {
if (environmentId) await stopUserShell(environmentId, t.id);
Comment thread
carlosflorencio marked this conversation as resolved.
Outdated
Comment thread
carlosflorencio marked this conversation as resolved.
Outdated
if (terminalTabValue === t.id) {
const next = terminals.find((row) => row.id !== t.id);
if (next) setRightPanelActiveTab(sessionId, next.id);
}
removeTerminal(t.id);
if (environmentId && terminals.length <= 1) {
releaseAutoCreatedEnvironment(environmentId);
}
setPendingClose(null);
} catch (err) {
console.error("Failed to stop terminal:", err);
}
setPendingClose(null);
} catch (err) {
console.error("Failed to stop terminal:", err);
}
}, [
pendingClose,
sessionId,
environmentId,
terminals,
terminalTabValue,
removeTerminal,
setRightPanelActiveTab,
]);
},
[sessionId, environmentId, terminals, terminalTabValue, removeTerminal, setRightPanelActiveTab],
);

return { pendingClose, setPendingClose, handleConfirmClose };
const handleConfirmClose = useCallback(async () => {
if (!pendingClose) return;
await closeTerminal(pendingClose);
}, [pendingClose, closeTerminal]);

return { pendingClose, setPendingClose, handleConfirmClose, closeTerminal };
}

const MobileTerminalsList = memo(function MobileTerminalsList({
Expand All @@ -179,14 +127,15 @@ const MobileTerminalsList = memo(function MobileTerminalsList({
useMobileTerminalsContext();
const { shells } = useUserShells(environmentId);
const setRightPanelActiveTab = useAppStore((s) => s.setRightPanelActiveTab);
const { pendingClose, setPendingClose, handleConfirmClose } = useTerminalCloseHandler({
sessionId,
environmentId,
terminals,
terminalTabValue,
removeTerminal,
setRightPanelActiveTab,
});
const { pendingClose, setPendingClose, handleConfirmClose, closeTerminal } =
useTerminalCloseHandler({
sessionId,
environmentId,
terminals,
terminalTabValue,
removeTerminal,
setRightPanelActiveTab,
});

const isShellRunning = useCallback(
(id: string) => shells.find((s) => s.terminalId === id)?.running ?? false,
Expand All @@ -202,8 +151,15 @@ const MobileTerminalsList = memo(function MobileTerminalsList({
);

const handleAskClose = useCallback(
(terminal: Terminal) => setPendingClose(terminal),
[setPendingClose],
(terminal: Terminal) => {
const needsConfirm = isTerminalBusy(terminal.id) || terminal.type === "script";
if (needsConfirm) {
setPendingClose(terminal);
return;
}
void closeTerminal(terminal);
},
[closeTerminal, setPendingClose],
);

if (!sessionId) {
Expand Down Expand Up @@ -247,7 +203,8 @@ const MobileTerminalsList = memo(function MobileTerminalsList({
))}
</div>
<CloseTerminalConfirmDialog
terminal={pendingClose}
open={pendingClose !== null}
terminalName={pendingClose?.label ?? ""}
onOpenChange={(open) => {
if (!open) setPendingClose(null);
}}
Expand Down
2 changes: 2 additions & 0 deletions apps/web/components/task/passthrough-terminal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { useEnvironmentSessionId } from "@/hooks/use-environment-session-id";
import { useTerminalSearch } from "./use-terminal-search";
import { TerminalSearchBar } from "./terminal-search-bar";
import { usePanelSearch } from "@/hooks/use-panel-search";
import { useTerminalBusyTracking } from "./use-terminal-busy-tracking";

type BaseProps = {
autoFocus?: boolean;
Expand Down Expand Up @@ -232,6 +233,7 @@ export function PassthroughTerminal(props: PassthroughTerminalProps) {
keyboardShortcutsRef,
onFindInPanelRef,
});
useTerminalBusyTracking(terminalId, xtermRef, mode === "shell" && isTerminalReady);

useTouchScroll({
terminalRef: refs.terminalRef,
Expand Down
Loading
Loading