diff --git a/client/ui/frontend/CLAUDE.md b/client/ui/frontend/CLAUDE.md index 27b3623b8..27ddfb647 100644 --- a/client/ui/frontend/CLAUDE.md +++ b/client/ui/frontend/CLAUDE.md @@ -178,7 +178,9 @@ This is the only SSO entry point used by the polished Main UI. There is no `/log ## Dialogs convention -Errors → `Dialogs.Error` with action-named title ("Save Settings Failed", not "Error"). Confirmations → `Dialogs.Warning` with explicit `Buttons` — compare against the **Label string**, not an index. **Skip** native dialogs for inline form validation, transient link errors on the dashboard, and "partial success" notes inside an otherwise-OK flow. Full API + per-OS notes in `../WAILS-DIALOGS.md`; full convention rationale in `../CLAUDE.md`. +**Always go through `src/lib/dialogs.ts`** — `errorDialog` / `warningDialog` / `infoDialog` / `questionDialog`, not `Dialogs.*` from `@wailsio/runtime` directly. These thin wrappers force `Detached: true` on Windows (no-op elsewhere, and any caller-supplied `Detached` wins). A native Windows `MessageBox` attached to a parent window sets that window `WS_DISABLED` for its lifetime and re-enables it on dismissal; when the parent is the main window — whose `WindowClosing` hook hides instead of closes (`main.go`) — the enable/hide sequence races and leaves the window unable to process its close (X) button afterwards. Detaching gives the box a NULL owner so no window is ever disabled. macOS keeps the attached sheet-style presentation. The wrappers re-export the same option shape, so call sites are otherwise unchanged. + +Errors → `errorDialog` with action-named title ("Save Settings Failed", not "Error"). Confirmations → `warningDialog` with explicit `Buttons` — compare against the **Label string**, not an index. **Skip** native dialogs for inline form validation, transient link errors on the dashboard, and "partial success" notes inside an otherwise-OK flow. Full API + per-OS notes in `../WAILS-DIALOGS.md`; full convention rationale in `../CLAUDE.md`. ## Tailwind tokens diff --git a/client/ui/frontend/src/components/LanguagePicker.tsx b/client/ui/frontend/src/components/LanguagePicker.tsx index ea9b93adc..ea0b650d8 100644 --- a/client/ui/frontend/src/components/LanguagePicker.tsx +++ b/client/ui/frontend/src/components/LanguagePicker.tsx @@ -3,7 +3,7 @@ import { useTranslation } from "react-i18next"; import * as Popover from "@radix-ui/react-popover"; import * as ScrollArea from "@radix-ui/react-scroll-area"; import { Command } from "cmdk"; -import { Dialogs } from "@wailsio/runtime"; +import { errorDialog } from "@/lib/dialogs.ts"; import { CheckIcon, ChevronDown, Search } from "lucide-react"; import { Preferences } from "@bindings/services"; import { LanguageCode, type Language } from "@bindings/i18n/models.js"; @@ -90,7 +90,7 @@ export function LanguagePicker() { try { await Preferences.SetLanguage(code as LanguageCode); } catch (e) { - await Dialogs.Error({ + await errorDialog({ Title: t("settings.error.saveTitle"), Message: formatErrorMessage(e), }); diff --git a/client/ui/frontend/src/contexts/ClientVersionContext.tsx b/client/ui/frontend/src/contexts/ClientVersionContext.tsx index af6b7f27c..c3de09aba 100644 --- a/client/ui/frontend/src/contexts/ClientVersionContext.tsx +++ b/client/ui/frontend/src/contexts/ClientVersionContext.tsx @@ -8,7 +8,8 @@ import { useState, type ReactNode, } from "react"; -import { Dialogs, Events } from "@wailsio/runtime"; +import { Events } from "@wailsio/runtime"; +import { errorDialog } from "@/lib/dialogs.ts"; import { Update as UpdateSvc, WindowManager } from "@bindings/services"; @@ -66,7 +67,7 @@ export const ClientVersionProvider = ({ children }: { children: ReactNode }) => }) .catch((e) => { if (cancelled || isDaemonUnavailable(e)) return; - void Dialogs.Error({ + void errorDialog({ Title: i18next.t("update.error.loadStateTitle"), Message: formatErrorMessage(e), }); @@ -106,7 +107,7 @@ export const ClientVersionProvider = ({ children }: { children: ReactNode }) => // produce a result) and surface the error. if (isDaemonUnavailable(e)) return; WindowManager.CloseInstallProgress().catch(console.error); - await Dialogs.Error({ + await errorDialog({ Title: i18next.t("update.error.triggerTitle"), Message: formatErrorMessage(e), }); diff --git a/client/ui/frontend/src/contexts/DebugBundleContext.tsx b/client/ui/frontend/src/contexts/DebugBundleContext.tsx index 8233329ec..9e0f79957 100644 --- a/client/ui/frontend/src/contexts/DebugBundleContext.tsx +++ b/client/ui/frontend/src/contexts/DebugBundleContext.tsx @@ -5,7 +5,7 @@ import { useState, type ReactNode, } from "react"; -import { Dialogs } from "@wailsio/runtime"; +import { errorDialog } from "@/lib/dialogs.ts"; import { Connection as ConnectionSvc, Debug as DebugSvc, @@ -163,7 +163,7 @@ const useDebugBundle = () => { return; } setStage({ kind: "idle" }); - await Dialogs.Error({ + await errorDialog({ Title: i18next.t("settings.error.debugBundleTitle"), Message: formatErrorMessage(e), }); diff --git a/client/ui/frontend/src/contexts/ProfileContext.tsx b/client/ui/frontend/src/contexts/ProfileContext.tsx index 7c6224fe8..c202a9b26 100644 --- a/client/ui/frontend/src/contexts/ProfileContext.tsx +++ b/client/ui/frontend/src/contexts/ProfileContext.tsx @@ -6,7 +6,8 @@ import { useState, type ReactNode, } from "react"; -import { Dialogs, Events } from "@wailsio/runtime"; +import { Events } from "@wailsio/runtime"; +import { errorDialog } from "@/lib/dialogs.ts"; import { Connection, ProfileSwitcher, @@ -65,7 +66,7 @@ export const ProfileProvider = ({ children }: { children: ReactNode }) => { if (msg.includes("code = Unavailable")) { return; } - await Dialogs.Error({ + await errorDialog({ Title: i18next.t("profile.error.loadTitle"), Message: formatErrorMessage(e), }); diff --git a/client/ui/frontend/src/contexts/SettingsContext.tsx b/client/ui/frontend/src/contexts/SettingsContext.tsx index 86eed4cd0..2c46fc99b 100644 --- a/client/ui/frontend/src/contexts/SettingsContext.tsx +++ b/client/ui/frontend/src/contexts/SettingsContext.tsx @@ -7,7 +7,7 @@ import { useState, type ReactNode, } from "react"; -import { Dialogs } from "@wailsio/runtime"; +import { errorDialog } from "@/lib/dialogs.ts"; import { Autostart, Settings as SettingsSvc } from "@bindings/services"; import type { Config } from "@bindings/services/models.js"; import i18next from "@/lib/i18n"; @@ -68,7 +68,7 @@ const useSettingsState = () => { }); setConfig(c); } catch (e) { - await Dialogs.Error({ + await errorDialog({ Title: i18next.t("settings.error.loadTitle"), Message: errorMessage(e), }); @@ -98,7 +98,7 @@ const useSettingsState = () => { username, }); } catch (e) { - await Dialogs.Error({ + await errorDialog({ Title: i18next.t("settings.error.saveTitle"), Message: errorMessage(e), }); @@ -211,7 +211,7 @@ export const AutostartSettingsProvider = ({ children }: { children: ReactNode }) await Autostart.SetEnabled(enabled); } catch (e) { setAutostart((s) => (s ? { ...s, enabled: !enabled } : s)); - await Dialogs.Error({ + await errorDialog({ Title: i18next.t("settings.general.autostart.errorTitle"), Message: errorMessage(e), }); diff --git a/client/ui/frontend/src/hooks/useManagementUrl.ts b/client/ui/frontend/src/hooks/useManagementUrl.ts index 4e8e22702..b4633f3cc 100644 --- a/client/ui/frontend/src/hooks/useManagementUrl.ts +++ b/client/ui/frontend/src/hooks/useManagementUrl.ts @@ -1,5 +1,5 @@ import { useEffect, useRef, useState } from "react"; -import { Dialogs } from "@wailsio/runtime"; +import { warningDialog } from "@/lib/dialogs.ts"; import i18next from "@/lib/i18n"; import { useSettings } from "@/contexts/SettingsContext.tsx"; @@ -70,7 +70,7 @@ export function useManagementUrl() { switchConfirmOpenRef.current = true; const cancelLabel = i18next.t("common.cancel"); const confirmLabel = i18next.t("settings.general.management.switchCloudConfirm"); - void Dialogs.Warning({ + void warningDialog({ Title: i18next.t("settings.general.management.switchCloudTitle"), Message: i18next.t("settings.general.management.switchCloudMessage"), Buttons: [ diff --git a/client/ui/frontend/src/lib/dialogs.ts b/client/ui/frontend/src/lib/dialogs.ts new file mode 100644 index 000000000..814401629 --- /dev/null +++ b/client/ui/frontend/src/lib/dialogs.ts @@ -0,0 +1,42 @@ +import { Dialogs } from "@wailsio/runtime"; + +import { isWindows } from "@/lib/platform"; + +// Derived from the runtime rather than deep-imported: the package's exports map +// only exposes the types barrel, not "@wailsio/runtime/types/dialogs". +type MessageDialogOptions = Parameters[0]; + +// On Windows a native MessageBox attached to a parent window disables that +// parent (WS_DISABLED) for the lifetime of the dialog and re-enables it on +// dismissal. When the parent is the main window — whose WindowClosing hook +// hides instead of closes (main.go) — the enable/hide sequence can race and +// leave the window unable to process its close (X) button afterwards: the user +// reports the main window can no longer be closed once an error dialog (e.g. a +// rejected login) has been shown. Detaching the dialog gives the MessageBox a +// NULL owner, so no window is ever disabled and the X keeps working. +// +// macOS keeps the attached (sheet-style) presentation — the bug is Windows-only +// and detaching there loses the sheet animation — so we only force Detached on +// Windows and leave any caller-supplied value untouched elsewhere. +function withDetached(options: MessageDialogOptions): MessageDialogOptions { + if (options.Detached !== undefined || !isWindows()) { + return options; + } + return { ...options, Detached: true }; +} + +export function errorDialog(options: MessageDialogOptions): Promise { + return Dialogs.Error(withDetached(options)); +} + +export function warningDialog(options: MessageDialogOptions): Promise { + return Dialogs.Warning(withDetached(options)); +} + +export function infoDialog(options: MessageDialogOptions): Promise { + return Dialogs.Info(withDetached(options)); +} + +export function questionDialog(options: MessageDialogOptions): Promise { + return Dialogs.Question(withDetached(options)); +} diff --git a/client/ui/frontend/src/modules/login/LoginWaitingForBrowserDialog.tsx b/client/ui/frontend/src/modules/login/LoginWaitingForBrowserDialog.tsx index 30dfaa8ba..7bee1702f 100644 --- a/client/ui/frontend/src/modules/login/LoginWaitingForBrowserDialog.tsx +++ b/client/ui/frontend/src/modules/login/LoginWaitingForBrowserDialog.tsx @@ -1,7 +1,8 @@ import { useCallback, useEffect, useRef } from "react"; import { useTranslation } from "react-i18next"; import { useSearchParams } from "react-router-dom"; -import { Dialogs, Events } from "@wailsio/runtime"; +import { Events } from "@wailsio/runtime"; +import { errorDialog } from "@/lib/dialogs.ts"; import { Loader2 } from "lucide-react"; import { Connection } from "@bindings/services"; import { Button } from "@/components/buttons/Button"; @@ -25,7 +26,7 @@ export default function LoginWaitingForBrowserDialog() { const reportOpenFailure = useCallback( (e: unknown) => { - void Dialogs.Error({ + void errorDialog({ Title: t("browserLogin.openFailedTitle"), Message: formatErrorMessage(e), }); diff --git a/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx b/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx index 18f36f45f..7ca025715 100644 --- a/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx +++ b/client/ui/frontend/src/modules/main/MainConnectionStatusSwitch.tsx @@ -1,8 +1,9 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Dialogs, Events } from "@wailsio/runtime"; +import { Events } from "@wailsio/runtime"; import { Connection, WindowManager } from "@bindings/services"; import i18next from "@/lib/i18n"; +import { errorDialog } from "@/lib/dialogs.ts"; import { ToggleSwitch } from "@/components/switches/ToggleSwitch.tsx"; import { useStatus } from "@/contexts/StatusContext.tsx"; import { useProfile } from "@/contexts/ProfileContext.tsx"; @@ -105,7 +106,7 @@ async function startLogin(): Promise { } catch (e) { WindowManager.CloseBrowserLogin().catch(console.error); if (cancelled) return; - await Dialogs.Error({ + await errorDialog({ Title: i18next.t("connect.error.loginTitle"), Message: errorMessage(e), }); @@ -192,7 +193,7 @@ export const MainConnectionStatusSwitch = () => { } catch (e) { setAction(null); await refresh(); - await Dialogs.Error({ + await errorDialog({ Title: t("connect.error.connectTitle"), Message: errorMessage(e), }); @@ -212,7 +213,7 @@ export const MainConnectionStatusSwitch = () => { } catch (e) { setAction(null); await refresh(); - await Dialogs.Error({ + await errorDialog({ Title: t("connect.error.disconnectTitle"), Message: errorMessage(e), }); diff --git a/client/ui/frontend/src/modules/profiles/ProfileDropdown.tsx b/client/ui/frontend/src/modules/profiles/ProfileDropdown.tsx index 92082583f..3a07a9a3e 100644 --- a/client/ui/frontend/src/modules/profiles/ProfileDropdown.tsx +++ b/client/ui/frontend/src/modules/profiles/ProfileDropdown.tsx @@ -1,6 +1,6 @@ import { forwardRef, useLayoutEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Dialogs } from "@wailsio/runtime"; +import { errorDialog } from "@/lib/dialogs.ts"; import * as Popover from "@radix-ui/react-popover"; import * as ScrollArea from "@radix-ui/react-scroll-area"; import { Command } from "cmdk"; @@ -36,7 +36,7 @@ export const ProfileDropdown = ({ onManageProfiles }: ProfileDropdownProps) => { try { await fn(); } catch (e) { - await Dialogs.Error({ + await errorDialog({ Title: title, Message: formatErrorMessage(e), }); diff --git a/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx b/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx index fc59afa7b..4dc1a4eaf 100644 --- a/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx +++ b/client/ui/frontend/src/modules/profiles/ProfilesTab.tsx @@ -1,6 +1,6 @@ import { useLayoutEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; -import { Dialogs } from "@wailsio/runtime"; +import { errorDialog, warningDialog } from "@/lib/dialogs.ts"; import { CircleMinus, PlusCircle, Trash2, UserCircle } from "lucide-react"; import type { Profile } from "@bindings/services/models.js"; import { Badge } from "@/components/Badge"; @@ -44,7 +44,7 @@ export function ProfilesTab() { try { await fn(); } catch (e) { - await Dialogs.Error({ + await errorDialog({ Title: title, Message: formatErrorMessage(e), }); @@ -56,7 +56,7 @@ export function ProfilesTab() { const handleDeregister = async (name: string) => { const cancelLabel = i18next.t("common.cancel"); const confirmLabel = i18next.t("profile.deregister.confirm"); - const result = await Dialogs.Warning({ + const result = await warningDialog({ Title: i18next.t("profile.deregister.title"), Message: i18next.t("profile.deregister.message", { name }), Buttons: [ @@ -72,7 +72,7 @@ export function ProfilesTab() { if (name === DEFAULT_PROFILE) return; const cancelLabel = i18next.t("common.cancel"); const confirmLabel = i18next.t("common.delete"); - const result = await Dialogs.Warning({ + const result = await warningDialog({ Title: i18next.t("profile.delete.title"), Message: i18next.t("profile.delete.message", { name }), Buttons: [ @@ -89,7 +89,7 @@ export function ProfilesTab() { await addProfile(name); await switchProfile(name); } catch (e) { - await Dialogs.Error({ + await errorDialog({ Title: i18next.t("profile.error.createTitle"), Message: formatErrorMessage(e), }); diff --git a/client/ui/frontend/src/modules/session/SessionAboutToExpireDialog.tsx b/client/ui/frontend/src/modules/session/SessionAboutToExpireDialog.tsx index 74c7b9b79..6571d8a58 100644 --- a/client/ui/frontend/src/modules/session/SessionAboutToExpireDialog.tsx +++ b/client/ui/frontend/src/modules/session/SessionAboutToExpireDialog.tsx @@ -1,7 +1,7 @@ import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; import { useSearchParams } from "react-router-dom"; -import { Dialogs } from "@wailsio/runtime"; +import { errorDialog } from "@/lib/dialogs.ts"; import { ClockIcon } from "lucide-react"; import { Button } from "@/components/buttons/Button"; import { ConfirmDialog } from "@/components/dialog/ConfirmDialog"; @@ -89,7 +89,7 @@ export default function SessionAboutToExpireDialog() { } WindowManager.CloseSessionAboutToExpire().catch(console.error); } catch (e) { - await Dialogs.Error({ + await errorDialog({ Title: t("sessionAboutToExpire.extendFailedTitle"), Message: formatErrorMessage(e), }); @@ -110,7 +110,7 @@ export default function SessionAboutToExpireDialog() { }); WindowManager.CloseSessionAboutToExpire().catch(console.error); } catch (e) { - await Dialogs.Error({ + await errorDialog({ Title: t("sessionAboutToExpire.logoutFailedTitle"), Message: formatErrorMessage(e), });