fix(tui): open recent picker before server reads (#45977)

Open the recent-session and project picker synchronously with selectable cached rows and independent refreshes. Reconcile committed moves and deletions without restoring stale rows, preserve dismissal and selection through delayed reads, and keep filtered selections visible after asynchronous results arrive.
This commit is contained in:
Kit Langton 2026-08-28 15:18:24 -04:00 committed by GitHub
parent ebdfcf4866
commit 426e5c6389
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 580 additions and 45 deletions

View file

@ -68,7 +68,7 @@ import { DialogThemeList } from "./component/dialog-theme-list"
import { DialogHelp } from "./ui/dialog-help"
import { DialogAgent } from "./component/dialog-agent"
import { DialogSessionList } from "./component/dialog-session-list"
import { DialogOpen, DialogOpenKey, loadDialogOpen } from "./component/dialog-open"
import { DialogOpen, DialogOpenKey, moveOpenSession } from "./component/dialog-open"
import { SessionTabs } from "./component/session-tabs"
import { clampSessionTabsWidth, sessionTabsFitVertically, SESSION_SIDEBAR_WIDTH } from "./ui/layout"
import { createPaneResize } from "./ui/pane-resize"
@ -507,7 +507,7 @@ function App(props: { pair?: DialogPairCredentials }) {
}).catch((error) => console.error("Failed to persist TUI layout", error))
},
})
let openingOpen: Promise<SessionInfo[]> | undefined
const [openSessions, setOpenSessions] = createSignal<SessionInfo[]>([])
// Toast once when an MCP server enters a failed or needs-auth state so the user knows to act,
// without having to open the status panel. Tracking the last alerted status avoids re-toasting
// the same problem on every refresh while still re-alerting if the state changes.
@ -719,14 +719,12 @@ function App(props: { pair?: DialogPairCredentials }) {
title: "Open session or project",
category: "Session",
slash: { name: "open", aliases: ["projects", "project"] },
run: async () => {
if (dialog.key === DialogOpenKey || openingOpen) return
const previous = dialog.stack.at(-1)
openingOpen = loadDialogOpen(data, client)
const sessions = await openingOpen
openingOpen = undefined
if (dialog.stack.at(-1) !== previous) return
dialog.replace(() => <DialogOpen sessions={sessions} />, undefined, { key: DialogOpenKey, size: "large" })
run: () => {
if (dialog.key === DialogOpenKey) return
dialog.replace(() => <DialogOpen sessions={openSessions()} onLoad={setOpenSessions} />, undefined, {
key: DialogOpenKey,
size: "large",
})
},
},
...Array.from({ length: 9 }, (_, i) => ({
@ -1213,7 +1211,14 @@ function App(props: { pair?: DialogPairCredentials }) {
})
})
event.on("session.moved", (evt) => {
setOpenSessions((sessions) =>
sessions.map((session) => (session.id !== evt.data.sessionID ? session : moveOpenSession(session, evt))),
)
})
event.on("session.deleted", (evt) => {
setOpenSessions((sessions) => sessions.filter((session) => session.id !== evt.data.sessionID))
if (route.data.type === "session" && route.data.sessionID === evt.data.sessionID) {
const title = active?.id === evt.data.sessionID ? active.title : undefined
route.navigate({ type: "home" })

View file

@ -1,5 +1,5 @@
import { createMemo, createResource, createSignal } from "solid-js"
import type { SessionInfo } from "@opencode-ai/client"
import { createMemo, createResource, createSignal, onCleanup, Show } from "solid-js"
import type { OpenCodeEvent, SessionInfo } from "@opencode-ai/client"
import { useTerminalDimensions } from "@opentui/solid"
import type { RGBA } from "@opentui/core"
import { dialogWidth, useDialog } from "../ui/dialog"
@ -25,18 +25,7 @@ export const DialogOpenKey = Symbol("DialogOpen")
type OpenTarget = { type: "session"; sessionID: string } | { type: "project"; directory: string }
export async function loadDialogOpen(data: ReturnType<typeof useData>, client: ReturnType<typeof useClient>) {
const [, sessions] = await Promise.all([
data.project.sync().catch(() => {}),
client.api.session
.list({ limit: 50, order: "desc", parentID: null })
.then((response) => response.data)
.catch(() => [] as SessionInfo[]),
])
return sessions
}
export function DialogOpen(props: { sessions: SessionInfo[] }) {
export function DialogOpen(props: { sessions: SessionInfo[]; onLoad: (sessions: SessionInfo[]) => void }) {
const dialog = useDialog()
const route = useRoute()
const data = useData()
@ -51,6 +40,41 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
const shortcuts = Keymap.useShortcuts()
const [filter, setFilter] = createSignal("")
const [selectionMoved, setSelectionMoved] = createSignal(false)
let closed = false
onCleanup(() => {
closed = true
})
const [recent] = createResource(() => {
// A late read must not overwrite deletion or placement facts observed in flight.
const changed = new Map<string, Extract<OpenCodeEvent, { type: "session.deleted" | "session.moved" }>>()
const unsubscribe = client.event.listen((message) => {
const event = message.details
if (event.type === "session.deleted" || event.type === "session.moved") changed.set(event.data.sessionID, event)
})
onCleanup(unsubscribe)
return client.api.session
.list({ limit: 50, order: "desc", parentID: null })
.then((response) => {
if (!closed)
props.onLoad(
response.data.flatMap((session) => {
const event = changed.get(session.id)
if (!event) return [session]
if (event.type === "session.deleted") return []
return [moveOpenSession(props.sessions.find((entry) => entry.id === session.id) ?? session, event)]
}),
)
return true
})
.catch(() => false)
.finally(unsubscribe)
})
const [projects] = createResource(() =>
data.project.sync().then(
() => true,
() => false,
),
)
const [matched] = createResource(
() => {
@ -154,12 +178,34 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
preserveSelection={selectionMoved()}
onMove={() => setSelectionMoved(true)}
onFilter={setFilter}
emptyView={
<Show when={!recent.loading && !projects.loading}>
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>No recent sessions or projects</text>
</box>
</Show>
}
footer={
<box>
<Show when={recent.loading || projects.loading}>
<Spinner color={theme.text.subdued}>Refreshing sessions and projects...</Spinner>
</Show>
<Show when={recent() === false || projects() === false}>
<text fg={theme.text.feedback.error.default}>
Could not refresh{" "}
{recent() === false ? (projects() === false ? "sessions and projects" : "sessions") : "projects"}.
</text>
</Show>
</box>
}
noMatchView={
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>
{shortcuts.get("session.list")
? `No matches · search all sessions with ${shortcuts.get("session.list")}`
: "No matches"}
{recent.loading || projects.loading || matched.loading
? "Searching sessions and projects..."
: shortcuts.get("session.list")
? `No matches · search all sessions with ${shortcuts.get("session.list")}`
: "No matches"}
</text>
</box>
}
@ -177,6 +223,16 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) {
)
}
export function moveOpenSession(session: SessionInfo, event: Extract<OpenCodeEvent, { type: "session.moved" }>) {
return {
...session,
location: event.data.location,
projectID: event.data.projectID ?? session.projectID,
subpath: event.data.subpath,
time: { ...session.time, updated: Math.max(session.time.updated, event.created) },
}
}
function timeAgo(timestamp: number) {
const minutes = Math.floor((Date.now() - timestamp) / 60_000)
if (minutes < 1) return "now"

View file

@ -284,8 +284,8 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
selection = option
if (!moved) return
if (
(!props.preserveSelection && (props.current === undefined || props.focusCurrent === false)) ||
store.filter.length > 0
!props.preserveSelection &&
(props.current === undefined || props.focusCurrent === false || store.filter.length > 0)
)
return
scrollAfterLayout(false, option.value)

View file

@ -8,6 +8,241 @@ import path from "node:path"
import { createEventStream, createFetch, directory, json } from "./fixture/tui-client"
import { tmpdir } from "./fixture/fixture"
test.each([100, 44])("Ctrl-O is immediate, dismissible, and prunes cached deletions at width %s", async (width) => {
await using state = await tmpdir()
const setup = await createTestRenderer({ width, height: 30, useThread: false, kittyKeyboard: true })
setup.renderer.start()
const ready = Promise.withResolvers<void>()
const requested = Promise.withResolvers<void>()
const response = Promise.withResolvers<Response>()
const projects = Promise.withResolvers<Response>()
const refresh = Promise.withResolvers<Response>()
const events = createEventStream()
const cachedSession = {
id: "ses_cached",
title: "Cached session",
projectID: "proj_fixture",
location: { directory: "/fixture" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 2 },
}
let requests = 0
const calls = createFetch((url) => {
if (url.pathname === "/api/session") {
requests++
requested.resolve()
if (requests === 1) return response.promise
if (requests === 2 || requests === 4) return refresh.promise.then((response) => response.clone())
if (requests > 4) return new Response("Unavailable", { status: 503 })
return json({ data: [cachedSession], cursor: {} })
}
if (url.pathname === "/api/project") return projects.promise
return undefined
}, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
try {
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({ animations: false }), update: async () => ({}) },
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
args: {},
log: () => {},
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
)
await ready.promise
await setup.waitForFrame((frame) => frame.includes("commands"))
setup.mockInput.pressKey("o", { ctrl: true })
await requested.promise
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain("Search sessions")
expect(setup.captureCharFrame()).toContain("Refreshing")
projects.resolve(
json([
{
id: "proj_fixture",
canonical: "/fixture",
name: "Fixture project",
time: { created: 1, updated: 2 },
sandboxes: [],
},
]),
)
await setup.waitForFrame((frame) => frame.includes("Fixture project"))
setup.mockInput.pressKey("o", { ctrl: true })
expect(requests).toBe(1)
setup.mockInput.pressEscape()
await setup.waitForFrame((frame) => !frame.includes("Fixture project"))
response.resolve(json({ data: [{ ...cachedSession, id: "ses_disposed", title: "Disposed response" }], cursor: {} }))
await setup.renderOnce()
expect(setup.captureCharFrame()).not.toContain("Fixture project")
setup.mockInput.pressKey("o", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes("Refreshing"))
expect(setup.captureCharFrame()).not.toContain("Disposed")
setup.mockInput.pressEscape()
await setup.waitForFrame((frame) => !frame.includes("Fixture project"))
setup.mockInput.pressKey("o", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes("Cached"))
setup.mockInput.pressEscape()
await setup.waitForFrame((frame) => !frame.includes("Fixture project"))
setup.mockInput.pressKey("o", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes("Cached") && frame.includes("Refreshing"))
events.emit({
id: "evt_deleted",
created: 1,
type: "session.deleted",
durable: { aggregateID: "ses_cached", seq: 1, version: 2 },
data: { sessionID: "ses_cached" },
})
await setup.waitForFrame((frame) => !frame.includes("Cached"))
refresh.resolve(json({ data: [cachedSession], cursor: {} }))
await setup.waitForFrame((frame) => frame.includes("Fixture project") && !frame.includes("Refreshing"))
expect(setup.captureCharFrame()).not.toContain("Cached")
setup.mockInput.pressEscape()
await setup.waitForFrame((frame) => !frame.includes("Fixture project"))
setup.mockInput.pressKey("o", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes("Could not refresh sessions"))
expect(setup.captureCharFrame()).not.toContain("Cached")
setup.renderer.destroy()
await task
} finally {
response.resolve(json({ data: [], cursor: {} }))
projects.resolve(json([]))
refresh.resolve(json({ data: [], cursor: {} }))
if (!setup.renderer.isDestroyed) setup.renderer.destroy()
await server.stop()
}
})
test.each(["dismissed", "refreshing"])(
"Ctrl-O retains committed movement of a cached-only session while %s",
async (phase) => {
await using state = await tmpdir()
const setup = await createTestRenderer({ width: 100, height: 30, useThread: false, kittyKeyboard: true })
setup.renderer.start()
const ready = Promise.withResolvers<void>()
const refresh = Promise.withResolvers<Response>()
const metadata = Promise.withResolvers<Response>()
const destinationRequested = Promise.withResolvers<void>()
const events = createEventStream()
const cached = {
id: "ses_cached_move",
title: "Cached movement",
projectID: "proj_old",
location: { directory: "/fixture/old" },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 2 },
}
let requests = 0
const locations: string[] = []
const calls = createFetch((url) => {
if (url.pathname === "/api/session") {
if (url.searchParams.has("parentID")) {
const parent = url.searchParams.get("parentID")
if (parent && parent !== "null") return json({ data: [], cursor: {} })
}
return requests++ === 0
? json({ data: [cached], cursor: {} })
: refresh.promise.then((response) => response.clone())
}
if (url.pathname === `/api/session/${cached.id}`) return metadata.promise
if (url.pathname === `/api/session/${cached.id}/message`) return json({ data: [], cursor: {} })
if (url.pathname === `/api/session/${cached.id}/inbox` || url.pathname === `/api/session/${cached.id}/permission`)
return json({ data: [] })
if (url.pathname === "/api/project")
return json(
["old", "new"].map((name) => ({
id: `proj_${name}`,
canonical: `/fixture/${name}`,
name: name === "old" ? "Old" : "New",
time: { created: 1, updated: 2 },
sandboxes: [],
})),
)
if (url.pathname === "/api/location") {
const query = url.searchParams.get("location[directory]") ?? ""
locations.push(query)
if (query.includes("/fixture/new")) {
destinationRequested.resolve()
return json({
directory: "/fixture/new",
project: { id: "proj_new", directory: "/fixture/new", canonical: "/fixture/new" },
})
}
}
return undefined
}, events)
const server = Bun.serve({ port: 0, fetch: (request) => calls.fetch(request) })
const { run } = await import("../src/app")
const task = Effect.runPromise(
run({
app: { name: "test", version: "test", channel: "test" },
server: { endpoint: { url: server.url.toString() } },
config: { get: async () => ({ animations: false, tabs: { enabled: false } }), update: async () => ({}) },
packages: { resolve: async () => undefined },
terminalHandoff: async () => ({ renderer: setup.renderer, mode: "dark", complete: ready.resolve }),
args: {},
log: () => {},
}).pipe(Effect.provide(Global.layerWith({ state: state.path })), Effect.provide(FileSystem.layerNoop({}))),
)
try {
await ready.promise
await setup.waitForFrame((frame) => frame.includes("commands"))
setup.mockInput.pressKey("o", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes(cached.title) && !frame.includes("Refreshing"))
setup.mockInput.pressEscape()
await setup.waitForFrame((frame) => !frame.includes("Search sessions"))
if (phase === "refreshing") {
setup.mockInput.pressKey("o", { ctrl: true })
await setup.waitForFrame((frame) => frame.includes(cached.title) && frame.includes("Refreshing"))
}
events.emit({
id: "evt_cached_moved",
created: 3,
type: "session.moved",
durable: { aggregateID: cached.id, seq: 1, version: 1 },
data: { sessionID: cached.id, location: { directory: "/fixture/new" }, projectID: "proj_new" },
})
if (phase === "dismissed") {
setup.mockInput.pressKey("o", { ctrl: true })
refresh.resolve(new Response("Unavailable", { status: 503 }))
await setup.waitForFrame((frame) => frame.includes("Could not refresh sessions"))
}
if (phase === "refreshing") {
await setup.waitForFrame((frame) =>
frame.split("\n").some((line) => line.includes(cached.title) && line.includes("New")),
)
refresh.resolve(json({ data: [cached], cursor: {} }))
await setup.waitForFrame((frame) => frame.includes(cached.title) && !frame.includes("Refreshing"))
}
await setup.waitForFrame((frame) =>
frame.split("\n").some((line) => line.includes(cached.title) && line.includes("New")),
)
expect(
setup
.captureCharFrame()
.split("\n")
.find((line) => line.includes(cached.title)),
).toContain("New")
locations.length = 0
setup.mockInput.pressEnter()
await destinationRequested.promise
expect(locations.some((query) => query.includes("/fixture/old"))).toBe(false)
} finally {
refresh.resolve(json({ data: [], cursor: {} }))
metadata.resolve(json({ data: { ...cached, projectID: "proj_new", location: { directory: "/fixture/new" } } }))
setup.renderer.destroy()
await task
await server.stop()
}
},
)
test("SIGHUP clears title and disposes scoped resources once", async () => {
const setup = await createTestRenderer({ width: 80, height: 24, useThread: false })
const titles: string[] = []

View file

@ -1,10 +1,13 @@
/** @jsxImportSource @opentui/solid */
import { expect, test } from "bun:test"
import { once } from "node:events"
import { CliRenderEvents, TextAttributes } from "@opentui/core"
import { testRender } from "@opentui/solid"
import { onMount } from "solid-js"
import { DialogOpen, DialogOpenKey, loadDialogOpen } from "../../../src/component/dialog-open"
import { createSignal, onMount } from "solid-js"
import type { SessionInfo } from "@opencode-ai/client"
import { DialogOpen, DialogOpenKey } from "../../../src/component/dialog-open"
import { ConfigProvider } from "../../../src/config"
import { ClientProvider, useClient } from "../../../src/context/client"
import { ClientProvider } from "../../../src/context/client"
import { DataProvider, useData } from "../../../src/context/data"
import { Keymap } from "../../../src/context/keymap"
import { LocationProvider, useLocation } from "../../../src/context/location"
@ -85,7 +88,7 @@ test("finds and opens an exact session ID outside the recent list", async () =>
expect(fixture.route.data).toEqual({ type: "session", sessionID })
expect(fixture.location.ref).toEqual(remote)
} finally {
fixture.dispose()
await fixture.dispose()
}
})
@ -131,7 +134,7 @@ test("shows the current project and opens its root", async () => {
}
})
test("waits for sessions before showing the populated picker", async () => {
test("shows projects while sessions refresh and preserves the selected project", async () => {
let resolveSessions!: (response: Response) => void
const sessions = new Promise<Response>((resolve) => (resolveSessions = resolve))
const fixture = await renderOpen((url) => {
@ -157,8 +160,9 @@ test("waits for sessions before showing the populated picker", async () => {
})
try {
await fixture.app.renderOnce()
expect(fixture.app.captureCharFrame()).not.toContain("Search sessions and projects")
await fixture.app.waitForFrame((frame) => frame.includes("Second project") && frame.includes("Refreshing"))
expect(fixture.app.captureCharFrame()).toContain("Search sessions and projects")
fixture.app.mockInput.pressArrow("down")
resolveSessions(
json({
@ -177,8 +181,6 @@ test("waits for sessions before showing the populated picker", async () => {
}),
)
await fixture.app.waitForFrame((frame) => frame.includes("Recent session") && frame.includes("Second project"))
fixture.app.mockInput.pressArrow("down")
fixture.app.mockInput.pressArrow("down")
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "home")
@ -188,6 +190,240 @@ test("waits for sessions before showing the populated picker", async () => {
}
})
test.each([false, true])("keeps a filtered selection visible after refresh with query reset %s", async (reset) => {
const sessions = Promise.withResolvers<Response>()
const fixture = await renderOpen((url) => {
if (url.pathname === "/api/session") return sessions.promise
if (url.pathname === "/api/project")
return json([
{
id: "proj_first",
canonical: "/tmp/opencode/first",
name: "First shared project",
time: { created: 1, updated: 2 },
sandboxes: [],
},
{
id: "proj_second",
canonical: "/tmp/opencode/second",
name: "Second shared project",
time: { created: 1, updated: 1 },
sandboxes: [],
},
])
return undefined
})
const selectedTitle = () =>
fixture.app
.captureSpans()
.lines.flatMap((line) => line.spans)
.filter((span) => span.attributes & TextAttributes.BOLD)
.map((span) => span.text)
.join("")
try {
await fixture.app.waitForFrame((frame) => frame.includes("Second shared project") && frame.includes("Refreshing"))
await fixture.app.mockInput.typeText("shared")
await fixture.app.waitForFrame(() => selectedTitle().includes("First shared project"))
fixture.app.mockInput.pressArrow("down")
await fixture.app.waitForFrame(() => selectedTitle().includes("Second shared project"))
sessions.resolve(
json({
data: Array.from({ length: 12 }, (_, index) => ({
...recentSession,
id: `ses_shared_${index}`,
title: "shared",
time: { created: 1, updated: index + 3 },
})),
cursor: {},
}),
)
await fixture.app.waitForFrame((frame) => frame.includes("Open") && !frame.includes("Refreshing"))
// Selection reveal runs on FRAME; the following paint must show the selected row.
const frame = once(fixture.app.renderer, CliRenderEvents.FRAME)
fixture.app.renderer.requestRender()
await frame
expect(fixture.app.captureCharFrame()).toContain("Second shared project")
expect(selectedTitle()).toContain("Second shared project")
if (reset) {
await fixture.app.mockInput.typeText(" project")
await fixture.app.waitForFrame(() => selectedTitle().includes("First shared project"))
expect(fixture.app.captureCharFrame()).toContain("Second shared project")
expect(selectedTitle()).not.toContain("Second shared project")
}
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "home")
expect(fixture.route.data).toEqual({
type: "home",
location: { directory: `/tmp/opencode/${reset ? "first" : "second"}` },
})
} finally {
sessions.resolve(json({ data: [], cursor: {} }))
await fixture.dispose()
}
})
const recentSession = {
id: "ses_recent",
projectID: "proj_recent",
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 1, updated: 2 },
title: "Recent session",
location: { directory: "/fixture" },
}
test("sessions remain selectable while projects are still loading", async () => {
const projects = Promise.withResolvers<Response>()
const fixture = await renderOpen((url) => {
if (url.pathname === "/api/session") return json({ data: [recentSession], cursor: {} })
if (url.pathname === "/api/project") return projects.promise
return undefined
})
try {
await fixture.app.waitForFrame((frame) => frame.includes("Recent session") && frame.includes("Refreshing"))
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "session")
expect(fixture.route.data).toEqual({ type: "session", sessionID: recentSession.id })
} finally {
projects.resolve(json([]))
await fixture.dispose()
}
})
test("shows hydrated sessions immediately without waiting for either read", async () => {
const sessions = Promise.withResolvers<Response>()
const projects = Promise.withResolvers<Response>()
const fixture = await renderOpen(
(url) => {
if (url.pathname === "/api/session") return sessions.promise
if (url.pathname === "/api/project") return projects.promise
return undefined
},
({ data }) => data.session.remember(recentSession),
)
try {
await fixture.app.waitForFrame((frame) => frame.includes("Recent session") && frame.includes("Refreshing"))
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "session")
expect(fixture.route.data).toEqual({ type: "session", sessionID: recentSession.id })
} finally {
sessions.resolve(json({ data: [], cursor: {} }))
projects.resolve(json([]))
await fixture.dispose()
}
})
test("keeps an uncached moved session in the first successful refresh", async () => {
const response = Promise.withResolvers<Response>()
const destination = { directory: "/fixture/destination" }
const fixture = await renderOpen((url) => (url.pathname === "/api/session" ? response.promise : undefined))
try {
await fixture.app.waitForFrame((frame) => frame.includes("Refreshing"))
fixture.emit({
id: "evt_uncached_move",
created: 3,
type: "session.moved",
durable: { aggregateID: recentSession.id, seq: 1, version: 1 },
data: { sessionID: recentSession.id, location: destination, projectID: "proj_destination" },
})
// The following event supplies an ordered-stream receipt barrier without hydrating metadata.
fixture.emit({
id: "evt_move_received",
created: 4,
type: "session.execution.started",
durable: { aggregateID: recentSession.id, seq: 2, version: 1 },
data: { sessionID: recentSession.id },
})
await fixture.app.waitFor(() => fixture.data.session.status(recentSession.id) === "running")
expect(fixture.data.session.get(recentSession.id)).toBeUndefined()
response.resolve(
json({
data: [
{ ...recentSession, location: destination, projectID: "proj_destination", time: { created: 1, updated: 3 } },
],
cursor: {},
}),
)
await fixture.app.waitForFrame((frame) => frame.includes(recentSession.title) && !frame.includes("Refreshing"))
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "session")
expect(fixture.route.data).toEqual({ type: "session", sessionID: recentSession.id })
expect(fixture.location.ref).toEqual(destination)
} finally {
response.resolve(json({ data: [], cursor: {} }))
await fixture.dispose()
}
})
test("keeps the previous recent list usable when reopening fails to refresh", async () => {
let requests = 0
const fixture = await renderOpen((url) => {
if (url.pathname === "/api/session")
return requests++ === 0
? json({ data: [recentSession], cursor: {} })
: new Response("Unavailable", { status: 503 })
return undefined
})
try {
await fixture.app.waitForFrame((frame) => frame.includes("Recent session") && !frame.includes("Refreshing"))
fixture.app.mockInput.pressEscape()
await fixture.app.waitForFrame((frame) => !frame.includes("Recent session"))
fixture.open()
await fixture.app.waitForFrame((frame) => frame.includes("Could not refresh sessions"))
expect(fixture.app.captureCharFrame()).toContain("Recent session")
fixture.app.mockInput.pressEnter()
await fixture.app.waitFor(() => fixture.route.data.type === "session")
expect(fixture.route.data).toEqual({ type: "session", sessionID: recentSession.id })
} finally {
await fixture.dispose()
}
})
test("shows an initial loading shell instead of reporting an empty list", async () => {
const sessions = Promise.withResolvers<Response>()
const projects = Promise.withResolvers<Response>()
const fixture = await renderOpen((url) => {
if (url.pathname === "/api/session") return sessions.promise
if (url.pathname === "/api/project") return projects.promise
return undefined
})
try {
await fixture.app.waitForFrame(
(frame) => frame.includes("Search sessions and projects") && frame.includes("Refreshing"),
)
expect(fixture.app.captureCharFrame()).not.toContain("No items available")
await fixture.app.mockInput.typeText("missing")
await fixture.app.waitForFrame((frame) => frame.includes("Searching sessions and projects"))
expect(fixture.app.captureCharFrame()).not.toContain("No matches")
sessions.resolve(json({ data: [], cursor: {} }))
projects.resolve(json([]))
await fixture.app.waitForFrame((frame) => frame.includes("No matches") && !frame.includes("Refreshing"))
} finally {
sessions.resolve(json({ data: [], cursor: {} }))
projects.resolve(json([]))
await fixture.dispose()
}
})
test("reports both refresh failures while keeping hydrated sessions usable", async () => {
const fixture = await renderOpen(
(url) => {
if (url.pathname === "/api/session" || url.pathname === "/api/project")
return new Response("Unavailable", { status: 503 })
return undefined
},
({ data }) => data.session.remember(recentSession),
)
try {
await fixture.app.waitForFrame((frame) => frame.includes("Could not refresh sessions and projects"))
expect(fixture.app.captureCharFrame()).toContain("Recent session")
} finally {
await fixture.dispose()
}
})
test("option arrows jump between sections", async () => {
const handler: FetchHandler = (url) => {
if (url.pathname === "/api/session")
@ -291,20 +527,21 @@ async function renderOpen(
let location!: ReturnType<typeof useLocation>
let data!: ReturnType<typeof useData>
let storage!: ReturnType<typeof useStorage>
let open!: () => void
function Probe() {
const dialog = useDialog()
const client = useClient()
const [sessions, setSessions] = createSignal<SessionInfo[]>([])
route = useRoute()
location = useLocation()
data = useData()
storage = useStorage()
onMount(
() =>
void Promise.all([beforeOpen?.({ data, location }), loadDialogOpen(data, client)]).then(([, sessions]) =>
dialog.replace(() => <DialogOpen sessions={sessions} />, undefined, { key: DialogOpenKey, size: "large" }),
),
)
open = () =>
dialog.replace(() => <DialogOpen sessions={sessions()} onLoad={setSessions} />, undefined, {
key: DialogOpenKey,
size: "large",
})
onMount(() => void Promise.resolve(beforeOpen?.({ data, location })).then(open))
return null
}
@ -344,6 +581,8 @@ async function renderOpen(
return {
app,
emit: events.emit,
open: () => open(),
get route() {
return route
},