feat(tui): add plus button to session tab bar (#41887)

This commit is contained in:
Kit Langton 2026-08-11 22:38:08 -04:00 committed by GitHub
parent 08dcf7d731
commit 99166f7c17
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
4 changed files with 137 additions and 28 deletions

View file

@ -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<ContextController, "tabs" | "current" | "select" | "close" | "move"> & {
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<string>()
const [addHovered, setAddHovered] = createSignal(false)
const marquee = createMarquee(hovered, animations)
const [dragging, setDragging] = createSignal<string>()
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 (
<box
@ -277,7 +282,7 @@ function VerticalSessionTabs(props: { controller?: SessionTabsController; animat
}}
onMouseUp={release}
onMouseDrag={(event) => {
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
)
}}
</For>
{/* 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. */}
<Show when={tabs.add || newTab()}>
<box
height={1}
width="100%"
position="relative"
flexDirection="row"
paddingLeft={1}
backgroundColor={
newTab()
? theme.background.action.primary.selected
: addHovered()
? theme.background.action.primary.hovered
: theme.background.default
}
onMouseOver={() => setAddHovered(true)}
onMouseOut={() => setAddHovered(false)}
onMouseUp={() => {
if (!newTab()) tabs.add?.()
}}
>
<text
width={2}
fg={newTab() ? activeNumber() : addHovered() ? theme.text.default : idleNumber()}
selectable={false}
attributes={newTab() ? TextAttributes.BOLD : undefined}
>
+
</text>
<text
fg={newTab() || addHovered() ? theme.text.default : theme.text.subdued}
wrapMode="none"
selectable={false}
attributes={newTab() ? TextAttributes.BOLD : undefined}
>
{NEW_SESSION_TAB_TITLE}
</text>
<Show when={newTab()}>
<text
position="absolute"
right={1}
zIndex={2}
width={1}
fg={theme.text.subdued}
selectable={false}
onMouseUp={(event) => {
if (!addHovered()) return
event.stopPropagation()
tabs.close()
}}
>
{addHovered() ? "×" : ""}
</text>
</Show>
</box>
</Show>
</box>
</scrollbox>
</box>
@ -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<string>()
const [addHovered, setAddHovered] = createSignal(false)
const marquee = createMarquee(hovered, animations)
const [dragging, setDragging] = createSignal<string>()
// 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<typeof adaptiveSessionTabLayout> | 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
{" "}
</text>
<text width={numberWidth()} fg={numberColor()} selectable={false} attributes={bold()}>
{sessionTabShortcutLabel(tabNumber() - 1)}
{tab === NEW_SESSION_TAB ? "+" : sessionTabShortcutLabel(tabNumber() - 1)}
</text>
<text
width={availableTitleWidth()}
@ -746,6 +817,19 @@ function HorizontalSessionTabs(props: { controller?: SessionTabsController; anim
{" " + layout().after}
</text>
</Show>
<Show when={showPlus()}>
<text
width={ADD_TAB_WIDTH}
fg={addHovered() ? theme.text.default : theme.text.subdued}
bg={addHovered() ? theme.background.action.primary.hovered : undefined}
selectable={false}
onMouseOver={() => setAddHovered(true)}
onMouseOut={() => setAddHovered(false)}
onMouseUp={() => tabs.add?.()}
>
{" + "}
</text>
</Show>
</box>
)
}

View file

@ -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()

View file

@ -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",

View file

@ -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(
>
<ClientProvider api={createApi(calls.fetch)}>
<DataProvider>
<SessionTabsProvider>
<Probe />
</SessionTabsProvider>
<LocationProvider>
<SessionTabsProvider>
<Probe />
</SessionTabsProvider>
</LocationProvider>
</DataProvider>
</ClientProvider>
</RouteProvider>
@ -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()
}
})