فهرست منبع

fix(app): all panels transition

Adam 1 ماه پیش
والد
کامیت
73c9b685a7

+ 1 - 1
packages/app/src/pages/layout.tsx

@@ -2252,7 +2252,7 @@ export default function Layout(props: ParentProps) {
         >
           <main
             classList={{
-              "size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base xl:border-l xl:rounded-tl-[12px]": true,
+              "size-full overflow-x-hidden flex flex-col items-start contain-strict border-t border-border-weak-base bg-background-base xl:border-l xl:rounded-tl-[12px]": true,
             }}
           >
             <Show when={!autoselecting()} fallback={<div class="size-full" />}>

+ 23 - 12
packages/app/src/pages/session.tsx

@@ -33,7 +33,7 @@ import { usePrompt } from "@/context/prompt"
 import { useSDK } from "@/context/sdk"
 import { useSync } from "@/context/sync"
 import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
-import { createOpenReviewFile } from "@/pages/session/helpers"
+import { createOpenReviewFile, createSizing } from "@/pages/session/helpers"
 import { MessageTimeline } from "@/pages/session/message-timeline"
 import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
 import { createScrollSpy } from "@/pages/session/scroll-spy"
@@ -332,6 +332,7 @@ export default function Page() {
   )
 
   const isDesktop = createMediaQuery("(min-width: 768px)")
+  const size = createSizing()
   const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
   const desktopFileTreeOpen = createMemo(() => isDesktop() && layout.fileTree.opened())
   const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen())
