From 287bcad89fe021f3977fb1d5136c699ce960e8c3 Mon Sep 17 00:00:00 2001 From: Jinjing <6427696+AmethystLiang@users.noreply.github.com> Date: Sun, 5 Jul 2026 14:44:21 -0700 Subject: [PATCH] Support native-prefill prompt delivery and fix chat message sorting * Treat native draft pre-fills as successful deliveries instead of marking the seeded launch prompts as failed. * Group native chat messages into sorting tiers (real content, streaming preview, optimistic echoes) so optimistic bubbles don't sort past the streaming preview due to finite timestamps. --- .../native-chat-message-grouping.test.ts | 8 +++--- .../native-chat/native-chat-pending.ts | 3 ++ .../native-chat-session-assembler.ts | 28 +++++++++++++------ .../lib/agent-launch-prompt-delivery.test.ts | 19 +++++++++++++ .../src/lib/agent-launch-prompt-delivery.ts | 10 +++++-- .../src/lib/agent-native-draft-prefill.ts | 17 +++++++++++ src/renderer/src/lib/agent-paste-draft.ts | 5 ++-- 7 files changed, 74 insertions(+), 16 deletions(-) create mode 100644 src/renderer/src/lib/agent-native-draft-prefill.ts diff --git a/src/renderer/src/components/native-chat/native-chat-message-grouping.test.ts b/src/renderer/src/components/native-chat/native-chat-message-grouping.test.ts index 1553cdc913f..7df292d5ed9 100644 --- a/src/renderer/src/components/native-chat/native-chat-message-grouping.test.ts +++ b/src/renderer/src/components/native-chat/native-chat-message-grouping.test.ts @@ -33,13 +33,13 @@ describe('orderNativeChatMessages', () => { expect(ordered.map((m) => m.id)).toEqual(['a', 'z']) }) - it('sorts the synthetic streaming message after timestamped content', () => { + 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 }), - msg({ id: 'pending-user', role: 'user', timestamp: 20, source: 'scrape' }) + msg({ id: 'real-user', role: 'user', timestamp: 10 }) ]) - expect(ordered.map((m) => m.id)).toEqual(['real-user', 'pending-user', 'streaming']) + expect(ordered.map((m) => m.id)).toEqual(['real-user', 'streaming', 'pending:abc']) }) }) diff --git a/src/renderer/src/components/native-chat/native-chat-pending.ts b/src/renderer/src/components/native-chat/native-chat-pending.ts index 9e82f52b924..e76349a9a62 100644 --- a/src/renderer/src/components/native-chat/native-chat-pending.ts +++ b/src/renderer/src/components/native-chat/native-chat-pending.ts @@ -153,6 +153,9 @@ 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[] = [] diff --git a/src/renderer/src/components/native-chat/native-chat-session-assembler.ts b/src/renderer/src/components/native-chat/native-chat-session-assembler.ts index 1d48780832b..53ebbd0084a 100644 --- a/src/renderer/src/components/native-chat/native-chat-session-assembler.ts +++ b/src/renderer/src/components/native-chat/native-chat-session-assembler.ts @@ -8,6 +8,7 @@ import { } from '../../../../shared/native-chat-types' import { NATIVE_CHAT_STREAMING_ID } from '../../../../shared/native-chat-streaming' import { normalizeImageTranscriptMessages } from './native-chat-image-transcript-markers' +import { isLaunchPromptMessageId, isPendingMessageId } from './native-chat-pending' /** Messages grouped by source. Higher-priority sources (transcript > hook > * scrape) supersede lower ones when they describe the same turn. */ @@ -85,20 +86,31 @@ function supersedes(candidate: NativeChatMessage, existing: NativeChatMessage): return candidateRank > existingRank } -function messageSortTimestamp(message: NativeChatMessage): number { +// Why: the tail bubbles form fixed tiers that timestamps alone can't express. +// The streaming preview (null timestamp) must follow real content but sit ahead +// of the optimistic composer echoes, which carry finite `sentAt` timestamps that +// would otherwise sort past it. Rank first, then timestamp within a tier. +function messageSortRank(message: NativeChatMessage): number { if (message.id === NATIVE_CHAT_STREAMING_ID) { - return Number.POSITIVE_INFINITY + return 1 } - return message.timestamp ?? Number.NEGATIVE_INFINITY + if (isPendingMessageId(message.id) || isLaunchPromptMessageId(message.id)) { + return 2 + } + return 0 } // Why: null timestamps (sources that can't supply one, e.g. scrape segments) -// sort before any real timestamp so they don't jump to the end. The synthetic -// streaming preview is the one exception: it is a tail bubble and sorts last. -// Ties break on id for a stable, deterministic order. +// sort before any real timestamp within their tier so they don't jump to the +// end. Ties break on id for a stable, deterministic order. export function compareMessages(a: NativeChatMessage, b: NativeChatMessage): number { - const at = messageSortTimestamp(a) - const bt = messageSortTimestamp(b) + const ar = messageSortRank(a) + const br = messageSortRank(b) + if (ar !== br) { + return ar - br + } + const at = a.timestamp ?? Number.NEGATIVE_INFINITY + const bt = b.timestamp ?? Number.NEGATIVE_INFINITY if (at !== bt) { return at - bt } diff --git a/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts b/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts index 5ea7aacda65..577db1e534e 100644 --- a/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts +++ b/src/renderer/src/lib/agent-launch-prompt-delivery.test.ts @@ -97,6 +97,25 @@ describe('deliverLaunchPromptToAgentTab', () => { expect(mocks.markNativeChatLaunchPromptFailed).toHaveBeenCalledWith('tab-1') }) + it('treats native-prefill delivery as success without flagging the seeded prompt', async () => { + // claude delivers via `--prefill` at launch, so paste no-ops (returns false) + // when forcePaste is false — that is a native delivery, not a failure. + mocks.pasteDraftWhenAgentReady.mockResolvedValue(false) + + await expect( + deliverLaunchPromptToAgentTab({ + tabId: 'tab-1', + agent: 'claude', + content: 'Large generated prompt', + submit: true, + forcePaste: false + }) + ).resolves.toBe(true) + + expect(mocks.seedNativeChatLaunchPrompt).toHaveBeenCalled() + expect(mocks.markNativeChatLaunchPromptFailed).not.toHaveBeenCalled() + }) + it('does not mark unseeded launches failed', async () => { mocks.pasteDraftWhenAgentReady.mockResolvedValue(false) diff --git a/src/renderer/src/lib/agent-launch-prompt-delivery.ts b/src/renderer/src/lib/agent-launch-prompt-delivery.ts index fc56f81d0fc..1569c5f1a92 100644 --- a/src/renderer/src/lib/agent-launch-prompt-delivery.ts +++ b/src/renderer/src/lib/agent-launch-prompt-delivery.ts @@ -1,3 +1,4 @@ +import { agentDeliversDraftViaNativePrefill } from '@/lib/agent-native-draft-prefill' import { pasteDraftWhenAgentReady } from '@/lib/agent-paste-draft' import { isNativeChatSupportedAgent } from '@/lib/native-chat-supported-agent' import { useAppStore } from '@/store' @@ -25,6 +26,11 @@ export function deliverLaunchPromptToAgentTab(args: { }) } + // Why: native-prefill agents (claude/openclaude etc.) get the prompt at launch, + // so pasteDraftWhenAgentReady returns false without pasting. That is a successful + // native delivery, not a failure — don't flag the seeded bubble in that case. + const deliversViaNativePrefill = agentDeliversDraftViaNativePrefill(agent, forcePaste) + return pasteDraftWhenAgentReady({ tabId, content, @@ -34,9 +40,9 @@ export function deliverLaunchPromptToAgentTab(args: { timeoutMs, onTimeout }).then((delivered) => { - if (shouldSeed && !delivered) { + if (shouldSeed && !delivered && !deliversViaNativePrefill) { useAppStore.getState().markNativeChatLaunchPromptFailed(tabId) } - return delivered + return delivered || deliversViaNativePrefill }) } diff --git a/src/renderer/src/lib/agent-native-draft-prefill.ts b/src/renderer/src/lib/agent-native-draft-prefill.ts new file mode 100644 index 00000000000..797b25e7827 --- /dev/null +++ b/src/renderer/src/lib/agent-native-draft-prefill.ts @@ -0,0 +1,17 @@ +import type { TuiAgent } from '../../../shared/types' +import { TUI_AGENT_CONFIG } from '../../../shared/tui-agent-config' + +// Why: agents with a native draft-prefill flag/env launch with the prompt +// already in their input box, so the paste helpers intentionally no-op (return +// false) unless `forcePaste` overrides. Callers use this to tell "delivered +// natively" apart from a real paste failure. +export function agentDeliversDraftViaNativePrefill( + agent: TuiAgent | undefined, + forcePaste: boolean | undefined +): boolean { + if (forcePaste) { + return false + } + const agentConfig = agent ? TUI_AGENT_CONFIG[agent] : null + return Boolean(agentConfig?.draftPromptFlag || agentConfig?.draftPromptEnvVar) +} diff --git a/src/renderer/src/lib/agent-paste-draft.ts b/src/renderer/src/lib/agent-paste-draft.ts index c840596de2c..e9246310881 100644 --- a/src/renderer/src/lib/agent-paste-draft.ts +++ b/src/renderer/src/lib/agent-paste-draft.ts @@ -14,6 +14,7 @@ import { waitForAgentReady } from './agent-ready-wait' import { getSettingsForWorktreeRuntimeOwner } from './worktree-runtime-owner' import type { GlobalSettings } from '../../../shared/types' import { sendAgentDraftPasteContent } from './agent-draft-paste-content' +import { agentDeliversDraftViaNativePrefill } from './agent-native-draft-prefill' import { waitForAgentDraftInputReady } from './agent-draft-readiness' import { isExpectedAgentProcess } from '../../../shared/agent-process-recognition' export { @@ -91,7 +92,7 @@ export async function pasteDraftWhenAgentReady(args: { // duplicate it. Callers should not invoke this helper for those agents; // the early return guards against accidental double-injection if a stale // call slips through. - if (!forcePaste && (agentConfig?.draftPromptFlag || agentConfig?.draftPromptEnvVar)) { + if (agentDeliversDraftViaNativePrefill(agent, forcePaste)) { return false } @@ -140,7 +141,7 @@ export async function pasteDraftToAgentPtyWhenReady(args: { const { tabId, ptyId, content, agent, submit, forcePaste, timeoutMs, onTimeout } = args const agentConfig = agent ? TUI_AGENT_CONFIG[agent] : null - if (!forcePaste && (agentConfig?.draftPromptFlag || agentConfig?.draftPromptEnvVar)) { + if (agentDeliversDraftViaNativePrefill(agent, forcePaste)) { return false }