fix(tui): distinguish asynchronous UI states (#36759)

This commit is contained in:
Kit Langton
2026-07-14 10:56:12 -04:00
committed by GitHub
parent ea386fba1f
commit 26e27c7a7f
10 changed files with 222 additions and 119 deletions
@@ -90,6 +90,11 @@ export function DialogIntegration(props: { onConnected?: OnIntegrationConnected
<text fg={theme.textMuted}>No integrations available</text>
</box>
}
noMatchView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.textMuted}>No integrations found</text>
</box>
}
/>
)
}
@@ -183,8 +188,8 @@ function KeyMethod(props: {
placeholder="API key"
onConfirm={(key) => {
if (!key) return
void client.api.integration
.connect.key({
void client.api.integration.connect
.key({
integrationID: props.integration.id,
location: location(data),
key,
@@ -222,8 +227,8 @@ function OAuthStarting(props: {
const toast = useToast()
onMount(() => {
void client.api.integration
.connect.oauth({
void client.api.integration.connect
.oauth({
integrationID: props.integration.id,
location: location(data),
methodID: props.method.id,
@@ -291,8 +296,8 @@ function OAuthAuto(props: {
}))
const poll = () => {
void client.api.integration
.attempt.status({ attemptID: props.attempt.attemptID, location: location(data) })
void client.api.integration.attempt
.status({ attemptID: props.attempt.attemptID, location: location(data) })
.then((result) => {
const status = result.data
if (status.status === "pending") {
@@ -357,8 +362,8 @@ function OAuthCode(props: {
placeholder="Authorization code"
onConfirm={(code) => {
if (!code) return
void client.api.integration
.attempt.complete({ attemptID: props.attempt.attemptID, location: location(data), code })
void client.api.integration.attempt
.complete({ attemptID: props.attempt.attemptID, location: location(data), code })
.then(() => {
settled = true
return connected(props.integration, data, dialog, toast, props.onConnected)
@@ -21,7 +21,9 @@ import type { ProjectDirectoriesOutput } from "@opencode-ai/client"
import { useRoute } from "../context/route"
import { DialogProjectCopyName } from "./dialog-project-copy-name"
export type MoveSessionSelection = { type: "directory"; directory: string; subdirectory: boolean } | { type: "new"; name: string }
export type MoveSessionSelection =
| { type: "directory"; directory: string; subdirectory: boolean }
| { type: "new"; name: string }
type ProjectDirectory = ProjectDirectoriesOutput[number]
type DialogMoveSessionProps = {
@@ -120,7 +122,7 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
if (showError()) return []
const data = directoryData()
const current = currentRoot()?.directory
if (directories.loading && !data && !current) return [{ title: "Loading project directories...", value: undefined }]
if (directories.loading && !data && !current) return []
const roots = [...(data ?? [])]
if (current && !roots.some((item) => item.directory === current)) roots.unshift({ directory: current })
roots.sort((a, b) => {
@@ -130,13 +132,12 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
if (!a.strategy && !b.strategy) return a.directory.length - b.directory.length
return 0
})
if (roots.length === 0) return [{ title: "No project directories found", value: undefined }]
if (roots.length === 0) return []
const subdirectories = sessionData.session
.list()
.filter(
(session) =>
session.projectID === props.projectID && session.subpath && ![".", "/"].includes(session.subpath),
(session) => session.projectID === props.projectID && session.subpath && ![".", "/"].includes(session.subpath),
)
.map((session) => session.location.directory)
.filter((directory) => !roots.some((root) => root.directory === directory))
@@ -326,13 +327,27 @@ export function DialogMoveSession(props: DialogMoveSessionProps) {
options={options()}
emptyView={
showError() ? (
<box paddingLeft={4} paddingRight={4}>
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.error} attributes={TextAttributes.BOLD}>
Could not load project directories
</text>
<text fg={theme.textMuted}>{errorMessage(loadError())}</text>
<text fg={theme.textMuted}>Close and reopen Move session to try again.</text>
</box>
) : undefined
) : directories.loading || loadedProject.loading ? (
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.textMuted}>Loading project directories</text>
</box>
) : (
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.textMuted}>No project directories available</text>
</box>
)
}
noMatchView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.textMuted}>No project directories found</text>
</box>
}
locked={showError() || directories.loading || loadedProject.loading || Boolean(removing())}
current={current()}
+33 -29
View File
@@ -25,12 +25,10 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
dialog.setCentered(true)
const [server] = createResource(() =>
client.api.server
.get()
.catch((error) => {
setLoadError(error)
return undefined
}),
client.api.server.get().catch((error) => {
setLoadError(error)
return undefined
}),
)
const info = createMemo(() => {
const current = server()
@@ -46,11 +44,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
const value = info()
if (!value) return
return (
<box
flexDirection={horizontal() ? "row" : "column"}
alignItems={horizontal() ? "flex-start" : "center"}
gap={2}
>
<box flexDirection={horizontal() ? "row" : "column"} alignItems={horizontal() ? "flex-start" : "center"} gap={2}>
<box width={horizontal() ? 29 : "100%"} flexShrink={0} gap={1}>
<box>
<text fg={theme.textMuted}>URLs</text>
@@ -72,9 +66,7 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
{showPassword() ? value.password : "************"}
</text>
</box>
<Show
when={value.urls.some((url) => ["localhost", "127.0.0.1", "[::1]"].includes(new URL(url).hostname))}
>
<Show when={value.urls.some((url) => ["localhost", "127.0.0.1", "[::1]"].includes(new URL(url).hostname))}>
<text fg={theme.textMuted} wrapMode="word">
Run `opencode service set hostname 0.0.0.0` to access the service remotely.
</text>
@@ -102,23 +94,35 @@ export function DialogPair(props: { credentials?: DialogPairCredentials }) {
esc
</text>
</box>
<Show when={loadError()}>
{(error) => <text fg={theme.error}>{errorMessage(error())}</text>}
</Show>
<Show when={info()} fallback={<text fg={theme.textMuted}>Loading server information...</text>}>
<Show
when={dimensions().height >= 36}
fallback={
<scrollbox
height={Math.max(8, dimensions().height - Math.floor(dimensions().height / 4) - 6)}
scrollbarOptions={{ visible: false }}
<Show
when={loadError()}
fallback={
<Show when={info()} fallback={<text fg={theme.textMuted}>Loading server information</text>}>
<Show
when={dimensions().height >= 36}
fallback={
<scrollbox
height={Math.max(8, dimensions().height - Math.floor(dimensions().height / 4) - 6)}
scrollbarOptions={{ visible: false }}
>
{content()}
</scrollbox>
}
>
{content()}
</scrollbox>
}
>
{content()}
</Show>
</Show>
</Show>
}
>
{(error) => (
<box>
<text fg={theme.error} attributes={TextAttributes.BOLD}>
Could not load server information
</text>
<text fg={theme.textMuted}>{errorMessage(error())}</text>
<text fg={theme.textMuted}>Close and reopen Pair to try again.</text>
</box>
)}
</Show>
</box>
)
@@ -26,6 +26,7 @@ export function DialogSessionList() {
const client = useClient()
const local = useLocal()
const toast = useToast()
const [filter, setFilter] = createSignal("")
const [search, setSearch] = createDebouncedSignal("", 150)
const [toDelete, setToDelete] = createSignal<string>()
const quickSwitch1 = useCommandShortcut("session.quick_switch.1")
@@ -44,21 +45,38 @@ export function DialogSessionList() {
directory: location.directory,
workspace: location.workspaceID,
})
return { query, sessions: response.data }
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.
toast.show({ message: errorMessage(error), variant: "error", duration: 5000 })
return { query, sessions: [] as SessionInfo[] }
return { query, 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()
if (!query) return sessions
return sessions.filter((session) => !session.parentID && session.title.toLowerCase().includes(query))
})
const sessions = createMemo(() => {
const query = search()
if (!query) return data.session.list()
const query = filter()
const local = localSessions()
if (!query) return local
if (query !== search() || searchResults.loading) return local
const result = searchResults()
return result?.query === query ? result.sessions : []
if (result?.query !== query || 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 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 }
})
const quickSwitchHint = createMemo(() => {
@@ -120,7 +138,20 @@ export function DialogSessionList() {
options={options()}
skipFilter={true}
current={currentSessionID()}
onFilter={setSearch}
onFilter={(query) => {
setFilter(query)
setSearch(query)
}}
emptyView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.textMuted}>No sessions available</text>
</box>
}
noMatchView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={searchState().error ? theme.error : theme.textMuted}>{searchState().message}</text>
</box>
}
onMove={() => setToDelete(undefined)}
onSelect={(option) => {
route.navigate({ type: "session", sessionID: option.value })
+30 -11
View File
@@ -1,6 +1,6 @@
import { TextAttributes } from "@opentui/core"
import { DialogSelect, type DialogSelectOption } from "../ui/dialog-select"
import { createResource, createMemo, createSignal } from "solid-js"
import { createResource, createMemo, createSignal, Match, Switch } from "solid-js"
import { useDialog } from "../ui/dialog"
import { useTheme } from "../context/theme"
import { errorMessage } from "../util/error"
@@ -57,17 +57,36 @@ export function DialogSkill(props: DialogSkillProps) {
<DialogSelect
title="Skills"
options={options()}
renderFilter={!showError()}
locked={showError()}
renderFilter={!showError() && !skills.loading}
locked={showError() || skills.loading}
emptyView={
showError() ? (
<box paddingLeft={4} paddingRight={4}>
<text fg={theme.error} attributes={TextAttributes.BOLD}>
Could not load skills
</text>
<text fg={theme.textMuted}>{errorMessage(loadError())}</text>
</box>
) : undefined
<Switch
fallback={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.textMuted}>No skills available</text>
</box>
}
>
<Match when={showError()}>
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.error} attributes={TextAttributes.BOLD}>
Could not load skills
</text>
<text fg={theme.textMuted}>{errorMessage(loadError())}</text>
<text fg={theme.textMuted}>Close and reopen Skills to try again.</text>
</box>
</Match>
<Match when={skills.loading}>
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.textMuted}>Loading skills</text>
</box>
</Match>
</Switch>
}
noMatchView={
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.textMuted}>No skills found</text>
</box>
}
/>
)
@@ -309,10 +309,10 @@ export function Autocomplete(props: {
}
const [files] = createResource(
() => ({ query: search(), location: location() }),
() => ({ query: search(), location: location(), visible: store.visible }),
async (input) => {
if (!store.visible || store.visible === "/") return []
if (referenceMatch()) return []
if (!input.visible || input.visible === "/") return { options: [], failed: false }
if (referenceMatch()) return { options: [], failed: false }
const { lineRange, baseQuery } = extractLineRange(input.query ?? "")
const result = await client.api.file
@@ -324,34 +324,37 @@ export function Autocomplete(props: {
workspace: input.location?.workspaceID ?? project.workspace.current(),
},
})
.catch(() => undefined)
.then(
(result) => result,
() => undefined,
)
if (!result) return { options: [], failed: true }
const options: AutocompleteOption[] = []
// Add file options. Trust the order returned by fff (frecency, fuzzy
// score, filename bonus, etc. are already factored in).
if (result) {
const width = props.anchor().width - 4
options.push(
...result.data.map((item): AutocompleteOption => {
const { filename, part } = createFilePart(item, path.join(result.location.directory, item.path), lineRange)
return {
display: Locale.truncateMiddle(filename, width),
value: filename,
isDirectory: item.type === "directory",
path: item.path,
onSelect: () => {
insertPart(filename, part)
},
}
}),
)
}
const width = props.anchor().width - 4
options.push(
...result.data.map((item): AutocompleteOption => {
const { filename, part } = createFilePart(item, path.join(result.location.directory, item.path), lineRange)
return {
display: Locale.truncateMiddle(filename, width),
value: filename,
isDirectory: item.type === "directory",
path: item.path,
onSelect: () => {
insertPart(filename, part)
},
}
}),
)
return options
return { options, failed: false }
},
{
initialValue: [],
initialValue: { options: [], failed: false },
},
)
@@ -470,8 +473,8 @@ export function Autocomplete(props: {
}))
})
const options = createMemo((prev: AutocompleteOption[] | undefined) => {
const filesValue = files()
const options = createMemo(() => {
const fileSearch = files()
const referenceMatchValue = referenceMatch()
const agentsValue = agents()
const referenceAliasesValue = referenceAliases()
@@ -484,7 +487,7 @@ export function Autocomplete(props: {
// Files come from fff already fuzzy ranked and filtered
// it shouldn't be additionally sorted by fuzzysort as it will loose the results
const fileOptions: AutocompleteOption[] = store.visible === "@" ? filesValue || [] : []
const fileOptions: AutocompleteOption[] = store.visible === "@" && !files.loading ? fileSearch.options : []
const nonFileOptions: AutocompleteOption[] =
store.visible === "@" ? [...referenceAliasesValue, ...agentsValue, ...mcpResources()] : [...commandsValue]
@@ -492,10 +495,6 @@ export function Autocomplete(props: {
return [...nonFileOptions, ...fileOptions]
}
if (files.loading && prev && prev.length > 0) {
return prev
}
const fuzziedNonFiles = fuzzysort
.go(removeLineRange(searchValue), nonFileOptions, {
keys: [
@@ -715,6 +714,13 @@ export function Autocomplete(props: {
let scroll: ScrollBoxRenderable
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))
const emptyMessage = createMemo(() => {
if (store.visible === "/") return "No matching commands"
if (files.loading) return "Searching…"
if (files().failed) return "Could not search files. Keep typing to try again."
return "No matching files, agents, or references"
})
const emptyError = createMemo(() => store.visible === "@" && !files.loading && files().failed)
return (
<box
@@ -738,7 +744,7 @@ export function Autocomplete(props: {
each={options()}
fallback={
<box paddingLeft={1} paddingRight={1}>
<text fg={theme.textMuted}>No matching items</text>
<text fg={emptyError() ? theme.error : theme.textMuted}>{emptyMessage()}</text>
</box>
}
>
@@ -104,16 +104,14 @@ function DiffViewer(props: { api: TuiPluginApi }) {
}
})
const [diff] = createResource(diffInput, async (input) => {
const result = await client.api.vcs.diff(
{
location: input.directory ? { directory: input.directory } : undefined,
mode: input.mode,
context: VCS_DIFF_CONTEXT_LINES,
},
)
const result = await client.api.vcs.diff({
location: input.directory ? { directory: input.directory } : undefined,
mode: input.mode,
context: VCS_DIFF_CONTEXT_LINES,
})
return normalizeDiffs(result.data ?? [])
})
const files = createMemo(() => diff() ?? [])
const files = createMemo(() => (diff.error ? [] : (diff() ?? [])))
const [focus, setFocus] = createSignal<DiffViewerFocus>("patches")
const [fileTreeEnabled, setFileTreeEnabled] = createSignal(config.data.diffs?.tree ?? true)
const showFileTree = createMemo(() => showDiffViewerFileTree(fileTreeEnabled(), files().length))
@@ -748,9 +746,11 @@ function DiffViewer(props: { api: TuiPluginApi }) {
<text fg={theme().text}>Diff </text>
<text fg={theme().textMuted}>{diffSourceLabel(mode())}</text>
<box flexGrow={1} />
<text fg={theme().textMuted}>
{files().length} {files().length === 1 ? "file" : "files"}
</text>
<Show when={!diff.loading && !diff.error}>
<text fg={theme().textMuted}>
{files().length} {files().length === 1 ? "file" : "files"}
</text>
</Show>
</Panel>
<box flexGrow={1} minHeight={0}>
@@ -758,19 +758,19 @@ function DiffViewer(props: { api: TuiPluginApi }) {
<Match when={diff.loading}>
<Separator axis="x" />
<box flexGrow={1} paddingLeft={1}>
<text fg={theme().textMuted}>Loading diff...</text>
</box>
</Match>
<Match when={!diff.loading && files().length === 0}>
<Separator axis="x" />
<box flexGrow={1} paddingLeft={1}>
<text fg={theme().textMuted}>No diff!</text>
<text fg={theme().textMuted}>Loading diff</text>
</box>
</Match>
<Match when={!diff.loading && diff.error}>
<Separator axis="x" />
<box flexGrow={1} paddingLeft={1}>
<text fg={theme().error}>Failed to load diff</text>
<text fg={theme().error}>Could not load diff. Reopen the diff viewer to try again.</text>
</box>
</Match>
<Match when={!diff.loading && files().length === 0}>
<Separator axis="x" />
<box flexGrow={1} paddingLeft={1}>
<text fg={theme().textMuted}>No changes to show</text>
</box>
</Match>
<Match when={!diff.loading}>
+17 -5
View File
@@ -26,6 +26,7 @@ export interface DialogSelectProps<T> {
placeholder?: string
footer?: JSX.Element
emptyView?: JSX.Element
noMatchView?: JSX.Element
options: DialogSelectOption<T>[]
flat?: boolean
ref?: (ref: DialogSelectRef<T>) => void
@@ -615,11 +616,22 @@ export function DialogSelect<T>(props: DialogSelectProps<T>) {
<Show
when={grouped().length > 0}
fallback={
props.emptyView ?? (
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.textMuted}>No results found</text>
</box>
)
<Show
when={props.renderFilter !== false && store.filter.length > 0}
fallback={
props.emptyView ?? (
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.textMuted}>No items available</text>
</box>
)
}
>
{props.noMatchView ?? (
<box paddingLeft={4} paddingRight={4} paddingTop={1}>
<text fg={theme.textMuted}>No results found</text>
</box>
)}
</Show>
}
>
<scrollbox
@@ -267,7 +267,7 @@ test("selects a repopulated option after removing the only option", async () =>
try {
select.replaceOptions([])
await select.app.waitForFrame((frame) => frame.includes("No results found"))
await select.app.waitForFrame((frame) => frame.includes("No items available"))
select.app.mockInput.pressEnter()
expect(select.selected).toEqual([])
+12 -1
View File
@@ -36,6 +36,16 @@ test("closing the diff viewer returns to the route it opened from", async () =>
}
})
test("shows an error instead of an empty diff when loading fails", async () => {
const viewer = await renderDiffViewer([], 20, undefined, true)
try {
await viewer.app.waitForFrame((frame) => frame.includes("Could not load diff"))
expect(viewer.app.captureCharFrame()).not.toContain("No changes to show")
} finally {
viewer.app.renderer.destroy()
}
})
test("brackets navigate diff hunks", async () => {
const viewer = await renderDiffViewer(
[
@@ -102,7 +112,7 @@ test("brackets navigate diff hunks", async () => {
}
})
async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?: TuiRouteCurrent) {
async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?: TuiRouteCurrent, fail = false) {
const commands = new Map<
string,
NonNullable<Parameters<TuiPluginApi["keymap"]["registerLayer"]>[0]["commands"]>[number]
@@ -113,6 +123,7 @@ async function renderDiffViewer(vcsDiff: unknown[], height = 20, initialRoute?:
const config = createTuiResolvedConfig()
const transport = createFetch((url) => {
if (url.pathname !== "/api/vcs/diff") return
if (fail) return json({ message: "boom" }, { status: 500 })
vcsDiffInput = {
location: { directory: url.searchParams.get("location[directory]") },
mode: url.searchParams.get("mode"),