# ACPX Teams Monitor Frontend Implementation Plan

> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.

**Goal:** Build a read-only Trello-style monitoring dashboard for acpx-teams-mcp that shows team members, tasks, and messages in a three-panel layout.

**Architecture:** The frontend is a standalone React + TypeScript SPA that talks to a lightweight FastAPI REST server. The REST server reuses the existing `claude_teams` Python package (teams, tasks, messaging modules) to read data from `~/.acpx/` — no MCP protocol involved. The frontend polls the REST API every 5-10 seconds for data updates.

**Tech Stack:** React 18 + TypeScript + Vite (frontend), FastAPI + uvicorn (REST API), TailwindCSS (styling), React Query / TanStack Query (polling/caching)

---

## File Structure

### Backend (REST API)

```
acpx-teams-mcp/
├── src/claude_teams/
│   ├── api.py              # FastAPI app with REST endpoints (NEW)
│   └── api_models.py       # Pydantic response models for REST (NEW)
├── tests/
│   ├── test_api.py          # API endpoint tests (NEW)
```

### Frontend

```
acpx-teams-mcp/
├── frontend/
│   ├── package.json
│   ├── tsconfig.json
│   ├── vite.config.ts
│   ├── index.html
│   ├── src/
│   │   ├── main.tsx                    # Entry point
│   │   ├── App.tsx                     # Root layout (three-panel)
│   │   ├── types.ts                    # TypeScript interfaces
│   │   ├── api.ts                      # API client functions
│   │   ├── hooks/
│   │   │   ├── useTeams.ts             # Teams list polling hook
│   │   │   ├── useTeamDetail.ts        # Current team data polling hook
│   │   │   └── useMessages.ts          # Messages polling hook
│   │   ├── components/
│   │   │   ├── TopBar.tsx              # Navigation bar + refresh + stats
│   │   │   ├── TeamSidebar.tsx         # Left panel: team list
│   │   │   ├── TeamItem.tsx            # Single team entry in sidebar
│   │   │   ├── StatsBar.tsx            # Summary statistics badges
│   │   │   ├── ViewTabs.tsx            # Members/Tasks tab switcher
│   │   │   ├── MembersView.tsx         # Kanban: columns by member
│   │   │   ├── MemberColumn.tsx        # Single member column
│   │   │   ├── TasksView.tsx           # Kanban: columns by status
│   │   │   ├── StatusColumn.tsx        # Single status column
│   │   │   ├── TaskCard.tsx            # Adaptive task card (compact/expanded)
│   │   │   ├── MessageSidebar.tsx      # Right panel: message feed
│   │   │   ├── MessageItem.tsx         # Single message entry
│   │   │   └── StatusDot.tsx           # Reusable status indicator dot
│   │   └── styles/
│   │       └── index.css               # Tailwind imports + custom CSS
│   └── tests/
│       ├── api.test.ts                 # API client tests
│       ├── TaskCard.test.tsx           # TaskCard component tests
│       └── setup.ts                    # Test setup (vitest)
```

---

## Task 1: REST API Server

**Files:**
- Create: `src/claude_teams/api.py`
- Create: `src/claude_teams/api_models.py`
- Create: `tests/test_api.py`

This task creates a FastAPI server that wraps the existing `claude_teams` modules into REST endpoints.

- [ ] **Step 1: Write test for list teams endpoint**

```python
# tests/test_api.py
import json
import os
import tempfile
from pathlib import Path
from unittest.mock import patch

import pytest
from fastapi.testclient import TestClient


@pytest.fixture
def tmp_acpx(tmp_path):
    """Create a temporary ~/.acpx structure with test data."""
    teams_dir = tmp_path / "teams"
    tasks_dir = tmp_path / "tasks"

    # Team alpha
    alpha_dir = teams_dir / "alpha"
    alpha_dir.mkdir(parents=True)
    (alpha_dir / "config.json").write_text(json.dumps({
        "name": "alpha",
        "description": "Test team",
        "createdAt": 1700000000000,
        "leadAgentId": "lead-1",
        "leadSessionId": "sess-1",
        "members": [
            {
                "agentId": "lead-1",
                "name": "team-lead",
                "agentType": "team-lead",
                "model": "claude-opus-4-6",
                "joinedAt": 1700000000000,
                "cwd": "/tmp",
            },
            {
                "agentId": "agent-1",
                "name": "sarah",
                "agentType": "claude",
                "model": "claude-sonnet-4-6",
                "color": "blue",
                "planModeRequired": False,
                "joinedAt": 1700000001000,
                "acpxSessionName": "sarah@alpha",
                "cwd": "/tmp",
                "isActive": True,
            },
        ],
    }))
    inboxes_dir = alpha_dir / "inboxes"
    inboxes_dir.mkdir()
    (inboxes_dir / "team-lead.json").write_text(json.dumps([
        {"from": "sarah", "text": "Hello", "timestamp": "2026-04-12T10:00:00Z", "read": False, "color": "blue"},
    ]))

    # Tasks
    alpha_tasks = tasks_dir / "alpha"
    alpha_tasks.mkdir(parents=True)
    (alpha_tasks / "1.json").write_text(json.dumps({
        "id": "1",
        "subject": "Fix bug",
        "description": "Fix the login bug",
        "activeForm": "Debugging",
        "status": "in_progress",
        "blocks": [],
        "blockedBy": [],
        "owner": "sarah",
    }))

    return tmp_path


@pytest.fixture
def client(tmp_acpx):
    with patch("claude_teams.teams.CLAUDE_DIR", tmp_acpx), \
         patch("claude_teams.teams.TEAMS_DIR", tmp_acpx / "teams"), \
         patch("claude_teams.teams.TASKS_DIR", tmp_acpx / "tasks"), \
         patch("claude_teams.tasks.TASKS_DIR", tmp_acpx / "tasks"):
        from claude_teams.api import app
        yield TestClient(app)


def test_list_teams(client):
    resp = client.get("/api/teams")
    assert resp.status_code == 200
    data = resp.json()
    assert len(data) == 1
    assert data[0]["name"] == "alpha"


def test_get_team_config(client):
    resp = client.get("/api/teams/alpha/config")
    assert resp.status_code == 200
    data = resp.json()
    assert data["name"] == "alpha"
    assert len(data["members"]) == 2


def test_get_team_tasks(client):
    resp = client.get("/api/teams/alpha/tasks")
    assert resp.status_code == 200
    data = resp.json()
    assert len(data) == 1
    assert data[0]["subject"] == "Fix bug"


def test_get_team_messages(client):
    resp = client.get("/api/teams/alpha/messages")
    assert resp.status_code == 200
    data = resp.json()
    assert len(data) == 1
    assert data[0]["from"] == "sarah"


def test_team_not_found(client):
    resp = client.get("/api/teams/nonexistent/config")
    assert resp.status_code == 404
```

- [ ] **Step 2: Run test to verify it fails**

Run: `cd acpx-teams-mcp && uv run pytest tests/test_api.py -v`
Expected: FAIL with "ModuleNotFoundError: No module named 'claude_teams.api'"

- [ ] **Step 3: Create API response models**

```python
# src/claude_teams/api_models.py
from __future__ import annotations

from pydantic import BaseModel


class TeamSummary(BaseModel):
    name: str
    description: str


class MessageOut(BaseModel):
    from_: str
    text: str
    timestamp: str
    read: bool
    summary: str | None = None
    color: str | None = None

    class Config:
        populate_by_name = True
```

- [ ] **Step 4: Create FastAPI app with endpoints**

```python
# src/claude_teams/api.py
from __future__ import annotations

from fastapi import FastAPI, HTTPException
from fastapi.middleware.cors import CORSMiddleware

from claude_teams import messaging, tasks, teams
from claude_teams.api_models import MessageOut, TeamSummary
from claude_teams.models import TeammateMember

app = FastAPI(title="ACPX Teams Monitor API")

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"],
    allow_methods=["GET"],
    allow_headers=["*"],
)


@app.get("/api/teams")
def list_teams() -> list[TeamSummary]:
    """List all teams."""
    teams_dir = teams.TEAMS_DIR
    if not teams_dir.exists():
        return []
    result = []
    for d in sorted(teams_dir.iterdir()):
        config_path = d / "config.json"
        if config_path.exists():
            try:
                config = teams.read_config(d.name)
                result.append(TeamSummary(name=config.name, description=config.description))
            except Exception:
                continue
    return result


@app.get("/api/teams/{team_name}/config")
def get_team_config(team_name: str) -> dict:
    """Get full team configuration."""
    try:
        config = teams.read_config(team_name)
    except FileNotFoundError:
        raise HTTPException(status_code=404, detail=f"Team '{team_name}' not found")
    return config.model_dump(by_alias=True)


@app.get("/api/teams/{team_name}/tasks")
def get_team_tasks(team_name: str) -> list[dict]:
    """List all tasks for a team."""
    try:
        result = tasks.list_tasks(team_name)
    except ValueError as e:
        raise HTTPException(status_code=404, detail=str(e))
    return [t.model_dump(by_alias=True, exclude_none=True) for t in result]


@app.get("/api/teams/{team_name}/tasks/{task_id}")
def get_task_detail(team_name: str, task_id: str) -> dict:
    """Get full task details."""
    try:
        task = tasks.get_task(team_name, task_id)
    except FileNotFoundError:
        raise HTTPException(status_code=404, detail=f"Task '{task_id}' not found")
    return task.model_dump(by_alias=True, exclude_none=True)


@app.get("/api/teams/{team_name}/messages")
def get_team_messages(team_name: str) -> list[dict]:
    """Get all messages from team-lead inbox (read-only, does NOT mark as read)."""
    try:
        config = teams.read_config(team_name)
    except FileNotFoundError:
        raise HTTPException(status_code=404, detail=f"Team '{team_name}' not found")
    msgs = messaging.read_inbox(team_name, "team-lead", unread_only=False, mark_as_read=False)
    return [m.model_dump(by_alias=True, exclude_none=True) for m in msgs]
```

- [ ] **Step 5: Run tests to verify they pass**

Run: `cd acpx-teams-mcp && uv add fastapi httpx --dev && uv run pytest tests/test_api.py -v`
Expected: All 5 tests PASS

- [ ] **Step 6: Commit**

```bash
cd acpx-teams-mcp
git add src/claude_teams/api.py src/claude_teams/api_models.py tests/test_api.py
git commit -m "feat: add REST API server for frontend dashboard"
```

---

## Task 2: Frontend Project Scaffolding

**Files:**
- Create: `frontend/package.json`
- Create: `frontend/tsconfig.json`
- Create: `frontend/vite.config.ts`
- Create: `frontend/index.html`
- Create: `frontend/src/main.tsx`
- Create: `frontend/src/styles/index.css`

- [ ] **Step 1: Scaffold Vite + React + TypeScript project**

```bash
cd acpx-teams-mcp
npm create vite@latest frontend -- --template react-ts
cd frontend
npm install
npm install -D tailwindcss @tailwindcss/vite
npm install @tanstack/react-query
```

- [ ] **Step 2: Configure Tailwind**

Replace `frontend/src/index.css` content:

```css
@import "tailwindcss";
```

- [ ] **Step 3: Add Tailwind Vite plugin**

Replace `frontend/vite.config.ts`:

```typescript
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import tailwindcss from "@tailwindcss/vite";

export default defineConfig({
  plugins: [react(), tailwindcss()],
  server: {
    proxy: {
      "/api": "http://localhost:8000",
    },
  },
});
```

- [ ] **Step 4: Verify dev server starts**

```bash
cd frontend && npm run dev
```
Expected: Vite dev server starts on port 5173

- [ ] **Step 5: Commit**

```bash
cd acpx-teams-mcp
git add frontend/
git commit -m "feat: scaffold frontend with Vite + React + TypeScript + Tailwind"
```

---

## Task 3: TypeScript Types & API Client

**Files:**
- Create: `frontend/src/types.ts`
- Create: `frontend/src/api.ts`

- [ ] **Step 1: Define TypeScript interfaces**

