From 810302f8921f332fd7c7bdb3cdb3624fb00c627c Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Fri, 22 May 2026 11:49:00 -0700 Subject: [PATCH] feat: allow moving floating terminal trigger (#2636) --- src/renderer/src/App.tsx | 3 - .../FloatingTerminalIconContextMenu.tsx | 5 +- .../FloatingTerminalToggleButton.tsx | 125 +++++++++++++++++- ...floating-terminal-trigger-position.test.ts | 41 ++++++ .../floating-terminal-trigger-position.ts | 58 ++++++++ 5 files changed, 221 insertions(+), 11 deletions(-) create mode 100644 src/renderer/src/components/floating-terminal/floating-terminal-trigger-position.test.ts create mode 100644 src/renderer/src/components/floating-terminal/floating-terminal-trigger-position.ts diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index c28dfbe0d..ed59219f2 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -1647,9 +1647,6 @@ function App(): React.JSX.Element { {showFloatingTerminalButton ? ( setFloatingTerminalOpenWithFocus((open) => !open)} /> diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalIconContextMenu.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalIconContextMenu.tsx index 5a3675870..d1918695f 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalIconContextMenu.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalIconContextMenu.tsx @@ -14,12 +14,14 @@ type FloatingTerminalIconContextMenuProps = { children: React.ReactNode currentLocation: FloatingTerminalTriggerLocation className?: string + style?: React.CSSProperties } export function FloatingTerminalIconContextMenu({ children, currentLocation, - className + className, + style }: FloatingTerminalIconContextMenuProps): React.JSX.Element { const updateSettings = useAppStore((s) => s.updateSettings) const [open, setOpen] = useState(false) @@ -44,6 +46,7 @@ export function FloatingTerminalIconContextMenu({ <> { // Why: workspace cards use DropdownMenu anchored at the cursor for diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalToggleButton.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalToggleButton.tsx index 0952c5afc..196a5094f 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalToggleButton.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalToggleButton.tsx @@ -1,24 +1,131 @@ +import { useCallback, useEffect, useRef, useState } from 'react' import { PanelsTopLeft } from 'lucide-react' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' -import { cn } from '@/lib/utils' import { FloatingTerminalIconContextMenu } from './FloatingTerminalIconContextMenu' import { useShortcutLabel } from '@/hooks/useShortcutLabel' +import { + clampFloatingTerminalTriggerPosition, + getDefaultFloatingTerminalTriggerPosition, + parseFloatingTerminalTriggerPosition, + type FloatingTerminalTriggerPosition +} from './floating-terminal-trigger-position' + +// Why: v2 resets older parked positions that sat too low over bottom bars. +const FLOATING_TERMINAL_TRIGGER_POSITION_STORAGE_KEY = 'orca-floating-terminal-trigger-position-v2' +const FLOATING_TERMINAL_TRIGGER_DRAG_THRESHOLD = 4 + +function readInitialTriggerPosition(): FloatingTerminalTriggerPosition { + if (typeof window === 'undefined') { + return getDefaultFloatingTerminalTriggerPosition() + } + return ( + parseFloatingTerminalTriggerPosition( + window.localStorage.getItem(FLOATING_TERMINAL_TRIGGER_POSITION_STORAGE_KEY) + ) ?? getDefaultFloatingTerminalTriggerPosition() + ) +} + +function persistTriggerPosition(position: FloatingTerminalTriggerPosition): void { + window.localStorage.setItem( + FLOATING_TERMINAL_TRIGGER_POSITION_STORAGE_KEY, + JSON.stringify(position) + ) +} export function FloatingTerminalToggleButton({ open, - onToggle, - className + onToggle }: { open: boolean onToggle: () => void - className?: string }): React.JSX.Element { const shortcutLabel = useShortcutLabel('floatingTerminal.toggle') + const [position, setPosition] = useState(readInitialTriggerPosition) + const dragRef = useRef<{ + pointerId: number + startX: number + startY: number + left: number + top: number + moved: boolean + } | null>(null) + const suppressClickRef = useRef(false) + + const updatePosition = useCallback((nextPosition: FloatingTerminalTriggerPosition): void => { + const clamped = clampFloatingTerminalTriggerPosition(nextPosition) + setPosition(clamped) + persistTriggerPosition(clamped) + }, []) + + useEffect(() => { + const handleResize = (): void => { + setPosition((current) => { + const clamped = clampFloatingTerminalTriggerPosition(current) + persistTriggerPosition(clamped) + return clamped + }) + } + window.addEventListener('resize', handleResize) + return () => window.removeEventListener('resize', handleResize) + }, []) + + const handlePointerDown = (event: React.PointerEvent): void => { + if (event.button !== 0) { + return + } + dragRef.current = { + pointerId: event.pointerId, + startX: event.clientX, + startY: event.clientY, + left: position.left, + top: position.top, + moved: false + } + event.currentTarget.setPointerCapture(event.pointerId) + } + + const handlePointerMove = (event: React.PointerEvent): void => { + const drag = dragRef.current + if (!drag || drag.pointerId !== event.pointerId) { + return + } + const dx = event.clientX - drag.startX + const dy = event.clientY - drag.startY + if (!drag.moved && Math.hypot(dx, dy) < FLOATING_TERMINAL_TRIGGER_DRAG_THRESHOLD) { + return + } + drag.moved = true + updatePosition({ + left: drag.left + dx, + top: drag.top + dy + }) + } + + const handlePointerEnd = (event: React.PointerEvent): void => { + const drag = dragRef.current + if (!drag || drag.pointerId !== event.pointerId) { + return + } + suppressClickRef.current = drag.moved + dragRef.current = null + } + + const handleClick = (event: React.MouseEvent): void => { + if (suppressClickRef.current) { + suppressClickRef.current = false + event.preventDefault() + event.stopPropagation() + return + } + onToggle() + } + return ( @@ -26,11 +133,15 @@ export function FloatingTerminalToggleButton({ type="button" variant="outline" size="icon-sm" - className="border-border bg-secondary text-secondary-foreground shadow-xs hover:bg-accent hover:text-accent-foreground" + className="cursor-grab border-border bg-secondary text-secondary-foreground shadow-xs hover:bg-accent hover:text-accent-foreground active:cursor-grabbing" data-floating-terminal-toggle aria-label={open ? 'Minimize floating workspace' : 'Show floating workspace'} aria-pressed={open} - onClick={onToggle} + onPointerDown={handlePointerDown} + onPointerMove={handlePointerMove} + onPointerUp={handlePointerEnd} + onPointerCancel={handlePointerEnd} + onClick={handleClick} > diff --git a/src/renderer/src/components/floating-terminal/floating-terminal-trigger-position.test.ts b/src/renderer/src/components/floating-terminal/floating-terminal-trigger-position.test.ts new file mode 100644 index 000000000..6917a2323 --- /dev/null +++ b/src/renderer/src/components/floating-terminal/floating-terminal-trigger-position.test.ts @@ -0,0 +1,41 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + clampFloatingTerminalTriggerPosition, + getDefaultFloatingTerminalTriggerPosition, + parseFloatingTerminalTriggerPosition +} from './floating-terminal-trigger-position' + +function stubViewport(width: number, height: number): void { + vi.stubGlobal('window', { innerWidth: width, innerHeight: height }) +} + +describe('floating terminal trigger position', () => { + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('defaults to the bottom right of the viewport', () => { + stubViewport(1200, 800) + + expect(getDefaultFloatingTerminalTriggerPosition()).toEqual({ + left: 1144, + top: 696 + }) + }) + + it('clamps parked positions into the viewport', () => { + stubViewport(640, 480) + + expect(clampFloatingTerminalTriggerPosition({ left: 900, top: -20 })).toEqual({ + left: 600, + top: 36 + }) + }) + + it('ignores malformed persisted positions', () => { + stubViewport(640, 480) + + expect(parseFloatingTerminalTriggerPosition('not-json')).toBeNull() + expect(parseFloatingTerminalTriggerPosition('{"left":"1","top":2}')).toBeNull() + }) +}) diff --git a/src/renderer/src/components/floating-terminal/floating-terminal-trigger-position.ts b/src/renderer/src/components/floating-terminal/floating-terminal-trigger-position.ts new file mode 100644 index 000000000..6f6c70e80 --- /dev/null +++ b/src/renderer/src/components/floating-terminal/floating-terminal-trigger-position.ts @@ -0,0 +1,58 @@ +const TRIGGER_SIZE = 32 +const DEFAULT_RIGHT_GAP = 24 +const DEFAULT_BOTTOM_GAP = 72 +const DRAG_MARGIN = 8 +const TITLEBAR_SAFE_TOP = 36 + +export type FloatingTerminalTriggerPosition = { + left: number + top: number +} + +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) +} + +export function getDefaultFloatingTerminalTriggerPosition(): FloatingTerminalTriggerPosition { + const viewport = getViewport() + return { + left: Math.max(DRAG_MARGIN, viewport.width - TRIGGER_SIZE - DEFAULT_RIGHT_GAP), + top: Math.max(TITLEBAR_SAFE_TOP, viewport.height - TRIGGER_SIZE - DEFAULT_BOTTOM_GAP) + } +} + +export function clampFloatingTerminalTriggerPosition( + position: FloatingTerminalTriggerPosition +): FloatingTerminalTriggerPosition { + const viewport = getViewport() + const maxLeft = Math.max(DRAG_MARGIN, viewport.width - TRIGGER_SIZE - DRAG_MARGIN) + const maxTop = Math.max(TITLEBAR_SAFE_TOP, viewport.height - TRIGGER_SIZE - DRAG_MARGIN) + return { + left: Math.min(Math.max(DRAG_MARGIN, position.left), maxLeft), + top: Math.min(Math.max(TITLEBAR_SAFE_TOP, position.top), maxTop) + } +} + +export function parseFloatingTerminalTriggerPosition( + serialized: string | null +): FloatingTerminalTriggerPosition | null { + if (!serialized) { + return null + } + try { + const parsed = JSON.parse(serialized) as Record + if (!isFiniteCoordinate(parsed.left) || !isFiniteCoordinate(parsed.top)) { + return null + } + return clampFloatingTerminalTriggerPosition({ left: parsed.left, top: parsed.top }) + } catch { + return null + } +}