Add userDismissedAt field to agents schema

This commit is contained in:
Lukas May
2026-02-07 00:33:12 +01:00
parent 111ed0962f
commit 2877484012
224 changed files with 30873 additions and 4672 deletions

View File

@@ -1,2 +1,3 @@
export type { AppRouter } from './trpc.js';
export type { Initiative, Phase, Plan, Task, Agent, Message, PendingQuestions, QuestionItem, SubscriptionEvent } from './types.js';
export type { Initiative, Phase, Plan, Task, Agent, Message, PendingQuestions, QuestionItem, SubscriptionEvent, Project } from './types.js';
export { sortByPriorityAndQueueTime, type SortableItem } from './utils.js';

View File

@@ -1,4 +1,4 @@
export type { Initiative, Phase, Plan, Task, Agent, Message } from '../../../src/db/schema.js';
export type { Initiative, Phase, Plan, Task, Agent, Message, Page, Project, Account } from '../../../src/db/schema.js';
export type { PendingQuestions, QuestionItem } from '../../../src/agent/types.js';
/**

View File

@@ -0,0 +1,37 @@
/**
* Shared utility functions that can be used across frontend and backend.
*/
export interface SortableItem {
priority: 'low' | 'medium' | 'high';
createdAt: Date | string;
}
/**
* Priority order mapping for sorting (higher number = higher priority)
*/
const PRIORITY_ORDER = {
high: 3,
medium: 2,
low: 1,
} as const;
/**
* Sorts items by priority (high to low) then by queue time (oldest first).
* This ensures high-priority items come first, but within the same priority,
* items are processed in FIFO order.
*/
export function sortByPriorityAndQueueTime<T extends SortableItem>(items: T[]): T[] {
return [...items].sort((a, b) => {
// First sort by priority (high to low)
const priorityDiff = PRIORITY_ORDER[b.priority] - PRIORITY_ORDER[a.priority];
if (priorityDiff !== 0) {
return priorityDiff;
}
// Within same priority, sort by creation time (oldest first - FIFO)
const aTime = typeof a.createdAt === 'string' ? new Date(a.createdAt) : a.createdAt;
const bTime = typeof b.createdAt === 'string' ? new Date(b.createdAt) : b.createdAt;
return aTime.getTime() - bTime.getTime();
});
}

View File

