feat(tui): make session preview tabs the default (#46497)

This commit is contained in:
Kit Langton
2026-09-01 11:02:11 -04:00
committed by GitHub
parent d4b4dd17cc
commit 30f998c5b9
5 changed files with 49 additions and 59 deletions
+6
View File
@@ -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.
@@ -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()
+10 -26
View File
@@ -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<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.
@@ -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<Record<string, number>>({})
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
})
+27 -23
View File
@@ -42,7 +42,7 @@ async function renderSessionTabs(
launchDirectory?: string
tabsEnabled?: boolean
viewFailures?: number
preview?: boolean
experimental?: Record<string, boolean>
},
) {
const temporary = options?.state ? undefined : await tmpdir()
@@ -136,7 +136,7 @@ async function renderSessionTabs(
let config!: ReturnType<typeof useConfig>
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<ReturnType<typeof renderSessionTabs>>[] = []
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"))
+5 -3
View File
@@ -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")