```typescript
// frontend/src/types.ts

export interface TeamSummary {
  name: string;
  description: string;
}

export interface LeadMember {
  agentId: string;
  name: string;
  agentType: string;
  model: string;
  joinedAt: number;
  cwd: string;
}

export interface TeammateMember {
  agentId: string;
  name: string;
  agentType: string;
  model: string;
  color: string;
  planModeRequired: boolean;
  joinedAt: number;
  acpxSessionName: string;
  cwd: string;
  isActive: boolean;
}

export type Member = LeadMember | TeammateMember;

export function isTeammate(m: Member): m is TeammateMember {
  return "color" in m;
}

export interface TeamConfig {
  name: string;
  description: string;
  createdAt: number;
  leadAgentId: string;
  leadSessionId: string;
  members: Member[];
}

export interface Task {
  id: string;
  subject: string;
  description: string;
  activeForm: string;
  status: "pending" | "in_progress" | "completed" | "deleted";
  blocks: string[];
  blockedBy: string[];
  owner: string | null;
  metadata: Record<string, unknown> | null;
}

export interface Message {
  from: string;
  text: string;
  timestamp: string;
  read: boolean;
  summary: string | null;
  color: string | null;
}

export type ViewMode = "members" | "tasks";

export type AgentStatus = "running" | "idle" | "dead" | "no-session";
```

- [ ] **Step 2: Create API client**

```typescript
// frontend/src/api.ts

import type { TeamSummary, TeamConfig, Task, Message } from "./types";

const BASE = "/api";

async function fetchJSON<T>(url: string): Promise<T> {
  const res = await fetch(`${BASE}${url}`);
  if (!res.ok) {
    throw new Error(`API error: ${res.status} ${res.statusText}`);
  }
  return res.json();
}

export function fetchTeams(): Promise<TeamSummary[]> {
  return fetchJSON("/teams");
}

export function fetchTeamConfig(teamName: string): Promise<TeamConfig> {
  return fetchJSON(`/teams/${teamName}/config`);
}

export function fetchTeamTasks(teamName: string): Promise<Task[]> {
  return fetchJSON(`/teams/${teamName}/tasks`);
}

export function fetchTaskDetail(teamName: string, taskId: string): Promise<Task> {
  return fetchJSON(`/teams/${teamName}/tasks/${taskId}`);
}

export function fetchTeamMessages(teamName: string): Promise<Message[]> {
  return fetchJSON(`/teams/${teamName}/messages`);
}
```

- [ ] **Step 3: Commit**

```bash
cd acpx-teams-mcp
git add frontend/src/types.ts frontend/src/api.ts
git commit -m "feat: add TypeScript types and API client"
```

---

## Task 4: React Query Hooks

**Files:**
- Create: `frontend/src/hooks/useTeams.ts`
- Create: `frontend/src/hooks/useTeamDetail.ts`
- Create: `frontend/src/hooks/useMessages.ts`

- [ ] **Step 1: Create teams list hook**

```typescript
// frontend/src/hooks/useTeams.ts

import { useQuery } from "@tanstack/react-query";
import { fetchTeams } from "../api";

export function useTeams() {
  return useQuery({
    queryKey: ["teams"],
    queryFn: fetchTeams,
    refetchInterval: 10_000,
  });
}
```

- [ ] **Step 2: Create team detail hook (config + tasks)**

```typescript
// frontend/src/hooks/useTeamDetail.ts

import { useQuery } from "@tanstack/react-query";
import { fetchTeamConfig, fetchTeamTasks } from "../api";

export function useTeamConfig(teamName: string | null) {
  return useQuery({
    queryKey: ["teamConfig", teamName],
    queryFn: () => fetchTeamConfig(teamName!),
    enabled: !!teamName,
    refetchInterval: 10_000,
  });
}

export function useTeamTasks(teamName: string | null) {
  return useQuery({
    queryKey: ["teamTasks", teamName],
    queryFn: () => fetchTeamTasks(teamName!),
    enabled: !!teamName,
    refetchInterval: 10_000,
  });
}
```

- [ ] **Step 3: Create messages hook**

```typescript
// frontend/src/hooks/useMessages.ts

import { useQuery } from "@tanstack/react-query";
import { fetchTeamMessages } from "../api";

export function useMessages(teamName: string | null) {
  return useQuery({
    queryKey: ["messages", teamName],
    queryFn: () => fetchTeamMessages(teamName!),
    enabled: !!teamName,
    refetchInterval: 5_000,
  });
}
```

- [ ] **Step 4: Commit**

```bash
cd acpx-teams-mcp
git add frontend/src/hooks/
git commit -m "feat: add React Query polling hooks"
```

---

## Task 5: Layout Shell & Team Sidebar

**Files:**
- Create: `frontend/src/App.tsx`
- Create: `frontend/src/components/TopBar.tsx`
- Create: `frontend/src/components/TeamSidebar.tsx`
- Create: `frontend/src/components/TeamItem.tsx`
- Create: `frontend/src/components/StatusDot.tsx`
- Modify: `frontend/src/main.tsx`

- [ ] **Step 1: Create StatusDot component**

```tsx
// frontend/src/components/StatusDot.tsx

import type { AgentStatus } from "../types";

const STATUS_COLORS: Record<string, string> = {
  running: "bg-emerald-400",   // #34D399
  idle: "bg-amber-400",        // #FBBF24
  dead: "bg-red-400",          // #F87171
  "no-session": "bg-gray-400",
  online: "bg-emerald-400",
  warning: "bg-amber-400",
  offline: "bg-gray-300",
};

export function StatusDot({ status, size = "sm" }: { status: string; size?: "sm" | "md" }) {
  const sizeClass = size === "sm" ? "w-2 h-2" : "w-3 h-3";
  const colorClass = STATUS_COLORS[status] ?? "bg-gray-400";
  return <span className={`inline-block rounded-full ${sizeClass} ${colorClass}`} />;
}
```

- [ ] **Step 2: Create TopBar component**

