refactor(app): derive unread state from server viewed watermarks (#54780)

Co-authored-by: Brendonovich <14191578+Brendonovich@users.noreply.github.com>
This commit is contained in:
opencode-agent[bot] 2026-10-13 14:58:13 +08:00 • committed by GitHub
parent bf8c48de73
commit 417f120ee3
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 491 additions and 414 deletions

View file

@ -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

View file

@ -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}

View file

@ -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(
</Menu.Item>
</Show>
<Menu.Item
disabled={props.unseen === 0}
disabled={!props.unread}
onSelect={() => props.onClearNotifications(props.server, props.project)}
>
{props.language.t("sidebar.project.clearNotifications")}

View file

@ -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({

View file

@ -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<S extends Schema.ConstraintCodec<object, unknown>>(
@ -21,11 +20,6 @@ function stored<S extends Schema.ConstraintCodec<object, unknown>>(
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 }],

View file

@ -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 (
<>
<MarkSessionNotificationsViewed sessionID={() => params.id} />
<MarkSessionViewed sessionID={() => params.id} />
<ModelsProvider directory={directory}>
<TargetSessionSettingsCommand />
<SessionRouteErrorBoundary sessionID={params.id} serverKey={requireServerKey(params.serverKey)}>
@ -186,14 +188,20 @@ function SessionPage() {
return <SessionScreenView session={session} />
}
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)
})

View file

@ -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<{
<Show when={revealProject.available(props.server)}>
<Menu.Item onSelect={() => revealProject.reveal(props.server, props.project)}>{language.t(fileManagerApp(platform.os ?? "unknown").actionLabel)}</Menu.Item>
</Show>
<Menu.Item disabled={unseen() === 0} onSelect={clearNotifications}>
<Menu.Item disabled={!hasUnread()} onSelect={clearNotifications}>
{language.t("sidebar.project.clearNotifications")}
</Menu.Item>
<Menu.Separator />

View file

@ -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<ServerConnection.Key>,
sessionId: Accessor<string>,
root?: Accessor<boolean>,
snapshot?: Accessor<SessionInfo | undefined>,
) {
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(() => {

View file

@ -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<void>) => fn() },
} as Parameters<typeof createServerNotificationState>[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()
})

View file

@ -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<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>
}
}
type NotificationTabs = Pick<ReturnType<typeof useTabs>, "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<SessionInfo, "time">) {
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<string, number>()
// 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<typeof setTimeout> | 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<NotificationIndex>(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<string, number>()
const unread = (session?: Pick<SessionInfo, "id" | "time">) => {
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))
},
},
}

View file

@ -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<Tab>() }
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<void>) => 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<string, Info> }>({
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<typeof start>["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()
})

View file

@ -249,7 +249,7 @@ function AuthenticationHome() {
projectsForServer={() => projects}
collapsed={() => false}
canRevealProject={() => false}
unseenCount={() => 0}
hasUnread={() => false}
onWheel={() => {}}
onChooseProject={(server) => {
authenticate(server)