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 {...@@ -46,7 +46,11 @@ import {
46import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList";46import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList";
47import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";47import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
48import { truncate } from "@t3tools/shared/String";48import { truncate } from "@t3tools/shared/String";
49import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels";49import {
50 getTerminalLabel,
51 nextTerminalId,
52 resolveTerminalSessionLabel,
53} from "@t3tools/shared/terminalLabels";
50import { Debouncer } from "@tanstack/react-pacer";54import { Debouncer } from "@tanstack/react-pacer";
51import { useAtomValue } from "@effect/atom-react";55import { useAtomValue } from "@effect/atom-react";
52import {56import {
...@@ -192,8 +196,12 @@ import {...@@ -192,8 +196,12 @@ import {
192import { useNowMinute } from "../hooks/useNowMinute";196import { useNowMinute } from "../hooks/useNowMinute";
193import { useNewThreadHandler } from "../hooks/useHandleNewThread";197import { useNewThreadHandler } from "../hooks/useHandleNewThread";
194import { resolveAppModelSelectionForInstance } from "../modelSelection";198import { resolveAppModelSelectionForInstance } from "../modelSelection";
199import { confirmTerminalClose, isTerminalCloseConfirmPending } from "../lib/terminalCloseConfirm";
195import { getTerminalFocusOwner } from "../lib/terminalFocus";200import { getTerminalFocusOwner } from "../lib/terminalFocus";
196import { preventRepeatedTerminalCloseShortcut } from "../lib/terminalCloseShortcut";201import {
202 preventRepeatedTerminalCloseShortcut,
203 preventTerminalCloseShortcut,
204} from "../lib/terminalCloseShortcut";
197import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions";205import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions";
198import {206import {
199 derivePhysicalProjectKey,207 derivePhysicalProjectKey,
...@@ -3453,6 +3461,24 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3453,6 +3461,24 @@ function ChatViewContent(props: ChatViewProps) {
3453 },3461 },
3454 [activeRightPanelSurface, activeThreadRef, closeTerminalMutation, storeCloseTerminal],3462 [activeRightPanelSurface, activeThreadRef, closeTerminalMutation, storeCloseTerminal],
3455 );3463 );
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 );
3456 const activateRightPanelSurface = useCallback(3482 const activateRightPanelSurface = useCallback(
3457 (surface: RightPanelSurface) => {3483 (surface: RightPanelSurface) => {
3458 if (!activeThreadRef) return;3484 if (!activeThreadRef) return;
...@@ -3527,11 +3553,33 @@ function ChatViewContent(props: ChatViewProps) {...@@ -3527,11 +3553,33 @@ function ChatViewContent(props: ChatViewProps) {
3527 const closeRightPanelSurface = useCallback(3553 const closeRightPanelSurface = useCallback(
3528 (surface: RightPanelSurface) => {3554 (surface: RightPanelSurface) => {
3529 if (!activeThreadRef) return;3555 if (!activeThreadRef) return;
3530 cleanupRightPanelSurfaces([surface]);3556 const finishClose = () => {
3531 useRightPanelStore.getState().closeSurface(activeThreadRef, surface.id);3557 cleanupRightPanelSurfaces([surface]);
3532 syncActivePreviewSurface();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 });
3533 },3576 },
3534 [activeThreadRef, cleanupRightPanelSurfaces, syncActivePreviewSurface],3577 [
3578 activeThreadRef,
3579 activeTerminalLabelsById,
3580 cleanupRightPanelSurfaces,
3581 syncActivePreviewSurface,
3582 ],
3535 );3583 );
3536 const closeOtherRightPanelSurfaces = useCallback(3584 const closeOtherRightPanelSurfaces = useCallback(
3537 (surface: RightPanelSurface) => {3585 (surface: RightPanelSurface) => {
...@@ -4724,6 +4772,13 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4724,6 +4772,13 @@ function ChatViewContent(props: ChatViewProps) {
4724 event.stopPropagation();4772 event.stopPropagation();
4725 return;4773 return;
4726 }4774 }
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 }
4727 if (!activeThreadId || isCommandPaletteOpen()) {4782 if (!activeThreadId || isCommandPaletteOpen()) {
4728 return;4783 return;
4729 }4784 }
...@@ -4807,11 +4862,11 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4807,11 +4862,11 @@ function ChatViewContent(props: ChatViewProps) {
4807 event.preventDefault();4862 event.preventDefault();
4808 event.stopPropagation();4863 event.stopPropagation();
4809 if (terminalFocusOwner === "right-panel" && activeRightPanelSurface?.kind === "terminal") {4864 if (terminalFocusOwner === "right-panel" && activeRightPanelSurface?.kind === "terminal") {
4810 closePanelTerminal(activeRightPanelSurface.activeTerminalId);4865 requestClosePanelTerminal(activeRightPanelSurface.activeTerminalId);
4811 return;4866 return;
4812 }4867 }
4813 if (!terminalUiState.terminalOpen) return;4868 if (!terminalUiState.terminalOpen) return;
4814 closeTerminal(terminalUiState.activeTerminalId);4869 requestCloseTerminal(terminalUiState.activeTerminalId);
4815 return;4870 return;
4816 }4871 }
48174872
...@@ -4860,8 +4915,8 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4860,8 +4915,8 @@ function ChatViewContent(props: ChatViewProps) {
4860 terminalUiState.terminalOpen,4915 terminalUiState.terminalOpen,
4861 terminalUiState.activeTerminalId,4916 terminalUiState.activeTerminalId,
4862 activeThreadId,4917 activeThreadId,
4863 closeTerminal,4918 requestCloseTerminal,
4864 closePanelTerminal,4919 requestClosePanelTerminal,
4865 createNewTerminal,4920 createNewTerminal,
4866 setTerminalOpen,4921 setTerminalOpen,
4867 runProjectScript,4922 runProjectScript,
apps/web/src/components/ThreadTerminalDrawer.tsx+13-3
...@@ -59,6 +59,7 @@ import {...@@ -59,6 +59,7 @@ import {
59 type ThreadTerminalGroup,59 type ThreadTerminalGroup,
60} from "../types";60} from "../types";
61import { readLocalApi } from "~/localApi";61import { readLocalApi } from "~/localApi";
62import { confirmTerminalClose } from "~/lib/terminalCloseConfirm";
62import { useClientSettings } from "../hooks/useSettings";63import { useClientSettings } from "../hooks/useSettings";
63import { useLocalStorage } from "../hooks/useLocalStorage";64import { useLocalStorage } from "../hooks/useLocalStorage";
64import { useAttachedTerminalSession } from "../state/terminalSessions";65import { useAttachedTerminalSession } from "../state/terminalSessions";
...@@ -1273,6 +1274,15 @@ export default function ThreadTerminalDrawer({...@@ -1273,6 +1274,15 @@ export default function ThreadTerminalDrawer({
1273 const onNewTerminalAction = useCallback(() => {1274 const onNewTerminalAction = useCallback(() => {
1274 onNewTerminal();1275 onNewTerminal();
1275 }, [onNewTerminal]);1276 }, [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
1277 useEffect(() => {1287 useEffect(() => {
1278 onHeightChangeRef.current = onHeightChange;1288 onHeightChangeRef.current = onHeightChange;
...@@ -1463,7 +1473,7 @@ export default function ThreadTerminalDrawer({...@@ -1463,7 +1473,7 @@ export default function ThreadTerminalDrawer({
1463 <div className="h-4 w-px bg-border/80" />1473 <div className="h-4 w-px bg-border/80" />
1464 <TerminalActionButton1474 <TerminalActionButton
1465 className="p-1 text-foreground/90 transition-colors hover:bg-accent"1475 className="p-1 text-foreground/90 transition-colors hover:bg-accent"
1466 onClick={() => onCloseTerminal(resolvedActiveTerminalId)}1476 onClick={() => confirmCloseTerminal(resolvedActiveTerminalId)}
1467 label={closeTerminalActionLabel}1477 label={closeTerminalActionLabel}
1468 >1478 >
1469 <Trash2 className="size-3.25" />1479 <Trash2 className="size-3.25" />
...@@ -1598,7 +1608,7 @@ export default function ThreadTerminalDrawer({...@@ -1598,7 +1608,7 @@ export default function ThreadTerminalDrawer({
1598 </TerminalActionButton>1608 </TerminalActionButton>
1599 <TerminalActionButton1609 <TerminalActionButton
1600 className="inline-flex h-full items-center border-l border-border/70 px-1 text-foreground/90 transition-colors hover:bg-accent/70"1610 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)}
1602 label={closeTerminalActionLabel}1612 label={closeTerminalActionLabel}
1603 >1613 >
1604 <Trash2 className="size-3.25" />1614 <Trash2 className="size-3.25" />
...@@ -1668,7 +1678,7 @@ export default function ThreadTerminalDrawer({...@@ -1668,7 +1678,7 @@ export default function ThreadTerminalDrawer({
1668 <button1678 <button
1669 type="button"1679 type="button"
1670 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"1680 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)}
1672 aria-label={closeTerminalLabel}1682 aria-label={closeTerminalLabel}
1673 />1683 />
1674 }1684 }
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}