Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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
45 changes: 32 additions & 13 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,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")

Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
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")
Comment thread
Astro-Han marked this conversation as resolved.
Resolved
await page.mouse.up()

await page.locator(pawworkSessionNewSelector).click()
await expect(page).toHaveURL(new RegExp(`/${slug}/session(?:\\?|#|$)`))
Comment thread
Astro-Han marked this conversation as resolved.
Resolved
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 }) => {
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}`
19 changes: 19 additions & 0 deletions packages/ui/src/components/resize-handle.test.ts
Original file line number Diff line number Diff line change
@@ -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("")
})
})
85 changes: 78 additions & 7 deletions packages/ui/src/components/resize-handle.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,69 @@ export interface ResizeHandleProps extends Omit<JSX.HTMLAttributes<HTMLDivElemen
collapseThreshold?: number
}

export const resizeInteractionStopEvents = [
"mouseup",
"pointerup",
"pointercancel",
"touchend",
"touchcancel",
"blur",
] as const

type BodyInteractionLockTarget = {
style: {
userSelect: string
overflow: string
}
}

export function createBodyInteractionLock(
body: BodyInteractionLockTarget,
options: {
target?: EventTarget
fallbackMs?: number
onRelease?: () => void
} = {},
) {
const target = options.target ?? window
const fallbackMs = options.fallbackMs ?? 5000
Comment thread
Astro-Han marked this conversation as resolved.
Resolved
let active = false
let fallback: ReturnType<typeof setTimeout> | 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",
Expand All @@ -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 =
Expand All @@ -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)
}
Expand Down
Loading