test(desktop): cover workspace panel persistence (#197)

This commit was merged in pull request #197.
This commit is contained in:
excho0 authored and GitHub committed 2026-10-14 13:27:31 +03:00
1 parent bb46d7d557
commit bc8d88c057
3 files changed
+378 -10

No files matched your search

+333
View File
@@ -88,6 +88,277 @@ test('a window with WebGL offers the topology, wide and narrow', async (t) => {
}
})
test('workspace panels retain their open state and widths after relaunch', async (t) => {
const profile = testProfile()
let app
try {
({ app } = await launch(node, profile))
let page = await app.firstWindow()
if (!await hasWebgl(page)) {
assert.ok(!process.env.CI, 'the window was given no WebGL, though it was started with WebGL drawn in software')
return t.skip('this machine gives the window no WebGL, even drawn in software, so panel persistence is not testable')
}
await wide(app, page)
await page.evaluate(() => localStorage.setItem('sisyphus-workspace-layout', JSON.stringify({ chat: 58, network: 42, historyOpen: false, historyWidth: 340, topologyOpen: true })))
await page.reload()
const topology = page.getByRole('separator', { name: 'Resize topology panel' })
const history = page.getByRole('separator', { name: 'Resize conversation history panel' })
await topology.waitFor()
await history.waitFor()
await page.waitForFunction(() => document.querySelector('.workspace-history-slot')?.getAttribute('data-open') === 'false')
await page.waitForFunction(() => document.querySelector('.workspace-topology-slot')?.getAttribute('data-open') === 'true')
const seeded = await page.evaluate(() => JSON.parse(localStorage.getItem('sisyphus-workspace-layout') || '{}'))
assert.equal(seeded.historyOpen, false)
assert.equal(seeded.historyWidth, 340)
assert.equal(seeded.topologyOpen, true)
assert.equal(seeded.network, 42)
await app.close()
app = undefined
;({ app, page } = await launch(node, profile))
await wide(app, page)
await page.waitForFunction(() => document.querySelector('.workspace-history-slot')?.getAttribute('data-open') === 'false')
await page.waitForFunction(() => document.querySelector('.workspace-topology-slot')?.getAttribute('data-open') === 'true')
const restored = await page.evaluate(() => JSON.parse(localStorage.getItem('sisyphus-workspace-layout') || '{}'))
assert.equal(restored.historyOpen, false)
assert.equal(restored.historyWidth, 340)
assert.equal(restored.topologyOpen, true)
assert.equal(restored.network, 42)
// The collapsed history handle remains available, and can bring its pane back.
const restoredHistory = page.getByRole('separator', { name: 'Resize conversation history panel' })
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()
}
})
test('a collapsed workspace panel reopens by dragging its visible handle', async (t) => {
const profile = testProfile()
const { app, page } = await launch(node, profile)
try {
if (!await hasWebgl(page)) {
assert.ok(!process.env.CI, 'the window was given no WebGL, though it was started with WebGL drawn in software')
return t.skip('this machine gives the window no WebGL, even drawn in software, so the topology handle is not available')
}
await wide(app, page)
const handle = page.getByRole('separator', { name: 'Resize topology panel' })
await handle.waitFor()
const slot = page.locator('.workspace-topology-slot')
await page.waitForFunction(() => document.querySelector('.workspace-topology-slot')?.getAttribute('data-open') === 'true')
const box = await handle.boundingBox()
assert.ok(box)
// Pull toward chat beyond the closing threshold. Pointer capture must keep
// the same drag alive even though the handle/panel geometry changes.
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
await page.mouse.down()
await page.mouse.move(box.x + box.width / 2 + 430, box.y + box.height / 2, { steps: 12 })
await page.waitForFunction(() => document.querySelector('.workspace-topology-slot')?.getAttribute('data-open') === 'false')
const closedHandle = await handle.boundingBox()
assert.ok(closedHandle, 'the collapsed resize handle disappeared')
// Keep holding the original pointer and drag back across the handle to reopen.
await page.mouse.move(closedHandle.x + closedHandle.width / 2 - 220, closedHandle.y + closedHandle.height / 2, { steps: 10 })
await page.waitForFunction(() => document.querySelector('.workspace-topology-slot')?.getAttribute('data-open') === 'true')
await page.mouse.up()
await page.waitForTimeout(400)
assert.equal(await slot.getAttribute('data-open'), 'true')
// Keyboard double-click equivalent: Enter on a focused, collapsed handle
// opens the same pane without requiring a pointer gesture.
await handle.focus()
await handle.press('Enter')
await page.waitForFunction(() => document.querySelector('.workspace-topology-slot')?.getAttribute('data-open') === 'false')
await handle.press('Enter')
await page.waitForFunction(() => document.querySelector('.workspace-topology-slot')?.getAttribute('data-open') === 'true')
} finally {
await app.close()
profile.remove()
}
})
test('the topology canvas stays mounted and stable through repeated advanced-route transitions', async (t) => {
const profile = testProfile()
const { app, page } = await launch(node, profile)
try {
if (!await hasWebgl(page)) {
assert.ok(!process.env.CI, 'the window was given no WebGL, though it was started with WebGL drawn in software')
return t.skip('this machine gives the window no WebGL, so the topology canvas is unavailable')
}
await wide(app, page)
const topology = page.getByRole('separator', { name: 'Resize topology panel' })
await topology.waitFor()
const canvas = page.locator('.workspace-network canvas')
await canvas.waitFor()
const identity = await canvas.evaluate((element) => ({ tag: element.tagName, width: element.width, height: element.height }))
const initialBounds = await canvas.boundingBox()
assert.ok(initialBounds && initialBounds.width > 0 && initialBounds.height > 0)
for (const route of ['Node overview', 'Operations', 'Settings', 'Workspace', 'Operations', 'Workspace', 'Settings', 'Workspace']) {
if (route === 'Workspace') {
await page.getByRole('button', { name: 'Open advanced settings' }).click().catch(() => {})
const workspaceLink = page.getByRole('complementary').getByRole('link', { name: 'Workspace', exact: true })
if (await workspaceLink.isVisible().catch(() => false)) await workspaceLink.click()
else await page.getByRole('button', { name: 'Open advanced settings' }).click()
} else {
await open(page, route)
}
await page.waitForTimeout(260)
assert.equal(await canvas.count(), 1, `the globe canvas was removed after navigating to ${route}`)
const current = await canvas.evaluate((element) => ({ tag: element.tagName, width: element.width, height: element.height }))
assert.equal(current.tag, identity.tag)
assert.equal(current.width, identity.width)
assert.equal(current.height, identity.height)
}
const finalBounds = await canvas.boundingBox()
assert.ok(finalBounds && finalBounds.width > 0 && finalBounds.height > 0)
} finally {
await app.close()
profile.remove()
}
})
test('the topology canvas stays fitted through live window resizes', async (t) => {
const profile = testProfile()
const { app, page } = await launch(node, profile)
try {
if (!await hasWebgl(page)) {
assert.ok(!process.env.CI, 'the window was given no WebGL, though it was started with WebGL drawn in software')
return t.skip('this machine gives the window no WebGL, so the topology canvas is unavailable')
}
const width = await wide(app, page)
assert.ok(width > 900)
const stage = page.locator('.network-globe-stage')
const canvas = stage.locator('canvas')
await canvas.waitFor()
const initialBox = await stage.boundingBox()
const initial = await canvas.evaluate((element) => ({ width: element.width, height: element.height }))
assert.ok(initialBox && initialBox.width > 0 && initialBox.height > 0)
assert.ok(initial.width > 0 && initial.height > 0)
let observedBufferResize = false
for (const [nextWidth, nextHeight] of [[1040, 720], [1280, 900], [980, 760], [1180, 800]]) {
await app.evaluate(({ BrowserWindow }, [w, h]) => BrowserWindow.getAllWindows()[0].setSize(w, h), [nextWidth, nextHeight])
await page.waitForFunction(([w, h]) => Math.abs(window.innerWidth - w) < 30 && Math.abs(window.innerHeight - h) < 80, [nextWidth, nextHeight])
await page.waitForFunction(() => {
const stageEl = document.querySelector('.network-globe-stage')
const canvasEl = stageEl?.querySelector('canvas')
if (!stageEl || !canvasEl) return false
const rect = stageEl.getBoundingClientRect()
return rect.width > 0 && rect.height > 0 && canvasEl.width > 0 && canvasEl.height > 0
})
const box = await stage.boundingBox()
const dimensions = await canvas.evaluate((element) => ({ width: element.width, height: element.height }))
if (dimensions.width !== initial.width || dimensions.height !== initial.height) observedBufferResize = true
assert.ok(box && box.width > 0 && box.height > 0, `globe stage collapsed at ${nextWidth}x${nextHeight}`)
assert.ok(dimensions.width > 0 && dimensions.height > 0, `WebGL drawing buffer collapsed at ${nextWidth}x${nextHeight}`)
assert.ok(Math.abs(box.width - box.height) <= 2, `globe viewport is not square at ${nextWidth}x${nextHeight}`)
}
const final = await canvas.evaluate((element) => ({ width: element.width, height: element.height }))
assert.ok(final.width > 0 && final.height > 0)
assert.ok(observedBufferResize, 'the WebGL drawing buffer never responded to any intermediate window size')
} finally {
await app.close()
profile.remove()
}
})
test('the workspace switches layouts live across tablet and desktop widths', async (t) => {
const profile = testProfile()
const { app, page } = await launch(node, profile)
try {
if (!await hasWebgl(page)) {
assert.ok(!process.env.CI, 'the window was given no WebGL, though it was started with WebGL drawn in software')
return t.skip('this machine gives the window no WebGL, so the two-view mobile layout is unavailable')
}
await wide(app, page)
const network = page.getByRole('img', { name: /This node/ })
await network.waitFor()
await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(820, 760))
await page.locator('.workspace-shell--compact').waitFor()
const dock = page.locator('.workspace-mobile-dock')
await dock.waitFor()
const dockButtons = dock.getByRole('button')
assert.equal(await dockButtons.count(), 2)
const dockBox = await dock.boundingBox()
const viewportWidth = await page.evaluate(() => window.innerWidth)
assert.ok(dockBox && dockBox.x >= 0 && dockBox.x + dockBox.width <= viewportWidth, 'the dock exceeds the tablet viewport')
assert.ok(Math.abs(dockBox.x + dockBox.width / 2 - viewportWidth / 2) <= 2, 'the dock is not centered in the tablet viewport')
const touch = await page.context().newCDPSession(page)
await touch.send('Emulation.setTouchEmulationEnabled', { enabled: true, maxTouchPoints: 1 })
await touch.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: Math.round(viewportWidth * 0.72), y: 360 }] })
await touch.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
await touch.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: Math.round(viewportWidth * 0.72), y: 360 }] })
await touch.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: Math.round(viewportWidth * 0.55), y: 360 }] })
await touch.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
await page.locator('.workspace-mobile-pane[data-active="true"]').getByRole('img', { name: /This node/ }).waitFor()
await touch.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: Math.round(viewportWidth * 0.45), y: 360 }] })
await touch.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: Math.round(viewportWidth * 0.65), y: 360 }] })
await touch.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
await page.locator('.workspace-mobile-pane[data-active="true"] textarea').waitFor()
const composer = page.locator('.workspace-mobile-pane[data-active="true"] textarea')
await composer.focus()
await page.waitForFunction(() => {
const dockElement = document.querySelector('.workspace-mobile-dock')
return dockElement && Number.parseFloat(getComputedStyle(dockElement).height) < 2
})
await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(820, 650))
await page.waitForFunction(() => window.innerHeight < 760)
const composerBox = await composer.boundingBox()
const viewportHeight = await page.evaluate(() => window.innerHeight)
assert.ok(composerBox && composerBox.y < viewportHeight && composerBox.y + composerBox.height <= viewportHeight, 'the focused composer fell below the resized viewport')
await composer.evaluate((element) => element.blur())
await page.waitForFunction(() => {
const dockElement = document.querySelector('.workspace-mobile-dock')
return dockElement && Number.parseFloat(getComputedStyle(dockElement).height) >= 70
})
await touch.detach()
await dock.getByRole('button', { name: 'Known network topology' }).click()
await page.locator('.workspace-mobile-pane[data-active="true"]').getByRole('img', { name: /This node/ }).waitFor()
await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(1180, 800))
await page.locator('.workspace-shell:not(.workspace-shell--compact)').waitFor()
await page.getByRole('separator', { name: 'Resize topology panel' }).waitFor()
await app.evaluate(({ BrowserWindow }) => BrowserWindow.getAllWindows()[0].setSize(680, 760))
await page.locator('.workspace-shell--compact').waitFor()
await dock.waitFor()
await page.locator('.workspace-mobile-pane[data-active="true"]').getByRole('img', { name: /This node/ }).waitFor()
const narrowDock = await dock.boundingBox()
const narrowWidth = await page.evaluate(() => window.innerWidth)
assert.ok(narrowDock && narrowDock.x >= 0 && narrowDock.x + narrowDock.width <= narrowWidth, 'the dock exceeds the narrow phone viewport')
assert.ok(Math.abs(narrowDock.x + narrowDock.width / 2 - narrowWidth / 2) <= 2, 'the dock is not centered after the live resize')
} finally {
await app.close()
profile.remove()
}
})
test('a job submitted in the window runs on the pool and shows its result', async () => {
const profile = testProfile()
const { app, page } = await launch(node, profile)
@@ -168,6 +439,68 @@ test('a language chosen with the keyboard turns the window round, and is still c
}
})
test('desktop Settings keeps the shell fixed and restores its own scroll position', async () => {
const profile = testProfile()
const { app, page } = await launch(node, profile)
try {
await wide(app, page)
await open(page, 'Settings')
const sidebar = page.locator('.app-shell-body--advanced > aside')
const header = page.locator('.app-global-header')
const main = page.locator('.app-shell--settings .app-main')
const settings = page.locator('.settings-page')
const settingsScroller = page.locator('.settings-page__scroll')
await settingsScroller.waitFor()
const layout = await page.evaluate(() => {
const sidebarRect = document.querySelector('.app-shell-body--advanced > aside').getBoundingClientRect()
const headerRect = document.querySelector('.app-global-header').getBoundingClientRect()
const mainElement = document.querySelector('.app-shell--settings .app-main')
const mainRect = mainElement.getBoundingClientRect()
const mainStyle = getComputedStyle(mainElement)
const settingsRect = document.querySelector('.settings-page').getBoundingClientRect()
const scroller = document.querySelector('.settings-page__scroll')
return {
sidebarRight: sidebarRect.right,
headerLeft: headerRect.left,
headerRight: headerRect.right,
mainLeft: mainRect.left,
mainRight: mainRect.right,
contentLeft: mainRect.left + Number.parseFloat(mainStyle.paddingLeft),
contentRight: mainRect.right - Number.parseFloat(mainStyle.paddingRight),
settingsLeft: settingsRect.left,
settingsRight: settingsRect.right,
documentHeight: document.documentElement.scrollHeight,
viewportHeight: window.innerHeight,
mainScrollHeight: document.querySelector('.app-shell--settings .app-main').scrollHeight,
mainClientHeight: document.querySelector('.app-shell--settings .app-main').clientHeight,
settingsScrollHeight: scroller.scrollHeight,
settingsClientHeight: scroller.clientHeight,
}
})
assert.ok(Math.abs(layout.headerLeft - layout.sidebarRight) <= 1, 'the global header has a gap from the sidebar')
assert.ok(Math.abs(layout.headerRight - layout.mainRight) <= 1, 'the header does not span the full content column')
assert.ok(Math.abs(layout.settingsLeft - layout.contentLeft) <= 1 && Math.abs(layout.settingsRight - layout.contentRight) <= 1, 'Settings is narrower than the full padded content width')
assert.ok(layout.documentHeight <= layout.viewportHeight + 2, 'the desktop document itself scrolls')
assert.ok(layout.mainScrollHeight <= layout.mainClientHeight + 2, 'the Settings main shell scrolls instead of its inner list')
assert.ok(layout.settingsScrollHeight > layout.settingsClientHeight, 'the settings list has no independent scroll area')
await settingsScroller.evaluate((element) => { element.scrollTop = Math.min(180, element.scrollHeight - element.clientHeight) })
await page.waitForFunction(() => Number(document.querySelector('.settings-page__scroll')?.scrollTop) > 40)
const savedSettingsTop = await page.locator('.settings-page__scroll').evaluate((element) => element.scrollTop)
await page.waitForFunction(() => Number(JSON.parse(localStorage.getItem('sisyphus-page-scroll-positions') ?? '{}')['/settings']) > 40)
await page.getByRole('complementary').getByRole('link', { name: 'Node overview', exact: true }).click()
await page.getByRole('heading', { name: 'Node overview', level: 1 }).waitFor()
await page.getByRole('complementary').getByRole('link', { name: 'Settings', exact: true }).click()
await settingsScroller.waitFor()
await page.waitForFunction((top) => Math.abs(Number(document.querySelector('.settings-page__scroll')?.scrollTop) - top) < 2, savedSettingsTop)
assert.ok(savedSettingsTop > 40, 'the Settings scroll position was not meaningfully saved')
} finally {
await app.close()
profile.remove()
}
})
test('a node that stops is said to be gone, and the window finds it again when it returns', async () => {
const profile = testProfile()
const { app, page } = await launch(node, profile)
+8 -1
View File
@@ -113,7 +113,14 @@ function AppRoutes({ snapshot, locale, setLocale, messages, direction, connected
const savePosition = () => {
if (!restored) return
scrollPositionsRef.current[location.pathname] = latestTop
try { window.localStorage.setItem(scrollPositionsStorageKey, JSON.stringify(scrollPositionsRef.current)) } catch { /* Storage can be unavailable in restricted browser contexts. */ }
try {
// Settings owns a separate desktop scroller but shares this storage key.
// Merge with the persisted map so a stale AppRoutes ref cannot erase
// the position Settings saved while another route is active.
const positions = { ...scrollPositionsRef.current, ...readScrollPositions(), [location.pathname]: latestTop }
scrollPositionsRef.current = positions
window.localStorage.setItem(scrollPositionsStorageKey, JSON.stringify(positions))
} catch { /* Storage can be unavailable in restricted browser contexts. */ }
}
const onScroll = () => {
if (!restored || scrollPathRef.current !== location.pathname) return
@@ -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(() => {
@@ -330,14 +332,17 @@ export function WorkspacePage({ snapshot, messages, direction, visible = true }:
event.preventDefault()
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 +375,17 @@ 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) {
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) setHistoryOpen((open) => !open)
} else {
historyLastTapRef.current = null
}
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId)
}
const keyHistoryResize = (event: ReactKeyboardEvent<HTMLButtonElement>) => {
@@ -398,12 +412,16 @@ export function WorkspacePage({ snapshot, messages, direction, visible = true }:
const startTopologyResize = (event: ReactPointerEvent<HTMLButtonElement>) => {
event.preventDefault()
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 +451,19 @@ 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) {
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) setTopologyOpen((open) => !open)
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId)
return
}
topologyLastTapRef.current = null
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 +518,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>