mirror of
https://gitee.com/zhangsken/opencode.git
synced 2026-10-16 10:30:01 +08:00
feat(tui): make session preview tabs the default (#46497)
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user