fix(tui): address inbox review findings
This commit is contained in:
parent
4f936c2c95
commit
dbd934689f
9 changed files with 125 additions and 40 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = () => {
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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" }])
|
||||
|
|
|
|||
|
|
@ -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"])
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue