Skip to content

Commit 0aa39b1

Browse files
committed
Render stream artifacts as structured chat items
1 parent 4852803 commit 0aa39b1

File tree

5 files changed

+278
-1
lines changed

5 files changed

+278
-1
lines changed

src/renderer/components/chat/ChatHistory.tsx

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { Layers } from 'lucide-react';
1010

1111
import { AIChatGroup } from './AIChatGroup';
1212
import { CHAT_LAYOUT_INVALIDATED_EVENT, notifyChatLayoutInvalidated } from './chatLayoutEvents';
13+
import { StreamArtifactItem, parseStreamArtifactContent } from './items';
1314
import { UserChatGroup } from './UserChatGroup';
1415

1516
interface ChatHistoryProps {
@@ -264,8 +265,11 @@ export const ChatHistory = ({ sessionId }: ChatHistoryProps): JSX.Element => {
264265

265266
const systemPreludeKind =
266267
chunk.type === 'system' ? classifyCodexBootstrapMessage(chunk.content) : null;
268+
const streamArtifactEvents =
269+
chunk.type === 'system' ? parseStreamArtifactContent(chunk.content) : null;
267270
const systemPreludeKey =
268271
chunk.type === 'system' &&
272+
!streamArtifactEvents &&
269273
systemPreludeKind &&
270274
systemPreludeKind !== 'collaboration_mode'
271275
? buildSystemPreludeKey(chunk.timestamp, virtualRow.index)
@@ -292,7 +296,9 @@ export const ChatHistory = ({ sessionId }: ChatHistoryProps): JSX.Element => {
292296
{chunk.type === 'user' ? <UserChatGroup chunk={chunk} /> : null}
293297
{chunk.type === 'ai' ? <AIChatGroup chunk={chunk} /> : null}
294298
{chunk.type === 'system' ? (
295-
systemPreludeKind ? (
299+
streamArtifactEvents ? (
300+
<StreamArtifactItem events={streamArtifactEvents} />
301+
) : systemPreludeKind ? (
296302
systemPreludeKind === 'collaboration_mode' ? (
297303
<div className="chat-model-change">
298304
<span className="chat-model-change-label">
Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,142 @@
1+
import { useMemo, useState } from 'react';
2+
3+
import { notifyChatLayoutInvalidated } from '../chatLayoutEvents';
4+
5+
export type StreamArtifactLine = {
6+
timestamp: string;
7+
engine: string;
8+
agent: string;
9+
eventType: string;
10+
providerThreadId: string | null;
11+
stageId: string | null;
12+
payload: unknown;
13+
};
14+
15+
type StreamArtifactItemProps = {
16+
content?: string;
17+
events?: StreamArtifactLine[] | null;
18+
};
19+
20+
function asRecord(value: unknown): Record<string, unknown> | null {
21+
if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
22+
return value as Record<string, unknown>;
23+
}
24+
25+
function asString(value: unknown): string | null {
26+
if (typeof value !== 'string') return null;
27+
const trimmed = value.trim();
28+
return trimmed || null;
29+
}
30+
31+
function parseLine(line: string): StreamArtifactLine | null {
32+
const parsed = JSON.parse(line);
33+
const record = asRecord(parsed);
34+
if (!record) return null;
35+
36+
const timestamp = asString(record.timestamp);
37+
const engine = asString(record.engine);
38+
const agent = asString(record.agent);
39+
const eventType = asString(record.event_type);
40+
if (!timestamp || !engine || !agent || !eventType) return null;
41+
42+
return {
43+
timestamp,
44+
engine,
45+
agent,
46+
eventType,
47+
providerThreadId: asString(record.provider_thread_id),
48+
stageId: asString(record.stage_id),
49+
payload: record.payload ?? null,
50+
};
51+
}
52+
53+
export function parseStreamArtifactContent(content: string): StreamArtifactLine[] | null {
54+
const lines = String(content || '')
55+
.split(/\r?\n/)
56+
.map((line) => line.trim())
57+
.filter(Boolean);
58+
if (!lines.length) return null;
59+
60+
const parsed: StreamArtifactLine[] = [];
61+
for (const line of lines) {
62+
try {
63+
const event = parseLine(line);
64+
if (!event) return null;
65+
parsed.push(event);
66+
} catch {
67+
return null;
68+
}
69+
}
70+
return parsed.length ? parsed : null;
71+
}
72+
73+
function previewPayload(value: unknown): string {
74+
if (value == null) return 'null';
75+
if (typeof value === 'string') return value;
76+
try {
77+
return JSON.stringify(value, null, 2);
78+
} catch {
79+
return String(value);
80+
}
81+
}
82+
83+
export const StreamArtifactItem = ({
84+
content = '',
85+
events: preParsedEvents = undefined,
86+
}: StreamArtifactItemProps): JSX.Element | null => {
87+
const events = useMemo(
88+
() => (preParsedEvents !== undefined ? preParsedEvents : parseStreamArtifactContent(content)),
89+
[content, preParsedEvents],
90+
);
91+
const [expanded, setExpanded] = useState(false);
92+
93+
if (!events) return null;
94+
95+
return (
96+
<section className={`stream-artifact ${expanded ? 'open' : ''}`}>
97+
<button
98+
type="button"
99+
className="stream-artifact-summary"
100+
onClick={() => {
101+
setExpanded((value) => !value);
102+
notifyChatLayoutInvalidated();
103+
}}
104+
aria-expanded={expanded}
105+
>
106+
<span>
107+
Stream events: <strong>{events.length}</strong>
108+
</span>
109+
<span className="stream-artifact-summary-hint">
110+
{events[0].engine}:{events[0].agent}
111+
</span>
112+
</button>
113+
114+
<div className="stream-artifact-list">
115+
{events.map((event, index) => (
116+
<article key={`${event.timestamp}-${event.eventType}-${index}`} className="stream-artifact-event">
117+
<header className="stream-artifact-event-header">
118+
<span className="stream-artifact-badge">{event.eventType}</span>
119+
<span className="stream-artifact-time">{event.timestamp}</span>
120+
</header>
121+
<p className="stream-artifact-meta">
122+
<code>{event.engine}</code> / <code>{event.agent}</code>
123+
{event.providerThreadId ? (
124+
<>
125+
{' '}thread <code>{event.providerThreadId}</code>
126+
</>
127+
) : null}
128+
{event.stageId ? (
129+
<>
130+
{' '}stage <code>{event.stageId}</code>
131+
</>
132+
) : null}
133+
</p>
134+
{expanded ? (
135+
<pre className="stream-artifact-payload">{previewPayload(event.payload)}</pre>
136+
) : null}
137+
</article>
138+
))}
139+
</div>
140+
</section>
141+
);
142+
};

src/renderer/components/chat/items/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,4 @@ export * from './ExecutionTrace';
44
export * from './MetricsPill';
55
export * from './TextItem';
66
export * from './ThinkingItem';
7+
export * from './StreamArtifactItem';

src/renderer/index.css

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1471,6 +1471,97 @@ select {
14711471
overflow-wrap: anywhere;
14721472
}
14731473

1474+
.stream-artifact {
1475+
width: min(900px, 100%);
1476+
border: 1px solid rgba(14, 165, 233, 0.45);
1477+
border-radius: 10px;
1478+
background: linear-gradient(180deg, rgba(2, 132, 199, 0.14), rgba(2, 132, 199, 0.08));
1479+
overflow: hidden;
1480+
}
1481+
1482+
.stream-artifact-summary {
1483+
display: flex;
1484+
align-items: center;
1485+
justify-content: space-between;
1486+
width: 100%;
1487+
border: 0;
1488+
background: transparent;
1489+
color: var(--text-secondary);
1490+
padding: 8px 10px;
1491+
font: inherit;
1492+
font-size: 0.78rem;
1493+
cursor: pointer;
1494+
}
1495+
1496+
.stream-artifact-summary-hint {
1497+
color: var(--text-muted);
1498+
font-family: var(--font-mono);
1499+
font-size: 0.72rem;
1500+
}
1501+
1502+
.stream-artifact-list {
1503+
border-top: 1px solid rgba(255, 255, 255, 0.1);
1504+
}
1505+
1506+
.stream-artifact-event {
1507+
padding: 8px 10px;
1508+
border-top: 1px solid rgba(255, 255, 255, 0.06);
1509+
}
1510+
1511+
.stream-artifact-event:first-child {
1512+
border-top: 0;
1513+
}
1514+
1515+
.stream-artifact-event-header {
1516+
display: flex;
1517+
align-items: center;
1518+
justify-content: space-between;
1519+
gap: 10px;
1520+
margin-bottom: 3px;
1521+
}
1522+
1523+
.stream-artifact-badge {
1524+
border: 1px solid rgba(14, 165, 233, 0.5);
1525+
border-radius: 999px;
1526+
background: rgba(2, 132, 199, 0.2);
1527+
color: #7dd3fc;
1528+
font-family: var(--font-mono);
1529+
font-size: 0.68rem;
1530+
padding: 2px 7px;
1531+
}
1532+
1533+
.stream-artifact-time {
1534+
color: var(--text-muted);
1535+
font-family: var(--font-mono);
1536+
font-size: 0.69rem;
1537+
}
1538+
1539+
.stream-artifact-meta {
1540+
margin: 0;
1541+
color: var(--text-secondary);
1542+
font-size: 0.73rem;
1543+
overflow-wrap: anywhere;
1544+
}
1545+
1546+
.stream-artifact-meta code {
1547+
font-family: var(--font-mono);
1548+
font-size: 0.71rem;
1549+
}
1550+
1551+
.stream-artifact-payload {
1552+
margin: 7px 0 0;
1553+
padding: 8px;
1554+
border: 1px solid rgba(255, 255, 255, 0.08);
1555+
border-radius: 8px;
1556+
background: rgba(15, 23, 42, 0.35);
1557+
color: #cbd5e1;
1558+
font-family: var(--font-mono);
1559+
font-size: 0.7rem;
1560+
line-height: 1.45;
1561+
white-space: pre-wrap;
1562+
overflow-wrap: anywhere;
1563+
}
1564+
14741565
.chat-session-token-sticky {
14751566
display: flex;
14761567
justify-content: flex-start;

test/renderer-chat-history.test.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ vi.mock('@tanstack/react-virtual', () => ({
3030
}));
3131

3232
import { ChatHistory } from '@renderer/components/chat/ChatHistory';
33+
import { parseStreamArtifactContent } from '@renderer/components/chat/items/StreamArtifactItem';
3334

3435
describe('ChatHistory system prelude rendering', () => {
3536
beforeEach(() => {
@@ -55,4 +56,40 @@ describe('ChatHistory system prelude rendering', () => {
5556
expect(html).toContain('aria-expanded="false"');
5657
expect(html).not.toContain('<details');
5758
});
59+
60+
it('renders codex stream artifact lines as structured stream cards', () => {
61+
mockStoreState.chunks = [
62+
{
63+
type: 'system',
64+
content: '{"timestamp":"2026-02-22T22:00:01.700Z","engine":"codex","agent":"reviewer-codex","event_type":"thread.started","provider_thread_id":"abc-123","payload":{"type":"thread.started"},"stage_id":"qa-stage"}',
65+
timestamp: '2026-02-22T22:00:01.700Z',
66+
},
67+
];
68+
69+
const html = renderToStaticMarkup(createElement(ChatHistory, { sessionId: 'session-stream' }));
70+
71+
expect(html).toContain('stream-artifact');
72+
expect(html).toContain('Stream events');
73+
expect(html).toContain('thread.started');
74+
expect(html).toContain('reviewer-codex');
75+
expect(html).not.toContain('chat-system-message');
76+
});
77+
});
78+
79+
describe('stream artifact parsing', () => {
80+
it('parses valid stream artifact jsonl content', () => {
81+
const parsed = parseStreamArtifactContent(
82+
'{"timestamp":"2026-02-22T22:00:01.700Z","engine":"codex","agent":"reviewer-codex","event_type":"turn.started","payload":{"type":"turn.started"}}\n' +
83+
'{"timestamp":"2026-02-22T22:00:02.100Z","engine":"codex","agent":"reviewer-codex","event_type":"turn.completed","payload":{"type":"turn.completed"}}',
84+
);
85+
86+
expect(parsed).not.toBeNull();
87+
expect(parsed?.length).toBe(2);
88+
expect(parsed?.[0]?.eventType).toBe('turn.started');
89+
});
90+
91+
it('returns null when content is not stream artifact jsonl', () => {
92+
expect(parseStreamArtifactContent('not-json')).toBeNull();
93+
expect(parseStreamArtifactContent('{"foo":"bar"}')).toBeNull();
94+
});
5895
});

0 commit comments

Comments
 (0)