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
This commit is contained in:
Simon Klee
2026-06-25 18:36:08 +02:00
committed by GitHub
parent 5a399eb19c
commit 06232cc755
5 changed files with 193 additions and 13 deletions
@@ -2,7 +2,7 @@ import { useDialog } from "../ui/dialog"
import { DialogSelect } from "../ui/dialog-select" import { DialogSelect } from "../ui/dialog-select"
import { useRoute } from "../context/route" import { useRoute } from "../context/route"
import { useSync } from "../context/sync" 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 path from "path"
import { Locale } from "../util/locale" import { Locale } from "../util/locale"
import { useProject } from "../context/project" import { useProject } from "../context/project"
@@ -17,6 +17,30 @@ import { Spinner } from "./spinner"
import { errorMessage } from "../util/error" import { errorMessage } from "../util/error"
import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed" import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed"
import { useCommandShortcut } from "../keymap" 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<T>(input: {
search?: string
filter: SessionListFilter
list: (query: ReturnType<typeof createDialogSessionListQuery>) => Promise<{ data?: T[] }>
}) {
return input.list(createDialogSessionListQuery(input)).then(
(result) => result.data,
() => undefined,
)
}
export function DialogSessionList() { export function DialogSessionList() {
const dialog = useDialog() const dialog = useDialog()
@@ -25,25 +49,54 @@ export function DialogSessionList() {
const project = useProject() const project = useProject()
const { theme } = useTheme() const { theme } = useTheme()
const sdk = useSDK() const sdk = useSDK()
const event = useEvent()
const local = useLocal() const local = useLocal()
const toast = useToast() const toast = useToast()
const [toDelete, setToDelete] = createSignal<string>() const [toDelete, setToDelete] = createSignal<string>()
const [deleted, setDeleted] = createSignal(new Set<string>())
const [search, setSearch] = createDebouncedSignal("", 150) const [search, setSearch] = createDebouncedSignal("", 150)
const deleteHint = useCommandShortcut("session.delete") const deleteHint = useCommandShortcut("session.delete")
const quickSwitch1 = useCommandShortcut("session.quick_switch.1") const quickSwitch1 = useCommandShortcut("session.quick_switch.1")
const quickSwitch9 = useCommandShortcut("session.quick_switch.9") 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( const [searchResults, { refetch }] = createResource(
() => ({ query: search(), filter: sync.session.query() }), () => ({ query: search(), filter: sync.session.query() }),
async (input) => { (input) => {
if (!input.query) return undefined if (!input.query) return undefined
const result = await sdk.client.session.list({ search: input.query, limit: 30, ...input.filter }) return loadDialogSessionList({
return result.data ?? [] 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 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<ReturnType<typeof sessions>[number]>) { function recover(session: NonNullable<ReturnType<typeof sessions>[number]>) {
const workspace = project.workspace.get(session.workspaceID!) const workspace = project.workspace.get(session.workspaceID!)
@@ -108,6 +161,7 @@ export function DialogSessionList() {
} }
await project.workspace.sync() await project.workspace.sync()
await sync.session.refresh() await sync.session.refresh()
await refetchBrowse()
if (search()) await refetch() if (search()) await refetch()
if (info?.workspaceID === session.workspaceID) { if (info?.workspaceID === session.workspaceID) {
route.navigate({ type: "home" }) route.navigate({ type: "home" })
@@ -138,7 +192,7 @@ export function DialogSessionList() {
.map((x) => x.id) .map((x) => x.id)
} }
const [browseOrder] = createSignal<string[]>(orderByRecency(sync.data.session)) const browseOrder = createMemo(() => orderByRecency(browseResults() ?? sync.data.session))
const quickSwitchHint = createMemo(() => { const quickSwitchHint = createMemo(() => {
const first = quickSwitch1() const first = quickSwitch1()
@@ -160,7 +214,9 @@ export function DialogSessionList() {
) )
const searchResult = searchResults() 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 pinned = local.session.pinned().filter((id) => sessionMap.has(id))
const pinnedSet = new Set(pinned) const pinnedSet = new Set(pinned)
@@ -218,6 +274,7 @@ export function DialogSessionList() {
title="Sessions" title="Sessions"
options={options()} options={options()}
skipFilter={true} skipFilter={true}
preserveSelection={true}
current={currentSessionID()} current={currentSessionID()}
onFilter={setSearch} onFilter={setSearch}
onMove={() => { onMove={() => {
@@ -279,6 +336,7 @@ export function DialogSessionList() {
if (status && status !== "connected") { if (status && status !== "connected") {
await sync.session.refresh() await sync.session.refresh()
} }
await refetchBrowse()
if (search()) await refetch() if (search()) await refetch()
setToDelete(undefined) setToDelete(undefined)
return return
+74 -3
View File
@@ -9,7 +9,7 @@ import {
import type { Binding } from "@opentui/keymap" import type { Binding } from "@opentui/keymap"
import { useTheme, selectedForeground } from "../context/theme" import { useTheme, selectedForeground } from "../context/theme"
import { entries, filter, flatMap, groupBy, pipe } from "remeda" 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 { createStore } from "solid-js/store"
import { useTerminalDimensions } from "@opentui/solid" import { useTerminalDimensions } from "@opentui/solid"
import * as fuzzysort from "fuzzysort" import * as fuzzysort from "fuzzysort"
@@ -35,6 +35,7 @@ export interface DialogSelectProps<T> {
skipFilter?: boolean skipFilter?: boolean
renderFilter?: boolean renderFilter?: boolean
locked?: boolean locked?: boolean
preserveSelection?: boolean
actions?: { actions?: {
command: string command: string
title: string title: string
@@ -93,6 +94,9 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
}) })
const [focusedAction, setFocusedAction] = createSignal<number>() const [focusedAction, setFocusedAction] = createSignal<number>()
const actionFocused = createMemo(() => focusedAction() !== undefined) const actionFocused = createMemo(() => focusedAction() !== undefined)
let selection: { value: T; category?: string } | undefined
let resetSelection = false
let visibilityGeneration = 0
createEffect( createEffect(
on( on(
@@ -102,6 +106,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current)) const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current))
if (currentIndex >= 0) { if (currentIndex >= 0) {
setStore("selected", currentIndex) setStore("selected", currentIndex)
selection = flat()[currentIndex]
} }
} }
}, },
@@ -209,11 +214,69 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
const selected = createMemo(() => flat()[store.selected]) 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( createEffect(
on([() => store.filter, () => props.current], ([filter, current]) => { on([() => store.filter, () => props.current], ([filter, current]) => {
if (filter.length > 0) resetSelection = true
setTimeout(() => { setTimeout(() => {
if (filter.length > 0) { if (filter.length > 0) {
moveTo(0, true) moveTo(0, true, false)
} else if (current) { } else if (current) {
const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current)) const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current))
if (currentIndex >= 0) { if (currentIndex >= 0) {
@@ -233,11 +296,19 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
moveTo(next, true) moveTo(next, true)
} }
function moveTo(next: number, center = false) { function moveTo(next: number, center = false, preserve = true) {
setFocusedAction(undefined) setFocusedAction(undefined)
setStore("selected", next) setStore("selected", next)
const option = selected() const option = selected()
if (option) {
selection = option
resetSelection = !preserve
}
if (option) props.onMove?.(option) if (option) props.onMove?.(option)
scrollToSelection(center)
}
function scrollToSelection(center: boolean) {
if (!scroll) return if (!scroll) return
let remaining = store.selected let remaining = store.selected
let index = 0 let index = 0
@@ -6,6 +6,7 @@ import { KVProvider, useKV } from "../../../../src/context/kv"
import { ProjectProvider, useProject } from "../../../../src/context/project" import { ProjectProvider, useProject } from "../../../../src/context/project"
import { SDKProvider } from "../../../../src/context/sdk" import { SDKProvider } from "../../../../src/context/sdk"
import { SyncProvider, useSync } from "../../../../src/context/sync" import { SyncProvider, useSync } from "../../../../src/context/sync"
import { ExitProvider } from "../../../../src/context/exit"
import { createEventSource, createFetch, type FetchHandler, directory } from "../../../fixture/tui-sdk" import { createEventSource, createFetch, type FetchHandler, directory } from "../../../fixture/tui-sdk"
import { TestTuiContexts } from "../../../fixture/tui-environment" import { TestTuiContexts } from "../../../fixture/tui-environment"
export { createEventSource, createFetch, directory, eventSource, json, worktree } from "../../../fixture/tui-sdk" export { createEventSource, createFetch, directory, eventSource, json, worktree } from "../../../fixture/tui-sdk"
@@ -48,9 +49,11 @@ export async function mount(override?: FetchHandler, state?: string) {
<KVProvider> <KVProvider>
<SDKProvider url="http://test" directory={directory} fetch={calls.fetch} events={events.source}> <SDKProvider url="http://test" directory={directory} fetch={calls.fetch} events={events.source}>
<ProjectProvider> <ProjectProvider>
<SyncProvider> <ExitProvider exit={() => {}}>
<Probe /> <SyncProvider>
</SyncProvider> <Probe />
</SyncProvider>
</ExitProvider>
</ProjectProvider> </ProjectProvider>
</SDKProvider> </SDKProvider>
</KVProvider> </KVProvider>
@@ -25,6 +25,7 @@ describe("tui sync", () => {
try { try {
expect(kv.get("session_directory_filter_enabled", true)).toBe(true) 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("scope")).toBeNull()
expect(session.at(-1)?.searchParams.get("path")).toBe("packages/tui") 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("scope")).toBe("project")
expect(session.at(-1)?.searchParams.get("path")).toBeNull() expect(session.at(-1)?.searchParams.get("path")).toBeNull()
expect(session.at(-1)?.searchParams.get("roots")).toBeNull()
} finally { } finally {
app.renderer.destroy() app.renderer.destroy()
} }
@@ -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<string>({
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()
})
})