From 3a732836c6e34bd413b86b4d15bfa3704ff11dff Mon Sep 17 00:00:00 2001 From: CasualDeveloper <10153929+CasualDeveloper@users.noreply.github.com> Date: Sat, 6 Jun 2026 23:22:14 +0800 Subject: [PATCH] fix(tui): load root sessions safely in session dialogs Problem: Session dialogs built options from mixed or not-yet-loaded session data, which let child sessions crowd out roots and could freeze or stall an empty browse order during startup.\n\nSolution: Query root sessions from dialog-local resources, keep the selector mounted while browse loads, and use parent-owned filtering so search narrows immediately while debounced server search augments off-page matches.\n\nNotes: Refs #16270 and #31125. --- .../tui/src/component/dialog-session-list.tsx | 172 ++++++++++++++++-- .../component/dialog-session-list.test.ts | 117 ++++++++++++ 2 files changed, 269 insertions(+), 20 deletions(-) create mode 100644 packages/tui/test/component/dialog-session-list.test.ts diff --git a/packages/tui/src/component/dialog-session-list.tsx b/packages/tui/src/component/dialog-session-list.tsx index 2965b3692e94..117074c668c3 100644 --- a/packages/tui/src/component/dialog-session-list.tsx +++ b/packages/tui/src/component/dialog-session-list.tsx @@ -2,7 +2,7 @@ import { useDialog } from "../ui/dialog" import { DialogSelect } from "../ui/dialog-select" import { useRoute } from "../context/route" import { useSync } from "../context/sync" -import { createMemo, createResource, createSignal, onMount } from "solid-js" +import { createEffect, createMemo, createResource, createSignal, onMount } from "solid-js" import path from "path" import { Locale } from "../util/locale" import { useProject } from "../context/project" @@ -18,6 +18,95 @@ import { errorMessage } from "../util/error" import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed" import { useCommandShortcut } from "../keymap" +type SessionListFilter = { + scope?: "project" + path?: string +} + +type SessionListItem = { + id: string + parentID?: string + title?: string + time: { + updated: number + } +} + +type SessionListResult = { + key: string + query: string + sessions: T[] +} + +export function createDialogSessionListQuery(input: { query: string; filter: SessionListFilter }) { + return { + ...input.filter, + roots: true, + limit: input.query ? 30 : 100, + ...(input.query ? { search: input.query } : {}), + } +} + +export function createDialogSessionListKey(input: { query: string; filter: SessionListFilter }) { + return [input.query, input.filter.scope ?? "", input.filter.path ?? ""].join("\x00") +} + +export function orderDialogSessionsByRecency(sessions: SessionListItem[]) { + return sessions + .filter((session) => session.parentID === undefined) + .toSorted((a, b) => b.time.updated - a.time.updated) + .map((session) => session.id) +} + +export function nextDialogSessionBrowseOrder(input: { + current: { key: string; ids: string[] } | undefined + result: SessionListResult | undefined +}) { + if (!input.result) return input.current + if (input.current?.key === input.result.key) return input.current + return { key: input.result.key, ids: orderDialogSessionsByRecency(input.result.sessions) } +} + +export function createDialogSessionItems(input: { + browse: T[] | undefined + remote: T[] | undefined + synced: T[] + pinned: string[] + current: string | undefined + query: string +}) { + const query = input.query.trim().toLowerCase() + const map = new Map( + (input.browse ?? input.synced) + .filter((session) => session.parentID === undefined) + .map((session) => [session.id, session] as const), + ) + const pinned = new Set(input.pinned) + + const remote = query ? (input.remote ?? []) : [] + remote.filter((session) => session.parentID === undefined).forEach((session) => map.set(session.id, session)) + + input.synced + .filter((session) => session.parentID === undefined) + .filter((session) => map.has(session.id) || pinned.has(session.id) || session.id === input.current) + .forEach((session) => map.set(session.id, session)) + + const sessions = [...map.values()] + if (!query) return sessions + return sessions.filter((session) => (session.title ?? "").toLowerCase().includes(query)) +} + +export function currentDialogSessionSearch(input: { + result: SessionListResult | undefined + key: string + query: string +}) { + if (!input.result) return undefined + if (input.result.key !== input.key) return undefined + if (input.result.query !== input.query) return undefined + return input.result.sessions +} + export function DialogSessionList() { const dialog = useDialog() const route = useRoute() @@ -28,22 +117,52 @@ export function DialogSessionList() { const local = useLocal() const toast = useToast() const [toDelete, setToDelete] = createSignal() - const [search, setSearch] = createDebouncedSignal("", 150) + const [filterText, setFilterText] = createSignal("") + const [serverSearch, setServerSearch] = createDebouncedSignal("", 150) const deleteHint = useCommandShortcut("session.delete") const quickSwitch1 = useCommandShortcut("session.quick_switch.1") const quickSwitch9 = useCommandShortcut("session.quick_switch.9") + const sessionFilter = createMemo(() => sync.session.query()) + const query = createMemo(() => filterText().trim()) + const searchKey = createMemo(() => createDialogSessionListKey({ query: serverSearch(), filter: sessionFilter() })) + const currentSearchKey = createMemo(() => createDialogSessionListKey({ query: query(), filter: sessionFilter() })) + const browseKey = createMemo(() => createDialogSessionListKey({ query: "", filter: sessionFilter() })) + + function onFilter(value: string) { + setFilterText(value) + setServerSearch(value.trim()) + } - const [searchResults, { refetch }] = createResource( - () => ({ query: search(), filter: sync.session.query() }), + const [searchResults, { refetch: refetchSearch }] = createResource( + () => ({ key: searchKey(), query: serverSearch(), filter: sessionFilter() }), async (input) => { if (!input.query) return undefined - const result = await sdk.client.session.list({ search: input.query, limit: 30, ...input.filter }) - return result.data ?? [] + const result = await sdk.client.session.list(createDialogSessionListQuery(input)) + return { key: input.key, query: input.query, sessions: result.data ?? [] } + }, + ) + + const [browseResults, { refetch: refetchBrowse }] = createResource( + () => ({ key: browseKey(), filter: sessionFilter() }), + async (input) => { + const result = await sdk.client.session.list(createDialogSessionListQuery({ query: "", filter: input.filter })) + return { key: input.key, query: "", sessions: result.data ?? [] } }, ) const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) - const sessions = createMemo(() => searchResults() ?? sync.data.session) + const sessions = createMemo(() => { + const searchResult = searchResults() + + return createDialogSessionItems({ + browse: browseResults()?.sessions, + remote: currentDialogSessionSearch({ result: searchResult, key: currentSearchKey(), query: query() }), + synced: sync.data.session, + pinned: local.session.pinned(), + current: currentSessionID(), + query: query(), + }) + }) function recover(session: NonNullable[number]>) { const workspace = project.workspace.get(session.workspaceID!) @@ -108,7 +227,8 @@ export function DialogSessionList() { } await project.workspace.sync() await sync.session.refresh() - if (search()) await refetch() + await refreshBrowse() + if (query()) await refetchSearch() if (info?.workspaceID === session.workspaceID) { route.navigate({ type: "home" }) } @@ -131,15 +251,17 @@ export function DialogSessionList() { )) } - function orderByRecency(sessionsList: NonNullable>) { - return sessionsList - .filter((x) => x.parentID === undefined) - .toSorted((a, b) => b.time.updated - a.time.updated) - .map((x) => x.id) + const [browseOrder, setBrowseOrder] = createSignal<{ key: string; ids: string[] }>() + createEffect(() => { + const current = browseOrder() + const next = nextDialogSessionBrowseOrder({ current, result: browseResults() }) + if (next !== current) setBrowseOrder(next) + }) + const browsePending = createMemo(() => !query() && browseResults.loading && browseOrder() === undefined) + const refreshBrowse = async () => { + await refetchBrowse() } - const [browseOrder] = createSignal(orderByRecency(sync.data.session)) - const quickSwitchHint = createMemo(() => { const first = quickSwitch1() const last = quickSwitch9() @@ -159,8 +281,14 @@ export function DialogSessionList() { .map((x) => [x.id, x]), ) - const searchResult = searchResults() - const displayOrder = searchResult ? orderByRecency(searchResult) : browseOrder() + const searching = query().length > 0 + const rootOrder = browseOrder()?.ids ?? orderDialogSessionsByRecency(sessions()) + const current = currentSessionID() + const displayOrder = searching + ? orderDialogSessionsByRecency(sessions()) + : current && sessionMap.has(current) && !rootOrder.includes(current) + ? [...rootOrder, current] + : rootOrder const pinned = local.session.pinned().filter((id) => sessionMap.has(id)) const pinnedSet = new Set(pinned) @@ -208,6 +336,8 @@ export function DialogSessionList() { return [...pinned.map((id) => buildOption(id, "Pinned")).filter((x) => x !== undefined), ...remaining] }) + const loading = createMemo(() => browsePending() && options().length === 0) + const selectOptions = createMemo(() => (loading() ? [{ title: "Loading sessions...", value: "" }] : options())) onMount(() => { dialog.setSize("large") @@ -216,10 +346,11 @@ export function DialogSessionList() { return ( { setToDelete(undefined) }} @@ -279,7 +410,8 @@ export function DialogSessionList() { if (status && status !== "connected") { await sync.session.refresh() } - if (search()) await refetch() + await refreshBrowse() + if (query()) await refetchSearch() setToDelete(undefined) return } diff --git a/packages/tui/test/component/dialog-session-list.test.ts b/packages/tui/test/component/dialog-session-list.test.ts new file mode 100644 index 000000000000..9e23910facbe --- /dev/null +++ b/packages/tui/test/component/dialog-session-list.test.ts @@ -0,0 +1,117 @@ +import { expect, test } from "bun:test" +import { + createDialogSessionListKey, + createDialogSessionItems, + createDialogSessionListQuery, + currentDialogSessionSearch, + nextDialogSessionBrowseOrder, +} from "../../src/component/dialog-session-list" + +test("dialog session list requests root sessions for the default picker", () => { + const query = createDialogSessionListQuery({ query: "", filter: { scope: "project" } }) + + expect(query).toEqual({ scope: "project", roots: true, limit: 100 }) + expect("start" in query).toBe(false) + expect("search" in query).toBe(false) +}) + +test("dialog session search preserves scope filters while requesting root sessions", () => { + const query = createDialogSessionListQuery({ query: "deploy", filter: { path: "packages/opencode" } }) + + expect(query).toEqual({ path: "packages/opencode", roots: true, limit: 30, search: "deploy" }) +}) + +test("dialog browse order waits for loaded sessions before freezing", () => { + expect(nextDialogSessionBrowseOrder({ current: undefined, result: undefined })).toBe(undefined) + + const loaded = nextDialogSessionBrowseOrder({ + current: undefined, + result: { + key: "project", + query: "", + sessions: [ + { id: "old", time: { updated: 1 } }, + { id: "child", parentID: "new", time: { updated: 3 } }, + { id: "new", time: { updated: 2 } }, + ], + }, + }) + + expect(loaded).toEqual({ key: "project", ids: ["new", "old"] }) + expect( + nextDialogSessionBrowseOrder({ + current: loaded, + result: { key: "project", query: "", sessions: [{ id: "newer", time: { updated: 4 } }] }, + }), + ).toBe(loaded) +}) + +test("dialog sessions use synced roots while root browse is loading", () => { + const sessions = createDialogSessionItems({ + browse: undefined, + current: undefined, + pinned: [], + query: "", + remote: undefined, + synced: [ + { id: "root", time: { updated: 1 } }, + { id: "child", parentID: "root", time: { updated: 2 } }, + ], + }) + + expect(sessions.map((session) => session.id)).toEqual(["root"]) +}) + +test("dialog search filters pinned and unpinned sessions", () => { + const sessions = createDialogSessionItems({ + browse: [ + { id: "pinned-match", title: "deploy fix", time: { updated: 3 } }, + { id: "pinned-miss", title: "readme update", time: { updated: 2 } }, + { id: "unpinned-match", title: "deploy docs", time: { updated: 1 } }, + ], + current: undefined, + pinned: ["pinned-match", "pinned-miss"], + query: "deploy", + remote: undefined, + synced: [], + }) + + expect(sessions.map((session) => session.id)).toEqual(["pinned-match", "unpinned-match"]) +}) + +test("dialog search augments local browse with current remote results", () => { + const key = createDialogSessionListKey({ query: "deploy", filter: { scope: "project" } }) + const remote = currentDialogSessionSearch({ + key, + query: "deploy", + result: { + key, + query: "deploy", + sessions: [{ id: "remote-match", title: "deploy remote", time: { updated: 4 } }], + }, + }) + const sessions = createDialogSessionItems({ + browse: [{ id: "local-match", title: "deploy local", time: { updated: 1 } }], + current: undefined, + pinned: [], + query: "deploy", + remote, + synced: [], + }) + + expect(sessions.map((session) => session.id)).toEqual(["local-match", "remote-match"]) +}) + +test("dialog search ignores stale remote results", () => { + const remote = currentDialogSessionSearch({ + key: createDialogSessionListKey({ query: "deploy", filter: { scope: "project" } }), + query: "deploy", + result: { + key: createDialogSessionListKey({ query: "readme", filter: { scope: "project" } }), + query: "readme", + sessions: [{ id: "stale", title: "deploy stale", time: { updated: 4 } }], + }, + }) + + expect(remote).toBe(undefined) +})