ソースを参照

feat(tui): use server viewed state

Kit Langton 15 時間 前
コミット
e99043faf8

+ 1 - 1
packages/core/src/session.ts

@@ -456,7 +456,7 @@ const layer = Layer.effect(
           .get()
           .pipe(Effect.orDie)
         if (!row) return yield* new NotFoundError({ sessionID: input.sessionID })
-        if (row.idle === null || row.viewed === row.idle) return
+        if (row.idle === null || (row.viewed !== null && row.viewed >= row.idle)) return
         yield* bus.publish(SessionEvent.Viewed, { sessionID: input.sessionID })
       }),
       remove: Effect.fn("Session.remove")(function* (sessionID) {

+ 65 - 2
packages/core/test/session-view.test.ts

@@ -1,10 +1,12 @@
 import { describe, expect } from "bun:test"
+import path from "path"
 import { Bus } from "@opencode-ai/core/bus"
 import { Database } from "@opencode-ai/core/database/database"
 import { AppNodeBuilder } from "@opencode-ai/core/effect/app-node-builder"
 import { EventTable } from "@opencode-ai/core/event/sql"
 import { Location } from "@opencode-ai/core/location"
 import { Project } from "@opencode-ai/core/project"
+import { ProjectTable } from "@opencode-ai/core/project/sql"
 import { AbsolutePath } from "@opencode-ai/core/schema"
 import { Session } from "@opencode-ai/core/session"
 import { SessionEvent } from "@opencode-ai/core/session/event"
@@ -13,8 +15,9 @@ import { SessionProjector } from "@opencode-ai/core/session/projector"
 import { SessionTable } from "@opencode-ai/core/session/sql"
 import { SessionStore } from "@opencode-ai/core/session/store"
 import { LayerNode } from "@opencode-ai/util/effect/layer-node"
-import { DateTime, Effect } from "effect"
-import { eq } from "drizzle-orm"
+import { DateTime, Effect, Layer } from "effect"
+import { asc, eq } from "drizzle-orm"
+import { tmpdir } from "./fixture/tmpdir"
 import { testEffect } from "./lib/effect"
 import { globalProjectLayer } from "./lib/project"
 
@@ -108,4 +111,64 @@ describe("Session.view", () => {
       expect(yield* Effect.flip(session.view({ sessionID }))).toEqual(new Session.NotFoundError({ sessionID }))
     }),
   )
+
+  it.effect("replays viewed state into a fresh database", () =>
+    Effect.gen(function* () {
+      const session = yield* Session.Service
+      const bus = yield* Bus.Service
+      const sourceDb = (yield* Database.Service).db
+      const created = yield* session.create({ id: Session.ID.make("ses_view_replay"), location })
+      yield* bus.publish(SessionEvent.Execution.Succeeded, { sessionID: created.id })
+      yield* session.view({ sessionID: created.id })
+      yield* bus.publish(SessionEvent.Execution.Failed, {
+        sessionID: created.id,
+        error: { type: "unknown", message: "failed" },
+      })
+      const expected = yield* session.get(created.id)
+      if (!expected.time.idle || !expected.time.viewed) return yield* Effect.die(new Error("Expected attention times"))
+      const expectedIdle = DateTime.toEpochMillis(expected.time.idle)
+      const expectedViewed = DateTime.toEpochMillis(expected.time.viewed)
+      const serialized = (yield* sourceDb
+        .select()
+        .from(EventTable)
+        .where(eq(EventTable.aggregate_id, created.id))
+        .orderBy(asc(EventTable.seq))
+        .all()
+        .pipe(Effect.orDie)).map((event) => ({
+        id: event.id,
+        created: event.created,
+        aggregateID: event.aggregate_id,
+        seq: event.seq,
+        type: event.type,
+        data: event.data,
+      }))
+      const tmp = yield* Effect.acquireRelease(
+        Effect.promise(() => tmpdir()),
+        (tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
+      )
+      const targetLayer = AppNodeBuilder.build(
+        LayerNode.group([Database.node, Bus.node, SessionProjector.node, SessionStore.node]),
+        [
+          [Database.node, Database.configured({ path: path.join(tmp.path, "target.sqlite") })],
+          [Bus.node, Bus.configured({ persist: true })],
+        ],
+      )
+
+      yield* Effect.gen(function* () {
+        const db = (yield* Database.Service).db
+        const targetBus = yield* Bus.Service
+        const store = yield* SessionStore.Service
+        yield* db
+          .insert(ProjectTable)
+          .values({ id: Project.ID.global, worktree: location.directory, sandboxes: [] })
+          .run()
+          .pipe(Effect.orDie)
+        yield* Effect.forEach(serialized, (event) => targetBus.replay(event), { discard: true })
+
+        expect((yield* store.get(created.id))?.time).toEqual(expected.time)
+        expect(expected.time.updated).toEqual(created.time.updated)
+        expect(expectedIdle).toBeGreaterThan(expectedViewed)
+      }).pipe(Effect.provide(Layer.fresh(targetLayer)))
+    }),
+  )
 })

