diff --git a/apps/mobile/.oxlintrc.json b/apps/mobile/.oxlintrc.json index 7414b9e13e..1dedb2c905 100644 --- a/apps/mobile/.oxlintrc.json +++ b/apps/mobile/.oxlintrc.json @@ -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" @@ -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": [ diff --git a/apps/mobile/src/components/agents/markdown-table.tsx b/apps/mobile/src/components/agents/markdown-table.tsx index f26cb25ca2..a20622467c 100644 --- a/apps/mobile/src/components/agents/markdown-table.tsx +++ b/apps/mobile/src/components/agents/markdown-table.tsx @@ -264,6 +264,7 @@ export function MarkdownTable({ {open ? ( ({ + useThemeColors: () => ({ background: '#000' }), +})); vi.mock('react-native', () => ({ Alert: { alert: vi.fn() }, Modal: 'Modal', diff --git a/apps/mobile/src/components/agents/part-detail-sheet-host.mounted.test.tsx b/apps/mobile/src/components/agents/part-detail-sheet-host.mounted.test.tsx index f14f30f69e..49cb4469e6 100644 --- a/apps/mobile/src/components/agents/part-detail-sheet-host.mounted.test.tsx +++ b/apps/mobile/src/components/agents/part-detail-sheet-host.mounted.test.tsx @@ -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', diff --git a/apps/mobile/src/components/agents/part-detail-sheet.mounted.test.tsx b/apps/mobile/src/components/agents/part-detail-sheet.mounted.test.tsx index acf33203b0..219954a1e7 100644 --- a/apps/mobile/src/components/agents/part-detail-sheet.mounted.test.tsx +++ b/apps/mobile/src/components/agents/part-detail-sheet.mounted.test.tsx @@ -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', diff --git a/apps/mobile/src/components/agents/session-detail-content.tsx b/apps/mobile/src/components/agents/session-detail-content.tsx index fbd504dab1..983974cd8a 100644 --- a/apps/mobile/src/components/agents/session-detail-content.tsx +++ b/apps/mobile/src/components/agents/session-detail-content.tsx @@ -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'; @@ -1458,6 +1459,7 @@ export function SessionDetailContent({ sessionId={sessionId} items={transcript} keyExtractor={getSessionTranscriptItemKey} + getItemType={getSessionTranscriptItemType} hasOlderMessages={hasOlderMessages} isLoadingOlderMessages={isLoadingOlderMessages} olderMessagesError={olderMessagesError} diff --git a/apps/mobile/src/components/agents/session-message-list.tsx b/apps/mobile/src/components/agents/session-message-list.tsx index cbb4abc8f7..7f518aecbf 100644 --- a/apps/mobile/src/components/agents/session-message-list.tsx +++ b/apps/mobile/src/components/agents/session-message-list.tsx @@ -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 = { sessionId: string; items: readonly T[]; keyExtractor: (item: T) => string; + getItemType?: (item: T) => string; hasOlderMessages: boolean; isLoadingOlderMessages: boolean; olderMessagesError: OlderMessagesError | null; @@ -57,6 +60,7 @@ export function SessionMessageList({ sessionId, items, keyExtractor, + getItemType, hasOlderMessages, isLoadingOlderMessages, olderMessagesError, @@ -190,7 +194,12 @@ export function SessionMessageList({ 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} diff --git a/apps/mobile/src/components/agents/session-page-sheet.mounted.test.tsx b/apps/mobile/src/components/agents/session-page-sheet.mounted.test.tsx index 91e63b1aa4..282769e64c 100644 --- a/apps/mobile/src/components/agents/session-page-sheet.mounted.test.tsx +++ b/apps/mobile/src/components/agents/session-page-sheet.mounted.test.tsx @@ -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', diff --git a/apps/mobile/src/components/agents/session-page-sheet.tsx b/apps/mobile/src/components/agents/session-page-sheet.tsx index 94fbcdbb66..be1e09062c 100644 --- a/apps/mobile/src/components/agents/session-page-sheet.tsx +++ b/apps/mobile/src/components/agents/session-page-sheet.tsx @@ -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 = { @@ -27,6 +28,7 @@ export function SessionPageSheet({ children, }: Readonly) { const insets = useSafeAreaInsets(); + const colors = useThemeColors(); const [coverClosed, setCoverClosed] = useState(false); // Close when the privacy cover fires (app backgrounds on a covered route). @@ -64,6 +66,9 @@ export function SessionPageSheet({ return ( + { 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; @@ -784,7 +784,7 @@ describe('ReviewDetailScreen spectator transcript', () => { renderScreen(true); expect(captured.onEvent).toBeDefined(); - act(() => { + await act(async () => { captured.onEvent?.({ eventId: 1, sessionId: 's-1', @@ -792,6 +792,9 @@ describe('ReviewDetailScreen spectator transcript', () => { timestamp: 't1', data: null, }); + await new Promise(resolve => { + setTimeout(resolve, 0); + }); }); const liveList = sessionListRenders.list.at(-1); const liveItems = liveList?.items as { message?: string }[] | undefined; @@ -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 }) => { @@ -835,7 +838,7 @@ describe('ReviewDetailScreen spectator transcript', () => { const renderer = mountScreen(true); expect(captured.onEvent).toBeDefined(); - act(() => { + await act(async () => { captured.onEvent?.({ eventId: 1, sessionId: 's-1', @@ -843,6 +846,9 @@ describe('ReviewDetailScreen spectator transcript', () => { timestamp: 't1', data: null, }); + await new Promise(resolve => { + setTimeout(resolve, 0); + }); }); // The review turns terminal while rows are already streamed: the gate must diff --git a/apps/mobile/src/components/code-reviewer/review-spectator-rows.test.ts b/apps/mobile/src/components/code-reviewer/review-spectator-rows.test.ts new file mode 100644 index 0000000000..061550d28c --- /dev/null +++ b/apps/mobile/src/components/code-reviewer/review-spectator-rows.test.ts @@ -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); + }); +}); diff --git a/apps/mobile/src/components/code-reviewer/review-spectator-rows.ts b/apps/mobile/src/components/code-reviewer/review-spectator-rows.ts index e5473aa1e1..e0e92f37bc 100644 --- a/apps/mobile/src/components/code-reviewer/review-spectator-rows.ts +++ b/apps/mobile/src/components/code-reviewer/review-spectator-rows.ts @@ -70,7 +70,7 @@ function toolDetail(input: Record | 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) { @@ -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(); + 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; } @@ -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; } @@ -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 | 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; + }, + }; +} diff --git a/apps/mobile/src/components/code-reviewer/review-spectator-stream.test.ts b/apps/mobile/src/components/code-reviewer/review-spectator-stream.test.ts index f655bfbb6a..80b3b3ee9e 100644 --- a/apps/mobile/src/components/code-reviewer/review-spectator-stream.test.ts +++ b/apps/mobile/src/components/code-reviewer/review-spectator-stream.test.ts @@ -58,6 +58,7 @@ describe('createReviewSpectatorStream', () => { organizationId: 'org-1', onEvent: noopCallback, onConnected: noopCallback, + onReconnected: noopCallback, onDisconnected: noopCallback, onError: noopCallback, }); @@ -94,6 +95,7 @@ describe('createReviewSpectatorStream', () => { organizationId: 'org-1', onEvent: noopCallback, onConnected: noopCallback, + onReconnected: noopCallback, onDisconnected: noopCallback, onError: noopCallback, }); @@ -120,6 +122,7 @@ describe('createReviewSpectatorStream', () => { organizationId: '', onEvent: noopCallback, onConnected: noopCallback, + onReconnected: noopCallback, onDisconnected: noopCallback, onError: noopCallback, }); diff --git a/apps/mobile/src/components/code-reviewer/review-spectator-stream.ts b/apps/mobile/src/components/code-reviewer/review-spectator-stream.ts index 4772fed935..8653a72ffc 100644 --- a/apps/mobile/src/components/code-reviewer/review-spectator-stream.ts +++ b/apps/mobile/src/components/code-reviewer/review-spectator-stream.ts @@ -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 { @@ -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 }, diff --git a/apps/mobile/src/components/code-reviewer/review-spectator.tsx b/apps/mobile/src/components/code-reviewer/review-spectator.tsx index 45bc696344..db2db5fdcc 100644 --- a/apps/mobile/src/components/code-reviewer/review-spectator.tsx +++ b/apps/mobile/src/components/code-reviewer/review-spectator.tsx @@ -9,7 +9,8 @@ import { SessionMessageList } from '@/components/agents/session-message-list'; import { SessionSkeletonMessages } from '@/components/agents/session-detail-skeleton'; import { CompactRetry } from '@/components/code-reviewer/review-spectator-retry'; import { - appendSpectatorRow, + appendSpectatorRows, + createSpectatorRowBatcher, formatSpectatorTime, type SpectatorRow, spectatorRowsFromEntries, @@ -117,6 +118,16 @@ export function ReviewSpectator({ // stale start from calling `connect()` and makes it destroy its connection. let disposed = false; let connection: Connection | null = null; + const clearLiveError = () => { + if (!disposed) { + setLiveError(false); + } + }; + const batcher = createSpectatorRowBatcher(batch => { + if (!disposed) { + setLiveRows(prev => appendSpectatorRows(prev, batch)); + } + }); void (async () => { if (liveCloudId === null) { @@ -132,17 +143,19 @@ export function ReviewSpectator({ return; } const row = toSpectatorRow(event, t); - if (row !== null) { - const keyedRow = - row.key === undefined ? { ...row, key: `event-${event.eventId}` } : row; - setLiveRows(prev => appendSpectatorRow(prev, keyedRow)); - } - }, - onConnected: () => { - if (!disposed) { - setLiveError(false); + if (row === null) { + return; } + // Synthetic events (connected, snapshots, queued messages) all carry + // eventId 0. A shared key would collapse them into one row. + const keyedRow = + row.key === undefined && event.eventId > 0 + ? { ...row, key: `event-${event.eventId}` } + : row; + batcher.push(keyedRow); }, + onConnected: clearLiveError, + onReconnected: clearLiveError, onDisconnected: () => { if (!disposed) { setLiveError(true); @@ -171,6 +184,7 @@ export function ReviewSpectator({ return () => { disposed = true; + batcher.dispose(); connection?.destroy(); }; }, [liveCloudId, info?.organizationId, retryNonce, t]); diff --git a/apps/mobile/src/components/image-viewer-modal.tsx b/apps/mobile/src/components/image-viewer-modal.tsx index 074fc0d025..f4c263314d 100644 --- a/apps/mobile/src/components/image-viewer-modal.tsx +++ b/apps/mobile/src/components/image-viewer-modal.tsx @@ -141,7 +141,12 @@ export function ImageViewerModal({ })); return ( - + 300 ? textContent.slice(0, 300) + '…' : textContent; - entries.push({ timestamp: baseTs, eventType: 'info', message: truncated }); + entries.push({ timestamp: baseTs, eventType: 'info', message: textContent }); } continue; } @@ -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; } @@ -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; } @@ -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; } @@ -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 }; } @@ -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 @@ -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 @@ -232,7 +225,7 @@ function cloudMessageToLogEntry( return { timestamp: ts, eventType: 'error', - message: `Error: ${text.length > 200 ? text.slice(0, 200) + '…' : text}`, + message: `Error: ${text}`, }; } diff --git a/services/session-ingest/src/dos/UserConnectionDO.test.ts b/services/session-ingest/src/dos/UserConnectionDO.test.ts index 1e055d80e8..19705a1690 100644 --- a/services/session-ingest/src/dos/UserConnectionDO.test.ts +++ b/services/session-ingest/src/dos/UserConnectionDO.test.ts @@ -3920,6 +3920,60 @@ 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('emits session.status.updated on the root when a child raise appears and clears', () => { + const { doInstance, mockCtx } = setup(); + const cliWs = addCliSocket(mockCtx, 'cli-1'); + const webWs = addWebSocket(mockCtx, 'web-1'); + const statusEvents = () => + webWs.send.mock.calls + .map(call => JSON.parse(call[0] as string) as { event?: string; data?: unknown }) + .filter(msg => msg.event === 'session.status.updated') + .map( + msg => msg.data as { sessionId: string; status: string; previousStatus: string | null } + ); + + sendHeartbeat(doInstance, cliWs, [ + makeSession('root-1', 'busy', 'Root session'), + makeSession('child-1', 'permission', 'Child session', 'root-1'), + ]); + expect(statusEvents()).toMatchObject([ + { sessionId: 'root-1', status: 'permission', previousStatus: null }, + ]); + + webWs.send.mockClear(); + sendHeartbeat(doInstance, cliWs, [makeSession('root-1', 'busy', 'Root session')]); + expect(statusEvents()).toMatchObject([ + { sessionId: 'root-1', status: 'busy', previousStatus: 'permission' }, + ]); + + webWs.send.mockClear(); + sendHeartbeat(doInstance, cliWs, [makeSession('root-1', 'busy', 'Root session')]); + expect(statusEvents()).toEqual([]); + }); + it('cleans up child tracking when session disappears from heartbeat', async () => { const { doInstance, mockCtx } = setup(); const cliWs = addCliSocket(mockCtx, 'cli-1'); diff --git a/services/session-ingest/src/dos/UserConnectionDO.ts b/services/session-ingest/src/dos/UserConnectionDO.ts index 1801331b08..7e23aa6b71 100644 --- a/services/session-ingest/src/dos/UserConnectionDO.ts +++ b/services/session-ingest/src/dos/UserConnectionDO.ts @@ -2,6 +2,7 @@ import { DurableObject } from 'cloudflare:workers'; import type { Env } from '../env'; import { getSessionIngestDO } from './SessionIngestDO'; +import { hoistedAttentionChanges, hoistedChildAttention } from './child-attention'; import { resolveAccessibleKiloSession } from '../services/session-access'; import { CLIOutboundMessageSchema, @@ -9,6 +10,7 @@ import { type Instance, type SessionEventPayload, SessionEventPayloadSchema, + SessionStatusSchema, type WebInboundMessage, WebOutboundMessageSchema, } from '../types/user-connection-protocol'; @@ -688,6 +690,27 @@ export class UserConnectionDO extends DurableObject { }, }); + // Clients keep a needs-input status sticky until an explicit status event + // names the session, so a hoisted child raise must arrive and clear as + // `session.status.updated` on the root. + const changedAt = new Date(now).toISOString(); + for (const change of hoistedAttentionChanges(previousSessions, sessions)) { + const status = SessionStatusSchema.safeParse(change.status); + const previousStatus = SessionStatusSchema.safeParse(change.previousStatus); + this.broadcastToWeb({ + type: 'system', + event: 'session.status.updated', + data: { + source: 'v2', + sessionId: change.sessionId, + previousStatus: previousStatus.success ? previousStatus.data : null, + status: status.success ? status.data : null, + statusUpdatedAt: changedAt, + changedAt, + }, + }); + } + this.sendToCli(ws, { type: 'heartbeat_ack' }); } @@ -2682,6 +2705,18 @@ export class UserConnectionDO extends DurableObject { capabilities?: ConnectionCapabilities; } > = []; + // A subagent raise arrives on the child row, but only root rows are + // emitted. Hoist the child's needs-input status onto its root so the + // session list shows NEEDS INPUT. Derived per call, so it clears when + // the child resolves. + // ponytail: one level deep; iterate to a fixed point if the CLI ever nests deeper. + const hoistedStatus = new Map(); + for (const [connectionId, sessions] of this.connectionSessions) { + if (!liveConnectionIds.has(connectionId)) continue; + for (const [root, status] of hoistedChildAttention(sessions)) { + hoistedStatus.set(root, status); + } + } for (const [connectionId, sessions] of this.connectionSessions) { if (!liveConnectionIds.has(connectionId)) continue; const protocolVersion = this.connectionProtocolVersion.get(connectionId); @@ -2694,6 +2729,7 @@ export class UserConnectionDO extends DurableObject { if (this.sessionOwners.get(session.id) !== connectionId) continue; result.push({ ...session, + status: hoistedStatus.get(session.id) ?? session.status, connectionId, ...(protocolVersion ? { protocolVersion } : {}), ...(capabilities ? { capabilities } : {}), diff --git a/services/session-ingest/src/dos/child-attention.ts b/services/session-ingest/src/dos/child-attention.ts new file mode 100644 index 0000000000..4f96a07ac0 --- /dev/null +++ b/services/session-ingest/src/dos/child-attention.ts @@ -0,0 +1,43 @@ +import { isNeedsInputStatus } from './session-ingest-attention'; + +type ChildSession = { id: string; status: string; parentSessionId?: string }; + +/** + * A subagent raise arrives on the child row, but the session list emits root + * rows only. Map each root id to the needs-input status of one of its children. + */ +export function hoistedChildAttention(sessions: readonly ChildSession[]): Map { + // ponytail: one level deep; iterate to a fixed point if the CLI ever nests deeper. + const hoisted = new Map(); + for (const session of sessions) { + if (session.parentSessionId && isNeedsInputStatus(session.status)) { + hoisted.set(session.parentSessionId, session.status); + } + } + return hoisted; +} + +/** + * Root ids whose hoisted status changed between two heartbeats, with the status + * the root must now show: the child's raise, or the root's own live status once + * every child settled or left the heartbeat. + */ +export function hoistedAttentionChanges( + previous: readonly ChildSession[], + current: readonly ChildSession[] +): Array<{ sessionId: string; status: string; previousStatus: string | null }> { + const before = hoistedChildAttention(previous); + const after = hoistedChildAttention(current); + const changes: Array<{ sessionId: string; status: string; previousStatus: string | null }> = []; + for (const root of current) { + if (root.parentSessionId) { + continue; + } + const was = before.get(root.id) ?? null; + const now = after.get(root.id) ?? null; + if (was !== now) { + changes.push({ sessionId: root.id, status: now ?? root.status, previousStatus: was }); + } + } + return changes; +} diff --git a/tools/oxlint/rn-modal-backdrop.mjs b/tools/oxlint/rn-modal-backdrop.mjs new file mode 100644 index 0000000000..907639b1c9 --- /dev/null +++ b/tools/oxlint/rn-modal-backdrop.mjs @@ -0,0 +1,33 @@ +// A react-native `Modal` paints its container `white`. On Android the Modal +// unmounts its children before the dismiss animation ends, so an opaque Modal +// shows a white flash on close. Every Modal must set `transparent` or a themed +// `backdropColor`. +const rule = { + meta: { + type: 'problem', + docs: { description: 'Require `transparent` or `backdropColor` on a react-native Modal.' }, + messages: { + missingBackdrop: + 'Set `backdropColor={colors.background}` (or `transparent`) on this Modal. ' + + 'The default white container flashes on Android when the sheet closes.', + }, + }, + create(context) { + return { + JSXOpeningElement(node) { + if (node.name.type !== 'JSXIdentifier' || node.name.name !== 'Modal') return; + const hasBackdrop = node.attributes.some( + attr => + attr.type === 'JSXAttribute' && + (attr.name.name === 'transparent' || attr.name.name === 'backdropColor') + ); + if (!hasBackdrop) context.report({ node, messageId: 'missingBackdrop' }); + }, + }; + }, +}; + +export default { + meta: { name: 'rn-modal-backdrop' }, + rules: { 'require-backdrop': rule }, +};