diff --git a/packages/app/src/components/titlebar-history.test.ts b/packages/app/src/components/titlebar-history.test.ts new file mode 100644 index 00000000000..25035d7ccf7 --- /dev/null +++ b/packages/app/src/components/titlebar-history.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test" +import { applyPath, backPath, forwardPath, type TitlebarHistory } from "./titlebar-history" + +function history(): TitlebarHistory { + return { stack: [], index: 0, action: undefined } +} + +describe("titlebar history", () => { + test("append and trim keeps max bounded", () => { + let state = history() + state = applyPath(state, "/", 3) + state = applyPath(state, "/a", 3) + state = applyPath(state, "/b", 3) + state = applyPath(state, "/c", 3) + + expect(state.stack).toEqual(["/a", "/b", "/c"]) + expect(state.stack.length).toBe(3) + expect(state.index).toBe(2) + }) + + test("back and forward indexes stay correct after trimming", () => { + let state = history() + state = applyPath(state, "/", 3) + state = applyPath(state, "/a", 3) + state = applyPath(state, "/b", 3) + state = applyPath(state, "/c", 3) + + expect(state.stack).toEqual(["/a", "/b", "/c"]) + expect(state.index).toBe(2) + + const back = backPath(state) + expect(back?.to).toBe("/b") + expect(back?.state.index).toBe(1) + + const afterBack = applyPath(back!.state, back!.to, 3) + expect(afterBack.stack).toEqual(["/a", "/b", "/c"]) + expect(afterBack.index).toBe(1) + + const forward = forwardPath(afterBack) + expect(forward?.to).toBe("/c") + expect(forward?.state.index).toBe(2) + + const afterForward = applyPath(forward!.state, forward!.to, 3) + expect(afterForward.stack).toEqual(["/a", "/b", "/c"]) + expect(afterForward.index).toBe(2) + }) + + test("action-driven navigation does not push duplicate history entries", () => { + const state: TitlebarHistory = { + stack: ["/", "/a", "/b"], + index: 2, + action: undefined, + } + + const back = backPath(state) + expect(back?.to).toBe("/a") + + const next = applyPath(back!.state, back!.to, 10) + expect(next.stack).toEqual(["/", "/a", "/b"]) + expect(next.index).toBe(1) + expect(next.action).toBeUndefined() + }) +}) diff --git a/packages/app/src/components/titlebar-history.ts b/packages/app/src/components/titlebar-history.ts new file mode 100644 index 00000000000..44dbbfa3a49 --- /dev/null +++ b/packages/app/src/components/titlebar-history.ts @@ -0,0 +1,57 @@ +export const MAX_TITLEBAR_HISTORY = 100 + +export type TitlebarAction = "back" | "forward" | undefined + +export type TitlebarHistory = { + stack: string[] + index: number + action: TitlebarAction +} + +export function applyPath(state: TitlebarHistory, current: string, max = MAX_TITLEBAR_HISTORY): TitlebarHistory { + if (!state.stack.length) { + const stack = current === "/" ? ["/"] : ["/", current] + return { stack, index: stack.length - 1, action: undefined } + } + + const active = state.stack[state.index] + if (current === active) { + if (!state.action) return state + return { ...state, action: undefined } + } + + if (state.action) return { ...state, action: undefined } + + return pushPath(state, current, max) +} + +export function pushPath(state: TitlebarHistory, path: string, max = MAX_TITLEBAR_HISTORY): TitlebarHistory { + const stack = state.stack.slice(0, state.index + 1).concat(path) + const next = trimHistory(stack, stack.length - 1, max) + return { ...state, ...next, action: undefined } +} + +export function trimHistory(stack: string[], index: number, max = MAX_TITLEBAR_HISTORY) { + if (stack.length <= max) return { stack, index } + const cut = stack.length - max + return { + stack: stack.slice(cut), + index: Math.max(0, index - cut), + } +} + +export function backPath(state: TitlebarHistory) { + if (state.index <= 0) return + const index = state.index - 1 + const to = state.stack[index] + if (!to) return + return { state: { ...state, index, action: "back" as const }, to } +} + +export function forwardPath(state: TitlebarHistory) { + if (state.index >= state.stack.length - 1) return + const index = state.index + 1 + const to = state.stack[index] + if (!to) return + return { state: { ...state, index, action: "forward" as const }, to } +} diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 32e36815ee2..d8735410a4a 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -11,6 +11,7 @@ import { useLayout } from "@/context/layout" import { usePlatform } from "@/context/platform" import { useCommand } from "@/context/command" import { useLanguage } from "@/context/language" +import { applyPath, backPath, forwardPath } from "./titlebar-history" export function Titlebar() { const layout = useLayout() @@ -39,25 +40,9 @@ export function Titlebar() { const current = path() untrack(() => { - if (!history.stack.length) { - const stack = current === "/" ? ["/"] : ["/", current] - setHistory({ stack, index: stack.length - 1 }) - return - } - - const active = history.stack[history.index] - if (current === active) { - if (history.action) setHistory("action", undefined) - return - } - - if (history.action) { - setHistory("action", undefined) - return - } - - const next = history.stack.slice(0, history.index + 1).concat(current) - setHistory({ stack: next, index: next.length - 1 }) + const next = applyPath(history, current) + if (next === history) return + setHistory(next) }) }) @@ -65,21 +50,17 @@ export function Titlebar() { const canForward = createMemo(() => history.index < history.stack.length - 1) const back = () => { - if (!canBack()) return - const index = history.index - 1 - const to = history.stack[index] - if (!to) return - setHistory({ index, action: "back" }) - navigate(to) + const next = backPath(history) + if (!next) return + setHistory(next.state) + navigate(next.to) } const forward = () => { - if (!canForward()) return - const index = history.index + 1 - const to = history.stack[index] - if (!to) return - setHistory({ index, action: "forward" }) - navigate(to) + const next = forwardPath(history) + if (!next) return + setHistory(next.state) + navigate(next.to) } command.register(() => [ diff --git a/packages/app/src/context/command.test.ts b/packages/app/src/context/command.test.ts new file mode 100644 index 00000000000..2b956287c54 --- /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 79156958400..e6a16fd4bb3 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 +} + +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[], + registrations: [] as CommandRegistration[], suspendCount: 0, }) + const warnedDuplicates = new Set() 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 00000000000..396b412318b --- /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 0facbdfff45..0d6b5dfff9b 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 + pins: Set + 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() const metaCache = new Map() const iconCache = new Map() + const lifecycle = new Map() + const pins = new Map() + const ownerPins = new WeakMap>() + const disposers = new Map void>() + const stats = { + evictions: 0, + loadSessionsFallback: 0, + } const sdkCache = new Map>() + + 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({ - 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({ + 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 00000000000..0b316e7ec10 --- /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(list: T[]) { + const sessionAll = new Map() + const sessionUnseen = new Map() + const sessionUnseenCount = new Map() + const sessionUnseenHasError = new Map() + const projectAll = new Map() + const projectUnseen = new Map() + const projectUnseenCount = new Map() + const projectUnseenHasError = new Map() + + 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 00000000000..44bacb70493 --- /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 6c110cae142..b876bd86273 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() - const sessionUnseen = new Map() - const projectAll = new Map() - const projectUnseen = new Map() - - 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) }, diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index c538b920cb2..3b66258c975 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -76,6 +76,44 @@ import { Titlebar } from "@/components/titlebar" import { useServer } from "@/context/server" import { useLanguage, type Locale } from "@/context/language" +const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" + +const workspaceKey = (directory: string) => directory.replace(/[\\/]+$/, "") + +function sortSessions(now: number) { + const oneMinuteAgo = now - 60 * 1000 + return (a: Session, b: Session) => { + const aUpdated = a.time.updated ?? a.time.created + const bUpdated = b.time.updated ?? b.time.created + const aRecent = aUpdated > oneMinuteAgo + const bRecent = bUpdated > oneMinuteAgo + if (aRecent && bRecent) return a.id < b.id ? -1 : a.id > b.id ? 1 : 0 + if (aRecent && !bRecent) return -1 + if (!aRecent && bRecent) return 1 + return bUpdated - aUpdated + } +} + +const isRootVisibleSession = (session: Session, directory: string) => + workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived + +const sortedRootSessions = (store: { session: Session[]; path: { directory: string } }, now: number) => + store.session.filter((session) => isRootVisibleSession(session, store.path.directory)).toSorted(sortSessions(now)) + +const childMapByParent = (sessions: Session[]) => { + const map = new Map() + for (const session of sessions) { + if (!session.parentID) continue + const existing = map.get(session.parentID) + if (existing) { + existing.push(session.id) + continue + } + map.set(session.parentID, [session.id]) + } + return map +} + export default function Layout(props: ParentProps) { const [store, setStore, , ready] = persisted( Persist.global("layout.page", ["layout.page.v1"]), @@ -119,6 +157,7 @@ export default function Layout(props: ParentProps) { dark: "theme.scheme.dark", } const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme]) + const currentDir = createMemo(() => decode64(params.dir) ?? "") const [state, setState] = createStore({ autoselect: !initialDirectory, @@ -143,8 +182,6 @@ export default function Layout(props: ParentProps) { }) } const isBusy = (directory: string) => state.busyWorkspaces.has(workspaceKey(directory)) - const editorRef = { current: undefined as HTMLInputElement | undefined } - const navLeave = { current: undefined as number | undefined } const aim = createAim({ @@ -289,7 +326,6 @@ export default function Layout(props: ParentProps) { > { - editorRef.current = el requestAnimationFrame(() => el.focus()) }} value={editorValue()} @@ -466,10 +502,9 @@ export default function Layout(props: ParentProps) { } } - const currentDir = decode64(params.dir) const currentSession = params.id - if (directory === currentDir && props.sessionID === currentSession) return - if (directory === currentDir && session?.parentID === currentSession) return + if (directory === currentDir() && props.sessionID === currentSession) return + if (directory === currentDir() && session?.parentID === currentSession) return const existingToastId = toastBySession.get(sessionKey) if (existingToastId !== undefined) toaster.dismiss(existingToastId) @@ -495,20 +530,19 @@ export default function Layout(props: ParentProps) { onCleanup(unsub) createEffect(() => { - const currentDir = decode64(params.dir) const currentSession = params.id - if (!currentDir || !currentSession) return - const sessionKey = `${currentDir}:${currentSession}` + if (!currentDir() || !currentSession) return + const sessionKey = `${currentDir()}:${currentSession}` const toastId = toastBySession.get(sessionKey) if (toastId !== undefined) { toaster.dismiss(toastId) toastBySession.delete(sessionKey) alertedAtBySession.delete(sessionKey) } - const [store] = globalSync.child(currentDir, { bootstrap: false }) + const [store] = globalSync.child(currentDir(), { bootstrap: false }) const childSessions = store.session.filter((s) => s.parentID === currentSession) for (const child of childSessions) { - const childKey = `${currentDir}:${child.id}` + const childKey = `${currentDir()}:${child.id}` const childToastId = toastBySession.get(childKey) if (childToastId !== undefined) { toaster.dismiss(childToastId) @@ -519,20 +553,6 @@ export default function Layout(props: ParentProps) { }) }) - function sortSessions(now: number) { - const oneMinuteAgo = now - 60 * 1000 - return (a: Session, b: Session) => { - const aUpdated = a.time.updated ?? a.time.created - const bUpdated = b.time.updated ?? b.time.created - const aRecent = aUpdated > oneMinuteAgo - const bRecent = bUpdated > oneMinuteAgo - if (aRecent && bRecent) return a.id < b.id ? -1 : a.id > b.id ? 1 : 0 - if (aRecent && !bRecent) return -1 - if (!aRecent && bRecent) return 1 - return bUpdated - aUpdated - } - } - function scrollToSession(sessionId: string, sessionKey: string) { if (!scrollContainerRef) return if (state.scrollSessionKey === sessionKey) return @@ -549,7 +569,7 @@ export default function Layout(props: ParentProps) { } const currentProject = createMemo(() => { - const directory = decode64(params.dir) + const directory = currentDir() if (!directory) return const projects = layout.projects.list() @@ -614,8 +634,6 @@ export default function Layout(props: ParentProps) { ), ) - const workspaceKey = (directory: string) => directory.replace(/[\\/]+$/, "") - const workspaceName = (directory: string, projectId?: string, branch?: string) => { const key = workspaceKey(directory) const direct = store.workspaceName[key] ?? store.workspaceName[directory] @@ -687,29 +705,23 @@ export default function Layout(props: ParentProps) { const currentSessions = createMemo(() => { const project = currentProject() if (!project) return [] as Session[] - const compare = sortSessions(Date.now()) + const now = Date.now() if (workspaceSetting()) { const dirs = workspaceIds(project) - const activeDir = decode64(params.dir) ?? "" + const activeDir = currentDir() const result: Session[] = [] for (const dir of dirs) { const expanded = store.workspaceExpanded[dir] ?? dir === project.worktree const active = dir === activeDir if (!expanded && !active) continue const [dirStore] = globalSync.child(dir, { bootstrap: true }) - const dirSessions = dirStore.session - .filter((session) => session.directory === dirStore.path.directory) - .filter((session) => !session.parentID && !session.time?.archived) - .toSorted(compare) + const dirSessions = sortedRootSessions(dirStore, now) result.push(...dirSessions) } return result } const [projectStore] = globalSync.child(project.worktree) - return projectStore.session - .filter((session) => session.directory === projectStore.path.directory) - .filter((session) => !session.parentID && !session.time?.archived) - .toSorted(compare) + return sortedRootSessions(projectStore, now) }) type PrefetchQueue = { @@ -951,7 +963,7 @@ export default function Layout(props: ParentProps) { const sessions = currentSessions() if (sessions.length === 0) return - const hasUnseen = sessions.some((session) => notification.session.unseen(session.id).length > 0) + const hasUnseen = sessions.some((session) => notification.session.unseenCount(session.id) > 0) if (!hasUnseen) return const activeIndex = params.id ? sessions.findIndex((s) => s.id === params.id) : -1 @@ -961,7 +973,7 @@ export default function Layout(props: ParentProps) { const index = offset > 0 ? (start + i) % sessions.length : (start - i + sessions.length) % sessions.length const session = sessions[index] if (!session) continue - if (notification.session.unseen(session.id).length === 0) continue + if (notification.session.unseenCount(session.id) === 0) continue prefetchSession(session, "high") @@ -1019,7 +1031,7 @@ export default function Layout(props: ParentProps) { } } - command.register(() => { + command.register("layout", () => { const commands: CommandOption[] = [ { id: "sidebar.toggle", @@ -1093,6 +1105,18 @@ export default function Layout(props: ParentProps) { if (session) archiveSession(session) }, }, + { + id: "workspace.new", + title: language.t("workspace.new"), + category: language.t("command.category.workspace"), + keybind: "mod+shift+w", + disabled: !workspaceSetting(), + onSelect: () => { + const project = currentProject() + if (!project) return + return createWorkspace(project) + }, + }, { id: "workspace.toggle", title: language.t("command.workspace.toggle"), @@ -1344,7 +1368,7 @@ export default function Layout(props: ParentProps) { layout.projects.close(directory) layout.projects.open(root) - if (params.dir && decode64(params.dir) === directory) { + if (params.dir && currentDir() === directory) { navigateToProject(root) } } @@ -1584,7 +1608,7 @@ export default function Layout(props: ParentProps) { if (!project) return if (workspaceSetting()) { - const activeDir = decode64(params.dir) ?? "" + const activeDir = currentDir() const dirs = [project.worktree, ...(project.sandboxes ?? [])] for (const directory of dirs) { const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree @@ -1634,7 +1658,7 @@ export default function Layout(props: ParentProps) { const local = project.worktree const dirs = [local, ...(project.sandboxes ?? [])] const active = currentProject() - const directory = active?.worktree === project.worktree ? decode64(params.dir) : undefined + const directory = active?.worktree === project.worktree ? currentDir() : undefined const extra = directory && directory !== local && !dirs.includes(directory) ? directory : undefined const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false @@ -1688,23 +1712,25 @@ export default function Layout(props: ParentProps) { const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => { const notification = useNotification() - const notifications = createMemo(() => notification.project.unseen(props.project.worktree)) - const hasError = createMemo(() => notifications().some((n) => n.type === "error")) + const unseenCount = createMemo(() => notification.project.unseenCount(props.project.worktree)) + const hasError = createMemo(() => notification.project.unseenHasError(props.project.worktree)) const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) - const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" - return (
0 && props.notify }} + classList={{ "badge-mask": unseenCount() > 0 && props.notify }} />
- 0 && props.notify}> + 0 && props.notify}>
+ children: Map }): JSX.Element => { const notification = useNotification() - const notifications = createMemo(() => notification.session.unseen(props.session.id)) - const hasError = createMemo(() => notifications().some((n) => n.type === "error")) + const unseenCount = createMemo(() => notification.session.unseenCount(props.session.id)) + const hasError = createMemo(() => notification.session.unseenHasError(props.session.id)) const [sessionStore] = globalSync.child(props.session.directory) const hasPermissions = createMemo(() => { const permissions = sessionStore.permission?.[props.session.id] ?? [] if (permissions.length > 0) return true - const childIDs = props.children?.get(props.session.id) - if (childIDs) { - for (const id of childIDs) { - const childPermissions = sessionStore.permission?.[id] ?? [] - if (childPermissions.length > 0) return true - } - return false - } - - const childSessions = sessionStore.session.filter((s) => s.parentID === props.session.id) - for (const child of childSessions) { - const childPermissions = sessionStore.permission?.[child.id] ?? [] + for (const id of props.children.get(props.session.id) ?? []) { + const childPermissions = sessionStore.permission?.[id] ?? [] if (childPermissions.length > 0) return true } return false @@ -1758,10 +1774,13 @@ export default function Layout(props: ParentProps) { const tint = createMemo(() => { const messages = sessionStore.message[props.session.id] if (!messages) return undefined - const user = messages - .slice() - .reverse() - .find((m) => m.role === "user") + let user: Message | undefined + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i] + if (message.role !== "user") continue + user = message + break + } if (!user?.agent) return undefined const agent = sessionStore.agent.find((a) => a.name === user.agent) @@ -1828,7 +1847,7 @@ export default function Layout(props: ParentProps) {
- 0}> + 0}>
@@ -2023,30 +2042,10 @@ export default function Layout(props: ParentProps) { pendingRename: false, }) const slug = createMemo(() => base64Encode(props.directory)) - const sessions = createMemo(() => - workspaceStore.session - .filter((session) => session.directory === workspaceStore.path.directory) - .filter((session) => !session.parentID && !session.time?.archived) - .toSorted(sortSessions(Date.now())), - ) - const children = createMemo(() => { - const map = new Map() - for (const session of workspaceStore.session) { - if (!session.parentID) continue - const existing = map.get(session.parentID) - if (existing) { - existing.push(session.id) - continue - } - map.set(session.parentID, [session.id]) - } - return map - }) + const sessions = createMemo(() => sortedRootSessions(workspaceStore, Date.now())) + const children = createMemo(() => childMapByParent(workspaceStore.session)) const local = createMemo(() => props.directory === props.project.worktree) - const active = createMemo(() => { - const current = decode64(params.dir) ?? "" - return current === props.directory - }) + const active = createMemo(() => currentDir() === props.directory) const workspaceValue = createMemo(() => { const branch = workspaceStore.vcs?.branch const name = branch ?? getFilename(props.directory) @@ -2257,7 +2256,7 @@ export default function Layout(props: ParentProps) { const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => { const sortable = createSortable(props.project.worktree) const selected = createMemo(() => { - const current = decode64(params.dir) ?? "" + const current = currentDir() return props.project.worktree === current || props.project.sandboxes?.includes(current) }) @@ -2288,25 +2287,16 @@ export default function Layout(props: ParentProps) { return `${kind} : ${name}` } - const sessions = (directory: string) => { + const projectStore = createMemo(() => globalSync.child(props.project.worktree, { bootstrap: false })[0]) + const projectSessions = createMemo(() => sortedRootSessions(projectStore(), Date.now()).slice(0, 2)) + const projectChildren = createMemo(() => childMapByParent(projectStore().session)) + const workspaceSessions = (directory: string) => { const [data] = globalSync.child(directory, { bootstrap: false }) - const root = workspaceKey(directory) - return data.session - .filter((session) => workspaceKey(session.directory) === root) - .filter((session) => !session.parentID && !session.time?.archived) - .toSorted(sortSessions(Date.now())) - .slice(0, 2) + return sortedRootSessions(data, Date.now()).slice(0, 2) } - - const projectSessions = () => { - const directory = props.project.worktree + const workspaceChildren = (directory: string) => { const [data] = globalSync.child(directory, { bootstrap: false }) - const root = workspaceKey(directory) - return data.session - .filter((session) => workspaceKey(session.directory) === root) - .filter((session) => !session.parentID && !session.time?.archived) - .toSorted(sortSessions(Date.now())) - .slice(0, 2) + return childMapByParent(data.session) } const projectName = () => props.project.name || getFilename(props.project.worktree) @@ -2435,33 +2425,39 @@ export default function Layout(props: ParentProps) { dense mobile={props.mobile} popover={false} + children={projectChildren()} /> )} } > - {(directory) => ( -
-
-
- + {(directory) => { + const sessions = createMemo(() => workspaceSessions(directory)) + const children = createMemo(() => workspaceChildren(directory)) + return ( +
+
+
+ +
+ {label(directory)}
- {label(directory)} + + {(session) => ( + + )} +
- - {(session) => ( - - )} - -
- )} + ) + }}
@@ -2494,27 +2490,8 @@ export default function Layout(props: ParentProps) { return { store, setStore } }) const slug = createMemo(() => base64Encode(props.project.worktree)) - const sessions = createMemo(() => { - const store = workspace().store - return store.session - .filter((session) => session.directory === store.path.directory) - .filter((session) => !session.parentID && !session.time?.archived) - .toSorted(sortSessions(Date.now())) - }) - const children = createMemo(() => { - const store = workspace().store - const map = new Map() - for (const session of store.session) { - if (!session.parentID) continue - const existing = map.get(session.parentID) - if (existing) { - existing.push(session.id) - continue - } - map.set(session.parentID, [session.id]) - } - return map - }) + const sessions = createMemo(() => sortedRootSessions(workspace().store, Date.now())) + const children = createMemo(() => childMapByParent(workspace().store.session)) const booted = createMemo((prev) => prev || workspace().store.status === "complete", false) const loading = createMemo(() => !booted() && sessions().length === 0) const hasMore = createMemo(() => workspace().store.sessionTotal > sessions().length) @@ -2819,21 +2796,6 @@ export default function Layout(props: ParentProps) { const SidebarContent = (sidebarProps: { mobile?: boolean }) => { const expanded = () => sidebarProps.mobile || layout.sidebar.opened() - command.register(() => [ - { - id: "workspace.new", - title: language.t("workspace.new"), - category: language.t("command.category.workspace"), - keybind: "mod+shift+w", - disabled: !workspaceSetting(), - onSelect: () => { - const project = currentProject() - if (!project) return - return createWorkspace(project) - }, - }, - ]) - return (