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.
This commit is contained in:
Merge Sim
2026-09-03 15:43:03 -07:00
parent 7d27c841b4
commit 5ff95ea862
16 changed files with 411 additions and 84 deletions
@@ -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)
}
@@ -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(
@@ -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<number | null>(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<RenameTerminalTabDetail | undefined>).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}
/>
</>
)
@@ -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 ? (
<>
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onToggleViewMode}>
{isChatView ? (
<SquareTerminal className="size-3.5 shrink-0" />
) : (
<MessageSquare className="size-3.5 shrink-0" />
)}
{isChatView
? translate(
'components.tab.bar.SortableTabContextMenu.switchToTerminalView',
'Switch to terminal view'
)
: translate(
'components.tab.bar.SortableTabContextMenu.switchToChatView',
'Switch to chat view'
)}
</DropdownMenuItem>
</>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={onTogglePin}>
{isPinned ? (
@@ -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 (
<SortableTab
key={item.id}
@@ -96,6 +128,11 @@ export function renderTabBarItems({
unifiedTabId={item.unifiedTabId}
groupId={resolvedGroupId}
tabCount={items.length}
canToggleViewMode={canToggleViewMode}
isChatView={nativeChatEnabled && unifiedTabForItem?.viewMode === 'chat'}
onToggleViewMode={
unifiedTabForItem ? () => toggleTabViewMode(unifiedTabForItem.id) : undefined
}
hasTabsToRight={index < items.length - 1}
hasTabsToLeft={index > 0}
isActive={
@@ -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<number | null>(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<RenameTerminalTabDetail | undefined>).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
}
}
@@ -77,6 +77,9 @@ function renderMenu(overrides: Record<string, unknown> = {}): 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: [] }
@@ -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'
)}
</DropdownMenuItem>
{canToggleNativeChat ? (
<DropdownMenuItem onSelect={onToggleNativeChat}>
{isNativeChatView ? <SquareTerminal /> : <MessageSquare />}
{isNativeChatView
? translate(
'components.tab.bar.SortableTabContextMenu.switchToTerminalView',
'Switch to terminal view'
)
: translate(
'components.tab.bar.SortableTabContextMenu.switchToChatView',
'Switch to chat view'
)}
<DropdownMenuShortcut>{shortcuts.nativeChat}</DropdownMenuShortcut>
</DropdownMenuItem>
) : null}
<DropdownMenuSeparator />
<DropdownMenuItem className="whitespace-nowrap" onSelect={onSplitRight}>
<PanelRightClose />
@@ -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<PaneManager | null>
paneTransportsRef: RefObject<Map<number, PtyTransport>>
/** 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({
</TooltipContent>
</Tooltip>
) : null}
{canToggleNativeChat && isActivePane ? (
<Tooltip>
<TooltipTrigger asChild>
<Button
type="button"
variant="ghost"
size="icon-xs"
// Same class as split so it shares the hover/active reveal
// and sits as a peer in the [chat][split][×] cluster.
className="pane-title-split-trigger"
aria-label={
isChatViewMode
? translate(
'components.native-chat.toggle.showTerminal',
'Show terminal'
)
: translate(
'components.native-chat.toggle.showChat',
'Show chat view'
)
}
aria-pressed={isChatViewMode}
onClick={(event) => {
event.stopPropagation()
onToggleNativeChat?.()
}}
>
{isChatViewMode ? (
<SquareTerminal className="size-3" />
) : (
<MessageSquare className="size-3" />
)}
</Button>
</TooltipTrigger>
<TooltipContent side="bottom" sideOffset={4}>
{isChatViewMode
? translate('components.native-chat.toggle.showTerminal', 'Show terminal')
: translate('components.native-chat.toggle.showChat', 'Show chat view')}
</TooltipContent>
</Tooltip>
) : null}
{showAlwaysOnHeaders && showSplitButton ? (
<Tooltip>
<TooltipTrigger asChild>
@@ -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) {
@@ -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)
@@ -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
@@ -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()
@@ -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
@@ -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
}
}
@@ -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',