Skip to content
Open
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
5 changes: 2 additions & 3 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,12 +65,11 @@ The canvas (`Canvas.tsx`) positions nodes using CSS transforms. Panel positions

Panel definitions are centralised in `src/shared/panels.ts`. The detachable panel
types (`PanelType` in `src/shared/types.ts`) are: terminal, browser, editor,
canvas, agent, document, review. Renderer components live in `src/renderer/panels/`:
- **EditorPanel** — Monaco Editor with integrated Files and Search navigation
canvas, agent, review. Renderer components live in `src/renderer/panels/`:
- **EditorPanel** — Files with Monaco editing, image/PDF/DOCX previews, and integrated Files and Search navigation
- **TerminalPanel** — xterm.js terminal with WebGL renderer, backed by node-pty
- **BrowserPanel** — embedded webview (file:// allowed for local HTML)
- **CanvasPanel** — nested canvas
- **DocumentPanel** — PDF / docx / image preview
- **AgentPanel** — Codex agent thread (sidebar + dock)

The file tree and Search are hosted by EditorPanel. Source Control and Pull
Expand Down
55 changes: 55 additions & 0 deletions docs/dock-rules.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
# Dock behavior and test matrix

This document defines the supported dock state transitions. “Maximize” means a
reversible presentation: a main-dock split is merged into tabs, or one pane of a
canvas node is promoted beside its containing canvas. “Minimize” means restoring
that saved layout. A presentation is restorable only while both its destination
and, for a promoted canvas pane, its source topology remain unchanged.

## Drag and placement rules

| Source | Drop target | Result | Automated coverage |
|---|---|---|---|
| Dock tab | Same stack tab bar | Reorder tabs; a one-tab self-drop is a no-op | `dockStore.rules.test.ts`, `drag/resolve.test.ts`, `drag/commit.test.ts` |
| Dock tab | Another dock stack tab bar | Move into that stack as a tab | `dockStore.presentation.test.ts`, `drag/commit.test.ts` |
| Dock tab | Left/right dock edge | Horizontal split, before/after the target | `dockStore.rules.test.ts`, `three-way-split.spec.ts` |
| Dock tab | Top/bottom dock edge | Vertical split, before/after the target | `dockStore.rules.test.ts`, `drag/commit.test.ts` |
| Dock tab | Empty canvas | Create a canvas node containing the panel | `dock-rules.spec.ts` |
| Dock tab | Canvas-node tab bar | Add the panel as a tab and remove it from the main dock | `drag/commit.test.ts` |
| Dock tab | Canvas-node edge | Add the panel as a split pane and remove it from the main dock | `drag/commit.test.ts` |
| Canvas node/pane | Empty area of the same canvas | Reposition the node; preserve its tabs/splits | `drag-move.spec.ts`, `drag-split.spec.ts` |
| Canvas node/pane | Another canvas-node tab bar | Merge into the target as tabs; remove an emptied source node | `drag-split.spec.ts` |
| Canvas node/pane | Another canvas-node edge | Split the target; remove an emptied source node | `drag-split.spec.ts` |
| Canvas node/pane | Main-dock tab bar | Move into the dock as a tab | `dock-rules.spec.ts` |
| Canvas node/pane | Main-dock edge | Move into the dock as a split | `drag/commit.test.ts` |
| Canvas node/pane | Outside the application window | Detach into a new dock window | `drag-detach.spec.ts` |
| Canvas panel | A canvas node | Reject recursive canvas nesting | `drag-canvas-into-canvas.spec.ts` |
| Detached-window panel | Main dock or canvas | Commit only after receiver acknowledgement; otherwise recover in the detached session | `detached-panels.spec.ts`, `existing-window-drop.test.ts`, `windowPanelSync.test.ts` |

For every split edge, placement order is fixed: left/top inserts before the
target and right/bottom inserts after it. Same-direction splits gain an equal
sibling instead of creating an unnecessary nested split.

## Split, maximize, minimize, and invalidation rules

| Starting state | Action | Defined result | Restore status | Automated coverage |
|---|---|---|---|---|
| One dock stack | Split right | A new surface in a horizontal sibling | Not applicable | `three-way-split.spec.ts`, `dock-rules.spec.ts` |
| Main dock with any split tree | Maximize a leaf | Flatten the zone into one tab stack, keeping deterministic tree order and the selected leaf active | Valid | `dockStore.presentation.test.ts`, `dock-rules.spec.ts` |
| Maximized main dock | Select a merged tab | Only active selection changes | Remains valid | `dockStore.rules.test.ts` |
| Maximized main dock | Resize/toggle another zone, add a panel to another zone, or take a snapshot | Presented topology is unchanged | Remains valid | `dockStore.rules.test.ts` |
| Maximized main dock | Add/remove/reorder/move a presented tab, split/collapse its stack, or restore a snapshot | Keep the user’s new topology | Permanently invalidated | `dockStore.rules.test.ts`, `dock-rules.spec.ts` |
| Maximized main dock, unchanged | Minimize | Restore the exact pre-merge split tree | Consumed | `dockStore.presentation.test.ts`, `dock-rules.spec.ts` |
| Singleton canvas node | Maximize | Remove the empty node and promote its panel beside the canvas | Valid | `CanvasNode.groupDrag.test.tsx` |
| Tabbed or split canvas node | Maximize one pane | Promote only the active pane; preserve the remaining node | Valid | `CanvasNode.groupDrag.test.tsx`, `dock-rules.spec.ts` |
| Promoted canvas pane | Select canvas/promoted tab, resize surrounding split, resize/toggle an unrelated zone, add to another zone, or take a snapshot | No structural change to either saved topology | Remains valid | `dockStore.presentation.test.ts` |
| Promoted canvas pane | Structurally change the source canvas node | Keep both the promoted pane and the edited source | Permanently invalidated | `CanvasNode.groupDrag.test.tsx`, `dock-rules.spec.ts` |
| Promoted canvas pane | Add/remove/reorder/move/split/collapse in the destination, including moving away and back | Keep the user’s new destination | Permanently invalidated | `dockStore.presentation.test.ts`, `dock-rules.spec.ts` |
| Promoted canvas pane, both sides unchanged | Minimize | Restore the exact node id, position, size, tabs, split tree, and active pane | Consumed | `CanvasNode.groupDrag.test.tsx`, `dock-rules.spec.ts` |
| Any active presentation | Maximize another stack/pane | Ignore the second request; presentations never nest | Existing presentation remains valid | `dockStore.presentation.test.ts` |
| Invalidated presentation | Minimize | No-op; the restore control is removed | Unavailable | `dockStore.presentation.test.ts`, `dock-rules.spec.ts` |

The invalidation rule is intentionally structural. Tab selection and split
ratios are presentation details and are safe; panel identity, order, tree shape,
and source-node existence are ownership/topology and cannot be overwritten by a
later restore.
84 changes: 82 additions & 2 deletions e2e/detached-panels.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,86 @@ test('Files root terminal action places the terminal in the visible detached doc
await expect(detached.locator(`[data-tab-panel-id="${terminal}"]`)).toBeVisible()
})

