Initial commit — Director app (API + UI)

This commit is contained in:
2026-04-07 15:18:16 -05:00
commit 5f29db67f3
44 changed files with 6727 additions and 0 deletions

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
dist/
.env
*.local
__pycache__/
*.py[cod]

20
api/db/db.go Normal file
View File

@@ -0,0 +1,20 @@
package db
import (
"database/sql"
_ "github.com/lib/pq"
)
func Connect(dsn string) (*sql.DB, error) {
db, err := sql.Open("postgres", dsn)
if err != nil {
return nil, err
}
if err := db.Ping(); err != nil {
return nil, err
}
db.SetMaxOpenConns(10)
db.SetMaxIdleConns(5)
return db, nil
}

BIN
api/director-api Executable file

Binary file not shown.

17
api/director-api.service Normal file
View File

@@ -0,0 +1,17 @@
[Unit]
Description=Director API
After=network.target postgresql.service redis-server.service
Requires=postgresql.service redis-server.service
[Service]
Type=simple
User=joe
Group=joe
WorkingDirectory=/data/director/app/api
ExecStart=/data/director/app/api/director-api
Environment=GIN_MODE=release
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target

42
api/go.mod Normal file
View File

@@ -0,0 +1,42 @@
module director/api
go 1.25.0
require (
github.com/bytedance/gopkg v0.1.3 // indirect
github.com/bytedance/sonic v1.15.0 // indirect
github.com/bytedance/sonic/loader v0.5.0 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/cloudwego/base64x v0.1.6 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/gabriel-vasile/mimetype v1.4.12 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/gin-gonic/gin v1.12.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.30.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/goccy/go-yaml v1.19.2 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.3.0 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lib/pq v1.12.3 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/quic-go/qpack v0.6.0 // indirect
github.com/quic-go/quic-go v0.59.0 // indirect
github.com/redis/go-redis/v9 v9.18.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.3.1 // indirect
go.mongodb.org/mongo-driver/v2 v2.5.0 // indirect
go.uber.org/atomic v1.11.0 // indirect
golang.org/x/arch v0.22.0 // indirect
golang.org/x/crypto v0.48.0 // indirect
golang.org/x/net v0.51.0 // indirect
golang.org/x/sys v0.41.0 // indirect
golang.org/x/text v0.34.0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
)

90
api/go.sum Normal file
View File

@@ -0,0 +1,90 @@
github.com/bytedance/gopkg v0.1.3 h1:TPBSwH8RsouGCBcMBktLt1AymVo2TVsBVCY4b6TnZ/M=
github.com/bytedance/gopkg v0.1.3/go.mod h1:576VvJ+eJgyCzdjS+c4+77QF3p7ubbtiKARP3TxducM=
github.com/bytedance/sonic v1.15.0 h1:/PXeWFaR5ElNcVE84U0dOHjiMHQOwNIx3K4ymzh/uSE=
github.com/bytedance/sonic v1.15.0/go.mod h1:tFkWrPz0/CUCLEF4ri4UkHekCIcdnkqXw9VduqpJh0k=
github.com/bytedance/sonic/loader v0.5.0 h1:gXH3KVnatgY7loH5/TkeVyXPfESoqSBSBEiDd5VjlgE=
github.com/bytedance/sonic/loader v0.5.0/go.mod h1:AR4NYCk5DdzZizZ5djGqQ92eEhCCcdf5x77udYiSJRo=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/cloudwego/base64x v0.1.6 h1:t11wG9AECkCDk5fMSoxmufanudBtJ+/HemLstXDLI2M=
github.com/cloudwego/base64x v0.1.6/go.mod h1:OFcloc187FXDaYHvrNIjxSe8ncn0OOM8gEHfghB2IPU=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/gabriel-vasile/mimetype v1.4.12 h1:e9hWvmLYvtp846tLHam2o++qitpguFiYCKbn0w9jyqw=
github.com/gabriel-vasile/mimetype v1.4.12/go.mod h1:d+9Oxyo1wTzWdyVUPMmXFvp4F9tea18J8ufA774AB3s=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
github.com/gin-gonic/gin v1.12.0 h1:b3YAbrZtnf8N//yjKeU2+MQsh2mY5htkZidOM7O0wG8=
github.com/gin-gonic/gin v1.12.0/go.mod h1:VxccKfsSllpKshkBWgVgRniFFAzFb9csfngsqANjnLc=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.30.1 h1:f3zDSN/zOma+w6+1Wswgd9fLkdwy06ntQJp0BBvFG0w=
github.com/go-playground/validator/v10 v10.30.1/go.mod h1:oSuBIQzuJxL//3MelwSLD5hc2Tu889bF0Idm9Dg26cM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/goccy/go-yaml v1.19.2 h1:PmFC1S6h8ljIz6gMRBopkjP1TVT7xuwrButHID66PoM=
github.com/goccy/go-yaml v1.19.2/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y=
github.com/klauspost/cpuid/v2 v2.3.0/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lib/pq v1.12.3 h1:tTWxr2YLKwIvK90ZXEw8GP7UFHtcbTtty8zsI+YjrfQ=
github.com/lib/pq v1.12.3/go.mod h1:/p+8NSbOcwzAEI7wiMXFlgydTwcgTr3OSKMsD2BitpA=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/quic-go/qpack v0.6.0 h1:g7W+BMYynC1LbYLSqRt8PBg5Tgwxn214ZZR34VIOjz8=
github.com/quic-go/qpack v0.6.0/go.mod h1:lUpLKChi8njB4ty2bFLX2x4gzDqXwUpaO1DP9qMDZII=
github.com/quic-go/quic-go v0.59.0 h1:OLJkp1Mlm/aS7dpKgTc6cnpynnD2Xg7C1pwL6vy/SAw=
github.com/quic-go/quic-go v0.59.0/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU=
github.com/redis/go-redis/v9 v9.18.0 h1:pMkxYPkEbMPwRdenAzUNyFNrDgHx9U+DrBabWNfSRQs=
github.com/redis/go-redis/v9 v9.18.0/go.mod h1:k3ufPphLU5YXwNTUcCRXGxUoF1fqxnhFQmscfkCoDA0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY=
github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
go.mongodb.org/mongo-driver/v2 v2.5.0 h1:yXUhImUjjAInNcpTcAlPHiT7bIXhshCTL3jVBkF3xaE=
go.mongodb.org/mongo-driver/v2 v2.5.0/go.mod h1:yOI9kBsufol30iFsl1slpdq1I0eHPzybRWdyYUs8K/0=
go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE=
go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0=
golang.org/x/arch v0.22.0 h1:c/Zle32i5ttqRXjdLyyHZESLD/bB90DCU1g9l/0YBDI=
golang.org/x/arch v0.22.0/go.mod h1:dNHoOeKiyja7GTvF9NJS1l3Z2yntpQNzgrjh1cU103A=
golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

697
api/handlers/handlers.go Normal file
View File

