mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-01 07:20:53 +00:00
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:
parent
bf8c48de73
commit
417f120ee3
12 changed files with 491 additions and 414 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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")}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 }],
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
|
|
@ -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))
|
||||
},
|
||||
},
|
||||
}
|
||||
|
|
|
|||
295
packages/app/test-browser/server-notification-state.test.ts
Normal file
295
packages/app/test-browser/server-notification-state.test.ts
Normal 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()
|
||||
})
|
||||
|
|
@ -249,7 +249,7 @@ function AuthenticationHome() {
|
|||
projectsForServer={() => projects}
|
||||
collapsed={() => false}
|
||||
canRevealProject={() => false}
|
||||
unseenCount={() => 0}
|
||||
hasUnread={() => false}
|
||||
onWheel={() => {}}
|
||||
onChooseProject={(server) => {
|
||||
authenticate(server)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue