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