+ 7 - 0
packages/tui/src/context/data.tsx

@@ -816,6 +816,13 @@ export const { use: useData, provider: DataProvider } = createSimpleContext({
             const currentAssistant = message.activeAssistant(draft)
             if (currentAssistant) currentAssistant.retry = undefined
           })
+          if (event.type === "session.execution.interrupted" && event.data.reason === "shutdown") break
+          result.session.invalidate(event.data.sessionID)
+          void result.session.sync(event.data.sessionID)
+          break
+        case "session.viewed":
+          result.session.invalidate(event.data.sessionID)
+          void result.session.sync(event.data.sessionID)
           break
         case "session.revert.staged":
           if (store.session.info[event.data.sessionID])

+ 17 - 32
packages/tui/src/context/session-tabs.tsx

@@ -25,12 +25,12 @@ import {
   type ClosedSessionTab,
   type SessionTab,
   type SessionTabHistory,
-  type SessionTabUnread,
 } from "./session-tabs-model"
 
 type TabsState = {
   tabs: SessionTab[]
-  unread: Record<string, SessionTabUnread>
+  // Read only long enough to remove the former client-owned state from persisted tab files.
+  unread?: Record<string, unknown>
 }
 
 type PersistedState = {
@@ -43,7 +43,7 @@ type ScrollAnchor = {
   screenY: number
 }
 
-const empty = (): TabsState => ({ tabs: [], unread: {} })
+const empty = (): TabsState => ({ tabs: [] })
 
 // Deliberately after connect settles: the visible session's mount syncs win the first slots.
 const TAB_PREFETCH_DELAY = 300
@@ -60,7 +60,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
     const paths = useTuiPaths()
     const renderer = useRenderer()
     const enabled = () => config.tabs.enabled
-    // Focus reporting emits transitions, so an interactive launch owns unread state until its first blur.
+    // Focus reporting emits transitions, so an interactive launch may acknowledge viewed sessions until its first blur.
     const [focused, setFocused] = createSignal(true)
     // Keyed reconcile keeps tab object identity across reorders, so strip rows move instead of
     // mutating in place, which per-row animations and drag state depend on.
@@ -110,16 +110,15 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
       const session = data.session.get(sessionID)
       return session?.title ?? persisted ?? fallback ?? (session ? withTimestampedFallback(session) : undefined)
     }
