From 7d3f700a3bd343499c6773804dca1668e385bbea Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Igor=20=C5=A0=C4=87eki=C4=87?= Date: Sat, 25 Jul 2026 02:26:06 +0200 Subject: [PATCH] fix(mobile): route snapshot-init progress to the working indicator row --- .../agents/collect-copyable-text.ts | 7 +- .../components/agents/compute-status.test.ts | 71 +++++++++++++++++++ .../src/components/agents/compute-status.ts | 8 +++ .../agents/message-copy-text.test.ts | 26 ++++++- .../components/agents/part-renderer.test.ts | 46 +++++++++++- .../src/components/agents/part-renderer.tsx | 6 ++ .../src/components/agents/part-types.test.ts | 34 +++++++-- .../src/components/agents/part-types.ts | 5 ++ 8 files changed, 196 insertions(+), 7 deletions(-) create mode 100644 apps/mobile/src/components/agents/compute-status.test.ts diff --git a/apps/mobile/src/components/agents/collect-copyable-text.ts b/apps/mobile/src/components/agents/collect-copyable-text.ts index e41a98e3e2..84a3f5ff9c 100644 --- a/apps/mobile/src/components/agents/collect-copyable-text.ts +++ b/apps/mobile/src/components/agents/collect-copyable-text.ts @@ -1,4 +1,4 @@ -type TextPartLike = { type: string; text: string }; +type TextPartLike = { type: string; text: string; synthetic?: boolean }; type CopyablePart = TextPartLike | { type: string }; type CopyableMessage = { @@ -9,9 +9,14 @@ function isTextPartLike(part: CopyablePart): part is TextPartLike { return part.type === 'text' && typeof (part as TextPartLike).text === 'string'; } +function isSnapshotProgressText(part: TextPartLike): boolean { + return part.synthetic === true && part.text.includes('Initializing snapshot'); +} + export function collectCopyableText(message: CopyableMessage): string { return message.parts .filter(isTextPartLike) + .filter(part => !isSnapshotProgressText(part)) .map(part => part.text) .join('\n\n'); } diff --git a/apps/mobile/src/components/agents/compute-status.test.ts b/apps/mobile/src/components/agents/compute-status.test.ts new file mode 100644 index 0000000000..99ca554225 --- /dev/null +++ b/apps/mobile/src/components/agents/compute-status.test.ts @@ -0,0 +1,71 @@ +import { type ReasoningPart, type TextPart, type ToolPart } from 'cloud-agent-sdk'; +import { describe, expect, it } from 'vitest'; + +import { computeStatus, SNAPSHOT_PROGRESS_STATUS } from './compute-status'; + +function makeTextPart(text: string, synthetic?: boolean): TextPart { + const part: TextPart = { + id: 't1', + sessionID: 's1', + messageID: 'm1', + type: 'text', + text, + time: { start: 1, end: 2 }, + }; + if (synthetic !== undefined) { + part.synthetic = synthetic; + } + return part; +} + +function makeReasoningPart(): ReasoningPart { + return { + id: 'r1', + sessionID: 's1', + messageID: 'm1', + type: 'reasoning', + text: 'thinking', + time: { start: 1, end: 2 }, + }; +} + +function makeToolPart(tool: string): ToolPart { + return { + id: 'tool1', + sessionID: 's1', + messageID: 'm1', + type: 'tool', + callID: 'c1', + tool, + state: { + status: 'running', + input: {}, + time: { start: 1 }, + }, + }; +} + +describe('computeStatus', () => { + it('maps snapshot-progress text parts to SNAPSHOT_PROGRESS_STATUS', () => { + const part = makeTextPart('⠋ Initializing snapshot…', true); + expect(computeStatus(part)).toBe(SNAPSHOT_PROGRESS_STATUS); + expect(SNAPSHOT_PROGRESS_STATUS).toBe('Initializing snapshot…'); + }); + + it('maps plain text parts to Writing response', () => { + expect(computeStatus(makeTextPart('Hello'))).toBe('Writing response'); + }); + + it('maps reasoning parts to Thinking', () => { + expect(computeStatus(makeReasoningPart())).toBe('Thinking'); + }); + + it('maps known tool parts via the tool status map', () => { + expect(computeStatus(makeToolPart('bash'))).toBe('Running commands'); + expect(computeStatus(makeToolPart('read'))).toBe('Exploring'); + }); + + it('maps unknown tool parts to Considering next steps', () => { + expect(computeStatus(makeToolPart('unknown-tool'))).toBe('Considering next steps'); + }); +}); diff --git a/apps/mobile/src/components/agents/compute-status.ts b/apps/mobile/src/components/agents/compute-status.ts index 23d51d83b6..1817a2ea22 100644 --- a/apps/mobile/src/components/agents/compute-status.ts +++ b/apps/mobile/src/components/agents/compute-status.ts @@ -1,5 +1,7 @@ import { type Part } from 'cloud-agent-sdk'; +import { isSnapshotProgressPart } from './part-types'; + const toolStatusMap: Record = { read: 'Exploring', grep: 'Searching the codebase', @@ -17,6 +19,9 @@ const toolStatusMap: Record = { question: 'Asking a question', }; +/** Matches CLI PROGRESS_INITIALIZING typography (U+2026 ellipsis). */ +export const SNAPSHOT_PROGRESS_STATUS = 'Initializing snapshot…'; + export function computeStatus(part: Part): string { if (part.type === 'tool') { return toolStatusMap[part.tool] ?? 'Considering next steps'; @@ -25,6 +30,9 @@ export function computeStatus(part: Part): string { return 'Thinking'; } if (part.type === 'text') { + if (isSnapshotProgressPart(part)) { + return SNAPSHOT_PROGRESS_STATUS; + } return 'Writing response'; } return 'Considering next steps'; diff --git a/apps/mobile/src/components/agents/message-copy-text.test.ts b/apps/mobile/src/components/agents/message-copy-text.test.ts index 2912f348ec..f57c8cbfef 100644 --- a/apps/mobile/src/components/agents/message-copy-text.test.ts +++ b/apps/mobile/src/components/agents/message-copy-text.test.ts @@ -3,7 +3,7 @@ import { describe, expect, it } from 'vitest'; import { collectCopyableText } from './collect-copyable-text'; type TestMessage = { - parts: { type: string; text?: string; url?: string }[]; + parts: { type: string; text?: string; url?: string; synthetic?: boolean }[]; }; describe('collectCopyableText', () => { @@ -24,4 +24,28 @@ describe('collectCopyableText', () => { }; expect(collectCopyableText(message)).toBe(''); }); + + it('excludes synthetic snapshot-progress text parts from copy', () => { + const message: TestMessage = { + parts: [ + { type: 'text', text: '⠋ Initializing snapshot…', synthetic: true }, + { type: 'text', text: 'Real answer' }, + ], + }; + expect(collectCopyableText(message)).toBe('Real answer'); + }); + + it('keeps non-synthetic text that mentions Initializing snapshot', () => { + const message: TestMessage = { + parts: [{ type: 'text', text: 'Note: Initializing snapshot can take a while' }], + }; + expect(collectCopyableText(message)).toBe('Note: Initializing snapshot can take a while'); + }); + + it('keeps synthetic user optimistic text parts', () => { + const message: TestMessage = { + parts: [{ type: 'text', text: 'User typed this', synthetic: true }], + }; + expect(collectCopyableText(message)).toBe('User typed this'); + }); }); diff --git a/apps/mobile/src/components/agents/part-renderer.test.ts b/apps/mobile/src/components/agents/part-renderer.test.ts index d8e624ddaf..82fa89660e 100644 --- a/apps/mobile/src/components/agents/part-renderer.test.ts +++ b/apps/mobile/src/components/agents/part-renderer.test.ts @@ -1,8 +1,9 @@ -import { type ReasoningPart } from 'cloud-agent-sdk'; +import { type ReasoningPart, type TextPart } from 'cloud-agent-sdk'; import { describe, expect, it, vi } from 'vitest'; import { PartRenderer } from './part-renderer'; import { ReasoningPartRenderer } from './reasoning-part-renderer'; +import { TextPartRenderer } from './text-part-renderer'; vi.mock('./child-session-section', () => ({})); vi.mock('./compaction-separator', () => ({ @@ -35,6 +36,21 @@ function makeReasoningPart(text: string, ended = true): ReasoningPart { }; } +function makeTextPart(text: string, synthetic?: boolean, ended = true): TextPart { + const part: TextPart = { + id: 't1', + sessionID: 's1', + messageID: 'm1', + type: 'text', + text, + time: { start: 1, end: ended ? 2 : undefined }, + }; + if (synthetic !== undefined) { + part.synthetic = synthetic; + } + return part; +} + describe('PartRenderer', () => { it('does not mount a completed empty reasoning part', () => { const part = makeReasoningPart('', true); @@ -61,4 +77,32 @@ describe('PartRenderer', () => { isStreaming: false, }); }); + + it('returns null for snapshot-progress parts while streaming', () => { + const part = makeTextPart('⠋ Initializing snapshot…', true, false); + // eslint-disable-next-line new-cap + const result = PartRenderer({ part, isStreaming: true }); + expect(result).toBeNull(); + }); + + it('returns null for snapshot-progress parts when not streaming', () => { + const part = makeTextPart('⠋ Initializing snapshot…', true, true); + // eslint-disable-next-line new-cap + const result = PartRenderer({ part, isStreaming: false }); + expect(result).toBeNull(); + }); + + it('routes normal text parts to TextPartRenderer', () => { + const part = makeTextPart('Hello world'); + // eslint-disable-next-line new-cap + const result = PartRenderer({ part, isStreaming: true }); + expect(result).not.toBeNull(); + const textElement = ( + result as unknown as { + props: { children: { type: unknown; props: Record } }; + } + ).props.children; + expect(textElement.type).toBe(TextPartRenderer); + expect(textElement.props).toMatchObject({ text: 'Hello world' }); + }); }); diff --git a/apps/mobile/src/components/agents/part-renderer.tsx b/apps/mobile/src/components/agents/part-renderer.tsx index d997fd5ce9..9a9c3f9302 100644 --- a/apps/mobile/src/components/agents/part-renderer.tsx +++ b/apps/mobile/src/components/agents/part-renderer.tsx @@ -8,6 +8,7 @@ import { isFilePart, isPartStreaming, isReasoningPart, + isSnapshotProgressPart, isTextPart, isToolPart, shouldRenderReasoningPart, @@ -33,6 +34,11 @@ export function PartRenderer({ onOpenChildSession, }: Readonly) { if (isTextPart(part)) { + // Snapshot-init progress is shown only in the fixed WorkingIndicator row. + // Hide unconditionally so a persisted part never lingers in the transcript. + if (isSnapshotProgressPart(part)) { + return null; + } return ( diff --git a/apps/mobile/src/components/agents/part-types.test.ts b/apps/mobile/src/components/agents/part-types.test.ts index f2b433c6bb..3b870e258c 100644 --- a/apps/mobile/src/components/agents/part-types.test.ts +++ b/apps/mobile/src/components/agents/part-types.test.ts @@ -1,7 +1,7 @@ -import { type Part, type ReasoningPart } from 'cloud-agent-sdk'; +import { type ReasoningPart, type TextPart } from 'cloud-agent-sdk'; import { describe, expect, it } from 'vitest'; -import { isPartStreaming, shouldRenderReasoningPart } from './part-types'; +import { isPartStreaming, isSnapshotProgressPart, shouldRenderReasoningPart } from './part-types'; function makeReasoningPart(text: string, ended = true): ReasoningPart { return { @@ -14,8 +14,8 @@ function makeReasoningPart(text: string, ended = true): ReasoningPart { }; } -function makeTextPart(text: string): Part { - return { +function makeTextPart(text: string, synthetic?: boolean): TextPart { + const part: TextPart = { id: 't1', sessionID: 's1', messageID: 'm1', @@ -23,8 +23,34 @@ function makeTextPart(text: string): Part { text, time: { start: 1, end: 2 }, }; + if (synthetic !== undefined) { + part.synthetic = synthetic; + } + return part; } +describe('isSnapshotProgressPart', () => { + it('is true for a synthetic text part whose text includes Initializing snapshot', () => { + const part = makeTextPart('⠋ Initializing snapshot…', true); + expect(isSnapshotProgressPart(part)).toBe(true); + }); + + it('is false for the same text when not synthetic', () => { + const part = makeTextPart('⠋ Initializing snapshot…', false); + expect(isSnapshotProgressPart(part)).toBe(false); + }); + + it('is false for a synthetic text part with other content', () => { + const part = makeTextPart('Hello from the agent', true); + expect(isSnapshotProgressPart(part)).toBe(false); + }); + + it('is false for non-text parts', () => { + const part = makeReasoningPart('thinking'); + expect(isSnapshotProgressPart(part)).toBe(false); + }); +}); + describe('shouldRenderReasoningPart', () => { it('does not render a completed reasoning part with empty text', () => { const part = makeReasoningPart('', true); diff --git a/apps/mobile/src/components/agents/part-types.ts b/apps/mobile/src/components/agents/part-types.ts index 8976391469..3407e6fc1c 100644 --- a/apps/mobile/src/components/agents/part-types.ts +++ b/apps/mobile/src/components/agents/part-types.ts @@ -11,6 +11,11 @@ export function isTextPart(part: Part): part is TextPart { return part.type === 'text'; } +/** CLI snapshot-init progress injected as a synthetic text part (matches kilo-vscode). */ +export function isSnapshotProgressPart(part: Part): boolean { + return isTextPart(part) && part.synthetic === true && part.text.includes('Initializing snapshot'); +} + export function isToolPart(part: Part): part is ToolPart { return part.type === 'tool'; }