```tsx
// frontend/src/components/TopBar.tsx

import { useQueryClient } from "@tanstack/react-query";

export function TopBar({ lastRefresh }: { lastRefresh: Date | null }) {
  const queryClient = useQueryClient();

  function handleRefresh() {
    queryClient.invalidateQueries();
  }

  return (
    <header className="flex items-center justify-between px-4 py-3 bg-white border-b border-gray-200">
      <h1 className="text-lg font-bold text-gray-800">ACPX Teams Monitor</h1>
      <div className="flex items-center gap-3">
        {lastRefresh && (
          <span className="text-xs text-gray-400">
            Last refresh: {lastRefresh.toLocaleTimeString()}
          </span>
        )}
        <button
          onClick={handleRefresh}
          className="px-3 py-1.5 text-sm bg-blue-50 text-blue-600 rounded hover:bg-blue-100 transition"
        >
          Refresh
        </button>
      </div>
    </header>
  );
}
```

- [ ] **Step 3: Create TeamItem component**

```tsx
// frontend/src/components/TeamItem.tsx

import { StatusDot } from "./StatusDot";

interface TeamItemProps {
  name: string;
  isSelected: boolean;
  onSelect: () => void;
}

export function TeamItem({ name, isSelected, onSelect }: TeamItemProps) {
  return (
    <button
      onClick={onSelect}
      className={`w-full text-left px-3 py-2 rounded-md transition text-sm ${
        isSelected
          ? "bg-blue-50 text-blue-700 font-medium"
          : "text-gray-700 hover:bg-gray-100"
      }`}
    >
      <div className="flex items-center gap-2">
        <StatusDot status="online" size="sm" />
        <span className="truncate">{name}</span>
      </div>
    </button>
  );
}
```

- [ ] **Step 4: Create TeamSidebar component**

```tsx
// frontend/src/components/TeamSidebar.tsx

import type { TeamSummary } from "../types";
import { TeamItem } from "./TeamItem";

interface TeamSidebarProps {
  teams: TeamSummary[];
  selectedTeam: string | null;
  onSelectTeam: (name: string) => void;
}

export function TeamSidebar({ teams, selectedTeam, onSelectTeam }: TeamSidebarProps) {
  return (
    <aside className="w-56 bg-gray-50 border-r border-gray-200 flex flex-col">
      <div className="px-3 py-3 border-b border-gray-200">
        <h2 className="text-xs font-semibold text-gray-500 uppercase tracking-wider">Teams</h2>
      </div>
      <nav className="flex-1 overflow-y-auto p-2 space-y-1">
        {teams.map((team) => (
          <TeamItem
            key={team.name}
            name={team.name}
            isSelected={team.name === selectedTeam}
            onSelect={() => onSelectTeam(team.name)}
          />
        ))}
        {teams.length === 0 && (
          <p className="text-sm text-gray-400 px-3 py-4">No teams found</p>
        )}
      </nav>
    </aside>
  );
}
```

- [ ] **Step 5: Create App.tsx layout shell**

```tsx
// frontend/src/App.tsx

import { useState } from "react";
import { TopBar } from "./components/TopBar";
import { TeamSidebar } from "./components/TeamSidebar";
import { useTeams } from "./hooks/useTeams";
import type { ViewMode } from "./types";

export default function App() {
  const [selectedTeam, setSelectedTeam] = useState<string | null>(null);
  const [viewMode, setViewMode] = useState<ViewMode>("members");
  const { data: teams = [] } = useTeams();

  // Auto-select first team
  if (teams.length > 0 && selectedTeam === null) {
    setSelectedTeam(teams[0].name);
  }

  return (
    <div className="h-screen flex flex-col bg-gray-50">
      <TopBar lastRefresh={new Date()} />
      <div className="flex flex-1 overflow-hidden">
        <TeamSidebar
          teams={teams}
          selectedTeam={selectedTeam}
          onSelectTeam={setSelectedTeam}
        />
        <main className="flex-1 flex overflow-hidden">
          <div className="flex-1 p-4 overflow-auto">
            {selectedTeam ? (
              <p className="text-gray-500">Team: {selectedTeam} / View: {viewMode}</p>
            ) : (
              <p className="text-gray-400 text-center mt-20">Select a team from the sidebar</p>
            )}
          </div>
        </main>
      </div>
    </div>
  );
}
```

- [ ] **Step 6: Update main.tsx with QueryClient**

```tsx
// frontend/src/main.tsx

import React from "react";
import ReactDOM from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import App from "./App";
import "./index.css";

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      retry: 1,
      staleTime: 3_000,
    },
  },
});

ReactDOM.createRoot(document.getElementById("root")!).render(
  <React.StrictMode>
    <QueryClientProvider client={queryClient}>
      <App />
    </QueryClientProvider>
  </React.StrictMode>,
);
```

- [ ] **Step 7: Verify the app renders**

```bash
cd frontend && npm run dev
```
Expected: App renders with top bar, left sidebar (empty or with teams if API running), and main area.

- [ ] **Step 8: Commit**

```bash
cd acpx-teams-mcp
git add frontend/src/
git commit -m "feat: add layout shell with team sidebar and top bar"
```

---

## Task 6: Stats Bar & View Tabs

**Files:**
- Create: `frontend/src/components/StatsBar.tsx`
- Create: `frontend/src/components/ViewTabs.tsx`
- Modify: `frontend/src/App.tsx`

- [ ] **Step 1: Create StatsBar component**

```tsx
// frontend/src/components/StatsBar.tsx

interface StatsBarProps {
  members: number;
  online: number;
  tasks: number;
  blocked: number;
  unread: number;
}

function StatBadge({ label, value, color = "gray" }: { label: string; value: number; color?: string }) {
  const colorMap: Record<string, string> = {
    gray: "bg-gray-100 text-gray-700",
    green: "bg-emerald-50 text-emerald-700",
    red: "bg-red-50 text-red-700",
    blue: "bg-blue-50 text-blue-700",
  };
  return (
    <div className={`px-3 py-1.5 rounded-md text-sm ${colorMap[color] ?? colorMap.gray}`}>
      <span className="font-semibold">{value}</span>
      <span className="ml-1 text-xs opacity-75">{label}</span>
    </div>
  );
}

export function StatsBar({ members, online, tasks, blocked, unread }: StatsBarProps) {
  return (
    <div className="flex items-center gap-2 flex-wrap">
      <StatBadge label="Members" value={members} />
      <StatBadge label="Online" value={online} color="green" />
      <StatBadge label="Tasks" value={tasks} />
      <StatBadge label="Blocked" value={blocked} color={blocked > 0 ? "red" : "gray"} />
      <StatBadge label="Unread" value={unread} color={unread > 0 ? "blue" : "gray"} />
    </div>
  );
}
```

