authorgravatar for 71498452+StiensWout@users.noreply.github.comWout Stiens <71498452+StiensWout@users.noreply.github.com> 2026-08-10 08:23:27+02:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-08-10 02:23:27-04:00
log9821bca1ceb97f137a9d93f1080fe1954b6641d3
tree050ac8266fc27212ea2f068455d1a4e2b9d5e230
parent3d74474f6cf825d3ef37b40dc7af0037e26a3bf9
signature Signed by PGP key B5690EEEBB952194

fix(web): use themed confirmation dialogs (#5624)


24 files changed, 589 insertions(+), 245 deletions(-)

apps/desktop/src/electron/ElectronDialog.test.ts-89
......@@ -28,70 +28,6 @@ describe("ElectronDialog", () => {
2828 showErrorBoxMock.mockReset();
2929 });
3030
31 it.effect("returns false without opening a confirm dialog for empty messages", () =>
32 Effect.gen(function* () {
33 const dialog = yield* ElectronDialog.ElectronDialog;
34
35 const result = yield* dialog.confirm({
36 message: " ",
37 owner: Option.none(),
38 });
39
40 assert.isFalse(result);
41 assert.equal(showMessageBoxMock.mock.calls.length, 0);
42 }).pipe(Effect.provide(ElectronDialog.layer)),
43 );
44
45 it.effect("opens a confirm dialog for the owner window", () =>
46 Effect.gen(function* () {
47 const owner = { id: 1 } as BrowserWindow;
48 showMessageBoxMock.mockResolvedValue({ response: 1 });
49 const dialog = yield* ElectronDialog.ElectronDialog;
50
51 const result = yield* dialog.confirm({
52 message: "Delete worktree?",
53 owner: Option.some(owner),
54 });
55
56 assert.isTrue(result);
57 assert.deepEqual(showMessageBoxMock.mock.calls[0], [
58 owner,
59 {
60 type: "question",
61 buttons: ["No", "Yes"],
62 defaultId: 0,
63 cancelId: 0,
64 noLink: true,
65 message: "Delete worktree?",
66 },
67 ]);
68 }).pipe(Effect.provide(ElectronDialog.layer)),
69 );
70
71 it.effect("opens an app-level confirm dialog when there is no owner window", () =>
72 Effect.gen(function* () {
73 showMessageBoxMock.mockResolvedValue({ response: 0 });
74 const dialog = yield* ElectronDialog.ElectronDialog;
75
76 const result = yield* dialog.confirm({
77 message: "Delete worktree?",
78 owner: Option.none(),
79 });
80
81 assert.isFalse(result);
82 assert.deepEqual(showMessageBoxMock.mock.calls[0], [
83 {
84 type: "question",
85 buttons: ["No", "Yes"],
86 defaultId: 0,
87 cancelId: 0,
88 noLink: true,
89 message: "Delete worktree?",
90 },
91 ]);
92 }).pipe(Effect.provide(ElectronDialog.layer)),
93 );
94
9531 it.effect("preserves folder picker request context and cause", () =>
9632 Effect.gen(function* () {
9733 const cause = new Error("folder picker failed");
......@@ -117,31 +53,6 @@ describe("ElectronDialog", () => {
11753 }).pipe(Effect.provide(ElectronDialog.layer)),
11854 );
11955
120 it.effect("preserves confirmation request context and cause", () =>
121 Effect.gen(function* () {
122 const cause = new Error("confirmation failed");
123 const owner = { id: 9 } as BrowserWindow;
124 showMessageBoxMock.mockRejectedValue(cause);
125 const dialog = yield* ElectronDialog.ElectronDialog;
126
127 const error = yield* Effect.flip(
128 dialog.confirm({
129 owner: Option.some(owner),
130 message: " Confirm removal? ",
131 }),
132 );
133
134 assert.instanceOf(error, ElectronDialog.ElectronDialogConfirmError);
135 assert.strictEqual(error.ownerWindowId, 9);
136 assert.strictEqual(error.promptLength, "Confirm removal?".length);
137 assert.notProperty(error, "promptMessage");
138 assert.strictEqual(error.cause, cause);
139 assert.include(error.message, "window 9");
140 assert.notInclude(error.message, "Confirm removal?");
141 assert.notInclude(error.message, cause.message);
142 }).pipe(Effect.provide(ElectronDialog.layer)),
143 );
144
14556 it.effect("preserves message box request context and cause", () =>
14657 Effect.gen(function* () {
14758 const cause = new Error("message box failed");
apps/desktop/src/electron/ElectronDialog.ts-58
......@@ -6,8 +6,6 @@ import * as Schema from "effect/Schema";
66
77import * as Electron from "electron";
88
9const CONFIRM_BUTTON_INDEX = 1;
10
119export class ElectronDialogPickFolderError extends Schema.TaggedErrorClass<ElectronDialogPickFolderError>()(
1210 "ElectronDialogPickFolderError",
1311 {
......@@ -38,20 +36,6 @@ export class ElectronDialogPickFilesError extends Schema.TaggedErrorClass<Electr
3836 }
3937}
4038
41export class ElectronDialogConfirmError extends Schema.TaggedErrorClass<ElectronDialogConfirmError>()(
42 "ElectronDialogConfirmError",
43 {
44 ownerWindowId: Schema.NullOr(Schema.Number),
45 promptLength: Schema.Number,
46 cause: Schema.Defect(),
47 },
48) {
49 override get message(): string {
50 const owner = this.ownerWindowId === null ? "the application" : `window ${this.ownerWindowId}`;
51 return `Failed to open an Electron confirmation dialog for ${owner} with a ${this.promptLength}-character prompt.`;
52 }
53}
54
5539export class ElectronDialogShowMessageBoxError extends Schema.TaggedErrorClass<ElectronDialogShowMessageBoxError>()(
5640 "ElectronDialogShowMessageBoxError",
5741 {
......@@ -85,7 +69,6 @@ export class ElectronDialogShowErrorBoxError extends Schema.TaggedErrorClass<Ele
8569export const ElectronDialogError = Schema.Union([
8670 ElectronDialogPickFolderError,
8771 ElectronDialogPickFilesError,
88 ElectronDialogConfirmError,
8972 ElectronDialogShowMessageBoxError,
9073 ElectronDialogShowErrorBoxError,
9174]);
......@@ -103,11 +86,6 @@ export interface ElectronDialogPickFilesInput {
10386 readonly filters: readonly Electron.FileFilter[];
10487}
10588
106export interface ElectronDialogConfirmInput {
107 readonly owner: Option.Option<Electron.BrowserWindow>;
108 readonly message: string;
109}
110
11189export class ElectronDialog extends Context.Service<
11290 ElectronDialog,
11391 {
......@@ -117,9 +95,6 @@ export class ElectronDialog extends Context.Service<
11795 readonly pickFiles: (
11896 input: ElectronDialogPickFilesInput,
11997 ) => Effect.Effect<readonly string[], ElectronDialogPickFilesError>;
120 readonly confirm: (
121 input: ElectronDialogConfirmInput,
122 ) => Effect.Effect<boolean, ElectronDialogConfirmError>;
12398 readonly showMessageBox: (
12499 options: Electron.MessageBoxOptions,
125100 ) => Effect.Effect<Electron.MessageBoxReturnValue, ElectronDialogShowMessageBoxError>;
......@@ -188,39 +163,6 @@ export const make = ElectronDialog.of({
188163 });
189164 return result.canceled ? [] : result.filePaths;
190165 }),
191 confirm: Effect.fn("desktop.electron.dialog.confirm")(function* (input) {
192 const normalizedMessage = input.message.trim();
193 if (normalizedMessage.length === 0) {
194 return false;
195 }
196
197 const options = {
198 type: "question" as const,
199 buttons: ["No", "Yes"],
200 defaultId: 0,
201 cancelId: 0,
202 noLink: true,
203 message: normalizedMessage,
204 };
205 const ownerWindowId = Option.match(input.owner, {
206 onNone: () => null,
207 onSome: (owner) => owner.id,
208 });
209 const result = yield* Effect.tryPromise({
210 try: () =>
211 Option.match(input.owner, {
212 onNone: () => Electron.dialog.showMessageBox(options),
213 onSome: (owner) => Electron.dialog.showMessageBox(owner, options),
214 }),
215 catch: (cause) =>
216 new ElectronDialogConfirmError({
217 ownerWindowId,
218 promptLength: normalizedMessage.length,
219 cause,
220 }),
221 });
222 return result.response === CONFIRM_BUTTON_INDEX;
223 }),
224166 showMessageBox: (options) =>
225167 Effect.tryPromise({
226168 try: () => Electron.dialog.showMessageBox(options),
apps/desktop/src/ipc/DesktopIpcHandlers.ts-2
......@@ -31,7 +31,6 @@ import {
3131 setUpdateChannel,
3232} from "./methods/updates.ts";
3333import {
34 confirm,
3534 getAppBranding,
3635 getLocalEnvironmentBootstraps,
3736 getLocalEnvironmentBearerToken,
......@@ -81,7 +80,6 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
8180
8281 yield* ipc.handle(pickFolder);
8382 yield* ipc.handle(pickThemeFiles);
84 yield* ipc.handle(confirm);
8583 yield* ipc.handle(setTheme);
8684 yield* ipc.handle(showContextMenu);
8785 yield* ipc.handle(openExternal);
apps/desktop/src/ipc/channels.ts-1
......@@ -1,6 +1,5 @@
11export const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
22export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files";
3export const CONFIRM_CHANNEL = "desktop:confirm";
43export const SET_THEME_CHANNEL = "desktop:set-theme";
54export const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
65export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
apps/desktop/src/ipc/methods/window.ts-13
......@@ -220,19 +220,6 @@ export const pickFolder = DesktopIpc.makeIpcMethod({
220220 }),
221221});
222222
223export const confirm = DesktopIpc.makeIpcMethod({
224 channel: IpcChannels.CONFIRM_CHANNEL,
225 payload: Schema.String,
226 result: Schema.Boolean,
227 handler: Effect.fn("desktop.ipc.window.confirm")(function* (message) {
228 const dialog = yield* ElectronDialog.ElectronDialog;
229 const electronWindow = yield* ElectronWindow.ElectronWindow;
230 return yield* electronWindow.focusedMainOrFirst.pipe(
231 Effect.flatMap((owner) => dialog.confirm({ owner, message })),
232 );
233 }),
234});
235
236223export const setTheme = DesktopIpc.makeIpcMethod({
237224 channel: IpcChannels.SET_THEME_CHANNEL,
238225 payload: DesktopThemeSchema,
apps/desktop/src/preload.ts-1
......@@ -98,7 +98,6 @@ contextBridge.exposeInMainWorld("desktopBridge", {
9898 setWslOnly: (enabled) => ipcRenderer.invoke(IpcChannels.SET_WSL_ONLY_CHANNEL, enabled),
9999 pickFolder: (options) => ipcRenderer.invoke(IpcChannels.PICK_FOLDER_CHANNEL, options),
100100 pickThemeFiles: () => ipcRenderer.invoke(IpcChannels.PICK_THEME_FILES_CHANNEL, undefined),
101 confirm: (message) => ipcRenderer.invoke(IpcChannels.CONFIRM_CHANNEL, message),
102101 setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme),
103102 showContextMenu: (items, position) =>
104103 ipcRenderer.invoke(IpcChannels.CONTEXT_MENU_CHANNEL, {
apps/desktop/src/window/DesktopApplicationMenu.test.ts-1
......@@ -53,7 +53,6 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, {
5353const electronDialogLayer = Layer.succeed(ElectronDialog.ElectronDialog, {
5454 pickFolder: () => Effect.succeed(Option.none()),
5555 pickFiles: () => Effect.succeed([]),
56 confirm: () => Effect.succeed(false),
5756 showMessageBox: () => Effect.succeed({ response: 0, checkboxChecked: false }),
5857 showErrorBox: () => Effect.void,
5958} satisfies ElectronDialog.ElectronDialog["Service"]);
apps/web/src/components/ChatView.tsx+1
......@@ -4745,6 +4745,7 @@ function ChatViewContent(props: ChatViewProps) {
47454745 "This will discard newer messages and turn diffs in this thread.",
47464746 "This action cannot be undone.",
47474747 ].join("\n"),
4748 { variant: "destructive" },
47484749 );
47494750 if (!confirmed) {
47504751 return;
apps/web/src/components/ConfirmDialogHost.tsx created+96
......@@ -0,0 +1,96 @@
1import { useEffect, useSyncExternalStore } from "react";
2
3import {
4 completeConfirmDialogClose,
5 readConfirmDialogState,
6 registerConfirmDialogHost,
7 respondToConfirmDialog,
8 subscribeConfirmDialog,
9} from "../confirmDialog";
10import {
11 AlertDialog,
12 AlertDialogClose,
13 AlertDialogDescription,
14 AlertDialogFooter,
15 AlertDialogHeader,
16 AlertDialogPopup,
17 AlertDialogTitle,
18} from "./ui/alert-dialog";
19import { Button } from "./ui/button";
20
21type ConfirmationCopy = {
22 readonly title: string;
23 readonly description: string | null;
24};
25
26export function resolveConfirmDialogCopy(message: string): ConfirmationCopy {
27 const normalizedMessage = message.trim();
28 const lines = normalizedMessage.split("\n");
29 const questionLineIndex = lines.findIndex((line) => line.trim().endsWith("?"));
30
31 if (questionLineIndex >= 0) {
32 const title = lines[questionLineIndex]!.trim();
33 const description = lines
34 .filter((_, index) => index !== questionLineIndex)
35 .join("\n")
36 .trim();
37 return { title, description: description || null };
38 }
39
40 const questionMarkIndex = normalizedMessage.indexOf("?");
41 if (questionMarkIndex >= 0) {
42 return {
43 title: normalizedMessage.slice(0, questionMarkIndex + 1).trim(),
44 description: normalizedMessage.slice(questionMarkIndex + 1).trim() || null,
45 };
46 }
47
48 return {
49 title: "Confirm action",
50 description: normalizedMessage || "This action requires your confirmation.",
51 };
52}
53
54export function ConfirmDialogHost() {
55 const state = useSyncExternalStore(
56 subscribeConfirmDialog,
57 readConfirmDialogState,
58 readConfirmDialogState,
59 );
60
61 useEffect(() => registerConfirmDialogHost(), []);
62
63 const copy = resolveConfirmDialogCopy(state.status === "idle" ? "" : state.message);
64 const confirmVariant = state.status === "idle" ? "default" : state.variant;
65 const onCancel = () => respondToConfirmDialog(false);
66 const onConfirm = () => respondToConfirmDialog(true);
67
68 return (
69 <AlertDialog
70 open={state.status === "confirming"}
71 onOpenChange={(open) => {
72 if (!open) onCancel();
73 }}
74 onOpenChangeComplete={(open) => {
75 if (!open) completeConfirmDialogClose();
76 }}
77 >
78 <AlertDialogPopup className="max-w-lg">
79 <AlertDialogHeader>
80 <AlertDialogTitle>{copy.title}</AlertDialogTitle>
81 {copy.description ? (
82 <AlertDialogDescription className="whitespace-pre-line">
83 {copy.description}
84 </AlertDialogDescription>
85 ) : null}
86 </AlertDialogHeader>
87 <AlertDialogFooter>
88 <AlertDialogClose render={<Button variant="outline" />}>Cancel</AlertDialogClose>
89 <Button variant={confirmVariant} onClick={onConfirm}>
90 Confirm
91 </Button>
92 </AlertDialogFooter>
93 </AlertDialogPopup>
94 </AlertDialog>
95 );
96}
apps/web/src/components/LegacySidebar.tsx+50-12
......@@ -104,7 +104,7 @@ import {
104104} from "../keybindings";
105105import { isModelPickerOpen } from "../modelPickerVisibility";
106106import { useShortcutModifierState } from "../shortcutModifierState";
107import { readLocalApi } from "../localApi";
107import { ensureLocalApi, readLocalApi } from "../localApi";
108108import { useComposerDraftStore } from "../composerDraftStore";
109109import { useNewThreadHandler } from "../hooks/useHandleNewThread";
110110import { useDesktopUpdateState } from "../state/desktopUpdate";
......@@ -1499,6 +1499,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
14991499 : []),
15001500 "This removes only this project entry.",
15011501 ].join("\n"),
1502 { variant: "destructive" },
15021503 );
15031504 if (!confirmed) {
15041505 return;
......@@ -1547,7 +1548,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
15471548 ...(member.environmentLabel ? [`Environment: ${member.environmentLabel}`] : []),
15481549 "This removes only this project entry.",
15491550 ].join("\n");
1550 const confirmed = await api.dialogs.confirm(message);
1551 const confirmed = await api.dialogs.confirm(message, { variant: "destructive" });
15511552 if (!confirmed) {
15521553 return;
15531554 }
......@@ -1830,6 +1831,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
18301831 `Delete ${count} thread${count === 1 ? "" : "s"}?`,
18311832 "This permanently clears conversation history for these threads.",
18321833 ].join("\n"),
1834 { variant: "destructive" },
18331835 );
18341836 if (!confirmed) return;
18351837 }
......@@ -2180,6 +2182,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
21802182 `Delete thread "${thread.title}"?`,
21812183 "This permanently clears conversation history for this thread.",
21822184 ].join("\n"),
2185 { variant: "destructive" },
21832186 );
21842187 if (!confirmed) {
21852188 return;
......@@ -2730,6 +2733,7 @@ interface SidebarProjectsContentProps {
27302733 arm64IntelBuildWarningDescription: string | null;
27312734 desktopUpdateButtonAction: "download" | "install" | "none";
27322735 desktopUpdateButtonDisabled: boolean;
2736 desktopUpdateActionPending: boolean;
27332737 handleDesktopUpdateButtonClick: () => void;
27342738 projectSortOrder: SidebarProjectSortOrder;
27352739 threadSortOrder: SidebarThreadSortOrder;
......@@ -2770,6 +2774,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
27702774 arm64IntelBuildWarningDescription,
27712775 desktopUpdateButtonAction,
27722776 desktopUpdateButtonDisabled,
2777 desktopUpdateActionPending,
27732778 handleDesktopUpdateButtonClick,
27742779 projectSortOrder,
27752780 threadSortOrder,
......@@ -2862,7 +2867,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
28622867 <Button
28632868 size="xs"
28642869 variant="outline"
2865 disabled={desktopUpdateButtonDisabled}
2870 disabled={desktopUpdateButtonDisabled || desktopUpdateActionPending}
28662871 onClick={handleDesktopUpdateButtonClick}
28672872 >
28682873 {desktopUpdateButtonAction === "download"
......@@ -3032,6 +3037,7 @@ export default function LegacySidebar() {
30323037 const suppressProjectClickAfterDragRef = useRef(false);
30333038 const suppressProjectClickForContextMenuRef = useRef(false);
30343039 const desktopUpdateState = useDesktopUpdateState();
3040 const [desktopUpdateActionPending, setDesktopUpdateActionPending] = useState(false);
30353041 const clearSelection = useThreadSelectionStore((s) => s.clearSelection);
30363042 const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor);
30373043 const platform = navigator.platform;
......@@ -3498,10 +3504,18 @@ export default function LegacySidebar() {
34983504 "commandPalette.toggle",
34993505 newThreadShortcutLabelOptions,
35003506 );
3501 const handleDesktopUpdateButtonClick = useCallback(() => {
3507 const handleDesktopUpdateButtonClick = useCallback(async () => {
35023508 const bridge = window.desktopBridge;
35033509 if (!bridge || !desktopUpdateState) return;
3504 if (desktopUpdateButtonDisabled || desktopUpdateButtonAction === "none") return;
3510 if (
3511 desktopUpdateButtonDisabled ||
3512 desktopUpdateButtonAction === "none" ||
3513 desktopUpdateActionPending
3514 ) {
3515 return;
3516 }
3517
3518 setDesktopUpdateActionPending(true);
35053519
35063520 if (desktopUpdateButtonAction === "download") {
35073521 void bridge
......@@ -3529,15 +3543,32 @@ export default function LegacySidebar() {
35293543 description: error instanceof Error ? error.message : "An unexpected error occurred.",
35303544 }),
35313545 );
3532 });
3546 })
3547 .finally(() => setDesktopUpdateActionPending(false));
35333548 return;
35343549 }
35353550
35363551 if (desktopUpdateButtonAction === "install") {
3537 const confirmed = window.confirm(
3538 getDesktopUpdateInstallConfirmationMessage(desktopUpdateState, navigator.platform),
3539 );
3540 if (!confirmed) return;
3552 let confirmed = false;
3553 try {
3554 confirmed = await ensureLocalApi().dialogs.confirm(
3555 getDesktopUpdateInstallConfirmationMessage(desktopUpdateState, navigator.platform),
3556 );
3557 } catch (error) {
3558 setDesktopUpdateActionPending(false);
3559 toastManager.add(
3560 stackedThreadToast({
3561 type: "error",
3562 title: "Could not confirm update",
3563 description: error instanceof Error ? error.message : "Update confirmation failed.",
3564 }),
3565 );
3566 return;
3567 }
3568 if (!confirmed) {
3569 setDesktopUpdateActionPending(false);
3570 return;
3571 }
35413572 void bridge
35423573 .installUpdate()
35433574 .then((result) => {
......@@ -3560,9 +3591,15 @@ export default function LegacySidebar() {
35603591 description: error instanceof Error ? error.message : "An unexpected error occurred.",
35613592 }),
35623593 );
3563 });
3594 })
3595 .finally(() => setDesktopUpdateActionPending(false));
35643596 }
3565 }, [desktopUpdateButtonAction, desktopUpdateButtonDisabled, desktopUpdateState]);
3597 }, [
3598 desktopUpdateActionPending,
3599 desktopUpdateButtonAction,
3600 desktopUpdateButtonDisabled,
3601 desktopUpdateState,
3602 ]);
35663603
35673604 const expandThreadListForProject = useCallback((projectKey: string) => {
35683605 setExpandedThreadListsByProject((current) => {
......@@ -3594,6 +3631,7 @@ export default function LegacySidebar() {
35943631 arm64IntelBuildWarningDescription={arm64IntelBuildWarningDescription}
35953632 desktopUpdateButtonAction={desktopUpdateButtonAction}
35963633 desktopUpdateButtonDisabled={desktopUpdateButtonDisabled}
3634 desktopUpdateActionPending={desktopUpdateActionPending}
35973635 handleDesktopUpdateButtonClick={handleDesktopUpdateButtonClick}
35983636 projectSortOrder={sidebarProjectSortOrder}
35993637 threadSortOrder={sidebarThreadSortOrder}
apps/web/src/components/Sidebar.tsx+2
......@@ -2812,6 +2812,7 @@ export default function Sidebar() {
28122812 `Delete ${count} thread${count === 1 ? "" : "s"}?`,
28132813 "This permanently clears conversation history for these threads.",
28142814 ].join("\n"),
2815 { variant: "destructive" },
28152816 ),
28162817 );
28172818 if (confirmed._tag === "Failure" || !confirmed.value) return;
......@@ -3014,6 +3015,7 @@ export default function Sidebar() {
30143015 `Delete thread "${thread.title}"?`,
30153016 "This permanently clears conversation history for this thread.",
30163017 ].join("\n"),
3018 { variant: "destructive" },
30173019 ),
30183020 );
30193021 if (confirmed._tag === "Failure" || !confirmed.value) return;
apps/web/src/components/settings/DiagnosticsSettings.tsx+46-15
......@@ -12,7 +12,7 @@ import {
1212 isAtomCommandInterrupted,
1313 squashAtomCommandFailure,
1414} from "@t3tools/client-runtime/state/runtime";
15import { useCallback, useMemo, useState, type ReactNode } from "react";
15import { useCallback, useMemo, useRef, useState, type ReactNode } from "react";
1616import type {
1717 ServerProcessDiagnosticsEntry,
1818 ServerProcessResourceHistorySummary,
......@@ -22,6 +22,7 @@ import * as DateTime from "effect/DateTime";
2222import * as Option from "effect/Option";
2323
2424import { cn } from "../../lib/utils";
25import { ensureLocalApi } from "../../localApi";
2526import { resolveAndPersistPreferredEditor } from "../../editorPreferences";
2627import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat";
2728import { useEnvironmentQuery } from "../../state/query";
......@@ -857,6 +858,11 @@ export function DiagnosticsSettingsPanel() {
857858 const [isOpeningLogsDirectory, setIsOpeningLogsDirectory] = useState(false);
858859 const [openLogsDirectoryError, setOpenLogsDirectoryError] = useState<string | null>(null);
859860 const [signalingPid, setSignalingPid] = useState<number | null>(null);
861 const signalingPidRef = useRef<number | null>(null);
862 const environmentIdRef = useRef(environmentId);
863 const processDataRef = useRef(processData);
864 environmentIdRef.current = environmentId;
865 processDataRef.current = processData;
860866
861867 const openLogsDirectory = useCallback(() => {
862868 const logsDirectoryPath = observability?.logsDirectoryPath ?? null;
......@@ -895,28 +901,51 @@ export function DiagnosticsSettingsPanel() {
895901 const isInitialLoading = isPending && data === null;
896902 const isProcessInitialLoading = isProcessPending && processData === null;
897903 const signalProcess = useCallback(
898 (pid: number, signal: ServerProcessSignal) => {
899 if (
900 signal === "SIGKILL" &&
901 !window.confirm(`Send SIGKILL to process ${pid}? This cannot be handled by the process.`)
902 ) {
903 return;
904 async (pid: number, signal: ServerProcessSignal) => {
905 if (signalingPidRef.current !== null) return;
906 signalingPidRef.current = pid;
907 setSignalingPid(pid);
908 const clearSignaling = () => {
909 signalingPidRef.current = null;
910 setSignalingPid(null);
911 };
912 if (signal === "SIGKILL") {
913 let confirmed = false;
914 try {
915 confirmed = await ensureLocalApi().dialogs.confirm(
916 `Send SIGKILL to process ${pid}? This cannot be handled by the process.`,
917 { variant: "destructive" },
918 );
919 } catch (error) {
920 clearSignaling();
921 toastManager.add({
922 type: "error",
923 title: "Could not confirm signal",
924 description: error instanceof Error ? error.message : `Failed to send ${signal}.`,
925 });
926 return;
927 }
928 if (!confirmed) {
929 clearSignaling();
930 return;
931 }
904932 }
905 if (environmentId === null) {
933 const currentEnvironmentId = environmentIdRef.current;
934 if (currentEnvironmentId === null) {
935 clearSignaling();
906936 return;
907937 }
908 const process = processData?.processes.find((entry) => entry.pid === pid);
938 const process = processDataRef.current?.processes.find((entry) => entry.pid === pid);
909939 if (process === undefined) {
940 clearSignaling();
910941 return;
911942 }
912943
913 setSignalingPid(pid);
914 void (async () => {
944 try {
915945 const result = await signalServerProcess({
916 environmentId,
946 environmentId: currentEnvironmentId,
917947 input: { pid, startTimeMs: process.startTimeMs, signal },
918948 });
919 setSignalingPid(null);
920949 if (result._tag === "Failure") {
921950 if (!isAtomCommandInterrupted(result)) {
922951 const error = squashAtomCommandFailure(result);
......@@ -949,9 +978,11 @@ export function DiagnosticsSettingsPanel() {
949978 return;
950979 }
951980 refreshProcesses();
952 })();
981 } finally {
982 clearSignaling();
983 }
953984 },
954 [environmentId, processData?.processes, refreshProcesses, signalServerProcess],
985 [refreshProcesses, signalServerProcess],
955986 );
956987
957988 const processDiagnosticsError = processData ? Option.getOrNull(processData.error) : null;
apps/web/src/components/settings/ProjectSettingsPanel.tsx+1
......@@ -700,6 +700,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
700700 : "Other entries in this grouped project are unaffected.",
701701 "This action cannot be undone.",
702702 ].join("\n"),
703 { variant: "destructive" },
703704 ),
704705 );
705706 if (confirmed._tag === "Failure" || !confirmed.value) return;
apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx+43-19
......@@ -27,7 +27,7 @@ import type {
2727} from "@t3tools/contracts";
2828import * as DateTime from "effect/DateTime";
2929import * as Option from "effect/Option";
30import { useCallback, useMemo, useState, type ReactNode } from "react";
30import { useCallback, useMemo, useRef, useState, type ReactNode } from "react";
3131import {
3232 isAtomCommandInterrupted,
3333 squashAtomCommandFailure,
......@@ -38,6 +38,7 @@ import {
3838 useResourceTelemetryHistory,
3939} from "../../lib/resourceTelemetryState";
4040import { cn } from "../../lib/utils";
41import { ensureLocalApi } from "../../localApi";
4142import { usePrimaryEnvironment } from "../../state/environments";
4243import { serverEnvironment } from "../../state/server";
4344import { useAtomCommand } from "../../state/use-atom-command";
......@@ -845,26 +846,54 @@ export function ResourceTelemetryDiagnostics() {
845846 reportFailure: false,
846847 });
847848 const [signalingKeys, setSignalingKeys] = useState<ReadonlySet<string>>(() => new Set());
849 const signalingKeysRef = useRef<ReadonlySet<string>>(new Set());
850 signalingKeysRef.current = signalingKeys;
851 const primaryEnvironmentIdRef = useRef(primaryEnvironment?.environmentId);
852 primaryEnvironmentIdRef.current = primaryEnvironment?.environmentId;
848853 const [isRetrying, setIsRetrying] = useState(false);
849854 const snapshot = telemetry.data;
850855 const allT3 = snapshot?.groups.allT3;
851856
852857 const signalProcess = useCallback(
853 (process: ResourceTelemetryProcess, signal: ServerProcessSignal) => {
854 if (
855 signal === "SIGKILL" &&
856 !window.confirm(
857 `Send SIGKILL to process ${process.identity.pid}? This cannot be handled by the process.`,
858 )
859 ) {
860 return;
861 }
858 async (process: ResourceTelemetryProcess, signal: ServerProcessSignal) => {
862859 const identityKey = processIdentityKey(process);
863 const environmentId = primaryEnvironment?.environmentId;
860 if (signalingKeysRef.current.has(identityKey)) return;
861 const nextSignalingKeys = new Set(signalingKeysRef.current).add(identityKey);
862 signalingKeysRef.current = nextSignalingKeys;
863 setSignalingKeys(nextSignalingKeys);
864 const clearSignaling = () => {
865 const next = new Set(signalingKeysRef.current);
866 next.delete(identityKey);
867 signalingKeysRef.current = next;
868 setSignalingKeys(next);
869 };
870
871 if (signal === "SIGKILL") {
872 let confirmed = false;
873 try {
874 confirmed = await ensureLocalApi().dialogs.confirm(
875 `Send SIGKILL to process ${process.identity.pid}? This cannot be handled by the process.`,
876 { variant: "destructive" },
877 );
878 } catch (error) {
879 clearSignaling();
880 toastManager.add({
881 type: "error",
882 title: "Could not confirm signal",
883 description: error instanceof Error ? error.message : `Failed to send ${signal}.`,
884 });
885 return;
886 }
887 if (!confirmed) {
888 clearSignaling();
889 return;
890 }
891 }
892 const environmentId = primaryEnvironmentIdRef.current;
864893 if (environmentId === undefined) {
894 clearSignaling();
865895 return;
866896 }
867 setSignalingKeys((current) => new Set(current).add(identityKey));
868897 void signalServerProcess({
869898 environmentId,
870899 input: {
......@@ -896,15 +925,10 @@ export function ResourceTelemetryDiagnostics() {
896925 });
897926 })
898927 .finally(() => {
899 setSignalingKeys((current) => {
900 if (!current.has(identityKey)) return current;
901 const next = new Set(current);
902 next.delete(identityKey);
903 return next;
904 });
928 clearSignaling();
905929 });
906930 },
907 [primaryEnvironment?.environmentId, signalServerProcess],
931 [signalServerProcess],
908932 );
909933
910934 const retryCollector = useCallback(() => {
apps/web/src/components/settings/SettingsPanels.tsx+37-14
......@@ -220,6 +220,7 @@ function AboutVersionTitle() {
220220function AboutVersionSection() {
221221 const updateState = useDesktopUpdateState();
222222 const [isChangingUpdateChannel, setIsChangingUpdateChannel] = useState(false);
223 const [isUpdateActionPending, setIsUpdateActionPending] = useState(false);
223224
224225 const hasDesktopBridge = typeof window !== "undefined" && Boolean(window.desktopBridge);
225226 const selectedUpdateChannel = updateState?.channel ?? "latest";
......@@ -255,7 +256,7 @@ function AboutVersionSection() {
255256 [selectedUpdateChannel],
256257 );
257258
258 const handleButtonClick = useCallback(() => {
259 const handleButtonClick = useCallback(async () => {
259260 const bridge = window.desktopBridge;
260261 if (!bridge) return;
261262
......@@ -275,22 +276,43 @@ function AboutVersionSection() {
275276 }
276277
277278 if (action === "install") {
278 const confirmed = window.confirm(
279 getDesktopUpdateInstallConfirmationMessage(
280 updateState ?? { availableVersion: null, downloadedVersion: null },
281 navigator.platform,
282 ),
283 );
284 if (!confirmed) return;
285 void bridge.installUpdate().catch((error: unknown) => {
279 if (isUpdateActionPending) return;
280 setIsUpdateActionPending(true);
281 let confirmed = false;
282 try {
283 confirmed = await ensureLocalApi().dialogs.confirm(
284 getDesktopUpdateInstallConfirmationMessage(
285 updateState ?? { availableVersion: null, downloadedVersion: null },
286 navigator.platform,
287 ),
288 );
289 } catch (error) {
290 setIsUpdateActionPending(false);
286291 toastManager.add(
287292 stackedThreadToast({
288293 type: "error",
289 title: "Could not install update",
290 description: error instanceof Error ? error.message : "Install failed.",
294 title: "Could not confirm update",
295 description: error instanceof Error ? error.message : "Update confirmation failed.",
291296 }),
292297 );
293 });
298 return;
299 }
300 if (!confirmed) {
301 setIsUpdateActionPending(false);
302 return;
303 }
304 void bridge
305 .installUpdate()
306 .catch((error: unknown) => {
307 toastManager.add(
308 stackedThreadToast({
309 type: "error",
310 title: "Could not install update",
311 description: error instanceof Error ? error.message : "Install failed.",
312 }),
313 );
314 })
315 .finally(() => setIsUpdateActionPending(false));
294316 return;
295317 }
296318
......@@ -318,7 +340,7 @@ function AboutVersionSection() {
318340 }),
319341 );
320342 });
321 }, [updateState]);
343 }, [isUpdateActionPending, updateState]);
322344
323345 const action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none";
324346 const buttonTooltip = updateState ? getDesktopUpdateButtonTooltip(updateState) : null;
......@@ -352,7 +374,7 @@ function AboutVersionSection() {
352374 <Button
353375 size="xs"
354376 variant={action === "install" ? "default" : "outline"}
355 disabled={buttonDisabled}
377 disabled={buttonDisabled || isUpdateActionPending}
356378 onClick={handleButtonClick}
357379 >
358380 {buttonLabel}
......@@ -550,6 +572,7 @@ export function useSettingsRestore(onRestored?: () => void) {
550572 ["Restore default settings?", `This will reset: ${changedSettingLabels.join(", ")}.`].join(
551573 "\n",
552574 ),
575 { variant: "destructive" },
553576 );
554577 if (!confirmed) return;
555578
apps/web/src/components/sidebar/SidebarUpdatePill.tsx+33-11
......@@ -1,6 +1,7 @@
11import { DownloadIcon, RotateCwIcon, TriangleAlertIcon, XIcon } from "lucide-react";
22import { useCallback, useState } from "react";
33import { isElectron } from "../../env";
4import { ensureLocalApi } from "../../localApi";
45import { useDesktopUpdateState } from "../../state/desktopUpdate";
56import { stackedThreadToast, toastManager } from "../ui/toast";
67import {
......@@ -70,6 +71,7 @@ function SidebarUpdateReleaseNotesTooltip({
7071export function SidebarUpdatePill() {
7172 const state = useDesktopUpdateState();
7273 const [dismissed, setDismissed] = useState(false);
74 const [isActionPending, setIsActionPending] = useState(false);
7375
7476 const visible = isElectron && shouldShowDesktopUpdateButton(state) && !dismissed;
7577 const tooltip = state ? getDesktopUpdateButtonTooltip(state) : "Update available";
......@@ -80,10 +82,12 @@ export function SidebarUpdatePill() {
8082 const arm64Description =
8183 state && showArm64Warning ? getArm64IntelBuildWarningDescription(state) : null;
8284
83 const handleAction = useCallback(() => {
85 const handleAction = useCallback(async () => {
8486 const bridge = window.desktopBridge;
8587 if (!bridge || !state) return;
86 if (disabled || action === "none") return;
88 if (disabled || action === "none" || isActionPending) return;
89
90 setIsActionPending(true);
8791
8892 if (action === "download") {
8993 void bridge
......@@ -111,15 +115,32 @@ export function SidebarUpdatePill() {
111115 description: error instanceof Error ? error.message : "An unexpected error occurred.",
112116 }),
113117 );
114 });
118 })
119 .finally(() => setIsActionPending(false));
115120 return;
116121 }
117122
118123 if (action === "install") {
119 const confirmed = window.confirm(
120 getDesktopUpdateInstallConfirmationMessage(state, navigator.platform),
121 );
122 if (!confirmed) return;
124 let confirmed = false;
125 try {
126 confirmed = await ensureLocalApi().dialogs.confirm(
127 getDesktopUpdateInstallConfirmationMessage(state, navigator.platform),
128 );
129 } catch (error) {
130 setIsActionPending(false);
131 toastManager.add(
132 stackedThreadToast({
133 type: "error",
134 title: "Could not confirm update",
135 description: error instanceof Error ? error.message : "Update confirmation failed.",
136 }),
137 );
138 return;
139 }
140 if (!confirmed) {
141 setIsActionPending(false);
142 return;
143 }
123144 void bridge
124145 .installUpdate()
125146 .then((result) => {
......@@ -142,9 +163,10 @@ export function SidebarUpdatePill() {
142163 description: error instanceof Error ? error.message : "An unexpected error occurred.",
143164 }),
144165 );
145 });
166 })
167 .finally(() => setIsActionPending(false));
146168 }
147 }, [action, disabled, state]);
169 }, [action, disabled, isActionPending, state]);
148170
149171 if (!visible && !showArm64Warning) return null;
150172
......@@ -170,8 +192,8 @@ export function SidebarUpdatePill() {
170192 <button
171193 type="button"
172194 aria-label={tooltip}
173 aria-disabled={disabled || undefined}
174 disabled={disabled}
195 aria-disabled={disabled || isActionPending || undefined}
196 disabled={disabled || isActionPending}
175197 className="update-main relative flex h-full flex-1 items-center gap-2 px-2 enabled:cursor-pointer"
176198 onClick={handleAction}
177199 >
apps/web/src/confirmDialog.test.ts created+104
......@@ -0,0 +1,104 @@
1import { beforeEach, describe, expect, it } from "vite-plus/test";
2
3import {
4 completeConfirmDialogClose,
5 readConfirmDialogState,
6 registerConfirmDialogHost,
7 requestConfirmDialog,
8 resetConfirmDialogForTests,
9 respondToConfirmDialog,
10} from "./confirmDialog";
11
12function requireConfirmation(confirmation: Promise<boolean> | undefined): Promise<boolean> {
13 if (!confirmation) {
14 throw new Error("Expected a registered confirmation host.");
15 }
16 return confirmation;
17}
18
19describe("confirm dialog coordinator", () => {
20 beforeEach(() => {
21 resetConfirmDialogForTests();
22 });
23
24 it("returns undefined until a themed host is mounted", () => {
25 expect(requestConfirmDialog("Confirm this action?")).toBeUndefined();
26 expect(readConfirmDialogState()).toEqual({ status: "idle" });
27 });
28
29 it("resolves a displayed confirmation and waits for its close transition", async () => {
30 const unregister = registerConfirmDialogHost();
31 const confirmation = requireConfirmation(
32 requestConfirmDialog("Delete this thread?", { variant: "destructive" }),
33 );
34
35 expect(readConfirmDialogState()).toEqual({
36 status: "confirming",
37 message: "Delete this thread?",
38 variant: "destructive",
39 });
40
41 respondToConfirmDialog(true);
42 await expect(confirmation).resolves.toBe(true);
43 expect(readConfirmDialogState()).toEqual({
44 status: "closing",
45 message: "Delete this thread?",
46 variant: "destructive",
47 });
48
49 completeConfirmDialogClose();
50 expect(readConfirmDialogState()).toEqual({ status: "idle" });
51 unregister();
52 });
53
54 it("serializes concurrent confirmations", async () => {
55 const unregister = registerConfirmDialogHost();
56 const first = requireConfirmation(requestConfirmDialog("Delete the project?"));
57 const second = requireConfirmation(requestConfirmDialog("Delete the worktree too?"));
58
59 respondToConfirmDialog(false);
60 await expect(first).resolves.toBe(false);
61 expect(readConfirmDialogState()).toEqual({
62 status: "closing",
63 message: "Delete the project?",
64 variant: "default",
65 });
66
67 completeConfirmDialogClose();
68 expect(readConfirmDialogState()).toEqual({
69 status: "confirming",
70 message: "Delete the worktree too?",
71 variant: "default",
72 });
73
74 respondToConfirmDialog(true);
75 await expect(second).resolves.toBe(true);
76 completeConfirmDialogClose();
77 expect(readConfirmDialogState()).toEqual({ status: "idle" });
78 unregister();
79 });
80
81 it("cancels active and queued confirmations if the last host unmounts", async () => {
82 const unregister = registerConfirmDialogHost();
83 const active = requireConfirmation(requestConfirmDialog("Delete the thread?"));
84 const queued = requireConfirmation(requestConfirmDialog("Delete the worktree too?"));
85
86 unregister();
87
88 await expect(Promise.all([active, queued])).resolves.toEqual([false, false]);
89 expect(readConfirmDialogState()).toEqual({ status: "idle" });
90 });
91
92 it("ignores responses after the active dialog has been closed", () => {
93 const unregister = registerConfirmDialogHost();
94 const confirmation = requireConfirmation(requestConfirmDialog("Continue?"));
95
96 respondToConfirmDialog(true);
97 respondToConfirmDialog(false);
98 completeConfirmDialogClose();
99
100 expect(readConfirmDialogState()).toEqual({ status: "idle" });
101 unregister();
102 return expect(confirmation).resolves.toBe(true);
103 });
104});
apps/web/src/confirmDialog.ts created+131
......@@ -0,0 +1,131 @@
1import type { ConfirmDialogOptions, ConfirmDialogVariant } from "@t3tools/contracts";
2
3export type ConfirmDialogState =
4 | { readonly status: "idle" }
5 | {
6 readonly status: "confirming";
7 readonly message: string;
8 readonly variant: ConfirmDialogVariant;
9 }
10 | {
11 readonly status: "closing";
12 readonly message: string;
13 readonly variant: ConfirmDialogVariant;
14 };
15
16type PendingConfirmation = {
17 readonly message: string;
18 readonly variant: ConfirmDialogVariant;
19 readonly resolve: (confirmed: boolean) => void;
20};
21
22const idleState: ConfirmDialogState = { status: "idle" };
23let state: ConfirmDialogState = idleState;
24let activeConfirmation: PendingConfirmation | null = null;
25let queuedConfirmations: PendingConfirmation[] = [];
26let registeredHostCount = 0;
27const listeners = new Set<() => void>();
28
29function publish(next: ConfirmDialogState): void {
30 state = next;
31 for (const listener of listeners) {
32 listener();
33 }
34}
35
36function resolvePendingConfirmations(confirmed: boolean): void {
37 activeConfirmation?.resolve(confirmed);
38 for (const confirmation of queuedConfirmations) {
39 confirmation.resolve(confirmed);
40 }
41 activeConfirmation = null;
42 queuedConfirmations = [];
43}
44
45export function readConfirmDialogState(): ConfirmDialogState {
46 return state;
47}
48
49export function subscribeConfirmDialog(listener: () => void): () => void {
50 listeners.add(listener);
51 return () => {
52 listeners.delete(listener);
53 };
54}
55
56/**
57 * Registers the renderer host that can present themed confirmations. The
58 * returned cleanup function also cancels any request left without a host.
59 */
60export function registerConfirmDialogHost(): () => void {
61 registeredHostCount += 1;
62 let registered = true;
63
64 return () => {
65 if (!registered) return;
66 registered = false;
67 registeredHostCount = Math.max(0, registeredHostCount - 1);
68
69 if (registeredHostCount === 0) {
70 resolvePendingConfirmations(false);
71 publish(idleState);
72 }
73 };
74}
75
76/**
77 * Requests a themed confirmation when a host is mounted. An undefined result
78 * means no themed host is currently available.
79 */
80export function requestConfirmDialog(
81 message: string,
82 options?: ConfirmDialogOptions,
83): Promise<boolean> | undefined {
84 if (registeredHostCount === 0) return undefined;
85
86 const confirmation = new Promise<boolean>((resolve) => {
87 const pending = {
88 message,
89 variant: options?.variant ?? "default",
90 resolve,
91 } satisfies PendingConfirmation;
92 if (activeConfirmation || state.status === "closing") {
93 queuedConfirmations.push(pending);
94 return;
95 }
96
97 activeConfirmation = pending;
98 publish({ status: "confirming", message, variant: pending.variant });
99 });
100
101 return confirmation;
102}
103
104export function respondToConfirmDialog(confirmed: boolean): void {
105 if (state.status !== "confirming" || !activeConfirmation) return;
106
107 const confirmation = activeConfirmation;
108 activeConfirmation = null;
109 confirmation.resolve(confirmed);
110 publish({ status: "closing", message: state.message, variant: state.variant });
111}
112
113export function completeConfirmDialogClose(): void {
114 if (state.status !== "closing") return;
115
116 const next = queuedConfirmations.shift();
117 if (!next) {
118 publish(idleState);
119 return;
120 }
121
122 activeConfirmation = next;
123 publish({ status: "confirming", message: next.message, variant: next.variant });
124}
125
126export function resetConfirmDialogForTests(): void {
127 resolvePendingConfirmations(false);
128 registeredHostCount = 0;
129 publish(idleState);
130 listeners.clear();
131}
apps/web/src/hooks/useThreadActionMenu.ts+1
......@@ -250,6 +250,7 @@ export function useThreadActionMenu(input: {
250250 `Delete thread "${thread.title}"?`,
251251 "This permanently clears conversation history for this thread.",
252252 ].join("\n"),
253 { variant: "destructive" },
253254 ),
254255 );
255256 if (confirmed._tag === "Failure" || !confirmed.value) return;
apps/web/src/hooks/useThreadActions.ts+2
......@@ -324,6 +324,7 @@ export function useThreadActions() {
324324 "",
325325 "Delete the worktree too?",
326326 ].join("\n"),
327 { variant: "destructive" },
327328 ),
328329 );
329330 if (confirmationResult._tag === "Failure") {
......@@ -678,6 +679,7 @@ export function useThreadActions() {
678679 `Delete thread "${title}"?`,
679680 "This permanently clears conversation history for this thread.",
680681 ].join("\n"),
682 { variant: "destructive" },
681683 ),
682684 );
683685 if (confirmationResult._tag === "Failure") {
apps/web/src/localApi.test.ts+28
......@@ -1,5 +1,6 @@
11import {
22 DEFAULT_CLIENT_SETTINGS,
3 type ConfirmDialogOptions,
34 type ContextMenuItem,
45 type DesktopBridge,
56} from "@t3tools/contracts";
......@@ -13,10 +14,17 @@ const showContextMenuFallbackMock =
1314 ) => Promise<T | null>
1415 >();
1516
17const requestConfirmDialogMock =
18 vi.fn<(message: string, options?: ConfirmDialogOptions) => Promise<boolean> | undefined>();
19
1620vi.mock("./contextMenuFallback", () => ({
1721 showContextMenuFallback: showContextMenuFallbackMock,
1822}));
1923
24vi.mock("./confirmDialog", () => ({
25 requestConfirmDialog: requestConfirmDialogMock,
26}));
27
2028function createLocalStorageStub(): Storage {
2129 const values = new Map<string, string>();
2230 return {
......@@ -77,6 +85,24 @@ describe("LocalApi", () => {
7785 expect(showContextMenuFallbackMock).toHaveBeenCalledWith(items, { x: 4, y: 5 });
7886 });
7987
88 it("uses the themed confirmation host when it is available", async () => {
89 requestConfirmDialogMock.mockResolvedValue(true);
90 const { createLocalApi } = await import("./localApi");
91 const options = { variant: "destructive" } as const;
92
93 await expect(createLocalApi().dialogs.confirm("Delete this thread?", options)).resolves.toBe(
94 true,
95 );
96 expect(requestConfirmDialogMock).toHaveBeenCalledWith("Delete this thread?", options);
97 });
98
99 it("fails closed in a browser when no themed host is available", async () => {
100 requestConfirmDialogMock.mockReturnValue(undefined);
101 const { createLocalApi } = await import("./localApi");
102
103 await expect(createLocalApi().dialogs.confirm("Delete this thread?")).resolves.toBe(false);
104 });
105
80106 it("delegates host capabilities and persistence to the desktop bridge", async () => {
81107 const showContextMenu = vi.fn().mockResolvedValue("delete");
82108 const pickFolder = vi.fn().mockResolvedValue("/tmp/project");
......@@ -94,6 +120,8 @@ describe("LocalApi", () => {
94120 const items = [{ id: "delete", label: "Delete" }] as const;
95121
96122 await expect(api.contextMenu.show(items)).resolves.toBe("delete");
123 requestConfirmDialogMock.mockReturnValue(undefined);
124 await expect(api.dialogs.confirm("Install update?")).resolves.toBe(false);
97125 await expect(api.dialogs.pickFolder({ initialPath: "/tmp" })).resolves.toBe("/tmp/project");
98126 await expect(api.persistence.getClientSettings()).resolves.toEqual(DEFAULT_CLIENT_SETTINGS);
99127 await api.persistence.setClientSettings(DEFAULT_CLIENT_SETTINGS);
apps/web/src/localApi.ts+5-7
......@@ -1,8 +1,9 @@
1import type { ContextMenuItem, LocalApi } from "@t3tools/contracts";
1import type { ConfirmDialogOptions, ContextMenuItem, LocalApi } from "@t3tools/contracts";
22
3import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState";
3import { requestConfirmDialog } from "./confirmDialog";
44import { showContextMenuFallback } from "./contextMenuFallback";
55import { readBrowserClientSettings, writeBrowserClientSettings } from "./clientPersistenceStorage";
6import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState";
67
78let cachedApi: LocalApi | undefined;
89
......@@ -13,11 +14,8 @@ function createBrowserLocalApi(): LocalApi {
1314 if (!window.desktopBridge) return null;
1415 return window.desktopBridge.pickFolder(options);
1516 },
16 confirm: async (message) => {
17 if (window.desktopBridge) {
18 return window.desktopBridge.confirm(message);
19 }
20 return window.confirm(message);
17 confirm: async (message, options?: ConfirmDialogOptions) => {
18 return requestConfirmDialog(message, options) ?? false;
2119 },
2220 },
2321 shell: {
apps/web/src/routes/__root.tsx+2
......@@ -14,6 +14,7 @@ import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL } from "../branding";
1414import { resolveServerBackedAppDisplayName } from "../branding.logic";
1515import { AppSidebarLayout } from "../components/AppSidebarLayout";
1616import { CommandPalette } from "../components/CommandPalette";
17import { ConfirmDialogHost } from "../components/ConfirmDialogHost";
1718import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog";
1819import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog";
1920import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog";
......@@ -135,6 +136,7 @@ function RootRouteView() {
135136 <RelayClientInstallDialog />
136137 <ConnectOnboardingDialog />
137138 <SshPasswordPromptDialog />
139 <ConfirmDialogHost />
138140 <SlowRpcRequestToastCoordinator />
139141 <HostedStaticEnvironmentBootstrap />
140142 {primaryEnvironmentAuthenticated ? <EventRouter /> : null}
packages/contracts/src/ipc.ts+7-2
......@@ -1042,7 +1042,6 @@ export interface DesktopBridge {
10421042 * web callers fall back to a plain file input.
10431043 */
10441044 pickThemeFiles?: () => Promise<readonly PickedThemeFile[] | null>;
1045 confirm: (message: string) => Promise<boolean>;
10461045 setTheme: (theme: DesktopTheme) => Promise<void>;
10471046 showContextMenu: <T extends string>(
10481047 items: readonly ContextMenuItem<T>[],
......@@ -1137,6 +1136,12 @@ export interface DesktopPreviewBridge {
11371136 onPointerEvent: (listener: (event: DesktopPreviewPointerEvent) => void) => () => void;
11381137}
11391138
1139export type ConfirmDialogVariant = "default" | "destructive";
1140
1141export interface ConfirmDialogOptions {
1142 readonly variant?: ConfirmDialogVariant;
1143}
1144
11401145/**
11411146 * APIs bound to the local app shell, not to any particular backend environment.
11421147 *
......@@ -1150,7 +1155,7 @@ export interface DesktopPreviewBridge {
11501155export interface LocalApi {
11511156 dialogs: {
11521157 pickFolder: (options?: PickFolderOptions) => Promise<string | null>;
1153 confirm: (message: string) => Promise<boolean>;
1158 confirm: (message: string, options?: ConfirmDialogOptions) => Promise<boolean>;
11541159 };
11551160 shell: {
11561161 openExternal: (url: string) => Promise<void>;