summaryrefslogtreecommitdiffhomepage
diff options
context:
space:
mode:
authorAdam <[email protected]>2025-12-29 20:54:33 -0600
committerAdam <[email protected]>2025-12-30 04:57:35 -0600
commitfa1ac7bc957f3b8b13b97e85eac40729f16b510b (patch)
tree0e65db62401b8057019222a8d05f6b9de452bffb
parentc82ab649e2307237b480a94dbb7df6d77a8bf71a (diff)
downloadopencode-fa1ac7bc957f3b8b13b97e85eac40729f16b510b.tar.gz
opencode-fa1ac7bc957f3b8b13b97e85eac40729f16b510b.zip
feat(desktop): system notifications
-rw-r--r--bun.lock3
-rw-r--r--packages/app/src/context/notification.tsx25
-rw-r--r--packages/app/src/context/platform.tsx3
-rw-r--r--packages/app/src/entry.tsx30
-rw-r--r--packages/app/src/pages/layout.tsx20
-rw-r--r--packages/desktop/package.json1
-rw-r--r--packages/desktop/src-tauri/Cargo.lock58
-rw-r--r--packages/desktop/src-tauri/Cargo.toml1
-rw-r--r--packages/desktop/src-tauri/capabilities/default.json5
-rw-r--r--packages/desktop/src-tauri/src/lib.rs1
-rw-r--r--packages/desktop/src/index.tsx29
11 files changed, 160 insertions, 16 deletions
diff --git a/bun.lock b/bun.lock
index 9cd79e5d5..4b3ca2fc8 100644
--- a/bun.lock
+++ b/bun.lock
@@ -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![
kill_sidecar,
diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx
index 58aca8fd1..a36da4125 100644
--- a/packages/desktop/src/index.tsx
+++ b/packages/desktop/src/index.tsx
@@ -12,6 +12,8 @@ import { UPDATER_ENABLED } from "./updater"
import { createMenu } from "./menu"
import { check, Update } from "@tauri-apps/plugin-updater"
import { invoke } from "@tauri-apps/api/core"
+import { getCurrentWindow } from "@tauri-apps/api/window"
+import { isPermissionGranted, requestPermission } from "@tauri-apps/plugin-notification"
import { relaunch } from "@tauri-apps/plugin-process"
import pkg from "../package.json"
@@ -94,6 +96,33 @@ const platform: Platform = {
await relaunch()
},
+ notify: async (title, description, href) => {
+ const granted = await isPermissionGranted().catch(() => false)
+ const permission = granted ? "granted" : await requestPermission().catch(() => "denied")
+ if (permission !== "granted") return
+
+ const win = getCurrentWindow()
+ const focused = await win.isFocused().catch(() => document.hasFocus())
+ if (focused) return
+
+ await Promise.resolve()
+ .then(() => {
+ const notification = new Notification(title, { body: description ?? "" })
+ notification.onclick = () => {
+ const win = getCurrentWindow()
+ void win.show().catch(() => undefined)
+ void win.unminimize().catch(() => undefined)
+ void win.setFocus().catch(() => undefined)
+ if (href) {
+ window.history.pushState(null, "", href)
+ window.dispatchEvent(new PopStateEvent("popstate"))
+ }
+ notification.close()
+ }
+ })
+ .catch(() => undefined)
+ },
+
// @ts-expect-error
fetch: tauriFetch,
}