Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 30 additions & 1 deletion packages/app/src/components/session/session-header.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@ import { useServerSync } from "@/context/server-sync"
import { useGlobal } from "@/context/global"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { sessionListDirectories, sortedRootSessions } from "@/pages/layout/helpers"
import { sessionListState } from "@/utils/session-list-state"
import { useNavigate } from "@solidjs/router"
import type { Session } from "@opencode-ai/sdk/v2/client"

Expand Down Expand Up @@ -759,6 +760,30 @@ export function SessionChatsDropdown(props: { currentSessionID?: string } = {})
}
})

// D2 honest states: "not yet fetched" (a completed list request is the only
// authority for "genuinely empty") vs "empty" vs ready — never render the
// empty state while no fetch has resolved (#293's invisible failure).
const activeListState = createMemo(() => {
if (!open()) return "ready" as const
try {
const conn = server.current
if (!conn) return "unfetched" as const
const ctx = globalCtx.ensureServerCtx(conn)
if (!ctx) return "unfetched" as const
const directories = sessionListDirectories(ctx.projects.list(), ctx.sync.data?.project ?? [])
let fetched = false
let count = 0
for (const dir of directories) {
const [store] = ctx.sync.child(dir, { bootstrap: false })
if (store.sessions_fetched) fetched = true
count += store.session?.length ?? 0
}
return sessionListState({ fetched, count, searching: !!searchQuery() })
} catch {
return "unfetched" as const
}
})

