From 010996348e3e3d432661d0e53d8bcf92a3d9d38b Mon Sep 17 00:00:00 2001 From: Marco De Nichilo <36410465+marcodenic@users.noreply.github.com> Date: Mon, 5 Jan 2026 01:37:52 +0000 Subject: [PATCH] Add terminal position toggle and allow thinner review panel - Add terminal position state (bottom/right) to layout context - Extract TerminalPanel into reusable component with position toggle button - Conditionally render terminal at bottom (full width) or right (stacked with review) - Increase session panel max width from 45% to 75% to allow thinner review panel - Show right panel when terminal is in right position even without review tabs - Terminal takes full height when no review tabs are present - Add dynamic padding to chat content to account for terminal height - Add keyboard shortcut (ctrl+shift+\) for terminal position toggle --- packages/app/src/components/session/index.ts | 1 + .../session/session-terminal-panel.tsx | 131 +++ packages/app/src/context/layout.tsx | 8 + packages/app/src/pages/session.tsx | 754 ++++++++---------- 4 files changed, 489 insertions(+), 405 deletions(-) create mode 100644 packages/app/src/components/session/session-terminal-panel.tsx diff --git a/packages/app/src/components/session/index.ts b/packages/app/src/components/session/index.ts index 20124b6fdef..085eb34e27a 100644 --- a/packages/app/src/components/session/index.ts +++ b/packages/app/src/components/session/index.ts @@ -3,3 +3,4 @@ export { SessionContextTab } from "./session-context-tab" export { SortableTab, FileVisual } from "./session-sortable-tab" export { SortableTerminalTab } from "./session-sortable-terminal-tab" export { NewSessionView } from "./session-new-view" +export { TerminalPanel } from "./session-terminal-panel" diff --git a/packages/app/src/components/session/session-terminal-panel.tsx b/packages/app/src/components/session/session-terminal-panel.tsx new file mode 100644 index 00000000000..52748bbb431 --- /dev/null +++ b/packages/app/src/components/session/session-terminal-panel.tsx @@ -0,0 +1,131 @@ +import { For, Show, createMemo, type JSX } from "solid-js" +import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" +import type { DragEvent } from "@thisbeyond/solid-dnd" +import { ResizeHandle } from "@opencode-ai/ui/resize-handle" +import { Tabs } from "@opencode-ai/ui/tabs" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { useLayout } from "@/context/layout" +import { useTerminal, type LocalPTY } from "@/context/terminal" +import { useCommand } from "@/context/command" +import { Terminal } from "@/components/terminal" +import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" +import { SortableTerminalTab } from "./session-sortable-terminal-tab" +import { createStore } from "solid-js/store" + +export interface TerminalPanelProps { + position: "bottom" | "right" + fullHeight?: boolean +} + +export function TerminalPanel(props: TerminalPanelProps): JSX.Element { + const layout = useLayout() + const terminal = useTerminal() + const command = useCommand() + + const [store, setStore] = createStore({ + activeDraggable: undefined as string | undefined, + }) + + const handleDragStart = (event: unknown) => { + const id = getDraggableId(event) + if (!id) return + setStore("activeDraggable", id) + } + + const handleDragOver = (event: DragEvent) => { + const { draggable, droppable } = event + if (draggable && droppable) { + const terminals = terminal.all() + const fromIndex = terminals.findIndex((t: LocalPTY) => t.id === draggable.id.toString()) + const toIndex = terminals.findIndex((t: LocalPTY) => t.id === droppable.id.toString()) + if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) { + terminal.move(draggable.id.toString(), toIndex) + } + } + } + + const handleDragEnd = () => { + setStore("activeDraggable", undefined) + } + + return ( +
+ + + + + + +
+ + + +
+ t.id)}> + {(pty) => } + +
+ + + +
+
+ + {(pty) => ( + + terminal.clone(pty.id)} /> + + )} + +
+ + + {(draggedId) => { + const pty = createMemo(() => terminal.all().find((t: LocalPTY) => t.id === draggedId())) + return ( + + {(t) => ( +
+ {t().title} +
+ )} +
+ ) + }} +
+
+
+
+ ) +} diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index bc0d49758bc..7be2299e4bd 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -65,6 +65,7 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( terminal: { opened: false, height: 280, + position: "bottom" as "bottom" | "right", }, review: { opened: true, @@ -181,6 +182,13 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( resize(height: number) { setStore("terminal", "height", height) }, + position: createMemo(() => store.terminal.position ?? "bottom"), + setPosition(position: "bottom" | "right") { + setStore("terminal", "position", position) + }, + togglePosition() { + setStore("terminal", "position", (p) => (p === "bottom" ? "right" : "bottom")) + }, }, review: { opened: createMemo(() => store.review?.opened ?? true), diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 1b044975209..333b773b9fa 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -21,9 +21,8 @@ import { SessionMessageRail } from "@opencode-ai/ui/session-message-rail" import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" import type { DragEvent } from "@thisbeyond/solid-dnd" import { useSync } from "@/context/sync" -import { useTerminal, type LocalPTY } from "@/context/terminal" +import { useTerminal } from "@/context/terminal" import { useLayout } from "@/context/layout" -import { Terminal } from "@/components/terminal" import { checksum, base64Decode } from "@opencode-ai/util/encode" import { useDialog } from "@opencode-ai/ui/context/dialog" import { DialogSelectFile } from "@/components/dialog-select-file" @@ -44,8 +43,8 @@ import { SessionContextTab, SortableTab, FileVisual, - SortableTerminalTab, NewSessionView, + TerminalPanel, } from "@/components/session" import { usePlatform } from "@/context/platform" @@ -248,7 +247,6 @@ export default function Page() { const [store, setStore] = createStore({ activeDraggable: undefined as string | undefined, - activeTerminalDraggable: undefined as string | undefined, expanded: {} as Record, messageId: undefined as string | undefined, mobileTab: "session" as "session" | "review", @@ -376,6 +374,14 @@ export default function Page() { keybind: "ctrl+shift+`", onSelect: () => terminal.new(), }, + { + id: "terminal.position.toggle", + title: "Toggle terminal position", + description: "Move terminal between bottom and right panel", + category: "Terminal", + keybind: "ctrl+shift+\\", + onSelect: () => layout.terminal.togglePosition(), + }, { id: "steps.toggle", title: "Toggle steps", @@ -601,28 +607,6 @@ export default function Page() { setStore("activeDraggable", undefined) } - const handleTerminalDragStart = (event: unknown) => { - const id = getDraggableId(event) - if (!id) return - setStore("activeTerminalDraggable", id) - } - - const handleTerminalDragOver = (event: DragEvent) => { - const { draggable, droppable } = event - if (draggable && droppable) { - const terminals = terminal.all() - const fromIndex = terminals.findIndex((t: LocalPTY) => t.id === draggable.id.toString()) - const toIndex = terminals.findIndex((t: LocalPTY) => t.id === droppable.id.toString()) - if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) { - terminal.move(draggable.id.toString(), toIndex) - } - } - } - - const handleTerminalDragEnd = () => { - setStore("activeTerminalDraggable", undefined) - } - const contextOpen = createMemo(() => tabs().active() === "context" || tabs().all().includes("context")) const openedTabs = createMemo(() => tabs() @@ -633,10 +617,14 @@ export default function Page() { const reviewTab = createMemo(() => diffs().length > 0 || tabs().active() === "review") const mobileReview = createMemo(() => !isDesktop() && diffs().length > 0 && store.mobileTab === "review") - const showTabs = createMemo( + const showReviewTabs = createMemo( () => layout.review.opened() && (diffs().length > 0 || tabs().all().length > 0 || contextOpen()), ) + const showTabs = createMemo( + () => showReviewTabs() || (layout.terminal.position() === "right" && layout.terminal.opened()), + ) + const activeTab = createMemo(() => { const active = tabs().active() if (active) return active @@ -820,11 +808,19 @@ export default function Page() { >
{(message) => ( @@ -901,7 +897,7 @@ export default function Page() { direction="horizontal" size={layout.session.width()} min={450} - max={window.innerWidth * 0.45} + max={window.innerWidth * 0.75} onResize={layout.session.resize} /> @@ -909,398 +905,346 @@ export default function Page() { {/* Desktop tabs panel (Review + Context + Files) - hidden on mobile */} -
- - - - -
- - - -
- - - -
-
Review
- -
- {info()?.summary?.files ?? 0} +
+ +
+ + + + +
+ + + +
+ + + +
+
Review
+ +
+ {info()?.summary?.files ?? 0} +
+
- -
+
+ + + + + tabs().close("context")} /> + + } + hideCloseButton + > +
+ +
Context
+
+
+
+ + {(tab) => } + +
+ + dialog.show(() => )} + /> +
- + +
+ + +
+ +
+
- - tabs().close("context")} /> - - } - hideCloseButton - > -
- -
Context
+ +
+
- +
- - {(tab) => } - -
- - dialog.show(() => )} - /> - -
- -
- - -
- -
-
-
- - -
- -
-
-
- - {(tab) => { - let scroll: HTMLDivElement | undefined - let scrollFrame: number | undefined - let pending: { x: number; y: number } | undefined - - const path = createMemo(() => file.pathFromTab(tab)) - const state = createMemo(() => { - const p = path() - if (!p) return - return file.get(p) - }) - const contents = createMemo(() => state()?.content?.content ?? "") - const cacheKey = createMemo(() => checksum(contents())) - const isImage = createMemo(() => { - const c = state()?.content - return ( - c?.encoding === "base64" && c?.mimeType?.startsWith("image/") && c?.mimeType !== "image/svg+xml" - ) - }) - const isSvg = createMemo(() => { - const c = state()?.content - return c?.mimeType === "image/svg+xml" - }) - const svgContent = createMemo(() => { - if (!isSvg()) return - const c = state()?.content - if (!c) return - if (c.encoding === "base64") return base64Decode(c.content) - return c.content - }) - const svgPreviewUrl = createMemo(() => { - if (!isSvg()) return - const c = state()?.content - if (!c) return - if (c.encoding === "base64") return `data:image/svg+xml;base64,${c.content}` - return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(c.content)}` - }) - const imageDataUrl = createMemo(() => { - if (!isImage()) return - const c = state()?.content - return `data:${c?.mimeType};base64,${c?.content}` - }) - const selectedLines = createMemo(() => { - const p = path() - if (!p) return null - return file.selectedLines(p) ?? null - }) - const selection = createMemo(() => { - const range = selectedLines() - if (!range) return - return selectionFromLines(range) - }) - const selectionLabel = createMemo(() => { - const sel = selection() - if (!sel) return - if (sel.startLine === sel.endLine) return `L${sel.startLine}` - return `L${sel.startLine}-${sel.endLine}` - }) - - const restoreScroll = (retries = 0) => { - const el = scroll - if (!el) return - - const s = view()?.scroll(tab) - if (!s) return - - // Wait for content to be scrollable - content may not have rendered yet - if (el.scrollHeight <= el.clientHeight && retries < 10) { - requestAnimationFrame(() => restoreScroll(retries + 1)) - return - } - - if (el.scrollTop !== s.y) el.scrollTop = s.y - if (el.scrollLeft !== s.x) el.scrollLeft = s.x - } + + {(tab) => { + let scroll: HTMLDivElement | undefined + let scrollFrame: number | undefined + let pending: { x: number; y: number } | undefined + + const path = createMemo(() => file.pathFromTab(tab)) + const state = createMemo(() => { + const p = path() + if (!p) return + return file.get(p) + }) + const contents = createMemo(() => state()?.content?.content ?? "") + const cacheKey = createMemo(() => checksum(contents())) + const isImage = createMemo(() => { + const c = state()?.content + return ( + c?.encoding === "base64" && + c?.mimeType?.startsWith("image/") && + c?.mimeType !== "image/svg+xml" + ) + }) + const isSvg = createMemo(() => { + const c = state()?.content + return c?.mimeType === "image/svg+xml" + }) + const svgContent = createMemo(() => { + if (!isSvg()) return + const c = state()?.content + if (!c) return + if (c.encoding === "base64") return base64Decode(c.content) + return c.content + }) + const svgPreviewUrl = createMemo(() => { + if (!isSvg()) return + const c = state()?.content + if (!c) return + if (c.encoding === "base64") return `data:image/svg+xml;base64,${c.content}` + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(c.content)}` + }) + const imageDataUrl = createMemo(() => { + if (!isImage()) return + const c = state()?.content + return `data:${c?.mimeType};base64,${c?.content}` + }) + const selectedLines = createMemo(() => { + const p = path() + if (!p) return null + return file.selectedLines(p) ?? null + }) + const selection = createMemo(() => { + const range = selectedLines() + if (!range) return + return selectionFromLines(range) + }) + const selectionLabel = createMemo(() => { + const sel = selection() + if (!sel) return + if (sel.startLine === sel.endLine) return `L${sel.startLine}` + return `L${sel.startLine}-${sel.endLine}` + }) + + const restoreScroll = (retries = 0) => { + const el = scroll + if (!el) return + + const s = view()?.scroll(tab) + if (!s) return + + // Wait for content to be scrollable - content may not have rendered yet + if (el.scrollHeight <= el.clientHeight && retries < 10) { + requestAnimationFrame(() => restoreScroll(retries + 1)) + return + } + + if (el.scrollTop !== s.y) el.scrollTop = s.y + if (el.scrollLeft !== s.x) el.scrollLeft = s.x + } - const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => { - pending = { - x: event.currentTarget.scrollLeft, - y: event.currentTarget.scrollTop, - } - if (scrollFrame !== undefined) return + const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => { + pending = { + x: event.currentTarget.scrollLeft, + y: event.currentTarget.scrollTop, + } + if (scrollFrame !== undefined) return - scrollFrame = requestAnimationFrame(() => { - scrollFrame = undefined + scrollFrame = requestAnimationFrame(() => { + scrollFrame = undefined - const next = pending - pending = undefined - if (!next) return + const next = pending + pending = undefined + if (!next) return - view().setScroll(tab, next) - }) - } + view().setScroll(tab, next) + }) + } - createEffect( - on( - () => state()?.loaded, - (loaded) => { - if (!loaded) return - requestAnimationFrame(restoreScroll) - }, - { defer: true }, - ), - ) - - createEffect( - on( - () => file.ready(), - (ready) => { - if (!ready) return - requestAnimationFrame(restoreScroll) - }, - { defer: true }, - ), - ) - - createEffect( - on( - () => tabs().active() === tab, - (active) => { - if (!active) return - if (!state()?.loaded) return - requestAnimationFrame(restoreScroll) - }, - ), - ) - - onCleanup(() => { - if (scrollFrame === undefined) return - cancelAnimationFrame(scrollFrame) - }) - - return ( - { - scroll = el - restoreScroll() - }} - onScroll={handleScroll} - > - - {(sel) => ( - - )} - - - -
- {path()} -
-
- -
- { - const p = path() - if (!p) return - file.setSelectedLines(p, range) - }} - overflow="scroll" - class="select-text" - /> - -
- {path()} + createEffect( + on( + () => state()?.loaded, + (loaded) => { + if (!loaded) return + requestAnimationFrame(restoreScroll) + }, + { defer: true }, + ), + ) + + createEffect( + on( + () => file.ready(), + (ready) => { + if (!ready) return + requestAnimationFrame(restoreScroll) + }, + { defer: true }, + ), + ) + + createEffect( + on( + () => tabs().active() === tab, + (active) => { + if (!active) return + if (!state()?.loaded) return + requestAnimationFrame(restoreScroll) + }, + ), + ) + + onCleanup(() => { + if (scrollFrame === undefined) return + cancelAnimationFrame(scrollFrame) + }) + + return ( + { + scroll = el + restoreScroll() + }} + onScroll={handleScroll} + > + + {(sel) => ( + - -
- - - { - const p = path() - if (!p) return - file.setSelectedLines(p, range) - }} - overflow="scroll" - class="select-text pb-40" - /> - - -
Loading...
-
- - {(err) =>
{err()}
} -
- - - ) - }} - - - - - {(tab) => { - const path = createMemo(() => file.pathFromTab(tab())) - return ( -
- {(p) => } -
- ) - }} -
-
- + )} +
+ + +
+ {path()} +
+
+ +
+ { + const p = path() + if (!p) return + file.setSelectedLines(p, range) + }} + overflow="scroll" + class="select-text" + /> + +
+ {path()} +
+
+
+
+ + { + const p = path() + if (!p) return + file.setSelectedLines(p, range) + }} + overflow="scroll" + class="select-text pb-40" + /> + + +
Loading...
+
+ + {(err) =>
{err()}
} +
+
+ + ) + }} + + + + + {(tab) => { + const path = createMemo(() => file.pathFromTab(tab())) + return ( +
+ {(p) => } +
+ ) + }} +
+
+ +
+
+ + +
- -
- - - - - - - t.id)}> - {(pty) => } - -
- - - -
-
- - {(pty) => ( - - terminal.clone(pty.id)} /> - - )} - -
- - - {(draggedId) => { - const pty = createMemo(() => terminal.all().find((t: LocalPTY) => t.id === draggedId())) - return ( - - {(t) => ( -
- {t().title} -
- )} -
- ) - }} -
-
-
-
+ +
)