diff --git a/packages/app/src/home/projects/controller.tsx b/packages/app/src/home/projects/controller.tsx
index 7cfa1ad37bc6..1d9eec3b8b6a 100644
--- a/packages/app/src/home/projects/controller.tsx
+++ b/packages/app/src/home/projects/controller.tsx
@@ -149,16 +149,14 @@ export function createHomeProjectsController(home: HomeController) {
project: project.worktree,
})
},
- unseenCount: (conn: ServerConnection.Any, project: LocalProject) => {
+ hasUnread: (conn: ServerConnection.Any, project: LocalProject) => {
const notification = global.ensureServerCtx(conn).notification
- return directories(project).reduce((total, directory) => total + notification.project.unseenCount(directory), 0)
+ return directories(project).some((directory) => notification.project.hasUnread(directory))
},
clearNotifications: (conn: ServerConnection.Any, project: LocalProject) => {
const notification = global.ensureServerCtx(conn).notification
- directories(project)
- .filter((directory) => notification.project.unseenCount(directory) > 0)
- .forEach((directory) => notification.project.markViewed(directory))
+ directories(project).forEach((directory) => notification.project.markViewed(directory))
},
choose: (conn: ServerConnection.Any) => {
if (authenticate(conn, (next) => choose(next))) return
diff --git a/packages/app/src/home/projects/region.tsx b/packages/app/src/home/projects/region.tsx
index 367557f64a37..19e1018d53fa 100644
--- a/packages/app/src/home/projects/region.tsx
+++ b/packages/app/src/home/projects/region.tsx
@@ -20,7 +20,7 @@ export function HomeProjects(props: {
projectsForServer={props.projects.server.projects}
collapsed={props.projects.server.collapsed}
canRevealProject={props.projects.project.canReveal}
- unseenCount={props.projects.project.unseenCount}
+ hasUnread={props.projects.project.hasUnread}
onWheel={props.scroll.viewport.containWheel}
onChooseProject={props.projects.project.choose}
onFocusServer={props.projects.server.focus}
diff --git a/packages/app/src/home/projects/view.tsx b/packages/app/src/home/projects/view.tsx
index b69dbd982446..1454f1685a67 100644
--- a/packages/app/src/home/projects/view.tsx
+++ b/packages/app/src/home/projects/view.tsx
@@ -53,7 +53,7 @@ export type HomeProjectsViewProps = {
projectsForServer: (server: ServerConnection.Any) => LocalProject[]
collapsed: (server: ServerConnection.Any) => boolean
canRevealProject: (server: ServerConnection.Any) => boolean
- unseenCount: (server: ServerConnection.Any, project: LocalProject) => number
+ hasUnread: (server: ServerConnection.Any, project: LocalProject) => boolean
onWheel: (event: WheelEvent) => void
onChooseProject: (server: ServerConnection.Any) => void
onFocusServer: (server: ServerConnection.Any) => void
@@ -571,7 +571,7 @@ function HomeProjectSlot(
selected={
props.selection.server === ServerConnection.key(props.server) && props.selection.directory === props.worktree
}
- unseen={props.unseenCount(props.server, project())}
+ unread={props.hasUnread(props.server, project())}
/>
)
}
@@ -649,7 +649,7 @@ function HomeProjectRow(
index: number
serverSelected: boolean
selected: boolean
- unseen: number
+ unread: boolean
},
) {
const platform = usePlatform()
@@ -782,7 +782,7 @@ function HomeProjectRow(
props.onClearNotifications(props.server, props.project)}
>
{props.language.t("sidebar.project.clearNotifications")}
diff --git a/packages/app/src/home/sessions/controller.tsx b/packages/app/src/home/sessions/controller.tsx
index ea010619fca9..3e27ebc2f051 100644
--- a/packages/app/src/home/sessions/controller.tsx
+++ b/packages/app/src/home/sessions/controller.tsx
@@ -437,6 +437,8 @@ export function HomeSessionStatusController(props: {
() => props.server,
() => props.record.session.id,
() => true,
+ // Home rows can come from the fetched index before the session is loaded.
+ () => props.record.session,
)
return props.render({
diff --git a/packages/app/src/runtime/persistence/consumers.test.ts b/packages/app/src/runtime/persistence/consumers.test.ts
index 6229c0a776fa..54e12b375c79 100644
--- a/packages/app/src/runtime/persistence/consumers.test.ts
+++ b/packages/app/src/runtime/persistence/consumers.test.ts
@@ -7,7 +7,6 @@ import { FileViewsSchema } from "@/workspaces/files/view-cache"
import { languageSchema } from "@/runtime/i18n/language"
import { HomeServersSchema } from "@/home/projects/controller"
import { ModelProvidersSchema } from "@/settings/models/models"
-import { NotificationStore, type Notification } from "@/shell/notifications/notification"
import { IconState, ProjectState, VcsState } from "@/runtime/server/persistence"
function stored>(
@@ -21,11 +20,6 @@ function stored>(
return { name, decode: Schema.decodeUnknownSync(codec), encode: Schema.encodeUnknownSync(codec), cases }
}
-const notifications: Notification[] = [
- { type: "turn-complete", time: 123, viewed: false, session: "session-1" },
- { type: "error", time: 124, viewed: true, error: { type: "api", message: "failed", status: 500 } },
-]
-
const collapsed = [
[{}, { collapsed: {} }],
[{ collapsed: [] }, { collapsed: {} }],
@@ -51,22 +45,6 @@ describe("persisted consumer schemas", () => {
stored("workspace tip", WorkspaceTipSchema, { dismissedAt: 0 }, [[{ dismissedAt: 123 }, { dismissedAt: 123 }]]),
stored("home server collapse", HomeServersSchema, { collapsed: {} }, collapsed),
stored("model provider collapse", ModelProvidersSchema, { collapsed: {} }, collapsed),
- stored("notifications", NotificationStore, { list: [] }, [
- [
- {
- list: [
- notifications[0],
- null,
- { type: "unknown", time: 123, viewed: false },
- { ...notifications[1], error: "invalid" },
- notifications[1],
- ],
- },
- { list: notifications },
- ],
- [{}, { list: [] }],
- [{ list: {} }, { list: [] }],
- ]),
stored("VCS cache", VcsState, { value: undefined }, [
[{}, { value: undefined }],
[{ value: null }, { value: undefined }],
diff --git a/packages/app/src/session/route.tsx b/packages/app/src/session/route.tsx
index c64822659ef9..cfd75e3fa148 100644
--- a/packages/app/src/session/route.tsx
+++ b/packages/app/src/session/route.tsx
@@ -1,5 +1,7 @@
import { ErrorBoundary, createEffect, createMemo, Show, type ParentProps } from "solid-js"
import { useParams } from "@solidjs/router"
+import { createStore } from "solid-js/store"
+import { makeEventListener } from "@solid-primitives/event-listener"
import { DataProvider } from "@opencode/session-ui/context"
import { SessionUserMessage } from "@opencode/session-ui/message"
import { TextShimmer } from "@opencode/ui/text-shimmer"
@@ -36,7 +38,7 @@ export function TargetSessionRouteContent() {
return (
<>
- params.id} />
+ params.id} />
@@ -186,14 +188,20 @@ function SessionPage() {
return
}
-function MarkSessionNotificationsViewed(props: { sessionID: () => string | undefined }) {
+function MarkSessionViewed(props: { sessionID: () => string | undefined }) {
const notification = useNotification()
+ const [focus, setFocus] = createStore({ window: document.hasFocus() })
+ const update = () => setFocus("window", document.hasFocus())
+ makeEventListener(window, "focus", update)
+ makeEventListener(window, "blur", update)
+ makeEventListener(document, "visibilitychange", update)
+
+ // Viewed state is shared by every client, so only a focused window reports what its user has seen.
+ // markViewed reads the session's idle watermark, so a turn finishing while it stays open re-runs this.
createEffect(() => {
const sessionID = props.sessionID()
- if (!notification.ready() || !sessionID) return
-
- if (notification.session.unseenCount(sessionID) === 0) return
+ if (!sessionID || !focus.window) return
notification.session.markViewed(sessionID)
})
diff --git a/packages/app/src/settings/workspaces/project-options.tsx b/packages/app/src/settings/workspaces/project-options.tsx
index 4c8ac5f94a81..fff00f795a1a 100644
--- a/packages/app/src/settings/workspaces/project-options.tsx
+++ b/packages/app/src/settings/workspaces/project-options.tsx
@@ -27,18 +27,13 @@ export const ProjectOptions: Component<{
const revealProject = useRevealProject()
const context = () => global.ensureServerCtx(props.server)
- const unseen = () =>
- [props.project.worktree, ...(props.project.sandboxes ?? [])].reduce(
- (total, directory) => total + context().notification.project.unseenCount(directory),
- 0,
- )
+ const directories = () => [props.project.worktree, ...(props.project.sandboxes ?? [])]
+
+ const hasUnread = () => directories().some((directory) => context().notification.project.hasUnread(directory))
const clearNotifications = () => {
const notification = context().notification
- const directories = [props.project.worktree, ...(props.project.sandboxes ?? [])]
- directories
- .filter((directory) => notification.project.unseenCount(directory) > 0)
- .forEach((directory) => notification.project.markViewed(directory))
+ directories().forEach((directory) => notification.project.markViewed(directory))
}
const close = () => {
@@ -73,7 +68,7 @@ export const ProjectOptions: Component<{
revealProject.reveal(props.server, props.project)}>{language.t(fileManagerApp(platform.os ?? "unknown").actionLabel)}
-
+
{language.t("sidebar.project.clearNotifications")}
diff --git a/packages/app/src/shell/layout/project-avatar-state.ts b/packages/app/src/shell/layout/project-avatar-state.ts
index a2750b4c784d..d4fe45146bff 100644
--- a/packages/app/src/shell/layout/project-avatar-state.ts
+++ b/packages/app/src/shell/layout/project-avatar-state.ts
@@ -1,4 +1,5 @@
import { createMemo, type Accessor } from "solid-js"
+import type { SessionInfo } from "@opencode/client/promise"
import { useGlobal, useServerCtx } from "@/runtime/server/runtime"
import { sessionPermissionRequest, sessionFormRequest } from "@/session/requests/session-request-tree"
import { ServerConnection } from "@/runtime/server/registry"
@@ -8,6 +9,7 @@ export function useSessionTabAvatarState(
server: Accessor,
sessionId: Accessor,
root?: Accessor,
+ snapshot?: Accessor,
) {
const global = useGlobal()
const settings = useSettings()
@@ -52,7 +54,7 @@ export function useSessionTabAvatarState(
const needsAttention = createMemo(() => hasPermissions() || hasForms())
const unread = createMemo(
- () => needsAttention() || (serverCtx()?.notification.session.unseenCount(sessionId()) ?? 0) > 0,
+ () => needsAttention() || (serverCtx()?.notification.session.unread(sessionId(), snapshot?.()) ?? false),
)
const loading = createMemo(() => {
diff --git a/packages/app/src/shell/notifications/notification-alert.test.ts b/packages/app/src/shell/notifications/notification-alert.test.ts
deleted file mode 100644
index c27968e249ab..000000000000
--- a/packages/app/src/shell/notifications/notification-alert.test.ts
+++ /dev/null
@@ -1,103 +0,0 @@
-import { afterAll, beforeAll, expect, mock, test } from "bun:test"
-import { createRoot } from "solid-js"
-import { createStore } from "solid-js/store"
-import type { ServerSDK } from "@/runtime/server/client"
-import type { Data } from "@opencode/client/solid"
-import type { ServerConnection } from "@/runtime/server/registry"
-import { ServerScope } from "@/runtime/server/scope"
-import type { Tab } from "@/shell/tabs/tabs"
-
-const server = "local\nhttp://localhost:4096" as ServerConnection.Key
-
-const session = { id: "session-1", title: "Test session", location: { directory: "/project" } }
-
-const alerts: string[] = []
-
-const tabs: { store: Tab[] } = { store: [] }
-
-let createServerNotificationState: typeof import("./notification").createServerNotificationState
-
-let storage: typeof import("@/runtime/persistence/storage")
-
-beforeAll(async () => {
- storage = await import("@/runtime/persistence/storage")
- const { sessionIDHasOpenTab } = await import("@/shell/tabs/tabs")
- mock.module("@/runtime/platform/platform", () => ({
- usePlatform: () => ({
- platform: "web",
- notify: async (title: string) => {
- alerts.push(title)
- },
- }),
- }))
- mock.module("@/settings/model", () => ({
- useSettings: () => ({
- sounds: { agentEnabled: () => false, errorsEnabled: () => false },
- notifications: { agent: () => true, errors: () => true },
- }),
- }))
- mock.module("@/runtime/i18n/language", () => ({ useLanguage: () => ({ t: (key: string) => key }) }))
- mock.module("@/shell/tabs/tabs", () => ({
- useTabs: () => tabs,
- sessionIDHasOpenTab,
- }))
- mock.module("@/runtime/persistence/storage", () => ({
- ...storage,
- persisted: () => {
- const [store, setStore] = createStore({ list: [] })
-
- return [store, setStore, undefined, () => false]
- },
- }))
- createServerNotificationState = (await import("./notification")).createServerNotificationState
-})
-
-afterAll(() => mock.module("@/runtime/persistence/storage", () => storage))
-
-test.each([
- ["session.execution.succeeded", "notification.session.responseReady.title"],
- ["session.execution.failed", "notification.session.error.title"],
-] as const)("system alert for %s requires an open session tab", async (type, title) => {
- alerts.length = 0
- tabs.store = [{ type: "session", server, sessionId: "another-session" }]
- let listener: ((event: unknown) => void) | undefined
-
- const dispose = createRoot((dispose) => {
- const state = createServerNotificationState({
- key: server,
- sdk: {
- scope: ServerScope.local,
- event: {
- listen: (fn: typeof listener) => {
- listener = fn
-
- return () => {}
- },
- },
- } as unknown as ServerSDK,
- data: { session: { get: () => session } } as unknown as Data,
- coordinator: { system: async (_id: string, fn: () => Promise) => fn() },
- } as Parameters[0])
-
- return { dispose, state }
- })
-
- listener?.({
- type,
- id: "event-1",
- data: { sessionID: session.id, error: { type: "api", message: "failed", status: 500 } },
- })
- await new Promise((resolve) => setTimeout(resolve, 0))
- expect(dispose.state.session.all(session.id)).toHaveLength(1)
- expect(alerts).toEqual([])
-
- tabs.store = [{ type: "session", server, sessionId: session.id }]
- listener?.({
- type,
- id: "event-2",
- data: { sessionID: session.id, error: { type: "api", message: "failed", status: 500 } },
- })
- await new Promise((resolve) => setTimeout(resolve, 0))
- expect(alerts).toEqual([title])
- dispose.dispose()
-})
diff --git a/packages/app/src/shell/notifications/notification.tsx b/packages/app/src/shell/notifications/notification.tsx
index 2be7b09effed..b841a201630f 100644
--- a/packages/app/src/shell/notifications/notification.tsx
+++ b/packages/app/src/shell/notifications/notification.tsx
@@ -1,53 +1,20 @@
-import { createStore, reconcile } from "solid-js/store"
+import { createEffect, createMemo, onCleanup, untrack } from "solid-js"
+import { createStore } from "solid-js/store"
import { Schema } from "effect"
-import { SessionError } from "@opencode/schema/session-error"
-import { batch, createEffect, onCleanup } from "solid-js"
+import type { SessionInfo } from "@opencode/client/promise"
import type { ServerSDK } from "@/runtime/server/client"
import type { Data } from "@opencode/client/solid"
import { usePlatform } from "@/runtime/platform/platform"
import { useLanguage } from "@/runtime/i18n/language"
import { useSettings } from "@/settings/model"
-import { Persist, persisted } from "@/runtime/persistence/storage"
+import { Persist, persisted, removePersisted } from "@/runtime/persistence/storage"
import { Persistence } from "@/runtime/persistence/schema"
import { playSoundById } from "@/shell/notifications/sound"
import type { createNotificationCoordinator } from "@/shell/notifications/coordinator"
import { ServerConnection } from "@/runtime/server/registry"
import { sessionIDHasOpenTab, useTabs } from "@/shell/tabs/tabs"
-import { sessionHref } from "@/shell/routes/session"
import { useServer } from "@/runtime/server/current"
-const NotificationBase = {
- directory: Schema.optional(Schema.String),
- session: Schema.optional(Schema.String),
- metadata: Schema.optional(Schema.Unknown),
- time: Schema.Finite,
- viewed: Schema.Boolean,
-}
-
-export const Notification = Schema.Union([
- Persistence.struct({ ...NotificationBase, type: Schema.Literal("turn-complete") }),
- Persistence.struct({ ...NotificationBase, type: Schema.Literal("error"), error: SessionError.Error }),
-])
-
-export type Notification = typeof Notification.Type
-
-export const NotificationStore = Persistence.struct({ list: Persistence.array(Notification) })
-
-type NotificationIndex = {
- session: {
- all: Record
- unseen: Record
- unseenCount: Record
- unseenHasError: Record
- }
- project: {
- all: Record
- unseen: Record
- unseenCount: Record
- unseenHasError: Record
- }
-}
-
type NotificationTabs = Pick, "addSessionTab" | "rememberSessionRoute" | "select">
export function openNotificationSession(tabs: NotificationTabs, server: ServerConnection.Key, sessionID: string) {
@@ -58,69 +25,33 @@ export function openNotificationSession(tabs: NotificationTabs, server: ServerCo
tabs.select(tab)
}
-const MAX_NOTIFICATIONS = 500
+/** A session is unread when its latest terminal execution is newer than the last one any client viewed. */
+function isSessionUnread(session?: Pick) {
+ const time = session?.time
-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)
+ return time?.idle !== undefined && (time.viewed === undefined || time.idle > time.viewed)
}
-function createNotificationIndex(): NotificationIndex {
- return {
- session: {
- all: {},
- unseen: {},
- unseenCount: {},
- unseenHasError: {},
- },
- project: {
- all: {},
- unseen: {},
- unseenCount: {},
- unseenHasError: {},
- },
- }
-}
+// The list Desktop kept before unread state moved to the server. Until its viewed state reaches the server, unread
+// state still honours it, so sessions read here don't light up while the server catches up.
+const LegacyNotificationStore = Persistence.struct({
+ list: Schema.optional(
+ Persistence.array(Persistence.struct({ session: Schema.optional(Schema.String), viewed: Schema.Boolean })),
+ ),
+ // When this client first found the list. Sessions that went idle later are new, whatever the list says.
+ started: Schema.optional(Schema.Finite),
+})
-function buildNotificationIndex(list: Notification[]) {
- const index = createNotificationIndex()
+// The carry-over waits this long after connecting so it doesn't compete with startup requests.
+const LEGACY_MIGRATION_DELAY = 5_000
- list.forEach((notification) => {
- if (notification.session) {
- const all = index.session.all[notification.session] ?? []
- index.session.all[notification.session] = [...all, notification]
+const LEGACY_MIGRATION_PAGE = 200
- 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
+const LEGACY_MIGRATION_CONCURRENCY = 4
- if (notification.type === "error") index.session.unseenHasError[notification.session] = true
- }
- }
+const VIEW_RETRY_DELAY = 250
- 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
-}
+const VIEW_RETRY_MAX_DELAY = 5_000
export function createServerNotificationState(input: {
sdk: ServerSDK
@@ -132,99 +63,149 @@ export function createServerNotificationState(input: {
const settings = useSettings()
const language = useLanguage()
const tabs = useTabs()
- const empty: Notification[] = []
+ const disposed = { value: false }
+ // Idle watermarks already reported per root session, so reactive re-runs don't re-post them.
+ const acknowledged = new Map()
+ // A failed view report re-runs the tracking caller with backoff, like the TUI.
+ const [state, setState] = createStore({ viewRetry: 0, legacyMigrated: false })
+ let viewRetryTimer: ReturnType | undefined
+ let viewRetryAttempt = 0
+ onCleanup(() => clearTimeout(viewRetryTimer))
- const [store, setStore, _, ready] = persisted(
- Persist.serverGlobal(input.sdk.scope, "notification"),
- NotificationStore,
- { list: [] },
- )
+ const legacyTarget = Persist.serverGlobal(input.sdk.scope, "notification")
+ const [legacy, setLegacy, , legacyReady] = persisted(legacyTarget, LegacyNotificationStore, {}, platform)
+ const started = Date.now()
+ const legacyStarted = () => legacy.started ?? started
- const [index, setIndex] = createStore(buildNotificationIndex(store.list))
+ // Sessions the old list still had unread, while its viewed state hasn't reached the server yet.
+ const legacyUnseen = createMemo(() => {
+ const list = legacy.list
- const meta = { pruned: false, disposed: false }
+ if (!list || state.legacyMigrated) return
- 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"),
- )
+ return new Set(list.flatMap((item) => (!item.viewed && item.session ? [item.session] : [])))
+ })
+
+ // Idle watermarks the carry-over reported, so rows drawn from an older fetched copy of a session stay read.
+ const legacyReported = new Map()
+
+ const unread = (session?: Pick) => {
+ const idle = session?.time.idle
+
+ // Until the old list is read, a session it had read could flash unread.
+ if (!legacyReady() || !session || idle === undefined || !isSessionUnread(session)) return false
+
+ if (idle <= (legacyReported.get(session.id) ?? -Infinity)) return false
+ const unseen = legacyUnseen()
+
+ return !unseen || unseen.has(session.id) || idle > legacyStarted()
}
- const appendToIndex = (notification: Notification) => {
- if (notification.session) {
- setIndex("session", "all", notification.session, (all = []) => [...all, notification])
+ // Carry the old list's viewed state to the server in the background: page through every root session and report
+ // the ones the server has unread but this client shows read. Then delete the list. A failure keeps it, and the
+ // next connection retries; reports already made are skipped because the server then has them read.
+ let legacyMigration: "idle" | "scheduled" | "running" = "idle"
- if (!notification.viewed) {
- setIndex("session", "unseen", notification.session, (unseen = []) => [...unseen, notification])
- setIndex("session", "unseenCount", notification.session, (count = 0) => count + 1)
+ const migrateLegacy = async () => {
+ legacyMigration = "running"
+ let cursor: string | undefined
- if (notification.type === "error") setIndex("session", "unseenHasError", notification.session, true)
+ for (;;) {
+ const page = await input.sdk.api.session.list({
+ limit: LEGACY_MIGRATION_PAGE,
+ order: "desc",
+ parentID: null,
+ cursor,
+ })
+
+ const hidden = page.data.flatMap((session) =>
+ session.time.idle !== undefined && isSessionUnread(session) && !unread(session)
+ ? [{ sessionID: session.id, idle: session.time.idle }]
+ : [],
+ )
+
+ for (let index = 0; index < hidden.length; index += LEGACY_MIGRATION_CONCURRENCY) {
+ if (disposed.value) return
+
+ await Promise.all(
+ hidden.slice(index, index + LEGACY_MIGRATION_CONCURRENCY).map(async (report) => {
+ await input.sdk.api.session.view(report)
+ legacyReported.set(report.sessionID, report.idle)
+ }),
+ )
}
+
+ if (page.data.length === 0 || !page.cursor.next) break
+ cursor = page.cursor.next
}
- 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)
- }
- }
+ removePersisted(legacyTarget, platform)
+ setState("legacyMigrated", true)
}
createEffect(() => {
- if (!ready()) return
+ if (!legacyReady() || !legacyUnseen()) return
- if (meta.pruned) return
- meta.pruned = true
- const list = pruneNotifications(store.list)
- batch(() => {
- setStore("list", list)
- setIndex(reconcile(buildNotificationIndex(list), { merge: false }))
+ if (untrack(() => legacy.started) === undefined) setLegacy("started", started)
+
+ if (input.sdk.connection.status() !== "connected" || legacyMigration !== "idle") return
+ legacyMigration = "scheduled"
+
+ const timer = setTimeout(
+ () =>
+ void migrateLegacy().catch(() => {
+ legacyMigration = "idle"
+ }),
+ LEGACY_MIGRATION_DELAY,
+ )
+
+ onCleanup(() => {
+ clearTimeout(timer)
+
+ if (legacyMigration === "scheduled") legacyMigration = "idle"
})
})
- const append = (notification: Notification) => {
- const list = pruneNotifications([...store.list, notification])
- const keep = new Set(list)
- const removed = store.list.filter((n) => !keep.has(n))
+ const roots = (directory: string) =>
+ input.data.session
+ .list()
+ .filter(
+ (session) =>
+ !session.parentID && session.time.archived === undefined && session.location.directory === directory,
+ )
- batch(() => {
- if (keep.has(notification)) appendToIndex(notification)
- removed.forEach((n) => removeFromIndex(n))
- setStore("list", list)
- })
+ const view = (sessionID: string) => {
+ void state.viewRetry
+ const root = input.data.session.root(sessionID)
+ const session = input.data.session.get(root)
+ const idle = session?.time.idle
+
+ if (idle === undefined || !isSessionUnread(session) || acknowledged.get(root) === idle) return
+ acknowledged.set(root, idle)
+ void input.sdk.api.session.view({ sessionID: root, idle }).then(
+ () => {
+ clearTimeout(viewRetryTimer)
+ viewRetryTimer = undefined
+ viewRetryAttempt = 0
+ },
+ () => {
+ if (acknowledged.get(root) !== idle) return
+ acknowledged.delete(root)
+
+ if (viewRetryTimer) return
+
+ const delay = Math.min(VIEW_RETRY_DELAY * 2 ** viewRetryAttempt, VIEW_RETRY_MAX_DELAY)
+ viewRetryAttempt++
+ viewRetryTimer = setTimeout(() => {
+ viewRetryTimer = undefined
+ setState("viewRetry", (value) => value + 1)
+ }, delay)
+ },
+ )
}
- const lookup = async (sessionID?: string) => {
- if (!sessionID) return undefined
+ // Loading the session keeps a background completion visible in the server-derived unread state.
+ const lookup = async (sessionID: string) => {
const session = input.data.session.get(sessionID)
if (session) return session
@@ -235,13 +216,9 @@ export function createServerNotificationState(input: {
.catch(() => undefined)
}
- const viewedInCurrentSession = (sessionID: string) => {
- return typeof location !== "undefined" && location.pathname === sessionHref(input.key, sessionID)
- }
-
- const handleSessionIdle = (sessionID: string, eventID: string, time: number) => {
+ const handleSessionIdle = (sessionID: string, eventID: string) => {
void lookup(sessionID).then((session) => {
- if (meta.disposed) return
+ if (disposed.value) return
if (!session) return
@@ -253,14 +230,6 @@ export function createServerNotificationState(input: {
void input.coordinator.sound(`${input.key}\0${eventID}`, () => playSoundById(settings.sounds.agent()))
}
- append({
- directory: session.location.directory,
- time,
- viewed: viewedInCurrentSession(sessionID),
- type: "turn-complete",
- session: sessionID,
- })
-
if (hasOpenTab && settings.notifications.agent()) {
void input.coordinator.system(`${input.key}\0${eventID}`, () =>
platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, () =>
@@ -271,9 +240,9 @@ export function createServerNotificationState(input: {
})
}
- const handleSessionError = (sessionID: string, error: SessionError.Error, eventID: string, time: number) => {
+ const handleSessionError = (sessionID: string, eventID: string) => {
void lookup(sessionID).then((session) => {
- if (meta.disposed) return
+ if (disposed.value) return
if (session?.parentID) return
@@ -283,18 +252,7 @@ export function createServerNotificationState(input: {
void input.coordinator.sound(`${input.key}\0${eventID}`, () => playSoundById(settings.sounds.errors()))
}
- append({
- directory: session?.location.directory,
- time,
- viewed: viewedInCurrentSession(sessionID),
- type: "error",
- session: sessionID,
- error,
- })
-
- const description =
- session?.title ??
- (typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription"))
+ const description = session?.title ?? language.t("notification.session.error.fallbackDescription")
if (hasOpenTab && settings.notifications.errors()) {
void input.coordinator.system(`${input.key}\0${eventID}`, () =>
@@ -307,94 +265,38 @@ export function createServerNotificationState(input: {
}
const unsub = input.sdk.event.listen((event) => {
- if (event.type !== "session.execution.succeeded" && event.type !== "session.execution.failed") return
-
- const time = Date.now()
-
if (event.type === "session.execution.failed") {
- handleSessionError(event.data.sessionID, event.data.error, event.id, time)
+ handleSessionError(event.data.sessionID, event.id)
return
}
- handleSessionIdle(event.data.sessionID, event.id, time)
+ if (event.type === "session.execution.succeeded") handleSessionIdle(event.data.sessionID, event.id)
})
onCleanup(() => {
- meta.disposed = true
+ disposed.value = 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)
- })
- })
+ /**
+ * Whether the session's root has an execution result no client has viewed yet. `snapshot` stands in for a root
+ * session that isn't loaded, such as a row from the fetched home index.
+ */
+ unread(sessionID: string, snapshot?: SessionInfo) {
+ return unread(input.data.session.get(input.data.session.root(sessionID)) ?? snapshot)
},
+ /** Reports the session's latest idle transition to the server as viewed. */
+ markViewed: view,
},
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
+ hasUnread(directory: string) {
+ return roots(directory).some((session) => unread(session))
},
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)
- })
- })
+ roots(directory).forEach((session) => view(session.id))
},
},
}
diff --git a/packages/app/test-browser/server-notification-state.test.ts b/packages/app/test-browser/server-notification-state.test.ts
new file mode 100644
index 000000000000..7afeda3fa9bd
--- /dev/null
+++ b/packages/app/test-browser/server-notification-state.test.ts
@@ -0,0 +1,295 @@
+import { beforeAll, expect, mock, setSystemTime, test, vi } from "bun:test"
+import { createEffect, createRoot } from "solid-js"
+import { createStore } from "solid-js/store"
+import type { OpenCodeEvent } from "@opencode/client/promise"
+import type { ServerConnection } from "@/runtime/server/registry"
+import { ServerScope } from "@/runtime/server/scope"
+import type { Tab } from "@/shell/tabs/tabs"
+
+// SAFETY: this is the key format `ServerConnection.key` produces for a local server.
+const server = "local\nhttp://localhost:4096" as ServerConnection.Key
+
+const session = { id: "session-1", title: "Test session", location: { directory: "/project" } }
+
+const alerts: string[] = []
+
+const tabs = { store: new Array() }
+
+let createServerNotificationState: typeof import("@/shell/notifications/notification").createServerNotificationState
+
+// Each test fakes only the parts of the SDK, data store and coordinator that the notification state reads.
+const createState = (input: { sdk: object; data: object; coordinator: object }) =>
+ // SAFETY: the fakes cover every member the notification state reaches in that test.
+ createServerNotificationState({ key: server, ...input } as never)
+
+beforeAll(async () => {
+ const { sessionIDHasOpenTab } = await import("@/shell/tabs/tabs")
+ mock.module("@/runtime/platform/platform", () => ({
+ usePlatform: () => ({
+ platform: "web",
+ notify: async (title: string) => {
+ alerts.push(title)
+ },
+ }),
+ }))
+ mock.module("@/settings/model", () => ({
+ useSettings: () => ({
+ sounds: { agentEnabled: () => false, errorsEnabled: () => false },
+ notifications: { agent: () => true, errors: () => true },
+ }),
+ }))
+ mock.module("@/runtime/i18n/language", () => ({ useLanguage: () => ({ t: (key: string) => key }) }))
+ mock.module("@/shell/tabs/tabs", () => ({
+ useTabs: () => tabs,
+ sessionIDHasOpenTab,
+ }))
+ createServerNotificationState = (await import("@/shell/notifications/notification")).createServerNotificationState
+})
+
+test.each([
+ ["session.execution.succeeded", "notification.session.responseReady.title"],
+ ["session.execution.failed", "notification.session.error.title"],
+] as const)("system alert for %s requires an open session tab", async (type, title) => {
+ alerts.length = 0
+ tabs.store = [{ type: "session", server, sessionId: "another-session" }]
+ const listeners = new Array<(event: OpenCodeEvent) => void>()
+
+ const emit = (id: string) =>
+ listeners.forEach((listener) =>
+ // SAFETY: the notification state reads only the type, id and session of execution events.
+ listener({ type, id, data: { sessionID: session.id, error: { type: "api", message: "failed" } } } as never),
+ )
+
+ const dispose = createRoot((dispose) => {
+ createState({
+ sdk: {
+ scope: ServerScope.local,
+ event: {
+ listen: (listener: (event: OpenCodeEvent) => void) => {
+ listeners.push(listener)
+
+ return () => {}
+ },
+ },
+ },
+ data: { session: { get: () => session } },
+ coordinator: { system: async (_id: string, fn: () => Promise) => fn() },
+ })
+
+ return dispose
+ })
+
+ emit("event-1")
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ expect(alerts).toEqual([])
+
+ tabs.store = [{ type: "session", server, sessionId: session.id }]
+ emit("event-2")
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ expect(alerts).toEqual([title])
+ dispose()
+})
+
+test("unread state and viewing follow the server's idle and viewed watermarks", async () => {
+ const views: { sessionID: string; idle: number }[] = []
+ const failures = { remaining: 1 }
+
+ type Info = {
+ id: string
+ parentID?: string
+ time: { idle?: number; viewed?: number; archived?: number }
+ location: { directory: string }
+ }
+
+ const [store, setStore] = createStore<{ viewing?: string; sessions: Record }>({
+ sessions: {
+ root: { id: "root", time: { idle: 2, viewed: 1 }, location: { directory: "/project" } },
+ child: { id: "child", parentID: "root", time: { idle: 5 }, location: { directory: "/project" } },
+ read: { id: "read", time: { idle: 3, viewed: 3 }, location: { directory: "/project" } },
+ archived: { id: "archived", time: { idle: 4, archived: 1 }, location: { directory: "/project" } },
+ other: { id: "other", time: { idle: 4 }, location: { directory: "/other" } },
+ },
+ })
+
+ const dispose = createRoot((dispose) => {
+ const state = createState({
+ sdk: {
+ scope: ServerScope.local,
+ event: { listen: () => () => {} },
+ api: {
+ session: {
+ view: async (input: { sessionID: string; idle: number }) => {
+ views.push(input)
+
+ if (failures.remaining-- > 0) throw new Error("offline")
+ },
+ },
+ },
+ },
+ data: {
+ session: {
+ get: (id: string) => store.sessions[id],
+ root: (id: string) => store.sessions[id]?.parentID ?? id,
+ list: () => Object.values(store.sessions),
+ },
+ },
+ coordinator: {},
+ })
+
+ // Mirrors the session route, which re-runs markViewed whenever what it reads changes.
+ createEffect(() => {
+ if (store.viewing) state.session.markViewed(store.viewing)
+ })
+
+ return { dispose, state }
+ })
+
+ const state = dispose.state
+
+ // Unread reads the root session; children and archived sessions never mark a project unread.
+ expect(["root", "child", "read", "other"].map((id) => state.session.unread(id))).toEqual([true, true, false, true])
+ expect(state.project.hasUnread("/project")).toBe(true)
+
+ // Viewing a child acknowledges its root's idle watermark; a failed report retries after a backoff.
+ setStore("viewing", "child")
+ await new Promise((resolve) => setTimeout(resolve, 0))
+ expect(views).toEqual([{ sessionID: "root", idle: 2 }])
+ await new Promise((resolve) => setTimeout(resolve, 300))
+ expect(views).toEqual([
+ { sessionID: "root", idle: 2 },
+ { sessionID: "root", idle: 2 },
+ ])
+
+ setStore("sessions", "root", "time", "viewed", 2)
+ expect(state.session.unread("root")).toBe(false)
+ expect(state.project.hasUnread("/project")).toBe(false)
+
+ // A newer turn while the session stays open makes it unread and reports the new watermark once.
+ setStore("sessions", "root", "time", "idle", 6)
+ expect(state.session.unread("root")).toBe(true)
+ state.project.markViewed("/project")
+ state.project.markViewed("/other")
+ expect(views.slice(2)).toEqual([
+ { sessionID: "root", idle: 6 },
+ { sessionID: "other", idle: 4 },
+ ])
+ dispose.dispose()
+})
+
+test("unread state honours the old local list until its viewed state is carried to the server, then deletes it", async () => {
+ const key = "opencode.global.dat:notification"
+ const lists: unknown[] = []
+ const views: { sessionID: string; idle: number }[] = []
+ const failures = { remaining: 1 }
+
+ // The old list was found at time 10: sessions that went idle by then were read here unless the list says otherwise.
+ vi.useFakeTimers()
+ setSystemTime(new Date(10))
+ localStorage.setItem(
+ key,
+ JSON.stringify({
+ list: [
+ { type: "turn-complete", time: 1, viewed: true, session: "read-here" },
+ { type: "turn-complete", time: 2, viewed: false, session: "unread-here" },
+ ],
+ }),
+ )
+
+ const sessions = [
+ { id: "read-here", time: { idle: 5 } },
+ { id: "unread-here", time: { idle: 6 } },
+ { id: "viewed", time: { idle: 4, viewed: 4 } },
+ { id: "untracked", time: { idle: 7, viewed: 3 } },
+ { id: "new", time: { idle: 20 } },
+ { id: "never-ran", time: {} },
+ ]
+
+ const start = () =>
+ createRoot((dispose) => ({
+ dispose,
+ state: createState({
+ sdk: {
+ scope: ServerScope.local,
+ connection: { status: () => "connected" },
+ event: { listen: () => () => {} },
+ api: {
+ session: {
+ list: async (input: { cursor?: string }) => {
+ lists.push(input)
+
+ return input.cursor
+ ? { data: sessions.slice(3), cursor: {} }
+ : { data: sessions.slice(0, 3), cursor: { next: "second" } }
+ },
+ view: async (input: { sessionID: string; idle: number }) => {
+ views.push(input)
+
+ if (failures.remaining-- > 0) throw new Error("offline")
+ },
+ },
+ },
+ },
+ data: { session: { get: () => undefined, root: (id: string) => id } },
+ coordinator: {},
+ }),
+ }))
+
+ // Sessions come from fetched snapshots here, like home rows for sessions that aren't loaded.
+ const unread = (state: ReturnType["state"]) =>
+ sessions.flatMap((session) =>
+ // SAFETY: the snapshots carry the id and time fields unread state reads.
+ state.session.unread(session.id, session as never) ? [session.id] : [],
+ )
+
+ // Advances fake time, then lets the mocked requests it started settle.
+ const advance = async (ms: number) => {
+ vi.advanceTimersByTime(ms)
+
+ for (const _ of Array.from({ length: 50 })) await Promise.resolve()
+ }
+
+ const first = start()
+ // Until the stored list is read, nothing it had read can flash unread.
+ expect(unread(first.state)).toEqual([])
+ await advance(0)
+
+ // Nothing read here lights up, while a session that went idle after the list was found is new.
+ expect(unread(first.state)).toEqual(["unread-here", "new"])
+
+ // The carry-over waits for startup to settle, then a failed report keeps the list for the next connection.
+ await advance(4_999)
+ expect(lists).toEqual([])
+ await advance(1)
+ expect(views).toEqual([{ sessionID: "read-here", idle: 5 }])
+ expect(JSON.parse(localStorage.getItem(key) ?? "null")).toMatchObject({ started: 10 })
+ first.dispose()
+
+ // A later start keeps the first start's cutoff, reports every page, then deletes the list without anything flashing.
+ setSystemTime(new Date(100))
+ lists.length = 0
+ views.length = 0
+ const second = start()
+ await advance(0)
+ expect(unread(second.state)).toEqual(["unread-here", "new"])
+ await advance(5_000)
+ expect(lists).toEqual([
+ { limit: 200, order: "desc", parentID: null },
+ { limit: 200, order: "desc", parentID: null, cursor: "second" },
+ ])
+ expect(views).toEqual([
+ { sessionID: "read-here", idle: 5 },
+ { sessionID: "untracked", idle: 7 },
+ ])
+ expect(localStorage.getItem(key)).toBeNull()
+ expect(unread(second.state)).toEqual(["unread-here", "new"])
+ second.dispose()
+
+ // Without the old list, later starts never touch the server's viewed state.
+ const third = start()
+ await advance(5_000)
+ expect(lists).toHaveLength(2)
+ expect(views).toHaveLength(2)
+ third.dispose()
+ vi.useRealTimers()
+ setSystemTime()
+})
diff --git a/packages/gui-extensions/src/ssh/dialog.stories.tsx b/packages/gui-extensions/src/ssh/dialog.stories.tsx
index a78083f50b12..1d706e85061a 100644
--- a/packages/gui-extensions/src/ssh/dialog.stories.tsx
+++ b/packages/gui-extensions/src/ssh/dialog.stories.tsx
@@ -249,7 +249,7 @@ function AuthenticationHome() {
projectsForServer={() => projects}
collapsed={() => false}
canRevealProject={() => false}
- unseenCount={() => 0}
+ hasUnread={() => false}
onWheel={() => {}}
onChooseProject={(server) => {
authenticate(server)