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:
Manohar 2026-05-02 20:11:43 +00:00
parent 968d6fd178
commit 4ee0517345
27 changed files with 2269 additions and 1263 deletions

View 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>
)
}

View 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 });
}
}

View 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.",
});
}

View file

@ -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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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 });
}
}

View 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>
)
}

View file

@ -1,434 +1,32 @@
"use client" "use client"
import useSWR from 'swr' import { CommandBar } from "@/components/command-bar"
import { StatCard } from "@/components/stat-card" import { AgentStrip } from "@/components/agent-strip"
import { import { DigestCard } from "@/components/digest-card"
Activity, import { TelegramThreadCard } from "@/components/telegram-thread-card"
Bot, import { StatusFooter } from "@/components/status-footer"
Clock, import { ScheduleCard } from "@/components/schedule-card"
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)
}
}
export default function HomePage() {
return ( 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 */} {/* AGENTS — one strip, all 5 agents, live state at a glance. */}
{isCrashed && ( <AgentStrip />
<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>
)}
{/* Stat Cards Row */} {/* CONTEXT ROW — digest (left) + Telegram thread (right) */}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-4"> <div className="grid gap-4 md:grid-cols-2">
<Card className={cn( <DigestCard />
"bg-card/50", <TelegramThreadCard />
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>
</div> </div>
{/* Error State */} {/* SCHEDULE ROW — Tiger's cron jobs + next-run times */}
{isOffline && !isLoading && ( <ScheduleCard />
<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>
)}
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-7"> {/* FOOTER — system health strip. Becomes a banner on crash. */}
<StatusFooter />
{/* 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>
</div> </div>
) )
} }

View file

@ -1,189 +1,361 @@
/**
* Projects Page Project management with tasks
*
* Lists projects as cards and provides project detail view with Kanban.
*/
"use client" "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 * 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 { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Input } from "@/components/ui/input" import { Input } from "@/components/ui/input"
import { Badge } from "@/components/ui/badge" import { Badge } from "@/components/ui/badge"
import { import {
Dialog, Dialog, DialogContent, DialogDescription, DialogHeader,
DialogContent, DialogTitle, DialogTrigger,
DialogDescription,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@/components/ui/dialog" } from "@/components/ui/dialog"
import { import {
DropdownMenu, DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu" } from "@/components/ui/dropdown-menu"
import { useBridgeRequest } from "@/hooks/use-bridge"
import { cn } from "@/lib/utils" 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 id: string
name: string name: string
description: string description: string
status: string status: string
priority: string priority: string
created_at: string created_at: string
updated_at: string
} }
interface Task { // ─── Constants ────────────────────────────────────────────────────────────────
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
}
const PRIORITY_COLORS: Record<string, string> = { const PRIORITY_COLORS: Record<string, string> = {
low: "bg-gray-500/10 text-gray-400 border-gray-500/20", low: "bg-gray-500/10 text-gray-400 border-gray-500/20",
medium: "bg-blue-500/10 text-blue-400 border-blue-500/20", medium: "bg-blue-500/10 text-blue-400 border-blue-500/20",
high: "bg-amber-500/10 text-amber-400 border-amber-500/20", high: "bg-amber-500/10 text-amber-400 border-amber-500/20",
urgent: "bg-red-500/10 text-red-400 border-red-500/20", urgent: "bg-red-500/10 text-red-400 border-red-500/20",
} }
const STATUS_COLORS: Record<string, string> = { const TASK_STATUS_COLORS: Record<string, string> = {
active: "bg-green-500/10 text-green-400 border-green-500/20", "in-progress": "bg-amber-500/10 text-amber-400",
paused: "bg-yellow-500/10 text-yellow-400 border-yellow-500/20", review: "bg-purple-500/10 text-purple-400",
completed: "bg-emerald-500/10 text-emerald-400 border-emerald-500/20", done: "bg-emerald-500/10 text-emerald-400",
archived: "bg-gray-500/10 text-gray-400 border-gray-500/20", backlog: "bg-zinc-500/10 text-zinc-400",
} }
export default function ProjectsPage() { const AGENT_EMOJI: Record<string, string> = {
const { request } = useBridgeRequest() tiger: "🐯", main: "🐯",
const [projects, setProjects] = React.useState<Project[]>([]) cody: "💻", coder: "💻",
const [selectedProject, setSelectedProject] = React.useState<Project | null>(null) ethan: "🔍", researcher: "🔍",
const [tasks, setTasks] = React.useState<Task[]>([]) cathy: "✍️", writer: "✍️",
const [loading, setLoading] = React.useState(false) elon: "📊", pm: "📊",
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 AGENT_COLORS: Record<string, string> = {
const loadProjects = React.useCallback(async () => { tiger: "text-orange-400", main: "text-orange-400",
setLoading(true) cody: "text-blue-400", coder: "text-blue-400",
setError(null) ethan: "text-green-400", researcher: "text-green-400",
try { cathy: "text-pink-400", writer: "text-pink-400",
const data = await request("/api/tiger/projects") as { ok: boolean; projects?: Project[] } elon: "text-violet-400", pm: "text-violet-400",
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 // ─── Helpers ──────────────────────────────────────────────────────────────────
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(() => { const fetcher = (url: string) => fetch(url).then((r) => r.json())
loadProjects()
}, [loadProjects])
React.useEffect(() => { function statusBadgeClass(status: string) {
if (selectedProject) { const s = status.toLowerCase()
loadTasks(selectedProject.id) if (s.includes("progress") || s.includes("active") || s.includes("🔴") || s.includes("🔄"))
} return "bg-green-500/10 text-green-400 border-green-500/20"
}, [selectedProject, loadTasks]) if (s.includes("review"))
return "bg-purple-500/10 text-purple-400 border-purple-500/20"
if (s.includes("done") || s.includes("complete") || s.includes("approved"))
return "bg-emerald-500/10 text-emerald-400 border-emerald-500/20"
return "bg-zinc-500/10 text-zinc-400 border-zinc-500/20"
}
const handleCreateProject = async () => { function cleanText(s: string) {
if (!newProjectName.trim()) return // Strip emoji and markdown bold markers for display
setCreating(true) return s.replace(/\*\*/g, "").replace(/[✅⏳🔄❌🛢️🔍💻📊✍️🐯]/g, "").trim()
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 handleDeleteProject = async (id: string) => { // ─── Task row inside expanded project ─────────────────────────────────────────
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)
}
}
// Group tasks by status function TaskRow({ task }: { task: FileTask }) {
const tasksByStatus = React.useMemo(() => { const agentKey = task.assigned_agent?.toLowerCase() ?? ""
const grouped: Record<string, Task[]> = { return (
backlog: [], <div className="flex items-center gap-3 px-3 py-2 rounded-md hover:bg-muted/20 transition-colors">
ready: [], {/* Status badge */}
"in-progress": [], <span className={cn(
review: [], "text-[10px] font-medium px-1.5 py-0.5 rounded-full shrink-0 whitespace-nowrap",
done: [], TASK_STATUS_COLORS[task.status] ?? "bg-zinc-500/10 text-zinc-400"
} )}>
tasks.forEach(task => { {task.status_raw ? cleanText(task.status_raw).slice(0, 20) : task.status}
if (grouped[task.status]) { </span>
grouped[task.status].push(task)
} {/* Stage/task name */}
}) <span className="text-sm flex-1 truncate text-foreground/80">
return grouped {cleanText(task.title)}
}, [tasks]) </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 ( 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 */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
@ -192,178 +364,86 @@ export default function ProjectsPage() {
Projects Projects
</h1> </h1>
<p className="text-sm text-muted-foreground"> <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> </p>
</div> </div>
<Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}> <Dialog open={isCreateOpen} onOpenChange={setIsCreateOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
<Button> <Button><Plus className="h-4 w-4 mr-2" />New Project</Button>
<Plus className="h-4 w-4 mr-2" />
New Project
</Button>
</DialogTrigger> </DialogTrigger>
<DialogContent> <DialogContent>
<DialogHeader> <DialogHeader>
<DialogTitle>Create Project</DialogTitle> <DialogTitle>Queue a Project for Tiger</DialogTitle>
<DialogDescription>Create a new project to organize tasks.</DialogDescription> <DialogDescription>
Tiger will pick this up and add it to PROJECTS.md.
</DialogDescription>
</DialogHeader> </DialogHeader>
<div className="space-y-4 pt-4"> <div className="space-y-4 pt-2">
<div> <div>
<label className="text-sm font-medium">Name</label> <label className="text-sm font-medium">Name</label>
<Input <Input value={form.name} onChange={(e) => setForm((f) => ({ ...f, name: e.target.value }))} placeholder="e.g. BESS Economics Model" className="mt-1" />
value={newProjectName}
onChange={(e) => setNewProjectName(e.target.value)}
placeholder="Project name"
className="mt-1"
/>
</div> </div>
<div> <div>
<label className="text-sm font-medium">Description</label> <label className="text-sm font-medium">Seed text <span className="text-muted-foreground font-normal">(Tiger generates title + goal)</span></label>
<Input <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" />
value={newProjectDesc}
onChange={(e) => setNewProjectDesc(e.target.value)}
placeholder="Project description"
className="mt-1"
/>
</div> </div>
<div> <div>
<label className="text-sm font-medium">Priority</label> <label className="text-sm font-medium">Priority</label>
<select <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">
value={newProjectPriority} {["low", "medium", "high", "urgent"].map((p) => <option key={p} value={p}>{p.charAt(0).toUpperCase() + p.slice(1)}</option>)}
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> </select>
</div> </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" />} {creating && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
Create Project Queue Project
</Button> </Button>
</div> </div>
</DialogContent> </DialogContent>
</Dialog> </Dialog>
</div> </div>
{error && ( {isLoading ? (
<div className="p-3 rounded-md bg-destructive/10 text-destructive text-sm">{error}</div>
)}
{/* Projects Grid */}
{loading ? (
<div className="flex items-center justify-center py-20"> <div className="flex items-center justify-center py-20">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div> </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>
) : ( ) : (
<div className="grid gap-4 md:grid-cols-2 lg:grid-cols-3"> <div className="space-y-8">
{projects.map((project) => ( {/* Tiger's PROJECTS.md — primary */}
<Card <div>
key={project.id} <div className="flex items-center gap-2 mb-3">
className={cn( <FolderOpen className="h-4 w-4 text-primary" />
"cursor-pointer hover:bg-muted/50 transition-colors", <span className="text-sm font-semibold">Tiger's Projects</span>
selectedProject?.id === project.id && "ring-2 ring-primary" <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>
onClick={() => setSelectedProject(project)} </div>
> {fileProjects.length === 0 ? (
<CardHeader className="pb-2"> <div className="text-center py-10 text-muted-foreground">
<div className="flex items-start justify-between"> <FolderOpen className="h-10 w-10 mx-auto mb-3 opacity-20" />
<CardTitle className="text-lg">{project.name}</CardTitle> <p className="text-sm">No projects in PROJECTS.md yet.</p>
<DropdownMenu> </div>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}> ) : (
<Button variant="ghost" size="icon" className="h-8 w-8"> <div className="flex flex-col gap-3">
<MoreVertical className="h-4 w-4" /> {fileProjects.map((p) => <FileProjectCard key={p.id} project={p} />)}
</Button> </div>
</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>
)}
{/* 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> </div>
{/* Simple Kanban - Tasks by Status */} {/* Dashboard queue — secondary */}
{loadingTasks ? ( {dbProjects.length > 0 && (
<div className="flex items-center justify-center py-10"> <div className="border-t border-border/40 pt-6">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" /> <div className="flex items-center gap-2 mb-3">
</div> <Inbox className="h-4 w-4 text-blue-400" />
) : ( <span className="text-sm font-semibold text-blue-400">Dashboard Queue</span>
<div className="grid gap-4 md:grid-cols-5"> <span className="text-xs text-muted-foreground bg-muted px-1.5 rounded-full ml-1">{dbProjects.length}</span>
{(["backlog", "ready", "in-progress", "review", "done"] as const).map((status) => ( </div>
<div key={status} className="space-y-2"> <p className="text-xs text-muted-foreground mb-3">
<div className="text-sm font-medium text-muted-foreground uppercase tracking-wider px-2"> Projects you've queued Tiger will add them to PROJECTS.md when he processes them.
{status.replace("-", " ")} ({tasksByStatus[status].length}) </p>
</div> <div className="flex flex-col gap-3">
<div className="space-y-2"> {dbProjects.map((p) => <DbProjectCard key={p.id} project={p} onDelete={handleDelete} />)}
{tasksByStatus[status].map((task) => ( </div>
<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>
)} )}
</div> </div>

View file

@ -1,12 +1,14 @@
"use client" "use client"
/** /**
* settings/page.tsx Tiger configuration * settings/page.tsx Tiger configuration + API key management
* *
* Sections: * Sections (in order):
* 1. Model primary model dropdown + fallback models * 0. API Keys & Router ANTHROPIC, OPENROUTER, TELEGRAM keys + TIGER_ROUTER_MODEL
* 2. Session dmScope, compaction mode * 1. Model OpenClaw global model dropdown + fallbacks + compaction
* 3. Telegram enabled toggle, streaming mode * 2. Session dmScope
* 4. Commands native commands, ownerDisplay * 3. Telegram enabled toggle, streaming mode
* 4. Commands native commands, ownerDisplay, restart
*/ */
import * as React from "react" import * as React from "react"
@ -14,6 +16,7 @@ import useSWR from "swr"
import { import {
Settings2, Save, Loader2, RefreshCw, Settings2, Save, Loader2, RefreshCw,
Bot, MessageSquare, Terminal, Cpu, AlertCircle, Check, Bot, MessageSquare, Terminal, Cpu, AlertCircle, Check,
Key, RotateCcw,
} from "lucide-react" } from "lucide-react"
import { Button } from "@/components/ui/button" import { Button } from "@/components/ui/button"
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card" import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "@/components/ui/card"
@ -31,12 +34,22 @@ interface ModelInfo {
} }
interface OpenClawConfig { 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 } session?: { dmScope?: string }
channels?: { telegram?: { enabled?: boolean; streaming?: string } } channels?: { telegram?: { enabled?: boolean; streaming?: string } }
commands?: { native?: string; ownerDisplay?: string; restart?: boolean } commands?: { native?: string; ownerDisplay?: string; restart?: boolean }
} }
interface KeyPresence {
isSet: boolean
preview?: string
}
// ─── Helpers ────────────────────────────────────────────────────────────────── // ─── Helpers ──────────────────────────────────────────────────────────────────
const fetcher = (url: string) => fetch(url).then(r => r.json()) 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 return result
} }
// ─── Sub-components ─────────────────────────────────────────────────────────── // ─── Shared sub-components ────────────────────────────────────────────────────
function SettingRow({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) { function SettingRow({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return ( return (
@ -107,26 +120,17 @@ function SelectInput({ value, options, onChange }: {
) )
} }
// ─── Model dropdown component ─────────────────────────────────────────────────
function ModelSelect({ value, models, onChange }: { function ModelSelect({ value, models, onChange }: {
value: string value: string
models: ModelInfo[] models: ModelInfo[]
onChange: (v: string) => void onChange: (v: string) => void
}) { }) {
// Group by provider
const grouped = models.reduce<Record<string, ModelInfo[]>>((acc, m) => { const grouped = models.reduce<Record<string, ModelInfo[]>>((acc, m) => {
if (!acc[m.provider]) acc[m.provider] = [] if (!acc[m.provider]) acc[m.provider] = []
acc[m.provider].push(m) acc[m.provider].push(m)
return acc return acc
}, {}) }, {})
const providerLabels: Record<string, string> = {
minimax: "MiniMax",
"minimax-portal": "MiniMax Portal",
openrouter: "OpenRouter",
}
const current = models.find(m => m.id === value) const current = models.find(m => m.id === value)
return ( 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" 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]) => ( {Object.entries(grouped).map(([prov, mods]) => (
<optgroup key={prov} label={providerLabels[prov] ?? prov}> <optgroup key={prov} label={prov}>
{mods.map(m => ( {mods.map(m => (
<option key={m.id} value={m.id}>{m.name}</option> <option key={m.id} value={m.id}>{m.name}</option>
))} ))}
</optgroup> </optgroup>
))} ))}
</select> </select>
{/* Model detail badge row */}
{current && ( {current && (
<div className="flex items-center gap-2 flex-wrap"> <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"> <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 && ( {current.contextWindow > 0 && (
<span className="text-xs text-muted-foreground"> <span className="text-xs text-muted-foreground">
{current.contextWindow >= 1000000 {current.contextWindow >= 1_000_000
? `${(current.contextWindow / 1000000).toFixed(1)}M ctx` ? `${(current.contextWindow / 1_000_000).toFixed(1)}M ctx`
: `${Math.round(current.contextWindow / 1000)}K ctx`} : `${Math.round(current.contextWindow / 1_000)}K ctx`}
</span>
)}
{current.cost && (
<span className="text-xs text-muted-foreground">
${current.cost.input}/M in · ${current.cost.output}/M out
</span> </span>
)} )}
</div> </div>
@ -174,8 +171,6 @@ function ModelSelect({ value, models, onChange }: {
) )
} }
// ─── Section card wrapper ─────────────────────────────────────────────────────
function SectionCard({ icon: Icon, title, description, children, dirty }: { function SectionCard({ icon: Icon, title, description, children, dirty }: {
icon: React.ElementType icon: React.ElementType
title: string 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 &amp; 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 ──────────────────────────────────────────────────────────────── // ─── Main page ────────────────────────────────────────────────────────────────
export default function SettingsPage() { export default function SettingsPage() {
// Raw config from bridge
const { data: configData, mutate: mutateConfig, isLoading: configLoading } = const { data: configData, mutate: mutateConfig, isLoading: configLoading } =
useSWR<{ ok: boolean; config: OpenClawConfig }>("/api/tiger/config", fetcher) useSWR<{ ok: boolean; config: OpenClawConfig }>("/api/tiger/config", fetcher)
// Available models
const { data: modelsData, isLoading: modelsLoading } = const { data: modelsData, isLoading: modelsLoading } =
useSWR<{ ok: boolean; models: ModelInfo[] }>("/api/tiger/config/models", fetcher) useSWR<{ ok: boolean; models: ModelInfo[] }>("/api/tiger/config/models", fetcher)
const remoteConfig = configData?.config ?? {} const remoteConfig = configData?.config ?? {}
const models = modelsData?.models ?? [] const models = modelsData?.models ?? []
// ── Local draft — tracks unsaved edits ─────────────────────────────────────
const [draft, setDraft] = React.useState<OpenClawConfig>({}) const [draft, setDraft] = React.useState<OpenClawConfig>({})
const [initialized, setInitialized] = React.useState(false) const [initialized, setInitialized] = React.useState(false)
@ -225,18 +413,12 @@ export default function SettingsPage() {
} }
}, [configData, initialized]) }, [configData, initialized])
const update = (path: string, value: any) => { const update = (path: string, value: any) => setDraft(prev => set(prev, path, value))
setDraft(prev => set(prev, path, value))
}
const g = (path: string, fallback: any = "") => get(draft, path, fallback) const g = (path: string, fallback: any = "") => get(draft, path, fallback)
const r = (path: string, fallback: any = "") => get(remoteConfig, 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 isDirty = (path: string) => JSON.stringify(g(path)) !== JSON.stringify(r(path))
const anyDirty = JSON.stringify(draft) !== JSON.stringify(remoteConfig) const anyDirty = JSON.stringify(draft) !== JSON.stringify(remoteConfig)
// ── Save ────────────────────────────────────────────────────────────────────
const [saving, setSaving] = React.useState(false) const [saving, setSaving] = React.useState(false)
const [saveState, setSaveState] = React.useState<"idle" | "ok" | "err">("idle") const [saveState, setSaveState] = React.useState<"idle" | "ok" | "err">("idle")
const [saveError, setSaveError] = React.useState("") const [saveError, setSaveError] = React.useState("")
@ -254,7 +436,7 @@ export default function SettingsPage() {
if (!data.ok) throw new Error(data.error ?? "Save failed") if (!data.ok) throw new Error(data.error ?? "Save failed")
setSaveState("ok") setSaveState("ok")
await mutateConfig() await mutateConfig()
setInitialized(false) // re-sync draft from fresh server data setInitialized(false)
setTimeout(() => setSaveState("idle"), 3000) setTimeout(() => setSaveState("idle"), 3000)
} catch (err: any) { } catch (err: any) {
setSaveError(err.message) setSaveError(err.message)
@ -271,7 +453,6 @@ export default function SettingsPage() {
const loading = configLoading || modelsLoading || !initialized const loading = configLoading || modelsLoading || !initialized
// ── Render ──────────────────────────────────────────────────────────────────
return ( return (
<div className="flex flex-col gap-6 p-6 max-w-3xl"> <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 <Settings2 className="h-6 w-6" /> Settings
</h1> </h1>
<p className="text-sm text-muted-foreground mt-1"> <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> </p>
</div> </div>
<div className="flex items-center gap-2"> <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" /> ? <Loader2 className="h-4 w-4 mr-1 animate-spin" />
: saveState === "ok" : saveState === "ok"
? <Check className="h-4 w-4 mr-1" /> ? <Check className="h-4 w-4 mr-1" />
: <Save className="h-4 w-4 mr-1" />} : <Save className="h-4 w-4 mr-1" />
{saving ? "Saving…" : saveState === "ok" ? "Saved!" : "Save Changes"} }
{saving ? "Saving…" : saveState === "ok" ? "Saved!" : "Save Config"}
</Button> </Button>
</div> </div>
</div> </div>
{/* Save error */}
{saveState === "err" && ( {saveState === "err" && (
<div className="flex items-center gap-2 p-3 rounded-md bg-destructive/10 text-destructive text-sm"> <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" /> <AlertCircle className="h-4 w-4 shrink-0" />
@ -308,35 +489,31 @@ export default function SettingsPage() {
</div> </div>
)} )}
{/* ── 0. API Keys (always rendered — has its own data fetching) ─── */}
<ApiKeysSection />
{loading ? ( {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" /> <Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</div> </div>
) : ( ) : (
<div className="space-y-6"> <div className="space-y-6">
{/* ── 1. Model ───────────────────────────────────────────────────── */} {/* ── 1. Model ─────────────────────────────────────────────────── */}
<SectionCard <SectionCard
icon={Cpu} icon={Cpu}
title="Model" 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")} dirty={isDirty("agents.defaults.model.primary") || isDirty("agents.defaults.model.fallbacks")}
> >
<SettingRow <SettingRow label="Primary model" hint="Active model for all agents (unless overridden)">
label="Primary model"
hint="Active model for all agents"
>
<ModelSelect <ModelSelect
value={g("agents.defaults.model.primary", "")} value={g("agents.defaults.model.primary", "")}
models={models} models={models}
onChange={v => update("agents.defaults.model.primary", v)} onChange={v => update("agents.defaults.model.primary", v)}
/> />
</SettingRow> </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 <input
type="text" type="text"
value={(g("agents.defaults.model.fallbacks", []) as string[]).join(", ")} 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" 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>
<SettingRow label="Compaction mode" hint="How Tiger handles context window limits">
<SettingRow
label="Compaction mode"
hint="How Tiger handles context window limits"
>
<SelectInput <SelectInput
value={g("agents.defaults.compaction.mode", "safeguard")} value={g("agents.defaults.compaction.mode", "safeguard")}
options={[ options={[
@ -365,17 +538,14 @@ export default function SettingsPage() {
</SettingRow> </SettingRow>
</SectionCard> </SectionCard>
{/* ── 2. Session ─────────────────────────────────────────────────── */} {/* ── 2. Session ───────────────────────────────────────────────── */}
<SectionCard <SectionCard
icon={Bot} icon={Bot}
title="Session" title="Session"
description="How Tiger manages conversation sessions and identity scoping." description="Conversation session and identity scoping."
dirty={isDirty("session.dmScope")} dirty={isDirty("session.dmScope")}
> >
<SettingRow <SettingRow label="DM scope" hint="Context isolation between Telegram chats">
label="DM scope"
hint="How Tiger isolates context between different Telegram chats"
>
<SelectInput <SelectInput
value={g("session.dmScope", "per-channel-peer")} value={g("session.dmScope", "per-channel-peer")}
options={[ options={[
@ -388,7 +558,7 @@ export default function SettingsPage() {
</SettingRow> </SettingRow>
</SectionCard> </SectionCard>
{/* ── 3. Telegram ────────────────────────────────────────────────── */} {/* ── 3. Telegram ──────────────────────────────────────────────── */}
<SectionCard <SectionCard
icon={MessageSquare} icon={MessageSquare}
title="Telegram" title="Telegram"
@ -401,11 +571,7 @@ export default function SettingsPage() {
onChange={v => update("channels.telegram.enabled", v)} onChange={v => update("channels.telegram.enabled", v)}
/> />
</SettingRow> </SettingRow>
<SettingRow label="Streaming" hint="How Tiger sends updates while generating">
<SettingRow
label="Streaming"
hint="How Tiger sends message updates while generating"
>
<SelectInput <SelectInput
value={g("channels.telegram.streaming", "partial")} value={g("channels.telegram.streaming", "partial")}
options={[ options={[
@ -418,17 +584,14 @@ export default function SettingsPage() {
</SettingRow> </SettingRow>
</SectionCard> </SectionCard>
{/* ── 4. Commands ────────────────────────────────────────────────── */} {/* ── 4. Commands ──────────────────────────────────────────────── */}
<SectionCard <SectionCard
icon={Terminal} icon={Terminal}
title="Commands" 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")} dirty={isDirty("commands.native") || isDirty("commands.ownerDisplay") || isDirty("commands.restart")}
> >
<SettingRow <SettingRow label="Native commands" hint="Whether Tiger can run shell commands on the host">
label="Native commands"
hint="Whether Tiger can run shell commands on the host"
>
<SelectInput <SelectInput
value={g("commands.native", "auto")} value={g("commands.native", "auto")}
options={[ options={[
@ -439,25 +602,17 @@ export default function SettingsPage() {
onChange={v => update("commands.native", v)} onChange={v => update("commands.native", v)}
/> />
</SettingRow> </SettingRow>
<SettingRow label="Owner display" hint="How your name appears to Tiger">
<SettingRow
label="Owner display"
hint="How Manohar's name appears to Tiger in context"
>
<SelectInput <SelectInput
value={g("commands.ownerDisplay", "raw")} value={g("commands.ownerDisplay", "raw")}
options={[ options={[
{ value: "raw", label: "Raw — show as-is" }, { value: "raw", label: "Raw — as-is" },
{ value: "formatted", label: "Formatted — display name" }, { value: "formatted", label: "Formatted — display name" },
]} ]}
onChange={v => update("commands.ownerDisplay", v)} onChange={v => update("commands.ownerDisplay", v)}
/> />
</SettingRow> </SettingRow>
<SettingRow label="Allow restart" hint="Tiger can restart itself when needed">
<SettingRow
label="Allow restart"
hint="Tiger can restart itself when needed"
>
<Toggle <Toggle
checked={g("commands.restart", true)} checked={g("commands.restart", true)}
onChange={v => update("commands.restart", v)} onChange={v => update("commands.restart", v)}

View file

@ -1,85 +1,287 @@
import { KanbanBoard } from "@/components/tasks/kanban-board" "use client"
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from "@/components/ui/card"
import { CheckSquare, GitBranch, Bot, Clock } from "lucide-react"
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 ( return (
<div className="space-y-6"> <div className="flex items-center gap-3 px-4 py-2.5 rounded-lg hover:bg-muted/30 transition-colors">
<div className="flex items-center justify-between"> <div className="flex-1 min-w-0">
<div> <div className="text-sm font-medium truncate text-foreground/90">{task.title}</div>
<h1 className="text-2xl font-bold flex items-center gap-2"> {task.project && (
<CheckSquare className="h-6 w-6 text-primary" /> <div className="text-xs text-muted-foreground truncate mt-0.5">{task.project}</div>
Task Progress )}
</h1>
<p className="text-muted-foreground">
Manage and track tasks across your team and AI sub-agents
</p>
</div>
</div> </div>
{agentKey && (
{/* Quick Stats */} <span className={cn("text-xs font-medium shrink-0", AGENT_COLORS[agentKey] ?? "text-muted-foreground")}>
<div className="grid gap-4 md:grid-cols-4"> {AGENT_EMOJI[agentKey] ?? ""} <span className="hidden sm:inline">{agentKey}</span>
<Card className="bg-card/40"> </span>
<CardContent className="pt-6"> )}
<div className="flex items-center justify-between"> <span className="text-xs text-muted-foreground shrink-0 italic">{task.status_raw}</span>
<div> </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" /> // ─── Section (Tiger's tasks grouped by status) ────────────────────────────────
</div>
</CardContent> function FileTaskSection({ status, tasks }: { status: string; tasks: FileTask[] }) {
</Card> const [collapsed, setCollapsed] = React.useState(status === "done")
if (tasks.length === 0) return null
<Card className="bg-card/40"> return (
<CardContent className="pt-6"> <div>
<div className="flex items-center justify-between"> <button
<div> className="flex items-center gap-2 w-full text-left mb-2 group"
<p className="text-sm text-muted-foreground">In Progress</p> onClick={() => setCollapsed((v) => !v)}
<p className="text-2xl font-bold"></p> >
</div> <span className={cn("text-xs font-semibold uppercase tracking-widest", SECTION_COLORS[status] ?? "text-zinc-400")}>
<GitBranch className="h-5 w-5 text-amber-400" /> {SECTION_LABELS[status] ?? status}
</div> </span>
</CardContent> <span className="text-xs text-muted-foreground bg-muted px-1.5 py-0 rounded-full">{tasks.length}</span>
</Card> <span className="text-xs text-muted-foreground ml-auto opacity-0 group-hover:opacity-100 transition-opacity">
{collapsed ? "expand" : "collapse"}
<Card className="bg-card/40"> </span>
<CardContent className="pt-6"> </button>
<div className="flex items-center justify-between"> {!collapsed && (
<div> <div className="space-y-0.5">
<p className="text-sm text-muted-foreground">AI Delegated</p> {tasks.map((t) => <FileTaskRow key={t.id} task={t} />)}
<p className="text-2xl font-bold"></p> </div>
</div> )}
<Bot className="h-5 w-5 text-violet-400" /> <div className="mt-4" />
</div> </div>
</CardContent> )
</Card> }
<Card className="bg-card/40"> // ─── Queue task row (SQLite dispatch queue) ───────────────────────────────────
<CardContent className="pt-6">
<div className="flex items-center justify-between"> function QueueTaskRow({ task }: { task: QueueTask }) {
<div> const warn = isRouterFailure(task.agent_reason)
<p className="text-sm text-muted-foreground">Completed Today</p> return (
<p className="text-2xl font-bold"></p> <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> <div className="flex-1 min-w-0">
<CheckSquare className="h-5 w-5 text-emerald-400" /> <div className="text-sm font-medium truncate text-foreground/70">{task.title}</div>
</div> <div className="text-xs text-muted-foreground mt-0.5">Queued waiting for Tiger</div>
</CardContent> </div>
</Card> {warn && (
</div> <span title={task.agent_reason ?? ""} className="shrink-0">
<AlertTriangle className="h-3.5 w-3.5 text-amber-400/80" />
{/* Kanban Board */} </span>
<Card className="bg-card/40"> )}
<CardHeader> <Badge className={cn("text-[10px] px-1.5 py-0 shrink-0", PRIORITY_COLORS[task.priority ?? "medium"])}>
<CardTitle>Task Board</CardTitle> {task.priority}
<CardDescription> </Badge>
Drag and drop tasks between columns. Click a task to edit or assign to an AI agent. </div>
</CardDescription> )
</CardHeader> }
<CardContent>
<KanbanBoard /> // ─── Main page ────────────────────────────────────────────────────────────────
</CardContent>
</Card> 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> </div>
) )
} }

View 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>
)
}

View file

@ -1,16 +1,35 @@
"use client" "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 * as React from "react"
import { import {
Bot, Home,
Settings2,
LayoutDashboard,
MessageSquare, MessageSquare,
ScrollText,
CheckSquare,
DollarSign,
FolderOpen,
Briefcase, Briefcase,
Bot,
Brain,
FolderOpen,
DollarSign,
ScrollText,
Settings2,
} from "lucide-react" } from "lucide-react"
import { useTigerLogs } from "@/hooks/use-bridge" import { useTigerLogs } from "@/hooks/use-bridge"
@ -25,56 +44,27 @@ import {
SidebarRail, SidebarRail,
} from "@/components/ui/sidebar" } 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 = [ const navMain = [
{ { title: "Home", url: "/", icon: Home },
title: "Dashboard", { title: "Chat", url: "/chat", icon: MessageSquare },
url: "/", { title: "Projects", url: "/projects", icon: Briefcase },
icon: LayoutDashboard, { title: "Agents", url: "/agents", icon: Bot },
}, { title: "Knowledge", url: "/knowledge", icon: Brain },
{ { title: "Workspace", url: "/workspace", icon: FolderOpen },
title: "Chat", { title: "Cost", url: "/cost", icon: DollarSign },
url: "/chat", { title: "Logs", url: "/logs", icon: ScrollText },
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,
},
] ]
// Secondary navigation — sits in the footer, less-frequent admin stuff.
const navSecondary = [ const navSecondary = [
{ { title: "Settings", url: "/settings", icon: Settings2 },
title: "Settings",
url: "/settings",
icon: Settings2,
},
] ]
export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) { export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
// Use Tiger logs SSE for connection status // Use the existing log stream as a heartbeat — if it's connected, the bridge
// connected means the bridge is reachable // is reachable, so we're "Live". If not, the dot goes red.
const { connected } = useTigerLogs({ lines: 1, maxLines: 1 }) const { connected } = useTigerLogs({ lines: 1, maxLines: 1 })
return ( return (
@ -84,46 +74,57 @@ export function AppSidebar({ ...props }: React.ComponentProps<typeof Sidebar>) {
<SidebarMenuItem> <SidebarMenuItem>
<SidebarMenuButton size="lg" asChild> <SidebarMenuButton size="lg" asChild>
<a href="/"> <a href="/">
<div className="flex aspect-square size-8 items-center justify-center rounded-lg bg-sidebar-primary text-sidebar-primary-foreground"> {/* Tiger badge — same gradient T as the favicon for brand consistency */}
<Bot className="size-4" /> <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>
<div className="flex items-center gap-2"> <div className="flex flex-col gap-0.5 leading-none">
<span className={`h-2 w-2 rounded-full ${connected ? 'bg-green-500' : 'bg-red-500'}`} /> <span className="text-sm font-semibold">Tiger</span>
<span className="text-xs text-muted-foreground">{connected ? "Live" : "Offline"}</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> </div>
</a> </a>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
</SidebarMenu> </SidebarMenu>
</SidebarHeader> </SidebarHeader>
<SidebarContent> <SidebarContent>
<SidebarMenu className="gap-2 p-2"> <SidebarMenu className="gap-1 p-2">
{navMain.map((item) => ( {navMain.map((item) => (
<SidebarMenuItem key={item.title}> <SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild tooltip={item.title}> <SidebarMenuButton asChild tooltip={item.title}>
<a href={item.url}> <a href={item.url}>
<item.icon /> <item.icon />
<span>{item.title}</span> <span>{item.title}</span>
</a> </a>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
))} ))}
</SidebarMenu> </SidebarMenu>
</SidebarContent> </SidebarContent>
<SidebarFooter> <SidebarFooter>
<SidebarMenu> <SidebarMenu>
{navSecondary.map((item) => ( {navSecondary.map((item) => (
<SidebarMenuItem key={item.title}> <SidebarMenuItem key={item.title}>
<SidebarMenuButton asChild size="sm"> <SidebarMenuButton asChild size="sm" tooltip={item.title}>
<a href={item.url}> <a href={item.url}>
<item.icon /> <item.icon />
<span>{item.title}</span> <span>{item.title}</span>
</a> </a>
</SidebarMenuButton> </SidebarMenuButton>
</SidebarMenuItem> </SidebarMenuItem>
))} ))}
</SidebarMenu> </SidebarMenu>
</SidebarFooter> </SidebarFooter>
<SidebarRail /> <SidebarRail />
</Sidebar> </Sidebar>
) )

View file

@ -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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View file

@ -72,14 +72,15 @@ export function KanbanBoard() {
const [loading, setLoading] = useState(true) const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false) const [saving, setSaving] = useState(false)
// Load tasks from API // Load tasks from TASKS.md via /api/tiger/file-tasks
const loadTasks = useCallback(async () => { const loadTasks = useCallback(async () => {
setLoading(true) setLoading(true)
try { 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) { if (data.ok && data.tasks) {
setTasks(data.tasks.map((t: Task) => ({ setTasks(data.tasks.map((t: any) => ({
...t, ...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 || [], tags: typeof t.tags === "string" ? JSON.parse(t.tags || "[]") : t.tags || [],
}))) })))
} }

View 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>
)
}

View file

@ -177,6 +177,27 @@ export async function bridgePut(
return res.json(); 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 { export function bridgeLogsUrl(lines = 100, filter = ""): string {
const url = new URL(`${BRIDGE_URL}/tiger/logs`); const url = new URL(`${BRIDGE_URL}/tiger/logs`);
url.searchParams.set("lines", String(lines)); url.searchParams.set("lines", String(lines));