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) { 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")) if (event.durable === undefined) return Effect.die(new Error("Durable Session event is missing aggregate sequence"))
const encoded = encodeMessage(message) 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.Succeeded, (event) => runAndTouch(db, event))
yield* bus.project(SessionEvent.Execution.Failed, (event) => run(db, event)) yield* bus.project(SessionEvent.Execution.Failed, (event) => runAndTouch(db, event))
yield* bus.project(SessionEvent.Execution.Interrupted, (event) => run(db, event)) yield* bus.project(SessionEvent.Execution.Interrupted, (event) => runAndTouch(db, event))
yield* bus.project(SessionEvent.InstructionsUpdated, (event) => yield* bus.project(SessionEvent.InstructionsUpdated, (event) =>
InstructionState.apply(db, event.data.sessionID, event.durable.seq, event.data.delta), InstructionState.apply(db, event.data.sessionID, event.durable.seq, event.data.delta),
) )

View file

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

View file

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

View file

@ -6,7 +6,6 @@ import { useConfig } from "../config"
import { useData } from "../context/data" import { useData } from "../context/data"
import { Keymap } from "../context/keymap" import { Keymap } from "../context/keymap"
import { usePromptRef } from "../context/prompt" import { usePromptRef } from "../context/prompt"
import { useRoute } from "../context/route"
import { useSessionTabs } from "../context/session-tabs" import { useSessionTabs } from "../context/session-tabs"
import { sessionInboxGroup, sessionInboxWidth, type SessionInboxGroup } from "../context/session-tabs-model" import { sessionInboxGroup, sessionInboxWidth, type SessionInboxGroup } from "../context/session-tabs-model"
import { useTheme, useThemes } from "../context/theme" import { useTheme, useThemes } from "../context/theme"
@ -25,7 +24,7 @@ const labels: Record<SessionInboxGroup, string> = {
earlier: "Earlier", earlier: "Earlier",
} }
export type SessionInboxRowInfo = { type SessionInboxRowInfo = {
sessionID: string sessionID: string
title: string title: string
updated: number updated: number
@ -34,13 +33,13 @@ export type SessionInboxRowInfo = {
group: SessionInboxGroup group: SessionInboxGroup
} }
export function SessionInboxRow(props: { function SessionInboxRow(props: {
row: SessionInboxRowInfo row: SessionInboxRowInfo
selected?: boolean selected?: boolean
focused?: boolean focused?: boolean
pendingDone?: boolean pendingDone?: boolean
number?: number number?: number
verb?: string verb: string
onSelect?: () => void onSelect?: () => void
}) { }) {
const theme = useTheme("elevated") const theme = useTheme("elevated")
@ -132,8 +131,8 @@ export function SessionInboxRow(props: {
</text> </text>
</Match> </Match>
<Match when={props.row.status.busy}> <Match when={props.row.status.busy}>
<Spinner color={accent()}> <Spinner color={accent()}>
<span style={{ fg: accent() }}>{props.verb ?? activityVerb(props.row.sessionID)}</span> <span style={{ fg: accent() }}>{props.verb}</span>
</Spinner> </Spinner>
</Match> </Match>
</Switch> </Switch>
@ -147,24 +146,26 @@ export function SessionInboxRow(props: {
export function SessionInbox() { export function SessionInbox() {
const tabs = useSessionTabs() const tabs = useSessionTabs()
const data = useData() const data = useData()
const route = useRoute()
const theme = useTheme("elevated") const theme = useTheme("elevated")
const themes = useThemes() const themes = useThemes()
const config = useConfig().data const config = useConfig().data
const prompt = usePromptRef() const prompt = usePromptRef()
const keymap = Keymap.use()
const dimensions = useTerminalDimensions() const dimensions = useTerminalDimensions()
let scroll: ScrollBoxRenderable let scroll: ScrollBoxRenderable
const [verbCycle, setVerbCycle] = createSignal(0) const [verbCycle, setVerbCycle] = createSignal(0)
const [groupClock, setGroupClock] = createSignal(Date.now())
const verbTimer = setInterval(() => setVerbCycle((value) => value + 1), 3_500) const verbTimer = setInterval(() => setVerbCycle((value) => value + 1), 3_500)
const groupTimer = setInterval(() => setGroupClock(Date.now()), 60_000)
onCleanup(() => { onCleanup(() => {
clearInterval(verbTimer) clearInterval(verbTimer)
clearInterval(groupTimer)
tabs.navigation.blur() tabs.navigation.blur()
}) })
const width = createMemo(() => sessionInboxWidth(dimensions().width)) const width = createMemo(() => sessionInboxWidth(dimensions().width))
const hueStep = () => (themes.mode() === "light" ? 800 : 200) const hueStep = () => (themes.mode() === "light" ? 800 : 200)
const accent = () => theme.hue.accent[hueStep()] const accent = () => theme.hue.accent[hueStep()]
const rows = createMemo(() => { const rows = createMemo(() => {
verbCycle()
return tabs return tabs
.recent() .recent()
.map((tab) => { .map((tab) => {
@ -187,17 +188,16 @@ export function SessionInbox() {
updated, updated,
preview: markdownPreview(preview ?? "") || "No assistant response yet", preview: markdownPreview(preview ?? "") || "No assistant response yet",
status, status,
group: sessionInboxGroup(updated, status.busy), group: sessionInboxGroup(updated, status.busy, groupClock()),
} }
}) })
.toSorted((a, b) => b.updated - a.updated)
}) })
const groups = createMemo(() => const groups = createMemo(() =>
(["running", "today", "yesterday", "earlier"] as const) (["running", "today", "yesterday", "earlier"] as const)
.map((group) => ({ group, rows: rows().filter((row) => row.group === group) })) .map((group) => ({ group, rows: rows().filter((row) => row.group === group) }))
.filter((group) => group.rows.length > 0), .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(() => { createEffect(() => {
if (!tabs.navigation.active()) return if (!tabs.navigation.active()) return
@ -219,12 +219,7 @@ export function SessionInbox() {
prompt.current?.focus() prompt.current?.focus()
} }
const newSession = () => { const newSession = () => {
tabs.navigation.blur() keymap.dispatch("session.new")
route.navigate({
type: "home",
location: route.data.type === "session" ? data.session.get(route.data.sessionID)?.location : undefined,
})
setTimeout(() => prompt.current?.focus(), 0)
} }
Keymap.createLayer(() => ({ Keymap.createLayer(() => ({
@ -236,18 +231,17 @@ export function SessionInbox() {
bind: "up,shift+tab", bind: "up,shift+tab",
title: "Previous session", title: "Previous session",
group: "Session", group: "Session",
run: () => tabs.navigation.move(-1, order()), run: () => tabs.navigation.move(-1),
}, },
{ {
bind: "down,tab", bind: "down,tab",
title: "Next session", title: "Next session",
group: "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: "return", title: "Open session", group: "Session", run: () => tabs.navigation.select() },
{ bind: "space", title: "Mark session done", group: "Session", run: () => tabs.navigation.done(order()) }, { bind: "space", title: "Mark session done", group: "Session", run: () => tabs.navigation.done() },
{ bind: "right", title: "Return to prompt", group: "Session", run: leave }, { bind: "right,escape", title: "Return to prompt", group: "Session", run: leave },
{ bind: "escape", title: "Return to prompt", group: "Session", run: leave },
], ],
})) }))
@ -301,7 +295,7 @@ export function SessionInbox() {
selected={tabs.current() === row.sessionID} selected={tabs.current() === row.sessionID}
focused={tabs.navigation.active() && tabs.navigation.selected() === row.sessionID} focused={tabs.navigation.active() && tabs.navigation.selected() === row.sessionID}
pendingDone={tabs.navigation.pendingDone() === row.sessionID} pendingDone={tabs.navigation.pendingDone() === row.sessionID}
number={order().indexOf(row.sessionID) + 1} number={numbers().get(row.sessionID)}
verb={activityVerb(row.sessionID, verbCycle())} verb={activityVerb(row.sessionID, verbCycle())}
onSelect={() => { onSelect={() => {
tabs.navigation.blur() 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 root = (sessionID: string) => data.session.root(sessionID)
const updated = (sessionID: string) => 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) 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 title = (sessionID: string, persisted?: string, fallback?: string) => {
const session = data.session.get(sessionID) const session = data.session.get(sessionID)
return session?.title ?? persisted ?? fallback ?? (session ? withTimestampedFallback(session) : undefined) 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) => { tabs: value.tabs.reduce<SessionTab[]>((tabs, tab) => {
const sessionID = root(tab.sessionID) const sessionID = root(tab.sessionID)
return openSessionTab(tabs, { sessionID, title: title(sessionID, tab.title) }) 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) { for (const sessionID of sessions) {
if (stale) return if (stale) return
await Promise.allSettled([ await Promise.allSettled([
data.session.sync(sessionID), data.session.sync(sessionID, { children: true }),
data.session.message.sync(sessionID), data.session.message.sync(sessionID),
data.session.pending.sync(sessionID), data.session.pending.sync(sessionID),
data.session.permission.sync(sessionID), data.session.permission.sync(sessionID),
@ -247,8 +259,8 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
onCleanup( onCleanup(
event.on("session.input.admitted", (evt) => { event.on("session.input.admitted", (evt) => {
if (!enabled() || evt.data.input.type !== "user") return if (!enabled() || evt.data.input.type !== "user") return
touch(evt.data.sessionID, evt.created)
const sessionID = root(evt.data.sessionID) const sessionID = root(evt.data.sessionID)
touch(sessionID, evt.created)
if (current() === sessionID || !state().tabs.some((tab) => tab.sessionID === sessionID)) return if (current() === sessionID || !state().tabs.some((tab) => tab.sessionID === sessionID)) return
setPromptPulses((pulses) => ({ ...pulses, [sessionID]: (pulses[sessionID] ?? 0) + 1 })) setPromptPulses((pulses) => ({ ...pulses, [sessionID]: (pulses[sessionID] ?? 0) + 1 }))
}), }),
@ -286,6 +298,14 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
delete next[target] delete next[target]
return next 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" }) if (selected) route.navigate(next ? { type: "session", sessionID: next } : { type: "home" })
} }
@ -364,9 +384,9 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
active: navigationActive, active: navigationActive,
selected: navigationSelection, selected: navigationSelection,
pendingDone: navigationPendingDone, pendingDone: navigationPendingDone,
focus(order: readonly string[] = recent().map((tab) => tab.sessionID)) { focus() {
if (!enabled() || state().tabs.length === 0) return false 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) setNavigationPendingDone(undefined)
setNavigationActive(true) setNavigationActive(true)
return true return true
@ -375,10 +395,8 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
setNavigationActive(false) setNavigationActive(false)
setNavigationPendingDone(undefined) setNavigationPendingDone(undefined)
}, },
move(direction: 1 | -1, order?: readonly string[]) { move(direction: 1 | -1) {
const tabs = (order ?? state().tabs.map((tab) => tab.sessionID)).filter((sessionID) => const tabs = recent().map((tab) => tab.sessionID)
state().tabs.some((tab) => tab.sessionID === sessionID),
)
if (!navigationActive() || tabs.length === 0) return if (!navigationActive() || tabs.length === 0) return
const index = tabs.findIndex((sessionID) => sessionID === navigationSelection()) const index = tabs.findIndex((sessionID) => sessionID === navigationSelection())
const start = index === -1 ? (direction === 1 ? -1 : 0) : index const start = index === -1 ? (direction === 1 ? -1 : 0) : index
@ -392,21 +410,20 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
setNavigationPendingDone(undefined) setNavigationPendingDone(undefined)
route.navigate({ type: "session", sessionID }) route.navigate({ type: "session", sessionID })
}, },
done(order?: readonly string[]) { done() {
const sessionID = navigationSelection() const sessionID = navigationSelection()
if (!navigationActive() || !sessionID) return if (!navigationActive() || !sessionID) return
if (navigationPendingDone() !== sessionID) { if (navigationPendingDone() !== sessionID) {
setNavigationPendingDone(sessionID) setNavigationPendingDone(sessionID)
return return
} }
const tabs = (order ?? state().tabs.map((tab) => tab.sessionID)).filter((id) => const tabs = recent().map((tab) => tab.sessionID)
state().tabs.some((tab) => tab.sessionID === id),
)
const index = tabs.indexOf(sessionID) const index = tabs.indexOf(sessionID)
const next = tabs[index + 1] ?? tabs[index - 1] const next = tabs[index + 1] ?? tabs[index - 1]
const selected = current() === sessionID
setNavigationPendingDone(undefined) setNavigationPendingDone(undefined)
setNavigationSelection(next)
remove(sessionID, true) remove(sessionID, true)
setNavigationSelection(selected ? (current() ?? next) : next)
if (!next) setNavigationActive(false) if (!next) setNavigationActive(false)
}, },
}, },

View file

@ -49,22 +49,43 @@ function stateDir(prefix: string) {
return dir 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 state = options?.state ?? stateDir("opencode-session-tabs-")
const events = createEventStream() const events = createEventStream()
const calls = createFetch((url) => { const calls = createFetch((url) => {
if (url.pathname !== `/api/session/${initialSessionID}`) return if (url.pathname === "/api/session" && url.searchParams.has("parentID")) {
return json({ const parentID = url.searchParams.get("parentID")
data: { return json({
id: initialSessionID, data: Object.entries(options?.sessions ?? {}).flatMap(([id, fixture]) =>
title: options?.title, 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", projectID: "project",
location: { directory }, location: { directory },
cost: 0, cost: 0,
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 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) }, events)
let tabs!: ReturnType<typeof useSessionTabs> let tabs!: ReturnType<typeof useSessionTabs>
let route!: ReturnType<typeof useRoute> 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 () => { test("tracks a temporary new session tab across close and creation", async () => {
const setup = await renderSessionTabs("first") const setup = await renderSessionTabs("first")
@ -309,3 +361,27 @@ test("navigates the inbox without changing sessions and confirms done twice", as
setup.destroy() 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", () => { test("rotates through 60 stable activity verbs", () => {
expect(ACTIVITY_VERBS).toHaveLength(60) expect(ACTIVITY_VERBS).toHaveLength(60)
expect(new Set(ACTIVITY_VERBS).size).toBe(60) expect(new Set(ACTIVITY_VERBS).size).toBe(ACTIVITY_VERBS.length)
expect(activityVerb("session-a", 0)).toBe(activityVerb("session-a", 60)) expect(activityVerb("session-a", 0)).toBe(activityVerb("session-a", ACTIVITY_VERBS.length))
expect(activityVerb("session-a", 1)).not.toBe(activityVerb("session-a", 0)) expect(activityVerb("session-a", 1)).not.toBe(activityVerb("session-a", 0))
}) })