Skip to content
Merged
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 @@ -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 @@ -173,8 +173,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
7 changes: 6 additions & 1 deletion apps/mobile/src/components/image-viewer-modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,12 @@ export function ImageViewerModal({
}));

return (
<Modal visible={visible} animationType="fade" onRequestClose={onClose}>
<Modal
visible={visible}
backdropColor={colors.background}
animationType="fade"
onRequestClose={onClose}
>
<View className="flex-1 bg-background">
<View
className="flex-row items-center justify-between border-b border-border bg-background px-4"
Expand Down
18 changes: 18 additions & 0 deletions apps/mobile/src/lib/active-sessions-live.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,24 @@ describe('selectRootWsSessions', () => {
];
expect(selectRootWsSessions(rows).map(r => r.id)).toEqual(['root1', 'root2']);
});

it('hoists a child attention status onto its root', () => {
const rows = [
{ id: 'root1', status: 'busy', title: 'r1', connectionId: 'c1' },
{
id: 'child1',
status: 'permission',
title: 'c1',
connectionId: 'c1',
parentSessionId: 'root1',
},
{ id: 'root2', status: 'busy', title: 'r2', connectionId: 'c1' },
];
expect(selectRootWsSessions(rows).map(r => [r.id, r.status])).toEqual([
['root1', 'permission'],
['root2', 'busy'],
]);
});
});

// ── Payload parsing ──────────────────────────────────────────────────
Expand Down
19 changes: 18 additions & 1 deletion apps/mobile/src/lib/active-sessions-live.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,25 @@ function isRootWsSession(session: IncomingWsSession): boolean {
return !session.parentSessionId;
}

/**
* Keep root rows only. A subagent raise arrives on the child row, so hoist
* the child's attention status onto its root before dropping the child.
*/
export function selectRootWsSessions<T extends IncomingWsSession>(sessions: readonly T[]): T[] {
return sessions.filter(session => isRootWsSession(session));
const hoisted = new Map<string, string>();
for (const session of sessions) {
if (session.parentSessionId && isAttentionStatus(session.status)) {
hoisted.set(session.parentSessionId, session.status);
}
}
const roots: T[] = [];
for (const session of sessions) {
if (isRootWsSession(session)) {
const status = hoisted.get(session.id);
roots.push(status === undefined ? session : { ...session, status });
Comment thread
iscekic marked this conversation as resolved.
Resolved
}
}
return roots;
}

// ── Payload parsing (WS trust boundary) ──────────────────────────────
Expand Down
25 changes: 9 additions & 16 deletions apps/web/src/lib/code-reviews/session-log.ts
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ export function v2SnapshotToLogEntries(
const time = info.time as { created?: number } | undefined;
const baseTs = time?.created ? new Date(time.created).toISOString() : '';

// Show the initial user prompt as a single log line (truncated)
// Show the initial user prompt as a single log line
if (role === 'user') {
if (options.includeFullAssistantText) continue;
const textContent = (msg.parts ?? [])
Expand All @@ -51,8 +51,7 @@ export function v2SnapshotToLogEntries(
.join('')
.trim();
if (textContent) {
const truncated = textContent.length > 300 ? textContent.slice(0, 300) + '…' : textContent;
entries.push({ timestamp: baseTs, eventType: 'info', message: truncated });
entries.push({ timestamp: baseTs, eventType: 'info', message: textContent });
}
continue;
}
Expand All @@ -79,8 +78,7 @@ export function v2SnapshotToLogEntries(
const command = input.command;
const query = input.query ?? input.pattern;
if (typeof filePath === 'string') detail = filePath;
else if (typeof command === 'string')
detail = command.length > 120 ? command.slice(0, 120) + '…' : command;
else if (typeof command === 'string') detail = command;
else if (typeof query === 'string') detail = query;
}

Expand Down Expand Up @@ -115,8 +113,7 @@ export function v2SnapshotToLogEntries(
});
continue;
}
const truncated = text.length > 200 ? text.slice(0, 200) + '…' : text;
entries.push({ timestamp: baseTs, eventType: 'text', message: truncated });
entries.push({ timestamp: baseTs, eventType: 'text', message: text });
continue;
}

Expand Down Expand Up @@ -164,8 +161,7 @@ export function v1BlobToLogEntries(
if (options.includeFullAssistantText) continue;
const text = (msg.text || msg.content || '').trim();
if (text) {
const truncated = text.length > 300 ? text.slice(0, 300) + '…' : text;
entries.push({ timestamp: ts, eventType: 'info', message: truncated });
entries.push({ timestamp: ts, eventType: 'info', message: text });
}
continue;
}
Expand Down Expand Up @@ -193,8 +189,7 @@ function cloudMessageToLogEntry(
const filePath = meta.path ?? meta.filePath;
const command = meta.command;
if (typeof filePath === 'string') detail = filePath;
else if (typeof command === 'string')
detail = command.length > 120 ? command.slice(0, 120) + '…' : command;
else if (typeof command === 'string') detail = command;
}
return { timestamp: ts, eventType: 'tool', message: `Tool: ${toolName}`, content: detail };
}
Expand All @@ -211,8 +206,7 @@ function cloudMessageToLogEntry(
if (options.includeFullAssistantText) {
return { timestamp: ts, eventType: 'text', message: 'Assistant response', content: text };
}
const truncated = text.length > 200 ? text.slice(0, 200) + '…' : text;
return { timestamp: ts, eventType: 'text', message: truncated };
return { timestamp: ts, eventType: 'text', message: text };
}

// General text output
Expand All @@ -222,8 +216,7 @@ function cloudMessageToLogEntry(
if (options.includeFullAssistantText) {
return { timestamp: ts, eventType: 'text', message: 'Assistant response', content: text };
}
const truncated = text.length > 200 ? text.slice(0, 200) + '…' : text;
return { timestamp: ts, eventType: 'text', message: truncated };
return { timestamp: ts, eventType: 'text', message: text };
}

// Error messages
Expand All @@ -232,7 +225,7 @@ function cloudMessageToLogEntry(
return {
timestamp: ts,
eventType: 'error',
message: `Error: ${text.length > 200 ? text.slice(0, 200) + '…' : text}`,
message: `Error: ${text}`,
};
}

Expand Down
23 changes: 23 additions & 0 deletions services/session-ingest/src/dos/UserConnectionDO.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3920,6 +3920,29 @@ describe('UserConnectionDO', () => {
]);
});

it('hoists a child needs-input status onto the root row', () => {
const { doInstance, mockCtx } = setup();
const cliWs = addCliSocket(mockCtx, 'cli-1');

sendHeartbeat(doInstance, cliWs, [
makeSession('root-1', 'busy', 'Root session'),
makeSession('child-1', 'permission', 'Child session', 'root-1'),
]);

expect(doInstance.getActiveSessions()).toEqual([
{ id: 'root-1', status: 'permission', title: 'Root session', connectionId: 'cli-1' },
]);

sendHeartbeat(doInstance, cliWs, [
makeSession('root-1', 'busy', 'Root session'),
makeSession('child-1', 'busy', 'Child session', 'root-1'),
]);

expect(doInstance.getActiveSessions()).toEqual([
{ id: 'root-1', status: 'busy', title: 'Root session', connectionId: 'cli-1' },
]);
});

it('cleans up child tracking when session disappears from heartbeat', async () => {
const { doInstance, mockCtx } = setup();
const cliWs = addCliSocket(mockCtx, 'cli-1');
Expand Down
Loading