- [ ] **Step 2: Create ViewTabs component**

```tsx
// frontend/src/components/ViewTabs.tsx

import type { ViewMode } from "../types";

interface ViewTabsProps {
  viewMode: ViewMode;
  onChangeView: (mode: ViewMode) => void;
}

export function ViewTabs({ viewMode, onChangeView }: ViewTabsProps) {
  return (
    <div className="flex gap-1 bg-gray-100 rounded-lg p-1">
      <button
        onClick={() => onChangeView("members")}
        className={`px-4 py-1.5 text-sm rounded-md transition ${
          viewMode === "members"
            ? "bg-white text-gray-800 shadow-sm font-medium"
            : "text-gray-500 hover:text-gray-700"
        }`}
      >
        Members
      </button>
      <button
        onClick={() => onChangeView("tasks")}
        className={`px-4 py-1.5 text-sm rounded-md transition ${
          viewMode === "tasks"
            ? "bg-white text-gray-800 shadow-sm font-medium"
            : "text-gray-500 hover:text-gray-700"
        }`}
      >
        Tasks
      </button>
    </div>
  );
}
```

- [ ] **Step 3: Integrate StatsBar and ViewTabs into App.tsx**

Update the main area in `App.tsx` to include StatsBar and ViewTabs above the kanban placeholder. Import `useTeamConfig`, `useTeamTasks`, `useMessages`, `StatsBar`, `ViewTabs`, and compute stats from data.

```tsx
// In App.tsx, replace the main content area:
import { StatsBar } from "./components/StatsBar";
import { ViewTabs } from "./components/ViewTabs";
import { useTeamConfig } from "./hooks/useTeamDetail";
import { useTeamTasks } from "./hooks/useTeamDetail";
import { useMessages } from "./hooks/useMessages";
import { isTeammate } from "./types";

// Inside the component, add hooks:
const { data: config } = useTeamConfig(selectedTeam);
const { data: taskList = [] } = useTeamTasks(selectedTeam);
const { data: messages = [] } = useMessages(selectedTeam);

const teammates = config?.members.filter(isTeammate) ?? [];
const activeTasks = taskList.filter((t) => t.status !== "deleted");
const blockedTasks = activeTasks.filter((t) => t.blockedBy.length > 0);
const unreadMessages = messages.filter((m) => !m.read);

// Replace the main area JSX:
<div className="flex-1 p-4 overflow-auto">
  {selectedTeam && config ? (
    <div className="space-y-4">
      <div className="flex items-center justify-between">
        <StatsBar
          members={teammates.length}
          online={0}
          tasks={activeTasks.length}
          blocked={blockedTasks.length}
          unread={unreadMessages.length}
        />
        <ViewTabs viewMode={viewMode} onChangeView={setViewMode} />
      </div>
      <p className="text-gray-400">Kanban board placeholder ({viewMode})</p>
    </div>
  ) : (
    <p className="text-gray-400 text-center mt-20">Select a team from the sidebar</p>
  )}
</div>
```

- [ ] **Step 4: Commit**

```bash
cd acpx-teams-mcp
git add frontend/src/
git commit -m "feat: add stats bar and view tabs"
```

---

## Task 7: Task Card Component

**Files:**
- Create: `frontend/src/components/TaskCard.tsx`

- [ ] **Step 1: Create adaptive TaskCard**

```tsx
// frontend/src/components/TaskCard.tsx

import { useState } from "react";
import type { Task } from "../types";
import { StatusDot } from "./StatusDot";

const STATUS_LABEL: Record<string, string> = {
  pending: "Pending",
  in_progress: "In Progress",
  completed: "Completed",
};

const STATUS_COLOR: Record<string, string> = {
  pending: "bg-blue-100 text-blue-700",
  in_progress: "bg-emerald-100 text-emerald-700",
  completed: "bg-gray-100 text-gray-600",
};

interface TaskCardProps {
  task: Task;
  expanded?: boolean;
  onToggle?: () => void;
}

export function TaskCard({ task, expanded = false, onToggle }: TaskCardProps) {
  const isBlocked = task.blockedBy.length > 0;

  return (
    <div
      className={`bg-white rounded-lg border p-3 mb-2 transition-shadow hover:shadow-sm ${
        isBlocked ? "border-l-4 border-l-red-400 border-t border-r border-b border-gray-200" : "border-gray-200"
      }`}
    >
      {/* Compact view (always shown) */}
      <div className="flex items-start justify-between gap-2">
        <div className="flex-1 min-w-0">
          <p className="text-sm font-medium text-gray-800 truncate">{task.subject}</p>
          {task.owner && (
            <p className="text-xs text-gray-400 mt-0.5">{task.owner}</p>
          )}
        </div>
        <div className="flex items-center gap-1.5 shrink-0">
          <span className={`text-xs px-1.5 py-0.5 rounded-full ${STATUS_COLOR[task.status] ?? ""}`}>
            {STATUS_LABEL[task.status] ?? task.status}
          </span>
          {onToggle && (
            <button onClick={onToggle} className="text-gray-400 hover:text-gray-600 text-xs">
              {expanded ? "▾" : "▸"}
            </button>
          )}
        </div>
      </div>

      {/* Expanded view */}
      {expanded && (
        <div className="mt-2 pt-2 border-t border-gray-100 space-y-1.5">
          {task.description && (
            <p className="text-xs text-gray-500 line-clamp-2">{task.description}</p>
          )}
          {task.activeForm && (
            <p className="text-xs text-blue-500">Currently: {task.activeForm}</p>
          )}
          {task.blockedBy.length > 0 && (
            <p className="text-xs text-red-500">
              Blocked by: {task.blockedBy.map((id) => `#${id}`).join(", ")}
            </p>
          )}
          {task.blocks.length > 0 && (
            <p className="text-xs text-amber-500">
              Blocks: {task.blocks.map((id) => `#${id}`).join(", ")}
            </p>
          )}
          {task.metadata && Object.keys(task.metadata).length > 0 && (
            <div className="text-xs text-gray-400">
              {Object.entries(task.metadata).map(([k, v]) => (
                <span key={k} className="mr-2">{k}: {String(v)}</span>
              ))}
            </div>
          )}
        </div>
      )}
    </div>
  );
}
```

- [ ] **Step 2: Commit**

```bash
cd acpx-teams-mcp
git add frontend/src/components/TaskCard.tsx
git commit -m "feat: add adaptive TaskCard component"
```

---

## Task 8: Members View (Kanban by Member)

**Files:**
- Create: `frontend/src/components/MemberColumn.tsx`
- Create: `frontend/src/components/MembersView.tsx`

- [ ] **Step 1: Create MemberColumn**

```tsx
// frontend/src/components/MemberColumn.tsx

