fix(tui): preserve family session recency

This commit is contained in:
Ryan Vogel 2026-08-01 11:48:29 -04:00
commit 729531550c
7 changed files with 165 additions and 56 deletions

View file

@ -444,6 +444,18 @@ function run(db: DatabaseService, event: MessageEvent) {
})
}
function runAndTouch(db: DatabaseService, event: MessageEvent) {
return Effect.gen(function* () {
yield* run(db, event)
yield* db
.update(SessionTable)
.set({ time_updated: DateTime.toEpochMillis(event.created) })
.where(eq(SessionTable.id, event.data.sessionID))
.run()
.pipe(Effect.orDie)
})
}
function insertMessage(db: DatabaseService, event: SessionEvent.DurableEvent, message: SessionMessage.Info) {
if (event.durable === undefined) return Effect.die(new Error("Durable Session event is missing aggregate sequence"))
const encoded = encodeMessage(message)
@ -678,9 +690,9 @@ const layer = Layer.effectDiscard(
})
}),
)
yield* bus.project(SessionEvent.Execution.Succeeded, (event) => run(db, event))
yield* bus.project(SessionEvent.Execution.Failed, (event) => run(db, event))
yield* bus.project(SessionEvent.Execution.Interrupted, (event) => run(db, event))
yield* bus.project(SessionEvent.Execution.Succeeded, (event) => runAndTouch(db, event))
yield* bus.project(SessionEvent.Execution.Failed, (event) => runAndTouch(db, event))
yield* bus.project(SessionEvent.Execution.Interrupted, (event) => runAndTouch(db, event))
yield* bus.project(SessionEvent.InstructionsUpdated, (event) =>
InstructionState.apply(db, event.data.sessionID, event.durable.seq, event.data.delta),
)

View file

