diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index aa4eb989caf..c1bdf5cfd74 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -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 | undefined + const [openSessions, setOpenSessions] = createSignal([]) // 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(() => , undefined, { key: DialogOpenKey, size: "large" }) + run: () => { + if (dialog.key === DialogOpenKey) return + dialog.replace(() => , 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" }) diff --git a/packages/tui/src/component/dialog-open.tsx b/packages/tui/src/component/dialog-open.tsx index 65798ef29eb..10feb36945f 100644 --- a/packages/tui/src/component/dialog-open.tsx +++ b/packages/tui/src/component/dialog-open.tsx @@ -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, client: ReturnType) { - 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>() + 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={ + + + No recent sessions or projects + + + } + footer={ + + + Refreshing sessions and projects... + + + + Could not refresh{" "} + {recent() === false ? (projects() === false ? "sessions and projects" : "sessions") : "projects"}. + + + + } noMatchView={ - {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"} } @@ -177,6 +223,16 @@ export function DialogOpen(props: { sessions: SessionInfo[] }) { ) } +export function moveOpenSession(session: SessionInfo, event: Extract) { + 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" diff --git a/packages/tui/src/ui/dialog-select.tsx b/packages/tui/src/ui/dialog-select.tsx index f851dc667ad..c1757be85ff 100644 --- a/packages/tui/src/ui/dialog-select.tsx +++ b/packages/tui/src/ui/dialog-select.tsx @@ -284,8 +284,8 @@ export function DialogSelect(props: DialogSelectProps) { 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) diff --git a/packages/tui/test/app-lifecycle.test.tsx b/packages/tui/test/app-lifecycle.test.tsx index 686c3954eb2..9faa64dd6ac 100644 --- a/packages/tui/test/app-lifecycle.test.tsx +++ b/packages/tui/test/app-lifecycle.test.tsx @@ -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() + const requested = Promise.withResolvers() + const response = Promise.withResolvers() + const projects = Promise.withResolvers() + const refresh = Promise.withResolvers() + 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() + const refresh = Promise.withResolvers() + const metadata = Promise.withResolvers() + const destinationRequested = Promise.withResolvers() + 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[] = [] diff --git a/packages/tui/test/cli/tui/dialog-open.test.tsx b/packages/tui/test/cli/tui/dialog-open.test.tsx index 1aa1f0adfb3..f66842d83c8 100644 --- a/packages/tui/test/cli/tui/dialog-open.test.tsx +++ b/packages/tui/test/cli/tui/dialog-open.test.tsx @@ -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((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() + 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() + 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() + const projects = Promise.withResolvers() + 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() + 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() + const projects = Promise.withResolvers() + 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 let data!: ReturnType let storage!: ReturnType + let open!: () => void function Probe() { const dialog = useDialog() - const client = useClient() + const [sessions, setSessions] = createSignal([]) route = useRoute() location = useLocation() data = useData() storage = useStorage() - onMount( - () => - void Promise.all([beforeOpen?.({ data, location }), loadDialogOpen(data, client)]).then(([, sessions]) => - dialog.replace(() => , undefined, { key: DialogOpenKey, size: "large" }), - ), - ) + open = () => + dialog.replace(() => , 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 },