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