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", () => {...@@ -28,70 +28,6 @@ describe("ElectronDialog", () => {
28 showErrorBoxMock.mockReset();28 showErrorBoxMock.mockReset();
29 });29 });
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
95 it.effect("preserves folder picker request context and cause", () =>31 it.effect("preserves folder picker request context and cause", () =>
96 Effect.gen(function* () {32 Effect.gen(function* () {
97 const cause = new Error("folder picker failed");33 const cause = new Error("folder picker failed");
...@@ -117,31 +53,6 @@ describe("ElectronDialog", () => {...@@ -117,31 +53,6 @@ describe("ElectronDialog", () => {
117 }).pipe(Effect.provide(ElectronDialog.layer)),53 }).pipe(Effect.provide(ElectronDialog.layer)),
118 );54 );
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
145 it.effect("preserves message box request context and cause", () =>56 it.effect("preserves message box request context and cause", () =>
146 Effect.gen(function* () {57 Effect.gen(function* () {
147 const cause = new Error("message box failed");58 const cause = new Error("message box failed");
apps/desktop/src/electron/ElectronDialog.ts-58
...@@ -6,8 +6,6 @@ import * as Schema from "effect/Schema";...@@ -6,8 +6,6 @@ import * as Schema from "effect/Schema";
66
7import * as Electron from "electron";7import * as Electron from "electron";
88
9const CONFIRM_BUTTON_INDEX = 1;
10
11export class ElectronDialogPickFolderError extends Schema.TaggedErrorClass<ElectronDialogPickFolderError>()(9export class ElectronDialogPickFolderError extends Schema.TaggedErrorClass<ElectronDialogPickFolderError>()(
12 "ElectronDialogPickFolderError",10 "ElectronDialogPickFolderError",
13 {11 {
...@@ -38,20 +36,6 @@ export class ElectronDialogPickFilesError extends Schema.TaggedErrorClass<Electr...@@ -38,20 +36,6 @@ export class ElectronDialogPickFilesError extends Schema.TaggedErrorClass<Electr
38 }36 }
39}37}
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
55export class ElectronDialogShowMessageBoxError extends Schema.TaggedErrorClass<ElectronDialogShowMessageBoxError>()(39export class ElectronDialogShowMessageBoxError extends Schema.TaggedErrorClass<ElectronDialogShowMessageBoxError>()(
56 "ElectronDialogShowMessageBoxError",40 "ElectronDialogShowMessageBoxError",
57 {41 {
...@@ -85,7 +69,6 @@ export class ElectronDialogShowErrorBoxError extends Schema.TaggedErrorClass<Ele...@@ -85,7 +69,6 @@ export class ElectronDialogShowErrorBoxError extends Schema.TaggedErrorClass<Ele
85export const ElectronDialogError = Schema.Union([69export const ElectronDialogError = Schema.Union([
86 ElectronDialogPickFolderError,70 ElectronDialogPickFolderError,
87 ElectronDialogPickFilesError,71 ElectronDialogPickFilesError,
88 ElectronDialogConfirmError,
89 ElectronDialogShowMessageBoxError,72 ElectronDialogShowMessageBoxError,
90 ElectronDialogShowErrorBoxError,73 ElectronDialogShowErrorBoxError,
91]);74]);
...@@ -103,11 +86,6 @@ export interface ElectronDialogPickFilesInput {...@@ -103,11 +86,6 @@ export interface ElectronDialogPickFilesInput {
103 readonly filters: readonly Electron.FileFilter[];86 readonly filters: readonly Electron.FileFilter[];
104}87}
10588
106export interface ElectronDialogConfirmInput {
107 readonly owner: Option.Option<Electron.BrowserWindow>;
108 readonly message: string;
109}
110
111export class ElectronDialog extends Context.Service<89export class ElectronDialog extends Context.Service<
112 ElectronDialog,90 ElectronDialog,
113 {91 {
...@@ -117,9 +95,6 @@ export class ElectronDialog extends Context.Service<...@@ -117,9 +95,6 @@ export class ElectronDialog extends Context.Service<
117 readonly pickFiles: (95 readonly pickFiles: (
118 input: ElectronDialogPickFilesInput,96 input: ElectronDialogPickFilesInput,
119 ) => Effect.Effect<readonly string[], ElectronDialogPickFilesError>;97 ) => Effect.Effect<readonly string[], ElectronDialogPickFilesError>;
120 readonly confirm: (
121 input: ElectronDialogConfirmInput,
122 ) => Effect.Effect<boolean, ElectronDialogConfirmError>;
123 readonly showMessageBox: (98 readonly showMessageBox: (
124 options: Electron.MessageBoxOptions,99 options: Electron.MessageBoxOptions,
125 ) => Effect.Effect<Electron.MessageBoxReturnValue, ElectronDialogShowMessageBoxError>;100 ) => Effect.Effect<Electron.MessageBoxReturnValue, ElectronDialogShowMessageBoxError>;
...@@ -188,39 +163,6 @@ export const make = ElectronDialog.of({...@@ -188,39 +163,6 @@ export const make = ElectronDialog.of({
188 });163 });
189 return result.canceled ? [] : result.filePaths;164 return result.canceled ? [] : result.filePaths;
190 }),165 }),
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 }),
224 showMessageBox: (options) =>166 showMessageBox: (options) =>
225 Effect.tryPromise({167 Effect.tryPromise({
226 try: () => Electron.dialog.showMessageBox(options),168 try: () => Electron.dialog.showMessageBox(options),
apps/desktop/src/ipc/DesktopIpcHandlers.ts-2
...@@ -31,7 +31,6 @@ import {...@@ -31,7 +31,6 @@ import {
31 setUpdateChannel,31 setUpdateChannel,
32} from "./methods/updates.ts";32} from "./methods/updates.ts";
33import {33import {
34 confirm,
35 getAppBranding,34 getAppBranding,
36 getLocalEnvironmentBootstraps,35 getLocalEnvironmentBootstraps,
37 getLocalEnvironmentBearerToken,36 getLocalEnvironmentBearerToken,
...@@ -81,7 +80,6 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"...@@ -81,7 +80,6 @@ export const installDesktopIpcHandlers = Effect.fn("desktop.ipc.installHandlers"
8180
82 yield* ipc.handle(pickFolder);81 yield* ipc.handle(pickFolder);
83 yield* ipc.handle(pickThemeFiles);82 yield* ipc.handle(pickThemeFiles);
84 yield* ipc.handle(confirm);
85 yield* ipc.handle(setTheme);83 yield* ipc.handle(setTheme);
86 yield* ipc.handle(showContextMenu);84 yield* ipc.handle(showContextMenu);
87 yield* ipc.handle(openExternal);85 yield* ipc.handle(openExternal);
apps/desktop/src/ipc/channels.ts-1
...@@ -1,6 +1,5 @@...@@ -1,6 +1,5 @@
1export const PICK_FOLDER_CHANNEL = "desktop:pick-folder";1export const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
2export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files";2export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files";
3export const CONFIRM_CHANNEL = "desktop:confirm";
4export const SET_THEME_CHANNEL = "desktop:set-theme";3export const SET_THEME_CHANNEL = "desktop:set-theme";
5export const CONTEXT_MENU_CHANNEL = "desktop:context-menu";4export const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
6export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";5export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";
apps/desktop/src/ipc/methods/window.ts-13
...@@ -220,19 +220,6 @@ export const pickFolder = DesktopIpc.makeIpcMethod({...@@ -220,19 +220,6 @@ export const pickFolder = DesktopIpc.makeIpcMethod({
220 }),220 }),
221});221});
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
236export const setTheme = DesktopIpc.makeIpcMethod({223export const setTheme = DesktopIpc.makeIpcMethod({
237 channel: IpcChannels.SET_THEME_CHANNEL,224 channel: IpcChannels.SET_THEME_CHANNEL,
238 payload: DesktopThemeSchema,225 payload: DesktopThemeSchema,
apps/desktop/src/preload.ts-1
...@@ -98,7 +98,6 @@ contextBridge.exposeInMainWorld("desktopBridge", {...@@ -98,7 +98,6 @@ contextBridge.exposeInMainWorld("desktopBridge", {
98 setWslOnly: (enabled) => ipcRenderer.invoke(IpcChannels.SET_WSL_ONLY_CHANNEL, enabled),98 setWslOnly: (enabled) => ipcRenderer.invoke(IpcChannels.SET_WSL_ONLY_CHANNEL, enabled),
99 pickFolder: (options) => ipcRenderer.invoke(IpcChannels.PICK_FOLDER_CHANNEL, options),99 pickFolder: (options) => ipcRenderer.invoke(IpcChannels.PICK_FOLDER_CHANNEL, options),
100 pickThemeFiles: () => ipcRenderer.invoke(IpcChannels.PICK_THEME_FILES_CHANNEL, undefined),100 pickThemeFiles: () => ipcRenderer.invoke(IpcChannels.PICK_THEME_FILES_CHANNEL, undefined),
101 confirm: (message) => ipcRenderer.invoke(IpcChannels.CONFIRM_CHANNEL, message),
102 setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme),101 setTheme: (theme) => ipcRenderer.invoke(IpcChannels.SET_THEME_CHANNEL, theme),
103 showContextMenu: (items, position) =>102 showContextMenu: (items, position) =>
104 ipcRenderer.invoke(IpcChannels.CONTEXT_MENU_CHANNEL, {103 ipcRenderer.invoke(IpcChannels.CONTEXT_MENU_CHANNEL, {
apps/desktop/src/window/DesktopApplicationMenu.test.ts-1
...@@ -53,7 +53,6 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, {...@@ -53,7 +53,6 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, {
53const electronDialogLayer = Layer.succeed(ElectronDialog.ElectronDialog, {53const electronDialogLayer = Layer.succeed(ElectronDialog.ElectronDialog, {
54 pickFolder: () => Effect.succeed(Option.none()),54 pickFolder: () => Effect.succeed(Option.none()),
55 pickFiles: () => Effect.succeed([]),55 pickFiles: () => Effect.succeed([]),
56 confirm: () => Effect.succeed(false),
57 showMessageBox: () => Effect.succeed({ response: 0, checkboxChecked: false }),56 showMessageBox: () => Effect.succeed({ response: 0, checkboxChecked: false }),
58 showErrorBox: () => Effect.void,57 showErrorBox: () => Effect.void,
59} satisfies ElectronDialog.ElectronDialog["Service"]);58} satisfies ElectronDialog.ElectronDialog["Service"]);
apps/web/src/components/ChatView.tsx+1
...@@ -4745,6 +4745,7 @@ function ChatViewContent(props: ChatViewProps) {...@@ -4745,6 +4745,7 @@ function ChatViewContent(props: ChatViewProps) {
4745 "This will discard newer messages and turn diffs in this thread.",4745 "This will discard newer messages and turn diffs in this thread.",
4746 "This action cannot be undone.",4746 "This action cannot be undone.",
4747 ].join("\n"),4747 ].join("\n"),
4748 { variant: "destructive" },
4748 );4749 );
4749 if (!confirmed) {4750 if (!confirmed) {
4750 return;4751 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 {...@@ -104,7 +104,7 @@ import {
104} from "../keybindings";104} from "../keybindings";
105import { isModelPickerOpen } from "../modelPickerVisibility";105import { isModelPickerOpen } from "../modelPickerVisibility";
106import { useShortcutModifierState } from "../shortcutModifierState";106import { useShortcutModifierState } from "../shortcutModifierState";
107import { readLocalApi } from "../localApi";107import { ensureLocalApi, readLocalApi } from "../localApi";
108import { useComposerDraftStore } from "../composerDraftStore";108import { useComposerDraftStore } from "../composerDraftStore";
109import { useNewThreadHandler } from "../hooks/useHandleNewThread";109import { useNewThreadHandler } from "../hooks/useHandleNewThread";
110import { useDesktopUpdateState } from "../state/desktopUpdate";110import { useDesktopUpdateState } from "../state/desktopUpdate";
...@@ -1499,6 +1499,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1499,6 +1499,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1499 : []),1499 : []),
1500 "This removes only this project entry.",1500 "This removes only this project entry.",
1501 ].join("\n"),1501 ].join("\n"),
1502 { variant: "destructive" },
1502 );1503 );
1503 if (!confirmed) {1504 if (!confirmed) {
1504 return;1505 return;
...@@ -1547,7 +1548,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1547,7 +1548,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1547 ...(member.environmentLabel ? [`Environment: ${member.environmentLabel}`] : []),1548 ...(member.environmentLabel ? [`Environment: ${member.environmentLabel}`] : []),
1548 "This removes only this project entry.",1549 "This removes only this project entry.",
1549 ].join("\n");1550 ].join("\n");
1550 const confirmed = await api.dialogs.confirm(message);1551 const confirmed = await api.dialogs.confirm(message, { variant: "destructive" });
1551 if (!confirmed) {1552 if (!confirmed) {
1552 return;1553 return;
1553 }1554 }
...@@ -1830,6 +1831,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -1830,6 +1831,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
1830 `Delete ${count} thread${count === 1 ? "" : "s"}?`,1831 `Delete ${count} thread${count === 1 ? "" : "s"}?`,
1831 "This permanently clears conversation history for these threads.",1832 "This permanently clears conversation history for these threads.",
1832 ].join("\n"),1833 ].join("\n"),
1834 { variant: "destructive" },
1833 );1835 );
1834 if (!confirmed) return;1836 if (!confirmed) return;
1835 }1837 }
...@@ -2180,6 +2182,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec...@@ -2180,6 +2182,7 @@ const SidebarProjectItem = memo(function SidebarProjectItem(props: SidebarProjec
2180 `Delete thread "${thread.title}"?`,2182 `Delete thread "${thread.title}"?`,
2181 "This permanently clears conversation history for this thread.",2183 "This permanently clears conversation history for this thread.",
2182 ].join("\n"),2184 ].join("\n"),
2185 { variant: "destructive" },
2183 );2186 );
2184 if (!confirmed) {2187 if (!confirmed) {
2185 return;2188 return;
...@@ -2730,6 +2733,7 @@ interface SidebarProjectsContentProps {...@@ -2730,6 +2733,7 @@ interface SidebarProjectsContentProps {
2730 arm64IntelBuildWarningDescription: string | null;2733 arm64IntelBuildWarningDescription: string | null;
2731 desktopUpdateButtonAction: "download" | "install" | "none";2734 desktopUpdateButtonAction: "download" | "install" | "none";
2732 desktopUpdateButtonDisabled: boolean;2735 desktopUpdateButtonDisabled: boolean;
2736 desktopUpdateActionPending: boolean;
2733 handleDesktopUpdateButtonClick: () => void;2737 handleDesktopUpdateButtonClick: () => void;
2734 projectSortOrder: SidebarProjectSortOrder;2738 projectSortOrder: SidebarProjectSortOrder;
2735 threadSortOrder: SidebarThreadSortOrder;2739 threadSortOrder: SidebarThreadSortOrder;
...@@ -2770,6 +2774,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(...@@ -2770,6 +2774,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
2770 arm64IntelBuildWarningDescription,2774 arm64IntelBuildWarningDescription,
2771 desktopUpdateButtonAction,2775 desktopUpdateButtonAction,
2772 desktopUpdateButtonDisabled,2776 desktopUpdateButtonDisabled,
2777 desktopUpdateActionPending,
2773 handleDesktopUpdateButtonClick,2778 handleDesktopUpdateButtonClick,
2774 projectSortOrder,2779 projectSortOrder,
2775 threadSortOrder,2780 threadSortOrder,
...@@ -2862,7 +2867,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(...@@ -2862,7 +2867,7 @@ const SidebarProjectsContent = memo(function SidebarProjectsContent(
2862 <Button2867 <Button
2863 size="xs"2868 size="xs"
2864 variant="outline"2869 variant="outline"
2865 disabled={desktopUpdateButtonDisabled}2870 disabled={desktopUpdateButtonDisabled || desktopUpdateActionPending}
2866 onClick={handleDesktopUpdateButtonClick}2871 onClick={handleDesktopUpdateButtonClick}
2867 >2872 >
2868 {desktopUpdateButtonAction === "download"2873 {desktopUpdateButtonAction === "download"
...@@ -3032,6 +3037,7 @@ export default function LegacySidebar() {...@@ -3032,6 +3037,7 @@ export default function LegacySidebar() {
3032 const suppressProjectClickAfterDragRef = useRef(false);3037 const suppressProjectClickAfterDragRef = useRef(false);
3033 const suppressProjectClickForContextMenuRef = useRef(false);3038 const suppressProjectClickForContextMenuRef = useRef(false);
3034 const desktopUpdateState = useDesktopUpdateState();3039 const desktopUpdateState = useDesktopUpdateState();
3040 const [desktopUpdateActionPending, setDesktopUpdateActionPending] = useState(false);
3035 const clearSelection = useThreadSelectionStore((s) => s.clearSelection);3041 const clearSelection = useThreadSelectionStore((s) => s.clearSelection);
3036 const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor);3042 const setSelectionAnchor = useThreadSelectionStore((s) => s.setAnchor);
3037 const platform = navigator.platform;3043 const platform = navigator.platform;
...@@ -3498,10 +3504,18 @@ export default function LegacySidebar() {...@@ -3498,10 +3504,18 @@ export default function LegacySidebar() {
3498 "commandPalette.toggle",3504 "commandPalette.toggle",
3499 newThreadShortcutLabelOptions,3505 newThreadShortcutLabelOptions,
3500 );3506 );
3501 const handleDesktopUpdateButtonClick = useCallback(() => {3507 const handleDesktopUpdateButtonClick = useCallback(async () => {
3502 const bridge = window.desktopBridge;3508 const bridge = window.desktopBridge;
3503 if (!bridge || !desktopUpdateState) return;3509 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
3506 if (desktopUpdateButtonAction === "download") {3520 if (desktopUpdateButtonAction === "download") {
3507 void bridge3521 void bridge
...@@ -3529,15 +3543,32 @@ export default function LegacySidebar() {...@@ -3529,15 +3543,32 @@ export default function LegacySidebar() {
3529 description: error instanceof Error ? error.message : "An unexpected error occurred.",3543 description: error instanceof Error ? error.message : "An unexpected error occurred.",
3530 }),3544 }),
3531 );3545 );
3532 });3546 })
3547 .finally(() => setDesktopUpdateActionPending(false));
3533 return;3548 return;
3534 }3549 }
35353550
3536 if (desktopUpdateButtonAction === "install") {3551 if (desktopUpdateButtonAction === "install") {
3537 const confirmed = window.confirm(3552 let confirmed = false;
3538 getDesktopUpdateInstallConfirmationMessage(desktopUpdateState, navigator.platform),3553 try {
3539 );3554 confirmed = await ensureLocalApi().dialogs.confirm(
3540 if (!confirmed) return;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 }
3541 void bridge3572 void bridge
3542 .installUpdate()3573 .installUpdate()
3543 .then((result) => {3574 .then((result) => {
...@@ -3560,9 +3591,15 @@ export default function LegacySidebar() {...@@ -3560,9 +3591,15 @@ export default function LegacySidebar() {
3560 description: error instanceof Error ? error.message : "An unexpected error occurred.",3591 description: error instanceof Error ? error.message : "An unexpected error occurred.",
3561 }),3592 }),
3562 );3593 );
3563 });3594 })
3595 .finally(() => setDesktopUpdateActionPending(false));
3564 }3596 }
3565 }, [desktopUpdateButtonAction, desktopUpdateButtonDisabled, desktopUpdateState]);3597 }, [
3598 desktopUpdateActionPending,
3599 desktopUpdateButtonAction,
3600 desktopUpdateButtonDisabled,
3601 desktopUpdateState,
3602 ]);
35663603
3567 const expandThreadListForProject = useCallback((projectKey: string) => {3604 const expandThreadListForProject = useCallback((projectKey: string) => {
3568 setExpandedThreadListsByProject((current) => {3605 setExpandedThreadListsByProject((current) => {
...@@ -3594,6 +3631,7 @@ export default function LegacySidebar() {...@@ -3594,6 +3631,7 @@ export default function LegacySidebar() {
3594 arm64IntelBuildWarningDescription={arm64IntelBuildWarningDescription}3631 arm64IntelBuildWarningDescription={arm64IntelBuildWarningDescription}
3595 desktopUpdateButtonAction={desktopUpdateButtonAction}3632 desktopUpdateButtonAction={desktopUpdateButtonAction}
3596 desktopUpdateButtonDisabled={desktopUpdateButtonDisabled}3633 desktopUpdateButtonDisabled={desktopUpdateButtonDisabled}
3634 desktopUpdateActionPending={desktopUpdateActionPending}
3597 handleDesktopUpdateButtonClick={handleDesktopUpdateButtonClick}3635 handleDesktopUpdateButtonClick={handleDesktopUpdateButtonClick}
3598 projectSortOrder={sidebarProjectSortOrder}3636 projectSortOrder={sidebarProjectSortOrder}
3599 threadSortOrder={sidebarThreadSortOrder}3637 threadSortOrder={sidebarThreadSortOrder}
apps/web/src/components/Sidebar.tsx+2
...@@ -2812,6 +2812,7 @@ export default function Sidebar() {...@@ -2812,6 +2812,7 @@ export default function Sidebar() {
2812 `Delete ${count} thread${count === 1 ? "" : "s"}?`,2812 `Delete ${count} thread${count === 1 ? "" : "s"}?`,
2813 "This permanently clears conversation history for these threads.",2813 "This permanently clears conversation history for these threads.",
2814 ].join("\n"),2814 ].join("\n"),
2815 { variant: "destructive" },
2815 ),2816 ),
2816 );2817 );
2817 if (confirmed._tag === "Failure" || !confirmed.value) return;2818 if (confirmed._tag === "Failure" || !confirmed.value) return;
...@@ -3014,6 +3015,7 @@ export default function Sidebar() {...@@ -3014,6 +3015,7 @@ export default function Sidebar() {
3014 `Delete thread "${thread.title}"?`,3015 `Delete thread "${thread.title}"?`,
3015 "This permanently clears conversation history for this thread.",3016 "This permanently clears conversation history for this thread.",
3016 ].join("\n"),3017 ].join("\n"),
3018 { variant: "destructive" },
3017 ),3019 ),
3018 );3020 );
3019 if (confirmed._tag === "Failure" || !confirmed.value) return;3021 if (confirmed._tag === "Failure" || !confirmed.value) return;
apps/web/src/components/settings/DiagnosticsSettings.tsx+46-15
...@@ -12,7 +12,7 @@ import {...@@ -12,7 +12,7 @@ import {
12 isAtomCommandInterrupted,12 isAtomCommandInterrupted,
13 squashAtomCommandFailure,13 squashAtomCommandFailure,
14} from "@t3tools/client-runtime/state/runtime";14} from "@t3tools/client-runtime/state/runtime";
15import { useCallback, useMemo, useState, type ReactNode } from "react";15import { useCallback, useMemo, useRef, useState, type ReactNode } from "react";
16import type {16import type {
17 ServerProcessDiagnosticsEntry,17 ServerProcessDiagnosticsEntry,
18 ServerProcessResourceHistorySummary,18 ServerProcessResourceHistorySummary,
...@@ -22,6 +22,7 @@ import * as DateTime from "effect/DateTime";...@@ -22,6 +22,7 @@ import * as DateTime from "effect/DateTime";
22import * as Option from "effect/Option";22import * as Option from "effect/Option";
2323
24import { cn } from "../../lib/utils";24import { cn } from "../../lib/utils";
25import { ensureLocalApi } from "../../localApi";
25import { resolveAndPersistPreferredEditor } from "../../editorPreferences";26import { resolveAndPersistPreferredEditor } from "../../editorPreferences";
26import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat";27import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat";
27import { useEnvironmentQuery } from "../../state/query";28import { useEnvironmentQuery } from "../../state/query";
...@@ -857,6 +858,11 @@ export function DiagnosticsSettingsPanel() {...@@ -857,6 +858,11 @@ export function DiagnosticsSettingsPanel() {
857 const [isOpeningLogsDirectory, setIsOpeningLogsDirectory] = useState(false);858 const [isOpeningLogsDirectory, setIsOpeningLogsDirectory] = useState(false);
858 const [openLogsDirectoryError, setOpenLogsDirectoryError] = useState<string | null>(null);859 const [openLogsDirectoryError, setOpenLogsDirectoryError] = useState<string | null>(null);
859 const [signalingPid, setSignalingPid] = useState<number | null>(null);860 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
861 const openLogsDirectory = useCallback(() => {867 const openLogsDirectory = useCallback(() => {
862 const logsDirectoryPath = observability?.logsDirectoryPath ?? null;868 const logsDirectoryPath = observability?.logsDirectoryPath ?? null;
...@@ -895,28 +901,51 @@ export function DiagnosticsSettingsPanel() {...@@ -895,28 +901,51 @@ export function DiagnosticsSettingsPanel() {
895 const isInitialLoading = isPending && data === null;901 const isInitialLoading = isPending && data === null;
896 const isProcessInitialLoading = isProcessPending && processData === null;902 const isProcessInitialLoading = isProcessPending && processData === null;
897 const signalProcess = useCallback(903 const signalProcess = useCallback(
898 (pid: number, signal: ServerProcessSignal) => {904 async (pid: number, signal: ServerProcessSignal) => {
899 if (905 if (signalingPidRef.current !== null) return;
900 signal === "SIGKILL" &&906 signalingPidRef.current = pid;
901 !window.confirm(`Send SIGKILL to process ${pid}? This cannot be handled by the process.`)907 setSignalingPid(pid);
902 ) {908 const clearSignaling = () => {
903 return;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 }
904 }932 }
905 if (environmentId === null) {933 const currentEnvironmentId = environmentIdRef.current;
934 if (currentEnvironmentId === null) {
935 clearSignaling();
906 return;936 return;
907 }937 }
908 const process = processData?.processes.find((entry) => entry.pid === pid);938 const process = processDataRef.current?.processes.find((entry) => entry.pid === pid);
909 if (process === undefined) {939 if (process === undefined) {
940 clearSignaling();
910 return;941 return;
911 }942 }
912943
913 setSignalingPid(pid);944 try {
914 void (async () => {
915 const result = await signalServerProcess({945 const result = await signalServerProcess({
916 environmentId,946 environmentId: currentEnvironmentId,
917 input: { pid, startTimeMs: process.startTimeMs, signal },947 input: { pid, startTimeMs: process.startTimeMs, signal },
918 });948 });
919 setSignalingPid(null);
920 if (result._tag === "Failure") {949 if (result._tag === "Failure") {
921 if (!isAtomCommandInterrupted(result)) {950 if (!isAtomCommandInterrupted(result)) {
922 const error = squashAtomCommandFailure(result);951 const error = squashAtomCommandFailure(result);
...@@ -949,9 +978,11 @@ export function DiagnosticsSettingsPanel() {...@@ -949,9 +978,11 @@ export function DiagnosticsSettingsPanel() {
949 return;978 return;
950 }979 }
951 refreshProcesses();980 refreshProcesses();
952 })();981 } finally {
982 clearSignaling();
983 }
953 },984 },
954 [environmentId, processData?.processes, refreshProcesses, signalServerProcess],985 [refreshProcesses, signalServerProcess],
955 );986 );
956987
957 const processDiagnosticsError = processData ? Option.getOrNull(processData.error) : null;988 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 }) {...@@ -700,6 +700,7 @@ function ProjectDetail({ group }: { group: SidebarProjectSnapshot }) {
700 : "Other entries in this grouped project are unaffected.",700 : "Other entries in this grouped project are unaffected.",
701 "This action cannot be undone.",701 "This action cannot be undone.",
702 ].join("\n"),702 ].join("\n"),
703 { variant: "destructive" },
703 ),704 ),
704 );705 );
705 if (confirmed._tag === "Failure" || !confirmed.value) return;706 if (confirmed._tag === "Failure" || !confirmed.value) return;
apps/web/src/components/settings/ResourceTelemetryDiagnostics.tsx+43-19
...@@ -27,7 +27,7 @@ import type {...@@ -27,7 +27,7 @@ import type {
27} from "@t3tools/contracts";27} from "@t3tools/contracts";
28import * as DateTime from "effect/DateTime";28import * as DateTime from "effect/DateTime";
29import * as Option from "effect/Option";29import * as Option from "effect/Option";
30import { useCallback, useMemo, useState, type ReactNode } from "react";30import { useCallback, useMemo, useRef, useState, type ReactNode } from "react";
31import {31import {
32 isAtomCommandInterrupted,32 isAtomCommandInterrupted,
33 squashAtomCommandFailure,33 squashAtomCommandFailure,
...@@ -38,6 +38,7 @@ import {...@@ -38,6 +38,7 @@ import {
38 useResourceTelemetryHistory,38 useResourceTelemetryHistory,
39} from "../../lib/resourceTelemetryState";39} from "../../lib/resourceTelemetryState";
40import { cn } from "../../lib/utils";40import { cn } from "../../lib/utils";
41import { ensureLocalApi } from "../../localApi";
41import { usePrimaryEnvironment } from "../../state/environments";42import { usePrimaryEnvironment } from "../../state/environments";
42import { serverEnvironment } from "../../state/server";43import { serverEnvironment } from "../../state/server";
43import { useAtomCommand } from "../../state/use-atom-command";44import { useAtomCommand } from "../../state/use-atom-command";
...@@ -845,26 +846,54 @@ export function ResourceTelemetryDiagnostics() {...@@ -845,26 +846,54 @@ export function ResourceTelemetryDiagnostics() {
845 reportFailure: false,846 reportFailure: false,
846 });847 });
847 const [signalingKeys, setSignalingKeys] = useState<ReadonlySet<string>>(() => new Set());848 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;
848 const [isRetrying, setIsRetrying] = useState(false);853 const [isRetrying, setIsRetrying] = useState(false);
849 const snapshot = telemetry.data;854 const snapshot = telemetry.data;
850 const allT3 = snapshot?.groups.allT3;855 const allT3 = snapshot?.groups.allT3;
851856
852 const signalProcess = useCallback(857 const signalProcess = useCallback(
853 (process: ResourceTelemetryProcess, signal: ServerProcessSignal) => {858 async (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 }
862 const identityKey = processIdentityKey(process);859 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;
864 if (environmentId === undefined) {893 if (environmentId === undefined) {
894 clearSignaling();
865 return;895 return;
866 }896 }
867 setSignalingKeys((current) => new Set(current).add(identityKey));
868 void signalServerProcess({897 void signalServerProcess({
869 environmentId,898 environmentId,
870 input: {899 input: {
...@@ -896,15 +925,10 @@ export function ResourceTelemetryDiagnostics() {...@@ -896,15 +925,10 @@ export function ResourceTelemetryDiagnostics() {
896 });925 });
897 })926 })
898 .finally(() => {927 .finally(() => {
899 setSignalingKeys((current) => {928 clearSignaling();
900 if (!current.has(identityKey)) return current;
901 const next = new Set(current);
902 next.delete(identityKey);
903 return next;
904 });
905 });929 });
906 },930 },
907 [primaryEnvironment?.environmentId, signalServerProcess],931 [signalServerProcess],
908 );932 );
909933
910 const retryCollector = useCallback(() => {934 const retryCollector = useCallback(() => {
apps/web/src/components/settings/SettingsPanels.tsx+37-14
...@@ -220,6 +220,7 @@ function AboutVersionTitle() {...@@ -220,6 +220,7 @@ function AboutVersionTitle() {
220function AboutVersionSection() {220function AboutVersionSection() {
221 const updateState = useDesktopUpdateState();221 const updateState = useDesktopUpdateState();
222 const [isChangingUpdateChannel, setIsChangingUpdateChannel] = useState(false);222 const [isChangingUpdateChannel, setIsChangingUpdateChannel] = useState(false);
223 const [isUpdateActionPending, setIsUpdateActionPending] = useState(false);
223224
224 const hasDesktopBridge = typeof window !== "undefined" && Boolean(window.desktopBridge);225 const hasDesktopBridge = typeof window !== "undefined" && Boolean(window.desktopBridge);
225 const selectedUpdateChannel = updateState?.channel ?? "latest";226 const selectedUpdateChannel = updateState?.channel ?? "latest";
...@@ -255,7 +256,7 @@ function AboutVersionSection() {...@@ -255,7 +256,7 @@ function AboutVersionSection() {
255 [selectedUpdateChannel],256 [selectedUpdateChannel],
256 );257 );
257258
258 const handleButtonClick = useCallback(() => {259 const handleButtonClick = useCallback(async () => {
259 const bridge = window.desktopBridge;260 const bridge = window.desktopBridge;
260 if (!bridge) return;261 if (!bridge) return;
261262
...@@ -275,22 +276,43 @@ function AboutVersionSection() {...@@ -275,22 +276,43 @@ function AboutVersionSection() {
275 }276 }
276277
277 if (action === "install") {278 if (action === "install") {
278 const confirmed = window.confirm(279 if (isUpdateActionPending) return;
279 getDesktopUpdateInstallConfirmationMessage(280 setIsUpdateActionPending(true);
280 updateState ?? { availableVersion: null, downloadedVersion: null },281 let confirmed = false;
281 navigator.platform,282 try {
282 ),283 confirmed = await ensureLocalApi().dialogs.confirm(
283 );284 getDesktopUpdateInstallConfirmationMessage(
284 if (!confirmed) return;285 updateState ?? { availableVersion: null, downloadedVersion: null },
285 void bridge.installUpdate().catch((error: unknown) => {286 navigator.platform,
287 ),
288 );
289 } catch (error) {
290 setIsUpdateActionPending(false);
286 toastManager.add(291 toastManager.add(
287 stackedThreadToast({292 stackedThreadToast({
288 type: "error",293 type: "error",
289 title: "Could not install update",294 title: "Could not confirm update",
290 description: error instanceof Error ? error.message : "Install failed.",295 description: error instanceof Error ? error.message : "Update confirmation failed.",
291 }),296 }),
292 );297 );
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));
294 return;316 return;
295 }317 }
296318
...@@ -318,7 +340,7 @@ function AboutVersionSection() {...@@ -318,7 +340,7 @@ function AboutVersionSection() {
318 }),340 }),
319 );341 );
320 });342 });
321 }, [updateState]);343 }, [isUpdateActionPending, updateState]);
322344
323 const action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none";345 const action = updateState ? resolveDesktopUpdateButtonAction(updateState) : "none";
324 const buttonTooltip = updateState ? getDesktopUpdateButtonTooltip(updateState) : null;346 const buttonTooltip = updateState ? getDesktopUpdateButtonTooltip(updateState) : null;
...@@ -352,7 +374,7 @@ function AboutVersionSection() {...@@ -352,7 +374,7 @@ function AboutVersionSection() {
352 <Button374 <Button
353 size="xs"375 size="xs"
354 variant={action === "install" ? "default" : "outline"}376 variant={action === "install" ? "default" : "outline"}
355 disabled={buttonDisabled}377 disabled={buttonDisabled || isUpdateActionPending}
356 onClick={handleButtonClick}378 onClick={handleButtonClick}
357 >379 >
358 {buttonLabel}380 {buttonLabel}
...@@ -550,6 +572,7 @@ export function useSettingsRestore(onRestored?: () => void) {...@@ -550,6 +572,7 @@ export function useSettingsRestore(onRestored?: () => void) {
550 ["Restore default settings?", `This will reset: ${changedSettingLabels.join(", ")}.`].join(572 ["Restore default settings?", `This will reset: ${changedSettingLabels.join(", ")}.`].join(
551 "\n",573 "\n",
552 ),574 ),
575 { variant: "destructive" },
553 );576 );
554 if (!confirmed) return;577 if (!confirmed) return;
555578
apps/web/src/components/sidebar/SidebarUpdatePill.tsx+33-11
...@@ -1,6 +1,7 @@...@@ -1,6 +1,7 @@
1import { DownloadIcon, RotateCwIcon, TriangleAlertIcon, XIcon } from "lucide-react";1import { DownloadIcon, RotateCwIcon, TriangleAlertIcon, XIcon } from "lucide-react";
2import { useCallback, useState } from "react";2import { useCallback, useState } from "react";
3import { isElectron } from "../../env";3import { isElectron } from "../../env";
4import { ensureLocalApi } from "../../localApi";
4import { useDesktopUpdateState } from "../../state/desktopUpdate";5import { useDesktopUpdateState } from "../../state/desktopUpdate";
5import { stackedThreadToast, toastManager } from "../ui/toast";6import { stackedThreadToast, toastManager } from "../ui/toast";
6import {7import {
...@@ -70,6 +71,7 @@ function SidebarUpdateReleaseNotesTooltip({...@@ -70,6 +71,7 @@ function SidebarUpdateReleaseNotesTooltip({
70export function SidebarUpdatePill() {71export function SidebarUpdatePill() {
71 const state = useDesktopUpdateState();72 const state = useDesktopUpdateState();
72 const [dismissed, setDismissed] = useState(false);73 const [dismissed, setDismissed] = useState(false);
74 const [isActionPending, setIsActionPending] = useState(false);
7375
74 const visible = isElectron && shouldShowDesktopUpdateButton(state) && !dismissed;76 const visible = isElectron && shouldShowDesktopUpdateButton(state) && !dismissed;
75 const tooltip = state ? getDesktopUpdateButtonTooltip(state) : "Update available";77 const tooltip = state ? getDesktopUpdateButtonTooltip(state) : "Update available";
...@@ -80,10 +82,12 @@ export function SidebarUpdatePill() {...@@ -80,10 +82,12 @@ export function SidebarUpdatePill() {
80 const arm64Description =82 const arm64Description =
81 state && showArm64Warning ? getArm64IntelBuildWarningDescription(state) : null;83 state && showArm64Warning ? getArm64IntelBuildWarningDescription(state) : null;
8284
83 const handleAction = useCallback(() => {85 const handleAction = useCallback(async () => {
84 const bridge = window.desktopBridge;86 const bridge = window.desktopBridge;
85 if (!bridge || !state) return;87 if (!bridge || !state) return;
86 if (disabled || action === "none") return;88 if (disabled || action === "none" || isActionPending) return;
89
90 setIsActionPending(true);
8791
88 if (action === "download") {92 if (action === "download") {
89 void bridge93 void bridge
...@@ -111,15 +115,32 @@ export function SidebarUpdatePill() {...@@ -111,15 +115,32 @@ export function SidebarUpdatePill() {
111 description: error instanceof Error ? error.message : "An unexpected error occurred.",115 description: error instanceof Error ? error.message : "An unexpected error occurred.",
112 }),116 }),
113 );117 );
114 });118 })
119 .finally(() => setIsActionPending(false));
115 return;120 return;
116 }121 }
117122
118 if (action === "install") {123 if (action === "install") {
119 const confirmed = window.confirm(124 let confirmed = false;
120 getDesktopUpdateInstallConfirmationMessage(state, navigator.platform),125 try {
121 );126 confirmed = await ensureLocalApi().dialogs.confirm(
122 if (!confirmed) return;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 }
123 void bridge144 void bridge
124 .installUpdate()145 .installUpdate()
125 .then((result) => {146 .then((result) => {
...@@ -142,9 +163,10 @@ export function SidebarUpdatePill() {...@@ -142,9 +163,10 @@ export function SidebarUpdatePill() {
142 description: error instanceof Error ? error.message : "An unexpected error occurred.",163 description: error instanceof Error ? error.message : "An unexpected error occurred.",
143 }),164 }),
144 );165 );
145 });166 })
167 .finally(() => setIsActionPending(false));
146 }168 }
147 }, [action, disabled, state]);169 }, [action, disabled, isActionPending, state]);
148170
149 if (!visible && !showArm64Warning) return null;171 if (!visible && !showArm64Warning) return null;
150172
...@@ -170,8 +192,8 @@ export function SidebarUpdatePill() {...@@ -170,8 +192,8 @@ export function SidebarUpdatePill() {
170 <button192 <button
171 type="button"193 type="button"
172 aria-label={tooltip}194 aria-label={tooltip}
173 aria-disabled={disabled || undefined}195 aria-disabled={disabled || isActionPending || undefined}
174 disabled={disabled}196 disabled={disabled || isActionPending}
175 className="update-main relative flex h-full flex-1 items-center gap-2 px-2 enabled:cursor-pointer"197 className="update-main relative flex h-full flex-1 items-center gap-2 px-2 enabled:cursor-pointer"
176 onClick={handleAction}198 onClick={handleAction}
177 >199 >
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: {...@@ -250,6 +250,7 @@ export function useThreadActionMenu(input: {
250 `Delete thread "${thread.title}"?`,250 `Delete thread "${thread.title}"?`,
251 "This permanently clears conversation history for this thread.",251 "This permanently clears conversation history for this thread.",
252 ].join("\n"),252 ].join("\n"),
253 { variant: "destructive" },
253 ),254 ),
254 );255 );
255 if (confirmed._tag === "Failure" || !confirmed.value) return;256 if (confirmed._tag === "Failure" || !confirmed.value) return;
apps/web/src/hooks/useThreadActions.ts+2
...@@ -324,6 +324,7 @@ export function useThreadActions() {...@@ -324,6 +324,7 @@ export function useThreadActions() {
324 "",324 "",
325 "Delete the worktree too?",325 "Delete the worktree too?",
326 ].join("\n"),326 ].join("\n"),
327 { variant: "destructive" },
327 ),328 ),
328 );329 );
329 if (confirmationResult._tag === "Failure") {330 if (confirmationResult._tag === "Failure") {
...@@ -678,6 +679,7 @@ export function useThreadActions() {...@@ -678,6 +679,7 @@ export function useThreadActions() {
678 `Delete thread "${title}"?`,679 `Delete thread "${title}"?`,
679 "This permanently clears conversation history for this thread.",680 "This permanently clears conversation history for this thread.",
680 ].join("\n"),681 ].join("\n"),
682 { variant: "destructive" },
681 ),683 ),
682 );684 );
683 if (confirmationResult._tag === "Failure") {685 if (confirmationResult._tag === "Failure") {
apps/web/src/localApi.test.ts+28
...@@ -1,5 +1,6 @@...@@ -1,5 +1,6 @@
1import {1import {
2 DEFAULT_CLIENT_SETTINGS,2 DEFAULT_CLIENT_SETTINGS,
3 type ConfirmDialogOptions,
3 type ContextMenuItem,4 type ContextMenuItem,
4 type DesktopBridge,5 type DesktopBridge,
5} from "@t3tools/contracts";6} from "@t3tools/contracts";
...@@ -13,10 +14,17 @@ const showContextMenuFallbackMock =...@@ -13,10 +14,17 @@ const showContextMenuFallbackMock =
13 ) => Promise<T | null>14 ) => Promise<T | null>
14 >();15 >();
1516
17const requestConfirmDialogMock =
18 vi.fn<(message: string, options?: ConfirmDialogOptions) => Promise<boolean> | undefined>();
19
16vi.mock("./contextMenuFallback", () => ({20vi.mock("./contextMenuFallback", () => ({
17 showContextMenuFallback: showContextMenuFallbackMock,21 showContextMenuFallback: showContextMenuFallbackMock,
18}));22}));
1923
24vi.mock("./confirmDialog", () => ({
25 requestConfirmDialog: requestConfirmDialogMock,
26}));
27
20function createLocalStorageStub(): Storage {28function createLocalStorageStub(): Storage {
21 const values = new Map<string, string>();29 const values = new Map<string, string>();
22 return {30 return {
...@@ -77,6 +85,24 @@ describe("LocalApi", () => {...@@ -77,6 +85,24 @@ describe("LocalApi", () => {
77 expect(showContextMenuFallbackMock).toHaveBeenCalledWith(items, { x: 4, y: 5 });85 expect(showContextMenuFallbackMock).toHaveBeenCalledWith(items, { x: 4, y: 5 });
78 });86 });
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
80 it("delegates host capabilities and persistence to the desktop bridge", async () => {106 it("delegates host capabilities and persistence to the desktop bridge", async () => {
81 const showContextMenu = vi.fn().mockResolvedValue("delete");107 const showContextMenu = vi.fn().mockResolvedValue("delete");
82 const pickFolder = vi.fn().mockResolvedValue("/tmp/project");108 const pickFolder = vi.fn().mockResolvedValue("/tmp/project");
...@@ -94,6 +120,8 @@ describe("LocalApi", () => {...@@ -94,6 +120,8 @@ describe("LocalApi", () => {
94 const items = [{ id: "delete", label: "Delete" }] as const;120 const items = [{ id: "delete", label: "Delete" }] as const;
95121
96 await expect(api.contextMenu.show(items)).resolves.toBe("delete");122 await expect(api.contextMenu.show(items)).resolves.toBe("delete");
123 requestConfirmDialogMock.mockReturnValue(undefined);
124 await expect(api.dialogs.confirm("Install update?")).resolves.toBe(false);
97 await expect(api.dialogs.pickFolder({ initialPath: "/tmp" })).resolves.toBe("/tmp/project");125 await expect(api.dialogs.pickFolder({ initialPath: "/tmp" })).resolves.toBe("/tmp/project");
98 await expect(api.persistence.getClientSettings()).resolves.toEqual(DEFAULT_CLIENT_SETTINGS);126 await expect(api.persistence.getClientSettings()).resolves.toEqual(DEFAULT_CLIENT_SETTINGS);
99 await api.persistence.setClientSettings(DEFAULT_CLIENT_SETTINGS);127 await api.persistence.setClientSettings(DEFAULT_CLIENT_SETTINGS);
apps/web/src/localApi.ts+5-7
...@@ -1,8 +1,9 @@...@@ -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";
4import { showContextMenuFallback } from "./contextMenuFallback";4import { showContextMenuFallback } from "./contextMenuFallback";
5import { readBrowserClientSettings, writeBrowserClientSettings } from "./clientPersistenceStorage";5import { readBrowserClientSettings, writeBrowserClientSettings } from "./clientPersistenceStorage";
6import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState";
67
7let cachedApi: LocalApi | undefined;8let cachedApi: LocalApi | undefined;
89
...@@ -13,11 +14,8 @@ function createBrowserLocalApi(): LocalApi {...@@ -13,11 +14,8 @@ function createBrowserLocalApi(): LocalApi {
13 if (!window.desktopBridge) return null;14 if (!window.desktopBridge) return null;
14 return window.desktopBridge.pickFolder(options);15 return window.desktopBridge.pickFolder(options);
15 },16 },
16 confirm: async (message) => {17 confirm: async (message, options?: ConfirmDialogOptions) => {
17 if (window.desktopBridge) {18 return requestConfirmDialog(message, options) ?? false;
18 return window.desktopBridge.confirm(message);
19 }
20 return window.confirm(message);
21 },19 },
22 },20 },
23 shell: {21 shell: {
apps/web/src/routes/__root.tsx+2
...@@ -14,6 +14,7 @@ import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL } from "../branding";...@@ -14,6 +14,7 @@ import { APP_BASE_NAME, APP_DISPLAY_NAME, APP_STAGE_LABEL } from "../branding";
14import { resolveServerBackedAppDisplayName } from "../branding.logic";14import { resolveServerBackedAppDisplayName } from "../branding.logic";
15import { AppSidebarLayout } from "../components/AppSidebarLayout";15import { AppSidebarLayout } from "../components/AppSidebarLayout";
16import { CommandPalette } from "../components/CommandPalette";16import { CommandPalette } from "../components/CommandPalette";
17import { ConfirmDialogHost } from "../components/ConfirmDialogHost";
17import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog";18import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog";
18import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog";19import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog";
19import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog";20import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog";
...@@ -135,6 +136,7 @@ function RootRouteView() {...@@ -135,6 +136,7 @@ function RootRouteView() {
135 <RelayClientInstallDialog />136 <RelayClientInstallDialog />
136 <ConnectOnboardingDialog />137 <ConnectOnboardingDialog />
137 <SshPasswordPromptDialog />138 <SshPasswordPromptDialog />
139 <ConfirmDialogHost />
138 <SlowRpcRequestToastCoordinator />140 <SlowRpcRequestToastCoordinator />
139 <HostedStaticEnvironmentBootstrap />141 <HostedStaticEnvironmentBootstrap />
140 {primaryEnvironmentAuthenticated ? <EventRouter /> : null}142 {primaryEnvironmentAuthenticated ? <EventRouter /> : null}
packages/contracts/src/ipc.ts+7-2
...@@ -1042,7 +1042,6 @@ export interface DesktopBridge {...@@ -1042,7 +1042,6 @@ export interface DesktopBridge {
1042 * web callers fall back to a plain file input.1042 * web callers fall back to a plain file input.
1043 */1043 */
1044 pickThemeFiles?: () => Promise<readonly PickedThemeFile[] | null>;1044 pickThemeFiles?: () => Promise<readonly PickedThemeFile[] | null>;
1045 confirm: (message: string) => Promise<boolean>;
1046 setTheme: (theme: DesktopTheme) => Promise<void>;1045 setTheme: (theme: DesktopTheme) => Promise<void>;
1047 showContextMenu: <T extends string>(1046 showContextMenu: <T extends string>(
1048 items: readonly ContextMenuItem<T>[],1047 items: readonly ContextMenuItem<T>[],
...@@ -1137,6 +1136,12 @@ export interface DesktopPreviewBridge {...@@ -1137,6 +1136,12 @@ export interface DesktopPreviewBridge {
1137 onPointerEvent: (listener: (event: DesktopPreviewPointerEvent) => void) => () => void;1136 onPointerEvent: (listener: (event: DesktopPreviewPointerEvent) => void) => () => void;
1138}1137}
11391138
1139export type ConfirmDialogVariant = "default" | "destructive";
1140
1141export interface ConfirmDialogOptions {
1142 readonly variant?: ConfirmDialogVariant;
1143}
1144
1140/**1145/**
1141 * APIs bound to the local app shell, not to any particular backend environment.1146 * APIs bound to the local app shell, not to any particular backend environment.
1142 *1147 *
...@@ -1150,7 +1155,7 @@ export interface DesktopPreviewBridge {...@@ -1150,7 +1155,7 @@ export interface DesktopPreviewBridge {
1150export interface LocalApi {1155export interface LocalApi {
1151 dialogs: {1156 dialogs: {
1152 pickFolder: (options?: PickFolderOptions) => Promise<string | null>;1157 pickFolder: (options?: PickFolderOptions) => Promise<string | null>;
1153 confirm: (message: string) => Promise<boolean>;1158 confirm: (message: string, options?: ConfirmDialogOptions) => Promise<boolean>;
1154 };1159 };
1155 shell: {1160 shell: {
1156 openExternal: (url: string) => Promise<void>;1161 openExternal: (url: string) => Promise<void>;