@@ -0,0 +1,697 @@
package handlers
import (
"database/sql"
"strconv"
"director/api/pubsub"
"github.com/gin-gonic/gin"
)
type Handler struct {
db *sql.DB
ps *pubsub.PubSub
}
func New(db *sql.DB, ps *pubsub.PubSub) *Handler {
return &Handler{db: db, ps: ps}
}
func (h *Handler) broadcast(c *gin.Context, event string, data any) {
h.ps.Publish(c.Request.Context(), event, data)
}
// --- Plans ---
func (h *Handler) ListPlans(c *gin.Context) {
rows, err := h.db.Query(`
SELECT id, title, description, team, priority, status,
created_at, updated_at, approved_at, started_at, completed_at
FROM plans ORDER BY
CASE priority
WHEN 'critical' THEN 0
WHEN '1' THEN 1 WHEN '2' THEN 2 WHEN '3' THEN 3
WHEN '4' THEN 4 WHEN '5' THEN 5 WHEN 'backlog' THEN 6
END,
created_at DESC
`)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
defer rows.Close()
plans := []map[string]any{}
for rows.Next() {
var id int
var title, desc, team, priority, status string
var createdAt, updatedAt string
var approvedAt, startedAt, completedAt sql.NullString
rows.Scan(&id, &title, &desc, &team, &priority, &status, &createdAt, &updatedAt, &approvedAt, &startedAt, &completedAt)
p := map[string]any{
"id": id, "title": title, "description": desc, "team": team,
"priority": priority, "status": status,
"created_at": createdAt, "updated_at": updatedAt,
}
if approvedAt.Valid {
p["approved_at"] = approvedAt.String
}
if startedAt.Valid {
p["started_at"] = startedAt.String
}
if completedAt.Valid {
p["completed_at"] = completedAt.String
}
plans = append(plans, p)
}
c.JSON(200, plans)
}
func (h *Handler) CreatePlan(c *gin.Context) {
var input struct {
Title string `json:"title" binding:"required"`
Description string `json:"description" binding:"required"`
Team string `json:"team" binding:"required"`
Priority string `json:"priority"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
if input.Priority == "" {
input.Priority = "3"
}
var id int
err := h.db.QueryRow(`
INSERT INTO plans (title, description, team, priority)
VALUES ($1, $2, $3, $4) RETURNING id
`, input.Title, input.Description, input.Team, input.Priority).Scan(&id)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
plan := map[string]any{
"id": id, "title": input.Title, "description": input.Description,
"team": input.Team, "priority": input.Priority, "status": "draft",
}
h.broadcast(c, "plan:created", plan)
c.JSON(201, plan)
}
func (h *Handler) GetPlan(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var title, desc, team, priority, status, createdAt, updatedAt string
var approvedAt, startedAt, completedAt sql.NullString
err := h.db.QueryRow(`
SELECT title, description, team, priority, status,
created_at, updated_at, approved_at, started_at, completed_at
FROM plans WHERE id = $1
`, id).Scan(&title, &desc, &team, &priority, &status, &createdAt, &updatedAt, &approvedAt, &startedAt, &completedAt)
if err == sql.ErrNoRows {
c.JSON(404, gin.H{"error": "plan not found"})
return
}
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
out := map[string]any{
"id": id, "title": title, "description": desc, "team": team,
"priority": priority, "status": status,
"created_at": createdAt, "updated_at": updatedAt,
}
if approvedAt.Valid {
out["approved_at"] = approvedAt.String
}
if startedAt.Valid {
out["started_at"] = startedAt.String
}
if completedAt.Valid {
out["completed_at"] = completedAt.String
}
c.JSON(200, out)
}
func (h *Handler) UpdatePlan(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var input map[string]any
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
// Build dynamic update
allowed := map[string]bool{"title": true, "description": true, "priority": true, "status": true}
sets := ""
args := []any{}
i := 1
for k, v := range input {
if !allowed[k] {
continue
}
if sets != "" {
sets += ", "
}
sets += k + " = $" + strconv.Itoa(i)
args = append(args, v)
i++
}
if sets == "" {
c.JSON(400, gin.H{"error": "no valid fields"})
return
}
args = append(args, id)
_, err := h.db.Exec("UPDATE plans SET "+sets+" WHERE id = $"+strconv.Itoa(i), args...)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
input["id"] = id
h.broadcast(c, "plan:updated", input)
c.JSON(200, input)
}
// --- Tasks ---
func (h *Handler) ListTasks(c *gin.Context) {
planID, _ := strconv.Atoi(c.Param("id"))
rows, err := h.db.Query(`
SELECT id, title, description, task_type, status, assigned_agent, progress_note, sort_order,
created_at, updated_at, started_at, completed_at
FROM tasks WHERE plan_id = $1 ORDER BY sort_order, id
`, planID)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
defer rows.Close()
tasks := []map[string]any{}
for rows.Next() {
var id, sortOrder int
var title, taskType, status, createdAt, updatedAt string
var desc, agent, note *string
var startedAt, completedAt sql.NullString
rows.Scan(&id, &title, &desc, &taskType, &status, &agent, &note, &sortOrder,
&createdAt, &updatedAt, &startedAt, &completedAt)
t := map[string]any{
"id": id, "plan_id": planID, "title": title, "task_type": taskType,
"status": status, "sort_order": sortOrder,
"created_at": createdAt, "updated_at": updatedAt,
}
if desc != nil {
t["description"] = *desc
}
if agent != nil {
t["assigned_agent"] = *agent
}
if note != nil {
t["progress_note"] = *note
}
if startedAt.Valid {
t["started_at"] = startedAt.String
}
if completedAt.Valid {
t["completed_at"] = completedAt.String
}
tasks = append(tasks, t)
}
c.JSON(200, tasks)
}
func (h *Handler) CreateTask(c *gin.Context) {
planID, _ := strconv.Atoi(c.Param("id"))
var input struct {
Title string `json:"title" binding:"required"`
Description string `json:"description"`
TaskType string `json:"task_type"`
AssignedAgent string `json:"assigned_agent"`
SortOrder int `json:"sort_order"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
if input.TaskType == "" {
input.TaskType = "core"
}
var id int
err := h.db.QueryRow(`
INSERT INTO tasks (plan_id, title, description, task_type, assigned_agent, sort_order)
VALUES ($1, $2, $3, $4, $5, $6) RETURNING id
`, planID, input.Title, input.Description, input.TaskType, input.AssignedAgent, input.SortOrder).Scan(&id)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
task := map[string]any{
"id": id, "plan_id": planID, "title": input.Title,
"task_type": input.TaskType, "status": "queued",
}
h.broadcast(c, "task:created", task)
c.JSON(201, task)
}
func (h *Handler) UpdateTask(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var input map[string]any
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
allowed := map[string]bool{"status": true, "assigned_agent": true, "progress_note": true, "blockers": true}
sets := ""
args := []any{}
i := 1
for k, v := range input {
if !allowed[k] {
continue
}
if sets != "" {
sets += ", "
}
sets += k + " = $" + strconv.Itoa(i)
args = append(args, v)
i++
}
if sets == "" {
c.JSON(400, gin.H{"error": "no valid fields"})
return
}
args = append(args, id)
_, err := h.db.Exec("UPDATE tasks SET "+sets+" WHERE id = $"+strconv.Itoa(i), args...)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
input["id"] = id
h.broadcast(c, "task:updated", input)
c.JSON(200, input)
}
// --- Agents ---
func (h *Handler) ListAgents(c *gin.Context) {
rows, err := h.db.Query(`
SELECT id, name, team, role, model_provider, model_id, gpu_default, status, current_task_id, last_seen_at, specialization
FROM agents ORDER BY team, role
`)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
defer rows.Close()
agents := []map[string]any{}
for rows.Next() {
var id, name, team, role, provider, model, status string
var gpu, lastSeen, specialization *string
var taskID *int
rows.Scan(&id, &name, &team, &role, &provider, &model, &gpu, &status, &taskID, &lastSeen, &specialization)
a := map[string]any{
"id": id, "name": name, "team": team, "role": role,
"model_provider": provider, "model_id": model, "status": status,
}
if gpu != nil {
a["gpu_default"] = *gpu
}
if taskID != nil {
a["current_task_id"] = *taskID
}
if lastSeen != nil {
a["last_seen_at"] = *lastSeen
}
if specialization != nil {
a["specialization"] = *specialization
}
agents = append(agents, a)
}
c.JSON(200, agents)
}
func (h *Handler) UpdateAgent(c *gin.Context) {
id := c.Param("id")
var input map[string]any
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
allowed := map[string]bool{"status": true, "current_task_id": true}
sets := ""
args := []any{}
i := 1
for k, v := range input {
if !allowed[k] {
continue
}
if sets != "" {
sets += ", "
}
sets += k + " = $" + strconv.Itoa(i)
args = append(args, v)
i++
}
if sets == "" {
c.JSON(400, gin.H{"error": "no valid fields"})
return
}
args = append(args, id)
_, err := h.db.Exec("UPDATE agents SET "+sets+", last_seen_at = now() WHERE id = $"+strconv.Itoa(i), args...)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
input["id"] = id
h.broadcast(c, "agent:updated", input)
c.JSON(200, input)
}
// --- Director Queue ---
func (h *Handler) ListQueue(c *gin.Context) {
status := c.DefaultQuery("status", "ready_for_approval")
rows, err := h.db.Query(`
SELECT dq.id, dq.plan_id, dq.task_id, dq.status, dq.verification_notes,
dq.director_notes, dq.submitted_at, dq.reviewed_at,
COALESCE(p.title, ''), COALESCE(t.title, '')
FROM director_queue dq
LEFT JOIN plans p ON dq.plan_id = p.id
LEFT JOIN tasks t ON dq.task_id = t.id
WHERE dq.status = $1
ORDER BY dq.submitted_at
`, status)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
defer rows.Close()
items := []map[string]any{}
for rows.Next() {
var id int
var planID, taskID *int
var qStatus, planTitle, taskTitle string
var verNotes, dirNotes *string
var submittedAt string
var reviewedAt *string
rows.Scan(&id, &planID, &taskID, &qStatus, &verNotes, &dirNotes, &submittedAt, &reviewedAt, &planTitle, &taskTitle)
item := map[string]any{
"id": id, "status": qStatus, "submitted_at": submittedAt,
"plan_title": planTitle, "task_title": taskTitle,
}
if planID != nil {
item["plan_id"] = *planID
}
if taskID != nil {
item["task_id"] = *taskID
}
if verNotes != nil {
item["verification_notes"] = *verNotes
}
if dirNotes != nil {
item["director_notes"] = *dirNotes
}
if reviewedAt != nil {
item["reviewed_at"] = *reviewedAt
}
items = append(items, item)
}
c.JSON(200, items)
}
func (h *Handler) UpdateQueueItem(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var input struct {
Status string `json:"status" binding:"required"`
DirectorNotes string `json:"director_notes"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
_, err := h.db.Exec(`
UPDATE director_queue SET status = $1, director_notes = $2, reviewed_at = now() WHERE id = $3
`, input.Status, input.DirectorNotes, id)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
result := map[string]any{"id": id, "status": input.Status}
h.broadcast(c, "queue:updated", result)
c.JSON(200, result)
}
// --- Pending Questions ---
func (h *Handler) CreateQuestion(c *gin.Context) {
var input struct {
PlanID *int `json:"plan_id"`
TaskID *int `json:"task_id"`
FromRole string `json:"from_role" binding:"required"`
FromID string `json:"from_id" binding:"required"`
Question string `json:"question" binding:"required"`
Context string `json:"context"`
Priority string `json:"priority"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
if input.Priority == "" {
input.Priority = "3"
}
var id int
err := h.db.QueryRow(`
INSERT INTO pending_questions (plan_id, task_id, from_role, from_id, question, context, priority)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id
`, input.PlanID, input.TaskID, input.FromRole, input.FromID, input.Question, input.Context, input.Priority).Scan(&id)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
q := map[string]any{
"id": id, "from_role": input.FromRole, "from_id": input.FromID,
"question": input.Question, "priority": input.Priority, "status": "awaiting_director",
}
if input.PlanID != nil {
q["plan_id"] = *input.PlanID
}
if input.TaskID != nil {
q["task_id"] = *input.TaskID
}
if input.Context != "" {
q["context"] = input.Context
}
h.broadcast(c, "question:created", q)
c.JSON(201, q)
}
func (h *Handler) ListQuestions(c *gin.Context) {
statusFilter := c.DefaultQuery("status", "awaiting_director")
rows, err := h.db.Query(`
SELECT pq.id, pq.plan_id, pq.task_id, pq.from_role, pq.from_id, pq.question, pq.context,
pq.priority, pq.status, pq.answer, pq.created_at, pq.answered_at,
COALESCE(p.title,''), COALESCE(t.title,'')
FROM pending_questions pq
LEFT JOIN plans p ON pq.plan_id = p.id
LEFT JOIN tasks t ON pq.task_id = t.id
WHERE pq.status = $1
ORDER BY
CASE pq.priority
WHEN 'critical' THEN 0
WHEN '1' THEN 1 WHEN '2' THEN 2 WHEN '3' THEN 3
WHEN '4' THEN 4 WHEN '5' THEN 5
END,
pq.created_at
`, statusFilter)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
defer rows.Close()
questions := []map[string]any{}
for rows.Next() {
var id int
var planID, taskID *int
var fromRole, fromID, question, priority, status, createdAt string
var ctx, answer, answeredAt *string
var planTitle, taskTitle string
rows.Scan(&id, &planID, &taskID, &fromRole, &fromID, &question, &ctx, &priority, &status,
&answer, &createdAt, &answeredAt, &planTitle, &taskTitle)
q := map[string]any{
"id": id, "from_role": fromRole, "from_id": fromID,
"question": question, "priority": priority, "status": status,
"created_at": createdAt,
"plan_title": planTitle, "task_title": taskTitle,
}
if planID != nil {
q["plan_id"] = *planID
}
if taskID != nil {
q["task_id"] = *taskID
}
if ctx != nil {
q["context"] = *ctx
}
if answer != nil {
q["answer"] = *answer
}
if answeredAt != nil {
q["answered_at"] = *answeredAt
}
questions = append(questions, q)
}
c.JSON(200, questions)
}
func (h *Handler) AnswerQuestion(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var input struct {
Answer string `json:"answer" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
_, err := h.db.Exec(`
UPDATE pending_questions SET answer = $1, status = 'answered', answered_at = now() WHERE id = $2
`, input.Answer, id)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
result := map[string]any{"id": id, "status": "answered"}
h.broadcast(c, "question:answered", result)
c.JSON(200, result)
}
// --- Messages ---
func (h *Handler) ListMessages(c *gin.Context) {
planID, _ := strconv.Atoi(c.Param("id"))
rows, err := h.db.Query(`
SELECT id, task_id, reply_to_id, from_role, from_id, message, message_type, created_at
FROM messages WHERE plan_id = $1 ORDER BY created_at
`, planID)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
defer rows.Close()
messages := []map[string]any{}
for rows.Next() {
var id int
var taskID, replyToID *int
var fromRole, fromID, message, msgType, createdAt string
rows.Scan(&id, &taskID, &replyToID, &fromRole, &fromID, &message, &msgType, &createdAt)
m := map[string]any{
"id": id, "plan_id": planID, "from_role": fromRole, "from_id": fromID,
"message": message, "message_type": msgType, "created_at": createdAt,
}
if taskID != nil {
m["task_id"] = *taskID
}
if replyToID != nil {
m["reply_to_id"] = *replyToID
}
messages = append(messages, m)
}
c.JSON(200, messages)
}
func (h *Handler) CreateMessage(c *gin.Context) {
planID, _ := strconv.Atoi(c.Param("id"))
var input struct {
TaskID *int `json:"task_id"`
ReplyToID *int `json:"reply_to_id"`
FromRole string `json:"from_role" binding:"required"`
FromID string `json:"from_id" binding:"required"`
Message string `json:"message" binding:"required"`
MessageType string `json:"message_type"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
if input.MessageType == "" {
input.MessageType = "comment"
}
var id int
err := h.db.QueryRow(`
INSERT INTO messages (plan_id, task_id, reply_to_id, from_role, from_id, message, message_type)
VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING id
`, planID, input.TaskID, input.ReplyToID, input.FromRole, input.FromID, input.Message, input.MessageType).Scan(&id)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
msg := map[string]any{
"id": id, "plan_id": planID, "from_role": input.FromRole,
"from_id": input.FromID, "message": input.Message,
"message_type": input.MessageType,
}
if input.TaskID != nil {
msg["task_id"] = *input.TaskID
}
if input.ReplyToID != nil {
msg["reply_to_id"] = *input.ReplyToID
}
h.broadcast(c, "message:created", msg)
c.JSON(201, msg)
}
func (h *Handler) UpdateMessage(c *gin.Context) {
id, _ := strconv.Atoi(c.Param("id"))
var input struct {
Message string `json:"message" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
// Only allow editing director-authored messages — never mutate agent or lead history
var fromRole string
err := h.db.QueryRow(`SELECT from_role FROM messages WHERE id = $1`, id).Scan(&fromRole)
if err == sql.ErrNoRows {
c.JSON(404, gin.H{"error": "message not found"})
return
}
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
if fromRole != "director" {
c.JSON(403, gin.H{"error": "only director-authored messages can be edited"})
return
}
_, err = h.db.Exec(`UPDATE messages SET message = $1 WHERE id = $2`, input.Message, id)
if err != nil {
c.JSON(500, gin.H{"error": err.Error()})
return
}
result := map[string]any{"id": id, "message": input.Message}
h.broadcast(c, "message:updated", result)
c.JSON(200, result)
}
func intParam(c *gin.Context, name string) int {
v, _ := strconv.Atoi(c.Param(name))
return v
}
func sendError(c *gin.Context, code int, err error) {
c.JSON(code, gin.H{"error": err.Error()})
}

83
api/handlers/system.go Normal file
View File

@@ -0,0 +1,83 @@
package handlers
import (
"os/exec"
"github.com/gin-gonic/gin"
)
const claudeModeScript = "/data/director/scripts/claude-mode.sh"
// GetClaudeMode returns the current Claude provider mode (api or proxy)
// by parsing the openclaw.json directly via the toggle script.
func (h *Handler) GetClaudeMode(c *gin.Context) {
out, err := exec.Command(claudeModeScript, "status").CombinedOutput()
if err != nil {
c.JSON(500, gin.H{"error": err.Error(), "output": string(out)})
return
}
mode := "unknown"
for _, line := range splitLines(string(out)) {
if hasPrefix(line, "Current Claude mode: ") {
mode = trim(line[len("Current Claude mode: "):])
break
}
}
c.JSON(200, gin.H{
"mode": mode,
"output": string(out),
})
}
// SetClaudeMode flips between api and proxy modes.
func (h *Handler) SetClaudeMode(c *gin.Context) {
var input struct {
Mode string `json:"mode" binding:"required"`
}
if err := c.ShouldBindJSON(&input); err != nil {
c.JSON(400, gin.H{"error": err.Error()})
return
}
if input.Mode != "api" && input.Mode != "proxy" {
c.JSON(400, gin.H{"error": "mode must be 'api' or 'proxy'"})
return
}
out, err := exec.Command(claudeModeScript, input.Mode).CombinedOutput()
if err != nil {
c.JSON(500, gin.H{"error": err.Error(), "output": string(out)})
return
}
h.broadcast(c, "system:claude-mode", gin.H{"mode": input.Mode})
c.JSON(200, gin.H{"mode": input.Mode, "output": string(out)})
}
// tiny string helpers — avoid importing strings just for these
func splitLines(s string) []string {
var out []string
start := 0
for i := 0; i < len(s); i++ {
if s[i] == '\n' {
out = append(out, s[start:i])
start = i + 1
}
}
if start < len(s) {
out = append(out, s[start:])
}
return out
}
func hasPrefix(s, p string) bool {
return len(s) >= len(p) && s[:len(p)] == p
}
func trim(s string) string {
start, end := 0, len(s)
for start < end && (s[start] == ' ' || s[start] == '\t' || s[start] == '\r') {
start++
}
for end > start && (s[end-1] == ' ' || s[end-1] == '\t' || s[end-1] == '\r') {
end--
}
return s[start:end]
}

119
api/main.go Normal file
View File

@@ -0,0 +1,119 @@
package main
import (
"context"
"log"
"os"
"director/api/db"
"director/api/handlers"
"director/api/pubsub"
"director/api/ws"
"github.com/gin-gonic/gin"
)
func main() {
dsn := os.Getenv("DATABASE_URL")
if dsn == "" {
dsn = "postgres://director:director_local_2026@127.0.0.1:5432/director?sslmode=disable"
}
redisAddr := os.Getenv("REDIS_URL")
if redisAddr == "" {
redisAddr = "127.0.0.1:6379"
}
database, err := db.Connect(dsn)
if err != nil {
log.Fatalf("database connection failed: %v", err)
}
defer database.Close()
hub := ws.NewHub()
go hub.Run()
// Redis pub/sub — bridges external events (OpenClaw agents) to WebSocket clients
ps := pubsub.New(redisAddr, hub)
if err := ps.Ping(context.Background()); err != nil {
log.Fatalf("redis connection failed: %v", err)
}
defer ps.Close()
go ps.Subscribe(context.Background())
r := gin.Default()
// CORS for local dev
r.Use(func(c *gin.Context) {
c.Header("Access-Control-Allow-Origin", "*")
c.Header("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
c.Header("Access-Control-Allow-Headers", "Content-Type, Authorization")
if c.Request.Method == "OPTIONS" {
c.AbortWithStatus(204)
return
}
c.Next()
})
api := r.Group("/api")
{
h := handlers.New(database, ps)
// Plans
api.GET("/plans", h.ListPlans)
api.POST("/plans", h.CreatePlan)
api.GET("/plans/:id", h.GetPlan)
api.PATCH("/plans/:id", h.UpdatePlan)
// Tasks
api.GET("/plans/:id/tasks", h.ListTasks)
api.POST("/plans/:id/tasks", h.CreateTask)
api.PATCH("/tasks/:id", h.UpdateTask)
// Agents
api.GET("/agents", h.ListAgents)
api.PATCH("/agents/:id", h.UpdateAgent)
// Director Queue
api.GET("/queue", h.ListQueue)
api.PATCH("/queue/:id", h.UpdateQueueItem)
// Pending Questions
api.GET("/questions", h.ListQuestions)
api.POST("/questions", h.CreateQuestion)
api.PATCH("/questions/:id", h.AnswerQuestion)
// Messages
api.GET("/plans/:id/messages", h.ListMessages)
api.POST("/plans/:id/messages", h.CreateMessage)
api.PATCH("/messages/:id", h.UpdateMessage)
// System / Settings
api.GET("/system/claude-mode", h.GetClaudeMode)
api.POST("/system/claude-mode", h.SetClaudeMode)
}
// WebSocket
r.GET("/ws", func(c *gin.Context) {
ws.HandleWebSocket(hub, c.Writer, c.Request)
})
// Health
r.GET("/health", func(c *gin.Context) {
if err := ps.Ping(c.Request.Context()); err != nil {
c.JSON(500, gin.H{"status": "error", "redis": err.Error()})
return
}
c.JSON(200, gin.H{"status": "ok"})
})
port := os.Getenv("PORT")
if port == "" {
port = "8090"
}
log.Printf("Director API starting on :%s", port)
if err := r.Run(":" + port); err != nil {
log.Fatalf("server failed: %v", err)
}
}

63
api/pubsub/pubsub.go Normal file
View File

@@ -0,0 +1,63 @@
package pubsub
import (
"context"
"encoding/json"
"log"
"director/api/ws"
"github.com/redis/go-redis/v9"
)
const Channel = "director:events"
type Event struct {
Type string `json:"event"`
Data any `json:"data"`
}
type PubSub struct {
rdb *redis.Client
hub *ws.Hub
}
func New(redisAddr string, hub *ws.Hub) *PubSub {
rdb := redis.NewClient(&redis.Options{
Addr: redisAddr,
})
return &PubSub{rdb: rdb, hub: hub}
}
// Publish sends an event to Redis pub/sub.
// Called by the API handlers and can also be called by external agents via redis-cli.
func (ps *PubSub) Publish(ctx context.Context, event string, data any) error {
msg, err := json.Marshal(Event{Type: event, Data: data})
if err != nil {
return err
}
return ps.rdb.Publish(ctx, Channel, msg).Err()
}
// Subscribe listens for events on Redis pub/sub and forwards them to WebSocket clients.
// This bridges external publishers (OpenClaw agents, cron jobs) to the React UI.
func (ps *PubSub) Subscribe(ctx context.Context) {
sub := ps.rdb.Subscribe(ctx, Channel)
ch := sub.Channel()
log.Printf("Redis pub/sub subscribed to %s", Channel)
for msg := range ch {
ps.hub.Broadcast([]byte(msg.Payload))
}
}
// Ping tests the Redis connection.
func (ps *PubSub) Ping(ctx context.Context) error {
return ps.rdb.Ping(ctx).Err()
}
// Close shuts down the Redis client.
func (ps *PubSub) Close() error {
return ps.rdb.Close()
}

71
api/ws/hub.go Normal file
View File

@@ -0,0 +1,71 @@
package ws
import (
"log"
"net/http"
"sync"
"github.com/gorilla/websocket"
)
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool { return true },
}
type Hub struct {
mu sync.RWMutex
clients map[*websocket.Conn]bool
}
func NewHub() *Hub {
return &Hub{
clients: make(map[*websocket.Conn]bool),
}
}
func (h *Hub) Run() {
// Hub is passive — broadcasts are push-based via Broadcast()
select {}
}
func (h *Hub) Register(conn *websocket.Conn) {
h.mu.Lock()
h.clients[conn] = true
h.mu.Unlock()
}
func (h *Hub) Unregister(conn *websocket.Conn) {
h.mu.Lock()
delete(h.clients, conn)
h.mu.Unlock()
conn.Close()
}
func (h *Hub) Broadcast(msg []byte) {
h.mu.RLock()
defer h.mu.RUnlock()
for conn := range h.clients {
if err := conn.WriteMessage(websocket.TextMessage, msg); err != nil {
log.Printf("ws write error: %v", err)
conn.Close()
delete(h.clients, conn)
}
}
}
func HandleWebSocket(hub *Hub, w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
log.Printf("ws upgrade error: %v", err)
return
}
hub.Register(conn)
defer hub.Unregister(conn)
for {
_, _, err := conn.ReadMessage()
if err != nil {
break
}
}
}

119
schema.sql Normal file
View File

@@ -0,0 +1,119 @@
-- Director Schema v1 — Phase 1 Foundation
-- Based on Director_MasterPlan_v1.2.md
-- Plans
CREATE TABLE plans (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
description TEXT NOT NULL,
team TEXT NOT NULL CHECK (team IN ('tech', 'comms')),
priority TEXT NOT NULL DEFAULT '3' CHECK (priority IN ('critical', '1', '2', '3', '4', '5', 'backlog')),
status TEXT NOT NULL DEFAULT 'draft' CHECK (status IN ('draft', 'clarification', 'approved', 'in_progress', 'done')),
created_by TEXT NOT NULL DEFAULT 'director',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Tasks within plans
CREATE TABLE tasks (
id SERIAL PRIMARY KEY,
plan_id INTEGER NOT NULL REFERENCES plans(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT,
task_type TEXT NOT NULL DEFAULT 'core' CHECK (task_type IN ('core', 'extended')),
status TEXT NOT NULL DEFAULT 'queued' CHECK (status IN ('queued', 'in_progress', 'blocked', 'complete', 'returned')),
assigned_agent TEXT,
progress_note TEXT,
blockers TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Task artifacts (files, links, outputs produced by agents)
CREATE TABLE task_artifacts (
id SERIAL PRIMARY KEY,
task_id INTEGER NOT NULL REFERENCES tasks(id) ON DELETE CASCADE,
artifact_type TEXT NOT NULL DEFAULT 'file' CHECK (artifact_type IN ('file', 'url', 'text')),
path TEXT NOT NULL,
description TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Agents registry
CREATE TABLE agents (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
team TEXT NOT NULL CHECK (team IN ('tech', 'comms')),
role TEXT NOT NULL,
model_provider TEXT NOT NULL,
model_id TEXT NOT NULL,
gpu_default TEXT,
status TEXT NOT NULL DEFAULT 'idle' CHECK (status IN ('idle', 'working', 'blocked', 'error', 'offline')),
current_task_id INTEGER REFERENCES tasks(id) ON DELETE SET NULL,
last_seen_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Messages (director <-> team lead <-> agent communication)
CREATE TABLE messages (
id SERIAL PRIMARY KEY,
plan_id INTEGER REFERENCES plans(id) ON DELETE SET NULL,
task_id INTEGER REFERENCES tasks(id) ON DELETE SET NULL,
from_role TEXT NOT NULL CHECK (from_role IN ('director', 'lead', 'agent', 'system')),
from_id TEXT NOT NULL,
message TEXT NOT NULL,
message_type TEXT NOT NULL DEFAULT 'comment' CHECK (message_type IN ('comment', 'question', 'answer', 'escalation', 'status', 'completion')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Director queue (verified completions awaiting approval)
CREATE TABLE director_queue (
id SERIAL PRIMARY KEY,
plan_id INTEGER REFERENCES plans(id) ON DELETE CASCADE,
task_id INTEGER REFERENCES tasks(id) ON DELETE CASCADE,
status TEXT NOT NULL DEFAULT 'pending_verification' CHECK (status IN ('pending_verification', 'ready_for_approval', 'returned', 'approved', 'redirected')),
verification_notes TEXT,
director_notes TEXT,
submitted_at TIMESTAMPTZ NOT NULL DEFAULT now(),
reviewed_at TIMESTAMPTZ
);
-- Pending questions queue
CREATE TABLE pending_questions (
id SERIAL PRIMARY KEY,
plan_id INTEGER REFERENCES plans(id) ON DELETE CASCADE,
task_id INTEGER REFERENCES tasks(id) ON DELETE CASCADE,
from_role TEXT NOT NULL CHECK (from_role IN ('lead', 'agent')),
from_id TEXT NOT NULL,
question TEXT NOT NULL,
context TEXT,
priority TEXT NOT NULL DEFAULT '3',
status TEXT NOT NULL DEFAULT 'awaiting_director' CHECK (status IN ('awaiting_director', 'answered', 'resolved')),
answer TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
answered_at TIMESTAMPTZ
);
-- Indexes for common queries
CREATE INDEX idx_plans_status ON plans(status);
CREATE INDEX idx_plans_priority ON plans(priority);
CREATE INDEX idx_plans_team ON plans(team);
CREATE INDEX idx_tasks_plan_id ON tasks(plan_id);
CREATE INDEX idx_tasks_status ON tasks(status);
CREATE INDEX idx_tasks_assigned_agent ON tasks(assigned_agent);
CREATE INDEX idx_messages_plan_id ON messages(plan_id);
CREATE INDEX idx_director_queue_status ON director_queue(status);
CREATE INDEX idx_pending_questions_status ON pending_questions(status);
-- Updated_at trigger function
CREATE OR REPLACE FUNCTION update_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = now();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER plans_updated_at BEFORE UPDATE ON plans FOR EACH ROW EXECUTE FUNCTION update_updated_at();
CREATE TRIGGER tasks_updated_at BEFORE UPDATE ON tasks FOR EACH ROW EXECUTE FUNCTION update_updated_at();

24
ui/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
ui/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

16
ui/director-ui.service Normal file
View File

@@ -0,0 +1,16 @@
[Unit]
Description=Director UI (Vite)
After=network.target director-api.service
[Service]
Type=simple
User=joe
Group=joe
WorkingDirectory=/data/director/app/ui
ExecStart=/usr/bin/npx vite --host --port 3000
Environment=NODE_ENV=production
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target

23
ui/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

16
ui/index.html Normal file
View File

@@ -0,0 +1,16 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Director</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Fraunces:opsz,wght@9..144,300;9..144,400;9..144,500;9..144,600;9..144,700&family=Inter:wght@400;500;600;700&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet" />
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3303
ui/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

32
ui/package.json Normal file
View File

@@ -0,0 +1,32 @@
{
"name": "ui",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"react": "^19.2.4",
"react-dom": "^19.2.4"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@tailwindcss/vite": "^4.2.2",
"@types/node": "^24.12.2",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"tailwindcss": "^4.2.2",
"typescript": "~6.0.2",
"typescript-eslint": "^8.58.0",
"vite": "^8.0.4"
}
}

1
ui/public/favicon.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
ui/public/icons.svg Normal file
View File

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

141
ui/src/App.tsx Normal file
View File

@@ -0,0 +1,141 @@
import { useEffect, useState, useCallback } from 'react'
import { api } from './lib/api'
import type { Plan, Agent, QueueItem, PendingQuestion } from './lib/api'
import { useWebSocket } from './hooks/useWebSocket'
import { AgentStatus } from './components/AgentStatus'
import { PlanBoard } from './components/PlanBoard'
import { DirectorQueue } from './components/DirectorQueue'
import { PendingQuestions } from './components/PendingQuestions'
import { PlanDetail } from './components/PlanDetail'
import { NewPlanModal } from './components/NewPlanModal'
import { ClaudeModeToggle } from './components/ClaudeModeToggle'
type RightTab = 'queue' | 'questions'
function App() {
const [plans, setPlans] = useState<Plan[]>([])
const [agents, setAgents] = useState<Agent[]>([])
const [queue, setQueue] = useState<QueueItem[]>([])
const [questions, setQuestions] = useState<PendingQuestion[]>([])
const [rightTab, setRightTab] = useState<RightTab>('queue')
const [selectedPlan, setSelectedPlan] = useState<Plan | null>(null)
const [reviewItem, setReviewItem] = useState<QueueItem | null>(null)
const [showNewPlan, setShowNewPlan] = useState(false)
const [connected, setConnected] = useState(false)
const loadData = useCallback(() => {
api.plans.list().then(setPlans).catch(() => {})
api.agents.list().then(setAgents).catch(() => {})
api.queue.list().then(setQueue).catch(() => {})
api.questions.list().then(setQuestions).catch(() => {})
}, [])
useEffect(() => {
loadData()
}, [loadData])
useWebSocket(useCallback((event: string, _data: unknown) => {
if (event.startsWith('plan:')) loadData()
if (event.startsWith('task:')) loadData()
if (event.startsWith('agent:')) api.agents.list().then(setAgents)
if (event.startsWith('queue:')) api.queue.list().then(setQueue)
if (event.startsWith('question:')) api.questions.list().then(setQuestions)
if (event.startsWith('message:')) loadData()
setConnected(true)
}, [loadData]))
const handleReview = useCallback(async (item: QueueItem) => {
if (!item.plan_id) return
try {
const plan = await api.plans.get(item.plan_id)
setReviewItem(item)
setSelectedPlan(plan)
} catch (err) {
console.error('failed to load plan for review', err)
}
}, [])
const closeDetail = useCallback(() => {
setSelectedPlan(null)
setReviewItem(null)
}, [])
return (
<div className="min-h-screen bg-gray-100 dark:bg-gray-900 text-gray-900 dark:text-gray-100">
<header className="bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-6 py-3">
<div className="flex items-center justify-between max-w-7xl mx-auto">
<h1 className="font-display text-xl font-medium">Director</h1>
<div className="flex items-center gap-3">
<ClaudeModeToggle />
<button
onClick={() => setShowNewPlan(true)}
className="text-sm px-3 py-1.5 rounded bg-blue-600 hover:bg-blue-700 text-white cursor-pointer"
>
+ New Plan
</button>
<span className={`w-2 h-2 rounded-full ${connected ? 'bg-green-500' : 'bg-red-500'}`} />
<span className="text-xs text-gray-500">{connected ? 'Connected' : 'Disconnected'}</span>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto p-6 space-y-6">
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
<div className="lg:col-span-1">
<AgentStatus agents={agents} />
</div>
<div className="lg:col-span-2">
{/* Tab toggle for queue vs questions */}
<div className="flex items-center gap-1 mb-3">
<button
onClick={() => setRightTab('queue')}
className={`font-mono text-[10px] uppercase tracking-wider px-3 py-1.5 rounded-md transition-colors cursor-pointer ${
rightTab === 'queue'
? 'bg-blue-600 text-white'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-800'
}`}
>
queue
{queue.length > 0 && (
<span className="ml-1.5 tabular">{queue.length}</span>
)}
</button>
<button
onClick={() => setRightTab('questions')}
className={`font-mono text-[10px] uppercase tracking-wider px-3 py-1.5 rounded-md transition-colors cursor-pointer ${
rightTab === 'questions'
? 'bg-blue-600 text-white'
: 'text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-800'
} ${questions.length > 0 && rightTab !== 'questions' ? 'ring-1 ring-amber-400' : ''}`}
>
questions
{questions.length > 0 && (
<span className="ml-1.5 tabular">{questions.length}</span>
)}
</button>
</div>
{rightTab === 'queue' && <DirectorQueue items={queue} onReview={handleReview} />}
{rightTab === 'questions' && <PendingQuestions questions={questions} onChange={loadData} />}
</div>
</div>
<PlanBoard plans={plans} onSelect={setSelectedPlan} />
</main>
{selectedPlan && (
<PlanDetail
plan={selectedPlan}
onClose={closeDetail}
reviewItem={reviewItem ?? undefined}
onReviewActioned={loadData}
/>
)}
{showNewPlan && (
<NewPlanModal onClose={() => setShowNewPlan(false)} onCreated={loadData} />
)}
</div>
)
}
export default App

BIN
ui/src/assets/hero.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 44 KiB

1
ui/src/assets/react.svg Normal file
View File

@@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>

After

Width:  |  Height:  |  Size: 4.0 KiB

1
ui/src/assets/vite.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1,41 @@
import type { Agent } from '../lib/api'
const statusColors: Record<string, string> = {
idle: 'bg-gray-400',
working: 'bg-green-500',
blocked: 'bg-yellow-500',
error: 'bg-red-500',
offline: 'bg-gray-700',
}
const providerBadge: Record<string, string> = {
ollama: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
anthropic: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200',
}
export function AgentStatus({ agents }: { agents: Agent[] }) {
return (
<div className="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-4">
<div className="flex items-center gap-2 mb-3">
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">Agents</h2>
</div>
<div className="space-y-2">
{agents.map((agent) => (
<div key={agent.id} className="flex items-center justify-between p-2 rounded bg-gray-50 dark:bg-gray-750">
<div className="flex items-center gap-2">
<span className={`w-2 h-2 rounded-full ${statusColors[agent.status] ?? 'bg-gray-400'}`} />
<span className="font-medium text-sm text-gray-900 dark:text-gray-100">{agent.name}</span>
<span className="text-xs text-gray-500">{agent.role}</span>
</div>
<div className="flex items-center gap-2">
<span className={`text-xs px-1.5 py-0.5 rounded ${providerBadge[agent.model_provider] ?? 'bg-gray-100'}`}>
{agent.model_provider}
</span>
<span className="text-xs text-gray-400">{agent.status}</span>
</div>
</div>
))}
</div>
</div>
)
}

View File

@@ -0,0 +1,48 @@
import { useEffect, useState } from 'react'
import { api } from '../lib/api'
export function ClaudeModeToggle() {
const [mode, setMode] = useState<'api' | 'proxy' | 'unknown'>('unknown')
const [busy, setBusy] = useState(false)
useEffect(() => {
api.system.getClaudeMode().then((r) => setMode(r.mode)).catch(() => {})
}, [])
async function flip() {
if (busy) return
setBusy(true)
const target = mode === 'proxy' ? 'api' : 'proxy'
try {
const result = await api.system.setClaudeMode(target)
setMode(result.mode)
} catch (err) {
console.error('claude-mode flip failed', err)
} finally {
setBusy(false)
}
}
const label = mode === 'proxy' ? 'sub' : mode === 'api' ? 'api' : '?'
const tooltip =
mode === 'proxy'
? 'Claude routed via Max subscription proxy (free). Click to switch to direct API.'
: mode === 'api'
? 'Claude routed via direct Anthropic API (billed). Click to switch to subscription proxy.'
: 'Claude provider mode unknown'
const dotColor = mode === 'proxy' ? 'bg-emerald-500' : mode === 'api' ? 'bg-amber-500' : 'bg-gray-400'
return (
<button
onClick={flip}
disabled={busy || mode === 'unknown'}
title={tooltip}
className="group flex items-center gap-2 px-2.5 py-1 rounded border border-gray-200 dark:border-gray-700 hover:border-gray-400 dark:hover:border-gray-500 transition-colors cursor-pointer disabled:opacity-50 disabled:cursor-wait"
>
<span className={`w-1.5 h-1.5 rounded-full ${dotColor} ${busy ? 'pulse-soft' : ''}`} />
<span className="font-mono text-[10px] uppercase tracking-wider text-gray-500 dark:text-gray-400">claude</span>
<span className="font-mono text-[10px] tabular text-gray-900 dark:text-gray-100">{label}</span>
</button>
)
}

View File

@@ -0,0 +1,82 @@
import type { QueueItem } from '../lib/api'
import { relativeTime, absoluteTime } from '../lib/time'
const statusColors: Record<string, string> = {
pending_verification: 'text-yellow-600',
ready_for_approval: 'text-blue-600 dark:text-blue-400',
returned: 'text-red-600',
approved: 'text-green-600',
redirected: 'text-orange-600',
}
function QueueItemCard({ item, onReview }: { item: QueueItem; onReview: () => void }) {
return (
<div className="group p-3 rounded-md border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:border-gray-400 dark:hover:border-gray-500 transition-colors">
<div className="flex items-start justify-between gap-3 mb-1">
<span className="font-medium text-sm text-gray-900 dark:text-gray-100 leading-snug">
{item.plan_title || `Plan #${item.plan_id}`}
</span>
<span className={`shrink-0 font-mono text-[10px] uppercase tracking-wider ${statusColors[item.status] ?? ''}`}>
{item.status.replace(/_/g, ' ')}
</span>
</div>
{item.task_title && (
<div className="font-mono text-[10px] text-gray-500 dark:text-gray-400 mb-1">
{item.task_title}
</div>
)}
{item.verification_notes && (
<div className="text-xs text-gray-600 dark:text-gray-400 mt-2 leading-relaxed line-clamp-2">
{item.verification_notes}
</div>
)}
<div className="flex items-center justify-between mt-3 pt-2 border-t border-gray-100 dark:border-gray-700/50">
<span className="font-mono text-[10px] tabular text-gray-400 dark:text-gray-500" title={absoluteTime(item.submitted_at)}>
submitted {relativeTime(item.submitted_at)}
</span>
{item.status === 'ready_for_approval' && (
<button
onClick={onReview}
className="font-mono text-[10px] uppercase tracking-wider px-3 py-1 rounded bg-blue-600 hover:bg-blue-700 text-white cursor-pointer"
>
review
</button>
)}
</div>
</div>
)
}
export function DirectorQueue({
items,
onReview,
}: {
items: QueueItem[]
onReview: (item: QueueItem) => void
}) {
return (
<div className="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5">
<div className="flex items-baseline justify-between mb-4">
<h2 className="font-display text-xl font-medium text-gray-900 dark:text-gray-100">
Director Queue
</h2>
<span className="font-mono text-[10px] uppercase tracking-wider text-gray-400">
{items.length} {items.length === 1 ? 'item' : 'items'}
</span>
</div>
{items.length === 0 ? (
<p className="font-mono text-xs text-gray-500 italic">No items awaiting review</p>
) : (
<div className="space-y-2">
{items.map((item) => (
<QueueItemCard key={item.id} item={item} onReview={() => onReview(item)} />
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,90 @@
import { useEffect, useRef, useState } from 'react'
interface Props {
text: string
onSave: (next: string) => Promise<void>
className?: string
}
// Inline-editable comment text. Click to enter edit mode (handled by parent),
// or pass `editing` to control externally. This component focuses on the
// editing interaction itself with a clean save/cancel UX.
export function EditableComment({ text, onSave, className = '' }: Props) {
const [editing, setEditing] = useState(false)
const [draft, setDraft] = useState(text)
const [busy, setBusy] = useState(false)
const ref = useRef<HTMLTextAreaElement>(null)
useEffect(() => { setDraft(text) }, [text])
useEffect(() => {
if (editing && ref.current) {
ref.current.focus()
// Place cursor at end
ref.current.setSelectionRange(ref.current.value.length, ref.current.value.length)
}
}, [editing])
async function save() {
if (busy) return
if (!draft.trim()) return
if (draft === text) { setEditing(false); return }
setBusy(true)
try {
await onSave(draft.trim())
setEditing(false)
} finally {
setBusy(false)
}
}
function cancel() {
setDraft(text)
setEditing(false)
}
if (editing) {
return (
<div onClick={(e) => e.stopPropagation()}>
<textarea
ref={ref}
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); save() }
if (e.key === 'Escape') { e.preventDefault(); cancel() }
}}
rows={Math.max(2, Math.min(8, draft.split('\n').length + 1))}
className="w-full px-2.5 py-1.5 rounded border border-blue-300 dark:border-blue-800 bg-blue-50/40 dark:bg-blue-950/30 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-900 resize-y leading-relaxed"
/>
<div className="flex items-center justify-end gap-2 mt-1.5">
<span className="font-mono text-[9px] text-gray-400 mr-auto">cmd+enter to save · esc to cancel</span>
<button
onClick={cancel}
disabled={busy}
className="px-2.5 py-1 rounded text-[10px] font-mono uppercase tracking-wider text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 cursor-pointer"
>
cancel
</button>
<button
onClick={save}
disabled={busy || !draft.trim() || draft === text}
className="px-3 py-1 rounded text-[10px] font-mono uppercase tracking-wider bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-50 cursor-pointer"
>
{busy ? 'saving…' : 'save'}
</button>
</div>
</div>
)
}
return (
<div
onClick={(e) => { e.stopPropagation(); setEditing(true) }}
title="Click to edit"
className={`cursor-text rounded -mx-1 px-1 transition-colors hover:bg-blue-50/60 dark:hover:bg-blue-950/30 ${className}`}
>
{text}
</div>
)
}

View File

@@ -0,0 +1,85 @@
import { useEffect, useRef, useState } from 'react'
interface Props {
onSubmit: (text: string) => Promise<void> | void
onCancel?: () => void
placeholder?: string
buttonLabel?: string
autoFocus?: boolean
compact?: boolean
}
export function InlineCommentBox({
onSubmit,
onCancel,
placeholder = 'Add your comment or question…',
buttonLabel = 'post',
autoFocus = true,
compact = false,
}: Props) {
const [text, setText] = useState('')
const [busy, setBusy] = useState(false)
const ref = useRef<HTMLTextAreaElement>(null)
useEffect(() => {
if (autoFocus && ref.current) ref.current.focus()
}, [autoFocus])
async function submit() {
if (!text.trim() || busy) return
setBusy(true)
try {
await onSubmit(text.trim())
setText('')
} finally {
setBusy(false)
}
}
return (
<div className={compact ? 'mt-1.5' : 'mt-2'}>
<div className="flex items-baseline gap-1.5 mb-1">
<span className="font-mono text-[9px] uppercase tracking-[0.12em] font-semibold text-blue-600 dark:text-blue-400">
&gt; director [joe]:
</span>
</div>
<textarea
ref={ref}
value={text}
onChange={(e) => setText(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
e.preventDefault()
submit()
}
if (e.key === 'Escape' && onCancel) {
e.preventDefault()
onCancel()
}
}}
placeholder={placeholder}
rows={compact ? 2 : 3}
className="w-full px-2.5 py-1.5 rounded border border-blue-200 dark:border-blue-900 bg-blue-50/30 dark:bg-blue-950/20 text-sm text-gray-900 dark:text-gray-100 placeholder:text-gray-400 dark:placeholder:text-gray-600 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-900 resize-y"
/>
<div className="flex items-center justify-end gap-2 mt-1.5">
<span className="font-mono text-[9px] text-gray-400 mr-auto">cmd+enter to post · esc to cancel</span>
{onCancel && (
<button
onClick={onCancel}
disabled={busy}
className="px-2.5 py-1 rounded text-[10px] font-mono uppercase tracking-wider text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 cursor-pointer"
>
cancel
</button>
)}
<button
onClick={submit}
disabled={busy || !text.trim()}
className="px-3 py-1 rounded text-[10px] font-mono uppercase tracking-wider bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-50 cursor-pointer"
>
{busy ? 'posting…' : buttonLabel}
</button>
</div>
</div>
)
}

View File

@@ -0,0 +1,35 @@
import { relativeTime, absoluteTime } from '../lib/time'
// Tiny clock-arc icon — drawn inline for crisp scaling and theming
export function ClockGlyph({ className = '' }: { className?: string }) {
return (
<svg viewBox="0 0 12 12" className={className} fill="none" stroke="currentColor" strokeWidth="1" strokeLinecap="round">
<circle cx="6" cy="6" r="4.5" />
<path d="M6 3.5 V6 L7.5 7.5" />
</svg>
)
}
// Single timestamp display — monospace, tabular, with hover-revealed absolute time
export function TimeStamp({ iso, label, className = '' }: { iso: string; label?: string; className?: string }) {
return (
<span className={`inline-flex items-baseline gap-1 font-mono text-[10px] tabular tracking-tight ${className}`} title={absoluteTime(iso)}>
{label && <span className="text-gray-400 dark:text-gray-500 uppercase">{label}</span>}
<span>{relativeTime(iso)}</span>
</span>
)
}
// A row of labeled metadata pairs — used in plan/task headers
export function MetaRow({ items }: { items: { label: string; value: string; iso?: string }[] }) {
return (
<div className="flex flex-wrap items-baseline gap-x-5 gap-y-2 font-mono text-[10px] tabular">
{items.map((item, i) => (
<div key={i} className="flex items-baseline gap-1.5" title={item.iso ? absoluteTime(item.iso) : undefined}>
<span className="text-gray-400 dark:text-gray-500 uppercase tracking-wider">{item.label}</span>
<span className="text-gray-900 dark:text-gray-100">{item.value}</span>
</div>
))}
</div>
)
}

View File

@@ -0,0 +1,120 @@
import { useState } from 'react'
import { api } from '../lib/api'
export function NewPlanModal({ onClose, onCreated }: { onClose: () => void; onCreated: () => void }) {
const [title, setTitle] = useState('')
const [description, setDescription] = useState('')
const [team, setTeam] = useState('tech')
const [priority, setPriority] = useState('3')
const [busy, setBusy] = useState(false)
const [error, setError] = useState('')
async function submit(e: React.FormEvent) {
e.preventDefault()
setBusy(true)
setError('')
try {
await api.plans.create({ title, description, team, priority })
onCreated()
onClose()
} catch (err) {
setError(err instanceof Error ? err.message : String(err))
} finally {
setBusy(false)
}
}
return (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50" onClick={onClose}>
<div
className="bg-white dark:bg-gray-800 rounded-lg w-full max-w-2xl m-4 p-6"
onClick={(e) => e.stopPropagation()}
>
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-gray-900 dark:text-gray-100">New Plan</h2>
<button onClick={onClose} className="text-gray-500 hover:text-gray-700 text-xl cursor-pointer">&times;</button>
</div>
<form onSubmit={submit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Title</label>
<input
type="text"
value={title}
onChange={(e) => setTitle(e.target.value)}
required
autoFocus
className="w-full px-3 py-2 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100"
placeholder="Feature or system name"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Description</label>
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
required
rows={6}
className="w-full px-3 py-2 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100"
placeholder="Describe the feature in plain English. The team lead will decompose it into tasks."
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Team</label>
<select
value={team}
onChange={(e) => setTeam(e.target.value)}
className="w-full px-3 py-2 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100"
>
<option value="tech">Tech</option>
<option value="comms">Comms</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Priority</label>
<select
value={priority}
onChange={(e) => setPriority(e.target.value)}
className="w-full px-3 py-2 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-900 text-gray-900 dark:text-gray-100"
>
<option value="critical">Critical</option>
<option value="1">P1 Highest</option>
<option value="2">P2 High</option>
<option value="3">P3 Normal</option>
<option value="4">P4 Low</option>
<option value="5">P5 Lowest</option>
<option value="backlog">Backlog</option>
</select>
</div>
</div>
{error && (
<div className="text-sm text-red-600">{error}</div>
)}
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={onClose}
disabled={busy}
className="px-4 py-2 rounded bg-gray-200 hover:bg-gray-300 dark:bg-gray-700 dark:hover:bg-gray-600 text-gray-800 dark:text-gray-200 cursor-pointer"
>
Cancel
</button>
<button
type="submit"
disabled={busy || !title || !description}
className="px-4 py-2 rounded bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-50 cursor-pointer"
>
{busy ? 'Creating...' : 'Create Plan'}
</button>
</div>
</form>
</div>
</div>
)
}

View File

@@ -0,0 +1,159 @@
import { useState } from 'react'
import type { PendingQuestion } from '../lib/api'
import { api } from '../lib/api'
import { relativeTime, absoluteTime } from '../lib/time'
const priorityBadge: Record<string, string> = {
critical: 'bg-red-600 text-white',
'1': 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
'2': 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200',
'3': 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
'4': 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200',
'5': 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400',
}
const roleColor: Record<string, string> = {
lead: 'text-emerald-600 dark:text-emerald-400',
agent: 'text-amber-600 dark:text-amber-400',
}
function QuestionCard({ q, onAnswered }: { q: PendingQuestion; onAnswered: () => void }) {
const [open, setOpen] = useState(false)
const [answer, setAnswer] = useState('')
const [busy, setBusy] = useState(false)
async function submit() {
if (!answer.trim() || busy) return
setBusy(true)
try {
await api.questions.answer(q.id, answer.trim())
setAnswer('')
setOpen(false)
onAnswered()
} finally {
setBusy(false)
}
}
return (
<div className="rounded-md border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:border-gray-400 dark:hover:border-gray-500 transition-colors">
<div className="p-3">
<div className="flex items-start justify-between gap-3 mb-2">
<div className="flex items-baseline gap-2 flex-wrap min-w-0">
<span className={`font-mono text-[10px] uppercase tracking-wider font-semibold ${roleColor[q.from_role] ?? 'text-gray-700'}`}>
{q.from_role}
</span>
<span className="font-mono text-[10px] text-gray-500 dark:text-gray-400">
{q.from_id}
</span>
{q.plan_title && (
<span className="font-mono text-[10px] text-gray-400 dark:text-gray-500 truncate">
· {q.plan_title}
</span>
)}
{q.task_title && (
<span className="font-mono text-[10px] text-gray-400 dark:text-gray-500 truncate">
/ {q.task_title}
</span>
)}
</div>
<span className={`shrink-0 text-[10px] font-mono font-medium uppercase tracking-wider px-1.5 py-0.5 rounded ${priorityBadge[q.priority] ?? ''}`}>
{q.priority === 'critical' ? 'crit' : `p${q.priority}`}
</span>
</div>
<div className="text-sm text-gray-900 dark:text-gray-100 leading-relaxed mb-2">
{q.question}
</div>
{q.context && (
<div className="text-xs text-gray-500 dark:text-gray-400 leading-relaxed mb-2 italic">
{q.context}
</div>
)}
<div className="flex items-center justify-between pt-2 border-t border-gray-100 dark:border-gray-700/50">
<span className="font-mono text-[10px] tabular text-gray-400 dark:text-gray-500" title={absoluteTime(q.created_at)}>
asked {relativeTime(q.created_at)}
</span>
{!open && (
<button
onClick={() => setOpen(true)}
className="font-mono text-[10px] uppercase tracking-wider px-3 py-1 rounded bg-blue-600 hover:bg-blue-700 text-white cursor-pointer"
>
answer
</button>
)}
</div>
{open && (
<div className="mt-3 pt-3 border-t border-gray-100 dark:border-gray-700/50">
<div className="font-mono text-[9px] uppercase tracking-[0.12em] font-semibold text-blue-600 dark:text-blue-400 mb-1">
&gt; director [joe]:
</div>
<textarea
value={answer}
onChange={(e) => setAnswer(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) { e.preventDefault(); submit() }
if (e.key === 'Escape') { e.preventDefault(); setOpen(false); setAnswer('') }
}}
placeholder="Your answer…"
autoFocus
rows={3}
className="w-full px-2.5 py-1.5 rounded border border-blue-200 dark:border-blue-900 bg-blue-50/30 dark:bg-blue-950/20 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-blue-200 dark:focus:ring-blue-900"
/>
<div className="flex items-center justify-end gap-2 mt-2">
<span className="font-mono text-[9px] text-gray-400 mr-auto">cmd+enter to submit · esc to cancel</span>
<button
onClick={() => { setOpen(false); setAnswer('') }}
disabled={busy}
className="px-2.5 py-1 rounded text-[10px] font-mono uppercase tracking-wider text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 cursor-pointer"
>
cancel
</button>
<button
onClick={submit}
disabled={busy || !answer.trim()}
className="px-3 py-1 rounded text-[10px] font-mono uppercase tracking-wider bg-blue-600 hover:bg-blue-700 text-white disabled:opacity-50 cursor-pointer"
>
{busy ? 'sending…' : 'send answer'}
</button>
</div>
</div>
)}
</div>
</div>
)
}
export function PendingQuestions({
questions,
onChange,
}: {
questions: PendingQuestion[]
onChange: () => void
}) {
return (
<div className="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5">
<div className="flex items-baseline justify-between mb-4">
<h2 className="font-display text-xl font-medium text-gray-900 dark:text-gray-100">
Pending Questions
</h2>
<span className="font-mono text-[10px] uppercase tracking-wider text-gray-400">
{questions.length} {questions.length === 1 ? 'question' : 'questions'}
</span>
</div>
{questions.length === 0 ? (
<p className="font-mono text-xs text-gray-500 italic">No pending questions</p>
) : (
<div className="space-y-2">
{questions.map((q) => (
<QuestionCard key={q.id} q={q} onAnswered={onChange} />
))}
</div>
)}
</div>
)
}

View File

@@ -0,0 +1,90 @@
import type { Plan } from '../lib/api'
import { TimeStamp, ClockGlyph } from './Metadata'
const priorityBadge: Record<string, string> = {
critical: 'bg-red-600 text-white',
'1': 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
'2': 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200',
'3': 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
'4': 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200',
'5': 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400',
backlog: 'bg-gray-50 text-gray-500 dark:bg-gray-800 dark:text-gray-500',
}
const columns = [
{ key: 'draft', label: 'Draft' },
{ key: 'approved', label: 'Approved' },
{ key: 'in_progress', label: 'In Progress' },
{ key: 'done', label: 'Done' },
] as const
function PlanCard({ plan, onClick }: { plan: Plan; onClick: () => void }) {
const isActive = plan.status === 'in_progress'
return (
<button
onClick={onClick}
className="group w-full text-left rounded-md border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 hover:border-gray-400 dark:hover:border-gray-500 transition-colors cursor-pointer overflow-hidden"
>
<div className="p-3">
<div className="flex items-start justify-between gap-2 mb-2">
<span className="font-display text-[15px] leading-snug font-medium text-gray-900 dark:text-gray-100 line-clamp-2">
{plan.title}
</span>
<span className={`shrink-0 text-[10px] font-mono font-medium uppercase tracking-wider px-1.5 py-0.5 rounded ${priorityBadge[plan.priority] ?? ''}`}>
{plan.priority === 'critical' ? 'crit' : `p${plan.priority}`}
</span>
</div>
<div className="flex items-center justify-between">
<span className="font-mono text-[10px] uppercase tracking-wider text-gray-400 dark:text-gray-500">
{plan.team}
</span>
<span className="flex items-center gap-1 text-gray-400 dark:text-gray-500">
<ClockGlyph className="w-2.5 h-2.5" />
<TimeStamp iso={plan.created_at} />
</span>
</div>
</div>
{isActive && (
<div className="h-0.5 bg-gradient-to-r from-blue-500 via-blue-400 to-transparent pulse-soft" />
)}
</button>
)
}
export function PlanBoard({ plans, onSelect }: { plans: Plan[]; onSelect: (plan: Plan) => void }) {
return (
<div className="rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 p-5">
<div className="flex items-baseline justify-between mb-4">
<h2 className="font-display text-xl font-medium text-gray-900 dark:text-gray-100">Plan Board</h2>
<span className="font-mono text-[10px] uppercase tracking-wider text-gray-400">
{plans.length} {plans.length === 1 ? 'plan' : 'plans'}
</span>
</div>
<div className="grid grid-cols-4 gap-4">
{columns.map((col) => {
const colPlans = plans.filter((p) => p.status === col.key || (col.key === 'draft' && p.status === 'clarification'))
return (
<div key={col.key}>
<div className="flex items-baseline justify-between mb-3 pb-2 border-b border-gray-200 dark:border-gray-700">
<span className="font-mono text-[10px] font-semibold text-gray-500 dark:text-gray-400 uppercase tracking-wider">
{col.label}
</span>
<span className="font-mono text-[10px] tabular text-gray-400 dark:text-gray-500">
{colPlans.length}
</span>
</div>
<div className="space-y-2 min-h-[100px]">
{colPlans.map((plan) => (
<PlanCard key={plan.id} plan={plan} onClick={() => onSelect(plan)} />
))}
</div>
</div>
)
})}
</div>
</div>
)
}

View File

@@ -0,0 +1,670 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import type { Plan, Task, Message, QueueItem } from '../lib/api'
import { api } from '../lib/api'
import { relativeTime, absoluteTime } from '../lib/time'
import { InlineCommentBox } from './InlineCommentBox'
import { EditableComment } from './EditableComment'
function duration(fromIso: string, toIso?: string): string {
const from = new Date(fromIso).getTime()
const to = toIso ? new Date(toIso).getTime() : Date.now()
const sec = Math.max(0, Math.floor((to - from) / 1000))
if (sec < 60) return `${sec}s`
const min = Math.floor(sec / 60)
if (min < 60) return `${min}m ${sec % 60}s`
const hr = Math.floor(min / 60)
if (hr < 24) return `${hr}h ${min % 60}m`
const day = Math.floor(hr / 24)
return `${day}d ${hr % 24}h`
}
const taskStatusGlyph: Record<string, string> = {
queued: '○',
in_progress: '◐',
blocked: '■',
complete: '●',
returned: '↩',
}
const taskStatusColor: Record<string, string> = {
queued: 'text-gray-400',
in_progress: 'text-blue-500',
blocked: 'text-yellow-500',
complete: 'text-green-500',
returned: 'text-red-500',
}
const roleColor: Record<string, string> = {
director: 'text-blue-600 dark:text-blue-400',
lead: 'text-emerald-600 dark:text-emerald-400',
agent: 'text-amber-600 dark:text-amber-400',
system: 'text-gray-500',
}
const priorityBadge: Record<string, string> = {
critical: 'bg-red-600 text-white',
'1': 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
'2': 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200',
'3': 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
'4': 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200',
'5': 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400',
backlog: 'bg-gray-50 text-gray-500 dark:bg-gray-800 dark:text-gray-500',
}
function MetaItem({ label, value, title }: { label: string; value: string; title?: string }) {
return (
<div className="flex flex-col gap-0.5" title={title}>
<span className="font-mono text-[9px] uppercase tracking-[0.12em] text-gray-400 dark:text-gray-500">{label}</span>
<span className="font-mono text-xs tabular text-gray-900 dark:text-gray-100">{value}</span>
</div>
)
}
// Wrapper that makes any region click-to-comment with refined hover affordance.
// The whole region is clickable; a "+ comment" chip appears top-right on hover
// as a secondary visual signal. Children stop propagation if they need to.
function Commentable({
active,
onActivate,
children,
className = '',
chipLabel = 'comment',
chipPosition = 'top-right',
}: {
active: boolean
onActivate: () => void
children: React.ReactNode
className?: string
chipLabel?: string
chipPosition?: 'top-right' | 'inline'
}) {
return (
<div
onClick={(e) => {
if (active) return
// Don't activate if user is selecting text
const sel = window.getSelection?.()
if (sel && sel.toString().length > 0) return
// Ignore clicks that originated on links or buttons
const target = e.target as HTMLElement
if (target.closest('button, a, textarea, input, [data-no-comment]')) return
onActivate()
}}
className={`relative group/c rounded-md transition-colors cursor-text ${
!active ? 'hover:bg-blue-50/50 dark:hover:bg-blue-950/20' : ''
} ${className}`}
>
{/* Subtle left accent on hover */}
{!active && (
<span
aria-hidden
className="pointer-events-none absolute left-0 top-1.5 bottom-1.5 w-[2px] rounded-full bg-blue-400/0 group-hover/c:bg-blue-400/60 dark:group-hover/c:bg-blue-500/60 transition-colors"
/>
)}
{!active && chipPosition === 'top-right' && (
<span className="pointer-events-none absolute top-2 right-2 font-mono text-[9px] uppercase tracking-wider text-blue-500/0 group-hover/c:text-blue-500 dark:group-hover/c:text-blue-400 transition-colors">
+ {chipLabel}
</span>
)}
{children}
</div>
)
}
// Single message rendered as a card. If it's a director message, the body is editable.
function MessageCard({
message,
replies,
activeReplyTarget,
setActiveReplyTarget,
postReply,
editMessage,
}: {
message: Message
replies: Message[]
activeReplyTarget: string | null
setActiveReplyTarget: (key: string | null) => void
postReply: (replyToId: number, text: string) => Promise<void>
editMessage: (id: number, next: string) => Promise<void>
}) {
const replyKey = `msg:${message.id}`
const isOpen = activeReplyTarget === replyKey
const isDirector = message.from_role === 'director'
const inner = (
<div className="border-l-2 border-gray-200 dark:border-gray-700 pl-3 py-1.5 pr-2">
<div className="flex items-baseline gap-2 mb-1">
<span className={`font-mono text-[10px] uppercase tracking-wider font-semibold ${roleColor[message.from_role] ?? 'text-gray-700'}`}>
{message.from_role}
</span>
<span className="font-mono text-[10px] text-gray-500 dark:text-gray-400">{message.from_id}</span>
<span className="font-mono text-[10px] tabular text-gray-400 dark:text-gray-500" title={absoluteTime(message.created_at)}>
{relativeTime(message.created_at)}
</span>
{isDirector && (
<span className="font-mono text-[9px] uppercase tracking-wider text-gray-400 ml-1">· editable</span>
)}
</div>
{isDirector ? (
<div className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed">
<EditableComment text={message.message} onSave={(next) => editMessage(message.id, next)} />
</div>
) : (
<div className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed whitespace-pre-line">
{message.message}
</div>
)}
{isOpen && (
<div data-no-comment>
<InlineCommentBox
placeholder="Reply to this message…"
buttonLabel="reply"
onCancel={() => setActiveReplyTarget(null)}
onSubmit={async (text) => {
await postReply(message.id, text)
setActiveReplyTarget(null)
}}
/>
</div>
)}
</div>
)
return (
<div>
{isDirector ? (
// Director messages: not click-to-comment (clicking edits via EditableComment)
// but agents can still be replied to via the structure below.
<div className="rounded-md">{inner}</div>
) : (
<Commentable
active={isOpen}
onActivate={() => setActiveReplyTarget(replyKey)}
chipLabel="reply"
>
{inner}
</Commentable>
)}
{replies.length > 0 && (
<div className="ml-5 mt-2 space-y-2">
{replies.map((reply) => (
<MessageCard
key={reply.id}
message={reply}
replies={[]}
activeReplyTarget={activeReplyTarget}
setActiveReplyTarget={setActiveReplyTarget}
postReply={postReply}
editMessage={editMessage}
/>
))}
</div>
)}
</div>
)
}
interface PlanDetailProps {
plan: Plan
onClose: () => void
reviewItem?: QueueItem
onReviewActioned?: () => void
}
export function PlanDetail({ plan: initial, onClose, reviewItem, onReviewActioned }: PlanDetailProps) {
const [plan, setPlan] = useState(initial)
const [tasks, setTasks] = useState<Task[]>([])
const [messages, setMessages] = useState<Message[]>([])
const [actionBusy, setActionBusy] = useState(false)
const [sendBackMode, setSendBackMode] = useState(false)
const [sendBackText, setSendBackText] = useState('')
const [activeReplyTarget, setActiveReplyTarget] = useState<string | null>(null)
const verificationRef = useRef<HTMLDivElement>(null)
function refresh() {
api.plans.get(initial.id).then(setPlan).catch(() => {})
api.tasks.list(initial.id).then(setTasks)
api.messages.list(initial.id).then(setMessages)
}
useEffect(() => {
refresh()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [initial.id])
useEffect(() => {
if (reviewItem && verificationRef.current) {
verificationRef.current.scrollIntoView({ behavior: 'smooth', block: 'center' })
}
}, [reviewItem])
// Esc closes any open inline editor
useEffect(() => {
function handler(e: KeyboardEvent) {
if (e.key === 'Escape' && activeReplyTarget) {
setActiveReplyTarget(null)
}
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [activeReplyTarget])
const totalDuration = plan.completed_at && plan.started_at
? duration(plan.started_at, plan.completed_at)
: plan.started_at
? duration(plan.started_at)
: null
const { topLevelMessages, repliesByMessageId, messagesByTaskId, verificationComments } = useMemo(() => {
const byMsg = new Map<number, Message[]>()
const byTask = new Map<number, Message[]>()
const verif: Message[] = []
const top: Message[] = []
for (const m of messages) {
if (m.reply_to_id != null) {
const arr = byMsg.get(m.reply_to_id) ?? []
arr.push(m)
byMsg.set(m.reply_to_id, arr)
} else if (m.task_id != null) {
const arr = byTask.get(m.task_id) ?? []
arr.push(m)
byTask.set(m.task_id, arr)
} else if (m.message_type === 'verification_comment') {
verif.push(m)
} else {
top.push(m)
}
}
return {
topLevelMessages: top,
repliesByMessageId: byMsg,
messagesByTaskId: byTask,
verificationComments: verif,
}
}, [messages])
async function postComment(payload: {
text: string
taskId?: number
replyToId?: number
messageType?: string
}) {
await api.messages.create(plan.id, {
from_role: 'director',
from_id: 'joe',
message: payload.text,
message_type: payload.messageType ?? 'comment',
task_id: payload.taskId,
reply_to_id: payload.replyToId,
})
refresh()
}
async function editMessage(id: number, next: string) {
await api.messages.update(id, next)
refresh()
}
async function handleApprove() {
if (!reviewItem || actionBusy) return
setActionBusy(true)
try {
await api.queue.update(reviewItem.id, { status: 'approved' })
onReviewActioned?.()
onClose()
} catch (err) {
console.error('approve failed', err)
} finally {
setActionBusy(false)
}
}
async function handleSendBack() {
if (!reviewItem || actionBusy || !sendBackText.trim()) return
setActionBusy(true)
try {
await postComment({ text: `Sent back: ${sendBackText.trim()}` })
await api.queue.update(reviewItem.id, {
status: 'redirected',
director_notes: sendBackText.trim(),
})
setSendBackText('')
setSendBackMode(false)
onReviewActioned?.()
onClose()
} catch (err) {
console.error('send back failed', err)
} finally {
setActionBusy(false)
}
}
return (
<div className="fixed inset-0 bg-black/60 backdrop-blur-sm flex items-center justify-center z-50 p-4" onClick={onClose}>
<div
className="bg-white dark:bg-gray-900 rounded-xl w-full max-w-3xl max-h-[92vh] overflow-auto shadow-2xl border border-gray-200 dark:border-gray-700"
onClick={(e) => e.stopPropagation()}
>
{reviewItem && (
<div className="bg-gradient-to-r from-blue-50 to-blue-50/50 dark:from-blue-950/30 dark:to-blue-950/10 border-b border-blue-200 dark:border-blue-900 px-7 py-3 sticky top-0 z-10 backdrop-blur-sm">
<div className="flex items-center gap-2">
<span className="font-mono text-[10px] uppercase tracking-wider font-semibold text-blue-700 dark:text-blue-400">
Review mode
</span>
<span className="font-mono text-[10px] text-blue-600/70 dark:text-blue-400/70">
· click any item to comment · click your own comments to edit
</span>
</div>
</div>
)}
{/* Header */}
<div className="px-7 pt-6 pb-5 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-start justify-between gap-4 mb-3">
<h2 className="font-display text-2xl font-medium leading-tight text-gray-900 dark:text-gray-100">
{plan.title}
</h2>
<div className="flex items-center gap-2 shrink-0">
<span className={`text-[10px] font-mono font-medium uppercase tracking-wider px-2 py-1 rounded ${priorityBadge[plan.priority] ?? ''}`}>
{plan.priority === 'critical' ? 'crit' : `p${plan.priority}`}
</span>
<button onClick={onClose} className="text-gray-400 hover:text-gray-700 dark:hover:text-gray-200 text-2xl leading-none w-7 h-7 flex items-center justify-center cursor-pointer">&times;</button>
</div>
</div>
<p className="text-sm text-gray-600 dark:text-gray-400 leading-relaxed mb-5">{plan.description}</p>
<div className="flex flex-wrap gap-x-7 gap-y-3 pt-4 border-t border-gray-100 dark:border-gray-800">
<MetaItem label="Plan #" value={String(plan.id)} />
<MetaItem label="Team" value={plan.team} />
<MetaItem label="Status" value={plan.status.replace(/_/g, ' ')} />
<MetaItem label="Created" value={relativeTime(plan.created_at)} title={absoluteTime(plan.created_at)} />
{plan.approved_at && <MetaItem label="Approved" value={relativeTime(plan.approved_at)} title={absoluteTime(plan.approved_at)} />}
{plan.started_at && <MetaItem label="Picked up" value={relativeTime(plan.started_at)} title={absoluteTime(plan.started_at)} />}
{plan.completed_at && <MetaItem label="Completed" value={relativeTime(plan.completed_at)} title={absoluteTime(plan.completed_at)} />}
{totalDuration && <MetaItem label={plan.completed_at ? 'Took' : 'Running'} value={totalDuration} />}
</div>
</div>
{/* Verification (review mode only) */}
{reviewItem && (
<div ref={verificationRef} className="px-7 py-5 border-b border-gray-200 dark:border-gray-700 bg-blue-50/30 dark:bg-blue-950/10">
<div className="flex items-baseline justify-between mb-2">
<h3 className="font-display text-base font-medium text-gray-900 dark:text-gray-100">Verification</h3>
<span className="font-mono text-[10px] uppercase tracking-wider text-blue-600 dark:text-blue-400">
{reviewItem.task_title ? `task: ${reviewItem.task_title}` : 'plan-level'}
</span>
</div>
<Commentable
active={activeReplyTarget === 'verification'}
onActivate={() => setActiveReplyTarget('verification')}
className="-mx-2 px-2 py-1.5"
>
{reviewItem.verification_notes ? (
<div className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed">
{reviewItem.verification_notes}
</div>
) : (
<div className="font-mono text-xs text-gray-500 italic">No verification notes recorded</div>
)}
<div className="mt-2 font-mono text-[10px] tabular text-gray-500 dark:text-gray-400">
submitted {relativeTime(reviewItem.submitted_at)}
</div>
{activeReplyTarget === 'verification' && (
<div data-no-comment>
<InlineCommentBox
placeholder="Comment on the verification…"
buttonLabel="post"
onCancel={() => setActiveReplyTarget(null)}
onSubmit={async (text) => {
await postComment({
text,
taskId: reviewItem.task_id,
messageType: 'verification_comment',
})
setActiveReplyTarget(null)
}}
/>
</div>
)}
</Commentable>
{verificationComments.length > 0 && (
<div className="mt-4 space-y-2">
{verificationComments.map((c) => (
<MessageCard
key={c.id}
message={c}
replies={repliesByMessageId.get(c.id) ?? []}
activeReplyTarget={activeReplyTarget}
setActiveReplyTarget={setActiveReplyTarget}
postReply={async (rid, text) => { await postComment({ text, replyToId: rid }) }}
editMessage={editMessage}
/>
))}
</div>
)}
</div>
)}
{/* Tasks */}
<section className="px-7 py-5 border-b border-gray-200 dark:border-gray-700">
<div className="flex items-baseline justify-between mb-3">
<h3 className="font-display text-base font-medium text-gray-900 dark:text-gray-100">Tasks</h3>
<span className="font-mono text-[10px] uppercase tracking-wider text-gray-400">
{tasks.filter((t) => t.status === 'complete').length} / {tasks.length} complete
</span>
</div>
{tasks.length === 0 ? (
<p className="font-mono text-xs text-gray-500 italic">No tasks yet awaiting team lead decomposition</p>
) : (
<div className="space-y-3">
{tasks.map((task) => {
const took = task.completed_at && task.started_at
? duration(task.started_at, task.completed_at)
: task.started_at
? duration(task.started_at)
: null
const isReviewedTask = task.id === reviewItem?.task_id
const taskKey = `task:${task.id}`
const taskComments = messagesByTaskId.get(task.id) ?? []
return (
<div
key={task.id}
className={isReviewedTask ? 'rounded-md ring-1 ring-blue-200 dark:ring-blue-900 bg-blue-50/40 dark:bg-blue-950/20' : ''}
>
<Commentable
active={activeReplyTarget === taskKey}
onActivate={() => setActiveReplyTarget(taskKey)}
className="px-3 py-2"
>
<div className="flex items-start gap-3">
<span className={`text-base leading-tight pt-0.5 ${taskStatusColor[task.status] ?? 'text-gray-400'}`} title={task.status}>
{taskStatusGlyph[task.status] ?? '○'}
</span>
<div className="flex-1 min-w-0">
<div className="flex items-baseline gap-2">
<span className="text-sm text-gray-900 dark:text-gray-100">{task.title}</span>
{task.assigned_agent && (
<span className="font-mono text-[10px] text-gray-400 dark:text-gray-500">
{task.assigned_agent}
</span>
)}
</div>
{task.description && (
<div className="text-xs text-gray-500 dark:text-gray-400 mt-0.5 leading-relaxed">{task.description}</div>
)}
{task.progress_note && (
<div className="font-mono text-[10px] text-gray-400 dark:text-gray-500 mt-1 italic">
{task.progress_note}
</div>
)}
<div className="flex items-center gap-3 mt-1 font-mono text-[10px] tabular text-gray-400 dark:text-gray-500">
{task.started_at && (
<span title={absoluteTime(task.started_at)}>started {relativeTime(task.started_at)}</span>
)}
{took && (
<span className={task.completed_at ? 'text-gray-500 dark:text-gray-400' : 'text-blue-500'}>
{task.completed_at ? `took ${took}` : `${took} elapsed`}
</span>
)}
</div>
{activeReplyTarget === taskKey && (
<div data-no-comment>
<InlineCommentBox
placeholder={`Comment on "${task.title}"…`}
buttonLabel="post"
onCancel={() => setActiveReplyTarget(null)}
onSubmit={async (text) => {
await postComment({ text, taskId: task.id })
setActiveReplyTarget(null)
}}
/>
</div>
)}
{taskComments.length > 0 && (
<div className="mt-3 space-y-2 border-l border-gray-200 dark:border-gray-700 pl-3" data-no-comment>
{taskComments.map((c) => (
<MessageCard
key={c.id}
message={c}
replies={repliesByMessageId.get(c.id) ?? []}
activeReplyTarget={activeReplyTarget}
setActiveReplyTarget={setActiveReplyTarget}
postReply={async (rid, text) => { await postComment({ text, replyToId: rid }) }}
editMessage={editMessage}
/>
))}
</div>
)}
</div>
</div>
</Commentable>
</div>
)
})}
</div>
)}
</section>
{/* Activity */}
<section className="px-7 py-5">
<div className="flex items-baseline justify-between mb-3">
<h3 className="font-display text-base font-medium text-gray-900 dark:text-gray-100">Activity</h3>
<span className="font-mono text-[10px] uppercase tracking-wider text-gray-400">
{messages.length} {messages.length === 1 ? 'message' : 'messages'}
</span>
</div>
{topLevelMessages.length === 0 ? (
<p className="font-mono text-xs text-gray-500 italic">No plan-level messages</p>
) : (
<div className="space-y-3">
{topLevelMessages.map((msg) => (
<MessageCard
key={msg.id}
message={msg}
replies={repliesByMessageId.get(msg.id) ?? []}
activeReplyTarget={activeReplyTarget}
setActiveReplyTarget={setActiveReplyTarget}
postReply={async (rid, text) => { await postComment({ text, replyToId: rid }) }}
editMessage={editMessage}
/>
))}
</div>
)}
<div className="mt-5 pt-4 border-t border-gray-100 dark:border-gray-800">
{activeReplyTarget === 'plan' ? (
<InlineCommentBox
placeholder="Add a general note to this plan…"
buttonLabel="post"
onCancel={() => setActiveReplyTarget(null)}
onSubmit={async (text) => {
await postComment({ text })
setActiveReplyTarget(null)
}}
/>
) : (
<button
onClick={() => setActiveReplyTarget('plan')}
className="font-mono text-[10px] uppercase tracking-wider text-gray-500 hover:text-blue-600 dark:hover:text-blue-400 cursor-pointer"
>
+ add plan-level comment
</button>
)}
</div>
</section>
{/* Sticky review actions */}
{reviewItem && (
<div className="sticky bottom-0 bg-white/95 dark:bg-gray-900/95 backdrop-blur-sm border-t border-gray-200 dark:border-gray-700 px-7 py-4">
{sendBackMode ? (
<div>
<div className="flex items-baseline gap-2 mb-2">
<span className="font-mono text-[10px] uppercase tracking-wider font-semibold text-orange-600 dark:text-orange-400">
sending back
</span>
<span className="font-mono text-[10px] text-orange-600/70 dark:text-orange-400/70">
explain what needs to change
</span>
</div>
<textarea
value={sendBackText}
onChange={(e) => setSendBackText(e.target.value)}
rows={3}
autoFocus
placeholder="Required: explain why and what to fix"
className="w-full px-3 py-2 rounded border border-orange-300 dark:border-orange-800 bg-white dark:bg-gray-800 text-sm text-gray-900 dark:text-gray-100 focus:outline-none focus:ring-2 focus:ring-orange-200 dark:focus:ring-orange-900"
/>
<div className="flex justify-end gap-2 mt-3">
<button
onClick={() => { setSendBackMode(false); setSendBackText('') }}
disabled={actionBusy}
className="px-3 py-1.5 rounded text-xs font-mono uppercase tracking-wider text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 cursor-pointer"
>
cancel
</button>
<button
onClick={handleSendBack}
disabled={actionBusy || !sendBackText.trim()}
className="px-4 py-1.5 rounded text-xs font-mono uppercase tracking-wider bg-orange-600 hover:bg-orange-700 text-white disabled:opacity-50 cursor-pointer"
>
{actionBusy ? 'sending…' : 'confirm send back'}
</button>
</div>
</div>
) : (
<div className="flex items-center justify-between">
<span className="font-mono text-[10px] text-gray-500 dark:text-gray-400">
ready to decide?
</span>
<div className="flex items-center gap-2">
<button
onClick={() => setSendBackMode(true)}
disabled={actionBusy}
className="px-4 py-1.5 rounded text-xs font-mono uppercase tracking-wider border border-orange-300 dark:border-orange-800 text-orange-700 dark:text-orange-400 hover:bg-orange-50 dark:hover:bg-orange-950/30 cursor-pointer"
>
send back
</button>
<button
onClick={handleApprove}
disabled={actionBusy}
className="px-5 py-1.5 rounded text-xs font-mono uppercase tracking-wider bg-emerald-600 hover:bg-emerald-700 text-white disabled:opacity-50 cursor-pointer"
>
{actionBusy ? 'approving…' : 'approve'}
</button>
</div>
</div>
)}
</div>
)}
</div>
</div>
)
}

View File

@@ -0,0 +1,34 @@
import { useEffect, useRef, useCallback } from 'react'
type EventHandler = (event: string, data: unknown) => void
export function useWebSocket(onEvent: EventHandler) {
const wsRef = useRef<WebSocket | null>(null)
const handleRef = useRef(onEvent)
handleRef.current = onEvent
const connect = useCallback(() => {
const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:'
const ws = new WebSocket(`${protocol}//${location.host}/ws`)
ws.onmessage = (e) => {
try {
const { event, data } = JSON.parse(e.data)
handleRef.current(event, data)
} catch {
// ignore malformed messages
}
}
ws.onclose = () => {
setTimeout(connect, 3000)
}
wsRef.current = ws
}, [])
useEffect(() => {
connect()
return () => wsRef.current?.close()
}, [connect])
}

37
ui/src/index.css Normal file
View File

@@ -0,0 +1,37 @@
@import "tailwindcss";
@theme {
--font-sans: "Inter", system-ui, sans-serif;
--font-display: "Fraunces", Georgia, serif;
--font-mono: "JetBrains Mono", ui-monospace, monospace;
}
:root {
font-family: var(--font-sans);
font-feature-settings: "cv02", "cv03", "cv04", "cv11";
}
.font-display {
font-family: var(--font-display);
font-optical-sizing: auto;
}
.font-mono {
font-family: var(--font-mono);
}
/* Tabular numerals — critical for timestamps and metrics */
.tabular {
font-variant-numeric: tabular-nums;
font-feature-settings: "tnum";
}
/* Subtle pulse for active/working states */
@keyframes pulse-soft {
0%, 100% { opacity: 1; }
50% { opacity: 0.5; }
}
.pulse-soft {
animation: pulse-soft 2s ease-in-out infinite;
}

144
ui/src/lib/api.ts Normal file
View File

@@ -0,0 +1,144 @@
const BASE = '/api'
async function request<T>(path: string, opts?: RequestInit): Promise<T> {
const res = await fetch(`${BASE}${path}`, {
headers: { 'Content-Type': 'application/json' },
...opts,
})
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))
throw new Error(err.error || res.statusText)
}
return res.json()
}
export interface Plan {
id: number
title: string
description: string
team: string
priority: string
status: string
created_at: string
updated_at: string
approved_at?: string
started_at?: string
completed_at?: string
}
export interface Task {
id: number
plan_id: number
title: string
description?: string
task_type: string
status: string
assigned_agent?: string
progress_note?: string
sort_order: number
created_at?: string
updated_at?: string
started_at?: string
completed_at?: string
}
export interface Agent {
id: string
name: string
team: string
role: string
model_provider: string
model_id: string
gpu_default?: string
status: string
current_task_id?: number
last_seen_at?: string
}
export interface QueueItem {
id: number
plan_id?: number
task_id?: number
status: string
verification_notes?: string
director_notes?: string
submitted_at: string
reviewed_at?: string
plan_title: string
task_title: string
}
export interface PendingQuestion {
id: number
plan_id?: number
task_id?: number
from_role: string
from_id: string
question: string
context?: string
priority: string
status: string
answer?: string
created_at: string
answered_at?: string
plan_title?: string
task_title?: string
}
export interface Message {
id: number
plan_id: number
task_id?: number
reply_to_id?: number
from_role: string
from_id: string
message: string
message_type: string
created_at: string
}
export interface ClaudeMode {
mode: 'api' | 'proxy' | 'unknown'
output: string
}
export const api = {
system: {
getClaudeMode: () => request<ClaudeMode>('/system/claude-mode'),
setClaudeMode: (mode: 'api' | 'proxy') =>
request<ClaudeMode>('/system/claude-mode', {
method: 'POST',
body: JSON.stringify({ mode }),
}),
},
plans: {
list: () => request<Plan[]>('/plans'),
get: (id: number) => request<Plan>(`/plans/${id}`),
create: (data: Partial<Plan>) => request<Plan>('/plans', { method: 'POST', body: JSON.stringify(data) }),
update: (id: number, data: Partial<Plan>) => request<Plan>(`/plans/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
},
tasks: {
list: (planId: number) => request<Task[]>(`/plans/${planId}/tasks`),
create: (planId: number, data: Partial<Task>) => request<Task>(`/plans/${planId}/tasks`, { method: 'POST', body: JSON.stringify(data) }),
update: (id: number, data: Partial<Task>) => request<Task>(`/tasks/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
},
agents: {
list: () => request<Agent[]>('/agents'),
},
queue: {
list: (status?: string) => request<QueueItem[]>(`/queue${status ? `?status=${status}` : ''}`),
update: (id: number, data: { status: string; director_notes?: string }) => request<QueueItem>(`/queue/${id}`, { method: 'PATCH', body: JSON.stringify(data) }),
},
questions: {
list: (status?: string) => request<PendingQuestion[]>(`/questions${status ? `?status=${status}` : ''}`),
answer: (id: number, answer: string) => request<{ id: number; status: string }>(`/questions/${id}`, {
method: 'PATCH',
body: JSON.stringify({ answer }),
}),
},
messages: {
list: (planId: number) => request<Message[]>(`/plans/${planId}/messages`),
create: (planId: number, data: Partial<Message>) => request<Message>(`/plans/${planId}/messages`, { method: 'POST', body: JSON.stringify(data) }),
update: (id: number, message: string) => request<{ id: number; message: string }>(`/messages/${id}`, { method: 'PATCH', body: JSON.stringify({ message }) }),
},
}

26
ui/src/lib/time.ts Normal file
View File

@@ -0,0 +1,26 @@
// Relative time formatter — "3m ago", "2h ago", "5d ago"
export function relativeTime(iso: string): string {
const then = new Date(iso).getTime()
const diff = Date.now() - then
const sec = Math.floor(diff / 1000)
if (sec < 10) return 'just now'
if (sec < 60) return `${sec}s ago`
const min = Math.floor(sec / 60)
if (min < 60) return `${min}m ago`
const hr = Math.floor(min / 60)
if (hr < 24) return `${hr}h ago`
const day = Math.floor(hr / 24)
if (day < 7) return `${day}d ago`
const wk = Math.floor(day / 7)
if (wk < 5) return `${wk}w ago`
const mo = Math.floor(day / 30)
if (mo < 12) return `${mo}mo ago`
return `${Math.floor(day / 365)}y ago`
}
export function absoluteTime(iso: string): string {
return new Date(iso).toLocaleString(undefined, {
year: 'numeric', month: 'short', day: 'numeric',
hour: 'numeric', minute: '2-digit',
})
}

10
ui/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

25
ui/tsconfig.app.json Normal file
View File

@@ -0,0 +1,25 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "esnext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["src"]
}

7
ui/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

24
ui/tsconfig.node.json Normal file
View File

@@ -0,0 +1,24 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "es2023",
"lib": ["ES2023"],
"module": "esnext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true
},
"include": ["vite.config.ts"]
}

18
ui/vite.config.ts Normal file
View File

@@ -0,0 +1,18 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
port: 3000,
hmr: false,
proxy: {
'/api': 'http://127.0.0.1:8090',
'/ws': {
target: 'ws://127.0.0.1:8090',
ws: true,
},
},
},
})