// Sort: open-tab sessions first
const sortedActiveSessions = createMemo(() => {
if (!open()) return []
Expand Down Expand Up @@ -1071,7 +1096,11 @@ export function SessionChatsDropdown(props: { currentSessionID?: string } = {})
when={filteredActiveSessions().length > 0}
fallback={
<div class="pl-1.5 py-2 text-v2-text-text-faint" style={{ "font-size": "12px" }}>
{searchQuery() ? language.t("home.sessions.search.noResults", { query: search() }) : language.t("home.sessions.empty")}
{searchQuery()
? language.t("home.sessions.search.noResults", { query: search() })
: activeListState() === "unfetched"
? language.t("common.loading")
: language.t("home.sessions.empty")}
</div>
}
>
Expand Down
44 changes: 44 additions & 0 deletions packages/app/src/context/global-sync/child-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,11 @@ import {
type IconCache,
type MetaCache,
type ProjectMeta,
type SessionSnapshotCache,
type State,
type VcsCache,
} from "./types"
import type { SessionSnapshot } from "./session-snapshot"
import { canDisposeDirectory, pickDirectoriesToEvict } from "./eviction"
import { useQuery } from "@tanstack/solid-query"
import { QueryOptionsApi } from "../server-sync"
Expand All @@ -39,6 +41,7 @@ export function createChildStoreManager(input: {
const vcsCache = new Map<string, VcsCache>()
const metaCache = new Map<string, MetaCache>()
const iconCache = new Map<string, IconCache>()
const snapshotCache = new Map<string, SessionSnapshotCache>()
const lifecycle = new Map<string, DirState>()
const pins = new Map<string, number>()
const ownerPins = new WeakMap<object, Set<string>>()
Expand Down Expand Up @@ -117,6 +120,7 @@ export function createChildStoreManager(input: {
vcsCache.delete(key)
metaCache.delete(key)
iconCache.delete(key)
snapshotCache.delete(key)
lifecycle.delete(key)
mcpDirectories.delete(key)
mcpToggles.delete(key)
Expand Down Expand Up @@ -181,6 +185,19 @@ export function createChildStoreManager(input: {
if (!icon) throw new Error(input.translate("error.childStore.persistedProjectIconCreateFailed"))
iconCache.set(key, { store: icon[0], setStore: icon[1], ready: icon[3] })

// D2: the persisted session snapshot is a render accelerator, never an
// authority — hydrated only until the first real list fetch resolves,
// and verified against the server's currency token on every boot.
const sessionSnapshot = runWithOwner(input.owner, () =>
input.persist(Persist.serverWorkspace(input.scope, directory, "session:snapshot"), createStore({ value: undefined as SessionSnapshot | undefined })),
)
if (!sessionSnapshot) throw new Error(input.translate("error.childStore.persistedCacheCreateFailed"))
snapshotCache.set(key, {
store: sessionSnapshot[0],
setStore: sessionSnapshot[1],
ready: sessionSnapshot[3],
})

const init = () =>
createRoot((dispose) => {
const initialMeta = meta[0].value
Expand Down Expand Up @@ -228,6 +245,7 @@ export function createChildStoreManager(input: {
},
session: [],
sessionTotal: 0,
sessions_fetched: false,
session_status: {},
session_working(id: string) {
const type = this.session_status[id]?.type
Expand Down Expand Up @@ -288,6 +306,16 @@ export function createChildStoreManager(input: {
if (child[0].icon !== initialIcon) return
child[1]("icon", icon[0].value)
})

// D2: hydrate the persisted snapshot as a render accelerator — only
// until a real list fetch resolves (sessions_fetched flips true),
// never over a store a fetch already filled.
onPersistedInit(sessionSnapshot[2], () => {
if (child[0].sessions_fetched) return
const cached = sessionSnapshot[0].value
if (!cached || child[0].session.length > 0) return
child[1]("session", cached.sessions)
})
})

runWithOwner(input.owner, init)
Expand Down Expand Up @@ -393,5 +421,21 @@ export function createChildStoreManager(input: {
vcsCache,
metaCache,
iconCache,
// D2: the persisted snapshot's read/write seam — server-sync verifies the
// token on every list response and overwrites the snapshot; the panel
// reset invalidates all of them.
sessionSnapshot(directory: string) {
return snapshotCache.get(directoryKey(directory))?.store.value
},
writeSessionSnapshot(directory: string, next: SessionSnapshot) {
const cache = snapshotCache.get(directoryKey(directory))
if (!cache) return
cache.setStore("value", next)
},
resetSessionSnapshots() {
for (const cache of snapshotCache.values()) {
cache.setStore("value", undefined)
}
},
}
}
77 changes: 77 additions & 0 deletions packages/app/src/context/global-sync/h1-client-boot.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { describe, expect, test } from "bun:test"
import { bootClient, createSeededHub, memorySessionStorage } from "./h1-client-boot"

const session = (id: string, updated: number) => ({
id,
directory: "/home",
projectID: "p1",
slug: id,
version: "test",
title: `Session ${id}`,
time: { created: updated, updated },
})

const hubSessions = () => [session("ses_a", 100), session("ses_b", 200)]

describe("H1 — the fresh-client boot harness (#288's headless boot, hub request log)", () => {
test("a fresh client boots with the session-list fetch initiated before the list first renders", async () => {
const hub = createSeededHub({ sessions: hubSessions(), currency: "v1.4.200.200.build-a" })
const storage = memorySessionStorage()

const boot = await bootClient({ hub, storage, directory: "/home" })

// The criterion is measured on the hub's request log, the same evidence
// that diagnosed #293: at least one session-list request in the boot
// window.
expect(boot.sessionListRequests.length).toBeGreaterThanOrEqual(1)
expect(boot.fetchedBeforeFirstRender).toBe(true)
// The fetched rows are adopted and the state is honest: a resolved fetch
// over a populated projection renders ready.
expect(boot.rendered.map((item) => item.id)).toEqual(["ses_b", "ses_a"])
expect(boot.state).toBe("ready")
// The snapshot layer now holds the fetched rows plus the token.
const persisted = storage.read("/home")
expect(persisted?.currency).toBe("v1.4.200.200.build-a")
expect(persisted?.sessions).toHaveLength(2)
})

test("a client seeded with the #293 stale-storage shape self-heals on boot", async () => {
const hub = createSeededHub({ sessions: hubSessions(), currency: "v1.4.200.200.build-b" })
const storage = memorySessionStorage({
"/home": { sessions: [session("ses_stale", 1)], currency: "v1.4.200.200.build-a" },
})

const boot = await bootClient({ hub, storage, directory: "/home" })

expect(boot.selfHealed).toBe(true)
// The stale snapshot never wins: the rendered list is the hub's.
expect(boot.rendered.map((item) => item.id)).toEqual(["ses_b", "ses_a"])
expect(boot.state).toBe("ready")
// The persisted snapshot was re-primed from the server's truth.
expect(storage.read("/home")?.currency).toBe("v1.4.200.200.build-b")
})

test("a tokenless persisted snapshot (the founding shape) is invalidated too", async () => {
const hub = createSeededHub({ sessions: [], currency: "v1.4.200.200.build-b" })
const storage = memorySessionStorage({ "/home": { sessions: [session("ses_stale", 1)] } })

const boot = await bootClient({ hub, storage, directory: "/home" })

expect(boot.selfHealed).toBe(true)
expect(boot.rendered).toHaveLength(0)
// Honest states: a resolved fetch over an empty projection is genuinely
// empty — never "not yet fetched".
expect(boot.state).toBe("empty")
})

test("a fresh snapshot is not flagged as self-healed", async () => {
const currency = "v1.4.200.200.build-a"
const hub = createSeededHub({ sessions: hubSessions(), currency })
const storage = memorySessionStorage({ "/home": { sessions: hubSessions(), currency } })

const boot = await bootClient({ hub, storage, directory: "/home" })

expect(boot.selfHealed).toBe(false)
expect(boot.rendered).toHaveLength(2)
})
})
150 changes: 150 additions & 0 deletions packages/app/src/context/global-sync/h1-client-boot.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,150 @@
import type { Session } from "@opencode-ai/sdk/v2/client"
import { bootCurrencyDecision, toSnapshot, type SessionSnapshot } from "./session-snapshot"
import { sessionListState, type SessionListState } from "@/utils/session-list-state"

/**
* H1 — the fresh-client boot harness (#288's headless boot).
*
* Boots the client's session-list layer against a seeded hub and asserts on
* the hub's request log — the same evidence that diagnosed #293. Headless by
* design (no DOM): it composes the real boot pieces — persisted-snapshot
* hydration, the unconditional boot fetch, bootCurrencyDecision, the honest
* sessionListState machine — in the boot order the D2 contract requires. The
* fetch-before-first-render and boot-self-heal criteria's tests wire through
* this fixture.
*/

export type HubRequest = {
endpoint: "session.list"
directory?: string
limit?: number
order?: string
cursor?: string
/** Monotonic tick — boot ordering assertions read this. */
at: number
}

export type SeededHub = {
/** The v2 session.list surface, shaped like the real client's. */
api: { list: (input: { directory: string; parentID: null; limit: number; order: "desc"; cursor?: string }) => Promise<{ data: Session[]; cursor: { next?: string }; currency?: string | null }> }
/** Every request the hub saw, in order. */
log: HubRequest[]
sessions: Session[]
currency: string | undefined
}

export function createSeededHub(input: { sessions: Session[]; currency?: string; pageSize?: number }): SeededHub {
const log: HubRequest[] = []
let tick = 0
const pageSize = input.pageSize ?? input.sessions.length
// desc store: newest first.
const ordered = input.sessions.toReversed()
return {
log,
sessions: input.sessions,
currency: input.currency,
api: {
list: async (request) => {
const start = request.cursor ? Number(request.cursor) : 0
const page = ordered.slice(start, start + (pageSize || input.sessions.length))
const next = start + page.length < input.sessions.length && page.length > 0 ? String(start + page.length) : undefined
log.push({
endpoint: "session.list",
directory: request.directory,
limit: request.limit,
order: request.order,
cursor: request.cursor,
at: tick++,
})
return { data: page, cursor: { next }, currency: input.currency }
},
},
}
}

export type PersistedSessionStorage = {
read: (key: string) => SessionSnapshot | undefined
write: (key: string, snapshot: SessionSnapshot | undefined) => void
}

export function memorySessionStorage(seed: Record<string, SessionSnapshot> = {}): PersistedSessionStorage {
const store = new Map(Object.entries(seed))
return {
read: (key) => structuredClone(store.get(key)),
write: (key, snapshot) => {
if (snapshot === undefined) store.delete(key)
else store.set(key, structuredClone(snapshot))
},
}
}

export type BootResult = {
/** Every session-list request the hub saw during the boot window. */
sessionListRequests: HubRequest[]
/** The rendered projection after the boot fetch resolved. */
rendered: Session[]
/** The session-list fetch was initiated before the list first rendered. */
fetchedBeforeFirstRender: boolean
/** A seeded snapshot was proven stale (the #293 shape) and invalidated. */
selfHealed: boolean
/** The honest list state after boot. */
state: SessionListState
}

/** The persisted snapshot store is per-workspace; the harness keys it by the
* booted directory (the real client namespaces this inside its workspace
* storage as the `session:snapshot` target). */
const snapshotKey = (directory: string) => directory

export async function bootClient(input: {
hub: SeededHub
storage: PersistedSessionStorage
directory: string
}): Promise<BootResult> {
const key = snapshotKey(input.directory)
const timeline: string[] = []

// Boot order per D2: hydrate the persisted snapshot (accelerator), initiate
// the session-list fetch BEFORE the list first renders, then render.
timeline.push("snapshot-hydrated")
const snapshot = input.storage.read(key)

// The fetch is initiated unconditionally — a persisted snapshot is never an
// authority, so no boot may skip it (the founding #293 failure).
let fetchFailure: unknown
const pending = input.hub.api
.list({ directory: input.directory, parentID: null, limit: 100, order: "desc" })
.then((response) => ({ response }))
.catch((error) => {
fetchFailure = error
return undefined
})
timeline.push("fetch-initiated")

// First render: the snapshot as accelerator, or the honest unfetched state.
timeline.push("first-render")
const firstRendered = snapshot?.sessions ?? []
void firstRendered

const settled = await pending
timeline.push("fetch-resolved")
if (fetchFailure !== undefined) throw fetchFailure

const response = settled!.response
const decision = bootCurrencyDecision({ snapshot, response: { sessions: response.data, currency: response.currency } })
// Invalidation is materialized by the overwrite: the fetched rows are the
// authority and the snapshot layer is re-primed from them.
input.storage.write(key, toSnapshot(response.data, decision.currency))

return {
sessionListRequests: input.hub.log.filter((request) => request.endpoint === "session.list"),
rendered: response.data,
// The criterion measured on the boot order: the fetch was initiated
// before the list first rendered — and the hub's log carries it.
fetchedBeforeFirstRender:
timeline.indexOf("fetch-initiated") < timeline.indexOf("first-render") &&
input.hub.log.some((request) => request.endpoint === "session.list"),
selfHealed: decision.stale,
state: sessionListState({ fetched: true, count: response.data.length }),
}
}
Loading
Loading