Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
22 changes: 22 additions & 0 deletions apps/mobile/src/components/agents/message-details-content.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { type SessionModelOption } from '@/lib/hooks/use-session-model-options';
import { collectCopyableText } from './collect-copyable-text';
import { formatCost } from './context-usage-display';
import { resolveMessageDisplayModel } from './message-model-label';
import { isPartStreaming } from './part-types';
import { friendlyModelName } from './session-model-display';

type MessageDetailsTokenRow = {
Expand All @@ -19,6 +20,7 @@ type MessageDetailsContent = {
costLabel: string | null;
tokenRows: MessageDetailsTokenRow[] | null;
copyableText: string | null;
canSelectText: boolean;
};

const SENT_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, {
Expand All @@ -39,6 +41,9 @@ export function getMessageDetailsContent(
const sentTimeLabel = formatMessageSentTime(message.info.time.created);
const copyable = collectCopyableText(message);
const copyableText = copyable.length > 0 ? copyable : null;
const canSelectText =
copyableText !== null &&
!message.parts.some(part => isPartInFlightForSelect(part, message.info.role));

if (message.info.role !== 'assistant') {
return {
Expand All @@ -48,6 +53,7 @@ export function getMessageDetailsContent(
costLabel: null,
tokenRows: null,
copyableText,
canSelectText,
};
}

Expand Down Expand Up @@ -75,9 +81,25 @@ export function getMessageDetailsContent(
]
: null,
copyableText,
canSelectText,
};
}

/**
* A part blocks range selection while it is in flight. A user text part never
* streams, so only an assistant text part with a `time` that lacks `end` is
* in flight; reasoning and tool parts reuse the shared streaming check.
*/
function isPartInFlightForSelect(
part: StoredMessage['parts'][number],
role: StoredMessage['info']['role']
): boolean {
if (part.type === 'text') {
return role === 'assistant' && part.time !== undefined && part.time.end === undefined;
}
return isPartStreaming(part);
}

type AssistantUsage = {
cost: number;
input: number;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,215 @@
/* eslint-disable typescript-eslint/no-deprecated -- react-test-renderer is the DOM-free renderer used to mount React/RN trees under vitest (same pattern as src/test/render-with-providers.tsx) */
import {
type AssistantMessage,
type Part,
type StoredMessage,
type UserMessage,
} from '@kilocode/cloud-agent-sdk';
import { createElement, type ReactElement } from 'react';
import TestRenderer, { act } from 'react-test-renderer';
import { describe, expect, it, vi } from 'vitest';

import { MessageDetailsSheet } from './message-details-sheet';

vi.mock('react-native', () => ({
Modal: 'Modal',
ScrollView: 'ScrollView',
Pressable: 'Pressable',
View: 'View',
}));
vi.mock('react-native-safe-area-context', () => ({
useSafeAreaInsets: () => ({ bottom: 0 }),
}));
vi.mock('@/components/sheet-header', () => ({
SheetHeader: 'SheetHeader',
}));
vi.mock('@/components/ui/text', async () => {
const React = await import('react');
return {
Text: 'Text',
TextClassContext: React.createContext<string | undefined>(undefined),
};
});
vi.mock('@/components/ui/selectable-text', () => ({
SelectableText: 'SelectableText',
}));
vi.mock('./message-details-copy', () => ({
handleMessageDetailsCopy: vi.fn(),
}));

function userInfo(overrides: Partial<UserMessage> = {}): UserMessage {
return {
id: 'u-1',
sessionID: 'ses-1',
role: 'user',
time: { created: 1_700_000_000_000 },
agent: 'test',
model: { providerID: 'kilo', modelID: 'claude-sonnet-4' },
...overrides,
};
}

function assistantInfo(overrides: Partial<AssistantMessage> = {}): AssistantMessage {
return {
id: 'msg-1',
sessionID: 'ses-1',
role: 'assistant',
time: { created: 1_700_000_000_000 },
parentID: 'msg-0',
modelID: 'claude-sonnet-4',
providerID: 'kilo',
mode: 'code',
agent: 'test',
path: { cwd: '/', root: '/' },
cost: 0,
tokens: {
input: 0,
output: 0,
reasoning: 0,
cache: { read: 0, write: 0 },
},
...overrides,
};
}

function textPart(text: string, id = 'p-text'): Part {
return {
id,
sessionID: 'ses-1',
messageID: 'msg-1',
type: 'text',
text,
};
}

function textPartWithTime(
text: string,
time: { start: number; end?: number },
id = 'p-text-time'
): Part {
return {
id,
sessionID: 'ses-1',
messageID: 'msg-1',
type: 'text',
text,
time,
};
}

function storedMessage(info: AssistantMessage | UserMessage, parts: Part[] = []): StoredMessage {
return { info, parts };
}

function sheetElement(message: StoredMessage | null): ReactElement {
return createElement(MessageDetailsSheet, {
visible: true,
message,
modelOptions: [],
onClose: vi.fn<() => void>(),
});
}

function findByTestID(
root: TestRenderer.ReactTestInstance,
testID: string
): TestRenderer.ReactTestInstance[] {
return root.findAll(node => node.props.testID === testID);
}

function press(instance: TestRenderer.ReactTestInstance | undefined): void {
if (!instance) {
throw new Error('target not found');
}
const onPress = instance.props.onPress as (() => void) | undefined;
if (typeof onPress !== 'function') {
throw new TypeError('target has no onPress');
}
onPress();
}

async function mountSheet(message: StoredMessage | null): Promise<TestRenderer.ReactTestRenderer> {
const ref: { current: TestRenderer.ReactTestRenderer | undefined } = { current: undefined };
await act(async () => {
await Promise.resolve();
ref.current = TestRenderer.create(sheetElement(message));
});
const renderer = ref.current;
if (!renderer) {
throw new Error('renderer was not created');
}
return renderer;
}

async function unmount(renderer: TestRenderer.ReactTestRenderer): Promise<void> {
await act(async () => {
await Promise.resolve();
renderer.unmount();
});
}

describe('MessageDetailsSheet mounted', () => {
it('renders Copy message and Select text for a finished copyable user message', async () => {
const renderer = await mountSheet(storedMessage(userInfo(), [textPart('hello world')]));

expect(findByTestID(renderer.root, 'message-details-copy')).toHaveLength(1);
expect(findByTestID(renderer.root, 'message-details-select-text')).toHaveLength(1);

await unmount(renderer);
});

it('swaps the details Modal content to the select view when Select text is pressed', async () => {
const renderer = await mountSheet(storedMessage(userInfo(), [textPart('selectable body')]));

const modals = () =>
renderer.root.findAll(
node => typeof node.type === 'string' && (node.type as string) === 'Modal'
);
const sheetHeaderTitles = () =>
renderer.root
.findAll(node => typeof node.type === 'string' && (node.type as string) === 'SheetHeader')
.map(node => node.props.title as string | undefined);

// Before press: a single Modal shows the details content.
expect(modals()).toHaveLength(1);
expect(sheetHeaderTitles()).toContain('Message details');

await act(async () => {
await Promise.resolve();
press(findByTestID(renderer.root, 'message-details-select-text')[0]);
});

// After press: the same single Modal swaps to the Select text view.
expect(modals()).toHaveLength(1);
expect(sheetHeaderTitles()).toContain('Select text');
expect(sheetHeaderTitles()).not.toContain('Message details');

const selectable = renderer.root.findAll(
node => typeof node.type === 'string' && (node.type as string) === 'SelectableText'
);
expect(selectable).toHaveLength(1);
expect(selectable[0]?.props.children).toBe('selectable body');

await unmount(renderer);
});

it('hides Select text for a streaming assistant text part but keeps Copy message', async () => {
const renderer = await mountSheet(
storedMessage(assistantInfo(), [textPartWithTime('streaming body', { start: 1 })])
);

expect(findByTestID(renderer.root, 'message-details-copy')).toHaveLength(1);
expect(findByTestID(renderer.root, 'message-details-select-text')).toHaveLength(0);

await unmount(renderer);
});

it('renders neither button when there is no copyable text', async () => {
const renderer = await mountSheet(storedMessage(userInfo(), []));

expect(findByTestID(renderer.root, 'message-details-copy')).toHaveLength(0);
expect(findByTestID(renderer.root, 'message-details-select-text')).toHaveLength(0);

await unmount(renderer);
});
});
108 changes: 108 additions & 0 deletions apps/mobile/src/components/agents/message-details-sheet.test.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
/* eslint-disable max-lines -- cohesive unit suite: projection, empty, canSelectText, and copy wiring share one harness */
import { beforeEach, describe, expect, it, vi } from 'vitest';

import {
Expand Down Expand Up @@ -261,6 +262,113 @@ describe('getMessageDetailsContent — empty', () => {
});
});

function textPartWithTime(
text: string,
time: { start: number; end?: number },
id = 'p-text-time'
): Part {
return {
id,
sessionID: 'ses-1',
messageID: 'msg-1',
type: 'text',
text,
time,
};
}

function reasoningPart(
text: string,
time: { start: number; end?: number },
id = 'p-reasoning'
): Part {
return {
id,
sessionID: 'ses-1',
messageID: 'msg-1',
type: 'reasoning',
text,
time,
};
}

function runningToolPart(id = 'p-tool'): Part {
return {
id,
sessionID: 'ses-1',
messageID: 'msg-1',
type: 'tool',
callID: 'call-1',
tool: 'bash',
state: { status: 'running', input: { command: 'echo hi' }, time: { start: 1 } },
};
}

describe('getMessageDetailsContent — canSelectText', () => {
it('allows selection for a finished user text part with no time', () => {
const message = storedMessage(userInfo(), [textPart('user body')]);
const content = getMessageDetailsContent(message, catalogOptions);
expect(content.copyableText).toBe('user body');
expect(content.canSelectText).toBe(true);
});

it('allows selection for a reconciled user text part with start and no end', () => {
const message = storedMessage(userInfo(), [textPartWithTime('user body', { start: 1 })]);
const content = getMessageDetailsContent(message, catalogOptions);
expect(content.copyableText).toBe('user body');
expect(content.canSelectText).toBe(true);
});

it('allows selection for a finished assistant text part with time.end', () => {
const message = storedMessage(assistantInfo(), [
textPartWithTime('assistant body', { start: 1, end: 2 }),
]);
const content = getMessageDetailsContent(message, catalogOptions);
expect(content.copyableText).toBe('assistant body');
expect(content.canSelectText).toBe(true);
});

it('allows selection for a finished assistant text part with no time', () => {
const message = storedMessage(assistantInfo(), [textPart('assistant body')]);
const content = getMessageDetailsContent(message, catalogOptions);
expect(content.copyableText).toBe('assistant body');
expect(content.canSelectText).toBe(true);
});

it('hides selection for a streaming assistant text part but keeps copyable text', () => {
const message = storedMessage(assistantInfo(), [
textPartWithTime('streaming body', { start: 1 }),
]);
const content = getMessageDetailsContent(message, catalogOptions);
expect(content.copyableText).toBe('streaming body');
expect(content.canSelectText).toBe(false);
});

it('hides selection while an assistant tool part is running', () => {
const message = storedMessage(assistantInfo(), [runningToolPart()]);
const content = getMessageDetailsContent(message, catalogOptions);
expect(content.copyableText).not.toBeNull();
expect(content.canSelectText).toBe(false);
});

it('hides selection while an assistant reasoning part is in flight', () => {
const message = storedMessage(assistantInfo(), [
textPart('finished text'),
reasoningPart('thinking...', { start: 1 }),
]);
const content = getMessageDetailsContent(message, catalogOptions);
expect(content.copyableText).not.toBeNull();
expect(content.canSelectText).toBe(false);
});

it('hides both actions for a user message with no parts', () => {
const message = storedMessage(userInfo(), []);
const content = getMessageDetailsContent(message, catalogOptions);
expect(content.copyableText).toBeNull();
expect(content.canSelectText).toBe(false);
});
});

describe('MessageDetailsSheet copy button wiring (retryable unhappy)', () => {
beforeEach(() => {
performCopyMock.mockReset().mockResolvedValue(undefined);
Expand Down
Loading