diff --git a/packages/app/e2e/commands/panels.spec.ts b/packages/app/e2e/commands/panels.spec.ts index 8ba762dc3..caaecc921 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,72 @@ 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) - - 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() +test("desktop remains clickable after right-panel resize ends with mouseup", async ({ page, gotoSession, slug, sdk }) => { + const stamp = Date.now() + let oneID: string | undefined + let twoID: string | undefined + + try { + 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('[data-component="right-panel"]') + await page.keyboard.press(`${modKey}+Shift+R`) + await expect(rightPanel).toHaveAttribute("aria-hidden", "false") + + 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(?:\\?|#|$)`)) + await expect(page.locator('[data-component="session-new-home"]')).toBeVisible() + + 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 { + if (oneID) await cleanupSession({ sdk, sessionID: oneID }) + if (twoID) await cleanupSession({ sdk, sessionID: twoID }) + } }) 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..ff3cb8fad --- /dev/null +++ b/packages/ui/src/components/resize-handle.test.ts @@ -0,0 +1,89 @@ +import { describe, expect, test } from "bun:test" +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: "" } } + const releases: string[] = [] + const lock = createBodyInteractionLock(body, { + target, + fallbackMs: 1000, + onRelease: (reason) => releases.push(reason), + }) + + 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("") + 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 d7774a684..a7aa8d59c 100644 --- a/packages/ui/src/components/resize-handle.tsx +++ b/packages/ui/src/components/resize-handle.tsx @@ -11,6 +11,90 @@ export interface ResizeHandleProps extends Omit void + } = {}, +) { + const target = options.target ?? window + const fallbackMs = options.fallbackMs ?? resizeInteractionFallbackMs + let active = false + let fallback: ReturnType | undefined + let previousUserSelect = "" + let previousOverflow = "" + + const release = (reason: BodyInteractionLockReleaseReason) => { + 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, complete) + target.removeEventListener(event, cancel) + } + options.onRelease?.(reason) + } + const complete = () => release("complete") + const cancel = () => release("cancel") + const timeout = () => release("timeout") + + 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 resizeInteractionCompleteEvents) { + target.addEventListener(event, complete) + } + for (const event of resizeInteractionCancelEvents) { + target.addEventListener(event, cancel) + } + fallback = setTimeout(timeout, fallbackMs) + }, + stop: complete, + } +} + export function ResizeHandle(props: ResizeHandleProps) { const [local, rest] = splitProps(props, [ "direction", @@ -32,9 +116,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 +131,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: (reason) => finish(reason === "complete"), + }) + + const onMouseUp = () => { + finish(true) + lock.stop() + } + + lock.start() document.addEventListener("mousemove", onMouseMove) document.addEventListener("mouseup", onMouseUp) }