feat(tui): add experimental session preview tabs (#45021)

This commit is contained in:
Kit Langton 2026-08-25 18:51:36 -04:00 committed by GitHub
parent 3726e3254d
commit 7f9e5e91ab
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 501 additions and 22 deletions

View file

@ -13,7 +13,13 @@ type Experiment = {
// In-flight features anyone can opt into. Each entry is temporary: an
// experiment either graduates (delete the entry, make the behavior
// unconditional) or dies (delete the entry and the branch it gated).
export const experiments: Experiment[] = []
export const experiments: Experiment[] = [
{
id: "session-preview-tabs",
title: "Session preview tabs",
description: "Preview sessions in temporary tabs before keeping them open",
},
]
export function DialogExperiments() {
const config = useConfig()

View file

@ -1380,6 +1380,8 @@ export function Prompt(props: PromptProps) {
if (pendingEditorSelection) editor.markSelectionSent()
}
sessionTabs.promote(target)
// Optimistic admission puts the message in the store synchronously, so
// the session view renders it on arrival.
if (!props.sessionID) {

View file

@ -80,12 +80,31 @@ export type SessionTabsController = Pick<ContextController, "tabs" | "current" |
newTab?: () => boolean
add?: () => void
detail?: (sessionID: string) => string | undefined
isPreview?: (sessionID: string) => boolean
promote?: (sessionID: string) => void
status(sessionID: string): SessionTabsStatus
}
const NEW_SESSION_TAB: SessionTab = { sessionID: "new", title: NEW_SESSION_TAB_TITLE }
const glowTextColor = (base: RGBA, glow: RGBA, index: number, width: number, level = 1) =>
tint(base, glow, 0.12 * unreadGlowIntensity(index, width) * level)
function createPreviewDoubleClick(tabs: SessionTabsController) {
let previous: { sessionID: string; time: number } | undefined
return (sessionID: string) => {
if (!tabs.isPreview?.(sessionID)) {
previous = undefined
return
}
const now = Date.now()
if (previous?.sessionID === sessionID && now - previous.time < 300) {
previous = undefined
tabs.promote?.(sessionID)
return
}
previous = { sessionID, time: now }
}
}
function createNumberIgnition(runs: () => boolean, prompt: () => number, animations: () => boolean) {
const ignition = createAnimatable({ level: 0 }, { enabled: animations, transition: tween({ duration: 0.7 }) })
let wasRunning = runs()
@ -253,6 +272,9 @@ function TabContextMenu(props: { state: TabContextMenuState; tabs: SessionTabsCo
...(props.tabs.add ? [{ title: "New tab", run: () => props.tabs.add?.() }] : []),
...(sessionID
? [
...(props.tabs.promote && props.tabs.isPreview?.(sessionID)
? [{ title: "Keep open", run: () => props.tabs.promote?.(sessionID) }]
: []),
{
title: "Rename",
run: () => DialogSessionRename.show(dialog, sessionID, props.state.title),
@ -366,6 +388,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
const [addHovered, setAddHovered] = createSignal(false)
const marquee = createTabMarquee(animations)
const hovered = marquee.hovered
const handleClick = createPreviewDoubleClick(tabs)
// OpenTUI captures the first drag target, which may differ from the tab pressed on a fast move.
const [dragging, setDragging] = createSignal<string>()
const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>()
@ -651,6 +674,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
return
}
didDrag = false
handleClick(tab.sessionID)
marquee.enter(tab.sessionID, title(), hoveredTitleWidth())
setDragging(tab.sessionID)
}}
@ -738,7 +762,10 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
fg={foreground()}
wrapMode="none"
selectable={false}
attributes={selected() ? TextAttributes.BOLD : undefined}
attributes={
(selected() ? TextAttributes.BOLD : 0) |
(tabs.isPreview?.(tab.sessionID) ? TextAttributes.ITALIC : 0) || undefined
}
>
<Show
when={scrolling() || titleGlow.value().level > 0 || titleFades()}
@ -900,6 +927,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
const [addHovered, setAddHovered] = createSignal(false)
const marquee = createTabMarquee(animations)
const hovered = marquee.hovered
const handleClick = createPreviewDoubleClick(tabs)
// OpenTUI captures the first drag target, which may differ from the tab pressed on a fast move.
const [dragging, setDragging] = createSignal<string>()
// A drag reorders a local preview and persists one move on release instead of writing
@ -1274,6 +1302,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
}
didDrag = false
releaseCloseHold()
handleClick(tab.sessionID)
marquee.enter(tab.sessionID, title(), hoveredTitleWidth())
setDragging(tab.sessionID)
}}
@ -1300,7 +1329,9 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
fg={foreground()}
wrapMode="none"
selectable={false}
attributes={bold()}
attributes={
(bold() ?? 0) | (tabs.isPreview?.(tab.sessionID) ? TextAttributes.ITALIC : 0) || undefined
}
>
<Show when={scrolling() || glows() || titleFades()} fallback={visibleTitle()}>
<Index each={visibleTitleParts()}>

View file

@ -61,22 +61,28 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
const location = useLocation()
const paths = useTuiPaths()
const renderer = useRenderer()
const storage = useStorage()
const enabled = () => config.tabs.enabled
const previews = () => config.experimental?.["session-preview-tabs"] === true
const [focused, setFocused] = createSignal<boolean>()
// 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.
const [store, updateStore] = useStorage().store<PersistedState>("tabs", {
const [store, updateStore] = storage.store<PersistedState>("tabs", {
initial: {
global: empty(),
cwd: {},
},
key: "sessionID",
})
const [preview, updatePreview] = storage.memory<{ global?: string; cwd?: string }>("session-tab-preview", {
initial: {},
})
const fallback = empty()
const [promptPulses, setPromptPulses] = createSignal<Record<string, number>>({})
let history: SessionTabHistory = { entries: [], index: -1 }
// User-closed tabs eligible for reopening; in-memory like history, deleted sessions pruned.
let closedTabs: ClosedSessionTab[] = []
let promotedSession: string | undefined
// Storage mutations apply against the on-disk draft under a file lock, so
// a registration queued by the route effect can land AFTER a removal that
// ran while the write was still in flight — resurrecting a tab that was
@ -101,6 +107,18 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
return store.global
}
const previewID = () => preview[config.tabs.scope]
const setPreview = (sessionID: string | undefined) => {
const scope = config.tabs.scope
updatePreview((draft) => {
if (sessionID === undefined) {
delete draft[scope]
return
}
draft[scope] = sessionID
})
}
function update(mutation: (draft: TabsState) => void) {
const scope = config.tabs.scope
void updateStore((draft) => mutation(scope === "cwd" ? (draft.cwd[paths.cwd] ??= empty()) : draft.global)).catch(
@ -156,6 +174,16 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
}
}
createEffect(() => {
if (enabled() && previews()) return
promotedSession = undefined
if (!preview.global && !preview.cwd) return
updatePreview((draft) => {
delete draft.global
delete draft.cwd
})
})
// Shared storage updates must not re-admit a tab unless this client changes route or scope.
createEffect(
on(
@ -166,16 +194,30 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
([routed]) => {
if (!routed || routed === "dummy") return
const sessionID = root(routed)
const permanent = promotedSession === sessionID
promotedSession = undefined
cancelledTabs.delete(sessionID)
history = recordSessionTabHistory(history, sessionID)
if (state().tabs.some((tab) => tab.sessionID === sessionID)) return
const fallback = newTab() ? NEW_SESSION_TAB_TITLE : undefined
const temporary = previews() && !permanent
const replaced = temporary ? previewID() : undefined
if (replaced) scrollAnchors.delete(replaced)
if (temporary) setPreview(sessionID)
update((draft) => {
if (cancelledTabs.has(sessionID)) return
draft.tabs = openSessionTab(draft.tabs, {
const tab = {
sessionID,
title: title(sessionID, draft.tabs.find((tab) => tab.sessionID === sessionID)?.title, fallback),
})
}
if (replaced && !draft.tabs.some((item) => item.sessionID === sessionID)) {
const index = draft.tabs.findIndex((item) => item.sessionID === replaced)
if (index !== -1) {
draft.tabs[index] = tab
return
}
}
draft.tabs = openSessionTab(draft.tabs, tab)
})
},
),
@ -305,6 +347,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
const target = root(sessionID)
cancelledTabs.add(target)
scrollAnchors.delete(target)
if (previewID() === target) setPreview(undefined)
const closed = closeSessionTab(state().tabs, target)
const selected = navigate && current() === target
if (closed.tabs === state().tabs && !selected) return
@ -330,6 +373,9 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
tabs() {
return state().tabs
},
isPreview(sessionID: string) {
return enabled() && previews() && previewID() === root(sessionID)
},
newTab() {
return newTab()
},
@ -354,6 +400,15 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
if (!enabled()) return
route.navigate({ type: "session", sessionID: root(sessionID) })
},
promote(sessionID: string) {
if (!enabled() || !previews()) return
const session = root(sessionID)
if (previewID() === session) {
setPreview(undefined)
return
}
if (!state().tabs.some((tab) => tab.sessionID === session)) promotedSession = session
},
add() {
if (!enabled()) return
const sessionID = current()
@ -390,6 +445,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
const tabs = result.tabs
if (!tabs || !result.sessionID) return
cancelledTabs.delete(result.sessionID)
if (previews()) promotedSession = result.sessionID
update((draft) => {
draft.tabs = tabs
})
@ -399,6 +455,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp
if (!enabled()) return
const session = root(sessionID)
if (moveSessionTab(state().tabs, session, index) === state().tabs) return
if (previewID() === session) setPreview(undefined)
update((draft) => {
draft.tabs = moveSessionTab(draft.tabs, session, index)
})

View file

@ -5,7 +5,10 @@ import { expect, test } from "bun:test"
import { createSignal } from "solid-js"
import { ConfigProvider } from "../../src/config"
import { EMPTY_SESSION_TAB_STATUS, SessionTabs, type SessionTabsController } from "../../src/component/session-tabs"
import { Keymap } from "../../src/context/keymap"
import { ThemeProvider } from "../../src/context/theme"
import { DialogProvider } from "../../src/ui/dialog"
import { ToastProvider } from "../../src/ui/toast"
import { emptyThemeSource } from "../fixture/fixture"
import { TestTuiContexts } from "../fixture/tui-environment"
import { createTuiResolvedConfig } from "../fixture/tui-runtime"
@ -62,6 +65,110 @@ test("releasing a transcript selection over tab controls does not activate them"
}
})
test("the tab context menu keeps preview tabs open without offering promotion for permanent tabs", async () => {
const [active, setActive] = createSignal("first")
const promoted: string[] = []
const controller = {
tabs: () => [
{ sessionID: "first", title: "First" },
{ sessionID: "second", title: "Second" },
],
current: active,
select: setActive,
close() {},
move() {},
isPreview: (sessionID: string) => sessionID === "second",
promote: (sessionID: string) => promoted.push(sessionID),
status: () => EMPTY_SESSION_TAB_STATUS,
} satisfies SessionTabsController
const app = await testRender(
() => (
<TestTuiContexts>
<ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
<Keymap.Provider>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<ToastProvider>
<DialogProvider>
<SessionTabs controller={controller} animations={false} />
</DialogProvider>
</ToastProvider>
</ThemeProvider>
</Keymap.Provider>
</ConfigProvider>
</TestTuiContexts>
),
{ width: 60, height: 8 },
)
try {
app.renderer.start()
await app.waitForFrame((frame) => frame.includes("Second"))
await app.mockMouse.click(5, 0, MouseButton.RIGHT)
await app.waitForFrame((frame) => frame.includes("Rename"))
expect(app.captureCharFrame()).not.toContain("Keep open")
await app.mockMouse.click(5, 0)
await app.mockMouse.click(40, 0, MouseButton.RIGHT)
await app.waitForFrame((frame) => frame.includes("Keep open"))
const frame = app.captureCharFrame().split("\n")
const row = frame.findIndex((line) => line.includes("Keep open"))
await app.mockMouse.click(frame[row]!.indexOf("Keep open"), row)
expect(promoted).toEqual(["second"])
expect(active()).toBe("first")
} finally {
app.renderer.destroy()
}
})
test("double-clicking a preview tab keeps it open without promoting permanent tabs", async () => {
const [active, setActive] = createSignal("first")
const promoted: string[] = []
const controller = {
tabs: () => [
{ sessionID: "first", title: "First" },
{ sessionID: "second", title: "Second" },
],
current: active,
select: setActive,
close() {},
move() {},
isPreview: (sessionID: string) => sessionID === "second",
promote: (sessionID: string) => promoted.push(sessionID),
status: () => EMPTY_SESSION_TAB_STATUS,
} satisfies SessionTabsController
const app = await testRender(
() => (
<TestTuiContexts>
<ConfigProvider config={createTuiResolvedConfig({ tabs: { enabled: true } })}>
<ThemeProvider mode="dark" source={emptyThemeSource}>
<SessionTabs controller={controller} animations={false} />
</ThemeProvider>
</ConfigProvider>
</TestTuiContexts>
),
{ width: 60, height: 8 },
)
try {
app.renderer.start()
await app.waitForFrame((frame) => frame.includes("Second"))
await app.mockMouse.doubleClick(5, 0)
expect(promoted).toEqual([])
await app.mockMouse.click(40, 0)
expect(active()).toBe("second")
expect(promoted).toEqual([])
await app.mockMouse.click(40, 0)
expect(promoted).toEqual(["second"])
} finally {
app.renderer.destroy()
}
})
test("middle-click closes a session tab without selecting it", async () => {
const [active, setActive] = createSignal("first")
const closed: Array<string | undefined> = []

View file

@ -4,7 +4,7 @@ import type { OpenCodeEvent } from "@opencode-ai/client"
import { testRender } from "@opentui/solid"
import { mkdirSync, watch } from "fs"
import path from "path"
import { ConfigProvider } from "../../src/config"
import { ConfigProvider, useConfig } from "../../src/config"
import { ClientProvider, useClient } from "../../src/context/client"
import { DataProvider, useData } from "../../src/context/data"
import { LocationProvider } from "../../src/context/location"
@ -41,6 +41,7 @@ async function renderSessionTabs(
newLocation?: "launch" | "inherit"
tabsEnabled?: boolean
viewFailures?: number
preview?: boolean
},
) {
const temporary = options?.state ? undefined : await tmpdir()
@ -131,6 +132,12 @@ async function renderSessionTabs(
let client!: ReturnType<typeof useClient>
let data!: ReturnType<typeof useData>
let storage!: ReturnType<typeof useStorage>
let config!: ReturnType<typeof useConfig>
let configuration = {
tabs: { enabled: options?.tabsEnabled ?? true },
experimental: options?.preview ? { "session-preview-tabs": true } : undefined,
session: { new_location: options?.newLocation ?? "launch" },
}
function Probe() {
tabs = useSessionTabs()
@ -138,6 +145,7 @@ async function renderSessionTabs(
client = useClient()
data = useData()
storage = useStorage()
config = useConfig()
return <box />
}
@ -146,10 +154,15 @@ async function renderSessionTabs(
<TuiAppProvider value={{ name: "test", version: "test", channel: "test" }}>
<StorageProvider>
<ConfigProvider
config={createTuiResolvedConfig({
tabs: { enabled: options?.tabsEnabled ?? true },
session: { new_location: options?.newLocation ?? "launch" },
})}
config={createTuiResolvedConfig(configuration)}
service={{
get: async () => configuration,
update: async (update) => {
configuration = structuredClone(configuration)
update(configuration)
return configuration
},
}}
>
<RouteProvider
initialRoute={options?.home ? { type: "home" } : { type: "session", sessionID: initialSessionID }}
@ -188,6 +201,11 @@ async function renderSessionTabs(
focus: () => app.renderer.emit("focus"),
blur: () => app.renderer.emit("blur"),
flush: () => storage.flush(),
setPreviews: (enabled: boolean) =>
config.update((draft) => {
draft.experimental ??= {}
draft.experimental["session-preview-tabs"] = enabled
}),
async destroy() {
app.renderer.destroy()
await storage.flush()
@ -196,6 +214,20 @@ async function renderSessionTabs(
}
}
function admitted(sessionID: string, inboxID: string): OpenCodeEvent {
return {
id: `evt_${inboxID}`,
created: Date.now(),
type: "session.inbox.enqueued",
durable: { aggregateID: sessionID, seq: Number(inboxID.replace(/\D/g, "")), version: 1 },
data: {
sessionID,
inboxID,
item: { type: "user", payload: { text: inboxID }, delivery: "steer" },
},
}
}
test("loads persisted tab metadata concurrently on connect", async () => {
let release!: () => void
const sessionGate = new Promise<void>((resolve) => (release = resolve))
@ -258,6 +290,261 @@ test("loads location metadata when an open session moves", async () => {
}
})
test("replaces session previews without replacing permanent tabs or opening existing tabs again", async () => {
const setup = await renderSessionTabs("first", { persisted: ["first", "permanent"], preview: true })
try {
await wait(() => setup.tabs.tabs().length === 2)
setup.route.navigate({ type: "session", sessionID: "preview-one" })
await wait(
() => setup.tabs.tabs().some((tab) => tab.sessionID === "preview-one") && setup.tabs.isPreview("preview-one"),
)
setup.tabs.select("permanent")
await wait(() => setup.tabs.current() === "permanent")
expect(setup.tabs.tabs().map((tab) => tab.sessionID)).toEqual(["first", "permanent", "preview-one"])
setup.route.navigate({ type: "session", sessionID: "preview-two" })
await wait(
() => setup.tabs.tabs().some((tab) => tab.sessionID === "preview-two") && setup.tabs.isPreview("preview-two"),
)
expect(setup.tabs.tabs().map((tab) => tab.sessionID)).toEqual(["first", "permanent", "preview-two"])
setup.tabs.promote("preview-two")
expect(setup.tabs.isPreview("preview-two")).toBe(false)
setup.route.navigate({ type: "session", sessionID: "preview-three" })
await wait(
() => setup.tabs.tabs().some((tab) => tab.sessionID === "preview-three") && setup.tabs.isPreview("preview-three"),
)
expect(setup.tabs.tabs().map((tab) => tab.sessionID)).toEqual([
"first",
"permanent",
"preview-two",
"preview-three",
])
} finally {
await setup.destroy()
}
})
test("server-wide prompt admissions do not promote a local session preview", async () => {
const setup = await renderSessionTabs("preview", { preview: true })
try {
await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "preview") && setup.tabs.isPreview("preview"))
setup.emit({
id: "evt_synthetic",
created: Date.now(),
type: "session.inbox.enqueued",
durable: { aggregateID: "preview", seq: 1, version: 1 },
data: {
sessionID: "preview",
inboxID: "msg_synthetic",
item: { type: "synthetic", payload: { text: "editor context" }, delivery: "steer" },
},
})
await Bun.sleep(20)
expect(setup.tabs.isPreview("preview")).toBe(true)
setup.emit(admitted("preview", "msg_2"))
await wait(() => setup.data.session.pending.list("preview").length === 2)
expect(setup.tabs.isPreview("preview")).toBe(true)
setup.tabs.promote("preview")
expect(setup.tabs.isPreview("preview")).toBe(false)
} finally {
await setup.destroy()
}
})
test("promotes a local preview before its tab has finished persisting", async () => {
const setup = await renderSessionTabs("permanent", { persisted: ["permanent"], preview: true })
try {
await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "permanent"))
setup.route.navigate({ type: "session", sessionID: "preview" })
expect(setup.tabs.isPreview("preview")).toBe(true)
expect(setup.tabs.tabs().some((tab) => tab.sessionID === "preview")).toBe(false)
setup.tabs.promote("preview")
expect(setup.tabs.isPreview("preview")).toBe(false)
await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "preview"))
setup.route.navigate({ type: "session", sessionID: "next" })
await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "next"))
expect(setup.tabs.tabs().map((tab) => tab.sessionID)).toEqual(["permanent", "preview", "next"])
} finally {
await setup.destroy()
}
})
test("reopens a previously permanent session as a preview after a user prompt", async () => {
const setup = await renderSessionTabs("permanent", { persisted: ["permanent"], preview: true })
try {
await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "permanent"))
setup.emit(admitted("permanent", "msg_1"))
await wait(() => setup.data.session.pending.list("permanent").length > 0)
setup.tabs.close("permanent")
await wait(() => setup.tabs.tabs().length === 0)
setup.route.navigate({ type: "session", sessionID: "permanent" })
await wait(
() => setup.tabs.tabs().some((tab) => tab.sessionID === "permanent") && setup.tabs.isPreview("permanent"),
)
} finally {
await setup.destroy()
}
})
test("keeps an explicitly promoted home session permanent when admission arrives before navigation", async () => {
const setup = await renderSessionTabs("created", { home: true, preview: true })
try {
setup.tabs.promote("created")
setup.emit(admitted("created", "msg_1"))
await wait(() => setup.data.session.pending.list("created").length > 0)
setup.route.navigate({ type: "session", sessionID: "created" })
await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "created"))
expect(setup.tabs.isPreview("created")).toBe(false)
} finally {
await setup.destroy()
}
})
test("stores preview tab membership without persisting preview identity", async () => {
const setup = await renderSessionTabs("preview", { preview: true })
try {
await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "preview") && setup.tabs.isPreview("preview"))
await setup.flush()
const stored = await Bun.file(path.join(setup.state, "test", "tui", "tabs.json")).json()
expect(stored.cwd[directory].tabs).toHaveLength(1)
expect(stored.cwd[directory].tabs[0].sessionID).toBe("preview")
expect(stored.cwd[directory].tabs[0]).not.toHaveProperty("preview")
expect(await Bun.file(path.join(setup.state, "test", "tui", "session-tab-preview.json")).exists()).toBe(false)
} finally {
await setup.destroy()
}
})
test("unrelated user admissions do not pre-promote an unopened local session", async () => {
const setup = await renderSessionTabs("remote", { home: true, preview: true })
try {
setup.emit(admitted("remote", "msg_1"))
await wait(() => setup.data.session.pending.list("remote").length > 0)
setup.route.navigate({ type: "session", sessionID: "remote" })
await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "remote"))
expect(setup.tabs.isPreview("remote")).toBe(true)
} finally {
await setup.destroy()
}
})
test("each client replaces only its own preview in shared tab storage", async () => {
await using temporary = await tmpdir()
const clients: Awaited<ReturnType<typeof renderSessionTabs>>[] = []
try {
const first = await renderSessionTabs("first", { state: temporary.path, preview: true })
clients.push(first)
await wait(() => first.tabs.tabs().some((tab) => tab.sessionID === "first"))
const second = await renderSessionTabs("first", { state: temporary.path, preview: true })
clients.push(second)
expect(second.tabs.isPreview("first")).toBe(false)
second.route.navigate({ type: "session", sessionID: "second" })
await wait(() => first.tabs.tabs().some((tab) => tab.sessionID === "second"))
expect(first.tabs.isPreview("first")).toBe(true)
expect(second.tabs.isPreview("second")).toBe(true)
first.route.navigate({ type: "session", sessionID: "third" })
await wait(() => second.tabs.tabs().some((tab) => tab.sessionID === "third"))
expect(second.tabs.tabs().map((tab) => tab.sessionID)).toEqual(["third", "second"])
expect(first.tabs.isPreview("third")).toBe(true)
expect(second.tabs.isPreview("second")).toBe(true)
} finally {
await Promise.allSettled(clients.map((client) => client.destroy()))
}
})
test("reopening a closed preview makes it permanent without replacing the current preview", async () => {
const setup = await renderSessionTabs("permanent", { persisted: ["permanent"], preview: true })
try {
await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "permanent"))
setup.route.navigate({ type: "session", sessionID: "closed-preview" })
await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "closed-preview"))
expect(setup.tabs.isPreview("closed-preview")).toBe(true)
setup.tabs.close("closed-preview")
await wait(() => setup.tabs.current() === "permanent" && setup.tabs.tabs().length === 1)
setup.route.navigate({ type: "session", sessionID: "current-preview" })
await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "current-preview"))
setup.tabs.reopen()
await wait(() => setup.tabs.current() === "closed-preview" && setup.tabs.tabs().length === 3)
await setup.flush()
expect(setup.tabs.tabs().map((tab) => tab.sessionID)).toEqual(["permanent", "closed-preview", "current-preview"])
expect(setup.tabs.isPreview("closed-preview")).toBe(false)
expect(setup.tabs.isPreview("current-preview")).toBe(true)
} finally {
await setup.destroy()
}
})
test("moving a preview promotes it before opening another preview", async () => {
const setup = await renderSessionTabs("first", { persisted: ["first", "last"], preview: true })
try {
await wait(() => setup.tabs.tabs().length === 2)
setup.route.navigate({ type: "session", sessionID: "moved-preview" })
await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "moved-preview"))
setup.tabs.move("moved-preview", 0)
expect(setup.tabs.isPreview("moved-preview")).toBe(false)
await wait(() => setup.tabs.tabs()[0]?.sessionID === "moved-preview")
setup.route.navigate({ type: "session", sessionID: "next-preview" })
await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "next-preview"))
expect(setup.tabs.tabs().map((tab) => tab.sessionID)).toEqual(["moved-preview", "first", "last", "next-preview"])
expect(setup.tabs.isPreview("next-preview")).toBe(true)
} finally {
await setup.destroy()
}
})
test("disabling previews clears local identity and prevents stale replacement after re-enabling", async () => {
const setup = await renderSessionTabs("first", { preview: true })
try {
await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "first") && setup.tabs.isPreview("first"))
await setup.setPreviews(false)
expect(setup.tabs.isPreview("first")).toBe(false)
await setup.setPreviews(true)
expect(setup.tabs.isPreview("first")).toBe(false)
setup.route.navigate({ type: "session", sessionID: "next" })
await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "next"))
expect(setup.tabs.tabs().map((tab) => tab.sessionID)).toEqual(["first", "next"])
expect(setup.tabs.isPreview("next")).toBe(true)
} finally {
await setup.destroy()
}
})
test("stores session tabs for the current working directory by default", async () => {
const setup = await renderSessionTabs("first")
@ -539,17 +826,6 @@ test("closing a tab is not undone by another TUI viewing the same session", asyn
test("user prompt admissions pulse an already-busy background tab", async () => {
const setup = await renderSessionTabs("background")
const admitted = (sessionID: string, inboxID: string): OpenCodeEvent => ({
id: `evt_${inboxID}`,
created: Date.now(),
type: "session.inbox.enqueued",
durable: { aggregateID: sessionID, seq: Number(inboxID.replace(/\D/g, "")), version: 1 },
data: {
sessionID,
inboxID,
item: { type: "user", payload: { text: inboxID }, delivery: "steer" },
},
})
try {
await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "background"))