import type { TeammateMember, Task } from "../types";
import { StatusDot } from "./StatusDot";
import { TaskCard } from "./TaskCard";

interface MemberColumnProps {
  member: TeammateMember;
  tasks: Task[];
  expandedCards: Set<string>;
  onToggleCard: (taskId: string) => void;
}

export function MemberColumn({ member, tasks, expandedCards, onToggleCard }: MemberColumnProps) {
  const memberTasks = tasks.filter((t) => t.owner === member.name && t.status !== "deleted");

  return (
    <div className="flex-shrink-0 w-72 bg-gray-100 rounded-lg flex flex-col max-h-full">
      {/* Column header */}
      <div className="px-3 py-2.5 border-b border-gray-200">
        <div className="flex items-center gap-2">
          <StatusDot status="idle" size="md" />
          <span className="text-sm font-semibold text-gray-800">{member.name}</span>
        </div>
        <div className="flex items-center gap-2 mt-1">
          <span className="text-xs text-gray-400 bg-gray-200 px-1.5 py-0.5 rounded">
            {member.agentType}
          </span>
          <span className="text-xs text-gray-400">{member.model}</span>
        </div>
      </div>

      {/* Task cards */}
      <div className="flex-1 overflow-y-auto p-2">
        {memberTasks.length > 0 ? (
          memberTasks.map((task) => (
            <TaskCard
              key={task.id}
              task={task}
              expanded={expandedCards.has(task.id)}
              onToggle={() => onToggleCard(task.id)}
            />
          ))
        ) : (
          <p className="text-xs text-gray-400 text-center py-4">(no tasks)</p>
        )}
      </div>
    </div>
  );
}
```

- [ ] **Step 2: Create MembersView**

```tsx
// frontend/src/components/MembersView.tsx

import { useState } from "react";
import type { TeammateMember, Task } from "../types";
import { MemberColumn } from "./MemberColumn";

interface MembersViewProps {
  members: TeammateMember[];
  tasks: Task[];
}

export function MembersView({ members, tasks }: MembersViewProps) {
  const [expandedCards, setExpandedCards] = useState<Set<string>>(new Set());
  const [allExpanded, setAllExpanded] = useState(false);

  const activeTasks = tasks.filter((t) => t.status !== "deleted");
  const allTaskIds = activeTasks.map((t) => t.id);

  function toggleCard(taskId: string) {
    setExpandedCards((prev) => {
      const next = new Set(prev);
      next.has(taskId) ? next.delete(taskId) : next.add(taskId);
      return next;
    });
  }

  function toggleAll() {
    if (allExpanded) {
      setExpandedCards(new Set());
    } else {
      setExpandedCards(new Set(allTaskIds));
    }
    setAllExpanded(!allExpanded);
  }

  return (
    <div className="flex flex-col h-full">
      <div className="flex justify-end mb-2">
        <button
          onClick={toggleAll}
          className="text-xs text-gray-500 hover:text-gray-700 px-2 py-1"
        >
          {allExpanded ? "Collapse All" : "Expand All"}
        </button>
      </div>
      <div className="flex gap-3 overflow-x-auto flex-1 pb-2">
        {members.map((member) => (
          <MemberColumn
            key={member.agentId}
            member={member}
            tasks={activeTasks}
            expandedCards={expandedCards}
            onToggleCard={toggleCard}
          />
        ))}
      </div>
    </div>
  );
}
```

- [ ] **Step 3: Commit**

```bash
cd acpx-teams-mcp
git add frontend/src/components/MemberColumn.tsx frontend/src/components/MembersView.tsx
git commit -m "feat: add Members view (kanban by member)"
```

---

## Task 9: Tasks View (Kanban by Status)

**Files:**
- Create: `frontend/src/components/StatusColumn.tsx`
- Create: `frontend/src/components/TasksView.tsx`

- [ ] **Step 1: Create StatusColumn**

```tsx
// frontend/src/components/StatusColumn.tsx

import type { Task } from "../types";
import { TaskCard } from "./TaskCard";

const COLUMN_COLORS: Record<string, string> = {
  pending: "border-t-blue-400",
  in_progress: "border-t-emerald-400",
  completed: "border-t-gray-300",
};

interface StatusColumnProps {
  status: string;
  label: string;
  tasks: Task[];
  expandedCards: Set<string>;
  onToggleCard: (taskId: string) => void;
}

export function StatusColumn({ status, label, tasks, expandedCards, onToggleCard }: StatusColumnProps) {
  return (
    <div className={`flex-shrink-0 w-72 bg-gray-100 rounded-lg flex flex-col max-h-full border-t-4 ${COLUMN_COLORS[status] ?? ""}`}>
      <div className="px-3 py-2.5 border-b border-gray-200 flex items-center justify-between">
        <span className="text-sm font-semibold text-gray-800">{label}</span>
        <span className="text-xs bg-gray-200 text-gray-600 px-2 py-0.5 rounded-full">
          {tasks.length}
        </span>
      </div>
      <div className="flex-1 overflow-y-auto p-2">
        {tasks.length > 0 ? (
          tasks.map((task) => (
            <TaskCard
              key={task.id}
              task={task}
              expanded={expandedCards.has(task.id)}
              onToggle={() => onToggleCard(task.id)}
            />
          ))
        ) : (
          <p className="text-xs text-gray-400 text-center py-4">No tasks</p>
        )}
      </div>
    </div>
  );
}
```

- [ ] **Step 2: Create TasksView**

```tsx
// frontend/src/components/TasksView.tsx

