Files
openchamber/packages/ui/src/stores/useGlobalSessionsStore-races.test.ts
T
Serhii Dziupin 577aca0437 feat: add restore/unarchive for archived sessions
Archived sessions had no way back to the active list: the only available
action was "Delete permanently". Add restore per session (sidebar context
menu, Archive page row) and in bulk (sidebar selection bar).

The OpenCode server cannot clear time.archived over HTTP — session.update
only applies the field for a finite number, so an omitted key is a no-op
and null is silently ignored (verified against opencode 1.18.12). Restore
therefore writes time.archived = 0: every client-side reader classifies
archive state by truthiness, so 0 reads as active in the UI, the event
reducer, and the OpenCode app/TUI.

The server's time_archived IS NULL list filter still excludes such rows,
so the global session cache no longer issues an archived:false request
for its active list. Full and per-directory loads now fetch once with
the inclusive flag and split client-side via splitGlobalSessionsByArchived,
which also halves per-directory refresh requests. Directory bootstrap
keeps the server filter because live child stores must not hold archived
sessions; a restored session re-enters its live store through the
authoritative session.updated event.

unarchiveSession/unarchiveSessions follow the archiveSession contract:
wait for server confirmation before reconciling stores, runtime-guard
every reconciliation, preserve partial batch results, and fail loudly
when the server keeps the session archived instead of toasting a
successful no-op.

Closes #2346
2026-08-04 13:21:04 +03:00

157 lines
6.0 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, test } from "bun:test"
import type { OpencodeClient, Session } from "@opencode-ai/sdk/v2"
import { opencodeClient } from "@/lib/opencode/client"
import { useGlobalSessionsStore } from "./useGlobalSessionsStore"
type Deferred<T> = {
promise: Promise<T>
resolve: (value: T) => void
reject: (reason: unknown) => void
}
const deferred = <T>(): Deferred<T> => {
let resolve!: (value: T) => void
let reject!: (reason: unknown) => void
const promise = new Promise<T>((res, rej) => {
resolve = res
reject = rej
})
return { promise, resolve, reject }
}
let listRequest: Deferred<Session[]>
// The store issues one inclusive (`archived: true`) paginated request per
// load/refresh scope and splits active/archived client-side, so restored
// sessions (`time.archived` falsy-but-present) stay visible in the active
// list. The mock serves that single request.
const sdk = {
experimental: {
session: {
list: async () => ({
data: await listRequest.promise,
response: { headers: new Headers() },
}),
},
},
} as unknown as OpencodeClient
const originalGetSdkClient = opencodeClient.getSdkClient
const session = (id: string, title = id, archived?: number): Session => ({
id,
title,
time: { created: 1, updated: 1, ...(archived !== undefined ? { archived } : {}) },
} as Session)
describe("global session mutation reconciliation", () => {
beforeEach(() => {
listRequest = deferred<Session[]>()
opencodeClient.getSdkClient = () => sdk
useGlobalSessionsStore.getState().resetForRuntimeSwitch()
})
afterEach(() => {
opencodeClient.getSdkClient = originalGetSdkClient
})
test("keeps a session created after a full load starts", async () => {
const loading = useGlobalSessionsStore.getState().loadSessions()
useGlobalSessionsStore.getState().upsertSession(session("created"))
listRequest.resolve([])
await loading
expect(useGlobalSessionsStore.getState().activeSessions.map((item) => item.id)).toEqual(["created"])
})
test("does not resurrect a session deleted after a full load starts", async () => {
const stale = session("deleted")
useGlobalSessionsStore.getState().applySnapshot([stale], [])
const loading = useGlobalSessionsStore.getState().loadSessions()
useGlobalSessionsStore.getState().removeSessions([stale.id])
listRequest.resolve([stale])
await loading
expect(useGlobalSessionsStore.getState().activeSessions).toEqual([])
expect(useGlobalSessionsStore.getState().archivedSessions).toEqual([])
})
test("keeps an archive mutation newer than both list requests", async () => {
const stale = session("archived")
useGlobalSessionsStore.getState().applySnapshot([stale], [])
const loading = useGlobalSessionsStore.getState().loadSessions()
useGlobalSessionsStore.getState().archiveSessions([stale.id], 10)
listRequest.resolve([stale])
await loading
expect(useGlobalSessionsStore.getState().activeSessions).toEqual([])
expect(useGlobalSessionsStore.getState().archivedSessions[0]?.time.archived).toBe(10)
})
test("keeps a newer title when an older response finishes last", async () => {
const stale = session("updated", "Old")
useGlobalSessionsStore.getState().applySnapshot([stale], [])
const loading = useGlobalSessionsStore.getState().loadSessions()
useGlobalSessionsStore.getState().upsertSession(session("updated", "New"))
listRequest.resolve([stale])
await loading
expect(useGlobalSessionsStore.getState().activeSessions[0]?.title).toBe("New")
})
test("uses commit-time state when the load fails", async () => {
const created = session("created")
const loading = useGlobalSessionsStore.getState().loadSessions()
useGlobalSessionsStore.getState().upsertSession(created)
listRequest.reject(new Error("unavailable"))
await loading
expect(useGlobalSessionsStore.getState().activeSessions).toEqual([created])
expect(useGlobalSessionsStore.getState().status).toBe("error")
})
test("splits a restored session into the active list", async () => {
const loading = useGlobalSessionsStore.getState().loadSessions()
listRequest.resolve([session("active"), session("archived", "archived", 5), session("restored", "restored", 0)])
await loading
expect(useGlobalSessionsStore.getState().activeSessions.map((item) => item.id)).toEqual(["active", "restored"])
expect(useGlobalSessionsStore.getState().archivedSessions.map((item) => item.id)).toEqual(["archived"])
expect(useGlobalSessionsStore.getState().status).toBe("ready")
})
test("does not undo a move while refreshing the source directory", async () => {
const source = { ...session("moved"), directory: "/source" } as Session
const destination = { ...source, directory: "/destination" } as Session
useGlobalSessionsStore.getState().applySnapshot([source], [])
const refreshing = useGlobalSessionsStore.getState().refreshSessionsForDirectories(["/source"])
useGlobalSessionsStore.getState().upsertSession(destination)
listRequest.resolve([source])
await refreshing
expect(useGlobalSessionsStore.getState().sessionsByDirectory.get("/source")).toBe(undefined)
expect(useGlobalSessionsStore.getState().sessionsByDirectory.get("/destination")?.[0]?.id).toBe("moved")
})
test("keeps a restore mutation newer than the directory refresh", async () => {
const archived = { ...session("restored", "restored", 5), directory: "/source" } as Session
useGlobalSessionsStore.getState().applySnapshot([], [archived])
const refreshing = useGlobalSessionsStore.getState().refreshSessionsForDirectories(["/source"])
useGlobalSessionsStore.getState().upsertSession({ ...archived, time: { ...archived.time, archived: 0 } })
// The server still reports the pre-restore row for this directory.
listRequest.resolve([archived])
await refreshing
expect(useGlobalSessionsStore.getState().activeSessions.map((item) => item.id)).toEqual(["restored"])
expect(useGlobalSessionsStore.getState().archivedSessions).toEqual([])
})
})