fix(app): keep search sessions in a stable order when it opens (#58908)

This commit is contained in:
Kit Langton
2026-10-25 08:15:08 -07:00
committed by GitHub
Unverified
parent 30311ae6a7
commit 161ad24f35
5 changed files with 147 additions and 10 deletions
@@ -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")
+2 -2
View File
@@ -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: () => [],
})
+41 -4
View File
@@ -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<void> }) {
const mutation = createSessionMutations(input.remove)
const [activity, setActivity] = createStore<Record<string, number>>({})
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<void>) {
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))
+8 -4
View File
@@ -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<SessionInfo>
@@ -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))
@@ -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 }),