Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
172 changes: 152 additions & 20 deletions packages/tui/src/component/dialog-session-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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<T extends SessionListItem> = {
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<SessionListItem> | 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<T extends SessionListItem>(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<T extends SessionListItem>(input: {
result: SessionListResult<T> | 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()
Expand All @@ -28,22 +117,52 @@ export function DialogSessionList() {
const local = useLocal()
const toast = useToast()
const [toDelete, setToDelete] = createSignal<string>()
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<ReturnType<typeof sessions>[number]>) {
const workspace = project.workspace.get(session.workspaceID!)
Expand Down Expand Up @@ -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" })
}
Expand All @@ -131,15 +251,17 @@ export function DialogSessionList() {
))
}

function orderByRecency(sessionsList: NonNullable<ReturnType<typeof sessions>>) {
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<string[]>(orderByRecency(sync.data.session))

const quickSwitchHint = createMemo(() => {
const first = quickSwitch1()
const last = quickSwitch9()
Expand All @@ -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)
Expand Down Expand Up @@ -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")
Expand All @@ -216,10 +346,11 @@ export function DialogSessionList() {
return (
<DialogSelect
title="Sessions"
options={options()}
options={selectOptions()}
skipFilter={true}
locked={loading()}
current={currentSessionID()}
onFilter={setSearch}
onFilter={onFilter}
onMove={() => {
setToDelete(undefined)
}}
Expand Down Expand Up @@ -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
}
Expand Down
117 changes: 117 additions & 0 deletions packages/tui/test/component/dialog-session-list.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
Loading