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/.oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,10 @@
"name": "zod-utils",
"specifier": "../../tools/oxlint/zod-utils.mjs"
},
{
"name": "rn-modal-backdrop",
"specifier": "../../tools/oxlint/rn-modal-backdrop.mjs"
},
{
"name": "no-literal-copy",
"specifier": "../../tools/oxlint/no-literal-copy/index.ts"
Expand Down Expand Up @@ -211,6 +215,7 @@
"anti-slop/no-runtime-typeof": "error",
"anti-slop/no-unknown-returns": "error",
"zod-utils/no-inline-zod-schema": "error",
"rn-modal-backdrop/require-backdrop": "error",
"no-literal-copy/no-literal-copy": "error"
},
"overrides": [
Expand Down
1 change: 1 addition & 0 deletions apps/mobile/src/components/agents/markdown-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -264,6 +264,7 @@ export function MarkdownTable({
{open ? (
<Modal
visible
backdropColor={colors.background}
animationType="slide"
// Best-effort focus after native presentation; moveA11yFocus is a no-op
// when the title handle is not mounted yet, so no retry loop is needed.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import { describe, expect, it, vi } from 'vitest';

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

vi.mock('@/lib/hooks/use-theme-colors', () => ({
useThemeColors: () => ({ background: '#000' }),
}));
vi.mock('react-native', () => ({
Alert: { alert: vi.fn() },
Modal: 'Modal',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@ import { PartDetailSheetHost } from './part-detail-sheet-host';
// while the sheet module loads, so its binding must already be initialized.
import { MonoScrollBlock } from './mono-scroll-block';

vi.mock('@/lib/hooks/use-theme-colors', () => ({
useThemeColors: () => ({ background: '#000' }),
}));
vi.mock('react-native', () => ({
Modal: 'Modal',
ScrollView: 'ScrollView',
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ import { describe, expect, it, type Mock, vi } from 'vitest';
import { MonoScrollBlock } from './mono-scroll-block';
import { PartDetailSheet } from './part-detail-sheet';

vi.mock('@/lib/hooks/use-theme-colors', () => ({
useThemeColors: () => ({ background: '#000' }),
}));
vi.mock('react-native', () => ({
Modal: 'Modal',
ScrollView: 'ScrollView',
Expand Down
2 changes: 2 additions & 0 deletions apps/mobile/src/components/agents/session-detail-content.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,7 @@ import { SessionSkeletonMessages } from '@/components/agents/session-detail-skel
import { SessionMessageList } from '@/components/agents/session-message-list';
import {
getSessionTranscriptItemKey,
getSessionTranscriptItemType,
mergeSessionTranscript,
type SessionTranscriptItem,
} from '@/components/agents/session-transcript';
Expand Down Expand Up @@ -1458,6 +1459,7 @@ export function SessionDetailContent({
sessionId={sessionId}
items={transcript}
keyExtractor={getSessionTranscriptItemKey}
getItemType={getSessionTranscriptItemType}
hasOlderMessages={hasOlderMessages}
isLoadingOlderMessages={isLoadingOlderMessages}
olderMessagesError={olderMessagesError}
Expand Down
9 changes: 9 additions & 0 deletions apps/mobile/src/components/agents/session-message-list.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,10 +24,13 @@ const listContentContainerStyle = { paddingVertical: 8 } satisfies ViewStyle;
// otherwise spam the FlashList event log.
const ON_START_REACHED_THRESHOLD = 2;

const DRAW_DISTANCE = 1000;

type SessionMessageListProps<T> = {
sessionId: string;
items: readonly T[];
keyExtractor: (item: T) => string;
getItemType?: (item: T) => string;
hasOlderMessages: boolean;
isLoadingOlderMessages: boolean;
olderMessagesError: OlderMessagesError | null;
Expand Down Expand Up @@ -57,6 +60,7 @@ export function SessionMessageList<T>({
sessionId,
items,
keyExtractor,
getItemType,
hasOlderMessages,
isLoadingOlderMessages,
olderMessagesError,
Expand Down Expand Up @@ -190,7 +194,12 @@ export function SessionMessageList<T>({
contentContainerStyle={resolvedContentContainerStyle}
data={items}
keyExtractor={keyExtractor}
getItemType={getItemType}
renderItem={renderItem}
// Transcript rows are tall and parse markdown on mount. The 250 dp
// default draws under half a screen ahead, so a fast fling shows blank
// space until the rows mount. Four screens of lookahead hides that.
drawDistance={DRAW_DISTANCE}
// Android Fabric can race clipped-view reattachment with rapid transcript updates.
removeClippedSubviews={false}
onScroll={handleScroll}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,9 @@ const safeAreaMock = vi.hoisted(() => ({
useSafeAreaInsets: vi.fn(() => ({ top: 0, bottom: 0 })),
}));

vi.mock('@/lib/hooks/use-theme-colors', () => ({
useThemeColors: () => ({ background: '#000' }),
}));
vi.mock('react-native', () => ({
Modal: 'Modal',
View: 'View',
Expand Down
12 changes: 11 additions & 1 deletion apps/mobile/src/components/agents/session-page-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { type ReactNode, useEffect, useState } from 'react';
import { AppState, Modal, Platform, View } from 'react-native';
import { useSafeAreaInsets } from 'react-native-safe-area-context';

import { useThemeColors } from '@/lib/hooks/use-theme-colors';
import { subscribePrivacyCover } from '@/lib/privacy-cover-events';

type SessionPageSheetProps = {
Expand All @@ -27,6 +28,7 @@ export function SessionPageSheet({
children,
}: Readonly<SessionPageSheetProps>) {
const insets = useSafeAreaInsets();
const colors = useThemeColors();
const [coverClosed, setCoverClosed] = useState(false);

// Close when the privacy cover fires (app backgrounds on a covered route).
Expand Down Expand Up @@ -64,6 +66,9 @@ export function SessionPageSheet({
return (
<Modal
visible={open}
// RN Modal paints its container white. Android unmounts the children
// before the slide-out ends, so the container shows as a white flash.
backdropColor={colors.background}
animationType="slide"
presentationStyle="pageSheet"
onRequestClose={onClose}
Expand All @@ -75,7 +80,12 @@ export function SessionPageSheet({
}

return (
<Modal visible={open} animationType="slide" onRequestClose={onClose}>
<Modal
visible={open}
backdropColor={colors.background}
animationType="slide"
onRequestClose={onClose}
>
<View
style={{ paddingTop: insets.top }}
className="flex-1 bg-background"
Expand Down
4 changes: 4 additions & 0 deletions apps/mobile/src/components/agents/session-transcript.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,10 @@ export function getSessionTranscriptItemKey(item: SessionTranscriptItem): string
return `time:${item.messageId}`;
}

export function getSessionTranscriptItemType(item: SessionTranscriptItem): string {
return item.type;
}

export function mergeSessionTranscript(
messages: readonly StoredMessage[],
preparationAttempts: readonly PreparationAttempt[]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -754,7 +754,7 @@ describe('ReviewDetailScreen spectator transcript', () => {
expect(spectatorQueries.streamInfo.refetch).not.toHaveBeenCalled();
});

it('keeps live rows and shows Retry after a websocket drop', () => {
it('keeps live rows and shows Retry after a websocket drop', async () => {
const captured: {
onEvent?: (event: unknown) => void;
onDisconnected?: () => void;
Expand Down Expand Up @@ -784,14 +784,17 @@ describe('ReviewDetailScreen spectator transcript', () => {
renderScreen(true);

expect(captured.onEvent).toBeDefined();
act(() => {
await act(async () => {
captured.onEvent?.({
eventId: 1,
sessionId: 's-1',
streamEventType: 'started',
timestamp: 't1',
data: null,
});
await new Promise<void>(resolve => {
setTimeout(resolve, 0);
});
});
const liveList = sessionListRenders.list.at(-1);
const liveItems = liveList?.items as { message?: string }[] | undefined;
Expand All @@ -809,7 +812,7 @@ describe('ReviewDetailScreen spectator transcript', () => {
expect(afterDropItems?.[0]?.message).toBe('Execution started');
});

it('keeps a streamed row when the review turns terminal (no skeleton or empty copy)', () => {
it('keeps a streamed row when the review turns terminal (no skeleton or empty copy)', async () => {
const captured: { onEvent?: (event: unknown) => void } = {};
spectatorStream.createReviewSpectatorStream.mockImplementation(
(input: { onEvent: (event: unknown) => void }) => {
Expand All @@ -835,14 +838,17 @@ describe('ReviewDetailScreen spectator transcript', () => {
const renderer = mountScreen(true);

expect(captured.onEvent).toBeDefined();
act(() => {
await act(async () => {
captured.onEvent?.({
eventId: 1,
sessionId: 's-1',
streamEventType: 'started',
timestamp: 't1',
data: null,
});
await new Promise<void>(resolve => {
setTimeout(resolve, 0);
});
});

// The review turns terminal while rows are already streamed: the gate must
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest';

import { appendSpectatorRows, type SpectatorRow } from './review-spectator-rows';

const row = (message: string, key?: string): SpectatorRow => ({
timestamp: 't',
message,
eventType: 'info',
...(key === undefined ? {} : { key }),
});

describe('appendSpectatorRows', () => {
it('replaces a keyed row and appends unkeyed rows', () => {
const rows = appendSpectatorRows(
[row('a', 'k1'), row('b')],
[row('a2', 'k1'), row('c'), row('d', 'k2'), row('d2', 'k2')]
);
expect(rows.map(r => r.message)).toEqual(['a2', 'b', 'c', 'd2']);
});

it('keeps every unkeyed row in a batch', () => {
const rows = appendSpectatorRows([], [row('connected'), row('snapshot'), row('queued')]);
expect(rows).toHaveLength(3);
});
});
68 changes: 57 additions & 11 deletions apps/mobile/src/components/code-reviewer/review-spectator-rows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,7 +70,7 @@ function toolDetail(input: Record<string, unknown> | undefined): string | undefi
}
const commandString = asString(command);
if (commandString !== undefined) {
return commandString.length > 100 ? `${commandString.slice(0, 100)}...` : commandString;
return commandString;
}
const queryString = asString(query);
if (queryString !== undefined) {
Expand Down Expand Up @@ -100,16 +100,33 @@ function isCompletedStatus(status: string | undefined): boolean {
return status === 'complete' || status === 'completed';
}

export function appendSpectatorRow(rows: SpectatorRow[], next: SpectatorRow): SpectatorRow[] {
if (next.key === undefined) {
return [...rows, next];
/**
* Append a batch of rows. A keyed row replaces the row with the same key; an
* unkeyed row is always appended. One pass over the batch keeps a stream replay
* of thousands of events linear.
*/
export function appendSpectatorRows(
rows: readonly SpectatorRow[],
batch: readonly SpectatorRow[]
): SpectatorRow[] {
const updated = [...rows];
const indexByKey = new Map<string, number>();
for (const [index, row] of updated.entries()) {
if (row.key !== undefined) {
indexByKey.set(row.key, index);
}
}
const index = rows.findIndex(row => row.key === next.key);
if (index === -1) {
return [...rows, next];
for (const next of batch) {
const index = next.key === undefined ? undefined : indexByKey.get(next.key);
if (index === undefined) {
if (next.key !== undefined) {
indexByKey.set(next.key, updated.length);
}
updated.push(next);
} else {
updated[index] = next;
}
}
const updated = [...rows];
updated[index] = next;
return updated;
}

Expand Down Expand Up @@ -173,8 +190,7 @@ function toRowFromKilocode(
const text = asString(part.text);
const trimmed = text?.trim();
if (trimmed) {
const truncated = trimmed.length > 200 ? `${trimmed.slice(0, 200)}...` : trimmed;
return { timestamp, message: truncated, eventType: 'text', key: partKey(part) };
return { timestamp, message: trimmed, eventType: 'text', key: partKey(part) };
}
return null;
}
Expand Down Expand Up @@ -285,3 +301,33 @@ export function formatSpectatorTime(timestamp: string): string {
}
return dateTimeFormat(i18n.language, { timeStyle: 'short' }).format(date);
}

/**
* Collect live rows and commit them once per tick. The server replays the whole
* event log on connect, one event per frame; a commit per event would cost one
* render per event.
*/
export function createSpectatorRowBatcher(commit: (batch: SpectatorRow[]) => void) {
const pending: SpectatorRow[] = [];
let timer: ReturnType<typeof setTimeout> | null = null;
const flush = () => {
timer = null;
const batch = pending.splice(0);
if (batch.length > 0) {
commit(batch);
}
};
return {
push: (row: SpectatorRow) => {
pending.push(row);
timer ??= setTimeout(flush, 0);
},
dispose: () => {
if (timer !== null) {
clearTimeout(timer);
timer = null;
}
pending.length = 0;
},
};
}
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ describe('createReviewSpectatorStream', () => {
organizationId: 'org-1',
onEvent: noopCallback,
onConnected: noopCallback,
onReconnected: noopCallback,
onDisconnected: noopCallback,
onError: noopCallback,
});
Expand Down Expand Up @@ -94,6 +95,7 @@ describe('createReviewSpectatorStream', () => {
organizationId: 'org-1',
onEvent: noopCallback,
onConnected: noopCallback,
onReconnected: noopCallback,
onDisconnected: noopCallback,
onError: noopCallback,
});
Expand All @@ -120,6 +122,7 @@ describe('createReviewSpectatorStream', () => {
organizationId: '',
onEvent: noopCallback,
onConnected: noopCallback,
onReconnected: noopCallback,
onDisconnected: noopCallback,
onError: noopCallback,
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ export async function createReviewSpectatorStream(input: {
organizationId?: string;
onEvent: (event: CloudAgentEvent) => void;
onConnected: () => void;
onReconnected: () => void;
onDisconnected: () => void;
onError: (error: StreamError) => void;
}): Promise<Connection> {
Expand All @@ -105,6 +106,7 @@ export async function createReviewSpectatorStream(input: {
ticket: ticketResult,
onEvent: input.onEvent,
onConnected: input.onConnected,
onReconnected: input.onReconnected,
onDisconnected: input.onDisconnected,
onError: input.onError,
websocketHeaders: { Origin: WEB_BASE_URL },
Expand Down
Loading