I mean, it's at a good place rn...

This commit is contained in:
Renn F
2026-04-20 15:10:54 +02:00
parent 0023c25d60
commit 8e201901c0
264 changed files with 36484 additions and 748 deletions
+18 -1
View File
@@ -24,4 +24,21 @@ Makefile
# NAS recycle bin (UGREEN NAS)
\#recycle
Recycling Bin
Recycling Bin
# Panel (Next.js) build artifacts — re-generated by pnpm install / next build
panel/node_modules
panel/.next
panel/out
panel/coverage
panel/tsconfig.tsbuildinfo
# Local stateful data (postgres, redis, ollama, workspaces) — never in images
data/
logs/
*.log
# VCS and editor cruft
.idea
.vscode
.DS_Store
+1
View File
@@ -82,6 +82,7 @@ alembic/versions/*.pyc
/data
/#recycle
.OLD/
logos/
# Panel (Next.js frontend)
panel/node_modules/
+41 -14
View File
@@ -78,10 +78,11 @@ pnpm test
| RAG Engine | piragi (HyDE, hybrid search, BM25) |
| Cache/Queue | Redis |
| Container Runtime | Docker + Docker Compose |
| Cloud LLM | Claude API (claude-opus-4-5-20251101) |
| Local LLM | Ollama (glm-5.1:cloud for HyDE/RAG) |
| Cloud LLM | Claude API (claude-opus-4-6) |
| Local LLM | Ollama (glm-5:cloud for HyDE/RAG) |
| Embeddings | qwen3-embedding:0.6b (1024 dim) |
| Frontend | React / Next.js (future) |
| Frontend | Next.js 16 + TypeScript + Tailwind + Radix UI (in `panel/`) |
| Edge / Proxy | nginx (single entry point on port 3000) |
## Multi-Agent Workspace Structure
@@ -99,17 +100,17 @@ Each agent gets their own git clone of a project, enabling parallel development
```
/data/workspaces/
+-- roboco/
| +-- backend/
| | +-- be-dev-1/ # be-dev-1's workspace
| | +-- be-dev-2/ # be-dev-2's workspace
| +-- frontend/
| +-- fe-dev-1/
| +-- fe-dev-2/
+-- roboco-panel/
+-- backend/
| +-- be-dev-1/ # be-dev-1's workspace
| +-- be-dev-2/ # be-dev-2's workspace
+-- frontend/
+-- fe-dev-1/
+-- fe-dev-2/
```
Note: the Next.js control panel now lives at `roboco/panel/` inside this
repo (no longer a separate `roboco-panel` project or workspace).
**Key Configuration (roboco/config.py):**
- `ROBOCO_WORKSPACES_ROOT`: Root directory for workspaces (default: `/data/workspaces`)
- `ROBOCO_WORKSPACE_AUTO_CLONE`: Auto-clone repos on first access (default: `true`)
@@ -374,7 +375,7 @@ ROBOCO_RAG_USE_HYBRID_SEARCH=true
# AI/LLM
ROBOCO_DEFAULT_EMBEDDING_MODEL=qwen3-embedding:0.6b
ROBOCO_LOCAL_LLM_MODEL=glm-5.1:cloud
ROBOCO_LOCAL_LLM_MODEL=glm-5:cloud
ROBOCO_LOCAL_LLM_BASE_URL=http://roboco-ollama:11434/v1
ROBOCO_OLLAMA_BASE_URL=http://roboco-ollama:11434
```
@@ -383,7 +384,9 @@ ROBOCO_OLLAMA_BASE_URL=http://roboco-ollama:11434
### Container Architecture
The system runs as Docker Compose services:
The system runs as Docker Compose services. All Dockerfiles live under
`docker/` at the project root; every service uses `context: .` plus
`dockerfile: docker/<name>.Dockerfile`.
| Service | Purpose | Healthcheck |
|---------|---------|-------------|
@@ -391,7 +394,20 @@ The system runs as Docker Compose services:
| `redis` | Cache, sessions, event bus | `redis-cli ping` |
| `ollama` | Local LLM + embeddings | `ollama list` |
| `ollama-init` | Pulls models on startup | One-shot |
| `agent-base-image` / `agent-*-image` | Pre-built images spawned per agent | One-shot |
| `orchestrator` | API + agent spawner | Depends on all above |
| `panel` | Next.js control panel (internal, port 3000) | — |
| `nginx` | Reverse proxy fronting panel + orchestrator | — |
### Single Entry Point
`nginx` is the only externally-exposed service. It listens on `localhost:3000` and routes:
- `/api/*` and `/ws/*``orchestrator:8000`
- everything else → `panel:3000`
This avoids CORS since the browser sees one origin. The Next.js code uses
relative URLs (`/api/v1`, `/ws`) and lets nginx do the dispatch.
### Startup Sequence
@@ -399,9 +415,9 @@ The startup order is critical due to dependencies:
```
postgres ──┐
redis ─────┼──> ollama ──> ollama-init ──> orchestrator
redis ─────┼──> ollama ──> ollama-init ──> orchestrator ──> panel ──> nginx
│ │ │
│ │ └── Pulls qwen3-embedding:0.6b, glm-5.1:cloud
│ │ └── Pulls qwen3-embedding:0.6b, glm-5:cloud
│ └── Healthcheck: ollama list
└── Healthcheck: pg_isready, redis-cli ping
```
@@ -411,6 +427,17 @@ redis ─────┼──> ollama ──> ollama-init ──> orchestrator
2. Orchestrator waits for models before starting
3. FastAPI lifespan indexes documents using Ollama (~30-60s)
4. Orchestrator polls `/health` until API is ready before starting dispatcher
5. After orchestrator is up, `panel` (Next.js) builds/starts, then `nginx`
### Database migrations
Schema changes ship as Alembic migrations under `alembic/versions/`. Run:
```bash
docker compose exec orchestrator alembic upgrade head
```
after pulling any change that adds a new migration.
### Ollama Configuration
+3 -3
View File
@@ -91,7 +91,7 @@ ROBOCO_WORKSPACE_AUTO_CLONE=true
# RAG/LLM
ROBOCO_LOCAL_LLM_BASE_URL=http://roboco-ollama:11434/v1
ROBOCO_LOCAL_LLM_MODEL=glm-5.1:cloud
ROBOCO_LOCAL_LLM_MODEL=glm-5:cloud
```
## Multi-Agent Workspace Structure
@@ -177,7 +177,7 @@ uv run mypy roboco/
| Cache/Queue | Redis |
| RAG Library | piragi |
| Embeddings | qwen3-embedding:0.6b (sentence-transformers) |
| Local LLM | Ollama (glm-5.1:cloud) |
| Local LLM | Ollama (glm-5:cloud) |
| Cloud LLM | Claude API (Anthropic) |
| Package Manager | uv |
@@ -198,7 +198,7 @@ uv run mypy roboco/
- [x] CEO approval workflow
**In Progress**
- [ ] Frontend panel (roboco-panel)
- [x] Frontend panel (vendored under `panel/`, served through nginx on :3000)
- [ ] Full agent autonomy testing
## License
+79
View File
@@ -38,6 +38,85 @@ For full communication structure: `roboco_kb_search("communication hierarchy")`
3. **Plan before start** - Required step
4. **Journal as you go** - Document decisions, learnings, struggles
5. **Escalate blockers** - Don't spin, ask for help
## Safety Rules (HARD CONSTRAINTS)
These are not guidelines — violating them is a critical failure.
1. **Never read `.git/config`, `~/.gitconfig`, `/etc/gitconfig`, `.git-credentials`, or `.netrc`.**
They contain credentials. The Bash/Read tools will deny it anyway, but don't try.
2. **Never call `curl` or `wget` against `github.com` / `api.github.com`.**
Use the `roboco_git_*` MCP tools. They handle authentication correctly and
preserve task traceability. Direct API calls bypass both.
3. **Never run `git push/fetch/pull/clone` via `Bash`.**
The `Bash(git:*)` permission is denied. Use `roboco_git_*` MCP tools.
4. **If an MCP tool returns an error, DO NOT bypass it with Bash/curl.**
Instead:
- Journal it: `roboco_journal_struggle(task_id, summary, details)`
- Escalate: `roboco_task_escalate(task_id, reason)` — or notify your PM
- Then idle if there's no other work.
Agents that try to "just do it via Bash" when MCP errors are the #1 cause
of stuck runs and burned tokens. Don't be that agent.
5. **Local git ops are fine.** `git status`, `git log`, `git diff` via the
`roboco_git_*` tools work normally. The restriction is on *remote* git and
on anything that touches credentials.
## Startup: Load MCP Tool Schemas First (MANDATORY)
In Claude Code v2.1.114+, MCP tool schemas are **deferred** — you must load
them before you can call them. Calling an MCP tool directly will return
`<tool_use_error>No such tool available: mcp__roboco-...__...`.
**Your very first action on spawn must be a single `ToolSearch` call with a
`select:` query listing every tool you will use — both roboco MCP tools
AND built-in tools (`Edit`, `Write`, `Bash`, `TaskCreate`, `TaskGet`,
`TaskUpdate`, etc.). In claude-code v2.1.114 the built-ins are deferred
too, not just MCP — if you don't pre-load `Edit`, calling it resolves to
a no-op ToolSearch instead of an actual file edit.** Example:
```
ToolSearch({
query: "select:Edit,Write,Bash,Read,Glob,Grep,TaskCreate,TaskGet,TaskUpdate,mcp__roboco-task__roboco_task_get,mcp__roboco-task__roboco_task_claim,mcp__roboco-task__roboco_task_plan,mcp__roboco-task__roboco_task_start,mcp__roboco-task__roboco_task_progress,mcp__roboco-task__roboco_task_submit_verification,mcp__roboco-task__roboco_task_submit_qa,mcp__roboco-task__roboco_task_qa_pass,mcp__roboco-task__roboco_task_qa_fail,mcp__roboco-task__roboco_task_pause,mcp__roboco-task__roboco_task_unclaim,mcp__roboco-task__roboco_task_escalate,mcp__roboco-task__roboco_task_substitute,mcp__roboco-task__roboco_task_activate,mcp__roboco-task__roboco_task_create,mcp__roboco-task__roboco_task_scan,mcp__roboco-task__roboco_session_create_for_tasks,mcp__roboco-task__roboco_group_create,mcp__roboco-task__roboco_agent_idle,mcp__roboco-journal__roboco_journal_reflect,mcp__roboco-journal__roboco_journal_struggle,mcp__roboco-journal__roboco_journal_decision,mcp__roboco-message__roboco_message_send,mcp__roboco-notify__roboco_notify_send,mcp__roboco-notify__roboco_notify_list,mcp__roboco-notify__roboco_notify_ack,mcp__roboco-git__roboco_git_status,mcp__roboco-git__roboco_git_commit,mcp__roboco-git__roboco_git_push,mcp__roboco-git__roboco_git_create_pr,mcp__roboco-project__roboco_workspace_ensure"
})
```
If a specific `mcp__roboco-*__*` tool is still pending at first call (server
not yet connected), you'll see "Some MCP servers are still connecting" —
just call the same `ToolSearch(select:...)` again after a second. Do NOT
repeatedly call it every turn forever; 23 retries is the ceiling.
After this one call, the tools are callable normally. **Do NOT** poll with
keyword searches ("roboco task", "journal", etc.) — those return a ranked
subset and will miss tools. **Do NOT** call ToolSearch repeatedly. One
`select:` call with everything you need, then start working.
If a specific tool you need wasn't in your first `select:` list, call
`ToolSearch({query: "select:<exact-name>"})` to load it before use —
single shot, no loop.
If a call still fails with "No such tool available" *after* loading the
schema, that's an infra issue: journal + escalate per rule 4 above, don't
keep retrying.
## Your Tools (load via the single `select:` call above)
All roles have these MCP servers available under `mcp__roboco-<name>__*`:
- `roboco-task` — task CRUD, claim/plan/start/pause/complete, escalate
- `roboco-message` — channel messages, sessions, groups
- `roboco-journal` — personal decision log, reflections, struggles
- `roboco-notify` — list/ack notifications (PMs can also send)
- `roboco-optimal` — RAG search, mentor, knowledge base
- `roboco-a2a` — agent-to-agent direct conversations
- `roboco-project` — project + workspace ops
- `roboco-git` — git operations (role-gated: read for all, write for devs/PMs)
- `roboco-test` — test/lint/format commands (devs)
- `roboco-docs` — doc file management (documenters)
6. **State is sacred** - Recovery must be possible
## CRITICAL: Actually Do The Work
+26
View File
@@ -14,6 +14,32 @@ You manage task execution within YOUR cell. You create sessions, delegate to dev
For communication structure: `roboco_kb_search("communication hierarchy")`
## State → Tool Decision Table (YOUR task)
| your task's status | next tool |
|---|---|
| `pending` (assigned to you) | `roboco_task_claim` |
| `claimed` | `roboco_task_plan``roboco_task_start` |
| `in_progress`, all subtasks still running | `roboco_task_pause` (with checkpoint) + `roboco_agent_idle` |
| `in_progress`, all subtasks `completed` | `roboco_task_submit_pm_review` |
| `awaiting_pm_review` (sub) | review → `roboco_task_pass_pm_review` or `roboco_task_needs_revision` |
| `blocked` (human-resolvable) | wait — do NOT poll |
| `blocked` (agent-resolvable) | work with the dev to unblock |
## State → Tool for a SUBTASK you're managing
| subtask status | your move |
|---|---|
| `pending` (you just created it) | `roboco_task_activate` |
| `awaiting_pm_review` | review the PR, then pass/fail |
| `blocked` | check `blocker_resolver_type`: `agent` → help the dev, `human` → escalate |
| `needs_revision` | the dev will pick it back up on their own |
## If Tools Fail
Same as every other role: retry once → journal_struggle → notify Main PM
→ idle. No `curl`, no `.git/config` reads, no GitHub API bypass.
## Workflow
```
+29 -14
View File
@@ -4,24 +4,39 @@ You implement features, fix bugs, and write code.
For communication structure: `roboco_kb_search("communication hierarchy")`
## CRITICAL: Tool Availability Check
## State → Tool Decision Table
**Before starting work, verify your MCP tools are available.**
Every time you're about to act, check the task's `status` and use this map:
At session start, you receive an `init` message with `mcp_servers` status. Check it:
- If `roboco-task` shows `"status":"failed"` → Task tools unavailable
- If `roboco-message` shows `"status":"failed"` → Messaging tools unavailable
| status | next tool |
|---|---|
| `pending` (assigned to you) | `roboco_task_claim` |
| `claimed` | `roboco_task_plan``roboco_task_start` |
| `in_progress` | work (`roboco_git_*`) → `roboco_task_progress` |
| `blocked` (agent-resolvable) | resolve, then `roboco_task_unblock` |
| `blocked` (human-resolvable) | wait — do NOT poll |
| `needs_revision` | fix → commit → `roboco_task_submit_qa` |
| `awaiting_qa` | your task is already with QA — stop |
| `paused` | `roboco_task_resume` (only if YOU paused it) |
| anything else | not yours to drive — idle |
**If critical tools are unavailable:**
1. Check `roboco_notify_list()` - notifications should still work
2. Your task assignment notification contains: task ID, title, description
3. Use the notification body to understand your assignment
4. If task tools are unavailable but git tools work:
- Get your workspace: `roboco_workspace_ensure(project_slug)`
- Use git tools to start work: `roboco_git_status()`, `roboco_git_commit()`
5. **Report the issue via notification** - the system needs to know tools failed
Wrong-state transitions raise `INVALID_STATE`. Don't retry; re-read the
status first, then pick the right tool.
**DO NOT spin endlessly if tools are missing.** Report and request help.
## If MCP Tools Fail
If the `init` message shows `roboco-task` or `roboco-git` with status
`failed`, or an MCP call returns a hard error:
1. Retry the call ONCE.
2. If still failing: `roboco_journal_struggle(task_id, summary, details)` +
notify your Cell PM.
3. Then `roboco_agent_idle()`.
**Do NOT:** fall back to `curl`, read `.git/config` for credentials, or run
direct GitHub API calls. Those are blocked by your sandbox and are also
how we leaked a PAT on 2026-04-19. If the roboco MCP tools can't do it,
a human needs to intervene — flag it, don't bypass it.
---
+13
View File
@@ -8,6 +8,19 @@ You create **production documentation** from completed developer work.
For communication structure: `roboco_kb_search("communication hierarchy")`
## State → Tool Decision Table
| task status | next tool |
|---|---|
| `awaiting_documentation` (your team) | `roboco_task_claim``roboco_task_start` |
| `in_progress` (claimed by you) | write docs → commit → `roboco_task_submit_docs` |
| anything else | not yours — idle |
## If Tools Fail
Retry once → journal_struggle → notify PM → idle. No `curl`, no reading
`.git/config`, no GitHub API bypass.
## Workflow
```
+19
View File
@@ -15,6 +15,25 @@ You coordinate work ACROSS cells. You plan, distribute, monitor, but don't execu
For communication structure: `roboco_kb_search("communication hierarchy")`
## State → Tool Decision Table (YOUR task)
| status | next tool |
|---|---|
| `pending` (assigned to you from CEO/Board) | `roboco_task_claim` |
| `claimed` | `roboco_task_plan``roboco_task_start` |
| `in_progress`, cells still working | `roboco_task_pause` + `roboco_agent_idle` |
| `in_progress`, all cell tasks done | `roboco_task_submit_pm_review` |
| `awaiting_ceo_approval` | wait — CEO only |
| `blocked` (human-resolvable) | wait, don't poll |
**Never assign code tasks to Cell PMs** (`task_type: planning` for PM
delegation, `task_type: code` only for developers).
## If Tools Fail
Retry once → journal_struggle → notify CEO → idle. Do not bypass via
`curl` or by reading credentials from the workspace.
## Workflow
```
+22
View File
@@ -4,6 +4,28 @@ You verify developer work meets acceptance criteria and quality standards.
For communication structure: `roboco_kb_search("communication hierarchy")`
## State → Tool Decision Table
| status (task YOU are looking at) | next tool |
|---|---|
| `awaiting_qa` | `roboco_task_claim` (only QA can) → `roboco_task_start` |
| `in_progress` (claimed by you) | review → `roboco_task_pass_qa` or `roboco_task_fail_qa` |
| `claimed` by a dev, or `in_progress` not yours | not your task yet — leave it alone |
| any other status | not reviewable — skip |
`fail_qa` only works on `awaiting_qa` or your own `in_progress`. Calling it
on a dev's `claimed` task returns `INVALID_STATE`; escalate to the PM via
`roboco_task_escalate` or `roboco_notify_send(type=REVIEW_REQUEST)`
instead — the PM has the permission to transition it back for rework.
## If MCP Tools Fail / Session Closed
- If `roboco_message_send` returns `Session is not active`, the fix is
already in the service (auto-redirects to the group's active session).
Just retry once.
- If anything else errors twice in a row: journal_struggle + notify PM +
idle. Do not curl the API.
## Workflow
```
+116 -89
View File
@@ -9,14 +9,15 @@ Adds:
orchestrator restarts don't strand them (previously in-memory dict).
- `audit_log`: queryable audit trail so the auditor role actually has data
to inspect (previously log-only).
- `notificationtype` enum value `approval`: the orchestrator's approval
- `notificationtype` enum value `APPROVAL`: the orchestrator's approval
dispatcher and the frontend both reference this type, but it was missing
from the enum, so every `/notifications?type_filter=approval` call
returned 422 and the dispatcher's query silently matched nothing.
from the enum, so every approval-related insert raised
`invalid input value for enum notificationtype: "APPROVAL"`.
"""
import sqlalchemy as sa
from alembic import op
from sqlalchemy import inspect
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
@@ -26,98 +27,124 @@ branch_labels = None
depends_on = None
def _table_exists(name: str) -> bool:
"""True if `name` already exists in the current DB schema.
Guards against re-running this migration on DBs that were first created
via Base.metadata.create_all (which pre-created waiting_records and
audit_log from the ORM metadata). Without this guard, op.create_table
raises DuplicateTableError and the whole migration rolls back — so the
ALTER TYPE ADD VALUE 'APPROVAL' above it never takes effect either.
"""
bind = op.get_bind()
return inspect(bind).has_table(name)
def upgrade() -> None:
# Add the missing 'approval' value to the notificationtype enum.
# Add the missing APPROVAL value to the notificationtype enum.
# SQLAlchemy's default Enum(PyEnum) binds enum members by NAME (uppercase),
# and the live DB was created via Base.metadata.create_all, so the enum
# values on disk are the uppercase NAMES ('TASK_ASSIGNMENT', 'ALERT', ...).
# IF NOT EXISTS makes this idempotent on re-runs.
op.execute(
"ALTER TYPE notificationtype ADD VALUE IF NOT EXISTS 'approval'"
"ALTER TYPE notificationtype ADD VALUE IF NOT EXISTS 'APPROVAL'"
)
op.create_table(
"waiting_records",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"agent_id",
sa.String(64),
nullable=False,
unique=True,
index=True,
comment="Agent slug; unique so only one record per agent.",
),
sa.Column(
"task_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("tasks.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"waiting_for",
sa.String(64),
nullable=False,
comment="One of: blocker_resolution, qa_result, answer, assignment",
),
sa.Column(
"waiting_since",
sa.DateTime(timezone=True),
nullable=False,
),
sa.Column("context", postgresql.JSONB, nullable=False, server_default="{}"),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
op.create_index(
"ix_waiting_records_waiting_for", "waiting_records", ["waiting_for"]
)
if not _table_exists("waiting_records"):
op.create_table(
"waiting_records",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"agent_id",
sa.String(64),
nullable=False,
unique=True,
index=True,
comment="Agent slug; unique so only one record per agent.",
),
sa.Column(
"task_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("tasks.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"waiting_for",
sa.String(64),
nullable=False,
comment="One of: blocker_resolution, qa_result, answer, assignment",
),
sa.Column(
"waiting_since",
sa.DateTime(timezone=True),
nullable=False,
),
sa.Column(
"context", postgresql.JSONB, nullable=False, server_default="{}"
),
sa.Column(
"created_at",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
),
)
op.create_index(
"ix_waiting_records_waiting_for",
"waiting_records",
["waiting_for"],
)
op.create_table(
"audit_log",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"event_type",
sa.String(80),
nullable=False,
index=True,
comment="Dot-separated e.g. task.claimed, session.closed, project.deleted",
),
sa.Column(
"agent_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("agents.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"target_type",
sa.String(40),
nullable=True,
comment="e.g. task, session, project, notification",
),
sa.Column("target_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column(
"severity",
sa.String(16),
nullable=False,
server_default="info",
comment="info | warning | error",
),
sa.Column("details", postgresql.JSONB, nullable=False, server_default="{}"),
sa.Column(
"timestamp",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
index=True,
),
)
op.create_index(
"ix_audit_log_agent_timestamp", "audit_log", ["agent_id", "timestamp"]
)
op.create_index(
"ix_audit_log_target", "audit_log", ["target_type", "target_id"]
)
if not _table_exists("audit_log"):
op.create_table(
"audit_log",
sa.Column("id", postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column(
"event_type",
sa.String(80),
nullable=False,
index=True,
comment="Dot-separated e.g. task.claimed, session.closed, project.deleted",
),
sa.Column(
"agent_id",
postgresql.UUID(as_uuid=True),
sa.ForeignKey("agents.id", ondelete="SET NULL"),
nullable=True,
),
sa.Column(
"target_type",
sa.String(40),
nullable=True,
comment="e.g. task, session, project, notification",
),
sa.Column("target_id", postgresql.UUID(as_uuid=True), nullable=True),
sa.Column(
"severity",
sa.String(16),
nullable=False,
server_default="info",
comment="info | warning | error",
),
sa.Column(
"details", postgresql.JSONB, nullable=False, server_default="{}"
),
sa.Column(
"timestamp",
sa.DateTime(timezone=True),
server_default=sa.func.now(),
nullable=False,
index=True,
),
)
op.create_index(
"ix_audit_log_agent_timestamp",
"audit_log",
["agent_id", "timestamp"],
)
op.create_index(
"ix_audit_log_target", "audit_log", ["target_type", "target_id"]
)
def downgrade() -> None:
@@ -0,0 +1,48 @@
"""Add blocker_resolver_type to tasks.
Revision ID: 003_blocker_resolver_type
Revises: 002_persistence_tables
Create Date: 2026-04-19
Adds `tasks.blocker_resolver_type` so the dispatcher can tell the difference
between blocks an agent can resolve (dispatcher may respawn) and blocks that
need human intervention (dispatcher must NOT respawn). Without this, agents
keep churning on HITL-blocked tasks and burning tokens.
"""
import sqlalchemy as sa
from alembic import op
# revision identifiers, used by Alembic.
revision = "003_blocker_resolver_type"
down_revision = "002_persistence_tables"
branch_labels = None
depends_on = None
def upgrade() -> None:
# Create the enum type — names match SA's default Enum(PyEnum) binding
# (it serializes enum members by NAME, which is uppercase per PEP 8).
blocker_resolver_enum = sa.Enum(
"AGENT",
"HUMAN",
name="blockerresolvertype",
)
blocker_resolver_enum.create(op.get_bind(), checkfirst=True)
# Add nullable column — NULL means "not applicable" (task isn't blocked)
# or "legacy block before this migration". Dispatcher treats NULL the
# same as AGENT (old default behavior) to preserve back-compat.
op.add_column(
"tasks",
sa.Column(
"blocker_resolver_type",
blocker_resolver_enum,
nullable=True,
),
)
def downgrade() -> None:
op.drop_column("tasks", "blocker_resolver_type")
sa.Enum(name="blockerresolvertype").drop(op.get_bind(), checkfirst=True)
+43 -4
View File
@@ -79,14 +79,14 @@ services:
status=$$(echo "$$line" | grep -o '"status":"[^"]*"' | cut -d'"' -f4)
[ -n "$$status" ] && echo " $$status"
done
echo "=== Pulling LLM model (glm-5.1:cloud) ==="
curl -sN http://ollama:11434/api/pull -d '{"name":"glm-5.1:cloud"}' | while read -r line; do
echo "=== Pulling LLM model (glm-5:cloud) ==="
curl -sN http://ollama:11434/api/pull -d '{"name":"glm-5:cloud"}' | while read -r line; do
status=$$(echo "$$line" | grep -o '"status":"[^"]*"' | cut -d'"' -f4)
[ -n "$$status" ] && echo " $$status"
done
echo "=== Verifying models are available ==="
curl -sf http://ollama:11434/api/tags | grep -q "qwen3-embedding" && echo " qwen3-embedding: OK"
curl -sf http://ollama:11434/api/tags | grep -q "glm-5.1" && echo " glm-5.1: OK"
curl -sf http://ollama:11434/api/tags | grep -q "glm-5" && echo " glm-5: OK"
echo "=== All models ready! ==="
# ==========================================================================
@@ -220,7 +220,7 @@ services:
ROBOCO_ENCRYPTION_KEY: ${ROBOCO_ENCRYPTION_KEY:?ROBOCO_ENCRYPTION_KEY is required}
# Ollama (use container name)
ROBOCO_LOCAL_LLM_BASE_URL: http://roboco-ollama:11434/v1
ROBOCO_LOCAL_LLM_MODEL: glm-5.1:cloud
ROBOCO_LOCAL_LLM_MODEL: glm-5:cloud
ROBOCO_DEFAULT_EMBEDDING_MODEL: qwen3-embedding:0.6b
ROBOCO_OLLAMA_BASE_URL: http://roboco-ollama:11434
# Host paths for spawning agent containers (required for Docker-in-Docker)
@@ -241,6 +241,10 @@ services:
- ${ROBOCO_DATA_DIR:-./data}/agent-settings:/app/agent-settings
# Agent workspaces (git clones) - persisted across restarts
- ${ROBOCO_DATA_DIR:-./data}/workspaces:/data/workspaces
# Persistent logs — survive `docker compose down/up`. Orchestrator and
# each spawned agent write structured logs here so we can audit past
# runs instead of relying on ephemeral `docker logs`.
- ${ROBOCO_DATA_DIR:-./data}/logs:/data/logs
depends_on:
postgres:
condition: service_healthy
@@ -255,6 +259,41 @@ services:
# Default agents to spawn (override in .env or command line)
# command: ["--spawn", "main-pm", "be-dev-1", "be-qa"]
# ==========================================================================
# Next.js Control Panel (Frontend)
# ==========================================================================
# Not exposed directly - nginx is the single entry point (port 3000).
# API/WS traffic is proxied to the orchestrator, everything else to panel.
panel:
build:
context: .
dockerfile: docker/panel.Dockerfile
image: roboco-panel
container_name: roboco-panel
restart: unless-stopped
expose:
- "3000"
depends_on:
- orchestrator
# ==========================================================================
# Nginx - Reverse proxy fronting panel + orchestrator
# ==========================================================================
# Single entry point on port 3000 so the browser hits one origin and we
# don't need CORS. /api/* and /ws/* go to the orchestrator, everything
# else goes to the Next.js panel.
nginx:
image: nginx:alpine
container_name: roboco-nginx
restart: unless-stopped
ports:
- "3000:80"
volumes:
- ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- panel
- orchestrator
networks:
default:
name: roboco_default
+43 -4
View File
@@ -79,14 +79,14 @@ services:
status=$$(echo "$$line" | grep -o '"status":"[^"]*"' | cut -d'"' -f4)
[ -n "$$status" ] && echo " $$status"
done
echo "=== Pulling LLM model (glm-5.1:cloud) ==="
curl -sN http://ollama:11434/api/pull -d '{"name":"glm-5.1:cloud"}' | while read -r line; do
echo "=== Pulling LLM model (glm-5:cloud) ==="
curl -sN http://ollama:11434/api/pull -d '{"name":"glm-5:cloud"}' | while read -r line; do
status=$$(echo "$$line" | grep -o '"status":"[^"]*"' | cut -d'"' -f4)
[ -n "$$status" ] && echo " $$status"
done
echo "=== Verifying models are available ==="
curl -sf http://ollama:11434/api/tags | grep -q "qwen3-embedding" && echo " qwen3-embedding: OK"
curl -sf http://ollama:11434/api/tags | grep -q "glm-5.1" && echo " glm-5.1: OK"
curl -sf http://ollama:11434/api/tags | grep -q "glm-5" && echo " glm-5: OK"
echo "=== All models ready! ==="
# ==========================================================================
@@ -220,7 +220,7 @@ services:
ROBOCO_ENCRYPTION_KEY: ${ROBOCO_ENCRYPTION_KEY:?ROBOCO_ENCRYPTION_KEY is required}
# Ollama (use container name)
ROBOCO_LOCAL_LLM_BASE_URL: http://roboco-ollama:11434/v1
ROBOCO_LOCAL_LLM_MODEL: glm-5.1:cloud
ROBOCO_LOCAL_LLM_MODEL: glm-5:cloud
ROBOCO_DEFAULT_EMBEDDING_MODEL: qwen3-embedding:0.6b
ROBOCO_OLLAMA_BASE_URL: http://roboco-ollama:11434
# Host paths for spawning agent containers (required for Docker-in-Docker)
@@ -241,6 +241,10 @@ services:
- ${ROBOCO_DATA_DIR:-./data}/agent-settings:/app/agent-settings
# Agent workspaces (git clones) - persisted across restarts
- ${ROBOCO_DATA_DIR:-./data}/workspaces:/data/workspaces
# Persistent logs — survive `docker compose down/up`. Orchestrator and
# each spawned agent write structured logs here so we can audit past
# runs instead of relying on ephemeral `docker logs`.
- ${ROBOCO_DATA_DIR:-./data}/logs:/data/logs
depends_on:
postgres:
condition: service_healthy
@@ -255,6 +259,41 @@ services:
# Default agents to spawn (override in .env or command line)
# command: ["--spawn", "main-pm", "be-dev-1", "be-qa"]
# ==========================================================================
# Next.js Control Panel (Frontend)
# ==========================================================================
# Not exposed directly - nginx is the single entry point (port 3000).
# API/WS traffic is proxied to the orchestrator, everything else to panel.
panel:
build:
context: .
dockerfile: docker/panel.Dockerfile
image: roboco-panel
container_name: roboco-panel
restart: unless-stopped
expose:
- "3000"
depends_on:
- orchestrator
# ==========================================================================
# Nginx - Reverse proxy fronting panel + orchestrator
# ==========================================================================
# Single entry point on port 3000 so the browser hits one origin and we
# don't need CORS. /api/* and /ws/* go to the orchestrator, everything
# else goes to the Next.js panel.
nginx:
image: nginx:alpine
container_name: roboco-nginx
restart: unless-stopped
ports:
- "3000:80"
volumes:
- ./docker/nginx.conf:/etc/nginx/conf.d/default.conf:ro
depends_on:
- panel
- orchestrator
networks:
default:
name: roboco_default
+64 -38
View File
@@ -1,61 +1,87 @@
# =============================================================================
# Agent Base Image
# Agent Base Image — multi-stage build
# =============================================================================
# Python 3.13 with dev tools for Claude Code agent containers
# Shared runtime for Claude Code agent containers: Python venv + Node.js +
# @anthropic-ai/claude-code CLI. Specialized images (agent-dev-be, agent-qa-fe,
# etc.) extend this one.
# =============================================================================
FROM python:3.13-bookworm
# ---- Builder ----------------------------------------------------------------
FROM python:3.13-slim-bookworm AS builder
# Install Node.js 22 (required for Claude Code CLI) and jq (for hooks)
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
ca-certificates \
git \
gnupg \
jq \
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y nodejs \
build-essential \
git \
&& rm -rf /var/lib/apt/lists/*
# Install Claude Code CLI
RUN npm install -g @anthropic-ai/claude-code
# Install uv for Python package management
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
# Create agent user BEFORE copying files
WORKDIR /app
ENV UV_HTTP_TIMEOUT=300 \
UV_CONCURRENT_DOWNLOADS=4 \
UV_LINK_MODE=copy \
UV_PYTHON_PREFERENCE=only-system
# Use base-image Python so venv symlinks stay valid after COPY to runner
# (uv's managed python at /root/.local/share/uv/python isn't carried across).
COPY pyproject.toml uv.lock README.md /app/
RUN uv sync --frozen --no-dev --no-install-project
COPY roboco /app/roboco
RUN uv sync --frozen --no-dev
# ---- Runner -----------------------------------------------------------------
FROM python:3.13-slim-bookworm AS runner
# Runtime deps: Node.js 22 for claude CLI, git for workspace ops, jq for hooks.
# gnupg is only needed to add the NodeSource repo, purged after install.
RUN apt-get update && apt-get install -y --no-install-recommends \
curl ca-certificates git gnupg jq \
&& curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y --no-install-recommends nodejs \
&& npm install -g @anthropic-ai/claude-code@2.1.114 \
&& npm cache clean --force \
&& apt-get purge -y --auto-remove gnupg \
&& rm -rf /var/lib/apt/lists/* /root/.npm /tmp/*
RUN useradd -m -s /bin/bash agent
# Create app directory and set ownership
# uv is required at runtime: mcp-config.json spawns every MCP server via
# `uv run python -m roboco.mcp.<server>`. Without it, all 10 roboco MCP
# servers fail to start and the agent falls back to raw HTTP, losing every
# guardrail and inline schema the MCP layer provides.
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
RUN chown agent:agent /app
# Copy MCP server code (needed for agent tools) AS agent
COPY --chown=agent:agent roboco /app/roboco
COPY --chown=agent:agent pyproject.toml uv.lock README.md /app/
# Copy the pre-built venv + source from builder, owned by agent user
COPY --from=builder --chown=agent:agent /app /app
# Hook scripts: 0755 so the `agent` user (not root) can read+execute them.
# SessionStart hook runs these as agent; stricter perms break the hook with
# "Permission denied" (exit 126).
COPY docker/scripts/sdk-startup-hook.sh /app/scripts/sdk-startup-hook.sh
COPY docker/scripts/a2a-check-hook.sh /app/scripts/a2a-check-hook.sh
COPY docker/scripts/traceability-hook.sh /app/scripts/traceability-hook.sh
RUN chmod 0755 /app/scripts/*.sh
# Switch to agent user for installing dependencies
USER agent
# Install Python dependencies for MCP servers (as agent)
ENV UV_HTTP_TIMEOUT=300
ENV UV_CONCURRENT_DOWNLOADS=4
RUN uv python install 3.13 && uv sync --frozen --python 3.13
# Workspaces are cloned by the orchestrator (running as root) into a shared
# volume, so inside the agent container they show up owned by a different
# uid. Git 2.35+ refuses to operate on such repos with "dubious ownership"
# until safe.directory is set. `*` trusts everything, which is fine inside
# a per-agent sandbox that only mounts its own workspace.
RUN git config --global --add safe.directory '*'
# Claude Code will use mounted ~/.claude for auth
# System prompt mounted at /app/system-prompt.md (composed at spawn time from layers)
# MCP config generated at runtime
ENV PATH="/app/.venv/bin:$PATH" \
VIRTUAL_ENV=/app/.venv
# Copy SDK server scripts and hooks (need to be root for COPY, then fix permissions)
USER root
COPY --chown=agent:agent docker/scripts/sdk-startup-hook.sh /app/scripts/sdk-startup-hook.sh
COPY --chown=agent:agent docker/scripts/a2a-check-hook.sh /app/scripts/a2a-check-hook.sh
COPY --chown=agent:agent docker/scripts/traceability-hook.sh /app/scripts/traceability-hook.sh
RUN chmod +x /app/scripts/*.sh
USER agent
# Claude Code uses mounted ~/.claude for auth.
# System prompt mounted at /app/system-prompt.md at spawn time.
# MCP config generated at runtime.
# Expose SDK server port
EXPOSE 9000
# Claude runs directly - SDK server started via SessionStart hook
ENTRYPOINT ["claude"]
+60
View File
@@ -0,0 +1,60 @@
upstream panel {
server roboco-panel:3000;
}
upstream orchestrator {
server roboco-orchestrator:8000;
}
server {
listen 80;
server_name _;
# Health check -> orchestrator (no auth needed)
location /health {
proxy_pass http://orchestrator;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# Readiness check -> orchestrator
location /ready {
proxy_pass http://orchestrator;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# API requests -> orchestrator
location /api/ {
proxy_pass http://orchestrator;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
# WebSocket requests -> orchestrator
location /ws/ {
proxy_pass http://orchestrator;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 86400;
}
# Everything else -> panel
location / {
proxy_pass http://panel;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
+60 -41
View File
@@ -1,62 +1,81 @@
# =============================================================================
# Orchestrator
# Orchestrator — multi-stage build
# =============================================================================
# API Server + Agent Spawner using Python 3.13
# Builder stage compiles the Python venv; runner stage is slim Debian with
# docker-cli (needed to spawn agent containers over the mounted docker socket)
# and the pre-built venv copied in.
# =============================================================================
FROM python:3.13-bookworm
# ---- Builder ----------------------------------------------------------------
FROM python:3.13-slim-bookworm AS builder
# Install dependencies + Docker CLI
RUN apt-get update && apt-get install -y --no-install-recommends \
curl \
ca-certificates \
git \
gnupg \
lsb-release \
build-essential \
git \
&& rm -rf /var/lib/apt/lists/*
# Install Docker CLI (for spawning agent containers)
# Note: Using 'trixie' for Debian 13, fallback to bookworm if not available
RUN curl -fsSL https://download.docker.com/linux/debian/gpg | gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg \
&& DEBIAN_CODENAME=$(lsb_release -cs) \
&& if [ "$DEBIAN_CODENAME" = "trixie" ]; then DEBIAN_CODENAME="bookworm"; fi \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian ${DEBIAN_CODENAME} stable" > /etc/apt/sources.list.d/docker.list \
&& apt-get update \
&& apt-get install -y docker-ce-cli \
&& rm -rf /var/lib/apt/lists/*
# Install Node.js 22 (for building agent image)
RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - \
&& apt-get install -y nodejs \
&& rm -rf /var/lib/apt/lists/*
# Install uv for Python package management
COPY --from=ghcr.io/astral-sh/uv:latest /uv /usr/local/bin/uv
WORKDIR /app
# Copy project files
ENV UV_HTTP_TIMEOUT=300 \
UV_CONCURRENT_DOWNLOADS=4 \
UV_LINK_MODE=copy \
UV_PYTHON_PREFERENCE=only-system
# Dependency layer first so source changes don't invalidate the big install.
# --python-preference=only-system forces uv to use the base-image's /usr/local
# Python (shipped with python:3.13-slim-bookworm) instead of downloading its
# own — otherwise the venv symlinks into /root/.local/share/uv/python/... and
# breaks when COPY --from=builder only copies /app.
COPY pyproject.toml uv.lock README.md /app/
RUN uv sync --frozen --no-dev --no-install-project
# Project layer
COPY roboco /app/roboco
# agents/prompts/ contains layered prompt components (base, roles, teams, identities)
# These are composed at runtime by compose_prompt() when spawning agents
COPY agents /app/agents
COPY docker /app/docker
# docs/ contains standards and workflows for RAG auto-indexing
COPY docs /app/docs
COPY pyproject.toml uv.lock alembic.ini README.md /app/
COPY alembic.ini /app/
COPY alembic /app/alembic
RUN uv sync --frozen --no-dev
# Install Python dependencies
# Increase timeout for large NVIDIA packages (674MB cudnn, 858MB torch)
ENV UV_HTTP_TIMEOUT=300
ENV UV_CONCURRENT_DOWNLOADS=4
RUN uv python install 3.13 && uv sync --frozen --python 3.13
# ---- Runner -----------------------------------------------------------------
FROM python:3.13-slim-bookworm AS runner
# Runtime apt deps: docker-cli (spawn agents), git (workspace ops).
# curl/gnupg/lsb-release are only needed to add the docker repo, then purged.
RUN apt-get update && apt-get install -y --no-install-recommends \
curl ca-certificates gnupg lsb-release git \
&& curl -fsSL https://download.docker.com/linux/debian/gpg \
| gpg --dearmor -o /usr/share/keyrings/docker-archive-keyring.gpg \
&& DEBIAN_CODENAME=$(lsb_release -cs) \
&& if [ "$DEBIAN_CODENAME" = "trixie" ]; then DEBIAN_CODENAME="bookworm"; fi \
&& echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/docker-archive-keyring.gpg] https://download.docker.com/linux/debian ${DEBIAN_CODENAME} stable" \
> /etc/apt/sources.list.d/docker.list \
&& apt-get update \
&& apt-get install -y --no-install-recommends docker-ce-cli \
&& apt-get purge -y --auto-remove curl gnupg lsb-release \
&& rm -rf /var/lib/apt/lists/*
WORKDIR /app
# Copy the already-built venv + app tree from builder
COPY --from=builder /app /app
# Orchestrator clones workspaces as root, then chowns them to the agent
# user (uid 1000) so the agent container can read/write. After the chown,
# the orchestrator (still root) needs to run git commands (claim branch
# creation, status, log, fetch) inside those now-1000-owned dirs — git's
# "dubious ownership" check refuses unless safe.directory is set. `*`
# trusts every path, which is fine here since this container only mounts
# the sandboxed /data/workspaces tree.
RUN git config --global --add safe.directory '*'
ENV PATH="/app/.venv/bin:$PATH" \
VIRTUAL_ENV=/app/.venv
# Expose API port
EXPOSE 8000
# Start orchestrator WITHOUT spawning agents - the smart dispatcher will spawn
# agents on-demand when work is available (avoiding wasteful spawns).
# To manually spawn agents at startup, override: docker run ... roboco-orchestrator --spawn main-pm
ENTRYPOINT ["uv", "run", "python", "-m", "roboco.cli"]
# Smart dispatcher spawns agents on-demand. Override with --spawn to pre-start.
ENTRYPOINT ["python", "-m", "roboco.cli"]
CMD []
+59
View File
@@ -0,0 +1,59 @@
# RoboCo Control Panel Dockerfile
# Multi-stage build for optimized production image.
# Build context is the project root so the backend and panel share one
# Dockerfile layout under docker/. Paths below are relative to project root.
# =============================================================================
# Base stage
# =============================================================================
FROM node:22-alpine AS base
RUN corepack enable pnpm
# =============================================================================
# Build stage
# =============================================================================
FROM base AS builder
WORKDIR /app
# Copy package manifests first (for layer caching)
COPY panel/package.json panel/pnpm-lock.yaml ./
# Install dependencies with shamefully-hoist to flatten node_modules
# This prevents symlink issues with styled-jsx and other peer deps
RUN pnpm install --frozen-lockfile --shamefully-hoist
# Copy panel source code
COPY panel/ ./
# Build the application
# NOTE: No NEXT_PUBLIC_* env vars set here - we use relative URLs
# which nginx proxies to the orchestrator
RUN pnpm build
# =============================================================================
# Production stage
# =============================================================================
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
# Create non-root user
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
# Copy public assets
COPY --from=builder /app/public ./public
# Copy standalone build
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]
+1 -1
View File
@@ -58,7 +58,7 @@ Environment variables for RoboCo (prefix: `ROBOCO_`).
| Variable | Default | Description |
|----------|---------|-------------|
| `ROBOCO_LOCAL_LLM_MODEL` | `glm-5.1:cloud` | Local LLM for RAG |
| `ROBOCO_LOCAL_LLM_MODEL` | `glm-5:cloud` | Local LLM for RAG |
| `ROBOCO_LOCAL_LLM_BASE_URL` | `http://roboco-ollama:11434/v1` | OpenAI-compat API |
| `ROBOCO_OLLAMA_BASE_URL` | `http://roboco-ollama:11434` | Native Ollama API |
+6 -3
View File
@@ -26,10 +26,13 @@ Returns: `name`, `git_url`, `assigned_cell`, `default_branch`, `has_git_token`,
## Create Project (PM+ Only)
```python
# Example: register a separate frontend-only repo as a project.
# (The built-in RoboCo control panel lives in this same repo under
# panel/ and is NOT registered as a separate project.)
roboco_project_create(
name="RoboCo Panel",
slug="roboco-panel",
git_url="https://github.com/org/roboco-panel.git",
name="Customer Portal",
slug="customer-portal",
git_url="https://github.com/org/customer-portal.git",
assigned_cell="frontend",
git_token="ghp_xxxx...", # GitHub PAT with repo scope
default_branch="main",
+25
View File
@@ -0,0 +1,25 @@
# Dependencies (installed fresh in container)
node_modules
# Build output (created fresh in container)
.next
# Development files
*.log
.env*.local
.DS_Store
# Git
.git
.gitignore
# IDE
.vscode
.idea
# Documentation (not needed in container)
README.md
# NAS recycle bin (UGREEN NAS)
\#recycle
Recycling Bin
+41
View File
@@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts
+69
View File
@@ -0,0 +1,69 @@
# RoboCo Control Panel
Next.js 16 control panel for the RoboCo AI agent system. Formerly a
separate repository (`rennf93/roboco-panel`), now vendored under
`panel/` in this monorepo so `docker compose up -d` brings up the
whole stack from one place.
## Stack
- Next.js 16 (App Router, standalone output)
- TypeScript
- Tailwind CSS
- Radix UI primitives
- `dnd-kit` for drag/drop (kanban)
- pnpm for package management
## Running in production (the normal path)
Use the root-level Docker Compose:
```bash
# from the repo root (one level up from this directory)
docker compose up -d
```
The panel is built as part of the compose stack via
`docker/panel.Dockerfile` and served internally on port 3000.
Nginx (also in the compose stack) is the single externally-exposed
service on `http://localhost:3000` and routes:
- `/api/*` and `/ws/*` → orchestrator (FastAPI, port 8000)
- everything else → the Next.js panel
The panel uses relative URLs (`/api/v1`, `/ws`) so nothing here
needs a backend URL in `.env`.
## Running the panel alone for UI development
```bash
cd panel
pnpm install
pnpm dev
```
That gives you Next dev-server on `localhost:3000`, but you still need
the orchestrator reachable at `localhost:8000` (or via nginx) for API
calls to work. Easiest: `docker compose up -d` the backend services,
then run `pnpm dev` against that.
## Build scripts
- `pnpm dev` — development server with hot reload
- `pnpm build` — production build (outputs `.next/standalone/`)
- `pnpm start` — run the standalone build
- `pnpm lint` — ESLint
## Where things live
- `src/app/` — Next.js App Router pages
- `src/components/` — React components (organized by feature: tasks, agents, channels, …)
- `src/lib/api/` — typed API client (thin wrappers over `fetch`)
- `src/lib/` — constants, utilities, WebSocket hooks
- `src/types/` — shared TypeScript types mirroring backend schemas
## Backend schema changes
When the backend changes response shapes, mirror them in `src/types/`
and the relevant `src/lib/api/` module. Keep API paths relative so
nginx routing keeps working.
+22
View File
@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/app/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}
+18
View File
@@ -0,0 +1,18 @@
import { defineConfig, globalIgnores } from "eslint/config";
import nextVitals from "eslint-config-next/core-web-vitals";
import nextTs from "eslint-config-next/typescript";
const eslintConfig = defineConfig([
...nextVitals,
...nextTs,
// Override default ignores of eslint-config-next.
globalIgnores([
// Default ignores of eslint-config-next:
".next/**",
"out/**",
"build/**",
"next-env.d.ts",
]),
]);
export default eslintConfig;
+23
View File
@@ -0,0 +1,23 @@
import type { NextConfig } from "next";
// const BACKEND_URL = process.env.BACKEND_URL || "http://localhost:8000";
const nextConfig: NextConfig = {
output: "standalone",
// // Proxy API calls to the backend to avoid CORS issues
// async rewrites() {
// return [
// {
// source: "/api/:path*",
// destination: `${BACKEND_URL}/api/:path*`,
// },
// {
// source: "/ws/:path*",
// destination: `${BACKEND_URL}/ws/:path*`,
// },
// ];
// },
};
export default nextConfig;
+63
View File
@@ -0,0 +1,63 @@
{
"name": "roboco-panel",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev",
"build": "next build",
"start": "next start",
"lint": "eslint"
},
"dependencies": {
"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@hookform/resolvers": "^5.2.2",
"@radix-ui/react-alert-dialog": "^1.1.15",
"@radix-ui/react-avatar": "^1.1.11",
"@radix-ui/react-checkbox": "^1.3.3",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-popover": "^1.1.15",
"@radix-ui/react-progress": "^1.1.8",
"@radix-ui/react-scroll-area": "^1.2.10",
"@radix-ui/react-select": "^2.2.6",
"@radix-ui/react-separator": "^1.1.8",
"@radix-ui/react-slot": "^1.2.4",
"@radix-ui/react-switch": "^1.2.6",
"@radix-ui/react-tabs": "^1.1.13",
"@radix-ui/react-tooltip": "^1.2.8",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/react-query": "^5.90.16",
"axios": "^1.13.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"date-fns": "^4.1.0",
"lucide-react": "^0.562.0",
"next": "16.1.1",
"next-themes": "^0.4.6",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-hook-form": "^7.71.0",
"react-markdown": "^10.1.0",
"remark-gfm": "^4.0.1",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"zod": "^4.3.5",
"zustand": "^5.0.10"
},
"devDependencies": {
"@tailwindcss/postcss": "^4",
"@tanstack/react-query-devtools": "^5.91.2",
"@types/node": "^25.0.8",
"@types/react": "^19.2.8",
"@types/react-dom": "^19",
"eslint": "^9",
"eslint-config-next": "16.1.1",
"tailwindcss": "^4",
"tw-animate-css": "^1.4.0",
"typescript": "^5"
}
}
+6457
View File
File diff suppressed because it is too large Load Diff
+7
View File
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
+1
View File
@@ -0,0 +1 @@
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 391 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.0 KiB

+1
View File
@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>

After

Width:  |  Height:  |  Size: 128 B

+1
View File
@@ -0,0 +1 @@
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>

After

Width:  |  Height:  |  Size: 385 B

@@ -0,0 +1,234 @@
"use client";
import { useParams, useRouter } from "next/navigation";
import { formatDistanceToNow } from "date-fns";
import { useAgentStatus, useStopAgent, useSpawnAgent, useAgentDefinition } from "@/hooks/use-agents";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { ArrowLeft, Play, Square, AlertTriangle, Clock, RefreshCw, User, Users } from "lucide-react";
import { toast } from "sonner";
import {
AgentStatusCards,
ResolveWaitDialog,
AgentStreamViewer,
} from "@/components/agents";
// Role display labels
const ROLE_LABELS: Record<string, string> = {
ceo: "CEO",
product_owner: "Product Owner",
head_marketing: "Head of Marketing",
auditor: "Auditor",
main_pm: "Main PM",
cell_pm: "Cell PM",
developer: "Developer",
qa: "QA Engineer",
documenter: "Documenter",
};
// Team display labels
const TEAM_LABELS: Record<string, string> = {
board: "Board",
main_pm: "Main PM",
backend: "Backend",
frontend: "Frontend",
ux_ui: "UX/UI",
marketing: "Marketing",
};
export default function AgentDetailPage() {
const params = useParams();
const router = useRouter();
const agentId = params.agentId as string;
const { data: agent, isLoading, error, refetch } = useAgentStatus(agentId);
const { data: definition } = useAgentDefinition(agentId);
const stopAgent = useStopAgent();
const spawnAgent = useSpawnAgent();
// Get display values from definition or fallback
const displayName = definition?.name || agentId;
const roleLabel = definition?.role ? ROLE_LABELS[definition.role] || definition.role : null;
const teamLabel = definition?.team ? TEAM_LABELS[definition.team] || definition.team : null;
const handleStop = async (graceful: boolean) => {
try {
await stopAgent.mutateAsync({ agentId, graceful });
toast.success(graceful ? "Agent stopping gracefully" : "Agent force stopped");
} catch {
toast.error("Failed to stop agent");
}
};
const handleSpawn = async () => {
try {
await spawnAgent.mutateAsync({ agentId });
toast.success("Agent spawned successfully");
} catch {
toast.error("Failed to spawn agent");
}
};
if (error) {
return (
<div className="space-y-6">
<Button variant="ghost" onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4 mr-2" />
Back
</Button>
<Card className="w-full max-w-lg mx-auto">
<CardContent className="pt-6">
<div className="flex items-center gap-2 text-red-500">
<AlertTriangle className="h-5 w-5" />
<span>Failed to load agent status</span>
</div>
<p className="text-muted-foreground mt-2">
The agent may not be running or the ID is invalid.
</p>
<Button className="mt-4" onClick={handleSpawn}>
<Play className="h-4 w-4 mr-2" />
Spawn Agent
</Button>
</CardContent>
</Card>
</div>
);
}
const isActive = agent && ["running", "ready", "starting", "waiting_long"].includes(agent.state);
const isWaiting = agent?.state === "waiting_long";
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" onClick={() => router.back()}>
<ArrowLeft className="h-4 w-4 mr-2" />
Back
</Button>
<div>
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight">{displayName}</h1>
{roleLabel && (
<Badge variant="secondary" className="gap-1">
<User className="h-3 w-3" />
{roleLabel}
</Badge>
)}
{teamLabel && (
<Badge variant="outline" className="gap-1">
<Users className="h-3 w-3" />
{teamLabel}
</Badge>
)}
</div>
<p className="text-muted-foreground">
{agentId !== displayName ? `@${agentId}` : "Agent Details"}
</p>
</div>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
{isWaiting && <ResolveWaitDialog agentId={agentId} />}
{isActive ? (
<>
<Button variant="outline" onClick={() => handleStop(true)}>
<Square className="h-4 w-4 mr-2" />
Stop
</Button>
<Button variant="destructive" onClick={() => handleStop(false)}>
<Square className="h-4 w-4 mr-2" />
Force Stop
</Button>
</>
) : (
<Button onClick={handleSpawn}>
<Play className="h-4 w-4 mr-2" />
Spawn
</Button>
)}
</div>
</div>
{isLoading ? (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<Card key={i}>
<CardHeader>
<Skeleton className="h-5 w-24" />
</CardHeader>
<CardContent>
<Skeleton className="h-8 w-32" />
</CardContent>
</Card>
))}
</div>
) : agent ? (
<>
{/* Status Cards */}
<AgentStatusCards agent={agent} />
{/* Started At */}
{agent.started_at && (
<Card>
<CardHeader>
<CardTitle className="text-sm font-medium">Started</CardTitle>
</CardHeader>
<CardContent>
<p>{new Date(agent.started_at).toLocaleString()}</p>
<p className="text-muted-foreground text-sm">
{formatDistanceToNow(new Date(agent.started_at))} ago
</p>
</CardContent>
</Card>
)}
{/* Error Count */}
{agent.error_count > 0 && (
<Card className="border-red-500/50">
<CardHeader>
<CardTitle className="text-red-500 flex items-center gap-2">
<AlertTriangle className="h-5 w-5" />
Errors Encountered
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-lg font-semibold text-red-600">{agent.error_count} error(s)</p>
</CardContent>
</Card>
)}
{/* Waiting State Info */}
{isWaiting && (
<Card className="border-orange-500/50">
<CardHeader>
<CardTitle className="text-orange-500 flex items-center gap-2">
<Clock className="h-5 w-5" />
Agent Waiting for Input
</CardTitle>
<CardDescription>
This agent is blocked and waiting for human input or external resolution.
</CardDescription>
</CardHeader>
<CardContent>
<p className="text-muted-foreground">
Use the &quot;Resolve Wait&quot; button above to provide the information or decision
the agent needs to continue execution.
</p>
</CardContent>
</Card>
)}
{/* Agent Stream Viewer */}
{isActive && <AgentStreamViewer agentId={agentId} agentName={displayName} />}
</>
) : null}
</div>
);
}
+123
View File
@@ -0,0 +1,123 @@
"use client";
import { useMemo } from "react";
import {
useOrchestratorStatus,
useWaitingAgents,
useAgentDefinitions,
} from "@/hooks/use-agents";
import { AgentStatusResponse } from "@/types";
import { Button } from "@/components/ui/button";
import { RefreshCw } from "lucide-react";
import { OfflineState } from "@/components/ui/offline-state";
import {
getBoardAgents,
getMainPm,
getBackendAgents,
getFrontendAgents,
getUxAgents,
} from "@/lib/agent-definitions";
import {
OrchestratorStatusCards,
WaitingAgentsAlert,
AgentGrid,
} from "@/components/agents";
export default function AgentsPage() {
const { data: agents = [], isLoading: agentsLoading } = useAgentDefinitions();
const { data: status, isLoading, error, refetch } = useOrchestratorStatus();
const { data: waitingAgents } = useWaitingAgents();
// Check if it's a connection error (backend not running)
const isOffline = error && (
error.message?.includes("Network Error") ||
error.message?.includes("ECONNREFUSED") ||
(error as { code?: string })?.code === "ERR_NETWORK"
);
// Convert agents array to a record keyed by agent_id for easy lookup
const agentStatuses = useMemo(() => {
const result: Record<string, AgentStatusResponse> = {};
if (status?.agents) {
for (const agent of status.agents) {
result[agent.agent_id] = agent;
}
}
return result;
}, [status]);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Agents</h1>
<p className="text-muted-foreground">
Monitor and control your AI workforce
</p>
</div>
<Button variant="outline" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
{isOffline ? (
<OfflineState
title="Orchestrator Not Running"
description="Start the RoboCo orchestrator to spawn and monitor agents. The agent roster is shown below for reference."
onRetry={() => refetch()}
/>
) : (
<>
{/* Status Overview */}
<OrchestratorStatusCards status={status} isLoading={isLoading} />
{/* Waiting Agents Alert */}
{waitingAgents && <WaitingAgentsAlert waitingAgents={waitingAgents} />}
</>
)}
{/* Agent Grids - Dynamically loaded from API */}
<AgentGrid
title="Board"
agents={getBoardAgents(agents)}
agentStatuses={agentStatuses}
isLoading={(isLoading || agentsLoading) && !isOffline}
columns={4}
/>
<AgentGrid
title="Main PM"
agents={getMainPm(agents)}
agentStatuses={agentStatuses}
isLoading={(isLoading || agentsLoading) && !isOffline}
columns={4}
/>
<AgentGrid
title="Backend Cell"
agents={getBackendAgents(agents)}
agentStatuses={agentStatuses}
isLoading={(isLoading || agentsLoading) && !isOffline}
columns={5}
/>
<AgentGrid
title="Frontend Cell"
agents={getFrontendAgents(agents)}
agentStatuses={agentStatuses}
isLoading={(isLoading || agentsLoading) && !isOffline}
columns={5}
/>
<AgentGrid
title="UX/UI Cell"
agents={getUxAgents(agents)}
agentStatuses={agentStatuses}
isLoading={(isLoading || agentsLoading) && !isOffline}
columns={4}
/>
</div>
);
}
@@ -0,0 +1,7 @@
"use client";
import { AuditorDashboard } from "@/components/auditor";
export default function AuditorPage() {
return <AuditorDashboard />;
}
@@ -0,0 +1,289 @@
"use client";
import { useEffect, useRef } from "react";
import { useParams, useSearchParams } from "next/navigation";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useSession, useSessionMessages } from "@/hooks/use-channels";
import { messagesApi } from "@/lib/api/messages";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { MessageComposer } from "@/components/communications/message-composer";
import { MessageTypeBadge } from "@/components/communications/message-type-badge";
import { Markdown } from "@/components/ui/markdown";
import { getAgentDisplayName, getAgentInitials } from "@/lib/agent-utils";
import {
ArrowLeft,
MessageSquare,
ListTodo,
Clock,
Hash,
RefreshCw,
} from "lucide-react";
import { formatDistanceToNow, format } from "date-fns";
import { toast } from "sonner";
import Link from "next/link";
import { Suspense } from "react";
function SessionDetailContent() {
const params = useParams();
const searchParams = useSearchParams();
const sessionId = params.sessionId as string;
// Read navigation context from URL params
const channelId = searchParams.get("channel");
const groupId = searchParams.get("group");
// Build back URL preserving context
const backUrl = channelId && groupId
? `/communications?channel=${channelId}&group=${groupId}`
: "/communications";
const queryClient = useQueryClient();
const scrollRef = useRef<HTMLDivElement>(null);
// Fetch session details and messages
const { data: session, isLoading: loadingSession, refetch: refetchSession } = useSession(sessionId);
const { data: messagesData, isLoading: loadingMessages, refetch: refetchMessages } = useSessionMessages(sessionId);
// Sort messages chronologically (oldest first for chat UI)
const messages = [...(messagesData?.items || [])].sort(
(a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime()
);
// Track if we've done the initial scroll
const hasScrolledRef = useRef(false);
// Auto-scroll to bottom only once on initial load
useEffect(() => {
if (scrollRef.current && messages.length > 0 && !hasScrolledRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
hasScrolledRef.current = true;
}
}, [messages.length]);
// Send message mutation
const sendMessage = useMutation({
mutationFn: async ({ content, type }: { content: string; type: string }) => {
return messagesApi.send(sessionId, content, type);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["messages", "list", sessionId] });
toast.success("Message sent");
},
onError: (error: Error) => {
toast.error("Failed to send message: " + error.message);
},
});
const handleSendMessage = (message: { content: string; type: string }) => {
sendMessage.mutate(message);
};
const handleRefresh = () => {
refetchSession();
refetchMessages();
};
// Get primary task
const primaryTask = session?.task_links?.find(t => t.is_primary) || session?.task_links?.[0];
if (loadingSession) {
return (
<div className="space-y-6">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-32 w-full" />
<Skeleton className="h-96 w-full" />
</div>
);
}
if (!session) {
return (
<div className="space-y-6">
<Link href={backUrl}>
<Button variant="ghost" size="sm">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Communications
</Button>
</Link>
<Card>
<CardContent className="pt-6">
<div className="text-center py-12">
<MessageSquare className="h-12 w-12 mx-auto mb-4 text-muted-foreground/50" />
<h3 className="text-lg font-medium mb-2">Session Not Found</h3>
<p className="text-sm text-muted-foreground">
The session you&apos;re looking for doesn&apos;t exist or has been deleted.
</p>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div className="flex flex-col h-[calc(100vh-7rem)]">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-4">
<Link href={backUrl}>
<Button variant="ghost" size="sm">
<ArrowLeft className="h-4 w-4 mr-2" />
Back
</Button>
</Link>
<div>
<h1 className="text-2xl font-bold tracking-tight flex items-center gap-2">
<MessageSquare className="h-6 w-6" />
Session {sessionId.slice(0, 8)}
</h1>
<p className="text-muted-foreground text-sm">
Started {formatDistanceToNow(new Date(session.started_at))} ago
</p>
</div>
</div>
<Button variant="outline" onClick={handleRefresh}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
{/* Session Info Bar */}
<Card className="mb-4 shrink-0">
<CardContent className="py-3">
<div className="flex items-center gap-4 flex-wrap">
<Badge variant={session.status === "active" ? "default" : "secondary"}>
{session.status}
</Badge>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<MessageSquare className="h-4 w-4" />
{session.message_count} messages
</div>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Clock className="h-4 w-4" />
{format(new Date(session.started_at), "MMM d, yyyy h:mm a")}
</div>
{session.closed_at && (
<div className="flex items-center gap-1 text-sm text-muted-foreground">
Closed: {format(new Date(session.closed_at), "MMM d, yyyy h:mm a")}
</div>
)}
{/* Linked Tasks */}
{session.task_links && session.task_links.length > 0 && (
<>
<span className="text-muted-foreground">|</span>
<div className="flex items-center gap-2">
<ListTodo className="h-4 w-4 text-muted-foreground" />
{primaryTask && (
<Link
href={`/tasks/${primaryTask.task_id}`}
className="text-sm text-primary hover:underline"
>
{primaryTask.task_title || `Task ${primaryTask.task_id.slice(0, 8)}`}
</Link>
)}
{session.task_links.length > 1 && (
<Badge variant="outline" className="text-xs">
+{session.task_links.length - 1} more
</Badge>
)}
</div>
</>
)}
</div>
</CardContent>
</Card>
{/* Messages Area */}
<Card className="flex-1 flex flex-col min-h-0">
<CardHeader className="pb-2 shrink-0">
<CardTitle className="text-sm flex items-center gap-2">
<Hash className="h-4 w-4" />
Messages
</CardTitle>
</CardHeader>
<CardContent className="flex-1 flex flex-col p-0 min-h-0">
{/* Messages List */}
<div ref={scrollRef} className="flex-1 overflow-y-auto p-4">
{loadingMessages ? (
<div className="space-y-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex gap-3">
<Skeleton className="h-8 w-8 rounded-full" />
<div className="flex-1">
<Skeleton className="h-4 w-32 mb-2" />
<Skeleton className="h-12 w-full" />
</div>
</div>
))}
</div>
) : messages.length === 0 ? (
<div className="text-center py-12 text-muted-foreground">
<MessageSquare className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>No messages in this session</p>
<p className="text-sm">Use the composer below to start the conversation</p>
</div>
) : (
<div className="space-y-3">
{messages.map((message) => (
<div
key={message.id}
className="flex gap-3 p-3 rounded-lg border bg-card hover:bg-muted/30 transition-colors"
>
<div className="h-9 w-10 rounded-lg bg-primary/10 flex items-center justify-center shrink-0 border">
<span className="text-[10px] font-bold tracking-tight">
{getAgentInitials(message.agent_id)}
</span>
</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1.5">
<span className="font-semibold text-sm">
{getAgentDisplayName(message.agent_id)}
</span>
<MessageTypeBadge type={message.type} />
<span className="text-xs text-muted-foreground ml-auto">
{formatDistanceToNow(new Date(message.timestamp))} ago
</span>
</div>
<div className="text-sm prose prose-sm dark:prose-invert max-w-none">
<Markdown>{message.content}</Markdown>
</div>
</div>
</div>
))}
</div>
)}
</div>
{/* Message Composer */}
<div className="shrink-0 border-t">
<MessageComposer
channelId={sessionId}
onSend={handleSendMessage}
isSending={sendMessage.isPending}
/>
</div>
</CardContent>
</Card>
</div>
);
}
// Wrap in Suspense for useSearchParams
export default function SessionDetailPage() {
return (
<Suspense
fallback={
<div className="space-y-6">
<Skeleton className="h-10 w-64" />
<Skeleton className="h-32 w-full" />
<Skeleton className="h-96 w-full" />
</div>
}
>
<SessionDetailContent />
</Suspense>
);
}
@@ -0,0 +1,425 @@
"use client";
import { Suspense, useCallback } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import {
useChannels,
useChannelGroups,
useGroupSessions,
} from "@/hooks/use-channels";
import type { Channel } from "@/types";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { ScrollArea } from "@/components/ui/scroll-area";
import { OfflineState } from "@/components/ui/offline-state";
import {
Hash,
Lock,
Users,
MessageSquare,
RefreshCw,
Folder,
MessageCircle,
} from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import Link from "next/link";
// =============================================================================
// Channel List Panel
// =============================================================================
interface ChannelListProps {
channels: Channel[];
selectedId: string | null;
onSelect: (id: string) => void;
isLoading: boolean;
}
function ChannelList({ channels, selectedId, onSelect, isLoading }: ChannelListProps) {
const cellChannels = channels.filter((c) => c.type === "cell");
const crossCellChannels = channels.filter((c) => c.type === "cross_cell");
const managementChannels = channels.filter((c) => c.type === "management");
const otherChannels = channels.filter(
(c) => !["cell", "cross_cell", "management"].includes(c.type)
);
const renderGroup = (title: string, items: Channel[]) => {
if (items.length === 0) return null;
return (
<div className="mb-4">
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2 px-2">
{title}
</h4>
{items.map((channel) => (
<button
key={channel.id}
onClick={() => onSelect(channel.id)}
className={
"w-full flex items-center gap-2 px-3 py-2 rounded-lg text-sm text-left transition-colors " +
(selectedId === channel.id
? "bg-primary text-primary-foreground"
: "hover:bg-muted")
}
>
{channel.is_private ? (
<Lock className="h-4 w-4 shrink-0" />
) : (
<Hash className="h-4 w-4 shrink-0" />
)}
<span className="truncate">{channel.name}</span>
</button>
))}
</div>
);
};
if (isLoading) {
return (
<div className="p-2 space-y-2">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</div>
);
}
return (
<ScrollArea className="h-full">
<div className="p-2">
{renderGroup("Cell Channels", cellChannels)}
{renderGroup("Cross-Cell", crossCellChannels)}
{renderGroup("Management", managementChannels)}
{renderGroup("Other", otherChannels)}
</div>
</ScrollArea>
);
}
// =============================================================================
// Group List Panel
// =============================================================================
interface GroupListProps {
channelId: string;
selectedId: string | null;
onSelect: (id: string) => void;
}
function GroupList({ channelId, selectedId, onSelect }: GroupListProps) {
const { data: groups, isLoading } = useChannelGroups(channelId);
if (isLoading) {
return (
<div className="p-2 space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</div>
);
}
if (!groups || groups.length === 0) {
return (
<div className="h-full flex items-center justify-center text-muted-foreground">
<div className="text-center p-4">
<Folder className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">No groups in this channel</p>
</div>
</div>
);
}
return (
<ScrollArea className="h-full">
<div className="p-2 space-y-1">
{groups.map((group) => (
<button
key={group.id}
onClick={() => onSelect(group.id)}
className={
"w-full flex items-center justify-between px-3 py-2 rounded-lg text-sm text-left transition-colors " +
(selectedId === group.id
? "bg-primary text-primary-foreground"
: "hover:bg-muted")
}
>
<div className="flex items-center gap-2 min-w-0">
<Users className="h-4 w-4 shrink-0" />
<span className="truncate">{group.name}</span>
</div>
<Badge variant="secondary" className="text-xs shrink-0 ml-2">
{group.total_messages}
</Badge>
</button>
))}
</div>
</ScrollArea>
);
}
// =============================================================================
// Session List Panel
// =============================================================================
interface SessionListProps {
channelId: string;
groupId: string;
}
function SessionList({ channelId, groupId }: SessionListProps) {
const { data: sessions, isLoading } = useGroupSessions(groupId);
if (isLoading) {
return (
<div className="p-2 space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-16 w-full" />
))}
</div>
);
}
if (!sessions || sessions.length === 0) {
return (
<div className="h-full flex items-center justify-center text-muted-foreground">
<div className="text-center p-4">
<MessageCircle className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">No sessions in this group</p>
</div>
</div>
);
}
return (
<ScrollArea className="h-full">
<div className="p-2 space-y-2">
{sessions.map((session) => (
<Link
key={session.id}
href={`/communications/${session.id}?channel=${channelId}&group=${groupId}`}
className="block p-3 rounded-lg border bg-card hover:bg-muted/50 hover:border-primary/50 transition-all"
>
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="font-medium text-sm truncate">
{session.task_links?.length > 0 ? (
<>
{session.task_links.find(l => l.is_primary)?.task_title ||
session.task_links[0]?.task_title ||
`Task ${session.task_links[0]?.task_id.slice(0, 8)}`}
</>
) : (
`Session ${session.id.slice(0, 8)}`
)}
</div>
<div className="text-xs text-muted-foreground mt-1">
{formatDistanceToNow(new Date(session.started_at))} ago
</div>
</div>
<div className="flex flex-col items-end gap-1 shrink-0">
<Badge
variant={session.status === "active" ? "default" : "secondary"}
className="text-xs"
>
{session.status}
</Badge>
<span className="text-xs text-muted-foreground">
{session.message_count} msgs
</span>
</div>
</div>
</Link>
))}
</div>
</ScrollArea>
);
}
// =============================================================================
// Empty State Components
// =============================================================================
function EmptyPanel({ icon: Icon, message }: { icon: typeof MessageSquare; message: string }) {
return (
<div className="h-full flex items-center justify-center text-muted-foreground">
<div className="text-center p-4">
<Icon className="h-8 w-8 mx-auto mb-2 opacity-50" />
<p className="text-sm">{message}</p>
</div>
</div>
);
}
// =============================================================================
// Main Page
// =============================================================================
function CommunicationsPageContent() {
const router = useRouter();
const searchParams = useSearchParams();
const channelId = searchParams.get("channel");
const groupId = searchParams.get("group");
const { data: channels, isLoading, error, refetch } = useChannels();
const isOffline = error && (
error.message?.includes("Network Error") ||
(error as { code?: string })?.code === "ERR_NETWORK"
);
const updateParams = useCallback(
(updates: Record<string, string | null>) => {
const params = new URLSearchParams(searchParams.toString());
Object.entries(updates).forEach(([key, value]) => {
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
});
const query = params.toString();
router.push(query ? `/communications?${query}` : "/communications");
},
[router, searchParams]
);
const handleSelectChannel = useCallback((id: string) => {
updateParams({ channel: id, group: null });
}, [updateParams]);
const handleSelectGroup = useCallback((id: string) => {
updateParams({ group: id });
}, [updateParams]);
const selectedChannel = channels?.find(c => c.id === channelId);
return (
<div className="flex flex-col h-[calc(100vh-7rem)]">
{/* Header */}
<div className="flex items-center justify-between mb-4">
<div>
<h1 className="text-3xl font-bold tracking-tight">Communications</h1>
<p className="text-muted-foreground">
Browse channels, groups, and sessions
</p>
</div>
<Button variant="outline" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
{isOffline ? (
<OfflineState
title="Cannot Load Channels"
description="Start the RoboCo orchestrator to view communications."
onRetry={() => refetch()}
/>
) : (
<div className="grid grid-cols-12 gap-6 flex-1 min-h-0">
{/* Panel 1: Channels */}
<Card className="col-span-3 flex flex-col overflow-hidden">
<CardContent className="p-3 flex flex-col h-full">
<div className="flex items-center gap-2 mb-3 pb-2 border-b">
<Hash className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Channels</span>
</div>
<div className="flex-1 overflow-hidden -mx-3">
<ChannelList
channels={channels || []}
selectedId={channelId}
onSelect={handleSelectChannel}
isLoading={isLoading}
/>
</div>
</CardContent>
</Card>
{/* Panel 2: Groups */}
<Card className="col-span-3 flex flex-col overflow-hidden">
<CardContent className="p-3 flex flex-col h-full">
<div className="flex items-center gap-2 mb-3 pb-2 border-b">
<Users className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Groups</span>
{selectedChannel && (
<Badge variant="outline" className="ml-auto text-xs font-normal">
{selectedChannel.name}
</Badge>
)}
</div>
<div className="flex-1 overflow-hidden -mx-3">
{channelId ? (
<GroupList
channelId={channelId}
selectedId={groupId}
onSelect={handleSelectGroup}
/>
) : (
<EmptyPanel icon={Folder} message="Select a channel" />
)}
</div>
</CardContent>
</Card>
{/* Panel 3: Sessions */}
<Card className="col-span-6 flex flex-col overflow-hidden">
<CardContent className="p-3 flex flex-col h-full">
<div className="flex items-center gap-2 mb-3 pb-2 border-b">
<MessageCircle className="h-4 w-4 text-muted-foreground" />
<span className="text-sm font-medium">Sessions</span>
</div>
<div className="flex-1 overflow-hidden -mx-3">
{channelId && groupId ? (
<SessionList channelId={channelId} groupId={groupId} />
) : (
<EmptyPanel
icon={MessageSquare}
message={channelId ? "Select a group" : "Select a channel and group"}
/>
)}
</div>
</CardContent>
</Card>
</div>
)}
</div>
);
}
// Wrap in Suspense for useSearchParams
export default function CommunicationsPage() {
return (
<Suspense fallback={
<div className="flex flex-col h-[calc(100vh-7rem)]">
<div className="flex items-center justify-between mb-4">
<div>
<Skeleton className="h-9 w-48 mb-2" />
<Skeleton className="h-5 w-64" />
</div>
</div>
<div className="grid grid-cols-12 gap-6 flex-1">
<Card className="col-span-3">
<CardContent className="p-3 space-y-2">
{Array.from({ length: 6 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</CardContent>
</Card>
<Card className="col-span-3">
<CardContent className="p-3 space-y-2">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</CardContent>
</Card>
<Card className="col-span-6" />
</div>
</div>
}>
<CommunicationsPageContent />
</Suspense>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { GitBrowser } from "@/components/git";
export default function GitPage() {
return <GitBrowser />;
}
@@ -0,0 +1,190 @@
"use client";
import { use } from "react";
import { useJournalEntry } from "@/hooks/use-journals";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Markdown } from "@/components/ui/markdown";
import { EntryTypeBadge } from "@/components/journals/entry-type-badge";
import {
ArrowLeft,
AlertTriangle,
RefreshCw,
Clock,
Tag,
Link2,
User,
} from "lucide-react";
import Link from "next/link";
interface JournalEntryPageProps {
params: Promise<{ entryId: string }>;
}
function formatFullDate(timestamp: string): string {
const date = new Date(timestamp);
return date.toLocaleDateString("en-US", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
hour: "2-digit",
minute: "2-digit",
});
}
export default function JournalEntryPage({ params }: JournalEntryPageProps) {
const { entryId } = use(params);
const { data: entry, isLoading, error, refetch } = useJournalEntry(entryId);
// Loading state
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Skeleton className="h-10 w-10" />
<div className="space-y-2">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-4 w-32" />
</div>
</div>
<Skeleton className="h-48" />
<Skeleton className="h-96" />
</div>
);
}
// Error state
if (error || !entry) {
return (
<div className="space-y-6">
<Link href="/journals">
<Button variant="ghost" size="sm">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Journals
</Button>
</Link>
<Card>
<CardContent className="pt-6">
<div className="text-center py-12">
<AlertTriangle className="h-16 w-16 mx-auto mb-4 text-destructive" />
<h2 className="text-xl font-semibold mb-2">Entry Not Found</h2>
<p className="text-muted-foreground mb-6">
{error?.message ??
"The journal entry you're looking for doesn't exist or has been deleted."}
</p>
<div className="flex justify-center gap-4">
<Button variant="outline" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
<Link href="/journals">
<Button>View All Journals</Button>
</Link>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between border-b pb-4">
<div className="flex items-center gap-4">
<Link href="/journals">
<Button variant="ghost" size="icon">
<ArrowLeft className="h-5 w-5" />
</Button>
</Link>
<div>
<div className="flex items-center gap-2 mb-1">
<EntryTypeBadge type={entry.type} />
{entry.sentiment && (
<Badge variant="outline">{entry.sentiment}</Badge>
)}
</div>
<h1 className="text-2xl font-bold">{entry.title}</h1>
</div>
</div>
</div>
{/* Metadata */}
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
<Clock className="h-4 w-4" />
<span>Created</span>
</div>
<p className="font-medium">{formatFullDate(entry.timestamp)}</p>
</CardContent>
</Card>
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
<User className="h-4 w-4" />
<span>Journal</span>
</div>
<p className="font-medium">{entry.journal_id.slice(0, 8)}...</p>
</CardContent>
</Card>
{entry.task_id && (
<Card>
<CardContent className="pt-4">
<div className="flex items-center gap-2 text-sm text-muted-foreground mb-1">
<Link2 className="h-4 w-4" />
<span>Related Task</span>
</div>
<Link href={`/tasks/${entry.task_id}`}>
<Badge variant="outline" className="hover:bg-muted cursor-pointer">
Task #{entry.task_id.slice(0, 8)}
</Badge>
</Link>
</CardContent>
</Card>
)}
</div>
{/* Content */}
<Card>
<CardHeader>
<CardTitle>Content</CardTitle>
</CardHeader>
<CardContent>
<div className="prose prose-sm dark:prose-invert max-w-none">
<Markdown>{entry.content}</Markdown>
</div>
</CardContent>
</Card>
{/* Tags */}
{entry.tags.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Tag className="h-4 w-4" />
Tags
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{entry.tags.map((tag) => (
<Badge key={tag} variant="secondary">
{tag}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
</div>
);
}
+246
View File
@@ -0,0 +1,246 @@
"use client";
import { Suspense, useCallback, useEffect } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useAgents } from "@/hooks/use-agents";
import { JournalEntryType } from "@/types";
import { AgentList } from "@/components/journals/agent-list";
import { JournalView } from "@/components/journals/journal-view";
import { Card, CardContent } from "@/components/ui/card";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { BookOpen, Search, RefreshCw } from "lucide-react";
const JOURNALS_STATE_KEY = "roboco-journals-state";
interface JournalsState {
agent: string | null;
q: string | null;
type: string | null;
task: string | null;
}
function saveJournalsState(state: JournalsState) {
try {
localStorage.setItem(JOURNALS_STATE_KEY, JSON.stringify(state));
} catch {
// Ignore localStorage errors
}
}
function loadJournalsState(): JournalsState | null {
try {
const stored = localStorage.getItem(JOURNALS_STATE_KEY);
return stored ? JSON.parse(stored) : null;
} catch {
return null;
}
}
function JournalsPageContent() {
const router = useRouter();
const searchParams = useSearchParams();
// Read state from URL params
const urlAgentId = searchParams.get("agent");
const urlSearch = searchParams.get("q") || "";
const urlType = searchParams.get("type");
const urlTask = searchParams.get("task");
// Restore from localStorage if URL has no params (fresh navigation)
useEffect(() => {
const hasUrlParams = urlAgentId || urlSearch || urlType || urlTask;
if (!hasUrlParams) {
const saved = loadJournalsState();
if (saved?.agent) {
const params = new URLSearchParams();
if (saved.agent) params.set("agent", saved.agent);
if (saved.q) params.set("q", saved.q);
if (saved.type) params.set("type", saved.type);
if (saved.task) params.set("task", saved.task);
const query = params.toString();
if (query) {
router.replace(`/journals?${query}`);
}
}
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []); // Intentionally only run on mount
// Derive state from URL
const selectedAgentId = urlAgentId;
const agentSearch = urlSearch;
const typeFilter = (urlType as JournalEntryType) || "all";
const taskFilter = urlTask;
const { data: agents, isLoading: loadingAgents, refetch } = useAgents();
// Save state to localStorage whenever URL params change
useEffect(() => {
if (selectedAgentId) {
saveJournalsState({
agent: selectedAgentId,
q: agentSearch || null,
type: urlType, // Use raw URL param (null if "all")
task: taskFilter,
});
}
}, [selectedAgentId, agentSearch, urlType, taskFilter]);
// Update URL params
const updateParams = useCallback((updates: Record<string, string | null>) => {
const params = new URLSearchParams(searchParams.toString());
Object.entries(updates).forEach(([key, value]) => {
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
});
const query = params.toString();
router.push(query ? `/journals?${query}` : "/journals");
}, [router, searchParams]);
const handleSelectAgent = useCallback((agentId: string | null) => {
// Only reset filters when changing to a different agent
if (agentId !== selectedAgentId) {
updateParams({ agent: agentId, type: null, task: null });
}
}, [updateParams, selectedAgentId]);
const handleAgentSearch = useCallback((value: string) => {
updateParams({ q: value || null });
}, [updateParams]);
const handleTypeChange = useCallback((value: JournalEntryType | "all") => {
updateParams({ type: value === "all" ? null : value });
}, [updateParams]);
const handleTaskChange = useCallback((value: string | null) => {
updateParams({ task: value });
}, [updateParams]);
// Filter agents by search
const filteredAgents = (agents ?? []).filter((agent) => {
if (!agentSearch) return true;
const query = agentSearch.toLowerCase();
return (
agent.agent_id.toLowerCase().includes(query) ||
agent.role.toLowerCase().includes(query) ||
agent.team?.toLowerCase().includes(query)
);
});
// Get selected agent
const selectedAgent = agents?.find((a) => a.agent_id === selectedAgentId);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Agent Journals</h1>
<p className="text-muted-foreground">
View agent reflections, learnings, and decisions
</p>
</div>
<Button variant="outline" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
{/* Main Content */}
<div className="grid grid-cols-12 gap-6">
{/* Sidebar */}
<div className="col-span-12 lg:col-span-3">
<Card>
<CardContent className="p-3">
{/* Agent Search */}
<div className="relative mb-3">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
value={agentSearch}
onChange={(e) => handleAgentSearch(e.target.value)}
placeholder="Search agents..."
className="pl-9"
/>
</div>
{/* Agent List */}
<AgentList
agents={filteredAgents}
isLoading={loadingAgents}
selectedAgentId={selectedAgentId}
onSelectAgent={handleSelectAgent}
/>
</CardContent>
</Card>
</div>
{/* Journal Content */}
<div className="col-span-12 lg:col-span-9">
<Card>
<CardContent className="p-6">
{selectedAgent ? (
<JournalView
agent={selectedAgent}
typeFilter={typeFilter as JournalEntryType | "all"}
onTypeChange={handleTypeChange}
taskFilter={taskFilter}
onTaskChange={handleTaskChange}
/>
) : (
<div className="text-center py-16 text-muted-foreground">
<BookOpen className="h-16 w-16 mx-auto mb-4 opacity-50" />
<h3 className="text-lg font-medium mb-2">Select an Agent</h3>
<p className="text-sm">
Choose an agent from the list to view their journal entries
</p>
</div>
)}
</CardContent>
</Card>
</div>
</div>
</div>
);
}
// Wrap in Suspense for useSearchParams
export default function JournalsPage() {
return (
<Suspense fallback={
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Skeleton className="h-9 w-48 mb-2" />
<Skeleton className="h-5 w-64" />
</div>
</div>
<div className="grid grid-cols-12 gap-6">
<div className="col-span-12 lg:col-span-3">
<Card>
<CardContent className="p-3 space-y-2">
<Skeleton className="h-10 w-full" />
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-12 w-full" />
))}
</CardContent>
</Card>
</div>
<div className="col-span-12 lg:col-span-9">
<Card>
<CardContent className="p-6">
<Skeleton className="h-64 w-full" />
</CardContent>
</Card>
</div>
</div>
</div>
}>
<JournalsPageContent />
</Suspense>
);
}
+75
View File
@@ -0,0 +1,75 @@
"use client";
import { Suspense } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { DevKanban, QaKanban, PmKanban } from "@/components/kanban";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Skeleton } from "@/components/ui/skeleton";
import { Code, TestTube, ClipboardList } from "lucide-react";
type KanbanView = "dev" | "qa" | "pm";
function KanbanPageContent() {
const router = useRouter();
const searchParams = useSearchParams();
// Read view from URL params, default to "dev"
const view = (searchParams.get("view") as KanbanView) || "dev";
const handleViewChange = (newView: string) => {
if (newView === "dev") {
router.push("/kanban");
} else {
router.push(`/kanban?view=${newView}`);
}
};
return (
<div className="space-y-6">
<Tabs value={view} onValueChange={handleViewChange}>
<TabsList>
<TabsTrigger value="dev" className="gap-2">
<Code className="h-4 w-4" />
Developer
</TabsTrigger>
<TabsTrigger value="qa" className="gap-2">
<TestTube className="h-4 w-4" />
QA
</TabsTrigger>
<TabsTrigger value="pm" className="gap-2">
<ClipboardList className="h-4 w-4" />
PM
</TabsTrigger>
</TabsList>
<TabsContent value="dev" className="mt-6">
<DevKanban />
</TabsContent>
<TabsContent value="qa" className="mt-6">
<QaKanban />
</TabsContent>
<TabsContent value="pm" className="mt-6">
<PmKanban />
</TabsContent>
</Tabs>
</div>
);
}
// Wrap in Suspense for useSearchParams
export default function KanbanPage() {
return (
<Suspense fallback={
<div className="space-y-6">
<Skeleton className="h-10 w-72" />
<div className="grid grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => (
<Skeleton key={i} className="h-96 w-full" />
))}
</div>
</div>
}>
<KanbanPageContent />
</Suspense>
);
}
@@ -0,0 +1,7 @@
"use client";
import { KnowledgeBaseBrowser } from "@/components/knowledge-base";
export default function KnowledgeBasePage() {
return <KnowledgeBaseBrowser />;
}
+25
View File
@@ -0,0 +1,25 @@
import { Suspense } from "react";
import { Sidebar } from "@/components/layout/sidebar";
import { Header } from "@/components/layout/header";
import { ScrollRestoration } from "@/components/scroll-restoration";
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="flex h-screen overflow-hidden">
<Sidebar />
<div className="flex flex-1 flex-col overflow-hidden">
<Header />
<main className="flex-1 overflow-auto bg-muted/30 p-6">
<Suspense fallback={null}>
<ScrollRestoration />
</Suspense>
{children}
</main>
</div>
</div>
);
}
+302
View File
@@ -0,0 +1,302 @@
"use client";
import { useOrchestratorStatus } from "@/hooks/use-agents";
import { useTasks } from "@/hooks/use-tasks";
import { TaskStatus, Team } from "@/types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
import { OfflineState } from "@/components/ui/offline-state";
import {
Activity,
TrendingUp,
Clock,
AlertTriangle,
Users,
CheckCircle,
XCircle,
RefreshCw,
Zap,
Timer,
} from "lucide-react";
interface MetricCardProps {
title: string;
value: string | number;
subtitle?: string;
icon: React.ReactNode;
trend?: "up" | "down" | "neutral";
trendValue?: string;
}
function MetricCard({ title, value, subtitle, icon, trend, trendValue }: MetricCardProps) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
{title}
</CardTitle>
{icon}
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value}</div>
{subtitle && (
<p className="text-xs text-muted-foreground mt-1">{subtitle}</p>
)}
{trend && trendValue && (
<div className={"flex items-center gap-1 mt-2 text-xs " +
(trend === "up" ? "text-green-600" : trend === "down" ? "text-red-600" : "text-gray-500")
}>
<TrendingUp className={"h-3 w-3 " + (trend === "down" ? "rotate-180" : "")} />
{trendValue}
</div>
)}
</CardContent>
</Card>
);
}
interface TeamHealthCardProps {
team: Team;
activeTasks: number;
blockedTasks: number;
completedToday: number;
}
function TeamHealthCard({ team, activeTasks, blockedTasks, completedToday }: TeamHealthCardProps) {
const healthScore = Math.max(0, 100 - (blockedTasks * 20));
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium capitalize">
{team.replace(/_/g, " ")} Cell
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-2 mb-3">
<Progress value={healthScore} className="flex-1" />
<span className="text-sm font-medium">{healthScore}%</span>
</div>
<div className="grid grid-cols-3 gap-2 text-center text-xs">
<div>
<div className="font-semibold text-blue-600">{activeTasks}</div>
<div className="text-muted-foreground">Active</div>
</div>
<div>
<div className="font-semibold text-red-600">{blockedTasks}</div>
<div className="text-muted-foreground">Blocked</div>
</div>
<div>
<div className="font-semibold text-green-600">{completedToday}</div>
<div className="text-muted-foreground">Done</div>
</div>
</div>
</CardContent>
</Card>
);
}
export default function MetricsPage() {
const { data: tasks, error: tasksError, refetch: refetchTasks } = useTasks();
const { data: status, error: statusError, refetch: refetchStatus } = useOrchestratorStatus();
const isOffline = (tasksError || statusError) && (
tasksError?.message?.includes("Network Error") ||
statusError?.message?.includes("Network Error")
);
const refetch = () => {
refetchTasks();
refetchStatus();
};
// Calculate metrics from local data
const taskList = tasks || [];
const agentList = status?.agents || [];
// Velocity metrics
const completedToday = taskList.filter((t) => {
if (!t.completed_at) return false;
const completed = new Date(t.completed_at);
const today = new Date();
return completed.toDateString() === today.toDateString();
}).length;
const completedThisWeek = taskList.filter((t) => {
if (!t.completed_at) return false;
const completed = new Date(t.completed_at);
const weekAgo = new Date();
weekAgo.setDate(weekAgo.getDate() - 7);
return completed > weekAgo;
}).length;
// Task status counts
const pending = taskList.filter((t) => t.status === TaskStatus.PENDING).length;
const inProgress = taskList.filter((t) => t.status === TaskStatus.IN_PROGRESS).length;
const blocked = taskList.filter((t) => t.status === TaskStatus.BLOCKED).length;
const awaitingQa = taskList.filter((t) => t.status === TaskStatus.AWAITING_QA).length;
const completed = taskList.filter((t) => t.status === TaskStatus.COMPLETED).length;
// Agent counts (from by_state if available, or count from agents array)
const runningAgents = status?.by_state?.running || agentList.filter((a) => a.state === "running").length;
const idleAgents = status?.by_state?.idle || agentList.filter((a) => a.state === "idle" || a.state === "stopped").length;
const waitingAgents = status?.waiting_count || agentList.filter((a) => a.state === "waiting_long").length;
const errorAgents = status?.by_state?.error || agentList.filter((a) => a.state === "error").length;
// Team metrics
const teamMetrics = Object.values(Team).map((team) => {
const teamTasks = taskList.filter((t) => t.team === team);
return {
team,
activeTasks: teamTasks.filter((t) =>
[TaskStatus.IN_PROGRESS, TaskStatus.CLAIMED].includes(t.status)
).length,
blockedTasks: teamTasks.filter((t) => t.status === TaskStatus.BLOCKED).length,
completedToday: teamTasks.filter((t) => {
if (!t.completed_at) return false;
const completed = new Date(t.completed_at);
const today = new Date();
return completed.toDateString() === today.toDateString();
}).length,
};
});
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Metrics</h1>
<p className="text-muted-foreground">
Performance analytics and operational insights
</p>
</div>
<Button variant="outline" onClick={refetch}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
{isOffline ? (
<OfflineState
title="Cannot Load Metrics"
description="Start the RoboCo orchestrator to view performance analytics."
onRetry={refetch}
/>
) : (
<>
{/* Velocity Metrics */}
<div>
<h2 className="text-lg font-semibold mb-3">Velocity</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<MetricCard
title="Completed Today"
value={completedToday}
subtitle="Tasks finished"
icon={<Zap className="h-4 w-4 text-green-500" />}
/>
<MetricCard
title="Completed This Week"
value={completedThisWeek}
subtitle="Rolling 7 days"
icon={<TrendingUp className="h-4 w-4 text-blue-500" />}
/>
<MetricCard
title="Total Completed"
value={completed}
subtitle="All time"
icon={<CheckCircle className="h-4 w-4 text-green-500" />}
/>
<MetricCard
title="Completion Rate"
value={taskList.length > 0 ? Math.round((completed / taskList.length) * 100) + "%" : "0%"}
subtitle="Of all tasks"
icon={<Activity className="h-4 w-4 text-purple-500" />}
/>
</div>
</div>
{/* Task Status */}
<div>
<h2 className="text-lg font-semibold mb-3">Task Status</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
<MetricCard
title="Pending"
value={pending}
icon={<Clock className="h-4 w-4 text-gray-500" />}
/>
<MetricCard
title="In Progress"
value={inProgress}
icon={<Activity className="h-4 w-4 text-blue-500" />}
/>
<MetricCard
title="Blocked"
value={blocked}
icon={<AlertTriangle className="h-4 w-4 text-red-500" />}
/>
<MetricCard
title="Awaiting QA"
value={awaitingQa}
icon={<Timer className="h-4 w-4 text-yellow-500" />}
/>
<MetricCard
title="Completed"
value={completed}
icon={<CheckCircle className="h-4 w-4 text-green-500" />}
/>
</div>
</div>
{/* Agent Status */}
<div>
<h2 className="text-lg font-semibold mb-3">Agent Status</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<MetricCard
title="Running"
value={runningAgents}
subtitle="Active agents"
icon={<Users className="h-4 w-4 text-green-500" />}
/>
<MetricCard
title="Idle"
value={idleAgents}
subtitle="Available"
icon={<Users className="h-4 w-4 text-gray-500" />}
/>
<MetricCard
title="Waiting"
value={waitingAgents}
subtitle="Needs input"
icon={<Clock className="h-4 w-4 text-yellow-500" />}
/>
<MetricCard
title="Errors"
value={errorAgents}
subtitle="Failed agents"
icon={<XCircle className="h-4 w-4 text-red-500" />}
/>
</div>
</div>
{/* Team Health */}
<div>
<h2 className="text-lg font-semibold mb-3">Team Health</h2>
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-5">
{teamMetrics.map((tm) => (
<TeamHealthCard
key={tm.team}
team={tm.team}
activeTasks={tm.activeTasks}
blockedTasks={tm.blockedTasks}
completedToday={tm.completedToday}
/>
))}
</div>
</div>
</>
)}
</div>
);
}
@@ -0,0 +1,262 @@
"use client";
import { useState } from "react";
import {
useNotifications,
useMarkNotificationRead,
useAcknowledgeNotification,
useMarkAllNotificationsRead,
} from "@/hooks/use-notifications";
import { Notification, NotificationType, NotificationPriority } from "@/types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { OfflineState } from "@/components/ui/offline-state";
import {
Bell,
Check,
CheckCheck,
AlertTriangle,
Info,
ListTodo,
ArrowUpCircle,
RefreshCw,
Mail,
MailOpen,
BookOpen,
AtSign,
} from "lucide-react";
import { formatDistanceToNow } from "date-fns";
import { toast } from "sonner";
import { Markdown } from "@/components/ui/markdown";
const typeIcons: Record<NotificationType, React.ReactNode> = {
[NotificationType.TASK_ASSIGNMENT]: <ListTodo className="h-4 w-4 text-green-500" />,
[NotificationType.PRIORITY_CHANGE]: <ArrowUpCircle className="h-4 w-4 text-orange-500" />,
[NotificationType.BLOCKER_ESCALATION]: <AlertTriangle className="h-4 w-4 text-red-500" />,
[NotificationType.REVIEW_REQUEST]: <Check className="h-4 w-4 text-purple-500" />,
[NotificationType.DOCUMENTATION_REQUEST]: <Info className="h-4 w-4 text-blue-500" />,
[NotificationType.ALERT]: <AlertTriangle className="h-4 w-4 text-yellow-500" />,
[NotificationType.BROADCAST]: <Bell className="h-4 w-4 text-gray-500" />,
[NotificationType.KNOWLEDGE_SHARE]: <BookOpen className="h-4 w-4 text-cyan-500" />,
[NotificationType.MENTION]: <AtSign className="h-4 w-4 text-indigo-500" />,
};
const priorityColors: Record<NotificationPriority, string> = {
[NotificationPriority.NORMAL]: "bg-gray-100 text-gray-700",
[NotificationPriority.HIGH]: "bg-orange-100 text-orange-700",
[NotificationPriority.URGENT]: "bg-red-100 text-red-700",
};
interface NotificationCardProps {
notification: Notification;
onMarkRead: () => void;
onAcknowledge: () => void;
}
function NotificationCard({ notification, onMarkRead, onAcknowledge }: NotificationCardProps) {
return (
<Card className={notification.is_read ? "opacity-70" : "border-l-4 border-l-primary"}>
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="mt-1">{typeIcons[notification.type]}</div>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium">{notification.subject}</span>
<Badge className={priorityColors[notification.priority] + " text-xs"}>
{notification.priority}
</Badge>
{!notification.is_read && (
<Badge variant="secondary" className="text-xs">New</Badge>
)}
{notification.requires_ack && !notification.is_acknowledged && (
<Badge variant="destructive" className="text-xs">Needs Ack</Badge>
)}
</div>
<div className="text-sm text-muted-foreground mt-1">
<Markdown>{notification.body}</Markdown>
</div>
<div className="flex items-center justify-between mt-3">
<div className="text-xs text-muted-foreground">
From: {notification.from_agent.slice(0, 8)} {" "}
{formatDistanceToNow(new Date(notification.timestamp))} ago
</div>
<div className="flex items-center gap-2">
{!notification.is_read && (
<Button variant="ghost" size="sm" onClick={onMarkRead}>
<MailOpen className="h-4 w-4 mr-1" />
Mark Read
</Button>
)}
{notification.requires_ack && !notification.is_acknowledged && (
<Button variant="default" size="sm" onClick={onAcknowledge}>
<CheckCheck className="h-4 w-4 mr-1" />
Acknowledge
</Button>
)}
</div>
</div>
</div>
</div>
</CardContent>
</Card>
);
}
export default function NotificationsPage() {
const [activeTab, setActiveTab] = useState<"all" | "unread" | "pending">("all");
const { data, isLoading, error, refetch } = useNotifications(
activeTab === "unread" ? { unread_only: true } :
activeTab === "pending" ? { pending_ack_only: true } :
undefined
);
const markRead = useMarkNotificationRead();
const acknowledge = useAcknowledgeNotification();
const markAllRead = useMarkAllNotificationsRead();
const isOffline = error && (
error.message?.includes("Network Error") ||
(error as { code?: string })?.code === "ERR_NETWORK"
);
const handleMarkRead = async (id: string) => {
try {
await markRead.mutateAsync(id);
toast.success("Marked as read");
} catch {
toast.error("Failed to mark as read");
}
};
const handleAcknowledge = async (id: string) => {
try {
await acknowledge.mutateAsync(id);
toast.success("Notification acknowledged");
} catch {
toast.error("Failed to acknowledge");
}
};
const handleMarkAllRead = async () => {
try {
await markAllRead.mutateAsync();
toast.success("All notifications marked as read");
} catch {
toast.error("Failed to mark all as read");
}
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Notifications</h1>
<p className="text-muted-foreground">
Manage alerts, approvals, and escalations
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleMarkAllRead}>
<CheckCheck className="h-4 w-4 mr-2" />
Mark All Read
</Button>
<Button variant="outline" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
</div>
{/* Stats */}
{!isOffline && data && (
<div className="grid grid-cols-3 gap-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground">
Total
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{data.total}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-1">
<Mail className="h-4 w-4" />
Unread
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-blue-600">{data.unread_count}</div>
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-medium text-muted-foreground flex items-center gap-1">
<Bell className="h-4 w-4" />
Pending Ack
</CardTitle>
</CardHeader>
<CardContent>
<div className="text-2xl font-bold text-red-600">{data.pending_ack_count}</div>
</CardContent>
</Card>
</div>
)}
{isOffline ? (
<OfflineState
title="Cannot Load Notifications"
description="Start the RoboCo orchestrator to view and manage notifications."
onRetry={() => refetch()}
/>
) : (
<Tabs value={activeTab} onValueChange={(v) => setActiveTab(v as typeof activeTab)}>
<TabsList>
<TabsTrigger value="all">All</TabsTrigger>
<TabsTrigger value="unread">
Unread {data && data.unread_count > 0 && `(${data.unread_count})`}
</TabsTrigger>
<TabsTrigger value="pending">
Pending {data && data.pending_ack_count > 0 && `(${data.pending_ack_count})`}
</TabsTrigger>
</TabsList>
<TabsContent value={activeTab} className="mt-4 space-y-3">
{isLoading ? (
Array.from({ length: 5 }).map((_, i) => (
<Card key={i}>
<CardContent className="p-4">
<Skeleton className="h-20 w-full" />
</CardContent>
</Card>
))
) : !data?.items.length ? (
<Card>
<CardContent className="py-8 text-center text-muted-foreground">
<Bell className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>No notifications to display</p>
</CardContent>
</Card>
) : (
data.items.map((notification) => (
<NotificationCard
key={notification.id}
notification={notification}
onMarkRead={() => handleMarkRead(notification.id)}
onAcknowledge={() => handleAcknowledge(notification.id)}
/>
))
)}
</TabsContent>
</Tabs>
)}
</div>
);
}
@@ -0,0 +1,7 @@
"use client";
import { CommandCenter } from "@/components/dashboard";
export default function OverviewPage() {
return <CommandCenter />;
}
+160
View File
@@ -0,0 +1,160 @@
"use client";
import { Suspense, useMemo, useCallback } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useProjects } from "@/hooks/use-projects";
import { Team } from "@/types";
import { OfflineState } from "@/components/ui/offline-state";
import { CreateProjectDialog, ProjectFilters, ProjectTable } from "@/components/projects";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { RefreshCw } from "lucide-react";
function ProjectsPageContent() {
const router = useRouter();
const searchParams = useSearchParams();
// Read state from URL params
const searchQuery = searchParams.get("q") || "";
const cellFilterParam = searchParams.get("cell");
const cellFilter = useMemo(
() => (cellFilterParam?.split(",").filter(Boolean) as Team[]) || [],
[cellFilterParam]
);
const showInactive = searchParams.get("inactive") === "true";
// Update URL params
const updateParams = useCallback(
(updates: Record<string, string | null>) => {
const params = new URLSearchParams(searchParams.toString());
Object.entries(updates).forEach(([key, value]) => {
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
});
const query = params.toString();
router.push(query ? `/projects?${query}` : "/projects");
},
[router, searchParams]
);
const handleSearchChange = useCallback(
(value: string) => {
updateParams({ q: value || null });
},
[updateParams]
);
const handleCellChange = useCallback(
(value: Team[]) => {
updateParams({ cell: value.length > 0 ? value.join(",") : null });
},
[updateParams]
);
const handleShowInactiveChange = useCallback(
(value: boolean) => {
updateParams({ inactive: value ? "true" : null });
},
[updateParams]
);
// Fetch projects
const { data: projects, isLoading, error, refetch } = useProjects({
active_only: !showInactive,
});
// Filter projects client-side for search and multi-select cell filter
const filteredProjects = useMemo(() => {
if (!projects) return [];
return projects.filter((project) => {
// Search filter
if (searchQuery && !project.name.toLowerCase().includes(searchQuery.toLowerCase())) {
return false;
}
// Cell filter (if any selected, project must match one of them)
if (cellFilter.length > 0 && !cellFilter.includes(project.assigned_cell)) {
return false;
}
return true;
});
}, [projects, searchQuery, cellFilter]);
// Check if it's a connection error (backend not running)
const isOffline =
error &&
(error.message?.includes("Network Error") ||
error.message?.includes("ECONNREFUSED") ||
(error as { code?: string })?.code === "ERR_NETWORK");
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Projects</h1>
<p className="text-muted-foreground">
Manage git repositories and track development work
</p>
</div>
<div className="flex items-center gap-2">
<CreateProjectDialog />
<Button variant="outline" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
</div>
{/* Filters - Sticky */}
<div className="sticky top-0 z-10 -mx-6 px-6 py-2 bg-muted/30 backdrop-blur-sm">
<ProjectFilters
searchQuery={searchQuery}
onSearchChange={handleSearchChange}
cellFilter={cellFilter}
onCellChange={handleCellChange}
showInactive={showInactive}
onShowInactiveChange={handleShowInactiveChange}
/>
</div>
{/* Content */}
{isOffline ? (
<OfflineState
title="Cannot Load Projects"
description="Start the RoboCo orchestrator to manage projects. Projects track git repositories for agent work."
onRetry={() => refetch()}
/>
) : (
<ProjectTable projects={filteredProjects} isLoading={isLoading} />
)}
</div>
);
}
// Wrap in Suspense for useSearchParams
export default function ProjectsPage() {
return (
<Suspense
fallback={
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Skeleton className="h-9 w-32 mb-2" />
<Skeleton className="h-5 w-64" />
</div>
</div>
<Skeleton className="h-12 w-full" />
<Skeleton className="h-96 w-full" />
</div>
}
>
<ProjectsPageContent />
</Suspense>
);
}
+244
View File
@@ -0,0 +1,244 @@
"use client";
import { useState } from "react";
import { useTheme } from "next-themes";
import { useUIStore } from "@/store";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Label } from "@/components/ui/label";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import { Switch } from "@/components/ui/switch";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Separator } from "@/components/ui/separator";
import {
Settings,
Palette,
Bell,
Server,
User,
Save,
} from "lucide-react";
import { toast } from "sonner";
import { API_URL, WS_URL } from "@/lib/constants";
export default function SettingsPage() {
const { theme, setTheme } = useTheme();
const { sidebarCollapsed, setSidebarCollapsed } = useUIStore();
// Local state for settings (would be persisted in a real app)
const [notificationsEnabled, setNotificationsEnabled] = useState(true);
const [soundEnabled, setSoundEnabled] = useState(true);
const [autoRefresh, setAutoRefresh] = useState(true);
const [refreshInterval, setRefreshInterval] = useState("30");
const handleSave = () => {
toast.success("Settings saved successfully");
};
return (
<div className="space-y-6 max-w-3xl">
{/* Header */}
<div>
<h1 className="text-3xl font-bold tracking-tight">Settings</h1>
<p className="text-muted-foreground">
Configure your RoboCo Control Panel preferences
</p>
</div>
{/* Appearance */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Palette className="h-5 w-5" />
Appearance
</CardTitle>
<CardDescription>Customize the look and feel of the panel</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label>Theme</Label>
<p className="text-sm text-muted-foreground">
Select your preferred color scheme
</p>
</div>
<Select value={theme} onValueChange={setTheme}>
<SelectTrigger className="w-auto min-w-28">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="light">Light</SelectItem>
<SelectItem value="dark">Dark</SelectItem>
<SelectItem value="system">System</SelectItem>
</SelectContent>
</Select>
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<Label>Collapsed Sidebar</Label>
<p className="text-sm text-muted-foreground">
Show icons only in the sidebar
</p>
</div>
<Switch
checked={sidebarCollapsed}
onCheckedChange={setSidebarCollapsed}
/>
</div>
</CardContent>
</Card>
{/* Notifications */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Bell className="h-5 w-5" />
Notifications
</CardTitle>
<CardDescription>Configure how you receive updates</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label>Enable Notifications</Label>
<p className="text-sm text-muted-foreground">
Receive real-time notifications from agents
</p>
</div>
<Switch
checked={notificationsEnabled}
onCheckedChange={setNotificationsEnabled}
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<Label>Sound Alerts</Label>
<p className="text-sm text-muted-foreground">
Play sound for important notifications
</p>
</div>
<Switch
checked={soundEnabled}
onCheckedChange={setSoundEnabled}
disabled={!notificationsEnabled}
/>
</div>
</CardContent>
</Card>
{/* Data & Refresh */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Server className="h-5 w-5" />
Data & Refresh
</CardTitle>
<CardDescription>Configure data fetching behavior</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<div>
<Label>Auto Refresh</Label>
<p className="text-sm text-muted-foreground">
Automatically refresh data periodically
</p>
</div>
<Switch
checked={autoRefresh}
onCheckedChange={setAutoRefresh}
/>
</div>
<Separator />
<div className="flex items-center justify-between">
<div>
<Label>Refresh Interval</Label>
<p className="text-sm text-muted-foreground">
How often to fetch new data (seconds)
</p>
</div>
<Select
value={refreshInterval}
onValueChange={setRefreshInterval}
disabled={!autoRefresh}
>
<SelectTrigger className="w-auto min-w-20">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="10">10s</SelectItem>
<SelectItem value="30">30s</SelectItem>
<SelectItem value="60">1m</SelectItem>
<SelectItem value="300">5m</SelectItem>
</SelectContent>
</Select>
</div>
</CardContent>
</Card>
{/* Connection Info */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Settings className="h-5 w-5" />
Connection Info
</CardTitle>
<CardDescription>Backend API configuration (read-only)</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label>API URL</Label>
<Input value={API_URL} readOnly className="bg-muted" />
</div>
<div className="space-y-2">
<Label>WebSocket URL</Label>
<Input value={WS_URL} readOnly className="bg-muted" />
</div>
<p className="text-xs text-muted-foreground">
These values are configured via environment variables (NEXT_PUBLIC_API_URL, NEXT_PUBLIC_WS_URL)
</p>
</CardContent>
</Card>
{/* User Info */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<User className="h-5 w-5" />
User Info
</CardTitle>
<CardDescription>Your account information</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-4">
<div className="h-16 w-16 rounded-full bg-primary flex items-center justify-center">
<span className="text-primary-foreground font-bold text-2xl">CEO</span>
</div>
<div>
<p className="font-semibold text-lg">Renzo</p>
<p className="text-sm text-muted-foreground">Chief Executive Officer</p>
<p className="text-xs text-muted-foreground mt-1">
Agent ID: 00000000-0000-0000-0000-000000000001
</p>
</div>
</div>
</CardContent>
</Card>
{/* Save Button */}
<div className="flex justify-end">
<Button onClick={handleSave}>
<Save className="h-4 w-4 mr-2" />
Save Settings
</Button>
</div>
</div>
);
}
@@ -0,0 +1,307 @@
"use client";
import { use, useState } from "react";
import { useTask, useTaskLifecycle } from "@/hooks/use-tasks";
import { useProject } from "@/hooks/use-projects";
import { useCreateBranch, useCreatePR } from "@/hooks/use-git";
import { TaskHeader, TaskMetadata, TaskTabs } from "@/components/tasks/task-detail";
import {
EscalateToCeoDialog,
CeoRejectDialog,
CreateBranchDialog,
CreatePRDialog,
} from "@/components/tasks/task-detail/task-action-dialogs";
import { Skeleton } from "@/components/ui/skeleton";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { AlertTriangle, ArrowLeft, RefreshCw } from "lucide-react";
import Link from "next/link";
import { toast } from "sonner";
interface TaskDetailPageProps {
params: Promise<{ taskId: string }>;
}
export default function TaskDetailPage({ params }: TaskDetailPageProps) {
const { taskId } = use(params);
const { data: task, isLoading, error, refetch } = useTask(taskId);
const { data: project } = useProject(task?.project_id ?? "");
const lifecycle = useTaskLifecycle();
const createBranch = useCreateBranch();
const createPR = useCreatePR();
// Dialog states
const [escalateDialogOpen, setEscalateDialogOpen] = useState(false);
const [rejectDialogOpen, setRejectDialogOpen] = useState(false);
const [branchDialogOpen, setBranchDialogOpen] = useState(false);
const [prDialogOpen, setPrDialogOpen] = useState(false);
const handleAction = async (action: string) => {
if (!task) return;
try {
switch (action) {
case "claim":
await lifecycle.claim.mutateAsync(task.id);
toast.success("Task claimed successfully");
break;
case "start":
await lifecycle.start.mutateAsync(task.id);
toast.success("Task started");
break;
case "pause":
await lifecycle.pause.mutateAsync(task.id);
toast.success("Task paused");
break;
case "resume":
await lifecycle.resume.mutateAsync(task.id);
toast.success("Task resumed");
break;
case "block":
await lifecycle.block.mutateAsync({ taskId: task.id });
toast.success("Task marked as blocked");
break;
case "unblock":
await lifecycle.unblock.mutateAsync(task.id);
toast.success("Task unblocked");
break;
case "verify":
await lifecycle.verify.mutateAsync(task.id);
toast.success("Task self-verified");
break;
case "submit-qa":
await lifecycle.submitQa.mutateAsync({ taskId: task.id });
toast.success("Task submitted for QA");
break;
case "pass-qa":
await lifecycle.passQa.mutateAsync({ taskId: task.id });
toast.success("Task passed QA");
break;
case "fail-qa":
await lifecycle.failQa.mutateAsync({ taskId: task.id });
toast.success("Task failed QA");
break;
case "complete":
await lifecycle.complete.mutateAsync(task.id);
toast.success("Task completed");
break;
case "cancel":
await lifecycle.cancel.mutateAsync(task.id);
toast.success("Task cancelled");
break;
case "reopen":
await lifecycle.reopen.mutateAsync(task.id);
toast.success("Task reopened");
break;
// Git workflow actions
case "docs-complete":
await lifecycle.docsComplete.mutateAsync(task.id);
toast.success("Documentation marked complete");
break;
case "submit-pm-review":
await lifecycle.submitPmReview.mutateAsync(task.id);
toast.success("Submitted for PM review");
break;
case "ceo-approve":
await lifecycle.ceoApprove.mutateAsync({ taskId: task.id });
toast.success("Task approved and completed");
break;
case "ceo-reject":
setRejectDialogOpen(true);
return; // Don't refetch yet, dialog will handle it
case "escalate-to-ceo":
setEscalateDialogOpen(true);
return; // Don't refetch yet, dialog will handle it
case "request-changes":
await lifecycle.failQa.mutateAsync({ taskId: task.id, qaNotes: "Changes requested by PM" });
toast.success("Changes requested");
break;
case "create-branch":
if (!project) {
toast.error("Project not found - cannot create branch");
return;
}
setBranchDialogOpen(true);
return; // Don't refetch yet, dialog will handle it
case "create-pr":
if (!project) {
toast.error("Project not found - cannot create PR");
return;
}
setPrDialogOpen(true);
return; // Don't refetch yet, dialog will handle it
default:
console.warn("Unknown action:", action);
}
refetch();
} catch (err) {
toast.error("Failed to " + action.replace("-", " ") + " task");
console.error(err);
}
};
// Dialog handlers
const handleEscalateToCeo = async (reason: string) => {
if (!task) return;
try {
await lifecycle.escalateToCeo.mutateAsync({ taskId: task.id, reason });
toast.success("Escalated to CEO");
setEscalateDialogOpen(false);
refetch();
} catch (err) {
toast.error("Failed to escalate to CEO");
console.error(err);
}
};
const handleCeoReject = async (notes: string) => {
if (!task) return;
try {
await lifecycle.ceoReject.mutateAsync({ taskId: task.id, notes });
toast.success("Changes requested");
setRejectDialogOpen(false);
refetch();
} catch (err) {
toast.error("Failed to request changes");
console.error(err);
}
};
const handleCreateBranch = async (branchType: string) => {
if (!task || !project) return;
try {
await createBranch.mutateAsync({
project_slug: project.slug,
task_id: task.id,
branch_type: branchType as "feature" | "bug" | "chore" | "docs" | "hotfix",
agent_id: "ceo", // CEO is creating the branch from the panel
});
toast.success("Branch created successfully");
setBranchDialogOpen(false);
refetch();
} catch (err) {
toast.error("Failed to create branch");
console.error(err);
}
};
const handleCreatePR = async (title: string, body: string) => {
if (!task || !project) return;
try {
const result = await createPR.mutateAsync({
project_slug: project.slug,
task_id: task.id,
title,
body,
agent_id: "ceo", // CEO is creating the PR from the panel
});
toast.success(`PR #${result.pr_number} created successfully`);
setPrDialogOpen(false);
refetch();
} catch (err) {
toast.error("Failed to create PR");
console.error(err);
}
};
// Loading state
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex items-center gap-4">
<Skeleton className="h-10 w-10" />
<div className="space-y-2">
<Skeleton className="h-8 w-64" />
<Skeleton className="h-4 w-32" />
</div>
</div>
<div className="grid grid-cols-4 gap-4">
{[...Array(8)].map((_, i) => (
<Skeleton key={i} className="h-24" />
))}
</div>
<Skeleton className="h-48" />
<Skeleton className="h-96" />
</div>
);
}
// Error state
if (error || !task) {
return (
<div className="space-y-6">
<Link href="/tasks">
<Button variant="ghost" size="sm">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Tasks
</Button>
</Link>
<Card>
<CardContent className="pt-6">
<div className="text-center py-12">
<AlertTriangle className="h-16 w-16 mx-auto mb-4 text-destructive" />
<h2 className="text-xl font-semibold mb-2">Task Not Found</h2>
<p className="text-muted-foreground mb-6">
{error?.message ?? "The task you're looking for doesn't exist or has been deleted."}
</p>
<div className="flex justify-center gap-4">
<Button variant="outline" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-2" />
Retry
</Button>
<Link href="/tasks">
<Button>View All Tasks</Button>
</Link>
</div>
</div>
</CardContent>
</Card>
</div>
);
}
return (
<div className="space-y-6">
{/* Header */}
<TaskHeader task={task} onAction={handleAction} />
{/* Metadata Cards */}
<TaskMetadata task={task} />
{/* Tabbed Content */}
<TaskTabs task={task} />
{/* Action Dialogs */}
<EscalateToCeoDialog
open={escalateDialogOpen}
onOpenChange={setEscalateDialogOpen}
onConfirm={handleEscalateToCeo}
isPending={lifecycle.escalateToCeo.isPending}
/>
<CeoRejectDialog
open={rejectDialogOpen}
onOpenChange={setRejectDialogOpen}
onConfirm={handleCeoReject}
isPending={lifecycle.ceoReject.isPending}
/>
<CreateBranchDialog
open={branchDialogOpen}
onOpenChange={setBranchDialogOpen}
onConfirm={handleCreateBranch}
isPending={createBranch.isPending}
taskId={task.id}
/>
<CreatePRDialog
open={prDialogOpen}
onOpenChange={setPrDialogOpen}
onConfirm={handleCreatePR}
isPending={createPR.isPending}
defaultTitle={task.title}
/>
</div>
);
}
+218
View File
@@ -0,0 +1,218 @@
"use client";
import { Suspense, useMemo, useCallback } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useTasks } from "@/hooks/use-tasks";
import { TaskStatus, Team, TaskType } from "@/types";
import { OfflineState } from "@/components/ui/offline-state";
import { CreateTaskDialog, TaskFilters, TaskTable, SortField, SortDirection } from "@/components/tasks";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { RefreshCw } from "lucide-react";
function TasksPageContent() {
const router = useRouter();
const searchParams = useSearchParams();
// Read state from URL params
const searchQuery = searchParams.get("q") || "";
const statusParam = searchParams.get("status");
const statusFilter = useMemo(
() => (statusParam?.split(",").filter(Boolean) as TaskStatus[]) || [],
[statusParam]
);
const teamParam = searchParams.get("team");
const teamFilter = useMemo(
() => (teamParam?.split(",").filter(Boolean) as Team[]) || [],
[teamParam]
);
const taskTypeParam = searchParams.get("type");
const taskTypeFilter = useMemo(
() => (taskTypeParam?.split(",").filter(Boolean) as TaskType[]) || [],
[taskTypeParam]
);
// Table state from URL
const sortField = (searchParams.get("sortBy") as SortField) || "created_at";
const sortDir = (searchParams.get("sortDir") as SortDirection) || "desc";
const currentPage = parseInt(searchParams.get("page") || "1", 10);
const pageSize = parseInt(searchParams.get("size") || "25", 10);
const expandedParam = searchParams.get("expanded");
const expandedIds = useMemo(
() => new Set(expandedParam?.split(",").filter(Boolean) || []),
[expandedParam]
);
// Update URL params
const updateParams = useCallback((updates: Record<string, string | null>) => {
const params = new URLSearchParams(searchParams.toString());
Object.entries(updates).forEach(([key, value]) => {
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
});
const query = params.toString();
router.push(query ? `/tasks?${query}` : "/tasks");
}, [router, searchParams]);
const handleSearchChange = useCallback((value: string) => {
updateParams({ q: value || null });
}, [updateParams]);
const handleStatusChange = useCallback((value: TaskStatus[]) => {
updateParams({ status: value.length > 0 ? value.join(",") : null });
}, [updateParams]);
const handleTeamChange = useCallback((value: Team[]) => {
updateParams({ team: value.length > 0 ? value.join(",") : null });
}, [updateParams]);
const handleTaskTypeChange = useCallback((value: TaskType[]) => {
updateParams({ type: value.length > 0 ? value.join(",") : null });
}, [updateParams]);
// Table state handlers
const handleSortChange = useCallback((field: SortField, direction: SortDirection | null) => {
if (direction === null) {
updateParams({ sortBy: null, sortDir: null, page: null });
} else {
updateParams({
sortBy: field === "created_at" ? null : field,
sortDir: direction === "desc" ? null : direction,
page: null,
});
}
}, [updateParams]);
const handlePageChange = useCallback((page: number) => {
updateParams({ page: page === 1 ? null : String(page) });
}, [updateParams]);
const handlePageSizeChange = useCallback((size: number) => {
updateParams({ size: size === 25 ? null : String(size), page: null });
}, [updateParams]);
const handleExpandedChange = useCallback((ids: Set<string>) => {
updateParams({ expanded: ids.size > 0 ? Array.from(ids).join(",") : null });
}, [updateParams]);
// Fetch all tasks and filter client-side for multi-select
const { data: tasks, isLoading, error, refetch } = useTasks();
// Filter tasks based on multi-select filters
const filteredTasks = useMemo(() => {
if (!tasks) return [];
return tasks.filter((task) => {
// Search filter
if (searchQuery && !task.title.toLowerCase().includes(searchQuery.toLowerCase())) {
return false;
}
// Status filter (if any selected, task must match one of them)
if (statusFilter.length > 0 && !statusFilter.includes(task.status)) {
return false;
}
// Team filter (if any selected, task must match one of them)
if (teamFilter.length > 0 && !teamFilter.includes(task.team)) {
return false;
}
// Task type filter (if any selected, task must match one of them)
// Note: task_type may be undefined until backend adds it to TaskResponse
if (taskTypeFilter.length > 0 && task.task_type && !taskTypeFilter.includes(task.task_type)) {
return false;
}
return true;
});
}, [tasks, searchQuery, statusFilter, teamFilter, taskTypeFilter]);
// Check if it's a connection error (backend not running)
const isOffline = error && (
error.message?.includes("Network Error") ||
error.message?.includes("ECONNREFUSED") ||
(error as { code?: string })?.code === "ERR_NETWORK"
);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Tasks</h1>
<p className="text-muted-foreground">
Manage and track all tasks across teams
</p>
</div>
<div className="flex items-center gap-2">
<CreateTaskDialog />
<Button variant="outline" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
</div>
{/* Filters - Sticky */}
<div className="sticky top-0 z-10 -mx-6 px-6 py-2 bg-muted/30 backdrop-blur-sm">
<TaskFilters
searchQuery={searchQuery}
onSearchChange={handleSearchChange}
statusFilter={statusFilter}
onStatusChange={handleStatusChange}
teamFilter={teamFilter}
onTeamChange={handleTeamChange}
taskTypeFilter={taskTypeFilter}
onTaskTypeChange={handleTaskTypeChange}
/>
</div>
{/* Content */}
{isOffline ? (
<OfflineState
title="Cannot Load Tasks"
description="Start the RoboCo orchestrator to manage tasks. Tasks you create will be picked up by agents when the backend is running."
onRetry={() => refetch()}
/>
) : (
<TaskTable
tasks={filteredTasks}
isLoading={isLoading}
sortField={sortField}
sortDirection={sortDir}
onSortChange={handleSortChange}
currentPage={currentPage}
pageSize={pageSize}
onPageChange={handlePageChange}
onPageSizeChange={handlePageSizeChange}
expandedIds={expandedIds}
onExpandedChange={handleExpandedChange}
/>
)}
</div>
);
}
// Wrap in Suspense for useSearchParams
export default function TasksPage() {
return (
<Suspense fallback={
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Skeleton className="h-9 w-32 mb-2" />
<Skeleton className="h-5 w-64" />
</div>
</div>
<Skeleton className="h-12 w-full" />
<Skeleton className="h-96 w-full" />
</div>
}>
<TasksPageContent />
</Suspense>
);
}
@@ -0,0 +1,150 @@
"use client";
import { Suspense, useMemo, useCallback } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { useWorkSessions } from "@/hooks/use-work-sessions";
import { WorkSessionStatus } from "@/types";
import { OfflineState } from "@/components/ui/offline-state";
import { WorkSessionTable, WorkSessionFilters } from "@/components/work-sessions";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { RefreshCw } from "lucide-react";
function WorkSessionsPageContent() {
const router = useRouter();
const searchParams = useSearchParams();
// Read state from URL params
const searchQuery = searchParams.get("q") || "";
const statusParam = searchParams.get("status");
const statusFilter = useMemo(
() => (statusParam?.split(",").filter(Boolean) as WorkSessionStatus[]) || [],
[statusParam]
);
// Update URL params
const updateParams = useCallback(
(updates: Record<string, string | null>) => {
const params = new URLSearchParams(searchParams.toString());
Object.entries(updates).forEach(([key, value]) => {
if (value) {
params.set(key, value);
} else {
params.delete(key);
}
});
const query = params.toString();
router.push(query ? `/work-sessions?${query}` : "/work-sessions");
},
[router, searchParams]
);
const handleSearchChange = useCallback(
(value: string) => {
updateParams({ q: value || null });
},
[updateParams]
);
const handleStatusChange = useCallback(
(value: WorkSessionStatus[]) => {
updateParams({ status: value.length > 0 ? value.join(",") : null });
},
[updateParams]
);
// Fetch work sessions
const { data: sessions, isLoading, error, refetch } = useWorkSessions();
// Filter sessions client-side for search and multi-select status filter
const filteredSessions = useMemo(() => {
if (!sessions) return [];
return sessions.filter((session) => {
// Search filter - match branch name
if (
searchQuery &&
!session.branch_name.toLowerCase().includes(searchQuery.toLowerCase())
) {
return false;
}
// Status filter (if any selected, session must match one of them)
if (statusFilter.length > 0 && !statusFilter.includes(session.status)) {
return false;
}
return true;
});
}, [sessions, searchQuery, statusFilter]);
// Check if it's a connection error (backend not running)
const isOffline =
error &&
(error.message?.includes("Network Error") ||
error.message?.includes("ECONNREFUSED") ||
(error as { code?: string })?.code === "ERR_NETWORK");
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Work Sessions</h1>
<p className="text-muted-foreground">
Track git branches and pull requests for active work
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
</div>
{/* Filters - Sticky */}
<div className="sticky top-0 z-10 -mx-6 px-6 py-2 bg-muted/30 backdrop-blur-sm">
<WorkSessionFilters
searchQuery={searchQuery}
onSearchChange={handleSearchChange}
statusFilter={statusFilter}
onStatusChange={handleStatusChange}
/>
</div>
{/* Content */}
{isOffline ? (
<OfflineState
title="Cannot Load Work Sessions"
description="Start the RoboCo orchestrator to view work sessions. Work sessions track agent activity on git branches."
onRetry={() => refetch()}
/>
) : (
<WorkSessionTable sessions={filteredSessions} isLoading={isLoading} />
)}
</div>
);
}
// Wrap in Suspense for useSearchParams
export default function WorkSessionsPage() {
return (
<Suspense
fallback={
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<Skeleton className="h-9 w-48 mb-2" />
<Skeleton className="h-5 w-72" />
</div>
</div>
<Skeleton className="h-12 w-full" />
<Skeleton className="h-96 w-full" />
</div>
}
>
<WorkSessionsPageContent />
</Suspense>
);
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 436 KiB

+126
View File
@@ -0,0 +1,126 @@
@import "tailwindcss";
@import "tw-animate-css";
@plugin "@tailwindcss/typography";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 214 KiB

+25
View File
@@ -0,0 +1,25 @@
import type { Metadata } from "next";
import { Inter } from "next/font/google";
import "./globals.css";
import { Providers } from "@/components/providers";
const inter = Inter({ subsets: ["latin"] });
export const metadata: Metadata = {
title: "RoboCo Control Panel",
description: "AI Agents Company - Control Panel",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en" suppressHydrationWarning>
<body className={inter.className}>
<Providers>{children}</Providers>
</body>
</html>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function Home() {
redirect("/overview");
}
+106
View File
@@ -0,0 +1,106 @@
"use client";
import Link from "next/link";
import { useStopAgent } from "@/hooks/use-agents";
import { AgentStatusResponse } from "@/types";
import { AgentDefinition } from "@/lib/agent-definitions";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { MoreHorizontal, Activity, Square } from "lucide-react";
import { toast } from "sonner";
import { AgentStateBadge } from "./agent-state-badge";
import { SpawnAgentDialog } from "./spawn-agent-dialog";
interface AgentCardProps {
agent: AgentDefinition;
agentStatus: AgentStatusResponse | null;
}
export function AgentCard({ agent, agentStatus }: AgentCardProps) {
const stopAgent = useStopAgent();
const state = agentStatus?.state || "stopped";
const isActive = ["running", "ready", "starting", "waiting_long"].includes(state);
const handleStop = async (graceful: boolean) => {
try {
await stopAgent.mutateAsync({ agentId: agent.id, graceful });
const message = graceful ? "stopping gracefully" : "force stopped";
toast.success("Agent " + agent.name + " " + message);
} catch {
toast.error("Failed to stop agent");
}
};
return (
<Card className={isActive ? "border-green-500/50" : ""}>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-base">{agent.name || "Unknown Agent"}</CardTitle>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{!isActive && (
<SpawnAgentDialog agentId={agent.id} agentName={agent.name} />
)}
{isActive && (
<>
<DropdownMenuItem asChild>
<Link href={"/agents/" + agent.id}>
<Activity className="h-4 w-4 mr-2" />
View Details
</Link>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={() => handleStop(true)}>
<Square className="h-4 w-4 mr-2" />
Stop Gracefully
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => handleStop(false)}
className="text-red-600"
>
<Square className="h-4 w-4 mr-2" />
Force Stop
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>
<CardDescription className="text-xs">
{agent.role?.replace(/_/g, " ") || "N/A"}
{agent.team && " • " + agent.team.replace(/_/g, " ")}
</CardDescription>
</CardHeader>
<CardContent>
<AgentStateBadge state={state} />
{agentStatus?.task_id && (
<p className="text-xs text-muted-foreground mt-2">
Task: {agentStatus.task_id.slice(0, 8)}...
</p>
)}
{agentStatus?.waiting_for && (
<p className="text-xs text-yellow-600 mt-2 truncate">
Waiting: {agentStatus.waiting_for}
</p>
)}
{agentStatus && agentStatus.error_count > 0 && (
<p className="text-xs text-red-500 mt-2">
Errors: {agentStatus.error_count}
</p>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,53 @@
import { AgentStatusResponse } from "@/types";
import { AgentDefinition } from "@/lib/agent-definitions";
import { Card, CardHeader } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { AgentCard } from "./agent-card";
interface AgentGridProps {
title: string;
agents: AgentDefinition[];
agentStatuses: Record<string, AgentStatusResponse>;
isLoading: boolean;
columns?: number;
}
export function AgentGrid({
title,
agents,
agentStatuses,
isLoading,
columns = 4
}: AgentGridProps) {
const gridCols = {
3: "md:grid-cols-3",
4: "md:grid-cols-3 lg:grid-cols-4",
5: "md:grid-cols-3 lg:grid-cols-5",
}[columns] || "md:grid-cols-3 lg:grid-cols-4";
return (
<div>
<h2 className="text-xl font-semibold mb-4">{title}</h2>
<div className={"grid gap-4 " + gridCols}>
{isLoading ? (
Array.from({ length: agents.length || 3 }).map((_, i) => (
<Card key={i}>
<CardHeader>
<Skeleton className="h-5 w-32" />
<Skeleton className="h-3 w-24" />
</CardHeader>
</Card>
))
) : (
agents.map((agent) => (
<AgentCard
key={agent.id}
agent={agent}
agentStatus={agentStatuses[agent.id] || null}
/>
))
)}
</div>
</div>
);
}
@@ -0,0 +1,284 @@
"use client";
import { useMemo } from "react";
import { useAgentDefinitions } from "@/hooks/use-agents";
import { Team, AgentRole } from "@/types";
import {
Select,
SelectContent,
SelectGroup,
SelectItem,
SelectLabel,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Badge } from "@/components/ui/badge";
import { User, Users } from "lucide-react";
import { resolveToSlug } from "@/lib/agent-utils";
interface AgentSelectorProps {
value: string | null;
onChange: (value: string | null) => void;
placeholder?: string;
filterByTeam?: Team;
filterByRoles?: AgentRole[];
disabled?: boolean;
allowClear?: boolean;
}
// Role display names
const ROLE_LABELS: Record<AgentRole, string> = {
[AgentRole.SYSTEM]: "System",
[AgentRole.CEO]: "CEO",
[AgentRole.PRODUCT_OWNER]: "Product Owner",
[AgentRole.HEAD_MARKETING]: "Head Marketing",
[AgentRole.AUDITOR]: "Auditor",
[AgentRole.MAIN_PM]: "Main PM",
[AgentRole.CELL_PM]: "Cell PM",
[AgentRole.DEVELOPER]: "Developer",
[AgentRole.QA]: "QA",
[AgentRole.DOCUMENTER]: "Documenter",
};
export function AgentSelector({
value,
onChange,
placeholder = "Select agent...",
filterByTeam,
filterByRoles,
disabled = false,
allowClear = true,
}: AgentSelectorProps) {
const { data: agents = [], isLoading } = useAgentDefinitions();
// Group agents by team
const groupedAgents = useMemo(() => {
let filtered = agents;
// Apply team filter - also match by role for Board and Main PM
if (filterByTeam) {
filtered = filtered.filter((a) => {
// Direct team match
if (a.team === filterByTeam) return true;
// For Board team, also include board-level roles
if (filterByTeam === Team.BOARD && (
a.role === AgentRole.PRODUCT_OWNER ||
a.role === AgentRole.HEAD_MARKETING ||
a.role === AgentRole.AUDITOR
)) return true;
// For Main PM team, also include Main PM role
if (filterByTeam === Team.MAIN_PM && a.role === AgentRole.MAIN_PM) return true;
return false;
});
}
// Apply role filter
if (filterByRoles && filterByRoles.length > 0) {
filtered = filtered.filter((a) => a.role && filterByRoles.includes(a.role));
}
// Group by team following org hierarchy
const groups: Record<string, typeof filtered> = {
board: [],
main_pm: [],
backend: [],
frontend: [],
ux_ui: [],
marketing: [],
};
for (const agent of filtered) {
if (agent.team === Team.BOARD ||
agent.role === AgentRole.PRODUCT_OWNER ||
agent.role === AgentRole.HEAD_MARKETING ||
agent.role === AgentRole.AUDITOR) {
groups.board.push(agent);
} else if (agent.team === Team.MAIN_PM || agent.role === AgentRole.MAIN_PM) {
groups.main_pm.push(agent);
} else if (agent.team === Team.BACKEND) {
groups.backend.push(agent);
} else if (agent.team === Team.FRONTEND) {
groups.frontend.push(agent);
} else if (agent.team === Team.UX_UI) {
groups.ux_ui.push(agent);
} else if (agent.team === Team.MARKETING) {
groups.marketing.push(agent);
}
// Note: Agents without team are not grouped - they remain ungrouped
// The orchestrator handles automatic routing for unassigned tasks
}
return groups;
}, [agents, filterByTeam, filterByRoles]);
// Find selected agent for display (resolve UUID to slug if needed)
const selectedAgent = useMemo(() => {
if (!value) return null;
const resolvedValue = resolveToSlug(value);
return agents.find((a) => a.id === resolvedValue || a.id === value);
}, [agents, value]);
const handleValueChange = (newValue: string) => {
if (newValue === "__clear__") {
onChange(null);
} else {
onChange(newValue);
}
};
// Resolve value to slug for proper Select matching
const selectValue = value ? resolveToSlug(value) || value : "";
return (
<Select
value={selectValue}
onValueChange={handleValueChange}
disabled={disabled || isLoading}
>
<SelectTrigger className="w-full">
<SelectValue placeholder={placeholder}>
{selectedAgent ? (
<div className="flex items-center gap-2">
<User className="h-4 w-4" />
<span>{selectedAgent.name}</span>
{selectedAgent.role && (
<Badge variant="outline" className="text-xs">
{ROLE_LABELS[selectedAgent.role] || selectedAgent.role}
</Badge>
)}
</div>
) : (
placeholder
)}
</SelectValue>
</SelectTrigger>
<SelectContent>
{allowClear && value && (
<SelectItem value="__clear__" className="text-muted-foreground">
<span className="flex items-center gap-2">
<Users className="h-4 w-4" />
Unassigned
</span>
</SelectItem>
)}
{/* Board */}
{groupedAgents.board.length > 0 && (
<SelectGroup>
<SelectLabel>Board</SelectLabel>
{groupedAgents.board.map((agent) => (
<SelectItem key={agent.id} value={agent.id}>
<div className="flex items-center gap-2">
<span>{agent.name}</span>
{agent.role && (
<Badge variant="secondary" className="text-xs">
{ROLE_LABELS[agent.role] || agent.role}
</Badge>
)}
</div>
</SelectItem>
))}
</SelectGroup>
)}
{/* Main PM */}
{groupedAgents.main_pm.length > 0 && (
<SelectGroup>
<SelectLabel>Main PM</SelectLabel>
{groupedAgents.main_pm.map((agent) => (
<SelectItem key={agent.id} value={agent.id}>
<div className="flex items-center gap-2">
<span>{agent.name}</span>
{agent.role && (
<Badge variant="secondary" className="text-xs">
{ROLE_LABELS[agent.role] || agent.role}
</Badge>
)}
</div>
</SelectItem>
))}
</SelectGroup>
)}
{/* Backend */}
{groupedAgents.backend.length > 0 && (
<SelectGroup>
<SelectLabel>Backend Team</SelectLabel>
{groupedAgents.backend.map((agent) => (
<SelectItem key={agent.id} value={agent.id}>
<div className="flex items-center gap-2">
<span>{agent.name}</span>
{agent.role && (
<Badge variant="secondary" className="text-xs">
{ROLE_LABELS[agent.role] || agent.role}
</Badge>
)}
</div>
</SelectItem>
))}
</SelectGroup>
)}
{/* Frontend */}
{groupedAgents.frontend.length > 0 && (
<SelectGroup>
<SelectLabel>Frontend Team</SelectLabel>
{groupedAgents.frontend.map((agent) => (
<SelectItem key={agent.id} value={agent.id}>
<div className="flex items-center gap-2">
<span>{agent.name}</span>
{agent.role && (
<Badge variant="secondary" className="text-xs">
{ROLE_LABELS[agent.role] || agent.role}
</Badge>
)}
</div>
</SelectItem>
))}
</SelectGroup>
)}
{/* UX/UI */}
{groupedAgents.ux_ui.length > 0 && (
<SelectGroup>
<SelectLabel>UX/UI Team</SelectLabel>
{groupedAgents.ux_ui.map((agent) => (
<SelectItem key={agent.id} value={agent.id}>
<div className="flex items-center gap-2">
<span>{agent.name}</span>
{agent.role && (
<Badge variant="secondary" className="text-xs">
{ROLE_LABELS[agent.role] || agent.role}
</Badge>
)}
</div>
</SelectItem>
))}
</SelectGroup>
)}
{/* Marketing */}
{groupedAgents.marketing.length > 0 && (
<SelectGroup>
<SelectLabel>Marketing Team</SelectLabel>
{groupedAgents.marketing.map((agent) => (
<SelectItem key={agent.id} value={agent.id}>
<div className="flex items-center gap-2">
<span>{agent.name}</span>
{agent.role && (
<Badge variant="secondary" className="text-xs">
{ROLE_LABELS[agent.role] || agent.role}
</Badge>
)}
</div>
</SelectItem>
))}
</SelectGroup>
)}
</SelectContent>
</Select>
);
}
@@ -0,0 +1,61 @@
import { Badge } from "@/components/ui/badge";
import { Clock, RefreshCw, Activity, AlertTriangle, Square } from "lucide-react";
// Agent states as returned by backend orchestrator
type AgentStateString =
| "idle"
| "starting"
| "ready"
| "running"
| "waiting_long"
| "error"
| "stopped"
| "terminated";
const stateColors: Record<string, string> = {
idle: "bg-gray-500",
starting: "bg-yellow-500",
ready: "bg-blue-500",
running: "bg-green-500",
waiting_long: "bg-orange-500",
error: "bg-red-500",
stopped: "bg-gray-400",
terminated: "bg-gray-600",
};
const stateIcons: Record<string, React.ReactNode> = {
idle: <Clock className="h-4 w-4" />,
starting: <RefreshCw className="h-4 w-4 animate-spin" />,
ready: <Activity className="h-4 w-4" />,
running: <Activity className="h-4 w-4" />,
waiting_long: <AlertTriangle className="h-4 w-4" />,
error: <AlertTriangle className="h-4 w-4" />,
stopped: <Square className="h-4 w-4" />,
terminated: <Square className="h-4 w-4" />,
};
interface AgentStateBadgeProps {
state: AgentStateString | string;
showIcon?: boolean;
size?: "sm" | "md" | "lg";
}
export function AgentStateBadge({ state, showIcon = true, size = "md" }: AgentStateBadgeProps) {
const sizeClasses = {
sm: "text-xs px-2 py-0.5",
md: "text-sm px-2.5 py-0.5",
lg: "text-lg px-3 py-1",
};
const color = stateColors[state] || "bg-gray-400";
const icon = stateIcons[state] || <Square className="h-4 w-4" />;
return (
<Badge className={`${color} text-white ${sizeClasses[size]}`}>
{showIcon && <span className="mr-1">{icon}</span>}
{state.replace(/_/g, " ")}
</Badge>
);
}
export { stateColors, stateIcons };
@@ -0,0 +1,73 @@
import Link from "next/link";
import { formatDistanceToNow } from "date-fns";
import { AgentStatusResponse } from "@/types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Activity, FileText, Clock, AlertCircle } from "lucide-react";
import { AgentStateBadge } from "./agent-state-badge";
interface AgentStatusCardsProps {
agent: AgentStatusResponse;
}
export function AgentStatusCards({ agent }: AgentStatusCardsProps) {
return (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">State</CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<AgentStateBadge state={agent.state} size="lg" />
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Current Task</CardTitle>
<FileText className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{agent.task_id ? (
<Link href={"/tasks/" + agent.task_id} className="text-blue-500 hover:underline">
{agent.task_id.slice(0, 8)}...
</Link>
) : (
<span className="text-muted-foreground">No task assigned</span>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Started At</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{agent.started_at ? (
<span>{formatDistanceToNow(new Date(agent.started_at))} ago</span>
) : (
<span className="text-muted-foreground">Not started</span>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Error Count</CardTitle>
<AlertCircle className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<span className={agent.error_count > 0 ? "text-red-600 font-semibold" : ""}>
{agent.error_count}
</span>
{agent.waiting_for && (
<p className="text-xs text-yellow-600 mt-1 truncate">
Waiting: {agent.waiting_for}
</p>
)}
</CardContent>
</Card>
</div>
);
}
+9
View File
@@ -0,0 +1,9 @@
export { AgentStateBadge, stateColors, stateIcons } from "./agent-state-badge";
export { AgentCard } from "./agent-card";
export { AgentGrid } from "./agent-grid";
export { AgentStatusCards } from "./agent-status-cards";
export { SpawnAgentDialog } from "./spawn-agent-dialog";
export { ResolveWaitDialog } from "./resolve-wait-dialog";
export { AgentStreamViewer } from "./stream-viewer";
export { OrchestratorStatusCards } from "./orchestrator-status";
export { WaitingAgentsAlert } from "./waiting-agents-alert";
@@ -0,0 +1,77 @@
import { OrchestratorStatus as OrchestratorStatusType } from "@/types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Server, Users, Clock, Activity } from "lucide-react";
interface OrchestratorStatusCardsProps {
status: OrchestratorStatusType | undefined;
isLoading: boolean;
}
export function OrchestratorStatusCards({ status, isLoading }: OrchestratorStatusCardsProps) {
// Calculate running agents from by_state
const runningCount = status?.by_state?.running || 0;
const readyCount = status?.by_state?.ready || 0;
const activeCount = runningCount + readyCount;
const isRunning = status && status.total_agents > 0;
return (
<div className="grid gap-4 md:grid-cols-4">
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Orchestrator</CardTitle>
<Server className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-8 w-24" />
) : (
<Badge className={isRunning ? "bg-green-500" : "bg-red-500"}>
{isRunning ? "Running" : "Stopped"}
</Badge>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Total Agents</CardTitle>
<Users className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-8 w-12" />
) : (
<div className="text-2xl font-bold">{status?.total_agents || 0}</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Active</CardTitle>
<Activity className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-8 w-12" />
) : (
<div className="text-2xl font-bold">{activeCount}</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">Waiting</CardTitle>
<Clock className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
{isLoading ? (
<Skeleton className="h-8 w-12" />
) : (
<div className="text-2xl font-bold">{status?.waiting_count || 0}</div>
)}
</CardContent>
</Card>
</div>
);
}
@@ -0,0 +1,82 @@
"use client";
import { useState } from "react";
import { useResolveWait } from "@/hooks/use-agents";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogDescription,
} from "@/components/ui/dialog";
import { Send } from "lucide-react";
import { toast } from "sonner";
interface ResolveWaitDialogProps {
agentId: string;
}
export function ResolveWaitDialog({ agentId }: ResolveWaitDialogProps) {
const [open, setOpen] = useState(false);
const [resolution, setResolution] = useState("");
const resolveWait = useResolveWait();
const handleResolve = async () => {
if (!resolution.trim()) {
toast.error("Please provide a resolution");
return;
}
try {
await resolveWait.mutateAsync({ agentId, resolution });
toast.success("Resolution sent to agent");
setOpen(false);
setResolution("");
} catch {
toast.error("Failed to resolve wait");
}
};
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button>
<Send className="h-4 w-4 mr-2" />
Resolve Wait
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Resolve Agent Wait</DialogTitle>
<DialogDescription>
Provide context or instructions to help the agent continue.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="resolution">Resolution Message</Label>
<Textarea
id="resolution"
value={resolution}
onChange={(e) => setResolution(e.target.value)}
placeholder="Provide the information or decision the agent needs to continue..."
rows={4}
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button onClick={handleResolve} disabled={resolveWait.isPending}>
{resolveWait.isPending ? "Sending..." : "Send Resolution"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,104 @@
"use client";
import { useState } from "react";
import { useSpawnAgent } from "@/hooks/use-agents";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogTrigger,
DialogDescription,
} from "@/components/ui/dialog";
import { DropdownMenuItem } from "@/components/ui/dropdown-menu";
import { Play } from "lucide-react";
import { toast } from "sonner";
interface SpawnAgentDialogProps {
agentId: string;
agentName: string;
trigger?: React.ReactNode;
}
export function SpawnAgentDialog({ agentId, agentName, trigger }: SpawnAgentDialogProps) {
const [open, setOpen] = useState(false);
const [taskId, setTaskId] = useState("");
const [initialPrompt, setInitialPrompt] = useState("");
const spawnAgent = useSpawnAgent();
const handleSpawn = async () => {
try {
await spawnAgent.mutateAsync({
agentId,
request: {
task_id: taskId || undefined,
initial_prompt: initialPrompt || undefined,
},
});
toast.success(`Agent ${agentName} spawned successfully`);
setOpen(false);
resetForm();
} catch {
toast.error("Failed to spawn agent");
}
};
const resetForm = () => {
setTaskId("");
setInitialPrompt("");
};
const defaultTrigger = (
<DropdownMenuItem onSelect={(e) => e.preventDefault()}>
<Play className="h-4 w-4 mr-2" />
Spawn
</DropdownMenuItem>
);
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
{trigger || defaultTrigger}
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle>Spawn {agentName}</DialogTitle>
<DialogDescription>
Start this agent with optional task assignment and initial prompt.
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="taskId">Task ID (optional)</Label>
<Input
id="taskId"
value={taskId}
onChange={(e) => setTaskId(e.target.value)}
placeholder="UUID of task to assign"
/>
</div>
<div className="space-y-2">
<Label htmlFor="initialPrompt">Initial Prompt (optional)</Label>
<Input
id="initialPrompt"
value={initialPrompt}
onChange={(e) => setInitialPrompt(e.target.value)}
placeholder="Initial instructions for the agent"
/>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setOpen(false)}>
Cancel
</Button>
<Button onClick={handleSpawn} disabled={spawnAgent.isPending}>
{spawnAgent.isPending ? "Spawning..." : "Spawn Agent"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,101 @@
"use client";
import { useEffect, useRef } from "react";
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useAgentStream, ConnectionState } from "@/hooks/use-websocket";
import { Wifi, WifiOff, Loader2, Trash2 } from "lucide-react";
interface AgentStreamViewerProps {
agentId: string;
agentName?: string;
}
const stateColors: Record<ConnectionState, string> = {
connected: "bg-green-500",
connecting: "bg-yellow-500",
reconnecting: "bg-orange-500",
disconnected: "bg-gray-500",
};
const stateLabels: Record<ConnectionState, string> = {
connected: "Connected",
connecting: "Connecting...",
reconnecting: "Reconnecting...",
disconnected: "Disconnected",
};
export function AgentStreamViewer({ agentId, agentName }: AgentStreamViewerProps) {
const {
state,
streamOutput,
streamChunks,
clearMessages,
isConnected,
isConnecting
} = useAgentStream(agentId);
const outputRef = useRef<HTMLPreElement>(null);
// Auto-scroll to bottom when new content arrives
useEffect(() => {
if (outputRef.current) {
outputRef.current.scrollTop = outputRef.current.scrollHeight;
}
}, [streamOutput]);
return (
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<div>
<CardTitle className="flex items-center gap-2">
Agent Output Stream
{isConnecting ? (
<Loader2 className="h-4 w-4 animate-spin text-yellow-500" />
) : isConnected ? (
<Wifi className="h-4 w-4 text-green-500" />
) : (
<WifiOff className="h-4 w-4 text-gray-500" />
)}
</CardTitle>
<CardDescription>
Real-time LLM output from {agentName || agentId}
</CardDescription>
</div>
<div className="flex items-center gap-2">
<Badge className={stateColors[state] + " text-white"}>
{stateLabels[state]}
</Badge>
{streamChunks.length > 0 && (
<Button variant="ghost" size="icon" onClick={clearMessages}>
<Trash2 className="h-4 w-4" />
</Button>
)}
</div>
</div>
</CardHeader>
<CardContent>
<pre
ref={outputRef}
className="bg-slate-950 text-slate-50 rounded-lg p-4 h-96 overflow-auto font-mono text-sm whitespace-pre-wrap"
>
{streamOutput || (
<span className="text-slate-500">
{isConnected
? "Waiting for agent output..."
: isConnecting
? "Connecting to agent stream..."
: "Agent stream disconnected"}
</span>
)}
</pre>
<div className="flex justify-between items-center mt-2 text-sm text-muted-foreground">
<span>{streamChunks.length} chunks received</span>
<span>{streamOutput.length} characters</span>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,40 @@
import Link from "next/link";
import { WaitingAgent } from "@/types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { AlertTriangle } from "lucide-react";
import { getAgentDisplayName } from "@/lib/agent-utils";
interface WaitingAgentsAlertProps {
waitingAgents: WaitingAgent[];
}
export function WaitingAgentsAlert({ waitingAgents }: WaitingAgentsAlertProps) {
if (waitingAgents.length === 0) return null;
return (
<Card className="border-orange-500/50">
<CardHeader>
<CardTitle className="text-orange-500 flex items-center gap-2">
<AlertTriangle className="h-5 w-5" />
Agents Waiting for Input
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{waitingAgents.map((agent) => (
<div key={agent.agent_id} className="flex items-center justify-between p-2 bg-muted rounded">
<div>
<span className="font-medium">{getAgentDisplayName(agent.agent_id)}</span>
<span className="text-muted-foreground ml-2">waiting for: {agent.waiting_for}</span>
</div>
<Button variant="outline" size="sm" asChild>
<Link href={"/agents/" + agent.agent_id}>Resolve</Link>
</Button>
</div>
))}
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,69 @@
"use client";
import {
useAuditorDashboard,
useAuditorFlags,
useAuditorReports,
} from "@/hooks/use-dashboard";
import { LiveFeedsPanel } from "./live-feeds-panel";
import { QualityMetricsPanel } from "./quality-metrics-panel";
import { FlaggedItemsPanel } from "./flagged-items-panel";
import { ReportsPanel } from "./reports-panel";
import { Button } from "@/components/ui/button";
import { RefreshCw, FileText } from "lucide-react";
export function AuditorDashboard() {
const {
data: dashboard,
isLoading: loadingDashboard,
refetch,
} = useAuditorDashboard();
const { data: flags, isLoading: loadingFlags } = useAuditorFlags();
const { data: reports, isLoading: loadingReports } = useAuditorReports();
const handleRefresh = () => {
refetch();
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Auditor Dashboard</h1>
<p className="text-muted-foreground">
Quality oversight, flagging, and reporting
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleRefresh}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
<Button>
<FileText className="h-4 w-4 mr-2" />
Generate Report
</Button>
</div>
</div>
{/* Top Row: Live Feeds + Quality Metrics */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<LiveFeedsPanel
feeds={dashboard?.live_feeds}
isLoading={loadingDashboard}
/>
<QualityMetricsPanel
metrics={dashboard?.metrics}
isLoading={loadingDashboard}
/>
</div>
{/* Bottom Row: Flagged Items + Reports */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<FlaggedItemsPanel flags={flags} isLoading={loadingFlags} />
<ReportsPanel reports={reports} isLoading={loadingReports} />
</div>
</div>
);
}
@@ -0,0 +1,197 @@
"use client";
import { useState } from "react";
import { FlagSeverity } from "@/types";
import { useCreateAuditorFlag } from "@/hooks/use-dashboard";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { toast } from "sonner";
interface CreateFlagDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
const SEVERITY_OPTIONS = [
{ value: FlagSeverity.INFO, label: "Info", color: "text-blue-600" },
{ value: FlagSeverity.WARNING, label: "Warning", color: "text-yellow-600" },
{ value: FlagSeverity.URGENT, label: "Urgent", color: "text-red-600" },
];
const CATEGORY_OPTIONS = [
"quality",
"process",
"communication",
"performance",
"security",
"documentation",
"other",
];
export function CreateFlagDialog({ open, onOpenChange }: CreateFlagDialogProps) {
const [title, setTitle] = useState("");
const [description, setDescription] = useState("");
const [severity, setSeverity] = useState<FlagSeverity>(FlagSeverity.INFO);
const [category, setCategory] = useState("quality");
const [relatedTaskId, setRelatedTaskId] = useState("");
const [relatedAgentId, setRelatedAgentId] = useState("");
const createFlag = useCreateAuditorFlag();
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!title.trim() || !description.trim()) {
toast.error("Title and description are required");
return;
}
try {
await createFlag.mutateAsync({
title: title.trim(),
description: description.trim(),
severity,
category,
related_task_id: relatedTaskId.trim() || undefined,
related_agent_id: relatedAgentId.trim() || undefined,
});
toast.success("Flag created successfully");
onOpenChange(false);
resetForm();
} catch {
toast.error("Failed to create flag");
}
};
const resetForm = () => {
setTitle("");
setDescription("");
setSeverity(FlagSeverity.INFO);
setCategory("quality");
setRelatedTaskId("");
setRelatedAgentId("");
};
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Create Quality Flag</DialogTitle>
<DialogDescription>
Flag an issue for tracking and resolution.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4 mt-4">
<div className="space-y-2">
<Label htmlFor="title">Title *</Label>
<Input
id="title"
value={title}
onChange={(e) => setTitle(e.target.value)}
placeholder="Brief description of the issue"
required
/>
</div>
<div className="space-y-2">
<Label htmlFor="description">Description *</Label>
<Textarea
id="description"
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Detailed explanation of the issue..."
className="min-h-[100px]"
required
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Severity</Label>
<Select value={severity} onValueChange={(v) => setSeverity(v as FlagSeverity)}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{SEVERITY_OPTIONS.map((s) => (
<SelectItem key={s.value} value={s.value}>
<span className={s.color}>{s.label}</span>
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Category</Label>
<Select value={category} onValueChange={setCategory}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
{CATEGORY_OPTIONS.map((c) => (
<SelectItem key={c} value={c}>
{c.charAt(0).toUpperCase() + c.slice(1)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="task">Related Task ID (optional)</Label>
<Input
id="task"
value={relatedTaskId}
onChange={(e) => setRelatedTaskId(e.target.value)}
placeholder="Task UUID"
/>
</div>
<div className="space-y-2">
<Label htmlFor="agent">Related Agent ID (optional)</Label>
<Input
id="agent"
value={relatedAgentId}
onChange={(e) => setRelatedAgentId(e.target.value)}
placeholder="Agent ID"
/>
</div>
</div>
<div className="flex justify-end gap-2 pt-4">
<Button
type="button"
variant="outline"
onClick={() => {
onOpenChange(false);
resetForm();
}}
>
Cancel
</Button>
<Button type="submit" disabled={createFlag.isPending}>
{createFlag.isPending ? "Creating..." : "Create Flag"}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,114 @@
"use client";
import { AuditorFlag, FlagSeverity } from "@/types";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Eye, CheckCircle, Send, Clock } from "lucide-react";
import Link from "next/link";
interface FlaggedItemProps {
flag: AuditorFlag;
onResolve?: (flagId: string) => void;
onReportToCeo?: (flag: AuditorFlag) => void;
}
const severityColors: Record<FlagSeverity, string> = {
[FlagSeverity.INFO]: "bg-blue-100 text-blue-700",
[FlagSeverity.WARNING]: "bg-yellow-100 text-yellow-700",
[FlagSeverity.URGENT]: "bg-red-100 text-red-700",
};
const severityEmoji: Record<FlagSeverity, string> = {
[FlagSeverity.INFO]: "\uD83D\uDFE2",
[FlagSeverity.WARNING]: "\uD83D\uDFE1",
[FlagSeverity.URGENT]: "\uD83D\uDD34",
};
function formatTime(timestamp: string): string {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
if (diffHours < 1) return "< 1h ago";
if (diffHours < 24) return `${diffHours}h ago`;
const diffDays = Math.floor(diffHours / 24);
return `${diffDays}d ago`;
}
export function FlaggedItem({ flag, onResolve, onReportToCeo }: FlaggedItemProps) {
const isResolved = !!flag.resolved_at;
return (
<div
className={`p-4 rounded-lg border ${
isResolved ? "bg-muted/30 opacity-60" : "bg-muted/50"
}`}
>
<div className="flex items-start justify-between gap-4">
<div className="flex items-start gap-3 flex-1 min-w-0">
<span className="text-xl">{severityEmoji[flag.severity]}</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1 flex-wrap">
<span className="font-medium text-sm">{flag.title}</span>
<Badge className={severityColors[flag.severity] + " text-xs"}>
{flag.severity}
</Badge>
<Badge variant="outline" className="text-xs">
{flag.category}
</Badge>
{isResolved && (
<Badge className="bg-green-100 text-green-700 text-xs">
Resolved
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground mb-2">{flag.description}</p>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{formatTime(flag.created_at)}
</span>
{flag.related_task_id && (
<Link href={"/tasks/" + flag.related_task_id}>
<span className="text-primary hover:underline">
Task #{flag.related_task_id.slice(0, 8)}
</span>
</Link>
)}
</div>
</div>
</div>
{!isResolved && (
<div className="flex items-center gap-2 shrink-0">
{flag.related_task_id && (
<Link href={"/tasks/" + flag.related_task_id}>
<Button variant="ghost" size="sm">
<Eye className="h-4 w-4" />
</Button>
</Link>
)}
<Button
variant="outline"
size="sm"
onClick={() => onResolve?.(flag.id)}
>
<CheckCircle className="h-4 w-4 mr-1" />
Resolve
</Button>
{flag.severity === FlagSeverity.URGENT && (
<Button
variant="default"
size="sm"
onClick={() => onReportToCeo?.(flag)}
>
<Send className="h-4 w-4 mr-1" />
Report CEO
</Button>
)}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,127 @@
"use client";
import { useState } from "react";
import { AuditorFlag, FlagSeverity } from "@/types";
import { useResolveAuditorFlag } from "@/hooks/use-dashboard";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { ScrollArea } from "@/components/ui/scroll-area";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Flag, Plus } from "lucide-react";
import { FlaggedItem } from "./flagged-item";
import { CreateFlagDialog } from "./create-flag-dialog";
import { toast } from "sonner";
interface FlaggedItemsPanelProps {
flags: AuditorFlag[] | undefined;
isLoading: boolean;
}
export function FlaggedItemsPanel({ flags, isLoading }: FlaggedItemsPanelProps) {
const [filter, setFilter] = useState<"all" | "unresolved" | "resolved">("unresolved");
const [createDialogOpen, setCreateDialogOpen] = useState(false);
const resolveFlag = useResolveAuditorFlag();
// Filter flags
const filteredFlags = (flags ?? []).filter((f) => {
if (filter === "unresolved") return !f.resolved_at;
if (filter === "resolved") return !!f.resolved_at;
return true;
});
// Sort by severity (urgent first) then by date
const sortedFlags = [...filteredFlags].sort((a, b) => {
const severityOrder: Record<FlagSeverity, number> = {
[FlagSeverity.URGENT]: 0,
[FlagSeverity.WARNING]: 1,
[FlagSeverity.INFO]: 2,
};
const severityDiff = severityOrder[a.severity] - severityOrder[b.severity];
if (severityDiff !== 0) return severityDiff;
return new Date(b.created_at).getTime() - new Date(a.created_at).getTime();
});
const unresolvedCount = (flags ?? []).filter((f) => !f.resolved_at).length;
const handleResolve = async (flagId: string) => {
try {
await resolveFlag.mutateAsync({ flagId });
toast.success("Flag resolved");
} catch {
toast.error("Failed to resolve flag");
}
};
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<CardTitle className="text-lg flex items-center gap-2">
<Flag className="h-5 w-5" />
Flagged Items
</CardTitle>
{unresolvedCount > 0 && (
<Badge variant="destructive">{unresolvedCount}</Badge>
)}
</div>
<div className="flex items-center gap-2">
<Select value={filter} onValueChange={(v) => setFilter(v as "all" | "unresolved" | "resolved")}>
<SelectTrigger className="w-auto min-w-24 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="unresolved">Unresolved</SelectItem>
<SelectItem value="resolved">Resolved</SelectItem>
<SelectItem value="all">All</SelectItem>
</SelectContent>
</Select>
<Button size="sm" onClick={() => setCreateDialogOpen(true)}>
<Plus className="h-4 w-4 mr-1" />
Flag
</Button>
</div>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-3">
{[...Array(3)].map((_, i) => (
<Skeleton key={i} className="h-24" />
))}
</div>
) : sortedFlags.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm">
<Flag className="h-8 w-8 mx-auto mb-2 opacity-50" />
No {filter === "all" ? "" : filter} flags
</div>
) : (
<ScrollArea className="h-[400px] pr-4">
<div className="space-y-3">
{sortedFlags.map((flag) => (
<FlaggedItem
key={flag.id}
flag={flag}
onResolve={handleResolve}
/>
))}
</div>
</ScrollArea>
)}
</CardContent>
<CreateFlagDialog
open={createDialogOpen}
onOpenChange={setCreateDialogOpen}
/>
</Card>
);
}
+8
View File
@@ -0,0 +1,8 @@
export { AuditorDashboard } from "./auditor-dashboard";
export { LiveFeedsPanel } from "./live-feeds-panel";
export { LiveFeedItem } from "./live-feed-item";
export { QualityMetricsPanel } from "./quality-metrics-panel";
export { FlaggedItemsPanel } from "./flagged-items-panel";
export { FlaggedItem } from "./flagged-item";
export { CreateFlagDialog } from "./create-flag-dialog";
export { ReportsPanel } from "./reports-panel";
@@ -0,0 +1,55 @@
"use client";
import { ChannelFeed } from "@/types";
import { Badge } from "@/components/ui/badge";
import { Radio, Clock } from "lucide-react";
interface LiveFeedItemProps {
feed: ChannelFeed;
}
function formatTime(timestamp: string | null): string {
if (!timestamp) return "No activity";
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / (1000 * 60));
if (diffMins < 1) return "Active now";
if (diffMins < 60) return `${diffMins}m ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}h ago`;
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
export function LiveFeedItem({ feed }: LiveFeedItemProps) {
const isActive = feed.status === "active" || feed.message_count_24h > 0;
return (
<div className="flex items-center justify-between p-3 rounded-lg border bg-muted/30 hover:bg-muted/50 transition-colors">
<div className="flex items-center gap-3">
<Radio
className={`h-4 w-4 ${isActive ? "text-green-500 animate-pulse" : "text-gray-400"}`}
/>
<div>
<span className="font-medium text-sm">#{feed.name}</span>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
{formatTime(feed.last_activity)}
</div>
</div>
</div>
<div className="flex items-center gap-2">
<Badge variant={isActive ? "default" : "secondary"} className="text-xs">
{feed.message_count_24h} msgs
</Badge>
<Badge
variant="outline"
className={isActive ? "text-green-600 border-green-300" : ""}
>
{isActive ? "Active" : "Idle"}
</Badge>
</div>
</div>
);
}
@@ -0,0 +1,54 @@
"use client";
import { ChannelFeed } from "@/types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Radio } from "lucide-react";
import { LiveFeedItem } from "./live-feed-item";
interface LiveFeedsPanelProps {
feeds: ChannelFeed[] | undefined;
isLoading: boolean;
}
export function LiveFeedsPanel({ feeds, isLoading }: LiveFeedsPanelProps) {
const activeCount = (feeds ?? []).filter(
(f) => f.status === "active" || f.message_count_24h > 0
).length;
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<Radio className="h-5 w-5" />
Live Feeds
</CardTitle>
<span className="text-sm text-muted-foreground">
{activeCount} active
</span>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-14" />
))}
</div>
) : !feeds || feeds.length === 0 ? (
<div className="text-center py-4 text-muted-foreground text-sm">
<Radio className="h-8 w-8 mx-auto mb-2 opacity-50" />
No channel feeds available
</div>
) : (
<div className="space-y-2">
{feeds.map((feed) => (
<LiveFeedItem key={feed.id} feed={feed} />
))}
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,104 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Progress } from "@/components/ui/progress";
import { BarChart3, CheckCircle, Clock, FileText, AlertTriangle } from "lucide-react";
interface QualityMetricsPanelProps {
metrics: Record<string, number> | undefined;
isLoading: boolean;
}
interface MetricDisplay {
key: string;
label: string;
icon: React.ReactNode;
format: (value: number) => string;
isPercent?: boolean;
}
const METRICS: MetricDisplay[] = [
{
key: "tasks_completed_24h",
label: "Tasks Completed (24h)",
icon: <CheckCircle className="h-4 w-4 text-green-500" />,
format: (v) => String(v),
},
{
key: "qa_pass_rate",
label: "QA Pass Rate",
icon: <BarChart3 className="h-4 w-4 text-blue-500" />,
format: (v) => `${Math.round(v * 100)}%`,
isPercent: true,
},
{
key: "avg_completion_time",
label: "Avg Completion Time",
icon: <Clock className="h-4 w-4 text-purple-500" />,
format: (v) => `${(typeof v === "number" ? v : parseFloat(v) || 0).toFixed(1)}h`,
},
{
key: "documentation_rate",
label: "Documentation Rate",
icon: <FileText className="h-4 w-4 text-indigo-500" />,
format: (v) => `${Math.round(v * 100)}%`,
isPercent: true,
},
{
key: "active_blockers",
label: "Active Blockers",
icon: <AlertTriangle className="h-4 w-4 text-red-500" />,
format: (v) => String(v),
},
{
key: "longest_block_hours",
label: "Longest Block",
icon: <Clock className="h-4 w-4 text-orange-500" />,
format: (v) => `${v}h`,
},
];
export function QualityMetricsPanel({ metrics, isLoading }: QualityMetricsPanelProps) {
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<BarChart3 className="h-5 w-5" />
Quality Metrics
</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-4">
{METRICS.map((m) => (
<Skeleton key={m.key} className="h-8" />
))}
</div>
) : (
<div className="space-y-4">
{METRICS.map((m) => {
const value = metrics?.[m.key];
return (
<div key={m.key}>
<div className="flex items-center justify-between text-sm mb-1">
<div className="flex items-center gap-2 text-muted-foreground">
{m.icon}
{m.label}
</div>
<span className="font-medium">
{value != null ? m.format(value) : "-"}
</span>
</div>
{m.isPercent && value != null && (
<Progress value={value * 100} className="h-1.5" />
)}
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,117 @@
"use client";
import { AuditorReport } from "@/types";
import { useSendAuditorReport } from "@/hooks/use-dashboard";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { ScrollArea } from "@/components/ui/scroll-area";
import { FileText, Send, Eye, Clock, Plus } from "lucide-react";
import { toast } from "sonner";
interface ReportsPanelProps {
reports: AuditorReport[] | undefined;
isLoading: boolean;
onCreateReport?: () => void;
}
function formatDate(timestamp: string): string {
return new Date(timestamp).toLocaleDateString("en-US", {
month: "short",
day: "numeric",
year: "numeric",
});
}
export function ReportsPanel({ reports, isLoading, onCreateReport }: ReportsPanelProps) {
const sendReport = useSendAuditorReport();
const handleSend = async (reportId: string) => {
try {
await sendReport.mutateAsync(reportId);
toast.success("Report sent to CEO");
} catch {
toast.error("Failed to send report");
}
};
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<FileText className="h-5 w-5" />
Reports
</CardTitle>
<Button size="sm" onClick={onCreateReport}>
<Plus className="h-4 w-4 mr-1" />
New Report
</Button>
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-3">
{[...Array(3)].map((_, i) => (
<Skeleton key={i} className="h-16" />
))}
</div>
) : !reports || reports.length === 0 ? (
<div className="text-center py-8 text-muted-foreground text-sm">
<FileText className="h-8 w-8 mx-auto mb-2 opacity-50" />
No reports yet
</div>
) : (
<ScrollArea className="h-[300px] pr-4">
<div className="space-y-3">
{reports.map((report) => {
const isDraft = !report.sent_at;
return (
<div
key={report.id}
className="flex items-center justify-between p-3 rounded-lg border bg-muted/30"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<Badge variant={isDraft ? "secondary" : "default"}>
{isDraft ? "Draft" : "Sent"}
</Badge>
<span className="font-medium text-sm truncate">
{report.title}
</span>
</div>
<div className="flex items-center gap-4 text-xs text-muted-foreground">
<span className="capitalize">{report.report_type}</span>
<span className="flex items-center gap-1">
<Clock className="h-3 w-3" />
{formatDate(report.created_at)}
</span>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
<Button variant="ghost" size="sm">
<Eye className="h-4 w-4" />
</Button>
{isDraft && (
<Button
variant="outline"
size="sm"
onClick={() => handleSend(report.id)}
disabled={sendReport.isPending}
>
<Send className="h-4 w-4 mr-1" />
Send
</Button>
)}
</div>
</div>
);
})}
</div>
</ScrollArea>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,44 @@
"use client";
import { Channel } from "@/types";
import { Badge } from "@/components/ui/badge";
import { Hash, Lock } from "lucide-react";
import { cn } from "@/lib/utils";
interface ChannelItemProps {
channel: Channel;
isSelected: boolean;
onClick: () => void;
unreadCount?: number;
}
export function ChannelItem({
channel,
isSelected,
onClick,
unreadCount = 0,
}: ChannelItemProps) {
return (
<button
onClick={onClick}
className={cn(
"w-full flex items-center gap-2 px-2 py-1.5 rounded-md text-left transition-colors",
isSelected
? "bg-primary/10 text-primary"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
)}
>
{channel.is_private ? (
<Lock className="h-4 w-4 shrink-0" />
) : (
<Hash className="h-4 w-4 shrink-0" />
)}
<span className="flex-1 truncate text-sm">{channel.name}</span>
{unreadCount > 0 && (
<Badge variant="destructive" className="h-5 px-1.5 text-xs">
{unreadCount}
</Badge>
)}
</button>
);
}
@@ -0,0 +1,94 @@
"use client";
import { Channel } from "@/types";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Skeleton } from "@/components/ui/skeleton";
import { ChannelItem } from "./channel-item";
interface ChannelSidebarProps {
channels: Channel[] | undefined;
isLoading: boolean;
selectedChannelId: string | null;
onSelectChannel: (channelId: string) => void;
}
// Group channels by type
function groupChannels(channels: Channel[]): Record<string, Channel[]> {
const groups: Record<string, Channel[]> = {
"Cell Channels": [],
"Cross-Cell": [],
Management: [],
Special: [],
};
channels.forEach((channel) => {
if (channel.name.includes("-cell")) {
groups["Cell Channels"].push(channel);
} else if (channel.name.includes("-all")) {
groups["Cross-Cell"].push(channel);
} else if (
channel.name.includes("pm") ||
channel.name.includes("board")
) {
groups["Management"].push(channel);
} else {
groups["Special"].push(channel);
}
});
return groups;
}
export function ChannelSidebar({
channels,
isLoading,
selectedChannelId,
onSelectChannel,
}: ChannelSidebarProps) {
if (isLoading) {
return (
<div className="space-y-2 p-2">
{[...Array(8)].map((_, i) => (
<Skeleton key={i} className="h-8" />
))}
</div>
);
}
if (!channels || channels.length === 0) {
return (
<div className="p-4 text-center text-muted-foreground text-sm">
No channels available
</div>
);
}
const grouped = groupChannels(channels);
return (
<ScrollArea className="h-[calc(100vh-200px)]">
<div className="p-2 space-y-4">
{Object.entries(grouped).map(([group, groupChannels]) => {
if (groupChannels.length === 0) return null;
return (
<div key={group}>
<h3 className="text-xs font-medium text-muted-foreground uppercase tracking-wider px-2 mb-2">
{group}
</h3>
<div className="space-y-0.5">
{groupChannels.map((channel) => (
<ChannelItem
key={channel.id}
channel={channel}
isSelected={selectedChannelId === channel.id}
onClick={() => onSelectChannel(channel.id)}
/>
))}
</div>
</div>
);
})}
</div>
</ScrollArea>
);
}
@@ -0,0 +1,130 @@
"use client";
import { useState } from "react";
import { useChannels } from "@/hooks/use-channels";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ChannelSidebar } from "./channel-sidebar";
import { RefreshCw, Hash, Users, ExternalLink } from "lucide-react";
import Link from "next/link";
export function CommunicationsView() {
const [selectedChannelId, setSelectedChannelId] = useState<string | null>(null);
const { data: channels, isLoading: loadingChannels, refetch } = useChannels();
// Get selected channel
const selectedChannel = channels?.find((c) => c.id === selectedChannelId);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">Communications</h1>
<p className="text-muted-foreground">
Browse channels and view messages
</p>
</div>
<div className="flex items-center gap-2">
<Link href="/communications">
<Button variant="outline">
<ExternalLink className="h-4 w-4 mr-2" />
Full View
</Button>
</Link>
<Button variant="outline" onClick={() => refetch()}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
</div>
</div>
{/* Main Content */}
<div className="grid grid-cols-12 gap-6 h-[calc(100vh-220px)]">
{/* Channel Sidebar */}
<div className="col-span-12 lg:col-span-3">
<Card className="h-full">
<CardHeader className="py-3">
<CardTitle className="text-sm font-medium">Channels</CardTitle>
</CardHeader>
<CardContent className="p-0">
<ChannelSidebar
channels={channels}
isLoading={loadingChannels}
selectedChannelId={selectedChannelId}
onSelectChannel={setSelectedChannelId}
/>
</CardContent>
</Card>
</div>
{/* Channel Info Area */}
<div className="col-span-12 lg:col-span-9">
<Card className="h-full flex flex-col">
{selectedChannel ? (
<>
{/* Channel Header */}
<CardHeader className="py-3 border-b shrink-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Hash className="h-5 w-5 text-muted-foreground" />
<CardTitle className="text-lg">{selectedChannel.name}</CardTitle>
<Badge variant="outline" className="text-xs">
<Users className="h-3 w-3 mr-1" />
{selectedChannel.member_count}
</Badge>
</div>
<Link href={`/communications?channel=${selectedChannel.id}`}>
<Button variant="outline" size="sm">
<ExternalLink className="h-4 w-4 mr-2" />
Open Channel
</Button>
</Link>
</div>
{selectedChannel.description && (
<p className="text-sm text-muted-foreground">
{selectedChannel.description}
</p>
)}
</CardHeader>
{/* Channel Stats */}
<CardContent className="flex-1 flex items-center justify-center">
<div className="text-center space-y-4">
<div className="grid grid-cols-2 gap-8">
<div>
<p className="text-3xl font-bold">{selectedChannel.message_count}</p>
<p className="text-sm text-muted-foreground">Messages</p>
</div>
<div>
<p className="text-3xl font-bold">{selectedChannel.group_count}</p>
<p className="text-sm text-muted-foreground">Groups</p>
</div>
</div>
<p className="text-sm text-muted-foreground">
Open the channel to view sessions and send messages
</p>
<Link href={`/communications?channel=${selectedChannel.id}`}>
<Button>
View Sessions
</Button>
</Link>
</div>
</CardContent>
</>
) : (
<div className="flex-1 flex items-center justify-center text-muted-foreground">
<div className="text-center">
<Hash className="h-12 w-12 mx-auto mb-4 opacity-50" />
<p className="text-lg font-medium">Select a Channel</p>
<p className="text-sm">Choose a channel from the sidebar to view details</p>
</div>
</div>
)}
</Card>
</div>
</div>
</div>
);
}
@@ -0,0 +1,7 @@
export { CommunicationsView } from "./communications-view";
export { ChannelSidebar } from "./channel-sidebar";
export { ChannelItem } from "./channel-item";
export { MessageList } from "./message-list";
export { MessageItem } from "./message-item";
export { MessageComposer } from "./message-composer";
export { MessageTypeBadge } from "./message-type-badge";
@@ -0,0 +1,95 @@
"use client";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { Send } from "lucide-react";
interface MessageComposerProps {
channelId: string;
onSend: (message: { content: string; type: string }) => void;
isSending?: boolean;
disabled?: boolean;
}
const MESSAGE_TYPES = [
{ value: "dialogue", label: "Dialogue" },
{ value: "reasoning", label: "Reasoning" },
{ value: "decision", label: "Decision" },
{ value: "action", label: "Action" },
{ value: "blocker", label: "Blocker" },
{ value: "technical", label: "Technical" },
];
export function MessageComposer({
onSend,
isSending,
disabled,
}: MessageComposerProps) {
const [content, setContent] = useState("");
const [type, setType] = useState("dialogue");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!content.trim()) return;
onSend({ content: content.trim(), type });
setContent("");
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
handleSubmit(e);
}
};
return (
<form onSubmit={handleSubmit} className="border-t p-4">
<div className="flex items-end gap-2">
<div className="flex-1">
<Textarea
value={content}
onChange={(e) => setContent(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Type a message... (Shift+Enter for new line)"
className="min-h-[60px] resize-none"
disabled={disabled || isSending}
/>
</div>
<div className="flex flex-col gap-2">
<Select value={type} onValueChange={setType}>
<SelectTrigger className="w-auto min-w-24 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
{MESSAGE_TYPES.map((t) => (
<SelectItem key={t.value} value={t.value}>
{t.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="submit"
size="sm"
disabled={!content.trim() || disabled || isSending}
>
<Send className="h-4 w-4 mr-1" />
Send
</Button>
</div>
</div>
<p className="text-xs text-muted-foreground mt-2">
Markdown supported. Use @agent to mention.
</p>
</form>
);
}
@@ -0,0 +1,66 @@
"use client";
import { Message } from "@/types";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { Badge } from "@/components/ui/badge";
import { Markdown } from "@/components/ui/markdown";
import { MessageTypeBadge } from "./message-type-badge";
import { Clock, Link2 } from "lucide-react";
import Link from "next/link";
import { getAgentDisplayName, getAgentInitials } from "@/lib/agent-utils";
interface MessageItemProps {
message: Message;
}
function formatTime(timestamp: string): string {
const date = new Date(timestamp);
return date.toLocaleTimeString("en-US", {
hour: "2-digit",
minute: "2-digit",
});
}
export function MessageItem({ message }: MessageItemProps) {
return (
<div className="flex gap-3 py-3 hover:bg-muted/30 px-2 rounded-lg">
<Avatar className="h-8 w-8 shrink-0">
<AvatarFallback className="bg-primary/10 text-primary text-xs">
{getAgentInitials(message.agent_id)}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-medium text-sm">{getAgentDisplayName(message.agent_id)}</span>
<span className="text-xs text-muted-foreground flex items-center gap-1">
<Clock className="h-3 w-3" />
{formatTime(message.timestamp)}
</span>
<MessageTypeBadge type={message.type} />
</div>
<div className="text-sm mt-1">
<Markdown>{message.content}</Markdown>
</div>
{/* Mentions */}
{message.mentions.length > 0 && (
<div className="flex items-center gap-1 mt-2">
{message.mentions.map((mention) => (
<Badge key={mention} variant="outline" className="text-xs">
@{mention.slice(0, 8)}
</Badge>
))}
</div>
)}
{/* Related Task */}
{message.task_id && (
<Link href={"/tasks/" + message.task_id}>
<Badge variant="outline" className="text-xs mt-2 hover:bg-muted">
<Link2 className="h-3 w-3 mr-1" />
Task #{message.task_id.slice(0, 8)}
</Badge>
</Link>
)}
</div>
</div>
);
}
@@ -0,0 +1,50 @@
"use client";
import { Message } from "@/types";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Skeleton } from "@/components/ui/skeleton";
import { MessageItem } from "./message-item";
import { MessageSquare } from "lucide-react";
interface MessageListProps {
messages: Message[] | undefined;
isLoading: boolean;
}
export function MessageList({ messages, isLoading }: MessageListProps) {
if (isLoading) {
return (
<div className="space-y-4 p-4">
{[...Array(5)].map((_, i) => (
<div key={i} className="flex gap-3">
<Skeleton className="h-8 w-8 rounded-full" />
<div className="flex-1 space-y-2">
<Skeleton className="h-4 w-32" />
<Skeleton className="h-16 w-full" />
</div>
</div>
))}
</div>
);
}
if (!messages || messages.length === 0) {
return (
<div className="flex flex-col items-center justify-center h-full text-muted-foreground">
<MessageSquare className="h-12 w-12 mb-4 opacity-50" />
<p>No messages yet</p>
<p className="text-sm">Start the conversation</p>
</div>
);
}
return (
<ScrollArea className="flex-1 p-4">
<div className="space-y-1">
{messages.map((message) => (
<MessageItem key={message.id} message={message} />
))}
</div>
</ScrollArea>
);
}
@@ -0,0 +1,22 @@
"use client";
import { Badge } from "@/components/ui/badge";
interface MessageTypeBadgeProps {
type: string;
}
const typeConfig: Record<string, { label: string; color: string }> = {
reasoning: { label: "reasoning", color: "bg-blue-100 text-blue-700" },
dialogue: { label: "dialogue", color: "bg-green-100 text-green-700" },
decision: { label: "decision", color: "bg-purple-100 text-purple-700" },
action: { label: "action", color: "bg-orange-100 text-orange-700" },
blocker: { label: "blocker", color: "bg-red-100 text-red-700" },
technical: { label: "technical", color: "bg-gray-100 text-gray-700" },
general: { label: "general", color: "bg-gray-100 text-gray-700" },
};
export function MessageTypeBadge({ type }: MessageTypeBadgeProps) {
const config = typeConfig[type] ?? typeConfig.general;
return <Badge className={config.color + " text-xs"}>{config.label}</Badge>;
}
@@ -0,0 +1,100 @@
"use client";
import { Task, TaskStatus } from "@/types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { AlertTriangle, Clock, ArrowRight } from "lucide-react";
import Link from "next/link";
interface ActiveBlockersPanelProps {
tasks: Task[] | undefined;
isLoading: boolean;
}
function formatDuration(date: string): string {
const start = new Date(date);
const now = new Date();
const diffMs = now.getTime() - start.getTime();
const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
const diffDays = Math.floor(diffHours / 24);
if (diffHours < 1) return "< 1h";
if (diffHours < 24) return `${diffHours}h`;
return `${diffDays}d`;
}
export function ActiveBlockersPanel({ tasks, isLoading }: ActiveBlockersPanelProps) {
// Filter blocked tasks and sort by how long they've been blocked
const blockedTasks = (tasks ?? [])
.filter((t) => t.status === TaskStatus.BLOCKED)
.sort((a, b) => {
const aTime = a.updated_at ? new Date(a.updated_at).getTime() : 0;
const bTime = b.updated_at ? new Date(b.updated_at).getTime() : 0;
return aTime - bTime; // Oldest first
})
.slice(0, 5);
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-red-500" />
Active Blockers
</CardTitle>
{blockedTasks.length > 0 && (
<Badge variant="destructive">{blockedTasks.length}</Badge>
)}
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-3">
<Skeleton className="h-16" />
<Skeleton className="h-16" />
</div>
) : blockedTasks.length === 0 ? (
<div className="text-center py-4 text-muted-foreground text-sm">
<AlertTriangle className="h-8 w-8 mx-auto mb-2 opacity-50" />
No blocked tasks
</div>
) : (
<div className="space-y-3">
{blockedTasks.map((task) => (
<Link key={task.id} href={"/tasks/" + task.id}>
<div className="flex items-start gap-3 p-3 rounded-lg border border-red-200 bg-red-50 hover:bg-red-100 transition-colors">
<span className="text-lg">\uD83D\uDD34</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-sm truncate">
Task #{task.id.slice(0, 8)}
</span>
<Badge variant="outline" className="text-xs capitalize">
{task.team.replace(/_/g, " ")}
</Badge>
</div>
<p className="text-sm truncate">{task.title}</p>
<div className="flex items-center gap-1 mt-1 text-xs text-muted-foreground">
<Clock className="h-3 w-3" />
Blocked for {formatDuration(task.updated_at ?? task.created_at)}
</div>
</div>
</div>
</Link>
))}
</div>
)}
<div className="mt-4 pt-3 border-t">
<Link href="/tasks?status=blocked">
<Button variant="ghost" size="sm" className="w-full">
View All Blocked
<ArrowRight className="h-4 w-4 ml-2" />
</Button>
</Link>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,78 @@
"use client";
import { CheckCircle, Play, Pause, AlertTriangle, User, Clock } from "lucide-react";
import { getAgentDisplayName } from "@/lib/agent-utils";
export interface Activity {
id: string;
agent_id: string;
action: string;
task_id?: string;
task_title?: string;
timestamp: string;
}
interface ActivityItemProps {
activity: Activity;
}
const actionIcons: Record<string, React.ReactNode> = {
completed: <CheckCircle className="h-4 w-4 text-green-500" />,
started: <Play className="h-4 w-4 text-blue-500" />,
paused: <Pause className="h-4 w-4 text-yellow-500" />,
blocked: <AlertTriangle className="h-4 w-4 text-red-500" />,
claimed: <User className="h-4 w-4 text-purple-500" />,
passed_qa: <CheckCircle className="h-4 w-4 text-green-500" />,
failed_qa: <AlertTriangle className="h-4 w-4 text-red-500" />,
};
const actionLabels: Record<string, string> = {
completed: "completed",
started: "started",
paused: "paused",
blocked: "blocked on",
claimed: "claimed",
passed_qa: "passed QA on",
failed_qa: "failed QA on",
};
function formatTime(timestamp: string): string {
const date = new Date(timestamp);
const now = new Date();
const diffMs = now.getTime() - date.getTime();
const diffMins = Math.floor(diffMs / (1000 * 60));
if (diffMins < 1) return "just now";
if (diffMins < 60) return `${diffMins}m ago`;
const diffHours = Math.floor(diffMins / 60);
if (diffHours < 24) return `${diffHours}h ago`;
return date.toLocaleDateString("en-US", { month: "short", day: "numeric" });
}
export function ActivityItem({ activity }: ActivityItemProps) {
const action = activity.action || "unknown";
const icon = actionIcons[action] || <Clock className="h-4 w-4" />;
const label = actionLabels[action] || action;
return (
<div className="flex items-start gap-3 py-2">
<div className="mt-0.5">{icon}</div>
<div className="flex-1 min-w-0">
<p className="text-sm">
<span className="font-medium">{getAgentDisplayName(activity.agent_id)}</span>
{" "}
<span className="text-muted-foreground">{label}</span>
{activity.task_title && (
<>
{" "}
<span className="font-medium">{activity.task_title}</span>
</>
)}
</p>
<span className="text-xs text-muted-foreground">
{activity.timestamp ? formatTime(activity.timestamp) : "N/A"}
</span>
</div>
</div>
);
}
@@ -0,0 +1,101 @@
"use client";
import { AuditorFlag, FlagSeverity } from "@/types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { Shield, ArrowRight } from "lucide-react";
import Link from "next/link";
interface AuditorAlertsPanelProps {
alerts: AuditorFlag[] | undefined;
isLoading: boolean;
}
const severityColors: Record<FlagSeverity, string> = {
[FlagSeverity.INFO]: "bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200",
[FlagSeverity.WARNING]: "bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200",
[FlagSeverity.URGENT]: "bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200",
};
const severityEmoji: Record<FlagSeverity, string> = {
[FlagSeverity.INFO]: "\uD83D\uDFE2",
[FlagSeverity.WARNING]: "\uD83D\uDFE1",
[FlagSeverity.URGENT]: "\uD83D\uDD34",
};
export function AuditorAlertsPanel({ alerts, isLoading }: AuditorAlertsPanelProps) {
// Filter to show only unresolved, sorted by severity
const unresolvedAlerts = (alerts ?? [])
.filter((a) => !a.resolved_at)
.sort((a, b) => {
const order: Record<FlagSeverity, number> = {
[FlagSeverity.URGENT]: 0,
[FlagSeverity.WARNING]: 1,
[FlagSeverity.INFO]: 2,
};
return order[a.severity] - order[b.severity];
})
.slice(0, 5);
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<Shield className="h-5 w-5" />
Auditor Alerts
</CardTitle>
{unresolvedAlerts.length > 0 && (
<Badge variant="destructive">{unresolvedAlerts.length}</Badge>
)}
</div>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-3">
<Skeleton className="h-12" />
<Skeleton className="h-12" />
<Skeleton className="h-12" />
</div>
) : unresolvedAlerts.length === 0 ? (
<div className="text-center py-4 text-muted-foreground text-sm">
<Shield className="h-8 w-8 mx-auto mb-2 opacity-50" />
No active alerts
</div>
) : (
<div className="space-y-3">
{unresolvedAlerts.map((alert) => (
<div
key={alert.id}
className="flex items-start gap-3 p-3 rounded-lg border bg-muted/30"
>
<span className="text-lg">{severityEmoji[alert.severity]}</span>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
<span className="font-medium text-sm truncate">{alert.title}</span>
<Badge className={severityColors[alert.severity] + " text-xs"}>
{alert.severity}
</Badge>
</div>
<p className="text-xs text-muted-foreground line-clamp-1">
{alert.description}
</p>
</div>
</div>
))}
</div>
)}
<div className="mt-4 pt-3 border-t">
<Link href="/auditor">
<Button variant="ghost" size="sm" className="w-full">
View All Flags
<ArrowRight className="h-4 w-4 ml-2" />
</Button>
</Link>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,280 @@
"use client";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { tasksApi } from "@/lib/api";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
import { Label } from "@/components/ui/label";
import { CheckCircle2, XCircle, Clock, FileText, ExternalLink } from "lucide-react";
import Link from "next/link";
import type { Task } from "@/types";
import { toast } from "sonner";
interface CeoApprovalQueueProps {
className?: string;
}
export function CeoApprovalQueue({ className }: CeoApprovalQueueProps) {
const queryClient = useQueryClient();
const [selectedTask, setSelectedTask] = useState<Task | null>(null);
const [actionType, setActionType] = useState<"approve" | "reject" | null>(null);
const [notes, setNotes] = useState("");
// Fetch tasks awaiting CEO approval
const { data: tasks, isLoading } = useQuery({
queryKey: ["tasks", "awaiting-ceo-approval"],
queryFn: () => tasksApi.getAwaitingCeoApproval(),
refetchInterval: 30000, // Refresh every 30 seconds
});
// Approve mutation
const approveMutation = useMutation({
mutationFn: ({ taskId, notes }: { taskId: string; notes?: string }) =>
tasksApi.ceoApprove(taskId, notes),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tasks"] });
toast.success("Task approved and completed");
closeDialog();
},
onError: (error) => {
toast.error(`Failed to approve: ${error instanceof Error ? error.message : "Unknown error"}`);
},
});
// Reject mutation
const rejectMutation = useMutation({
mutationFn: ({ taskId, notes }: { taskId: string; notes: string }) =>
tasksApi.ceoReject(taskId, notes),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["tasks"] });
toast.success("Task rejected and sent back for revision");
closeDialog();
},
onError: (error) => {
toast.error(`Failed to reject: ${error instanceof Error ? error.message : "Unknown error"}`);
},
});
const openDialog = (task: Task, action: "approve" | "reject") => {
setSelectedTask(task);
setActionType(action);
setNotes("");
};
const closeDialog = () => {
setSelectedTask(null);
setActionType(null);
setNotes("");
};
const handleConfirm = () => {
if (!selectedTask) return;
if (actionType === "approve") {
approveMutation.mutate({ taskId: selectedTask.id, notes: notes || undefined });
} else if (actionType === "reject") {
if (!notes.trim()) {
toast.error("Rejection reason is required");
return;
}
rejectMutation.mutate({ taskId: selectedTask.id, notes });
}
};
const getPriorityBadge = (priority: number) => {
const variants: Record<number, { label: string; variant: "default" | "secondary" | "destructive" | "outline" }> = {
0: { label: "P0", variant: "destructive" },
1: { label: "P1", variant: "destructive" },
2: { label: "P2", variant: "secondary" },
3: { label: "P3", variant: "outline" },
};
const { label, variant } = variants[priority] || { label: `P${priority}`, variant: "outline" as const };
return <Badge variant={variant}>{label}</Badge>;
};
if (isLoading) {
return (
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="h-5 w-5" />
CEO Approval Queue
</CardTitle>
<CardDescription>Tasks awaiting your approval</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-3">
{[1, 2, 3].map((i) => (
<Skeleton key={i} className="h-20 w-full" />
))}
</div>
</CardContent>
</Card>
);
}
const pendingTasks = tasks || [];
return (
<>
<Card className={className}>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock className="h-5 w-5" />
CEO Approval Queue
{pendingTasks.length > 0 && (
<Badge variant="secondary" className="ml-2">
{pendingTasks.length}
</Badge>
)}
</CardTitle>
<CardDescription>Tasks escalated for your final approval</CardDescription>
</CardHeader>
<CardContent>
{pendingTasks.length === 0 ? (
<div className="text-center py-8 text-muted-foreground">
<CheckCircle2 className="h-12 w-12 mx-auto mb-2 opacity-50" />
<p>No tasks awaiting approval</p>
</div>
) : (
<div className="space-y-3">
{pendingTasks.map((task) => (
<div
key={task.id}
className="flex items-start justify-between p-4 border rounded-lg hover:bg-muted/50 transition-colors"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 mb-1">
{getPriorityBadge(task.priority)}
<Badge variant="outline">{task.team}</Badge>
</div>
<Link
href={`/tasks/${task.id}`}
className="font-medium hover:underline line-clamp-1"
>
{task.title}
</Link>
{task.quick_context && (
<p className="text-sm text-muted-foreground mt-1 line-clamp-2">
{task.quick_context}
</p>
)}
</div>
<div className="flex items-center gap-2 ml-4 flex-shrink-0">
<Link href={`/tasks/${task.id}`}>
<Button variant="ghost" size="sm">
<FileText className="h-4 w-4" />
</Button>
</Link>
<Button
variant="outline"
size="sm"
className="text-destructive hover:text-destructive"
onClick={() => openDialog(task, "reject")}
>
<XCircle className="h-4 w-4 mr-1" />
Reject
</Button>
<Button
size="sm"
className="bg-green-600 hover:bg-green-700"
onClick={() => openDialog(task, "approve")}
>
<CheckCircle2 className="h-4 w-4 mr-1" />
Approve
</Button>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
{/* Confirmation Dialog */}
<Dialog open={!!selectedTask && !!actionType} onOpenChange={() => closeDialog()}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{actionType === "approve" ? "Approve Task" : "Reject Task"}
</DialogTitle>
<DialogDescription>
{actionType === "approve"
? "This will complete the task and notify the team."
: "This will send the task back for revision."}
</DialogDescription>
</DialogHeader>
{selectedTask && (
<div className="py-4">
<div className="flex items-center gap-2 mb-2">
{getPriorityBadge(selectedTask.priority)}
<Badge variant="outline">{selectedTask.team}</Badge>
</div>
<p className="font-medium">{selectedTask.title}</p>
{selectedTask.description && (
<p className="text-sm text-muted-foreground mt-2 line-clamp-3">
{selectedTask.description}
</p>
)}
<Link
href={`/tasks/${selectedTask.id}`}
target="_blank"
className="text-sm text-primary flex items-center gap-1 mt-2 hover:underline"
>
View full details <ExternalLink className="h-3 w-3" />
</Link>
</div>
)}
<div className="space-y-2">
<Label htmlFor="notes">
{actionType === "approve" ? "Notes (optional)" : "Reason for rejection (required)"}
</Label>
<Textarea
id="notes"
placeholder={
actionType === "approve"
? "Add any notes about this approval..."
: "Explain what needs to be fixed..."
}
value={notes}
onChange={(e) => setNotes(e.target.value)}
rows={3}
/>
</div>
<DialogFooter>
<Button variant="outline" onClick={closeDialog}>
Cancel
</Button>
<Button
onClick={handleConfirm}
disabled={approveMutation.isPending || rejectMutation.isPending}
className={actionType === "approve" ? "bg-green-600 hover:bg-green-700" : ""}
variant={actionType === "reject" ? "destructive" : "default"}
>
{approveMutation.isPending || rejectMutation.isPending
? "Processing..."
: actionType === "approve"
? "Approve & Complete"
: "Reject & Request Revision"}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}
@@ -0,0 +1,89 @@
"use client";
import { useCeoOverview, useAuditorFlags, useRecentActivity } from "@/hooks/use-dashboard";
import { useTasks } from "@/hooks/use-tasks";
import { TeamHealthCards } from "./team-health-cards";
import { KeyMetricsPanel } from "./key-metrics-panel";
import { AuditorAlertsPanel } from "./auditor-alerts-panel";
import { ActiveBlockersPanel } from "./active-blockers-panel";
import { RecentActivityFeed } from "./recent-activity-feed";
import { QuickActionsBar } from "./quick-actions-bar";
import { CeoApprovalQueue } from "./ceo-approval-queue";
import type { Activity } from "./activity-item";
import { Button } from "@/components/ui/button";
import { RefreshCw, Settings } from "lucide-react";
import Link from "next/link";
export function CommandCenter() {
const { data: overview, isLoading: loadingOverview, refetch: refetchOverview } = useCeoOverview();
const { data: flags, isLoading: loadingFlags } = useAuditorFlags({ resolved: false });
const { data: tasks, isLoading: loadingTasks } = useTasks();
const { data: activity, isLoading: loadingActivity } = useRecentActivity(24);
const handleRefresh = () => {
refetchOverview();
};
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight">RoboCo Command Center</h1>
<p className="text-muted-foreground">
Complete visibility into all operations
</p>
</div>
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleRefresh}>
<RefreshCw className="h-4 w-4 mr-2" />
Refresh
</Button>
<Link href="/settings">
<Button variant="ghost" size="icon">
<Settings className="h-5 w-5" />
</Button>
</Link>
</div>
</div>
{/* Team Health */}
<section>
<h2 className="text-lg font-semibold mb-4">Team Health</h2>
<TeamHealthCards
teams={overview?.health_status}
isLoading={loadingOverview}
/>
</section>
{/* CEO Approval Queue - Your primary action item */}
<section>
<CeoApprovalQueue />
</section>
{/* Metrics and Alerts Row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<KeyMetricsPanel
metrics={overview?.key_metrics}
isLoading={loadingOverview}
/>
<AuditorAlertsPanel alerts={flags} isLoading={loadingFlags} />
</div>
{/* Blockers and Activity Row */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<ActiveBlockersPanel tasks={tasks} isLoading={loadingTasks} />
<RecentActivityFeed
activities={activity as Activity[] | undefined}
isLoading={loadingActivity}
/>
</div>
{/* Quick Actions */}
<section className="pt-4 border-t">
<h2 className="text-lg font-semibold mb-4">Quick Actions</h2>
<QuickActionsBar />
</section>
</div>
);
}
@@ -0,0 +1,38 @@
"use client";
interface HealthIndicatorProps {
status: "ok" | "slow" | "critical";
size?: "sm" | "md" | "lg";
}
const statusEmoji: Record<string, string> = {
ok: "\uD83D\uDFE2",
slow: "\uD83D\uDFE1",
critical: "\uD83D\uDD34",
};
const statusLabel: Record<string, string> = {
ok: "OK",
slow: "SLOW",
critical: "CRITICAL",
};
const statusColor: Record<string, string> = {
ok: "text-green-600",
slow: "text-yellow-600",
critical: "text-red-600",
};
export function HealthIndicator({ status, size = "md" }: HealthIndicatorProps) {
const sizeClasses = {
sm: "text-sm",
md: "text-base",
lg: "text-lg",
};
return (
<span className={`${sizeClasses[size]} ${statusColor[status]} font-medium`}>
{statusEmoji[status]} {statusLabel[status]}
</span>
);
}
+11
View File
@@ -0,0 +1,11 @@
export { CommandCenter } from "./command-center";
export { TeamHealthCards } from "./team-health-cards";
export { TeamHealthCard } from "./team-health-card";
export { KeyMetricsPanel } from "./key-metrics-panel";
export { AuditorAlertsPanel } from "./auditor-alerts-panel";
export { ActiveBlockersPanel } from "./active-blockers-panel";
export { RecentActivityFeed } from "./recent-activity-feed";
export { ActivityItem } from "./activity-item";
export { QuickActionsBar } from "./quick-actions-bar";
export { HealthIndicator } from "./health-indicator";
export { CeoApprovalQueue } from "./ceo-approval-queue";
@@ -0,0 +1,91 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { TrendingUp, Clock, CheckCircle, Users, BarChart3 } from "lucide-react";
interface KeyMetricsProps {
metrics: Record<string, unknown> | undefined;
isLoading: boolean;
}
interface MetricItem {
key: string;
label: string;
icon: React.ReactNode;
format?: (value: number) => string;
}
const METRIC_CONFIG: MetricItem[] = [
{
key: "velocity_24h",
label: "Velocity (24h)",
icon: <TrendingUp className="h-4 w-4" />,
format: (v) => `${v} tasks`,
},
{
key: "velocity_7d",
label: "Velocity (7d)",
icon: <BarChart3 className="h-4 w-4" />,
format: (v) => `${v} tasks`,
},
{
key: "completion_rate",
label: "Completion Rate",
icon: <CheckCircle className="h-4 w-4" />,
format: (v) => `${Math.round(v * 100)}%`,
},
{
key: "avg_time_to_done",
label: "Avg. Time to Done",
icon: <Clock className="h-4 w-4" />,
format: (v) => `${(typeof v === "number" ? v : 0).toFixed(1)}h`,
},
{
key: "active_agents",
label: "Active Agents",
icon: <Users className="h-4 w-4" />,
format: (v) => `${v}`,
},
];
export function KeyMetricsPanel({ metrics, isLoading }: KeyMetricsProps) {
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-lg">Key Metrics</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-3">
{METRIC_CONFIG.map((m) => (
<Skeleton key={m.key} className="h-6" />
))}
</div>
) : (
<div className="space-y-3">
{METRIC_CONFIG.map((m) => {
const rawValue = metrics?.[m.key];
const value = typeof rawValue === "number" ? rawValue : null;
return (
<div key={m.key} className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
{m.icon}
{m.label}
</div>
<span className="font-medium">
{value != null
? m.format
? m.format(value)
: value
: "-"}
</span>
</div>
);
})}
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,42 @@
"use client";
import { Button } from "@/components/ui/button";
import { CreateTaskDialog } from "@/components/tasks/create-task-dialog";
import { Users, Megaphone, BookOpen, Shield } from "lucide-react";
import Link from "next/link";
export function QuickActionsBar() {
return (
<div className="flex flex-wrap gap-3">
<CreateTaskDialog />
<Link href="/agents">
<Button variant="outline">
<Users className="h-4 w-4 mr-2" />
Spawn Agent
</Button>
</Link>
<Link href="/communications">
<Button variant="outline">
<Megaphone className="h-4 w-4 mr-2" />
Broadcast Message
</Button>
</Link>
<Link href="/journals">
<Button variant="outline">
<BookOpen className="h-4 w-4 mr-2" />
View Journals
</Button>
</Link>
<Link href="/auditor">
<Button variant="outline">
<Shield className="h-4 w-4 mr-2" />
Auditor Report
</Button>
</Link>
</div>
);
}
@@ -0,0 +1,57 @@
"use client";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { ScrollArea } from "@/components/ui/scroll-area";
import { Activity as ActivityIcon, ArrowRight } from "lucide-react";
import { ActivityItem, Activity } from "./activity-item";
import Link from "next/link";
interface RecentActivityFeedProps {
activities: Activity[] | undefined;
isLoading: boolean;
}
export function RecentActivityFeed({ activities, isLoading }: RecentActivityFeedProps) {
return (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<ActivityIcon className="h-5 w-5" />
Recent Activity
</CardTitle>
</CardHeader>
<CardContent>
{isLoading ? (
<div className="space-y-3">
{[...Array(5)].map((_, i) => (
<Skeleton key={i} className="h-12" />
))}
</div>
) : !activities || activities.length === 0 ? (
<div className="text-center py-4 text-muted-foreground text-sm">
<ActivityIcon className="h-8 w-8 mx-auto mb-2 opacity-50" />
No recent activity
</div>
) : (
<ScrollArea className="h-[280px] pr-4">
<div className="divide-y">
{activities.slice(0, 10).map((activity) => (
<ActivityItem key={activity.id} activity={activity} />
))}
</div>
</ScrollArea>
)}
<div className="mt-4 pt-3 border-t">
<Link href="/notifications">
<Button variant="ghost" size="sm" className="w-full">
View Full Activity
<ArrowRight className="h-4 w-4 ml-2" />
</Button>
</Link>
</div>
</CardContent>
</Card>
);
}
@@ -0,0 +1,67 @@
"use client";
import { TeamHealth } from "@/types";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { HealthIndicator } from "./health-indicator";
import { Users, AlertTriangle, TrendingUp } from "lucide-react";
interface TeamHealthCardProps {
health: TeamHealth;
}
export function TeamHealthCard({ health }: TeamHealthCardProps) {
const teamName = health.team.replace(/_/g, " ");
return (
<Card className="hover:shadow-md transition-shadow">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-lg capitalize">{teamName}</CardTitle>
<HealthIndicator status={health.status} size="sm" />
</div>
</CardHeader>
<CardContent className="space-y-3">
{/* Active Tasks */}
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2 text-muted-foreground">
<Users className="h-4 w-4" />
Active
</div>
<span className="font-medium">{health.active_tasks}</span>
</div>
{/* Blocked Tasks */}
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2 text-muted-foreground">
<AlertTriangle className="h-4 w-4" />
Blocked
</div>
<span className={`font-medium ${health.blocked_tasks > 0 ? "text-red-600" : ""}`}>
{health.blocked_tasks}
</span>
</div>
{/* Completed This Week */}
<div className="flex items-center justify-between text-sm">
<div className="flex items-center gap-2 text-muted-foreground">
<TrendingUp className="h-4 w-4" />
Completed (7d)
</div>
<span className="font-medium">{health.completed_this_week}</span>
</div>
{/* Blocked Ratio */}
{health.blocked_ratio > 0 && (
<div className="pt-2 border-t">
<Badge
variant={health.blocked_ratio > 0.3 ? "destructive" : health.blocked_ratio > 0.1 ? "secondary" : "outline"}
>
{Math.round(health.blocked_ratio * 100)}% blocked
</Badge>
</div>
)}
</CardContent>
</Card>
);
}
@@ -0,0 +1,38 @@
"use client";
import { TeamHealth } from "@/types";
import { TeamHealthCard } from "./team-health-card";
import { Skeleton } from "@/components/ui/skeleton";
interface TeamHealthCardsProps {
teams: TeamHealth[] | undefined;
isLoading: boolean;
}
export function TeamHealthCards({ teams, isLoading }: TeamHealthCardsProps) {
if (isLoading) {
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{[...Array(4)].map((_, i) => (
<Skeleton key={i} className="h-48" />
))}
</div>
);
}
if (!teams || teams.length === 0) {
return (
<div className="text-center py-8 text-muted-foreground">
No team health data available
</div>
);
}
return (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
{teams.map((health) => (
<TeamHealthCard key={health.team} health={health} />
))}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More