summaryrefslogtreecommitdiffhomepage
path: root/packages
diff options
context:
space:
mode:
Diffstat (limited to 'packages')
-rw-r--r--packages/app/src/components/titlebar-history.test.ts63
-rw-r--r--packages/app/src/components/titlebar-history.ts57
-rw-r--r--packages/app/src/components/titlebar.tsx43
-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
-rw-r--r--packages/app/src/pages/layout.tsx294
11 files changed, 899 insertions, 292 deletions
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 000000000..25035d7cc
--- /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 000000000..44dbbfa3a
--- /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 32e36815e..d8735410a 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 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)
},
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx
index c538b920c..3b66258c9 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<string, string[]>()
+ 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) {
>
<InlineInput
ref={(el) => {
- 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",
@@ -1094,6 +1106,18 @@ export default function Layout(props: ParentProps) {
},
},
{
+ 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"),
description: language.t("command.workspace.toggle.description"),
@@ -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 (
<div class={`relative size-8 shrink-0 rounded ${props.class ?? ""}`}>
<div class="size-full rounded overflow-clip">
<Avatar
fallback={name()}
- src={props.project.id === opencode ? "https://opencode.ai/favicon.svg" : props.project.icon?.override}
+ src={
+ props.project.id === OPENCODE_PROJECT_ID
+ ? "https://opencode.ai/favicon.svg"
+ : props.project.icon?.override
+ }
{...getAvatarColors(props.project.icon?.color)}
class="size-full rounded"
- classList={{ "badge-mask": notifications().length > 0 && props.notify }}
+ classList={{ "badge-mask": unseenCount() > 0 && props.notify }}
/>
</div>
- <Show when={notifications().length > 0 && props.notify}>
+ <Show when={unseenCount() > 0 && props.notify}>
<div
classList={{
"absolute top-px right-px size-1.5 rounded-full z-10": true,
@@ -1723,28 +1749,18 @@ export default function Layout(props: ParentProps) {
mobile?: boolean
dense?: boolean
popover?: boolean
- children?: Map<string, string[]>
+ children: Map<string, string[]>
}): 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) {
<Match when={hasError()}>
<div class="size-1.5 rounded-full bg-text-diff-delete-base" />
</Match>
- <Match when={notifications().length > 0}>
+ <Match when={unseenCount() > 0}>
<div class="size-1.5 rounded-full bg-text-interactive-base" />
</Match>
</Switch>
@@ -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<string, string[]>()
- 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()}
/>
)}
</For>
}
>
<For each={workspaces()}>
- {(directory) => (
- <div class="flex flex-col gap-1">
- <div class="px-2 py-0.5 flex items-center gap-1 min-w-0">
- <div class="shrink-0 size-6 flex items-center justify-center">
- <Icon name="branch" size="small" class="text-icon-base" />
+ {(directory) => {
+ const sessions = createMemo(() => workspaceSessions(directory))
+ const children = createMemo(() => workspaceChildren(directory))
+ return (
+ <div class="flex flex-col gap-1">
+ <div class="px-2 py-0.5 flex items-center gap-1 min-w-0">
+ <div class="shrink-0 size-6 flex items-center justify-center">
+ <Icon name="branch" size="small" class="text-icon-base" />
+ </div>
+ <span class="truncate text-14-medium text-text-base">{label(directory)}</span>
</div>
- <span class="truncate text-14-medium text-text-base">{label(directory)}</span>
+ <For each={sessions()}>
+ {(session) => (
+ <SessionItem
+ session={session}
+ slug={base64Encode(directory)}
+ dense
+ mobile={props.mobile}
+ popover={false}
+ children={children()}
+ />
+ )}
+ </For>
</div>
- <For each={sessions(directory)}>
- {(session) => (
- <SessionItem
- session={session}
- slug={base64Encode(directory)}
- dense
- mobile={props.mobile}
- popover={false}
- />
- )}
- </For>
- </div>
- )}
+ )
+ }}
</For>
</Show>
</div>
@@ -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<string, string[]>()
- 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 (
<div class="flex h-full w-full overflow-hidden">
<div class="w-16 shrink-0 bg-background-base flex flex-col items-center overflow-hidden" onMouseMove={aim.move}>