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
5 changes: 5 additions & 0 deletions apps/mobile/src/app/(app)/(tabs)/(3_profile)/preferences.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { PreferencesScreen } from '@/components/preferences-screen';

export default function PreferencesRoute() {
return <PreferencesScreen />;
}
35 changes: 1 addition & 34 deletions apps/mobile/src/components/agents/chat-toolbar.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,8 @@
import { useState } from 'react';
import { Pressable, View } from 'react-native';
import { Settings2 } from 'lucide-react-native';
import { View } from 'react-native';

import { ReasoningSettingsModal } from '@/components/agents/reasoning-settings-modal';
import { type AgentMode, ModeSelector } from '@/components/agents/mode-selector';
import { ModelSelector } from '@/components/agents/model-selector';
import { type ModelOption } from '@/lib/hooks/use-available-models';
import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { cn } from '@/lib/utils';

type ChatToolbarOrder = 'mode-first' | 'model-first';
Expand All @@ -22,7 +18,6 @@ type ChatToolbarProps = {
isLoadingModels?: boolean;
order?: ChatToolbarOrder;
className?: string;
showReasoningSettings?: boolean;
};

export function ChatToolbar({
Expand All @@ -36,10 +31,7 @@ export function ChatToolbar({
isLoadingModels = false,
order = 'mode-first',
className,
showReasoningSettings = true,
}: Readonly<ChatToolbarProps>) {
const colors = useThemeColors();
const [isSettingsOpen, setIsSettingsOpen] = useState(false);
const modeSelector = <ModeSelector value={mode} onChange={onModeChange} disabled={disabled} />;
const modelSelector = (
<ModelSelector
Expand All @@ -62,31 +54,6 @@ export function ChatToolbar({
>
{order === 'model-first' ? modelSelector : modeSelector}
{order === 'model-first' ? modeSelector : modelSelector}
{showReasoningSettings ? (
<>
<Pressable
onPress={() => {
if (!disabled) {
setIsSettingsOpen(true);
}
}}
disabled={disabled}
hitSlop={{ top: 8, bottom: 8, left: 8, right: 8 }}
className="ml-auto h-8 w-8 items-center justify-center rounded-full active:opacity-70"
accessibilityRole="button"
accessibilityLabel="Reasoning settings"
accessibilityState={{ disabled }}
>
<Settings2 size={16} color={colors.mutedForeground} />
</Pressable>
<ReasoningSettingsModal
visible={isSettingsOpen}
onClose={() => {
setIsSettingsOpen(false);
}}
/>
</>
) : null}
</View>
);
}
126 changes: 1 addition & 125 deletions apps/mobile/src/components/agents/message-bubble.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
/* eslint-disable max-lines -- Queued-badge, delivery, a11y, and time-label seams share the direct-invocation MessageBubble harness. */
/* eslint-disable max-lines -- Queued-badge, delivery, and a11y seams share the direct-invocation MessageBubble harness. */
import { describe, expect, it, vi } from 'vitest';

import { formatTranscriptTimeLabel } from './message-time-label';
import {
assistantMessage,
findElementByType,
Expand Down Expand Up @@ -151,78 +150,6 @@ describe('MessageBubble failed delivery state', () => {
});
});

describe('MessageBubble time label', () => {
it('renders a same-day time label matching the formatter evaluated in the test', async () => {
const created = Date.now();
const message = userMessage('m-time-same-day');
message.info.time = { created };
const tree = await renderBubble(message);
const expected = formatTranscriptTimeLabel(created, Date.now());
expect(expected).not.toBeNull();
expect(findText(tree, t => t === expected)).toBe(true);
});

it('renders the user time label in the meta row after the queued badge slot', async () => {
const tree = await renderBubble(userMessage('m-time-user'), { status: 'queued' });
const metaRow = findElementByType(
tree,
'View',
p =>
typeof p.className === 'string' &&
p.className.includes('flex-row items-center gap-2 self-end pr-1')
);
expect(metaRow).not.toBeNull();
if (!metaRow) {
throw new Error('expected meta row');
}
const children = Array.isArray(metaRow.props.children)
? metaRow.props.children
: [metaRow.props.children];
expect(children.length).toBe(2);
const badge = children[0] as { props?: Record<string, unknown> };
const badgeClass = typeof badge.props?.className === 'string' ? badge.props.className : null;
expect(badgeClass).not.toBeNull();
expect(badgeClass?.includes(BADGE_CLASS)).toBe(true);
const label = children[1] as { props?: Record<string, unknown> };
const labelClass = typeof label.props?.className === 'string' ? label.props.className : null;
expect(labelClass).not.toBeNull();
expect(labelClass?.includes('tabular-nums')).toBe(true);
expect(typeof label.props?.children).toBe('string');
});

it('renders the assistant time label after the parts view', async () => {
const tree = await renderBubble(assistantMessage('m-time-asst'));
const pressable = findElementByType(tree, 'Pressable');
expect(pressable).not.toBeNull();
if (!pressable) {
throw new Error('expected pressable');
}
const children = Array.isArray(pressable.props.children)
? pressable.props.children
: [pressable.props.children];
const partsIndex = children.findIndex(child =>
subtreeContains(child, p => typeof p.className === 'string' && p.className.includes('gap-2'))
);
const labelIndex = children.findIndex(child => subtreeContainsTimeLabel(child));
expect(partsIndex).toBeGreaterThanOrEqual(0);
expect(labelIndex).toBeGreaterThan(partsIndex);
});

it('does not render a time label when time.created is absent', async () => {
const message = userMessage('m-time-absent');
(message.info.time as { created?: number }).created = undefined;
const tree = await renderBubble(message);
expect(findTimeLabel(tree)).toBeNull();
});

it('does not render a time label when time.created is invalid', async () => {
const message = assistantMessage('m-time-invalid');
message.info.time = { created: Number.NaN };
const tree = await renderBubble(message);
expect(findTimeLabel(tree)).toBeNull();
});
});

describe('MessageBubble regressions', () => {
it('holds badge slot when queued and holdQueuedSlot is set after dequeue', async () => {
const message = userMessage('m7');
Expand Down Expand Up @@ -351,54 +278,3 @@ function findProvider(
}
return null;
}

function subtreeContains(
node: unknown,
predicate: (props: Record<string, unknown>) => boolean
): boolean {
if (node == null || typeof node !== 'object') {
return false;
}
const element = node as { type?: unknown; props?: Record<string, unknown> };
if (predicate(element.props ?? {})) {
return true;
}
const children = element.props?.children;
if (Array.isArray(children)) {
return children.some(child => subtreeContains(child, predicate));
}
if (children && typeof children === 'object') {
return subtreeContains(children, predicate);
}
return false;
}

function subtreeContainsTimeLabel(node: unknown): boolean {
return subtreeContains(
node,
p => typeof p.className === 'string' && p.className.includes('tabular-nums')
);
}

function findTimeLabel(node: unknown): { props: Record<string, unknown> } | null {
if (node == null || typeof node !== 'object') {
return null;
}
const element = node as { type?: unknown; props?: Record<string, unknown> };
const props = element.props ?? {};
if (typeof props.className === 'string' && props.className.includes('tabular-nums')) {
return { props };
}
const children = element.props?.children;
if (Array.isArray(children)) {
for (const child of children) {
const hit = findTimeLabel(child);
if (hit) {
return hit;
}
}
} else if (children && typeof children === 'object') {
return findTimeLabel(children);
}
return null;
}
49 changes: 17 additions & 32 deletions apps/mobile/src/components/agents/message-bubble.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import { ChatMarkdownText } from './chat-markdown-text';
import { CompactionSeparator } from './compaction-separator';
import { FilePartRenderer } from './file-part-renderer';
import { buildAgentMessageBubbleAccessibilityProps } from './message-bubble-a11y';
import { formatTranscriptTimeLabel } from './message-time-label';
import { PartRenderer } from './part-renderer';
import { isFilePart, isTextPart } from './part-types';
import { useMessageCopy } from './use-message-copy';
Expand Down Expand Up @@ -79,12 +78,6 @@ export function MessageBubble({
);
}

// Subtle time label, computed once per render. `Date.now()` at render time
// only: no timer and no day-boundary watcher, so a message mounted across
// midnight keeps its label until the next render (stream tick, list recycle,
// navigation).
const timeLabel = formatTranscriptTimeLabel(message.info.time.created, Date.now());

if (isUser) {
// Composer, queued-message synthesis, and slash commands emit exactly one
// human-authored text part, so the separator separates it from synthesized
Expand All @@ -111,29 +104,24 @@ export function MessageBubble({
))}
</InMessageBubbleContext.Provider>
</Bubble>
{hasBadgeSlot || timeLabel ? (
{hasBadgeSlot ? (
<View className="flex-row items-center gap-2 self-end pr-1">
{hasBadgeSlot ? (
<View
accessibilityRole={isQueued ? 'text' : undefined}
accessibilityLabel={isQueued ? 'Message queued' : undefined}
accessible={isQueued}
{...(!isQueued
? {
accessibilityElementsHidden: true as const,
importantForAccessibility: 'no-hide-descendants' as const,
}
: {})}
pointerEvents={isQueued ? 'auto' : 'none'}
className={`flex-row items-center gap-1 self-end pr-1 ${isQueued ? 'opacity-100' : 'opacity-0'}`}
>
<Clock size={12} color={colors.mutedForeground} />
<Text className="text-xs text-muted-foreground">Queued</Text>
</View>
) : null}
{timeLabel ? (
<Text className="text-xs text-muted-foreground tabular-nums">{timeLabel}</Text>
) : null}
<View
accessibilityRole={isQueued ? 'text' : undefined}
accessibilityLabel={isQueued ? 'Message queued' : undefined}
accessible={isQueued}
{...(!isQueued
? {
accessibilityElementsHidden: true as const,
importantForAccessibility: 'no-hide-descendants' as const,
}
: {})}
pointerEvents={isQueued ? 'auto' : 'none'}
className={`flex-row items-center gap-1 self-end pr-1 ${isQueued ? 'opacity-100' : 'opacity-0'}`}
>
<Clock size={12} color={colors.mutedForeground} />
<Text className="text-xs text-muted-foreground">Queued</Text>
</View>
</View>
) : null}
</View>
Expand Down Expand Up @@ -173,9 +161,6 @@ export function MessageBubble({
))}
</View>
</InMessageBubbleContext.Provider>
{timeLabel ? (
<Text className="mt-1 text-xs text-muted-foreground tabular-nums">{timeLabel}</Text>
) : null}
{a11y.accessibilityActions.length > 0 ? (
<View
accessible
Expand Down
60 changes: 59 additions & 1 deletion apps/mobile/src/components/agents/message-time-label.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,11 @@
import { describe, expect, it } from 'vitest';

import { formatTranscriptTimeLabel } from './message-time-label';
import {
formatTranscriptMarkerLabel,
formatTranscriptTimeLabel,
isSameLocalDay,
isValidTranscriptTime,
} from './message-time-label';

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

Expand Down Expand Up @@ -52,3 +57,56 @@ describe('formatTranscriptTimeLabel', () => {
expect(formatTranscriptTimeLabel(created, now)).toBe(DATE_TIME.format(new Date(created)));
});
});

describe('isSameLocalDay', () => {
it('returns true for two instants anywhere on the same local calendar day', () => {
const dayStart = new Date(2026, 0, 1, 0, 0, 1).getTime();
const dayEnd = new Date(2026, 0, 1, 23, 59, 59).getTime();
expect(isSameLocalDay(dayStart, dayEnd)).toBe(true);
});

it('returns false for two instants one second across local midnight', () => {
const beforeMidnight = new Date(2026, 0, 1, 23, 59, 59).getTime();
const afterMidnight = new Date(2026, 0, 2, 0, 0, 0).getTime();
expect(isSameLocalDay(beforeMidnight, afterMidnight)).toBe(false);
});
});

describe('isValidTranscriptTime', () => {
it('rejects absent, non-positive, non-finite, and out-of-range epochs', () => {
expect(isValidTranscriptTime(undefined)).toBe(false);
expect(isValidTranscriptTime(null)).toBe(false);
expect(isValidTranscriptTime(0)).toBe(false);
expect(isValidTranscriptTime(-1)).toBe(false);
expect(isValidTranscriptTime(Number.NaN)).toBe(false);
expect(isValidTranscriptTime(Number.MAX_VALUE)).toBe(false);
});

it('accepts a current epoch', () => {
expect(isValidTranscriptTime(Date.now())).toBe(true);
});
});

describe('formatTranscriptMarkerLabel', () => {
it('always shows the date for a day-change marker even when the day is today', () => {
const created = new Date(2026, 7, 5, 14, 32).getTime();
const now = new Date(2026, 7, 5, 9, 1).getTime();
expect(formatTranscriptMarkerLabel(created, now, true)).toBe(
DATE_TIME.format(new Date(created))
);
});

it('keeps the message-label rule when the day did not change', () => {
const created = new Date(2026, 7, 5, 14, 32).getTime();
const now = new Date(2026, 7, 5, 9, 1).getTime();
expect(formatTranscriptMarkerLabel(created, now, false)).toBe(
formatTranscriptTimeLabel(created, now)
);
});

it('returns null for an out-of-range epoch with either flag', () => {
const now = new Date(2026, 7, 5, 9, 1).getTime();
expect(formatTranscriptMarkerLabel(Number.MAX_VALUE, now, true)).toBeNull();
expect(formatTranscriptMarkerLabel(Number.MAX_VALUE, now, false)).toBeNull();
});
});
Loading