mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-27 04:01:56 +00:00
feat(tui): add experimental session preview tabs (#45021)
This commit is contained in:
parent
3726e3254d
commit
7f9e5e91ab
6 changed files with 501 additions and 22 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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()}>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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> = []
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue