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
12 changes: 6 additions & 6 deletions src/renderer/src/components/native-chat/NativeChatComposer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,7 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
onSlashCommand,
onSwitchToTerminal,
readTerminalScreen,
launchDraft,
launchDraftResolved = false,
launchSeed,
structuredTransport
},
ref
Expand All @@ -88,8 +87,9 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
useNativeChatLaunchDraftAdoption({
terminalTabId,
agent,
launchDraft,
launchDraftResolved,
launchDraft: launchSeed?.launchDraft,
launchDraftResolved: launchSeed?.launchDraftResolved === true,
ownsTabWideLaunchDraft: launchSeed?.ownsTabWideLaunchDraft === true,
draft,
setDraft,
setCaret
Expand Down Expand Up @@ -285,8 +285,8 @@ export const NativeChatComposer = forwardRef<NativeChatComposerHandle, NativeCha
imageAttachments,
disabled,
isDispatchingSessionOption,
launchDraft,
launchDraftResolved,
launchDraft: launchSeed?.launchDraft,
launchDraftResolved: launchSeed?.launchDraftResolved === true,
readTerminalScreen,
resolveTarget,
classifySend,
Expand Down
6 changes: 4 additions & 2 deletions src/renderer/src/components/native-chat/NativeChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
targetPtyId = null,
launchAgent,
resolvedAgent,
ownsTabWideLaunchDraft,
onSwitchToTerminal,
readTerminalScreen,
contextMenuActions,
Expand All @@ -99,6 +100,7 @@
isVisible={isVisible}
targetPtyId={targetPtyId}
terminalTabId={terminalTabId}
ownsTabWideLaunchDraft={ownsTabWideLaunchDraft}
onSwitchToTerminal={onSwitchToTerminal}
readTerminalScreen={readTerminalScreen}
contextMenuActions={contextMenuActions}
Expand All @@ -117,6 +119,7 @@
isVisible,
targetPtyId,
terminalTabId,
ownsTabWideLaunchDraft,
onSwitchToTerminal,
readTerminalScreen,
contextMenuActions,
Expand All @@ -140,7 +143,6 @@
const paneLaunchPrompt = launchPrompt?.agent === agent ? launchPrompt : null
// Launch context prefilled into the TUI input as an unsent draft; the
// composer adopts it so the GUI view shows the same context as the TUI.
// Shape matches NativeChatComposer's two launch-draft props, so it spreads.
const launchDraftSignal = useNativeChatLaunchDraftSignal({
terminalTabId,
agent,
Expand Down Expand Up @@ -450,10 +452,10 @@
onSlashCommand={onSlashCommand}
onSwitchToTerminal={onSwitchToTerminal}
readTerminalScreen={readTerminalScreen}
{...launchDraftSignal}
launchSeed={{ ...launchDraftSignal, ownsTabWideLaunchDraft }}
/>
)}
{contextMenu.menu}
</div>
)
}

Check failure on line 461 in src/renderer/src/components/native-chat/NativeChatView.tsx

View workflow job for this annotation

GitHub Actions / static analysis

eslint(max-lines)

src/renderer/src/components/native-chat/NativeChatView.tsx:461:2: File has too many lines (403).
Original file line number Diff line number Diff line change
Expand Up @@ -47,14 +47,22 @@ export type NativeChatComposerProps = {
onSwitchToTerminal?: () => void
/** Reads the hosted TUI's current rendered screen when chat is entered. */
readTerminalScreen?: () => string | null
/** Launch context prefilled into the TUI input as an unsent draft; adopted as the composer draft. */
launchDraft?: NativeChatLaunchDraft | null
/** True once the transcript shows the TUI-side draft was submitted or cleared. */
launchDraftResolved?: boolean
/** The tab's launch seed as this pane sees it. */
launchSeed?: NativeChatLaunchSeed
/** Structured journal transport; absent keeps the existing PTY path unchanged. */
structuredTransport?: NativeChatStructuredComposerTransport
}

/** Launch context prefilled into the TUI input as an unsent draft, plus the two
* facts that decide its fate in this pane's composer. */
export type NativeChatLaunchSeed = {
launchDraft: NativeChatLaunchDraft | null
/** True once the transcript shows the TUI-side draft was submitted or cleared. */
launchDraftResolved: boolean
/** False for every pane of a split tab; gates adopting the seed, not cleanup. */
ownsTabWideLaunchDraft: boolean
}

