Fix floating terminal default migration (#1767)

* Fix floating terminal default migration

* Add floating terminal icon context menu
This commit is contained in:
Neil
2026-05-13 16:18:22 -07:00
committed by GitHub
parent 5a6b2994ea
commit 07210ae24c
7 changed files with 214 additions and 56 deletions
+37
View File
@@ -91,6 +91,8 @@ describe('Store', () => {
expect(settings.rightSidebarOpenByDefault).toBe(true)
expect(settings.showTasksButton).toBe(true)
expect(settings.experimentalActivity).toBe(true)
expect(settings.floatingTerminalEnabled).toBe(true)
expect(settings.floatingTerminalDefaultedForAllUsers).toBe(true)
expect(settings.notifications.customSoundPath).toBeNull()
})
@@ -166,6 +168,41 @@ describe('Store', () => {
expect(store.getRepos()).toHaveLength(1)
})
it('migrates the legacy floating terminal disabled default to enabled', async () => {
writeDataFile({
schemaVersion: 1,
repos: [],
worktreeMeta: {},
settings: { floatingTerminalEnabled: false },
ui: {},
githubCache: { pr: {}, issue: {} },
workspaceSession: {}
})
const store = await createStore()
expect(store.getSettings().floatingTerminalEnabled).toBe(true)
expect(store.getSettings().floatingTerminalDefaultedForAllUsers).toBe(true)
})
it('preserves a post-migration floating terminal opt-out', async () => {
writeDataFile({
schemaVersion: 1,
repos: [],
worktreeMeta: {},
settings: {
floatingTerminalEnabled: false,
floatingTerminalDefaultedForAllUsers: true
},
ui: {},
githubCache: { pr: {}, issue: {} },
workspaceSession: {}
})
const store = await createStore()
expect(store.getSettings().floatingTerminalEnabled).toBe(false)
expect(store.getSettings().floatingTerminalDefaultedForAllUsers).toBe(true)
})
it('preserves custom notification sound paths from persisted settings', async () => {
writeDataFile({
schemaVersion: 1,
+10
View File
@@ -277,6 +277,14 @@ export class Store {
: rawOptionAsAlt === undefined || rawOptionAsAlt === 'true'
? 'auto'
: rawOptionAsAlt
const floatingTerminalDefaultedForAllUsers =
parsed.settings?.floatingTerminalDefaultedForAllUsers === true
// Why: early floating-terminal builds persisted the old off-by-default
// value into user profiles. Flip only unmigrated profiles so a later
// deliberate opt-out still survives reload.
const migratedFloatingTerminalEnabled = floatingTerminalDefaultedForAllUsers
? (parsed.settings?.floatingTerminalEnabled ?? true)
: true
result = {
...defaults,
...parsed,
@@ -293,6 +301,8 @@ export class Store {
experimentalActivity: true,
terminalMacOptionAsAlt: migratedOptionAsAlt,
terminalMacOptionAsAltMigrated: true,
floatingTerminalEnabled: migratedFloatingTerminalEnabled,
floatingTerminalDefaultedForAllUsers: true,
notifications: {
...getDefaultNotificationSettings(),
...parsed.settings?.notifications
@@ -0,0 +1,97 @@
import { useMemo, useState } from 'react'
import { EyeOff, PanelBottom, PanelTop } from 'lucide-react'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { useAppStore } from '@/store'
import type { FloatingTerminalTriggerLocation } from '../../../../shared/types'
type FloatingTerminalIconContextMenuProps = {
children: React.ReactNode
currentLocation: FloatingTerminalTriggerLocation
className?: string
}
export function FloatingTerminalIconContextMenu({
children,
currentLocation,
className
}: FloatingTerminalIconContextMenuProps): React.JSX.Element {
const updateSettings = useAppStore((s) => s.updateSettings)
const [open, setOpen] = useState(false)
const [menuPoint, setMenuPoint] = useState({ x: 0, y: 0 })
const moveAction = useMemo(() => {
if (currentLocation === 'floating-button') {
return {
icon: <PanelBottom className="size-3.5" />,
label: 'Move to Status Bar',
location: 'status-bar' as const
}
}
return {
icon: <PanelTop className="size-3.5" />,
label: 'Move to Floating Button',
location: 'floating-button' as const
}
}, [currentLocation])
return (
<>
<span
className={className}
data-floating-terminal-toggle
onContextMenuCapture={(event) => {
// Why: workspace cards use DropdownMenu anchored at the cursor for
// right-click menus; match that style instead of Radix ContextMenu.
event.preventDefault()
event.stopPropagation()
setMenuPoint({ x: event.clientX, y: event.clientY })
setOpen(false)
window.requestAnimationFrame(() => setOpen(true))
}}
onContextMenu={(event) => {
event.preventDefault()
event.stopPropagation()
}}
>
{children}
</span>
<DropdownMenu open={open} onOpenChange={setOpen} modal={false}>
<DropdownMenuTrigger asChild>
<button
aria-hidden
tabIndex={-1}
className="pointer-events-none fixed size-px opacity-0"
style={{ left: menuPoint.x, top: menuPoint.y }}
/>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-52" sideOffset={0} align="start">
<DropdownMenuItem
className="whitespace-nowrap"
onSelect={() => {
void updateSettings({ floatingTerminalTriggerLocation: moveAction.location })
}}
>
{moveAction.icon}
{moveAction.label}
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="whitespace-nowrap"
onSelect={() => {
void updateSettings({ floatingTerminalEnabled: false })
}}
>
<EyeOff className="size-3.5" />
Hide
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</>
)
}
@@ -1,5 +1,5 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Maximize2, Minimize2, TerminalSquare, X } from 'lucide-react'
import { Maximize2, Minimize2, Minus, TerminalSquare } from 'lucide-react'
import TabBar from '@/components/tab-bar/TabBar'
import TerminalPane from '@/components/terminal-pane/TerminalPane'
import { Button } from '@/components/ui/button'
@@ -15,6 +15,7 @@ import {
getMaximizedFloatingTerminalBounds,
type FloatingTerminalPanelBounds
} from './floating-terminal-panel-bounds'
import { FloatingTerminalIconContextMenu } from './FloatingTerminalIconContextMenu'
const EMPTY_TERMINAL_TABS: TerminalTab[] = []
type FloatingTerminalPanelProps = {
@@ -32,26 +33,30 @@ export function FloatingTerminalToggleButton({
const shortcutLabel =
typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac') ? '⌘⌥T' : 'Ctrl+Alt+T'
return (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="outline"
size="icon-sm"
className="fixed bottom-8 right-3 z-40 bg-card/95 shadow-xs"
data-floating-terminal-toggle
aria-label={open ? 'Hide floating terminal' : 'Show floating terminal'}
aria-pressed={open}
onClick={onToggle}
>
<TerminalSquare className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent
side="left"
sideOffset={6}
>{`${open ? 'Hide' : 'Show'} floating terminal (${shortcutLabel})`}</TooltipContent>
</Tooltip>
<FloatingTerminalIconContextMenu
currentLocation="floating-button"
className="fixed bottom-8 right-3 z-40"
>
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="outline"
size="icon-sm"
className="bg-card/95 shadow-xs"
aria-label={open ? 'Minimize floating terminal' : 'Show floating terminal'}
aria-pressed={open}
onClick={onToggle}
>
<TerminalSquare className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent
side="left"
sideOffset={6}
>{`${open ? 'Minimize' : 'Show'} floating terminal (${shortcutLabel})`}</TooltipContent>
</Tooltip>
</FloatingTerminalIconContextMenu>
)
}
@@ -312,14 +317,14 @@ export function FloatingTerminalPanel({
type="button"
variant="ghost"
size="icon-xs"
aria-label="Hide floating terminal"
aria-label="Minimize floating terminal"
onClick={() => onOpenChange(false)}
>
<X className="size-3.5" />
<Minus className="size-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={6}>
Hide floating terminal
Minimize
</TooltipContent>
</Tooltip>
</div>
@@ -37,6 +37,7 @@ import { ResourceUsageStatusSegment } from './ResourceUsageStatusSegment'
import { isStatusBarItemAvailable } from './status-bar-agent-gating'
import { PetStatusSegment } from './PetStatusSegment'
import { TOGGLE_FLOATING_TERMINAL_EVENT } from '@/lib/floating-terminal'
import { FloatingTerminalIconContextMenu } from '@/components/floating-terminal/FloatingTerminalIconContextMenu'
type StatusBarProps = {
floatingTerminalOpen: boolean
@@ -827,13 +828,19 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
const compact = containerWidth < 900
const iconOnly = containerWidth < 500
const floatingTerminalActionLabel = floatingTerminalOpen ? 'Hide Terminal' : 'Show Terminal'
const floatingTerminalActionLabel = floatingTerminalOpen ? 'Minimize Terminal' : 'Show Terminal'
return (
<div
ref={containerRefCallback}
className="flex items-center h-6 min-h-[24px] px-3 gap-4 border-t border-border bg-[var(--bg-titlebar,var(--card))] text-xs select-none shrink-0 relative"
onContextMenuCapture={(event) => {
if (
event.target instanceof Element &&
event.target.closest('[data-floating-terminal-toggle]')
) {
return
}
// Why: mirror the right-click pattern used across the app
// (WorktreeContextMenu, TerminalContextMenu, tab bar) — dispatch the
// global close event so peer menus dismiss, then place a hidden
@@ -893,36 +900,33 @@ function StatusBarInner({ floatingTerminalOpen }: StatusBarProps): React.JSX.Ele
<div className="flex items-center gap-3">
<UpdateStatusSegment compact={compact} iconOnly={iconOnly} />
{petEnabled && <PetStatusSegment />}
{showFloatingTerminalToggle && (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex items-center gap-1 rounded px-1.5 py-0.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
aria-label={floatingTerminalActionLabel}
onClick={() => {
window.dispatchEvent(new CustomEvent(TOGGLE_FLOATING_TERMINAL_EVENT))
}}
>
<TerminalSquare className="size-3.5" />
{!iconOnly && (
<span className="inline-block w-[86px] whitespace-nowrap text-left">
{floatingTerminalActionLabel}
</span>
)}
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6}>
{floatingTerminalActionLabel} (
{typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac')
? '⌘⌥T'
: 'Ctrl+Alt+T'}
)
</TooltipContent>
</Tooltip>
)}
{showResourceUsage && <ResourceUsageStatusSegment compact={compact} iconOnly={iconOnly} />}
{showSsh && <SshStatusSegment compact={compact} iconOnly={iconOnly} />}
{showFloatingTerminalToggle && (
<FloatingTerminalIconContextMenu currentLocation="status-bar" className="relative">
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
className="inline-flex size-5 items-center justify-center rounded text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
aria-label={floatingTerminalActionLabel}
onClick={() => {
window.dispatchEvent(new CustomEvent(TOGGLE_FLOATING_TERMINAL_EVENT))
}}
>
<TerminalSquare className="size-3.5" />
</button>
</TooltipTrigger>
<TooltipContent side="top" sideOffset={6}>
{floatingTerminalActionLabel} (
{typeof navigator !== 'undefined' && navigator.userAgent.includes('Mac')
? '⌘⌥T'
: 'Ctrl+Alt+T'}
)
</TooltipContent>
</Tooltip>
</FloatingTerminalIconContextMenu>
)}
</div>
<DropdownMenu open={menuOpen} onOpenChange={setMenuOpen} modal={false}>
+1
View File
@@ -203,6 +203,7 @@ export function getDefaultSettings(homedir: string): GlobalSettings {
showTitlebarAppName: true,
showTasksButton: true,
floatingTerminalEnabled: true,
floatingTerminalDefaultedForAllUsers: true,
floatingTerminalCwd: '~',
floatingTerminalTriggerLocation: 'floating-button',
notifications: getDefaultNotificationSettings(),
+7 -3
View File
@@ -1187,14 +1187,18 @@ export type GlobalSettings = {
* left sidebar free of its button entirely. Hiding the button here also
* removes it from keyboard navigation. */
showTasksButton: boolean
/** Why: Floating Terminal is a global terminal surface. Keep it opt-in until
* users explicitly want a global shell outside repo/worktree context. */
/** Why: Floating Terminal is the default global shell surface so users can
* reach a terminal outside repo/worktree context immediately. */
floatingTerminalEnabled: boolean
/** One-shot migration flag for the default-on rollout. Before this field
* landed, the floating terminal defaulted off and many profiles persisted
* that inherited false. Once migrated, an explicit off choice sticks. */
floatingTerminalDefaultedForAllUsers?: boolean
/** Where new Floating Terminal tabs start. Defaults to '~' so the visible
* setting matches the shell-oriented directory users expect. */
floatingTerminalCwd: string
/** Where the Floating Terminal toggle is shown. Defaults to the floating
* button for discoverability after the user opts into the feature. */
* button for discoverability. */
floatingTerminalTriggerLocation: FloatingTerminalTriggerLocation
diffDefaultView: 'inline' | 'side-by-side'
notifications: NotificationSettings