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)