summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src
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 /packages/app/src
parentc82ab649e2307237b480a94dbb7df6d77a8bf71a (diff)
downloadopencode-fa1ac7bc957f3b8b13b97e85eac40729f16b510b.tar.gz
opencode-fa1ac7bc957f3b8b13b97e85eac40729f16b510b.zip
feat(desktop): system notifications
Diffstat (limited to 'packages/app/src')
-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
4 files changed, 62 insertions, 16 deletions
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)
},
},
{