fix(native-chat): target native chat to the active split leaf

This commit is contained in:
Jinjing
2026-07-13 06:54:59 -07:00
parent 7b3103f890
commit fcdfb70275
8 changed files with 507 additions and 60 deletions
@@ -0,0 +1,172 @@
import { describe, expect, it } from 'vitest'
import {
nativeChatLaunchAgentForLeaf,
resolveNativeChatLeafRoute
} from './native-chat-leaf-routing'
describe('nativeChatLaunchAgentForLeaf', () => {
it('uses the tab launch hint only for its sole leaf', () => {
expect(
nativeChatLaunchAgentForLeaf({
launchAgent: 'claude',
launchAgentLeafId: 'leaf-a',
leafId: 'leaf-a',
leafIds: ['leaf-a']
})
).toBe('claude')
expect(
nativeChatLaunchAgentForLeaf({
launchAgent: 'claude',
launchAgentLeafId: 'leaf-a',
leafId: 'leaf-b',
leafIds: ['leaf-a']
})
).toBeNull()
expect(
nativeChatLaunchAgentForLeaf({
launchAgent: 'claude',
launchAgentLeafId: 'leaf-a',
leafId: 'leaf-a',
leafIds: []
})
).toBeNull()
})
it('does not lend the original launch agent to either leaf of a mixed split', () => {
const leafIds = ['agent-leaf', 'shell-leaf']
expect(
nativeChatLaunchAgentForLeaf({
launchAgent: 'codex',
launchAgentLeafId: 'agent-leaf',
leafId: 'agent-leaf',
leafIds
})
).toBeNull()
expect(
nativeChatLaunchAgentForLeaf({
launchAgent: 'codex',
launchAgentLeafId: 'agent-leaf',
leafId: 'shell-leaf',
leafIds
})
).toBeNull()
})
it('does not transfer the launch hint when the original leaf closes', () => {
expect(
nativeChatLaunchAgentForLeaf({
launchAgent: 'codex',
launchAgentLeafId: 'closed-agent-leaf',
leafId: 'remaining-shell-leaf',
leafIds: ['remaining-shell-leaf']
})
).toBeNull()
})
})
describe('resolveNativeChatLeafRoute', () => {
it('keeps chat attached to its eligible leaf when focus moves to a shell sibling', () => {
expect(
resolveNativeChatLeafRoute({
isChatViewMode: true,
chatLeafId: 'agent-leaf',
activeLeafId: 'shell-leaf',
chatLeafStillMounted: true,
chatLeafIsEligible: true,
activeLeafIsEligible: false
})
).toEqual({ chatLeafId: 'agent-leaf', exitChat: false })
})
it('moves chat to an eligible active sibling after its leaf closes', () => {
expect(
resolveNativeChatLeafRoute({
isChatViewMode: true,
chatLeafId: 'closed-leaf',
activeLeafId: 'agent-sibling',
chatLeafStillMounted: false,
chatLeafIsEligible: false,
activeLeafIsEligible: true
})
).toEqual({ chatLeafId: 'agent-sibling', exitChat: false })
})
it('moves chat to an eligible active sibling when its mounted leaf becomes ineligible', () => {
expect(
resolveNativeChatLeafRoute({
isChatViewMode: true,
chatLeafId: 'stopped-agent',
activeLeafId: 'agent-sibling',
chatLeafStillMounted: true,
chatLeafIsEligible: false,
activeLeafIsEligible: true
})
).toEqual({ chatLeafId: 'agent-sibling', exitChat: false })
})
it('exits chat rather than inheriting an active shell after close', () => {
expect(
resolveNativeChatLeafRoute({
isChatViewMode: true,
chatLeafId: 'closed-agent',
activeLeafId: 'shell-leaf',
chatLeafStillMounted: false,
chatLeafIsEligible: false,
activeLeafIsEligible: false
})
).toEqual({ chatLeafId: null, exitChat: true })
})
it('exits chat when its leaf becomes ineligible and the active leaf is a shell', () => {
expect(
resolveNativeChatLeafRoute({
isChatViewMode: true,
chatLeafId: 'stopped-agent',
activeLeafId: 'shell-leaf',
chatLeafStillMounted: true,
chatLeafIsEligible: false,
activeLeafIsEligible: false
})
).toEqual({ chatLeafId: null, exitChat: true })
})
it('attaches a tab-level chat request to the eligible active leaf', () => {
expect(
resolveNativeChatLeafRoute({
isChatViewMode: true,
chatLeafId: null,
activeLeafId: 'active-agent',
chatLeafStillMounted: false,
chatLeafIsEligible: false,
activeLeafIsEligible: true
})
).toEqual({ chatLeafId: 'active-agent', exitChat: false })
})
it('waits through manager hydration when there is no concrete active leaf', () => {
expect(
resolveNativeChatLeafRoute({
isChatViewMode: true,
chatLeafId: 'restored-agent',
activeLeafId: null,
chatLeafStillMounted: false,
chatLeafIsEligible: false,
activeLeafIsEligible: false
})
).toEqual({ chatLeafId: 'restored-agent', exitChat: false })
})
it('clears leaf ownership after returning to terminal view', () => {
expect(
resolveNativeChatLeafRoute({
isChatViewMode: false,
chatLeafId: 'agent-leaf',
activeLeafId: 'agent-leaf',
chatLeafStillMounted: true,
chatLeafIsEligible: true,
activeLeafIsEligible: true
})
).toEqual({ chatLeafId: null, exitChat: false })
})
})
@@ -0,0 +1,50 @@
import type { TuiAgent } from '../../../../shared/types'
export function nativeChatLaunchAgentForLeaf(args: {
launchAgent?: TuiAgent | null
launchAgentLeafId: string | null
leafId: string | null
leafIds: readonly string[]
}): TuiAgent | null {
const { launchAgent, launchAgentLeafId, leafId, leafIds } = args
if (!launchAgent || !launchAgentLeafId || !leafId) {
return null
}
// Why: launchAgent belongs to the tab's original pane. Once a split exists,
// it is not evidence that an agent is running in any particular sibling.
return leafIds.length === 1 && leafIds[0] === leafId && launchAgentLeafId === leafId
? launchAgent
: null
}
export type NativeChatLeafRoute = {
chatLeafId: string | null
exitChat: boolean
}
export function resolveNativeChatLeafRoute(args: {
isChatViewMode: boolean
chatLeafId: string | null
activeLeafId: string | null
chatLeafStillMounted: boolean
chatLeafIsEligible: boolean
activeLeafIsEligible: boolean
}): NativeChatLeafRoute {
if (!args.isChatViewMode) {
return { chatLeafId: null, exitChat: false }
}
if (args.chatLeafId && args.chatLeafStillMounted && args.chatLeafIsEligible) {
return { chatLeafId: args.chatLeafId, exitChat: false }
}
// Manager hydration can briefly have no active pane; preserve the requested
// mode until a concrete leaf exists instead of toggling it off during mount.
if (!args.activeLeafId) {
return { chatLeafId: args.chatLeafId, exitChat: false }
}
if (args.activeLeafIsEligible) {
return { chatLeafId: args.activeLeafId, exitChat: false }
}
// Why: closing or invalidating the chat-owning leaf must not move its composer
// onto a plain-shell sibling. Return the tab to terminal mode instead.
return { chatLeafId: null, exitChat: true }
}
@@ -43,6 +43,20 @@ describe('resolveNativeChatToggleShortcutDetectedAgent', () => {
})
).toBe('claude')
})
it('does not inherit a split sibling before the active leaf is known', () => {
expect(
resolveNativeChatToggleShortcutDetectedAgent({
terminalTabId: 'tab-1',
activeLeafId: null,
agentStatusByPaneKey: {
'tab-1:agent-leaf': { agentType: 'claude' },
'tab-1:shell-leaf': {}
},
allowTabFallback: false
})
).toBeNull()
})
})
describe('isNativeChatShortcutTitleFallbackSafe', () => {
@@ -17,15 +17,20 @@ export function isNativeChatShortcutTitleFallbackSafe(
export function resolveNativeChatToggleShortcutDetectedAgent({
terminalTabId,
activeLeafId,
agentStatusByPaneKey
agentStatusByPaneKey,
allowTabFallback = true
}: {
terminalTabId: string
activeLeafId: string | null
agentStatusByPaneKey: Record<string, { agentType?: AgentType }>
allowTabFallback?: boolean
}): AgentType | null {
if (activeLeafId) {
return agentStatusByPaneKey[`${terminalTabId}:${activeLeafId}`]?.agentType ?? null
}
if (!allowTabFallback) {
return null
}
return (
Object.entries(agentStatusByPaneKey).find(([paneKey]) =>
paneKey.startsWith(`${terminalTabId}:`)
@@ -68,12 +73,14 @@ export function useNativeChatToggleShortcut(worktreeId: string, isWorktreeActive
// the unified tab id.
const terminalLayout = state.terminalLayoutsByTabId[tab.entityId]
const activeLeafId = terminalLayout?.activeLeafId ?? null
const tabWideFallbackSafe = isNativeChatShortcutTitleFallbackSafe(terminalLayout?.root)
const detectedAgent = resolveNativeChatToggleShortcutDetectedAgent({
terminalTabId: tab.entityId,
activeLeafId,
agentStatusByPaneKey: state.agentStatusByPaneKey
agentStatusByPaneKey: state.agentStatusByPaneKey,
allowTabFallback: tabWideFallbackSafe
})
const titleFallbackAgent = isNativeChatShortcutTitleFallbackSafe(terminalLayout?.root)
const titleFallbackAgent = tabWideFallbackSafe
? (resolveCommittedTitleAgentType(tab.label ?? '') ??
(terminalTab ? resolveCommittedTitleAgentType(terminalTab.title) : null))
: null
@@ -81,7 +88,7 @@ export function useNativeChatToggleShortcut(worktreeId: string, isWorktreeActive
!canToggleNativeChat({
experimentalNativeChatEnabled: state.settings?.experimentalNativeChat === true,
contentType: 'terminal',
launchAgent: detectedAgent ? null : terminalTab?.launchAgent,
launchAgent: detectedAgent || !tabWideFallbackSafe ? null : terminalTab?.launchAgent,
detectedAgent,
resolvedAgent: detectedAgent ? null : titleFallbackAgent,
nativeChatTranscriptIsLocalReadable: isNativeChatTranscriptLocalReadable(
+15 -7
View File
@@ -81,7 +81,10 @@ import { useTabStripDragScrollHandlers } from './tab-strip-drag-scroll'
import { shouldShowWindowsShellMenu } from './windows-shell-menu-visibility'
import { canToggleNativeChat } from '../native-chat/native-chat-availability'
import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability'
import { selectTabAgentTypesByTabId } from './tab-agent-types-by-tab-id'
import {
selectSplitTerminalTabsById,
selectTabAgentTypesByTabId
} from './tab-agent-types-by-tab-id'
import { resolveCommittedTitleAgentType } from '@/lib/pane-agent-evidence'
const isWindows = navigator.userAgent.includes('Windows')
@@ -449,16 +452,20 @@ function TabBarInner({
[unifiedTabs]
)
// Why: gate the tab long-press view-mode toggle to agent terminals. A tab is
// eligible when it launched an agent or has a live agent-status entry on any of
// its panes (paneKey = `${unifiedTabId}:…`), mirroring the toggle button's gate.
// Why: gate the tab long-press view-mode toggle to the agent in its active leaf.
// Tab-wide launch/title hints are safe only before the terminal is split.
const toggleTabViewMode = useAppStore((s) => s.toggleTabViewMode)
// Why: the strip only needs each tab's stable agent identity, but the whole
// agentStatusByPaneKey map churns on every working↔idle transition app-wide.
// Select a shallow-stable { tabId: agentType } projection so the strip
// re-renders only when a tab gains/loses/changes its agent, not on status flips.
const tabAgentTypesByTabId = useAppStore(
useShallow((s) => selectTabAgentTypesByTabId(s.agentStatusByPaneKey ?? {}))
useShallow((s) =>
selectTabAgentTypesByTabId(s.agentStatusByPaneKey ?? {}, s.terminalLayoutsByTabId)
)
)
const splitTerminalTabsById = useAppStore(
useShallow((s) => selectSplitTerminalTabsById(s.terminalLayoutsByTabId))
)
const nativeChatEnabled = useAppStore((s) => s.settings?.experimentalNativeChat === true)
const nativeChatTranscriptIsLocalReadable = useAppStore((s) =>
@@ -1117,14 +1124,15 @@ function TabBarInner({
// agent-status pane keys are `${terminalTab.id}:${leafId}`, and
// the unified tab id can differ from it.
const detectedAgent = tabAgentTypesByTabId[terminalTab.id] ?? null
const isSplitTerminalTab = splitTerminalTabsById[terminalTab.id] === true
const canToggleViewMode =
unifiedTabForItem !== undefined &&
canToggleNativeChat({
experimentalNativeChatEnabled: nativeChatEnabled,
contentType: 'terminal',
launchAgent: terminalTab.launchAgent,
launchAgent: isSplitTerminalTab ? null : terminalTab.launchAgent,
detectedAgent,
resolvedAgent,
resolvedAgent: isSplitTerminalTab ? null : resolvedAgent,
nativeChatTranscriptIsLocalReadable,
isChatViewMode: unifiedTabForItem.viewMode === 'chat'
})
@@ -1,13 +1,30 @@
import { describe, expect, it } from 'vitest'
import { shallow } from 'zustand/shallow'
import type { AgentStatusEntry } from '../../../../shared/agent-status-types'
import type { TerminalLayoutSnapshot } from '../../../../shared/types'
import { findTabAgentEntry } from '../native-chat/native-chat-tab-agent-entry'
import { selectTabAgentTypesByTabId } from './tab-agent-types-by-tab-id'
import {
selectSplitTerminalTabsById,
selectTabAgentTypesByTabId
} from './tab-agent-types-by-tab-id'
function entry(partial: Partial<AgentStatusEntry>): AgentStatusEntry {
return { state: 'working', updatedAt: 0, ...partial } as AgentStatusEntry
}
function splitLayout(activeLeafId: string | null): TerminalLayoutSnapshot {
return {
root: {
type: 'split',
direction: 'horizontal',
first: { type: 'leaf', leafId: 'leaf-a' },
second: { type: 'leaf', leafId: 'leaf-b' }
},
activeLeafId,
expandedLeafId: null
}
}
describe('selectTabAgentTypesByTabId', () => {
it('maps each tab to its first pane agent type, matching findTabAgentEntry', () => {
const map: Record<string, AgentStatusEntry> = {
@@ -36,6 +53,81 @@ describe('selectTabAgentTypesByTabId', () => {
)
})
it('uses the active split leaf regardless of pane-map insertion order', () => {
const layouts = { 'tab-1': splitLayout('leaf-b') }
const agentFirst = {
'tab-1:leaf-a': entry({ agentType: 'claude' }),
'tab-1:leaf-b': entry({ agentType: 'codex' })
}
const activeFirst = {
'tab-1:leaf-b': entry({ agentType: 'codex' }),
'tab-1:leaf-a': entry({ agentType: 'claude' })
}
expect(selectTabAgentTypesByTabId(agentFirst, layouts)['tab-1']).toBe('codex')
expect(selectTabAgentTypesByTabId(activeFirst, layouts)['tab-1']).toBe('codex')
expect(selectSplitTerminalTabsById(layouts)).toEqual({ 'tab-1': true })
})
it('does not inherit a supported sibling when the active split leaf is a shell', () => {
const projection = selectTabAgentTypesByTabId(
{
'tab-1:leaf-a': entry({ agentType: 'claude' }),
'tab-1:leaf-b': entry({ agentType: undefined })
},
{ 'tab-1': splitLayout('leaf-b') }
)
expect(projection['tab-1'] ?? null).toBeNull()
})
it('uses the reassigned active sibling after the prior agent leaf closes', () => {
const statuses = {
'tab-1:leaf-a': entry({ agentType: 'claude' }),
'tab-1:leaf-b': entry({ agentType: 'codex' })
}
expect(
selectTabAgentTypesByTabId(statuses, {
'tab-1': {
root: { type: 'leaf', leafId: 'leaf-b' },
activeLeafId: 'leaf-b',
expandedLeafId: null
}
})['tab-1']
).toBe('codex')
})
it('does not fall back to insertion order while a split has no active leaf', () => {
const projection = selectTabAgentTypesByTabId(
{
'tab-1:leaf-a': entry({ agentType: 'claude' }),
'tab-1:leaf-b': entry({ agentType: undefined })
},
{ 'tab-1': splitLayout(null) }
)
expect(projection['tab-1'] ?? null).toBeNull()
})
it('ignores a stale active leaf id that is no longer in the layout', () => {
const projection = selectTabAgentTypesByTabId(
{
'tab-1:closed-leaf': entry({ agentType: 'claude' }),
'tab-1:leaf-a': entry({ agentType: undefined })
},
{
'tab-1': {
root: { type: 'leaf', leafId: 'leaf-a' },
activeLeafId: 'closed-leaf',
expandedLeafId: null
}
}
)
expect(projection['tab-1'] ?? null).toBeNull()
})
it('stays shallow-equal across a working<->idle status flip (no re-render)', () => {
const working: Record<string, AgentStatusEntry> = {
'tab-1:leaf-a': entry({ agentType: 'claude', state: 'working' })
@@ -1,4 +1,18 @@
import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-status-types'
import type { TerminalLayoutSnapshot } from '../../../../shared/types'
function layoutContainsLeaf(layout: TerminalLayoutSnapshot, leafId: string): boolean {
const visit = (node: TerminalLayoutSnapshot['root']): boolean => {
if (!node) {
return false
}
if (node.type === 'leaf') {
return node.leafId === leafId
}
return visit(node.first) || visit(node.second)
}
return layout.root === null || visit(layout.root)
}
/**
* Project `agentStatusByPaneKey` down to the stable `{ terminalTabId: agentType }`
@@ -13,16 +27,30 @@ import type { AgentStatusEntry, AgentType } from '../../../../shared/agent-statu
* those transitions, so the strip re-renders only when a tab actually gains, loses,
* or changes its agent.
*
* First matching pane per tab wins, mirroring `findTabAgentEntry` exactly (tab ids
* are colon-free by construction, so the substring before the first `:` is the
* tab id). A pane whose entry has no `agentType` still claims the tab and yields
* `null`, identical to `findTabAgentEntry(...)?.agentType ?? null`.
* The active layout leaf wins when available because that is where a tab-level
* chat action opens. Before layout hydration, the first matching pane preserves
* the legacy lookup behavior (tab ids are colon-free by construction).
*/
export function selectTabAgentTypesByTabId(
agentStatusByPaneKey: Record<string, AgentStatusEntry>
agentStatusByPaneKey: Record<string, AgentStatusEntry>,
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot> = {}
): Record<string, AgentType> {
const byTabId: Record<string, AgentType> = {}
const claimed = new Set<string>()
// Why: the tab action opens chat on the active split leaf, so that leaf's
// identity must outrank object insertion order from unrelated siblings.
for (const [tabId, layout] of Object.entries(terminalLayoutsByTabId)) {
claimed.add(tabId)
const activeLeafId =
layout.activeLeafId ?? (layout.root?.type === 'leaf' ? layout.root.leafId : null)
if (!activeLeafId || !layoutContainsLeaf(layout, activeLeafId)) {
continue
}
const entry = agentStatusByPaneKey[`${tabId}:${activeLeafId}`]
if (entry?.agentType != null) {
byTabId[tabId] = entry.agentType
}
}
for (const [paneKey, entry] of Object.entries(agentStatusByPaneKey)) {
const colon = paneKey.indexOf(':')
if (colon <= 0) {
@@ -39,3 +67,15 @@ export function selectTabAgentTypesByTabId(
}
return byTabId
}
export function selectSplitTerminalTabsById(
terminalLayoutsByTabId: Record<string, TerminalLayoutSnapshot>
): Record<string, true> {
const splitTabs: Record<string, true> = {}
for (const [tabId, layout] of Object.entries(terminalLayoutsByTabId)) {
if (layout.root?.type === 'split') {
splitTabs[tabId] = true
}
}
return splitTabs
}
@@ -101,6 +101,10 @@ import {
} from '@/lib/pane-manager/mobile-driver-state'
import { shouldChatTakeOverMobileSurface } from '../native-chat/native-chat-send-eligibility'
import { canToggleNativeChat } from '../native-chat/native-chat-availability'
import {
nativeChatLaunchAgentForLeaf,
resolveNativeChatLeafRoute
} from '../native-chat/native-chat-leaf-routing'
import { isNativeChatTranscriptLocalReadable } from '@/lib/native-chat-transcript-readability'
import { resolvePaneKeyForManager } from '@/lib/pane-manager/pane-key-resolution'
import { safeFit } from '@/lib/pane-manager/pane-tree-ops'
@@ -376,10 +380,8 @@ export default function TerminalPane({
// list via managerRef.current?.getPanes()) re-runs when a pane is split or
// closed. managerRef is imperative and doesn't trigger React's dependency
// tracking. The lifecycle hook updates this via setPaneCount on
// onPaneCreated / onPaneClosed / onLayoutChanged. The value is never
// read — the portal map at line ~914 calls `managerRef.current?.getPanes()`
// imperatively, so `setPaneCount` is used only as a render-trigger side
// effect to force that map to re-run when a pane is split or closed.
// onPaneCreated / onPaneClosed / onLayoutChanged. The portal map reads the
// manager imperatively; the count also wakes leaf-ownership initialization.
const [paneCount, setPaneCount] = useState<number>(0)
// Why: pane reorders can move panes without changing count or size, so
// overlay rects need an explicit layout-change render trigger.
@@ -394,6 +396,9 @@ export default function TerminalPane({
} | null>(null)
const [quickCommandEditorOpen, setQuickCommandEditorOpen] = useState(false)
const [chatLeafId, setChatLeafId] = useState<string | null>(null)
const [tabWideAgentHintLeafId, setTabWideAgentHintLeafId] = useState<string | null | undefined>(
undefined
)
// Why: the terminal menu can be the first quick-command entry point, so each
// Add action starts with a fresh draft instead of reusing cancelled text.
const [quickCommandDraft, setQuickCommandDraft] = useState(createTerminalQuickCommandDraft)
@@ -685,53 +690,106 @@ export default function TerminalPane({
selectTerminalTabAgentTypesByLeaf(store.agentStatusByPaneKey, tabId)
)
const toggleTabViewMode = useAppStore((store) => store.toggleTabViewMode)
const setTabViewMode = useAppStore((store) => store.setTabViewMode)
const savedLayout = useAppStore((store) => store.terminalLayoutsByTabId[tabId] ?? EMPTY_LAYOUT)
const terminalTab = useAppStore((store) =>
getCachedTerminalTabForWorktree(store.tabsByWorktree, worktreeId, tabId)
)
const restoredLayout = useMemo(
() => (terminalTab ? sanitizeTerminalLayoutPaneTitles(savedLayout, terminalTab) : savedLayout),
[savedLayout, terminalTab]
)
const expectedLayoutLeafIds = useMemo(
() => collectLeafIdsInOrder(restoredLayout.root),
[restoredLayout.root]
)
const getNativeChatLeafIds = useCallback((): string[] => {
const mountedLeafIds = managerRef.current?.getPanes().map((pane) => pane.leafId) ?? []
// Why: a partially hydrated manager can expose one pane from a restored
// split. Union both sources so tab-wide evidence stays disabled meanwhile.
return [...new Set([...expectedLayoutLeafIds, ...mountedLeafIds])]
}, [expectedLayoutLeafIds])
const getTabWideAgentHintLeafId = useCallback((): string | null => {
if (tabWideAgentHintLeafId !== undefined) {
return tabWideAgentHintLeafId
}
const leafIds = getNativeChatLeafIds()
return leafIds.length === 1 ? leafIds[0] : null
}, [getNativeChatLeafIds, tabWideAgentHintLeafId])
useEffect(() => {
if (tabWideAgentHintLeafId !== undefined) {
return
}
const leafIds = getNativeChatLeafIds()
if (leafIds.length === 0) {
return
}
// Why: tab-wide launch/title metadata predates leaf ownership. Bind it only
// when the first concrete topology proves which sole leaf it can describe.
setTabWideAgentHintLeafId(leafIds.length === 1 ? leafIds[0] : null)
}, [getNativeChatLeafIds, paneCount, tabWideAgentHintLeafId])
const resolveTitleAgentForLeaf = useCallback(
(leafId: string | null) =>
resolveNativeChatLeafTitleAgent({
(leafId: string | null) => {
const hasSingleKnownLeaf =
getNativeChatLeafIds().length === 1 && getTabWideAgentHintLeafId() === leafId
return resolveNativeChatLeafTitleAgent({
leafId,
panes: managerRef.current?.getPanes() ?? [],
runtimePaneTitlesByPaneId,
tabLabel: unifiedTabLabel,
terminalTitle: terminalTab?.title
}),
[runtimePaneTitlesByPaneId, terminalTab?.title, unifiedTabLabel]
tabLabel: hasSingleKnownLeaf ? unifiedTabLabel : null,
terminalTitle: hasSingleKnownLeaf ? terminalTab?.title : null
})
},
[
getNativeChatLeafIds,
getTabWideAgentHintLeafId,
runtimePaneTitlesByPaneId,
terminalTab?.title,
unifiedTabLabel
]
)
// Per-leaf eligibility: a split can mix a supported agent in one leaf with an
// unsupported one in another, so the toggle is gated by the specific leaf.
// A leaf's own live agent is authoritative; the tab-wide launch/title hints
// only fill in before hooks arrive (or for the single-pane case) so they
// can't enable the toggle on a sibling actually running an unsupported agent.
const canToggleChatForLeaf = useCallback(
const isChatEligibleForLeaf = useCallback(
(leafId: string | null): boolean => {
const detectedAgent = leafId ? (tabAgentTypeByLeaf[leafId] ?? null) : null
// Scope the "always allow toggling back" rule to the leaf actually showing
// chat — passing the tab-wide flag would re-enable the toggle on an
// unsupported sibling whenever any leaf in the split is in chat view.
const isChatViewForLeaf = effectiveChatViewMode && leafId !== null && chatLeafId === leafId
const launchAgent = nativeChatLaunchAgentForLeaf({
launchAgent: terminalTab?.launchAgent,
launchAgentLeafId: getTabWideAgentHintLeafId(),
leafId,
leafIds: getNativeChatLeafIds()
})
return canToggleNativeChat({
experimentalNativeChatEnabled: nativeChatEnabled,
contentType: 'terminal',
launchAgent: detectedAgent ? null : terminalTab?.launchAgent,
launchAgent: detectedAgent ? null : launchAgent,
detectedAgent,
resolvedAgent: detectedAgent ? null : resolveTitleAgentForLeaf(leafId),
nativeChatTranscriptIsLocalReadable,
isChatViewMode: isChatViewForLeaf
nativeChatTranscriptIsLocalReadable
})
},
[
tabAgentTypeByLeaf,
effectiveChatViewMode,
chatLeafId,
nativeChatEnabled,
nativeChatTranscriptIsLocalReadable,
terminalTab?.launchAgent,
getNativeChatLeafIds,
getTabWideAgentHintLeafId,
resolveTitleAgentForLeaf
]
)
const canToggleChatForLeaf = useCallback(
(leafId: string | null): boolean => {
// Scope the "always allow toggling back" rule to the leaf actually showing
// chat; it must not make an unsupported sibling look eligible.
const isChatViewForLeaf = effectiveChatViewMode && leafId !== null && chatLeafId === leafId
return (nativeChatEnabled && isChatViewForLeaf) || isChatEligibleForLeaf(leafId)
},
[chatLeafId, effectiveChatViewMode, isChatEligibleForLeaf, nativeChatEnabled]
)
const toggleNativeChatForLeaf = useCallback(
(leafId: string) => {
if (!unifiedTabId) {
@@ -757,14 +815,6 @@ export default function TerminalPane({
toggleNativeChatForLeaf(activeLeafId)
}, [toggleNativeChatForLeaf])
const setTabLayout = useAppStore((store) => store.setTabLayout)
const restoredLayout = useMemo(
() => (terminalTab ? sanitizeTerminalLayoutPaneTitles(savedLayout, terminalTab) : savedLayout),
[savedLayout, terminalTab]
)
const expectedLayoutLeafIds = useMemo(
() => collectLeafIdsInOrder(restoredLayout.root),
[restoredLayout.root]
)
const expectedLayoutLeafIdsAttr =
expectedLayoutLeafIds.length > 0 ? expectedLayoutLeafIds.join(' ') : undefined
const initialLayoutRef = useRef(restoredLayout)
@@ -2920,23 +2970,31 @@ export default function TerminalPane({
? managedPanes.some((pane) => pane.leafId === chatLeafId)
: false
useEffect(() => {
if (!isChatViewMode) {
if (chatLeafId !== null) {
setChatLeafId(null)
}
return
}
const activeLeafId = activePane?.leafId ?? null
if (!chatLeafId) {
if (activeLeafId) {
setChatLeafId(activeLeafId)
}
return
const route = resolveNativeChatLeafRoute({
isChatViewMode,
chatLeafId,
activeLeafId,
chatLeafStillMounted,
chatLeafIsEligible: isChatEligibleForLeaf(chatLeafId),
activeLeafIsEligible: isChatEligibleForLeaf(activeLeafId)
})
if (route.chatLeafId !== chatLeafId) {
setChatLeafId(route.chatLeafId)
}
if (!chatLeafStillMounted) {
setChatLeafId(activeLeafId)
if (route.exitChat && unifiedTabId) {
// Why: effect replay must not flip terminal mode back to chat.
setTabViewMode(unifiedTabId, 'terminal')
}
}, [isChatViewMode, chatLeafId, activePane?.leafId, chatLeafStillMounted])
}, [
isChatViewMode,
chatLeafId,
activePane?.leafId,
chatLeafStillMounted,
isChatEligibleForLeaf,
unifiedTabId,
setTabViewMode
])
const chatPane =
isChatViewMode && chatLeafId
? (managedPanes.find((pane) => pane.leafId === chatLeafId) ?? null)
@@ -2945,6 +3003,12 @@ export default function TerminalPane({
? (paneTransportsRef.current.get(chatPane.id)?.getPtyId() ?? null)
: null
const chatPaneResolvedAgent = chatPane ? resolveTitleAgentForLeaf(chatPane.leafId) : null
const chatPaneLaunchAgent = nativeChatLaunchAgentForLeaf({
launchAgent: terminalTab?.launchAgent,
launchAgentLeafId: getTabWideAgentHintLeafId(),
leafId: chatPane?.leafId ?? null,
leafIds: getNativeChatLeafIds()
})
const activePaneIsChatLeaf = Boolean(
isChatViewMode && activePane?.leafId && activePane.leafId === chatLeafId
)
@@ -3045,7 +3109,7 @@ export default function TerminalPane({
terminalTabId={tabId}
paneKey={makePaneKey(tabId, chatPane.leafId)}
targetPtyId={chatPanePtyId}
launchAgent={terminalTab?.launchAgent}
launchAgent={chatPaneLaunchAgent}
resolvedAgent={chatPaneResolvedAgent}
onSwitchToTerminal={() => toggleNativeChatForLeaf(chatPane.leafId)}
contextMenuActions={{