2026-08-13 13:46:53 +09:00
|
|
|
|
import { afterEach, describe, expect, test } from "bun:test"
|
|
|
|
|
|
import { createTestRenderer, type TestRendererSetup } from "@opentui/core/testing"
|
|
|
|
|
|
|
2026-08-15 01:34:15 +08:00
|
|
|
|
import { SessionMenu, sessionLabel } from "./session-menu"
|
2026-08-13 13:46:53 +09:00
|
|
|
|
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,
|
2026-08-16 12:17:46 +08:00
|
|
|
|
modelPreset: "Codex",
|
2026-08-13 15:22:06 +09:00
|
|
|
|
pinned: true,
|
|
|
|
|
|
archived: false,
|
2026-08-13 13:46:53 +09:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
chatId: "two",
|
|
|
|
|
|
title: "Release checklist",
|
|
|
|
|
|
preview: "Prepare the stable release",
|
|
|
|
|
|
createdAt: "2026-08-11T10:00:00Z",
|
|
|
|
|
|
updatedAt: "2026-08-12T10:00:00Z",
|
|
|
|
|
|
runStartedAt: null,
|
2026-08-16 12:17:46 +08:00
|
|
|
|
modelPreset: null,
|
2026-08-22 19:33:22 +08:00
|
|
|
|
recoveryState: {
|
|
|
|
|
|
status: "awaiting_user",
|
|
|
|
|
|
recovery_id: "recovery-two",
|
|
|
|
|
|
reason: "tool execution interrupted",
|
|
|
|
|
|
},
|
2026-08-13 15:22:06 +09:00
|
|
|
|
pinned: false,
|
|
|
|
|
|
archived: false,
|
2026-08-13 13:46:53 +09:00
|
|
|
|
},
|
|
|
|
|
|
]
|
|
|
|
|
|
|
|
|
|
|
|
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",
|
2026-08-23 23:20:17 +08:00
|
|
|
|
accent: "#FF8A33",
|
|
|
|
|
|
warning: "#F5C451",
|
2026-08-13 13:46:53 +09:00
|
|
|
|
})
|
|
|
|
|
|
setup.renderer.root.add(menu.root)
|
2026-08-23 23:45:48 +08:00
|
|
|
|
menu.open(sessions, "one", 6, "Codex")
|
2026-08-13 13:46:53 +09:00
|
|
|
|
await setup.renderOnce()
|
|
|
|
|
|
|
|
|
|
|
|
expect(setup.captureCharFrame()).toContain("› ● API migration")
|
2026-08-23 23:45:48 +08:00
|
|
|
|
expect(setup.captureCharFrame()).not.toContain("Move authentication")
|
|
|
|
|
|
expect(setup.captureCharFrame()).not.toContain("Codex")
|
2026-08-13 13:46:53 +09:00
|
|
|
|
expect(menu.choose()?.chatId).toBe("one")
|
|
|
|
|
|
|
|
|
|
|
|
menu.update("release stable", 6)
|
|
|
|
|
|
await setup.renderOnce()
|
2026-08-23 23:20:17 +08:00
|
|
|
|
expect(setup.captureCharFrame()).toContain("⚠ Release checklist")
|
2026-08-13 13:46:53 +09:00
|
|
|
|
expect(menu.choose()?.chatId).toBe("two")
|
|
|
|
|
|
})
|
2026-08-15 01:34:15 +08:00
|
|
|
|
|
|
|
|
|
|
test("keeps generated multi-line titles on one terminal row", () => {
|
|
|
|
|
|
expect(sessionLabel({ ...sessions[0]!, title: "Release\n checklist" })).toBe(
|
|
|
|
|
|
"Release checklist",
|
|
|
|
|
|
)
|
|
|
|
|
|
})
|
2026-08-17 20:26:00 +08:00
|
|
|
|
|
2026-08-23 23:20:17 +08:00
|
|
|
|
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()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-08-23 23:28:01 +08:00
|
|
|
|
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()
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-08-21 18:02:50 +08:00
|
|
|
|
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",
|
2026-08-23 23:20:17 +08:00
|
|
|
|
accent: "#FF8A33",
|
|
|
|
|
|
warning: "#F5C451",
|
2026-08-21 18:02:50 +08:00
|
|
|
|
})
|
|
|
|
|
|
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")
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-08-17 20:26:00 +08:00
|
|
|
|
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",
|
2026-08-23 23:20:17 +08:00
|
|
|
|
accent: "#FF8A33",
|
|
|
|
|
|
warning: "#F5C451",
|
2026-08-17 20:26:00 +08:00
|
|
|
|
})
|
|
|
|
|
|
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")
|
|
|
|
|
|
})
|
2026-08-23 23:45:48 +08:00
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
|
})
|
2026-08-13 13:46:53 +09:00
|
|
|
|
})
|