From 5ff95ea8622a0120ce4bcb8860cf189120926ac2 Mon Sep 17 00:00:00 2001 From: Merge Sim Date: Thu, 3 Sep 2026 15:20:02 -0700 Subject: [PATCH] feat(native-chat): restore the terminal/chat switcher for bridge chat only #16729 removed every user-facing terminal<->chat switching affordance as a side effect of the structured Codex restructure ("renderer switching affordances and their dead leftovers"). That was right for structured Codex sessions, which render their own transcript with no live TUI underneath, but it also took the switcher away from bridge native chat, which still reads the terminal and has one to return to. Restore all four surfaces, each gated so structured sessions keep the removal: - pane header chat/terminal button (TerminalPaneHeaderOverlay) - pane context-menu "Switch to chat/terminal view" (TerminalContextMenu) - tab context-menu equivalent (SortableTabContextMenu) - the keyboard chord, whose hook had survived uncalled since #16729 Gating is one rule in one place: `canSwitchNativeChatView` refuses whenever a `structuredSessionId` is present, over the existing `canToggleNativeChat` eligibility. Standalone structured tabs are already excluded by the `contentType === 'terminal'` check; the new guard covers a terminal tab that adopted a structured session. The shortcut hook applies the same rule. The state plumbing (`viewMode`, `setTabViewMode`, `toggleTabViewMode`, host mirroring, `native_chat_toggled` telemetry) was never removed, so this rewires live actions rather than reintroducing logic. SortableTab.tsx sat exactly at its 400-line cap, so its inline-rename state and the window rename-request listener move to `use-sortable-tab-rename.ts` to make room. No behavior change; its rename tests pass unmodified. Two ratchets move for real, explained in place: - store-subscription budget: per-pane listeners stay pinned at 17 (the folded action bundle is still one listener); only the counterfactual pre-fold constant grows 48 -> 49 for the added `toggleTabViewMode` key. - hook-order parity: 204 -> 208 hooks for the four added `useCallback`s, useMemo count unchanged at 8. --- .../native-chat/native-chat-availability.ts | 13 +++ .../use-native-chat-toggle-shortcut.ts | 5 +- .../src/components/tab-bar/SortableTab.tsx | 98 ++++++------------- .../tab-bar/SortableTabContextMenu.tsx | 47 ++++++++- .../tab-bar/tab-bar-item-surface.tsx | 39 +++++++- .../tab-bar/use-sortable-tab-rename.ts | 94 ++++++++++++++++++ .../TerminalContextMenu.test.tsx | 3 + .../terminal-pane/TerminalContextMenu.tsx | 29 +++++- .../TerminalPaneHeaderOverlay.tsx | 62 +++++++++++- .../TerminalPaneOverlayLayer.tsx | 3 + .../terminal-pane/TerminalPaneSurface.tsx | 12 +++ .../terminal-pane-hook-order-parity.test.ts | 7 +- ...al-pane-store-subscription-budget.test.tsx | 8 +- .../use-terminal-pane-chat-state.ts | 50 +++++++++- .../use-terminal-pane-projection.ts | 23 ++++- .../use-terminal-pane-store-actions.ts | 2 + 16 files changed, 411 insertions(+), 84 deletions(-) create mode 100644 src/renderer/src/components/tab-bar/use-sortable-tab-rename.ts diff --git a/src/renderer/src/components/native-chat/native-chat-availability.ts b/src/renderer/src/components/native-chat/native-chat-availability.ts index 3cad7fc157f..1f883630f13 100644 --- a/src/renderer/src/components/native-chat/native-chat-availability.ts +++ b/src/renderer/src/components/native-chat/native-chat-availability.ts @@ -58,3 +58,16 @@ export function canToggleNativeChat(input: NativeChatAvailabilityInput): boolean } return isNativeChatSupportedAgent(agent) } + +/** Whether a user-facing terminal⇄chat switcher may be offered. A structured + * session IS the conversation — it owns the surface with no live TUI beneath + * it — so only terminal-backed (bridge) chat, which renders a terminal we can + * return to, gets the switch. */ +export function canSwitchNativeChatView( + input: NativeChatAvailabilityInput & { structuredSessionId?: string | null } +): boolean { + if (input.structuredSessionId) { + return false + } + return canToggleNativeChat(input) +} diff --git a/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts b/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts index 90c84470cee..006e5a962b2 100644 --- a/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts +++ b/src/renderer/src/components/native-chat/use-native-chat-toggle-shortcut.ts @@ -58,7 +58,10 @@ export function useNativeChatToggleShortcut(worktreeId: string, isWorktreeActive const tab = (state.unifiedTabsByWorktree[worktreeId] ?? []).find( (candidate) => candidate.id === group.activeTabId ) - if (!tab || tab.contentType !== 'terminal') { + // contentType gates out standalone structured (agent-session) tabs; + // structuredSessionId gates out a terminal tab that adopted one, which + // renders the structured surface with no TUI to switch back to. + if (!tab || tab.contentType !== 'terminal' || tab.structuredSessionId) { return } const terminalTab = (state.tabsByWorktree[worktreeId] ?? []).find( diff --git a/src/renderer/src/components/tab-bar/SortableTab.tsx b/src/renderer/src/components/tab-bar/SortableTab.tsx index cb5966d1e23..8e793f96773 100644 --- a/src/renderer/src/components/tab-bar/SortableTab.tsx +++ b/src/renderer/src/components/tab-bar/SortableTab.tsx @@ -1,4 +1,4 @@ -import { useCallback, useEffect, useRef, useState } from 'react' +import { useCallback, useEffect, useState } from 'react' import { useSortable } from '@dnd-kit/sortable' import { X, Minimize2, Pin } from 'lucide-react' import { stripLeadingAgentTitleDecoration } from '../../../../shared/agent-title-decoration' @@ -17,10 +17,7 @@ import { type DropIndicator } from './drop-indicator' import { preventMiddleButtonDefault } from './middle-button-default-guard' -import { - RENAME_TERMINAL_TAB_EVENT, - type RenameTerminalTabDetail -} from './terminal-tab-rename-request' +import { useSortableTabRename } from './use-sortable-tab-rename' import { SortableTabContextMenu } from './SortableTabContextMenu' import { translate } from '@/i18n/i18n' import { TAB_CONTAINER_WIDTH_CLASSES, TAB_LABEL_WIDTH_CLASSES } from './tab-width-rules' @@ -55,6 +52,12 @@ type SortableTabProps = { dragData: TabDragItemData dropIndicator?: DropIndicator includeTopTabBorder?: boolean + /** True when this agent terminal can switch between the terminal and native chat views; surfaces the "Switch view" context-menu item. */ + canToggleViewMode?: boolean + /** True when the tab is currently showing the native chat view. */ + isChatView?: boolean + /** Toggle the tab between terminal and native chat view. */ + onToggleViewMode?: () => void } export const CLOSE_ALL_CONTEXT_MENUS_EVENT = 'orca-close-all-context-menus' @@ -80,7 +83,10 @@ export default function SortableTab({ onToggleExpand, dragData, dropIndicator, - includeTopTabBorder = true + includeTopTabBorder = true, + canToggleViewMode = false, + isChatView = false, + onToggleViewMode }: SortableTabProps): React.JSX.Element { // Why: agent-completion unread exists even with terminal-attention off; collapse both sources to one primitive so unrelated tabs don't re-render. const hasUnreadActivity = useAppStore((s) => @@ -121,72 +127,23 @@ export default function SortableTab({ // Why: no transform/transition/opacity so tabs stay anchored during drag, only the insertion bar moves (see TabBar.tsx). const [menuOpen, setMenuOpen] = useState(false) const [menuPoint, setMenuPoint] = useState({ x: 0, y: 0 }) - const [isEditing, setIsEditing] = useState(false) + const { + isEditing, + renameValue, + setRenameValue, + handleRenameOpen, + commitRename, + cancelRename, + setRenameInputElement + } = useSortableTabRename({ + tabId: tab.id, + title: tab.title, + customTitle: tab.customTitle, + onSetCustomTitle + }) // Why: a live working/needs-input state is newer than a prior-turn unread, so it owns the icon until the turn ends. const showUnreadActivity = hasUnreadActivity && !isEditing && !isTerminalTabActivityLive(activityStatus) - const [renameValue, setRenameValue] = useState('') - const renameFocusFrameRef = useRef(null) - // Why: onBlur fires during Input unmount; mark rename resolved so it can't re-commit and overwrite discarded edits. - const committedOrCancelledRef = useRef(false) - - const handleRenameOpen = useCallback(() => { - committedOrCancelledRef.current = false - // Why: snapshot title once; don't refresh if tab.title changes mid-edit (e.g. OSC) so the user's edits aren't overwritten. - setRenameValue(tab.customTitle ?? tab.title) - setIsEditing(true) - }, [tab.customTitle, tab.title]) - - const commitRename = useCallback(() => { - if (committedOrCancelledRef.current) { - return - } - committedOrCancelledRef.current = true - const trimmed = renameValue.trim() - onSetCustomTitle(tab.id, trimmed.length > 0 ? trimmed : null) - setIsEditing(false) - }, [renameValue, onSetCustomTitle, tab.id]) - - const cancelRename = useCallback(() => { - committedOrCancelledRef.current = true - setIsEditing(false) - }, []) - - const setRenameInputElement = useCallback((input: HTMLInputElement | null) => { - if (renameFocusFrameRef.current !== null) { - cancelAnimationFrame(renameFocusFrameRef.current) - renameFocusFrameRef.current = null - } - if (!input) { - return - } - // Why: defer past Radix menu teardown/focus restore; key off input mount so title updates don't re-select edited text. - renameFocusFrameRef.current = requestAnimationFrame(() => { - renameFocusFrameRef.current = null - input.focus() - input.select() - }) - }, []) - - // Why the ref: keeps the listener subscribed to tab.id alone, so OSC title churn can't - // resubscribe it mid-edit. Written from an Effect, not in render -- a render React discards - // must not leave a stale handler behind for the next commit to fire. - const handleRenameOpenRef = useRef(handleRenameOpen) - useEffect(() => { - handleRenameOpenRef.current = handleRenameOpen - }, [handleRenameOpen]) - - useEffect(() => { - const onRenameRequest = (event: Event): void => { - const detail = (event as CustomEvent).detail - if (detail?.tabId !== tab.id) { - return - } - handleRenameOpenRef.current() - } - window.addEventListener(RENAME_TERMINAL_TAB_EVENT, onRenameRequest) - return () => window.removeEventListener(RENAME_TERMINAL_TAB_EVENT, onRenameRequest) - }, [tab.id]) useEffect(() => { const closeMenu = (): void => setMenuOpen(false) @@ -439,6 +396,9 @@ export default function SortableTab({ onRenameOpen={handleRenameOpen} onSetTabColor={onSetTabColor} onTogglePin={onTogglePin} + canToggleViewMode={canToggleViewMode} + isChatView={isChatView} + onToggleViewMode={onToggleViewMode} /> ) diff --git a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx index 53b31012d39..5b6caaecb55 100644 --- a/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx +++ b/src/renderer/src/components/tab-bar/SortableTabContextMenu.tsx @@ -1,4 +1,14 @@ -import { PanelLeftClose, PanelRightClose, Pin, PinOff, Pencil, X, ListX } from 'lucide-react' +import { + MessageSquare, + PanelLeftClose, + PanelRightClose, + Pin, + PinOff, + Pencil, + SquareTerminal, + X, + ListX +} from 'lucide-react' import { DropdownMenu, DropdownMenuContent, @@ -97,6 +107,15 @@ type SortableTabContextMenuProps = { onRenameOpen: () => void onSetTabColor: (tabId: string, color: string | null) => void onTogglePin: () => void + /** True when this tab is an agent terminal that can switch between the terminal + * and native chat views; gates the "Switch view" menu item. Structured + * sessions never qualify — they have no terminal underneath. */ + canToggleViewMode?: boolean + /** True when the tab is currently showing the native chat view (drives the + * item's label/icon between "chat" and "terminal"). */ + isChatView?: boolean + /** Toggle the tab between terminal and native chat view. */ + onToggleViewMode?: () => void } export function SortableTabContextMenu({ @@ -118,7 +137,10 @@ export function SortableTabContextMenu({ onCloseToLeft, onRenameOpen, onSetTabColor, - onTogglePin + onTogglePin, + canToggleViewMode = false, + isChatView = false, + onToggleViewMode }: SortableTabContextMenuProps): React.JSX.Element { const keybindings = useAppStore((state) => state.keybindings) const splitRightShortcut = formatShortcutLabel('terminal.splitRight', keybindings) @@ -147,6 +169,27 @@ export function SortableTabContextMenu({ splitRightShortcut={splitRightShortcut} splitDownShortcut={splitDownShortcut} /> + {canToggleViewMode && onToggleViewMode ? ( + <> + + + {isChatView ? ( + + ) : ( + + )} + {isChatView + ? translate( + 'components.tab.bar.SortableTabContextMenu.switchToTerminalView', + 'Switch to terminal view' + ) + : translate( + 'components.tab.bar.SortableTabContextMenu.switchToChatView', + 'Switch to chat view' + )} + + + ) : null} {isPinned ? ( diff --git a/src/renderer/src/components/tab-bar/tab-bar-item-surface.tsx b/src/renderer/src/components/tab-bar/tab-bar-item-surface.tsx index c0af60cf6fc..8167edd4163 100644 --- a/src/renderer/src/components/tab-bar/tab-bar-item-surface.tsx +++ b/src/renderer/src/components/tab-bar/tab-bar-item-surface.tsx @@ -4,6 +4,8 @@ import type { TerminalTab } from '../../../../shared/terminal-tab-types' import type { TuiAgent } from '../../../../shared/tui-agent' import { isAgentSessionHandleProvider } from '../../../../shared/agent-session-provider-handle' import type { OpenFile } from '../../store/slices/editor' +import { canSwitchNativeChatView } from '../native-chat/native-chat-availability' +import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence' import SortableTab from './SortableTab' import EditorFileTab from './EditorFileTab' import BrowserTab from './BrowserTab' @@ -56,7 +58,17 @@ export function renderTabBarItems({ onCloseAllFiles, onMakePreviewFilePermanent } = props - const { resolvedGroupId, generatedTabTitlesEnabled, statusByRelativePath } = runtime + const { + resolvedGroupId, + generatedTabTitlesEnabled, + unifiedTabByVisibleId, + nativeChatEnabled, + tabAgentTypesByTabId, + nativeChatTabWideFallbackUnsafeTabsById, + nativeChatTranscriptIsLocalReadable, + toggleTabViewMode, + statusByRelativePath + } = runtime // A selected client-hosted row covers the pane, so the tab it covers must stop looking active — // the group's own activeTabId never moves for it, and two underlines would show at once. @@ -89,6 +101,26 @@ export function renderTabBarItems({ ...item.data, title: resolveTerminalTabTitle(item.data, generatedTabTitlesEnabled, item.data.title) } + const unifiedTabForItem = unifiedTabByVisibleId.get(item.id) + // Carry the agent *identity* (not just "an agent exists") so the native-chat gate can reject agents like Grok. + const resolvedAgent = + resolveCommittedTitleAgentType(unifiedTabForItem?.label ?? '') ?? + resolveCommittedTitleAgentType(terminalTab.title) + // Key the live-agent lookup by the backing terminal tab id: agent-status pane keys use it, not the unified tab id. + const detectedAgent = tabAgentTypesByTabId[terminalTab.id] ?? null + const tabWideFallbackSafe = nativeChatTabWideFallbackUnsafeTabsById[terminalTab.id] !== true + const canToggleViewMode = + unifiedTabForItem !== undefined && + canSwitchNativeChatView({ + experimentalNativeChatEnabled: nativeChatEnabled, + contentType: 'terminal', + launchAgent: tabWideFallbackSafe ? terminalTab.launchAgent : null, + detectedAgent, + resolvedAgent: tabWideFallbackSafe ? resolvedAgent : null, + nativeChatTranscriptIsLocalReadable, + isChatViewMode: unifiedTabForItem.viewMode === 'chat', + structuredSessionId: unifiedTabForItem.structuredSessionId ?? null + }) return ( toggleTabViewMode(unifiedTabForItem.id) : undefined + } hasTabsToRight={index < items.length - 1} hasTabsToLeft={index > 0} isActive={ diff --git a/src/renderer/src/components/tab-bar/use-sortable-tab-rename.ts b/src/renderer/src/components/tab-bar/use-sortable-tab-rename.ts new file mode 100644 index 00000000000..05b85d226bb --- /dev/null +++ b/src/renderer/src/components/tab-bar/use-sortable-tab-rename.ts @@ -0,0 +1,94 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import { + RENAME_TERMINAL_TAB_EVENT, + type RenameTerminalTabDetail +} from './terminal-tab-rename-request' + +/** Inline tab-title rename: snapshots the title on open so mid-edit OSC churn + * cannot overwrite the user's text, commits at most once, and answers the + * window rename request addressed to this tab. */ +export function useSortableTabRename({ + tabId, + title, + customTitle, + onSetCustomTitle +}: { + tabId: string + title: string + customTitle?: string | null + onSetCustomTitle: (tabId: string, title: string | null) => void +}) { + const [isEditing, setIsEditing] = useState(false) + const [renameValue, setRenameValue] = useState('') + const renameFocusFrameRef = useRef(null) + // Why: onBlur fires during Input unmount; mark rename resolved so it can't re-commit and overwrite discarded edits. + const committedOrCancelledRef = useRef(false) + + const handleRenameOpen = useCallback(() => { + committedOrCancelledRef.current = false + // Why: snapshot title once; don't refresh if tab.title changes mid-edit (e.g. OSC) so the user's edits aren't overwritten. + setRenameValue(customTitle ?? title) + setIsEditing(true) + }, [customTitle, title]) + + const commitRename = useCallback(() => { + if (committedOrCancelledRef.current) { + return + } + committedOrCancelledRef.current = true + const trimmed = renameValue.trim() + onSetCustomTitle(tabId, trimmed.length > 0 ? trimmed : null) + setIsEditing(false) + }, [renameValue, onSetCustomTitle, tabId]) + + const cancelRename = useCallback(() => { + committedOrCancelledRef.current = true + setIsEditing(false) + }, []) + + const setRenameInputElement = useCallback((input: HTMLInputElement | null) => { + if (renameFocusFrameRef.current !== null) { + cancelAnimationFrame(renameFocusFrameRef.current) + renameFocusFrameRef.current = null + } + if (!input) { + return + } + // Why: defer past Radix menu teardown/focus restore; key off input mount so title updates don't re-select edited text. + renameFocusFrameRef.current = requestAnimationFrame(() => { + renameFocusFrameRef.current = null + input.focus() + input.select() + }) + }, []) + + // Why the ref: keeps the listener subscribed to tabId alone, so OSC title churn can't + // resubscribe it mid-edit. Written from an Effect, not in render -- a render React discards + // must not leave a stale handler behind for the next commit to fire. + const handleRenameOpenRef = useRef(handleRenameOpen) + useEffect(() => { + handleRenameOpenRef.current = handleRenameOpen + }, [handleRenameOpen]) + + useEffect(() => { + const onRenameRequest = (event: Event): void => { + const detail = (event as CustomEvent).detail + if (detail?.tabId !== tabId) { + return + } + handleRenameOpenRef.current() + } + window.addEventListener(RENAME_TERMINAL_TAB_EVENT, onRenameRequest) + return () => window.removeEventListener(RENAME_TERMINAL_TAB_EVENT, onRenameRequest) + }, [tabId]) + + return { + isEditing, + renameValue, + setRenameValue, + handleRenameOpen, + commitRename, + cancelRename, + setRenameInputElement + } +} diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx index d8dc54c954b..58a39f28a50 100644 --- a/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.test.tsx @@ -77,6 +77,9 @@ function renderMenu(overrides: Record = {}): string { canContinueAgentSessionInNewSession: false, onContinueAgentSessionInNewSession: vi.fn(), onForkAgentSession: vi.fn(), + canToggleNativeChat: false, + isNativeChatView: false, + onToggleNativeChat: vi.fn(), onCopyAgentSessionContext: vi.fn(), quickCommandHosts: [ { hostId: 'local' as const, label: 'Local Linux', repoCommands: [], globalCommands: [] } diff --git a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx index 8311fb3df9d..2cc76cd7164 100644 --- a/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalContextMenu.tsx @@ -6,11 +6,13 @@ import { Eraser, GitFork, Maximize2, + MessageSquare, Minimize2, PanelBottomClose, PanelsTopLeft, PanelRightClose, Pencil, + SquareTerminal, TextSelect, X } from 'lucide-react' @@ -28,6 +30,7 @@ import type { ExecutionHostId } from '../../../../shared/execution-host' import { formatPrimaryShortcutLabel } from '@/hooks/useShortcutLabel' import type { KeybindingOverrides } from '../../../../shared/keybindings' import { translate } from '@/i18n/i18n' +import { isMacPlatform, nativeChatToggleShortcutLabel } from '../native-chat/native-chat-shortcut' import { AgentSessionContinuationMenuItem } from './AgentSessionContinuationMenuItem' import type { TerminalQuickCommandMenuHost } from '@/hooks/use-terminal-quick-command-hosts' import { TerminalQuickCommandsSubmenu } from './TerminalQuickCommandsSubmenu' @@ -53,6 +56,11 @@ type TerminalContextMenuProps = { canContinueAgentSessionInNewSession: boolean onContinueAgentSessionInNewSession: () => void onForkAgentSession: () => void + /** True when this pane may switch between the terminal and native chat views. + * Structured sessions are excluded — they have no terminal underneath. */ + canToggleNativeChat: boolean + isNativeChatView: boolean + onToggleNativeChat: () => void onCopyAgentSessionContext: () => void quickCommandHosts: TerminalQuickCommandMenuHost[] quickCommandHostLoadFailed: boolean @@ -91,6 +99,9 @@ export default function TerminalContextMenu({ canContinueAgentSessionInNewSession, onContinueAgentSessionInNewSession, onForkAgentSession, + canToggleNativeChat, + isNativeChatView, + onToggleNativeChat, onCopyAgentSessionContext, quickCommandHosts, quickCommandHostLoadFailed, @@ -119,7 +130,8 @@ export default function TerminalContextMenu({ expand: formatPrimaryShortcutLabel('terminal.expandPane', keybindings), setTitle: formatPrimaryShortcutLabel('terminal.setTitle', keybindings), clearPaneTitle: formatPrimaryShortcutLabel('terminal.clearPaneTitle', keybindings), - close: formatPrimaryShortcutLabel('terminal.closePane', keybindings) + close: formatPrimaryShortcutLabel('terminal.closePane', keybindings), + nativeChat: nativeChatToggleShortcutLabel(isMacPlatform()) }), [keybindings] ) @@ -209,6 +221,21 @@ export default function TerminalContextMenu({ 'Copy Context' )} + {canToggleNativeChat ? ( + + {isNativeChatView ? : } + {isNativeChatView + ? translate( + 'components.tab.bar.SortableTabContextMenu.switchToTerminalView', + 'Switch to terminal view' + ) + : translate( + 'components.tab.bar.SortableTabContextMenu.switchToChatView', + 'Switch to chat view' + )} + {shortcuts.nativeChat} + + ) : null} diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx index 6b246701c43..6256bc64c07 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneHeaderOverlay.tsx @@ -1,5 +1,11 @@ import type { CSSProperties, RefObject } from 'react' -import { MessageSquarePlus, SquareSplitVertical, X } from 'lucide-react' +import { + MessageSquare, + MessageSquarePlus, + SquareSplitVertical, + SquareTerminal, + X +} from 'lucide-react' import type { ManagedPane, PaneManager } from '@/lib/pane-manager/pane-manager' import { Button } from '@/components/ui/button' import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip' @@ -36,6 +42,16 @@ type TerminalPaneHeaderOverlayProps = { hiddenStartupStyle: CSSProperties managerRef: RefObject paneTransportsRef: RefObject> + /** When true, this pane can switch between the terminal and the native chat + * view; renders a chat/terminal toggle as the first button in the pane header + * actions row (beside split/close). The caller gates it to the active pane to + * avoid duplicating it across splits, and to bridge chat only — a structured + * session has no terminal underneath to switch to. */ + canToggleNativeChat?: boolean + /** True when the active pane is currently showing the native chat view. */ + isChatViewMode?: boolean + /** Flip the active pane between the terminal and the native chat view. */ + onToggleNativeChat?: () => void canContinueAgentSessionInNewSession?: boolean onContinueAgentSessionInNewSession?: (pane: ManagedPane) => void onSplitPane: (pane: ManagedPane, direction: 'vertical' | 'horizontal') => void @@ -71,6 +87,9 @@ export default function TerminalPaneHeaderOverlay({ hiddenStartupStyle, managerRef, paneTransportsRef, + canToggleNativeChat, + isChatViewMode, + onToggleNativeChat, canContinueAgentSessionInNewSession, onContinueAgentSessionInNewSession, onSplitPane, @@ -255,6 +274,47 @@ export default function TerminalPaneHeaderOverlay({ ) : null} + {canToggleNativeChat && isActivePane ? ( + + + + + + {isChatViewMode + ? translate('components.native-chat.toggle.showTerminal', 'Show terminal') + : translate('components.native-chat.toggle.showChat', 'Show chat view')} + + + ) : null} {showAlwaysOnHeaders && showSplitButton ? ( diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx index 79d0f42a14e..2cb20783f53 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneOverlayLayer.tsx @@ -8,6 +8,7 @@ import { type ActivityTerminalPortalTarget } from '../activity/activity-terminal-portal' import { shouldMountBackgroundWorktreeTab } from '../terminal/background-terminal-worktree-mount' +import { useNativeChatToggleShortcut } from '../native-chat/use-native-chat-toggle-shortcut' import { TerminalOverlaySlot } from './TerminalOverlaySlot' import { useTerminalTabColdParking } from './use-terminal-tab-cold-parking' @@ -59,6 +60,8 @@ const TerminalPaneOverlayLayer = memo(function TerminalPaneOverlayLayer({ const setActiveWorktree = useAppStore((state) => state.setActiveWorktree) const reconcileWorktreeTabModel = useAppStore((state) => state.reconcileWorktreeTabModel) + useNativeChatToggleShortcut(worktreeId, isWorktreeActive) + const leaveWorktreeIfEmpty = useCallback(() => { const state = useAppStore.getState() if (state.activeWorktreeId !== worktreeId) { diff --git a/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx b/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx index fc33cab3f18..1773aa48d20 100644 --- a/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx +++ b/src/renderer/src/components/terminal-pane/TerminalPaneSurface.tsx @@ -32,6 +32,8 @@ export function TerminalPaneSurface({ const { activePane, activePaneCanContinueInNewSession, + activePaneCanToggleChat, + activePaneIsChatLeaf, activatePaneTitleInteraction, agentSessionContinuation, agentSessionFork, @@ -39,6 +41,8 @@ export function TerminalPaneSurface({ closeTerminalLinkActions, contextMenu, contextMenuCanContinueInNewSession, + contextMenuCanToggleChat, + contextMenuIsChatView, cwd, daemonActions, dismissTerminalError, @@ -46,6 +50,7 @@ export function TerminalPaneSurface({ expandedPaneId, handleCancelClose, handleConfirmClose, + handleContextMenuToggleNativeChat, handlePrimarySelectionAuxClick, handlePrimarySelectionMiddleMouseDown, handleRemoveTitle, @@ -54,6 +59,7 @@ export function TerminalPaneSurface({ handleRenameSubmit, handleRequestClosePane, handleStartRename, + handleToggleNativeChat, hiddenStartupStyle, isActive, keybindings, @@ -231,6 +237,9 @@ export function TerminalPaneSurface({ canContinueAgentSessionInNewSession={contextMenuCanContinueInNewSession} onContinueAgentSessionInNewSession={contextMenu.onContinueAgentSessionInNewSession} onForkAgentSession={() => void contextMenu.onForkAgentSession()} + canToggleNativeChat={contextMenuCanToggleChat} + isNativeChatView={contextMenuIsChatView} + onToggleNativeChat={handleContextMenuToggleNativeChat} onCopyAgentSessionContext={() => void contextMenu.onCopyAgentSessionContext()} quickCommandHosts={visibleQuickCommandHosts} quickCommandHostLoadFailed={quickCommandHostLoadFailed} @@ -303,6 +312,9 @@ export function TerminalPaneSurface({ hiddenStartupStyle={hiddenStartupStyle} managerRef={managerRef} paneTransportsRef={paneTransportsRef} + canToggleNativeChat={activePaneCanToggleChat} + isChatViewMode={activePaneIsChatLeaf} + onToggleNativeChat={handleToggleNativeChat} canContinueAgentSessionInNewSession={activePaneCanContinueInNewSession} onContinueAgentSessionInNewSession={(pane) => contextMenu.runForPane(pane.id, contextMenu.onContinueAgentSessionInNewSession) diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-hook-order-parity.test.ts b/src/renderer/src/components/terminal-pane/terminal-pane-hook-order-parity.test.ts index 6151594ff83..a6b96168047 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-hook-order-parity.test.ts +++ b/src/renderer/src/components/terminal-pane/terminal-pane-hook-order-parity.test.ts @@ -11,8 +11,11 @@ const TERMINAL_PANE_HOOK_SOURCE_PATTERN = // split-cwd changes; the pane session-ID projection added one render hook (230 hooks). // Then 27 stable-action `useAppStore` subscriptions folded into four // `useTerminalPaneStoreActions()` calls, each one `useMemo` (204 hooks, 8 useMemo). +// Restoring the terminal/chat switcher added four `useCallback`s -- three in +// chat-state (can-toggle, toggle-for-leaf, toggle-active) and the context-menu +// toggle in projection (208 hooks, still 8 useMemo). const PRE_REFACTOR_HOOK_ORDER_SHA256 = - 'b541d26ff66a1db0b7150ced2a4340c5cf025aba79a651a2ad8d2fa68d403ae3' + '983ad067c9feca82c5435eb1b865674344489c368ec2007dc7bb40c81aef037c' const sourceFiles = readdirSync(__dirname) .filter((name) => TERMINAL_PANE_HOOK_SOURCE_PATTERN.test(name)) @@ -77,7 +80,7 @@ function readFlattenedHookOrder(): string[] { describe('TerminalPane refactor hook parity', () => { it('preserves the recursively flattened render hook order', () => { const hooks = readFlattenedHookOrder() - expect(hooks).toHaveLength(204) + expect(hooks).toHaveLength(208) expect(hooks.filter((hook) => hook === 'useMemo')).toHaveLength(8) expect(createHash('sha256').update(hooks.join('\n')).digest('hex')).toBe( PRE_REFACTOR_HOOK_ORDER_SHA256 diff --git a/src/renderer/src/components/terminal-pane/terminal-pane-store-subscription-budget.test.tsx b/src/renderer/src/components/terminal-pane/terminal-pane-store-subscription-budget.test.tsx index 9562c46c772..2418a5da6d3 100644 --- a/src/renderer/src/components/terminal-pane/terminal-pane-store-subscription-budget.test.tsx +++ b/src/renderer/src/components/terminal-pane/terminal-pane-store-subscription-budget.test.tsx @@ -4,8 +4,8 @@ * synchronously on every publication, so the per-pane subscription count is a * direct multiplier on agent-status burn (docs/reference/renderer-agent-status-performance.md). * - * On `main` one mounted pane opened 48 listeners; 31 of them selected values that - * can never change — 27 store actions and 4 duplicate reads of one unified tab. + * On `main` one mounted pane opened 49 listeners; 32 of them selected values that + * can never change — 28 store actions and 4 duplicate reads of one unified tab. */ import { act, createRef, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' @@ -27,7 +27,7 @@ import { */ const TERMINAL_PANE_LISTENER_BUDGET = 17 /** What the same mount cost before the stable-action and unified-tab folds. */ -const PRE_FOLD_LISTENERS_PER_PANE = 48 +const PRE_FOLD_LISTENERS_PER_PANE = 49 const originalState = useAppStore.getState() @@ -103,7 +103,7 @@ describe('TerminalPane store subscription budget', () => { expect(perPane).toBe(TERMINAL_PANE_LISTENER_BUDGET) expect(perPane).toBeLessThan(PRE_FOLD_LISTENERS_PER_PANE) - // 27 stable actions plus four duplicate unified-tab reads. + // 28 stable actions plus four duplicate unified-tab reads. expect(PRE_FOLD_LISTENERS_PER_PANE - perPane).toBe(TERMINAL_PANE_STORE_ACTION_KEYS.length + 4) unmount() diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-chat-state.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-chat-state.ts index 92bc444e299..2fd75f3f093 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-chat-state.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-chat-state.ts @@ -37,7 +37,8 @@ export function useTerminalPaneChatState(controller: TerminalPaneTitleController setTabCanExpandPane, setTabPaneExpanded, setTabViewMode, - suppressPtyExit + suppressPtyExit, + toggleTabViewMode } = useTerminalPaneStoreActions() const pendingCodexPaneRestartIds = useAppStore((store) => store.pendingCodexPaneRestartIds) // Why one selector: five separate subscriptions each re-read the same unified @@ -207,6 +208,50 @@ export function useTerminalPaneChatState(controller: TerminalPaneTitleController onAgentExitedRef.current = handleConfirmedAgentExit // oxlint-disable-next-line react-hooks/exhaustive-deps -- Preserve the pre-split dependency contract. }, [handleConfirmedAgentExit]) + const canToggleChatForLeaf = useCallback( + (leafId: string | null): boolean => { + // A structured session renders its own transcript with no TUI beneath it, + // so the switcher stays off for it while bridge chat keeps it. + if (structuredSessionId) { + return false + } + // Scope the "always allow toggling back" rule to the leaf showing chat; must not make an unsupported sibling look eligible. + const isChatViewForLeaf = effectiveChatViewMode && leafId !== null && chatLeafId === leafId + return (nativeChatEnabled && isChatViewForLeaf) || isChatEligibleForLeaf(leafId) + }, + [ + chatLeafId, + effectiveChatViewMode, + isChatEligibleForLeaf, + nativeChatEnabled, + structuredSessionId + ] + ) + const toggleNativeChatForLeaf = useCallback( + (leafId: string) => { + if (!unifiedTabId) { + return + } + if (effectiveChatViewMode && chatLeafId === leafId) { + setChatLeafId(null) + toggleTabViewMode(unifiedTabId) + return + } + setChatLeafId(leafId) + if (!effectiveChatViewMode) { + toggleTabViewMode(unifiedTabId) + } + }, + [chatLeafId, effectiveChatViewMode, setChatLeafId, toggleTabViewMode, unifiedTabId] + ) + const handleToggleNativeChat = useCallback(() => { + const activeLeafId = managerRef.current?.getActivePane()?.leafId ?? null + if (!activeLeafId) { + return + } + toggleNativeChatForLeaf(activeLeafId) + // oxlint-disable-next-line react-hooks/exhaustive-deps -- managerRef is a stable ref container. + }, [toggleNativeChatForLeaf]) const switchNativeChatToTerminal = useCallback(() => { if (chatLeafId && unifiedTabId) { setChatLeafId(null) @@ -249,6 +294,9 @@ export function useTerminalPaneChatState(controller: TerminalPaneTitleController getTabWideAgentHintLeafIdRef, resolveTitleAgentForLeaf, isChatEligibleForLeaf, + canToggleChatForLeaf, + toggleNativeChatForLeaf, + handleToggleNativeChat, applyNativeChatLeafRoute, switchNativeChatToTerminal, readNativeChatTerminalScreen diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-projection.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-projection.ts index ea179b6925e..7ef23834873 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-projection.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-projection.ts @@ -1,4 +1,4 @@ -import { useEffect, useMemo } from 'react' +import { useCallback, useEffect, useMemo } from 'react' import type { CSSProperties } from 'react' import { DEFAULT_TERMINAL_DIVIDER_DARK, @@ -23,10 +23,13 @@ import { resolvePaneAgentSessionId } from './pane-agent-session-id' export function useTerminalPaneProjection(controller: TerminalPaneMobileController) { const { applyNativeChatLeafRoute, + canToggleChatForLeaf, chatLeafId, chatPaneDispatchStatus, contextMenu, contextMenuLeafId, + effectiveChatViewMode, + getContextMenuLeafId, getNativeChatLeafIds, getTabWideAgentHintLeafId, isActive, @@ -34,6 +37,7 @@ export function useTerminalPaneProjection(controller: TerminalPaneMobileControll isChatViewMode, isVisible, managerRef, + toggleNativeChatForLeaf, paneTitles, paneTransportsRef, resolveTitleAgentForLeaf, @@ -177,6 +181,17 @@ export function useTerminalPaneProjection(controller: TerminalPaneMobileControll const contextMenuCanContinueInNewSession = canContinueAgentSessionInNewSession( resolveAgentForLeaf(contextMenuLeafId) ) + // Each switcher gates on its own leaf (header=active, menu=opened-over), so mixed splits show it only where chat can render. + const activePaneCanToggleChat = canToggleChatForLeaf(activePane?.leafId ?? null) + const contextMenuCanToggleChat = canToggleChatForLeaf(contextMenuLeafId) + const contextMenuIsChatView = effectiveChatViewMode && contextMenuLeafId === chatLeafId + const handleContextMenuToggleNativeChat = useCallback(() => { + const leafId = getContextMenuLeafId() + if (!leafId) { + return + } + toggleNativeChatForLeaf(leafId) + }, [getContextMenuLeafId, toggleNativeChatForLeaf]) return { effectiveAppearance, terminalBackground, @@ -204,7 +219,11 @@ export function useTerminalPaneProjection(controller: TerminalPaneMobileControll activePaneIsChatLeaf, resolveAgentForLeaf, activePaneCanContinueInNewSession, - contextMenuCanContinueInNewSession + contextMenuCanContinueInNewSession, + activePaneCanToggleChat, + contextMenuCanToggleChat, + contextMenuIsChatView, + handleContextMenuToggleNativeChat } } diff --git a/src/renderer/src/components/terminal-pane/use-terminal-pane-store-actions.ts b/src/renderer/src/components/terminal-pane/use-terminal-pane-store-actions.ts index 5eb20c1559d..02c58e96049 100644 --- a/src/renderer/src/components/terminal-pane/use-terminal-pane-store-actions.ts +++ b/src/renderer/src/components/terminal-pane/use-terminal-pane-store-actions.ts @@ -37,6 +37,7 @@ export function useTerminalPaneStoreActions() { setTabLayout: state.setTabLayout, setTabPaneExpanded: state.setTabPaneExpanded, setTabViewMode: state.setTabViewMode, + toggleTabViewMode: state.toggleTabViewMode, suppressPtyExit: state.suppressPtyExit, updateSettings: state.updateSettings, updateTabPtyId: state.updateTabPtyId, @@ -72,6 +73,7 @@ export const TERMINAL_PANE_STORE_ACTION_KEYS = [ 'setTabLayout', 'setTabPaneExpanded', 'setTabViewMode', + 'toggleTabViewMode', 'suppressPtyExit', 'updateSettings', 'updateTabPtyId',