mirror of
https://github.com/anomalyco/opencode.git
synced 2026-11-13 19:46:16 +03:00
fix(app): keep search sessions in a stable order when it opens (#58908)
This commit is contained in:
committed by
GitHub
Unverified
parent
30311ae6a7
commit
161ad24f35
@@ -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")
|
||||
|
||||
@@ -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: () => [],
|
||||
})
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
Reference in New Issue
Block a user