import { useState } from "react";
import type { Task } from "../types";
import { StatusColumn } from "./StatusColumn";

const COLUMNS = [
  { status: "pending", label: "Pending" },
  { status: "in_progress", label: "In Progress" },
  { status: "completed", label: "Completed" },
] as const;

interface TasksViewProps {
  tasks: Task[];
}

export function TasksView({ tasks }: TasksViewProps) {
  const [expandedCards, setExpandedCards] = useState<Set<string>>(new Set());
  const [allExpanded, setAllExpanded] = useState(false);

  const activeTasks = tasks.filter((t) => t.status !== "deleted");
  const allTaskIds = activeTasks.map((t) => t.id);

  function toggleCard(taskId: string) {
    setExpandedCards((prev) => {
      const next = new Set(prev);
      next.has(taskId) ? next.delete(taskId) : next.add(taskId);
      return next;
    });
  }

  function toggleAll() {
    if (allExpanded) {
      setExpandedCards(new Set());
    } else {
      setExpandedCards(new Set(allTaskIds));
    }
    setAllExpanded(!allExpanded);
  }

  return (
    <div className="flex flex-col h-full">
      <div className="flex justify-end mb-2">
        <button
          onClick={toggleAll}
          className="text-xs text-gray-500 hover:text-gray-700 px-2 py-1"
        >
          {allExpanded ? "Collapse All" : "Expand All"}
        </button>
      </div>
      <div className="flex gap-3 overflow-x-auto flex-1 pb-2">
        {COLUMNS.map(({ status, label }) => (
          <StatusColumn
            key={status}
            status={status}
            label={label}
            tasks={activeTasks.filter((t) => t.status === status)}
            expandedCards={expandedCards}
            onToggleCard={toggleCard}
          />
        ))}
      </div>
    </div>
  );
}
```

- [ ] **Step 3: Commit**

```bash
cd acpx-teams-mcp
git add frontend/src/components/StatusColumn.tsx frontend/src/components/TasksView.tsx
git commit -m "feat: add Tasks view (kanban by status)"
```

---

## Task 10: Message Sidebar

**Files:**
- Create: `frontend/src/components/MessageItem.tsx`
- Create: `frontend/src/components/MessageSidebar.tsx`

- [ ] **Step 1: Create MessageItem**

```tsx
// frontend/src/components/MessageItem.tsx

import type { Message } from "../types";

const MEMBER_COLORS: Record<string, string> = {
  blue: "bg-blue-400",
  green: "bg-emerald-400",
  yellow: "bg-amber-400",
  purple: "bg-purple-400",
  orange: "bg-orange-400",
  pink: "bg-pink-400",
  cyan: "bg-cyan-400",
  red: "bg-red-400",
};

const TYPE_ICONS: Record<string, string> = {
  task_assignment: "\uD83D\uDCCB",
  shutdown_request: "\u23F9",
  idle_notification: "\uD83C\uDF19",
};

function getMessageType(text: string): string | null {
  try {
    const parsed = JSON.parse(text);
    return parsed.type ?? null;
  } catch {
    return null;
  }
}

function formatRelativeTime(timestamp: string): string {
  const diff = Date.now() - new Date(timestamp).getTime();
  const minutes = Math.floor(diff / 60000);
  if (minutes < 1) return "just now";
  if (minutes < 60) return `${minutes}m ago`;
  const hours = Math.floor(minutes / 60);
  if (hours < 24) return `${hours}h ago`;
  return `${Math.floor(hours / 24)}d ago`;
}

function getDisplayText(text: string): string {
  try {
    const parsed = JSON.parse(text);
    if (parsed.type === "task_assignment") return `Task assigned: ${parsed.subject}`;
    if (parsed.type === "shutdown_request") return `Shutdown requested: ${parsed.reason}`;
    if (parsed.type === "idle_notification") return `Idle: ${parsed.idleReason ?? "available"}`;
    return text;
  } catch {
    return text;
  }
}

export function MessageItem({ message }: { message: Message }) {
  const msgType = getMessageType(message.text);
  const icon = msgType ? TYPE_ICONS[msgType] : "\uD83D\uDCAC";
  const colorClass = MEMBER_COLORS[message.color ?? ""] ?? "bg-gray-400";

  return (
    <div className={`px-3 py-2 border-b border-gray-100 ${!message.read ? "bg-blue-50" : ""}`}>
      <div className="flex items-center gap-2 mb-1">
        <span className={`w-2 h-2 rounded-full ${colorClass}`} />
        <span className="text-xs font-medium text-gray-700">{message.from}</span>
        <span className="text-xs text-gray-400 ml-auto">{formatRelativeTime(message.timestamp)}</span>
      </div>
      <p className="text-xs text-gray-600 line-clamp-2">
        <span className="mr-1">{icon}</span>
        {getDisplayText(message.text)}
      </p>
    </div>
  );
}
```

- [ ] **Step 2: Create MessageSidebar**

```tsx
// frontend/src/components/MessageSidebar.tsx

import { useState } from "react";
import type { Message } from "../types";
import { MessageItem } from "./MessageItem";

interface MessageSidebarProps {
  messages: Message[];
}

