소스 검색

feat(app): terminal improvements (#34747)

Co-authored-by: Brendan Allan <git@brendonovich.dev>
Aarav Sareen 2 달 전
부모
커밋
7cf8d1ca6d

+ 3 - 2
packages/app/e2e/regression/terminal-hidden.spec.ts

@@ -7,7 +7,7 @@ const projectID = "proj_hidden_terminal_regression"
 const sessionID = "ses_hidden_terminal_regression"
 const title = "Hidden terminal regression"
 
-test("unmounts the terminal renderer while the pane is hidden", async ({ page }) => {
+test("unmounts the terminal panel while it is hidden", async ({ page }) => {
   await page.setViewportSize({ width: 1400, height: 900 })
   await mockOpenCodeServer(page, {
     directory,
@@ -64,13 +64,14 @@ test("unmounts the terminal renderer while the pane is hidden", async ({ page })
   await expect(page.locator('[data-component="terminal"]')).toBeVisible()
 
   await page.keyboard.press("Control+Backquote")
-  await expect(panel).toHaveAttribute("aria-hidden", "true")
+  await expect(panel).toHaveCount(0)
   await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
 
   await page.setViewportSize({ width: 1200, height: 700 })
   await expect(page.locator('[data-component="terminal"]')).toHaveCount(0)
 
   await page.keyboard.press("Control+Backquote")
+  await expect(panel).toBeVisible()
   await expect(page.locator('[data-component="terminal"]')).toBeVisible()
 })
 

+ 10 - 1
packages/app/e2e/utils/mock-server.ts

@@ -1,7 +1,7 @@
 import type { Page, Route } from "@playwright/test"
 
 const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"])
-const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp"])
+const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"])
 
 export interface MockServerConfig {
   provider: unknown
@@ -55,6 +55,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
     const path = url.pathname
     if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry)
     if (path === "/global/health") return json(route, { healthy: true })
+    if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: false })
     if (path === "/permission")
       return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? []))
     if (path === "/question")
@@ -65,6 +66,11 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
       return json(route, await config.fileList(url.searchParams.get("path") ?? ""))
     if (path === "/file/content" && config.fileContent)
       return json(route, await config.fileContent(url.searchParams.get("path") ?? ""))
+    if (path === "/api/reference")
+      return json(route, {
+        location: { directory: config.directory, project: { id: (config.project as { id?: string }).id, directory: config.directory } },
+        data: [],
+      })
     if (emptyObject.has(path)) return json(route, {})
     if (emptyList.has(path)) return json(route, [])
     if (path in staticRoutes) return json(route, staticRoutes[path])
@@ -75,6 +81,9 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) {
       return json(route, session ?? {})
     }
 
+    const projectMatch = path.match(/^\/project\/([^/]+)$/)
+    if (projectMatch) return json(route, config.project)
+
     const messageMatch = path.match(/^\/session\/([^/]+)\/message\/([^/]+)$/)
     if (messageMatch) {
       config.onMessage?.({ sessionID: messageMatch[1]!, messageID: messageMatch[2]! })

+ 271 - 0
packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx

@@ -0,0 +1,271 @@
+import type { JSX } from "solid-js"
+import { Show, createEffect, onCleanup } from "solid-js"
+import { createStore } from "solid-js/store"
+import { useSortable } from "@dnd-kit/solid/sortable"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { Tabs } from "@opencode-ai/ui/tabs"
+import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu"
+import { Icon } from "@opencode-ai/ui/icon"
+import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2"
+import { isDefaultTitle as isDefaultTerminalTitle } from "@/context/terminal-title"
+import { useTerminal, type LocalPTY } from "@/context/terminal"
+import { useLanguage } from "@/context/language"
+import { focusTerminalById } from "@/pages/session/helpers"
+
+export function SortableTerminalTabV2(props: {
+  terminal: LocalPTY
+  index: () => number
+  newLayout: boolean
+  onClose?: () => void
+}): JSX.Element {
+  const terminal = useTerminal()
+  const language = useLanguage()
+  const sortable = useSortable({
+    get id() {
+      return props.terminal.id
+    },
+    get index() {
+      return props.index()
+    },
+  })
+  const [store, setStore] = createStore({
+    editing: false,
+    title: props.terminal.title,
+    menuOpen: false,
+    menuPosition: { x: 0, y: 0 },
+    blurEnabled: false,
+  })
+  let input: HTMLInputElement | undefined
+  let blurFrame: number | undefined
+  let editRequested = false
+
+  const isDefaultTitle = () => {
+    const number = props.terminal.titleNumber
+    if (!Number.isFinite(number) || number <= 0) return false
+    return isDefaultTerminalTitle(props.terminal.title, number)
+  }
+
+  const label = () => {
+    language.locale()
+    if (props.terminal.title && !isDefaultTitle()) return props.terminal.title
+
+    const number = props.terminal.titleNumber
+    if (Number.isFinite(number) && number > 0) return language.t("terminal.title.numbered", { number })
+    if (props.terminal.title) return props.terminal.title
+    return language.t("terminal.title")
+  }
+
+  const close = () => {
+    const count = terminal.all().length
+    void terminal.close(props.terminal.id)
+    if (count === 1) {
+      props.onClose?.()
+    }
+  }
+
+  const focus = () => {
+    if (store.editing) return
+    terminal.open(props.terminal.id)
+    if (document.activeElement instanceof HTMLElement) document.activeElement.blur()
+    focusTerminalById(props.terminal.id)
+  }
+
+  const edit = (e?: Event) => {
+    if (e) {
+      e.stopPropagation()
+      e.preventDefault()
+    }
+
+    setStore("blurEnabled", false)
+    setStore("title", props.terminal.title)
+    setStore("editing", true)
+  }
+
+  const save = () => {
+    if (!store.blurEnabled) return
+
+    const value = store.title.trim()
+    if (value && value !== props.terminal.title) {
+      terminal.update({ id: props.terminal.id, title: value })
+    }
+    setStore("editing", false)
+  }
+
+  const keydown = (e: KeyboardEvent) => {
+    if (e.key === "Enter") {
+      e.preventDefault()
+      save()
+      return
+    }
+    if (e.key === "Escape") {
+      e.preventDefault()
+      setStore("editing", false)
+    }
+  }
+
+  const menu = (e: MouseEvent) => {
+    e.preventDefault()
+    setStore("menuPosition", { x: e.clientX, y: e.clientY })
+    setStore("menuOpen", true)
+  }
+
+  createEffect(() => {
+    if (!store.editing) return
+    if (!input) return
+    input.focus()
+    input.select()
+    if (blurFrame !== undefined) cancelAnimationFrame(blurFrame)
+    blurFrame = requestAnimationFrame(() => {
+      blurFrame = undefined
+      setStore("blurEnabled", true)
+    })
+  })
+
+  onCleanup(() => {
+    if (blurFrame === undefined) return
+    cancelAnimationFrame(blurFrame)
+  })
+
+  return (
+    <div
+      ref={sortable.ref}
+      class="outline-none focus:outline-none focus-visible:outline-none"
+      classList={{
+        "h-full flex items-center": props.newLayout,
+        "h-full": !props.newLayout,
+      }}
+    >
+      <Show
+        when={props.newLayout}
+        fallback={
+          <div class="relative h-full">
+            <Tabs.Trigger
+              value={props.terminal.id}
+              onClick={focus}
+              onMouseDown={(e) => e.preventDefault()}
+              onContextMenu={menu}
+              class="!shadow-none"
+              classes={{
+                button: "border-0 outline-none focus:outline-none focus-visible:outline-none !shadow-none !ring-0",
+              }}
+              closeButton={
+                <IconButton
+                  icon="close"
+                  variant="ghost"
+                  onClick={(e) => {
+                    e.stopPropagation()
+                    close()
+                  }}
+                  aria-label={language.t("terminal.close")}
+                />
+              }
+            >
+              <span onDblClick={edit} classList={{ invisible: store.editing }}>
+                {label()}
+              </span>
+            </Tabs.Trigger>
+            <Show when={store.editing}>
+              <div class="absolute inset-0 flex items-center px-3 bg-muted z-10 pointer-events-auto">
+                <input
+                  ref={input}
+                  type="text"
+                  value={store.title}
+                  onInput={(e) => setStore("title", e.currentTarget.value)}
+                  onBlur={save}
+                  onKeyDown={keydown}
+                  onMouseDown={(e) => e.stopPropagation()}
+                  class="bg-transparent border-none outline-none text-sm min-w-0 flex-1"
+                />
+              </div>
+            </Show>
+            <DropdownMenu open={store.menuOpen} onOpenChange={(open) => setStore("menuOpen", open)}>
+              <DropdownMenu.Portal>
+                <DropdownMenu.Content
+                  class="fixed"
+                  style={{
+                    left: `${store.menuPosition.x}px`,
+                    top: `${store.menuPosition.y}px`,
+                  }}
+                  onCloseAutoFocus={(e) => {
+                    if (!editRequested) return
+                    e.preventDefault()
+                    editRequested = false
+                    requestAnimationFrame(() => edit())
+                  }}
+                >
+                  <DropdownMenu.Item onSelect={() => (editRequested = true)}>
+                    <Icon name="edit" class="w-4 h-4 mr-2" />
+                    {language.t("common.rename")}
+                  </DropdownMenu.Item>
+                  <DropdownMenu.Item onSelect={close}>
+                    <Icon name="close" class="w-4 h-4 mr-2" />
+                    {language.t("common.close")}
+                  </DropdownMenu.Item>
+                </DropdownMenu.Content>
+              </DropdownMenu.Portal>
+            </DropdownMenu>
+          </div>
+        }
+      >
+        <MenuV2.Context>
+          <MenuV2.Context.Trigger class="relative" as="div">
+            <Tabs.Trigger
+              value={props.terminal.id}
+              onClick={focus}
+              closeButton={
+                <IconButton
+                  icon="close-small"
+                  variant="ghost"
+                  class="h-5 w-5"
+                  onClick={(e) => {
+                    e.stopPropagation()
+                    close()
+                  }}
+                  aria-label={language.t("terminal.close")}
+                />
+              }
+              hideCloseButton
+              onMiddleClick={close}
+            >
+              <span
+                class="truncate"
+                data-slot="terminal-tab-title"
+                onDblClick={edit}
+                classList={{ invisible: store.editing }}
+              >
+                {label()}
+              </span>
+            </Tabs.Trigger>
+            <Show when={store.editing}>
+              <div class="absolute inset-0 flex items-center bg-v2-background-bg-layer-01 z-10 pointer-events-auto rounded-[6px] shadow-[inset_0_0_0_0.5px_var(--v2-border-border-muted)] px-2">
+                <input
+                  ref={input}
+                  type="text"
+                  value={store.title}
+                  onInput={(e) => setStore("title", e.currentTarget.value)}
+                  onBlur={save}
+                  onKeyDown={keydown}
+                  onMouseDown={(e) => e.stopPropagation()}
+                  class="bg-transparent border-none outline-none min-w-0 flex-1 p-0 text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-base [font-weight:440] [font-variation-settings:'slnt'_0] [font-variant-numeric:tabular-nums]"
+                />
+              </div>
+            </Show>
+          </MenuV2.Context.Trigger>
+          <MenuV2.Context.Portal>
+            <MenuV2.Context.Content
+              onCloseAutoFocus={(e) => {
+                if (!editRequested) return
+                e.preventDefault()
+                editRequested = false
+                requestAnimationFrame(() => edit())
+              }}
+            >
+              <MenuV2.Item onSelect={() => (editRequested = true)}>{language.t("common.rename")}</MenuV2.Item>
+              <MenuV2.Item onSelect={close}>{language.t("common.close")}</MenuV2.Item>
+            </MenuV2.Context.Content>
+          </MenuV2.Context.Portal>
+        </MenuV2.Context>
+      </Show>
+    </div>
+  )
+}

+ 0 - 2
packages/app/src/components/titlebar-tab-strip.tsx

@@ -17,8 +17,6 @@ import { createTabPromptState } from "@/context/prompt"
 import { base64Encode } from "@opencode-ai/core/util/encode"
 import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture"
 
-const sortableTransition = { duration: 0 }
-
 function SessionTabSlot(props: {
   tab: SessionTab
   id: string

+ 86 - 18
packages/app/src/pages/session.tsx

@@ -71,11 +71,13 @@ import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/
 import { useSessionLayout } from "@/pages/session/session-layout"
 import { syncSessionModel } from "@/pages/session/session-model-helpers"
 import { SessionSidePanel } from "@/pages/session/session-side-panel"
+import { sessionPanelLayout } from "@/pages/session/session-panel-layout"
 import { SessionReviewEmptyChangesV2 } from "@opencode-ai/session-ui/v2/session-review-empty-changes-v2"
 import { SessionReviewEmptyNoGitV2 } from "@opencode-ai/session-ui/v2/session-review-empty-no-git-v2"
 import { ReviewPanelV2 } from "@/pages/session/v2/review-panel-v2"
 import { createReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state"
 import { TerminalPanel } from "@/pages/session/terminal-panel"
+import { TerminalPanelV2 } from "@/pages/session/terminal-panel-v2"
 import { useComposerCommands } from "@/pages/session/use-composer-commands"
 import { useSessionCommands } from "@/pages/session/use-session-commands"
 import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll"
@@ -427,6 +429,12 @@ export default function Page() {
   const isDesktop = createMediaQuery("(min-width: 768px)")
   const size = createSizing()
   const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
+  const desktopV2ReviewOpen = createMemo(() => newSessionDesign() && desktopReviewOpen() && !!params.id)
+  const terminalOpen = createMemo(() => view().terminal.opened())
+  const desktopTerminalOpen = createMemo(() => isDesktop() && terminalOpen())
+  const desktopInlineTerminalOnlyOpen = createMemo(
+    () => newSessionDesign() && desktopTerminalOpen() && !desktopV2ReviewOpen(),
+  )
   const desktopFileTreeOpen = createMemo(
     () =>
       isDesktop() &&
@@ -435,13 +443,23 @@ export default function Page() {
         opened: layout.fileTree.opened(),
       }),
   )
-  const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen())
+  const desktopSessionResizeOpen = createMemo(
+    () => (newSessionDesign() ? desktopV2ReviewOpen() || desktopTerminalOpen() : desktopReviewOpen()),
+  )
+  const desktopSidePanelOpen = createMemo(() => desktopSessionResizeOpen() || desktopFileTreeOpen())
   const sessionPanelWidth = createMemo(() => {
     if (!desktopSidePanelOpen()) return "100%"
-    if (desktopReviewOpen()) return `${layout.session.width()}px`
+    if (desktopSessionResizeOpen()) return `${layout.session.width()}px`
     return `calc(100% - ${layout.fileTree.width()}px)`
   })
   const centered = createMemo(() => isDesktop() && !desktopReviewOpen())
+  const desktopV2PanelLayout = createMemo(() =>
+    sessionPanelLayout({
+      review: desktopV2ReviewOpen(),
+      terminal: desktopTerminalOpen(),
+      files: desktopFileTreeOpen(),
+    }),
+  )
 
   function normalizeTab(tab: string) {
     if (!tab.startsWith("file://")) return tab
@@ -2093,7 +2111,7 @@ export default function Page() {
           classList={{
             "@container relative shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]": true,
             "duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
-              !size.active() && !ui.reviewSnap,
+              !size.active() && !ui.reviewSnap && !desktopInlineTerminalOnlyOpen(),
           }}
           style={{
             width: sessionPanelWidth(),
@@ -2113,7 +2131,7 @@ export default function Page() {
             </SessionPanelFrame>
           )}
 
-          <Show when={desktopReviewOpen()}>
+          <Show when={desktopSessionResizeOpen()}>
             <div onPointerDown={() => size.start()}>
               <ResizeHandle
                 classList={{
@@ -2132,22 +2150,72 @@ export default function Page() {
           </Show>
         </div>
 
-        <SessionSidePanel
-          canReview={canReview}
-          diffs={reviewDiffs}
-          diffsReady={reviewReady}
-          empty={reviewEmptyText}
-          hasReview={hasReview}
-          reviewCount={reviewCount}
-          reviewPanel={() => (newSessionDesign() ? reviewPanelV2() : reviewPanel())}
-          activeDiff={tree.activeDiff}
-          focusReviewDiff={focusReviewDiff}
-          reviewSnap={ui.reviewSnap}
-          size={size}
-        />
+        <Show when={!newSessionDesign()}>
+          <SessionSidePanel
+            canReview={canReview}
+            diffs={reviewDiffs}
+            diffsReady={reviewReady}
+            empty={reviewEmptyText}
+            hasReview={hasReview}
+            reviewCount={reviewCount}
+            reviewPanel={reviewPanel}
+            activeDiff={tree.activeDiff}
+            focusReviewDiff={focusReviewDiff}
+            reviewSnap={ui.reviewSnap}
+            size={size}
+          />
+        </Show>
+        <Show when={newSessionDesign()}>
+          <Show when={isDesktop() ? desktopV2PanelLayout().visible : terminalOpen()}>
+            <div class="min-w-0 h-full flex flex-1 flex-col">
+              <Show when={isDesktop() && (desktopV2ReviewOpen() || desktopFileTreeOpen())}>
+                <div class="min-h-0 flex-1">
+                  <SessionSidePanel
+                    canReview={canReview}
+                    diffs={reviewDiffs}
+                    diffsReady={reviewReady}
+                    empty={reviewEmptyText}
+                    hasReview={hasReview}
+                    reviewCount={reviewCount}
+                    reviewPanel={reviewPanelV2}
+                    activeDiff={tree.activeDiff}
+                    focusReviewDiff={focusReviewDiff}
+                    reviewSnap={ui.reviewSnap}
+                    size={size}
+                    stacked={desktopV2PanelLayout().stacked}
+                  />
+                </div>
+              </Show>
+              <Show when={desktopV2PanelLayout().stacked}>
+                <div class="relative h-2 shrink-0" onPointerDown={() => size.start()}>
+                  <ResizeHandle
+                    class="!relative !inset-auto !h-full !w-full !transform-none"
+                    direction="vertical"
+                    size={layout.terminal.height()}
+                    min={100}
+                    max={typeof window === "undefined" ? 600 : window.innerHeight * 0.6}
+                    collapseThreshold={50}
+                    onResize={(height) => {
+                      size.touch()
+                      layout.terminal.resize(height)
+                    }}
+                    onCollapse={() => view().terminal.close()}
+                  />
+                </div>
+              </Show>
+              <Show when={terminalOpen()}>
+                <div class="min-h-0 flex-1">
+                  <TerminalPanelV2 stacked={desktopV2PanelLayout().stacked} />
+                </div>
+              </Show>
+            </div>
+          </Show>
+        </Show>
       </div>
 
-      <TerminalPanel />
+      <Show when={!newSessionDesign()}>
+        <TerminalPanel />
+      </Show>
     </SessionRouteFrame>
   )
 }

+ 19 - 0
packages/app/src/pages/session/session-panel-layout.test.ts

@@ -0,0 +1,19 @@
+import { describe, expect, test } from "bun:test"
+import { sessionPanelLayout } from "./session-panel-layout"
+
+describe("sessionPanelLayout", () => {
+  test("keeps one V2 owner while changing panel geometry", () => {
+    expect(sessionPanelLayout({ review: false, terminal: false, files: false })).toEqual({
+      visible: false,
+      stacked: false,
+    })
+    expect(sessionPanelLayout({ review: false, terminal: true, files: false })).toEqual({
+      visible: true,
+      stacked: false,
+    })
+    expect(sessionPanelLayout({ review: true, terminal: true, files: false })).toEqual({
+      visible: true,
+      stacked: true,
+    })
+  })
+})

+ 6 - 0
packages/app/src/pages/session/session-panel-layout.ts

@@ -0,0 +1,6 @@
+export function sessionPanelLayout(input: { review: boolean; terminal: boolean; files: boolean }) {
+  return {
+    visible: input.review || input.terminal || input.files,
+    stacked: input.review && input.terminal,
+  }
+}

+ 4 - 1
packages/app/src/pages/session/session-side-panel.tsx

@@ -51,6 +51,7 @@ export function SessionSidePanel(props: {
   focusReviewDiff: (path: string) => void
   reviewSnap: boolean
   size: Sizing
+  stacked?: boolean
 }) {
   const layout = useLayout()
   const settings = useSettings()
@@ -219,8 +220,10 @@ export function SessionSidePanel(props: {
         aria-label={language.t("session.panel.reviewAndFiles")}
         aria-hidden={!open()}
         inert={!open()}
-        class="relative min-w-0 h-full flex shrink-0 overflow-hidden bg-background-base"
+        class="relative min-w-0 flex overflow-hidden bg-background-base"
         classList={{
+          "h-full shrink-0": !props.stacked,
+          "min-h-0 flex-1": props.stacked,
           "pointer-events-none": !open(),
           "transition-[width] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
             !props.size.active() && !props.reviewSnap,

+ 364 - 0
packages/app/src/pages/session/terminal-panel-v2.tsx

@@ -0,0 +1,364 @@
+import { For, Show, createEffect, createMemo, on, onCleanup, onMount } from "solid-js"
+import { createStore } from "solid-js/store"
+import { makeEventListener } from "@solid-primitives/event-listener"
+import { createMediaQuery } from "@solid-primitives/media"
+import { DragDropProvider, PointerSensor } from "@dnd-kit/solid"
+import { isSortable } from "@dnd-kit/solid/sortable"
+import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom"
+import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers"
+import { RestrictToElement } from "@dnd-kit/dom/modifiers"
+import { Tabs } from "@opencode-ai/ui/tabs"
+import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
+import { IconButton } from "@opencode-ai/ui/icon-button"
+import { TooltipKeybind } from "@opencode-ai/ui/tooltip"
+import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
+import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2"
+
+import { SortableTerminalTabV2 } from "@/components/session/session-sortable-terminal-tab-v2"
+import { Terminal } from "@/components/terminal"
+import { useCommand } from "@/context/command"
+import { useLanguage } from "@/context/language"
+import { useLayout } from "@/context/layout"
+import { useSettings } from "@/context/settings"
+import { useTerminal } from "@/context/terminal"
+import { useSDK } from "@/context/sdk"
+import { terminalTabLabel } from "@/pages/session/terminal-label"
+import { createSizing, focusTerminalById } from "@/pages/session/helpers"
+import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff"
+import { useSessionLayout } from "@/pages/session/session-layout"
+
+export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
+  const delays = [120, 240]
+  const layout = useLayout()
+  const terminal = useTerminal()
+  const sdk = useSDK()
+  const language = useLanguage()
+  const command = useCommand()
+  const settings = useSettings()
+  const { workspaceKey, view } = useSessionLayout()
+
+  const isDesktop = createMediaQuery("(min-width: 768px)")
+  const newLayout = createMemo(() => settings.general.newLayoutDesigns())
+  const opened = createMemo(() => view().terminal.opened())
+  const size = createSizing()
+  const height = createMemo(() => layout.terminal.height())
+  const close = () => view().terminal.close()
+  let root: HTMLDivElement | undefined
+  let tabList: HTMLDivElement | undefined
+
+  const [store, setStore] = createStore({
+    autoCreated: false,
+    recovered: {} as Record<string, boolean>,
+    view: typeof window === "undefined" ? 1000 : (window.visualViewport?.height ?? window.innerHeight),
+  })
+
+  const max = () => store.view * 0.6
+  const pane = () => Math.min(height(), max())
+  const stacked = createMemo(() => isDesktop() && props.stacked)
+  const panelHeight = createMemo(() => (isDesktop() ? (stacked() ? `${pane()}px` : "100%") : opened() ? `${pane()}px` : "0px"))
+  const contentHeight = createMemo(() => (isDesktop() ? (stacked() ? `${pane()}px` : "100%") : `${pane()}px`))
+  const newTerminalKeybind = createMemo(() => command.keybindParts("terminal.new"))
+
+  onMount(() => {
+    if (typeof window === "undefined") return
+
+    const sync = () => setStore("view", window.visualViewport?.height ?? window.innerHeight)
+    const port = window.visualViewport
+
+    sync()
+    makeEventListener(window, "resize", sync)
+    if (port) makeEventListener(port, "resize", sync)
+  })
+
+  createEffect(() => {
+    if (!opened()) {
+      setStore("autoCreated", false)
+      return
+    }
+
+    if (!terminal.ready() || terminal.all().length !== 0 || store.autoCreated) return
+    terminal.new()
+    setStore("autoCreated", true)
+  })
+
+  createEffect(
+    on(
+      () => terminal.all().length,
+      (count, prevCount) => {
+        if (prevCount === undefined || prevCount <= 0 || count !== 0) return
+        if (!opened()) return
+        close()
+      },
+    ),
+  )
+
+  const focus = (id: string) => {
+    focusTerminalById(id)
+
+    const frame = requestAnimationFrame(() => {
+      if (!opened()) return
+      if (terminal.active() !== id) return
+      focusTerminalById(id)
+    })
+
+    const timers = delays.map((ms) =>
+      window.setTimeout(() => {
+        if (!opened()) return
+        if (terminal.active() !== id) return
+        focusTerminalById(id)
+      }, ms),
+    )
+
+    return () => {
+      cancelAnimationFrame(frame)
+      for (const timer of timers) clearTimeout(timer)
+    }
+  }
+
+  createEffect(
+    on(
+      () => [opened(), terminal.active()] as const,
+      ([next, id]) => {
+        if (!next || !id) return
+        const stop = focus(id)
+        onCleanup(stop)
+      },
+    ),
+  )
+
+  createEffect(() => {
+    if (opened()) return
+    const active = document.activeElement
+    if (!(active instanceof HTMLElement)) return
+    if (!root?.contains(active)) return
+    active.blur()
+  })
+
+  createEffect(() => {
+    const dir = sdk().directory
+    if (!dir) return
+    if (!terminal.ready()) return
+    language.locale()
+
+    setTerminalHandoff(
+      workspaceKey(),
+      terminal.all().map((pty) =>
+        terminalTabLabel({
+          title: pty.title,
+          titleNumber: pty.titleNumber,
+          t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
+        }),
+      ),
+    )
+  })
+
+  const handoff = createMemo(() => {
+    const dir = sdk().directory
+    if (!dir) return []
+    return getTerminalHandoff(workspaceKey()) ?? []
+  })
+
+  const all = terminal.all
+  const ids = createMemo(() => all().map((pty) => pty.id))
+
+  const recoverTerminal = (key: string, id: string, clone: (id: string) => Promise<void>) => {
+    if (store.recovered[key]) return
+    setStore("recovered", key, true)
+    void clone(id)
+  }
+
+  const terminalRecoveryKey = (pty: { id: string; title: string; titleNumber: number }) => {
+    return String(pty.titleNumber || pty.title || pty.id)
+  }
+
+  const markTerminalConnected = (key: string, id: string, trim: (id: string) => void) => {
+    setStore("recovered", key, false)
+    trim(id)
+  }
+
+  const handleTerminalDragEnd = () => {
+    const activeId = terminal.active()
+    if (!activeId) return
+    requestAnimationFrame(() => {
+      if (terminal.active() !== activeId) return
+      focusTerminalById(activeId)
+    })
+  }
+
+  return (
+    <aside
+      ref={root}
+      id="terminal-panel"
+      role="region"
+      aria-label={language.t("terminal.title")}
+      aria-hidden={!opened()}
+      inert={!opened()}
+      class="relative shrink-0 overflow-hidden bg-background-stronger"
+      classList={{
+        "w-full": !isDesktop() || stacked(),
+        "min-w-0 h-full flex-1": isDesktop() && opened() && !stacked(),
+        "w-0 h-full pointer-events-none": isDesktop() && !opened(),
+        "rounded-[10px] shadow-[var(--v2-elevation-raised)]": isDesktop() && newLayout(),
+        "transition-[height] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[height] motion-reduce:transition-none":
+          !isDesktop() && !size.active(),
+      }}
+      style={{ height: panelHeight() }}
+    >
+      <div classList={{ "md:hidden": !stacked(), hidden: stacked() }} onPointerDown={() => size.start()}>
+        <ResizeHandle
+          classList={{
+            "-top-1": newLayout(),
+          }}
+          direction="vertical"
+          size={pane()}
+          min={100}
+          max={max()}
+          collapseThreshold={50}
+          onResize={(next) => {
+            size.touch()
+            layout.terminal.resize(next)
+          }}
+          onCollapse={close}
+        />
+      </div>
+      <div
+        class="absolute inset-0 flex flex-col overflow-hidden"
+        classList={{
+          "border-t border-border-weak-base": opened() && !isDesktop(),
+          "border-t border-border-weaker-base": opened() && stacked() && !newLayout(),
+          "border-l border-border-weaker-base": opened() && isDesktop() && !newLayout(),
+          "pointer-events-none": !opened(),
+        }}
+        style={{ height: contentHeight() }}
+      >
+        <Show
+          when={terminal.ready()}
+          fallback={
+            <div class="flex flex-col h-full pointer-events-none">
+              <div class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-background-stronger overflow-hidden">
+                <For each={handoff()}>
+                  {(title) => (
+                    <div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
+                      {title}
+                    </div>
+                  )}
+                </For>
+                <div class="flex-1" />
+                <div class="text-text-weak pr-2">
+                  {language.t("common.loading")}
+                  {language.t("common.loading.ellipsis")}
+                </div>
+              </div>
+              <div class="flex-1 flex items-center justify-center text-text-weak">{language.t("terminal.loading")}</div>
+            </div>
+          }
+        >
+          <DragDropProvider
+            sensors={[
+              PointerSensor.configure({
+                activationConstraints: [new PointerActivationConstraints.Distance({ value: 4 })],
+                preventActivation: (event) =>
+                  event.target instanceof Element &&
+                  !!event.target.closest('[data-slot="tabs-trigger-close-button"], input, [contenteditable="true"]'),
+              }),
+            ]}
+            modifiers={[RestrictToHorizontalAxis, RestrictToElement.configure({ element: () => tabList ?? null })]}
+            plugins={(defaults) => [
+              ...defaults.filter((plugin) => plugin !== Accessibility),
+              AutoScroller.configure({ acceleration: 8, threshold: { x: 0.05, y: 0 } }),
+              Feedback.configure({ dropAnimation: null }),
+            ]}
+            onDragEnd={(event) => {
+              const source = event.operation.source
+              if (!event.canceled && isSortable(source) && source.initialIndex !== source.index) {
+                terminal.move(source.id.toString(), source.index)
+              }
+              handleTerminalDragEnd()
+            }}
+          >
+            <div class="flex flex-col h-full">
+              <Tabs
+                variant={newLayout() ? "normal" : "alt"}
+                value={terminal.active()}
+                onChange={(id) => terminal.open(id)}
+                class={newLayout() ? "!h-[52px] !flex-none" : "!h-auto !flex-none"}
+              >
+                <Tabs.List ref={tabList} class={newLayout() ? undefined : "h-10 border-b border-border-weaker-base"}>
+                  <For each={all()}>
+                    {(pty, index) => (
+                      <SortableTerminalTabV2 terminal={pty} index={index} newLayout={newLayout()} onClose={close} />
+                    )}
+                  </For>
+                  <div class="h-full flex items-center justify-center">
+                    <Show
+                      when={newLayout()}
+                      fallback={
+                        <TooltipKeybind
+                          title={language.t("command.terminal.new")}
+                          keybind={command.keybind("terminal.new")}
+                          class="flex items-center"
+                        >
+                          <IconButton
+                            icon="plus-small"
+                            variant="ghost"
+                            iconSize="large"
+                            onClick={terminal.new}
+                            aria-label={language.t("command.terminal.new")}
+                          />
+                        </TooltipKeybind>
+                      }
+                    >
+                      <TooltipV2
+                        value={
+                          <>
+                            {language.t("command.terminal.new")}
+                            <Show when={newTerminalKeybind().length > 0}>
+                              <KeybindV2 keys={newTerminalKeybind()} variant="neutral" />
+                            </Show>
+                          </>
+                        }
+                        placement="bottom"
+                        class="flex items-center"
+                      >
+                        <IconButton
+                          icon="plus-small"
+                          variant="ghost"
+                          iconSize="large"
+                          onClick={terminal.new}
+                          aria-label={language.t("command.terminal.new")}
+                        />
+                      </TooltipV2>
+                    </Show>
+                  </div>
+                </Tabs.List>
+              </Tabs>
+              <div class="flex-1 min-h-0 relative">
+                <Show when={opened() && terminal.active()} keyed>
+                  {(id) => {
+                    const ops = terminal.bind()
+                    return (
+                      <Show when={all().find((pty) => pty.id === id)}>
+                        {(pty) => (
+                          <div id={`terminal-wrapper-${id}`} class="absolute inset-0">
+                            <Terminal
+                              pty={pty()}
+                              autoFocus={opened()}
+                              class="!px-[14px]"
+                              onConnect={() => markTerminalConnected(terminalRecoveryKey(pty()), id, ops.trim)}
+                              onCleanup={ops.update}
+                              onConnectError={() => recoverTerminal(terminalRecoveryKey(pty()), id, ops.clone)}
+                            />
+                          </div>
+                        )}
+                      </Show>
+                    )
+                  }}
+                </Show>
+              </div>
+            </div>
+          </DragDropProvider>
+        </Show>
+      </div>
+    </aside>
+  )
+}

+ 47 - 1
packages/ui/src/components/tabs.css

@@ -135,7 +135,8 @@
     }
   }
 
-  #review-panel &[data-variant="normal"][data-orientation="horizontal"] {
+  #review-panel &[data-variant="normal"][data-orientation="horizontal"],
+  #terminal-panel &[data-variant="normal"][data-orientation="horizontal"] {
     background-color: var(--background-stronger);
 
     [data-slot="tabs-list"] {
@@ -267,6 +268,51 @@
     }
   }
 
+  #terminal-panel &[data-variant="normal"][data-orientation="horizontal"] {
+    [data-slot="tabs-list"] {
+      height: 52px;
+      padding-right: 12px;
+      gap: 8px;
+    }
+
+    [data-slot="tabs-trigger-wrapper"] {
+      height: 28px;
+      padding-inline: 8px;
+      border: 0;
+      background-color: transparent;
+      box-shadow: none;
+
+      &::after {
+        display: none;
+      }
+
+      [data-slot="tabs-trigger"] {
+        padding: 0 !important;
+      }
+
+      &:has([data-slot="tabs-trigger-close-button"]) {
+        padding-right: 8px;
+      }
+
+      &:has([data-selected]) {
+        background-color: var(--v2-background-bg-layer-01);
+        box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted);
+      }
+    }
+
+    [data-slot="terminal-tab-title"] {
+      font-family: Inter, sans-serif;
+      font-style: normal;
+      font-weight: 440;
+      font-size: 13px;
+      line-height: 16px;
+      letter-spacing: -0.04px;
+      color: var(--v2-text-text-base);
+      font-variation-settings: "slnt" 0;
+      font-variant-numeric: tabular-nums;
+    }
+  }
+
   &[data-variant="alt"] {
     [data-slot="tabs-list"] {
       padding-left: 24px;