Parcourir la source

fix(app): terminal jank

Adam il y a 5 mois
Parent
commit
835a27cf51

+ 44 - 31
packages/app/src/components/session/session-header.tsx

@@ -21,6 +21,8 @@ import { useLayout } from "@/context/layout"
 import { usePlatform } from "@/context/platform"
 import { useServer } from "@/context/server"
 import { useSync } from "@/context/sync"
+import { useTerminal } from "@/context/terminal"
+import { focusTerminalById } from "@/pages/session/helpers"
 import { decode64 } from "@/utils/base64"
 import { Persist, persisted } from "@/utils/persist"
 import { StatusPopover } from "../status-popover"
@@ -229,6 +231,7 @@ export function SessionHeader() {
   const sync = useSync()
   const platform = usePlatform()
   const language = useLanguage()
+  const terminal = useTerminal()
 
   const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
   const project = createMemo(() => {
@@ -296,6 +299,16 @@ export function SessionHeader() {
     ] as const
   })
 
+  const toggleTerminal = () => {
+    const next = !view().terminal.opened()
+    view().terminal.toggle()
+    if (!next) return
+
+    const id = terminal.active()
+    if (!id) return
+    focusTerminalById(id)
+  }
+
   const [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp }))
   const [menu, setMenu] = createStore({ open: false })
   const [openRequest, setOpenRequest] = createStore({
@@ -617,39 +630,39 @@ export function SessionHeader() {
                 </div>
               </Show>
               <div class="flex items-center gap-1">
-                <div class="hidden md:flex items-center gap-1 shrink-0">
-                  <TooltipKeybind
-                    title={language.t("command.terminal.toggle")}
-                    keybind={command.keybind("terminal.toggle")}
+                <TooltipKeybind
+                  title={language.t("command.terminal.toggle")}
+                  keybind={command.keybind("terminal.toggle")}
+                >
+                  <Button
+                    variant="ghost"
+                    class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
+                    onClick={toggleTerminal}
+                    aria-label={language.t("command.terminal.toggle")}
+                    aria-expanded={view().terminal.opened()}
+                    aria-controls="terminal-panel"
                   >
-                    <Button
-                      variant="ghost"
-                      class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border"
-                      onClick={() => view().terminal.toggle()}
-                      aria-label={language.t("command.terminal.toggle")}
-                      aria-expanded={view().terminal.opened()}
-                      aria-controls="terminal-panel"
-                    >
-                      <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
-                        <Icon
-                          size="small"
-                          name={view().terminal.opened() ? "layout-bottom-partial" : "layout-bottom"}
-                          class="group-hover/terminal-toggle:hidden"
-                        />
-                        <Icon
-                          size="small"
-                          name="layout-bottom-partial"
-                          class="hidden group-hover/terminal-toggle:inline-block"
-                        />
-                        <Icon
-                          size="small"
-                          name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-partial"}
-                          class="hidden group-active/terminal-toggle:inline-block"
-                        />
-                      </div>
-                    </Button>
-                  </TooltipKeybind>
+                    <div class="relative flex items-center justify-center size-4 [&>*]:absolute [&>*]:inset-0">
+                      <Icon
+                        size="small"
+                        name={view().terminal.opened() ? "layout-bottom-partial" : "layout-bottom"}
+                        class="group-hover/terminal-toggle:hidden"
+                      />
+                      <Icon
+                        size="small"
+                        name="layout-bottom-partial"
+                        class="hidden group-hover/terminal-toggle:inline-block"
+                      />
+                      <Icon
+                        size="small"
+                        name={view().terminal.opened() ? "layout-bottom" : "layout-bottom-partial"}
+                        class="hidden group-active/terminal-toggle:inline-block"
+                      />
+                    </div>
+                  </Button>
+                </TooltipKeybind>
 
+                <div class="hidden md:flex items-center gap-1 shrink-0">
                   <TooltipKeybind
                     title={language.t("command.review.toggle")}
                     keybind={command.keybind("review.toggle")}

+ 3 - 2
packages/app/src/components/terminal.tsx

@@ -17,6 +17,7 @@ const TOGGLE_TERMINAL_ID = "terminal.toggle"
 const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
 export interface TerminalProps extends ComponentProps<"div"> {
   pty: LocalPTY
+  autoFocus?: boolean
   onSubmit?: () => void
   onCleanup?: (pty: Partial<LocalPTY> & { id: string }) => void
   onConnect?: () => void
@@ -157,7 +158,7 @@ export const Terminal = (props: TerminalProps) => {
   const language = useLanguage()
   const server = useServer()
   let container!: HTMLDivElement
-  const [local, others] = splitProps(props, ["pty", "class", "classList", "onConnect", "onConnectError"])
+  const [local, others] = splitProps(props, ["pty", "class", "classList", "autoFocus", "onConnect", "onConnectError"])
   const id = local.pty.id
   const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
   const restoreSize =
@@ -386,7 +387,7 @@ export const Terminal = (props: TerminalProps) => {
         handleLinkClick,
       })
 
-      focusTerminal()
+      if (local.autoFocus !== false) focusTerminal()
 
       if (typeof document !== "undefined" && document.fonts) {
         document.fonts.ready.then(scheduleFit)

+ 8 - 3
packages/app/src/pages/session.tsx

@@ -32,8 +32,9 @@ import { useLayout } from "@/context/layout"
 import { usePrompt } from "@/context/prompt"
 import { useSDK } from "@/context/sdk"
 import { useSync } from "@/context/sync"
+import { useTerminal } from "@/context/terminal"
 import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer"
-import { createOpenReviewFile, createSizing } from "@/pages/session/helpers"
+import { createOpenReviewFile, createSizing, focusTerminalById } from "@/pages/session/helpers"
 import { MessageTimeline } from "@/pages/session/message-timeline"
 import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
 import { resetSessionModel, syncSessionModel } from "@/pages/session/session-model-helpers"
@@ -267,6 +268,7 @@ export default function Page() {
   const sdk = useSDK()
   const prompt = usePrompt()
   const comments = useComments()
+  const terminal = useTerminal()
   const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
 
   createEffect(() => {
@@ -759,8 +761,11 @@ export default function Page() {
       return
     }
 
-    // Don't autofocus chat if desktop terminal panel is open
-    if (isDesktop() && view().terminal.opened()) return
+    // Prefer the open terminal over the composer when it can take focus
+    if (view().terminal.opened()) {
+      const id = terminal.active()
+      if (id && focusTerminalById(id)) return
+    }
 
     // Only treat explicit scroll keys as potential "user scroll" gestures.
     if (event.key === "PageUp" || event.key === "PageDown" || event.key === "Home" || event.key === "End") {

+ 153 - 139
packages/app/src/pages/session/terminal-panel.tsx

@@ -1,6 +1,5 @@
 import { For, Show, createEffect, createMemo, on, onCleanup } from "solid-js"
 import { createStore } from "solid-js/store"
-import { createMediaQuery } from "@solid-primitives/media"
 import { useParams } from "@solidjs/router"
 import { Tabs } from "@opencode-ai/ui/tabs"
 import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
@@ -27,12 +26,10 @@ export function TerminalPanel() {
   const language = useLanguage()
   const command = useCommand()
 
-  const isDesktop = createMediaQuery("(min-width: 768px)")
   const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
   const view = createMemo(() => layout.view(sessionKey))
 
   const opened = createMemo(() => view().terminal.opened())
-  const open = createMemo(() => isDesktop() && opened())
   const size = createSizing()
   const height = createMemo(() => layout.terminal.height())
   const close = () => view().terminal.close()
@@ -41,6 +38,25 @@ export function TerminalPanel() {
   const [store, setStore] = createStore({
     autoCreated: false,
     activeDraggable: undefined as string | undefined,
+    view: typeof window === "undefined" ? 1000 : (window.visualViewport?.height ?? window.innerHeight),
+  })
+
+  const max = () => store.view * 0.6
+  const pane = () => Math.min(height(), max())
+
+  createEffect(() => {
+    if (typeof window === "undefined") return
+
+    const sync = () => setStore("view", window.visualViewport?.height ?? window.innerHeight)
+    const port = window.visualViewport
+
+    sync()
+    window.addEventListener("resize", sync)
+    port?.addEventListener("resize", sync)
+    onCleanup(() => {
+      window.removeEventListener("resize", sync)
+      port?.removeEventListener("resize", sync)
+    })
   })
 
   createEffect(() => {
@@ -69,14 +85,14 @@ export function TerminalPanel() {
     focusTerminalById(id)
 
     const frame = requestAnimationFrame(() => {
-      if (!open()) return
+      if (!opened()) return
       if (terminal.active() !== id) return
       focusTerminalById(id)
     })
 
     const timers = [120, 240].map((ms) =>
       window.setTimeout(() => {
-        if (!open()) return
+        if (!opened()) return
         if (terminal.active() !== id) return
         focusTerminalById(id)
       }, ms),
@@ -90,7 +106,7 @@ export function TerminalPanel() {
 
   createEffect(
     on(
-      () => [open(), terminal.active()] as const,
+      () => [opened(), terminal.active()] as const,
       ([next, id]) => {
         if (!next || !id) return
         const stop = focus(id)
@@ -100,7 +116,7 @@ export function TerminalPanel() {
   )
 
   createEffect(() => {
-    if (open()) return
+    if (opened()) return
     const active = document.activeElement
     if (!(active instanceof HTMLElement)) return
     if (!root?.contains(active)) return
@@ -165,151 +181,149 @@ export function TerminalPanel() {
   }
 
   return (
-    <Show when={isDesktop()}>
+    <div
+      ref={root}
+      id="terminal-panel"
+      role="region"
+      aria-label={language.t("terminal.title")}
+      aria-hidden={!opened()}
+      inert={!opened()}
+      class="relative w-full shrink-0 overflow-hidden bg-background-stronger"
+      classList={{
+        "border-t border-border-weak-base": opened(),
+        "transition-[height] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[height] motion-reduce:transition-none":
+          !size.active(),
+      }}
+      style={{ height: opened() ? `${pane()}px` : "0px" }}
+    >
       <div
-        ref={root}
-        id="terminal-panel"
-        role="region"
-        aria-label={language.t("terminal.title")}
-        aria-hidden={!open()}
-        inert={!open()}
-        class="relative w-full shrink-0 overflow-hidden"
+        class="absolute inset-x-0 top-0 flex flex-col"
         classList={{
-          "transition-[height] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[height] motion-reduce:transition-none":
+          "translate-y-0": opened(),
+          "translate-y-full pointer-events-none": !opened(),
+          "transition-transform duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform motion-reduce:transition-none":
             !size.active(),
         }}
-        style={{ height: open() ? `${height()}px` : "0px" }}
+        style={{ height: `${pane()}px` }}
       >
-        <div
-          class="absolute inset-x-0 top-0 flex flex-col border-t border-border-weak-base"
-          classList={{
-            "translate-y-0 opacity-100": open(),
-            "translate-y-full opacity-0 pointer-events-none": !open(),
-            "transition-[transform,opacity] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[transform,opacity] motion-reduce:transition-none":
-              !size.active(),
-          }}
-          style={{ height: `${height()}px` }}
-        >
-          <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>
-            }
-          >
-            <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 class="hidden md:block" onPointerDown={() => size.start()}>
+          <ResizeHandle
+            direction="vertical"
+            size={pane()}
+            min={100}
+            max={max()}
+            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>
-                  </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()}
-                              onConnect={() => terminal.trim(id)}
-                              onCleanup={terminal.update}
-                              onConnectError={() => terminal.clone(id)}
-                            />
-                          </div>
-                        )}
-                      </Show>
-                    )}
-                  </Show>
+                  )}
+                </For>
+                <div class="flex-1" />
+                <div class="text-text-weak pr-2">
+                  {language.t("common.loading")}
+                  {language.t("common.loading.ellipsis")}
                 </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 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()}
+                            autoFocus={opened()}
+                            onConnect={() => terminal.trim(id)}
+                            onCleanup={terminal.update}
+                            onConnectError={() => terminal.clone(id)}
+                          />
                         </div>
                       )}
                     </Show>
                   )}
                 </Show>
-              </DragOverlay>
-            </DragDropProvider>
-          </Show>
-        </div>
+              </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>
       </div>
-    </Show>
+    </div>
   )
 }