I mean, it's at a good place rn...
@@ -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
|
||||
@@ -82,6 +82,7 @@ alembic/versions/*.pyc
|
||||
/data
|
||||
/#recycle
|
||||
.OLD/
|
||||
logos/
|
||||
|
||||
# Panel (Next.js frontend)
|
||||
panel/node_modules/
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; 2–3 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
|
||||
|
||||
@@ -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
|
||||
|
||||
```
|
||||
|
||||
@@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
```
|
||||
|
||||
@@ -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
|
||||
|
||||
```
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 []
|
||||
|
||||
@@ -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"]
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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": {}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
"@tailwindcss/postcss": {},
|
||||
},
|
||||
};
|
||||
|
||||
export default config;
|
||||
@@ -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 |
@@ -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 |
@@ -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 |
|
After Width: | Height: | Size: 214 KiB |
|
After Width: | Height: | Size: 436 KiB |
@@ -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 |
@@ -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 "Resolve Wait" 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>
|
||||
);
|
||||
}
|
||||
@@ -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're looking for doesn'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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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 />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
After Width: | Height: | Size: 436 KiB |
@@ -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;
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 214 KiB |
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function Home() {
|
||||
redirect("/overview");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||