)
}
- if (block.presentation === 'command-output') {
+ if (look.kind === 'command-output') {
// Why: command output is laid out in columns; proportional type breaks its grid.
return (
@@ -82,20 +88,20 @@ export function NativeChatNoticeRow({
)
}
- if (isAgentSessionHostStatusPresentation(block.presentation)) {
+ if (look.kind === 'host-status') {
// The look of any other host status line; only the words are the reader's.
return (
- {HOST_STATUS_WORDS[block.presentation]()}
+ {HOST_STATUS_WORDS[look.presentation]()}
)
}
- if (block.presentation === 'plan-document') {
+ if (look.kind === 'plan') {
return (
- {translate('components.native-chat.notices.plan', 'Plan')}
+ {translate('components.native-chat.notices.plan', NATIVE_CHAT_NOTICE_COPY.plan)}
@@ -144,8 +150,7 @@ export function NativeChatNoticeRow({
sayAgentSessionFailureTranslated
)
: block.text
- const tone =
- named || block.presentation === AGENT_SESSION_ORCA_STOP_PRESENTATION ? 'notice' : block.tone
+ const tone = named ? 'notice' : look.kind === 'line' ? look.tone : null
const Icon =
tone === 'warning'
? AlertTriangle
diff --git a/src/renderer/src/components/native-chat/native-chat-live-message-preparation.ts b/src/renderer/src/components/native-chat/native-chat-live-message-preparation.ts
index bed16d90663..0639ba7d4bf 100644
--- a/src/renderer/src/components/native-chat/native-chat-live-message-preparation.ts
+++ b/src/renderer/src/components/native-chat/native-chat-live-message-preparation.ts
@@ -1,7 +1,7 @@
-import { getVerifiedNativeChatCommands } from '../../../../shared/native-chat-agent-profiles'
-import { surfaceSkillInvocationUserTurns } from '../../../../shared/native-chat-command-envelope'
-import { surfaceNativeChatCommandOutputs } from '../../../../shared/native-chat-command-output'
-import { normalizeImageTranscriptMessages } from '../../../../shared/native-chat-image-transcript-markers'
+import {
+ prepareNativeChatMessages,
+ surfaceNativeChatHarnessTurns
+} from '../../../../shared/native-chat-message-preparation'
import type { AgentType, NativeChatMessage } from '../../../../shared/native-chat-types'
import { assembleNativeChatSession } from './native-chat-session-assembler'
@@ -9,18 +9,13 @@ export function prepareNativeChatLiveMessages(
messages: NativeChatMessage[],
agent: AgentType
): NativeChatMessage[] {
- const commandNames = new Set(getVerifiedNativeChatCommands(agent).map((command) => command.name))
- const surfaced = surfaceNativeChatCommandOutputs(
- surfaceSkillInvocationUserTurns(messages, commandNames),
- agent
- )
- const normalized = normalizeImageTranscriptMessages(surfaced)
- if (!hasMixedSources(normalized)) {
- return normalized
+ const prepared = prepareNativeChatMessages(messages, agent)
+ if (!hasMixedSources(prepared)) {
+ return prepared
}
// A second pass preserves legacy cross-source winners after sorting or presentation transforms.
return assembleNativeChatSession({
- sources: { transcript: surfaced },
+ sources: { transcript: surfaceNativeChatHarnessTurns(messages, agent) },
sessionId: null,
agent
}).messages
diff --git a/src/renderer/src/i18n/en-runtime-required.json b/src/renderer/src/i18n/en-runtime-required.json
index 5a3863f6759..32ff25cbead 100644
--- a/src/renderer/src/i18n/en-runtime-required.json
+++ b/src/renderer/src/i18n/en-runtime-required.json
@@ -2939,8 +2939,11 @@
"writeFailed": "Orca couldn't hand this message to the agent, so it was not sent."
},
"notices": {
+ "compaction": "Context compacted",
+ "contextCleared": "Context cleared",
"historyItemTooLarge": "This part of the chat was too large to show.",
- "historyRepaired": "Part of this chat's history couldn't be loaded."
+ "historyRepaired": "Part of this chat's history couldn't be loaded.",
+ "plan": "Plan"
},
"question": {
"other": "Other…"
diff --git a/src/shared/native-chat-message-preparation.ts b/src/shared/native-chat-message-preparation.ts
new file mode 100644
index 00000000000..c61350a305c
--- /dev/null
+++ b/src/shared/native-chat-message-preparation.ts
@@ -0,0 +1,30 @@
+// What a chat reads from a terminal agent's transcript before folding it into rows. Desktop and
+// mobile both prepare through here, so a turn one of them draws is never dropped by the other.
+
+import type { AgentType } from './agent-status-types'
+import { getVerifiedNativeChatCommands } from './native-chat-agent-profiles'
+import { surfaceSkillInvocationUserTurns } from './native-chat-command-envelope'
+import { surfaceNativeChatCommandOutputs } from './native-chat-command-output'
+import { normalizeImageTranscriptMessages } from './native-chat-image-transcript-markers'
+import type { NativeChatMessage } from './native-chat-types'
+
+/** A skill invocation as the `/name args` the user sent, and a reporting command's reply as its
+ * output; the harness records both as envelopes the noise filter would otherwise hide. */
+export function surfaceNativeChatHarnessTurns(
+ messages: readonly NativeChatMessage[],
+ agent: AgentType
+): NativeChatMessage[] {
+ const commandNames = new Set(getVerifiedNativeChatCommands(agent).map((command) => command.name))
+ return surfaceNativeChatCommandOutputs(
+ surfaceSkillInvocationUserTurns(messages, commandNames),
+ agent
+ )
+}
+
+/** The harness turns surfaced, then image marker turns folded into the prompt they belong to. */
+export function prepareNativeChatMessages(
+ messages: readonly NativeChatMessage[],
+ agent: AgentType
+): NativeChatMessage[] {
+ return normalizeImageTranscriptMessages(surfaceNativeChatHarnessTurns(messages, agent))
+}
diff --git a/src/shared/native-chat-notice.test.ts b/src/shared/native-chat-notice.test.ts
new file mode 100644
index 00000000000..065f640aea3
--- /dev/null
+++ b/src/shared/native-chat-notice.test.ts
@@ -0,0 +1,44 @@
+import { describe, expect, it } from 'vitest'
+import { nativeChatNoticeBlock, nativeChatNoticeLook } from './native-chat-notice'
+import type { NativeChatTextBlock } from './native-chat-types'
+
+const block = (fields: Partial): NativeChatTextBlock => ({
+ type: 'text',
+ text: 'words',
+ ...fields
+})
+
+describe('nativeChatNoticeLook', () => {
+ it.each([
+ [{ presentation: 'compaction' }, { kind: 'divider', label: 'compaction' }],
+ [{ presentation: 'context-cleared' }, { kind: 'divider', label: 'contextCleared' }],
+ [{ presentation: 'command-output' }, { kind: 'command-output' }],
+ [
+ { presentation: 'history-repaired' },
+ { kind: 'host-status', presentation: 'history-repaired' }
+ ],
+ [{ presentation: 'plan-document' }, { kind: 'plan' }],
+ [{ presentation: 'stopped-before-start' }, { kind: 'aside' }],
+ // Stored red for clients that predate the orca-stop row; Orca stopped it, nothing failed.
+ [
+ { presentation: 'orca-stop', tone: 'error' },
+ { kind: 'line', tone: 'notice' }
+ ],
+ [
+ { presentation: 'compaction-skipped', tone: 'warning' },
+ { kind: 'line', tone: 'warning' }
+ ],
+ [{ tone: 'error' }, { kind: 'line', tone: 'error' }],
+ [{ tone: 'loud' }, { kind: 'line', tone: null }]
+ ] as const)('reads %j as %j', (fields, look) => {
+ expect(nativeChatNoticeLook(block(fields))).toEqual(look)
+ })
+})
+
+describe('nativeChatNoticeBlock', () => {
+ it('finds the block with a presentation or tone, and none in plain words', () => {
+ const notice = block({ tone: 'warning' })
+ expect(nativeChatNoticeBlock([block({}), notice])).toBe(notice)
+ expect(nativeChatNoticeBlock([block({})])).toBeUndefined()
+ })
+})
diff --git a/src/shared/native-chat-notice.ts b/src/shared/native-chat-notice.ts
new file mode 100644
index 00000000000..a6745279594
--- /dev/null
+++ b/src/shared/native-chat-notice.ts
@@ -0,0 +1,73 @@
+// How a chat draws a row the agent did not write in its own words: compaction and clear markers,
+// command output, host status, plans, and warnings or errors. Desktop translates these words (they
+// are its fallbacks); the phone shows them as they are.
+
+import {
+ isAgentSessionHostStatusPresentation,
+ type AgentSessionHostStatusPresentation
+} from './agent-session-host-status-rows'
+import { AGENT_SESSION_ORCA_STOP_PRESENTATION } from './agent-session-orca-stop'
+import { NATIVE_CHAT_STOPPED_BEFORE_START_PRESENTATION } from './native-chat-stopped-before-start'
+import type { NativeChatBlock, NativeChatTextBlock } from './native-chat-types'
+
+export const NATIVE_CHAT_NOTICE_COPY = {
+ compaction: 'Context compacted',
+ contextCleared: 'Context cleared',
+ plan: 'Plan'
+} as const
+
+export type NativeChatNoticeTone = 'warning' | 'error' | 'notice'
+
+export type NativeChatNoticeLook =
+ /** A rule across the chat with its label. */
+ | { kind: 'divider'; label: 'compaction' | 'contextCleared' }
+ /** Laid out in columns, so drawn in monospace. */
+ | { kind: 'command-output' }
+ /** The host's line in the reader's words, drawn faint. */
+ | { kind: 'host-status'; presentation: AgentSessionHostStatusPresentation }
+ | { kind: 'plan' }
+ /** Faint, as a system row with no notice of its own is. */
+ | { kind: 'aside' }
+ /** A boxed line with its icon when it has a tone; plain text when it has none. */
+ | { kind: 'line'; tone: NativeChatNoticeTone | null }
+
+/** The block a system row draws as its notice. A system row without one reads as a faint aside. */
+export function nativeChatNoticeBlock(
+ blocks: readonly NativeChatBlock[]
+): NativeChatTextBlock | undefined {
+ return blocks.find(
+ (block): block is NativeChatTextBlock =>
+ block.type === 'text' && (block.presentation !== undefined || block.tone !== undefined)
+ )
+}
+
+export function nativeChatNoticeLook(block: NativeChatTextBlock): NativeChatNoticeLook {
+ const { presentation } = block
+ if (presentation === 'compaction') {
+ return { kind: 'divider', label: 'compaction' }
+ }
+ if (presentation === 'context-cleared') {
+ return { kind: 'divider', label: 'contextCleared' }
+ }
+ if (presentation === 'command-output') {
+ return { kind: 'command-output' }
+ }
+ if (isAgentSessionHostStatusPresentation(presentation)) {
+ return { kind: 'host-status', presentation }
+ }
+ if (presentation === 'plan-document') {
+ return { kind: 'plan' }
+ }
+ if (presentation === NATIVE_CHAT_STOPPED_BEFORE_START_PRESENTATION) {
+ return { kind: 'aside' }
+ }
+ // Orca stopped the reply, not the agent: stored red for clients that predate it, read as a notice.
+ if (presentation === AGENT_SESSION_ORCA_STOP_PRESENTATION) {
+ return { kind: 'line', tone: 'notice' }
+ }
+ const { tone } = block
+ return {
+ kind: 'line',
+ tone: tone === 'warning' || tone === 'error' || tone === 'notice' ? tone : null
+ }
+}
diff --git a/src/shared/native-chat-streaming.spec.ts b/src/shared/native-chat-streaming.spec.ts
index 12ff5c7dfac..36646a1af2d 100644
--- a/src/shared/native-chat-streaming.spec.ts
+++ b/src/shared/native-chat-streaming.spec.ts
@@ -118,6 +118,28 @@ describe('deriveNativeChatStreamingText', () => {
})
).toBe('Partial answer that is now much longer than before')
})
+ it('hides the sentence it streamed while a tool runs, but not a new reply after the tool', () => {
+ // A Claude transcript writes the sentence and the tool call it led into as separate rows.
+ const sentence = 'Checking the notes file now, then I will compare it with the settings.'
+ const toolCall: NativeChatMessage = {
+ id: 'f4470ba3',
+ role: 'assistant',
+ blocks: [{ type: 'tool-call', name: 'Bash', input: { command: 'sleep 12' } }],
+ timestamp: null,
+ source: 'transcript'
+ }
+ const messages = [user('Say it, then run sleep 12.'), assistant(sentence), toolCall]
+ expect(
+ deriveNativeChatStreamingText({ messages, previewText: sentence, working: true })
+ ).toBeNull()
+ expect(
+ deriveNativeChatStreamingText({
+ messages,
+ previewText: 'The settings look correct.',
+ working: true
+ })
+ ).toBe('The settings look correct.')
+ })
})
describe('nativeChatStreamingMessage', () => {
diff --git a/src/shared/native-chat-streaming.ts b/src/shared/native-chat-streaming.ts
index eacce8dd2cb..b489abd0217 100644
--- a/src/shared/native-chat-streaming.ts
+++ b/src/shared/native-chat-streaming.ts
@@ -22,6 +22,25 @@ function assistantText(message: NativeChatMessage | undefined): string {
.trim()
}
+/** The newest assistant words after row `after`: read back past the tool calls and thoughts that
+ * landed after them, but never past the user's message, which starts a different reply. */
+export function latestNativeChatReplyText(
+ messages: readonly NativeChatMessage[],
+ after = -1
+): string {
+ for (let index = messages.length - 1; index > after; index -= 1) {
+ const message = messages[index]!
+ if (message.role === 'user') {
+ return ''
+ }
+ const text = assistantText(message)
+ if (text) {
+ return text
+ }
+ }
+ return ''
+}
+
/**
* Decide the streaming text to show, or null to show nothing. Returns the
* preview only while it leads the transcript — i.e. it's longer than (and not
@@ -54,6 +73,11 @@ export function deriveNativeChatStreamingText(args: {
if (lastText.includes(text) || text.length <= lastText.length) {
return null
}
+ // A tool call or thought after the sentence leaves the last row wordless. Hide only that same
+ // sentence: a new, shorter reply after the tool is not the earlier, longer commentary.
+ if (!lastText && latestNativeChatReplyText(messages).includes(text)) {
+ return null
+ }
return text
}