|
|
@@ -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()
|
|
|
}
|
|
|
})
|
|
|
|