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")