@@ -1252,9 +1253,9 @@ export default function Page() {
         {/* Session panel */}
         <div
           classList={{
-            "@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger": true,
-            "flex-1": true,
-            "md:flex-none": desktopSidePanelOpen(),
+            "@container relative shrink-0 flex flex-col min-h-0 h-full bg-background-stronger flex-1 md:flex-none": true,
+            "transition-[width] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
+              !size.active(),
           }}
           style={{
             width: sessionPanelWidth(),
@@ -1351,17 +1352,27 @@ export default function Page() {
           />
 
           <Show when={desktopReviewOpen()}>
-            <ResizeHandle
-              direction="horizontal"
-              size={layout.session.width()}
-              min={450}
-              max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.45}
-              onResize={layout.session.resize}
-            />
+            <div onPointerDown={() => size.start()}>
+              <ResizeHandle
+                direction="horizontal"
+                size={layout.session.width()}
+                min={450}
+                max={typeof window === "undefined" ? 1000 : window.innerWidth * 0.45}
+                onResize={(width) => {
+                  size.touch()
+                  layout.session.resize(width)
+                }}
+              />
+            </div>
           </Show>
         </div>
 
-        <SessionSidePanel reviewPanel={reviewPanel} activeDiff={tree.activeDiff} focusReviewDiff={focusReviewDiff} />
+        <SessionSidePanel
+          reviewPanel={reviewPanel}
+          activeDiff={tree.activeDiff}
+          focusReviewDiff={focusReviewDiff}
+          size={size}
+        />
       </div>
 
       <TerminalPanel />

+ 103 - 1
packages/app/src/pages/session/helpers.ts

@@ -1,4 +1,5 @@
-import { batch } from "solid-js"
+import { batch, createEffect, on, onCleanup, onMount, type Accessor } from "solid-js"
+import { createStore } from "solid-js/store"
 
 export const focusTerminalById = (id: string) => {
   const wrapper = document.getElementById(`terminal-wrapper-${id}`)
@@ -69,3 +70,104 @@ export const getTabReorderIndex = (tabs: readonly string[], from: string, to: st
   if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) return undefined
   return toIndex
 }
+
+export const createSizing = () => {
+  const [state, setState] = createStore({ active: false })
+  let t: number | undefined
+
+  const stop = () => {
+    if (t !== undefined) {
+      clearTimeout(t)
+      t = undefined
+    }
+    setState("active", false)
+  }
+
+  const start = () => {
+    if (t !== undefined) {
+      clearTimeout(t)
+      t = undefined
+    }
+    setState("active", true)
+  }
+
+  onMount(() => {
+    window.addEventListener("pointerup", stop)
+    window.addEventListener("pointercancel", stop)
+    window.addEventListener("blur", stop)
+    onCleanup(() => {
+      window.removeEventListener("pointerup", stop)
+      window.removeEventListener("pointercancel", stop)
+      window.removeEventListener("blur", stop)
+    })
+  })
+
+  onCleanup(() => {
+    if (t !== undefined) clearTimeout(t)
+  })
+
+  return {
+    active: () => state.active,
+    start,
+    touch() {
+      start()
+      t = window.setTimeout(stop, 120)
+    },
+  }
+}
+
+export type Sizing = ReturnType<typeof createSizing>
+
+export const createPresence = (open: Accessor<boolean>, wait = 200) => {
+  const [state, setState] = createStore({
+    show: open(),
+    open: open(),
+  })
+  let frame: number | undefined
+  let t: number | undefined
+
+  const clear = () => {
+    if (frame !== undefined) {
+      cancelAnimationFrame(frame)
+      frame = undefined
+    }
+    if (t !== undefined) {
+      clearTimeout(t)
+      t = undefined
+    }
+  }
+
+  createEffect(
+    on(open, (next) => {
+      clear()
+
+      if (next) {
+        if (state.show) {
+          setState("open", true)
+          return
+        }
+
+        setState({ show: true, open: false })
+        frame = requestAnimationFrame(() => {
+          frame = undefined
+          setState("open", true)
+        })
+        return
+      }
+
+      if (!state.show) return
+      setState("open", false)
+      t = window.setTimeout(() => {
+        t = undefined
+        setState("show", false)
+      }, wait)
+    }),
+  )
+
+  onCleanup(clear)
+
+  return {
+    show: () => state.show,
+    open: () => state.open,
+  }
+}

+ 187 - 138
packages/app/src/pages/session/session-side-panel.tsx

@@ -23,7 +23,7 @@ import { useLayout } from "@/context/layout"
 import { useSync } from "@/context/sync"
 import { createFileTabListSync } from "@/pages/session/file-tab-scroll"
 import { FileTabContent } from "@/pages/session/file-tabs"
-import { createOpenSessionFileTab, getTabReorderIndex } from "@/pages/session/helpers"
+import { createOpenSessionFileTab, getTabReorderIndex, type Sizing } from "@/pages/session/helpers"
 import { StickyAddButton } from "@/pages/session/review-tab"
 import { setSessionHandoff } from "@/pages/session/handoff"
 
@@ -31,6 +31,7 @@ export function SessionSidePanel(props: {
   reviewPanel: () => JSX.Element
   activeDiff?: string
   focusReviewDiff: (path: string) => void
+  size: Sizing
 }) {
   const params = useParams()
   const layout = useLayout()
@@ -46,8 +47,20 @@ export function SessionSidePanel(props: {
   const view = createMemo(() => layout.view(sessionKey))
 
   const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened())
-  const open = createMemo(() => isDesktop() && (view().reviewPanel.opened() || layout.fileTree.opened()))
+  const fileOpen = createMemo(() => isDesktop() && layout.fileTree.opened())
+  const open = createMemo(() => reviewOpen() || fileOpen())
   const reviewTab = createMemo(() => isDesktop())
+  const panelWidth = createMemo(() => {
+    if (!open()) return "0px"
+    if (reviewOpen()) return `calc(100% - ${layout.session.width()}px)`
+    return `${layout.fileTree.width()}px`
+  })
+  const reviewWidth = createMemo(() => {
+    if (!reviewOpen()) return "0px"
+    if (!fileOpen()) return "100%"
+    return `calc(100% - ${layout.fileTree.width()}px)`
+  })
+  const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px"))
 
   const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined))
   const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : []))
@@ -210,146 +223,175 @@ export function SessionSidePanel(props: {
   })
 
   return (
-    <Show when={open()}>
+    <Show when={isDesktop()}>
       <aside
         id="review-panel"
         aria-label={language.t("session.panel.reviewAndFiles")}
-        class="relative min-w-0 h-full border-l border-border-weaker-base flex"
+        aria-hidden={!open()}
+        inert={!open()}
+        class="relative min-w-0 h-full flex shrink-0 overflow-hidden bg-background-base"
         classList={{
-          "flex-1": reviewOpen(),
-          "shrink-0": !reviewOpen(),
+          "opacity-100": open(),
+          "opacity-0 pointer-events-none": !open(),
+          "transition-[width,opacity] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
+            !props.size.active(),
         }}
-        style={{ width: reviewOpen() ? undefined : `${layout.fileTree.width()}px` }}
+        style={{ width: panelWidth() }}
       >
-        <Show when={reviewOpen()}>
-          <div class="flex-1 min-w-0 h-full">
-            <DragDropProvider
-              onDragStart={handleDragStart}
-              onDragEnd={handleDragEnd}
-              onDragOver={handleDragOver}
-              collisionDetector={closestCenter}
-            >
-              <DragDropSensors />
-              <ConstrainDragYAxis />
-              <Tabs value={activeTab()} onChange={openTab}>
-                <div class="sticky top-0 shrink-0 flex">
-                  <Tabs.List
-                    ref={(el: HTMLDivElement) => {
-                      const stop = createFileTabListSync({ el, contextOpen })
-                      onCleanup(stop)
-                    }}
-                  >
-                    <Show when={reviewTab()}>
-                      <Tabs.Trigger value="review">
-                        <div class="flex items-center gap-1.5">
-                          <div>{language.t("session.tab.review")}</div>
-                          <Show when={hasReview()}>
-                            <div>{reviewCount()}</div>
-                          </Show>
-                        </div>
-                      </Tabs.Trigger>
-                    </Show>
-                    <Show when={contextOpen()}>
-                      <Tabs.Trigger
-                        value="context"
-                        closeButton={
-                          <TooltipKeybind
-                            title={language.t("common.closeTab")}
-                            keybind={command.keybind("tab.close")}
-                            placement="bottom"
-                            gutter={10}
-                          >
-                            <IconButton
-                              icon="close-small"
-                              variant="ghost"
-                              class="h-5 w-5"
-                              onClick={() => tabs().close("context")}
-                              aria-label={language.t("common.closeTab")}
-                            />
-                          </TooltipKeybind>
-                        }
-                        hideCloseButton
-                        onMiddleClick={() => tabs().close("context")}
-                      >
-                        <div class="flex items-center gap-2">
-                          <SessionContextUsage variant="indicator" />
-                          <div>{language.t("session.tab.context")}</div>
-                        </div>
-                      </Tabs.Trigger>
-                    </Show>
-                    <SortableProvider ids={openedTabs()}>
-                      <For each={openedTabs()}>{(tab) => <SortableTab tab={tab} onTabClose={tabs().close} />}</For>
-                    </SortableProvider>
-                    <StickyAddButton>
-                      <TooltipKeybind
-                        title={language.t("command.file.open")}
-                        keybind={command.keybind("file.open")}
-                        class="flex items-center"
-                      >
-                        <IconButton
-                          icon="plus-small"
-                          variant="ghost"
-                          iconSize="large"
-                          class="!rounded-md"
-                          onClick={() => dialog.show(() => <DialogSelectFile mode="files" onOpenFile={showAllFiles} />)}
-                          aria-label={language.t("command.file.open")}
-                        />
-                      </TooltipKeybind>
-                    </StickyAddButton>
-                  </Tabs.List>
-                </div>
-
-                <Show when={reviewTab()}>
-                  <Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
-                    <Show when={activeTab() === "review"}>{props.reviewPanel()}</Show>
-                  </Tabs.Content>
-                </Show>
-
-                <Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
-                  <Show when={activeTab() === "empty"}>
-                    <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
-                      <div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
-                        <Mark class="w-14 opacity-10" />
-                        <div class="text-14-regular text-text-weak max-w-56">
-                          {language.t("session.files.selectToOpen")}
-                        </div>
-                      </div>
-                    </div>
+        <div class="size-full flex border-l border-border-weaker-base">
+          <div
+            aria-hidden={!reviewOpen()}
+            inert={!reviewOpen()}
+            class="relative min-w-0 h-full shrink-0 overflow-hidden bg-background-base"
+            classList={{
+              "opacity-100": reviewOpen(),
+              "opacity-0 pointer-events-none": !reviewOpen(),
+              "transition-[width,opacity] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
+                !props.size.active(),
+            }}
+            style={{ width: reviewWidth() }}
+          >
+            <div class="size-full min-w-0 h-full bg-background-base">
+              <DragDropProvider
+                onDragStart={handleDragStart}
+                onDragEnd={handleDragEnd}
+                onDragOver={handleDragOver}
+                collisionDetector={closestCenter}
+              >
+                <DragDropSensors />
+                <ConstrainDragYAxis />
+                <Tabs value={activeTab()} onChange={openTab}>
+                  <div class="sticky top-0 shrink-0 flex">
+                    <Tabs.List
+                      ref={(el: HTMLDivElement) => {
+                        const stop = createFileTabListSync({ el, contextOpen })
+                        onCleanup(stop)
+                      }}
+                    >
+                      <Show when={reviewTab()}>
+                        <Tabs.Trigger value="review">
+                          <div class="flex items-center gap-1.5">
+                            <div>{language.t("session.tab.review")}</div>
+                            <Show when={hasReview()}>
+                              <div>{reviewCount()}</div>
+                            </Show>
+                          </div>
+                        </Tabs.Trigger>
+                      </Show>
+                      <Show when={contextOpen()}>
+                        <Tabs.Trigger
+                          value="context"
+                          closeButton={
+                            <TooltipKeybind
+                              title={language.t("common.closeTab")}
+                              keybind={command.keybind("tab.close")}
+                              placement="bottom"
+                              gutter={10}
+                            >
+                              <IconButton
+                                icon="close-small"
+                                variant="ghost"
+                                class="h-5 w-5"
+                                onClick={() => tabs().close("context")}
+                                aria-label={language.t("common.closeTab")}
+                              />
+                            </TooltipKeybind>
+                          }
+                          hideCloseButton
+                          onMiddleClick={() => tabs().close("context")}
+                        >
+                          <div class="flex items-center gap-2">
+                            <SessionContextUsage variant="indicator" />
+                            <div>{language.t("session.tab.context")}</div>
+                          </div>
+                        </Tabs.Trigger>
+                      </Show>
+                      <SortableProvider ids={openedTabs()}>
+                        <For each={openedTabs()}>{(tab) => <SortableTab tab={tab} onTabClose={tabs().close} />}</For>
+                      </SortableProvider>
+                      <StickyAddButton>
+                        <TooltipKeybind
+                          title={language.t("command.file.open")}
+                          keybind={command.keybind("file.open")}
+                          class="flex items-center"
+                        >
+                          <IconButton
+                            icon="plus-small"
+                            variant="ghost"
+                            iconSize="large"
+                            class="!rounded-md"
+                            onClick={() =>
+                              dialog.show(() => <DialogSelectFile mode="files" onOpenFile={showAllFiles} />)
+                            }
+                            aria-label={language.t("command.file.open")}
+                          />
+                        </TooltipKeybind>
+                      </StickyAddButton>
+                    </Tabs.List>
+                  </div>
+
+                  <Show when={reviewTab()}>
+                    <Tabs.Content value="review" class="flex flex-col h-full overflow-hidden contain-strict">
+                      <Show when={activeTab() === "review"}>{props.reviewPanel()}</Show>
+                    </Tabs.Content>
                   </Show>
-                </Tabs.Content>
 
-                <Show when={contextOpen()}>
-                  <Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
-                    <Show when={activeTab() === "context"}>
+                  <Tabs.Content value="empty" class="flex flex-col h-full overflow-hidden contain-strict">
+                    <Show when={activeTab() === "empty"}>
                       <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
-                        <SessionContextTab />
+                        <div class="h-full px-6 pb-42 flex flex-col items-center justify-center text-center gap-6">
+                          <Mark class="w-14 opacity-10" />
+                          <div class="text-14-regular text-text-weak max-w-56">
+                            {language.t("session.files.selectToOpen")}
+                          </div>
+                        </div>
                       </div>
                     </Show>
                   </Tabs.Content>
-                </Show>
 
-                <Show when={activeFileTab()} keyed>
-                  {(tab) => <FileTabContent tab={tab} />}
-                </Show>
-              </Tabs>
-              <DragOverlay>
-                <Show when={store.activeDraggable} keyed>
-                  {(tab) => {
-                    const path = createMemo(() => file.pathFromTab(tab))
-                    return (
-                      <div data-component="tabs-drag-preview">
-                        <Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show>
-                      </div>
-                    )
-                  }}
-                </Show>
-              </DragOverlay>
-            </DragDropProvider>
+                  <Show when={contextOpen()}>
+                    <Tabs.Content value="context" class="flex flex-col h-full overflow-hidden contain-strict">
+                      <Show when={activeTab() === "context"}>
+                        <div class="relative pt-2 flex-1 min-h-0 overflow-hidden">
+                          <SessionContextTab />
+                        </div>
+                      </Show>
+                    </Tabs.Content>
+                  </Show>
+
+                  <Show when={activeFileTab()} keyed>
+                    {(tab) => <FileTabContent tab={tab} />}
+                  </Show>
+                </Tabs>
+                <DragOverlay>
+                  <Show when={store.activeDraggable} keyed>
+                    {(tab) => {
+                      const path = createMemo(() => file.pathFromTab(tab))
+                      return (
+                        <div data-component="tabs-drag-preview">
+                          <Show when={path()}>{(p) => <FileVisual active path={p()} />}</Show>
+                        </div>
+                      )
+                    }}
+                  </Show>
+                </DragOverlay>
+              </DragDropProvider>
+            </div>
           </div>
-        </Show>
 
-        <Show when={layout.fileTree.opened()}>
-          <div id="file-tree-panel" class="relative shrink-0 h-full" style={{ width: `${layout.fileTree.width()}px` }}>
+          <div
+            id="file-tree-panel"
+            aria-hidden={!fileOpen()}
+            inert={!fileOpen()}
+            class="relative min-w-0 h-full shrink-0 overflow-hidden"
+            classList={{
+              "opacity-100": fileOpen(),
+              "opacity-0 pointer-events-none": !fileOpen(),
+              "transition-[width,opacity] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none":
+                !props.size.active(),
+            }}
+            style={{ width: treeWidth() }}
+          >
             <div
               class="h-full flex flex-col overflow-hidden group/filetree"
               classList={{ "border-l border-border-weaker-base": reviewOpen() }}
@@ -412,18 +454,25 @@ export function SessionSidePanel(props: {
                 </Tabs.Content>
               </Tabs>
             </div>
-            <ResizeHandle
-              direction="horizontal"
-              edge="start"
-              size={layout.fileTree.width()}
-              min={200}
-              max={480}
-              collapseThreshold={160}
-              onResize={layout.fileTree.resize}
-              onCollapse={layout.fileTree.close}
-            />
+            <Show when={fileOpen()}>
+              <div onPointerDown={() => props.size.start()}>
+                <ResizeHandle
+                  direction="horizontal"
+                  edge="start"
+                  size={layout.fileTree.width()}
+                  min={200}
+                  max={480}
+                  collapseThreshold={160}
+                  onResize={(width) => {
+                    props.size.touch()
+                    layout.fileTree.resize(width)
+                  }}
+                  onCollapse={layout.fileTree.close}
+                />
+              </div>
+            </Show>
           </div>
-        </Show>
+        </div>
       </aside>
     </Show>
   )

+ 136 - 103
packages/app/src/pages/session/terminal-panel.tsx

@@ -17,7 +17,7 @@ import { useLanguage } from "@/context/language"
 import { useLayout } from "@/context/layout"
 import { useTerminal, type LocalPTY } from "@/context/terminal"
 import { terminalTabLabel } from "@/pages/session/terminal-label"
-import { focusTerminalById } from "@/pages/session/helpers"
+import { createPresence, createSizing, focusTerminalById } from "@/pages/session/helpers"
 import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff"
 
 export function TerminalPanel() {
@@ -33,8 +33,11 @@ export function TerminalPanel() {
 
   const opened = createMemo(() => view().terminal.opened())
   const open = createMemo(() => isDesktop() && opened())
+  const panel = createPresence(open)
+  const size = createSizing()
   const height = createMemo(() => layout.terminal.height())
   const close = () => view().terminal.close()
+  let root: HTMLDivElement | undefined
 
   const [store, setStore] = createStore({
     autoCreated: false,
@@ -67,7 +70,7 @@ export function TerminalPanel() {
     on(
       () => terminal.active(),
       (activeId) => {
-        if (!activeId || !open()) return
+        if (!activeId || !panel.open()) return
         if (document.activeElement instanceof HTMLElement) {
           document.activeElement.blur()
         }
@@ -76,6 +79,14 @@ export function TerminalPanel() {
     ),
   )
 
+  createEffect(() => {
+    if (panel.open()) return
+    const active = document.activeElement
+    if (!(active instanceof HTMLElement)) return
+    if (!root?.contains(active)) return
+    active.blur()
+  })
+
   createEffect(() => {
     const dir = params.dir
     if (!dir) return
@@ -133,120 +144,142 @@ export function TerminalPanel() {
   }
 
   return (
-    <Show when={open()}>
+    <Show when={panel.show()}>
       <div
+        ref={root}
         id="terminal-panel"
         role="region"
         aria-label={language.t("terminal.title")}
-        class="relative w-full flex flex-col shrink-0 border-t border-border-weak-base"
-        style={{ height: `${height()}px` }}
+        aria-hidden={!panel.open()}
+        inert={!panel.open()}
+        class="relative w-full shrink-0 overflow-hidden"
+        classList={{
+          "opacity-100": panel.open(),
+          "opacity-0 pointer-events-none": !panel.open(),
+          "transition-[height,opacity] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[height] motion-reduce:transition-none":
+            !size.active(),
+        }}
+        style={{ height: panel.open() ? `${height()}px` : "0px" }}
       >
-        <ResizeHandle
-          direction="vertical"
-          size={height()}
-          min={100}
-          max={typeof window === "undefined" ? 1000 : window.innerHeight * 0.6}
-          collapseThreshold={50}
-          onResize={layout.terminal.resize}
-          onCollapse={close}
-        />
-        <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 class="size-full flex flex-col border-t border-border-weak-base">
+          <div onPointerDown={() => size.start()}>
+            <ResizeHandle
+              direction="vertical"
+              size={height()}
+              min={100}
+              max={typeof window === "undefined" ? 1000 : window.innerHeight * 0.6}
+              collapseThreshold={50}
+              onResize={(next) => {
+                size.touch()
+                layout.terminal.resize(next)
+              }}
+              onCollapse={close}
+            />
+          </div>
+          <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>
-              <div class="flex-1 flex items-center justify-center text-text-weak">{language.t("terminal.loading")}</div>
-            </div>
-          }
-        >
-          <DragDropProvider
-            onDragStart={handleTerminalDragStart}
-            onDragEnd={handleTerminalDragEnd}
-            onDragOver={handleTerminalDragOver}
-            collisionDetector={closestCenter}
+            }
           >
-            <DragDropSensors />
-            <ConstrainDragYAxis />
-            <div class="flex flex-col h-full">
-              <Tabs
-                variant="alt"
-                value={terminal.active()}
-                onChange={(id) => terminal.open(id)}
-                class="!h-auto !flex-none"
-              >
-                <Tabs.List class="h-10 border-b border-border-weaker-base">
-                  <SortableProvider ids={ids()}>
-                    <For each={ids()}>
-                      {(id) => (
-                        <Show when={byId().get(id)}>
-                          {(pty) => <SortableTerminalTab terminal={pty()} onClose={close} />}
-                        </Show>
-                      )}
-                    </For>
-                  </SortableProvider>
-                  <div class="h-full flex items-center justify-center">
-                    <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>
-                  </div>
-                </Tabs.List>
-              </Tabs>
-              <div class="flex-1 min-h-0 relative">
-                <Show when={terminal.active()} keyed>
-                  {(id) => (
-                    <Show when={byId().get(id)}>
-                      {(pty) => (
-                        <div id={`terminal-wrapper-${id}`} class="absolute inset-0">
-                          <Terminal pty={pty()} onCleanup={terminal.update} onConnectError={() => terminal.clone(id)} />
+            <DragDropProvider
+              onDragStart={handleTerminalDragStart}
+              onDragEnd={handleTerminalDragEnd}
+              onDragOver={handleTerminalDragOver}
+              collisionDetector={closestCenter}
+            >
+              <DragDropSensors />
+              <ConstrainDragYAxis />
+              <div class="flex flex-col h-full">
+                <Tabs
+                  variant="alt"
+                  value={terminal.active()}
+                  onChange={(id) => terminal.open(id)}
+                  class="!h-auto !flex-none"
+                >
+                  <Tabs.List class="h-10 border-b border-border-weaker-base">
+                    <SortableProvider ids={ids()}>
+                      <For each={ids()}>
+                        {(id) => (
+                          <Show when={byId().get(id)}>
+                            {(pty) => <SortableTerminalTab terminal={pty()} onClose={close} />}
+                          </Show>
+                        )}
+                      </For>
+                    </SortableProvider>
+                    <div class="h-full flex items-center justify-center">
+                      <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>
+                    </div>
+                  </Tabs.List>
+                </Tabs>
+                <div class="flex-1 min-h-0 relative">
+                  <Show when={terminal.active()} keyed>
+                    {(id) => (
+                      <Show when={byId().get(id)}>
+                        {(pty) => (
+                          <div id={`terminal-wrapper-${id}`} class="absolute inset-0">
+                            <Terminal
+                              pty={pty()}
+                              onCleanup={terminal.update}
+                              onConnectError={() => terminal.clone(id)}
+                            />
+                          </div>
+                        )}
+                      </Show>
+                    )}
+                  </Show>
+                </div>
+              </div>
+              <DragOverlay>
+                <Show when={store.activeDraggable}>
+                  {(draggedId) => (
+                    <Show when={byId().get(draggedId())}>
+                      {(t) => (
+                        <div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
+                          {terminalTabLabel({
+                            title: t().title,
+                            titleNumber: t().titleNumber,
+                            t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
+                          })}
                         </div>
                       )}
                     </Show>
                   )}
                 </Show>
-              </div>
-            </div>
-            <DragOverlay>
-              <Show when={store.activeDraggable}>
-                {(draggedId) => (
-                  <Show when={byId().get(draggedId())}>
-                    {(t) => (
-                      <div class="relative p-1 h-10 flex items-center bg-background-stronger text-14-regular">
-                        {terminalTabLabel({
-                          title: t().title,
-                          titleNumber: t().titleNumber,
-                          t: language.t as (key: string, vars?: Record<string, string | number | boolean>) => string,
-                        })}
-                      </div>
-                    )}
-                  </Show>
-                )}
-              </Show>
-            </DragOverlay>
-          </DragDropProvider>
-        </Show>
+              </DragOverlay>
+            </DragDropProvider>
+          </Show>
+        </div>
       </div>
     </Show>
   )