export type NativeChatComposerHandle = {
focus: () => boolean
insertTypedText: (text: string) => boolean
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,45 @@
import { describe, expect, it } from 'vitest'
import {
nativeChatLaunchAgentForLeaf,
nativeChatLeafOwnsTabWideEvidence,
resolveNativeChatLeafRoute
} from './native-chat-leaf-routing'

describe('nativeChatLeafOwnsTabWideEvidence', () => {
it("owns tab-wide evidence only while the bound leaf is still the tab's sole pane", () => {
expect(
nativeChatLeafOwnsTabWideEvidence({
ownerLeafId: 'leaf-a',
leafId: 'leaf-a',
leafIds: ['leaf-a']
})
).toBe(true)
// A split sibling must not inherit the tab's launch draft (issue #16695).
expect(
nativeChatLeafOwnsTabWideEvidence({
ownerLeafId: 'leaf-a',
leafId: 'leaf-b',
leafIds: ['leaf-a', 'leaf-b']
})
).toBe(false)
// Not even the original pane keeps it once the tab is split.
expect(
nativeChatLeafOwnsTabWideEvidence({
ownerLeafId: 'leaf-a',
leafId: 'leaf-a',
leafIds: ['leaf-a', 'leaf-b']
})
).toBe(false)
expect(
nativeChatLeafOwnsTabWideEvidence({
ownerLeafId: null,
leafId: 'leaf-a',
leafIds: ['leaf-a']
})
).toBe(false)
})
})

describe('nativeChatLaunchAgentForLeaf', () => {
it('uses the tab launch hint only for its sole leaf', () => {
expect(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,19 +44,37 @@ export function isNativeChatTabWideFallbackSafe(
return !layout.activeLeafId || layout.activeLeafId === layout.root.leafId
}

/** Whether tab-wide launch evidence (agent hint, launch draft) describes this
* leaf: it must still be the tab's sole pane and the one the evidence bound to. */
export function nativeChatLeafOwnsTabWideEvidence(args: {
ownerLeafId: string | null
leafId: string | null
leafIds: readonly string[]
}): boolean {
const { ownerLeafId, leafId, leafIds } = args
if (!ownerLeafId || !leafId) {
return false
}
// Why: the evidence belongs to the tab's original pane. Once a split exists,
// it says nothing about any particular sibling.
return leafIds.length === 1 && leafIds[0] === leafId && ownerLeafId === 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) {
if (!launchAgent) {
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
return nativeChatLeafOwnsTabWideEvidence({
ownerLeafId: launchAgentLeafId,
leafId,
leafIds
})
? launchAgent
: null
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ export type NativeChatBridgeViewProps = NativeChatOrchestrationProps & {
launchAgent?: TuiAgent | null
/** Trusted title/foreground fallback for manually-started agents. */
resolvedAgent?: TuiAgent | null
/** Whether this pane owns the tab's launch draft; false for split siblings. */
ownsTabWideLaunchDraft: boolean
/** Return this pane to the hosted terminal surface. */
onSwitchToTerminal?: () => void
/** Current xterm screen reader used to recover agent-reported session state. */
Expand All @@ -50,6 +52,7 @@ export type NativeChatResolvedViewProps = NativeChatOrchestrationProps & {
isVisible: boolean
targetPtyId: string | null
terminalTabId: string
ownsTabWideLaunchDraft: boolean
onSwitchToTerminal?: () => void
readTerminalScreen?: () => string | null
contextMenuActions?: Omit<NativeChatContextMenuActions, 'onPaste'>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,11 +29,13 @@ vi.mock('../../store', () => {
return { useAppStore }
})

const SEED_TEXT = 'https://github.com/o/r/issues/12'

function launchDraft(overrides: Partial<NativeChatLaunchDraft> = {}): NativeChatLaunchDraft {
return {
tabId: 'tab-1',
agent: 'claude',
text: 'https://github.com/o/r/issues/12',
text: SEED_TEXT,
createdAt: 1000,
...overrides
}
Expand All @@ -44,6 +46,7 @@ function setup(args: {
launchDraftResolved?: boolean
draft?: string
agent?: string
ownsTabWideLaunchDraft?: boolean
}): { setDraft: ReturnType<typeof vi.fn>; setCaret: ReturnType<typeof vi.fn> } {
const setDraft = vi.fn()
const setCaret = vi.fn()
Expand All @@ -55,7 +58,8 @@ function setup(args: {
launchDraftResolved: args.launchDraftResolved ?? false,
draft: args.draft ?? '',
setDraft,
setCaret
setCaret,
ownsTabWideLaunchDraft: args.ownsTabWideLaunchDraft ?? true
})
)
return { setDraft, setCaret }
Expand All @@ -67,7 +71,10 @@ function userTurn(id: string, timestamp: number | null): NativeChatMessage {
return { id, role: 'user', blocks: [{ type: 'text', text: id }], timestamp, source: 'transcript' }
}

type SignalProps = { messages: NativeChatMessage[]; transcriptLoading?: boolean }
type SignalProps = {
messages: NativeChatMessage[]
transcriptLoading?: boolean
}

function renderSignal(messages: NativeChatMessage[], transcriptLoading = false) {
const initialProps: SignalProps = { messages, transcriptLoading }
Expand Down Expand Up @@ -186,6 +193,14 @@ describe('useNativeChatLaunchDraftSignal', () => {
expect(result.current.launchDraft?.resolved).toBe(true)
})

it("selects the tab's seed for every pane so the resolution machine keeps running", () => {
// Pane ownership gates the composer *write*, not the signal: nulling it here
// would also kill the resolved/cleanup branch for a split tab.
const { result } = renderSignal([])

expect(result.current.launchDraft?.text).toBe(SEED_TEXT)
})

it('ignores a draft seeded for another agent', () => {
mocks.storeState.nativeChatLaunchDraftByTabId = {
'tab-1': launchDraft({ agent: 'codex', createdAt: SEEDED_AT })
Expand Down Expand Up @@ -258,6 +273,21 @@ describe('useNativeChatLaunchDraftAdoption', () => {
expect(mocks.clearNativeChatLaunchDraft).toHaveBeenCalledWith('tab-1')
})

it('does not mirror the seed into a pane that does not own the tab-wide evidence', () => {
const { setDraft } = setup({ launchDraft: launchDraft(), ownsTabWideLaunchDraft: false })

expect(setDraft).not.toHaveBeenCalled()
expect(mocks.markNativeChatLaunchDraftAdopted).not.toHaveBeenCalled()
})

it('leaves an unadopted seed alone when a non-owning pane resolves it', () => {
// The owner may not have mirrored it yet; a sibling's transcript is no
// evidence about the owner's copy.
setup({ launchDraft: launchDraft(), launchDraftResolved: true, ownsTabWideLaunchDraft: false })

expect(mocks.clearNativeChatLaunchDraft).not.toHaveBeenCalled()
})

it('drops an unadopted seed once the transcript resolves it', () => {
const { setDraft } = setup({ launchDraft: launchDraft(), launchDraftResolved: true })

Expand All @@ -266,3 +296,82 @@ describe('useNativeChatLaunchDraftAdoption', () => {
expect(mocks.clearNativeChatLaunchDraft).toHaveBeenCalledWith('tab-1')
})
})

describe('launch draft adoption across a split', () => {
beforeEach(() => {
vi.clearAllMocks()
mocks.storeState.nativeChatLaunchDraftByTabId = {
'tab-1': launchDraft({ createdAt: SEEDED_AT })
}
})

function renderPaneComposer(args: {
ownsTabWideLaunchDraft: boolean
draft?: string
messages?: NativeChatMessage[]
}): { setDraft: ReturnType<typeof vi.fn>; setCaret: ReturnType<typeof vi.fn> } {
const setDraft = vi.fn()
const setCaret = vi.fn()
renderHook(() => {
const signal = useNativeChatLaunchDraftSignal({
terminalTabId: 'tab-1',
agent: 'claude',
messages: args.messages ?? [],
transcriptLoading: false
})
useNativeChatLaunchDraftAdoption({
terminalTabId: 'tab-1',
agent: 'claude',
launchDraft: signal.launchDraft,
launchDraftResolved: signal.launchDraftResolved,
draft: args.draft ?? '',
setDraft,
setCaret,
ownsTabWideLaunchDraft: args.ownsTabWideLaunchDraft
})
})
return { setDraft, setCaret }
}

it("never fills a non-owning pane's composer with the tab's launch draft", () => {
const { setDraft } = renderPaneComposer({ ownsTabWideLaunchDraft: false })

expect(setDraft).not.toHaveBeenCalled()
expect(mocks.markNativeChatLaunchDraftAdopted).not.toHaveBeenCalled()
})

it('never lets a non-owning pane destroy a seed nobody has adopted yet', () => {
const { setDraft } = renderPaneComposer({
ownsTabWideLaunchDraft: false,
messages: [userTurn('u1', null)]
})

expect(setDraft).not.toHaveBeenCalled()
expect(mocks.clearNativeChatLaunchDraft).not.toHaveBeenCalled()
})

it('still mirrors the launch draft into the owning pane composer', () => {
const { setDraft } = renderPaneComposer({ ownsTabWideLaunchDraft: true })

expect(setDraft).toHaveBeenCalledWith(SEED_TEXT)
})

it('clears the adopted copy after a split once the transcript resolves the seed', () => {
// Splitting drops ownership for *both* panes, so the pane that already
// mirrored the seed must still clean up — otherwise the submitted prompt
// sits in its composer forever and a later Enter re-sends it (#16695).
mocks.storeState.nativeChatLaunchDraftByTabId = {
'tab-1': launchDraft({ adopted: true, createdAt: SEEDED_AT })
}

const { setDraft, setCaret } = renderPaneComposer({
ownsTabWideLaunchDraft: false,
draft: SEED_TEXT,
messages: [userTurn('u1', null)]
})

expect(setDraft).toHaveBeenCalledWith('')
expect(setCaret).toHaveBeenCalledWith(0)
expect(mocks.clearNativeChatLaunchDraft).toHaveBeenCalledWith('tab-1')
})
})
Loading
Loading