diff options
| author | Adam <[email protected]> | 2026-03-10 11:29:57 -0500 |
|---|---|---|
| committer | Adam <[email protected]> | 2026-03-10 13:00:14 -0500 |
| commit | 835a27cf517fae5d9952c30989de8be8f760d7a5 (patch) | |
| tree | 94c5ec763e09517d42ce86a960264854c139f2f3 | |
| parent | 85afaaa13d693f400d8ec8e257fec086a58b68c1 (diff) | |
| download | opencode-835a27cf517fae5d9952c30989de8be8f760d7a5.tar.gz opencode-835a27cf517fae5d9952c30989de8be8f760d7a5.zip | |
fix(app): terminal jank
| -rw-r--r-- | packages/app/src/components/session/session-header.tsx | 75 | ||||
| -rw-r--r-- | packages/app/src/components/terminal.tsx | 5 | ||||
| -rw-r--r-- | packages/app/src/pages/session.tsx | 11 | ||||
| -rw-r--r-- | packages/app/src/pages/session/terminal-panel.tsx | 292 |
4 files changed, 208 insertions, 175 deletions
diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 9b4551584..97f0530e9 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/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")} diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 9e5f12ee4..120af0a17 100644 --- a/packages/app/src/components/terminal.tsx +++ b/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) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index c1552ad02..79c8d42f5 100644 --- a/packages/app/src/pages/session.tsx +++ b/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") { diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx index 19a656b53..a6c3929c1 100644 --- a/packages/app/src/pages/session/terminal-panel.tsx +++ b/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> ) } |
