summaryrefslogtreecommitdiffhomepage
path: root/packages/app/src/context
diff options
context:
space:
mode:
authorAdam <[email protected]>2026-02-06 05:51:01 -0600
committerGitHub <[email protected]>2026-02-06 05:51:01 -0600
commit4afec6731d1051b6b7ea1f3a278c7e16b577736c (patch)
tree70f0ddda33809ba21413e62afde9f429e21f3558 /packages/app/src/context
parent5d922198121338ba67ec1e809a57cf257889bb3c (diff)
downloadopencode-4afec6731d1051b6b7ea1f3a278c7e16b577736c.tar.gz
opencode-4afec6731d1051b6b7ea1f3a278c7e16b577736c.zip
ignore: refactoring and tests (#12460)
Diffstat (limited to 'packages/app/src/context')
-rw-r--r--packages/app/src/context/command.test.ts25
-rw-r--r--packages/app/src/context/command.tsx48
-rw-r--r--packages/app/src/context/global-sync.test.ts136
-rw-r--r--packages/app/src/context/global-sync.tsx329
-rw-r--r--packages/app/src/context/notification-index.ts66
-rw-r--r--packages/app/src/context/notification.test.ts73
-rw-r--r--packages/app/src/context/notification.tsx57
7 files changed, 639 insertions, 95 deletions
diff --git a/packages/app/src/context/command.test.ts b/packages/app/src/context/command.test.ts
new file mode 100644
index 000000000..2b956287c
--- /dev/null
+++ b/packages/app/src/context/command.test.ts
@@ -0,0 +1,25 @@
+import { describe, expect, test } from "bun:test"
+import { upsertCommandRegistration } from "./command"
+
+describe("upsertCommandRegistration", () => {
+ test("replaces keyed registrations", () => {
+ const one = () => [{ id: "one", title: "One" }]
+ const two = () => [{ id: "two", title: "Two" }]
+
+ const next = upsertCommandRegistration([{ key: "layout", options: one }], { key: "layout", options: two })
+
+ expect(next).toHaveLength(1)
+ expect(next[0]?.options).toBe(two)
+ })
+
+ test("keeps unkeyed registrations additive", () => {
+ const one = () => [{ id: "one", title: "One" }]
+ const two = () => [{ id: "two", title: "Two" }]
+
+ const next = upsertCommandRegistration([{ options: one }], { options: two })
+
+ expect(next).toHaveLength(2)
+ expect(next[0]?.options).toBe(two)
+ expect(next[1]?.options).toBe(one)
+ })
+})
diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx
index 791569584..e6a16fd4b 100644
--- a/packages/app/src/context/command.tsx
+++ b/packages/app/src/context/command.tsx
@@ -64,6 +64,16 @@ export type CommandCatalogItem = {
slash?: string
}
+export type CommandRegistration = {
+ key?: string
+ options: Accessor<CommandOption[]>
+}
+
+export function upsertCommandRegistration(registrations: CommandRegistration[], entry: CommandRegistration) {
+ if (entry.key === undefined) return [entry, ...registrations]
+ return [entry, ...registrations.filter((x) => x.key !== entry.key)]
+}
+
export function parseKeybind(config: string): Keybind[] {
if (!config || config === "none") return []
@@ -166,9 +176,10 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
const settings = useSettings()
const language = useLanguage()
const [store, setStore] = createStore({
- registrations: [] as Accessor<CommandOption[]>[],
+ registrations: [] as CommandRegistration[],
suspendCount: 0,
})
+ const warnedDuplicates = new Set<string>()
const [catalog, setCatalog, _, catalogReady] = persisted(
Persist.global("command.catalog.v1"),
@@ -187,8 +198,14 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
const all: CommandOption[] = []
for (const reg of store.registrations) {
- for (const opt of reg()) {
- if (seen.has(opt.id)) continue
+ for (const opt of reg.options()) {
+ if (seen.has(opt.id)) {
+ if (import.meta.env.DEV && !warnedDuplicates.has(opt.id)) {
+ warnedDuplicates.add(opt.id)
+ console.warn(`[command] duplicate command id \"${opt.id}\" registered; keeping first entry`)
+ }
+ continue
+ }
seen.add(opt.id)
all.push(opt)
}
@@ -296,14 +313,25 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex
document.removeEventListener("keydown", handleKeyDown)
})
+ function register(cb: () => CommandOption[]): void
+ function register(key: string, cb: () => CommandOption[]): void
+ function register(key: string | (() => CommandOption[]), cb?: () => CommandOption[]) {
+ const id = typeof key === "string" ? key : undefined
+ const next = typeof key === "function" ? key : cb
+ if (!next) return
+ const options = createMemo(next)
+ const entry: CommandRegistration = {
+ key: id,
+ options,
+ }
+ setStore("registrations", (arr) => upsertCommandRegistration(arr, entry))
+ onCleanup(() => {
+ setStore("registrations", (arr) => arr.filter((x) => x !== entry))
+ })
+ }
+
return {
- register(cb: () => CommandOption[]) {
- const results = createMemo(cb)
- setStore("registrations", (arr) => [results, ...arr])
- onCleanup(() => {
- setStore("registrations", (arr) => arr.filter((x) => x !== results))
- })
- },
+ register,
trigger(id: string, source?: "palette" | "keybind" | "slash") {
run(id, source)
},
diff --git a/packages/app/src/context/global-sync.test.ts b/packages/app/src/context/global-sync.test.ts
new file mode 100644
index 000000000..396b41231
--- /dev/null
+++ b/packages/app/src/context/global-sync.test.ts
@@ -0,0 +1,136 @@
+import { describe, expect, test } from "bun:test"
+import {
+ canDisposeDirectory,
+ estimateRootSessionTotal,
+ loadRootSessionsWithFallback,
+ pickDirectoriesToEvict,
+} from "./global-sync"
+
+describe("pickDirectoriesToEvict", () => {
+ test("keeps pinned stores and evicts idle stores", () => {
+ const now = 5_000
+ const picks = pickDirectoriesToEvict({
+ stores: ["a", "b", "c", "d"],
+ state: new Map([
+ ["a", { lastAccessAt: 1_000 }],
+ ["b", { lastAccessAt: 4_900 }],
+ ["c", { lastAccessAt: 4_800 }],
+ ["d", { lastAccessAt: 3_000 }],
+ ]),
+ pins: new Set(["a"]),
+ max: 2,
+ ttl: 1_500,
+ now,
+ })
+
+ expect(picks).toEqual(["d", "c"])
+ })
+})
+
+describe("loadRootSessionsWithFallback", () => {
+ test("uses limited roots query when supported", async () => {
+ const calls: Array<{ directory: string; roots: true; limit?: number }> = []
+ let fallback = 0
+
+ const result = await loadRootSessionsWithFallback({
+ directory: "dir",
+ limit: 10,
+ list: async (query) => {
+ calls.push(query)
+ return { data: [] }
+ },
+ onFallback: () => {
+ fallback += 1
+ },
+ })
+
+ expect(result.data).toEqual([])
+ expect(result.limited).toBe(true)
+ expect(calls).toEqual([{ directory: "dir", roots: true, limit: 10 }])
+ expect(fallback).toBe(0)
+ })
+
+ test("falls back to full roots query on limited-query failure", async () => {
+ const calls: Array<{ directory: string; roots: true; limit?: number }> = []
+ let fallback = 0
+
+ const result = await loadRootSessionsWithFallback({
+ directory: "dir",
+ limit: 25,
+ list: async (query) => {
+ calls.push(query)
+ if (query.limit) throw new Error("unsupported")
+ return { data: [] }
+ },
+ onFallback: () => {
+ fallback += 1
+ },
+ })
+
+ expect(result.data).toEqual([])
+ expect(result.limited).toBe(false)
+ expect(calls).toEqual([
+ { directory: "dir", roots: true, limit: 25 },
+ { directory: "dir", roots: true },
+ ])
+ expect(fallback).toBe(1)
+ })
+})
+
+describe("estimateRootSessionTotal", () => {
+ test("keeps exact total for full fetches", () => {
+ expect(estimateRootSessionTotal({ count: 42, limit: 10, limited: false })).toBe(42)
+ })
+
+ test("marks has-more for full-limit limited fetches", () => {
+ expect(estimateRootSessionTotal({ count: 10, limit: 10, limited: true })).toBe(11)
+ })
+
+ test("keeps exact total when limited fetch is under limit", () => {
+ expect(estimateRootSessionTotal({ count: 9, limit: 10, limited: true })).toBe(9)
+ })
+})
+
+describe("canDisposeDirectory", () => {
+ test("rejects pinned or inflight directories", () => {
+ expect(
+ canDisposeDirectory({
+ directory: "dir",
+ hasStore: true,
+ pinned: true,
+ booting: false,
+ loadingSessions: false,
+ }),
+ ).toBe(false)
+ expect(
+ canDisposeDirectory({
+ directory: "dir",
+ hasStore: true,
+ pinned: false,
+ booting: true,
+ loadingSessions: false,
+ }),
+ ).toBe(false)
+ expect(
+ canDisposeDirectory({
+ directory: "dir",
+ hasStore: true,
+ pinned: false,
+ booting: false,
+ loadingSessions: true,
+ }),
+ ).toBe(false)
+ })
+
+ test("accepts idle unpinned directory store", () => {
+ expect(
+ canDisposeDirectory({
+ directory: "dir",
+ hasStore: true,
+ pinned: false,
+ booting: false,
+ loadingSessions: false,
+ }),
+ ).toBe(true)
+ })
+})
diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx
index 0facbdfff..0d6b5dfff 100644
--- a/packages/app/src/context/global-sync.tsx
+++ b/packages/app/src/context/global-sync.tsx
@@ -27,6 +27,7 @@ import type { InitError } from "../pages/error"
import {
batch,
createContext,
+ createRoot,
createEffect,
untrack,
getOwner,
@@ -131,6 +132,96 @@ function normalizeProviderList(input: ProviderListResponse): ProviderListRespons
}
}
+const MAX_DIR_STORES = 30
+const DIR_IDLE_TTL_MS = 20 * 60 * 1000
+
+type DirState = {
+ lastAccessAt: number
+}
+
+type EvictPlan = {
+ stores: string[]
+ state: Map<string, DirState>
+ pins: Set<string>
+ max: number
+ ttl: number
+ now: number
+}
+
+export function pickDirectoriesToEvict(input: EvictPlan) {
+ const overflow = Math.max(0, input.stores.length - input.max)
+ let pendingOverflow = overflow
+ const sorted = input.stores
+ .filter((dir) => !input.pins.has(dir))
+ .slice()
+ .sort((a, b) => (input.state.get(a)?.lastAccessAt ?? 0) - (input.state.get(b)?.lastAccessAt ?? 0))
+
+ const output: string[] = []
+ for (const dir of sorted) {
+ const last = input.state.get(dir)?.lastAccessAt ?? 0
+ const idle = input.now - last >= input.ttl
+ if (!idle && pendingOverflow <= 0) continue
+ output.push(dir)
+ if (pendingOverflow > 0) pendingOverflow -= 1
+ }
+ return output
+}
+
+type RootLoadArgs = {
+ directory: string
+ limit: number
+ list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: Session[] }>
+ onFallback: () => void
+}
+
+type RootLoadResult = {
+ data?: Session[]
+ limit: number
+ limited: boolean
+}
+
+export async function loadRootSessionsWithFallback(input: RootLoadArgs) {
+ try {
+ const result = await input.list({ directory: input.directory, roots: true, limit: input.limit })
+ return {
+ data: result.data,
+ limit: input.limit,
+ limited: true,
+ } satisfies RootLoadResult
+ } catch {
+ input.onFallback()
+ const result = await input.list({ directory: input.directory, roots: true })
+ return {
+ data: result.data,
+ limit: input.limit,
+ limited: false,
+ } satisfies RootLoadResult
+ }
+}
+
+export function estimateRootSessionTotal(input: { count: number; limit: number; limited: boolean }) {
+ if (!input.limited) return input.count
+ if (input.count < input.limit) return input.count
+ return input.count + 1
+}
+
+type DisposeCheck = {
+ directory: string
+ hasStore: boolean
+ pinned: boolean
+ booting: boolean
+ loadingSessions: boolean
+}
+
+export function canDisposeDirectory(input: DisposeCheck) {
+ if (!input.directory) return false
+ if (!input.hasStore) return false
+ if (input.pinned) return false
+ if (input.booting) return false
+ if (input.loadingSessions) return false
+ return true
+}
+
function createGlobalSync() {
const globalSDK = useGlobalSDK()
const platform = usePlatform()
@@ -140,8 +231,133 @@ function createGlobalSync() {
const vcsCache = new Map<string, VcsCache>()
const metaCache = new Map<string, MetaCache>()
const iconCache = new Map<string, IconCache>()
+ const lifecycle = new Map<string, DirState>()
+ const pins = new Map<string, number>()
+ const ownerPins = new WeakMap<object, Set<string>>()
+ const disposers = new Map<string, () => void>()
+ const stats = {
+ evictions: 0,
+ loadSessionsFallback: 0,
+ }
const sdkCache = new Map<string, ReturnType<typeof createOpencodeClient>>()
+
+ const updateStats = () => {
+ if (!import.meta.env.DEV) return
+ ;(
+ globalThis as {
+ __OPENCODE_GLOBAL_SYNC_STATS?: {
+ activeDirectoryStores: number
+ evictions: number
+ loadSessionsFullFetchFallback: number
+ }
+ }
+ ).__OPENCODE_GLOBAL_SYNC_STATS = {
+ activeDirectoryStores: Object.keys(children).length,
+ evictions: stats.evictions,
+ loadSessionsFullFetchFallback: stats.loadSessionsFallback,
+ }
+ }
+
+ const mark = (directory: string) => {
+ if (!directory) return
+ lifecycle.set(directory, { lastAccessAt: Date.now() })
+ runEviction()
+ }
+
+ const pin = (directory: string) => {
+ if (!directory) return
+ pins.set(directory, (pins.get(directory) ?? 0) + 1)
+ mark(directory)
+ }
+
+ const unpin = (directory: string) => {
+ if (!directory) return
+ const next = (pins.get(directory) ?? 0) - 1
+ if (next > 0) {
+ pins.set(directory, next)
+ return
+ }
+ pins.delete(directory)
+ runEviction()
+ }
+
+ const pinned = (directory: string) => (pins.get(directory) ?? 0) > 0
+
+ const pinForOwner = (directory: string) => {
+ const current = getOwner()
+ if (!current) return
+ if (current === owner) return
+ const key = current as object
+ const set = ownerPins.get(key)
+ if (set?.has(directory)) return
+ if (set) set.add(directory)
+ else ownerPins.set(key, new Set([directory]))
+ pin(directory)
+ onCleanup(() => {
+ const set = ownerPins.get(key)
+ if (set) {
+ set.delete(directory)
+ if (set.size === 0) ownerPins.delete(key)
+ }
+ unpin(directory)
+ })
+ }
+
+ function disposeDirectory(directory: string) {
+ if (
+ !canDisposeDirectory({
+ directory,
+ hasStore: !!children[directory],
+ pinned: pinned(directory),
+ booting: booting.has(directory),
+ loadingSessions: sessionLoads.has(directory),
+ })
+ ) {
+ return false
+ }
+
+ queued.delete(directory)
+ sessionMeta.delete(directory)
+ sdkCache.delete(directory)
+ vcsCache.delete(directory)
+ metaCache.delete(directory)
+ iconCache.delete(directory)
+ lifecycle.delete(directory)
+
+ const dispose = disposers.get(directory)
+ if (dispose) {
+ dispose()
+ disposers.delete(directory)
+ }
+
+ delete children[directory]
+ updateStats()
+ return true
+ }
+
+ function runEviction() {
+ const stores = Object.keys(children)
+ if (stores.length === 0) return
+ const list = pickDirectoriesToEvict({
+ stores,
+ state: lifecycle,
+ pins: new Set(stores.filter(pinned)),
+ max: MAX_DIR_STORES,
+ ttl: DIR_IDLE_TTL_MS,
+ now: Date.now(),
+ })
+
+ if (list.length === 0) return
+ let changed = false
+ for (const directory of list) {
+ if (!disposeDirectory(directory)) continue
+ stats.evictions += 1
+ changed = true
+ }
+ if (changed) updateStats()
+ }
+
const sdkFor = (directory: string) => {
const cached = sdkCache.get(directory)
if (cached) return cached
@@ -379,52 +595,56 @@ function createGlobalSync() {
if (!icon) throw new Error("Failed to create persisted project icon")
iconCache.set(directory, { store: icon[0], setStore: icon[1], ready: icon[3] })
- const init = () => {
- const child = createStore<State>({
- project: "",
- projectMeta: meta[0].value,
- icon: icon[0].value,
- provider: { all: [], connected: [], default: {} },
- config: {},
- path: { state: "", config: "", worktree: "", directory: "", home: "" },
- status: "loading" as const,
- agent: [],
- command: [],
- session: [],
- sessionTotal: 0,
- session_status: {},
- session_diff: {},
- todo: {},
- permission: {},
- question: {},
- mcp: {},
- lsp: [],
- vcs: vcsStore.value,
- limit: 5,
- message: {},
- part: {},
- })
+ const init = () =>
+ createRoot((dispose) => {
+ const child = createStore<State>({
+ project: "",
+ projectMeta: meta[0].value,
+ icon: icon[0].value,
+ provider: { all: [], connected: [], default: {} },
+ config: {},
+ path: { state: "", config: "", worktree: "", directory: "", home: "" },
+ status: "loading" as const,
+ agent: [],
+ command: [],
+ session: [],
+ sessionTotal: 0,
+ session_status: {},
+ session_diff: {},
+ todo: {},
+ permission: {},
+ question: {},
+ mcp: {},
+ lsp: [],
+ vcs: vcsStore.value,
+ limit: 5,
+ message: {},
+ part: {},
+ })
- children[directory] = child
+ children[directory] = child
+ disposers.set(directory, dispose)
- createEffect(() => {
- if (!vcsReady()) return
- const cached = vcsStore.value
- if (!cached?.branch) return
- child[1]("vcs", (value) => value ?? cached)
- })
+ createEffect(() => {
+ if (!vcsReady()) return
+ const cached = vcsStore.value
+ if (!cached?.branch) return
+ child[1]("vcs", (value) => value ?? cached)
+ })
- createEffect(() => {
- child[1]("projectMeta", meta[0].value)
- })
+ createEffect(() => {
+ child[1]("projectMeta", meta[0].value)
+ })
- createEffect(() => {
- child[1]("icon", icon[0].value)
+ createEffect(() => {
+ child[1]("icon", icon[0].value)
+ })
})
- }
runWithOwner(owner, init)
+ updateStats()
}
+ mark(directory)
const childStore = children[directory]
if (!childStore) throw new Error("Failed to create store")
return childStore
@@ -432,6 +652,7 @@ function createGlobalSync() {
function child(directory: string, options: ChildOptions = {}) {
const childStore = ensureChild(directory)
+ pinForOwner(directory)
const shouldBootstrap = options.bootstrap ?? true
if (shouldBootstrap && childStore[0].status === "loading") {
void bootstrapInstance(directory)
@@ -443,6 +664,7 @@ function createGlobalSync() {
const pending = sessionLoads.get(directory)
if (pending) return pending
+ pin(directory)
const [store, setStore] = child(directory, { bootstrap: false })
const meta = sessionMeta.get(directory)
if (meta && meta.limit >= store.limit) {
@@ -450,11 +672,20 @@ function createGlobalSync() {
if (next.length !== store.session.length) {
setStore("session", reconcile(next, { key: "id" }))
}
+ unpin(directory)
return
}
- const promise = globalSDK.client.session
- .list({ directory, roots: true })
+ const limit = Math.max(store.limit + sessionRecentLimit, sessionRecentLimit)
+ const promise = loadRootSessionsWithFallback({
+ directory,
+ limit,
+ list: (query) => globalSDK.client.session.list(query),
+ onFallback: () => {
+ stats.loadSessionsFallback += 1
+ updateStats()
+ },
+ })
.then((x) => {
const nonArchived = (x.data ?? [])
.filter((s) => !!s?.id)
@@ -468,8 +699,13 @@ function createGlobalSync() {
const children = store.session.filter((s) => !!s.parentID)
const sessions = trimSessions([...nonArchived, ...children], { limit, permission: store.permission })
- // Store total session count (used for "load more" pagination)
- setStore("sessionTotal", nonArchived.length)
+ // Store root session total for "load more" pagination.
+ // For limited root queries, preserve has-more behavior by treating
+ // full-limit responses as "potentially more".
+ setStore(
+ "sessionTotal",
+ estimateRootSessionTotal({ count: nonArchived.length, limit: x.limit, limited: x.limited }),
+ )
setStore("session", reconcile(sessions, { key: "id" }))
sessionMeta.set(directory, { limit })
})
@@ -482,6 +718,7 @@ function createGlobalSync() {
sessionLoads.set(directory, promise)
promise.finally(() => {
sessionLoads.delete(directory)
+ unpin(directory)
})
return promise
}
@@ -491,6 +728,7 @@ function createGlobalSync() {
const pending = booting.get(directory)
if (pending) return pending
+ pin(directory)
const promise = (async () => {
const [store, setStore] = ensureChild(directory)
const cache = vcsCache.get(directory)
@@ -605,6 +843,7 @@ function createGlobalSync() {
booting.set(directory, promise)
promise.finally(() => {
booting.delete(directory)
+ unpin(directory)
})
return promise
}
@@ -670,6 +909,7 @@ function createGlobalSync() {
const existing = children[directory]
if (!existing) return
+ mark(directory)
const [store, setStore] = existing
@@ -955,6 +1195,11 @@ function createGlobalSync() {
if (!timer) return
clearTimeout(timer)
})
+ onCleanup(() => {
+ for (const directory of Object.keys(children)) {
+ disposeDirectory(directory)
+ }
+ })
async function bootstrap() {
const health = await globalSDK.client.global
diff --git a/packages/app/src/context/notification-index.ts b/packages/app/src/context/notification-index.ts
new file mode 100644
index 000000000..0b316e7ec
--- /dev/null
+++ b/packages/app/src/context/notification-index.ts
@@ -0,0 +1,66 @@
+type NotificationIndexItem = {
+ directory?: string
+ session?: string
+ viewed: boolean
+ type: string
+}
+
+export function buildNotificationIndex<T extends NotificationIndexItem>(list: T[]) {
+ const sessionAll = new Map<string, T[]>()
+ const sessionUnseen = new Map<string, T[]>()
+ const sessionUnseenCount = new Map<string, number>()
+ const sessionUnseenHasError = new Map<string, boolean>()
+ const projectAll = new Map<string, T[]>()
+ const projectUnseen = new Map<string, T[]>()
+ const projectUnseenCount = new Map<string, number>()
+ const projectUnseenHasError = new Map<string, boolean>()
+
+ for (const notification of list) {
+ const session = notification.session
+ if (session) {
+ const all = sessionAll.get(session)
+ if (all) all.push(notification)
+ else sessionAll.set(session, [notification])
+
+ if (!notification.viewed) {
+ const unseen = sessionUnseen.get(session)
+ if (unseen) unseen.push(notification)
+ else sessionUnseen.set(session, [notification])
+
+ sessionUnseenCount.set(session, (sessionUnseenCount.get(session) ?? 0) + 1)
+ if (notification.type === "error") sessionUnseenHasError.set(session, true)
+ }
+ }
+
+ const directory = notification.directory
+ if (directory) {
+ const all = projectAll.get(directory)
+ if (all) all.push(notification)
+ else projectAll.set(directory, [notification])
+
+ if (!notification.viewed) {
+ const unseen = projectUnseen.get(directory)
+ if (unseen) unseen.push(notification)
+ else projectUnseen.set(directory, [notification])
+
+ projectUnseenCount.set(directory, (projectUnseenCount.get(directory) ?? 0) + 1)
+ if (notification.type === "error") projectUnseenHasError.set(directory, true)
+ }
+ }
+ }
+
+ return {
+ session: {
+ all: sessionAll,
+ unseen: sessionUnseen,
+ unseenCount: sessionUnseenCount,
+ unseenHasError: sessionUnseenHasError,
+ },
+ project: {
+ all: projectAll,
+ unseen: projectUnseen,
+ unseenCount: projectUnseenCount,
+ unseenHasError: projectUnseenHasError,
+ },
+ }
+}
diff --git a/packages/app/src/context/notification.test.ts b/packages/app/src/context/notification.test.ts
new file mode 100644
index 000000000..44bacb704
--- /dev/null
+++ b/packages/app/src/context/notification.test.ts
@@ -0,0 +1,73 @@
+import { describe, expect, test } from "bun:test"
+import { buildNotificationIndex } from "./notification-index"
+
+type Notification = {
+ type: "turn-complete" | "error"
+ session: string
+ directory: string
+ viewed: boolean
+ time: number
+}
+
+const turn = (session: string, directory: string, viewed = false): Notification => ({
+ type: "turn-complete",
+ session,
+ directory,
+ viewed,
+ time: 1,
+})
+
+const error = (session: string, directory: string, viewed = false): Notification => ({
+ type: "error",
+ session,
+ directory,
+ viewed,
+ time: 1,
+})
+
+describe("buildNotificationIndex", () => {
+ test("builds unseen counts and unseen error flags", () => {
+ const list = [
+ turn("s1", "d1", false),
+ error("s1", "d1", false),
+ turn("s1", "d1", true),
+ turn("s2", "d1", false),
+ error("s3", "d2", true),
+ ]
+
+ const index = buildNotificationIndex(list)
+
+ expect(index.session.all.get("s1")?.length).toBe(3)
+ expect(index.session.unseen.get("s1")?.length).toBe(2)
+ expect(index.session.unseenCount.get("s1")).toBe(2)
+ expect(index.session.unseenHasError.get("s1")).toBe(true)
+
+ expect(index.session.unseenCount.get("s2")).toBe(1)
+ expect(index.session.unseenHasError.get("s2") ?? false).toBe(false)
+ expect(index.session.unseenCount.get("s3") ?? 0).toBe(0)
+ expect(index.session.unseenHasError.get("s3") ?? false).toBe(false)
+
+ expect(index.project.unseenCount.get("d1")).toBe(3)
+ expect(index.project.unseenHasError.get("d1")).toBe(true)
+ expect(index.project.unseenCount.get("d2") ?? 0).toBe(0)
+ expect(index.project.unseenHasError.get("d2") ?? false).toBe(false)
+ })
+
+ test("updates selectors after viewed transitions", () => {
+ const list = [turn("s1", "d1", false), error("s1", "d1", false), turn("s2", "d1", false)]
+ const next = list.map((item) => (item.session === "s1" ? { ...item, viewed: true } : item))
+
+ const before = buildNotificationIndex(list)
+ const after = buildNotificationIndex(next)
+
+ expect(before.session.unseenCount.get("s1")).toBe(2)
+ expect(before.session.unseenHasError.get("s1")).toBe(true)
+ expect(before.project.unseenCount.get("d1")).toBe(3)
+ expect(before.project.unseenHasError.get("d1")).toBe(true)
+
+ expect(after.session.unseenCount.get("s1") ?? 0).toBe(0)
+ expect(after.session.unseenHasError.get("s1") ?? false).toBe(false)
+ expect(after.project.unseenCount.get("d1")).toBe(1)
+ expect(after.project.unseenHasError.get("d1") ?? false).toBe(false)
+ })
+})
diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx
index 6c110cae1..b876bd862 100644
--- a/packages/app/src/context/notification.tsx
+++ b/packages/app/src/context/notification.tsx
@@ -13,6 +13,7 @@ import { decode64 } from "@/utils/base64"
import { EventSessionError } from "@opencode-ai/sdk/v2"
import { Persist, persisted } from "@/utils/persist"
import { playSound, soundSrc } from "@/utils/sound"
+import { buildNotificationIndex } from "./notification-index"
type NotificationBase = {
directory?: string
@@ -81,49 +82,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
setStore("list", (list) => pruneNotifications([...list, notification]))
}
- const index = createMemo(() => {
- const sessionAll = new Map<string, Notification[]>()
- const sessionUnseen = new Map<string, Notification[]>()
- const projectAll = new Map<string, Notification[]>()
- const projectUnseen = new Map<string, Notification[]>()
-
- for (const notification of store.list) {
- const session = notification.session
- if (session) {
- const list = sessionAll.get(session)
- if (list) list.push(notification)
- else sessionAll.set(session, [notification])
- if (!notification.viewed) {
- const unseen = sessionUnseen.get(session)
- if (unseen) unseen.push(notification)
- else sessionUnseen.set(session, [notification])
- }
- }
-
- const directory = notification.directory
- if (directory) {
- const list = projectAll.get(directory)
- if (list) list.push(notification)
- else projectAll.set(directory, [notification])
- if (!notification.viewed) {
- const unseen = projectUnseen.get(directory)
- if (unseen) unseen.push(notification)
- else projectUnseen.set(directory, [notification])
- }
- }
- }
-
- return {
- session: {
- all: sessionAll,
- unseen: sessionUnseen,
- },
- project: {
- all: projectAll,
- unseen: projectUnseen,
- },
- }
- })
+ const index = createMemo(() => buildNotificationIndex(store.list))
const unsub = globalSDK.event.listen((e) => {
const event = e.details
@@ -208,6 +167,12 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
unseen(session: string) {
return index().session.unseen.get(session) ?? empty
},
+ unseenCount(session: string) {
+ return index().session.unseenCount.get(session) ?? 0
+ },
+ unseenHasError(session: string) {
+ return index().session.unseenHasError.get(session) ?? false
+ },
markViewed(session: string) {
setStore("list", (n) => n.session === session, "viewed", true)
},
@@ -219,6 +184,12 @@ export const { use: useNotification, provider: NotificationProvider } = createSi
unseen(directory: string) {
return index().project.unseen.get(directory) ?? empty
},
+ unseenCount(directory: string) {
+ return index().project.unseenCount.get(directory) ?? 0
+ },
+ unseenHasError(directory: string) {
+ return index().project.unseenHasError.get(directory) ?? false
+ },
markViewed(directory: string) {
setStore("list", (n) => n.directory === directory, "viewed", true)
},