Skip to content
Open
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
34 changes: 16 additions & 18 deletions src/renderer/src/components/native-chat/NativeChatResolvedView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ import {
shouldPruneLaunchPrompt
} from './native-chat-pending'
import { useNativeChatPendingDelivery } from './use-native-chat-pending-delivery'
import { useClearNativeChatQueuedWhenIdle } from './use-clear-native-chat-queued-when-idle'
import {
appendCommandMarkerCache,
applyCommandMarkerBoundaries,
Expand Down Expand Up @@ -156,14 +157,13 @@ export function NativeChatResolvedView({
[paneKey, agent, sessionId]
)
const delivery = useNativeChatPendingDelivery({ paneKey, agent, session })
const { pending, record, clear } = delivery
const { pending, record, clear, clearQueued } = delivery
// Slash commands aren't chat turns, so they get a small local "Ran /clear"
// system line instead of a user bubble. Capped + cached per conversation.
const [commandMarkers, setCommandMarkers] = useState<NativeChatCommandMarker[]>(() =>
readCommandMarkerCache(commandMarkerScope)
)
// Command markers are session-scoped because slash commands like /clear are
// local feedback for a specific transcript boundary.
// Command markers are session-scoped (/clear is local transcript feedback).
useEffect(() => {
setCommandMarkers(readCommandMarkerCache(commandMarkerScope))
setWorkingInterrupted(false)
Expand All @@ -174,13 +174,6 @@ export function NativeChatResolvedView({
}
clearNativeChatLaunchPrompt(terminalTabId)
}, [clearNativeChatLaunchPrompt, paneLaunchPrompt, session.messages, terminalTabId])
const onOptimisticSend = useCallback(
(text: string, imagePaths?: string[]) => {
setWorkingInterrupted(false)
return record(text, imagePaths)
},
[record]
)
const onSlashCommand = useCallback(
(command: string, output?: string) => {
setCommandMarkers(appendCommandMarkerCache(commandMarkerScope, command, Date.now(), output))
Expand Down Expand Up @@ -244,9 +237,7 @@ export function NativeChatResolvedView({
isFocusedGroup,
composerReady: shownPromptCard === null && targetPtyId !== null && canSend
})

// The streaming preview bubble (if any) sits after the transcript but before
// the optimistic user echoes — same order mobile uses.
// Pending above stream; queued follow-ups wait after via rank.
const pendingMessages = useMemo(
() => pendingSendsAsMessages(pending, sessionAfterCommandBoundaries.messages),
[pending, sessionAfterCommandBoundaries.messages]
Expand All @@ -268,6 +259,7 @@ export function NativeChatResolvedView({
liveWorking,
hookPreviewIsToolOutput
])
useClearNativeChatQueuedWhenIdle(liveWorking, streamingText, clearQueued)
const sessionWithPending = useMemo<typeof session>(() => {
if (pending.length === 0 && commandMarkers.length === 0 && !streamingText) {
return sessionAfterCommandBoundaries
Expand All @@ -277,14 +269,20 @@ export function NativeChatResolvedView({
messages: [
...sessionAfterCommandBoundaries.messages,
...commandMarkersAsMessages(commandMarkers),
...(streamingText ? [nativeChatStreamingMessage(streamingText)] : []),
...pendingMessages
...pendingMessages,
...(streamingText ? [nativeChatStreamingMessage(streamingText)] : [])
]
}
}, [sessionAfterCommandBoundaries, pending, pendingMessages, commandMarkers, streamingText])
// Derive the view state from the pending-augmented session so a send into an
// otherwise-empty conversation flips to the list (showing the queued bubble)
// instead of staying on the empty state.
const onOptimisticSend = useCallback(
(text: string, imagePaths?: string[]) => {
setWorkingInterrupted(false)
// Mid-turn follow-ups queue behind the live reply; first prompt of the turn does not.
return record(text, imagePaths, { queued: Boolean(liveWorking || streamingText) })
},
[liveWorking, record, streamingText]
)
// Pending-augmented session drives empty→list flip on first send.
const viewState = selectNativeChatViewState(sessionWithPending)

const isConversation = viewState.kind === 'ready'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest'
import { NATIVE_CHAT_STREAMING_ID } from '../../../../shared/native-chat-streaming'
import { clearQueuedOnPendingSends } from './native-chat-clear-queued-pending-sends'
import { pendingSendsAsMessages } from './native-chat-pending'
import { compareMessages } from './native-chat-session-assembler'

describe('clearQueuedOnPendingSends', () => {
it('clears sticky queued so a follow-up sorts as an ordinary prompt above a later stream', () => {
const pending = [
{ id: 'p1', text: 'trigger', sentAt: 10 },
{ id: 'p2', text: 'follow-up', sentAt: 20, queued: true as const }
]
const cleared = clearQueuedOnPendingSends(pending)
expect(cleared).toEqual([
{ id: 'p1', text: 'trigger', sentAt: 10 },
{ id: 'p2', text: 'follow-up', sentAt: 20 }
])
expect(clearQueuedOnPendingSends(cleared)).toBe(cleared)

const projected = pendingSendsAsMessages(cleared)
const streaming = {
id: NATIVE_CHAT_STREAMING_ID,
role: 'assistant' as const,
blocks: [{ type: 'text' as const, text: '…' }],
timestamp: null,
source: 'hook' as const
}
expect([...projected, streaming].sort(compareMessages).map((message) => message.id)).toEqual([
'pending:p1',
'pending:p2',
NATIVE_CHAT_STREAMING_ID
])
})
})
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import type { NativeChatPendingSend } from './native-chat-pending'

/**
* Drop sticky mid-turn `queued` once the live turn that required queueing has
* gone idle. Remaining pendings then sort as ordinary rank-0 prompts above any
* later stream instead of staying pinned behind it.
*/
export function clearQueuedOnPendingSends(
pending: NativeChatPendingSend[]
): NativeChatPendingSend[] {
if (!pending.some((entry) => entry.queued)) {
return pending
}
return pending.map((entry) => {
if (!entry.queued) {
return entry
}
const { queued: _queued, ...rest } = entry
return rest
})
}
Original file line number Diff line number Diff line change
Expand Up @@ -33,12 +33,29 @@ describe('orderNativeChatMessages', () => {
expect(ordered.map((m) => m.id)).toEqual(['a', 'z'])
})

it('sorts the streaming preview after real content but before optimistic echoes', () => {
it('sorts a pending user prompt before an active streaming assistant preview triggered by it', () => {
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'])
expect(ordered.map((m) => m.id)).toEqual(['real-user', 'pending:abc', 'streaming'])
})

it('sorts a pending user prompt before a subsequent assistant response', () => {
const ordered = orderNativeChatMessages([
msg({ id: 'resp-1', role: 'assistant', timestamp: 200 }),
msg({ id: 'pending:abc', role: 'user', timestamp: 100, source: 'scrape' })
])
expect(ordered.map((m) => m.id)).toEqual(['pending:abc', 'resp-1'])
})

it('sorts a queued prompt after an active streaming assistant preview', () => {
const ordered = orderNativeChatMessages([
msg({ id: 'pending:queued', role: 'user', timestamp: 30, source: 'scrape', queued: true }),
msg({ id: NATIVE_CHAT_STREAMING_ID, timestamp: null }),
msg({ id: 'pending:trigger', role: 'user', timestamp: 20, source: 'scrape' })
])
expect(ordered.map((m) => m.id)).toEqual(['pending:trigger', 'streaming', 'pending:queued'])
})
})
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import {
readCommandMarkerCache
} from './native-chat-command-marker'
import { stripNoiseMessages } from './native-chat-noise'
import { LIFECYCLE_CLOCK_SKEW_SLACK_MS } from './native-chat-live-status'

function userMessage(id: string, text: string, timestamp = 1): NativeChatMessage {
return {
Expand Down Expand Up @@ -206,6 +207,33 @@ describe('prunePendingSends', () => {
expect(prunePendingSends(pending, transcript)).toEqual([])
})

it('prunes a wall-clock first send when host timestamp is within clock skew slack', () => {
const sentAt = 1_700_000_000_000
const pending: NativeChatPendingSend[] = [
{ id: 'p1', text: 'fix the bug', sentAt, afterMessageId: null }
]
const transcript = [
userMessage('u1', 'fix the bug', sentAt - 1500),
assistantMessage('a1', 'done', sentAt - 1000)
]

expect(prunePendingSends(pending, transcript)).toEqual([])
})

it('does not prune a wall-clock first send when host timestamp is beyond clock skew slack', () => {
const sentAt = 1_700_000_000_000
const pending: NativeChatPendingSend[] = [
{ id: 'p1', text: 'fix the bug', sentAt, afterMessageId: null }
]
const hostTs = sentAt - (LIFECYCLE_CLOCK_SKEW_SLACK_MS + 500)
const transcript = [
userMessage('u1', 'fix the bug', hostTs),
assistantMessage('a1', 'done', hostTs + 1)
]

expect(prunePendingSends(pending, transcript)).toEqual(pending)
})

it('prunes only one of two identical pending sends for one completed turn', () => {
const pending = [pendingOf('p1', 'repeat'), pendingOf('p2', 'repeat')]
expect(
Expand Down Expand Up @@ -469,6 +497,17 @@ describe('pendingSendsAsMessages', () => {
])
})

it('projects entry.queued onto the optimistic message so follow-ups rank after streaming', () => {
const messages = pendingSendsAsMessages([
{ id: 'p1', text: 'trigger', sentAt: 10 },
{ id: 'p2', text: 'follow-up', sentAt: 20, queued: true }
])
expect(messages.map((message) => ({ id: message.id, queued: message.queued }))).toEqual([
{ id: 'pending:p1', queued: undefined },
{ id: 'pending:p2', queued: true }
])
})

it('includes image refs for pending attachment sends', () => {
const messages = pendingSendsAsMessages([
{ id: 'p1', text: 'what do you see?', imagePaths: ['/tmp/shot.png'], sentAt: 42 }
Expand Down Expand Up @@ -684,6 +723,41 @@ describe('launchPromptAsMessage', () => {
expect(launchPromptAsMessage(entry, oldHistory)).not.toBeNull()
expect(shouldPruneLaunchPrompt(entry, oldHistory)).toBe(false)
})

it('hides and prunes a launch prompt when host turn is within clock skew slack', () => {
const createdAt = 1_700_000_000_000
const entry = {
tabId: 'tab-1',
agent: 'claude' as const,
text: 'run tests',
createdAt
}
const hostTs = createdAt - 1500
const userOnly = [userMessage('u1', 'run tests', hostTs)]
const advanced = [...userOnly, assistantMessage('a1', 'done', hostTs + 1)]

expect(launchPromptAsMessage(entry, userOnly)).toBeNull()
expect(shouldPruneLaunchPrompt(entry, userOnly)).toBe(false)
expect(shouldPruneLaunchPrompt(entry, advanced)).toBe(true)
})

it('does not bind a launch prompt to a turn beyond clock skew slack', () => {
const createdAt = 1_700_000_000_000
const entry = {
tabId: 'tab-1',
agent: 'claude' as const,
text: 'run tests',
createdAt
}
const oldTs = createdAt - (LIFECYCLE_CLOCK_SKEW_SLACK_MS + 500)
const oldHistory = [
userMessage('old-user', 'run tests', oldTs),
assistantMessage('old-answer', 'passed', oldTs + 1)
]

expect(launchPromptAsMessage(entry, oldHistory)).not.toBeNull()
expect(shouldPruneLaunchPrompt(entry, oldHistory)).toBe(false)
})
})

describe('pending send cache', () => {
Expand Down
54 changes: 42 additions & 12 deletions src/renderer/src/components/native-chat/native-chat-pending.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
type NativeChatGluedUserRow,
type NativeChatUserRow
} from './native-chat-pending-occurrence'
import { LIFECYCLE_CLOCK_SKEW_SLACK_MS } from './native-chat-live-status'

/** An optimistic, not-yet-confirmed composer send. */
export type NativeChatPendingSend = {
Expand Down Expand Up @@ -47,6 +48,8 @@ export type NativeChatPendingSend = {
matchingOccurrence?: number
/** Shared time boundary when that message boundary is unavailable. */
matchingAfterTimestamp?: number
/** Queued while the agent was already streaming/working — sorts after the live reply. */
queued?: boolean
}

export type NativeChatPendingSendScope = {
Expand Down Expand Up @@ -163,11 +166,21 @@ function messageIsAfterPendingTimestamp(
return true
}
const boundary = nativeChatPendingMatchingAfter(pending)
// A transcript-clock boundary describes an existing message, so exclude ties.
// Local send time has no existing record and remains inclusive.
return pending.afterMessageTimestamp == null
? message.timestamp >= boundary
: message.timestamp > boundary
if (pending.afterMessageTimestamp == null) {
// Local send time has no existing record. Slack accounts for cross-host
// clock skew between the client renderer's sentAt and the host transcript clock.
if (
message.timestamp > 1e11 &&
boundary > 1e11 &&
message.timestamp + LIFECYCLE_CLOCK_SKEW_SLACK_MS >= boundary
) {
return true
}
return message.timestamp >= boundary
}
// A transcript-clock boundary describes an existing message from the same host,
// so no cross-host clock skew exists and ties are strictly excluded.
return message.timestamp > boundary
}

/**
Expand Down Expand Up @@ -254,10 +267,11 @@ export function prunePendingSends(
}

/**
* Turn pending sends into chat messages so they render in the list as queued
* Turn pending sends into chat messages so they render in the list as optimistic
* user bubbles. They carry the `scrape` source (lowest priority) so the real
* transcript turn always supersedes them if both are briefly present, and the
* send time as the timestamp so they sort to the end (most recent) of the list.
* send time as the timestamp so they sort with ordinary transcript order.
* Mid-turn follow-ups set `queued: true` so they wait behind the streaming preview.
*/
export function pendingSendsAsMessages(
pending: NativeChatPendingSend[],
Expand Down Expand Up @@ -303,7 +317,8 @@ export function pendingSendsAsMessages(
...(entry.text.trim().length > 0 ? [{ type: 'text' as const, text: entry.text }] : [])
],
timestamp: entry.sentAt,
source: 'scrape' as const
source: 'scrape' as const,
...(entry.queued ? { queued: true as const } : {})
}))
}

Expand All @@ -312,6 +327,21 @@ export function isPendingMessageId(id: string): boolean {
return id.startsWith('pending:')
}

/** Include transcript rows at/after a launch seed, with wall-clock skew slack. */
function messageIsAtOrAfterLaunchCreatedAt(message: NativeChatMessage, createdAt: number): boolean {
if (message.timestamp === null) {
return true
}
if (
message.timestamp > 1e11 &&
createdAt > 1e11 &&
message.timestamp + LIFECYCLE_CLOCK_SKEW_SLACK_MS >= createdAt
) {
return true
}
return message.timestamp >= createdAt
}

// 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.
Expand All @@ -325,8 +355,8 @@ export function launchPromptAsMessage(
// Why: a launch prompt seeds a brand-new session, so a matching user turn
// with no timestamp (e.g. Grok transcripts) can only be its own delivery.
const represented = matchingNativeChatUserContentCounts(
existingMessages.filter(
(message) => message.timestamp === null || message.timestamp >= entry.createdAt
existingMessages.filter((message) =>
messageIsAtOrAfterLaunchCreatedAt(message, entry.createdAt)
)
)
if ((represented.get(nativeChatPendingContentKey(entry)) ?? 0) > 0) {
Expand All @@ -348,8 +378,8 @@ export function shouldPruneLaunchPrompt(
entry: NativeChatLaunchPrompt,
messages: NativeChatMessage[]
): boolean {
const relevant = messages.filter(
(message) => message.timestamp === null || message.timestamp >= entry.createdAt
const relevant = messages.filter((message) =>
messageIsAtOrAfterLaunchCreatedAt(message, entry.createdAt)
)
return (
(advancedNativeChatUserContentCounts(relevant).get(nativeChatPendingContentKey(entry)) ?? 0) > 0
Expand Down
Loading