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
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,13 @@ export function CodeReviewDetailClient({ reviewId }: CodeReviewDetailClientProps
}

const review = data.review;
const latestAttempt = data.attempts.at(-1);
const displaySessionId =
review.session_id ??
review.cli_session_id ??
latestAttempt?.session_id ??
latestAttempt?.cli_session_id ??
null;
const status = review.status as CodeReviewStatus;
const statusInfo = getCodeReviewStatusIcon(status);
const statusLabel = CODE_REVIEW_STATUS_LABELS[status] ?? review.status;
Expand Down Expand Up @@ -209,7 +216,15 @@ export function CodeReviewDetailClient({ reviewId }: CodeReviewDetailClientProps
{review.model && (
<div>
<dt className="text-muted-foreground">Model</dt>
<dd>{review.model}</dd>
<dd className="break-all">{review.model}</dd>
</div>
)}
{displaySessionId && (
<div>
<dt className="text-muted-foreground">Session</dt>
<dd title={displaySessionId} className="font-mono text-xs break-all">
{displaySessionId}
</dd>
</div>
)}
<div>
Expand Down
121 changes: 15 additions & 106 deletions apps/web/src/components/code-reviews/CodeReviewStreamView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,14 @@ import {
} from '@/lib/cloud-agent-next/websocket-manager';
import type { CloudAgentEvent, StreamError } from '@/lib/cloud-agent-next/event-types';
import { CLOUD_AGENT_NEXT_WS_URL } from '@/lib/constants';
import { isInFlightReviewStatus } from '@kilocode/app-shared/code-review';
import { getCodeReviewDisplayBehavior } from './code-review-stream-behavior';
import { fetchStreamTicket } from './fetch-stream-ticket';
import {
appendCodeReviewDisplayEvent,
toCodeReviewDisplayEvent,
type CodeReviewDisplayEvent,
} from './code-review-stream-events';

type CodeReviewStreamViewProps = {
reviewId: string;
Expand All @@ -40,107 +46,7 @@ type CodeReviewAttemptSummary = {
terminal_reason: string | null;
};

/** Simplified event for display in the code review log */
type DisplayEvent = {
timestamp: string;
message: string;
content?: string;
eventType: string;
};

// ---------------------------------------------------------------------------
// cloud-agent-next event conversion (WebSocket flow)
// ---------------------------------------------------------------------------

function toDisplayEvent(event: CloudAgentEvent): DisplayEvent | null {
const { streamEventType, timestamp, data } = event;
const payload = data as Record<string, unknown> | undefined;

if (streamEventType === 'started') {
return { timestamp, message: 'Execution started', eventType: streamEventType };
}
if (streamEventType === 'complete') {
return { timestamp, message: 'Review completed', eventType: streamEventType };
}
if (streamEventType === 'interrupted') {
return { timestamp, message: 'Review interrupted', eventType: streamEventType };
}
if (streamEventType === 'error') {
const errorMsg = typeof payload?.message === 'string' ? payload.message : 'An error occurred';
return { timestamp, message: `Error: ${errorMsg}`, eventType: streamEventType };
}
if (streamEventType === 'kilocode' && payload) {
return toDisplayEventFromKilocode(timestamp, payload);
}
if (streamEventType === 'status') {
const status = typeof payload?.status === 'string' ? payload.status : '';
if (status) {
return { timestamp, message: `Status: ${status}`, eventType: streamEventType };
}
}
return null;
}

function toDisplayEventFromKilocode(
timestamp: string,
payload: Record<string, unknown>
): DisplayEvent | null {
const type = payload.type as string | undefined;
const properties = payload.properties as Record<string, unknown> | undefined;
if (!type || !properties) return null;

if (type === 'message.part.updated') {
const part = properties.part as Record<string, unknown> | undefined;
if (!part) return null;
const partType = part.type as string | undefined;

if (partType === 'tool') {
const toolName = part.name as string | undefined;
const state = part.state as string | undefined;
if (toolName && state === 'running') {
const input = part.input as Record<string, unknown> | undefined;
let detail: string | undefined;
if (input) {
const filePath = input.filePath ?? input.file_path ?? input.path;
const command = input.command;
const query = input.query ?? input.pattern;
if (typeof filePath === 'string') detail = filePath;
else if (typeof command === 'string')
detail = command.length > 100 ? command.slice(0, 100) + '...' : command;
else if (typeof query === 'string') detail = query;
}
return { timestamp, message: `Tool: ${toolName}`, content: detail, eventType: 'tool' };
}
return null;
}

if (partType === 'text') {
const state = part.state as string | undefined;
if (state && state !== 'complete') return null;
const text = part.text as string | undefined;
if (text && text.trim()) {
const truncated = text.length > 200 ? text.slice(0, 200) + '...' : text;
return { timestamp, message: truncated, eventType: 'text' };
}
return null;
}
return null;
}

if (type === 'session.status') {
const status = properties.status as string | undefined;
if (status === 'idle') return { timestamp, message: 'Agent idle', eventType: 'status' };
if (status === 'busy') return { timestamp, message: 'Agent working...', eventType: 'status' };
return null;
}

if (type === 'session.error') {
const error = properties.error as string | undefined;
return { timestamp, message: `Session error: ${error ?? 'Unknown error'}`, eventType: 'error' };
}

return null;
}
type DisplayEvent = CodeReviewDisplayEvent;

// ---------------------------------------------------------------------------
// Shared helpers
Expand Down Expand Up @@ -202,7 +108,10 @@ export function CodeReviewStreamView({
const latestCompletedAttempt = [...orderedAttempts]
.reverse()
.find(attempt => attempt.status === 'completed');
const defaultAttemptId = latestCompletedAttempt?.id ?? latestAttempt?.id;
const defaultAttemptId =
latestAttempt && isInFlightReviewStatus(latestAttempt.status)
? latestAttempt.id
: (latestCompletedAttempt?.id ?? latestAttempt?.id);
const queryAttemptId = searchParams.get('attemptId');
const queryAttemptExists = orderedAttempts.some(attempt => attempt.id === queryAttemptId);
const effectiveAttemptId = queryAttemptExists ? (queryAttemptId ?? undefined) : defaultAttemptId;
Expand Down Expand Up @@ -312,9 +221,9 @@ export function CodeReviewStreamView({

const handleEvent = useCallback(
(event: CloudAgentEvent) => {
const displayEvent = toDisplayEvent(event);
const displayEvent = toCodeReviewDisplayEvent(event);
if (displayEvent) {
setEvents(prev => [...prev, displayEvent]);
setEvents(prev => appendCodeReviewDisplayEvent(prev, displayEvent));
}
if (event.streamEventType === 'complete' || event.streamEventType === 'interrupted') {
setIsComplete(true);
Expand Down Expand Up @@ -458,7 +367,7 @@ export function CodeReviewStreamView({
<CardTitle className="shrink-0 text-sm font-medium">
{shouldLoadHistory ? 'Session Log' : 'Code Review Progress'}
</CardTitle>
{shouldLoadHistory && cloudAgentSessionId && (
{cloudAgentSessionId && (
<span
title={cloudAgentSessionId}
className="bg-muted text-muted-foreground max-w-[min(20rem,50vw)] truncate rounded px-2 py-0.5 font-mono text-xs font-normal"
Expand Down Expand Up @@ -557,7 +466,7 @@ export function CodeReviewStreamView({
<div className="space-y-1">
{events.map((event, index) => (
<div
key={index}
key={event.key ?? index}
className="rounded px-2 py-1 transition-colors hover:bg-slate-900/50"
>
<div className="flex gap-3 text-slate-300">
Expand Down
201 changes: 201 additions & 0 deletions apps/web/src/components/code-reviews/code-review-stream-events.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
import {
appendCodeReviewDisplayEvent,
toCodeReviewDisplayEvent,
} from './code-review-stream-events';
import type { CloudAgentEvent } from '@/lib/cloud-agent-next/event-types';

function event(streamEventType: string, data: unknown): CloudAgentEvent {
return {
eventId: 1,
executionId: 'exec-1',
sessionId: 'ses-1',
streamEventType,
timestamp: '2026-08-18T12:00:00.000Z',
data,
};
}

function kilocode(type: string, properties: unknown): CloudAgentEvent {
return event('kilocode', { type, properties });
}

describe('toCodeReviewDisplayEvent', () => {
it('shows started and complete stream events', () => {
expect(toCodeReviewDisplayEvent(event('started', {}))).toEqual({
timestamp: '2026-08-18T12:00:00.000Z',
message: 'Execution started',
eventType: 'started',
});
expect(toCodeReviewDisplayEvent(event('complete', {}))).toEqual({
timestamp: '2026-08-18T12:00:00.000Z',
message: 'Review completed',
eventType: 'complete',
});
});

it('shows live tool parts that use object state and part.tool', () => {
expect(
toCodeReviewDisplayEvent(
kilocode('message.part.updated', {
part: {
id: 'prt_read',
type: 'tool',
tool: 'read',
state: { status: 'running', input: { path: '/src/bug.ts' } },
},
})
)
).toEqual({
timestamp: '2026-08-18T12:00:00.000Z',
message: 'Tool: read',
content: '/src/bug.ts',
eventType: 'tool',
key: 'prt_read',
});
});

it('shows completed tool parts so mid-run reconnects still render progress', () => {
expect(
toCodeReviewDisplayEvent(
kilocode('message.part.updated', {
part: {
id: 'prt_bash',
type: 'tool',
name: 'bash',
state: { status: 'completed', input: { command: 'ls src' } },
},
})
)
).toEqual({
timestamp: '2026-08-18T12:00:00.000Z',
message: 'Tool: bash',
content: 'ls src',
eventType: 'tool',
key: 'prt_bash',
});
});

it('drops running tool ticks that have no part id', () => {
expect(
toCodeReviewDisplayEvent(
kilocode('message.part.updated', {
part: {
type: 'tool',
tool: 'bash',
state: { status: 'running', input: { command: 'sleep 10' } },
},
})
)
).toBeNull();
});

it('skips pending tool parts', () => {
expect(
toCodeReviewDisplayEvent(
kilocode('message.part.updated', {
part: {
type: 'tool',
tool: 'read',
state: { status: 'pending', input: {} },
},
})
)
).toBeNull();
});

it('skips streaming text parts until they complete', () => {
expect(
toCodeReviewDisplayEvent(
kilocode('message.part.updated', {
part: { type: 'text', text: 'Looking at the diff now.' },
})
)
).toBeNull();
});

it('does not drop completed text parts whose state is an object', () => {
expect(
toCodeReviewDisplayEvent(
kilocode('message.part.updated', {
part: {
id: 'prt_text',
type: 'text',
text: 'Review summary',
state: { status: 'completed' },
},
})
)
).toEqual({
timestamp: '2026-08-18T12:00:00.000Z',
message: 'Review summary',
eventType: 'text',
key: 'prt_text',
});
});

it('shows session.status when status is an object', () => {
expect(
toCodeReviewDisplayEvent(
kilocode('session.status', { sessionID: 'ses-1', status: { type: 'busy' } })
)
).toEqual({
timestamp: '2026-08-18T12:00:00.000Z',
message: 'Agent working...',
eventType: 'status',
});
});

it('still accepts legacy string tool state and session status', () => {
expect(
toCodeReviewDisplayEvent(
kilocode('message.part.updated', {
part: {
id: 'prt_grep',
type: 'tool',
name: 'grep',
state: 'running',
input: { query: 'TODO' },
},
})
)
).toEqual({
timestamp: '2026-08-18T12:00:00.000Z',
message: 'Tool: grep',
content: 'TODO',
eventType: 'tool',
key: 'prt_grep',
});
expect(toCodeReviewDisplayEvent(kilocode('session.status', { status: 'idle' }))).toEqual({
timestamp: '2026-08-18T12:00:00.000Z',
message: 'Agent idle',
eventType: 'status',
});
});

it('replaces a keyed live event instead of appending another row', () => {
const running = toCodeReviewDisplayEvent(
kilocode('message.part.updated', {
part: {
id: 'prt_bash',
type: 'tool',
tool: 'bash',
state: { status: 'running', input: { command: 'sleep 10' } },
},
})
);
const completed = toCodeReviewDisplayEvent(
kilocode('message.part.updated', {
part: {
id: 'prt_bash',
type: 'tool',
tool: 'bash',
state: { status: 'completed', input: { command: 'sleep 10' } },
},
})
);
expect(running).not.toBeNull();
expect(completed).not.toBeNull();
if (!running || !completed) return;
expect(appendCodeReviewDisplayEvent([running], completed)).toEqual([completed]);
});
});
Loading