History

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

maria committed 2 months ago

Commit 05087921 parent a354dd94 files · +198 −13Browse files

Unified · Split

4 files
  1. apps/web/src/components/ChatView.tsx +65 −10
  2. apps/web/src/components/ThreadTerminalDrawer.tsx +13 −3
  3. apps/web/src/lib/terminalCloseConfirm.test.ts +78 −0
  4. apps/web/src/lib/terminalCloseConfirm.ts +42 −0
apps/web/src/components/ChatView.tsx+65−10
@@ -46,7 +46,11 @@ import { · 45 unmodified lines
4646 import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList";
4747 import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts";
4848 import { truncate } from "@t3tools/shared/String";
49−import { nextTerminalId, resolveTerminalSessionLabel } from "@t3tools/shared/terminalLabels";
49+import {
50+ getTerminalLabel,
51+ nextTerminalId,
52+ resolveTerminalSessionLabel,
53+} from "@t3tools/shared/terminalLabels";
5054 import { Debouncer } from "@tanstack/react-pacer";
5155 import { useAtomValue } from "@effect/atom-react";
5256 import {
@@ -192,8 +196,12 @@ import { · 139 unmodified lines
192196 import { useNowMinute } from "../hooks/useNowMinute";
193197 import { useNewThreadHandler } from "../hooks/useHandleNewThread";
194198 import { resolveAppModelSelectionForInstance } from "../modelSelection";
199+import { confirmTerminalClose, isTerminalCloseConfirmPending } from "../lib/terminalCloseConfirm";
195200 import { getTerminalFocusOwner } from "../lib/terminalFocus";
196−import { preventRepeatedTerminalCloseShortcut } from "../lib/terminalCloseShortcut";
201+import {
202+ preventRepeatedTerminalCloseShortcut,
203+ preventTerminalCloseShortcut,
204+} from "../lib/terminalCloseShortcut";
197205 import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions";
198206 import {
199207 derivePhysicalProjectKey,
@@ -3453,6 +3461,24 @@ function ChatViewContent(props: ChatViewProps) { · 3253 unmodified lines
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) { · 68 unmodified lines
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) { · 1186 unmodified lines
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) { · 77 unmodified lines
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) { · 42 unmodified lines
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 { · 58 unmodified lines
5959 type ThreadTerminalGroup,
6060 } from "../types";
6161 import { readLocalApi } from "~/localApi";
62+import { confirmTerminalClose } from "~/lib/terminalCloseConfirm";
6263 import { useClientSettings } from "../hooks/useSettings";
6364 import { useLocalStorage } from "../hooks/useLocalStorage";
6465 import { useAttachedTerminalSession } from "../state/terminalSessions";
@@ -1273,6 +1274,15 @@ export default function ThreadTerminalDrawer({ · 1208 unmodified lines
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({ · 184 unmodified lines
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({ · 128 unmodified lines
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({ · 63 unmodified lines
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+78−0
@@ -0,0 +1,78 @@
1+import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
2+
3+const { 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+
15+vi.mock("~/localApi", () => ({
16+ readLocalApi: () => readLocalApiMock(),
17+}));
18+
19+import { confirmTerminalClose, isTerminalCloseConfirmPending } from "./terminalCloseConfirm";
20+
21+describe("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+42−0
@@ -0,0 +1,42 @@
1+import { readLocalApi } from "~/localApi";
2+
3+let pendingConfirmations = 0;
4+
5+/** Whether a terminal-close confirmation is currently waiting on the user. */
6+export 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+ */
16+export 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+}