diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 6373d70f08..afd59d9cd9 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -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 diff --git a/packages/tui/src/component/session-inbox.tsx b/packages/tui/src/component/session-inbox.tsx index b9c5a183b6..6107fd9776 100644 --- a/packages/tui/src/component/session-inbox.tsx +++ b/packages/tui/src/component/session-inbox.tsx @@ -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} - + - - {props.verb ?? activityVerb(props.row.sessionID)} - - } + - - Space again to mark done - - + {props.row.preview} + } > - - {props.row.preview} - - + + + Space again to mark done + + + + + Action required + + + + + {props.verb ?? activityVerb(props.row.sessionID)} + + + @@ -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 = () => { diff --git a/packages/tui/src/config/v1/keybind.ts b/packages/tui/src/config/v1/keybind.ts index 9edfa33f8c..f497a14087 100644 --- a/packages/tui/src/config/v1/keybind.ts +++ b/packages/tui/src/config/v1/keybind.ts @@ -85,7 +85,7 @@ export const Definitions = { session_export: keybind("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,n", "Create a new session"), + session_new: keybind("alt+t,n", "Create a new session"), session_list: keybind("l", "List all sessions"), open_menu: keybind("ctrl+o", "Open recent sessions and projects"), session_tab_next: keybind("ctrl+tab,right,alt+shift+]", "Switch to next open tab"), diff --git a/packages/tui/src/context/session-tabs-model.ts b/packages/tui/src/context/session-tabs-model.ts index 8e59bbfa0a..8741aec476 100644 --- a/packages/tui/src/context/session-tabs-model.ts +++ b/packages/tui/src/context/session-tabs-model.ts @@ -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) diff --git a/packages/tui/src/context/session-tabs.tsx b/packages/tui/src/context/session-tabs.tsx index 3b55a24ce1..efb87c180b 100644 --- a/packages/tui/src/context/session-tabs.tsx +++ b/packages/tui/src/context/session-tabs.tsx @@ -62,6 +62,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp }) const fallback = empty() const [promptPulses, setPromptPulses] = createSignal>({}) + const [lastActivity, setLastActivity] = createSignal>({}) const [navigationActive, setNavigationActive] = createSignal(false) const [navigationSelection, setNavigationSelection] = createSignal() const [navigationPendingDone, setNavigationPendingDone] = createSignal() @@ -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() }, diff --git a/packages/tui/src/routes/session/index.tsx b/packages/tui/src/routes/session/index.tsx index e7aa1f84ed..06bddf814a 100644 --- a/packages/tui/src/routes/session/index.tsx +++ b/packages/tui/src/routes/session/index.tsx @@ -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(() => 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)) diff --git a/packages/tui/test/config.test.tsx b/packages/tui/test/config.test.tsx index 73260285b2..58fa057110 100644 --- a/packages/tui/test/config.test.tsx +++ b/packages/tui/test/config.test.tsx @@ -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,n" }]) + expect(config.keybinds.get("session.new")).toMatchObject([{ key: "alt+t,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: "1" }]) diff --git a/packages/tui/test/context/session-tabs-model.test.ts b/packages/tui/test/context/session-tabs-model.test.ts index fe13bd0e5b..99365194d6 100644 --- a/packages/tui/test/context/session-tabs-model.test.ts +++ b/packages/tui/test/context/session-tabs-model.test.ts @@ -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"]) diff --git a/packages/tui/test/context/session-tabs.test.tsx b/packages/tui/test/context/session-tabs.test.tsx index 9bd17059a1..c98cb61885 100644 --- a/packages/tui/test/context/session-tabs.test.tsx +++ b/packages/tui/test/context/session-tabs.test.tsx @@ -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")