From 98ab911e6e540e2f2bbaaa2045b3ad08d5e9bdb5 Mon Sep 17 00:00:00 2001 From: Jinwoo-H Date: Tue, 19 May 2026 15:34:33 -0400 Subject: [PATCH] WIP: floating terminal mixed tabs Co-authored-by: Orca --- 00-review-context.md | 60 ++ .../FloatingTerminalPanel.test.tsx | 146 ++++- .../FloatingTerminalPanel.tsx | 519 ++++++++++++++---- src/renderer/src/store/slices/browser.test.ts | 15 + src/renderer/src/store/slices/browser.ts | 5 +- src/renderer/src/store/slices/editor.test.ts | 29 + src/renderer/src/store/slices/editor.ts | 37 +- 7 files changed, 691 insertions(+), 120 deletions(-) create mode 100644 00-review-context.md diff --git a/00-review-context.md b/00-review-context.md new file mode 100644 index 00000000000..44aaa5d51ab --- /dev/null +++ b/00-review-context.md @@ -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) + + + +| 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) + + + + + +None. + +## Iteration State + +Current iteration: 1 +Last completed phase: Setup +Files fixed this iteration: [] diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx index cf0eef15867..f2a7390984a 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.test.tsx @@ -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 + browserTabsByWorktree: Record + browserPagesByWorkspace: Record + groupsByWorktree: Record + unifiedTabsByWorktree: Record + openFiles: OpenFile[] activeTabIdByWorktree: Record expandedPaneByTabId: Record 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 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 { function setFloatingTabs(tabs: TerminalTab[]): void { const state = storeBox.state as FloatingPanelStoreState + const groupId = 'floating-group' + const unifiedTabs = tabs.map((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') diff --git a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx index dace0aab399..e5518bccbc4 100644 --- a/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx +++ b/src/renderer/src/components/floating-terminal/FloatingTerminalPanel.tsx @@ -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(null) @@ -65,6 +106,7 @@ export function FloatingTerminalPanel({ const restoreBoundsRef = useRef(null) const normalizedInitialBoundsRef = useRef(false) const previousOpenRef = useRef(false) + const pendingLastEditorCloseRef = useRef(false) const panelRef = useRef(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 => { 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({ >
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} />
@@ -354,29 +624,58 @@ export function FloatingTerminalPanel({
{cwd - ? tabs.map((tab) => ( -
- closeTab(tab.id)} - onCloseTab={() => closeFloatingTab(tab.id)} - /> -
- )) + ? tabs.map((tab) => { + const isActive = tab.id === activeTerminalId + return ( +
+ closeTab(tab.id)} + onCloseTab={() => closeFloatingItem(tab.id)} + /> +
+ ) + }) : null} + {browserTabs.map((tab) => { + const isActive = tab.id === activeBrowserTab?.id + return ( +
+ +
+ ) + })} + {activeEditorFile ? ( +
+ + Loading editor... +
+ } + > + + +
+ ) : null} - {showOrchestrationSetup ? ( + {showOrchestrationSetup && activeTabType === 'terminal' ? (
} void refreshOrchestrationSetupVisibility()} /> + { + if (!nextOpen) { + handleSaveDialogCancel() + } + }} + > + + + Unsaved Changes + + {saveDialogFile + ? `"${saveDialogFile.relativePath.split('/').pop()}" has unsaved changes. Do you want to save before closing?` + : 'This file has unsaved changes.'} + + + + + + + + +
) } diff --git a/src/renderer/src/store/slices/browser.test.ts b/src/renderer/src/store/slices/browser.test.ts index cea51ba8405..15f0b82e9cc 100644 --- a/src/renderer/src/store/slices/browser.test.ts +++ b/src/renderer/src/store/slices/browser.test.ts @@ -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) + + 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() diff --git a/src/renderer/src/store/slices/browser.ts b/src/renderer/src/store/slices/browser.ts index 644c4bfe928..2244d6f0459 100644 --- a/src/renderer/src/store/slices/browser.ts +++ b/src/renderer/src/store/slices/browser.ts @@ -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 = 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)) diff --git a/src/renderer/src/store/slices/editor.test.ts b/src/renderer/src/store/slices/editor.test.ts index d7626d38284..eb511f6190b 100644 --- a/src/renderer/src/store/slices/editor.test.ts +++ b/src/renderer/src/store/slices/editor.test.ts @@ -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) + + 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() diff --git a/src/renderer/src/store/slices/editor.ts b/src/renderer/src/store/slices/editor.ts index b93272e4114..e4aa2346dde 100644 --- a/src/renderer/src/store/slices/editor.ts +++ b/src/renderer/src/store/slices/editor.ts @@ -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, + worktreeId: string, + fileId: string +): { + activeFileId?: string + activeTabType?: 'editor' + activeFileIdByWorktree: Record + activeTabTypeByWorktree: Record +} { + 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 = (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 = (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 =