Adopt design comps: dark system, new landing/auth, dashboard shell + canvas
Re-skins the whole app to the dark design comps and wires the new surfaces to
the backend (the /api proxy + auth + schemas are unchanged).
Design system:
- globals.css: remapped @theme tokens to the comp palette (#08080a base, coral
#ff6f61, status cyan/green/amber/purple/teal); token names preserved
- MeshMark: triangle + 3-node brand glyph; cm-flow/cm-blink/cm-halo keyframes
- marketing flipped light → dark
Backend (migration 0012):
- agents.model_binding (persisted on team deploy) + GET /api/claws/{id}/runtime-config
- routine_runs table + scheduler journaling + GET /api/routines/runs
- GET /api/claws/{id}/compartments (anatomy aggregate)
- GET /api/structure/stats (workspace counts)
Frontend:
- Landing: full dark marketing page (hero constellation, deploy ladder,
12-topology taxonomy, recursive execution, compare/Pareto, safety, self-host)
- Auth: dark split-panel AuthShell + comp LoginForm + Clerk SignIn themed dark
- Dashboard shell: TopBar (breadcrumb + live stats + deploy + user) + StatusBar
(runner/sandbox/doors); rail slimmed to 60px + 252px context column
- ConstellationCanvas (radial recursive) replaces the graph view in StructureCanvas;
selecting a claw opens ComputerPanel (apps/now-running/dock); RoutinesPanel
- Claw anatomy view (/claws/[id]/anatomy) from compartments + runtime-config
Co-Authored-By: Claude Opus 4.8 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
3eca4ed70c
commit
540c74f42e
+22
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT count(*) AS n FROM topology_runs\n WHERE workspace_id = $1 AND status IN ('queued', 'running')",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "n",
|
||||||
|
"type_info": "Int8"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
null
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "10b74d224745153bc8093b36c839afe6f9ce0ed6e1b57234f1446c54d159dc76"
|
||||||
|
}
|
||||||
+53
@@ -0,0 +1,53 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT id, routine_id, status, started_at, completed_at, error\n FROM routine_runs WHERE routine_id = $1\n ORDER BY started_at DESC LIMIT $2",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "routine_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "status",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "started_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "completed_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "error",
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Int8"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "13adc241216cf3dbe14fa7a354d8f35555a3f523ea44915b299b942ff91311a6"
|
||||||
|
}
|
||||||
+59
@@ -0,0 +1,59 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT rr.id, rr.routine_id, r.name AS routine_name, rr.status,\n rr.started_at, rr.completed_at, rr.error\n FROM routine_runs rr\n JOIN routines r ON r.id = rr.routine_id\n JOIN agents a ON a.id = r.agent_id\n WHERE a.workspace_id = $1\n ORDER BY rr.started_at DESC LIMIT $2",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 1,
|
||||||
|
"name": "routine_id",
|
||||||
|
"type_info": "Uuid"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 2,
|
||||||
|
"name": "routine_name",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 3,
|
||||||
|
"name": "status",
|
||||||
|
"type_info": "Text"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 4,
|
||||||
|
"name": "started_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 5,
|
||||||
|
"name": "completed_at",
|
||||||
|
"type_info": "Timestamptz"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"ordinal": 6,
|
||||||
|
"name": "error",
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Int8"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
false,
|
||||||
|
true,
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "1fc9514989fca5b73858151e4b5382de4577a102c381732a37ccce25af8216f0"
|
||||||
|
}
|
||||||
+22
@@ -0,0 +1,22 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "SELECT model_binding FROM agents WHERE id = $1 AND deleted_at IS NULL",
|
||||||
|
"describe": {
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"ordinal": 0,
|
||||||
|
"name": "model_binding",
|
||||||
|
"type_info": "Text"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": [
|
||||||
|
true
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"hash": "69fcf84df6a78dff7a6b77c16a000109719a186fa4603ccbcdd3ed4fbef67adc"
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "INSERT INTO routine_runs (id, routine_id) VALUES ($1, $2)",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Uuid"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "9f1591df289525680a349ec50b187270406abec8f6641202cd6f2a62d0a78910"
|
||||||
|
}
|
||||||
+15
@@ -0,0 +1,15 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "UPDATE agents SET model_binding = $2 WHERE id = $1 AND deleted_at IS NULL",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "ab03cd7cc21106a9ec86d1cc0aee9761c82e6ccd8a01bd0d326c61d57147f459"
|
||||||
|
}
|
||||||
+16
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"db_name": "PostgreSQL",
|
||||||
|
"query": "UPDATE routine_runs SET status = $2, completed_at = now(), error = $3 WHERE id = $1",
|
||||||
|
"describe": {
|
||||||
|
"columns": [],
|
||||||
|
"parameters": {
|
||||||
|
"Left": [
|
||||||
|
"Uuid",
|
||||||
|
"Text",
|
||||||
|
"Text"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"nullable": []
|
||||||
|
},
|
||||||
|
"hash": "f15168fa16b14adae75436d359daeec766e892e315aa68c0661ad6b96dfdfe66"
|
||||||
|
}
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# Clawmates
|
||||||
|
|
||||||
|
**Deploy agents at any scale — a single claw, a team, a company, or a whole org — and run a task across
|
||||||
|
the organizational *topology* that fits it.**
|
||||||
|
|
||||||
|
Clawmates is a multi-agent platform where every unit of work is a **topology**: a graph of role-slots
|
||||||
|
bound to real AI agents ("claws"). The same model nests recursively — a team is a topology of claws, a
|
||||||
|
company is a topology of teams, an org is a topology of companies — so you compose and run agentic
|
||||||
|
systems from one agent up to an entire organization. A safety invariant runs through all of it:
|
||||||
|
**authority is topology-invariant** — no choice of structure can let an agent exceed its sandbox (spec
|
||||||
|
§15).
|
||||||
|
|
||||||
|
Live at **[clawmates.work](https://clawmates.work)**.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
- **The deploy ladder — single → team → company → org.** Pick a scale; each rung instantiates a baseline
|
||||||
|
topology and binds it to real, individually-chattable claws. Higher rungs *compose* the rung below:
|
||||||
|
a company is staffed with teams, an org with companies.
|
||||||
|
- **Recursive execution.** Running a parent runs each child's whole sub-topology, all the way down to the
|
||||||
|
leaf claws — on a **durable, crash-resumable** runner (checkpointed per step, with cancellation).
|
||||||
|
- **12 organizational topologies.** Hierarchical, pipeline, swarm, mesh, debate, hub-spoke, star-MoE,
|
||||||
|
market, ring, flat, holacratic, blackboard — over five execution patterns.
|
||||||
|
- **Multi-topology comparison + evolution.** Run one task across many topologies and get a quality/cost
|
||||||
|
**Pareto front**; a MAP-Elites search evolves better (kind × team-size) configurations using the
|
||||||
|
comparison harness as fitness.
|
||||||
|
- **Recursive zoom canvas.** One view for every tier: click a node to drill down (org→company→team→claw),
|
||||||
|
breadcrumb to zoom back up.
|
||||||
|
- **§15 safety by construction.** Agents run tool-free in network-isolated sandboxes; every
|
||||||
|
sandbox-leaving action is a gated, human-approvable "door" tool. A secret broker holds credentials that
|
||||||
|
never reach agent code, and an allow-listed Docker socket caps blast radius.
|
||||||
|
- **Heterogeneous models.** Bind any node to a different backend (Claude, Gemini, Groq, GLM, Kimi).
|
||||||
|
- **Self-hostable.** A single-node Docker Compose deployment runs the whole platform with the same
|
||||||
|
network-segmented security model as the Kubernetes path.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
A Rust workspace (the platform) + a Next.js app (the web UI).
|
||||||
|
|
||||||
|
**Backend — Rust workspace (`crates/`):**
|
||||||
|
|
||||||
|
| Crate | Role |
|
||||||
|
|---|---|
|
||||||
|
| `cm-topology` | Topology data model: 12-kind taxonomy, graph, classifier, per-kind builders + heuristics |
|
||||||
|
| `cm-orchestrator` | Execution engine: async control-flow over a generic `TurnExecutor`; planners, comparison harness, evolution |
|
||||||
|
| `cm-runtime` | The §15-safe per-tenant agent runtime |
|
||||||
|
| `cm-api` | REST/SSE API + streaming gateway + recursive tier execution + the MCP "door" |
|
||||||
|
| `cm-db` | Postgres persistence (sqlx, offline-checked) |
|
||||||
|
| `cm-llm` | Provider abstraction over the model backends |
|
||||||
|
| `cm-secrets` / `clawmates-broker` | The secret broker — credentials never leave it |
|
||||||
|
| `cm-sandbox` / `cm-safety` | Sandbox provisioning + the §15 approval/gating model |
|
||||||
|
| `cm-auth`, `cm-billing`, `cm-files`, `cm-scheduler`, `cm-config`, `cm-domain`, `cm-telemetry` | Supporting services |
|
||||||
|
| `clawmates-server` | The single server binary (API + gateway + runtime + scheduler) |
|
||||||
|
|
||||||
|
**Frontend (`frontend/`):** Next.js 16, React 19, Tailwind v4 — a two-tier rail (structure + context),
|
||||||
|
the recursive zoom canvas, and the deploy wizards. Talks to the backend through a same-origin `/api`
|
||||||
|
proxy that swaps the session for a bearer token and streams SSE.
|
||||||
|
|
||||||
|
**Data plane:** Postgres, with the server self-migrating on boot.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Quickstart
|
||||||
|
|
||||||
|
The fastest path is the single-node Docker Compose deployment.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd deploy/compose
|
||||||
|
cp .env.example .env
|
||||||
|
# Edit .env: set POSTGRES_PASSWORD and CLAWMATES_BOOTSTRAP_OWNER_PASSWORD.
|
||||||
|
|
||||||
|
docker compose build # or load a signed release bundle (deploy/airgapped/install.sh)
|
||||||
|
docker compose up -d
|
||||||
|
```
|
||||||
|
|
||||||
|
The server self-migrates and, if `CLAWMATES_BOOTSTRAP_OWNER_PASSWORD` is set, provisions the first
|
||||||
|
Owner + workspace on first boot. Then:
|
||||||
|
|
||||||
|
- **App** → http://localhost:3000 (sign in with the bootstrap owner)
|
||||||
|
- **API health** → http://localhost:8080/healthz
|
||||||
|
|
||||||
|
By default it runs a local model endpoint (`openai_compat`, point `[llm].base_url` at vLLM/Ollama/
|
||||||
|
llama.cpp); set `provider = "anthropic"` + `ANTHROPIC_API_KEY` to use Claude. Auth is `local` by default
|
||||||
|
or `clerk` at runtime. See [`deploy/compose/README.md`](deploy/compose/README.md) for all knobs and the
|
||||||
|
broker master-key backup step.
|
||||||
|
|
||||||
|
**Local development:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Backend (needs a Postgres; sqlx is offline-checked against .sqlx/)
|
||||||
|
cargo build
|
||||||
|
cargo test
|
||||||
|
cargo clippy --all-targets
|
||||||
|
|
||||||
|
# Regenerate the sqlx cache after changing any query!:
|
||||||
|
# DATABASE_URL=… cargo sqlx prepare --workspace
|
||||||
|
|
||||||
|
# Frontend
|
||||||
|
cd frontend
|
||||||
|
npm install
|
||||||
|
npm run dev # also: npm run lint / npm run typecheck / npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the reproducible topology benchmark (offline-deterministic; real models with `ANTHROPIC_API_KEY`):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo run -p cm-orchestrator --example topology_bench --features provider
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Roadmap
|
||||||
|
|
||||||
|
**Shipped**
|
||||||
|
- ✅ Pure-Rust topology engine — model → classify → build (all 12 kinds) → execute → compare (Pareto) →
|
||||||
|
workflow → LLM-judge → evolve.
|
||||||
|
- ✅ Topologies UI: catalog browser, builder/visualizer, multi-topology comparison with a Pareto scatter.
|
||||||
|
- ✅ Durable topology runs — crash-resumable, checkpointed per step, cancellable, with live SSE.
|
||||||
|
- ✅ **The full deploy ladder** — single → team → company → org, with recursive execution down to the
|
||||||
|
leaf claws and a recursive zoom canvas + two-tier navigation.
|
||||||
|
- ✅ §15 safety: tool-free sandboxes, the gated MCP "door" (with real email/Slack delivery), the secret
|
||||||
|
broker, allow-listed Docker socket.
|
||||||
|
- ✅ Self-host: single-node Docker Compose with full network segmentation.
|
||||||
|
|
||||||
|
**Next**
|
||||||
|
- Team / company templates as first-class saved catalogs (compose orgs from reusable building blocks).
|
||||||
|
- Per-leaf nested checkpoint resume (today the recursive runner resumes at parent-node granularity).
|
||||||
|
- Persona injection into runtime turns (beyond role-driven prompting).
|
||||||
|
- Richer per-tier dashboards (company coordination, org portfolio/governance metrics).
|
||||||
|
- Group lifecycle management (delete/edit a deployed team/company/org; deprovision its agents).
|
||||||
|
|
||||||
|
**Research**
|
||||||
|
- The accompanying paper, *Large Dynamic Agentic Topologies* (`papers/dynamic-agentic-topologies.md`):
|
||||||
|
the topology-invariant authority result, the comparison/Pareto methodology, and benchmark results.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Safety
|
||||||
|
|
||||||
|
The network segmentation **is** the security model. Agent sandboxes run with no network at all; only the
|
||||||
|
browser container has egress. The secret broker is reachable only over a private socket and credentials
|
||||||
|
never enter agent code. The server reaches Docker through an allow-listed socket proxy that can manage
|
||||||
|
sandbox containers and nothing else. Every sandbox-leaving action is gated behind a human approval. No
|
||||||
|
topology — and no switch between topologies — can bypass any of this.
|
||||||
@@ -93,6 +93,14 @@ pub fn router(state: AppState) -> Router {
|
|||||||
"/api/claws/{id}/access",
|
"/api/claws/{id}/access",
|
||||||
axum::routing::put(routes::claws::set_access),
|
axum::routing::put(routes::claws::set_access),
|
||||||
)
|
)
|
||||||
|
.route(
|
||||||
|
"/api/claws/{id}/runtime-config",
|
||||||
|
get(routes::claws::runtime_config),
|
||||||
|
)
|
||||||
|
.route(
|
||||||
|
"/api/claws/{id}/compartments",
|
||||||
|
get(routes::claws::compartments),
|
||||||
|
)
|
||||||
.route("/api/claw-chat/threads", get(routes::claw_chat::threads))
|
.route("/api/claw-chat/threads", get(routes::claw_chat::threads))
|
||||||
.route("/api/claw-chat/messages", get(routes::claw_chat::messages))
|
.route("/api/claw-chat/messages", get(routes::claw_chat::messages))
|
||||||
.route(
|
.route(
|
||||||
@@ -105,6 +113,7 @@ pub fn router(state: AppState) -> Router {
|
|||||||
.route("/api/gateway", post(routes::gateway::gateway))
|
.route("/api/gateway", post(routes::gateway::gateway))
|
||||||
.route("/api/routines", get(routes::routines::list))
|
.route("/api/routines", get(routes::routines::list))
|
||||||
.route("/api/routines", post(routes::routines::create))
|
.route("/api/routines", post(routes::routines::create))
|
||||||
|
.route("/api/routines/runs", get(routes::routines::runs))
|
||||||
.route("/api/skills", get(routes::skills::list))
|
.route("/api/skills", get(routes::skills::list))
|
||||||
.route("/api/skills/install", post(routes::skills::install))
|
.route("/api/skills/install", post(routes::skills::install))
|
||||||
.route("/api/skills/uninstall", post(routes::skills::uninstall))
|
.route("/api/skills/uninstall", post(routes::skills::uninstall))
|
||||||
@@ -173,6 +182,7 @@ pub fn router(state: AppState) -> Router {
|
|||||||
)
|
)
|
||||||
.route("/api/orgs/{id}", get(routes::orgs::get_org))
|
.route("/api/orgs/{id}", get(routes::orgs::get_org))
|
||||||
.route("/api/orgs/{id}/run", post(routes::orgs::run_org))
|
.route("/api/orgs/{id}/run", post(routes::orgs::run_org))
|
||||||
|
.route("/api/structure/stats", get(routes::structure::stats))
|
||||||
.route("/api/structure/{level}/{id}", get(routes::structure::node))
|
.route("/api/structure/{level}/{id}", get(routes::structure::node))
|
||||||
.route("/api/topology-runs", get(routes::topology::list_runs))
|
.route("/api/topology-runs", get(routes::topology::list_runs))
|
||||||
.route("/api/topology-runs/{id}", get(routes::topology::get_run))
|
.route("/api/topology-runs/{id}", get(routes::topology::get_run))
|
||||||
|
|||||||
@@ -3,9 +3,10 @@ use axum::http::StatusCode;
|
|||||||
use axum::Json;
|
use axum::Json;
|
||||||
use cm_db::repo::audit::Actor;
|
use cm_db::repo::audit::Actor;
|
||||||
use cm_domain::{AccessPolicy, Agent, AgentId, AgentStatus};
|
use cm_domain::{AccessPolicy, Agent, AgentId, AgentStatus};
|
||||||
use serde::Deserialize;
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::{json, Value};
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
|
use crate::runtime_provision::provider_alias_for;
|
||||||
use crate::{ApiError, AppState, Authed};
|
use crate::{ApiError, AppState, Authed};
|
||||||
|
|
||||||
/// Loads an agent and enforces tenant isolation: agents in other workspaces
|
/// Loads an agent and enforces tenant isolation: agents in other workspaces
|
||||||
@@ -22,6 +23,104 @@ pub(crate) async fn workspace_agent(
|
|||||||
Ok(agent)
|
Ok(agent)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `GET /api/claws/{id}/runtime-config` — the claw's model + §15 sandbox facts
|
||||||
|
/// (for the claw card / anatomy view's model badge).
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct RuntimeConfig {
|
||||||
|
pub model: Option<String>,
|
||||||
|
pub provider_alias: String,
|
||||||
|
pub sandbox_enabled: bool,
|
||||||
|
pub network_allowed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn runtime_config(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authed(user): Authed,
|
||||||
|
Path(id): Path<AgentId>,
|
||||||
|
) -> Result<Json<RuntimeConfig>, ApiError> {
|
||||||
|
let agent = workspace_agent(&state, &user, id).await?;
|
||||||
|
let model = cm_db::repo::agents::model_binding(&state.pool, agent.id).await?;
|
||||||
|
let provider_alias = provider_alias_for(model.as_deref().unwrap_or("claude")).to_string();
|
||||||
|
Ok(Json(RuntimeConfig {
|
||||||
|
model,
|
||||||
|
provider_alias,
|
||||||
|
// Claws are provisioned tool-free in network-isolated sandboxes (§15).
|
||||||
|
sandbox_enabled: true,
|
||||||
|
network_allowed: false,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One "anatomy" compartment of a claw (skills / personality / memory / tools /
|
||||||
|
/// capabilities / safety), aggregated from existing data.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct Compartment {
|
||||||
|
pub key: String,
|
||||||
|
pub label: String,
|
||||||
|
pub items: Vec<String>,
|
||||||
|
pub count: Option<i64>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/claws/{id}/compartments` — the claw anatomy view.
|
||||||
|
pub async fn compartments(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authed(user): Authed,
|
||||||
|
Path(id): Path<AgentId>,
|
||||||
|
) -> Result<Json<Vec<Compartment>>, ApiError> {
|
||||||
|
let agent = workspace_agent(&state, &user, id).await?;
|
||||||
|
let skills = cm_db::repo::skills::installed(&state.pool, agent.id).await?;
|
||||||
|
let personality = if agent.system_prompt.trim().is_empty() {
|
||||||
|
vec![]
|
||||||
|
} else {
|
||||||
|
vec![agent.system_prompt.clone()]
|
||||||
|
};
|
||||||
|
let out = vec![
|
||||||
|
Compartment {
|
||||||
|
key: "skills".into(),
|
||||||
|
label: "Skills".into(),
|
||||||
|
items: skills.iter().map(|s| s.title.clone()).collect(),
|
||||||
|
count: Some(skills.len() as i64),
|
||||||
|
},
|
||||||
|
Compartment {
|
||||||
|
key: "personality".into(),
|
||||||
|
label: "Personality".into(),
|
||||||
|
items: personality,
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
Compartment {
|
||||||
|
key: "memory".into(),
|
||||||
|
label: "Memory".into(),
|
||||||
|
items: vec![],
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
Compartment {
|
||||||
|
// The §15 "door": email/slack are gated MCP tools, browser gated,
|
||||||
|
// shell blocked (claws are tool-free in the sandbox).
|
||||||
|
key: "tools".into(),
|
||||||
|
label: "Tools · Doors".into(),
|
||||||
|
items: vec![
|
||||||
|
"Email · gated".into(),
|
||||||
|
"Slack · gated".into(),
|
||||||
|
"Browser · gated".into(),
|
||||||
|
"Shell · blocked".into(),
|
||||||
|
],
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
Compartment {
|
||||||
|
key: "capabilities".into(),
|
||||||
|
label: "Capabilities".into(),
|
||||||
|
items: vec!["File management".into(), "Scheduling".into()],
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
Compartment {
|
||||||
|
key: "safety".into(),
|
||||||
|
label: "Safety · §15".into(),
|
||||||
|
items: vec!["Sandbox: isolated".into(), "Network: none".into()],
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
Ok(Json(out))
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct CreateClawRequest {
|
pub struct CreateClawRequest {
|
||||||
name: String,
|
name: String,
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ use axum::Json;
|
|||||||
use cm_db::repo::routines::Routine;
|
use cm_db::repo::routines::Routine;
|
||||||
use cm_domain::AgentId;
|
use cm_domain::AgentId;
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use serde_json::json;
|
use serde_json::{json, Value};
|
||||||
|
|
||||||
use crate::routes::claws::workspace_agent;
|
use crate::routes::claws::workspace_agent;
|
||||||
use crate::{ApiError, AppState, Authed};
|
use crate::{ApiError, AppState, Authed};
|
||||||
@@ -56,3 +56,19 @@ pub async fn create(
|
|||||||
.await?;
|
.await?;
|
||||||
Ok((StatusCode::CREATED, Json(routine)))
|
Ok((StatusCode::CREATED, Json(routine)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// GET /api/routines/runs — recent routine firings across the workspace (the
|
||||||
|
/// routines panel's run history).
|
||||||
|
pub async fn runs(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authed(user): Authed,
|
||||||
|
) -> Result<Json<Vec<Value>>, ApiError> {
|
||||||
|
Ok(Json(
|
||||||
|
cm_db::repo::routine_runs::recent_for_workspace(
|
||||||
|
&state.pool,
|
||||||
|
user.workspace_id.as_uuid(),
|
||||||
|
20,
|
||||||
|
)
|
||||||
|
.await?,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|||||||
@@ -44,6 +44,36 @@ pub struct StructureNode {
|
|||||||
pub children: Vec<StructureChild>,
|
pub children: Vec<StructureChild>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Workspace-wide hierarchy counts for the breadcrumb + status bar.
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct StructureStats {
|
||||||
|
pub org_count: usize,
|
||||||
|
pub company_count: usize,
|
||||||
|
pub team_count: usize,
|
||||||
|
pub claw_count: usize,
|
||||||
|
pub running_now: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `GET /api/structure/stats` — counts across the whole workspace hierarchy.
|
||||||
|
pub async fn stats(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Authed(user): Authed,
|
||||||
|
) -> Result<Json<StructureStats>, ApiError> {
|
||||||
|
let ws = user.workspace_id;
|
||||||
|
let orgs = cm_db::repo::orgs::list_for_workspace(&state.pool, ws, 1000).await?;
|
||||||
|
let companies = cm_db::repo::companies::list_for_workspace(&state.pool, ws, 1000).await?;
|
||||||
|
let teams = cm_db::repo::teams::list_for_workspace(&state.pool, ws, 1000).await?;
|
||||||
|
let claws = cm_db::repo::agents::roster(&state.pool, ws).await?;
|
||||||
|
let running_now = cm_db::repo::topology_runs::count_active(&state.pool, ws).await?;
|
||||||
|
Ok(Json(StructureStats {
|
||||||
|
org_count: orgs.len(),
|
||||||
|
company_count: companies.len(),
|
||||||
|
team_count: teams.len(),
|
||||||
|
claw_count: claws.len(),
|
||||||
|
running_now,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
/// `GET /api/structure/{level}/{id}` — one level of the recursive hierarchy.
|
/// `GET /api/structure/{level}/{id}` — one level of the recursive hierarchy.
|
||||||
pub async fn node(
|
pub async fn node(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
|
|||||||
@@ -84,6 +84,8 @@ pub async fn create_team(
|
|||||||
eprintln!("teams: provision claw {claw_id} failed: {e}");
|
eprintln!("teams: provision claw {claw_id} failed: {e}");
|
||||||
ApiError::Internal
|
ApiError::Internal
|
||||||
})?;
|
})?;
|
||||||
|
// Persist the model so the claw card / anatomy can show it later.
|
||||||
|
cm_db::repo::agents::set_model_binding(&state.pool, agent.id, &m.model).await?;
|
||||||
claw_ids.push(claw_id);
|
claw_ids.push(claw_id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -76,6 +76,34 @@ pub async fn get(pool: &PgPool, agent_id: AgentId) -> Result<Agent, DbError> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Persist the model a claw was deployed with (e.g. "claude", "gemini",
|
||||||
|
/// "glm-5.2") — the runtime config is otherwise the only record of it.
|
||||||
|
pub async fn set_model_binding(
|
||||||
|
pool: &PgPool,
|
||||||
|
agent_id: AgentId,
|
||||||
|
model: &str,
|
||||||
|
) -> Result<(), DbError> {
|
||||||
|
sqlx::query!(
|
||||||
|
"UPDATE agents SET model_binding = $2 WHERE id = $1 AND deleted_at IS NULL",
|
||||||
|
agent_id.as_uuid(),
|
||||||
|
model,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The persisted model binding for a claw, if any (NULL for pre-team claws).
|
||||||
|
pub async fn model_binding(pool: &PgPool, agent_id: AgentId) -> Result<Option<String>, DbError> {
|
||||||
|
let row = sqlx::query!(
|
||||||
|
"SELECT model_binding FROM agents WHERE id = $1 AND deleted_at IS NULL",
|
||||||
|
agent_id.as_uuid(),
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.model_binding)
|
||||||
|
}
|
||||||
|
|
||||||
/// Patch-style profile update (§7.7 Edit profile): only provided fields
|
/// Patch-style profile update (§7.7 Edit profile): only provided fields
|
||||||
/// change; the system prompt is the Job Description textarea verbatim.
|
/// change; the system prompt is the Job Description textarea verbatim.
|
||||||
#[allow(clippy::too_many_arguments)]
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ pub mod files;
|
|||||||
pub mod messages;
|
pub mod messages;
|
||||||
pub mod orgs;
|
pub mod orgs;
|
||||||
pub mod outbox;
|
pub mod outbox;
|
||||||
|
pub mod routine_runs;
|
||||||
pub mod routines;
|
pub mod routines;
|
||||||
pub mod run_events;
|
pub mod run_events;
|
||||||
pub mod runs;
|
pub mod runs;
|
||||||
|
|||||||
@@ -0,0 +1,109 @@
|
|||||||
|
//! Journal of routine firings — one row per execution, powering the dashboard
|
||||||
|
//! routines panel's run history + live ("now running") loops.
|
||||||
|
|
||||||
|
use serde_json::Value;
|
||||||
|
use sqlx::PgPool;
|
||||||
|
use time::OffsetDateTime;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::DbError;
|
||||||
|
|
||||||
|
/// One routine execution.
|
||||||
|
#[derive(Debug, Clone, serde::Serialize)]
|
||||||
|
pub struct RoutineRun {
|
||||||
|
pub id: Uuid,
|
||||||
|
pub routine_id: Uuid,
|
||||||
|
pub status: String, // running | ok | error
|
||||||
|
#[serde(with = "time::serde::rfc3339")]
|
||||||
|
pub started_at: OffsetDateTime,
|
||||||
|
#[serde(with = "time::serde::rfc3339::option")]
|
||||||
|
pub completed_at: Option<OffsetDateTime>,
|
||||||
|
pub error: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Record the start of a routine firing; returns the run id to finish later.
|
||||||
|
pub async fn start(pool: &PgPool, routine_id: Uuid) -> Result<Uuid, DbError> {
|
||||||
|
let id = Uuid::now_v7();
|
||||||
|
sqlx::query!(
|
||||||
|
"INSERT INTO routine_runs (id, routine_id) VALUES ($1, $2)",
|
||||||
|
id,
|
||||||
|
routine_id,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mark a routine run terminal (`ok`/`error`).
|
||||||
|
pub async fn finish(
|
||||||
|
pool: &PgPool,
|
||||||
|
id: Uuid,
|
||||||
|
status: &str,
|
||||||
|
error: Option<&str>,
|
||||||
|
) -> Result<(), DbError> {
|
||||||
|
sqlx::query!(
|
||||||
|
"UPDATE routine_runs SET status = $2, completed_at = now(), error = $3 WHERE id = $1",
|
||||||
|
id,
|
||||||
|
status,
|
||||||
|
error,
|
||||||
|
)
|
||||||
|
.execute(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recent runs for one routine, newest first.
|
||||||
|
pub async fn list_by_routine(
|
||||||
|
pool: &PgPool,
|
||||||
|
routine_id: Uuid,
|
||||||
|
limit: i64,
|
||||||
|
) -> Result<Vec<RoutineRun>, DbError> {
|
||||||
|
let rows = sqlx::query_as!(
|
||||||
|
RoutineRun,
|
||||||
|
r#"SELECT id, routine_id, status, started_at, completed_at, error
|
||||||
|
FROM routine_runs WHERE routine_id = $1
|
||||||
|
ORDER BY started_at DESC LIMIT $2"#,
|
||||||
|
routine_id,
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Recent runs across all of a workspace's routines (the panel's run history).
|
||||||
|
/// Joined through `routines` → `agents` to scope to the workspace. Each row also
|
||||||
|
/// carries its routine name for display.
|
||||||
|
pub async fn recent_for_workspace(
|
||||||
|
pool: &PgPool,
|
||||||
|
workspace_id: Uuid,
|
||||||
|
limit: i64,
|
||||||
|
) -> Result<Vec<Value>, DbError> {
|
||||||
|
let rows = sqlx::query!(
|
||||||
|
r#"SELECT rr.id, rr.routine_id, r.name AS routine_name, rr.status,
|
||||||
|
rr.started_at, rr.completed_at, rr.error
|
||||||
|
FROM routine_runs rr
|
||||||
|
JOIN routines r ON r.id = rr.routine_id
|
||||||
|
JOIN agents a ON a.id = r.agent_id
|
||||||
|
WHERE a.workspace_id = $1
|
||||||
|
ORDER BY rr.started_at DESC LIMIT $2"#,
|
||||||
|
workspace_id,
|
||||||
|
limit,
|
||||||
|
)
|
||||||
|
.fetch_all(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| {
|
||||||
|
serde_json::json!({
|
||||||
|
"id": r.id,
|
||||||
|
"routine_id": r.routine_id,
|
||||||
|
"routine_name": r.routine_name,
|
||||||
|
"status": r.status,
|
||||||
|
"started_at": r.started_at.format(&time::format_description::well_known::Rfc3339).unwrap_or_default(),
|
||||||
|
"completed_at": r.completed_at.and_then(|t| t.format(&time::format_description::well_known::Rfc3339).ok()),
|
||||||
|
"error": r.error,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
@@ -251,6 +251,19 @@ pub async fn requeue_stale(pool: &PgPool, older_than_secs: f64) -> Result<u64, D
|
|||||||
Ok(result.rows_affected())
|
Ok(result.rows_affected())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How many durable runs are currently `queued` or `running` for a workspace
|
||||||
|
/// (the dashboard "running now" / status-bar counter).
|
||||||
|
pub async fn count_active(pool: &PgPool, workspace_id: WorkspaceId) -> Result<i64, DbError> {
|
||||||
|
let row = sqlx::query!(
|
||||||
|
"SELECT count(*) AS n FROM topology_runs
|
||||||
|
WHERE workspace_id = $1 AND status IN ('queued', 'running')",
|
||||||
|
workspace_id.as_uuid(),
|
||||||
|
)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
Ok(row.n.unwrap_or(0))
|
||||||
|
}
|
||||||
|
|
||||||
/// The most recent runs for a workspace, newest first.
|
/// The most recent runs for a workspace, newest first.
|
||||||
pub async fn list_recent(
|
pub async fn list_recent(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
//! drives a REAL run through the runtime in the routine's dedicated
|
//! drives a REAL run through the runtime in the routine's dedicated
|
||||||
//! session — gated tools inside a routine still hit the approval queue.
|
//! session — gated tools inside a routine still hit the approval queue.
|
||||||
|
|
||||||
use cm_db::repo::{agents, routines, sessions};
|
use cm_db::repo::{agents, routine_runs, routines, sessions};
|
||||||
use cm_runtime::Runtime;
|
use cm_runtime::Runtime;
|
||||||
use sqlx::PgPool;
|
use sqlx::PgPool;
|
||||||
use time::OffsetDateTime;
|
use time::OffsetDateTime;
|
||||||
@@ -56,7 +56,16 @@ impl Scheduler {
|
|||||||
Some(existing) => existing,
|
Some(existing) => existing,
|
||||||
None => sessions::create(&self.pool, agent_id, agent.workspace_id, &title).await?,
|
None => sessions::create(&self.pool, agent_id, agent.workspace_id, &title).await?,
|
||||||
};
|
};
|
||||||
let _ = self.runtime.send_message(session.id, message).await;
|
// Journal the firing for the dashboard routines panel.
|
||||||
|
let run_id = routine_runs::start(&self.pool, routine.id).await.ok();
|
||||||
|
let res = self.runtime.send_message(session.id, message).await;
|
||||||
|
if let Some(rid) = run_id {
|
||||||
|
let (status, err) = match &res {
|
||||||
|
Ok(_) => ("ok", None),
|
||||||
|
Err(e) => ("error", Some(format!("{e}"))),
|
||||||
|
};
|
||||||
|
let _ = routine_runs::finish(&self.pool, rid, status, err.as_deref()).await;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Ok(due.len())
|
Ok(due.len())
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,14 @@
|
|||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
/* The marketing site is LIGHT — the inverse of the dark app. Scoped here
|
/* The marketing site now shares the app's DARK design system (the redesigned
|
||||||
via the `marketing` class so the app's dark tokens are untouched. */
|
comps are dark): near-black base, coral accent. */
|
||||||
export default function MarketingLayout({
|
export default function MarketingLayout({
|
||||||
children,
|
children,
|
||||||
}: {
|
}: {
|
||||||
children: ReactNode;
|
children: ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="marketing min-h-dvh overflow-x-hidden bg-white text-marketing-ink">
|
<div className="min-h-dvh overflow-x-hidden bg-background text-foreground">
|
||||||
{children}
|
{children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,39 +1,15 @@
|
|||||||
import type { Metadata } from "next";
|
import type { Metadata } from "next";
|
||||||
|
|
||||||
import { Faq } from "@/components/marketing/Faq";
|
import { Landing } from "@/components/marketing/Landing";
|
||||||
import { HierarchyDiagram } from "@/components/marketing/HierarchyDiagram";
|
|
||||||
import { MarketingNav } from "@/components/marketing/Nav";
|
|
||||||
import {
|
|
||||||
FinalCta,
|
|
||||||
Footer,
|
|
||||||
Hero,
|
|
||||||
Scale,
|
|
||||||
UseCases,
|
|
||||||
WhyGoverned,
|
|
||||||
} from "@/components/marketing/Sections";
|
|
||||||
|
|
||||||
export const metadata: Metadata = {
|
export const metadata: Metadata = {
|
||||||
title: "Clawmates — AI coworkers that ship work",
|
title: "Clawmates — deploy agents at any scale",
|
||||||
description:
|
description:
|
||||||
"Collaborative AI agents with a human-in-the-loop safety layer. Run them in the cloud or fully air-gapped on your own hardware.",
|
"Every unit of work is a topology — a graph of role-slots bound to real AI agents. Compose and run agentic systems from a single claw up to a whole org, on a durable, §15-safe runner.",
|
||||||
};
|
};
|
||||||
|
|
||||||
// The public marketing landing (served at "/" for logged-out visitors via
|
// The public marketing landing (served at "/" for logged-out visitors via the
|
||||||
// the middleware rewrite; authed users get the workspace app instead).
|
// middleware rewrite; authed users get the workspace app instead).
|
||||||
export default function MarketingPage() {
|
export default function MarketingPage() {
|
||||||
return (
|
return <Landing />;
|
||||||
<>
|
|
||||||
<MarketingNav />
|
|
||||||
<main>
|
|
||||||
<Hero />
|
|
||||||
<HierarchyDiagram />
|
|
||||||
<Scale />
|
|
||||||
<WhyGoverned />
|
|
||||||
<UseCases />
|
|
||||||
<Faq />
|
|
||||||
<FinalCta />
|
|
||||||
</main>
|
|
||||||
<Footer />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,10 @@
|
|||||||
|
import { ClawAnatomy } from "@/components/claw/ClawAnatomy";
|
||||||
|
|
||||||
|
export default async function ClawAnatomyPage({
|
||||||
|
params,
|
||||||
|
}: {
|
||||||
|
params: Promise<{ clawId: string }>;
|
||||||
|
}) {
|
||||||
|
const { clawId } = await params;
|
||||||
|
return <ClawAnatomy clawId={clawId} />;
|
||||||
|
}
|
||||||
@@ -1,13 +1,23 @@
|
|||||||
import { redirect } from "next/navigation";
|
import { redirect } from "next/navigation";
|
||||||
|
import { z } from "zod";
|
||||||
|
|
||||||
import { LeftRail } from "@/components/shell/LeftRail";
|
import { LeftRail } from "@/components/shell/LeftRail";
|
||||||
import { ApiAuthError } from "@/lib/api/http";
|
import { StatusBar } from "@/components/shell/StatusBar";
|
||||||
|
import { TopBar } from "@/components/shell/TopBar";
|
||||||
|
import { ApiAuthError, apiFetch } from "@/lib/api/http";
|
||||||
import { fetchClaws, fetchCredits, fetchMe } from "@/lib/api/team";
|
import { fetchClaws, fetchCredits, fetchMe } from "@/lib/api/team";
|
||||||
import { fetchCompanies, fetchOrgs, fetchTeams, type GroupSummary } from "@/lib/api/structure";
|
import {
|
||||||
|
fetchCompanies,
|
||||||
|
fetchOrgs,
|
||||||
|
fetchStats,
|
||||||
|
fetchTeams,
|
||||||
|
type GroupSummary,
|
||||||
|
type StructureStats,
|
||||||
|
} from "@/lib/api/structure";
|
||||||
import type { Agent, Credits, User } from "@/lib/api/schemas";
|
import type { Agent, Credits, User } from "@/lib/api/schemas";
|
||||||
|
|
||||||
// Every workspace page shares the persistent rail (§4). This layout reads
|
// Every workspace page shares the persistent shell (top bar + structure rail +
|
||||||
// only path-level data — search params stay client-side (see panel-params).
|
// context column + status bar). The layout server-fetches the chrome data once.
|
||||||
export default async function WorkspaceLayout({
|
export default async function WorkspaceLayout({
|
||||||
children,
|
children,
|
||||||
}: Readonly<{ children: React.ReactNode }>) {
|
}: Readonly<{ children: React.ReactNode }>) {
|
||||||
@@ -17,16 +27,24 @@ export default async function WorkspaceLayout({
|
|||||||
let orgs: GroupSummary[];
|
let orgs: GroupSummary[];
|
||||||
let companies: GroupSummary[];
|
let companies: GroupSummary[];
|
||||||
let teams: GroupSummary[];
|
let teams: GroupSummary[];
|
||||||
|
let stats: StructureStats | null;
|
||||||
|
let doors: number;
|
||||||
try {
|
try {
|
||||||
// Structure lists are non-critical chrome — never let one fail the shell.
|
// Non-critical chrome — never let one failed fetch break the shell.
|
||||||
|
const opt = <T,>(p: Promise<T>, fallback: T) => p.catch(() => fallback);
|
||||||
const groups = <T,>(p: Promise<T[]>) => p.catch(() => [] as T[]);
|
const groups = <T,>(p: Promise<T[]>) => p.catch(() => [] as T[]);
|
||||||
[user, roster, credits, orgs, companies, teams] = await Promise.all([
|
[user, roster, credits, orgs, companies, teams, stats, doors] = await Promise.all([
|
||||||
fetchMe(),
|
fetchMe(),
|
||||||
fetchClaws(),
|
fetchClaws(),
|
||||||
fetchCredits(),
|
fetchCredits(),
|
||||||
groups(fetchOrgs()),
|
groups(fetchOrgs()),
|
||||||
groups(fetchCompanies()),
|
groups(fetchCompanies()),
|
||||||
groups(fetchTeams()),
|
groups(fetchTeams()),
|
||||||
|
opt(fetchStats(), null),
|
||||||
|
opt(
|
||||||
|
apiFetch(z.array(z.unknown()), "/api/approvals").then((a) => a.length),
|
||||||
|
0,
|
||||||
|
),
|
||||||
]);
|
]);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof ApiAuthError) {
|
if (error instanceof ApiAuthError) {
|
||||||
@@ -35,16 +53,19 @@ export default async function WorkspaceLayout({
|
|||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<div className="flex min-h-dvh">
|
<div className="flex h-dvh flex-col">
|
||||||
|
<TopBar user={user} stats={stats} />
|
||||||
|
<div className="flex min-h-0 flex-1">
|
||||||
<LeftRail
|
<LeftRail
|
||||||
user={user}
|
|
||||||
roster={roster}
|
roster={roster}
|
||||||
orgs={orgs}
|
orgs={orgs}
|
||||||
companies={companies}
|
companies={companies}
|
||||||
teams={teams}
|
teams={teams}
|
||||||
creditsBalance={credits.available}
|
creditsBalance={credits.available}
|
||||||
/>
|
/>
|
||||||
<main className="min-w-0 flex-1">{children}</main>
|
<main className="min-w-0 flex-1 overflow-y-auto">{children}</main>
|
||||||
|
</div>
|
||||||
|
<StatusBar stats={stats} doors={doors} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,30 +46,32 @@
|
|||||||
popups, autofill) renders dark instead of the browser's light default. */
|
popups, autofill) renders dark instead of the browser's light default. */
|
||||||
color-scheme: dark;
|
color-scheme: dark;
|
||||||
|
|
||||||
--background: #0a0a0a;
|
/* New design-comp palette (Landing/Auth/Dashboard/Admin): near-black base,
|
||||||
--foreground: #fafafa;
|
coral accent, cool status hues. */
|
||||||
--card: #0a0a0a;
|
--background: #08080a;
|
||||||
--card-foreground: #fafafa;
|
--foreground: #f3f3f5;
|
||||||
--popover: #0a0a0a;
|
--card: #0b0b0e;
|
||||||
--popover-foreground: #fafafa;
|
--card-foreground: #f3f3f5;
|
||||||
--primary: #fafafa;
|
--popover: #0f0f13;
|
||||||
--primary-foreground: #171717;
|
--popover-foreground: #f3f3f5;
|
||||||
--secondary: #262626;
|
--primary: #f3f3f5;
|
||||||
--secondary-foreground: #fafafa;
|
--primary-foreground: #08080a;
|
||||||
--muted: #262626;
|
--secondary: #141417;
|
||||||
--muted-foreground: #a3a3a3;
|
--secondary-foreground: #f3f3f5;
|
||||||
--accent: #262626;
|
--muted: #141417;
|
||||||
--accent-foreground: #fafafa;
|
--muted-foreground: #b5b5bd;
|
||||||
|
--accent: #141417;
|
||||||
|
--accent-foreground: #f3f3f5;
|
||||||
--destructive: #7f1d1d;
|
--destructive: #7f1d1d;
|
||||||
--destructive-foreground: #fafafa;
|
--destructive-foreground: #f3f3f5;
|
||||||
--border: #262626;
|
--border: #1c1c22;
|
||||||
--input: #262626;
|
--input: #1c1c22;
|
||||||
--ring: #fafafa;
|
--ring: #ff6f61;
|
||||||
--chart-1: #2662d9;
|
--chart-1: #ff6f61;
|
||||||
--chart-2: #2eb88a;
|
--chart-2: #5ec8d8;
|
||||||
--chart-3: #e88c30;
|
--chart-3: #5fd08a;
|
||||||
--chart-4: #af57db;
|
--chart-4: #c98af0;
|
||||||
--chart-5: #e23670;
|
--chart-5: #e8b465;
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Map the bare tokens into Tailwind's --color-* / radius utilities. */
|
/* Map the bare tokens into Tailwind's --color-* / radius utilities. */
|
||||||
@@ -112,28 +114,35 @@
|
|||||||
from the reverse-engineered reference system.
|
from the reverse-engineered reference system.
|
||||||
─────────────────────────────────────────────────────────────────────── */
|
─────────────────────────────────────────────────────────────────────── */
|
||||||
@theme {
|
@theme {
|
||||||
/* raised surfaces + extra foregrounds */
|
/* raised surfaces + extra foregrounds (design-comp values) */
|
||||||
--color-surface-warm: #141414; /* raised tier 1 */
|
--color-surface-warm: #0f0f13; /* raised tier 1 (cards) */
|
||||||
--color-surface-warm-muted: #1f1f1f; /* raised tier 2 (WorkClaw prod value) */
|
--color-surface-warm-muted: #141417; /* raised tier 2 */
|
||||||
--color-subtle: #141414;
|
--color-subtle: #0d0d10; /* input / inset fill */
|
||||||
--color-hover-bg: #171717; /* generic hover fill */
|
--color-hover-bg: #1a1a1f; /* generic hover fill */
|
||||||
--color-divider-subtle: #1a1a1a;
|
--color-divider-subtle: #16161a;
|
||||||
--color-subtle-foreground: #737373;
|
--color-subtle-foreground: #6a6a72;
|
||||||
|
|
||||||
/* brand accent (coral) + variants */
|
/* brand accent (coral) + variants */
|
||||||
--color-coral: #f96565;
|
--color-coral: #ff6f61;
|
||||||
--color-coral-light: #fa7575;
|
--color-coral-light: #ff8a7a;
|
||||||
--color-coral-dark: #e85555;
|
--color-coral-dark: #ff5f57;
|
||||||
--color-cream: #f5f5f5; /* cream primary button fill */
|
--color-cream: #f3f3f5; /* light primary button fill */
|
||||||
--color-focus-ring: #f5f5f5;
|
--color-focus-ring: #ff6f61;
|
||||||
|
|
||||||
/* chat bubbles (measured) */
|
/* status hues (running/online/etc.) — Tailwind: text-running, bg-online, … */
|
||||||
--color-bubble-user: #1a1a1a;
|
--color-running: #5ec8d8; /* cyan — running / research */
|
||||||
--color-bubble-user-text: #d4d4d4;
|
--color-online: #5fd08a; /* green — online / approved */
|
||||||
|
--color-amber: #e8b465; /* amber — cron / warn */
|
||||||
|
--color-purple: #c98af0; /* purple — personality / debate */
|
||||||
|
--color-teal: #6fd0c0; /* teal — mesh / safety */
|
||||||
|
|
||||||
|
/* chat bubbles */
|
||||||
|
--color-bubble-user: #141417;
|
||||||
|
--color-bubble-user-text: #e6e6ea;
|
||||||
|
|
||||||
/* gradient stops (coral tile) */
|
/* gradient stops (coral tile) */
|
||||||
--color-gradient-coral-top: #fa7575;
|
--color-gradient-coral-top: #ff8a7a;
|
||||||
--color-gradient-coral-bottom: #e85555;
|
--color-gradient-coral-bottom: #ff5f57;
|
||||||
|
|
||||||
/* ---- Type scale (size / line-height) ---- */
|
/* ---- Type scale (size / line-height) ---- */
|
||||||
--text-xxs: 10px;
|
--text-xxs: 10px;
|
||||||
@@ -176,8 +185,8 @@
|
|||||||
--shadow-edge: -3px 0 14px -4px #0006; /* panel left edge */
|
--shadow-edge: -3px 0 14px -4px #0006; /* panel left edge */
|
||||||
--shadow-dock-tile: 0 0 0 1px #ffffff14, 0 4.65px 9.35px #0003,
|
--shadow-dock-tile: 0 0 0 1px #ffffff14, 0 4.65px 9.35px #0003,
|
||||||
inset 0 1px 0 #ffffff2e; /* white hairline + grounding drop + top inset (WorkClaw) */
|
inset 0 1px 0 #ffffff2e; /* white hairline + grounding drop + top inset (WorkClaw) */
|
||||||
--shadow-cta: inset 0 1px 0 #ffffff40, 0 3px 10px -3px #f965658c; /* coral CTA */
|
--shadow-cta: inset 0 1px 0 #ffffff40, 0 3px 10px -3px #ff6f618c; /* coral CTA */
|
||||||
--shadow-launcher: inset 0 1px 0 #ffffff38, 0 5px 14px -3px #f965657a; /* header Computer launcher */
|
--shadow-launcher: inset 0 1px 0 #ffffff38, 0 5px 14px -3px #ff6f617a; /* header Computer launcher */
|
||||||
--shadow-screen:
|
--shadow-screen:
|
||||||
0 4px 12px #ffffff06, 0 24px 60px -8px #ffffff08,
|
0 4px 12px #ffffff06, 0 24px 60px -8px #ffffff08,
|
||||||
0 40px 80px -20px #ffffff05; /* panel screen card ambient lift */
|
0 40px 80px -20px #ffffff05; /* panel screen card ambient lift */
|
||||||
|
|||||||
@@ -4,29 +4,49 @@ import { AuthShell } from "@/components/auth/AuthShell";
|
|||||||
import { LoginForm } from "@/components/auth/LoginForm";
|
import { LoginForm } from "@/components/auth/LoginForm";
|
||||||
import { authMode } from "@/lib/auth/mode";
|
import { authMode } from "@/lib/auth/mode";
|
||||||
|
|
||||||
|
// Clerk appearance themed to the dark design comp. Social OAuth providers
|
||||||
|
// (Google / Apple / GitHub) render here when enabled in the Clerk dashboard.
|
||||||
|
const clerkAppearance = {
|
||||||
|
variables: {
|
||||||
|
colorPrimary: "#ff6f61",
|
||||||
|
colorBackground: "#0d0d10",
|
||||||
|
colorText: "#f3f3f5",
|
||||||
|
colorTextSecondary: "#b5b5bd",
|
||||||
|
colorInputBackground: "#0d0d10",
|
||||||
|
colorInputText: "#f3f3f5",
|
||||||
|
colorNeutral: "#f3f3f5",
|
||||||
|
borderRadius: "10px",
|
||||||
|
fontFamily: "var(--font-sans)",
|
||||||
|
},
|
||||||
|
elements: {
|
||||||
|
rootBox: "w-full",
|
||||||
|
cardBox: "w-full shadow-none",
|
||||||
|
card: "bg-transparent shadow-none border-0 p-0 w-full",
|
||||||
|
headerTitle: "text-[28px] font-bold tracking-[-0.02em]",
|
||||||
|
headerSubtitle: "text-[15px] text-[#a8a8b0]",
|
||||||
|
socialButtonsBlockButton:
|
||||||
|
"h-[46px] border border-white/[0.12] bg-[#141417] hover:bg-[#1a1a1f] text-foreground",
|
||||||
|
formFieldInput:
|
||||||
|
"h-11 rounded-[10px] border border-white/[0.12] bg-[#0d0d10] text-foreground",
|
||||||
|
formButtonPrimary:
|
||||||
|
"h-[46px] bg-gradient-to-br from-[#ff8a7a] to-[#ff5f57] text-[#2a0d0a] font-bold hover:brightness-110",
|
||||||
|
footer: "hidden",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
export default async function LoginPage() {
|
export default async function LoginPage() {
|
||||||
// Read AUTH_MODE at request time, not build time (see IdentityProvider).
|
// Read AUTH_MODE at request time, not build time (see IdentityProvider).
|
||||||
await connection();
|
await connection();
|
||||||
if (authMode() === "clerk") {
|
if (authMode() === "clerk") {
|
||||||
const { SignIn } = await import("@clerk/nextjs");
|
const { SignIn } = await import("@clerk/nextjs");
|
||||||
return (
|
return (
|
||||||
<AuthShell
|
<AuthShell>
|
||||||
title="Multiply your"
|
<SignIn routing="hash" appearance={clerkAppearance} />
|
||||||
accent="team's brainpower"
|
|
||||||
subtitle="Sign up to get early access"
|
|
||||||
>
|
|
||||||
<div className="flex justify-center">
|
|
||||||
<SignIn routing="hash" />
|
|
||||||
</div>
|
|
||||||
</AuthShell>
|
</AuthShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return (
|
return (
|
||||||
<AuthShell
|
<AuthShell>
|
||||||
title="Multiply your"
|
|
||||||
accent="team's brainpower"
|
|
||||||
subtitle="Sign up to get early access"
|
|
||||||
>
|
|
||||||
<LoginForm />
|
<LoginForm />
|
||||||
</AuthShell>
|
</AuthShell>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,122 +1,103 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { PawPrint } from "lucide-react";
|
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
const ROSTER: { name: string; role: string }[] = [
|
import { MeshMark } from "@/components/brand/MeshMark";
|
||||||
{ name: "Executive Assistant", role: "Operations" },
|
|
||||||
{ name: "CX to Product", role: "Product" },
|
|
||||||
{ name: "Design Coordinator", role: "Design" },
|
|
||||||
{ name: "User Researcher", role: "Research" },
|
|
||||||
{ name: "Engineering Coordinator", role: "Engineering" },
|
|
||||||
{ name: "Ops Assistant", role: "Operations" },
|
|
||||||
{ name: "Growth Coordinator", role: "Growth" },
|
|
||||||
{ name: "Analytics Assistant", role: "Data" },
|
|
||||||
];
|
|
||||||
|
|
||||||
/** Two-panel auth shell mirroring WorkClaw's /sign-in: a white form card on
|
function Node({
|
||||||
* the left, a periwinkle "meet the crew" panel on the right (hidden on
|
letter,
|
||||||
* mobile). Light-themed via the `marketing` palette. The actual auth UI
|
from,
|
||||||
* (our email/password form, or Clerk) is passed as children. */
|
to,
|
||||||
export function AuthShell({
|
ink,
|
||||||
title,
|
size,
|
||||||
accent,
|
left,
|
||||||
subtitle,
|
top,
|
||||||
children,
|
halo,
|
||||||
|
ring,
|
||||||
|
dim,
|
||||||
}: {
|
}: {
|
||||||
title: string;
|
letter: string;
|
||||||
accent: string;
|
from: string;
|
||||||
subtitle: string;
|
to: string;
|
||||||
children: ReactNode;
|
ink: string;
|
||||||
|
size: number;
|
||||||
|
left: string;
|
||||||
|
top: string;
|
||||||
|
halo?: string;
|
||||||
|
ring?: boolean;
|
||||||
|
dim?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="marketing flex min-h-dvh items-center justify-center bg-[#e9eef5] p-4 text-marketing-ink">
|
|
||||||
<div className="grid w-full max-w-[1040px] overflow-hidden rounded-[32px] bg-white shadow-[0_32px_80px_-32px_rgba(22,29,39,0.35)] lg:grid-cols-2">
|
|
||||||
{/* Left — form card */}
|
|
||||||
<div className="flex flex-col items-center gap-8 px-8 py-12 sm:px-14">
|
|
||||||
<Link href="/" aria-label="clawmates" className="flex items-center gap-2">
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
|
||||||
src="/images/clawmates-mark.png"
|
|
||||||
alt=""
|
|
||||||
className="h-8 w-auto"
|
|
||||||
/>
|
|
||||||
<span className="text-lg font-bold tracking-tight text-marketing-ink">
|
|
||||||
clawmates
|
|
||||||
</span>
|
|
||||||
</Link>
|
|
||||||
<div className="text-center">
|
|
||||||
<h1
|
|
||||||
className="font-extrabold tracking-[-0.04em]"
|
|
||||||
style={{ fontSize: "clamp(34px, 4vw, 44px)", lineHeight: 0.95 }}
|
|
||||||
>
|
|
||||||
{title}
|
|
||||||
<br />
|
|
||||||
<span className="text-marketing-coral-strong">{accent}</span>
|
|
||||||
</h1>
|
|
||||||
<p className="pt-3 text-base font-medium text-marketing-slate">
|
|
||||||
{subtitle}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="w-full max-w-sm">{children}</div>
|
|
||||||
<p className="max-w-sm text-center text-xs text-marketing-slate">
|
|
||||||
By continuing, you agree to our{" "}
|
|
||||||
<a href="#" className="underline hover:text-marketing-ink">
|
|
||||||
Terms of Service
|
|
||||||
</a>{" "}
|
|
||||||
and{" "}
|
|
||||||
<a href="#" className="underline hover:text-marketing-ink">
|
|
||||||
Privacy Policy
|
|
||||||
</a>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right — crew panel: role-card grid + caption + crew art (mirrors
|
|
||||||
WorkClaw's sign-in panel). Decorative; hidden on mobile. */}
|
|
||||||
<div className="relative hidden flex-col items-center justify-between overflow-hidden bg-[#ebf4fd] py-10 lg:flex">
|
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 opacity-[0.5]"
|
className="absolute -translate-x-1/2 -translate-y-1/2"
|
||||||
|
style={{ left, top, width: size, height: size, opacity: dim ? 0.7 : 1 }}
|
||||||
|
>
|
||||||
|
{halo ? (
|
||||||
|
<span className="cm-halo absolute inset-0 rounded-full" style={{ background: halo }} />
|
||||||
|
) : null}
|
||||||
|
<div
|
||||||
|
className="relative flex items-center justify-center rounded-full font-bold"
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: "url('/images/marketing/dot-pattern.svg')",
|
width: size,
|
||||||
backgroundSize: "cover",
|
height: size,
|
||||||
backgroundPosition: "center",
|
background: `linear-gradient(135deg, ${from}, ${to})`,
|
||||||
|
color: ink,
|
||||||
|
fontSize: size > 48 ? 19 : 13,
|
||||||
|
border: ring ? "2px solid #ff6f61" : undefined,
|
||||||
}}
|
}}
|
||||||
/>
|
|
||||||
<div className="relative -mt-6 grid w-[112%] grid-cols-2 gap-2.5 px-3">
|
|
||||||
{ROSTER.map((r) => (
|
|
||||||
<div
|
|
||||||
key={r.name}
|
|
||||||
className="flex items-center gap-2.5 rounded-2xl bg-white/95 p-2.5 shadow-[0_8px_24px_-12px_rgba(22,29,39,0.25)]"
|
|
||||||
>
|
>
|
||||||
<span
|
{letter}
|
||||||
className="flex size-9 shrink-0 items-center justify-center rounded-xl"
|
</div>
|
||||||
style={{ backgroundColor: "rgba(233,86,86,0.1)" }}
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Dark split-panel auth shell (design comp): a brand panel with the team
|
||||||
|
* constellation on the left, the auth form (our local form, or Clerk) on the
|
||||||
|
* right. The form provides its own heading. */
|
||||||
|
export function AuthShell({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-dvh w-full bg-background text-foreground">
|
||||||
|
{/* LEFT BRAND PANEL */}
|
||||||
|
<div
|
||||||
|
className="relative hidden flex-1 flex-col overflow-hidden border-r border-white/[0.06] p-10 lg:flex"
|
||||||
|
style={{ background: "radial-gradient(120% 90% at 35% 30%, #0f0f14, #08080a 72%)" }}
|
||||||
>
|
>
|
||||||
<PawPrint aria-hidden size={18} className="text-marketing-coral" />
|
<Link href="/" className="relative z-10 flex items-center gap-2.5 text-coral">
|
||||||
|
<MeshMark size={24} title="" />
|
||||||
|
<span className="text-[17px] font-bold tracking-[-0.01em] text-foreground">
|
||||||
|
Clawmates
|
||||||
</span>
|
</span>
|
||||||
<span className="min-w-0">
|
</Link>
|
||||||
<span className="block truncate text-xs font-bold text-marketing-ink">
|
|
||||||
{r.name}
|
<div className="relative min-h-[280px] flex-1">
|
||||||
</span>
|
<svg viewBox="0 0 100 100" preserveAspectRatio="none" className="absolute inset-0 size-full">
|
||||||
<span className="block truncate text-[11px] text-marketing-slate">
|
<path d="M50,52 C40,38 34,32 24,26" fill="none" stroke="rgba(94,200,216,.45)" strokeWidth={1.3} strokeDasharray="3 4" vectorEffect="non-scaling-stroke" className="cm-flow" />
|
||||||
{r.role}
|
<path d="M50,52 C40,64 34,70 26,76" fill="none" stroke="rgba(94,200,216,.45)" strokeWidth={1.3} strokeDasharray="3 4" vectorEffect="non-scaling-stroke" className="cm-flow" />
|
||||||
</span>
|
<path d="M50,52 C52,40 53,30 54,20" fill="none" stroke="rgba(255,255,255,.1)" strokeWidth={1.2} vectorEffect="non-scaling-stroke" />
|
||||||
</span>
|
<path d="M50,52 C64,46 72,40 78,32" fill="none" stroke="rgba(255,255,255,.1)" strokeWidth={1.2} vectorEffect="non-scaling-stroke" />
|
||||||
|
<path d="M50,52 C64,58 72,66 78,74" fill="none" stroke="rgba(255,111,97,.55)" strokeWidth={1.5} strokeDasharray="3 4" vectorEffect="non-scaling-stroke" className="cm-flow" />
|
||||||
|
</svg>
|
||||||
|
<Node letter="A" from="#ff9a6a" to="#ff6f4a" ink="#2a0d05" size={52} left="50%" top="52%" />
|
||||||
|
<Node letter="I" from="#6fd0c0" to="#4aa3b8" ink="#06201f" size={36} left="24%" top="26%" halo="rgba(94,200,216,.4)" />
|
||||||
|
<Node letter="N" from="#e8c46a" to="#d89a3a" ink="#2a1d05" size={32} left="54%" top="20%" />
|
||||||
|
<Node letter="S" from="#c98af0" to="#9a5ad8" ink="#1a0a2a" size={32} left="78%" top="32%" dim />
|
||||||
|
<Node letter="E" from="#8a9af0" to="#5a6ad8" ink="#0a0e2a" size={32} left="26%" top="76%" />
|
||||||
|
<Node letter="M" from="#ff8a7a" to="#ff5f57" ink="#2a0d0a" size={38} left="78%" top="74%" ring />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative z-10 max-w-[380px]">
|
||||||
|
<div className="mb-2.5 text-2xl font-bold leading-snug tracking-[-0.02em] text-balance">
|
||||||
|
Deploy agents at any scale — a single claw to a whole org.
|
||||||
</div>
|
</div>
|
||||||
))}
|
<div className="font-mono text-[11px] tracking-[0.04em] text-[#6a6a72]">
|
||||||
|
12 topologies · durable runner · §15-safe
|
||||||
</div>
|
</div>
|
||||||
<p className="relative px-8 text-center text-xl font-semibold text-marketing-ink">
|
|
||||||
The AI crew for your team
|
|
||||||
</p>
|
|
||||||
<div className="relative w-[88%]">
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
|
||||||
src="/images/marketing/claws-at-desk.png"
|
|
||||||
alt="The clawmate crew"
|
|
||||||
className="block h-auto w-full"
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* RIGHT FORM PANEL */}
|
||||||
|
<div className="flex w-full items-center justify-center p-10 lg:w-[520px] lg:max-w-[48vw] lg:flex-none">
|
||||||
|
<div className="w-full max-w-[380px]">{children}</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,26 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { ArrowRight, ArrowLeft } from "lucide-react";
|
|
||||||
import { useState, type FormEvent } from "react";
|
import { useState, type FormEvent } from "react";
|
||||||
|
|
||||||
/** WorkClaw-style progressive sign-in: an email-first step (Slack + work
|
/** Local-mode (dev) sign-in styled to the design comp: email + password posting
|
||||||
* email / phone tabs + "Continue with work email"), then a password step.
|
* to /auth/session. (Social OAuth is provided by Clerk in prod — see the clerk
|
||||||
* Slack / phone / "Other SSO Options" are cosmetic placeholders — we don't
|
* branch of the login page.) */
|
||||||
* support them yet. The real auth is our email + password POST. */
|
|
||||||
export function LoginForm() {
|
export function LoginForm() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [step, setStep] = useState<"email" | "password">("email");
|
|
||||||
const [email, setEmail] = useState("");
|
|
||||||
const [pending, setPending] = useState(false);
|
const [pending, setPending] = useState(false);
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
|
|
||||||
function continueWithEmail(event: FormEvent<HTMLFormElement>) {
|
|
||||||
event.preventDefault();
|
|
||||||
if (!email.trim()) return;
|
|
||||||
setStep("password");
|
|
||||||
}
|
|
||||||
|
|
||||||
async function signIn(event: FormEvent<HTMLFormElement>) {
|
async function signIn(event: FormEvent<HTMLFormElement>) {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
setPending(true);
|
setPending(true);
|
||||||
@@ -29,7 +19,10 @@ export function LoginForm() {
|
|||||||
const res = await fetch("/auth/session", {
|
const res = await fetch("/auth/session", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ email, password: data.get("password") }),
|
body: JSON.stringify({
|
||||||
|
email: data.get("email"),
|
||||||
|
password: data.get("password"),
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
router.push("/");
|
router.push("/");
|
||||||
@@ -40,102 +33,62 @@ export function LoginForm() {
|
|||||||
setFailed(true);
|
setFailed(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (step === "password") {
|
const field =
|
||||||
|
"h-11 w-full rounded-[10px] border border-white/[0.12] bg-subtle px-3.5 text-sm text-foreground outline-none transition-colors placeholder:text-[#5a5a62] focus:border-coral";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<form onSubmit={signIn} className="flex w-full flex-col gap-4">
|
<div>
|
||||||
<button
|
<h1 className="mb-2 text-[28px] font-bold tracking-[-0.02em]">Welcome back</h1>
|
||||||
type="button"
|
<p className="mb-7 text-[15px] text-[#a8a8b0]">Sign in to your Clawmates workspace.</p>
|
||||||
onClick={() => setStep("email")}
|
|
||||||
className="flex items-center gap-1.5 self-start text-sm font-medium text-marketing-slate transition-colors hover:text-marketing-ink"
|
<form onSubmit={signIn} className="flex flex-col gap-3.5">
|
||||||
>
|
<div>
|
||||||
<ArrowLeft aria-hidden size={16} /> Back
|
<div className="mb-[7px] text-xs font-semibold text-[#b5b5bd]">Email address</div>
|
||||||
</button>
|
<input
|
||||||
<p className="text-sm text-marketing-slate">
|
name="email"
|
||||||
Signing in as{" "}
|
type="email"
|
||||||
<span className="font-semibold text-marketing-ink">{email}</span>
|
required
|
||||||
</p>
|
autoComplete="email"
|
||||||
<label className="flex flex-col gap-1.5 text-sm font-medium text-marketing-slate-dark">
|
placeholder="[email protected]"
|
||||||
Password
|
className={field}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="mb-[7px] flex items-center">
|
||||||
|
<span className="text-xs font-semibold text-[#b5b5bd]">Password</span>
|
||||||
|
<span className="flex-1" />
|
||||||
|
<span className="cursor-pointer text-xs text-coral-light">Forgot?</span>
|
||||||
|
</div>
|
||||||
<input
|
<input
|
||||||
name="password"
|
name="password"
|
||||||
type="password"
|
type="password"
|
||||||
aria-label="Password"
|
|
||||||
required
|
required
|
||||||
autoFocus
|
|
||||||
autoComplete="current-password"
|
autoComplete="current-password"
|
||||||
className={`rounded-xl border bg-white px-4 py-3 text-base text-marketing-ink outline-none transition-colors focus:border-marketing-coral-strong ${
|
placeholder="••••••••••"
|
||||||
failed ? "border-marketing-coral-strong" : "border-[#e2e8f0]"
|
className={field}
|
||||||
}`}
|
|
||||||
/>
|
/>
|
||||||
</label>
|
</div>
|
||||||
{failed && (
|
{failed ? (
|
||||||
<p role="alert" className="text-sm text-marketing-coral-strong">
|
<p role="alert" className="text-sm text-coral">
|
||||||
Invalid email or password.
|
Invalid email or password.
|
||||||
</p>
|
</p>
|
||||||
)}
|
) : null}
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={pending}
|
disabled={pending}
|
||||||
className="flex h-12 items-center justify-center rounded-full bg-marketing-ink px-4 text-base font-semibold text-white transition-colors hover:bg-marketing-slate-dark disabled:opacity-50"
|
className="mt-1 flex h-[46px] items-center justify-center rounded-[10px] bg-gradient-to-br from-coral-light to-coral-dark text-[15px] font-bold text-[#2a0d0a] transition-[filter] hover:brightness-110 disabled:opacity-60"
|
||||||
>
|
>
|
||||||
{pending ? "Signing in…" : "Sign in"}
|
{pending ? "Signing in…" : "Sign in"}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
<div className="mt-[30px] flex items-center justify-center gap-1.5 font-mono text-[10px] text-[#4a4a52]">
|
||||||
<div className="flex w-full flex-col gap-4">
|
<svg width="11" height="11" viewBox="0 0 20 20" fill="none">
|
||||||
<button
|
<rect x="4" y="9" width="12" height="8" rx="2" stroke="#4a4a52" strokeWidth="1.5" />
|
||||||
type="button"
|
<path d="M7 9V6.5a3 3 0 016 0V9" stroke="#4a4a52" strokeWidth="1.5" />
|
||||||
className="flex h-12 items-center justify-center gap-2.5 rounded-full border border-[#e2e8f0] bg-white text-base font-semibold text-marketing-ink transition-colors hover:bg-[#f8fafc]"
|
</svg>
|
||||||
>
|
Local dev auth
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img src="/images/marketing/icon-slack.svg" alt="" className="size-5" />
|
|
||||||
Sign up with Slack
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 text-xs font-medium text-marketing-slate">
|
|
||||||
<span className="h-px flex-1 bg-[#e2e8f0]" />
|
|
||||||
OR
|
|
||||||
<span className="h-px flex-1 bg-[#e2e8f0]" />
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex gap-1 rounded-full bg-[#f1f5f9] p-1 text-sm font-medium">
|
|
||||||
<span className="flex-1 rounded-full bg-white py-1.5 text-center text-marketing-ink shadow-sm">
|
|
||||||
Work Email
|
|
||||||
</span>
|
|
||||||
<span className="flex-1 py-1.5 text-center text-marketing-slate">
|
|
||||||
Phone Number
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form onSubmit={continueWithEmail} className="flex flex-col gap-4">
|
|
||||||
<input
|
|
||||||
name="email"
|
|
||||||
type="email"
|
|
||||||
aria-label="Email"
|
|
||||||
required
|
|
||||||
autoComplete="email"
|
|
||||||
placeholder="e.g. [email protected]"
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
className="rounded-xl border border-[#e2e8f0] bg-white px-4 py-3 text-base text-marketing-ink outline-none transition-colors placeholder:text-marketing-slate/60 focus:border-marketing-coral-strong"
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className="flex h-12 items-center justify-center gap-2 rounded-full bg-marketing-ink px-4 text-base font-semibold text-white transition-colors hover:bg-marketing-slate-dark"
|
|
||||||
>
|
|
||||||
Continue with work email <ArrowRight aria-hidden size={18} />
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="text-center text-sm font-medium text-marketing-slate underline-offset-2 hover:underline"
|
|
||||||
>
|
|
||||||
Other SSO Options
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,20 +30,18 @@ const SHAPES: Record<MeshVariant, MeshShape> = {
|
|||||||
[1, 2],
|
[1, 2],
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
// The wordmark glyph: a triangle outline + a filled node at each vertex
|
||||||
|
// (matches the design-comp logo — coral triangle + 3 circles).
|
||||||
mark: {
|
mark: {
|
||||||
nodes: [
|
nodes: [
|
||||||
[12, 12],
|
|
||||||
[12, 4],
|
[12, 4],
|
||||||
[5, 19],
|
[5, 19],
|
||||||
[19, 19],
|
[19, 19],
|
||||||
],
|
],
|
||||||
edges: [
|
edges: [
|
||||||
[0, 1],
|
[0, 1],
|
||||||
[0, 2],
|
|
||||||
[0, 3],
|
|
||||||
[1, 2],
|
[1, 2],
|
||||||
[1, 3],
|
[2, 0],
|
||||||
[2, 3],
|
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
company: {
|
company: {
|
||||||
|
|||||||
@@ -0,0 +1,129 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// The claw "anatomy" view (design comp, CLAW tier): a central core surrounded by
|
||||||
|
// compartment cards — Skills / Personality / Memory / Tools·Doors / Capabilities
|
||||||
|
// / Safety·§15 — aggregated from the backend. Each compartment is color-tinted.
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { ArrowLeft } from "lucide-react";
|
||||||
|
|
||||||
|
interface Compartment {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
items: string[];
|
||||||
|
count: number | null;
|
||||||
|
}
|
||||||
|
interface RuntimeConfig {
|
||||||
|
model: string | null;
|
||||||
|
sandbox_enabled: boolean;
|
||||||
|
network_allowed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TINT: Record<string, string> = {
|
||||||
|
skills: "#ff6f61",
|
||||||
|
personality: "#c98af0",
|
||||||
|
memory: "#5fd08a",
|
||||||
|
tools: "#5ec8d8",
|
||||||
|
capabilities: "#e8b465",
|
||||||
|
safety: "#6fd0c0",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function ClawAnatomy({ clawId }: { clawId: string }) {
|
||||||
|
const [compartments, setCompartments] = useState<Compartment[]>([]);
|
||||||
|
const [cfg, setCfg] = useState<RuntimeConfig | null>(null);
|
||||||
|
const [name, setName] = useState("Claw");
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true;
|
||||||
|
void (async () => {
|
||||||
|
const [c, rc, settings] = await Promise.all([
|
||||||
|
fetch(`/api/claws/${clawId}/compartments`).then((x) => (x.ok ? x.json() : [])).catch(() => []),
|
||||||
|
fetch(`/api/claws/${clawId}/runtime-config`).then((x) => (x.ok ? x.json() : null)).catch(() => null),
|
||||||
|
fetch(`/api/claws/settings/full?clawId=${clawId}`).then((x) => (x.ok ? x.json() : null)).catch(() => null),
|
||||||
|
]);
|
||||||
|
if (!live) return;
|
||||||
|
setCompartments(c as Compartment[]);
|
||||||
|
setCfg(rc as RuntimeConfig | null);
|
||||||
|
const n = (settings as { agent?: { name?: string } } | null)?.agent?.name;
|
||||||
|
if (n) setName(n);
|
||||||
|
setLoaded(true);
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
|
}, [clawId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6 p-6">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Link
|
||||||
|
href={`/claws/${clawId}`}
|
||||||
|
className="flex items-center gap-1.5 font-mono text-[11px] text-muted-foreground hover:text-foreground"
|
||||||
|
>
|
||||||
|
<ArrowLeft size={13} /> back to chat
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Core */}
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<div
|
||||||
|
className="relative flex size-[72px] items-center justify-center rounded-full text-2xl font-bold"
|
||||||
|
style={{ background: "linear-gradient(135deg,#ff9a6a,#ff6f4a)", color: "#2a0d05" }}
|
||||||
|
>
|
||||||
|
<span className="cm-halo absolute inset-0 rounded-full" style={{ background: "rgba(255,111,97,.3)" }} />
|
||||||
|
<span className="relative">{name.charAt(0).toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<h1 className="text-xl font-semibold tracking-tight">{name}</h1>
|
||||||
|
<p className="font-mono text-[11px] text-muted-foreground">
|
||||||
|
ANATOMY · what {name} is made of
|
||||||
|
{cfg?.model ? <span className="ml-2 capitalize text-running">● {cfg.model}</span> : null}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Compartments */}
|
||||||
|
{!loaded ? (
|
||||||
|
<p className="text-sm text-muted-foreground">Loading…</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{compartments.map((c) => {
|
||||||
|
const tint = TINT[c.key] ?? "#ff6f61";
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={c.key}
|
||||||
|
className="rounded-2xl border bg-[#0c0c0f] p-4"
|
||||||
|
style={{ borderColor: `${tint}40` }}
|
||||||
|
>
|
||||||
|
<div className="mb-2.5 flex items-center justify-between">
|
||||||
|
<span className="font-mono text-[10px] tracking-[0.12em]" style={{ color: tint }}>
|
||||||
|
{c.label.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
{c.count != null ? (
|
||||||
|
<span className="font-mono text-[10px] text-muted-foreground">{c.count}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{c.items.length === 0 ? (
|
||||||
|
<p className="text-xs text-subtle-foreground">—</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-1.5">
|
||||||
|
{c.items.slice(0, 5).map((it, i) => (
|
||||||
|
<div key={i} className="truncate text-[13px] text-[#cfcfd5]">
|
||||||
|
{it}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{c.items.length > 5 ? (
|
||||||
|
<div className="text-xs text-subtle-foreground">+{c.items.length - 5} more</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,132 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// The claw "computer" (design comp): the apps a claw is connected to, its
|
||||||
|
// now-running scheduled tasks, a model/sandbox header, and a dock. Shown on the
|
||||||
|
// right of the team canvas when a claw node is selected.
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { Bell, Cpu, FolderClosed, Settings, Zap } from "lucide-react";
|
||||||
|
|
||||||
|
interface AppRow {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
connected: boolean;
|
||||||
|
}
|
||||||
|
interface Routine {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
schedule_cron: string;
|
||||||
|
status: string;
|
||||||
|
}
|
||||||
|
interface RuntimeConfig {
|
||||||
|
model: string | null;
|
||||||
|
provider_alias: string;
|
||||||
|
sandbox_enabled: boolean;
|
||||||
|
network_allowed: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ComputerPanel({ clawId, clawName }: { clawId: string; clawName: string }) {
|
||||||
|
const [apps, setApps] = useState<AppRow[]>([]);
|
||||||
|
const [routines, setRoutines] = useState<Routine[]>([]);
|
||||||
|
const [cfg, setCfg] = useState<RuntimeConfig | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true;
|
||||||
|
void (async () => {
|
||||||
|
const [a, r, c] = await Promise.all([
|
||||||
|
fetch(`/api/apps?clawId=${clawId}`).then((x) => (x.ok ? x.json() : [])).catch(() => []),
|
||||||
|
fetch(`/api/routines?clawId=${clawId}`).then((x) => (x.ok ? x.json() : [])).catch(() => []),
|
||||||
|
fetch(`/api/claws/${clawId}/runtime-config`).then((x) => (x.ok ? x.json() : null)).catch(() => null),
|
||||||
|
]);
|
||||||
|
if (!live) return;
|
||||||
|
setApps((a as AppRow[]).filter((x) => x.connected).slice(0, 7));
|
||||||
|
setRoutines(r as Routine[]);
|
||||||
|
setCfg(c as RuntimeConfig | null);
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
|
}, [clawId]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex w-full flex-col gap-4 rounded-2xl border border-white/[0.08] bg-[#0c0c0f] p-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-bold">{clawName}'s Computer</div>
|
||||||
|
<div className="mt-0.5 flex items-center gap-2 font-mono text-[10px] text-[#6a6a72]">
|
||||||
|
<span className="text-running">● sandbox live · no network</span>
|
||||||
|
{cfg?.model ? (
|
||||||
|
<span className="rounded-[5px] bg-white/[0.04] px-1.5 py-0.5 capitalize text-[#cfcfd5]">
|
||||||
|
{cfg.model}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* APPS */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 font-mono text-[10px] tracking-[0.12em] text-[#5a5a62]">APPS</div>
|
||||||
|
<div className="grid grid-cols-4 gap-2">
|
||||||
|
{apps.map((a) => (
|
||||||
|
<div
|
||||||
|
key={a.id}
|
||||||
|
title={a.name}
|
||||||
|
className="flex aspect-square items-center justify-center rounded-xl border border-white/[0.08] bg-[#141417] text-[11px] font-semibold text-[#cfcfd5]"
|
||||||
|
>
|
||||||
|
{a.name.slice(0, 2)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
<Link
|
||||||
|
href="/apps"
|
||||||
|
className="flex aspect-square items-center justify-center rounded-xl border border-dashed border-white/[0.16] text-coral"
|
||||||
|
>
|
||||||
|
+
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* NOW RUNNING */}
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 font-mono text-[10px] tracking-[0.12em] text-[#5a5a62]">NOW RUNNING</div>
|
||||||
|
{routines.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground">No scheduled tasks.</p>
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{routines.slice(0, 4).map((r) => (
|
||||||
|
<div key={r.id} className="rounded-lg border border-white/[0.06] bg-[#0f0f13] px-3 py-2">
|
||||||
|
<div className="flex items-center gap-2 text-[13px] font-medium">
|
||||||
|
<span
|
||||||
|
className={`size-1.5 rounded-full ${
|
||||||
|
r.status === "active" ? "bg-running cm-blink" : "bg-[#3a3a40]"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{r.name}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 font-mono text-[10px] text-[#6a6a72]">cron · {r.schedule_cron}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* DOCK */}
|
||||||
|
<div className="flex items-center justify-around rounded-xl border border-white/[0.06] bg-[#0a0a0c] p-2">
|
||||||
|
<Link href="/skills" title="Skills" className="text-muted-foreground hover:text-foreground">
|
||||||
|
<Zap size={16} />
|
||||||
|
</Link>
|
||||||
|
<button type="button" title="Files" className="text-muted-foreground hover:text-foreground">
|
||||||
|
<FolderClosed size={16} />
|
||||||
|
</button>
|
||||||
|
<Link href={`/claws/${clawId}`} title="Routines" className="text-coral">
|
||||||
|
<Bell size={16} />
|
||||||
|
</Link>
|
||||||
|
<Link href={`/claws/${clawId}`} title="Settings" className="text-muted-foreground hover:text-foreground">
|
||||||
|
<Settings size={16} />
|
||||||
|
</Link>
|
||||||
|
<Link href={`/claws/${clawId}/anatomy`} title="Anatomy" className="text-muted-foreground hover:text-foreground">
|
||||||
|
<Cpu size={16} />
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// The routines panel (design comp, Frame B): recent routine firings across the
|
||||||
|
// workspace — the scheduler's run history (cron jobs + research loops journal).
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
interface RoutineRun {
|
||||||
|
id: string;
|
||||||
|
routine_name: string;
|
||||||
|
status: string; // running | ok | error
|
||||||
|
started_at: string;
|
||||||
|
completed_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DOT: Record<string, string> = {
|
||||||
|
ok: "bg-online",
|
||||||
|
running: "bg-running cm-blink",
|
||||||
|
error: "bg-coral",
|
||||||
|
};
|
||||||
|
|
||||||
|
function clock(iso: string): string {
|
||||||
|
// HH:MM, locale-independent (avoid hydration drift).
|
||||||
|
const m = iso.match(/T(\d{2}:\d{2})/);
|
||||||
|
return m ? m[1] : "";
|
||||||
|
}
|
||||||
|
|
||||||
|
export function RoutinesPanel() {
|
||||||
|
const [runs, setRuns] = useState<RoutineRun[]>([]);
|
||||||
|
const [loaded, setLoaded] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let live = true;
|
||||||
|
void (async () => {
|
||||||
|
const r = await fetch("/api/routines/runs")
|
||||||
|
.then((x) => (x.ok ? x.json() : []))
|
||||||
|
.catch(() => []);
|
||||||
|
if (!live) return;
|
||||||
|
setRuns(r as RoutineRun[]);
|
||||||
|
setLoaded(true);
|
||||||
|
})();
|
||||||
|
return () => {
|
||||||
|
live = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="rounded-2xl border border-white/[0.08] bg-[#0c0c0f] p-4">
|
||||||
|
<div className="mb-3 font-mono text-[10px] tracking-[0.12em] text-[#5a5a62]">
|
||||||
|
RUN HISTORY
|
||||||
|
</div>
|
||||||
|
{!loaded ? (
|
||||||
|
<p className="text-xs text-muted-foreground">Loading…</p>
|
||||||
|
) : runs.length === 0 ? (
|
||||||
|
<p className="text-xs text-muted-foreground">No routine runs yet.</p>
|
||||||
|
) : (
|
||||||
|
<ul className="flex flex-col gap-1.5">
|
||||||
|
{runs.map((r) => (
|
||||||
|
<li
|
||||||
|
key={r.id}
|
||||||
|
className="flex items-center gap-2.5 rounded-lg border border-white/[0.05] bg-[#0f0f13] px-3 py-2 font-mono text-[11px]"
|
||||||
|
>
|
||||||
|
<span className={`size-1.5 rounded-full ${DOT[r.status] ?? "bg-[#3a3a40]"}`} />
|
||||||
|
<span className="text-[#6a6a72]">{clock(r.started_at)}</span>
|
||||||
|
<span className="min-w-0 flex-1 truncate font-sans text-[13px] font-medium text-foreground">
|
||||||
|
{r.routine_name}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={
|
||||||
|
r.status === "error"
|
||||||
|
? "text-coral"
|
||||||
|
: r.status === "running"
|
||||||
|
? "text-running"
|
||||||
|
: "text-online"
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{r.status}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,554 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import { MeshMark } from "@/components/brand/MeshMark";
|
||||||
|
|
||||||
|
// ── Topology taxonomy (12 kinds × mini node-graphs), from the design comp. ──
|
||||||
|
const C = {
|
||||||
|
coral: "#ff6f61",
|
||||||
|
cyan: "#5ec8d8",
|
||||||
|
green: "#5fd08a",
|
||||||
|
amber: "#e8b465",
|
||||||
|
purple: "#c98af0",
|
||||||
|
teal: "#6fd0c0",
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Topo {
|
||||||
|
name: string;
|
||||||
|
kind: string;
|
||||||
|
color: string;
|
||||||
|
pts: [number, number][];
|
||||||
|
edges: [number, number][];
|
||||||
|
big?: number;
|
||||||
|
dash?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const TOPOLOGIES: Topo[] = [
|
||||||
|
{ name: "Hierarchical", kind: "tree command", color: C.coral, pts: [[50, 15], [28, 50], [72, 50], [16, 85], [40, 85], [84, 85]], edges: [[0, 1], [0, 2], [1, 3], [1, 4], [2, 5]] },
|
||||||
|
{ name: "Pipeline", kind: "sequential", color: C.amber, pts: [[12, 50], [37, 50], [63, 50], [88, 50]], edges: [[0, 1], [1, 2], [2, 3]] },
|
||||||
|
{ name: "Swarm", kind: "parallel", color: C.cyan, pts: [[26, 30], [60, 22], [82, 52], [54, 80], [22, 64], [48, 48]], edges: [[0, 1], [1, 2], [3, 4], [5, 0], [5, 2], [5, 3]] },
|
||||||
|
{ name: "Mesh", kind: "all-to-all", color: C.green, pts: [[50, 14], [86, 42], [70, 84], [30, 84], [14, 42]], edges: [[0, 1], [0, 2], [0, 3], [0, 4], [1, 2], [1, 3], [1, 4], [2, 3], [2, 4], [3, 4]] },
|
||||||
|
{ name: "Debate", kind: "adversarial", color: C.purple, pts: [[50, 12], [26, 46], [26, 82], [74, 46], [74, 82]], edges: [[1, 3], [1, 4], [2, 3], [2, 4], [0, 1], [0, 3]] },
|
||||||
|
{ name: "Hub-spoke", kind: "one coordinator", color: C.coral, pts: [[50, 50], [50, 16], [82, 38], [70, 82], [30, 82], [18, 38]], edges: [[0, 1], [0, 2], [0, 3], [0, 4], [0, 5]], big: 0 },
|
||||||
|
{ name: "Star-MoE", kind: "router + experts", color: C.teal, pts: [[50, 50], [24, 24], [76, 24], [24, 76], [76, 76]], edges: [[0, 1], [0, 2], [0, 3], [0, 4]], big: 0 },
|
||||||
|
{ name: "Market", kind: "bid / auction", color: C.amber, pts: [[50, 50], [20, 30], [80, 30], [20, 72], [80, 72]], edges: [[0, 1], [0, 2], [0, 3], [0, 4]], big: 0, dash: true },
|
||||||
|
{ name: "Ring", kind: "cyclic hand-off", color: C.cyan, pts: [[50, 14], [81, 32], [81, 68], [50, 86], [19, 68], [19, 32]], edges: [[0, 1], [1, 2], [2, 3], [3, 4], [4, 5], [5, 0]] },
|
||||||
|
{ name: "Flat", kind: "peers", color: C.purple, pts: [[30, 32], [70, 32], [30, 68], [70, 68]], edges: [] },
|
||||||
|
{ name: "Holacratic", kind: "nested circles", color: C.teal, pts: [[50, 50], [50, 22], [74, 64], [26, 64]], edges: [[0, 1], [0, 2], [0, 3]], big: 0 },
|
||||||
|
{ name: "Blackboard", kind: "shared memory", color: C.cyan, pts: [[50, 50], [24, 26], [76, 26], [24, 74], [76, 74]], edges: [[0, 1], [0, 2], [0, 3], [0, 4]], big: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const mono = "font-mono tracking-[0.14em]";
|
||||||
|
const coralBtn =
|
||||||
|
"inline-flex items-center gap-2 rounded-[10px] bg-gradient-to-br from-coral-light to-coral-dark font-bold text-[#2a0d0a] transition-[filter] hover:brightness-110";
|
||||||
|
const ghostBtn =
|
||||||
|
"inline-flex items-center gap-2 rounded-[10px] border border-white/[0.14] font-semibold text-[#e6e6ea] transition-colors hover:bg-white/[0.04]";
|
||||||
|
|
||||||
|
function TopoCard({ t }: { t: Topo }) {
|
||||||
|
return (
|
||||||
|
<div className="rounded-[13px] border border-white/[0.07] bg-[#0d0d10] p-4 transition-colors hover:border-white/20">
|
||||||
|
<div className="mb-3 flex h-[88px] items-center justify-center">
|
||||||
|
<svg viewBox="0 0 100 100" className="h-[88px] w-[100px]">
|
||||||
|
{t.edges.map(([a, b], i) => (
|
||||||
|
<line
|
||||||
|
key={i}
|
||||||
|
x1={t.pts[a][0]}
|
||||||
|
y1={t.pts[a][1]}
|
||||||
|
x2={t.pts[b][0]}
|
||||||
|
y2={t.pts[b][1]}
|
||||||
|
stroke={t.color}
|
||||||
|
strokeWidth={1.3}
|
||||||
|
strokeOpacity={0.4}
|
||||||
|
strokeDasharray={t.dash ? "3 3" : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{t.pts.map(([x, y], i) => (
|
||||||
|
<circle key={i} cx={x} cy={y} r={t.big === i ? 7 : 5} fill={t.color} />
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="mb-0.5 text-[15px] font-bold">{t.name}</div>
|
||||||
|
<div className={`${mono} text-[10px] text-[#6a6a72]`}>{t.kind}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// One labelled avatar node for the constellation panels.
|
||||||
|
function Node({
|
||||||
|
letter,
|
||||||
|
from,
|
||||||
|
to,
|
||||||
|
ink,
|
||||||
|
size = 38,
|
||||||
|
left,
|
||||||
|
top,
|
||||||
|
label,
|
||||||
|
labelColor,
|
||||||
|
ring,
|
||||||
|
halo,
|
||||||
|
dim,
|
||||||
|
}: {
|
||||||
|
letter: string;
|
||||||
|
from: string;
|
||||||
|
to: string;
|
||||||
|
ink: string;
|
||||||
|
size?: number;
|
||||||
|
left: string;
|
||||||
|
top: string;
|
||||||
|
label?: string;
|
||||||
|
labelColor?: string;
|
||||||
|
ring?: boolean;
|
||||||
|
halo?: string;
|
||||||
|
dim?: boolean;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="absolute flex -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-1.5"
|
||||||
|
style={{ left, top, opacity: dim ? 0.7 : 1 }}
|
||||||
|
>
|
||||||
|
<div className="relative" style={{ width: size, height: size }}>
|
||||||
|
{halo ? (
|
||||||
|
<span
|
||||||
|
className="cm-halo absolute inset-0 rounded-full"
|
||||||
|
style={{ background: halo }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
{ring ? (
|
||||||
|
<span className="absolute -inset-[5px] rounded-full border-2 border-coral" />
|
||||||
|
) : null}
|
||||||
|
<div
|
||||||
|
className="relative flex items-center justify-center rounded-full font-bold"
|
||||||
|
style={{
|
||||||
|
width: size,
|
||||||
|
height: size,
|
||||||
|
background: `linear-gradient(135deg, ${from}, ${to})`,
|
||||||
|
color: ink,
|
||||||
|
fontSize: size > 50 ? 21 : 13,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{letter}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{label ? (
|
||||||
|
<span
|
||||||
|
className={label.length > 4 ? `${mono} text-[9px]` : "text-[11px] font-semibold"}
|
||||||
|
style={{ color: labelColor }}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Landing() {
|
||||||
|
return (
|
||||||
|
<div className="bg-background text-foreground">
|
||||||
|
{/* NAV */}
|
||||||
|
<header className="sticky top-0 z-50 flex items-center gap-3.5 border-b border-white/[0.06] bg-[#08080acc] px-8 py-4 backdrop-blur-md">
|
||||||
|
<Link href="/" className="flex items-center gap-2.5 text-coral">
|
||||||
|
<MeshMark size={24} title="" />
|
||||||
|
<span className="text-[17px] font-bold tracking-[-0.01em] text-foreground">
|
||||||
|
Clawmates
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<nav className="hidden items-center gap-7 text-sm text-[#b5b5bd] md:flex">
|
||||||
|
<span className="cursor-pointer hover:text-foreground">Platform</span>
|
||||||
|
<span className="cursor-pointer hover:text-foreground">Topologies</span>
|
||||||
|
<span className="cursor-pointer hover:text-foreground">Safety</span>
|
||||||
|
<span className="cursor-pointer hover:text-foreground">Docs</span>
|
||||||
|
</nav>
|
||||||
|
<span className="mx-5 hidden h-[22px] w-px bg-white/10 md:block" />
|
||||||
|
<Link href="/login" className="text-sm text-[#b5b5bd] hover:text-foreground">
|
||||||
|
Sign in
|
||||||
|
</Link>
|
||||||
|
<Link href="/login" className={`${coralBtn} px-4 py-[9px] text-sm`}>
|
||||||
|
Get started
|
||||||
|
</Link>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* HERO */}
|
||||||
|
<section className="relative mx-auto grid max-w-[1240px] grid-cols-1 items-center gap-12 px-8 pb-20 pt-[88px] lg:grid-cols-[1.05fr_0.95fr]">
|
||||||
|
<div
|
||||||
|
className="pointer-events-none absolute left-[30%] top-[-40px] h-[420px] w-[520px]"
|
||||||
|
style={{ background: "radial-gradient(circle, rgba(255,111,97,.13), transparent 68%)" }}
|
||||||
|
/>
|
||||||
|
<div className="relative">
|
||||||
|
<div
|
||||||
|
className={`${mono} mb-6 inline-flex items-center gap-2 rounded-[7px] border px-[11px] py-1.5 text-[11px] text-coral-light`}
|
||||||
|
style={{ borderColor: "rgba(255,111,97,.25)", background: "rgba(255,111,97,.06)" }}
|
||||||
|
>
|
||||||
|
<span className="cm-blink size-1.5 rounded-full bg-coral" />
|
||||||
|
MULTI-AGENT PLATFORM
|
||||||
|
</div>
|
||||||
|
<h1 className="mb-[22px] text-5xl font-bold leading-[1.02] tracking-[-0.035em] text-balance lg:text-[62px]">
|
||||||
|
Deploy agents at any scale.
|
||||||
|
</h1>
|
||||||
|
<p className="mb-8 max-w-[480px] text-[18px] leading-relaxed text-[#a8a8b0]">
|
||||||
|
Every unit of work is a{" "}
|
||||||
|
<span className="font-semibold text-foreground">topology</span> — a graph of role-slots
|
||||||
|
bound to real AI agents. Compose and run agentic systems from a single claw up to a
|
||||||
|
whole org, on a durable, crash-resumable runner.
|
||||||
|
</p>
|
||||||
|
<div className="mb-[30px] flex flex-wrap gap-3">
|
||||||
|
<Link href="/login" className={`${coralBtn} px-[22px] py-[13px] text-[15px]`}>
|
||||||
|
Start deploying →
|
||||||
|
</Link>
|
||||||
|
<span className={`${ghostBtn} px-[22px] py-[13px] text-[15px]`}>Read the paper</span>
|
||||||
|
</div>
|
||||||
|
<div className={`${mono} flex flex-wrap gap-x-[18px] gap-y-2 text-[11px] text-[#6a6a72]`}>
|
||||||
|
<span className="text-running">12 topologies</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>durable runner</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span className="text-online">§15-safe</span>
|
||||||
|
<span>·</span>
|
||||||
|
<span>self-hostable</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* hero constellation */}
|
||||||
|
<div
|
||||||
|
className="relative h-[420px] overflow-hidden rounded-2xl border border-white/[0.07]"
|
||||||
|
style={{
|
||||||
|
background: "radial-gradient(120% 100% at 60% 30%, #0f0f14, #0a0a0c)",
|
||||||
|
boxShadow: "0 30px 80px rgba(0,0,0,.4)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className={`${mono} absolute left-4 top-3.5 text-[10px] tracking-[0.1em] text-[#5a5a62]`}>
|
||||||
|
GROWTH TEAM · hub-spoke · running
|
||||||
|
</div>
|
||||||
|
<svg viewBox="0 0 100 100" preserveAspectRatio="none" className="absolute inset-0 size-full">
|
||||||
|
<path d="M50,52 C40,38 34,32 24,26" fill="none" stroke="rgba(94,200,216,.5)" strokeWidth={1.4} strokeDasharray="3 4" vectorEffect="non-scaling-stroke" className="cm-flow" />
|
||||||
|
<path d="M50,52 C40,64 34,70 26,76" fill="none" stroke="rgba(94,200,216,.5)" strokeWidth={1.4} strokeDasharray="3 4" vectorEffect="non-scaling-stroke" className="cm-flow" />
|
||||||
|
<path d="M50,52 C52,38 53,28 54,18" fill="none" stroke="rgba(255,255,255,.12)" strokeWidth={1.3} vectorEffect="non-scaling-stroke" />
|
||||||
|
<path d="M50,52 C64,46 72,40 80,32" fill="none" stroke="rgba(255,255,255,.12)" strokeWidth={1.3} vectorEffect="non-scaling-stroke" />
|
||||||
|
<path d="M50,52 C64,58 72,66 80,74" fill="none" stroke="rgba(255,111,97,.6)" strokeWidth={1.6} strokeDasharray="3 4" vectorEffect="non-scaling-stroke" className="cm-flow" />
|
||||||
|
</svg>
|
||||||
|
<Node letter="A" from="#ff9a6a" to="#ff6f4a" ink="#2a0d05" size={58} left="50%" top="52%" label="Atlas" />
|
||||||
|
<Node letter="I" from="#6fd0c0" to="#4aa3b8" ink="#06201f" size={40} left="24%" top="26%" label="research…" labelColor="#5ec8d8" halo="rgba(94,200,216,.4)" />
|
||||||
|
<Node letter="N" from="#e8c46a" to="#d89a3a" ink="#2a1d05" left="54%" top="18%" />
|
||||||
|
<Node letter="S" from="#c98af0" to="#9a5ad8" ink="#1a0a2a" left="80%" top="32%" dim />
|
||||||
|
<Node letter="E" from="#8a9af0" to="#5a6ad8" ink="#0a0e2a" left="26%" top="76%" />
|
||||||
|
<Node letter="M" from="#ff8a7a" to="#ff5f57" ink="#2a0d0a" size={44} left="80%" top="74%" label="Morpheus" labelColor="#ff8a7a" ring />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* DEPLOY LADDER */}
|
||||||
|
<section className="mx-auto max-w-[1240px] px-8 py-16">
|
||||||
|
<div className="mb-12 text-center">
|
||||||
|
<div className={`${mono} mb-3.5 text-[11px] tracking-[0.16em] text-running`}>
|
||||||
|
THE DEPLOY LADDER
|
||||||
|
</div>
|
||||||
|
<h2 className="mb-3.5 text-[40px] font-bold tracking-[-0.03em]">One model, four scales.</h2>
|
||||||
|
<p className="mx-auto max-w-[580px] text-[17px] leading-relaxed text-[#a8a8b0]">
|
||||||
|
Each rung composes the one below — a company is staffed with teams, an org with companies.
|
||||||
|
Pick a scale; we instantiate the topology and bind it to real, chattable claws.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{[
|
||||||
|
{ n: "01 · SINGLE", t: "A claw", d: "One agent in a §15 sandbox. Chat with it, give it skills, point it at a task." },
|
||||||
|
{ n: "02 · TEAM", t: "A team", d: "A topology of claws — lead, researchers, writer, critic — running one of 12 patterns." },
|
||||||
|
{ n: "03 · COMPANY", t: "A company", d: "Teams composed into a coordinating topology. Run the parent, run every child." },
|
||||||
|
{ n: "04 · ORG", t: "A whole org", d: "Companies under one roof. Recursive execution all the way down to the leaf claws.", hot: true },
|
||||||
|
].map((c) => (
|
||||||
|
<div
|
||||||
|
key={c.n}
|
||||||
|
className={`rounded-[14px] border p-[22px] transition-colors ${
|
||||||
|
c.hot
|
||||||
|
? "border-coral/30 bg-gradient-to-b from-[#150f12] to-[#0d0d10]"
|
||||||
|
: "border-white/[0.08] bg-[#0d0d10] hover:border-coral/35"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<div className={`${mono} mb-[18px] text-[10px] ${c.hot ? "text-coral-light" : "text-[#5a5a62]"}`}>
|
||||||
|
{c.n}
|
||||||
|
</div>
|
||||||
|
<div className="mb-2 text-coral">
|
||||||
|
<MeshMark size={32} variant={c.t === "A claw" ? "team" : c.t === "A team" ? "team" : c.t === "A company" ? "company" : "org"} title="" />
|
||||||
|
</div>
|
||||||
|
<div className="mb-1.5 text-[18px] font-bold">{c.t}</div>
|
||||||
|
<div className="text-[13px] leading-relaxed text-[#8a8a92]">{c.d}</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* TOPOLOGIES */}
|
||||||
|
<section className="border-t border-white/[0.06] bg-gradient-to-b from-[#0a0a0c] to-[#08080a]">
|
||||||
|
<div className="mx-auto max-w-[1240px] px-8 py-[72px]">
|
||||||
|
<div className="mb-10">
|
||||||
|
<div className={`${mono} mb-3.5 text-[11px] tracking-[0.16em] text-running`}>
|
||||||
|
THE TAXONOMY
|
||||||
|
</div>
|
||||||
|
<h2 className="mb-3 max-w-[620px] text-[40px] font-bold leading-tight tracking-[-0.03em] text-balance">
|
||||||
|
Twelve topologies. Five execution patterns.
|
||||||
|
</h2>
|
||||||
|
<p className="max-w-[520px] text-[17px] leading-relaxed text-[#a8a8b0]">
|
||||||
|
Classify a task, build the structure that fits it, and switch patterns without rewriting
|
||||||
|
a thing — authority stays invariant across every one.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3.5 sm:grid-cols-3 lg:grid-cols-4">
|
||||||
|
{TOPOLOGIES.map((t) => (
|
||||||
|
<TopoCard key={t.name} t={t} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* RECURSIVE EXECUTION */}
|
||||||
|
<section className="mx-auto max-w-[1240px] px-8 py-[72px]">
|
||||||
|
<div className="grid grid-cols-1 items-center gap-12 lg:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<div className={`${mono} mb-3.5 text-[11px] tracking-[0.16em] text-running`}>
|
||||||
|
RECURSIVE EXECUTION
|
||||||
|
</div>
|
||||||
|
<h2 className="mb-4 text-[38px] font-bold tracking-[-0.03em] text-balance">
|
||||||
|
Run the parent, run everything beneath it.
|
||||||
|
</h2>
|
||||||
|
<p className="mb-[26px] text-[17px] leading-relaxed text-[#a8a8b0]">
|
||||||
|
Executing a node runs its entire sub-topology, all the way down to the leaf claws — on a
|
||||||
|
durable runner that checkpoints every step.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-3.5">
|
||||||
|
{[
|
||||||
|
{ c: "#5fd08a", t: "Crash-resumable", d: "Checkpointed per step. A crash resumes where it left off — not from zero." },
|
||||||
|
{ c: "#5ec8d8", t: "Live + cancellable", d: "Stream every turn over SSE; cancel a run mid-flight at any tier." },
|
||||||
|
{ c: "#ff6f61", t: "Recursive zoom canvas", d: "One view for every tier — click a node to drill down, breadcrumb to climb back up." },
|
||||||
|
].map((f) => (
|
||||||
|
<div key={f.t} className="flex items-start gap-3">
|
||||||
|
<span className="mt-1.5 size-2 flex-none rounded-[2px]" style={{ background: f.c }} />
|
||||||
|
<div>
|
||||||
|
<div className="text-[15px] font-semibold">{f.t}</div>
|
||||||
|
<div className="text-[13px] leading-relaxed text-[#8a8a92]">{f.d}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="relative h-[340px] overflow-hidden rounded-2xl border border-white/[0.07] bg-[#0c0c0f]"
|
||||||
|
style={{ boxShadow: "0 24px 60px rgba(0,0,0,.4)" }}
|
||||||
|
>
|
||||||
|
<div className={`${mono} flex items-center gap-1.5 border-b border-white/[0.06] px-4 py-3.5 text-[11px]`}>
|
||||||
|
<span className="text-[#6a6a72]">Org</span>
|
||||||
|
<span className="text-[#3a3a40]">/</span>
|
||||||
|
<span className="text-[#9a9aa2]">Acme Corp</span>
|
||||||
|
<span className="text-[#3a3a40]">/</span>
|
||||||
|
<span className="text-coral-light">Growth Team</span>
|
||||||
|
</div>
|
||||||
|
<div className="absolute inset-x-0 bottom-0 top-12">
|
||||||
|
<svg viewBox="0 0 100 100" preserveAspectRatio="none" className="absolute inset-0 size-full">
|
||||||
|
<path d="M50,46 L24,26 M50,46 L76,26 M50,46 L28,74 M50,46 L74,74" stroke="rgba(255,255,255,.12)" strokeWidth={1.2} vectorEffect="non-scaling-stroke" />
|
||||||
|
<path d="M50,46 L24,26" stroke="rgba(94,200,216,.55)" strokeWidth={1.5} strokeDasharray="3 4" vectorEffect="non-scaling-stroke" className="cm-flow" />
|
||||||
|
</svg>
|
||||||
|
<Node letter="A" from="#ff9a6a" to="#ff6f4a" ink="#2a0d05" size={46} left="50%" top="46%" />
|
||||||
|
<Node letter="I" from="#6fd0c0" to="#4aa3b8" ink="#06201f" size={30} left="24%" top="26%" />
|
||||||
|
<Node letter="N" from="#e8c46a" to="#d89a3a" ink="#2a1d05" size={30} left="76%" top="26%" />
|
||||||
|
<Node letter="E" from="#8a9af0" to="#5a6ad8" ink="#0a0e2a" size={30} left="28%" top="74%" />
|
||||||
|
<Node letter="S" from="#c98af0" to="#9a5ad8" ink="#1a0a2a" size={30} left="74%" top="74%" />
|
||||||
|
</div>
|
||||||
|
<div className={`${mono} absolute bottom-3 left-4 text-[10px] text-online`}>
|
||||||
|
● step 142 · checkpoint 3s ago
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* COMPARE + MODELS */}
|
||||||
|
<section className="mx-auto max-w-[1240px] px-8 pb-[72px]">
|
||||||
|
<div className="grid grid-cols-1 gap-4 lg:grid-cols-[1.2fr_1fr]">
|
||||||
|
<div className="flex items-center gap-7 rounded-2xl border border-white/[0.08] bg-[#0d0d10] p-7">
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className={`${mono} mb-3 text-[11px] text-amber`}>COMPARE & EVOLVE</div>
|
||||||
|
<h3 className="mb-2.5 text-2xl font-bold tracking-[-0.02em]">
|
||||||
|
Run one task across many topologies.
|
||||||
|
</h3>
|
||||||
|
<p className="text-sm leading-relaxed text-[#9a9aa2]">
|
||||||
|
Get a quality/cost Pareto front, then let a MAP-Elites search evolve better team
|
||||||
|
configurations using the comparison harness as fitness.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<svg width="180" height="150" viewBox="0 0 180 150" className="flex-none">
|
||||||
|
<line x1="24" y1="126" x2="168" y2="126" stroke="rgba(255,255,255,.12)" />
|
||||||
|
<line x1="24" y1="14" x2="24" y2="126" stroke="rgba(255,255,255,.12)" />
|
||||||
|
<path d="M34,104 C70,96 108,52 156,30" fill="none" stroke="rgba(255,111,97,.5)" strokeWidth={1.4} strokeDasharray="3 3" />
|
||||||
|
{[[34, 104], [68, 88], [104, 58], [156, 30]].map(([x, y], i) => (
|
||||||
|
<circle key={i} cx={x} cy={y} r={4} fill="#ff6f61" />
|
||||||
|
))}
|
||||||
|
{[[58, 112], [92, 96], [120, 92], [138, 64]].map(([x, y], i) => (
|
||||||
|
<circle key={i} cx={x} cy={y} r={3} fill="#4a4a52" />
|
||||||
|
))}
|
||||||
|
<text x="92" y="144" fill="#5a5a62" fontSize="9" textAnchor="middle" className="font-mono">cost →</text>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-2xl border border-white/[0.08] bg-[#0d0d10] p-7">
|
||||||
|
<div className={`${mono} mb-3 text-[11px] text-running`}>HETEROGENEOUS</div>
|
||||||
|
<h3 className="mb-4 text-2xl font-bold tracking-[-0.02em]">Bind any node to any model.</h3>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{["Claude", "Gemini", "Groq", "GLM", "Kimi"].map((m, i) => (
|
||||||
|
<span
|
||||||
|
key={m}
|
||||||
|
className={`${mono} rounded-lg px-3 py-[7px] text-xs ${
|
||||||
|
i === 0 ? "text-[#e6e6ea]" : "text-[#cfcfd5]"
|
||||||
|
}`}
|
||||||
|
style={
|
||||||
|
i === 0
|
||||||
|
? { border: "1px solid rgba(255,111,97,.3)", background: "rgba(255,111,97,.07)" }
|
||||||
|
: { border: "1px solid rgba(255,255,255,.1)", background: "rgba(255,255,255,.03)" }
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{m}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="mt-[18px] text-sm leading-relaxed text-[#9a9aa2]">
|
||||||
|
Mix backends across a single topology — a cheap model for fan-out, a strong one for the
|
||||||
|
judge.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* SAFETY */}
|
||||||
|
<section className="border-t border-white/[0.06] bg-gradient-to-b from-[#0a0a0c] to-[#08080a]">
|
||||||
|
<div className="mx-auto max-w-[1240px] px-8 py-[72px]">
|
||||||
|
<div className="grid grid-cols-1 items-center gap-12 lg:grid-cols-[1fr_1.1fr]">
|
||||||
|
<div>
|
||||||
|
<div className={`${mono} mb-3.5 text-[11px] tracking-[0.16em] text-online`}>
|
||||||
|
§15 · SAFE BY CONSTRUCTION
|
||||||
|
</div>
|
||||||
|
<h2 className="mb-4 text-[38px] font-bold tracking-[-0.03em] text-balance">
|
||||||
|
Authority is topology-invariant.
|
||||||
|
</h2>
|
||||||
|
<p className="mb-[22px] text-[17px] leading-relaxed text-[#a8a8b0]">
|
||||||
|
No choice of structure — and no switch between structures — can let an agent exceed its
|
||||||
|
sandbox. The network segmentation{" "}
|
||||||
|
<span className="font-semibold text-foreground">is</span> the security model.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-col gap-3 text-sm text-[#b5b5bd]">
|
||||||
|
{[
|
||||||
|
"Agents run tool-free in network-isolated sandboxes",
|
||||||
|
'Every sandbox-leaving action is a gated, human-approvable "door"',
|
||||||
|
"A secret broker holds credentials that never reach agent code",
|
||||||
|
].map((s) => (
|
||||||
|
<div key={s} className="flex items-center gap-2.5">
|
||||||
|
<span className="text-online">✓</span> {s}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className="rounded-2xl border border-white/[0.08] bg-[#0c0c0f] p-6"
|
||||||
|
style={{ boxShadow: "0 24px 60px rgba(0,0,0,.4)" }}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col gap-2.5">
|
||||||
|
<div className="flex items-center gap-3 rounded-[11px] border border-dashed border-white/[0.14] bg-[#0f0f13] px-4 py-3.5">
|
||||||
|
<span className="flex size-[34px] items-center justify-center rounded-[9px]" style={{ background: "rgba(255,111,97,.14)" }}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 20 20"><rect x="4" y="4" width="12" height="12" rx="3" stroke="#ff6f61" strokeWidth="1.5" fill="none" /><circle cx="10" cy="10" r="2" fill="#ff6f61" /></svg>
|
||||||
|
</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-[13px] font-semibold">Agent sandbox</div>
|
||||||
|
<div className={`${mono} text-[10px] text-[#6a6a72]`}>tool-free · no network</div>
|
||||||
|
</div>
|
||||||
|
<span className={`${mono} rounded-[5px] px-2 py-[3px] text-[9px] text-coral-light`} style={{ background: "rgba(255,111,97,.1)" }}>isolated</span>
|
||||||
|
</div>
|
||||||
|
<div className={`${mono} flex justify-center text-[11px] text-[#3a3a40]`}>↓ gated door · human approval</div>
|
||||||
|
<div className="flex items-center gap-3 rounded-[11px] border bg-[#0f0f13] px-4 py-3.5" style={{ borderColor: "rgba(94,200,216,.22)" }}>
|
||||||
|
<span className="flex size-[34px] items-center justify-center rounded-[9px]" style={{ background: "rgba(94,200,216,.14)" }}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 20 20"><path d="M10 2v6m0 0l3-3m-3 3L7 5M3 12v4a2 2 0 002 2h10a2 2 0 002-2v-4" stroke="#5ec8d8" strokeWidth="1.5" fill="none" strokeLinecap="round" /></svg>
|
||||||
|
</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-[13px] font-semibold">Door tool → egress</div>
|
||||||
|
<div className={`${mono} text-[10px] text-[#6a6a72]`}>email · slack · browser</div>
|
||||||
|
</div>
|
||||||
|
<span className={`${mono} rounded-[5px] px-2 py-[3px] text-[9px] text-online`} style={{ background: "rgba(95,208,138,.1)" }}>approved</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3 rounded-[11px] border border-white/[0.08] bg-[#0f0f13] px-4 py-3.5">
|
||||||
|
<span className="flex size-[34px] items-center justify-center rounded-[9px]" style={{ background: "rgba(232,196,106,.14)" }}>
|
||||||
|
<svg width="18" height="18" viewBox="0 0 20 20"><rect x="4" y="9" width="12" height="8" rx="2" stroke="#e8b465" strokeWidth="1.5" fill="none" /><path d="M7 9V6.5a3 3 0 016 0V9" stroke="#e8b465" strokeWidth="1.5" fill="none" /></svg>
|
||||||
|
</span>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="text-[13px] font-semibold">Secret broker</div>
|
||||||
|
<div className={`${mono} text-[10px] text-[#6a6a72]`}>private socket · creds never leave</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* SELF-HOST */}
|
||||||
|
<section className="mx-auto max-w-[1240px] px-8 py-[72px]">
|
||||||
|
<div className="grid grid-cols-1 items-center gap-10 rounded-2xl border border-white/[0.08] bg-gradient-to-b from-[#0e0e12] to-[#0a0a0c] p-10 lg:grid-cols-2">
|
||||||
|
<div>
|
||||||
|
<div className={`${mono} mb-3.5 text-[11px] tracking-[0.16em] text-purple`}>
|
||||||
|
SELF-HOSTABLE
|
||||||
|
</div>
|
||||||
|
<h2 className="mb-3.5 text-[32px] font-bold tracking-[-0.03em]">
|
||||||
|
The whole platform, one node.
|
||||||
|
</h2>
|
||||||
|
<p className="text-base leading-relaxed text-[#a8a8b0]">
|
||||||
|
A single-node Docker Compose deployment runs everything with the same network-segmented
|
||||||
|
security model as the Kubernetes path. The server self-migrates on boot.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className={`${mono} rounded-xl border border-white/[0.08] bg-[#070708] p-5 text-[13px] leading-[1.9]`}>
|
||||||
|
<div className="text-[#5a5a62]"># bring up the stack</div>
|
||||||
|
<div><span className="text-online">$</span> <span className="text-[#cfcfd5]">cd deploy/compose</span></div>
|
||||||
|
<div><span className="text-online">$</span> <span className="text-[#cfcfd5]">docker compose up -d</span></div>
|
||||||
|
<div className="mt-2 text-[#5a5a62]"># app → localhost:3000</div>
|
||||||
|
<div className="text-running">✓ server self-migrated · owner provisioned</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* CTA */}
|
||||||
|
<section className="mx-auto max-w-[1240px] px-8 pb-24 pt-10">
|
||||||
|
<div
|
||||||
|
className="relative overflow-hidden rounded-[20px] border px-8 py-[72px] text-center"
|
||||||
|
style={{
|
||||||
|
borderColor: "rgba(255,111,97,.25)",
|
||||||
|
background: "radial-gradient(120% 140% at 50% 0%, rgba(255,111,97,.12), #0c0c0f 60%)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<h2 className="mb-4 text-[46px] font-bold tracking-[-0.035em] text-balance">
|
||||||
|
Deploy your first claw.
|
||||||
|
</h2>
|
||||||
|
<p className="mb-[30px] text-[18px] text-[#a8a8b0]">
|
||||||
|
From one agent to a whole org — same model, same guarantees.
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap justify-center gap-3">
|
||||||
|
<Link href="/login" className={`${coralBtn} px-[26px] py-3.5 text-base`}>
|
||||||
|
Get started free →
|
||||||
|
</Link>
|
||||||
|
<span className={`${ghostBtn} px-[26px] py-3.5 text-base`}>clawmates.work</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* FOOTER */}
|
||||||
|
<footer className="border-t border-white/[0.06] bg-[#0a0a0c]">
|
||||||
|
<div className="mx-auto flex max-w-[1240px] flex-wrap items-center gap-4 px-8 py-10">
|
||||||
|
<div className="flex items-center gap-2.5 text-coral">
|
||||||
|
<MeshMark size={20} title="" />
|
||||||
|
<span className="text-sm font-bold text-foreground">Clawmates</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<div className="flex gap-6 text-[13px] text-[#8a8a92]">
|
||||||
|
<span className="cursor-pointer hover:text-foreground">Platform</span>
|
||||||
|
<span className="cursor-pointer hover:text-foreground">Topologies</span>
|
||||||
|
<span className="cursor-pointer hover:text-foreground">Safety</span>
|
||||||
|
<span className="cursor-pointer hover:text-foreground">Docs</span>
|
||||||
|
<span className="cursor-pointer hover:text-foreground">Paper</span>
|
||||||
|
</div>
|
||||||
|
<div className={`${mono} mt-6 w-full border-t border-white/[0.05] pt-5 text-[11px] text-[#5a5a62]`}>
|
||||||
|
© 2026 Clawmates · authority is topology-invariant
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,14 +5,13 @@ import { useQueryStates } from "nuqs";
|
|||||||
import { Menu } from "lucide-react";
|
import { Menu } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
import type { Agent, User } from "@/lib/api/schemas";
|
import type { Agent } from "@/lib/api/schemas";
|
||||||
import type { GroupSummary } from "@/lib/api/structure";
|
import type { GroupSummary } from "@/lib/api/structure";
|
||||||
import { panelParsers } from "@/lib/url/panel-params";
|
import { panelParsers } from "@/lib/url/panel-params";
|
||||||
import { RosterColumn } from "./RosterColumn";
|
import { RosterColumn } from "./RosterColumn";
|
||||||
import { StructureRail } from "./StructureRail";
|
import { StructureRail } from "./StructureRail";
|
||||||
|
|
||||||
interface LeftRailProps {
|
interface LeftRailProps {
|
||||||
user: User;
|
|
||||||
roster: Agent[];
|
roster: Agent[];
|
||||||
orgs: GroupSummary[];
|
orgs: GroupSummary[];
|
||||||
companies: GroupSummary[];
|
companies: GroupSummary[];
|
||||||
@@ -20,13 +19,11 @@ interface LeftRailProps {
|
|||||||
creditsBalance?: number;
|
creditsBalance?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** The persistent two-tier left rail (Discord-style): a thin 68px structure
|
/** The persistent left navigation: a thin structure column (the org/company/team
|
||||||
* column (mesh mark + the org/company/team hierarchy + tools + deploy + user)
|
* hierarchy as glyph tiles + tools + deploy) and a context column (the selected
|
||||||
* and a 176px context column (the selected group's children, or your claws).
|
* group's children, or your claws). Brand + user live in the TopBar. In
|
||||||
* In full-panel mode the context column collapses away; at ≤md the whole rail
|
* full-panel mode the context column collapses; at ≤md the rail is a drawer. */
|
||||||
* slides in as a drawer. */
|
|
||||||
export function LeftRail({
|
export function LeftRail({
|
||||||
user,
|
|
||||||
roster,
|
roster,
|
||||||
orgs,
|
orgs,
|
||||||
companies,
|
companies,
|
||||||
@@ -66,12 +63,11 @@ export function LeftRail({
|
|||||||
)}
|
)}
|
||||||
<aside
|
<aside
|
||||||
data-collapsed={collapsed ? "true" : "false"}
|
data-collapsed={collapsed ? "true" : "false"}
|
||||||
className={`group/rail z-50 flex h-dvh shrink-0 bg-background transition-transform duration-(--duration-normal) ease-app max-md:fixed max-md:shadow-edge ${
|
className={`group/rail z-50 flex h-full shrink-0 bg-background transition-transform duration-(--duration-normal) ease-app max-md:fixed max-md:inset-y-0 max-md:shadow-edge ${
|
||||||
open ? "max-md:translate-x-0" : "max-md:-translate-x-full"
|
open ? "max-md:translate-x-0" : "max-md:-translate-x-full"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<StructureRail
|
<StructureRail
|
||||||
user={user}
|
|
||||||
orgs={orgs}
|
orgs={orgs}
|
||||||
companies={companies}
|
companies={companies}
|
||||||
teams={teams}
|
teams={teams}
|
||||||
|
|||||||
@@ -59,8 +59,8 @@ export function RosterColumn({ roster }: { roster: Agent[] }) {
|
|||||||
const showGroup = level && id;
|
const showGroup = level && id;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-rail flex-col border-l border-divider-subtle pt-2 pb-6">
|
<div className="flex h-full w-[252px] flex-col border-r border-white/[0.06] bg-[#0b0b0e] pb-4 pt-3">
|
||||||
<header className="flex h-20 items-center px-4">
|
<header className="flex h-12 items-center px-4">
|
||||||
<span className="truncate text-sm font-semibold text-foreground">
|
<span className="truncate text-sm font-semibold text-foreground">
|
||||||
{showGroup ? (node?.name ?? "…") : "Claws"}
|
{showGroup ? (node?.name ?? "…") : "Claws"}
|
||||||
</span>
|
</span>
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// The dashboard status bar (design comp): durable-runner state, sandbox/§15
|
||||||
|
// facts, and the count of doors (gated actions) awaiting approval.
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
import type { StructureStats } from "@/lib/api/structure";
|
||||||
|
|
||||||
|
export function StatusBar({
|
||||||
|
stats,
|
||||||
|
doors,
|
||||||
|
}: {
|
||||||
|
stats: StructureStats | null;
|
||||||
|
doors: number;
|
||||||
|
}) {
|
||||||
|
const running = stats?.running_now ?? 0;
|
||||||
|
return (
|
||||||
|
<footer className="flex h-7 flex-none items-center gap-4 border-t border-white/[0.06] bg-[#0a0a0c] px-4 font-mono text-[10px] text-[#6a6a72]">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className={`size-[6px] rounded-full ${running > 0 ? "bg-running cm-blink" : "bg-[#3a3a40]"}`} />
|
||||||
|
durable runner {running > 0 ? `· ${running} active` : "· idle"}
|
||||||
|
</span>
|
||||||
|
<span className="text-[#3a3a40]">·</span>
|
||||||
|
<span className="text-online">sandbox: isolated</span>
|
||||||
|
<span className="text-[#3a3a40]">·</span>
|
||||||
|
<span>network: none</span>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<Link
|
||||||
|
href="/approvals"
|
||||||
|
className={`flex items-center gap-1.5 transition-colors hover:text-foreground ${
|
||||||
|
doors > 0 ? "text-amber" : ""
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span className={`size-[6px] rounded-full ${doors > 0 ? "bg-amber cm-blink" : "bg-[#3a3a40]"}`} />
|
||||||
|
{doors} door{doors === 1 ? "" : "s"} awaiting approval
|
||||||
|
</Link>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
// The thin (68px) structure column of the two-tier rail — Discord-style. The
|
// The thin (60px) structure column of the dashboard shell. Below the top bar,
|
||||||
// mesh mark sits on top; below it the deploy hierarchy as small node-cluster
|
// it lists the deploy hierarchy as small node-cluster glyphs (Orgs, then
|
||||||
// glyphs (Orgs, then Companies, then Teams), each tier separated by a divider.
|
// Companies, then Teams), each tier separated by a divider; selecting a tile
|
||||||
// Selecting a tile navigates to its zoom canvas (and drives the context column).
|
// navigates to its zoom canvas. The bottom holds the tools popover + Deploy.
|
||||||
// The bottom holds the tools popover, a Deploy tile, and the user avatar.
|
// (Brand + user live in the TopBar.)
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
@@ -12,12 +12,9 @@ import { Plus, X } from "lucide-react";
|
|||||||
|
|
||||||
import { MeshMark, type MeshVariant } from "@/components/brand/MeshMark";
|
import { MeshMark, type MeshVariant } from "@/components/brand/MeshMark";
|
||||||
import type { GroupSummary } from "@/lib/api/structure";
|
import type { GroupSummary } from "@/lib/api/structure";
|
||||||
import type { User } from "@/lib/api/schemas";
|
|
||||||
import { SecondaryNav } from "./SecondaryNav";
|
import { SecondaryNav } from "./SecondaryNav";
|
||||||
import { UserMenu } from "./UserMenu";
|
|
||||||
|
|
||||||
interface StructureRailProps {
|
interface StructureRailProps {
|
||||||
user: User;
|
|
||||||
orgs: GroupSummary[];
|
orgs: GroupSummary[];
|
||||||
companies: GroupSummary[];
|
companies: GroupSummary[];
|
||||||
teams: GroupSummary[];
|
teams: GroupSummary[];
|
||||||
@@ -52,7 +49,6 @@ function StructureTile({
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function StructureRail({
|
export function StructureRail({
|
||||||
user,
|
|
||||||
orgs,
|
orgs,
|
||||||
companies,
|
companies,
|
||||||
teams,
|
teams,
|
||||||
@@ -67,23 +63,17 @@ export function StructureRail({
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex h-full w-[68px] shrink-0 flex-col items-center pt-2 pb-6">
|
<div className="flex h-full w-[60px] shrink-0 flex-col items-center border-r border-white/[0.06] bg-[#0a0a0c] pt-3.5 pb-4">
|
||||||
<header className="flex h-20 w-full items-center justify-center">
|
|
||||||
<Link href="/" aria-label="clawmates" className="inline-flex text-coral">
|
|
||||||
<MeshMark size={36} title="" />
|
|
||||||
<span className="sr-only">clawmates</span>
|
|
||||||
</Link>
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
aria-label="Close navigation"
|
aria-label="Close navigation"
|
||||||
onClick={onClose}
|
onClick={onClose}
|
||||||
className="absolute top-6 right-3 hidden text-muted-foreground max-md:inline-flex"
|
className="mb-2 hidden text-muted-foreground max-md:inline-flex"
|
||||||
>
|
>
|
||||||
<X aria-hidden size={16} />
|
<X aria-hidden size={16} />
|
||||||
</button>
|
</button>
|
||||||
</header>
|
|
||||||
|
|
||||||
<div className="flex w-full flex-1 flex-col items-center gap-2 overflow-y-auto py-2 [&::-webkit-scrollbar]:hidden">
|
<div className="flex w-full flex-1 flex-col items-center gap-2 overflow-y-auto py-1 [&::-webkit-scrollbar]:hidden">
|
||||||
{tiers.map((tier) =>
|
{tiers.map((tier) =>
|
||||||
tier.items.length > 0 ? (
|
tier.items.length > 0 ? (
|
||||||
<div key={tier.base} className="flex w-full flex-col items-center gap-1">
|
<div key={tier.base} className="flex w-full flex-col items-center gap-1">
|
||||||
@@ -110,11 +100,10 @@ export function StructureRail({
|
|||||||
title="Deploy"
|
title="Deploy"
|
||||||
className="group flex justify-center"
|
className="group flex justify-center"
|
||||||
>
|
>
|
||||||
<span className="inline-flex size-11 items-center justify-center rounded-[15px] border border-dashed border-border text-coral transition-colors duration-(--duration-normal) ease-app group-hover:border-coral">
|
<span className="inline-flex size-9 items-center justify-center rounded-lg border border-dashed border-white/[0.16] text-coral transition-colors duration-(--duration-normal) ease-app group-hover:border-coral">
|
||||||
<Plus size={18} />
|
<Plus size={18} />
|
||||||
</span>
|
</span>
|
||||||
</Link>
|
</Link>
|
||||||
<UserMenu user={user} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -0,0 +1,71 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// The dashboard top bar (design comp): brand, a tier breadcrumb derived from the
|
||||||
|
// route, a live "N claws · running" badge, the Deploy button, and the user
|
||||||
|
// avatar. Full-width chrome above the structure rail + canvas.
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
|
import { MeshMark } from "@/components/brand/MeshMark";
|
||||||
|
import type { User } from "@/lib/api/schemas";
|
||||||
|
import type { StructureStats } from "@/lib/api/structure";
|
||||||
|
|
||||||
|
const TIER_OF: Record<string, string> = {
|
||||||
|
orgs: "Org",
|
||||||
|
companies: "Company",
|
||||||
|
teams: "Team",
|
||||||
|
claws: "Claw",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function TopBar({ user, stats }: { user: User; stats: StructureStats | null }) {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const seg = pathname.split("/").filter(Boolean); // e.g. ["teams","<id>"]
|
||||||
|
const tier = seg[0] ? TIER_OF[seg[0]] : undefined;
|
||||||
|
|
||||||
|
const crumbs: string[] = ["Workspace"];
|
||||||
|
if (tier) crumbs.push(tier);
|
||||||
|
|
||||||
|
const running = stats?.running_now ?? 0;
|
||||||
|
const claws = stats?.claw_count ?? 0;
|
||||||
|
const initial = (user.display_name || user.email || "?").trim().charAt(0).toUpperCase();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<header className="flex h-[54px] flex-none items-center gap-3.5 border-b border-white/[0.06] bg-gradient-to-b from-[#0d0d10] to-[#0a0a0c] px-4">
|
||||||
|
<Link href="/" className="flex items-center gap-2.5 text-coral">
|
||||||
|
<MeshMark size={22} title="" />
|
||||||
|
<span className="text-[15px] font-bold tracking-[-0.01em] text-foreground">Clawmates</span>
|
||||||
|
</Link>
|
||||||
|
<span className="h-[22px] w-px bg-white/[0.08]" />
|
||||||
|
<nav className="flex items-center gap-1.5 font-mono text-xs">
|
||||||
|
{crumbs.map((c, i) => (
|
||||||
|
<span key={i} className="flex items-center gap-1.5">
|
||||||
|
{i > 0 ? <span className="text-[#3a3a40]">/</span> : null}
|
||||||
|
<span className={i === crumbs.length - 1 ? "text-coral-light" : "text-[#9a9aa2]"}>{c}</span>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<div
|
||||||
|
className="hidden items-center gap-1.5 rounded-[7px] border px-2.5 py-[5px] font-mono text-[11px] text-online sm:flex"
|
||||||
|
style={{ borderColor: "rgba(95,208,138,.25)", background: "rgba(95,208,138,.06)" }}
|
||||||
|
>
|
||||||
|
<span className={`size-[7px] rounded-full bg-online ${running > 0 ? "cm-blink" : ""}`} />
|
||||||
|
{claws} claws{running > 0 ? " · running" : ""}
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href="/claws/new"
|
||||||
|
className="flex items-center gap-1.5 rounded-lg bg-gradient-to-br from-coral-light to-coral-dark px-3 py-1.5 text-xs font-bold text-[#2a0d0a] transition-[filter] hover:brightness-110"
|
||||||
|
>
|
||||||
|
<span className="text-[15px] leading-none">+</span> Deploy
|
||||||
|
</Link>
|
||||||
|
<span
|
||||||
|
className="flex size-8 items-center justify-center rounded-lg text-[13px] font-bold text-[#2a0d0a]"
|
||||||
|
style={{ background: "linear-gradient(135deg,#ff8a7a,#ff5f57)" }}
|
||||||
|
title={user.display_name || user.email}
|
||||||
|
>
|
||||||
|
{initial}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
// The radial recursive canvas (design comp, Frame A "Constellation"): the
|
||||||
|
// current group sits at the center, its children radiate around it on animated
|
||||||
|
// flow edges. Clicking a child opens it (drill down a tier, or — for a claw —
|
||||||
|
// select it to reveal the computer panel). Pure SVG + positioned avatar nodes.
|
||||||
|
|
||||||
|
import type { StructureChild } from "@/lib/api/structure";
|
||||||
|
|
||||||
|
const GRADS: [string, string, string][] = [
|
||||||
|
["#ff8a7a", "#ff5f57", "#2a0d0a"],
|
||||||
|
["#6fd0c0", "#4aa3b8", "#06201f"],
|
||||||
|
["#e8c46a", "#d89a3a", "#2a1d05"],
|
||||||
|
["#c98af0", "#9a5ad8", "#1a0a2a"],
|
||||||
|
["#8a9af0", "#5a6ad8", "#0a0e2a"],
|
||||||
|
["#5ec8d8", "#3a8aa0", "#06222a"],
|
||||||
|
];
|
||||||
|
|
||||||
|
function initial(s: string): string {
|
||||||
|
return (s || "?").trim().charAt(0).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ConstellationCanvas({
|
||||||
|
centerLabel,
|
||||||
|
kind,
|
||||||
|
items,
|
||||||
|
onOpen,
|
||||||
|
selectedNodeId,
|
||||||
|
}: {
|
||||||
|
centerLabel: string;
|
||||||
|
kind: string | null;
|
||||||
|
items: StructureChild[];
|
||||||
|
onOpen: (child: StructureChild) => void;
|
||||||
|
selectedNodeId?: string | null;
|
||||||
|
}) {
|
||||||
|
const n = Math.max(items.length, 1);
|
||||||
|
const R = 34; // radius in %
|
||||||
|
const pos = items.map((_, i) => {
|
||||||
|
const a = (i / n) * Math.PI * 2 - Math.PI / 2;
|
||||||
|
return { x: 50 + R * Math.cos(a), y: 50 + R * Math.sin(a) };
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="relative h-[360px] overflow-hidden rounded-2xl border border-white/[0.07]"
|
||||||
|
style={{
|
||||||
|
background:
|
||||||
|
"radial-gradient(120% 100% at 50% 40%, #0f0f14, #0a0a0c), repeating-linear-gradient(0deg, transparent 0 27px, rgba(255,255,255,.02) 27px 28px)",
|
||||||
|
boxShadow: "0 24px 60px rgba(0,0,0,.4)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="absolute left-4 top-3.5 font-mono text-[10px] tracking-[0.1em] text-[#5a5a62]">
|
||||||
|
{kind ? kind.toUpperCase() : "TOPOLOGY"} · click a node to descend
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* edges */}
|
||||||
|
<svg viewBox="0 0 100 100" preserveAspectRatio="none" className="absolute inset-0 size-full">
|
||||||
|
{pos.map((p, i) => (
|
||||||
|
<path
|
||||||
|
key={i}
|
||||||
|
d={`M50,50 L${p.x},${p.y}`}
|
||||||
|
fill="none"
|
||||||
|
stroke={i % 3 === 0 ? "rgba(94,200,216,.5)" : "rgba(255,255,255,.12)"}
|
||||||
|
strokeWidth={i % 3 === 0 ? 1.4 : 1.2}
|
||||||
|
strokeDasharray={i % 3 === 0 ? "3 4" : undefined}
|
||||||
|
vectorEffect="non-scaling-stroke"
|
||||||
|
className={i % 3 === 0 ? "cm-flow" : undefined}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* center node */}
|
||||||
|
<div className="absolute left-1/2 top-1/2 flex -translate-x-1/2 -translate-y-1/2 flex-col items-center gap-1.5">
|
||||||
|
<div
|
||||||
|
className="flex size-[54px] items-center justify-center rounded-full text-lg font-bold"
|
||||||
|
style={{
|
||||||
|
background: "linear-gradient(135deg,#ff9a6a,#ff6f4a)",
|
||||||
|
color: "#2a0d05",
|
||||||
|
boxShadow: "0 0 34px rgba(255,111,97,.45)",
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{initial(centerLabel)}
|
||||||
|
</div>
|
||||||
|
<span className="max-w-[120px] truncate text-[11px] font-semibold">{centerLabel}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* child nodes */}
|
||||||
|
{items.map((c, i) => {
|
||||||
|
const [from, to, ink] = GRADS[i % GRADS.length];
|
||||||
|
const p = pos[i];
|
||||||
|
const selected = selectedNodeId === c.node_id;
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={c.node_id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onOpen(c)}
|
||||||
|
aria-label={`Open ${c.child_name || c.role}`}
|
||||||
|
className="absolute flex -translate-x-1/2 -translate-y-1/2 cursor-pointer flex-col items-center gap-1.5 outline-none"
|
||||||
|
style={{ left: `${p.x}%`, top: `${p.y}%` }}
|
||||||
|
>
|
||||||
|
<div className="relative" style={{ width: 40, height: 40 }}>
|
||||||
|
{c.child_level === "claw" && i % 2 === 0 ? (
|
||||||
|
<span
|
||||||
|
className="cm-halo absolute inset-0 rounded-full"
|
||||||
|
style={{ background: "rgba(94,200,216,.35)" }}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
<div
|
||||||
|
className="relative flex size-10 items-center justify-center rounded-full text-[13px] font-bold transition-transform hover:scale-105"
|
||||||
|
style={{
|
||||||
|
background: `linear-gradient(135deg, ${from}, ${to})`,
|
||||||
|
color: ink,
|
||||||
|
border: selected ? "2px solid #ff6f61" : undefined,
|
||||||
|
boxShadow: selected ? "0 0 18px rgba(255,111,97,.5)" : undefined,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{initial(c.child_name || c.role)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="max-w-[88px] truncate text-[10px] text-[#cfcfd5]">
|
||||||
|
{c.child_name || c.role}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,20 +1,21 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
// The recursive zoom canvas: ONE view for every tier (org → company → team →
|
// The recursive zoom canvas: ONE view for every tier (org → company → team →
|
||||||
// claw). It fetches the level's topology, renders it with clickable nodes that
|
// claw). It renders the level's topology as a radial constellation; clicking a
|
||||||
// drill DOWN a tier (a company node → that company's teams; a team node → its
|
// child drills DOWN a tier — except a claw node, which selects it and opens its
|
||||||
// claws; a claw node → the claw's chat), shows a breadcrumb to zoom UP, and a
|
// "computer" panel on the right. A breadcrumb zooms UP; the routines panel and
|
||||||
// per-level action panel. Selecting in the rail and drilling in the canvas
|
// a run panel sit below. Level is fully URL-derivable.
|
||||||
// converge on the same routes, so the level is fully URL-derivable.
|
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
import { MeshMark, type MeshVariant } from "@/components/brand/MeshMark";
|
import { MeshMark, type MeshVariant } from "@/components/brand/MeshMark";
|
||||||
import { TopologyGraphView, type NodeMeta } from "@/components/topology/TopologyGraphView";
|
import { ComputerPanel } from "@/components/computer/ComputerPanel";
|
||||||
|
import { RoutinesPanel } from "@/components/computer/RoutinesPanel";
|
||||||
import { TeamRunPanel } from "@/components/team/TeamRunPanel";
|
import { TeamRunPanel } from "@/components/team/TeamRunPanel";
|
||||||
import { Breadcrumb, type Crumb } from "./Breadcrumb";
|
import { Breadcrumb, type Crumb } from "./Breadcrumb";
|
||||||
|
import { ConstellationCanvas } from "./ConstellationCanvas";
|
||||||
import type { StructureChild, StructureLevel, StructureNode } from "@/lib/api/structure";
|
import type { StructureChild, StructureLevel, StructureNode } from "@/lib/api/structure";
|
||||||
|
|
||||||
const TIER_LABEL: Record<StructureLevel, string> = {
|
const TIER_LABEL: Record<StructureLevel, string> = {
|
||||||
@@ -23,8 +24,6 @@ const TIER_LABEL: Record<StructureLevel, string> = {
|
|||||||
team: "Team",
|
team: "Team",
|
||||||
claw: "Claw",
|
claw: "Claw",
|
||||||
};
|
};
|
||||||
|
|
||||||
// Where a child node drills to + the run scope for the action panel.
|
|
||||||
const CHILD_ROUTE: Record<StructureChild["child_level"], (id: string) => string> = {
|
const CHILD_ROUTE: Record<StructureChild["child_level"], (id: string) => string> = {
|
||||||
company: (id) => `/companies/${id}`,
|
company: (id) => `/companies/${id}`,
|
||||||
team: (id) => `/teams/${id}`,
|
team: (id) => `/teams/${id}`,
|
||||||
@@ -42,19 +41,26 @@ const RUN_SCOPE: Partial<Record<StructureLevel, "teams" | "companies" | "orgs">>
|
|||||||
org: "orgs",
|
org: "orgs",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
interface SelectedClaw {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
nodeId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export function StructureCanvas({ level, id }: { level: StructureLevel; id: string }) {
|
export function StructureCanvas({ level, id }: { level: StructureLevel; id: string }) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [node, setNode] = useState<StructureNode | null>(null);
|
const [node, setNode] = useState<StructureNode | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [selected, setSelected] = useState<SelectedClaw | null>(null);
|
||||||
|
|
||||||
// Reset to the loading state when the target changes (render-phase pattern —
|
// Reset to loading state when the target changes (render-phase pattern).
|
||||||
// avoids a synchronous setState inside the effect).
|
|
||||||
const key = `${level}:${id}`;
|
const key = `${level}:${id}`;
|
||||||
const [seenKey, setSeenKey] = useState(key);
|
const [seenKey, setSeenKey] = useState(key);
|
||||||
if (seenKey !== key) {
|
if (seenKey !== key) {
|
||||||
setSeenKey(key);
|
setSeenKey(key);
|
||||||
setNode(null);
|
setNode(null);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
setSelected(null);
|
||||||
}
|
}
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -73,35 +79,34 @@ export function StructureCanvas({ level, id }: { level: StructureLevel; id: stri
|
|||||||
};
|
};
|
||||||
}, [level, id]);
|
}, [level, id]);
|
||||||
|
|
||||||
if (error) {
|
if (error) return <div className="p-8 text-sm text-coral">{error}</div>;
|
||||||
return <div className="p-8 text-sm text-red-500">{error}</div>;
|
if (!node) return <div className="p-8 text-sm text-muted-foreground">Loading…</div>;
|
||||||
}
|
|
||||||
if (!node) {
|
|
||||||
return <div className="p-8 text-sm text-muted-foreground">Loading…</div>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const childByNode = new Map(node.children.map((c) => [c.node_id, c]));
|
|
||||||
const nodeMeta: Record<string, NodeMeta> = {};
|
|
||||||
for (const c of node.children) {
|
|
||||||
nodeMeta[c.node_id] = { label: c.child_name || c.role, drillable: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
const crumbs: Crumb[] = [
|
|
||||||
{ label: `${TIER_LABEL[level]}s`, href: TIER_LIST[level] },
|
|
||||||
{ label: node.name },
|
|
||||||
];
|
|
||||||
const runScope = RUN_SCOPE[level];
|
const runScope = RUN_SCOPE[level];
|
||||||
const childLabel = node.children[0]?.child_level
|
const childLabel = node.children[0]?.child_level
|
||||||
? `${node.children[0].child_level}s`
|
? `${node.children[0].child_level}s`
|
||||||
: "members";
|
: "members";
|
||||||
|
const crumbs: Crumb[] = [
|
||||||
|
{ label: `${TIER_LABEL[level]}s`, href: TIER_LIST[level] },
|
||||||
|
{ label: node.name },
|
||||||
|
];
|
||||||
|
const headVariant: MeshVariant = level === "claw" ? "team" : level;
|
||||||
|
|
||||||
|
function onOpen(child: StructureChild) {
|
||||||
|
if (child.child_level === "claw") {
|
||||||
|
setSelected({ id: child.child_id, name: child.child_name || child.role, nodeId: child.node_id });
|
||||||
|
} else {
|
||||||
|
router.push(CHILD_ROUTE[child.child_level](child.child_id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="mx-auto flex w-full max-w-4xl flex-col gap-6 p-6">
|
<div className="mx-auto flex w-full max-w-5xl flex-col gap-6 p-6">
|
||||||
<div className="flex flex-col gap-2">
|
<div className="flex flex-col gap-2">
|
||||||
<Breadcrumb items={crumbs} />
|
<Breadcrumb items={crumbs} />
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<span className="text-coral">
|
<span className="text-coral">
|
||||||
<MeshMark size={28} variant={level as MeshVariant} title="" />
|
<MeshMark size={28} variant={headVariant} title="" />
|
||||||
</span>
|
</span>
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-semibold tracking-tight">{node.name}</h1>
|
<h1 className="text-xl font-semibold tracking-tight">{node.name}</h1>
|
||||||
@@ -115,36 +120,29 @@ export function StructureCanvas({ level, id }: { level: StructureLevel; id: stri
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{node.graph ? (
|
{node.graph ? (
|
||||||
<div className="overflow-hidden rounded-lg border border-border bg-card">
|
<div className={`grid grid-cols-1 gap-4 ${selected ? "lg:grid-cols-[1fr_320px]" : ""}`}>
|
||||||
<TopologyGraphView
|
<ConstellationCanvas
|
||||||
graph={node.graph}
|
centerLabel={node.name}
|
||||||
nodeMeta={nodeMeta}
|
kind={node.kind}
|
||||||
onNodeClick={(n) => {
|
items={node.children}
|
||||||
const child = childByNode.get(n.id);
|
onOpen={onOpen}
|
||||||
if (child) router.push(CHILD_ROUTE[child.child_level](child.child_id));
|
selectedNodeId={selected?.nodeId}
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
|
{selected ? (
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
<ComputerPanel clawId={selected.id} clawName={selected.name} />
|
||||||
|
<Link
|
||||||
|
href={`/claws/${selected.id}`}
|
||||||
|
className="text-center font-mono text-[11px] text-coral-light hover:underline"
|
||||||
|
>
|
||||||
|
open {selected.name} →
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{node.children.length > 0 ? (
|
{level === "team" ? <RoutinesPanel /> : null}
|
||||||
<div>
|
|
||||||
<p className="mb-2 text-xs font-medium capitalize text-muted-foreground">{childLabel}</p>
|
|
||||||
<ul className="flex flex-wrap gap-2">
|
|
||||||
{node.children.map((c) => (
|
|
||||||
<li key={c.node_id}>
|
|
||||||
<Link
|
|
||||||
href={CHILD_ROUTE[c.child_level](c.child_id)}
|
|
||||||
className="flex items-center gap-2 rounded-full border border-border px-3 py-1 text-xs text-foreground transition-colors duration-(--duration-normal) ease-app hover:bg-muted/30"
|
|
||||||
>
|
|
||||||
<span className="font-medium">{c.child_name || c.role}</span>
|
|
||||||
<span className="capitalize text-muted-foreground">{c.role}</span>
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{runScope ? (
|
{runScope ? (
|
||||||
<TeamRunPanel
|
<TeamRunPanel
|
||||||
|
|||||||
@@ -37,8 +37,22 @@ export type StructureLevel = z.infer<typeof StructureNodeSchema>["level"];
|
|||||||
export type StructureChild = z.infer<typeof StructureChildSchema>;
|
export type StructureChild = z.infer<typeof StructureChildSchema>;
|
||||||
export type StructureNode = z.infer<typeof StructureNodeSchema>;
|
export type StructureNode = z.infer<typeof StructureNodeSchema>;
|
||||||
|
|
||||||
|
/** Workspace-wide hierarchy counts (breadcrumb + status bar). */
|
||||||
|
export const StructureStatsSchema = z.object({
|
||||||
|
org_count: z.number(),
|
||||||
|
company_count: z.number(),
|
||||||
|
team_count: z.number(),
|
||||||
|
claw_count: z.number(),
|
||||||
|
running_now: z.number(),
|
||||||
|
});
|
||||||
|
export type StructureStats = z.infer<typeof StructureStatsSchema>;
|
||||||
|
|
||||||
// --- server-side (rail, via apiFetch) ---
|
// --- server-side (rail, via apiFetch) ---
|
||||||
|
|
||||||
|
export function fetchStats(): Promise<StructureStats> {
|
||||||
|
return apiFetch(StructureStatsSchema, "/api/structure/stats");
|
||||||
|
}
|
||||||
|
|
||||||
export function fetchOrgs(): Promise<GroupSummary[]> {
|
export function fetchOrgs(): Promise<GroupSummary[]> {
|
||||||
return apiFetch(z.array(GroupSummarySchema), "/api/orgs");
|
return apiFetch(z.array(GroupSummarySchema), "/api/orgs");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -229,6 +229,61 @@
|
|||||||
border-color var(--duration-slow) var(--ease-out-app);
|
border-color var(--duration-slow) var(--ease-out-app);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ───────────────────────────────────────────────────────────────────────
|
||||||
|
Design-comp canvas animations (constellation / topology graphs):
|
||||||
|
- cm-flow : dashed edges "flowing" between nodes (stroke-dashoffset)
|
||||||
|
- cm-blink : status-dot pulse for running/active indicators
|
||||||
|
- cm-halo : expanding ring behind an active/selected node
|
||||||
|
- cm-drift : slow vertical drift for decorative nodes
|
||||||
|
─────────────────────────────────────────────────────────────────────── */
|
||||||
|
@keyframes cm-flow {
|
||||||
|
to {
|
||||||
|
stroke-dashoffset: -24;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes cm-blink {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes cm-halo {
|
||||||
|
0% {
|
||||||
|
transform: scale(0.6);
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: scale(1.6);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@keyframes cm-drift {
|
||||||
|
0%,
|
||||||
|
100% {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
transform: translateY(-4px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.cm-flow {
|
||||||
|
stroke-dasharray: 4 6;
|
||||||
|
animation: cm-flow 1.2s linear infinite;
|
||||||
|
}
|
||||||
|
.cm-blink {
|
||||||
|
animation: cm-blink 1.6s var(--ease-app) infinite;
|
||||||
|
}
|
||||||
|
.cm-halo {
|
||||||
|
transform-origin: center;
|
||||||
|
animation: cm-halo 2s var(--ease-out-app) infinite;
|
||||||
|
}
|
||||||
|
.cm-drift {
|
||||||
|
animation: cm-drift 4s var(--ease-app) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
*,
|
*,
|
||||||
*::before,
|
*::before,
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
-- Dashboard surfaces (design-comp redesign): persist a claw's model binding so
|
||||||
|
-- the claw card/anatomy can show it (today it's ephemeral in the ZeroClaw
|
||||||
|
-- runtime config), and journal routine firings so the routines panel can show
|
||||||
|
-- run history. Everything else the dashboard needs (compartments, counts,
|
||||||
|
-- now-running) is aggregated from existing tables at read time.
|
||||||
|
|
||||||
|
-- The model a claw was deployed with (e.g. "claude", "gemini", "glm-5.2").
|
||||||
|
-- NULL for claws created before this migration / outside the team flow.
|
||||||
|
ALTER TABLE agents ADD COLUMN model_binding TEXT;
|
||||||
|
|
||||||
|
-- One row per routine firing — the routines panel's run history + live loops.
|
||||||
|
CREATE TABLE routine_runs (
|
||||||
|
id UUID PRIMARY KEY,
|
||||||
|
routine_id UUID NOT NULL REFERENCES routines (id) ON DELETE CASCADE,
|
||||||
|
status TEXT NOT NULL DEFAULT 'running', -- running | ok | error
|
||||||
|
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
completed_at TIMESTAMPTZ,
|
||||||
|
error TEXT
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX routine_runs_routine_idx ON routine_runs (routine_id, started_at DESC);
|
||||||
Reference in New Issue
Block a user