From 1e4559141d48864fc6758a87643c6941a22fc0f5 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Thu, 10 Sep 2026 09:00:25 -0400 Subject: [PATCH 1/4] feat(preview): add pane-local controls and drag proxy --- .../preview-baseline-contract.spec.ts | 104 ++++++++++++++++++ .../session/session-preview-tab.tsx | 78 ++++++++++--- .../session/session-preview-tree.test.ts | 35 +++++- .../session/session-preview-tree.ts | 8 ++ packages/app/src/design-polish.css | 21 +++- .../components/preview-file-routing.test.ts | 28 +++-- 6 files changed, 244 insertions(+), 30 deletions(-) diff --git a/packages/app/e2e/regression/preview-baseline-contract.spec.ts b/packages/app/e2e/regression/preview-baseline-contract.spec.ts index f3981dc58..5206a3db3 100644 --- a/packages/app/e2e/regression/preview-baseline-contract.spec.ts +++ b/packages/app/e2e/regression/preview-baseline-contract.spec.ts @@ -236,6 +236,11 @@ test("splits a Preview leaf at its right edge without recreating the moved rende await page.mouse.down() await page.mouse.move(leafBox.x + leafBox.width - 2, leafBox.y + leafBox.height / 2, { steps: 8 }) await expect(panel.locator('[data-preview-dragging="notes/second.md"]')).toBeVisible() + const dragProxy = panel.locator("[data-preview-tab-drag-proxy]") + await expect(dragProxy).toBeVisible() + await expect(dragProxy).toContainText("second.md") + await expect(dragProxy).toHaveCSS("pointer-events", "none") + expect((await dragProxy.boundingBox())?.x).toBeGreaterThan(leafBox.x) const preview = panel.locator('[data-preview-drop-preview="right"]') await expect(preview).toBeVisible() await expect(preview).toHaveCSS("pointer-events", "none") @@ -245,6 +250,7 @@ test("splits a Preview leaf at its right edge without recreating the moved rende await page.mouse.up() await page.waitForTimeout(250) await expect(preview).toHaveCount(0) + await expect(dragProxy).toHaveCount(0) const destinationLeaf = panel.locator('[data-preview-leaf="pane-1"]') await expect(panel.locator("[data-preview-leaf]")).toHaveCount(2) @@ -330,6 +336,99 @@ test("retains an unsaved editor draft through a Preview edge split", async ({ pa expect(await host.evaluate((element) => element.isConnected)).toBe(true) }) +test("keeps Preview zoom independent in split panes", async ({ page }) => { + await openPreview(page) + await openPreviewFile(page, secondMarkdownFile) + + const panel = page.locator("#review-panel") + const source = panel.getByRole("tab", { name: "second.md" }) + const rootLeaf = panel.locator('[data-preview-leaf="root"]') + const sourceBox = await source.boundingBox() + const rootBox = await rootLeaf.boundingBox() + if (!sourceBox || !rootBox) throw new Error("Preview tab and leaf must be measurable before splitting") + + await page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2) + await page.mouse.down() + await page.mouse.move(rootBox.x + rootBox.width - 2, rootBox.y + rootBox.height / 2, { steps: 8 }) + await page.mouse.up() + + const destinationLeaf = panel.locator('[data-preview-leaf="pane-1"]') + const rootZoom = rootLeaf.locator('input[type="text"]:visible') + const destinationZoom = destinationLeaf.locator('input[type="text"]') + await expect(rootZoom).toHaveValue("100%") + await expect(destinationZoom).toHaveValue("100%") + + await rootZoom.fill("130") + await rootZoom.press("Enter") + + await expect(rootZoom).toHaveValue("130%") + await expect(destinationZoom).toHaveValue("100%") +}) + +test("inherits source zoom and focuses the new Preview leaf after a split", async ({ page }) => { + await openPreview(page) + await openPreviewFile(page, secondMarkdownFile) + + const panel = page.locator("#review-panel") + const rootLeaf = panel.locator('[data-preview-leaf="root"]') + const rootZoom = rootLeaf.locator('[data-preview-host="notes/second.md"] input[type="text"]') + await rootZoom.fill("130") + await rootZoom.press("Enter") + await expect(rootZoom).toHaveValue("130%") + + const source = panel.getByRole("tab", { name: "second.md" }) + const sourceBox = await source.boundingBox() + const rootBox = await rootLeaf.boundingBox() + if (!sourceBox || !rootBox) throw new Error("Preview tab and leaf must be measurable before splitting") + + await page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2) + await page.mouse.down() + await page.mouse.move(rootBox.x + rootBox.width - 2, rootBox.y + rootBox.height / 2, { steps: 8 }) + await page.mouse.up() + + const destinationLeaf = panel.locator('[data-preview-leaf="pane-1"]') + await expect(destinationLeaf).toHaveAttribute("data-focused", "true") + await expect(destinationLeaf.locator('input[type="text"]')).toHaveValue("130%") +}) + +test("adopts destination pane zoom when transferring a Preview renderer", async ({ page }) => { + await openPreview(page) + await openPreviewFile(page, secondMarkdownFile) + + const panel = page.locator("#review-panel") + const source = panel.getByRole("tab", { name: "second.md" }) + const rootLeaf = panel.locator('[data-preview-leaf="root"]') + const sourceBox = await source.boundingBox() + const rootBox = await rootLeaf.boundingBox() + if (!sourceBox || !rootBox) throw new Error("Preview tab and leaf must be measurable before splitting") + + await page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2) + await page.mouse.down() + await page.mouse.move(rootBox.x + rootBox.width - 2, rootBox.y + rootBox.height / 2, { steps: 8 }) + await page.mouse.up() + + const rootZoom = rootLeaf.locator('input[type="text"]') + await rootZoom.fill("130") + await rootZoom.press("Enter") + await expect(rootZoom).toHaveValue("130%") + + const sourceLeaf = panel.locator('[data-preview-leaf="pane-1"]') + const movedHost = sourceLeaf.locator('[data-preview-host="notes/second.md"]') + const host = await movedHost.elementHandle() + const transferTab = sourceLeaf.getByRole("tab", { name: "second.md" }) + const transferBox = await transferTab.boundingBox() + const transferTarget = await rootLeaf.boundingBox() + if (!host || !transferBox || !transferTarget) throw new Error("Preview leaves must be measurable before transferring") + + await page.mouse.move(transferBox.x + transferBox.width / 2, transferBox.y + transferBox.height / 2) + await page.mouse.down() + await page.mouse.move(transferTarget.x + transferTarget.width / 2, transferTarget.y + transferTarget.height / 2, { steps: 8 }) + await page.mouse.up() + + await expect(rootLeaf.locator('[data-preview-host="notes/second.md"] input[type="text"]')).toHaveValue("130%") + expect(await host.evaluate((element) => element.isConnected)).toBe(true) +}) + test("keeps both Preview leaves at their minimum width while resizing a divider", async ({ page }) => { await openPreview(page) await openPreviewFile(page, secondMarkdownFile) @@ -431,10 +530,15 @@ test("clears the prospective pane preview when a Preview drag is cancelled", asy await page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2) await page.mouse.down() await page.mouse.move(leafBox.x + leafBox.width - 2, leafBox.y + leafBox.height / 2, { steps: 8 }) + const dragProxy = panel.locator("[data-preview-tab-drag-proxy]") + await expect(dragProxy).toBeVisible() await expect(panel.locator('[data-preview-drop-preview="right"]')).toBeVisible() await page.mouse.move(leafBox.x - 20, leafBox.y + leafBox.height / 2, { steps: 4 }) await expect(panel.locator("[data-preview-drop-preview]")).toHaveCount(0) await page.mouse.up() + await expect(dragProxy).toHaveAttribute("data-leaving", "true") + await page.waitForTimeout(200) + await expect(dragProxy).toHaveCount(0) await expect(panel.locator("[data-preview-leaf]")).toHaveCount(1) await expect(leaf.getByRole("tab", { name: "second.md" })).toHaveAttribute("aria-selected", "true") diff --git a/packages/app/src/components/session/session-preview-tab.tsx b/packages/app/src/components/session/session-preview-tab.tsx index 98a8d11c0..674227630 100644 --- a/packages/app/src/components/session/session-preview-tab.tsx +++ b/packages/app/src/components/session/session-preview-tab.tsx @@ -21,6 +21,7 @@ import { removePreviewPath, resizePreviewSplit, selectPreviewPath, + setPreviewLeafZoom, type PreviewDropPosition, type PreviewLeaf, type PreviewPane, @@ -39,21 +40,24 @@ type PreviewDrop = { type PreviewDrag = { path: string sourceLeafID: string + x: number + y: number drop?: PreviewDrop } export function SessionPreviewTab(props: { previewFile: Accessor }) { const [dirtyPaths, setDirtyPaths] = createStore>({}) const [saveRequests, setSaveRequests] = createStore>({}) - const [zoom, setZoom] = createSignal(100) const [workspace, setWorkspace] = createSignal(createPreviewWorkspace()) const [capacityMessage, setCapacityMessage] = createSignal(null) const [closingPath, setClosingPath] = createSignal(null) const [previewDrag, setPreviewDrag] = createSignal(null) + const [dragProxy, setDragProxy] = createSignal<{ path: string; x: number; y: number; leaving: boolean } | null>(null) const leafElements = new Map() const [hostMounts, setHostMounts] = createStore>({}) let stopPreviewDrag: (() => void) | undefined + let dragProxyTimer: ReturnType | undefined const openedPaths = () => previewLeaves(workspace().tree).flatMap((leaf) => leaf.tabs) @@ -84,11 +88,16 @@ export function SessionPreviewTab(props: { previewFile: Accessor window.addEventListener("message", handlePreviewFile) onCleanup(() => window.removeEventListener("message", handlePreviewFile)) }) - onCleanup(() => stopPreviewDrag?.()) + onCleanup(() => { + stopPreviewDrag?.() + if (dragProxyTimer) clearTimeout(dragProxyTimer) + }) - const zoomIn = () => setZoom((value) => Math.min(value + 10, 500)) - const zoomOut = () => setZoom((value) => Math.max(value - 10, 50)) - const onZoomChange = (value: number) => setZoom(Math.round(Math.min(Math.max(value, 50), 500))) + const zoomForPath = (path: string) => previewLeafContaining(workspace().tree, path)?.zoom ?? 100 + const setZoomForPath = (path: string, value: number) => { + const leaf = previewLeafContaining(workspace().tree, path) + if (leaf) setWorkspace((current) => setPreviewLeafZoom(current, leaf.id, value)) + } const removePath = (path: string) => { setWorkspace((current) => removePreviewPath(current, path)) @@ -144,13 +153,30 @@ export function SessionPreviewTab(props: { previewFile: Accessor const source = event.currentTarget as HTMLElement source.setPointerCapture?.(event.pointerId) - const stop = () => { + const clearDragProxy = (fade: boolean) => { + const proxy = dragProxy() + if (!proxy) return + if (!fade) { + if (dragProxyTimer) clearTimeout(dragProxyTimer) + dragProxyTimer = undefined + setDragProxy(null) + return + } + setDragProxy({ ...proxy, leaving: true }) + if (dragProxyTimer) clearTimeout(dragProxyTimer) + dragProxyTimer = setTimeout(() => { + setDragProxy(null) + dragProxyTimer = undefined + }, 160) + } + const stop = (fadeProxy = false) => { window.removeEventListener("pointermove", onMove) window.removeEventListener("pointerup", onUp) window.removeEventListener("pointercancel", onCancel) if (source.hasPointerCapture?.(event.pointerId)) source.releasePointerCapture(event.pointerId) stopPreviewDrag = undefined setPreviewDrag(null) + clearDragProxy(fadeProxy) } const onMove = (moveEvent: PointerEvent) => { if (!active) { @@ -158,15 +184,25 @@ export function SessionPreviewTab(props: { previewFile: Accessor active = true } moveEvent.preventDefault() - setPreviewDrag({ path, sourceLeafID, drop: dropTargetAt(moveEvent.clientX, moveEvent.clientY, path, sourceLeafID) }) + if (dragProxyTimer) clearTimeout(dragProxyTimer) + dragProxyTimer = undefined + const next = { + path, + sourceLeafID, + x: moveEvent.clientX, + y: moveEvent.clientY, + drop: dropTargetAt(moveEvent.clientX, moveEvent.clientY, path, sourceLeafID), + } + setPreviewDrag(next) + setDragProxy({ path, x: next.x, y: next.y, leaving: false }) } const onUp = (upEvent: PointerEvent) => { const drop = active ? dropTargetAt(upEvent.clientX, upEvent.clientY, path, sourceLeafID) : undefined - stop() + stop(!drop) if (!drop) return setWorkspace((current) => movePreviewTab(current, { path, targetLeafID: drop.leafID, position: drop.position, targetIndex: drop.targetIndex })) } - const onCancel = () => stop() + const onCancel = () => stop(true) stopPreviewDrag?.() stopPreviewDrag = stop @@ -303,6 +339,22 @@ export function SessionPreviewTab(props: { previewFile: Accessor return (
+ + {(proxy) => ( + + )} + diff --git a/packages/app/src/components/session/session-preview-tree.test.ts b/packages/app/src/components/session/session-preview-tree.test.ts index 1154cd7c1..fb20f5b95 100644 --- a/packages/app/src/components/session/session-preview-tree.test.ts +++ b/packages/app/src/components/session/session-preview-tree.test.ts @@ -1,5 +1,13 @@ import { describe, expect, test } from "bun:test" -import { createPreviewWorkspace, movePreviewTab, openPreviewPath, previewLeaves, previewMinimumExtent } from "./session-preview-tree" +import { + createPreviewWorkspace, + movePreviewTab, + openPreviewPath, + previewLeafByID, + previewLeaves, + previewMinimumExtent, + setPreviewLeafZoom, +} from "./session-preview-tree" describe("Preview workspace pane tree", () => { test("moves a tab into a focused right sibling leaf", () => { @@ -11,9 +19,9 @@ describe("Preview workspace pane tree", () => { position: "right", }) - expect(previewLeaves(moved.tree).map(({ id, tabs, selectedPath }) => ({ id, tabs, selectedPath }))).toEqual([ - { id: "root", tabs: ["notes/baseline.md"], selectedPath: "notes/baseline.md" }, - { id: "pane-1", tabs: ["notes/second.md"], selectedPath: "notes/second.md" }, + expect(previewLeaves(moved.tree).map(({ id, tabs, selectedPath, zoom }) => ({ id, tabs, selectedPath, zoom }))).toEqual([ + { id: "root", tabs: ["notes/baseline.md"], selectedPath: "notes/baseline.md", zoom: 100 }, + { id: "pane-1", tabs: ["notes/second.md"], selectedPath: "notes/second.md", zoom: 100 }, ]) expect(moved.focusedLeafID).toBe("pane-1") }) @@ -46,4 +54,23 @@ describe("Preview workspace pane tree", () => { expect(previewMinimumExtent(nested.tree, "horizontal")).toBe(450) expect(previewMinimumExtent(nested.tree, "vertical")).toBe(150) }) + + test("inherits source zoom on split and destination zoom on transfer", () => { + const sourceZoomed = setPreviewLeafZoom(createPreviewWorkspace(["notes/one.md", "notes/two.md"]), "root", 130) + const split = movePreviewTab(sourceZoomed, { + path: "notes/two.md", + targetLeafID: "root", + position: "right", + }) + + expect(previewLeafByID(split.tree, "pane-1")?.zoom).toBe(130) + const destinationZoomed = setPreviewLeafZoom(split, "root", 90) + const transferred = movePreviewTab(destinationZoomed, { + path: "notes/two.md", + targetLeafID: "root", + position: "center", + }) + + expect(previewLeafByID(transferred.tree, "root")?.zoom).toBe(90) + }) }) diff --git a/packages/app/src/components/session/session-preview-tree.ts b/packages/app/src/components/session/session-preview-tree.ts index 79c310a15..b94624b56 100644 --- a/packages/app/src/components/session/session-preview-tree.ts +++ b/packages/app/src/components/session/session-preview-tree.ts @@ -3,6 +3,7 @@ export type PreviewLeaf = { id: string tabs: string[] selectedPath: string | null + zoom: number } export type PreviewSplit = { @@ -32,6 +33,7 @@ export const createPreviewWorkspace = (paths: readonly string[] = []): PreviewWo id: "root", tabs: [...paths], selectedPath: paths.at(-1) ?? null, + zoom: 100, }, focusedLeafID: "root", nextPaneID: 1, @@ -139,6 +141,11 @@ export const selectPreviewPath = (workspace: PreviewWorkspace, leafID: string, p } } +export const setPreviewLeafZoom = (workspace: PreviewWorkspace, leafID: string, zoom: number): PreviewWorkspace => ({ + ...workspace, + tree: mapLeaf(workspace.tree, leafID, (leaf) => ({ ...leaf, zoom: Math.round(Math.min(Math.max(zoom, 50), 500)) })), +}) + export const removePreviewPath = (workspace: PreviewWorkspace, path: string): PreviewWorkspace => { const source = previewLeafContaining(workspace.tree, path) if (!source) return workspace @@ -195,6 +202,7 @@ export const movePreviewTab = ( id: `pane-${workspace.nextPaneID}`, tabs: [input.path], selectedPath: input.path, + zoom: source.zoom, } const direction = input.position === "left" || input.position === "right" ? "horizontal" : "vertical" const newPaneBeforeTarget = input.position === "left" || input.position === "top" diff --git a/packages/app/src/design-polish.css b/packages/app/src/design-polish.css index 012c0ccba..079b69574 100644 --- a/packages/app/src/design-polish.css +++ b/packages/app/src/design-polish.css @@ -171,7 +171,7 @@ body[data-new-layout] #review-panel [data-component="tabs"].preview-tab-strip[da background: var(--v2-background-bg-base); } #review-panel .preview-pane-leaf[data-focused] { - box-shadow: inset 0 0 0 var(--border-width) var(--v2-border-border-strong); + box-shadow: inset 0 0 0 var(--border-width) var(--accent-edge); } #review-panel .preview-pane-leaf > [data-component="tabs"].preview-tab-strip { flex: 0 0 auto; @@ -225,6 +225,25 @@ body[data-new-layout] #review-panel [data-component="tabs"].preview-tab-strip[da } #review-panel .preview-pane-drop-preview[data-preview-drop-preview="top"] { top: 0; } #review-panel .preview-pane-drop-preview[data-preview-drop-preview="bottom"] { bottom: 0; } +#review-panel .preview-tab-drag-proxy { + position: fixed; + z-index: 60; + top: 0; + left: 0; + display: flex; + align-items: center; + max-width: min-content; + padding: var(--space-1) var(--space-2); + border: var(--border-width) solid var(--v2-border-border-base); + border-radius: var(--radius-md); + background: var(--v2-background-bg-layer-01); + box-shadow: var(--elev-float); + opacity: 0.92; + pointer-events: none; + transform: translate3d(calc(var(--preview-drag-x) + var(--space-2)), calc(var(--preview-drag-y) + var(--space-2)), 0); + transition: opacity 0.16s ease; +} +#review-panel .preview-tab-drag-proxy[data-leaving] { opacity: 0; } /* ── session status dots ── Four states: green (done, unread), grey (done, seen), yellow (running), diff --git a/packages/session-ui/src/v2/components/preview-file-routing.test.ts b/packages/session-ui/src/v2/components/preview-file-routing.test.ts index 42eea1ca5..d2ef75087 100644 --- a/packages/session-ui/src/v2/components/preview-file-routing.test.ts +++ b/packages/session-ui/src/v2/components/preview-file-routing.test.ts @@ -226,6 +226,10 @@ describe("toolbar layout (#934)", () => { path.resolve(__dirname, "../../../../app/src/components/session/preview-file-view.tsx"), "utf8", ) + const previewTreeSrc = fs.readFileSync( + path.resolve(__dirname, "../../../../app/src/components/session/session-preview-tree.ts"), + "utf8", + ) const pdfCanvasViewSrc = fs.readFileSync( path.resolve(__dirname, "../../../../app/src/components/session/pdf-canvas-view.tsx"), "utf8", @@ -235,8 +239,8 @@ describe("toolbar layout (#934)", () => { // SessionPreviewTab must NOT render the zoom buttons/input (aria-label) expect(previewTabSrc).not.toContain("Zoom out") expect(previewTabSrc).not.toContain("Zoom in") - // But it must define and pass zoom/zoomIn/zoomOut as props - expect(previewTabSrc).toContain("zoom={zoom}") + // It routes the pane-local zoom accessor to each renderer host. + expect(previewTabSrc).toContain("zoom={() => zoomForPath(path)}") // PreviewFileView MUST render the zoom widget expect(fileViewSrc).toContain("Zoom out") @@ -256,8 +260,8 @@ describe("toolbar layout (#934)", () => { }) test("SessionPreviewTab passes zoomIn and zoomOut to PreviewFileView", () => { - expect(previewTabSrc).toContain("zoomIn={zoomIn}") - expect(previewTabSrc).toContain("zoomOut={zoomOut}") + expect(previewTabSrc).toContain("zoomIn={() => setZoomForPath(path, zoomForPath(path) + 10)}") + expect(previewTabSrc).toContain("zoomOut={() => setZoomForPath(path, zoomForPath(path) - 10)}") }) test("image zoom uses direct CSS sizing, not transform: scale (#934)", () => { @@ -371,8 +375,8 @@ describe("toolbar layout (#934)", () => { }) test("zoom floor is category-aware: 100% for image/pdf, 50% for markdown/text (#934)", () => { - // The parent (SessionPreviewTab) keeps the global floor at 50% - expect(previewTabSrc).toContain("Math.max(z - 10, 50)") + // The parent routes existing renderer controls to the owning leaf pane. + expect(previewTabSrc).toContain("setZoomForPath") // PreviewFileView applies a higher floor for image/pdf in its zoom-out handler expect(fileViewSrc).toContain("zoomFloor") }) @@ -380,8 +384,8 @@ describe("toolbar layout (#934)", () => { test("pinch/wheel zoom: SessionPreviewTab exposes onZoomChange to PreviewFileView (#934)", () => { // Parent must define a handler that accepts an arbitrary zoom value expect(previewTabSrc).toContain("onZoomChange") - // Must clamp to [50, 500] — same bounds as the +/- buttons - expect(previewTabSrc).toContain("500") + // The leaf model clamps to the same [50, 500] range. + expect(previewTreeSrc).toContain("Math.min(Math.max(zoom, 50), 500)") }) test("pinch/wheel zoom: PreviewFileView has a wheel handler for ctrlKey/shiftKey (#934)", () => { @@ -638,9 +642,9 @@ describe("dirty dot indicator (#937)", () => { "utf8", ) - test("shows an unsaved dot based on file state unsavedContent", () => { - // Must reference unsavedContent to determine dot visibility - expect(previewTabSrc).toContain("unsavedContent") + test("shows an unsaved dot based on workspace dirty state", () => { + // The workspace owns only the dirty flag; the renderer owns draft text. + expect(previewTabSrc).toContain("dirtyPaths") }) test("unsaved dot has aria-label for accessibility", () => { @@ -696,7 +700,7 @@ describe("file save uses SDK client (#937)", () => { test("clears unsavedContent after successful save", () => { // After a successful write, the dirty state must be cleared (null) - expect(saveFileBody).toMatch(/onUnsavedContent\(null\)|onClearUnsaved/) + expect(saveFileBody).toContain("setUnsavedContent(null)") }) test("saveFile uses try/catch for error handling", () => { From 7fcafffadc43e5dc18be24bedfed20e9006e027c Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Thu, 10 Sep 2026 09:05:56 -0400 Subject: [PATCH 2/4] fix(preview): keep pane controls within leaf bounds --- .../preview-baseline-contract.spec.ts | 26 +++++++++++++++++++ .../components/session/preview-file-view.tsx | 4 +-- packages/app/src/design-polish.css | 13 ++++++++++ 3 files changed, 40 insertions(+), 3 deletions(-) diff --git a/packages/app/e2e/regression/preview-baseline-contract.spec.ts b/packages/app/e2e/regression/preview-baseline-contract.spec.ts index 5206a3db3..b160a65ca 100644 --- a/packages/app/e2e/regression/preview-baseline-contract.spec.ts +++ b/packages/app/e2e/regression/preview-baseline-contract.spec.ts @@ -365,6 +365,32 @@ test("keeps Preview zoom independent in split panes", async ({ page }) => { await expect(destinationZoom).toHaveValue("100%") }) +test("keeps each pane's zoom control inside its minimum-width leaf", async ({ page }) => { + await openPreview(page) + await openPreviewFile(page, secondMarkdownFile) + + const panel = page.locator("#review-panel") + const source = panel.getByRole("tab", { name: "second.md" }) + const rootLeaf = panel.locator('[data-preview-leaf="root"]') + const sourceBox = await source.boundingBox() + const rootBox = await rootLeaf.boundingBox() + if (!sourceBox || !rootBox) throw new Error("Preview tab and leaf must be measurable before splitting") + + await page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2) + await page.mouse.down() + await page.mouse.move(rootBox.x + rootBox.width - 2, rootBox.y + rootBox.height / 2, { steps: 8 }) + await page.mouse.up() + + const destinationLeaf = panel.locator('[data-preview-leaf="pane-1"]') + for (const leaf of [rootLeaf, destinationLeaf]) { + const leafBox = await leaf.boundingBox() + const inputBox = await leaf.locator('input[type="text"]:visible').boundingBox() + if (!leafBox || !inputBox) throw new Error("Preview leaf and zoom input must be measurable") + expect(inputBox.x).toBeGreaterThanOrEqual(leafBox.x) + expect(inputBox.x + inputBox.width).toBeLessThanOrEqual(leafBox.x + leafBox.width) + } +}) + test("inherits source zoom and focuses the new Preview leaf after a split", async ({ page }) => { await openPreview(page) await openPreviewFile(page, secondMarkdownFile) diff --git a/packages/app/src/components/session/preview-file-view.tsx b/packages/app/src/components/session/preview-file-view.tsx index 6f8216edf..3d26bd5ea 100644 --- a/packages/app/src/components/session/preview-file-view.tsx +++ b/packages/app/src/components/session/preview-file-view.tsx @@ -360,15 +360,13 @@ export function PreviewFileView(props: { > {/* Floating controls — top-right overlay */}
Date: Thu, 10 Sep 2026 15:17:54 -0400 Subject: [PATCH 3/4] feat(preview): refine pane workspace interactions --- .../preview-baseline-contract.spec.ts | 144 ++++++++- .../components/session/preview-file-view.tsx | 20 +- .../session/session-preview-tab.tsx | 277 +++++++++++------- .../session/session-preview-tree.ts | 4 +- packages/app/src/design-polish.css | 70 ++++- 5 files changed, 384 insertions(+), 131 deletions(-) diff --git a/packages/app/e2e/regression/preview-baseline-contract.spec.ts b/packages/app/e2e/regression/preview-baseline-contract.spec.ts index b160a65ca..3cd4925a9 100644 --- a/packages/app/e2e/regression/preview-baseline-contract.spec.ts +++ b/packages/app/e2e/regression/preview-baseline-contract.spec.ts @@ -9,11 +9,13 @@ const sessionID = "ses_preview_baseline_contract" const title = "Preview baseline contract" const markdownFile = "notes/baseline.md" const secondMarkdownFile = "notes/second.md" +const imageFile = "assets/preview.png" const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` const markdownContent = ["# Baseline Preview", "", "The renderer must keep focus."] .concat(Array.from({ length: 200 }, (_, index) => `Scrollable preview paragraph ${index}.`)) .join("\n\n") const secondMarkdownContent = "# Second Preview\n\nThe second renderer stays alive." +const imageContent = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR+UgAAAABJRU5ErkJggg==" test.use({ viewport: { width: 1440, height: 900 } }) @@ -44,6 +46,44 @@ test("uses the Preview header for VS Code-style file tabs instead of a duplicate await expect(tablist).toHaveCSS("height", "32px") await expect(tab.locator("xpath=ancestor::*[@data-slot='tabs-trigger-wrapper']")).toHaveCount(1) await expect(panel.getByText("baseline.md", { exact: true })).toHaveCount(1) + await expect(tablist).toHaveCSS("border-bottom-width", "0px") + await expect(tablist.evaluate((element) => getComputedStyle(element, "::after").borderBottomWidth)).resolves.toBe("0px") +}) + +test("allows image previews to zoom to 1000%", async ({ page }) => { + await openPreview(page) + await openPreviewFile(page, imageFile) + + const panel = page.locator("#review-panel") + const zoom = panel.locator(`[data-preview-host="${imageFile}"] input[type="text"]`) + await expect(panel.getByAltText("preview.png")).toBeVisible() + await expect(zoom).toHaveValue("100%") + await zoom.fill("1000") + await zoom.press("Enter") + await expect(zoom).toHaveValue("1000%") +}) + +test("copies a Preview tab filename or full filepath from its context menu", async ({ page }) => { + await openPreview(page) + await page.evaluate(() => { + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { + writeText: async (value: string) => { + ;(window as Window & { copiedPreviewValue?: string }).copiedPreviewValue = value + }, + }, + }) + }) + + const tab = page.locator("#review-panel").getByRole("tab", { name: "baseline.md" }) + await tab.click({ button: "right" }) + await page.getByRole("menuitem", { name: "Copy filename" }).click() + await expect.poll(() => page.evaluate(() => (window as Window & { copiedPreviewValue?: string }).copiedPreviewValue)).toBe("baseline.md") + + await tab.click({ button: "right" }) + await page.getByRole("menuitem", { name: "Copy filepath" }).click() + await expect.poll(() => page.evaluate(() => (window as Window & { copiedPreviewValue?: string }).copiedPreviewValue)).toBe(markdownFile) }) test("keeps ordinary Preview scrolling at the position selected by the user", async ({ page }) => { @@ -101,11 +141,17 @@ test("retains renderer hosts and editor state through inner and outer Preview ta const tabs = panel.getByRole("tablist", { name: "Open previews" }) await expect(tabs.getByRole("tab", { name: "baseline.md" })).toBeVisible() await expect(tabs.getByRole("tab", { name: "second.md" })).toBeVisible() + const secondHost = panel.locator('[data-preview-host="notes/second.md"]') + await expect(secondHost).toBeVisible() + await expect(panel.locator('[data-preview-host="notes/baseline.md"]')).toBeHidden() + await expect(panel.locator('[data-preview-host="notes/baseline.md"]')).toHaveAttribute("hidden", "") await expect(panel.getByText("The second renderer stays alive.", { exact: true })).toBeVisible() await tabs.getByRole("tab", { name: "baseline.md" }).click() const baselineHost = panel.locator('[data-preview-host="notes/baseline.md"]') await expect(baselineHost).toBeVisible() + await expect(secondHost).toBeHidden() + await expect(secondHost).toHaveAttribute("hidden", "") const host = await baselineHost.elementHandle() expect(host).not.toBeNull() @@ -236,11 +282,15 @@ test("splits a Preview leaf at its right edge without recreating the moved rende await page.mouse.down() await page.mouse.move(leafBox.x + leafBox.width - 2, leafBox.y + leafBox.height / 2, { steps: 8 }) await expect(panel.locator('[data-preview-dragging="notes/second.md"]')).toBeVisible() - const dragProxy = panel.locator("[data-preview-tab-drag-proxy]") + const dragProxy = page.locator("[data-preview-tab-drag-proxy]") await expect(dragProxy).toBeVisible() await expect(dragProxy).toContainText("second.md") await expect(dragProxy).toHaveCSS("pointer-events", "none") - expect((await dragProxy.boundingBox())?.x).toBeGreaterThan(leafBox.x) + const dragProxyBox = await dragProxy.boundingBox() + expect(dragProxyBox).not.toBeNull() + expect(await dragProxy.evaluate((element) => element.parentElement?.parentElement === document.body)).toBe(true) + expect(Math.abs(dragProxyBox!.x - (leafBox.x + leafBox.width - 2 + 8))).toBeLessThan(2) + expect(Math.abs(dragProxyBox!.y - (leafBox.y + leafBox.height / 2 + 8))).toBeLessThan(2) const preview = panel.locator('[data-preview-drop-preview="right"]') await expect(preview).toBeVisible() await expect(preview).toHaveCSS("pointer-events", "none") @@ -260,12 +310,61 @@ test("splits a Preview leaf at its right edge without recreating the moved rende await expect(sourceLeaf.getByText("The renderer must keep focus.", { exact: true })).toBeVisible() expect(await host.evaluate((element) => element.isConnected)).toBe(true) + await sourceLeaf.getByText("The renderer must keep focus.", { exact: true }).click() + await expect(sourceLeaf).toHaveAttribute("data-focused", "true") + await expect(destinationLeaf).not.toHaveAttribute("data-focused", "true") + + await destinationLeaf.getByText("The second renderer stays alive.", { exact: true }).click() + await expect(destinationLeaf).toHaveAttribute("data-focused", "true") + await expect(sourceLeaf).not.toHaveAttribute("data-focused", "true") + const sourceLeafBox = await sourceLeaf.boundingBox() const destinationLeafBox = await destinationLeaf.boundingBox() expect(sourceLeafBox?.width).toBeGreaterThanOrEqual(150) expect(destinationLeafBox?.width).toBeGreaterThanOrEqual(150) expect(destinationLeafBox?.x).toBeGreaterThan(sourceLeafBox!.x) expect((await outerPreview.boundingBox())?.x).toBe(outerPreviewBox.x) + await expect(panel.getByRole("separator", { name: "Resize Preview panes" })).toHaveCSS("width", "1px") +}) + +test("distinguishes focused and unfocused Preview pane tabs while preserving pane corners", async ({ page }) => { + await openPreview(page) + await openPreviewFile(page, secondMarkdownFile) + + const panel = page.locator("#review-panel") + const source = panel.getByRole("tab", { name: "second.md" }) + const rootLeaf = panel.locator('[data-preview-leaf="root"]') + const sourceBox = await source.boundingBox() + const rootBox = await rootLeaf.boundingBox() + if (!sourceBox || !rootBox) throw new Error("Preview tab and leaf must be measurable before splitting") + + await page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2) + await page.mouse.down() + await page.mouse.move(rootBox.x + rootBox.width / 2, rootBox.y + rootBox.height - 2, { steps: 8 }) + await page.mouse.up() + + const bottomLeaf = panel.locator('[data-preview-leaf="pane-1"]') + await expect(bottomLeaf).toHaveAttribute("data-focused", "true") + await expect(rootLeaf).toHaveCSS("border-bottom-left-radius", "0px") + await expect(rootLeaf).toHaveCSS("border-bottom-right-radius", "0px") + await expect(bottomLeaf).toHaveCSS("border-bottom-left-radius", "4px") + await expect(bottomLeaf).toHaveCSS("border-bottom-right-radius", "4px") + + const selectedTabStyle = (leaf: ReturnType) => + leaf.locator('[data-slot="tabs-trigger-wrapper"]:has([data-selected])').evaluate((element) => { + const style = getComputedStyle(element) + return { backgroundColor: style.backgroundColor, color: style.color } + }) + + const focusedStyle = await selectedTabStyle(bottomLeaf) + const unfocusedStyle = await selectedTabStyle(rootLeaf) + expect(focusedStyle).not.toEqual(unfocusedStyle) + + await rootLeaf.getByText("The renderer must keep focus.", { exact: true }).click() + await expect(rootLeaf).toHaveAttribute("data-focused", "true") + await expect(bottomLeaf).not.toHaveAttribute("data-focused", "true") + await expect.poll(() => selectedTabStyle(rootLeaf)).toEqual(focusedStyle) + await expect.poll(() => selectedTabStyle(bottomLeaf)).toEqual(unfocusedStyle) }) test("transfers a renderer into another leaf and collapses its emptied source", async ({ page }) => { @@ -295,6 +394,16 @@ test("transfers a renderer into another leaf and collapses its emptied source", await page.mouse.move(transferBox.x + transferBox.width / 2, transferBox.y + transferBox.height / 2) await page.mouse.down() await page.mouse.move(transferTarget.x + transferTarget.width / 2, transferTarget.y + transferTarget.height / 2, { steps: 8 }) + + const mergePreview = panel.locator('[data-preview-drop-preview="center"]') + await expect(mergePreview).toBeVisible() + const mergePreviewBox = await mergePreview.boundingBox() + expect(mergePreviewBox).not.toBeNull() + expect(Math.abs(mergePreviewBox!.x - transferTarget.x)).toBeLessThan(2) + expect(Math.abs(mergePreviewBox!.y - transferTarget.y)).toBeLessThan(2) + expect(Math.abs(mergePreviewBox!.width - transferTarget.width)).toBeLessThanOrEqual(2) + expect(Math.abs(mergePreviewBox!.height - transferTarget.height)).toBeLessThanOrEqual(2) + await page.mouse.up() await page.waitForTimeout(250) @@ -303,6 +412,9 @@ test("transfers a renderer into another leaf and collapses its emptied source", await expect(rootLeaf.locator('[data-preview-host="notes/second.md"]')).toBeVisible() await expect(rootLeaf.getByText("The second renderer stays alive.", { exact: true })).toBeVisible() expect(await host.evaluate((element) => element.isConnected)).toBe(true) + await expect + .poll(() => panel.locator("[data-preview-workspace]").evaluate((element) => element.scrollWidth - element.clientWidth)) + .toBeLessThanOrEqual(1) }) test("retains an unsaved editor draft through a Preview edge split", async ({ page }) => { @@ -474,16 +586,35 @@ test("keeps both Preview leaves at their minimum width while resizing a divider" const divider = panel.getByRole("separator", { name: "Resize Preview panes" }) await expect(divider).toBeVisible() const dividerBox = await divider.boundingBox() - if (!dividerBox) throw new Error("Preview pane divider must be measurable") + const initialRootLeafBox = await rootLeaf.boundingBox() + if (!dividerBox || !initialRootLeafBox) throw new Error("Preview pane divider must be measurable") + + const accent = await page.evaluate(() => { + const probe = document.createElement("div") + probe.style.color = "var(--accent)" + document.body.append(probe) + const color = getComputedStyle(probe).color + probe.remove() + return color + }) - await page.mouse.move(dividerBox.x + dividerBox.width / 2, dividerBox.y + dividerBox.height / 2) + await page.mouse.move(dividerBox.x - 3, dividerBox.y + dividerBox.height / 2) await page.mouse.down() + await expect(divider).toHaveAttribute("data-resizing", "true") + await expect(divider).toHaveCSS("background-color", accent) await page.mouse.move(dividerBox.x + 400, dividerBox.y + dividerBox.height / 2, { steps: 8 }) await page.mouse.up() + await expect(divider).not.toHaveAttribute("data-resizing", "true") + + const resizedDividerBox = await divider.boundingBox() + if (!resizedDividerBox) throw new Error("Resized Preview pane divider must be measurable") + await page.mouse.move(resizedDividerBox.x - 3, resizedDividerBox.y + resizedDividerBox.height / 2) + await expect(divider).toHaveCSS("background-color", accent) const destinationLeaf = panel.locator('[data-preview-leaf="pane-1"]') const sourceLeafBox = await rootLeaf.boundingBox() const destinationLeafBox = await destinationLeaf.boundingBox() + expect(Math.abs((sourceLeafBox?.width ?? 0) - initialRootLeafBox.width)).toBeGreaterThan(1) expect(sourceLeafBox?.width).toBeGreaterThanOrEqual(150) expect(destinationLeafBox?.width).toBeGreaterThanOrEqual(150) }) @@ -556,7 +687,7 @@ test("clears the prospective pane preview when a Preview drag is cancelled", asy await page.mouse.move(sourceBox.x + sourceBox.width / 2, sourceBox.y + sourceBox.height / 2) await page.mouse.down() await page.mouse.move(leafBox.x + leafBox.width - 2, leafBox.y + leafBox.height / 2, { steps: 8 }) - const dragProxy = panel.locator("[data-preview-tab-drag-proxy]") + const dragProxy = page.locator("[data-preview-tab-drag-proxy]") await expect(dragProxy).toBeVisible() await expect(panel.locator('[data-preview-drop-preview="right"]')).toBeVisible() await page.mouse.move(leafBox.x - 20, leafBox.y + leafBox.height / 2, { steps: 4 }) @@ -715,6 +846,7 @@ async function openPreview(page: Parameters[0]) { fileContent: (path) => { if (path === markdownFile) return { type: "text", content: markdownContent } if (path === secondMarkdownFile) return { type: "text", content: secondMarkdownContent } + if (path === imageFile) return { type: "binary", content: imageContent, encoding: "base64", mimeType: "image/png" } if (path === "notes/third.md") return { type: "text", content: "# Third Preview\n\nThe nested renderer stays alive." } if (path.startsWith("notes/capacity-")) return { type: "text", content: `# ${path}` } return undefined @@ -754,5 +886,5 @@ async function openPreviewFile(page: Parameters[0], p await page.evaluate((path) => { window.postMessage({ source: "amicode", kind: "preview-file", path }, "*") }, path) - await expect(page.locator("#review-panel").getByRole("tab", { name: "Preview" })).toHaveAttribute("data-selected", "") + await expect(page.locator("#review-panel").getByRole("tab", { name: "Preview", exact: true })).toHaveAttribute("data-selected", "") } diff --git a/packages/app/src/components/session/preview-file-view.tsx b/packages/app/src/components/session/preview-file-view.tsx index 3d26bd5ea..8af107834 100644 --- a/packages/app/src/components/session/preview-file-view.tsx +++ b/packages/app/src/components/session/preview-file-view.tsx @@ -73,9 +73,9 @@ export function PreviewFileView(props: { saveRequest?: () => number onSaveStatusChange?: (status: "idle" | "saving" | "saved") => void zoom: () => number - zoomIn: () => void + zoomIn: (maximum: number) => void zoomOut: () => void - onZoomChange?: (zoom: number) => void + onZoomChange?: (zoom: number, maximum: number) => void }) { const sdk = useSDK() const serverSDK = useServerSDK() @@ -266,6 +266,10 @@ export function PreviewFileView(props: { const cat = category() return cat === "image" || cat === "pdf" ? 100 : 50 } + const zoomCeiling = () => { + const cat = category() + return cat === "image" || cat === "pdf" ? 1000 : 500 + } const handleZoomOut = () => { if (props.zoom() <= zoomFloor()) return @@ -335,10 +339,10 @@ export function PreviewFileView(props: { const oldZoom = props.zoom() const factor = Math.exp(-delta * 0.003) const next = Math.round( - Math.min(Math.max(oldZoom * factor, zoomFloor()), 500), + Math.min(Math.max(oldZoom * factor, zoomFloor()), zoomCeiling()), ) if (next === oldZoom) return - props.onZoomChange(next) + props.onZoomChange(next, zoomCeiling()) adjustScrollForZoom(oldZoom, next) setShowControls(true) startIdleTimer() @@ -396,9 +400,9 @@ export function PreviewFileView(props: { onBlur={(e) => { const val = parseInt(e.currentTarget.value) if (!isNaN(val) && props.onZoomChange) { - const clamped = Math.min(Math.max(val, zoomFloor()), 500) + const clamped = Math.min(Math.max(val, zoomFloor()), zoomCeiling()) const before = props.zoom() - props.onZoomChange(clamped) + props.onZoomChange(clamped, zoomCeiling()) adjustScrollForZoom(before, clamped) } e.currentTarget.value = `${props.zoom()}%` @@ -409,7 +413,7 @@ export function PreviewFileView(props: { class="flex items-center justify-center w-6 h-full border-l border-border-base text-text-weak hover:text-text-base hover:bg-background-stronger transition-colors" onClick={() => { const before = props.zoom() - props.onZoomChange?.(100) + props.onZoomChange?.(100, zoomCeiling()) adjustScrollForZoom(before, 100) }} aria-label="Reset zoom" @@ -425,7 +429,7 @@ export function PreviewFileView(props: { class="flex items-center justify-center w-5 h-3.5 text-text-weak hover:text-text-base hover:bg-background-stronger transition-colors" onClick={() => { const before = props.zoom() - props.zoomIn() + props.zoomIn(zoomCeiling()) adjustScrollForZoom(before, props.zoom()) }} aria-label="Zoom in" diff --git a/packages/app/src/components/session/session-preview-tab.tsx b/packages/app/src/components/session/session-preview-tab.tsx index 674227630..5da140b4e 100644 --- a/packages/app/src/components/session/session-preview-tab.tsx +++ b/packages/app/src/components/session/session-preview-tab.tsx @@ -8,6 +8,8 @@ import { createStore } from "solid-js/store" import { Portal } from "solid-js/web" import { Icon } from "@opencode-ai/ui/icon" import { Tabs } from "@opencode-ai/ui/tabs" +import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" +import { usePlatform } from "@/context/platform" import { PreviewFileView } from "./preview-file-view" import { FileVisual } from "./session-sortable-tab" import { @@ -45,7 +47,15 @@ type PreviewDrag = { drop?: PreviewDrop } +type PreviewLeafEdges = { + top: boolean + right: boolean + bottom: boolean + left: boolean +} + export function SessionPreviewTab(props: { previewFile: Accessor }) { + const platform = usePlatform() const [dirtyPaths, setDirtyPaths] = createStore>({}) const [saveRequests, setSaveRequests] = createStore>({}) const [workspace, setWorkspace] = createSignal(createPreviewWorkspace()) @@ -53,6 +63,8 @@ export function SessionPreviewTab(props: { previewFile: Accessor const [closingPath, setClosingPath] = createSignal(null) const [previewDrag, setPreviewDrag] = createSignal(null) const [dragProxy, setDragProxy] = createSignal<{ path: string; x: number; y: number; leaving: boolean } | null>(null) + const [resizingSplitID, setResizingSplitID] = createSignal(null) + const [tabContextMenu, setTabContextMenu] = createSignal<{ path: string; x: number; y: number } | null>(null) const leafElements = new Map() const [hostMounts, setHostMounts] = createStore>({}) @@ -85,8 +97,20 @@ export function SessionPreviewTab(props: { previewFile: Accessor const data = event.data as { source?: string; kind?: string; path?: string } | undefined if (data?.source === "amicode" && data.kind === "preview-file" && data.path) openPath(data.path) } + const focusLeafFromPointer = (event: PointerEvent) => { + if (!(event.target instanceof Node)) return + for (const [leafID, element] of leafElements) { + if (!element.contains(event.target)) continue + setWorkspace((current) => ({ ...current, focusedLeafID: leafID })) + return + } + } window.addEventListener("message", handlePreviewFile) - onCleanup(() => window.removeEventListener("message", handlePreviewFile)) + document.addEventListener("pointerdown", focusLeafFromPointer, true) + onCleanup(() => { + window.removeEventListener("message", handlePreviewFile) + document.removeEventListener("pointerdown", focusLeafFromPointer, true) + }) }) onCleanup(() => { stopPreviewDrag?.() @@ -94,9 +118,9 @@ export function SessionPreviewTab(props: { previewFile: Accessor }) const zoomForPath = (path: string) => previewLeafContaining(workspace().tree, path)?.zoom ?? 100 - const setZoomForPath = (path: string, value: number) => { + const setZoomForPath = (path: string, value: number, maximum = 500) => { const leaf = previewLeafContaining(workspace().tree, path) - if (leaf) setWorkspace((current) => setPreviewLeafZoom(current, leaf.id, value)) + if (leaf) setWorkspace((current) => setPreviewLeafZoom(current, leaf.id, value, maximum)) } const removePath = (path: string) => { @@ -113,6 +137,18 @@ export function SessionPreviewTab(props: { previewFile: Accessor removePath(path) } + const copyPreviewPath = async (value: string) => { + if (platform.writeClipboardText && (await platform.writeClipboardText(value))) return + try { + await navigator.clipboard?.writeText(value) + } catch {} + } + + const openTabContextMenu = (event: MouseEvent, path: string) => { + event.preventDefault() + setTabContextMenu({ path, x: event.clientX, y: event.clientY }) + } + const dropTargetAt = (clientX: number, clientY: number, path: string, sourceLeafID: string): PreviewDrop | undefined => { for (const leaf of previewLeaves(workspace().tree)) { const element = leafElements.get(leaf.id) @@ -227,12 +263,14 @@ export function SessionPreviewTab(props: { previewFile: Accessor const secondMinimum = previewMinimumExtent(split.second, axis) if (extent <= 0 || firstMinimum + secondMinimum > extent) return + setResizingSplitID(split.id) divider.setPointerCapture?.(event.pointerId) const stop = () => { window.removeEventListener("pointermove", onMove) window.removeEventListener("pointerup", onUp) window.removeEventListener("pointercancel", stop) if (divider.hasPointerCapture?.(event.pointerId)) divider.releasePointerCapture(event.pointerId) + setResizingSplitID((current) => (current === split.id ? null : current)) } const onMove = (moveEvent: PointerEvent) => { moveEvent.preventDefault() @@ -253,12 +291,19 @@ export function SessionPreviewTab(props: { previewFile: Accessor "--preview-branch-min-height": `${previewMinimumExtent(pane, "vertical")}px`, }) - const renderPane = (pane: PreviewPane): JSX.Element => { - if (pane.kind === "leaf") return renderLeaf(pane) + const renderPane = ( + pane: PreviewPane, + edges: PreviewLeafEdges = { top: true, right: true, bottom: true, left: true }, + ): JSX.Element => { + if (pane.kind === "leaf") return renderLeaf(pane, edges) + const firstEdges = + pane.direction === "horizontal" ? { ...edges, right: false } : { ...edges, bottom: false } + const secondEdges = + pane.direction === "horizontal" ? { ...edges, left: false } : { ...edges, top: false } return (
- {renderPane(pane.first)} + {renderPane(pane.first, firstEdges)}
aria-orientation={pane.direction === "horizontal" ? "vertical" : "horizontal"} tabIndex={0} data-preview-divider={pane.id} + data-resizing={resizingSplitID() === pane.id || undefined} data-direction={pane.direction} class="preview-pane-divider" onPointerDown={(event) => startDividerResize(event, pane)} />
- {renderPane(pane.second)} + {renderPane(pane.second, secondEdges)}
) } - const renderLeaf = (leaf: PreviewLeaf): JSX.Element => { + const renderLeaf = (leaf: PreviewLeaf, edges: PreviewLeafEdges): JSX.Element => { const activeDrop = () => previewDrag()?.drop return (
leafElements.set(leaf.id, element)} data-preview-leaf={leaf.id} + data-preview-edge-top={edges.top || undefined} + data-preview-edge-right={edges.right || undefined} + data-preview-edge-bottom={edges.bottom || undefined} + data-preview-edge-left={edges.left || undefined} data-focused={workspace().focusedLeafID === leaf.id || undefined} class="preview-pane-leaf" - onPointerDown={() => setWorkspace((current) => ({ ...current, focusedLeafID: leaf.id }))} > startPreviewDrag(event, path, leaf.id)} + onContextMenu={(event: MouseEvent) => openTabContextMenu(event, path)} closeButton={
@@ -338,105 +388,124 @@ export function SessionPreviewTab(props: { previewFile: Accessor } return ( -
- - {(proxy) => ( - - )} - - - - - - - {(path) => ( - + + )} + + + - )} - - -
- 0} - fallback={ -
- -

Select a file from the sidebar

+ + + + {(path) => ( + - } - > -
- {renderPane} - - {(path) => ( - - {(mount) => ( - container.classList.add("preview-pane-renderer-mount")}> -
- setDirtyPaths(path, dirty)} - saveRequest={() => saveRequests[path] ?? 0} - onSaveComplete={() => { - removePath(path) - setClosingPath(null) - }} - zoom={() => zoomForPath(path)} - zoomIn={() => setZoomForPath(path, zoomForPath(path) + 10)} - zoomOut={() => setZoomForPath(path, zoomForPath(path) - 10)} - onZoomChange={(value) => setZoomForPath(path, value)} - /> -
-
- )} -
- )} -
-
+ )}
+ +
+ 0} + fallback={ +
+ +

Select a file from the sidebar

+
+ } + > +
+ {(pane) => renderPane(pane)} + + {(path) => ( + + {(mount) => ( + container.classList.add("preview-pane-renderer-mount")}> + + + )} + + )} + +
+
+
-
+ !open && setTabContextMenu(null)}> + + + void copyPreviewPath((tabContextMenu()?.path ?? "").split("/").at(-1) ?? "")}>Copy filename + void copyPreviewPath(tabContextMenu()?.path ?? "")}>Copy filepath + + + + ) } diff --git a/packages/app/src/components/session/session-preview-tree.ts b/packages/app/src/components/session/session-preview-tree.ts index b94624b56..d4bbee9bc 100644 --- a/packages/app/src/components/session/session-preview-tree.ts +++ b/packages/app/src/components/session/session-preview-tree.ts @@ -141,9 +141,9 @@ export const selectPreviewPath = (workspace: PreviewWorkspace, leafID: string, p } } -export const setPreviewLeafZoom = (workspace: PreviewWorkspace, leafID: string, zoom: number): PreviewWorkspace => ({ +export const setPreviewLeafZoom = (workspace: PreviewWorkspace, leafID: string, zoom: number, maximum = 500): PreviewWorkspace => ({ ...workspace, - tree: mapLeaf(workspace.tree, leafID, (leaf) => ({ ...leaf, zoom: Math.round(Math.min(Math.max(zoom, 50), 500)) })), + tree: mapLeaf(workspace.tree, leafID, (leaf) => ({ ...leaf, zoom: Math.round(Math.min(Math.max(zoom, 50), Math.min(Math.max(maximum, 50), 1000))) })), }) export const removePreviewPath = (workspace: PreviewWorkspace, path: string): PreviewWorkspace => { diff --git a/packages/app/src/design-polish.css b/packages/app/src/design-polish.css index 452ceff39..936a3c767 100644 --- a/packages/app/src/design-polish.css +++ b/packages/app/src/design-polish.css @@ -115,6 +115,22 @@ body[data-new-layout] #review-panel [data-component="tabs"].preview-tab-strip[da body[data-new-layout] #review-panel [data-component="tabs"].preview-tab-strip[data-variant="normal"][data-orientation="horizontal"] [data-slot="tabs-trigger-wrapper"] { height: var(--space-6); } +body[data-new-layout] #review-panel [data-component="tabs"].preview-tab-strip[data-variant="normal"][data-orientation="horizontal"] [data-slot="tabs-list"]::after { + border-bottom: 0; +} +body[data-new-layout] #review-panel [data-component="tabs"].preview-tab-strip[data-variant="normal"][data-orientation="horizontal"] [data-slot="tabs-list"] { + border-bottom: 0; +} + +/* Preview follows VS Code's editor-group hierarchy: each pane keeps its + selected file, while only the keyboard-target pane gives that tab full emphasis. */ +body[data-new-layout] #review-panel .preview-pane-leaf:not([data-focused]) > [data-component="tabs"].preview-tab-strip[data-variant="normal"][data-orientation="horizontal"] [data-slot="tabs-trigger-wrapper"]:has([data-selected]) { + background-color: var(--v2-background-bg-layer-01); + color: var(--v2-text-text-muted); +} +body[data-new-layout] #review-panel .preview-pane-leaf:not([data-focused]) > [data-component="tabs"].preview-tab-strip[data-variant="normal"][data-orientation="horizontal"] [data-slot="tabs-trigger"][data-selected] { + color: var(--v2-text-text-muted); +} /* Preview's pane tree is structural. The scrollable parent exposes a canvas that can outgrow the Work Column; no position is read, stored, or restored. */ @@ -148,17 +164,34 @@ body[data-new-layout] #review-panel [data-component="tabs"].preview-tab-strip[da min-height: var(--preview-branch-min-height); } #review-panel .preview-pane-divider { - flex: 0 0 var(--space-1); + position: relative; + z-index: 2; + flex: 0 0 var(--border-width); background: var(--v2-border-border-muted); transition: background-color 0.16s ease; } +#review-panel .preview-pane-divider::before { + content: ""; + position: absolute; +} +#review-panel .preview-pane-divider[data-direction="horizontal"]::before { + inset: 0 calc(var(--space-1) * -1); +} +#review-panel .preview-pane-divider[data-direction="vertical"]::before { + inset: calc(var(--space-1) * -1) 0; +} #review-panel .preview-pane-divider[data-direction="horizontal"] { cursor: col-resize; } #review-panel .preview-pane-divider[data-direction="vertical"] { cursor: row-resize; } -#review-panel .preview-pane-divider:hover, +#review-panel .preview-pane-divider:hover { + background: var(--accent); +} #review-panel .preview-pane-divider:focus-visible { background: var(--v2-border-border-focus); outline: none; } +#review-panel .preview-pane-divider[data-resizing] { + background: var(--accent); +} #review-panel .preview-pane-leaf { position: relative; display: flex; @@ -167,11 +200,21 @@ body[data-new-layout] #review-panel [data-component="tabs"].preview-tab-strip[da min-width: var(--preview-pane-min-size); min-height: var(--preview-pane-min-size); overflow: hidden; - border: var(--border-width) solid var(--v2-border-border-muted); + border-radius: 0; background: var(--v2-background-bg-base); + isolation: isolate; +} +#review-panel .preview-pane-leaf[data-preview-edge-top][data-preview-edge-left] { + border-top-left-radius: var(--radius-md); +} +#review-panel .preview-pane-leaf[data-preview-edge-top][data-preview-edge-right] { + border-top-right-radius: var(--radius-md); +} +#review-panel .preview-pane-leaf[data-preview-edge-bottom][data-preview-edge-left] { + border-bottom-left-radius: var(--radius-md); } -#review-panel .preview-pane-leaf[data-focused] { - box-shadow: inset 0 0 0 var(--border-width) var(--accent-edge); +#review-panel .preview-pane-leaf[data-preview-edge-bottom][data-preview-edge-right] { + border-bottom-right-radius: var(--radius-md); } #review-panel .preview-pane-leaf > [data-component="tabs"].preview-tab-strip { flex: 0 0 auto; @@ -199,10 +242,7 @@ body[data-new-layout] #review-panel [data-component="tabs"].preview-tab-strip[da overflow: hidden; } #review-panel .preview-pane-content > .preview-pane-renderer-mount { - display: flex; - flex: 1 1 auto; - min-width: 0; - min-height: 0; + display: contents; } #review-panel .preview-pane-renderer-mount > [data-preview-host] { display: flex; @@ -215,11 +255,18 @@ body[data-new-layout] #review-panel [data-component="tabs"].preview-tab-strip[da min-width: 0; min-height: 0; } +/* A retained host remains attached for editor state, but the inactive one must + not participate in the leaf's flex layout. The Portal wrapper is layout- + transparent so the host's hidden state works in every embedded webview. */ +#review-panel .preview-pane-renderer-mount > [data-preview-host].hidden { + display: none; +} #review-panel .preview-pane-drop-preview { position: absolute; z-index: 10; pointer-events: none; border: var(--border-width) solid var(--accent-edge); + border-radius: inherit; background: var(--accent-fill-soft); } #review-panel .preview-pane-drop-preview[data-preview-drop-preview="left"], @@ -238,7 +285,7 @@ body[data-new-layout] #review-panel [data-component="tabs"].preview-tab-strip[da } #review-panel .preview-pane-drop-preview[data-preview-drop-preview="top"] { top: 0; } #review-panel .preview-pane-drop-preview[data-preview-drop-preview="bottom"] { bottom: 0; } -#review-panel .preview-tab-drag-proxy { +[data-preview-tab-drag-proxy] { position: fixed; z-index: 60; top: 0; @@ -256,7 +303,8 @@ body[data-new-layout] #review-panel [data-component="tabs"].preview-tab-strip[da transform: translate3d(calc(var(--preview-drag-x) + var(--space-2)), calc(var(--preview-drag-y) + var(--space-2)), 0); transition: opacity 0.16s ease; } -#review-panel .preview-tab-drag-proxy[data-leaving] { opacity: 0; } +[data-preview-tab-drag-proxy][data-leaving] { opacity: 0; } +#review-panel .preview-pane-drop-preview[data-preview-drop-preview="center"] { inset: 0; } /* ── session status dots ── Four states: green (done, unread), grey (done, seen), yellow (running), From 7d1ed6a78f2d432bd64f5736192d3e609431ae0f Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Thu, 10 Sep 2026 16:19:16 -0400 Subject: [PATCH 4/4] feat(preview): support file previews in drafts --- .../new-session-panel-corner.spec.ts | 96 ++++++++++++------- packages/app/src/pages/new-session.tsx | 36 ++++++- 2 files changed, 98 insertions(+), 34 deletions(-) diff --git a/packages/app/e2e/regression/new-session-panel-corner.spec.ts b/packages/app/e2e/regression/new-session-panel-corner.spec.ts index a17b8c081..af23a6a09 100644 --- a/packages/app/e2e/regression/new-session-panel-corner.spec.ts +++ b/packages/app/e2e/regression/new-session-panel-corner.spec.ts @@ -1,10 +1,12 @@ -import { expect, test } from "@playwright/test" +import { expect, test, type Page } from "@playwright/test" import { mockOpenCodeServer } from "../utils/mock-server" import { expectAppVisible } from "../utils/waits" const draftID = "draft_new_session_panel_corner" const directory = "C:/OpenCode/NewSessionPanelCorner" const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` +const previewFile = "notes/draft-preview.md" +const previewContent = "Draft preview contents." test.use({ viewport: { width: 935, height: 522 }, @@ -12,6 +14,60 @@ test.use({ }) test("matches the rounded panel corners to the dark new-session background", async ({ page }, testInfo) => { + await openDraft(page) + const panel = page.locator('[data-component="session-new-design"]') + await expect(panel).toHaveCount(1) + const box = await panel.boundingBox() + if (!box) throw new Error("New-session panel bounds are unavailable") + + const screenshot = await page.screenshot({ path: testInfo.outputPath("new-session-dark.png") }) + const corners = await page.evaluate( + async ({ source, points }) => { + const image = new Image() + image.src = source + await image.decode() + const canvas = document.createElement("canvas") + canvas.width = image.naturalWidth + canvas.height = image.naturalHeight + const context = canvas.getContext("2d") + if (!context) throw new Error("2D canvas is unavailable") + context.drawImage(image, 0, 0) + return points.map((point) => Array.from(context.getImageData(point.x, point.y, 1, 1).data)) + }, + { + source: `data:image/png;base64,${screenshot.toString("base64")}`, + points: [ + { x: Math.floor(box.x), y: Math.floor(box.y) }, + { x: Math.ceil(box.x + box.width) - 1, y: Math.floor(box.y) }, + { x: Math.floor(box.x), y: Math.ceil(box.y + box.height) - 1 }, + { x: Math.ceil(box.x + box.width) - 1, y: Math.ceil(box.y + box.height) - 1 }, + ], + }, + ) + + expect(corners.every(([red, green, blue, alpha]) => red <= 8 && green <= 8 && blue <= 8 && alpha === 255)).toBe(true) +}) + +test("opens and reopens Preview without promoting a new-session draft", async ({ page }) => { + await openDraft(page) + + await postPreviewFile(page) + const preview = page.locator("[data-draft-preview]") + await expect(preview).toBeVisible() + await expect(preview.getByRole("tab", { name: "draft-preview.md" })).toBeVisible() + await expect(preview.getByText(previewContent, { exact: true })).toBeVisible() + expect(new URL(page.url()).pathname).toBe("/new-session") + expect(new URL(page.url()).searchParams.get("draftId")).toBe(draftID) + + await preview.getByRole("button", { name: "Close Preview" }).click() + await expect(preview).toHaveCount(0) + + await postPreviewFile(page) + await expect(preview).toBeVisible() + await expect(preview.getByRole("tab", { name: "draft-preview.md" })).toBeVisible() +}) + +async function openDraft(page: Page) { await mockOpenCodeServer(page, { directory, project: { @@ -25,6 +81,7 @@ test("matches the rounded panel corners to the dark new-session background", asy provider: { all: [], connected: [], default: {} }, sessions: [], pageMessages: () => ({ items: [] }), + fileContent: (path) => (path === previewFile ? { type: "text", content: previewContent } : undefined), }) await page.addInitScript( ({ directory, draftID, server }) => { @@ -49,35 +106,10 @@ test("matches the rounded panel corners to the dark new-session background", asy await page.goto(`/new-session?draftId=${draftID}`) await expectAppVisible(page.locator('[data-component="prompt-input"]')) await expect(page.locator("html")).toHaveAttribute("data-color-scheme", "dark") - const panel = page.locator('main div[class*="rounded-[10px]"][class*="overflow-hidden"]') - await expect(panel).toHaveCount(1) - const box = await panel.boundingBox() - if (!box) throw new Error("New-session panel bounds are unavailable") +} - const screenshot = await page.screenshot({ path: testInfo.outputPath("new-session-dark.png") }) - const corners = await page.evaluate( - async ({ source, points }) => { - const image = new Image() - image.src = source - await image.decode() - const canvas = document.createElement("canvas") - canvas.width = image.naturalWidth - canvas.height = image.naturalHeight - const context = canvas.getContext("2d") - if (!context) throw new Error("2D canvas is unavailable") - context.drawImage(image, 0, 0) - return points.map((point) => Array.from(context.getImageData(point.x, point.y, 1, 1).data)) - }, - { - source: `data:image/png;base64,${screenshot.toString("base64")}`, - points: [ - { x: Math.floor(box.x), y: Math.floor(box.y) }, - { x: Math.ceil(box.x + box.width) - 1, y: Math.floor(box.y) }, - { x: Math.floor(box.x), y: Math.ceil(box.y + box.height) - 1 }, - { x: Math.ceil(box.x + box.width) - 1, y: Math.ceil(box.y + box.height) - 1 }, - ], - }, - ) - - expect(corners.every(([red, green, blue, alpha]) => red <= 8 && green <= 8 && blue <= 8 && alpha === 255)).toBe(true) -}) +async function postPreviewFile(page: Page) { + await page.evaluate((path) => { + window.postMessage({ source: "amicode", kind: "preview-file", path }, "*") + }, previewFile) +} diff --git a/packages/app/src/pages/new-session.tsx b/packages/app/src/pages/new-session.tsx index 1c0c60f6f..ac3d00ecd 100644 --- a/packages/app/src/pages/new-session.tsx +++ b/packages/app/src/pages/new-session.tsx @@ -1,7 +1,9 @@ import { createPromptProjectController } from "@/components/prompt-project-selector" +import { SessionPreviewTab } from "@/components/session/session-preview-tab" import { useTitlebarControlMount } from "@/components/titlebar" import { useSettings } from "@/context/settings" -import { createEffect, createResource, onMount } from "solid-js" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { createEffect, createResource, createSignal, onCleanup, onMount, Show } from "solid-js" import { useLocation } from "@solidjs/router" import { createNewSessionDraftController } from "./new-session/new-session-draft-controller" import { NewSessionStatus, NewSessionView } from "./new-session/new-session-view" @@ -32,6 +34,7 @@ export default function NewSessionPage() { }, }) const location = useLocation() + const [previewFile, setPreviewFile] = createSignal(null) // amicode: register the Amico ops commands here too — the draft page has no // palette, so restart/update-memory are reachable via their direct keybinds. @@ -46,6 +49,12 @@ export default function NewSessionPage() { // amicode(deck): label the framing pane tab; the draftId rides the search // so the shell can rebuild this pane with its draft text intact. postRouteInfo(`${location.pathname}${location.search}`, "New session") + const onPreviewFile = (event: MessageEvent) => { + const data = event.data as { source?: string; kind?: string; path?: string } | undefined + if (data?.source === "amicode" && data.kind === "preview-file" && data.path) setPreviewFile(data.path) + } + window.addEventListener("message", onPreviewFile) + onCleanup(() => window.removeEventListener("message", onPreviewFile)) }) const ready = Promise.resolve() const [suspendUntilPromptReady] = createResource( @@ -57,12 +66,35 @@ export default function NewSessionPage() {
{suspendUntilPromptReady()} -
+
+ + +
)