From 156836264973ba4fa0bc65e48b93da7a2c131d06 Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Wed, 1 Jul 2026 15:11:02 +0530 Subject: [PATCH 01/11] basic layout --- packages/app/src/pages/session.tsx | 83 +++++++++++++++---- .../src/pages/session/session-side-panel.tsx | 5 +- .../app/src/pages/session/terminal-panel.tsx | 31 ++++--- 3 files changed, 90 insertions(+), 29 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 36be14546bbf..741df38da13c 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -426,6 +426,8 @@ export default function Page() { const isDesktop = createMediaQuery("(min-width: 768px)") const size = createSizing() const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened()) + const desktopTerminalOpen = createMemo(() => isDesktop() && view().terminal.opened()) + const desktopReviewAndTerminalOpen = createMemo(() => desktopReviewOpen() && desktopTerminalOpen()) const desktopFileTreeOpen = createMemo( () => isDesktop() && @@ -434,10 +436,11 @@ export default function Page() { opened: layout.fileTree.opened(), }), ) - const desktopSidePanelOpen = createMemo(() => desktopReviewOpen() || desktopFileTreeOpen()) + const desktopSessionResizeOpen = createMemo(() => desktopReviewOpen() || desktopTerminalOpen()) + const desktopSidePanelOpen = createMemo(() => desktopSessionResizeOpen() || desktopFileTreeOpen()) const sessionPanelWidth = createMemo(() => { if (!desktopSidePanelOpen()) return "100%" - if (desktopReviewOpen()) return `${layout.session.width()}px` + if (desktopSessionResizeOpen()) return `${layout.session.width()}px` return `calc(100% - ${layout.fileTree.width()}px)` }) const centered = createMemo(() => isDesktop() && !desktopReviewOpen()) @@ -2022,7 +2025,7 @@ export default function Page() { )} - +
size.start()}>
- - + + + + + + + + + + } + > +
+ + +
size.start()}> + { + size.touch() + layout.terminal.resize(height) + }} + /> +
- + +
+
+ ) } diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 097df2219376..690262102039 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -51,6 +51,7 @@ export function SessionSidePanel(props: { focusReviewDiff: (path: string) => void reviewSnap: boolean size: Sizing + stacked?: boolean }) { const layout = useLayout() const settings = useSettings() @@ -219,8 +220,10 @@ export function SessionSidePanel(props: { aria-label={language.t("session.panel.reviewAndFiles")} aria-hidden={!open()} inert={!open()} - class="relative min-w-0 h-full flex shrink-0 overflow-hidden bg-background-base" + class="relative min-w-0 flex overflow-hidden bg-background-base" classList={{ + "h-full shrink-0": !props.stacked, + "min-h-0 flex-1": props.stacked, "pointer-events-none": !open(), "transition-[width] duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none": !props.size.active() && !props.reviewSnap, diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx index 54195e2ebc85..e3dc8d044816 100644 --- a/packages/app/src/pages/session/terminal-panel.tsx +++ b/packages/app/src/pages/session/terminal-panel.tsx @@ -1,6 +1,7 @@ import { For, Show, createEffect, createMemo, on, onCleanup, onMount } from "solid-js" import { createStore } from "solid-js/store" import { makeEventListener } from "@solid-primitives/event-listener" +import { createMediaQuery } from "@solid-primitives/media" import { Tabs } from "@opencode-ai/ui/tabs" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { IconButton } from "@opencode-ai/ui/icon-button" @@ -22,7 +23,7 @@ import { createSizing, focusTerminalById } from "@/pages/session/helpers" import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff" import { useSessionLayout } from "@/pages/session/session-layout" -export function TerminalPanel() { +export function TerminalPanel(props: { stacked?: boolean } = {}) { const delays = [120, 240] const layout = useLayout() const terminal = useTerminal() @@ -32,6 +33,7 @@ export function TerminalPanel() { const settings = useSettings() const { workspaceKey, view } = useSessionLayout() + const isDesktop = createMediaQuery("(min-width: 768px)") const opened = createMemo(() => view().terminal.opened()) const size = createSizing() const height = createMemo(() => layout.terminal.height()) @@ -47,6 +49,9 @@ export function TerminalPanel() { const max = () => store.view * 0.6 const pane = () => Math.min(height(), max()) + const stacked = createMemo(() => isDesktop() && props.stacked) + const panelHeight = createMemo(() => (isDesktop() ? (stacked() ? `${pane()}px` : "100%") : opened() ? `${pane()}px` : "0px")) + const contentHeight = createMemo(() => (isDesktop() ? (stacked() ? `${pane()}px` : "100%") : `${pane()}px`)) onMount(() => { if (typeof window === "undefined") return @@ -195,21 +200,25 @@ export function TerminalPanel() { } return ( -
- + ) } From 8560727705c0c1ba51f96b62ec96a3df4d7efcf7 Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:30:41 +0530 Subject: [PATCH 02/11] tabs --- .../session/session-sortable-terminal-tab.tsx | 160 ++++++++---------- .../app/src/pages/session/terminal-panel.tsx | 102 +++++------ packages/ui/src/components/tabs.css | 48 +++++- 3 files changed, 165 insertions(+), 145 deletions(-) diff --git a/packages/app/src/components/session/session-sortable-terminal-tab.tsx b/packages/app/src/components/session/session-sortable-terminal-tab.tsx index 2d88ed180645..900b6457f07c 100644 --- a/packages/app/src/components/session/session-sortable-terminal-tab.tsx +++ b/packages/app/src/components/session/session-sortable-terminal-tab.tsx @@ -1,25 +1,35 @@ import type { JSX } from "solid-js" import { Show, createEffect, onCleanup } from "solid-js" import { createStore } from "solid-js/store" -import { createSortable } from "@thisbeyond/solid-dnd" +import { useSortable } from "@dnd-kit/solid/sortable" import { IconButton } from "@opencode-ai/ui/icon-button" +import { TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Tabs } from "@opencode-ai/ui/tabs" -import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" -import { Icon } from "@opencode-ai/ui/icon" +import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" import { isDefaultTitle as isDefaultTerminalTitle } from "@/context/terminal-title" import { useTerminal, type LocalPTY } from "@/context/terminal" import { useLanguage } from "@/context/language" +import { useCommand } from "@/context/command" import { focusTerminalById } from "@/pages/session/helpers" -export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => void }): JSX.Element { +const sortableTransition = { duration: 0 } + +export function SortableTerminalTab(props: { terminal: LocalPTY; index: () => number; onClose?: () => void }): JSX.Element { const terminal = useTerminal() const language = useLanguage() - const sortable = createSortable(props.terminal.id) + const command = useCommand() + const sortable = useSortable({ + get id() { + return props.terminal.id + }, + get index() { + return props.index() + }, + transition: sortableTransition, + }) const [store, setStore] = createStore({ editing: false, title: props.terminal.title, - menuOpen: false, - menuPosition: { x: 0, y: 0 }, blurEnabled: false, }) let input: HTMLInputElement | undefined @@ -89,12 +99,6 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => } } - const menu = (e: MouseEvent) => { - e.preventDefault() - setStore("menuPosition", { x: e.clientX, y: e.clientY }) - setStore("menuOpen", true) - } - createEffect(() => { if (!store.editing) return if (!input) return @@ -114,80 +118,64 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => return (
-
- e.preventDefault()} - onContextMenu={menu} - class="!shadow-none" - classes={{ - button: "border-0 outline-none focus:outline-none focus-visible:outline-none !shadow-none !ring-0", - }} - closeButton={ - { - e.stopPropagation() - close() - }} - aria-label={language.t("terminal.close")} - /> - } - > - - {label()} - - - -
- setStore("title", e.currentTarget.value)} - onBlur={save} - onKeyDown={keydown} - onMouseDown={(e) => e.stopPropagation()} - class="bg-transparent border-none outline-none text-sm min-w-0 flex-1" - /> -
-
- setStore("menuOpen", open)}> - - { - if (!editRequested) return - e.preventDefault() - editRequested = false - requestAnimationFrame(() => edit()) - }} - > - (editRequested = true)}> - - {language.t("common.rename")} - - - - {language.t("common.close")} - - - - -
+ + + + { + e.stopPropagation() + close() + }} + aria-label={language.t("terminal.close")} + /> + + } + hideCloseButton + onMiddleClick={close} + > + + {label()} + + + +
+ setStore("title", e.currentTarget.value)} + onBlur={save} + onKeyDown={keydown} + onMouseDown={(e) => e.stopPropagation()} + 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]" + /> +
+
+
+ + { + if (!editRequested) return + e.preventDefault() + editRequested = false + requestAnimationFrame(() => edit()) + }} + > + (editRequested = true)}>{language.t("common.rename")} + {language.t("common.close")} + + +
) } diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx index e3dc8d044816..d0adc36b52df 100644 --- a/packages/app/src/pages/session/terminal-panel.tsx +++ b/packages/app/src/pages/session/terminal-panel.tsx @@ -2,13 +2,16 @@ import { For, Show, createEffect, createMemo, on, onCleanup, onMount } from "sol import { createStore } from "solid-js/store" import { makeEventListener } from "@solid-primitives/event-listener" import { createMediaQuery } from "@solid-primitives/media" +import { DragDropProvider, PointerSensor } from "@dnd-kit/solid" +import { isSortable } from "@dnd-kit/solid/sortable" +import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom" +import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers" +import { RestrictToElement } from "@dnd-kit/dom/modifiers" import { Tabs } from "@opencode-ai/ui/tabs" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { IconButton } from "@opencode-ai/ui/icon-button" -import { TooltipKeybind } from "@opencode-ai/ui/tooltip" -import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" -import type { DragEvent } from "@thisbeyond/solid-dnd" -import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" import { SortableTerminalTab } from "@/components/session" import { Terminal } from "@/components/terminal" @@ -39,10 +42,10 @@ export function TerminalPanel(props: { stacked?: boolean } = {}) { const height = createMemo(() => layout.terminal.height()) const close = () => view().terminal.close() let root: HTMLDivElement | undefined + let tabList: HTMLDivElement | undefined const [store, setStore] = createStore({ autoCreated: false, - activeDraggable: undefined as string | undefined, recovered: {} as Record, view: typeof window === "undefined" ? 1000 : (window.visualViewport?.height ?? window.innerHeight), }) @@ -52,6 +55,7 @@ export function TerminalPanel(props: { stacked?: boolean } = {}) { const stacked = createMemo(() => isDesktop() && props.stacked) const panelHeight = createMemo(() => (isDesktop() ? (stacked() ? `${pane()}px` : "100%") : opened() ? `${pane()}px` : "0px")) const contentHeight = createMemo(() => (isDesktop() ? (stacked() ? `${pane()}px` : "100%") : `${pane()}px`)) + const newTerminalKeybind = createMemo(() => command.keybindParts("terminal.new")) onMount(() => { if (typeof window === "undefined") return @@ -170,27 +174,7 @@ export function TerminalPanel(props: { stacked?: boolean } = {}) { trim(id) } - const handleTerminalDragStart = (event: unknown) => { - const id = getDraggableId(event) - if (!id) return - setStore("activeDraggable", id) - } - - const handleTerminalDragOver = (event: DragEvent) => { - const { draggable, droppable } = event - if (!draggable || !droppable) return - - const terminals = terminal.all() - const fromIndex = terminals.findIndex((t) => t.id === draggable.id.toString()) - const toIndex = terminals.findIndex((t) => t.id === droppable.id.toString()) - if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) { - terminal.move(draggable.id.toString(), toIndex) - } - } - const handleTerminalDragEnd = () => { - setStore("activeDraggable", undefined) - const activeId = terminal.active() if (!activeId) return requestAnimationFrame(() => { @@ -268,28 +252,47 @@ export function TerminalPanel(props: { stacked?: boolean } = {}) { } > + event.target instanceof Element && + !!event.target.closest('[data-slot="tabs-trigger-close-button"], input, [contenteditable="true"]'), + }), + ]} + modifiers={[RestrictToHorizontalAxis, RestrictToElement.configure({ element: () => tabList ?? null })]} + plugins={(defaults) => [ + ...defaults.filter((plugin) => plugin !== Accessibility), + AutoScroller.configure({ acceleration: 8, threshold: { x: 0.05, y: 0 } }), + Feedback.configure({ dropAnimation: null }), + ]} + onDragEnd={(event) => { + const source = event.operation.source + if (!event.canceled && isSortable(source) && source.initialIndex !== source.index) { + terminal.move(source.id.toString(), source.index) + } + handleTerminalDragEnd() + }} > - -
terminal.open(id)} - class="!h-auto !flex-none" + class="!h-[52px] !flex-none" > - - - {(pty) => } - + + {(pty, index) => }
- + {language.t("command.terminal.new")} + 0}> + + + + } + placement="bottom" class="flex items-center" > - +
@@ -326,23 +329,6 @@ export function TerminalPanel(props: { stacked?: boolean } = {}) {
- - - {(id) => ( - pty.id === id)}> - {(t) => ( -
- {terminalTabLabel({ - title: t().title, - titleNumber: t().titleNumber, - t: language.t as (key: string, vars?: Record) => string, - })} -
- )} -
- )} -
-
diff --git a/packages/ui/src/components/tabs.css b/packages/ui/src/components/tabs.css index 036533c10fb8..fe450da85a5e 100644 --- a/packages/ui/src/components/tabs.css +++ b/packages/ui/src/components/tabs.css @@ -135,7 +135,8 @@ } } - #review-panel &[data-variant="normal"][data-orientation="horizontal"] { + #review-panel &[data-variant="normal"][data-orientation="horizontal"], + #terminal-panel &[data-variant="normal"][data-orientation="horizontal"] { background-color: var(--background-stronger); [data-slot="tabs-list"] { @@ -267,6 +268,51 @@ } } + #terminal-panel &[data-variant="normal"][data-orientation="horizontal"] { + [data-slot="tabs-list"] { + height: 52px; + padding-right: 12px; + gap: 8px; + } + + [data-slot="tabs-trigger-wrapper"] { + height: 28px; + padding-inline: 8px; + border: 0; + background-color: transparent; + box-shadow: none; + + &::after { + display: none; + } + + [data-slot="tabs-trigger"] { + padding: 0 !important; + } + + &:has([data-slot="tabs-trigger-close-button"]) { + padding-right: 8px; + } + + &:has([data-selected]) { + background-color: var(--v2-background-bg-layer-01); + box-shadow: inset 0 0 0 0.5px var(--v2-border-border-muted); + } + } + + [data-slot="terminal-tab-title"] { + font-family: Inter, sans-serif; + font-style: normal; + font-weight: 440; + font-size: 13px; + line-height: 16px; + letter-spacing: -0.04px; + color: var(--v2-text-text-base); + font-variation-settings: "slnt" 0; + font-variant-numeric: tabular-nums; + } + } + &[data-variant="alt"] { [data-slot="tabs-list"] { padding-left: 24px; From 2375c70ed9b9f7096dfabcd97ddd09a95d1361ad Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Wed, 1 Jul 2026 16:44:48 +0530 Subject: [PATCH 03/11] cleanup --- .../components/session/session-sortable-terminal-tab.tsx | 1 + packages/app/src/pages/session/terminal-panel.tsx | 6 ++++-- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/session/session-sortable-terminal-tab.tsx b/packages/app/src/components/session/session-sortable-terminal-tab.tsx index 900b6457f07c..dc4aa4c91ac5 100644 --- a/packages/app/src/components/session/session-sortable-terminal-tab.tsx +++ b/packages/app/src/components/session/session-sortable-terminal-tab.tsx @@ -62,6 +62,7 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; index: () => nu const focus = () => { if (store.editing) return + terminal.open(props.terminal.id) if (document.activeElement instanceof HTMLElement) document.activeElement.blur() focusTerminalById(props.terminal.id) } diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx index d0adc36b52df..86c3c6ea07a1 100644 --- a/packages/app/src/pages/session/terminal-panel.tsx +++ b/packages/app/src/pages/session/terminal-panel.tsx @@ -280,8 +280,10 @@ export function TerminalPanel(props: { stacked?: boolean } = {}) { onChange={(id) => terminal.open(id)} class="!h-[52px] !flex-none" > - - {(pty, index) => } + +
+ {(pty, index) => } +
Date: Wed, 1 Jul 2026 16:50:07 +0530 Subject: [PATCH 04/11] fix spacing --- packages/app/src/pages/session/terminal-panel.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx index 86c3c6ea07a1..663b0d5ec446 100644 --- a/packages/app/src/pages/session/terminal-panel.tsx +++ b/packages/app/src/pages/session/terminal-panel.tsx @@ -319,6 +319,7 @@ export function TerminalPanel(props: { stacked?: boolean } = {}) { markTerminalConnected(terminalRecoveryKey(pty()), id, ops.trim)} onCleanup={ops.update} onConnectError={() => recoverTerminal(terminalRecoveryKey(pty()), id, ops.clone)} From ab7bf3347d3d38dcd6edf86227a2c2f47ef1b400 Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Wed, 1 Jul 2026 17:14:38 +0530 Subject: [PATCH 05/11] fix transition --- packages/app/src/pages/session.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 741df38da13c..59893accbb7a 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -428,6 +428,7 @@ export default function Page() { const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened()) const desktopTerminalOpen = createMemo(() => isDesktop() && view().terminal.opened()) const desktopReviewAndTerminalOpen = createMemo(() => desktopReviewOpen() && desktopTerminalOpen()) + const desktopTerminalOnlyOpen = createMemo(() => desktopTerminalOpen() && !desktopReviewOpen()) const desktopFileTreeOpen = createMemo( () => isDesktop() && @@ -2011,7 +2012,7 @@ export default function Page() { classList={{ "@container relative shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]": true, "duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none": - !size.active() && !ui.reviewSnap, + !size.active() && !ui.reviewSnap && !desktopTerminalOnlyOpen(), }} style={{ width: sessionPanelWidth(), From 7486362d717219dfabc9a3ce9bbf16f560bb838c Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Wed, 1 Jul 2026 21:06:27 +0800 Subject: [PATCH 06/11] bring back old terminal impl for old design --- .../session/session-sortable-terminal-tab.tsx | 195 +++++++++++++----- packages/app/src/pages/session.tsx | 36 +++- .../app/src/pages/session/terminal-panel.tsx | 78 ++++--- 3 files changed, 227 insertions(+), 82 deletions(-) diff --git a/packages/app/src/components/session/session-sortable-terminal-tab.tsx b/packages/app/src/components/session/session-sortable-terminal-tab.tsx index dc4aa4c91ac5..5f1322b71a05 100644 --- a/packages/app/src/components/session/session-sortable-terminal-tab.tsx +++ b/packages/app/src/components/session/session-sortable-terminal-tab.tsx @@ -5,6 +5,8 @@ import { useSortable } from "@dnd-kit/solid/sortable" import { IconButton } from "@opencode-ai/ui/icon-button" import { TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Tabs } from "@opencode-ai/ui/tabs" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" +import { Icon } from "@opencode-ai/ui/icon" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" import { isDefaultTitle as isDefaultTerminalTitle } from "@/context/terminal-title" import { useTerminal, type LocalPTY } from "@/context/terminal" @@ -14,7 +16,12 @@ import { focusTerminalById } from "@/pages/session/helpers" const sortableTransition = { duration: 0 } -export function SortableTerminalTab(props: { terminal: LocalPTY; index: () => number; onClose?: () => void }): JSX.Element { +export function SortableTerminalTab(props: { + terminal: LocalPTY + index: () => number + newLayout: boolean + onClose?: () => void +}): JSX.Element { const terminal = useTerminal() const language = useLanguage() const command = useCommand() @@ -30,6 +37,8 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; index: () => nu const [store, setStore] = createStore({ editing: false, title: props.terminal.title, + menuOpen: false, + menuPosition: { x: 0, y: 0 }, blurEnabled: false, }) let input: HTMLInputElement | undefined @@ -100,6 +109,12 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; index: () => nu } } + const menu = (e: MouseEvent) => { + e.preventDefault() + setStore("menuPosition", { x: e.clientX, y: e.clientY }) + setStore("menuOpen", true) + } + createEffect(() => { if (!store.editing) return if (!input) return @@ -120,63 +135,145 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; index: () => nu return (
- - - + + e.preventDefault()} + onContextMenu={menu} + class="!shadow-none" + classes={{ + button: "border-0 outline-none focus:outline-none focus-visible:outline-none !shadow-none !ring-0", + }} + closeButton={ { e.stopPropagation() close() }} aria-label={language.t("terminal.close")} /> - - } - hideCloseButton - onMiddleClick={close} - > - - {label()} - - - -
- setStore("title", e.currentTarget.value)} - onBlur={save} - onKeyDown={keydown} - onMouseDown={(e) => e.stopPropagation()} - 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]" - /> -
-
-
- - { - if (!editRequested) return - e.preventDefault() - editRequested = false - requestAnimationFrame(() => edit()) - }} - > - (editRequested = true)}>{language.t("common.rename")} - {language.t("common.close")} - - -
+ } + > + + {label()} + + + +
+ setStore("title", e.currentTarget.value)} + onBlur={save} + onKeyDown={keydown} + onMouseDown={(e) => e.stopPropagation()} + class="bg-transparent border-none outline-none text-sm min-w-0 flex-1" + /> +
+
+ setStore("menuOpen", open)}> + + { + if (!editRequested) return + e.preventDefault() + editRequested = false + requestAnimationFrame(() => edit()) + }} + > + (editRequested = true)}> + + {language.t("common.rename")} + + + + {language.t("common.close")} + + + + +
+ } + > + + + + { + e.stopPropagation() + close() + }} + aria-label={language.t("terminal.close")} + /> + + } + hideCloseButton + onMiddleClick={close} + > + + {label()} + + + +
+ setStore("title", e.currentTarget.value)} + onBlur={save} + onKeyDown={keydown} + onMouseDown={(e) => e.stopPropagation()} + 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]" + /> +
+
+
+ + { + if (!editRequested) return + e.preventDefault() + editRequested = false + requestAnimationFrame(() => edit()) + }} + > + (editRequested = true)}>{language.t("common.rename")} + {language.t("common.close")} + + +
+
) } diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 59893accbb7a..1714bcc7a354 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -427,8 +427,13 @@ export default function Page() { const size = createSizing() const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened()) const desktopTerminalOpen = createMemo(() => isDesktop() && view().terminal.opened()) - const desktopReviewAndTerminalOpen = createMemo(() => desktopReviewOpen() && desktopTerminalOpen()) - const desktopTerminalOnlyOpen = createMemo(() => desktopTerminalOpen() && !desktopReviewOpen()) + const desktopStackedReviewAndTerminalOpen = createMemo( + () => newSessionDesign() && desktopReviewOpen() && desktopTerminalOpen(), + ) + const desktopBottomTerminalOpen = createMemo(() => !newSessionDesign() && desktopTerminalOpen()) + const desktopInlineTerminalOnlyOpen = createMemo( + () => newSessionDesign() && desktopTerminalOpen() && !desktopReviewOpen(), + ) const desktopFileTreeOpen = createMemo( () => isDesktop() && @@ -437,7 +442,9 @@ export default function Page() { opened: layout.fileTree.opened(), }), ) - const desktopSessionResizeOpen = createMemo(() => desktopReviewOpen() || desktopTerminalOpen()) + const desktopSessionResizeOpen = createMemo( + () => desktopReviewOpen() || (newSessionDesign() && desktopTerminalOpen()), + ) const desktopSidePanelOpen = createMemo(() => desktopSessionResizeOpen() || desktopFileTreeOpen()) const sessionPanelWidth = createMemo(() => { if (!desktopSidePanelOpen()) return "100%" @@ -2012,7 +2019,7 @@ export default function Page() { classList={{ "@container relative shrink-0 flex flex-col min-h-0 h-full flex-1 md:flex-none transition-[width]": true, "duration-[240ms] ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[width] motion-reduce:transition-none": - !size.active() && !ui.reviewSnap && !desktopTerminalOnlyOpen(), + !size.active() && !ui.reviewSnap && !desktopInlineTerminalOnlyOpen(), }} style={{ width: sessionPanelWidth(), @@ -2046,7 +2053,7 @@ export default function Page() { @@ -2065,7 +2072,7 @@ export default function Page() { /> - + @@ -2105,6 +2112,23 @@ export default function Page() { + + + + + + ) } diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx index 663b0d5ec446..022287126965 100644 --- a/packages/app/src/pages/session/terminal-panel.tsx +++ b/packages/app/src/pages/session/terminal-panel.tsx @@ -10,6 +10,7 @@ import { RestrictToElement } from "@dnd-kit/dom/modifiers" import { Tabs } from "@opencode-ai/ui/tabs" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { IconButton } from "@opencode-ai/ui/icon-button" +import { TooltipKeybind } from "@opencode-ai/ui/tooltip" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" @@ -37,6 +38,7 @@ export function TerminalPanel(props: { stacked?: boolean } = {}) { const { workspaceKey, view } = useSessionLayout() const isDesktop = createMediaQuery("(min-width: 768px)") + const newLayout = createMemo(() => settings.general.newLayoutDesigns()) const opened = createMemo(() => view().terminal.opened()) const size = createSizing() const height = createMemo(() => layout.terminal.height()) @@ -196,7 +198,7 @@ export function TerminalPanel(props: { stacked?: boolean } = {}) { "w-full": !isDesktop() || stacked(), "min-w-0 h-full flex-1": isDesktop() && opened() && !stacked(), "w-0 h-full pointer-events-none": isDesktop() && !opened(), - "rounded-[10px] shadow-[var(--v2-elevation-raised)]": isDesktop() && settings.general.newLayoutDesigns(), + "rounded-[10px] shadow-[var(--v2-elevation-raised)]": isDesktop() && newLayout(), "transition-[height] duration-200 ease-[cubic-bezier(0.22,1,0.36,1)] will-change-[height] motion-reduce:transition-none": !isDesktop() && !size.active(), }} @@ -205,7 +207,7 @@ export function TerminalPanel(props: { stacked?: boolean } = {}) {
size.start()}>
terminal.open(id)} - class="!h-[52px] !flex-none" + class={newLayout() ? "!h-[52px] !flex-none" : "!h-auto !flex-none"} > - -
- {(pty, index) => } -
+ + + {(pty, index) => ( + + )} +
- - {language.t("command.terminal.new")} - 0}> - - - + + + } - placement="bottom" - class="flex items-center" > - - + + {language.t("command.terminal.new")} + 0}> + + + + } + placement="bottom" + class="flex items-center" + > + + +
From 7743cae37fda6e3a1ae98e7e013c12333ee8afc7 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Wed, 1 Jul 2026 21:07:38 +0800 Subject: [PATCH 07/11] remove duration: 0 dndkit transition --- .../session/session-sortable-terminal-tab.tsx | 10 ++++++---- packages/app/src/components/titlebar-tab-strip.tsx | 2 -- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/app/src/components/session/session-sortable-terminal-tab.tsx b/packages/app/src/components/session/session-sortable-terminal-tab.tsx index 5f1322b71a05..7d402aad8479 100644 --- a/packages/app/src/components/session/session-sortable-terminal-tab.tsx +++ b/packages/app/src/components/session/session-sortable-terminal-tab.tsx @@ -14,8 +14,6 @@ import { useLanguage } from "@/context/language" import { useCommand } from "@/context/command" import { focusTerminalById } from "@/pages/session/helpers" -const sortableTransition = { duration: 0 } - export function SortableTerminalTab(props: { terminal: LocalPTY index: () => number @@ -32,7 +30,6 @@ export function SortableTerminalTab(props: { get index() { return props.index() }, - transition: sortableTransition, }) const [store, setStore] = createStore({ editing: false, @@ -240,7 +237,12 @@ export function SortableTerminalTab(props: { hideCloseButton onMiddleClick={close} > - + {label()} diff --git a/packages/app/src/components/titlebar-tab-strip.tsx b/packages/app/src/components/titlebar-tab-strip.tsx index 9b877614d11d..5ee44250e66f 100644 --- a/packages/app/src/components/titlebar-tab-strip.tsx +++ b/packages/app/src/components/titlebar-tab-strip.tsx @@ -17,8 +17,6 @@ import { createTabPromptState } from "@/context/prompt" import { base64Encode } from "@opencode-ai/core/util/encode" import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture" -const sortableTransition = { duration: 0 } - function SessionTabSlot(props: { tab: SessionTab id: string From 06877c9b59b1a43435e8a32dd98b333db2b80402 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Thu, 2 Jul 2026 16:38:07 +0800 Subject: [PATCH 08/11] merge & e2e --- packages/app/e2e/utils/mock-server.ts | 11 ++- packages/app/src/pages/session.tsx | 34 ++++----- .../app/src/pages/session/route-boundary.ts | 24 +++++++ .../session-route-boundary.test.ts | 72 +++++++++++++++++++ 4 files changed, 121 insertions(+), 20 deletions(-) create mode 100644 packages/app/src/pages/session/route-boundary.ts create mode 100644 packages/app/test-browser/session-route-boundary.test.ts diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index dc0a8d0c41c6..005ab7092307 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -1,7 +1,7 @@ import type { Page, Route } from "@playwright/test" const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/status", "/vcs/diff"]) -const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp"]) +const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"]) export interface MockServerConfig { provider: unknown @@ -52,6 +52,7 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { const path = url.pathname if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry) if (path === "/global/health") return json(route, { healthy: true }) + if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: false }) if (path === "/permission") return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])) if (path === "/question") @@ -62,6 +63,11 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { return json(route, await config.fileList(url.searchParams.get("path") ?? "")) if (path === "/file/content" && config.fileContent) return json(route, await config.fileContent(url.searchParams.get("path") ?? "")) + if (path === "/api/reference") + return json(route, { + location: { directory: config.directory, project: { id: (config.project as { id?: string }).id, directory: config.directory } }, + data: [], + }) if (emptyObject.has(path)) return json(route, {}) if (emptyList.has(path)) return json(route, []) if (path in staticRoutes) return json(route, staticRoutes[path]) @@ -72,6 +78,9 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { return json(route, session ?? {}) } + const projectMatch = path.match(/^\/project\/([^/]+)$/) + if (projectMatch) return json(route, config.project) + const todoMatch = path.match(/^\/session\/([^/]+)\/todo$/) if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? []) if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, []) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 840d1bb3c966..704be2b5adcd 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -94,6 +94,7 @@ import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route" import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs" import { createSessionOwnership } from "./session/session-ownership" +import { SessionRouteErrorBoundary } from "./session/route-boundary" import { createSessionLineage } from "./session/session-lineage" type FollowupItem = FollowupDraft & { id: string } @@ -152,32 +153,27 @@ export function SessionPage() { export function TargetSessionRouteContent() { const params = useParams<{ serverKey: string; id: string }>() return ( - + ( + + )} + > ) } -function SessionRouteErrorBoundary( - props: ParentProps<{ sessionID?: string; serverKey?: ServerConnection.Key; padded?: boolean }>, -) { +function SessionRouteFallback(props: { error: unknown; sessionID: string; serverKey: ServerConnection.Key }) { const settings = useSettings() return ( - - settings.general.newLayoutDesigns() ? ( - - - - - - ) : ( - - ) - } - > - {props.children} - + }> + + + + + + ) } diff --git a/packages/app/src/pages/session/route-boundary.ts b/packages/app/src/pages/session/route-boundary.ts new file mode 100644 index 000000000000..bfdb8f972783 --- /dev/null +++ b/packages/app/src/pages/session/route-boundary.ts @@ -0,0 +1,24 @@ +import { ErrorBoundary, createComponent, createEffect, on } from "solid-js" +import type { JSX } from "solid-js" + +// Error scope for the target session route. All session tabs on a server share +// one route instance, so this must NOT key or remount per session: the subtree +// holds workspace-scoped state (notably TerminalProvider and its PTY WebSockets) +// that has to survive switching tabs within the same workspace. +export function SessionRouteErrorBoundary(props: { + sessionID: string + fallback: (error: unknown) => JSX.Element + children: JSX.Element +}) { + return createComponent(ErrorBoundary, { + fallback: (error: unknown, reset: () => void) => { + // Clear stale session errors (for example, not found) on tab switch + // without remounting workspace-scoped providers below the boundary. + createEffect(on(() => props.sessionID, reset, { defer: true })) + return props.fallback(error) + }, + get children() { + return props.children + }, + }) +} diff --git a/packages/app/test-browser/session-route-boundary.test.ts b/packages/app/test-browser/session-route-boundary.test.ts new file mode 100644 index 000000000000..41a16aa83a2b --- /dev/null +++ b/packages/app/test-browser/session-route-boundary.test.ts @@ -0,0 +1,72 @@ +import { expect, test } from "bun:test" +import { createComponent, createSignal, onCleanup } from "solid-js" +import { render } from "solid-js/web" +import { SessionRouteErrorBoundary } from "@/pages/session/route-boundary" + +test("switching sessions does not remount the route subtree", () => { + const [session, setSession] = createSignal("ses_a") + let mounts = 0 + let disposals = 0 + const Probe = () => { + mounts += 1 + onCleanup(() => { + disposals += 1 + }) + return null + } + + const dispose = render( + () => + createComponent(SessionRouteErrorBoundary, { + get sessionID() { + return session() + }, + fallback: () => null, + get children() { + return createComponent(Probe, {}) + }, + }), + document.createElement("div"), + ) + + const initialMounts = mounts + expect(initialMounts).toBeGreaterThan(0) + + setSession("ses_b") + expect(mounts).toBe(initialMounts) + expect(disposals).toBe(0) + + dispose() +}) + +test("route error clears when navigating to a different session", () => { + const [session, setSession] = createSignal("ses_a") + const [broken, setBroken] = createSignal(true) + const Thrower = () => { + if (broken()) throw new Error(`Session not found: ${session()}`) + return "content" + } + + const container = document.createElement("div") + const dispose = render( + () => + createComponent(SessionRouteErrorBoundary, { + get sessionID() { + return session() + }, + fallback: () => "error-fallback", + get children() { + return createComponent(Thrower, {}) + }, + }), + container, + ) + + expect(container.textContent).toBe("error-fallback") + + setBroken(false) + setSession("ses_b") + expect(container.textContent).toBe("content") + + dispose() +}) From 5851451bd5c8c9db60f4d33c68658b6c575ae15b Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Fri, 3 Jul 2026 15:05:32 +0800 Subject: [PATCH 09/11] fix merge conflict artifacts --- packages/app/src/pages/session.tsx | 34 +++++---- .../app/src/pages/session/route-boundary.ts | 24 ------- .../session-route-boundary.test.ts | 72 ------------------- 3 files changed, 19 insertions(+), 111 deletions(-) delete mode 100644 packages/app/src/pages/session/route-boundary.ts delete mode 100644 packages/app/test-browser/session-route-boundary.test.ts diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 704be2b5adcd..840d1bb3c966 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -94,7 +94,6 @@ import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError import { legacySessionHref, requireServerKey, sessionHref } from "@/utils/session-route" import { useUsageExceededDialogs } from "./session/usage-exceeded-dialogs" import { createSessionOwnership } from "./session/session-ownership" -import { SessionRouteErrorBoundary } from "./session/route-boundary" import { createSessionLineage } from "./session/session-lineage" type FollowupItem = FollowupDraft & { id: string } @@ -153,27 +152,32 @@ export function SessionPage() { export function TargetSessionRouteContent() { const params = useParams<{ serverKey: string; id: string }>() return ( - ( - - )} - > + ) } -function SessionRouteFallback(props: { error: unknown; sessionID: string; serverKey: ServerConnection.Key }) { +function SessionRouteErrorBoundary( + props: ParentProps<{ sessionID?: string; serverKey?: ServerConnection.Key; padded?: boolean }>, +) { const settings = useSettings() return ( - }> - - - - - - + + settings.general.newLayoutDesigns() ? ( + + + + + + ) : ( + + ) + } + > + {props.children} + ) } diff --git a/packages/app/src/pages/session/route-boundary.ts b/packages/app/src/pages/session/route-boundary.ts deleted file mode 100644 index bfdb8f972783..000000000000 --- a/packages/app/src/pages/session/route-boundary.ts +++ /dev/null @@ -1,24 +0,0 @@ -import { ErrorBoundary, createComponent, createEffect, on } from "solid-js" -import type { JSX } from "solid-js" - -// Error scope for the target session route. All session tabs on a server share -// one route instance, so this must NOT key or remount per session: the subtree -// holds workspace-scoped state (notably TerminalProvider and its PTY WebSockets) -// that has to survive switching tabs within the same workspace. -export function SessionRouteErrorBoundary(props: { - sessionID: string - fallback: (error: unknown) => JSX.Element - children: JSX.Element -}) { - return createComponent(ErrorBoundary, { - fallback: (error: unknown, reset: () => void) => { - // Clear stale session errors (for example, not found) on tab switch - // without remounting workspace-scoped providers below the boundary. - createEffect(on(() => props.sessionID, reset, { defer: true })) - return props.fallback(error) - }, - get children() { - return props.children - }, - }) -} diff --git a/packages/app/test-browser/session-route-boundary.test.ts b/packages/app/test-browser/session-route-boundary.test.ts deleted file mode 100644 index 41a16aa83a2b..000000000000 --- a/packages/app/test-browser/session-route-boundary.test.ts +++ /dev/null @@ -1,72 +0,0 @@ -import { expect, test } from "bun:test" -import { createComponent, createSignal, onCleanup } from "solid-js" -import { render } from "solid-js/web" -import { SessionRouteErrorBoundary } from "@/pages/session/route-boundary" - -test("switching sessions does not remount the route subtree", () => { - const [session, setSession] = createSignal("ses_a") - let mounts = 0 - let disposals = 0 - const Probe = () => { - mounts += 1 - onCleanup(() => { - disposals += 1 - }) - return null - } - - const dispose = render( - () => - createComponent(SessionRouteErrorBoundary, { - get sessionID() { - return session() - }, - fallback: () => null, - get children() { - return createComponent(Probe, {}) - }, - }), - document.createElement("div"), - ) - - const initialMounts = mounts - expect(initialMounts).toBeGreaterThan(0) - - setSession("ses_b") - expect(mounts).toBe(initialMounts) - expect(disposals).toBe(0) - - dispose() -}) - -test("route error clears when navigating to a different session", () => { - const [session, setSession] = createSignal("ses_a") - const [broken, setBroken] = createSignal(true) - const Thrower = () => { - if (broken()) throw new Error(`Session not found: ${session()}`) - return "content" - } - - const container = document.createElement("div") - const dispose = render( - () => - createComponent(SessionRouteErrorBoundary, { - get sessionID() { - return session() - }, - fallback: () => "error-fallback", - get children() { - return createComponent(Thrower, {}) - }, - }), - container, - ) - - expect(container.textContent).toBe("error-fallback") - - setBroken(false) - setSession("ses_b") - expect(container.textContent).toBe("content") - - dispose() -}) From 143a0c95f7b7b693c244763fd50634ee03730486 Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sun, 5 Jul 2026 11:25:37 +1000 Subject: [PATCH 10/11] fix(app): isolate terminal improvements to v2 --- .../session-sortable-terminal-tab-v2.tsx | 271 +++++++++++++ .../session/session-sortable-terminal-tab.tsx | 216 +++-------- packages/app/src/pages/session.tsx | 147 ++++--- .../session/session-panel-layout.test.ts | 19 + .../src/pages/session/session-panel-layout.ts | 6 + .../src/pages/session/terminal-panel-v2.tsx | 364 ++++++++++++++++++ .../app/src/pages/session/terminal-panel.tsx | 176 ++++----- 7 files changed, 871 insertions(+), 328 deletions(-) create mode 100644 packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx create mode 100644 packages/app/src/pages/session/session-panel-layout.test.ts create mode 100644 packages/app/src/pages/session/session-panel-layout.ts create mode 100644 packages/app/src/pages/session/terminal-panel-v2.tsx diff --git a/packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx b/packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx new file mode 100644 index 000000000000..f0cdf41ac1f5 --- /dev/null +++ b/packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx @@ -0,0 +1,271 @@ +import type { JSX } from "solid-js" +import { Show, createEffect, onCleanup } from "solid-js" +import { createStore } from "solid-js/store" +import { useSortable } from "@dnd-kit/solid/sortable" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { Tabs } from "@opencode-ai/ui/tabs" +import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" +import { Icon } from "@opencode-ai/ui/icon" +import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" +import { isDefaultTitle as isDefaultTerminalTitle } from "@/context/terminal-title" +import { useTerminal, type LocalPTY } from "@/context/terminal" +import { useLanguage } from "@/context/language" +import { focusTerminalById } from "@/pages/session/helpers" + +export function SortableTerminalTabV2(props: { + terminal: LocalPTY + index: () => number + newLayout: boolean + onClose?: () => void +}): JSX.Element { + const terminal = useTerminal() + const language = useLanguage() + const sortable = useSortable({ + get id() { + return props.terminal.id + }, + get index() { + return props.index() + }, + }) + const [store, setStore] = createStore({ + editing: false, + title: props.terminal.title, + menuOpen: false, + menuPosition: { x: 0, y: 0 }, + blurEnabled: false, + }) + let input: HTMLInputElement | undefined + let blurFrame: number | undefined + let editRequested = false + + const isDefaultTitle = () => { + const number = props.terminal.titleNumber + if (!Number.isFinite(number) || number <= 0) return false + return isDefaultTerminalTitle(props.terminal.title, number) + } + + const label = () => { + language.locale() + if (props.terminal.title && !isDefaultTitle()) return props.terminal.title + + const number = props.terminal.titleNumber + if (Number.isFinite(number) && number > 0) return language.t("terminal.title.numbered", { number }) + if (props.terminal.title) return props.terminal.title + return language.t("terminal.title") + } + + const close = () => { + const count = terminal.all().length + void terminal.close(props.terminal.id) + if (count === 1) { + props.onClose?.() + } + } + + const focus = () => { + if (store.editing) return + terminal.open(props.terminal.id) + if (document.activeElement instanceof HTMLElement) document.activeElement.blur() + focusTerminalById(props.terminal.id) + } + + const edit = (e?: Event) => { + if (e) { + e.stopPropagation() + e.preventDefault() + } + + setStore("blurEnabled", false) + setStore("title", props.terminal.title) + setStore("editing", true) + } + + const save = () => { + if (!store.blurEnabled) return + + const value = store.title.trim() + if (value && value !== props.terminal.title) { + terminal.update({ id: props.terminal.id, title: value }) + } + setStore("editing", false) + } + + const keydown = (e: KeyboardEvent) => { + if (e.key === "Enter") { + e.preventDefault() + save() + return + } + if (e.key === "Escape") { + e.preventDefault() + setStore("editing", false) + } + } + + const menu = (e: MouseEvent) => { + e.preventDefault() + setStore("menuPosition", { x: e.clientX, y: e.clientY }) + setStore("menuOpen", true) + } + + createEffect(() => { + if (!store.editing) return + if (!input) return + input.focus() + input.select() + if (blurFrame !== undefined) cancelAnimationFrame(blurFrame) + blurFrame = requestAnimationFrame(() => { + blurFrame = undefined + setStore("blurEnabled", true) + }) + }) + + onCleanup(() => { + if (blurFrame === undefined) return + cancelAnimationFrame(blurFrame) + }) + + return ( +
+ + e.preventDefault()} + onContextMenu={menu} + class="!shadow-none" + classes={{ + button: "border-0 outline-none focus:outline-none focus-visible:outline-none !shadow-none !ring-0", + }} + closeButton={ + { + e.stopPropagation() + close() + }} + aria-label={language.t("terminal.close")} + /> + } + > + + {label()} + + + +
+ setStore("title", e.currentTarget.value)} + onBlur={save} + onKeyDown={keydown} + onMouseDown={(e) => e.stopPropagation()} + class="bg-transparent border-none outline-none text-sm min-w-0 flex-1" + /> +
+
+ setStore("menuOpen", open)}> + + { + if (!editRequested) return + e.preventDefault() + editRequested = false + requestAnimationFrame(() => edit()) + }} + > + (editRequested = true)}> + + {language.t("common.rename")} + + + + {language.t("common.close")} + + + + +
+ } + > + + + { + e.stopPropagation() + close() + }} + aria-label={language.t("terminal.close")} + /> + } + hideCloseButton + onMiddleClick={close} + > + + {label()} + + + +
+ setStore("title", e.currentTarget.value)} + onBlur={save} + onKeyDown={keydown} + onMouseDown={(e) => e.stopPropagation()} + 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]" + /> +
+
+
+ + { + if (!editRequested) return + e.preventDefault() + editRequested = false + requestAnimationFrame(() => edit()) + }} + > + (editRequested = true)}>{language.t("common.rename")} + {language.t("common.close")} + + +
+ +
+ ) +} diff --git a/packages/app/src/components/session/session-sortable-terminal-tab.tsx b/packages/app/src/components/session/session-sortable-terminal-tab.tsx index 7d402aad8479..2d88ed180645 100644 --- a/packages/app/src/components/session/session-sortable-terminal-tab.tsx +++ b/packages/app/src/components/session/session-sortable-terminal-tab.tsx @@ -1,36 +1,20 @@ import type { JSX } from "solid-js" import { Show, createEffect, onCleanup } from "solid-js" import { createStore } from "solid-js/store" -import { useSortable } from "@dnd-kit/solid/sortable" +import { createSortable } from "@thisbeyond/solid-dnd" import { IconButton } from "@opencode-ai/ui/icon-button" -import { TooltipKeybind } from "@opencode-ai/ui/tooltip" import { Tabs } from "@opencode-ai/ui/tabs" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Icon } from "@opencode-ai/ui/icon" -import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" import { isDefaultTitle as isDefaultTerminalTitle } from "@/context/terminal-title" import { useTerminal, type LocalPTY } from "@/context/terminal" import { useLanguage } from "@/context/language" -import { useCommand } from "@/context/command" import { focusTerminalById } from "@/pages/session/helpers" -export function SortableTerminalTab(props: { - terminal: LocalPTY - index: () => number - newLayout: boolean - onClose?: () => void -}): JSX.Element { +export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => void }): JSX.Element { const terminal = useTerminal() const language = useLanguage() - const command = useCommand() - const sortable = useSortable({ - get id() { - return props.terminal.id - }, - get index() { - return props.index() - }, - }) + const sortable = createSortable(props.terminal.id) const [store, setStore] = createStore({ editing: false, title: props.terminal.title, @@ -68,7 +52,6 @@ export function SortableTerminalTab(props: { const focus = () => { if (store.editing) return - terminal.open(props.terminal.id) if (document.activeElement instanceof HTMLElement) document.activeElement.blur() focusTerminalById(props.terminal.id) } @@ -131,138 +114,61 @@ export function SortableTerminalTab(props: { return (
- - e.preventDefault()} - onContextMenu={menu} - class="!shadow-none" - classes={{ - button: "border-0 outline-none focus:outline-none focus-visible:outline-none !shadow-none !ring-0", +
+ e.preventDefault()} + onContextMenu={menu} + class="!shadow-none" + classes={{ + button: "border-0 outline-none focus:outline-none focus-visible:outline-none !shadow-none !ring-0", + }} + closeButton={ + { + e.stopPropagation() + close() }} - closeButton={ - { - e.stopPropagation() - close() - }} - aria-label={language.t("terminal.close")} - /> - } - > - - {label()} - - - -
- setStore("title", e.currentTarget.value)} - onBlur={save} - onKeyDown={keydown} - onMouseDown={(e) => e.stopPropagation()} - class="bg-transparent border-none outline-none text-sm min-w-0 flex-1" - /> -
-
- setStore("menuOpen", open)}> - - { - if (!editRequested) return - e.preventDefault() - editRequested = false - requestAnimationFrame(() => edit()) - }} - > - (editRequested = true)}> - - {language.t("common.rename")} - - - - {language.t("common.close")} - - - - + aria-label={language.t("terminal.close")} + /> + } + > + + {label()} + + + +
+ setStore("title", e.currentTarget.value)} + onBlur={save} + onKeyDown={keydown} + onMouseDown={(e) => e.stopPropagation()} + class="bg-transparent border-none outline-none text-sm min-w-0 flex-1" + />
- } - > - - - - { - e.stopPropagation() - close() - }} - aria-label={language.t("terminal.close")} - /> - - } - hideCloseButton - onMiddleClick={close} - > - - {label()} - - - -
- setStore("title", e.currentTarget.value)} - onBlur={save} - onKeyDown={keydown} - onMouseDown={(e) => e.stopPropagation()} - 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]" - /> -
-
-
- - + setStore("menuOpen", open)}> + + { if (!editRequested) return e.preventDefault() @@ -270,12 +176,18 @@ export function SortableTerminalTab(props: { requestAnimationFrame(() => edit()) }} > - (editRequested = true)}>{language.t("common.rename")} - {language.t("common.close")} - - -
-
+ (editRequested = true)}> + + {language.t("common.rename")} + + + + {language.t("common.close")} + + + + +
) } diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 840d1bb3c966..a9496faec427 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -78,11 +78,13 @@ import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/ import { useSessionLayout } from "@/pages/session/session-layout" import { syncSessionModel } from "@/pages/session/session-model-helpers" import { SessionSidePanel } from "@/pages/session/session-side-panel" +import { sessionPanelLayout } from "@/pages/session/session-panel-layout" import { SessionReviewEmptyChangesV2 } from "@opencode-ai/session-ui/v2/session-review-empty-changes-v2" import { SessionReviewEmptyNoGitV2 } from "@opencode-ai/session-ui/v2/session-review-empty-no-git-v2" import { ReviewPanelV2 } from "@/pages/session/v2/review-panel-v2" import { createReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state" import { TerminalPanel } from "@/pages/session/terminal-panel" +import { TerminalPanelV2 } from "@/pages/session/terminal-panel-v2" import { useComposerCommands } from "@/pages/session/use-composer-commands" import { useSessionCommands } from "@/pages/session/use-session-commands" import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll" @@ -434,13 +436,10 @@ export default function Page() { const isDesktop = createMediaQuery("(min-width: 768px)") const size = createSizing() const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened()) + const desktopV2ReviewOpen = createMemo(() => newSessionDesign() && desktopReviewOpen() && !!params.id) const desktopTerminalOpen = createMemo(() => isDesktop() && view().terminal.opened()) - const desktopStackedReviewAndTerminalOpen = createMemo( - () => newSessionDesign() && desktopReviewOpen() && desktopTerminalOpen(), - ) - const desktopBottomTerminalOpen = createMemo(() => !newSessionDesign() && desktopTerminalOpen()) const desktopInlineTerminalOnlyOpen = createMemo( - () => newSessionDesign() && desktopTerminalOpen() && !desktopReviewOpen(), + () => newSessionDesign() && desktopTerminalOpen() && !desktopV2ReviewOpen(), ) const desktopFileTreeOpen = createMemo( () => @@ -451,7 +450,7 @@ export default function Page() { }), ) const desktopSessionResizeOpen = createMemo( - () => desktopReviewOpen() || (newSessionDesign() && desktopTerminalOpen()), + () => (newSessionDesign() ? desktopV2ReviewOpen() || desktopTerminalOpen() : desktopReviewOpen()), ) const desktopSidePanelOpen = createMemo(() => desktopSessionResizeOpen() || desktopFileTreeOpen()) const sessionPanelWidth = createMemo(() => { @@ -460,6 +459,13 @@ export default function Page() { return `calc(100% - ${layout.fileTree.width()}px)` }) const centered = createMemo(() => isDesktop() && !desktopReviewOpen()) + const desktopV2PanelLayout = createMemo(() => + sessionPanelLayout({ + review: desktopV2ReviewOpen(), + terminal: desktopTerminalOpen(), + files: desktopFileTreeOpen(), + }), + ) function normalizeTab(tab: string) { if (!tab.startsWith("file://")) return tab @@ -2153,82 +2159,71 @@ export default function Page() {
- - - (newSessionDesign() ? reviewPanelV2() : reviewPanel())} - activeDiff={tree.activeDiff} - focusReviewDiff={focusReviewDiff} - reviewSnap={ui.reviewSnap} - size={size} - /> - - - - - - - } - > -
- (newSessionDesign() ? reviewPanelV2() : reviewPanel())} - activeDiff={tree.activeDiff} - focusReviewDiff={focusReviewDiff} - reviewSnap={ui.reviewSnap} - size={size} - stacked - /> - -
size.start()}> - { - size.touch() - layout.terminal.resize(height) - }} + + + + +
+
+
- - + +
size.start()}> + { + size.touch() + layout.terminal.resize(height) + }} + onCollapse={() => view().terminal.close()} + /> +
+
+
+ +
- - - - + + ) diff --git a/packages/app/src/pages/session/session-panel-layout.test.ts b/packages/app/src/pages/session/session-panel-layout.test.ts new file mode 100644 index 000000000000..499dd7411a0b --- /dev/null +++ b/packages/app/src/pages/session/session-panel-layout.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "bun:test" +import { sessionPanelLayout } from "./session-panel-layout" + +describe("sessionPanelLayout", () => { + test("keeps one V2 owner while changing panel geometry", () => { + expect(sessionPanelLayout({ review: false, terminal: false, files: false })).toEqual({ + visible: false, + stacked: false, + }) + expect(sessionPanelLayout({ review: false, terminal: true, files: false })).toEqual({ + visible: true, + stacked: false, + }) + expect(sessionPanelLayout({ review: true, terminal: true, files: false })).toEqual({ + visible: true, + stacked: true, + }) + }) +}) diff --git a/packages/app/src/pages/session/session-panel-layout.ts b/packages/app/src/pages/session/session-panel-layout.ts new file mode 100644 index 000000000000..49feaebedeef --- /dev/null +++ b/packages/app/src/pages/session/session-panel-layout.ts @@ -0,0 +1,6 @@ +export function sessionPanelLayout(input: { review: boolean; terminal: boolean; files: boolean }) { + return { + visible: input.review || input.terminal || input.files, + stacked: input.review && input.terminal, + } +} diff --git a/packages/app/src/pages/session/terminal-panel-v2.tsx b/packages/app/src/pages/session/terminal-panel-v2.tsx new file mode 100644 index 000000000000..aec999d5dc15 --- /dev/null +++ b/packages/app/src/pages/session/terminal-panel-v2.tsx @@ -0,0 +1,364 @@ +import { For, Show, createEffect, createMemo, on, onCleanup, onMount } from "solid-js" +import { createStore } from "solid-js/store" +import { makeEventListener } from "@solid-primitives/event-listener" +import { createMediaQuery } from "@solid-primitives/media" +import { DragDropProvider, PointerSensor } from "@dnd-kit/solid" +import { isSortable } from "@dnd-kit/solid/sortable" +import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom" +import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers" +import { RestrictToElement } from "@dnd-kit/dom/modifiers" +import { Tabs } from "@opencode-ai/ui/tabs" +import { ResizeHandle } from "@opencode-ai/ui/resize-handle" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" + +import { SortableTerminalTabV2 } from "@/components/session/session-sortable-terminal-tab-v2" +import { Terminal } from "@/components/terminal" +import { useCommand } from "@/context/command" +import { useLanguage } from "@/context/language" +import { useLayout } from "@/context/layout" +import { useSettings } from "@/context/settings" +import { useTerminal } from "@/context/terminal" +import { useSDK } from "@/context/sdk" +import { terminalTabLabel } from "@/pages/session/terminal-label" +import { createSizing, focusTerminalById } from "@/pages/session/helpers" +import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff" +import { useSessionLayout } from "@/pages/session/session-layout" + +export function TerminalPanelV2(props: { stacked?: boolean } = {}) { + const delays = [120, 240] + const layout = useLayout() + const terminal = useTerminal() + const sdk = useSDK() + const language = useLanguage() + const command = useCommand() + const settings = useSettings() + const { workspaceKey, view } = useSessionLayout() + + const isDesktop = createMediaQuery("(min-width: 768px)") + const newLayout = createMemo(() => settings.general.newLayoutDesigns()) + const opened = createMemo(() => view().terminal.opened()) + const size = createSizing() + const height = createMemo(() => layout.terminal.height()) + const close = () => view().terminal.close() + let root: HTMLDivElement | undefined + let tabList: HTMLDivElement | undefined + + const [store, setStore] = createStore({ + autoCreated: false, + recovered: {} as Record, + view: typeof window === "undefined" ? 1000 : (window.visualViewport?.height ?? window.innerHeight), + }) + + const max = () => store.view * 0.6 + const pane = () => Math.min(height(), max()) + const stacked = createMemo(() => isDesktop() && props.stacked) + const panelHeight = createMemo(() => (isDesktop() ? (stacked() ? `${pane()}px` : "100%") : opened() ? `${pane()}px` : "0px")) + const contentHeight = createMemo(() => (isDesktop() ? (stacked() ? `${pane()}px` : "100%") : `${pane()}px`)) + const newTerminalKeybind = createMemo(() => command.keybindParts("terminal.new")) + + onMount(() => { + if (typeof window === "undefined") return + + const sync = () => setStore("view", window.visualViewport?.height ?? window.innerHeight) + const port = window.visualViewport + + sync() + makeEventListener(window, "resize", sync) + if (port) makeEventListener(port, "resize", sync) + }) + + createEffect(() => { + if (!opened()) { + setStore("autoCreated", false) + return + } + + if (!terminal.ready() || terminal.all().length !== 0 || store.autoCreated) return + terminal.new() + setStore("autoCreated", true) + }) + + createEffect( + on( + () => terminal.all().length, + (count, prevCount) => { + if (prevCount === undefined || prevCount <= 0 || count !== 0) return + if (!opened()) return + close() + }, + ), + ) + + const focus = (id: string) => { + focusTerminalById(id) + + const frame = requestAnimationFrame(() => { + if (!opened()) return + if (terminal.active() !== id) return + focusTerminalById(id) + }) + + const timers = delays.map((ms) => + window.setTimeout(() => { + if (!opened()) return + if (terminal.active() !== id) return + focusTerminalById(id) + }, ms), + ) + + return () => { + cancelAnimationFrame(frame) + for (const timer of timers) clearTimeout(timer) + } + } + + createEffect( + on( + () => [opened(), terminal.active()] as const, + ([next, id]) => { + if (!next || !id) return + const stop = focus(id) + onCleanup(stop) + }, + ), + ) + + createEffect(() => { + if (opened()) return + const active = document.activeElement + if (!(active instanceof HTMLElement)) return + if (!root?.contains(active)) return + active.blur() + }) + + createEffect(() => { + const dir = sdk().directory + if (!dir) return + if (!terminal.ready()) return + language.locale() + + setTerminalHandoff( + workspaceKey(), + terminal.all().map((pty) => + terminalTabLabel({ + title: pty.title, + titleNumber: pty.titleNumber, + t: language.t as (key: string, vars?: Record) => string, + }), + ), + ) + }) + + const handoff = createMemo(() => { + const dir = sdk().directory + if (!dir) return [] + return getTerminalHandoff(workspaceKey()) ?? [] + }) + + const all = terminal.all + const ids = createMemo(() => all().map((pty) => pty.id)) + + const recoverTerminal = (key: string, id: string, clone: (id: string) => Promise) => { + if (store.recovered[key]) return + setStore("recovered", key, true) + void clone(id) + } + + const terminalRecoveryKey = (pty: { id: string; title: string; titleNumber: number }) => { + return String(pty.titleNumber || pty.title || pty.id) + } + + const markTerminalConnected = (key: string, id: string, trim: (id: string) => void) => { + setStore("recovered", key, false) + trim(id) + } + + const handleTerminalDragEnd = () => { + const activeId = terminal.active() + if (!activeId) return + requestAnimationFrame(() => { + if (terminal.active() !== activeId) return + focusTerminalById(activeId) + }) + } + + return ( + + ) +} diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx index 022287126965..54195e2ebc85 100644 --- a/packages/app/src/pages/session/terminal-panel.tsx +++ b/packages/app/src/pages/session/terminal-panel.tsx @@ -1,18 +1,13 @@ import { For, Show, createEffect, createMemo, on, onCleanup, onMount } from "solid-js" import { createStore } from "solid-js/store" import { makeEventListener } from "@solid-primitives/event-listener" -import { createMediaQuery } from "@solid-primitives/media" -import { DragDropProvider, PointerSensor } from "@dnd-kit/solid" -import { isSortable } from "@dnd-kit/solid/sortable" -import { Accessibility, AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom" -import { RestrictToHorizontalAxis } from "@dnd-kit/abstract/modifiers" -import { RestrictToElement } from "@dnd-kit/dom/modifiers" import { Tabs } from "@opencode-ai/ui/tabs" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { IconButton } from "@opencode-ai/ui/icon-button" import { TooltipKeybind } from "@opencode-ai/ui/tooltip" -import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" -import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" +import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" +import type { DragEvent } from "@thisbeyond/solid-dnd" +import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" import { SortableTerminalTab } from "@/components/session" import { Terminal } from "@/components/terminal" @@ -27,7 +22,7 @@ import { createSizing, focusTerminalById } from "@/pages/session/helpers" import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff" import { useSessionLayout } from "@/pages/session/session-layout" -export function TerminalPanel(props: { stacked?: boolean } = {}) { +export function TerminalPanel() { const delays = [120, 240] const layout = useLayout() const terminal = useTerminal() @@ -37,27 +32,21 @@ export function TerminalPanel(props: { stacked?: boolean } = {}) { const settings = useSettings() const { workspaceKey, view } = useSessionLayout() - const isDesktop = createMediaQuery("(min-width: 768px)") - const newLayout = createMemo(() => settings.general.newLayoutDesigns()) const opened = createMemo(() => view().terminal.opened()) const size = createSizing() const height = createMemo(() => layout.terminal.height()) const close = () => view().terminal.close() let root: HTMLDivElement | undefined - let tabList: HTMLDivElement | undefined const [store, setStore] = createStore({ autoCreated: false, + activeDraggable: undefined as string | undefined, recovered: {} as Record, view: typeof window === "undefined" ? 1000 : (window.visualViewport?.height ?? window.innerHeight), }) const max = () => store.view * 0.6 const pane = () => Math.min(height(), max()) - const stacked = createMemo(() => isDesktop() && props.stacked) - const panelHeight = createMemo(() => (isDesktop() ? (stacked() ? `${pane()}px` : "100%") : opened() ? `${pane()}px` : "0px")) - const contentHeight = createMemo(() => (isDesktop() ? (stacked() ? `${pane()}px` : "100%") : `${pane()}px`)) - const newTerminalKeybind = createMemo(() => command.keybindParts("terminal.new")) onMount(() => { if (typeof window === "undefined") return @@ -176,7 +165,27 @@ export function TerminalPanel(props: { stacked?: boolean } = {}) { trim(id) } + const handleTerminalDragStart = (event: unknown) => { + const id = getDraggableId(event) + if (!id) return + setStore("activeDraggable", id) + } + + const handleTerminalDragOver = (event: DragEvent) => { + const { draggable, droppable } = event + if (!draggable || !droppable) return + + const terminals = terminal.all() + const fromIndex = terminals.findIndex((t) => t.id === draggable.id.toString()) + const toIndex = terminals.findIndex((t) => t.id === droppable.id.toString()) + if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) { + terminal.move(draggable.id.toString(), toIndex) + } + } + const handleTerminalDragEnd = () => { + setStore("activeDraggable", undefined) + const activeId = terminal.active() if (!activeId) return requestAnimationFrame(() => { @@ -186,28 +195,24 @@ export function TerminalPanel(props: { stacked?: boolean } = {}) { } return ( - +
) } From 45ffc47c653d23379ff2c54376c27a73f65a089f Mon Sep 17 00:00:00 2001 From: LukeParkerDev <10430890+Hona@users.noreply.github.com> Date: Sun, 5 Jul 2026 11:50:10 +1000 Subject: [PATCH 11/11] fix(app): unmount hidden v2 panels --- .../e2e/regression/terminal-hidden.spec.ts | 5 +- packages/app/src/pages/session.tsx | 89 ++++++++++--------- 2 files changed, 48 insertions(+), 46 deletions(-) diff --git a/packages/app/e2e/regression/terminal-hidden.spec.ts b/packages/app/e2e/regression/terminal-hidden.spec.ts index 357d9ac203e3..73821580af07 100644 --- a/packages/app/e2e/regression/terminal-hidden.spec.ts +++ b/packages/app/e2e/regression/terminal-hidden.spec.ts @@ -7,7 +7,7 @@ const projectID = "proj_hidden_terminal_regression" const sessionID = "ses_hidden_terminal_regression" const title = "Hidden terminal regression" -test("unmounts the terminal renderer while the pane is hidden", async ({ page }) => { +test("unmounts the terminal panel while it is hidden", async ({ page }) => { await page.setViewportSize({ width: 1400, height: 900 }) await mockOpenCodeServer(page, { directory, @@ -64,13 +64,14 @@ test("unmounts the terminal renderer while the pane is hidden", async ({ page }) await expect(page.locator('[data-component="terminal"]')).toBeVisible() await page.keyboard.press("Control+Backquote") - await expect(panel).toHaveAttribute("aria-hidden", "true") + await expect(panel).toHaveCount(0) await expect(page.locator('[data-component="terminal"]')).toHaveCount(0) await page.setViewportSize({ width: 1200, height: 700 }) await expect(page.locator('[data-component="terminal"]')).toHaveCount(0) await page.keyboard.press("Control+Backquote") + await expect(panel).toBeVisible() await expect(page.locator('[data-component="terminal"]')).toBeVisible() }) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index f8286e0e9bb2..db2c1f03e5d6 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -430,7 +430,8 @@ export default function Page() { const size = createSizing() const desktopReviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened()) const desktopV2ReviewOpen = createMemo(() => newSessionDesign() && desktopReviewOpen() && !!params.id) - const desktopTerminalOpen = createMemo(() => isDesktop() && view().terminal.opened()) + const terminalOpen = createMemo(() => view().terminal.opened()) + const desktopTerminalOpen = createMemo(() => isDesktop() && terminalOpen()) const desktopInlineTerminalOnlyOpen = createMemo( () => newSessionDesign() && desktopTerminalOpen() && !desktopV2ReviewOpen(), ) @@ -2165,50 +2166,50 @@ export default function Page() { />
-
-
- + +
+ +
+ +
+
+ +
size.start()}> + { + size.touch() + layout.terminal.resize(height) + }} + onCollapse={() => view().terminal.close()} + /> +
+
+ +
+ +
+
- -
size.start()}> - { - size.touch() - layout.terminal.resize(height) - }} - onCollapse={() => view().terminal.close()} - /> -
-
-
- -
-
+