diff --git a/packages/app/e2e/regression/command-palette.spec.ts b/packages/app/e2e/regression/command-palette.spec.ts index 2687ce30d85..4c3339af13d 100644 --- a/packages/app/e2e/regression/command-palette.spec.ts +++ b/packages/app/e2e/regression/command-palette.spec.ts @@ -1,3 +1,4 @@ +import type { OpenCodeEvent } from "@opencode/client/promise" import { expect, test } from "@playwright/test" import { captureConsoleWarnings, @@ -172,6 +173,100 @@ test("Search projects opens a new session's project picker from a session and fr await expect(picker).toBeFocused() }) +declare global { + interface Window { + __paletteOrders?: string[][] + } +} + +test("titlebar search opens recent sessions in the server's order on every open", async ({ page }) => { + const busy = { ...paletteSession, time: { ...paletteSession.time } } + + const fresh = { + ...paletteSession, + id: "ses_command_palette_fresh", + title: "Fresh fixture session", + time: { created: 1700000001000, updated: 1700000001000 }, + } + + const mock = await openPaletteWorkspace(page, true, [busy, fresh]) + // A step starts after Home loaded its index, so the server now lists the busy session first. + busy.time.updated = 1700000002000 + // SAFETY: a step in a session whose transcript is not open; only its session ID and time matter here. + await mock.push([ + { + id: "evt_busy_step", + created: busy.time.updated, + type: "session.step.started", + durable: { aggregateID: busy.id, seq: 1, version: 1 }, + location: { directory: busy.directory }, + data: { + sessionID: busy.id, + assistantMessageID: "msg_busy_step", + agent: "build", + model: { providerID: "test", id: "test" }, + started: busy.time.updated, + }, + } as OpenCodeEvent, + ]) + const home = page.getByRole("region", { name: "Recent sessions" }).getByRole("button", { name: /fixture session/ }) + await expect(home).toHaveText([/Palette fixture session/, /Fresh fixture session/]) + + await page.evaluate(() => { + const seen: string[][] = [] + window.__paletteOrders = seen + + new MutationObserver(() => { + const order = [...document.querySelectorAll('[role="dialog"] [role="option"] .command-palette-title')].map( + (title) => title.textContent ?? "", + ) + + if (order.length > 0 && order.join() !== seen.at(-1)?.join()) seen.push(order) + }).observe(document.body, { childList: true, subtree: true, characterData: true }) + }) + + const dialog = page.getByRole("dialog") + + const openAndClose = async () => { + const listed = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/session") + await page.getByRole("banner").getByRole("button", { name: "Search sessions", exact: true }).click() + await listed + await expect(dialog.getByRole("option")).toHaveText([/Palette fixture session/, /Fresh fixture session/]) + await dialog.getByRole("combobox").press("Escape") + await expect(dialog).toHaveCount(0) + } + + await openAndClose() + await openAndClose() + + expect(await page.evaluate(() => window.__paletteOrders)).toEqual([[paletteSession.title, fresh.title]]) +}) + +const olderSession = { + ...paletteSession, + id: "ses_command_palette_older", + title: "Older fixture session", + time: { created: 1699999999000, updated: 1699999999000 }, +} + +test("titlebar search drops a recent session deleted while it is open", async ({ page }) => { + const mock = await openPaletteWorkspace(page, true, [paletteSession, olderSession]) + await page.getByRole("banner").getByRole("button", { name: "Search sessions", exact: true }).click() + const options = page.getByRole("dialog").getByRole("option") + await expect(options).toHaveText([/Palette fixture session/, /Older fixture session/]) + await mock.push([ + { + id: "evt_older_deleted", + created: 1700000003000, + type: "session.deleted", + durable: { aggregateID: olderSession.id, seq: 1, version: 2 }, + location: { directory: olderSession.directory }, + data: { sessionID: olderSession.id }, + }, + ]) + await expect(options).toHaveText([/Palette fixture session/]) +}) + test("reopens from its shortcut as soon as it closes", async ({ page }) => { const { dialog, input } = await openCommandPalette(page) await input.press("Escape") diff --git a/packages/app/e2e/utils/command-palette.ts b/packages/app/e2e/utils/command-palette.ts index ae5f9648faa..782357780e3 100644 --- a/packages/app/e2e/utils/command-palette.ts +++ b/packages/app/e2e/utils/command-palette.ts @@ -36,12 +36,12 @@ export async function openCommandPalette(page: Page, home = false) { return { dialog, input, push: mock.push } } -export async function openPaletteWorkspace(page: Page, home = false) { +export async function openPaletteWorkspace(page: Page, home = false, sessions = [paletteSession]) { const mock = await mockOpenCodeServer(page, { directory: paletteSession.directory, project: project({ id: paletteSession.projectID, directory: paletteSession.directory, name: "command-palette" }), provider: NO_PROVIDER, - sessions: [paletteSession], + sessions, pageMessages: () => ({ items: [] }), findFiles: () => [], }) diff --git a/packages/app/src/runtime/server/data.ts b/packages/app/src/runtime/server/data.ts index 8d4a744c7ed..5c9739a9eb3 100644 --- a/packages/app/src/runtime/server/data.ts +++ b/packages/app/src/runtime/server/data.ts @@ -1,28 +1,65 @@ import type { Data } from "@opencode/client/solid" -import type { SessionInfo } from "@opencode/client/promise" +import type { OpenCodeEvent, SessionInfo } from "@opencode/client/promise" import { onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { uuid } from "@/runtime/persistence/uuid" type SessionMutation = { readonly id: string; readonly type: "remove"; readonly sessionID: string } +// The events on which the server advances a session's `time.updated` to the event's time. +const SESSION_ACTIVITY = [ + "session.inbox.enqueued", + "session.step.started", + "session.step.ended", + "session.step.failed", + "session.renamed", + "session.agent.selected", + "session.model.selected", + "session.metadata.updated", + "session.permissions", + "session.moved", + "session.archived", + "session.unarchived", + "session.revert.staged", + "session.revert.cleared", + "session.revert.committed", +] satisfies OpenCodeEvent["type"][] + export function createDesktopData(input: { data: Data; remove: (sessionID: string) => Promise }) { const mutation = createSessionMutations(input.remove) + const [activity, setActivity] = createStore>({}) onCleanup(input.data.on("session.deleted", (event) => mutation.deleted(event.data.sessionID))) + // Lists loaded earlier (the Home index, the live store, search results) learn each session's newer + // `time.updated` from its events, so they rank sessions in the server's order without reloading. + SESSION_ACTIVITY.forEach((type) => + onCleanup( + input.data.on(type, (event) => { + if (event.created > (activity[event.data.sessionID] ?? 0)) setActivity(event.data.sessionID, event.created) + }), + ), + ) + + const apply = (sessions: readonly SessionInfo[]) => + mutation.apply(sessions).map((session) => { + const updated = activity[session.id] + + return updated && updated > session.time.updated ? { ...session, time: { ...session.time, updated } } : session + }) + return { ...input.data, session: { ...input.data.session, - list: () => mutation.apply(input.data.session.list()), - apply: mutation.apply, + list: () => apply(input.data.session.list()), + apply, remove: mutation.remove, }, } } export function createSessionMutations(remove: (sessionID: string) => Promise) { - const [store, setStore] = createStore({ session: [] as SessionMutation[] }) + const [store, setStore] = createStore<{ session: SessionMutation[] }>({ session: [] }) const clear = (id: string) => { setStore("session", (current) => current.filter((mutation) => mutation.id !== id)) diff --git a/packages/app/src/shell/commands/palette.ts b/packages/app/src/shell/commands/palette.ts index 53c2e2d8884..33a95ff057a 100644 --- a/packages/app/src/shell/commands/palette.ts +++ b/packages/app/src/shell/commands/palette.ts @@ -243,6 +243,8 @@ type SessionSearchState = { export function createServerSessionEntries(props: { server: ServerConnection.Key known?: () => SessionInfo[] + /** Hides sessions deleted since they were loaded. */ + apply?: (sessions: SessionInfo[]) => SessionInfo[] projects: () => Project[] load: (search: string, signal: AbortSignal) => Promise<{ data: SessionInfo[] }> get: (sessionID: string, signal: AbortSignal) => Promise @@ -255,15 +257,17 @@ export function createServerSessionEntries(props: { const [store, setStore] = createStore<{ loaded: SessionInfo[] }>({ loaded: [] }) // Let the newest metadata win duplicates before ranking the merged sessions newest-first. - const known = createMemo(() => - [ + const known = createMemo(() => { + const merged = [ ...new Map( [...(props.known?.() ?? []), ...store.loaded] .toSorted((a, b) => a.time.updated - b.time.updated) .map((session) => [session.id, session] as const), ).values(), - ].toSorted((a, b) => b.time.updated - a.time.updated), - ) + ].toSorted((a, b) => b.time.updated - a.time.updated) + + return props.apply?.(merged) ?? merged + }) onCleanup(() => abort(state.abort)) diff --git a/packages/app/src/shell/commands/session-search.tsx b/packages/app/src/shell/commands/session-search.tsx index 62f1a5da7eb..b14c0633b3c 100644 --- a/packages/app/src/shell/commands/session-search.tsx +++ b/packages/app/src/shell/commands/session-search.tsx @@ -26,6 +26,7 @@ export function DialogSessionSearch() { const load = createServerSessionEntries({ server: ServerConnection.key(conn), known: createKnownServerSessions(ctx), + apply: ctx.data.session.apply, projects: ctx.projects.list, load: (search, signal) => ctx.sdk.api.session.list({ parentID: null, search, archived: false, limit: 50 }, { signal }),