diff --git a/packages/app/e2e/regression/review-terminal-stacked.spec.ts b/packages/app/e2e/regression/review-terminal-bottom.spec.ts similarity index 75% rename from packages/app/e2e/regression/review-terminal-stacked.spec.ts rename to packages/app/e2e/regression/review-terminal-bottom.spec.ts index 8c30bb75030..88dd108d88b 100644 --- a/packages/app/e2e/regression/review-terminal-stacked.spec.ts +++ b/packages/app/e2e/regression/review-terminal-bottom.spec.ts @@ -18,7 +18,7 @@ const branchDiffs = [ ), ] -test("keeps the review tree and terminal sized when both panels are open", async ({ page }) => { +test("uses side placement by default and supports the terminal across the bottom", async ({ page }) => { test.setTimeout(120_000) await page.setViewportSize({ width: 1400, height: 900 }) await mockOpenCodeServer(page, { @@ -27,7 +27,7 @@ test("keeps the review tree and terminal sized when both panels are open", async id: projectID, worktree: directory, vcs: "git", - name: "review-terminal-stacked", + name: "review-terminal-bottom", time: { created: 1700000000000, updated: 1700000000000 }, sandboxes: [], }, @@ -45,7 +45,7 @@ test("keeps the review tree and terminal sized when both panels are open", async sessions: [ { id: sessionID, - slug: "review-terminal-stacked", + slug: "review-terminal-bottom", projectID, directory, title, @@ -138,7 +138,22 @@ test("keeps the review tree and terminal sized when both panels are open", async await page.keyboard.press("Control+Backquote") await expect(page.locator("#terminal-panel")).toBeVisible() await expectTree(page, 2_773, "action.yml") - await expectStackGeometry(page) + await expectSideGeometry(page) + + await page.evaluate(() => { + const settings = JSON.parse(localStorage.getItem("settings.v3") ?? "{}") + localStorage.setItem( + "settings.v3", + JSON.stringify({ ...settings, general: { ...settings.general, terminalPlacement: "bottom" } }), + ) + }) + await page.reload() + await expectSessionReady(page, { server, sessionID, title }) + await expect(page.locator("#review-panel")).toBeVisible() + await page.keyboard.press("Control+Backquote") + await expect(page.locator("#terminal-panel")).toBeVisible() + await expectTree(page, 2_773, "action.yml") + await expectBottomGeometry(page) }) async function expectTree(page: Page, total: number, file: string) { @@ -163,23 +178,51 @@ async function expectMountedTree(page: Page, total: number) { expect(state.rows).toBeLessThanOrEqual(60) } -async function expectStackGeometry(page: Page) { +async function expectSideGeometry(page: Page) { + const geometry = await page.evaluate(() => { + const review = document.querySelector("#review-panel")!.getBoundingClientRect() + const terminal = document.querySelector("#terminal-panel")!.getBoundingClientRect() + return { + reviewLeft: review.left, + reviewRight: review.right, + terminalLeft: terminal.left, + terminalRight: terminal.right, + terminalTop: terminal.top, + reviewTop: review.top, + } + }) + expect(Math.abs(geometry.terminalLeft - geometry.reviewLeft)).toBeLessThanOrEqual(1) + expect(Math.abs(geometry.terminalRight - geometry.reviewRight)).toBeLessThanOrEqual(1) + expect(geometry.terminalTop).toBeGreaterThan(geometry.reviewTop) +} + +async function expectBottomGeometry(page: Page) { const geometry = await page.evaluate(() => { const review = document.querySelector("#review-panel")! const terminal = document.querySelector("#terminal-panel")! + const terminalRect = terminal.getBoundingClientRect() const reviewParent = review.parentElement!.getBoundingClientRect() const terminalParent = terminal.parentElement!.getBoundingClientRect() const sidebar = review.querySelector('[data-slot="session-review-v2-sidebar"]')! return { review: review.getBoundingClientRect().height, + reviewBottom: review.getBoundingClientRect().bottom, reviewParent: reviewParent.height, - terminal: terminal.getBoundingClientRect().height, + terminal: terminalRect.height, + terminalLeft: terminalRect.left, + terminalRight: terminalRect.right, + terminalTop: terminalRect.top, terminalParent: terminalParent.height, sidebar: sidebar.getBoundingClientRect().width, + viewport: window.innerWidth, } }) expect(Math.abs(geometry.review - geometry.reviewParent)).toBeLessThanOrEqual(1) expect(Math.abs(geometry.terminal - geometry.terminalParent)).toBeLessThanOrEqual(1) + expect(geometry.terminalTop - geometry.reviewBottom).toBeGreaterThanOrEqual(7) + expect(geometry.terminalTop - geometry.reviewBottom).toBeLessThanOrEqual(9) + expect(geometry.terminalLeft).toBeLessThanOrEqual(9) + expect(geometry.terminalRight).toBeGreaterThanOrEqual(geometry.viewport - 9) expect(geometry.sidebar).toBeGreaterThanOrEqual(240) } diff --git a/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts b/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts index 1f190ceb9ea..d9c85997cf2 100644 --- a/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts +++ b/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts @@ -84,11 +84,11 @@ test("transitions a streaming shell from writing through command execution", asy executed: true, }), ) - await expect(titleShimmer).toHaveAttribute("data-active", "false") + await expect(titleShimmer).toHaveAttribute("data-active", "true") await expect(subtitle).toHaveText(command) }) -test("shows and expands a running shell command without shimmering it", async ({ page }) => { +test("shimmers and expands a running shell command", async ({ page }) => { const id = "prt_shell_running_command" const command = "sleep 10 && echo done" await setupTimeline(page, { @@ -97,7 +97,7 @@ test("shows and expands a running shell command without shimmering it", async ({ }) const tool = page.locator(`[data-timeline-part-id="${id}"]`) - await expect(tool.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "false") + await expect(tool.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true") await expect(tool).not.toContainText("Writing command...") await expect(tool.locator('[data-component="shell-submessage"]')).toHaveText(command) await expect(tool.locator('[data-component="shell-submessage"] [data-component="text-shimmer"]')).toHaveCount(0) diff --git a/packages/app/src/runtime/i18n/en.ts b/packages/app/src/runtime/i18n/en.ts index fe3b072f462..057f47d32d7 100644 --- a/packages/app/src/runtime/i18n/en.ts +++ b/packages/app/src/runtime/i18n/en.ts @@ -922,6 +922,10 @@ export const dict = { "settings.general.row.shell.description": "Shell used by the terminal and agent tools", "settings.general.row.shell.autoDefault": "Auto (Default)", "settings.general.row.shell.terminalOnly": "terminal only", + "settings.general.row.terminalPlacement.title": "Terminal placement", + "settings.general.row.terminalPlacement.description": "Choose where the terminal opens in sessions", + "settings.general.row.terminalPlacement.side": "Side", + "settings.general.row.terminalPlacement.bottom": "Bottom", "settings.general.row.appearance.title": "Appearance", "settings.general.row.appearance.description": "Customise how OpenCode looks on your device", "settings.general.row.colorScheme.title": "Color scheme", diff --git a/packages/app/src/session/screen-layout.ts b/packages/app/src/session/screen-layout.ts index 50bda09eec5..48872ccb64c 100644 --- a/packages/app/src/session/screen-layout.ts +++ b/packages/app/src/session/screen-layout.ts @@ -15,6 +15,10 @@ export function createSessionScreenLayout(session: SessionModel, serverScope: st const reviewPanelOpen = createMemo(() => reviewOpen() && !!session.identity.params.id) const terminalOpen = createMemo(() => session.layout.view().terminal.opened()) const desktopTerminalOpen = createMemo(() => session.isDesktop() && terminalOpen()) + const sideTerminalOpen = createMemo(() => desktopTerminalOpen() && settings.general.terminalPlacement() === "side") + const bottomTerminalOpen = createMemo( + () => desktopTerminalOpen() && settings.general.terminalPlacement() === "bottom", + ) const fileTreeOpen = createMemo( () => session.isDesktop() && @@ -23,7 +27,7 @@ export function createSessionScreenLayout(session: SessionModel, serverScope: st opened: layout.fileTree.opened(), }), ) - const resizable = createMemo(() => reviewPanelOpen() || desktopTerminalOpen()) + const resizable = createMemo(() => reviewPanelOpen() || sideTerminalOpen()) const sidePanelOpen = createMemo(() => resizable() || fileTreeOpen()) const [rowWidth, setRowWidth] = createSignal() let row: HTMLDivElement | undefined @@ -57,7 +61,7 @@ export function createSessionScreenLayout(session: SessionModel, serverScope: st const panelLayout = createMemo(() => sessionPanelLayout({ review: reviewPanelOpen(), - terminal: desktopTerminalOpen(), + terminal: sideTerminalOpen(), files: fileTreeOpen(), }), ) @@ -97,11 +101,11 @@ export function createSessionScreenLayout(session: SessionModel, serverScope: st panelOpen: reviewPanelOpen, snap: reviewSnap, }, - side: { layout: panelLayout, open: sidePanelOpen }, + side: { layout: panelLayout }, size, terminal: { - desktopOpen: desktopTerminalOpen, - inlineOnlyOpen: createMemo(() => desktopTerminalOpen() && !reviewPanelOpen()), + bottomOpen: bottomTerminalOpen, + inlineOnlyOpen: createMemo(() => sideTerminalOpen() && !reviewPanelOpen()), open: terminalOpen, }, } diff --git a/packages/app/src/session/screen.tsx b/packages/app/src/session/screen.tsx index 5a3ef35364e..122b9775ddc 100644 --- a/packages/app/src/session/screen.tsx +++ b/packages/app/src/session/screen.tsx @@ -119,51 +119,88 @@ export function SessionScreen(props: { session: SessionModel }) { return ( -
-
- - {(_) => ( - - {sessionPanelContent()} - - )} - +
+
+
+ + {(_) => ( + + {sessionPanelContent()} + + )} + - -
screen.size.start()}> - { - screen.size.touch() - layout.session.resize(width) - }} - /> + +
screen.size.start()}> + { + screen.size.touch() + layout.session.resize(width) + }} + /> +
+
+
+ + +
+ +
+ +
+
+ +
screen.size.start()}> + { + screen.size.touch() + layout.terminal.resize(height) + }} + onCollapse={() => session.layout.view().terminal.close()} + /> +
+
+ +
+ +
+
- -
- -
- -
-
- -
screen.size.start()}> + +
+ +
screen.size.start()} + >
- -
- -
-
+
diff --git a/packages/app/src/session/timeline/virtualizer.tsx b/packages/app/src/session/timeline/virtualizer.tsx index 613deb61624..835087dc723 100644 --- a/packages/app/src/session/timeline/virtualizer.tsx +++ b/packages/app/src/session/timeline/virtualizer.tsx @@ -267,6 +267,7 @@ export function createTimelineVirtualizer(input: Input) { const item = createMemo(() => virtualItemByKey().get(rowProps.rowKey) ?? initialItem) const row = createMemo(() => rowByKey().get(rowProps.rowKey) ?? rows()[item().index] ?? initialRow) const [ready, setReady] = createSignal(initialItem.size <= fallbackItemSize || !props.deferred(initialRow)) + let contentMeasureFrame: number | undefined onMount(() => virtualizer.measureElement(element)) createEffect( @@ -278,7 +279,10 @@ export function createTimelineVirtualizer(input: Input) { { defer: true }, ), ) - onCleanup(() => virtualizer.measureElement(null)) + onCleanup(() => { + if (contentMeasureFrame !== undefined) cancelAnimationFrame(contentMeasureFrame) + queueMicrotask(() => virtualizer.measureElement(null)) + }) return (
{props.renderRow(row, () => { setReady(true) + if (contentMeasureFrame !== undefined) cancelAnimationFrame(contentMeasureFrame) + contentMeasureFrame = requestAnimationFrame(() => { + contentMeasureFrame = undefined + if (element.isConnected) virtualizer.measureElement(element) + }) })}
diff --git a/packages/app/src/settings/general/general.tsx b/packages/app/src/settings/general/general.tsx index 3eabfd18500..721d31432e8 100644 --- a/packages/app/src/settings/general/general.tsx +++ b/packages/app/src/settings/general/general.tsx @@ -7,7 +7,7 @@ import { TextInput } from "@opencode-ai/ui/text-input" import { useLanguage } from "@/runtime/i18n/language" import { usePlatform } from "@/runtime/platform/platform" import { useUpdaterAction } from "@/shell/updates/action" -import { type WorkspaceDefaultDestination, useSettings } from "@/settings/model" +import { type TerminalPlacement, type WorkspaceDefaultDestination, useSettings } from "@/settings/model" import { ExternalLink } from "@/runtime/platform/external-link" import { SettingsList } from "@/settings/list" import { SettingsRow } from "@/settings/row" @@ -123,6 +123,33 @@ const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) ) } +const TerminalPlacementSetting: Component = () => { + const language = useLanguage() + const settings = useSettings() + const options = createMemo((): { value: TerminalPlacement; label: string }[] => [ + { value: "side", label: language.t("settings.general.row.terminalPlacement.side") }, + { value: "bottom", label: language.t("settings.general.row.terminalPlacement.bottom") }, + ]) + + return ( + +