fix(app): terminal improvements - focus, rename, error state, CSP (#9700)

This commit is contained in:
Halil Tezcan KARABULUT
2026-01-21 15:49:46 +03:00
committed by GitHub
parent 259b2a3c2d
commit 87d91c29e2
6 changed files with 348 additions and 62 deletions

View File

@@ -1,4 +1,16 @@
import { For, onCleanup, onMount, Show, Match, Switch, createMemo, createEffect, on, createSignal } from "solid-js"
import {
For,
Index,
onCleanup,
onMount,
Show,
Match,
Switch,
createMemo,
createEffect,
on,
createSignal,
} from "solid-js"
import { createMediaQuery } from "@solid-primitives/media"
import { createResizeObserver } from "@solid-primitives/resize-observer"
import { Dynamic } from "solid-js/web"
@@ -350,14 +362,7 @@ export default function Page() {
const current = activeMessage()
const currentIndex = current ? msgs.findIndex((m) => m.id === current.id) : -1
let targetIndex: number
if (currentIndex === -1) {
targetIndex = offset > 0 ? 0 : msgs.length - 1
} else {
targetIndex = currentIndex + offset
}
const targetIndex = currentIndex === -1 ? (offset > 0 ? 0 : msgs.length - 1) : currentIndex + offset
if (targetIndex < 0 || targetIndex >= msgs.length) return
scrollToMessage(msgs[targetIndex], "auto")
@@ -381,11 +386,16 @@ export default function Page() {
sync.session.sync(params.id)
})
const [autoCreated, setAutoCreated] = createSignal(false)
createEffect(() => {
if (!view().terminal.opened()) return
if (!terminal.ready()) return
if (terminal.all().length !== 0) return
if (!view().terminal.opened()) {
setAutoCreated(false)
return
}
if (!terminal.ready() || terminal.all().length !== 0 || autoCreated()) return
terminal.new()
setAutoCreated(true)
})
createEffect(
@@ -401,6 +411,32 @@ export default function Page() {
),
)
createEffect(
on(
() => terminal.active(),
(activeId) => {
if (!activeId || !view().terminal.opened()) return
// Immediately remove focus
if (document.activeElement instanceof HTMLElement) {
document.activeElement.blur()
}
const wrapper = document.getElementById(`terminal-wrapper-${activeId}`)
const element = wrapper?.querySelector('[data-component="terminal"]') as HTMLElement
if (!element) return
// Find and focus the ghostty textarea (the actual input element)
const textarea = element.querySelector("textarea") as HTMLTextAreaElement
if (textarea) {
textarea.focus()
return
}
// Fallback: focus container and dispatch pointer event
element.focus()
element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, cancelable: true }))
},
),
)
createEffect(
on(
() => visibleUserMessages().at(-1)?.id,
@@ -753,6 +789,9 @@ export default function Page() {
return
}
// Don't autofocus chat if terminal panel is open
if (view().terminal.opened()) return
if (event.key.length === 1 && event.key !== "Unidentified" && !(event.ctrlKey || event.metaKey)) {
inputRef?.focus()
}
@@ -800,6 +839,23 @@ export default function Page() {
const handleTerminalDragEnd = () => {
setStore("activeTerminalDraggable", undefined)
const activeId = terminal.active()
if (!activeId) return
setTimeout(() => {
const wrapper = document.getElementById(`terminal-wrapper-${activeId}`)
const element = wrapper?.querySelector('[data-component="terminal"]') as HTMLElement
if (!element) return
// Find and focus the ghostty textarea (the actual input element)
const textarea = element.querySelector("textarea") as HTMLTextAreaElement
if (textarea) {
textarea.focus()
return
}
// Fallback: focus container and dispatch pointer event
element.focus()
element.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true, cancelable: true }))
}, 0)
}
const contextOpen = createMemo(() => tabs().active() === "context" || tabs().all().includes("context"))
@@ -1855,7 +1911,7 @@ export default function Page() {
<Show when={isDesktop() && view().terminal.opened()}>
<div
class="relative w-full flex-col shrink-0 border-t border-border-weak-base"
class="relative w-full flex flex-col shrink-0 border-t border-border-weak-base"
style={{ height: `${layout.terminal.height()}px` }}
>
<ResizeHandle
@@ -1896,29 +1952,101 @@ export default function Page() {
>
<DragDropSensors />
<ConstrainDragYAxis />
<Tabs variant="alt" value={terminal.active()} onChange={terminal.open}>
<Tabs.List class="h-10">
<SortableProvider ids={terminal.all().map((t: LocalPTY) => t.id)}>
<For each={terminal.all()}>{(pty) => <SortableTerminalTab terminal={pty} />}</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} />
</TooltipKeybind>
</div>
</Tabs.List>
<For each={terminal.all()}>
{(pty) => (
<Tabs.Content value={pty.id}>
<Terminal pty={pty} onCleanup={terminal.update} onConnectError={() => terminal.clone(pty.id)} />
</Tabs.Content>
)}
</For>
</Tabs>
<div class="flex flex-col h-full">
<Tabs
variant="alt"
value={terminal.active()}
onChange={(id) => {
// Only switch tabs if not in the middle of starting edit mode
terminal.open(id)
}}
class="!h-auto !flex-none"
>
<Tabs.List class="h-10">
<SortableProvider ids={terminal.all().map((t: LocalPTY) => t.id)}>
<For each={terminal.all()}>
{(pty) => (
<SortableTerminalTab
terminal={pty}
onClose={() => {
view().terminal.close()
setAutoCreated(false)
}}
/>
)}
</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} />
</TooltipKeybind>
</div>
</Tabs.List>
</Tabs>
<div class="flex-1 min-h-0 relative">
<For each={terminal.all()}>
{(pty) => {
const [dismissed, setDismissed] = createSignal(false)
return (
<div
id={`terminal-wrapper-${pty.id}`}
class="absolute inset-0"
style={{
display: terminal.active() === pty.id ? "block" : "none",
}}
>
<Terminal
pty={pty}
onCleanup={(data) => terminal.update({ ...data, id: pty.id })}
onConnectError={() => {
terminal.update({ id: pty.id, error: true })
}}
/>
<Show when={pty.error && !dismissed()}>
<div
class="absolute inset-0 flex flex-col items-center justify-center gap-3"
style={{ "background-color": "rgba(0, 0, 0, 0.6)" }}
>
<Icon
name="circle-ban-sign"
class="w-8 h-8"
style={{ color: "rgba(239, 68, 68, 0.8)" }}
/>
<div class="text-center" style={{ color: "rgba(255, 255, 255, 0.7)" }}>
<div class="text-14-semibold mb-1">Connection Lost</div>
<div class="text-12-regular" style={{ color: "rgba(255, 255, 255, 0.5)" }}>
The terminal connection was interrupted. This can happen when the server restarts.
</div>
</div>
<button
class="mt-2 px-3 py-1.5 text-12-medium rounded-lg transition-colors"
style={{
"background-color": "rgba(255, 255, 255, 0.1)",
color: "rgba(255, 255, 255, 0.7)",
border: "1px solid rgba(255, 255, 255, 0.2)",
}}
onMouseEnter={(e) =>
(e.currentTarget.style.backgroundColor = "rgba(255, 255, 255, 0.15)")
}
onMouseLeave={(e) =>
(e.currentTarget.style.backgroundColor = "rgba(255, 255, 255, 0.1)")
}
onClick={() => setDismissed(true)}
>
Dismiss
</button>
</div>
</Show>
</div>
)
}}
</For>
</div>
</div>
<DragOverlay>
<Show when={store.activeTerminalDraggable}>
{(draggedId) => {