From 4ee0517345d5d82c280a0189d0b544274d5a716e Mon Sep 17 00:00:00 2001 From: Manohar Date: Sat, 2 May 2026 20:11:43 +0000 Subject: [PATCH] feat(dashboard): agents page, knowledge, schedule/digest cards, dual-source tasks - app/agents/: new Agents page showing per-agent status + workspace files - app/knowledge/: Knowledge base viewer - app/api/tiger/: proxy routes for cron, file-tasks, file-projects, keys, agents - components/agent-strip.tsx: agent status bar for dashboard home - components/command-bar.tsx: command palette - components/digest-card.tsx + schedule-card.tsx: weekly digest + cron schedule - components/status-footer.tsx: system status footer - tasks page: dual-source (TASKS.md JSON block + SQLite) with fallback - projects page: PROJECTS.md reader with kanban board integration --- dashboard/src/app/agents/page.tsx | 136 ++++ .../app/api/tiger/agents-activity/route.ts | 20 + .../src/app/api/tiger/bridge-restart/route.ts | 21 + .../tiger/config/models/agents/[id]/route.ts | 23 + .../api/tiger/config/models/agents/route.ts | 14 + .../src/app/api/tiger/cron/[id]/run/route.ts | 15 + dashboard/src/app/api/tiger/cron/route.ts | 11 + .../src/app/api/tiger/file-projects/route.ts | 21 + .../app/api/tiger/file-tasks/active/route.ts | 11 + .../api/tiger/file-tasks/projects/route.ts | 11 + .../src/app/api/tiger/file-tasks/route.ts | 29 + dashboard/src/app/api/tiger/keys/route.ts | 26 + dashboard/src/app/knowledge/page.tsx | 90 +++ dashboard/src/app/page.tsx | 444 +----------- dashboard/src/app/projects/page.tsx | 662 ++++++++++-------- dashboard/src/app/settings/page.tsx | 339 ++++++--- dashboard/src/app/tasks/page.tsx | 360 +++++++--- dashboard/src/components/agent-strip.tsx | 148 ++++ dashboard/src/components/app-sidebar.tsx | 157 ++--- .../src/components/chat-interface.tsx.pre-ws | 297 -------- dashboard/src/components/command-bar.tsx | 136 ++++ dashboard/src/components/digest-card.tsx | 110 +++ dashboard/src/components/schedule-card.tsx | 186 +++++ dashboard/src/components/status-footer.tsx | 134 ++++ .../src/components/tasks/kanban-board.tsx | 7 +- .../src/components/telegram-thread-card.tsx | 103 +++ dashboard/src/lib/bridge.ts | 21 + 27 files changed, 2269 insertions(+), 1263 deletions(-) create mode 100644 dashboard/src/app/agents/page.tsx create mode 100644 dashboard/src/app/api/tiger/agents-activity/route.ts create mode 100644 dashboard/src/app/api/tiger/bridge-restart/route.ts create mode 100644 dashboard/src/app/api/tiger/config/models/agents/[id]/route.ts create mode 100644 dashboard/src/app/api/tiger/config/models/agents/route.ts create mode 100644 dashboard/src/app/api/tiger/cron/[id]/run/route.ts create mode 100644 dashboard/src/app/api/tiger/cron/route.ts create mode 100644 dashboard/src/app/api/tiger/file-projects/route.ts create mode 100644 dashboard/src/app/api/tiger/file-tasks/active/route.ts create mode 100644 dashboard/src/app/api/tiger/file-tasks/projects/route.ts create mode 100644 dashboard/src/app/api/tiger/file-tasks/route.ts create mode 100644 dashboard/src/app/api/tiger/keys/route.ts create mode 100644 dashboard/src/app/knowledge/page.tsx create mode 100644 dashboard/src/components/agent-strip.tsx delete mode 100644 dashboard/src/components/chat-interface.tsx.pre-ws create mode 100644 dashboard/src/components/command-bar.tsx create mode 100644 dashboard/src/components/digest-card.tsx create mode 100644 dashboard/src/components/schedule-card.tsx create mode 100644 dashboard/src/components/status-footer.tsx create mode 100644 dashboard/src/components/telegram-thread-card.tsx diff --git a/dashboard/src/app/agents/page.tsx b/dashboard/src/app/agents/page.tsx new file mode 100644 index 0000000..c4053f4 --- /dev/null +++ b/dashboard/src/app/agents/page.tsx @@ -0,0 +1,136 @@ +"use client" + +/** + * /agents — Agent overview page (Phase 1) + * + * Lists all 5 agents with their full details. Phase 3 will turn each card + * into a clickable drill-in with the per-agent model dropdown. OpenClaw + * supports per-agent model overrides via agents.list[].model with live + * config patching — no container restart needed. + */ + +import * as React from "react" +import useSWR from "swr" +import { Bot } from "lucide-react" +import { Card } from "@/components/ui/card" +import { cn } from "@/lib/utils" + +interface Agent { + id: string + name: string + emoji: string + role: string + fileCount: number + lastActivity: number +} + +const fetcher = (url: string) => fetch(url).then((r) => r.json()) + +function relativeTime(ts: number): string { + if (!ts) return "—" + const diff = Date.now() - ts + const m = Math.floor(diff / 60_000) + if (m < 1) return "just now" + if (m < 60) return `${m}m ago` + const h = Math.floor(m / 60) + if (h < 24) return `${h}h ago` + return `${Math.floor(h / 24)}d ago` +} + +function statusOf(ts: number): "active" | "recent" | "idle" { + if (!ts) return "idle" + const diff = Date.now() - ts + if (diff < 5 * 60_000) return "active" + if (diff < 60 * 60_000) return "recent" + return "idle" +} + +const STATUS_COLOR = { + active: "bg-green-500", + recent: "bg-amber-500", + idle: "bg-zinc-500", +} + +export default function AgentsPage() { + const { data, isLoading } = useSWR<{ ok: boolean; agents: Agent[] }>( + "/api/tiger/agents", + fetcher, + { refreshInterval: 30_000 } + ) + + const agents = data?.agents ?? [] + + return ( +
+
+

+ + Agents +

+

+ Tiger's orchestrator and 4 specialist sub-agents. Phase 3 will let you + override the model per agent here. +

+
+ + {isLoading ? ( +
+ {Array.from({ length: 5 }).map((_, i) => ( + + ))} +
+ ) : ( +
+ {agents.map((agent) => { + const status = statusOf(agent.lastActivity) + return ( + +
+ {agent.emoji} +
+
+

{agent.name}

+ +
+

+ {agent.role} +

+
+
+ +
+
+ Last activity + {relativeTime(agent.lastActivity)} +
+
+ Workspace files + {agent.fileCount} +
+
+ Model + + inherits global + +
+
+
+ ) + })} +
+ )} + +
+ Coming in Phase 3: click any + agent to set a custom model (e.g., a cheaper model for Researcher, + a stronger model for Coder). +
+
+ ) +} diff --git a/dashboard/src/app/api/tiger/agents-activity/route.ts b/dashboard/src/app/api/tiger/agents-activity/route.ts new file mode 100644 index 0000000..4de396e --- /dev/null +++ b/dashboard/src/app/api/tiger/agents-activity/route.ts @@ -0,0 +1,20 @@ +/** + * /api/tiger/agents-activity — Agent Activity Proxy + * + * Reads per-agent activity from Tiger's workspace via bridge endpoint. + */ + +import { NextResponse } from "next/server"; +import { bridgeGet } from "@/lib/bridge"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + const result = await bridgeGet("/tiger/agents/activity"); + return NextResponse.json(result); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json({ ok: false, error: message }, { status: 502 }); + } +} diff --git a/dashboard/src/app/api/tiger/bridge-restart/route.ts b/dashboard/src/app/api/tiger/bridge-restart/route.ts new file mode 100644 index 0000000..c489b00 --- /dev/null +++ b/dashboard/src/app/api/tiger/bridge-restart/route.ts @@ -0,0 +1,21 @@ +// POST /api/tiger/bridge-restart — restart the tiger-bridge systemd service +// Responds immediately, then triggers the restart with a short delay so the +// HTTP response is fully written before the process exits. +import { NextResponse } from "next/server"; +import { exec } from "child_process"; + +export const dynamic = "force-dynamic"; + +export async function POST() { + // Schedule restart after response is flushed + setTimeout(() => { + exec("systemctl restart tiger-bridge", (err) => { + if (err) console.error("[bridge-restart] systemctl failed:", err.message); + }); + }, 600); + + return NextResponse.json({ + ok: true, + message: "Bridge restart initiated. Dashboard will reconnect in ~5s.", + }); +} diff --git a/dashboard/src/app/api/tiger/config/models/agents/[id]/route.ts b/dashboard/src/app/api/tiger/config/models/agents/[id]/route.ts new file mode 100644 index 0000000..96da157 --- /dev/null +++ b/dashboard/src/app/api/tiger/config/models/agents/[id]/route.ts @@ -0,0 +1,23 @@ +// PATCH /api/tiger/config/models/agents/[id] — set or clear a per-agent model override +// Next.js 15+ requires params to be awaited (they are now a Promise) +import { NextRequest, NextResponse } from "next/server"; +import { bridgePatch } from "@/lib/bridge"; + +export const dynamic = "force-dynamic"; + +export async function PATCH( + request: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const body = await request.json(); + const result = await bridgePatch( + `/tiger/config/models/agents/${id}`, + body as Record + ); + return NextResponse.json(result); + } catch (err: any) { + return NextResponse.json({ ok: false, error: err.message }, { status: 502 }); + } +} diff --git a/dashboard/src/app/api/tiger/config/models/agents/route.ts b/dashboard/src/app/api/tiger/config/models/agents/route.ts new file mode 100644 index 0000000..c7083c2 --- /dev/null +++ b/dashboard/src/app/api/tiger/config/models/agents/route.ts @@ -0,0 +1,14 @@ +// GET /api/tiger/config/models/agents — per-agent model overrides + defaults +import { NextResponse } from "next/server"; +import { bridgeGet } from "@/lib/bridge"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + const result = await bridgeGet("/tiger/config/models/agents"); + return NextResponse.json(result); + } catch (err: any) { + return NextResponse.json({ ok: false, error: err.message }, { status: 502 }); + } +} diff --git a/dashboard/src/app/api/tiger/cron/[id]/run/route.ts b/dashboard/src/app/api/tiger/cron/[id]/run/route.ts new file mode 100644 index 0000000..9e24ebc --- /dev/null +++ b/dashboard/src/app/api/tiger/cron/[id]/run/route.ts @@ -0,0 +1,15 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { bridgePost } from '@/lib/bridge'; +export const dynamic = 'force-dynamic'; +export async function POST( + _req: NextRequest, + { params }: { params: Promise<{ id: string }> } +) { + try { + const { id } = await params; + const result = await bridgePost('/tiger/cron/' + id + '/run', {}); + return NextResponse.json(result); + } catch (err: any) { + return NextResponse.json({ ok: false, error: err.message }, { status: 502 }); + } +} diff --git a/dashboard/src/app/api/tiger/cron/route.ts b/dashboard/src/app/api/tiger/cron/route.ts new file mode 100644 index 0000000..e2a71cc --- /dev/null +++ b/dashboard/src/app/api/tiger/cron/route.ts @@ -0,0 +1,11 @@ +import { NextResponse } from 'next/server'; +import { bridgeGet } from '@/lib/bridge'; +export const dynamic = 'force-dynamic'; +export async function GET() { + try { + const result = await bridgeGet('/tiger/cron'); + return NextResponse.json(result); + } catch (err: any) { + return NextResponse.json({ ok: false, error: err.message }, { status: 502 }); + } +} diff --git a/dashboard/src/app/api/tiger/file-projects/route.ts b/dashboard/src/app/api/tiger/file-projects/route.ts new file mode 100644 index 0000000..3017b13 --- /dev/null +++ b/dashboard/src/app/api/tiger/file-projects/route.ts @@ -0,0 +1,21 @@ +/** + * /api/tiger/file-projects — Tiger Workspace Projects Proxy + * + * Reads projects from Tiger's PROJECTS.md (via /tiger/file-tasks/projects bridge endpoint). + */ + +import { NextResponse } from "next/server"; +import { bridgeGet } from "@/lib/bridge"; + +export const dynamic = "force-dynamic"; + +// GET /api/tiger/file-projects — all projects from PROJECTS.md +export async function GET() { + try { + const result = await bridgeGet("/tiger/file-tasks/projects"); + return NextResponse.json(result); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json({ ok: false, error: message }, { status: 502 }); + } +} diff --git a/dashboard/src/app/api/tiger/file-tasks/active/route.ts b/dashboard/src/app/api/tiger/file-tasks/active/route.ts new file mode 100644 index 0000000..3a339c1 --- /dev/null +++ b/dashboard/src/app/api/tiger/file-tasks/active/route.ts @@ -0,0 +1,11 @@ +import { NextResponse } from 'next/server'; +import { bridgeGet } from '@/lib/bridge'; +export const dynamic = 'force-dynamic'; +export async function GET() { + try { + const result = await bridgeGet('/tiger/file-tasks/active'); + return NextResponse.json(result); + } catch (err: any) { + return NextResponse.json({ ok: false, error: err.message }, { status: 502 }); + } +} diff --git a/dashboard/src/app/api/tiger/file-tasks/projects/route.ts b/dashboard/src/app/api/tiger/file-tasks/projects/route.ts new file mode 100644 index 0000000..3ff062c --- /dev/null +++ b/dashboard/src/app/api/tiger/file-tasks/projects/route.ts @@ -0,0 +1,11 @@ +import { NextResponse } from 'next/server'; +import { bridgeGet } from '@/lib/bridge'; +export const dynamic = 'force-dynamic'; +export async function GET() { + try { + const result = await bridgeGet('/tiger/file-tasks/projects'); + return NextResponse.json(result); + } catch (err: any) { + return NextResponse.json({ ok: false, error: err.message }, { status: 502 }); + } +} diff --git a/dashboard/src/app/api/tiger/file-tasks/route.ts b/dashboard/src/app/api/tiger/file-tasks/route.ts new file mode 100644 index 0000000..b099cb4 --- /dev/null +++ b/dashboard/src/app/api/tiger/file-tasks/route.ts @@ -0,0 +1,29 @@ +/** + * /api/tiger/file-tasks — Tiger Workspace Tasks Proxy + * + * Reads tasks from Tiger's TASKS.md (via /tiger/file-tasks bridge endpoint). + * This is the authoritative task list — not from SQLite. + */ + +import { NextResponse } from "next/server"; +import { bridgeGet } from "@/lib/bridge"; + +export const dynamic = "force-dynamic"; + +// GET /api/tiger/file-tasks — all tasks from TASKS.md +export async function GET(request: Request) { + try { + const { searchParams } = new URL(request.url); + const section = searchParams.get("section"); // active | completed | all + + let endpoint = "/tiger/file-tasks"; + if (section === "active") endpoint = "/tiger/file-tasks/active"; + else if (section === "completed") endpoint = "/tiger/file-tasks/completed"; + + const result = await bridgeGet(endpoint); + return NextResponse.json(result); + } catch (err: unknown) { + const message = err instanceof Error ? err.message : "Unknown error"; + return NextResponse.json({ ok: false, error: message }, { status: 502 }); + } +} diff --git a/dashboard/src/app/api/tiger/keys/route.ts b/dashboard/src/app/api/tiger/keys/route.ts new file mode 100644 index 0000000..912e2a0 --- /dev/null +++ b/dashboard/src/app/api/tiger/keys/route.ts @@ -0,0 +1,26 @@ +// GET /api/tiger/keys — returns key presence (never values) +// PATCH /api/tiger/keys — set one or more keys + +import { NextRequest, NextResponse } from "next/server"; +import { bridgeGet, bridgePatch } from "@/lib/bridge"; + +export const dynamic = "force-dynamic"; + +export async function GET() { + try { + const result = await bridgeGet("/tiger/keys"); + return NextResponse.json(result); + } catch (err: any) { + return NextResponse.json({ ok: false, error: err.message }, { status: 502 }); + } +} + +export async function PATCH(request: NextRequest) { + try { + const body = await request.json(); + const result = await bridgePatch("/tiger/keys", body as Record); + return NextResponse.json(result); + } catch (err: any) { + return NextResponse.json({ ok: false, error: err.message }, { status: 502 }); + } +} diff --git a/dashboard/src/app/knowledge/page.tsx b/dashboard/src/app/knowledge/page.tsx new file mode 100644 index 0000000..ea637b9 --- /dev/null +++ b/dashboard/src/app/knowledge/page.tsx @@ -0,0 +1,90 @@ +"use client" + +/** + * /knowledge — Tiger's brain (Phase 1 hub) + * + * Absorbs four orphans that previously had no sidebar entry: + * /memory — SOUL.md, USER.md, IDENTITY.md, MEMORY.md content + * /skills — registry of skills + * /activity — agent file-write timeline + * /cron — scheduled tasks + * + * For Phase 1 this is a hub that links into each existing page. Phase 6 + * will fold all four into tabs on this page. + */ + +import { Brain, ScrollText, Wrench, Activity, Clock } from "lucide-react" +import { Card } from "@/components/ui/card" + +const sections = [ + { + title: "Memory", + href: "/memory", + icon: ScrollText, + description: + "Tiger's persistent memory — SOUL.md, USER.md, IDENTITY.md, MEMORY.md. " + + "Edit these files to teach Tiger about you and itself.", + }, + { + title: "Skills", + href: "/skills", + icon: Wrench, + description: + "Registry of capabilities Tiger can invoke. Skills are reusable " + + "instruction sets that ship with the openclaw runtime.", + }, + { + title: "Activity", + href: "/activity", + icon: Activity, + description: + "Timeline of every workspace file write across all agents. Useful for " + + "auditing what Tiger and the sub-agents have been doing.", + }, + { + title: "Scheduled jobs", + href: "/cron", + icon: Clock, + description: + "Cron-scheduled agent runs. Things like 'morning digest at 8am' or " + + "'pull RE news daily' live here.", + }, +] + +export default function KnowledgePage() { + return ( +
+
+

