fix(tui): address inbox review findings

This commit is contained in:
Ryan Vogel 2026-08-01 15:20:41 +00:00
commit dbd934689f
9 changed files with 125 additions and 40 deletions

View file

@ -69,6 +69,7 @@ import { DialogSessionList } from "./component/dialog-session-list"
import { DialogOpen } from "./component/dialog-open"
import { SessionTabs } from "./component/session-tabs"
import { SessionInbox } from "./component/session-inbox"
import { SESSION_INBOX_MIN_TERMINAL_WIDTH } from "./context/session-tabs-model"
import { ThemeErrorToast } from "./component/theme-error-toast"
import { ThemeProvider, useTheme, useThemes } from "./context/theme"
import { Home } from "./routes/home"
@ -520,7 +521,8 @@ function App(props: { pair?: DialogPairCredentials }) {
const terminalTitleEnabled = () => config.data.terminal?.title ?? true
const copyOnSelectEnabled = () => config.data.terminal?.copy_on_select ?? process.platform !== "win32"
const pasteSummaryEnabled = () => config.data.prompt?.paste !== "full"
const inboxTabsEnabled = () => config.data.tabs?.layout === "inbox" && dimensions().width >= 72
const inboxTabsEnabled = () =>
config.data.tabs?.layout === "inbox" && dimensions().width >= SESSION_INBOX_MIN_TERMINAL_WIDTH
createEffect(() => {
renderer.useMouse = config.data.mouse

View file

@ -1,14 +1,14 @@
import { TextAttributes, type ScrollBoxRenderable } from "@opentui/core"
import { useTerminalDimensions } from "@opentui/solid"
import type { SessionMessageAssistant } from "@opencode-ai/client"
import { For, Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { For, Match, Show, Switch, createEffect, createMemo, createSignal, onCleanup } from "solid-js"
import { useConfig } from "../config"
import { useData } from "../context/data"
import { Keymap } from "../context/keymap"
import { usePromptRef } from "../context/prompt"
import { useRoute } from "../context/route"
import { useSessionTabs } from "../context/session-tabs"
import { sessionInboxGroup, type SessionInboxGroup } from "../context/session-tabs-model"
import { sessionInboxGroup, sessionInboxWidth, type SessionInboxGroup } from "../context/session-tabs-model"
import { useTheme, useThemes } from "../context/theme"
import { tint } from "../theme/color"
import { getScrollAcceleration } from "../util/scroll"
@ -101,40 +101,42 @@ export function SessionInboxRow(props: {
>
{props.row.title}
</text>
<Show when={!props.row.status.busy && (props.row.status.unread || props.row.status.attention)}>
<Show when={props.row.status.attention || (!props.row.status.busy && props.row.status.unread)}>
<text width={2} fg={feedback()} selectable={false}>
</text>
</Show>
</box>
<box height={1}>
<Show
when={!props.pendingDone && !props.row.status.busy}
<Switch
fallback={
<Show
when={props.pendingDone}
fallback={
<Spinner color={accent()}>
<span style={{ fg: accent() }}>{props.verb ?? activityVerb(props.row.sessionID)}</span>
</Spinner>
}
<text
height={1}
fg={tint(theme.text.subdued, theme.text.default, props.selected ? 0.25 : 0)}
wrapMode="none"
truncate
selectable={false}
>
<text fg={theme.text.feedback.warning.default} wrapMode="none" truncate>
Space again to mark done
</text>
</Show>
{props.row.preview}
</text>
}
>
<text
height={1}
fg={tint(theme.text.subdued, theme.text.default, props.selected ? 0.25 : 0)}
wrapMode="none"
truncate
selectable={false}
>
{props.row.preview}
</text>
</Show>
<Match when={props.pendingDone}>
<text fg={theme.text.feedback.warning.default} wrapMode="none" truncate>
Space again to mark done
</text>
</Match>
<Match when={props.row.status.attention}>
<text fg={theme.text.feedback.warning.default} wrapMode="none" truncate>
Action required
</text>
</Match>
<Match when={props.row.status.busy}>
<Spinner color={accent()}>
<span style={{ fg: accent() }}>{props.verb ?? activityVerb(props.row.sessionID)}</span>
</Spinner>
</Match>
</Switch>
</box>
</box>
</box>
@ -158,7 +160,7 @@ export function SessionInbox() {
clearInterval(verbTimer)
tabs.navigation.blur()
})
const width = createMemo(() => Math.max(28, Math.min(40, Math.floor(dimensions().width * 0.28))))
const width = createMemo(() => sessionInboxWidth(dimensions().width))
const hueStep = () => (themes.mode() === "light" ? 800 : 200)
const accent = () => theme.hue.accent[hueStep()]
const rows = createMemo(() => {
@ -178,13 +180,14 @@ export function SessionInbox() {
.filter((part) => part.type === "text")
.map((part) => part.text)
.join("\n")
const updated = tabs.updated(tab.sessionID)
return {
sessionID: tab.sessionID,
title: session ? withTimestampedFallback(session) : (tab.title ?? "Loading session…"),
updated: session?.time.updated ?? 0,
updated,
preview: markdownPreview(preview ?? "") || "No assistant response yet",
status,
group: sessionInboxGroup(session?.time.updated ?? 0, status.busy),
group: sessionInboxGroup(updated, status.busy),
}
})
.toSorted((a, b) => b.updated - a.updated)
@ -204,8 +207,11 @@ export function SessionInbox() {
if (!row) return
const top = scroll.scrollTop + row.y - scroll.viewport.y
const bottom = top + row.height
if (top < scroll.scrollTop) scroll.scrollTo(top)
else if (bottom > scroll.scrollTop + scroll.viewport.height) scroll.scrollTo(bottom - scroll.viewport.height)
if (top < scroll.scrollTop) {
scroll.scrollTo(top)
return
}
if (bottom > scroll.scrollTop + scroll.viewport.height) scroll.scrollTo(bottom - scroll.viewport.height)
})
const leave = () => {

View file

@ -85,7 +85,7 @@ export const Definitions = {
session_export: keybind("<leader>x", "Export session to editor"),
session_copy: keybind("none", "Copy session transcript"),
session_move: keybind("none", "Move session"),
session_new: keybind("alt+t,ctrl+t,<leader>n", "Create a new session"),
session_new: keybind("alt+t,<leader>n", "Create a new session"),
session_list: keybind("<leader>l", "List all sessions"),
open_menu: keybind("ctrl+o", "Open recent sessions and projects"),
session_tab_next: keybind("ctrl+tab,<leader>right,alt+shift+]", "Switch to next open tab"),

View file

@ -24,6 +24,12 @@ export const sessionTabOverflowWidth = (count: number) => String(count).length +
export type SessionInboxGroup = "running" | "today" | "yesterday" | "earlier"
export const SESSION_INBOX_MIN_TERMINAL_WIDTH = 72
export function sessionInboxWidth(terminalWidth: number) {
return Math.max(28, Math.min(40, Math.floor(terminalWidth * 0.28)))
}
export function sessionInboxGroup(updated: number, running: boolean, now = Date.now()): SessionInboxGroup {
if (running) return "running"
const today = new Date(now)

View file

@ -62,6 +62,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
})
const fallback = empty()
const [promptPulses, setPromptPulses] = createSignal<Record<string, number>>({})
const [lastActivity, setLastActivity] = createSignal<Record<string, number>>({})
const [navigationActive, setNavigationActive] = createSignal(false)
const [navigationSelection, setNavigationSelection] = createSignal<string>()
const [navigationPendingDone, setNavigationPendingDone] = createSignal<string>()
@ -83,6 +84,12 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
}
const root = (sessionID: string) => data.session.root(sessionID)
const updated = (sessionID: string) =>
Math.max(data.session.get(sessionID)?.time.updated ?? 0, lastActivity()[root(sessionID)] ?? 0)
const touch = (sessionID: string, created: number) => {
const session = root(sessionID)
setLastActivity((value) => ({ ...value, [session]: Math.max(value[session] ?? 0, created) }))
}
const title = (sessionID: string, persisted?: string, fallback?: string) => {
const session = data.session.get(sessionID)
return session?.title ?? persisted ?? fallback ?? (session ? withTimestampedFallback(session) : undefined)
@ -121,7 +128,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
const recent = () =>
orderSessionTabs(state().tabs, (sessionID) => ({
busy: status(sessionID).busy,
updated: data.session.get(sessionID)?.time.updated ?? 0,
updated: updated(sessionID),
}))
function markUnread(sessionID: string, unread: SessionTabUnread) {
@ -219,13 +226,29 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
})
})
onCleanup(event.on("session.execution.succeeded", (evt) => markUnread(evt.data.sessionID, "activity")))
onCleanup(event.on("session.execution.interrupted", (evt) => markUnread(evt.data.sessionID, "activity")))
onCleanup(event.on("session.execution.failed", (evt) => markUnread(evt.data.sessionID, "error")))
onCleanup(
event.on("session.execution.succeeded", (evt) => {
touch(evt.data.sessionID, evt.created)
markUnread(evt.data.sessionID, "activity")
}),
)
onCleanup(
event.on("session.execution.interrupted", (evt) => {
touch(evt.data.sessionID, evt.created)
markUnread(evt.data.sessionID, "activity")
}),
)
onCleanup(
event.on("session.execution.failed", (evt) => {
touch(evt.data.sessionID, evt.created)
markUnread(evt.data.sessionID, "error")
}),
)
onCleanup(
event.on("session.input.admitted", (evt) => {
if (!enabled() || evt.data.input.type !== "user") return
const sessionID = root(evt.data.sessionID)
touch(sessionID, evt.created)
if (current() === sessionID || !state().tabs.some((tab) => tab.sessionID === sessionID)) return
setPromptPulses((pulses) => ({ ...pulses, [sessionID]: (pulses[sessionID] ?? 0) + 1 }))
}),
@ -272,6 +295,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
return state().tabs
},
recent,
updated,
newTab() {
return newTab()
},

View file

@ -73,6 +73,8 @@ import { DialogExportOptions } from "../../ui/dialog-export-options"
import { DialogExportResult } from "../../ui/dialog-export-result"
import { sessionEpilogue } from "../../util/presentation"
import { useConfig } from "../../config"
import { useSessionTabs } from "../../context/session-tabs"
import { SESSION_INBOX_MIN_TERMINAL_WIDTH, sessionInboxWidth } from "../../context/session-tabs-model"
import { useClipboard } from "../../context/clipboard"
import { nextThinkingMode, reasoningSummary, type ThinkingMode } from "../../context/thinking"
import { getScrollAcceleration } from "../../util/scroll"
@ -190,6 +192,7 @@ export function Session() {
})
const dimensions = useTerminalDimensions()
const sessionTabs = useSessionTabs()
const sidebar = createMemo(() => config.session?.sidebar ?? "auto")
const [sidebarOpen, setSidebarOpen] = createSignal(false)
const thinkingMode = createMemo<ThinkingMode>(() => config.session?.thinking ?? "hide")
@ -199,14 +202,20 @@ export function Session() {
const diffWrapMode = createMemo(() => config.diffs?.wrap ?? "word")
const groupExploration = createMemo(() => config.session?.grouping !== "none")
const wide = createMemo(() => dimensions().width > 120)
const viewportWidth = createMemo(() => {
const width = dimensions().width
if (!sessionTabs.enabled() || config.tabs?.layout !== "inbox" || width < SESSION_INBOX_MIN_TERMINAL_WIDTH)
return width
return width - sessionInboxWidth(width)
})
const wide = createMemo(() => viewportWidth() > 120)
const sidebarVisible = createMemo(() => {
if (session()?.parentID) return false
if (sidebarOpen()) return true
if (sidebar() === "auto" && wide()) return true
return false
})
const contentWidth = createMemo(() => dimensions().width - (sidebarVisible() ? 42 : 0) - 4)
const contentWidth = createMemo(() => viewportWidth() - (sidebarVisible() ? 42 : 0) - 4)
const models = createMemo(() => data.location.model.list(location()) ?? [])
const scrollAcceleration = createMemo(() => getScrollAcceleration(config))

View file

@ -117,7 +117,7 @@ test("navigates session tabs with leader arrows", () => {
test("preserves pinned session bindings alongside tab bindings", () => {
const config = resolve({}, { terminalSuspend: true })
expect(config.keybinds.get("session.new")).toMatchObject([{ key: "alt+t,ctrl+t,<leader>n" }])
expect(config.keybinds.get("session.new")).toMatchObject([{ key: "alt+t,<leader>n" }])
expect(config.keybinds.has("session.toggle.thinking")).toBe(false)
expect(config.keybinds.get("session.pin.toggle")).toMatchObject([{ key: "ctrl+f" }])
expect(config.keybinds.get("session.quick_switch.1")).toMatchObject([{ key: "<leader>1" }])

View file

@ -12,6 +12,7 @@ import {
reopenSessionTab,
seedSessionTabMotion,
sessionInboxGroup,
sessionInboxWidth,
sessionTabComplete,
sessionTabOverflowWidth,
} from "../../src/context/session-tabs-model"
@ -42,6 +43,12 @@ describe("session tabs", () => {
expect(sessionInboxGroup(new Date(2026, 6, 29, 23).getTime(), false, now)).toBe("earlier")
})
test("sizes the inbox within its minimum and maximum rails", () => {
expect(sessionInboxWidth(72)).toBe(28)
expect(sessionInboxWidth(120)).toBe(33)
expect(sessionInboxWidth(200)).toBe(40)
})
test("moves a tab to a clamped index and returns the same tabs for no-ops", () => {
const tabs = ["a", "b", "c"].map((sessionID) => ({ sessionID }))
expect(moveSessionTab(tabs, "a", 2).map((tab) => tab.sessionID)).toEqual(["b", "c", "a"])

View file

@ -218,6 +218,37 @@ test("user prompt admissions pulse an already-busy background tab", async () =>
}
})
test("tracks live inbox recency beyond cached session metadata", async () => {
const setup = await renderSessionTabs("background")
try {
expect(setup.tabs.updated("background")).toBe(0)
setup.emit({
id: "evt_admitted",
created: 100,
type: "session.input.admitted",
durable: { aggregateID: "background", seq: 1, version: 1 },
data: {
sessionID: "background",
inputID: "msg_1",
input: { type: "user", data: { text: "work" }, delivery: "steer" },
},
})
await wait(() => setup.tabs.updated("background") === 100)
setup.emit({
id: "evt_succeeded",
created: 200,
type: "session.execution.succeeded",
durable: { aggregateID: "background", seq: 2, version: 1 },
data: { sessionID: "background" },
})
await wait(() => setup.tabs.updated("background") === 200)
} finally {
setup.destroy()
}
})
test("tracks a temporary new session tab across close and creation", async () => {
const setup = await renderSessionTabs("first")