From e7c59b17a8f4c0a6d79473622f8dad7b471a532d Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Thu, 25 Jun 2026 18:36:08 +0200 Subject: [PATCH] fix(tui): load root sessions in session switcher (#33931) Request root sessions before applying the session list limit so child sessions cannot crowd roots out of the switcher. Keep the synchronized cache available while requests are pending or fail, reconcile results with live updates, and retain current and pinned sessions. Preserve selection by session ID when asynchronous results reorder the list. Closes #16270 Closes #32725 --- .../tui/src/component/dialog-session-list.tsx | 72 +++++++++++++++-- packages/tui/src/ui/dialog-select.tsx | 77 ++++++++++++++++++- .../tui/test/cli/cmd/tui/sync-fixture.tsx | 9 ++- packages/tui/test/cli/cmd/tui/sync.test.tsx | 2 + .../component/dialog-session-list.test.ts | 46 +++++++++++ 5 files changed, 193 insertions(+), 13 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 2965b3692e..c508fc6b7b 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 { createMemo, createResource, createSignal, onCleanup, onMount } from "solid-js" import path from "path" import { Locale } from "../util/locale" import { useProject } from "../context/project" @@ -17,6 +17,30 @@ import { Spinner } from "./spinner" import { errorMessage } from "../util/error" import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed" import { useCommandShortcut } from "../keymap" +import { useEvent } from "../context/event" + +type SessionListFilter = { scope?: "project"; path?: string } + +export function createDialogSessionListQuery(input: { search?: string; filter: SessionListFilter }) { + const search = input.search?.trim() + return { + roots: true, + limit: search ? 30 : 100, + ...(search ? { search } : {}), + ...input.filter, + } +} + +export function loadDialogSessionList(input: { + search?: string + filter: SessionListFilter + list: (query: ReturnType) => Promise<{ data?: T[] }> +}) { + return input.list(createDialogSessionListQuery(input)).then( + (result) => result.data, + () => undefined, + ) +} export function DialogSessionList() { const dialog = useDialog() @@ -25,25 +49,54 @@ export function DialogSessionList() { const project = useProject() const { theme } = useTheme() const sdk = useSDK() + const event = useEvent() const local = useLocal() const toast = useToast() const [toDelete, setToDelete] = createSignal() + const [deleted, setDeleted] = createSignal(new Set()) const [search, setSearch] = createDebouncedSignal("", 150) const deleteHint = useCommandShortcut("session.delete") const quickSwitch1 = useCommandShortcut("session.quick_switch.1") const quickSwitch9 = useCommandShortcut("session.quick_switch.9") + const [browseResults, { refetch: refetchBrowse }] = createResource( + () => sync.session.query(), + (filter) => loadDialogSessionList({ filter, list: (query) => sdk.client.session.list(query) }), + ) const [searchResults, { refetch }] = createResource( () => ({ query: search(), filter: sync.session.query() }), - async (input) => { + (input) => { if (!input.query) return undefined - const result = await sdk.client.session.list({ search: input.query, limit: 30, ...input.filter }) - return result.data ?? [] + return loadDialogSessionList({ + search: input.query, + filter: input.filter, + list: (query) => sdk.client.session.list(query), + }) }, ) const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined)) - const sessions = createMemo(() => searchResults() ?? sync.data.session) + const sessions = createMemo(() => { + const result = searchResults() ?? browseResults() ?? sync.data.session + const synced = new Map(sync.data.session.map((session) => [session.id, session])) + const ids = new Set(result.map((session) => session.id)) + const extra = [currentSessionID(), ...local.session.pinned()].flatMap((id) => { + if (!id || ids.has(id)) return [] + const session = synced.get(id) + if (session) ids.add(id) + return session ? [session] : [] + }) + const query = search().trim().toLowerCase() + return [...result.map((session) => synced.get(session.id) ?? session), ...extra] + .filter((session) => !deleted().has(session.id)) + .filter((session) => !query || session.title.toLowerCase().includes(query)) + }) + + onCleanup( + event.on("session.deleted", (event) => { + setDeleted((current) => new Set(current).add(event.properties.info.id)) + }), + ) function recover(session: NonNullable[number]>) { const workspace = project.workspace.get(session.workspaceID!) @@ -108,6 +161,7 @@ export function DialogSessionList() { } await project.workspace.sync() await sync.session.refresh() + await refetchBrowse() if (search()) await refetch() if (info?.workspaceID === session.workspaceID) { route.navigate({ type: "home" }) @@ -138,7 +192,7 @@ export function DialogSessionList() { .map((x) => x.id) } - const [browseOrder] = createSignal(orderByRecency(sync.data.session)) + const browseOrder = createMemo(() => orderByRecency(browseResults() ?? sync.data.session)) const quickSwitchHint = createMemo(() => { const first = quickSwitch1() @@ -160,7 +214,9 @@ export function DialogSessionList() { ) const searchResult = searchResults() - const displayOrder = searchResult ? orderByRecency(searchResult) : browseOrder() + const order = searchResult ? orderByRecency(sessions()) : browseOrder() + const current = currentSessionID() + const displayOrder = current && sessionMap.has(current) && !order.includes(current) ? [...order, current] : order const pinned = local.session.pinned().filter((id) => sessionMap.has(id)) const pinnedSet = new Set(pinned) @@ -218,6 +274,7 @@ export function DialogSessionList() { title="Sessions" options={options()} skipFilter={true} + preserveSelection={true} current={currentSessionID()} onFilter={setSearch} onMove={() => { @@ -279,6 +336,7 @@ export function DialogSessionList() { if (status && status !== "connected") { await sync.session.refresh() } + await refetchBrowse() if (search()) await refetch() setToDelete(undefined) return diff --git a/packages/tui/src/ui/dialog-select.tsx b/packages/tui/src/ui/dialog-select.tsx index 99299f4e22..dab9103d24 100644 --- a/packages/tui/src/ui/dialog-select.tsx +++ b/packages/tui/src/ui/dialog-select.tsx @@ -9,7 +9,7 @@ import { import type { Binding } from "@opentui/keymap" import { useTheme, selectedForeground } from "../context/theme" import { entries, filter, flatMap, groupBy, pipe } from "remeda" -import { batch, createEffect, createMemo, createSignal, For, Show, type JSX, on } from "solid-js" +import { batch, createEffect, createMemo, createSignal, For, Show, type JSX, on, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { useTerminalDimensions } from "@opentui/solid" import * as fuzzysort from "fuzzysort" @@ -35,6 +35,7 @@ export interface DialogSelectProps { skipFilter?: boolean renderFilter?: boolean locked?: boolean + preserveSelection?: boolean actions?: { command: string title: string @@ -93,6 +94,9 @@ export function DialogSelect(props: DialogSelectProps) { }) const [focusedAction, setFocusedAction] = createSignal() const actionFocused = createMemo(() => focusedAction() !== undefined) + let selection: { value: T; category?: string } | undefined + let resetSelection = false + let visibilityGeneration = 0 createEffect( on( @@ -102,6 +106,7 @@ export function DialogSelect(props: DialogSelectProps) { const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current)) if (currentIndex >= 0) { setStore("selected", currentIndex) + selection = flat()[currentIndex] } } }, @@ -209,11 +214,69 @@ export function DialogSelect(props: DialogSelectProps) { const selected = createMemo(() => flat()[store.selected]) + createEffect( + on( + () => props.options, + () => { + if (!props.preserveSelection) return + if (resetSelection && store.filter.length > 0) { + const option = flat()[0] + if (!option) return + setStore("selected", 0) + selection = option + return + } + if (!selection) { + if (props.current !== undefined) { + const index = flat().findIndex((option) => isDeepEqual(option.value, props.current)) + if (index >= 0) { + setStore("selected", index) + selection = flat()[index] + return + } + } + const option = selected() + if (!option) return + selection = option + return + } + const previous = selection + const index = flat().findIndex((option) => isDeepEqual(option.value, previous.value)) + if (index >= 0) { + const option = flat()[index] + const moved = index !== store.selected || option.category !== previous.category + setStore("selected", index) + selection = option + if (!moved) return + const value = option.value + const generation = ++visibilityGeneration + requestAnimationFrame(() => { + requestAnimationFrame(() => { + if (generation !== visibilityGeneration) return + if (!props.preserveSelection || store.filter.length > 0) return + if (!isDeepEqual(selected()?.value, value)) return + scrollToSelection(false) + }) + }) + return + } + const next = Math.min(store.selected, flat().length - 1) + if (next < 0) return + setStore("selected", next) + selection = flat()[next] + }, + ), + ) + onCleanup(() => { + visibilityGeneration++ + }) + createEffect( on([() => store.filter, () => props.current], ([filter, current]) => { + if (filter.length > 0) resetSelection = true setTimeout(() => { if (filter.length > 0) { - moveTo(0, true) + moveTo(0, true, false) } else if (current) { const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current)) if (currentIndex >= 0) { @@ -233,11 +296,19 @@ export function DialogSelect(props: DialogSelectProps) { moveTo(next, true) } - function moveTo(next: number, center = false) { + function moveTo(next: number, center = false, preserve = true) { setFocusedAction(undefined) setStore("selected", next) const option = selected() + if (option) { + selection = option + resetSelection = !preserve + } if (option) props.onMove?.(option) + scrollToSelection(center) + } + + function scrollToSelection(center: boolean) { if (!scroll) return let remaining = store.selected let index = 0 diff --git a/packages/tui/test/cli/cmd/tui/sync-fixture.tsx b/packages/tui/test/cli/cmd/tui/sync-fixture.tsx index a212f5be1d..5b7872ee30 100644 --- a/packages/tui/test/cli/cmd/tui/sync-fixture.tsx +++ b/packages/tui/test/cli/cmd/tui/sync-fixture.tsx @@ -6,6 +6,7 @@ import { KVProvider, useKV } from "../../../../src/context/kv" import { ProjectProvider, useProject } from "../../../../src/context/project" import { SDKProvider } from "../../../../src/context/sdk" import { SyncProvider, useSync } from "../../../../src/context/sync" +import { ExitProvider } from "../../../../src/context/exit" import { createEventSource, createFetch, type FetchHandler, directory } from "../../../fixture/tui-sdk" import { TestTuiContexts } from "../../../fixture/tui-environment" export { createEventSource, createFetch, directory, eventSource, json, worktree } from "../../../fixture/tui-sdk" @@ -48,9 +49,11 @@ export async function mount(override?: FetchHandler, state?: string) { - - - + {}}> + + + + diff --git a/packages/tui/test/cli/cmd/tui/sync.test.tsx b/packages/tui/test/cli/cmd/tui/sync.test.tsx index a7472ab1d6..6c70295b69 100644 --- a/packages/tui/test/cli/cmd/tui/sync.test.tsx +++ b/packages/tui/test/cli/cmd/tui/sync.test.tsx @@ -25,6 +25,7 @@ describe("tui sync", () => { try { expect(kv.get("session_directory_filter_enabled", true)).toBe(true) + expect(session.at(-1)?.searchParams.get("roots")).toBeNull() expect(session.at(-1)?.searchParams.get("scope")).toBeNull() expect(session.at(-1)?.searchParams.get("path")).toBe("packages/tui") @@ -33,6 +34,7 @@ describe("tui sync", () => { expect(session.at(-1)?.searchParams.get("scope")).toBe("project") expect(session.at(-1)?.searchParams.get("path")).toBeNull() + expect(session.at(-1)?.searchParams.get("roots")).toBeNull() } finally { app.renderer.destroy() } 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 0000000000..9779047995 --- /dev/null +++ b/packages/tui/test/component/dialog-session-list.test.ts @@ -0,0 +1,46 @@ +import { describe, expect, test } from "bun:test" +import { createDialogSessionListQuery, loadDialogSessionList } from "../../src/component/dialog-session-list" + +describe("dialog session list", () => { + test("requests root sessions for the default browse list", () => { + expect(createDialogSessionListQuery({ filter: { path: "packages/tui" } })).toEqual({ + roots: true, + limit: 100, + path: "packages/tui", + }) + }) + + test("requests root sessions for search results", () => { + expect(createDialogSessionListQuery({ search: " deploy ", filter: { scope: "project" } })).toEqual({ + roots: true, + limit: 30, + search: "deploy", + scope: "project", + }) + }) + + test("keeps the cache usable while the root request is pending", async () => { + let resolve!: (result: { data: string[] }) => void + const pending = loadDialogSessionList({ + filter: {}, + list: () => new Promise((done) => (resolve = done)), + }) + + expect(await Promise.race([pending, Promise.resolve("pending")])).toBe("pending") + resolve({ data: ["root"] }) + expect(await pending).toEqual(["root"]) + }) + + test("falls back when the root request returns an error response", async () => { + expect(await loadDialogSessionList({ filter: {}, list: async () => ({}) })).toBeUndefined() + }) + + test("falls back when the root request rejects", async () => { + expect( + await loadDialogSessionList({ + filter: {}, + list: () => Promise.reject(new Error("offline")), + }), + ).toBeUndefined() + }) +})