fix(app): terminal jank

This commit is contained in:
Adam
2026-03-10 13:00:14 -05:00
parent 85afaaa13d
commit 835a27cf51
4 changed files with 208 additions and 175 deletions
@@ -21,6 +21,8 @@ import { useLayout } from "@/context/layout"
import { usePlatform } from "@/context/platform" import { usePlatform } from "@/context/platform"
import { useServer } from "@/context/server" import { useServer } from "@/context/server"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal"
import { focusTerminalById } from "@/pages/session/helpers"
import { decode64 } from "@/utils/base64" import { decode64 } from "@/utils/base64"
import { Persist, persisted } from "@/utils/persist" import { Persist, persisted } from "@/utils/persist"
import { StatusPopover } from "../status-popover" import { StatusPopover } from "../status-popover"
@@ -229,6 +231,7 @@ export function SessionHeader() {
const sync = useSync() const sync = useSync()
const platform = usePlatform() const platform = usePlatform()
const language = useLanguage() const language = useLanguage()
const terminal = useTerminal()
const projectDirectory = createMemo(() => decode64(params.dir) ?? "") const projectDirectory = createMemo(() => decode64(params.dir) ?? "")
const project = createMemo(() => { const project = createMemo(() => {
@@ -296,6 +299,16 @@ export function SessionHeader() {
] as const ] 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 [prefs, setPrefs] = persisted(Persist.global("open.app"), createStore({ app: "finder" as OpenApp }))
const [menu, setMenu] = createStore({ open: false }) const [menu, setMenu] = createStore({ open: false })
const [openRequest, setOpenRequest] = createStore({ const [openRequest, setOpenRequest] = createStore({
@@ -617,15 +630,14 @@ export function SessionHeader() {
</div> </div>
</Show> </Show>
<div class="flex items-center gap-1"> <div class="flex items-center gap-1">
<div class="hidden md:flex items-center gap-1 shrink-0">
<TooltipKeybind <TooltipKeybind
title={language.t("command.terminal.toggle")} title={language.t("command.terminal.toggle")}
keybind={command.keybind("terminal.toggle")} keybind={command.keybind("terminal.toggle")}
> >
<Button <Button
variant="ghost" variant="ghost"
class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border" class="group/terminal-toggle titlebar-icon w-8 h-6 p-0 box-border shrink-0"
onClick={() => view().terminal.toggle()} onClick={toggleTerminal}
aria-label={language.t("command.terminal.toggle")} aria-label={language.t("command.terminal.toggle")}
aria-expanded={view().terminal.opened()} aria-expanded={view().terminal.opened()}
aria-controls="terminal-panel" aria-controls="terminal-panel"
@@ -650,6 +662,7 @@ export function SessionHeader() {
</Button> </Button>
</TooltipKeybind> </TooltipKeybind>
<div class="hidden md:flex items-center gap-1 shrink-0">
<TooltipKeybind <TooltipKeybind
title={language.t("command.review.toggle")} title={language.t("command.review.toggle")}
keybind={command.keybind("review.toggle")} keybind={command.keybind("review.toggle")}
+3 -2
View File
@@ -17,6 +17,7 @@ const TOGGLE_TERMINAL_ID = "terminal.toggle"
const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`" const DEFAULT_TOGGLE_TERMINAL_KEYBIND = "ctrl+`"
export interface TerminalProps extends ComponentProps<"div"> { export interface TerminalProps extends ComponentProps<"div"> {
pty: LocalPTY pty: LocalPTY
autoFocus?: boolean
onSubmit?: () => void onSubmit?: () => void
onCleanup?: (pty: Partial<LocalPTY> & { id: string }) => void onCleanup?: (pty: Partial<LocalPTY> & { id: string }) => void
onConnect?: () => void onConnect?: () => void
@@ -157,7 +158,7 @@ export const Terminal = (props: TerminalProps) => {
const language = useLanguage() const language = useLanguage()
const server = useServer() const server = useServer()
let container!: HTMLDivElement 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 id = local.pty.id
const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : "" const restore = typeof local.pty.buffer === "string" ? local.pty.buffer : ""
const restoreSize = const restoreSize =
@@ -386,7 +387,7 @@ export const Terminal = (props: TerminalProps) => {
handleLinkClick, handleLinkClick,
}) })
focusTerminal() if (local.autoFocus !== false) focusTerminal()
if (typeof document !== "undefined" && document.fonts) { if (typeof document !== "undefined" && document.fonts) {
document.fonts.ready.then(scheduleFit) document.fonts.ready.then(scheduleFit)
+8 -3
View File
@@ -32,8 +32,9 @@ import { useLayout } from "@/context/layout"
import { usePrompt } from "@/context/prompt" import { usePrompt } from "@/context/prompt"
import { useSDK } from "@/context/sdk" import { useSDK } from "@/context/sdk"
import { useSync } from "@/context/sync" import { useSync } from "@/context/sync"
import { useTerminal } from "@/context/terminal"
import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer" 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 { MessageTimeline } from "@/pages/session/message-timeline"
import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab" import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab"
import { resetSessionModel, syncSessionModel } from "@/pages/session/session-model-helpers" import { resetSessionModel, syncSessionModel } from "@/pages/session/session-model-helpers"
@@ -267,6 +268,7 @@ export default function Page() {
const sdk = useSDK() const sdk = useSDK()
const prompt = usePrompt() const prompt = usePrompt()
const comments = useComments() const comments = useComments()
const terminal = useTerminal()
const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>() const [searchParams, setSearchParams] = useSearchParams<{ prompt?: string }>()
createEffect(() => { createEffect(() => {
@@ -759,8 +761,11 @@ export default function Page() {
return return
} }
// Don't autofocus chat if desktop terminal panel is open // Prefer the open terminal over the composer when it can take focus
if (isDesktop() && view().terminal.opened()) return if (view().terminal.opened()) {
const id = terminal.active()
if (id && focusTerminalById(id)) return
}
// Only treat explicit scroll keys as potential "user scroll" gestures. // Only treat explicit scroll keys as potential "user scroll" gestures.
if (event.key === "PageUp" || event.key === "PageDown" || event.key === "Home" || event.key === "End") { if (event.key === "PageUp" || event.key === "PageDown" || event.key === "Home" || event.key === "End") {
@@ -1,6 +1,5 @@
import { For, Show, createEffect, createMemo, on, onCleanup } from "solid-js" import { For, Show, createEffect, createMemo, on, onCleanup } from "solid-js"
import { createStore } from "solid-js/store" import { createStore } from "solid-js/store"
import { createMediaQuery } from "@solid-primitives/media"
import { useParams } from "@solidjs/router" import { useParams } from "@solidjs/router"
import { Tabs } from "@opencode-ai/ui/tabs" import { Tabs } from "@opencode-ai/ui/tabs"
import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { ResizeHandle } from "@opencode-ai/ui/resize-handle"
@@ -27,12 +26,10 @@ export function TerminalPanel() {
const language = useLanguage() const language = useLanguage()
const command = useCommand() const command = useCommand()
const isDesktop = createMediaQuery("(min-width: 768px)")
const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`)
const view = createMemo(() => layout.view(sessionKey)) const view = createMemo(() => layout.view(sessionKey))
const opened = createMemo(() => view().terminal.opened()) const opened = createMemo(() => view().terminal.opened())
const open = createMemo(() => isDesktop() && opened())
const size = createSizing() const size = createSizing()
const height = createMemo(() => layout.terminal.height()) const height = createMemo(() => layout.terminal.height())
const close = () => view().terminal.close() const close = () => view().terminal.close()
@@ -41,6 +38,25 @@ export function TerminalPanel() {
const [store, setStore] = createStore({ const [store, setStore] = createStore({
autoCreated: false, autoCreated: false,
activeDraggable: undefined as string | undefined, 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(() => { createEffect(() => {
@@ -69,14 +85,14 @@ export function TerminalPanel() {
focusTerminalById(id) focusTerminalById(id)
const frame = requestAnimationFrame(() => { const frame = requestAnimationFrame(() => {
if (!open()) return if (!opened()) return
if (terminal.active() !== id) return if (terminal.active() !== id) return
focusTerminalById(id) focusTerminalById(id)
}) })
const timers = [120, 240].map((ms) => const timers = [120, 240].map((ms) =>
window.setTimeout(() => { window.setTimeout(() => {
if (!open()) return if (!opened()) return
if (terminal.active() !== id) return if (terminal.active() !== id) return
focusTerminalById(id) focusTerminalById(id)
}, ms), }, ms),
@@ -90,7 +106,7 @@ export function TerminalPanel() {
createEffect( createEffect(
on( on(
() => [open(), terminal.active()] as const, () => [opened(), terminal.active()] as const,
([next, id]) => { ([next, id]) => {
if (!next || !id) return if (!next || !id) return
const stop = focus(id) const stop = focus(id)
@@ -100,7 +116,7 @@ export function TerminalPanel() {
) )
createEffect(() => { createEffect(() => {
if (open()) return if (opened()) return
const active = document.activeElement const active = document.activeElement
if (!(active instanceof HTMLElement)) return if (!(active instanceof HTMLElement)) return
if (!root?.contains(active)) return if (!root?.contains(active)) return
@@ -165,37 +181,37 @@ export function TerminalPanel() {
} }
return ( return (
<Show when={isDesktop()}>
<div <div
ref={root} ref={root}
id="terminal-panel" id="terminal-panel"
role="region" role="region"
aria-label={language.t("terminal.title")} aria-label={language.t("terminal.title")}
aria-hidden={!open()} aria-hidden={!opened()}
inert={!open()} inert={!opened()}
class="relative w-full shrink-0 overflow-hidden" class="relative w-full shrink-0 overflow-hidden bg-background-stronger"
classList={{ 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": "transition-[height] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[height] motion-reduce:transition-none":
!size.active(), !size.active(),
}} }}
style={{ height: open() ? `${height()}px` : "0px" }} style={{ height: opened() ? `${pane()}px` : "0px" }}
> >
<div <div
class="absolute inset-x-0 top-0 flex flex-col border-t border-border-weak-base" class="absolute inset-x-0 top-0 flex flex-col"
classList={{ classList={{
"translate-y-0 opacity-100": open(), "translate-y-0": opened(),
"translate-y-full opacity-0 pointer-events-none": !open(), "translate-y-full pointer-events-none": !opened(),
"transition-[transform,opacity] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[transform,opacity] motion-reduce:transition-none": "transition-transform duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-transform motion-reduce:transition-none":
!size.active(), !size.active(),
}} }}
style={{ height: `${height()}px` }} style={{ height: `${pane()}px` }}
> >
<div onPointerDown={() => size.start()}> <div class="hidden md:block" onPointerDown={() => size.start()}>
<ResizeHandle <ResizeHandle
direction="vertical" direction="vertical"
size={height()} size={pane()}
min={100} min={100}
max={typeof window === "undefined" ? 1000 : window.innerHeight * 0.6} max={max()}
collapseThreshold={50} collapseThreshold={50}
onResize={(next) => { onResize={(next) => {
size.touch() size.touch()
@@ -222,9 +238,7 @@ export function TerminalPanel() {
{language.t("common.loading.ellipsis")} {language.t("common.loading.ellipsis")}
</div> </div>
</div> </div>
<div class="flex-1 flex items-center justify-center text-text-weak"> <div class="flex-1 flex items-center justify-center text-text-weak">{language.t("terminal.loading")}</div>
{language.t("terminal.loading")}
</div>
</div> </div>
} }
> >
@@ -278,6 +292,7 @@ export function TerminalPanel() {
<div id={`terminal-wrapper-${id}`} class="absolute inset-0"> <div id={`terminal-wrapper-${id}`} class="absolute inset-0">
<Terminal <Terminal
pty={pty()} pty={pty()}
autoFocus={opened()}
onConnect={() => terminal.trim(id)} onConnect={() => terminal.trim(id)}
onCleanup={terminal.update} onCleanup={terminal.update}
onConnectError={() => terminal.clone(id)} onConnectError={() => terminal.clone(id)}
@@ -310,6 +325,5 @@ export function TerminalPanel() {
</Show> </Show>
</div> </div>
</div> </div>
</Show>
) )
} }