| 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,70 +28,6 @@ describe("ElectronDialog", () => { |
| 28 | showErrorBoxMock.mockReset(); | 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 | 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 | ); |
| 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 | 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"; |
| 6 | 6 | ||
| 7 | import * as Electron from "electron"; | 7 | import * as Electron from "electron"; |
| 8 | 8 | ||
| 9 | const CONFIRM_BUTTON_INDEX = 1; | ||
| 10 | |||
| 11 | export class ElectronDialogPickFolderError extends Schema.TaggedErrorClass<ElectronDialogPickFolderError>()( | 9 | export 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 | } |
| 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 | export class ElectronDialogShowMessageBoxError extends Schema.TaggedErrorClass<ElectronDialogShowMessageBoxError>()( | 39 | export 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 |
| 85 | export const ElectronDialogError = Schema.Union([ | 69 | export 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 | } |
| 105 | 88 | ||
| 106 | export interface ElectronDialogConfirmInput { | ||
| 107 | readonly owner: Option.Option<Electron.BrowserWindow>; | ||
| 108 | readonly message: string; | ||
| 109 | } | ||
| 110 | |||
| 111 | export class ElectronDialog extends Context.Service< | 89 | export 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"; |
| 33 | import { | 33 | import { |
| 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" |
| 81 | 80 | ||
| 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 @@ |
| 1 | export const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; | 1 | export const PICK_FOLDER_CHANNEL = "desktop:pick-folder"; |
| 2 | export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files"; | 2 | export const PICK_THEME_FILES_CHANNEL = "desktop:pick-theme-files"; |
| 3 | export const CONFIRM_CHANNEL = "desktop:confirm"; | ||
| 4 | export const SET_THEME_CHANNEL = "desktop:set-theme"; | 3 | export const SET_THEME_CHANNEL = "desktop:set-theme"; |
| 5 | export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; | 4 | export const CONTEXT_MENU_CHANNEL = "desktop:context-menu"; |
| 6 | export const OPEN_EXTERNAL_CHANNEL = "desktop:open-external"; | 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,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 | export const setTheme = DesktopIpc.makeIpcMethod({ | 223 | export 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, { |
| 53 | const electronDialogLayer = Layer.succeed(ElectronDialog.ElectronDialog, { | 53 | const 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 @@ | ||
| 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,7 +104,7 @@ import { |
| 104 | } from "../keybindings"; | 104 | } from "../keybindings"; |
| 105 | import { isModelPickerOpen } from "../modelPickerVisibility"; | 105 | import { isModelPickerOpen } from "../modelPickerVisibility"; |
| 106 | import { useShortcutModifierState } from "../shortcutModifierState"; | 106 | import { useShortcutModifierState } from "../shortcutModifierState"; |
| 107 | import { readLocalApi } from "../localApi"; | 107 | import { ensureLocalApi, readLocalApi } from "../localApi"; |
| 108 | import { useComposerDraftStore } from "../composerDraftStore"; | 108 | import { useComposerDraftStore } from "../composerDraftStore"; |
| 109 | import { useNewThreadHandler } from "../hooks/useHandleNewThread"; | 109 | import { useNewThreadHandler } from "../hooks/useHandleNewThread"; |
| 110 | import { useDesktopUpdateState } from "../state/desktopUpdate"; | 110 | import { 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 | <Button | 2867 | <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); | ||
| 3505 | 3519 | ||
| 3506 | if (desktopUpdateButtonAction === "download") { | 3520 | if (desktopUpdateButtonAction === "download") { |
| 3507 | void bridge | 3521 | 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 | } |
| 3535 | 3550 | ||
| 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 bridge | 3572 | 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 | ]); | ||
| 3566 | 3603 | ||
| 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"; |
| 15 | import { useCallback, useMemo, useState, type ReactNode } from "react"; | 15 | import { useCallback, useMemo, useRef, useState, type ReactNode } from "react"; |
| 16 | import type { | 16 | import 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"; |
| 22 | import * as Option from "effect/Option"; | 22 | import * as Option from "effect/Option"; |
| 23 | 23 | ||
| 24 | import { cn } from "../../lib/utils"; | 24 | import { cn } from "../../lib/utils"; |
| 25 | import { ensureLocalApi } from "../../localApi"; | ||
| 25 | import { resolveAndPersistPreferredEditor } from "../../editorPreferences"; | 26 | import { resolveAndPersistPreferredEditor } from "../../editorPreferences"; |
| 26 | import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat"; | 27 | import { formatRelativeTimeLabel, getRelativeTimeState } from "../../timestampFormat"; |
| 27 | import { useEnvironmentQuery } from "../../state/query"; | 28 | import { 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; | ||
| 860 | 866 | ||
| 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 | } |
| 912 | 943 | ||
| 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 | ); |
| 956 | 987 | ||
| 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"; |
| 28 | import * as DateTime from "effect/DateTime"; | 28 | import * as DateTime from "effect/DateTime"; |
| 29 | import * as Option from "effect/Option"; | 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 | import { | 31 | import { |
| 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"; |
| 40 | import { cn } from "../../lib/utils"; | 40 | import { cn } from "../../lib/utils"; |
| 41 | import { ensureLocalApi } from "../../localApi"; | ||
| 41 | import { usePrimaryEnvironment } from "../../state/environments"; | 42 | import { usePrimaryEnvironment } from "../../state/environments"; |
| 42 | import { serverEnvironment } from "../../state/server"; | 43 | import { serverEnvironment } from "../../state/server"; |
| 43 | import { useAtomCommand } from "../../state/use-atom-command"; | 44 | import { 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; |
| 851 | 856 | ||
| 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 | ); |
| 909 | 933 | ||
| 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() { |
| 220 | function AboutVersionSection() { | 220 | function 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); | ||
| 223 | 224 | ||
| 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 | ); |
| 257 | 258 | ||
| 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; |
| 261 | 262 | ||
| ... | @@ -275,22 +276,43 @@ function AboutVersionSection() { | ... | @@ -275,22 +276,43 @@ function AboutVersionSection() { |
| 275 | } | 276 | } |
| 276 | 277 | ||
| 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 | } |
| 296 | 318 | ||
| ... | @@ -318,7 +340,7 @@ function AboutVersionSection() { | ... | @@ -318,7 +340,7 @@ function AboutVersionSection() { |
| 318 | }), | 340 | }), |
| 319 | ); | 341 | ); |
| 320 | }); | 342 | }); |
| 321 | }, [updateState]); | 343 | }, [isUpdateActionPending, updateState]); |
| 322 | 344 | ||
| 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 | <Button | 374 | <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; |
| 555 | 578 |
apps/web/src/components/sidebar/SidebarUpdatePill.tsx+33-11| ... | @@ -1,6 +1,7 @@ | ... | @@ -1,6 +1,7 @@ |
| 1 | import { DownloadIcon, RotateCwIcon, TriangleAlertIcon, XIcon } from "lucide-react"; | 1 | import { DownloadIcon, RotateCwIcon, TriangleAlertIcon, XIcon } from "lucide-react"; |
| 2 | import { useCallback, useState } from "react"; | 2 | import { useCallback, useState } from "react"; |
| 3 | import { isElectron } from "../../env"; | 3 | import { isElectron } from "../../env"; |
| 4 | import { ensureLocalApi } from "../../localApi"; | ||
| 4 | import { useDesktopUpdateState } from "../../state/desktopUpdate"; | 5 | import { useDesktopUpdateState } from "../../state/desktopUpdate"; |
| 5 | import { stackedThreadToast, toastManager } from "../ui/toast"; | 6 | import { stackedThreadToast, toastManager } from "../ui/toast"; |
| 6 | import { | 7 | import { |
| ... | @@ -70,6 +71,7 @@ function SidebarUpdateReleaseNotesTooltip({ | ... | @@ -70,6 +71,7 @@ function SidebarUpdateReleaseNotesTooltip({ |
| 70 | export function SidebarUpdatePill() { | 71 | export 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); | ||
| 73 | 75 | ||
| 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; |
| 82 | 84 | ||
| 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); | ||
| 87 | 91 | ||
| 88 | if (action === "download") { | 92 | if (action === "download") { |
| 89 | void bridge | 93 | 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 | } |
| 117 | 122 | ||
| 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 bridge | 144 | 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]); |
| 148 | 170 | ||
| 149 | if (!visible && !showArm64Warning) return null; | 171 | if (!visible && !showArm64Warning) return null; |
| 150 | 172 | ||
| ... | @@ -170,8 +192,8 @@ export function SidebarUpdatePill() { | ... | @@ -170,8 +192,8 @@ export function SidebarUpdatePill() { |
| 170 | <button | 192 | <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 @@ | ||
| 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,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 @@ |
| 1 | import { | 1 | import { |
| 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 | >(); |
| 15 | 16 | ||
| 17 | const requestConfirmDialogMock = | ||
| 18 | vi.fn<(message: string, options?: ConfirmDialogOptions) => Promise<boolean> | undefined>(); | ||
| 19 | |||
| 16 | vi.mock("./contextMenuFallback", () => ({ | 20 | vi.mock("./contextMenuFallback", () => ({ |
| 17 | showContextMenuFallback: showContextMenuFallbackMock, | 21 | showContextMenuFallback: showContextMenuFallbackMock, |
| 18 | })); | 22 | })); |
| 19 | 23 | ||
| 24 | vi.mock("./confirmDialog", () => ({ | ||
| 25 | requestConfirmDialog: requestConfirmDialogMock, | ||
| 26 | })); | ||
| 27 | |||
| 20 | function createLocalStorageStub(): Storage { | 28 | function 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 | }); |
| 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 | 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; |
| 95 | 121 | ||
| 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 @@ |
| 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 | import { showContextMenuFallback } from "./contextMenuFallback"; | 4 | import { showContextMenuFallback } from "./contextMenuFallback"; |
| 5 | import { readBrowserClientSettings, writeBrowserClientSettings } from "./clientPersistenceStorage"; | 5 | import { readBrowserClientSettings, writeBrowserClientSettings } from "./clientPersistenceStorage"; |
| 6 | import { resetRequestLatencyStateForTests } from "./rpc/requestLatencyState"; | ||
| 6 | 7 | ||
| 7 | let cachedApi: LocalApi | undefined; | 8 | let cachedApi: LocalApi | undefined; |
| 8 | 9 | ||
| ... | @@ -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"; |
| 14 | import { resolveServerBackedAppDisplayName } from "../branding.logic"; | 14 | import { resolveServerBackedAppDisplayName } from "../branding.logic"; |
| 15 | import { AppSidebarLayout } from "../components/AppSidebarLayout"; | 15 | import { AppSidebarLayout } from "../components/AppSidebarLayout"; |
| 16 | import { CommandPalette } from "../components/CommandPalette"; | 16 | import { CommandPalette } from "../components/CommandPalette"; |
| 17 | import { ConfirmDialogHost } from "../components/ConfirmDialogHost"; | ||
| 17 | import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog"; | 18 | import { ConnectOnboardingDialog } from "../components/cloud/ConnectOnboardingDialog"; |
| 18 | import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog"; | 19 | import { RelayClientInstallDialog } from "../components/cloud/RelayClientInstallDialog"; |
| 19 | import { SshPasswordPromptDialog } from "../components/desktop/SshPasswordPromptDialog"; | 20 | import { 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 | } |
| 1139 | 1138 | ||
| 1139 | export type ConfirmDialogVariant = "default" | "destructive"; | ||
| 1140 | |||
| 1141 | export 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 { |
| 1150 | export interface LocalApi { | 1155 | export 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>; |