WIP: floating terminal mixed tabs

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
Jinwoo-H
2026-05-19 15:34:33 -04:00
co-authored by Orca
parent 95526a42c4
commit 98ab911e6e
7 changed files with 691 additions and 120 deletions
+60
View File
@@ -0,0 +1,60 @@
# Review Context
## Branch Info
- Base: origin/main (`95526a42c4ab06518c7cd44577ff2ed270a0edf3`)
- Current: `Jinwoo-H/floating-terminal-improvements`
## Changed Files Summary
- M `src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx`
- M `src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx`
- M `src/renderer/src/store/slices/browser.test.ts`
- M `src/renderer/src/store/slices/browser.ts`
- M `src/renderer/src/store/slices/editor.test.ts`
- M `src/renderer/src/store/slices/editor.ts`
## Changed Line Ranges (PR Scope)
<!-- In scope: issues on these lines OR caused by these changes. Out of scope: unrelated pre-existing issues -->
| File | Changed Lines |
| --- | --- |
| `src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx` | 1-3, 6-7, 18-22, 31-35, 37-41, 45-47, 63-65, 67, 72-75, 139-150, 157-204, 263-275, 277-288, 296-300, 303-305, 307, 309-312, 316, 404-405, 497-499 |
| `src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx` | 1-7, 9, 12-22, 24, 33-34, 36-41, 53-57, 76-80, 83, 85-89, 93-96, 109, 120-234, 258, 260, 263-265, 267-272, 275, 278-282, 317-335, 337-365, 367-384, 386-387, 389-410, 415-422, 426, 428-439, 443, 447, 449-463, 467-472, 475, 580-581, 584-585, 588-591, 597-622, 635-654, 656-683, 686, 724, 728-757 |
| `src/renderer/src/store/slices/browser.test.ts` | 12, 184-199 |
| `src/renderer/src/store/slices/browser.ts` | 16, 422-423, 425 |
| `src/renderer/src/store/slices/editor.test.ts` | 12, 255-282 |
| `src/renderer/src/store/slices/editor.ts` | 28, 498-519, 849, 1059 |
## Review Standards Reference
- Follow `/review-code` standards
- Follow `/review-algorithm-architecture` standards
- Focus on correctness, security, performance, maintainability
- Priority levels: Critical > High > Medium > Low
## File Categories
### Frontend/UI
- `src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx`
- `src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx`
- `src/renderer/src/store/slices/browser.test.ts`
- `src/renderer/src/store/slices/browser.ts`
- `src/renderer/src/store/slices/editor.test.ts`
- `src/renderer/src/store/slices/editor.ts`
## Skipped Issues (Do Not Re-validate)
<!-- Issues validated but deemed not worth fixing. Do not re-validate these in future iterations. -->
<!-- Format: [file:line-range] | [severity] | [reason skipped] | [issue summary] -->
<!-- NOTE: Skips should be RARE - only purely cosmetic issues with no functional impact -->
None.
## Iteration State
Current iteration: 1
Last completed phase: Setup
Files fixed this iteration: []
@@ -1,6 +1,10 @@
/* eslint-disable max-lines -- Why: these tests mock the floating panel's
* React/store environment directly so close and bootstrap behavior can be
* asserted without mounting the full Electron renderer. */
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import type { TerminalTab } from '../../../../shared/types'
import type { BrowserTab, Tab, TabGroup, TerminalTab } from '../../../../shared/types'
import type { OpenFile } from '@/store/slices/editor'
type EffectCallback = () => void | (() => void)
@@ -11,6 +15,11 @@ type ReactElementLike = {
type FloatingPanelStoreState = {
tabsByWorktree: Record<string, TerminalTab[]>
browserTabsByWorktree: Record<string, BrowserTab[]>
browserPagesByWorkspace: Record<string, unknown[]>
groupsByWorktree: Record<string, TabGroup[]>
unifiedTabsByWorktree: Record<string, Tab[]>
openFiles: OpenFile[]
activeTabIdByWorktree: Record<string, string | null>
expandedPaneByTabId: Record<string, boolean>
createTab: (
@@ -19,12 +28,23 @@ type FloatingPanelStoreState = {
shellOverride?: string,
options?: { activate?: boolean }
) => TerminalTab
createBrowserTab: (
worktreeId: string,
url: string,
options?: { title?: string; targetGroupId?: string }
) => BrowserTab
closeTab: (tabId: string) => void
setActiveTabForWorktree: (worktreeId: string, tabId: string) => void
setTabBarOrder: (worktreeId: string, order: string[]) => void
closeBrowserTab: (tabId: string) => void
closeFile: (fileId: string) => void
markFileDirty: (fileId: string, dirty: boolean) => void
activateTab: (tabId: string) => void
setActiveTab: (tabId: string) => void
setTabCustomTitle: (tabId: string, title: string | null) => void
setTabColor: (tabId: string, color: string | null) => void
setTabPaneExpanded: (tabId: string, expanded: boolean) => void
pinFile: (fileId: string, tabId?: string) => void
openFile: (file: unknown, options?: unknown) => void
browserDefaultUrl: string
tabBarOrderByWorktree: Record<string, string[]>
settings: { floatingTerminalCwd?: string }
}
@@ -40,13 +60,19 @@ const storeBox = vi.hoisted(() => ({
}))
const mocks = vi.hoisted(() => ({
activateTab: vi.fn(),
closeBrowserTab: vi.fn(),
closeFile: vi.fn(),
closeTab: vi.fn(),
createBrowserTab: vi.fn(),
createTab: vi.fn(),
focusTerminalTabSurface: vi.fn(),
getFloatingTerminalCwd: vi.fn(),
getInstallStatus: vi.fn(),
setActiveTabForWorktree: vi.fn(),
setTabBarOrder: vi.fn(),
markFileDirty: vi.fn(),
openFile: vi.fn(),
pinFile: vi.fn(),
setActiveTab: vi.fn(),
setTabColor: vi.fn(),
setTabCustomTitle: vi.fn(),
setTabPaneExpanded: vi.fn()
@@ -110,12 +136,72 @@ vi.mock('@/components/terminal-pane/TerminalPane', () => ({
}
}))
vi.mock('@/components/browser-pane/BrowserPane', () => ({
default: function BrowserPane() {
return null
}
}))
vi.mock('@/components/editor/EditorPanel', () => ({
default: function EditorPanel() {
return null
}
}))
vi.mock('@/components/ui/button', () => ({
Button: function Button() {
return null
}
}))
vi.mock('@/components/ui/dialog', () => ({
Dialog: function Dialog(props: { children?: unknown }) {
return props.children
},
DialogContent: function DialogContent(props: { children?: unknown }) {
return props.children
},
DialogDescription: function DialogDescription(props: { children?: unknown }) {
return props.children
},
DialogFooter: function DialogFooter(props: { children?: unknown }) {
return props.children
},
DialogHeader: function DialogHeader(props: { children?: unknown }) {
return props.children
},
DialogTitle: function DialogTitle(props: { children?: unknown }) {
return props.children
}
}))
vi.mock('@/components/terminal/useTerminalSaveDialog', () => ({
useTerminalSaveDialog: () => ({
handleSaveDialogCancel: vi.fn(),
handleSaveDialogDiscard: vi.fn(),
handleSaveDialogSave: vi.fn(),
requestCloseFile: mocks.closeFile,
saveDialogFile: null,
saveDialogFileId: null
})
}))
vi.mock('@/lib/connection-context', () => ({
getConnectionId: () => undefined
}))
vi.mock('@/lib/create-untitled-markdown', () => ({
createUntitledMarkdownFile: vi.fn()
}))
vi.mock('@/lib/ipc-error', () => ({
extractIpcErrorMessage: (_err: unknown, fallback: string) => fallback
}))
vi.mock('sonner', () => ({
toast: { error: vi.fn() }
}))
vi.mock('@/lib/focus-terminal-tab-surface', () => ({
focusTerminalTabSurface: mocks.focusTerminalTabSurface
}))
@@ -174,7 +260,32 @@ function makeTab(overrides: Partial<TerminalTab> = {}): TerminalTab {
function setFloatingTabs(tabs: TerminalTab[]): void {
const state = storeBox.state as FloatingPanelStoreState
const groupId = 'floating-group'
const unifiedTabs = tabs.map<Tab>((tab, index) => ({
id: tab.id,
entityId: tab.id,
groupId,
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
contentType: 'terminal',
label: tab.title,
customLabel: tab.customTitle,
color: tab.color,
sortOrder: index,
createdAt: tab.createdAt
}))
state.tabsByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: tabs }
state.unifiedTabsByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: unifiedTabs }
state.groupsByWorktree = {
[FLOATING_TERMINAL_WORKTREE_ID]: [
{
id: groupId,
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
activeTabId: unifiedTabs[0]?.id ?? null,
tabOrder: unifiedTabs.map((tab) => tab.id),
recentTabIds: unifiedTabs.map((tab) => tab.id)
}
]
}
state.activeTabIdByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: tabs[0]?.id ?? null }
state.tabBarOrderByWorktree = { [FLOATING_TERMINAL_WORKTREE_ID]: tabs.map((tab) => tab.id) }
}
@@ -182,15 +293,27 @@ function setFloatingTabs(tabs: TerminalTab[]): void {
function resetStore(tabs: TerminalTab[] = []): void {
storeBox.state = {
tabsByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: tabs },
browserTabsByWorktree: {},
browserPagesByWorkspace: {},
groupsByWorktree: {},
unifiedTabsByWorktree: {},
openFiles: [],
activeTabIdByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: tabs[0]?.id ?? null },
expandedPaneByTabId: {},
activateTab: mocks.activateTab,
closeBrowserTab: mocks.closeBrowserTab,
closeFile: mocks.closeFile,
createTab: mocks.createTab,
createBrowserTab: mocks.createBrowserTab,
closeTab: mocks.closeTab,
setActiveTabForWorktree: mocks.setActiveTabForWorktree,
setTabBarOrder: mocks.setTabBarOrder,
markFileDirty: mocks.markFileDirty,
openFile: mocks.openFile,
pinFile: mocks.pinFile,
setActiveTab: mocks.setActiveTab,
setTabCustomTitle: mocks.setTabCustomTitle,
setTabColor: mocks.setTabColor,
setTabPaneExpanded: mocks.setTabPaneExpanded,
browserDefaultUrl: 'about:blank',
tabBarOrderByWorktree: { [FLOATING_TERMINAL_WORKTREE_ID]: tabs.map((tab) => tab.id) },
settings: { floatingTerminalCwd: '~' }
} satisfies FloatingPanelStoreState
@@ -278,10 +401,8 @@ describe('FloatingTerminalPanel close behavior', () => {
await renderPanel(true)
runEffects()
expect(mocks.createTab).toHaveBeenCalledTimes(1)
expect(mocks.setActiveTabForWorktree).toHaveBeenCalledWith(
FLOATING_TERMINAL_WORKTREE_ID,
'created-tab'
)
expect(mocks.createTab).toHaveBeenCalledWith(FLOATING_TERMINAL_WORKTREE_ID)
expect(mocks.focusTerminalTabSurface).toHaveBeenCalledWith('created-tab')
await renderPanel(true)
runEffects()
@@ -373,6 +494,9 @@ describe('FloatingTerminalPanel close behavior', () => {
;(storeBox.state as FloatingPanelStoreState).tabBarOrderByWorktree = {
[FLOATING_TERMINAL_WORKTREE_ID]: ['tab-c', 'tab-a', 'tab-b']
}
;(storeBox.state as FloatingPanelStoreState).groupsByWorktree[
FLOATING_TERMINAL_WORKTREE_ID
][0].tabOrder = ['tab-c', 'tab-a', 'tab-b']
const element = await renderPanel(true)
const tabBar = findByTypeName(element, 'TabBar')
@@ -1,8 +1,27 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
/* eslint-disable max-lines -- Why: the floating panel owns window chrome,
* 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 { toast } from 'sonner'
import BrowserPane from '@/components/browser-pane/BrowserPane'
import TabBar from '@/components/tab-bar/TabBar'
import { resolveGroupTabFromVisibleId } from '@/components/tab-group/tab-group-visible-id'
import TerminalPane from '@/components/terminal-pane/TerminalPane'
import { Button } from '@/components/ui/button'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle
} from '@/components/ui/dialog'
import { useTerminalSaveDialog } from '@/components/terminal/useTerminalSaveDialog'
import { getConnectionId } from '@/lib/connection-context'
import { createUntitledMarkdownFile } from '@/lib/create-untitled-markdown'
import { focusTerminalTabSurface } from '@/lib/focus-terminal-tab-surface'
import { extractIpcErrorMessage } from '@/lib/ipc-error'
import {
ORCHESTRATION_SETUP_DISMISSED_STORAGE_KEY,
ORCHESTRATION_SETUP_STATE_EVENT,
@@ -11,12 +30,18 @@ import {
notifyOrchestrationSetupStateChanged
} from '@/lib/orchestration-setup-state'
import { useAppStore } from '@/store'
import type { OpenFile } from '@/store/slices/editor'
import { destroyWorkspaceWebviews } from '@/store/slices/browser-webview-cleanup'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import type { TerminalTab } from '../../../../shared/types'
import type {
BrowserTab as BrowserTabState,
Tab,
TabGroup,
TerminalTab
} from '../../../../shared/types'
import { FloatingTerminalOrchestrationDialog } from './FloatingTerminalOrchestrationDialog'
import { FloatingTerminalResizeHandles } from './FloatingTerminalResizeHandles'
import { FloatingTerminalWindowControls } from './FloatingTerminalWindowControls'
import { reconcileTabOrder } from '@/components/tab-bar/reconcile-order'
export { FloatingTerminalToggleButton } from './FloatingTerminalToggleButton'
import {
clampFloatingTerminalBounds,
@@ -25,6 +50,11 @@ import {
type FloatingTerminalPanelBounds
} from './floating-terminal-panel-bounds'
const EMPTY_TERMINAL_TABS: TerminalTab[] = []
const EMPTY_BROWSER_TABS: BrowserTabState[] = []
const EMPTY_GROUPS: TabGroup[] = []
const EMPTY_UNIFIED_TABS: Tab[] = []
const EditorPanel = lazy(() => import('@/components/editor/EditorPanel'))
type FloatingTerminalPanelProps = {
open: boolean
@@ -43,16 +73,27 @@ export function FloatingTerminalPanel({
onOpenChange
}: FloatingTerminalPanelProps): React.JSX.Element | null {
const tabsByWorktree = useAppStore((s) => s.tabsByWorktree)
const activeTabIdByWorktree = useAppStore((s) => s.activeTabIdByWorktree)
const browserTabsByWorktree = useAppStore((s) => s.browserTabsByWorktree)
const browserPagesByWorkspace = useAppStore((s) => s.browserPagesByWorkspace)
const groupsByWorktree = useAppStore((s) => s.groupsByWorktree)
const unifiedTabsByWorktree = useAppStore((s) => s.unifiedTabsByWorktree)
const openFiles = useAppStore((s) => s.openFiles)
const expandedPaneByTabId = useAppStore((s) => s.expandedPaneByTabId)
const createTab = useAppStore((s) => s.createTab)
const createBrowserTab = useAppStore((s) => s.createBrowserTab)
const closeTab = useAppStore((s) => s.closeTab)
const setActiveTabForWorktree = useAppStore((s) => s.setActiveTabForWorktree)
const setTabBarOrder = useAppStore((s) => s.setTabBarOrder)
const closeBrowserTab = useAppStore((s) => s.closeBrowserTab)
const closeFile = useAppStore((s) => s.closeFile)
const markFileDirty = useAppStore((s) => s.markFileDirty)
const activateTab = useAppStore((s) => s.activateTab)
const setActiveTab = useAppStore((s) => s.setActiveTab)
const setTabCustomTitle = useAppStore((s) => s.setTabCustomTitle)
const setTabColor = useAppStore((s) => s.setTabColor)
const setTabPaneExpanded = useAppStore((s) => s.setTabPaneExpanded)
const tabBarOrder = useAppStore((s) => s.tabBarOrderByWorktree[FLOATING_TERMINAL_WORKTREE_ID])
const pinFile = useAppStore((s) => s.pinFile)
const openFile = useAppStore((s) => s.openFile)
const browserDefaultUrl = useAppStore((s) => s.browserDefaultUrl)
const settings = useAppStore((s) => s.settings)
const floatingTerminalCwd = useAppStore((s) => s.settings?.floatingTerminalCwd ?? '~')
const [cwd, setCwd] = useState<string | null>(null)
@@ -65,6 +106,7 @@ export function FloatingTerminalPanel({
const restoreBoundsRef = useRef<FloatingTerminalPanelBounds | null>(null)
const normalizedInitialBoundsRef = useRef(false)
const previousOpenRef = useRef(false)
const pendingLastEditorCloseRef = useRef(false)
const panelRef = useRef<HTMLDivElement>(null)
const dragRef = useRef<{
pointerId: number
@@ -75,7 +117,116 @@ export function FloatingTerminalPanel({
} | null>(null)
const tabs = tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? EMPTY_TERMINAL_TABS
const activeTabId = activeTabIdByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? tabs[0]?.id ?? null
const browserTabs = browserTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? EMPTY_BROWSER_TABS
const groups = groupsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? EMPTY_GROUPS
const unifiedTabs = unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? EMPTY_UNIFIED_TABS
const floatingFiles = useMemo(
() => openFiles.filter((file) => file.worktreeId === FLOATING_TERMINAL_WORKTREE_ID),
[openFiles]
)
const activeGroup = useMemo(
() =>
groups.find((group) => group.activeTabId != null) ??
(unifiedTabs[0]
? (groups.find((group) => group.id === unifiedTabs[0].groupId) ?? null)
: null),
[groups, unifiedTabs]
)
const groupTabs = useMemo(
() => (activeGroup ? unifiedTabs.filter((tab) => tab.groupId === activeGroup.id) : unifiedTabs),
[activeGroup, unifiedTabs]
)
const activeTab = useMemo(
() =>
(activeGroup?.activeTabId
? groupTabs.find((tab) => tab.id === activeGroup.activeTabId)
: null) ??
groupTabs[0] ??
null,
[activeGroup, groupTabs]
)
const activeTerminalId = activeTab?.contentType === 'terminal' ? activeTab.entityId : null
const activeBrowserId = activeTab?.contentType === 'browser' ? activeTab.entityId : null
const activeEditorUnifiedId =
activeTab && activeTab.contentType !== 'terminal' && activeTab.contentType !== 'browser'
? activeTab.id
: null
const activeEditorFileId =
activeTab && activeTab.contentType !== 'terminal' && activeTab.contentType !== 'browser'
? activeTab.entityId
: null
const terminalTabById = useMemo(() => new Map(tabs.map((tab) => [tab.id, tab])), [tabs])
const terminalItems = useMemo(
() =>
groupTabs
.filter((tab) => tab.contentType === 'terminal')
.map((tab) => {
const terminalTab = terminalTabById.get(tab.entityId)
return terminalTab
? {
...terminalTab,
unifiedTabId: tab.id,
title: tab.label,
customTitle: tab.customLabel ?? terminalTab.customTitle,
color: tab.color ?? terminalTab.color
}
: null
})
.filter((tab): tab is TerminalTab & { unifiedTabId: string } => tab !== null),
[groupTabs, terminalTabById]
)
const browserItems = useMemo(
() =>
groupTabs
.filter((tab) => tab.contentType === 'browser')
.map((tab) => {
const browserTab = browserTabs.find((candidate) => candidate.id === tab.entityId)
return browserTab ? { ...browserTab, tabId: tab.id } : null
})
.filter((tab): tab is BrowserTabState & { tabId: string } => tab !== null),
[browserTabs, groupTabs]
)
const editorItems = useMemo(
() =>
groupTabs
.filter((tab) => tab.contentType !== 'terminal' && tab.contentType !== 'browser')
.map((tab) => {
const file = floatingFiles.find((candidate) => candidate.id === tab.entityId)
return file ? { ...file, tabId: tab.id } : null
})
.filter((file): file is OpenFile & { tabId: string } => file !== null),
[floatingFiles, groupTabs]
)
const tabBarOrder = useMemo(
() =>
(activeGroup?.tabOrder ?? []).map((tabId) => {
const tab = groupTabs.find((candidate) => candidate.id === tabId)
return tab?.contentType === 'terminal' || tab?.contentType === 'browser'
? tab.entityId
: tabId
}),
[activeGroup, groupTabs]
)
const activeBrowserTab = activeBrowserId
? (browserTabs.find((tab) => tab.id === activeBrowserId) ?? null)
: null
const activeEditorFile = activeEditorFileId
? (floatingFiles.find((file) => file.id === activeEditorFileId) ?? null)
: null
const activeTabType =
activeTab?.contentType === 'browser'
? 'browser'
: activeTab?.contentType === 'terminal'
? 'terminal'
: 'editor'
const {
saveDialogFileId,
saveDialogFile,
requestCloseFile,
handleSaveDialogSave,
handleSaveDialogDiscard,
handleSaveDialogCancel
} = useTerminalSaveDialog({ openFiles, closeFile, markFileDirty })
useEffect(() => {
if (!open || normalizedInitialBoundsRef.current || typeof window === 'undefined') {
@@ -99,27 +250,31 @@ export function FloatingTerminalPanel({
useEffect(() => {
const opened = open && !previousOpenRef.current
previousOpenRef.current = open
// Why: zero terminal tabs only means "bootstrap" when the panel is newly
// Why: zero renderable tabs only means "bootstrap" when the panel is newly
// opened. Later zero-tab states are intentional closes or PTY exits.
if (!opened || tabs.length > 0) {
if (!opened || unifiedTabs.length > 0) {
return
}
const tab = createTab(FLOATING_TERMINAL_WORKTREE_ID, undefined, undefined, { activate: false })
setActiveTabForWorktree(FLOATING_TERMINAL_WORKTREE_ID, tab.id)
}, [createTab, open, setActiveTabForWorktree, tabs.length])
const activeTab = useMemo(
() => tabs.find((tab) => tab.id === activeTabId) ?? tabs[0] ?? null,
[activeTabId, tabs]
)
const activeFloatingTabId = activeTab?.id ?? null
const tab = createTab(FLOATING_TERMINAL_WORKTREE_ID)
focusTerminalTabSurface(tab.id)
}, [createTab, open, unifiedTabs.length])
useEffect(() => {
if (!open || !activeFloatingTabId) {
if (!open || !activeTerminalId) {
return
}
focusTerminalTabSurface(activeFloatingTabId)
}, [activeFloatingTabId, open])
focusTerminalTabSurface(activeTerminalId)
}, [activeTerminalId, open])
useEffect(() => {
if (!open || saveDialogFileId !== null || !pendingLastEditorCloseRef.current) {
return
}
if (unifiedTabs.length === 0) {
pendingLastEditorCloseRef.current = false
onOpenChange(false)
}
}, [onOpenChange, open, saveDialogFileId, unifiedTabs.length])
const refreshOrchestrationSetupVisibility = useCallback(async (): Promise<void> => {
if (isOrchestrationSetupDismissed()) {
@@ -154,73 +309,162 @@ export function FloatingTerminalPanel({
}
}, [refreshOrchestrationSetupVisibility])
const createFloatingTab = useCallback(() => {
const tab = createTab(FLOATING_TERMINAL_WORKTREE_ID, undefined, undefined, { activate: false })
setActiveTabForWorktree(FLOATING_TERMINAL_WORKTREE_ID, tab.id)
const state = useAppStore.getState()
const currentTabs = state.tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []
const stored = state.tabBarOrderByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []
const validIds = new Set(currentTabs.map((entry) => entry.id))
const order = stored.filter((id) => validIds.has(id) && id !== tab.id)
for (const entry of currentTabs) {
if (entry.id !== tab.id && !order.includes(entry.id)) {
order.push(entry.id)
const activateFloatingItem = useCallback(
(visibleId: string) => {
const item = resolveGroupTabFromVisibleId(groupTabs, visibleId)
if (!item) {
return
}
}
order.push(tab.id)
setTabBarOrder(FLOATING_TERMINAL_WORKTREE_ID, order)
focusTerminalTabSurface(tab.id)
}, [createTab, setActiveTabForWorktree, setTabBarOrder])
activateTab(item.id)
if (item.contentType === 'terminal') {
setActiveTab(item.entityId)
focusTerminalTabSurface(item.entityId)
} else if (item.contentType === 'browser') {
const workspace = useAppStore
.getState()
.browserTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID]?.find(
(tab) => tab.id === item.entityId
)
if (workspace?.activePageId && window.api?.browser) {
void window.api.browser.notifyActiveTabChanged({ browserPageId: workspace.activePageId })
}
}
},
[activateTab, groupTabs, setActiveTab]
)
const closeFloatingTab = useCallback(
(tabId: string) => {
const createFloatingTerminalTab = useCallback(
(shellOverride?: string) => {
const tab = createTab(FLOATING_TERMINAL_WORKTREE_ID, activeGroup?.id, shellOverride, {
activate: true
})
focusTerminalTabSurface(tab.id)
},
[activeGroup, createTab]
)
const createFloatingBrowserTab = useCallback(() => {
const url = browserDefaultUrl ?? 'about:blank'
createBrowserTab(FLOATING_TERMINAL_WORKTREE_ID, url, {
title: 'New Browser Tab',
focusAddressBar: true,
targetGroupId: activeGroup?.id
})
}, [activeGroup, browserDefaultUrl, createBrowserTab])
const createFloatingMarkdownTab = useCallback(() => {
if (!cwd) {
return
}
void (async () => {
try {
const fileInfo = await createUntitledMarkdownFile(
cwd,
FLOATING_TERMINAL_WORKTREE_ID,
getConnectionId(FLOATING_TERMINAL_WORKTREE_ID) ?? undefined,
settings
)
openFile(fileInfo, {
preview: false,
targetGroupId: activeGroup?.id,
suppressActiveRuntimeFallback: true
})
} catch (err) {
toast.error(extractIpcErrorMessage(err, 'Failed to create untitled markdown file.'))
}
})()
}, [activeGroup, cwd, openFile, settings])
const closeFloatingItem = useCallback(
(visibleId: string) => {
const state = useAppStore.getState()
const currentTabs = state.tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []
const isClosingLastTab = currentTabs.length === 1 && currentTabs[0]?.id === tabId
closeTab(tabId)
const currentGroupTabs = activeGroup
? (state.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []).filter(
(tab) => tab.groupId === activeGroup.id
)
: (state.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? [])
const item = resolveGroupTabFromVisibleId(currentGroupTabs, visibleId)
if (!item) {
return
}
const isClosingLastTab = currentGroupTabs.length === 1
if (item.contentType === 'terminal') {
closeTab(item.entityId)
} else if (item.contentType === 'browser') {
destroyWorkspaceWebviews(browserPagesByWorkspace, item.entityId)
closeBrowserTab(item.entityId)
} else {
requestCloseFile(item.entityId)
if (state.openFiles.find((file) => file.id === item.entityId)?.isDirty) {
pendingLastEditorCloseRef.current = isClosingLastTab
return
}
}
if (isClosingLastTab) {
onOpenChange(false)
}
},
[closeTab, onOpenChange]
[
activeGroup,
browserPagesByWorkspace,
closeBrowserTab,
closeTab,
onOpenChange,
requestCloseFile
]
)
const closeOthers = useCallback(
(tabId: string) => {
// Why: bulk close mutates Zustand immediately, so read the live tab list
// instead of closing over the render snapshot.
(visibleId: string) => {
const state = useAppStore.getState()
const currentTabs = state.tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []
for (const tab of currentTabs) {
if (tab.id !== tabId) {
closeTab(tab.id)
const currentGroupTabs = activeGroup
? (state.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []).filter(
(tab) => tab.groupId === activeGroup.id
)
: (state.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? [])
const item = resolveGroupTabFromVisibleId(currentGroupTabs, visibleId)
if (!item) {
return
}
for (const tab of currentGroupTabs) {
if (tab.id !== item.id && !tab.isPinned) {
closeFloatingItem(tab.id)
}
}
},
[closeTab]
[activeGroup, closeFloatingItem]
)
const closeToRight = useCallback(
(tabId: string) => {
// Why: see closeOthers; context menu actions can run after the rendered
// tab snapshot is stale.
(visibleId: string) => {
const state = useAppStore.getState()
const currentTabs = state.tabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []
const terminalIds = currentTabs.map((tab) => tab.id)
const visibleIds = reconcileTabOrder(
state.tabBarOrderByWorktree[FLOATING_TERMINAL_WORKTREE_ID],
terminalIds,
[]
)
const index = visibleIds.findIndex((id) => id === tabId)
const currentGroup = activeGroup
? state.groupsByWorktree[FLOATING_TERMINAL_WORKTREE_ID]?.find(
(group) => group.id === activeGroup.id
)
: null
const currentGroupTabs = currentGroup
? (state.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? []).filter(
(tab) => tab.groupId === currentGroup.id
)
: (state.unifiedTabsByWorktree[FLOATING_TERMINAL_WORKTREE_ID] ?? [])
const item = resolveGroupTabFromVisibleId(currentGroupTabs, visibleId)
if (!item || !currentGroup) {
return
}
const index = currentGroup.tabOrder.indexOf(item.id)
if (index === -1) {
return
}
for (const id of visibleIds.slice(index + 1)) {
closeTab(id)
const tabById = new Map(currentGroupTabs.map((tab) => [tab.id, tab]))
for (const tabId of currentGroup.tabOrder.slice(index + 1)) {
const tab = tabById.get(tabId)
if (tab && !tab.isPinned) {
closeFloatingItem(tab.id)
}
}
},
[closeTab]
[activeGroup, closeFloatingItem]
)
const toggleMaximized = useCallback(() => {
@@ -325,23 +569,49 @@ export function FloatingTerminalPanel({
>
<div className="flex h-full min-w-0 flex-1">
<TabBar
tabs={tabs}
activeTabId={activeTab?.id ?? null}
tabs={terminalItems}
activeTabId={activeTerminalId}
worktreeId={FLOATING_TERMINAL_WORKTREE_ID}
expandedPaneByTabId={expandedPaneByTabId}
onActivate={(tabId) => setActiveTabForWorktree(FLOATING_TERMINAL_WORKTREE_ID, tabId)}
onClose={closeFloatingTab}
onActivate={activateFloatingItem}
onClose={closeFloatingItem}
onCloseOthers={closeOthers}
onCloseToRight={closeToRight}
onNewTerminalTab={createFloatingTab}
onNewBrowserTab={() => {}}
terminalOnly
onNewTerminalTab={() => createFloatingTerminalTab()}
onNewTerminalWithShell={createFloatingTerminalTab}
onNewBrowserTab={createFloatingBrowserTab}
onNewFileTab={createFloatingMarkdownTab}
onSetCustomTitle={setTabCustomTitle}
onSetTabColor={setTabColor}
onTogglePaneExpand={(tabId) =>
setTabPaneExpanded(tabId, expandedPaneByTabId[tabId] !== true)
}
activeTabType="terminal"
editorFiles={editorItems}
browserTabs={browserItems}
activeFileId={activeEditorUnifiedId}
activeBrowserTabId={activeBrowserId}
activeTabType={activeTabType}
onActivateFile={activateFloatingItem}
onCloseFile={closeFloatingItem}
onActivateBrowserTab={activateFloatingItem}
onCloseBrowserTab={closeFloatingItem}
onDuplicateBrowserTab={(browserTabId) => {
const source = browserTabs.find((tab) => tab.id === browserTabId)
if (!source) {
return
}
createBrowserTab(FLOATING_TERMINAL_WORKTREE_ID, source.url, {
title: source.title,
sessionProfileId: source.sessionProfileId,
targetGroupId: activeGroup?.id
})
}}
onCloseAllFiles={() => {
for (const file of editorItems) {
closeFloatingItem(file.tabId)
}
}}
onPinFile={pinFile}
tabBarOrder={tabBarOrder}
/>
</div>
@@ -354,29 +624,58 @@ export function FloatingTerminalPanel({
<div className="relative min-h-0 flex-1 overflow-hidden bg-background">
{cwd
? tabs.map((tab) => (
<div
key={`${tab.id}-${tab.generation ?? 0}`}
className={
tab.id === activeTab?.id ? 'absolute inset-0' : 'absolute inset-0 hidden'
}
aria-hidden={tab.id !== activeTab?.id}
>
<TerminalPane
tabId={tab.id}
worktreeId={FLOATING_TERMINAL_WORKTREE_ID}
cwd={cwd}
isActive={tab.id === activeTab?.id}
isVisible={tab.id === activeTab?.id}
onPtyExit={() => closeTab(tab.id)}
onCloseTab={() => closeFloatingTab(tab.id)}
/>
</div>
))
? tabs.map((tab) => {
const isActive = tab.id === activeTerminalId
return (
<div
key={`${tab.id}-${tab.generation ?? 0}`}
className={isActive ? 'absolute inset-0' : 'absolute inset-0 hidden'}
aria-hidden={!isActive}
>
<TerminalPane
tabId={tab.id}
worktreeId={FLOATING_TERMINAL_WORKTREE_ID}
cwd={cwd}
isActive={isActive}
isVisible={isActive}
onPtyExit={() => closeTab(tab.id)}
onCloseTab={() => closeFloatingItem(tab.id)}
/>
</div>
)
})
: null}
{browserTabs.map((tab) => {
const isActive = tab.id === activeBrowserTab?.id
return (
<div
key={tab.id}
className={isActive ? 'absolute inset-0 flex' : 'absolute inset-0 hidden'}
aria-hidden={!isActive}
>
<BrowserPane browserTab={tab} isActive={isActive} />
</div>
)
})}
{activeEditorFile ? (
<div className="absolute inset-0 flex min-h-0 min-w-0">
<Suspense
fallback={
<div className="flex flex-1 items-center justify-center text-sm text-muted-foreground">
Loading editor...
</div>
}
>
<EditorPanel
activeFileId={activeEditorFile.id}
activeViewStateId={activeEditorUnifiedId}
/>
</Suspense>
</div>
) : null}
</div>
</div>
{showOrchestrationSetup ? (
{showOrchestrationSetup && activeTabType === 'terminal' ? (
<div
className="absolute right-4 bottom-4 z-10 w-[280px] rounded-md border border-border/60 bg-card/95 p-3 text-card-foreground shadow-xs"
data-floating-terminal-no-drag
@@ -414,10 +713,40 @@ export function FloatingTerminalPanel({
{!maximized && <FloatingTerminalResizeHandles bounds={bounds} setBounds={setBounds} />}
<FloatingTerminalOrchestrationDialog
open={orchestrationDialogOpen}
activeTabId={activeTab?.id ?? null}
activeTabId={activeTerminalId}
onOpenChange={setOrchestrationDialogOpen}
onSetupStateChange={() => void refreshOrchestrationSetupVisibility()}
/>
<Dialog
open={saveDialogFileId !== null}
onOpenChange={(nextOpen) => {
if (!nextOpen) {
handleSaveDialogCancel()
}
}}
>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="text-sm">Unsaved Changes</DialogTitle>
<DialogDescription className="text-xs">
{saveDialogFile
? `"${saveDialogFile.relativePath.split('/').pop()}" has unsaved changes. Do you want to save before closing?`
: 'This file has unsaved changes.'}
</DialogDescription>
</DialogHeader>
<DialogFooter className="gap-2">
<Button type="button" variant="outline" size="sm" onClick={handleSaveDialogCancel}>
Cancel
</Button>
<Button type="button" variant="outline" size="sm" onClick={handleSaveDialogDiscard}>
Don&apos;t Save
</Button>
<Button type="button" size="sm" onClick={handleSaveDialogSave}>
Save
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
}
@@ -9,6 +9,7 @@ import {
} from '../../runtime/runtime-compatibility-test-fixture'
import { GRAB_BUDGET, type BrowserPageAnnotation } from '../../../../shared/browser-grab-types'
import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
const runtimeEnvironmentCall = vi.fn()
const runtimeEnvironmentTransportCall = vi.fn()
@@ -180,6 +181,20 @@ describe('createBrowserSlice annotations', () => {
})
})
describe('createBrowserSlice floating tabs', () => {
it('focuses the address bar for new floating browser tabs without changing the main surface', () => {
const store = createTestStore()
store.setState({ activeWorktreeId: 'wt-1', activeTabType: 'terminal' } as Partial<AppState>)
const tab = store.getState().createBrowserTab(FLOATING_TERMINAL_WORKTREE_ID, 'about:blank', {
focusAddressBar: true
})
expect(store.getState().pendingAddressBarFocusByTabId[tab.id]).toBe(true)
expect(store.getState().activeTabType).toBe('terminal')
})
})
describe('createBrowserSlice runtime guard', () => {
beforeEach(() => {
vi.clearAllMocks()
+3 -2
View File
@@ -13,7 +13,7 @@ import type {
WorkspaceSessionState
} from '../../../../shared/types'
import { GRAB_BUDGET, type BrowserPageAnnotation } from '../../../../shared/browser-grab-types'
import { ORCA_BROWSER_BLANK_URL } from '../../../../shared/constants'
import { FLOATING_TERMINAL_WORKTREE_ID, ORCA_BROWSER_BLANK_URL } from '../../../../shared/constants'
import { redactKagiSessionToken } from '../../../../shared/browser-url'
import {
MAX_BROWSER_HISTORY_ENTRIES,
@@ -419,8 +419,9 @@ export const createBrowserSlice: StateCreator<AppState, [], [], BrowserSlice> =
const shouldActivate = options?.activate ?? true
const shouldUpdateGlobalActiveSurface = shouldActivate && s.activeWorktreeId === worktreeId
const shouldFocusFloatingTab = shouldActivate && worktreeId === FLOATING_TERMINAL_WORKTREE_ID
const shouldFocusAddressBar =
shouldUpdateGlobalActiveSurface &&
(shouldUpdateGlobalActiveSurface || shouldFocusFloatingTab) &&
(options?.focusAddressBar ??
(page.url === 'about:blank' || page.url === ORCA_BROWSER_BLANK_URL))
@@ -9,6 +9,7 @@ import {
type RuntimeEnvironmentCallRequest
} from '../../runtime/runtime-compatibility-test-fixture'
import { clearRuntimeCompatibilityCacheForTests } from '../../runtime/runtime-rpc-client'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
const { toastErrorMock } = vi.hoisted(() => ({
toastErrorMock: vi.fn()
@@ -251,6 +252,34 @@ describe('createEditorSlice openDiff', () => {
})
})
describe('createEditorSlice floating editor activation', () => {
it('opens floating markdown tabs without changing the main active editor surface', () => {
const store = createEditorStore()
store.setState({
activeFileId: '/repo/main.md',
activeTabType: 'editor',
activeFileIdByWorktree: { 'wt-1': '/repo/main.md' },
activeTabTypeByWorktree: { 'wt-1': 'editor' }
} as Partial<AppState>)
store.getState().openFile({
filePath: '/tmp/orca/untitled.md',
relativePath: 'untitled.md',
worktreeId: FLOATING_TERMINAL_WORKTREE_ID,
language: 'markdown',
isUntitled: true,
mode: 'edit'
})
expect(store.getState().activeFileId).toBe('/repo/main.md')
expect(store.getState().activeTabType).toBe('editor')
expect(store.getState().activeFileIdByWorktree[FLOATING_TERMINAL_WORKTREE_ID]).toBe(
'/tmp/orca/untitled.md'
)
expect(store.getState().activeTabTypeByWorktree[FLOATING_TERMINAL_WORKTREE_ID]).toBe('editor')
})
})
describe('createEditorSlice untitled cleanup routing', () => {
const runtimeEnvironmentCallMock = vi.fn()
const runtimeEnvironmentTransportCallMock = vi.fn()
+25 -12
View File
@@ -25,6 +25,7 @@ import type {
WorkspaceVisibleTabType
} from '../../../../shared/types'
import { stripCredentialsFromMessage } from '../../../../shared/git-remote-error'
import { FLOATING_TERMINAL_WORKTREE_ID } from '../../../../shared/constants'
import type { RemoteOpKind } from '@/components/right-sidebar/source-control-primary-action'
import {
fetchRuntimeGit,
@@ -494,6 +495,28 @@ function openWorkspaceEditorItem(
return created?.id ?? fileId
}
function buildEditorActiveResult(
state: Pick<EditorSlice, 'activeFileIdByWorktree' | 'activeTabTypeByWorktree'>,
worktreeId: string,
fileId: string
): {
activeFileId?: string
activeTabType?: 'editor'
activeFileIdByWorktree: Record<string, string | null>
activeTabTypeByWorktree: Record<string, WorkspaceVisibleTabType>
} {
return {
// Why: floating markdown tabs use the editor surface without becoming the
// main worktree's active editor. Updating only the per-worktree maps keeps
// the workspace behind the floating panel from switching surfaces.
...(worktreeId === FLOATING_TERMINAL_WORKTREE_ID
? {}
: { activeFileId: fileId, activeTabType: 'editor' as const }),
activeFileIdByWorktree: { ...state.activeFileIdByWorktree, [worktreeId]: fileId },
activeTabTypeByWorktree: { ...state.activeTabTypeByWorktree, [worktreeId]: 'editor' }
}
}
const REMOTE_OPERATION_FAILED_MESSAGE = 'Remote operation failed'
const REMOTE_OPERATION_DETAIL_MAX_LENGTH = 200
@@ -823,12 +846,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
}
}
const activeResult = {
activeFileId: id,
activeTabType: 'editor' as const,
activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id },
activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [worktreeId]: 'editor' as const }
}
const activeResult = buildEditorActiveResult(s, worktreeId, id)
if (existing) {
// If opening as non-preview, also pin the existing tab
@@ -1038,12 +1056,7 @@ export const createEditorSlice: StateCreator<AppState, [], [], EditorSlice> = (s
const worktreeId = file.worktreeId
const runtimeEnvironmentId =
file.runtimeEnvironmentId ?? s.settings?.activeRuntimeEnvironmentId?.trim() ?? undefined
const activeResult = {
activeFileId: id,
activeTabType: 'editor' as const,
activeFileIdByWorktree: { ...s.activeFileIdByWorktree, [worktreeId]: id },
activeTabTypeByWorktree: { ...s.activeTabTypeByWorktree, [worktreeId]: 'editor' as const }
}
const activeResult = buildEditorActiveResult(s, worktreeId, id)
if (existing) {
const needsUpdate =