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 /packages/app/src | |
| parent | c82ab649e2307237b480a94dbb7df6d77a8bf71a (diff) | |
| download | opencode-fa1ac7bc957f3b8b13b97e85eac40729f16b510b.tar.gz opencode-fa1ac7bc957f3b8b13b97e85eac40729f16b510b.zip | |
feat(desktop): system notifications
Diffstat (limited to 'packages/app/src')
| -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 |
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) }, }, { |
