fix(tui): correct project-aware session lists

This commit is contained in:
Dax Raad 2026-07-29 21:58:53 -04:00
commit 488445a679
38 changed files with 506 additions and 220 deletions

View file

@ -102,12 +102,12 @@ export function DialogIntegration(
title="Connect a service"
options={options()}
emptyView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>No integrations available</text>
</box>
}
noMatchView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>No integrations found</text>
</box>
}

View file

@ -328,7 +328,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
options={options()}
emptyView={
showError() ? (
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.feedback.error.default} attributes={TextAttributes.BOLD}>
Could not load project directories
</text>
@ -336,17 +336,17 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
<text fg={theme.text.subdued}>Close and reopen Move session to try again.</text>
</box>
) : directories.loading || loadedProject.loading ? (
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>Loading project directories</text>
</box>
) : (
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>No project directories available</text>
</box>
)
}
noMatchView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>No project directories found</text>
</box>
}

View file

@ -1,6 +1,7 @@
import { createMemo, createResource, createSignal, onMount } from "solid-js"
import { createMemo, createResource, createSignal, onMount, Show } from "solid-js"
import path from "path"
import type { SessionInfo } from "@opencode-ai/client"
import { TextAttributes } from "@opentui/core"
import { useDialog } from "../ui/dialog"
import { DialogSelect } from "../ui/dialog-select"
import { useRoute } from "../context/route"
@ -32,52 +33,69 @@ export function DialogSessionList() {
const shortcuts = Keymap.useShortcuts()
const [search, setSearch] = createDebouncedSignal("", 150)
const [toDelete, setToDelete] = createSignal<string>()
const [allProjects, setAllProjects] = createSignal(false)
const [searchResults] = createResource(search, async (query) => {
if (!query) return
try {
if (!data.location.info()) await data.location.sync()
const current = data.location.info()
if (!current) throw new Error("Location unavailable")
const response = await client.api.session.list({
project: current.project.id,
search: query,
limit: 50,
order: "desc",
parentID: null,
})
return { query, sessions: response.data, error: undefined }
} catch (error) {
// A transient transport failure must degrade search, not crash the TUI
// through the root ErrorBoundary when the errored resource is read.
return { query, sessions: [] as SessionInfo[], error }
}
})
const [searchResults, { mutate: setSearchResults }] = createResource(
() => ({ query: search().trim(), allProjects: allProjects() }),
async ({ query, allProjects }) => {
try {
if (!data.location.info()) await data.location.sync()
const current = data.location.info()
if (!current) throw new Error("Location unavailable")
const response = await client.api.session.list({
...(allProjects
? {}
: {
project: current.project.id,
subpath: path.relative(current.project.directory, current.directory).replaceAll("\\", "/"),
}),
...(query ? { search: query } : {}),
limit: 50,
order: "desc",
parentID: null,
})
return { query, allProjects, sessions: response.data, error: undefined }
} catch (error) {
// A transient transport failure must degrade search, not crash the TUI
// through the root ErrorBoundary when the errored resource is read.
return { query, allProjects, sessions: [] as SessionInfo[], error }
}
},
)
const currentSessionID = createMemo(() => (route.data.type === "session" ? route.data.sessionID : undefined))
const localSessions = createMemo(() => {
const query = filter().trim().toLowerCase()
const sessions = data.session.list()
const current = data.location.info()
const sessions = data.session
.list()
.filter(
(session) =>
allProjects() ||
(session.projectID === current?.project.id && session.location.directory === current.directory),
)
if (!query) return sessions
return sessions.filter((session) => !session.parentID && session.title.toLowerCase().includes(query))
})
const sessions = createMemo(() => {
const query = filter()
const query = filter().trim()
const local = localSessions()
if (!query) return local
if (query !== search() || searchResults.loading) return local
if (query !== search().trim() || searchResults.loading) return searchResults.latest?.sessions ?? local
const result = searchResults()
if (result?.query !== query || result.error) return local
if (result?.query !== query || result.allProjects !== allProjects() || result.error) return local
return result.sessions
})
const searchState = createMemo(() => {
const query = filter()
if (!query) return { message: "No sessions available", error: false }
if (query !== search() || searchResults.loading) return { message: "Searching sessions…", error: false }
const query = filter().trim()
if (query !== search().trim() || searchResults.loading)
return { message: query ? "Searching sessions…" : "Loading sessions…", error: false }
const result = searchResults()
if (result?.query === query && result.error)
return { message: "Could not search sessions. Change the search to try again.", error: true }
return { message: "No sessions found", error: false }
return {
message: query ? "Could not search sessions. Change the search to try again." : "Could not load sessions.",
error: true,
}
return { message: query ? "No sessions found" : "No sessions available", error: false }
})
const quickSwitchHint = createMemo(() => {
@ -91,6 +109,13 @@ export function DialogSessionList() {
const hint = quickSwitchHint()
return hint && local.session.slots().length > 0 ? [{ title: "switch", label: hint }] : []
})
const currentProjectName = createMemo(() => {
const current = data.location.info()
if (!current) return ""
const project = data.project.get(current.project.id)
if (!project) return ""
return project.name || path.basename(project.canonical)
})
const options = createMemo(() => {
const today = new Date().toDateString()
@ -105,8 +130,12 @@ export function DialogSessionList() {
const option = (session: SessionInfo, category: string) => {
const directory = session.location.directory
const footer =
directory !== data.location.info()?.project.directory ? Locale.truncate(path.basename(directory), 20) : ""
const project = data.project.get(session.projectID)
const footer = allProjects()
? Locale.truncate(project?.name || path.basename(project?.canonical ?? directory), 20)
: directory !== data.location.info()?.project.directory
? Locale.truncate(path.basename(directory), 20)
: ""
const slot = sessionTabs.enabled() ? undefined : slotByID.get(session.id)
const deleting = toDelete() === session.id
return {
@ -139,6 +168,16 @@ export function DialogSessionList() {
return (
<DialogSelect
title="Sessions"
titleView={
<box flexDirection="row">
<text fg={theme.text.default} attributes={TextAttributes.BOLD}>
Sessions
</text>
<Show when={!allProjects() && currentProjectName()}>
<text fg={theme.text.subdued}> for {currentProjectName()}</text>
</Show>
</box>
}
options={options()}
skipFilter={true}
current={currentSessionID()}
@ -146,13 +185,25 @@ export function DialogSessionList() {
setFilter(query)
setSearch(query)
}}
bindings={[
{
bind: "ctrl+a",
title: allProjects() ? "Show current directory sessions" : "Show all project sessions",
group: "Dialog",
run: () => {
setAllProjects((value) => !value)
},
},
]}
emptyView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.text.subdued}>No sessions available</text>
<box paddingLeft={4} paddingRight={4}>
<text fg={searchState().error ? theme.text.feedback.error.default : theme.text.subdued}>
{searchState().message}
</text>
</box>
}
noMatchView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<box paddingLeft={4} paddingRight={4}>
<text fg={searchState().error ? theme.text.feedback.error.default : theme.text.subdued}>
{searchState().message}
</text>
@ -178,24 +229,34 @@ export function DialogSessionList() {
setToDelete(option.value)
return
}
void client.api.session.remove({ sessionID: option.value }).catch((error) => {
setToDelete(undefined)
toast.show({
message: `Failed to delete session: ${errorMessage(error)}`,
variant: "error",
duration: 5000,
void client.api.session
.remove({ sessionID: option.value })
.then(() => {
setSearchResults((result) =>
result ? { ...result, sessions: result.sessions.filter((session) => session.id !== option.value) } : result,
)
})
.catch((error) => {
setToDelete(undefined)
toast.show({
message: `Failed to delete session: ${errorMessage(error)}`,
variant: "error",
duration: 5000,
})
})
})
},
},
{
command: "session.rename",
title: "rename",
onTrigger: (option: { value: string }) =>
DialogSessionRename.show(dialog, option.value, data.session.get(option.value)?.title),
onTrigger: (option: { value: string; title: string }) =>
DialogSessionRename.show(dialog, option.value, option.title),
},
]}
footerHints={quickSwitchFooterHints()}
footerHints={[
...quickSwitchFooterHints(),
{ title: allProjects() ? "current directory" : "all projects", label: "ctrl+a", side: "right" },
]}
/>
)
}

View file

@ -62,13 +62,13 @@ export function DialogSkill(props: DialogSkillProps) {
emptyView={
<Switch
fallback={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>No skills available</text>
</box>
}
>
<Match when={showError()}>
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.feedback.error.default} attributes={TextAttributes.BOLD}>
Could not load skills
</text>
@ -77,14 +77,14 @@ export function DialogSkill(props: DialogSkillProps) {
</box>
</Match>
<Match when={skills.loading}>
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>Loading skills</text>
</box>
</Match>
</Switch>
}
noMatchView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>No skills found</text>
</box>
}

View file

@ -15,6 +15,7 @@ import type {
ModelInfo,
PermissionSavedInfo,
PermissionRequest,
Project,
ProviderInfo,
ReferenceInfo,
SessionMessageInfo,
@ -80,6 +81,7 @@ type Store = {
form: Record<string, FormWithLocation[]>
}
project: {
info: Record<string, Project>
permission: Record<string, PermissionSavedInfo[]>
}
location: Record<string, LocationData>
@ -139,6 +141,7 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
form: {},
},
project: {
info: {},
permission: {},
},
location: {},
@ -954,10 +957,26 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
sync.invalidate(`session.pending:${sessionID}`)
},
},
sync(sessionID: string) {
return sync.run(`session:${sessionID}`, async () => {
setStore("session", "info", sessionID, await client.api.session.get({ sessionID }))
registerSession(sessionID)
sync(sessionID: string, options?: { children?: boolean }) {
return sync.run(options?.children ? `session.family:${sessionID}` : `session:${sessionID}`, async () => {
const [info, children] = await Promise.all([
client.api.session.get({ sessionID }),
options?.children
? client.api.session.list({ parentID: sessionID, order: "desc" }).then((response) => response.data)
: [],
])
const sessions = [info, ...children]
setStore(
"session",
"info",
produce((draft) => {
for (const session of sessions) draft[session.id] = session
}),
)
for (const session of sessions) {
sync.complete(`session:${session.id}`)
registerSession(session.id)
}
})
},
invalidate(sessionID: string) {
@ -1037,6 +1056,21 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
},
},
project: {
list() {
return Object.values(store.project.info).toSorted((a, b) => b.time.updated - a.time.updated)
},
get(projectID: string) {
return store.project.info[projectID]
},
sync() {
return sync.run("project", async () => {
const projects = await client.api.project.list()
setStore("project", "info", reconcile(Object.fromEntries(projects.map((project) => [project.id, project]))))
})
},
invalidate() {
sync.invalidate("project")
},
permission: {
list(projectID: string) {
return store.project.permission[projectID]
@ -1318,27 +1352,9 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
.then((location) => {
const key = locationKey(location)
setStore("location", key, { ...store.location[key], info: location })
return client.api.session.list({
project: location.project.id,
limit: 50,
order: "desc",
parentID: null,
})
})
.then((response) => {
setStore(
"session",
"info",
produce((draft) => {
for (const session of response.data) draft[session.id] = session
}),
)
for (const session of response.data) {
sync.complete(`session:${session.id}`)
registerSession(session.id)
}
})
.catch((error) => console.error("Failed to preload sessions", error))
.catch((error) => console.error("Failed to preload location", error))
void result.project.sync().catch((error) => console.error("Failed to preload projects", error))
return
}
handleEvent(details)

View file

@ -256,7 +256,7 @@ export function Session() {
const sessionID = route.sessionID
void (async () => {
await Promise.all([
data.session.sync(sessionID),
data.session.sync(sessionID, { children: true }),
data.session.permission.sync(sessionID),
data.session.form.sync(sessionID),
])

View file

@ -615,14 +615,14 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
when={props.renderFilter !== false && store.filter.length > 0}
fallback={
props.emptyView ?? (
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>No items available</text>
</box>
)
}
>
{props.noMatchView ?? (
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.text.subdued}>No results found</text>
</box>
)}