From ef950401d3be7fdd44c44ebbeb4f196fdcbda165 Mon Sep 17 00:00:00 2001
From: Kelvin Amoaba <97001695+AmoabaKelvin@users.noreply.github.com>
Date: Tue, 20 Oct 2026 23:13:32 +0000
Subject: [PATCH] fix(native-chat): show prompts sent mid-turn and `!` commands
where Claude read them (#31204)
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Co-authored-by: Paweł Kotowski
---
.../native-chat/transcript-file-order.test.ts | 126 ++++++++++++++++++
src/main/native-chat/transcript-file-order.ts | 17 +++
.../transcript-incremental-reader.ts | 11 +-
...line-decoders-claude-queued-prompt.test.ts | 88 ++++++++++++
.../transcript-line-decoders-claude.ts | 40 +++++-
.../native-chat/transcript-stream-lines.ts | 6 +-
.../native-chat/transcript-tail-reader.ts | 11 +-
.../transcript-turn-lifecycle.test.ts | 36 +++++
.../native-chat/transcript-turn-lifecycle.ts | 16 ++-
.../native-chat/transcript-watch-engine.ts | 2 +
src/shared/claude-bash-input.test.ts | 19 +++
src/shared/claude-bash-input.ts | 18 +++
12 files changed, 382 insertions(+), 8 deletions(-)
create mode 100644 src/main/native-chat/transcript-file-order.test.ts
create mode 100644 src/main/native-chat/transcript-file-order.ts
create mode 100644 src/main/native-chat/transcript-line-decoders-claude-queued-prompt.test.ts
create mode 100644 src/shared/claude-bash-input.test.ts
create mode 100644 src/shared/claude-bash-input.ts
diff --git a/src/main/native-chat/transcript-file-order.test.ts b/src/main/native-chat/transcript-file-order.test.ts
new file mode 100644
index 00000000000..d7a9767581d
--- /dev/null
+++ b/src/main/native-chat/transcript-file-order.test.ts
@@ -0,0 +1,126 @@
+import { appendFile, mkdtemp, rm, writeFile } from 'node:fs/promises'
+import { tmpdir } from 'node:os'
+import { join } from 'node:path'
+import { afterEach, describe, expect, it } from 'vitest'
+import type { NativeChatMessage } from '../../shared/native-chat-types'
+import { projectNativeChatTranscriptMessages } from '../../shared/native-chat-transcript-projection'
+import { readNativeChatTranscript } from './transcript-reader'
+import { readNativeChatTranscriptTail, subscribeNativeChatTranscript } from './transcript-watch'
+
+// A send while a tool runs: Claude stamps the queue entry at send time (10:00:10)
+// but writes it after the tool result it read it with (10:00:30).
+const PROMPT = {
+ type: 'user',
+ uuid: 'u-1',
+ timestamp: '2026-06-01T10:00:00.000Z',
+ message: { role: 'user', content: 'start the long task' }
+}
+const TOOL_CALL = {
+ type: 'assistant',
+ uuid: 'a-1',
+ timestamp: '2026-06-01T10:00:01.000Z',
+ message: {
+ role: 'assistant',
+ content: [{ type: 'tool_use', id: 'toolu_1', name: 'Bash', input: { command: 'sleep 30' } }]
+ }
+}
+const TOOL_RESULT = {
+ type: 'user',
+ uuid: 'r-1',
+ timestamp: '2026-06-01T10:00:30.000Z',
+ message: {
+ role: 'user',
+ content: [{ type: 'tool_result', tool_use_id: 'toolu_1', content: 'done' }]
+ }
+}
+const QUEUED = {
+ type: 'attachment',
+ uuid: 'q-1',
+ parentUuid: 'r-1',
+ timestamp: '2026-06-01T10:00:10.000Z',
+ attachment: {
+ type: 'queued_command',
+ prompt: 'also check the config',
+ commandMode: 'prompt',
+ origin: { kind: 'human' },
+ timestamp: '2026-06-01T10:00:10.000Z'
+ }
+}
+const REPLY = {
+ type: 'assistant',
+ uuid: 'a-2',
+ timestamp: '2026-06-01T10:00:35.000Z',
+ message: {
+ role: 'assistant',
+ stop_reason: 'end_turn',
+ content: [{ type: 'text', text: 'both done' }]
+ }
+}
+
+const lines = (...records: object[]): string =>
+ records.map((record) => `${JSON.stringify(record)}\n`).join('')
+
+let roots: string[] = []
+afterEach(async () => {
+ await Promise.all(roots.map((root) => rm(root, { recursive: true, force: true })))
+ roots = []
+})
+
+async function transcriptFile(content: string): Promise {
+ const root = await mkdtemp(join(tmpdir(), 'orca-transcript-file-order-'))
+ roots.push(root)
+ const filePath = join(root, 'session.jsonl')
+ await writeFile(filePath, content)
+ return filePath
+}
+
+/** What the chat draws: the prompt after the tool run, with the call holding its result. */
+function expectPromptAfterToolRun(messages: NativeChatMessage[]): void {
+ const rows = projectNativeChatTranscriptMessages(messages)
+ expect(rows.map((row) => row.id)).toEqual(['u-1', 'a-1', 'q-1', 'a-2'])
+ expect(rows[1]?.blocks.map((block) => block.type)).toEqual(['tool-call', 'tool-result'])
+}
+
+describe('transcript rows keep file order', () => {
+ it.each([
+ ['full read', (filePath: string) => readNativeChatTranscript('claude', 's', { filePath })],
+ [
+ 'tail read',
+ (filePath: string) =>
+ readNativeChatTranscriptTail({ agent: 'claude', sessionId: 's', filePath, limit: 40 })
+ ]
+ ])(
+ 'places a mid-turn prompt after the tool result it was read with (%s)',
+ async (_name, read) => {
+ const filePath = await transcriptFile(lines(PROMPT, TOOL_CALL, TOOL_RESULT, QUEUED, REPLY))
+ const result = await read(filePath)
+ if (!('messages' in result)) {
+ throw new Error(result.error)
+ }
+ expectPromptAfterToolRun(result.messages)
+ }
+ )
+
+ it('places a mid-turn prompt appended after the initial snapshot', async () => {
+ const filePath = await transcriptFile(lines(PROMPT, TOOL_CALL, TOOL_RESULT))
+ const received: NativeChatMessage[] = []
+ const subscription = await subscribeNativeChatTranscript({
+ agent: 'claude',
+ sessionId: 's',
+ filePath,
+ initialLimit: 40,
+ onInitialSnapshot: (messages) => received.push(...messages),
+ onAppend: (messages) => received.push(...messages),
+ debounceMs: 5,
+ reconciliationIntervalMs: 20
+ })
+ try {
+ await expect.poll(() => received.length).toBe(3)
+ await appendFile(filePath, lines(QUEUED, REPLY))
+ await expect.poll(() => received.length).toBe(5)
+ expectPromptAfterToolRun(received)
+ } finally {
+ subscription.unsubscribe()
+ }
+ })
+})
diff --git a/src/main/native-chat/transcript-file-order.ts b/src/main/native-chat/transcript-file-order.ts
new file mode 100644
index 00000000000..0404cc00844
--- /dev/null
+++ b/src/main/native-chat/transcript-file-order.ts
@@ -0,0 +1,17 @@
+import type { NativeChatMessage } from '../../shared/native-chat-types'
+
+/** Desktop orders terminal transcript rows by time, but the file holds the true order:
+ * Claude stamps a prompt it took mid-turn with when it was queued, then writes it
+ * where it read it. So a row never sorts above the rows before it. `latest` is the
+ * newest time above this row; returns the row and the newest time including it. */
+export function inTranscriptFileOrder(
+ message: NativeChatMessage,
+ latest: number
+): { message: NativeChatMessage; latest: number } {
+ if (message.timestamp === null || message.timestamp >= latest) {
+ return { message, latest: Math.max(latest, message.timestamp ?? latest) }
+ }
+ // Why: one past, not equal: equal times fall back to id order, which can still put it first.
+ const timestamp = latest + 1
+ return { message: { ...message, timestamp }, latest: timestamp }
+}
diff --git a/src/main/native-chat/transcript-incremental-reader.ts b/src/main/native-chat/transcript-incremental-reader.ts
index 67bd3dce1e4..be93314d320 100644
--- a/src/main/native-chat/transcript-incremental-reader.ts
+++ b/src/main/native-chat/transcript-incremental-reader.ts
@@ -1,6 +1,7 @@
import type { NativeChatMessage, NativeChatTurnLifecycle } from '../../shared/native-chat-types'
import { transcriptFallbackId } from './transcript-fallback-id'
import { extendTranscriptBoundary } from './transcript-file-version'
+import { inTranscriptFileOrder } from './transcript-file-order'
import {
MAX_NATIVE_CHAT_TRANSCRIPT_RECORD_BYTES,
type NativeChatLineDecoder
@@ -16,6 +17,8 @@ export type IncrementalTranscriptState = {
pendingStart: number
pendingBytes: number
droppingOversizedRecord: boolean
+ /** Newest row time read so far, so an append never sorts above earlier rows. */
+ latestTimestamp: number
}
export function createIncrementalTranscriptState(): IncrementalTranscriptState {
@@ -25,7 +28,8 @@ export function createIncrementalTranscriptState(): IncrementalTranscriptState {
pendingChunks: [],
pendingStart: 0,
pendingBytes: 0,
- droppingOversizedRecord: false
+ droppingOversizedRecord: false,
+ latestTimestamp: Number.NEGATIVE_INFINITY
}
}
@@ -36,6 +40,7 @@ export function resetIncrementalTranscriptState(state: IncrementalTranscriptStat
state.pendingStart = 0
state.pendingBytes = 0
state.droppingOversizedRecord = false
+ state.latestTimestamp = Number.NEGATIVE_INFINITY
}
export async function readIncrementalTranscriptMessages(
@@ -127,7 +132,9 @@ export async function readIncrementalTranscriptMessages(
if (!message) {
return
}
- messages.push(message)
+ const ordered = inTranscriptFileOrder(message, state.latestTimestamp)
+ state.latestTimestamp = ordered.latest
+ messages.push(ordered.message)
if (onBatch && messages.length >= APPEND_BATCH_MESSAGE_LIMIT) {
onBatch(messages.splice(0))
}
diff --git a/src/main/native-chat/transcript-line-decoders-claude-queued-prompt.test.ts b/src/main/native-chat/transcript-line-decoders-claude-queued-prompt.test.ts
new file mode 100644
index 00000000000..7d2252ec6ae
--- /dev/null
+++ b/src/main/native-chat/transcript-line-decoders-claude-queued-prompt.test.ts
@@ -0,0 +1,88 @@
+import { describe, expect, it } from 'vitest'
+import { decodeClaudeTranscriptLine } from './transcript-line-decoders-claude'
+
+// Shape captured from Claude Code 2.1.292 for a prompt absorbed mid-turn.
+function queuedCommand(attachment: Record): string {
+ return JSON.stringify({
+ parentUuid: 'tool-result-row',
+ isSidechain: false,
+ attachment: {
+ type: 'queued_command',
+ source_uuid: 'cmd-1',
+ delivery_id: 'delivery-1',
+ timestamp: '2026-10-06T21:49:54.910Z',
+ ...attachment
+ },
+ type: 'attachment',
+ uuid: 'queued-row',
+ timestamp: '2026-10-06T21:49:54.910Z',
+ rendered: [{ content: '\nThe user sent a new message…' }]
+ })
+}
+
+describe('Claude queued_command attachment', () => {
+ it('decodes a prompt absorbed mid-turn as the user row Claude never writes', () => {
+ const line = queuedCommand({
+ prompt: 'or you know what, use fable as orchestrator',
+ commandMode: 'prompt',
+ origin: { kind: 'human' },
+ humanTurn: true
+ })
+ expect(decodeClaudeTranscriptLine(line, 'fallback')).toEqual({
+ id: 'queued-row',
+ role: 'user',
+ blocks: [{ type: 'text', text: 'or you know what, use fable as orchestrator' }],
+ timestamp: Date.parse('2026-10-06T21:49:54.910Z'),
+ source: 'transcript',
+ parentId: 'tool-result-row'
+ })
+ })
+
+ it('decodes legacy entries without an origin and content-block prompts', () => {
+ const decoded = decodeClaudeTranscriptLine(
+ queuedCommand({
+ prompt: [{ type: 'text', text: 'is this true?' }],
+ commandMode: 'prompt'
+ }),
+ 'fallback'
+ )
+ expect(decoded?.role).toBe('user')
+ expect(decoded?.blocks).toEqual([{ type: 'text', text: 'is this true?' }])
+ })
+
+ it.each([
+ { prompt: 'done', commandMode: 'task-notification' },
+ {
+ prompt: 'done',
+ commandMode: 'task-notification',
+ origin: { kind: 'task-notification' }
+ },
+ { prompt: 'from another agent', commandMode: 'prompt', origin: { kind: 'peer' } },
+ { commandMode: 'prompt', origin: { kind: 'human' } }
+ ])('drops queue entries the user did not type (%o)', (attachment) => {
+ expect(decodeClaudeTranscriptLine(queuedCommand(attachment), 'fallback')).toBeNull()
+ })
+
+ it('still drops other attachment records', () => {
+ const line = JSON.stringify({
+ type: 'attachment',
+ uuid: 'a',
+ attachment: { type: 'edited_text_file', filename: 'x.ts' }
+ })
+ expect(decodeClaudeTranscriptLine(line, 'fallback')).toBeNull()
+ })
+})
+
+describe('Claude `!` shell command row', () => {
+ it('decodes as the line the user typed so its echo can match', () => {
+ const line = JSON.stringify({
+ type: 'user',
+ uuid: 'bash-row',
+ timestamp: '2026-10-06T10:00:00.000Z',
+ message: { role: 'user', content: ' gcloud auth login' }
+ })
+ expect(decodeClaudeTranscriptLine(line, 'fallback')?.blocks).toEqual([
+ { type: 'text', text: '! gcloud auth login' }
+ ])
+ })
+})
diff --git a/src/main/native-chat/transcript-line-decoders-claude.ts b/src/main/native-chat/transcript-line-decoders-claude.ts
index 8693b4296a9..5d8ec177ea9 100644
--- a/src/main/native-chat/transcript-line-decoders-claude.ts
+++ b/src/main/native-chat/transcript-line-decoders-claude.ts
@@ -16,6 +16,7 @@ import {
import { imageSourcePathFromText } from '../../shared/native-chat-image-transcript-markers'
import { claudeContentBlocks } from './transcript-record-blocks'
import { unwrapClaudePastedContentBlock } from '../../shared/claude-pasted-content'
+import { unwrapClaudeBashInputBlock } from '../../shared/claude-bash-input'
import { claudeInterruptedMessageId } from './transcript-turn-markers'
const MAX_EDIT_PATCH_HUNKS = 40
@@ -75,10 +76,11 @@ export function decodeClaudeTranscriptLine(
line: string,
fallbackId: string
): NativeChatMessage | null {
- const record = parseJsonObject(line)
- if (!record) {
+ const parsed = parseJsonObject(line)
+ if (!parsed) {
return null
}
+ const record = claudeQueuedPromptAsUserRecord(parsed) ?? parsed
const role = record.type
if (role !== 'user' && role !== 'assistant') {
return null
@@ -129,13 +131,45 @@ export function decodeClaudeTranscriptLine(
return {
id: messageId ?? fallbackId,
role: claudeMessageRole(role, blocks),
- blocks: role === 'user' ? blocks.map(unwrapClaudePastedContentBlock) : blocks,
+ // Why: a `!` command's echo reads `!cmd`; left as harness markup the row was
+ // hidden as noise and the echo stayed pinned at the tail (#25956).
+ blocks:
+ role === 'user'
+ ? blocks.map((block) => unwrapClaudeBashInputBlock(unwrapClaudePastedContentBlock(block)))
+ : blocks,
timestamp,
source: 'transcript',
...parent
}
}
+/** A prompt sent mid-turn is recorded only as a `queued_command` attachment, never
+ * a `type:"user"` row; without one its echo had nothing to match and stayed
+ * pinned below every later turn (#14308, #20846). Task notifications and
+ * peer-agent messages share the attachment type but were not typed by the user. */
+function claudeQueuedPromptAsUserRecord(
+ record: Record
+): Record | null {
+ if (record.type !== 'attachment') {
+ return null
+ }
+ const attachment = asRecord(record.attachment)
+ if (attachment?.type !== 'queued_command' || attachment.commandMode !== 'prompt') {
+ return null
+ }
+ // Why: older Claude versions omit `origin`; their prompt-mode queue entries
+ // were all typed by the user.
+ const originKind = asRecord(attachment.origin)?.kind
+ if (originKind !== undefined && originKind !== 'human') {
+ return null
+ }
+ const prompt = attachment.prompt
+ if (typeof prompt !== 'string' && !Array.isArray(prompt)) {
+ return null
+ }
+ return { ...record, type: 'user', message: { role: 'user', content: prompt } }
+}
+
// Keep only genuine image companion records; a marker mixed with prose must
// remain an injected turn (or be dropped), never become an image-source turn.
function isImageSourceRecord(blocks: NativeChatBlock[]): boolean {
diff --git a/src/main/native-chat/transcript-stream-lines.ts b/src/main/native-chat/transcript-stream-lines.ts
index 64de2adb625..4a6bb550fb2 100644
--- a/src/main/native-chat/transcript-stream-lines.ts
+++ b/src/main/native-chat/transcript-stream-lines.ts
@@ -2,6 +2,7 @@ import type { Readable } from 'node:stream'
import { StringDecoder } from 'node:string_decoder'
import type { NativeChatMessage } from '../../shared/native-chat-types'
import { transcriptFallbackId } from './transcript-fallback-id'
+import { inTranscriptFileOrder } from './transcript-file-order'
type TranscriptDecoder = (line: string, fallbackId: string) => NativeChatMessage | null
@@ -15,6 +16,7 @@ export async function decodeTranscriptStream(
): Promise<{ messages: NativeChatMessage[]; consumedBytes: number }> {
const messages: NativeChatMessage[] = []
let consumedBytes = 0
+ let latest = Number.NEGATIVE_INFINITY
const framer = createTranscriptLineFramer((line, byteLength, terminated) => {
if (terminated || includeTrailingLine) {
decodeLine(line, consumedBytes)
@@ -35,7 +37,9 @@ export async function decodeTranscriptStream(
}
const message = decode(line, transcriptFallbackId(filePath, start + relativeOffset))
if (message) {
- messages.push(message)
+ const ordered = inTranscriptFileOrder(message, latest)
+ latest = ordered.latest
+ messages.push(ordered.message)
}
}
}
diff --git a/src/main/native-chat/transcript-tail-reader.ts b/src/main/native-chat/transcript-tail-reader.ts
index fce4b630e4e..38a81b0a57e 100644
--- a/src/main/native-chat/transcript-tail-reader.ts
+++ b/src/main/native-chat/transcript-tail-reader.ts
@@ -14,6 +14,7 @@ import {
decodeOmpTranscriptLine
} from './transcript-line-decoders'
import { transcriptFallbackId } from './transcript-fallback-id'
+import { inTranscriptFileOrder } from './transcript-file-order'
import { extendTranscriptBoundary } from './transcript-file-version'
import {
nativeChatTurnLifecycleDecoderForAgent,
@@ -68,6 +69,8 @@ export async function readNativeChatTranscriptTailFile(
consumedTo: number
hasMore: boolean
beforeOffset: number
+ /** Newest row time up to `consumedTo`, where an incremental read continues. */
+ latestTimestamp?: number
malformedRecordCount?: number
oversizedRecordCount?: number
}> {
@@ -153,7 +156,12 @@ export async function readNativeChatTranscriptTailFile(
if (cursor === 0 && lineParts.length > 0 && newestFirst.length <= limit) {
decodeLine(0, newestFirst)
}
- const chronological = newestFirst.toReversed()
+ let latest = Number.NEGATIVE_INFINITY
+ const chronological = newestFirst.toReversed().map((entry) => {
+ const ordered = inTranscriptFileOrder(entry.message, latest)
+ latest = ordered.latest
+ return { message: ordered.message, offset: entry.offset }
+ })
// Why: slice(-0) returns the whole array, so a non-positive limit must
// window to nothing explicitly rather than leak every buffered record.
const selected = limit > 0 ? chronological.slice(Math.max(0, chronological.length - limit)) : []
@@ -163,6 +171,7 @@ export async function readNativeChatTranscriptTailFile(
consumedTo,
hasMore: limit > 0 && chronological.length > limit,
beforeOffset: selected[0]?.offset ?? end,
+ ...(Number.isFinite(latest) ? { latestTimestamp: latest } : {}),
...(malformedRecordCount > 0 ? { malformedRecordCount } : {}),
...(oversizedRecordCount > 0 ? { oversizedRecordCount } : {})
}
diff --git a/src/main/native-chat/transcript-turn-lifecycle.test.ts b/src/main/native-chat/transcript-turn-lifecycle.test.ts
index e46fcde0020..44bc59a33ed 100644
--- a/src/main/native-chat/transcript-turn-lifecycle.test.ts
+++ b/src/main/native-chat/transcript-turn-lifecycle.test.ts
@@ -243,6 +243,42 @@ describe('native chat transcript turn lifecycle', () => {
).toBeNull()
})
+ it.each([
+ ' git status',
+ [{ type: 'text', text: 'ls' }]
+ ])('does not treat a `!` shell command as a new working generation', (content) => {
+ expect(
+ decodeClaudeTurnLifecycle(
+ JSON.stringify({
+ type: 'user',
+ uuid: 'bash-1',
+ timestamp: '2026-07-16T23:46:12.000Z',
+ message: { role: 'user', content }
+ }),
+ 'fallback'
+ )
+ ).toBeNull()
+ })
+
+ it('does not treat a prompt absorbed mid-turn as a new generation', () => {
+ expect(
+ decodeClaudeTurnLifecycle(
+ JSON.stringify({
+ type: 'attachment',
+ uuid: 'queued-1',
+ timestamp: '2026-07-16T23:46:13.000Z',
+ attachment: {
+ type: 'queued_command',
+ prompt: 'also check the config',
+ commandMode: 'prompt',
+ origin: { kind: 'human' }
+ }
+ }),
+ 'fallback'
+ )
+ ).toBeNull()
+ })
+
it('excludes Claude tool-result rows that also carry text sidecars', () => {
expect(
decodeClaudeTurnLifecycle(
diff --git a/src/main/native-chat/transcript-turn-lifecycle.ts b/src/main/native-chat/transcript-turn-lifecycle.ts
index b91e4fa7bee..0db70aea258 100644
--- a/src/main/native-chat/transcript-turn-lifecycle.ts
+++ b/src/main/native-chat/transcript-turn-lifecycle.ts
@@ -1,6 +1,7 @@
import type { AgentType, NativeChatTurnLifecycle } from '../../shared/native-chat-types'
import { resolveNativeChatTranscriptAgent } from '../../shared/native-chat-agent-support'
import { isNoiseMessage } from '../../shared/native-chat-noise'
+import { claudeBashInputAsTypedCommand } from '../../shared/claude-bash-input'
import {
asRecord,
extractString,
@@ -120,12 +121,25 @@ export function decodeClaudeTurnLifecycle(
// Why: harness noise (task-notification, system-reminder, …) is user-role in
// the JSONL but not a new generation. Treating it as working would overwrite
// a real terminal marker and re-stick the chat spinner after done/interrupt.
- if (isNoiseMessage(decoded)) {
+ if (isNoiseMessage(decoded) || isClaudeBashInputRecord(record)) {
return null
}
return { state: 'working', turnId: decoded.id, timestamp }
}
+// Why: a `!` row is the shell command, not a prompt; any reply Claude writes after it
+// carries its own completed marker, and with none a working mark would stick.
+function isClaudeBashInputRecord(record: Record): boolean {
+ const content = asRecord(record.message)?.content
+ const texts =
+ typeof content === 'string'
+ ? [content]
+ : Array.isArray(content)
+ ? content.map((block) => extractString(asRecord(block)?.text))
+ : []
+ return texts.some((text) => text != null && claudeBashInputAsTypedCommand(text) !== null)
+}
+
function lifecycleTimestamp(value: unknown): number | null {
const parsed = timestampMs(value)
return Number.isFinite(parsed) ? parsed : null
diff --git a/src/main/native-chat/transcript-watch-engine.ts b/src/main/native-chat/transcript-watch-engine.ts
index 894863bc970..9792323092a 100644
--- a/src/main/native-chat/transcript-watch-engine.ts
+++ b/src/main/native-chat/transcript-watch-engine.ts
@@ -164,6 +164,7 @@ export async function installTranscriptWatcher(
if (replacementSnapshot && onReplace) {
state.offset = replacementSnapshot.consumedTo
state.pendingStart = state.offset
+ state.latestTimestamp = replacementSnapshot.latestTimestamp ?? Number.NEGATIVE_INFINITY
onReplace(
replacementSnapshot.messages,
replacementSnapshot.hasMore,
@@ -186,6 +187,7 @@ export async function installTranscriptWatcher(
if (initialSnapshot) {
state.offset = initialSnapshot.consumedTo
state.pendingStart = state.offset
+ state.latestTimestamp = initialSnapshot.latestTimestamp ?? Number.NEGATIVE_INFINITY
onInitialSnapshot(
initialSnapshot.messages,
initialSnapshot.hasMore,
diff --git a/src/shared/claude-bash-input.test.ts b/src/shared/claude-bash-input.test.ts
new file mode 100644
index 00000000000..88626ecf1c5
--- /dev/null
+++ b/src/shared/claude-bash-input.test.ts
@@ -0,0 +1,19 @@
+import { describe, expect, it } from 'vitest'
+import { claudeBashInputAsTypedCommand } from './claude-bash-input'
+
+describe('claudeBashInputAsTypedCommand', () => {
+ it('restores the line as typed, keeping the space after `!`', () => {
+ expect(claudeBashInputAsTypedCommand(' gcloud auth login')).toBe(
+ '! gcloud auth login'
+ )
+ expect(claudeBashInputAsTypedCommand('ls -la')).toBe('!ls -la')
+ })
+ it.each([
+ 'run ls',
+ 'ls and more',
+ 'ok',
+ 'ab'
+ ])('leaves anything else alone (%s)', (text) => {
+ expect(claudeBashInputAsTypedCommand(text)).toBeNull()
+ })
+})
diff --git a/src/shared/claude-bash-input.ts b/src/shared/claude-bash-input.ts
new file mode 100644
index 00000000000..4c36a05c9f9
--- /dev/null
+++ b/src/shared/claude-bash-input.ts
@@ -0,0 +1,18 @@
+import type { NativeChatBlock } from './native-chat-types'
+
+const BASH_INPUT = /^\s*([\s\S]*)<\/bash-input>\s*$/
+
+/** Claude records a `!` shell command as `cmd`, with the `!`
+ * stripped. Returns the line as typed, or null when the text is anything else. */
+export function claudeBashInputAsTypedCommand(text: string): string | null {
+ const command = BASH_INPUT.exec(text)?.[1]
+ return command === undefined || command.includes('') ? null : `!${command}`
+}
+
+export function unwrapClaudeBashInputBlock(block: NativeChatBlock): NativeChatBlock {
+ if (block.type !== 'text') {
+ return block
+ }
+ const typed = claudeBashInputAsTypedCommand(block.text)
+ return typed === null ? block : { ...block, text: typed }
+}