+ + Knowledge +

+

+ Tiger's brain — what it remembers, what it can do, what it's done, + and what it'll do next. +

+
+ +
+ {sections.map(({ title, href, icon: Icon, description }) => ( + + +
+
+ +
+

{title}

+
+

+ {description} +

+
+ Open {title.toLowerCase()} → +
+
+
+ ))} +
+
+ ) +} diff --git a/dashboard/src/app/page.tsx b/dashboard/src/app/page.tsx index 063710a..a838f6a 100644 --- a/dashboard/src/app/page.tsx +++ b/dashboard/src/app/page.tsx @@ -1,434 +1,32 @@ "use client" -import useSWR from 'swr' -import { StatCard } from "@/components/stat-card" -import { - Activity, - Bot, - Clock, - AlertCircle, - Zap, - Cpu, - Check, - Loader2, - RefreshCw, - Server, - Terminal, - MemoryStick, -} from "lucide-react" -import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" -import { Button } from "@/components/ui/button" -import { useBridgeRequest } from "@/hooks/use-bridge" -import { cn } from "@/lib/utils" -import * as React from "react" - -const fetcher = (url: string) => fetch(url).then((res) => res.json()) - -interface TigerStatus { - status: "online" | "degraded" | "offline" - container: { - status: string - exitCode: number - startedAt: string - } - openclaw: { - running: boolean - processInfo: string - } - system: { - memoryUsagePct: number - memoryTotalMb: number - uptime: string - } - agent: { - currentModel: string - fallbackModels: string[] - availableModels?: string[] - heartbeat: string | null - soul: string | null - } -} - -function formatUptime(startedAt: string): string { - if (!startedAt) return "—" - const start = new Date(startedAt) - const now = new Date() - const diffMs = now.getTime() - start.getTime() - const diffHours = Math.floor(diffMs / (1000 * 60 * 60)) - const diffMins = Math.floor((diffMs % (1000 * 60 * 60)) / (1000 * 60)) - - if (diffHours > 24) { - const days = Math.floor(diffHours / 24) - return `${days}d ${diffHours % 24}h` - } - return `${diffHours}h ${diffMins}m` -} - -export default function DashboardPage() { - const { data: status, error: statusError, isLoading } = useSWR('/api/tiger/status', fetcher, { - refreshInterval: 5000, - revalidateOnFocus: true, - }) - // Agent activity — used for "Last Activity" row in health card - const { data: agentsData } = useSWR<{ ok: boolean; agents: { lastActivity: number }[] }>( - '/api/tiger/agents', fetcher, { refreshInterval: 30000 } - ) - const lastActivity = React.useMemo(() => { - const ts = agentsData?.agents?.map(a => a.lastActivity).filter(Boolean) ?? [] - return ts.length > 0 ? Math.max(...ts) : 0 - }, [agentsData]) - const { request } = useBridgeRequest() - const [restarting, setRestarting] = React.useState(false) - const [restartSuccess, setRestartSuccess] = React.useState(false) - - const isOffline = statusError || status?.status === "offline" - const isCrashed = status?.container?.exitCode === 255 - - const handleRestart = async () => { - setRestarting(true) - setRestartSuccess(false) - try { - await request("/api/tiger/restart", "POST") - setRestartSuccess(true) - setTimeout(() => setRestartSuccess(false), 3000) - } catch (e) { - console.error("Failed to restart:", e) - } finally { - setRestarting(false) - } - } +import { CommandBar } from "@/components/command-bar" +import { AgentStrip } from "@/components/agent-strip" +import { DigestCard } from "@/components/digest-card" +import { TelegramThreadCard } from "@/components/telegram-thread-card" +import { StatusFooter } from "@/components/status-footer" +import { ScheduleCard } from "@/components/schedule-card" +export default function HomePage() { return ( -
+
+ {/* HERO — the command bar is the front door of Tiger. */} + - {/* Crash Recovery Banner */} - {isCrashed && ( -
-
- -
- Tiger Crashed - {`(exit code 255 — ${status?.agent?.currentModel ?? "API"} unreachable)`} -
-
- -
- )} + {/* AGENTS — one strip, all 5 agents, live state at a glance. */} + - {/* Stat Cards Row */} -
- - -
-
-

