Просмотр исходного кода

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

Kit Langton 4 дней назад
Родитель
Сommit
99166f7c17

+ 94 - 10
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<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>
   )
 }

+ 10 - 0
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()

+ 13 - 15
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",

+ 20 - 3
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(
             >
               <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()
+  }
+})