mirror of
https://github.com/stablyai/orca.git
synced 2026-10-17 16:02:55 +00:00
fix(native-chat): target native chat to the active split leaf
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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={{
|
||||
|
||||
Reference in New Issue
Block a user