diff --git a/packages/core/src/session/projector.ts b/packages/core/src/session/projector.ts index 2498151625..49000550db 100644 --- a/packages/core/src/session/projector.ts +++ b/packages/core/src/session/projector.ts @@ -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), ) diff --git a/packages/core/test/session-projector.test.ts b/packages/core/test/session-projector.test.ts index 9891a0b962..f2b898c442 100644 --- a/packages/core/test/session-projector.test.ts +++ b/packages/core/test/session-projector.test.ts @@ -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() diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 4ddf665b9b..7102a950fa 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -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 === "" && diff --git a/packages/tui/src/component/session-inbox.tsx b/packages/tui/src/component/session-inbox.tsx index 6107fd9776..0147a083f9 100644 --- a/packages/tui/src/component/session-inbox.tsx +++ b/packages/tui/src/component/session-inbox.tsx @@ -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 = { 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: { - - {props.verb ?? activityVerb(props.row.sessionID)} + + {props.verb} @@ -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() diff --git a/packages/tui/src/context/session-tabs.tsx b/packages/tui/src/context/session-tabs.tsx index efb87c180b..e2836c8d90 100644 --- a/packages/tui/src/context/session-tabs.tsx +++ b/packages/tui/src/context/session-tabs.tsx @@ -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((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) }, }, diff --git a/packages/tui/test/context/session-tabs.test.tsx b/packages/tui/test/context/session-tabs.test.tsx index c98cb61885..aea159007a 100644 --- a/packages/tui/test/context/session-tabs.test.tsx +++ b/packages/tui/test/context/session-tabs.test.tsx @@ -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 }, +) { 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 let route!: ReturnType @@ -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 = { 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() + } +}) diff --git a/packages/tui/test/util/activity-verb.test.ts b/packages/tui/test/util/activity-verb.test.ts index 29be5828a4..9a2cc05de5 100644 --- a/packages/tui/test/util/activity-verb.test.ts +++ b/packages/tui/test/util/activity-verb.test.ts @@ -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)) })