Files
research-workbench/docs/plans/2026-05-26-research-workbench-phase2.md
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

45 KiB

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:

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:

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:

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:

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:

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:

/**
 * 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:

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:

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:

/* 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:

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:

/**
 * 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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

/**
 * 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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

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:

                {msg.toolCalls?.map((tc) => (
                  <ToolCallCard key={tc.id} toolCall={tc} />
                ))}

With:

                {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:

cd /home/ken/workspace/research-workbench/frontend
npm run build 2>&1 | tail -10

Expected: Build succeeds.

Step 4: Commit

Run:

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:

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.