Files
nanobot/tui/src/session-menu.test.ts
T

264 lines
8.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { afterEach, describe, expect, test } from "bun:test"
import { createTestRenderer, type TestRendererSetup } from "@opentui/core/testing"
import { SessionMenu, sessionLabel } from "./session-menu"
import type { SessionSummary } from "./protocol"
const sessions: SessionSummary[] = [
{
chatId: "one",
title: "API migration",
preview: "Move authentication to the new client",
createdAt: "2026-08-12T10:00:00Z",
updatedAt: "2026-08-13T10:00:00Z",
runStartedAt: null,
modelPreset: "Codex",
pinned: true,
archived: false,
},
{
chatId: "two",
title: "Release checklist",
preview: "Prepare the stable release",
createdAt: "2026-08-11T10:00:00Z",
updatedAt: "2026-08-12T10:00:00Z",
runStartedAt: null,
modelPreset: null,
recoveryState: {
status: "awaiting_user",
recovery_id: "recovery-two",
reason: "tool execution interrupted",
},
pinned: false,
archived: false,
},
]
describe("SessionMenu", () => {
let setup: TestRendererSetup | undefined
afterEach(() => {
if (setup && !setup.renderer.isDestroyed) setup.renderer.destroy()
setup = undefined
})
test("marks, filters, and chooses gateway sessions", async () => {
setup = await createTestRenderer({ width: 80, height: 18, screenMode: "alternate-screen" })
const menu = new SessionMenu(setup.renderer, {
text: "#FFFFFF",
muted: "#999999",
border: "#555555",
accent: "#FF8A33",
warning: "#F5C451",
})
setup.renderer.root.add(menu.root)
menu.open(sessions, "one", 6, "Codex")
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain(" ● API migration")
expect(setup.captureCharFrame()).not.toContain("Move authentication")
expect(setup.captureCharFrame()).not.toContain("Codex")
expect(menu.choose()?.chatId).toBe("one")
menu.update("release stable", 6)
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain("⚠ Release checklist")
expect(menu.choose()?.chatId).toBe("two")
})
test("keeps generated multi-line titles on one terminal row", () => {
expect(sessionLabel({ ...sessions[0]!, title: "Release\n checklist" })).toBe(
"Release checklist",
)
})
test("animates running sessions and marks completed background sessions unread", async () => {
setup = await createTestRenderer({ width: 80, height: 18, screenMode: "alternate-screen" })
const menu = new SessionMenu(setup.renderer, {
text: "#FFFFFF",
muted: "#999999",
border: "#555555",
accent: "#FF8A33",
warning: "#F5C451",
})
setup.renderer.root.add(menu.root)
const running = {
...sessions[0]!,
chatId: "running",
title: "Background task",
runStartedAt: Date.now(),
pinned: false,
}
menu.open([sessions[0]!, running], "one", 6)
await setup.renderOnce()
expect(setup.captureCharFrame()).toMatch(/[⠋⠙⠹⠸⠼⠴⠦⠧⠇⠏] Background task/u)
menu.replace([{ ...sessions[0]! }, { ...running, runStartedAt: null }], "one")
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain("• Background task")
menu.markRead("running")
menu.replace([{ ...sessions[0]! }, { ...running, runStartedAt: null }], "running")
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain("● Background task")
menu.hide()
})
test("prioritizes actionable sessions without moving keyboard selection on refresh", async () => {
setup = await createTestRenderer({ width: 80, height: 18, screenMode: "alternate-screen" })
const menu = new SessionMenu(setup.renderer, {
text: "#FFFFFF",
muted: "#999999",
border: "#555555",
accent: "#FF8A33",
warning: "#F5C451",
})
setup.renderer.root.add(menu.root)
const running = {
...sessions[0]!,
chatId: "running",
title: "Background task",
runStartedAt: Date.now(),
pinned: false,
}
const idle = { ...sessions[1]!, recoveryState: null }
menu.open([sessions[0]!, running, idle], "one", 6)
expect(menu.choose()?.chatId).toBe("one")
expect(menu.move(1)).toBe(true)
expect(menu.choose()?.chatId).toBe("running")
menu.replace([sessions[0]!, running, sessions[1]!], "one")
await setup.renderOnce()
expect(menu.choose()?.chatId).toBe("running")
const frame = setup.captureCharFrame()
expect(frame.indexOf("Release checklist")).toBeLessThan(frame.indexOf("Background task"))
menu.hide()
})
test("keeps keyboard selection when the pointer stays over the previous row", async () => {
setup = await createTestRenderer({ width: 80, height: 18, screenMode: "alternate-screen" })
const menu = new SessionMenu(setup.renderer, {
text: "#FFFFFF",
muted: "#999999",
border: "#555555",
accent: "#FF8A33",
warning: "#F5C451",
})
setup.renderer.root.add(menu.root)
menu.open(sessions, "one", 6)
await setup.renderOnce()
const firstRow = menu.root.getChildren()[0]
const secondRow = menu.root.getChildren()[1]
if (!firstRow || !secondRow) throw new Error("session rows were not rendered")
const firstPosition = { x: firstRow.x + 2, y: firstRow.y }
await setup.mockMouse.moveTo(secondRow.x + 2, secondRow.y)
await setup.flush()
expect(menu.choose()?.chatId).toBe("two")
await setup.mockMouse.moveTo(firstPosition.x, firstPosition.y)
await setup.flush()
expect(menu.choose()?.chatId).toBe("one")
expect(menu.move(1)).toBe(true)
await setup.flush()
expect(menu.choose()?.chatId).toBe("two")
})
test("shows compact workspace names only when they distinguish sessions", async () => {
setup = await createTestRenderer({ width: 100, height: 18, screenMode: "alternate-screen" })
const menu = new SessionMenu(setup.renderer, {
text: "#FFFFFF",
muted: "#999999",
border: "#555555",
accent: "#FF8A33",
warning: "#F5C451",
})
setup.renderer.root.add(menu.root)
const scoped = sessions.map((session) => ({
...session,
workspaceScope: {
project_path: "/work/nanobot",
project_name: "nanobot",
access_mode: "restricted" as const,
},
}))
menu.open(scoped, "one", 6)
await setup.renderOnce()
expect(setup.captureCharFrame()).not.toContain("nanobot · Codex")
menu.open([
scoped[0]!,
{
...scoped[1]!,
workspaceScope: {
project_path: "C:\\work\\desktop",
project_name: "desktop",
access_mode: "restricted",
},
},
], "one", 6)
await setup.renderOnce()
const frame = setup.captureCharFrame()
expect(frame).toContain("nanobot · Codex")
expect(frame).toContain("desktop")
menu.update("desktop", 6)
expect(menu.choose()?.chatId).toBe("two")
menu.open([
{
...scoped[0]!,
workspaceScope: {
project_path: "/work/frontend/nanobot",
project_name: "nanobot",
access_mode: "restricted",
},
},
{
...scoped[1]!,
workspaceScope: {
project_path: "/work/backend/nanobot",
project_name: "nanobot",
access_mode: "restricted",
},
},
], "one", 6)
await setup.renderOnce()
const duplicates = setup.captureCharFrame()
expect(duplicates).toContain("frontend/nanobot")
expect(duplicates).toContain("backend/nanobot")
})
test("shows only model overrides and keeps previews searchable", async () => {
setup = await createTestRenderer({ width: 80, height: 18, screenMode: "alternate-screen" })
const menu = new SessionMenu(setup.renderer, {
text: "#FFFFFF",
muted: "#999999",
border: "#555555",
accent: "#FF8A33",
warning: "#F5C451",
})
setup.renderer.root.add(menu.root)
menu.open(sessions, "one", 6, "Codex")
await setup.renderOnce()
const frame = setup.captureCharFrame()
expect(frame).not.toContain("Codex")
expect(frame).not.toContain("Prepare the stable release")
menu.update("prepare stable", 6)
await setup.renderOnce()
expect(menu.choose()?.chatId).toBe("two")
menu.replace([{ ...sessions[1]!, modelPreset: "Deep Research" }], "one", "Codex")
await setup.renderOnce()
expect(setup.captureCharFrame()).toContain("Deep Research")
menu.hide()
})
})