diff --git a/bun.lock b/bun.lock index ac224853e5..17dbce79f4 100644 --- a/bun.lock +++ b/bun.lock @@ -14,6 +14,9 @@ "@ai-sdk/xai": "^2.0.39", "@aws-sdk/credential-providers": "^3.940.0", "@coder/mux-md-client": "^0.1.0-main.14", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@homebridge/ciao": "^1.3.4", "@jitl/quickjs-wasmfile-release-asyncify": "^0.31.0", "@lydell/node-pty": "1.1.0", @@ -38,6 +41,8 @@ "@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-visually-hidden": "^1.2.4", + "@xterm/addon-serialize": "^0.14.0", + "@xterm/headless": "^6.0.0", "ai": "^5.0.106", "ai-tokenizer": "^1.0.4", "chalk": "^5.6.2", @@ -67,6 +72,7 @@ "quickjs-emscripten": "^0.31.0", "quickjs-emscripten-core": "^0.31.0", "react-colorful": "^5.6.1", + "react-resizable-panels": "^3.0.6", "react-router-dom": "^7.11.0", "rehype-harden": "^1.1.5", "rehype-sanitize": "^6.0.0", @@ -542,6 +548,14 @@ "@develar/schema-utils": ["@develar/schema-utils@2.6.5", "", { "dependencies": { "ajv": "^6.12.0", "ajv-keywords": "^3.4.1" } }, "sha512-0cp4PsWQ/9avqTVMCtZ+GirikIA36ikvjtHweU4/j8yLtgObI0+JUPhYFScgwlteveGB1rt3Cm8UhN04XayDig=="], + "@dnd-kit/accessibility": ["@dnd-kit/accessibility@3.1.1", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw=="], + + "@dnd-kit/core": ["@dnd-kit/core@6.3.1", "", { "dependencies": { "@dnd-kit/accessibility": "^3.1.1", "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0", "react-dom": ">=16.8.0" } }, "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ=="], + + "@dnd-kit/sortable": ["@dnd-kit/sortable@10.0.0", "", { "dependencies": { "@dnd-kit/utilities": "^3.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@dnd-kit/core": "^6.3.0", "react": ">=16.8.0" } }, "sha512-+xqhmIIzvAYMGfBYYnbKuNicfSsk4RksY2XdmJhT+HAC01nix6fHCztU68jooFiMUB01Ky3F0FyOvhG/BZrWkg=="], + + "@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="], + "@electron/asar": ["@electron/asar@3.4.1", "", { "dependencies": { "commander": "^5.0.0", "glob": "^7.1.6", "minimatch": "^3.0.4" }, "bin": { "asar": "bin/asar.js" } }, "sha512-i4/rNPRS84t0vSRa2HorerGRXWyF4vThfHesw0dmcWHp+cspK743UanA0suA5Q5y8kzY2y6YKrvbIUn69BCAiA=="], "@electron/get": ["@electron/get@2.0.3", "", { "dependencies": { "debug": "^4.1.1", "env-paths": "^2.2.0", "fs-extra": "^8.1.0", "got": "^11.8.5", "progress": "^2.0.3", "semver": "^6.2.0", "sumchecker": "^3.0.1" }, "optionalDependencies": { "global-agent": "^3.0.0" } }, "sha512-Qkzpg2s9GnVV2I2BjRksUi43U5e6+zaQMcjoJy0C+C5oxaKl+fmckGDQFtRpZpZV0NQekuZZ+tGz7EA9TVnQtQ=="], @@ -1552,6 +1566,10 @@ "@xmldom/xmldom": ["@xmldom/xmldom@0.8.11", "", {}, "sha512-cQzWCtO6C8TQiYl1ruKNn2U6Ao4o4WBBcbL61yJl84x+j5sOWWFU9X7DpND8XZG3daDppSsigMdfAIl2upQBRw=="], + "@xterm/addon-serialize": ["@xterm/addon-serialize@0.14.0", "", {}, "sha512-uteyTU1EkrQa2Ux6P/uFl2fzmXI46jy5uoQMKEOM0fKTyiW7cSn0WrFenHm5vO5uEXX/GpwW/FgILvv3r0WbkA=="], + + "@xterm/headless": ["@xterm/headless@6.0.0", "", {}, "sha512-5Yj1QINYCyzrZtf8OFIHi47iQtI+0qYFPHmouEfG8dHNxbZ9Tb9YGSuLcsEwj9Z+OL75GJqPyJbyoFer80a2Hw=="], + "abbrev": ["abbrev@3.0.1", "", {}, "sha512-AO2ac6pjRB3SJmGJo+v5/aK6Omggp6fsLrs6wN9bd35ulu4cCwaAU9+7ZhXjeqHVkaHThLuzH0nZr0YpCDhygg=="], "accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], @@ -3182,6 +3200,8 @@ "react-remove-scroll-bar": ["react-remove-scroll-bar@2.3.8", "", { "dependencies": { "react-style-singleton": "^2.2.2", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react"] }, "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q=="], + "react-resizable-panels": ["react-resizable-panels@3.0.6", "", { "peerDependencies": { "react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" } }, "sha512-b3qKHQ3MLqOgSS+FRYKapNkJZf5EQzuf6+RLiq1/IlTHw99YrZ2NJZLk4hQIzTnnIkRg2LUqyVinu6YWWpUYew=="], + "react-router": ["react-router@7.11.0", "", { "dependencies": { "cookie": "^1.0.1", "set-cookie-parser": "^2.6.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" }, "optionalPeers": ["react-dom"] }, "sha512-uI4JkMmjbWCZc01WVP2cH7ZfSzH91JAZUDd7/nIprDgWxBV1TkkmLToFh7EbMTcMak8URFRa2YoBL/W8GWnCTQ=="], "react-router-dom": ["react-router-dom@7.11.0", "", { "dependencies": { "react-router": "7.11.0" }, "peerDependencies": { "react": ">=18", "react-dom": ">=18" } }, "sha512-e49Ir/kMGRzFOOrYQBdoitq3ULigw4lKbAyKusnvtDu2t4dBX4AGYPrzNvorXmVuOyeakai6FUPW5MmibvVG8g=="], @@ -4635,4 +4655,3 @@ "electron-rebuild/node-gyp/make-fetch-happen/cacache/glob/minimatch/brace-expansion": ["brace-expansion@2.0.2", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ=="], } } - diff --git a/package.json b/package.json index 96fad04d25..151dca1839 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,9 @@ "@ai-sdk/xai": "^2.0.39", "@aws-sdk/credential-providers": "^3.940.0", "@coder/mux-md-client": "^0.1.0-main.14", + "@dnd-kit/core": "^6.3.1", + "@dnd-kit/sortable": "^10.0.0", + "@dnd-kit/utilities": "^3.2.2", "@homebridge/ciao": "^1.3.4", "@jitl/quickjs-wasmfile-release-asyncify": "^0.31.0", "@lydell/node-pty": "1.1.0", @@ -78,6 +81,8 @@ "@radix-ui/react-toggle-group": "^1.1.11", "@radix-ui/react-tooltip": "^1.2.8", "@radix-ui/react-visually-hidden": "^1.2.4", + "@xterm/addon-serialize": "^0.14.0", + "@xterm/headless": "^6.0.0", "ai": "^5.0.106", "ai-tokenizer": "^1.0.4", "chalk": "^5.6.2", @@ -90,6 +95,7 @@ "electron-updater": "^6.6.2", "electron-window-state": "^5.0.3", "express": "^5.1.0", + "fix-path": "5.0.0", "ghostty-web": "^0.3.0-next.13.g3dd4aef", "jsdom": "^27.2.0", "json-schema-to-typescript": "^15.0.4", @@ -106,6 +112,7 @@ "quickjs-emscripten": "^0.31.0", "quickjs-emscripten-core": "^0.31.0", "react-colorful": "^5.6.1", + "react-resizable-panels": "^3.0.6", "react-router-dom": "^7.11.0", "rehype-harden": "^1.1.5", "rehype-sanitize": "^6.0.0", @@ -122,8 +129,7 @@ "xxhash-wasm": "^1.1.0", "yaml": "^2.8.2", "zod": "^4.1.11", - "zod-to-json-schema": "^3.24.6", - "fix-path": "5.0.0" + "zod-to-json-schema": "^3.24.6" }, "devDependencies": { "@babel/core": "^7.28.5", diff --git a/src/browser/App.tsx b/src/browser/App.tsx index 48037edb57..585c261ac1 100644 --- a/src/browser/App.tsx +++ b/src/browser/App.tsx @@ -443,7 +443,12 @@ function AppInner() { onRemoveProject: removeProjectFromPalette, onToggleSidebar: toggleSidebarFromPalette, onNavigateWorkspace: navigateWorkspaceFromPalette, - onOpenWorkspaceInTerminal: openWorkspaceInTerminal, + onOpenWorkspaceInTerminal: (workspaceId, runtimeConfig) => { + // Best-effort only. Palette actions should never throw. + void openWorkspaceInTerminal(workspaceId, runtimeConfig).catch(() => { + // Errors are surfaced elsewhere (toasts/logs) and users can retry. + }); + }, onToggleTheme: toggleTheme, onSetTheme: setThemePreference, onOpenSettings: openSettings, diff --git a/src/browser/components/AIView.tsx b/src/browser/components/AIView.tsx index 09ba67aaf7..955d6053ab 100644 --- a/src/browser/components/AIView.tsx +++ b/src/browser/components/AIView.tsx @@ -17,13 +17,11 @@ import { PinnedTodoList } from "./PinnedTodoList"; import { getAutoRetryKey, VIM_ENABLED_KEY, - RIGHT_SIDEBAR_TAB_KEY, - RIGHT_SIDEBAR_COSTS_WIDTH_KEY, - RIGHT_SIDEBAR_REVIEW_WIDTH_KEY, + RIGHT_SIDEBAR_WIDTH_KEY, } from "@/common/constants/storage"; import { WORKSPACE_DEFAULTS } from "@/constants/workspaceDefaults"; import { ChatInput, type ChatInputAPI } from "./ChatInput/index"; -import { RightSidebar, type TabType } from "./RightSidebar"; +import { RightSidebar } from "./RightSidebar"; import { useResizableSidebar } from "@/browser/hooks/useResizableSidebar"; import { shouldShowInterruptedBarrier, @@ -40,7 +38,7 @@ import { ProviderOptionsProvider } from "@/browser/contexts/ProviderOptionsConte import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; import { useAutoScroll } from "@/browser/hooks/useAutoScroll"; -import { useOpenTerminal } from "@/browser/hooks/useOpenTerminal"; + import { useOpenInEditor } from "@/browser/hooks/useOpenInEditor"; import { usePersistedState } from "@/browser/hooks/usePersistedState"; @@ -104,34 +102,16 @@ const AIViewInner: React.FC = ({ const { workspaceMetadata } = useWorkspaceContext(); const chatAreaRef = useRef(null); - // Track which right sidebar tab is selected (listener: true to sync with RightSidebar changes) - const [selectedRightTab] = usePersistedState(RIGHT_SIDEBAR_TAB_KEY, "costs", { - listener: true, - }); - - // Resizable RightSidebar width - separate hooks per tab for independent persistence - const costsSidebar = useResizableSidebar({ - // Costs + Stats share the same resizable width persistence - enabled: selectedRightTab === "costs" || selectedRightTab === "stats", - defaultWidth: 300, + // Resizable RightSidebar width - single unified width for all tabs + const sidebar = useResizableSidebar({ + enabled: true, + defaultWidth: 400, minWidth: 300, maxWidth: 1200, - storageKey: RIGHT_SIDEBAR_COSTS_WIDTH_KEY, - }); - const reviewSidebar = useResizableSidebar({ - enabled: selectedRightTab === "review", - defaultWidth: 600, - minWidth: 300, - maxWidth: 1200, - storageKey: RIGHT_SIDEBAR_REVIEW_WIDTH_KEY, + storageKey: RIGHT_SIDEBAR_WIDTH_KEY, }); - // Derive active sidebar props based on selected tab - const sidebarWidth = selectedRightTab === "review" ? reviewSidebar.width : costsSidebar.width; - const isResizing = - selectedRightTab === "review" ? reviewSidebar.isResizing : costsSidebar.isResizing; - const startResize = - selectedRightTab === "review" ? reviewSidebar.startResize : costsSidebar.startResize; + const { width: sidebarWidth, isResizing, startResize } = sidebar; const workspaceState = useWorkspaceState(workspaceId); const storeRaw = useWorkspaceStoreRaw(); @@ -422,10 +402,11 @@ const AIViewInner: React.FC = ({ [api] ); - const openTerminal = useOpenTerminal(); + // Ref to hold addTerminal function from RightSidebar (for Cmd/Ctrl+T keybind) + const addTerminalRef = useRef<(() => void) | null>(null); const handleOpenTerminal = useCallback(() => { - openTerminal(workspaceId, runtimeConfig); - }, [workspaceId, openTerminal, runtimeConfig]); + addTerminalRef.current?.(); + }, []); const openInEditor = useOpenInEditor(); const handleOpenInEditor = useCallback(() => { @@ -593,6 +574,7 @@ const AIViewInner: React.FC = ({ workspaceName={workspaceName} namedWorkspacePath={namedWorkspacePath} runtimeConfig={runtimeConfig} + onOpenTerminal={handleOpenTerminal} />
@@ -802,6 +784,7 @@ const AIViewInner: React.FC = ({ isResizing={isResizing} onReviewNote={handleReviewNote} isCreating={status === "creating"} + addTerminalRef={addTerminalRef} /> - + + + ); } diff --git a/src/browser/components/RightSidebar.tsx b/src/browser/components/RightSidebar.tsx index 1036865ac5..0ad54d8e1a 100644 --- a/src/browser/components/RightSidebar.tsx +++ b/src/browser/components/RightSidebar.tsx @@ -1,38 +1,90 @@ import React from "react"; -import { RIGHT_SIDEBAR_TAB_KEY, RIGHT_SIDEBAR_COLLAPSED_KEY } from "@/common/constants/storage"; -import { usePersistedState } from "@/browser/hooks/usePersistedState"; +import { + RIGHT_SIDEBAR_COLLAPSED_KEY, + RIGHT_SIDEBAR_TAB_KEY, + getRightSidebarLayoutKey, + getTerminalTitlesKey, +} from "@/common/constants/storage"; +import { + readPersistedState, + updatePersistedState, + usePersistedState, +} from "@/browser/hooks/usePersistedState"; import { useWorkspaceUsage, useWorkspaceStatsSnapshot } from "@/browser/stores/WorkspaceStore"; import { useFeatureFlags } from "@/browser/contexts/FeatureFlagsContext"; -import { ErrorBoundary } from "./ErrorBoundary"; +import { useAPI } from "@/browser/contexts/API"; import { CostsTab } from "./RightSidebar/CostsTab"; -import { StatsTab } from "./RightSidebar/StatsTab"; + import { ReviewPanel } from "./RightSidebar/CodeReview/ReviewPanel"; +import { ErrorBoundary } from "./ErrorBoundary"; +import { StatsTab } from "./RightSidebar/StatsTab"; + import { sumUsageHistory, type ChatUsageDisplay } from "@/common/utils/tokens/usageAggregator"; import { matchesKeybind, KEYBINDS, formatKeybind } from "@/browser/utils/ui/keybinds"; -import { Tooltip, TooltipTrigger, TooltipContent } from "./ui/tooltip"; import { SidebarCollapseButton } from "./ui/SidebarCollapseButton"; import { cn } from "@/common/lib/utils"; import type { ReviewNoteData } from "@/common/types/review"; +import { TerminalTab } from "./RightSidebar/TerminalTab"; +import { + RIGHT_SIDEBAR_TABS, + isTabType, + isTerminalTab, + getTerminalSessionId, + makeTerminalTabType, + type TabType, +} from "@/browser/types/rightSidebar"; +import { Panel, PanelGroup, PanelResizeHandle } from "react-resizable-panels"; +import { + addTabToFocusedTabset, + collectAllTabs, + collectAllTabsWithTabset, + dockTabToEdge, + findTabset, + getDefaultRightSidebarLayoutState, + isRightSidebarLayoutState, + moveTabToTabset, + parseRightSidebarLayoutState, + removeTabEverywhere, + reorderTabInTabset, + selectTabByIndex, + selectTabInTabset, + setFocusedTabset, + updateSplitSizes, + type RightSidebarLayoutNode, + type RightSidebarLayoutState, +} from "@/browser/utils/rightSidebarLayout"; +import { + RightSidebarTabStrip, + getTabName, + type TabDragData, +} from "./RightSidebar/RightSidebarTabStrip"; +import { createTerminalSession, openTerminalPopout } from "@/browser/utils/terminal"; +import { + CostsTabLabel, + ReviewTabLabel, + StatsTabLabel, + TerminalTabLabel, + getTabContentClassName, + type ReviewStats, +} from "./RightSidebar/tabs"; +import { + DndContext, + DragOverlay, + PointerSensor, + useSensor, + useSensors, + useDroppable, + type DragStartEvent, + type DragEndEvent, +} from "@dnd-kit/core"; +import { SortableContext, horizontalListSortingStrategy } from "@dnd-kit/sortable"; -/** Stats reported by ReviewPanel for tab display */ -export interface ReviewStats { - total: number; - read: number; -} - -/** Format duration for tab display (compact format) */ -function formatTabDuration(ms: number): string { - if (ms < 1000) return `${Math.round(ms)}ms`; - if (ms < 60000) return `${Math.round(ms / 1000)}s`; - const mins = Math.floor(ms / 60000); - const secs = Math.round((ms % 60000) / 1000); - return secs > 0 ? `${mins}m${secs}s` : `${mins}m`; -} +// Re-export for consumers +export type { ReviewStats }; interface SidebarContainerProps { - collapsed?: boolean; - wide?: boolean; - /** Custom width from drag-resize (persisted per-tab by AIView) */ + collapsed: boolean; + /** Custom width from drag-resize (unified across all tabs) */ customWidth?: number; /** Whether actively dragging resize handle (disables transition) */ isResizing?: boolean; @@ -45,33 +97,27 @@ interface SidebarContainerProps { * SidebarContainer - Main sidebar wrapper with dynamic width * * Width priority (first match wins): - * 1. collapsed (20px) - Manual collapse via toggle - * 2. customWidth - From drag-resize (persisted per-tab) - * 3. wide - Auto-calculated max width for Review tab (when not drag-resizing) - * 4. default (300px) - Costs tab when no customWidth saved + * 1. collapsed (20px) - Shows collapse button only + * 2. customWidth - From drag-resize (unified width from AIView) + * 3. default (400px) - Fallback when no custom width set */ const SidebarContainer: React.FC = ({ collapsed, - wide, customWidth, isResizing, children, role, "aria-label": ariaLabel, }) => { - const width = collapsed - ? "20px" // Match left sidebar collapsed width (w-5 = 20px) - : customWidth - ? `${customWidth}px` - : wide - ? "min(1200px, calc(100vw - 400px))" - : "300px"; + const width = collapsed ? "20px" : customWidth ? `${customWidth}px` : "400px"; return (
= ({ ); }; -type TabType = "costs" | "stats" | "review"; - +export { RIGHT_SIDEBAR_TABS, isTabType }; export type { TabType }; interface RightSidebarProps { @@ -102,8 +147,320 @@ interface RightSidebarProps { onReviewNote?: (data: ReviewNoteData) => void; /** Workspace is still being created (git operations in progress) */ isCreating?: boolean; + /** Ref callback to expose addTerminal function to parent */ + addTerminalRef?: React.MutableRefObject<(() => void) | null>; +} + +/** + * Wrapper component for PanelResizeHandle that disables pointer events during tab drag. + * Uses isDragging prop passed from parent DndContext. + */ +const DragAwarePanelResizeHandle: React.FC<{ + direction: "horizontal" | "vertical"; + isDraggingTab: boolean; +}> = ({ direction, isDraggingTab }) => { + const className = cn( + direction === "horizontal" + ? "w-0.5 flex-shrink-0 z-10 transition-[background] duration-150 cursor-col-resize bg-border-light hover:bg-accent" + : "h-0.5 flex-shrink-0 z-10 transition-[background] duration-150 cursor-row-resize bg-border-light hover:bg-accent", + isDraggingTab && "pointer-events-none" + ); + + return ; +}; + +type TabsetNode = Extract; + +interface RightSidebarTabsetNodeProps { + node: TabsetNode; + baseId: string; + workspaceId: string; + workspacePath: string; + projectPath: string; + isCreating: boolean; + focusTrigger: number; + onReviewNote?: (data: ReviewNoteData) => void; + reviewStats: ReviewStats | null; + onReviewStatsChange: (stats: ReviewStats | null) => void; + sessionCost: number | null; + statsTabEnabled: boolean; + sessionDuration: number | null; + /** Whether any sidebar tab is currently being dragged */ + isDraggingTab: boolean; + /** Data about the currently dragged tab (if any) */ + activeDragData: TabDragData | null; + setLayout: (updater: (prev: RightSidebarLayoutState) => RightSidebarLayoutState) => void; + /** Handler to pop out a terminal tab to a separate window */ + onPopOutTerminal: (tab: TabType) => void; + /** Handler to add a new terminal tab */ + onAddTerminal: () => void; + /** Handler to close a terminal tab */ + onCloseTerminal: (tab: TabType) => void; + /** Map of terminal tab types to their current titles (from OSC sequences) */ + terminalTitles: Map; + /** Handler to update a terminal's title */ + onTerminalTitleChange: (tab: TabType, title: string) => void; + /** Map of tab → global position index (0-based) for keybind tooltips */ + tabPositions: Map; } +const RightSidebarTabsetNode: React.FC = (props) => { + const tabsetBaseId = `${props.baseId}-${props.node.id}`; + + // Content container class comes from tab registry - each tab defines its own padding/overflow + const tabsetContentClassName = cn( + "relative flex-1 min-h-0", + getTabContentClassName(props.node.activeTab) + ); + + // Drop zones using @dnd-kit's useDroppable + const { setNodeRef: contentRef, isOver: isOverContent } = useDroppable({ + id: `content:${props.node.id}`, + data: { type: "content", tabsetId: props.node.id }, + }); + + const { setNodeRef: topRef, isOver: isOverTop } = useDroppable({ + id: `edge:${props.node.id}:top`, + data: { type: "edge", tabsetId: props.node.id, edge: "top" }, + }); + + const { setNodeRef: bottomRef, isOver: isOverBottom } = useDroppable({ + id: `edge:${props.node.id}:bottom`, + data: { type: "edge", tabsetId: props.node.id, edge: "bottom" }, + }); + + const { setNodeRef: leftRef, isOver: isOverLeft } = useDroppable({ + id: `edge:${props.node.id}:left`, + data: { type: "edge", tabsetId: props.node.id, edge: "left" }, + }); + + const { setNodeRef: rightRef, isOver: isOverRight } = useDroppable({ + id: `edge:${props.node.id}:right`, + data: { type: "edge", tabsetId: props.node.id, edge: "right" }, + }); + + const showDockHints = + props.isDraggingTab && + (isOverContent || isOverTop || isOverBottom || isOverLeft || isOverRight); + + const setFocused = () => { + props.setLayout((prev) => setFocusedTabset(prev, props.node.id)); + }; + + const selectTab = (tab: TabType) => { + props.setLayout((prev) => { + const withFocus = setFocusedTabset(prev, props.node.id); + return selectTabInTabset(withFocus, props.node.id, tab); + }); + }; + + // Count terminal tabs in this tabset for numbering (Terminal, Terminal 2, etc.) + const terminalTabs = props.node.tabs.filter(isTerminalTab); + + const items = props.node.tabs.flatMap((tab) => { + if (tab === "stats" && !props.statsTabEnabled) { + return []; + } + + const tabId = `${tabsetBaseId}-tab-${tab}`; + const panelId = `${tabsetBaseId}-panel-${tab}`; + + // Show keybind for tabs 1-9 based on their position in the layout + const isTerminal = isTerminalTab(tab); + const tabPosition = props.tabPositions.get(tab); + const keybinds = [ + KEYBINDS.SIDEBAR_TAB_1, + KEYBINDS.SIDEBAR_TAB_2, + KEYBINDS.SIDEBAR_TAB_3, + KEYBINDS.SIDEBAR_TAB_4, + KEYBINDS.SIDEBAR_TAB_5, + KEYBINDS.SIDEBAR_TAB_6, + KEYBINDS.SIDEBAR_TAB_7, + KEYBINDS.SIDEBAR_TAB_8, + KEYBINDS.SIDEBAR_TAB_9, + ]; + const tooltip = + tabPosition !== undefined && tabPosition < keybinds.length + ? formatKeybind(keybinds[tabPosition]) + : undefined; + + // Build label using tab-specific label components + let label: React.ReactNode; + + if (tab === "costs") { + label = ; + } else if (tab === "review") { + label = ; + } else if (tab === "stats") { + label = ; + } else if (isTerminal) { + const terminalIndex = terminalTabs.indexOf(tab); + label = ( + props.onPopOutTerminal(tab)} + onClose={() => props.onCloseTerminal(tab)} + /> + ); + } else { + label = tab; + } + + return [ + { + id: tabId, + panelId, + selected: props.node.activeTab === tab, + onSelect: () => selectTab(tab), + label, + tooltip, + tab, + }, + ]; + }); + + const costsPanelId = `${tabsetBaseId}-panel-costs`; + const reviewPanelId = `${tabsetBaseId}-panel-review`; + const statsPanelId = `${tabsetBaseId}-panel-stats`; + + const costsTabId = `${tabsetBaseId}-tab-costs`; + const reviewTabId = `${tabsetBaseId}-tab-review`; + const statsTabId = `${tabsetBaseId}-tab-stats`; + + // Generate sortable IDs for tabs in this tabset + const sortableIds = items.map((item) => `${props.node.id}:${item.tab}`); + + return ( +
+ + + +
+ {/* Edge docking zones - always rendered but only visible/interactive during drag */} +
+
+
+
+ + {props.node.activeTab === "costs" && ( +
+ +
+ )} + + {/* Render all terminal tabs (keep-alive: hidden but mounted) */} + {terminalTabs.map((terminalTab) => { + const terminalTabId = `${tabsetBaseId}-tab-${terminalTab}`; + const terminalPanelId = `${tabsetBaseId}-panel-${terminalTab}`; + const isActive = props.node.activeTab === terminalTab; + + return ( + + ); + })} + + {props.node.tabs.includes("stats") && props.statsTabEnabled && ( + + )} + + {props.node.activeTab === "review" && ( +
+ +
+ )} +
+
+ ); +}; + const RightSidebarComponent: React.FC = ({ workspaceId, workspacePath, @@ -113,60 +470,163 @@ const RightSidebarComponent: React.FC = ({ isResizing = false, onReviewNote, isCreating = false, + addTerminalRef, }) => { - // Global tab preference (not per-workspace) - const [selectedTab, setSelectedTab] = usePersistedState(RIGHT_SIDEBAR_TAB_KEY, "costs"); + // Trigger for focusing Review panel (preserves hunk selection) + const [focusTrigger, _setFocusTrigger] = React.useState(0); + + // Review stats reported by ReviewPanel + const [reviewStats, setReviewStats] = React.useState(null); // Manual collapse state (persisted globally) const [collapsed, setCollapsed] = usePersistedState(RIGHT_SIDEBAR_COLLAPSED_KEY, false); + // Stats tab feature flag const { statsTabState } = useFeatureFlags(); const statsTabEnabled = Boolean(statsTabState?.enabled); + // Read last-used focused tab for better defaults when initializing a new layout. + const initialActiveTab = React.useMemo(() => { + const raw = readPersistedState(RIGHT_SIDEBAR_TAB_KEY, "costs"); + return isTabType(raw) ? raw : "costs"; + }, []); + + const defaultLayout = React.useMemo( + () => getDefaultRightSidebarLayoutState(initialActiveTab), + [initialActiveTab] + ); + + // Layout is per-workspace so each workspace can have its own split/tab configuration + // (e.g., different numbers of terminals). Width and collapsed state remain global. + const layoutKey = getRightSidebarLayoutKey(workspaceId); + const [layoutRaw, setLayoutRaw] = usePersistedState( + layoutKey, + defaultLayout, + { + listener: true, + } + ); + + // While dragging tabs (hover-based reorder), keep layout changes in-memory and + // commit once on drop to avoid localStorage writes on every mousemove. + const [layoutDraft, setLayoutDraft] = React.useState(null); + const layoutDraftRef = React.useRef(null); + + // Ref to access latest layoutRaw without causing callback recreation + const layoutRawRef = React.useRef(layoutRaw); + layoutRawRef.current = layoutRaw; + + const isSidebarTabDragInProgressRef = React.useRef(false); + + const handleSidebarTabDragStart = React.useCallback(() => { + isSidebarTabDragInProgressRef.current = true; + layoutDraftRef.current = null; + }, []); + + const handleSidebarTabDragEnd = React.useCallback(() => { + isSidebarTabDragInProgressRef.current = false; + + const draft = layoutDraftRef.current; + if (draft) { + setLayoutRaw(draft); + } + + layoutDraftRef.current = null; + setLayoutDraft(null); + }, [setLayoutRaw]); + + const layout = React.useMemo( + () => parseRightSidebarLayoutState(layoutDraft ?? layoutRaw, initialActiveTab), + [layoutDraft, layoutRaw, initialActiveTab] + ); + + // If the Stats tab feature is enabled, ensure it exists in the layout. + // If disabled, ensure it doesn't linger in persisted layouts. React.useEffect(() => { - if (!statsTabEnabled && selectedTab === "stats") { - setSelectedTab("costs"); + setLayoutRaw((prevRaw) => { + const prev = parseRightSidebarLayoutState(prevRaw, initialActiveTab); + const hasStats = collectAllTabs(prev.root).includes("stats"); + + if (statsTabEnabled && !hasStats) { + // Add stats tab to the focused tabset + return addTabToFocusedTabset(prev, "stats"); + } + + if (!statsTabEnabled && hasStats) { + return removeTabEverywhere(prev, "stats"); + } + + return prev; + }); + }, [initialActiveTab, setLayoutRaw, statsTabEnabled]); + // If we ever deserialize an invalid layout (e.g. schema changes), reset to defaults. + React.useEffect(() => { + if (!isRightSidebarLayoutState(layoutRaw)) { + setLayoutRaw(layout); } - }, [statsTabEnabled, selectedTab, setSelectedTab]); + }, [layout, layoutRaw, setLayoutRaw]); - // Trigger for focusing Review panel (preserves hunk selection) - const [focusTrigger, setFocusTrigger] = React.useState(0); + const setLayout = React.useCallback( + (updater: (prev: RightSidebarLayoutState) => RightSidebarLayoutState) => { + if (isSidebarTabDragInProgressRef.current) { + // Use ref to get latest layoutRaw without dependency + const base = + layoutDraftRef.current ?? + parseRightSidebarLayoutState(layoutRawRef.current, initialActiveTab); + const next = updater(base); + layoutDraftRef.current = next; + setLayoutDraft(next); + return; + } - // Review stats reported by ReviewPanel - const [reviewStats, setReviewStats] = React.useState(null); + setLayoutRaw((prevRaw) => updater(parseRightSidebarLayoutState(prevRaw, initialActiveTab))); + }, + [initialActiveTab, setLayoutRaw] + ); - // Keyboard shortcuts for tab switching (auto-expands if collapsed) + // Keyboard shortcuts for tab switching by position (Cmd/Ctrl+1-9) + // Auto-expands sidebar if collapsed React.useEffect(() => { + const tabKeybinds = [ + KEYBINDS.SIDEBAR_TAB_1, + KEYBINDS.SIDEBAR_TAB_2, + KEYBINDS.SIDEBAR_TAB_3, + KEYBINDS.SIDEBAR_TAB_4, + KEYBINDS.SIDEBAR_TAB_5, + KEYBINDS.SIDEBAR_TAB_6, + KEYBINDS.SIDEBAR_TAB_7, + KEYBINDS.SIDEBAR_TAB_8, + KEYBINDS.SIDEBAR_TAB_9, + ]; + const handleKeyDown = (e: KeyboardEvent) => { - if (matchesKeybind(e, KEYBINDS.COSTS_TAB)) { - e.preventDefault(); - setSelectedTab("costs"); - setCollapsed(false); - } else if (matchesKeybind(e, KEYBINDS.REVIEW_TAB)) { - e.preventDefault(); - setSelectedTab("review"); - setCollapsed(false); - setFocusTrigger((prev) => prev + 1); - } else if (statsTabEnabled && matchesKeybind(e, KEYBINDS.STATS_TAB)) { - e.preventDefault(); - setSelectedTab("stats"); - setCollapsed(false); + for (let i = 0; i < tabKeybinds.length; i++) { + if (matchesKeybind(e, tabKeybinds[i])) { + e.preventDefault(); + setLayout((prev) => selectTabByIndex(prev, i)); + setCollapsed(false); + return; + } } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); - }, [setSelectedTab, setCollapsed, statsTabEnabled]); + }, [setLayout, setCollapsed]); const usage = useWorkspaceUsage(workspaceId); const baseId = `right-sidebar-${workspaceId}`; - const costsTabId = `${baseId}-tab-costs`; - const statsTabId = `${baseId}-tab-stats`; - const reviewTabId = `${baseId}-tab-review`; - const costsPanelId = `${baseId}-panel-costs`; - const statsPanelId = `${baseId}-panel-stats`; - const reviewPanelId = `${baseId}-panel-review`; + + // Build map of tab → position for keybind tooltips + const tabPositions = React.useMemo(() => { + const allTabs = collectAllTabsWithTabset(layout.root); + const positions = new Map(); + allTabs.forEach(({ tab }, index) => { + positions.set(tab, index); + }); + return positions; + }, [layout.root]); // Calculate session cost for tab display const sessionCost = React.useMemo(() => { @@ -198,171 +658,403 @@ const RightSidebarComponent: React.FC = ({ return total > 0 ? total : null; })(); - return ( - - {!collapsed && ( - <> - {/* Resize handle (left edge) */} - {onStartResize && ( -
onStartResize(e as unknown as React.MouseEvent)} - /> - )} + // @dnd-kit state for tracking active drag + const [activeDragData, setActiveDragData] = React.useState(null); -
- - - - - - {formatKeybind(KEYBINDS.COSTS_TAB)} - - - - - - - - {formatKeybind(KEYBINDS.REVIEW_TAB)} - - - {statsTabEnabled && ( - - - - - - {formatKeybind(KEYBINDS.STATS_TAB)} - - - )} -
-
- {selectedTab === "costs" && ( -
- -
- )} - {selectedTab === "review" && ( + // Terminal titles from OSC sequences (e.g., shell setting window title) + // Persisted to localStorage so they survive reload + const terminalTitlesKey = getTerminalTitlesKey(workspaceId); + const [terminalTitles, setTerminalTitles] = React.useState>(() => { + const stored = readPersistedState>(terminalTitlesKey, {}); + return new Map(Object.entries(stored) as Array<[TabType, string]>); + }); + + // API for opening terminal windows and managing sessions + const { api } = useAPI(); + + // Keyboard shortcut for closing active tab (Ctrl/Cmd+W) + // Only closeable tabs (terminal) can be closed this way + React.useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + if (!matchesKeybind(e, KEYBINDS.CLOSE_TAB)) return; + + const focusedTabset = findTabset(layout.root, layout.focusedTabsetId); + if (focusedTabset?.type !== "tabset") return; + + const activeTab = focusedTabset.activeTab; + if (!isTerminalTab(activeTab)) return; // Only terminal tabs are closeable + + e.preventDefault(); + + // Close the backend session + const sessionId = getTerminalSessionId(activeTab); + if (sessionId) { + void api?.terminal.close({ sessionId }); + } + + // Remove the tab from layout + setLayout((prev) => removeTabEverywhere(prev, activeTab)); + + // Clean up title (and persist) + setTerminalTitles((prev) => { + const next = new Map(prev); + next.delete(activeTab); + updatePersistedState(terminalTitlesKey, Object.fromEntries(next)); + return next; + }); + }; + + window.addEventListener("keydown", handleKeyDown, { capture: true }); + return () => window.removeEventListener("keydown", handleKeyDown, { capture: true }); + }, [api, layout.root, layout.focusedTabsetId, setLayout, terminalTitlesKey]); + + // Sync terminal tabs with backend sessions on workspace mount. + // - Adds tabs for backend sessions that don't have tabs (restore after reload) + // - Removes "ghost" tabs for sessions that no longer exist (cleanup after app restart) + React.useEffect(() => { + if (!api) return; + + let cancelled = false; + + void api.terminal.listSessions({ workspaceId }).then((backendSessionIds) => { + if (cancelled) return; + + const backendSessionSet = new Set(backendSessionIds); + + // Get current terminal tabs in layout + const currentTabs = collectAllTabs(layout.root); + const currentTerminalTabs = currentTabs.filter(isTerminalTab); + const currentTerminalSessionIds = new Set( + currentTerminalTabs.map(getTerminalSessionId).filter(Boolean) + ); + + // Find sessions that don't have tabs yet (add them) + const missingSessions = backendSessionIds.filter( + (sid) => !currentTerminalSessionIds.has(sid) + ); + + // Find tabs for sessions that no longer exist in backend (remove them) + const ghostTabs = currentTerminalTabs.filter((tab) => { + const sessionId = getTerminalSessionId(tab); + return sessionId && !backendSessionSet.has(sessionId); + }); + + if (missingSessions.length > 0 || ghostTabs.length > 0) { + setLayout((prev) => { + let next = prev; + + // Remove ghost tabs first + for (const ghostTab of ghostTabs) { + next = removeTabEverywhere(next, ghostTab); + } + + // Add tabs for backend sessions that don't have tabs + for (const sessionId of missingSessions) { + next = addTabToFocusedTabset(next, makeTerminalTabType(sessionId), false); + } + + return next; + }); + } + }); + + return () => { + cancelled = true; + }; + // eslint-disable-next-line react-hooks/exhaustive-deps -- Only run on workspace change, not layout change. layout.root would cause infinite loop. + }, [api, workspaceId, setLayout]); + + // Handler to update a terminal's title (from OSC sequences) + // Also persists to localStorage for reload survival + const handleTerminalTitleChange = React.useCallback( + (tab: TabType, title: string) => { + setTerminalTitles((prev) => { + const next = new Map(prev); + next.set(tab, title); + // Persist to localStorage + updatePersistedState(terminalTitlesKey, Object.fromEntries(next)); + return next; + }); + }, + [terminalTitlesKey] + ); + + // Handler to add a new terminal tab. + // Creates the backend session first, then adds the tab with the real sessionId. + // This ensures the tabType (and React key) never changes, preventing remounts. + const handleAddTerminal = React.useCallback(() => { + if (!api) return; + + // Also expand sidebar if collapsed + setCollapsed(false); + + void createTerminalSession(api, workspaceId).then((session) => { + const newTab = makeTerminalTabType(session.sessionId); + setLayout((prev) => addTabToFocusedTabset(prev, newTab)); + }); + }, [api, workspaceId, setLayout, setCollapsed]); + + // Expose handleAddTerminal to parent via ref (for Cmd/Ctrl+T keybind) + React.useEffect(() => { + if (addTerminalRef) { + addTerminalRef.current = handleAddTerminal; + } + return () => { + if (addTerminalRef) { + addTerminalRef.current = null; + } + }; + }, [addTerminalRef, handleAddTerminal]); + + // Handler to close a terminal tab + const handleCloseTerminal = React.useCallback( + (tab: TabType) => { + // Close the backend session + const sessionId = getTerminalSessionId(tab); + if (sessionId) { + void api?.terminal.close({ sessionId }); + } + + // Remove the tab from layout + setLayout((prev) => removeTabEverywhere(prev, tab)); + + // Clean up title (and persist) + setTerminalTitles((prev) => { + const next = new Map(prev); + next.delete(tab); + updatePersistedState(terminalTitlesKey, Object.fromEntries(next)); + return next; + }); + }, + [api, setLayout, terminalTitlesKey] + ); + + // Handler to pop out a terminal to a separate window, then remove the tab + const handlePopOutTerminal = React.useCallback( + (tab: TabType) => { + if (!api) return; + + // Session ID is embedded in the tab type + const sessionId = getTerminalSessionId(tab); + if (!sessionId) return; // Can't pop out without a session + + // Open the pop-out window (handles browser vs Electron modes) + openTerminalPopout(api, workspaceId, sessionId); + + // Remove the tab from the sidebar (terminal now lives in its own window) + // Don't close the session - the pop-out window takes over + setLayout((prev) => removeTabEverywhere(prev, tab)); + + // Clean up title (and persist) + setTerminalTitles((prev) => { + const next = new Map(prev); + next.delete(tab); + updatePersistedState(terminalTitlesKey, Object.fromEntries(next)); + return next; + }); + }, + [workspaceId, api, setLayout, terminalTitlesKey] + ); + + // Configure sensors with distance threshold for click vs drag disambiguation + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8, // 8px movement required before drag starts + }, + }) + ); + + const handleDragStart = React.useCallback( + (event: DragStartEvent) => { + const data = event.active.data.current as TabDragData | undefined; + if (data) { + setActiveDragData(data); + handleSidebarTabDragStart(); + } + }, + [handleSidebarTabDragStart] + ); + + const handleDragEnd = React.useCallback( + (event: DragEndEvent) => { + const { active, over } = event; + const activeData = active.data.current as TabDragData | undefined; + + if (activeData && over) { + const overData = over.data.current as + | { type: "edge"; tabsetId: string; edge: "top" | "bottom" | "left" | "right" } + | { type: "content"; tabsetId: string } + | { tabsetId: string } + | TabDragData + | undefined; + + if (overData) { + // Handle dropping on edge zones (create splits) + if ("type" in overData && overData.type === "edge") { + setLayout((prev) => + dockTabToEdge( + prev, + activeData.tab, + activeData.sourceTabsetId, + overData.tabsetId, + overData.edge + ) + ); + } + // Handle dropping on content area (move to tabset) + else if ("type" in overData && overData.type === "content") { + if (activeData.sourceTabsetId !== overData.tabsetId) { + setLayout((prev) => + moveTabToTabset(prev, activeData.tab, activeData.sourceTabsetId, overData.tabsetId) + ); + } + } + // Handle dropping on another tabstrip (move to tabset) + else if ("tabsetId" in overData && !("tab" in overData)) { + if (activeData.sourceTabsetId !== overData.tabsetId) { + setLayout((prev) => + moveTabToTabset(prev, activeData.tab, activeData.sourceTabsetId, overData.tabsetId) + ); + } + } + // Handle reordering within same tabset (sortable handles this via arrayMove pattern) + else if ("tab" in overData && "sourceTabsetId" in overData) { + // Both are tabs - check if same tabset for reorder + if (activeData.sourceTabsetId === overData.sourceTabsetId) { + const fromIndex = activeData.index; + const toIndex = overData.index; + if (fromIndex !== toIndex) { + setLayout((prev) => + reorderTabInTabset(prev, activeData.sourceTabsetId, fromIndex, toIndex) + ); + } + } else { + // Different tabsets - move tab + setLayout((prev) => + moveTabToTabset( + prev, + activeData.tab, + activeData.sourceTabsetId, + overData.sourceTabsetId + ) + ); + } + } + } + } + + setActiveDragData(null); + handleSidebarTabDragEnd(); + }, + [setLayout, handleSidebarTabDragEnd] + ); + + const isDraggingTab = activeDragData !== null; + + const renderLayoutNode = (node: RightSidebarLayoutNode): React.ReactNode => { + if (node.type === "split") { + // Our layout uses "horizontal" to mean a horizontal divider (top/bottom panes). + // react-resizable-panels uses "vertical" for top/bottom. + const groupDirection = node.direction === "horizontal" ? "vertical" : "horizontal"; + + return ( + { + if (sizes.length !== 2) return; + const nextSizes: [number, number] = [ + typeof sizes[0] === "number" ? sizes[0] : 50, + typeof sizes[1] === "number" ? sizes[1] : 50, + ]; + setLayout((prev) => updateSplitSizes(prev, node.id, nextSizes)); + }} + > + + {renderLayoutNode(node.children[0])} + + + + {renderLayoutNode(node.children[1])} + + + ); + } + + return ( + + ); + }; + + return ( + + + {!collapsed && ( +
+ {/* Resize handle (left edge) */} + {onStartResize && (
- -
- )} - {statsTabEnabled && selectedTab === "stats" && ( -
- - - -
+ className={cn( + "w-0.5 flex-shrink-0 z-10 transition-[background] duration-150 cursor-col-resize", + isResizing ? "bg-accent" : "bg-border-light hover:bg-accent" + )} + onMouseDown={(e) => onStartResize(e as unknown as React.MouseEvent)} + /> )} + +
+ {renderLayoutNode(layout.root)} +
- - )} + )} - setCollapsed(!collapsed)} - side="right" - /> -
+ setCollapsed(!collapsed)} + side="right" + /> + + + {/* Drag overlay - shows tab being dragged at cursor position */} + + {activeDragData ? ( +
+ {getTabName(activeDragData.tab)} +
+ ) : null} +
+
); }; diff --git a/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx b/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx index 691a7f48a5..fd33bd4996 100644 --- a/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx +++ b/src/browser/components/RightSidebar/CodeReview/ReviewPanel.tsx @@ -113,7 +113,7 @@ function preserveHunkReferences(prev: DiffHunk[], next: DiffHunk[]): DiffHunk[] const result = next.map((hunk, i) => { const prevHunk = prevById.get(hunk.id); // Fast path: same ID and content means unchanged (content hash is part of ID) - if (prevHunk && prevHunk.content === hunk.content) { + if (prevHunk?.content === hunk.content) { if (allSame && prev[i]?.id !== hunk.id) allSame = false; return prevHunk; } diff --git a/src/browser/components/RightSidebar/RightSidebarTabStrip.tsx b/src/browser/components/RightSidebar/RightSidebarTabStrip.tsx new file mode 100644 index 0000000000..1a09a9beb4 --- /dev/null +++ b/src/browser/components/RightSidebar/RightSidebarTabStrip.tsx @@ -0,0 +1,155 @@ +import React from "react"; +import { cn } from "@/common/lib/utils"; +import { Tooltip, TooltipContent, TooltipTrigger } from "../ui/tooltip"; +import { useSortable } from "@dnd-kit/sortable"; +import { CSS } from "@dnd-kit/utilities"; +import { useDroppable, useDndContext } from "@dnd-kit/core"; +import { Plus } from "lucide-react"; +import type { TabType } from "@/browser/types/rightSidebar"; + +// Re-export for consumers that import from this file +export { getTabName } from "./tabs"; + +/** Data attached to dragged sidebar tabs */ +export interface TabDragData { + tab: TabType; + sourceTabsetId: string; + index: number; +} + +export interface RightSidebarTabStripItem { + id: string; + panelId: string; + selected: boolean; + onSelect: () => void; + label: React.ReactNode; + tooltip: React.ReactNode; + disabled?: boolean; + /** The tab type (used for drag identification) */ + tab: TabType; +} + +interface RightSidebarTabStripProps { + items: RightSidebarTabStripItem[]; + ariaLabel?: string; + /** Unique ID of this tabset (for drag/drop) */ + tabsetId: string; + /** Called when user clicks the "+" button to add a new terminal */ + onAddTerminal?: () => void; +} + +/** + * Individual sortable tab button using @dnd-kit. + * Uses useSortable for drag + drop within the same tabset. + */ +const SortableTab: React.FC<{ + item: RightSidebarTabStripItem; + index: number; + tabsetId: string; +}> = ({ item, index, tabsetId }) => { + // Create a unique sortable ID that encodes tabset + tab + const sortableId = `${tabsetId}:${item.tab}`; + + const { attributes, listeners, setNodeRef, transform, transition, isDragging } = useSortable({ + id: sortableId, + data: { + tab: item.tab, + sourceTabsetId: tabsetId, + index, + } satisfies TabDragData, + }); + + const style: React.CSSProperties = { + transform: CSS.Transform.toString(transform), + transition, + }; + + return ( +
+ + + + + + {item.tooltip} + + +
+ ); +}; + +export const RightSidebarTabStrip: React.FC = ({ + items, + ariaLabel = "Sidebar views", + tabsetId, + onAddTerminal, +}) => { + const { active } = useDndContext(); + const activeData = active?.data.current as TabDragData | undefined; + + // Track if we're dragging from this tabset (for visual feedback) + const isDraggingFromHere = activeData?.sourceTabsetId === tabsetId; + + // Make the tabstrip a drop target for tabs from OTHER tabsets + const { setNodeRef, isOver } = useDroppable({ + id: `tabstrip:${tabsetId}`, + data: { tabsetId }, + }); + + const canDrop = activeData !== undefined && activeData.sourceTabsetId !== tabsetId; + const showDropHighlight = isOver && canDrop; + + return ( +
+ {items.map((item, index) => ( + + ))} + {onAddTerminal && ( + + + + + New terminal + + )} +
+ ); +}; diff --git a/src/browser/components/RightSidebar/TerminalTab.tsx b/src/browser/components/RightSidebar/TerminalTab.tsx new file mode 100644 index 0000000000..d5e05540a0 --- /dev/null +++ b/src/browser/components/RightSidebar/TerminalTab.tsx @@ -0,0 +1,45 @@ +import React from "react"; +import { TerminalView } from "@/browser/components/TerminalView"; +import type { TabType } from "@/browser/types/rightSidebar"; +import { getTerminalSessionId } from "@/browser/types/rightSidebar"; + +interface TerminalTabProps { + workspaceId: string; + /** The tab type (e.g., "terminal:ws-123-1704567890") */ + tabType: TabType; + visible: boolean; + /** Called when terminal title changes (from shell OSC sequences) */ + onTitleChange?: (title: string) => void; +} + +/** + * Terminal tab component that renders a terminal view. + * + * Session ID is extracted directly from the tabType ("terminal:"). + * Sessions are created by RightSidebar before adding the tab, so tabType + * always contains a valid sessionId (never the placeholder "terminal"). + */ +export const TerminalTab: React.FC = (props) => { + // Extract session ID from tab type - must exist (sessions created before tab added) + const sessionId = getTerminalSessionId(props.tabType); + + if (!sessionId) { + // This should never happen - RightSidebar creates session before adding tab + return ( +
+ Invalid terminal tab: missing session ID +
+ ); + } + + return ( + + ); +}; diff --git a/src/browser/components/RightSidebar/tabs/TabLabels.tsx b/src/browser/components/RightSidebar/tabs/TabLabels.tsx new file mode 100644 index 0000000000..f4f01f8548 --- /dev/null +++ b/src/browser/components/RightSidebar/tabs/TabLabels.tsx @@ -0,0 +1,126 @@ +/** + * Tab label components for RightSidebar tabs. + * + * Each tab type has its own label component that handles badges, icons, and actions. + */ + +import React from "react"; +import { ExternalLink, Terminal as TerminalIcon, X } from "lucide-react"; +import { Tooltip, TooltipContent, TooltipTrigger } from "../../ui/tooltip"; +import { formatTabDuration, type ReviewStats } from "./registry"; +import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; +import { cn } from "@/common/lib/utils"; + +interface CostsTabLabelProps { + sessionCost: number | null; +} + +/** Costs tab label with session cost badge */ +export const CostsTabLabel: React.FC = ({ sessionCost }) => ( + <> + Costs + {sessionCost !== null && ( + + ${sessionCost < 0.01 ? "<0.01" : sessionCost.toFixed(2)} + + )} + +); + +interface ReviewTabLabelProps { + reviewStats: ReviewStats | null; +} + +/** Review tab label with read/total badge */ +export const ReviewTabLabel: React.FC = ({ reviewStats }) => ( + <> + Review + {reviewStats !== null && reviewStats.total > 0 && ( + + {reviewStats.read}/{reviewStats.total} + + )} + +); + +interface StatsTabLabelProps { + sessionDuration: number | null; +} + +/** Stats tab label with session duration badge */ +export const StatsTabLabel: React.FC = ({ sessionDuration }) => ( + <> + Stats + {sessionDuration !== null && ( + {formatTabDuration(sessionDuration)} + )} + +); + +interface TerminalTabLabelProps { + /** Dynamic title from OSC sequences, if available */ + dynamicTitle?: string; + /** Terminal index (0-based) within the current tabset */ + terminalIndex: number; + /** Callback when pop-out button is clicked */ + onPopOut: () => void; + /** Callback when close button is clicked */ + onClose: () => void; +} + +/** Terminal tab label with icon, dynamic title, and action buttons */ +export const TerminalTabLabel: React.FC = ({ + dynamicTitle, + terminalIndex, + onPopOut, + onClose, +}) => { + const fallbackName = terminalIndex === 0 ? "Terminal" : `Terminal ${terminalIndex + 1}`; + const displayName = dynamicTitle ?? fallbackName; + + return ( + + + {displayName} + + + + + Open in new window + + + + + + + Close terminal ({formatKeybind(KEYBINDS.CLOSE_TAB)}) + + + + ); +}; diff --git a/src/browser/components/RightSidebar/tabs/index.ts b/src/browser/components/RightSidebar/tabs/index.ts new file mode 100644 index 0000000000..0ae1a5739a --- /dev/null +++ b/src/browser/components/RightSidebar/tabs/index.ts @@ -0,0 +1,23 @@ +/** + * Tab system for RightSidebar. + * + * Exports: + * - Registry: Tab configuration and utilities + * - TabLabels: Label components for each tab type + */ + +export { + TAB_CONFIGS, + TERMINAL_TAB_CONFIG, + getTabConfig, + getTabName, + getTabContentClassName, + formatTabDuration, + type TabConfig, + type TabRenderContext, + type TerminalTabRenderContext, + type TabLabelProps, + type ReviewStats, +} from "./registry"; + +export { CostsTabLabel, ReviewTabLabel, StatsTabLabel, TerminalTabLabel } from "./TabLabels"; diff --git a/src/browser/components/RightSidebar/tabs/registry.ts b/src/browser/components/RightSidebar/tabs/registry.ts new file mode 100644 index 0000000000..bd25d6131b --- /dev/null +++ b/src/browser/components/RightSidebar/tabs/registry.ts @@ -0,0 +1,130 @@ +/** + * Tab Registry - Centralized configuration for RightSidebar tabs. + * + * Each tab type defines: + * - name: Display name for the tab + * - contentClassName: CSS classes for the tab panel container + * - keepAlive: Whether the tab should remain mounted while hidden + * - featureFlag: Optional feature flag key required to show the tab + * + * This keeps per-tab decisions out of RightSidebar.tsx and avoids switch statements. + */ + +import type { TabType } from "@/browser/types/rightSidebar"; +import type { ReviewNoteData } from "@/common/types/review"; + +/** Stats reported by ReviewPanel for tab display */ +export interface ReviewStats { + total: number; + read: number; +} + +/** Context passed to tab renderers */ +export interface TabRenderContext { + workspaceId: string; + workspacePath: string; + projectPath: string; + isCreating: boolean; + focusTrigger: number; + onReviewNote?: (data: ReviewNoteData) => void; + onReviewStatsChange: (stats: ReviewStats | null) => void; + /** Whether this tab is currently visible/active */ + visible: boolean; +} + +/** Context for terminal tab rendering */ +export interface TerminalTabRenderContext extends TabRenderContext { + tabType: TabType; + onTitleChange: (title: string) => void; +} + +/** Label props passed to label renderers */ +export interface TabLabelProps { + /** Cost in dollars for the current session (costs tab) */ + sessionCost?: number | null; + /** Review panel stats (review tab) */ + reviewStats?: ReviewStats | null; + /** Session duration in ms (stats tab) */ + sessionDuration?: number | null; + /** For terminal tabs: dynamic title from OSC sequences */ + terminalTitle?: string; + /** For terminal tabs: index within the tabset (for "Terminal 2" etc) */ + terminalIndex?: number; + /** Callback when pop-out button clicked */ + onPopOut?: () => void; + /** Callback when close button clicked */ + onClose?: () => void; +} + +/** Configuration for a single tab type */ +export interface TabConfig { + /** Base display name (e.g., "Costs", "Review", "Terminal") */ + name: string; + + /** CSS classes for the tab panel content area */ + contentClassName: string; + + /** + * Whether this tab should be rendered when hidden (keep-alive). + * Most tabs only render when active. Terminal tabs stay mounted to preserve state. + */ + keepAlive?: boolean; + + /** + * Whether this tab requires a feature flag to be shown. + * Returns the feature flag key, or undefined if always available. + */ + featureFlag?: string; +} + +/** Static tab configurations (non-terminal tabs) */ +export const TAB_CONFIGS: Record<"costs" | "review" | "stats", TabConfig> = { + costs: { + name: "Costs", + contentClassName: "overflow-y-auto p-[15px]", + }, + review: { + name: "Review", + contentClassName: "overflow-y-auto p-0", + }, + stats: { + name: "Stats", + contentClassName: "overflow-y-auto p-[15px]", + featureFlag: "statsTab", + }, +}; + +/** Terminal tab configuration */ +export const TERMINAL_TAB_CONFIG: TabConfig = { + name: "Terminal", + contentClassName: "overflow-hidden p-0", + keepAlive: true, +}; + +/** Get config for a tab type */ +export function getTabConfig(tab: TabType): TabConfig { + if (tab === "costs" || tab === "review" || tab === "stats") { + return TAB_CONFIGS[tab]; + } + // All terminal tabs (including "terminal" placeholder) + return TERMINAL_TAB_CONFIG; +} + +/** Get display name for a tab type */ +export function getTabName(tab: TabType): string { + return getTabConfig(tab).name; +} + +/** Get content container class name for a tab type */ +export function getTabContentClassName(tab: TabType): string { + return getTabConfig(tab).contentClassName; +} + +/** Format duration for tab display (compact format) */ +export function formatTabDuration(ms: number): string { + if (ms < 1000) return `${Math.round(ms)}ms`; + if (ms < 60000) return `${Math.round(ms / 1000)}s`; + const mins = Math.floor(ms / 60000); + const secs = Math.round((ms % 60000) / 1000); + return secs > 0 ? `${mins}m${secs}s` : `${mins}m`; +} diff --git a/src/browser/components/Settings/sections/KeybindsSection.tsx b/src/browser/components/Settings/sections/KeybindsSection.tsx index 9cc94c4118..6553a28425 100644 --- a/src/browser/components/Settings/sections/KeybindsSection.tsx +++ b/src/browser/components/Settings/sections/KeybindsSection.tsx @@ -22,14 +22,21 @@ const KEYBIND_LABELS: Record = { PREV_WORKSPACE: "Previous workspace", TOGGLE_SIDEBAR: "Toggle sidebar", CYCLE_MODEL: "Cycle model", - OPEN_TERMINAL: "Open in terminal", + OPEN_TERMINAL: "New terminal", OPEN_IN_EDITOR: "Open in editor", OPEN_COMMAND_PALETTE: "Command palette", TOGGLE_THINKING: "Toggle thinking", FOCUS_CHAT: "Focus chat input", - COSTS_TAB: "Costs tab", - REVIEW_TAB: "Review tab", - STATS_TAB: "Stats tab", + CLOSE_TAB: "Close tab", + SIDEBAR_TAB_1: "Tab 1", + SIDEBAR_TAB_2: "Tab 2", + SIDEBAR_TAB_3: "Tab 3", + SIDEBAR_TAB_4: "Tab 4", + SIDEBAR_TAB_5: "Tab 5", + SIDEBAR_TAB_6: "Tab 6", + SIDEBAR_TAB_7: "Tab 7", + SIDEBAR_TAB_8: "Tab 8", + SIDEBAR_TAB_9: "Tab 9", REFRESH_REVIEW: "Refresh diff", FOCUS_REVIEW_SEARCH: "Search in review", TOGGLE_HUNK_READ: "Toggle hunk read", @@ -78,8 +85,19 @@ const KEYBIND_GROUPS: Array<{ label: string; keys: Array keys: ["NEW_WORKSPACE", "NEXT_WORKSPACE", "PREV_WORKSPACE", "JUMP_TO_BOTTOM"], }, { - label: "Tabs", - keys: ["COSTS_TAB", "REVIEW_TAB", "STATS_TAB"], + label: "Sidebar Tabs", + keys: [ + "SIDEBAR_TAB_1", + "SIDEBAR_TAB_2", + "SIDEBAR_TAB_3", + "SIDEBAR_TAB_4", + "SIDEBAR_TAB_5", + "SIDEBAR_TAB_6", + "SIDEBAR_TAB_7", + "SIDEBAR_TAB_8", + "SIDEBAR_TAB_9", + "CLOSE_TAB", + ], }, { label: "Code Review", diff --git a/src/browser/components/TerminalView.tsx b/src/browser/components/TerminalView.tsx index c366fc87b9..8a272a5374 100644 --- a/src/browser/components/TerminalView.tsx +++ b/src/browser/components/TerminalView.tsx @@ -1,43 +1,53 @@ -import { useRef, useEffect, useState } from "react"; +import { useRef, useEffect, useState, useCallback } from "react"; import { init, Terminal, FitAddon } from "ghostty-web"; -import { useTerminalSession } from "@/browser/hooks/useTerminalSession"; import { useAPI } from "@/browser/contexts/API"; +import { useTerminalRouter } from "@/browser/terminal/TerminalRouterContext"; interface TerminalViewProps { workspaceId: string; - sessionId?: string; + /** Session ID to connect to (required - must be created before mounting) */ + sessionId: string; visible: boolean; + /** + * Whether to set document.title based on workspace name. + * + * Default: true (used by the dedicated terminal window). + * Set to false when embedding inside the app (e.g. RightSidebar). + */ + setDocumentTitle?: boolean; + /** Called when the terminal title changes (via OSC escape sequences from running processes) */ + onTitleChange?: (title: string) => void; + /** + * Whether to auto-focus the terminal on mount/visibility change. + * + * Default: true (used by dedicated terminal window). + * Set to false when embedding (e.g. RightSidebar) to avoid stealing focus on workspace switch. + */ + autoFocus?: boolean; } -export function TerminalView({ workspaceId, sessionId, visible }: TerminalViewProps) { +export function TerminalView({ + workspaceId, + sessionId, + visible, + setDocumentTitle = true, + onTitleChange, + autoFocus = true, +}: TerminalViewProps) { const containerRef = useRef(null); const termRef = useRef(null); const fitAddonRef = useRef(null); const [terminalError, setTerminalError] = useState(null); const [terminalReady, setTerminalReady] = useState(false); - const [terminalSize, setTerminalSize] = useState<{ cols: number; rows: number } | null>(null); - - // Handler for terminal output - const handleOutput = (data: string) => { - const term = termRef.current; - if (term) { - term.write(data); - } - }; - - // Handler for terminal exit - const handleExit = (exitCode: number) => { - const term = termRef.current; - if (term) { - term.write(`\r\n[Process exited with code ${exitCode}]\r\n`); - } - }; + // Track whether we've received the initial screen state from backend + const [isLoading, setIsLoading] = useState(true); const { api } = useAPI(); + const router = useTerminalRouter(); - // Set window title + // Set window title (dedicated terminal window only) useEffect(() => { - if (!api) return; + if (!api || !setDocumentTitle) return; const setWindowDetails = async () => { try { const workspaces = await api.workspace.list(); @@ -52,44 +62,147 @@ export function TerminalView({ workspaceId, sessionId, visible }: TerminalViewPr } }; void setWindowDetails(); - }, [api, workspaceId]); - const { - sendInput, - resize, - error: sessionError, - } = useTerminalSession(workspaceId, sessionId, visible, terminalSize, handleOutput, handleExit); + }, [api, workspaceId, setDocumentTitle]); - // Keep refs to latest functions so callbacks always use current version - const sendInputRef = useRef(sendInput); - const resizeRef = useRef(resize); + // Reset loading state when session changes + useEffect(() => { + setIsLoading(true); + }, [sessionId]); + // Subscribe to router when terminal is ready and visible useEffect(() => { - sendInputRef.current = sendInput; - resizeRef.current = resize; - }, [sendInput, resize]); + if (!visible || !terminalReady || !termRef.current) { + return; + } + + // Capture current terminal ref for this subscription's lifetime + const term = termRef.current; + + // Clear terminal before subscribing to prevent any stale content flash + try { + term.clear(); + } catch (err) { + console.warn("[TerminalView] Error clearing terminal:", err); + } - // Initialize terminal when visible + const unsubscribe = router.subscribe(sessionId, { + onOutput: (data) => { + try { + term.write(data); + } catch (err) { + // xterm WASM can throw "memory access out of bounds" intermittently + console.warn("[TerminalView] Error writing output:", err); + } + }, + onScreenState: (state) => { + // Write screen state (may be empty for new sessions) + if (state) { + try { + term.write(state); + } catch (err) { + // xterm WASM can throw "memory access out of bounds" intermittently + console.warn("[TerminalView] Error writing screenState:", err); + } + } + // Mark loading complete - we now have valid content to show + setIsLoading(false); + }, + onExit: (code) => { + try { + term.write(`\r\n[Process exited with code ${code}]\r\n`); + } catch (err) { + console.warn("[TerminalView] Error writing exit message:", err); + } + }, + }); + + // Send initial resize to sync PTY dimensions + const { cols, rows } = term; + router.resize(sessionId, cols, rows); + + return unsubscribe; + }, [visible, terminalReady, sessionId, router]); + + // Keep ref to onTitleChange for use in terminal callback + const onTitleChangeRef = useRef(onTitleChange); + useEffect(() => { + onTitleChangeRef.current = onTitleChange; + }, [onTitleChange]); + + const disposeOnDataRef = useRef<{ dispose: () => void } | null>(null); + const disposeOnTitleChangeRef = useRef<{ dispose: () => void } | null>(null); + const initInProgressRef = useRef(false); + + // Clean up the terminal instance when workspace changes (or component unmounts). useEffect(() => { - if (!containerRef.current || !visible) { + const containerEl = containerRef.current; + + return () => { + disposeOnDataRef.current?.dispose(); + disposeOnTitleChangeRef.current?.dispose(); + disposeOnDataRef.current = null; + disposeOnTitleChangeRef.current = null; + + termRef.current?.dispose(); + + // Ensure the DOM is clean even if the terminal init was interrupted. + containerEl?.replaceChildren(); + + termRef.current = null; + fitAddonRef.current = null; + initInProgressRef.current = false; + setTerminalReady(false); + }; + }, [workspaceId]); + + // Initialize terminal when it first becomes visible. + // We intentionally keep the terminal instance alive when hidden so we don't lose + // frontend-only state (like scrollback) and so TUI apps don't thrash on tab switches. + useEffect(() => { + if (!visible) return; + if (termRef.current || initInProgressRef.current) return; + + const containerEl = containerRef.current; + if (!containerEl) { return; } + // StrictMode will run this effect twice in dev (setup → cleanup → setup). + // If the first async init completes after cleanup, we can end up with two ghostty-web + // terminals wired to the same DOM node (double cursor + duplicated input). Make the + // init path explicitly cancelable. + let cancelled = false; + initInProgressRef.current = true; + let terminal: Terminal | null = null; + let disposeOnData: { dispose: () => void } | null = null; + let disposeOnTitleChange: { dispose: () => void } | null = null; + + setTerminalError(null); const initTerminal = async () => { try { // Initialize ghostty-web WASM module (idempotent, safe to call multiple times) await init(); + if (cancelled) { + return; + } + + // Be defensive: if anything previously mounted into this container (e.g. from an + // interrupted init), clear it before opening a new terminal. + containerEl.replaceChildren(); + // Resolve CSS variables for xterm.js (canvas rendering doesn't support CSS vars) const styles = getComputedStyle(document.documentElement); const terminalBg = styles.getPropertyValue("--color-terminal-bg").trim() || "#1e1e1e"; const terminalFg = styles.getPropertyValue("--color-terminal-fg").trim() || "#d4d4d4"; terminal = new Terminal({ - fontSize: 14, + fontSize: 13, fontFamily: "JetBrains Mono, Menlo, Monaco, monospace", - cursorBlink: true, + // Start with no blinking - we enable it on focus + cursorBlink: false, theme: { background: terminalBg, foreground: terminalFg, @@ -99,48 +212,107 @@ export function TerminalView({ workspaceId, sessionId, visible }: TerminalViewPr const fitAddon = new FitAddon(); terminal.loadAddon(fitAddon); - terminal.open(containerRef.current!); + terminal.open(containerEl); fitAddon.fit(); - const { cols, rows } = terminal; - - // Set terminal size so PTY session can be created with matching dimensions - // Use stable object reference to prevent unnecessary effect re-runs - setTerminalSize((prev) => { - if (prev?.cols === cols && prev?.rows === rows) { - return prev; + // ghostty-web calls focus() internally in open(), which steals focus. + // It also schedules a delayed focus with setTimeout(0) as "backup". + // If autoFocus is disabled, blur immediately AND with a delayed blur to counteract. + // If autoFocus is enabled, focus the hidden textarea to avoid browser caret. + if (autoFocus) { + const textarea = containerEl.querySelector("textarea"); + if (textarea instanceof HTMLTextAreaElement) { + textarea.focus(); } - return { cols, rows }; + } else { + // Blur immediately + terminal.blur(); + // Counter the delayed focus() in ghostty-web + const termToBlur = terminal; + setTimeout(() => { + termToBlur.blur(); + }, 0); + } + + // User input → router + disposeOnData = terminal.onData((data: string) => { + router.sendInput(sessionId, data); }); - // User input → IPC (use ref to always get latest sendInput) - terminal.onData((data: string) => { - sendInputRef.current(data); + // Terminal title changes (from OSC escape sequences like "echo -ne '\033]0;Title\007'") + // Use ref to always get latest callback + disposeOnTitleChange = terminal.onTitleChange((title: string) => { + onTitleChangeRef.current?.(title); }); termRef.current = terminal; fitAddonRef.current = fitAddon; + disposeOnDataRef.current = disposeOnData; + disposeOnTitleChangeRef.current = disposeOnTitleChange; + setTerminalReady(true); } catch (err) { + if (cancelled) { + return; + } + console.error("Failed to initialize terminal:", err); setTerminalError(err instanceof Error ? err.message : "Failed to initialize terminal"); + } finally { + initInProgressRef.current = false; } }; void initTerminal(); return () => { - if (terminal) { - terminal.dispose(); + cancelled = true; + + // If the terminal finished initializing, we keep it alive across visible toggles. + if (termRef.current) { + return; } - termRef.current = null; - fitAddonRef.current = null; - setTerminalReady(false); - setTerminalSize(null); + + // Otherwise, clean up any partially created resources so a future attempt can succeed. + disposeOnData?.dispose(); + disposeOnTitleChange?.dispose(); + terminal?.dispose(); + containerEl.replaceChildren(); + initInProgressRef.current = false; }; - // Note: sendInput and resize are intentionally not in deps - // They're used in callbacks, not during effect execution - }, [visible, workspaceId]); + }, [visible, workspaceId, router, sessionId, autoFocus]); + + // Track focus/blur on the terminal container to control cursor blinking + useEffect(() => { + if (!terminalReady || !containerRef.current) { + return; + } + + const container = containerRef.current; + + const handleFocusIn = () => { + if (termRef.current) { + termRef.current.options.cursorBlink = true; + } + }; + + const handleFocusOut = (e: FocusEvent) => { + // Only blur if focus is leaving the container entirely + if (!container.contains(e.relatedTarget as Node)) { + if (termRef.current) { + termRef.current.options.cursorBlink = false; + } + } + }; + + container.addEventListener("focusin", handleFocusIn); + container.addEventListener("focusout", handleFocusOut); + + return () => { + container.removeEventListener("focusin", handleFocusIn); + container.removeEventListener("focusout", handleFocusOut); + }; + }, [terminalReady]); // Resize on container size change useEffect(() => { @@ -171,14 +343,6 @@ export function TerminalView({ workspaceId, sessionId, visible }: TerminalViewPr lastCols = cols; lastRows = rows; - // Update state (with stable reference to prevent unnecessary re-renders) - setTerminalSize((prev) => { - if (prev?.cols === cols && prev?.rows === rows) { - return prev; - } - return { cols, rows }; - }); - // Store pending resize pendingResize = { cols, rows }; @@ -190,14 +354,11 @@ export function TerminalView({ workspaceId, sessionId, visible }: TerminalViewPr resizeTimeoutId = setTimeout(() => { if (pendingResize) { - console.log( - `[TerminalView] Sending resize to PTY: ${pendingResize.cols}x${pendingResize.rows}` - ); // Double requestAnimationFrame to ensure vim is ready requestAnimationFrame(() => { requestAnimationFrame(() => { if (pendingResize) { - resizeRef.current(pendingResize.cols, pendingResize.rows); + router.resize(sessionId, pendingResize.cols, pendingResize.rows); pendingResize = null; } }); @@ -224,20 +385,33 @@ export function TerminalView({ workspaceId, sessionId, visible }: TerminalViewPr resizeObserver.disconnect(); window.removeEventListener("resize", handleResize); }; - }, [visible, terminalReady]); // terminalReady ensures ResizeObserver is set up after terminal is initialized + }, [visible, terminalReady, router, sessionId]); - if (!visible) return null; + const errorMessage = terminalError; - const errorMessage = terminalError ?? sessionError; + // Focus the terminal when the container is clicked + const handleContainerClick = useCallback(() => { + if (termRef.current) { + termRef.current.focus(); + } + }, []); + + // Show loading overlay until we receive initial screen state + const showLoading = isLoading && terminalReady && visible; return (
{errorMessage && (
@@ -248,11 +422,32 @@ export function TerminalView({ workspaceId, sessionId, visible }: TerminalViewPr ref={containerRef} className="terminal-container" style={{ + flex: 1, + minHeight: 0, width: "100%", - height: "100%", overflow: "hidden", + // ghostty-web uses a contenteditable root for input; hide the browser caret + // so we don't show a "second cursor". + caretColor: "transparent", + // Hide terminal content while loading to prevent flash + visibility: showLoading ? "hidden" : "visible", }} /> + {/* Loading overlay - shows until we receive screen state from backend */} + {showLoading && ( +
+ Connecting... +
+ )}
); } diff --git a/src/browser/components/WorkspaceHeader.tsx b/src/browser/components/WorkspaceHeader.tsx index 9a852d0eb7..b88f18e390 100644 --- a/src/browser/components/WorkspaceHeader.tsx +++ b/src/browser/components/WorkspaceHeader.tsx @@ -21,6 +21,8 @@ interface WorkspaceHeaderProps { workspaceName: string; namedWorkspacePath: string; runtimeConfig?: RuntimeConfig; + /** Callback to open integrated terminal in sidebar (optional, falls back to popout) */ + onOpenTerminal?: () => void; } export const WorkspaceHeader: React.FC = ({ @@ -30,8 +32,9 @@ export const WorkspaceHeader: React.FC = ({ workspaceName, namedWorkspacePath, runtimeConfig, + onOpenTerminal, }) => { - const openTerminal = useOpenTerminal(); + const openTerminalPopout = useOpenTerminal(); const openInEditor = useOpenInEditor(); const gitStatus = useGitStatus(workspaceId); const { canInterrupt } = useWorkspaceSidebarState(workspaceId); @@ -40,8 +43,13 @@ export const WorkspaceHeader: React.FC = ({ const [mcpModalOpen, setMcpModalOpen] = useState(false); const handleOpenTerminal = useCallback(() => { - openTerminal(workspaceId, runtimeConfig); - }, [workspaceId, openTerminal, runtimeConfig]); + if (onOpenTerminal) { + onOpenTerminal(); + } else { + // Fallback to popout if no integrated terminal callback provided + void openTerminalPopout(workspaceId, runtimeConfig); + } + }, [workspaceId, openTerminalPopout, runtimeConfig, onOpenTerminal]); const handleOpenInEditor = useCallback(async () => { setEditorError(null); @@ -138,7 +146,7 @@ export const WorkspaceHeader: React.FC = ({ - Open terminal window ({formatKeybind(KEYBINDS.OPEN_TERMINAL)}) + New terminal ({formatKeybind(KEYBINDS.OPEN_TERMINAL)})
diff --git a/src/browser/hooks/useAIViewKeybinds.ts b/src/browser/hooks/useAIViewKeybinds.ts index f76e2d7961..98b622ec6d 100644 --- a/src/browser/hooks/useAIViewKeybinds.ts +++ b/src/browser/hooks/useAIViewKeybinds.ts @@ -115,8 +115,10 @@ export function useAIViewKeybinds({ } }; - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); + // Use capture phase so keybinds work even when terminal is focused + // (terminal components may consume events in bubble phase) + window.addEventListener("keydown", handleKeyDown, { capture: true }); + return () => window.removeEventListener("keydown", handleKeyDown, { capture: true }); }, [ jumpToBottom, handleOpenTerminal, diff --git a/src/browser/hooks/useOpenTerminal.ts b/src/browser/hooks/useOpenTerminal.ts index 39d6f4a8eb..492eb26ff1 100644 --- a/src/browser/hooks/useOpenTerminal.ts +++ b/src/browser/hooks/useOpenTerminal.ts @@ -2,6 +2,7 @@ import { useCallback } from "react"; import { useAPI } from "@/browser/contexts/API"; import type { RuntimeConfig } from "@/common/types/runtime"; import { isSSHRuntime } from "@/common/types/runtime"; +import { createTerminalSession, openTerminalPopout } from "@/browser/utils/terminal"; /** * Hook to open a terminal window for a workspace. @@ -19,7 +20,9 @@ export function useOpenTerminal() { const { api } = useAPI(); return useCallback( - (workspaceId: string, runtimeConfig?: RuntimeConfig) => { + async (workspaceId: string, runtimeConfig?: RuntimeConfig) => { + if (!api) return; + // Check if running in browser mode // window.api is only available in Electron (set by preload.ts) // If window.api exists, we're in Electron; if not, we're in browser mode @@ -29,24 +32,13 @@ export function useOpenTerminal() { // SSH workspaces always use web terminal (in browser popup or Electron window) // because the PTY service handles the SSH connection to the remote host if (isBrowser || isSSH) { - if (isBrowser) { - // In browser mode, we must open the window client-side using window.open - // The backend cannot open a window on the user's client - const url = `/terminal.html?workspaceId=${encodeURIComponent(workspaceId)}`; - window.open( - url, - `terminal-${workspaceId}-${Date.now()}`, - "width=1000,height=600,popup=yes" - ); - } - - // Open web terminal window (Electron pops up BrowserWindow, browser already opened above) - // For SSH: this is the only way to get a terminal that works through PTY service - void api?.terminal.openWindow({ workspaceId }); + // Create terminal session first - window needs sessionId to connect + const session = await createTerminalSession(api, workspaceId); + openTerminalPopout(api, workspaceId, session.sessionId); } else { // In Electron (desktop) mode with local workspace, open the native system terminal // This spawns the user's preferred terminal emulator (Ghostty, Terminal.app, etc.) - void api?.terminal.openNative({ workspaceId }); + void api.terminal.openNative({ workspaceId }); } }, [api] diff --git a/src/browser/hooks/useTerminalSession.ts b/src/browser/hooks/useTerminalSession.ts deleted file mode 100644 index 8a228cdbfd..0000000000 --- a/src/browser/hooks/useTerminalSession.ts +++ /dev/null @@ -1,172 +0,0 @@ -import { useState, useEffect, useCallback, useRef } from "react"; -import { useAPI } from "@/browser/contexts/API"; - -import type { TerminalSession } from "@/common/types/terminal"; - -/** - * Hook to manage terminal IPC session lifecycle. - * - * Supports two modes: - * 1. Create new session: when existingSessionId is undefined, creates a new PTY session - * 2. Reattach to existing session: when existingSessionId is provided (e.g., from openInEditor), - * subscribes to that session without creating a new one - */ -export function useTerminalSession( - workspaceId: string, - existingSessionId: string | undefined, - enabled: boolean, - terminalSize?: { cols: number; rows: number } | null, - onOutput?: (data: string) => void, - onExit?: (exitCode: number) => void -) { - const { api } = useAPI(); - const [sessionId, setSessionId] = useState(null); - const [connected, setConnected] = useState(false); - const [error, setError] = useState(null); - const [shouldInit, setShouldInit] = useState(false); - - // Track whether we created the session (vs reattaching to existing) - // Used to determine if we should close the session on cleanup - const createdSessionRef = useRef(false); - - // Watch for terminalSize to become available - useEffect(() => { - if (enabled && terminalSize && !shouldInit) { - setShouldInit(true); - } - }, [enabled, terminalSize, shouldInit]); - - // Create terminal session and subscribe to IPC events - // Only depends on workspaceId, existingSessionId and shouldInit, NOT terminalSize - useEffect(() => { - if (!shouldInit || !terminalSize || !api) { - return; - } - - let mounted = true; - let targetSessionId: string | null = null; - const cleanupFns: Array<() => void> = []; - - const initSession = async () => { - try { - if (existingSessionId) { - // Reattach to existing session (e.g., from openInEditor) - // The session was already created by the backend with initialCommand - targetSessionId = existingSessionId; - createdSessionRef.current = false; - } else { - // Create new terminal session with current terminal size - const session: TerminalSession = await api.terminal.create({ - workspaceId, - cols: terminalSize.cols, - rows: terminalSize.rows, - }); - - if (!mounted) { - return; - } - - targetSessionId = session.sessionId; - createdSessionRef.current = true; - } - - setSessionId(targetSessionId); - - const abortController = new AbortController(); - const { signal } = abortController; - - // Subscribe to output events via ORPC async iterator - // Fire and forget async loop - (async () => { - try { - const iterator = await api.terminal.onOutput( - { sessionId: targetSessionId }, - { signal } - ); - for await (const data of iterator) { - if (!mounted) break; - if (onOutput) onOutput(data); - } - } catch (err) { - if (!signal.aborted) { - console.error("[Terminal] Output stream error:", err); - } - } - })(); - - // Subscribe to exit events via ORPC async iterator - (async () => { - try { - const iterator = await api.terminal.onExit({ sessionId: targetSessionId }, { signal }); - for await (const code of iterator) { - if (!mounted) break; - setConnected(false); - if (onExit) onExit(code); - break; // Exit happens only once - } - } catch (err) { - if (!signal.aborted) { - console.error("[Terminal] Exit stream error:", err); - } - } - })(); - - cleanupFns.push(() => abortController.abort()); - setConnected(true); - setError(null); - } catch (err) { - console.error("[Terminal] Failed to initialize terminal session:", err); - if (mounted) { - setError(err instanceof Error ? err.message : "Failed to initialize terminal"); - } - } - }; - - void initSession(); - - return () => { - mounted = false; - - // Unsubscribe from IPC events - cleanupFns.forEach((fn) => fn()); - - // Only close the session if WE created it (not for reattached sessions) - // Reattached sessions (e.g., from openInEditor) should persist when the window closes - if (targetSessionId && createdSessionRef.current) { - void api?.terminal.close({ sessionId: targetSessionId }); - } - - // Reset init flag so a new session can be created if workspace changes - setShouldInit(false); - }; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [workspaceId, existingSessionId, shouldInit, api]); // DO NOT include terminalSize - changes should not recreate session - - // Send input to terminal - const sendInput = useCallback( - (data: string) => { - if (sessionId) { - void api?.terminal.sendInput({ sessionId, data }); - } - }, - [sessionId, api] - ); - - // Resize terminal - const resize = useCallback( - (cols: number, rows: number) => { - if (sessionId) { - void api?.terminal.resize({ sessionId, cols, rows }); - } - }, - [sessionId, api] - ); - - return { - connected, - sessionId, - error, - sendInput, - resize, - }; -} diff --git a/src/browser/stores/WorkspaceStore.ts b/src/browser/stores/WorkspaceStore.ts index 9d9c784562..5b8fc10cc8 100644 --- a/src/browser/stores/WorkspaceStore.ts +++ b/src/browser/stores/WorkspaceStore.ts @@ -895,8 +895,7 @@ export class WorkspaceStore { // streaming token and sidebar items don't use them. Components needing timing should // use useWorkspaceStatsSnapshot() which has its own subscription. if ( - cached && - cached.canInterrupt === fullState.canInterrupt && + cached?.canInterrupt === fullState.canInterrupt && cached.awaitingUserQuestion === fullState.awaitingUserQuestion && cached.currentModel === fullState.currentModel && cached.recencyTimestamp === fullState.recencyTimestamp && diff --git a/src/browser/stories/App.rightSidebar.stories.tsx b/src/browser/stories/App.rightSidebar.stories.tsx index 4a44baeee9..faa654eeba 100644 --- a/src/browser/stories/App.rightSidebar.stories.tsx +++ b/src/browser/stories/App.rightSidebar.stories.tsx @@ -16,8 +16,8 @@ import { createUserMessage, createAssistantMessage } from "./mockFactory"; import { within, userEvent, waitFor, expect } from "@storybook/test"; import { RIGHT_SIDEBAR_TAB_KEY, - RIGHT_SIDEBAR_COSTS_WIDTH_KEY, - RIGHT_SIDEBAR_REVIEW_WIDTH_KEY, + RIGHT_SIDEBAR_WIDTH_KEY, + getRightSidebarLayoutKey, } from "@/common/constants/storage"; import type { ComponentType } from "react"; import type { MockSessionUsage } from "@/browser/stories/mocks/orpc"; @@ -76,9 +76,9 @@ export const CostsTab: AppStory = { { localStorage.setItem(RIGHT_SIDEBAR_TAB_KEY, JSON.stringify("costs")); - // Set per-tab widths: costs at 350px, review at 700px - localStorage.setItem(RIGHT_SIDEBAR_COSTS_WIDTH_KEY, "350"); - localStorage.setItem(RIGHT_SIDEBAR_REVIEW_WIDTH_KEY, "700"); + localStorage.setItem("costsTab:viewMode", JSON.stringify("session")); + localStorage.setItem(RIGHT_SIDEBAR_WIDTH_KEY, "400"); + localStorage.removeItem(getRightSidebarLayoutKey("ws-costs")); const client = setupSimpleChatStory({ workspaceId: "ws-costs", @@ -120,7 +120,19 @@ export const CostsTabWithCacheCreate: AppStory = { { localStorage.setItem(RIGHT_SIDEBAR_TAB_KEY, JSON.stringify("costs")); - localStorage.setItem(RIGHT_SIDEBAR_COSTS_WIDTH_KEY, "350"); + localStorage.setItem("costsTab:viewMode", JSON.stringify("session")); + localStorage.setItem(RIGHT_SIDEBAR_WIDTH_KEY, "350"); + const modelUsage = { + // Realistic Anthropic usage: heavy caching, cache create is expensive + input: { tokens: 2000, cost_usd: 0.006 }, + cached: { tokens: 45000, cost_usd: 0.0045 }, // Cache read: cheap + cacheCreate: { tokens: 30000, cost_usd: 0.1125 }, // Cache create: expensive! + output: { tokens: 3000, cost_usd: 0.045 }, + reasoning: { tokens: 0, cost_usd: 0 }, + model: "anthropic:claude-sonnet-4-20250514", + }; + + localStorage.removeItem(getRightSidebarLayoutKey("ws-cache-create")); const client = setupSimpleChatStory({ workspaceId: "ws-cache-create", @@ -134,15 +146,12 @@ export const CostsTabWithCacheCreate: AppStory = { ], sessionUsage: { byModel: { - "anthropic:claude-sonnet-4-20250514": { - // Realistic Anthropic usage: heavy caching, cache create is expensive - input: { tokens: 2000, cost_usd: 0.006 }, - cached: { tokens: 45000, cost_usd: 0.0045 }, // Cache read: cheap - cacheCreate: { tokens: 30000, cost_usd: 0.1125 }, // Cache create: expensive! - output: { tokens: 3000, cost_usd: 0.045 }, - reasoning: { tokens: 0, cost_usd: 0 }, - model: "anthropic:claude-sonnet-4-20250514", - }, + [modelUsage.model]: modelUsage, + }, + lastRequest: { + model: modelUsage.model, + usage: modelUsage, + timestamp: 0, }, version: 1, }, @@ -155,13 +164,17 @@ export const CostsTabWithCacheCreate: AppStory = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - // Wait for costs to render - cache create should be dominant cost + // Ensure we're on the Costs tab (layout state can persist across stories). + const costsTab = await canvas.findByRole("tab", { name: /^costs/i }, { timeout: 10_000 }); + await userEvent.click(costsTab); + + // Wait for session usage to load + render. await waitFor( () => { - canvas.getByText("Cache Create"); - canvas.getByText("Cache Read"); + canvas.getByText(/cache create/i); + canvas.getByText(/cache read/i); }, - { timeout: 5000 } + { timeout: 15_000 } ); }, }; @@ -175,9 +188,8 @@ export const ReviewTab: AppStory = { { localStorage.setItem(RIGHT_SIDEBAR_TAB_KEY, JSON.stringify("costs")); - // Set distinct widths per tab to verify switching behavior - localStorage.setItem(RIGHT_SIDEBAR_COSTS_WIDTH_KEY, "350"); - localStorage.setItem(RIGHT_SIDEBAR_REVIEW_WIDTH_KEY, "700"); + localStorage.setItem(RIGHT_SIDEBAR_WIDTH_KEY, "700"); + localStorage.removeItem(getRightSidebarLayoutKey("ws-review")); const client = setupSimpleChatStory({ workspaceId: "ws-review", @@ -222,11 +234,14 @@ export const StatsTabIdle: AppStory = { { localStorage.setItem(RIGHT_SIDEBAR_TAB_KEY, JSON.stringify("stats")); + // Clear persisted layout to ensure stats tab appears in fresh default layout + localStorage.removeItem(getRightSidebarLayoutKey("ws-stats-idle")); const client = setupSimpleChatStory({ workspaceId: "ws-stats-idle", workspaceName: "feature/stats", projectName: "my-app", + statsTabEnabled: true, messages: [ createUserMessage("msg-1", "Help me with something", { historySequence: 1 }), createAssistantMessage("msg-2", "Sure, I can help with that.", { historySequence: 2 }), @@ -259,6 +274,8 @@ export const StatsTabStreaming: AppStory = { { localStorage.setItem(RIGHT_SIDEBAR_TAB_KEY, JSON.stringify("stats")); + // Clear persisted layout to ensure stats tab appears in fresh default layout + localStorage.removeItem(getRightSidebarLayoutKey("ws-stats-streaming")); const client = setupStreamingChatStory({ workspaceId: "ws-stats-streaming", @@ -384,9 +401,10 @@ export const ReviewTabSortByLastEdit: AppStory = { { localStorage.setItem(RIGHT_SIDEBAR_TAB_KEY, JSON.stringify("review")); - localStorage.setItem(RIGHT_SIDEBAR_REVIEW_WIDTH_KEY, "700"); - + localStorage.setItem(RIGHT_SIDEBAR_WIDTH_KEY, "700"); + // Clear persisted layout to ensure review tab appears in fresh default layout const workspaceId = "ws-review-sort"; + localStorage.removeItem(getRightSidebarLayoutKey(workspaceId)); const now = Date.now(); // Set up first-seen timestamps for hunks (oldest to newest: format -> button -> client) @@ -426,12 +444,21 @@ export const ReviewTabSortByLastEdit: AppStory = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - // Wait for review tab to be selected and loaded + // Ensure the Review tab is active. Storybook can reuse a long-lived AppLoader + // instance between stories, so persisted state might not apply until interaction. + const expandButtons = canvas.queryAllByRole("button", { name: "Expand sidebar" }); + if (expandButtons.length > 0) { + await userEvent.click(expandButtons[expandButtons.length - 1]); + } + + const reviewTab = await canvas.findByRole("tab", { name: /^review/i }, { timeout: 10_000 }); + await userEvent.click(reviewTab); + await waitFor( () => { canvas.getByRole("tab", { name: /^review/i, selected: true }); }, - { timeout: 5000 } + { timeout: 10_000 } ); // Verify the sort dropdown shows "Last edit" @@ -476,9 +503,9 @@ export const ReviewTabSortByFileOrder: AppStory = { { localStorage.setItem(RIGHT_SIDEBAR_TAB_KEY, JSON.stringify("review")); - localStorage.setItem(RIGHT_SIDEBAR_REVIEW_WIDTH_KEY, "700"); - + localStorage.setItem(RIGHT_SIDEBAR_WIDTH_KEY, "700"); const workspaceId = "ws-review-file-order"; + localStorage.removeItem(getRightSidebarLayoutKey(workspaceId)); // Set sort order to "file-order" (default) setReviewSortOrder("file-order"); @@ -504,12 +531,20 @@ export const ReviewTabSortByFileOrder: AppStory = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - // Wait for review tab to be selected + // Ensure Review tab is active (Storybook may reuse the same App instance). + const expandButtons = canvas.queryAllByRole("button", { name: "Expand sidebar" }); + if (expandButtons.length > 0) { + await userEvent.click(expandButtons[expandButtons.length - 1]); + } + + const reviewTab = await canvas.findByRole("tab", { name: /^review/i }, { timeout: 10_000 }); + await userEvent.click(reviewTab); + await waitFor( () => { canvas.getByRole("tab", { name: /^review/i, selected: true }); }, - { timeout: 5000 } + { timeout: 10_000 } ); // Verify the sort dropdown shows "File order" @@ -580,7 +615,8 @@ export const DiffPaddingAlignment: AppStory = { { localStorage.setItem(RIGHT_SIDEBAR_TAB_KEY, JSON.stringify("review")); - localStorage.setItem(RIGHT_SIDEBAR_REVIEW_WIDTH_KEY, "700"); + localStorage.setItem(RIGHT_SIDEBAR_WIDTH_KEY, "700"); + localStorage.removeItem(getRightSidebarLayoutKey("ws-diff-alignment")); const client = setupSimpleChatStory({ workspaceId: "ws-diff-alignment", @@ -603,12 +639,21 @@ export const DiffPaddingAlignment: AppStory = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - // Wait for review tab to be selected + // Ensure Review tab is active. + const expandButtons = canvas.queryAllByRole("button", { name: "Expand sidebar" }); + if (expandButtons.length > 0) { + await userEvent.click(expandButtons[expandButtons.length - 1]); + } + + const reviewTab = await canvas.findByRole("tab", { name: /^review/i }, { timeout: 10_000 }); + await userEvent.click(reviewTab); + + // Wait for Review tab to be selected. await waitFor( () => { canvas.getByRole("tab", { name: /^review/i, selected: true }); }, - { timeout: 5000 } + { timeout: 10_000 } ); // Wait for diff content to render @@ -657,7 +702,8 @@ export const DiffPaddingAlignmentModification: AppStory = { { localStorage.setItem(RIGHT_SIDEBAR_TAB_KEY, JSON.stringify("review")); - localStorage.setItem(RIGHT_SIDEBAR_REVIEW_WIDTH_KEY, "700"); + localStorage.setItem(RIGHT_SIDEBAR_WIDTH_KEY, "700"); + localStorage.removeItem(getRightSidebarLayoutKey("ws-diff-modification")); const client = setupSimpleChatStory({ workspaceId: "ws-diff-modification", @@ -680,12 +726,21 @@ export const DiffPaddingAlignmentModification: AppStory = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); + // Ensure Review tab is active. + const expandButtons = canvas.queryAllByRole("button", { name: "Expand sidebar" }); + if (expandButtons.length > 0) { + await userEvent.click(expandButtons[expandButtons.length - 1]); + } + + const reviewTab = await canvas.findByRole("tab", { name: /^review/i }, { timeout: 10_000 }); + await userEvent.click(reviewTab); + // Wait for diff content to render await waitFor( () => { canvas.getByText(/export const config/i); }, - { timeout: 5000 } + { timeout: 10_000 } ); // Visual verification for mixed diff types @@ -765,9 +820,9 @@ export const ReviewTabReadMore: AppStory = { { localStorage.setItem(RIGHT_SIDEBAR_TAB_KEY, JSON.stringify("review")); - localStorage.setItem(RIGHT_SIDEBAR_REVIEW_WIDTH_KEY, "700"); - + localStorage.setItem(RIGHT_SIDEBAR_WIDTH_KEY, "700"); const workspaceId = "ws-read-more"; + localStorage.removeItem(getRightSidebarLayoutKey(workspaceId)); const client = setupSimpleChatStory({ workspaceId, @@ -824,7 +879,7 @@ export const ReviewTabWithFileFilter: AppStory = { { localStorage.setItem(RIGHT_SIDEBAR_TAB_KEY, JSON.stringify("review")); - localStorage.setItem(RIGHT_SIDEBAR_REVIEW_WIDTH_KEY, "700"); + localStorage.setItem(RIGHT_SIDEBAR_WIDTH_KEY, "700"); const workspaceId = "ws-review-file-filter"; @@ -857,12 +912,20 @@ export const ReviewTabWithFileFilter: AppStory = { play: async ({ canvasElement }) => { const canvas = within(canvasElement); - // Wait for review tab to be selected + // Ensure Review tab is active. + const expandButtons = canvas.queryAllByRole("button", { name: "Expand sidebar" }); + if (expandButtons.length > 0) { + await userEvent.click(expandButtons[expandButtons.length - 1]); + } + + const reviewTab = await canvas.findByRole("tab", { name: /^review/i }, { timeout: 10_000 }); + await userEvent.click(reviewTab); + await waitFor( () => { canvas.getByRole("tab", { name: /^review/i, selected: true }); }, - { timeout: 5000 } + { timeout: 10_000 } ); // Wait for file tree to load @@ -891,9 +954,9 @@ export const ReviewTabReadMoreBoundaries: AppStory = { { localStorage.setItem(RIGHT_SIDEBAR_TAB_KEY, JSON.stringify("review")); - localStorage.setItem(RIGHT_SIDEBAR_REVIEW_WIDTH_KEY, "700"); - + localStorage.setItem(RIGHT_SIDEBAR_WIDTH_KEY, "700"); const workspaceId = "ws-read-more-boundaries"; + localStorage.removeItem(getRightSidebarLayoutKey(workspaceId)); const client = setupSimpleChatStory({ workspaceId, diff --git a/src/browser/stories/mocks/orpc.ts b/src/browser/stories/mocks/orpc.ts index ed016990c8..67aadd72e7 100644 --- a/src/browser/stories/mocks/orpc.ts +++ b/src/browser/stories/mocks/orpc.ts @@ -665,6 +665,7 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl setTitle: () => Promise.resolve(undefined), }, terminal: { + listSessions: (_input: { workspaceId: string }) => Promise.resolve([]), create: () => Promise.resolve({ sessionId: "mock-session", @@ -675,7 +676,8 @@ export function createMockORPCClient(options: MockORPCClientOptions = {}): APICl close: () => Promise.resolve(undefined), resize: () => Promise.resolve(undefined), sendInput: () => undefined, - onOutput: async function* () { + attach: async function* (_input: { sessionId: string }) { + yield { type: "screenState", data: "" }; yield* []; await new Promise(() => undefined); }, diff --git a/src/browser/stories/storyPlayHelpers.ts b/src/browser/stories/storyPlayHelpers.ts index 578d6ea01d..645090ce17 100644 --- a/src/browser/stories/storyPlayHelpers.ts +++ b/src/browser/stories/storyPlayHelpers.ts @@ -12,7 +12,7 @@ export async function waitForChatMessagesLoaded(canvasElement: HTMLElement): Pro await waitFor( () => { const messageWindow = canvasElement.querySelector('[data-testid="message-window"]'); - if (!messageWindow || messageWindow.getAttribute("data-loaded") !== "true") { + if (messageWindow?.getAttribute("data-loaded") !== "true") { throw new Error("Messages not loaded yet"); } }, diff --git a/src/browser/terminal-window.tsx b/src/browser/terminal-window.tsx index bb1b22c0ba..f19f084662 100644 --- a/src/browser/terminal-window.tsx +++ b/src/browser/terminal-window.tsx @@ -9,17 +9,18 @@ import React from "react"; import ReactDOM from "react-dom/client"; import { TerminalView } from "@/browser/components/TerminalView"; import { APIProvider } from "@/browser/contexts/API"; +import { TerminalRouterProvider } from "@/browser/terminal/TerminalRouterContext"; import "./styles/globals.css"; // Get workspace ID from query parameter const params = new URLSearchParams(window.location.search); const workspaceId = params.get("workspaceId"); -const sessionId = params.get("sessionId"); // Reserved for future reload support +const sessionId = params.get("sessionId"); -if (!workspaceId) { +if (!workspaceId || !sessionId) { document.body.innerHTML = `
- Error: No workspace ID provided + Error: Missing workspace ID or session ID
`; } else { @@ -30,7 +31,9 @@ if (!workspaceId) { // race conditions with WebSocket connections and terminal lifecycle ReactDOM.createRoot(document.getElementById("root")!).render( - + + + ); } diff --git a/src/browser/terminal/TerminalRouterContext.tsx b/src/browser/terminal/TerminalRouterContext.tsx new file mode 100644 index 0000000000..3c28c741b5 --- /dev/null +++ b/src/browser/terminal/TerminalRouterContext.tsx @@ -0,0 +1,66 @@ +/** + * React context for TerminalSessionRouter. + * + * Provides centralized terminal session management to all TerminalView components. + * Must be wrapped inside APIProvider since it depends on the API client. + */ + +import { createContext, useContext, useEffect, useRef } from "react"; +import { useAPI } from "@/browser/contexts/API"; +import { TerminalSessionRouter } from "./TerminalSessionRouter"; + +const TerminalRouterContext = createContext(null); + +interface TerminalRouterProviderProps { + children: React.ReactNode; +} + +/** + * Provides TerminalSessionRouter to the component tree. + * + * Creates a single router instance that lives for the lifetime of the provider. + * The router is recreated if the API client changes (e.g., reconnection). + */ +export function TerminalRouterProvider(props: TerminalRouterProviderProps) { + const { api } = useAPI(); + const routerRef = useRef(null); + + // Create/recreate router when API changes + if (api && (!routerRef.current || routerRef.current.getApi() !== api)) { + // Dispose old router if exists + routerRef.current?.dispose(); + routerRef.current = new TerminalSessionRouter(api); + } + + // Cleanup on unmount + useEffect(() => { + return () => { + routerRef.current?.dispose(); + routerRef.current = null; + }; + }, []); + + // Don't render children until API is ready + if (!api || !routerRef.current) { + return null; + } + + return ( + + {props.children} + + ); +} + +/** + * Hook to access the TerminalSessionRouter. + * + * @throws If used outside of TerminalRouterProvider + */ +export function useTerminalRouter(): TerminalSessionRouter { + const router = useContext(TerminalRouterContext); + if (!router) { + throw new Error("useTerminalRouter must be used within a TerminalRouterProvider"); + } + return router; +} diff --git a/src/browser/terminal/TerminalSessionRouter.ts b/src/browser/terminal/TerminalSessionRouter.ts new file mode 100644 index 0000000000..1cec37ae88 --- /dev/null +++ b/src/browser/terminal/TerminalSessionRouter.ts @@ -0,0 +1,247 @@ +/** + * TerminalSessionRouter - Centralized manager for terminal session streams + * + * Eliminates entire classes of bugs by enforcing: + * 1. Exactly one ORPC stream per sessionId (no duplicate subscriptions) + * 2. Explicit routing via Map lookup (no closure captures) + * 3. Synchronous subscribe/unsubscribe (no timing races) + * 4. Cached screenState for late subscribers + * + * Usage: + * ```typescript + * const router = new TerminalSessionRouter(api); + * const unsubscribe = router.subscribe(sessionId, { + * onOutput: (data) => term.write(data), + * onScreenState: (state) => { term.clear(); term.write(state); }, + * onExit: (code) => console.log('Exit:', code), + * }); + * // Later: + * unsubscribe(); + * ``` + */ + +import type { RouterClient } from "@orpc/server"; +import type { AppRouter } from "@/node/orpc/router"; + +type APIClient = RouterClient; + +export interface TerminalSubscriberCallbacks { + onOutput: (data: string) => void; + onScreenState: (state: string) => void; + onExit: (code: number) => void; +} + +interface SessionState { + /** Unique subscriber ID → callbacks */ + subscribers: Map; + /** Cached screen state (sent to new subscribers immediately) */ + screenState: string | null; + /** Abort controller for the attach stream */ + abortController: AbortController; + /** Whether the session has exited */ + exited: boolean; + /** Exit code if exited */ + exitCode?: number; +} + +let nextSubscriberId = 1; + +export class TerminalSessionRouter { + private readonly api: APIClient; + private sessions = new Map(); + + constructor(api: APIClient) { + this.api = api; + } + + /** Get the API client (for identity comparison when recreating router) */ + getApi(): APIClient { + return this.api; + } + + /** + * Subscribe to a terminal session's output. + * + * If this is the first subscriber for the session, starts the ORPC stream. + * If screenState is already cached (from a previous subscriber), delivers it immediately. + * + * @returns Unsubscribe function (call to stop receiving data) + */ + subscribe(sessionId: string, callbacks: TerminalSubscriberCallbacks): () => void { + const subscriberId = nextSubscriberId++; + + console.debug(`[TerminalRouter] Subscribe: session=${sessionId}, subscriberId=${subscriberId}`); + + let session = this.sessions.get(sessionId); + if (!session) { + // First subscriber - create session state and start stream + console.debug(`[TerminalRouter] First subscriber for ${sessionId}, starting stream`); + session = { + subscribers: new Map(), + screenState: null, + abortController: new AbortController(), + exited: false, + }; + this.sessions.set(sessionId, session); + this.startStream(sessionId, session); + } + + // Add subscriber + session.subscribers.set(subscriberId, callbacks); + console.debug( + `[TerminalRouter] Session ${sessionId} now has ${session.subscribers.size} subscribers` + ); + + // Deliver cached screenState if available (for late subscribers) + if (session.screenState !== null) { + // Use setTimeout to ensure this happens after the caller has finished setup + setTimeout(() => { + const currentSession = this.sessions.get(sessionId); + const currentCallbacks = currentSession?.subscribers.get(subscriberId); + if (currentCallbacks && currentSession && currentSession.screenState !== null) { + currentCallbacks.onScreenState(currentSession.screenState); + } + }, 0); + } + + // If session already exited, notify immediately + if (session.exited && session.exitCode !== undefined) { + setTimeout(() => { + const currentSession = this.sessions.get(sessionId); + const currentCallbacks = currentSession?.subscribers.get(subscriberId); + if (currentCallbacks && currentSession?.exited && currentSession.exitCode !== undefined) { + currentCallbacks.onExit(currentSession.exitCode); + } + }, 0); + } + + // Return unsubscribe function + return () => { + console.debug( + `[TerminalRouter] Unsubscribe: session=${sessionId}, subscriberId=${subscriberId}` + ); + const currentSession = this.sessions.get(sessionId); + if (!currentSession) { + console.debug(`[TerminalRouter] Session ${sessionId} already removed`); + return; + } + + currentSession.subscribers.delete(subscriberId); + console.debug( + `[TerminalRouter] Session ${sessionId} now has ${currentSession.subscribers.size} subscribers` + ); + + // If no more subscribers, tear down the stream + if (currentSession.subscribers.size === 0) { + console.debug(`[TerminalRouter] No more subscribers for ${sessionId}, tearing down stream`); + currentSession.abortController.abort(); + this.sessions.delete(sessionId); + } + }; + } + + /** + * Send input to a terminal session. + */ + sendInput(sessionId: string, data: string): void { + void this.api.terminal.sendInput({ sessionId, data }); + } + + /** + * Resize a terminal session. + */ + resize(sessionId: string, cols: number, rows: number): void { + void this.api.terminal.resize({ sessionId, cols, rows }); + } + + /** + * Clean up all sessions (call on unmount). + */ + dispose(): void { + for (const session of this.sessions.values()) { + session.abortController.abort(); + } + this.sessions.clear(); + } + + /** + * Check if a session has any subscribers. + */ + hasSubscribers(sessionId: string): boolean { + const session = this.sessions.get(sessionId); + return session ? session.subscribers.size > 0 : false; + } + + // ============================================================================ + // Private methods + // ============================================================================ + + private startStream(sessionId: string, session: SessionState): void { + const { signal } = session.abortController; + + console.debug(`[TerminalRouter] Starting stream for session ${sessionId}`); + + // Start attach stream (fire-and-forget, but managed by abort controller) + void (async () => { + try { + const iterator = await this.api.terminal.attach({ sessionId }, { signal }); + for await (const msg of iterator) { + // Check if session was removed (unsubscribed) + const currentSession = this.sessions.get(sessionId); + if (!currentSession) { + console.debug(`[TerminalRouter] Session ${sessionId} removed, stopping stream`); + break; + } + + if (msg.type === "screenState") { + // Cache and broadcast + currentSession.screenState = msg.data; + console.debug( + `[TerminalRouter] Broadcasting screenState for ${sessionId} to ${currentSession.subscribers.size} subscribers` + ); + for (const callbacks of currentSession.subscribers.values()) { + callbacks.onScreenState(msg.data); + } + } else if (msg.type === "output") { + // Broadcast to all subscribers + for (const callbacks of currentSession.subscribers.values()) { + callbacks.onOutput(msg.data); + } + } + } + } catch (err) { + if (!signal.aborted) { + console.error(`[TerminalRouter] Stream error for ${sessionId}:`, err); + } + } + })(); + + // Start exit stream + void (async () => { + try { + const iterator = await this.api.terminal.onExit({ sessionId }, { signal }); + for await (const code of iterator) { + const currentSession = this.sessions.get(sessionId); + if (!currentSession) break; + + currentSession.exited = true; + currentSession.exitCode = code; + + // Broadcast to all subscribers + for (const callbacks of currentSession.subscribers.values()) { + callbacks.onExit(code); + } + break; // Exit only happens once + } + } catch (err) { + if (!signal.aborted) { + // Ignore "session not found" errors for exit stream + const errMsg = err instanceof Error ? err.message : String(err); + if (!errMsg.includes("isOpen") && !errMsg.includes("undefined")) { + console.error(`[TerminalRouter] Exit stream error for ${sessionId}:`, err); + } + } + } + })(); + } +} diff --git a/src/browser/types/rightSidebar.ts b/src/browser/types/rightSidebar.ts new file mode 100644 index 0000000000..837177eb58 --- /dev/null +++ b/src/browser/types/rightSidebar.ts @@ -0,0 +1,39 @@ +export const RIGHT_SIDEBAR_TABS = ["costs", "review", "terminal", "stats"] as const; + +/** Base tab types that are always valid */ +export type BaseTabType = (typeof RIGHT_SIDEBAR_TABS)[number]; + +/** + * Extended tab type that supports multiple terminal instances. + * Terminal tabs use the format "terminal" (placeholder for new) or "terminal:" for real sessions. + * The sessionId comes from the backend when the terminal is created. + */ +export type TabType = BaseTabType | `terminal:${string}`; + +/** Check if a value is a valid tab type (base tab or terminal instance) */ +export function isTabType(value: unknown): value is TabType { + if (typeof value !== "string") return false; + if ((RIGHT_SIDEBAR_TABS as readonly string[]).includes(value)) return true; + // Support terminal instances like "terminal:ws-123-1704567890" + return value.startsWith("terminal:"); +} + +/** Check if a tab type represents a terminal (either base "terminal" or "terminal:") */ +export function isTerminalTab(tab: TabType): boolean { + return tab === "terminal" || tab.startsWith("terminal:"); +} + +/** + * Get the backend session ID from a terminal tab type. + * Returns undefined for the placeholder "terminal" tab (new terminal being created). + */ +export function getTerminalSessionId(tab: TabType): string | undefined { + if (tab === "terminal") return undefined; + if (tab.startsWith("terminal:")) return tab.slice("terminal:".length); + return undefined; +} + +/** Create a terminal tab type for a given session ID */ +export function makeTerminalTabType(sessionId?: string): TabType { + return sessionId ? `terminal:${sessionId}` : "terminal"; +} diff --git a/src/browser/utils/commandIds.ts b/src/browser/utils/commandIds.ts index 8161601ebf..400c705403 100644 --- a/src/browser/utils/commandIds.ts +++ b/src/browser/utils/commandIds.ts @@ -33,6 +33,10 @@ export const CommandIds = { navNext: () => "nav:next" as const, navPrev: () => "nav:prev" as const, navToggleSidebar: () => "nav:toggleSidebar" as const, + navRightSidebarFocusTerminal: () => "nav:rightSidebar:focusTerminal" as const, + navRightSidebarSplitHorizontal: () => "nav:rightSidebar:splitHorizontal" as const, + navRightSidebarSplitVertical: () => "nav:rightSidebar:splitVertical" as const, + navRightSidebarAddTool: () => "nav:rightSidebar:addTool" as const, // Chat commands chatClear: () => "chat:clear" as const, diff --git a/src/browser/utils/commands/sources.test.ts b/src/browser/utils/commands/sources.test.ts index 78a5411f57..7a59684b48 100644 --- a/src/browser/utils/commands/sources.test.ts +++ b/src/browser/utils/commands/sources.test.ts @@ -73,8 +73,12 @@ test("buildCoreSources includes create/switch workspace actions", () => { const titles = actions.map((a) => a.title); expect(titles.some((t) => t.startsWith("Create New Workspace"))).toBe(true); expect(titles.some((t) => t.includes("Switch to "))).toBe(true); - expect(titles.includes("Open Current Workspace in Terminal")).toBe(true); - expect(titles.includes("Open Workspace in Terminal…")).toBe(true); + expect(titles.includes("Right Sidebar: Split Horizontally")).toBe(true); + expect(titles.includes("Right Sidebar: Split Vertically")).toBe(true); + expect(titles.includes("Right Sidebar: Add Tool…")).toBe(true); + expect(titles.includes("Right Sidebar: Focus Terminal")).toBe(true); + expect(titles.includes("New Terminal Window")).toBe(true); + expect(titles.includes("Open Terminal Window for Workspace…")).toBe(true); }); test("buildCoreSources adds thinking effort command", () => { diff --git a/src/browser/utils/commands/sources.ts b/src/browser/utils/commands/sources.ts index 80b7587e61..41b917ff05 100644 --- a/src/browser/utils/commands/sources.ts +++ b/src/browser/utils/commands/sources.ts @@ -4,7 +4,18 @@ import type { APIClient } from "@/browser/contexts/API"; import { formatKeybind, KEYBINDS } from "@/browser/utils/ui/keybinds"; import type { ThinkingLevel } from "@/common/types/thinking"; import { CUSTOM_EVENTS, createCustomEvent } from "@/common/constants/events"; +import { getRightSidebarLayoutKey, RIGHT_SIDEBAR_TAB_KEY } from "@/common/constants/storage"; +import { readPersistedState, updatePersistedState } from "@/browser/hooks/usePersistedState"; import { CommandIds } from "@/browser/utils/commandIds"; +import { isTabType, type TabType } from "@/browser/types/rightSidebar"; +import { + addToolToFocusedTabset, + getDefaultRightSidebarLayoutState, + parseRightSidebarLayoutState, + selectTabInTabset, + setFocusedTabset, + splitFocusedTabset, +} from "@/browser/utils/rightSidebarLayout"; import type { ProjectConfig } from "@/node/config"; import type { FrontendWorkspaceMetadata } from "@/common/types/workspace"; @@ -82,6 +93,39 @@ const section = { settings: COMMAND_SECTIONS.SETTINGS, }; +const getRightSidebarTabFallback = (): TabType => { + const raw = readPersistedState(RIGHT_SIDEBAR_TAB_KEY, "costs"); + return isTabType(raw) ? raw : "costs"; +}; + +const updateRightSidebarLayout = ( + workspaceId: string, + updater: ( + state: ReturnType + ) => ReturnType +) => { + const fallback = getRightSidebarTabFallback(); + const defaultLayout = getDefaultRightSidebarLayoutState(fallback); + + updatePersistedState>( + getRightSidebarLayoutKey(workspaceId), + (prev) => updater(parseRightSidebarLayoutState(prev, fallback)), + defaultLayout + ); +}; + +const findFirstTerminalSessionTab = ( + node: ReturnType["root"] +): { tabsetId: string; tab: TabType } | null => { + if (node.type === "tabset") { + const tab = node.tabs.find((t) => t.startsWith("terminal:") && t !== "terminal"); + return tab ? { tabsetId: node.id, tab } : null; + } + + return ( + findFirstTerminalSessionTab(node.children[0]) ?? findFirstTerminalSessionTab(node.children[1]) + ); +}; export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandAction[]> { const actions: Array<() => CommandAction[]> = []; @@ -139,10 +183,10 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi const selectedMeta = p.workspaceMetadata.get(selected.workspaceId); list.push({ id: CommandIds.workspaceOpenTerminalCurrent(), - title: "Open Current Workspace in Terminal", + title: "New Terminal Window", subtitle: workspaceDisplayName, section: section.workspaces, - shortcutHint: formatKeybind(KEYBINDS.OPEN_TERMINAL), + // Note: Cmd/Ctrl+T opens integrated terminal in sidebar (not shown here since this opens a popout) run: () => { p.onOpenWorkspaceInTerminal(selected.workspaceId, selectedMeta?.runtimeConfig); }, @@ -193,11 +237,11 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi if (p.workspaceMetadata.size > 0) { list.push({ id: CommandIds.workspaceOpenTerminal(), - title: "Open Workspace in Terminal…", + title: "Open Terminal Window for Workspace…", section: section.workspaces, run: () => undefined, prompt: { - title: "Open Workspace in Terminal", + title: "Open Terminal Window", fields: [ { type: "select", @@ -327,29 +371,111 @@ export function buildCoreSources(p: BuildSourcesParams): Array<() => CommandActi }); // Navigation / Interface - actions.push(() => [ - { - id: CommandIds.navNext(), - title: "Next Workspace", - section: section.navigation, - shortcutHint: formatKeybind(KEYBINDS.NEXT_WORKSPACE), - run: () => p.onNavigateWorkspace("next"), - }, - { - id: CommandIds.navPrev(), - title: "Previous Workspace", - section: section.navigation, - shortcutHint: formatKeybind(KEYBINDS.PREV_WORKSPACE), - run: () => p.onNavigateWorkspace("prev"), - }, - { - id: CommandIds.navToggleSidebar(), - title: "Toggle Sidebar", - section: section.navigation, - shortcutHint: formatKeybind(KEYBINDS.TOGGLE_SIDEBAR), - run: () => p.onToggleSidebar(), - }, - ]); + actions.push(() => { + const list: CommandAction[] = [ + { + id: CommandIds.navNext(), + title: "Next Workspace", + section: section.navigation, + shortcutHint: formatKeybind(KEYBINDS.NEXT_WORKSPACE), + run: () => p.onNavigateWorkspace("next"), + }, + { + id: CommandIds.navPrev(), + title: "Previous Workspace", + section: section.navigation, + shortcutHint: formatKeybind(KEYBINDS.PREV_WORKSPACE), + run: () => p.onNavigateWorkspace("prev"), + }, + { + id: CommandIds.navToggleSidebar(), + title: "Toggle Sidebar", + section: section.navigation, + shortcutHint: formatKeybind(KEYBINDS.TOGGLE_SIDEBAR), + run: () => p.onToggleSidebar(), + }, + ]; + + // Right sidebar layout commands require a selected workspace (layout is per-workspace) + const wsId = p.selectedWorkspace?.workspaceId; + if (wsId) { + list.push( + { + id: CommandIds.navRightSidebarFocusTerminal(), + title: "Right Sidebar: Focus Terminal", + section: section.navigation, + run: () => + updateRightSidebarLayout(wsId, (s) => { + const found = findFirstTerminalSessionTab(s.root); + if (!found) return s; + return selectTabInTabset( + setFocusedTabset(s, found.tabsetId), + found.tabsetId, + found.tab + ); + }), + }, + { + id: CommandIds.navRightSidebarSplitHorizontal(), + title: "Right Sidebar: Split Horizontally", + section: section.navigation, + run: () => updateRightSidebarLayout(wsId, (s) => splitFocusedTabset(s, "horizontal")), + }, + { + id: CommandIds.navRightSidebarSplitVertical(), + title: "Right Sidebar: Split Vertically", + section: section.navigation, + run: () => updateRightSidebarLayout(wsId, (s) => splitFocusedTabset(s, "vertical")), + }, + { + id: CommandIds.navRightSidebarAddTool(), + title: "Right Sidebar: Add Tool…", + section: section.navigation, + run: () => undefined, + prompt: { + title: "Add Right Sidebar Tool", + fields: [ + { + type: "select", + name: "tool", + label: "Tool", + placeholder: "Select a tool…", + getOptions: () => + (["costs", "review", "terminal"] as TabType[]).map((tab) => ({ + id: tab, + label: tab === "costs" ? "Costs" : tab === "review" ? "Review" : "Terminal", + keywords: [tab], + })), + }, + ], + onSubmit: (vals) => { + const tool = vals.tool; + if (!isTabType(tool)) return; + + // "terminal" is now an alias for "focus an existing terminal session tab". + // Creating new terminal sessions is handled in the main UI ("+" button). + if (tool === "terminal") { + updateRightSidebarLayout(wsId, (s) => { + const found = findFirstTerminalSessionTab(s.root); + if (!found) return s; + return selectTabInTabset( + setFocusedTabset(s, found.tabsetId), + found.tabsetId, + found.tab + ); + }); + return; + } + + updateRightSidebarLayout(wsId, (s) => addToolToFocusedTabset(s, tool)); + }, + }, + } + ); + } + + return list; + }); // Appearance actions.push(() => { diff --git a/src/browser/utils/rightSidebarLayout.test.ts b/src/browser/utils/rightSidebarLayout.test.ts new file mode 100644 index 0000000000..6619c762fd --- /dev/null +++ b/src/browser/utils/rightSidebarLayout.test.ts @@ -0,0 +1,244 @@ +import { expect, test } from "bun:test"; +import { + addToolToFocusedTabset, + closeSplit, + dockTabToEdge, + getDefaultRightSidebarLayoutState, + moveTabToTabset, + reorderTabInTabset, + selectTabInFocusedTabset, + splitFocusedTabset, + type RightSidebarLayoutState, +} from "./rightSidebarLayout"; + +test("selectTabInFocusedTabset adds missing tool and makes it active", () => { + let s = getDefaultRightSidebarLayoutState("costs"); + // Start with a layout that only has costs. + s = { + ...s, + root: { type: "tabset", id: "tabset-1", tabs: ["costs"], activeTab: "costs" }, + }; + + s = selectTabInFocusedTabset(s, "terminal"); + expect(s.root.type).toBe("tabset"); + if (s.root.type !== "tabset") throw new Error("expected tabset"); + expect(s.root.tabs).toEqual(["costs", "terminal"]); + expect(s.root.activeTab).toBe("terminal"); +}); + +test("splitFocusedTabset moves active tab when possible (no empty tabsets)", () => { + const s0 = getDefaultRightSidebarLayoutState("terminal"); + const s1 = splitFocusedTabset(s0, "horizontal"); + expect(s1.root.type).toBe("split"); + if (s1.root.type !== "split") throw new Error("expected split"); + expect(s1.root.children[0].type).toBe("tabset"); + expect(s1.root.children[1].type).toBe("tabset"); + + const left = s1.root.children[0]; + const right = s1.root.children[1]; + if (left.type !== "tabset" || right.type !== "tabset") throw new Error("expected tabsets"); + + expect(left.tabs.length).toBeGreaterThan(0); + expect(right.tabs.length).toBeGreaterThan(0); +}); + +test("splitFocusedTabset avoids empty by spawning a neighbor tool for 1-tab tabsets", () => { + let s = getDefaultRightSidebarLayoutState("costs"); + s = { + ...s, + root: { type: "tabset", id: "tabset-1", tabs: ["review"], activeTab: "review" }, + }; + + const s1 = splitFocusedTabset(s, "vertical"); + expect(s1.root.type).toBe("split"); + if (s1.root.type !== "split") throw new Error("expected split"); + + const left = s1.root.children[0]; + const right = s1.root.children[1]; + if (left.type !== "tabset" || right.type !== "tabset") throw new Error("expected tabsets"); + + expect(left.tabs).toEqual(["review"]); + expect(right.tabs.length).toBe(1); + expect(right.tabs[0]).not.toBe("review"); +}); + +test("addToolToFocusedTabset is an alias of selectTabInFocusedTabset", () => { + const s0 = getDefaultRightSidebarLayoutState("costs"); + const s1 = addToolToFocusedTabset(s0, "review"); + expect(JSON.stringify(s1)).toContain("review"); +}); + +test("moveTabToTabset moves tab between tabsets", () => { + // Create a split layout with two tabsets + const s0 = getDefaultRightSidebarLayoutState("costs"); + const s1 = splitFocusedTabset(s0, "horizontal"); + expect(s1.root.type).toBe("split"); + if (s1.root.type !== "split") throw new Error("expected split"); + + const left = s1.root.children[0]; + const right = s1.root.children[1]; + if (left.type !== "tabset" || right.type !== "tabset") throw new Error("expected tabsets"); + + // Move costs from left to right + const s2 = moveTabToTabset(s1, "costs", left.id, right.id); + expect(s2.root.type).toBe("split"); + if (s2.root.type !== "split") throw new Error("expected split"); + + const newLeft = s2.root.children[0]; + const newRight = s2.root.children[1]; + if (newLeft.type !== "tabset" || newRight.type !== "tabset") throw new Error("expected tabsets"); + + expect(newRight.tabs).toContain("costs"); + expect(newRight.activeTab).toBe("costs"); +}); + +test("moveTabToTabset removes empty source tabset", () => { + // Create a split where one tabset has only one tab + let s: RightSidebarLayoutState = { + version: 1, + nextId: 3, + focusedTabsetId: "tabset-1", + root: { + type: "split", + id: "split-1", + direction: "horizontal", + sizes: [50, 50], + children: [ + { type: "tabset", id: "tabset-1", tabs: ["costs"], activeTab: "costs" }, + { type: "tabset", id: "tabset-2", tabs: ["review", "terminal"], activeTab: "review" }, + ], + }, + }; + + // Move the only tab from tabset-1 to tabset-2 + s = moveTabToTabset(s, "costs", "tabset-1", "tabset-2"); + + // The split should be replaced by the remaining tabset + expect(s.root.type).toBe("tabset"); + if (s.root.type !== "tabset") throw new Error("expected tabset"); + expect(s.root.tabs).toContain("costs"); + expect(s.root.tabs).toContain("review"); + expect(s.root.tabs).toContain("terminal"); +}); + +test("reorderTabInTabset reorders tabs within a tabset", () => { + // Default layout has ["costs", "review"]; reorder costs from 0 to 1 + const s0 = getDefaultRightSidebarLayoutState("costs"); + const s1 = reorderTabInTabset(s0, "tabset-1", 0, 1); + + expect(s1.root.type).toBe("tabset"); + if (s1.root.type !== "tabset") throw new Error("expected tabset"); + + expect(s1.root.tabs).toEqual(["review", "costs"]); + expect(s1.root.activeTab).toBe("costs"); +}); + +test("dockTabToEdge splits a tabset and moves the dragged tab into the new pane", () => { + // Default layout has ["costs", "review"]; drag review into a bottom split + const s0 = getDefaultRightSidebarLayoutState("costs"); + + const s1 = dockTabToEdge(s0, "review", "tabset-1", "tabset-1", "bottom"); + + expect(s1.root.type).toBe("split"); + if (s1.root.type !== "split") throw new Error("expected split"); + + expect(s1.root.direction).toBe("horizontal"); + + const top = s1.root.children[0]; + const bottom = s1.root.children[1]; + if (top.type !== "tabset" || bottom.type !== "tabset") throw new Error("expected tabsets"); + + expect(bottom.tabs).toEqual(["review"]); + expect(bottom.activeTab).toBe("review"); + expect(top.tabs).not.toContain("review"); +}); + +test("dockTabToEdge avoids empty tabsets when dragging out the last tab", () => { + const s0: RightSidebarLayoutState = { + version: 1, + nextId: 2, + focusedTabsetId: "tabset-1", + root: { type: "tabset", id: "tabset-1", tabs: ["costs"], activeTab: "costs" }, + }; + + const s1 = dockTabToEdge(s0, "costs", "tabset-1", "tabset-1", "right"); + expect(s1.root.type).toBe("split"); + if (s1.root.type !== "split") throw new Error("expected split"); + + expect(s1.root.direction).toBe("vertical"); + + const left = s1.root.children[0]; + const right = s1.root.children[1]; + if (left.type !== "tabset" || right.type !== "tabset") throw new Error("expected tabsets"); + + // The dragged tab goes into the new right pane. + expect(right.tabs).toEqual(["costs"]); + + // The original pane gets a fallback tool instead of going empty. + expect(left.tabs.length).toBe(1); + expect(left.tabs[0]).not.toBe("costs"); +}); + +test("dockTabToEdge removes an empty source tabset when docking into another tabset", () => { + const s0: RightSidebarLayoutState = { + version: 1, + nextId: 3, + focusedTabsetId: "tabset-1", + root: { + type: "split", + id: "split-1", + direction: "horizontal", + sizes: [50, 50], + children: [ + { type: "tabset", id: "tabset-1", tabs: ["costs"], activeTab: "costs" }, + { type: "tabset", id: "tabset-2", tabs: ["review"], activeTab: "review" }, + ], + }, + }; + + // Dock the costs tab to the left edge of tabset-2. + const s1 = dockTabToEdge(s0, "costs", "tabset-1", "tabset-2", "left"); + + // The original source tabset should be removed and the root should now be the new split. + expect(s1.root.type).toBe("split"); + if (s1.root.type !== "split") throw new Error("expected split"); + + const left = s1.root.children[0]; + const right = s1.root.children[1]; + if (left.type !== "tabset" || right.type !== "tabset") throw new Error("expected tabsets"); + + expect(left.tabs).toEqual(["costs"]); + expect(right.tabs).toEqual(["review"]); +}); + +test("closeSplit keeps the specified child", () => { + const s: RightSidebarLayoutState = { + version: 1, + nextId: 3, + focusedTabsetId: "tabset-1", + root: { + type: "split", + id: "split-1", + direction: "horizontal", + sizes: [50, 50], + children: [ + { type: "tabset", id: "tabset-1", tabs: ["costs"], activeTab: "costs" }, + { type: "tabset", id: "tabset-2", tabs: ["review"], activeTab: "review" }, + ], + }, + }; + + // Close split, keeping the first child (left) + const s1 = closeSplit(s, "split-1", 0); + expect(s1.root.type).toBe("tabset"); + if (s1.root.type !== "tabset") throw new Error("expected tabset"); + expect(s1.root.id).toBe("tabset-1"); + expect(s1.root.tabs).toEqual(["costs"]); + + // Close split, keeping the second child (right) + const s2 = closeSplit(s, "split-1", 1); + expect(s2.root.type).toBe("tabset"); + if (s2.root.type !== "tabset") throw new Error("expected tabset"); + expect(s2.root.id).toBe("tabset-2"); + expect(s2.root.tabs).toEqual(["review"]); +}); diff --git a/src/browser/utils/rightSidebarLayout.ts b/src/browser/utils/rightSidebarLayout.ts new file mode 100644 index 0000000000..0f78d7c3ed --- /dev/null +++ b/src/browser/utils/rightSidebarLayout.ts @@ -0,0 +1,739 @@ +import { isTabType, type TabType } from "@/browser/types/rightSidebar"; + +export type RightSidebarLayoutNode = + | { + type: "split"; + id: string; + direction: "horizontal" | "vertical"; + sizes: [number, number]; + children: [RightSidebarLayoutNode, RightSidebarLayoutNode]; + } + | { + type: "tabset"; + id: string; + tabs: TabType[]; + activeTab: TabType; + }; + +function isLayoutNode(value: unknown): value is RightSidebarLayoutNode { + if (!value || typeof value !== "object") return false; + const v = value as Record; + + if (v.type === "tabset") { + return ( + typeof v.id === "string" && + Array.isArray(v.tabs) && + v.tabs.every((t) => isTabType(t)) && + isTabType(v.activeTab) + ); + } + + if (v.type === "split") { + if (typeof v.id !== "string") return false; + if (v.direction !== "horizontal" && v.direction !== "vertical") return false; + if (!Array.isArray(v.sizes) || v.sizes.length !== 2) return false; + if (typeof v.sizes[0] !== "number" || typeof v.sizes[1] !== "number") return false; + if (!Array.isArray(v.children) || v.children.length !== 2) return false; + return isLayoutNode(v.children[0]) && isLayoutNode(v.children[1]); + } + + return false; +} + +export function isRightSidebarLayoutState(value: unknown): value is RightSidebarLayoutState { + if (!value || typeof value !== "object") return false; + const v = value as Record; + if (v.version !== 1) return false; + if (typeof v.nextId !== "number") return false; + if (typeof v.focusedTabsetId !== "string") return false; + if (!isLayoutNode(v.root)) return false; + return findTabset(v.root, v.focusedTabsetId) !== null; +} +export interface RightSidebarLayoutState { + version: 1; + nextId: number; + focusedTabsetId: string; + root: RightSidebarLayoutNode; +} + +export function getDefaultRightSidebarLayoutState(activeTab: TabType): RightSidebarLayoutState { + // Default tabs exclude terminal - users add terminals via the "+" button + const baseTabs: TabType[] = ["costs", "review"]; + const tabs = baseTabs.includes(activeTab) ? baseTabs : [...baseTabs, activeTab]; + + return { + version: 1, + nextId: 2, + focusedTabsetId: "tabset-1", + root: { + type: "tabset", + id: "tabset-1", + tabs, + activeTab, + }, + }; +} + +export function parseRightSidebarLayoutState( + raw: unknown, + activeTabFallback: TabType +): RightSidebarLayoutState { + if (isRightSidebarLayoutState(raw)) { + return raw; + } + + return getDefaultRightSidebarLayoutState(activeTabFallback); +} + +export function findTabset( + root: RightSidebarLayoutNode, + tabsetId: string +): RightSidebarLayoutNode | null { + if (root.type === "tabset") { + return root.id === tabsetId ? root : null; + } + return findTabset(root.children[0], tabsetId) ?? findTabset(root.children[1], tabsetId); +} + +export function findFirstTabsetId(root: RightSidebarLayoutNode): string | null { + if (root.type === "tabset") return root.id; + return findFirstTabsetId(root.children[0]) ?? findFirstTabsetId(root.children[1]); +} + +function allocId(state: RightSidebarLayoutState, prefix: "tabset" | "split") { + const id = `${prefix}-${state.nextId}`; + return { id, nextId: state.nextId + 1 }; +} + +function removeTabFromNode( + node: RightSidebarLayoutNode, + tab: TabType +): RightSidebarLayoutNode | null { + if (node.type === "tabset") { + const oldIndex = node.tabs.indexOf(tab); + const tabs = node.tabs.filter((t) => t !== tab); + if (tabs.length === 0) return null; + + // When removing the active tab, focus next tab (or previous if no next) + let activeTab = node.activeTab; + if (node.activeTab === tab) { + // Prefer next tab, fall back to previous + activeTab = tabs[Math.min(oldIndex, tabs.length - 1)]; + } + return { + ...node, + tabs, + activeTab: tabs.includes(activeTab) ? activeTab : tabs[0], + }; + } + + const left = removeTabFromNode(node.children[0], tab); + const right = removeTabFromNode(node.children[1], tab); + + if (!left && !right) { + return null; + } + + // If one side goes empty, promote the other side to avoid empty panes. + if (!left) return right; + if (!right) return left; + + return { + ...node, + children: [left, right], + }; +} + +export function removeTabEverywhere( + state: RightSidebarLayoutState, + tab: TabType +): RightSidebarLayoutState { + const nextRoot = removeTabFromNode(state.root, tab); + if (!nextRoot) { + return getDefaultRightSidebarLayoutState("costs"); + } + + const focusedExists = findTabset(nextRoot, state.focusedTabsetId) !== null; + const focusedTabsetId = focusedExists + ? state.focusedTabsetId + : (findFirstTabsetId(nextRoot) ?? "tabset-1"); + + return { + ...state, + root: nextRoot, + focusedTabsetId, + }; +} +function updateNode( + node: RightSidebarLayoutNode, + tabsetId: string, + updater: (tabset: Extract) => RightSidebarLayoutNode +): RightSidebarLayoutNode { + if (node.type === "tabset") { + if (node.id !== tabsetId) return node; + return updater(node); + } + + return { + ...node, + children: [ + updateNode(node.children[0], tabsetId, updater), + updateNode(node.children[1], tabsetId, updater), + ], + }; +} + +export function setFocusedTabset( + state: RightSidebarLayoutState, + tabsetId: string +): RightSidebarLayoutState { + if (state.focusedTabsetId === tabsetId) return state; + return { ...state, focusedTabsetId: tabsetId }; +} + +export function selectTabInTabset( + state: RightSidebarLayoutState, + tabsetId: string, + tab: TabType +): RightSidebarLayoutState { + const target = findTabset(state.root, tabsetId); + if (target?.type !== "tabset") { + return state; + } + + if (target.activeTab === tab && target.tabs.includes(tab)) { + return state; + } + + return { + ...state, + root: updateNode(state.root, tabsetId, (ts) => { + const tabs = ts.tabs.includes(tab) ? ts.tabs : [...ts.tabs, tab]; + return { ...ts, tabs, activeTab: tab }; + }), + }; +} + +export function reorderTabInTabset( + state: RightSidebarLayoutState, + tabsetId: string, + fromIndex: number, + toIndex: number +): RightSidebarLayoutState { + const tabset = findTabset(state.root, tabsetId); + if (tabset?.type !== "tabset") { + return state; + } + + if ( + fromIndex === toIndex || + fromIndex < 0 || + toIndex < 0 || + fromIndex >= tabset.tabs.length || + toIndex >= tabset.tabs.length + ) { + return state; + } + + return { + ...state, + root: updateNode(state.root, tabsetId, (node) => { + const nextTabs = [...node.tabs]; + const [moved] = nextTabs.splice(fromIndex, 1); + if (!moved) { + return node; + } + + nextTabs.splice(toIndex, 0, moved); + return { + ...node, + tabs: nextTabs, + }; + }), + }; +} + +export function selectTabInFocusedTabset( + state: RightSidebarLayoutState, + tab: TabType +): RightSidebarLayoutState { + const focused = findTabset(state.root, state.focusedTabsetId); + if (focused?.type !== "tabset") { + return state; + } + + if (focused.activeTab === tab && focused.tabs.includes(tab)) { + return state; + } + + return { + ...state, + root: updateNode(state.root, focused.id, (ts) => { + const tabs = ts.tabs.includes(tab) ? ts.tabs : [...ts.tabs, tab]; + return { ...ts, tabs, activeTab: tab }; + }), + }; +} + +export function splitFocusedTabset( + state: RightSidebarLayoutState, + direction: "horizontal" | "vertical" +): RightSidebarLayoutState { + const focused = findTabset(state.root, state.focusedTabsetId); + if (focused?.type !== "tabset") { + return state; + } + + const splitAlloc = allocId(state, "split"); + const tabsetAlloc = allocId({ ...state, nextId: splitAlloc.nextId }, "tabset"); + + const fallbackTab: TabType = + focused.activeTab === "terminal" + ? "costs" + : focused.activeTab === "costs" + ? "terminal" + : "terminal"; + + let left: Extract = focused; + let right: Extract; + const newFocusedId = tabsetAlloc.id; + + if (focused.tabs.length > 1) { + const moved = focused.activeTab; + const remaining = focused.tabs.filter((t) => t !== moved); + const oldActive = remaining[0] ?? "costs"; + + left = { + ...focused, + tabs: remaining, + activeTab: oldActive, + }; + + right = { + type: "tabset", + id: tabsetAlloc.id, + tabs: [moved], + activeTab: moved, + }; + } else { + // Avoid empty tabsets: keep the current tabset intact and spawn a useful default neighbor. + right = { + type: "tabset", + id: tabsetAlloc.id, + tabs: [fallbackTab], + activeTab: fallbackTab, + }; + } + + const splitNode: RightSidebarLayoutNode = { + type: "split", + id: splitAlloc.id, + direction, + sizes: [50, 50], + children: [left, right], + }; + + // Replace the focused tabset node in-place. + const replaceFocused = (node: RightSidebarLayoutNode): RightSidebarLayoutNode => { + if (node.type === "tabset") { + return node.id === focused.id ? splitNode : node; + } + + return { + ...node, + children: [replaceFocused(node.children[0]), replaceFocused(node.children[1])], + }; + }; + + return { + ...state, + nextId: tabsetAlloc.nextId, + focusedTabsetId: newFocusedId, + root: replaceFocused(state.root), + }; +} + +export function updateSplitSizes( + state: RightSidebarLayoutState, + splitId: string, + sizes: [number, number] +): RightSidebarLayoutState { + const update = (node: RightSidebarLayoutNode): RightSidebarLayoutNode => { + if (node.type === "split") { + if (node.id === splitId) { + return { ...node, sizes }; + } + return { + ...node, + children: [update(node.children[0]), update(node.children[1])], + }; + } + return node; + }; + + return { + ...state, + root: update(state.root), + }; +} + +export function collectAllTabs(node: RightSidebarLayoutNode): TabType[] { + if (node.type === "tabset") return [...node.tabs]; + return [...collectAllTabs(node.children[0]), ...collectAllTabs(node.children[1])]; +} +export function collectActiveTabs(node: RightSidebarLayoutNode): TabType[] { + if (node.type === "tabset") return [node.activeTab]; + return [...collectActiveTabs(node.children[0]), ...collectActiveTabs(node.children[1])]; +} + +/** + * Collect all tabs from all tabsets with their tabset IDs. + * Returns tabs in layout order (depth-first, left-to-right/top-to-bottom). + */ +export function collectAllTabsWithTabset( + node: RightSidebarLayoutNode +): Array<{ tab: TabType; tabsetId: string }> { + if (node.type === "tabset") { + return node.tabs.map((tab) => ({ tab, tabsetId: node.id })); + } + return [ + ...collectAllTabsWithTabset(node.children[0]), + ...collectAllTabsWithTabset(node.children[1]), + ]; +} + +/** + * Select a tab by its position in the layout (0-indexed). + * Returns the updated state, or the original state if index is out of bounds. + */ +export function selectTabByIndex( + state: RightSidebarLayoutState, + index: number +): RightSidebarLayoutState { + const allTabs = collectAllTabsWithTabset(state.root); + if (index < 0 || index >= allTabs.length) { + return state; + } + const { tab, tabsetId } = allTabs[index]; + return selectTabInTabset(setFocusedTabset(state, tabsetId), tabsetId, tab); +} + +export function getFocusedActiveTab(state: RightSidebarLayoutState, fallback: TabType): TabType { + const focused = findTabset(state.root, state.focusedTabsetId); + if (focused?.type === "tabset") return focused.activeTab; + return fallback; +} +export function addToolToFocusedTabset( + state: RightSidebarLayoutState, + tab: TabType +): RightSidebarLayoutState { + return selectTabInFocusedTabset(state, tab); +} + +/** + * Add a tab to the focused tabset without changing the active tab. + * Used for feature-flagged tabs that should be available but not auto-selected. + */ +export function addTabToFocusedTabset( + state: RightSidebarLayoutState, + tab: TabType, + /** Whether to make the new tab active (default: true) */ + activate = true +): RightSidebarLayoutState { + const focused = findTabset(state.root, state.focusedTabsetId); + if (focused?.type !== "tabset") { + return state; + } + + // Already has the tab - just activate if requested + if (focused.tabs.includes(tab)) { + if (activate && focused.activeTab !== tab) { + return { + ...state, + root: updateNode(state.root, focused.id, (ts) => ({ + ...ts, + activeTab: tab, + })), + }; + } + return state; + } + + return { + ...state, + root: updateNode(state.root, focused.id, (ts) => ({ + ...ts, + tabs: [...ts.tabs, tab], + activeTab: activate ? tab : ts.activeTab, + })), + }; +} + +/** + * Move a tab from one tabset to another. + * Handles edge cases: + * - If source tabset becomes empty, it gets removed (along with its parent split if needed) + * - If target tabset already has the tab, just activates it + * + * @returns Updated layout state, or original state if move is invalid + */ +export function moveTabToTabset( + state: RightSidebarLayoutState, + tab: TabType, + sourceTabsetId: string, + targetTabsetId: string +): RightSidebarLayoutState { + // No-op if moving to same tabset + if (sourceTabsetId === targetTabsetId) { + return selectTabInTabset(state, targetTabsetId, tab); + } + + const source = findTabset(state.root, sourceTabsetId); + const target = findTabset(state.root, targetTabsetId); + + if (source?.type !== "tabset" || target?.type !== "tabset") { + return state; + } + + // Check if tab exists in source + if (!source.tabs.includes(tab)) { + return state; + } + + // Update the tree: remove from source, add to target + const updateNode = (node: RightSidebarLayoutNode): RightSidebarLayoutNode | null => { + if (node.type === "tabset") { + if (node.id === sourceTabsetId) { + // Remove tab from source + const newTabs = node.tabs.filter((t) => t !== tab); + if (newTabs.length === 0) { + // Tabset is now empty, signal for removal + return null; + } + const newActiveTab = node.activeTab === tab ? newTabs[0] : node.activeTab; + return { ...node, tabs: newTabs, activeTab: newActiveTab }; + } + if (node.id === targetTabsetId) { + // Add tab to target (avoid duplicates) + const newTabs = target.tabs.includes(tab) ? target.tabs : [...target.tabs, tab]; + return { ...node, tabs: newTabs, activeTab: tab }; + } + return node; + } + + // Split node: recursively update children + const left = updateNode(node.children[0]); + const right = updateNode(node.children[1]); + + // Handle case where one child was removed (became null) + if (left === null && right === null) { + // Both children empty (shouldn't happen with valid moves) + return null; + } + if (left === null) { + // Left child removed, promote right + return right; + } + if (right === null) { + // Right child removed, promote left + return left; + } + + return { + ...node, + children: [left, right], + }; + }; + + const newRoot = updateNode(state.root); + if (newRoot === null) { + // Entire tree collapsed (shouldn't happen) + return state; + } + + // Ensure focusedTabsetId is still valid + let newFocusedId: string = targetTabsetId; + if (findTabset(newRoot, newFocusedId) === null) { + newFocusedId = findFirstTabsetId(newRoot) ?? targetTabsetId; + } + + return { + ...state, + focusedTabsetId: newFocusedId, + root: newRoot, + }; +} + +export type TabDockEdge = "left" | "right" | "top" | "bottom"; + +function getFallbackTabForEmptyTabset(movedTab: TabType): TabType { + return movedTab === "terminal" ? "costs" : movedTab === "costs" ? "terminal" : "terminal"; +} + +/** + * Create a new split adjacent to a target tabset and dock a dragged tab into it. + * + * This is the "edge drop" behavior for drag+dock: + * - drop Left/Right => vertical split + * - drop Top/Bottom => horizontal split + * + * Also handles: + * - dragging a tab out of its own tabset (source === target) + * - removing empty source tabsets (collapsing parent splits) + * - avoiding empty tabsets when a user drags out the last remaining tab + */ +export function dockTabToEdge( + state: RightSidebarLayoutState, + tab: TabType, + sourceTabsetId: string, + targetTabsetId: string, + edge: TabDockEdge +): RightSidebarLayoutState { + const source = findTabset(state.root, sourceTabsetId); + const target = findTabset(state.root, targetTabsetId); + + if (source?.type !== "tabset" || target?.type !== "tabset") { + return state; + } + + if (!source.tabs.includes(tab)) { + return state; + } + + const splitDirection: "horizontal" | "vertical" = + edge === "top" || edge === "bottom" ? "horizontal" : "vertical"; + const insertBefore = edge === "top" || edge === "left"; + + const splitAlloc = allocId(state, "split"); + const tabsetAlloc = allocId({ ...state, nextId: splitAlloc.nextId }, "tabset"); + + const newTabset: Extract = { + type: "tabset", + id: tabsetAlloc.id, + tabs: [tab], + activeTab: tab, + }; + + const updateNode = (node: RightSidebarLayoutNode): RightSidebarLayoutNode | null => { + if (node.type === "tabset") { + if (node.id === targetTabsetId) { + let updatedTarget = node; + + // When dragging out of this tabset, remove the tab before splitting. + if (sourceTabsetId === targetTabsetId) { + const remaining = node.tabs.filter((t) => t !== tab); + const fallbackTab = getFallbackTabForEmptyTabset(tab); + const nextTabs = remaining.length > 0 ? remaining : [fallbackTab]; + const nextActiveTab = + node.activeTab === tab || !nextTabs.includes(node.activeTab) + ? nextTabs[0] + : node.activeTab; + updatedTarget = { ...node, tabs: nextTabs, activeTab: nextActiveTab }; + } + + const children: [RightSidebarLayoutNode, RightSidebarLayoutNode] = insertBefore + ? [newTabset, updatedTarget] + : [updatedTarget, newTabset]; + + return { + type: "split", + id: splitAlloc.id, + direction: splitDirection, + sizes: [50, 50], + children, + }; + } + + if (node.id === sourceTabsetId) { + // Remove from source (unless source === target, handled above). + if (sourceTabsetId === targetTabsetId) { + return node; + } + + const remaining = node.tabs.filter((t) => t !== tab); + if (remaining.length === 0) { + return null; + } + + const nextActiveTab = node.activeTab === tab ? remaining[0] : node.activeTab; + return { ...node, tabs: remaining, activeTab: nextActiveTab }; + } + + return node; + } + + const left = updateNode(node.children[0]); + const right = updateNode(node.children[1]); + + if (left === null && right === null) { + return null; + } + if (left === null) { + return right; + } + if (right === null) { + return left; + } + + return { + ...node, + children: [left, right], + }; + }; + + const newRoot = updateNode(state.root); + if (newRoot === null) { + return state; + } + + const newFocusedId = tabsetAlloc.id; + + return { + ...state, + nextId: tabsetAlloc.nextId, + focusedTabsetId: findTabset(newRoot, newFocusedId) ? newFocusedId : state.focusedTabsetId, + root: newRoot, + }; +} + +/** + * Close (remove) a split, keeping one of its children. + * Called when user wants to close a pane. + * + * @param keepChildIndex Which child to keep (0 = first/left/top, 1 = second/right/bottom) + */ +export function closeSplit( + state: RightSidebarLayoutState, + splitId: string, + keepChildIndex: 0 | 1 +): RightSidebarLayoutState { + const replaceNode = (node: RightSidebarLayoutNode): RightSidebarLayoutNode => { + if (node.type === "tabset") { + return node; + } + + if (node.id === splitId) { + // Replace this split with the kept child + return node.children[keepChildIndex]; + } + + return { + ...node, + children: [replaceNode(node.children[0]), replaceNode(node.children[1])], + }; + }; + + const newRoot = replaceNode(state.root); + + // Ensure focusedTabsetId is still valid + let newFocusedId: string = state.focusedTabsetId; + if (findTabset(newRoot, newFocusedId) === null) { + newFocusedId = findFirstTabsetId(newRoot) ?? state.focusedTabsetId; + } + + return { + ...state, + focusedTabsetId: newFocusedId, + root: newRoot, + }; +} diff --git a/src/browser/utils/terminal.ts b/src/browser/utils/terminal.ts new file mode 100644 index 0000000000..6bbac26fd2 --- /dev/null +++ b/src/browser/utils/terminal.ts @@ -0,0 +1,62 @@ +/** + * Terminal utilities for managing terminal sessions and windows. + * + * Consolidates common terminal operations used across: + * - useOpenTerminal hook (new pop-out terminals) + * - RightSidebar (integrated terminals, pop-out existing) + */ + +import type { RouterClient } from "@orpc/server"; +import type { AppRouter } from "@/node/orpc/router"; + +type APIClient = RouterClient; + +/** Default terminal size used when creating sessions before the terminal is mounted */ +export const DEFAULT_TERMINAL_SIZE = { cols: 80, rows: 24 }; + +/** + * Open a terminal in a pop-out window. + * + * Handles both browser mode (window.open) and Electron mode (terminal.openWindow). + * In browser mode, opens the window client-side since the backend can't open windows. + * In Electron mode, the backend opens a BrowserWindow. + * + * @param api - The API client + * @param workspaceId - Workspace ID + * @param sessionId - Terminal session ID (required) + */ +export function openTerminalPopout(api: APIClient, workspaceId: string, sessionId: string): void { + const isBrowser = !window.api; + + if (isBrowser) { + // In browser mode, we must open the window client-side + // The backend cannot open a window on the user's client + const params = new URLSearchParams({ workspaceId, sessionId }); + window.open( + `/terminal.html?${params.toString()}`, + `terminal-${workspaceId}-${Date.now()}`, + "width=1000,height=600,popup=yes" + ); + } + + // Open via backend (Electron pops up BrowserWindow, browser already opened above) + void api.terminal.openWindow({ workspaceId, sessionId }); +} + +/** + * Create a new terminal session with default size. + * + * @param api - The API client + * @param workspaceId - Workspace ID + * @returns The created session with sessionId + */ +export async function createTerminalSession( + api: APIClient, + workspaceId: string +): Promise<{ sessionId: string; workspaceId: string; cols: number; rows: number }> { + return api.terminal.create({ + workspaceId, + cols: DEFAULT_TERMINAL_SIZE.cols, + rows: DEFAULT_TERMINAL_SIZE.rows, + }); +} diff --git a/src/browser/utils/ui/keybinds.ts b/src/browser/utils/ui/keybinds.ts index 16e85e4f12..ae1b105335 100644 --- a/src/browser/utils/ui/keybinds.ts +++ b/src/browser/utils/ui/keybinds.ts @@ -243,7 +243,7 @@ export const KEYBINDS = { /** Cycle through configured models */ CYCLE_MODEL: { key: "/", ctrl: true }, - /** Open workspace in terminal */ + /** Open new integrated terminal tab in sidebar */ // macOS: Cmd+T, Win/Linux: Ctrl+T OPEN_TERMINAL: { key: "T", ctrl: true }, @@ -266,19 +266,22 @@ export const KEYBINDS = { // macOS: Cmd+I, Win/Linux: Ctrl+I FOCUS_CHAT: { key: "I", ctrl: true }, - /** Switch to Costs tab in right sidebar */ - // macOS: Cmd+1, Win/Linux: Ctrl+1 - COSTS_TAB: { key: "1", ctrl: true, description: "Costs tab" }, + /** Close current tab in right sidebar (if closeable - currently only terminal tabs) */ + // macOS: Cmd+W (matches Ghostty), Win/Linux: Ctrl+W + CLOSE_TAB: { key: "w", ctrl: true, macCtrlBehavior: "command" }, - /** Switch to Review tab in right sidebar */ - // macOS: Cmd+2, Win/Linux: Ctrl+2 + /** Switch to tab by position in right sidebar (1-9) */ + // macOS: Cmd+N, Win/Linux: Ctrl+N // NOTE: Both Ctrl and Cmd work for switching tabs on Mac (macOS has no standard Cmd+number behavior) - // This differs from other keybinds where we distinguish Ctrl (literal) from Cmd (meta) - REVIEW_TAB: { key: "2", ctrl: true, description: "Review tab" }, - - /** Switch to Stats tab in right sidebar */ - // macOS: Cmd+3, Win/Linux: Ctrl+3 - STATS_TAB: { key: "3", ctrl: true, description: "Stats tab" }, + SIDEBAR_TAB_1: { key: "1", ctrl: true, description: "Tab 1" }, + SIDEBAR_TAB_2: { key: "2", ctrl: true, description: "Tab 2" }, + SIDEBAR_TAB_3: { key: "3", ctrl: true, description: "Tab 3" }, + SIDEBAR_TAB_4: { key: "4", ctrl: true, description: "Tab 4" }, + SIDEBAR_TAB_5: { key: "5", ctrl: true, description: "Tab 5" }, + SIDEBAR_TAB_6: { key: "6", ctrl: true, description: "Tab 6" }, + SIDEBAR_TAB_7: { key: "7", ctrl: true, description: "Tab 7" }, + SIDEBAR_TAB_8: { key: "8", ctrl: true, description: "Tab 8" }, + SIDEBAR_TAB_9: { key: "9", ctrl: true, description: "Tab 9" }, /** Refresh diff in Code Review panel */ // macOS: Cmd+R, Win/Linux: Ctrl+R diff --git a/src/cli/proxifyOrpc.test.ts b/src/cli/proxifyOrpc.test.ts index b23a29989e..83aa218c49 100644 --- a/src/cli/proxifyOrpc.test.ts +++ b/src/cli/proxifyOrpc.test.ts @@ -165,13 +165,17 @@ describe("proxifyOrpc CLI help output", () => { const r = router(); const proxied = proxifyOrpc(r, { baseUrl: "http://localhost:8080" }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - const resumeStream = (proxied as any).workspace?.resumeStream; - // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access - const inputSchema = resumeStream?.["~orpc"]?.inputSchema; + // eslint-disable-next-line @typescript-eslint/no-explicit-any, @typescript-eslint/no-unsafe-member-access + const resumeStream = (proxied as any).workspace?.resumeStream as unknown; + + const inputSchema = ( + resumeStream as { ["~orpc"]?: { inputSchema?: z.ZodTypeAny } } | undefined + )?.["~orpc"]?.inputSchema; + + expect(inputSchema).toBeDefined(); + if (!inputSchema) throw new Error("Expected input schema"); // Convert to JSON Schema (what trpc-cli does) - // eslint-disable-next-line @typescript-eslint/no-unsafe-argument const jsonSchema = zod4Core.toJSONSchema(inputSchema, { io: "input", unrepresentable: "any", diff --git a/src/common/constants/storage.ts b/src/common/constants/storage.ts index 4d1c4ed93b..4f7e4bea0c 100644 --- a/src/common/constants/storage.ts +++ b/src/common/constants/storage.ts @@ -347,17 +347,29 @@ export const RIGHT_SIDEBAR_TAB_KEY = "right-sidebar-tab"; export const RIGHT_SIDEBAR_COLLAPSED_KEY = "right-sidebar:collapsed"; /** - * Right sidebar width for Costs tab (global) - * Format: "right-sidebar:width:costs" + * Right sidebar width (unified across all tabs) + * Format: "right-sidebar:width" */ -export const RIGHT_SIDEBAR_COSTS_WIDTH_KEY = "right-sidebar:width:costs"; +export const RIGHT_SIDEBAR_WIDTH_KEY = "right-sidebar:width"; /** - * Right sidebar width for Review tab (global) - * Reuses legacy key to preserve existing user preferences - * Format: "review-sidebar-width" + * Get the localStorage key for right sidebar dock-lite layout per workspace. + * Each workspace can have its own split/tab configuration (e.g., different + * numbers of terminals). Width and collapsed state remain global. + * Format: "right-sidebar:layout:{workspaceId}" */ -export const RIGHT_SIDEBAR_REVIEW_WIDTH_KEY = "review-sidebar-width"; +export function getRightSidebarLayoutKey(workspaceId: string): string { + return `right-sidebar:layout:${workspaceId}`; +} + +/** + * Get the localStorage key for terminal titles per workspace. + * Maps sessionId -> title for persisting OSC-set terminal titles. + * Format: "right-sidebar:terminal-titles:{workspaceId}" + */ +export function getTerminalTitlesKey(workspaceId: string): string { + return `right-sidebar:terminal-titles:${workspaceId}`; +} /** * Get the localStorage key for unified Review search state per workspace diff --git a/src/common/orpc/schemas/api.ts b/src/common/orpc/schemas/api.ts index 0b9fec7de5..ab5bf974d8 100644 --- a/src/common/orpc/schemas/api.ts +++ b/src/common/orpc/schemas/api.ts @@ -722,18 +722,46 @@ export const terminal = { input: z.object({ sessionId: z.string() }), output: eventIterator(z.string()), }, + /** + * Attach to a terminal session with race-free state restore. + * First yields { type: "screenState", data: string } with serialized screen (~4KB), + * then yields { type: "output", data: string } for each live output chunk. + * Guarantees no missed output between state snapshot and live stream. + */ + attach: { + input: z.object({ sessionId: z.string() }), + output: eventIterator( + z.discriminatedUnion("type", [ + z.object({ type: z.literal("screenState"), data: z.string() }), + z.object({ type: z.literal("output"), data: z.string() }), + ]) + ), + }, + onExit: { input: z.object({ sessionId: z.string() }), output: eventIterator(z.number()), }, openWindow: { - input: z.object({ workspaceId: z.string() }), + input: z.object({ + workspaceId: z.string(), + /** Optional session ID to reattach to an existing terminal session (for pop-out handoff) */ + sessionId: z.string().optional(), + }), output: z.void(), }, closeWindow: { input: z.object({ workspaceId: z.string() }), output: z.void(), }, + /** + * List active terminal sessions for a workspace. + * Used by frontend to discover existing sessions to reattach to after reload. + */ + listSessions: { + input: z.object({ workspaceId: z.string() }), + output: z.array(z.string()), + }, /** * Open the native system terminal for a workspace. * Opens the user's preferred terminal emulator (Ghostty, Terminal.app, etc.) diff --git a/src/desktop/main.ts b/src/desktop/main.ts index 4be37d1131..a19b0a44b9 100644 --- a/src/desktop/main.ts +++ b/src/desktop/main.ts @@ -91,15 +91,19 @@ if (process.env.MUX_DEBUG_START_TIME === "1") { } // Global error handlers for better error reporting -process.on("uncaughtException", (error) => { +process.on("uncaughtException", (error: unknown) => { console.error("Uncaught Exception:", error); - console.error("Stack:", error.stack); + + const message = error instanceof Error ? error.message : String(error); + const stack = error instanceof Error ? error.stack : undefined; + + console.error("Stack:", stack); // Show error dialog in production if (app.isPackaged) { dialog.showErrorBox( "Application Error", - `An unexpected error occurred:\n\n${error.message}\n\nStack trace:\n${error.stack ?? "No stack trace available"}` + `An unexpected error occurred:\n\n${message}\n\nStack trace:\n${stack ?? "No stack trace available"}` ); } }); diff --git a/src/desktop/terminalWindowManager.ts b/src/desktop/terminalWindowManager.ts index 1b581cc4a3..d997f64d76 100644 --- a/src/desktop/terminalWindowManager.ts +++ b/src/desktop/terminalWindowManager.ts @@ -22,8 +22,9 @@ export class TerminalWindowManager { /** * Open a new terminal window for a workspace * Multiple windows can be open for the same workspace + * @param sessionId Optional session ID to reattach to (for pop-out handoff from embedded terminal) */ - async openTerminalWindow(workspaceId: string): Promise { + async openTerminalWindow(workspaceId: string, sessionId?: string): Promise { this.windowCount++; const windowId = this.windowCount; @@ -75,16 +76,21 @@ export class TerminalWindowManager { const forceDistLoad = process.env.MUX_E2E_LOAD_DIST === "1"; const useDevServer = !app.isPackaged && !forceDistLoad; + // Build query params including optional sessionId for session handoff + const queryParams: Record = { workspaceId }; + if (sessionId) { + queryParams.sessionId = sessionId; + } + if (useDevServer) { // Development mode - load from Vite dev server - await terminalWindow.loadURL( - `http://localhost:5173/terminal.html?workspaceId=${encodeURIComponent(workspaceId)}` - ); + const params = new URLSearchParams(queryParams); + await terminalWindow.loadURL(`http://localhost:5173/terminal.html?${params.toString()}`); terminalWindow.webContents.openDevTools(); } else { // Production mode (or E2E dist mode) - load from built files await terminalWindow.loadFile(path.join(__dirname, "../terminal.html"), { - query: { workspaceId }, + query: queryParams, }); } diff --git a/src/node/config.ts b/src/node/config.ts index 806501c2ec..4f8feb5c5c 100644 --- a/src/node/config.ts +++ b/src/node/config.ts @@ -131,9 +131,18 @@ export class Config { const rawPairs = parsed.projects as Array<[string, ProjectConfig]>; // Migrate: normalize project paths by stripping trailing slashes // This fixes configs created with paths like "/home/user/project/" - const normalizedPairs = rawPairs.map(([projectPath, projectConfig]) => { - return [stripTrailingSlashes(projectPath), projectConfig] as [string, ProjectConfig]; - }); + // Also filter out any malformed entries (null/undefined paths) + const normalizedPairs = rawPairs + .filter(([projectPath]) => { + if (!projectPath || typeof projectPath !== "string") { + log.warn("Filtering out project with invalid path", { projectPath }); + return false; + } + return true; + }) + .map(([projectPath, projectConfig]) => { + return [stripTrailingSlashes(projectPath), projectConfig] as [string, ProjectConfig]; + }); const projectsMap = new Map(normalizedPairs); const taskSettings = normalizeTaskSettings(parsed.taskSettings); @@ -504,6 +513,14 @@ export class Config { let configModified = false; for (const [projectPath, projectConfig] of config.projects) { + // Validate project path is not empty (defensive check for corrupted config) + if (!projectPath) { + log.warn("Skipping project with empty path in config", { + workspaceCount: projectConfig.workspaces?.length ?? 0, + }); + continue; + } + const projectName = this.getProjectName(projectPath); for (const workspace of projectConfig.workspaces) { diff --git a/src/node/orpc/router.ts b/src/node/orpc/router.ts index 979c2a3b14..b3b1dcf23e 100644 --- a/src/node/orpc/router.ts +++ b/src/node/orpc/router.ts @@ -1669,6 +1669,58 @@ export const router = (authToken?: string) => { unsubscribe(); } }), + attach: t + .input(schemas.terminal.attach.input) + .output(schemas.terminal.attach.output) + .handler(async function* ({ context, input }) { + type AttachMessage = + | { type: "screenState"; data: string } + | { type: "output"; data: string }; + + let resolveNext: ((value: AttachMessage) => void) | null = null; + const queue: AttachMessage[] = []; + let ended = false; + + const push = (msg: AttachMessage) => { + if (ended) return; + if (resolveNext) { + const resolve = resolveNext; + resolveNext = null; + resolve(msg); + } else { + queue.push(msg); + } + }; + + // CRITICAL: Subscribe to output FIRST, BEFORE capturing screen state. + // This ensures any output that arrives during/after getScreenState() is queued. + const unsubscribe = context.terminalService.onOutput(input.sessionId, (data) => { + push({ type: "output", data }); + }); + + try { + // Capture screen state AFTER subscription is set up - guarantees no missed output + const screenState = context.terminalService.getScreenState(input.sessionId); + + // First message is always the screen state (may be empty for new sessions) + yield { type: "screenState" as const, data: screenState }; + + // Now yield any queued output and continue with live stream + while (!ended) { + if (queue.length > 0) { + yield queue.shift()!; + } else { + const msg = await new Promise((resolve) => { + resolveNext = resolve; + }); + yield msg; + } + } + } finally { + ended = true; + unsubscribe(); + } + }), onExit: t .input(schemas.terminal.onExit.input) .output(schemas.terminal.onExit.output) @@ -1713,7 +1765,7 @@ export const router = (authToken?: string) => { .input(schemas.terminal.openWindow.input) .output(schemas.terminal.openWindow.output) .handler(async ({ context, input }) => { - return context.terminalService.openWindow(input.workspaceId); + return context.terminalService.openWindow(input.workspaceId, input.sessionId); }), closeWindow: t .input(schemas.terminal.closeWindow.input) @@ -1721,6 +1773,12 @@ export const router = (authToken?: string) => { .handler(({ context, input }) => { return context.terminalService.closeWindow(input.workspaceId); }), + listSessions: t + .input(schemas.terminal.listSessions.input) + .output(schemas.terminal.listSessions.output) + .handler(({ context, input }) => { + return context.terminalService.getWorkspaceSessionIds(input.workspaceId); + }), openNative: t .input(schemas.terminal.openNative.input) .output(schemas.terminal.openNative.output) diff --git a/src/node/services/mcpServerManager.ts b/src/node/services/mcpServerManager.ts index 714e87b2f6..7917018035 100644 --- a/src/node/services/mcpServerManager.ts +++ b/src/node/services/mcpServerManager.ts @@ -739,7 +739,7 @@ export class MCPServerManager { await transport.start(); const client = await experimental_createMCPClient({ transport }); const rawTools = await client.tools(); - const tools = wrapMCPTools(rawTools); + const tools = wrapMCPTools(rawTools as unknown as Record); log.info("[MCP] Server ready", { name, @@ -816,7 +816,7 @@ export class MCPServerManager { } const rawTools = await client.tools(); - const tools = wrapMCPTools(rawTools); + const tools = wrapMCPTools(rawTools as unknown as Record); log.info("[MCP] Server ready", { name, diff --git a/src/node/services/ptyService.ts b/src/node/services/ptyService.ts index 2450366553..efc8f38708 100644 --- a/src/node/services/ptyService.ts +++ b/src/node/services/ptyService.ts @@ -5,6 +5,8 @@ * Uses callbacks for output/exit events to avoid circular dependencies. */ +import { randomUUID } from "crypto"; + import { log } from "@/node/services/log"; import type { Runtime } from "@/node/runtime/Runtime"; import type { @@ -159,10 +161,11 @@ export class PTYService { onData: (data: string) => void, onExit: (exitCode: number) => void ): Promise { - const sessionId = `${params.workspaceId}-${Date.now()}`; + // Include a random suffix to avoid collisions when creating multiple sessions quickly. + // Collisions can cause two PTYs to appear "merged" under one sessionId. + const sessionId = `${params.workspaceId}-${Date.now()}-${randomUUID().slice(0, 8)}`; const runtimeType = runtime instanceof SSHRuntime ? "SSH" : runtime instanceof DockerRuntime ? "Docker" : "Local"; - log.info( `Creating terminal session ${sessionId} for workspace ${params.workspaceId} (${runtimeType})` ); @@ -316,6 +319,16 @@ export class PTYService { this.sessions.delete(sessionId); } + /** + * Get all session IDs for a workspace. + * Used by frontend to discover existing sessions to reattach to after reload. + */ + getWorkspaceSessionIds(workspaceId: string): string[] { + return Array.from(this.sessions.entries()) + .filter(([, session]) => session.workspaceId === workspaceId) + .map(([id]) => id); + } + /** * Close all terminal sessions for a workspace */ diff --git a/src/node/services/serverService.test.ts b/src/node/services/serverService.test.ts index e5630e3432..ba7b830cfd 100644 --- a/src/node/services/serverService.test.ts +++ b/src/node/services/serverService.test.ts @@ -79,11 +79,6 @@ describe("ServerService.startServer", () => { } test("cleans up server when lockfile acquisition fails", async () => { - // Skip on Windows where chmod doesn't work the same way - if (process.platform === "win32") { - return; - } - const service = new ServerService(); // Make muxHome a *file* (not a directory) so lockfile.acquire() fails reliably, @@ -91,7 +86,7 @@ describe("ServerService.startServer", () => { const muxHomeFile = path.join(tempDir, "muxHome-not-a-dir"); await fs.writeFile(muxHomeFile, "not a directory"); - let thrownError: Error | null = null; + let thrownError: unknown = null; try { // Start server - this should fail when trying to write lockfile @@ -102,12 +97,15 @@ describe("ServerService.startServer", () => { port: 0, // random port }); } catch (err) { - thrownError = err as Error; + thrownError = err; } // Verify that an error was thrown expect(thrownError).not.toBeNull(); - expect(thrownError!.message).toMatch(/EACCES|permission denied|ENOTDIR|not a directory/i); + expect(thrownError).toBeInstanceOf(Error); + expect((thrownError as Error).message).toMatch( + /EACCES|permission denied|ENOTDIR|not a directory/i + ); // Verify the server is NOT left running expect(service.isServerRunning()).toBe(false); diff --git a/src/node/services/terminalService.test.ts b/src/node/services/terminalService.test.ts index 49712b2b04..764498592b 100644 --- a/src/node/services/terminalService.test.ts +++ b/src/node/services/terminalService.test.ts @@ -80,7 +80,7 @@ describe("TerminalService", () => { openTerminalWindowMock.mockClear(); }); - it("should create a session and buffer initial output", async () => { + it("should create a session", async () => { const session = await service.create({ workspaceId: "ws-1", cols: 80, @@ -88,16 +88,8 @@ describe("TerminalService", () => { }); expect(session.sessionId).toBe("session-1"); + expect(session.workspaceId).toBe("ws-1"); expect(createSessionMock).toHaveBeenCalled(); - - // Verify buffering: subscribe AFTER creation - let output = ""; - const unsubscribe = service.onOutput("session-1", (data) => { - output += data; - }); - - expect(output).toBe("initial data"); - unsubscribe(); }); it("should handle resizing", () => { @@ -116,7 +108,8 @@ describe("TerminalService", () => { it("should open terminal window via manager", async () => { await service.openWindow("ws-1"); - expect(openTerminalWindowMock).toHaveBeenCalledWith("ws-1"); + // openWindow(workspaceId, sessionId?) passes sessionId as undefined when not provided + expect(openTerminalWindowMock).toHaveBeenCalledWith("ws-1", undefined); }); it("should handle session exit", async () => { diff --git a/src/node/services/terminalService.ts b/src/node/services/terminalService.ts index 987d0167a4..baed1b264e 100644 --- a/src/node/services/terminalService.ts +++ b/src/node/services/terminalService.ts @@ -13,6 +13,8 @@ import type { RuntimeConfig } from "@/common/types/runtime"; import { isSSHRuntime, isDockerRuntime } from "@/common/types/runtime"; import { log } from "@/node/services/log"; import { isCommandAvailable, findAvailableCommand } from "@/node/utils/commandDiscovery"; +import { Terminal } from "@xterm/headless"; +import { SerializeAddon } from "@xterm/addon-serialize"; /** * Configuration for opening a native terminal @@ -35,10 +37,10 @@ export class TerminalService { private readonly outputEmitters = new Map(); private readonly exitEmitters = new Map(); - // Buffer for initial output to handle race condition between create and subscribe - // Map - private readonly outputBuffers = new Map(); - private readonly MAX_BUFFER_SIZE = 50; // Keep last 50 chunks + // Headless terminals for maintaining parsed terminal state on the backend. + // On reconnect, we serialize the screen state (~4KB) instead of replaying raw output (~512KB). + private readonly headlessTerminals = new Map(); + private readonly serializeAddons = new Map(); constructor(config: Config, ptyService: PTYService) { this.config = config; @@ -66,6 +68,21 @@ export class TerminalService { throw new Error(`Workspace not found: ${params.workspaceId}`); } + // Validate required fields before proceeding - projectPath is required for project-dir runtimes + if (!workspaceMetadata.projectPath) { + log.error("Workspace metadata missing projectPath", { + workspaceId: params.workspaceId, + name: workspaceMetadata.name, + runtimeConfig: workspaceMetadata.runtimeConfig, + projectName: workspaceMetadata.projectName, + metadata: JSON.stringify(workspaceMetadata), + }); + throw new Error( + `Workspace "${workspaceMetadata.name}" (${params.workspaceId}) is missing projectPath. ` + + `This may indicate a corrupted config or a workspace that was not properly associated with a project.` + ); + } + // 2. Create runtime (pass workspace info for Docker container name derivation) const runtime = createRuntime( workspaceMetadata.runtimeConfig ?? { type: "local", srcBaseDir: this.config.srcDir }, @@ -121,10 +138,21 @@ export class TerminalService { tempSessionId = session.sessionId; - // Initialize emitters + // Initialize emitters and headless terminal for state tracking this.outputEmitters.set(session.sessionId, new EventEmitter()); this.exitEmitters.set(session.sessionId, new EventEmitter()); - this.outputBuffers.set(session.sessionId, []); + + // Create headless terminal to maintain parsed state for reconnection + // allowProposedApi is required for SerializeAddon to access the buffer + const headless = new Terminal({ + cols: params.cols, + rows: params.rows, + allowProposedApi: true, + }); + const serializeAddon = new SerializeAddon(); + headless.loadAddon(serializeAddon); + this.headlessTerminals.set(session.sessionId, headless); + this.serializeAddons.set(session.sessionId, serializeAddon); // Replay local buffer that arrived during creation for (const data of localBuffer) { @@ -156,6 +184,10 @@ export class TerminalService { resize(params: TerminalResizeParams): void { try { this.ptyService.resize(params); + + // Also resize the headless terminal to keep state in sync + const headless = this.headlessTerminals.get(params.sessionId); + headless?.resize(params.cols, params.rows); } catch (err) { log.error("Error resizing terminal:", err); throw err; @@ -171,7 +203,7 @@ export class TerminalService { } } - async openWindow(workspaceId: string): Promise { + async openWindow(workspaceId: string, sessionId?: string): Promise { try { const allMetadata = await this.config.getAllWorkspaceMetadata(); const workspace = allMetadata.find((w) => w.id === workspaceId); @@ -185,8 +217,10 @@ export class TerminalService { const isDesktop = !!this.terminalWindowManager; if (isDesktop) { - log.info(`Opening terminal window for workspace: ${workspaceId}`); - await this.terminalWindowManager!.openTerminalWindow(workspaceId); + log.info( + `Opening terminal window for workspace: ${workspaceId}${sessionId ? ` (session: ${sessionId})` : ""}` + ); + await this.terminalWindowManager!.openTerminalWindow(workspaceId, sessionId); } else { log.info( `Browser mode: terminal UI handled by browser for ${isSSH ? "SSH" : "local"} workspace: ${workspaceId}` @@ -518,11 +552,8 @@ export class TerminalService { }; } - // Replay buffer - const buffer = this.outputBuffers.get(sessionId); - if (buffer) { - buffer.forEach((data) => callback(data)); - } + // Note: The attach stream yields screenState first, then live output. + // This subscription only provides live output from the point of subscription onward. const handler = (data: string) => callback(data); emitter.on("data", handler); @@ -547,20 +578,36 @@ export class TerminalService { }; } + /** + * Get serialized screen state for a session. + * Called by frontend on reconnect to restore terminal view instantly (~4KB vs 512KB raw replay). + * Returns VT escape sequences that reconstruct the current screen state. + * + * Note: @xterm/addon-serialize v0.14+ automatically includes the alternate buffer switch + * sequence (\x1b[?1049h) when the terminal is in alternate screen mode (htop, vim, etc.). + */ + getScreenState(sessionId: string): string { + const addon = this.serializeAddons.get(sessionId); + return addon?.serialize() ?? ""; + } + private emitOutput(sessionId: string, data: string) { const emitter = this.outputEmitters.get(sessionId); if (emitter) { emitter.emit("data", data); } - // Update buffer - const buffer = this.outputBuffers.get(sessionId); - if (buffer) { - buffer.push(data); - if (buffer.length > this.MAX_BUFFER_SIZE) { - buffer.shift(); - } - } + // Write to headless terminal to maintain parsed state + const headless = this.headlessTerminals.get(sessionId); + headless?.write(data); + } + + /** + * Get all session IDs for a workspace. + * Used by frontend to discover existing sessions to reattach to after reload. + */ + getWorkspaceSessionIds(workspaceId: string): string[] { + return this.ptyService.getWorkspaceSessionIds(workspaceId); } /** @@ -582,6 +629,11 @@ export class TerminalService { private cleanup(sessionId: string) { this.outputEmitters.delete(sessionId); this.exitEmitters.delete(sessionId); - this.outputBuffers.delete(sessionId); + + // Dispose and clean up headless terminal + const headless = this.headlessTerminals.get(sessionId); + headless?.dispose(); + this.headlessTerminals.delete(sessionId); + this.serializeAddons.delete(sessionId); } } diff --git a/src/node/services/tools/bash_background_terminate.ts b/src/node/services/tools/bash_background_terminate.ts index 9b2c901bf0..8571925377 100644 --- a/src/node/services/tools/bash_background_terminate.ts +++ b/src/node/services/tools/bash_background_terminate.ts @@ -27,7 +27,7 @@ export const createBashBackgroundTerminateTool: ToolFactory = (config: ToolConfi // Verify process belongs to this workspace before terminating const process = await config.backgroundProcessManager.getProcess(process_id); - if (!process || process.workspaceId !== config.workspaceId) { + if (process?.workspaceId !== config.workspaceId) { return { success: false, error: `Process not found: ${process_id}`, diff --git a/src/node/services/tools/bash_output.ts b/src/node/services/tools/bash_output.ts index c36cb95e49..94a5f15a53 100644 --- a/src/node/services/tools/bash_output.ts +++ b/src/node/services/tools/bash_output.ts @@ -31,7 +31,7 @@ export const createBashOutputTool: ToolFactory = (config: ToolConfiguration) => // Verify process belongs to this workspace const proc = await config.backgroundProcessManager.getProcess(process_id); - if (!proc || proc.workspaceId !== config.workspaceId) { + if (proc?.workspaceId !== config.workspaceId) { return { success: false, error: `Process not found: ${process_id}. The process may have exited or the app was restarted. Do not retry - use bash_background_list to see active processes.`, diff --git a/src/node/utils/paths.main.ts b/src/node/utils/paths.main.ts index eb81dacf78..2eb27b9c3c 100644 --- a/src/node/utils/paths.main.ts +++ b/src/node/utils/paths.main.ts @@ -204,6 +204,30 @@ export class PlatformPaths { return filePath; } + // In tests and other isolated environments, mux can be configured to store all + // state under a custom root via MUX_ROOT. We also allow runtime config paths + // like "~/.mux/src" (portable, works for both local + SSH) to resolve to that + // root when MUX_ROOT is set. + const muxRoot = env.MUX_ROOT; + if (muxRoot) { + const normalizedMuxRoot = muxRoot.replace(/[\\/]+$/g, ""); + const sep = getSeparator(); + const prefixes = ["~/.mux", "~\\.mux"] as const; + for (const prefix of prefixes) { + if (filePath === prefix) { + return normalizedMuxRoot; + } + + const slashPrefix = `${prefix}/`; + const backslashPrefix = `${prefix}\\`; + if (filePath.startsWith(slashPrefix) || filePath.startsWith(backslashPrefix)) { + const rest = filePath.slice(prefix.length + 1); + const normalizedRest = rest.replace(/[\\/]+/g, sep); + return normalizedMuxRoot + (normalizedRest ? sep + normalizedRest : ""); + } + } + } + if (filePath === "~") { return getHomeDir() || filePath; } diff --git a/src/node/utils/paths.test.ts b/src/node/utils/paths.test.ts index 7317209de7..bfd4e69b90 100644 --- a/src/node/utils/paths.test.ts +++ b/src/node/utils/paths.test.ts @@ -116,6 +116,28 @@ describe("PlatformPaths", () => { const testPath = path.join("/", "home", "user", "project"); expect(PlatformPaths.expandHome(testPath)).toBe(testPath); }); + test("expands ~/.mux to MUX_ROOT when set", () => { + const originalMuxRoot = process.env.MUX_ROOT; + const testMuxRoot = path.join(os.tmpdir(), "mux-root-test"); + process.env.MUX_ROOT = testMuxRoot; + + try { + const sep = path.sep; + const muxPath = `~${sep}.mux${sep}src${sep}project`; + expect(PlatformPaths.expandHome(muxPath)).toBe(path.join(testMuxRoot, "src", "project")); + + // Other ~ paths should still resolve to the actual OS home directory. + const home = os.homedir(); + const homePath = `~${sep}projects${sep}mux`; + expect(PlatformPaths.expandHome(homePath)).toBe(path.join(home, "projects", "mux")); + } finally { + if (originalMuxRoot === undefined) { + delete process.env.MUX_ROOT; + } else { + process.env.MUX_ROOT = originalMuxRoot; + } + } + }); test("handles empty input", () => { expect(PlatformPaths.expandHome("")).toBe(""); diff --git a/tests/e2e/scenarios/sidebarDragDrop.spec.ts b/tests/e2e/scenarios/sidebarDragDrop.spec.ts new file mode 100644 index 0000000000..3f7e190638 --- /dev/null +++ b/tests/e2e/scenarios/sidebarDragDrop.spec.ts @@ -0,0 +1,215 @@ +import { expect } from "@playwright/test"; +import { electronTest as test } from "../electronTest"; + +test.skip( + ({ browserName }) => browserName !== "chromium", + "Electron scenario runs on chromium only" +); + +test.describe("sidebar drag and drop", () => { + // Drag reorder tests are flaky on Linux/Xvfb - programmatic DragEvent dispatch + // doesn't trigger dnd-kit's sortable reordering reliably in headless environments. + // These tests pass consistently on macOS where native DnD events work. + const skipDragOnLinux = process.platform === "linux"; + + test("can drag an active tab to reorder within tabstrip", async ({ page, ui }) => { + test.skip(skipDragOnLinux, "Drag reorder is flaky on Linux/Xvfb"); + await ui.projects.openFirstWorkspace(); + + const sidebar = page.getByRole("complementary", { name: "Workspace insights" }); + await expect(sidebar).toBeVisible({ timeout: 5000 }); + + const tablist = sidebar.getByRole("tablist"); + await expect(tablist).toBeVisible({ timeout: 5000 }); + + const costsTab = tablist.getByRole("tab", { name: /Costs/ }); + const reviewTab = tablist.getByRole("tab", { name: /Review/ }); + await expect(costsTab).toBeVisible({ timeout: 5000 }); + await expect(reviewTab).toBeVisible({ timeout: 5000 }); + + // Costs tab should be selected (active) by default + await expect(costsTab).toHaveAttribute("aria-selected", "true"); + + // Verify initial order: costs comes before review + const initialTabs = await tablist.getByRole("tab").all(); + const initialLabels = await Promise.all(initialTabs.map((t) => t.textContent())); + const costsIndex = initialLabels.findIndex((l) => l?.includes("Costs")); + const reviewIndex = initialLabels.findIndex((l) => l?.includes("Review")); + expect(costsIndex).toBeLessThan(reviewIndex); + + // Drag active costs tab to after review tab position (reorder) + // Tabs are directly draggable without needing a handle + await ui.dragElement(costsTab, reviewTab, { targetPosition: "after" }); + + // Verify tabs were reordered: review now comes before costs + const reorderedTabs = await tablist.getByRole("tab").all(); + const reorderedLabels = await Promise.all(reorderedTabs.map((t) => t.textContent())); + const newCostsIndex = reorderedLabels.findIndex((l) => l?.includes("Costs")); + const newReviewIndex = reorderedLabels.findIndex((l) => l?.includes("Review")); + expect(newReviewIndex).toBeLessThan(newCostsIndex); + }); + + test("can drag an inactive tab to reorder within tabstrip", async ({ page, ui }) => { + test.skip(skipDragOnLinux, "Drag reorder is flaky on Linux/Xvfb"); + await ui.projects.openFirstWorkspace(); + + const sidebar = page.getByRole("complementary", { name: "Workspace insights" }); + await expect(sidebar).toBeVisible({ timeout: 5000 }); + + // Add a terminal tab first (not present by default) + await ui.metaSidebar.addTerminal(); + + const tablist = sidebar.getByRole("tablist"); + const costsTab = tablist.getByRole("tab", { name: /Costs/ }); + const reviewTab = tablist.getByRole("tab", { name: /Review/ }); + // Terminal tab name may be "Terminal" initially or a cwd path after shell starts + // Find it by looking for tab with close button (only terminal tabs have X button) + const terminalTab = tablist.locator('[role="tab"]').filter({ + has: page.getByRole("button", { name: "Close terminal" }), + }); + await expect(costsTab).toBeVisible({ timeout: 5000 }); + await expect(reviewTab).toBeVisible({ timeout: 5000 }); + await expect(terminalTab).toBeVisible({ timeout: 5000 }); + + // Terminal tab is selected after adding; select Costs to make Terminal inactive + await costsTab.click(); + await expect(costsTab).toHaveAttribute("aria-selected", "true"); + await expect(terminalTab).toHaveAttribute("aria-selected", "false"); + + // Verify initial order: costs, review, terminal (terminal is last after adding) + const initialTabs = await tablist.getByRole("tab").all(); + const costsIndex = await tablist.getByRole("tab", { name: /Costs/ }).evaluate((el) => { + return Array.from(el.parentElement?.parentElement?.children ?? []).indexOf(el.parentElement!); + }); + const reviewIndex = await tablist.getByRole("tab", { name: /Review/ }).evaluate((el) => { + return Array.from(el.parentElement?.parentElement?.children ?? []).indexOf(el.parentElement!); + }); + const terminalIndex = await terminalTab.evaluate((el) => { + return Array.from(el.parentElement?.parentElement?.children ?? []).indexOf(el.parentElement!); + }); + expect(reviewIndex).toBeLessThan(terminalIndex); + + // Drag INACTIVE terminal tab to before review tab (reorder) + // This tests that inactive tabs can be dragged just like active tabs + await ui.dragElement(terminalTab, reviewTab, { targetPosition: "before" }); + + // Verify tabs were reordered: terminal now comes before review + const newTerminalIndex = await terminalTab.evaluate((el) => { + return Array.from(el.parentElement?.parentElement?.children ?? []).indexOf(el.parentElement!); + }); + const newReviewIndex = await tablist.getByRole("tab", { name: /Review/ }).evaluate((el) => { + return Array.from(el.parentElement?.parentElement?.children ?? []).indexOf(el.parentElement!); + }); + expect(newTerminalIndex).toBeLessThan(newReviewIndex); + + // The active tab should still be costs (drag shouldn't change selection) + await expect(costsTab).toHaveAttribute("aria-selected", "true"); + }); + + test("sidebar tabs are interactive and switch content", async ({ page, ui }) => { + await ui.projects.openFirstWorkspace(); + + const sidebar = page.getByRole("complementary", { name: "Workspace insights" }); + await expect(sidebar).toBeVisible(); + + // Add a terminal tab first (not present by default) + await ui.metaSidebar.addTerminal(); + + const tablist = sidebar.getByRole("tablist"); + await expect(tablist).toBeVisible(); + + // Get all tabs - terminal has close button (unique to terminal tabs) + const costsTab = tablist.getByRole("tab", { name: /Costs/ }); + const reviewTab = tablist.getByRole("tab", { name: /Review/ }); + const terminalTab = tablist.locator('[role="tab"]').filter({ + has: page.getByRole("button", { name: "Close terminal" }), + }); + + // Click through each tab and verify it becomes selected + await costsTab.click(); + await expect(costsTab).toHaveAttribute("aria-selected", "true"); + + await reviewTab.click(); + await expect(reviewTab).toHaveAttribute("aria-selected", "true"); + await expect(costsTab).toHaveAttribute("aria-selected", "false"); + + await terminalTab.click(); + await expect(terminalTab).toHaveAttribute("aria-selected", "true"); + await expect(reviewTab).toHaveAttribute("aria-selected", "false"); + + // Return to costs + await costsTab.click(); + await expect(costsTab).toHaveAttribute("aria-selected", "true"); + }); + + test("split layout can be created and navigated via keyboard/localStorage", async ({ + page, + ui, + }) => { + await ui.projects.openFirstWorkspace(); + + const sidebar = page.getByRole("complementary", { name: "Workspace insights" }); + await expect(sidebar).toBeVisible(); + + // Get workspaceId from context for per-workspace layout key + const workspaceId = ui.context.workspaceId; + + // Set up a split layout via localStorage (simulating persistence) + // Layout key is per-workspace: "right-sidebar:layout:{workspaceId}" + await page.evaluate( + ({ wsId }) => { + const splitLayout = { + version: 1, + nextId: 3, + focusedTabsetId: "tabset-1", + root: { + type: "split", + id: "split-0", + direction: "vertical", + sizes: [50, 50], + children: [ + { + type: "tabset", + id: "tabset-1", + tabs: ["costs", "review"], + activeTab: "costs", + }, + { + type: "tabset", + id: "tabset-2", + tabs: ["costs"], // Use costs since terminal isn't persisted by default + activeTab: "costs", + }, + ], + }, + }; + localStorage.setItem(`right-sidebar:layout:${wsId}`, JSON.stringify(splitLayout)); + }, + { wsId: workspaceId } + ); + + // Reload to pick up the layout + await page.reload(); + await page.waitForLoadState("domcontentloaded"); + + // Wait for sidebar to appear + await expect(sidebar).toBeVisible({ timeout: 10000 }); + + // Verify we now have two tablists (split layout) + const tablists = await sidebar.getByRole("tablist").all(); + expect(tablists.length).toBe(2); + + // Verify each tablist has expected tabs + const topTabs = await tablists[0].getByRole("tab").all(); + const bottomTabs = await tablists[1].getByRole("tab").all(); + + expect(topTabs.length).toBe(2); // Costs, Review + expect(bottomTabs.length).toBe(1); // Costs (duplicate tab in split) + }); + + // Note: Full drag-drop tests require real browser mouse events which + // don't work reliably with Playwright + Xvfb + react-dnd HTML5 backend. + // Drag behavior is tested via: + // - Unit tests: src/browser/utils/rightSidebarLayout.test.ts + // - UI integration: tests/ui/rightSidebar.integration.test.ts +}); diff --git a/tests/e2e/scenarios/terminal.spec.ts b/tests/e2e/scenarios/terminal.spec.ts new file mode 100644 index 0000000000..b693a87192 --- /dev/null +++ b/tests/e2e/scenarios/terminal.spec.ts @@ -0,0 +1,33 @@ +import { electronTest as test } from "../electronTest"; + +test.skip( + ({ browserName }) => browserName !== "chromium", + "Electron scenario runs on chromium only" +); + +test("terminal tab opens without error", async ({ ui }) => { + await ui.projects.openFirstWorkspace(); + + // Terminal is not a default tab - click "+" to add one + await ui.metaSidebar.expectVisible(); + await ui.metaSidebar.addTerminal(); + + // Verify the terminal opens without the "isOpen" error + await ui.metaSidebar.expectTerminalNoError(); +}); + +test("terminal tab handles workspace switching", async ({ ui, page }) => { + await ui.projects.openFirstWorkspace(); + + // Terminal is not a default tab - click "+" to add one + await ui.metaSidebar.expectVisible(); + await ui.metaSidebar.addTerminal(); + await ui.metaSidebar.expectTerminalNoError(); + + // Switch to Costs tab (unmounts terminal UI but keeps session alive) + await ui.metaSidebar.selectTab("Costs"); + + // Switch back to Terminal tab (should reattach to existing session) + await ui.metaSidebar.selectTab("Terminal"); + await ui.metaSidebar.expectTerminalNoError(); +}); diff --git a/tests/e2e/utils/ui.ts b/tests/e2e/utils/ui.ts index 1ec7480036..9f3caf187d 100644 --- a/tests/e2e/utils/ui.ts +++ b/tests/e2e/utils/ui.ts @@ -41,6 +41,9 @@ export interface WorkspaceUI { readonly metaSidebar: { expectVisible(): Promise; selectTab(label: string): Promise; + addTerminal(): Promise; + expectTerminalNoError(): Promise; + expectTerminalError(expectedText?: string): Promise; }; readonly settings: { open(): Promise; @@ -51,6 +54,21 @@ export interface WorkspaceUI { expandProvider(providerName: string): Promise; }; readonly context: DemoProjectConfig; + /** + * Perform a drag-and-drop operation between two elements. + * Uses programmatic DragEvent dispatch which works with react-dnd HTML5Backend + * even in Xvfb environments where Playwright's mouse.move() hangs. + * + * @param source - The element to drag from + * @param target - The element to drag to + * @param options - Optional positioning for drop location + * - targetPosition: 'before' | 'after' | 'center' - where to drop relative to target + */ + dragElement( + source: Locator, + target: Locator, + options?: { targetPosition?: "before" | "after" | "center" } + ): Promise; } function sanitizeMode(mode: ChatMode): ChatMode { @@ -423,6 +441,37 @@ export function createWorkspaceUI(page: Page, context: DemoProjectConfig): Works throw new Error(`Tab "${label}" did not enter selected state`); } }, + + async addTerminal(): Promise { + // Click the "+" button to add a new terminal tab + const addButton = page.getByRole("button", { name: "New terminal" }); + await expect(addButton).toBeVisible(); + await addButton.click(); + // Wait for a terminal tab to appear (name may be "Terminal" or include cwd path) + // and be selected. Use a locator that matches tabs containing "Terminal" or terminal icon. + const terminalTab = page + .locator('[role="tab"]') + .filter({ has: page.locator("svg") }) + .last(); + await expect(terminalTab).toBeVisible({ timeout: 5000 }); + await expect(terminalTab).toHaveAttribute("aria-selected", "true"); + }, + + async expectTerminalNoError(): Promise { + // Wait a bit for the terminal to initialize + await page.waitForTimeout(500); + // Check that there's no error message displayed + const errorElement = page.locator(".terminal-view").getByText(/Terminal Error:/); + await expect(errorElement).not.toBeVisible({ timeout: 2000 }); + }, + + async expectTerminalError(expectedText?: string): Promise { + const errorElement = page.locator(".terminal-view").getByText(/Terminal Error:/); + await expect(errorElement).toBeVisible({ timeout: 5000 }); + if (expectedText) { + await expect(errorElement).toContainText(expectedText); + } + }, }; const settings = { @@ -465,11 +514,118 @@ export function createWorkspaceUI(page: Page, context: DemoProjectConfig): Works }, }; + /** + * Perform a drag-and-drop operation between two elements. + * Uses programmatic DragEvent dispatch which works with react-dnd HTML5Backend + * even in Xvfb environments where Playwright's mouse.move() hangs. + * + * @param source - The element to drag from + * @param target - The element to drag to + * @param options - Optional positioning for drop location + * - targetPosition: 'before' | 'after' | 'center' - where to drop relative to target + */ + async function dragElement( + source: Locator, + target: Locator, + options?: { targetPosition?: "before" | "after" | "center" } + ): Promise { + const sourceHandle = await source.elementHandle(); + const targetHandle = await target.elementHandle(); + + if (!sourceHandle || !targetHandle) { + throw new Error("Could not get element handles for drag"); + } + + const targetPosition = options?.targetPosition ?? "center"; + + await page.evaluate( + async ([src, tgt, pos]) => { + const sourceRect = src.getBoundingClientRect(); + const targetRect = tgt.getBoundingClientRect(); + + // Calculate target X based on position + let targetX: number; + if (pos === "before") { + targetX = targetRect.x + 5; // Near left edge + } else if (pos === "after") { + targetX = targetRect.x + targetRect.width - 5; // Near right edge + } else { + targetX = targetRect.x + targetRect.width / 2; // Center + } + const targetY = targetRect.y + targetRect.height / 2; + + const dataTransfer = new DataTransfer(); + + // Dispatch dragstart on source + src.dispatchEvent( + new DragEvent("dragstart", { + bubbles: true, + cancelable: true, + dataTransfer, + clientX: sourceRect.x + sourceRect.width / 2, + clientY: sourceRect.y + sourceRect.height / 2, + }) + ); + + await new Promise((r) => setTimeout(r, 50)); + + // Dispatch dragenter on target + tgt.dispatchEvent( + new DragEvent("dragenter", { + bubbles: true, + cancelable: true, + dataTransfer, + clientX: targetX, + clientY: targetY, + }) + ); + + // Dispatch dragover on target (required for drop, and triggers reorder) + tgt.dispatchEvent( + new DragEvent("dragover", { + bubbles: true, + cancelable: true, + dataTransfer, + clientX: targetX, + clientY: targetY, + }) + ); + + await new Promise((r) => setTimeout(r, 50)); + + // Dispatch drop on target + tgt.dispatchEvent( + new DragEvent("drop", { + bubbles: true, + cancelable: true, + dataTransfer, + clientX: targetX, + clientY: targetY, + }) + ); + + // Dispatch dragend on source + src.dispatchEvent( + new DragEvent("dragend", { + bubbles: true, + cancelable: true, + dataTransfer, + }) + ); + }, + [sourceHandle, targetHandle, targetPosition] as const + ); + + // Allow React state updates + await page.waitForTimeout(100); + } + return { projects, chat, metaSidebar, settings, context, + dragElement, }; } diff --git a/tests/ipc/terminal.test.ts b/tests/ipc/terminal.test.ts index cd97fcc8ad..43c5c48ec0 100644 --- a/tests/ipc/terminal.test.ts +++ b/tests/ipc/terminal.test.ts @@ -214,4 +214,309 @@ describeIntegration("terminal PTY", () => { }, 20000 ); + + test.concurrent( + "attach should return screenState first, then live output", + async () => { + const env = await createTestEnvironment(); + const tempGitRepo = await createTempGitRepo(); + + try { + const createResult = await createWorkspace(env, tempGitRepo, "test-attach"); + const metadata = expectWorkspaceCreationSuccess(createResult); + const workspaceId = metadata.id; + const client = resolveOrpcClient(env); + + // Create terminal session + const session = await client.terminal.create({ + workspaceId, + cols: 80, + rows: 24, + }); + + // Give terminal time to initialize + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Use attach endpoint - first message should be screenState + const messages: Array<{ type: "screenState" | "output"; data: string }> = []; + const attachPromise = (async () => { + const iterator = await client.terminal.attach({ sessionId: session.sessionId }); + for await (const msg of iterator) { + messages.push(msg); + // Collect until we have screenState + at least one output with our marker + const fullOutput = messages + .filter((m) => m.type === "output") + .map((m) => m.data) + .join(""); + if (messages.length >= 1 && fullOutput.includes("ATTACH_TEST")) { + break; + } + // Safety limit + if (messages.length > 50) break; + } + })(); + + // Small delay to ensure attach has started + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Send command + client.terminal.sendInput({ + sessionId: session.sessionId, + data: "echo ATTACH_TEST\\n", + }); + + const timeoutPromise = new Promise((_, reject) => { + setTimeout(() => reject(new Error("Timeout in attach test")), 10000); + }); + + await Promise.race([attachPromise, timeoutPromise]); + + // First message should always be screenState + expect(messages.length).toBeGreaterThan(0); + expect(messages[0].type).toBe("screenState"); + + // Should have output messages after screenState + const outputMessages = messages.filter((m) => m.type === "output"); + expect(outputMessages.length).toBeGreaterThan(0); + + // Output should contain our test marker + const fullOutput = outputMessages.map((m) => m.data).join(""); + expect(fullOutput).toContain("ATTACH_TEST"); + + await client.terminal.close({ sessionId: session.sessionId }); + await client.workspace.remove({ workspaceId }); + } finally { + await cleanupTestEnvironment(env); + await cleanupTempGitRepo(tempGitRepo); + } + }, + 20000 + ); + + test.concurrent( + "attach should preserve output order - no race conditions", + async () => { + const env = await createTestEnvironment(); + const tempGitRepo = await createTempGitRepo(); + + try { + const createResult = await createWorkspace(env, tempGitRepo, "test-attach-order"); + const metadata = expectWorkspaceCreationSuccess(createResult); + const workspaceId = metadata.id; + const client = resolveOrpcClient(env); + + // Create terminal and send numbered output + const session = await client.terminal.create({ + workspaceId, + cols: 80, + rows: 24, + }); + + await new Promise((resolve) => setTimeout(resolve, 500)); + + // First, send some commands to populate terminal state + client.terminal.sendInput({ + sessionId: session.sessionId, + data: "echo LINE1\\n", + }); + await new Promise((resolve) => setTimeout(resolve, 200)); + client.terminal.sendInput({ + sessionId: session.sessionId, + data: "echo LINE2\\n", + }); + await new Promise((resolve) => setTimeout(resolve, 200)); + + // Now attach and send more lines - verify order is preserved + const messages: Array<{ type: "screenState" | "output"; data: string }> = []; + const attachPromise = (async () => { + const iterator = await client.terminal.attach({ sessionId: session.sessionId }); + for await (const msg of iterator) { + messages.push(msg); + const fullOutput = messages + .filter((m) => m.type === "output") + .map((m) => m.data) + .join(""); + if (fullOutput.includes("LINE4")) { + break; + } + if (messages.length > 100) break; + } + })(); + + // After attach starts, send more lines + await new Promise((resolve) => setTimeout(resolve, 100)); + client.terminal.sendInput({ + sessionId: session.sessionId, + data: "echo LINE3\\n", + }); + await new Promise((resolve) => setTimeout(resolve, 100)); + client.terminal.sendInput({ + sessionId: session.sessionId, + data: "echo LINE4\\n", + }); + + await Promise.race([ + attachPromise, + new Promise((_, reject) => + setTimeout(() => reject(new Error("Timeout in order test")), 10000) + ), + ]); + + // Screen state should be first + expect(messages[0].type).toBe("screenState"); + + // Screen state should contain LINE1 and LINE2 (from before attach) + expect(messages[0].data).toContain("LINE1"); + expect(messages[0].data).toContain("LINE2"); + + // Output messages should contain LINE3 and LINE4 (sent after attach) + const outputData = messages + .filter((m) => m.type === "output") + .map((m) => m.data) + .join(""); + expect(outputData).toContain("LINE3"); + expect(outputData).toContain("LINE4"); + + // Verify LINE3 comes before LINE4 in output + const line3Pos = outputData.indexOf("LINE3"); + const line4Pos = outputData.indexOf("LINE4"); + expect(line3Pos).toBeLessThan(line4Pos); + + await client.terminal.close({ sessionId: session.sessionId }); + await client.workspace.remove({ workspaceId }); + } finally { + await cleanupTestEnvironment(env); + await cleanupTempGitRepo(tempGitRepo); + } + }, + 25000 + ); + + test.concurrent( + "reattach via attach should restore terminal state with escape sequences", + async () => { + const env = await createTestEnvironment(); + const tempGitRepo = await createTempGitRepo(); + + try { + const createResult = await createWorkspace(env, tempGitRepo, "test-reattach"); + const metadata = expectWorkspaceCreationSuccess(createResult); + const workspaceId = metadata.id; + const client = resolveOrpcClient(env); + + // Create terminal session + const session = await client.terminal.create({ + workspaceId, + cols: 80, + rows: 24, + }); + + // Wait for shell to initialize and produce output + await new Promise((resolve) => setTimeout(resolve, 500)); + + // Send a command that produces visible output + const outputChunks: string[] = []; + const outputPromise = (async () => { + const iterator = await client.terminal.onOutput({ sessionId: session.sessionId }); + for await (const chunk of iterator) { + outputChunks.push(chunk); + if (outputChunks.join("").includes("REATTACH_TEST")) break; + } + })(); + + client.terminal.sendInput({ + sessionId: session.sessionId, + data: "echo REATTACH_TEST\n", + }); + + await Promise.race([ + outputPromise, + new Promise((_, reject) => + setTimeout(() => reject(new Error("Timeout waiting for output")), 5000) + ), + ]); + + // Now use attach to reattach - simulating what happens on workspace switch + const attachMessages: Array<{ type: "screenState" | "output"; data: string }> = []; + const attachPromise = (async () => { + const iterator = await client.terminal.attach({ sessionId: session.sessionId }); + for await (const msg of iterator) { + attachMessages.push(msg); + // Just get the first message (screenState) + break; + } + })(); + + await Promise.race([ + attachPromise, + new Promise((_, reject) => + setTimeout(() => reject(new Error("Timeout in attach")), 5000) + ), + ]); + + // First message should be screenState + expect(attachMessages[0].type).toBe("screenState"); + + // Screen state should contain escape sequences (colors, cursor positioning, etc.) + const screenState = attachMessages[0].data; + expect(screenState.length).toBeGreaterThan(0); + // Should contain escape sequences + expect(screenState).toMatch(/\x1b\[/); + + await client.terminal.close({ sessionId: session.sessionId }); + await client.workspace.remove({ workspaceId }); + } finally { + await cleanupTestEnvironment(env); + await cleanupTempGitRepo(tempGitRepo); + } + }, + 15000 + ); + + test.concurrent( + "listSessions should return active session IDs for a workspace", + async () => { + const env = await createTestEnvironment(); + const tempGitRepo = await createTempGitRepo(); + + try { + const createResult = await createWorkspace(env, tempGitRepo, "test-list-sessions"); + const metadata = expectWorkspaceCreationSuccess(createResult); + const workspaceId = metadata.id; + const client = resolveOrpcClient(env); + + // Initially no sessions + const initialSessions = await client.terminal.listSessions({ workspaceId }); + expect(initialSessions).toEqual([]); + + // Create a terminal session + const session = await client.terminal.create({ + workspaceId, + cols: 80, + rows: 24, + }); + + // Now should have one session + const afterCreate = await client.terminal.listSessions({ workspaceId }); + expect(afterCreate).toContain(session.sessionId); + expect(afterCreate.length).toBe(1); + + // Close session + await client.terminal.close({ sessionId: session.sessionId }); + + // Wait for close to propagate + await new Promise((resolve) => setTimeout(resolve, 100)); + + // Should be empty again + const afterClose = await client.terminal.listSessions({ workspaceId }); + expect(afterClose).toEqual([]); + + await client.workspace.remove({ workspaceId }); + } finally { + await cleanupTestEnvironment(env); + await cleanupTempGitRepo(tempGitRepo); + } + }, + 15000 + ); }); diff --git a/tests/ipc/truncate.test.ts b/tests/ipc/truncate.test.ts index 336df5eb2c..bba8195080 100644 --- a/tests/ipc/truncate.test.ts +++ b/tests/ipc/truncate.test.ts @@ -80,7 +80,7 @@ describeIntegration("truncateHistory", () => { expect(result.success).toBe(true); // Wait for response - await collector.waitForEvent("stream-end", 10000); + await collector.waitForEvent("stream-end", 20000); assertStreamSuccess(collector); // Get response content @@ -102,7 +102,7 @@ describeIntegration("truncateHistory", () => { await cleanup(); } }, - 30000 + 45000 ); test.concurrent( @@ -163,7 +163,7 @@ describeIntegration("truncateHistory", () => { expect(result.success).toBe(true); // Wait for response - await collector.waitForEvent("stream-end", 10000); + await collector.waitForEvent("stream-end", 20000); assertStreamSuccess(collector); // Get response content @@ -193,7 +193,7 @@ describeIntegration("truncateHistory", () => { await cleanup(); } }, - 30000 + 45000 ); test.concurrent( diff --git a/tests/ui/renderReviewPanel.tsx b/tests/ui/renderReviewPanel.tsx index 1b27bc11a8..8cb96db49a 100644 --- a/tests/ui/renderReviewPanel.tsx +++ b/tests/ui/renderReviewPanel.tsx @@ -73,7 +73,7 @@ export function renderApp(props: RenderReviewPanelParams): RenderedApp { ); }, - async selectTab(tab: "costs" | "review" | "stats"): Promise { + async selectTab(tab: "costs" | "review" | "terminal" | "stats"): Promise { await waitFor( () => { // Find tab button by role and name diff --git a/tests/ui/rightSidebar.integration.test.ts b/tests/ui/rightSidebar.integration.test.ts new file mode 100644 index 0000000000..5bca1cfa2e --- /dev/null +++ b/tests/ui/rightSidebar.integration.test.ts @@ -0,0 +1,740 @@ +/** + * Integration tests for RightSidebar dock-lite behavior. + * + * Tests cover: + * - Tab switching (costs, review, terminal) + * - Sidebar collapse/expand + * - Tab persistence across navigation + * + * Note: These tests drive the UI from the user's perspective - clicking tabs, + * not calling backend APIs directly for the actions being tested. + */ + +import { fireEvent, waitFor } from "@testing-library/react"; + +import { shouldRunIntegrationTests } from "../testUtils"; +import { + cleanupSharedRepo, + createSharedRepo, + withSharedWorkspace, +} from "../ipc/sendMessageTestHelpers"; + +import { installDom } from "./dom"; +import { renderApp } from "./renderReviewPanel"; +import { cleanupView, setupWorkspaceView } from "./helpers"; +import { + RIGHT_SIDEBAR_TAB_KEY, + RIGHT_SIDEBAR_COLLAPSED_KEY, + getRightSidebarLayoutKey, +} from "@/common/constants/storage"; +// RightSidebarLayoutState used for initial setup via localStorage - acceptable for test fixtures +import type { RightSidebarLayoutState } from "@/browser/utils/rightSidebarLayout"; + +const describeIntegration = shouldRunIntegrationTests() ? describe : describe.skip; + +describeIntegration("RightSidebar (UI)", () => { + beforeAll(async () => { + await createSharedRepo(); + }); + + afterAll(async () => { + await cleanupSharedRepo(); + }); + + beforeEach(() => { + // Clear persisted state before each test + // Note: layout is per-workspace now, so cleared inside tests with workspace context + if (typeof localStorage !== "undefined") { + localStorage.removeItem(RIGHT_SIDEBAR_TAB_KEY); + localStorage.removeItem(RIGHT_SIDEBAR_COLLAPSED_KEY); + } + }); + + test("tab switching updates active tab and persists selection", async () => { + await withSharedWorkspace("anthropic", async ({ env, workspaceId, metadata }) => { + const cleanupDom = installDom(); + + // Clear any persisted state + localStorage.removeItem(RIGHT_SIDEBAR_TAB_KEY); + localStorage.removeItem(getRightSidebarLayoutKey(workspaceId)); + + const view = renderApp({ + apiClient: env.orpc, + metadata, + }); + + try { + await setupWorkspaceView(view, metadata, workspaceId); + + // Find the right sidebar + const sidebar = await waitFor( + () => { + const el = view.container.querySelector( + '[role="complementary"][aria-label="Workspace insights"]' + ); + if (!el) throw new Error("RightSidebar not found"); + return el as HTMLElement; + }, + { timeout: 10_000 } + ); + + // Find the Costs tab (should be default) + const costsTab = await waitFor( + () => { + const tab = sidebar.querySelector( + '[role="tab"][aria-controls*="costs"]' + ) as HTMLElement; + if (!tab) throw new Error("Costs tab not found"); + return tab; + }, + { timeout: 5_000 } + ); + + // Costs should be selected by default + expect(costsTab.getAttribute("aria-selected")).toBe("true"); + + // Click Review tab + const reviewTab = sidebar.querySelector( + '[role="tab"][aria-controls*="review"]' + ) as HTMLElement; + expect(reviewTab).toBeTruthy(); + fireEvent.click(reviewTab); + + // Wait for Review tab to become selected (visible UI state) + await waitFor(() => { + expect(reviewTab.getAttribute("aria-selected")).toBe("true"); + expect(costsTab.getAttribute("aria-selected")).toBe("false"); + }); + + // Verify Review panel is now visible + await waitFor(() => { + const reviewPanel = sidebar.querySelector('[role="tabpanel"][id*="review"]'); + if (!reviewPanel) throw new Error("Review panel should be visible after tab click"); + }); + + // Create a terminal via the "+" button + const newTerminalButton = await waitFor( + () => { + const btn = sidebar.querySelector('button[aria-label="New terminal"]'); + if (!btn) throw new Error("New terminal button not found"); + return btn as HTMLElement; + }, + { timeout: 5_000 } + ); + fireEvent.click(newTerminalButton); + + // Wait for the terminal tab to appear and become selected + const terminalTab = await waitFor( + () => { + const tab = sidebar.querySelector( + '[role="tab"][aria-controls*="terminal:"]' + ) as HTMLElement | null; + if (!tab) throw new Error("Terminal tab not found"); + return tab; + }, + { timeout: 10_000 } + ); + + await waitFor(() => { + expect(terminalTab.getAttribute("aria-selected")).toBe("true"); + expect(reviewTab.getAttribute("aria-selected")).toBe("false"); + }); + + // Verify terminal panel is now visible + await waitFor(() => { + const terminalPanel = sidebar.querySelector('[role="tabpanel"][id*="terminal"]'); + if (!terminalPanel) throw new Error("Terminal panel should be visible after tab click"); + }); + } finally { + await cleanupView(view, cleanupDom); + } + }); + }, 60_000); + + test("sidebar collapse and expand via button", async () => { + await withSharedWorkspace("anthropic", async ({ env, workspaceId, metadata }) => { + const cleanupDom = installDom(); + + // Start expanded + localStorage.setItem(RIGHT_SIDEBAR_COLLAPSED_KEY, JSON.stringify(false)); + + const view = renderApp({ + apiClient: env.orpc, + metadata, + }); + + try { + await setupWorkspaceView(view, metadata, workspaceId); + + // Find sidebar - should be expanded with tabs visible + const sidebar = await waitFor( + () => { + const el = view.container.querySelector( + '[role="complementary"][aria-label="Workspace insights"]' + ); + if (!el) throw new Error("RightSidebar not found"); + return el as HTMLElement; + }, + { timeout: 10_000 } + ); + + // Verify tabs are visible (expanded state) + await waitFor(() => { + const tablist = sidebar.querySelector('[role="tablist"]'); + if (!tablist) throw new Error("Tablist should be visible when expanded"); + }); + + // Find and click collapse button + const collapseButton = await waitFor( + () => { + // The collapse button has aria-label containing "collapse" or "expand" + const btn = sidebar.querySelector('button[aria-label*="ollapse"]') as HTMLElement; + if (!btn) throw new Error("Collapse button not found"); + return btn; + }, + { timeout: 5_000 } + ); + fireEvent.click(collapseButton); + + // Wait for collapse - tablist should not be rendered + await waitFor(() => { + const tablist = sidebar.querySelector('[role="tablist"]'); + if (tablist) throw new Error("Tablist should be hidden when collapsed"); + }); + + // Re-query sidebar and find expand button (sidebar reference may be stale after collapse) + const collapsedSidebar = view.container.querySelector( + '[role="complementary"][aria-label="Workspace insights"]' + ) as HTMLElement; + expect(collapsedSidebar).toBeTruthy(); + const expandButton = collapsedSidebar.querySelector( + 'button[aria-label="Expand sidebar"]' + ) as HTMLElement; + expect(expandButton).toBeTruthy(); + fireEvent.click(expandButton); + + // Wait for expand - tablist should be visible again + await waitFor(() => { + const tablist = sidebar.querySelector('[role="tablist"]'); + if (!tablist) throw new Error("Tablist should be visible after expand"); + }); + } finally { + await cleanupView(view, cleanupDom); + } + }); + }, 60_000); + + test("tab selection persists across workspace navigation", async () => { + await withSharedWorkspace("anthropic", async ({ env, workspaceId, metadata }) => { + const cleanupDom = installDom(); + + // Start with Review tab selected + const initialLayout: RightSidebarLayoutState = { + version: 1, + nextId: 2, + focusedTabsetId: "tabset-1", + root: { + type: "tabset", + id: "tabset-1", + tabs: ["costs", "review"], + activeTab: "review", + }, + }; + localStorage.setItem(getRightSidebarLayoutKey(workspaceId), JSON.stringify(initialLayout)); + + const view = renderApp({ + apiClient: env.orpc, + metadata, + }); + + try { + await setupWorkspaceView(view, metadata, workspaceId); + + // Find the right sidebar + const sidebar = await waitFor( + () => { + const el = view.container.querySelector( + '[role="complementary"][aria-label="Workspace insights"]' + ); + if (!el) throw new Error("RightSidebar not found"); + return el as HTMLElement; + }, + { timeout: 10_000 } + ); + + // Verify Review tab is selected (from persisted state) + await waitFor(() => { + const reviewTab = sidebar.querySelector( + '[role="tab"][aria-controls*="review"]' + ) as HTMLElement; + if (!reviewTab) throw new Error("Review tab not found"); + if (reviewTab.getAttribute("aria-selected") !== "true") { + throw new Error("Review tab should be selected from persisted state"); + } + }); + + // Navigate away by clicking project row (goes to home) + const projectRow = view.container.querySelector( + `[data-project-path="${metadata.projectPath}"]` + ) as HTMLElement; + if (projectRow) { + fireEvent.click(projectRow); + } + + // Wait a moment for navigation + await new Promise((r) => setTimeout(r, 200)); + + // Navigate back to workspace + const workspaceElement = await waitFor( + () => { + const el = view.container.querySelector(`[data-workspace-id="${workspaceId}"]`); + if (!el) throw new Error("Workspace not found in sidebar"); + return el as HTMLElement; + }, + { timeout: 5_000 } + ); + fireEvent.click(workspaceElement); + + // Verify Review tab is still selected after navigation + await waitFor(() => { + const sidebar2 = view.container.querySelector( + '[role="complementary"][aria-label="Workspace insights"]' + ); + if (!sidebar2) throw new Error("Sidebar not found after navigation"); + const reviewTab = sidebar2.querySelector( + '[role="tab"][aria-controls*="review"]' + ) as HTMLElement; + if (!reviewTab) throw new Error("Review tab not found after navigation"); + if (reviewTab.getAttribute("aria-selected") !== "true") { + throw new Error("Review tab selection should persist across navigation"); + } + }); + } finally { + await cleanupView(view, cleanupDom); + } + }); + }, 60_000); + + test("correct tab content is displayed for each tab", async () => { + await withSharedWorkspace("anthropic", async ({ env, workspaceId, metadata }) => { + const cleanupDom = installDom(); + + const view = renderApp({ + apiClient: env.orpc, + metadata, + }); + + try { + await setupWorkspaceView(view, metadata, workspaceId); + + const sidebar = await waitFor( + () => { + const el = view.container.querySelector( + '[role="complementary"][aria-label="Workspace insights"]' + ); + if (!el) throw new Error("RightSidebar not found"); + return el as HTMLElement; + }, + { timeout: 10_000 } + ); + + // Switch to Costs tab and verify content + const costsTab = await waitFor( + () => { + const tab = sidebar.querySelector( + '[role="tab"][aria-controls*="costs"]' + ) as HTMLElement | null; + if (!tab) throw new Error("Costs tab not found"); + return tab; + }, + { timeout: 5_000 } + ); + fireEvent.click(costsTab); + await waitFor(() => { + // Costs panel should contain model/cost info or "No usage data" + const costsPanel = sidebar.querySelector('[role="tabpanel"][id*="costs"]'); + if (!costsPanel) throw new Error("Costs panel not found"); + }); + + // Switch to Review tab and verify content + const reviewTab = await waitFor( + () => { + const tab = sidebar.querySelector( + '[role="tab"][aria-controls*="review"]' + ) as HTMLElement | null; + if (!tab) throw new Error("Review tab not found"); + return tab; + }, + { timeout: 5_000 } + ); + fireEvent.click(reviewTab); + await waitFor(() => { + // Review panel should exist + const reviewPanel = sidebar.querySelector('[role="tabpanel"][id*="review"]'); + if (!reviewPanel) throw new Error("Review panel not found"); + }); + + // Create a terminal via the "+" button + const newTerminalButton = await waitFor( + () => { + const btn = sidebar.querySelector('button[aria-label="New terminal"]'); + if (!btn) throw new Error("New terminal button not found"); + return btn as HTMLElement; + }, + { timeout: 5_000 } + ); + fireEvent.click(newTerminalButton); + + // Wait for the terminal tab to appear and verify its content + const terminalTab = await waitFor( + () => { + const tab = sidebar.querySelector( + '[role="tab"][aria-controls*="terminal:"]' + ) as HTMLElement | null; + if (!tab) throw new Error("Terminal tab not found"); + return tab; + }, + { timeout: 10_000 } + ); + + await waitFor(() => { + expect(terminalTab.getAttribute("aria-selected")).toBe("true"); + }); + + await waitFor(() => { + // Terminal panel should exist (may contain terminal-view class) + const terminalPanel = sidebar.querySelector('[role="tabpanel"][id*="terminal"]'); + if (!terminalPanel) throw new Error("Terminal panel not found"); + }); + } finally { + await cleanupView(view, cleanupDom); + } + }); + }, 60_000); + + test("sidebar width persists consistently across all tabs", async () => { + await withSharedWorkspace("anthropic", async ({ env, workspaceId, metadata }) => { + const cleanupDom = installDom(); + + // Clear any persisted width state + localStorage.removeItem("right-sidebar:width"); + + const view = renderApp({ + apiClient: env.orpc, + metadata, + }); + + try { + await setupWorkspaceView(view, metadata, workspaceId); + + // Find the right sidebar + const sidebar = await waitFor( + () => { + const el = view.container.querySelector( + '[role="complementary"][aria-label="Workspace insights"]' + ); + if (!el) throw new Error("RightSidebar not found"); + return el as HTMLElement; + }, + { timeout: 10_000 } + ); + + // Find the resize handle (left edge of sidebar) + const resizeHandle = await waitFor( + () => { + const handle = sidebar.querySelector('[class*="cursor-col-resize"]') as HTMLElement; + if (!handle) throw new Error("Resize handle not found"); + return handle; + }, + { timeout: 5_000 } + ); + + // Simulate drag resize to 500px + // Start on Costs tab (default) + const costsTab = await waitFor( + () => { + const tab = sidebar.querySelector( + '[role="tab"][aria-controls*="costs"]' + ) as HTMLElement | null; + if (!tab) throw new Error("Costs tab not found"); + return tab; + }, + { timeout: 5_000 } + ); + expect(costsTab.getAttribute("aria-selected")).toBe("true"); + + // Simulate mousedown on resize handle + fireEvent.mouseDown(resizeHandle, { clientX: 1000 }); + + // Move mouse to resize (moving left increases width) + fireEvent.mouseMove(document, { clientX: 500 }); // Move left by 500px + + // Release mouse + fireEvent.mouseUp(document); + + // Helper to get sidebar's computed width (the style attribute is set inline) + const getSidebarWidth = () => { + const styleWidth = sidebar.style.width; + if (styleWidth && styleWidth.endsWith("px")) { + return parseInt(styleWidth, 10); + } + return sidebar.getBoundingClientRect().width; + }; + + // Wait for width to change (resize should update inline style) + await waitFor(() => { + const width = getSidebarWidth(); + // Should have a width greater than default (~400px after resize) + if (width < 400) throw new Error(`Expected width >= 400, got ${width}`); + }); + + const widthAfterResize = getSidebarWidth(); + + // Switch to Review tab + const reviewTab = await waitFor( + () => { + const tab = sidebar.querySelector( + '[role="tab"][aria-controls*="review"]' + ) as HTMLElement | null; + if (!tab) throw new Error("Review tab not found"); + return tab; + }, + { timeout: 5_000 } + ); + fireEvent.click(reviewTab); + + await waitFor(() => { + expect(reviewTab.getAttribute("aria-selected")).toBe("true"); + }); + + // Width should still be the same (unified across tabs) - verify via UI + expect(getSidebarWidth()).toBe(widthAfterResize); + + // Create a terminal via the "+" button (terminal tabs are not present by default) + const newTerminalButton = await waitFor( + () => { + const btn = sidebar.querySelector('button[aria-label="New terminal"]'); + if (!btn) throw new Error("New terminal button not found"); + return btn as HTMLElement; + }, + { timeout: 5_000 } + ); + fireEvent.click(newTerminalButton); + + const terminalTab = await waitFor( + () => { + const tab = sidebar.querySelector( + '[role="tab"][aria-controls*="terminal:"]' + ) as HTMLElement | null; + if (!tab) throw new Error("Terminal tab not found"); + return tab; + }, + { timeout: 10_000 } + ); + + await waitFor(() => { + expect(terminalTab.getAttribute("aria-selected")).toBe("true"); + }); + + // Width should still be the same (verify via UI) + expect(getSidebarWidth()).toBe(widthAfterResize); + } finally { + await cleanupView(view, cleanupDom); + } + }); + }, 60_000); + + test("resizing works the same regardless of which tab is active", async () => { + await withSharedWorkspace("anthropic", async ({ env, workspaceId, metadata }) => { + const cleanupDom = installDom(); + + // Clear any persisted state + localStorage.removeItem("right-sidebar:width"); + + const view = renderApp({ + apiClient: env.orpc, + metadata, + }); + + try { + await setupWorkspaceView(view, metadata, workspaceId); + + const sidebar = await waitFor( + () => { + const el = view.container.querySelector( + '[role="complementary"][aria-label="Workspace insights"]' + ); + if (!el) throw new Error("RightSidebar not found"); + return el as HTMLElement; + }, + { timeout: 10_000 } + ); + + // Switch to Review tab first + const reviewTab = await waitFor( + () => { + const tab = sidebar.querySelector( + '[role="tab"][aria-controls*="review"]' + ) as HTMLElement | null; + if (!tab) throw new Error("Review tab not found"); + return tab; + }, + { timeout: 5_000 } + ); + fireEvent.click(reviewTab); + + await waitFor(() => { + expect(reviewTab.getAttribute("aria-selected")).toBe("true"); + }); + + // Find and use resize handle + const resizeHandle = await waitFor( + () => { + const handle = sidebar.querySelector('[class*="cursor-col-resize"]') as HTMLElement; + if (!handle) throw new Error("Resize handle not found"); + return handle; + }, + { timeout: 5_000 } + ); + + // Helper to get sidebar's visible width + const getSidebarWidth = () => { + const styleWidth = sidebar.style.width; + if (styleWidth && styleWidth.endsWith("px")) { + return parseInt(styleWidth, 10); + } + return sidebar.getBoundingClientRect().width; + }; + + // Resize while on Review tab + fireEvent.mouseDown(resizeHandle, { clientX: 1000 }); + fireEvent.mouseMove(document, { clientX: 600 }); + fireEvent.mouseUp(document); + + // Wait for width to change in UI + await waitFor(() => { + const width = getSidebarWidth(); + if (width < 400) throw new Error(`Expected width >= 400, got ${width}`); + }); + + const widthAfterReviewResize = getSidebarWidth(); + + // Switch to Costs tab + const costsTab = await waitFor( + () => { + const tab = sidebar.querySelector( + '[role="tab"][aria-controls*="costs"]' + ) as HTMLElement | null; + if (!tab) throw new Error("Costs tab not found"); + return tab; + }, + { timeout: 5_000 } + ); + fireEvent.click(costsTab); + + await waitFor(() => { + expect(costsTab.getAttribute("aria-selected")).toBe("true"); + }); + + // Width should persist when switching to Costs (verify via UI) + expect(getSidebarWidth()).toBe(widthAfterReviewResize); + + // Resize again on Costs tab + fireEvent.mouseDown(resizeHandle, { clientX: 800 }); + fireEvent.mouseMove(document, { clientX: 500 }); + fireEvent.mouseUp(document); + + await waitFor(() => { + const width = getSidebarWidth(); + // Width should have changed + if (width === widthAfterReviewResize) throw new Error("Width should have changed"); + }); + + const widthAfterCostsResize = getSidebarWidth(); + + // Switch back to Review - should have same new width (verify via UI) + fireEvent.click(reviewTab); + await waitFor(() => { + expect(reviewTab.getAttribute("aria-selected")).toBe("true"); + }); + + expect(getSidebarWidth()).toBe(widthAfterCostsResize); + } finally { + await cleanupView(view, cleanupDom); + } + }); + }, 60_000); + + test("split layout renders multiple panes with separate tablists", async () => { + await withSharedWorkspace("anthropic", async ({ env, workspaceId, metadata }) => { + const cleanupDom = installDom(); + + // Set up a split layout with two panes (top: costs, bottom: review) + const splitLayout: RightSidebarLayoutState = { + version: 1, + nextId: 10, + root: { + type: "split", + id: "split-1", + direction: "horizontal", + sizes: [50, 50], + children: [ + { type: "tabset", id: "tabset-top", tabs: ["costs"], activeTab: "costs" }, + { type: "tabset", id: "tabset-bottom", tabs: ["review"], activeTab: "review" }, + ], + }, + focusedTabsetId: "tabset-top", + }; + localStorage.setItem(getRightSidebarLayoutKey(workspaceId), JSON.stringify(splitLayout)); + + const view = renderApp({ + apiClient: env.orpc, + metadata, + }); + + try { + await setupWorkspaceView(view, metadata, workspaceId); + + const sidebar = await waitFor( + () => { + const el = view.container.querySelector( + '[role="complementary"][aria-label="Workspace insights"]' + ); + if (!el) throw new Error("RightSidebar not found"); + return el as HTMLElement; + }, + { timeout: 10_000 } + ); + + // Wait for both tablists (two panes) + await waitFor(() => { + const tablists = sidebar.querySelectorAll('[role="tablist"]'); + if (tablists.length < 2) throw new Error(`Expected 2 tablists, found ${tablists.length}`); + }); + + const tablists = sidebar.querySelectorAll('[role="tablist"]'); + expect(tablists.length).toBe(2); + + // Verify top pane has Costs tab selected + const topTablist = tablists[0] as HTMLElement; + const costsTab = topTablist.querySelector('[role="tab"]') as HTMLElement; + expect(costsTab).toBeTruthy(); + expect(costsTab.getAttribute("aria-selected")).toBe("true"); + + // Verify bottom pane has Review tab selected + const bottomTablist = tablists[1] as HTMLElement; + const reviewTab = bottomTablist.querySelector('[role="tab"]') as HTMLElement; + expect(reviewTab).toBeTruthy(); + expect(reviewTab.getAttribute("aria-selected")).toBe("true"); + + // Verify both tabpanels are rendered + const costsPanel = sidebar.querySelector('[role="tabpanel"][id*="costs"]'); + const reviewPanel = sidebar.querySelector('[role="tabpanel"][id*="review"]'); + expect(costsPanel).toBeTruthy(); + expect(reviewPanel).toBeTruthy(); + } finally { + await cleanupView(view, cleanupDom); + } + }); + }, 60_000); +});