diff --git a/packages/tui/src/component/dialog-experiments.tsx b/packages/tui/src/component/dialog-experiments.tsx index 8938ced5bc9..84334603348 100644 --- a/packages/tui/src/component/dialog-experiments.tsx +++ b/packages/tui/src/component/dialog-experiments.tsx @@ -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() diff --git a/packages/tui/src/component/prompt/index.tsx b/packages/tui/src/component/prompt/index.tsx index 1838023bf04..dbb241bbd17 100644 --- a/packages/tui/src/component/prompt/index.tsx +++ b/packages/tui/src/component/prompt/index.tsx @@ -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) { diff --git a/packages/tui/src/component/session-tabs.tsx b/packages/tui/src/component/session-tabs.tsx index 9245eccd893..cbc5d4e641d 100644 --- a/packages/tui/src/component/session-tabs.tsx +++ b/packages/tui/src/component/session-tabs.tsx @@ -80,12 +80,31 @@ export type SessionTabsController = Pick 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() 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 + } > 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() // 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 + } > diff --git a/packages/tui/src/context/session-tabs.tsx b/packages/tui/src/context/session-tabs.tsx index 202ad89cc53..84a761dcace 100644 --- a/packages/tui/src/context/session-tabs.tsx +++ b/packages/tui/src/context/session-tabs.tsx @@ -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() // 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("tabs", { + const [store, updateStore] = storage.store("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>({}) 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) }) diff --git a/packages/tui/test/component/session-tabs-mouse.test.tsx b/packages/tui/test/component/session-tabs-mouse.test.tsx index b2f6ec1963b..e8206b0a270 100644 --- a/packages/tui/test/component/session-tabs-mouse.test.tsx +++ b/packages/tui/test/component/session-tabs-mouse.test.tsx @@ -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( + () => ( + + + + + + + + + + + + + + ), + { 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( + () => ( + + + + + + + + ), + { 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 = [] diff --git a/packages/tui/test/context/session-tabs.test.tsx b/packages/tui/test/context/session-tabs.test.tsx index 5d9481eedb5..280a24b9ce4 100644 --- a/packages/tui/test/context/session-tabs.test.tsx +++ b/packages/tui/test/context/session-tabs.test.tsx @@ -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 let data!: ReturnType let storage!: ReturnType + let config!: ReturnType + 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 } @@ -146,10 +154,15 @@ async function renderSessionTabs( configuration, + update: async (update) => { + configuration = structuredClone(configuration) + update(configuration) + return configuration + }, + }} > 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((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>[] = [] + + 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"))