Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 | Promise<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={(event) => {
event.preventDefault();
void 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
154 changes: 63 additions & 91 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 { shouldConfirmTerminalClose } 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,44 +68,10 @@ 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;
taskId: string | null;
terminals: Terminal[];
terminalTabValue: string;
removeTerminal: (id: string) => void;
Expand All @@ -123,49 +81,49 @@ type CloseHandlerArgs = {
function useTerminalCloseHandler({
sessionId,
environmentId,
taskId,
terminals,
terminalTabValue,
removeTerminal,
setRightPanelActiveTab,
}: 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, taskId ?? undefined);
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,
taskId,
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 @@ -177,16 +135,19 @@ const MobileTerminalsList = memo(function MobileTerminalsList({
}) {
const { terminals, terminalTabValue, addTerminal, removeTerminal, environmentId } =
useMobileTerminalsContext();
const { shells } = useUserShells(environmentId);
const setRightPanelActiveTab = useAppStore((s) => s.setRightPanelActiveTab);
const { pendingClose, setPendingClose, handleConfirmClose } = useTerminalCloseHandler({
sessionId,
environmentId,
terminals,
terminalTabValue,
removeTerminal,
setRightPanelActiveTab,
});
const taskId = useAppStore((s) => s.tasks?.activeTaskId ?? null);
Comment thread
carlosflorencio marked this conversation as resolved.
const { shells } = useUserShells(environmentId, taskId);
const { pendingClose, setPendingClose, handleConfirmClose, closeTerminal } =
useTerminalCloseHandler({
sessionId,
environmentId,
taskId,
terminals,
terminalTabValue,
removeTerminal,
setRightPanelActiveTab,
});

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

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

if (!sessionId) {
Expand Down Expand Up @@ -247,7 +218,8 @@ const MobileTerminalsList = memo(function MobileTerminalsList({
))}
</div>
<CloseTerminalConfirmDialog
terminal={pendingClose}
open={pendingClose !== null}
terminalName={pendingClose?.label || "Terminal"}
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