# 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(url: string, options?: RequestInit): Promise { 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("/api/auth/login", { method: "POST", body: JSON.stringify({ email, password }), }); export const getMe = () => request("/api/auth/me"); export const logout = () => request<{ status: string }>("/api/auth/logout", { method: "POST" }); // Sessions export const listSessions = () => request("/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(`/api/artifacts/${sessionId}`); export const getArtifact = (sessionId: string, name: string) => request(`/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(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([]); const [activeSessionId, setActiveSessionId] = useState(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([]); const [isStreaming, setIsStreaming] = useState(false); const abortRef = useRef(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 = {}; 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([]); const [activeArtifact, setActiveArtifact] = useState(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; } 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 (

Research Workbench

{error &&
{error}
} setEmail(e.target.value)} autoFocus required /> setPassword(e.target.value)} required />
); } ``` **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 (

Sessions

{sessions.length === 0 && (
No sessions yet. Click + to start researching.
)} {sessions.map((s) => (
onSelect(s.session_id)} >
{s.session_id.slice(0, 12)}...
{s.total_messages ?? 0} msgs · {s.status}
))}
); } ``` **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 (
{toolCall.name} {toolCall.status === "running" ? ( ) : ( )}{" "} {toolCall.status} {argsSummary &&
{argsSummary}
}
); } ``` **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(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) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); handleSend(); } }; if (!sessionId) { return (
Select or create a session to start researching
); } return (
{messages.map((msg) => (
{msg.role === "assistant" ? ( <> {msg.content} {msg.toolCalls?.map((tc) => ( ))} ) : (
{msg.content}
)}
))} {isStreaming && messages.length > 0 && (
Researching...
)}
setInput(e.target.value)} onKeyDown={handleKeyDown} disabled={isStreaming} autoFocus />
); } ``` **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 (
{activeTab === "browser" ? (
{sessionId ? "Browser panel (noVNC) — implemented in Phase 3" : "Select a session to view the browser"}
) : (
{sessionId ? "Artifacts panel — implemented in Phase 3" : "Select a session to view artifacts"}
)}
); } ``` **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 (
Loading...
); } // Show login if not authenticated if (!user) { return ; } return (
{ switchTo(id); }} onCreate={async () => { await createNew(); await refresh(); }} onLogout={logout} />
); } ``` **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( ); ``` **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; /** Optional CSP override from _meta.ui.csp */ csp?: string; /** Callback when the iframe app calls tools/call */ onToolCall?: (toolName: string, args: Record) => 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(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(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 (
MCP App error: {error}
); } return (
{/* Header bar */}
{loading ? : } {appName}
{/* Sandboxed iframe */}