diff --git a/docs/plans/2026-05-26-research-workbench-phase1.md b/docs/plans/2026-05-26-research-workbench-phase1.md new file mode 100644 index 0000000..3b73946 --- /dev/null +++ b/docs/plans/2026-05-26-research-workbench-phase1.md @@ -0,0 +1,1507 @@ +# Research Workbench — Phase 1: Infrastructure + +> **Execution:** Use the subagent-driven-development workflow to implement this plan. + +**Goal:** Build the Python backend (FastAPI + auth + AG-UI adapter + artifact API) and Docker container infrastructure for the research workbench. + +**Architecture:** A single Docker container runs amplifierd (session daemon), FastAPI (serves frontend + proxies amplifierd via AG-UI protocol), playwright-cli (browser control), and Xvfb/noVNC (virtual display). FastAPI translates between amplifierd's SSE event format and the AG-UI protocol that CopilotKit expects. Tool results with `_meta.ui.resourceUri` (MCP Apps) are passed through so the frontend can render interactive HTML visualizations in sandboxed iframes. Auth is simple email + bcrypt password hash with session cookies. + +**Tech Stack:** Python 3.13, FastAPI, uvicorn, httpx, bcrypt, ag-ui-protocol, Docker, Xvfb, x11vnc, websockify, noVNC + +--- + +## Prerequisites + +- The repo is at `/home/ken/workspace/research-workbench` +- It currently contains old car-help code (search.py, guide.py, sites.yaml, results/, static/, app.py) that will be replaced +- Keep: `DESIGN.md`, `README.md`, `.gitignore`, `.python-version`, `.git/` + +--- + +### Task 1: Clean out old car-help files + +**Files:** +- Delete: `search.py`, `guide.py`, `sites.yaml`, `app.py` +- Delete: `static/` directory, `results/` directory +- Delete: `Dockerfile`, `entrypoint.sh`, `pyproject.toml`, `uv.lock` +- Modify: `.gitignore` + +**Step 1: Remove old files** + +Run: +```bash +cd /home/ken/workspace/research-workbench +rm -f search.py guide.py sites.yaml app.py +rm -rf static/ results/ +rm -f Dockerfile entrypoint.sh pyproject.toml uv.lock +``` + +**Step 2: Update .gitignore for new project structure** + +Replace `.gitignore` with: + +```gitignore +# Python +.venv/ +__pycache__/ +*.pyc +*.egg-info/ + +# Node / Frontend +frontend/node_modules/ +frontend/dist/ + +# Runtime +artifacts/ + +# IDE +.idea/ +.vscode/ +*.swp +``` + +**Step 3: Create directory structure** + +Run: +```bash +cd /home/ken/workspace/research-workbench +mkdir -p backend tests bundle/behaviors bundle/agents bundle/context frontend/src artifacts docs/plans +``` + +**Step 4: Commit** + +Run: +```bash +cd /home/ken/workspace/research-workbench +git add -A && git commit -m "chore: remove old car-help files, prepare new structure" +``` + +--- + +### Task 2: Create backend pyproject.toml + +**Files:** +- Create: `backend/pyproject.toml` + +**Step 1: Create the pyproject.toml** + +Create `backend/pyproject.toml`: + +```toml +[project] +name = "research-workbench" +version = "0.1.0" +description = "AI research workbench backend - FastAPI + AG-UI adapter for amplifierd" +requires-python = ">=3.13" +dependencies = [ + "fastapi>=0.115.0", + "uvicorn[standard]>=0.34.0", + "httpx>=0.28.0", + "bcrypt>=4.2.0", + "python-multipart>=0.0.18", + "ag-ui-protocol>=0.1.18", +] + +[dependency-groups] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.24", + "httpx>=0.28.0", +] +``` + +**Step 2: Initialize the venv and install deps** + +Run: +```bash +cd /home/ken/workspace/research-workbench/backend +uv sync +``` + +Expected: Dependencies install successfully. A `uv.lock` file is created. + +**Step 3: Verify ag-ui-protocol is importable** + +Run: +```bash +cd /home/ken/workspace/research-workbench/backend +uv run python -c "from ag_ui.core import RunAgentInput, EventType; print('ag-ui-protocol OK')" +``` + +Expected: `ag-ui-protocol OK` + +**Step 4: Commit** + +Run: +```bash +cd /home/ken/workspace/research-workbench +git add backend/pyproject.toml backend/uv.lock && git commit -m "feat: add backend pyproject.toml with dependencies" +``` + +--- + +### Task 3: Auth module (email + bcrypt + session cookie) + +**Files:** +- Create: `backend/auth.py` +- Create: `tests/test_auth.py` + +**Step 1: Write the failing test** + +Create `tests/test_auth.py`: + +```python +"""Tests for auth module.""" + +import os +import sys +import pytest +from unittest.mock import patch + +# Add backend to path so tests can import modules +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "backend")) + +from auth import verify_password, create_session_token, validate_session_token + + +class TestVerifyPassword: + """Test bcrypt password verification.""" + + def test_correct_password(self): + import bcrypt + hashed = bcrypt.hashpw(b"testpass123", bcrypt.gensalt()).decode() + with patch.dict(os.environ, {"AUTH_PASS_HASH": hashed}): + assert verify_password("testpass123") is True + + def test_wrong_password(self): + import bcrypt + hashed = bcrypt.hashpw(b"testpass123", bcrypt.gensalt()).decode() + with patch.dict(os.environ, {"AUTH_PASS_HASH": hashed}): + assert verify_password("wrongpassword") is False + + def test_empty_password(self): + import bcrypt + hashed = bcrypt.hashpw(b"testpass123", bcrypt.gensalt()).decode() + with patch.dict(os.environ, {"AUTH_PASS_HASH": hashed}): + assert verify_password("") is False + + +class TestSessionToken: + """Test session token creation and validation.""" + + def test_create_and_validate(self): + token = create_session_token("ken@example.com") + assert token is not None + assert len(token) > 20 + + def test_validate_valid_token(self): + token = create_session_token("ken@example.com") + email = validate_session_token(token) + assert email == "ken@example.com" + + def test_validate_invalid_token(self): + email = validate_session_token("bogus-token-value") + assert email is None + + def test_validate_empty_token(self): + email = validate_session_token("") + assert email is None +``` + +**Step 2: Run test to verify it fails** + +Run: +```bash +cd /home/ken/workspace/research-workbench/backend +uv run python -m pytest ../tests/test_auth.py -v 2>&1 | head -20 +``` + +Expected: FAIL with `ModuleNotFoundError: No module named 'auth'` + +**Step 3: Write the implementation** + +Create `backend/auth.py`: + +```python +""" +Simple email + password authentication with session cookies. + +Auth credentials come from environment variables: + AUTH_USER - the allowed email address + AUTH_PASS_HASH - bcrypt hash of the password + +Session tokens are stored in-memory (single container, single user). +Token format: random hex string mapped to email in a dict. +""" + +import os +import secrets + +import bcrypt + +AUTH_USER: str = os.environ.get("AUTH_USER", "admin@localhost") +AUTH_PASS_HASH: str = os.environ.get("AUTH_PASS_HASH", "") + +# In-memory session store: token -> email +_sessions: dict[str, str] = {} + + +def verify_password(password: str) -> bool: + """Check a plaintext password against the AUTH_PASS_HASH env var.""" + stored_hash = os.environ.get("AUTH_PASS_HASH", AUTH_PASS_HASH) + if not stored_hash or not password: + return False + try: + return bcrypt.checkpw(password.encode("utf-8"), stored_hash.encode("utf-8")) + except (ValueError, TypeError): + return False + + +def create_session_token(email: str) -> str: + """Create a new session token for the given email.""" + token = secrets.token_hex(32) + _sessions[token] = email + return token + + +def validate_session_token(token: str) -> str | None: + """Return the email for a valid token, or None.""" + if not token: + return None + return _sessions.get(token) + + +def invalidate_session_token(token: str) -> None: + """Remove a session token.""" + _sessions.pop(token, None) +``` + +**Step 4: Run tests to verify they pass** + +Run: +```bash +cd /home/ken/workspace/research-workbench/backend +uv run python -m pytest ../tests/test_auth.py -v +``` + +Expected: All 6 tests PASS. + +**Step 5: Commit** + +Run: +```bash +cd /home/ken/workspace/research-workbench +git add backend/auth.py tests/test_auth.py && git commit -m "feat: auth module with bcrypt password verification and session tokens" +``` + +--- + +### Task 4: Artifacts module (per-session CRUD) + +**Files:** +- Create: `backend/artifacts.py` +- Create: `tests/test_artifacts.py` + +**Step 1: Write the failing test** + +Create `tests/test_artifacts.py`: + +```python +"""Tests for artifacts module.""" + +import os +import sys +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "backend")) + +from artifacts import list_artifacts, get_artifact, save_artifact + + +@pytest.fixture(autouse=True) +def temp_artifacts(tmp_path, monkeypatch): + """Use a temp directory for artifacts during tests.""" + monkeypatch.setattr("artifacts.ARTIFACTS_DIR", tmp_path) + return tmp_path + + +class TestArtifacts: + def test_list_empty_session(self): + result = list_artifacts("session-1") + assert result == [] + + def test_save_and_list(self, temp_artifacts): + save_artifact("session-1", "guide.md", "# My Guide\n\nContent here.") + result = list_artifacts("session-1") + assert len(result) == 1 + assert result[0]["name"] == "guide.md" + assert result[0]["session_id"] == "session-1" + + def test_save_and_get(self, temp_artifacts): + save_artifact("session-1", "report.md", "# Report\n\nFindings.") + content = get_artifact("session-1", "report.md") + assert content == "# Report\n\nFindings." + + def test_get_nonexistent(self): + content = get_artifact("no-session", "no-file.md") + assert content is None + + def test_multiple_artifacts(self, temp_artifacts): + save_artifact("s1", "a.md", "A") + save_artifact("s1", "b.md", "B") + save_artifact("s2", "c.md", "C") + assert len(list_artifacts("s1")) == 2 + assert len(list_artifacts("s2")) == 1 + + def test_overwrite_artifact(self, temp_artifacts): + save_artifact("s1", "doc.md", "v1") + save_artifact("s1", "doc.md", "v2") + assert get_artifact("s1", "doc.md") == "v2" + assert len(list_artifacts("s1")) == 1 +``` + +**Step 2: Run test to verify it fails** + +Run: +```bash +cd /home/ken/workspace/research-workbench/backend +uv run python -m pytest ../tests/test_artifacts.py -v 2>&1 | head -10 +``` + +Expected: FAIL with `ModuleNotFoundError: No module named 'artifacts'` + +**Step 3: Write the implementation** + +Create `backend/artifacts.py`: + +```python +""" +Per-session artifact storage. + +Artifacts are markdown files stored at artifacts//.md +Written by the researcher agent via tool-filesystem, served by FastAPI. +""" + +from pathlib import Path + +ARTIFACTS_DIR = Path("/app/artifacts") + + +def list_artifacts(session_id: str) -> list[dict]: + """List all artifacts for a session.""" + session_dir = ARTIFACTS_DIR / session_id + if not session_dir.is_dir(): + return [] + artifacts = [] + for f in sorted(session_dir.iterdir()): + if f.is_file(): + artifacts.append({ + "name": f.name, + "session_id": session_id, + "size": f.stat().st_size, + }) + return artifacts + + +def get_artifact(session_id: str, name: str) -> str | None: + """Get the content of a specific artifact.""" + path = ARTIFACTS_DIR / session_id / name + if not path.is_file(): + return None + return path.read_text(encoding="utf-8") + + +def save_artifact(session_id: str, name: str, content: str) -> Path: + """Save (or overwrite) an artifact.""" + session_dir = ARTIFACTS_DIR / session_id + session_dir.mkdir(parents=True, exist_ok=True) + path = session_dir / name + path.write_text(content, encoding="utf-8") + return path +``` + +**Step 4: Run tests to verify they pass** + +Run: +```bash +cd /home/ken/workspace/research-workbench/backend +uv run python -m pytest ../tests/test_artifacts.py -v +``` + +Expected: All 6 tests PASS. + +**Step 5: Commit** + +Run: +```bash +cd /home/ken/workspace/research-workbench +git add backend/artifacts.py tests/test_artifacts.py && git commit -m "feat: artifact storage module with per-session CRUD" +``` + +--- + +### Task 5: AG-UI adapter (amplifierd SSE -> AG-UI events, MCP App metadata passthrough) + +This is the critical integration piece. It translates amplifierd's SSE event format into AG-UI protocol events that CopilotKit understands. It also passes through `_meta.ui` and `structuredContent` from tool results so the frontend can render MCP App iframes for interactive visualizations. + +**amplifierd SSE events** (from `GET /events?session=X`): +- `content_block:start` — `{block_type: "text"|"tool_use", block_index, tool_id?, tool_name?}` +- `content_block:delta` — `{token?, block_type?, content?, block_index}` +- `content_block:end` — `{block_index}` +- `tool:result` — `{tool_call_id, tool_name, content}` +- `orchestrator:complete` — `{}` + +**AG-UI events** (what CopilotKit frontend expects): +- `RUN_STARTED`, `TEXT_MESSAGE_START/CONTENT/END`, `TOOL_CALL_START/ARGS/END/RESULT`, `RUN_FINISHED` + +**Files:** +- Create: `backend/agui_adapter.py` +- Create: `tests/test_agui_adapter.py` + +**Step 1: Write the failing test** + +Create `tests/test_agui_adapter.py`: + +```python +"""Tests for the AG-UI adapter that translates amplifierd SSE to AG-UI events.""" + +import os +import sys + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "backend")) + +from agui_adapter import AmplifierdEventTranslator + + +class TestTextBlocks: + """Test text content block translation.""" + + def test_text_block_start(self): + translator = AmplifierdEventTranslator(run_id="run-1", thread_id="thread-1") + event = { + "event_type": "content_block:start", + "data": {"block_type": "text", "block_index": 0}, + } + ag_events = list(translator.translate(event)) + assert len(ag_events) == 1 + assert ag_events[0]["type"] == "TEXT_MESSAGE_START" + assert ag_events[0]["role"] == "assistant" + assert "messageId" in ag_events[0] + + def test_text_delta(self): + translator = AmplifierdEventTranslator(run_id="run-1", thread_id="thread-1") + # Start text block first + translator.translate({ + "event_type": "content_block:start", + "data": {"block_type": "text", "block_index": 0}, + }) + ag_events = list(translator.translate({ + "event_type": "content_block:delta", + "data": {"token": "Hello ", "block_index": 0}, + })) + assert len(ag_events) == 1 + assert ag_events[0]["type"] == "TEXT_MESSAGE_CONTENT" + assert ag_events[0]["delta"] == "Hello " + + def test_text_block_end(self): + translator = AmplifierdEventTranslator(run_id="run-1", thread_id="thread-1") + translator.translate({ + "event_type": "content_block:start", + "data": {"block_type": "text", "block_index": 0}, + }) + ag_events = list(translator.translate({ + "event_type": "content_block:end", + "data": {"block_index": 0}, + })) + assert len(ag_events) == 1 + assert ag_events[0]["type"] == "TEXT_MESSAGE_END" + + +class TestToolBlocks: + """Test tool call translation.""" + + def test_tool_use_start(self): + translator = AmplifierdEventTranslator(run_id="run-1", thread_id="thread-1") + ag_events = list(translator.translate({ + "event_type": "content_block:start", + "data": { + "block_type": "tool_use", + "block_index": 1, + "tool_id": "tool_123", + "tool_name": "bash", + }, + })) + assert len(ag_events) == 1 + assert ag_events[0]["type"] == "TOOL_CALL_START" + assert ag_events[0]["toolCallName"] == "bash" + assert ag_events[0]["toolCallId"] == "tool_123" + + def test_tool_use_delta(self): + translator = AmplifierdEventTranslator(run_id="run-1", thread_id="thread-1") + translator.translate({ + "event_type": "content_block:start", + "data": { + "block_type": "tool_use", + "block_index": 1, + "tool_id": "tool_123", + "tool_name": "bash", + }, + }) + ag_events = list(translator.translate({ + "event_type": "content_block:delta", + "data": { + "block_type": "tool_use", + "content": '{"command": "ls"}', + "block_index": 1, + }, + })) + assert len(ag_events) == 1 + assert ag_events[0]["type"] == "TOOL_CALL_ARGS" + assert ag_events[0]["delta"] == '{"command": "ls"}' + + def test_tool_use_end(self): + translator = AmplifierdEventTranslator(run_id="run-1", thread_id="thread-1") + translator.translate({ + "event_type": "content_block:start", + "data": { + "block_type": "tool_use", + "block_index": 1, + "tool_id": "tool_123", + "tool_name": "bash", + }, + }) + ag_events = list(translator.translate({ + "event_type": "content_block:end", + "data": {"block_index": 1}, + })) + assert len(ag_events) == 1 + assert ag_events[0]["type"] == "TOOL_CALL_END" + + def test_tool_result(self): + translator = AmplifierdEventTranslator(run_id="run-1", thread_id="thread-1") + ag_events = list(translator.translate({ + "event_type": "tool:result", + "data": { + "tool_call_id": "tool_123", + "tool_name": "bash", + "content": "file1.txt\nfile2.txt", + }, + })) + assert len(ag_events) == 1 + assert ag_events[0]["type"] == "TOOL_CALL_RESULT" + assert ag_events[0]["toolCallId"] == "tool_123" + + def test_tool_result_with_mcp_app_metadata(self): + """Tool results with _meta.ui.resourceUri pass through UI metadata for MCP Apps.""" + translator = AmplifierdEventTranslator(run_id="run-1", thread_id="thread-1") + ag_events = list(translator.translate({ + "event_type": "tool:result", + "data": { + "tool_call_id": "tool_456", + "tool_name": "show_chart", + "content": "Chart rendered with 5 data points", + "_meta": { + "ui": { + "resourceUri": "app://chart", + "csp": "default-src 'self'; script-src 'unsafe-inline' cdn.jsdelivr.net", + } + }, + "structuredContent": { + "chartType": "bar", + "labels": ["Q1", "Q2", "Q3"], + "datasets": [{"label": "Sales", "data": [10, 20, 30]}], + }, + }, + })) + assert len(ag_events) == 1 + assert ag_events[0]["type"] == "TOOL_CALL_RESULT" + assert ag_events[0]["toolCallId"] == "tool_456" + # _meta.ui must be passed through for the frontend MCPAppRenderer + assert ag_events[0]["_meta"]["ui"]["resourceUri"] == "app://chart" + # structuredContent must be passed through for the iframe data + assert ag_events[0]["structuredContent"]["chartType"] == "bar" + + def test_tool_result_without_mcp_app_metadata(self): + """Tool results without _meta.ui should NOT have _meta or structuredContent keys.""" + translator = AmplifierdEventTranslator(run_id="run-1", thread_id="thread-1") + ag_events = list(translator.translate({ + "event_type": "tool:result", + "data": { + "tool_call_id": "tool_789", + "tool_name": "bash", + "content": "hello world", + }, + })) + assert len(ag_events) == 1 + assert "_meta" not in ag_events[0] + assert "structuredContent" not in ag_events[0] + + +class TestLifecycle: + """Test lifecycle event translation.""" + + def test_orchestrator_complete(self): + translator = AmplifierdEventTranslator(run_id="run-1", thread_id="thread-1") + ag_events = list(translator.translate({ + "event_type": "orchestrator:complete", + "data": {}, + })) + assert len(ag_events) == 1 + assert ag_events[0]["type"] == "RUN_FINISHED" + assert ag_events[0]["threadId"] == "thread-1" + assert ag_events[0]["runId"] == "run-1" + + def test_unknown_event_ignored(self): + translator = AmplifierdEventTranslator(run_id="run-1", thread_id="thread-1") + ag_events = list(translator.translate({ + "event_type": "some:unknown:event", + "data": {"foo": "bar"}, + })) + assert ag_events == [] +``` + +**Step 2: Run test to verify it fails** + +Run: +```bash +cd /home/ken/workspace/research-workbench/backend +uv run python -m pytest ../tests/test_agui_adapter.py -v 2>&1 | head -10 +``` + +Expected: FAIL with `ModuleNotFoundError: No module named 'agui_adapter'` + +**Step 3: Write the implementation** + +Create `backend/agui_adapter.py`: + +```python +""" +AG-UI protocol adapter for amplifierd. + +Translates amplifierd's SSE events into AG-UI protocol events that CopilotKit +can consume. This is the critical bridge between the two systems. + +amplifierd events (from GET /events?session=X): + content_block:start {block_type: "text"|"tool_use", block_index, tool_id?, tool_name?} + content_block:delta {token?, block_type?, content?, block_index} + content_block:end {block_index} + tool:result {tool_call_id, tool_name, content} + orchestrator:complete {} + +AG-UI events (sent to CopilotKit frontend): + RUN_STARTED {threadId, runId} + TEXT_MESSAGE_START {messageId, role} + TEXT_MESSAGE_CONTENT {messageId, delta} + TEXT_MESSAGE_END {messageId} + TOOL_CALL_START {toolCallId, toolCallName, parentMessageId?} + TOOL_CALL_ARGS {toolCallId, delta} + TOOL_CALL_END {toolCallId} + TOOL_CALL_RESULT {toolCallId, content, role, _meta?, structuredContent?} + RUN_FINISHED {threadId, runId} + +MCP App passthrough: + When a tool:result includes _meta.ui.resourceUri, the TOOL_CALL_RESULT event + preserves _meta and structuredContent so the frontend can render an MCP App + iframe instead of a plain ToolCallCard. +""" + +import uuid + + +class AmplifierdEventTranslator: + """Stateful translator from amplifierd SSE events to AG-UI events. + + Tracks active content blocks to correctly pair start/end events and + assign consistent message IDs across the stream. + """ + + def __init__(self, run_id: str, thread_id: str): + self.run_id = run_id + self.thread_id = thread_id + # Track active blocks: block_index -> {type, message_id, tool_id} + self._blocks: dict[int, dict] = {} + # Current assistant message ID (for text blocks) + self._current_message_id: str | None = None + + def translate(self, event: dict) -> list[dict]: + """Translate a single amplifierd event into zero or more AG-UI events.""" + event_type = event.get("event_type", "") + data = event.get("data", {}) + + if event_type == "content_block:start": + return self._handle_block_start(data) + elif event_type == "content_block:delta": + return self._handle_block_delta(data) + elif event_type == "content_block:end": + return self._handle_block_end(data) + elif event_type == "tool:result": + return self._handle_tool_result(data) + elif event_type == "orchestrator:complete": + return self._handle_complete() + else: + return [] + + def _handle_block_start(self, data: dict) -> list[dict]: + block_type = data.get("block_type", "text") + block_index = data.get("block_index", 0) + + if block_type == "tool_use": + tool_id = data.get("tool_id", str(uuid.uuid4())) + tool_name = data.get("tool_name", "unknown") + self._blocks[block_index] = { + "type": "tool_use", + "tool_id": tool_id, + "tool_name": tool_name, + } + return [{ + "type": "TOOL_CALL_START", + "toolCallId": tool_id, + "toolCallName": tool_name, + "parentMessageId": self._current_message_id, + }] + else: + message_id = str(uuid.uuid4()) + self._current_message_id = message_id + self._blocks[block_index] = { + "type": "text", + "message_id": message_id, + } + return [{ + "type": "TEXT_MESSAGE_START", + "messageId": message_id, + "role": "assistant", + }] + + def _handle_block_delta(self, data: dict) -> list[dict]: + block_index = data.get("block_index", 0) + block_info = self._blocks.get(block_index) + + if block_info is None: + # Delta without a matching start - handle gracefully + if data.get("block_type") == "tool_use": + tool_id = data.get("tool_id", "unknown") + return [{ + "type": "TOOL_CALL_ARGS", + "toolCallId": tool_id, + "delta": data.get("content", ""), + }] + return [] + + if block_info["type"] == "tool_use": + return [{ + "type": "TOOL_CALL_ARGS", + "toolCallId": block_info["tool_id"], + "delta": data.get("content", ""), + }] + else: + token = data.get("token", "") + if not token: + return [] + return [{ + "type": "TEXT_MESSAGE_CONTENT", + "messageId": block_info["message_id"], + "delta": token, + }] + + def _handle_block_end(self, data: dict) -> list[dict]: + block_index = data.get("block_index", 0) + block_info = self._blocks.pop(block_index, None) + + if block_info is None: + return [] + + if block_info["type"] == "tool_use": + return [{ + "type": "TOOL_CALL_END", + "toolCallId": block_info["tool_id"], + }] + else: + return [{ + "type": "TEXT_MESSAGE_END", + "messageId": block_info["message_id"], + }] + + def _handle_tool_result(self, data: dict) -> list[dict]: + event: dict = { + "type": "TOOL_CALL_RESULT", + "toolCallId": data.get("tool_call_id", "unknown"), + "content": data.get("content", ""), + "role": "tool", + } + # Pass through MCP App metadata if present. + # When a tool result includes _meta.ui.resourceUri, the frontend + # renders an MCPAppRenderer (sandboxed iframe) instead of a ToolCallCard. + if "_meta" in data: + event["_meta"] = data["_meta"] + if "structuredContent" in data: + event["structuredContent"] = data["structuredContent"] + return [event] + + def _handle_complete(self) -> list[dict]: + return [{ + "type": "RUN_FINISHED", + "threadId": self.thread_id, + "runId": self.run_id, + }] +``` + +**Step 4: Run tests to verify they pass** + +Run: +```bash +cd /home/ken/workspace/research-workbench/backend +uv run python -m pytest ../tests/test_agui_adapter.py -v +``` + +Expected: All 10 tests PASS. + +**Step 5: Commit** + +Run: +```bash +cd /home/ken/workspace/research-workbench +git add backend/agui_adapter.py tests/test_agui_adapter.py && git commit -m "feat: AG-UI adapter translating amplifierd SSE events to AG-UI protocol (with MCP App metadata passthrough)" +``` + +--- + +### Task 6: FastAPI application (app.py) + +The main FastAPI app: auth endpoints, AG-UI/CopilotKit SSE endpoint, artifact API, MCP App template serving, session proxy to amplifierd, and static file serving. + +**Files:** +- Create: `backend/app.py` +- Create: `tests/test_app.py` + +**Step 1: Write the failing test** + +Create `tests/test_app.py`: + +```python +"""Integration tests for the FastAPI app.""" + +import os +import sys + +import bcrypt +import pytest + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "backend")) + +# Set auth env vars before importing app +TEST_HASH = bcrypt.hashpw(b"testpass", bcrypt.gensalt()).decode() +os.environ["AUTH_USER"] = "test@example.com" +os.environ["AUTH_PASS_HASH"] = TEST_HASH + +from fastapi.testclient import TestClient + +from app import app + +client = TestClient(app) + + +class TestHealthEndpoint: + def test_health(self): + resp = client.get("/api/health") + assert resp.status_code == 200 + assert resp.json()["status"] == "ok" + + +class TestAuthEndpoints: + def test_login_success(self): + resp = client.post("/api/auth/login", json={ + "email": "test@example.com", + "password": "testpass", + }) + assert resp.status_code == 200 + data = resp.json() + assert "token" in data + assert "session" in resp.cookies + + def test_login_wrong_password(self): + resp = client.post("/api/auth/login", json={ + "email": "test@example.com", + "password": "wrongpass", + }) + assert resp.status_code == 401 + + def test_login_wrong_email(self): + resp = client.post("/api/auth/login", json={ + "email": "wrong@example.com", + "password": "testpass", + }) + assert resp.status_code == 401 + + def test_me_unauthenticated(self): + resp = client.get("/api/auth/me") + assert resp.status_code == 401 + + def test_me_authenticated(self): + login_resp = client.post("/api/auth/login", json={ + "email": "test@example.com", + "password": "testpass", + }) + token = login_resp.json()["token"] + resp = client.get("/api/auth/me", cookies={"session": token}) + assert resp.status_code == 200 + assert resp.json()["email"] == "test@example.com" + + def test_logout(self): + login_resp = client.post("/api/auth/login", json={ + "email": "test@example.com", + "password": "testpass", + }) + token = login_resp.json()["token"] + resp = client.post("/api/auth/logout", cookies={"session": token}) + assert resp.status_code == 200 + # Token should now be invalid + resp2 = client.get("/api/auth/me", cookies={"session": token}) + assert resp2.status_code == 401 + + +class TestArtifactEndpoints: + def _auth_cookies(self) -> dict: + resp = client.post("/api/auth/login", json={ + "email": "test@example.com", + "password": "testpass", + }) + return {"session": resp.json()["token"]} + + def test_list_artifacts_empty(self, tmp_path, monkeypatch): + monkeypatch.setattr("artifacts.ARTIFACTS_DIR", tmp_path) + cookies = self._auth_cookies() + resp = client.get("/api/artifacts/test-session", cookies=cookies) + assert resp.status_code == 200 + assert resp.json() == [] + + def test_list_artifacts_unauthenticated(self): + resp = client.get("/api/artifacts/test-session") + assert resp.status_code == 401 + + +class TestMCPAppsEndpoint: + """Test serving MCP App HTML templates.""" + + def _auth_cookies(self) -> dict: + resp = client.post("/api/auth/login", json={ + "email": "test@example.com", + "password": "testpass", + }) + return {"session": resp.json()["token"]} + + def test_get_app_not_found(self): + cookies = self._auth_cookies() + resp = client.get("/api/apps/nonexistent", cookies=cookies) + assert resp.status_code == 404 + + def test_get_app_unauthenticated(self): + resp = client.get("/api/apps/chart") + assert resp.status_code == 401 +``` + +**Step 2: Run test to verify it fails** + +Run: +```bash +cd /home/ken/workspace/research-workbench/backend +uv run python -m pytest ../tests/test_app.py -v 2>&1 | head -10 +``` + +Expected: FAIL with `ModuleNotFoundError: No module named 'app'` + +**Step 3: Write the implementation** + +Create `backend/app.py` with the full FastAPI application. This is the longest file. Key sections: +- Auth endpoints (`/api/auth/login`, `/api/auth/me`, `/api/auth/logout`) +- Session proxy to amplifierd (`/api/sessions/*`) +- AG-UI endpoint for CopilotKit (`/api/copilotkit`) - receives RunAgentInput, streams AG-UI events +- Artifact API (`/api/artifacts/*`) +- Static file serving for built frontend + +```python +""" +Research Workbench - FastAPI application. + +Serves: + /api/health - health check + /api/auth/* - login, logout, me + /api/sessions/* - proxy to amplifierd session API + /api/copilotkit - AG-UI protocol endpoint for CopilotKit + /api/artifacts/* - per-session artifact CRUD + /api/apps/{name} - MCP App HTML templates (for sandboxed iframes) + / - built frontend static files +""" + +import json +import os +import uuid +from contextlib import asynccontextmanager +from pathlib import Path + +import httpx +from fastapi import Cookie, FastAPI, HTTPException, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import HTMLResponse, JSONResponse, StreamingResponse +from fastapi.staticfiles import StaticFiles + +from agui_adapter import AmplifierdEventTranslator +from artifacts import get_artifact, list_artifacts +from auth import ( + create_session_token, + invalidate_session_token, + validate_session_token, + verify_password, +) + +AMPLIFIERD_URL = os.environ.get("AMPLIFIERD_URL", "http://localhost:8410") +AUTH_USER = os.environ.get("AUTH_USER", "admin@localhost") +BUNDLE_NAME = os.environ.get("BUNDLE_NAME", "research-workbench") + + +@asynccontextmanager +async def lifespan(app: FastAPI): + app.state.http_client = httpx.AsyncClient(base_url=AMPLIFIERD_URL, timeout=30.0) + yield + await app.state.http_client.aclose() + + +app = FastAPI(title="research-workbench", version="0.1.0", lifespan=lifespan) +app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], + allow_credentials=True, +) + + +# --- Auth helpers --- + +def _require_auth(session: str | None) -> str: + email = validate_session_token(session) if session else None + if email is None: + raise HTTPException(status_code=401, detail="Not authenticated") + return email + + +# --- Health --- + +@app.get("/api/health") +async def health(): + return {"status": "ok"} + + +# --- Auth endpoints --- + +@app.post("/api/auth/login") +async def login(request: Request): + body = await request.json() + email = body.get("email", "") + password = body.get("password", "") + if email != AUTH_USER or not verify_password(password): + return JSONResponse({"error": "Invalid credentials"}, status_code=401) + token = create_session_token(email) + response = JSONResponse({"token": token, "email": email}) + response.set_cookie(key="session", value=token, httponly=True, + samesite="lax", max_age=86400 * 7) + return response + + +@app.get("/api/auth/me") +async def me(session: str | None = Cookie(default=None)): + email = validate_session_token(session) if session else None + if email is None: + return JSONResponse({"error": "Not authenticated"}, status_code=401) + return {"email": email} + + +@app.post("/api/auth/logout") +async def logout(session: str | None = Cookie(default=None)): + if session: + invalidate_session_token(session) + response = JSONResponse({"status": "ok"}) + response.delete_cookie("session") + return response + + +# --- Session proxy (to amplifierd) --- + +@app.get("/api/sessions") +async def list_sessions(session: str | None = Cookie(default=None)): + _require_auth(session) + resp = await app.state.http_client.get("/sessions") + return JSONResponse(resp.json(), status_code=resp.status_code) + + +@app.post("/api/sessions") +async def create_session(request: Request, session: str | None = Cookie(default=None)): + _require_auth(session) + body = await request.json() + if "bundle_name" not in body: + body["bundle_name"] = BUNDLE_NAME + resp = await app.state.http_client.post("/sessions", json=body) + return JSONResponse(resp.json(), status_code=resp.status_code) + + +@app.get("/api/sessions/{session_id}") +async def get_session(session_id: str, session: str | None = Cookie(default=None)): + _require_auth(session) + resp = await app.state.http_client.get(f"/sessions/{session_id}") + return JSONResponse(resp.json(), status_code=resp.status_code) + + +@app.delete("/api/sessions/{session_id}") +async def delete_session(session_id: str, session: str | None = Cookie(default=None)): + _require_auth(session) + resp = await app.state.http_client.delete(f"/sessions/{session_id}") + return JSONResponse({"status": "deleted"}, status_code=resp.status_code) + + +@app.get("/api/sessions/{session_id}/transcript") +async def get_transcript(session_id: str, session: str | None = Cookie(default=None)): + _require_auth(session) + resp = await app.state.http_client.get(f"/sessions/{session_id}/transcript") + return JSONResponse(resp.json(), status_code=resp.status_code) + + +# --- AG-UI endpoint (CopilotKit connects here) --- + +@app.post("/api/copilotkit") +async def copilotkit_endpoint(request: Request, session: str | None = Cookie(default=None)): + """AG-UI protocol endpoint. + + CopilotKit sends RunAgentInput (messages, threadId, runId). + We map threadId to an amplifierd session, execute the prompt, + and stream back AG-UI events translated from amplifierd's SSE. + """ + _require_auth(session) + body = await request.json() + + thread_id = body.get("threadId", str(uuid.uuid4())) + run_id = body.get("runId", str(uuid.uuid4())) + messages = body.get("messages", []) + + # Extract latest user message as prompt + prompt = "" + for msg in reversed(messages): + if msg.get("role") == "user" and msg.get("content"): + prompt = msg["content"] + break + if not prompt: + return JSONResponse({"error": "No user message found"}, status_code=400) + + # Use threadId as amplifierd session_id + amp_session_id = thread_id + client = app.state.http_client + + # Create session if it doesn't exist + check = await client.get(f"/sessions/{amp_session_id}") + if check.status_code == 404: + await client.post("/sessions", json={ + "session_id": amp_session_id, + "bundle_name": BUNDLE_NAME, + }) + + # Fire execute/stream + exec_resp = await client.post( + f"/sessions/{amp_session_id}/execute/stream", + json={"prompt": prompt}, + ) + if exec_resp.status_code != 202: + return JSONResponse( + {"error": "Failed to start execution", "detail": exec_resp.text}, + status_code=502, + ) + + async def event_generator(): + translator = AmplifierdEventTranslator(run_id=run_id, thread_id=thread_id) + yield _sse({"type": "RUN_STARTED", "threadId": thread_id, "runId": run_id}) + + async with httpx.AsyncClient(base_url=AMPLIFIERD_URL, timeout=None) as sse_client: + async with sse_client.stream( + "GET", "/events", params={"session": amp_session_id}, + ) as response: + buffer = "" + async for chunk in response.aiter_text(): + buffer += chunk + while "\n\n" in buffer: + raw_event, buffer = buffer.split("\n\n", 1) + parsed = _parse_sse(raw_event) + if parsed is None: + continue + for ag_event in translator.translate(parsed): + yield _sse(ag_event) + if parsed.get("event_type") == "orchestrator:complete": + return + + return StreamingResponse( + event_generator(), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache", "X-Accel-Buffering": "no"}, + ) + + +def _sse(event: dict) -> str: + return f"data: {json.dumps(event)}\n\n" + + +def _parse_sse(raw: str) -> dict | None: + event_type = None + data_lines = [] + for line in raw.strip().split("\n"): + if line.startswith("event:"): + event_type = line[6:].strip() + elif line.startswith("data:"): + data_lines.append(line[5:].strip()) + if not data_lines: + return None + try: + data = json.loads("\n".join(data_lines)) + except json.JSONDecodeError: + return None + return {"event_type": event_type or data.get("event_type", ""), "data": data} + + +# --- Artifacts --- + +@app.get("/api/artifacts/{session_id}") +async def api_list_artifacts(session_id: str, session: str | None = Cookie(default=None)): + _require_auth(session) + return JSONResponse(list_artifacts(session_id)) + + +@app.get("/api/artifacts/{session_id}/{name}") +async def api_get_artifact(session_id: str, name: str, + session: str | None = Cookie(default=None)): + _require_auth(session) + content = get_artifact(session_id, name) + if content is None: + return JSONResponse({"error": "Not found"}, status_code=404) + return JSONResponse({"name": name, "session_id": session_id, "content": content}) + + +# --- MCP App templates (served to sandboxed iframes) --- + +APPS_DIR = Path(__file__).parent.parent / "bundle" / "apps" + + +@app.get("/api/apps/{app_name}") +async def get_mcp_app(app_name: str, session: str | None = Cookie(default=None)): + """Serve MCP App HTML templates for inline interactive visualizations. + + MCP Apps are self-contained HTML files rendered in sandboxed iframes. + Tool results with _meta.ui.resourceUri reference these apps. + """ + _require_auth(session) + # Normalize: allow "chart" or "chart.html" + if not app_name.endswith(".html"): + app_name += ".html" + app_path = APPS_DIR / app_name + if not app_path.is_file(): + return JSONResponse({"error": f"App '{app_name}' not found"}, status_code=404) + html = app_path.read_text(encoding="utf-8") + return HTMLResponse(content=html) + + +# --- Static files (built frontend) - MUST be last --- + +_static_dir = Path(__file__).parent.parent / "frontend" / "dist" +if _static_dir.is_dir(): + app.mount("/", StaticFiles(directory=str(_static_dir), html=True), name="frontend") +``` + +**Step 4: Run tests to verify they pass** + +Run: +```bash +cd /home/ken/workspace/research-workbench/backend +uv run python -m pytest ../tests/test_app.py -v +``` + +Expected: All 11 tests PASS. (Session proxy tests that call amplifierd will fail without amplifierd running -- that's expected. The tests above only cover auth, health, and artifact endpoints.) + +**Step 5: Commit** + +Run: +```bash +cd /home/ken/workspace/research-workbench +git add backend/app.py tests/test_app.py && git commit -m "feat: FastAPI app with auth, AG-UI endpoint, session proxy, artifact API" +``` + +--- + +### Task 7: Dockerfile + +**Files:** +- Create: `Dockerfile` + +**Step 1: Create the Dockerfile** + +Create `Dockerfile`: + +```dockerfile +FROM python:3.13-slim + +# System deps: display, VNC, browser libs, Node.js for playwright-cli +RUN apt-get update -qq && apt-get install -y -qq --no-install-recommends \ + xvfb x11vnc websockify novnc curl \ + libnss3 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \ + libxkbcommon0 libxcomposite1 libxdamage1 libxrandr2 libgbm1 \ + libpango-1.0-0 libcairo2 libasound2 libxshmfence1 libgtk-3-0 \ + fonts-liberation && \ + rm -rf /var/lib/apt/lists/* + +# Install Node.js (for playwright-cli) +RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \ + apt-get install -y nodejs && \ + rm -rf /var/lib/apt/lists/* + +# Install playwright-cli globally +RUN npm install -g @anthropic-ai/playwright-cli + +# Install uv +RUN pip install uv + +# Backend deps +COPY backend/pyproject.toml backend/uv.lock /app/backend/ +WORKDIR /app/backend +RUN uv sync --frozen + +# Install Playwright browser +RUN uv run playwright install chromium + +# Install amplifierd +RUN uv pip install amplifierd + +# Copy backend source +COPY backend/*.py /app/backend/ + +# Copy bundle +COPY bundle/ /app/bundle/ + +# Copy built frontend (build before docker build) +COPY frontend/dist/ /app/frontend/dist/ + +# Copy entrypoint +COPY entrypoint.sh /entrypoint.sh +RUN chmod +x /entrypoint.sh + +# Create artifacts directory +RUN mkdir -p /app/artifacts + +WORKDIR /app + +EXPOSE 8080 6080 + +ENV DISPLAY=:99 +ENV AMPLIFIERD_URL=http://localhost:8410 +ENV BUNDLE_NAME=research-workbench + +ENTRYPOINT ["/entrypoint.sh"] +``` + +**Step 2: Commit** + +Run: +```bash +cd /home/ken/workspace/research-workbench +git add Dockerfile && git commit -m "feat: Dockerfile with all-in-one container" +``` + +--- + +### Task 8: Entrypoint script + +**Files:** +- Create: `entrypoint.sh` + +**Step 1: Create the entrypoint** + +Create `entrypoint.sh`: + +```bash +#!/bin/bash +set -e + +export DISPLAY=:99 + +echo "=== Starting Research Workbench ===" + +# 1. Virtual display +Xvfb :99 -screen 0 1280x720x24 -ac & +sleep 1 +echo "[OK] Xvfb display :99" + +# 2. VNC server +x11vnc -display :99 -forever -nopw -listen 0.0.0.0 -rfbport 5900 -shared 2>/dev/null & +sleep 1 +echo "[OK] x11vnc on :5900" + +# 3. noVNC (web VNC client) +websockify --web=/usr/share/novnc/ 6080 localhost:5900 & +sleep 1 +echo "[OK] noVNC on :6080" + +# 4. amplifierd (session daemon) +cd /app +amplifierd --port 8410 --bundle /app/bundle/bundle.md & +AMPLIFIERD_PID=$! + +# Wait for amplifierd to be ready +echo "Waiting for amplifierd..." +for i in $(seq 1 30); do + if curl -s http://localhost:8410/health > /dev/null 2>&1; then + echo "[OK] amplifierd on :8410" + break + fi + sleep 1 +done + +# 5. FastAPI (web frontend + API) +cd /app/backend +uv run python -m uvicorn app:app --host 0.0.0.0 --port 8080 --log-level info & +sleep 2 +echo "[OK] FastAPI on :8080" + +echo "=============================================" +echo " Research Workbench: http://localhost:8080" +echo " Browser VNC: http://localhost:6080" +echo "=============================================" + +# Keep container running +if [ $# -eq 0 ]; then + wait $AMPLIFIERD_PID +else + exec "$@" +fi +``` + +**Step 2: Commit** + +Run: +```bash +cd /home/ken/workspace/research-workbench +git add entrypoint.sh && git commit -m "feat: entrypoint.sh starting all services" +``` + +--- + +## Phase 1 Complete + +At this point you have: +- Clean repo with new directory structure +- Backend with auth, artifacts, AG-UI adapter (with MCP App metadata passthrough), and FastAPI app +- MCP App template serving endpoint (`/api/apps/{name}`) +- Tests for auth, artifacts, AG-UI adapter (including MCP App passthrough), and app integration +- Dockerfile and entrypoint for the all-in-one container +- No frontend yet (that's Phase 2) + +**Verify everything passes:** + +Run: +```bash +cd /home/ken/workspace/research-workbench/backend +uv run python -m pytest ../tests/ -v +``` + +Expected: All tests pass. \ No newline at end of file diff --git a/docs/plans/2026-05-26-research-workbench-phase2.md b/docs/plans/2026-05-26-research-workbench-phase2.md new file mode 100644 index 0000000..38608c7 --- /dev/null +++ b/docs/plans/2026-05-26-research-workbench-phase2.md @@ -0,0 +1,1820 @@ +# 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 */} +