test('screenshot preview and comment editing stay in the detached canvas window', async () => {
await app.evaluate(({ ipcMain }) => {
const svg = `data:image/svg+xml;base64,${Buffer.from('<svg xmlns="http://www.w3.org/2000/svg" width="800" height="600"><rect width="800" height="600" fill="#345"/></svg>').toString('base64')}`
const shots = [
{ id: 'detached-shot', filePath: '/tmp/detached-shot.png', dataUrl: svg },
{ id: 'detached-shot-2', filePath: '/tmp/detached-shot-2.png', dataUrl: svg },
]
ipcMain.removeHandler('recentScreenshot:get')
ipcMain.removeHandler('recentScreenshot:read')
ipcMain.removeHandler('recentScreenshot:save')
ipcMain.handle('recentScreenshot:get', () => shots)
ipcMain.handle('recentScreenshot:read', () => svg)
ipcMain.handle('recentScreenshot:save', (_event, _id, dataUrl) => {
;(globalThis as typeof globalThis & { __detachedAnnotation?: string }).__detachedAnnotation = dataUrl
return { ...shots[0], id: 'detached-saved', filePath: '/tmp/detached-saved.png', dataUrl, annotated: true }
})
})
const id = await main.evaluate(() => window.__cateE2E!.createPanel('canvas'))
const detached = await detach(id)
const previewButton = detached.getByRole('button', { name: 'Open screenshot preview' }).first()
await expect(previewButton).toBeVisible()
await previewButton.click()
const viewer = detached.getByRole('dialog', { name: 'Screenshot preview' })
await expect(viewer).toBeVisible()
await expect(main.getByRole('dialog', { name: 'Screenshot preview' })).toHaveCount(0)
await expect(detached.getByRole('link', { name: 'Download screenshot' })).toHaveAttribute('download', 'detached-shot.png')
await viewer.getByRole('button', { name: 'Next screenshot' }).click()
await expect(detached.getByText('2 / 2')).toBeVisible()
await expect(viewer.getByRole('img', { name: 'Screenshot 2 of 2' })).toBeVisible()
await viewer.getByRole('button', { name: 'Zoom in' }).click()
await expect(viewer.getByRole('button', { name: 'Fit screenshot' })).not.toHaveText('100%')
await expect(viewer.getByRole('toolbar', { name: 'Screenshot drawing tools' })).toBeVisible()
const annotation = detached.getByLabel('Annotate screenshot')
const box = await annotation.boundingBox()
if (!box) throw new Error('Missing detached screenshot annotation bounds')
await detached.mouse.move(box.x + box.width * 0.2, box.y + box.height * 0.2)
await detached.mouse.down()
await detached.mouse.move(box.x + box.width * 0.3, box.y + box.height * 0.3)
await detached.mouse.up()
await detached.getByRole('button', { name: 'Add comment' }).click()
await annotation.locator('rect').click({ position: { x: box.width / 2, y: box.height / 2 } })
await detached.getByRole('textbox', { name: 'Comment 1' }).fill('Detached canvas comment')
const comment = detached.locator('foreignObject').last()
const initialX = Number(await comment.getAttribute('x'))
const handle = detached.getByRole('button', { name: 'Move comment 1' })
const handleBox = await handle.boundingBox()
if (!handleBox) throw new Error('Missing detached comment drag handle')
await detached.mouse.move(handleBox.x + handleBox.width / 2, handleBox.y + handleBox.height / 2)
await detached.mouse.down()
await detached.mouse.move(handleBox.x + handleBox.width / 2 + 50, handleBox.y + handleBox.height / 2 + 20)
await detached.mouse.up()
await expect.poll(async () => Number(await comment.getAttribute('x'))).toBeGreaterThan(initialX)
const initialWidth = Number(await comment.getAttribute('width'))
const initialHeight = Number(await comment.getAttribute('height'))
const scaleHandle = detached.locator('[aria-label="Resize comment 1"]')
const scaleBox = await scaleHandle.boundingBox()
if (!scaleBox) throw new Error('Missing detached comment scale handle')
await detached.mouse.move(scaleBox.x + scaleBox.width - 2, scaleBox.y + scaleBox.height / 2)
await detached.mouse.down()
await detached.mouse.move(scaleBox.x + scaleBox.width + 38, scaleBox.y + scaleBox.height / 2)
await detached.mouse.up()
await expect.poll(async () => Number(await comment.getAttribute('width'))).toBeGreaterThan(initialWidth)
await expect.poll(async () => Number(await comment.getAttribute('height'))).toBe(initialHeight)
const calloutGroup = comment.locator('..')
const initialTransform = await calloutGroup.getAttribute('transform')
const rotateHandle = detached.locator('[aria-label="Rotate comment 1"]')
await expect(rotateHandle).toHaveCSS('cursor', /url/)
const rotateBox = await rotateHandle.boundingBox()
if (!rotateBox) throw new Error('Missing detached comment rotation handle')
await detached.mouse.move(rotateBox.x + rotateBox.width / 2, rotateBox.y + 2)
await detached.mouse.down()
await detached.mouse.move(rotateBox.x + rotateBox.width / 2 + 45, rotateBox.y + 27)
await detached.mouse.up()
await expect.poll(() => calloutGroup.getAttribute('transform')).not.toBe(initialTransform)
await detached.getByRole('button', { name: 'Save' }).click()
await expect(detached.getByRole('dialog', { name: 'Screenshot preview' })).toHaveCount(0)
await expect(detached.getByLabel('Annotated screenshot')).toBeVisible()
await expect.poll(() => app.evaluate(() => (globalThis as typeof globalThis & { __detachedAnnotation?: string }).__detachedAnnotation?.startsWith('data:image/png;base64,'))).toBe(true)
})

