fix(app): terminal jank

This commit is contained in:
Adam
2026-03-10 11:29:57 -05:00
parent 85afaaa13d
commit 835a27cf51
4 changed files with 208 additions and 175 deletions

View File

@@ -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") {

View File

@@ -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>
)
}