Преглед изворни кода

feat(desktop): system notifications

Adam пре 1 месец
родитељ
комит
fa1ac7bc95

+ 3 - 0
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=="],

+ 16 - 9
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
         }
       }

+ 3 - 0
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>
 

+ 30 - 0
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(

+ 13 - 7
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)
             },
           },
           {

+ 1 - 0
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",

+ 58 - 0
packages/desktop/src-tauri/Cargo.lock

@@ -2210,6 +2210,18 @@ version = "0.1.1"
 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"
@@ -2384,6 +2396,20 @@ dependencies = [
  "memchr",
 ]
 
+[[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"
@@ -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",
@@ -4519,6 +4546,25 @@ dependencies = [
  "urlpattern",
 ]
 
+[[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"
@@ -4754,6 +4800,18 @@ dependencies = [
  "toml 0.9.8",
 ]
 
+[[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"

+ 1 - 0
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"

+ 5 - 0
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://*:*/*" }]

+ 1 - 0
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,

+ 29 - 0
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,
 }