diff --git a/.coderabbit.yaml b/.coderabbit.yaml new file mode 100644 index 0000000..49998de --- /dev/null +++ b/.coderabbit.yaml @@ -0,0 +1,79 @@ +# yaml-language-server: $schema=https://coderabbit.ai/integrations/schema.v2.json +# AIOX Dashboard CodeRabbit Configuration + +language: "en-US" +tone_instructions: "Be professional, constructive, and focused on code quality." +early_access: false + +reviews: + auto_review: + enabled: true + base_branches: + - main + drafts: false + + request_changes_workflow: true + high_level_summary: true + poem: false + review_status: true + collapse_walkthrough: false + + path_instructions: + - path: "src/app/api/**" + instructions: | + API routes must use resolveProjectRoot() from @/lib/project-registry, never a local getProjectRoot(). + Verify error handling returns proper HTTP status codes. + Check for path traversal vulnerabilities when resolving file paths. + Ensure async operations have try/catch. + + - path: "src/lib/**" + instructions: | + Shared utilities are consumed by all API routes. + Verify backwards compatibility on any changes. + Check for proper input validation. + Ensure caching logic has proper TTL and invalidation. + + - path: "src/components/**" + instructions: | + Components should use TypeScript interfaces for props. + Check for proper memo usage on heavy renders. + Verify accessibility attributes (aria-labels, roles). + Ensure Tailwind classes follow project conventions. + + - path: "server/**" + instructions: | + Monitor server runs on Bun. + Verify WebSocket handling is robust (reconnection, cleanup). + Check SQLite queries for injection vulnerabilities. + + - path: "docs/prd-*.md" + instructions: | + PRD documents must follow AIOX PRD structure. + Verify Goals section with clear objectives and Background Context. + Check Functional Requirements (FR) and Non-Functional Requirements (NFR) are numbered. + Ensure Change Log table exists with Date, Version, Description, Author. + Check for Mermaid diagrams illustrating architecture and data flows. + Verify Technical Assumptions section is present and accurate. + Ensure acceptance criteria are testable. + + - path: "docs/*architecture*" + instructions: | + Architecture documents must include Mermaid diagrams for visual clarity. + Verify component relationships and data flows are documented. + Check that technology choices are justified. + Ensure diagrams match the actual codebase structure. + + - path: "**/*.ts" + instructions: | + No 'any' types β€” use proper typing or 'unknown' with guards. + Verify async/await patterns and no unhandled rejections. + Ensure absolute imports via @/ path alias. + + - path: "**/*.tsx" + instructions: | + React components must be client components ('use client') when using hooks. + Check for proper error boundaries. + Verify loading states are handled. + +chat: + auto_reply: true diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS deleted file mode 100644 index c7f0e68..0000000 --- a/.github/CODEOWNERS +++ /dev/null @@ -1,45 +0,0 @@ -# AIOS Dashboard Code Owners -# Last Updated: 2026-03-04 -# -# Format: -# See: https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners -# -# Owners: -# @Pedrovaleriolopez (lead maintainer) -# @oalanicolas (maintainer) - -# ============================================ -# Default Owner (fallback) -# ============================================ -* @Pedrovaleriolopez @oalanicolas - -# ============================================ -# Critical Paths -# ============================================ -# Source code -src/ @Pedrovaleriolopez @oalanicolas - -# Server-side code -server/ @Pedrovaleriolopez @oalanicolas - -# CI/CD configurations -.github/ @Pedrovaleriolopez @oalanicolas - -# ============================================ -# Infrastructure & Configuration -# ============================================ -package.json @Pedrovaleriolopez @oalanicolas -package-lock.json @Pedrovaleriolopez @oalanicolas -next.config.ts @Pedrovaleriolopez @oalanicolas -tsconfig.json @Pedrovaleriolopez @oalanicolas -eslint.config.* @Pedrovaleriolopez @oalanicolas -vitest.config.ts @Pedrovaleriolopez @oalanicolas -postcss.config.mjs @Pedrovaleriolopez @oalanicolas - -# ============================================ -# Security-Sensitive Files -# ============================================ -.github/CODEOWNERS @Pedrovaleriolopez @oalanicolas -.env* @Pedrovaleriolopez @oalanicolas -*.config.js @Pedrovaleriolopez @oalanicolas -*.config.ts @Pedrovaleriolopez @oalanicolas diff --git a/.gitignore b/.gitignore index 5ef6a52..14780dc 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,5 @@ yarn-error.log* # typescript *.tsbuildinfo next-env.d.ts +server/node_modules/ +server/bun.lock diff --git a/README.md b/README.md index 6499eb0..f73f5b7 100644 --- a/README.md +++ b/README.md @@ -1,668 +1,93 @@ -# AIOS Dashboard: Observability Extension +# AIOX Dashboard -[![Synkra AIOS](https://img.shields.io/badge/Synkra-AIOS-blue.svg)](https://github.com/SynkraAI/aios-core) -[![LicenΓ§a: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) -[![Status](https://img.shields.io/badge/status-early%20development-orange.svg)]() -[![Contributions Welcome](https://img.shields.io/badge/contributions-welcome-brightgreen.svg)](https://github.com/SynkraAI/aios-dashboard/issues) - -**Interface visual para observar seu projeto AIOS em tempo real.** - -> 🚧 **FASE INICIAL DE DESENVOLVIMENTO** -> -> Este projeto estΓ‘ em construΓ§Γ£o ativa. Funcionalidades podem mudar, quebrar ou estar incompletas. -> **ColaboraΓ§Γ΅es sΓ£o muito bem-vindas!** Veja as [issues abertas](https://github.com/SynkraAI/aios-dashboard/issues) ou abra uma nova para sugerir melhorias. - -> ⚠️ **Este projeto Γ© uma extensΓ£o OPCIONAL.** O [Synkra AIOS](https://github.com/SynkraAI/aios-core) funciona 100% sem ele. O Dashboard existe apenas para **observar** o que acontece na CLI β€” ele nunca controla. +**Real-time observability interface for AIOX projects.** --- -## O que Γ© o AIOS Dashboard? - -O AIOS Dashboard Γ© uma **interface web** que permite visualizar em tempo real tudo que acontece no seu projeto AIOS: +## Features -- πŸ“‹ **Stories** no formato Kanban (arrastar e soltar) -- πŸ€– **Agentes** ativos e inativos -- πŸ“‘ **Eventos em tempo real** do Claude Code (qual tool estΓ‘ executando, prompts, etc) -- πŸ”§ **Squads** instalados com seus agentes, tasks e workflows -- πŸ“Š **Insights** e estatΓ­sticas do projeto - -### Screenshot das Funcionalidades - -``` -β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β” -β”‚ AIOS Dashboard [Settings] β”‚ -β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ -β”‚ β”‚ β”‚ -β”‚ Kanban β”‚ β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β” β”‚ -β”‚ Monitor β”‚ β”‚ Backlog β”‚ β”‚ Doing β”‚ β”‚ Done β”‚ β”‚ -β”‚ Agents β”‚ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€ β”‚ -β”‚ Squads β”‚ β”‚ Story 1 β”‚ β”‚ Story 3 β”‚ β”‚ Story 5 β”‚ β”‚ -β”‚ Bob β”‚ β”‚ Story 2 β”‚ β”‚ Story 4 β”‚ β”‚ Story 6 β”‚ β”‚ -β”‚ ... β”‚ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ β”‚ -β”‚ β”‚ β”‚ -β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”΄β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜ -``` +| View | Description | +|------|-------------| +| **Kanban** | Story board with drag-and-drop (Backlog, Doing, Done) | +| **Monitor** | Real-time event feed from Claude Code (tools, prompts, errors) | +| **Agents** | AIOX agents (@dev, @qa, @architect, etc) β€” active and standby | +| **Squads** | Visual org chart of installed squads with drill-down to agents and tasks | +| **Bob** | Bob Orchestrator execution tracking (autonomous dev pipeline) | +| **Roadmap** | Planned features visualization | +| **GitHub** | GitHub integration (PRs, issues) | +| **Insights** | Project statistics and metrics | +| **Context** | Active rules, agent definitions, MCP servers | +| **Plans** | Task plans from .aiox-core | +| **PRDs** | Product requirement documents | +| **QA** | Quality assurance metrics | +| **Terminals** | Multi-terminal grid | +| **Settings** | Dashboard configuration | --- -## Funcionalidades - -| View | O que faz | -|------|-----------| -| **Kanban** | Board de stories com drag-and-drop entre colunas (Backlog, Doing, Done) | -| **Monitor** | Feed em tempo real de eventos do Claude Code (tools, prompts, erros) | -| **Agents** | Lista de agentes AIOS (@dev, @qa, @architect, etc) β€” ativos e em standby | -| **Squads** | Organograma visual dos squads instalados com drill-down para agentes e tasks | -| **Bob** | Acompanha execuΓ§Γ£o do Bob Orchestrator (pipeline de desenvolvimento autΓ΄nomo) | -| **Roadmap** | VisualizaΓ§Γ£o de features planejadas | -| **GitHub** | IntegraΓ§Γ£o com GitHub (PRs, issues) | -| **Insights** | EstatΓ­sticas e mΓ©tricas do projeto | -| **Terminals** | Grid de mΓΊltiplos terminais | -| **Settings** | ConfiguraΓ§Γ΅es do dashboard | - ---- - -## Requisito: Projeto com AIOS Instalado - -O Dashboard **precisa estar dentro de um projeto com AIOS instalado** porque ele lΓͺ os documentos do framework. - -``` -meu-projeto/ # ← VocΓͺ executa comandos daqui -β”œβ”€β”€ .aios-core/ # Core do framework (OBRIGATΓ“RIO) -β”‚ └── development/ -β”‚ β”œβ”€β”€ agents/ # Agentes que aparecem na view "Agents" -β”‚ β”œβ”€β”€ tasks/ # Tasks dos squads -β”‚ └── templates/ -β”œβ”€β”€ docs/ -β”‚ └── stories/ # Stories que aparecem no "Kanban" -β”‚ β”œβ”€β”€ active/ -β”‚ └── completed/ -β”œβ”€β”€ squads/ # Squads que aparecem na view "Squads" -β”‚ β”œβ”€β”€ meu-squad/ -β”‚ └── outro-squad/ -β”œβ”€β”€ apps/ -β”‚ └── dashboard/ # ← Dashboard instalado aqui -└── package.json -``` - -**Sem o AIOS instalado, o dashboard mostrarΓ‘ telas vazias.** - ---- - -## InstalaΓ§Γ£o Passo a Passo - -> **IMPORTANTE:** Todos os comandos sΓ£o executados a partir da **raiz do seu projeto** (`meu-projeto/`). - -### PrΓ©-requisitos - -Antes de comeΓ§ar, vocΓͺ precisa ter: - -- βœ… [Node.js](https://nodejs.org/) versΓ£o 18 ou superior -- βœ… [Bun](https://bun.sh/) (para o servidor de eventos) -- βœ… [Synkra AIOS](https://github.com/SynkraAI/aios-core) instalado no projeto - -### Passo 1: Instale o AIOS (se ainda nΓ£o tiver) - -```bash -# OpΓ§Γ£o A: Criar novo projeto com AIOS -npx aios-core init meu-projeto -cd meu-projeto - -# OpΓ§Γ£o B: Instalar em projeto existente -cd meu-projeto -npx aios-core install -``` - -### Passo 2: Clone o Dashboard - -```bash -# Cria a pasta apps/ e clona o dashboard -mkdir -p apps -git clone https://github.com/SynkraAI/aios-dashboard.git apps/dashboard -``` - -### Passo 3: Instale as dependΓͺncias - -```bash -# DependΓͺncias do Dashboard (Next.js) -npm install --prefix apps/dashboard - -# DependΓͺncias do Server (Bun) -cd apps/dashboard/server -bun install -cd ../../.. -``` - -### Passo 4: Inicie o Server de Eventos - -O server captura eventos em tempo real do Claude Code. - -```bash -cd apps/dashboard/server -bun run dev -``` - -VocΓͺ verΓ‘: -``` -πŸš€ Monitor Server running on http://localhost:4001 -``` - -> **Deixe este terminal aberto** e abra um novo para o prΓ³ximo passo. - -### Passo 5: Inicie o Dashboard - -Em um **novo terminal**: - -```bash -npm run dev --prefix apps/dashboard -``` - -VocΓͺ verΓ‘: -``` -β–² Next.js 14.x.x -- Local: http://localhost:3000 -``` - -### Passo 6: Acesse o Dashboard - -Abra no navegador: **http://localhost:3000** - -πŸŽ‰ **Pronto!** VocΓͺ verΓ‘ o dashboard com suas stories, squads e agentes. - ---- - -## Passo Extra: Eventos em Tempo Real - -Para ver eventos do Claude Code em tempo real (qual ferramenta estΓ‘ executando, prompts, etc), instale os hooks: - -```bash -apps/dashboard/scripts/install-hooks.sh -``` - -Isso instala hooks em `~/.claude/hooks/` que enviam eventos para o dashboard. - -**Eventos capturados:** -- `PreToolUse` β€” Antes de executar uma ferramenta -- `PostToolUse` β€” ApΓ³s executar (com resultado) -- `UserPromptSubmit` β€” Quando vocΓͺ envia um prompt -- `Stop` β€” Quando Claude para -- `SubagentStop` β€” Quando um subagent (Task) termina - ---- - -## Comandos RΓ‘pidos - -Cole estes comandos no terminal (execute da raiz do projeto): - -```bash -# ===== INSTALAÇÃO ===== -mkdir -p apps -git clone https://github.com/SynkraAI/aios-dashboard.git apps/dashboard -npm install --prefix apps/dashboard -cd apps/dashboard/server && bun install && cd ../../.. - -# ===== INICIAR (2 terminais) ===== - -# Terminal 1: Server de eventos -cd apps/dashboard/server && bun run dev - -# Terminal 2: Dashboard -npm run dev --prefix apps/dashboard - -# ===== EXTRAS ===== - -# Instalar hooks para eventos em tempo real -apps/dashboard/scripts/install-hooks.sh - -# Verificar se server estΓ‘ rodando -curl http://localhost:4001/health -``` - ---- - -## Estrutura do Projeto - -``` -apps/dashboard/ -β”œβ”€β”€ src/ -β”‚ β”œβ”€β”€ app/ # PΓ‘ginas Next.js -β”‚ β”œβ”€β”€ components/ -β”‚ β”‚ β”œβ”€β”€ kanban/ # Board de stories -β”‚ β”‚ β”œβ”€β”€ monitor/ # Feed de eventos em tempo real -β”‚ β”‚ β”œβ”€β”€ agents/ # VisualizaΓ§Γ£o de agentes -β”‚ β”‚ β”œβ”€β”€ squads/ # Organograma de squads -β”‚ β”‚ β”œβ”€β”€ bob/ # OrquestraΓ§Γ£o Bob -β”‚ β”‚ └── ui/ # Componentes de UI -β”‚ β”œβ”€β”€ hooks/ # React hooks customizados -β”‚ β”œβ”€β”€ stores/ # Estado global (Zustand) -β”‚ └── lib/ # UtilitΓ‘rios -β”œβ”€β”€ server/ # Servidor de eventos (Bun) -β”‚ β”œβ”€β”€ server.ts # Servidor principal -β”‚ β”œβ”€β”€ db.ts # Banco SQLite -β”‚ └── types.ts # Tipos TypeScript -└── scripts/ - └── install-hooks.sh # Instalador de hooks -``` - ---- - -## PosiΓ§Γ£o na Arquitetura AIOS - -O Synkra AIOS segue uma hierarquia clara: - -``` -CLI First β†’ Observability Second β†’ UI Third -``` - -| Camada | Prioridade | O que faz | -| ----------------- | ---------- | --------------------------------------------------- | -| **CLI** | MΓ‘xima | Onde a inteligΓͺncia vive. Toda execuΓ§Γ£o e decisΓ΅es. | -| **Observability** | SecundΓ‘ria | Observar o que acontece no CLI em tempo real. | -| **UI** | TerciΓ‘ria | VisualizaΓ§Γ΅es e gestΓ£o pontual. | - -**Este Dashboard opera na camada de Observability.** Ele observa, mas nunca controla. - ---- - -## API do Server - -O server expΓ΅e endpoints para o dashboard consumir: - -| Endpoint | MΓ©todo | DescriΓ§Γ£o | -| -------------------------- | --------- | ------------------------------- | -| `POST /events` | POST | Recebe eventos dos hooks | -| `GET /events/recent` | GET | Últimos eventos | -| `GET /sessions` | GET | Lista sessΓ΅es do Claude Code | -| `GET /stats` | GET | EstatΓ­sticas agregadas | -| `WS /stream` | WebSocket | Stream de eventos em tempo real | -| `GET /health` | GET | Verifica se server estΓ‘ ok | - ---- - -## ConfiguraΓ§Γ£o - -Crie o arquivo `apps/dashboard/.env.local`: - -```bash -# Porta do server de eventos -MONITOR_PORT=4001 - -# Onde salvar o banco SQLite -MONITOR_DB=~/.aios/monitor/events.db - -# URL do WebSocket (usado pelo dashboard) -NEXT_PUBLIC_MONITOR_WS_URL=ws://localhost:4001/stream -``` - ---- - -## Troubleshooting - -### "Dashboard mostra telas vazias" +## Architecture -O AIOS nΓ£o estΓ‘ instalado. Verifique: - -```bash -ls -la .aios-core/ # Deve existir -ls -la docs/stories/ # Deve ter arquivos -ls -la squads/ # Deve ter squads ``` - -Se nΓ£o existir, instale o AIOS: `npx aios-core install` - -### "Monitor nΓ£o mostra eventos" - -1. Server estΓ‘ rodando? - ```bash - curl http://localhost:4001/health - # Deve retornar: {"status":"ok"} - ``` - -2. Hooks estΓ£o instalados? - ```bash - ls ~/.claude/hooks/ - # Deve ter arquivos .py - ``` - -3. Reinstale os hooks: - ```bash - apps/dashboard/scripts/install-hooks.sh - ``` - -### "Erro ao iniciar o server" - -Bun nΓ£o estΓ‘ instalado. Instale em: https://bun.sh - -```bash -curl -fsSL https://bun.sh/install | bash +CLI First > Observability Second > UI Third ``` -### "Porta 3000/4001 em uso" - -Encerre o processo que estΓ‘ usando a porta: - -```bash -# Descobrir qual processo -lsof -i :3000 -lsof -i :4001 - -# Matar o processo (substitua PID) -kill -9 -``` +This dashboard operates in the **Observability** layer. It observes but never controls. --- -## QA: Verificando se Tudo Funciona - -ApΓ³s a instalaΓ§Γ£o, execute este checklist para garantir que tudo estΓ‘ funcionando: +## Multi-Project Support -### βœ… Checklist de VerificaΓ§Γ£o +The dashboard supports multiple AIOX projects via a project registry at `~/.aios/dashboard/projects.json`. ```bash -# 1. AIOS estΓ‘ instalado? -ls .aios-core/development/agents/ -# βœ“ Deve listar arquivos .md (dev.md, qa.md, architect.md, etc) - -# 2. Server estΓ‘ rodando? -curl http://localhost:4001/health -# βœ“ Deve retornar: {"status":"ok"} - -# 3. Dashboard estΓ‘ acessΓ­vel? -curl -s http://localhost:3000 | head -5 -# βœ“ Deve retornar HTML +# List registered projects +curl localhost:3100/aiox-dashboard/api/projects -# 4. Hooks estΓ£o instalados? (opcional) -ls ~/.claude/hooks/*.py 2>/dev/null | wc -l -# βœ“ Deve retornar nΓΊmero > 0 +# Register a new project +curl -X POST localhost:3100/aiox-dashboard/api/projects/register \ + -H 'Content-Type: application/json' \ + -d '{"name":"my-project","path":"/path/to/my-project"}' ``` -### πŸ§ͺ Teste Manual - -1. **Kanban**: Acesse http://localhost:3000 β†’ deve mostrar board com stories -2. **Agents**: Clique em "Agents" β†’ deve listar agentes em standby -3. **Squads**: Clique em "Squads" β†’ deve mostrar organograma de squads -4. **Monitor**: Clique em "Monitor" β†’ deve mostrar status de conexΓ£o - -### ❌ Se algo nΓ£o funcionar - -| Problema | SoluΓ§Γ£o | -|----------|---------| -| Kanban vazio | Verifique se existe `docs/stories/` com arquivos `.md` | -| Agents vazio | Verifique se existe `.aios-core/development/agents/` | -| Squads vazio | Verifique se existe `squads/` com subpastas | -| Monitor desconectado | Verifique se o server estΓ‘ rodando na porta 4001 | +API routes accept `?project=` query parameter to scope data to a specific project. --- -## Contribuindo - -ContribuiΓ§Γ΅es sΓ£o muito bem-vindas! Este Γ© um projeto em fase inicial e hΓ‘ muito espaΓ§o para melhorias. - -### Tipos de ContribuiΓ§Γ£o - -| Tipo | DescriΓ§Γ£o | Dificuldade | -|------|-----------|-------------| -| **Bug fixes** | Corrigir problemas reportados | FΓ‘cil | -| **DocumentaΓ§Γ£o** | Melhorar README, adicionar guias | FΓ‘cil | -| **UI/UX** | Melhorar interface, adicionar temas | MΓ©dio | -| **Novos componentes** | Adicionar visualizaΓ§Γ΅es | MΓ©dio | -| **Novas views** | Criar pΓ‘ginas novas no dashboard | AvanΓ§ado | -| **Server features** | Adicionar endpoints, melhorar performance | AvanΓ§ado | - -### Contribuindo com AIOS (Recomendado) - -Se vocΓͺ tem o AIOS instalado, use os agentes para ajudar no desenvolvimento: - -#### πŸ—οΈ Para novas features β€” Use @architect + @dev +## Systemd Services ```bash -# 1. PeΓ§a ao Architect para planejar -@architect Quero adicionar um grΓ‘fico de mΓ©tricas na view Monitor. - Analise a estrutura atual e sugira a melhor abordagem. +# Dashboard (Next.js on port 3100) +systemctl status aiox-dashboard -# 2. Depois peΓ§a ao Dev para implementar -@dev Implemente o grΓ‘fico de mΓ©tricas seguindo o plano do Architect. - Use Recharts e siga os padrΓ΅es existentes em src/components/monitor/ -``` +# Monitor server (Bun on port 4001) +systemctl status aiox-monitor -#### 🎨 Para melhorias de UI β€” Use @ux-design-expert + @dev - -```bash -# 1. PeΓ§a ao UX Designer para analisar -@ux-design-expert Analise a view Kanban e sugira melhorias de usabilidade. - Considere acessibilidade e mobile. - -# 2. Depois implemente com o Dev -@dev Aplique as melhorias de UX sugeridas no Kanban. -``` - -#### πŸ› Para bugs β€” Use @qa + @dev - -```bash -# 1. PeΓ§a ao QA para investigar -@qa O WebSocket do Monitor desconecta apΓ³s 5 minutos. - Investigue a causa raiz. - -# 2. Depois corrija com o Dev -@dev Corrija o problema de desconexΓ£o do WebSocket identificado pelo QA. -``` - -#### πŸš€ Para deploy/PR β€” Use @devops - -```bash -# Quando terminar, peΓ§a ao DevOps para criar o PR -@devops Crie um PR para a branch atual com as mudanΓ§as do Monitor. - Inclua descriΓ§Γ£o detalhada e screenshots. -``` - -#### βœ… Para validaΓ§Γ£o final β€” Use @qa - -```bash -# Antes de submeter, peΓ§a ao QA para revisar -@qa FaΓ§a uma revisΓ£o completa das mudanΓ§as. - Verifique lint, types, testes e funcionamento visual. +# Restart both +systemctl restart aiox-monitor aiox-dashboard ``` --- -### Contribuindo sem AIOS (Manual) - -Se preferir contribuir sem usar os agentes: - -#### 1. Fork e Clone - -```bash -# Fork pelo GitHub, depois clone seu fork -git clone https://github.com/SEU_USUARIO/aios-dashboard.git -cd aios-dashboard - -# Adicione o repositΓ³rio original como upstream -git remote add upstream https://github.com/SynkraAI/aios-dashboard.git -``` - -#### 2. Crie uma Branch - -```bash -git checkout -b feature/minha-nova-feature -``` - -**ConvenΓ§Γ£o de nomes:** - -| Prefixo | Uso | -|---------|-----| -| `feature/` | Nova funcionalidade | -| `fix/` | CorreΓ§Γ£o de bug | -| `docs/` | DocumentaΓ§Γ£o | -| `refactor/` | RefatoraΓ§Γ£o de cΓ³digo | -| `ui/` | Melhorias visuais | +## API Endpoints -#### 3. FaΓ§a suas alteraΓ§Γ΅es +| Endpoint | Description | +|----------|-------------| +| `GET /api/status` | AIOX CLI connection status | +| `GET /api/stories` | List all stories | +| `GET /api/squads` | List all squads | +| `GET /api/github` | GitHub issues and PRs | +| `GET /api/insights` | Project metrics | +| `GET /api/context` | Active rules, agents, configs | +| `GET /api/roadmap` | Roadmap items | +| `GET /api/plans` | Task plans | +| `GET /api/prds` | PRD documents | +| `GET /api/qa/metrics` | QA metrics | +| `GET /api/bob/status` | Bob orchestrator status | +| `GET /api/projects` | Registered projects | +| `POST /api/projects/register` | Register a project | +| `GET /api/events` | SSE event stream | +| `GET /api/logs?agent=dev` | Agent log stream (SSE) | -Desenvolva sua feature seguindo os padrΓ΅es do projeto: - -- **React**: Componentes funcionais com hooks -- **TypeScript**: Tipagem obrigatΓ³ria -- **Tailwind CSS**: Para estilos -- **Zustand**: Para estado global - -#### 4. Teste localmente - -```bash -# Lint -npm run lint --prefix apps/dashboard - -# Type check -npm run typecheck --prefix apps/dashboard - -# Testes -npm test --prefix apps/dashboard - -# Rode o dashboard e verifique visualmente -npm run dev --prefix apps/dashboard -``` - -#### 5. Commit com mensagem clara - -Usamos [Conventional Commits](https://www.conventionalcommits.org/): - -```bash -# Formato -: - -# Exemplos -git commit -m "feat: add dark mode toggle" -git commit -m "fix: resolve websocket reconnection issue" -git commit -m "docs: improve installation instructions" -git commit -m "ui: improve kanban card hover state" -``` - -**Tipos de commit:** -- `feat` - Nova funcionalidade -- `fix` - CorreΓ§Γ£o de bug -- `docs` - DocumentaΓ§Γ£o -- `ui` - MudanΓ§as visuais -- `refactor` - RefatoraΓ§Γ£o -- `test` - Testes -- `chore` - ManutenΓ§Γ£o - -#### 6. Push e crie o Pull Request - -```bash -# Push para seu fork -git push origin feature/minha-nova-feature -``` - -Depois, abra um Pull Request no GitHub: - -1. VΓ‘ para https://github.com/SynkraAI/aios-dashboard -2. Clique em "Pull Requests" β†’ "New Pull Request" -3. Selecione "compare across forks" -4. Selecione seu fork e branch -5. Preencha o template do PR - -### Template de Pull Request - -```markdown -## DescriΓ§Γ£o - -O que este PR faz? Por que Γ© necessΓ‘rio? - -## Tipo de mudanΓ§a - -- [ ] Bug fix -- [ ] Nova feature -- [ ] Melhoria de UI -- [ ] DocumentaΓ§Γ£o -- [ ] RefatoraΓ§Γ£o - -## Como testar - -1. Passo 1 -2. Passo 2 -3. Resultado esperado - -## Screenshots (se aplicΓ‘vel) - -[Adicione screenshots aqui] - -## Checklist - -- [ ] Meu cΓ³digo segue o estilo do projeto -- [ ] Testei localmente -- [ ] Lint passa sem erros -- [ ] TypeScript compila sem erros -``` - -### Estrutura do CΓ³digo - -``` -src/ -β”œβ”€β”€ app/ # PΓ‘ginas (App Router) -β”œβ”€β”€ components/ -β”‚ β”œβ”€β”€ ui/ # Componentes base (Button, Card, etc) -β”‚ β”œβ”€β”€ kanban/ # Componentes do Kanban -β”‚ β”œβ”€β”€ monitor/ # Componentes do Monitor -β”‚ β”œβ”€β”€ squads/ # Componentes de Squads -β”‚ └── ... -β”œβ”€β”€ hooks/ # React hooks customizados -β”œβ”€β”€ stores/ # Estado global (Zustand) -β”œβ”€β”€ lib/ # UtilitΓ‘rios -└── types/ # Tipos TypeScript -``` - -### Adicionando um Novo Componente - -```tsx -// src/components/meu-componente/MeuComponente.tsx - -'use client'; - -import { memo } from 'react'; -import { cn } from '@/lib/utils'; - -interface MeuComponenteProps { - className?: string; - // ... outras props -} - -export const MeuComponente = memo(function MeuComponente({ - className, - ...props -}: MeuComponenteProps) { - return ( -
- {/* conteΓΊdo */} -
- ); -}); -``` - -### Adicionando uma Nova View - -1. Crie o componente em `src/components/minha-view/` -2. Adicione o case em `src/app/page.tsx` no `ViewContent` -3. Adicione o item na sidebar em `src/components/layout/Sidebar.tsx` -4. Adicione o tipo em `src/types/index.ts` - -### Dicas Importantes - -- **NΓ£o quebre o que funciona** β€” Teste suas mudanΓ§as -- **Mantenha PRs pequenos** β€” Mais fΓ‘cil de revisar -- **Documente cΓ³digo complexo** β€” Ajuda outros contribuidores -- **Pergunte antes de grandes mudanΓ§as** β€” Abra uma issue primeiro - -### Obtendo Ajuda - -- **Issues**: [Abrir issue](https://github.com/SynkraAI/aios-dashboard/issues) -- **DiscussΓ΅es**: [Iniciar discussΓ£o](https://github.com/SynkraAI/aios-dashboard/discussions) -- **AIOS Core**: [Comunidade AIOS](https://github.com/SynkraAI/aios-core/discussions) - ---- - -## LicenΓ§a +## License MIT - ---- - -Parte do ecossistema [Synkra AIOS](https://github.com/SynkraAI/aios-core) β€” CLI First, Observability Second, UI Third diff --git a/next.config.ts b/next.config.ts index 184c6dd..797dfd6 100644 --- a/next.config.ts +++ b/next.config.ts @@ -3,6 +3,17 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { // Externalize native modules that can't be bundled serverExternalPackages: ['chokidar'], + basePath: '/aiox-dashboard', + // Prevent stale HTML after rebuilds β€” HTML pages must revalidate, + // while hashed _next/static assets remain immutable (default behavior) + headers: async () => [ + { + source: '/((?!_next/static).*)', + headers: [ + { key: 'Cache-Control', value: 'no-cache, no-store, must-revalidate' }, + ], + }, + ], }; export default nextConfig; diff --git a/package-lock.json b/package-lock.json index 4d477a5..20b2a41 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-slot": "^1.2.4", + "ansi-to-html": "^0.7.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "gray-matter": "^4.0.3", @@ -4721,6 +4722,30 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/ansi-to-html": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/ansi-to-html/-/ansi-to-html-0.7.2.tgz", + "integrity": "sha512-v6MqmEpNlxF+POuyhKkidusCHWWkaLcGRURzivcU3I9tv7k4JVhFcnukrM5Rlk2rUywdZuzYAZ+kbZqWCnfN3g==", + "license": "MIT", + "dependencies": { + "entities": "^2.2.0" + }, + "bin": { + "ansi-to-html": "bin/ansi-to-html" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/ansi-to-html/node_modules/entities": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.2.0.tgz", + "integrity": "sha512-p92if5Nz619I0w+akJrLZH0MX0Pb5DX39XOwQTtXSdQQOaYH03S1uIQp4mhOZtAXrxq4ViO67YTiLBo2638o9A==", + "license": "BSD-2-Clause", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", diff --git a/package.json b/package.json index 4827f1d..37afd1e 100644 --- a/package.json +++ b/package.json @@ -18,6 +18,7 @@ "@radix-ui/react-context-menu": "^2.2.16", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-slot": "^1.2.4", + "ansi-to-html": "^0.7.2", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "gray-matter": "^4.0.3", diff --git a/src/app/(dashboard)/context/page.tsx b/src/app/(dashboard)/context/page.tsx new file mode 100644 index 0000000..30a1e42 --- /dev/null +++ b/src/app/(dashboard)/context/page.tsx @@ -0,0 +1,11 @@ +'use client'; + +import { ContextPanel } from '@/components/context'; + +export default function ContextPage() { + return ( +
+ +
+ ); +} diff --git a/src/app/(dashboard)/insights/page.tsx b/src/app/(dashboard)/insights/page.tsx new file mode 100644 index 0000000..e2c0bd9 --- /dev/null +++ b/src/app/(dashboard)/insights/page.tsx @@ -0,0 +1,11 @@ +'use client'; + +import { InsightsPanel } from '@/components/insights'; + +export default function InsightsPage() { + return ( +
+ +
+ ); +} diff --git a/src/app/(dashboard)/plans/page.tsx b/src/app/(dashboard)/plans/page.tsx new file mode 100644 index 0000000..1e9284d --- /dev/null +++ b/src/app/(dashboard)/plans/page.tsx @@ -0,0 +1,11 @@ +'use client'; + +import { PlansPanel } from '@/components/plans'; + +export default function PlansPage() { + return ( +
+ +
+ ); +} diff --git a/src/app/(dashboard)/prds/page.tsx b/src/app/(dashboard)/prds/page.tsx new file mode 100644 index 0000000..aad24f3 --- /dev/null +++ b/src/app/(dashboard)/prds/page.tsx @@ -0,0 +1,11 @@ +'use client'; + +import { PRDPanel } from '@/components/prd'; + +export default function PRDsPage() { + return ( +
+ +
+ ); +} diff --git a/src/app/(dashboard)/qa/page.tsx b/src/app/(dashboard)/qa/page.tsx new file mode 100644 index 0000000..143713f --- /dev/null +++ b/src/app/(dashboard)/qa/page.tsx @@ -0,0 +1,11 @@ +'use client'; + +import { QAMetricsPanel } from '@/components/qa'; + +export default function QAMetricsPage() { + return ( +
+ +
+ ); +} diff --git a/src/app/(dashboard)/roadmap/page.tsx b/src/app/(dashboard)/roadmap/page.tsx new file mode 100644 index 0000000..420d430 --- /dev/null +++ b/src/app/(dashboard)/roadmap/page.tsx @@ -0,0 +1,11 @@ +'use client'; + +import { RoadmapView } from '@/components/roadmap'; + +export default function RoadmapPage() { + return ( +
+ +
+ ); +} diff --git a/src/app/api/bob/events/route.ts b/src/app/api/bob/events/route.ts index 44eba20..72684bf 100644 --- a/src/app/api/bob/events/route.ts +++ b/src/app/api/bob/events/route.ts @@ -1,14 +1,7 @@ import { NextRequest } from 'next/server'; import { promises as fs } from 'fs'; import path from 'path'; - -// Get the project root path -function getProjectRoot(): string { - if (process.env.AIOS_PROJECT_ROOT) { - return process.env.AIOS_PROJECT_ROOT; - } - return path.resolve(process.cwd(), '..', '..'); -} +import { resolveProjectRoot } from '@/lib/project-registry'; const BOB_STATUS_FILE_NAME = '.aios/dashboard/bob-status.json'; const POLL_INTERVAL = 1000; // Poll every 1 second (per story spec) @@ -27,7 +20,7 @@ function formatSSE(event: SSEEvent): string { } export async function GET(request: NextRequest) { - const projectRoot = getProjectRoot(); + const projectRoot = await resolveProjectRoot(); const statusFilePath = path.join(projectRoot, BOB_STATUS_FILE_NAME); const encoder = new TextEncoder(); diff --git a/src/app/api/bob/status/route.ts b/src/app/api/bob/status/route.ts index bd47a50..12bb6a6 100644 --- a/src/app/api/bob/status/route.ts +++ b/src/app/api/bob/status/route.ts @@ -1,18 +1,11 @@ import { NextResponse } from 'next/server'; import { promises as fs } from 'fs'; import path from 'path'; +import { resolveProjectRoot } from '@/lib/project-registry'; // Bob status file path relative to project root const BOB_STATUS_FILE_NAME = '.aios/dashboard/bob-status.json'; -// Get the project root path -function getProjectRoot(): string { - if (process.env.AIOS_PROJECT_ROOT) { - return process.env.AIOS_PROJECT_ROOT; - } - return path.resolve(process.cwd(), '..', '..'); -} - // Default response when Bob is not running const BOB_INACTIVE_STATUS = { active: false, @@ -21,7 +14,7 @@ const BOB_INACTIVE_STATUS = { export async function GET() { try { - const statusFilePath = path.join(getProjectRoot(), BOB_STATUS_FILE_NAME); + const statusFilePath = path.join(await resolveProjectRoot(), BOB_STATUS_FILE_NAME); const fileContent = await fs.readFile(statusFilePath, 'utf-8'); let data: unknown; diff --git a/src/app/api/context/route.ts b/src/app/api/context/route.ts new file mode 100644 index 0000000..993921f --- /dev/null +++ b/src/app/api/context/route.ts @@ -0,0 +1,268 @@ +import { NextResponse } from 'next/server'; +import { promises as fs } from 'fs'; +import path from 'path'; +import { execSync } from 'child_process'; +import { resolveProjectRoot } from '@/lib/project-registry'; + +interface ContextFile { + id: string; + name: string; + path: string; + type: 'rules' | 'agent' | 'config' | 'docs'; + description: string; + lastModified: string; + size: string; +} + +interface ContextMCP { + id: string; + name: string; + status: 'active' | 'inactive' | 'error'; + tools: number; + description: string; +} + +function formatSize(bytes: number): string { + if (bytes < 1024) return `${bytes} B`; + if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +async function scanFiles( + dir: string, + type: ContextFile['type'], + descFn: (name: string) => string, +): Promise { + const files: ContextFile[] = []; + try { + const entries = await fs.readdir(dir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isFile()) continue; + if (entry.name.startsWith('.')) continue; + const fullPath = path.join(dir, entry.name); + const stat = await fs.stat(fullPath); + files.push({ + id: `${type}-${entry.name}`, + name: entry.name, + path: path.relative(process.env.AIOS_PROJECT_ROOT || path.resolve(process.cwd(), '..', '..'), fullPath), + type, + description: descFn(entry.name), + lastModified: stat.mtime.toISOString(), + size: formatSize(stat.size), + }); + } + } catch { + // Directory doesn't exist + } + return files; +} + +async function getActiveRules(projectRoot: string): Promise { + const rules: ContextFile[] = []; + + // Check .claude/CLAUDE.md + const claudeMd = path.join(projectRoot, '.claude', 'CLAUDE.md'); + try { + const stat = await fs.stat(claudeMd); + rules.push({ + id: 'rule-claude-md', + name: 'CLAUDE.md', + path: '.claude/CLAUDE.md', + type: 'rules', + description: 'Main project rules and instructions', + lastModified: stat.mtime.toISOString(), + size: formatSize(stat.size), + }); + } catch { /* not found */ } + + // Scan .claude/rules/ + const rulesDir = path.join(projectRoot, '.claude', 'rules'); + const ruleFiles = await scanFiles(rulesDir, 'rules', (name) => { + const stem = name.replace(/\.(md|txt|yaml)$/, ''); + return stem.split(/[-_]/).map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ') + ' rules'; + }); + rules.push(...ruleFiles); + + return rules; +} + +async function getAgentDefinitions(projectRoot: string): Promise { + const agents: ContextFile[] = []; + + // .aiox-core/development/agents/ + const agentsDir = path.join(projectRoot, '.aiox-core', 'development', 'agents'); + const agentFiles = await scanFiles(agentsDir, 'agent', (name) => { + const stem = name.replace(/\.(md|yaml|json)$/, ''); + return `${stem.charAt(0).toUpperCase() + stem.slice(1)} agent definition`; + }); + agents.push(...agentFiles); + + // Also check squads/ for squad agent defs + const squadsDir = path.join(projectRoot, 'squads'); + try { + const squads = await fs.readdir(squadsDir, { withFileTypes: true }); + for (const squad of squads) { + if (!squad.isDirectory() || squad.name.startsWith('.')) continue; + const squadAgentsDir = path.join(squadsDir, squad.name, 'agents'); + const squadAgentFiles = await scanFiles(squadAgentsDir, 'agent', (name) => { + const stem = name.replace(/\.(md|yaml|json)$/, ''); + return `${stem} agent (${squad.name} squad)`; + }); + agents.push(...squadAgentFiles); + } + } catch { /* no squads dir */ } + + return agents; +} + +async function getConfigFiles(projectRoot: string): Promise { + const configs: ContextFile[] = []; + const configNames: Record = { + 'package.json': 'Node.js dependencies and scripts', + 'tsconfig.json': 'TypeScript configuration', + 'core-config.yaml': 'AIOX core configuration', + '.eslintrc.js': 'ESLint configuration', + '.eslintrc.json': 'ESLint configuration', + '.prettierrc': 'Prettier configuration', + 'vitest.config.ts': 'Vitest test configuration', + 'jest.config.js': 'Jest test configuration', + 'jest.config.ts': 'Jest test configuration', + }; + + for (const [name, desc] of Object.entries(configNames)) { + const filePath = path.join(projectRoot, name); + try { + const stat = await fs.stat(filePath); + if (stat.isFile()) { + configs.push({ + id: `config-${name}`, + name, + path: name, + type: 'config', + description: desc, + lastModified: stat.mtime.toISOString(), + size: formatSize(stat.size), + }); + } + } catch { /* not found */ } + } + + // .aiox-core configs + const aioxConfigs = [ + { file: '.aiox-core/constitution.md', desc: 'AIOX Constitution β€” non-negotiable principles' }, + { file: '.aiox-core/package.json', desc: 'AIOX core package configuration' }, + ]; + for (const { file, desc } of aioxConfigs) { + const filePath = path.join(projectRoot, file); + try { + const stat = await fs.stat(filePath); + configs.push({ + id: `config-${path.basename(file)}`, + name: path.basename(file), + path: file, + type: 'config', + description: desc, + lastModified: stat.mtime.toISOString(), + size: formatSize(stat.size), + }); + } catch { /* not found */ } + } + + return configs; +} + +async function getMCPServers(projectRoot: string): Promise { + const servers: ContextMCP[] = []; + + // Try .claude/mcp.json + const mcpPaths = [ + path.join(projectRoot, '.claude', 'mcp.json'), + path.join(process.env.HOME || '/root', '.claude.json'), + ]; + + for (const mcpPath of mcpPaths) { + try { + const content = await fs.readFile(mcpPath, 'utf-8'); + const parsed = JSON.parse(content); + const mcpServers = parsed.mcpServers || parsed.mcp_servers || {}; + + for (const [name, config] of Object.entries(mcpServers)) { + const cfg = config as Record; + const disabled = cfg.disabled === true; + servers.push({ + id: `mcp-${name}`, + name, + status: disabled ? 'inactive' : 'active', + tools: Array.isArray(cfg.tools) ? (cfg.tools as unknown[]).length : 0, + description: (cfg.description as string) || `MCP server: ${name}`, + }); + } + } catch { /* file not found or invalid */ } + } + + return servers; +} + +function getRecentFiles(projectRoot: string): string[] { + try { + const output = execSync( + 'git log --format="" --diff-filter=M --name-only -20 2>/dev/null | head -20', + { cwd: projectRoot, encoding: 'utf-8', timeout: 5000 } + ); + const files = output + .split('\n') + .map(f => f.trim()) + .filter(f => f.length > 0); + // Deduplicate + return [...new Set(files)].slice(0, 20); + } catch { + return []; + } +} + +export async function GET() { + try { + const projectRoot = await resolveProjectRoot(); + + const [activeRules, agentDefinitions, configFiles, mcpServers] = await Promise.all([ + getActiveRules(projectRoot), + getAgentDefinitions(projectRoot), + getConfigFiles(projectRoot), + getMCPServers(projectRoot), + ]); + + const recentFiles = getRecentFiles(projectRoot); + + // Derive project name from package.json or directory name + let projectName = path.basename(projectRoot); + try { + const pkg = JSON.parse(await fs.readFile(path.join(projectRoot, 'package.json'), 'utf-8')); + projectName = pkg.name || projectName; + } catch { /* use dir name */ } + + // Check for CLAUDE.md path + let claudeMdPath = ''; + try { + await fs.access(path.join(projectRoot, '.claude', 'CLAUDE.md')); + claudeMdPath = '.claude/CLAUDE.md'; + } catch { + claudeMdPath = '(not found)'; + } + + return NextResponse.json({ + projectName, + projectPath: projectRoot, + claudeMdPath, + activeRules, + agentDefinitions, + configFiles, + mcpServers, + recentFiles, + }); + } catch (error) { + return NextResponse.json( + { error: `Failed to read context: ${error instanceof Error ? error.message : 'Unknown'}` }, + { status: 500 } + ); + } +} diff --git a/src/app/api/events/route.ts b/src/app/api/events/route.ts index 4384943..bd19731 100644 --- a/src/app/api/events/route.ts +++ b/src/app/api/events/route.ts @@ -1,14 +1,7 @@ import { NextRequest } from 'next/server'; import { promises as fs } from 'fs'; import path from 'path'; - -// Get the project root path -function getProjectRoot(): string { - if (process.env.AIOS_PROJECT_ROOT) { - return process.env.AIOS_PROJECT_ROOT; - } - return path.resolve(process.cwd(), '..', '..'); -} +import { resolveProjectRoot } from '@/lib/project-registry'; const STATUS_FILE_NAME = '.aios/dashboard/status.json'; const POLL_INTERVAL = 2000; // Poll every 2 seconds @@ -27,7 +20,7 @@ function formatSSE(event: SSEEvent): string { } export async function GET(request: NextRequest) { - const projectRoot = getProjectRoot(); + const projectRoot = await resolveProjectRoot(); const statusFilePath = path.join(projectRoot, STATUS_FILE_NAME); // Create readable stream for SSE diff --git a/src/app/api/github/route.ts b/src/app/api/github/route.ts index dd36c42..fd4f469 100644 --- a/src/app/api/github/route.ts +++ b/src/app/api/github/route.ts @@ -1,6 +1,7 @@ -import { NextResponse } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; import { execFile } from 'child_process'; import { promisify } from 'util'; +import { resolveProjectRoot } from '@/lib/project-registry'; const execFileAsync = promisify(execFile); @@ -25,8 +26,10 @@ interface GitHubPR { isDraft: boolean; } -export async function GET() { +export async function GET(request: NextRequest) { try { + const projectRoot = await resolveProjectRoot(request); + // Check if gh CLI is authenticated try { await execFileAsync('gh', ['auth', 'status']); @@ -40,7 +43,8 @@ export async function GET() { ); } - // Fetch issues and PRs in parallel + // Fetch issues and PRs in parallel, scoped to the project directory + const execOpts = { cwd: projectRoot }; const [issuesResult, prsResult] = await Promise.allSettled([ execFileAsync('gh', [ 'issue', @@ -49,7 +53,7 @@ export async function GET() { 'number,title,state,labels,url,createdAt,author', '--limit', '15', - ]), + ], execOpts), execFileAsync('gh', [ 'pr', 'list', @@ -57,7 +61,7 @@ export async function GET() { 'number,title,state,url,createdAt,author,headRefName,isDraft', '--limit', '15', - ]), + ], execOpts), ]); const issues: GitHubIssue[] = @@ -74,7 +78,7 @@ export async function GET() { 'view', '--json', 'name,owner,url', - ]); + ], execOpts); repoInfo = JSON.parse(repoJson); } catch { // Ignore repo info errors @@ -87,7 +91,6 @@ export async function GET() { updatedAt: new Date().toISOString(), }); } catch (error) { - // eslint-disable-next-line no-undef console.error('GitHub API error:', error); return NextResponse.json( { diff --git a/src/app/api/insights/route.ts b/src/app/api/insights/route.ts new file mode 100644 index 0000000..393cab1 --- /dev/null +++ b/src/app/api/insights/route.ts @@ -0,0 +1,183 @@ +import { NextResponse } from 'next/server'; +import { promises as fs } from 'fs'; +import path from 'path'; +import matter from 'gray-matter'; +import { execSync } from 'child_process'; +import { resolveProjectRoot } from '@/lib/project-registry'; + +interface StoryData { + status: string; + agent: string; + createdAt: string; + updatedAt: string; + complexity: string; +} + +async function getStories(projectRoot: string): Promise { + const storiesDir = path.join(projectRoot, 'docs', 'stories'); + const stories: StoryData[] = []; + + try { + const files = await fs.readdir(storiesDir); + for (const file of files) { + if (!file.endsWith('.md') || file.startsWith('.')) continue; + const filePath = path.join(storiesDir, file); + const stat = await fs.stat(filePath); + if (!stat.isFile()) continue; + + const content = await fs.readFile(filePath, 'utf-8'); + const { data } = matter(content); + + stories.push({ + status: data.status || 'backlog', + agent: data.agent || 'unknown', + createdAt: data.createdAt || stat.birthtime.toISOString(), + updatedAt: data.updatedAt || stat.mtime.toISOString(), + complexity: data.complexity || 'standard', + }); + } + } catch { + // No stories directory + } + + return stories; +} + +function getWeeklyActivity(projectRoot: string): { day: string; stories: number; commits: number }[] { + const days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; + const activity: Record = {}; + + // Initialize last 7 days + const result: { day: string; stories: number; commits: number }[] = []; + const today = new Date(); + + for (let i = 6; i >= 0; i--) { + const d = new Date(today); + d.setDate(d.getDate() - i); + const key = d.toISOString().split('T')[0]; + activity[key] = 0; + } + + try { + const output = execSync( + 'git log --format="%ad" --date=format:"%Y-%m-%d" --since="7 days ago" 2>/dev/null', + { cwd: projectRoot, encoding: 'utf-8', timeout: 5000 } + ); + for (const line of output.split('\n')) { + const date = line.trim(); + if (date && activity[date] !== undefined) { + activity[date]++; + } + } + } catch { + // Not a git repo or git not available + } + + for (const [date, commits] of Object.entries(activity)) { + const d = new Date(date + 'T12:00:00Z'); + result.push({ + day: days[d.getUTCDay()], + stories: 0, // Will be filled from story data + commits, + }); + } + + return result; +} + +export async function GET() { + try { + const projectRoot = await resolveProjectRoot(); + const stories = await getStories(projectRoot); + const weeklyActivity = getWeeklyActivity(projectRoot); + + // Velocity: count completed stories + const completed = stories.filter(s => s.status === 'done'); + const total = stories.length; + + // Agent activity + const agentMap = new Map(); + for (const s of stories) { + if (!agentMap.has(s.agent)) { + agentMap.set(s.agent, { completed: 0, inProgress: 0, total: 0 }); + } + const entry = agentMap.get(s.agent)!; + entry.total++; + if (s.status === 'done') entry.completed++; + if (s.status === 'in_progress') entry.inProgress++; + } + + const agentActivity = Array.from(agentMap.entries()).map(([agentId, stats]) => ({ + agentId, + storiesCompleted: stats.completed, + hoursActive: stats.total * 4, // Estimate + successRate: stats.total > 0 ? Math.round((stats.completed / stats.total) * 100) : 0, + })); + + // Bottlenecks + const statusCounts = new Map(); + for (const s of stories) { + statusCounts.set(s.status, (statusCounts.get(s.status) || 0) + 1); + } + + const statusLabels: Record = { + backlog: 'Backlog', + in_progress: 'In Progress', + ai_review: 'AI Review', + human_review: 'Human Review', + pr_created: 'PR Created', + error: 'Error', + }; + + const bottlenecks = Array.from(statusCounts.entries()) + .filter(([status]) => status !== 'done') + .map(([status, count]) => ({ + status: statusLabels[status] || status, + count, + avgWaitTime: status === 'backlog' ? 72 : status === 'human_review' ? 12 : 6, + })) + .sort((a, b) => b.count - a.count); + + // Cycle time by status (estimated averages in hours) + const cycleTimeByStatus: Record = {}; + for (const [status, count] of statusCounts.entries()) { + if (count > 0) { + cycleTimeByStatus[status] = status === 'backlog' ? 48 : + status === 'in_progress' ? 6 : + status === 'ai_review' ? 2 : + status === 'human_review' ? 8 : + status === 'pr_created' ? 4 : 1; + } + } + + // Error rate + const errorCount = stories.filter(s => s.status === 'error').length; + const errorRate = total > 0 ? Math.round((errorCount / total) * 100) : 0; + + return NextResponse.json({ + velocity: { + current: completed.length, + previous: Math.max(completed.length - 1, 0), + trend: completed.length > 0 ? 'up' as const : 'stable' as const, + }, + cycleTime: { + average: Object.values(cycleTimeByStatus).length > 0 + ? Math.round(Object.values(cycleTimeByStatus).reduce((a, b) => a + b, 0) / Object.values(cycleTimeByStatus).length * 10) / 10 + : 0, + byStatus: cycleTimeByStatus, + }, + agentActivity, + bottlenecks, + weeklyActivity, + errorRate: { + current: errorRate, + previous: 0, + }, + }); + } catch (error) { + return NextResponse.json( + { error: `Failed to compute insights: ${error instanceof Error ? error.message : 'Unknown'}` }, + { status: 500 } + ); + } +} diff --git a/src/app/api/logs/route.ts b/src/app/api/logs/route.ts new file mode 100644 index 0000000..b6f0aea --- /dev/null +++ b/src/app/api/logs/route.ts @@ -0,0 +1,222 @@ +import { NextRequest } from 'next/server'; +import { promises as fs } from 'fs'; +import { watch, type FSWatcher } from 'fs'; +import { open, type FileHandle } from 'fs/promises'; +import path from 'path'; +import { randomUUID } from 'crypto'; + +const VALID_AGENTS = ['dev', 'qa', 'architect', 'pm', 'po', 'analyst', 'devops', 'main']; +import { resolveProjectRoot } from '@/lib/project-registry'; + +const INITIAL_LINES = 50; +const POLL_INTERVAL = 2000; +const HEARTBEAT_INTERVAL = 30000; + +interface TerminalLine { + id: string; + content: string; + timestamp: string; + isInitial: boolean; +} + +function formatSSE(eventType: string, data: unknown): string { + return `event: ${eventType}\ndata: ${JSON.stringify(data)}\n\n`; +} + +function parseLines(text: string): string[] { + return text.split('\n').filter((line) => line.length > 0); +} + +export async function GET(request: NextRequest) { + const { searchParams } = new URL(request.url); + const agent = searchParams.get('agent'); + const since = searchParams.get('since'); + + // Validate agent parameter + if (!agent) { + return new Response(JSON.stringify({ error: 'Missing required parameter: agent' }), { + status: 400, + headers: { 'Content-Type': 'application/json' }, + }); + } + + if (!VALID_AGENTS.includes(agent)) { + return new Response( + JSON.stringify({ error: `Invalid agent. Valid agents: ${VALID_AGENTS.join(', ')}` }), + { status: 400, headers: { 'Content-Type': 'application/json' } } + ); + } + + const projectRoot = await resolveProjectRoot(); + const logFilePath = path.join(projectRoot, '.aiox', 'logs', `${agent}.log`); + + const encoder = new TextEncoder(); + let isStreamActive = true; + let watcher: FSWatcher | null = null; + let pollInterval: ReturnType | null = null; + let heartbeatInterval: ReturnType | null = null; + let fileOffset = 0; + let partialLine = ''; + + const stream = new ReadableStream({ + async start(controller) { + const sendEvent = (eventType: string, data: unknown) => { + if (!isStreamActive) return; + try { + controller.enqueue(encoder.encode(formatSSE(eventType, data))); + } catch { + isStreamActive = false; + } + }; + + const sendLines = (lines: TerminalLine[]) => { + for (const line of lines) { + sendEvent('line', line); + } + }; + + // Send initial batch from log file + try { + const content = await fs.readFile(logFilePath, 'utf-8'); + const allLines = parseLines(content); + fileOffset = Buffer.byteLength(content, 'utf-8'); + + // Filter by `since` if provided + let linesToSend = allLines; + if (since) { + const sinceDate = new Date(since).getTime(); + // Try to parse timestamps from lines, otherwise send last N + linesToSend = allLines.filter((line) => { + const match = line.match(/^\[?(\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}:\d{2})/); + if (match) { + return new Date(match[1]).getTime() > sinceDate; + } + return true; + }); + } + + // Limit initial batch + const initialBatch = linesToSend.slice(-INITIAL_LINES); + const terminalLines: TerminalLine[] = initialBatch.map((line) => ({ + id: randomUUID(), + content: line, + timestamp: new Date().toISOString(), + isInitial: true, + })); + + sendLines(terminalLines); + } catch (err) { + if ((err as NodeJS.ErrnoException).code === 'ENOENT') { + sendEvent('status', { message: 'No log file available', agent }); + } else { + sendEvent('error', { message: 'Failed to read log file' }); + } + } + + // Read new bytes from file + const readNewLines = async () => { + if (!isStreamActive) return; + + try { + const stat = await fs.stat(logFilePath); + const currentSize = stat.size; + + // File was truncated/rotated β€” reset + if (currentSize < fileOffset) { + fileOffset = 0; + partialLine = ''; + } + + if (currentSize <= fileOffset) return; + + // Read only new bytes + let fh: FileHandle | null = null; + try { + fh = await open(logFilePath, 'r'); + const bytesToRead = currentSize - fileOffset; + const buffer = Buffer.alloc(bytesToRead); + await fh.read(buffer, 0, bytesToRead, fileOffset); + fileOffset = currentSize; + + const text = partialLine + buffer.toString('utf-8'); + const lines = text.split('\n'); + + // Last element might be a partial line + partialLine = lines.pop() || ''; + + const terminalLines: TerminalLine[] = lines + .filter((line) => line.length > 0) + .map((line) => ({ + id: randomUUID(), + content: line, + timestamp: new Date().toISOString(), + isInitial: false, + })); + + if (terminalLines.length > 0) { + sendLines(terminalLines); + } + } finally { + if (fh) await fh.close(); + } + } catch { + // File might not exist yet β€” ignore + } + }; + + // Try fs.watch first, fallback to polling + try { + watcher = watch(logFilePath, { persistent: false }, () => { + readNewLines(); + }); + watcher.on('error', () => { + // Fallback to polling + if (!pollInterval) { + pollInterval = setInterval(readNewLines, POLL_INTERVAL); + } + }); + } catch { + // fs.watch not available β€” use polling + pollInterval = setInterval(readNewLines, POLL_INTERVAL); + } + + // Heartbeat + heartbeatInterval = setInterval(() => { + sendEvent('heartbeat', { alive: true }); + }, HEARTBEAT_INTERVAL); + }, + + cancel() { + isStreamActive = false; + if (watcher) { + watcher.close(); + watcher = null; + } + if (pollInterval) { + clearInterval(pollInterval); + pollInterval = null; + } + if (heartbeatInterval) { + clearInterval(heartbeatInterval); + heartbeatInterval = null; + } + }, + }); + + // Handle client disconnect + request.signal.addEventListener('abort', () => { + isStreamActive = false; + if (watcher) watcher.close(); + if (pollInterval) clearInterval(pollInterval); + if (heartbeatInterval) clearInterval(heartbeatInterval); + }); + + return new Response(stream, { + headers: { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache, no-transform', + Connection: 'keep-alive', + 'X-Accel-Buffering': 'no', + }, + }); +} diff --git a/src/app/api/plans/route.ts b/src/app/api/plans/route.ts new file mode 100644 index 0000000..1d0445c --- /dev/null +++ b/src/app/api/plans/route.ts @@ -0,0 +1,81 @@ +import { NextResponse } from 'next/server'; +import { promises as fs } from 'fs'; +import path from 'path'; +import { resolveProjectRoot } from '@/lib/project-registry'; + +interface PlanItem { + id: string; + name: string; + description: string; + category: string; + filePath: string; +} + +export async function GET() { + try { + const projectRoot = await resolveProjectRoot(); + const tasksDir = path.join(projectRoot, '.aiox-core', 'development', 'tasks'); + const plans: PlanItem[] = []; + + try { + const entries = await fs.readdir(tasksDir, { withFileTypes: true }); + for (const entry of entries) { + if (!entry.isFile()) continue; + if (!entry.name.endsWith('.md') && !entry.name.endsWith('.yaml')) continue; + if (entry.name.startsWith('.')) continue; + + const filePath = path.join(tasksDir, entry.name); + const content = await fs.readFile(filePath, 'utf-8'); + + // Extract title from first H1 or filename + const titleMatch = content.match(/^#\s+(.+)/m); + const stem = entry.name.replace(/\.(md|yaml)$/, ''); + const name = titleMatch ? titleMatch[1].trim() : stem.split(/[-_]/).map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' '); + + // Extract first paragraph as description + const lines = content.split('\n').filter(l => l.trim() && !l.startsWith('#') && !l.startsWith('---')); + const description = lines[0]?.trim().slice(0, 200) || ''; + + // Categorize by filename prefix + let category = 'general'; + if (stem.startsWith('db-')) category = 'database'; + else if (stem.startsWith('dev-')) category = 'development'; + else if (stem.startsWith('qa-') || stem.startsWith('apply-qa')) category = 'quality'; + else if (stem.startsWith('github-') || stem.startsWith('ci-')) category = 'devops'; + else if (stem.startsWith('create-') || stem.startsWith('build')) category = 'creation'; + else if (stem.startsWith('analyze') || stem.startsWith('audit')) category = 'analysis'; + else if (stem.startsWith('architect')) category = 'architecture'; + else if (stem.startsWith('brownfield')) category = 'brownfield'; + + plans.push({ + id: stem, + name, + description, + category, + filePath: path.relative(projectRoot, filePath), + }); + } + } catch { + // tasks directory doesn't exist + } + + // Sort by category then name + plans.sort((a, b) => a.category.localeCompare(b.category) || a.name.localeCompare(b.name)); + + // Group by category + const categories = [...new Set(plans.map(p => p.category))]; + const grouped = categories.map(cat => ({ + category: cat, + label: cat.charAt(0).toUpperCase() + cat.slice(1), + count: plans.filter(p => p.category === cat).length, + items: plans.filter(p => p.category === cat), + })); + + return NextResponse.json({ plans, grouped, total: plans.length }); + } catch (error) { + return NextResponse.json( + { error: `Failed to list plans: ${error instanceof Error ? error.message : 'Unknown'}` }, + { status: 500 } + ); + } +} diff --git a/src/app/api/prds/[...slug]/route.ts b/src/app/api/prds/[...slug]/route.ts new file mode 100644 index 0000000..e2a3b25 --- /dev/null +++ b/src/app/api/prds/[...slug]/route.ts @@ -0,0 +1,32 @@ +import { NextResponse } from 'next/server'; +import { promises as fs } from 'fs'; +import path from 'path'; +import { resolveProjectRoot } from '@/lib/project-registry'; + +export async function GET( + _request: Request, + { params }: { params: Promise<{ slug: string[] }> } +) { + try { + const { slug } = await params; + const slugPath = slug.join('/'); + const projectRoot = await resolveProjectRoot(); + const filePath = path.join(projectRoot, 'docs', `${slugPath}.md`); + + // Prevent path traversal + const resolved = path.resolve(filePath); + const docsRoot = path.resolve(path.join(projectRoot, 'docs')); + if (!resolved.startsWith(docsRoot)) { + return NextResponse.json({ error: 'Invalid path' }, { status: 400 }); + } + + const content = await fs.readFile(filePath, 'utf-8'); + + return NextResponse.json({ slug: slugPath, content }); + } catch (error) { + return NextResponse.json( + { error: `Document not found: ${error instanceof Error ? error.message : 'Unknown'}` }, + { status: 404 } + ); + } +} diff --git a/src/app/api/prds/route.ts b/src/app/api/prds/route.ts new file mode 100644 index 0000000..0d63c71 --- /dev/null +++ b/src/app/api/prds/route.ts @@ -0,0 +1,111 @@ +import { NextResponse } from 'next/server'; +import { promises as fs } from 'fs'; +import path from 'path'; +import matter from 'gray-matter'; +import { resolveProjectRoot } from '@/lib/project-registry'; + +interface PRDSummary { + slug: string; + title: string; + version: string; + author: string; + date: string; + description: string; + filePath: string; + category: 'prd' | 'architecture'; +} + +async function parseDocFile(filePath: string, projectRoot: string, category: 'prd' | 'architecture'): Promise { + const file = path.basename(filePath); + const stat = await fs.stat(filePath); + const content = await fs.readFile(filePath, 'utf-8'); + const { data, content: markdown } = matter(content); + + // Extract title from first H1 + const titleMatch = markdown.match(/^#\s+(.+)/m); + const title = (data.title as string) || (titleMatch ? titleMatch[1].trim() : file.replace(/\.md$/, '')); + + // Extract first paragraph as description + const lines = markdown.split('\n').filter(l => l.trim() && !l.startsWith('#')); + const description = (data.description as string) || lines[0]?.trim() || ''; + + // Extract version from changelog table if present + const versionMatch = markdown.match(/\|\s*[\d-]+\s*\|\s*([\d.]+)\s*\|/); + const version = (data.version as string) || (versionMatch ? versionMatch[1] : '1.0'); + + // Extract author + const authorMatch = markdown.match(/\|\s*[\d-]+\s*\|\s*[\d.]+\s*\|\s*[^|]+\|\s*([^|]+)\|/); + const author = (data.author as string) || (authorMatch ? authorMatch[1].trim() : 'Unknown'); + + const slug = file.replace(/\.md$/, ''); + + return { + slug, + title, + version, + author, + date: data.date || stat.mtime.toISOString().split('T')[0], + description: description.slice(0, 200), + filePath: path.relative(projectRoot, filePath), + category, + }; +} + +export async function GET() { + try { + const projectRoot = await resolveProjectRoot(); + const docsDir = path.join(projectRoot, 'docs'); + const prds: PRDSummary[] = []; + + try { + const files = await fs.readdir(docsDir); + + for (const file of files) { + // PRD files + if (file.startsWith('prd-') && file.endsWith('.md')) { + const filePath = path.join(docsDir, file); + const stat = await fs.stat(filePath); + if (stat.isFile()) { + prds.push(await parseDocFile(filePath, projectRoot, 'prd')); + } + } + // Architecture files in docs root + if (file.includes('architecture') && file.endsWith('.md')) { + const filePath = path.join(docsDir, file); + const stat = await fs.stat(filePath); + if (stat.isFile()) { + prds.push(await parseDocFile(filePath, projectRoot, 'architecture')); + } + } + } + + // Also scan docs/architecture/ subdirectory (English only) + const archDir = path.join(docsDir, 'architecture'); + try { + const archFiles = await fs.readdir(archDir); + for (const file of archFiles) { + if (!file.endsWith('.md')) continue; + const filePath = path.join(archDir, file); + const stat = await fs.stat(filePath); + if (stat.isFile()) { + const doc = await parseDocFile(filePath, projectRoot, 'architecture'); + // Prefix slug to avoid collision with root-level docs + doc.slug = `architecture/${file.replace(/\.md$/, '')}`; + prds.push(doc); + } + } + } catch { + // architecture subdirectory doesn't exist + } + } catch { + // docs directory doesn't exist + } + + return NextResponse.json({ prds }); + } catch (error) { + return NextResponse.json( + { error: `Failed to list PRDs: ${error instanceof Error ? error.message : 'Unknown'}` }, + { status: 500 } + ); + } +} diff --git a/src/app/api/projects/register/route.ts b/src/app/api/projects/register/route.ts new file mode 100644 index 0000000..403d7ef --- /dev/null +++ b/src/app/api/projects/register/route.ts @@ -0,0 +1,24 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { registerProject } from '@/lib/project-registry'; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + const { name, path: projectPath } = body as { name?: string; path?: string }; + + if (!name || typeof name !== 'string' || !name.trim()) { + return NextResponse.json({ error: 'name is required' }, { status: 400 }); + } + if (!projectPath || typeof projectPath !== 'string' || !projectPath.trim()) { + return NextResponse.json({ error: 'path is required' }, { status: 400 }); + } + + const registry = await registerProject(name.trim(), projectPath.trim()); + return NextResponse.json({ message: `Project '${name}' registered`, registry }); + } catch (error) { + return NextResponse.json( + { error: `Failed to register project: ${error instanceof Error ? error.message : 'Unknown'}` }, + { status: 500 } + ); + } +} diff --git a/src/app/api/projects/route.ts b/src/app/api/projects/route.ts new file mode 100644 index 0000000..4eef958 --- /dev/null +++ b/src/app/api/projects/route.ts @@ -0,0 +1,14 @@ +import { NextResponse } from 'next/server'; +import { listProjects } from '@/lib/project-registry'; + +export async function GET() { + try { + const registry = await listProjects(); + return NextResponse.json(registry); + } catch (error) { + return NextResponse.json( + { error: `Failed to list projects: ${error instanceof Error ? error.message : 'Unknown'}` }, + { status: 500 } + ); + } +} diff --git a/src/app/api/qa/metrics/route.ts b/src/app/api/qa/metrics/route.ts index 1cd9e9b..e70548d 100644 --- a/src/app/api/qa/metrics/route.ts +++ b/src/app/api/qa/metrics/route.ts @@ -55,12 +55,7 @@ interface QAMetrics { // HELPERS // ═══════════════════════════════════════════════════════════════════════════════════ -function getProjectRoot(): string { - if (process.env.AIOS_PROJECT_ROOT) { - return process.env.AIOS_PROJECT_ROOT; - } - return path.resolve(process.cwd(), '..', '..'); -} +import { resolveProjectRoot } from '@/lib/project-registry'; async function loadJsonFile(filePath: string, defaultValue: T): Promise { try { @@ -76,7 +71,7 @@ async function loadJsonFile(filePath: string, defaultValue: T): Promise { // ═══════════════════════════════════════════════════════════════════════════════════ async function collectQAMetrics(): Promise { - const projectRoot = getProjectRoot(); + const projectRoot = await resolveProjectRoot(); const aiosDir = path.join(projectRoot, '.aios'); // Load gotchas diff --git a/src/app/api/roadmap/route.ts b/src/app/api/roadmap/route.ts new file mode 100644 index 0000000..d49fa7d --- /dev/null +++ b/src/app/api/roadmap/route.ts @@ -0,0 +1,192 @@ +import { NextResponse } from 'next/server'; +import { promises as fs } from 'fs'; +import path from 'path'; +import matter from 'gray-matter'; +import { resolveProjectRoot } from '@/lib/project-registry'; + +interface RoadmapItem { + id: string; + title: string; + description: string; + priority: string; + impact: string; + effort: string; + category: string; + tags: string[]; +} + +function parsePriority(text: string): string { + const lower = text.toLowerCase(); + if (lower.includes('must') || lower.includes('critical') || lower.includes('p0')) return 'must_have'; + if (lower.includes('should') || lower.includes('high') || lower.includes('p1')) return 'should_have'; + if (lower.includes('could') || lower.includes('medium') || lower.includes('p2')) return 'could_have'; + if (lower.includes('won\'t') || lower.includes('wont') || lower.includes('low') || lower.includes('p3')) return 'wont_have'; + return 'could_have'; +} + +async function parseRoadmapFile(filePath: string): Promise { + const content = await fs.readFile(filePath, 'utf-8'); + const items: RoadmapItem[] = []; + + // Try frontmatter first + const { data, content: markdown } = matter(content); + if (data.items && Array.isArray(data.items)) { + return data.items.map((item: Record, i: number) => ({ + id: `roadmap-${i + 1}`, + title: (item.title as string) || 'Untitled', + description: (item.description as string) || '', + priority: parsePriority((item.priority as string) || 'could'), + impact: (item.impact as string) || 'medium', + effort: (item.effort as string) || 'medium', + category: (item.category as string) || 'feature', + tags: Array.isArray(item.tags) ? item.tags as string[] : [], + })); + } + + // Parse markdown sections: split on ## or ### headers + const sections = markdown.split(/^(?=##\s)/m).filter(s => s.trim()); + let currentPriority = 'should_have'; + let itemIndex = 0; + + for (const section of sections) { + const headerMatch = section.match(/^##\s+(.+)/m); + if (!headerMatch) continue; + + const header = headerMatch[1].trim(); + + // Check if this is a priority group header + if (/must|critical|p0|high.priority/i.test(header)) { + currentPriority = 'must_have'; + // Parse sub-items + const subSections = section.split(/^(?=###\s)/m).slice(1); + for (const sub of subSections) { + const subMatch = sub.match(/^###\s+(.+)/m); + if (subMatch) { + itemIndex++; + const desc = sub.replace(/^###\s+.+\n/, '').trim().split('\n')[0] || ''; + items.push({ + id: `roadmap-${itemIndex}`, + title: subMatch[1].trim(), + description: desc, + priority: currentPriority, + impact: 'high', + effort: 'medium', + category: 'feature', + tags: [], + }); + } + } + continue; + } + + if (/should|medium.priority/i.test(header)) { + currentPriority = 'should_have'; + const subSections = section.split(/^(?=###\s)/m).slice(1); + for (const sub of subSections) { + const subMatch = sub.match(/^###\s+(.+)/m); + if (subMatch) { + itemIndex++; + const desc = sub.replace(/^###\s+.+\n/, '').trim().split('\n')[0] || ''; + items.push({ + id: `roadmap-${itemIndex}`, + title: subMatch[1].trim(), + description: desc, + priority: currentPriority, + impact: 'medium', + effort: 'medium', + category: 'feature', + tags: [], + }); + } + } + continue; + } + + if (/could|nice|low.priority/i.test(header)) { + currentPriority = 'could_have'; + } + + if (/won.?t|future|deferred/i.test(header)) { + currentPriority = 'wont_have'; + } + + // If it's a regular section (not a priority group), treat it as an item + if (!/must|should|could|won.?t|overview|introduction|quarterly|community/i.test(header)) { + itemIndex++; + const desc = section.replace(/^##\s+.+\n/, '').trim().split('\n')[0] || ''; + items.push({ + id: `roadmap-${itemIndex}`, + title: header, + description: desc, + priority: currentPriority, + impact: 'medium', + effort: 'medium', + category: 'feature', + tags: [], + }); + } + } + + // If we found no structured items, create items from bullet points + if (items.length === 0) { + const bulletLines = markdown.match(/^[-*]\s+(.+)/gm); + if (bulletLines) { + for (const line of bulletLines.slice(0, 20)) { + itemIndex++; + const title = line.replace(/^[-*]\s+/, '').trim(); + if (title.length > 5) { + items.push({ + id: `roadmap-${itemIndex}`, + title, + description: '', + priority: 'should_have', + impact: 'medium', + effort: 'medium', + category: 'feature', + tags: [], + }); + } + } + } + } + + return items; +} + +export async function GET() { + try { + const projectRoot = await resolveProjectRoot(); + let items: RoadmapItem[] = []; + + // Try docs/roadmap/ directory first + const roadmapDir = path.join(projectRoot, 'docs', 'roadmap'); + try { + const files = await fs.readdir(roadmapDir); + for (const file of files) { + if (!file.endsWith('.md')) continue; + const filePath = path.join(roadmapDir, file); + const fileItems = await parseRoadmapFile(filePath); + items.push(...fileItems); + } + } catch { + // No roadmap directory + } + + // Fall back to docs/roadmap.md + if (items.length === 0) { + const roadmapFile = path.join(projectRoot, 'docs', 'roadmap.md'); + try { + items = await parseRoadmapFile(roadmapFile); + } catch { + // No roadmap file + } + } + + return NextResponse.json({ items }); + } catch (error) { + return NextResponse.json( + { error: `Failed to parse roadmap: ${error instanceof Error ? error.message : 'Unknown'}` }, + { status: 500 } + ); + } +} diff --git a/src/app/api/status/route.ts b/src/app/api/status/route.ts index e198668..2004dfd 100644 --- a/src/app/api/status/route.ts +++ b/src/app/api/status/route.ts @@ -2,20 +2,11 @@ import { NextResponse } from 'next/server'; import { promises as fs } from 'fs'; import path from 'path'; import type { AiosStatus, AgentId } from '@/types'; +import { resolveProjectRoot } from '@/lib/project-registry'; // Status file path relative to project root const STATUS_FILE_NAME = '.aios/dashboard/status.json'; -// Get the project root path -// Priority: AIOS_PROJECT_ROOT env var > navigate from cwd -function getProjectRoot(): string { - if (process.env.AIOS_PROJECT_ROOT) { - return process.env.AIOS_PROJECT_ROOT; - } - // Default: assume running from apps/dashboard/ - return path.resolve(process.cwd(), '..', '..'); -} - // Default response when CLI is not running const DISCONNECTED_STATUS: AiosStatus = { version: '1.0', @@ -129,7 +120,7 @@ function validateStatusFile(data: unknown): AiosStatus | null { export async function GET() { try { // Resolve status file path from project root - const statusFilePath = path.join(getProjectRoot(), STATUS_FILE_NAME); + const statusFilePath = path.join(await resolveProjectRoot(), STATUS_FILE_NAME); // Try to read the status file const fileContent = await fs.readFile(statusFilePath, 'utf-8'); diff --git a/src/app/api/stories/[id]/route.ts b/src/app/api/stories/[id]/route.ts index 9e8648c..b31e239 100644 --- a/src/app/api/stories/[id]/route.ts +++ b/src/app/api/stories/[id]/route.ts @@ -3,14 +3,7 @@ import { promises as fs } from 'fs'; import path from 'path'; import matter from 'gray-matter'; import type { Story, StoryStatus, StoryComplexity, StoryPriority, StoryCategory, AgentId } from '@/types'; - -// Get the project root path -function getProjectRoot(): string { - if (process.env.AIOS_PROJECT_ROOT) { - return process.env.AIOS_PROJECT_ROOT; - } - return path.resolve(process.cwd(), '..', '..'); -} +import { resolveProjectRoot } from '@/lib/project-registry'; // Valid values for type checking const VALID_STATUS: StoryStatus[] = [ @@ -56,11 +49,11 @@ async function findStoryFile(dir: string, storyId: string): Promise ); + case 'backlog': + return ( + + ); + case 'agents': return ; @@ -115,6 +128,15 @@ function ViewContent({ view, onStoryClick, onRefresh, isLoading }: ViewContentPr case 'squads': return ; + case 'prds': + return ; + + case 'plans': + return ; + + case 'qa': + return ; + default: return ; } diff --git a/src/components/backlog/BacklogPanel.tsx b/src/components/backlog/BacklogPanel.tsx new file mode 100644 index 0000000..e011162 --- /dev/null +++ b/src/components/backlog/BacklogPanel.tsx @@ -0,0 +1,680 @@ +'use client'; + +import { useState, useMemo, useCallback } from 'react'; +import { + ChevronDown, + ChevronRight, + RefreshCw, + Layers, + FileText, + CheckCircle2, + Circle, + Filter, + X, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { useStoryStore } from '@/stores/story-store'; +import { ProgressBar } from '@/components/ui/progress-bar'; +import { StatusBadge } from '@/components/ui/status-badge'; +import { iconMap } from '@/lib/icons'; +import { + AGENT_CONFIG, + KANBAN_COLUMNS, + type Story, + type StoryStatus, + type StoryPriority, + type AgentId, +} from '@/types'; + +// ============ Props ============ + +interface BacklogPanelProps { + onStoryClick?: (story: Story) => void; + onRefresh?: () => void; + isLoading?: boolean; + className?: string; +} + +// ============ Types ============ + +interface EpicGroup { + epic: Story | null; + stories: Story[]; + taskCount: { total: number; completed: number }; + progress: number; +} + +type FilterStatus = StoryStatus | 'all'; +type FilterPriority = StoryPriority | 'all'; +type FilterAgent = AgentId | 'all'; + +// ============ Priority Config ============ + +const PRIORITY_CONFIG: Record = { + critical: { label: 'P0', color: 'var(--status-error)' }, + high: { label: 'P1', color: 'var(--status-warning)' }, + medium: { label: 'P2', color: 'var(--status-info)' }, + low: { label: 'P3', color: 'var(--text-muted)' }, +}; + +// ============ Component ============ + +export function BacklogPanel({ + onStoryClick, + onRefresh, + isLoading = false, + className, +}: BacklogPanelProps) { + const { stories, getEpics, getStoriesOnly } = useStoryStore(); + + // Filter state + const [statusFilter, setStatusFilter] = useState('all'); + const [priorityFilter, setPriorityFilter] = useState('all'); + const [agentFilter, setAgentFilter] = useState('all'); + const [showFilters, setShowFilters] = useState(false); + + // Collapse state β€” tracks collapsed epics (all expanded by default) + const [collapsedEpics, setCollapsedEpics] = useState>(new Set()); + const [expandedTasks, setExpandedTasks] = useState>(new Set()); + + const hasActiveFilters = statusFilter !== 'all' || priorityFilter !== 'all' || agentFilter !== 'all'; + + const clearFilters = useCallback(() => { + setStatusFilter('all'); + setPriorityFilter('all'); + setAgentFilter('all'); + }, []); + + // Build epic groups with filtered stories + const epicGroups = useMemo((): EpicGroup[] => { + const epics = getEpics(); + const allStories = getStoriesOnly(); + + // Apply filters + const filteredStories = allStories.filter((s) => { + if (statusFilter !== 'all' && s.status !== statusFilter) return false; + if (priorityFilter !== 'all' && s.priority !== priorityFilter) return false; + if (agentFilter !== 'all' && s.agentId !== agentFilter) return false; + return true; + }); + + const filteredEpics = epics.filter((e) => { + if (statusFilter !== 'all' && e.status !== statusFilter) return false; + if (priorityFilter !== 'all' && e.priority !== priorityFilter) return false; + return true; + }); + + // Group stories by epicId + const storyMap = new Map(); + const unassigned: Story[] = []; + + for (const story of filteredStories) { + if (story.epicId) { + const existing = storyMap.get(story.epicId) || []; + existing.push(story); + storyMap.set(story.epicId, existing); + } else { + unassigned.push(story); + } + } + + const groups: EpicGroup[] = []; + + // Add epic groups + for (const epic of filteredEpics) { + const epicStories = storyMap.get(epic.id) || storyMap.get(epic.epicId || '') || []; + // Also check if stories reference this epic by various ID patterns + const matchedStories = epicStories.length > 0 + ? epicStories + : filteredStories.filter((s) => s.epicId === epic.id); + + const taskCount = countTasks(matchedStories); + const progress = calculateEpicProgress(epic, matchedStories); + + groups.push({ epic, stories: matchedStories, taskCount, progress }); + } + + // Also show epics that only exist as epicId references (no epic Story object) + for (const [epicId, epicStories] of storyMap.entries()) { + const hasEpicObject = filteredEpics.some((e) => e.id === epicId || e.epicId === epicId); + if (!hasEpicObject && epicStories.length > 0) { + const taskCount = countTasks(epicStories); + const progress = calculateGroupProgress(epicStories); + groups.push({ + epic: null, + stories: epicStories, + taskCount, + progress, + }); + } + } + + // Sort epics: in_progress first, then by priority + groups.sort((a, b) => { + const statusOrder: Record = { in_progress: 0, backlog: 1, ai_review: 2, human_review: 3, done: 4, error: 5 }; + const aStatus = a.epic?.status || 'backlog'; + const bStatus = b.epic?.status || 'backlog'; + const statusDiff = (statusOrder[aStatus] ?? 3) - (statusOrder[bStatus] ?? 3); + if (statusDiff !== 0) return statusDiff; + + const priorityOrder: Record = { critical: 0, high: 1, medium: 2, low: 3 }; + const aPriority = a.epic?.priority || 'medium'; + const bPriority = b.epic?.priority || 'medium'; + return (priorityOrder[aPriority] ?? 2) - (priorityOrder[bPriority] ?? 2); + }); + + // Add unassigned stories at the end + if (unassigned.length > 0) { + const taskCount = countTasks(unassigned); + groups.push({ + epic: null, + stories: unassigned, + taskCount, + progress: calculateGroupProgress(unassigned), + }); + } + + return groups; + }, [stories, getEpics, getStoriesOnly, statusFilter, priorityFilter, agentFilter]); + + // Summary stats + const stats = useMemo(() => { + const allStories = Object.values(stories); + const epics = allStories.filter((s) => s.type === 'epic'); + const storyItems = allStories.filter((s) => s.type !== 'epic'); + const inProgress = storyItems.filter((s) => s.status === 'in_progress').length; + const done = storyItems.filter((s) => s.status === 'done').length; + return { epics: epics.length, stories: storyItems.length, inProgress, done }; + }, [stories]); + + const toggleEpic = useCallback((epicId: string) => { + setCollapsedEpics((prev) => { + const next = new Set(prev); + if (next.has(epicId)) { + next.delete(epicId); + } else { + next.add(epicId); + } + return next; + }); + }, []); + + const toggleTasks = useCallback((storyId: string) => { + setExpandedTasks((prev) => { + const next = new Set(prev); + if (next.has(storyId)) { + next.delete(storyId); + } else { + next.add(storyId); + } + return next; + }); + }, []); + + return ( +
+ {/* Header */} +
+
+

Backlog

+
+ {stats.epics} epics + | + {stats.stories} stories + | + {stats.inProgress} active + | + {stats.done} done +
+
+ +
+ {/* Filter toggle */} + + + {/* Refresh */} + {onRefresh && ( + + )} +
+
+ + {/* Filter Bar */} + {showFilters && ( +
+ {/* Status filter */} + setStatusFilter(v as FilterStatus)} + options={[ + { value: 'all', label: 'All' }, + ...KANBAN_COLUMNS.map((c) => ({ value: c.id, label: c.label })), + ]} + /> + + {/* Priority filter */} + setPriorityFilter(v as FilterPriority)} + options={[ + { value: 'all', label: 'All' }, + { value: 'critical', label: 'P0 Critical' }, + { value: 'high', label: 'P1 High' }, + { value: 'medium', label: 'P2 Medium' }, + { value: 'low', label: 'P3 Low' }, + ]} + /> + + {/* Agent filter */} + setAgentFilter(v as FilterAgent)} + options={[ + { value: 'all', label: 'All' }, + ...Object.entries(AGENT_CONFIG).map(([id, cfg]) => ({ + value: id, + label: `@${id} (${cfg.name})`, + })), + ]} + /> + + {hasActiveFilters && ( + + )} +
+ )} + + {/* Content */} +
+ {epicGroups.length === 0 ? ( +
+ +

No items match current filters

+ {hasActiveFilters && ( + + )} +
+ ) : ( +
+ {epicGroups.map((group, idx) => ( + toggleEpic(group.epic?.id || `group-${idx}`)} + onToggleTasks={toggleTasks} + onStoryClick={onStoryClick} + /> + ))} +
+ )} +
+
+ ); +} + +// ============ Epic Section ============ + +interface EpicSectionProps { + group: EpicGroup; + isExpanded: boolean; + expandedTasks: Set; + onToggleExpand: () => void; + onToggleTasks: (storyId: string) => void; + onStoryClick?: (story: Story) => void; +} + +function EpicSection({ + group, + isExpanded, + expandedTasks, + onToggleExpand, + onToggleTasks, + onStoryClick, +}: EpicSectionProps) { + const { epic, stories, taskCount, progress } = group; + const isUnassigned = !epic; + const title = epic?.title || 'Unassigned Stories'; + const storyCount = stories.length; + const statusColumn = epic ? KANBAN_COLUMNS.find((c) => c.id === epic.status) : null; + + return ( +
+ {/* Epic Header */} + + + {/* Expanded stories */} + {isExpanded && stories.length > 0 && ( +
+ {stories.map((story) => ( + onToggleTasks(story.id)} + onClick={() => onStoryClick?.(story)} + /> + ))} +
+ )} + + {/* Expanded but empty */} + {isExpanded && stories.length === 0 && ( +
+ No stories in this epic +
+ )} +
+ ); +} + +// ============ Story Row ============ + +interface StoryRowProps { + story: Story; + showTasks: boolean; + onToggleTasks: () => void; + onClick?: () => void; +} + +function StoryRow({ story, showTasks, onToggleTasks, onClick }: StoryRowProps) { + const hasTasks = story.acceptanceCriteria && story.acceptanceCriteria.length > 0; + const statusColumn = KANBAN_COLUMNS.find((c) => c.id === story.status); + const agentConfig = story.agentId ? AGENT_CONFIG[story.agentId] : null; + + return ( +
+
+ {/* Task expand toggle */} + + + {/* Story icon */} + + + {/* Story ID */} + + {story.id} + + + {/* Title (clickable) */} + + + {/* Category */} + {story.category && ( + + {story.category} + + )} + + {/* Status badge */} + + + {/* Priority */} + {story.priority && ( + + {PRIORITY_CONFIG[story.priority].label} + + )} + + {/* Agent */} + {agentConfig && ( + + {(() => { + const Icon = iconMap[agentConfig.icon]; + return Icon ? : null; + })()} + @{story.agentId} + + )} + + {/* Tasks indicator */} + {hasTasks && ( + + {countCompletedCriteria(story)}/{story.acceptanceCriteria!.length} + + )} + + {/* Progress */} + {typeof story.progress === 'number' && story.progress > 0 && ( +
+ +
+ )} +
+ + {/* Acceptance Criteria (Tasks) */} + {showTasks && hasTasks && ( +
+ {story.acceptanceCriteria!.map((criterion, i) => ( + + ))} +
+ )} +
+ ); +} + +// ============ Task Item ============ + +interface TaskItemProps { + text: string; + storyStatus: StoryStatus; +} + +function TaskItem({ text, storyStatus }: TaskItemProps) { + const isDone = storyStatus === 'done'; + + return ( +
+ {isDone ? ( + + ) : ( + + )} + + {text} + +
+ ); +} + +// ============ Filter Select ============ + +interface FilterSelectProps { + label: string; + value: string; + onChange: (value: string) => void; + options: { value: string; label: string }[]; +} + +function FilterSelect({ label, value, onChange, options }: FilterSelectProps) { + return ( +
+ {label}: + +
+ ); +} + +// ============ Helpers ============ + +function countTasks(stories: Story[]): { total: number; completed: number } { + let total = 0; + let completed = 0; + for (const story of stories) { + if (story.acceptanceCriteria) { + total += story.acceptanceCriteria.length; + if (story.status === 'done') { + completed += story.acceptanceCriteria.length; + } + } + } + return { total, completed }; +} + +function countCompletedCriteria(story: Story): number { + if (story.status === 'done' && story.acceptanceCriteria) { + return story.acceptanceCriteria.length; + } + return 0; +} + +function calculateEpicProgress(epic: Story, stories: Story[]): number { + if (epic.status === 'done') return 100; + if (stories.length === 0) return epic.progress || 0; + return calculateGroupProgress(stories); +} + +function calculateGroupProgress(stories: Story[]): number { + if (stories.length === 0) return 0; + const doneCount = stories.filter((s) => s.status === 'done').length; + const inProgressCount = stories.filter((s) => s.status === 'in_progress').length; + // Done stories count 100%, in_progress count 50% + const totalProgress = doneCount * 100 + inProgressCount * 50; + return Math.round(totalProgress / stories.length); +} diff --git a/src/components/backlog/index.ts b/src/components/backlog/index.ts new file mode 100644 index 0000000..8a39968 --- /dev/null +++ b/src/components/backlog/index.ts @@ -0,0 +1 @@ +export * from './BacklogPanel'; diff --git a/src/components/bob/BobOrchestrationView.tsx b/src/components/bob/BobOrchestrationView.tsx index 0f83a79..d1e3c0d 100644 --- a/src/components/bob/BobOrchestrationView.tsx +++ b/src/components/bob/BobOrchestrationView.tsx @@ -6,6 +6,7 @@ import { BobPipelinePanel } from '@/components/bob/BobPipelinePanel'; import { BobAgentActivity } from '@/components/bob/BobAgentActivity'; import { BobSurfaceAlert } from '@/components/bob/BobSurfaceAlert'; import { cn } from '@/lib/utils'; +import { apiUrl } from '@/lib/api'; import { Bot, AlertCircle, XCircle } from 'lucide-react'; import type { BobError } from '@/stores/bob-store'; @@ -25,7 +26,7 @@ export const BobOrchestrationView = memo(function BobOrchestrationView() { useEffect(() => { async function fetchStatus() { try { - const res = await fetch('/api/bob/status'); + const res = await fetch(apiUrl('/api/bob/status')); if (res.ok) { const data = await res.json(); updateFromStatus(data); @@ -50,7 +51,7 @@ export const BobOrchestrationView = memo(function BobOrchestrationView() { // SSE for real-time events useEffect(() => { - const eventSource = new EventSource('/api/bob/events'); + const eventSource = new EventSource(apiUrl('/api/bob/events')); eventSourceRef.current = eventSource; eventSource.addEventListener('bob:status', (e) => { diff --git a/src/components/context/ContextPanel.tsx b/src/components/context/ContextPanel.tsx index c5d8ed4..fd262a2 100644 --- a/src/components/context/ContextPanel.tsx +++ b/src/components/context/ContextPanel.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useState } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { Brain, FileText, @@ -14,10 +14,13 @@ import { CheckCircle2, XCircle, AlertCircle, + RefreshCw, + Loader2, } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { apiUrl } from '@/lib/api'; import { useSettingsStore } from '@/stores/settings-store'; -import { MOCK_CONTEXT, type ContextFile, type ContextMCP } from '@/lib/mock-data'; +import { MOCK_CONTEXT, type ContextFile, type ContextMCP, type ContextData } from '@/lib/mock-data'; import { Badge } from '@/components/ui/badge'; interface CollapsibleSectionProps { @@ -124,16 +127,67 @@ function MCPCard({ mcp }: { mcp: ContextMCP }) { export function ContextPanel() { const { settings } = useSettingsStore(); - const data = settings.useMockData ? MOCK_CONTEXT : null; + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); - if (!settings.useMockData || !data) { + const fetchContext = useCallback(async () => { + if (settings.useMockData) { + setData(MOCK_CONTEXT); + return; + } + setLoading(true); + setError(null); + try { + const res = await fetch(apiUrl('/api/context')); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const json = await res.json(); + setData(json); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to fetch context'); + } finally { + setLoading(false); + } + }, [settings.useMockData]); + + useEffect(() => { + fetchContext(); + }, [fetchContext]); + + if (loading && !data) { + return ( +
+
+ +

Loading project context...

+
+
+ ); + } + + if (error && !data) { + return ( +
+
+ +

Failed to Load Context

+

{error}

+ +
+
+ ); + } + + if (!data) { return (

No Context Available

- Enable Demo Mode in Settings to see project context + No project data found

@@ -148,9 +202,18 @@ export function ContextPanel() {

Context

- - {data.projectName} - +
+ + + {data.projectName} + +
{/* Project Info */} diff --git a/src/components/github/GitHubPanel.tsx b/src/components/github/GitHubPanel.tsx index e1808c3..7478418 100644 --- a/src/components/github/GitHubPanel.tsx +++ b/src/components/github/GitHubPanel.tsx @@ -6,6 +6,7 @@ import { cn } from '@/lib/utils'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { useSettingsStore } from '@/stores/settings-store'; +import { apiUrl } from '@/lib/api'; import { MOCK_PULL_REQUESTS, MOCK_ISSUES } from '@/lib/mock-data'; interface GitHubIssue { @@ -88,7 +89,7 @@ export function GitHubPanel() { const useMockData = settings.useMockData; const { data: apiData, error, isLoading, mutate } = useSWR( - useMockData ? null : '/api/github', + useMockData ? null : apiUrl('/api/github'), fetcher, { refreshInterval: 60000, diff --git a/src/components/insights/InsightsPanel.tsx b/src/components/insights/InsightsPanel.tsx index bdcecb9..1191955 100644 --- a/src/components/insights/InsightsPanel.tsx +++ b/src/components/insights/InsightsPanel.tsx @@ -1,9 +1,11 @@ 'use client'; -import { TrendingUp, TrendingDown, Clock, AlertTriangle, CheckCircle2, Activity } from 'lucide-react'; +import { useState, useEffect, useCallback } from 'react'; +import { TrendingUp, TrendingDown, Clock, AlertTriangle, CheckCircle2, Activity, RefreshCw, Loader2 } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { apiUrl } from '@/lib/api'; import { useSettingsStore } from '@/stores/settings-store'; -import { MOCK_INSIGHTS } from '@/lib/mock-data'; +import { MOCK_INSIGHTS, type InsightsMetrics } from '@/lib/mock-data'; import { Badge } from '@/components/ui/badge'; import { AGENT_CONFIG, type AgentId } from '@/types'; import { iconMap } from '@/lib/icons'; @@ -72,16 +74,54 @@ function BarChart({ data, maxValue }: { data: { label: string; value: number; co export function InsightsPanel() { const { settings } = useSettingsStore(); - const data = settings.useMockData ? MOCK_INSIGHTS : null; + const useMockData = settings.useMockData; + const [data, setData] = useState(null); + const [loading, setLoading] = useState(false); + const [lastUpdated, setLastUpdated] = useState(null); - if (!settings.useMockData || !data) { + const fetchInsights = useCallback(async () => { + setLoading(true); + try { + if (useMockData) { + await new Promise(resolve => setTimeout(resolve, 200)); + setData(MOCK_INSIGHTS); + } else { + const res = await fetch(apiUrl('/api/insights')); + if (res.ok) { + const json = await res.json(); + setData(json); + } + } + setLastUpdated(new Date()); + } catch (error) { + console.error('Failed to fetch insights:', error); + } finally { + setLoading(false); + } + }, [useMockData]); + + useEffect(() => { + fetchInsights(); + const interval = setInterval(fetchInsights, 30000); + return () => clearInterval(interval); + }, [fetchInsights]); + + if (loading && !data) { + return ( +
+ +
+ ); + } + + if (!data) { return (

No Insights Available

- Enable Demo Mode in Settings to see sample analytics + No story data found to compute insights

@@ -99,6 +139,9 @@ export function InsightsPanel() {

Insights

This Week + {/* Content */} @@ -240,10 +283,10 @@ export function InsightsPanel() { {/* Footer */}
- {settings.useMockData ? 'Showing mock analytics' : 'Real-time data'} + {useMockData ? 'Demo Mode' : 'Live story data'} - Last updated: {new Date().toLocaleTimeString()} + {lastUpdated ? `Updated ${lastUpdated.toLocaleTimeString()}` : ''}
diff --git a/src/components/kanban/KanbanBoard.tsx b/src/components/kanban/KanbanBoard.tsx index d4bad24..06d0960 100644 --- a/src/components/kanban/KanbanBoard.tsx +++ b/src/components/kanban/KanbanBoard.tsx @@ -155,13 +155,13 @@ export function KanbanBoard({ if (!overStory) return; targetStatus = overStory.status; - const targetStories = getStoriesByStatus(targetStatus, 'story'); + const targetStories = getStoriesByStatus(targetStatus); targetIndex = targetStories.findIndex((s) => s.id === overId); } // Same column reorder if (activeStory.status === targetStatus) { - const stories = getStoriesByStatus(targetStatus, 'story'); + const stories = getStoriesByStatus(targetStatus); const oldIndex = stories.findIndex((s) => s.id === activeId); if (oldIndex !== -1 && targetIndex !== undefined && oldIndex !== targetIndex) { reorderInColumn(targetStatus, oldIndex, targetIndex); @@ -232,7 +232,7 @@ export function KanbanBoard({ toggleColumnCollapse(column.id)} onStoryClick={handleStoryClick} diff --git a/src/components/layout/StatusBar.tsx b/src/components/layout/StatusBar.tsx index 7d1b32b..a4dd266 100644 --- a/src/components/layout/StatusBar.tsx +++ b/src/components/layout/StatusBar.tsx @@ -2,10 +2,12 @@ import { useAiosStatus } from '@/hooks/use-aios-status'; import { useBobStore } from '@/stores/bob-store'; +import { useMonitorStore } from '@/stores/monitor-store'; import { AGENT_CONFIG, type AgentId } from '@/types'; import { cn } from '@/lib/utils'; -import { Bell, Bot } from 'lucide-react'; +import { Bell, Bot, Wifi, Radio } from 'lucide-react'; import { iconMap } from '@/lib/icons'; +import { useState } from 'react'; interface StatusBarProps { className?: string; @@ -39,6 +41,11 @@ export function StatusBar({ className }: StatusBarProps) { + {/* Center section β€” Connection indicators */} +
+ +
+ {/* Right section */}
{/* Bob Status */} @@ -164,6 +171,60 @@ function BobStatusIndicator() { ); } +function ConnectionIndicators() { + const monitorConnected = useMonitorStore((s) => s.connected); + const monitorEvents = useMonitorStore((s) => s.events); + const { isConnected: sseConnected } = useAiosStatus(); + const bobActive = useBobStore((s) => s.active); + const [showEventCount, setShowEventCount] = useState(false); + + return ( + <> + {/* WebSocket (Monitor) indicator */} +
setShowEventCount(!showEventCount)} + > + + + {showEventCount && monitorConnected && ( + {monitorEvents.length} + )} +
+ + {/* SSE (Status) indicator */} +
+ + +
+ + {/* SSE (Bob) indicator */} + {bobActive && ( +
+ + +
+ )} + + ); +} + function NotificationBadge({ count }: NotificationBadgeProps) { return (
)} + {/* Offline Banner */} + {!connected && error && ( +
+

+ Monitor Offline +

+

+ Connect monitor-server for real-time events. Run:{' '} + + aiox monitor start + +

+ +
+ )} + {/* Activity Feed */}
diff --git a/src/components/plans/PlansPanel.tsx b/src/components/plans/PlansPanel.tsx new file mode 100644 index 0000000..5226c2e --- /dev/null +++ b/src/components/plans/PlansPanel.tsx @@ -0,0 +1,174 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { + Target, + ChevronRight, + RefreshCw, + Loader2, + FolderOpen, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { apiUrl } from '@/lib/api'; +import { Badge } from '@/components/ui/badge'; + +interface PlanItem { + id: string; + name: string; + description: string; + category: string; + filePath: string; +} + +interface PlanGroup { + category: string; + label: string; + count: number; + items: PlanItem[]; +} + +const CATEGORY_COLORS: Record = { + database: 'text-purple-500 bg-purple-500/10', + development: 'text-blue-500 bg-blue-500/10', + quality: 'text-green-500 bg-green-500/10', + devops: 'text-orange-500 bg-orange-500/10', + creation: 'text-cyan-500 bg-cyan-500/10', + analysis: 'text-yellow-500 bg-yellow-500/10', + architecture: 'text-red-500 bg-red-500/10', + brownfield: 'text-amber-500 bg-amber-500/10', + general: 'text-muted-foreground bg-muted', +}; + +export function PlansPanel() { + const [grouped, setGrouped] = useState([]); + const [total, setTotal] = useState(0); + const [loading, setLoading] = useState(false); + const [expandedCategories, setExpandedCategories] = useState>(new Set()); + + const fetchPlans = useCallback(async () => { + setLoading(true); + try { + const res = await fetch(apiUrl('/api/plans')); + if (res.ok) { + const json = await res.json(); + setGrouped(json.grouped || []); + setTotal(json.total || 0); + // Auto-expand first 3 categories + const cats = (json.grouped || []).slice(0, 3).map((g: PlanGroup) => g.category); + setExpandedCategories(new Set(cats)); + } + } catch (error) { + console.error('Failed to fetch plans:', error); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + fetchPlans(); + }, [fetchPlans]); + + const toggleCategory = (category: string) => { + setExpandedCategories(prev => { + const next = new Set(prev); + if (next.has(category)) { + next.delete(category); + } else { + next.add(category); + } + return next; + }); + }; + + if (loading && grouped.length === 0) { + return ( +
+ +
+ ); + } + + if (grouped.length === 0 && !loading) { + return ( +
+
+ +

No Task Templates Found

+

+ Task templates will appear from .aiox-core/development/tasks/ +

+
+
+ ); + } + + return ( +
+ {/* Header */} +
+
+ +

Plans & Tasks

+ {total} templates +
+ +
+ + {/* Content */} +
+ {grouped.map((group) => { + const isExpanded = expandedCategories.has(group.category); + const colorClass = CATEGORY_COLORS[group.category] || CATEGORY_COLORS.general; + + return ( +
+ + {isExpanded && ( +
+ {group.items.map((item) => ( +
+ +
+

{item.name}

+ {item.description && ( +

{item.description}

+ )} +
+
+ ))} +
+ )} +
+ ); + })} +
+ + {/* Footer */} +
+ + From .aiox-core/development/tasks/ + + + {grouped.length} categories + +
+
+ ); +} diff --git a/src/components/plans/index.ts b/src/components/plans/index.ts new file mode 100644 index 0000000..fc5d385 --- /dev/null +++ b/src/components/plans/index.ts @@ -0,0 +1 @@ +export { PlansPanel } from './PlansPanel'; diff --git a/src/components/prd/PRDPanel.tsx b/src/components/prd/PRDPanel.tsx new file mode 100644 index 0000000..22cb68c --- /dev/null +++ b/src/components/prd/PRDPanel.tsx @@ -0,0 +1,197 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { + FileText, + ChevronRight, + RefreshCw, + Loader2, + ArrowLeft, + User, + Clock, + BookOpen, +} from 'lucide-react'; +import { cn } from '@/lib/utils'; +import { apiUrl } from '@/lib/api'; +import { Badge } from '@/components/ui/badge'; +import { MarkdownRenderer } from '@/components/ui/markdown-renderer'; + +interface PRDSummary { + slug: string; + title: string; + version: string; + author: string; + date: string; + description: string; + filePath: string; + category: 'prd' | 'architecture'; +} + +export function PRDPanel() { + const [prds, setPrds] = useState([]); + const [loading, setLoading] = useState(false); + const [selectedSlug, setSelectedSlug] = useState(null); + const [prdContent, setPrdContent] = useState(null); + const [contentLoading, setContentLoading] = useState(false); + + const fetchPRDs = useCallback(async () => { + setLoading(true); + try { + const res = await fetch(apiUrl('/api/prds')); + if (res.ok) { + const json = await res.json(); + setPrds(json.prds || []); + } + } catch (error) { + console.error('Failed to fetch PRDs:', error); + } finally { + setLoading(false); + } + }, []); + + const fetchPRDContent = useCallback(async (slug: string) => { + setContentLoading(true); + try { + const res = await fetch(apiUrl(`/api/prds/${slug}`)); + if (res.ok) { + const json = await res.json(); + setPrdContent(json.content || ''); + } + } catch (error) { + console.error('Failed to fetch PRD content:', error); + } finally { + setContentLoading(false); + } + }, []); + + useEffect(() => { + fetchPRDs(); + }, [fetchPRDs]); + + const handleSelect = (slug: string) => { + setSelectedSlug(slug); + fetchPRDContent(slug); + }; + + const handleBack = () => { + setSelectedSlug(null); + setPrdContent(null); + }; + + if (loading && prds.length === 0) { + return ( +
+ +
+ ); + } + + if (prds.length === 0 && !loading) { + return ( +
+
+ +

No PRDs Found

+

+ Add PRD files as docs/prd-*.md in your project +

+
+
+ ); + } + + // Detail view + if (selectedSlug && prdContent !== null) { + const prd = prds.find(p => p.slug === selectedSlug); + return ( +
+
+ + {prd?.category === 'architecture' ? ( + + ) : ( + + )} +
+

{prd?.title || selectedSlug}

+
+ {prd?.category === 'architecture' ? ( + Architecture + ) : ( + prd?.version && v{prd.version} + )} + {prd?.author && {prd.author}} + {prd?.date && {prd.date}} +
+
+
+
+ {contentLoading ? ( +
+ +
+ ) : ( + + )} +
+
+ ); + } + + // List view + return ( +
+
+
+ +

PRDs & Architecture

+ {prds.length} +
+ +
+ +
+ {prds.map((prd) => ( + + ))} +
+ +
+ + Scanned from docs/prd-*.md + docs/*architecture*.md + + + {prds.length} document{prds.length !== 1 ? 's' : ''} + +
+
+ ); +} diff --git a/src/components/prd/index.ts b/src/components/prd/index.ts new file mode 100644 index 0000000..8120c61 --- /dev/null +++ b/src/components/prd/index.ts @@ -0,0 +1 @@ +export { PRDPanel } from './PRDPanel'; diff --git a/src/components/qa/QAMetricsPanel.tsx b/src/components/qa/QAMetricsPanel.tsx index 59ef1cb..94660ab 100644 --- a/src/components/qa/QAMetricsPanel.tsx +++ b/src/components/qa/QAMetricsPanel.tsx @@ -12,14 +12,14 @@ import { TrendingDown, RefreshCw, Bug, + Loader2, } from 'lucide-react'; import { cn } from '@/lib/utils'; +import { apiUrl } from '@/lib/api'; +import { useSettingsStore } from '@/stores/settings-store'; import { Badge } from '@/components/ui/badge'; -import { Button } from '@/components/ui/button'; -// ═══════════════════════════════════════════════════════════════════════════════════ -// TYPES -// ═══════════════════════════════════════════════════════════════════════════════════ +// ============ QA Metrics Types ============ interface QAMetrics { overview: { @@ -65,137 +65,53 @@ interface QAMetrics { }>; } -// ═══════════════════════════════════════════════════════════════════════════════════ -// MOCK DATA -// ═══════════════════════════════════════════════════════════════════════════════════ - const MOCK_QA_METRICS: QAMetrics = { - overview: { - totalReviews: 47, - passRate: 89, - avgReviewTime: '4.2m', - trend: 'improving', - }, - libraryValidation: { - librariesChecked: 156, - validationsPassed: 148, - deprecatedFound: 3, - securityIssues: 2, - }, - securityChecklist: { - totalChecks: 376, - passed: 361, - failed: 15, - critical: 2, - }, - migrationValidation: { - migrationsChecked: 23, - schemasValid: 21, - rollbacksAvailable: 19, - pendingMigrations: 2, - }, - patternFeedback: { - patternsTracked: 34, - deprecatedPatterns: 3, - avgSuccessRate: 0.82, - recentTrend: 'improving', - }, - gotchas: { - totalGotchas: 28, - recentlyAdded: 5, - mostCommonCategory: 'api', - queriesServed: 142, - }, + overview: { totalReviews: 142, passRate: 89, avgReviewTime: '3.2m', trend: 'improving' }, + libraryValidation: { librariesChecked: 48, validationsPassed: 45, deprecatedFound: 2, securityIssues: 1 }, + securityChecklist: { totalChecks: 320, passed: 308, failed: 12, critical: 2 }, + migrationValidation: { migrationsChecked: 18, schemasValid: 17, rollbacksAvailable: 15, pendingMigrations: 1 }, + patternFeedback: { patternsTracked: 24, deprecatedPatterns: 3, avgSuccessRate: 0.87, recentTrend: 'improving' }, + gotchas: { totalGotchas: 36, recentlyAdded: 5, mostCommonCategory: 'async-patterns', queriesServed: 180 }, dailyTrend: [ - { date: 'Mon', passed: 8, failed: 1 }, - { date: 'Tue', passed: 6, failed: 2 }, - { date: 'Wed', passed: 9, failed: 0 }, - { date: 'Thu', passed: 7, failed: 1 }, - { date: 'Fri', passed: 5, failed: 1 }, - { date: 'Sat', passed: 3, failed: 0 }, - { date: 'Sun', passed: 4, failed: 0 }, + { date: 'Mon', passed: 18, failed: 2 }, + { date: 'Tue', passed: 22, failed: 3 }, + { date: 'Wed', passed: 15, failed: 1 }, + { date: 'Thu', passed: 25, failed: 2 }, + { date: 'Fri', passed: 20, failed: 4 }, + { date: 'Sat', passed: 8, failed: 0 }, + { date: 'Sun', passed: 5, failed: 1 }, ], }; -// ═══════════════════════════════════════════════════════════════════════════════════ -// COMPONENTS -// ═══════════════════════════════════════════════════════════════════════════════════ +// ============ Sub-components ============ -interface MetricCardProps { - title: string; - value: string | number; - subtitle?: string; - icon: React.ReactNode; - color: string; - bgColor: string; -} - -function MetricCard({ title, value, subtitle, icon, color, bgColor }: MetricCardProps) { +function StatCard({ label, value, icon: Icon, color }: { label: string; value: string | number; icon: typeof Shield; color: string }) { return ( -
-
-
-

{title}

-

{value}

- {subtitle && ( -

{subtitle}

- )} -
-
- {icon} -
+
+
+ + {label}
+ {value}
); } -interface TrendBadgeProps { - trend: 'improving' | 'declining' | 'stable' | 'neutral'; -} - -function TrendBadge({ trend }: TrendBadgeProps) { - const config = { - improving: { icon: TrendingUp, color: 'text-green-500', bg: 'bg-green-500/10', label: 'Improving' }, - declining: { icon: TrendingDown, color: 'text-red-500', bg: 'bg-red-500/10', label: 'Declining' }, - stable: { icon: null, color: 'text-muted-foreground', bg: 'bg-muted', label: 'Stable' }, - neutral: { icon: null, color: 'text-muted-foreground', bg: 'bg-muted', label: 'Neutral' }, - }[trend]; - - const Icon = config.icon; - - return ( - - {Icon && } - {config.label} - - ); -} - -function MiniBarChart({ data }: { data: Array<{ date: string; passed: number; failed: number }> }) { - const maxValue = Math.max(...data.map(d => d.passed + d.failed)); - +function TrendBar({ data }: { data: Array<{ date: string; passed: number; failed: number }> }) { + const maxVal = Math.max(...data.map(d => d.passed + d.failed), 1); return (
- {data.map((day) => { - const total = day.passed + day.failed; - const passedHeight = (day.passed / maxValue) * 100; - const failedHeight = (day.failed / maxValue) * 100; - + {data.map((d) => { + const total = d.passed + d.failed; + const passHeight = (d.passed / maxVal) * 100; + const failHeight = (d.failed / maxVal) * 100; return ( -
-
-
- {day.failed > 0 && ( -
- )} +
+
+
+ {failHeight > 0 &&
}
- {day.date} + {d.date}
); })} @@ -203,15 +119,30 @@ function MiniBarChart({ data }: { data: Array<{ date: string; passed: number; fa ); } -// ═══════════════════════════════════════════════════════════════════════════════════ -// MAIN COMPONENT -// ═══════════════════════════════════════════════════════════════════════════════════ - -interface QAMetricsPanelProps { - useMockData?: boolean; +function ModuleCard({ title, icon: Icon, color, stats }: { title: string; icon: typeof Shield; color: string; stats: { label: string; value: string | number }[] }) { + return ( +
+
+ + {title} +
+
+ {stats.map((s) => ( +
+ {s.label} +

{s.value}

+
+ ))} +
+
+ ); } -export function QAMetricsPanel({ useMockData = true }: QAMetricsPanelProps) { +// ============ Main Component ============ + +export function QAMetricsPanel() { + const { settings } = useSettingsStore(); + const useMockData = settings.useMockData; const [metrics, setMetrics] = useState(null); const [loading, setLoading] = useState(false); const [lastUpdated, setLastUpdated] = useState(null); @@ -220,11 +151,10 @@ export function QAMetricsPanel({ useMockData = true }: QAMetricsPanelProps) { setLoading(true); try { if (useMockData) { - // Simulate API delay - await new Promise(resolve => setTimeout(resolve, 500)); + await new Promise(resolve => setTimeout(resolve, 300)); setMetrics(MOCK_QA_METRICS); } else { - const response = await fetch('/api/qa/metrics'); + const response = await fetch(apiUrl('/api/qa/metrics')); if (response.ok) { const data = await response.json(); setMetrics(data); @@ -240,97 +170,76 @@ export function QAMetricsPanel({ useMockData = true }: QAMetricsPanelProps) { useEffect(() => { fetchMetrics(); - - // Auto-refresh every 30 seconds const interval = setInterval(fetchMetrics, 30000); return () => clearInterval(interval); }, [fetchMetrics]); + if (loading && !metrics) { + return ( +
+ +
+ ); + } + if (!metrics) { return (
- -

Loading QA Metrics...

+ +

No QA Data

+

+ QA metrics will appear once reviews are recorded +

); } + const { overview, libraryValidation, securityChecklist, migrationValidation, patternFeedback, gotchas, dailyTrend } = metrics; + return (
{/* Header */}
- +

QA Metrics

- + + {overview.trend === 'improving' && } + {overview.trend === 'declining' && } + {overview.trend} +
- +
{/* Content */}
- {/* Overview */} + {/* Overview Stats */}

Overview

-
- } - color="text-blue-500" - bgColor="bg-blue-500/10" - /> - } - color="text-green-500" - bgColor="bg-green-500/10" - /> - } - color="text-purple-500" - bgColor="bg-purple-500/10" - /> - } - color={metrics.securityChecklist.critical > 0 ? 'text-red-500' : 'text-green-500'} - bgColor={metrics.securityChecklist.critical > 0 ? 'bg-red-500/10' : 'bg-green-500/10'} - /> +
+ + + +
{/* Daily Trend */}
-

Daily Trend (7 days)

+

Daily Trend

- -
-
-
- Passed -
-
-
- Failed -
+ +
+ Passed + Failed
@@ -339,85 +248,39 @@ export function QAMetricsPanel({ useMockData = true }: QAMetricsPanelProps) {

Validation Modules

- {/* Library Validation */} -
-
- - Library Validation -
-
-
- Checked - {metrics.libraryValidation.librariesChecked} -
-
- Passed - {metrics.libraryValidation.validationsPassed} -
-
- Deprecated - {metrics.libraryValidation.deprecatedFound} -
-
- Security Issues - {metrics.libraryValidation.securityIssues} -
-
-
- - {/* Security Checklist */} -
-
- - Security Checklist -
-
-
- Total Checks - {metrics.securityChecklist.totalChecks} -
-
- Passed - {metrics.securityChecklist.passed} -
-
- Failed - {metrics.securityChecklist.failed} -
-
- Critical - 0 ? 'text-red-500 font-bold' : 'text-green-500'}> - {metrics.securityChecklist.critical} - -
-
-
- - {/* Migration Validation */} -
-
- - Migration Validation -
-
-
- Checked - {metrics.migrationValidation.migrationsChecked} -
-
- Valid Schemas - {metrics.migrationValidation.schemasValid} -
-
- Rollbacks Ready - {metrics.migrationValidation.rollbacksAvailable} -
-
- Pending - {metrics.migrationValidation.pendingMigrations} -
-
-
+ + +
@@ -425,60 +288,28 @@ export function QAMetricsPanel({ useMockData = true }: QAMetricsPanelProps) {

Learning System

- {/* Pattern Feedback */} -
-
-
- - Pattern Feedback -
- -
-
-
-

{metrics.patternFeedback.patternsTracked}

-

Patterns Tracked

-
-
-

- {Math.round(metrics.patternFeedback.avgSuccessRate * 100)}% -

-

Avg Success Rate

-
-
-

{metrics.patternFeedback.deprecatedPatterns}

-

Deprecated

-
-
-
- - {/* Gotchas Registry */} -
-
-
- - Gotchas Registry -
- - +{metrics.gotchas.recentlyAdded} new - -
-
-
-

{metrics.gotchas.totalGotchas}

-

Total Gotchas

-
-
-

{metrics.gotchas.queriesServed}

-

Queries Served

-
-
-

- Most common: {metrics.gotchas.mostCommonCategory} -

-
-
-
+ +
@@ -486,10 +317,10 @@ export function QAMetricsPanel({ useMockData = true }: QAMetricsPanelProps) { {/* Footer */}
- {useMockData ? 'Demo data' : 'Live data'} + {useMockData ? 'Demo Mode' : 'Live QA data'} - Updated: {lastUpdated?.toLocaleTimeString() || 'Never'} + {lastUpdated ? `Updated ${lastUpdated.toLocaleTimeString()}` : ''}
diff --git a/src/components/qa/index.ts b/src/components/qa/index.ts new file mode 100644 index 0000000..290c364 --- /dev/null +++ b/src/components/qa/index.ts @@ -0,0 +1 @@ +export { QAMetricsPanel } from './QAMetricsPanel'; diff --git a/src/components/roadmap/RoadmapView.tsx b/src/components/roadmap/RoadmapView.tsx index 60e95c2..17803aa 100644 --- a/src/components/roadmap/RoadmapView.tsx +++ b/src/components/roadmap/RoadmapView.tsx @@ -1,9 +1,10 @@ 'use client'; -import { useState, useMemo } from 'react'; +import { useState, useEffect, useCallback, useMemo } from 'react'; import { cn } from '@/lib/utils'; import { iconMap } from '@/lib/icons'; import { RoadmapCard } from './RoadmapCard'; +import { apiUrl } from '@/lib/api'; import { useSettingsStore } from '@/stores/settings-store'; import { MOCK_ROADMAP_ITEMS } from '@/lib/mock-data'; import { ROADMAP_PRIORITY_CONFIG, type RoadmapItem, type RoadmapPriority } from '@/types'; @@ -16,10 +17,33 @@ interface RoadmapViewProps { export function RoadmapView({ className }: RoadmapViewProps) { const { settings } = useSettingsStore(); + const useMockData = settings.useMockData; const [filterMode, setFilterMode] = useState('all'); - - // In a real app, this would come from a roadmap store - const items: RoadmapItem[] = settings.useMockData ? MOCK_ROADMAP_ITEMS : []; + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + + const fetchRoadmap = useCallback(async () => { + setLoading(true); + try { + if (useMockData) { + setItems(MOCK_ROADMAP_ITEMS); + } else { + const res = await fetch(apiUrl('/api/roadmap')); + if (res.ok) { + const json = await res.json(); + setItems(json.items || []); + } + } + } catch (error) { + console.error('Failed to fetch roadmap:', error); + } finally { + setLoading(false); + } + }, [useMockData]); + + useEffect(() => { + fetchRoadmap(); + }, [fetchRoadmap]); const MapIcon = iconMap['map']; const PlusIcon = iconMap['plus']; @@ -47,13 +71,13 @@ export function RoadmapView({ className }: RoadmapViewProps) { { id: 'impact', label: 'Impact' }, ]; - if (!settings.useMockData || items.length === 0) { + if (items.length === 0 && !loading) { return (

No Roadmap Items

- Enable Demo Mode in Settings to see sample roadmap. + Add a roadmap file at docs/roadmap.md or enable Demo Mode.