diff options
| author | Adam <[email protected]> | 2025-12-29 20:54:33 -0600 |
|---|---|---|
| committer | Adam <[email protected]> | 2025-12-30 04:57:35 -0600 |
| commit | fa1ac7bc957f3b8b13b97e85eac40729f16b510b (patch) | |
| tree | 0e65db62401b8057019222a8d05f6b9de452bffb | |
| parent | c82ab649e2307237b480a94dbb7df6d77a8bf71a (diff) | |
| download | opencode-fa1ac7bc957f3b8b13b97e85eac40729f16b510b.tar.gz opencode-fa1ac7bc957f3b8b13b97e85eac40729f16b510b.zip | |
feat(desktop): system notifications
| -rw-r--r-- | bun.lock | 3 | ||||
| -rw-r--r-- | packages/app/src/context/notification.tsx | 25 | ||||
| -rw-r--r-- | packages/app/src/context/platform.tsx | 3 | ||||
| -rw-r--r-- | packages/app/src/entry.tsx | 30 | ||||
| -rw-r--r-- | packages/app/src/pages/layout.tsx | 20 | ||||
| -rw-r--r-- | packages/desktop/package.json | 1 | ||||
| -rw-r--r-- | packages/desktop/src-tauri/Cargo.lock | 58 | ||||
| -rw-r--r-- | packages/desktop/src-tauri/Cargo.toml | 1 | ||||
| -rw-r--r-- | packages/desktop/src-tauri/capabilities/default.json | 5 | ||||
| -rw-r--r-- | packages/desktop/src-tauri/src/lib.rs | 1 | ||||
| -rw-r--r-- | packages/desktop/src/index.tsx | 29 |
11 files changed, 160 insertions, 16 deletions
@@ -180,6 +180,7 @@ "@tauri-apps/api": "^2", "@tauri-apps/plugin-dialog": "~2", "@tauri-apps/plugin-http": "~2", + "@tauri-apps/plugin-notification": "~2", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-os": "~2", "@tauri-apps/plugin-process": "~2", @@ -1706,6 +1707,8 @@ "@tauri-apps/plugin-http": ["@tauri-apps/[email protected]", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-/i4U/9za3mrytTgfRn5RHneKubZE/dwRmshYwyMvNRlkWjvu1m4Ma72kcbVJMZFGXpkbl+qLyWMGrihtWB76Zg=="], + "@tauri-apps/plugin-notification": ["@tauri-apps/[email protected]", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-Zw+ZH18RJb41G4NrfHgIuofJiymusqN+q8fGUIIV7vyCH+5sSn5coqRv/MWB9qETsUs97vmU045q7OyseCV3Qg=="], + "@tauri-apps/plugin-opener": ["@tauri-apps/[email protected]", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-ei/yRRoCklWHImwpCcDK3VhNXx+QXM9793aQ64YxpqVF0BDuuIlXhZgiAkc15wnPVav+IbkYhmDJIv5R326Mew=="], "@tauri-apps/plugin-os": ["@tauri-apps/[email protected]", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-n+nXWeuSeF9wcEsSPmRnBEGrRgOy6jjkSU+UVCOV8YUGKb2erhDOxis7IqRXiRVHhY8XMKks00BJ0OAdkpf6+A=="], diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx index 2b258ebd6..33d72d4f2 100644 --- a/packages/app/src/context/notification.tsx +++ b/packages/app/src/context/notification.tsx @@ -2,7 +2,9 @@ import { createStore } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" import { useGlobalSDK } from "./global-sdk" import { useGlobalSync } from "./global-sync" +import { usePlatform } from "@/context/platform" import { Binary } from "@opencode-ai/util/binary" +import { base64Encode } from "@opencode-ai/util/encode" import { EventSessionError } from "@opencode-ai/sdk/v2" import { makeAudioPlayer } from "@solid-primitives/audio" import idleSound from "@opencode-ai/ui/audio/staplebops-01.aac" @@ -43,6 +45,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi const globalSDK = useGlobalSDK() const globalSync = useGlobalSync() + const platform = usePlatform() const [store, setStore, _, ready] = persisted( "notification.v1", @@ -64,8 +67,8 @@ export const { use: useNotification, provider: NotificationProvider } = createSi const sessionID = event.properties.sessionID const [syncStore] = globalSync.child(directory) const match = Binary.search(syncStore.session, sessionID, (s) => s.id) - const isChild = match.found && syncStore.session[match.index].parentID - if (isChild) break + const session = match.found ? syncStore.session[match.index] : undefined + if (session?.parentID) break try { idlePlayer?.play() } catch {} @@ -74,25 +77,29 @@ export const { use: useNotification, provider: NotificationProvider } = createSi type: "turn-complete", session: sessionID, }) + const href = `/${base64Encode(directory)}/session/${sessionID}` + void platform.notify("Response ready", session?.title ?? sessionID, href) break } case "session.error": { const sessionID = event.properties.sessionID - if (sessionID) { - const [syncStore] = globalSync.child(directory) - const match = Binary.search(syncStore.session, sessionID, (s) => s.id) - const isChild = match.found && syncStore.session[match.index].parentID - if (isChild) break - } + const [syncStore] = globalSync.child(directory) + const match = sessionID ? Binary.search(syncStore.session, sessionID, (s) => s.id) : undefined + const session = sessionID && match?.found ? syncStore.session[match.index] : undefined + if (session?.parentID) break try { errorPlayer?.play() } catch {} + const error = "error" in event.properties ? event.properties.error : undefined setStore("list", store.list.length, { ...base, type: "error", session: sessionID ?? "global", - error: "error" in event.properties ? event.properties.error : undefined, + error, }) + const description = session?.title ?? (typeof error === "string" ? error : "An error occurred") + const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}` + void platform.notify("Session error", description, href) break } } diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 2b710e6f2..85afd1e1f 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -14,6 +14,9 @@ export type Platform = { /** Restart the app */ restart(): Promise<void> + /** Send a system notification (optional deep link) */ + notify(title: string, description?: string, href?: string): Promise<void> + /** Open native directory picker dialog (Tauri only) */ openDirectoryPickerDialog?(opts?: { title?: string; multiple?: boolean }): Promise<string | string[] | null> diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index cbcac355f..a915aa25b 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -20,6 +20,36 @@ const platform: Platform = { restart: async () => { window.location.reload() }, + notify: async (title, description, href) => { + if (!("Notification" in window)) return + + const permission = + Notification.permission === "default" + ? await Notification.requestPermission().catch(() => "denied") + : Notification.permission + + if (permission !== "granted") return + + const inView = document.visibilityState === "visible" && document.hasFocus() + if (inView) return + + await Promise.resolve() + .then(() => { + const notification = new Notification(title, { + body: description ?? "", + icon: "https://opencode.ai/favicon-96x96.png", + }) + notification.onclick = () => { + window.focus() + if (href) { + window.history.pushState(null, "", href) + window.dispatchEvent(new PopStateEvent("popstate")) + } + notification.close() + } + }) + .catch(() => undefined) + }, } render( diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 1c38f05bb..480c5eddf 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -161,27 +161,33 @@ export default function Layout(props: ParentProps) { if (e.details?.type !== "permission.updated") return const directory = e.name const permission = e.details.properties - const sessionKey = `${directory}:${permission.sessionID}` - if (seenSessions.has(sessionKey)) return - seenSessions.add(sessionKey) const currentDir = params.dir ? base64Decode(params.dir) : undefined const currentSession = params.id - if (directory === currentDir && permission.sessionID === currentSession) return const [store] = globalSync.child(directory) const session = store.session.find((s) => s.id === permission.sessionID) - if (directory === currentDir && session?.parentID === currentSession) return const sessionTitle = session?.title ?? "New session" const projectName = getFilename(directory) + const description = `${sessionTitle} in ${projectName} needs permission` + const href = `/${base64Encode(directory)}/session/${permission.sessionID}` + void platform.notify("Permission required", description, href) + + if (directory === currentDir && permission.sessionID === currentSession) return + if (directory === currentDir && session?.parentID === currentSession) return + + const sessionKey = `${directory}:${permission.sessionID}` + if (seenSessions.has(sessionKey)) return + seenSessions.add(sessionKey) + const toastId = showToast({ persistent: true, icon: "checklist", title: "Permission required", - description: `${sessionTitle} in ${projectName} needs permission`, + description, actions: [ { label: "Go to session", onClick: () => { - navigate(`/${base64Encode(directory)}/session/${permission.sessionID}`) + navigate(href) }, }, { diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 9c791f25c..3edb5baf2 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -18,6 +18,7 @@ "@tauri-apps/plugin-dialog": "~2", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-os": "~2", + "@tauri-apps/plugin-notification": "~2", "@tauri-apps/plugin-process": "~2", "@tauri-apps/plugin-shell": "~2", "@tauri-apps/plugin-store": "~2", diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index 0bf5f7013..11afce91e 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -2211,6 +2211,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" [[package]] +name = "mac-notification-sys" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65fd3f75411f4725061682ed91f131946e912859d0044d39c4ec0aac818d7621" +dependencies = [ + "cc", + "objc2 0.6.3", + "objc2-foundation 0.3.2", + "time", +] + +[[package]] name = "markup5ever" version = "0.14.1" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2385,6 +2397,20 @@ dependencies = [ ] [[package]] +name = "notify-rust" +version = "4.11.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6442248665a5aa2514e794af3b39661a8e73033b1cc5e59899e1276117ee4400" +dependencies = [ + "futures-lite", + "log", + "mac-notification-sys", + "serde", + "tauri-winrt-notification", + "zbus", +] + +[[package]] name = "num-conv" version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2758,6 +2784,7 @@ dependencies = [ "tauri-plugin-clipboard-manager", "tauri-plugin-dialog", "tauri-plugin-http", + "tauri-plugin-notification", "tauri-plugin-opener", "tauri-plugin-os", "tauri-plugin-process", @@ -4520,6 +4547,25 @@ dependencies = [ ] [[package]] +name = "tauri-plugin-notification" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01fc2c5ff41105bd1f7242d8201fdf3efd70749b82fa013a17f2126357d194cc" +dependencies = [ + "log", + "notify-rust", + "rand 0.9.2", + "serde", + "serde_json", + "serde_repr", + "tauri", + "tauri-plugin", + "thiserror 2.0.17", + "time", + "url", +] + +[[package]] name = "tauri-plugin-opener" version = "2.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" @@ -4755,6 +4801,18 @@ dependencies = [ ] [[package]] +name = "tauri-winrt-notification" +version = "0.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b1e66e07de489fe43a46678dd0b8df65e0c973909df1b60ba33874e297ba9b9" +dependencies = [ + "quick-xml 0.37.5", + "thiserror 2.0.17", + "windows", + "windows-version", +] + +[[package]] name = "tempfile" version = "3.23.0" source = "registry+https://github.com/rust-lang/crates.io-index" diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index 0463966c0..b7c238f06 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -28,6 +28,7 @@ tauri-plugin-store = "2" tauri-plugin-window-state = "2" tauri-plugin-clipboard-manager = "2" tauri-plugin-http = "2" +tauri-plugin-notification = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/packages/desktop/src-tauri/capabilities/default.json b/packages/desktop/src-tauri/capabilities/default.json index c805f623b..1b305aebe 100644 --- a/packages/desktop/src-tauri/capabilities/default.json +++ b/packages/desktop/src-tauri/capabilities/default.json @@ -8,6 +8,10 @@ "opener:default", "core:window:allow-start-dragging", "core:webview:allow-set-webview-zoom", + "core:window:allow-is-focused", + "core:window:allow-show", + "core:window:allow-unminimize", + "core:window:allow-set-focus", "shell:default", "updater:default", "dialog:default", @@ -15,6 +19,7 @@ "store:default", "window-state:default", "os:default", + "notification:default", { "identifier": "http:default", "allow": [{ "url": "http://*" }, { "url": "https://*" }, { "url": "http://*:*/*" }] diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index 3c08841ab..46c0ab256 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -198,6 +198,7 @@ pub fn run() { .plugin(tauri_plugin_opener::init()) .plugin(tauri_plugin_clipboard_manager::init()) .plugin(tauri_plugin_http::init()) + .plugin(tauri_plugin_notification::init()) .plugin(PinchZoomDisablePlugin) .invoke_handler(tauri::generate_handler