diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index 7f920e74e6e7..c769bf0da132 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -148,7 +148,7 @@ export interface MockServerConfig { onInboxChange?: (input: { sessionID: string; inboxID: string; action: "cancel" | "steer" | "queue" }) => void onBackground?: (input: { sessionID: string }) => void // Serves the session-scoped persistent PTY routes and their connect WebSockets (`e2e/utils/persistent-pty.ts`). - // Without it, those routes answer 503 like a server whose opencode-pty daemon cannot run. + // Without it, the server has a working daemon and no terminals; `capability: false` makes the routes answer 503. persistentPty?: MockPersistentPtyConfig // Answers 500 InvalidDirectory when a request names a directory this server does not own. strictDirectory?: boolean diff --git a/packages/app/src/runtime/plugin/side-region.tsx b/packages/app/src/runtime/plugin/side-region.tsx index f12dfe06c031..789d9d325366 100644 --- a/packages/app/src/runtime/plugin/side-region.tsx +++ b/packages/app/src/runtime/plugin/side-region.tsx @@ -255,8 +255,9 @@ export function PaneTabs(props: { selectionEvent.target instanceof Element && selectionEvent.target.closest('[role="tab"]') ) { - props.region.newTabs(props.pane.id).forEach((entry) => props.region.dismissNewTab(entry.id)) + // Select first: dismissing the only New tab with nothing selected lets Kobalte pick the first tab meanwhile. props.region.select(value) + props.region.newTabs(props.pane.id).forEach((entry) => props.region.dismissNewTab(entry.id)) } }} > diff --git a/packages/gui-plugins/src/terminal/model.ts b/packages/gui-plugins/src/terminal/model.ts index a4a9ee0c7afa..71f724d2826d 100644 --- a/packages/gui-plugins/src/terminal/model.ts +++ b/packages/gui-plugins/src/terminal/model.ts @@ -127,7 +127,11 @@ export function createTerminalModel(input: { if (supported) refreshServer(server) }) - .catch((error) => console.error("Failed to read server terminal support", error)) + .catch((error) => { + // Like the TUI: unknown support offers no terminals until the next connection checks again. + setState("capabilities", server.id, false) + console.error("Failed to read server terminal support", error) + }) // A restarted server replaces its data under the same ref, so reads that track `server.data` subscribe again. const watch = (server: ServerRef) => { diff --git a/packages/gui-plugins/src/terminal/panel.tsx b/packages/gui-plugins/src/terminal/panel.tsx index decedc9b46f9..99359ad1ab10 100644 --- a/packages/gui-plugins/src/terminal/panel.tsx +++ b/packages/gui-plugins/src/terminal/panel.tsx @@ -102,15 +102,18 @@ export default function TerminalPanel(props: { return opened() && id && terminal().focusRequested(id) ? { id } : undefined }, - (request) => - requestAnimationFrame(() => { + (request) => { + const focusFrame = requestAnimationFrame(() => { if (!opened() || active() !== request.id || !terminal().focusRequested(request.id)) return focusTerminalById(request.id) const input = document.getElementById(`terminal-wrapper-${request.id}`)?.querySelector("textarea") if (input === document.activeElement) terminal().consumeFocus(request.id) - }), + }) + + onCleanup(() => cancelAnimationFrame(focusFrame)) + }, ) // Terminals whose tabs the user moved to another pane render there, so this pane lets their screens go. diff --git a/packages/gui-plugins/src/terminal/tab.tsx b/packages/gui-plugins/src/terminal/tab.tsx index f6ec1f66b535..6230bd6cd952 100644 --- a/packages/gui-plugins/src/terminal/tab.tsx +++ b/packages/gui-plugins/src/terminal/tab.tsx @@ -74,6 +74,10 @@ export function TerminalTab(props: { } // Ending the edit unmounts the focused field, whose blur would save again, or save a cancelled title. + // The field renders inside the strip's tab trigger, so it keeps its keys, pointer and focus from the trigger: the + // tab list returns focus that enters it from outside to the trigger. + const isolate = (event: Event) => event.stopPropagation() + const save = () => { if (!store.editing || !store.blurEnabled) return @@ -141,8 +145,15 @@ export function TerminalTab(props: { value={store.title} onInput={(e) => setStore("title", e.currentTarget.value)} onBlur={save} - onKeyDown={keydown} - onMouseDown={(e) => e.stopPropagation()} + onKeyDown={(e) => { + isolate(e) + keydown(e) + }} + onKeyUp={isolate} + onFocusIn={isolate} + onPointerDown={isolate} + onMouseDown={isolate} + onClick={isolate} class="bg-transparent border-none outline-none min-w-0 flex-1 p-0 text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-base [font-weight:440] [font-variation-settings:'slnt'_0] [font-variant-numeric:tabular-nums]" /> diff --git a/packages/gui-plugins/src/terminal/terminal.tsx b/packages/gui-plugins/src/terminal/terminal.tsx index b2aa99a9e215..7726dbf2384b 100644 --- a/packages/gui-plugins/src/terminal/terminal.tsx +++ b/packages/gui-plugins/src/terminal/terminal.tsx @@ -307,6 +307,8 @@ export const Terminal = (props: TerminalProps) => { // A tab's rename field keeps focus, before and after a tick; it may take it before the terminal mounts. Other // fields, such as the command palette that created the terminal, give focus to it. const focus = () => { + if (disposed) return + const current = document.activeElement if (current instanceof HTMLInputElement && current.closest('[role="tablist"]')) return