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
This commit is contained in:
parent
968d6fd178
commit
4ee0517345
27 changed files with 2269 additions and 1263 deletions
136
dashboard/src/app/agents/page.tsx
Normal file
136
dashboard/src/app/agents/page.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="space-y-6 max-w-5xl mx-auto w-full">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Bot className="h-6 w-6 text-primary" />
|
||||
Agents
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Tiger's orchestrator and 4 specialist sub-agents. Phase 3 will let you
|
||||
override the model per agent here.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Card key={i} className="h-32 animate-pulse bg-card/30" />
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{agents.map((agent) => {
|
||||
const status = statusOf(agent.lastActivity)
|
||||
return (
|
||||
<Card key={agent.id} className="bg-card/40 p-4 hover:bg-card/60 transition-colors">
|
||||
<div className="flex items-start gap-3 mb-3">
|
||||
<span className="text-3xl leading-none">{agent.emoji}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="font-semibold truncate">{agent.name}</h3>
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full",
|
||||
STATUS_COLOR[status],
|
||||
status === "active" && "animate-pulse"
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
<p className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
{agent.role}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5 text-xs">
|
||||
<div className="flex justify-between text-muted-foreground">
|
||||
<span>Last activity</span>
|
||||
<span className="text-foreground/80">{relativeTime(agent.lastActivity)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-muted-foreground">
|
||||
<span>Workspace files</span>
|
||||
<span className="text-foreground/80 tabular-nums">{agent.fileCount}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-muted-foreground">
|
||||
<span>Model</span>
|
||||
<span className="text-foreground/60 italic text-[11px]">
|
||||
inherits global
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="text-xs text-muted-foreground border-t border-border/30 pt-4">
|
||||
<strong className="text-foreground/80">Coming in Phase 3:</strong> click any
|
||||
agent to set a custom model (e.g., a cheaper model for Researcher,
|
||||
a stronger model for Coder).
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
20
dashboard/src/app/api/tiger/agents-activity/route.ts
Normal file
20
dashboard/src/app/api/tiger/agents-activity/route.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
21
dashboard/src/app/api/tiger/bridge-restart/route.ts
Normal file
21
dashboard/src/app/api/tiger/bridge-restart/route.ts
Normal file
|
|
@ -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.",
|
||||
});
|
||||
}
|
||||
|
|
@ -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<string, unknown>
|
||||
);
|
||||
return NextResponse.json(result);
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ ok: false, error: err.message }, { status: 502 });
|
||||
}
|
||||
}
|
||||
14
dashboard/src/app/api/tiger/config/models/agents/route.ts
Normal file
14
dashboard/src/app/api/tiger/config/models/agents/route.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
15
dashboard/src/app/api/tiger/cron/[id]/run/route.ts
Normal file
15
dashboard/src/app/api/tiger/cron/[id]/run/route.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
11
dashboard/src/app/api/tiger/cron/route.ts
Normal file
11
dashboard/src/app/api/tiger/cron/route.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
21
dashboard/src/app/api/tiger/file-projects/route.ts
Normal file
21
dashboard/src/app/api/tiger/file-projects/route.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
11
dashboard/src/app/api/tiger/file-tasks/active/route.ts
Normal file
11
dashboard/src/app/api/tiger/file-tasks/active/route.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
11
dashboard/src/app/api/tiger/file-tasks/projects/route.ts
Normal file
11
dashboard/src/app/api/tiger/file-tasks/projects/route.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
29
dashboard/src/app/api/tiger/file-tasks/route.ts
Normal file
29
dashboard/src/app/api/tiger/file-tasks/route.ts
Normal file
|
|
@ -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 });
|
||||
}
|
||||
}
|
||||
26
dashboard/src/app/api/tiger/keys/route.ts
Normal file
26
dashboard/src/app/api/tiger/keys/route.ts
Normal file
|
|
@ -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<string, unknown>);
|
||||
return NextResponse.json(result);
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ ok: false, error: err.message }, { status: 502 });
|
||||
}
|
||||
}
|
||||
90
dashboard/src/app/knowledge/page.tsx
Normal file
90
dashboard/src/app/knowledge/page.tsx
Normal file
|
|
@ -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 (
|
||||
<div className="space-y-6 max-w-5xl mx-auto w-full">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<Brain className="h-6 w-6 text-primary" />
|
||||
Knowledge
|
||||
</h1>
|
||||
<p className="text-muted-foreground text-sm">
|
||||
Tiger's brain — what it remembers, what it can do, what it's done,
|
||||
and what it'll do next.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
{sections.map(({ title, href, icon: Icon, description }) => (
|
||||
<a key={title} href={href} className="contents">
|
||||
<Card className="bg-card/40 p-5 hover:bg-card/60 hover:border-primary/30 transition-colors cursor-pointer">
|
||||
<div className="flex items-start gap-3 mb-2">
|
||||
<div className="shrink-0 h-9 w-9 rounded-lg bg-primary/10 border border-primary/20 flex items-center justify-center">
|
||||
<Icon className="h-4 w-4 text-primary" />
|
||||
</div>
|
||||
<h2 className="font-semibold text-lg leading-snug">{title}</h2>
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground leading-relaxed">
|
||||
{description}
|
||||
</p>
|
||||
<div className="mt-3 text-xs text-primary">
|
||||
Open {title.toLowerCase()} →
|
||||
</div>
|
||||
</Card>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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<TigerStatus>('/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 (
|
||||
<div className="flex flex-col gap-6">
|
||||
<div className="flex flex-col gap-5 max-w-5xl mx-auto w-full">
|
||||
{/* HERO — the command bar is the front door of Tiger. */}
|
||||
<CommandBar />
|
||||
|
||||
{/* Crash Recovery Banner */}
|
||||
{isCrashed && (
|
||||
<div className="p-4 rounded-md bg-red-500/10 border border-red-500/20 text-red-400 flex items-center justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<AlertCircle className="h-5 w-5 shrink-0" />
|
||||
<div>
|
||||
<span className="font-semibold">Tiger Crashed</span>
|
||||
<span className="text-sm text-red-400/80 ml-2">{`(exit code 255 — ${status?.agent?.currentModel ?? "API"} unreachable)`}</span>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="border-red-500/30 text-red-400 hover:bg-red-500/10"
|
||||
onClick={handleRestart}
|
||||
disabled={restarting}
|
||||
>
|
||||
{restarting ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{restartSuccess ? "Restarting..." : "Restart Container"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{/* AGENTS — one strip, all 5 agents, live state at a glance. */}
|
||||
<AgentStrip />
|
||||
|
||||
{/* Stat Cards Row */}
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<Card className={cn(
|
||||
"bg-card/50",
|
||||
status?.container?.status === "running" && "border-green-500/30",
|
||||
status?.container?.status !== "running" && "border-red-500/30"
|
||||
)}>
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Container</p>
|
||||
<p className={cn(
|
||||
"text-2xl font-bold capitalize",
|
||||
status?.container?.status === "running" ? "text-green-400" : "text-red-400"
|
||||
)}>
|
||||
{isLoading ? "..." : status?.container?.status || "Unknown"}
|
||||
</p>
|
||||
</div>
|
||||
<Server className={cn(
|
||||
"h-5 w-5",
|
||||
status?.container?.status === "running" ? "text-green-400" : "text-red-400"
|
||||
)} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card/50">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">OpenClaw</p>
|
||||
<p className={cn(
|
||||
"text-2xl font-bold",
|
||||
status?.openclaw?.running ? "text-green-400" : "text-red-400"
|
||||
)}>
|
||||
{isLoading ? "..." : status?.openclaw?.running ? "Running" : "Stopped"}
|
||||
</p>
|
||||
</div>
|
||||
<Terminal className={cn(
|
||||
"h-5 w-5",
|
||||
status?.openclaw?.running ? "text-green-400" : "text-red-400"
|
||||
)} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card/50">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Memory</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{isLoading ? "..." : `${status?.system?.memoryUsagePct || 0}%`}
|
||||
</p>
|
||||
</div>
|
||||
<MemoryStick className="h-5 w-5 text-blue-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card/50">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Uptime</p>
|
||||
<p className="text-2xl font-bold">
|
||||
{isLoading ? "..." : formatUptime(status?.container?.startedAt || "")}
|
||||
</p>
|
||||
</div>
|
||||
<Clock className="h-5 w-5 text-amber-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
{/* CONTEXT ROW — digest (left) + Telegram thread (right) */}
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<DigestCard />
|
||||
<TelegramThreadCard />
|
||||
</div>
|
||||
|
||||
{/* Error State */}
|
||||
{isOffline && !isLoading && (
|
||||
<div className="p-4 rounded-md bg-destructive/10 text-destructive flex items-center gap-2">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
<span>Failed to connect to Tiger Bridge. Ensure the bridge server is running on the VPS.</span>
|
||||
</div>
|
||||
)}
|
||||
{/* SCHEDULE ROW — Tiger's cron jobs + next-run times */}
|
||||
<ScheduleCard />
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7">
|
||||
|
||||
{/* Container Health Card */}
|
||||
<Card className="col-span-4 bg-card/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Activity className="h-5 w-5 text-primary" />
|
||||
Tiger Health
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
{status?.status === "online" ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Zap className="h-3 w-3 text-green-500" /> All systems operational
|
||||
</span>
|
||||
) : status?.status === "degraded" ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<Zap className="h-3 w-3 text-yellow-500" /> Degraded mode
|
||||
</span>
|
||||
) : (
|
||||
"Connection lost"
|
||||
)}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{/* Container Status */}
|
||||
<div className="p-3 rounded-md border border-border bg-background/50 text-sm flex justify-between items-center">
|
||||
<span className="text-muted-foreground">Container Status</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn(
|
||||
"w-2 h-2 rounded-full",
|
||||
status?.container?.status === "running" ? "bg-green-500" : "bg-red-500"
|
||||
)} />
|
||||
<span className="capitalize">{status?.container?.status || "—"}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Exit Code */}
|
||||
{status?.container?.exitCode !== undefined && status?.container?.exitCode !== 0 && (
|
||||
<div className="p-3 rounded-md border border-red-500/30 bg-red-500/5 text-sm flex justify-between items-center">
|
||||
<span className="text-muted-foreground">Exit Code</span>
|
||||
<span className={cn(
|
||||
"font-mono",
|
||||
status?.container?.exitCode === 255 ? "text-red-400 font-bold" : "text-red-300"
|
||||
)}>
|
||||
{status?.container?.exitCode}
|
||||
{status?.container?.exitCode === 255 && " (API unreachable)"}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* OpenClaw Process */}
|
||||
<div className="p-3 rounded-md border border-border bg-background/50 text-sm flex justify-between items-center">
|
||||
<span className="text-muted-foreground">OpenClaw Process</span>
|
||||
<span className={cn(
|
||||
status?.openclaw?.running ? "text-green-400" : "text-red-400"
|
||||
)}>
|
||||
{status?.openclaw?.running ? "Running" : "Not running"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Memory Usage */}
|
||||
<div className="p-3 rounded-md border border-border bg-background/50 text-sm flex justify-between items-center">
|
||||
<span className="text-muted-foreground">Memory Usage</span>
|
||||
<span>
|
||||
{status?.system?.memoryUsagePct || 0}% of {status?.system?.memoryTotalMb || 0}MB
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Uptime */}
|
||||
<div className="p-3 rounded-md border border-border bg-background/50 text-sm flex justify-between items-center">
|
||||
<span className="text-muted-foreground">Container Uptime</span>
|
||||
<span>{formatUptime(status?.container?.startedAt || "")}</span>
|
||||
</div>
|
||||
|
||||
{/* Last Activity — most recent agent file write */}
|
||||
<div className="p-3 rounded-md border border-border bg-background/50 text-sm flex justify-between items-center">
|
||||
<span className="text-muted-foreground">Last Activity</span>
|
||||
<span className="text-xs tabular-nums">
|
||||
{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`
|
||||
})()
|
||||
: "—"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Restart Button */}
|
||||
<div className="pt-2 flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRestart}
|
||||
disabled={restarting}
|
||||
className="border-amber-500/30 text-amber-400 hover:bg-amber-500/10"
|
||||
>
|
||||
{restarting ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
Restart Container
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Agent Model Card */}
|
||||
<Card className="col-span-3 bg-card/40">
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2">
|
||||
<Bot className="h-5 w-5 text-primary" />
|
||||
Agent Model
|
||||
</CardTitle>
|
||||
<CardDescription>Current AI model configuration</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{/* Primary Model */}
|
||||
<div className="p-4 rounded-lg border border-primary/30 bg-primary/5">
|
||||
<div className="text-xs font-medium text-muted-foreground mb-1 uppercase tracking-wider">Primary Model</div>
|
||||
<div className="font-semibold text-base">
|
||||
{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
|
||||
})()}
|
||||
</div>
|
||||
{!isLoading && status?.agent?.currentModel && (
|
||||
<div className="text-xs text-muted-foreground mt-1 font-mono truncate">
|
||||
{status.agent.currentModel}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Fallback Models */}
|
||||
{status?.agent?.fallbackModels && status.agent.fallbackModels.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wider">Fallback Models</div>
|
||||
<div className="space-y-1">
|
||||
{status.agent.fallbackModels.map((model, i) => (
|
||||
<div key={i} className="p-2 rounded-md border border-border bg-background/50 text-sm font-mono">
|
||||
{model}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Available Models (from providers config) */}
|
||||
{status?.agent?.availableModels && status.agent.availableModels.length > 0 && (
|
||||
<div>
|
||||
<div className="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wider">Available Models</div>
|
||||
<div className="space-y-1">
|
||||
{status.agent.availableModels.map((model, i) => (
|
||||
<div key={i} className="p-2 rounded-md border border-border/50 bg-background/30 text-xs font-mono text-muted-foreground">
|
||||
{model}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Heartbeat */}
|
||||
{status?.agent?.heartbeat && (
|
||||
<div className="p-3 rounded-md border border-border bg-background/50 text-xs">
|
||||
<div className="text-muted-foreground mb-1">Last Heartbeat</div>
|
||||
<pre className="whitespace-pre-wrap text-muted-foreground/80 font-mono text-[10px] max-h-20 overflow-y-auto">
|
||||
{status.agent.heartbeat.slice(0, 500)}
|
||||
</pre>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Quick Links */}
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<a href="/logs" className="contents">
|
||||
<StatCard
|
||||
title="View Logs"
|
||||
value="→"
|
||||
description="Live container log stream"
|
||||
icon={Activity}
|
||||
className="bg-card/50 hover:bg-card/80 transition-colors cursor-pointer"
|
||||
/>
|
||||
</a>
|
||||
<a href="/tasks" className="contents">
|
||||
<StatCard
|
||||
title="Task Board"
|
||||
value="→"
|
||||
description="Manage and track tasks"
|
||||
icon={Check}
|
||||
className="bg-card/50 hover:bg-card/80 transition-colors cursor-pointer"
|
||||
/>
|
||||
</a>
|
||||
<a href="/settings" className="contents">
|
||||
<StatCard
|
||||
title="Settings"
|
||||
value="→"
|
||||
description="Configure Tiger agent"
|
||||
icon={Cpu}
|
||||
className="bg-card/50 hover:bg-card/80 transition-colors cursor-pointer"
|
||||
/>
|
||||
</a>
|
||||
</div>
|
||||
{/* FOOTER — system health strip. Becomes a banner on crash. */}
|
||||
<StatusFooter />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,56 +1,67 @@
|
|||
/**
|
||||
* 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<string, string> = {
|
||||
low: "bg-gray-500/10 text-gray-400 border-gray-500/20",
|
||||
|
|
@ -59,131 +70,292 @@ const PRIORITY_COLORS: Record<string, string> = {
|
|||
urgent: "bg-red-500/10 text-red-400 border-red-500/20",
|
||||
}
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
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<string, string> = {
|
||||
"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<Project[]>([])
|
||||
const [selectedProject, setSelectedProject] = React.useState<Project | null>(null)
|
||||
const [tasks, setTasks] = React.useState<Task[]>([])
|
||||
const [loading, setLoading] = React.useState(false)
|
||||
const [loadingTasks, setLoadingTasks] = React.useState(false)
|
||||
const [error, setError] = React.useState<string | null>(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)
|
||||
|
||||
// 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])
|
||||
|
||||
// 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])
|
||||
|
||||
React.useEffect(() => {
|
||||
loadProjects()
|
||||
}, [loadProjects])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (selectedProject) {
|
||||
loadTasks(selectedProject.id)
|
||||
}
|
||||
}, [selectedProject, loadTasks])
|
||||
|
||||
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)
|
||||
}
|
||||
const AGENT_EMOJI: Record<string, string> = {
|
||||
tiger: "🐯", main: "🐯",
|
||||
cody: "💻", coder: "💻",
|
||||
ethan: "🔍", researcher: "🔍",
|
||||
cathy: "✍️", writer: "✍️",
|
||||
elon: "📊", pm: "📊",
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
const AGENT_COLORS: Record<string, string> = {
|
||||
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",
|
||||
}
|
||||
|
||||
// Group tasks by status
|
||||
const tasksByStatus = React.useMemo(() => {
|
||||
const grouped: Record<string, Task[]> = {
|
||||
backlog: [],
|
||||
ready: [],
|
||||
"in-progress": [],
|
||||
review: [],
|
||||
done: [],
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((r) => r.json())
|
||||
|
||||
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"
|
||||
}
|
||||
tasks.forEach(task => {
|
||||
if (grouped[task.status]) {
|
||||
grouped[task.status].push(task)
|
||||
|
||||
function cleanText(s: string) {
|
||||
// Strip emoji and markdown bold markers for display
|
||||
return s.replace(/\*\*/g, "").replace(/[✅⏳🔄❌🛢️🔍💻📊✍️🐯]/g, "").trim()
|
||||
}
|
||||
})
|
||||
return grouped
|
||||
}, [tasks])
|
||||
|
||||
// ─── Task row inside expanded project ─────────────────────────────────────────
|
||||
|
||||
function TaskRow({ task }: { task: FileTask }) {
|
||||
const agentKey = task.assigned_agent?.toLowerCase() ?? ""
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-muted/20 transition-colors">
|
||||
{/* Status badge */}
|
||||
<span className={cn(
|
||||
"text-[10px] font-medium px-1.5 py-0.5 rounded-full shrink-0 whitespace-nowrap",
|
||||
TASK_STATUS_COLORS[task.status] ?? "bg-zinc-500/10 text-zinc-400"
|
||||
)}>
|
||||
{task.status_raw ? cleanText(task.status_raw).slice(0, 20) : task.status}
|
||||
</span>
|
||||
|
||||
{/* Stage/task name */}
|
||||
<span className="text-sm flex-1 truncate text-foreground/80">
|
||||
{cleanText(task.title)}
|
||||
</span>
|
||||
|
||||
{/* Agent */}
|
||||
{agentKey && agentKey !== "unassigned" && (
|
||||
<span className={cn(
|
||||
"text-xs font-medium shrink-0 flex items-center gap-1",
|
||||
AGENT_COLORS[agentKey] ?? "text-muted-foreground"
|
||||
)}>
|
||||
<span>{AGENT_EMOJI[agentKey] ?? "🤖"}</span>
|
||||
<span className="hidden sm:inline capitalize">{agentKey}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 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 (
|
||||
<div className="flex flex-col gap-6 p-6">
|
||||
<Card className="bg-card/40 transition-colors hover:bg-card/60">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start gap-2">
|
||||
{/* Expand toggle + title */}
|
||||
<button
|
||||
className="flex items-center gap-2 text-left flex-1 min-w-0 group"
|
||||
onClick={() => setExpanded((v) => !v)}
|
||||
>
|
||||
{expanded
|
||||
? <ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground mt-0.5" />
|
||||
: <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground mt-0.5" />
|
||||
}
|
||||
<CardTitle className="text-base group-hover:text-primary transition-colors">
|
||||
{cleanText(project.name)}
|
||||
</CardTitle>
|
||||
</button>
|
||||
|
||||
{/* Status badge */}
|
||||
<Badge className={cn("text-xs shrink-0", statusBadgeClass(project.status))}>
|
||||
{cleanText(project.status).replace(/in progress/i, "Active")}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{project.description && (
|
||||
<CardDescription className="line-clamp-2 ml-6 text-xs">
|
||||
{project.description}
|
||||
</CardDescription>
|
||||
)}
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className="pt-0">
|
||||
{/* Meta row */}
|
||||
<div className="flex items-center gap-3 ml-6 text-xs text-muted-foreground">
|
||||
<span>Created {project.created}</span>
|
||||
<span>·</span>
|
||||
<span>{project.tasks_count}</span>
|
||||
{/* Show primary agent when collapsed */}
|
||||
{!expanded && projectTask?.assigned_agent && (
|
||||
<>
|
||||
<span>·</span>
|
||||
<span className={cn(
|
||||
"flex items-center gap-1",
|
||||
AGENT_COLORS[projectTask.assigned_agent] ?? "text-muted-foreground"
|
||||
)}>
|
||||
{AGENT_EMOJI[projectTask.assigned_agent] ?? ""} {projectTask.assigned_agent}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Expanded task list */}
|
||||
{expanded && (
|
||||
<div className="mt-4 ml-2 border-t border-border/30 pt-3">
|
||||
{tasksLoading ? (
|
||||
<div className="flex justify-center py-4">
|
||||
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : allTasks.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground px-3 italic">
|
||||
No tasks found in TASKS.md for this project.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-0.5">
|
||||
{/* Project-level agent row */}
|
||||
{projectTask && (
|
||||
<div className="flex items-center gap-2 px-3 pb-2 mb-1 border-b border-border/20">
|
||||
<span className="text-xs text-muted-foreground">Lead:</span>
|
||||
<span className={cn(
|
||||
"text-xs font-medium flex items-center gap-1",
|
||||
AGENT_COLORS[projectTask.assigned_agent] ?? "text-muted-foreground"
|
||||
)}>
|
||||
{AGENT_EMOJI[projectTask.assigned_agent] ?? ""}
|
||||
<span className="capitalize">{projectTask.assigned_agent}</span>
|
||||
</span>
|
||||
<span className={cn(
|
||||
"ml-auto text-[10px] px-1.5 py-0.5 rounded-full",
|
||||
TASK_STATUS_COLORS[projectTask.status] ?? "bg-zinc-500/10 text-zinc-400"
|
||||
)}>
|
||||
{cleanText(projectTask.status_raw || projectTask.status)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sub-task rows (review pipeline stages etc) */}
|
||||
{subTasks.length > 0 ? (
|
||||
subTasks.map((t) => <TaskRow key={t.id} task={t} />)
|
||||
) : (
|
||||
<p className="text-xs text-muted-foreground px-3 italic">
|
||||
No task breakdown available — Tiger tracks this at project level.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Dashboard-queued project card ───────────────────────────────────────────
|
||||
|
||||
function DbProjectCard({ project, onDelete }: { project: DbProject; onDelete: (id: string) => void }) {
|
||||
return (
|
||||
<Card className="bg-card/40 border-dashed border-border/60">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<CardTitle className="text-base truncate text-foreground/80">{project.name}</CardTitle>
|
||||
{project.description && (
|
||||
<CardDescription className="line-clamp-2 text-xs mt-1">{project.description}</CardDescription>
|
||||
)}
|
||||
</div>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7 shrink-0">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={() => onDelete(project.id)} className="text-destructive">
|
||||
<Trash2 className="h-4 w-4 mr-2" /> Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="pt-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className={cn("text-xs", PRIORITY_COLORS[project.priority])}>
|
||||
{project.priority}
|
||||
</Badge>
|
||||
<span className="text-xs text-muted-foreground">Queued — waiting for Tiger</span>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── 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<string, string> = { 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 (
|
||||
<div className="flex flex-col gap-6 p-6 max-w-4xl">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
|
|
@ -192,178 +364,86 @@ export default function ProjectsPage() {
|
|||
Projects
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Manage projects and track tasks across your team
|
||||
Tiger's projects from <span className="font-mono text-xs">PROJECTS.md</span>.
|
||||
Click a project to see its tasks and agents.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
New Project
|
||||
</Button>
|
||||
<Button><Plus className="h-4 w-4 mr-2" />New Project</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Create Project</DialogTitle>
|
||||
<DialogDescription>Create a new project to organize tasks.</DialogDescription>
|
||||
<DialogTitle>Queue a Project for Tiger</DialogTitle>
|
||||
<DialogDescription>
|
||||
Tiger will pick this up and add it to PROJECTS.md.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4 pt-4">
|
||||
<div className="space-y-4 pt-2">
|
||||
<div>
|
||||
<label className="text-sm font-medium">Name</label>
|
||||
<Input
|
||||
value={newProjectName}
|
||||
onChange={(e) => setNewProjectName(e.target.value)}
|
||||
placeholder="Project name"
|
||||
className="mt-1"
|
||||
/>
|
||||
<Input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} placeholder="e.g. BESS Economics Model" className="mt-1" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Description</label>
|
||||
<Input
|
||||
value={newProjectDesc}
|
||||
onChange={(e) => setNewProjectDesc(e.target.value)}
|
||||
placeholder="Project description"
|
||||
className="mt-1"
|
||||
/>
|
||||
<label className="text-sm font-medium">Seed text <span className="text-muted-foreground font-normal">(Tiger generates title + goal)</span></label>
|
||||
<textarea value={form.seed} onChange={(e) => setForm((f) => ({ ...f, seed: e.target.value }))} placeholder="Describe what you want Tiger to work on…" rows={3} className="mt-1 w-full rounded-md border border-input bg-background px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-ring resize-none" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium">Priority</label>
|
||||
<select
|
||||
value={newProjectPriority}
|
||||
onChange={(e) => setNewProjectPriority(e.target.value)}
|
||||
className="w-full mt-1 px-3 py-2 rounded-md border bg-background"
|
||||
>
|
||||
<option value="low">Low</option>
|
||||
<option value="medium">Medium</option>
|
||||
<option value="high">High</option>
|
||||
<option value="urgent">Urgent</option>
|
||||
<select value={form.priority} onChange={(e) => setForm((f) => ({ ...f, priority: e.target.value }))} className="w-full mt-1 px-3 py-2 rounded-md border bg-background text-sm">
|
||||
{["low", "medium", "high", "urgent"].map((p) => <option key={p} value={p}>{p.charAt(0).toUpperCase() + p.slice(1)}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<Button onClick={handleCreateProject} disabled={creating || !newProjectName.trim()}>
|
||||
{createError && <p className="text-xs text-destructive">{createError}</p>}
|
||||
<Button onClick={handleCreate} disabled={creating || (!form.name.trim() && !form.seed.trim())} className="w-full">
|
||||
{creating && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
Create Project
|
||||
Queue Project
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="p-3 rounded-md bg-destructive/10 text-destructive text-sm">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Projects Grid */}
|
||||
{loading ? (
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : projects.length === 0 ? (
|
||||
<div className="text-center py-20 text-muted-foreground">
|
||||
<FolderOpen className="h-12 w-12 mx-auto mb-4 opacity-20" />
|
||||
<p>No projects yet. Create your first project to get started.</p>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{/* Tiger's PROJECTS.md — primary */}
|
||||
<div>
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<FolderOpen className="h-4 w-4 text-primary" />
|
||||
<span className="text-sm font-semibold">Tiger's Projects</span>
|
||||
<span className="text-xs font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded">PROJECTS.md</span>
|
||||
<span className="text-xs text-muted-foreground bg-muted px-1.5 rounded-full ml-1">{fileProjects.length}</span>
|
||||
</div>
|
||||
{fileProjects.length === 0 ? (
|
||||
<div className="text-center py-10 text-muted-foreground">
|
||||
<FolderOpen className="h-10 w-10 mx-auto mb-3 opacity-20" />
|
||||
<p className="text-sm">No projects in PROJECTS.md yet.</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3">
|
||||
{projects.map((project) => (
|
||||
<Card
|
||||
key={project.id}
|
||||
className={cn(
|
||||
"cursor-pointer hover:bg-muted/50 transition-colors",
|
||||
selectedProject?.id === project.id && "ring-2 ring-primary"
|
||||
)}
|
||||
onClick={() => setSelectedProject(project)}
|
||||
>
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-start justify-between">
|
||||
<CardTitle className="text-lg">{project.name}</CardTitle>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
// TODO: Edit project
|
||||
}}>
|
||||
<Pencil className="h-4 w-4 mr-2" />
|
||||
Edit
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
handleDeleteProject(project.id)
|
||||
}} className="text-destructive">
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
<CardDescription className="line-clamp-2">
|
||||
{project.description || "No description"}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-2">
|
||||
<Badge className={cn("text-xs", PRIORITY_COLORS[project.priority])}>
|
||||
{project.priority}
|
||||
</Badge>
|
||||
<Badge className={cn("text-xs", STATUS_COLORS[project.status])}>
|
||||
{project.status}
|
||||
</Badge>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
<div className="flex flex-col gap-3">
|
||||
{fileProjects.map((p) => <FileProjectCard key={p.id} project={p} />)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Project Detail with Tasks */}
|
||||
{selectedProject && (
|
||||
<div className="mt-8">
|
||||
<div className="flex items-center gap-4 mb-4">
|
||||
<Button variant="ghost" onClick={() => setSelectedProject(null)}>
|
||||
← Back
|
||||
</Button>
|
||||
<h2 className="text-xl font-bold">{selectedProject.name}</h2>
|
||||
<Badge className={cn(PRIORITY_COLORS[selectedProject.priority])}>
|
||||
{selectedProject.priority}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Simple Kanban - Tasks by Status */}
|
||||
{loadingTasks ? (
|
||||
<div className="flex items-center justify-center py-10">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
{/* Dashboard queue — secondary */}
|
||||
{dbProjects.length > 0 && (
|
||||
<div className="border-t border-border/40 pt-6">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Inbox className="h-4 w-4 text-blue-400" />
|
||||
<span className="text-sm font-semibold text-blue-400">Dashboard Queue</span>
|
||||
<span className="text-xs text-muted-foreground bg-muted px-1.5 rounded-full ml-1">{dbProjects.length}</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-4 md:grid-cols-5">
|
||||
{(["backlog", "ready", "in-progress", "review", "done"] as const).map((status) => (
|
||||
<div key={status} className="space-y-2">
|
||||
<div className="text-sm font-medium text-muted-foreground uppercase tracking-wider px-2">
|
||||
{status.replace("-", " ")} ({tasksByStatus[status].length})
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
Projects you've queued — Tiger will add them to PROJECTS.md when he processes them.
|
||||
</p>
|
||||
<div className="flex flex-col gap-3">
|
||||
{dbProjects.map((p) => <DbProjectCard key={p.id} project={p} onDelete={handleDelete} />)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{tasksByStatus[status].map((task) => (
|
||||
<Card key={task.id} className="p-3 cursor-pointer hover:bg-muted/50">
|
||||
<div className="font-medium text-sm">{task.title}</div>
|
||||
{task.description && (
|
||||
<div className="text-xs text-muted-foreground mt-1 line-clamp-2">
|
||||
{task.description}
|
||||
</div>
|
||||
)}
|
||||
{task.assigned_agent && (
|
||||
<Badge variant="outline" className="mt-2 text-xs">
|
||||
{task.assigned_agent}
|
||||
</Badge>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,12 +1,14 @@
|
|||
"use client"
|
||||
|
||||
/**
|
||||
* settings/page.tsx — Tiger configuration
|
||||
* settings/page.tsx — Tiger configuration + API key management
|
||||
*
|
||||
* Sections:
|
||||
* 1. Model — primary model dropdown + fallback models
|
||||
* 2. Session — dmScope, compaction mode
|
||||
* Sections (in order):
|
||||
* 0. API Keys & Router — ANTHROPIC, OPENROUTER, TELEGRAM keys + TIGER_ROUTER_MODEL
|
||||
* 1. Model — OpenClaw global model dropdown + fallbacks + compaction
|
||||
* 2. Session — dmScope
|
||||
* 3. Telegram — enabled toggle, streaming mode
|
||||
* 4. Commands — native commands, ownerDisplay
|
||||
* 4. Commands — native commands, ownerDisplay, restart
|
||||
*/
|
||||
|
||||
import * as React from "react"
|
||||
|
|
@ -14,6 +16,7 @@ import useSWR from "swr"
|
|||
import {
|
||||
Settings2, Save, Loader2, RefreshCw,
|
||||
Bot, MessageSquare, Terminal, Cpu, AlertCircle, Check,
|
||||
Key, RotateCcw,
|
||||
} from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
|
|
@ -31,12 +34,22 @@ interface ModelInfo {
|
|||
}
|
||||
|
||||
interface OpenClawConfig {
|
||||
agents?: { defaults?: { model?: { primary?: string; fallbacks?: string[] }; compaction?: { mode?: string } } }
|
||||
agents?: {
|
||||
defaults?: {
|
||||
model?: { primary?: string; fallbacks?: string[] }
|
||||
compaction?: { mode?: string }
|
||||
}
|
||||
}
|
||||
session?: { dmScope?: string }
|
||||
channels?: { telegram?: { enabled?: boolean; streaming?: string } }
|
||||
commands?: { native?: string; ownerDisplay?: string; restart?: boolean }
|
||||
}
|
||||
|
||||
interface KeyPresence {
|
||||
isSet: boolean
|
||||
preview?: string
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then(r => r.json())
|
||||
|
|
@ -57,7 +70,7 @@ function set(obj: any, path: string, value: any): any {
|
|||
return result
|
||||
}
|
||||
|
||||
// ─── Sub-components ───────────────────────────────────────────────────────────
|
||||
// ─── Shared sub-components ────────────────────────────────────────────────────
|
||||
|
||||
function SettingRow({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
|
|
@ -107,26 +120,17 @@ function SelectInput({ value, options, onChange }: {
|
|||
)
|
||||
}
|
||||
|
||||
// ─── Model dropdown component ─────────────────────────────────────────────────
|
||||
|
||||
function ModelSelect({ value, models, onChange }: {
|
||||
value: string
|
||||
models: ModelInfo[]
|
||||
onChange: (v: string) => void
|
||||
}) {
|
||||
// Group by provider
|
||||
const grouped = models.reduce<Record<string, ModelInfo[]>>((acc, m) => {
|
||||
if (!acc[m.provider]) acc[m.provider] = []
|
||||
acc[m.provider].push(m)
|
||||
return acc
|
||||
}, {})
|
||||
|
||||
const providerLabels: Record<string, string> = {
|
||||
minimax: "MiniMax",
|
||||
"minimax-portal": "MiniMax Portal",
|
||||
openrouter: "OpenRouter",
|
||||
}
|
||||
|
||||
const current = models.find(m => m.id === value)
|
||||
|
||||
return (
|
||||
|
|
@ -137,15 +141,13 @@ function ModelSelect({ value, models, onChange }: {
|
|||
className="h-9 rounded-md border border-input bg-background px-3 py-1 text-sm font-mono focus:outline-none focus:ring-1 focus:ring-ring w-full max-w-sm"
|
||||
>
|
||||
{Object.entries(grouped).map(([prov, mods]) => (
|
||||
<optgroup key={prov} label={providerLabels[prov] ?? prov}>
|
||||
<optgroup key={prov} label={prov}>
|
||||
{mods.map(m => (
|
||||
<option key={m.id} value={m.id}>{m.name}</option>
|
||||
))}
|
||||
</optgroup>
|
||||
))}
|
||||
</select>
|
||||
|
||||
{/* Model detail badge row */}
|
||||
{current && (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-xs font-mono text-muted-foreground bg-muted px-2 py-0.5 rounded">
|
||||
|
|
@ -158,14 +160,9 @@ function ModelSelect({ value, models, onChange }: {
|
|||
)}
|
||||
{current.contextWindow > 0 && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{current.contextWindow >= 1000000
|
||||
? `${(current.contextWindow / 1000000).toFixed(1)}M ctx`
|
||||
: `${Math.round(current.contextWindow / 1000)}K ctx`}
|
||||
</span>
|
||||
)}
|
||||
{current.cost && (
|
||||
<span className="text-xs text-muted-foreground">
|
||||
${current.cost.input}/M in · ${current.cost.output}/M out
|
||||
{current.contextWindow >= 1_000_000
|
||||
? `${(current.contextWindow / 1_000_000).toFixed(1)}M ctx`
|
||||
: `${Math.round(current.contextWindow / 1_000)}K ctx`}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
|
@ -174,8 +171,6 @@ function ModelSelect({ value, models, onChange }: {
|
|||
)
|
||||
}
|
||||
|
||||
// ─── Section card wrapper ─────────────────────────────────────────────────────
|
||||
|
||||
function SectionCard({ icon: Icon, title, description, children, dirty }: {
|
||||
icon: React.ElementType
|
||||
title: string
|
||||
|
|
@ -200,21 +195,214 @@ function SectionCard({ icon: Icon, title, description, children, dirty }: {
|
|||
)
|
||||
}
|
||||
|
||||
// ─── Section 0: API Keys ──────────────────────────────────────────────────────
|
||||
|
||||
const SECRET_KEYS = [
|
||||
{ key: "ANTHROPIC_API_KEY", label: "Anthropic API key", hint: "Required for claude-* models via TIGER_ROUTER_MODEL" },
|
||||
{ key: "OPENROUTER_API_KEY", label: "OpenRouter API key", hint: "Required for openrouter/* and other non-Anthropic models" },
|
||||
{ key: "TELEGRAM_BOT_TOKEN", label: "Telegram bot token", hint: "Bot token from @BotFather — enables Telegram channel" },
|
||||
{ key: "TELEGRAM_CHAT_ID", label: "Telegram chat ID", hint: "Your personal chat ID — restricts who can DM Tiger" },
|
||||
] as const
|
||||
|
||||
type SecretKey = (typeof SECRET_KEYS)[number]["key"]
|
||||
|
||||
function ApiKeysSection() {
|
||||
const { data: keysData, mutate: mutateKeys } = useSWR<{
|
||||
ok: boolean
|
||||
keys: Record<string, KeyPresence>
|
||||
}>("/api/tiger/keys", fetcher, { revalidateOnFocus: false })
|
||||
|
||||
const keysPresence = keysData?.keys ?? {}
|
||||
|
||||
// Local draft for pending key values (user types new value)
|
||||
const [draft, setDraft] = React.useState<Partial<Record<SecretKey | "TIGER_ROUTER_MODEL", string>>>({})
|
||||
const [saving, setSaving] = React.useState(false)
|
||||
const [saveState, setSaveState] = React.useState<"idle" | "ok" | "err">("idle")
|
||||
const [saveMsg, setSaveMsg] = React.useState("")
|
||||
const [restarting, setRestarting] = React.useState(false)
|
||||
const [restartMsg, setRestartMsg] = React.useState("")
|
||||
|
||||
// Router model uses preview value (non-secret)
|
||||
const routerModelPreview = keysPresence["TIGER_ROUTER_MODEL"]?.preview ?? ""
|
||||
const [routerModelDraft, setRouterModelDraft] = React.useState("")
|
||||
|
||||
// Sync router model draft from server on first load
|
||||
React.useEffect(() => {
|
||||
if (routerModelPreview && !routerModelDraft) {
|
||||
setRouterModelDraft(routerModelPreview)
|
||||
}
|
||||
}, [routerModelPreview])
|
||||
|
||||
const anyChanges = Object.keys(draft).length > 0 || routerModelDraft !== routerModelPreview
|
||||
|
||||
const handleSaveKeys = async () => {
|
||||
setSaving(true)
|
||||
setSaveState("idle")
|
||||
const payload: Record<string, string | null> = {}
|
||||
for (const [k, v] of Object.entries(draft)) {
|
||||
// Empty string in the input → clear the key
|
||||
payload[k] = v === "" ? null : v
|
||||
}
|
||||
if (routerModelDraft !== routerModelPreview) {
|
||||
payload["TIGER_ROUTER_MODEL"] = routerModelDraft || null
|
||||
}
|
||||
try {
|
||||
const res = await fetch("/api/tiger/keys", {
|
||||
method: "PATCH",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
const data = await res.json()
|
||||
if (!data.ok) throw new Error(data.error ?? "Save failed")
|
||||
setSaveState("ok")
|
||||
setSaveMsg("Keys saved. Restart bridge to apply.")
|
||||
setDraft({})
|
||||
mutateKeys()
|
||||
setTimeout(() => { setSaveState("idle"); setSaveMsg("") }, 5000)
|
||||
} catch (err: any) {
|
||||
setSaveState("err")
|
||||
setSaveMsg(err.message)
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleRestartBridge = async () => {
|
||||
setRestarting(true)
|
||||
setRestartMsg("")
|
||||
try {
|
||||
const res = await fetch("/api/tiger/bridge-restart", { method: "POST" })
|
||||
const data = await res.json()
|
||||
setRestartMsg(data.message ?? "Restart initiated.")
|
||||
setTimeout(() => setRestartMsg(""), 8000)
|
||||
} catch {
|
||||
setRestartMsg("Restart request failed.")
|
||||
} finally {
|
||||
setRestarting(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="pb-2">
|
||||
<CardTitle className="text-base flex items-center gap-2">
|
||||
<Key className="h-4 w-4 text-muted-foreground" />
|
||||
API Keys & Router
|
||||
</CardTitle>
|
||||
<CardDescription>
|
||||
Stored in bridge .env. Values are never returned — only presence is shown.
|
||||
After saving, click <strong>Restart bridge</strong> to apply.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* Secret keys */}
|
||||
{SECRET_KEYS.map(({ key, label, hint }) => {
|
||||
const isSet = keysPresence[key]?.isSet ?? false
|
||||
const hasDraft = draft[key] !== undefined
|
||||
return (
|
||||
<SettingRow key={key} label={label} hint={hint}>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="password"
|
||||
autoComplete="off"
|
||||
placeholder={isSet ? "●●●●●●●● (set)" : "Not set — paste to update"}
|
||||
value={draft[key] ?? ""}
|
||||
onChange={(e) => setDraft((d) => ({ ...d, [key]: e.target.value }))}
|
||||
className={cn(
|
||||
"h-9 rounded-md border border-input bg-background px-3 text-sm font-mono focus:outline-none focus:ring-1 focus:ring-ring w-full max-w-sm",
|
||||
hasDraft && "border-primary/60"
|
||||
)}
|
||||
/>
|
||||
{isSet && !hasDraft && (
|
||||
<span className="text-xs text-emerald-400 shrink-0">✓ set</span>
|
||||
)}
|
||||
{hasDraft && draft[key] === "" && (
|
||||
<span className="text-xs text-amber-400 shrink-0">will clear</span>
|
||||
)}
|
||||
</div>
|
||||
</SettingRow>
|
||||
)
|
||||
})}
|
||||
|
||||
{/* Router model (non-secret, shown as text) */}
|
||||
<SettingRow
|
||||
label="Router model"
|
||||
hint="Model slug for classifyAgent / generateProject*. Prefix 'anthropic/' uses Anthropic API; anything else uses OpenRouter."
|
||||
>
|
||||
<input
|
||||
type="text"
|
||||
value={routerModelDraft}
|
||||
onChange={(e) => setRouterModelDraft(e.target.value)}
|
||||
placeholder="e.g. anthropic/claude-haiku-4-5"
|
||||
className={cn(
|
||||
"h-9 rounded-md border border-input bg-background px-3 text-sm font-mono focus:outline-none focus:ring-1 focus:ring-ring w-full max-w-sm",
|
||||
routerModelDraft !== routerModelPreview && "border-primary/60"
|
||||
)}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
{/* Feedback */}
|
||||
{saveMsg && (
|
||||
<div className={cn(
|
||||
"mt-3 text-xs px-3 py-2 rounded",
|
||||
saveState === "err"
|
||||
? "bg-destructive/10 text-destructive"
|
||||
: "bg-emerald-500/10 text-emerald-400"
|
||||
)}>
|
||||
{saveMsg}
|
||||
</div>
|
||||
)}
|
||||
{restartMsg && (
|
||||
<div className="mt-2 text-xs px-3 py-2 rounded bg-blue-500/10 text-blue-400">
|
||||
{restartMsg}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
<div className="flex items-center gap-2 mt-4">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleSaveKeys}
|
||||
disabled={!anyChanges || saving}
|
||||
>
|
||||
{saving
|
||||
? <Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
: saveState === "ok"
|
||||
? <Check className="h-4 w-4 mr-1" />
|
||||
: <Save className="h-4 w-4 mr-1" />
|
||||
}
|
||||
{saving ? "Saving…" : saveState === "ok" ? "Saved!" : "Save Keys"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={handleRestartBridge}
|
||||
disabled={restarting}
|
||||
>
|
||||
{restarting
|
||||
? <Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
: <RotateCcw className="h-4 w-4 mr-1" />
|
||||
}
|
||||
{restarting ? "Restarting…" : "Restart bridge"}
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function SettingsPage() {
|
||||
// Raw config from bridge
|
||||
const { data: configData, mutate: mutateConfig, isLoading: configLoading } =
|
||||
useSWR<{ ok: boolean; config: OpenClawConfig }>("/api/tiger/config", fetcher)
|
||||
|
||||
// Available models
|
||||
const { data: modelsData, isLoading: modelsLoading } =
|
||||
useSWR<{ ok: boolean; models: ModelInfo[] }>("/api/tiger/config/models", fetcher)
|
||||
|
||||
const remoteConfig = configData?.config ?? {}
|
||||
const models = modelsData?.models ?? []
|
||||
|
||||
// ── Local draft — tracks unsaved edits ─────────────────────────────────────
|
||||
const [draft, setDraft] = React.useState<OpenClawConfig>({})
|
||||
const [initialized, setInitialized] = React.useState(false)
|
||||
|
||||
|
|
@ -225,18 +413,12 @@ export default function SettingsPage() {
|
|||
}
|
||||
}, [configData, initialized])
|
||||
|
||||
const update = (path: string, value: any) => {
|
||||
setDraft(prev => set(prev, path, value))
|
||||
}
|
||||
|
||||
const update = (path: string, value: any) => setDraft(prev => set(prev, path, value))
|
||||
const g = (path: string, fallback: any = "") => get(draft, path, fallback)
|
||||
const r = (path: string, fallback: any = "") => get(remoteConfig, path, fallback)
|
||||
|
||||
// Dirty check — compare draft to remote at path level
|
||||
const isDirty = (path: string) => JSON.stringify(g(path)) !== JSON.stringify(r(path))
|
||||
const anyDirty = JSON.stringify(draft) !== JSON.stringify(remoteConfig)
|
||||
|
||||
// ── Save ────────────────────────────────────────────────────────────────────
|
||||
const [saving, setSaving] = React.useState(false)
|
||||
const [saveState, setSaveState] = React.useState<"idle" | "ok" | "err">("idle")
|
||||
const [saveError, setSaveError] = React.useState("")
|
||||
|
|
@ -254,7 +436,7 @@ export default function SettingsPage() {
|
|||
if (!data.ok) throw new Error(data.error ?? "Save failed")
|
||||
setSaveState("ok")
|
||||
await mutateConfig()
|
||||
setInitialized(false) // re-sync draft from fresh server data
|
||||
setInitialized(false)
|
||||
setTimeout(() => setSaveState("idle"), 3000)
|
||||
} catch (err: any) {
|
||||
setSaveError(err.message)
|
||||
|
|
@ -271,7 +453,6 @@ export default function SettingsPage() {
|
|||
|
||||
const loading = configLoading || modelsLoading || !initialized
|
||||
|
||||
// ── Render ──────────────────────────────────────────────────────────────────
|
||||
return (
|
||||
<div className="flex flex-col gap-6 p-6 max-w-3xl">
|
||||
|
||||
|
|
@ -282,7 +463,7 @@ export default function SettingsPage() {
|
|||
<Settings2 className="h-6 w-6" /> Settings
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
Live Tiger configuration — writes directly to openclaw.json inside the container.
|
||||
API keys and Tiger configuration.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -294,13 +475,13 @@ export default function SettingsPage() {
|
|||
? <Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
: saveState === "ok"
|
||||
? <Check className="h-4 w-4 mr-1" />
|
||||
: <Save className="h-4 w-4 mr-1" />}
|
||||
{saving ? "Saving…" : saveState === "ok" ? "Saved!" : "Save Changes"}
|
||||
: <Save className="h-4 w-4 mr-1" />
|
||||
}
|
||||
{saving ? "Saving…" : saveState === "ok" ? "Saved!" : "Save Config"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Save error */}
|
||||
{saveState === "err" && (
|
||||
<div className="flex items-center gap-2 p-3 rounded-md bg-destructive/10 text-destructive text-sm">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
|
|
@ -308,35 +489,31 @@ export default function SettingsPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* ── 0. API Keys (always rendered — has its own data fetching) ─── */}
|
||||
<ApiKeysSection />
|
||||
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<div className="flex items-center justify-center py-16">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-6">
|
||||
|
||||
{/* ── 1. Model ───────────────────────────────────────────────────── */}
|
||||
{/* ── 1. Model ─────────────────────────────────────────────────── */}
|
||||
<SectionCard
|
||||
icon={Cpu}
|
||||
title="Model"
|
||||
description="Which AI model Tiger and sub-agents use. Changes take effect on the next conversation."
|
||||
description="Global model for Tiger and sub-agents. Per-agent overrides live on the Agents page."
|
||||
dirty={isDirty("agents.defaults.model.primary") || isDirty("agents.defaults.model.fallbacks")}
|
||||
>
|
||||
<SettingRow
|
||||
label="Primary model"
|
||||
hint="Active model for all agents"
|
||||
>
|
||||
<SettingRow label="Primary model" hint="Active model for all agents (unless overridden)">
|
||||
<ModelSelect
|
||||
value={g("agents.defaults.model.primary", "")}
|
||||
models={models}
|
||||
onChange={v => update("agents.defaults.model.primary", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label="Fallback models"
|
||||
hint="Comma-separated, tried in order if primary fails"
|
||||
>
|
||||
<SettingRow label="Fallback models" hint="Comma-separated, tried in order if primary fails">
|
||||
<input
|
||||
type="text"
|
||||
value={(g("agents.defaults.model.fallbacks", []) as string[]).join(", ")}
|
||||
|
|
@ -348,11 +525,7 @@ export default function SettingsPage() {
|
|||
className="h-9 rounded-md border border-input bg-background px-3 text-sm font-mono focus:outline-none focus:ring-1 focus:ring-ring w-full max-w-sm"
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label="Compaction mode"
|
||||
hint="How Tiger handles context window limits"
|
||||
>
|
||||
<SettingRow label="Compaction mode" hint="How Tiger handles context window limits">
|
||||
<SelectInput
|
||||
value={g("agents.defaults.compaction.mode", "safeguard")}
|
||||
options={[
|
||||
|
|
@ -365,17 +538,14 @@ export default function SettingsPage() {
|
|||
</SettingRow>
|
||||
</SectionCard>
|
||||
|
||||
{/* ── 2. Session ─────────────────────────────────────────────────── */}
|
||||
{/* ── 2. Session ───────────────────────────────────────────────── */}
|
||||
<SectionCard
|
||||
icon={Bot}
|
||||
title="Session"
|
||||
description="How Tiger manages conversation sessions and identity scoping."
|
||||
description="Conversation session and identity scoping."
|
||||
dirty={isDirty("session.dmScope")}
|
||||
>
|
||||
<SettingRow
|
||||
label="DM scope"
|
||||
hint="How Tiger isolates context between different Telegram chats"
|
||||
>
|
||||
<SettingRow label="DM scope" hint="Context isolation between Telegram chats">
|
||||
<SelectInput
|
||||
value={g("session.dmScope", "per-channel-peer")}
|
||||
options={[
|
||||
|
|
@ -388,7 +558,7 @@ export default function SettingsPage() {
|
|||
</SettingRow>
|
||||
</SectionCard>
|
||||
|
||||
{/* ── 3. Telegram ────────────────────────────────────────────────── */}
|
||||
{/* ── 3. Telegram ──────────────────────────────────────────────── */}
|
||||
<SectionCard
|
||||
icon={MessageSquare}
|
||||
title="Telegram"
|
||||
|
|
@ -401,11 +571,7 @@ export default function SettingsPage() {
|
|||
onChange={v => update("channels.telegram.enabled", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label="Streaming"
|
||||
hint="How Tiger sends message updates while generating"
|
||||
>
|
||||
<SettingRow label="Streaming" hint="How Tiger sends updates while generating">
|
||||
<SelectInput
|
||||
value={g("channels.telegram.streaming", "partial")}
|
||||
options={[
|
||||
|
|
@ -418,17 +584,14 @@ export default function SettingsPage() {
|
|||
</SettingRow>
|
||||
</SectionCard>
|
||||
|
||||
{/* ── 4. Commands ────────────────────────────────────────────────── */}
|
||||
{/* ── 4. Commands ──────────────────────────────────────────────── */}
|
||||
<SectionCard
|
||||
icon={Terminal}
|
||||
title="Commands"
|
||||
description="How Tiger handles native and system commands."
|
||||
description="Native and system command settings."
|
||||
dirty={isDirty("commands.native") || isDirty("commands.ownerDisplay") || isDirty("commands.restart")}
|
||||
>
|
||||
<SettingRow
|
||||
label="Native commands"
|
||||
hint="Whether Tiger can run shell commands on the host"
|
||||
>
|
||||
<SettingRow label="Native commands" hint="Whether Tiger can run shell commands on the host">
|
||||
<SelectInput
|
||||
value={g("commands.native", "auto")}
|
||||
options={[
|
||||
|
|
@ -439,25 +602,17 @@ export default function SettingsPage() {
|
|||
onChange={v => update("commands.native", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label="Owner display"
|
||||
hint="How Manohar's name appears to Tiger in context"
|
||||
>
|
||||
<SettingRow label="Owner display" hint="How your name appears to Tiger">
|
||||
<SelectInput
|
||||
value={g("commands.ownerDisplay", "raw")}
|
||||
options={[
|
||||
{ value: "raw", label: "Raw — show as-is" },
|
||||
{ value: "raw", label: "Raw — as-is" },
|
||||
{ value: "formatted", label: "Formatted — display name" },
|
||||
]}
|
||||
onChange={v => update("commands.ownerDisplay", v)}
|
||||
/>
|
||||
</SettingRow>
|
||||
|
||||
<SettingRow
|
||||
label="Allow restart"
|
||||
hint="Tiger can restart itself when needed"
|
||||
>
|
||||
<SettingRow label="Allow restart" hint="Tiger can restart itself when needed">
|
||||
<Toggle
|
||||
checked={g("commands.restart", true)}
|
||||
onChange={v => update("commands.restart", v)}
|
||||
|
|
|
|||
|
|
@ -1,85 +1,287 @@
|
|||
import { KanbanBoard } from "@/components/tasks/kanban-board"
|
||||
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
|
||||
import { CheckSquare, GitBranch, Bot, Clock } from "lucide-react"
|
||||
"use client"
|
||||
|
||||
export default function TasksPage() {
|
||||
/**
|
||||
* /tasks — Dual-source task view
|
||||
*
|
||||
* PRIMARY → Tiger's TASKS.md (source of truth, read-only)
|
||||
* fetched via /api/tiger/file-tasks
|
||||
* SECONDARY → SQLite dispatch queue (status=backlog only)
|
||||
* fetched via /api/tiger/tasks
|
||||
* These are dashboard-queued items Tiger hasn't touched yet.
|
||||
*
|
||||
* Tiger owns TASKS.md entirely. Dashboard-queued items graduate to TASKS.md
|
||||
* once Tiger picks them up; the watcher marks them done in SQLite.
|
||||
*/
|
||||
|
||||
import * as React from "react"
|
||||
import useSWR from "swr"
|
||||
import {
|
||||
CheckSquare, GitBranch, Bot, Clock,
|
||||
AlertTriangle, Loader2, FolderOpen, Inbox,
|
||||
} from "lucide-react"
|
||||
import { Card, CardContent } from "@/components/ui/card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
// ─── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
// Tiger's TASKS.md task (from file-tasks route)
|
||||
interface FileTask {
|
||||
id: string
|
||||
title: string
|
||||
status: string
|
||||
status_raw: string
|
||||
section: string
|
||||
assigned_agent: string
|
||||
project: string
|
||||
description: string
|
||||
}
|
||||
|
||||
// SQLite dispatch queue task
|
||||
interface QueueTask {
|
||||
id: string
|
||||
project_id: string | null
|
||||
title: string
|
||||
status: string
|
||||
priority: string
|
||||
assigned_agent: string | null
|
||||
agent_reason: string | null
|
||||
}
|
||||
|
||||
// ─── Constants ────────────────────────────────────────────────────────────────
|
||||
|
||||
const SECTION_ORDER = ["in-progress", "review", "ready", "backlog", "done"]
|
||||
const SECTION_LABELS: Record<string, string> = {
|
||||
"in-progress": "In Progress",
|
||||
review: "Review",
|
||||
ready: "Ready",
|
||||
backlog: "Backlog",
|
||||
done: "Done",
|
||||
}
|
||||
const SECTION_COLORS: Record<string, string> = {
|
||||
"in-progress": "text-amber-400",
|
||||
review: "text-purple-400",
|
||||
ready: "text-blue-400",
|
||||
backlog: "text-zinc-400",
|
||||
done: "text-emerald-400",
|
||||
}
|
||||
const PRIORITY_COLORS: Record<string, string> = {
|
||||
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",
|
||||
urgent: "bg-red-500/10 text-red-400 border-red-500/20",
|
||||
}
|
||||
const AGENT_EMOJI: Record<string, string> = {
|
||||
tiger: "🐯", main: "🐯",
|
||||
cody: "💻", coder: "💻",
|
||||
ethan: "🔍", researcher: "🔍",
|
||||
cathy: "✍️", writer: "✍️",
|
||||
elon: "📊", pm: "📊",
|
||||
}
|
||||
const AGENT_COLORS: Record<string, string> = {
|
||||
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",
|
||||
}
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((r) => r.json())
|
||||
|
||||
function isRouterFailure(reason: string | null) {
|
||||
return !!reason && reason.startsWith("router_")
|
||||
}
|
||||
|
||||
// ─── File task row (Tiger's TASKS.md) ─────────────────────────────────────────
|
||||
|
||||
function FileTaskRow({ task }: { task: FileTask }) {
|
||||
const agentKey = task.assigned_agent?.toLowerCase() ?? ""
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<CheckSquare className="h-6 w-6 text-primary" />
|
||||
Task Progress
|
||||
</h1>
|
||||
<p className="text-muted-foreground">
|
||||
Manage and track tasks across your team and AI sub-agents
|
||||
</p>
|
||||
<div className="flex items-center gap-3 px-4 py-2.5 rounded-lg hover:bg-muted/30 transition-colors">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate text-foreground/90">{task.title}</div>
|
||||
{task.project && (
|
||||
<div className="text-xs text-muted-foreground truncate mt-0.5">{task.project}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Quick Stats */}
|
||||
<div className="grid gap-4 md:grid-cols-4">
|
||||
<Card className="bg-card/40">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Active Tasks</p>
|
||||
<p className="text-2xl font-bold">—</p>
|
||||
</div>
|
||||
<Clock className="h-5 w-5 text-blue-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card/40">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">In Progress</p>
|
||||
<p className="text-2xl font-bold">—</p>
|
||||
</div>
|
||||
<GitBranch className="h-5 w-5 text-amber-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card/40">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">AI Delegated</p>
|
||||
<p className="text-2xl font-bold">—</p>
|
||||
</div>
|
||||
<Bot className="h-5 w-5 text-violet-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
<Card className="bg-card/40">
|
||||
<CardContent className="pt-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm text-muted-foreground">Completed Today</p>
|
||||
<p className="text-2xl font-bold">—</p>
|
||||
</div>
|
||||
<CheckSquare className="h-5 w-5 text-emerald-400" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* Kanban Board */}
|
||||
<Card className="bg-card/40">
|
||||
<CardHeader>
|
||||
<CardTitle>Task Board</CardTitle>
|
||||
<CardDescription>
|
||||
Drag and drop tasks between columns. Click a task to edit or assign to an AI agent.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<KanbanBoard />
|
||||
</CardContent>
|
||||
</Card>
|
||||
{agentKey && (
|
||||
<span className={cn("text-xs font-medium shrink-0", AGENT_COLORS[agentKey] ?? "text-muted-foreground")}>
|
||||
{AGENT_EMOJI[agentKey] ?? ""} <span className="hidden sm:inline">{agentKey}</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground shrink-0 italic">{task.status_raw}</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Section (Tiger's tasks grouped by status) ────────────────────────────────
|
||||
|
||||
function FileTaskSection({ status, tasks }: { status: string; tasks: FileTask[] }) {
|
||||
const [collapsed, setCollapsed] = React.useState(status === "done")
|
||||
if (tasks.length === 0) return null
|
||||
return (
|
||||
<div>
|
||||
<button
|
||||
className="flex items-center gap-2 w-full text-left mb-2 group"
|
||||
onClick={() => setCollapsed((v) => !v)}
|
||||
>
|
||||
<span className={cn("text-xs font-semibold uppercase tracking-widest", SECTION_COLORS[status] ?? "text-zinc-400")}>
|
||||
{SECTION_LABELS[status] ?? status}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground bg-muted px-1.5 py-0 rounded-full">{tasks.length}</span>
|
||||
<span className="text-xs text-muted-foreground ml-auto opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
{collapsed ? "expand" : "collapse"}
|
||||
</span>
|
||||
</button>
|
||||
{!collapsed && (
|
||||
<div className="space-y-0.5">
|
||||
{tasks.map((t) => <FileTaskRow key={t.id} task={t} />)}
|
||||
</div>
|
||||
)}
|
||||
<div className="mt-4" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Queue task row (SQLite dispatch queue) ───────────────────────────────────
|
||||
|
||||
function QueueTaskRow({ task }: { task: QueueTask }) {
|
||||
const warn = isRouterFailure(task.agent_reason)
|
||||
return (
|
||||
<div className="flex items-center gap-3 px-4 py-2.5 rounded-lg hover:bg-muted/30 transition-colors border border-dashed border-border/40">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm font-medium truncate text-foreground/70">{task.title}</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5">Queued — waiting for Tiger</div>
|
||||
</div>
|
||||
{warn && (
|
||||
<span title={task.agent_reason ?? ""} className="shrink-0">
|
||||
<AlertTriangle className="h-3.5 w-3.5 text-amber-400/80" />
|
||||
</span>
|
||||
)}
|
||||
<Badge className={cn("text-[10px] px-1.5 py-0 shrink-0", PRIORITY_COLORS[task.priority ?? "medium"])}>
|
||||
{task.priority}
|
||||
</Badge>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Main page ────────────────────────────────────────────────────────────────
|
||||
|
||||
export default function TasksPage() {
|
||||
const { data: fileData, isLoading: fileLoading } = useSWR<{ ok: boolean; tasks: FileTask[] }>(
|
||||
"/api/tiger/file-tasks",
|
||||
fetcher,
|
||||
{ refreshInterval: 60_000 }
|
||||
)
|
||||
const { data: queueData, isLoading: queueLoading } = useSWR<{ ok: boolean; tasks: QueueTask[] }>(
|
||||
"/api/tiger/tasks",
|
||||
fetcher,
|
||||
{ refreshInterval: 30_000 }
|
||||
)
|
||||
|
||||
const fileTasks = fileData?.tasks ?? []
|
||||
// Only show SQLite tasks that are still in backlog (Tiger hasn't processed yet)
|
||||
const queueTasks = (queueData?.tasks ?? []).filter((t) => t.status === "backlog")
|
||||
|
||||
// Group Tiger's tasks by status
|
||||
const grouped = React.useMemo(() => {
|
||||
const g: Record<string, FileTask[]> = {}
|
||||
for (const t of fileTasks) {
|
||||
if (!g[t.status]) g[t.status] = []
|
||||
g[t.status].push(t)
|
||||
}
|
||||
return g
|
||||
}, [fileTasks])
|
||||
|
||||
const knownStatuses = new Set(SECTION_ORDER)
|
||||
const otherStatuses = Object.keys(grouped).filter((s) => !knownStatuses.has(s))
|
||||
const orderedStatuses = [...SECTION_ORDER, ...otherStatuses]
|
||||
|
||||
const inProgress = (grouped["in-progress"] ?? []).length
|
||||
const inReview = (grouped["review"] ?? []).length
|
||||
const done = (grouped["done"] ?? []).length
|
||||
const queued = queueTasks.length
|
||||
|
||||
const isLoading = fileLoading || queueLoading
|
||||
|
||||
return (
|
||||
<div className="space-y-6 max-w-3xl mx-auto w-full p-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold flex items-center gap-2">
|
||||
<CheckSquare className="h-6 w-6 text-primary" />
|
||||
Tasks
|
||||
</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Tiger's active work from <span className="font-mono text-xs">TASKS.md</span> — plus any dashboard-queued items waiting for pickup.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Stats */}
|
||||
<div className="grid gap-3 grid-cols-2 sm:grid-cols-4">
|
||||
{[
|
||||
{ label: "In Progress", value: inProgress, icon: GitBranch, color: "text-amber-400" },
|
||||
{ label: "In Review", value: inReview, icon: Bot, color: "text-purple-400" },
|
||||
{ label: "Done", value: done, icon: CheckSquare,color: "text-emerald-400" },
|
||||
{ label: "Queued", value: queued, icon: Inbox, color: "text-blue-400" },
|
||||
].map(({ label, value, icon: Icon, color }) => (
|
||||
<Card key={label} className="bg-card/40">
|
||||
<CardContent className="pt-4 pb-3 px-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground">{label}</p>
|
||||
<p className="text-xl font-bold tabular-nums">{isLoading ? "—" : value}</p>
|
||||
</div>
|
||||
<Icon className={cn("h-4 w-4", color)} />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : (
|
||||
<div>
|
||||
{/* Primary: Tiger's TASKS.md */}
|
||||
{fileTasks.length === 0 ? (
|
||||
<div className="text-center py-12 text-muted-foreground">
|
||||
<CheckSquare className="h-10 w-10 mx-auto mb-3 opacity-20" />
|
||||
<p className="text-sm">Tiger's task list is empty — no active work tracked in TASKS.md.</p>
|
||||
</div>
|
||||
) : (
|
||||
orderedStatuses.map((status) =>
|
||||
grouped[status]?.length ? (
|
||||
<FileTaskSection key={status} status={status} tasks={grouped[status]} />
|
||||
) : null
|
||||
)
|
||||
)}
|
||||
|
||||
{/* Secondary: Dashboard dispatch queue */}
|
||||
{queueTasks.length > 0 && (
|
||||
<div className="mt-6 pt-6 border-t border-border/40">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Inbox className="h-4 w-4 text-blue-400" />
|
||||
<span className="text-xs font-semibold uppercase tracking-widest text-blue-400">
|
||||
Dashboard Queue
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground bg-muted px-1.5 rounded-full">
|
||||
{queueTasks.length}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground mb-3">
|
||||
Dispatched from dashboard — waiting for Tiger to pick up.
|
||||
</p>
|
||||
<div className="space-y-1.5">
|
||||
{queueTasks.map((t) => <QueueTaskRow key={t.id} task={t} />)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
148
dashboard/src/components/agent-strip.tsx
Normal file
148
dashboard/src/components/agent-strip.tsx
Normal file
|
|
@ -0,0 +1,148 @@
|
|||
"use client"
|
||||
|
||||
/**
|
||||
* agent-strip.tsx — Live state of Tiger + sub-agents
|
||||
*
|
||||
* Replaces the old "Agent Model" card on the home page. Now you see ALL 5
|
||||
* agents at once with status, last activity, and file count.
|
||||
*
|
||||
* DATA SOURCE: /api/tiger/agents — already exists, returns:
|
||||
* { ok: true, agents: [{ id, name, emoji, role, fileCount, lastActivity }] }
|
||||
*/
|
||||
|
||||
import * as React from "react"
|
||||
import useSWR from "swr"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface Agent {
|
||||
id: string
|
||||
name: string
|
||||
emoji: string
|
||||
role: string
|
||||
fileCount: number
|
||||
lastActivity: number
|
||||
}
|
||||
|
||||
interface AgentsResponse {
|
||||
ok: boolean
|
||||
agents: Agent[]
|
||||
}
|
||||
|
||||
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 "now"
|
||||
if (m < 60) return `${m}m`
|
||||
const h = Math.floor(m / 60)
|
||||
if (h < 24) return `${h}h`
|
||||
const d = Math.floor(h / 24)
|
||||
return `${d}d`
|
||||
}
|
||||
|
||||
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_DOT: Record<"active" | "recent" | "idle", string> = {
|
||||
active: "bg-green-500 animate-pulse",
|
||||
recent: "bg-amber-500",
|
||||
idle: "bg-zinc-500",
|
||||
}
|
||||
|
||||
export function AgentStrip() {
|
||||
const { data, error, isLoading } = useSWR<AgentsResponse>(
|
||||
"/api/tiger/agents",
|
||||
fetcher,
|
||||
{ refreshInterval: 30_000 }
|
||||
)
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div>
|
||||
<SectionLabel>Agents</SectionLabel>
|
||||
<div className="flex gap-3 overflow-x-auto pb-2 snap-x">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="min-w-[140px] h-[80px] rounded-lg border border-border/50 bg-card/30 animate-pulse"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !data?.ok) {
|
||||
return (
|
||||
<div>
|
||||
<SectionLabel>Agents</SectionLabel>
|
||||
<div className="text-sm text-muted-foreground p-3 rounded-lg border border-border/50">
|
||||
Could not load agents. Bridge unreachable?
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const agents = data.agents ?? []
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex items-baseline justify-between mb-2">
|
||||
<SectionLabel>Agents</SectionLabel>
|
||||
<a href="/agents" className="text-xs text-primary hover:underline">
|
||||
View all →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 overflow-x-auto pb-2 snap-x snap-mandatory">
|
||||
{agents.map((agent) => {
|
||||
const status = statusOf(agent.lastActivity)
|
||||
return (
|
||||
<a
|
||||
key={agent.id}
|
||||
href={`/agents?id=${agent.id}`}
|
||||
className="snap-start shrink-0 min-w-[140px] p-3 rounded-lg border border-border/50 bg-card/40 hover:bg-card/60 hover:border-primary/30 transition-colors cursor-pointer"
|
||||
>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="text-lg leading-none">{agent.emoji}</span>
|
||||
<span className="text-sm font-medium truncate flex-1">
|
||||
{agent.name}
|
||||
</span>
|
||||
<span
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full shrink-0",
|
||||
STATUS_DOT[status]
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-1">
|
||||
{agent.role}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between text-[11px] text-muted-foreground">
|
||||
<span>last: {relativeTime(agent.lastActivity)}</span>
|
||||
<span className="tabular-nums">{agent.fileCount} files</span>
|
||||
</div>
|
||||
</a>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function SectionLabel({ children }: { children: React.ReactNode }) {
|
||||
return (
|
||||
<span className="text-[11px] uppercase tracking-wider text-muted-foreground/80">
|
||||
{children}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
|
@ -1,16 +1,35 @@
|
|||
"use client"
|
||||
|
||||
/**
|
||||
* app-sidebar.tsx — Tiger Command Center sidebar
|
||||
*
|
||||
* Phase 1 redesign. The new IA is built around the way YOU actually work:
|
||||
* Home — command + live state (the new front door)
|
||||
* Chat — unified web + Telegram thread (Phase 4 will wire Telegram)
|
||||
* Projects — orchestration containers; click a project to see tasks + agents
|
||||
* Agents — per-sub-agent detail and (Phase 3) per-agent model overrides
|
||||
* Knowledge — Tiger's brain: memory, skills, activity, scheduled jobs
|
||||
* Workspace — file tree of /sandbox + diffs
|
||||
* Cost — finance-grade cost dashboard
|
||||
* Logs — raw streaming logs (dev drill-down)
|
||||
* Settings
|
||||
*
|
||||
* Key change from the previous sidebar: no orphan pages. Memory, Sessions,
|
||||
* Skills, Activity, and Cron are now reachable through Knowledge / Agents /
|
||||
* Chat instead of being floating routes nobody could navigate to.
|
||||
*/
|
||||
|
||||
import * as React from "react"
|
||||
import {
|
||||
Bot,
|
||||
Settings2,
|
||||
LayoutDashboard,
|
||||
Home,
|
||||
MessageSquare,
|
||||
ScrollText,
|
||||
CheckSquare,
|
||||
DollarSign,
|
||||
FolderOpen,
|
||||
Briefcase,
|
||||
Bot,
|
||||
Brain,
|
||||
FolderOpen,
|
||||
DollarSign,
|
||||
ScrollText,
|
||||
Settings2,
|
||||
} from "lucide-react"
|
||||
import { useTigerLogs } from "@/hooks/use-bridge"
|
||||
|
||||
|
|
@ -25,56 +44,27 @@ import {
|
|||
SidebarRail,
|
||||
} from "@/components/ui/sidebar"
|
||||
|
||||
// Tiger-specific navigation - no more old clawdbot pages
|
||||
// Primary navigation — the main verbs of using Tiger.
|
||||
// Ordered by frequency-of-use so the most-tapped items sit at the top.
|
||||
const navMain = [
|
||||
{
|
||||
title: "Dashboard",
|
||||
url: "/",
|
||||
icon: LayoutDashboard,
|
||||
},
|
||||
{
|
||||
title: "Chat",
|
||||
url: "/chat",
|
||||
icon: MessageSquare,
|
||||
},
|
||||
{
|
||||
title: "Projects",
|
||||
url: "/projects",
|
||||
icon: Briefcase,
|
||||
},
|
||||
{
|
||||
title: "Workspace",
|
||||
url: "/workspace",
|
||||
icon: FolderOpen,
|
||||
},
|
||||
{
|
||||
title: "Tasks",
|
||||
url: "/tasks",
|
||||
icon: CheckSquare,
|
||||
},
|
||||
{
|
||||
title: "Cost Monitor",
|
||||
url: "/cost",
|
||||
icon: DollarSign,
|
||||
},
|
||||
{
|
||||
title: "Logs",
|
||||
url: "/logs",
|
||||
icon: ScrollText,
|
||||
},
|
||||
{ title: "Home", url: "/", icon: Home },
|
||||
{ title: "Chat", url: "/chat", icon: MessageSquare },
|
||||
{ title: "Projects", url: "/projects", icon: Briefcase },
|
||||
{ title: "Agents", url: "/agents", icon: Bot },
|
||||
{ title: "Knowledge", url: "/knowledge", icon: Brain },
|
||||
{ title: "Workspace", url: "/workspace", icon: FolderOpen },
|
||||
{ title: "Cost", url: "/cost", icon: DollarSign },
|
||||
{ title: "Logs", url: "/logs", icon: ScrollText },
|
||||
]
|
||||
|
||||
// Secondary navigation — sits in the footer, less-frequent admin stuff.
|
||||
const navSecondary = [
|
||||
{
|
||||
title: "Settings",
|
||||
url: "/settings",
|
||||
icon: Settings2,
|
||||
},
|
||||
{ title: "Settings", url: "/settings", icon: Settings2 },
|
||||
]
|
||||
|
||||
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
||||
// Use Tiger logs SSE for connection status
|
||||
// connected means the bridge is reachable
|
||||
// Use the existing log stream as a heartbeat — if it's connected, the bridge
|
||||
// is reachable, so we're "Live". If not, the dot goes red.
|
||||
const { connected } = useTigerLogs({ lines: 1, maxLines: 1 })
|
||||
|
||||
return (
|
||||
|
|
@ -84,20 +74,29 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||
<SidebarMenuItem>
|
||||
<SidebarMenuButton size="lg" asChild>
|
||||
<a href="/">
|
||||
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground">
|
||||
<Bot className="size-4" />
|
||||
{/* Tiger badge — same gradient T as the favicon for brand consistency */}
|
||||
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-orange-500/15 border border-orange-500/30">
|
||||
<span className="text-orange-400 font-bold text-base">T</span>
|
||||
</div>
|
||||
<div className="flex flex-col gap-0.5 leading-none">
|
||||
<span className="text-sm font-semibold">Tiger</span>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`h-1.5 w-1.5 rounded-full ${
|
||||
connected ? "bg-green-500 animate-pulse" : "bg-red-500"
|
||||
}`} />
|
||||
<span className="text-[10px] text-muted-foreground uppercase tracking-wide">
|
||||
{connected ? "Live" : "Offline"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={`h-2 w-2 rounded-full ${connected ? 'bg-green-500' : 'bg-red-500'}`} />
|
||||
<span className="text-xs text-muted-foreground">{connected ? "Live" : "Offline"}</span>
|
||||
</div>
|
||||
</a>
|
||||
</SidebarMenuButton>
|
||||
</SidebarMenuItem>
|
||||
</SidebarMenu>
|
||||
</SidebarHeader>
|
||||
|
||||
<SidebarContent>
|
||||
<SidebarMenu className="gap-2 p-2">
|
||||
<SidebarMenu className="gap-1 p-2">
|
||||
{navMain.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton asChild tooltip={item.title}>
|
||||
|
|
@ -110,11 +109,12 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarContent>
|
||||
|
||||
<SidebarFooter>
|
||||
<SidebarMenu>
|
||||
{navSecondary.map((item) => (
|
||||
<SidebarMenuItem key={item.title}>
|
||||
<SidebarMenuButton asChild size="sm">
|
||||
<SidebarMenuButton asChild size="sm" tooltip={item.title}>
|
||||
<a href={item.url}>
|
||||
<item.icon />
|
||||
<span>{item.title}</span>
|
||||
|
|
@ -124,6 +124,7 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
|
|||
))}
|
||||
</SidebarMenu>
|
||||
</SidebarFooter>
|
||||
|
||||
<SidebarRail />
|
||||
</Sidebar>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,297 +0,0 @@
|
|||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import { Send, Square, Bot, User, AlertCircle, Loader2, Eraser } from "lucide-react"
|
||||
import ReactMarkdown from "react-markdown"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardContent, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { ScrollArea } from "@/components/ui/scroll-area"
|
||||
import { useChatContext } from "@/contexts/chat-context"
|
||||
|
||||
export function ChatInterface({ className, ...props }: React.ComponentProps<typeof Card>) {
|
||||
const [input, setInput] = React.useState("")
|
||||
// Persistent chat state — survives navigation between routes.
|
||||
// See contexts/chat-context.tsx for the rationale.
|
||||
const { messages, setMessages, clearChat } = useChatContext()
|
||||
const [sending, setSending] = React.useState(false)
|
||||
const scrollRef = React.useRef<HTMLDivElement>(null)
|
||||
const abortRef = React.useRef<AbortController | null>(null)
|
||||
const streamingRef = React.useRef("")
|
||||
|
||||
React.useEffect(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||
}
|
||||
}, [messages])
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
if (!input.trim() || sending) return
|
||||
|
||||
const text = input.trim()
|
||||
setInput("")
|
||||
setSending(true)
|
||||
streamingRef.current = ""
|
||||
|
||||
// Add user message
|
||||
setMessages(prev => [...prev, {
|
||||
id: `user-${Date.now()}`,
|
||||
role: "user",
|
||||
content: text,
|
||||
timestamp: Date.now(),
|
||||
}])
|
||||
|
||||
try {
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
|
||||
const res = await fetch("/api/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message: text }),
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
if (!res.ok || !res.body) throw new Error("Failed to connect")
|
||||
|
||||
const reader = res.body.getReader()
|
||||
const decoder = new TextDecoder()
|
||||
// Buffer across reads — a single SSE event ("data: ...\n\n") may be
|
||||
// split across TCP chunks. Accumulate, then split on the SSE delimiter.
|
||||
let buffer = ""
|
||||
|
||||
const streamId = `streaming-${Date.now()}`
|
||||
|
||||
while (true) {
|
||||
const { done: readerDone, value } = await reader.read()
|
||||
if (readerDone) break
|
||||
|
||||
// {stream: true} preserves decoder state for multi-byte UTF-8 chars
|
||||
// (e.g. emoji) that happen to land across chunk boundaries.
|
||||
buffer += decoder.decode(value, { stream: true })
|
||||
|
||||
// SSE events end with a blank line (\n\n). Anything after the last
|
||||
// \n\n is a partial event — keep it in `buffer` for the next read.
|
||||
const events = buffer.split("\n\n")
|
||||
buffer = events.pop() || ""
|
||||
|
||||
for (const eventBlock of events) {
|
||||
const dataLine = eventBlock.split("\n").find(l => l.startsWith("data: "))
|
||||
if (!dataLine) continue
|
||||
|
||||
let data: { type: string; content?: string }
|
||||
try {
|
||||
data = JSON.parse(dataLine.slice(6))
|
||||
} catch (err) {
|
||||
// Don't swallow silently — log so real parse bugs are visible.
|
||||
console.warn("[chat] SSE parse error:", err, "line:", dataLine)
|
||||
continue
|
||||
}
|
||||
|
||||
console.log("[chat] event:", data.type, "content:", data.content?.substring(0, 50))
|
||||
|
||||
if (data.type === "status") {
|
||||
// Transient 'Tiger is thinking...' indicator. Do NOT append to
|
||||
// the message content — that was Bug A. Just ensure a streaming
|
||||
// placeholder exists so the UI shows activity.
|
||||
setMessages(prev => {
|
||||
if (prev.some(m => m.streaming)) return prev
|
||||
return [...prev, {
|
||||
id: streamId,
|
||||
role: "agent",
|
||||
content: "",
|
||||
streaming: true,
|
||||
timestamp: Date.now(),
|
||||
}]
|
||||
})
|
||||
} else if (data.type === "chunk") {
|
||||
streamingRef.current += data.content || ""
|
||||
setMessages(prev => {
|
||||
const existing = prev.find(m => m.streaming)
|
||||
if (existing) {
|
||||
return prev.map(m =>
|
||||
m.streaming ? { ...m, content: streamingRef.current } : m
|
||||
)
|
||||
}
|
||||
return [...prev, {
|
||||
id: streamId,
|
||||
role: "agent",
|
||||
content: streamingRef.current,
|
||||
streaming: true,
|
||||
timestamp: Date.now(),
|
||||
}]
|
||||
})
|
||||
} else if (data.type === "message") {
|
||||
// Non-streaming full message
|
||||
setMessages(prev => {
|
||||
const filtered = prev.filter(m => !m.streaming)
|
||||
return [...filtered, {
|
||||
id: `agent-${Date.now()}`,
|
||||
role: "agent",
|
||||
content: data.content || "",
|
||||
timestamp: Date.now(),
|
||||
}]
|
||||
})
|
||||
} else if (data.type === "done") {
|
||||
// Fall back to data.content if the chunk event somehow didn't
|
||||
// land — Bug D. This is a belt-and-suspenders safety.
|
||||
const finalContent = streamingRef.current || data.content || ""
|
||||
setMessages(prev => {
|
||||
const filtered = prev.filter(m => !m.streaming)
|
||||
if (!finalContent) return filtered
|
||||
return [...filtered, {
|
||||
id: `agent-${Date.now()}`,
|
||||
role: "agent",
|
||||
content: finalContent,
|
||||
timestamp: Date.now(),
|
||||
}]
|
||||
})
|
||||
streamingRef.current = ""
|
||||
setSending(false)
|
||||
} else if (data.type === "error") {
|
||||
setMessages(prev => [...prev.filter(m => !m.streaming), {
|
||||
id: `err-${Date.now()}`,
|
||||
role: "system",
|
||||
content: data.content || "Something went wrong",
|
||||
timestamp: Date.now(),
|
||||
}])
|
||||
setSending(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err.name !== "AbortError") {
|
||||
setMessages(prev => [...prev.filter(m => !m.streaming), {
|
||||
id: `err-${Date.now()}`,
|
||||
role: "system",
|
||||
content: "Failed to send message. Is Tiger running?",
|
||||
timestamp: Date.now(),
|
||||
}])
|
||||
}
|
||||
setSending(false)
|
||||
}
|
||||
|
||||
abortRef.current = null
|
||||
}
|
||||
|
||||
const handleAbort = () => {
|
||||
abortRef.current?.abort()
|
||||
setSending(false)
|
||||
streamingRef.current = ""
|
||||
setMessages(prev => prev.filter(m => !m.streaming))
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className={cn("w-full flex flex-col", className)} {...props}>
|
||||
<CardHeader className="pb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="flex items-center gap-2 text-base">
|
||||
<Bot className="h-5 w-5" />
|
||||
Chat with Tiger
|
||||
</CardTitle>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={clearChat}
|
||||
className="text-xs text-muted-foreground h-7"
|
||||
title="Clear conversation"
|
||||
>
|
||||
<Eraser className="h-3 w-3 mr-1" />
|
||||
Clear
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent className="flex-1 p-0 min-h-0">
|
||||
<ScrollArea className="h-[500px] px-4" ref={scrollRef}>
|
||||
<div className="space-y-4 py-2">
|
||||
{messages.map((message) => (
|
||||
<div
|
||||
key={message.id}
|
||||
className={cn(
|
||||
"flex gap-2 max-w-[85%]",
|
||||
message.role === "user" ? "ml-auto flex-row-reverse" : "",
|
||||
message.role === "system" ? "mx-auto max-w-full" : ""
|
||||
)}
|
||||
>
|
||||
{message.role !== "system" && (
|
||||
<div className={cn(
|
||||
"flex-shrink-0 h-7 w-7 rounded-full flex items-center justify-center",
|
||||
message.role === "user" ? "bg-primary" : "bg-muted"
|
||||
)}>
|
||||
{message.role === "user" ? (
|
||||
<User className="h-4 w-4 text-primary-foreground" />
|
||||
) : (
|
||||
<Bot className="h-4 w-4" />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
<div className={cn(
|
||||
"rounded-lg px-3 py-2 text-sm",
|
||||
message.role === "user"
|
||||
? "bg-primary text-primary-foreground"
|
||||
: message.role === "system"
|
||||
? "bg-destructive/10 text-destructive flex items-center gap-2 w-full justify-center"
|
||||
: "bg-muted",
|
||||
message.streaming ? "border border-primary/30" : ""
|
||||
)}>
|
||||
{message.role === "system" && <AlertCircle className="h-3 w-3" />}
|
||||
{message.role === "agent" ? (
|
||||
// While streaming: render raw text (cheap, one DOM node update per token).
|
||||
// After streaming completes: render full ReactMarkdown (expensive but
|
||||
// only happens once). This is what makes the typing feel actually show up.
|
||||
message.streaming ? (
|
||||
<div className="whitespace-pre-wrap">{message.content}</div>
|
||||
) : (
|
||||
<div className="prose prose-sm prose-invert max-w-none [&>p]:m-0">
|
||||
<ReactMarkdown>{message.content}</ReactMarkdown>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
message.content
|
||||
)}
|
||||
{message.streaming && (
|
||||
<span className="inline-block w-1.5 h-4 bg-primary/70 animate-pulse ml-0.5" />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{sending && !messages.some(m => m.streaming) && (
|
||||
<div className="flex gap-2">
|
||||
<div className="h-7 w-7 rounded-full bg-muted flex items-center justify-center">
|
||||
<Bot className="h-4 w-4" />
|
||||
</div>
|
||||
<div className="bg-muted rounded-lg px-3 py-2">
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</CardContent>
|
||||
<CardFooter className="pt-3">
|
||||
<form onSubmit={handleSubmit} className="flex w-full items-center space-x-2">
|
||||
<Input
|
||||
placeholder="Message Tiger..."
|
||||
className="flex-1"
|
||||
autoComplete="off"
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
disabled={sending}
|
||||
/>
|
||||
{sending ? (
|
||||
<Button type="button" size="icon" variant="destructive" onClick={handleAbort}>
|
||||
<Square className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button type="submit" size="icon" disabled={!input.trim()}>
|
||||
<Send className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</form>
|
||||
</CardFooter>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
136
dashboard/src/components/command-bar.tsx
Normal file
136
dashboard/src/components/command-bar.tsx
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
"use client"
|
||||
|
||||
/**
|
||||
* command-bar.tsx — The new home-page hero
|
||||
*
|
||||
* This is the single most important UI change in the redesign.
|
||||
* The old home page told you Tiger was alive. This one lets you tell
|
||||
* Tiger what to do. That shift — from monitoring to commanding —
|
||||
* is the whole reason Phase 1 exists.
|
||||
*
|
||||
* BEHAVIOR:
|
||||
* - User types a prompt and hits Enter (or clicks Send).
|
||||
* - We POST to /api/chat which already exists and routes to the bridge.
|
||||
* - On success we redirect to /chat so the user can watch the response.
|
||||
* - The chips below the input are auto-derived prompts. Phase 1 ships with
|
||||
* sensible defaults; Phase 2 will swap those for SQL-aggregated frequent
|
||||
* prompts from the chat_messages table.
|
||||
*/
|
||||
|
||||
import * as React from "react"
|
||||
import { Send, Loader2 } from "lucide-react"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import useSWR from "swr"
|
||||
|
||||
// Hard-coded for Phase 1. Phase 2 replaces this with real history aggregation.
|
||||
const FALLBACK_PROMPTS = [
|
||||
"Morning digest",
|
||||
"Pull latest CERC orders",
|
||||
"Status report",
|
||||
"Plan a new project",
|
||||
]
|
||||
|
||||
const fetcher = (url: string) =>
|
||||
fetch(url).then((r) => (r.ok ? r.json() : null))
|
||||
|
||||
interface FrequentPromptsResponse {
|
||||
ok: boolean
|
||||
prompts: string[]
|
||||
}
|
||||
|
||||
export function CommandBar() {
|
||||
const [value, setValue] = React.useState("")
|
||||
const [submitting, setSubmitting] = React.useState(false)
|
||||
|
||||
// Frequent prompts — gracefully degrades if the endpoint doesn't exist yet.
|
||||
const { data } = useSWR<FrequentPromptsResponse>(
|
||||
"/api/tiger/prompts/frequent",
|
||||
fetcher,
|
||||
{
|
||||
refreshInterval: 5 * 60_000,
|
||||
revalidateOnFocus: false,
|
||||
shouldRetryOnError: false,
|
||||
}
|
||||
)
|
||||
|
||||
const chips =
|
||||
data?.ok && data.prompts && data.prompts.length > 0
|
||||
? data.prompts.slice(0, 4)
|
||||
: FALLBACK_PROMPTS
|
||||
|
||||
const handleSubmit = async () => {
|
||||
const trimmed = value.trim()
|
||||
if (!trimmed || submitting) return
|
||||
|
||||
setSubmitting(true)
|
||||
try {
|
||||
const res = await fetch("/api/chat", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ message: trimmed }),
|
||||
})
|
||||
|
||||
if (res.ok) {
|
||||
window.location.href = "/chat"
|
||||
} else {
|
||||
console.error("Submit failed:", res.status, await res.text())
|
||||
setSubmitting(false)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error("Submit threw:", e)
|
||||
setSubmitting(false)
|
||||
}
|
||||
}
|
||||
|
||||
const handleKey = (e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter" && !e.shiftKey) {
|
||||
e.preventDefault()
|
||||
handleSubmit()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-card/50 border-primary/20 p-4 md:p-5">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Tell Tiger…"
|
||||
value={value}
|
||||
onChange={(e) => setValue(e.target.value)}
|
||||
onKeyDown={handleKey}
|
||||
disabled={submitting}
|
||||
className="flex-1 bg-transparent outline-none text-base md:text-lg placeholder:text-muted-foreground/60 disabled:opacity-50"
|
||||
autoFocus
|
||||
/>
|
||||
<Button
|
||||
size="icon"
|
||||
onClick={handleSubmit}
|
||||
disabled={!value.trim() || submitting}
|
||||
className="shrink-0"
|
||||
aria-label="Send to Tiger"
|
||||
>
|
||||
{submitting ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<Send className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2 mt-3">
|
||||
{chips.map((chip) => (
|
||||
<button
|
||||
key={chip}
|
||||
type="button"
|
||||
onClick={() => setValue(chip)}
|
||||
disabled={submitting}
|
||||
className="text-xs px-3 py-1.5 rounded-full bg-muted/50 hover:bg-muted/80 text-muted-foreground hover:text-foreground border border-border/50 transition-colors disabled:opacity-50"
|
||||
>
|
||||
{chip}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
110
dashboard/src/components/digest-card.tsx
Normal file
110
dashboard/src/components/digest-card.tsx
Normal file
|
|
@ -0,0 +1,110 @@
|
|||
"use client"
|
||||
|
||||
/**
|
||||
* digest-card.tsx — Today's digest of agent activity
|
||||
*
|
||||
* The home page used to make you click into /logs or /activity to see what
|
||||
* Tiger had been doing. Now it surfaces directly. Live "what just happened"
|
||||
* feed driven by /api/tiger/agents-activity.
|
||||
*/
|
||||
|
||||
import * as React from "react"
|
||||
import useSWR from "swr"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Activity } from "lucide-react"
|
||||
|
||||
interface ActivityEvent {
|
||||
agentId: string
|
||||
agentName: string
|
||||
agentEmoji: string
|
||||
path: string
|
||||
action: string
|
||||
ts: number
|
||||
}
|
||||
|
||||
interface ActivityResponse {
|
||||
ok: boolean
|
||||
events: ActivityEvent[]
|
||||
}
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((r) => r.json())
|
||||
|
||||
function relTime(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`
|
||||
const d = Math.floor(h / 24)
|
||||
return `${d}d ago`
|
||||
}
|
||||
|
||||
// Truncate from the LEFT so the filename stays readable.
|
||||
function leftTruncate(s: string, max = 40): string {
|
||||
if (s.length <= max) return s
|
||||
return "…" + s.slice(-(max - 1))
|
||||
}
|
||||
|
||||
export function DigestCard() {
|
||||
const { data, isLoading } = useSWR<ActivityResponse>(
|
||||
"/api/tiger/agents-activity",
|
||||
fetcher,
|
||||
{ refreshInterval: 60_000 }
|
||||
)
|
||||
|
||||
const events = React.useMemo(() => {
|
||||
const list = data?.events ?? []
|
||||
return [...list].sort((a, b) => b.ts - a.ts).slice(0, 6)
|
||||
}, [data])
|
||||
|
||||
return (
|
||||
<Card className="bg-card/40 p-4">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Activity className="h-4 w-4 text-primary" />
|
||||
<span className="text-[11px] uppercase tracking-wider text-muted-foreground/80">
|
||||
Today's digest
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 4 }).map((_, i) => (
|
||||
<div key={i} className="h-6 rounded bg-muted/30 animate-pulse" />
|
||||
))}
|
||||
</div>
|
||||
) : events.length === 0 ? (
|
||||
<div className="text-sm text-muted-foreground py-4 text-center">
|
||||
No activity yet today.
|
||||
</div>
|
||||
) : (
|
||||
<ul className="space-y-2">
|
||||
{events.map((ev, i) => (
|
||||
<li key={`${ev.ts}-${i}`} className="flex items-start gap-2 text-sm">
|
||||
<span className="shrink-0 text-base leading-snug" aria-hidden>
|
||||
{ev.agentEmoji}
|
||||
</span>
|
||||
|
||||
<div className="flex-1 min-w-0">
|
||||
<div
|
||||
className="font-mono text-xs text-foreground/90 truncate"
|
||||
title={ev.path}
|
||||
>
|
||||
{leftTruncate(ev.path)}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground">
|
||||
{ev.agentName} {ev.action}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<span className="shrink-0 text-[11px] text-muted-foreground tabular-nums pt-0.5">
|
||||
{relTime(ev.ts)}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
186
dashboard/src/components/schedule-card.tsx
Normal file
186
dashboard/src/components/schedule-card.tsx
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
"use client"
|
||||
|
||||
/**
|
||||
* ScheduleCard — shows Tiger's cron jobs + scheduler status
|
||||
* Displayed on the dashboard home page.
|
||||
*
|
||||
* Fetches GET /api/tiger/cron → { ok, jobs[], status{} }
|
||||
* Each job row shows: name, schedule, next run, last run status, "Run now" button
|
||||
*/
|
||||
|
||||
import * as React from "react"
|
||||
import useSWR from "swr"
|
||||
import { Clock, Play, CheckCircle2, XCircle, Loader2, CalendarClock, RefreshCw } from "lucide-react"
|
||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
interface CronJob {
|
||||
id: string
|
||||
name?: string
|
||||
label?: string
|
||||
schedule: string
|
||||
enabled: boolean
|
||||
nextRun?: string
|
||||
lastRun?: { status: string; at: string }
|
||||
}
|
||||
|
||||
interface CronStatus {
|
||||
running?: boolean
|
||||
nextCheck?: string
|
||||
}
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((r) => r.json())
|
||||
|
||||
function relTime(iso: string | undefined): string {
|
||||
if (!iso) return "—"
|
||||
const d = new Date(iso)
|
||||
if (isNaN(d.getTime())) return iso
|
||||
const diff = d.getTime() - Date.now()
|
||||
const abs = Math.abs(diff)
|
||||
const m = Math.floor(abs / 60_000)
|
||||
const h = Math.floor(m / 60)
|
||||
const suffix = diff > 0 ? "from now" : "ago"
|
||||
if (m < 1) return diff > 0 ? "imminent" : "just now"
|
||||
if (m < 60) return `${m}m ${suffix}`
|
||||
if (h < 24) return `${h}h ${suffix}`
|
||||
return `${Math.floor(h / 24)}d ${suffix}`
|
||||
}
|
||||
|
||||
export function ScheduleCard() {
|
||||
const { data, isLoading, mutate } = useSWR<{
|
||||
ok: boolean
|
||||
jobs: CronJob[]
|
||||
status: CronStatus
|
||||
}>("/api/tiger/cron", fetcher, { refreshInterval: 60_000 })
|
||||
|
||||
const [running, setRunning] = React.useState<string | null>(null)
|
||||
const [runMsg, setRunMsg] = React.useState<Record<string, string>>({})
|
||||
|
||||
const jobs = data?.jobs ?? []
|
||||
const schedulerRunning = data?.status?.running ?? false
|
||||
|
||||
const handleRunNow = async (jobId: string) => {
|
||||
setRunning(jobId)
|
||||
try {
|
||||
const res = await fetch(`/api/tiger/cron/${jobId}/run`, { method: "POST" })
|
||||
const d = await res.json()
|
||||
setRunMsg((m) => ({ ...m, [jobId]: d.ok ? "triggered" : (d.error ?? "failed") }))
|
||||
setTimeout(() => setRunMsg((m) => { const n = { ...m }; delete n[jobId]; return n }), 4000)
|
||||
} catch {
|
||||
setRunMsg((m) => ({ ...m, [jobId]: "error" }))
|
||||
} finally {
|
||||
setRunning(null)
|
||||
mutate()
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Card className="bg-card/40">
|
||||
<CardHeader className="pb-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<CardTitle className="text-sm font-semibold flex items-center gap-2">
|
||||
<CalendarClock className="h-4 w-4 text-muted-foreground" />
|
||||
Schedule
|
||||
{/* Scheduler health dot */}
|
||||
<span
|
||||
title={schedulerRunning ? "Scheduler active" : "Scheduler offline"}
|
||||
className={cn(
|
||||
"h-2 w-2 rounded-full",
|
||||
isLoading ? "bg-zinc-500" : schedulerRunning ? "bg-green-500" : "bg-red-500"
|
||||
)}
|
||||
/>
|
||||
</CardTitle>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => mutate()}
|
||||
title="Refresh"
|
||||
>
|
||||
<RefreshCw className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="flex justify-center py-6">
|
||||
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
) : jobs.length === 0 ? (
|
||||
<div className="text-center py-6 text-muted-foreground">
|
||||
<Clock className="h-8 w-8 mx-auto mb-2 opacity-20" />
|
||||
<p className="text-xs">No cron jobs configured.</p>
|
||||
<p className="text-xs mt-1 opacity-70">
|
||||
Use <span className="font-mono">openclaw cron add</span> to schedule Tiger.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{jobs.map((job) => {
|
||||
const label = job.name ?? job.label ?? job.id
|
||||
const lastStatus = job.lastRun?.status?.toLowerCase() ?? ""
|
||||
const isOk = lastStatus === "success" || lastStatus === "ok" || lastStatus === "done"
|
||||
const isFail= lastStatus === "failed" || lastStatus === "error"
|
||||
|
||||
return (
|
||||
<div
|
||||
key={job.id}
|
||||
className={cn(
|
||||
"flex items-center gap-3 px-3 py-2 rounded-md transition-colors",
|
||||
job.enabled ? "hover:bg-muted/30" : "opacity-50"
|
||||
)}
|
||||
>
|
||||
{/* Last run status icon */}
|
||||
{isOk && <CheckCircle2 className="h-3.5 w-3.5 text-emerald-400 shrink-0" />}
|
||||
{isFail && <XCircle className="h-3.5 w-3.5 text-red-400 shrink-0" />}
|
||||
{!isOk && !isFail && (
|
||||
<Clock className="h-3.5 w-3.5 text-zinc-500 shrink-0" />
|
||||
)}
|
||||
|
||||
{/* Name + schedule */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-xs font-medium truncate">{label}</div>
|
||||
<div className="text-[10px] text-muted-foreground font-mono">{job.schedule}</div>
|
||||
</div>
|
||||
|
||||
{/* Next run */}
|
||||
<div className="text-[10px] text-muted-foreground shrink-0 text-right">
|
||||
{job.nextRun ? (
|
||||
<>
|
||||
<div className="text-foreground/60">{relTime(job.nextRun)}</div>
|
||||
<div className="opacity-50">next</div>
|
||||
</>
|
||||
) : job.lastRun?.at ? (
|
||||
<>
|
||||
<div className="text-foreground/60">{relTime(job.lastRun.at)}</div>
|
||||
<div className="opacity-50">last run</div>
|
||||
</>
|
||||
) : "—"}
|
||||
</div>
|
||||
|
||||
{/* Run now */}
|
||||
{job.enabled && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 shrink-0"
|
||||
onClick={() => handleRunNow(job.id)}
|
||||
disabled={running === job.id}
|
||||
title={runMsg[job.id] ?? "Run now"}
|
||||
>
|
||||
{running === job.id
|
||||
? <Loader2 className="h-3 w-3 animate-spin" />
|
||||
: <Play className="h-3 w-3" />
|
||||
}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
134
dashboard/src/components/status-footer.tsx
Normal file
134
dashboard/src/components/status-footer.tsx
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
"use client"
|
||||
|
||||
/**
|
||||
* status-footer.tsx — Thin status strip at the bottom of the home page
|
||||
*
|
||||
* The OLD home page made "is Tiger alive" the headline. The NEW home page
|
||||
* relegates it to a footer strip. When something IS wrong, the strip
|
||||
* promotes itself into a banner with a Restart button.
|
||||
*/
|
||||
|
||||
import * as React from "react"
|
||||
import useSWR from "swr"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { AlertCircle, RefreshCw, Loader2 } from "lucide-react"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useBridgeRequest } from "@/hooks/use-bridge"
|
||||
|
||||
interface TigerStatus {
|
||||
status: "online" | "degraded" | "offline"
|
||||
container: {
|
||||
status: string
|
||||
exitCode: number
|
||||
startedAt: string
|
||||
}
|
||||
openclaw: { running: boolean }
|
||||
system: { memoryUsagePct: number; memoryTotalMb: number }
|
||||
agent: { currentModel: string }
|
||||
}
|
||||
|
||||
const fetcher = (url: string) => fetch(url).then((r) => r.json())
|
||||
|
||||
function uptimeShort(startedAt: string): string {
|
||||
if (!startedAt) return "—"
|
||||
const start = new Date(startedAt).getTime()
|
||||
const diff = Date.now() - start
|
||||
const m = Math.floor(diff / 60_000)
|
||||
if (m < 60) return `${m}m`
|
||||
const h = Math.floor(m / 60)
|
||||
if (h < 24) return `${h}h`
|
||||
const d = Math.floor(h / 24)
|
||||
return `${d}d`
|
||||
}
|
||||
|
||||
function shortModel(m: string): string {
|
||||
if (!m) return "—"
|
||||
const parts = m.split("/")
|
||||
return parts[parts.length - 1].replace(/:.*$/, "")
|
||||
}
|
||||
|
||||
export function StatusFooter() {
|
||||
const { data, error } = useSWR<TigerStatus>("/api/tiger/status", fetcher, {
|
||||
refreshInterval: 10_000,
|
||||
})
|
||||
|
||||
const { request } = useBridgeRequest()
|
||||
const [restarting, setRestarting] = React.useState(false)
|
||||
|
||||
const isCrashed = data?.container?.exitCode === 255
|
||||
const isOffline = error || data?.status === "offline"
|
||||
|
||||
const handleRestart = async () => {
|
||||
setRestarting(true)
|
||||
try {
|
||||
await request("/api/tiger/restart", "POST")
|
||||
setTimeout(() => setRestarting(false), 3000)
|
||||
} catch (e) {
|
||||
console.error("Restart failed:", e)
|
||||
setRestarting(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (isCrashed) {
|
||||
return (
|
||||
<div className="p-3 rounded-md bg-red-500/10 border border-red-500/30 text-red-400 flex items-center justify-between gap-3">
|
||||
<div className="flex items-center gap-2 text-sm">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
<span className="font-medium">Tiger crashed</span>
|
||||
<span className="text-red-400/80 text-xs">
|
||||
(exit 255 — {shortModel(data?.agent?.currentModel || "")} unreachable)
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRestart}
|
||||
disabled={restarting}
|
||||
className="border-red-500/30 text-red-400 hover:bg-red-500/10 h-7"
|
||||
>
|
||||
{restarting ? (
|
||||
<Loader2 className="h-3 w-3 mr-1.5 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-3 w-3 mr-1.5" />
|
||||
)}
|
||||
Restart
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (isOffline) {
|
||||
return (
|
||||
<div className="p-3 rounded-md bg-amber-500/10 border border-amber-500/30 text-amber-400 flex items-center gap-2 text-sm">
|
||||
<AlertCircle className="h-4 w-4 shrink-0" />
|
||||
<span>Bridge unreachable. Status data may be stale.</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const dotClass =
|
||||
data?.status === "online" ? "bg-green-500" :
|
||||
data?.status === "degraded" ? "bg-amber-500" :
|
||||
"bg-zinc-500"
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-4 px-4 py-2 rounded-md bg-card/30 border border-border/40 text-xs text-muted-foreground flex-wrap">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className={cn("h-2 w-2 rounded-full", dotClass)} />
|
||||
<span>up {uptimeShort(data?.container?.startedAt || "")}</span>
|
||||
</span>
|
||||
|
||||
<span className="tabular-nums">
|
||||
{data?.system?.memoryUsagePct ?? 0}% mem
|
||||
</span>
|
||||
|
||||
<span className="font-mono text-[11px]">
|
||||
{shortModel(data?.agent?.currentModel || "")}
|
||||
</span>
|
||||
|
||||
<span className="ml-auto text-muted-foreground/60">
|
||||
₹— today
|
||||
</span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -72,14 +72,15 @@ export function KanbanBoard() {
|
|||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
|
||||
// Load tasks from API
|
||||
// Load tasks from TASKS.md via /api/tiger/file-tasks
|
||||
const loadTasks = useCallback(async () => {
|
||||
setLoading(true)
|
||||
try {
|
||||
const data = await request("/api/tiger/tasks") as { ok: boolean; tasks?: Task[] }
|
||||
const data = await request("/api/tiger/file-tasks") as { ok: boolean; tasks?: any[] }
|
||||
if (data.ok && data.tasks) {
|
||||
setTasks(data.tasks.map((t: Task) => ({
|
||||
setTasks(data.tasks.map((t: any) => ({
|
||||
...t,
|
||||
content: t.title || t.content || t.description || "", // map title to content for kanban
|
||||
tags: typeof t.tags === "string" ? JSON.parse(t.tags || "[]") : t.tags || [],
|
||||
})))
|
||||
}
|
||||
|
|
|
|||
103
dashboard/src/components/telegram-thread-card.tsx
Normal file
103
dashboard/src/components/telegram-thread-card.tsx
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
"use client"
|
||||
|
||||
/**
|
||||
* telegram-thread-card.tsx — Preview of recent Telegram conversation
|
||||
*
|
||||
* PHASE 1: Renders an empty state today. Phase 4 of the plan adds a Telegram
|
||||
* listener in the bridge that writes inbound/outbound Telegram messages
|
||||
* with session_id = "telegram:<chat_id>". When that lands, this component
|
||||
* starts populating with zero additional frontend work.
|
||||
*/
|
||||
|
||||
import * as React from "react"
|
||||
import useSWR from "swr"
|
||||
import { Card } from "@/components/ui/card"
|
||||
import { Send } from "lucide-react"
|
||||
|
||||
interface TelegramMessage {
|
||||
role: "user" | "agent" | "system"
|
||||
content: string
|
||||
ts: number
|
||||
}
|
||||
|
||||
interface TelegramResponse {
|
||||
ok: boolean
|
||||
messages: TelegramMessage[]
|
||||
}
|
||||
|
||||
const fetcher = (url: string) =>
|
||||
fetch(url).then((r) => (r.ok ? r.json() : { ok: false, messages: [] }))
|
||||
|
||||
function relTime(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`
|
||||
const d = Math.floor(h / 24)
|
||||
return `${d}d ago`
|
||||
}
|
||||
|
||||
function truncate(s: string, max = 90): string {
|
||||
return s.length <= max ? s : s.slice(0, max - 1) + "…"
|
||||
}
|
||||
|
||||
export function TelegramThreadCard() {
|
||||
const { data } = useSWR<TelegramResponse>(
|
||||
"/api/chat?source=telegram&limit=5",
|
||||
fetcher,
|
||||
{ refreshInterval: 60_000, shouldRetryOnError: false }
|
||||
)
|
||||
|
||||
const messages = data?.messages ?? []
|
||||
const hasData = messages.length > 0
|
||||
|
||||
return (
|
||||
<Card className="bg-card/40 p-4 flex flex-col">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Send className="h-4 w-4 text-primary" />
|
||||
<span className="text-[11px] uppercase tracking-wider text-muted-foreground/80">
|
||||
Telegram thread
|
||||
</span>
|
||||
</div>
|
||||
<a href="/chat" className="text-xs text-primary hover:underline">
|
||||
Open chat →
|
||||
</a>
|
||||
</div>
|
||||
|
||||
{hasData ? (
|
||||
<ul className="space-y-2 flex-1">
|
||||
{messages.map((msg, i) => (
|
||||
<li key={i} className="text-sm">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="font-medium text-xs">
|
||||
{msg.role === "user" ? "You" : "Tiger"}
|
||||
</span>
|
||||
<span className="text-[10px] text-muted-foreground">
|
||||
{relTime(msg.ts)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-foreground/85 text-xs leading-relaxed mt-0.5">
|
||||
{truncate(msg.content)}
|
||||
</p>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<div className="flex-1 flex flex-col items-center justify-center text-center py-6 px-2">
|
||||
<Send className="h-8 w-8 text-muted-foreground/30 mb-2" />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
No Telegram messages mirrored yet.
|
||||
</p>
|
||||
<p className="text-[11px] text-muted-foreground/60 mt-1 max-w-[260px]">
|
||||
Phase 4 will sync your @Tiger_4321_bot conversation here so web
|
||||
and Telegram share one history.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
|
@ -177,6 +177,27 @@ export async function bridgePut(
|
|||
return res.json();
|
||||
}
|
||||
|
||||
|
||||
export async function bridgePatch(
|
||||
path: string,
|
||||
body: Record<string, unknown> = {}
|
||||
): Promise<unknown> {
|
||||
const res = await fetch(BRIDGE_URL + path, {
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...authHeaders(),
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
cache: 'no-store',
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errBody = await res.text().catch(() => '');
|
||||
throw new Error('Bridge PATCH ' + path + ' failed: ' + res.status + ' ' + errBody);
|
||||
}
|
||||
return res.json();
|
||||
}
|
||||
|
||||
export function bridgeLogsUrl(lines = 100, filter = ""): string {
|
||||
const url = new URL(`${BRIDGE_URL}/tiger/logs`);
|
||||
url.searchParams.set("lines", String(lines));
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue