= ({
className="absolute z-50 flex gap-2 pointer-events-auto"
style={{
...(mmBottom ? { bottom: '1rem' } : { top: '1rem' }),
- ...(mmRight ? { right: !isHorizontal && mmBottom ? 'calc(1rem + 52px)' : '1rem' } : { left: '1rem' }),
+ ...(mmRight ? { right: '1rem' } : { left: '1rem' }),
flexDirection: mmRight ? 'row' : 'row-reverse',
alignItems: mmBottom ? 'flex-end' : 'flex-start',
}}
diff --git a/src/renderer/canvas/Minimap.test.tsx b/src/renderer/canvas/Minimap.test.tsx
index 356a83c10..1f16fa415 100644
--- a/src/renderer/canvas/Minimap.test.tsx
+++ b/src/renderer/canvas/Minimap.test.tsx
@@ -5,7 +5,7 @@ import { PANEL_DEFINITIONS } from '../../shared/panels'
const h = vi.hoisted(() => ({
canvas: {
nodes: {
- one: { id: 'one', origin: { x: 0, y: 0 }, size: { width: 600, height: 400 }, dockLayout: { type: 'tabs', id: 'one', panelIds: ['document'], activeIndex: 0 } },
+ one: { id: 'one', origin: { x: 0, y: 0 }, size: { width: 600, height: 400 }, dockLayout: { type: 'tabs', id: 'one', panelIds: ['editor'], activeIndex: 0 } },
two: { id: 'two', origin: { x: 700, y: 0 }, size: { width: 600, height: 400 }, dockLayout: { type: 'tabs', id: 'two', panelIds: ['review'], activeIndex: 0 } },
},
zoomLevel: 1, containerSize: { width: 1000, height: 800 }, viewportOffset: { x: 0, y: 0 },
@@ -17,7 +17,7 @@ vi.mock('../stores/CanvasStoreContext', () => ({
}))
vi.mock('../stores/appStore', () => ({
useAppStore: (select: any) => select({ selectedWorkspaceId: 'ws' }),
- useWorkspacePanels: () => ({ document: { type: 'document' }, review: { type: 'review' } }),
+ useWorkspacePanels: () => ({ editor: { type: 'editor' }, review: { type: 'review' } }),
}))
vi.mock('../hooks/useAgentPanelInfo', () => ({ useAgentInfoByPanel: () => ({}) }))
vi.mock('./worktree/useWorktreeMembership', () => ({ useWorktreeMembership: () => ({ groups: [] }) }))
@@ -30,7 +30,7 @@ it('uses registered document and review colors in the minimap', () => {
try {
act(() => root.render(
))
const colors = [...host.querySelectorAll
('[style]')].map(element => element.style.backgroundColor)
- for (const type of ['document', 'review'] as const) {
+ for (const type of ['editor', 'review'] as const) {
expect(colors).toContain(`var(--panel-${type}, ${PANEL_DEFINITIONS[type].mutedColor})`)
}
} finally { act(() => root.unmount()) }
diff --git a/src/renderer/canvas/RecentScreenshotButton.test.tsx b/src/renderer/canvas/RecentScreenshotButton.test.tsx
index 7e1a1011d..6b0d2fd9b 100644
--- a/src/renderer/canvas/RecentScreenshotButton.test.tsx
+++ b/src/renderer/canvas/RecentScreenshotButton.test.tsx
@@ -73,3 +73,119 @@ it('does not crash the canvas when an older preload lacks screenshot APIs', asyn
vi.unstubAllGlobals()
}
})
+
+it('renders the annotation marker for a saved screenshot', async () => {
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
+ const originalAPI = window.electronAPI
+ window.electronAPI = {
+ ...originalAPI,
+ getRecentScreenshot: vi.fn().mockResolvedValue([{
+ id: 'annotated', filePath: '/annotated.png', dataUrl: 'data:image/png;base64,test', annotated: true,
+ }]),
+ onRecentScreenshotChanged: vi.fn(() => () => {}),
+ dragRecentScreenshot: vi.fn().mockResolvedValue(undefined),
+ }
+ const host = document.createElement('div')
+ const root = createRoot(host)
+ try {
+ await act(async () => root.render( ))
+ expect(host.querySelector('[aria-label="Annotated screenshot"]')).not.toBeNull()
+ } finally {
+ act(() => root.unmount())
+ window.electronAPI = originalAPI
+ vi.unstubAllGlobals()
+ }
+})
+
+it('opens clicked screenshots, navigates with overlay controls and keys, and closes the viewer', async () => {
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
+ const originalAPI = window.electronAPI
+ let nextImageWidth = 800
+ let nextImageHeight = window.innerHeight - 160
+ vi.stubGlobal('Image', class {
+ src = ''
+ naturalWidth = nextImageWidth
+ naturalHeight = nextImageHeight
+ decode = async () => {}
+ })
+ const shots = ['first', 'second', 'third'].map(id => ({ id, filePath: `/${id}.png`, dataUrl: `data:image/png;base64,${id}` }))
+ window.electronAPI = {
+ ...originalAPI,
+ getRecentScreenshot: vi.fn().mockResolvedValue(shots),
+ readRecentScreenshot: vi.fn().mockResolvedValue('data:image/png;base64,iVBORw=='),
+ shellOpenPath: vi.fn().mockResolvedValue({ ok: true }),
+ onRecentScreenshotChanged: vi.fn(() => () => {}),
+ dragRecentScreenshot: vi.fn().mockResolvedValue(undefined),
+ }
+ const host = document.createElement('div')
+ document.body.appendChild(host)
+ const root = createRoot(host)
+ const dialog = () => document.querySelector('[role="dialog"]')!
+ const key = (value: string) => act(async () => document.activeElement!.dispatchEvent(new KeyboardEvent('keydown', { key: value, bubbles: true })))
+ const expectOriginal = (index: number) => {
+ expect(window.electronAPI.readRecentScreenshot).toHaveBeenLastCalledWith(shots[index].id)
+ expect(dialog().querySelector('img')?.getAttribute('src')).toBe('data:image/png;base64,iVBORw==')
+ }
+ try {
+ await act(async () => root.render( ))
+ const thumbnail = host.querySelectorAll('[draggable="true"]')[1]
+ act(() => thumbnail.focus())
+ await act(async () => thumbnail.click())
+ expectOriginal(1)
+ expect(dialog().contains(document.activeElement)).toBe(true)
+ expect(dialog().querySelector('[aria-label="Annotate screenshot"]')).not.toBeNull()
+ nextImageWidth = 4000
+ nextImageHeight = 100
+ await key('ArrowRight')
+ expectOriginal(2)
+ expect(parseFloat((dialog().querySelector('img') as HTMLImageElement).style.height)).toBeCloseTo((window.innerWidth - 96) / 40)
+ expect((dialog().querySelector('img') as HTMLImageElement).style.width).toBe(`${window.innerWidth - 96}px`)
+ nextImageWidth = 800
+ nextImageHeight = window.innerHeight - 160
+ await key('ArrowRight')
+ expectOriginal(0)
+ await key('ArrowLeft')
+ expectOriginal(2)
+ await key('ArrowLeft')
+ expectOriginal(1)
+ await act(async () => (dialog().querySelector('[aria-label="Next screenshot"]') as HTMLButtonElement).click())
+ expectOriginal(2)
+ await act(async () => (dialog().querySelector('[aria-label="Previous screenshot"]') as HTMLButtonElement).click())
+ expectOriginal(1)
+ await key('Tab')
+ expect(document.activeElement?.getAttribute('aria-label')).toBe('Draw with pen')
+ expect(dialog().querySelector('img')?.className).toContain('rounded-xl')
+ expect(window.electronAPI.shellOpenPath).not.toHaveBeenCalled()
+ expect(dialog().querySelector('[aria-label="Annotate screenshot"]')).not.toBeNull()
+ await act(async () => (dialog().querySelector('[aria-label="Add comment"]') as HTMLButtonElement).click())
+ const annotation = dialog().querySelector('[aria-label="Annotate screenshot"]') as SVGSVGElement
+ annotation.getBoundingClientRect = () => ({ x: 0, y: 0, left: 0, top: 0, right: 800, bottom: 600, width: 800, height: 600, toJSON: () => ({}) })
+ await act(async () => annotation.dispatchEvent(new MouseEvent('click', { clientX: 400, clientY: 300, bubbles: true })))
+ const comment = dialog().querySelector('[aria-label="Comment 1"]') as HTMLTextAreaElement
+ expect(comment).not.toBeNull()
+ const caretKey = new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true, cancelable: true })
+ await act(async () => comment.dispatchEvent(caretKey))
+ expect(caretKey.defaultPrevented).toBe(false)
+ expect(dialog().querySelector('[aria-label="Download screenshot"]')?.getAttribute('href')).toBe('data:image/png;base64,iVBORw==')
+ expect(dialog().querySelector('[aria-label="Download screenshot"]')?.getAttribute('download')).toBe('second.png')
+ await act(async () => (dialog().querySelector('[aria-label="Zoom in"]') as HTMLButtonElement).click())
+ expect(dialog().querySelector('[aria-label="Fit screenshot"]')?.textContent).toBe('120%')
+ await act(async () => (dialog().querySelector('[aria-label="Fit screenshot"]') as HTMLButtonElement).click())
+ expect(dialog().querySelector('[aria-label="Fit screenshot"]')?.textContent).toBe('100%')
+ await key('Escape')
+ expect(dialog()).toBeNull()
+ expect(document.activeElement).toBe(thumbnail)
+ await act(async () => thumbnail.click())
+ await act(async () => (dialog().querySelector('[aria-label="Close screenshot preview"]') as HTMLButtonElement).click())
+ expect(dialog()).toBeNull()
+ await act(async () => thumbnail.dispatchEvent(new Event('dragstart', { bubbles: true, cancelable: true })))
+ await act(async () => thumbnail.click())
+ expect(dialog()).toBeNull()
+ expect(window.electronAPI.dragRecentScreenshot).toHaveBeenCalledWith('second')
+ } finally {
+ act(() => root.unmount())
+ host.remove()
+ window.electronAPI = originalAPI
+ vi.unstubAllGlobals()
+ }
+})
diff --git a/src/renderer/canvas/RecentScreenshotButton.tsx b/src/renderer/canvas/RecentScreenshotButton.tsx
index a74fadf22..eab7f61b7 100644
--- a/src/renderer/canvas/RecentScreenshotButton.tsx
+++ b/src/renderer/canvas/RecentScreenshotButton.tsx
@@ -1,13 +1,176 @@
-import { useEffect, useState } from 'react'
-import { X } from '@phosphor-icons/react'
+import { useEffect, useLayoutEffect, useRef, useState } from 'react'
+import { CaretLeft, CaretRight, DownloadSimple, Minus, PencilSimple, Plus, X } from '@phosphor-icons/react'
import type { RecentScreenshot } from '../../shared/recentScreenshot'
import { Tooltip } from '../ui/Tooltip'
+import { createPortal } from 'react-dom'
+import { ScreenshotDrawing } from './ScreenshotDrawing'
+
+function ScreenshotViewer({ screenshots, initialIndex, onClose, onSaved }: {
+ screenshots: RecentScreenshot[]
+ initialIndex: number
+ onClose: () => void
+ onSaved: (screenshot: RecentScreenshot) => void
+}) {
+ const [index, setIndex] = useState(initialIndex)
+ const [image, setImage] = useState<{ id: string; url: string; width: number; height: number } | null>(null)
+ const [errorId, setErrorId] = useState(null)
+ const [zoom, setZoom] = useState(null)
+ const [viewport, setViewport] = useState({ width: window.innerWidth, height: window.innerHeight })
+ const [drawingToolbarHost, setDrawingToolbarHost] = useState(null)
+ const [direction, setDirection] = useState(1)
+ const scrollRef = useRef(null)
+ useLayoutEffect(() => {
+ if (scrollRef.current) { scrollRef.current.scrollTop = 0; scrollRef.current.scrollLeft = 0 }
+ }, [image?.id])
+ const imageElementRef = useRef(null)
+ const directionRef = useRef(direction)
+ directionRef.current = direction
+ const dialogRef = useRef(null)
+ useEffect(() => {
+ const resize = () => setViewport({ width: window.innerWidth, height: window.innerHeight })
+ window.addEventListener('resize', resize)
+ const previous = document.activeElement
+ dialogRef.current?.focus({ preventScroll: true })
+ return () => {
+ window.removeEventListener('resize', resize)
+ if (previous instanceof HTMLElement && previous.isConnected) previous.focus({ preventScroll: true })
+ }
+ }, [])
+ const screenshot = screenshots[index]
+ useEffect(() => {
+ let active = true
+ setErrorId(null)
+ const readImage = typeof window.electronAPI.readRecentScreenshot === 'function'
+ ? window.electronAPI.readRecentScreenshot(screenshot.id)
+ : window.electronAPI.fsReadBinary(screenshot.filePath).then(bytes => {
+ const data = new Uint8Array(bytes)
+ let binary = ''
+ for (let offset = 0; offset < data.length; offset += 8192) {
+ binary += String.fromCharCode(...data.subarray(offset, offset + 8192))
+ }
+ const extension = screenshot.filePath.split('.').pop()?.toLowerCase()
+ const format = extension === 'jpg' ? 'jpeg' : extension === 'tif' ? 'tiff' : extension
+ return `data:image/${format || 'png'};base64,${btoa(binary)}`
+ })
+ void readImage.then(async url => {
+ if (!active) return
+ const decoded = new Image()
+ decoded.src = url
+ await decoded.decode()
+ if (!active) return
+ const previous = imageElementRef.current
+ if (previous?.animate && !window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
+ const animation = previous.animate([
+ { opacity: 1, transform: 'translateX(0)' },
+ { opacity: 0, transform: `translateX(${-directionRef.current * 28}px)` },
+ ], { duration: 120, easing: 'ease-in', fill: 'forwards' })
+ await animation.finished.catch(() => {})
+ if (!active) { animation.cancel(); return }
+ }
+ setZoom(null)
+ setImage({ id: screenshot.id, url, width: decoded.naturalWidth, height: decoded.naturalHeight })
+ }).catch(() => {
+ if (active) setErrorId(screenshot.id)
+ })
+ return () => {
+ active = false
+ }
+ }, [screenshot.id, screenshot.filePath])
+
+ useEffect(() => {
+ const onKey = (event: KeyboardEvent) => {
+ if (!['ArrowLeft', 'ArrowRight', 'Escape', 'Tab'].includes(event.key)) return
+ const target = event.target
+ const editingText = target instanceof HTMLInputElement
+ || target instanceof HTMLTextAreaElement
+ || (target instanceof HTMLElement && target.isContentEditable)
+ if (editingText && (event.key === 'ArrowLeft' || event.key === 'ArrowRight')) return
+ event.preventDefault()
+ event.stopImmediatePropagation()
+ if (event.key === 'Escape') { onClose(); return }
+ if (event.key === 'Tab') {
+ const buttons = Array.from(dialogRef.current?.querySelectorAll('button:not(:disabled), a[href]') ?? [])
+ const current = buttons.indexOf(document.activeElement as HTMLElement)
+ buttons[(current + (event.shiftKey ? -1 : 1) + buttons.length) % buttons.length]?.focus()
+ return
+ }
+ setDirection(event.key === 'ArrowLeft' ? -1 : 1)
+ setIndex(current => (current + (event.key === 'ArrowLeft' ? -1 : 1) + screenshots.length) % screenshots.length)
+ }
+ document.addEventListener('keydown', onKey, true)
+ return () => document.removeEventListener('keydown', onKey, true)
+ }, [screenshots.length, onClose])
+
+ const displayHeight = Math.max(80, viewport.height - 160)
+ const fit = image ? Math.min(1, Math.max(1, viewport.width - 96) / image.width, displayHeight / image.height) : 1
+ const scale = zoom ?? fit
+ const controlClass = 'flex h-9 w-9 shrink-0 items-center justify-center rounded-full bg-white/10 text-white/90 transition-colors hover:bg-white/20 hover:text-white focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-white disabled:opacity-30'
+
+ return createPortal(
+ event.stopPropagation()} onPointerMove={event => event.stopPropagation()}
+ onMouseDown={event => event.stopPropagation()} onMouseMove={event => event.stopPropagation()}
+ onWheel={event => event.stopPropagation()}>
+
+
+
event.stopPropagation()}>
+ {image?.id === screenshot.id &&
}
+
+
+
+
+
+ {errorId === screenshot.id ? (
+
Could not load the original screenshot.
+ ) : image ? (
+
0 ? 'screenshot-slide-from-right' : 'screenshot-slide-from-left' }}>
+
shot.id === image.id) + 1} of ${screenshots.length}`}
+ onClick={event => event.stopPropagation()} onError={() => setErrorId(screenshot.id)} draggable={false}
+ style={{ width: image.width * scale, height: image.height * scale }}
+ className="block max-w-none rounded-xl shadow-2xl" />
+
onSaved(saved)} />
+
+ ) :
Loading screenshot...
}
+
+
+
+
+
event.stopPropagation()}>
+ { setDirection(-1); setIndex(current => (current - 1 + screenshots.length) % screenshots.length) }}>
+ {index + 1} / {screenshots.length}
+ { setDirection(1); setIndex(current => (current + 1) % screenshots.length) }}>
+
+
event.stopPropagation()}>
+
setZoom(Math.max(0.05, scale / 1.2))}>
+
setZoom(null)}
+ className="min-w-14 rounded-full py-2 text-center text-xs text-white/90 hover:bg-white/10">{Math.round(scale * 100)}%
+
= 4}
+ onClick={() => setZoom(Math.min(4, scale * 1.2))}>
+
+
+
+
,
+ document.body,
+ )
+}
export function RecentScreenshotButton({ expandDown = false }: { expandDown?: boolean }) {
const [screenshots, setScreenshots] = useState([])
const [dismissed, setDismissed] = useState([])
const [expanded, setExpanded] = useState(false)
const [hoveredId, setHoveredId] = useState(null)
+ const [viewer, setViewer] = useState<{ screenshots: RecentScreenshot[]; initialIndex: number } | null>(null)
+ const dragged = useRef(false)
useEffect(() => {
// Renderer hot reload can run against an older preload bridge until restart.
@@ -34,8 +197,9 @@ export function RecentScreenshotButton({ expandDown = false }: { expandDown?: bo
}, [])
const visible = screenshots.filter(screenshot => !dismissed.includes(screenshot.id))
- if (!visible.length) return null
+ if (!visible.length && !viewer) return null
return (
+ <>
-
+
{ dragged.current = false }}
+ onKeyDown={event => {
+ if (event.key === 'Enter' || event.key === ' ') dragged.current = false
+ }}
+ onClick={() => {
+ if (dragged.current) return
+ setViewer({ screenshots: visible, initialIndex: index })
+ }}
onDragStart={event => {
+ dragged.current = true
event.preventDefault()
void window.electronAPI.dragRecentScreenshot(screenshot.id).catch(() => {
// Keep the shortcut available if the native drag could not start.
@@ -68,9 +241,12 @@ export function RecentScreenshotButton({ expandDown = false }: { expandDown?: bo
}}
>
+ className="h-full w-full object-contain" />
+ {screenshot.annotated &&
+
+ }
))}
+ {viewer && setViewer(null)} onSaved={saved => {
+ setScreenshots(current => [saved, ...current.filter(shot => shot.id !== saved.id)].slice(0, 5))
+ setViewer(null)
+ }} />}
+ >
)
}
diff --git a/src/renderer/canvas/ScreenshotDrawing.test.tsx b/src/renderer/canvas/ScreenshotDrawing.test.tsx
new file mode 100644
index 000000000..5ebee5e5a
--- /dev/null
+++ b/src/renderer/canvas/ScreenshotDrawing.test.tsx
@@ -0,0 +1,61 @@
+import React, { act } from 'react'
+import { createRoot } from 'react-dom/client'
+import { afterEach, expect, it, vi } from 'vitest'
+import { ScreenshotDrawing } from './ScreenshotDrawing'
+
+afterEach(() => vi.unstubAllGlobals())
+
+it('adds an editable speech-bubble comment and composites it into the saved PNG', async () => {
+ vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
+ vi.stubGlobal('Image', class { src = ''; decode = async () => {} })
+ const context = {
+ drawImage: vi.fn(), beginPath: vi.fn(), moveTo: vi.fn(), lineTo: vi.fn(), quadraticCurveTo: vi.fn(), closePath: vi.fn(),
+ save: vi.fn(), restore: vi.fn(), translate: vi.fn(), rotate: vi.fn(),
+ fill: vi.fn(), stroke: vi.fn(), measureText: vi.fn(() => ({ width: 40 })), fillText: vi.fn(),
+ set lineCap(_value: string) {}, set lineJoin(_value: string) {}, set strokeStyle(_value: string) {},
+ set fillStyle(_value: string) {}, set lineWidth(_value: number) {}, set font(_value: string) {}, set textBaseline(_value: string) {},
+ }
+ const originalGetContext = HTMLCanvasElement.prototype.getContext
+ const originalToDataURL = HTMLCanvasElement.prototype.toDataURL
+ HTMLCanvasElement.prototype.getContext = vi.fn(() => context) as never
+ HTMLCanvasElement.prototype.toDataURL = vi.fn(() => 'data:image/png;base64,annotated')
+ const originalAPI = window.electronAPI
+ const saved = { id: 'saved', filePath: '/desktop/saved.png', dataUrl: 'data:image/png;base64,thumb', annotated: true }
+ window.electronAPI = { ...originalAPI, saveRecentScreenshot: vi.fn().mockResolvedValue(saved) }
+ const host = document.createElement('div')
+ const toolbar = document.createElement('div')
+ document.body.append(host, toolbar)
+ const root = createRoot(host)
+ const onSaved = vi.fn()
+ try {
+ await act(async () => root.render( ))
+ const svg = host.querySelector('svg')!
+ svg.getBoundingClientRect = () => ({ x: 0, y: 0, left: 0, top: 0, right: 800, bottom: 600, width: 800, height: 600, toJSON: () => ({}) })
+ await act(async () => (toolbar.querySelector('[aria-label="Add comment"]') as HTMLButtonElement).click())
+ await act(async () => svg.dispatchEvent(new MouseEvent('click', { clientX: 400, clientY: 300, bubbles: true })))
+ const input = host.querySelector('[aria-label="Comment 1"]') as HTMLTextAreaElement
+ expect(host.querySelector('[aria-label="Move comment 1"]')).not.toBeNull()
+ expect(host.querySelector('polygon')).toBeNull()
+ expect(host.querySelector('[aria-label="Resize comment 1"] circle')).toBeNull()
+ expect(host.querySelector('[aria-label="Rotate comment 1"] circle')).toBeNull()
+ expect((host.querySelector('[aria-label="Rotate comment 1"]') as SVGGElement).style.cursor).toContain('data:image/svg+xml')
+ expect(host.querySelector('foreignObject > div')?.className).toContain('bg-black/30')
+ await act(async () => {
+ const setter = Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')!.set!
+ setter.call(input, 'Please align this section')
+ input.dispatchEvent(new Event('input', { bubbles: true }))
+ })
+ expect(Number(host.querySelector('foreignObject')?.getAttribute('height'))).toBeLessThan(60)
+ await act(async () => [...toolbar.querySelectorAll('button')].find(button => button.textContent === 'Save')!.click())
+ expect(context.fillText).toHaveBeenCalledWith('Please align this section', expect.any(Number), expect.any(Number), expect.any(Number))
+ expect(window.electronAPI.saveRecentScreenshot).toHaveBeenCalledWith('shot', 'data:image/png;base64,annotated')
+ expect(onSaved).toHaveBeenCalledWith(saved, 'data:image/png;base64,annotated')
+ } finally {
+ await act(async () => root.unmount())
+ host.remove(); toolbar.remove()
+ window.electronAPI = originalAPI
+ HTMLCanvasElement.prototype.getContext = originalGetContext
+ HTMLCanvasElement.prototype.toDataURL = originalToDataURL
+ }
+})
diff --git a/src/renderer/canvas/ScreenshotDrawing.tsx b/src/renderer/canvas/ScreenshotDrawing.tsx
new file mode 100644
index 000000000..971b70e32
--- /dev/null
+++ b/src/renderer/canvas/ScreenshotDrawing.tsx
@@ -0,0 +1,311 @@
+import { useRef, useState } from 'react'
+import type { RecentScreenshot } from '../../shared/recentScreenshot'
+import { createPortal } from 'react-dom'
+import { ArrowCounterClockwise, ChatCircleText, DotsSix, DownloadSimple, PencilSimple, Trash } from '@phosphor-icons/react'
+
+type Point = { x: number; y: number }
+type Stroke = { color: string; width: number; points: Point[] }
+type Callout = Point & { id: string; text: string; width: number; height: number; rotation: number; manuallySized: boolean }
+type Tool = 'pen' | 'comment'
+type ResizeEdges = { left: boolean; right: boolean; top: boolean; bottom: boolean }
+
+const ROTATE_CURSOR = `url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='24' height='24' viewBox='0 0 24 24'%3E%3Cpath d='M18 8a7 7 0 1 0 1 7M18 4v5h-5' fill='none' stroke='white' stroke-width='4' stroke-linecap='round' stroke-linejoin='round'/%3E%3Cpath d='M18 8a7 7 0 1 0 1 7M18 4v5h-5' fill='none' stroke='%23171717' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E") 12 12, crosshair`
+
+function unrotatePoint(value: Point, center: Point, rotation: number): Point {
+ const angle = -rotation * Math.PI / 180
+ const dx = value.x - center.x
+ const dy = value.y - center.y
+ return { x: center.x + dx * Math.cos(angle) - dy * Math.sin(angle), y: center.y + dx * Math.sin(angle) + dy * Math.cos(angle) }
+}
+
+function calloutFontSize(imageHeight: number): number {
+ return Math.max(14, Math.min(24, imageHeight / 36))
+}
+
+function sizeCallout(text: string, imageWidth: number, imageHeight: number): Pick {
+ const fontSize = calloutFontSize(imageHeight)
+ const lineHeight = fontSize * 1.25
+ const maxTextWidth = Math.max(80, Math.min(imageWidth * 0.6, 420 * imageHeight / 600))
+ const lines = text.split('\n')
+ const widths = lines.map(line => Math.max(fontSize * 2, line.length * fontSize * 0.58))
+ const wrappedLines = widths.reduce((total, lineWidth) => total + Math.max(1, Math.ceil(lineWidth / maxTextWidth)), 0)
+ return {
+ width: Math.min(imageWidth, Math.max(112, Math.min(maxTextWidth, Math.max(...widths)) + 70)),
+ height: Math.min(imageHeight, Math.max(44, wrappedLines * lineHeight + 20)),
+ }
+}
+
+function bubblePath(context: CanvasRenderingContext2D, callout: Callout, radius: number): void {
+ const { x, y, width, height } = callout
+ context.beginPath()
+ context.moveTo(x + radius, y)
+ context.lineTo(x + width - radius, y)
+ context.quadraticCurveTo(x + width, y, x + width, y + radius)
+ context.lineTo(x + width, y + height - radius)
+ context.quadraticCurveTo(x + width, y + height, x + width - radius, y + height)
+ context.lineTo(x + radius, y + height)
+ context.quadraticCurveTo(x, y + height, x, y + height - radius)
+ context.lineTo(x, y + radius)
+ context.quadraticCurveTo(x, y, x + radius, y)
+ context.closePath()
+}
+
+function drawCallout(context: CanvasRenderingContext2D, callout: Callout, imageHeight: number): void {
+ const centerX = callout.x + callout.width / 2
+ const centerY = callout.y + callout.height / 2
+ context.save()
+ context.translate(centerX, centerY)
+ context.rotate(callout.rotation * Math.PI / 180)
+ context.translate(-centerX, -centerY)
+ bubblePath(context, callout, Math.min(18, callout.height / 5))
+ context.fillStyle = 'rgba(18,18,18,0.32)'
+ context.fill()
+ context.strokeStyle = 'rgba(255,255,255,0.32)'
+ context.lineWidth = Math.max(1, imageHeight / 900)
+ context.stroke()
+ const fontSize = calloutFontSize(imageHeight)
+ const lineHeight = fontSize * 1.25
+ context.fillStyle = '#ffffff'
+ context.font = `600 ${fontSize}px system-ui, sans-serif`
+ context.textBaseline = 'top'
+ const maxWidth = callout.width - 28
+ const lines: string[] = []
+ for (const paragraph of callout.text.trim().split('\n')) {
+ const paragraphLines: string[] = []
+ for (const word of paragraph.split(/\s+/)) {
+ const candidate = paragraphLines.length ? `${paragraphLines[paragraphLines.length - 1]} ${word}` : word
+ if (paragraphLines.length && context.measureText(candidate).width > maxWidth) paragraphLines.push(word)
+ else if (paragraphLines.length) paragraphLines[paragraphLines.length - 1] = candidate
+ else paragraphLines.push(candidate)
+ }
+ lines.push(...(paragraphLines.length ? paragraphLines : ['']))
+ }
+ lines.slice(0, Math.max(1, Math.floor((callout.height - 24) / lineHeight))).forEach((line, index) => {
+ context.fillText(line, callout.x + 14, callout.y + 12 + index * lineHeight, maxWidth)
+ })
+ context.restore()
+}
+
+export function ScreenshotDrawing({ id, url, width, height, toolbarHost, onClose, onSaved }: {
+ id: string; url: string; width: number; height: number; toolbarHost: HTMLElement | null; onClose: () => void; onSaved: (screenshot: RecentScreenshot, url: string) => void
+}) {
+ const [strokes, setStrokes] = useState([])
+ const [callouts, setCallouts] = useState([])
+ const [tool, setTool] = useState('pen')
+ const [color, setColor] = useState('#ff453a')
+ const [brush, setBrush] = useState(4)
+ const [saving, setSaving] = useState(false)
+ const [message, setMessage] = useState('')
+ const activePointer = useRef(null)
+ const calloutDrag = useRef<{ id: string; pointerId: number; point: Point; x: number; y: number } | null>(null)
+ const calloutResize = useRef<{ id: string; pointerId: number; point: Point; x: number; y: number; width: number; height: number; rotation: number; edges: ResizeEdges } | null>(null)
+ const calloutRotation = useRef<{ id: string; pointerId: number; center: Point; angle: number; rotation: number } | null>(null)
+ const svgRef = useRef(null)
+ const point = (event: { clientX: number; clientY: number }): Point => {
+ const bounds = svgRef.current?.getBoundingClientRect()
+ if (!bounds) return { x: 0, y: 0 }
+ return {
+ x: Math.max(0, Math.min(width, (event.clientX - bounds.left) * width / bounds.width)),
+ y: Math.max(0, Math.min(height, (event.clientY - bounds.top) * height / bounds.height)),
+ }
+ }
+ const save = async () => {
+ setSaving(true)
+ setMessage('')
+ try {
+ const original = new Image()
+ original.src = url
+ await original.decode()
+ const canvas = document.createElement('canvas')
+ canvas.width = width
+ canvas.height = height
+ const context = canvas.getContext('2d')
+ if (!context) throw new Error('Drawing is unavailable.')
+ context.drawImage(original, 0, 0, width, height)
+ context.lineCap = 'round'
+ context.lineJoin = 'round'
+ for (const stroke of strokes) {
+ context.strokeStyle = stroke.color
+ context.lineWidth = stroke.width
+ context.beginPath()
+ context.moveTo(stroke.points[0].x, stroke.points[0].y)
+ for (const p of stroke.points.slice(1)) context.lineTo(p.x, p.y)
+ context.stroke()
+ }
+ for (const callout of callouts) {
+ if (callout.text.trim()) drawCallout(context, callout, height)
+ }
+ const dataUrl = canvas.toDataURL('image/png')
+ if (typeof window.electronAPI.saveRecentScreenshot !== 'function') {
+ setMessage('Restart Cate to enable saving to the screenshot stack. Your drawing is still here.')
+ return
+ }
+ const saved = await window.electronAPI.saveRecentScreenshot(id, dataUrl)
+ onSaved(saved, dataUrl)
+ } catch (error) {
+ console.error('Could not save annotated screenshot', error)
+ setMessage('Could not save. Your drawing is still here; try again.')
+ } finally {
+ setSaving(false)
+ }
+ }
+ const buttonClass = 'flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-full px-3 text-xs font-medium text-white/80 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-30 focus-visible:outline focus-visible:outline-2 focus-visible:outline-white'
+ return <>
+ {
+ event.stopPropagation()
+ if (tool !== 'comment' || saving) return
+ const p = point(event)
+ const { width: bubbleWidth, height: bubbleHeight } = sizeCallout('', width, height)
+ setCallouts(current => [...current, {
+ id: crypto.randomUUID(), text: '', width: bubbleWidth, height: bubbleHeight, rotation: 0, manuallySized: false,
+ x: Math.max(0, Math.min(width - bubbleWidth, p.x - bubbleWidth / 2)),
+ y: Math.max(0, Math.min(height - bubbleHeight, p.y - bubbleHeight / 2)),
+ }])
+ }}
+ onPointerDown={event => {
+ if (tool !== 'pen' || event.button !== 0 || saving || activePointer.current !== null) return
+ event.preventDefault()
+ event.stopPropagation()
+ event.currentTarget.setPointerCapture(event.pointerId)
+ activePointer.current = event.pointerId
+ const p = point(event)
+ setMessage('')
+ setStrokes(current => [...current, { color, width: brush * height / 600, points: [p, { x: p.x + 0.01, y: p.y }] }])
+ }}
+ onPointerMove={event => {
+ if (activePointer.current !== event.pointerId) return
+ const p = point(event)
+ setStrokes(current => current.map((stroke, index) => index === current.length - 1 ? { ...stroke, points: [...stroke.points, p] } : stroke))
+ }}
+ onPointerUp={event => { if (activePointer.current === event.pointerId) activePointer.current = null }}
+ onPointerCancel={() => { activePointer.current = null }}
+ onLostPointerCapture={() => { activePointer.current = null }}>
+
+ {strokes.map((stroke, index) => `${p.x},${p.y}`).join(' ')}
+ fill="none" stroke={stroke.color} strokeWidth={stroke.width} strokeLinecap="round" strokeLinejoin="round" />)}
+ {callouts.map((callout, index) => {
+ const center = { x: callout.x + callout.width / 2, y: callout.y + callout.height / 2 }
+ return
+ event.stopPropagation()}>
+
+ event.stopPropagation()}
+ onPointerDown={event => {
+ event.preventDefault(); event.stopPropagation(); event.currentTarget.setPointerCapture(event.pointerId)
+ calloutDrag.current = { id: callout.id, pointerId: event.pointerId, point: point(event), x: callout.x, y: callout.y }
+ }}
+ onPointerMove={event => {
+ const drag = calloutDrag.current
+ if (!drag || drag.id !== callout.id || drag.pointerId !== event.pointerId) return
+ const current = point(event)
+ setCallouts(values => values.map(value => value.id === callout.id ? {
+ ...value,
+ x: Math.max(0, Math.min(width - value.width, drag.x + current.x - drag.point.x)),
+ y: Math.max(0, Math.min(height - value.height, drag.y + current.y - drag.point.y)),
+ } : value))
+ }}
+ onPointerUp={event => { if (calloutDrag.current?.pointerId === event.pointerId) calloutDrag.current = null }}
+ onPointerCancel={() => { calloutDrag.current = null }}>
+ event.stopPropagation()} onClick={() => setCallouts(current => current.filter(value => value.id !== callout.id))}>
+
+
+ event.stopPropagation()}
+ onPointerDown={event => {
+ event.preventDefault(); event.stopPropagation(); event.currentTarget.setPointerCapture(event.pointerId)
+ const current = point(event)
+ const local = unrotatePoint(current, center, callout.rotation)
+ const horizontal = Math.abs(local.x - callout.x) < Math.abs(local.x - callout.x - callout.width) ? 'left' : 'right'
+ const vertical = Math.abs(local.y - callout.y) < Math.abs(local.y - callout.y - callout.height) ? 'top' : 'bottom'
+ const nearHorizontal = Math.min(Math.abs(local.x - callout.x), Math.abs(local.x - callout.x - callout.width)) <= 14
+ const nearVertical = Math.min(Math.abs(local.y - callout.y), Math.abs(local.y - callout.y - callout.height)) <= 14
+ calloutResize.current = {
+ id: callout.id, pointerId: event.pointerId, point: current, x: callout.x, y: callout.y, width: callout.width, height: callout.height, rotation: callout.rotation,
+ edges: { left: nearHorizontal && horizontal === 'left', right: nearHorizontal && horizontal === 'right', top: nearVertical && vertical === 'top', bottom: nearVertical && vertical === 'bottom' },
+ }
+ }}
+ onPointerMove={event => {
+ const drag = calloutResize.current
+ if (!drag || drag.id !== callout.id || drag.pointerId !== event.pointerId) return
+ const localStart = unrotatePoint(drag.point, center, drag.rotation)
+ const localCurrent = unrotatePoint(point(event), center, drag.rotation)
+ const dx = localCurrent.x - localStart.x
+ const dy = localCurrent.y - localStart.y
+ let x = drag.x; let y = drag.y; let nextWidth = drag.width; let nextHeight = drag.height
+ if (drag.edges.right) nextWidth = Math.max(80, Math.min(width - x, drag.width + dx))
+ if (drag.edges.bottom) nextHeight = Math.max(44, Math.min(height - y, drag.height + dy))
+ if (drag.edges.left) { x = Math.max(0, Math.min(drag.x + drag.width - 80, drag.x + dx)); nextWidth = drag.width + drag.x - x }
+ if (drag.edges.top) { y = Math.max(0, Math.min(drag.y + drag.height - 44, drag.y + dy)); nextHeight = drag.height + drag.y - y }
+ setCallouts(values => values.map(value => value.id === callout.id ? { ...value, x, y, width: nextWidth, height: nextHeight, manuallySized: true } : value))
+ }}
+ onPointerUp={event => { if (calloutResize.current?.pointerId === event.pointerId) calloutResize.current = null }}
+ onPointerCancel={() => { calloutResize.current = null }}>
+
+
+ event.stopPropagation()}
+ onPointerDown={event => {
+ event.preventDefault(); event.stopPropagation(); event.currentTarget.setPointerCapture(event.pointerId)
+ const current = point(event)
+ calloutRotation.current = { id: callout.id, pointerId: event.pointerId, center, angle: Math.atan2(current.y - center.y, current.x - center.x), rotation: callout.rotation }
+ }}
+ onPointerMove={event => {
+ const drag = calloutRotation.current
+ if (!drag || drag.id !== callout.id || drag.pointerId !== event.pointerId) return
+ const current = point(event)
+ const angle = Math.atan2(current.y - drag.center.y, current.x - drag.center.x)
+ setCallouts(values => values.map(value => value.id === callout.id ? { ...value, rotation: drag.rotation + (angle - drag.angle) * 180 / Math.PI } : value))
+ }}
+ onPointerUp={event => { if (calloutRotation.current?.pointerId === event.pointerId) calloutRotation.current = null }}
+ onPointerCancel={() => { calloutRotation.current = null }}>
+
+
+
+ })}
+
+ {toolbarHost && createPortal(
+ event.stopPropagation()} onPointerDown={event => event.stopPropagation()}>
+
setTool('pen')}
+ className={`flex h-9 w-9 items-center justify-center rounded-full ${tool === 'pen' ? 'bg-white text-neutral-900' : 'bg-white/10 text-white/70 hover:bg-white/20 hover:text-white'}`}>
+
setTool('comment')}
+ className={`flex h-9 w-9 items-center justify-center rounded-full ${tool === 'comment' ? 'bg-white text-neutral-900' : 'bg-white/10 text-white/70 hover:bg-white/20 hover:text-white'}`}>
+
+ {['#ff453a', '#ffcc00', '#30d158', '#0a84ff', '#ffffff'].map(value => setColor(value)}
+ className={`h-5 w-5 rounded-full border border-white/20 transition-shadow ${color === value ? 'ring-2 ring-white ring-offset-2 ring-offset-neutral-800' : 'hover:ring-2 hover:ring-white/40'}`}
+ style={{ backgroundColor: value }} />)}
+
+
+
+ {[2, 4, 8].map(value => setBrush(value)}> )}
+
+
setStrokes(current => current.slice(0, -1))}>
+
+
Cancel
+
callout.text.trim())) || saving} onClick={() => { void save() }}> {saving ? 'Saving...' : 'Save'}
+ {message &&
{message} }
+
, toolbarHost)}
+ >
+}
diff --git a/src/renderer/canvas/useCanvasNodeStyle.ts b/src/renderer/canvas/useCanvasNodeStyle.ts
index 4a4c42933..ea56ee7c0 100644
--- a/src/renderer/canvas/useCanvasNodeStyle.ts
+++ b/src/renderer/canvas/useCanvasNodeStyle.ts
@@ -45,7 +45,6 @@ interface StyleArgs {
isFocused: boolean
isSelected: boolean
activityState: NodeActivityState | undefined
- isAnimatingLayout: boolean
isHovered: boolean
chromeTint: { background: string; accent: string } | null
isWholeNodeDragSource: boolean
@@ -64,7 +63,6 @@ export function useCanvasNodeStyle(args: StyleArgs) {
isFocused,
isSelected,
activityState,
- isAnimatingLayout,
isHovered,
chromeTint,
isWholeNodeDragSource,
@@ -82,11 +80,9 @@ export function useCanvasNodeStyle(args: StyleArgs) {
const baseTransition =
'border-color 150ms ease, box-shadow 200ms ease, outline-color 200ms ease, transform 200ms cubic-bezier(0.34, 1.56, 0.64, 1), opacity 150ms ease-out, filter 200ms ease'
- const layoutTransition = isAnimatingLayout
- ? ', left 250ms cubic-bezier(0.16, 1, 0.3, 1), top 250ms cubic-bezier(0.16, 1, 0.3, 1), width 250ms cubic-bezier(0.16, 1, 0.3, 1), height 250ms cubic-bezier(0.16, 1, 0.3, 1)'
- : ''
- const baseOpacity = isEntering ? 0 : isExiting ? 0 : isWholeNodeDragSource ? 0 : 1
+
+ const baseOpacity = isEntering ? 0 : isExiting ? 0 : 1
// Focus lens: nodes outside the focused worktree recede.
const opacity = worktreeDim ? baseOpacity * 0.5 : baseOpacity
@@ -112,27 +108,28 @@ export function useCanvasNodeStyle(args: StyleArgs) {
? `color-mix(in srgb, ${chromeTint.background} 86%, white 14%)`
: 'var(--surface-3)',
['--node-chrome-accent' as any]: chromeTint?.accent ?? 'var(--focus-blue)',
- transition: baseTransition + layoutTransition,
+ transition: baseTransition,
filter: worktreeDim ? 'saturate(0.4)' : undefined,
transform: isEntering ? 'scale(0.85)' : isExiting ? 'scale(0.9)' : 'scale(1)',
opacity,
+ // Electron webviews are separate guest surfaces and can remain painted
+ // through an opacity:0 ancestor. visibility reliably suppresses the guest
+ // whenever this node must fall back to the generic drag ghost.
+ visibility: isWholeNodeDragSource ? 'hidden' : undefined,
pointerEvents: isExiting || isWholeNodeDragSource ? 'none' : undefined,
userSelect: 'none',
}
- }, [node, isFocused, isSelected, activityState, isAnimatingLayout, isHovered, chromeTint, isWholeNodeDragSource, worktreeDim])
+ }, [node, isFocused, isSelected, activityState, isHovered, chromeTint, isWholeNodeDragSource, worktreeDim])
const glowStyle = useMemo(() => {
if (!node) return null
if (!(isFocused || isSelected || worktreeHighlight)) return null
// Hide the focus glow while the node is the drag source — the source node
- // itself is hidden (containerStyle.opacity = 0 above) and the glow would
- // otherwise float at the node's original origin while the ghost moves.
+ // itself is hidden and the glow would otherwise overlap the ghost.
if (isWholeNodeDragSource) return null
const isEntering = node.animationState === 'entering'
const isExiting = node.animationState === 'exiting'
- const layoutTransition = isAnimatingLayout
- ? 'left 250ms cubic-bezier(0.16, 1, 0.3, 1), top 250ms cubic-bezier(0.16, 1, 0.3, 1), width 250ms cubic-bezier(0.16, 1, 0.3, 1), height 250ms cubic-bezier(0.16, 1, 0.3, 1), '
- : ''
+
return {
position: 'absolute',
left: node.origin.x,
@@ -157,9 +154,9 @@ export function useCanvasNodeStyle(args: StyleArgs) {
pointerEvents: 'none',
transform: isEntering ? 'scale(0.85)' : isExiting ? 'scale(0.9)' : 'scale(1)',
opacity: isEntering || isExiting ? 0 : 1,
- transition: `${layoutTransition}transform 200ms cubic-bezier(0.34, 1.56, 0.64, 1), opacity 150ms ease-out, box-shadow 200ms ease`,
+ transition: `transform 200ms cubic-bezier(0.34, 1.56, 0.64, 1), opacity 150ms ease-out, box-shadow 200ms ease`,
}
- }, [node, isFocused, isSelected, isAnimatingLayout, isWholeNodeDragSource, worktreeHighlight, worktreeColor])
+ }, [node, isFocused, isSelected, isWholeNodeDragSource, worktreeHighlight, worktreeColor])
return { containerStyle, glowStyle }
}
diff --git a/src/renderer/dialogs/SkillsDialog.tsx b/src/renderer/dialogs/SkillsDialog.tsx
index db0f9b9ae..78b63f71b 100644
--- a/src/renderer/dialogs/SkillsDialog.tsx
+++ b/src/renderer/dialogs/SkillsDialog.tsx
@@ -34,6 +34,7 @@ import { LoadingState, Spinner } from '../ui/Spinner'
import { PaletteTextInput } from '../ui/PaletteTextInput'
import { InlineNotice } from '../ui/InlineNotice'
import { IconButton } from '../ui/Button'
+import { POPOVER_SURFACE } from '../ui/Popover'
import {
SKILL_TARGETS,
type InstalledSkill,
@@ -44,7 +45,7 @@ import {
const api = () => window.electronAPI
-// The list of repos the curated catalog is crawled from. Linked at the bottom so
+// The list of repos the curated catalog is crawled from. Linked in the header so
// anyone can PR a missing skill's source repo in (the CI crawler turns this into
// skills-index.json).
const SKILL_SOURCES_URL = 'https://github.com/0-AI-UG/cate/blob/main/registry/sources.json'
@@ -263,6 +264,15 @@ export function SkillsDialog() {
{!contentSlot && Back }
+ window.electronAPI?.openExternalUrl(SKILL_SOURCES_URL)}
+ className="inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-xs text-secondary hover:bg-hover hover:text-primary"
+ title="Suggest a missing skill for the catalog"
+ >
+ Add a skill source
+
+
@@ -384,19 +394,6 @@ export function SkillsDialog() {
{visibleBrowseRows.map((e) => renderRow(e, false))}
)}
-
- {/* Pinned footer — PR a missing skill into the curated index. Stays put
- below the (possibly very long) scrolling list so it's always seen. */}
-
- Missing a skill?{' '}
-
window.electronAPI?.openExternalUrl(SKILL_SOURCES_URL)}
- className="inline-flex items-center gap-0.5 text-secondary hover:text-primary underline decoration-dotted underline-offset-2"
- >
- Add its source
-
-
-
,
contentSlot ?? document.body,
@@ -643,11 +640,11 @@ function AgentMenu({
return createPortal(
e.stopPropagation()}
>
-
Install for
+
Install for
{SKILL_TARGETS.map((t) => {
const on = installedKeys.has(`${entry.id}:${t.id}`)
const working = busy === t.id
@@ -656,7 +653,7 @@ function AgentMenu({
key={t.id}
onClick={() => void toggle(t.id)}
disabled={working}
- className="w-full flex items-center gap-2 px-2.5 py-1.5 text-left text-secondary hover:bg-surface-4 hover:text-primary disabled:opacity-50"
+ className="w-full flex items-center gap-2 rounded-lg px-2.5 py-1.5 text-left text-primary hover:bg-hover focus-visible:bg-hover disabled:opacity-50"
title={on ? 'Uninstall skill' : 'Install skill'}
>
diff --git a/src/renderer/docking/DockLayoutRenderer.tsx b/src/renderer/docking/DockLayoutRenderer.tsx
index f87f328c4..42ab5fb71 100644
--- a/src/renderer/docking/DockLayoutRenderer.tsx
+++ b/src/renderer/docking/DockLayoutRenderer.tsx
@@ -1,7 +1,5 @@
import React from 'react'
import type { DockLayoutNode, DockTabStack as DockTabStackNode, PanelType } from '../../shared/types'
-import { useDockStoreContext } from '../stores/DockStoreContext'
-import { findTabStack } from '../stores/dockTreeUtils'
import { layoutMinimum } from './splitSizing'
import DockSplitContainer from './DockSplitContainer'
@@ -13,9 +11,7 @@ interface DockLayoutRendererProps {
/** Shared recursive renderer for window docks and canvas-node mini-docks. */
export default function DockLayoutRenderer({ layout, renderTabs, getPanelType }: DockLayoutRendererProps) {
- const maximizedId = useDockStoreContext((s) => s.maximizedStackId)
- const maximized = maximizedId ? findTabStack(layout, maximizedId) : null
- const minimum = layoutMinimum(maximized ?? layout, getPanelType)
+ const minimum = layoutMinimum(layout, getPanelType)
const renderNode = (node: DockLayoutNode, isRoot: boolean): React.ReactNode => {
if (node.type === 'tabs') return renderTabs(node, isRoot)
return (
diff --git a/src/renderer/docking/DockResizeHandle.tsx b/src/renderer/docking/DockResizeHandle.tsx
index 6ca2c8555..f138f0b08 100644
--- a/src/renderer/docking/DockResizeHandle.tsx
+++ b/src/renderer/docking/DockResizeHandle.tsx
@@ -4,6 +4,7 @@
import React, { useCallback, useRef, useEffect } from 'react'
import { pinDocumentCursor } from '../lib/dom/pinDocumentCursor'
+import { SPLIT_DIVIDER_SIZE } from './splitSizing'
interface DockResizeHandleProps {
direction: 'horizontal' | 'vertical' // horizontal = left/right drag, vertical = up/down drag
@@ -101,18 +102,17 @@ export default function DockResizeHandle({ direction, onResize, onDoubleClick }:
-
- {/* Visible indicator on hover */}
+
+ {/* Fill the layout width so pane content meets the divider. */}
diff --git a/src/renderer/docking/DockSplitContainer.drag.test.tsx b/src/renderer/docking/DockSplitContainer.drag.test.tsx
index e23fbc316..f4d1e9a0b 100644
--- a/src/renderer/docking/DockSplitContainer.drag.test.tsx
+++ b/src/renderer/docking/DockSplitContainer.drag.test.tsx
@@ -17,10 +17,10 @@ it('starts resizing at the visible divider when saved ratios were constrained by
Object.defineProperty(host.firstElementChild!, 'offsetWidth', { value: 1000 })
const panes = host.querySelectorAll('[data-dock-pane]')
Object.defineProperty(panes[0], 'offsetWidth', { value: 320 })
- Object.defineProperty(panes[1], 'offsetWidth', { value: 675 })
+ Object.defineProperty(panes[1], 'offsetWidth', { value: 679 })
act(() => host.querySelector('.cursor-col-resize')!.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: 320 })))
act(() => document.dispatchEvent(new MouseEvent('mousemove', { clientX: 370 })))
- expect((store.getState().zones.center.layout as DockSplitNode).ratios[0]).toBeCloseTo(370 / 995)
+ expect((store.getState().zones.center.layout as DockSplitNode).ratios[0]).toBeCloseTo(370 / 999)
} finally {
act(() => document.dispatchEvent(new MouseEvent('mouseup')))
act(() => root.unmount())
@@ -47,9 +47,9 @@ it('can drag away from the minimum and reverse direction during the same gesture
const handle = host.querySelector('.cursor-col-resize')!
act(() => handle.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, clientX: 400 })))
act(() => document.dispatchEvent(new MouseEvent('mousemove', { clientX: 500 })))
- expect((store.getState().zones.center.layout as DockSplitNode).ratios[0]).toBeCloseTo(0.4 + 100 / 995)
+ expect((store.getState().zones.center.layout as DockSplitNode).ratios[0]).toBeCloseTo(0.4 + 100 / 999)
act(() => document.dispatchEvent(new MouseEvent('mousemove', { clientX: 450 })))
- expect((store.getState().zones.center.layout as DockSplitNode).ratios[0]).toBeCloseTo(0.4 + 50 / 995)
+ expect((store.getState().zones.center.layout as DockSplitNode).ratios[0]).toBeCloseTo(0.4 + 50 / 999)
} finally {
act(() => document.dispatchEvent(new MouseEvent('mouseup')))
act(() => root.unmount())
diff --git a/src/renderer/docking/DockSplitContainer.test.ts b/src/renderer/docking/DockSplitContainer.test.ts
index b59a79315..56f91058e 100644
--- a/src/renderer/docking/DockSplitContainer.test.ts
+++ b/src/renderer/docking/DockSplitContainer.test.ts
@@ -22,15 +22,15 @@ describe('clampSplitDelta', () => {
{ type: 'tabs', id: 'editor-stack', panelIds: ['editor'], activeIndex: 0 },
] }
expect(clampSplitDelta(split, 0, -0.15, 1000, panelType)).toBeCloseTo(-0.15)
- expect(clampSplitDelta(split, 0, -0.4, 1000, panelType)).toBeCloseTo(320 / 995 - 0.5)
+ expect(clampSplitDelta(split, 0, -0.4, 1000, panelType)).toBeCloseTo(320 / 999 - 0.5)
})
it('keeps a canvas at least its minimum wide in a horizontal split', () => {
- expect(clampSplitDelta(horizontalSplit, 0, -0.4, 2000, panelType)).toBeCloseTo(PANEL_MINIMUM_SIZES.canvas.width / 1995 - 0.5)
+ expect(clampSplitDelta(horizontalSplit, 0, -0.4, 2000, panelType)).toBeCloseTo(PANEL_MINIMUM_SIZES.canvas.width / 1999 - 0.5)
})
it('keeps a canvas at least its minimum tall in a vertical split', () => {
const verticalSplit = { ...horizontalSplit, direction: 'vertical' as const }
- expect(clampSplitDelta(verticalSplit, 0, -0.4, 2000, panelType)).toBeCloseTo(PANEL_MINIMUM_SIZES.canvas.height / 1995 - 0.5)
+ expect(clampSplitDelta(verticalSplit, 0, -0.4, 2000, panelType)).toBeCloseTo(PANEL_MINIMUM_SIZES.canvas.height / 1999 - 0.5)
})
it('does not shrink panes further when their minimums cannot fit', () => {
diff --git a/src/renderer/docking/DockSplitContainer.tsx b/src/renderer/docking/DockSplitContainer.tsx
index db6524ca9..f088e01d6 100644
--- a/src/renderer/docking/DockSplitContainer.tsx
+++ b/src/renderer/docking/DockSplitContainer.tsx
@@ -6,7 +6,6 @@
import React, { useCallback, useRef } from 'react'
import { useDockStoreContext } from '../stores/DockStoreContext'
import { type DockLayoutNode, type DockSplitNode, type PanelType } from '../../shared/types'
-import { findTabStack } from '../stores/dockTreeUtils'
import { layoutMinimum, SPLIT_DIVIDER_SIZE } from './splitSizing'
import DockResizeHandle from './DockResizeHandle'
@@ -42,8 +41,6 @@ export default function DockSplitContainer({
getPanelType,
}: DockSplitContainerProps) {
const setSplitRatio = useDockStoreContext((s) => s.setSplitRatio)
- const maximizedStackId = useDockStoreContext((s) => s.maximizedStackId)
- const containsMaximized = !!maximizedStackId && !!findTabStack(node, maximizedStackId)
const isHorizontal = node.direction === 'horizontal'
const containerRef = useRef(null)
@@ -98,16 +95,15 @@ export default function DockSplitContainer({
{renderNode(child)}
- {!containsMaximized && i < node.children.length - 1 && (
+ {i < node.children.length - 1 && (
handleResize(i, delta)}
diff --git a/src/renderer/docking/DockSplitMaximize.test.tsx b/src/renderer/docking/DockSplitMaximize.test.tsx
deleted file mode 100644
index 3ce564da2..000000000
--- a/src/renderer/docking/DockSplitMaximize.test.tsx
+++ /dev/null
@@ -1,47 +0,0 @@
-import React, { act, useEffect } from 'react'
-import { createRoot } from 'react-dom/client'
-import { expect, it, vi } from 'vitest'
-import { createDockStore } from '../stores/dockStore'
-import { DockStoreProvider } from '../stores/DockStoreContext'
-import DockLayoutRenderer from './DockLayoutRenderer'
-import type { DockLayoutNode } from '../../shared/types'
-
-it('maximizes nested panes without remounting or changing ratios and restores the layout', () => {
- const tabs = (id: string): DockLayoutNode => ({ id, type: 'tabs', panelIds: [id], activeIndex: 0 })
- const layout: DockLayoutNode = { id: 'outer', type: 'split', direction: 'horizontal', ratios: [0.3, 0.7], children: [tabs('a'), {
- id: 'inner', type: 'split', direction: 'vertical', ratios: [0.4, 0.6], children: [tabs('b'), tabs('c')],
- }] }
- const store = createDockStore()
- const mounted = vi.fn()
- const unmounted = vi.fn()
- function Panel({ id }: { id: string }) {
- useEffect(() => { mounted(id); return () => unmounted(id) }, [id])
- return
- }
- const host = document.createElement('div')
- const root = createRoot(host)
- try {
- act(() => root.render( } /> ))
- const a = host.querySelector('[data-panel="a"]')!.parentElement!
- const b = host.querySelector('[data-panel="b"]')!.parentElement!
- const c = host.querySelector('[data-panel="c"]')!.parentElement!
- act(() => store.getState().toggleStackMaximized('c'))
- expect(a.style.display).toBe('none')
- expect(b.style.display).toBe('none')
- expect(c.style.height).toBe('100%')
- expect(host.querySelector('.cursor-col-resize')).toBeNull()
- expect(mounted).toHaveBeenCalledTimes(3)
- expect(unmounted).not.toHaveBeenCalled()
- act(() => store.getState().toggleStackMaximized('c'))
- expect(a.style.display).toBe('')
- expect(a.style.width).toContain('0.3')
- expect(b.style.height).toContain('0.4')
- expect(c.style.height).toContain('0.6')
- expect(mounted).toHaveBeenCalledTimes(3)
- act(() => store.getState().toggleStackMaximized('missing'))
- expect(a.style.display).toBe('')
- expect(b.style.display).toBe('')
- store.getState().restoreSnapshot(store.getState().getSnapshot())
- expect(store.getState().maximizedStackId).toBeNull()
- } finally { act(() => root.unmount()) }
-})
diff --git a/src/renderer/docking/DockTabContextMenu.tsx b/src/renderer/docking/DockTabContextMenu.tsx
index eb26bb33c..d2641ebde 100644
--- a/src/renderer/docking/DockTabContextMenu.tsx
+++ b/src/renderer/docking/DockTabContextMenu.tsx
@@ -5,6 +5,7 @@ import { createPortal } from 'react-dom'
import type { PanelType } from '../../shared/types'
import { SPLIT_MENU_PANEL_TYPES } from '../../shared/panels'
import { PANEL_REGISTRY } from '../panels/registry'
+import { POPOVER_SURFACE } from '../ui/Popover'
export type SplitMenuItem = { type: PanelType; label: string; Icon: React.ComponentType }
@@ -49,7 +50,7 @@ export function DockTabContextMenu({ open, position, items, onPick, onClose, anc
ref={menuRef}
role="menu"
aria-label="New Tab"
- className="dock-new-tab-menu pointer-events-auto z-[1000] w-[220px] max-w-[calc(100vw-16px)] overflow-y-auto rounded-2xl border border-subtle bg-surface-3 shadow-lg p-1.5 text-[13px]"
+ className={`dock-new-tab-menu pointer-events-auto z-[1000] w-[220px] max-w-[calc(100vw-16px)] overflow-y-auto ${POPOVER_SURFACE} p-1.5 text-[13px]`}
onKeyDown={(event) => {
if (event.key === 'Escape' || event.key === 'Tab') { onClose(); event.stopPropagation(); return }
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return
diff --git a/src/renderer/docking/DockTabStack.tsx b/src/renderer/docking/DockTabStack.tsx
index 1e2639850..20f224bc7 100644
--- a/src/renderer/docking/DockTabStack.tsx
+++ b/src/renderer/docking/DockTabStack.tsx
@@ -52,16 +52,30 @@ interface DockTabStackProps {
/** When true, this stack's drop-zone returns a null rect so it can't be
* hit-tested as a target. */
dropDisabled?: boolean
+ /** Canvas-node stacks promote their active panel into the canvas's owning
+ * dock instead of merging their private mini-dock. */
+ onPresentPanel?: (panelId: string) => void
}
-export default function DockTabStack({ stack, zone: zoneProp, renderPanel, getPanelTitle, onClosePanel, onClosePanels, getPanel: getPanelProp, workspaceId: workspaceIdProp, onPanelRemoved, onPanelRenamed, excludePanelTypes, trailingControls, newTabControl, onTabBarMouseDown, localOnly, compact, dropDisabled }: DockTabStackProps) {
+export default function DockTabStack({ stack, zone: zoneProp, renderPanel, getPanelTitle, onClosePanel, onClosePanels, getPanel: getPanelProp, workspaceId: workspaceIdProp, onPanelRemoved, onPanelRenamed, excludePanelTypes, trailingControls, newTabControl, onTabBarMouseDown, localOnly, compact, dropDisabled, onPresentPanel }: DockTabStackProps) {
const dockStoreApi = useDockStoreApi()
- const canMaximize = useDockStoreContext((s) => Object.values(s.zones).some((zone) =>
- zone.visible && zone.layout && (zone.layout.type === 'split' || zone.layout.id !== stack.id),
- ))
- const maximized = useDockStoreContext((s) => s.maximizedStackId === stack.id)
+ const presentation = useDockStoreContext((s) => s.presentation)
+ const zoneLayout = useDockStoreContext((s) => s.zones[zoneProp].layout)
+ const activePanelId = stack.panelIds[stack.activeIndex]
+ const ownsPresentation = presentation?.stackId === stack.id
+ const canRestore = !!ownsPresentation && dockStoreApi.getState().canRestorePresentation(stack.id)
+ const presented = ownsPresentation
+ && (!presentation.panelId || presentation.panelId === activePanelId)
+ const canMerge = !presentation && zoneLayout?.type === 'split'
const stackRef = useRef(null)
+ // A structural mutation makes the saved reverse operation unsafe. Drop the
+ // transaction immediately so it cannot leave dead controls or block a later
+ // presentation; the already-mutated real dock tree remains authoritative.
+ useEffect(() => {
+ if (ownsPresentation && !canRestore) dockStoreApi.getState().discardPresentation()
+ }, [ownsPresentation, canRestore, dockStoreApi])
+
const isDragging = useDragStore((s) => s.isDragging)
const target = useDragStore((s) => s.target)
const dragSource = useDragStore((s) => s.source)
@@ -86,8 +100,6 @@ export default function DockTabStack({ stack, zone: zoneProp, renderPanel, getPa
})
}, [stack.id, zoneProp, dockStoreApi, acceptsPanelType])
- const activePanelId = stack.panelIds[stack.activeIndex]
-
// Effective workspace for status lookups: explicit prop, else the selected
// workspace (matches resolvePanel's fallback). Subscribed so a workspace
// switch re-scopes the tab agent indicators.
@@ -115,7 +127,7 @@ export default function DockTabStack({ stack, zone: zoneProp, renderPanel, getPa
const viewport = element.closest('[data-dock-viewport]')
const dock = dockStoreApi.getState()
const layout = dock.zones[zoneProp].layout
- if (viewport && layout && !dock.maximizedStackId) {
+ if (viewport && layout) {
return canSplitLayout(layout, stack.id, viewport.clientWidth, viewport.clientHeight, (id) => resolvePanel(id)?.type)
}
return canSplitPane(Math.min(element.clientWidth, viewport?.clientWidth ?? element.clientWidth),
@@ -325,16 +337,24 @@ export default function DockTabStack({ stack, zone: zoneProp, renderPanel, getPa
)}
- {canMaximize &&
+ {(presented || canMerge || !!onPresentPanel) &&
event.stopPropagation()}
- onClick={() => dockStoreApi.getState().toggleStackMaximized(stack.id)}
+ onClick={() => {
+ if (presented) dockStoreApi.getState().restorePresentation(stack.id)
+ else if (onPresentPanel && activePanelId) onPresentPanel(activePanelId)
+ else dockStoreApi.getState().mergeSplitToStack(stack.id)
+ }}
>
- {maximized ? : }
+ {presented ? : }
}
diff --git a/src/renderer/docking/SurfacePanels.test.tsx b/src/renderer/docking/SurfacePanels.test.tsx
index cb6d6501a..034635e33 100644
--- a/src/renderer/docking/SurfacePanels.test.tsx
+++ b/src/renderer/docking/SurfacePanels.test.tsx
@@ -178,31 +178,51 @@ it('refreshes canvas chrome when panel records settle without a layout change',
expect(host.querySelector('.dock-tab-bar')?.classList.contains('border-b')).toBe(false)
})
-it('offers maximize and restore in each split header', () => {
+it('merges splits into tabs and restores the previous layout', () => {
act(() => root.render( ))
- expect(host.querySelector('[aria-label="Maximize split"]')).toBeNull()
+ expect(host.querySelector('[aria-label="Merge splits into tabs"]')).toBeNull()
act(() => (host.querySelector('[aria-label="Split Right"]') as HTMLButtonElement).click())
- const buttons = host.querySelectorAll('[aria-label="Maximize split"]')
+ const buttons = host.querySelectorAll('[aria-label="Merge splits into tabs"]')
expect(buttons).toHaveLength(2)
const layout = dock.getState().zones.center.layout
act(() => buttons[1].click())
- const restore = host.querySelector('[aria-label="Restore split"]')!
+ const restore = host.querySelector('[aria-label="Restore previous layout"]')!
expect(restore.getAttribute('aria-pressed')).toBe('true')
- expect(dock.getState().zones.center.layout).toBe(layout)
+ expect(dock.getState().zones.center.layout?.type).toBe('tabs')
act(() => restore.click())
- expect(dock.getState().maximizedStackId).toBeNull()
- expect(host.querySelectorAll('[aria-label="Maximize split"]')).toHaveLength(2)
+ expect(dock.getState().zones.center.layout).toBe(layout)
+ expect(host.querySelectorAll('[aria-label="Merge splits into tabs"]')).toHaveLength(2)
})
+it('shows canvas-promotion restore only while the promoted panel is active', () => {
+ const restoreLayout = dock.getState().zones.center.layout!
+ useAppStore.getState().addPanel('test', { id: 'promoted', type: 'editor', title: 'Promoted', isDirty: false })
+ dock.getState().dockPanel('promoted', 'center', { type: 'tab', stackId })
+ const expectedLayout = dock.getState().zones.center.layout!
+ dock.getState().beginPresentation({
+ stackId,
+ panelId: 'promoted',
+ zone: 'center',
+ restoreLayout,
+ expectedLayout,
+ })
+ act(() => root.render( ))
+ expect(host.querySelector('[aria-label="Restore previous layout"]')).not.toBeNull()
+
+ act(() => dock.getState().setActiveTab(stackId, 0))
+ expect(host.querySelector('[aria-label="Restore previous layout"]')).toBeNull()
+
+ act(() => dock.getState().setActiveTab(stackId, 1))
+ expect(host.querySelector('[aria-label="Restore previous layout"]')).not.toBeNull()
+})
-it('splitting a maximized pane reveals the new pane', () => {
+it('discards restore after changing the merged layout', () => {
act(() => root.render( ))
act(() => (host.querySelector('[aria-label="Split Right"]') as HTMLButtonElement).click())
- act(() => (host.querySelector('[aria-label="Maximize split"]') as HTMLButtonElement).click())
- expect(dock.getState().maximizedStackId).not.toBeNull()
+ act(() => (host.querySelector('[aria-label="Merge splits into tabs"]') as HTMLButtonElement).click())
act(() => (host.querySelector('[aria-label="Split Right"]') as HTMLButtonElement).click())
- expect(dock.getState().maximizedStackId).toBeNull()
- expect(host.querySelectorAll('[aria-label="Maximize split"]')).toHaveLength(3)
+ expect(dock.getState().presentation).toBeNull()
+ expect(host.querySelector('[aria-label="Restore previous layout"]')).toBeNull()
})
diff --git a/src/renderer/docking/splitSizing.test.ts b/src/renderer/docking/splitSizing.test.ts
index 11a0e5805..35a2d6ebb 100644
--- a/src/renderer/docking/splitSizing.test.ts
+++ b/src/renderer/docking/splitSizing.test.ts
@@ -6,18 +6,18 @@ const pane = (id: string): DockLayoutNode => ({ id, type: 'tabs', panelIds: [id]
it('allows a diff panel in a standard narrow dock without expanding its layout', () => {
expect(layoutMinimum(pane('review'), () => 'review')).toEqual({ width: 320, height: 220 })
expect(layoutMinimum({ id: 'row', type: 'split', direction: 'horizontal', ratios: [0.5, 0.5], children: [pane('review'), pane('terminal')] },
- id => id as 'review' | 'terminal')).toEqual({ width: 645, height: 220 })
+ id => id as 'review' | 'terminal')).toEqual({ width: 641, height: 220 })
})
it('requires enough room for two usable panes and the divider', () => {
- expect(canSplitPane(644, 400)).toBe(false)
- expect(canSplitPane(645, 220)).toBe(true)
+ expect(canSplitPane(640, 400)).toBe(false)
+ expect(canSplitPane(641, 220)).toBe(true)
expect(canSplitPane(1000, 219)).toBe(false)
})
it('uses physical pane minimums without inflating the dock to preserve uneven ratios', () => {
const layout: DockLayoutNode = { id: 'row', type: 'split', direction: 'horizontal', ratios: [0.25, 0.75], children: [pane('a'), {
id: 'column', type: 'split', direction: 'vertical', ratios: [0.5, 0.5], children: [pane('b'), pane('c')],
}] }
- expect(layoutMinimum(layout)).toEqual({ width: 645, height: 445 })
+ expect(layoutMinimum(layout)).toEqual({ width: 641, height: 441 })
expect(layout.ratios).toEqual([0.25, 0.75])
})
@@ -25,13 +25,13 @@ it('permits a third column based on the full row instead of requiring half the r
const layout: DockLayoutNode = { id: 'row', type: 'split', direction: 'horizontal', ratios: [0.5, 0.5], children: [pane('a'), pane('b')] }
expect(canSplitPane(500, 400)).toBe(false)
expect(canSplitLayout(layout, 'b', 1000, 400)).toBe(true)
- expect(canSplitLayout(layout, 'b', 969, 400)).toBe(false)
- expect(canSplitLayout(layout, 'b', 970, 400)).toBe(true)
+ expect(canSplitLayout(layout, 'b', 961, 400)).toBe(false)
+ expect(canSplitLayout(layout, 'b', 962, 400)).toBe(true)
expect(canSplitLayout(layout, 'missing', 1000, 400)).toBe(false)
})
it('keeps restored half/quarter/quarter layouts within the real three-pane minimum', () => {
- expect(layoutMinimum({ id: 'row', type: 'split', direction: 'horizontal', ratios: [0.5, 0.25, 0.25], children: [pane('a'), pane('b'), pane('c')] })).toEqual({ width: 970, height: 220 })
+ expect(layoutMinimum({ id: 'row', type: 'split', direction: 'horizontal', ratios: [0.5, 0.25, 0.25], children: [pane('a'), pane('b'), pane('c')] })).toEqual({ width: 962, height: 220 })
})
it('honors every panel type minimum, including inactive tabs in a mixed stack', async () => {
diff --git a/src/renderer/docking/splitSizing.ts b/src/renderer/docking/splitSizing.ts
index c08101a07..8f50264da 100644
--- a/src/renderer/docking/splitSizing.ts
+++ b/src/renderer/docking/splitSizing.ts
@@ -1,6 +1,6 @@
import { PANEL_MINIMUM_SIZES, type DockLayoutNode, type PanelType } from '../../shared/types'
-export const SPLIT_DIVIDER_SIZE = 5
+export const SPLIT_DIVIDER_SIZE = 1
export const MIN_PANE_SIZE = { width: 320, height: 220 }
export function layoutMinimum(node: DockLayoutNode, getPanelType?: (id: string) => PanelType | undefined): { width: number; height: number } {
diff --git a/src/renderer/drag/__tests__/harness.tsx b/src/renderer/drag/__tests__/harness.tsx
index f6a62ad73..d0a25daf5 100644
--- a/src/renderer/drag/__tests__/harness.tsx
+++ b/src/renderer/drag/__tests__/harness.tsx
@@ -48,8 +48,6 @@ export interface NodeSpec {
panelType?: PanelType
origin: Point
size: Size
- preMaximizeOrigin?: Point
- preMaximizeSize?: Size
}
export interface SceneSpec {
@@ -182,9 +180,6 @@ function TestNode({ spec, canvasStore }: { spec: NodeSpec; canvasStore: StoreApi
id: finalId,
origin: spec.origin,
size: spec.size,
- ...(spec.preMaximizeOrigin && spec.preMaximizeSize
- ? { preMaximizeOrigin: spec.preMaximizeOrigin, preMaximizeSize: spec.preMaximizeSize }
- : {}),
}
return { ...s, nodes: next }
})
diff --git a/src/renderer/drag/__tests__/scenarios.test.tsx b/src/renderer/drag/__tests__/scenarios.test.tsx
index 05bf4d5fe..347012ff0 100644
--- a/src/renderer/drag/__tests__/scenarios.test.tsx
+++ b/src/renderer/drag/__tests__/scenarios.test.tsx
@@ -5,8 +5,8 @@
//
// Numbered scenarios match Phase 1 of the plan. Dock-related scenarios (6, 7,
// 10) require dock-stack support in the harness and are skipped pending a
-// follow-up harness extension. preMaximizeSize / proportional grab / cross-
-// store invariant are the high-priority regressions targeted here.
+// follow-up harness extension. Cross-store invariants are the high-priority
+// regressions targeted here.
// =============================================================================
import { describe, it, expect, vi, afterEach } from 'vitest'
@@ -167,57 +167,6 @@ describe('drag integration — canvas-node scenarios', () => {
expect(store.getState().nodes['n1'].origin).toEqual(initialOrigin)
})
- // ---------------------------------------------------------------------------
- // 4. preMaximizeSize ghost sizing — drag a maximized node; ghost size =
- // preMaximizeSize, not the current (maximized) size. Lost in 0.4.4.
- // ---------------------------------------------------------------------------
- it('4: ghost size for a maximized node equals preMaximizeSize', () => {
- scene = renderDragScene({
- canvases: [{ panelId: 'c1', rect: { x: 0, y: 0, w: 1000, h: 800 } }],
- nodes: [{
- canvasPanelId: 'c1',
- nodeId: 'n1',
- origin: { x: 0, y: 0 },
- size: { width: 1000, height: 800 }, // maximized
- preMaximizeOrigin: { x: 200, y: 150 },
- preMaximizeSize: { width: 300, height: 200 },
- }],
- })
- scene.mouse.downOnNode('n1', { offset: { x: 500, y: 400 } })
- scene.mouse.moveBy({ x: 50, y: 50 })
- const drag = scene.drag()
- expect(drag.isDragging).toBe(true)
- expect(drag.ghostSize?.width).toBe(300)
- expect(drag.ghostSize?.height).toBe(200)
- scene.mouse.up()
- })
-
- // ---------------------------------------------------------------------------
- // 5. Proportional grab on maximized node — cursor lands at same relative
- // fraction inside the smaller ghost.
- // ---------------------------------------------------------------------------
- it('5: grab on a maximized node is projected proportionally into the pre-maximize rect', () => {
- scene = renderDragScene({
- canvases: [{ panelId: 'c1', rect: { x: 0, y: 0, w: 1000, h: 800 } }],
- nodes: [{
- canvasPanelId: 'c1',
- nodeId: 'n1',
- origin: { x: 0, y: 0 },
- size: { width: 1000, height: 800 },
- preMaximizeOrigin: { x: 0, y: 0 },
- preMaximizeSize: { width: 300, height: 200 },
- }],
- })
- // Grab at (750, 600): fraction (0.75, 0.75) of the maximized footprint.
- scene.mouse.downOnNode('n1', { offset: { x: 750, y: 600 } })
- scene.mouse.moveBy({ x: 10, y: 10 })
- const drag = scene.drag()
- expect(drag.isDragging).toBe(true)
- // Expected grab = 0.75 × ghostSize.
- expect(drag.grab?.x).toBeCloseTo(0.75 * 300, 0)
- expect(drag.grab?.y).toBeCloseTo(0.75 * 200, 0)
- })
-
// ---------------------------------------------------------------------------
// 8. Cross-store invariant — drop from canvas A onto canvas B: the resolved
// target's canvasStoreApi must match canvas B's store, not A's. The
diff --git a/src/renderer/drag/useDragOp.ts b/src/renderer/drag/useDragOp.ts
index 71cd6a87a..8332d1d17 100644
--- a/src/renderer/drag/useDragOp.ts
+++ b/src/renderer/drag/useDragOp.ts
@@ -159,21 +159,8 @@ function measureCanvasNodeGrab(
ghostZoom: zoom,
}
}
- // If the node is currently maximized, the spring-load effect (see
- // CanvasNode's drag-store subscription) will un-maximize it ~200ms into
- // the drag, snapping node.size/origin back to preMaximizeSize/Origin. The
- // ghost is sized once at START and isn't re-measured, so taking the live
- // maximized size would leave a huge stale ghost as soon as spring-load
- // fires. Use the pre-maximize geometry up-front so the ghost matches the
- // node's actual post-spring-load footprint (this mirrors the 0.4.4
- // behaviour that was lost in the unified-drag refactor).
- const isMaximized = node.preMaximizeOrigin != null && node.preMaximizeSize != null
- const effectiveSize: Size = isMaximized && node.preMaximizeSize
- ? { width: node.preMaximizeSize.width, height: node.preMaximizeSize.height }
- : { width: node.size.width, height: node.size.height }
- const effectiveOrigin: Point = isMaximized && node.preMaximizeOrigin
- ? { x: node.preMaximizeOrigin.x, y: node.preMaximizeOrigin.y }
- : { x: node.origin.x, y: node.origin.y }
+ const effectiveSize: Size = { width: node.size.width, height: node.size.height }
+ const effectiveOrigin: Point = { x: node.origin.x, y: node.origin.y }
const container = findCanvasContainerForStore(canvasStoreApi)
if (!container) {
@@ -189,19 +176,6 @@ function measureCanvasNodeGrab(
y: cursorClient.y - container.rect.top,
}
const cursorCanvas = viewToCanvas(localView, zoom, container.viewportOffset)
- // For a maximized node, project the grab proportionally into the pre-maximize
- // rect so the cursor stays at the same relative spot inside the (smaller)
- // ghost — otherwise grabbing the right side of a maximized node would put
- // the cursor far outside a much smaller pre-maximize ghost.
- if (isMaximized) {
- const fx = (cursorCanvas.x - node.origin.x) / Math.max(node.size.width, 1)
- const fy = (cursorCanvas.y - node.origin.y) / Math.max(node.size.height, 1)
- return {
- grab: { x: fx * effectiveSize.width, y: fy * effectiveSize.height },
- ghostSize: effectiveSize,
- ghostZoom: zoom,
- }
- }
return {
grab: { x: cursorCanvas.x - effectiveOrigin.x, y: cursorCanvas.y - effectiveOrigin.y },
ghostSize: effectiveSize,
diff --git a/src/renderer/hooks/useShortcuts.activeCanvas.test.tsx b/src/renderer/hooks/useShortcuts.activeCanvas.test.tsx
index 154082f79..c00886b9e 100644
--- a/src/renderer/hooks/useShortcuts.activeCanvas.test.tsx
+++ b/src/renderer/hooks/useShortcuts.activeCanvas.test.tsx
@@ -151,7 +151,7 @@ describe('navigation from panel content', () => {
expect(active.getState().selection).toEqual([left])
})
- it.each(['agent', 'browser', 'terminal', 'editor', 'canvas', 'document', 'review'])(
+ it.each(['agent', 'browser', 'terminal', 'editor', 'canvas', 'review'])(
'supports chained jumps starting from a %s panel', (type) => {
const left = active.getState().addNode('source', type, { x: 0, y: 0 })
const middle = active.getState().addNode('middle', 'editor', { x: 2000, y: 0 })
diff --git a/src/renderer/lib/e2eHarness.ts b/src/renderer/lib/e2eHarness.ts
index 1565c0112..80f60bb52 100644
--- a/src/renderer/lib/e2eHarness.ts
+++ b/src/renderer/lib/e2eHarness.ts
@@ -21,8 +21,9 @@ import { getLastReveal } from './editor/editorReveal'
import { applyTheme } from './themeManager'
import { BUILT_IN_THEMES } from '../../shared/themes'
import { terminalRegistry } from './terminal/terminalRegistry'
-import type { Point, WorktreeMeta } from '../../shared/types'
-import { activeDockPanelId } from '../../shared/collectPanelIds'
+import type { DockLayoutNode, DockZonePosition, Point, WorktreeMeta } from '../../shared/types'
+import { activeDockPanelId, collectPanelIds } from '../../shared/collectPanelIds'
+import { getOrCreateWorkspaceDockStore } from './workspace/dockRegistry'
import { useSettingsStore } from '../stores/settingsStore'
import { parseCodingAgentId, type CodingAgentRunSnapshot } from '../../shared/codingAgentRuns'
import { codingAgentSnapshot, handleCodingAgentMethod } from './agent/codingAgentDriver'
@@ -76,6 +77,11 @@ declare global {
): Promise
browserWebContentsId(panelId: string): number | null
nodes(): { id: string; panelId: string; origin: Point; size: { width: number; height: number } }[]
+ dockDebug(): {
+ zones: Record
+ presentation: { stackId: string; panelId: string | null; canRestore: boolean } | null
+ }
+ canvasDebug(canvasPanelId?: string): { id: string; panelIds: string[]; leafCount: number }[]
zoom(): number
setZoom(z: number): void
resetViewport(): void
@@ -282,6 +288,49 @@ export function installE2EHarness(): void {
}))
}
+ const leafCount = (layout: DockLayoutNode | null): number => {
+ if (!layout) return 0
+ return layout.type === 'tabs'
+ ? 1
+ : layout.children.reduce((total, child) => total + leafCount(child), 0)
+ }
+
+ const dockDebug = () => {
+ const workspaceId = useAppStore.getState().selectedWorkspaceId
+ const state = getOrCreateWorkspaceDockStore(workspaceId).getState()
+ const zone = (position: DockZonePosition) => ({
+ panelIds: collectPanelIds(state.zones[position].layout),
+ leafCount: leafCount(state.zones[position].layout),
+ })
+ return {
+ zones: {
+ left: zone('left'),
+ right: zone('right'),
+ bottom: zone('bottom'),
+ center: zone('center'),
+ },
+ presentation: state.presentation
+ ? {
+ stackId: state.presentation.stackId,
+ panelId: state.presentation.panelId ?? null,
+ canRestore: state.canRestorePresentation(state.presentation.stackId),
+ }
+ : null,
+ }
+ }
+
+ const canvasDebug = (canvasPanelId?: string) => {
+ const store = canvasPanelId
+ ? getOrCreateCanvasStoreForPanel(canvasPanelId)
+ : activeCanvasStore()
+ if (!store) return []
+ return Object.values(store.getState().nodes).map((node) => ({
+ id: node.id,
+ panelIds: collectPanelIds(node.dockLayout),
+ leafCount: leafCount(node.dockLayout),
+ }))
+ }
+
const zoom = () => activeCanvasStore()?.getState().zoomLevel ?? 1
const setZoom = (z: number) => {
@@ -548,6 +597,8 @@ export function installE2EHarness(): void {
browserInvoke,
browserWebContentsId,
nodes,
+ dockDebug,
+ canvasDebug,
zoom,
setZoom,
resetViewport,
@@ -559,7 +610,7 @@ export function installE2EHarness(): void {
selectWorkspace,
panelTypes,
panels: () => Object.values(useAppStore.getState().getWorkspace(useAppStore.getState().selectedWorkspaceId)?.panels ?? {}),
- createPanel: (type, filePath) => getPanelDef(type).create({ filePath, documentType: filePath ? 'image' : undefined, workspaceId: useAppStore.getState().selectedWorkspaceId, placement: { target: 'dock', zone: 'center' } })!,
+ createPanel: (type, filePath) => getPanelDef(type).create({ filePath, workspaceId: useAppStore.getState().selectedWorkspaceId, placement: { target: 'dock', zone: 'center' } })!,
detachPanel: (id) => movePanelToNewWindow(useAppStore.getState().selectedWorkspaceId, id),
openApplicationOverlay: (view, section) => {
const ui = useUIStore.getState()
diff --git a/src/renderer/lib/editor/editorDocuments.ts b/src/renderer/lib/editor/editorDocuments.ts
index 3c985415e..7302afb3c 100644
--- a/src/renderer/lib/editor/editorDocuments.ts
+++ b/src/renderer/lib/editor/editorDocuments.ts
@@ -268,9 +268,9 @@ export class EditorDocument {
dispose(): void { this.identityRevision++; this.watchEpoch++; this.stopWatching?.(); this.stopWatching = undefined; this.watchKey = undefined; this.listeners.clear() }
}
-export function editorDocument(workspaceId: string, panelId: string, filePath?: string, rootPath?: string): EditorDocument {
+export function editorDocument(workspaceId: string, panelId: string, filePath?: string | null, rootPath?: string): EditorDocument {
const panel = useAppStore.getState().workspaces.find(ws => ws.id === workspaceId)?.panels[panelId]
- filePath ??= panel?.filePath
+ filePath = filePath === null ? undefined : filePath ?? panel?.filePath
const previous = panelDocuments.get(panelId)
if (previous && previous.filePathRef.current !== filePath) releaseEditorPanel(panelId)
const key = keyFor(panelId, filePath)
@@ -339,7 +339,7 @@ export function applyFileEntryMove(event: FileEntryMoved): void {
}
const app = useAppStore.getState()
const movedPanels = app.workspaces.flatMap(ws => Object.values(ws.panels).flatMap(panel => {
- const path = (panel.type === 'editor' || panel.type === 'document') ? movedPath(panel.filePath) : undefined
+ const path = panel.type === 'editor' ? movedPath(panel.filePath) : undefined
return path ? [{ workspaceId: ws.id, panel, path }] : []
}))
for (const [oldPath, document] of [...documents]) {
diff --git a/src/renderer/lib/editor/useFileSync.ts b/src/renderer/lib/editor/useFileSync.ts
index 6ed4403a7..f030498ed 100644
--- a/src/renderer/lib/editor/useFileSync.ts
+++ b/src/renderer/lib/editor/useFileSync.ts
@@ -6,7 +6,7 @@ export type { EditorConflict }
export interface UseFileSyncParams {
workspaceId: string
panelId: string
- filePath: string | undefined
+ filePath: string | null | undefined
rootPath: string | undefined
getModel: () => monaco.editor.ITextModel | null
onExternalReplace?: (content: string) => void
diff --git a/src/renderer/lib/fs/fileRouting.ts b/src/renderer/lib/fs/fileRouting.ts
index 9d37ba6c8..e1b6aaffe 100644
--- a/src/renderer/lib/fs/fileRouting.ts
+++ b/src/renderer/lib/fs/fileRouting.ts
@@ -33,9 +33,5 @@ export function openFileAsPanel(
placement?: PanelPlacement,
): string {
const store = useAppStore.getState()
- const docType = getDocumentType(filePath)
- if (docType) {
- return store.createDocument(workspaceId, filePath, docType, position, placement)
- }
return store.createEditor(workspaceId, filePath, position, placement)
}
diff --git a/src/renderer/lib/panelTargetPicker.test.ts b/src/renderer/lib/panelTargetPicker.test.ts
index 99a328946..024e2cf46 100644
--- a/src/renderer/lib/panelTargetPicker.test.ts
+++ b/src/renderer/lib/panelTargetPicker.test.ts
@@ -56,7 +56,7 @@ describe.each(['main', 'detached'])('%s window target routing', (owner) => {
vi.stubGlobal('window', { electronAPI: { showContextMenu: menu } })
})
- it.each(['terminal', 'agent', 'editor', 'browser', 'document', 'review'] as const)('creates %s directly in an entirely empty dock for every source', async (panelType) => {
+ it.each(['terminal', 'agent', 'editor', 'browser', 'review'] as const)('creates %s directly in an entirely empty dock for every source', async (panelType) => {
for (const source of [{ source: 'overlay' as const }, {}, { sourcePanelId: 'closed-source' }]) {
for (const availability of ['new', 'both', 'existing'] as const) {
const result = await requestPanelTarget({ workspaceId, panelType, availability, ...source })
diff --git a/src/renderer/lib/themeManager.test.tsx b/src/renderer/lib/themeManager.test.tsx
new file mode 100644
index 000000000..c4b0631f5
--- /dev/null
+++ b/src/renderer/lib/themeManager.test.tsx
@@ -0,0 +1,42 @@
+import { afterEach, expect, it, vi } from 'vitest'
+import { applyTheme, getActiveTheme, subscribeTheme } from './themeManager'
+import { useSettingsStore } from '../stores/settingsStore'
+import { DEFAULT_SETTINGS } from '../../shared/types'
+import { BUILT_IN_BY_ID, DEFAULT_DARK_THEME_ID, DEFAULT_LIGHT_THEME_ID } from '../../shared/themes'
+import { mergeThemeApp } from '../../shared/themeResolution'
+
+const custom = {
+ ...BUILT_IN_BY_ID[DEFAULT_DARK_THEME_ID], id: 'custom',
+ app: { 'surface-1': '#ff00ff', 'text-primary': '#abcdef', 'custom-token': '#123456' },
+}
+
+afterEach(() => {
+ useSettingsStore.setState(DEFAULT_SETTINGS)
+ applyTheme(DEFAULT_DARK_THEME_ID)
+ document.documentElement.style.removeProperty('--unrelated')
+})
+
+it.each([DEFAULT_DARK_THEME_ID, DEFAULT_LIGHT_THEME_ID, 'deleted-theme'])('fully replaces custom colors when switching to %s', (selection) => {
+ useSettingsStore.setState({ customThemes: [custom] })
+ applyTheme(custom.id)
+ expect(document.documentElement.style.getPropertyValue('--surface-1')).toBe('#ff00ff')
+ expect(document.documentElement.style.getPropertyValue('--custom-token')).toBe('#123456')
+ document.documentElement.style.setProperty('--unrelated', '42px')
+ useSettingsStore.setState({ customThemes: [] })
+ const listener = vi.fn()
+ const unsubscribe = subscribeTheme(listener)
+ try {
+ applyTheme(selection)
+ const expected = BUILT_IN_BY_ID[selection] ?? BUILT_IN_BY_ID[DEFAULT_DARK_THEME_ID]
+ expect(getActiveTheme()).toBe(expected)
+ for (const [key, value] of Object.entries(mergeThemeApp(expected))) {
+ expect(document.documentElement.style.getPropertyValue('--' + key)).toBe(value)
+ }
+ expect(document.documentElement.style.getPropertyValue('--custom-token')).toBe('')
+ expect(document.documentElement.style.getPropertyValue('--unrelated')).toBe('42px')
+ expect(document.documentElement.dataset.theme).toBe(expected.type)
+ expect(listener).toHaveBeenCalledWith(expected)
+ } finally {
+ unsubscribe()
+ }
+})
diff --git a/src/renderer/lib/themeManager.ts b/src/renderer/lib/themeManager.ts
index 11ecdd644..2886ea443 100644
--- a/src/renderer/lib/themeManager.ts
+++ b/src/renderer/lib/themeManager.ts
@@ -26,6 +26,7 @@ import { mergeThemeApp, resolveTheme as resolveThemeSelection } from '../../shar
let currentTheme: Theme = BUILT_IN_BY_ID[DEFAULT_DARK_THEME_ID]
let currentSelection: ThemeSelection = 'system'
+let appliedAppKeys = new Set()
const subscribers = new Set<(t: Theme) => void>()
let mediaQuery: MediaQueryList | null = null
@@ -66,6 +67,12 @@ function injectAppVars(theme: Theme): void {
if (typeof document === 'undefined') return
const root = document.documentElement
const merged = mergeThemeApp(theme)
+ // Settings-file themes can contain extra tokens absent from the base palette.
+ // Remove their old overrides when switching or deleting a theme.
+ for (const key of appliedAppKeys) {
+ if (!(key in merged)) root.style.removeProperty('--' + key)
+ }
+ appliedAppKeys = new Set(Object.keys(merged))
for (const [key, value] of Object.entries(merged)) {
root.style.setProperty('--' + key, value)
}
diff --git a/src/renderer/lib/workspace/sessionSerialize.ts b/src/renderer/lib/workspace/sessionSerialize.ts
index 970d3a45e..60e2281ba 100644
--- a/src/renderer/lib/workspace/sessionSerialize.ts
+++ b/src/renderer/lib/workspace/sessionSerialize.ts
@@ -36,7 +36,6 @@ const PASSTHROUGH_PANEL_FIELDS = [
'tabs',
'activeTabId',
'proxyUrl',
- 'documentType',
'sidebarView',
'sidebarVisible',
] as const
diff --git a/src/renderer/lib/worktreeContext.test.ts b/src/renderer/lib/worktreeContext.test.ts
index 29689b73c..5e1030afa 100644
--- a/src/renderer/lib/worktreeContext.test.ts
+++ b/src/renderer/lib/worktreeContext.test.ts
@@ -29,7 +29,7 @@ describe('worktreeContext', () => {
filePath: '/checkouts/feature/src/app.ts',
}
const document: PanelState = {
- id: 'document', type: 'document', title: 'spec.pdf', isDirty: false,
+ id: 'document', type: 'editor', title: 'spec.pdf', isDirty: false,
filePath: '/checkouts/feature/docs/spec.pdf',
}
const review: PanelState = {
diff --git a/src/renderer/lib/worktreeContext.ts b/src/renderer/lib/worktreeContext.ts
index ad9619d2e..feb80321d 100644
--- a/src/renderer/lib/worktreeContext.ts
+++ b/src/renderer/lib/worktreeContext.ts
@@ -46,7 +46,7 @@ export function worktreeForPanel(
const explicit = worktrees.find((worktree) => worktree.id === panel.worktreeId)
if (explicit) return explicit
if (panel.type === 'terminal' || panel.type === 'agent') return worktreeForPath(panel.cwd, worktrees)
- if (panel.type === 'editor' || panel.type === 'document') {
+ if (panel.type === 'editor') {
return worktreeForPath(panel.filePath, worktrees)
}
if (panel.type === 'review') return worktreeForPath(panel.reviewState?.repoPath, worktrees)
diff --git a/src/renderer/panels/AgentChangesView.tsx b/src/renderer/panels/AgentChangesView.tsx
index 09d1271cc..3e9b34333 100644
--- a/src/renderer/panels/AgentChangesView.tsx
+++ b/src/renderer/panels/AgentChangesView.tsx
@@ -5,7 +5,7 @@ import { RecordedReviewButton } from './RecordedReviewButton'
import { RecordedDiffHunk, type NoteDraft } from './ReviewDiff'
import { ReviewDisplayOptions, ReviewFileFilter, ReviewRunStatus, ReviewStats, ToolbarButton } from './ReviewControls'
import { getAgentLogoById } from '../lib/agent/agentLogos'
-import { PopoverSurface, useDismissableLayer, useViewportPopoverPosition } from '../ui/Popover'
+import { POPOVER_SURFACE, PopoverSurface, useDismissableLayer, useViewportPopoverPosition } from '../ui/Popover'
import { AGENTS } from '../../shared/agents'
import { filterAgentChanges } from '../../shared/agentChanges'
import type { AgentChangedFile, AgentChangesFilter } from '../../shared/agentChanges'
@@ -119,7 +119,7 @@ function AgentChangesContent({ workspaceId, panelId, workspace, state }: PanelPr
updateDisplay({ split: !display.split })}>{display.split ? : }
setMoreOpen(!moreOpen)}>
- {moreOpen &&
}
+ {moreOpen &&
}
diff --git a/src/renderer/panels/BrowserDownloadsPopover.tsx b/src/renderer/panels/BrowserDownloadsPopover.tsx
index d65ab61b2..10e00f477 100644
--- a/src/renderer/panels/BrowserDownloadsPopover.tsx
+++ b/src/renderer/panels/BrowserDownloadsPopover.tsx
@@ -1,7 +1,7 @@
import { useRef, type RefObject } from 'react'
import { CircleCheck as CheckCircle, Download as DownloadSimple, FolderOpen, CircleAlert as WarningCircle, X } from 'lucide-react'
import type { BrowserDownloadEntry } from '../../shared/types'
-import { useDismissableLayer } from '../ui/Popover'
+import { POPOVER_SURFACE, useDismissableLayer } from '../ui/Popover'
export interface BrowserPanelDownload extends BrowserDownloadEntry {
webContentsId: number
@@ -42,7 +42,7 @@ export function BrowserDownloadsPopover({ downloads, onAction, onClose, triggerR
event.stopPropagation()}
>
diff --git a/src/renderer/panels/BrowserMenu.tsx b/src/renderer/panels/BrowserMenu.tsx
index 22dd5e0d5..bddd9fa24 100644
--- a/src/renderer/panels/BrowserMenu.tsx
+++ b/src/renderer/panels/BrowserMenu.tsx
@@ -7,7 +7,7 @@ import { useBrowserStore } from '../stores/browserStore'
import { useUIStore } from '../stores/uiStore'
import { BrowserFavicon } from './BrowserFavicon'
import { faviconForUrl } from './browserUrl'
-import { useDismissableLayer } from '../ui/Popover'
+import { POPOVER_SURFACE, useDismissableLayer } from '../ui/Popover'
interface Props {
onNewTab: () => void
@@ -40,16 +40,16 @@ export function BrowserMenu({
useDismissableLayer({ open: true, contentRef: ref, triggerRefs: [triggerRef], onDismiss: onClose })
- const item = 'w-full flex items-center gap-2.5 px-3 h-8 text-sm text-secondary hover:bg-hover transition-colors text-left'
+ const item = 'w-full flex items-center gap-2.5 rounded-lg px-2.5 h-8 text-[13px] text-primary hover:bg-hover focus-visible:bg-hover transition-colors duration-100 motion-reduce:transition-none text-left'
return (
e.stopPropagation()}
>
{ onClose(); onNewTab() }}>
- New tab
+ New tab
-
+
Bookmarks
@@ -75,7 +75,7 @@ export function BrowserMenu({
{bookmarks.length === 0 ? (
No bookmarks yet
@@ -84,7 +84,7 @@ export function BrowserMenu({
key={bookmark.url}
role="menuitem"
title={bookmark.url}
- className="flex h-8 w-full items-center gap-2.5 px-3 text-left text-sm text-secondary transition-colors hover:bg-hover"
+ className={item}
onClick={() => {
onClose()
onNavigate(bookmark.url)
@@ -98,42 +98,44 @@ export function BrowserMenu({
)}
{ onClose(); onOpenHistory() }}>
- History
+ History
{ onClose(); onOpenPasswordManager() }}>
- Passwords and autofill
+ Passwords and autofill
-
-
+
+
Zoom
-
-
-
-
- {zoomPercent}%
-
-
= 500}
- className="flex h-7 w-7 items-center justify-center rounded-md text-secondary transition-colors hover:bg-hover hover:text-primary disabled:opacity-30"
- aria-label="Zoom in"
- >
-
-
+
+
+
+
+
+ {zoomPercent}%
+
+
= 500}
+ className="flex h-7 w-7 items-center justify-center text-secondary transition-colors hover:bg-hover hover:text-primary focus-visible:bg-hover disabled:opacity-30 disabled:hover:bg-transparent"
+ aria-label="Zoom in"
+ >
+
+
+
-
+
{
@@ -141,7 +143,7 @@ export function BrowserMenu({
useUIStore.getState().openSettings('browser')
}}
>
- Browser settings…
+ Browser settings…
)
diff --git a/src/renderer/panels/BrowserPanel.tsx b/src/renderer/panels/BrowserPanel.tsx
index 9e95cd219..9b8fe695d 100644
--- a/src/renderer/panels/BrowserPanel.tsx
+++ b/src/renderer/panels/BrowserPanel.tsx
@@ -32,6 +32,7 @@ import { Tooltip } from '../ui/Tooltip'
import { Spinner } from '../ui/Spinner'
import { PanelCenteredState } from '../ui/PanelCenteredState'
import { Button } from '../ui/Button'
+import { POPOVER_SURFACE } from '../ui/Popover'
import { useActivePanelStore } from '../lib/activePanel'
import {
BROWSER_HISTORY_URL,
@@ -1347,7 +1348,7 @@ export default function BrowserPanel({
IPC; the selected password is decrypted and filled in main. */}
{autofillPopup && (
firstPanelId ? s.workspaces.find((w) => w.id === workspaceId)?.panels[firstPanelId]?.title : undefined)
const canvasStoreApi = useCanvasStoreApi()
+ const outerDockStoreApi = useOptionalDockStoreApi()
// ------------------------------------------------------------------
// Create (or reuse) the per-node DockStore, keyed by canvasPanelId:nodeId
@@ -180,8 +182,10 @@ const CanvasNodeWrapper = React.memo(({ nodeId, canvasPanelId, workspaceId, rend
diff --git a/src/renderer/panels/EditorPanel.navigation.test.tsx b/src/renderer/panels/EditorPanel.navigation.test.tsx
index fbc1d4852..b8cc5e938 100644
--- a/src/renderer/panels/EditorPanel.navigation.test.tsx
+++ b/src/renderer/panels/EditorPanel.navigation.test.tsx
@@ -1,3 +1,4 @@
+vi.mock('./FilePreview', () => ({ default: () =>
}))
import React, { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
@@ -206,12 +207,19 @@ it('protects edits to the next file after discarding the previous file', async (
await act(async () => { await h.open!(['/test/third.ts']) })
expect(window.electronAPI.confirmUnsavedChanges).toHaveBeenCalledTimes(2)
})
-it.each([['pdf', 'pdf'], ['docx', 'docx'], ['png', 'image']])('routes %s files to document panels', async (ext, documentType) => {
+it.each(['pdf', 'docx', 'png'])('previews %s in the current Files tab and returns to text', async (ext) => {
await mount('/test/code.ts')
+ const editorCount = h.editors.length
await act(async () => { await h.open!([`/test/document.${ext}`]) })
const panels = Object.values(useAppStore.getState().getWorkspace('test')!.panels)
- expect(panels).toEqual(expect.arrayContaining([expect.objectContaining({ type: 'document', documentType, filePath: `/test/document.${ext}` })]))
- expect(panels.find(p => p.id === 'editor')!.filePath).toBe('/test/code.ts')
+ expect(panels).toHaveLength(1)
+ expect(panels[0]).toMatchObject({ type: 'editor', filePath: `/test/document.${ext}` })
+ expect(h.editors).toHaveLength(editorCount)
+ expect(window.electronAPI.fsReadFile).not.toHaveBeenCalledWith(`/test/document.${ext}`, 'test')
+ expect(host.querySelector('[data-testid="file-preview"]')).not.toBeNull()
+ await act(async () => { await h.open!(['/test/next.ts']) })
+ expect(h.editors).toHaveLength(editorCount + 1)
+ expect(host.querySelector('[data-testid="file-preview"]')).toBeNull()
})
it('opens every selected file, reusing the current editor for the first text file', async () => {
await mount('/test/code.ts')
diff --git a/src/renderer/panels/EditorPanel.tsx b/src/renderer/panels/EditorPanel.tsx
index aa2475940..906203a36 100644
--- a/src/renderer/panels/EditorPanel.tsx
+++ b/src/renderer/panels/EditorPanel.tsx
@@ -5,9 +5,9 @@ import { panelSearchStore } from '../stores/panelSearchStores'
// EditorPanel — Monaco Editor wrapper for CanvasIDE editor panels.
// =============================================================================
-import { useEffect, useRef, useCallback, useState } from 'react'
-import type { ReactNode } from 'react'
-import { Check, ChevronDown, ChevronLeft, ChevronRight, Copy, ExternalLink, FolderOpen, Folders, Github, Search } from 'lucide-react'
+import { lazy, Suspense, useEffect, useRef, useCallback, useState } from 'react'
+import type { CSSProperties, ReactNode } from 'react'
+import { Check, ChevronDown, ChevronLeft, ChevronRight, Copy, ExternalLink, FolderOpen, Folders, Github, PanelLeftClose, PanelLeftOpen, Search } from 'lucide-react'
import { perfCount, useRenderCount } from '../lib/perf/perfClient'
import log from '../lib/logger'
import * as monaco from 'monaco-editor'
@@ -242,6 +242,8 @@ function detectLanguage(filePath: string): string {
// EditorPanel component
// -----------------------------------------------------------------------------
+const FilePreview = lazy(() => import('./FilePreview'))
+
export default function EditorPanel({
panelId,
workspaceId,
@@ -249,6 +251,7 @@ export default function EditorPanel({
nodeId,
}: EditorPanelProps) {
useRenderCount('EditorPanel')
+ const previewType = filePath ? getDocumentType(filePath) : null
const shortcutLabel = useShortcutLabel()
const containerRef = useRef(null)
const editorRef = useRef(null)
@@ -257,6 +260,7 @@ export default function EditorPanel({
const [markdownContent, setMarkdownContent] = useState('')
const [loadError, setLoadError] = useState(null)
const [fileLoading, setFileLoading] = useState(!!filePath)
+ const [editorCollapsed, setEditorCollapsed] = useState(false)
const toolbarRef = useRef(null)
const [toolbarScroll, setToolbarScroll] = useState({ left: false, right: false })
const [editorBackground, setEditorBackground] = useState(() => getActiveTheme().editor.colors?.['editor.background'] ?? 'var(--surface-1)')
@@ -272,6 +276,7 @@ export default function EditorPanel({
const currentWorktree = worktreeForPanel(panel, worktrees)
const explorerRoot = currentWorktree?.path ?? worktreeForPanel(panel, ws?.worktrees ?? [])?.path ?? ws?.rootPath ?? ''
const explorerVisible = panel?.sidebarVisible !== false
+ const editorVisible = !editorCollapsed || !explorerVisible || !explorerRoot
const setExplorerVisible = (visible: boolean) => useAppStore.getState().setPanelNavigation(workspaceId, panelId, panel?.sidebarView === 'search' ? 'search' : 'explorer', visible)
const activePanelId = useActivePanelStore((s) => s.activePanelId)
@@ -292,7 +297,7 @@ export default function EditorPanel({
toolbar.removeEventListener('scroll', update)
observer.disconnect()
}
- }, [filePath, explorerVisible, searchVisible])
+ }, [filePath, explorerVisible, searchVisible, editorVisible])
const setNavigationView = (view: 'explorer' | 'search') => {
useAppStore.getState().setPanelNavigation(workspaceId, panelId, view)
}
@@ -382,7 +387,7 @@ export default function EditorPanel({
const sync = useFileSync({
workspaceId,
panelId,
- filePath,
+ filePath: previewType ? null : filePath,
rootPath: checkoutRoot,
getModel,
onExternalReplace,
@@ -408,7 +413,7 @@ export default function EditorPanel({
return
}
if (switchingFile.current) return
- const nextPath = paths.find((path) => !getDocumentType(path))
+ const nextPath = paths[0]
switchingFile.current = true
try {
const store = useAppStore.getState()
@@ -462,7 +467,7 @@ export default function EditorPanel({
// ---------------------------------------------------------------------------
useEffect(() => {
- if (!containerRef.current) return
+ if (previewType || !containerRef.current) return
setLoadError(null)
setMarkdownContent('')
@@ -644,7 +649,7 @@ export default function EditorPanel({
editorRef.current = null
}
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [filePath, workspaceId])
+ }, [filePath, workspaceId, previewType])
// ---------------------------------------------------------------------------
// Listen for save-file custom event
@@ -834,6 +839,17 @@ export default function EditorPanel({
className="shrink-0 flex items-center gap-2 px-2.5 py-1.5 rounded-lg border border-strong text-secondary hover:bg-hover hover:text-primary disabled:opacity-40"
title="Open in another app"
>Open
+ {
+ if (editorVisible) setExplorerVisible(true)
+ setEditorCollapsed(editorVisible)
+ }}
+ disabled={!explorerRoot}
+ className="shrink-0 p-1.5 rounded-md text-secondary hover:bg-hover hover:text-primary disabled:opacity-40"
+ title={editorVisible ? 'Show sidebar only' : 'Show editor'}
+ aria-label={editorVisible ? 'Show sidebar only' : 'Show editor'}
+ aria-pressed={!editorVisible}
+ >{editorVisible ? : }
{
if (explorerVisible && !searchVisible) setExplorerVisible(false)
@@ -894,9 +910,10 @@ export default function EditorPanel({
}} className="flex items-center gap-2.5 rounded-lg px-2.5 py-1.5 text-left text-[13px] text-primary hover:bg-hover focus-visible:bg-hover"> {label} )}
}
-
-
- {showDiff && conflict?.kind === 'changed' && (
+
+
+ {previewType && filePath &&
}>
}
+ {!previewType && showDiff && conflict?.kind === 'changed' && (
@@ -904,7 +921,7 @@ export default function EditorPanel({
{markdownPreview && isMarkdown && (
)}
- {loadError && (
+ {!previewType && loadError && (
}
/>
)}
- {fileLoading && (
+ {!previewType && fileLoading && (
)}
-
+
{explorerRoot && (
-
setExplorerVisible(false)}>
+ setExplorerVisible(false)}>
{searchVisible
? { void openExplorerFiles([path], 'dock', { line, column }) }} />
: }
diff --git a/src/renderer/panels/ExplorerSidebar.test.tsx b/src/renderer/panels/ExplorerSidebar.test.tsx
index 7ca94025b..68b25c77b 100644
--- a/src/renderer/panels/ExplorerSidebar.test.tsx
+++ b/src/renderer/panels/ExplorerSidebar.test.tsx
@@ -32,3 +32,28 @@ it('holds at minimum width and collapses only after the second drag threshold',
await act(async () => root.unmount())
host.remove()
})
+
+it('fills the panel without a resize handle and restores the sidebar width', async () => {
+ const host = document.createElement('div')
+ document.body.appendChild(host)
+ const root = createRoot(host)
+ const onHide = vi.fn()
+ const render = async (fill: boolean) => {
+ await act(async () => root.render(Files ))
+ }
+ await render(false)
+ await act(async () => host.querySelector('[role="separator"]')!.dispatchEvent(
+ new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true }),
+ ))
+ expect(host.querySelector('aside')!.style.width).toBe('276px')
+ await render(true)
+ expect(host.querySelector('aside')!.style.width).toBe('100%')
+ expect(host.querySelector('aside')!.style.maxWidth).toBe('none')
+ expect(host.querySelector('[role="separator"]')).toBeNull()
+ expect(host.textContent).toBe('Files')
+ await render(false)
+ expect(host.querySelector('aside')!.style.width).toBe('276px')
+ expect(host.querySelector('[role="separator"]')).not.toBeNull()
+ await act(async () => root.unmount())
+ host.remove()
+})
diff --git a/src/renderer/panels/ExplorerSidebar.tsx b/src/renderer/panels/ExplorerSidebar.tsx
index 79a88c2b5..57e507ad6 100644
--- a/src/renderer/panels/ExplorerSidebar.tsx
+++ b/src/renderer/panels/ExplorerSidebar.tsx
@@ -4,8 +4,9 @@ const MIN_WIDTH = 180
const MAX_WIDTH = 480
const COLLAPSE_OVERSHOOT = 64
-export function ExplorerSidebar({ visible, onHide, children }: {
+export function ExplorerSidebar({ visible, fill = false, onHide, children }: {
visible: boolean
+ fill?: boolean
onHide: () => void
children: ReactNode
}) {
@@ -18,12 +19,12 @@ export function ExplorerSidebar({ visible, onHide, children }: {
ref={sidebarRef}
aria-hidden={!visible}
className={`relative shrink-0 h-full ${dragging ? '' : 'transition-[width,opacity] duration-200 ease-out motion-reduce:transition-none'}`}
- style={{ width: visible ? width : 0, maxWidth: '70%', opacity: visible ? 1 : 0 }}
+ style={{ width: visible ? (fill ? '100%' : width) : 0, maxWidth: fill ? 'none' : '70%', opacity: visible ? 1 : 0 }}
>
-
-
{children}
+
- {visible &&
({
getDocument: pdfMocks.getDocument,
}))
-import DocumentPanel from './DocumentPanel'
+import FilePreview from './FilePreview'
import { useAppStore } from '../stores/appStore'
import type { PanelState, WorkspaceState } from '../../shared/types'
@@ -32,7 +32,7 @@ let root: Root
let fsReadBinary: ReturnType
let shellShowInFolder: ReturnType
-function workspace(filePath?: string, documentType?: PanelState['documentType']): WorkspaceState {
+function workspace(filePath?: string, _documentType?: 'pdf' | 'docx' | 'image'): WorkspaceState {
return {
id: 'ws-1',
name: 'Workspace',
@@ -41,18 +41,22 @@ function workspace(filePath?: string, documentType?: PanelState['documentType'])
panels: {
'document-1': {
id: 'document-1',
- type: 'document',
+ type: 'editor',
title: 'Document',
filePath,
- documentType,
} as PanelState,
},
}
}
+function PreviewHarness() {
+ const filePath = useAppStore(s => s.workspaces[0].panels['document-1'].filePath)
+ return
+}
+
function mount(): void {
act(() => {
- root.render( )
+ root.render( )
})
}
@@ -79,7 +83,7 @@ afterEach(() => {
useAppStore.setState(initialAppState, true)
})
-describe('DocumentPanel component', () => {
+describe('FilePreview component', () => {
it('loads binary data for the owning workspace and trusts magic bytes over stale persisted type', async () => {
useAppStore.setState({ workspaces: [workspace('/workspace/photo.png', 'pdf')], selectedWorkspaceId: 'ws-1' })
fsReadBinary.mockResolvedValue(Uint8Array.from([0x89, 0x50, 0x4e, 0x47]).buffer)
diff --git a/src/renderer/panels/DocumentPanel.tsx b/src/renderer/panels/FilePreview.tsx
similarity index 96%
rename from src/renderer/panels/DocumentPanel.tsx
rename to src/renderer/panels/FilePreview.tsx
index 4e01e1554..b044dd14a 100644
--- a/src/renderer/panels/DocumentPanel.tsx
+++ b/src/renderer/panels/FilePreview.tsx
@@ -1,7 +1,6 @@
import React, { useEffect, useRef, useState, useCallback, useMemo } from 'react'
import * as pdfjsLib from 'pdfjs-dist'
-import type { PanelProps } from './types'
-import { useAppStore } from '../stores/appStore'
+import { getDocumentType } from '../lib/fs/fileRouting'
import { ArrowLeft, ArrowRight, Minus, Plus } from 'lucide-react'
import { errorMessage } from '../lib/errorMessage'
import { viewedArrayBuffer } from './documentBytes'
@@ -302,14 +301,8 @@ function DocxViewer({ data }: { data: Uint8Array }) {
// Main component
// ---------------------------------------------------------------------------
-export default function DocumentPanel({ panelId, workspaceId }: PanelProps) {
- const panelState = useAppStore((s) => {
- const ws = s.workspaces.find((w) => w.id === workspaceId) ?? s.workspaces.find((w) => w.id === s.selectedWorkspaceId)
- return ws?.panels[panelId]
- })
-
- const filePath = panelState?.filePath
- const storeDocumentType = panelState?.documentType
+export default function FilePreview({ filePath, workspaceId }: { filePath: string; workspaceId: string }) {
+ const storeDocumentType = getDocumentType(filePath)
const [data, setData] = useState(null)
const [loading, setLoading] = useState(true)
diff --git a/src/renderer/panels/GitReviewPanel.lifecycle.test.tsx b/src/renderer/panels/GitReviewPanel.lifecycle.test.tsx
index 4bc2aa1a7..0d0175c40 100644
--- a/src/renderer/panels/GitReviewPanel.lifecycle.test.tsx
+++ b/src/renderer/panels/GitReviewPanel.lifecycle.test.tsx
@@ -2,8 +2,8 @@ import React, { act } from 'react'
import { createRoot } from 'react-dom/client'
import { expect, it, vi } from 'vitest'
-const h = vi.hoisted(() => ({ createEditor: vi.fn(), createDocument: vi.fn(), workspace: { id: 'ws', rootPath: '/repo', panels: { review: { reviewState: { repoPath: '/repo', spec: { kind: 'uncommitted' }, display: {}, notes: [{ path: 'gone.ts', side: 'file', status: 'open' }] } as any } } } }))
-vi.mock('../stores/appStore', () => ({ useAppStore: Object.assign((selector: any) => selector({ workspaces: [h.workspace] }), { getState: () => ({ createEditor: h.createEditor, createDocument: h.createDocument, getWorkspace: () => h.workspace, setPanelReviewState: (_w: string, _p: string, next: any) => { h.workspace.panels.review.reviewState = next } }) }) }))
+const h = vi.hoisted(() => ({ createEditor: vi.fn(), workspace: { id: 'ws', rootPath: '/repo', panels: { review: { reviewState: { repoPath: '/repo', spec: { kind: 'uncommitted' }, display: {}, notes: [{ path: 'gone.ts', side: 'file', status: 'open' }] } as any } } } }))
+vi.mock('../stores/appStore', () => ({ useAppStore: Object.assign((selector: any) => selector({ workspaces: [h.workspace] }), { getState: () => ({ createEditor: h.createEditor, getWorkspace: () => h.workspace, setPanelReviewState: (_w: string, _p: string, next: any) => { h.workspace.panels.review.reviewState = next } }) }) }))
vi.mock('../stores/gitStatusStore', () => ({ useGitStatusSnapshot: () => ({ revision: 0 }), gitStatusStore: {} }))
vi.mock('../stores/useWorktrees', () => ({ useWorktrees: () => [] }))
vi.mock('../lib/review/reviewAgent', () => ({}))
@@ -25,8 +25,8 @@ it('does not overwrite Agent changes when an unmounted Git comparison finishes',
} finally { act(() => root.unmount()) }
})
-it.each([['a.png', 'image'], ['a.pdf', 'pdf'], ['a.docx', 'docx']])('routes review file %s to its document surface', async (path, type) => {
- h.createEditor.mockClear(); h.createDocument.mockClear()
+it.each(['a.png', 'a.pdf', 'a.docx'])('opens review file %s in Files', async (path) => {
+ h.createEditor.mockClear()
h.workspace.panels.review.reviewState = { repoPath: '/repo', spec: { kind: 'uncommitted' }, display: {}, notes: [] }
window.electronAPI = { gitCompare: async () => ({ files: [{ path, status: 'modified', additions: 0, deletions: 0 }], additions: 0, deletions: 0 }), gitBranchList: async () => ({ branches: [] }), gitLog: async () => [] } as any
const host = document.createElement('div')
@@ -36,7 +36,6 @@ it.each([['a.png', 'image'], ['a.pdf', 'pdf'], ['a.docx', 'docx']])('routes revi
const open = host.querySelector('button[aria-label="Open file"]')!
expect(open).not.toBeNull()
await act(async () => open.click())
- expect(h.createDocument).toHaveBeenCalledWith('ws', '/repo/' + path, type, undefined, { target: 'dock', zone: 'right', stackId: 'review-stack' })
- expect(h.createEditor).not.toHaveBeenCalled()
+ expect(h.createEditor).toHaveBeenCalledWith('ws', '/repo/' + path, undefined, { target: 'dock', zone: 'right', stackId: 'review-stack' })
} finally { act(() => root.unmount()) }
})
diff --git a/src/renderer/panels/GitReviewPanel.tsx b/src/renderer/panels/GitReviewPanel.tsx
index 3e6ba9972..1e54adcff 100644
--- a/src/renderer/panels/GitReviewPanel.tsx
+++ b/src/renderer/panels/GitReviewPanel.tsx
@@ -25,6 +25,7 @@ import { openFileAsPanel } from '../lib/fs/fileRouting'
import { placementForPanel } from '../lib/workspace/canvasAccess'
import { AgentPickerPopover, ReviewActionButton, ReviewDisplayOptions, ReviewFileFilter, ReviewMenuButton, ReviewRunStatus, ReviewStats, ToolbarButton, type AgentChoice } from './ReviewControls'
import { HunkView, type NoteDraft } from './ReviewDiff'
+import { POPOVER_SURFACE } from '../ui/Popover'
interface BranchInfo {
name: string
@@ -890,7 +891,7 @@ export default function GitReviewPanel({ panelId, workspaceId }: PanelProps) {
setMoreOpen((open) => !open)}>
{moreOpen && (
-
+
{ updateDisplay({ fullFile: !reviewState.display.fullFile }); setDiffs({}) }}>
updateDisplay({ advancedPreview: !reviewState.display.advancedPreview })}>
diff --git a/src/renderer/panels/PanelHost.test.tsx b/src/renderer/panels/PanelHost.test.tsx
index 3d58af7f9..3fb8dd735 100644
--- a/src/renderer/panels/PanelHost.test.tsx
+++ b/src/renderer/panels/PanelHost.test.tsx
@@ -122,7 +122,7 @@ it('updates only changed leaf content without invalidating the canvas render cal
expect(host.textContent).toBe('Changedb')
})
-it.each(['canvas', 'terminal', 'editor', 'browser', 'agent', 'document', 'review', 'surface'] as const)('gates %s content before mounting without a workspace', (type) => {
+it.each(['canvas', 'terminal', 'editor', 'browser', 'agent', 'review', 'surface'] as const)('gates %s content before mounting without a workspace', (type) => {
const mounted = vi.fn()
function Content() { React.useEffect(mounted, []); return
Content }
registryMocks.renderPanelComponent.mockReturnValue(
)
diff --git a/src/renderer/panels/ReviewControls.tsx b/src/renderer/panels/ReviewControls.tsx
index 5264221a5..d568ccacc 100644
--- a/src/renderer/panels/ReviewControls.tsx
+++ b/src/renderer/panels/ReviewControls.tsx
@@ -6,6 +6,7 @@ import { useAppStore } from '../stores/appStore'
import { getAgentLogoById } from '../lib/agent/agentLogos'
import { Tooltip } from '../ui/Tooltip'
import { LoadingState, Spinner } from '../ui/Spinner'
+import { POPOVER_SURFACE } from '../ui/Popover'
export interface AgentChoice { agent: AgentDef; ready: boolean }
type AgentAction = { kind: 'review' | 'changes' }
@@ -120,7 +121,7 @@ export function AgentPickerPopover({
onConfirm: () => void
}) {
return (
-
+
{action.kind === 'review'
? 'Choose a terminal CLI to review this diff.'
diff --git a/src/renderer/panels/TerminalPanel.renderScale.test.tsx b/src/renderer/panels/TerminalPanel.renderScale.test.tsx
index a8a3c7e4a..238fbf581 100644
--- a/src/renderer/panels/TerminalPanel.renderScale.test.tsx
+++ b/src/renderer/panels/TerminalPanel.renderScale.test.tsx
@@ -130,7 +130,7 @@ vi.mock('../lib/terminal/terminalRegistry', () => {
// --- collaborators the panel pulls in but that are irrelevant here ----------
-const canvasState = { zoomLevel: 2.0 }
+const canvasState = { zoomLevel: 2.0, nodes: {} }
vi.mock('../stores/CanvasStoreContext', () => ({
useOptionalCanvasStoreApi: () => null,
// Selectors that read state this fake doesn't model fall back, as they do
diff --git a/src/renderer/panels/UrlSuggestions.tsx b/src/renderer/panels/UrlSuggestions.tsx
index ed423e81c..6df571ec9 100644
--- a/src/renderer/panels/UrlSuggestions.tsx
+++ b/src/renderer/panels/UrlSuggestions.tsx
@@ -3,6 +3,9 @@
// presentational: the parent (BrowserPanel) owns the query + active selection.
// =============================================================================
import type { BrowserHistoryEntry } from '../../shared/types'
+import { POPOVER_SURFACE } from '../ui/Popover'
+import { BrowserFavicon } from './BrowserFavicon'
+import { faviconForUrl } from './browserUrl'
interface Props {
items: BrowserHistoryEntry[]
@@ -14,7 +17,7 @@ interface Props {
export function UrlSuggestions({ items, activeIndex, onPick, onHover }: Props): JSX.Element | null {
if (items.length === 0) return null
return (
-
+
{items.map((item, i) => (
{ e.preventDefault(); onPick(item.url) }}
onMouseEnter={() => onHover(i)}
- className={`w-full flex items-center gap-2 px-3 py-1.5 text-left text-sm ${
+ className={`w-full flex items-center gap-2.5 rounded-lg px-2.5 h-9 text-left text-[13px] hover:bg-hover focus-visible:bg-hover transition-colors duration-100 motion-reduce:transition-none ${
i === activeIndex ? 'bg-hover' : ''
}`}
>
- {item.title || item.url}
- {item.url}
+
+ {item.title || item.url}
+
+ ·
+ {item.url.replace(/^https?:\/\/(?:www\.)?/, '').replace(/\/$/, '')}
+
))}
diff --git a/src/renderer/panels/documentBytes.ts b/src/renderer/panels/documentBytes.ts
index 5e5f1137c..b0bb9d33e 100644
--- a/src/renderer/panels/documentBytes.ts
+++ b/src/renderer/panels/documentBytes.ts
@@ -1,5 +1,5 @@
// =============================================================================
-// Pure byte helpers for DocumentPanel sub-viewers. Kept out of the React module
+// Pure byte helpers for FilePreview viewers. Kept out of the React module
// so they can be unit-tested without jsdom/pdfjs/mammoth.
// =============================================================================
diff --git a/src/renderer/panels/registry.ts b/src/renderer/panels/registry.ts
index 787d4fa5f..f83f8f8be 100644
--- a/src/renderer/panels/registry.ts
+++ b/src/renderer/panels/registry.ts
@@ -14,7 +14,7 @@ import { T3Logo } from '../ui/T3Logo'
// =============================================================================
import React, { type LazyExoticComponent, type ComponentType } from 'react'
-import { Terminal, Globe, Grid2X2 as SquaresFour, FileText as FileDoc, GitCompareArrows as GitDiff, type LucideIcon } from 'lucide-react'
+import { Terminal, Globe, Grid2X2 as SquaresFour, GitCompareArrows as GitDiff, type LucideIcon } from 'lucide-react'
import { Folders, Plus } from 'lucide-react'
import type { PanelType, Point, PanelState } from '../../shared/types'
import type { PanelPlacement } from '../stores/appStore'
@@ -35,7 +35,6 @@ const EditorPanel = React.lazy(() => import('./EditorPanel'))
const BrowserPanel = React.lazy(() => import('./BrowserPanel'))
const CanvasPanel = React.lazy(() => import('./CanvasPanel'))
const AgentPanel = React.lazy(() => import('./AgentPanel'))
-const DocumentPanel = React.lazy(() => import('./DocumentPanel'))
const ReviewPanel = React.lazy(() => import('./ReviewPanel'))
// -----------------------------------------------------------------------------
@@ -57,7 +56,6 @@ export interface PanelCreateArgs {
/** Terminal only. */
initialInput?: string
/** Document only. */
- documentType?: 'pdf' | 'docx' | 'image'
}
export interface RendererPanelDefinition extends SharedPanelDefinition {
@@ -159,14 +157,6 @@ export const PANEL_REGISTRY: Record
= {
trackCreated('agent', useAppStore.getState().createAgent(workspaceId, canvasPoint, placement, cwd, worktreeId) || null),
props: baseProps,
},
- document: {
- ...PANEL_DEFINITIONS.document,
- icon: FileDoc,
- Component: DocumentPanel,
- create: ({ workspaceId, canvasPoint, placement, filePath, documentType }) =>
- trackCreated('document', useAppStore.getState().createDocument(workspaceId, filePath, documentType, canvasPoint, placement) || null),
- props: (panel, ctx) => ({ ...baseProps(panel, ctx), filePath: panel.filePath }),
- },
review: {
...PANEL_DEFINITIONS.review,
icon: GitDiff,
@@ -211,7 +201,7 @@ export function renderPanelComponent(
panel: PanelState,
ctx: PanelRenderContext,
): React.ReactElement | null {
- const def = PANEL_REGISTRY[panel.type]
+ const def = getPanelDef(panel.type)
if (!def) return null
const { Component } = def
const props = def.props(panel, ctx) as PanelProps & Record
diff --git a/src/renderer/repository/RepositoryOverview.tsx b/src/renderer/repository/RepositoryOverview.tsx
index 7b96970b6..6dab3d2af 100644
--- a/src/renderer/repository/RepositoryOverview.tsx
+++ b/src/renderer/repository/RepositoryOverview.tsx
@@ -53,7 +53,7 @@ export default function RepositoryOverview() {
useUIStore.getState().openSettings('source control')}>
-
+
{repositories.length > 1 ?
setSelected(e.target.value)} className="max-w-full rounded-lg border border-subtle bg-surface-2 px-2 py-1 text-xl font-semibold">{repositories.map(path => {pathDisplayName(path)} )} :
{root ? pathDisplayName(root) : 'Your repositories'} }
diff --git a/src/renderer/repository/SourceControlView.tsx b/src/renderer/repository/SourceControlView.tsx
index 8317dc9d3..29e5ea30e 100644
--- a/src/renderer/repository/SourceControlView.tsx
+++ b/src/renderer/repository/SourceControlView.tsx
@@ -117,21 +117,21 @@ const Section: React.FC<{
if (count === 0) return null
return (
-
+
setOpen(!open)}
>
{open ?
:
}
{title}
-
{count}
+
{count}
{actions && (
e.stopPropagation()}>
{actions}
)}
- {open &&
{children}
}
+ {open &&
{children}
}
)
}
@@ -151,15 +151,15 @@ const FileEntry: React.FC<{
const dir = dirName(file.path)
return (
{statusChar}
-
+
{fileName(file.path)}
- {dir && {dir} }
+ {dir && {dir} }
{onDiscard && (
@@ -283,15 +283,15 @@ const BranchPicker: React.FC<{
const branchCount = branches.length || 1 // at least show current
return (
-
+
{/* Section header — matches Section component style */}
setIsOpen(!isOpen)}
>
{isOpen ?
:
}
Branches
-
{branchCount}
+
{branchCount}
{!isOpen && (
{currentBranch}
)}
@@ -300,7 +300,7 @@ const BranchPicker: React.FC<{
{isOpen && (
{/* Search / Create */}
-
+
{creating ? (
handleCheckout(b.name)}
>
@@ -379,11 +379,11 @@ const BranchPicker: React.FC<{
})}
{filtered(remoteBranches).length > 0 && (
<>
-
Remote
+
Remote
{filtered(remoteBranches).map(b => (
handleCheckout(b.name)}
>
@@ -670,8 +670,8 @@ const RepoSourceControl: React.FC
= ({ rootPath, workspa
return (
-
- {(['changes', 'branches', 'history', 'worktrees'] as const).map(value => setSection(value)}>{value === 'history' ? 'History' : value[0].toUpperCase() + value.slice(1)} )}
+
+ {(['changes', 'branches', 'history', 'worktrees'] as const).map(value => setSection(value)}>{value === 'history' ? 'History' : value[0].toUpperCase() + value.slice(1)} )}
{headerActions}
{section === 'changes' && (
@@ -701,11 +701,11 @@ const RepoSourceControl: React.FC = ({ rootPath, workspa
)}
-
+
{/* Commit area */}
{section === 'changes' && (
-
-
Commit staged changes
+
+
Commit staged changes
{stagedFiles.length} staged {stagedFiles.length === 1 ? 'file' : 'files'} · {status?.current ?? 'Detached HEAD'}