diff --git a/packages/tui/src/app.tsx b/packages/tui/src/app.tsx index 0a81682d30..af1a59a7ca 100644 --- a/packages/tui/src/app.tsx +++ b/packages/tui/src/app.tsx @@ -1219,7 +1219,13 @@ function App(props: { pair?: DialogPairCredentials }) { - 0 && route.data.type !== "plugin"}> + 0 || sessionTabs.newTab()) && + route.data.type !== "plugin" + } + > diff --git a/packages/tui/src/component/session-tabs.tsx b/packages/tui/src/component/session-tabs.tsx index 69d3577214..6c9c95eceb 100644 --- a/packages/tui/src/component/session-tabs.tsx +++ b/packages/tui/src/component/session-tabs.tsx @@ -9,6 +9,7 @@ import { sessionTabComplete, seedSessionTabMotion, sessionTabOverflowWidth, + type SessionTab, type SessionTabUnread, } from "../context/session-tabs-model" import { createAnimatable, spring, tween } from "../ui/animation" @@ -24,10 +25,19 @@ type ContextController = ReturnType export type SessionTabsStatus = Omit, "unread"> & { unread: SessionTabUnread | undefined } +export const EMPTY_SESSION_TAB_STATUS: SessionTabsStatus = { + unread: undefined, + promptPulse: 0, + attention: false, + busy: false, +} export type SessionTabsController = Pick & { + newTab?: () => boolean status(sessionID: string): SessionTabsStatus } +const NEW_SESSION_TAB: SessionTab = { sessionID: "new", title: "New session" } + export function SessionTabs(props: { controller?: SessionTabsController; animations?: boolean } = {}) { const tabs = props.controller ?? useSessionTabs() const dimensions = useTerminalDimensions() @@ -42,8 +52,9 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati const accent = () => theme.hue.accent[hueStep()] const activeNumber = () => theme.hue.interactive[hueStep()] const idleNumber = () => tint(theme.text.subdued, theme.background.default, 0.35) - const activeID = createMemo(tabs.current) - const items = tabs.tabs + const newTab = () => tabs.newTab?.() ?? false + const activeID = createMemo(() => (newTab() ? NEW_SESSION_TAB.sessionID : tabs.current())) + const items = createMemo(() => (newTab() ? [...tabs.tabs(), NEW_SESSION_TAB] : tabs.tabs())) const layout = createMemo((previous: ReturnType | undefined) => adaptiveSessionTabLayout(items(), activeID(), dimensions().width, previous?.start), ) @@ -51,7 +62,7 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati () => new Map( layout().tabs.map((tab) => { - const status = tabs.status(tab.sessionID) + const status = tab === NEW_SESSION_TAB ? EMPTY_SESSION_TAB_STATUS : tabs.status(tab.sessionID) return [ tab.sessionID, { @@ -262,6 +273,7 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati // keeping sloppy clicks indistinguishable from clean ones. const release = () => { setDragging(undefined) + if (tab === NEW_SESSION_TAB) return tabs.select(tab.sessionID) } return ( @@ -275,6 +287,7 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati onMouseDown={() => setDragging(tab.sessionID)} onMouseUp={release} onMouseDrag={(event) => { + if (tab === NEW_SESSION_TAB) return const slot = slotAt(event.x) if (slot !== undefined && slot !== tabNumber() - 1) tabs.move(tab.sessionID, slot) }} @@ -323,7 +336,7 @@ export function SessionTabs(props: { controller?: SessionTabsController; animati selectable={false} onMouseUp={(event) => { event.stopPropagation() - tabs.close(tab.sessionID) + tabs.close(tab === NEW_SESSION_TAB ? undefined : tab.sessionID) }} > {hovered() === tab.sessionID ? "×" : ""} diff --git a/packages/tui/src/context/session-tabs.tsx b/packages/tui/src/context/session-tabs.tsx index bee249369a..5a6653108c 100644 --- a/packages/tui/src/context/session-tabs.tsx +++ b/packages/tui/src/context/session-tabs.tsx @@ -225,6 +225,9 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp tabs() { return state().tabs }, + newTab() { + return route.data.type === "home" + }, current, status, select(sessionID: string) { @@ -235,8 +238,10 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp if (!enabled()) return const target = sessionID ? root(sessionID) : current() if (!target) { - const previous = state().tabs.at(-1) - if (route.data.type === "home" && previous) route.navigate({ type: "session", sessionID: previous.sessionID }) + const previous = moveSessionTabHistory(history, state().tabs, undefined, -1) + history = previous.history + const session = previous.sessionID ?? state().tabs.at(-1)?.sessionID + if (route.data.type === "home" && session) route.navigate({ type: "session", sessionID: session }) return } remove(target, true) diff --git a/packages/tui/src/feature-plugins/system/storybook/session-tabs.tsx b/packages/tui/src/feature-plugins/system/storybook/session-tabs.tsx index 423a7e4005..439a05e477 100644 --- a/packages/tui/src/feature-plugins/system/storybook/session-tabs.tsx +++ b/packages/tui/src/feature-plugins/system/storybook/session-tabs.tsx @@ -2,7 +2,11 @@ import { Plugin } from "@opencode-ai/plugin/tui" import { useTerminalDimensions } from "@opentui/solid" import { batch, createSignal, For, onCleanup } from "solid-js" import { createStore, reconcile } from "solid-js/store" -import { SessionTabs, type SessionTabsController } from "../../../component/session-tabs" +import { + EMPTY_SESSION_TAB_STATUS, + SessionTabs, + type SessionTabsController, +} from "../../../component/session-tabs" import { moveSessionTab } from "../../../context/session-tabs-model" import type { Story } from "./index" @@ -23,7 +27,6 @@ const FIXTURE_TABS = [ { sessionID: "fixture-12", title: "Prepare review" }, ] -const EMPTY_STATUS: FixtureStatus = { unread: undefined, promptPulse: 0, attention: false, busy: false } const RUN_DURATION = 1_800 const RESUME_DURATION = 900 @@ -66,7 +69,7 @@ function SessionTabsStory(props: { context: Plugin.Context }) { if (!resumed && roll < 0.25) { setStatuses((current) => ({ ...current, - [sessionID]: { ...(current[sessionID] ?? EMPTY_STATUS), attention: true }, + [sessionID]: { ...(current[sessionID] ?? EMPTY_SESSION_TAB_STATUS), attention: true }, })) setLastEvent(`tab ${number(sessionID)} needs input; select it to resolve`) return @@ -77,7 +80,7 @@ function SessionTabsStory(props: { context: Plugin.Context }) { setOutcomes((current) => ({ ...current, [sessionID]: failed ? "failed" : "completed" })) setStatuses((current) => ({ ...current, - [sessionID]: { ...(current[sessionID] ?? EMPTY_STATUS), busy: false, unread }, + [sessionID]: { ...(current[sessionID] ?? EMPTY_SESSION_TAB_STATUS), busy: false, unread }, })) // An untitled session earns its title after its first completed run, like a real summarization. const index = number(sessionID) - 1 @@ -110,7 +113,7 @@ function SessionTabsStory(props: { context: Plugin.Context }) { tabs, current: active, status(sessionID) { - return statuses()[sessionID] ?? EMPTY_STATUS + return statuses()[sessionID] ?? EMPTY_SESSION_TAB_STATUS }, select, move(sessionID: string, index: number) { @@ -150,7 +153,7 @@ function SessionTabsStory(props: { context: Plugin.Context }) { const startRun = (sessionID: string) => { setStatuses((current) => ({ ...current, - [sessionID]: { ...(current[sessionID] ?? EMPTY_STATUS), busy: true, unread: undefined }, + [sessionID]: { ...(current[sessionID] ?? EMPTY_SESSION_TAB_STATUS), busy: true, unread: undefined }, })) setOutcomes((current) => { const next = { ...current } @@ -223,7 +226,7 @@ function SessionTabsStory(props: { context: Plugin.Context }) { const selectedState = () => { const current = active() - const status = current ? controller.status(current) : EMPTY_STATUS + const status = current ? controller.status(current) : EMPTY_SESSION_TAB_STATUS const activity = status.busy ? "running" : status.unread === "activity" diff --git a/packages/tui/test/context/session-tabs-model.test.ts b/packages/tui/test/context/session-tabs-model.test.ts index c5946f7d35..0c89652e9e 100644 --- a/packages/tui/test/context/session-tabs-model.test.ts +++ b/packages/tui/test/context/session-tabs-model.test.ts @@ -151,12 +151,12 @@ describe("session tabs", () => { expect(layout.widths.reduce((total, width) => total + width, 0)).toBe(76) }) - test("does not reserve an active tab slot on the new session page", () => { - const tabs = ["a", "b", "c", "d", "e"].map((sessionID) => ({ sessionID })) - const layout = adaptiveSessionTabLayout(tabs, "dummy", 40) + test("reserves an active tab slot for the new session page", () => { + const tabs = ["a", "b", "c", "d", "new"].map((sessionID) => ({ sessionID })) + const layout = adaptiveSessionTabLayout(tabs, "new", 54) expect(layout.tabs).toEqual(tabs) - expect(layout.widths).toEqual([8, 8, 8, 8, 8]) + expect(layout.widths).toEqual([8, 8, 8, 8, 22]) expect(layout.widths.reduce((total, width) => total + width, 0)).toBe(layout.total) }) diff --git a/packages/tui/test/context/session-tabs.test.tsx b/packages/tui/test/context/session-tabs.test.tsx index cb77916688..fa6b57d2f3 100644 --- a/packages/tui/test/context/session-tabs.test.tsx +++ b/packages/tui/test/context/session-tabs.test.tsx @@ -24,7 +24,7 @@ async function wait(fn: () => boolean, timeout = 2_000) { } } -test("user prompt admissions pulse an already-busy background tab", async () => { +async function renderSessionTabs(initialSessionID: string) { const state = mkdtempSync(path.join(tmpdir(), "opencode-session-tabs-")) const events = createEventStream() const calls = createFetch(undefined, events) @@ -44,7 +44,7 @@ test("user prompt admissions pulse an already-busy background tab", async () => - + @@ -59,7 +59,20 @@ test("user prompt admissions pulse an already-busy background tab", async () => )) - const emit = (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }) + await wait(() => client.connection.status() === "connected") + return { + tabs, + route, + emit: (event: OpenCodeEvent) => events.emit({ ...event, location: { directory } }), + destroy() { + app.renderer.destroy() + rmSync(state, { recursive: true, force: true }) + }, + } +} + +test("user prompt admissions pulse an already-busy background tab", async () => { + const setup = await renderSessionTabs("background") const admitted = (sessionID: string, inputID: string): OpenCodeEvent => ({ id: `evt_${inputID}`, created: Date.now(), @@ -73,13 +86,11 @@ test("user prompt admissions pulse an already-busy background tab", async () => }) try { - await wait( - () => client.connection.status() === "connected" && tabs.tabs().some((tab) => tab.sessionID === "background"), - ) - route.navigate({ type: "session", sessionID: "active" }) - await wait(() => tabs.current() === "active" && tabs.tabs().length === 2) + await wait(() => setup.tabs.tabs().some((tab) => tab.sessionID === "background")) + setup.route.navigate({ type: "session", sessionID: "active" }) + await wait(() => setup.tabs.current() === "active" && setup.tabs.tabs().length === 2) - emit({ + setup.emit({ id: "evt_context", created: Date.now(), type: "session.input.admitted", @@ -91,20 +102,52 @@ test("user prompt admissions pulse an already-busy background tab", async () => }, }) await Bun.sleep(20) - expect(tabs.status("background").promptPulse).toBe(0) + expect(setup.tabs.status("background").promptPulse).toBe(0) - emit(admitted("background", "msg_1")) - await wait(() => tabs.status("background").promptPulse === 1 && tabs.status("background").busy) + setup.emit(admitted("background", "msg_1")) + await wait( + () => setup.tabs.status("background").promptPulse === 1 && setup.tabs.status("background").busy, + ) - emit(admitted("background", "msg_2")) - await wait(() => tabs.status("background").promptPulse === 2) + setup.emit(admitted("background", "msg_2")) + await wait(() => setup.tabs.status("background").promptPulse === 2) - emit(admitted("active", "msg_3")) + setup.emit(admitted("active", "msg_3")) await Bun.sleep(20) - expect(tabs.status("active").promptPulse).toBe(0) - expect(tabs.status("background")).toMatchObject({ promptPulse: 2, busy: true }) + expect(setup.tabs.status("active").promptPulse).toBe(0) + expect(setup.tabs.status("background")).toMatchObject({ promptPulse: 2, busy: true }) } finally { - app.renderer.destroy() - rmSync(state, { recursive: true, force: true }) + setup.destroy() + } +}) + +test("tracks a temporary new session tab across close and creation", async () => { + const setup = await renderSessionTabs("first") + + try { + await wait(() => setup.tabs.current() === "first") + setup.route.navigate({ type: "session", sessionID: "second" }) + await wait(() => setup.tabs.current() === "second" && setup.tabs.tabs().length === 2) + setup.route.navigate({ type: "session", sessionID: "first" }) + await wait(() => setup.tabs.current() === "first") + + setup.route.navigate({ type: "home" }) + await wait(() => setup.tabs.newTab() && setup.tabs.current() === undefined) + expect(setup.tabs.tabs().map((tab) => tab.sessionID)).toEqual(["first", "second"]) + setup.tabs.close() + await wait(() => setup.route.data.type === "session") + + expect(setup.route.data).toEqual({ type: "session", sessionID: "first" }) + + setup.route.navigate({ type: "home" }) + await wait(() => setup.tabs.newTab()) + setup.route.navigate({ type: "session", sessionID: "third" }) + await wait( + () => setup.tabs.current() === "third" && setup.tabs.tabs().some((tab) => tab.sessionID === "third"), + ) + + expect(setup.tabs.newTab()).toBe(false) + } finally { + setup.destroy() } })