Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 68 additions & 16 deletions packages/app/e2e/commands/panels.spec.ts
Original file line number Diff line number Diff line change
@@ -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 }) => {
Expand Down Expand Up @@ -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

Comment thread
coderabbitai[bot] marked this conversation as resolved.
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(?:\\?|#|$)`))
Comment thread
Astro-Han marked this conversation as resolved.
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 }) => {
Expand Down
15 changes: 8 additions & 7 deletions packages/app/src/pages/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,9 @@ import {
displayName,
effectiveWorkspaceOrder,
errorMessage,
projectSessionRouteTarget,
newSessionRoute,
openProjectRoute,
openSessionRoute,
startupAutoselectDirectory,
sortedRootSessions,
workspaceKey,
Expand Down Expand Up @@ -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) {
Expand All @@ -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[]) => {
Expand Down
12 changes: 12 additions & 0 deletions packages/app/src/pages/layout/helpers.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { describe, expect, test } from "bun:test"
import { base64Encode } from "@opencode-ai/util/encode"
import {
collectNewSessionDeepLinks,
collectOpenProjectDeepLinks,
Expand All @@ -14,6 +15,9 @@ import {
errorMessage,
hasProjectPermissions,
latestRootSession,
newSessionRoute,
openProjectRoute,
openSessionRoute,
projectSessionRouteTarget,
sortedRootSessions,
startupAutoselectDirectory,
Expand Down Expand Up @@ -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")
Expand Down
7 changes: 7 additions & 0 deletions packages/app/src/pages/layout/helpers.ts
Original file line number Diff line number Diff line change
@@ -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[]
Expand Down Expand Up @@ -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}`
89 changes: 89 additions & 0 deletions packages/ui/src/components/resize-handle.test.ts
Original file line number Diff line number Diff line change
@@ -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"])
})
})
Loading
Loading