Skip to content
Merged
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
7 changes: 6 additions & 1 deletion apps/mobile/src/components/agents/collect-copyable-text.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -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');
}
71 changes: 71 additions & 0 deletions apps/mobile/src/components/agents/compute-status.test.ts
Original file line number Diff line number Diff line change
@@ -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');
});
});
8 changes: 8 additions & 0 deletions apps/mobile/src/components/agents/compute-status.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import { type Part } from 'cloud-agent-sdk';

import { isSnapshotProgressPart } from './part-types';

const toolStatusMap: Record<string, string> = {
read: 'Exploring',
grep: 'Searching the codebase',
Expand All @@ -17,6 +19,9 @@ const toolStatusMap: Record<string, string> = {
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';
Expand All @@ -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';
Expand Down
26 changes: 25 additions & 1 deletion apps/mobile/src/components/agents/message-copy-text.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand All @@ -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');
});
});
46 changes: 45 additions & 1 deletion apps/mobile/src/components/agents/part-renderer.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => ({
Expand Down Expand Up @@ -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);
Expand All @@ -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<string, unknown> } };
}
).props.children;
expect(textElement.type).toBe(TextPartRenderer);
expect(textElement.props).toMatchObject({ text: 'Hello world' });
});
});
6 changes: 6 additions & 0 deletions apps/mobile/src/components/agents/part-renderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import {
isFilePart,
isPartStreaming,
isReasoningPart,
isSnapshotProgressPart,
isTextPart,
isToolPart,
shouldRenderReasoningPart,
Expand All @@ -33,6 +34,11 @@ export function PartRenderer({
onOpenChildSession,
}: Readonly<PartRendererProps>) {
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 (
<MessageErrorBoundary>
<TextPartRenderer text={part.text} />
Expand Down
34 changes: 30 additions & 4 deletions apps/mobile/src/components/agents/part-types.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -14,17 +14,43 @@ 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',
type: 'text',
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);
Expand Down
5 changes: 5 additions & 0 deletions apps/mobile/src/components/agents/part-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
}
Expand Down