@ -659,6 +659,7 @@ describe("SessionProjector", () => {
directory: "/project",
title: "test",
version: "test",
time_updated: -1,
})
.run()
.pipe(Effect.orDie)
@ -670,9 +671,17 @@ describe("SessionProjector", () => {
.where(eq(SessionTable.id, sessionID))
.get()
.pipe(Effect.orDie)
const updated = () =>
db
.select({ value: SessionTable.time_updated })
.from(SessionTable)
.where(eq(SessionTable.id, sessionID))
.get()
.pipe(Effect.orDie)
yield* bus.publish(SessionEvent.Execution.Interrupted, { sessionID, reason: "shutdown" })
expect((yield* suspended())?.timeSuspended).toBeNull()
expect((yield* updated())?.value ?? -1).toBeGreaterThan(-1)
yield* bus.publish(SessionEvent.Execution.Started, { sessionID })
expect((yield* suspended())?.timeSuspended).toBeNull()

View file

@ -55,6 +55,7 @@ import { Keymap, type KeymapCommand } from "../../context/keymap"
import { abbreviateHome } from "../../runtime"
import { PluginSlot } from "../../plugin/render"
import { useSessionTabs } from "../../context/session-tabs"
import { SESSION_INBOX_MIN_TERMINAL_WIDTH } from "../../context/session-tabs-model"
registerOpencodeSpinner()
@ -821,7 +822,7 @@ export function Prompt(props: PromptProps) {
store.mode === "normal" &&
!auto()?.visible &&
config.tabs?.layout === "inbox" &&
dimensions().width >= 72 &&
dimensions().width >= SESSION_INBOX_MIN_TERMINAL_WIDTH &&
sessionTabs.enabled() &&
sessionTabs.tabs().length > 0 &&
store.prompt.text === "" &&

View file

@ -6,7 +6,6 @@ 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, sessionInboxWidth, type SessionInboxGroup } from "../context/session-tabs-model"
import { useTheme, useThemes } from "../context/theme"
@ -25,7 +24,7 @@ const labels: Record<SessionInboxGroup, string> = {
earlier: "Earlier",
}
export type SessionInboxRowInfo = {
type SessionInboxRowInfo = {
sessionID: string
title: string
updated: number
@ -34,13 +33,13 @@ export type SessionInboxRowInfo = {
group: SessionInboxGroup
}
export function SessionInboxRow(props: {
function SessionInboxRow(props: {
row: SessionInboxRowInfo
selected?: boolean
focused?: boolean
pendingDone?: boolean
number?: number
verb?: string
verb: string
onSelect?: () => void
}) {
const theme = useTheme("elevated")
@ -132,8 +131,8 @@ export function SessionInboxRow(props: {
</text>
</Match>
<Match when={props.row.status.busy}>
<Spinner color={accent()}>
<span style={{ fg: accent() }}>{props.verb ?? activityVerb(props.row.sessionID)}</span>
<Spinner color={accent()}>
<span style={{ fg: accent() }}>{props.verb}</span>
</Spinner>
</Match>
</Switch>
@ -147,24 +146,26 @@ export function SessionInboxRow(props: {
export function SessionInbox() {
const tabs = useSessionTabs()
const data = useData()
const route = useRoute()
const theme = useTheme("elevated")
const themes = useThemes()
const config = useConfig().data
const prompt = usePromptRef()
const keymap = Keymap.use()
const dimensions = useTerminalDimensions()
let scroll: ScrollBoxRenderable
const [verbCycle, setVerbCycle] = createSignal(0)
const [groupClock, setGroupClock] = createSignal(Date.now())
const verbTimer = setInterval(() => setVerbCycle((value) => value + 1), 3_500)
const groupTimer = setInterval(() => setGroupClock(Date.now()), 60_000)
onCleanup(() => {
clearInterval(verbTimer)
clearInterval(groupTimer)
tabs.navigation.blur()
})
const width = createMemo(() => sessionInboxWidth(dimensions().width))
const hueStep = () => (themes.mode() === "light" ? 800 : 200)
const accent = () => theme.hue.accent[hueStep()]
const rows = createMemo(() => {
verbCycle()
return tabs
.recent()
.map((tab) => {
@ -187,17 +188,16 @@ export function SessionInbox() {
updated,
preview: markdownPreview(preview ?? "") || "No assistant response yet",
status,
group: sessionInboxGroup(updated, status.busy),
group: sessionInboxGroup(updated, status.busy, groupClock()),
}
})
.toSorted((a, b) => b.updated - a.updated)
})
const groups = createMemo(() =>
(["running", "today", "yesterday", "earlier"] as const)
.map((group) => ({ group, rows: rows().filter((row) => row.group === group) }))
.filter((group) => group.rows.length > 0),
)
const order = () => groups().flatMap((group) => group.rows.map((row) => row.sessionID))
const numbers = createMemo(() => new Map(rows().map((row, index) => [row.sessionID, index + 1])))
createEffect(() => {
if (!tabs.navigation.active()) return
@ -219,12 +219,7 @@ export function SessionInbox() {
prompt.current?.focus()
}
const newSession = () => {
tabs.navigation.blur()
route.navigate({
type: "home",
location: route.data.type === "session" ? data.session.get(route.data.sessionID)?.location : undefined,
})
setTimeout(() => prompt.current?.focus(), 0)
keymap.dispatch("session.new")
}
Keymap.createLayer(() => ({
@ -236,18 +231,17 @@ export function SessionInbox() {
bind: "up,shift+tab",
title: "Previous session",
group: "Session",
run: () => tabs.navigation.move(-1, order()),
run: () => tabs.navigation.move(-1),
},
{
bind: "down,tab",
title: "Next session",
group: "Session",
run: () => tabs.navigation.move(1, order()),
run: () => tabs.navigation.move(1),
},
{ bind: "return", title: "Open session", group: "Session", run: () => tabs.navigation.select() },
{ bind: "space", title: "Mark session done", group: "Session", run: () => tabs.navigation.done(order()) },
{ bind: "right", title: "Return to prompt", group: "Session", run: leave },
{ bind: "escape", title: "Return to prompt", group: "Session", run: leave },
{ bind: "space", title: "Mark session done", group: "Session", run: () => tabs.navigation.done() },
{ bind: "right,escape", title: "Return to prompt", group: "Session", run: leave },
],
}))
@ -301,7 +295,7 @@ export function SessionInbox() {
selected={tabs.current() === row.sessionID}
focused={tabs.navigation.active() && tabs.navigation.selected() === row.sessionID}
pendingDone={tabs.navigation.pendingDone() === row.sessionID}
number={order().indexOf(row.sessionID) + 1}
number={numbers().get(row.sessionID)}
verb={activityVerb(row.sessionID, verbCycle())}
onSelect={() => {
tabs.navigation.blur()

View file

@ -84,17 +84,29 @@ 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 updated = (sessionID: string) => {
const session = root(sessionID)
setLastActivity((value) => ({ ...value, [session]: Math.max(value[session] ?? 0, created) }))
const members = data.session.family(session)
return (members.length > 0 ? members : [session]).reduce(
(latest, id) =>
Math.max(
latest,
data.session.get(id)?.time.updated ?? 0,
lastActivity()[id] ?? 0,
),
0,
)
}
const touch = (sessionID: string, created: number) => {
if (!enabled()) return
if ((lastActivity()[sessionID] ?? 0) >= created) return
setLastActivity((value) => ({ ...value, [sessionID]: Math.max(value[sessionID] ?? 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)
}
const normalize = (value: TabsState) => ({
const normalize = (value: TabsState): TabsState => ({
tabs: value.tabs.reduce<SessionTab[]>((tabs, tab) => {
const sessionID = root(tab.sessionID)
return openSessionTab(tabs, { sessionID, title: title(sessionID, tab.title) })
@ -212,7 +224,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
for (const sessionID of sessions) {
if (stale) return
await Promise.allSettled([
data.session.sync(sessionID),
data.session.sync(sessionID, { children: true }),
data.session.message.sync(sessionID),
data.session.pending.sync(sessionID),
data.session.permission.sync(sessionID),
@ -247,8 +259,8 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
onCleanup(
event.on("session.input.admitted", (evt) => {
if (!enabled() || evt.data.input.type !== "user") return
touch(evt.data.sessionID, evt.created)
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 }))
}),
@ -286,6 +298,14 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
delete next[target]
return next
})
setLastActivity((activity) => {
const family = data.session.family(target)
const members = family.length > 0 ? family : [target]
if (!members.some((id) => activity[id] !== undefined)) return activity
const next = { ...activity }
for (const id of members) delete next[id]
return next
})
if (selected) route.navigate(next ? { type: "session", sessionID: next } : { type: "home" })
}
@ -364,9 +384,9 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
active: navigationActive,
selected: navigationSelection,
pendingDone: navigationPendingDone,
focus(order: readonly string[] = recent().map((tab) => tab.sessionID)) {
focus() {
if (!enabled() || state().tabs.length === 0) return false
setNavigationSelection(current() ?? order.find((sessionID) => state().tabs.some((tab) => tab.sessionID === sessionID)))
setNavigationSelection(current() ?? recent()[0]?.sessionID)
setNavigationPendingDone(undefined)
setNavigationActive(true)
return true
@ -375,10 +395,8 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
setNavigationActive(false)
setNavigationPendingDone(undefined)
},
move(direction: 1 | -1, order?: readonly string[]) {
const tabs = (order ?? state().tabs.map((tab) => tab.sessionID)).filter((sessionID) =>
state().tabs.some((tab) => tab.sessionID === sessionID),
)
move(direction: 1 | -1) {
const tabs = recent().map((tab) => tab.sessionID)
if (!navigationActive() || tabs.length === 0) return
const index = tabs.findIndex((sessionID) => sessionID === navigationSelection())
const start = index === -1 ? (direction === 1 ? -1 : 0) : index
@ -392,21 +410,20 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
setNavigationPendingDone(undefined)
route.navigate({ type: "session", sessionID })
},
done(order?: readonly string[]) {
done() {
const sessionID = navigationSelection()
if (!navigationActive() || !sessionID) return
if (navigationPendingDone() !== sessionID) {
setNavigationPendingDone(sessionID)
return
}
const tabs = (order ?? state().tabs.map((tab) => tab.sessionID)).filter((id) =>
state().tabs.some((tab) => tab.sessionID === id),
)
const tabs = recent().map((tab) => tab.sessionID)
const index = tabs.indexOf(sessionID)
const next = tabs[index + 1] ?? tabs[index - 1]
const selected = current() === sessionID
setNavigationPendingDone(undefined)
setNavigationSelection(next)
remove(sessionID, true)
setNavigationSelection(selected ? (current() ?? next) : next)
if (!next) setNavigationActive(false)
},
},

View file

@ -49,22 +49,43 @@ function stateDir(prefix: string) {
return dir
}
async function renderSessionTabs(initialSessionID: string, options?: { state?: string; title?: string }) {
type SessionFixture = { parentID?: string; title?: string; updated?: number }
async function renderSessionTabs(
initialSessionID: string,
options?: { state?: string; title?: string; sessions?: Record<string, SessionFixture> },
) {
const state = options?.state ?? stateDir("opencode-session-tabs-")
const events = createEventStream()
const calls = createFetch((url) => {
if (url.pathname !== `/api/session/${initialSessionID}`) return
return json({
data: {
id: initialSessionID,
title: options?.title,
if (url.pathname === "/api/session" && url.searchParams.has("parentID")) {
const parentID = url.searchParams.get("parentID")
return json({
data: Object.entries(options?.sessions ?? {}).flatMap(([id, fixture]) =>
fixture.parentID === parentID ? [session(id, fixture)] : [],
),
cursor: {},
})
}
const match = /^\/api\/session\/([^/]+)$/.exec(url.pathname)
if (!match) return
const id = match[1]!
const fixture = options?.sessions?.[id] ?? (id === initialSessionID ? { title: options?.title } : undefined)
if (!fixture) return
return json({ data: session(id, fixture) })
function session(id: string, fixture: SessionFixture) {
return {
id,
parentID: fixture.parentID,
title: fixture.title,
projectID: "project",
location: { directory },
cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
time: { created: 0, updated: 0 },
},
})
time: { created: 0, updated: fixture.updated ?? 0 },
}
}
}, events)
let tabs!: ReturnType<typeof useSessionTabs>
let route!: ReturnType<typeof useRoute>
@ -249,6 +270,37 @@ test("tracks live inbox recency beyond cached session metadata", async () => {
}
})
test("tracks child activity before family hydration and after restart", async () => {
const state = stateDir("opencode-session-tabs-recency-")
const sessions: Record<string, SessionFixture> = { child: { parentID: "parent" } }
const first = await renderSessionTabs("parent", { state, sessions })
try {
first.emit({
id: "evt_child_succeeded",
created: 200,
type: "session.execution.succeeded",
durable: { aggregateID: "child", seq: 1, version: 1 },
data: { sessionID: "child" },
})
expect(first.tabs.updated("parent")).toBe(0)
await first.data.session.sync("child")
await wait(() => first.tabs.updated("parent") === 200)
} finally {
first.destroy()
}
sessions.child.updated = 200
const second = await renderSessionTabs("parent", { state, sessions })
try {
expect(second.tabs.updated("parent")).toBe(0)
await second.data.session.sync("parent", { children: true })
await wait(() => second.tabs.updated("parent") === 200)
} finally {
second.destroy()
}
})
test("tracks a temporary new session tab across close and creation", async () => {
const setup = await renderSessionTabs("first")
@ -309,3 +361,27 @@ test("navigates the inbox without changing sessions and confirms done twice", as
setup.destroy()
}
})
test("keeps inbox focus aligned with history after marking the current session done", async () => {
const setup = await renderSessionTabs("first")
try {
await wait(() => setup.tabs.current() === "first")
setup.route.navigate({ type: "session", sessionID: "second" })
await wait(() => setup.tabs.current() === "second")
setup.route.navigate({ type: "session", sessionID: "third" })
await wait(() => setup.tabs.current() === "third")
setup.route.navigate({ type: "session", sessionID: "first" })
await wait(() => setup.tabs.current() === "first" && setup.tabs.tabs().length === 3)
setup.tabs.navigation.focus()
setup.tabs.navigation.done()
setup.tabs.navigation.done()
await wait(() => setup.tabs.tabs().length === 2)
expect(setup.tabs.current()).toBe("third")
expect(setup.tabs.navigation.selected()).toBe("third")
} finally {
setup.destroy()
}
})

View file

@ -3,7 +3,7 @@ import { ACTIVITY_VERBS, activityVerb } from "../../src/util/activity-verb"
test("rotates through 60 stable activity verbs", () => {
expect(ACTIVITY_VERBS).toHaveLength(60)
expect(new Set(ACTIVITY_VERBS).size).toBe(60)
expect(activityVerb("session-a", 0)).toBe(activityVerb("session-a", 60))
expect(new Set(ACTIVITY_VERBS).size).toBe(ACTIVITY_VERBS.length)
expect(activityVerb("session-a", 0)).toBe(activityVerb("session-a", ACTIVITY_VERBS.length))
expect(activityVerb("session-a", 1)).not.toBe(activityVerb("session-a", 0))
})