Răsfoiți Sursa

fix(tui): preserve family session recency

Ryan Vogel 2 săptămâni în urmă
părinte
comite
729531550c

+ 15 - 3
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),
     )

+ 9 - 0
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()

+ 2 - 1
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 === "" &&

+ 17 - 23
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<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()

+ 35 - 18
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<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)
         },
       },

+ 85 - 9
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<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()
+  }
+})

+ 2 - 2
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))
 })