diff --git a/packages/app/e2e/regression/review-terminal-bottom.spec.ts b/packages/app/e2e/regression/review-terminal-bottom.spec.ts index 88dd108d88b..c8efac235dd 100644 --- a/packages/app/e2e/regression/review-terminal-bottom.spec.ts +++ b/packages/app/e2e/regression/review-terminal-bottom.spec.ts @@ -179,6 +179,7 @@ async function expectMountedTree(page: Page, total: number) { } async function expectSideGeometry(page: Page) { + await expectPanelGap(page, 8) const geometry = await page.evaluate(() => { const review = document.querySelector("#review-panel")!.getBoundingClientRect() const terminal = document.querySelector("#terminal-panel")!.getBoundingClientRect() @@ -188,15 +189,20 @@ async function expectSideGeometry(page: Page) { terminalLeft: terminal.left, terminalRight: terminal.right, terminalTop: terminal.top, + terminalBottom: terminal.bottom, reviewTop: review.top, + reviewBottom: review.bottom, } }) expect(Math.abs(geometry.terminalLeft - geometry.reviewLeft)).toBeLessThanOrEqual(1) expect(Math.abs(geometry.terminalRight - geometry.reviewRight)).toBeLessThanOrEqual(1) expect(geometry.terminalTop).toBeGreaterThan(geometry.reviewTop) + expect(geometry.terminalTop - geometry.reviewBottom).toBeGreaterThanOrEqual(7) + expect(geometry.terminalTop - geometry.reviewBottom).toBeLessThanOrEqual(9) } async function expectBottomGeometry(page: Page) { + await expectPanelGap(page, 8) const geometry = await page.evaluate(() => { const review = document.querySelector("#review-panel")! const terminal = document.querySelector("#terminal-panel")! @@ -226,6 +232,30 @@ async function expectBottomGeometry(page: Page) { expect(geometry.sidebar).toBeGreaterThanOrEqual(240) } +async function expectPanelGap(page: Page, expected: number) { + await expect + .poll(() => { + return page.evaluate(() => { + const review = document.querySelector("#review-panel")?.getBoundingClientRect() + const terminal = document.querySelector("#terminal-panel")?.getBoundingClientRect() + if (!review || !terminal) return Number.NEGATIVE_INFINITY + const gap = terminal.top - review.bottom + return gap + }) + }) + .toBeGreaterThanOrEqual(expected - 1) + await expect + .poll(() => { + return page.evaluate(() => { + const review = document.querySelector("#review-panel")?.getBoundingClientRect() + const terminal = document.querySelector("#terminal-panel")?.getBoundingClientRect() + if (!review || !terminal) return Number.POSITIVE_INFINITY + return terminal.top - review.bottom + }) + }) + .toBeLessThanOrEqual(expected + 1) +} + function base64Encode(value: string) { return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "") } diff --git a/packages/app/e2e/regression/terminal-composer-focus.spec.ts b/packages/app/e2e/regression/terminal-composer-focus.spec.ts index b395a5ec0f6..b7013e8c81a 100644 --- a/packages/app/e2e/regression/terminal-composer-focus.spec.ts +++ b/packages/app/e2e/regression/terminal-composer-focus.spec.ts @@ -243,7 +243,8 @@ test("focuses a terminal created from the new-terminal button", async ({ page }) await page.getByRole("button", { name: "New terminal" }).click() await expect(page.getByRole("tab", { name: "Terminal 2" })).toHaveAttribute("aria-selected", "true") - await expect.poll(() => terminal.evaluate((element) => element.contains(document.activeElement))).toBe(true) + const active = page.locator(`#terminal-wrapper-${newPtyID} [data-component="terminal"]`) + await expect.poll(() => active.evaluate((element) => element.contains(document.activeElement))).toBe(true) }) function seedCachedTerminal(page: Page) { diff --git a/packages/app/e2e/regression/terminal-hidden.spec.ts b/packages/app/e2e/regression/terminal-hidden.spec.ts index 66a5a92ef1e..a66f6655909 100644 --- a/packages/app/e2e/regression/terminal-hidden.spec.ts +++ b/packages/app/e2e/regression/terminal-hidden.spec.ts @@ -1,4 +1,4 @@ -import { expect, test } from "@playwright/test" +import { expect, test, type Page } from "@playwright/test" import { mockOpenCodeServer } from "../utils/mock-server" import { expectSessionTitle } from "../utils/waits" @@ -8,7 +8,7 @@ const sessionID = "ses_hidden_terminal_regression" const title = "Hidden terminal regression" const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` -test("unmounts the terminal panel while it is hidden", async ({ page }) => { +test("animates review and terminal panels while caching hidden terminal content", async ({ page }) => { await page.setViewportSize({ width: 1400, height: 900 }) await mockOpenCodeServer(page, { directory, @@ -42,6 +42,16 @@ test("unmounts the terminal panel while it is hidden", async ({ page }) => { time: { created: 1700000000000, updated: 1700000000000 }, }, ], + vcsDiff: [ + { + file: "src/animation.ts", + additions: 1, + deletions: 1, + status: "modified", + patch: + "diff --git a/src/animation.ts b/src/animation.ts\n--- a/src/animation.ts\n+++ b/src/animation.ts\n@@ -1 +1 @@\n-export const value = 'before'\n+export const value = 'after'\n", + }, + ], pageMessages: () => ({ items: [] }), }) await page.route("**/api/pty*", (route) => @@ -94,24 +104,403 @@ test("unmounts the terminal panel while it is hidden", async ({ page }) => { await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) await expectSessionTitle(page, title) + await installMotionProbe(page) + + const reviewToggle = page.getByRole("button", { name: "Toggle review" }) + await reviewToggle.click() + await expect(page.locator("#review-panel")).toBeVisible() + await expectWidthMotions(page, 1) + await expectReviewWidthStable(page) + await expectLogicalSideAlignment(page, "ltr") + await page.evaluate(() => (document.documentElement.dir = "rtl")) + await expectLogicalSideAlignment(page, "rtl") + await page.evaluate(() => (document.documentElement.dir = "ltr")) - await page.keyboard.press("Control+Backquote") const panel = page.locator("#terminal-panel") + const terminalContent = page.locator('[data-component="terminal"]') + await page.keyboard.press("Control+Backquote") + await expect(panel).toBeVisible() + await expect(terminalContent).toBeVisible() + await terminalContent.evaluate((element) => element.setAttribute("data-cache-probe", "original")) + await expectHeightMotions(page, "session-side-region", 1) + await expectHeightMotions(page, "session-side-terminal-region", 1) + await expectStackedGeometry(page) + await expectPanelGapHeld(page) + + await resetTerminalTopMotion(page) + await resetTerminalBottomMotion(page) + await resetTerminalAnchorGaps(page) + await resetPanelGaps(page) + await reviewToggle.click() + await expect(page.locator("#review-panel")).toHaveCount(0) + await expect(panel).toBeVisible() + await expectHeightMotions(page, "session-side-region", 2) + await expectHeightMotions(page, "session-side-terminal-region", 2) + await expectTerminalTopMotion(page) + await expectTerminalBottomFixed(page) + await expectTerminalTopAnchored(page) + await expectPanelGapHeld(page) + await reviewToggle.click() + await expect(page.locator("#review-panel")).toBeVisible() + await expectHeightMotions(page, "session-side-region", 3) + await expectHeightMotions(page, "session-side-terminal-region", 3) + + await resetTerminalContentSizes(page) + await resetPanelGaps(page) + await page.keyboard.press("Control+Backquote") + await expect(page.locator('[data-slot="side-terminal-panel-clip"]')).toHaveCSS("overflow", "clip") + await expectHeightMotions(page, "session-side-region", 4) + await expectHeightMotions(page, "session-side-terminal-region", 4) + await expect(panel).toBeHidden() + await expect(terminalContent).toHaveAttribute("data-cache-probe", "original") + await expectTerminalContentCachedSize(page) + await expectStackPainted(page) + await expectPanelGapHeld(page) + await expect(page.locator('[data-slot="session-side-panel-gap"]')).toHaveCSS("height", "0px") + + await reviewToggle.click() + await expect(page.locator("#review-panel")).toHaveCount(0) + await expectWidthMotions(page, 2) + + await resetHeightMotions(page) + await page.keyboard.press("Control+Backquote") await expect(panel).toHaveAttribute("aria-hidden", "false") await expect(page.locator('[data-component="terminal"]')).toBeVisible() + await expectWidthMotions(page, 3) + await expectSideMotionSettled(page) + await expectNoHeightMotion(page) await page.keyboard.press("Control+Backquote") - await expect(panel).toHaveCount(0) - await expect(page.locator('[data-component="terminal"]')).toHaveCount(0) + await expect(panel).toBeHidden() + await expect(terminalContent).toHaveAttribute("data-cache-probe", "original") + await expectWidthMotions(page, 4) await page.setViewportSize({ width: 1200, height: 700 }) - await expect(page.locator('[data-component="terminal"]')).toHaveCount(0) + await expect(terminalContent).toHaveAttribute("data-cache-probe", "original") await page.keyboard.press("Control+Backquote") await expect(panel).toBeVisible() - await expect(page.locator('[data-component="terminal"]')).toBeVisible() + await expect(terminalContent).toBeVisible() + await expect(terminalContent).toHaveAttribute("data-cache-probe", "original") + await expectWidthMotions(page, 5) + + await page.keyboard.press("Control+Backquote") + await expect(panel).toBeHidden() + + 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 expectSessionTitle(page, title) + await installMotionProbe(page) + + await page.keyboard.press("Control+Backquote") + await expect(panel).toBeVisible() + await expectAnimation(page, "terminal-panel-size-in") + await page.keyboard.press("Control+Backquote") + await expectAnimation(page, "terminal-panel-size-out") + await expect(panel).toBeHidden() + await expect(page.locator('[data-component="terminal"]')).toBeAttached() }) +type MotionProbe = { + widths: number + reviewWidths: number[] + paintGaps: { review: number; terminalSurface: number }[] + terminalContentSizes: { width: number; height: number }[] + terminalAnchorGaps: number[] + resetAnchorOnMotion: boolean + panelGaps: number[] + terminalTops: number[] + terminalBottoms: number[] + heights: string[] + animations: string[] +} + +async function installMotionProbe(page: Page) { + await page.evaluate(() => { + const probe: MotionProbe = { + widths: 0, + reviewWidths: [], + paintGaps: [], + terminalContentSizes: [], + terminalAnchorGaps: [], + resetAnchorOnMotion: false, + panelGaps: [], + terminalTops: [], + terminalBottoms: [], + heights: [], + animations: [], + } + const observed = new WeakSet() + const observers: ResizeObserver[] = [] + const observeReview = () => { + const review = document.querySelector('[data-component="session-review-v2"]') + if (!review || observed.has(review)) return + observed.add(review) + const observer = new ResizeObserver(([entry]) => probe.reviewWidths.push(entry.contentRect.width)) + observer.observe(review) + observers.push(observer) + } + const observedRegions = new WeakSet() + const observeStack = () => { + const reviewRegion = document.querySelector('[data-slot="session-side-region"]') + const terminalRegion = document.querySelector('[data-slot="session-side-terminal-region"]') + if (!reviewRegion || !terminalRegion || observedRegions.has(reviewRegion)) return + observedRegions.add(reviewRegion) + const observer = new ResizeObserver(() => { + const review = document.querySelector("#review-panel") + const terminal = document.querySelector("#terminal-panel") + const terminalContent = document.querySelector('[data-slot="terminal-panel-content"]') + const panelGap = document.querySelector('[data-slot="session-side-panel-gap"]') + if (!terminal || !terminalContent) return + probe.terminalTops.push(terminal.getBoundingClientRect().top) + probe.terminalBottoms.push(terminal.getBoundingClientRect().bottom) + probe.terminalContentSizes.push({ + width: terminalContent.getBoundingClientRect().width, + height: terminalContent.getBoundingClientRect().height, + }) + const anchorGap = Math.abs(terminal.getBoundingClientRect().top - terminalContent.getBoundingClientRect().top) + if (probe.resetAnchorOnMotion) { + if (anchorGap > 8) return + probe.terminalAnchorGaps = [] + probe.resetAnchorOnMotion = false + } + probe.terminalAnchorGaps.push(anchorGap) + if (panelGap && terminalRegion.getBoundingClientRect().height > 1) + probe.panelGaps.push(panelGap.getBoundingClientRect().height) + if (!review) return + probe.paintGaps.push({ + review: Math.abs(reviewRegion.getBoundingClientRect().height - review.getBoundingClientRect().height), + terminalSurface: Math.abs( + terminalRegion.getBoundingClientRect().height - terminal.getBoundingClientRect().height, + ), + }) + }) + observer.observe(reviewRegion) + observer.observe(terminalRegion) + observers.push(observer) + } + new MutationObserver(() => { + observeReview() + observeStack() + }).observe(document.body, { childList: true, subtree: true }) + observeReview() + observeStack() + document.addEventListener("transitionrun", (event) => { + if (!(event.target instanceof Element)) return + const slot = event.target.getAttribute("data-slot") + if (event.propertyName === "width" && slot === "session-chat-panel") probe.widths++ + if (event.propertyName === "height" && slot) { + probe.heights.push(slot) + } + }) + document.addEventListener("animationstart", (event) => { + if (!(event.target instanceof Element) || event.target.getAttribute("data-component") !== "terminal-panel") return + probe.animations.push(event.animationName) + }) + ;(window as Window & { __panelMotion?: MotionProbe }).__panelMotion = probe + }) +} + +async function expectWidthMotions(page: Page, count: number) { + await expect + .poll(() => page.evaluate(() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.widths ?? 0)) + .toBeGreaterThanOrEqual(count) +} + +async function resetHeightMotions(page: Page) { + await page.evaluate(() => { + const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion + if (probe) probe.heights = [] + }) +} + +async function expectSideMotionSettled(page: Page) { + const side = page.locator('[data-slot="session-side-panel-presence"]') + await expect + .poll(() => side.evaluate((element) => element.getAnimations().every((item) => item.playState === "finished"))) + .toBe(true) +} + +async function expectNoHeightMotion(page: Page) { + const heights = await page.evaluate( + () => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.heights ?? [], + ) + expect(heights).toEqual([]) +} + +async function expectReviewWidthStable(page: Page) { + const side = page.locator('[data-slot="session-side-panel-presence"]') + await expect + .poll(() => side.evaluate((element) => element.getAnimations().every((item) => item.playState === "finished"))) + .toBe(true) + await expect + .poll(() => + page.evaluate(() => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.reviewWidths.length ?? 0), + ) + .toBeGreaterThan(0) + const widths = await page.evaluate( + () => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.reviewWidths.map(Math.round) ?? [], + ) + expect(new Set(widths).size).toBe(1) +} + +async function expectStackedGeometry(page: Page) { + await expect + .poll(() => + page.evaluate(() => { + const review = document.querySelector("#review-panel")?.getBoundingClientRect() + const terminal = document.querySelector("#terminal-panel")?.getBoundingClientRect() + if (!review || !terminal) return Number.POSITIVE_INFINITY + return terminal.top - review.bottom + }), + ) + .toBeLessThanOrEqual(9) + await expect + .poll(() => + page.evaluate(() => { + const review = document.querySelector("#review-panel")?.getBoundingClientRect() + const terminal = document.querySelector("#terminal-panel")?.getBoundingClientRect() + if (!review || !terminal) return Number.NEGATIVE_INFINITY + return terminal.top - review.bottom + }), + ) + .toBeGreaterThanOrEqual(7) +} + +async function expectLogicalSideAlignment(page: Page, direction: "ltr" | "rtl") { + await expect + .poll(() => + page.evaluate((direction) => { + const frame = document.querySelector('[data-slot="session-side-panel-presence"]')?.getBoundingClientRect() + const content = document.querySelector('[data-slot="session-side-panel-content"]')?.getBoundingClientRect() + if (!frame || !content) return Number.POSITIVE_INFINITY + return direction === "rtl" ? Math.abs(frame.right - content.right) : Math.abs(frame.left - content.left) + }, direction), + ) + .toBeLessThanOrEqual(1) +} + +async function expectStackPainted(page: Page) { + const gaps = await page.evaluate( + () => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.paintGaps ?? [], + ) + expect(gaps.length).toBeGreaterThan(0) + expect(Math.max(...gaps.map((gap) => gap.review))).toBeLessThanOrEqual(1) + expect(Math.max(...gaps.map((gap) => gap.terminalSurface)), JSON.stringify(gaps)).toBeLessThanOrEqual(1) +} + +async function resetTerminalTopMotion(page: Page) { + await page.evaluate(() => { + const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion + if (probe) probe.terminalTops = [] + }) +} + +async function resetTerminalBottomMotion(page: Page) { + await page.evaluate(() => { + const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion + if (probe) probe.terminalBottoms = [] + }) +} + +async function expectTerminalBottomFixed(page: Page) { + const bottoms = await page.evaluate( + () => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.terminalBottoms ?? [], + ) + expect(bottoms.length).toBeGreaterThan(0) + expect(Math.max(...bottoms) - Math.min(...bottoms)).toBeLessThanOrEqual(1) +} + +async function resetTerminalAnchorGaps(page: Page) { + await page.evaluate(() => { + const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion + if (probe) probe.resetAnchorOnMotion = true + }) +} + +async function resetPanelGaps(page: Page) { + await page.evaluate(() => { + const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion + if (probe) probe.panelGaps = [] + }) +} + +async function expectPanelGapHeld(page: Page) { + const gaps = await page.evaluate( + () => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.panelGaps ?? [], + ) + expect(gaps.length).toBeGreaterThan(0) + expect(gaps.filter((gap) => gap >= 7 && gap <= 9).length / gaps.length).toBeGreaterThan(0.6) + expect(Math.min(...gaps)).toBeGreaterThanOrEqual(0) + expect(Math.max(...gaps)).toBeLessThanOrEqual(9) +} + +async function expectTerminalTopAnchored(page: Page) { + const gaps = await page.evaluate( + () => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.terminalAnchorGaps ?? [], + ) + expect(gaps.length).toBeGreaterThan(0) + expect(Math.max(...gaps), JSON.stringify(gaps)).toBeLessThanOrEqual(8) +} + +async function resetTerminalContentSizes(page: Page) { + await page.evaluate(() => { + const probe = (window as Window & { __panelMotion?: MotionProbe }).__panelMotion + if (probe) probe.terminalContentSizes = [] + }) +} + +async function expectTerminalContentCachedSize(page: Page) { + const sizes = await page.evaluate( + () => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.terminalContentSizes ?? [], + ) + expect(sizes.length).toBeGreaterThan(0) + expect(Math.min(...sizes.map((size) => size.width))).toBeGreaterThan(100) + expect(Math.min(...sizes.map((size) => size.height))).toBeGreaterThan(100) +} + +async function expectTerminalTopMotion(page: Page) { + const tops = await page.evaluate( + () => (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.terminalTops.map(Math.round) ?? [], + ) + const unique = [...new Set(tops)] + const range = Math.max(...unique) - Math.min(...unique) + const maxDelta = Math.max(...unique.slice(1).map((value, index) => Math.abs(value - unique[index]))) + expect(unique.length, JSON.stringify(unique)).toBeGreaterThan(6) + expect(maxDelta, JSON.stringify({ unique, range, maxDelta })).toBeLessThan(range * 0.3) +} + +async function expectHeightMotions(page: Page, slot: string, count: number) { + await expect + .poll(() => + page.evaluate( + (slot) => + (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.heights.filter((value) => value === slot) + .length ?? 0, + slot, + ), + ) + .toBeGreaterThanOrEqual(count) +} + +async function expectAnimation(page: Page, name: string) { + await expect + .poll(() => + page.evaluate( + (name) => + (window as Window & { __panelMotion?: MotionProbe }).__panelMotion?.animations.includes(name) ?? false, + name, + ), + ) + .toBe(true) +} + function base64Encode(value: string) { return Buffer.from(value, "utf8").toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=/g, "") } diff --git a/packages/app/src/index.css b/packages/app/src/index.css index e0751bfb389..2635fbd6f24 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -33,6 +33,133 @@ } @layer components { + [data-slot="session-side-panel-presence"][data-opened="true"] { + animation: terminal-panel-presence-in 240ms cubic-bezier(0.22, 1, 0.36, 1); + } + + [data-slot="session-side-panel-presence"][data-opened="false"] { + animation: terminal-panel-presence-out 240ms cubic-bezier(0.22, 1, 0.36, 1) forwards; + } + + [data-slot="session-side-region-presence"][data-opened="true"] { + animation: side-region-presence-in 240ms cubic-bezier(0.22, 1, 0.36, 1); + } + + [data-slot="session-side-region-presence"][data-opened="false"] { + animation: side-region-presence-out 240ms cubic-bezier(0.22, 1, 0.36, 1); + } + + [data-slot="terminal-panel-presence"][data-opened="true"] { + animation: terminal-panel-presence-in 200ms cubic-bezier(0.22, 1, 0.36, 1); + } + + [data-slot="terminal-panel-presence"][data-opened="false"] { + animation: terminal-panel-presence-out 200ms cubic-bezier(0.22, 1, 0.36, 1) forwards; + } + + [data-slot="side-terminal-panel-presence"][data-opened="true"] { + animation: side-terminal-panel-presence-in 240ms cubic-bezier(0.22, 1, 0.36, 1); + } + + [data-slot="side-terminal-panel-presence"][data-opened="false"] { + animation: side-terminal-panel-presence-out 240ms cubic-bezier(0.22, 1, 0.36, 1) forwards; + } + + [data-component="terminal-panel"][data-size-animated="true"][data-opened="true"] { + animation: terminal-panel-size-in 200ms cubic-bezier(0.22, 1, 0.36, 1); + } + + [data-component="terminal-panel"][data-size-animated="true"][data-opened="false"] { + animation: terminal-panel-size-out 200ms cubic-bezier(0.22, 1, 0.36, 1); + } + + @media (prefers-reduced-motion: reduce) { + [data-slot="terminal-panel-presence"], + [data-slot="side-terminal-panel-presence"], + [data-slot="session-side-panel-presence"], + [data-slot="session-side-region-presence"], + [data-component="terminal-panel"] { + animation: none !important; + } + } + + @keyframes terminal-panel-presence-in { + from { + opacity: 0; + } + to { + opacity: 1; + } + } + + @keyframes terminal-panel-presence-out { + from { + opacity: 1; + } + to { + opacity: 0; + } + } + + @keyframes side-terminal-panel-presence-in { + from { + opacity: 0.999999; + } + to { + opacity: 1; + } + } + + @keyframes side-terminal-panel-presence-out { + from { + opacity: 1; + } + to { + opacity: 0.999999; + visibility: hidden; + } + } + + @keyframes side-region-presence-in { + from { + opacity: 0; + } + 0.01% { + opacity: 0.999999; + } + to { + opacity: 1; + } + } + + @keyframes side-region-presence-out { + from { + opacity: 1; + } + to { + opacity: 0.999999; + visibility: hidden; + } + } + + @keyframes terminal-panel-size-in { + from { + height: 0; + } + to { + height: var(--terminal-panel-height); + } + } + + @keyframes terminal-panel-size-out { + from { + height: var(--terminal-panel-height); + } + to { + height: 0; + } + } + [data-component="getting-started"] { container-type: inline-size; container-name: getting-started; diff --git a/packages/app/src/session/files/session-side-panel.tsx b/packages/app/src/session/files/session-side-panel.tsx index 5874bbf8194..562752313e4 100644 --- a/packages/app/src/session/files/session-side-panel.tsx +++ b/packages/app/src/session/files/session-side-panel.tsx @@ -62,7 +62,7 @@ export function SessionSidePanel(props: { fileBrowserState: SessionFileBrowserState activeDiff?: string focusReviewDiff: (path: string) => void - reviewSnap: boolean + reviewPresent?: boolean size: Sizing stacked?: boolean }) { @@ -79,6 +79,7 @@ export function SessionSidePanel(props: { const shown = settings.visibility.fileTree const reviewOpen = createMemo(() => isDesktop() && view().reviewPanel.opened()) + const reviewVisible = createMemo(() => reviewOpen() || !!props.reviewPresent) const fileOpen = createMemo( () => isDesktop() && @@ -88,11 +89,12 @@ export function SessionSidePanel(props: { }), ) const open = createMemo(() => reviewOpen() || fileOpen()) + const visible = createMemo(() => reviewVisible() || fileOpen()) const fileTreeWidth = createMemo(() => Math.max(FILE_TREE_WIDTH_MIN, layout.fileTree.width())) const reviewTab = createMemo(() => isDesktop()) const panelWidth = createMemo(() => { - if (!open()) return "0px" - if (reviewOpen()) return "auto" + if (!visible()) return "0px" + if (reviewVisible()) return "auto" return `${fileTreeWidth()}px` }) const treeWidth = createMemo(() => (fileOpen() ? `${fileTreeWidth()}px` : "0px")) @@ -252,14 +254,14 @@ export function SessionSidePanel(props: { "h-full min-h-0": 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, - "flex-1": reviewOpen(), + !props.size.active(), + "flex-1": reviewVisible(), }} style={{ width: panelWidth() }} > - +
- +
{ + createEffect(() => { + const stop = location().event.listen((event) => { if (event.type === "filesystem.changed") refresh() - }), - ) + }) + onCleanup(stop) + }) createEffect( on( () => input.screen.review.open() || mobileChanges(), diff --git a/packages/app/src/session/review/view.tsx b/packages/app/src/session/review/view.tsx index 633b1a7e018..687fcb98ac0 100644 --- a/packages/app/src/session/review/view.tsx +++ b/packages/app/src/session/review/view.tsx @@ -58,7 +58,7 @@ export function SessionMobileReview(props: { review: SessionReviewModel }) { ) } -export function SessionDesktopReview(props: { review: SessionReviewModel }) { +export function SessionDesktopReview(props: { review: SessionReviewModel; present?: boolean }) { return ( diff --git a/packages/app/src/session/route.tsx b/packages/app/src/session/route.tsx index 827974fb758..e1618f4e1d2 100644 --- a/packages/app/src/session/route.tsx +++ b/packages/app/src/session/route.tsx @@ -2,12 +2,11 @@ import { ErrorBoundary, createEffect, createMemo, Show, type ParentProps } from import { useParams } from "@solidjs/router" import { CommentsProvider } from "@/composer/comments" import { FileProvider } from "@/workspaces/files/model" -import { LocationProvider, useWorkspaceLocation } from "@/workspaces/location" +import { LocationProvider } from "@/workspaces/location" import { ModelsProvider } from "@/providers/models/models" import { useNotification } from "@/shell/notifications/notification" import { ComposerPersistenceProvider } from "@/composer/persistence" import { useData, useServer } from "@/runtime/server/current" -import { useServerSDK } from "@/runtime/server/client" import { ServerConnection } from "@/runtime/server/registry" import { TerminalProvider } from "@/session/terminal/context" import { useSettingsCommand } from "@/settings/command" @@ -86,7 +85,7 @@ function ResolvedTargetSessionRoute() { > }> {(value) => ( - + @@ -114,23 +113,18 @@ function SessionStatePanel(props: ParentProps) { } function TargetSessionPage() { - const location = useWorkspaceLocation() - const server = useServerSDK() - return ( - // Keep workspace-scoped file, prompt, comment, and terminal state alive when - // the user switches between Sessions in the same workspace. - - - - - - - - - - - + // These providers select their scoped state reactively and retain bounded caches, + // so keep their owners alive while navigating between workspaces on this server. + + + + + + + + + ) } diff --git a/packages/app/src/session/screen-layout.ts b/packages/app/src/session/screen-layout.ts index 48872ccb64c..0e7e0f36de6 100644 --- a/packages/app/src/session/screen-layout.ts +++ b/packages/app/src/session/screen-layout.ts @@ -1,4 +1,5 @@ -import { createComputed, createMemo, createSignal, onCleanup } from "solid-js" +import { createEffect, createMemo } from "solid-js" +import { createStore } from "solid-js/store" import { createResizeObserver } from "@solid-primitives/resize-observer" import { useLayout } from "@/shell/state/layout" import { useSettings } from "@/settings/model" @@ -14,11 +15,9 @@ export function createSessionScreenLayout(session: SessionModel, serverScope: st const reviewOpen = createMemo(() => session.isDesktop() && session.layout.view().reviewPanel.opened()) 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 sideTerminal = createMemo(() => session.isDesktop() && settings.general.terminalPlacement() === "side") + const bottomTerminal = createMemo(() => session.isDesktop() && settings.general.terminalPlacement() === "bottom") + const sideTerminalOpen = createMemo(() => terminalOpen() && sideTerminal()) const fileTreeOpen = createMemo( () => session.isDesktop() && @@ -29,14 +28,14 @@ export function createSessionScreenLayout(session: SessionModel, serverScope: st ) const resizable = createMemo(() => reviewPanelOpen() || sideTerminalOpen()) const sidePanelOpen = createMemo(() => resizable() || fileTreeOpen()) - const [rowWidth, setRowWidth] = createSignal() + const [rowSize, setRowSize] = createStore<{ width?: number; height?: number }>({}) let row: HTMLDivElement | undefined createResizeObserver( () => row, - ({ width }) => setRowWidth(width), + ({ width, height }) => setRowSize({ width, height }), ) const available = createMemo(() => { - const width = rowWidth() + const width = rowSize.width if (width === undefined) return undefined return width - 8 }) @@ -65,24 +64,30 @@ export function createSessionScreenLayout(session: SessionModel, serverScope: st files: fileTreeOpen(), }), ) - const [reviewSnap, setReviewSnap] = createSignal(false) - let reviewFrame: number | undefined - createComputed((previous) => { - const open = reviewOpen() - if (previous === undefined || previous === open) return open - - if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame) - setReviewSnap(true) - reviewFrame = requestAnimationFrame(() => { - reviewFrame = undefined - setReviewSnap(false) - }) - return open - }, reviewOpen()) - onCleanup(() => { - if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame) + const [motion, setMotion] = createStore({ gap: panelLayout().stacked, closing: false }) + createEffect((previous) => { + const stacked = panelLayout().stacked + if (previous !== stacked) setMotion({ gap: stacked, closing: !stacked }) + return stacked + }, panelLayout().stacked) + const sideRegionOpen = createMemo(() => reviewPanelOpen() || fileTreeOpen()) + const terminalPane = createMemo(() => + Math.min(layout.terminal.height(), typeof window === "undefined" ? 600 : window.innerHeight * 0.6), + ) + const terminalPaneHeight = createMemo(() => `${terminalPane()}px`) + const sideHeight = createMemo(() => rowSize.height) + const fullSideHeight = createMemo(() => (sideHeight() === undefined ? "100%" : `${sideHeight()}px`)) + const stackedReviewHeight = createMemo(() => { + const height = sideHeight() + if (height === undefined) return `calc(100% - ${terminalPaneHeight()} - 8px)` + return `${Math.max(0, height - terminalPane() - 8)}px` }) - + const sideContentWidth = createMemo((previous) => { + const width = available() + if (resizable() && width !== undefined) return `${Math.max(0, width - resizedWidth())}px` + if (fileTreeOpen()) return `${layout.fileTree.width()}px` + return previous + }, "100%") return { centered: createMemo(() => session.isDesktop()), files: { open: fileTreeOpen }, @@ -99,14 +104,36 @@ export function createSessionScreenLayout(session: SessionModel, serverScope: st review: { open: reviewOpen, panelOpen: reviewPanelOpen, - snap: reviewSnap, }, - side: { layout: panelLayout }, + side: { + contentWidth: sideContentWidth, + gap: { + closing: () => motion.closing, + height: createMemo(() => (motion.gap ? "8px" : "0px")), + }, + layout: panelLayout, + region: { + height: createMemo(() => { + if (!sideRegionOpen()) return "0px" + if (sideTerminalOpen()) return stackedReviewHeight() + return fullSideHeight() + }), + open: sideRegionOpen, + }, + terminal: { + contentHeight: createMemo(() => (sideRegionOpen() ? terminalPaneHeight() : fullSideHeight())), + height: createMemo(() => { + if (!sideTerminalOpen()) return "0px" + if (sideRegionOpen()) return terminalPaneHeight() + return fullSideHeight() + }), + }, + }, size, terminal: { - bottomOpen: bottomTerminalOpen, - inlineOnlyOpen: createMemo(() => sideTerminalOpen() && !reviewPanelOpen()), + bottom: bottomTerminal, open: terminalOpen, + side: sideTerminal, }, } } diff --git a/packages/app/src/session/screen.tsx b/packages/app/src/session/screen.tsx index decf16a708d..e5c56685fe9 100644 --- a/packages/app/src/session/screen.tsx +++ b/packages/app/src/session/screen.tsx @@ -1,5 +1,6 @@ import { ErrorBoundary, Show, Match, Switch, createMemo, createEffect, createComputed, on } from "solid-js" import { createStore } from "solid-js/store" +import createPresence from "solid-presence" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { SessionHeader } from "@/session/header/session-header" import { useLayout } from "@/shell/state/layout" @@ -28,7 +29,42 @@ export function SessionScreen(props: { session: SessionModel }) { const screen = createSessionScreenLayout(session, serverSDK.scope) const timeline = createSessionTimelineInteraction(session) const messagesReady = timeline.ready - const [store, setStore] = createStore({ deferRender: false }) + const [store, setStore] = createStore({ + deferRender: false, + bottomTerminalCached: false, + sideHeightMotion: false, + sideRegionPresent: false, + sideReviewPresent: false, + sideTerminalPresent: false, + }) + const [elements, setElements] = createStore<{ + side?: HTMLDivElement + bottomTerminal?: HTMLDivElement + }>({}) + const sideVisible = createMemo(() => isDesktop() && screen.side.layout().visible) + const sideTerminalVisible = createMemo(() => isDesktop() && screen.terminal.side() && screen.terminal.open()) + const bottomTerminalVisible = createMemo(() => screen.terminal.open() && (!isDesktop() || screen.terminal.bottom())) + const sidePresence = createPresence({ + show: sideVisible, + element: () => elements.side ?? null, + }) + const bottomTerminalPresence = createPresence({ + show: bottomTerminalVisible, + element: () => elements.bottomTerminal ?? null, + }) + createEffect(() => { + if (sideTerminalVisible()) setStore("sideTerminalPresent", true) + if (bottomTerminalVisible()) setStore("bottomTerminalCached", true) + if (!sideVisible()) setStore("sideHeightMotion", false) + }) + createEffect(() => { + if (!isDesktop() || screen.terminal.bottom()) setStore("sideTerminalPresent", false) + if (isDesktop() && screen.terminal.side()) setStore("bottomTerminalCached", false) + }) + createEffect(() => { + if (screen.side.region.open()) setStore("sideRegionPresent", true) + if (screen.review.panelOpen()) setStore("sideReviewPresent", true) + }) createComputed((prev) => { const key = session.identity.sessionKey() @@ -75,15 +111,9 @@ export function SessionScreen(props: { session: SessionModel }) { - + - + {(_id) => (
- -
- -
- -
-
- -
screen.size.start()}> - { - screen.size.touch() - layout.terminal.resize(height) - }} - onCollapse={() => session.layout.view().terminal.close()} - /> -
-
- + +
setElements("side", element)} + data-slot="session-side-panel-presence" + data-opened={sideVisible()} + onAnimationEnd={(event) => { + if (event.currentTarget !== event.target) return + if (event.animationName !== "terminal-panel-presence-in" || !sideVisible()) return + setStore("sideHeightMotion", true) + }} + classList={{ + "relative z-0 min-w-0 h-full flex-1 overflow-visible": sidePresence.present(), + "absolute inset-y-0 end-0 z-0 w-0 invisible pointer-events-none overflow-visible": + !sidePresence.present(), + }} + > +
- + +
{ + if (event.currentTarget !== event.target) return + if (event.animationName !== "side-region-presence-out") return + if (screen.side.region.open()) return + setStore("sideRegionPresent", false) + setStore("sideReviewPresent", false) + }} + > + +
+
- +
+
screen.size.start()} + > + + { + screen.size.touch() + layout.terminal.resize(height) + }} + onCollapse={() => session.layout.view().terminal.close()} + /> + +
+
+ +
+
+ +
+
+
+
+
+
- -
+ +
setElements("bottomTerminal", element)} + data-slot="terminal-panel-presence" + data-opened={bottomTerminalVisible()} + classList={{ + hidden: !bottomTerminalPresence.present(), + "relative min-h-0 shrink-0": isDesktop(), + }} + > -
screen.size.start()} - > +
screen.size.start()}>
- +
diff --git a/packages/app/src/session/terminal/panel.tsx b/packages/app/src/session/terminal/panel.tsx index 09171e18e02..6e944dc4138 100644 --- a/packages/app/src/session/terminal/panel.tsx +++ b/packages/app/src/session/terminal/panel.tsx @@ -18,7 +18,7 @@ import { Terminal } from "@/session/terminal/terminal" import { useCommand } from "@/shell/commands/command" import { useLanguage } from "@/runtime/i18n/language" import { useLayout } from "@/shell/state/layout" -import { useTerminal } from "@/session/terminal/context" +import { useTerminal, type LocalPTY } from "@/session/terminal/context" import { useWorkspaceLocation } from "@/workspaces/location" import { terminalTabLabel } from "@/session/terminal/terminal-label" import { createSizing, focusTerminalById } from "@/session/helpers" @@ -26,7 +26,20 @@ import { getTerminalHandoff, setTerminalHandoff } from "@/session/handoff" import { useSessionLayout } from "@/session/session-layout" import { TerminalSurface } from "./surface" -export function TerminalPanel(props: { stacked?: boolean } = {}) { +const MAX_CACHED_TERMINAL_WORKSPACES = 20 + +type TerminalBinding = ReturnType["bind"]> +type CachedTerminalSurface = { + key: string + workspace: string + pty: LocalPTY + ops: TerminalBinding + focus: boolean +} + +export function TerminalPanel( + props: { stacked?: boolean; fill?: boolean; framed?: boolean; present?: boolean; contentHeight?: string } = {}, +) { const layout = useLayout() const terminal = useTerminal() const sdk = useWorkspaceLocation() @@ -45,18 +58,26 @@ export function TerminalPanel(props: { stacked?: boolean } = {}) { onCleanup(() => terminal.cancelFocus()) const [store, setStore] = createStore({ - autoCreated: false, + autoCreated: undefined as string | undefined, recovered: {} as Record, + surfaces: [] as CachedTerminalSurface[], + workspaces: [] as string[], 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 panelHeight = createMemo(() => { + if (props.fill) return "100%" + if (!opened()) return "0px" + if (isDesktop()) return stacked() ? `${pane()}px` : "100%" + return `${pane()}px` + }) + const contentHeight = createMemo( + () => props.contentHeight ?? (isDesktop() ? (stacked() ? `${pane()}px` : "100%") : `${pane()}px`), ) - const contentHeight = createMemo(() => (isDesktop() ? (stacked() ? `${pane()}px` : "100%") : `${pane()}px`)) + const present = createMemo(() => opened() || !!props.present) const newTerminalKeybind = createMemo(() => command.keybindParts("terminal.new")) onMount(() => { @@ -68,24 +89,29 @@ export function TerminalPanel(props: { stacked?: boolean } = {}) { sync() makeEventListener(window, "resize", sync) if (port) makeEventListener(port, "resize", sync) + makeEventListener(document, "focusin", (event) => { + if (event.target instanceof Element && event.target.closest("#terminal-panel")) return + setStore("surfaces", (surface) => surface.focus, "focus", false) + }) }) createEffect(() => { if (!opened()) { - setStore("autoCreated", false) + setStore("autoCreated", undefined) return } - if (!terminal.ready() || terminal.all().length !== 0 || store.autoCreated) return + const workspace = workspaceKey() + if (!terminal.ready() || terminal.all().length !== 0 || store.autoCreated === workspace) return terminal.new() - setStore("autoCreated", true) + setStore("autoCreated", workspace) }) createEffect( on( - () => terminal.all().length, - (count, prevCount) => { - if (prevCount === undefined || prevCount <= 0 || count !== 0) return + () => [workspaceKey(), terminal.all().length] as const, + ([workspace, count], previous) => { + if (!previous || previous[0] !== workspace || previous[1] <= 0 || count !== 0) return if (!opened()) return close() }, @@ -97,7 +123,10 @@ export function TerminalPanel(props: { stacked?: boolean } = {}) { () => [opened(), terminal.active(), terminal.focusRequested(terminal.active())] as const, ([next, id, requested]) => { if (!next || !id || !requested) return - focusTerminalById(id) + requestAnimationFrame(() => { + if (!opened() || terminal.active() !== id || !terminal.focusRequested(id)) return + focusTerminalById(id) + }) }, ), ) @@ -136,19 +165,44 @@ export function TerminalPanel(props: { stacked?: boolean } = {}) { const all = terminal.all + createEffect( + on( + () => [workspaceKey(), terminal.ready(), terminal.active(), terminal.all()] as const, + ([workspace, ready, active, ptys]) => { + if (!ready) return + + const ids = new Set(ptys.map((pty) => pty.id)) + const surfaces = store.surfaces.filter((surface) => surface.workspace !== workspace || ids.has(surface.pty.id)) + const pty = ptys.find((item) => item.id === active) + const key = pty ? `${workspace}\0${pty.id}` : undefined + if (pty && key && !surfaces.some((surface) => surface.key === key)) { + surfaces.push({ key, workspace, pty, ops: terminal.bind(), focus: terminal.focusRequested(pty.id) }) + } + + const workspaces = [...store.workspaces.filter((item) => item !== workspace), workspace].slice( + -MAX_CACHED_TERMINAL_WORKSPACES, + ) + const keep = new Set(workspaces) + setStore({ surfaces: surfaces.filter((surface) => keep.has(surface.workspace)), workspaces }) + }, + ), + ) + 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 index = store.surfaces.findIndex((surface) => surface.key === key) + if (!store.surfaces[index]?.focus) return + setStore("surfaces", index, "focus", false) + if (!opened() || terminal.active() !== id) return + focusTerminalById(id) + terminal.consumeFocus(id) } const handleTerminalDragEnd = () => { @@ -167,6 +221,8 @@ export function TerminalPanel(props: { stacked?: boolean } = {}) { }} label={language.t("terminal.title")} opened={opened()} + present={present()} + framed={props.framed} desktop={isDesktop()} stacked={stacked()} height={panelHeight()} @@ -182,7 +238,7 @@ export function TerminalPanel(props: { stacked?: boolean } = {}) { onCollapse={close} > 0} fallback={
@@ -268,34 +324,35 @@ export function TerminalPanel(props: { stacked?: boolean } = {}) {
- - {(id) => { - const ops = terminal.bind() - return ( - pty.id === id)}> - {(pty) => ( -
- terminal.consumeFocus(id)} - class="!px-[14px]" - onConnect={() => - markTerminalConnected(terminalRecoveryKey(pty()), id, (terminalID) => - ops.trim(terminalID), - ) - } - onCleanup={(terminal) => ops.update(terminal)} - onConnectError={() => - recoverTerminal(terminalRecoveryKey(pty()), id, (terminalID) => ops.clone(terminalID)) - } - /> -
- )} -
- ) - }} -
+ + {(surface) => ( +
+ { + focusTerminalById(surface.pty.id) + terminal.consumeFocus(surface.pty.id) + }} + class="!px-[14px]" + onConnect={() => + markTerminalConnected(surface.key, surface.pty.id, (terminalID) => surface.ops.trim(terminalID)) + } + onCleanup={(terminal) => surface.ops.update(terminal)} + onConnectError={() => + recoverTerminal(surface.key, surface.pty.id, (terminalID) => surface.ops.clone(terminalID)) + } + /> +
+ )} +
diff --git a/packages/app/src/session/terminal/surface.tsx b/packages/app/src/session/terminal/surface.tsx index 489ddeea423..445593aa8c1 100644 --- a/packages/app/src/session/terminal/surface.tsx +++ b/packages/app/src/session/terminal/surface.tsx @@ -5,6 +5,8 @@ export function TerminalSurface( props: ParentProps<{ label: string opened: boolean + present?: boolean + framed?: boolean desktop: boolean stacked: boolean height: string @@ -22,6 +24,9 @@ export function TerminalSurface(