Skip to content

Commit 9821bca

Browse files
authored
fix(web): use themed confirmation dialogs (#5624)
1 parent 3d74474 commit 9821bca

24 files changed

Lines changed: 589 additions & 245 deletions

apps/desktop/src/electron/ElectronDialog.test.ts

Lines changed: 0 additions & 89 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 0 additions & 58 deletions
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,6 @@ import * as Schema from "effect/Schema";
66

77
import * as Electron from "electron";
88

9-
const CONFIRM_BUTTON_INDEX = 1;
10-
119
export class ElectronDialogPickFolderError extends Schema.TaggedErrorClass<ElectronDialogPickFolderError>()(
1210
"ElectronDialogPickFolderError",
1311
{
@@ -38,20 +36,6 @@ export class ElectronDialogPickFilesError extends Schema.TaggedErrorClass<Electr
3836
}
3937
}
4038

41-
export 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-
5539
export class ElectronDialogShowMessageBoxError extends Schema.TaggedErrorClass<ElectronDialogShowMessageBoxError>()(
5640
"ElectronDialogShowMessageBoxError",
5741
{
@@ -85,7 +69,6 @@ export class ElectronDialogShowErrorBoxError extends Schema.TaggedErrorClass<Ele
8569
export 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

106-
export interface ElectronDialogConfirmInput {
107-
readonly owner: Option.Option<Electron.BrowserWindow>;
108-
readonly message: string;
109-
}
110-
11189
export 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

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,6 @@ import {
3131
setUpdateChannel,
3232
} from "./methods/updates.ts";
3333
import {
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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
11
export const PICK_FOLDER_CHANNEL = "desktop:pick-folder";
22
export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files";
3-
export const CONFIRM_CHANNEL = "desktop:confirm";
43
export const SET_THEME_CHANNEL = "desktop:set-theme";
54
export const CONTEXT_MENU_CHANNEL = "desktop:context-menu";
65
export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external";

apps/desktop/src/ipc/methods/window.ts

Lines changed: 0 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -220,19 +220,6 @@ export const pickFolder = DesktopIpc.makeIpcMethod({
220220
}),
221221
});
222222

223-
export 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-
236223
export const setTheme = DesktopIpc.makeIpcMethod({
237224
channel: IpcChannels.SET_THEME_CHANNEL,
238225
payload: DesktopThemeSchema,

apps/desktop/src/preload.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -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

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,6 @@ const electronAppLayer = Layer.succeed(ElectronApp.ElectronApp, {
5353
const 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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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;
Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
import { useEffect, useSyncExternalStore } from "react";
2+
3+
import {
4+
completeConfirmDialogClose,
5+
readConfirmDialogState,
6+
registerConfirmDialogHost,
7+
respondToConfirmDialog,
8+
subscribeConfirmDialog,
9+
} from "../confirmDialog";
10+
import {
11+
AlertDialog,
12+
AlertDialogClose,
13+
AlertDialogDescription,
14+
AlertDialogFooter,
15+
AlertDialogHeader,
16+
AlertDialogPopup,
17+
AlertDialogTitle,
18+
} from "./ui/alert-dialog";
19+
import { Button } from "./ui/button";
20+
21+
type ConfirmationCopy = {
22+
readonly title: string;
23+
readonly description: string | null;
24+
};
25+
26+
export 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+
54+
export 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+
}

0 commit comments

Comments
 (0)