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',