diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index ea314e15..7df5b893 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -1,7 +1,9 @@ import { Component, Show, createMemo, createEffect, createSignal } from "solid-js" import { Dialog } from "@kobalte/core/dialog" import { Toaster } from "solid-toast" +import AlertDialog from "./components/alert-dialog" import FolderSelectionView from "./components/folder-selection-view" +import { showConfirmDialog } from "./stores/alerts" import InstanceTabs from "./components/instance-tabs" import InstanceDisconnectedModal from "./components/instance-disconnected-modal" import InstanceShell from "./components/instance/instance-shell" @@ -135,11 +137,21 @@ const App: Component = () => { } async function handleCloseInstance(instanceId: string) { - if (confirm("Stop OpenCode instance? This will stop the server.")) { - await stopInstance(instanceId) - if (instances().size === 0) { - setHasInstances(false) - } + const confirmed = await showConfirmDialog( + "Stop OpenCode instance? This will stop the server.", + { + title: "Stop instance", + variant: "warning", + confirmLabel: "Stop", + cancelLabel: "Keep running", + }, + ) + + if (!confirmed) return + + await stopInstance(instanceId) + if (instances().size === 0) { + setHasInstances(false) } } @@ -321,6 +333,8 @@ const App: Component = () => { + + = { + info: { + badgeBg: "var(--badge-neutral-bg)", + badgeBorder: "var(--border-base)", + badgeText: "var(--accent-primary)", + symbol: "i", + fallbackTitle: "Heads up", + }, + warning: { + badgeBg: "rgba(255, 152, 0, 0.14)", + badgeBorder: "var(--status-warning)", + badgeText: "var(--status-warning)", + symbol: "!", + fallbackTitle: "Please review", + }, + error: { + badgeBg: "var(--danger-soft-bg)", + badgeBorder: "var(--status-error)", + badgeText: "var(--status-error)", + symbol: "!", + fallbackTitle: "Something went wrong", + }, +} + +function dismiss(confirmed: boolean, payload?: AlertDialogState | null) { + const current = payload ?? alertDialogState() + if (current?.type === "confirm") { + if (confirmed) { + current.onConfirm?.() + } else { + current.onCancel?.() + } + current.resolve?.(confirmed) + } else if (confirmed) { + current?.onConfirm?.() + } + dismissAlertDialog() +} + +const AlertDialog: Component = () => { + let primaryButtonRef: HTMLButtonElement | undefined + + createEffect(() => { + if (alertDialogState()) { + queueMicrotask(() => { + primaryButtonRef?.focus() + }) + } + }) + + return ( + + {(payload) => { + const variant = payload.variant ?? "info" + const accent = variantAccent[variant] + const title = payload.title || accent.fallbackTitle + const isConfirm = payload.type === "confirm" + const confirmLabel = payload.confirmLabel || (isConfirm ? "Confirm" : "OK") + const cancelLabel = payload.cancelLabel || "Cancel" + + return ( + { + if (!open) { + dismiss(false, payload) + } + }} + > + + + + + + + {accent.symbol} + + + {title} + + {payload.message} + {payload.detail && {payload.detail}} + + + + + + {isConfirm && ( + dismiss(false, payload)} + > + {cancelLabel} + + )} + { + primaryButtonRef = el + }} + onClick={() => dismiss(true, payload)} + > + {confirmLabel} + + + + + + + ) + }} + + ) +} + +export default AlertDialog diff --git a/packages/ui/src/components/prompt-input.tsx b/packages/ui/src/components/prompt-input.tsx index 47d9621a..e749266d 100644 --- a/packages/ui/src/components/prompt-input.tsx +++ b/packages/ui/src/components/prompt-input.tsx @@ -10,6 +10,7 @@ import Kbd from "./kbd" import HintRow from "./hint-row" import { getActiveInstance } from "../stores/instances" import { agents, getSessionDraftPrompt, setSessionDraftPrompt, clearSessionDraftPrompt } from "../stores/sessions" +import { showAlertDialog } from "../stores/alerts" interface PromptInputProps { instanceId: string @@ -526,7 +527,11 @@ export default function PromptInput(props: PromptInputProps) { } } catch (error) { console.error("Failed to send message:", error) - alert("Failed to send message: " + (error instanceof Error ? error.message : String(error))) + showAlertDialog("Failed to send message", { + title: "Send failed", + detail: error instanceof Error ? error.message : String(error), + variant: "error", + }) } finally { textareaRef?.focus() } diff --git a/packages/ui/src/components/session/session-view.tsx b/packages/ui/src/components/session/session-view.tsx index 4f5d5af8..04d35b54 100644 --- a/packages/ui/src/components/session/session-view.tsx +++ b/packages/ui/src/components/session/session-view.tsx @@ -6,6 +6,7 @@ import MessageStream from "../message-stream" import PromptInput from "../prompt-input" import { instances } from "../../stores/instances" import { loadMessages, sendMessage, forkSession, isSessionMessagesLoading, setActiveParentSession, setActiveSession, runShellCommand } from "../../stores/sessions" +import { showAlertDialog } from "../../stores/alerts" interface SessionViewProps { sessionId: string @@ -73,7 +74,10 @@ export const SessionView: Component = (props) => { } } catch (error) { console.error("Failed to revert:", error) - alert("Failed to revert to message") + showAlertDialog("Failed to revert to message", { + title: "Revert failed", + variant: "error", + }) } } @@ -106,7 +110,10 @@ export const SessionView: Component = (props) => { } } catch (error) { console.error("Failed to fork session:", error) - alert("Failed to fork session") + showAlertDialog("Failed to fork session", { + title: "Fork failed", + variant: "error", + }) } } diff --git a/packages/ui/src/lib/command-utils.ts b/packages/ui/src/lib/command-utils.ts index b964e3bc..535454b5 100644 --- a/packages/ui/src/lib/command-utils.ts +++ b/packages/ui/src/lib/command-utils.ts @@ -1,5 +1,6 @@ import type { Command } from "./commands" import type { Command as SDKCommand } from "@opencode-ai/sdk" +import { showAlertDialog } from "../stores/alerts" import { activeSessionId, executeCustomCommand } from "../stores/sessions" export function commandRequiresArguments(template?: string): boolean { @@ -33,7 +34,10 @@ export function buildCustomCommandEntries(instanceId: string, commands: SDKComma action: async () => { const sessionId = activeSessionId().get(instanceId) if (!sessionId || sessionId === "info") { - alert("Select a session before running a custom command.") + showAlertDialog("Select a session before running a custom command.", { + title: "Session required", + variant: "warning", + }) return } const args = promptForCommandArguments(cmd) @@ -44,7 +48,10 @@ export function buildCustomCommandEntries(instanceId: string, commands: SDKComma await executeCustomCommand(instanceId, sessionId, cmd.name, args) } catch (error) { console.error("Failed to run custom command:", error) - alert("Failed to run custom command. Check the console for details.") + showAlertDialog("Failed to run custom command. Check the console for details.", { + title: "Command failed", + variant: "error", + }) } }, })) diff --git a/packages/ui/src/lib/hooks/use-commands.ts b/packages/ui/src/lib/hooks/use-commands.ts index f8a7d49a..ac023799 100644 --- a/packages/ui/src/lib/hooks/use-commands.ts +++ b/packages/ui/src/lib/hooks/use-commands.ts @@ -11,6 +11,7 @@ import { setActiveSession, } from "../../stores/sessions" import { setSessionCompactionState } from "../../stores/session-compaction" +import { showAlertDialog } from "../../stores/alerts" import type { Instance } from "../../types/instance" export interface UseCommandsOptions { @@ -207,7 +208,10 @@ export function useCommands(options: UseCommandsOptions) { setSessionCompactionState(instance.id, sessionId, false) console.error("Failed to compact session:", error) const message = error instanceof Error ? error.message : "Failed to compact session" - alert(`Compact failed: ${message}`) + showAlertDialog(`Compact failed: ${message}`, { + title: "Compact failed", + variant: "error", + }) } }, }) @@ -256,7 +260,10 @@ export function useCommands(options: UseCommandsOptions) { } if (!messageID) { - alert("Nothing to undo") + showAlertDialog("Nothing to undo", { + title: "No actions to undo", + variant: "info", + }) return } @@ -282,7 +289,10 @@ export function useCommands(options: UseCommandsOptions) { } } catch (error) { console.error("Failed to revert message:", error) - alert("Failed to revert message") + showAlertDialog("Failed to revert message", { + title: "Undo failed", + variant: "error", + }) } }, }) diff --git a/packages/ui/src/stores/alerts.ts b/packages/ui/src/stores/alerts.ts new file mode 100644 index 00000000..a3c7775b --- /dev/null +++ b/packages/ui/src/stores/alerts.ts @@ -0,0 +1,46 @@ +import { createSignal } from "solid-js" + +export type AlertVariant = "info" | "warning" | "error" + +export type AlertDialogState = { + type?: "alert" | "confirm" + title?: string + message: string + detail?: string + variant?: AlertVariant + confirmLabel?: string + cancelLabel?: string + onConfirm?: () => void + onCancel?: () => void + resolve?: (value: boolean) => void +} + +const [alertDialogState, setAlertDialogState] = createSignal(null) + +export function showAlertDialog(message: string, options?: Omit) { + setAlertDialogState({ + type: "alert", + message, + ...options, + }) +} + +export function showConfirmDialog(message: string, options?: Omit): Promise { + const activeElement = typeof document !== "undefined" ? (document.activeElement as HTMLElement | null) : null + activeElement?.blur() + + return new Promise((resolve) => { + setAlertDialogState({ + type: "confirm", + message, + ...options, + resolve, + }) + }) +} + +export function dismissAlertDialog() { + setAlertDialogState(null) +} + +export { alertDialogState } diff --git a/packages/ui/src/stores/session-events.ts b/packages/ui/src/stores/session-events.ts index 3b470aba..a123acd0 100644 --- a/packages/ui/src/stores/session-events.ts +++ b/packages/ui/src/stores/session-events.ts @@ -16,6 +16,7 @@ import type { import { showToastNotification, ToastVariant } from "../lib/notifications" import { preferences } from "./preferences" import { instances, addPermissionToQueue, removePermissionFromQueue, refreshPermissionsForSession } from "./instances" +import { showAlertDialog } from "./alerts" import { sessions, setSessions, @@ -441,7 +442,10 @@ function handleSessionError(_instanceId: string, event: EventSessionError): void } } - alert(`Error: ${message}`) + showAlertDialog(`Error: ${message}`, { + title: "Session error", + variant: "error", + }) } function handleMessageRemoved(instanceId: string, event: MessageRemovedEvent): void {
{payload.detail}