From 5acb2530b4c64bbcc50effb017c469fad175701d Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Sat, 27 Jun 2026 03:24:33 +0800 Subject: [PATCH] fix(app): centralize notification state (#34105) --- packages/app/src/app.tsx | 34 +- packages/app/src/context/notification.tsx | 588 +++++++++++++--------- packages/app/src/pages/home.tsx | 10 +- packages/app/src/pages/layout-new.tsx | 10 +- 4 files changed, 371 insertions(+), 271 deletions(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 6a1c8f268d..5b9223b5ee 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -38,7 +38,7 @@ import { HighlightsProvider } from "@/context/highlights" import { LanguageProvider, type Locale, useLanguage } from "@/context/language" import { LayoutProvider } from "@/context/layout" import { ModelsProvider } from "@/context/models" -import { NotificationProvider } from "@/context/notification" +import { NotificationProvider, useNotification } from "@/context/notification" import { PermissionProvider } from "@/context/permission" import { PromptProvider } from "@/context/prompt" import { ServerConnection, ServerProvider, serverName, useServer } from "@/context/server" @@ -316,9 +316,7 @@ function ServerScopedProviders(props: ServerScopedShellProps) { return ( - - {props.children} - + {props.children} ) @@ -345,13 +343,23 @@ function NewAppLayout(props: ParentProps) { function TargetServerScopedProviders(props: ServerScopedShellProps) { return ( - - {props.children} - + + {props.children} ) } +function MarkSessionNotificationsViewed(props: { sessionID?: () => string | undefined }) { + const notification = useNotification() + createEffect(() => { + const sessionID = props.sessionID?.() + if (!notification.ready() || !sessionID) return + if (notification.session.unseenCount(sessionID) === 0) return + notification.session.markViewed(sessionID) + }) + return null +} + function SessionProviders(props: ParentProps) { return ( @@ -560,11 +568,13 @@ export function AppInterface(props: { component={props.router ?? Router} root={(routerProps) => ( - - - {routerProps.children} - - + + + + {routerProps.children} + + + )} > diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx index 7318af084f..e12f8f1250 100644 --- a/packages/app/src/context/notification.tsx +++ b/packages/app/src/context/notification.tsx @@ -1,9 +1,9 @@ import { createStore, reconcile } from "solid-js/store" -import { type Accessor, batch, createEffect, createMemo, onCleanup } from "solid-js" -import { useParams } from "@solidjs/router" +import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js" +import { useParams, useSearchParams } from "@solidjs/router" import { createSimpleContext } from "@opencode-ai/ui/context" -import { useServerSDK } from "./server-sdk" -import { useServerSync } from "./server-sync" +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" @@ -12,6 +12,11 @@ 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 @@ -107,267 +112,360 @@ function buildNotificationIndex(list: Notification[]) { export const { use: useNotification, provider: NotificationProvider } = createSimpleContext({ name: "Notification", gate: false, - init: (props: { directory?: Accessor; sessionID?: Accessor }) => { - const params = useParams() - const serverSDK = useServerSDK() - const serverSync = useServerSync() + 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 platform = usePlatform() const settings = useSettings() const language = useLanguage() + const owner = getOwner() + const states = new Map void; state: NotificationState }>() - const empty: Notification[] = [] - - const currentDirectory = createMemo(() => { - return props.directory?.() ?? decode64(params.dir) + 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 currentSession = createMemo(() => props.sessionID?.() ?? params.id) - - const [store, setStore, _, ready] = persisted( - Persist.serverGlobal(serverSDK().scope, "notification", ["notification.v1"]), - createStore({ - list: [] as Notification[], - }), - ) - const [index, setIndex] = createStore(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 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, + }), + }), + owner ?? undefined, ) - } - - 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) - } - } + states.set(ctx.sdk.scope, root) + return root.state } 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 })) + 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) }) }) - const append = (notification: Notification) => { - const list = pruneNotifications([...store.list, notification]) - const keep = new Set(list) - const removed = store.list.filter((n) => !keep.has(n)) + onCleanup(() => states.forEach((value) => value.dispose())) - 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) => { - const activeDirectory = currentDirectory() - const activeSession = currentSession() - if (!activeDirectory) return false - if (!activeSession) return false - if (!sessionID) return false - if (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, 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, 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() - }) + const selected = () => ensure(activeServer()) return { - ready, + ready: () => selected().ready(), + ensureServerState: ensure, 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) - }) - }) - }, + 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) { - 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) - }) - }) - }, + 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 + +function createServerNotificationState(input: { + sdk: ServerSDK + sync: ServerSync + active: Accessor + directory: Accessor + sessionID: Accessor + platform: ReturnType + settings: ReturnType + language: ReturnType +}) { + 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(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, 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, 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) + }) + }) + }, + }, + } +} diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 4610608947..8243c07ab9 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -341,15 +341,15 @@ export function NewHome() { } function unseenCount(conn: ServerConnection.Any, project: LocalProject) { - if (ServerConnection.key(conn) !== server.key) return 0 - return directories(project).reduce((total, directory) => total + notification.project.unseenCount(directory), 0) + const state = notification.ensureServerState(ServerConnection.key(conn)) + return directories(project).reduce((total, directory) => total + state.project.unseenCount(directory), 0) } function clearNotifications(conn: ServerConnection.Any, project: LocalProject) { - if (ServerConnection.key(conn) !== server.key) return + const state = notification.ensureServerState(ServerConnection.key(conn)) directories(project) - .filter((directory) => notification.project.unseenCount(directory) > 0) - .forEach((directory) => notification.project.markViewed(directory)) + .filter((directory) => state.project.unseenCount(directory) > 0) + .forEach((directory) => state.project.markViewed(directory)) } function openSession(session: Session) { diff --git a/packages/app/src/pages/layout-new.tsx b/packages/app/src/pages/layout-new.tsx index f9222cdeb7..aa9f41890b 100644 --- a/packages/app/src/pages/layout-new.tsx +++ b/packages/app/src/pages/layout-new.tsx @@ -1,26 +1,18 @@ import { createEffect, Suspense, type ParentProps } from "solid-js" -import { useNavigate, useParams } from "@solidjs/router" +import { useNavigate } from "@solidjs/router" import { DebugBar } from "@/components/debug-bar" import { HelpButton } from "@/components/help-button" import { Titlebar, type TitlebarUpdate } from "@/components/titlebar" -import { useNotification } from "@/context/notification" import { usePlatform } from "@/context/platform" import { setNavigate } from "@/utils/notification-click" import { setV2Toast, ToastRegion } from "@/utils/toast" export default function NewLayout(props: ParentProps) { const platform = usePlatform() - const notification = useNotification() const navigate = useNavigate() - const params = useParams<{ id?: string }>() setNavigate(navigate) createEffect(() => setV2Toast(true)) - createEffect(() => { - if (!notification.ready() || !params.id) return - if (notification.session.unseenCount(params.id) === 0) return - notification.session.markViewed(params.id) - }) const update: TitlebarUpdate = { version: () => {