From bca90275477ba62d94cae6ed8820b6ade31dff76 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Mon, 4 May 2026 09:38:00 +0800 Subject: [PATCH 1/3] fix: harden interaction locks and session routes --- packages/app/e2e/commands/panels.spec.ts | 45 +++++++--- packages/app/src/pages/layout.tsx | 15 ++-- packages/app/src/pages/layout/helpers.test.ts | 12 +++ packages/app/src/pages/layout/helpers.ts | 7 ++ .../ui/src/components/resize-handle.test.ts | 19 +++++ packages/ui/src/components/resize-handle.tsx | 85 +++++++++++++++++-- 6 files changed, 156 insertions(+), 27 deletions(-) create mode 100644 packages/ui/src/components/resize-handle.test.ts diff --git a/packages/app/e2e/commands/panels.spec.ts b/packages/app/e2e/commands/panels.spec.ts index 8ba762dc3..54d5a36f8 100644 --- a/packages/app/e2e/commands/panels.spec.ts +++ b/packages/app/e2e/commands/panels.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from "../fixtures" -import { openSidebar } from "../actions" -import { pawworkSessionNewSelector, titlebarRightSelector } from "../selectors" +import { cleanupSession, openSidebar } from "../actions" +import { pawworkSessionNewSelector, promptSelector, titlebarRightSelector } from "../selectors" import { modKey } from "../utils" test("desktop right-panel tabs switch between review and files within a unified utility shell", async ({ page, gotoSession }) => { @@ -37,20 +37,39 @@ test("desktop right-panel tabs switch between review and files within a unified await expect(reviewTab).toHaveAttribute("aria-selected", "true") }) -test("desktop remains clickable after right-panel resize ends with mouseup", async ({ page, gotoSession, slug }) => { - await gotoSession() - await openSidebar(page) +test("desktop remains clickable after right-panel resize ends with mouseup", async ({ page, gotoSession, slug, sdk }) => { + const stamp = Date.now() + const one = await sdk.session.create({ title: `e2e resize source ${stamp}` }).then((r) => r.data) + const two = await sdk.session.create({ title: `e2e resize target ${stamp}` }).then((r) => r.data) - const rightPanel = page.locator("#right-panel") - await page.keyboard.press(`${modKey}+Shift+R`) - await expect(rightPanel).toHaveAttribute("aria-hidden", "false") + if (!one?.id) throw new Error("Source session create did not return an id") + if (!two?.id) throw new Error("Target session create did not return an id") + + try { + await gotoSession(one.id) + await openSidebar(page) + + const rightPanel = page.locator("#right-panel") + await page.keyboard.press(`${modKey}+Shift+R`) + await expect(rightPanel).toHaveAttribute("aria-hidden", "false") + + await page.locator('[data-component="right-panel-resize-wrapper"]').dispatchEvent("pointerdown") + await page.mouse.up() + + await page.locator(pawworkSessionNewSelector).click() + await expect(page).toHaveURL(new RegExp(`/${slug}/session(?:\\?|#|$)`)) + await expect(page.locator('[data-component="session-new-home"]')).toBeVisible() - await page.locator('[data-component="right-panel-resize-wrapper"]').dispatchEvent("pointerdown") - await page.mouse.up() + await page.locator(`[data-session-id="${two.id}"] a`).first().click() + await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`)) + await expect(page.locator(promptSelector)).toBeVisible() - await page.locator(pawworkSessionNewSelector).click() - await expect(page).toHaveURL(new RegExp(`/${slug}/session(?:\\?|#|$)`)) - await expect(page.locator('[data-component="session-new-home"]')).toBeVisible() + await page.locator('[data-action="pawwork-open-settings"]').click() + await expect(page.locator('[data-component="settings-page"]')).toBeVisible() + } finally { + await cleanupSession({ sdk, sessionID: one.id }) + await cleanupSession({ sdk, sessionID: two.id }) + } }) test("desktop session keeps a single right-panel toggle and icon-first utility tabs", async ({ page, gotoSession }) => { diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 8d5cbf198..79263f2ac 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -66,7 +66,9 @@ import { displayName, effectiveWorkspaceOrder, errorMessage, - projectSessionRouteTarget, + newSessionRoute, + openProjectRoute, + openSessionRoute, startupAutoselectDirectory, sortedRootSessions, workspaceKey, @@ -1427,13 +1429,12 @@ export default function Layout(props: ParentProps) { if (!directory) return const root = projectRoot(directory) server.projects.touch(root) - const target = projectSessionRouteTarget(root) - navigate(`/${base64Encode(target.directory)}/session`) + navigate(openProjectRoute(root)) } function navigateToSession(session: Session | undefined) { if (!session) return - navigate(`/${base64Encode(session.directory)}/session/${session.id}`) + navigate(openSessionRoute(session.directory, session.id)) } function openPawworkHome(directory?: string) { @@ -1442,12 +1443,12 @@ export default function Layout(props: ParentProps) { chooseProject() return } - navigate(`/${base64Encode(root)}/session`) + navigate(newSessionRoute(root)) } - function openProject(directory: string, navigate = true) { + function openProject(directory: string, shouldNavigate = true) { layout.projects.open(directory) - if (navigate) return navigateToProject(directory) + if (shouldNavigate) return navigateToProject(directory) } const handleDeepLinks = (urls: string[]) => { diff --git a/packages/app/src/pages/layout/helpers.test.ts b/packages/app/src/pages/layout/helpers.test.ts index d8628781b..cddd890fe 100644 --- a/packages/app/src/pages/layout/helpers.test.ts +++ b/packages/app/src/pages/layout/helpers.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import { base64Encode } from "@opencode-ai/util/encode" import { collectNewSessionDeepLinks, collectOpenProjectDeepLinks, @@ -14,6 +15,9 @@ import { errorMessage, hasProjectPermissions, latestRootSession, + newSessionRoute, + openProjectRoute, + openSessionRoute, projectSessionRouteTarget, sortedRootSessions, startupAutoselectDirectory, @@ -120,6 +124,14 @@ describe("layout workspace helpers", () => { expect(projectSessionRouteTarget("/Users/demo/PawWork")).toEqual({ directory: "/Users/demo/PawWork" }) }) + test("keeps project, new-session, and explicit-session routes separate", () => { + expect(openProjectRoute("/Users/demo/PawWork")).toBe(`/${base64Encode("/Users/demo/PawWork")}/session`) + expect(newSessionRoute("/Users/demo/worktree")).toBe(`/${base64Encode("/Users/demo/worktree")}/session`) + expect(openSessionRoute("/Users/demo/worktree", "ses_123")).toBe( + `/${base64Encode("/Users/demo/worktree")}/session/ses_123`, + ) + }) + test("normalizes trailing slash in workspace key", () => { expect(workspaceKey("/tmp/demo///")).toBe("/tmp/demo") expect(workspaceKey("C:\\tmp\\demo\\\\")).toBe("C:/tmp/demo") diff --git a/packages/app/src/pages/layout/helpers.ts b/packages/app/src/pages/layout/helpers.ts index 309a798a9..ed4f3c053 100644 --- a/packages/app/src/pages/layout/helpers.ts +++ b/packages/app/src/pages/layout/helpers.ts @@ -1,6 +1,7 @@ import { getFilename } from "@opencode-ai/util/path" import { type Session } from "@opencode-ai/sdk/v2/client" import { compareSessionsByCreated } from "@/context/global-sync/utils" +import { base64Encode } from "@opencode-ai/util/encode" type SessionStore = { session?: Session[] @@ -88,3 +89,9 @@ export const startupAutoselectDirectory = (enabled: boolean, backendDirectory?: } export const projectSessionRouteTarget = (root: string) => ({ directory: root }) + +export const newSessionRoute = (directory: string) => `/${base64Encode(directory)}/session` + +export const openProjectRoute = (root: string) => newSessionRoute(projectSessionRouteTarget(root).directory) + +export const openSessionRoute = (directory: string, id: string) => `${newSessionRoute(directory)}/${id}` diff --git a/packages/ui/src/components/resize-handle.test.ts b/packages/ui/src/components/resize-handle.test.ts new file mode 100644 index 000000000..16f74691e --- /dev/null +++ b/packages/ui/src/components/resize-handle.test.ts @@ -0,0 +1,19 @@ +import { describe, expect, test } from "bun:test" +import { createBodyInteractionLock } from "./resize-handle" + +describe("createBodyInteractionLock", () => { + test("releases body interaction styles on fallback stop events", () => { + const target = new EventTarget() + const body = { style: { userSelect: "", overflow: "" } } + const lock = createBodyInteractionLock(body, { target, fallbackMs: 1000 }) + + lock.start() + expect(body.style.userSelect).toBe("none") + expect(body.style.overflow).toBe("hidden") + + target.dispatchEvent(new Event("blur")) + + expect(body.style.userSelect).toBe("") + expect(body.style.overflow).toBe("") + }) +}) diff --git a/packages/ui/src/components/resize-handle.tsx b/packages/ui/src/components/resize-handle.tsx index d7774a684..4b4410329 100644 --- a/packages/ui/src/components/resize-handle.tsx +++ b/packages/ui/src/components/resize-handle.tsx @@ -11,6 +11,69 @@ export interface ResizeHandleProps extends Omit void + } = {}, +) { + const target = options.target ?? window + const fallbackMs = options.fallbackMs ?? 5000 + let active = false + let fallback: ReturnType | undefined + let previousUserSelect = "" + let previousOverflow = "" + + const release = () => { + if (!active) return + active = false + body.style.userSelect = previousUserSelect + body.style.overflow = previousOverflow + if (fallback !== undefined) { + clearTimeout(fallback) + fallback = undefined + } + for (const event of resizeInteractionStopEvents) { + target.removeEventListener(event, release) + } + options.onRelease?.() + } + + return { + start() { + if (active) return + active = true + previousUserSelect = body.style.userSelect + previousOverflow = body.style.overflow + body.style.userSelect = "none" + body.style.overflow = "hidden" + for (const event of resizeInteractionStopEvents) { + target.addEventListener(event, release) + } + fallback = setTimeout(release, fallbackMs) + }, + stop: release, + } +} + export function ResizeHandle(props: ResizeHandleProps) { const [local, rest] = splitProps(props, [ "direction", @@ -32,9 +95,6 @@ export function ResizeHandle(props: ResizeHandleProps) { const startSize = local.size let current = startSize - document.body.style.userSelect = "none" - document.body.style.overflow = "hidden" - const onMouseMove = (moveEvent: MouseEvent) => { const pos = local.direction === "horizontal" ? moveEvent.clientX : moveEvent.clientY const delta = @@ -50,18 +110,29 @@ export function ResizeHandle(props: ResizeHandleProps) { local.onResize(clamped) } - const onMouseUp = () => { - document.body.style.userSelect = "" - document.body.style.overflow = "" + let finished = false + const finish = (collapse: boolean) => { + if (finished) return + finished = true document.removeEventListener("mousemove", onMouseMove) document.removeEventListener("mouseup", onMouseUp) const threshold = local.collapseThreshold ?? 0 - if (local.onCollapse && threshold > 0 && current < threshold) { + if (collapse && local.onCollapse && threshold > 0 && current < threshold) { local.onCollapse() } } + const lock = createBodyInteractionLock(document.body, { + onRelease: () => finish(false), + }) + + const onMouseUp = () => { + finish(true) + lock.stop() + } + + lock.start() document.addEventListener("mousemove", onMouseMove) document.addEventListener("mouseup", onMouseUp) } From d4fa3904bbb911d1c8beebafb09aff31c652c8e2 Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Mon, 4 May 2026 09:53:16 +0800 Subject: [PATCH 2/3] test: cover resize lock release paths --- packages/app/e2e/commands/panels.spec.ts | 30 +++++++- .../ui/src/components/resize-handle.test.ts | 70 ++++++++++++++++++- packages/ui/src/components/resize-handle.tsx | 37 +++++++--- 3 files changed, 125 insertions(+), 12 deletions(-) diff --git a/packages/app/e2e/commands/panels.spec.ts b/packages/app/e2e/commands/panels.spec.ts index 54d5a36f8..3eecdb540 100644 --- a/packages/app/e2e/commands/panels.spec.ts +++ b/packages/app/e2e/commands/panels.spec.ts @@ -53,8 +53,36 @@ test("desktop remains clickable after right-panel resize ends with mouseup", asy await page.keyboard.press(`${modKey}+Shift+R`) await expect(rightPanel).toHaveAttribute("aria-hidden", "false") - await page.locator('[data-component="right-panel-resize-wrapper"]').dispatchEvent("pointerdown") + const handle = page.locator('[data-component="right-panel-resize-wrapper"] [data-component="resize-handle"]') + await expect(handle).toBeVisible() + const box = await handle.boundingBox() + if (!box) throw new Error("Right-panel resize handle missing") + + const start = { x: box.x + box.width / 2, y: box.y + box.height / 2 } + await handle.dispatchEvent("mousedown", { + button: 0, + buttons: 1, + clientX: start.x, + clientY: start.y, + }) + await expect + .poll(() => + page.evaluate(() => ({ + overflow: document.body.style.overflow, + userSelect: document.body.style.userSelect, + })), + ) + .toEqual({ overflow: "hidden", userSelect: "none" }) + await page.mouse.move(start.x - 40, start.y) await page.mouse.up() + await expect + .poll(() => + page.evaluate(() => ({ + overflow: document.body.style.overflow, + userSelect: document.body.style.userSelect, + })), + ) + .toEqual({ overflow: "", userSelect: "" }) await page.locator(pawworkSessionNewSelector).click() await expect(page).toHaveURL(new RegExp(`/${slug}/session(?:\\?|#|$)`)) diff --git a/packages/ui/src/components/resize-handle.test.ts b/packages/ui/src/components/resize-handle.test.ts index 16f74691e..c93608bd3 100644 --- a/packages/ui/src/components/resize-handle.test.ts +++ b/packages/ui/src/components/resize-handle.test.ts @@ -2,10 +2,15 @@ import { describe, expect, test } from "bun:test" import { createBodyInteractionLock } from "./resize-handle" describe("createBodyInteractionLock", () => { - test("releases body interaction styles on fallback stop events", () => { + test("releases body interaction styles on cancel events", () => { const target = new EventTarget() const body = { style: { userSelect: "", overflow: "" } } - const lock = createBodyInteractionLock(body, { target, fallbackMs: 1000 }) + const releases: string[] = [] + const lock = createBodyInteractionLock(body, { + target, + fallbackMs: 1000, + onRelease: (reason) => releases.push(reason), + }) lock.start() expect(body.style.userSelect).toBe("none") @@ -15,5 +20,66 @@ describe("createBodyInteractionLock", () => { expect(body.style.userSelect).toBe("") expect(body.style.overflow).toBe("") + expect(releases).toEqual(["cancel"]) + }) + + test("treats pointerup as a completed resize release", () => { + const target = new EventTarget() + const body = { style: { userSelect: "", overflow: "" } } + const releases: string[] = [] + const lock = createBodyInteractionLock(body, { + target, + fallbackMs: 1000, + onRelease: (reason) => releases.push(reason), + }) + + lock.start() + target.dispatchEvent(new Event("pointerup")) + + expect(body.style.userSelect).toBe("") + expect(body.style.overflow).toBe("") + expect(releases).toEqual(["complete"]) + }) + + test("restores previous styles and keeps repeated start idempotent", () => { + const target = new EventTarget() + const body = { style: { userSelect: "text", overflow: "auto" } } + const releases: string[] = [] + const lock = createBodyInteractionLock(body, { + target, + fallbackMs: 1000, + onRelease: (reason) => releases.push(reason), + }) + + lock.start() + lock.start() + target.dispatchEvent(new Event("mouseup")) + target.dispatchEvent(new Event("mouseup")) + + expect(body.style.userSelect).toBe("text") + expect(body.style.overflow).toBe("auto") + expect(releases).toEqual(["complete"]) + }) + + test("releases on timeout once", async () => { + const target = new EventTarget() + const body = { style: { userSelect: "", overflow: "" } } + const releases: string[] = [] + const timeoutLock = createBodyInteractionLock(body, { + target, + fallbackMs: 1, + onRelease: (reason) => releases.push(reason), + }) + + timeoutLock.start() + expect(body.style.userSelect).toBe("none") + expect(body.style.overflow).toBe("hidden") + + await new Promise((resolve) => setTimeout(resolve, 5)) + timeoutLock.stop() + + expect(body.style.userSelect).toBe("") + expect(body.style.overflow).toBe("") + expect(releases).toEqual(["timeout"]) }) }) diff --git a/packages/ui/src/components/resize-handle.tsx b/packages/ui/src/components/resize-handle.tsx index 4b4410329..31584aa9d 100644 --- a/packages/ui/src/components/resize-handle.tsx +++ b/packages/ui/src/components/resize-handle.tsx @@ -11,6 +11,15 @@ export interface ResizeHandleProps extends Omit void + onRelease?: (reason: BodyInteractionLockReleaseReason) => void } = {}, ) { const target = options.target ?? window @@ -42,7 +54,7 @@ export function createBodyInteractionLock( let previousUserSelect = "" let previousOverflow = "" - const release = () => { + const release = (reason: BodyInteractionLockReleaseReason) => { if (!active) return active = false body.style.userSelect = previousUserSelect @@ -52,10 +64,14 @@ export function createBodyInteractionLock( fallback = undefined } for (const event of resizeInteractionStopEvents) { - target.removeEventListener(event, release) + target.removeEventListener(event, complete) + target.removeEventListener(event, cancel) } - options.onRelease?.() + options.onRelease?.(reason) } + const complete = () => release("complete") + const cancel = () => release("cancel") + const timeout = () => release("timeout") return { start() { @@ -65,12 +81,15 @@ export function createBodyInteractionLock( previousOverflow = body.style.overflow body.style.userSelect = "none" body.style.overflow = "hidden" - for (const event of resizeInteractionStopEvents) { - target.addEventListener(event, release) + for (const event of resizeInteractionCompleteEvents) { + target.addEventListener(event, complete) + } + for (const event of resizeInteractionCancelEvents) { + target.addEventListener(event, cancel) } - fallback = setTimeout(release, fallbackMs) + fallback = setTimeout(timeout, fallbackMs) }, - stop: release, + stop: complete, } } @@ -124,7 +143,7 @@ export function ResizeHandle(props: ResizeHandleProps) { } const lock = createBodyInteractionLock(document.body, { - onRelease: () => finish(false), + onRelease: (reason) => finish(reason === "complete"), }) const onMouseUp = () => { From e095e2273e558e8159959c9f4aa57f1e18b8eb4c Mon Sep 17 00:00:00 2001 From: Yuhan Lei Date: Mon, 4 May 2026 10:05:57 +0800 Subject: [PATCH 3/3] test: address resize review feedback --- packages/app/e2e/commands/panels.spec.ts | 27 +++++++++++-------- .../ui/src/components/resize-handle.test.ts | 6 ++++- packages/ui/src/components/resize-handle.tsx | 4 ++- 3 files changed, 24 insertions(+), 13 deletions(-) diff --git a/packages/app/e2e/commands/panels.spec.ts b/packages/app/e2e/commands/panels.spec.ts index 3eecdb540..caaecc921 100644 --- a/packages/app/e2e/commands/panels.spec.ts +++ b/packages/app/e2e/commands/panels.spec.ts @@ -39,17 +39,22 @@ test("desktop right-panel tabs switch between review and files within a unified test("desktop remains clickable after right-panel resize ends with mouseup", async ({ page, gotoSession, slug, sdk }) => { const stamp = Date.now() - const one = await sdk.session.create({ title: `e2e resize source ${stamp}` }).then((r) => r.data) - const two = await sdk.session.create({ title: `e2e resize target ${stamp}` }).then((r) => r.data) - - if (!one?.id) throw new Error("Source session create did not return an id") - if (!two?.id) throw new Error("Target session create did not return an id") + let oneID: string | undefined + let twoID: string | undefined try { - await gotoSession(one.id) + const one = await sdk.session.create({ title: `e2e resize source ${stamp}` }).then((r) => r.data) + if (!one?.id) throw new Error("Source session create did not return an id") + oneID = one.id + + const two = await sdk.session.create({ title: `e2e resize target ${stamp}` }).then((r) => r.data) + if (!two?.id) throw new Error("Target session create did not return an id") + twoID = two.id + + await gotoSession(oneID) await openSidebar(page) - const rightPanel = page.locator("#right-panel") + const rightPanel = page.locator('[data-component="right-panel"]') await page.keyboard.press(`${modKey}+Shift+R`) await expect(rightPanel).toHaveAttribute("aria-hidden", "false") @@ -88,15 +93,15 @@ test("desktop remains clickable after right-panel resize ends with mouseup", asy await expect(page).toHaveURL(new RegExp(`/${slug}/session(?:\\?|#|$)`)) await expect(page.locator('[data-component="session-new-home"]')).toBeVisible() - await page.locator(`[data-session-id="${two.id}"] a`).first().click() - await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`)) + await page.locator(`[data-session-id="${twoID}"] a`).first().click() + await expect(page).toHaveURL(new RegExp(`/${slug}/session/${twoID}(?:\\?|#|$)`)) await expect(page.locator(promptSelector)).toBeVisible() await page.locator('[data-action="pawwork-open-settings"]').click() await expect(page.locator('[data-component="settings-page"]')).toBeVisible() } finally { - await cleanupSession({ sdk, sessionID: one.id }) - await cleanupSession({ sdk, sessionID: two.id }) + if (oneID) await cleanupSession({ sdk, sessionID: oneID }) + if (twoID) await cleanupSession({ sdk, sessionID: twoID }) } }) diff --git a/packages/ui/src/components/resize-handle.test.ts b/packages/ui/src/components/resize-handle.test.ts index c93608bd3..ff3cb8fad 100644 --- a/packages/ui/src/components/resize-handle.test.ts +++ b/packages/ui/src/components/resize-handle.test.ts @@ -1,7 +1,11 @@ import { describe, expect, test } from "bun:test" -import { createBodyInteractionLock } from "./resize-handle" +import { createBodyInteractionLock, resizeInteractionFallbackMs } from "./resize-handle" describe("createBodyInteractionLock", () => { + test("uses a long default fallback timeout for manual resizing", () => { + expect(resizeInteractionFallbackMs).toBe(30_000) + }) + test("releases body interaction styles on cancel events", () => { const target = new EventTarget() const body = { style: { userSelect: "", overflow: "" } } diff --git a/packages/ui/src/components/resize-handle.tsx b/packages/ui/src/components/resize-handle.tsx index 31584aa9d..a7aa8d59c 100644 --- a/packages/ui/src/components/resize-handle.tsx +++ b/packages/ui/src/components/resize-handle.tsx @@ -32,6 +32,8 @@ export const resizeInteractionStopEvents = [ export type BodyInteractionLockReleaseReason = "complete" | "cancel" | "timeout" +export const resizeInteractionFallbackMs = 30_000 + type BodyInteractionLockTarget = { style: { userSelect: string @@ -48,7 +50,7 @@ export function createBodyInteractionLock( } = {}, ) { const target = options.target ?? window - const fallbackMs = options.fallbackMs ?? 5000 + const fallbackMs = options.fallbackMs ?? resizeInteractionFallbackMs let active = false let fallback: ReturnType | undefined let previousUserSelect = ""