Files
research-workbench/docs/plans/2026-05-26-research-workbench-phase2.md
T
Ken 4656af010e docs: implementation plans for research workbench (3 phases, 30 tasks)
Phase 1: Infrastructure (8 tasks) - Dockerfile, amplifierd, FastAPI, auth, AG-UI adapter
Phase 2: Frontend (10 tasks) - React/TS/Vite, Tabler, CopilotKit headless, SSE streaming
Phase 3: Bundle + Browser + Artifacts (12 tasks) - Amplifier bundle, researcher agent,
  AI-generated visual artifacts (Claude builds visuals style), noVNC browser panel
2026-05-26 18:01:47 +00:00

1820 lines
45 KiB
Markdown

# Research Workbench — Phase 2: Frontend (Chat + Sessions)
> **Execution:** Use the subagent-driven-development workflow to implement this plan.
**Goal:** Build the React frontend with CopilotKit headless chat, Tabler UI components, session management sidebar, tool call rendering, and MCP App inline visualization support.
**Architecture:** A Vite + React + TypeScript SPA using CopilotKit's `useCopilotChat` hook for headless chat control (we build ALL UI with Tabler), connecting to the backend's AG-UI endpoint at `/api/copilotkit`. The layout is a 3-column design: session sidebar (240px), chat panel (flex), and right panel (45%) for browser/artifacts. Tool results with `_meta.ui.resourceUri` (MCP Apps, SEP-1865) are rendered as sandboxed iframes with JSON-RPC 2.0 postMessage bridges for interactive inline visualizations.
**Tech Stack:** React 19, TypeScript, Vite (with `@rolldown/vite`), Tabler (`@tabler/core`, `@tabler/icons-react`), CopilotKit (`@copilotkit/react-core`, `@copilotkit/react-ui`), react-markdown
**Depends on:** Phase 1 complete (backend running at `:8080`)
---
### Task 1: Scaffold Vite + React + TypeScript project
**Files:**
- Create: `frontend/package.json`
- Create: `frontend/tsconfig.json`
- Create: `frontend/vite.config.ts`
- Create: `frontend/index.html`
**Step 1: Initialize the frontend project**
Run:
```bash
cd /home/ken/workspace/research-workbench/frontend
npm create vite@latest . -- --template react-ts
```
When prompted, select: React, TypeScript.
If the directory already has files, answer "yes" to proceed.
**Step 2: Install dependencies**
Run:
```bash
cd /home/ken/workspace/research-workbench/frontend
npm install @copilotkit/react-core @copilotkit/react-ui @tabler/core @tabler/icons-react react-markdown
npm install -D @types/react @types/react-dom
```
**Step 3: Update vite.config.ts for API proxy**
Replace `frontend/vite.config.ts` with:
```typescript
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
server: {
port: 3000,
proxy: {
"/api": {
target: "http://localhost:8080",
changeOrigin: true,
},
},
},
build: {
outDir: "dist",
sourcemap: true,
},
});
```
**Step 4: Verify the dev server starts**
Run:
```bash
cd /home/ken/workspace/research-workbench/frontend
npm run dev -- --host 2>&1 &
sleep 3
curl -s http://localhost:3000 | head -5
kill %1
```
Expected: HTML content from the Vite dev server.
**Step 5: Commit**
Run:
```bash
cd /home/ken/workspace/research-workbench
git add frontend/package.json frontend/package-lock.json frontend/tsconfig.json frontend/tsconfig.app.json frontend/tsconfig.node.json frontend/vite.config.ts frontend/index.html frontend/src frontend/public frontend/eslint.config.js -f
git commit -m "feat: scaffold Vite + React + TypeScript frontend"
```
---
### Task 2: TypeScript types
**Files:**
- Create: `frontend/src/lib/types.ts`
**Step 1: Create the types file**
Create `frontend/src/lib/types.ts`:
```typescript
/**
* Shared TypeScript types for the Research Workbench frontend.
*/
// --- Sessions ---
export interface Session {
session_id: string;
status: string;
bundle: string | null;
created_at: string | null;
last_activity: string | null;
total_messages: number | null;
tool_invocations: number | null;
stale: boolean | null;
}
export interface SessionListResponse {
sessions: Session[];
total: number;
}
// --- Artifacts ---
export interface Artifact {
name: string;
session_id: string;
size: number;
}
export interface ArtifactContent {
name: string;
session_id: string;
content: string;
}
// --- A2UI Blocks ---
export type A2UIBlockType = "filters" | "form" | "table" | "cards";
export interface A2UIFilterOption {
label: string;
active: boolean;
}
export interface A2UIFiltersBlock {
type: "filters";
options: A2UIFilterOption[];
}
export interface A2UIFormField {
name: string;
type: "text" | "number" | "select";
label?: string;
value?: string | number;
options?: string[];
}
export interface A2UIFormBlock {
type: "form";
fields: A2UIFormField[];
submitLabel?: string;
}
export interface A2UITableBlock {
type: "table";
headers: string[];
rows: string[][];
}
export interface A2UICardItem {
title: string;
description?: string;
image?: string;
price?: string;
url?: string;
}
export interface A2UICardsBlock {
type: "cards";
items: A2UICardItem[];
}
export type A2UIBlock =
| A2UIFiltersBlock
| A2UIFormBlock
| A2UITableBlock
| A2UICardsBlock;
// --- Tool Calls ---
export interface ToolCall {
id: string;
name: string;
args: string;
result?: string;
status: "pending" | "running" | "complete" | "error";
/** MCP App metadata -- when present, render MCPAppRenderer instead of ToolCallCard */
_meta?: MCPAppMeta;
/** Structured data for the MCP App iframe */
structuredContent?: MCPAppStructuredContent;
}
// --- MCP Apps (inline interactive visualizations) ---
/**
* MCP App metadata attached to tool results.
* When present, the frontend renders an MCPAppRenderer (sandboxed iframe)
* instead of a plain ToolCallCard.
*/
export interface MCPAppMeta {
ui: {
resourceUri: string; // e.g. "app://chart"
csp?: string; // Content Security Policy override
};
}
export interface MCPAppStructuredContent {
[key: string]: unknown; // App-specific data (chart data, table rows, etc.)
}
// --- Auth ---
export interface AuthUser {
email: string;
}
export interface LoginResponse {
token: string;
email: string;
}
```
**Step 2: Verify it compiles**
Run:
```bash
cd /home/ken/workspace/research-workbench/frontend
npx tsc --noEmit --pretty 2>&1 | tail -5
```
Expected: No errors (or only warnings from the template files which we'll replace).
**Step 3: Commit**
Run:
```bash
cd /home/ken/workspace/research-workbench
git add frontend/src/lib/types.ts && git commit -m "feat: TypeScript types for sessions, artifacts, A2UI, tool calls, auth"
```
---
### Task 3: Global styles (Tabler dark theme)
**Files:**
- Create: `frontend/src/styles/globals.css`
**Step 1: Create the globals.css**
Create `frontend/src/styles/globals.css`:
```css
/* Import Tabler core CSS */
@import "@tabler/core/dist/css/tabler.min.css";
@import "@tabler/core/dist/css/tabler-vendors.min.css";
/* Force dark theme */
:root {
color-scheme: dark;
}
body {
background-color: #1a1a2e;
color: #e0e0e0;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
margin: 0;
overflow: hidden;
height: 100vh;
}
#root {
height: 100vh;
display: flex;
}
/* --- Layout: 3-column --- */
.app-layout {
display: flex;
width: 100%;
height: 100vh;
}
.sidebar {
width: 240px;
min-width: 240px;
background-color: #16213e;
border-right: 1px solid #2a2a4a;
display: flex;
flex-direction: column;
overflow-y: auto;
}
.chat-panel {
flex: 1;
display: flex;
flex-direction: column;
min-width: 0;
}
.right-panel {
width: 45%;
min-width: 300px;
background-color: #16213e;
border-left: 1px solid #2a2a4a;
display: flex;
flex-direction: column;
}
/* --- Sidebar --- */
.sidebar-header {
padding: 1rem;
border-bottom: 1px solid #2a2a4a;
display: flex;
justify-content: space-between;
align-items: center;
}
.sidebar-header h3 {
margin: 0;
font-size: 0.9rem;
color: #8888aa;
text-transform: uppercase;
letter-spacing: 0.05em;
}
.session-list {
flex: 1;
overflow-y: auto;
padding: 0.5rem;
}
.session-item {
padding: 0.6rem 0.8rem;
border-radius: 6px;
cursor: pointer;
margin-bottom: 2px;
font-size: 0.85rem;
color: #c0c0d0;
transition: background-color 0.15s;
}
.session-item:hover {
background-color: #1e2a4a;
}
.session-item.active {
background-color: #2a3a5a;
color: #ffffff;
}
.session-item .session-meta {
font-size: 0.7rem;
color: #6a6a8a;
margin-top: 2px;
}
/* --- Chat --- */
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 1rem;
}
.chat-message {
margin-bottom: 1rem;
max-width: 85%;
}
.chat-message.user {
margin-left: auto;
background-color: #2a3a6a;
border-radius: 12px 12px 4px 12px;
padding: 0.8rem 1rem;
}
.chat-message.assistant {
background-color: #1e2a4a;
border-radius: 12px 12px 12px 4px;
padding: 0.8rem 1rem;
}
.chat-message.assistant p {
margin: 0.3rem 0;
line-height: 1.5;
}
.chat-input-area {
padding: 0.75rem 1rem;
border-top: 1px solid #2a2a4a;
display: flex;
gap: 0.5rem;
align-items: center;
}
.chat-input-area input {
flex: 1;
background-color: #1e2a4a;
border: 1px solid #3a3a5a;
border-radius: 8px;
padding: 0.6rem 1rem;
color: #e0e0e0;
font-size: 0.9rem;
outline: none;
}
.chat-input-area input:focus {
border-color: #5a7aba;
}
.chat-input-area button {
background-color: #3a5aba;
color: white;
border: none;
border-radius: 8px;
padding: 0.6rem 1.2rem;
cursor: pointer;
font-size: 0.9rem;
}
.chat-input-area button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* --- Tool call cards --- */
.tool-call-card {
background-color: #12192e;
border: 1px solid #2a2a4a;
border-radius: 8px;
padding: 0.6rem 0.8rem;
margin: 0.5rem 0;
font-size: 0.8rem;
}
.tool-call-card .tool-name {
color: #7aa2f7;
font-weight: 600;
font-family: monospace;
}
.tool-call-card .tool-args {
color: #8888aa;
font-family: monospace;
font-size: 0.75rem;
margin-top: 4px;
white-space: pre-wrap;
word-break: break-all;
}
.tool-call-card .tool-status {
display: inline-block;
padding: 1px 6px;
border-radius: 4px;
font-size: 0.7rem;
margin-left: 0.5rem;
}
.tool-call-card .tool-status.running {
background-color: #2a4a3a;
color: #7af7a2;
}
.tool-call-card .tool-status.complete {
background-color: #2a3a4a;
color: #7aa2f7;
}
/* --- Right panel tabs --- */
.right-panel-tabs {
display: flex;
border-bottom: 1px solid #2a2a4a;
}
.right-panel-tabs button {
flex: 1;
padding: 0.6rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
color: #8888aa;
cursor: pointer;
font-size: 0.85rem;
}
.right-panel-tabs button.active {
color: #ffffff;
border-bottom-color: #5a7aba;
}
.right-panel-content {
flex: 1;
overflow-y: auto;
}
/* --- Login page --- */
.login-page {
display: flex;
align-items: center;
justify-content: center;
height: 100vh;
width: 100%;
}
.login-card {
background-color: #16213e;
border: 1px solid #2a2a4a;
border-radius: 12px;
padding: 2rem;
width: 360px;
}
.login-card h2 {
margin-top: 0;
text-align: center;
}
.login-card input {
width: 100%;
padding: 0.6rem;
margin-bottom: 0.8rem;
background-color: #1e2a4a;
border: 1px solid #3a3a5a;
border-radius: 6px;
color: #e0e0e0;
font-size: 0.9rem;
box-sizing: border-box;
}
.login-card button {
width: 100%;
padding: 0.7rem;
background-color: #3a5aba;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 1rem;
}
.login-card .error {
color: #f7607a;
font-size: 0.85rem;
text-align: center;
margin-bottom: 0.5rem;
}
```
**Step 2: Commit**
Run:
```bash
cd /home/ken/workspace/research-workbench
git add frontend/src/styles/globals.css && git commit -m "feat: global styles with Tabler dark theme, 3-column layout"
```
---
### Task 4: API client and hooks
**Files:**
- Create: `frontend/src/lib/api.ts`
- Create: `frontend/src/hooks/useAuth.ts`
- Create: `frontend/src/hooks/useSessions.ts`
- Create: `frontend/src/hooks/useChat.ts`
- Create: `frontend/src/hooks/useArtifacts.ts`
**Step 1: Create the API client**
Create `frontend/src/lib/api.ts`:
```typescript
/**
* HTTP client for the Research Workbench backend API.
* All endpoints are relative (proxied by Vite in dev, served by FastAPI in prod).
*/
import type {
SessionListResponse,
Artifact,
ArtifactContent,
LoginResponse,
AuthUser,
} from "./types";
async function request<T>(url: string, options?: RequestInit): Promise<T> {
const resp = await fetch(url, {
credentials: "include",
...options,
headers: {
"Content-Type": "application/json",
...options?.headers,
},
});
if (!resp.ok) {
const body = await resp.text();
throw new Error(`${resp.status}: ${body}`);
}
return resp.json();
}
// Auth
export const login = (email: string, password: string) =>
request<LoginResponse>("/api/auth/login", {
method: "POST",
body: JSON.stringify({ email, password }),
});
export const getMe = () => request<AuthUser>("/api/auth/me");
export const logout = () =>
request<{ status: string }>("/api/auth/logout", { method: "POST" });
// Sessions
export const listSessions = () =>
request<SessionListResponse>("/api/sessions");
export const createSession = (name?: string) =>
request<{ session_id: string }>("/api/sessions", {
method: "POST",
body: JSON.stringify({ name }),
});
export const deleteSession = (sessionId: string) =>
request<{ status: string }>(`/api/sessions/${sessionId}`, {
method: "DELETE",
});
export const getTranscript = (sessionId: string) =>
request<{ messages: Array<{ role: string; content: string }> }>(
`/api/sessions/${sessionId}/transcript`
);
// Artifacts
export const listArtifacts = (sessionId: string) =>
request<Artifact[]>(`/api/artifacts/${sessionId}`);
export const getArtifact = (sessionId: string, name: string) =>
request<ArtifactContent>(`/api/artifacts/${sessionId}/${name}`);
```
**Step 2: Create the auth hook**
Create `frontend/src/hooks/useAuth.ts`:
```typescript
import { useState, useEffect, useCallback } from "react";
import * as api from "../lib/api";
import type { AuthUser } from "../lib/types";
export function useAuth() {
const [user, setUser] = useState<AuthUser | null>(null);
const [loading, setLoading] = useState(true);
// Check if already authenticated on mount
useEffect(() => {
api
.getMe()
.then(setUser)
.catch(() => setUser(null))
.finally(() => setLoading(false));
}, []);
const doLogin = useCallback(async (email: string, password: string) => {
const resp = await api.login(email, password);
setUser({ email: resp.email });
}, []);
const doLogout = useCallback(async () => {
await api.logout();
setUser(null);
}, []);
return { user, loading, login: doLogin, logout: doLogout };
}
```
**Step 3: Create the sessions hook**
Create `frontend/src/hooks/useSessions.ts`:
```typescript
import { useState, useEffect, useCallback } from "react";
import * as api from "../lib/api";
import type { Session } from "../lib/types";
export function useSessions() {
const [sessions, setSessions] = useState<Session[]>([]);
const [activeSessionId, setActiveSessionId] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const refresh = useCallback(async () => {
setLoading(true);
try {
const data = await api.listSessions();
setSessions(data.sessions);
} catch (e) {
console.error("Failed to list sessions:", e);
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
refresh();
}, [refresh]);
const createNew = useCallback(async () => {
try {
const data = await api.createSession();
await refresh();
setActiveSessionId(data.session_id);
return data.session_id;
} catch (e) {
console.error("Failed to create session:", e);
return null;
}
}, [refresh]);
const switchTo = useCallback((sessionId: string) => {
setActiveSessionId(sessionId);
}, []);
const activeSession = sessions.find((s) => s.session_id === activeSessionId) ?? null;
return { sessions, activeSession, activeSessionId, loading, createNew, switchTo, refresh };
}
```
**Step 4: Create the chat hook**
This is the core hook. It connects to the backend's AG-UI SSE endpoint and manages the message stream.
Create `frontend/src/hooks/useChat.ts`:
```typescript
import { useState, useCallback, useRef } from "react";
import type { ToolCall } from "../lib/types";
export interface ChatMessage {
id: string;
role: "user" | "assistant" | "tool";
content: string;
toolCalls?: ToolCall[];
}
export function useChat(sessionId: string | null) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [isStreaming, setIsStreaming] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const sendMessage = useCallback(
async (content: string) => {
if (!sessionId || !content.trim()) return;
// Add user message
const userMsg: ChatMessage = {
id: `user-${Date.now()}`,
role: "user",
content: content.trim(),
};
setMessages((prev) => [...prev, userMsg]);
setIsStreaming(true);
// Prepare AG-UI request
const allMessages = [...messages, userMsg].map((m) => ({
id: m.id,
role: m.role,
content: m.content,
}));
try {
abortRef.current = new AbortController();
const response = await fetch("/api/copilotkit", {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: "include",
body: JSON.stringify({
threadId: sessionId,
runId: `run-${Date.now()}`,
messages: allMessages,
tools: [],
context: [],
}),
signal: abortRef.current.signal,
});
if (!response.ok || !response.body) {
throw new Error(`HTTP ${response.status}`);
}
// Process SSE stream
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = "";
let assistantContent = "";
let assistantId = "";
const toolCalls: Record<string, ToolCall> = {};
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n\n");
buffer = lines.pop() ?? "";
for (const chunk of lines) {
if (!chunk.startsWith("data: ")) continue;
try {
const event = JSON.parse(chunk.slice(6));
switch (event.type) {
case "TEXT_MESSAGE_START":
assistantId = event.messageId;
assistantContent = "";
break;
case "TEXT_MESSAGE_CONTENT":
assistantContent += event.delta;
// Update message in place for streaming effect
setMessages((prev) => {
const existing = prev.find((m) => m.id === assistantId);
if (existing) {
return prev.map((m) =>
m.id === assistantId ? { ...m, content: assistantContent } : m
);
}
return [
...prev,
{
id: assistantId,
role: "assistant" as const,
content: assistantContent,
},
];
});
break;
case "TEXT_MESSAGE_END":
// Content is already fully updated via deltas
break;
case "TOOL_CALL_START":
toolCalls[event.toolCallId] = {
id: event.toolCallId,
name: event.toolCallName,
args: "",
status: "running",
};
break;
case "TOOL_CALL_ARGS":
if (toolCalls[event.toolCallId]) {
toolCalls[event.toolCallId].args += event.delta;
}
break;
case "TOOL_CALL_END":
if (toolCalls[event.toolCallId]) {
toolCalls[event.toolCallId].status = "complete";
}
// Update the assistant message with tool calls
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId
? { ...m, toolCalls: Object.values(toolCalls) }
: m
)
);
break;
case "TOOL_CALL_RESULT":
if (toolCalls[event.toolCallId]) {
toolCalls[event.toolCallId].result = event.content;
// Capture MCP App metadata for inline iframe rendering
if (event._meta?.ui?.resourceUri) {
toolCalls[event.toolCallId]._meta = event._meta;
}
if (event.structuredContent) {
toolCalls[event.toolCallId].structuredContent = event.structuredContent;
}
}
// Update the assistant message with tool call results (including MCP App data)
setMessages((prev) =>
prev.map((m) =>
m.id === assistantId
? { ...m, toolCalls: Object.values(toolCalls) }
: m
)
);
break;
case "RUN_FINISHED":
break;
}
} catch {
// Skip unparseable chunks
}
}
}
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
// User cancelled
} else {
console.error("Chat stream error:", err);
}
} finally {
setIsStreaming(false);
}
},
[sessionId, messages]
);
const cancel = useCallback(() => {
abortRef.current?.abort();
}, []);
const clearMessages = useCallback(() => {
setMessages([]);
}, []);
return { messages, isStreaming, sendMessage, cancel, clearMessages };
}
```
**Step 5: Create the artifacts hook**
Create `frontend/src/hooks/useArtifacts.ts`:
```typescript
import { useState, useCallback } from "react";
import * as api from "../lib/api";
import type { Artifact, ArtifactContent } from "../lib/types";
export function useArtifacts(sessionId: string | null) {
const [artifacts, setArtifacts] = useState<Artifact[]>([]);
const [activeArtifact, setActiveArtifact] = useState<ArtifactContent | null>(null);
const refresh = useCallback(async () => {
if (!sessionId) return;
try {
const data = await api.listArtifacts(sessionId);
setArtifacts(data);
} catch (e) {
console.error("Failed to list artifacts:", e);
}
}, [sessionId]);
const loadArtifact = useCallback(
async (name: string) => {
if (!sessionId) return;
try {
const data = await api.getArtifact(sessionId, name);
setActiveArtifact(data);
} catch (e) {
console.error("Failed to load artifact:", e);
}
},
[sessionId]
);
return { artifacts, activeArtifact, refresh, loadArtifact };
}
```
**Step 6: Commit**
Run:
```bash
cd /home/ken/workspace/research-workbench
git add frontend/src/lib/api.ts frontend/src/hooks/ && git commit -m "feat: API client and hooks for auth, sessions, chat SSE, artifacts"
```
---
### Task 5: Login page component
**Files:**
- Create: `frontend/src/components/LoginPage.tsx`
**Step 1: Create the login page**
Create `frontend/src/components/LoginPage.tsx`:
```tsx
import { useState, type FormEvent } from "react";
interface LoginPageProps {
onLogin: (email: string, password: string) => Promise<void>;
}
export function LoginPage({ onLogin }: LoginPageProps) {
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError("");
setLoading(true);
try {
await onLogin(email, password);
} catch {
setError("Invalid credentials");
} finally {
setLoading(false);
}
};
return (
<div className="login-page">
<form className="login-card" onSubmit={handleSubmit}>
<h2>Research Workbench</h2>
{error && <div className="error">{error}</div>}
<input
type="email"
placeholder="Email"
value={email}
onChange={(e) => setEmail(e.target.value)}
autoFocus
required
/>
<input
type="password"
placeholder="Password"
value={password}
onChange={(e) => setPassword(e.target.value)}
required
/>
<button type="submit" disabled={loading}>
{loading ? "Signing in..." : "Sign In"}
</button>
</form>
</div>
);
}
```
**Step 2: Commit**
Run:
```bash
cd /home/ken/workspace/research-workbench
git add frontend/src/components/LoginPage.tsx && git commit -m "feat: login page component"
```
---
### Task 6: Session sidebar component
**Files:**
- Create: `frontend/src/components/SessionSidebar.tsx`
**Step 1: Create the sidebar**
Create `frontend/src/components/SessionSidebar.tsx`:
```tsx
import { IconPlus, IconLogout } from "@tabler/icons-react";
import type { Session } from "../lib/types";
interface SessionSidebarProps {
sessions: Session[];
activeSessionId: string | null;
onSelect: (sessionId: string) => void;
onCreate: () => void;
onLogout: () => void;
}
export function SessionSidebar({
sessions,
activeSessionId,
onSelect,
onCreate,
onLogout,
}: SessionSidebarProps) {
return (
<div className="sidebar">
<div className="sidebar-header">
<h3>Sessions</h3>
<div style={{ display: "flex", gap: "0.3rem" }}>
<button
onClick={onCreate}
title="New session"
style={{
background: "none",
border: "none",
color: "#7aa2f7",
cursor: "pointer",
padding: "4px",
}}
>
<IconPlus size={18} />
</button>
<button
onClick={onLogout}
title="Log out"
style={{
background: "none",
border: "none",
color: "#8888aa",
cursor: "pointer",
padding: "4px",
}}
>
<IconLogout size={18} />
</button>
</div>
</div>
<div className="session-list">
{sessions.length === 0 && (
<div style={{ padding: "1rem", color: "#6a6a8a", fontSize: "0.85rem" }}>
No sessions yet. Click + to start researching.
</div>
)}
{sessions.map((s) => (
<div
key={s.session_id}
className={`session-item ${s.session_id === activeSessionId ? "active" : ""}`}
onClick={() => onSelect(s.session_id)}
>
<div>{s.session_id.slice(0, 12)}...</div>
<div className="session-meta">
{s.total_messages ?? 0} msgs &middot; {s.status}
</div>
</div>
))}
</div>
</div>
);
}
```
**Step 2: Commit**
Run:
```bash
cd /home/ken/workspace/research-workbench
git add frontend/src/components/SessionSidebar.tsx && git commit -m "feat: session sidebar component with create, select, logout"
```
---
### Task 7: Chat panel and tool call card components
**Files:**
- Create: `frontend/src/components/ChatPanel.tsx`
- Create: `frontend/src/components/ToolCallCard.tsx`
**Step 1: Create the ToolCallCard**
Create `frontend/src/components/ToolCallCard.tsx`:
```tsx
import { IconTerminal, IconCheck, IconLoader } from "@tabler/icons-react";
import type { ToolCall } from "../lib/types";
interface ToolCallCardProps {
toolCall: ToolCall;
}
export function ToolCallCard({ toolCall }: ToolCallCardProps) {
// Try to extract a readable summary from the tool args
let argsSummary = toolCall.args;
try {
const parsed = JSON.parse(toolCall.args);
if (parsed.command) {
argsSummary = parsed.command;
}
} catch {
// Use raw args
}
return (
<div className="tool-call-card">
<span className="tool-name">
<IconTerminal size={14} style={{ verticalAlign: "middle", marginRight: 4 }} />
{toolCall.name}
</span>
<span className={`tool-status ${toolCall.status}`}>
{toolCall.status === "running" ? (
<IconLoader size={12} />
) : (
<IconCheck size={12} />
)}{" "}
{toolCall.status}
</span>
{argsSummary && <div className="tool-args">{argsSummary}</div>}
</div>
);
}
```
**Step 2: Create the ChatPanel**
Create `frontend/src/components/ChatPanel.tsx`:
```tsx
import { useState, useRef, useEffect, type KeyboardEvent } from "react";
import ReactMarkdown from "react-markdown";
import { IconSend } from "@tabler/icons-react";
import { ToolCallCard } from "./ToolCallCard";
import type { ChatMessage } from "../hooks/useChat";
interface ChatPanelProps {
messages: ChatMessage[];
isStreaming: boolean;
onSend: (content: string) => void;
sessionId: string | null;
}
export function ChatPanel({ messages, isStreaming, onSend, sessionId }: ChatPanelProps) {
const [input, setInput] = useState("");
const messagesEndRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom on new messages
useEffect(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, [messages]);
const handleSend = () => {
if (input.trim() && !isStreaming) {
onSend(input.trim());
setInput("");
}
};
const handleKeyDown = (e: KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSend();
}
};
if (!sessionId) {
return (
<div className="chat-panel" style={{ display: "flex", alignItems: "center", justifyContent: "center" }}>
<div style={{ color: "#6a6a8a", fontSize: "1.1rem" }}>
Select or create a session to start researching
</div>
</div>
);
}
return (
<div className="chat-panel">
<div className="chat-messages">
{messages.map((msg) => (
<div key={msg.id} className={`chat-message ${msg.role}`}>
{msg.role === "assistant" ? (
<>
<ReactMarkdown>{msg.content}</ReactMarkdown>
{msg.toolCalls?.map((tc) => (
<ToolCallCard key={tc.id} toolCall={tc} />
))}
</>
) : (
<div>{msg.content}</div>
)}
</div>
))}
{isStreaming && messages.length > 0 && (
<div style={{ color: "#6a6a8a", fontSize: "0.8rem", padding: "0.5rem" }}>
Researching...
</div>
)}
<div ref={messagesEndRef} />
</div>
<div className="chat-input-area">
<input
type="text"
placeholder="Ask a research question..."
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
disabled={isStreaming}
autoFocus
/>
<button onClick={handleSend} disabled={isStreaming || !input.trim()}>
<IconSend size={18} />
</button>
</div>
</div>
);
}
```
**Step 3: Commit**
Run:
```bash
cd /home/ken/workspace/research-workbench
git add frontend/src/components/ChatPanel.tsx frontend/src/components/ToolCallCard.tsx && git commit -m "feat: chat panel with SSE streaming and tool call cards"
```
---
### Task 8: App shell and main entry point
**Files:**
- Modify: `frontend/src/App.tsx`
- Modify: `frontend/src/main.tsx`
- Create: `frontend/src/components/RightPanel.tsx`
**Step 1: Create the RightPanel placeholder**
Create `frontend/src/components/RightPanel.tsx`:
```tsx
/**
* Right panel with Browser and Artifacts tabs.
* Browser tab renders noVNC iframe. Artifacts tab shows per-session docs.
* Full implementation in Phase 3.
*/
import { useState } from "react";
interface RightPanelProps {
sessionId: string | null;
}
export function RightPanel({ sessionId }: RightPanelProps) {
const [activeTab, setActiveTab] = useState<"browser" | "artifacts">("browser");
return (
<div className="right-panel">
<div className="right-panel-tabs">
<button
className={activeTab === "browser" ? "active" : ""}
onClick={() => setActiveTab("browser")}
>
Browser
</button>
<button
className={activeTab === "artifacts" ? "active" : ""}
onClick={() => setActiveTab("artifacts")}
>
Artifacts
</button>
</div>
<div className="right-panel-content">
{activeTab === "browser" ? (
<div style={{ padding: "1rem", color: "#6a6a8a" }}>
{sessionId
? "Browser panel (noVNC) — implemented in Phase 3"
: "Select a session to view the browser"}
</div>
) : (
<div style={{ padding: "1rem", color: "#6a6a8a" }}>
{sessionId
? "Artifacts panel — implemented in Phase 3"
: "Select a session to view artifacts"}
</div>
)}
</div>
</div>
);
}
```
**Step 2: Replace App.tsx**
Replace `frontend/src/App.tsx` with:
```tsx
import { useAuth } from "./hooks/useAuth";
import { useSessions } from "./hooks/useSessions";
import { useChat } from "./hooks/useChat";
import { LoginPage } from "./components/LoginPage";
import { SessionSidebar } from "./components/SessionSidebar";
import { ChatPanel } from "./components/ChatPanel";
import { RightPanel } from "./components/RightPanel";
export default function App() {
const { user, loading: authLoading, login, logout } = useAuth();
const { sessions, activeSessionId, createNew, switchTo, refresh } = useSessions();
const { messages, isStreaming, sendMessage } = useChat(activeSessionId);
// Show loading while checking auth
if (authLoading) {
return (
<div className="login-page">
<div style={{ color: "#8888aa" }}>Loading...</div>
</div>
);
}
// Show login if not authenticated
if (!user) {
return <LoginPage onLogin={login} />;
}
return (
<div className="app-layout">
<SessionSidebar
sessions={sessions}
activeSessionId={activeSessionId}
onSelect={(id) => {
switchTo(id);
}}
onCreate={async () => {
await createNew();
await refresh();
}}
onLogout={logout}
/>
<ChatPanel
messages={messages}
isStreaming={isStreaming}
onSend={sendMessage}
sessionId={activeSessionId}
/>
<RightPanel sessionId={activeSessionId} />
</div>
);
}
```
**Step 3: Replace main.tsx**
Replace `frontend/src/main.tsx` with:
```tsx
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import App from "./App";
import "./styles/globals.css";
createRoot(document.getElementById("root")!).render(
<StrictMode>
<App />
</StrictMode>
);
```
**Step 4: Clean up unused Vite template files**
Run:
```bash
cd /home/ken/workspace/research-workbench/frontend
rm -f src/App.css src/index.css src/assets/react.svg public/vite.svg
```
**Step 5: Verify the build compiles**
Run:
```bash
cd /home/ken/workspace/research-workbench/frontend
npm run build 2>&1 | tail -10
```
Expected: Build succeeds. Output in `frontend/dist/`.
**Step 6: Commit**
Run:
```bash
cd /home/ken/workspace/research-workbench
git add frontend/src/ && git commit -m "feat: App shell with login, session sidebar, chat panel, right panel"
```
---
### Task 9: MCP App Renderer component (sandboxed iframe for MCP Apps)
Tool results with `_meta.ui.resourceUri` trigger inline interactive visualizations rendered in sandboxed iframes. This component handles the full MCP Apps lifecycle: fetching the HTML template, setting up the iframe sandbox, and bridging JSON-RPC 2.0 communication over `postMessage`.
**Files:**
- Create: `frontend/src/components/MCPAppRenderer.tsx`
- Create: `frontend/src/components/__tests__/MCPAppRenderer.test.tsx`
**Step 1: Create the MCPAppRenderer component**
Create `frontend/src/components/MCPAppRenderer.tsx`:
```tsx
import { useRef, useEffect, useCallback, useState } from "react";
import { IconAppWindow, IconLoader } from "@tabler/icons-react";
interface MCPAppRendererProps {
/** Resource URI from tool result _meta.ui.resourceUri, e.g. "app://chart" */
resourceUri: string;
/** Structured data to send to the iframe app */
data: Record<string, unknown>;
/** Optional CSP override from _meta.ui.csp */
csp?: string;
/** Callback when the iframe app calls tools/call */
onToolCall?: (toolName: string, args: Record<string, unknown>) => void;
/** Callback when the iframe app sends a user message via ui/sendMessage */
onSendMessage?: (content: string) => void;
}
/**
* Renders an MCP App (SEP-1865) in a sandboxed iframe.
*
* Flow:
* 1. Parse resourceUri ("app://chart" -> "/api/apps/chart")
* 2. Fetch the HTML from the backend
* 3. Set iframe srcdoc with the HTML
* 4. Listen for postMessage JSON-RPC 2.0 calls from the iframe
* 5. On "ui/initialize", send the structured data as tool/input
* 6. On "tools/call", proxy to the backend and return the result
* 7. On "ui/sendMessage", inject into the chat
*/
export function MCPAppRenderer({
resourceUri,
data,
csp,
onToolCall,
onSendMessage,
}: MCPAppRendererProps) {
const iframeRef = useRef<HTMLIFrameElement>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [iframeHeight, setIframeHeight] = useState(300);
// Parse app name from resourceUri: "app://chart" -> "chart"
const appName = resourceUri.replace(/^app:\/\//, "");
// Fetch HTML and set iframe srcdoc
useEffect(() => {
let cancelled = false;
async function loadApp() {
try {
setLoading(true);
setError(null);
const resp = await fetch(`/api/apps/${appName}`, {
credentials: "include",
});
if (!resp.ok) {
throw new Error(`Failed to load app: ${resp.status}`);
}
const html = await resp.text();
if (!cancelled && iframeRef.current) {
iframeRef.current.srcdoc = html;
}
} catch (err) {
if (!cancelled) {
setError(err instanceof Error ? err.message : "Failed to load app");
}
} finally {
if (!cancelled) setLoading(false);
}
}
loadApp();
return () => { cancelled = true; };
}, [appName]);
// Handle incoming JSON-RPC 2.0 messages from the iframe
const handleMessage = useCallback(
(event: MessageEvent) => {
// Only accept messages from our iframe
if (!iframeRef.current?.contentWindow) return;
const msg = event.data;
if (!msg || typeof msg !== "object" || msg.jsonrpc !== "2.0") return;
switch (msg.method) {
case "ui/initialize":
// App is ready -- send it the structured data
iframeRef.current.contentWindow.postMessage(
{
jsonrpc: "2.0",
method: "tool/input",
params: data,
},
"*"
);
break;
case "tools/call":
// App wants to call a server tool
if (onToolCall) {
onToolCall(msg.params?.name, msg.params?.arguments ?? {});
}
// Send back an acknowledgment (the tool result will come async)
iframeRef.current.contentWindow.postMessage(
{
jsonrpc: "2.0",
id: msg.id,
result: { status: "accepted" },
},
"*"
);
break;
case "ui/sendMessage":
// App wants to inject a user message into the chat
if (onSendMessage && msg.params?.content) {
onSendMessage(msg.params.content);
}
break;
case "ui/resize":
// App requests a height change
if (msg.params?.height && typeof msg.params.height === "number") {
setIframeHeight(Math.min(msg.params.height, 800));
}
break;
}
},
[data, onToolCall, onSendMessage]
);
// Attach/detach postMessage listener
useEffect(() => {
window.addEventListener("message", handleMessage);
return () => window.removeEventListener("message", handleMessage);
}, [handleMessage]);
if (error) {
return (
<div
className="tool-call-card"
style={{ color: "#f7607a", fontSize: "0.8rem" }}
>
<IconAppWindow size={14} style={{ marginRight: 4 }} />
MCP App error: {error}
</div>
);
}
return (
<div
className="card"
style={{
margin: "0.5rem 0",
border: "1px solid #2a2a4a",
borderRadius: "8px",
overflow: "hidden",
backgroundColor: "#0a0a1a",
}}
>
{/* Header bar */}
<div
style={{
padding: "0.3rem 0.6rem",
borderBottom: "1px solid #2a2a4a",
display: "flex",
alignItems: "center",
gap: "0.3rem",
fontSize: "0.75rem",
color: "#7aa2f7",
backgroundColor: "#12192e",
}}
>
{loading ? <IconLoader size={14} /> : <IconAppWindow size={14} />}
<span>{appName}</span>
</div>
{/* Sandboxed iframe */}
<iframe
ref={iframeRef}
title={`MCP App: ${appName}`}
sandbox="allow-scripts allow-forms allow-popups"
style={{
width: "100%",
height: `${iframeHeight}px`,
border: "none",
backgroundColor: "#0a0a1a",
}}
/>
</div>
);
}
```
**Step 2: Create a basic test**
Create `frontend/src/components/__tests__/MCPAppRenderer.test.tsx`:
```tsx
import { describe, it, expect } from "vitest";
/**
* MCPAppRenderer is primarily integration-tested (needs iframe + postMessage).
* These tests verify the component module loads and the app name parsing logic.
*/
describe("MCPAppRenderer", () => {
it("module exports MCPAppRenderer component", async () => {
const mod = await import("../MCPAppRenderer");
expect(mod.MCPAppRenderer).toBeDefined();
expect(typeof mod.MCPAppRenderer).toBe("function");
});
it("parses app:// resource URIs correctly", () => {
// The component internally does: resourceUri.replace(/^app:\/\//, "")
const uri = "app://chart";
const appName = uri.replace(/^app:\/\//, "");
expect(appName).toBe("chart");
});
it("handles bare app names", () => {
const uri = "app://comparison";
const appName = uri.replace(/^app:\/\//, "");
expect(appName).toBe("comparison");
});
});
```
**Step 3: Install vitest if not present and run test**
Run:
```bash
cd /home/ken/workspace/research-workbench/frontend
npm install -D vitest @testing-library/react jsdom
npx vitest run src/components/__tests__/MCPAppRenderer.test.tsx 2>&1 | tail -10
```
Expected: All 3 tests PASS.
**Step 4: Commit**
Run:
```bash
cd /home/ken/workspace/research-workbench
git add frontend/src/components/MCPAppRenderer.tsx frontend/src/components/__tests__/ && git commit -m "feat: MCPAppRenderer for inline MCP App iframes with JSON-RPC postMessage bridge"
```
---
### Task 10: Wire MCP App Renderer into ChatPanel
When a tool call result includes `_meta.ui.resourceUri`, render an `MCPAppRenderer` instead of a `ToolCallCard`. This requires updating the ChatPanel to check each tool call for MCP App metadata.
**Files:**
- Modify: `frontend/src/components/ChatPanel.tsx`
**Step 1: Add MCPAppRenderer import to ChatPanel**
Add this import at the top of `frontend/src/components/ChatPanel.tsx`, alongside the existing imports:
```tsx
import { MCPAppRenderer } from "./MCPAppRenderer";
```
**Step 2: Update tool call rendering to detect MCP Apps**
In `frontend/src/components/ChatPanel.tsx`, find the tool call rendering section inside `messages.map()`:
Replace:
```tsx
{msg.toolCalls?.map((tc) => (
<ToolCallCard key={tc.id} toolCall={tc} />
))}
```
With:
```tsx
{msg.toolCalls?.map((tc) =>
tc._meta?.ui?.resourceUri ? (
<MCPAppRenderer
key={tc.id}
resourceUri={tc._meta.ui.resourceUri}
data={tc.structuredContent ?? {}}
csp={tc._meta.ui.csp}
onSendMessage={(content) => onSend(content)}
/>
) : (
<ToolCallCard key={tc.id} toolCall={tc} />
)
)}
```
**Step 3: Verify the build compiles**
Run:
```bash
cd /home/ken/workspace/research-workbench/frontend
npm run build 2>&1 | tail -10
```
Expected: Build succeeds.
**Step 4: Commit**
Run:
```bash
cd /home/ken/workspace/research-workbench
git add frontend/src/components/ChatPanel.tsx && git commit -m "feat: wire MCPAppRenderer into ChatPanel for MCP App tool results"
```
---
## Phase 2 Complete
At this point you have:
- Full React SPA with login flow, session sidebar, streaming chat, tool call rendering
- MCP App Renderer component for inline interactive HTML visualizations in sandboxed iframes
- Tool call results with `_meta.ui.resourceUri` automatically render as MCP Apps instead of ToolCallCards
- Direct SSE streaming from the AG-UI endpoint (no CopilotKit runtime dependency)
- Tabler dark theme with 3-column layout
- Right panel placeholder (Browser + Artifacts tabs, implemented in Phase 3)
- Build output in `frontend/dist/` ready for Docker
**Verify the build:**
Run:
```bash
cd /home/ken/workspace/research-workbench/frontend
npm run build && echo "BUILD OK" && ls -la dist/
```
Expected: `BUILD OK` with `dist/index.html` and `dist/assets/` directory.