Download packages/app/src/context/notification.tsx from SaylorTwift/opencode: direct link, hf CLI and curl.
- Browser
- Download file 17.1 kB
-
https://huggingface.co/SaylorTwift/opencode/resolve/main/packages/app/src/context/notification.tsx
- Command line
-
hf download hf://SaylorTwift/opencode/packages/app/src/context/notification.tsx
-
curl -L -o notification.tsx https://huggingface.co/SaylorTwift/opencode/resolve/main/packages/app/src/context/notification.tsx
17.1 kB
| import { createStore, reconcile } from "solid-js/store" | |
| import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js" | |
| import { useNavigate, useParams, useSearchParams } from "@solidjs/router" | |
| import { createSimpleContext } from "@opencode-ai/ui/context" | |
| import type { ServerSDK } from "./server-sdk" | |
| import type { ServerSync } from "./server-sync" | |
| import { usePlatform } from "@/context/platform" | |
| import { useLanguage } from "@/context/language" | |
| import { useSettings } from "@/context/settings" | |
| import { base64Encode } from "@opencode-ai/core/util/encode" | |
| import { decode64 } from "@/utils/base64" | |
| import { EventSessionError } from "@opencode-ai/sdk/v2" | |
| import { Persist, persisted } from "@/utils/persist" | |
| import { playSoundById } from "@/utils/sound" | |
| import { useGlobal } from "./global" | |
| import { ServerConnection, useServer } from "./server" | |
| import { type DraftTab, useTabs } from "./tabs" | |
| import { requireServerKey } from "@/utils/session-route" | |
| import type { ServerScope } from "@/utils/server-scope" | |
| type NotificationBase = { | |
| directory?: string | |
| session?: string | |
| metadata?: unknown | |
| time: number | |
| viewed: boolean | |
| } | |
| type TurnCompleteNotification = NotificationBase & { | |
| type: "turn-complete" | |
| } | |
| type ErrorNotification = NotificationBase & { | |
| type: "error" | |
| error: EventSessionError["properties"]["error"] | |
| } | |
| export type Notification = TurnCompleteNotification | ErrorNotification | |
| type NotificationIndex = { | |
| session: { | |
| all: Record<string, Notification[]> | |
| unseen: Record<string, Notification[]> | |
| unseenCount: Record<string, number> | |
| unseenHasError: Record<string, boolean> | |
| } | |
| project: { | |
| all: Record<string, Notification[]> | |
| unseen: Record<string, Notification[]> | |
| unseenCount: Record<string, number> | |
| unseenHasError: Record<string, boolean> | |
| } | |
| } | |
| const MAX_NOTIFICATIONS = 500 | |
| const NOTIFICATION_TTL_MS = 1000 * 60 * 60 * 24 * 30 | |
| function pruneNotifications(list: Notification[]) { | |
| const cutoff = Date.now() - NOTIFICATION_TTL_MS | |
| const pruned = list.filter((n) => n.time >= cutoff) | |
| if (pruned.length <= MAX_NOTIFICATIONS) return pruned | |
| return pruned.slice(pruned.length - MAX_NOTIFICATIONS) | |
| } | |
| function createNotificationIndex(): NotificationIndex { | |
| return { | |
| session: { | |
| all: {}, | |
| unseen: {}, | |
| unseenCount: {}, | |
| unseenHasError: {}, | |
| }, | |
| project: { | |
| all: {}, | |
| unseen: {}, | |
| unseenCount: {}, | |
| unseenHasError: {}, | |
| }, | |
| } | |
| } | |
| function buildNotificationIndex(list: Notification[]) { | |
| const index = createNotificationIndex() | |
| list.forEach((notification) => { | |
| if (notification.session) { | |
| const all = index.session.all[notification.session] ?? [] | |
| index.session.all[notification.session] = [...all, notification] | |
| if (!notification.viewed) { | |
| const unseen = index.session.unseen[notification.session] ?? [] | |
| index.session.unseen[notification.session] = [...unseen, notification] | |
| index.session.unseenCount[notification.session] = unseen.length + 1 | |
| if (notification.type === "error") index.session.unseenHasError[notification.session] = true | |
| } | |
| } | |
| if (notification.directory) { | |
| const all = index.project.all[notification.directory] ?? [] | |
| index.project.all[notification.directory] = [...all, notification] | |
| if (!notification.viewed) { | |
| const unseen = index.project.unseen[notification.directory] ?? [] | |
| index.project.unseen[notification.directory] = [...unseen, notification] | |
| index.project.unseenCount[notification.directory] = unseen.length + 1 | |
| if (notification.type === "error") index.project.unseenHasError[notification.directory] = true | |
| } | |
| } | |
| }) | |
| return index | |
| } | |
| export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({ | |
| name: "Notification", | |
| gate: false, | |
| init: () => { | |
| const params = useParams<{ serverKey?: string; dir?: string; id?: string }>() | |
| const [search] = useSearchParams<{ draftId?: string }>() | |
| const global = useGlobal() | |
| const server = useServer() | |
| const tabs = useTabs() | |
| const navigate = useNavigate() | |
| const platform = usePlatform() | |
| const settings = useSettings() | |
| const language = useLanguage() | |
| const owner = getOwner() | |
| const states = new Map<ServerScope, { dispose: () => void; state: NotificationState }>() | |
| const activeServer = createMemo(() => { | |
| if (params.serverKey) return requireServerKey(params.serverKey) | |
| if (search.draftId) { | |
| const draft = tabs.store.find((tab): tab is DraftTab => tab.type === "draft" && tab.draftID === search.draftId) | |
| if (draft) return draft.server | |
| } | |
| return server.key | |
| }) | |
| const activeDirectory = createMemo(() => decode64(params.dir)) | |
| const activeSession = createMemo(() => params.id) | |
| const ensure = (key: ServerConnection.Key) => { | |
| const conn = global.servers.list().find((item) => ServerConnection.key(item) === key) | |
| if (!conn) throw new Error(`Notification server not found: ${key}`) | |
| const ctx = global.ensureServerCtx(conn) | |
| const existing = states.get(ctx.sdk.scope) | |
| if (existing) return existing.state | |
| const root = createRoot( | |
| (dispose) => ({ | |
| dispose, | |
| state: createServerNotificationState({ | |
| sdk: ctx.sdk, | |
| sync: ctx.sync, | |
| active: () => server.scope(activeServer()) === ctx.sdk.scope, | |
| directory: activeDirectory, | |
| sessionID: activeSession, | |
| platform, | |
| settings, | |
| language, | |
| navigate, | |
| }), | |
| }), | |
| owner ?? undefined, | |
| ) | |
| states.set(ctx.sdk.scope, root) | |
| return root.state | |
| } | |
| createEffect(() => { | |
| global.servers.list().forEach((conn) => ensure(ServerConnection.key(conn))) | |
| }) | |
| createEffect(() => { | |
| const scopes = new Set(global.servers.list().map((conn) => server.scope(ServerConnection.key(conn)))) | |
| states.forEach((value, scope) => { | |
| if (scopes.has(scope)) return | |
| value.dispose() | |
| states.delete(scope) | |
| }) | |
| }) | |
| onCleanup(() => states.forEach((value) => value.dispose())) | |
| const selected = () => { | |
| const list = global.servers.list() | |
| const key = activeServer() | |
| if (list.some((conn) => ServerConnection.key(conn) === key)) return ensure(key) | |
| const conn = list.find((conn) => ServerConnection.key(conn) === server.key) ?? list[0] | |
| if (!conn) throw new Error("Notification server not found") | |
| return ensure(ServerConnection.key(conn)) | |
| } | |
| return { | |
| ready: () => selected().ready(), | |
| ensureServerState: ensure, | |
| session: { | |
| all: (session: string) => selected().session.all(session), | |
| unseen: (session: string) => selected().session.unseen(session), | |
| unseenCount: (session: string) => selected().session.unseenCount(session), | |
| unseenHasError: (session: string) => selected().session.unseenHasError(session), | |
| markViewed: (session: string) => selected().session.markViewed(session), | |
| }, | |
| project: { | |
| all: (directory: string) => selected().project.all(directory), | |
| unseen: (directory: string) => selected().project.unseen(directory), | |
| unseenCount: (directory: string) => selected().project.unseenCount(directory), | |
| unseenHasError: (directory: string) => selected().project.unseenHasError(directory), | |
| markViewed: (directory: string) => selected().project.markViewed(directory), | |
| }, | |
| } | |
| }, | |
| }) | |
| type NotificationState = ReturnType<typeof createServerNotificationState> | |
| function createServerNotificationState(input: { | |
| sdk: ServerSDK | |
| sync: ServerSync | |
| active: Accessor<boolean> | |
| directory: Accessor<string | undefined> | |
| sessionID: Accessor<string | undefined> | |
| platform: ReturnType<typeof usePlatform> | |
| settings: ReturnType<typeof useSettings> | |
| language: ReturnType<typeof useLanguage> | |
| navigate: (href: string) => void | |
| }) { | |
| const serverSDK = () => input.sdk | |
| const serverSync = () => input.sync | |
| const platform = input.platform | |
| const settings = input.settings | |
| const language = input.language | |
| const empty: Notification[] = [] | |
| const currentDirectory = input.directory | |
| const currentSession = input.sessionID | |
| const [store, setStore, _, ready] = persisted( | |
| Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]), | |
| createStore({ | |
| list: [] as Notification[], | |
| }), | |
| ) | |
| const [index, setIndex] = createStore<NotificationIndex>(buildNotificationIndex(store.list)) | |
| const meta = { pruned: false, disposed: false } | |
| const updateUnseen = (scope: "session" | "project", key: string, unseen: Notification[]) => { | |
| setIndex(scope, "unseen", key, unseen) | |
| setIndex(scope, "unseenCount", key, unseen.length) | |
| setIndex( | |
| scope, | |
| "unseenHasError", | |
| key, | |
| unseen.some((notification) => notification.type === "error"), | |
| ) | |
| } | |
| const appendToIndex = (notification: Notification) => { | |
| if (notification.session) { | |
| setIndex("session", "all", notification.session, (all = []) => [...all, notification]) | |
| if (!notification.viewed) { | |
| setIndex("session", "unseen", notification.session, (unseen = []) => [...unseen, notification]) | |
| setIndex("session", "unseenCount", notification.session, (count = 0) => count + 1) | |
| if (notification.type === "error") setIndex("session", "unseenHasError", notification.session, true) | |
| } | |
| } | |
| if (notification.directory) { | |
| setIndex("project", "all", notification.directory, (all = []) => [...all, notification]) | |
| if (!notification.viewed) { | |
| setIndex("project", "unseen", notification.directory, (unseen = []) => [...unseen, notification]) | |
| setIndex("project", "unseenCount", notification.directory, (count = 0) => count + 1) | |
| if (notification.type === "error") setIndex("project", "unseenHasError", notification.directory, true) | |
| } | |
| } | |
| } | |
| const removeFromIndex = (notification: Notification) => { | |
| if (notification.session) { | |
| setIndex("session", "all", notification.session, (all = []) => all.filter((n) => n !== notification)) | |
| if (!notification.viewed) { | |
| const unseen = (index.session.unseen[notification.session] ?? empty).filter((n) => n !== notification) | |
| updateUnseen("session", notification.session, unseen) | |
| } | |
| } | |
| if (notification.directory) { | |
| setIndex("project", "all", notification.directory, (all = []) => all.filter((n) => n !== notification)) | |
| if (!notification.viewed) { | |
| const unseen = (index.project.unseen[notification.directory] ?? empty).filter((n) => n !== notification) | |
| updateUnseen("project", notification.directory, unseen) | |
| } | |
| } | |
| } | |
| createEffect(() => { | |
| if (!ready()) return | |
| if (meta.pruned) return | |
| meta.pruned = true | |
| const list = pruneNotifications(store.list) | |
| batch(() => { | |
| setStore("list", list) | |
| setIndex(reconcile(buildNotificationIndex(list), { merge: false })) | |
| }) | |
| }) | |
| const append = (notification: Notification) => { | |
| const list = pruneNotifications([...store.list, notification]) | |
| const keep = new Set(list) | |
| const removed = store.list.filter((n) => !keep.has(n)) | |
| batch(() => { | |
| if (keep.has(notification)) appendToIndex(notification) | |
| removed.forEach((n) => removeFromIndex(n)) | |
| setStore("list", list) | |
| }) | |
| } | |
| const lookup = async (directory: string, sessionID?: string) => { | |
| if (!sessionID) return undefined | |
| const sync = serverSync().ensureDirSyncContext(directory) | |
| const session = sync.session.get(sessionID) | |
| if (session) return session | |
| return sync.session | |
| .sync(sessionID) | |
| .then(() => sync.session.get(sessionID)) | |
| .catch(() => undefined) | |
| } | |
| const viewedInCurrentSession = (directory: string, sessionID?: string) => { | |
| if (!input.active()) return false | |
| const activeDirectory = currentDirectory() | |
| const activeSession = currentSession() | |
| if (!activeSession) return false | |
| if (!sessionID) return false | |
| if (activeDirectory && directory !== activeDirectory) return false | |
| return sessionID === activeSession | |
| } | |
| const handleSessionIdle = (directory: string, event: { properties: { sessionID?: string } }, time: number) => { | |
| const sessionID = event.properties.sessionID | |
| void lookup(directory, sessionID).then((session) => { | |
| if (meta.disposed) return | |
| if (!session) return | |
| if (session.parentID) return | |
| if (settings.sounds.agentEnabled()) { | |
| void playSoundById(settings.sounds.agent()) | |
| } | |
| append({ | |
| directory, | |
| time, | |
| viewed: viewedInCurrentSession(directory, sessionID), | |
| type: "turn-complete", | |
| session: sessionID, | |
| }) | |
| const href = `/${base64Encode(directory)}/session/${sessionID}` | |
| if (settings.notifications.agent()) { | |
| void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, () => | |
| input.navigate(href), | |
| ) | |
| } | |
| }) | |
| } | |
| const handleSessionError = ( | |
| directory: string, | |
| event: { properties: { sessionID?: string; error?: EventSessionError["properties"]["error"] } }, | |
| time: number, | |
| ) => { | |
| const sessionID = event.properties.sessionID | |
| void lookup(directory, sessionID).then((session) => { | |
| if (meta.disposed) return | |
| if (session?.parentID) return | |
| if (settings.sounds.errorsEnabled()) { | |
| void playSoundById(settings.sounds.errors()) | |
| } | |
| const error = "error" in event.properties ? event.properties.error : undefined | |
| append({ | |
| directory, | |
| time, | |
| viewed: viewedInCurrentSession(directory, sessionID), | |
| type: "error", | |
| session: sessionID ?? "global", | |
| error, | |
| }) | |
| const description = | |
| session?.title ?? | |
| (typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription")) | |
| const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}` | |
| if (settings.notifications.errors()) { | |
| void platform.notify(language.t("notification.session.error.title"), description, () => input.navigate(href)) | |
| } | |
| }) | |
| } | |
| const unsub = serverSDK().event.listen((e) => { | |
| const event = e.details | |
| if (event.type !== "session.idle" && event.type !== "session.error") return | |
| const directory = e.name | |
| const time = Date.now() | |
| if (event.type === "session.idle") { | |
| handleSessionIdle(directory, event, time) | |
| return | |
| } | |
| handleSessionError(directory, event, time) | |
| }) | |
| onCleanup(() => { | |
| meta.disposed = true | |
| unsub() | |
| }) | |
| return { | |
| ready, | |
| session: { | |
| all(session: string) { | |
| return index.session.all[session] ?? empty | |
| }, | |
| unseen(session: string) { | |
| return index.session.unseen[session] ?? empty | |
| }, | |
| unseenCount(session: string) { | |
| return index.session.unseenCount[session] ?? 0 | |
| }, | |
| unseenHasError(session: string) { | |
| return index.session.unseenHasError[session] ?? false | |
| }, | |
| markViewed(session: string) { | |
| const unseen = index.session.unseen[session] ?? empty | |
| if (!unseen.length) return | |
| const projects = [ | |
| ...new Set(unseen.flatMap((notification) => (notification.directory ? [notification.directory] : []))), | |
| ] | |
| batch(() => { | |
| setStore("list", (n) => n.session === session && !n.viewed, "viewed", true) | |
| updateUnseen("session", session, []) | |
| projects.forEach((directory) => { | |
| const next = (index.project.unseen[directory] ?? empty).filter( | |
| (notification) => notification.session !== session, | |
| ) | |
| updateUnseen("project", directory, next) | |
| }) | |
| }) | |
| }, | |
| }, | |
| project: { | |
| all(directory: string) { | |
| return index.project.all[directory] ?? empty | |
| }, | |
| unseen(directory: string) { | |
| return index.project.unseen[directory] ?? empty | |
| }, | |
| unseenCount(directory: string) { | |
| return index.project.unseenCount[directory] ?? 0 | |
| }, | |
| unseenHasError(directory: string) { | |
| return index.project.unseenHasError[directory] ?? false | |
| }, | |
| markViewed(directory: string) { | |
| const unseen = index.project.unseen[directory] ?? empty | |
| if (!unseen.length) return | |
| const sessions = [ | |
| ...new Set(unseen.flatMap((notification) => (notification.session ? [notification.session] : []))), | |
| ] | |
| batch(() => { | |
| setStore("list", (n) => n.directory === directory && !n.viewed, "viewed", true) | |
| updateUnseen("project", directory, []) | |
| sessions.forEach((session) => { | |
| const next = (index.session.unseen[session] ?? empty).filter( | |
| (notification) => notification.directory !== directory, | |
| ) | |
| updateUnseen("session", session, next) | |
| }) | |
| }) | |
| }, | |
| }, | |
| } | |
| } | |