Skip to content

Commit 908847a

Browse files
simonkleeiwwa-bot
authored andcommitted
fix(tui): load root sessions in session switcher (anomalyco#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 anomalyco#16270 Closes anomalyco#32725
1 parent 2a3a2b1 commit 908847a

5 files changed

Lines changed: 193 additions & 13 deletions

File tree

‎packages/tui/src/component/dialog-session-list.tsx‎

Lines changed: 65 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { useDialog } from "../ui/dialog"
22
import { DialogSelect } from "../ui/dialog-select"
33
import { useRoute } from "../context/route"
44
import { useSync } from "../context/sync"
5-
import { createMemo, createResource, createSignal, onMount } from "solid-js"
5+
import { createMemo, createResource, createSignal, onCleanup, onMount } from "solid-js"
66
import path from "path"
77
import { Locale } from "../util/locale"
88
import { useProject } from "../context/project"
@@ -17,6 +17,30 @@ import { Spinner } from "./spinner"
1717
import { errorMessage } from "../util/error"
1818
import { DialogSessionDeleteFailed } from "./dialog-session-delete-failed"
1919
import { useCommandShortcut } from "../keymap"
20+
import { useEvent } from "../context/event"
21+
22+
type SessionListFilter = { scope?: "project"; path?: string }
23+
24+
export function createDialogSessionListQuery(input: { search?: string; filter: SessionListFilter }) {
25+
const search = input.search?.trim()
26+
return {
27+
roots: true,
28+
limit: search ? 30 : 100,
29+
...(search ? { search } : {}),
30+
...input.filter,
31+
}
32+
}
33+
34+
export function loadDialogSessionList<T>(input: {
35+
search?: string
36+
filter: SessionListFilter
37+
list: (query: ReturnType<typeof createDialogSessionListQuery>) => Promise<{ data?: T[] }>
38+
}) {
39+
return input.list(createDialogSessionListQuery(input)).then(
40+
(result) => result.data,
41+
() => undefined,
42+
)
43+
}
2044

2145
export function DialogSessionList() {
2246
const dialog = useDialog()
@@ -25,25 +49,54 @@ export function DialogSessionList() {
2549
const project = useProject()
2650
const { theme } = useTheme()
2751
const sdk = useSDK()
52+
const event = useEvent()
2853
const local = useLocal()
2954
const toast = useToast()
3055
const [toDelete, setToDelete] = createSignal<string>()
56+
const [deleted, setDeleted] = createSignal(new Set<string>())
3157
const [search, setSearch] = createDebouncedSignal("", 150)
3258
const deleteHint = useCommandShortcut("session.delete")
3359
const quickSwitch1 = useCommandShortcut("session.quick_switch.1")
3460
const quickSwitch9 = useCommandShortcut("session.quick_switch.9")
3561

62+
const [browseResults, { refetch: refetchBrowse }] = createResource(
63+
() => sync.session.query(),
64+
(filter) => loadDialogSessionList({ filter, list: (query) => sdk.client.session.list(query) }),
65+
)
3666
const [searchResults, { refetch }] = createResource(
3767
() => ({ query: search(), filter: sync.session.query() }),
38-
async (input) => {
68+
(input) => {
3969
if (!input.query) return undefined
40-
const result = await sdk.client.session.list({ search: input.query, limit: 30, ...input.filter })
41-
return result.data ?? []
70+
return loadDialogSessionList({
71+
search: input.query,
72+
filter: input.filter,
73+
list: (query) => sdk.client.session.list(query),
74+
})
4275
},
4376
)
4477

4578
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
46-
const sessions = createMemo(() => searchResults() ?? sync.data.session)
79+
const sessions = createMemo(() => {
80+
const result = searchResults() ?? browseResults() ?? sync.data.session
81+
const synced = new Map(sync.data.session.map((session) => [session.id, session]))
82+
const ids = new Set(result.map((session) => session.id))
83+
const extra = [currentSessionID(), ...local.session.pinned()].flatMap((id) => {
84+
if (!id || ids.has(id)) return []
85+
const session = synced.get(id)
86+
if (session) ids.add(id)
87+
return session ? [session] : []
88+
})
89+
const query = search().trim().toLowerCase()
90+
return [...result.map((session) => synced.get(session.id) ?? session), ...extra]
91+
.filter((session) => !deleted().has(session.id))
92+
.filter((session) => !query || session.title.toLowerCase().includes(query))
93+
})
94+
95+
onCleanup(
96+
event.on("session.deleted", (event) => {
97+
setDeleted((current) => new Set(current).add(event.properties.info.id))
98+
}),
99+
)
47100

48101
function recover(session: NonNullable<ReturnType<typeof sessions>[number]>) {
49102
const workspace = project.workspace.get(session.workspaceID!)
@@ -108,6 +161,7 @@ export function DialogSessionList() {
108161
}
109162
await project.workspace.sync()
110163
await sync.session.refresh()
164+
await refetchBrowse()
111165
if (search()) await refetch()
112166
if (info?.workspaceID === session.workspaceID) {
113167
route.navigate({ type: "home" })
@@ -138,7 +192,7 @@ export function DialogSessionList() {
138192
.map((x) => x.id)
139193
}
140194

141-
const [browseOrder] = createSignal<string[]>(orderByRecency(sync.data.session))
195+
const browseOrder = createMemo(() => orderByRecency(browseResults() ?? sync.data.session))
142196

143197
const quickSwitchHint = createMemo(() => {
144198
const first = quickSwitch1()
@@ -160,7 +214,9 @@ export function DialogSessionList() {
160214
)
161215

162216
const searchResult = searchResults()
163-
const displayOrder = searchResult ? orderByRecency(searchResult) : browseOrder()
217+
const order = searchResult ? orderByRecency(sessions()) : browseOrder()
218+
const current = currentSessionID()
219+
const displayOrder = current && sessionMap.has(current) && !order.includes(current) ? [...order, current] : order
164220

165221
const pinned = local.session.pinned().filter((id) => sessionMap.has(id))
166222
const pinnedSet = new Set(pinned)
@@ -218,6 +274,7 @@ export function DialogSessionList() {
218274
title="Sessions"
219275
options={options()}
220276
skipFilter={true}
277+
preserveSelection={true}
221278
current={currentSessionID()}
222279
onFilter={setSearch}
223280
onMove={() => {
@@ -279,6 +336,7 @@ export function DialogSessionList() {
279336
if (status && status !== "connected") {
280337
await sync.session.refresh()
281338
}
339+
await refetchBrowse()
282340
if (search()) await refetch()
283341
setToDelete(undefined)
284342
return

‎packages/tui/src/ui/dialog-select.tsx‎

Lines changed: 74 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import {
99
import type { Binding } from "@opentui/keymap"
1010
import { useTheme, selectedForeground } from "../context/theme"
1111
import { entries, filter, flatMap, groupBy, pipe } from "remeda"
12-
import { batch, createEffect, createMemo, createSignal, For, Show, type JSX, on } from "solid-js"
12+
import { batch, createEffect, createMemo, createSignal, For, Show, type JSX, on, onCleanup } from "solid-js"
1313
import { createStore } from "solid-js/store"
1414
import { useTerminalDimensions } from "@opentui/solid"
1515
import * as fuzzysort from "fuzzysort"
@@ -35,6 +35,7 @@ export interface DialogSelectProps<T> {
3535
skipFilter?: boolean
3636
renderFilter?: boolean
3737
locked?: boolean
38+
preserveSelection?: boolean
3839
actions?: {
3940
command: string
4041
title: string
@@ -93,6 +94,9 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
9394
})
9495
const [focusedAction, setFocusedAction] = createSignal<number>()
9596
const actionFocused = createMemo(() => focusedAction() !== undefined)
97+
let selection: { value: T; category?: string } | undefined
98+
let resetSelection = false
99+
let visibilityGeneration = 0
96100

97101
createEffect(
98102
on(
@@ -102,6 +106,7 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
102106
const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current))
103107
if (currentIndex >= 0) {
104108
setStore("selected", currentIndex)
109+
selection = flat()[currentIndex]
105110
}
106111
}
107112
},
@@ -209,11 +214,69 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
209214

210215
const selected = createMemo(() => flat()[store.selected])
211216

217+
createEffect(
218+
on(
219+
() => props.options,
220+
() => {
221+
if (!props.preserveSelection) return
222+
if (resetSelection && store.filter.length > 0) {
223+
const option = flat()[0]
224+
if (!option) return
225+
setStore("selected", 0)
226+
selection = option
227+
return
228+
}
229+
if (!selection) {
230+
if (props.current !== undefined) {
231+
const index = flat().findIndex((option) => isDeepEqual(option.value, props.current))
232+
if (index >= 0) {
233+
setStore("selected", index)
234+
selection = flat()[index]
235+
return
236+
}
237+
}
238+
const option = selected()
239+
if (!option) return
240+
selection = option
241+
return
242+
}
243+
const previous = selection
244+
const index = flat().findIndex((option) => isDeepEqual(option.value, previous.value))
245+
if (index >= 0) {
246+
const option = flat()[index]
247+
const moved = index !== store.selected || option.category !== previous.category
248+
setStore("selected", index)
249+
selection = option
250+
if (!moved) return
251+
const value = option.value
252+
const generation = ++visibilityGeneration
253+
requestAnimationFrame(() => {
254+
requestAnimationFrame(() => {
255+
if (generation !== visibilityGeneration) return
256+
if (!props.preserveSelection || store.filter.length > 0) return
257+
if (!isDeepEqual(selected()?.value, value)) return
258+
scrollToSelection(false)
259+
})
260+
})
261+
return
262+
}
263+
const next = Math.min(store.selected, flat().length - 1)
264+
if (next < 0) return
265+
setStore("selected", next)
266+
selection = flat()[next]
267+
},
268+
),
269+
)
270+
onCleanup(() => {
271+
visibilityGeneration++
272+
})
273+
212274
createEffect(
213275
on([() => store.filter, () => props.current], ([filter, current]) => {
276+
if (filter.length > 0) resetSelection = true
214277
setTimeout(() => {
215278
if (filter.length > 0) {
216-
moveTo(0, true)
279+
moveTo(0, true, false)
217280
} else if (current) {
218281
const currentIndex = flat().findIndex((opt) => isDeepEqual(opt.value, current))
219282
if (currentIndex >= 0) {
@@ -233,11 +296,19 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
233296
moveTo(next, true)
234297
}
235298

236-
function moveTo(next: number, center = false) {
299+
function moveTo(next: number, center = false, preserve = true) {
237300
setFocusedAction(undefined)
238301
setStore("selected", next)
239302
const option = selected()
303+
if (option) {
304+
selection = option
305+
resetSelection = !preserve
306+
}
240307
if (option) props.onMove?.(option)
308+
scrollToSelection(center)
309+
}
310+
311+
function scrollToSelection(center: boolean) {
241312
if (!scroll) return
242313
let remaining = store.selected
243314
let index = 0

‎packages/tui/test/cli/cmd/tui/sync-fixture.tsx‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { KVProvider, useKV } from "../../../../src/context/kv"
66
import { ProjectProvider, useProject } from "../../../../src/context/project"
77
import { SDKProvider } from "../../../../src/context/sdk"
88
import { SyncProvider, useSync } from "../../../../src/context/sync"
9+
import { ExitProvider } from "../../../../src/context/exit"
910
import { createEventSource, createFetch, type FetchHandler, directory } from "../../../fixture/tui-sdk"
1011
import { TestTuiContexts } from "../../../fixture/tui-environment"
1112
export { createEventSource, createFetch, directory, eventSource, json, worktree } from "../../../fixture/tui-sdk"
@@ -48,9 +49,11 @@ export async function mount(override?: FetchHandler, state?: string) {
4849
<KVProvider>
4950
<SDKProvider url="http://test" directory={directory} fetch={calls.fetch} events={events.source}>
5051
<ProjectProvider>
51-
<SyncProvider>
52-
<Probe />
53-
</SyncProvider>
52+
<ExitProvider exit={() => {}}>
53+
<SyncProvider>
54+
<Probe />
55+
</SyncProvider>
56+
</ExitProvider>
5457
</ProjectProvider>
5558
</SDKProvider>
5659
</KVProvider>

‎packages/tui/test/cli/cmd/tui/sync.test.tsx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ describe("tui sync", () => {
2525

2626
try {
2727
expect(kv.get("session_directory_filter_enabled", true)).toBe(true)
28+
expect(session.at(-1)?.searchParams.get("roots")).toBeNull()
2829
expect(session.at(-1)?.searchParams.get("scope")).toBeNull()
2930
expect(session.at(-1)?.searchParams.get("path")).toBe("packages/tui")
3031

@@ -33,6 +34,7 @@ describe("tui sync", () => {
3334

3435
expect(session.at(-1)?.searchParams.get("scope")).toBe("project")
3536
expect(session.at(-1)?.searchParams.get("path")).toBeNull()
37+
expect(session.at(-1)?.searchParams.get("roots")).toBeNull()
3638
} finally {
3739
app.renderer.destroy()
3840
}
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
import { describe, expect, test } from "bun:test"
2+
import { createDialogSessionListQuery, loadDialogSessionList } from "../../src/component/dialog-session-list"
3+
4+
describe("dialog session list", () => {
5+
test("requests root sessions for the default browse list", () => {
6+
expect(createDialogSessionListQuery({ filter: { path: "packages/tui" } })).toEqual({
7+
roots: true,
8+
limit: 100,
9+
path: "packages/tui",
10+
})
11+
})
12+
13+
test("requests root sessions for search results", () => {
14+
expect(createDialogSessionListQuery({ search: " deploy ", filter: { scope: "project" } })).toEqual({
15+
roots: true,
16+
limit: 30,
17+
search: "deploy",
18+
scope: "project",
19+
})
20+
})
21+
22+
test("keeps the cache usable while the root request is pending", async () => {
23+
let resolve!: (result: { data: string[] }) => void
24+
const pending = loadDialogSessionList<string>({
25+
filter: {},
26+
list: () => new Promise((done) => (resolve = done)),
27+
})
28+
29+
expect(await Promise.race([pending, Promise.resolve("pending")])).toBe("pending")
30+
resolve({ data: ["root"] })
31+
expect(await pending).toEqual(["root"])
32+
})
33+
34+
test("falls back when the root request returns an error response", async () => {
35+
expect(await loadDialogSessionList({ filter: {}, list: async () => ({}) })).toBeUndefined()
36+
})
37+
38+
test("falls back when the root request rejects", async () => {
39+
expect(
40+
await loadDialogSessionList({
41+
filter: {},
42+
list: () => Promise.reject(new Error("offline")),
43+
}),
44+
).toBeUndefined()
45+
})
46+
})

0 commit comments

Comments
 (0)