@@ -15,6 +15,14 @@
"@radix-ui/react-label": "^2.1.8",
"@tanstack/react-query": "^5.75.0",
"@tanstack/react-router": "^1.158.0",
"@tiptap/extension-link": "^3.19.0",
"@tiptap/extension-placeholder": "^3.19.0",
"@tiptap/extension-table": "^3.19.0",
"@tiptap/html": "^3.19.0",
"@tiptap/pm": "^3.19.0",
"@tiptap/react": "^3.19.0",
"@tiptap/starter-kit": "^3.19.0",
"@tiptap/suggestion": "^3.19.0",
"@trpc/client": "^11.9.0",
"@trpc/react-query": "^11.9.0",
"class-variance-authority": "^0.7.1",
@@ -23,7 +31,8 @@
"react": "^19.0.0",
"react-dom": "^19.0.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0"
"tailwind-merge": "^3.4.0",
"tippy.js": "^6.3.7"
},
"devDependencies": {
"@radix-ui/react-dropdown-menu": "^2.1.16",

View File

@@ -0,0 +1,177 @@
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { ArrowDown, Pause, Play, AlertCircle } from "lucide-react";
import { trpc } from "@/lib/trpc";
import { useSubscriptionWithErrorHandling } from "@/hooks";
interface AgentOutputViewerProps {
agentId: string;
agentName?: string;
}
export function AgentOutputViewer({ agentId, agentName }: AgentOutputViewerProps) {
const [output, setOutput] = useState<string[]>([]);
const [follow, setFollow] = useState(true);
const containerRef = useRef<HTMLPreElement>(null);
// Load initial/historical output
const outputQuery = trpc.getAgentOutput.useQuery(
{ id: agentId },
{
refetchOnWindowFocus: false,
}
);
// Subscribe to live output with error handling
const subscription = useSubscriptionWithErrorHandling(
() => trpc.onAgentOutput.useSubscription({ agentId }),
{
onData: (event) => {
// event is TrackedEnvelope<{ agentId: string; data: string }>
// event.data is the inner data object
const payload = event.data as { agentId: string; data: string };
setOutput((prev) => [...prev, payload.data]);
},
onError: (error) => {
console.error('Agent output subscription error:', error);
},
autoReconnect: true,
maxReconnectAttempts: 3,
}
);
// Set initial output when query loads
useEffect(() => {
if (outputQuery.data) {
// Split NDJSON content into chunks for display
// Each line might be a JSON event, so we just display raw for now
const lines = outputQuery.data.split("\n").filter(Boolean);
// Extract text from JSONL events for display
const textChunks: string[] = [];
for (const line of lines) {
try {
const event = JSON.parse(line);
if (event.type === "assistant" && Array.isArray(event.message?.content)) {
// Claude CLI stream-json: complete assistant messages with content blocks
for (const block of event.message.content) {
if (block.type === "text" && block.text) {
textChunks.push(block.text);
}
}
} else if (event.type === "stream_event" && event.event?.delta?.text) {
// Legacy streaming format: granular text deltas
textChunks.push(event.event.delta.text);
} else if (event.type === "result" && event.result) {
// Don't add result text since it duplicates the content
}
} catch {
// Not JSON, display as-is
textChunks.push(line + "\n");
}
}
setOutput(textChunks);
}
}, [outputQuery.data]);
// Reset output when agent changes
useEffect(() => {
setOutput([]);
setFollow(true);
}, [agentId]);
// Auto-scroll to bottom when following
useEffect(() => {
if (follow && containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}
}, [output, follow]);
// Handle scroll to detect user scrolling up
function handleScroll() {
if (!containerRef.current) return;
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
if (!isAtBottom && follow) {
setFollow(false);
}
}
// Jump to bottom
function scrollToBottom() {
if (containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight;
setFollow(true);
}
}
const isLoading = outputQuery.isLoading;
const hasOutput = output.length > 0;
return (
<div className="flex flex-col h-[600px] rounded-lg border overflow-hidden">
{/* Header */}
<div className="flex items-center justify-between border-b bg-zinc-900 px-4 py-2">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-zinc-100">
{agentName ? `Output: ${agentName}` : "Agent Output"}
</span>
{subscription.error && (
<div className="flex items-center gap-1 text-red-400" title={subscription.error.message}>
<AlertCircle className="h-3 w-3" />
<span className="text-xs">Connection error</span>
</div>
)}
{subscription.isConnecting && (
<span className="text-xs text-yellow-400">Connecting...</span>
)}
</div>
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => setFollow(!follow)}
className="h-7 text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800"
>
{follow ? (
<>
<Pause className="mr-1 h-3 w-3" />
Following
</>
) : (
<>
<Play className="mr-1 h-3 w-3" />
Paused
</>
)}
</Button>
{!follow && (
<Button
variant="ghost"
size="sm"
onClick={scrollToBottom}
className="h-7 text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800"
>
<ArrowDown className="mr-1 h-3 w-3" />
Jump to bottom
</Button>
)}
</div>
</div>
{/* Output content */}
<pre
ref={containerRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto bg-zinc-900 p-4 font-mono text-sm text-zinc-100 whitespace-pre-wrap"
>
{isLoading ? (
<span className="text-zinc-500">Loading output...</span>
) : !hasOutput ? (
<span className="text-zinc-500">No output yet...</span>
) : (
output.join("")
)}
</pre>
</div>
);
}

View File

@@ -10,9 +10,9 @@ import {
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { Textarea } from "@/components/ui/textarea";
import { toast } from "sonner";
import { trpc } from "@/lib/trpc";
import { ProjectPicker } from "./ProjectPicker";
interface CreateInitiativeDialogProps {
open: boolean;
@@ -24,7 +24,7 @@ export function CreateInitiativeDialog({
onOpenChange,
}: CreateInitiativeDialogProps) {
const [name, setName] = useState("");
const [description, setDescription] = useState("");
const [projectIds, setProjectIds] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
const utils = trpc.useUtils();
@@ -44,7 +44,7 @@ export function CreateInitiativeDialog({
useEffect(() => {
if (open) {
setName("");
setDescription("");
setProjectIds([]);
setError(null);
}
}, [open]);
@@ -54,7 +54,7 @@ export function CreateInitiativeDialog({
setError(null);
createMutation.mutate({
name: name.trim(),
description: description.trim() || undefined,
projectIds: projectIds.length > 0 ? projectIds : undefined,
});
}
@@ -81,19 +81,13 @@ export function CreateInitiativeDialog({
/>
</div>
<div className="space-y-2">
<Label htmlFor="initiative-description">
Description{" "}
<Label>
Projects{" "}
<span className="text-muted-foreground font-normal">
(optional)
</span>
</Label>
<Textarea
id="initiative-description"
placeholder="Brief description of the initiative..."
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={3}
/>
<ProjectPicker value={projectIds} onChange={setProjectIds} />
</div>
{error && (
<p className="text-sm text-destructive">{error}</p>

View File

@@ -0,0 +1,48 @@
import {
ExecutionProvider,
PhaseActions,
PhasesList,
ProgressSidebar,
TaskModal,
type PhaseData,
} from "@/components/execution";
interface ExecutionTabProps {
initiativeId: string;
phases: PhaseData[];
phasesLoading: boolean;
phasesLoaded: boolean;
}
export function ExecutionTab({
initiativeId,
phases,
phasesLoading,
phasesLoaded,
}: ExecutionTabProps) {
return (
<ExecutionProvider>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_340px]">
{/* Left column: Phases */}
<div className="space-y-0">
<div className="flex items-center justify-between border-b border-border pb-3">
<h2 className="text-lg font-semibold">Phases</h2>
<PhaseActions initiativeId={initiativeId} phases={phases} />
</div>
<PhasesList
initiativeId={initiativeId}
phases={phases}
phasesLoading={phasesLoading}
phasesLoaded={phasesLoaded}
/>
</div>
{/* Right column: Progress + Decisions */}
<ProgressSidebar phases={phases} />
</div>
<TaskModal />
</ExecutionProvider>
);
}

View File

@@ -1,52 +1,118 @@
import { ChevronLeft } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { useState } from "react";
import { ChevronLeft, Pencil, Check } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { StatusBadge } from "@/components/StatusBadge";
import { ProjectPicker } from "./ProjectPicker";
import { trpc } from "@/lib/trpc";
import { toast } from "sonner";
export interface InitiativeHeaderProps {
initiative: {
id: string;
name: string;
status: string;
createdAt: string;
updatedAt: string;
};
projects?: Array<{ id: string; name: string; url: string }>;
onBack: () => void;
}
export function InitiativeHeader({
initiative,
projects,
onBack,
}: InitiativeHeaderProps) {
return (
<div className="flex flex-col gap-4">
{/* Top bar: back button + actions placeholder */}
<div className="flex items-center justify-between">
<Button variant="ghost" size="sm" onClick={onBack}>
<ChevronLeft className="mr-1 h-4 w-4" />
Back to Dashboard
</Button>
<Button variant="outline" size="sm" disabled>
Actions
</Button>
</div>
const [editing, setEditing] = useState(false);
const [editIds, setEditIds] = useState<string[]>([]);
{/* Initiative metadata card */}
<Card>
<CardContent className="p-6">
<div className="flex flex-col gap-2">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold">{initiative.name}</h1>
<StatusBadge status={initiative.status} />
</div>
<p className="text-sm text-muted-foreground">
Created: {new Date(initiative.createdAt).toLocaleDateString()}
{" | "}
Updated: {new Date(initiative.updatedAt).toLocaleDateString()}
</p>
const utils = trpc.useUtils();
const updateMutation = trpc.updateInitiativeProjects.useMutation({
onSuccess: () => {
utils.getInitiative.invalidate({ id: initiative.id });
setEditing(false);
toast.success("Projects updated");
},
onError: (err) => {
toast.error(err.message);
},
});
function startEditing() {
setEditIds(projects?.map((p) => p.id) ?? []);
setEditing(true);
}
function saveProjects() {
if (editIds.length === 0) {
toast.error("At least one project is required");
return;
}
updateMutation.mutate({
initiativeId: initiative.id,
projectIds: editIds,
});
}
return (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onBack}>
<ChevronLeft className="h-4 w-4" />
</Button>
<h1 className="text-xl font-semibold">{initiative.name}</h1>
<StatusBadge status={initiative.status} />
{!editing && projects && projects.length > 0 && (
<>
{projects.map((p) => (
<Badge key={p.id} variant="outline" className="text-xs font-normal">
{p.name}
</Badge>
))}
<Button
variant="ghost"
size="icon"
className="h-6 w-6"
onClick={startEditing}
>
<Pencil className="h-3 w-3" />
</Button>
</>
)}
{!editing && (!projects || projects.length === 0) && (
<Button
variant="ghost"
size="sm"
className="text-xs text-muted-foreground"
onClick={startEditing}
>
+ Add projects
</Button>
)}
</div>
</div>
{editing && (
<div className="ml-11 max-w-sm space-y-2">
<ProjectPicker value={editIds} onChange={setEditIds} />
<div className="flex gap-2">
<Button
size="sm"
onClick={saveProjects}
disabled={editIds.length === 0 || updateMutation.isPending}
>
<Check className="mr-1 h-3 w-3" />
{updateMutation.isPending ? "Saving..." : "Save"}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setEditing(false)}
>
Cancel
</Button>
</div>
</CardContent>
</Card>
</div>
)}
</div>
);
}

View File

@@ -1,20 +1,5 @@
import { Card } from "@/components/ui/card";
import { cn } from "@/lib/utils";
function formatRelativeTime(isoDate: string): string {
const now = Date.now();
const then = new Date(isoDate).getTime();
const diffMs = now - then;
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHr = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHr / 24);
if (diffSec < 60) return "just now";
if (diffMin < 60) return `${diffMin} min ago`;
if (diffHr < 24) return `${diffHr}h ago`;
return `${diffDay}d ago`;
}
import { cn, formatRelativeTime } from "@/lib/utils";
interface MessageCardProps {
agentName: string;

View File

@@ -0,0 +1,65 @@
import { useState } from "react";
import { Plus } from "lucide-react";
import { trpc } from "@/lib/trpc";
import { RegisterProjectDialog } from "./RegisterProjectDialog";
interface ProjectPickerProps {
value: string[];
onChange: (ids: string[]) => void;
error?: string;
}
export function ProjectPicker({ value, onChange, error }: ProjectPickerProps) {
const [registerOpen, setRegisterOpen] = useState(false);
const projectsQuery = trpc.listProjects.useQuery();
const projects = projectsQuery.data ?? [];
function toggle(id: string) {
if (value.includes(id)) {
onChange(value.filter((v) => v !== id));
} else {
onChange([...value, id]);
}
}
return (
<div className="space-y-2">
{projects.length === 0 && !projectsQuery.isLoading && (
<p className="text-sm text-muted-foreground">No projects registered yet.</p>
)}
{projects.length > 0 && (
<div className="max-h-40 overflow-y-auto rounded border border-border p-2 space-y-1">
{projects.map((p) => (
<label
key={p.id}
className="flex items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-accent cursor-pointer"
>
<input
type="checkbox"
checked={value.includes(p.id)}
onChange={() => toggle(p.id)}
className="rounded border-border"
/>
<span className="font-medium">{p.name}</span>
<span className="text-muted-foreground text-xs truncate">{p.url}</span>
</label>
))}
</div>
)}
<button
type="button"
onClick={() => setRegisterOpen(true)}
className="inline-flex items-center gap-1 text-sm text-primary hover:underline"
>
<Plus className="h-3 w-3" />
Register new project
</button>
{error && <p className="text-sm text-destructive">{error}</p>}
<RegisterProjectDialog
open={registerOpen}
onOpenChange={setRegisterOpen}
/>
</div>
);
}

View File

@@ -0,0 +1,125 @@
import { useState } from "react";
import { Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Textarea } from "@/components/ui/textarea";
interface RefineSpawnDialogProps {
/** Button text to show in the trigger */
triggerText: string;
/** Dialog title */
title: string;
/** Dialog description */
description: string;
/** Whether to show the instruction textarea */
showInstructionInput?: boolean;
/** Placeholder text for the instruction textarea */
instructionPlaceholder?: string;
/** Whether the spawn mutation is pending */
isSpawning: boolean;
/** Error message if spawn failed */
error?: string;
/** Called when the user wants to spawn */
onSpawn: (instruction?: string) => void;
/** Custom trigger button (optional) */
trigger?: React.ReactNode;
}
export function RefineSpawnDialog({
triggerText,
title,
description,
showInstructionInput = true,
instructionPlaceholder = "What should the agent focus on? (optional)",
isSpawning,
error,
onSpawn,
trigger,
}: RefineSpawnDialogProps) {
const [showDialog, setShowDialog] = useState(false);
const [instruction, setInstruction] = useState("");
const handleSpawn = () => {
const finalInstruction = showInstructionInput && instruction.trim()
? instruction.trim()
: undefined;
onSpawn(finalInstruction);
};
const handleOpenChange = (open: boolean) => {
setShowDialog(open);
if (!open) {
setInstruction("");
}
};
const defaultTrigger = (
<Button
variant="outline"
size="sm"
onClick={() => setShowDialog(true)}
className="gap-1.5"
>
<Sparkles className="h-3.5 w-3.5" />
{triggerText}
</Button>
);
return (
<>
{trigger ? (
<div onClick={() => setShowDialog(true)}>
{trigger}
</div>
) : (
defaultTrigger
)}
<Dialog open={showDialog} onOpenChange={handleOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
{showInstructionInput && (
<Textarea
placeholder={instructionPlaceholder}
value={instruction}
onChange={(e) => setInstruction(e.target.value)}
rows={3}
/>
)}
<DialogFooter>
<Button
variant="outline"
onClick={() => setShowDialog(false)}
>
Cancel
</Button>
<Button
onClick={handleSpawn}
disabled={isSpawning}
>
{isSpawning ? "Starting..." : "Start"}
</Button>
</DialogFooter>
{error && (
<p className="text-xs text-destructive">
{error}
</p>
)}
</DialogContent>
</Dialog>
</>
);
}

View File

@@ -0,0 +1,110 @@
import { useEffect, useState } from "react";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { toast } from "sonner";
import { trpc } from "@/lib/trpc";
interface RegisterProjectDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function RegisterProjectDialog({
open,
onOpenChange,
}: RegisterProjectDialogProps) {
const [name, setName] = useState("");
const [url, setUrl] = useState("");
const [error, setError] = useState<string | null>(null);
const utils = trpc.useUtils();
const registerMutation = trpc.registerProject.useMutation({
onSuccess: () => {
utils.listProjects.invalidate();
onOpenChange(false);
toast.success("Project registered");
},
onError: (err) => {
setError(err.message);
},
});
useEffect(() => {
if (open) {
setName("");
setUrl("");
setError(null);
}
}, [open]);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
registerMutation.mutate({
name: name.trim(),
url: url.trim(),
});
}
const canSubmit =
name.trim().length > 0 &&
url.trim().length > 0 &&
!registerMutation.isPending;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Register Project</DialogTitle>
<DialogDescription>
Register a git repository as a project.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="project-name">Name</Label>
<Input
id="project-name"
placeholder="e.g. my-app"
value={name}
onChange={(e) => setName(e.target.value)}
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor="project-url">Repository URL</Label>
<Input
id="project-url"
placeholder="e.g. https://github.com/org/repo.git"
value={url}
onChange={(e) => setUrl(e.target.value)}
/>
</div>
{error && <p className="text-sm text-destructive">{error}</p>}
<DialogFooter>
<Button
type="button"
variant="outline"
onClick={() => onOpenChange(false)}
>
Cancel
</Button>
<Button type="submit" disabled={!canSubmit}>
{registerMutation.isPending ? "Registering..." : "Register"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -7,59 +7,42 @@ import {
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { toast } from "sonner";
import { trpc } from "@/lib/trpc";
import { useSpawnMutation } from "@/hooks/useSpawnMutation";
interface SpawnArchitectDropdownProps {
initiativeId: string;
initiativeName: string;
initiativeName?: string;
}
export function SpawnArchitectDropdown({
initiativeId,
initiativeName,
}: SpawnArchitectDropdownProps) {
const [open, setOpen] = useState(false);
const [successText, setSuccessText] = useState<string | null>(null);
const discussMutation = trpc.spawnArchitectDiscuss.useMutation({
onSuccess: () => {
setOpen(false);
setSuccessText("Spawned!");
setTimeout(() => setSuccessText(null), 2000);
toast.success("Architect spawned");
},
onError: () => {
toast.error("Failed to spawn architect");
},
const handleSuccess = () => {
setOpen(false);
setSuccessText("Spawned!");
setTimeout(() => setSuccessText(null), 2000);
};
const discussSpawn = useSpawnMutation(trpc.spawnArchitectDiscuss.useMutation, {
onSuccess: handleSuccess,
});
const breakdownMutation = trpc.spawnArchitectBreakdown.useMutation({
onSuccess: () => {
setOpen(false);
setSuccessText("Spawned!");
setTimeout(() => setSuccessText(null), 2000);
toast.success("Architect spawned");
},
onError: () => {
toast.error("Failed to spawn architect");
},
const breakdownSpawn = useSpawnMutation(trpc.spawnArchitectBreakdown.useMutation, {
onSuccess: handleSuccess,
});
const isPending = discussMutation.isPending || breakdownMutation.isPending;
const isPending = discussSpawn.isSpawning || breakdownSpawn.isSpawning;
function handleDiscuss() {
discussMutation.mutate({
name: initiativeName + "-discuss",
initiativeId,
});
discussSpawn.spawn({ initiativeId });
}
function handleBreakdown() {
breakdownMutation.mutate({
name: initiativeName + "-breakdown",
initiativeId,
});
breakdownSpawn.spawn({ initiativeId });
}
return (

View File

@@ -0,0 +1,76 @@
import { cn } from "@/lib/utils";
/**
* Color mapping for different status values.
* Uses semantic colors that work well as small dots.
*/
const statusColors: Record<string, string> = {
// Task statuses
pending: "bg-gray-400",
pending_approval: "bg-yellow-400",
in_progress: "bg-blue-500",
completed: "bg-green-500",
blocked: "bg-red-500",
// Agent statuses
idle: "bg-gray-400",
running: "bg-blue-500",
waiting_for_input: "bg-yellow-400",
stopped: "bg-gray-600",
crashed: "bg-red-500",
// Initiative/Phase statuses
active: "bg-blue-500",
archived: "bg-gray-400",
// Message statuses
read: "bg-green-500",
responded: "bg-blue-500",
// Priority indicators
low: "bg-green-400",
medium: "bg-yellow-400",
high: "bg-red-400",
} as const;
const defaultColor = "bg-gray-400";
interface StatusDotProps {
status: string;
size?: "sm" | "md" | "lg";
className?: string;
title?: string;
}
/**
* Small colored dot to indicate status at a glance.
* More compact than StatusBadge for use in lists or tight spaces.
*/
export function StatusDot({
status,
size = "md",
className,
title
}: StatusDotProps) {
const sizeClasses = {
sm: "h-2 w-2",
md: "h-3 w-3",
lg: "h-4 w-4"
};
const color = statusColors[status] ?? defaultColor;
const displayTitle = title ?? status.replace(/_/g, " ").toLowerCase();
return (
<div
className={cn(
"rounded-full",
sizeClasses[size],
color,
className
)}
title={displayTitle}
aria-label={`Status: ${displayTitle}`}
/>
);
}

View File

@@ -8,6 +8,7 @@ import {
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { StatusBadge } from "@/components/StatusBadge";
import { StatusDot } from "@/components/StatusDot";
/** Serialized Task shape as returned by tRPC (Date serialized to string over JSON) */
export interface SerializedTask {
@@ -117,7 +118,7 @@ export function TaskDetailModal({
className="flex items-center gap-2 text-sm"
>
<span>{dep.name}</span>
<StatusBadge status={dep.status} />
<StatusDot status={dep.status} size="md" />
</li>
))}
</ul>
@@ -137,7 +138,7 @@ export function TaskDetailModal({
className="flex items-center gap-2 text-sm"
>
<span>{dep.name}</span>
<StatusBadge status={dep.status} />
<StatusDot status={dep.status} size="md" />
</li>
))}
</ul>

View File

@@ -0,0 +1,186 @@
import { Extension } from "@tiptap/core";
import { Plugin, PluginKey, type EditorState, type Transaction } from "@tiptap/pm/state";
import { Decoration, DecorationSet } from "@tiptap/pm/view";
export type BlockSelectionState = {
anchorIndex: number;
headIndex: number;
} | null;
export const blockSelectionKey = new PluginKey<BlockSelectionState>(
"blockSelection",
);
function selectedRange(
state: BlockSelectionState,
): { from: number; to: number } | null {
if (!state) return null;
return {
from: Math.min(state.anchorIndex, state.headIndex),
to: Math.max(state.anchorIndex, state.headIndex),
};
}
/** Returns doc positions spanning the selected block range. */
export function getBlockRange(
editorState: EditorState,
sel: BlockSelectionState,
): { fromPos: number; toPos: number } | null {
if (!sel) return null;
const range = selectedRange(sel)!;
const doc = editorState.doc;
let fromPos = 0;
let toPos = 0;
let idx = 0;
doc.forEach((node, offset) => {
if (idx === range.from) fromPos = offset;
if (idx === range.to) toPos = offset + node.nodeSize;
idx++;
});
return { fromPos, toPos };
}
function isPrintable(e: KeyboardEvent): boolean {
if (e.ctrlKey || e.metaKey || e.altKey) return false;
return e.key.length === 1;
}
export const BlockSelectionExtension = Extension.create({
name: "blockSelection",
addProseMirrorPlugins() {
return [
new Plugin<BlockSelectionState>({
key: blockSelectionKey,
state: {
init(): BlockSelectionState {
return null;
},
apply(tr: Transaction, value: BlockSelectionState): BlockSelectionState {
const meta = tr.getMeta(blockSelectionKey);
if (meta !== undefined) return meta;
// Doc changed while selection active → clear (positions stale)
if (value && tr.docChanged) return null;
// User set a new text selection (not from our plugin) → clear
if (value && tr.selectionSet && !tr.getMeta("blockSelectionInternal")) {
return null;
}
return value;
},
},
props: {
decorations(state: EditorState): DecorationSet {
const sel = blockSelectionKey.getState(state);
const range = selectedRange(sel);
if (!range) return DecorationSet.empty;
const decorations: Decoration[] = [];
let idx = 0;
state.doc.forEach((node, pos) => {
if (idx >= range.from && idx <= range.to) {
decorations.push(
Decoration.node(pos, pos + node.nodeSize, {
class: "block-selected",
}),
);
}
idx++;
});
return DecorationSet.create(state.doc, decorations);
},
attributes(state: EditorState): Record<string, string> | null {
const sel = blockSelectionKey.getState(state);
if (sel) return { class: "has-block-selection" };
return null;
},
handleKeyDown(view, event) {
const sel = blockSelectionKey.getState(view.state);
if (!sel) return false;
const childCount = view.state.doc.childCount;
if (event.key === "ArrowDown" && event.shiftKey) {
event.preventDefault();
const newHead = Math.min(sel.headIndex + 1, childCount - 1);
const tr = view.state.tr.setMeta(blockSelectionKey, {
anchorIndex: sel.anchorIndex,
headIndex: newHead,
});
tr.setMeta("blockSelectionInternal", true);
view.dispatch(tr);
return true;
}
if (event.key === "ArrowUp" && event.shiftKey) {
event.preventDefault();
const newHead = Math.max(sel.headIndex - 1, 0);
const tr = view.state.tr.setMeta(blockSelectionKey, {
anchorIndex: sel.anchorIndex,
headIndex: newHead,
});
tr.setMeta("blockSelectionInternal", true);
view.dispatch(tr);
return true;
}
if (event.key === "Escape") {
event.preventDefault();
view.dispatch(
view.state.tr.setMeta(blockSelectionKey, null),
);
return true;
}
if (event.key === "Backspace" || event.key === "Delete") {
event.preventDefault();
const range = selectedRange(sel);
if (!range) return true;
const blockRange = getBlockRange(view.state, sel);
if (!blockRange) return true;
const tr = view.state.tr.delete(blockRange.fromPos, blockRange.toPos);
tr.setMeta(blockSelectionKey, null);
view.dispatch(tr);
return true;
}
if (isPrintable(event)) {
// Delete selected blocks, clear selection, let PM handle char insertion
const blockRange = getBlockRange(view.state, sel);
if (blockRange) {
const tr = view.state.tr.delete(blockRange.fromPos, blockRange.toPos);
tr.setMeta(blockSelectionKey, null);
view.dispatch(tr);
}
return false;
}
// Modifier-only keys (Shift, Ctrl, Alt, Meta) — ignore
if (["Shift", "Control", "Alt", "Meta"].includes(event.key)) {
return false;
}
// Any other key — clear selection and pass through
view.dispatch(
view.state.tr.setMeta(blockSelectionKey, null),
);
return false;
},
handleClick(view) {
const sel = blockSelectionKey.getState(view.state);
if (sel) {
view.dispatch(
view.state.tr.setMeta(blockSelectionKey, null),
);
}
return false;
},
},
}),
];
},
});

View File

@@ -0,0 +1,191 @@
import { useState, useCallback } from "react";
import { Check, ChevronDown, ChevronRight, AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { trpc } from "@/lib/trpc";
import { markdownToTiptapJson } from "@/lib/markdown-to-tiptap";
interface ContentProposal {
pageId: string;
pageTitle: string;
summary: string;
markdown: string;
}
interface ContentProposalReviewProps {
proposals: ContentProposal[];
agentCreatedAt: Date;
agentId: string;
onDismiss: () => void;
}
export function ContentProposalReview({
proposals,
agentCreatedAt,
agentId,
onDismiss,
}: ContentProposalReviewProps) {
const [accepted, setAccepted] = useState<Set<string>>(new Set());
const utils = trpc.useUtils();
const updatePageMutation = trpc.updatePage.useMutation({
onSuccess: () => {
void utils.listPages.invalidate();
void utils.getPage.invalidate();
},
});
const dismissMutation = trpc.dismissAgent.useMutation({
onSuccess: () => {
void utils.listAgents.invalidate();
onDismiss();
},
});
const handleAccept = useCallback(
async (proposal: ContentProposal) => {
const tiptapJson = markdownToTiptapJson(proposal.markdown);
await updatePageMutation.mutateAsync({
id: proposal.pageId,
content: JSON.stringify(tiptapJson),
});
setAccepted((prev) => new Set(prev).add(proposal.pageId));
},
[updatePageMutation],
);
const handleAcceptAll = useCallback(async () => {
for (const proposal of proposals) {
if (!accepted.has(proposal.pageId)) {
const tiptapJson = markdownToTiptapJson(proposal.markdown);
await updatePageMutation.mutateAsync({
id: proposal.pageId,
content: JSON.stringify(tiptapJson),
});
setAccepted((prev) => new Set(prev).add(proposal.pageId));
}
}
}, [proposals, accepted, updatePageMutation]);
const allAccepted = proposals.every((p) => accepted.has(p.pageId));
return (
<div className="rounded-lg border border-border bg-card p-4 space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-semibold">
Agent Proposals ({proposals.length})
</h3>
<div className="flex gap-2">
{!allAccepted && (
<Button
variant="outline"
size="sm"
onClick={handleAcceptAll}
disabled={updatePageMutation.isPending}
>
Accept All
</Button>
)}
<Button
variant="ghost"
size="sm"
onClick={() => dismissMutation.mutate({ id: agentId })}
disabled={dismissMutation.isPending}
>
{dismissMutation.isPending ? "Dismissing..." : "Dismiss"}
</Button>
</div>
</div>
<div className="space-y-2">
{proposals.map((proposal) => (
<ProposalCard
key={proposal.pageId}
proposal={proposal}
isAccepted={accepted.has(proposal.pageId)}
agentCreatedAt={agentCreatedAt}
onAccept={() => handleAccept(proposal)}
isAccepting={updatePageMutation.isPending}
/>
))}
</div>
</div>
);
}
interface ProposalCardProps {
proposal: ContentProposal;
isAccepted: boolean;
agentCreatedAt: Date;
onAccept: () => void;
isAccepting: boolean;
}
function ProposalCard({
proposal,
isAccepted,
agentCreatedAt,
onAccept,
isAccepting,
}: ProposalCardProps) {
const [expanded, setExpanded] = useState(false);
// Check if page was modified since agent started
const pageQuery = trpc.getPage.useQuery({ id: proposal.pageId });
const pageUpdatedAt = pageQuery.data?.updatedAt;
const isStale =
pageUpdatedAt && new Date(pageUpdatedAt) > agentCreatedAt;
return (
<div className="rounded border border-border p-3 space-y-2">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 min-w-0">
<button
className="flex items-center gap-1 text-sm font-medium hover:text-foreground/80"
onClick={() => setExpanded(!expanded)}
>
{expanded ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
)}
{proposal.pageTitle}
</button>
<p className="text-xs text-muted-foreground mt-0.5 pl-5">
{proposal.summary}
</p>
</div>
{isAccepted ? (
<div className="flex items-center gap-1 text-xs text-green-600 shrink-0">
<Check className="h-3.5 w-3.5" />
Accepted
</div>
) : (
<Button
variant="outline"
size="sm"
onClick={onAccept}
disabled={isAccepting}
className="shrink-0"
>
Accept
</Button>
)}
</div>
{isStale && !isAccepted && (
<div className="flex items-center gap-1.5 text-xs text-yellow-600 pl-5">
<AlertTriangle className="h-3 w-3" />
Content was modified since agent started
</div>
)}
{expanded && (
<div className="pl-5 pt-1">
<div className="prose prose-sm max-w-none rounded bg-muted/50 p-3 text-xs overflow-auto max-h-64">
<pre className="whitespace-pre-wrap text-xs">{proposal.markdown}</pre>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,348 @@
import { useState, useCallback, useRef, useEffect } from "react";
import type { Editor } from "@tiptap/react";
import { AlertCircle } from "lucide-react";
import { trpc } from "@/lib/trpc";
import { useAutoSave } from "@/hooks/useAutoSave";
import { TiptapEditor } from "./TiptapEditor";
import { PageTitleProvider } from "./PageTitleContext";
import { PageTree } from "./PageTree";
import { RefineAgentPanel } from "./RefineAgentPanel";
import { Skeleton } from "@/components/Skeleton";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
interface ContentTabProps {
initiativeId: string;
initiativeName: string;
}
interface DeleteConfirmation {
pageId: string;
redo: () => void;
}
export function ContentTab({ initiativeId, initiativeName }: ContentTabProps) {
const utils = trpc.useUtils();
const handleSaved = useCallback(() => {
void utils.listPages.invalidate({ initiativeId });
}, [utils, initiativeId]);
const { save, flush, isSaving } = useAutoSave({ onSaved: handleSaved });
// Get or create root page
const rootPageQuery = trpc.getRootPage.useQuery({ initiativeId });
const allPagesQuery = trpc.listPages.useQuery({ initiativeId });
const createPageMutation = trpc.createPage.useMutation({
onSuccess: () => {
void utils.listPages.invalidate({ initiativeId });
},
});
const deletePageMutation = trpc.deletePage.useMutation({
onSuccess: () => {
void utils.listPages.invalidate({ initiativeId });
},
});
const updateInitiativeMutation = trpc.updateInitiative.useMutation({
onSuccess: () => {
void utils.getInitiative.invalidate({ id: initiativeId });
},
});
const initiativeNameTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingInitiativeNameRef = useRef<string | null>(null);
const [activePageId, setActivePageId] = useState<string | null>(null);
const [deleteConfirm, setDeleteConfirm] = useState<DeleteConfirmation | null>(null);
const [pageTitle, setPageTitle] = useState("");
// Keep a ref to the current editor so subpage creation can insert links
const editorRef = useRef<Editor | null>(null);
// Resolve active page: use explicit selection, or fallback to root
const resolvedActivePageId =
activePageId ?? rootPageQuery.data?.id ?? null;
const isRootPage = resolvedActivePageId != null && resolvedActivePageId === rootPageQuery.data?.id;
// Fetch active page content
const activePageQuery = trpc.getPage.useQuery(
{ id: resolvedActivePageId! },
{ enabled: !!resolvedActivePageId },
);
const handleEditorUpdate = useCallback(
(json: string) => {
if (resolvedActivePageId) {
save(resolvedActivePageId, { content: json });
}
},
[resolvedActivePageId, save],
);
// Sync title from server when active page changes
useEffect(() => {
if (activePageQuery.data) {
setPageTitle(isRootPage ? initiativeName : activePageQuery.data.title);
}
}, [activePageQuery.data?.id, isRootPage, initiativeName]); // eslint-disable-line react-hooks/exhaustive-deps
const handleTitleChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const newTitle = e.target.value;
setPageTitle(newTitle);
if (isRootPage) {
// Debounce initiative name updates
pendingInitiativeNameRef.current = newTitle;
if (initiativeNameTimerRef.current) {
clearTimeout(initiativeNameTimerRef.current);
}
initiativeNameTimerRef.current = setTimeout(() => {
pendingInitiativeNameRef.current = null;
updateInitiativeMutation.mutate({ id: initiativeId, name: newTitle });
initiativeNameTimerRef.current = null;
}, 1000);
} else if (resolvedActivePageId) {
save(resolvedActivePageId, { title: newTitle });
}
},
[isRootPage, resolvedActivePageId, save, initiativeId, updateInitiativeMutation],
);
// Flush pending initiative name save on unmount
useEffect(() => {
return () => {
if (initiativeNameTimerRef.current) {
clearTimeout(initiativeNameTimerRef.current);
initiativeNameTimerRef.current = null;
}
if (pendingInitiativeNameRef.current != null) {
updateInitiativeMutation.mutate({ id: initiativeId, name: pendingInitiativeNameRef.current });
pendingInitiativeNameRef.current = null;
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const handleTitleKeyDown = useCallback(
(e: React.KeyboardEvent<HTMLInputElement>) => {
if (e.key === "Enter") {
e.preventDefault();
// Focus the Tiptap editor below
const el = (e.target as HTMLElement)
.closest(".flex-1")
?.querySelector<HTMLElement>("[contenteditable]");
el?.focus();
}
},
[],
);
const handleCreateChild = useCallback(
(parentPageId: string) => {
createPageMutation.mutate({
initiativeId,
parentPageId,
title: "Untitled",
});
},
[initiativeId, createPageMutation],
);
const handleNavigate = useCallback((pageId: string) => {
setActivePageId(pageId);
}, []);
// Slash command: /subpage — creates a page and inserts a link at cursor
const handleSubpageCreate = useCallback(
async (editor: Editor) => {
editorRef.current = editor;
try {
const page = await createPageMutation.mutateAsync({
initiativeId,
parentPageId: resolvedActivePageId,
title: "Untitled",
});
// Insert page link at current cursor position
editor.commands.insertPageLink({ pageId: page.id });
// Wait for auto-save to persist the link before navigating
await flush();
// Update the query cache so navigating back shows content with the link
utils.getPage.setData(
{ id: resolvedActivePageId! },
(old) => old ? { ...old, content: JSON.stringify(editor.getJSON()) } : undefined,
);
// Navigate directly to the newly created subpage
setActivePageId(page.id);
} catch {
// Mutation errors surfaced via React Query state
}
},
[initiativeId, resolvedActivePageId, createPageMutation, flush, utils],
);
// Detect when a page link is deleted from the editor (already undone by plugin)
const handlePageLinkDeleted = useCallback(
(pageId: string, redo: () => void) => {
// Don't prompt for pages that don't exist in our tree
const allPages = allPagesQuery.data ?? [];
const exists = allPages.some((p) => p.id === pageId);
if (!exists) {
// Page doesn't exist — redo the deletion so the stale link is removed
redo();
return;
}
setDeleteConfirm({ pageId, redo });
},
[allPagesQuery.data],
);
const confirmDeleteSubpage = useCallback(() => {
if (deleteConfirm) {
// Re-delete the page link from the editor, then delete the page from DB
deleteConfirm.redo();
deletePageMutation.mutate({ id: deleteConfirm.pageId });
setDeleteConfirm(null);
}
}, [deleteConfirm, deletePageMutation]);
const dismissDeleteConfirm = useCallback(() => {
setDeleteConfirm(null);
}, []);
const allPages = allPagesQuery.data ?? [];
// Loading
if (rootPageQuery.isLoading) {
return (
<div className="flex gap-4">
<Skeleton className="h-64 w-48" />
<Skeleton className="h-64 flex-1" />
</div>
);
}
// Error — server likely needs restart or migration hasn't applied
if (rootPageQuery.isError) {
return (
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
<AlertCircle className="h-6 w-6 text-destructive" />
<p className="text-sm text-destructive">
Failed to load editor: {rootPageQuery.error.message}
</p>
<p className="text-xs text-muted-foreground">
Make sure the backend server is running with the latest code.
</p>
<Button
variant="outline"
size="sm"
onClick={() => void rootPageQuery.refetch()}
>
Retry
</Button>
</div>
);
}
return (
<>
<PageTitleProvider pages={allPages}>
<div className="flex gap-4 pt-4">
{/* Page tree sidebar */}
<div className="w-48 shrink-0 border-r border-border pr-3">
<div className="flex items-center justify-between mb-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Pages
</span>
</div>
<PageTree
pages={allPages}
activePageId={resolvedActivePageId ?? ""}
onNavigate={handleNavigate}
onCreateChild={handleCreateChild}
/>
</div>
{/* Editor area */}
<div className="flex-1 min-w-0">
{/* Refine agent panel — sits above editor */}
<RefineAgentPanel initiativeId={initiativeId} />
{resolvedActivePageId && (
<>
{(isSaving || updateInitiativeMutation.isPending) && (
<div className="flex justify-end mb-2">
<span className="text-xs text-muted-foreground">
Saving...
</span>
</div>
)}
{activePageQuery.isSuccess && (
<input
value={pageTitle}
onChange={handleTitleChange}
onKeyDown={handleTitleKeyDown}
placeholder="Untitled"
className="w-full text-3xl font-bold bg-transparent border-none outline-none placeholder:text-muted-foreground/40 pl-11 mb-2"
/>
)}
{activePageQuery.isSuccess && (
<TiptapEditor
key={resolvedActivePageId}
pageId={resolvedActivePageId}
content={activePageQuery.data?.content ?? null}
onUpdate={handleEditorUpdate}
onPageLinkClick={handleNavigate}
onSubpageCreate={handleSubpageCreate}
onPageLinkDeleted={handlePageLinkDeleted}
/>
)}
{activePageQuery.isLoading && (
<Skeleton className="h-64 w-full" />
)}
{activePageQuery.isError && (
<div className="flex items-center gap-2 py-4 text-sm text-destructive">
<AlertCircle className="h-4 w-4" />
Failed to load page: {activePageQuery.error.message}
</div>
)}
</>
)}
</div>
</div>
{/* Delete subpage confirmation dialog */}
<Dialog
open={deleteConfirm !== null}
onOpenChange={(open) => {
if (!open) dismissDeleteConfirm();
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete subpage?</DialogTitle>
<DialogDescription>
You removed the link to &ldquo;{allPages.find((p) => p.id === deleteConfirm?.pageId)?.title ?? "Untitled"}&rdquo;.
Do you also want to delete the subpage and all its content?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={dismissDeleteConfirm}>
Keep subpage
</Button>
<Button variant="destructive" onClick={confirmDeleteSubpage}>
Delete subpage
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</PageTitleProvider>
</>
);
}

View File

@@ -0,0 +1,52 @@
import { useMemo } from "react";
import { ChevronRight } from "lucide-react";
interface PageBreadcrumbProps {
pages: Array<{
id: string;
parentPageId: string | null;
title: string;
}>;
activePageId: string;
onNavigate: (pageId: string) => void;
}
export function PageBreadcrumb({
pages,
activePageId,
onNavigate,
}: PageBreadcrumbProps) {
const trail = useMemo(() => {
const byId = new Map(pages.map((p) => [p.id, p]));
const result: Array<{ id: string; title: string }> = [];
let current = byId.get(activePageId);
while (current) {
result.unshift({ id: current.id, title: current.title });
current = current.parentPageId
? byId.get(current.parentPageId)
: undefined;
}
return result;
}, [pages, activePageId]);
return (
<nav className="flex items-center gap-1 text-sm text-muted-foreground">
{trail.map((item, i) => (
<span key={item.id} className="flex items-center gap-1">
{i > 0 && <ChevronRight className="h-3 w-3" />}
{i < trail.length - 1 ? (
<button
onClick={() => onNavigate(item.id)}
className="hover:text-foreground transition-colors"
>
{item.title}
</button>
) : (
<span className="text-foreground font-medium">{item.title}</span>
)}
</span>
))}
</nav>
);
}

View File

@@ -0,0 +1,75 @@
import { Node, mergeAttributes, ReactNodeViewRenderer, NodeViewWrapper } from "@tiptap/react";
import type { NodeViewProps } from "@tiptap/react";
import { FileText } from "lucide-react";
import { usePageTitle } from "./PageTitleContext";
declare module "@tiptap/react" {
interface Commands<ReturnType> {
pageLink: {
insertPageLink: (attrs: { pageId: string }) => ReturnType;
};
}
}
function PageLinkNodeView({ node }: NodeViewProps) {
const title = usePageTitle(node.attrs.pageId);
const handleClick = (e: React.MouseEvent) => {
(e.currentTarget as HTMLElement).dispatchEvent(
new CustomEvent("page-link-click", {
bubbles: true,
detail: { pageId: node.attrs.pageId },
}),
);
};
return (
<NodeViewWrapper className="page-link-block" data-page-link={node.attrs.pageId} onClick={handleClick}>
<FileText className="h-5 w-5 shrink-0" />
<span>{title}</span>
</NodeViewWrapper>
);
}
export const PageLinkExtension = Node.create({
name: "pageLink",
group: "block",
atom: true,
addAttributes() {
return {
pageId: { default: null },
};
},
parseHTML() {
return [
{ tag: 'div[data-page-link]' },
{ tag: 'span[data-page-link]' },
];
},
renderHTML({ HTMLAttributes }) {
return [
"div",
mergeAttributes(HTMLAttributes, {
"data-page-link": HTMLAttributes.pageId,
class: "page-link-block",
}),
];
},
addCommands() {
return {
insertPageLink:
(attrs) =>
({ chain }) => {
return chain().insertContent({ type: this.name, attrs }).run();
},
};
},
addNodeView() {
return ReactNodeViewRenderer(PageLinkNodeView);
},
});

View File

@@ -0,0 +1,26 @@
import { createContext, useContext, useMemo } from "react";
const PageTitleContext = createContext<Map<string, string>>(new Map());
interface PageTitleProviderProps {
pages: { id: string; title: string }[];
children: React.ReactNode;
}
export function PageTitleProvider({ pages, children }: PageTitleProviderProps) {
const titleMap = useMemo(
() => new Map(pages.map((p) => [p.id, p.title])),
[pages],
);
return (
<PageTitleContext.Provider value={titleMap}>
{children}
</PageTitleContext.Provider>
);
}
export function usePageTitle(pageId: string): string {
const titleMap = useContext(PageTitleContext);
return titleMap.get(pageId) ?? "Untitled";
}

View File

@@ -0,0 +1,119 @@
import { useMemo } from "react";
import { FileText, Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
interface PageTreeProps {
pages: Array<{
id: string;
parentPageId: string | null;
title: string;
}>;
activePageId: string;
onNavigate: (pageId: string) => void;
onCreateChild: (parentPageId: string) => void;
}
interface TreeNode {
id: string;
title: string;
children: TreeNode[];
}
export function PageTree({
pages,
activePageId,
onNavigate,
onCreateChild,
}: PageTreeProps) {
const tree = useMemo(() => {
const childrenMap = new Map<string | null, typeof pages>([]);
for (const page of pages) {
const key = page.parentPageId;
const existing = childrenMap.get(key) ?? [];
existing.push(page);
childrenMap.set(key, existing);
}
function buildTree(parentId: string | null): TreeNode[] {
const children = childrenMap.get(parentId) ?? [];
return children.map((page) => ({
id: page.id,
title: page.title,
children: buildTree(page.id),
}));
}
return buildTree(null);
}, [pages]);
return (
<div className="space-y-0.5">
{tree.map((node) => (
<PageTreeNode
key={node.id}
node={node}
depth={0}
activePageId={activePageId}
onNavigate={onNavigate}
onCreateChild={onCreateChild}
/>
))}
</div>
);
}
interface PageTreeNodeProps {
node: TreeNode;
depth: number;
activePageId: string;
onNavigate: (pageId: string) => void;
onCreateChild: (parentPageId: string) => void;
}
function PageTreeNode({
node,
depth,
activePageId,
onNavigate,
onCreateChild,
}: PageTreeNodeProps) {
const isActive = node.id === activePageId;
return (
<div>
<div
className={`group flex items-center gap-1.5 rounded-sm px-2 py-1 text-sm cursor-pointer ${
isActive
? "bg-accent text-accent-foreground"
: "text-muted-foreground hover:bg-muted hover:text-foreground"
}`}
style={{ paddingLeft: `${depth * 12 + 8}px` }}
onClick={() => onNavigate(node.id)}
>
<FileText className="h-3.5 w-3.5 shrink-0" />
<span className="truncate flex-1">{node.title}</span>
<Button
variant="ghost"
size="icon"
className="h-5 w-5 opacity-0 group-hover:opacity-100 shrink-0"
onClick={(e) => {
e.stopPropagation();
onCreateChild(node.id);
}}
>
<Plus className="h-3 w-3" />
</Button>
</div>
{node.children.map((child) => (
<PageTreeNode
key={child.id}
node={child}
depth={depth + 1}
activePageId={activePageId}
onNavigate={onNavigate}
onCreateChild={onCreateChild}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,142 @@
import { useCallback } from "react";
import { Loader2, AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { QuestionForm } from "@/components/QuestionForm";
import { ContentProposalReview } from "./ContentProposalReview";
import { RefineSpawnDialog } from "../RefineSpawnDialog";
import { useRefineAgent } from "@/hooks";
interface RefineAgentPanelProps {
initiativeId: string;
}
export function RefineAgentPanel({ initiativeId }: RefineAgentPanelProps) {
// All agent logic is now encapsulated in the hook
const { state, agent, questions, proposals, spawn, resume, refresh } = useRefineAgent(initiativeId);
// spawn.mutate and resume.mutate are stable (ref-backed in useRefineAgent),
// so these callbacks won't change on every render.
const handleSpawn = useCallback((instruction?: string) => {
spawn.mutate({
initiativeId,
instruction,
});
}, [initiativeId, spawn.mutate]);
const handleAnswerSubmit = useCallback(
(answers: Record<string, string>) => {
resume.mutate(answers);
},
[resume.mutate],
);
const handleDismiss = useCallback(() => {
refresh();
}, [refresh]);
// No active agent — show spawn button
if (state === "none") {
return (
<div className="mb-3">
<RefineSpawnDialog
triggerText="Refine with Agent"
title="Refine Initiative Content"
description="An agent will review all pages and suggest improvements. Optionally tell it what to focus on."
instructionPlaceholder="What should the agent focus on? (optional)"
isSpawning={spawn.isPending}
error={spawn.error?.message}
onSpawn={handleSpawn}
/>
</div>
);
}
// Running
if (state === "running") {
return (
<div className="mb-3 flex items-center gap-2 rounded-lg border border-border bg-card px-3 py-2">
<Loader2 className="h-3.5 w-3.5 animate-spin text-primary" />
<span className="text-sm text-muted-foreground">
Architect is refining...
</span>
</div>
);
}
// Waiting for input — show inline questions
if (state === "waiting" && questions) {
return (
<div className="mb-3 rounded-lg border border-border bg-card p-4">
<h3 className="text-sm font-semibold mb-3">Agent has questions</h3>
<QuestionForm
questions={questions.questions}
onSubmit={handleAnswerSubmit}
onCancel={() => {
// Can't cancel mid-question — just dismiss
}}
isSubmitting={resume.isPending}
/>
</div>
);
}
// Completed with proposals
if (state === "completed" && proposals && proposals.length > 0) {
return (
<div className="mb-3">
<ContentProposalReview
proposals={proposals}
agentCreatedAt={new Date(agent!.createdAt)}
agentId={agent!.id}
onDismiss={handleDismiss}
/>
</div>
);
}
// Completed without proposals (or generic result)
if (state === "completed") {
return (
<div className="mb-3 flex items-center gap-2 rounded-lg border border-border bg-card px-3 py-2">
<span className="text-sm text-muted-foreground">
Agent completed no changes proposed.
</span>
<Button variant="ghost" size="sm" onClick={handleDismiss}>
Dismiss
</Button>
</div>
);
}
// Crashed
if (state === "crashed") {
return (
<div className="mb-3 rounded-lg border border-destructive/50 bg-destructive/5 px-3 py-2">
<div className="flex items-center gap-2">
<AlertCircle className="h-3.5 w-3.5 text-destructive" />
<span className="text-sm text-destructive">Agent crashed</span>
<RefineSpawnDialog
triggerText="Retry"
title="Refine Initiative Content"
description="An agent will review all pages and suggest improvements."
instructionPlaceholder="What should the agent focus on? (optional)"
isSpawning={spawn.isPending}
error={spawn.error?.message}
onSpawn={handleSpawn}
trigger={
<Button
variant="outline"
size="sm"
className="ml-auto"
>
Retry
</Button>
}
/>
</div>
</div>
);
}
return null;
}

View File

@@ -0,0 +1,88 @@
import {
useState,
useEffect,
useCallback,
forwardRef,
useImperativeHandle,
} from "react";
import type { SlashCommandItem } from "./slash-command-items";
export interface SlashCommandListRef {
onKeyDown: (props: { event: KeyboardEvent }) => boolean;
}
interface SlashCommandListProps {
items: SlashCommandItem[];
command: (item: SlashCommandItem) => void;
}
export const SlashCommandList = forwardRef<
SlashCommandListRef,
SlashCommandListProps
>(({ items, command }, ref) => {
const [selectedIndex, setSelectedIndex] = useState(0);
useEffect(() => {
setSelectedIndex(0);
}, [items]);
const selectItem = useCallback(
(index: number) => {
const item = items[index];
if (item) {
command(item);
}
},
[items, command],
);
useImperativeHandle(ref, () => ({
onKeyDown: ({ event }: { event: KeyboardEvent }) => {
if (event.key === "ArrowUp") {
setSelectedIndex((prev) => (prev + items.length - 1) % items.length);
return true;
}
if (event.key === "ArrowDown") {
setSelectedIndex((prev) => (prev + 1) % items.length);
return true;
}
if (event.key === "Enter") {
selectItem(selectedIndex);
return true;
}
return false;
},
}));
if (items.length === 0) {
return null;
}
return (
<div className="z-50 min-w-[200px] overflow-hidden rounded-md border border-border bg-popover p-1 shadow-md">
{items.map((item, index) => (
<button
key={item.label}
onClick={() => selectItem(index)}
className={`flex w-full items-center gap-2 rounded-sm px-2 py-1.5 text-sm ${
index === selectedIndex
? "bg-accent text-accent-foreground"
: "text-popover-foreground"
}`}
>
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded border border-border bg-muted text-xs font-mono">
{item.icon}
</span>
<div className="flex flex-col items-start">
<span className="font-medium">{item.label}</span>
<span className="text-xs text-muted-foreground">
{item.description}
</span>
</div>
</button>
))}
</div>
);
});
SlashCommandList.displayName = "SlashCommandList";

View File

@@ -0,0 +1,121 @@
import { Extension } from "@tiptap/react";
import { ReactRenderer } from "@tiptap/react";
import Suggestion from "@tiptap/suggestion";
import tippy, { type Instance as TippyInstance } from "tippy.js";
import {
slashCommandItems,
type SlashCommandItem,
} from "./slash-command-items";
import { SlashCommandList, type SlashCommandListRef } from "./SlashCommandList";
export const SlashCommands = Extension.create({
name: "slashCommands",
addStorage() {
return {
onSubpageCreate: null as ((editor: unknown) => void) | null,
};
},
addOptions() {
return {
suggestion: {
char: "/",
startOfLine: false,
command: ({
editor,
range,
props,
}: {
editor: ReturnType<typeof import("@tiptap/react").useEditor>;
range: { from: number; to: number };
props: SlashCommandItem;
}) => {
// Delete the slash command text
editor.chain().focus().deleteRange(range).run();
// Execute the selected command
props.action(editor);
},
items: ({ query }: { query: string }): SlashCommandItem[] => {
return slashCommandItems.filter((item) =>
item.label.toLowerCase().includes(query.toLowerCase()),
);
},
render: () => {
let component: ReactRenderer<SlashCommandListRef> | null = null;
let popup: TippyInstance[] | null = null;
return {
onStart: (props: {
editor: ReturnType<typeof import("@tiptap/react").useEditor>;
clientRect: (() => DOMRect | null) | null;
items: SlashCommandItem[];
command: (item: SlashCommandItem) => void;
}) => {
component = new ReactRenderer(SlashCommandList, {
props: {
items: props.items,
command: props.command,
},
editor: props.editor,
});
const getReferenceClientRect = props.clientRect;
popup = tippy("body", {
getReferenceClientRect: getReferenceClientRect as () => DOMRect,
appendTo: () => document.body,
content: component.element,
showOnCreate: true,
interactive: true,
trigger: "manual",
placement: "bottom-start",
});
},
onUpdate: (props: {
items: SlashCommandItem[];
command: (item: SlashCommandItem) => void;
clientRect: (() => DOMRect | null) | null;
}) => {
component?.updateProps({
items: props.items,
command: props.command,
});
if (popup?.[0]) {
popup[0].setProps({
getReferenceClientRect:
props.clientRect as unknown as () => DOMRect,
});
}
},
onKeyDown: (props: { event: KeyboardEvent }) => {
if (props.event.key === "Escape") {
popup?.[0]?.hide();
return true;
}
return component?.ref?.onKeyDown(props) ?? false;
},
onExit: () => {
popup?.[0]?.destroy();
component?.destroy();
},
};
},
},
};
},
addProseMirrorPlugins() {
return [
Suggestion({
editor: this.editor,
...this.options.suggestion,
}),
];
},
});

View File

@@ -0,0 +1,361 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useEditor, EditorContent, Extension } from "@tiptap/react";
import type { Editor } from "@tiptap/react";
import { GripVertical, Plus } from "lucide-react";
import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder";
import Link from "@tiptap/extension-link";
import { Table, TableRow, TableCell, TableHeader } from "@tiptap/extension-table";
import { Plugin, PluginKey, NodeSelection, TextSelection } from "@tiptap/pm/state";
import { Fragment, Slice, type Node as PmNode } from "@tiptap/pm/model";
import { SlashCommands } from "./SlashCommands";
import { PageLinkExtension } from "./PageLinkExtension";
import {
BlockSelectionExtension,
blockSelectionKey,
getBlockRange,
} from "./BlockSelectionExtension";
interface TiptapEditorProps {
content: string | null;
onUpdate: (json: string) => void;
pageId: string;
onPageLinkClick?: (pageId: string) => void;
onSubpageCreate?: (
editor: Editor,
) => void;
onPageLinkDeleted?: (pageId: string, redo: () => void) => void;
}
export function TiptapEditor({
content,
onUpdate,
pageId,
onPageLinkClick,
onSubpageCreate,
onPageLinkDeleted,
}: TiptapEditorProps) {
const containerRef = useRef<HTMLDivElement>(null);
const onPageLinkDeletedRef = useRef(onPageLinkDeleted);
onPageLinkDeletedRef.current = onPageLinkDeleted;
const blockIndexRef = useRef<number | null>(null);
const savedBlockSelRef = useRef<{ anchorIndex: number; headIndex: number } | null>(null);
const editor = useEditor(
{
extensions: [
StarterKit,
Table.configure({ resizable: true, cellMinWidth: 50 }),
TableRow,
TableCell,
TableHeader,
Placeholder.configure({
includeChildren: true,
placeholder: ({ node }) => {
if (node.type.name === 'heading') {
return `Heading ${node.attrs.level}`;
}
return "Type '/' for commands...";
},
}),
Link.configure({
openOnClick: false,
}),
SlashCommands,
PageLinkExtension,
BlockSelectionExtension,
// Detect pageLink node deletions by comparing old/new doc state
Extension.create({
name: "pageLinkDeletionDetector",
addStorage() {
return { skipDetection: false };
},
addProseMirrorPlugins() {
const tiptapEditor = this.editor;
return [
new Plugin({
key: new PluginKey("pageLinkDeletionDetector"),
appendTransaction(_transactions, oldState, newState) {
if (oldState.doc.eq(newState.doc)) return null;
const oldLinks = new Set<string>();
oldState.doc.descendants((node) => {
if (node.type.name === "pageLink" && node.attrs.pageId) {
oldLinks.add(node.attrs.pageId);
}
});
const newLinks = new Set<string>();
newState.doc.descendants((node) => {
if (node.type.name === "pageLink" && node.attrs.pageId) {
newLinks.add(node.attrs.pageId);
}
});
for (const removedPageId of oldLinks) {
if (!newLinks.has(removedPageId)) {
// Fire async to avoid dispatching during appendTransaction
setTimeout(() => {
if (tiptapEditor.storage.pageLinkDeletionDetector.skipDetection) {
tiptapEditor.storage.pageLinkDeletionDetector.skipDetection = false;
return;
}
// Undo the deletion immediately so the link reappears
tiptapEditor.commands.undo();
// Pass a redo function so the caller can re-delete if confirmed
onPageLinkDeletedRef.current?.(
removedPageId,
() => {
tiptapEditor.storage.pageLinkDeletionDetector.skipDetection = true;
tiptapEditor.commands.redo();
},
);
}, 0);
}
}
return null;
},
}),
];
},
}),
],
content: content ? JSON.parse(content) : undefined,
onUpdate: ({ editor: e }) => {
onUpdate(JSON.stringify(e.getJSON()));
},
editorProps: {
attributes: {
class:
"prose prose-sm prose-p:my-1 prose-headings:mb-1 prose-headings:mt-3 prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5 prose-blockquote:my-1 prose-pre:my-1 prose-hr:my-2 dark:prose-invert max-w-none focus:outline-none min-h-[400px] pl-11 pr-4 py-1",
},
},
},
[pageId],
);
// Wire the onSubpageCreate callback into editor storage
useEffect(() => {
if (editor && onSubpageCreate) {
editor.storage.slashCommands.onSubpageCreate = (ed: Editor) => {
onSubpageCreate(ed);
};
}
}, [editor, onSubpageCreate]);
// Handle page link clicks via custom event
const handlePageLinkClick = useCallback(
(e: Event) => {
const detail = (e as CustomEvent).detail;
if (detail?.pageId && onPageLinkClick) {
onPageLinkClick(detail.pageId);
}
},
[onPageLinkClick],
);
useEffect(() => {
const el = containerRef.current;
if (!el) return;
el.addEventListener("page-link-click", handlePageLinkClick);
return () =>
el.removeEventListener("page-link-click", handlePageLinkClick);
}, [handlePageLinkClick]);
// Floating drag handle: track which block the mouse is over
const [handlePos, setHandlePos] = useState<{ top: number; height: number } | null>(null);
const blockElRef = useRef<HTMLElement | null>(null);
const onMouseMove = useCallback((e: React.MouseEvent) => {
// If hovering the handle itself, keep current position
if ((e.target as HTMLElement).closest("[data-block-handle-row]")) return;
const editorEl = containerRef.current?.querySelector(".ProseMirror");
if (!editorEl || !editor) return;
// Walk from event target up to a direct child of .ProseMirror
let target = e.target as HTMLElement;
while (target && target !== editorEl && target.parentElement !== editorEl) {
target = target.parentElement!;
}
if (target && target !== editorEl && target.parentElement === editorEl) {
blockElRef.current = target;
const editorRect = editorEl.getBoundingClientRect();
const blockRect = target.getBoundingClientRect();
setHandlePos({
top: blockRect.top - editorRect.top,
height: blockRect.height,
});
// Track top-level block index for block selection
try {
const pos = editor.view.posAtDOM(target, 0);
blockIndexRef.current = editor.view.state.doc.resolve(pos).index(0);
} catch {
blockIndexRef.current = null;
}
}
// Don't clear — only onMouseLeave clears
}, [editor]);
const onMouseLeave = useCallback(() => {
setHandlePos(null);
blockElRef.current = null;
blockIndexRef.current = null;
}, []);
// Click on drag handle → select block (Shift+click extends)
const onHandleClick = useCallback(
(e: React.MouseEvent) => {
if (!editor) return;
const idx = blockIndexRef.current;
if (idx == null) return;
// Use saved state from mousedown (PM may have cleared it due to focus change)
const existing = savedBlockSelRef.current;
let newSel;
if (e.shiftKey && existing) {
newSel = { anchorIndex: existing.anchorIndex, headIndex: idx };
} else {
newSel = { anchorIndex: idx, headIndex: idx };
}
const tr = editor.view.state.tr.setMeta(blockSelectionKey, newSel);
tr.setMeta("blockSelectionInternal", true);
editor.view.dispatch(tr);
// Refocus editor so Shift+Arrow keys reach PM's handleKeyDown
editor.view.focus();
},
[editor],
);
// Add a new empty paragraph below the hovered block
const onHandleAdd = useCallback(() => {
if (!editor || !blockElRef.current) return;
const view = editor.view;
try {
const pos = view.posAtDOM(blockElRef.current, 0);
const $pos = view.state.doc.resolve(pos);
const after = $pos.after($pos.depth);
const paragraph = view.state.schema.nodes.paragraph.create();
const tr = view.state.tr.insert(after, paragraph);
// Place cursor inside the new paragraph
tr.setSelection(TextSelection.create(tr.doc, after + 1));
view.dispatch(tr);
view.focus();
} catch {
// posAtDOM can throw if the element isn't in the editor
}
}, [editor]);
// Initiate ProseMirror-native drag when handle is dragged
const onHandleDragStart = useCallback(
(e: React.DragEvent) => {
if (!editor || !blockElRef.current) return;
const view = editor.view;
const el = blockElRef.current;
// Use saved state from mousedown (PM may have cleared it due to focus change)
const bsel = savedBlockSelRef.current;
try {
// Multi-block drag: if block selection is active and hovered block is in range
if (bsel && blockIndexRef.current != null) {
const from = Math.min(bsel.anchorIndex, bsel.headIndex);
const to = Math.max(bsel.anchorIndex, bsel.headIndex);
if (blockIndexRef.current >= from && blockIndexRef.current <= to) {
const blockRange = getBlockRange(view.state, bsel);
if (blockRange) {
const nodes: PmNode[] = [];
let idx = 0;
view.state.doc.forEach((node) => {
if (idx >= from && idx <= to) nodes.push(node);
idx++;
});
const sel = TextSelection.create(
view.state.doc,
blockRange.fromPos,
blockRange.toPos,
);
const tr = view.state.tr.setSelection(sel);
tr.setMeta("blockSelectionInternal", true);
view.dispatch(tr);
view.dragging = {
slice: new Slice(Fragment.from(nodes), 0, 0),
move: true,
};
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setDragImage(el, 0, 0);
e.dataTransfer.setData("application/x-pm-drag", "true");
return;
}
}
}
// Single-block drag (existing behavior)
const pos = view.posAtDOM(el, 0);
const $pos = view.state.doc.resolve(pos);
const before = $pos.before($pos.depth);
const sel = NodeSelection.create(view.state.doc, before);
view.dispatch(view.state.tr.setSelection(sel));
view.dragging = { slice: sel.content(), move: true };
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setDragImage(el, 0, 0);
e.dataTransfer.setData("application/x-pm-drag", "true");
} catch {
// posAtDOM can throw if the element isn't in the editor
}
},
[editor],
);
return (
<div
ref={containerRef}
className="relative"
onMouseMove={onMouseMove}
onMouseLeave={onMouseLeave}
>
{handlePos && (
<div
data-block-handle-row
className="absolute left-0 flex items-start z-10"
style={{ top: handlePos.top + 1 }}
>
<div
onClick={onHandleAdd}
onMouseDown={(e) => e.preventDefault()}
className="flex items-center justify-center w-5 h-6 cursor-pointer rounded hover:bg-muted"
>
<Plus className="h-3.5 w-3.5 text-muted-foreground/60" />
</div>
<div
data-drag-handle
draggable
onMouseDown={() => {
if (editor) {
savedBlockSelRef.current = blockSelectionKey.getState(editor.view.state) ?? null;
}
}}
onClick={onHandleClick}
onDragStart={onHandleDragStart}
className="flex items-center justify-center w-5 h-6 cursor-grab rounded hover:bg-muted"
>
<GripVertical className="h-3.5 w-3.5 text-muted-foreground/60" />
</div>
</div>
)}
<EditorContent editor={editor} />
</div>
);
}

View File

@@ -0,0 +1,86 @@
import type { Editor } from "@tiptap/react";
export interface SlashCommandItem {
label: string;
icon: string;
description: string;
/** If true, the action reads onSubpageCreate from editor.storage.slashCommands */
isSubpage?: boolean;
action: (editor: Editor) => void;
}
export const slashCommandItems: SlashCommandItem[] = [
{
label: "Heading 1",
icon: "H1",
description: "Large heading",
action: (editor) =>
editor.chain().focus().toggleHeading({ level: 1 }).run(),
},
{
label: "Heading 2",
icon: "H2",
description: "Medium heading",
action: (editor) =>
editor.chain().focus().toggleHeading({ level: 2 }).run(),
},
{
label: "Heading 3",
icon: "H3",
description: "Small heading",
action: (editor) =>
editor.chain().focus().toggleHeading({ level: 3 }).run(),
},
{
label: "Bullet List",
icon: "UL",
description: "Unordered list",
action: (editor) => editor.chain().focus().toggleBulletList().run(),
},
{
label: "Numbered List",
icon: "OL",
description: "Ordered list",
action: (editor) => editor.chain().focus().toggleOrderedList().run(),
},
{
label: "Code Block",
icon: "<>",
description: "Code snippet",
action: (editor) => editor.chain().focus().toggleCodeBlock().run(),
},
{
label: "Quote",
icon: "\"",
description: "Block quote",
action: (editor) => editor.chain().focus().toggleBlockquote().run(),
},
{
label: "Divider",
icon: "---",
description: "Horizontal rule",
action: (editor) => editor.chain().focus().setHorizontalRule().run(),
},
{
label: "Table",
icon: "T#",
description: "Insert a table",
action: (editor) =>
editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(),
},
{
label: "Subpage",
icon: "\uD83D\uDCC4",
description: "Create a linked subpage",
isSubpage: true,
action: (editor) => {
const callback = editor.storage.slashCommands
?.onSubpageCreate as
| ((editor: Editor) => void)
| undefined;
if (callback) {
callback(editor);
}
},
},
];

View File

@@ -0,0 +1,90 @@
import { useCallback, useMemo } from "react";
import { Loader2, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { trpc } from "@/lib/trpc";
import { useSpawnMutation } from "@/hooks/useSpawnMutation";
interface BreakdownSectionProps {
initiativeId: string;
phasesLoaded: boolean;
phases: Array<{ status: string }>;
}
export function BreakdownSection({
initiativeId,
phasesLoaded,
phases,
}: BreakdownSectionProps) {
const utils = trpc.useUtils();
// Breakdown agent tracking
const agentsQuery = trpc.listAgents.useQuery();
const allAgents = agentsQuery.data ?? [];
const breakdownAgent = useMemo(() => {
const candidates = allAgents
.filter(
(a) =>
a.mode === "breakdown" &&
a.taskId === initiativeId &&
["running", "waiting_for_input", "idle"].includes(a.status),
)
.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
return candidates[0] ?? null;
}, [allAgents, initiativeId]);
const isBreakdownRunning = breakdownAgent?.status === "running";
const breakdownSpawn = useSpawnMutation(trpc.spawnArchitectBreakdown.useMutation, {
onSuccess: () => {
void utils.listAgents.invalidate();
},
showToast: false, // We show our own error UI
});
const handleBreakdown = useCallback(() => {
breakdownSpawn.spawn({ initiativeId });
}, [initiativeId, breakdownSpawn]);
// Don't render if we have phases
if (phasesLoaded && phases.length > 0) {
return null;
}
// Don't render during loading
if (!phasesLoaded) {
return null;
}
return (
<div className="py-8 text-center space-y-3">
<p className="text-muted-foreground">No phases yet</p>
{isBreakdownRunning ? (
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Breaking down initiative...
</div>
) : (
<Button
variant="outline"
size="sm"
onClick={handleBreakdown}
disabled={breakdownSpawn.isSpawning}
className="gap-1.5"
>
<Sparkles className="h-3.5 w-3.5" />
{breakdownSpawn.isSpawning
? "Starting..."
: "Break Down Initiative"}
</Button>
)}
{breakdownSpawn.isError && (
<p className="text-xs text-destructive">
{breakdownSpawn.error}
</p>
)}
</div>
);
}

View File

@@ -0,0 +1,150 @@
import { createContext, useContext, useState, useCallback, useMemo, ReactNode } from "react";
import type { SerializedTask } from "@/components/TaskRow";
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
export interface TaskCounts {
complete: number;
total: number;
}
export interface FlatTaskEntry {
task: SerializedTask;
phaseName: string;
agentName: string | null;
blockedBy: Array<{ name: string; status: string }>;
dependents: Array<{ name: string; status: string }>;
}
export interface PhaseData {
id: string;
initiativeId: string;
number: number;
name: string;
description: string | null;
status: string;
createdAt: string | Date;
updatedAt: string | Date;
}
// ---------------------------------------------------------------------------
// Context
// ---------------------------------------------------------------------------
interface ExecutionContextValue {
// Task selection
selectedTaskId: string | null;
setSelectedTaskId: (taskId: string | null) => void;
// Task counts by phase
taskCountsByPhase: Record<string, TaskCounts>;
handleTaskCounts: (phaseId: string, counts: TaskCounts) => void;
// Tasks by phase
tasksByPhase: Record<string, FlatTaskEntry[]>;
handleRegisterTasks: (phaseId: string, entries: FlatTaskEntry[]) => void;
// Derived data
allFlatTasks: FlatTaskEntry[];
selectedEntry: FlatTaskEntry | null;
tasksComplete: number;
tasksTotal: number;
}
const ExecutionContext = createContext<ExecutionContextValue | null>(null);
// ---------------------------------------------------------------------------
// Provider
// ---------------------------------------------------------------------------
interface ExecutionProviderProps {
children: ReactNode;
}
export function ExecutionProvider({ children }: ExecutionProviderProps) {
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [taskCountsByPhase, setTaskCountsByPhase] = useState<
Record<string, TaskCounts>
>({});
const [tasksByPhase, setTasksByPhase] = useState<
Record<string, FlatTaskEntry[]>
>({});
const handleTaskCounts = useCallback(
(phaseId: string, counts: TaskCounts) => {
setTaskCountsByPhase((prev) => {
if (
prev[phaseId]?.complete === counts.complete &&
prev[phaseId]?.total === counts.total
) {
return prev;
}
return { ...prev, [phaseId]: counts };
});
},
[],
);
const handleRegisterTasks = useCallback(
(phaseId: string, entries: FlatTaskEntry[]) => {
setTasksByPhase((prev) => {
if (prev[phaseId] === entries) return prev;
return { ...prev, [phaseId]: entries };
});
},
[],
);
const allFlatTasks = useMemo(
() => Object.values(tasksByPhase).flat(),
[tasksByPhase]
);
const selectedEntry = useMemo(
() => selectedTaskId
? allFlatTasks.find((e) => e.task.id === selectedTaskId) ?? null
: null,
[selectedTaskId, allFlatTasks]
);
const { tasksComplete, tasksTotal } = useMemo(() => {
const allTaskCounts = Object.values(taskCountsByPhase);
return {
tasksComplete: allTaskCounts.reduce((s, c) => s + c.complete, 0),
tasksTotal: allTaskCounts.reduce((s, c) => s + c.total, 0),
};
}, [taskCountsByPhase]);
const value: ExecutionContextValue = {
selectedTaskId,
setSelectedTaskId,
taskCountsByPhase,
handleTaskCounts,
tasksByPhase,
handleRegisterTasks,
allFlatTasks,
selectedEntry,
tasksComplete,
tasksTotal,
};
return (
<ExecutionContext.Provider value={value}>
{children}
</ExecutionContext.Provider>
);
}
// ---------------------------------------------------------------------------
// Hook
// ---------------------------------------------------------------------------
export function useExecutionContext() {
const context = useContext(ExecutionContext);
if (!context) {
throw new Error("useExecutionContext must be used within ExecutionProvider");
}
return context;
}

View File

@@ -0,0 +1,60 @@
import { useCallback, useMemo } from "react";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { trpc } from "@/lib/trpc";
interface PhaseActionsProps {
initiativeId: string;
phases: Array<{ id: string; status: string }>;
}
export function PhaseActions({ initiativeId, phases }: PhaseActionsProps) {
const queuePhaseMutation = trpc.queuePhase.useMutation();
// Breakdown agent tracking for status display
const agentsQuery = trpc.listAgents.useQuery();
const allAgents = agentsQuery.data ?? [];
const breakdownAgent = useMemo(() => {
const candidates = allAgents
.filter(
(a) =>
a.mode === "breakdown" &&
a.taskId === initiativeId &&
["running", "waiting_for_input", "idle"].includes(a.status),
)
.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
return candidates[0] ?? null;
}, [allAgents, initiativeId]);
const isBreakdownRunning = breakdownAgent?.status === "running";
const hasPendingPhases = phases.some((p) => p.status === "pending");
const handleQueueAll = useCallback(() => {
const pendingPhases = phases.filter((p) => p.status === "pending");
for (const phase of pendingPhases) {
queuePhaseMutation.mutate({ phaseId: phase.id });
}
}, [phases, queuePhaseMutation]);
return (
<div className="flex items-center gap-2">
{isBreakdownRunning && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
Breaking down...
</div>
)}
<Button
variant="outline"
size="sm"
disabled={!hasPendingPhases}
onClick={handleQueueAll}
>
Queue All
</Button>
</div>
);
}

View File

@@ -0,0 +1,137 @@
import { useState, useCallback, useEffect } from "react";
import { trpc } from "@/lib/trpc";
import { PhaseAccordion } from "@/components/PhaseAccordion";
import { PlanTasksFetcher } from "./PlanTasksFetcher";
import type { SerializedTask } from "@/components/TaskRow";
import type { TaskCounts, FlatTaskEntry } from "./ExecutionContext";
import { sortByPriorityAndQueueTime } from "@codewalk-district/shared";
interface PhaseWithTasksProps {
phase: {
id: string;
initiativeId: string;
number: number;
name: string;
description: string | null;
status: string;
createdAt: string;
updatedAt: string;
};
defaultExpanded: boolean;
onTaskClick: (taskId: string) => void;
onTaskCounts: (phaseId: string, counts: TaskCounts) => void;
registerTasks: (phaseId: string, entries: FlatTaskEntry[]) => void;
}
export function PhaseWithTasks({
phase,
defaultExpanded,
onTaskClick,
onTaskCounts,
registerTasks,
}: PhaseWithTasksProps) {
const plansQuery = trpc.listPlans.useQuery({ phaseId: phase.id });
const depsQuery = trpc.getPhaseDependencies.useQuery({ phaseId: phase.id });
const plans = plansQuery.data ?? [];
const planIds = plans.map((p) => p.id);
return (
<PhaseWithTasksInner
phase={phase}
planIds={planIds}
plansLoaded={plansQuery.isSuccess}
phaseDependencyIds={depsQuery.data?.dependencies ?? []}
defaultExpanded={defaultExpanded}
onTaskClick={onTaskClick}
onTaskCounts={onTaskCounts}
registerTasks={registerTasks}
/>
);
}
interface PhaseWithTasksInnerProps {
phase: PhaseWithTasksProps["phase"];
planIds: string[];
plansLoaded: boolean;
phaseDependencyIds: string[];
defaultExpanded: boolean;
onTaskClick: (taskId: string) => void;
onTaskCounts: (phaseId: string, counts: TaskCounts) => void;
registerTasks: (phaseId: string, entries: FlatTaskEntry[]) => void;
}
function PhaseWithTasksInner({
phase,
planIds,
plansLoaded,
phaseDependencyIds: _phaseDependencyIds,
defaultExpanded,
onTaskClick,
onTaskCounts,
registerTasks,
}: PhaseWithTasksInnerProps) {
const [planTasks, setPlanTasks] = useState<Record<string, SerializedTask[]>>(
{},
);
const handlePlanTasks = useCallback(
(planId: string, tasks: SerializedTask[]) => {
setPlanTasks((prev) => {
if (prev[planId] === tasks) return prev;
return { ...prev, [planId]: tasks };
});
},
[],
);
// Propagate derived counts and entries outside the setState updater
// to avoid synchronous setState-inside-setState cascades.
useEffect(() => {
const allTasks = Object.values(planTasks).flat();
const complete = allTasks.filter(
(t) => t.status === "completed",
).length;
onTaskCounts(phase.id, { complete, total: allTasks.length });
const entries: FlatTaskEntry[] = allTasks.map((task) => ({
task,
phaseName: `Phase ${phase.number}: ${phase.name}`,
agentName: null,
blockedBy: [],
dependents: [],
}));
registerTasks(phase.id, entries);
}, [planTasks, phase.id, phase.number, phase.name, onTaskCounts, registerTasks]);
const allTasks = planIds.flatMap((pid) => planTasks[pid] ?? []);
const sortedTasks = sortByPriorityAndQueueTime(allTasks);
const taskEntries = sortedTasks.map((task) => ({
task,
agentName: null as string | null,
blockedBy: [] as Array<{ name: string; status: string }>,
}));
const phaseDeps: Array<{ name: string; status: string }> = [];
return (
<>
{plansLoaded &&
planIds.map((planId) => (
<PlanTasksFetcher
key={planId}
planId={planId}
onTasks={handlePlanTasks}
/>
))}
<PhaseAccordion
phase={phase}
tasks={taskEntries}
defaultExpanded={defaultExpanded}
phaseDependencies={phaseDeps}
onTaskClick={onTaskClick}
/>
</>
);
}

View File

@@ -0,0 +1,74 @@
import { Skeleton } from "@/components/Skeleton";
import { useExecutionContext, type PhaseData } from "./ExecutionContext";
import { PhaseWithTasks } from "./PhaseWithTasks";
import { BreakdownSection } from "./BreakdownSection";
interface PhasesListProps {
initiativeId: string;
phases: PhaseData[];
phasesLoading: boolean;
phasesLoaded: boolean;
}
export function PhasesList({
initiativeId,
phases,
phasesLoading,
phasesLoaded,
}: PhasesListProps) {
const { setSelectedTaskId, handleTaskCounts, handleRegisterTasks } =
useExecutionContext();
const firstIncompletePhaseIndex = phases.findIndex(
(p) => p.status !== "completed",
);
if (phasesLoading) {
return (
<div className="space-y-1 pt-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
);
}
if (phasesLoaded && phases.length === 0) {
return (
<BreakdownSection
initiativeId={initiativeId}
phasesLoaded={phasesLoaded}
phases={phases}
/>
);
}
return (
<>
{phasesLoaded &&
phases.map((phase, idx) => {
const serializedPhase = {
id: phase.id,
initiativeId: phase.initiativeId,
number: phase.number,
name: phase.name,
description: phase.description,
status: phase.status,
createdAt: String(phase.createdAt),
updatedAt: String(phase.updatedAt),
};
return (
<PhaseWithTasks
key={phase.id}
phase={serializedPhase}
defaultExpanded={idx === firstIncompletePhaseIndex}
onTaskClick={setSelectedTaskId}
onTaskCounts={handleTaskCounts}
registerTasks={handleRegisterTasks}
/>
);
})}
</>
);
}

View File

@@ -0,0 +1,20 @@
import { useEffect } from "react";
import { trpc } from "@/lib/trpc";
import type { SerializedTask } from "@/components/TaskRow";
interface PlanTasksFetcherProps {
planId: string;
onTasks: (planId: string, tasks: SerializedTask[]) => void;
}
export function PlanTasksFetcher({ planId, onTasks }: PlanTasksFetcherProps) {
const tasksQuery = trpc.listTasks.useQuery({ planId });
useEffect(() => {
if (tasksQuery.data) {
onTasks(planId, tasksQuery.data as unknown as SerializedTask[]);
}
}, [tasksQuery.data, planId, onTasks]);
return null;
}

View File

@@ -0,0 +1,28 @@
import { ProgressPanel } from "@/components/ProgressPanel";
import { DecisionList } from "@/components/DecisionList";
import { useExecutionContext, type PhaseData } from "./ExecutionContext";
interface ProgressSidebarProps {
phases: PhaseData[];
}
export function ProgressSidebar({ phases }: ProgressSidebarProps) {
const { tasksComplete, tasksTotal } = useExecutionContext();
const phasesComplete = phases.filter(
(p) => p.status === "completed",
).length;
return (
<div className="space-y-6">
<ProgressPanel
phasesComplete={phasesComplete}
phasesTotal={phases.length}
tasksComplete={tasksComplete}
tasksTotal={tasksTotal}
/>
<DecisionList decisions={[]} />
</div>
);
}

View File

@@ -0,0 +1,34 @@
import { useCallback } from "react";
import { TaskDetailModal } from "@/components/TaskDetailModal";
import { useExecutionContext } from "./ExecutionContext";
import { trpc } from "@/lib/trpc";
export function TaskModal() {
const { selectedEntry, setSelectedTaskId } = useExecutionContext();
const queueTaskMutation = trpc.queueTask.useMutation();
const handleQueueTask = useCallback(
(taskId: string) => {
queueTaskMutation.mutate({ taskId });
setSelectedTaskId(null);
},
[queueTaskMutation, setSelectedTaskId],
);
const handleClose = useCallback(() => {
setSelectedTaskId(null);
}, [setSelectedTaskId]);
return (
<TaskDetailModal
task={selectedEntry?.task ?? null}
phaseName={selectedEntry?.phaseName ?? ""}
agentName={selectedEntry?.agentName ?? null}
dependencies={selectedEntry?.blockedBy ?? []}
dependents={selectedEntry?.dependents ?? []}
onClose={handleClose}
onQueueTask={handleQueueTask}
onStopTask={handleClose}
/>
);
}

View File

@@ -0,0 +1,9 @@
export { ExecutionProvider, useExecutionContext } from "./ExecutionContext";
export { BreakdownSection } from "./BreakdownSection";
export { PhaseActions } from "./PhaseActions";
export { PhasesList } from "./PhasesList";
export { PhaseWithTasks } from "./PhaseWithTasks";
export { PlanTasksFetcher } from "./PlanTasksFetcher";
export { ProgressSidebar } from "./ProgressSidebar";
export { TaskModal } from "./TaskModal";
export type { TaskCounts, FlatTaskEntry, PhaseData } from "./ExecutionContext";

View File

@@ -0,0 +1,18 @@
/**
* Shared React hooks for the Codewalk District frontend.
*
* This module provides reusable hooks for common patterns like
* debouncing, subscription management, and agent interactions.
*/
export { useAutoSave } from './useAutoSave.js';
export { useDebounce, useDebounceWithImmediate } from './useDebounce.js';
export { useRefineAgent } from './useRefineAgent.js';
export { useSubscriptionWithErrorHandling } from './useSubscriptionWithErrorHandling.js';
export type {
RefineAgentState,
ContentProposal,
SpawnRefineAgentOptions,
UseRefineAgentResult,
} from './useRefineAgent.js';

View File

@@ -0,0 +1,68 @@
import { useRef, useCallback, useEffect } from "react";
import { trpc } from "@/lib/trpc";
interface UseAutoSaveOptions {
debounceMs?: number;
onSaved?: () => void;
}
export function useAutoSave({ debounceMs = 1000, onSaved }: UseAutoSaveOptions = {}) {
const updateMutation = trpc.updatePage.useMutation({ onSuccess: onSaved });
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingRef = useRef<{
id: string;
title?: string;
content?: string | null;
} | null>(null);
const flush = useCallback(() => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
if (pendingRef.current) {
const data = pendingRef.current;
pendingRef.current = null;
const promise = updateMutation.mutateAsync(data);
// Prevent unhandled rejection when called from debounce timer
promise.catch(() => {});
return promise;
}
return Promise.resolve();
}, [updateMutation]);
const save = useCallback(
(id: string, data: { title?: string; content?: string | null }) => {
pendingRef.current = { id, ...data };
if (timerRef.current) {
clearTimeout(timerRef.current);
}
timerRef.current = setTimeout(() => void flush(), debounceMs);
},
[debounceMs, flush],
);
// Flush on unmount
useEffect(() => {
return () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
}
if (pendingRef.current) {
// Fire off the save — mutation will complete asynchronously
const data = pendingRef.current;
pendingRef.current = null;
updateMutation.mutate(data);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return {
save,
flush,
isSaving: updateMutation.isPending,
};
}

View File

@@ -0,0 +1,157 @@
import { useEffect, useState, useRef } from 'react';
/**
* Hook that debounces a value, delaying updates until after a specified delay.
*
* Useful for delaying API calls, search queries, or other expensive operations
* until the user has stopped typing or interacting.
*
* @param value - The value to debounce
* @param delayMs - Delay in milliseconds (default: 500)
* @returns The debounced value
*
* @example
* ```tsx
* function SearchInput() {
* const [query, setQuery] = useState('');
* const debouncedQuery = useDebounce(query, 300);
*
* // This effect will only run when debouncedQuery changes
* useEffect(() => {
* if (debouncedQuery) {
* performSearch(debouncedQuery);
* }
* }, [debouncedQuery]);
*
* return (
* <input
* value={query}
* onChange={(e) => setQuery(e.target.value)}
* placeholder="Search..."
* />
* );
* }
* ```
*/
export function useDebounce<T>(value: T, delayMs: number = 500): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
// Clear existing timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// Set new timeout
timeoutRef.current = setTimeout(() => {
setDebouncedValue(value);
}, delayMs);
// Cleanup function
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, [value, delayMs]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
return debouncedValue;
}
/**
* Alternative debounce hook that also provides immediate control.
*
* Returns both the debounced value and a function to immediately update it,
* useful when you need to bypass the debounce in certain cases.
*
* @param value - The value to debounce
* @param delayMs - Delay in milliseconds (default: 500)
* @returns Object with debouncedValue and setImmediate function
*
* @example
* ```tsx
* function AutoSaveForm() {
* const [formData, setFormData] = useState({ title: '', content: '' });
* const { debouncedValue: debouncedFormData, setImmediate } = useDebounceWithImmediate(formData, 1000);
*
* // Auto-save after 1 second of no changes
* useEffect(() => {
* saveFormData(debouncedFormData);
* }, [debouncedFormData]);
*
* const handleSubmit = () => {
* // Immediately save without waiting for debounce
* setImmediate(formData);
* submitForm(formData);
* };
*
* return (
* <form onSubmit={handleSubmit}>
* <input
* value={formData.title}
* onChange={(e) => setFormData(prev => ({ ...prev, title: e.target.value }))}
* />
* <button type="submit">Submit</button>
* </form>
* );
* }
* ```
*/
export function useDebounceWithImmediate<T>(value: T, delayMs: number = 500) {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
const timeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
// Clear existing timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
// Set new timeout
timeoutRef.current = setTimeout(() => {
setDebouncedValue(value);
}, delayMs);
// Cleanup function
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, [value, delayMs]);
// Cleanup on unmount
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);
const setImmediate = (newValue: T) => {
// Clear pending timeout
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
timeoutRef.current = null;
}
// Immediately update debounced value
setDebouncedValue(newValue);
};
return {
debouncedValue,
setImmediate,
};
}

View File

@@ -0,0 +1,253 @@
import { useMemo, useCallback, useRef } from 'react';
import { trpc } from '@/lib/trpc';
import type { Agent, PendingQuestions } from '@codewalk-district/shared';
export type RefineAgentState = 'none' | 'running' | 'waiting' | 'completed' | 'crashed';
export interface ContentProposal {
pageId: string;
pageTitle: string;
summary: string;
markdown: string;
}
export interface SpawnRefineAgentOptions {
initiativeId: string;
instruction?: string;
}
export interface UseRefineAgentResult {
/** Current refine agent for the initiative */
agent: Agent | null;
/** Current state of the refine agent */
state: RefineAgentState;
/** Questions from the agent (when state is 'waiting') */
questions: PendingQuestions | null;
/** Parsed content proposals (when state is 'completed') */
proposals: ContentProposal[] | null;
/** Raw result message (when state is 'completed') */
result: string | null;
/** Mutation for spawning a new refine agent */
spawn: {
mutate: (options: SpawnRefineAgentOptions) => void;
isPending: boolean;
error: Error | null;
};
/** Mutation for resuming agent with answers */
resume: {
mutate: (answers: Record<string, string>) => void;
isPending: boolean;
error: Error | null;
};
/** Whether any queries are loading */
isLoading: boolean;
/** Function to refresh agent data */
refresh: () => void;
}
/**
* Hook for managing refine agents for a specific initiative.
*
* Encapsulates the logic for finding, spawning, and interacting with refine agents
* that analyze and suggest improvements to initiative content.
*
* @param initiativeId - The ID of the initiative to manage refine agents for
* @returns Object with agent state, mutations, and helper functions
*
* @example
* ```tsx
* function RefineSection({ initiativeId }: { initiativeId: string }) {
* const {
* state,
* agent,
* questions,
* proposals,
* spawn,
* resume,
* refresh
* } = useRefineAgent(initiativeId);
*
* const handleSpawn = () => {
* spawn.mutate({
* initiativeId,
* instruction: 'Focus on clarity and structure'
* });
* };
*
* if (state === 'none') {
* return (
* <button onClick={handleSpawn} disabled={spawn.isPending}>
* Start Refine Agent
* </button>
* );
* }
*
* if (state === 'waiting' && questions) {
* return (
* <QuestionForm
* questions={questions.questions}
* onSubmit={(answers) => resume.mutate(answers)}
* isSubmitting={resume.isPending}
* />
* );
* }
*
* if (state === 'completed' && proposals) {
* return <ProposalReview proposals={proposals} onDismiss={refresh} />;
* }
*
* return <div>Agent is {state}...</div>;
* }
* ```
*/
export function useRefineAgent(initiativeId: string): UseRefineAgentResult {
const utils = trpc.useUtils();
// Query all agents and find the active refine agent
const agentsQuery = trpc.listAgents.useQuery();
const agents = agentsQuery.data ?? [];
const agent = useMemo(() => {
// Find the most recent refine agent for this initiative
const candidates = agents
.filter(
(a) =>
a.mode === 'refine' &&
a.initiativeId === initiativeId &&
['running', 'waiting_for_input', 'idle', 'crashed'].includes(a.status) &&
!a.userDismissedAt, // Exclude dismissed agents
)
.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
return candidates[0] ?? null;
}, [agents, initiativeId]);
const state: RefineAgentState = useMemo(() => {
if (!agent) return 'none';
switch (agent.status) {
case 'running':
return 'running';
case 'waiting_for_input':
return 'waiting';
case 'idle':
return 'completed';
case 'crashed':
return 'crashed';
default:
return 'none';
}
}, [agent]);
// Fetch questions when waiting for input
const questionsQuery = trpc.getAgentQuestions.useQuery(
{ id: agent?.id ?? '' },
{ enabled: state === 'waiting' && !!agent },
);
// Fetch result when completed
const resultQuery = trpc.getAgentResult.useQuery(
{ id: agent?.id ?? '' },
{ enabled: state === 'completed' && !!agent },
);
// Parse proposals from result
const { proposals, result } = useMemo(() => {
if (!resultQuery.data?.success || !resultQuery.data.message) {
return { proposals: null, result: null };
}
const message = resultQuery.data.message;
try {
const parsed = JSON.parse(message);
if (parsed.proposals && Array.isArray(parsed.proposals)) {
const proposals: ContentProposal[] = parsed.proposals.map(
(p: { pageId: string; title?: string; pageTitle?: string; summary: string; body?: string; markdown?: string }) => ({
pageId: p.pageId,
pageTitle: p.pageTitle ?? p.title ?? '',
summary: p.summary,
markdown: p.markdown ?? p.body ?? '',
}),
);
return { proposals, result: message };
}
} catch {
// Not JSON — treat as regular result
}
return { proposals: null, result: message };
}, [resultQuery.data]);
// Spawn mutation
const spawnMutation = trpc.spawnArchitectRefine.useMutation({
onSuccess: () => {
void utils.listAgents.invalidate();
},
});
// Resume mutation
const resumeMutation = trpc.resumeAgent.useMutation({
onSuccess: () => {
void utils.listAgents.invalidate();
},
});
// Keep mutation functions in refs so the returned spawn/resume objects are
// stable across renders. tRPC mutation objects change identity every render,
// which cascades into unstable callbacks → unstable props → Radix Dialog
// re-renders that trigger the React 19 compose-refs infinite loop.
const spawnMutateRef = useRef(spawnMutation.mutate);
spawnMutateRef.current = spawnMutation.mutate;
const agentRef = useRef(agent);
agentRef.current = agent;
const resumeMutateRef = useRef(resumeMutation.mutate);
resumeMutateRef.current = resumeMutation.mutate;
const spawnFn = useCallback(({ initiativeId, instruction }: SpawnRefineAgentOptions) => {
spawnMutateRef.current({
initiativeId,
instruction: instruction?.trim() || undefined,
});
}, []);
const spawn = useMemo(() => ({
mutate: spawnFn,
isPending: spawnMutation.isPending,
error: spawnMutation.error,
}), [spawnFn, spawnMutation.isPending, spawnMutation.error]);
const resumeFn = useCallback((answers: Record<string, string>) => {
const a = agentRef.current;
if (a) {
resumeMutateRef.current({ id: a.id, answers });
}
}, []);
const resume = useMemo(() => ({
mutate: resumeFn,
isPending: resumeMutation.isPending,
error: resumeMutation.error,
}), [resumeFn, resumeMutation.isPending, resumeMutation.error]);
const refresh = useCallback(() => {
void utils.listAgents.invalidate();
}, [utils]);
const isLoading = agentsQuery.isLoading ||
(state === 'waiting' && questionsQuery.isLoading) ||
(state === 'completed' && resultQuery.isLoading);
return {
agent,
state,
questions: questionsQuery.data ?? null,
proposals,
result,
spawn,
resume,
isLoading,
refresh,
};
}

View File

@@ -0,0 +1,49 @@
import { useCallback } from "react";
import { toast } from "sonner";
interface SpawnMutationOptions {
onSuccess?: () => void;
showToast?: boolean;
successMessage?: string;
errorMessage?: string;
}
export function useSpawnMutation<T>(
mutationFn: any,
options: SpawnMutationOptions = {}
) {
const {
onSuccess,
showToast = true,
successMessage = "Architect spawned",
errorMessage = "Failed to spawn architect",
} = options;
const mutation = mutationFn({
onSuccess: () => {
if (showToast) {
toast.success(successMessage);
}
onSuccess?.();
},
onError: () => {
if (showToast) {
toast.error(errorMessage);
}
},
});
const spawn = useCallback(
(params: T) => {
mutation.mutate(params);
},
[mutation]
);
return {
spawn,
isSpawning: mutation.isPending,
error: mutation.error?.message,
isError: mutation.isError,
};
}

View File

@@ -0,0 +1,180 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { trpc } from '@/lib/trpc';
import type { SubscriptionEvent } from '@codewalk-district/shared';
interface UseSubscriptionWithErrorHandlingOptions {
/** Called when subscription receives data */
onData?: (data: SubscriptionEvent) => void;
/** Called when subscription encounters an error */
onError?: (error: Error) => void;
/** Called when subscription starts */
onStarted?: () => void;
/** Called when subscription stops */
onStopped?: () => void;
/** Whether to automatically reconnect on errors (default: true) */
autoReconnect?: boolean;
/** Delay before attempting reconnection in ms (default: 1000) */
reconnectDelay?: number;
/** Maximum number of reconnection attempts (default: 5) */
maxReconnectAttempts?: number;
/** Whether the subscription is enabled (default: true) */
enabled?: boolean;
}
interface SubscriptionState {
isConnected: boolean;
isConnecting: boolean;
error: Error | null;
reconnectAttempts: number;
lastEventId: string | null;
}
/**
* Hook for managing tRPC subscriptions with error handling, reconnection, and cleanup.
*
* Provides automatic reconnection on connection failures, tracks connection state,
* and ensures proper cleanup on unmount.
*/
export function useSubscriptionWithErrorHandling(
subscription: () => ReturnType<typeof trpc.subscribeToEvents.useSubscription>,
options: UseSubscriptionWithErrorHandlingOptions = {}
) {
const {
autoReconnect = true,
reconnectDelay = 1000,
maxReconnectAttempts = 5,
enabled = true,
} = options;
const [state, setState] = useState<SubscriptionState>({
isConnected: false,
isConnecting: false,
error: null,
reconnectAttempts: 0,
lastEventId: null,
});
// Store callbacks in refs so they never appear in effect deps.
// Callers pass inline arrows that change identity every render —
// putting them in deps causes setState → re-render → new callback → effect re-fire → infinite loop.
const callbacksRef = useRef(options);
callbacksRef.current = options;
const reconnectAttemptsRef = useRef(0);
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const mountedRef = useRef(true);
// Clear reconnect timeout on unmount
useEffect(() => {
mountedRef.current = true;
return () => {
mountedRef.current = false;
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
}
callbacksRef.current.onStopped?.();
};
}, []);
const scheduleReconnect = useCallback(() => {
if (!autoReconnect || reconnectAttemptsRef.current >= maxReconnectAttempts || !mountedRef.current) {
return;
}
reconnectTimeoutRef.current = setTimeout(() => {
if (mountedRef.current) {
reconnectAttemptsRef.current += 1;
setState(prev => ({
...prev,
isConnecting: true,
reconnectAttempts: reconnectAttemptsRef.current,
}));
}
}, reconnectDelay);
}, [autoReconnect, maxReconnectAttempts, reconnectDelay]);
const subscriptionResult = subscription();
// Handle subscription state changes.
// Only depends on primitive/stable values — never on caller callbacks.
useEffect(() => {
if (!enabled) {
setState(prev => {
if (!prev.isConnected && !prev.isConnecting && prev.error === null) return prev;
return { ...prev, isConnected: false, isConnecting: false, error: null };
});
return;
}
if (subscriptionResult.status === 'pending') {
setState(prev => {
if (prev.isConnecting && prev.error === null) return prev;
return { ...prev, isConnecting: true, error: null };
});
callbacksRef.current.onStarted?.();
} else if (subscriptionResult.status === 'error') {
const error = subscriptionResult.error instanceof Error
? subscriptionResult.error
: new Error('Subscription error');
setState(prev => ({
...prev,
isConnected: false,
isConnecting: false,
error,
}));
callbacksRef.current.onError?.(error);
scheduleReconnect();
} else if (subscriptionResult.status === 'success') {
reconnectAttemptsRef.current = 0;
setState(prev => {
if (prev.isConnected && !prev.isConnecting && prev.error === null && prev.reconnectAttempts === 0) return prev;
return { ...prev, isConnected: true, isConnecting: false, error: null, reconnectAttempts: 0 };
});
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
}
}, [enabled, subscriptionResult.status, subscriptionResult.error, scheduleReconnect]);
// Handle incoming data
useEffect(() => {
if (subscriptionResult.data) {
setState(prev => ({ ...prev, lastEventId: subscriptionResult.data.id }));
callbacksRef.current.onData?.(subscriptionResult.data);
}
}, [subscriptionResult.data]);
return {
...state,
/** Manually trigger a reconnection attempt */
reconnect: () => {
if (mountedRef.current) {
reconnectAttemptsRef.current = 0;
setState(prev => ({
...prev,
isConnecting: true,
error: null,
reconnectAttempts: 0,
}));
}
},
/** Reset error state and reconnection attempts */
reset: () => {
if (reconnectTimeoutRef.current) {
clearTimeout(reconnectTimeoutRef.current);
reconnectTimeoutRef.current = null;
}
reconnectAttemptsRef.current = 0;
setState(prev => ({
...prev,
error: null,
reconnectAttempts: 0,
isConnecting: false,
}));
},
};
}

View File

@@ -60,3 +60,71 @@
min-height: 100vh;
}
}
/* Notion-style page link blocks inside the editor */
.page-link-block {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.2rem 0.25rem;
border-radius: 0.25rem;
cursor: pointer;
color: hsl(var(--foreground));
font-size: 0.9375rem;
line-height: 1.4;
transition: background-color 0.15s;
}
.page-link-block:hover {
background-color: hsl(var(--muted));
}
.page-link-block svg {
color: hsl(var(--muted-foreground));
flex-shrink: 0;
}
/* Block selection highlight */
.ProseMirror .block-selected {
background-color: hsl(var(--primary) / 0.08);
border-radius: 0.25rem;
box-shadow: 0.375rem 0 0 hsl(var(--primary) / 0.08), -0.375rem 0 0 hsl(var(--primary) / 0.08);
}
.dark .ProseMirror .block-selected {
background-color: hsl(var(--primary) / 0.12);
box-shadow: 0.375rem 0 0 hsl(var(--primary) / 0.12), -0.375rem 0 0 hsl(var(--primary) / 0.12);
}
/* Hide cursor and text selection during block selection mode */
.ProseMirror.has-block-selection {
caret-color: transparent;
}
.ProseMirror.has-block-selection *::selection {
background: transparent;
}
/* Notion-style placeholder on empty blocks */
.ProseMirror .is-empty::before {
color: hsl(var(--muted-foreground));
content: attr(data-placeholder);
float: left;
height: 0;
pointer-events: none;
}
/* Table wrapper overflow */
.ProseMirror .tableWrapper { overflow-x: auto; margin: 1em 0; }
/* Cell positioning for resize handles */
.ProseMirror table td, .ProseMirror table th { position: relative; min-width: 50px; vertical-align: top; }
/* Column resize handle */
.ProseMirror .column-resize-handle { position: absolute; right: -2px; top: 0; bottom: -2px; width: 4px; background-color: hsl(var(--primary) / 0.4); pointer-events: none; z-index: 20; }
/* Resize cursor */
.ProseMirror.resize-cursor { cursor: col-resize; }
/* Selected cell highlight */
.ProseMirror td.selectedCell, .ProseMirror th.selectedCell { background-color: hsl(var(--primary) / 0.08); }
.dark .ProseMirror td.selectedCell, .dark .ProseMirror th.selectedCell { background-color: hsl(var(--primary) / 0.15); }

View File

@@ -2,7 +2,9 @@ import { Link } from '@tanstack/react-router'
const navItems = [
{ label: 'Initiatives', to: '/initiatives' },
{ label: 'Agents', to: '/agents' },
{ label: 'Inbox', to: '/inbox' },
{ label: 'Settings', to: '/settings' },
] as const
export function AppLayout({ children }: { children: React.ReactNode }) {

View File

@@ -0,0 +1,217 @@
/**
* Markdown to Tiptap JSON converter.
*
* Converts agent-produced markdown back into Tiptap JSON for page updates.
* Uses @tiptap/html's generateJSON to parse HTML into Tiptap nodes.
*/
import { generateJSON } from '@tiptap/html';
import StarterKit from '@tiptap/starter-kit';
import { Table, TableRow, TableCell, TableHeader } from '@tiptap/extension-table';
/**
* Convert markdown string to Tiptap JSON document.
*/
export function markdownToTiptapJson(markdown: string): object {
const html = markdownToHtml(markdown);
return generateJSON(html, [StarterKit, Table, TableRow, TableCell, TableHeader]);
}
/**
* Simple markdown → HTML converter covering StarterKit nodes.
* Handles: headings, paragraphs, bold, italic, code, code blocks,
* bullet lists, ordered lists, blockquotes, links, horizontal rules, tables.
*/
function markdownToHtml(md: string): string {
// Normalize line endings
let text = md.replace(/\r\n/g, '\n');
// Code blocks (fenced)
text = text.replace(/```(\w*)\n([\s\S]*?)```/g, (_match, lang, code) => {
const escaped = escapeHtml(code.replace(/\n$/, ''));
const langAttr = lang ? ` class="language-${lang}"` : '';
return `<pre><code${langAttr}>${escaped}</code></pre>`;
});
// Split into lines for block-level processing
const lines = text.split('\n');
const htmlLines: string[] = [];
let i = 0;
while (i < lines.length) {
const line = lines[i];
// Skip lines inside pre blocks (already handled)
if (line.startsWith('<pre>')) {
let block = line;
while (i < lines.length && !lines[i].includes('</pre>')) {
i++;
block += '\n' + lines[i];
}
htmlLines.push(block);
i++;
continue;
}
// Horizontal rule
if (/^---+$/.test(line.trim())) {
htmlLines.push('<hr>');
i++;
continue;
}
// Headings
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
if (headingMatch) {
const level = headingMatch[1].length;
htmlLines.push(`<h${level}>${inlineMarkdown(headingMatch[2])}</h${level}>`);
i++;
continue;
}
// Blockquote
if (line.startsWith('> ')) {
const quoteLines: string[] = [];
while (i < lines.length && lines[i].startsWith('> ')) {
quoteLines.push(lines[i].slice(2));
i++;
}
htmlLines.push(`<blockquote><p>${inlineMarkdown(quoteLines.join(' '))}</p></blockquote>`);
continue;
}
// Unordered list
if (/^[-*]\s+/.test(line)) {
const items: string[] = [];
while (i < lines.length && /^[-*]\s+/.test(lines[i])) {
items.push(lines[i].replace(/^[-*]\s+/, ''));
i++;
}
const lis = items.map((item) => `<li><p>${inlineMarkdown(item)}</p></li>`).join('');
htmlLines.push(`<ul>${lis}</ul>`);
continue;
}
// Ordered list
if (/^\d+\.\s+/.test(line)) {
const items: string[] = [];
while (i < lines.length && /^\d+\.\s+/.test(lines[i])) {
items.push(lines[i].replace(/^\d+\.\s+/, ''));
i++;
}
const lis = items.map((item) => `<li><p>${inlineMarkdown(item)}</p></li>`).join('');
htmlLines.push(`<ol>${lis}</ol>`);
continue;
}
// Table: current line has | and next line is a separator row
if (line.includes('|') && i + 1 < lines.length && /^\s*\|?\s*[-:]+[-| :]*$/.test(lines[i + 1])) {
const headerCells = parseTableRow(line);
i += 2; // skip header + separator
const bodyRows: string[][] = [];
while (i < lines.length && lines[i].includes('|') && lines[i].trim() !== '') {
bodyRows.push(parseTableRow(lines[i]));
i++;
}
const ths = headerCells.map((c) => `<th>${inlineMarkdown(c)}</th>`).join('');
const thead = `<thead><tr>${ths}</tr></thead>`;
let tbody = '';
if (bodyRows.length > 0) {
const trs = bodyRows
.map((row) => {
const tds = row.map((c) => `<td>${inlineMarkdown(c)}</td>`).join('');
return `<tr>${tds}</tr>`;
})
.join('');
tbody = `<tbody>${trs}</tbody>`;
}
htmlLines.push(`<table>${thead}${tbody}</table>`);
continue;
}
// Empty line
if (line.trim() === '') {
i++;
continue;
}
// Paragraph (collect consecutive non-empty, non-block lines)
const paraLines: string[] = [];
while (
i < lines.length &&
lines[i].trim() !== '' &&
!lines[i].startsWith('#') &&
!lines[i].startsWith('> ') &&
!/^[-*]\s+/.test(lines[i]) &&
!/^\d+\.\s+/.test(lines[i]) &&
!/^---+$/.test(lines[i].trim()) &&
!lines[i].startsWith('<pre>') &&
!lines[i].startsWith('```') &&
!isTableStart(lines, i)
) {
paraLines.push(lines[i]);
i++;
}
if (paraLines.length > 0) {
htmlLines.push(`<p>${inlineMarkdown(paraLines.join(' '))}</p>`);
} else {
i++;
}
}
return htmlLines.join('');
}
/**
* Check if lines[i] starts a markdown table (has | and next line is separator).
*/
function isTableStart(lines: string[], i: number): boolean {
return (
lines[i].includes('|') &&
i + 1 < lines.length &&
/^\s*\|?\s*[-:]+[-| :]*$/.test(lines[i + 1])
);
}
/**
* Parse a markdown table row: strip leading/trailing pipes, split on |, trim cells.
*/
function parseTableRow(line: string): string[] {
let trimmed = line.trim();
if (trimmed.startsWith('|')) trimmed = trimmed.slice(1);
if (trimmed.endsWith('|')) trimmed = trimmed.slice(0, -1);
return trimmed.split('|').map((c) => c.trim());
}
/**
* Process inline markdown: bold, italic, inline code, links.
*/
function inlineMarkdown(text: string): string {
let result = escapeHtml(text);
// Inline code (must come before bold/italic to avoid conflicts)
result = result.replace(/`([^`]+)`/g, '<code>$1</code>');
// Bold
result = result.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
// Italic
result = result.replace(/\*(.+?)\*/g, '<em>$1</em>');
// Links [text](url)
result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
return result;
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}

View File

@@ -4,3 +4,40 @@ import { twMerge } from "tailwind-merge";
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs));
}
/**
* Format a date as relative time (e.g., "2 minutes ago", "1 hour ago")
*/
export function formatRelativeTime(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
const diffInMs = now.getTime() - date.getTime();
const diffInSeconds = Math.floor(diffInMs / 1000);
if (diffInSeconds < 60) {
return diffInSeconds <= 1 ? "just now" : `${diffInSeconds} seconds ago`;
}
const diffInMinutes = Math.floor(diffInSeconds / 60);
if (diffInMinutes < 60) {
return diffInMinutes === 1 ? "1 minute ago" : `${diffInMinutes} minutes ago`;
}
const diffInHours = Math.floor(diffInMinutes / 60);
if (diffInHours < 24) {
return diffInHours === 1 ? "1 hour ago" : `${diffInHours} hours ago`;
}
const diffInDays = Math.floor(diffInHours / 24);
if (diffInDays < 30) {
return diffInDays === 1 ? "1 day ago" : `${diffInDays} days ago`;
}
const diffInMonths = Math.floor(diffInDays / 30);
if (diffInMonths < 12) {
return diffInMonths === 1 ? "1 month ago" : `${diffInMonths} months ago`;
}
const diffInYears = Math.floor(diffInMonths / 12);
return diffInYears === 1 ? "1 year ago" : `${diffInYears} years ago`;
}

View File

@@ -9,26 +9,50 @@
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
import { Route as rootRouteImport } from './routes/__root'
import { Route as SettingsRouteImport } from './routes/settings'
import { Route as InboxRouteImport } from './routes/inbox'
import { Route as AgentsRouteImport } from './routes/agents'
import { Route as IndexRouteImport } from './routes/index'
import { Route as SettingsIndexRouteImport } from './routes/settings/index'
import { Route as InitiativesIndexRouteImport } from './routes/initiatives/index'
import { Route as SettingsHealthRouteImport } from './routes/settings/health'
import { Route as InitiativesIdRouteImport } from './routes/initiatives/$id'
const SettingsRoute = SettingsRouteImport.update({
id: '/settings',
path: '/settings',
getParentRoute: () => rootRouteImport,
} as any)
const InboxRoute = InboxRouteImport.update({
id: '/inbox',
path: '/inbox',
getParentRoute: () => rootRouteImport,
} as any)
const AgentsRoute = AgentsRouteImport.update({
id: '/agents',
path: '/agents',
getParentRoute: () => rootRouteImport,
} as any)
const IndexRoute = IndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => rootRouteImport,
} as any)
const SettingsIndexRoute = SettingsIndexRouteImport.update({
id: '/',
path: '/',
getParentRoute: () => SettingsRoute,
} as any)
const InitiativesIndexRoute = InitiativesIndexRouteImport.update({
id: '/initiatives/',
path: '/initiatives/',
getParentRoute: () => rootRouteImport,
} as any)
const SettingsHealthRoute = SettingsHealthRouteImport.update({
id: '/health',
path: '/health',
getParentRoute: () => SettingsRoute,
} as any)
const InitiativesIdRoute = InitiativesIdRouteImport.update({
id: '/initiatives/$id',
path: '/initiatives/$id',
@@ -37,40 +61,84 @@ const InitiativesIdRoute = InitiativesIdRouteImport.update({
export interface FileRoutesByFullPath {
'/': typeof IndexRoute
'/agents': typeof AgentsRoute
'/inbox': typeof InboxRoute
'/settings': typeof SettingsRouteWithChildren
'/initiatives/$id': typeof InitiativesIdRoute
'/settings/health': typeof SettingsHealthRoute
'/initiatives/': typeof InitiativesIndexRoute
'/settings/': typeof SettingsIndexRoute
}
export interface FileRoutesByTo {
'/': typeof IndexRoute
'/agents': typeof AgentsRoute
'/inbox': typeof InboxRoute
'/initiatives/$id': typeof InitiativesIdRoute
'/settings/health': typeof SettingsHealthRoute
'/initiatives': typeof InitiativesIndexRoute
'/settings': typeof SettingsIndexRoute
}
export interface FileRoutesById {
__root__: typeof rootRouteImport
'/': typeof IndexRoute
'/agents': typeof AgentsRoute
'/inbox': typeof InboxRoute
'/settings': typeof SettingsRouteWithChildren
'/initiatives/$id': typeof InitiativesIdRoute
'/settings/health': typeof SettingsHealthRoute
'/initiatives/': typeof InitiativesIndexRoute
'/settings/': typeof SettingsIndexRoute
}
export interface FileRouteTypes {
fileRoutesByFullPath: FileRoutesByFullPath
fullPaths: '/' | '/inbox' | '/initiatives/$id' | '/initiatives/'
fullPaths:
| '/'
| '/agents'
| '/inbox'
| '/settings'
| '/initiatives/$id'
| '/settings/health'
| '/initiatives/'
| '/settings/'
fileRoutesByTo: FileRoutesByTo
to: '/' | '/inbox' | '/initiatives/$id' | '/initiatives'
id: '__root__' | '/' | '/inbox' | '/initiatives/$id' | '/initiatives/'
to:
| '/'
| '/agents'
| '/inbox'
| '/initiatives/$id'
| '/settings/health'
| '/initiatives'
| '/settings'
id:
| '__root__'
| '/'
| '/agents'
| '/inbox'
| '/settings'
| '/initiatives/$id'
| '/settings/health'
| '/initiatives/'
| '/settings/'
fileRoutesById: FileRoutesById
}
export interface RootRouteChildren {
IndexRoute: typeof IndexRoute
AgentsRoute: typeof AgentsRoute
InboxRoute: typeof InboxRoute
SettingsRoute: typeof SettingsRouteWithChildren
InitiativesIdRoute: typeof InitiativesIdRoute
InitiativesIndexRoute: typeof InitiativesIndexRoute
}
declare module '@tanstack/react-router' {
interface FileRoutesByPath {
'/settings': {
id: '/settings'
path: '/settings'
fullPath: '/settings'
preLoaderRoute: typeof SettingsRouteImport
parentRoute: typeof rootRouteImport
}
'/inbox': {
id: '/inbox'
path: '/inbox'
@@ -78,6 +146,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof InboxRouteImport
parentRoute: typeof rootRouteImport
}
'/agents': {
id: '/agents'
path: '/agents'
fullPath: '/agents'
preLoaderRoute: typeof AgentsRouteImport
parentRoute: typeof rootRouteImport
}
'/': {
id: '/'
path: '/'
@@ -85,6 +160,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof IndexRouteImport
parentRoute: typeof rootRouteImport
}
'/settings/': {
id: '/settings/'
path: '/'
fullPath: '/settings/'
preLoaderRoute: typeof SettingsIndexRouteImport
parentRoute: typeof SettingsRoute
}
'/initiatives/': {
id: '/initiatives/'
path: '/initiatives'
@@ -92,6 +174,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof InitiativesIndexRouteImport
parentRoute: typeof rootRouteImport
}
'/settings/health': {
id: '/settings/health'
path: '/health'
fullPath: '/settings/health'
preLoaderRoute: typeof SettingsHealthRouteImport
parentRoute: typeof SettingsRoute
}
'/initiatives/$id': {
id: '/initiatives/$id'
path: '/initiatives/$id'
@@ -102,9 +191,25 @@ declare module '@tanstack/react-router' {
}
}
interface SettingsRouteChildren {
SettingsHealthRoute: typeof SettingsHealthRoute
SettingsIndexRoute: typeof SettingsIndexRoute
}
const SettingsRouteChildren: SettingsRouteChildren = {
SettingsHealthRoute: SettingsHealthRoute,
SettingsIndexRoute: SettingsIndexRoute,
}
const SettingsRouteWithChildren = SettingsRoute._addFileChildren(
SettingsRouteChildren,
)
const rootRouteChildren: RootRouteChildren = {
IndexRoute: IndexRoute,
AgentsRoute: AgentsRoute,
InboxRoute: InboxRoute,
SettingsRoute: SettingsRouteWithChildren,
InitiativesIdRoute: InitiativesIdRoute,
InitiativesIndexRoute: InitiativesIndexRoute,
}

View File

@@ -0,0 +1,196 @@
import { useState } from "react";
import { createFileRoute } from "@tanstack/react-router";
import { AlertCircle, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/Skeleton";
import { toast } from "sonner";
import { trpc } from "@/lib/trpc";
import { AgentOutputViewer } from "@/components/AgentOutputViewer";
import { formatRelativeTime } from "@/lib/utils";
import { cn } from "@/lib/utils";
import { useSubscriptionWithErrorHandling } from "@/hooks";
export const Route = createFileRoute("/agents")({
component: AgentsPage,
});
function AgentsPage() {
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
// Live updates: invalidate agents on agent events with robust error handling
const utils = trpc.useUtils();
const subscription = useSubscriptionWithErrorHandling(
() => trpc.onAgentUpdate.useSubscription(undefined),
{
onData: () => {
void utils.listAgents.invalidate();
},
onError: (error) => {
toast.error("Live updates disconnected. Refresh to reconnect.", {
id: "sub-error",
duration: Infinity,
});
console.error('Agent updates subscription error:', error);
},
onStarted: () => {
// Clear any existing error toasts when reconnecting
toast.dismiss("sub-error");
},
autoReconnect: true,
maxReconnectAttempts: 5,
}
);
// Data fetching
const agentsQuery = trpc.listAgents.useQuery();
// Handlers
function handleRefresh() {
void utils.listAgents.invalidate();
}
// Loading state
if (agentsQuery.isLoading) {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Skeleton className="h-6 w-20" />
<Skeleton className="h-5 w-8 rounded-full" />
</div>
<Skeleton className="h-8 w-20" />
</div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[300px_1fr]">
<div className="space-y-2">
{Array.from({ length: 5 }).map((_, i) => (
<Card key={i} className="p-3">
<div className="flex items-center gap-3">
<Skeleton className="h-2 w-2 rounded-full" />
<Skeleton className="h-4 w-24" />
</div>
</Card>
))}
</div>
<Skeleton className="h-96 rounded-lg" />
</div>
</div>
);
}
// Error state
if (agentsQuery.isError) {
return (
<div className="flex flex-col items-center justify-center gap-4 py-12">
<AlertCircle className="h-8 w-8 text-destructive" />
<p className="text-sm text-destructive">
Failed to load agents: {agentsQuery.error?.message ?? "Unknown error"}
</p>
<Button variant="outline" size="sm" onClick={handleRefresh}>
Retry
</Button>
</div>
);
}
const agents = agentsQuery.data ?? [];
const selectedAgent = selectedAgentId
? agents.find((a) => a.id === selectedAgentId)
: null;
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold">Agents</h1>
<Badge variant="secondary">{agents.length}</Badge>
</div>
<Button variant="outline" size="sm" onClick={handleRefresh}>
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
Refresh
</Button>
</div>
{/* Two-column layout */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[300px_1fr]">
{/* Left: Agent List */}
<div className="space-y-2">
{agents.length === 0 ? (
<div className="rounded-lg border border-dashed p-8 text-center">
<p className="text-sm text-muted-foreground">No agents found</p>
</div>
) : (
agents.map((agent) => (
<Card
key={agent.id}
className={cn(
"cursor-pointer p-3 transition-colors hover:bg-muted/50",
selectedAgentId === agent.id && "bg-muted"
)}
onClick={() => setSelectedAgentId(agent.id)}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<StatusDot status={agent.status} />
<span className="truncate text-sm font-medium">
{agent.name}
</span>
</div>
<div className="flex items-center gap-1.5 shrink-0">
<Badge variant="outline" className="text-xs">
{agent.provider}
</Badge>
<Badge variant="secondary" className="text-xs">
{agent.mode}
</Badge>
</div>
</div>
<div className="mt-1 text-xs text-muted-foreground">
{formatRelativeTime(String(agent.createdAt))}
</div>
</Card>
))
)}
</div>
{/* Right: Output Viewer */}
{selectedAgent ? (
<AgentOutputViewer agentId={selectedAgent.id} agentName={selectedAgent.name} />
) : (
<div className="flex items-center justify-center rounded-lg border border-dashed p-8">
<p className="text-sm text-muted-foreground">
Select an agent to view output
</p>
</div>
)}
</div>
</div>
);
}
// ---------------------------------------------------------------------------
// Components
// ---------------------------------------------------------------------------
function StatusDot({ status }: { status: string }) {
const colors: Record<string, string> = {
running: "bg-green-500",
waiting_for_input: "bg-yellow-500",
idle: "bg-zinc-400",
stopped: "bg-zinc-400",
crashed: "bg-red-500",
};
return (
<span
className={cn("h-2 w-2 rounded-full shrink-0", colors[status] ?? "bg-zinc-400")}
title={status}
/>
);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

View File

@@ -8,6 +8,7 @@ import { toast } from "sonner";
import { trpc } from "@/lib/trpc";
import { InboxList } from "@/components/InboxList";
import { QuestionForm } from "@/components/QuestionForm";
import { formatRelativeTime } from "@/lib/utils";
export const Route = createFileRoute("/inbox")({
component: InboxPage,
@@ -328,17 +329,3 @@ function InboxPage() {
// Helpers
// ---------------------------------------------------------------------------
function formatRelativeTime(isoDate: string): string {
const now = Date.now();
const then = new Date(isoDate).getTime();
const diffMs = now - then;
const diffSec = Math.floor(diffMs / 1000);
const diffMin = Math.floor(diffSec / 60);
const diffHr = Math.floor(diffMin / 60);
const diffDay = Math.floor(diffHr / 24);
if (diffSec < 60) return "just now";
if (diffMin < 60) return `${diffMin} min ago`;
if (diffHr < 24) return `${diffHr}h ago`;
return `${diffDay}d ago`;
}

View File

@@ -1,4 +1,4 @@
import { useState, useCallback, useEffect } from "react";
import { useState } from "react";
import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
@@ -6,247 +6,87 @@ import { Skeleton } from "@/components/Skeleton";
import { toast } from "sonner";
import { trpc } from "@/lib/trpc";
import { InitiativeHeader } from "@/components/InitiativeHeader";
import { ProgressPanel } from "@/components/ProgressPanel";
import { PhaseAccordion } from "@/components/PhaseAccordion";
import { DecisionList } from "@/components/DecisionList";
import { TaskDetailModal } from "@/components/TaskDetailModal";
import type { SerializedTask } from "@/components/TaskRow";
import { ContentTab } from "@/components/editor/ContentTab";
import { ExecutionTab } from "@/components/ExecutionTab";
import { useSubscriptionWithErrorHandling } from "@/hooks";
export const Route = createFileRoute("/initiatives/$id")({
component: InitiativeDetailPage,
});
// ---------------------------------------------------------------------------
// Types
// ---------------------------------------------------------------------------
/** Aggregated task counts reported upward from PhaseWithTasks */
interface TaskCounts {
complete: number;
total: number;
}
/** Flat task entry with metadata needed for the modal */
interface FlatTaskEntry {
task: SerializedTask;
phaseName: string;
agentName: string | null;
blockedBy: Array<{ name: string; status: string }>;
dependents: Array<{ name: string; status: string }>;
}
// ---------------------------------------------------------------------------
// PhaseWithTasks — solves the "hooks inside loops" problem
// ---------------------------------------------------------------------------
interface PhaseWithTasksProps {
phase: {
id: string;
initiativeId: string;
number: number;
name: string;
description: string | null;
status: string;
createdAt: string;
updatedAt: string;
};
defaultExpanded: boolean;
onTaskClick: (taskId: string) => void;
onTaskCounts: (phaseId: string, counts: TaskCounts) => void;
registerTasks: (phaseId: string, entries: FlatTaskEntry[]) => void;
}
function PhaseWithTasks({
phase,
defaultExpanded,
onTaskClick,
onTaskCounts,
registerTasks,
}: PhaseWithTasksProps) {
// Fetch all plans for this phase
const plansQuery = trpc.listPlans.useQuery({ phaseId: phase.id });
// Fetch phase dependencies
const depsQuery = trpc.getPhaseDependencies.useQuery({ phaseId: phase.id });
const plans = plansQuery.data ?? [];
const planIds = plans.map((p) => p.id);
return (
<PhaseWithTasksInner
phase={phase}
planIds={planIds}
plansLoaded={plansQuery.isSuccess}
phaseDependencyIds={depsQuery.data?.dependencies ?? []}
defaultExpanded={defaultExpanded}
onTaskClick={onTaskClick}
onTaskCounts={onTaskCounts}
registerTasks={registerTasks}
/>
);
}
// Inner component that fetches tasks for each plan — needs stable hook count
// Since planIds array changes, we fetch tasks per plan inside yet another child
interface PhaseWithTasksInnerProps {
phase: PhaseWithTasksProps["phase"];
planIds: string[];
plansLoaded: boolean;
phaseDependencyIds: string[];
defaultExpanded: boolean;
onTaskClick: (taskId: string) => void;
onTaskCounts: (phaseId: string, counts: TaskCounts) => void;
registerTasks: (phaseId: string, entries: FlatTaskEntry[]) => void;
}
function PhaseWithTasksInner({
phase,
planIds,
plansLoaded,
phaseDependencyIds: _phaseDependencyIds,
defaultExpanded,
onTaskClick,
onTaskCounts,
registerTasks,
}: PhaseWithTasksInnerProps) {
// We can't call useQuery in a loop, so we render PlanTasksFetcher per plan
// and aggregate the results
const [planTasks, setPlanTasks] = useState<Record<string, SerializedTask[]>>(
{},
);
const handlePlanTasks = useCallback(
(planId: string, tasks: SerializedTask[]) => {
setPlanTasks((prev) => {
// Skip if unchanged (same reference)
if (prev[planId] === tasks) return prev;
const next = { ...prev, [planId]: tasks };
// Aggregate all tasks across plans
const allTasks = Object.values(next).flat();
const complete = allTasks.filter(
(t) => t.status === "completed",
).length;
// Report counts up
onTaskCounts(phase.id, { complete, total: allTasks.length });
// Register flat entries for the modal lookup
const entries: FlatTaskEntry[] = allTasks.map((task) => ({
task,
phaseName: `Phase ${phase.number}: ${phase.name}`,
agentName: null, // No agent info from task data alone
blockedBy: [], // Simplified: no dependency lookup per task in v1
dependents: [],
}));
registerTasks(phase.id, entries);
return next;
});
},
[phase.id, phase.number, phase.name, onTaskCounts, registerTasks],
);
// Build task entries for PhaseAccordion
const allTasks = planIds.flatMap((pid) => planTasks[pid] ?? []);
const taskEntries = allTasks.map((task) => ({
task,
agentName: null as string | null,
blockedBy: [] as Array<{ name: string; status: string }>,
}));
// Phase-level dependencies (empty for now — would need to resolve IDs to names)
const phaseDeps: Array<{ name: string; status: string }> = [];
return (
<>
{/* Hidden fetchers — one per plan */}
{plansLoaded &&
planIds.map((planId) => (
<PlanTasksFetcher
key={planId}
planId={planId}
onTasks={handlePlanTasks}
/>
))}
<PhaseAccordion
phase={phase}
tasks={taskEntries}
defaultExpanded={defaultExpanded}
phaseDependencies={phaseDeps}
onTaskClick={onTaskClick}
/>
</>
);
}
// ---------------------------------------------------------------------------
// PlanTasksFetcher — fetches tasks for a single plan (stable hook count)
// ---------------------------------------------------------------------------
interface PlanTasksFetcherProps {
planId: string;
onTasks: (planId: string, tasks: SerializedTask[]) => void;
}
function PlanTasksFetcher({ planId, onTasks }: PlanTasksFetcherProps) {
const tasksQuery = trpc.listTasks.useQuery({ planId });
// Report tasks upward via useEffect (not during render) to avoid
// setState-during-render loops when the parent re-renders on state update.
useEffect(() => {
if (tasksQuery.data) {
onTasks(planId, tasksQuery.data as unknown as SerializedTask[]);
}
}, [tasksQuery.data, planId, onTasks]);
return null; // Render nothing — this is a data-fetching component
}
// ---------------------------------------------------------------------------
// Main Page Component
// ---------------------------------------------------------------------------
type Tab = "content" | "execution";
function InitiativeDetailPage() {
const { id } = Route.useParams();
const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<Tab>("content");
// Live updates: invalidate detail queries on task/phase and agent events
// Live updates: keep subscriptions at page level so they work across both tabs
const utils = trpc.useUtils();
trpc.onTaskUpdate.useSubscription(undefined, {
onData: () => {
void utils.listPhases.invalidate();
void utils.listTasks.invalidate();
void utils.listPlans.invalidate();
},
onError: () => {
toast.error("Live updates disconnected. Refresh to reconnect.", {
id: "sub-error",
duration: Infinity,
});
},
});
trpc.onAgentUpdate.useSubscription(undefined, {
onData: () => {
void utils.listAgents.invalidate();
},
onError: () => {
toast.error("Live updates disconnected. Refresh to reconnect.", {
id: "sub-error",
duration: Infinity,
});
},
});
// State
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
const [taskCountsByPhase, setTaskCountsByPhase] = useState<
Record<string, TaskCounts>
>({});
const [tasksByPhase, setTasksByPhase] = useState<
Record<string, FlatTaskEntry[]>
>({});
// Task updates subscription with robust error handling
useSubscriptionWithErrorHandling(
() => trpc.onTaskUpdate.useSubscription(undefined),
{
onData: () => {
void utils.listPhases.invalidate();
void utils.listTasks.invalidate();
void utils.listPlans.invalidate();
},
onError: (error) => {
toast.error("Live updates disconnected. Refresh to reconnect.", {
id: "sub-error",
duration: Infinity,
});
console.error('Task updates subscription error:', error);
},
onStarted: () => toast.dismiss("sub-error"),
autoReconnect: true,
maxReconnectAttempts: 5,
}
);
// Agent updates subscription with robust error handling
useSubscriptionWithErrorHandling(
() => trpc.onAgentUpdate.useSubscription(undefined),
{
onData: () => {
void utils.listAgents.invalidate();
},
onError: (error) => {
toast.error("Live updates disconnected. Refresh to reconnect.", {
id: "sub-error",
duration: Infinity,
});
console.error('Agent updates subscription error:', error);
},
onStarted: () => toast.dismiss("sub-error"),
autoReconnect: true,
maxReconnectAttempts: 5,
}
);
// Page updates subscription with robust error handling
useSubscriptionWithErrorHandling(
() => trpc.onPageUpdate.useSubscription(undefined),
{
onData: () => {
void utils.listPages.invalidate();
void utils.getPage.invalidate();
void utils.getRootPage.invalidate();
},
onError: (error) => {
toast.error("Live updates disconnected. Refresh to reconnect.", {
id: "sub-error",
duration: Infinity,
});
console.error('Page updates subscription error:', error);
},
onStarted: () => toast.dismiss("sub-error"),
autoReconnect: true,
maxReconnectAttempts: 5,
}
);
// tRPC queries
const initiativeQuery = trpc.getInitiative.useQuery({ id });
@@ -255,94 +95,20 @@ function InitiativeDetailPage() {
{ enabled: !!initiativeQuery.data },
);
// tRPC mutations
const queueTaskMutation = trpc.queueTask.useMutation();
const queuePhaseMutation = trpc.queuePhase.useMutation();
// Callbacks for PhaseWithTasks
const handleTaskCounts = useCallback(
(phaseId: string, counts: TaskCounts) => {
setTaskCountsByPhase((prev) => {
if (
prev[phaseId]?.complete === counts.complete &&
prev[phaseId]?.total === counts.total
) {
return prev;
}
return { ...prev, [phaseId]: counts };
});
},
[],
);
const handleRegisterTasks = useCallback(
(phaseId: string, entries: FlatTaskEntry[]) => {
setTasksByPhase((prev) => {
if (prev[phaseId] === entries) return prev;
return { ...prev, [phaseId]: entries };
});
},
[],
);
// Derived data
const phases = phasesQuery.data ?? [];
const phasesComplete = phases.filter(
(p) => p.status === "completed",
).length;
const allTaskCounts = Object.values(taskCountsByPhase);
const tasksComplete = allTaskCounts.reduce((s, c) => s + c.complete, 0);
const tasksTotal = allTaskCounts.reduce((s, c) => s + c.total, 0);
// Find selected task across all phases
const allFlatTasks = Object.values(tasksByPhase).flat();
const selectedEntry = selectedTaskId
? allFlatTasks.find((e) => e.task.id === selectedTaskId) ?? null
: null;
// Determine which phase should be expanded by default (first non-completed)
const firstIncompletePhaseIndex = phases.findIndex(
(p) => p.status !== "completed",
);
// Queue all pending phases
const handleQueueAll = useCallback(() => {
const pendingPhases = phases.filter((p) => p.status === "pending");
for (const phase of pendingPhases) {
queuePhaseMutation.mutate({ phaseId: phase.id });
}
}, [phases, queuePhaseMutation]);
// Queue a single task
const handleQueueTask = useCallback(
(taskId: string) => {
queueTaskMutation.mutate({ taskId });
setSelectedTaskId(null);
},
[queueTaskMutation],
);
// Loading state
if (initiativeQuery.isLoading) {
return (
<div className="space-y-6">
{/* Header skeleton */}
<div className="flex items-center gap-4">
<Skeleton className="h-4 w-4" />
<Skeleton className="h-7 w-64" />
<Skeleton className="h-5 w-20" />
</div>
{/* Two-column grid skeleton */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_340px]">
{/* Left: phase accordion skeletons */}
<div className="space-y-1">
<Skeleton className="h-12 w-full rounded border" />
<Skeleton className="h-12 w-full rounded border" />
</div>
{/* Right: ProgressPanel + DecisionList skeletons */}
<div className="space-y-6">
<Skeleton className="h-24 w-full rounded" />
<Skeleton className="h-20 w-full rounded" />
@@ -376,109 +142,59 @@ function InitiativeDetailPage() {
const initiative = initiativeQuery.data;
if (!initiative) return null;
// tRPC serializes Date to string over JSON — cast to wire format
const serializedInitiative = {
id: initiative.id,
name: initiative.name,
status: initiative.status,
createdAt: String(initiative.createdAt),
updatedAt: String(initiative.updatedAt),
};
const hasPendingPhases = phases.some((p) => p.status === "pending");
const projects = (initiative as { projects?: Array<{ id: string; name: string; url: string }> }).projects;
const phases = phasesQuery.data ?? [];
return (
<div className="space-y-6">
<div className="space-y-3">
{/* Header */}
<InitiativeHeader
initiative={serializedInitiative}
projects={projects}
onBack={() => navigate({ to: "/initiatives" })}
/>
{/* Two-column layout */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_340px]">
{/* Left column: Phases */}
<div className="space-y-0">
{/* Section header */}
<div className="flex items-center justify-between border-b border-border pb-3">
<h2 className="text-lg font-semibold">Phases</h2>
<Button
variant="outline"
size="sm"
disabled={!hasPendingPhases}
onClick={handleQueueAll}
>
Queue All
</Button>
</div>
{/* Phase loading */}
{phasesQuery.isLoading && (
<div className="space-y-1 pt-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-10 w-full" />
))}
</div>
)}
{/* Phases list */}
{phasesQuery.isSuccess && phases.length === 0 && (
<div className="py-8 text-center text-muted-foreground">
No phases yet
</div>
)}
{phasesQuery.isSuccess &&
phases.map((phase, idx) => {
// tRPC serializes Date to string over JSON — cast to wire format
const serializedPhase = {
id: phase.id,
initiativeId: phase.initiativeId,
number: phase.number,
name: phase.name,
description: phase.description,
status: phase.status,
createdAt: String(phase.createdAt),
updatedAt: String(phase.updatedAt),
};
return (
<PhaseWithTasks
key={phase.id}
phase={serializedPhase}
defaultExpanded={idx === firstIncompletePhaseIndex}
onTaskClick={setSelectedTaskId}
onTaskCounts={handleTaskCounts}
registerTasks={handleRegisterTasks}
/>
);
})}
</div>
{/* Right column: Progress + Decisions */}
<div className="space-y-6">
<ProgressPanel
phasesComplete={phasesComplete}
phasesTotal={phases.length}
tasksComplete={tasksComplete}
tasksTotal={tasksTotal}
/>
<DecisionList decisions={[]} />
</div>
{/* Tab bar */}
<div className="flex gap-1 border-b border-border">
<button
onClick={() => setActiveTab("content")}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === "content"
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
Content
</button>
<button
onClick={() => setActiveTab("execution")}
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
activeTab === "execution"
? "border-primary text-foreground"
: "border-transparent text-muted-foreground hover:text-foreground"
}`}
>
Execution
</button>
</div>
{/* Task Detail Modal */}
<TaskDetailModal
task={selectedEntry?.task ?? null}
phaseName={selectedEntry?.phaseName ?? ""}
agentName={selectedEntry?.agentName ?? null}
dependencies={selectedEntry?.blockedBy ?? []}
dependents={selectedEntry?.dependents ?? []}
onClose={() => setSelectedTaskId(null)}
onQueueTask={handleQueueTask}
onStopTask={() => setSelectedTaskId(null)}
/>
{/* Tab content */}
{activeTab === "content" && <ContentTab initiativeId={id} initiativeName={initiative.name} />}
{activeTab === "execution" && (
<ExecutionTab
initiativeId={id}
phases={phases}
phasesLoading={phasesQuery.isLoading}
phasesLoaded={phasesQuery.isSuccess}
/>
)}
</div>
);
}

View File

@@ -0,0 +1,35 @@
import { createFileRoute, Link, Outlet } from '@tanstack/react-router'
export const Route = createFileRoute('/settings')({
component: SettingsLayout,
})
const settingsTabs = [
{ label: 'Health Check', to: '/settings/health' },
] as const
function SettingsLayout() {
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold tracking-tight">Settings</h1>
</div>
<nav className="flex gap-1 border-b border-border">
{settingsTabs.map((tab) => (
<Link
key={tab.to}
to={tab.to}
className="px-4 py-2 text-sm font-medium border-b-2 border-transparent text-muted-foreground transition-colors hover:text-foreground"
activeProps={{
className:
'px-4 py-2 text-sm font-medium border-b-2 border-primary text-foreground',
}}
>
{tab.label}
</Link>
))}
</nav>
<Outlet />
</div>
)
}

View File

@@ -0,0 +1,383 @@
import { createFileRoute } from '@tanstack/react-router'
import {
CheckCircle2,
XCircle,
AlertTriangle,
RefreshCw,
Server,
} from 'lucide-react'
import { trpc } from '@/lib/trpc'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/Skeleton'
export const Route = createFileRoute('/settings/health')({
component: HealthCheckPage,
})
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function formatUptime(seconds: number): string {
const d = Math.floor(seconds / 86400)
const h = Math.floor((seconds % 86400) / 3600)
const m = Math.floor((seconds % 3600) / 60)
const s = Math.floor(seconds % 60)
const parts: string[] = []
if (d > 0) parts.push(`${d}d`)
if (h > 0) parts.push(`${h}h`)
if (m > 0) parts.push(`${m}m`)
if (s > 0 || parts.length === 0) parts.push(`${s}s`)
return parts.join(' ')
}
function formatResetTime(isoDate: string): string {
const now = Date.now()
const target = new Date(isoDate).getTime()
const diffMs = target - now
if (diffMs <= 0) return 'now'
const totalMinutes = Math.floor(diffMs / 60_000)
const totalHours = Math.floor(totalMinutes / 60)
const totalDays = Math.floor(totalHours / 24)
if (totalDays > 0) {
const remainingHours = totalHours - totalDays * 24
return `in ${totalDays}d ${remainingHours}h`
}
const remainingMinutes = totalMinutes - totalHours * 60
return `in ${totalHours}h ${remainingMinutes}m`
}
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1)
}
// ---------------------------------------------------------------------------
// Usage bar
// ---------------------------------------------------------------------------
function UsageBar({
label,
utilization,
resetsAt,
}: {
label: string
utilization: number
resetsAt: string | null
}) {
const color =
utilization >= 90
? 'bg-destructive'
: utilization >= 70
? 'bg-yellow-500'
: 'bg-green-500'
const resetText = resetsAt ? formatResetTime(resetsAt) : null
return (
<div className="flex items-center gap-2 text-xs">
<span className="w-20 shrink-0 text-muted-foreground">{label}</span>
<div className="h-2 flex-1 rounded-full bg-muted">
<div
className={`h-2 rounded-full ${color}`}
style={{ width: `${Math.min(utilization, 100)}%` }}
/>
</div>
<span className="w-12 shrink-0 text-right">
{utilization.toFixed(0)}%
</span>
{resetText && (
<span className="shrink-0 text-muted-foreground">
resets {resetText}
</span>
)}
</div>
)
}
// ---------------------------------------------------------------------------
// Page component
// ---------------------------------------------------------------------------
function HealthCheckPage() {
const healthQuery = trpc.systemHealthCheck.useQuery(undefined, {
refetchInterval: 30_000,
})
const { data, isLoading, isError, error, refetch } = healthQuery
// Loading state
if (isLoading) {
return (
<div className="space-y-6">
<div className="flex justify-end">
<Skeleton className="h-9 w-24" />
</div>
<Skeleton className="h-32 w-full" />
<Skeleton className="h-48 w-full" />
<Skeleton className="h-32 w-full" />
</div>
)
}
// Error state
if (isError) {
return (
<div className="flex flex-col items-center justify-center gap-4 py-12">
<XCircle className="h-8 w-8 text-destructive" />
<p className="text-sm text-destructive">
Failed to load health check: {error?.message ?? 'Unknown error'}
</p>
<Button variant="outline" size="sm" onClick={() => void refetch()}>
Retry
</Button>
</div>
)
}
if (!data) return null
const { server, accounts, projects } = data
return (
<div className="space-y-6">
{/* Refresh button */}
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
onClick={() => void refetch()}
>
<RefreshCw className="mr-2 h-4 w-4" />
Refresh
</Button>
</div>
{/* Server Status */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-lg">
<Server className="h-5 w-5" />
Server Status
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center gap-3">
<CheckCircle2 className="h-5 w-5 text-green-500" />
<div>
<p className="text-sm font-medium">Running</p>
<p className="text-xs text-muted-foreground">
Uptime: {formatUptime(server.uptime)}
{server.startedAt && (
<>
{' '}
&middot; Started{' '}
{new Date(server.startedAt).toLocaleString()}
</>
)}
</p>
</div>
</div>
</CardContent>
</Card>
{/* Accounts */}
<div className="space-y-3">
<h2 className="text-lg font-semibold">Accounts</h2>
{accounts.length === 0 ? (
<Card>
<CardContent className="py-6">
<p className="text-center text-sm text-muted-foreground">
No accounts configured. Use{' '}
<code className="rounded bg-muted px-1 py-0.5 text-xs">
cw account add
</code>{' '}
to register one.
</p>
</CardContent>
</Card>
) : (
accounts.map((account) => (
<AccountCard key={account.id} account={account} />
))
)}
</div>
{/* Projects */}
<div className="space-y-3">
<h2 className="text-lg font-semibold">Projects</h2>
{projects.length === 0 ? (
<Card>
<CardContent className="py-6">
<p className="text-center text-sm text-muted-foreground">
No projects registered yet.
</p>
</CardContent>
</Card>
) : (
projects.map((project) => (
<Card key={project.id}>
<CardContent className="flex items-center gap-3 py-4">
{project.repoExists ? (
<CheckCircle2 className="h-5 w-5 shrink-0 text-green-500" />
) : (
<XCircle className="h-5 w-5 shrink-0 text-destructive" />
)}
<div className="min-w-0 flex-1">
<p className="text-sm font-medium">{project.name}</p>
<p className="truncate text-xs text-muted-foreground">
{project.url}
</p>
</div>
<span className="shrink-0 text-xs text-muted-foreground">
{project.repoExists ? 'Clone found' : 'Clone missing'}
</span>
</CardContent>
</Card>
))
)}
</div>
</div>
)
}
// ---------------------------------------------------------------------------
// Account card
// ---------------------------------------------------------------------------
type AccountData = {
id: string
email: string
provider: string
credentialsValid: boolean
tokenValid: boolean
tokenExpiresAt: string | null
subscriptionType: string | null
error: string | null
usage: {
five_hour: { utilization: number; resets_at: string | null } | null
seven_day: { utilization: number; resets_at: string | null } | null
seven_day_sonnet: { utilization: number; resets_at: string | null } | null
seven_day_opus: { utilization: number; resets_at: string | null } | null
extra_usage: {
is_enabled: boolean
monthly_limit: number | null
used_credits: number | null
utilization: number | null
} | null
} | null
isExhausted: boolean
exhaustedUntil: string | null
lastUsedAt: string | null
agentCount: number
activeAgentCount: number
}
function AccountCard({ account }: { account: AccountData }) {
const statusIcon = !account.credentialsValid ? (
<XCircle className="h-5 w-5 shrink-0 text-destructive" />
) : account.isExhausted ? (
<AlertTriangle className="h-5 w-5 shrink-0 text-yellow-500" />
) : (
<CheckCircle2 className="h-5 w-5 shrink-0 text-green-500" />
)
const statusText = !account.credentialsValid
? 'Invalid credentials'
: account.isExhausted
? `Exhausted until ${account.exhaustedUntil ? new Date(account.exhaustedUntil).toLocaleTimeString() : 'unknown'}`
: 'Available'
const usage = account.usage
return (
<Card>
<CardContent className="space-y-3 py-4">
{/* Header row */}
<div className="flex items-start gap-3">
{statusIcon}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium">{account.email}</span>
<Badge variant="outline">{account.provider}</Badge>
{account.subscriptionType && (
<Badge variant="secondary">
{capitalize(account.subscriptionType)}
</Badge>
)}
</div>
<div className="mt-1 flex items-center gap-3 text-xs text-muted-foreground">
<span>
{account.agentCount} agent{account.agentCount !== 1 ? 's' : ''}{' '}
({account.activeAgentCount} active)
</span>
<span>{statusText}</span>
</div>
</div>
</div>
{/* Usage bars */}
{usage && (
<div className="space-y-1.5 pl-8">
{usage.five_hour && (
<UsageBar
label="Session (5h)"
utilization={usage.five_hour.utilization}
resetsAt={usage.five_hour.resets_at}
/>
)}
{usage.seven_day && (
<UsageBar
label="Weekly (7d)"
utilization={usage.seven_day.utilization}
resetsAt={usage.seven_day.resets_at}
/>
)}
{usage.seven_day_sonnet &&
usage.seven_day_sonnet.utilization > 0 && (
<UsageBar
label="Sonnet (7d)"
utilization={usage.seven_day_sonnet.utilization}
resetsAt={usage.seven_day_sonnet.resets_at}
/>
)}
{usage.seven_day_opus && usage.seven_day_opus.utilization > 0 && (
<UsageBar
label="Opus (7d)"
utilization={usage.seven_day_opus.utilization}
resetsAt={usage.seven_day_opus.resets_at}
/>
)}
{usage.extra_usage && usage.extra_usage.is_enabled && (
<div className="flex items-center gap-2 text-xs">
<span className="w-20 shrink-0 text-muted-foreground">
Extra usage
</span>
<span>
${((usage.extra_usage.used_credits ?? 0) / 100).toFixed(2)}{' '}
used
{usage.extra_usage.monthly_limit != null && (
<>
{' '}
/ ${(usage.extra_usage.monthly_limit / 100).toFixed(
2
)}{' '}
limit
</>
)}
</span>
</div>
)}
</div>
)}
{/* Error message */}
{account.error && (
<p className="pl-8 text-xs text-destructive">{account.error}</p>
)}
</CardContent>
</Card>
)
}

View File

@@ -0,0 +1,7 @@
import { createFileRoute, redirect } from '@tanstack/react-router'
export const Route = createFileRoute('/settings/')({
beforeLoad: () => {
throw redirect({ to: '/settings/health' })
},
})

View File

@@ -0,0 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/routetree.gen.ts","./src/router.tsx","./src/vite-env.d.ts","./src/components/actionmenu.tsx","./src/components/agentoutputviewer.tsx","./src/components/createinitiativedialog.tsx","./src/components/decisionlist.tsx","./src/components/dependencyindicator.tsx","./src/components/errorboundary.tsx","./src/components/executiontab.tsx","./src/components/freetextinput.tsx","./src/components/inboxlist.tsx","./src/components/initiativecard.tsx","./src/components/initiativeheader.tsx","./src/components/initiativelist.tsx","./src/components/messagecard.tsx","./src/components/optiongroup.tsx","./src/components/phaseaccordion.tsx","./src/components/progressbar.tsx","./src/components/progresspanel.tsx","./src/components/projectpicker.tsx","./src/components/questionform.tsx","./src/components/refinespawndialog.tsx","./src/components/registerprojectdialog.tsx","./src/components/skeleton.tsx","./src/components/spawnarchitectdropdown.tsx","./src/components/statusbadge.tsx","./src/components/statusdot.tsx","./src/components/taskdetailmodal.tsx","./src/components/taskrow.tsx","./src/components/editor/blockselectionextension.ts","./src/components/editor/contentproposalreview.tsx","./src/components/editor/contenttab.tsx","./src/components/editor/pagebreadcrumb.tsx","./src/components/editor/pagelinkextension.tsx","./src/components/editor/pagetitlecontext.tsx","./src/components/editor/pagetree.tsx","./src/components/editor/refineagentpanel.tsx","./src/components/editor/slashcommandlist.tsx","./src/components/editor/slashcommands.ts","./src/components/editor/tiptapeditor.tsx","./src/components/editor/slash-command-items.ts","./src/components/execution/breakdownsection.tsx","./src/components/execution/executioncontext.tsx","./src/components/execution/phaseactions.tsx","./src/components/execution/phasewithtasks.tsx","./src/components/execution/phaseslist.tsx","./src/components/execution/plantasksfetcher.tsx","./src/components/execution/progresssidebar.tsx","./src/components/execution/taskmodal.tsx","./src/components/execution/index.ts","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/sonner.tsx","./src/components/ui/textarea.tsx","./src/hooks/index.ts","./src/hooks/useautosave.ts","./src/hooks/usedebounce.ts","./src/hooks/userefineagent.ts","./src/hooks/usespawnmutation.ts","./src/hooks/usesubscriptionwitherrorhandling.ts","./src/layouts/applayout.tsx","./src/lib/markdown-to-tiptap.ts","./src/lib/trpc.ts","./src/lib/utils.ts","./src/routes/__root.tsx","./src/routes/agents.tsx","./src/routes/inbox.tsx","./src/routes/index.tsx","./src/routes/settings.tsx","./src/routes/initiatives/$id.tsx","./src/routes/initiatives/index.tsx","./src/routes/settings/health.tsx","./src/routes/settings/index.tsx"],"errors":true,"version":"5.9.3"}