fix(desktop): make panel handle toggles reliable

This commit is contained in:
excho0 committed 2026-10-10 16:18:27 +03:00
1 parent 432b49d38b
commit b8f8a3e88c
2 files changed
+65 -10

No files matched your search

+22 -1
View File
@@ -127,8 +127,29 @@ test('workspace panels retain their open state and widths after relaunch', async
// The collapsed history handle remains available, and can bring its pane back.
const restoredHistory = page.getByRole('separator', { name: 'Resize conversation history panel' })
await restoredHistory.dblclick()
const maxHistoryWidth = Number(await restoredHistory.getAttribute('aria-valuemax'))
assert.ok(maxHistoryWidth >= 280, `history panel has no available width (${maxHistoryWidth})`)
await page.getByRole('button', { name: 'Open conversation history' }).click()
await page.waitForFunction(() => document.querySelector('.workspace-history-slot')?.getAttribute('data-open') === 'true')
await page.waitForFunction((minimum) => {
const slot = document.querySelector('.workspace-history-slot')
return slot?.getAttribute('data-open') === 'true' && slot.getBoundingClientRect().width >= minimum
}, Math.min(280, maxHistoryWidth))
const restoredWidth = await page.locator('.workspace-history-slot').evaluate((slot) => slot.getBoundingClientRect().width)
assert.ok(restoredWidth >= 280, `history panel reopened at ${restoredWidth}px`)
await page.locator('button[aria-pressed="true"][aria-label="Close conversation history"]').click()
await page.waitForFunction(() => document.querySelector('.workspace-history-slot')?.getAttribute('data-open') === 'false')
const historyHandleBox = await restoredHistory.boundingBox()
assert.ok(historyHandleBox, 'the collapsed history handle is visible')
const historyHandleX = historyHandleBox.x + historyHandleBox.width / 2
const historyHandleY = historyHandleBox.y + historyHandleBox.height / 2
await page.mouse.click(historyHandleX, historyHandleY)
await page.waitForTimeout(80)
await page.mouse.click(historyHandleX, historyHandleY)
await page.waitForFunction(() => {
const slot = document.querySelector('.workspace-history-slot')
return slot?.getAttribute('data-open') === 'true' && slot.getBoundingClientRect().width >= 280
}, Math.min(280, maxHistoryWidth))
} finally {
await app?.close()
profile.remove()
@@ -275,7 +275,8 @@ export function WorkspacePage({ snapshot, messages, direction, visible = true }:
const [historyResizing, setHistoryResizing] = useState(false)
const [historyPanelHost, setHistoryPanelHost] = useState<HTMLDivElement | null>(null)
const desktopStageRef = useRef<HTMLDivElement>(null)
const historyDragRef = useRef<{ pointerId: number; startX: number; startWidth: number } | null>(null)
const historyDragRef = useRef<{ pointerId: number; startX: number; startY: number; startWidth: number; dragging: boolean } | null>(null)
const historyLastTapRef = useRef<{ at: number; x: number; y: number } | null>(null)
const [topologyOpen, setTopologyOpen] = useState(savedLayout.topologyOpen)
const [topologyRatio, setTopologyRatio] = useState(() => {
const savedRatio = savedLayout.network / 100
@@ -283,7 +284,8 @@ export function WorkspacePage({ snapshot, messages, direction, visible = true }:
})
const [topologyResizing, setTopologyResizing] = useState(false)
const [desktopStageWidth, setDesktopStageWidth] = useState(() => window.innerWidth)
const topologyDragRef = useRef<{ pointerId: number; startX: number; startWidth: number; startRatio: number; startOpen: boolean } | null>(null)
const topologyDragRef = useRef<{ pointerId: number; startX: number; startY: number; startWidth: number; startRatio: number; startOpen: boolean; dragging: boolean } | null>(null)
const topologyLastTapRef = useRef<{ at: number; x: number; y: number } | null>(null)
const [globeReady, setGlobeReady] = useState(false)
const [touchStart, setTouchStart] = useState<{ x: number; y: number } | null>(null)
useEffect(() => {
@@ -328,16 +330,28 @@ export function WorkspacePage({ snapshot, messages, direction, visible = true }:
const topologyWidth = topologyAvailable && topologyOpen ? Math.min(panelAreaWidth * topologyRatio, topologyMaxWidth) : 0
const startHistoryResize = (event: ReactPointerEvent<HTMLButtonElement>) => {
event.preventDefault()
const previousTap = historyLastTapRef.current
const isDoubleTap = previousTap && Date.now() - previousTap.at < 450 && Math.hypot(event.clientX - previousTap.x, event.clientY - previousTap.y) < 12
historyLastTapRef.current = isDoubleTap ? null : { at: Date.now(), x: event.clientX, y: event.clientY }
if (isDoubleTap) {
historyDragRef.current = null
setHistoryResizing(false)
setHistoryOpen((open) => !open)
return
}
event.currentTarget.setPointerCapture(event.pointerId)
const startWidth = historyOpen ? visibleHistoryWidth : 0
historyDragRef.current = { pointerId: event.pointerId, startX: event.clientX, startWidth }
if (!historyOpen) setHistoryWidth(0)
historyDragRef.current = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, startWidth, dragging: false }
if (historyOpen) setHistoryOpen(true)
setHistoryResizing(true)
}
const moveHistoryResize = (event: ReactPointerEvent<HTMLButtonElement>) => {
const drag = historyDragRef.current
if (!drag || drag.pointerId !== event.pointerId) return
if (!drag.dragging) {
if (Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY) < 4) return
drag.dragging = true
setHistoryResizing(true)
}
const delta = (event.clientX - drag.startX) * (direction === 'rtl' ? -1 : 1)
const rawWidth = drag.startWidth + delta
const minimumWidth = Math.min(historyMinimumWidth, historyMaxWidth)
@@ -370,8 +384,10 @@ export function WorkspacePage({ snapshot, messages, direction, visible = true }:
const finishHistoryResize = (event: ReactPointerEvent<HTMLButtonElement>) => {
if (historyDragRef.current?.pointerId !== event.pointerId) return
if (historyOpen && historyWidth < historyMinimumWidth) setHistoryWidth(historyMinimumWidth)
const wasDragging = historyDragRef.current.dragging
historyDragRef.current = null
setHistoryResizing(false)
if (!wasDragging) return
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId)
}
const keyHistoryResize = (event: ReactKeyboardEvent<HTMLButtonElement>) => {
@@ -397,13 +413,26 @@ export function WorkspacePage({ snapshot, messages, direction, visible = true }:
}
const startTopologyResize = (event: ReactPointerEvent<HTMLButtonElement>) => {
event.preventDefault()
const previousTap = topologyLastTapRef.current
const isDoubleTap = previousTap && Date.now() - previousTap.at < 450 && Math.hypot(event.clientX - previousTap.x, event.clientY - previousTap.y) < 12
topologyLastTapRef.current = isDoubleTap ? null : { at: Date.now(), x: event.clientX, y: event.clientY }
if (isDoubleTap) {
topologyDragRef.current = null
setTopologyResizing(false)
setTopologyOpen((open) => !open)
return
}
event.currentTarget.setPointerCapture(event.pointerId)
topologyDragRef.current = { pointerId: event.pointerId, startX: event.clientX, startWidth: topologyWidth, startRatio: topologyRatio, startOpen: topologyOpen }
setTopologyResizing(true)
topologyDragRef.current = { pointerId: event.pointerId, startX: event.clientX, startY: event.clientY, startWidth: topologyWidth, startRatio: topologyRatio, startOpen: topologyOpen, dragging: false }
}
const moveTopologyResize = (event: ReactPointerEvent<HTMLButtonElement>) => {
const drag = topologyDragRef.current
if (!drag || drag.pointerId !== event.pointerId) return
if (!drag.dragging) {
if (Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY) < 4) return
drag.dragging = true
setTopologyResizing(true)
}
const delta = (event.clientX - drag.startX) * (direction === 'rtl' ? 1 : -1)
const rawWidth = drag.startWidth + delta
const minimumWidth = topologyMinimumWidth
@@ -433,9 +462,14 @@ export function WorkspacePage({ snapshot, messages, direction, visible = true }:
const finishTopologyResize = (event: ReactPointerEvent<HTMLButtonElement>) => {
const drag = topologyDragRef.current
if (!drag || drag.pointerId !== event.pointerId) return
const wasDragging = drag.dragging
if (topologyOpen && topologyWidth < topologyMinimumWidth && topologyWidth > 0) setTopologyRatio(Math.min(1, topologyMinimumWidth / Math.max(1, panelAreaWidth)))
topologyDragRef.current = null
setTopologyResizing(false)
if (!wasDragging) {
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId)
return
}
if (topologyOpen) {
const ratio = topologyWidth < topologyMinimumWidth && topologyWidth > 0 ? Math.min(1, topologyMinimumWidth / Math.max(1, panelAreaWidth)) : topologyRatio
saveLayout({ chat: (1 - ratio) * 100, network: ratio * 100, topologyOpen: true })
@@ -490,10 +524,10 @@ export function WorkspacePage({ snapshot, messages, direction, visible = true }:
{visible && paneItems.length > 1 && createPortal(<nav className="workspace-mobile-dock" aria-label={messages.workspace}><LiquidGlassDock items={paneItems} label={messages.workspace} onNavigate={(id) => setMobileView(id as 'chat' | 'network')} /></nav>, document.body)}
</> : <div ref={desktopStageRef} className="workspace-desktop-stage">
<div data-open={historyOpen} data-resizing={historyResizing} aria-hidden={!historyOpen} style={{ flexBasis: historyOpen ? visibleHistoryWidth : 0 }} className="workspace-history-slot"><div ref={setHistoryPanelHost} className="workspace-history-host" /></div>
<button type="button" role="separator" aria-orientation="vertical" aria-label={messages.resizeHistory} aria-valuemin={0} aria-valuemax={Math.round(historyMaxWidth)} aria-valuenow={Math.round(visibleHistoryWidth)} onPointerDown={startHistoryResize} onPointerMove={moveHistoryResize} onPointerUp={finishHistoryResize} onPointerCancel={finishHistoryResize} onKeyDown={keyHistoryResize} onDoubleClick={(event) => { event.preventDefault(); setHistoryOpen((open) => !open) }} className="workspace-history-resize-handle" />
<button type="button" role="separator" aria-orientation="vertical" aria-label={messages.resizeHistory} aria-valuemin={0} aria-valuemax={Math.round(historyMaxWidth)} aria-valuenow={Math.round(visibleHistoryWidth)} onPointerDown={startHistoryResize} onPointerMove={moveHistoryResize} onPointerUp={finishHistoryResize} onPointerCancel={finishHistoryResize} onKeyDown={keyHistoryResize} className="workspace-history-resize-handle" />
<div className="workspace-chat-desktop-pane">{chatPane}</div>
{topologyAvailable && <>
<button type="button" role="separator" aria-orientation="vertical" aria-label={messages.resizeTopology} aria-valuemin={0} aria-valuemax={Math.round(topologyMaxWidth)} aria-valuenow={Math.round(topologyWidth)} onPointerDown={startTopologyResize} onPointerMove={moveTopologyResize} onPointerUp={finishTopologyResize} onPointerCancel={finishTopologyResize} onKeyDown={keyTopologyResize} onDoubleClick={(event) => { event.preventDefault(); setTopologyOpen((open) => !open) }} className="workspace-topology-resize-handle"><span /></button>
<button type="button" role="separator" aria-orientation="vertical" aria-label={messages.resizeTopology} aria-valuemin={0} aria-valuemax={Math.round(topologyMaxWidth)} aria-valuenow={Math.round(topologyWidth)} onPointerDown={startTopologyResize} onPointerMove={moveTopologyResize} onPointerUp={finishTopologyResize} onPointerCancel={finishTopologyResize} onKeyDown={keyTopologyResize} className="workspace-topology-resize-handle"><span /></button>
<div data-open={topologyOpen} data-resizing={topologyResizing} aria-hidden={!topologyOpen} style={{ flexBasis: topologyWidth }} className="workspace-topology-slot">{networkPane}</div>
</>}
</div>