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
17 changes: 15 additions & 2 deletions src/renderer/src/components/native-chat/NativeChatMessageList.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -127,14 +127,16 @@ function MessageRow({
expandSignal,
onScrollMessageToTop,
onLinkClick,
allowFileUriLinks = false
allowFileUriLinks = false,
deliveryFailed = false
}: {
message: NativeChatMessage
expandSignal: boolean
/** Align this message's top to the top of the scroll viewport. */
onScrollMessageToTop: (el: HTMLElement) => void
onLinkClick?: CommentMarkdownLinkClickHandler
allowFileUriLinks?: boolean
deliveryFailed?: boolean
}): React.JSX.Element | null {
const rowRef = useRef<HTMLDivElement | null>(null)
const { prose, tools } = useMemo(() => splitNativeChatBlocks(message.blocks), [message.blocks])
Expand Down Expand Up @@ -181,6 +183,14 @@ function MessageRow({
<ImageAttachmentRefs blocks={prose} />
)}
</div>
{deliveryFailed ? (
<div className="max-w-[85%] text-[11px] text-destructive/80">
{translate(
'components.native-chat.launchPromptNotDelivered',
'Not delivered — check the terminal'
)}
</div>
) : null}
</div>
)
}
Expand Down Expand Up @@ -227,7 +237,8 @@ export function NativeChatMessageList({
expandSignal,
fontScale,
onLinkClick,
allowFileUriLinks = false
allowFileUriLinks = false,
failedDeliveryMessageIds
}: {
session: NativeChatLiveSession
isWorking: boolean
Expand All @@ -237,6 +248,7 @@ export function NativeChatMessageList({
fontScale: number
onLinkClick?: CommentMarkdownLinkClickHandler
allowFileUriLinks?: boolean
failedDeliveryMessageIds?: ReadonlySet<string>
}): React.JSX.Element {
const scrollRef = useRef<HTMLDivElement | null>(null)
const [stuckToBottom, setStuckToBottom] = useState(true)
Expand Down Expand Up @@ -370,6 +382,7 @@ export function NativeChatMessageList({
onScrollMessageToTop={scrollMessageToTop}
onLinkClick={onLinkClick}
allowFileUriLinks={allowFileUriLinks}
deliveryFailed={failedDeliveryMessageIds?.has(message.id) === true}
/>
))}
{showTypingIndicator ? <TypingIndicatorRow /> : null}
Expand Down
40 changes: 37 additions & 3 deletions src/renderer/src/components/native-chat/NativeChatView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,12 @@ import {
appendPendingSendCache,
commandMarkersAsMessages,
appendCommandMarkerCache,
launchPromptAsMessage,
pendingSendsAsMessages,
prunePendingSends,
readCommandMarkerCache,
readPendingSendCache,
shouldPruneLaunchPrompt,
writePendingSendCache,
type NativeChatCommandMarker,
type NativeChatPendingSend
Expand Down Expand Up @@ -156,6 +158,9 @@ function NativeChatResolvedView({
contextMenuActions?: Omit<NativeChatContextMenuActions, 'onPaste'>
}): React.JSX.Element {
const session = useNativeChatLiveSession({ paneKey, agent, sessionId, transcriptPath })
const launchPrompt = useAppStore((s) => s.nativeChatLaunchPromptByTabId[terminalTabId] ?? null)
const clearNativeChatLaunchPrompt = useAppStore((s) => s.clearNativeChatLaunchPrompt)
const paneLaunchPrompt = launchPrompt?.agent === agent ? launchPrompt : null
// Live hook state for this pane, selected directly so the working indicator
// flips the instant the agent reports 'working' — even when switching to chat
// mid-turn before the transcript merge has caught up.
Expand Down Expand Up @@ -270,6 +275,12 @@ function NativeChatResolvedView({
writePendingSendCache(pendingScope, prunePendingSends(prev, session.messages))
)
}, [session.messages, pendingScope])
useEffect(() => {
if (!paneLaunchPrompt || !shouldPruneLaunchPrompt(paneLaunchPrompt, session.messages)) {
return
}
clearNativeChatLaunchPrompt(terminalTabId)
}, [clearNativeChatLaunchPrompt, paneLaunchPrompt, session.messages, terminalTabId])
const onOptimisticSend = useCallback(
(text: string, imagePaths?: string[]) => {
setWorkingInterrupted(false)
Expand All @@ -291,10 +302,32 @@ function NativeChatResolvedView({
[commandMarkerScope]
)

const launchPromptMessage = useMemo(
() => launchPromptAsMessage(paneLaunchPrompt, session.messages),
[paneLaunchPrompt, session.messages]
)
const sessionWithLaunchPrompt = useMemo<typeof session>(() => {
if (!launchPromptMessage) {
return session
}
return { ...session, messages: [...session.messages, launchPromptMessage] }
}, [launchPromptMessage, session])

const sessionAfterCommandBoundaries = useMemo<typeof session>(() => {
const messages = applyCommandMarkerBoundaries(session.messages, commandMarkers)
return messages === session.messages ? session : { ...session, messages }
}, [session, commandMarkers])
const messages = applyCommandMarkerBoundaries(sessionWithLaunchPrompt.messages, commandMarkers)
return messages === sessionWithLaunchPrompt.messages
? sessionWithLaunchPrompt
: { ...sessionWithLaunchPrompt, messages }
}, [sessionWithLaunchPrompt, commandMarkers])
const launchPromptVisible =
launchPromptMessage !== null &&
sessionAfterCommandBoundaries.messages.some((message) => message.id === launchPromptMessage.id)
const failedLaunchPromptMessageIds = useMemo(() => {
if (!paneLaunchPrompt?.failed || !launchPromptVisible || !launchPromptMessage) {
return undefined
}
return new Set([launchPromptMessage.id])
}, [paneLaunchPrompt?.failed, launchPromptMessage, launchPromptVisible])

// The streaming preview bubble (if any) sits after the transcript but before
// the optimistic user echoes — same order mobile uses.
Expand Down Expand Up @@ -422,6 +455,7 @@ function NativeChatResolvedView({
fontScale={fontScale.scale}
onLinkClick={nativeChatFileLinkClick}
allowFileUriLinks={fileLinkContext !== null}
failedDeliveryMessageIds={failedLaunchPromptMessageIds}
/>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,25 +1,8 @@
import type { Tab, TuiAgent } from '../../../../shared/types'
import type { AgentType } from '../../../../shared/agent-status-types'
import { isNativeChatSupportedAgent } from '@/lib/native-chat-supported-agent'

/** Agents whose transcripts the native chat view can actually parse and render.
* Native chat depends on provider-specific transcript/streaming parsing, so the
* toggle must stay limited to the providers we support — currently Claude
* (including the OpenClaude variant) and Codex. Other agents (Grok, Gemini, …)
* run fine in the terminal but have no native-chat rendering, so they must not
* show the toggle. */
const NATIVE_CHAT_SUPPORTED_AGENTS: ReadonlySet<string> = new Set<string>([
'claude',
'openclaude',
'codex'
])

/** Whether the given agent identity (from any signal: launch hint, live
* detection, or title resolution) is one native chat can render. */
export function isNativeChatSupportedAgent(
agent: TuiAgent | AgentType | null | undefined
): boolean {
return agent != null && NATIVE_CHAT_SUPPORTED_AGENTS.has(agent)
}
export { isNativeChatSupportedAgent }

/** Inputs that decide whether a tab may toggle into the native chat view.
* Kept as a plain shape (not the live store) so the decision stays pure and
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { describe, it, expect } from 'vitest'
import type { NativeChatMessage } from '../../../../shared/native-chat-types'
import { buildNativeChatRenderItems, orderNativeChatMessages } from './native-chat-message-grouping'
import { NATIVE_CHAT_STREAMING_ID } from '../../../../shared/native-chat-streaming'

function msg(
overrides: Partial<NativeChatMessage> & Pick<NativeChatMessage, 'id'>
Expand Down Expand Up @@ -31,6 +32,15 @@ describe('orderNativeChatMessages', () => {
])
expect(ordered.map((m) => m.id)).toEqual(['a', 'z'])
})

it('sorts the streaming preview after real content but before optimistic echoes', () => {
const ordered = orderNativeChatMessages([
msg({ id: 'pending:abc', role: 'user', timestamp: 20, source: 'scrape' }),
msg({ id: NATIVE_CHAT_STREAMING_ID, timestamp: null }),
msg({ id: 'real-user', role: 'user', timestamp: 10 })
])
expect(ordered.map((m) => m.id)).toEqual(['real-user', 'streaming', 'pending:abc'])
})
})

describe('buildNativeChatRenderItems', () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,14 @@ import {
clearPendingSendCacheForTests,
commandMarkersAsMessages,
isCommandMarkerId,
isLaunchPromptMessageId,
isPendingMessageId,
launchPromptAsMessage,
pendingSendsAsMessages,
prunePendingSends,
readCommandMarkerCache,
readPendingSendCache,
shouldPruneLaunchPrompt,
writePendingSendCache,
type NativeChatPendingSend
} from './native-chat-pending'
Expand Down Expand Up @@ -135,6 +138,86 @@ describe('pendingSendsAsMessages', () => {
})
})

describe('launchPromptAsMessage', () => {
it('maps a launch prompt to a tab-keyed scrape-source user message', () => {
expect(
launchPromptAsMessage({
tabId: 'tab-1',
agent: 'codex',
text: 'Fix failing checks',
createdAt: 42
})
).toEqual({
id: 'launch-pending:tab-1',
role: 'user',
blocks: [{ type: 'text', text: 'Fix failing checks' }],
timestamp: 42,
source: 'scrape'
})
})

it('hides the launch prompt while its transcript user turn is visible', () => {
expect(
launchPromptAsMessage(
{
tabId: 'tab-1',
agent: 'codex',
text: 'Fix failing checks',
createdAt: 42
},
[userMessage('u1', 'Fix failing checks')]
)
).toBeNull()
})

it('uses pending-send normalization for large multiline generated prompts', () => {
const prompt = [
'[Image #1] Resolve the failing checks:',
'',
'Resolve the failing checks:',
'',
'- lint failed',
' fix spacing'
].join('\n')
const transcript = [
userMessage(
'u1',
'Resolve the failing checks: Resolve the failing checks: - lint failed fix spacing'
),
assistantMessage('a1', 'I will fix it')
]

expect(
shouldPruneLaunchPrompt(
{
tabId: 'tab-1',
agent: 'codex',
text: prompt,
createdAt: 42
},
transcript
)
).toBe(true)
})

it('keeps the launch prompt until the transcript advances past the user turn', () => {
const prompt = {
tabId: 'tab-1',
agent: 'claude' as const,
text: 'Fix failing checks',
createdAt: 42
}

expect(shouldPruneLaunchPrompt(prompt, [userMessage('u1', 'Fix failing checks')])).toBe(false)
expect(
shouldPruneLaunchPrompt(prompt, [
userMessage('u1', 'Fix failing checks'),
assistantMessage('a1', 'working')
])
).toBe(true)
})
})

describe('pending send cache', () => {
it('persists optimistic sends for the same pane and agent', () => {
clearPendingSendCacheForTests()
Expand Down Expand Up @@ -165,6 +248,13 @@ describe('isPendingMessageId', () => {
})
})

describe('isLaunchPromptMessageId', () => {
it('recognizes the launch prompt id prefix', () => {
expect(isLaunchPromptMessageId('launch-pending:tab-1')).toBe(true)
expect(isLaunchPromptMessageId('pending:p1')).toBe(false)
})
})

describe('commandMarkersAsMessages', () => {
it('renders a slash command as a system "Ran <cmd>" message', () => {
expect(commandMarkersAsMessages([{ id: 'c1', command: '/clear', sentAt: 7 }])).toEqual([
Expand Down
38 changes: 38 additions & 0 deletions src/renderer/src/components/native-chat/native-chat-pending.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

import { isTextBlock, type NativeChatMessage } from '../../../../shared/native-chat-types'
import { stripImagePromptMarker } from './native-chat-image-transcript-markers'
import type { NativeChatLaunchPrompt } from '@/lib/native-chat-launch-prompt'

/** An optimistic, not-yet-confirmed composer send. */
export type NativeChatPendingSend = {
Expand Down Expand Up @@ -152,6 +153,43 @@ export function isPendingMessageId(id: string): boolean {
return id.startsWith('pending:')
}

// Why: the seeded prompt has a synthetic id that never matches the real turn's,
// so dedup/prune match on normalized user-message text instead — this hides the
// optimistic bubble once the transcript's own copy of the turn catches up.
export function launchPromptAsMessage(
entry: NativeChatLaunchPrompt | null,
existingMessages: NativeChatMessage[] = []
): NativeChatMessage | null {
if (!entry) {
return null
}
const represented = matchingUserMessageTexts(existingMessages)
if (represented.has(normalize(entry.text))) {
return null
}
return {
id: `launch-pending:${entry.tabId}`,
role: 'user' as const,
blocks: entry.text.trim().length > 0 ? [{ type: 'text' as const, text: entry.text }] : [],
timestamp: entry.createdAt,
source: 'scrape' as const
}
}

// Why: prune only once an assistant turn has landed after the matching user
// text — keeping the optimistic bubble through the user-only phase avoids a
// first-turn flash before the transcript's own copy of the turn catches up.
export function shouldPruneLaunchPrompt(
entry: NativeChatLaunchPrompt,
messages: NativeChatMessage[]
): boolean {
return advancedPastUserMessageTexts(messages).has(normalize(entry.text))
}

export function isLaunchPromptMessageId(id: string): boolean {
return id.startsWith('launch-pending:')
}

/** A locally-recorded slash command (e.g. `/clear`). Slash commands dispatch to
* the agent's TUI and are not chat turns, so we surface a small system line as
* feedback that the command ran rather than echoing a user bubble. */
Expand Down
Loading
Loading