async function chooseNativeMenu(choice: string) {
await app.evaluate(({ ipcMain }, choice) => {
ipcMain.removeHandler('menu:showContext')
Expand All @@ -70,11 +150,11 @@ async function chooseNativeMenu(choice: string) {
}, choice)
}

for (const type of ['terminal', 'browser', 'editor', 'canvas', 'agent', 'document', 'review', 'surface'] as const) {
for (const type of ['terminal', 'browser', 'editor', 'canvas', 'agent', 'review', 'surface'] as const) {
test(`${type}: real detach, palette, overview reveal and owner-routed close`, async () => {
const image = path.join(directory, 'preview.svg')
writeFileSync(image, '<svg xmlns="http://www.w3.org/2000/svg" width="40" height="40"><rect width="40" height="40" fill="red"/></svg>')
const id = await main.evaluate(({ type, image }) => window.__cateE2E!.createPanel(type, type === 'document' ? image : undefined), { type, image })
const id = await main.evaluate(({ type, image }) => window.__cateE2E!.createPanel(type, type === 'editor' ? image : undefined), { type, image })
expect(id).toBeTruthy()
const detached = await detach(id)
await expect.poll(() => main.evaluate(id => window.__cateE2E!.panels().some(p => p.id === id), id)).toBe(false)
Expand Down
Loading