Container

-

- {isLoading ? "..." : status?.container?.status || "Unknown"} -

-
- -
-
-
- - - -
-
-

OpenClaw

-

- {isLoading ? "..." : status?.openclaw?.running ? "Running" : "Stopped"} -

-
- -
-
-
- - - -
-
-

Memory

-

- {isLoading ? "..." : `${status?.system?.memoryUsagePct || 0}%`} -

-
- -
-
-
- - - -
-
-

Uptime

-

- {isLoading ? "..." : formatUptime(status?.container?.startedAt || "")} -

-
- -
-
-
+ {/* CONTEXT ROW — digest (left) + Telegram thread (right) */} +
+ +
- {/* Error State */} - {isOffline && !isLoading && ( -
- - Failed to connect to Tiger Bridge. Ensure the bridge server is running on the VPS. -
- )} + {/* SCHEDULE ROW — Tiger's cron jobs + next-run times */} + -
- - {/* Container Health Card */} - - - - - Tiger Health - - - {status?.status === "online" ? ( - - All systems operational - - ) : status?.status === "degraded" ? ( - - Degraded mode - - ) : ( - "Connection lost" - )} - - - -
- {/* Container Status */} -
- Container Status -
- - {status?.container?.status || "—"} -
-
- - {/* Exit Code */} - {status?.container?.exitCode !== undefined && status?.container?.exitCode !== 0 && ( -
- Exit Code - - {status?.container?.exitCode} - {status?.container?.exitCode === 255 && " (API unreachable)"} - -
- )} - - {/* OpenClaw Process */} -
- OpenClaw Process - - {status?.openclaw?.running ? "Running" : "Not running"} - -
- - {/* Memory Usage */} -
- Memory Usage - - {status?.system?.memoryUsagePct || 0}% of {status?.system?.memoryTotalMb || 0}MB - -
- - {/* Uptime */} -
- Container Uptime - {formatUptime(status?.container?.startedAt || "")} -
- - {/* Last Activity — most recent agent file write */} -
- Last Activity - - {lastActivity > 0 - ? (() => { - const diff = Date.now() - lastActivity - const m = Math.floor(diff / 60000) - if (m < 1) return "just now" - if (m < 60) return `${m}m ago` - const h = Math.floor(m / 60) - if (h < 24) return `${h}h ${m % 60}m ago` - return `${Math.floor(h / 24)}d ago` - })() - : "—"} - -
- - {/* Restart Button */} -
- -
-
-
-
- - {/* Agent Model Card */} - - - - - Agent Model - - Current AI model configuration - - -
- {/* Primary Model */} -
-
Primary Model
-
- {isLoading ? "..." : (() => { - const m = status?.agent?.currentModel || "" - if (!m) return "Not configured" - // openrouter/provider/model-name:tag → "model-name" + badge - const parts = m.split("/") - const modelSlug = parts[parts.length - 1].replace(/:.*$/, "") - const via = parts.length >= 2 ? parts[0] : null - return modelSlug || m - })()} -
- {!isLoading && status?.agent?.currentModel && ( -
- {status.agent.currentModel} -
- )} -
- - {/* Fallback Models */} - {status?.agent?.fallbackModels && status.agent.fallbackModels.length > 0 && ( -
-
Fallback Models
-
- {status.agent.fallbackModels.map((model, i) => ( -
- {model} -
- ))} -
-
- )} - - {/* Available Models (from providers config) */} - {status?.agent?.availableModels && status.agent.availableModels.length > 0 && ( -
-
Available Models
-
- {status.agent.availableModels.map((model, i) => ( -
- {model} -
- ))} -
-
- )} - - {/* Heartbeat */} - {status?.agent?.heartbeat && ( -
-
Last Heartbeat
-
-                    {status.agent.heartbeat.slice(0, 500)}
-                  
-
- )} -
-
-
-
- - {/* Quick Links */} - + {/* FOOTER — system health strip. Becomes a banner on crash. */} +
) -} \ No newline at end of file +} diff --git a/dashboard/src/app/projects/page.tsx b/dashboard/src/app/projects/page.tsx index 48fc579..d9678e8 100644 --- a/dashboard/src/app/projects/page.tsx +++ b/dashboard/src/app/projects/page.tsx @@ -1,189 +1,361 @@ -/** - * Projects Page — Project management with tasks - * - * Lists projects as cards and provides project detail view with Kanban. - */ - "use client" +/** + * /projects — Dual-source project view with inline task+agent expansion + * + * PRIMARY → Tiger's PROJECTS.md (source of truth, read-only) + * SECONDARY → SQLite projects (dashboard-queued, waiting for Tiger) + * + * Click any project → expands to show tasks from TASKS.md for that project, + * each task row showing: stage name, assigned agent (emoji+name), status badge. + */ + import * as React from "react" -import { FolderOpen, Plus, Loader2, MoreVertical, Pencil, Trash2 } from "lucide-react" +import useSWR from "swr" +import { + FolderOpen, Plus, Loader2, ChevronDown, ChevronRight, + Inbox, Trash2, MoreVertical, +} from "lucide-react" import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card" import { Button } from "@/components/ui/button" import { Input } from "@/components/ui/input" import { Badge } from "@/components/ui/badge" import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, - DialogTrigger, + Dialog, DialogContent, DialogDescription, DialogHeader, + DialogTitle, DialogTrigger, } from "@/components/ui/dialog" import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, + DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu" -import { useBridgeRequest } from "@/hooks/use-bridge" import { cn } from "@/lib/utils" -interface Project { +// ─── Types ──────────────────────────────────────────────────────────────────── + +interface FileProject { + id: string + name: string + description: string + created: string + tasks_count: string + status: string +} + +interface FileTask { + id: string + title: string + status: string + status_raw: string + assigned_agent: string + project: string + isProject?: boolean + isSubTask?: boolean + parentId?: string +} + +interface DbProject { id: string name: string description: string status: string priority: string created_at: string - updated_at: string } -interface Task { - id: string - project_id: string - title: string - description: string - status: string - priority: string - assigned_agent: string | null - progress: number - created_at: string - updated_at: string -} +// ─── Constants ──────────────────────────────────────────────────────────────── const PRIORITY_COLORS: Record = { - low: "bg-gray-500/10 text-gray-400 border-gray-500/20", + low: "bg-gray-500/10 text-gray-400 border-gray-500/20", medium: "bg-blue-500/10 text-blue-400 border-blue-500/20", - high: "bg-amber-500/10 text-amber-400 border-amber-500/20", + high: "bg-amber-500/10 text-amber-400 border-amber-500/20", urgent: "bg-red-500/10 text-red-400 border-red-500/20", } -const STATUS_COLORS: Record = { - active: "bg-green-500/10 text-green-400 border-green-500/20", - paused: "bg-yellow-500/10 text-yellow-400 border-yellow-500/20", - completed: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20", - archived: "bg-gray-500/10 text-gray-400 border-gray-500/20", +const TASK_STATUS_COLORS: Record = { + "in-progress": "bg-amber-500/10 text-amber-400", + review: "bg-purple-500/10 text-purple-400", + done: "bg-emerald-500/10 text-emerald-400", + backlog: "bg-zinc-500/10 text-zinc-400", } -export default function ProjectsPage() { - const { request } = useBridgeRequest() - const [projects, setProjects] = React.useState([]) - const [selectedProject, setSelectedProject] = React.useState(null) - const [tasks, setTasks] = React.useState([]) - const [loading, setLoading] = React.useState(false) - const [loadingTasks, setLoadingTasks] = React.useState(false) - const [error, setError] = React.useState(null) - const [isCreateOpen, setIsCreateOpen] = React.useState(false) - const [newProjectName, setNewProjectName] = React.useState("") - const [newProjectDesc, setNewProjectDesc] = React.useState("") - const [newProjectPriority, setNewProjectPriority] = React.useState("medium") - const [creating, setCreating] = React.useState(false) +const AGENT_EMOJI: Record = { + tiger: "🐯", main: "🐯", + cody: "💻", coder: "💻", + ethan: "🔍", researcher: "🔍", + cathy: "✍️", writer: "✍️", + elon: "📊", pm: "📊", +} - // Load projects - const loadProjects = React.useCallback(async () => { - setLoading(true) - setError(null) - try { - const data = await request("/api/tiger/projects") as { ok: boolean; projects?: Project[] } - if (data.ok && data.projects) { - setProjects(data.projects) - } else { - setError("Failed to load projects") - } - } catch (e: unknown) { - setError("Failed to load projects") - } finally { - setLoading(false) - } - }, [request]) +const AGENT_COLORS: Record = { + tiger: "text-orange-400", main: "text-orange-400", + cody: "text-blue-400", coder: "text-blue-400", + ethan: "text-green-400", researcher: "text-green-400", + cathy: "text-pink-400", writer: "text-pink-400", + elon: "text-violet-400", pm: "text-violet-400", +} - // Load tasks for selected project - const loadTasks = React.useCallback(async (projectId: string) => { - setLoadingTasks(true) - try { - const data = await request("/api/tiger/projects") as { ok: boolean; projects?: Project[] } - // Get tasks from project - const projectData = await request(`/api/tiger/projects/${projectId}`) as { ok: boolean; project?: { tasks?: Task[] } } - if (projectData.ok && projectData.project?.tasks) { - setTasks(projectData.project.tasks) - } else { - // Fallback: get all tasks and filter - const allTasks = await request("/api/tiger/tasks") as { ok: boolean; tasks?: Task[] } - if (allTasks.ok && allTasks.tasks) { - setTasks(allTasks.tasks.filter(t => t.project_id === projectId)) - } - } - } catch (e: unknown) { - console.error("Failed to load tasks:", e) - } finally { - setLoadingTasks(false) - } - }, [request]) +// ─── Helpers ────────────────────────────────────────────────────────────────── - React.useEffect(() => { - loadProjects() - }, [loadProjects]) +const fetcher = (url: string) => fetch(url).then((r) => r.json()) - React.useEffect(() => { - if (selectedProject) { - loadTasks(selectedProject.id) - } - }, [selectedProject, loadTasks]) +function statusBadgeClass(status: string) { + const s = status.toLowerCase() + if (s.includes("progress") || s.includes("active") || s.includes("🔴") || s.includes("🔄")) + return "bg-green-500/10 text-green-400 border-green-500/20" + if (s.includes("review")) + return "bg-purple-500/10 text-purple-400 border-purple-500/20" + if (s.includes("done") || s.includes("complete") || s.includes("approved")) + return "bg-emerald-500/10 text-emerald-400 border-emerald-500/20" + return "bg-zinc-500/10 text-zinc-400 border-zinc-500/20" +} - const handleCreateProject = async () => { - if (!newProjectName.trim()) return - setCreating(true) - try { - await request("/api/tiger/projects", "POST", { - name: newProjectName, - description: newProjectDesc, - priority: newProjectPriority, - }) - setNewProjectName("") - setNewProjectDesc("") - setNewProjectPriority("medium") - setIsCreateOpen(false) - loadProjects() - } catch (e: unknown) { - console.error("Failed to create project:", e) - } finally { - setCreating(false) - } - } +function cleanText(s: string) { + // Strip emoji and markdown bold markers for display + return s.replace(/\*\*/g, "").replace(/[✅⏳🔄❌🛢️🔍💻📊✍️🐯]/g, "").trim() +} - const handleDeleteProject = async (id: string) => { - try { - await request("/api/tiger/projects", "POST", { _method: "DELETE", id }) - loadProjects() - if (selectedProject?.id === id) { - setSelectedProject(null) - setTasks([]) - } - } catch (e: unknown) { - console.error("Failed to delete project:", e) - } - } +// ─── Task row inside expanded project ───────────────────────────────────────── - // Group tasks by status - const tasksByStatus = React.useMemo(() => { - const grouped: Record = { - backlog: [], - ready: [], - "in-progress": [], - review: [], - done: [], - } - tasks.forEach(task => { - if (grouped[task.status]) { - grouped[task.status].push(task) - } - }) - return grouped - }, [tasks]) +function TaskRow({ task }: { task: FileTask }) { + const agentKey = task.assigned_agent?.toLowerCase() ?? "" + return ( +
+ {/* Status badge */} + + {task.status_raw ? cleanText(task.status_raw).slice(0, 20) : task.status} + + + {/* Stage/task name */} + + {cleanText(task.title)} + + + {/* Agent */} + {agentKey && agentKey !== "unassigned" && ( + + {AGENT_EMOJI[agentKey] ?? "🤖"} + {agentKey} + + )} +
+ ) +} + +// ─── Tiger's project card (read-only, with task expand) ─────────────────────── + +function FileProjectCard({ project }: { project: FileProject }) { + const [expanded, setExpanded] = React.useState(false) + + // Fetch tasks from TASKS.md filtered to this project name + const projectName = cleanText(project.name) + const { data: tasksData, isLoading: tasksLoading } = useSWR<{ + ok: boolean; tasks: FileTask[] + }>( + expanded + ? `/api/tiger/file-tasks?project=${encodeURIComponent(projectName)}` + : null, + fetcher + ) + + // Show sub-tasks (stage rows) and the project-level task (agent + status) + const allTasks = tasksData?.tasks ?? [] + const projectTask = allTasks.find((t) => t.isProject) + const subTasks = allTasks.filter((t) => t.isSubTask) return ( -
+ + +
+ {/* Expand toggle + title */} + + + {/* Status badge */} + + {cleanText(project.status).replace(/in progress/i, "Active")} + +
+ + {project.description && ( + + {project.description} + + )} +
+ + + {/* Meta row */} +
+ Created {project.created} + · + {project.tasks_count} + {/* Show primary agent when collapsed */} + {!expanded && projectTask?.assigned_agent && ( + <> + · + + {AGENT_EMOJI[projectTask.assigned_agent] ?? ""} {projectTask.assigned_agent} + + + )} +
+ + {/* Expanded task list */} + {expanded && ( +
+ {tasksLoading ? ( +
+ +
+ ) : allTasks.length === 0 ? ( +

+ No tasks found in TASKS.md for this project. +

+ ) : ( +
+ {/* Project-level agent row */} + {projectTask && ( +
+ Lead: + + {AGENT_EMOJI[projectTask.assigned_agent] ?? ""} + {projectTask.assigned_agent} + + + {cleanText(projectTask.status_raw || projectTask.status)} + +
+ )} + + {/* Sub-task rows (review pipeline stages etc) */} + {subTasks.length > 0 ? ( + subTasks.map((t) => ) + ) : ( +

+ No task breakdown available — Tiger tracks this at project level. +

+ )} +
+ )} +
+ )} +
+
+ ) +} + +// ─── Dashboard-queued project card ─────────────────────────────────────────── + +function DbProjectCard({ project, onDelete }: { project: DbProject; onDelete: (id: string) => void }) { + return ( + + +
+
+ {project.name} + {project.description && ( + {project.description} + )} +
+ + + + + + onDelete(project.id)} className="text-destructive"> + Delete + + + +
+
+ +
+ + {project.priority} + + Queued — waiting for Tiger +
+
+
+ ) +} + +// ─── Main page ──────────────────────────────────────────────────────────────── + +export default function ProjectsPage() { + const { data: fileData, isLoading: fileLoading } = useSWR<{ + ok: boolean; projects: FileProject[] + }>("/api/tiger/file-tasks/projects", fetcher, { refreshInterval: 60_000 }) + + const { data: dbData, isLoading: dbLoading, mutate: mutateDb } = useSWR<{ + ok: boolean; projects: DbProject[] + }>("/api/tiger/projects", fetcher, { refreshInterval: 60_000 }) + + const fileProjects = fileData?.projects ?? [] + const dbProjects = dbData?.projects ?? [] + const isLoading = fileLoading || dbLoading + + const [isCreateOpen, setIsCreateOpen] = React.useState(false) + const [form, setForm] = React.useState({ name: "", seed: "", description: "", priority: "medium" }) + const [creating, setCreating] = React.useState(false) + const [createError, setCreateError] = React.useState("") + + const handleCreate = async () => { + const payload: Record = { priority: form.priority } + if (form.name.trim()) payload.name = form.name.trim() + if (form.seed.trim()) payload.seed = form.seed.trim() + if (form.description.trim()) payload.description = form.description.trim() + if (!payload.name && !payload.seed) { setCreateError("Enter a project name or seed text."); return } + setCreating(true); setCreateError("") + try { + const res = await fetch("/api/tiger/projects", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }) + const result = await res.json() + if (!result.ok) throw new Error(result.error ?? "Failed") + setForm({ name: "", seed: "", description: "", priority: "medium" }) + setIsCreateOpen(false) + mutateDb() + } catch (e: any) { setCreateError(e.message) } + finally { setCreating(false) } + } + + const handleDelete = async (id: string) => { + await fetch(`/api/tiger/projects/${id}`, { method: "DELETE" }) + mutateDb() + } + + return ( +
{/* Header */}
@@ -192,182 +364,90 @@ export default function ProjectsPage() { Projects

- Manage projects and track tasks across your team + Tiger's projects from PROJECTS.md. + Click a project to see its tasks and agents.

- - + - Create Project - Create a new project to organize tasks. + Queue a Project for Tiger + + Tiger will pick this up and add it to PROJECTS.md. + -
+
- setNewProjectName(e.target.value)} - placeholder="Project name" - className="mt-1" - /> + setForm((f) => ({ ...f, name: e.target.value }))} placeholder="e.g. BESS Economics Model" className="mt-1" />
- - setNewProjectDesc(e.target.value)} - placeholder="Project description" - className="mt-1" - /> + +