From 30f998c5b9cc9072a93adb778c97cfae564b651c Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Tue, 1 Sep 2026 11:02:11 -0400 Subject: [PATCH] feat(tui): make session preview tabs the default (#46497) --- packages/tui/AGENTS.md | 6 +++ .../tui/src/component/dialog-experiments.tsx | 8 +-- packages/tui/src/context/session-tabs.tsx | 36 ++++--------- .../tui/test/context/session-tabs.test.tsx | 50 ++++++++++--------- packages/tui/test/plugin-hot-reload.test.tsx | 8 +-- 5 files changed, 49 insertions(+), 59 deletions(-) create mode 100644 packages/tui/AGENTS.md diff --git a/packages/tui/AGENTS.md b/packages/tui/AGENTS.md new file mode 100644 index 00000000000..6a0beac90af --- /dev/null +++ b/packages/tui/AGENTS.md @@ -0,0 +1,6 @@ +# TUI Experiments + +- Temporary, opt-in UI experiments belong in the `experiments` registry in `src/component/dialog-experiments.tsx`, with behavior gated by the matching `config.experimental` key. +- The Experiments framework is permanent infrastructure. Keep the registry, dialog, settings persistence, toggle handling, and UI entry point even when there are no active experiments. +- Graduating an experiment means removing only its registry entry and feature-specific gate, making that feature's behavior unconditional. Abandoning an experiment means removing its entry, gate, and experimental behavior. +- Do not remove or simplify away the reusable Experiments infrastructure because the registry is empty. An empty registry is intentional and ready for future experiments, not dead code. diff --git a/packages/tui/src/component/dialog-experiments.tsx b/packages/tui/src/component/dialog-experiments.tsx index 84334603348..8938ced5bc9 100644 --- a/packages/tui/src/component/dialog-experiments.tsx +++ b/packages/tui/src/component/dialog-experiments.tsx @@ -13,13 +13,7 @@ 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[] = [ - { - id: "session-preview-tabs", - title: "Session preview tabs", - description: "Preview sessions in temporary tabs before keeping them open", - }, -] +export const experiments: Experiment[] = [] export function DialogExperiments() { const config = useConfig() diff --git a/packages/tui/src/context/session-tabs.tsx b/packages/tui/src/context/session-tabs.tsx index 59b26fa7d43..3e506c98807 100644 --- a/packages/tui/src/context/session-tabs.tsx +++ b/packages/tui/src/context/session-tabs.tsx @@ -1,4 +1,5 @@ import { createEffect, createMemo, createSignal, on, onCleanup } from "solid-js" +import { createStore } from "solid-js/store" import { useKeyboard, useRenderer } from "@opentui/solid" import { isDeepEqual } from "remeda" import { createSimpleContext } from "./helper" @@ -63,7 +64,6 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp 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. @@ -74,9 +74,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp }, key: "sessionID", }) - const [preview, updatePreview] = storage.memory<{ global?: string; cwd?: string }>("session-tab-preview", { - initial: {}, - }) + const [preview, updatePreview] = createStore<{ global?: string; cwd?: string }>({}) const fallback = empty() const [promptPulses, setPromptPulses] = createSignal>({}) let history: SessionTabHistory = { entries: [], index: -1 } @@ -108,16 +106,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp } 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 - }) - } + const setPreview = (sessionID: string | undefined) => updatePreview(config.tabs.scope, sessionID) function update(mutation: (draft: TabsState) => void) { const scope = config.tabs.scope @@ -178,13 +167,9 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp } createEffect(() => { - if (enabled() && previews()) return + if (enabled()) return promotedSession = undefined - if (!preview.global && !preview.cwd) return - updatePreview((draft) => { - delete draft.global - delete draft.cwd - }) + updatePreview({ global: undefined, cwd: undefined }) }) // Shared storage updates must not re-admit a tab unless this client changes route or scope. @@ -203,10 +188,9 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp 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 + const replaced = permanent ? undefined : previewID() if (replaced) scrollAnchors.delete(replaced) - if (temporary) setPreview(sessionID) + if (!permanent) setPreview(sessionID) update((draft) => { if (cancelledTabs.has(sessionID)) return const tab = { @@ -377,7 +361,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp return state().tabs }, isPreview(sessionID: string) { - return enabled() && previews() && previewID() === root(sessionID) + return enabled() && previewID() === root(sessionID) }, newTab() { return newTab() @@ -404,7 +388,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp route.navigate({ type: "session", sessionID: root(sessionID) }) }, promote(sessionID: string) { - if (!enabled() || !previews()) return + if (!enabled()) return const session = root(sessionID) if (previewID() === session) { setPreview(undefined) @@ -448,7 +432,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 + promotedSession = result.sessionID update((draft) => { draft.tabs = tabs }) diff --git a/packages/tui/test/context/session-tabs.test.tsx b/packages/tui/test/context/session-tabs.test.tsx index 3473eebf00b..4b31c15ee5a 100644 --- a/packages/tui/test/context/session-tabs.test.tsx +++ b/packages/tui/test/context/session-tabs.test.tsx @@ -42,7 +42,7 @@ async function renderSessionTabs( launchDirectory?: string tabsEnabled?: boolean viewFailures?: number - preview?: boolean + experimental?: Record }, ) { const temporary = options?.state ? undefined : await tmpdir() @@ -136,7 +136,7 @@ async function renderSessionTabs( let config!: ReturnType let configuration = { tabs: { enabled: options?.tabsEnabled ?? true }, - experimental: options?.preview ? { "session-preview-tabs": true } : undefined, + experimental: options?.experimental, session: { new_location: options?.newLocation ?? "launch" }, } @@ -202,10 +202,10 @@ async function renderSessionTabs( focus: () => app.renderer.emit("focus"), blur: () => app.renderer.emit("blur"), flush: () => storage.flush(), - setPreviews: (enabled: boolean) => + setTabsEnabled: (enabled: boolean) => config.update((draft) => { - draft.experimental ??= {} - draft.experimental["session-preview-tabs"] = enabled + draft.tabs ??= {} + draft.tabs.enabled = enabled }), async destroy() { app.renderer.destroy() @@ -291,8 +291,11 @@ 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 }) +test.each([undefined, true, false])("replaces previews with legacy experiment setting %s", async (preview) => { + const setup = await renderSessionTabs("first", { + persisted: ["first", "permanent"], + experimental: preview === undefined ? undefined : { "session-preview-tabs": preview }, + }) try { await wait(() => setup.tabs.tabs().length === 2) @@ -330,7 +333,7 @@ test("replaces session previews without replacing permanent tabs or opening exis }) test("server-wide prompt admissions do not promote a local session preview", async () => { - const setup = await renderSessionTabs("preview", { preview: true }) + const setup = await renderSessionTabs("preview") try { await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "preview") && setup.tabs.isPreview("preview")) @@ -360,7 +363,7 @@ test("server-wide prompt admissions do not promote a local session preview", asy }) test("promotes a local preview before its tab has finished persisting", async () => { - const setup = await renderSessionTabs("permanent", { persisted: ["permanent"], preview: true }) + const setup = await renderSessionTabs("permanent", { persisted: ["permanent"] }) try { await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "permanent")) @@ -382,7 +385,7 @@ test("promotes a local preview before its tab has finished persisting", async () }) test("reopens a previously permanent session as a preview after a user prompt", async () => { - const setup = await renderSessionTabs("permanent", { persisted: ["permanent"], preview: true }) + const setup = await renderSessionTabs("permanent", { persisted: ["permanent"] }) try { await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "permanent")) @@ -401,7 +404,7 @@ test("reopens a previously permanent session as a preview after a user prompt", }) test("keeps an explicitly promoted home session permanent when admission arrives before navigation", async () => { - const setup = await renderSessionTabs("created", { home: true, preview: true }) + const setup = await renderSessionTabs("created", { home: true }) try { setup.tabs.promote("created") @@ -416,7 +419,7 @@ test("keeps an explicitly promoted home session permanent when admission arrives }) test("stores preview tab membership without persisting preview identity", async () => { - const setup = await renderSessionTabs("preview", { preview: true }) + const setup = await renderSessionTabs("preview") try { await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "preview") && setup.tabs.isPreview("preview")) @@ -433,7 +436,7 @@ test("stores preview tab membership without persisting preview identity", async }) test("unrelated user admissions do not pre-promote an unopened local session", async () => { - const setup = await renderSessionTabs("remote", { home: true, preview: true }) + const setup = await renderSessionTabs("remote", { home: true }) try { setup.emit(admitted("remote", "msg_1")) @@ -453,11 +456,11 @@ test("each client replaces only its own preview in shared tab storage", async () const clients: Awaited>[] = [] try { - const first = await renderSessionTabs("first", { state: temporary.path, preview: true }) + const first = await renderSessionTabs("first", { state: temporary.path }) clients.push(first) await wait(() => first.tabs.tabs().some((tab) => tab.sessionID === "first")) - const second = await renderSessionTabs("first", { state: temporary.path, preview: true }) + const second = await renderSessionTabs("first", { state: temporary.path }) clients.push(second) expect(second.tabs.isPreview("first")).toBe(false) @@ -478,7 +481,7 @@ test("each client replaces only its own preview in shared tab storage", async () }) test("reopening a closed preview makes it permanent without replacing the current preview", async () => { - const setup = await renderSessionTabs("permanent", { persisted: ["permanent"], preview: true }) + const setup = await renderSessionTabs("permanent", { persisted: ["permanent"] }) try { await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "permanent")) @@ -504,7 +507,7 @@ test("reopening a closed preview makes it permanent without replacing the curren }) test("moving a preview promotes it before opening another preview", async () => { - const setup = await renderSessionTabs("first", { persisted: ["first", "last"], preview: true }) + const setup = await renderSessionTabs("first", { persisted: ["first", "last"] }) try { await wait(() => setup.tabs.tabs().length === 2) @@ -525,16 +528,16 @@ test("moving a preview promotes it before opening another preview", async () => } }) -test("disabling previews clears local identity and prevents stale replacement after re-enabling", async () => { - const setup = await renderSessionTabs("first", { preview: true }) +test("disabling tabs clears local preview identity and prevents stale replacement after re-enabling", async () => { + const setup = await renderSessionTabs("first") try { await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "first") && setup.tabs.isPreview("first")) - await setup.setPreviews(false) + await setup.setTabsEnabled(false) expect(setup.tabs.isPreview("first")).toBe(false) - await setup.setPreviews(true) + await setup.setTabsEnabled(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")) @@ -896,7 +899,7 @@ 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 setup = await renderSessionTabs("background", { persisted: ["background"] }) try { await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "background")) @@ -933,7 +936,7 @@ test("user prompt admissions pulse an already-busy background tab", async () => }) test("tracks a temporary new session tab across close and creation", async () => { - const setup = await renderSessionTabs("first") + const setup = await renderSessionTabs("first", { persisted: ["first"] }) try { await wait(() => setup.tabs.current() === "first") @@ -952,6 +955,7 @@ test("tracks a temporary new session tab across close and creation", async () => setup.route.navigate({ type: "home" }) await wait(() => setup.tabs.newTab()) + setup.tabs.promote("third") setup.route.navigate({ type: "session", sessionID: "third" }) expect(setup.tabs.newTab()).toBe(true) await wait(() => setup.tabs.current() === "third" && setup.tabs.tabs().some((tab) => tab.sessionID === "third")) diff --git a/packages/tui/test/plugin-hot-reload.test.tsx b/packages/tui/test/plugin-hot-reload.test.tsx index 54dc85645a5..5795e87571f 100644 --- a/packages/tui/test/plugin-hot-reload.test.tsx +++ b/packages/tui/test/plugin-hot-reload.test.tsx @@ -2,7 +2,7 @@ import { expect, mock, test } from "bun:test" import { createTestRenderer } from "@opentui/core/testing" import { Effect, FileSystem } from "effect" import { Global } from "@opencode-ai/util/global" -import { mkdir, readFile, symlink, writeFile } from "node:fs/promises" +import { mkdir, readFile, symlink, utimes, writeFile } from "node:fs/promises" import path from "node:path" import { pathToFileURL } from "node:url" import { createEventStream, createFetch, json } from "./fixture/tui-client" @@ -367,8 +367,10 @@ export default { ) // Duplicate notifications for unchanged contents must not retry the broken - // generation and cycle the restored plugin again. - await writeFile(source, broken) + // generation and cycle the restored plugin again. Rewriting the file can + // expose truncated contents to the watcher, so change only its metadata. + const touched = new Date(Date.now() + 1000) + await utimes(source, touched, touched) await writeFile(sourceB, lifecycleSource(markerB, "test.b", "b2")) expect(await until(readB, (value) => value?.includes("b2:setup") ?? false)).toBe("b1:setup\nb1:cleanup\nb2:setup\n") expect(await read()).toBe("a1:setup\na1:cleanup\na1:setup\n")