+    const isUnread = (sessionID: string) => {
+      const info = data.session.get(sessionID)
+      return info?.time.idle !== undefined && (info.time.viewed === undefined || info.time.idle > info.time.viewed)
+    }
     const normalize = (value: TabsState) => ({
       tabs: value.tabs.reduce<SessionTab[]>((tabs, tab) => {
         const sessionID = root(tab.sessionID)
         return openSessionTab(tabs, { sessionID, title: title(sessionID, tab.title) })
       }, []),
-      unread: Object.entries(value.unread).reduce<Record<string, SessionTabUnread>>((result, entry) => {
-        const sessionID = root(entry[0])
-        result[sessionID] = result[sessionID] === "error" ? "error" : entry[1]
-        return result
-      }, {}),
     })
     const current = () => (route.data.type === "session" ? root(route.data.sessionID) : undefined)
     const newTab = createMemo((open = false) => {
@@ -133,7 +132,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
       const members = data.session.family(session)
       const family = members.length > 0 ? members : [session]
       return {
-        unread: state().unread[session],
+        unread: family.some(isUnread) ? ("activity" as const) : undefined,
         promptPulse: promptPulses()[session] ?? 0,
         attention: family.some(
           (id) => (data.session.permission.list(id)?.length ?? 0) > 0 || (data.session.form.list(id)?.length ?? 0) > 0,
@@ -142,17 +141,6 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
       }
     }
 
-    function markUnread(sessionID: string, unread: SessionTabUnread) {
-      if (!enabled() || !focused()) return
-      const session = root(sessionID)
-      if (current() === session || !state().tabs.some((tab) => tab.sessionID === session)) return
-      if (state().unread[session] === unread) return
-      update((draft) => {
-        if (!draft.tabs.some((tab) => tab.sessionID === session)) return
-        draft.unread[session] = unread
-      })
-    }
-
     createEffect(() => {
       if (!enabled()) return
       if (route.data.type !== "session" || route.data.sessionID === "dummy") return
@@ -176,10 +164,11 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
       if (!enabled() || !focused()) return
       if (route.data.type !== "session" || route.data.sessionID === "dummy") return
       const sessionID = root(route.data.sessionID)
-      if (!state().unread[sessionID]) return
-      update((draft) => {
-        delete draft.unread[sessionID]
-      })
+      const members = data.session.family(sessionID)
+      const family = members.length > 0 ? members : [sessionID]
+      const unread = family.filter(isUnread)
+      if (unread.length === 0) return
+      void Promise.allSettled(unread.map((id) => client.api.session.view({ sessionID: id })))
     })
 
     createEffect(() => {
@@ -189,7 +178,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
       update((draft) => {
         const next = normalize(draft)
         draft.tabs = next.tabs
-        draft.unread = next.unread
+        delete draft.unread
       })
     })
 
@@ -210,7 +199,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
       const sessionIDs = signature.split("\n")
       let stale = false
       void (async () => {
-        await Promise.allSettled(sessionIDs.map((sessionID) => data.session.sync(sessionID)))
+        await Promise.allSettled(sessionIDs.map((sessionID) => data.session.sync(sessionID, { children: true })))
         if (stale) return
         const locations = new Map(
           sessionIDs
@@ -244,9 +233,6 @@ 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.moved", (evt) => {
         if (!enabled() || !state().tabs.some((tab) => tab.sessionID === root(evt.data.sessionID))) return
@@ -282,7 +268,6 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
       history = previous.history
       update((draft) => {
         draft.tabs = closeSessionTab(draft.tabs, target).tabs
-        delete draft.unread[target]
       })
       setPromptPulses((pulses) => {
         if (pulses[target] === undefined) return pulses
@@ -378,7 +363,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
       cycleUnread(direction: 1 | -1) {
         if (!enabled()) return
         const tab = cycleSessionTab(state().tabs, current(), direction, (tab) =>
-          Boolean(state().unread[tab.sessionID] || status(tab.sessionID).attention),
+          Boolean(status(tab.sessionID).unread || status(tab.sessionID).attention),
         )
         if (tab) route.navigate({ type: "session", sessionID: tab.sessionID })
       },

+ 97 - 49
packages/tui/test/context/session-tabs.test.tsx

@@ -35,6 +35,8 @@ async function renderSessionTabs(
     persisted?: string[]
     sessionGate?: Promise<void>
     sessionDirectories?: Record<string, string>
+    sessionParents?: Record<string, string>
+    sessionTimes?: Record<string, { idle?: number; viewed?: number }>
     newLocation?: "launch" | "inherit"
   },
 ) {
@@ -53,9 +55,13 @@ async function renderSessionTabs(
   }
   const events = createEventStream()
   const sessions: string[] = []
+  const views: string[] = []
   const locations: string[] = []
   const vcsLocations: string[] = []
-  const calls = createFetch(async (url) => {
+  const sessionTimes = Object.fromEntries(
+    Object.entries(options?.sessionTimes ?? {}).map(([sessionID, time]) => [sessionID, { ...time }]),
+  )
+  const calls = createFetch(async (url, request) => {
     if (url.pathname === "/api/location") {
       const requested = url.searchParams.get("location[directory]") ?? directory
       locations.push(requested)
@@ -72,6 +78,13 @@ async function renderSessionTabs(
         data: { branch: { current: "main", default: "main" } },
       })
     }
+    const viewed = url.pathname.match(/^\/api\/session\/([^/]+)\/view$/)?.[1]
+    if (viewed && request.method === "POST") {
+      views.push(viewed)
+      const time = (sessionTimes[viewed] ??= {})
+      time.viewed = time.idle
+      return new Response(null, { status: 204 })
+    }
     const sessionID = url.pathname.match(/^\/api\/session\/([^/]+)$/)?.[1]
     if (!sessionID) return undefined
     sessions.push(sessionID)
@@ -79,12 +92,13 @@ async function renderSessionTabs(
     return json({
       data: {
         id: sessionID,
+        parentID: options?.sessionParents?.[sessionID],
         title: sessionID === initialSessionID ? options?.title : undefined,
         projectID: "project",
         location: { directory: options?.sessionDirectories?.[sessionID] ?? directory },
         cost: 0,
         tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
-        time: { created: 0, updated: 0 },
+        time: { created: 0, updated: 0, ...sessionTimes[sessionID] },
       },
     })
   }, events)
@@ -138,9 +152,13 @@ async function renderSessionTabs(
     route,
     data,
     sessions,
+    views,
     locations,
     vcsLocations,
     state,
+    setSessionTime(sessionID: string, time: { idle?: number; viewed?: number }) {
+      sessionTimes[sessionID] = time
+    },
     emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }),
     focus: () => app.renderer.emit("focus"),
     blur: () => app.renderer.emit("blur"),
@@ -153,14 +171,6 @@ async function renderSessionTabs(
   }
 }
 
-const executionSucceeded = (sessionID: string): OpenCodeEvent => ({
-  id: `evt_done_${sessionID}`,
-  created: Date.now(),
-  type: "session.execution.succeeded",
-  durable: { aggregateID: sessionID, seq: 1, version: 1 },
-  data: { sessionID },
-})
-
 test("loads persisted tab metadata concurrently on connect", async () => {
   let release!: () => void
   const sessionGate = new Promise<void>((resolve) => (release = resolve))
@@ -230,56 +240,94 @@ test("stores session tabs for the current working directory by default", async (
     const file = path.join(setup.state, "test", "tui", "tabs.json")
     await wait(() => Bun.file(file).size > 0)
     const stored = await Bun.file(file).json()
-    expect(stored.global).toEqual({ tabs: [], unread: {} })
+    expect(stored.global).toEqual({ tabs: [] })
     expect(Object.keys(stored.cwd)).toEqual([directory])
     expect(stored.cwd[directory].tabs.map((tab: { sessionID: string }) => tab.sessionID)).toEqual(["first"])
-    expect(stored.cwd[directory].unread).toEqual({})
+    expect(stored.cwd[directory]).not.toHaveProperty("unread")
   } finally {
     await setup.destroy()
   }
 })
 
-test("only the foreground TUI mutates unread state", async () => {
-  await using temporary = await tmpdir()
-  let foreground: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
-  let background: Awaited<ReturnType<typeof renderSessionTabs>> | undefined
+test("derives unread state from server session times", async () => {
+  const setup = await renderSessionTabs("first", {
+    home: true,
+    persisted: ["first", "second"],
+    sessionTimes: { second: { idle: 2 } },
+  })
+  try {
+    await wait(() => setup.tabs.status("second").unread === "activity")
+    expect(setup.tabs.status("first").unread).toBeUndefined()
+  } finally {
+    await setup.destroy()
+  }
+})
 
+test("refreshes server session times after terminal events", async () => {
+  const setup = await renderSessionTabs("first", { home: true, persisted: ["first"] })
   try {
-    foreground = await renderSessionTabs("first", { state: temporary.path, persisted: ["first", "second"] })
-    background = await renderSessionTabs("second", { state: temporary.path })
-    foreground.focus()
-    background.blur()
-    await wait(() => foreground?.tabs.tabs().length === 2 && background?.tabs.tabs().length === 2, 2_000, "shared tabs")
-
-    const firstDone = executionSucceeded("first")
-    foreground.emit(firstDone)
-    background.emit(firstDone)
-    await Promise.all([foreground.flush(), background.flush()])
-    expect(foreground.tabs.status("first").unread).toBeUndefined()
-    expect(background.tabs.status("first").unread).toBeUndefined()
-
-    const secondDone = executionSucceeded("second")
-    foreground.emit(secondDone)
-    background.emit(secondDone)
-    await wait(
-      () =>
-        foreground?.tabs.status("second").unread === "activity" &&
-        background?.tabs.status("second").unread === "activity",
-      10_000,
-      "shared unread activity",
-    )
+    setup.setSessionTime("first", { idle: 2 })
+    setup.emit({
+      id: "evt_done_first",
+      created: 2,
+      type: "session.execution.succeeded",
+      durable: { aggregateID: "first", seq: 1, version: 1 },
+      data: { sessionID: "first" },
+    })
+    await wait(() => setup.tabs.status("first").unread === "activity")
+  } finally {
+    await setup.destroy()
+  }
+})
 
-    foreground.tabs.select("second")
-    await wait(
-      () =>
-        foreground?.tabs.status("second").unread === undefined &&
-        background?.tabs.status("second").unread === undefined,
-      10_000,
-      "shared unread clearing",
-    )
+test("views a selected unread session only while focused", async () => {
+  const setup = await renderSessionTabs("first", {
+    home: true,
+    persisted: ["first"],
+    sessionTimes: { first: { idle: 2 } },
+  })
+  try {
+    setup.blur()
+    setup.route.navigate({ type: "session", sessionID: "first" })
+    await wait(() => setup.tabs.current() === "first" && setup.tabs.status("first").unread === "activity")
+    await Bun.sleep(20)
+    expect(setup.views).toEqual([])
+
+    setup.focus()
+    await wait(() => setup.views.includes("first"))
+    setup.emit({
+      id: "evt_viewed_first",
+      created: 3,
+      type: "session.viewed",
+      durable: { aggregateID: "first", seq: 2, version: 1 },
+      data: { sessionID: "first" },
+    })
+    await wait(() => setup.tabs.status("first").unread === undefined)
   } finally {
-    if (foreground) await foreground.destroy()
-    if (background) await background.destroy()
+    await setup.destroy()
+  }
+})
+
+test("views unread child sessions through their root tab", async () => {
+  const setup = await renderSessionTabs("root", {
+    home: true,
+    persisted: ["root"],
+    sessionParents: { child: "root" },
+    sessionTimes: { child: { idle: 2 } },
+  })
+  try {
+    setup.blur()
+    await setup.data.session.sync("child")
+    await wait(() => setup.tabs.status("root").unread === "activity")
+
+    setup.route.navigate({ type: "session", sessionID: "root" })
+    await Bun.sleep(20)
+    expect(setup.views).toEqual([])
+    setup.focus()
+    await wait(() => setup.views.includes("child"))
+    expect(setup.views).not.toContain("root")
+  } finally {
+    await setup.destroy()
   }
 })