mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 00:02:31 +00:00
fix: persist floating terminal panel bounds (#3828)
Add bounds normalization and persistence for the floating terminal panel, covering default placement, drag/resize persistence, maximize restore behavior, and viewport clamping. Design: docs/floating-terminal-panel-position-persistence.md
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
# Floating Terminal Panel Position Persistence
|
||||
|
||||
## Problem
|
||||
|
||||
The floating workspace panel appears in a different default location after app restart instead of returning to the user's last dragged or resized placement.
|
||||
|
||||
- `src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx:132` initializes panel `bounds` from `getDefaultFloatingTerminalBounds()` on every renderer mount.
|
||||
- `src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx:349` only normalizes that in-memory initial state when the panel opens; it does not read or write durable position state.
|
||||
- `src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx:1039` updates bounds while dragging, and `src/renderer/src/components/floating-terminal/FloatingTerminalResizeHandles.tsx:98` updates bounds while resizing, but both changes remain React state only.
|
||||
- The toggle button already persists its own location through localStorage in `src/renderer/src/components/floating-terminal/FloatingTerminalToggleButton.tsx:33`, so the inconsistency is isolated to the larger panel.
|
||||
|
||||
## Root Cause
|
||||
|
||||
Panel geometry is transient renderer state. Restarting Orca remounts `FloatingTerminalPanel`, so the panel recomputes from the current viewport instead of restoring the last user placement. The component also has no source tracking, so its legacy right-gap normalization cannot distinguish a default position from an intentional user drag.
|
||||
|
||||
## Non-Goals
|
||||
|
||||
- Do not change terminal, browser, or markdown tab persistence.
|
||||
- Do not persist remote or SSH-specific state; panel geometry is local renderer chrome.
|
||||
- Do not persist maximized panel bounds as the normal restored size.
|
||||
- Do not add new settings UI or change design tokens.
|
||||
|
||||
## Design
|
||||
|
||||
1. Add floating panel bounds persistence helpers beside the existing panel bounds math:
|
||||
- use a panel-specific versioned key, e.g. `orca-floating-terminal-panel-bounds-v1`;
|
||||
- parse only finite `left`, `top`, `width`, and `height` numbers;
|
||||
- distinguish `default` versus `user` bounds sources;
|
||||
- expose a panel viewport-usability guard so saved user bounds are not clamped against Electron's transient zero-sized startup viewport;
|
||||
- expand clamping to normalize both position and size. The current `clampFloatingTerminalBounds` only clamps `left` and `top`; persisted restores also need the resize-handle width/height caps.
|
||||
2. Read and write storage defensively:
|
||||
- `window` absence or `localStorage` get/set failures fall back to in-memory behavior for the session;
|
||||
- malformed, partial, non-finite, or non-object JSON falls back to default bounds.
|
||||
3. Initialize `FloatingTerminalPanel` once from persisted bounds when present, otherwise from the current default. Store the initial source in a ref, mirroring the toggle button pattern.
|
||||
4. Reconcile bounds in `useLayoutEffect` before first paint and on viewport resize:
|
||||
- default-sourced bounds re-anchor to the current bottom-right default;
|
||||
- user-sourced bounds clamp into the visible viewport and persist the clamped result only after the viewport is usable;
|
||||
- remove `normalizedInitialBoundsRef` and the `rightGap > 160` reset. That heuristic is superseded by source-aware reconciliation and would otherwise wipe valid saved left-side placements.
|
||||
5. Replace direct `setBounds` calls for user-driven placement changes with a panel-local geometry updater:
|
||||
- drag and resize pointer moves should clamp and update React state for smooth feedback, but stage the latest bounds in a ref;
|
||||
- pointer up and pointer cancel commit the staged bounds to storage only after movement produced staged geometry. `localStorage.setItem` is synchronous, so do not write on every pointer move or after a titlebar click with no movement;
|
||||
- the panel-level `onMouseUp` size capture must not convert a default-sourced panel into a user-sourced panel after an ordinary click. Commit measured dimensions only after a real geometry interaction, or when the panel was already user-sourced;
|
||||
- resize handles should receive explicit preview/commit callbacks instead of the raw React setter, so resize, drag, and restore use the same persistence rules.
|
||||
6. When entering maximized mode, store the pre-maximized bounds and source in memory only. Do not persist the maximized rectangle. While maximized, viewport resize should recompute maximized bounds without touching the stored normal bounds or source. On restore, return to the stored normal bounds, reconcile them through the same source-aware rules, and persist only if the restored source is `user`.
|
||||
|
||||
## Consistency Model
|
||||
|
||||
- Persistence is a restart seed, not live cross-window synchronization.
|
||||
- Multiple renderer windows share the same localStorage key and therefore use last-writer-wins across restarts/reloads. Do not subscribe to `storage` events for live updates; another window or DevTools edit should not move an open panel mid-drag.
|
||||
- External storage mutations are picked up on the next renderer mount or reload.
|
||||
- The state is local renderer chrome. It must not go through worktree settings, terminal state, or SSH-backed runtime APIs.
|
||||
|
||||
## Edge Cases
|
||||
|
||||
- Malformed or partial localStorage JSON falls back to default bounds.
|
||||
- Unavailable localStorage should not break the floating workspace; persistence simply becomes best-effort.
|
||||
- Startup can briefly report a zero-sized renderer; saved user bounds must not be clamped to that unusable viewport.
|
||||
- A saved position from a larger monitor should be clamped back on-screen on the current monitor.
|
||||
- A saved size larger than the current viewport should shrink to the largest size that still leaves a small visible margin while respecting minimum panel dimensions.
|
||||
- A viewport smaller than the minimum panel size should keep the panel at the minimum size and keep its top-left corner reachable.
|
||||
- Maximized mode should not overwrite the normal saved size and position.
|
||||
- Plain clicks inside a default-positioned panel should not make that default position sticky forever.
|
||||
- Pointer cancellation should commit the last valid drag or resize bounds, matching pointer-up behavior.
|
||||
- SSH-backed terminals keep using the same floating workspace UI; geometry persistence stays local and does not assume local command execution.
|
||||
|
||||
## Rollout
|
||||
|
||||
1. Extend `floating-terminal-panel-bounds.ts` with parse, source, viewport usability, resolve, and size-aware clamp helpers plus focused unit coverage.
|
||||
2. Wire `FloatingTerminalPanel` to read, reconcile, and persist panel bounds through those helpers; remove the legacy right-gap normalization.
|
||||
3. Update `FloatingTerminalResizeHandles` to use preview/commit callbacks so drag, resize, measured-size capture, and maximize restore share one persistence path.
|
||||
4. Add component coverage for persisted startup, bad storage, zero-sized startup deferral, default re-anchoring, user clamping, and the "plain click does not persist default" case.
|
||||
5. Run focused Vitest coverage, then `pnpm typecheck` and `pnpm lint`.
|
||||
6. Verify in Electron by moving/resizing the panel, reloading or restarting the dev app, checking maximized restore, and confirming evidence screenshots are not committed.
|
||||
@@ -6,6 +6,13 @@ import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
|
||||
import type { BrowserTab, Tab, TabGroup, TerminalTab } from '../../../../shared/types'
|
||||
import type { OpenFile } from '@/store/slices/editor'
|
||||
import { createUntitledMarkdownFile } from '@/lib/create-untitled-markdown'
|
||||
import {
|
||||
FLOATING_TERMINAL_PANEL_BOUNDS_STORAGE_KEY,
|
||||
clampFloatingTerminalBounds,
|
||||
getDefaultFloatingTerminalBounds,
|
||||
getMaximizedFloatingTerminalBounds,
|
||||
type FloatingTerminalPanelBounds
|
||||
} from './floating-terminal-panel-bounds'
|
||||
|
||||
type EffectCallback = () => void | (() => void)
|
||||
|
||||
@@ -59,6 +66,7 @@ type FloatingPanelStoreState = {
|
||||
|
||||
const hookRuntime = vi.hoisted(() => ({
|
||||
effects: [] as EffectCallback[],
|
||||
layoutEffects: [] as EffectCallback[],
|
||||
index: 0,
|
||||
values: [] as unknown[]
|
||||
}))
|
||||
@@ -105,6 +113,9 @@ vi.mock('react', async () => {
|
||||
useEffect: (effect: EffectCallback) => {
|
||||
hookRuntime.effects.push(effect)
|
||||
},
|
||||
useLayoutEffect: (effect: EffectCallback) => {
|
||||
hookRuntime.layoutEffects.push(effect)
|
||||
},
|
||||
useMemo: <T,>(factory: () => T) => factory(),
|
||||
useRef: <T,>(initialValue: T) => {
|
||||
const index = hookRuntime.index
|
||||
@@ -288,12 +299,6 @@ vi.mock('./FloatingTerminalWindowControls', () => ({
|
||||
}
|
||||
}))
|
||||
|
||||
vi.mock('./floating-terminal-panel-bounds', () => ({
|
||||
clampFloatingTerminalBounds: (bounds: unknown) => bounds,
|
||||
getDefaultFloatingTerminalBounds: () => ({ height: 480, left: 20, top: 20, width: 720 }),
|
||||
getMaximizedFloatingTerminalBounds: () => ({ height: 700, left: 0, top: 0, width: 1000 })
|
||||
}))
|
||||
|
||||
function makeTab(overrides: Partial<TerminalTab> = {}): TerminalTab {
|
||||
return {
|
||||
id: overrides.id ?? 'tab-1',
|
||||
@@ -465,6 +470,10 @@ function findByProp(node: unknown, propName: string): ReactElementLike {
|
||||
}
|
||||
|
||||
function runEffects(): void {
|
||||
const layoutEffects = hookRuntime.layoutEffects.splice(0)
|
||||
for (const effect of layoutEffects) {
|
||||
effect()
|
||||
}
|
||||
const effects = hookRuntime.effects.splice(0)
|
||||
for (const effect of effects) {
|
||||
effect()
|
||||
@@ -490,6 +499,33 @@ async function renderPanel(open: boolean, onOpenChange = vi.fn()): Promise<unkno
|
||||
return FloatingTerminalPanel({ open, onOpenChange })
|
||||
}
|
||||
|
||||
function getPanelStyleBounds(element: unknown): FloatingTerminalPanelBounds {
|
||||
const panel = findByProp(element, 'data-floating-terminal-panel')
|
||||
const style = panel.props.style as Record<string, number>
|
||||
return {
|
||||
left: style.left,
|
||||
top: style.top,
|
||||
width: style.width,
|
||||
height: style.height
|
||||
}
|
||||
}
|
||||
|
||||
function getMockedLocalStorage(): {
|
||||
getItem: ReturnType<typeof vi.fn>
|
||||
setItem: ReturnType<typeof vi.fn>
|
||||
} {
|
||||
return window.localStorage as unknown as {
|
||||
getItem: ReturnType<typeof vi.fn>
|
||||
setItem: ReturnType<typeof vi.fn>
|
||||
}
|
||||
}
|
||||
|
||||
function setViewport(width: number, height: number): void {
|
||||
const viewport = window as unknown as { innerHeight: number; innerWidth: number }
|
||||
viewport.innerWidth = width
|
||||
viewport.innerHeight = height
|
||||
}
|
||||
|
||||
function makeMacShortcutKeyEvent({
|
||||
key,
|
||||
preventDefault = vi.fn(),
|
||||
@@ -523,6 +559,7 @@ describe('FloatingTerminalPanel close behavior', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
hookRuntime.effects = []
|
||||
hookRuntime.layoutEffects = []
|
||||
hookRuntime.index = 0
|
||||
hookRuntime.values = []
|
||||
saveDialogBox.fileId = null
|
||||
@@ -535,6 +572,11 @@ describe('FloatingTerminalPanel close behavior', () => {
|
||||
mocks.getInstallStatus.mockResolvedValue({ state: 'installed', pathConfigured: true })
|
||||
mocks.isWebRuntimeSessionActive.mockReturnValue(false)
|
||||
mocks.pickFloatingMarkdownDocument.mockResolvedValue(null)
|
||||
const localStorage = {
|
||||
getItem: vi.fn(() => null),
|
||||
removeItem: vi.fn(),
|
||||
setItem: vi.fn()
|
||||
}
|
||||
vi.stubGlobal('window', {
|
||||
addEventListener: vi.fn(),
|
||||
api: {
|
||||
@@ -547,7 +589,9 @@ describe('FloatingTerminalPanel close behavior', () => {
|
||||
cli: { getInstallStatus: mocks.getInstallStatus },
|
||||
ui: { setFloatingTerminalInputFocused: vi.fn() }
|
||||
},
|
||||
innerHeight: 800,
|
||||
innerWidth: 1200,
|
||||
localStorage,
|
||||
requestAnimationFrame: vi.fn((callback: FrameRequestCallback) => {
|
||||
callback(0)
|
||||
return 1
|
||||
@@ -556,13 +600,155 @@ describe('FloatingTerminalPanel close behavior', () => {
|
||||
})
|
||||
vi.stubGlobal('navigator', { userAgent: 'Macintosh' })
|
||||
vi.stubGlobal('HTMLElement', class {})
|
||||
vi.stubGlobal('localStorage', { setItem: vi.fn() })
|
||||
vi.stubGlobal('localStorage', localStorage)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
it('starts from persisted user bounds when storage has valid geometry', async () => {
|
||||
const savedBounds = { left: 120, top: 96, width: 760, height: 420 }
|
||||
getMockedLocalStorage().getItem.mockImplementation((key: string) =>
|
||||
key === FLOATING_TERMINAL_PANEL_BOUNDS_STORAGE_KEY ? JSON.stringify(savedBounds) : null
|
||||
)
|
||||
|
||||
const element = await renderPanel(true)
|
||||
|
||||
expect(getPanelStyleBounds(element)).toEqual(savedBounds)
|
||||
})
|
||||
|
||||
it('falls back to default bounds when persisted geometry is malformed', async () => {
|
||||
getMockedLocalStorage().getItem.mockImplementation((key: string) =>
|
||||
key === FLOATING_TERMINAL_PANEL_BOUNDS_STORAGE_KEY
|
||||
? '{"left":120,"top":96,"width":760}'
|
||||
: null
|
||||
)
|
||||
|
||||
const element = await renderPanel(true)
|
||||
|
||||
expect(getPanelStyleBounds(element)).toEqual(getDefaultFloatingTerminalBounds())
|
||||
})
|
||||
|
||||
it('defers saved user-bound clamping while the startup viewport is zero-sized', async () => {
|
||||
const savedBounds = { left: 900, top: 500, width: 760, height: 420 }
|
||||
setViewport(0, 0)
|
||||
getMockedLocalStorage().getItem.mockImplementation((key: string) =>
|
||||
key === FLOATING_TERMINAL_PANEL_BOUNDS_STORAGE_KEY ? JSON.stringify(savedBounds) : null
|
||||
)
|
||||
|
||||
let element = await renderPanel(true)
|
||||
expect(getPanelStyleBounds(element)).toEqual(savedBounds)
|
||||
|
||||
runEffects()
|
||||
element = await renderPanel(true)
|
||||
|
||||
expect(getPanelStyleBounds(element)).toEqual(savedBounds)
|
||||
expect(getMockedLocalStorage().setItem).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('re-anchors default bounds when the viewport becomes usable', async () => {
|
||||
setViewport(0, 0)
|
||||
await renderPanel(true)
|
||||
setViewport(1200, 800)
|
||||
|
||||
runEffects()
|
||||
const element = await renderPanel(true)
|
||||
|
||||
expect(getPanelStyleBounds(element)).toEqual(getDefaultFloatingTerminalBounds())
|
||||
expect(getMockedLocalStorage().setItem).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('clamps saved user bounds into the current viewport and persists the clamped result', async () => {
|
||||
const savedBounds = { left: 2000, top: 1200, width: 1000, height: 700 }
|
||||
setViewport(800, 600)
|
||||
getMockedLocalStorage().getItem.mockImplementation((key: string) =>
|
||||
key === FLOATING_TERMINAL_PANEL_BOUNDS_STORAGE_KEY ? JSON.stringify(savedBounds) : null
|
||||
)
|
||||
const expectedBounds = clampFloatingTerminalBounds(savedBounds)
|
||||
|
||||
let element = await renderPanel(true)
|
||||
expect(getPanelStyleBounds(element)).toEqual(savedBounds)
|
||||
|
||||
runEffects()
|
||||
element = await renderPanel(true)
|
||||
|
||||
expect(getPanelStyleBounds(element)).toEqual(expectedBounds)
|
||||
expect(getMockedLocalStorage().setItem).toHaveBeenCalledWith(
|
||||
FLOATING_TERMINAL_PANEL_BOUNDS_STORAGE_KEY,
|
||||
JSON.stringify(expectedBounds)
|
||||
)
|
||||
})
|
||||
|
||||
it('does not persist a plain click on a default-positioned panel', async () => {
|
||||
const element = await renderPanel(true)
|
||||
const panel = findByProp(element, 'data-floating-terminal-panel')
|
||||
|
||||
;(panel.props.onMouseUp as (event: unknown) => void)({
|
||||
currentTarget: {
|
||||
getBoundingClientRect: () => ({ height: 560, width: 920 })
|
||||
}
|
||||
})
|
||||
|
||||
expect(getMockedLocalStorage().setItem).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('commits the last dragged bounds on pointer cancellation', async () => {
|
||||
const element = await renderPanel(true)
|
||||
const titlebar = findByProp(element, 'data-floating-terminal-shortcut-surface')
|
||||
const titlebarTarget = { closest: vi.fn().mockReturnValue(null) }
|
||||
Object.setPrototypeOf(titlebarTarget, HTMLElement.prototype)
|
||||
vi.stubGlobal('document', { activeElement: null })
|
||||
const startBounds = getDefaultFloatingTerminalBounds()
|
||||
const expectedBounds = clampFloatingTerminalBounds({
|
||||
...startBounds,
|
||||
left: startBounds.left + 24,
|
||||
top: startBounds.top + 12
|
||||
})
|
||||
|
||||
;(titlebar.props.onPointerDown as (event: unknown) => void)({
|
||||
button: 0,
|
||||
clientX: 10,
|
||||
clientY: 20,
|
||||
currentTarget: { setPointerCapture: vi.fn() },
|
||||
pointerId: 1,
|
||||
target: titlebarTarget
|
||||
})
|
||||
;(titlebar.props.onPointerMove as (event: unknown) => void)({
|
||||
clientX: 34,
|
||||
clientY: 32,
|
||||
pointerId: 1
|
||||
})
|
||||
;(titlebar.props.onPointerCancel as (event: unknown) => void)({ pointerId: 1 })
|
||||
|
||||
expect(getMockedLocalStorage().setItem).toHaveBeenCalledWith(
|
||||
FLOATING_TERMINAL_PANEL_BOUNDS_STORAGE_KEY,
|
||||
JSON.stringify(expectedBounds)
|
||||
)
|
||||
})
|
||||
|
||||
it('does not persist maximized bounds over the saved normal bounds', async () => {
|
||||
const savedBounds = { left: 120, top: 96, width: 760, height: 420 }
|
||||
getMockedLocalStorage().getItem.mockImplementation((key: string) =>
|
||||
key === FLOATING_TERMINAL_PANEL_BOUNDS_STORAGE_KEY ? JSON.stringify(savedBounds) : null
|
||||
)
|
||||
|
||||
let element = await renderPanel(true)
|
||||
const controls = findByTypeName(element, 'FloatingTerminalWindowControls')
|
||||
;(controls.props.onToggleMaximized as () => void)()
|
||||
|
||||
element = await renderPanel(true)
|
||||
expect(getPanelStyleBounds(element)).toEqual(getMaximizedFloatingTerminalBounds())
|
||||
expect(getMockedLocalStorage().setItem).not.toHaveBeenCalled()
|
||||
|
||||
const restoredControls = findByTypeName(element, 'FloatingTerminalWindowControls')
|
||||
;(restoredControls.props.onToggleMaximized as () => void)()
|
||||
element = await renderPanel(true)
|
||||
|
||||
expect(getPanelStyleBounds(element)).toEqual(savedBounds)
|
||||
expect(getMockedLocalStorage().setItem).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('does not bootstrap a terminal tab when the panel opens empty', async () => {
|
||||
await renderPanel(false)
|
||||
runEffects()
|
||||
|
||||
@@ -2,7 +2,16 @@
|
||||
* resizing, orchestration setup, and mixed terminal/browser/editor tab
|
||||
* handling in one surface so the floating worktree does not drift from the
|
||||
* main tab model while still keeping the DOM-mounted panes local. */
|
||||
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import {
|
||||
lazy,
|
||||
Suspense,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState
|
||||
} from 'react'
|
||||
import { FileText, Globe, Minus, TerminalSquare } from 'lucide-react'
|
||||
import { toast } from 'sonner'
|
||||
import BrowserPane from '@/components/browser-pane/BrowserPane'
|
||||
@@ -73,7 +82,12 @@ import {
|
||||
clampFloatingTerminalBounds,
|
||||
getDefaultFloatingTerminalBounds,
|
||||
getMaximizedFloatingTerminalBounds,
|
||||
type FloatingTerminalPanelBounds
|
||||
persistFloatingTerminalPanelBounds,
|
||||
readPersistedFloatingTerminalPanelBounds,
|
||||
resolveFloatingTerminalPanelBounds,
|
||||
shouldReconcileFloatingTerminalPanelBounds,
|
||||
type FloatingTerminalPanelBounds,
|
||||
type FloatingTerminalPanelBoundsSource
|
||||
} from './floating-terminal-panel-bounds'
|
||||
const EMPTY_TERMINAL_TABS: TerminalTab[] = []
|
||||
const EMPTY_BROWSER_TABS: BrowserTabState[] = []
|
||||
@@ -92,10 +106,41 @@ const FLOATING_TERMINAL_NO_DRAG_SELECTOR =
|
||||
'button,input,textarea,select,[role="menuitem"],[data-testid="sortable-tab"],[data-floating-terminal-no-drag]'
|
||||
const FLOATING_TERMINAL_SHORTCUT_SURFACE_SELECTOR = '[data-floating-terminal-shortcut-surface]'
|
||||
|
||||
type FloatingTerminalPanelBoundsState = {
|
||||
bounds: FloatingTerminalPanelBounds
|
||||
source: FloatingTerminalPanelBoundsSource
|
||||
}
|
||||
|
||||
function isFloatingTerminalDragTarget(target: EventTarget): boolean {
|
||||
return !(target instanceof HTMLElement && target.closest(FLOATING_TERMINAL_NO_DRAG_SELECTOR))
|
||||
}
|
||||
|
||||
function readInitialPanelBounds(): FloatingTerminalPanelBoundsState {
|
||||
const persistedBounds = readPersistedFloatingTerminalPanelBounds()
|
||||
return persistedBounds
|
||||
? {
|
||||
bounds: persistedBounds,
|
||||
source: 'user'
|
||||
}
|
||||
: {
|
||||
bounds: getDefaultFloatingTerminalBounds(),
|
||||
source: 'default'
|
||||
}
|
||||
}
|
||||
|
||||
function areFloatingTerminalPanelBoundsEqual(
|
||||
left: FloatingTerminalPanelBounds | null,
|
||||
right: FloatingTerminalPanelBounds
|
||||
): boolean {
|
||||
return (
|
||||
left !== null &&
|
||||
left.left === right.left &&
|
||||
left.top === right.top &&
|
||||
left.width === right.width &&
|
||||
left.height === right.height
|
||||
)
|
||||
}
|
||||
|
||||
export function FloatingTerminalPanel({
|
||||
open,
|
||||
onOpenChange
|
||||
@@ -129,14 +174,24 @@ export function FloatingTerminalPanel({
|
||||
|
||||
const [cwd, setCwd] = useState<string | null>(null)
|
||||
const [markdownCwd, setMarkdownCwd] = useState<string | null>(null)
|
||||
const [bounds, setBounds] = useState(() => getDefaultFloatingTerminalBounds())
|
||||
const initialBoundsStateRef = useRef<FloatingTerminalPanelBoundsState | null>(null)
|
||||
if (initialBoundsStateRef.current === null) {
|
||||
initialBoundsStateRef.current = readInitialPanelBounds()
|
||||
}
|
||||
const boundsSourceRef = useRef<FloatingTerminalPanelBoundsSource>(
|
||||
initialBoundsStateRef.current.source
|
||||
)
|
||||
const [bounds, setBounds] = useState(initialBoundsStateRef.current.bounds)
|
||||
const [maximized, setMaximized] = useState(false)
|
||||
const [orchestrationDialogOpen, setOrchestrationDialogOpen] = useState(false)
|
||||
const [showOrchestrationSetup, setShowOrchestrationSetup] = useState(
|
||||
() => !hasOrchestrationSetupMarker() && !isOrchestrationSetupDismissed()
|
||||
)
|
||||
const restoreBoundsRef = useRef<FloatingTerminalPanelBounds | null>(null)
|
||||
const normalizedInitialBoundsRef = useRef(false)
|
||||
const restoreBoundsRef = useRef<FloatingTerminalPanelBoundsState | null>(null)
|
||||
const stagedBoundsRef = useRef<FloatingTerminalPanelBounds | null>(null)
|
||||
const lastPersistedBoundsRef = useRef<FloatingTerminalPanelBounds | null>(
|
||||
initialBoundsStateRef.current.source === 'user' ? initialBoundsStateRef.current.bounds : null
|
||||
)
|
||||
const pendingEditorCloseQueueRef = useRef<string[]>([])
|
||||
const saveDialogFileIdRef = useRef<string | null>(null)
|
||||
const panelRef = useRef<HTMLDivElement | null>(null)
|
||||
@@ -147,8 +202,8 @@ export function FloatingTerminalPanel({
|
||||
pointerId: number
|
||||
startX: number
|
||||
startY: number
|
||||
left: number
|
||||
top: number
|
||||
bounds: FloatingTerminalPanelBounds
|
||||
moved: boolean
|
||||
} | null>(null)
|
||||
|
||||
const tabs = tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? EMPTY_TERMINAL_TABS
|
||||
@@ -346,16 +401,63 @@ export function FloatingTerminalPanel({
|
||||
handleSaveDialogCancel()
|
||||
}, [handleSaveDialogCancel])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || normalizedInitialBoundsRef.current || typeof window === 'undefined') {
|
||||
const persistUserBounds = useCallback((nextBounds: FloatingTerminalPanelBounds): void => {
|
||||
if (areFloatingTerminalPanelBoundsEqual(lastPersistedBoundsRef.current, nextBounds)) {
|
||||
return
|
||||
}
|
||||
normalizedInitialBoundsRef.current = true
|
||||
const rightGap = window.innerWidth - bounds.left - bounds.width
|
||||
if (rightGap > 160) {
|
||||
setBounds(getDefaultFloatingTerminalBounds())
|
||||
lastPersistedBoundsRef.current = nextBounds
|
||||
persistFloatingTerminalPanelBounds(nextBounds)
|
||||
}, [])
|
||||
|
||||
const previewUserBounds = useCallback((nextBounds: FloatingTerminalPanelBounds): void => {
|
||||
const clampedBounds = clampFloatingTerminalBounds(nextBounds)
|
||||
stagedBoundsRef.current = clampedBounds
|
||||
setBounds(clampedBounds)
|
||||
}, [])
|
||||
|
||||
const commitUserBounds = useCallback(
|
||||
(nextBounds: FloatingTerminalPanelBounds | null = stagedBoundsRef.current): void => {
|
||||
if (!nextBounds) {
|
||||
return
|
||||
}
|
||||
const clampedBounds = clampFloatingTerminalBounds(nextBounds)
|
||||
stagedBoundsRef.current = null
|
||||
boundsSourceRef.current = 'user'
|
||||
setBounds(clampedBounds)
|
||||
persistUserBounds(clampedBounds)
|
||||
},
|
||||
[persistUserBounds]
|
||||
)
|
||||
|
||||
const reconcileBounds = useCallback((): void => {
|
||||
if (maximized) {
|
||||
setBounds(getMaximizedFloatingTerminalBounds())
|
||||
return
|
||||
}
|
||||
}, [bounds.left, bounds.width, open])
|
||||
setBounds((currentBounds) => {
|
||||
const source = boundsSourceRef.current
|
||||
if (!shouldReconcileFloatingTerminalPanelBounds(source)) {
|
||||
return currentBounds
|
||||
}
|
||||
const nextBounds = resolveFloatingTerminalPanelBounds(currentBounds, source)
|
||||
if (source === 'user') {
|
||||
persistUserBounds(nextBounds)
|
||||
}
|
||||
return nextBounds
|
||||
})
|
||||
}, [maximized, persistUserBounds])
|
||||
|
||||
useLayoutEffect(() => {
|
||||
// Why: Electron can mount before final renderer dimensions are known; default
|
||||
// bounds should re-anchor before paint while saved user bounds wait for a usable viewport.
|
||||
reconcileBounds()
|
||||
}, [reconcileBounds])
|
||||
|
||||
useEffect(() => {
|
||||
const handleResize = (): void => reconcileBounds()
|
||||
window.addEventListener('resize', handleResize)
|
||||
return () => window.removeEventListener('resize', handleResize)
|
||||
}, [reconcileBounds])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@@ -1005,17 +1107,33 @@ export function FloatingTerminalPanel({
|
||||
}, [open])
|
||||
|
||||
const toggleMaximized = useCallback(() => {
|
||||
setMaximized((current) => {
|
||||
if (current) {
|
||||
setBounds(restoreBoundsRef.current ?? getDefaultFloatingTerminalBounds())
|
||||
restoreBoundsRef.current = null
|
||||
return false
|
||||
if (maximized) {
|
||||
const restoredState = restoreBoundsRef.current ?? {
|
||||
bounds: getDefaultFloatingTerminalBounds(),
|
||||
source: 'default' as const
|
||||
}
|
||||
restoreBoundsRef.current = bounds
|
||||
setBounds(getMaximizedFloatingTerminalBounds())
|
||||
return true
|
||||
})
|
||||
}, [bounds])
|
||||
restoreBoundsRef.current = null
|
||||
boundsSourceRef.current = restoredState.source
|
||||
const restoredBounds = shouldReconcileFloatingTerminalPanelBounds(restoredState.source)
|
||||
? resolveFloatingTerminalPanelBounds(restoredState.bounds, restoredState.source)
|
||||
: restoredState.bounds
|
||||
if (restoredState.source === 'user') {
|
||||
commitUserBounds(restoredBounds)
|
||||
} else {
|
||||
stagedBoundsRef.current = null
|
||||
setBounds(restoredBounds)
|
||||
}
|
||||
setMaximized(false)
|
||||
return
|
||||
}
|
||||
restoreBoundsRef.current = {
|
||||
bounds,
|
||||
source: boundsSourceRef.current
|
||||
}
|
||||
stagedBoundsRef.current = null
|
||||
setBounds(getMaximizedFloatingTerminalBounds())
|
||||
setMaximized(true)
|
||||
}, [bounds, commitUserBounds, maximized])
|
||||
|
||||
const handleDragStart = (event: React.PointerEvent<HTMLDivElement>): void => {
|
||||
if (maximized) {
|
||||
@@ -1035,8 +1153,8 @@ export function FloatingTerminalPanel({
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
left: bounds.left,
|
||||
top: bounds.top
|
||||
bounds,
|
||||
moved: false
|
||||
}
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
}
|
||||
@@ -1046,19 +1164,28 @@ export function FloatingTerminalPanel({
|
||||
if (!drag || drag.pointerId !== event.pointerId) {
|
||||
return
|
||||
}
|
||||
setBounds((prev) =>
|
||||
clampFloatingTerminalBounds({
|
||||
...prev,
|
||||
left: drag.left + event.clientX - drag.startX,
|
||||
top: drag.top + event.clientY - drag.startY
|
||||
})
|
||||
)
|
||||
const dx = event.clientX - drag.startX
|
||||
const dy = event.clientY - drag.startY
|
||||
if (dx === 0 && dy === 0) {
|
||||
return
|
||||
}
|
||||
drag.moved = true
|
||||
previewUserBounds({
|
||||
...drag.bounds,
|
||||
left: drag.bounds.left + dx,
|
||||
top: drag.bounds.top + dy
|
||||
})
|
||||
}
|
||||
|
||||
const handleDragEnd = (event: React.PointerEvent<HTMLDivElement>): void => {
|
||||
if (dragRef.current?.pointerId === event.pointerId) {
|
||||
dragRef.current = null
|
||||
const drag = dragRef.current
|
||||
if (!drag || drag.pointerId !== event.pointerId) {
|
||||
return
|
||||
}
|
||||
if (drag.moved) {
|
||||
commitUserBounds()
|
||||
}
|
||||
dragRef.current = null
|
||||
}
|
||||
|
||||
const handleTitlebarDoubleClick = (event: React.MouseEvent<HTMLDivElement>): void => {
|
||||
@@ -1093,10 +1220,13 @@ export function FloatingTerminalPanel({
|
||||
if (maximized) {
|
||||
return
|
||||
}
|
||||
if (!stagedBoundsRef.current && boundsSourceRef.current !== 'user') {
|
||||
return
|
||||
}
|
||||
const rect = event.currentTarget.getBoundingClientRect()
|
||||
setBounds((prev) =>
|
||||
clampFloatingTerminalBounds({ ...prev, width: rect.width, height: rect.height })
|
||||
)
|
||||
const measuredBaseBounds =
|
||||
stagedBoundsRef.current ?? lastPersistedBoundsRef.current ?? bounds
|
||||
commitUserBounds({ ...measuredBaseBounds, width: rect.width, height: rect.height })
|
||||
}}
|
||||
onFocusCapture={(event) => setFloatingTerminalInputFocused(event.target)}
|
||||
onBlurCapture={(event) => setFloatingTerminalInputFocused(event.relatedTarget)}
|
||||
@@ -1280,7 +1410,13 @@ export function FloatingTerminalPanel({
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{!maximized && <FloatingTerminalResizeHandles bounds={bounds} setBounds={setBounds} />}
|
||||
{!maximized && (
|
||||
<FloatingTerminalResizeHandles
|
||||
bounds={bounds}
|
||||
onPreviewBounds={previewUserBounds}
|
||||
onCommitBounds={commitUserBounds}
|
||||
/>
|
||||
)}
|
||||
<FloatingTerminalOrchestrationDialog
|
||||
open={orchestrationDialogOpen}
|
||||
activeTabId={activeTerminalId}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import { useRef } from 'react'
|
||||
import {
|
||||
clampFloatingTerminalBounds,
|
||||
MIN_PANEL_HEIGHT,
|
||||
MIN_PANEL_WIDTH,
|
||||
type FloatingTerminalPanelBounds
|
||||
@@ -21,25 +20,14 @@ type ResizeEdge = (typeof RESIZE_HANDLES)[number][0]
|
||||
|
||||
type FloatingTerminalResizeHandlesProps = {
|
||||
bounds: FloatingTerminalPanelBounds
|
||||
setBounds: React.Dispatch<React.SetStateAction<FloatingTerminalPanelBounds>>
|
||||
}
|
||||
|
||||
function clampResizedBounds(bounds: FloatingTerminalPanelBounds): FloatingTerminalPanelBounds {
|
||||
const viewportWidth =
|
||||
typeof window === 'undefined' ? bounds.left + bounds.width : window.innerWidth
|
||||
const viewportHeight =
|
||||
typeof window === 'undefined' ? bounds.top + bounds.height : window.innerHeight
|
||||
const next = clampFloatingTerminalBounds(bounds)
|
||||
return {
|
||||
...next,
|
||||
width: Math.max(MIN_PANEL_WIDTH, Math.min(bounds.width, viewportWidth - next.left - 8)),
|
||||
height: Math.max(MIN_PANEL_HEIGHT, Math.min(bounds.height, viewportHeight - next.top - 8))
|
||||
}
|
||||
onPreviewBounds: (bounds: FloatingTerminalPanelBounds) => void
|
||||
onCommitBounds: () => void
|
||||
}
|
||||
|
||||
export function FloatingTerminalResizeHandles({
|
||||
bounds,
|
||||
setBounds
|
||||
onPreviewBounds,
|
||||
onCommitBounds
|
||||
}: FloatingTerminalResizeHandlesProps): React.JSX.Element {
|
||||
const resizeRef = useRef<{
|
||||
pointerId: number
|
||||
@@ -47,6 +35,7 @@ export function FloatingTerminalResizeHandles({
|
||||
startX: number
|
||||
startY: number
|
||||
bounds: FloatingTerminalPanelBounds
|
||||
moved: boolean
|
||||
} | null>(null)
|
||||
|
||||
const handleResizeStart =
|
||||
@@ -62,7 +51,8 @@ export function FloatingTerminalResizeHandles({
|
||||
edge,
|
||||
startX: event.clientX,
|
||||
startY: event.clientY,
|
||||
bounds
|
||||
bounds,
|
||||
moved: false
|
||||
}
|
||||
event.currentTarget.setPointerCapture(event.pointerId)
|
||||
}
|
||||
@@ -74,6 +64,9 @@ export function FloatingTerminalResizeHandles({
|
||||
}
|
||||
const dx = event.clientX - resize.startX
|
||||
const dy = event.clientY - resize.startY
|
||||
if (dx === 0 && dy === 0) {
|
||||
return
|
||||
}
|
||||
const next = { ...resize.bounds }
|
||||
if (resize.edge.includes('e')) {
|
||||
next.width = resize.bounds.width + dx
|
||||
@@ -95,13 +88,19 @@ export function FloatingTerminalResizeHandles({
|
||||
if (next.height < MIN_PANEL_HEIGHT && resize.edge.includes('n')) {
|
||||
next.top = resize.bounds.top + resize.bounds.height - MIN_PANEL_HEIGHT
|
||||
}
|
||||
setBounds(clampResizedBounds(next))
|
||||
resize.moved = true
|
||||
onPreviewBounds(next)
|
||||
}
|
||||
|
||||
const handleResizeEnd = (event: React.PointerEvent<HTMLDivElement>): void => {
|
||||
if (resizeRef.current?.pointerId === event.pointerId) {
|
||||
resizeRef.current = null
|
||||
const resize = resizeRef.current
|
||||
if (!resize || resize.pointerId !== event.pointerId) {
|
||||
return
|
||||
}
|
||||
if (resize.moved) {
|
||||
onCommitBounds()
|
||||
}
|
||||
resizeRef.current = null
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
+131
-3
@@ -1,13 +1,27 @@
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
FLOATING_TERMINAL_PANEL_BOUNDS_STORAGE_KEY,
|
||||
MIN_PANEL_HEIGHT,
|
||||
MIN_PANEL_WIDTH,
|
||||
TITLEBAR_SAFE_TOP,
|
||||
clampFloatingTerminalBounds,
|
||||
getDefaultFloatingTerminalBounds,
|
||||
getMaximizedFloatingTerminalBounds
|
||||
getMaximizedFloatingTerminalBounds,
|
||||
hasUsableFloatingTerminalPanelViewport,
|
||||
parseFloatingTerminalPanelBounds,
|
||||
persistFloatingTerminalPanelBounds,
|
||||
readPersistedFloatingTerminalPanelBounds,
|
||||
resolveFloatingTerminalPanelBounds,
|
||||
shouldReconcileFloatingTerminalPanelBounds
|
||||
} from './floating-terminal-panel-bounds'
|
||||
|
||||
function stubViewport(width: number, height: number, userAgent: string): void {
|
||||
vi.stubGlobal('window', { innerWidth: width, innerHeight: height })
|
||||
function stubViewport(
|
||||
width: number,
|
||||
height: number,
|
||||
userAgent = 'Macintosh',
|
||||
localStorage?: Partial<Storage>
|
||||
): void {
|
||||
vi.stubGlobal('window', { innerWidth: width, innerHeight: height, localStorage })
|
||||
vi.stubGlobal('navigator', { userAgent })
|
||||
}
|
||||
|
||||
@@ -58,4 +72,118 @@ describe('floating terminal panel bounds', () => {
|
||||
|
||||
expect(getDefaultFloatingTerminalBounds().top).toBeGreaterThanOrEqual(TITLEBAR_SAFE_TOP)
|
||||
})
|
||||
|
||||
it('parses only complete finite persisted bounds', () => {
|
||||
expect(
|
||||
parseFloatingTerminalPanelBounds('{"left":12,"top":36,"width":700,"height":400}')
|
||||
).toEqual({
|
||||
left: 12,
|
||||
top: 36,
|
||||
width: 700,
|
||||
height: 400
|
||||
})
|
||||
expect(parseFloatingTerminalPanelBounds('not-json')).toBeNull()
|
||||
expect(parseFloatingTerminalPanelBounds('{"left":12,"top":36,"width":700}')).toBeNull()
|
||||
expect(
|
||||
parseFloatingTerminalPanelBounds('{"left":12,"top":36,"width":700,"height":null}')
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('falls back when persisted storage is malformed or unavailable', () => {
|
||||
const localStorage = {
|
||||
getItem: vi.fn(() => '{"left":"bad","top":36,"width":700,"height":400}'),
|
||||
setItem: vi.fn()
|
||||
}
|
||||
stubViewport(1200, 800, 'Macintosh', localStorage)
|
||||
|
||||
expect(readPersistedFloatingTerminalPanelBounds()).toBeNull()
|
||||
|
||||
localStorage.getItem.mockImplementation(() => {
|
||||
throw new Error('blocked')
|
||||
})
|
||||
expect(readPersistedFloatingTerminalPanelBounds()).toBeNull()
|
||||
|
||||
localStorage.setItem.mockImplementation(() => {
|
||||
throw new Error('blocked')
|
||||
})
|
||||
expect(() =>
|
||||
persistFloatingTerminalPanelBounds({ left: 20, top: 40, width: 700, height: 400 })
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('persists bounds through the panel-specific storage key', () => {
|
||||
const localStorage = {
|
||||
getItem: vi.fn(() => null),
|
||||
setItem: vi.fn()
|
||||
}
|
||||
stubViewport(1200, 800, 'Macintosh', localStorage)
|
||||
|
||||
persistFloatingTerminalPanelBounds({ left: 20, top: 40, width: 700, height: 400 })
|
||||
|
||||
expect(localStorage.setItem).toHaveBeenCalledWith(
|
||||
FLOATING_TERMINAL_PANEL_BOUNDS_STORAGE_KEY,
|
||||
'{"left":20,"top":40,"width":700,"height":400}'
|
||||
)
|
||||
})
|
||||
|
||||
it('shrinks oversized saved bounds while keeping a viewport margin', () => {
|
||||
stubViewport(800, 600)
|
||||
|
||||
expect(
|
||||
clampFloatingTerminalBounds({
|
||||
left: 2000,
|
||||
top: 1200,
|
||||
width: 1200,
|
||||
height: 900
|
||||
})
|
||||
).toEqual({
|
||||
left: 8,
|
||||
top: TITLEBAR_SAFE_TOP,
|
||||
width: 784,
|
||||
height: 556
|
||||
})
|
||||
})
|
||||
|
||||
it('keeps the top-left reachable when the viewport is smaller than the minimum panel', () => {
|
||||
stubViewport(300, 260)
|
||||
|
||||
expect(
|
||||
clampFloatingTerminalBounds({
|
||||
left: 200,
|
||||
top: 200,
|
||||
width: 900,
|
||||
height: 500
|
||||
})
|
||||
).toEqual({
|
||||
left: 8,
|
||||
top: TITLEBAR_SAFE_TOP,
|
||||
width: MIN_PANEL_WIDTH,
|
||||
height: MIN_PANEL_HEIGHT
|
||||
})
|
||||
})
|
||||
|
||||
it('re-anchors default bounds and clamps user bounds by source', () => {
|
||||
stubViewport(0, 0)
|
||||
const initialBounds = getDefaultFloatingTerminalBounds()
|
||||
|
||||
stubViewport(1200, 800)
|
||||
expect(resolveFloatingTerminalPanelBounds(initialBounds, 'default')).toEqual(
|
||||
getDefaultFloatingTerminalBounds()
|
||||
)
|
||||
expect(
|
||||
resolveFloatingTerminalPanelBounds({ left: 900, top: 500, width: 720, height: 480 }, 'user')
|
||||
).toEqual({ left: 472, top: 312, width: 720, height: 480 })
|
||||
})
|
||||
|
||||
it('defers user-bound reconciliation until the viewport is usable', () => {
|
||||
stubViewport(0, 0)
|
||||
|
||||
expect(hasUsableFloatingTerminalPanelViewport()).toBe(false)
|
||||
expect(shouldReconcileFloatingTerminalPanelBounds('default')).toBe(true)
|
||||
expect(shouldReconcileFloatingTerminalPanelBounds('user')).toBe(false)
|
||||
|
||||
stubViewport(640, 480)
|
||||
expect(hasUsableFloatingTerminalPanelViewport()).toBe(true)
|
||||
expect(shouldReconcileFloatingTerminalPanelBounds('user')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -7,6 +7,9 @@ export const MAXIMIZED_BOTTOM_GAP = 36
|
||||
export const TITLEBAR_SAFE_TOP = 36
|
||||
const DEFAULT_RIGHT_GAP = 24
|
||||
const DEFAULT_BOTTOM_GAP = 84
|
||||
const PANEL_EDGE_MARGIN = 8
|
||||
|
||||
export const FLOATING_TERMINAL_PANEL_BOUNDS_STORAGE_KEY = 'orca-floating-terminal-panel-bounds-v1'
|
||||
|
||||
export type FloatingTerminalPanelBounds = {
|
||||
left: number
|
||||
@@ -15,17 +18,39 @@ export type FloatingTerminalPanelBounds = {
|
||||
height: number
|
||||
}
|
||||
|
||||
export type FloatingTerminalPanelBoundsSource = 'default' | 'user'
|
||||
|
||||
function getViewport(): { width: number; height: number } {
|
||||
return {
|
||||
width: typeof window === 'undefined' ? 1200 : window.innerWidth,
|
||||
height: typeof window === 'undefined' ? 800 : window.innerHeight
|
||||
}
|
||||
}
|
||||
|
||||
function isFiniteCoordinate(value: unknown): value is number {
|
||||
return typeof value === 'number' && Number.isFinite(value)
|
||||
}
|
||||
|
||||
function clampValue(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(min, value), max)
|
||||
}
|
||||
|
||||
function getWindowStorage(): Storage | null {
|
||||
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
|
||||
? window.localStorage
|
||||
: null
|
||||
}
|
||||
|
||||
export function getDefaultFloatingTerminalBounds(): FloatingTerminalPanelBounds {
|
||||
const viewportWidth = typeof window === 'undefined' ? 1200 : window.innerWidth
|
||||
const viewportHeight = typeof window === 'undefined' ? 800 : window.innerHeight
|
||||
const viewport = getViewport()
|
||||
// Why: the floating panel may touch the renderer titlebar, but must not
|
||||
// overlap it or the native window controls above it.
|
||||
const safeTop = TITLEBAR_SAFE_TOP
|
||||
const width = Math.min(DEFAULT_PANEL_WIDTH, Math.max(MIN_PANEL_WIDTH, viewportWidth - 48))
|
||||
const height = Math.min(DEFAULT_PANEL_HEIGHT, Math.max(MIN_PANEL_HEIGHT, viewportHeight - 96))
|
||||
const width = Math.min(DEFAULT_PANEL_WIDTH, Math.max(MIN_PANEL_WIDTH, viewport.width - 48))
|
||||
const height = Math.min(DEFAULT_PANEL_HEIGHT, Math.max(MIN_PANEL_HEIGHT, viewport.height - 96))
|
||||
return {
|
||||
left: Math.max(16, viewportWidth - width - DEFAULT_RIGHT_GAP),
|
||||
top: Math.max(safeTop, viewportHeight - height - DEFAULT_BOTTOM_GAP),
|
||||
left: Math.max(16, viewport.width - width - DEFAULT_RIGHT_GAP),
|
||||
top: Math.max(safeTop, viewport.height - height - DEFAULT_BOTTOM_GAP),
|
||||
width,
|
||||
height
|
||||
}
|
||||
@@ -34,26 +59,109 @@ export function getDefaultFloatingTerminalBounds(): FloatingTerminalPanelBounds
|
||||
export function clampFloatingTerminalBounds(
|
||||
bounds: FloatingTerminalPanelBounds
|
||||
): FloatingTerminalPanelBounds {
|
||||
const viewportWidth =
|
||||
typeof window === 'undefined' ? bounds.left + bounds.width : window.innerWidth
|
||||
const viewportHeight =
|
||||
typeof window === 'undefined' ? bounds.top + bounds.height : window.innerHeight
|
||||
const viewport = getViewport()
|
||||
const safeTop = TITLEBAR_SAFE_TOP
|
||||
const width = Math.max(
|
||||
MIN_PANEL_WIDTH,
|
||||
Math.min(bounds.width, Math.max(MIN_PANEL_WIDTH, viewport.width - PANEL_EDGE_MARGIN * 2))
|
||||
)
|
||||
const height = Math.max(
|
||||
MIN_PANEL_HEIGHT,
|
||||
Math.min(
|
||||
bounds.height,
|
||||
Math.max(MIN_PANEL_HEIGHT, viewport.height - safeTop - PANEL_EDGE_MARGIN)
|
||||
)
|
||||
)
|
||||
const maxLeft = Math.max(PANEL_EDGE_MARGIN, viewport.width - width - PANEL_EDGE_MARGIN)
|
||||
const maxTop = Math.max(safeTop, viewport.height - height - PANEL_EDGE_MARGIN)
|
||||
return {
|
||||
...bounds,
|
||||
left: Math.min(Math.max(8, bounds.left), Math.max(8, viewportWidth - 80)),
|
||||
top: Math.min(Math.max(safeTop, bounds.top), Math.max(safeTop, viewportHeight - 80))
|
||||
left: clampValue(bounds.left, PANEL_EDGE_MARGIN, maxLeft),
|
||||
top: clampValue(bounds.top, safeTop, maxTop),
|
||||
width,
|
||||
height
|
||||
}
|
||||
}
|
||||
|
||||
export function getMaximizedFloatingTerminalBounds(): FloatingTerminalPanelBounds {
|
||||
const viewportWidth = typeof window === 'undefined' ? 1200 : window.innerWidth
|
||||
const viewportHeight = typeof window === 'undefined' ? 800 : window.innerHeight
|
||||
const viewport = getViewport()
|
||||
const top = TITLEBAR_SAFE_TOP
|
||||
return {
|
||||
left: MAXIMIZED_MARGIN,
|
||||
top,
|
||||
width: Math.max(MIN_PANEL_WIDTH, viewportWidth - MAXIMIZED_MARGIN * 2),
|
||||
height: Math.max(MIN_PANEL_HEIGHT, viewportHeight - top - MAXIMIZED_BOTTOM_GAP)
|
||||
width: Math.max(MIN_PANEL_WIDTH, viewport.width - MAXIMIZED_MARGIN * 2),
|
||||
height: Math.max(MIN_PANEL_HEIGHT, viewport.height - top - MAXIMIZED_BOTTOM_GAP)
|
||||
}
|
||||
}
|
||||
|
||||
export function hasUsableFloatingTerminalPanelViewport(): boolean {
|
||||
const viewport = getViewport()
|
||||
return (
|
||||
viewport.width > PANEL_EDGE_MARGIN * 2 &&
|
||||
viewport.height > TITLEBAR_SAFE_TOP + PANEL_EDGE_MARGIN
|
||||
)
|
||||
}
|
||||
|
||||
export function shouldReconcileFloatingTerminalPanelBounds(
|
||||
source: FloatingTerminalPanelBoundsSource
|
||||
): boolean {
|
||||
return source === 'default' || hasUsableFloatingTerminalPanelViewport()
|
||||
}
|
||||
|
||||
export function resolveFloatingTerminalPanelBounds(
|
||||
bounds: FloatingTerminalPanelBounds,
|
||||
source: FloatingTerminalPanelBoundsSource
|
||||
): FloatingTerminalPanelBounds {
|
||||
if (source === 'default') {
|
||||
return getDefaultFloatingTerminalBounds()
|
||||
}
|
||||
return clampFloatingTerminalBounds(bounds)
|
||||
}
|
||||
|
||||
export function parseFloatingTerminalPanelBounds(
|
||||
serialized: string | null
|
||||
): FloatingTerminalPanelBounds | null {
|
||||
if (!serialized) {
|
||||
return null
|
||||
}
|
||||
try {
|
||||
const parsed: unknown = JSON.parse(serialized)
|
||||
if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
||||
return null
|
||||
}
|
||||
const record = parsed as Record<string, unknown>
|
||||
if (
|
||||
!isFiniteCoordinate(record.left) ||
|
||||
!isFiniteCoordinate(record.top) ||
|
||||
!isFiniteCoordinate(record.width) ||
|
||||
!isFiniteCoordinate(record.height)
|
||||
) {
|
||||
return null
|
||||
}
|
||||
return {
|
||||
left: record.left,
|
||||
top: record.top,
|
||||
width: record.width,
|
||||
height: record.height
|
||||
}
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function readPersistedFloatingTerminalPanelBounds(): FloatingTerminalPanelBounds | null {
|
||||
try {
|
||||
return parseFloatingTerminalPanelBounds(
|
||||
getWindowStorage()?.getItem(FLOATING_TERMINAL_PANEL_BOUNDS_STORAGE_KEY) ?? null
|
||||
)
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function persistFloatingTerminalPanelBounds(bounds: FloatingTerminalPanelBounds): void {
|
||||
try {
|
||||
getWindowStorage()?.setItem(FLOATING_TERMINAL_PANEL_BOUNDS_STORAGE_KEY, JSON.stringify(bounds))
|
||||
} catch {
|
||||
// localStorage may be unavailable; the floating workspace remains session-local.
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user