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
72 changes: 72 additions & 0 deletions apps/mobile/src/components/agents/message-bubble.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,3 +179,75 @@ describe('MessageBubble regressions', () => {
expect(findText(dequeuedTree, t => t === 'Queued')).toBe(false);
});
});

function pressableProps(node: unknown): Record<string, unknown> | null {
if (node == null || typeof node !== 'object') {
return null;
}
const element = node as { type?: unknown; props?: Record<string, unknown> };
if (element.type === 'Pressable' && element.props) {
return element.props;
}
const children = element.props?.children;
if (Array.isArray(children)) {
for (const child of children) {
const found = pressableProps(child);
if (found) {
return found;
}
}
} else if (children && typeof children === 'object') {
return pressableProps(children);
}
return null;
}

describe('MessageBubble long-press details', () => {
it('uses the details accessibility hint on user messages', async () => {
const tree = await renderBubble(userMessage('m-hint-user'));
const props = pressableProps(tree);
expect(props?.accessibilityHint).toBe('Long press for message details');
expect(props?.accessibilityActions).toEqual([{ name: 'copy', label: 'Copy message' }]);
});

it('uses the details accessibility hint on assistant messages', async () => {
const base = userMessage('m-hint-asst');
const assistant: StoredMessage = {
info: {
id: base.info.id,
sessionID: base.info.sessionID,
role: 'assistant',
time: { created: base.info.time.created },
parentID: 'm0',
modelID: 'anthropic/claude-sonnet-4',
providerID: 'kilo',
mode: 'code',
agent: 'build',
path: { cwd: '/', root: '/' },
cost: 0,
tokens: { total: 0, input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
},
parts: [],
};
const tree = await renderBubble(assistant);
const props = pressableProps(tree);
expect(props?.accessibilityHint).toBe('Long press for message details');
});

it('invokes onLongPressDetails on long-press, not copyMessage', async () => {
const onLongPressDetails = vi.fn((..._args: unknown[]) => {
// void-returning callback matching MessageBubble's prop type
});
const { MessageBubble } = await import('./message-bubble');
const message = userMessage('m-long');
// eslint-disable-next-line new-cap
const tree = MessageBubble({ message, onLongPressDetails });
const props = pressableProps(tree);
expect(props).not.toBeNull();
const handler = props === null ? undefined : props.onLongPress;
expect(typeof handler).toBe('function');
const invoke = handler as (() => void) | undefined;
invoke?.();
expect(onLongPressDetails).toHaveBeenCalledWith(message);
});
});
33 changes: 10 additions & 23 deletions apps/mobile/src/components/agents/message-bubble.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,15 +24,12 @@ type MessageBubbleProps = {
onOpenChildSession?: OpenChildSession;
/** Per-user-message delivery state. v1 surfaces only a "Queued" badge. */
deliveryState?: MessageDeliveryState;
/**
* Subtle model label for an assistant message, precomputed by the parent
* via `computeMessageModelLabels`. Only the assistant branch renders it
* (and only when truthy); the user branch and the unlabelled
* same-model follow-ups render nothing.
*/
modelLabel?: string;
/** Opens the message-details sheet; long-press never triggers the copy ActionSheet. */
onLongPressDetails?: (message: StoredMessage) => void;
};

const DETAILS_HINT = 'Long press for message details';

export function MessageBubble({
message,
isLastAssistantMessage,
Expand All @@ -41,19 +38,18 @@ export function MessageBubble({
defaultReasoningExpanded,
onOpenChildSession,
deliveryState,
modelLabel,
onLongPressDetails,
}: Readonly<MessageBubbleProps>) {
const isUser = message.info.role === 'user';
const { copyMessage } = useMessageCopy();
const colors = useThemeColors();

const handleLongPress = () => {
void copyMessage(message);
onLongPressDetails?.(message);
};

// Long-press is an accelerator; expose the same "copy" action to
// accessibility tooling (VoiceOver/TalkBack rotor) since a long-press
// gesture isn't reliably discoverable there.
// Long-press opens details; keep the VoiceOver/TalkBack rotor "copy" action
// on the bubble so a11y tooling still reaches the existing ActionSheet path.
const copyAccessibilityActions = [{ name: 'copy', label: 'Copy message' }];
const handleAccessibilityAction = (event: AccessibilityActionEvent) => {
if (event.nativeEvent.actionName === 'copy') {
Expand Down Expand Up @@ -85,7 +81,7 @@ export function MessageBubble({
className="px-4 py-1"
accessibilityRole="text"
accessibilityLabel="User message"
accessibilityHint="Long press to copy message text"
accessibilityHint={DETAILS_HINT}
accessibilityActions={copyAccessibilityActions}
onAccessibilityAction={handleAccessibilityAction}
>
Expand Down Expand Up @@ -124,7 +120,7 @@ export function MessageBubble({
onLongPress={handleLongPress}
accessibilityRole="text"
accessibilityLabel="Assistant message"
accessibilityHint="Long press to copy message text"
accessibilityHint={DETAILS_HINT}
accessibilityActions={copyAccessibilityActions}
onAccessibilityAction={handleAccessibilityAction}
>
Expand All @@ -139,15 +135,6 @@ export function MessageBubble({
onOpenChildSession={onOpenChildSession}
/>
))}
{modelLabel ? (
<Text
className="text-xs text-muted-foreground"
accessibilityRole="text"
accessibilityLabel={`Model: ${modelLabel}`}
>
{modelLabel}
</Text>
) : null}
</View>
</Pressable>
);
Expand Down
129 changes: 129 additions & 0 deletions apps/mobile/src/components/agents/message-details-content.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { type StoredMessage } from 'cloud-agent-sdk';

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 { friendlyModelName } from './session-model-display';

type MessageDetailsTokenRow = {
label: string;
value: number;
};

type MessageDetailsContent = {
roleLabel: string;
sentTimeLabel: string | null;
modelLabel: string | null;
costLabel: string | null;
tokenRows: MessageDetailsTokenRow[] | null;
copyableText: string | null;
};

const SENT_TIME_FORMATTER = new Intl.DateTimeFormat(undefined, {
dateStyle: 'medium',
timeStyle: 'short',
});

/**
* Pure projection of a StoredMessage into the details-sheet fields.
* Unit-tested for happy / empty visibility rules; the sheet component
* only renders this shape.
*/
export function getMessageDetailsContent(
message: StoredMessage,
modelOptions: SessionModelOption[]
): MessageDetailsContent {
const roleLabel = message.info.role === 'user' ? 'User' : 'Assistant';
const sentTimeLabel = formatMessageSentTime(message.info.time.created);
const copyable = collectCopyableText(message);
const copyableText = copyable.length > 0 ? copyable : null;

if (message.info.role !== 'assistant') {
return {
roleLabel,
sentTimeLabel,
modelLabel: null,
costLabel: null,
tokenRows: null,
copyableText,
};
}

const resolved = resolveMessageDisplayModel(message);
const modelLabel = resolved
? friendlyModelName(resolved.providerID, resolved.modelID, modelOptions)
: null;

const usage = getAssistantUsage(message);
const showUsage = usage !== null && !isZeroUsage(usage);

return {
roleLabel,
sentTimeLabel,
modelLabel,
costLabel: showUsage ? formatCost(usage.cost) : null,
tokenRows: showUsage
? [
{ label: 'Input', value: usage.input },
{ label: 'Output', value: usage.output },
{ label: 'Reasoning', value: usage.reasoning },
{ label: 'Cache read', value: usage.cacheRead },
{ label: 'Cache write', value: usage.cacheWrite },
{ label: 'Total', value: usage.total },
]
: null,
copyableText,
};
}

type AssistantUsage = {
cost: number;
input: number;
output: number;
reasoning: number;
cacheRead: number;
cacheWrite: number;
total: number;
};

function getAssistantUsage(message: StoredMessage): AssistantUsage | null {
if (message.info.role !== 'assistant') {
return null;
}
const { cost, tokens } = message.info;
const input = tokens.input;
const output = tokens.output;
const reasoning = tokens.reasoning;
const cacheRead = tokens.cache.read;
const cacheWrite = tokens.cache.write;
return {
cost,
input,
output,
reasoning,
cacheRead,
cacheWrite,
total: input + output + reasoning + cacheRead + cacheWrite,
};
}

function isZeroUsage(usage: AssistantUsage): boolean {
return (
usage.cost === 0 &&
usage.input === 0 &&
usage.output === 0 &&
usage.reasoning === 0 &&
usage.cacheRead === 0 &&
usage.cacheWrite === 0
);
}

/** Format an epoch-ms created timestamp; null when absent/invalid. */
export function formatMessageSentTime(created: number | undefined | null): string | null {
if (created === undefined || created === null || !Number.isFinite(created) || created <= 0) {
return null;
}
return SENT_TIME_FORMATTER.format(new Date(created));
}
12 changes: 12 additions & 0 deletions apps/mobile/src/components/agents/message-details-copy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { performCopy } from './use-message-copy';

/**
* Details-sheet Copy path: immediate shared `performCopy`, no ActionSheet.
* Kept free of RN UI so unit tests can pin the wiring.
*/
export function handleMessageDetailsCopy(copyableText: string | null | undefined): void {
if (!copyableText) {
return;
}
void performCopy(copyableText);
}
Loading