diff --git a/packages/tui/src/component/session-tabs.tsx b/packages/tui/src/component/session-tabs.tsx index 63312db5008..b111af5a895 100644 --- a/packages/tui/src/component/session-tabs.tsx +++ b/packages/tui/src/component/session-tabs.tsx @@ -27,6 +27,8 @@ import { marqueeText } from "../util/marquee" // A long title fades out over its last cells instead of cutting hard. const FADE_WIDTH = 4 +// The add button renders as " + " at the end of the strip, so the tab layout leaves it room. +const ADD_TAB_WIDTH = 3 const MARQUEE_DELAY = 600 const MARQUEE_INTERVAL = 100 @@ -42,6 +44,7 @@ export const EMPTY_SESSION_TAB_STATUS: SessionTabsStatus = { } export type SessionTabsController = Pick & { newTab?: () => boolean + add?: () => void status(sessionID: string): SessionTabsStatus } @@ -103,22 +106,23 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat const separatorUpperPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.04)) const separatorLowerPulseColor = createMemo(() => tint(theme.background.default, theme.text.default, 0.05)) const [hovered, setHovered] = createSignal() + const [addHovered, setAddHovered] = createSignal(false) const marquee = createMarquee(hovered, animations) const [dragging, setDragging] = createSignal() const [preview, setPreview] = createSignal<{ sessionID: string; index: number }>() const newTab = () => tabs.newTab?.() ?? false - const activeID = createMemo(() => (newTab() ? NEW_SESSION_TAB.sessionID : tabs.current())) + const activeID = createMemo(() => (newTab() ? undefined : tabs.current())) const ordered = createMemo(() => { const pending = preview() if (!pending) return tabs.tabs() return moveSessionTab(tabs.tabs(), pending.sessionID, pending.index) }) - const items = createMemo(() => (newTab() ? [...ordered(), NEW_SESSION_TAB] : ordered())) + const items = ordered const statuses = createMemo( () => new Map( items().map((tab) => { - const status = tab === NEW_SESSION_TAB ? EMPTY_SESSION_TAB_STATUS : tabs.status(tab.sessionID) + const status = tabs.status(tab.sessionID) return [ tab.sessionID, { @@ -145,6 +149,8 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat createEffect(() => { if (!scroll) return + // The promoted new-session slot sits below the list, so bring the rail's bottom into view. + if (newTab()) return scroll.scrollTo(Math.max(0, items().length * 3 + 1 - scroll.viewport.height)) const index = items().findIndex((tab) => tab.sessionID === activeID()) if (index === -1) return const top = index * 3 @@ -171,7 +177,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat const selected = () => activeID() === tab.sessionID const status = createMemo(() => itemStatus(tab)) const [sweepLevel, setSweepLevel] = createSignal(0) - const session = createMemo(() => (tab === NEW_SESSION_TAB ? undefined : data.session.get(tab.sessionID))) + const session = createMemo(() => data.session.get(tab.sessionID)) const project = createMemo(() => { const value = session() return value ? data.project.get(value.projectID) : undefined @@ -188,7 +194,6 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat const visibleTitleParts = createMemo(() => Locale.graphemes(visibleTitle())) const titleFades = createMemo(() => stringWidth(title()) >= titleWidth() && titleWidth() > FADE_WIDTH) const detail = createMemo(() => { - if (tab === NEW_SESSION_TAB) return Locale.takeWidth("Start a new session", titleWidth()) const value = session() return Locale.takeWidth(projectName(project(), value?.location.directory) ?? "", titleWidth()) }) @@ -260,7 +265,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat setDragging(undefined) const pending = preview() if (pending?.sessionID === tab.sessionID) tabs.move(pending.sessionID, pending.index) - if (tab !== NEW_SESSION_TAB) tabs.select(tab.sessionID) + tabs.select(tab.sessionID) } return ( { - if (!rail || tab === NEW_SESSION_TAB) return + if (!rail) return const target = Math.max( 0, Math.min( @@ -386,7 +391,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat onMouseUp={(event) => { if (hovered() !== tab.sessionID) return event.stopPropagation() - tabs.close(tab === NEW_SESSION_TAB ? undefined : tab.sessionID) + tabs.close(tab.sessionID) }} > {hovered() === tab.sessionID ? "×" : ""} @@ -417,6 +422,63 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat ) }} + {/* One slot with two states: a subdued affordance that promotes in place into the + active new-session tab, instead of spawning a separate pseudo tab above itself. */} + + setAddHovered(true)} + onMouseOut={() => setAddHovered(false)} + onMouseUp={() => { + if (!newTab()) tabs.add?.() + }} + > + + + + + + {NEW_SESSION_TAB_TITLE} + + + { + if (!addHovered()) return + event.stopPropagation() + tabs.close() + }} + > + {addHovered() ? "×" : ""} + + + + @@ -431,6 +493,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim const config = useConfig().data const animations = () => props.animations ?? config.animations ?? true const [hovered, setHovered] = createSignal() + const [addHovered, setAddHovered] = createSignal(false) const marquee = createMarquee(hovered, animations) const [dragging, setDragging] = createSignal() // A drag reorders a local preview and persists one move on release instead of writing @@ -449,7 +512,10 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim if (!pending) return tabs.tabs() return moveSessionTab(tabs.tabs(), pending.sessionID, pending.index) }) + // The promoted new-session slot joins the strip as the active tab; the idle plus affordance + // and the promoted slot are mutually exclusive states of one control. const items = createMemo(() => (newTab() ? [...ordered(), NEW_SESSION_TAB] : ordered())) + const showPlus = () => Boolean(tabs.add) && !newTab() createEffect(() => { const pending = preview() if (!pending || dragging()) return @@ -457,7 +523,12 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim if (index === -1 || index === Math.min(pending.index, tabs.tabs().length - 1)) setPreview(undefined) }) const layout = createMemo((previous: ReturnType | undefined) => - adaptiveSessionTabLayout(items(), activeID(), dimensions().width, previous?.start), + adaptiveSessionTabLayout( + items(), + activeID(), + dimensions().width - (showPlus() ? ADD_TAB_WIDTH : 0), + previous?.start, + ), ) const statuses = createMemo( () => @@ -704,7 +775,7 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim {" "} - {sessionTabShortcutLabel(tabNumber() - 1)} + {tab === NEW_SESSION_TAB ? "+" : sessionTabShortcutLabel(tabNumber() - 1)} + + setAddHovered(true)} + onMouseOut={() => setAddHovered(false)} + onMouseUp={() => tabs.add?.()} + > + {" + "} + + ) } diff --git a/packages/tui/src/context/session-tabs.tsx b/packages/tui/src/context/session-tabs.tsx index c980c4d93b1..82e60803e3d 100644 --- a/packages/tui/src/context/session-tabs.tsx +++ b/packages/tui/src/context/session-tabs.tsx @@ -7,6 +7,7 @@ import { withTimestampedFallback } from "@opencode-ai/util/session-title-fallbac import { useEvent } from "./event" import { useRoute } from "./route" import { useConfig } from "../config" +import { useLocation } from "./location" import { useStorage } from "./storage" import { useTuiPaths } from "./runtime" import { @@ -48,6 +49,7 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp const data = useData() const event = useEvent() const config = useConfig().data + const location = useLocation() const paths = useTuiPaths() const enabled = () => config.tabs.enabled // Keyed reconcile keeps tab object identity across reorders, so strip rows move instead of @@ -249,6 +251,14 @@ export const { use: useSessionTabs, provider: SessionTabsProvider } = createSimp if (!enabled()) return route.navigate({ type: "session", sessionID: root(sessionID) }) }, + add() { + if (!enabled()) return + const sessionID = current() + route.navigate({ + type: "home", + location: (sessionID ? data.session.get(sessionID)?.location : undefined) ?? location.ref, + }) + }, close(sessionID?: string) { if (!enabled()) return const target = sessionID ? root(sessionID) : current() 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 302889136f6..0dee2af8a55 100644 --- a/packages/tui/src/feature-plugins/system/storybook/session-tabs.tsx +++ b/packages/tui/src/feature-plugins/system/storybook/session-tabs.tsx @@ -105,9 +105,21 @@ function SessionTabsStory(props: { context: Plugin.Context }) { } } + const addTab = () => { + const next = FIXTURE_TABS.find((fixture) => !tabs().some((tab) => tab.sessionID === fixture.sessionID)) + if (!next) { + setLastEvent("all fixture tabs are open") + return + } + setItems([...tabs().map((tab) => ({ ...tab })), { sessionID: next.sessionID }]) + select(next.sessionID) + setLastEvent(`tab ${number(next.sessionID)} opened untitled; run it to earn its title`) + } + const controller = { tabs, current: active, + add: addTab, status(sessionID) { return statuses()[sessionID] ?? EMPTY_SESSION_TAB_STATUS }, @@ -283,21 +295,7 @@ function SessionTabsStory(props: { context: Plugin.Context }) { startRun(current) }, }, - { - bind: "t", - title: "Add tab", - group: "Storybook", - run() { - const next = FIXTURE_TABS.find((fixture) => !tabs().some((tab) => tab.sessionID === fixture.sessionID)) - if (!next) { - setLastEvent("all fixture tabs are open") - return - } - setItems([...tabs().map((tab) => ({ ...tab })), { sessionID: next.sessionID }]) - select(next.sessionID) - setLastEvent(`tab ${number(next.sessionID)} opened untitled; run it to earn its title`) - }, - }, + { bind: "t", title: "Add tab", group: "Storybook", run: addTab }, { bind: "d", title: "Close tab", group: "Storybook", run: () => controller.close() }, { bind: "r", diff --git a/packages/tui/test/context/session-tabs.test.tsx b/packages/tui/test/context/session-tabs.test.tsx index 66ef608e7e7..e8f863be14c 100644 --- a/packages/tui/test/context/session-tabs.test.tsx +++ b/packages/tui/test/context/session-tabs.test.tsx @@ -7,6 +7,7 @@ import path from "path" import { ConfigProvider } from "../../src/config" import { ClientProvider, useClient } from "../../src/context/client" import { DataProvider, useData } from "../../src/context/data" +import { LocationProvider } from "../../src/context/location" import { RouteProvider, useRoute } from "../../src/context/route" import { TuiAppProvider } from "../../src/context/runtime" import { SessionTabsProvider, useSessionTabs } from "../../src/context/session-tabs" @@ -86,9 +87,11 @@ async function renderSessionTabs( > - - - + + + + + @@ -272,3 +275,17 @@ test("tracks a temporary new session tab across close and creation", async () => await setup.destroy() } }) + +test("add opens the new session tab carrying the current session's location", async () => { + const setup = await renderSessionTabs("first") + + try { + await wait(() => setup.tabs.current() === "first" && setup.data.session.get("first") !== undefined) + setup.tabs.add() + expect(setup.route.data).toEqual({ type: "home", location: { directory } }) + await wait(() => setup.tabs.newTab()) + expect(setup.tabs.tabs().map((tab) => tab.sessionID)).toEqual(["first"]) + } finally { + await setup.destroy() + } +})