Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12,368 changes: 12,368 additions & 0 deletions interface/package-lock.json

Large diffs are not rendered by default.

5 changes: 2 additions & 3 deletions interface/src/components/AgentTabs.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,13 @@ import { Link, useMatchRoute } from "@tanstack/react-router";
import { motion } from "framer-motion";

const tabs = [
{ label: "Overview", to: "/agents/$agentId" as const, exact: true },
{ label: "Board", to: "/agents/$agentId" as const, exact: true },
{ label: "Overview", to: "/agents/$agentId/overview" as const, exact: false },
{ label: "Chat", to: "/agents/$agentId/chat" as const, exact: false },
{ label: "Channels", to: "/agents/$agentId/channels" as const, exact: false },
{ label: "Memories", to: "/agents/$agentId/memories" as const, exact: false },
{ label: "Ingest", to: "/agents/$agentId/ingest" as const, exact: false },
{ label: "Workers", to: "/agents/$agentId/workers" as const, exact: false },
{ label: "Projects", to: "/agents/$agentId/projects" as const, exact: false },
{ label: "Tasks", to: "/agents/$agentId/tasks" as const, exact: false },
{ label: "Cortex", to: "/agents/$agentId/cortex" as const, exact: false },
{ label: "Skills", to: "/agents/$agentId/skills" as const, exact: false },
{ label: "Cron", to: "/agents/$agentId/cron" as const, exact: false },
Expand Down
76 changes: 76 additions & 0 deletions interface/src/components/DiffViewer.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
import { useMemo } from "react";

interface DiffViewerProps {
diff: string;
maxHeight?: string;
}

interface DiffLine {
type: "add" | "remove" | "context" | "header" | "hunk";
content: string;
lineNumber?: number;
}

export function DiffViewer({ diff, maxHeight = "400px" }: DiffViewerProps) {
const lines = useMemo(() => parseDiff(diff), [diff]);

if (!diff.trim()) {
return (
<div className="flex items-center justify-center rounded-md border border-app-line bg-app-darkBox p-4 text-sm text-ink-faint">
No changes to display
</div>
);
}

return (
<div
className="overflow-auto rounded-md border border-app-line bg-app-darkBox font-mono text-xs"
style={{ maxHeight }}
>
{lines.map((line, i) => (
<div
key={i}
className={`whitespace-pre px-3 py-0.5 ${lineClass(line.type)}`}
>
{line.content}
</div>
))}
</div>
);
}

function lineClass(type: DiffLine["type"]): string {
switch (type) {
case "add":
return "bg-emerald-500/10 text-emerald-300";
case "remove":
return "bg-red-500/10 text-red-300";
case "header":
return "bg-accent/10 text-accent font-semibold sticky top-0";
case "hunk":
return "bg-app-box text-ink-faint";
default:
return "text-ink-dull";
}
}

function parseDiff(raw: string): DiffLine[] {
return raw.split("\n").map((content) => {
if (content.startsWith("+++") || content.startsWith("---")) {
return { type: "header" as const, content };
}
if (content.startsWith("diff ")) {
return { type: "header" as const, content };
}
if (content.startsWith("@@")) {
return { type: "hunk" as const, content };
}
if (content.startsWith("+")) {
return { type: "add" as const, content };
}
if (content.startsWith("-")) {
return { type: "remove" as const, content };
}
return { type: "context" as const, content };
});
}
12 changes: 11 additions & 1 deletion interface/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ import { CSS } from "@dnd-kit/utilities";
import { api } from "@/api/client";
import type { ChannelLiveState } from "@/hooks/useChannelLiveState";
import { useAgentOrder } from "@/hooks/useAgentOrder";
import { DashboardSquare01Icon, Settings01Icon } from "@hugeicons/core-free-icons";
import { DashboardSquare01Icon, Settings01Icon, Dollar02Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/react";
import { CreateAgentDialog } from "@/components/CreateAgentDialog";
import { ProfileAvatar } from "@/components/ProfileAvatar";
Expand Down Expand Up @@ -112,6 +112,7 @@ export function Sidebar({ liveStates: _liveStates }: SidebarProps) {
const matchRoute = useMatchRoute();
const isOverview = matchRoute({ to: "/" });
const isSettings = matchRoute({ to: "/settings" });
const isPricing = matchRoute({ to: "/pricing" });

const sensors = useSensors(
useSensor(PointerSensor, {
Expand Down Expand Up @@ -156,6 +157,15 @@ export function Sidebar({ liveStates: _liveStates }: SidebarProps) {
>
<HugeiconsIcon icon={Settings01Icon} className="h-4 w-4" />
</Link>
<Link
to="/pricing"
className={`flex h-8 w-8 items-center justify-center rounded-md ${
isPricing ? "bg-sidebar-selected text-sidebar-ink" : "text-sidebar-inkDull hover:bg-sidebar-selected/50"
}`}
title="Pricing"
>
<HugeiconsIcon icon={Dollar02Icon} className="h-4 w-4" />
</Link>
<div className="my-1 h-px w-5 bg-sidebar-line" />
<DndContext
sensors={sensors}
Expand Down
222 changes: 222 additions & 0 deletions interface/src/components/TaskDependencyGraph.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,222 @@
import { useEffect, useRef, useCallback, useState } from "react";
import Graph from "graphology";
import Sigma from "sigma";
import { EdgeArrowProgram } from "sigma/rendering";
import type { TaskItem } from "@/api/client";

const STATUS_COLORS: Record<string, string> = {
pending_approval: "#f59e0b",
backlog: "#6b7280",
ready: "#3b82f6",
in_progress: "#a855f7",
done: "#22c55e",
};

const PRIORITY_SIZES: Record<string, number> = {
critical: 14,
high: 11,
medium: 8,
low: 6,
};

interface TaskDependencyGraphProps {
tasks: TaskItem[];
onSelectTask?: (taskNumber: number) => void;
}

export function TaskDependencyGraph({
tasks,
onSelectTask,
}: TaskDependencyGraphProps) {
const containerRef = useRef<HTMLDivElement>(null);
const sigmaRef = useRef<Sigma | null>(null);
const graphRef = useRef<Graph | null>(null);
const [hoveredNode, setHoveredNode] = useState<string | null>(null);

const buildGraph = useCallback(() => {
const graph = new Graph({ type: "directed" });

// Add nodes
for (const task of tasks) {
const key = String(task.task_number);
const deps = getDeps(task);
const isBlocked =
deps.length > 0 &&
deps.some((d) => {
const dt = tasks.find((t) => t.task_number === d);
return dt && dt.status !== "done";
});

graph.addNode(key, {
label: `#${task.task_number} ${task.title.slice(0, 25)}${task.title.length > 25 ? "…" : ""}`,
size: PRIORITY_SIZES[task.priority] ?? 8,
color: isBlocked ? "#ef4444" : (STATUS_COLORS[task.status] ?? "#6b7280"),
x: Math.random() * 10,
y: Math.random() * 10,
});
}

// Add edges (dependency → task)
for (const task of tasks) {
const deps = getDeps(task);
for (const depNum of deps) {
const depKey = String(depNum);
const taskKey = String(task.task_number);
if (graph.hasNode(depKey) && graph.hasNode(taskKey)) {
const edgeKey = `${depKey}->${taskKey}`;
if (!graph.hasEdge(edgeKey)) {
graph.addEdgeWithKey(edgeKey, depKey, taskKey, {
color: "#555555",
size: 2,
});
}
}
}
}

return graph;
}, [tasks]);

// Simple force-directed layout (no worker needed for small graphs)
const applyLayout = useCallback((graph: Graph) => {
const nodes = graph.nodes();
const positions: Record<string, { x: number; y: number }> = {};

// Initialize positions
for (const node of nodes) {
positions[node] = {
x: graph.getNodeAttribute(node, "x"),
y: graph.getNodeAttribute(node, "y"),
};
}

// Simple force iterations
for (let iter = 0; iter < 100; iter++) {
// Repulsion between all nodes
for (let i = 0; i < nodes.length; i++) {
for (let j = i + 1; j < nodes.length; j++) {
const a = positions[nodes[i]];
const b = positions[nodes[j]];
const dx = b.x - a.x;
const dy = b.y - a.y;
const dist = Math.max(Math.sqrt(dx * dx + dy * dy), 0.1);
const force = 2 / (dist * dist);
const fx = (dx / dist) * force;
const fy = (dy / dist) * force;
a.x -= fx;
a.y -= fy;
b.x += fx;
b.y += fy;
}
}

// Attraction along edges
graph.forEachEdge((_edge, _attrs, source, target) => {
const a = positions[source];
const b = positions[target];
const dx = b.x - a.x;
const dy = b.y - a.y;
const dist = Math.sqrt(dx * dx + dy * dy);
const force = dist * 0.05;
const fx = (dx / Math.max(dist, 0.1)) * force;
const fy = (dy / Math.max(dist, 0.1)) * force;
a.x += fx;
a.y += fy;
b.x -= fx;
b.y -= fy;
});
}

// Apply positions
for (const node of nodes) {
graph.setNodeAttribute(node, "x", positions[node].x);
graph.setNodeAttribute(node, "y", positions[node].y);
}
}, []);

useEffect(() => {
if (!containerRef.current) return;

// Only show graph if there are dependencies
const hasDeps = tasks.some((t) => getDeps(t).length > 0);
if (!hasDeps) return;

const graph = buildGraph();
applyLayout(graph);
graphRef.current = graph;

const sigma = new Sigma(graph, containerRef.current, {
defaultEdgeType: "arrow",
edgeProgramClasses: { arrow: EdgeArrowProgram },
labelColor: { color: "#e0e0e0" },
labelFont: "JetBrains Mono, monospace",
labelSize: 11,
labelRenderedSizeThreshold: 4,
defaultNodeColor: "#6b7280",
defaultEdgeColor: "#555555",
stagePadding: 40,
});

sigma.on("clickNode", ({ node }) => {
onSelectTask?.(parseInt(node, 10));
});

sigma.on("enterNode", ({ node }) => setHoveredNode(node));
sigma.on("leaveNode", () => setHoveredNode(null));

sigmaRef.current = sigma;

return () => {
sigma.kill();
sigmaRef.current = null;
graphRef.current = null;
};
}, [tasks, buildGraph, applyLayout, onSelectTask]);

const hasDeps = tasks.some((t) => getDeps(t).length > 0);

if (!hasDeps) {
return (
<div className="flex h-full items-center justify-center text-sm text-ink-faint">
No task dependencies to visualize
</div>
);
}

return (
<div className="relative h-full w-full">
<div ref={containerRef} className="h-full w-full" />
{/* Legend */}
<div className="absolute bottom-3 left-3 flex flex-wrap gap-2 rounded-md bg-app/80 px-3 py-2 backdrop-blur-sm">
{Object.entries(STATUS_COLORS).map(([status, color]) => (
<div key={status} className="flex items-center gap-1">
<span
className="inline-block h-2.5 w-2.5 rounded-full"
style={{ backgroundColor: color }}
/>
<span className="text-tiny text-ink-faint">
{status.replace("_", " ")}
</span>
</div>
))}
<div className="flex items-center gap-1">
<span className="inline-block h-2.5 w-2.5 rounded-full bg-red-500" />
<span className="text-tiny text-ink-faint">blocked</span>
</div>
</div>
{/* Hover tooltip */}
{hoveredNode && (
<div className="absolute right-3 top-3 rounded-md bg-app-box/90 px-3 py-2 text-xs text-ink backdrop-blur-sm">
Task #{hoveredNode}
</div>
)}
</div>
);
}

function getDeps(task: TaskItem): number[] {
const deps = task.metadata?.depends_on;
if (Array.isArray(deps))
return deps.filter((n): n is number => typeof n === "number");
return [];
}
Loading
Loading