Skip to content

Commit 35721ec

Browse files
committed
refactor(agent-streaming): Create React components
- chore: Add React dependencies - chore: Update configs for React - refactor: Create React types - refactor: Create ThoughtBox component - refactor: Create MessageBubble component - refactor: Create ChatInput component - refactor: Create App component
1 parent f8869b2 commit 35721ec

8 files changed

Lines changed: 264 additions & 3 deletions

File tree

agent-streaming/package-lock.json

Lines changed: 86 additions & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

agent-streaming/package.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -20,12 +20,17 @@
2020
"express": "^5.2.1",
2121
"genkit": "^1.36.0",
2222
"helmet": "^8.2.0",
23-
"marked": "^18.0.5"
23+
"marked": "^18.0.5",
24+
"react": "^19.2.7",
25+
"react-dom": "^19.2.7"
2426
},
2527
"devDependencies": {
2628
"@types/dompurify": "^3.0.5",
2729
"@types/express": "^5.0.6",
2830
"@types/node": "^25.9.3",
31+
"@types/react": "^19.2.17",
32+
"@types/react-dom": "^19.2.3",
33+
"@vitejs/plugin-react": "^6.0.2",
2934
"tsx": "^4.22.4",
3035
"typescript": "^6.0.3",
3136
"vite": "^8.0.16"
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import React, { useState, useRef, useEffect } from 'react';
2+
3+
interface ChatInputProps {
4+
onSubmit: (prompt: string) => void;
5+
disabled: boolean;
6+
}
7+
8+
export const ChatInput: React.FC<ChatInputProps> = ({ onSubmit, disabled }) => {
9+
const [input, setInput] = useState('');
10+
const inputRef = useRef<HTMLInputElement>(null);
11+
12+
const handleSubmit = (e: React.FormEvent) => {
13+
e.preventDefault();
14+
const trimmed = input.trim();
15+
if (trimmed && !disabled) {
16+
onSubmit(trimmed);
17+
setInput('');
18+
}
19+
};
20+
21+
// Keep focus on input when transition completes or loading ends
22+
useEffect(() => {
23+
if (!disabled) {
24+
inputRef.current?.focus();
25+
}
26+
}, [disabled]);
27+
28+
return (
29+
<form className="chat-input-form" onSubmit={handleSubmit}>
30+
<input
31+
ref={inputRef}
32+
type="text"
33+
placeholder="Type a message..."
34+
autoComplete="off"
35+
required
36+
value={input}
37+
onChange={(e) => setInput(e.target.value)}
38+
disabled={disabled}
39+
/>
40+
<button
41+
type="submit"
42+
aria-label="Send message"
43+
disabled={disabled || !input.trim()}
44+
>
45+
<svg viewBox="0 0 24 24" width="24" height="24">
46+
<path fill="currentColor" d="M2,21L23,12L2,3V10L17,12L2,14V21Z" />
47+
</svg>
48+
</button>
49+
</form>
50+
);
51+
};
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
import React, { useMemo } from 'react';
2+
import { marked } from 'marked';
3+
import DOMPurify from 'dompurify';
4+
5+
import { TextMessage, ErrorMessage } from '../types.js';
6+
7+
type MessageBubbleProps = TextMessage | ErrorMessage;
8+
9+
export const MessageBubble: React.FC<MessageBubbleProps> = ({ role, type, content }) => {
10+
const sanitizedHtml = useMemo(() => {
11+
if (role === 'model' && type === 'text') {
12+
try {
13+
const rawHtml = marked.parse(content, { breaks: true }) as string;
14+
return DOMPurify.sanitize(rawHtml);
15+
} catch (err) {
16+
console.error('Markdown parsing/sanitization error:', err);
17+
}
18+
}
19+
20+
// Content shouldn't be, or can't be, converted to HTML
21+
return null;
22+
}, [content, role, type]);
23+
24+
// Determine container classes
25+
let containerClass = 'message';
26+
if (role === 'user') {
27+
containerClass += ' user-message';
28+
} else if (role === 'model') {
29+
containerClass += ' model-message';
30+
} else if (role === 'system') {
31+
containerClass += ' system-message';
32+
if (type === 'error') {
33+
containerClass += ' error';
34+
}
35+
}
36+
37+
return (
38+
<div className={containerClass}>
39+
{role === 'model' && type === 'text' && sanitizedHtml ? (
40+
<div
41+
className="message-content"
42+
dangerouslySetInnerHTML={{ __html: sanitizedHtml }}
43+
/>
44+
) : (
45+
<div className="message-content">{content}</div>
46+
)}
47+
</div>
48+
);
49+
};
Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,36 @@
1+
import React, { useState } from 'react';
2+
3+
interface ThoughtBoxProps {
4+
content: string;
5+
stepName?: string;
6+
}
7+
8+
export const ThoughtBox: React.FC<ThoughtBoxProps> = ({ content, stepName }) => {
9+
const [isOpen, setIsOpen] = useState(false);
10+
const activeStep = stepName || 'Thinking';
11+
12+
return (
13+
<div className="message thought-message">
14+
<div className="thought-box">
15+
<div className="thought-header">
16+
{/* Use key={activeStep} on indicator and label to trigger a re-animation on step change */}
17+
<div key={`indicator-${activeStep}`} className="thought-indicator indicator-animate" />
18+
<div key={`label-${activeStep}`} className="thought-step-label step-animate">
19+
Thinking: {activeStep}
20+
</div>
21+
</div>
22+
<details
23+
className="thought-details"
24+
onToggle={(e) => setIsOpen(e.currentTarget.open)}
25+
>
26+
<summary className="thought-summary">
27+
<span className="summary-text">
28+
{isOpen ? 'Hide Full Reasoning' : 'Show Full Reasoning'}
29+
</span>
30+
</summary>
31+
<div className="thought-body">{content}</div>
32+
</details>
33+
</div>
34+
</div>
35+
);
36+
};
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
export interface StreamChunk {
2+
messageId: string;
3+
type: 'thought' | 'text' | 'error';
4+
content: string;
5+
currentStep?: string;
6+
}
7+
8+
export interface BaseMessage {
9+
id: string;
10+
}
11+
12+
export interface TextMessage extends BaseMessage {
13+
type: 'text';
14+
role: 'user' | 'model' | 'system';
15+
content: string;
16+
}
17+
18+
export interface ThoughtMessage extends BaseMessage {
19+
type: 'thought';
20+
role: 'model';
21+
content: string;
22+
stepName?: string; // For thought cards
23+
}
24+
25+
export interface ErrorMessage extends BaseMessage {
26+
type: 'error';
27+
role: 'system';
28+
content: string;
29+
}
30+
31+
export type Message = TextMessage | ThoughtMessage | ErrorMessage;
32+

agent-streaming/tsconfig.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,8 @@
1616
"forceConsistentCasingInFileNames": true,
1717
"resolveJsonModule": true,
1818
"isolatedModules": true,
19-
"noEmit": true
19+
"noEmit": true,
20+
"jsx": "react-jsx"
2021
},
2122
"include": [
2223
"src"

agent-streaming/vite.config.mjs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import { defineConfig } from 'vite';
2+
import react from '@vitejs/plugin-react';
23

34
export default defineConfig({
5+
plugins: [react()],
46
server: {
57
// Port for the Vite development frontend server
68
port: 5173,

0 commit comments

Comments
 (0)