feat: allow moving floating terminal trigger (#2636)

This commit is contained in:
Jinjing 2026-05-22 11:49:00 -07:00 committed by GitHub
parent d121f911f9
commit 810302f892
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 221 additions and 11 deletions

View File

@ -1647,9 +1647,6 @@ function App(): React.JSX.Element {
</div>
{showFloatingTerminalButton ? (
<FloatingTerminalToggleButton
// Why: anchor the floating trigger to the center surface so it
// cannot cover the worktree sidebar or right sidebar.
className="absolute bottom-3 right-3"
open={floatingTerminalOpen}
onToggle={() => setFloatingTerminalOpenWithFocus((open) => !open)}
/>

View File

@ -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({
<>
<span
className={className}
style={style}
data-floating-terminal-toggle
onContextMenuCapture={(event) => {
// Why: workspace cards use DropdownMenu anchored at the cursor for

View File

@ -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<HTMLButtonElement>): 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<HTMLButtonElement>): 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<HTMLButtonElement>): void => {
const drag = dragRef.current
if (!drag || drag.pointerId !== event.pointerId) {
return
}
suppressClickRef.current = drag.moved
dragRef.current = null
}
const handleClick = (event: React.MouseEvent<HTMLButtonElement>): void => {
if (suppressClickRef.current) {
suppressClickRef.current = false
event.preventDefault()
event.stopPropagation()
return
}
onToggle()
}
return (
<FloatingTerminalIconContextMenu
currentLocation="floating-button"
className={cn('fixed bottom-3 right-3 z-40', className)}
className="fixed z-40"
style={{ left: position.left, top: position.top }}
>
<Tooltip>
<TooltipTrigger asChild>
@ -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}
>
<PanelsTopLeft className="size-3.5" />
</Button>

View File

@ -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()
})
})

View File

@ -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<string, unknown>
if (!isFiniteCoordinate(parsed.left) || !isFiniteCoordinate(parsed.top)) {
return null
}
return clampFloatingTerminalTriggerPosition({ left: parsed.left, top: parsed.top })
} catch {
return null
}
}