Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -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 })
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import type {
TerminalLayoutSnapshot,
TerminalPaneLayoutNode,
TuiAgent
} from '../../../../shared/types'

function layoutNodeContainsLeaf(node: TerminalPaneLayoutNode | null, leafId: string): boolean {
if (!node) {
return false
}
if (node.type === 'leaf') {
return node.leafId === leafId
}
return layoutNodeContainsLeaf(node.first, leafId) || layoutNodeContainsLeaf(node.second, leafId)
}

export function resolveNativeChatActiveLayoutLeafId(
layout: TerminalLayoutSnapshot | null | undefined
): string | null {
if (!layout) {
return null
}
if (layout.activeLeafId) {
// Why: close/hydration races can leave activeLeafId one snapshot behind
// the topology; stale pane evidence must not route chat to a removed leaf.
return !layout.root || layoutNodeContainsLeaf(layout.root, layout.activeLeafId)
? layout.activeLeafId
: null
}
return layout.root?.type === 'leaf' ? layout.root.leafId : null
}

export function isNativeChatTabWideFallbackSafe(
layout: TerminalLayoutSnapshot | null | undefined
): boolean {
if (!layout?.root) {
return true
}
if (layout.root.type === 'split') {
return false
}
// Why: a stale active id means the single-leaf collapse is not yet settled;
// tab-wide launch/title evidence could still describe the removed sibling.
return !layout.activeLeafId || layout.activeLeafId === layout.root.leafId
}

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 }
}
Loading
Loading