export function MessageSidebar({ messages }: MessageSidebarProps) {
  const [collapsed, setCollapsed] = useState(false);
  const unreadCount = messages.filter((m) => !m.read).length;
  const sorted = [...messages].sort(
    (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()
  );

  if (collapsed) {
    return (
      <aside className="w-10 bg-white border-l border-gray-200 flex flex-col items-center pt-3">
        <button
          onClick={() => setCollapsed(false)}
          className="text-gray-400 hover:text-gray-600 text-sm"
          title="Show Messages"
        >
          &laquo;
        </button>
        {unreadCount > 0 && (
          <span className="mt-2 bg-blue-500 text-white text-xs w-5 h-5 rounded-full flex items-center justify-center">
            {unreadCount}
          </span>
        )}
      </aside>
    );
  }

  return (
    <aside className="w-80 bg-white border-l border-gray-200 flex flex-col">
      <div className="px-3 py-3 border-b border-gray-200 flex items-center justify-between">
        <div className="flex items-center gap-2">
          <h2 className="text-sm font-semibold text-gray-700">Messages</h2>
          <span className="text-xs bg-gray-100 text-gray-500 px-1.5 py-0.5 rounded-full">
            {messages.length}
          </span>
          {unreadCount > 0 && (
            <span className="text-xs bg-blue-500 text-white px-1.5 py-0.5 rounded-full">
              {unreadCount} unread
            </span>
          )}
        </div>
        <button
          onClick={() => setCollapsed(true)}
          className="text-gray-400 hover:text-gray-600 text-sm"
        >
          &raquo;
        </button>
      </div>
      <div className="flex-1 overflow-y-auto">
        {sorted.length > 0 ? (
          sorted.map((msg, i) => <MessageItem key={`${msg.timestamp}-${i}`} message={msg} />)
        ) : (
          <p className="text-xs text-gray-400 text-center py-8">No messages</p>
        )}
      </div>
    </aside>
  );
}
```

- [ ] **Step 3: Commit**

```bash
cd acpx-teams-mcp
git add frontend/src/components/MessageItem.tsx frontend/src/components/MessageSidebar.tsx
git commit -m "feat: add message sidebar with collapsible panel"
```

---

## Task 11: Wire Everything Together in App.tsx

**Files:**
- Modify: `frontend/src/App.tsx`

- [ ] **Step 1: Update App.tsx to include all components**

```tsx
// frontend/src/App.tsx

import { useState } from "react";
import { TopBar } from "./components/TopBar";
import { TeamSidebar } from "./components/TeamSidebar";
import { StatsBar } from "./components/StatsBar";
import { ViewTabs } from "./components/ViewTabs";
import { MembersView } from "./components/MembersView";
import { TasksView } from "./components/TasksView";
import { MessageSidebar } from "./components/MessageSidebar";
import { useTeams } from "./hooks/useTeams";
import { useTeamConfig, useTeamTasks } from "./hooks/useTeamDetail";
import { useMessages } from "./hooks/useMessages";
import { isTeammate } from "./types";
import type { ViewMode } from "./types";

export default function App() {
  const [selectedTeam, setSelectedTeam] = useState<string | null>(null);
  const [viewMode, setViewMode] = useState<ViewMode>("members");
  const { data: teams = [] } = useTeams();
  const { data: config } = useTeamConfig(selectedTeam);
  const { data: taskList = [], dataUpdatedAt } = useTeamTasks(selectedTeam);
  const { data: messages = [] } = useMessages(selectedTeam);

  // Auto-select first team
  if (teams.length > 0 && selectedTeam === null) {
    setSelectedTeam(teams[0].name);
  }

  const teammates = config?.members.filter(isTeammate) ?? [];
  const activeTasks = taskList.filter((t) => t.status !== "deleted");
  const blockedTasks = activeTasks.filter((t) => t.blockedBy.length > 0);
  const unreadMessages = messages.filter((m) => !m.read);

  return (
    <div className="h-screen flex flex-col bg-gray-50">
      <TopBar lastRefresh={dataUpdatedAt ? new Date(dataUpdatedAt) : null} />
      <div className="flex flex-1 overflow-hidden">
        <TeamSidebar
          teams={teams}
          selectedTeam={selectedTeam}
          onSelectTeam={setSelectedTeam}
        />
        <main className="flex-1 flex overflow-hidden">
          <div className="flex-1 flex flex-col p-4 overflow-hidden">
            {selectedTeam && config ? (
              <>
                <div className="flex items-center justify-between mb-4 flex-shrink-0">
                  <StatsBar
                    members={teammates.length}
                    online={0}
                    tasks={activeTasks.length}
                    blocked={blockedTasks.length}
                    unread={unreadMessages.length}
                  />
                  <ViewTabs viewMode={viewMode} onChangeView={setViewMode} />
                </div>
                <div className="flex-1 overflow-hidden">
                  {viewMode === "members" ? (
                    <MembersView members={teammates} tasks={taskList} />
                  ) : (
                    <TasksView tasks={taskList} />
                  )}
                </div>
              </>
            ) : (
              <p className="text-gray-400 text-center mt-20">Select a team from the sidebar</p>
            )}
          </div>
          {selectedTeam && <MessageSidebar messages={messages} />}
        </main>
      </div>
    </div>
  );
}
```

- [ ] **Step 2: Start both servers and verify end-to-end**

Terminal 1 (API server):
```bash
cd acpx-teams-mcp && uv run uvicorn claude_teams.api:app --reload --port 8000
```

Terminal 2 (Frontend):
```bash
cd acpx-teams-mcp/frontend && npm run dev
```

Open http://localhost:5173 — verify three-panel layout renders with data from any existing teams.

- [ ] **Step 3: Commit**

```bash
cd acpx-teams-mcp
git add frontend/src/App.tsx
git commit -m "feat: wire all components into complete dashboard layout"
```

---

## Task 12: Dev Scripts & README

**Files:**
- Modify: `frontend/package.json` (add proxy)
- Create: `frontend/README.md`

- [ ] **Step 1: Add dev script to package.json**

Add to `frontend/package.json` scripts section:
```json
{
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "preview": "vite preview"
  }
}
```

- [ ] **Step 2: Add pyproject.toml entry for API server**

Add `fastapi` and `uvicorn` to the project dependencies in `pyproject.toml`:

```toml
[project.scripts]
acpx-teams-api = "uvicorn:main"
```

Or simply document the run command.

- [ ] **Step 3: Verify full stack works**

```bash
# Terminal 1
cd acpx-teams-mcp && uv add fastapi uvicorn && uv run uvicorn claude_teams.api:app --port 8000

# Terminal 2
cd acpx-teams-mcp/frontend && npm run dev
```

Open http://localhost:5173 and verify:
1. Left sidebar shows team list
2. Clicking team shows stats + kanban
3. Members/Tasks tab switches views
4. Right sidebar shows messages
5. Data auto-refreshes every 5-10s

- [ ] **Step 4: Commit**

```bash
cd acpx-teams-mcp
git add -A
git commit -m "feat: complete ACPX Teams Monitor v1 dashboard"
```
