| author | |
| committer | |
| log | 0508792c572abd39b1afd6f68cd420f2ca00a484 |
| tree | e9714e6732b5f4c2a801c3df647a2dfa50d115d1 |
| parent | a354dd9ddc08ef67da7e971ad59a3cd095068325 |
| signature | Signed by PGP key B5690EEEBB952194 |
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 { |
| 46 | import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList"; | 46 | import { CHAT_LIST_ANCHOR_OFFSET } from "@t3tools/shared/chatList"; |
| 47 | import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; | 47 | import { projectScriptCwd, projectScriptRuntimeEnv } from "@t3tools/shared/projectScripts"; |
| 48 | import { truncate } from "@t3tools/shared/String"; | 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 | import { Debouncer } from "@tanstack/react-pacer"; | 54 | import { Debouncer } from "@tanstack/react-pacer"; |
| 51 | import { useAtomValue } from "@effect/atom-react"; | 55 | import { useAtomValue } from "@effect/atom-react"; |
| 52 | import { | 56 | import { |
| ... | @@ -192,8 +196,12 @@ import { | ... | @@ -192,8 +196,12 @@ import { |
| 192 | import { useNowMinute } from "../hooks/useNowMinute"; | 196 | import { useNowMinute } from "../hooks/useNowMinute"; |
| 193 | import { useNewThreadHandler } from "../hooks/useHandleNewThread"; | 197 | import { useNewThreadHandler } from "../hooks/useHandleNewThread"; |
| 194 | import { resolveAppModelSelectionForInstance } from "../modelSelection"; | 198 | import { resolveAppModelSelectionForInstance } from "../modelSelection"; |
| 199 | import { confirmTerminalClose, isTerminalCloseConfirmPending } from "../lib/terminalCloseConfirm"; | ||
| 195 | import { getTerminalFocusOwner } from "../lib/terminalFocus"; | 200 | import { getTerminalFocusOwner } from "../lib/terminalFocus"; |
| 196 | import { preventRepeatedTerminalCloseShortcut } from "../lib/terminalCloseShortcut"; | 201 | import { |
| 202 | preventRepeatedTerminalCloseShortcut, | ||
| 203 | preventTerminalCloseShortcut, | ||
| 204 | } from "../lib/terminalCloseShortcut"; | ||
| 197 | import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; | 205 | import { resolveNewDraftStartFromOrigin } from "../lib/chatThreadActions"; |
| 198 | import { | 206 | import { |
| 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 | } |
| 4817 | 4872 | ||
| ... | @@ -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"; |
| 61 | import { readLocalApi } from "~/localApi"; | 61 | import { readLocalApi } from "~/localApi"; |
| 62 | import { confirmTerminalClose } from "~/lib/terminalCloseConfirm"; | ||
| 62 | import { useClientSettings } from "../hooks/useSettings"; | 63 | import { useClientSettings } from "../hooks/useSettings"; |
| 63 | import { useLocalStorage } from "../hooks/useLocalStorage"; | 64 | import { useLocalStorage } from "../hooks/useLocalStorage"; |
| 64 | import { useAttachedTerminalSession } from "../state/terminalSessions"; | 65 | import { 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 | ); | ||
| 1276 | 1286 | ||
| 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 | <TerminalActionButton | 1474 | <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 | <TerminalActionButton | 1609 | <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 | <button | 1678 | <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 @@ | ||
| 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 created+42| ... | @@ -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 | } | ||