authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-08-19 17:46:57-04:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-19 17:46:57-04:00
log0508792c572abd39b1afd6f68cd420f2ca00a484
treee9714e6732b5f4c2a801c3df647a2dfa50d115d1
parenta354dd9ddc08ef67da7e971ad59a3cd095068325
signature Signed by PGP key B5690EEEBB952194

feat(web): confirm before closing a terminal (#7592)


4 files changed, 198 insertions(+), 13 deletions(-)

apps/web/src/components/ChatView.tsx+65-10
......@@ -46,7 +46,11 @@ import {
4646import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList";
4747import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
4848import { truncate } from "@t3tools/shared/String";
49import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels";
49import {
50 getTerminalLabel,
51 nextTerminalId,
52 resolveTerminalSessionLabel,
53} from "@t3tools/shared/terminalLabels";
5054import { Debouncer } from "@tanstack/react-pacer";
5155import { useAtomValue } from "@effect/atom-react";
5256import {
......@@ -192,8 +196,12 @@ import {
192196import { useNowMinute } from "../hooks/useNowMinute";
193197import { useNewThreadHandler } from "../hooks/useHandleNewThread";
194198import { resolveAppModelSelectionForInstance } from "../modelSelection";
199import { confirmTerminalClose, isTerminalCloseConfirmPending } from "../lib/terminalCloseConfirm";
195200import { getTerminalFocusOwner } from "../lib/terminalFocus";
196import { preventRepeatedTerminalCloseShortcut } from "../lib/terminalCloseShortcut";
201import {
202 preventRepeatedTerminalCloseShortcut,
203 preventTerminalCloseShortcut,
204} from "../lib/terminalCloseShortcut";
197205import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions";
198206import {
199207 derivePhysicalProjectKey,
......@@ -3453,6 +3461,24 @@ function ChatViewContent(props: ChatViewProps) {
34533461 },
34543462 [activeRightPanelSurface, activeThreadRef, closeTerminalMutation, storeCloseTerminal],
34553463 );
3464 const requestCloseTerminal = useCallback(
3465 (terminalId: string) => {
3466 const label = activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId);
3467 void confirmTerminalClose([label]).then((confirmed) => {
3468 if (confirmed) closeTerminal(terminalId);
3469 });
3470 },
3471 [activeTerminalLabelsById, closeTerminal],
3472 );
3473 const requestClosePanelTerminal = useCallback(
3474 (terminalId: string) => {
3475 const label = activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId);
3476 void confirmTerminalClose([label]).then((confirmed) => {
3477 if (confirmed) closePanelTerminal(terminalId);
3478 });
3479 },
3480 [activeTerminalLabelsById, closePanelTerminal],
3481 );
34563482 const activateRightPanelSurface = useCallback(
34573483 (surface: RightPanelSurface) => {
34583484 if (!activeThreadRef) return;
......@@ -3527,11 +3553,33 @@ function ChatViewContent(props: ChatViewProps) {
35273553 const closeRightPanelSurface = useCallback(
35283554 (surface: RightPanelSurface) => {
35293555 if (!activeThreadRef) return;
3530 cleanupRightPanelSurfaces([surface]);
3531 useRightPanelStore.getState().closeSurface(activeThreadRef, surface.id);
3532 syncActivePreviewSurface();
3556 const finishClose = () => {
3557 cleanupRightPanelSurfaces([surface]);
3558 useRightPanelStore.getState().closeSurface(activeThreadRef, surface.id);
3559 syncActivePreviewSurface();
3560 };
3561 if (surface.kind !== "terminal") {
3562 finishClose();
3563 return;
3564 }
3565 const activeLabel =
3566 activeTerminalLabelsById.get(surface.activeTerminalId) ??
3567 getTerminalLabel(surface.activeTerminalId);
3568 const otherLabels = surface.terminalIds
3569 .filter((terminalId) => terminalId !== surface.activeTerminalId)
3570 .map(
3571 (terminalId) => activeTerminalLabelsById.get(terminalId) ?? getTerminalLabel(terminalId),
3572 );
3573 void confirmTerminalClose([activeLabel, ...otherLabels]).then((confirmed) => {
3574 if (confirmed) finishClose();
3575 });
35333576 },
3534 [activeThreadRef, cleanupRightPanelSurfaces, syncActivePreviewSurface],
3577 [
3578 activeThreadRef,
3579 activeTerminalLabelsById,
3580 cleanupRightPanelSurfaces,
3581 syncActivePreviewSurface,
3582 ],
35353583 );
35363584 const closeOtherRightPanelSurfaces = useCallback(
35373585 (surface: RightPanelSurface) => {
......@@ -4724,6 +4772,13 @@ function ChatViewContent(props: ChatViewProps) {
47244772 event.stopPropagation();
47254773 return;
47264774 }
4775 // While a close confirmation is open, terminal focus has moved to the
4776 // dialog, so a deliberate second close shortcut would otherwise fall
4777 // through to the native window/tab close accelerator.
4778 if (isTerminalCloseConfirmPending() && preventTerminalCloseShortcut(event, keybindings)) {
4779 event.stopPropagation();
4780 return;
4781 }
47274782 if (!activeThreadId || isCommandPaletteOpen()) {
47284783 return;
47294784 }
......@@ -4807,11 +4862,11 @@ function ChatViewContent(props: ChatViewProps) {
48074862 event.preventDefault();
48084863 event.stopPropagation();
48094864 if (terminalFocusOwner === "right-panel" && activeRightPanelSurface?.kind === "terminal") {
4810 closePanelTerminal(activeRightPanelSurface.activeTerminalId);
4865 requestClosePanelTerminal(activeRightPanelSurface.activeTerminalId);
48114866 return;
48124867 }
48134868 if (!terminalUiState.terminalOpen) return;
4814 closeTerminal(terminalUiState.activeTerminalId);
4869 requestCloseTerminal(terminalUiState.activeTerminalId);
48154870 return;
48164871 }
48174872
......@@ -4860,8 +4915,8 @@ function ChatViewContent(props: ChatViewProps) {
48604915 terminalUiState.terminalOpen,
48614916 terminalUiState.activeTerminalId,
48624917 activeThreadId,
4863 closeTerminal,
4864 closePanelTerminal,
4918 requestCloseTerminal,
4919 requestClosePanelTerminal,
48654920 createNewTerminal,
48664921 setTerminalOpen,
48674922 runProjectScript,
apps/web/src/components/ThreadTerminalDrawer.tsx+13-3
......@@ -59,6 +59,7 @@ import {
5959 type ThreadTerminalGroup,
6060} from "../types";
6161import { readLocalApi } from "~/localApi";
62import { confirmTerminalClose } from "~/lib/terminalCloseConfirm";
6263import { useClientSettings } from "../hooks/useSettings";
6364import { useLocalStorage } from "../hooks/useLocalStorage";
6465import { useAttachedTerminalSession } from "../state/terminalSessions";
......@@ -1273,6 +1274,15 @@ export default function ThreadTerminalDrawer({
12731274 const onNewTerminalAction = useCallback(() => {
12741275 onNewTerminal();
12751276 }, [onNewTerminal]);
1277 const confirmCloseTerminal = useCallback(
1278 (terminalId: string) => {
1279 const label = terminalLabelById.get(terminalId) ?? getTerminalLabel(terminalId);
1280 void confirmTerminalClose([label]).then((confirmed) => {
1281 if (confirmed) onCloseTerminal(terminalId);
1282 });
1283 },
1284 [onCloseTerminal, terminalLabelById],
1285 );
12761286
12771287 useEffect(() => {
12781288 onHeightChangeRef.current = onHeightChange;
......@@ -1463,7 +1473,7 @@ export default function ThreadTerminalDrawer({
14631473 <div className="h-4 w-px bg-border/80" />
14641474 <TerminalActionButton
14651475 className="p-1 text-foreground/90 transition-colors hover:bg-accent"
1466 onClick={() => onCloseTerminal(resolvedActiveTerminalId)}
1476 onClick={() => confirmCloseTerminal(resolvedActiveTerminalId)}
14671477 label={closeTerminalActionLabel}
14681478 >
14691479 <Trash2 className="size-3.25" />
......@@ -1598,7 +1608,7 @@ export default function ThreadTerminalDrawer({
15981608 </TerminalActionButton>
15991609 <TerminalActionButton
16001610 className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70"
1601 onClick={() => onCloseTerminal(resolvedActiveTerminalId)}
1611 onClick={() => confirmCloseTerminal(resolvedActiveTerminalId)}
16021612 label={closeTerminalActionLabel}
16031613 >
16041614 <Trash2 className="size-3.25" />
......@@ -1668,7 +1678,7 @@ export default function ThreadTerminalDrawer({
16681678 <button
16691679 type="button"
16701680 className="inline-flex size-3.5 items-center justify-center rounded text-xs font-medium leading-none text-muted-foreground opacity-0 transition hover:bg-accent hover:text-foreground group-hover:opacity-100"
1671 onClick={() => onCloseTerminal(terminalId)}
1681 onClick={() => confirmCloseTerminal(terminalId)}
16721682 aria-label={closeTerminalLabel}
16731683 />
16741684 }
apps/web/src/lib/terminalCloseConfirm.test.ts created+78
......@@ -0,0 +1,78 @@
1import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
2
3const { confirmMock, readLocalApiMock } = vi.hoisted(() => {
4 const confirmMock = vi.fn<(message: string, options?: unknown) => Promise<boolean>>();
5 const readLocalApiMock = vi.fn<
6 () =>
7 | {
8 dialogs: { confirm: (message: string, options?: unknown) => Promise<boolean> };
9 }
10 | undefined
11 >();
12 return { confirmMock, readLocalApiMock };
13});
14
15vi.mock("~/localApi", () => ({
16 readLocalApi: () => readLocalApiMock(),
17}));
18
19import { confirmTerminalClose, isTerminalCloseConfirmPending } from "./terminalCloseConfirm";
20
21describe("terminal close confirmation", () => {
22 beforeEach(() => {
23 confirmMock.mockReset();
24 readLocalApiMock.mockReset();
25 readLocalApiMock.mockReturnValue({ dialogs: { confirm: confirmMock } });
26 });
27
28 it("tracks pending state until the confirmation settles", async () => {
29 let settle: (value: boolean) => void = () => undefined;
30 confirmMock.mockImplementation(() => new Promise<boolean>((resolve) => (settle = resolve)));
31
32 expect(isTerminalCloseConfirmPending()).toBe(false);
33
34 const confirmation = confirmTerminalClose(["Terminal 1"]);
35 expect(isTerminalCloseConfirmPending()).toBe(true);
36
37 settle(true);
38 await expect(confirmation).resolves.toBe(true);
39 expect(isTerminalCloseConfirmPending()).toBe(false);
40 });
41
42 it("clears pending state and resolves false when the dialog rejects", async () => {
43 let reject: (reason?: unknown) => void = () => undefined;
44 confirmMock.mockImplementation(
45 () =>
46 new Promise<boolean>((_resolve, rejectPromise) => {
47 reject = rejectPromise;
48 }),
49 );
50
51 const confirmation = confirmTerminalClose(["Terminal 1"]);
52 expect(isTerminalCloseConfirmPending()).toBe(true);
53
54 reject(new Error("dialog failed"));
55 await expect(confirmation).resolves.toBe(false);
56 expect(isTerminalCloseConfirmPending()).toBe(false);
57 });
58
59 it("names every terminal in a multi-terminal close", async () => {
60 confirmMock.mockResolvedValue(true);
61
62 await expect(confirmTerminalClose(["Terminal 1", "Development server"])).resolves.toBe(true);
63 expect(confirmMock).toHaveBeenCalledWith(
64 [
65 "Close 2 terminals?",
66 'This stops their running processes and clears their histories: "Terminal 1", "Development server".',
67 ].join("\n"),
68 { variant: "destructive" },
69 );
70 });
71
72 it("closes without prompting when no local API is available", async () => {
73 readLocalApiMock.mockReturnValue(undefined);
74
75 await expect(confirmTerminalClose(["Terminal 1"])).resolves.toBe(true);
76 expect(confirmMock).not.toHaveBeenCalled();
77 });
78});
apps/web/src/lib/terminalCloseConfirm.ts created+42
......@@ -0,0 +1,42 @@
1import { readLocalApi } from "~/localApi";
2
3let pendingConfirmations = 0;
4
5/** Whether a terminal-close confirmation is currently waiting on the user. */
6export function isTerminalCloseConfirmPending(): boolean {
7 return pendingConfirmations > 0;
8}
9
10/**
11 * Confirmation for individual terminal close actions: drawer buttons, panel
12 * buttons, the `terminal.close` keybinding, and closing a terminal surface from
13 * the tab strip. Auto-exit cleanup and bulk tab closes skip this path and close
14 * directly.
15 */
16export async function confirmTerminalClose(
17 labels: readonly [string, ...string[]],
18): Promise<boolean> {
19 const localApi = readLocalApi();
20 if (!localApi) return true;
21 pendingConfirmations += 1;
22 try {
23 return await localApi.dialogs.confirm(
24 labels.length === 1
25 ? [
26 `Close terminal "${labels[0]}"?`,
27 "This stops the running process and clears its history.",
28 ].join("\n")
29 : [
30 `Close ${labels.length} terminals?`,
31 `This stops their running processes and clears their histories: ${labels
32 .map((label) => `"${label}"`)
33 .join(", ")}.`,
34 ].join("\n"),
35 { variant: "destructive" },
36 );
37 } catch {
38 return false;
39 } finally {
40 pendingConfirmations -= 1;
41 }
42}