refactor: Restructure monorepo to apps/server/ and apps/web/ layout

Move src/ → apps/server/ and packages/web/ → apps/web/ to adopt
standard monorepo conventions (apps/ for runnable apps, packages/
for reusable libraries). Update all config files, shared package
imports, test fixtures, and documentation to reflect new paths.

Key fixes:
- Update workspace config to ["apps/*", "packages/*"]
- Update tsconfig.json rootDir/include for apps/server/
- Add apps/web/** to vitest exclude list
- Update drizzle.config.ts schema path
- Fix ensure-schema.ts migration path detection (3 levels up in dev,
  2 levels up in dist)
- Fix tests/integration/cli-server.test.ts import paths
- Update packages/shared imports to apps/server/ paths
- Update all docs/ files with new paths
This commit is contained in:
Lukas May
2026-03-03 11:22:53 +01:00
parent 8c38d958ce
commit 34578d39c6
535 changed files with 75452 additions and 687 deletions

16
apps/web/components.json Normal file
View File

@@ -0,0 +1,16 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "default",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "tailwind.config.ts",
"css": "src/index.css",
"baseColor": "neutral",
"cssVariables": true
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils"
}
}

12
apps/web/index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Codewalk District</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

54
apps/web/package.json Normal file
View File

@@ -0,0 +1,54 @@
{
"name": "@codewalk-district/web",
"private": true,
"version": "0.0.1",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview",
"lint": "tsc --noEmit"
},
"dependencies": {
"@codewalk-district/shared": "*",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-label": "^2.1.8",
"@radix-ui/react-select": "^2.2.6",
"@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",
"clsx": "^2.1.1",
"lucide-react": "^0.563.0",
"react": "^19.0.0",
"react-dom": "^19.0.0",
"sonner": "^2.0.7",
"tailwind-merge": "^3.4.0",
"tippy.js": "^6.3.7"
},
"devDependencies": {
"@radix-ui/react-dropdown-menu": "^2.1.16",
"@radix-ui/react-slot": "^1.2.4",
"@tailwindcss/typography": "^0.5.19",
"@tanstack/router-plugin": "^1.158.0",
"@types/node": "^25.2.0",
"@types/react": "^19.0.0",
"@types/react-dom": "^19.0.0",
"@vitejs/plugin-react": "^4.3.4",
"autoprefixer": "^10.4.24",
"postcss": "^8.5.6",
"tailwindcss": "^3.4.19",
"tailwindcss-animate": "^1.0.7",
"typescript": "^5.7.3",
"vite": "^6.1.0"
}
}

View File

@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};

8
apps/web/src/App.tsx Normal file
View File

@@ -0,0 +1,8 @@
import { RouterProvider } from '@tanstack/react-router'
import { router } from './router'
function App() {
return <RouterProvider router={router} />
}
export default App

View File

@@ -0,0 +1,207 @@
import { CheckCircle2, XCircle, AlertTriangle } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
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);
}
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>
);
}
export 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;
};
export function AccountCard({ account }: { account: AccountData }) {
const hasWarning = account.credentialsValid && !account.isExhausted && account.error;
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" />
) : hasWarning ? (
<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"}`
: hasWarning
? "Setup incomplete"
: "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 / warning message */}
{account.error && (
<p className={`pl-8 text-xs ${hasWarning ? 'text-yellow-600 dark:text-yellow-500' : 'text-destructive'}`}>
{account.error}
</p>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,63 @@
import { MoreHorizontal } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { toast } from "sonner";
import { trpc } from "@/lib/trpc";
interface ActionMenuProps {
initiativeId: string;
onDelete?: () => void;
}
export function ActionMenu({ initiativeId, onDelete }: ActionMenuProps) {
const archiveMutation = trpc.updateInitiative.useMutation({
onSuccess: () => {
onDelete?.();
toast.success("Initiative archived");
},
onError: () => {
toast.error("Failed to archive initiative");
},
});
function handleArchive() {
const confirmed = window.confirm(
"Are you sure you want to archive this initiative? It can be restored later."
);
if (!confirmed) return;
archiveMutation.mutate({
id: initiativeId,
status: "archived",
});
}
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-8 w-8">
<MoreHorizontal className="h-4 w-4" />
<span className="sr-only">More actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem disabled>Edit</DropdownMenuItem>
<DropdownMenuItem disabled>Duplicate</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleArchive}
disabled={archiveMutation.isPending}
>
{archiveMutation.isPending ? "Archiving..." : "Archive"}
</DropdownMenuItem>
<DropdownMenuItem disabled>Delete</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,73 @@
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { Button } from "@/components/ui/button";
import { MoreHorizontal } from "lucide-react";
interface AgentActionsProps {
agentId: string;
status: string;
isDismissed: boolean;
onStop: (id: string) => void;
onDelete: (id: string) => void;
onDismiss: (id: string) => void;
onGoToInbox: () => void;
}
export function AgentActions({
agentId,
status,
isDismissed,
onStop,
onDelete,
onDismiss,
onGoToInbox,
}: AgentActionsProps) {
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-6 w-6">
<MoreHorizontal className="h-3.5 w-3.5" />
<span className="sr-only">Agent actions</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{status === "waiting_for_input" && (
<>
<DropdownMenuItem onClick={onGoToInbox}>
Go to Inbox
</DropdownMenuItem>
<DropdownMenuSeparator />
</>
)}
{(status === "running" || status === "waiting_for_input") && (
<DropdownMenuItem onClick={() => onStop(agentId)}>
Stop
</DropdownMenuItem>
)}
{!isDismissed &&
["stopped", "crashed", "idle"].includes(status) && (
<DropdownMenuItem onClick={() => onDismiss(agentId)}>
Dismiss
</DropdownMenuItem>
)}
{(isDismissed ||
["stopped", "crashed", "idle"].includes(status)) && (
<>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={() => onDelete(agentId)}
>
Delete
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,241 @@
import { useEffect, useRef, useState } from "react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { ArrowDown, Pause, Play, AlertCircle, Square } from "lucide-react";
import { trpc } from "@/lib/trpc";
import { useSubscriptionWithErrorHandling } from "@/hooks";
import {
type ParsedMessage,
getMessageStyling,
parseAgentOutput,
} from "@/lib/parse-agent-output";
interface AgentOutputViewerProps {
agentId: string;
agentName?: string;
status?: string;
onStop?: (id: string) => void;
}
export function AgentOutputViewer({ agentId, agentName, status, onStop }: AgentOutputViewerProps) {
const [messages, setMessages] = useState<ParsedMessage[]>([]);
const [follow, setFollow] = useState(true);
const containerRef = useRef<HTMLDivElement>(null);
// Accumulate raw JSONL: initial query data + live subscription chunks
const rawBufferRef = useRef<string>('');
// 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: any) => {
// TrackedEnvelope shape: { id, data: { agentId, data: string } }
const raw = event?.data?.data ?? event?.data;
const chunk = typeof raw === 'string' ? raw : JSON.stringify(raw);
rawBufferRef.current += chunk;
setMessages(parseAgentOutput(rawBufferRef.current));
},
onError: (error) => {
console.error('Agent output subscription error:', error);
},
autoReconnect: true,
maxReconnectAttempts: 3,
}
);
// Set initial output when query loads
useEffect(() => {
if (outputQuery.data) {
rawBufferRef.current = outputQuery.data;
setMessages(parseAgentOutput(outputQuery.data));
}
}, [outputQuery.data]);
// Reset output when agent changes
useEffect(() => {
rawBufferRef.current = '';
setMessages([]);
setFollow(true);
}, [agentId]);
// Auto-scroll to bottom when following
useEffect(() => {
if (follow && containerRef.current) {
containerRef.current.scrollTop = containerRef.current.scrollHeight;
}
}, [messages, 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 = messages.length > 0;
return (
<div className="flex flex-col h-full 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">
{onStop && (status === "running" || status === "waiting_for_input") && (
<Button
variant="destructive"
size="sm"
onClick={() => onStop(agentId)}
className="h-7"
>
<Square className="mr-1 h-3 w-3" />
Stop
</Button>
)}
<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 */}
<div
ref={containerRef}
onScroll={handleScroll}
className="flex-1 overflow-y-auto bg-zinc-900 p-4"
>
{isLoading ? (
<div className="text-zinc-500 text-sm">Loading output...</div>
) : !hasOutput ? (
<div className="text-zinc-500 text-sm">No output yet...</div>
) : (
<div className="space-y-2">
{messages.map((message, index) => (
<div key={index} className={getMessageStyling(message.type)}>
{message.type === 'system' && (
<div className="flex items-center gap-2">
<Badge variant="secondary" className="text-xs">System</Badge>
<span className="text-xs text-zinc-400">{message.content}</span>
</div>
)}
{message.type === 'text' && (
<div className="font-mono text-sm whitespace-pre-wrap text-zinc-100">
{message.content}
</div>
)}
{message.type === 'tool_call' && (
<div className="border-l-2 border-blue-500 pl-3 py-1">
<Badge variant="default" className="mb-1 text-xs">
{message.meta?.toolName}
</Badge>
<div className="font-mono text-xs text-zinc-300 whitespace-pre-wrap">
{message.content}
</div>
</div>
)}
{message.type === 'tool_result' && (
<div className="border-l-2 border-green-500 pl-3 py-1 bg-zinc-800/30">
<Badge variant="outline" className="mb-1 text-xs">
Result
</Badge>
<div className="font-mono text-xs text-zinc-300 whitespace-pre-wrap">
{message.content}
</div>
</div>
)}
{message.type === 'error' && (
<div className="border-l-2 border-red-500 pl-3 py-1 bg-red-900/20">
<Badge variant="destructive" className="mb-1 text-xs">
Error
</Badge>
<div className="font-mono text-xs text-red-200 whitespace-pre-wrap">
{message.content}
</div>
</div>
)}
{message.type === 'session_end' && (
<div className="border-t border-zinc-700 pt-2 mt-4">
<div className="flex items-center gap-2">
<Badge variant={message.meta?.isError ? "destructive" : "default"} className="text-xs">
{message.content}
</Badge>
{message.meta?.cost && (
<span className="text-xs text-zinc-500">${message.meta.cost.toFixed(4)}</span>
)}
{message.meta?.duration && (
<span className="text-xs text-zinc-500">{(message.meta.duration / 1000).toFixed(1)}s</span>
)}
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,139 @@
import { useState, useCallback } from "react";
import { ChevronDown, ChevronRight, Undo2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { trpc } from "@/lib/trpc";
import type { ChangeSet } from "@codewalk-district/shared";
interface ChangeSetBannerProps {
changeSet: ChangeSet;
onDismiss: () => void;
}
const MODE_LABELS: Record<string, string> = {
plan: "phases",
detail: "tasks",
refine: "pages",
};
export function ChangeSetBanner({ changeSet, onDismiss }: ChangeSetBannerProps) {
const [expanded, setExpanded] = useState(false);
const [conflicts, setConflicts] = useState<string[] | null>(null);
const detailQuery = trpc.getChangeSet.useQuery(
{ id: changeSet.id },
{ enabled: expanded },
);
const revertMutation = trpc.revertChangeSet.useMutation({
onSuccess: (result) => {
if (!result.success && "conflicts" in result) {
setConflicts(result.conflicts);
} else {
setConflicts(null);
}
},
});
const handleRevert = useCallback(
(force?: boolean) => {
revertMutation.mutate({ id: changeSet.id, force });
},
[changeSet.id, revertMutation],
);
const entries = detailQuery.data?.entries ?? [];
const entityLabel = MODE_LABELS[changeSet.mode] ?? "entities";
const isReverted = changeSet.status === "reverted";
return (
<div className="rounded-lg border border-border bg-card p-3 space-y-2">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 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" />
)}
{changeSet.summary ??
`Agent ${isReverted ? "reverted" : "applied"} ${entityLabel}`}
</button>
{isReverted && (
<span className="text-xs text-muted-foreground italic">
(reverted)
</span>
)}
</div>
<div className="flex gap-1.5 shrink-0">
{!isReverted && (
<Button
variant="outline"
size="sm"
onClick={() => handleRevert()}
disabled={revertMutation.isPending}
className="gap-1"
>
<Undo2 className="h-3 w-3" />
{revertMutation.isPending ? "Reverting..." : "Revert"}
</Button>
)}
<Button variant="ghost" size="sm" onClick={onDismiss}>
Dismiss
</Button>
</div>
</div>
{conflicts && (
<div className="rounded border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950 p-2 space-y-2">
<p className="text-xs font-medium text-amber-800 dark:text-amber-200">
Conflicts detected:
</p>
<ul className="text-xs text-amber-700 dark:text-amber-300 list-disc pl-4 space-y-0.5">
{conflicts.map((c, i) => (
<li key={i}>{c}</li>
))}
</ul>
<Button
variant="outline"
size="sm"
onClick={() => {
setConflicts(null);
handleRevert(true);
}}
disabled={revertMutation.isPending}
>
Force Revert
</Button>
</div>
)}
{expanded && (
<div className="pl-5 space-y-1 text-xs text-muted-foreground">
{detailQuery.isLoading && <p>Loading entries...</p>}
{entries.map((entry) => (
<div key={entry.id} className="flex items-center gap-2">
<span className="font-mono">
{entry.action === "create" ? "+" : entry.action === "delete" ? "-" : "~"}
</span>
<span>
{entry.entityType}
{entry.newState && (() => {
try {
const parsed = JSON.parse(entry.newState);
return parsed.name || parsed.title ? `: ${parsed.name || parsed.title}` : "";
} catch { return ""; }
})()}
</span>
</div>
))}
{entries.length === 0 && !detailQuery.isLoading && (
<p>No entries</p>
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,168 @@
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 {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
import { toast } from "sonner";
import { trpc } from "@/lib/trpc";
import { ProjectPicker } from "./ProjectPicker";
interface CreateInitiativeDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
}
export function CreateInitiativeDialog({
open,
onOpenChange,
}: CreateInitiativeDialogProps) {
const [name, setName] = useState("");
const [branch, setBranch] = useState("");
const [projectIds, setProjectIds] = useState<string[]>([]);
const [executionMode, setExecutionMode] = useState<"yolo" | "review_per_phase">("review_per_phase");
const [error, setError] = useState<string | null>(null);
const utils = trpc.useUtils();
const createMutation = trpc.createInitiative.useMutation({
onMutate: async ({ name }) => {
await utils.listInitiatives.cancel();
const previousInitiatives = utils.listInitiatives.getData();
const tempInitiative = {
id: `temp-${Date.now()}`,
name: name.trim(),
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
mergeRequiresApproval: true,
branch: null,
projects: [],
};
utils.listInitiatives.setData(undefined, (old = []) => [tempInitiative, ...old]);
return { previousInitiatives };
},
onSuccess: () => {
onOpenChange(false);
toast.success("Initiative created");
},
onError: (err, _variables, context) => {
if (context?.previousInitiatives) {
utils.listInitiatives.setData(undefined, context.previousInitiatives);
}
setError(err.message);
toast.error("Failed to create initiative");
},
});
// Reset form when dialog opens
useEffect(() => {
if (open) {
setName("");
setBranch("");
setProjectIds([]);
setExecutionMode("review_per_phase");
setError(null);
}
}, [open]);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
createMutation.mutate({
name: name.trim(),
branch: branch.trim() || null,
projectIds: projectIds.length > 0 ? projectIds : undefined,
executionMode,
});
}
const canSubmit = name.trim().length > 0 && !createMutation.isPending;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent>
<DialogHeader>
<DialogTitle>Create Initiative</DialogTitle>
<DialogDescription>
Create a new initiative to plan and execute work.
</DialogDescription>
</DialogHeader>
<form onSubmit={handleSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="initiative-name">Name</Label>
<Input
id="initiative-name"
placeholder="e.g. User Authentication"
value={name}
onChange={(e) => setName(e.target.value)}
autoFocus
/>
</div>
<div className="space-y-2">
<Label htmlFor="initiative-branch">
Branch{" "}
<span className="text-muted-foreground font-normal">
(optional auto-generated if blank)
</span>
</Label>
<Input
id="initiative-branch"
placeholder="e.g. cw/my-feature"
value={branch}
onChange={(e) => setBranch(e.target.value)}
/>
</div>
<div className="space-y-2">
<Label>Execution Mode</Label>
<Select value={executionMode} onValueChange={(v) => setExecutionMode(v as "yolo" | "review_per_phase")}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="review_per_phase">Review per Phase</SelectItem>
<SelectItem value="yolo">YOLO (auto-merge)</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>
Projects{" "}
<span className="text-muted-foreground font-normal">
(optional)
</span>
</Label>
<ProjectPicker value={projectIds} onChange={setProjectIds} />
</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}>
{createMutation.isPending ? "Creating..." : "Create"}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,96 @@
import { useState } from "react";
import { ChevronDown, ChevronRight } from "lucide-react";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
export interface Decision {
topic: string;
decision: string;
reason: string;
}
interface DecisionListProps {
decisions: Decision[];
maxVisible?: number;
}
export function DecisionList({ decisions, maxVisible = 3 }: DecisionListProps) {
const [expandedIndices, setExpandedIndices] = useState<Set<number>>(
new Set()
);
const [showAll, setShowAll] = useState(false);
function toggleDecision(index: number) {
setExpandedIndices((prev) => {
const next = new Set(prev);
if (next.has(index)) {
next.delete(index);
} else {
next.add(index);
}
return next;
});
}
const visibleDecisions = showAll
? decisions
: decisions.slice(0, maxVisible);
const hasMore = decisions.length > maxVisible;
return (
<Card>
<CardHeader>
<CardTitle className="text-base">Key Decisions</CardTitle>
</CardHeader>
<CardContent>
{decisions.length === 0 ? (
<p className="text-sm text-muted-foreground">
No decisions recorded
</p>
) : (
<div className="space-y-1">
{visibleDecisions.map((d, i) => {
const isExpanded = expandedIndices.has(i);
return (
<div key={i}>
<button
type="button"
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm font-medium hover:bg-accent/50"
onClick={() => toggleDecision(i)}
>
{isExpanded ? (
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
{d.topic}
</button>
{isExpanded && (
<div className="ml-8 space-y-1 pb-2 text-sm">
<p>{d.decision}</p>
<p className="text-muted-foreground">
Reason: {d.reason}
</p>
</div>
)}
</div>
);
})}
{hasMore && (
<Button
variant="ghost"
size="sm"
className="mt-1 w-full"
onClick={() => setShowAll((prev) => !prev)}
>
{showAll
? "Show less"
: `Show ${decisions.length - maxVisible} more`}
</Button>
)}
</div>
)}
</CardContent>
</Card>
);
}

View File

@@ -0,0 +1,28 @@
import { cn } from "@/lib/utils";
interface DependencyItem {
name: string;
status: string;
}
interface DependencyIndicatorProps {
blockedBy: DependencyItem[];
type: "task" | "phase";
className?: string;
}
export function DependencyIndicator({
blockedBy,
type: _type,
className,
}: DependencyIndicatorProps) {
if (blockedBy.length === 0) return null;
const names = blockedBy.map((item) => item.name).join(", ");
return (
<div className={cn("pl-8 text-sm text-amber-600", className)}>
<span className="font-mono">^</span> blocked by: {names}
</div>
);
}

View File

@@ -0,0 +1,54 @@
import { Component } from "react";
import type { ErrorInfo, ReactNode } from "react";
import { AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
interface ErrorBoundaryProps {
children: ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
export class ErrorBoundary extends Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo): void {
console.error("ErrorBoundary caught an error:", error, errorInfo);
}
handleReload = () => {
window.location.reload();
};
render() {
if (this.state.hasError) {
return (
<div className="flex flex-col items-center justify-center gap-4 py-12">
<AlertCircle className="h-8 w-8 text-destructive" />
<h2 className="text-lg font-semibold">Something went wrong</h2>
<p className="max-w-md text-center text-sm text-muted-foreground">
{this.state.error?.message ?? "An unexpected error occurred."}
</p>
<Button variant="outline" size="sm" onClick={this.handleReload}>
Reload
</Button>
</div>
);
}
return this.props.children;
}
}

View File

@@ -0,0 +1,326 @@
import { useState, useMemo, useRef, useEffect } from "react";
import { toast } from "sonner";
import { trpc } from "@/lib/trpc";
import { topologicalSortPhases, type DependencyEdge } from "@codewalk-district/shared";
import {
ExecutionProvider,
PhaseActions,
PlanSection,
TaskModal,
type PhaseData,
} from "@/components/execution";
import { PhaseSidebarItem } from "@/components/execution/PhaseSidebarItem";
import {
PhaseDetailPanel,
PhaseDetailEmpty,
} from "@/components/execution/PhaseDetailPanel";
import { Skeleton } from "@/components/Skeleton";
interface ExecutionTabProps {
initiativeId: string;
phases: PhaseData[];
phasesLoading: boolean;
phasesLoaded: boolean;
dependencyEdges: DependencyEdge[];
branch?: string | null;
}
export function ExecutionTab({
initiativeId,
phases,
phasesLoading,
phasesLoaded,
dependencyEdges,
branch,
}: ExecutionTabProps) {
// Topological sort
const sortedPhases = useMemo(
() => topologicalSortPhases(phases, dependencyEdges),
[phases, dependencyEdges],
);
// Build dependency name map from bulk edges
const depNamesByPhase = useMemo(() => {
const map = new Map<string, string[]>();
const phaseIndex = new Map(sortedPhases.map((p, i) => [p.id, i + 1]));
for (const edge of dependencyEdges) {
const depIdx = phaseIndex.get(edge.dependsOnPhaseId);
if (!depIdx) continue;
const existing = map.get(edge.phaseId) ?? [];
existing.push(`Phase ${depIdx}`);
map.set(edge.phaseId, existing);
}
return map;
}, [dependencyEdges, sortedPhases]);
// Detail agent tracking: map phaseId → most recent active detail agent
const agentsQuery = trpc.listAgents.useQuery();
const allAgents = agentsQuery.data ?? [];
// Default to first incomplete phase
const firstIncompleteId = useMemo(() => {
const found = sortedPhases.find((p) => p.status !== "completed");
return found?.id ?? sortedPhases[0]?.id ?? null;
}, [sortedPhases]);
const [selectedPhaseId, setSelectedPhaseId] = useState<string | null>(null);
const [isAddingPhase, setIsAddingPhase] = useState(false);
const deletePhase = trpc.deletePhase.useMutation({
onSuccess: () => {
setSelectedPhaseId(null);
toast.success("Phase deleted");
},
onError: () => {
toast.error("Failed to delete phase");
},
});
const createPhase = trpc.createPhase.useMutation({
onSuccess: () => {
setIsAddingPhase(false);
toast.success("Phase created");
},
onError: () => {
toast.error("Failed to create phase");
},
});
function handleStartAdd() {
setIsAddingPhase(true);
}
function handleConfirmAdd(name: string) {
const trimmed = name.trim();
if (!trimmed) {
setIsAddingPhase(false);
return;
}
createPhase.mutate({ initiativeId, name: trimmed });
}
function handleCancelAdd() {
setIsAddingPhase(false);
}
// Resolve actual selected ID (use default if none explicitly selected)
const activePhaseId = selectedPhaseId ?? firstIncompleteId;
const activePhase = sortedPhases.find((p) => p.id === activePhaseId) ?? null;
const activeDisplayIndex = activePhase
? sortedPhases.indexOf(activePhase) + 1
: 0;
// Fetch all tasks for the initiative in one query (for sidebar counts)
const allTasksQuery = trpc.listInitiativeTasks.useQuery(
{ initiativeId },
{ enabled: phasesLoaded && sortedPhases.length > 0 },
);
const allTasks = allTasksQuery.data ?? [];
// Group tasks and counts by phaseId
const { taskCountsByPhase, tasksByPhase } = useMemo(() => {
const counts: Record<string, { complete: number; total: number }> = {};
const grouped: Record<string, typeof allTasks> = {};
for (const task of allTasks) {
const pid = task.phaseId;
if (!pid) continue;
if (!counts[pid]) counts[pid] = { complete: 0, total: 0 };
counts[pid].total++;
if (task.status === "completed") counts[pid].complete++;
if (!grouped[pid]) grouped[pid] = [];
grouped[pid].push(task);
}
return { taskCountsByPhase: counts, tasksByPhase: grouped };
}, [allTasks]);
// Map phaseId → most recent active detail agent
const detailAgentByPhase = useMemo(() => {
const map = new Map<string, (typeof allAgents)[number]>();
// Build taskId → phaseId lookup from allTasks
const taskPhaseMap = new Map<string, string>();
for (const t of allTasks) {
if (t.phaseId) taskPhaseMap.set(t.id, t.phaseId);
}
const candidates = allAgents.filter(
(a) =>
a.mode === "detail" &&
a.initiativeId === initiativeId &&
["running", "waiting_for_input", "idle"].includes(a.status) &&
!a.userDismissedAt,
);
for (const agent of candidates) {
const phaseId = taskPhaseMap.get(agent.taskId ?? "");
if (!phaseId) continue;
const existing = map.get(phaseId);
if (
!existing ||
new Date(agent.createdAt).getTime() >
new Date(existing.createdAt).getTime()
) {
map.set(phaseId, agent);
}
}
return map;
}, [allAgents, allTasks, initiativeId]);
// Phase IDs that have zero tasks (eligible for detailing)
const phasesWithoutTasks = useMemo(
() =>
sortedPhases
.filter((p) => !taskCountsByPhase[p.id]?.total)
.map((p) => p.id),
[sortedPhases, taskCountsByPhase],
);
// Build display indices map for PhaseDetailPanel
const allDisplayIndices = useMemo(
() => new Map(sortedPhases.map((p, i) => [p.id, i + 1])),
[sortedPhases],
);
// No phases yet and not adding — show plan section
if (phasesLoaded && sortedPhases.length === 0 && !isAddingPhase) {
return (
<ExecutionProvider>
<PlanSection
initiativeId={initiativeId}
phasesLoaded={phasesLoaded}
phases={sortedPhases}
onAddPhase={handleStartAdd}
/>
<TaskModal />
</ExecutionProvider>
);
}
const nextNumber = sortedPhases.length + 1;
return (
<ExecutionProvider>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[260px_1fr]">
{/* Left: Phase sidebar */}
<div className="space-y-0">
<div className="flex items-center justify-between border-b border-border pb-3">
<h2 className="text-sm font-semibold uppercase tracking-wide text-muted-foreground">
Phases
</h2>
<PhaseActions
initiativeId={initiativeId}
phases={sortedPhases}
onAddPhase={handleStartAdd}
phasesWithoutTasks={phasesWithoutTasks}
detailAgentByPhase={detailAgentByPhase}
/>
</div>
{phasesLoading ? (
<div className="space-y-1 pt-2">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-14 w-full" />
))}
</div>
) : (
<div className="space-y-0.5 pt-2">
{sortedPhases.map((phase, index) => (
<PhaseSidebarItem
key={phase.id}
phase={phase}
displayIndex={index + 1}
taskCount={
taskCountsByPhase[phase.id] ?? { complete: 0, total: 0 }
}
dependencies={depNamesByPhase.get(phase.id) ?? []}
isSelected={phase.id === activePhaseId}
onClick={() => setSelectedPhaseId(phase.id)}
/>
))}
{isAddingPhase && (
<NewPhaseEntry
number={nextNumber}
onConfirm={handleConfirmAdd}
onCancel={handleCancelAdd}
/>
)}
</div>
)}
</div>
{/* Right: Phase detail */}
<div className="min-h-[400px]">
{activePhase ? (
<PhaseDetailPanel
key={activePhase.id}
phase={activePhase}
phases={sortedPhases}
displayIndex={activeDisplayIndex}
allDisplayIndices={allDisplayIndices}
initiativeId={initiativeId}
tasks={tasksByPhase[activePhase.id] ?? []}
tasksLoading={allTasksQuery.isLoading}
onDelete={() => deletePhase.mutate({ id: activePhase.id })}
detailAgent={detailAgentByPhase.get(activePhase.id) ?? null}
branch={branch}
/>
) : (
<PhaseDetailEmpty />
)}
</div>
</div>
<TaskModal />
</ExecutionProvider>
);
}
/** Editable placeholder entry that looks like a PhaseSidebarItem */
function NewPhaseEntry({
number,
onConfirm,
onCancel,
}: {
number: number;
onConfirm: (name: string) => void;
onCancel: () => void;
}) {
const [name, setName] = useState("");
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, []);
function handleBlur() {
const trimmed = name.trim();
if (trimmed) {
onConfirm(trimmed);
} else {
onCancel();
}
}
return (
<div className="flex w-full flex-col gap-0.5 rounded-md border-l-2 border-primary bg-accent px-3 py-2">
<div className="flex items-center gap-1">
<span className="shrink-0 text-sm font-medium">Phase {number}:</span>
<input
ref={inputRef}
className="min-w-0 flex-1 bg-transparent text-sm font-medium outline-none placeholder:text-muted-foreground"
placeholder="Phase name"
value={name}
onChange={(e) => setName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
const trimmed = name.trim();
if (trimmed) onConfirm(trimmed);
else onCancel();
}
if (e.key === "Escape") onCancel();
}}
onBlur={handleBlur}
/>
</div>
<div className="text-xs text-muted-foreground">0/0 tasks</div>
</div>
);
}

View File

@@ -0,0 +1,39 @@
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
interface FreeTextInputProps {
questionId: string;
value: string;
onChange: (value: string) => void;
multiline?: boolean;
placeholder?: string;
}
export function FreeTextInput({
questionId,
value,
onChange,
multiline = false,
placeholder,
}: FreeTextInputProps) {
if (multiline) {
return (
<Textarea
id={questionId}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder ?? "Type your answer..."}
className="min-h-[80px]"
/>
);
}
return (
<Input
id={questionId}
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder ?? "Type your answer..."}
/>
);
}

View File

@@ -0,0 +1,171 @@
import { Link } from "@tanstack/react-router";
import { ChevronLeft } from "lucide-react";
import { Button } from "@/components/ui/button";
import { QuestionForm } from "@/components/QuestionForm";
import { formatRelativeTime } from "@/lib/utils";
interface InboxDetailPanelProps {
agent: {
id: string;
name: string;
status: string;
taskId: string | null;
updatedAt: string;
};
message: {
id: string;
content: string;
requiresResponse: boolean;
} | null;
questions:
| {
id: string;
question: string;
options: any;
multiSelect: boolean;
}[]
| null;
isLoadingQuestions: boolean;
questionsError: string | null;
onBack: () => void;
onSubmitAnswers: (answers: Record<string, string>) => void;
onDismissQuestions: () => void;
onDismissMessage: () => void;
isSubmitting: boolean;
isDismissingQuestions: boolean;
isDismissingMessage: boolean;
submitError: string | null;
dismissMessageError: string | null;
}
export function InboxDetailPanel({
agent,
message,
questions,
isLoadingQuestions,
questionsError,
onBack,
onSubmitAnswers,
onDismissQuestions,
onDismissMessage,
isSubmitting,
isDismissingQuestions,
isDismissingMessage,
submitError,
dismissMessageError,
}: InboxDetailPanelProps) {
return (
<div className="space-y-4 rounded-lg border border-border p-4">
{/* Mobile back button */}
<Button
variant="ghost"
size="sm"
className="lg:hidden"
onClick={onBack}
>
<ChevronLeft className="mr-1 h-4 w-4" />
Back to list
</Button>
{/* Detail Header */}
<div className="border-b border-border pb-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-bold">
{agent.name}{" "}
<span className="font-normal text-muted-foreground">
&rarr; You
</span>
</h3>
<span className="text-xs text-muted-foreground">
{formatRelativeTime(agent.updatedAt)}
</span>
</div>
<p className="mt-1 text-xs text-muted-foreground">
Task:{" "}
{agent.taskId ? (
<Link
to="/initiatives"
className="text-primary hover:underline"
>
{agent.taskId}
</Link>
) : (
"\u2014"
)}
</p>
{agent.taskId && (
<Link
to="/initiatives"
className="mt-1 inline-block text-xs text-primary hover:underline"
>
View in context &rarr;
</Link>
)}
</div>
{/* Question Form or Notification Content */}
{isLoadingQuestions && (
<div className="py-4 text-center text-sm text-muted-foreground">
Loading questions...
</div>
)}
{questionsError && (
<div className="py-4 text-center text-sm text-destructive">
Failed to load questions: {questionsError}
</div>
)}
{questions && questions.length > 0 && (
<QuestionForm
questions={questions}
onSubmit={onSubmitAnswers}
onCancel={onBack}
onDismiss={onDismissQuestions}
isSubmitting={isSubmitting}
isDismissing={isDismissingQuestions}
/>
)}
{submitError && (
<p className="text-sm text-destructive">Error: {submitError}</p>
)}
{/* Notification message (no questions / requiresResponse=false) */}
{message && !message.requiresResponse && !isLoadingQuestions && (
<div className="space-y-3">
<p className="text-sm">{message.content}</p>
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
onClick={onDismissMessage}
disabled={isDismissingMessage}
>
{isDismissingMessage ? "Dismissing..." : "Dismiss"}
</Button>
</div>
{dismissMessageError && (
<p className="text-sm text-destructive">
Error: {dismissMessageError}
</p>
)}
</div>
)}
{/* No questions and requires response -- message content only */}
{message &&
message.requiresResponse &&
questions &&
questions.length === 0 &&
!isLoadingQuestions && (
<div className="space-y-3">
<p className="text-sm">{message.content}</p>
<p className="text-xs text-muted-foreground">
Waiting for structured questions...
</p>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,195 @@
import { useMemo, useState } from "react";
import { RefreshCw } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { MessageCard } from "@/components/MessageCard";
import { cn } from "@/lib/utils";
interface Agent {
id: string;
name: string;
status: string;
taskId: string;
updatedAt: string;
}
interface Message {
id: string;
senderId: string | null;
content: string;
requiresResponse: boolean;
status: string;
createdAt: string;
}
type FilterValue = "all" | "waiting" | "completed";
type SortValue = "newest" | "oldest";
interface InboxListProps {
agents: Agent[];
messages: Message[];
selectedAgentId: string | null;
onSelectAgent: (agentId: string) => void;
onRefresh: () => void;
}
interface JoinedEntry {
agent: Agent;
message: Message;
}
export function InboxList({
agents,
messages,
selectedAgentId,
onSelectAgent,
onRefresh,
}: InboxListProps) {
const [filter, setFilter] = useState<FilterValue>("all");
const [sort, setSort] = useState<SortValue>("newest");
// Join agents with their latest message (match message.senderId to agent.id)
// Also include agents with waiting_for_input status even if they don't have messages
const joined = useMemo(() => {
const latestByAgent = new Map<string, Message>();
for (const msg of messages) {
if (msg.senderId === null) continue;
const existing = latestByAgent.get(msg.senderId);
if (!existing || new Date(msg.createdAt) > new Date(existing.createdAt)) {
latestByAgent.set(msg.senderId, msg);
}
}
const entries: JoinedEntry[] = [];
for (const agent of agents) {
const msg = latestByAgent.get(agent.id);
if (msg) {
// Agent has a message
entries.push({ agent, message: msg });
} else if (agent.status === 'waiting_for_input') {
// Agent is waiting for input but has no message - create a placeholder message for questions
const placeholderMessage: Message = {
id: `questions-${agent.id}`,
senderId: agent.id,
content: "Agent has questions that need answers",
requiresResponse: true,
status: "pending",
createdAt: agent.updatedAt, // Use agent's updated time
};
entries.push({ agent, message: placeholderMessage });
}
}
return entries;
}, [agents, messages]);
// Filter
const filtered = useMemo(() => {
switch (filter) {
case "waiting":
return joined.filter((e) => e.message.requiresResponse);
case "completed":
return joined.filter((e) => !e.message.requiresResponse);
default:
return joined;
}
}, [joined, filter]);
// Sort
const sorted = useMemo(() => {
const items = [...filtered];
items.sort((a, b) => {
const ta = new Date(a.message.createdAt).getTime();
const tb = new Date(b.message.createdAt).getTime();
return sort === "newest" ? tb - ta : ta - tb;
});
return items;
}, [filtered, sort]);
const filterOptions: { value: FilterValue; label: string }[] = [
{ value: "all", label: "All" },
{ value: "waiting", label: "Waiting" },
{ value: "completed", label: "Completed" },
];
const sortOptions: { value: SortValue; label: string }[] = [
{ value: "newest", label: "Newest" },
{ value: "oldest", label: "Oldest" },
];
return (
<div className="space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h2 className="text-lg font-semibold">Agent Inbox</h2>
<Badge variant="secondary">{joined.length}</Badge>
</div>
<Button variant="outline" size="sm" onClick={onRefresh}>
<RefreshCw className="mr-1 h-4 w-4" />
Refresh
</Button>
</div>
{/* Filter and Sort controls */}
<div className="flex items-center gap-4">
<div className="flex items-center gap-1">
<span className="text-sm text-muted-foreground">Filter:</span>
{filterOptions.map((opt) => (
<Button
key={opt.value}
variant={filter === opt.value ? "default" : "outline"}
size="sm"
className="h-7 px-2 text-xs"
onClick={() => setFilter(opt.value)}
>
{opt.label}
</Button>
))}
</div>
<div className="flex items-center gap-1">
<span className="text-sm text-muted-foreground">Sort:</span>
{sortOptions.map((opt) => (
<Button
key={opt.value}
variant={sort === opt.value ? "default" : "outline"}
size="sm"
className={cn("h-7 px-2 text-xs")}
onClick={() => setSort(opt.value)}
>
{opt.label}
</Button>
))}
</div>
</div>
{/* Message list or empty state */}
{sorted.length === 0 ? (
<div className="flex flex-col items-center justify-center gap-2 py-16">
<p className="text-lg font-medium text-muted-foreground">
No pending messages
</p>
<p className="text-sm text-muted-foreground">
Agents will appear here when they have questions or status updates
</p>
</div>
) : (
<div className="space-y-2">
{sorted.map((entry) => (
<MessageCard
key={entry.message.id}
agentName={entry.agent.name}
agentStatus={entry.agent.status}
preview={entry.message.content}
timestamp={entry.message.createdAt}
requiresResponse={entry.message.requiresResponse}
isSelected={selectedAgentId === entry.agent.id}
onClick={() => onSelectAgent(entry.agent.id)}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,152 @@
import { MoreHorizontal, Eye, Bot } from "lucide-react";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
DropdownMenuSeparator,
} from "@/components/ui/dropdown-menu";
import { StatusBadge } from "@/components/StatusBadge";
import { ProgressBar } from "@/components/ProgressBar";
import { trpc } from "@/lib/trpc";
/** Initiative shape as returned by tRPC (Date serialized to string over JSON) */
export interface SerializedInitiative {
id: string;
name: string;
status: "active" | "completed" | "archived";
mergeRequiresApproval: boolean;
branch: string | null;
createdAt: string;
updatedAt: string;
}
interface InitiativeCardProps {
initiative: SerializedInitiative;
onView: () => void;
onSpawnArchitect: (mode: "discuss" | "plan") => void;
}
export function InitiativeCard({
initiative,
onView,
onSpawnArchitect,
}: InitiativeCardProps) {
const utils = trpc.useUtils();
const archiveMutation = trpc.updateInitiative.useMutation({
onSuccess: () => utils.listInitiatives.invalidate(),
});
const deleteMutation = trpc.deleteInitiative.useMutation({
onSuccess: () => utils.listInitiatives.invalidate(),
});
function handleArchive(e: React.MouseEvent) {
if (
!e.shiftKey &&
!window.confirm(`Archive "${initiative.name}"?`)
) {
return;
}
archiveMutation.mutate({ id: initiative.id, status: "archived" });
}
function handleDelete(e: React.MouseEvent) {
if (
!e.shiftKey &&
!window.confirm(`Delete "${initiative.name}"? This cannot be undone.`)
) {
return;
}
deleteMutation.mutate({ id: initiative.id });
}
// Each card fetches its own phase stats (N+1 acceptable for v1 small counts)
const phasesQuery = trpc.listPhases.useQuery({
initiativeId: initiative.id,
});
const phases = phasesQuery.data ?? [];
const completedCount = phases.filter((p) => p.status === "completed").length;
const totalCount = phases.length;
return (
<Card
className="cursor-pointer p-4 transition-colors hover:bg-accent/50"
onClick={onView}
>
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
{/* Left: Initiative name */}
<div className="min-w-0 flex-shrink-0">
<span className="text-base font-bold">{initiative.name}</span>
</div>
{/* Middle: Status + Progress + Phase count */}
<div className="flex flex-1 items-center gap-4">
<StatusBadge status={initiative.status} />
<ProgressBar
completed={completedCount}
total={totalCount}
className="w-32"
/>
<span className="hidden text-sm text-muted-foreground md:inline">
{completedCount}/{totalCount} phases
</span>
</div>
{/* Right: Action buttons */}
<div
className="flex items-center gap-2"
onClick={(e) => e.stopPropagation()}
>
<Button variant="outline" size="sm" onClick={onView}>
<Eye className="mr-1 h-4 w-4" />
View
</Button>
{/* Spawn Architect Dropdown */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
<Bot className="mr-1 h-4 w-4" />
Spawn Architect
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
onClick={() => onSpawnArchitect("discuss")}
>
Discuss
</DropdownMenuItem>
<DropdownMenuItem
onClick={() => onSpawnArchitect("plan")}
>
Plan
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{/* More Actions Menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={handleArchive}>Archive</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="text-destructive"
onClick={handleDelete}
>
Delete
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
</div>
</Card>
);
}

View File

@@ -0,0 +1,214 @@
import { useState } from "react";
import { ChevronLeft, Pencil, Check, X, GitBranch } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import { Input } from "@/components/ui/input";
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;
executionMode?: string;
branch?: string | null;
branchLocked?: boolean;
};
projects?: Array<{ id: string; name: string; url: string }>;
onBack: () => void;
}
export function InitiativeHeader({
initiative,
projects,
onBack,
}: InitiativeHeaderProps) {
const [editingProjects, setEditingProjects] = useState(false);
const [editIds, setEditIds] = useState<string[]>([]);
const [editingBranch, setEditingBranch] = useState(false);
const [branchValue, setBranchValue] = useState("");
const utils = trpc.useUtils();
const projectMutation = trpc.updateInitiativeProjects.useMutation({
onSuccess: () => {
setEditingProjects(false);
utils.getInitiative.invalidate({ id: initiative.id });
toast.success("Projects updated");
},
onError: (err) => {
toast.error(err.message);
},
});
const configMutation = trpc.updateInitiativeConfig.useMutation({
onSuccess: () => {
utils.getInitiative.invalidate({ id: initiative.id });
},
onError: (err) => {
toast.error(err.message);
},
});
function startEditingProjects() {
setEditIds(projects?.map((p) => p.id) ?? []);
setEditingProjects(true);
}
function saveProjects() {
if (editIds.length === 0) {
toast.error("At least one project is required");
return;
}
projectMutation.mutate({
initiativeId: initiative.id,
projectIds: editIds,
});
}
function toggleExecutionMode() {
const newMode = initiative.executionMode === "yolo" ? "review_per_phase" : "yolo";
configMutation.mutate({
initiativeId: initiative.id,
executionMode: newMode as "yolo" | "review_per_phase",
});
}
function startEditingBranch() {
setBranchValue(initiative.branch ?? "");
setEditingBranch(true);
}
function saveBranch() {
configMutation.mutate({
initiativeId: initiative.id,
branch: branchValue.trim() || null,
});
setEditingBranch(false);
}
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} />
{initiative.executionMode && (
<Badge
variant="outline"
className={`cursor-pointer select-none transition-colors ${
initiative.executionMode === "yolo"
? "border-orange-300 text-orange-700 text-[10px] hover:bg-orange-50"
: "border-blue-300 text-blue-700 text-[10px] hover:bg-blue-50"
}`}
onClick={toggleExecutionMode}
>
{configMutation.isPending
? "..."
: initiative.executionMode === "yolo"
? "YOLO"
: "REVIEW"}
</Badge>
)}
{!editingBranch && initiative.branch && (
<Badge
variant="outline"
className={`gap-1 text-[10px] font-mono transition-colors ${
initiative.branchLocked ? "" : "cursor-pointer hover:bg-muted"
}`}
onClick={initiative.branchLocked ? undefined : startEditingBranch}
>
<GitBranch className="h-3 w-3" />
{initiative.branch}
</Badge>
)}
{!editingBranch && !initiative.branch && !initiative.branchLocked && (
<button
className="text-[10px] text-muted-foreground hover:text-foreground transition-colors"
onClick={startEditingBranch}
>
+ branch
</button>
)}
{editingBranch && (
<div className="flex items-center gap-1">
<GitBranch className="h-3 w-3 text-muted-foreground" />
<Input
value={branchValue}
onChange={(e) => setBranchValue(e.target.value)}
placeholder="cw/my-feature"
className="h-6 w-40 text-xs font-mono"
autoFocus
onKeyDown={(e) => {
if (e.key === "Enter") saveBranch();
if (e.key === "Escape") setEditingBranch(false);
}}
/>
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={saveBranch}>
<Check className="h-3 w-3" />
</Button>
<Button variant="ghost" size="icon" className="h-5 w-5" onClick={() => setEditingBranch(false)}>
<X className="h-3 w-3" />
</Button>
</div>
)}
{!editingProjects && 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={startEditingProjects}
>
<Pencil className="h-3 w-3" />
</Button>
</>
)}
{!editingProjects && (!projects || projects.length === 0) && (
<Button
variant="ghost"
size="sm"
className="text-xs text-muted-foreground"
onClick={startEditingProjects}
>
+ Add projects
</Button>
)}
</div>
</div>
{editingProjects && (
<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 || projectMutation.isPending}
>
<Check className="mr-1 h-3 w-3" />
{projectMutation.isPending ? "Saving..." : "Save"}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setEditingProjects(false)}
>
Cancel
</Button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,100 @@
import { AlertCircle, Plus } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { Skeleton } from "@/components/Skeleton";
import { InitiativeCard } from "@/components/InitiativeCard";
import { trpc } from "@/lib/trpc";
interface InitiativeListProps {
statusFilter?: "all" | "active" | "completed" | "archived";
onCreateNew: () => void;
onViewInitiative: (id: string) => void;
onSpawnArchitect: (
initiativeId: string,
mode: "discuss" | "plan",
) => void;
}
export function InitiativeList({
statusFilter = "all",
onCreateNew,
onViewInitiative,
onSpawnArchitect,
}: InitiativeListProps) {
const initiativesQuery = trpc.listInitiatives.useQuery(
statusFilter === "all" ? undefined : { status: statusFilter },
);
// Loading state
if (initiativesQuery.isLoading) {
return (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => (
<Card key={i} className="p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<Skeleton className="h-5 w-48" />
<div className="flex flex-1 items-center gap-4">
<Skeleton className="h-5 w-16" />
<Skeleton className="h-2 w-32" />
<Skeleton className="h-4 w-24" />
</div>
</div>
</Card>
))}
</div>
);
}
// Error state
if (initiativesQuery.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 initiatives: {initiativesQuery.error.message}
</p>
<Button
variant="outline"
size="sm"
onClick={() => initiativesQuery.refetch()}
>
Retry
</Button>
</div>
);
}
const initiatives = initiativesQuery.data ?? [];
// Empty state
if (initiatives.length === 0) {
return (
<div className="flex flex-col items-center justify-center gap-4 py-16">
<p className="text-lg font-medium text-muted-foreground">
No initiatives yet
</p>
<p className="text-sm text-muted-foreground">
Create your first initiative to start planning and executing work.
</p>
<Button onClick={onCreateNew}>
<Plus className="mr-1 h-4 w-4" />
New Initiative
</Button>
</div>
);
}
// Populated state
return (
<div className="space-y-3">
{initiatives.map((initiative) => (
<InitiativeCard
key={initiative.id}
initiative={initiative}
onView={() => onViewInitiative(initiative.id)}
onSpawnArchitect={(mode) => onSpawnArchitect(initiative.id, mode)}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,68 @@
import { Card } from "@/components/ui/card";
import { cn, formatRelativeTime } from "@/lib/utils";
interface MessageCardProps {
agentName: string;
agentStatus: string;
preview: string;
timestamp: string;
requiresResponse: boolean;
isSelected: boolean;
onClick: () => void;
}
function formatStatusLabel(status: string): string {
return status.replace(/_/g, " ");
}
function truncatePreview(text: string, maxLength = 80): string {
if (text.length <= maxLength) return text;
return text.slice(0, maxLength) + "...";
}
export function MessageCard({
agentName,
agentStatus,
preview,
timestamp,
requiresResponse,
isSelected,
onClick,
}: MessageCardProps) {
return (
<Card
className={cn(
"cursor-pointer p-4 transition-colors hover:bg-accent/50",
isSelected && "bg-accent",
)}
onClick={onClick}
>
<div className="flex items-start justify-between gap-4">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span
className={cn(
"text-sm",
requiresResponse ? "text-orange-500" : "text-muted-foreground",
)}
>
{requiresResponse ? "\u25CF" : "\u25CB"}
</span>
<span className="text-sm font-bold">
{agentName}{" "}
<span className="font-normal text-muted-foreground">
({formatStatusLabel(agentStatus)})
</span>
</span>
</div>
<p className="mt-1 pl-5 text-sm text-muted-foreground">
&ldquo;{truncatePreview(preview)}&rdquo;
</p>
</div>
<span className="shrink-0 text-xs text-muted-foreground">
{formatRelativeTime(timestamp)}
</span>
</div>
</Card>
);
}

View File

@@ -0,0 +1,155 @@
import { useState } from "react";
import { Input } from "@/components/ui/input";
interface OptionGroupProps {
questionId: string;
options: Array<{ label: string; description?: string }>;
multiSelect: boolean;
value: string;
onChange: (value: string) => void;
allowOther?: boolean;
}
export function OptionGroup({
questionId,
options,
multiSelect,
value,
onChange,
allowOther = true,
}: OptionGroupProps) {
const [otherText, setOtherText] = useState("");
const selectedLabels = value ? value.split(",").map((v) => v.trim()) : [];
function isSelected(label: string): boolean {
return selectedLabels.includes(label);
}
function isOtherSelected(): boolean {
// "Other" is selected if value contains something not in the options list
const optionLabels = options.map((o) => o.label);
return selectedLabels.some((s) => !optionLabels.includes(s) && s !== "");
}
function handleOptionChange(label: string, checked: boolean) {
if (multiSelect) {
let updated: string[];
if (checked) {
updated = [...selectedLabels.filter((s) => s !== ""), label];
} else {
updated = selectedLabels.filter((s) => s !== label);
}
// Preserve "Other" text if selected
if (isOtherSelected() && otherText) {
updated = updated.filter(
(s) => options.some((o) => o.label === s) || s === otherText
);
}
onChange(updated.join(","));
} else {
// Radio: single selection
onChange(label);
setOtherText("");
}
}
function handleOtherToggle(checked: boolean) {
if (multiSelect) {
if (checked && otherText) {
const existing = selectedLabels.filter(
(s) => options.some((o) => o.label === s)
);
onChange([...existing, otherText].join(","));
} else if (!checked) {
const existing = selectedLabels.filter((s) =>
options.some((o) => o.label === s)
);
onChange(existing.join(","));
setOtherText("");
}
} else {
if (checked && otherText) {
onChange(otherText);
} else {
onChange("");
setOtherText("");
}
}
}
function handleOtherTextChange(text: string) {
setOtherText(text);
if (text) {
if (multiSelect) {
const existing = selectedLabels.filter((s) =>
options.some((o) => o.label === s)
);
onChange([...existing, text].join(","));
} else {
onChange(text);
}
} else {
if (multiSelect) {
const existing = selectedLabels.filter((s) =>
options.some((o) => o.label === s)
);
onChange(existing.join(","));
} else {
onChange("");
}
}
}
const inputType = multiSelect ? "checkbox" : "radio";
return (
<div className="space-y-2">
{options.map((option) => (
<label
key={option.label}
className="flex items-start gap-3 cursor-pointer rounded-md border border-transparent px-3 py-2 hover:bg-muted/50"
>
<input
type={inputType}
name={questionId}
checked={isSelected(option.label)}
onChange={(e) =>
handleOptionChange(option.label, e.target.checked)
}
className="mt-0.5 h-4 w-4 accent-primary"
/>
<div className="flex flex-col">
<span className="text-sm font-medium">{option.label}</span>
{option.description && (
<span className="text-xs text-muted-foreground">
{option.description}
</span>
)}
</div>
</label>
))}
{allowOther && (
<label className="flex items-start gap-3 cursor-pointer rounded-md border border-transparent px-3 py-2 hover:bg-muted/50">
<input
type={inputType}
name={questionId}
checked={isOtherSelected()}
onChange={(e) => handleOtherToggle(e.target.checked)}
className="mt-0.5 h-4 w-4 accent-primary"
/>
<div className="flex items-center gap-2">
<span className="text-sm font-medium">Other:</span>
<Input
value={otherText}
onChange={(e) => handleOtherTextChange(e.target.value)}
placeholder="Type your answer..."
className="h-8 w-48 text-sm"
/>
</div>
</label>
)}
</div>
);
}

View File

@@ -0,0 +1,103 @@
import { useState } from "react";
import { ChevronDown, ChevronRight } from "lucide-react";
import { StatusBadge } from "@/components/StatusBadge";
import { DependencyIndicator } from "@/components/DependencyIndicator";
import { TaskRow, type SerializedTask } from "@/components/TaskRow";
import { PhaseContentEditor } from "@/components/editor/PhaseContentEditor";
/** Phase shape as returned by tRPC (Date fields serialized to string over JSON) */
interface SerializedPhase {
id: string;
initiativeId: string;
name: string;
content: string | null;
status: string;
createdAt: string;
updatedAt: string;
}
/** Task entry with associated metadata, pre-assembled by the parent page */
interface TaskEntry {
task: SerializedTask;
agentName: string | null;
blockedBy: Array<{ name: string; status: string }>;
}
interface PhaseAccordionProps {
phase: SerializedPhase;
tasks: TaskEntry[];
defaultExpanded: boolean;
phaseDependencies: Array<{ name: string; status: string }>;
onTaskClick: (taskId: string) => void;
}
export function PhaseAccordion({
phase,
tasks,
defaultExpanded,
phaseDependencies,
onTaskClick,
}: PhaseAccordionProps) {
const [expanded, setExpanded] = useState(defaultExpanded);
const completedCount = tasks.filter(
(t) => t.task.status === "completed",
).length;
const totalCount = tasks.length;
return (
<div className="border-b border-border">
{/* Phase header — clickable to toggle */}
<button
className="flex w-full items-center gap-3 px-4 py-3 text-left hover:bg-accent/50"
onClick={() => setExpanded((prev) => !prev)}
>
{/* Expand / collapse chevron */}
{expanded ? (
<ChevronDown className="h-4 w-4 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
)}
{/* Phase name */}
<span className="min-w-0 flex-1 truncate font-medium">
{phase.name}
</span>
{/* Task count */}
<span className="shrink-0 text-sm text-muted-foreground">
({completedCount}/{totalCount})
</span>
{/* Phase status */}
<StatusBadge status={phase.status} className="shrink-0" />
</button>
{/* Phase-level dependency indicator (when blocked by another phase) */}
{phaseDependencies.length > 0 && (
<DependencyIndicator
blockedBy={phaseDependencies}
type="phase"
className="pb-2 pl-11"
/>
)}
{/* Expanded content editor + task list */}
{expanded && (
<div className="pb-3 pl-10 pr-4">
<PhaseContentEditor phaseId={phase.id} initiativeId={phase.initiativeId} />
{tasks.map((entry, idx) => (
<TaskRow
key={entry.task.id}
task={entry.task}
agentName={entry.agentName}
blockedBy={entry.blockedBy}
isLast={idx === tasks.length - 1}
onClick={() => onTaskClick(entry.task.id)}
/>
))}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,34 @@
import { cn } from "@/lib/utils";
interface ProgressBarProps {
completed: number;
total: number;
className?: string;
}
export function ProgressBar({ completed, total, className }: ProgressBarProps) {
const percentage = total > 0 ? Math.round((completed / total) * 100) : 0;
const fillColor =
percentage === 0
? ""
: percentage === 100
? "bg-green-500"
: "bg-primary";
return (
<div className={cn("flex items-center gap-2", className)}>
<div className="h-2 flex-1 rounded-full bg-muted">
{percentage > 0 && (
<div
className={cn("h-2 rounded-full transition-all", fillColor)}
style={{ width: `${percentage}%` }}
/>
)}
</div>
<span className="text-xs font-medium text-muted-foreground">
{percentage}%
</span>
</div>
);
}

View File

@@ -0,0 +1,35 @@
import { Card, CardHeader, CardContent } from "@/components/ui/card";
import { ProgressBar } from "@/components/ProgressBar";
export interface ProgressPanelProps {
phasesComplete: number;
phasesTotal: number;
tasksComplete: number;
tasksTotal: number;
}
export function ProgressPanel({
phasesComplete,
phasesTotal,
tasksComplete,
tasksTotal,
}: ProgressPanelProps) {
return (
<Card>
<CardHeader>
<h2 className="text-lg font-semibold">Progress</h2>
</CardHeader>
<CardContent className="flex flex-col gap-3">
<ProgressBar completed={tasksComplete} total={tasksTotal} />
<div className="flex flex-col gap-1 text-sm text-muted-foreground">
<span>
Phases: {phasesComplete}/{phasesTotal} complete
</span>
<span>
Tasks: {tasksComplete}/{tasksTotal} complete
</span>
</div>
</CardContent>
</Card>
);
}

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,104 @@
import { useState } from "react";
import { Button } from "@/components/ui/button";
import { OptionGroup } from "@/components/OptionGroup";
import { FreeTextInput } from "@/components/FreeTextInput";
interface QuestionFormQuestion {
id: string;
question: string;
options?: Array<{ label: string; description?: string }>;
multiSelect?: boolean;
}
interface QuestionFormProps {
questions: QuestionFormQuestion[];
onSubmit: (answers: Record<string, string>) => void;
onCancel: () => void;
onDismiss?: () => void;
isSubmitting?: boolean;
isDismissing?: boolean;
}
export function QuestionForm({
questions,
onSubmit,
onCancel,
onDismiss,
isSubmitting = false,
isDismissing = false,
}: QuestionFormProps) {
const [answers, setAnswers] = useState<Record<string, string>>(() => {
const initial: Record<string, string> = {};
for (const q of questions) {
initial[q.id] = "";
}
return initial;
});
function handleAnswerChange(questionId: string, value: string) {
setAnswers((prev) => ({ ...prev, [questionId]: value }));
}
const allAnswered = questions.every(
(q) => answers[q.id] !== undefined && answers[q.id].trim() !== ""
);
function handleSubmit() {
if (allAnswered) {
onSubmit(answers);
}
}
return (
<div className="space-y-6">
{questions.map((q, index) => (
<div key={q.id} className="space-y-2">
<p className="text-sm font-medium">
Q{index + 1}: {q.question}
</p>
{q.options && q.options.length > 0 ? (
<OptionGroup
questionId={q.id}
options={q.options}
multiSelect={q.multiSelect ?? false}
value={answers[q.id] ?? ""}
onChange={(value) => handleAnswerChange(q.id, value)}
/>
) : (
<FreeTextInput
questionId={q.id}
value={answers[q.id] ?? ""}
onChange={(value) => handleAnswerChange(q.id, value)}
/>
)}
</div>
))}
<div className="flex justify-end gap-2 pt-2">
<Button
variant="outline"
onClick={onCancel}
disabled={isSubmitting || isDismissing}
>
Cancel
</Button>
{onDismiss && (
<Button
variant="destructive"
onClick={onDismiss}
disabled={isSubmitting || isDismissing}
>
{isDismissing ? "Dismissing..." : "Dismiss"}
</Button>
)}
<Button
onClick={handleSubmit}
disabled={!allAnswered || isSubmitting || isDismissing}
>
{isSubmitting ? "Sending..." : "Send Answers"}
</Button>
</div>
</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,119 @@
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 [defaultBranch, setDefaultBranch] = useState("main");
const [error, setError] = useState<string | null>(null);
const registerMutation = trpc.registerProject.useMutation({
onSuccess: () => {
onOpenChange(false);
toast.success("Project registered");
},
onError: (err) => {
setError(err.message);
},
});
useEffect(() => {
if (open) {
setName("");
setUrl("");
setDefaultBranch("main");
setError(null);
}
}, [open]);
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
setError(null);
registerMutation.mutate({
name: name.trim(),
url: url.trim(),
defaultBranch: defaultBranch.trim() || undefined,
});
}
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>
<div className="space-y-2">
<Label htmlFor="default-branch">Default Branch</Label>
<Input
id="default-branch"
placeholder="main"
value={defaultBranch}
onChange={(e) => setDefaultBranch(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

@@ -0,0 +1,11 @@
import { cn } from "@/lib/utils";
interface SkeletonProps {
className?: string;
}
export function Skeleton({ className }: SkeletonProps) {
return (
<div className={cn("animate-pulse rounded-md bg-muted", className)} />
);
}

View File

@@ -0,0 +1,66 @@
import { useState } from "react";
import { ChevronDown } from "lucide-react";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { trpc } from "@/lib/trpc";
import { useSpawnMutation } from "@/hooks/useSpawnMutation";
interface SpawnArchitectDropdownProps {
initiativeId: string;
initiativeName?: string;
}
export function SpawnArchitectDropdown({
initiativeId,
}: SpawnArchitectDropdownProps) {
const [open, setOpen] = useState(false);
const [successText, setSuccessText] = useState<string | null>(null);
const handleSuccess = () => {
setOpen(false);
setSuccessText("Spawned!");
setTimeout(() => setSuccessText(null), 2000);
};
const discussSpawn = useSpawnMutation(trpc.spawnArchitectDiscuss.useMutation, {
onSuccess: handleSuccess,
});
const planSpawn = useSpawnMutation(trpc.spawnArchitectPlan.useMutation, {
onSuccess: handleSuccess,
});
const isPending = discussSpawn.isSpawning || planSpawn.isSpawning;
function handleDiscuss() {
discussSpawn.spawn({ initiativeId });
}
function handlePlan() {
planSpawn.spawn({ initiativeId });
}
return (
<DropdownMenu open={open} onOpenChange={setOpen}>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" disabled={isPending}>
{successText ?? "Spawn Architect"}
<ChevronDown className="ml-1 h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuItem onClick={handleDiscuss} disabled={isPending}>
Discuss
</DropdownMenuItem>
<DropdownMenuItem onClick={handlePlan} disabled={isPending}>
Plan
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
);
}

View File

@@ -0,0 +1,39 @@
import { Badge } from "@/components/ui/badge";
import { cn } from "@/lib/utils";
const statusStyles: Record<string, string> = {
// Initiative statuses
active: "bg-blue-100 text-blue-800 hover:bg-blue-100/80 border-blue-200",
completed: "bg-green-100 text-green-800 hover:bg-green-100/80 border-green-200",
archived: "bg-gray-100 text-gray-800 hover:bg-gray-100/80 border-gray-200",
// Phase statuses
pending: "bg-gray-100 text-gray-800 hover:bg-gray-100/80 border-gray-200",
approved: "bg-amber-100 text-amber-800 hover:bg-amber-100/80 border-amber-200",
in_progress: "bg-blue-100 text-blue-800 hover:bg-blue-100/80 border-blue-200",
blocked: "bg-red-100 text-red-800 hover:bg-red-100/80 border-red-200",
pending_review: "bg-amber-100 text-amber-800 hover:bg-amber-100/80 border-amber-200",
pending_approval: "bg-amber-100 text-amber-800 hover:bg-amber-100/80 border-amber-200",
};
const defaultStyle = "bg-gray-100 text-gray-800 hover:bg-gray-100/80 border-gray-200";
function formatStatusText(status: string): string {
return status.replace(/_/g, " ").toUpperCase();
}
interface StatusBadgeProps {
status: string;
className?: string;
}
export function StatusBadge({ status, className }: StatusBadgeProps) {
if (!status) return null;
const style = statusStyles[status] ?? defaultStyle;
return (
<Badge className={cn(style, className)}>
{formatStatusText(status)}
</Badge>
);
}

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

@@ -0,0 +1,158 @@
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { StatusBadge } from "@/components/StatusBadge";
import { StatusDot } from "@/components/StatusDot";
import type { SerializedTask } from "@/components/TaskRow";
interface DependencyInfo {
name: string;
status: string;
}
interface TaskDetailModalProps {
task: SerializedTask | null;
phaseName: string;
agentName: string | null;
dependencies: DependencyInfo[];
dependents: DependencyInfo[];
onClose: () => void;
onQueueTask: (taskId: string) => void;
onStopTask: (taskId: string) => void;
}
export function TaskDetailModal({
task,
phaseName,
agentName,
dependencies,
dependents,
onClose,
onQueueTask,
onStopTask,
}: TaskDetailModalProps) {
const allDependenciesComplete =
dependencies.length === 0 ||
dependencies.every((d) => d.status === "completed");
const canQueue = task !== null && task.status === "pending" && allDependenciesComplete;
const canStop = task !== null && task.status === "in_progress";
return (
<Dialog open={task !== null} onOpenChange={(open) => { if (!open) onClose(); }}>
<DialogContent>
<DialogHeader>
<DialogTitle>{task?.name ?? "Task"}</DialogTitle>
<DialogDescription>Task details and dependencies</DialogDescription>
</DialogHeader>
{task && (
<div className="space-y-4">
{/* Metadata grid */}
<div className="grid grid-cols-2 gap-3 text-sm">
<div>
<span className="text-muted-foreground">Status</span>
<div className="mt-1">
<StatusBadge status={task.status} />
</div>
</div>
<div>
<span className="text-muted-foreground">Priority</span>
<p className="mt-1 font-medium capitalize">{task.priority}</p>
</div>
<div>
<span className="text-muted-foreground">Phase</span>
<p className="mt-1 font-medium">{phaseName}</p>
</div>
<div>
<span className="text-muted-foreground">Type</span>
<p className="mt-1 font-medium">{task.type}</p>
</div>
<div className="col-span-2">
<span className="text-muted-foreground">Agent</span>
<p className="mt-1 font-medium">
{agentName ?? "Unassigned"}
</p>
</div>
</div>
{/* Description */}
<div>
<h4 className="mb-1 text-sm font-medium">Description</h4>
<p className="text-sm text-muted-foreground">
{task.description ?? "No description"}
</p>
</div>
{/* Dependencies */}
<div>
<h4 className="mb-1 text-sm font-medium">Dependencies</h4>
{dependencies.length === 0 ? (
<p className="text-sm text-muted-foreground">
No dependencies
</p>
) : (
<ul className="space-y-1">
{dependencies.map((dep) => (
<li
key={dep.name}
className="flex items-center gap-2 text-sm"
>
<span>{dep.name}</span>
<StatusDot status={dep.status} size="md" />
</li>
))}
</ul>
)}
</div>
{/* Dependents (Blocks) */}
<div>
<h4 className="mb-1 text-sm font-medium">Blocks</h4>
{dependents.length === 0 ? (
<p className="text-sm text-muted-foreground">None</p>
) : (
<ul className="space-y-1">
{dependents.map((dep) => (
<li
key={dep.name}
className="flex items-center gap-2 text-sm"
>
<span>{dep.name}</span>
<StatusDot status={dep.status} size="md" />
</li>
))}
</ul>
)}
</div>
</div>
)}
<DialogFooter>
<Button
variant="outline"
size="sm"
disabled={!canQueue}
onClick={() => task && onQueueTask(task.id)}
>
Queue Task
</Button>
<Button
variant="destructive"
size="sm"
disabled={!canStop}
onClick={() => task && onStopTask(task.id)}
>
Stop Task
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,103 @@
import { Link } from "@tanstack/react-router";
import { X } from "lucide-react";
import { StatusBadge } from "@/components/StatusBadge";
import { DependencyIndicator } from "@/components/DependencyIndicator";
import { cn } from "@/lib/utils";
/** Task shape as returned by tRPC (Date fields serialized to string over JSON) */
export interface SerializedTask {
id: string;
phaseId: string | null;
initiativeId: string | null;
parentTaskId: string | null;
name: string;
description: string | null;
type: "auto" | "checkpoint:human-verify" | "checkpoint:decision" | "checkpoint:human-action";
category: string;
priority: "low" | "medium" | "high";
status: "pending_approval" | "pending" | "in_progress" | "completed" | "blocked";
requiresApproval: boolean | null;
order: number;
createdAt: string;
updatedAt: string;
}
interface TaskRowProps {
task: SerializedTask;
agentName: string | null;
blockedBy: Array<{ name: string; status: string }>;
isLast: boolean;
onClick: () => void;
onDelete?: () => void;
}
export function TaskRow({
task,
agentName,
blockedBy,
isLast,
onClick,
onDelete,
}: TaskRowProps) {
const connector = isLast ? "└──" : "├──";
return (
<div>
{/* Task row */}
<div
className={cn(
"group flex cursor-pointer items-center gap-2 rounded px-2 py-1 hover:bg-accent",
!isLast && "border-l-2 border-muted-foreground/20",
)}
onClick={onClick}
>
{/* Tree connector */}
<span className="shrink-0 font-mono text-sm text-muted-foreground">
{connector}
</span>
{/* Task name */}
<span className="min-w-0 flex-1 truncate text-sm">{task.name}</span>
{/* Agent assignment */}
{agentName && (
<Link
to="/inbox"
className="shrink-0 text-xs text-primary hover:underline"
onClick={(e) => e.stopPropagation()}
>
[{agentName}]
</Link>
)}
{/* Status badge */}
<StatusBadge status={task.status} className="shrink-0" />
{/* Delete button */}
{onDelete && (
<button
className="shrink-0 rounded p-0.5 text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100"
title="Delete task (Shift+click to skip confirmation)"
onClick={(e) => {
e.stopPropagation();
if (e.shiftKey || window.confirm(`Delete "${task.name}"?`)) {
onDelete();
}
}}
>
<X className="h-3.5 w-3.5" />
</button>
)}
</div>
{/* Dependency indicator below the row */}
{blockedBy.length > 0 && (
<DependencyIndicator
blockedBy={blockedBy}
type="task"
className="ml-6"
/>
)}
</div>
);
}

View File

@@ -0,0 +1,238 @@
import { useState, useRef, useCallback } from "react";
import type { Editor } from "@tiptap/react";
import { GripVertical, Plus } from "lucide-react";
import { NodeSelection, TextSelection } from "@tiptap/pm/state";
import { Fragment, Slice, type Node as PmNode } from "@tiptap/pm/model";
import {
blockSelectionKey,
getBlockRange,
} from "./BlockSelectionExtension";
interface BlockDragHandleProps {
editor: Editor | null;
children: React.ReactNode;
}
export function BlockDragHandle({ editor, children }: BlockDragHandleProps) {
const blockIndexRef = useRef<number | null>(null);
const savedBlockSelRef = useRef<{
anchorIndex: number;
headIndex: number;
} | null>(null);
const blockElRef = useRef<HTMLElement | null>(null);
const [handlePos, setHandlePos] = useState<{
top: number;
height: number;
} | null>(null);
// Track which block the mouse is over
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 = (e.currentTarget as HTMLElement).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
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>
)}
{children}
</div>
);
}

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,310 @@
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 { DeleteSubpageDialog } from "./DeleteSubpageDialog";
import { Skeleton } from "@/components/Skeleton";
import { Button } from "@/components/ui/button";
interface ContentTabProps {
initiativeId: string;
initiativeName: string;
}
interface DeleteConfirmation {
pageId: string;
redo: () => void;
}
export function ContentTab({ initiativeId, initiativeName }: ContentTabProps) {
const utils = trpc.useUtils();
const { save, flush, isSaving } = useAutoSave();
// Get or create root page
const rootPageQuery = trpc.getRootPage.useQuery({ initiativeId });
const allPagesQuery = trpc.listPages.useQuery({ initiativeId });
const createPageMutation = trpc.createPage.useMutation();
const deletePageMutation = trpc.deletePage.useMutation();
const updateInitiativeMutation = trpc.updateInitiative.useMutation();
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}
entityId={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 */}
<DeleteSubpageDialog
open={deleteConfirm !== null}
pageName={
allPages.find((p) => p.id === deleteConfirm?.pageId)?.title ??
"Untitled"
}
onConfirm={confirmDeleteSubpage}
onCancel={dismissDeleteConfirm}
/>
</PageTitleProvider>
</>
);
}

View File

@@ -0,0 +1,50 @@
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
interface DeleteSubpageDialogProps {
open: boolean;
pageName: string;
onConfirm: () => void;
onCancel: () => void;
}
export function DeleteSubpageDialog({
open,
pageName,
onConfirm,
onCancel,
}: DeleteSubpageDialogProps) {
return (
<Dialog
open={open}
onOpenChange={(isOpen) => {
if (!isOpen) onCancel();
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>Delete subpage?</DialogTitle>
<DialogDescription>
You removed the link to &ldquo;{pageName}&rdquo;. Do you also want
to delete the subpage and all its content?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={onCancel}>
Keep subpage
</Button>
<Button variant="destructive" onClick={onConfirm}>
Delete subpage
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

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,66 @@
import { Extension } from "@tiptap/react";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import type { MutableRefObject } from "react";
export function createPageLinkDeletionDetector(
onPageLinkDeletedRef: MutableRefObject<
((pageId: string, redo: () => void) => void) | undefined
>,
) {
return 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;
},
}),
];
},
});
}

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,47 @@
import { useCallback } from "react";
import { trpc } from "@/lib/trpc";
import { usePhaseAutoSave } from "@/hooks/usePhaseAutoSave";
import { TiptapEditor } from "./TiptapEditor";
import { Skeleton } from "@/components/Skeleton";
interface PhaseContentEditorProps {
phaseId: string;
initiativeId?: string;
}
export function PhaseContentEditor({ phaseId }: PhaseContentEditorProps) {
const { save, isSaving } = usePhaseAutoSave();
const phaseQuery = trpc.getPhase.useQuery({ id: phaseId });
const handleUpdate = useCallback(
(json: string) => {
save(phaseId, { content: json });
},
[phaseId, save],
);
if (phaseQuery.isLoading) {
return <Skeleton className="h-32 w-full" />;
}
if (phaseQuery.isError) {
return null;
}
return (
<div className="mb-3">
{isSaving && (
<div className="flex justify-end mb-1">
<span className="text-xs text-muted-foreground">Saving...</span>
</div>
)}
<TiptapEditor
entityId={phaseId}
content={phaseQuery.data?.content ?? null}
onUpdate={handleUpdate}
enablePageLinks={false}
/>
</div>
);
}

View File

@@ -0,0 +1,155 @@
import { useCallback, useEffect } from "react";
import { Loader2, AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button";
import { QuestionForm } from "@/components/QuestionForm";
import { ChangeSetBanner } from "@/components/ChangeSetBanner";
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, changeSet, spawn, resume, stop, dismiss, 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(() => {
dismiss();
}, [dismiss]);
// Cmd+Enter (Mac) / Ctrl+Enter (Windows) dismisses when completed
useEffect(() => {
if (state !== "completed") return;
const handler = (e: KeyboardEvent) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleDismiss();
}
};
window.addEventListener("keydown", handler);
return () => window.removeEventListener("keydown", handler);
}, [state, handleDismiss]);
// 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
}}
onDismiss={() => stop.mutate()}
isSubmitting={resume.isPending}
isDismissing={stop.isPending}
/>
</div>
);
}
// Completed with change set
if (state === "completed" && changeSet) {
return (
<div className="mb-3">
<ChangeSetBanner
changeSet={changeSet}
onDismiss={handleDismiss}
/>
</div>
);
}
// Completed without changes
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 made.
</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,126 @@
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,
hideSubpage: false,
};
},
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, editor }: { query: string; editor: ReturnType<typeof import("@tiptap/react").useEditor> }): SlashCommandItem[] => {
let items = slashCommandItems.filter((item) =>
item.label.toLowerCase().includes(query.toLowerCase()),
);
if (editor.storage.slashCommands?.hideSubpage) {
items = items.filter((item) => !item.isSubpage);
}
return items;
},
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,122 @@
import { useEffect, useRef, useCallback } from "react";
import { useEditor, EditorContent } from "@tiptap/react";
import type { Editor } from "@tiptap/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 { SlashCommands } from "./SlashCommands";
import { PageLinkExtension } from "./PageLinkExtension";
import { BlockSelectionExtension } from "./BlockSelectionExtension";
import { createPageLinkDeletionDetector } from "./PageLinkDeletionDetector";
import { BlockDragHandle } from "./BlockDragHandle";
interface TiptapEditorProps {
content: string | null;
onUpdate: (json: string) => void;
entityId: string;
enablePageLinks?: boolean;
onPageLinkClick?: (pageId: string) => void;
onSubpageCreate?: (
editor: Editor,
) => void;
onPageLinkDeleted?: (pageId: string, redo: () => void) => void;
}
export function TiptapEditor({
content,
onUpdate,
entityId,
enablePageLinks = true,
onPageLinkClick,
onSubpageCreate,
onPageLinkDeleted,
}: TiptapEditorProps) {
const containerRef = useRef<HTMLDivElement>(null);
const onPageLinkDeletedRef = useRef(onPageLinkDeleted);
onPageLinkDeletedRef.current = onPageLinkDeleted;
const pageLinkDeletionDetector = createPageLinkDeletionDetector(onPageLinkDeletedRef);
const baseExtensions = [
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,
BlockSelectionExtension,
];
const extensions = enablePageLinks
? [...baseExtensions, PageLinkExtension, pageLinkDeletionDetector]
: baseExtensions;
const editor = useEditor(
{
extensions,
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",
},
},
},
[entityId],
);
// Wire the onSubpageCreate callback into editor storage
useEffect(() => {
if (editor) {
if (onSubpageCreate) {
editor.storage.slashCommands.onSubpageCreate = (ed: Editor) => {
onSubpageCreate(ed);
};
}
editor.storage.slashCommands.hideSubpage = !enablePageLinks;
}
}, [editor, onSubpageCreate, enablePageLinks]);
// 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]);
return (
<div ref={containerRef}>
<BlockDragHandle editor={editor}>
<EditorContent editor={editor} />
</BlockDragHandle>
</div>
);
}

View File

@@ -0,0 +1,92 @@
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: "Inline Code",
icon: "`c`",
description: "Inline code (Cmd+E)",
action: (editor) => editor.chain().focus().toggleCode().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,149 @@
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;
name: string;
content: 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,87 @@
import { useCallback, useMemo, useState } from "react";
import { Loader2, Plus, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { trpc } from "@/lib/trpc";
interface PhaseActionsProps {
initiativeId: string;
phases: Array<{ id: string; status: string }>;
onAddPhase: () => void;
phasesWithoutTasks: string[];
detailAgentByPhase: Map<string, { id: string; status: string }>;
}
export function PhaseActions({
onAddPhase,
phasesWithoutTasks,
detailAgentByPhase,
}: PhaseActionsProps) {
const detailMutation = trpc.spawnArchitectDetail.useMutation();
const [isDetailingAll, setIsDetailingAll] = useState(false);
// Phases eligible for detailing: no tasks AND no active detail agent
const eligiblePhaseIds = useMemo(
() => phasesWithoutTasks.filter((id) => !detailAgentByPhase.has(id)),
[phasesWithoutTasks, detailAgentByPhase],
);
// Count of phases currently being detailed
const activeDetailCount = useMemo(() => {
let count = 0;
for (const [, agent] of detailAgentByPhase) {
if (agent.status === "running" || agent.status === "waiting_for_input") {
count++;
}
}
return count;
}, [detailAgentByPhase]);
const handleDetailAll = useCallback(async () => {
setIsDetailingAll(true);
try {
for (const phaseId of eligiblePhaseIds) {
try {
await detailMutation.mutateAsync({ phaseId });
} catch {
// CONFLICT errors expected if agent already exists — continue
}
}
} finally {
setIsDetailingAll(false);
}
}, [eligiblePhaseIds, detailMutation]);
return (
<div className="flex items-center gap-2">
{activeDetailCount > 0 && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
Detailing ({activeDetailCount})
</div>
)}
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onAddPhase}
title="Add phase"
>
<Plus className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
disabled={eligiblePhaseIds.length === 0 || isDetailingAll}
onClick={handleDetailAll}
className="gap-1.5"
>
{isDetailingAll ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : (
<Sparkles className="h-3.5 w-3.5" />
)}
Detail All
</Button>
</div>
);
}

View File

@@ -0,0 +1,401 @@
import { useEffect, useState, useRef, useMemo, useCallback } from "react";
import { GitBranch, Loader2, MoreHorizontal, Plus, Sparkles, Trash2, X } from "lucide-react";
import { toast } from "sonner";
import { trpc } from "@/lib/trpc";
import { StatusBadge } from "@/components/StatusBadge";
import { TaskRow, type SerializedTask } from "@/components/TaskRow";
import { PhaseContentEditor } from "@/components/editor/PhaseContentEditor";
import { ChangeSetBanner } from "@/components/ChangeSetBanner";
import { Skeleton } from "@/components/Skeleton";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { sortByPriorityAndQueueTime } from "@codewalk-district/shared";
import { useExecutionContext, type FlatTaskEntry } from "./ExecutionContext";
interface PhaseDetailPanelProps {
phase: {
id: string;
initiativeId: string;
name: string;
content: string | null;
status: string;
};
phases: Array<{
id: string;
name: string;
status: string;
}>;
displayIndex: number;
allDisplayIndices: Map<string, number>;
initiativeId: string;
tasks: SerializedTask[];
tasksLoading: boolean;
onDelete?: () => void;
branch?: string | null;
detailAgent: {
id: string;
status: string;
createdAt: string | Date;
} | null;
}
export function PhaseDetailPanel({
phase,
phases,
displayIndex,
allDisplayIndices,
initiativeId,
tasks,
tasksLoading,
onDelete,
branch,
detailAgent,
}: PhaseDetailPanelProps) {
const { setSelectedTaskId, handleTaskCounts, handleRegisterTasks } =
useExecutionContext();
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [editName, setEditName] = useState(phase.name);
const inputRef = useRef<HTMLInputElement>(null);
const updatePhase = trpc.updatePhase.useMutation();
const utils = trpc.useUtils();
const deleteTask = trpc.deleteTask.useMutation({
onSuccess: () => {
utils.listInitiativeTasks.invalidate({ initiativeId });
toast.success("Task deleted");
},
onError: () => toast.error("Failed to delete task"),
});
function startEditing() {
setEditName(phase.name);
setIsEditingTitle(true);
setTimeout(() => inputRef.current?.select(), 0);
}
function saveTitle() {
const trimmed = editName.trim();
if (!trimmed || trimmed === phase.name) {
setEditName(phase.name);
setIsEditingTitle(false);
return;
}
updatePhase.mutate(
{ id: phase.id, name: trimmed },
{
onSuccess: () => {
setIsEditingTitle(false);
toast.success("Phase renamed");
},
onError: () => {
setEditName(phase.name);
setIsEditingTitle(false);
toast.error("Failed to rename phase");
},
},
);
}
function cancelEditing() {
setEditName(phase.name);
setIsEditingTitle(false);
}
const addDependency = trpc.createPhaseDependency.useMutation({
onSuccess: () => toast.success("Dependency added"),
onError: () => toast.error("Failed to add dependency"),
});
const removeDependency = trpc.removePhaseDependency.useMutation({
onSuccess: () => toast.success("Dependency removed"),
onError: () => toast.error("Failed to remove dependency"),
});
const depsQuery = trpc.getPhaseDependencies.useQuery({ phaseId: phase.id });
const dependencyIds = depsQuery.data?.dependencies ?? [];
// Resolve dependency IDs to phase objects
const resolvedDeps = dependencyIds
.map((depId) => phases.find((p) => p.id === depId))
.filter(Boolean) as Array<{ id: string; name: string; status: string }>;
// Phases available to add as dependencies (exclude self + already-added)
const availableDeps = useMemo(
() => phases.filter((p) => p.id !== phase.id && !dependencyIds.includes(p.id)),
[phases, phase.id, dependencyIds],
);
// Propagate task counts and entries to ExecutionContext
useEffect(() => {
const complete = tasks.filter((t) => t.status === "completed").length;
handleTaskCounts(phase.id, { complete, total: tasks.length });
const entries: FlatTaskEntry[] = tasks.map((task) => ({
task,
phaseName: `Phase ${displayIndex}: ${phase.name}`,
agentName: null,
blockedBy: [],
dependents: [],
}));
handleRegisterTasks(phase.id, entries);
}, [tasks, phase.id, displayIndex, phase.name, handleTaskCounts, handleRegisterTasks]);
// --- Change sets for detail agent ---
const changeSetsQuery = trpc.listChangeSets.useQuery(
{ agentId: detailAgent?.id ?? "" },
{ enabled: !!detailAgent && detailAgent.status === "idle" },
);
const latestChangeSet = useMemo(
() => (changeSetsQuery.data ?? []).find((cs) => cs.status === "applied") ?? null,
[changeSetsQuery.data],
);
// --- Detail spawn ---
const detailMutation = trpc.spawnArchitectDetail.useMutation();
const handleDetail = useCallback(() => {
detailMutation.mutate({ phaseId: phase.id });
}, [phase.id, detailMutation]);
// --- Dismiss handler for detail agent ---
const dismissMutation = trpc.dismissAgent.useMutation();
const handleDismissDetail = useCallback(() => {
if (!detailAgent) return;
dismissMutation.mutate({ id: detailAgent.id });
}, [detailAgent, dismissMutation]);
// Compute phase branch name if initiative has a merge target
const phaseBranch = branch
? `${branch}-phase-${phase.name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "")}`
: null;
const isPendingReview = phase.status === "pending_review";
const sortedTasks = sortByPriorityAndQueueTime(tasks);
const hasTasks = tasks.length > 0;
const isDetailRunning =
detailAgent?.status === "running" ||
detailAgent?.status === "waiting_for_input";
const showDetailButton =
!detailAgent && !hasTasks;
const showChangeSet =
detailAgent?.status === "idle" && !!latestChangeSet;
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
{isEditingTitle ? (
<div className="flex items-center gap-1">
<span className="text-lg font-semibold">Phase {displayIndex}:</span>
<input
ref={inputRef}
className="border-b border-border bg-transparent text-lg font-semibold outline-none focus:border-primary"
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") saveTitle();
if (e.key === "Escape") cancelEditing();
}}
onBlur={saveTitle}
/>
</div>
) : (
<h3
className="cursor-pointer text-lg font-semibold hover:text-primary"
onClick={startEditing}
title="Click to rename"
>
Phase {displayIndex}: {phase.name}
</h3>
)}
<StatusBadge status={phase.status} />
{phaseBranch && ["in_progress", "completed", "pending_review"].includes(phase.status) && (
<span className="inline-flex items-center gap-1 rounded-md border border-border px-2 py-0.5 text-[10px] font-mono text-muted-foreground">
<GitBranch className="h-3 w-3" />
{phaseBranch}
</span>
)}
{/* Detail button in header */}
{showDetailButton && (
<Button
variant="outline"
size="sm"
onClick={handleDetail}
disabled={detailMutation.isPending}
className="gap-1.5"
>
<Sparkles className="h-3.5 w-3.5" />
{detailMutation.isPending ? "Starting..." : "Detail Tasks"}
</Button>
)}
{/* Running indicator in header */}
{isDetailRunning && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
Detailing...
</div>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="ml-auto h-7 w-7">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-destructive"
onClick={() => {
if (window.confirm(`Delete "${phase.name}"? All tasks in this phase will also be deleted.`)) {
onDelete?.();
}
}}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete Phase
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Pending review banner */}
{isPendingReview && (
<div className="rounded-md border border-amber-200 bg-amber-50 px-4 py-3 dark:border-amber-800 dark:bg-amber-950">
<p className="text-sm font-medium text-amber-800 dark:text-amber-200">
This phase is pending review. Switch to the{" "}
<span className="font-semibold">Review</span> tab to view the diff and approve.
</p>
</div>
)}
{/* Tiptap Editor */}
<PhaseContentEditor phaseId={phase.id} initiativeId={initiativeId} />
{/* Dependencies */}
<div>
<div className="mb-2 flex items-center gap-2">
<h4 className="text-sm font-medium text-muted-foreground">
Dependencies
</h4>
{availableDeps.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-5 w-5">
<Plus className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{availableDeps.map((p) => (
<DropdownMenuItem
key={p.id}
onClick={() =>
addDependency.mutate({
phaseId: phase.id,
dependsOnPhaseId: p.id,
})
}
>
Phase {allDisplayIndices.get(p.id) ?? "?"}: {p.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{resolvedDeps.length === 0 ? (
<p className="text-xs text-muted-foreground">No dependencies</p>
) : (
<div className="space-y-1">
{resolvedDeps.map((dep) => (
<div
key={dep.id}
className="flex items-center gap-2 text-sm"
>
<span
className={
dep.status === "completed"
? "text-green-600"
: "text-muted-foreground"
}
>
{dep.status === "completed" ? "\u25CF" : "\u25CB"}
</span>
<span>
Phase {allDisplayIndices.get(dep.id) ?? "?"}: {dep.name}
</span>
<StatusBadge status={dep.status} className="text-[10px]" />
<button
className="ml-1 text-muted-foreground hover:text-destructive"
onClick={() =>
removeDependency.mutate({
phaseId: phase.id,
dependsOnPhaseId: dep.id,
})
}
title="Remove dependency"
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
</div>
{/* Detail change set */}
{showChangeSet && (
<ChangeSetBanner
changeSet={latestChangeSet!}
onDismiss={handleDismissDetail}
/>
)}
{/* Tasks */}
<div>
<h4 className="mb-2 text-sm font-medium text-muted-foreground">
Tasks ({tasks.filter((t) => t.status === "completed").length}/
{tasks.length})
</h4>
{tasksLoading ? (
<div className="space-y-1">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</div>
) : sortedTasks.length === 0 ? (
<p className="text-sm text-muted-foreground">No tasks yet</p>
) : (
<div>
{sortedTasks.map((task, idx) => (
<TaskRow
key={task.id}
task={task}
agentName={null}
blockedBy={[]}
isLast={idx === sortedTasks.length - 1}
onClick={() => setSelectedTaskId(task.id)}
onDelete={() => deleteTask.mutate({ id: task.id })}
/>
))}
</div>
)}
</div>
</div>
);
}
export function PhaseDetailEmpty() {
return (
<div className="flex h-full items-center justify-center text-muted-foreground">
<p>Select a phase to view details</p>
</div>
);
}

View File

@@ -0,0 +1,57 @@
import { StatusBadge } from "@/components/StatusBadge";
import { cn } from "@/lib/utils";
interface PhaseSidebarItemProps {
phase: {
id: string;
name: string;
status: string;
};
displayIndex: number;
taskCount: { complete: number; total: number };
dependencies: string[];
isSelected: boolean;
onClick: () => void;
}
export function PhaseSidebarItem({
phase,
displayIndex,
taskCount,
dependencies,
isSelected,
onClick,
}: PhaseSidebarItemProps) {
return (
<button
className={cn(
"flex w-full flex-col gap-0.5 rounded-md px-3 py-2 text-left transition-colors",
isSelected
? "border-l-2 border-primary bg-accent"
: "border-l-2 border-transparent hover:bg-accent/50",
)}
onClick={onClick}
>
<div className="flex items-center gap-2">
<span className="min-w-0 flex-1 truncate text-sm font-medium">
Phase {displayIndex}: {phase.name}
</span>
<StatusBadge status={phase.status} className="shrink-0 text-[10px]" />
</div>
<div className="flex items-center gap-2 text-xs text-muted-foreground">
<span>
{taskCount.total === 0
? "Needs decomposition"
: `${taskCount.complete}/${taskCount.total} tasks`}
</span>
</div>
{dependencies.length > 0 && (
<div className="text-xs text-muted-foreground">
depends on: {dependencies.join(", ")}
</div>
)}
</button>
);
}

View File

@@ -0,0 +1,110 @@
import { useEffect } from "react";
import { trpc } from "@/lib/trpc";
import { PhaseAccordion } from "@/components/PhaseAccordion";
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;
name: string;
content: 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 tasksQuery = trpc.listPhaseTasks.useQuery({ phaseId: phase.id });
const depsQuery = trpc.getPhaseDependencies.useQuery({ phaseId: phase.id });
const tasks = tasksQuery.data ?? [];
return (
<PhaseWithTasksInner
phase={phase}
tasks={tasks}
tasksLoaded={tasksQuery.isSuccess}
phaseDependencyIds={depsQuery.data?.dependencies ?? []}
defaultExpanded={defaultExpanded}
onTaskClick={onTaskClick}
onTaskCounts={onTaskCounts}
registerTasks={registerTasks}
/>
);
}
interface PhaseWithTasksInnerProps {
phase: PhaseWithTasksProps["phase"];
tasks: SerializedTask[];
tasksLoaded: boolean;
phaseDependencyIds: string[];
defaultExpanded: boolean;
onTaskClick: (taskId: string) => void;
onTaskCounts: (phaseId: string, counts: TaskCounts) => void;
registerTasks: (phaseId: string, entries: FlatTaskEntry[]) => void;
}
function PhaseWithTasksInner({
phase,
tasks,
tasksLoaded,
phaseDependencyIds: _phaseDependencyIds,
defaultExpanded,
onTaskClick,
onTaskCounts,
registerTasks,
}: PhaseWithTasksInnerProps) {
// Propagate task counts and entries
useEffect(() => {
const complete = tasks.filter(
(t) => t.status === "completed",
).length;
onTaskCounts(phase.id, { complete, total: tasks.length });
const entries: FlatTaskEntry[] = tasks.map((task) => ({
task,
phaseName: phase.name,
agentName: null,
blockedBy: [],
dependents: [],
}));
registerTasks(phase.id, entries);
}, [tasks, phase.id, phase.name, onTaskCounts, registerTasks]);
const sortedTasks = sortByPriorityAndQueueTime(tasks);
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 }> = [];
if (!tasksLoaded) {
return null;
}
return (
<PhaseAccordion
phase={phase}
tasks={taskEntries}
defaultExpanded={defaultExpanded}
phaseDependencies={phaseDeps}
onTaskClick={onTaskClick}
/>
);
}

View File

@@ -0,0 +1,73 @@
import { Skeleton } from "@/components/Skeleton";
import { useExecutionContext, type PhaseData } from "./ExecutionContext";
import { PhaseWithTasks } from "./PhaseWithTasks";
import { PlanSection } from "./PlanSection";
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 (
<PlanSection
initiativeId={initiativeId}
phasesLoaded={phasesLoaded}
phases={phases}
/>
);
}
return (
<>
{phasesLoaded &&
phases.map((phase, idx) => {
const serializedPhase = {
id: phase.id,
initiativeId: phase.initiativeId,
name: phase.name,
content: phase.content,
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,133 @@
import { useCallback, useMemo } from "react";
import { Loader2, Plus, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button";
import { trpc } from "@/lib/trpc";
import { useSpawnMutation } from "@/hooks/useSpawnMutation";
import { ChangeSetBanner } from "@/components/ChangeSetBanner";
interface PlanSectionProps {
initiativeId: string;
phasesLoaded: boolean;
phases: Array<{ status: string }>;
onAddPhase?: () => void;
}
export function PlanSection({
initiativeId,
phasesLoaded,
phases,
onAddPhase,
}: PlanSectionProps) {
// Plan agent tracking
const agentsQuery = trpc.listAgents.useQuery();
const allAgents = agentsQuery.data ?? [];
const planAgent = useMemo(() => {
const candidates = allAgents
.filter(
(a) =>
a.mode === "plan" &&
a.initiativeId === 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 isPlanRunning = planAgent?.status === "running";
// Query change sets when we have a completed plan agent
const changeSetsQuery = trpc.listChangeSets.useQuery(
{ agentId: planAgent?.id ?? "" },
{ enabled: !!planAgent && planAgent.status === "idle" },
);
const latestChangeSet = useMemo(
() => (changeSetsQuery.data ?? []).find((cs) => cs.status === "applied") ?? null,
[changeSetsQuery.data],
);
const dismissMutation = trpc.dismissAgent.useMutation();
const planSpawn = useSpawnMutation(trpc.spawnArchitectPlan.useMutation, {
showToast: false,
});
const handlePlan = useCallback(() => {
planSpawn.spawn({ initiativeId });
}, [initiativeId, planSpawn]);
const handleDismiss = useCallback(() => {
if (!planAgent) return;
dismissMutation.mutate({ id: planAgent.id });
}, [planAgent, dismissMutation]);
// Don't render during loading
if (!phasesLoaded) {
return null;
}
// If phases exist and no change set to show, hide section
if (phases.length > 0 && !latestChangeSet) {
return null;
}
// Show change set banner when plan agent completed
if (planAgent?.status === "idle" && latestChangeSet) {
return (
<div className="py-4">
<ChangeSetBanner
changeSet={latestChangeSet}
onDismiss={handleDismiss}
/>
</div>
);
}
return (
<div className="py-8 text-center space-y-3">
<p className="text-muted-foreground">No phases yet</p>
{isPlanRunning ? (
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
<Loader2 className="h-3.5 w-3.5 animate-spin" />
Planning phases...
</div>
) : (
<div className="flex items-center justify-center gap-2">
<Button
variant="outline"
size="sm"
onClick={handlePlan}
disabled={planSpawn.isSpawning}
className="gap-1.5"
>
<Sparkles className="h-3.5 w-3.5" />
{planSpawn.isSpawning
? "Starting..."
: "Plan Phases"}
</Button>
{onAddPhase && (
<>
<span className="text-xs text-muted-foreground">or</span>
<Button
variant="outline"
size="sm"
onClick={onAddPhase}
className="gap-1.5"
>
<Plus className="h-3.5 w-3.5" />
Add Phase
</Button>
</>
)}
</div>
)}
{planSpawn.isError && (
<p className="text-xs text-destructive">
{planSpawn.error}
</p>
)}
</div>
);
}

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,7 @@
export { ExecutionProvider, useExecutionContext } from "./ExecutionContext";
export { PlanSection } from "./PlanSection";
export { PhaseActions } from "./PhaseActions";
export { PhaseSidebarItem } from "./PhaseSidebarItem";
export { PhaseDetailPanel, PhaseDetailEmpty } from "./PhaseDetailPanel";
export { TaskModal } from "./TaskModal";
export type { TaskCounts, FlatTaskEntry, PhaseData } from "./ExecutionContext";

View File

@@ -0,0 +1,37 @@
import type { PipelineColumn } from "@codewalk-district/shared";
import { PipelineStageColumn } from "./PipelineStageColumn";
import type { SerializedTask } from "@/components/TaskRow";
interface PipelineGraphProps {
columns: PipelineColumn<{
id: string;
name: string;
status: string;
createdAt: string | Date;
}>[];
tasksByPhase: Record<string, SerializedTask[]>;
}
export function PipelineGraph({ columns, tasksByPhase }: PipelineGraphProps) {
return (
<div className="overflow-x-auto pb-4">
<div className="flex min-w-max items-start gap-0">
{columns.map((column, idx) => (
<div key={column.depth} className="flex items-start">
{/* Connector arrow between columns */}
{idx > 0 && (
<div className="flex items-center self-center py-4">
<div className="h-px w-6 bg-border" />
<div className="h-0 w-0 border-y-[4px] border-l-[6px] border-y-transparent border-l-border" />
</div>
)}
<PipelineStageColumn
phases={column.phases}
tasksByPhase={tasksByPhase}
/>
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,52 @@
import { Play } from "lucide-react";
import { StatusDot } from "@/components/StatusDot";
import { trpc } from "@/lib/trpc";
import { sortByPriorityAndQueueTime } from "@codewalk-district/shared";
import { PipelineTaskCard } from "./PipelineTaskCard";
import type { SerializedTask } from "@/components/TaskRow";
interface PipelinePhaseGroupProps {
phase: {
id: string;
name: string;
status: string;
};
tasks: SerializedTask[];
}
export function PipelinePhaseGroup({ phase, tasks }: PipelinePhaseGroupProps) {
const queuePhase = trpc.queuePhase.useMutation();
const sorted = sortByPriorityAndQueueTime(tasks);
return (
<div className="rounded-lg border border-border bg-card overflow-hidden">
{/* Header */}
<div className="flex items-center gap-2 px-3 py-2 border-b border-border bg-muted/30">
<StatusDot status={phase.status} size="sm" />
<span className="min-w-0 flex-1 truncate text-sm font-medium">
{phase.name}
</span>
{phase.status === "pending" && (
<button
onClick={() => queuePhase.mutate({ phaseId: phase.id })}
title="Queue phase"
className="shrink-0"
>
<Play className="h-3.5 w-3.5 text-muted-foreground hover:text-foreground" />
</button>
)}
</div>
{/* Tasks */}
<div className="py-1">
{sorted.length === 0 ? (
<p className="px-3 py-1 text-xs text-muted-foreground">No tasks</p>
) : (
sorted.map((task) => (
<PipelineTaskCard key={task.id} task={task} />
))
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,25 @@
import { PipelinePhaseGroup } from "./PipelinePhaseGroup";
import type { SerializedTask } from "@/components/TaskRow";
interface PipelineStageColumnProps {
phases: Array<{
id: string;
name: string;
status: string;
}>;
tasksByPhase: Record<string, SerializedTask[]>;
}
export function PipelineStageColumn({ phases, tasksByPhase }: PipelineStageColumnProps) {
return (
<div className="flex w-64 shrink-0 flex-col gap-3">
{phases.map((phase) => (
<PipelinePhaseGroup
key={phase.id}
phase={phase}
tasks={tasksByPhase[phase.id] ?? []}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,114 @@
import { useEffect, useMemo } from "react";
import { Loader2 } from "lucide-react";
import { trpc } from "@/lib/trpc";
import {
groupPhasesByDependencyLevel,
type DependencyEdge,
} from "@codewalk-district/shared";
import {
ExecutionProvider,
useExecutionContext,
TaskModal,
PlanSection,
type PhaseData,
type FlatTaskEntry,
} from "@/components/execution";
import type { SerializedTask } from "@/components/TaskRow";
import { PipelineGraph } from "./PipelineGraph";
interface PipelineTabProps {
initiativeId: string;
phases: PhaseData[];
phasesLoading: boolean;
}
export function PipelineTab({ initiativeId, phases, phasesLoading }: PipelineTabProps) {
return (
<ExecutionProvider>
<PipelineTabInner
initiativeId={initiativeId}
phases={phases}
phasesLoading={phasesLoading}
/>
<TaskModal />
</ExecutionProvider>
);
}
function PipelineTabInner({ initiativeId, phases, phasesLoading }: PipelineTabProps) {
const { handleRegisterTasks, handleTaskCounts } = useExecutionContext();
// Fetch all tasks for the initiative
const tasksQuery = trpc.listInitiativeTasks.useQuery(
{ initiativeId },
{ enabled: phases.length > 0 },
);
const allTasks = (tasksQuery.data ?? []) as SerializedTask[];
// Fetch dependency edges
const depsQuery = trpc.listInitiativePhaseDependencies.useQuery(
{ initiativeId },
{ enabled: phases.length > 0 },
);
const dependencyEdges: DependencyEdge[] = depsQuery.data ?? [];
// Group tasks by phaseId
const tasksByPhase = useMemo(() => {
const map: Record<string, SerializedTask[]> = {};
for (const task of allTasks) {
if (task.phaseId) {
if (!map[task.phaseId]) map[task.phaseId] = [];
map[task.phaseId].push(task);
}
}
return map;
}, [allTasks]);
// Compute pipeline columns
const columns = useMemo(
() => groupPhasesByDependencyLevel(phases, dependencyEdges),
[phases, dependencyEdges],
);
// Register tasks with ExecutionContext for TaskModal
useEffect(() => {
for (const phase of phases) {
const phaseTasks = tasksByPhase[phase.id] ?? [];
const entries: FlatTaskEntry[] = phaseTasks.map((task) => ({
task,
phaseName: phase.name,
agentName: null,
blockedBy: [],
dependents: [],
}));
handleRegisterTasks(phase.id, entries);
handleTaskCounts(phase.id, {
complete: phaseTasks.filter((t) => t.status === "completed").length,
total: phaseTasks.length,
});
}
}, [phases, tasksByPhase, handleRegisterTasks, handleTaskCounts]);
// Empty state
if (!phasesLoading && phases.length === 0) {
return (
<PlanSection
initiativeId={initiativeId}
phasesLoaded={!phasesLoading}
phases={phases}
/>
);
}
// Loading
if (phasesLoading || tasksQuery.isLoading) {
return (
<div className="flex items-center justify-center py-12 text-muted-foreground">
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Loading pipeline...
</div>
);
}
return <PipelineGraph columns={columns} tasksByPhase={tasksByPhase} />;
}

View File

@@ -0,0 +1,49 @@
import { CheckCircle2, Loader2, Clock, Ban, Play, AlertTriangle } from "lucide-react";
import { cn } from "@/lib/utils";
import { trpc } from "@/lib/trpc";
import { useExecutionContext } from "@/components/execution";
import type { SerializedTask } from "@/components/TaskRow";
const statusConfig: Record<string, { icon: typeof Clock; color: string; spin?: boolean }> = {
pending: { icon: Clock, color: "text-muted-foreground" },
pending_approval: { icon: AlertTriangle, color: "text-yellow-500" },
in_progress: { icon: Loader2, color: "text-blue-500", spin: true },
completed: { icon: CheckCircle2, color: "text-green-500" },
blocked: { icon: Ban, color: "text-red-500" },
};
interface PipelineTaskCardProps {
task: SerializedTask;
}
export function PipelineTaskCard({ task }: PipelineTaskCardProps) {
const { setSelectedTaskId } = useExecutionContext();
const queueTask = trpc.queueTask.useMutation();
const config = statusConfig[task.status] ?? statusConfig.pending;
const Icon = config.icon;
return (
<div
className="flex items-center gap-2 rounded px-2 py-1 cursor-pointer hover:bg-accent/50 group"
onClick={() => setSelectedTaskId(task.id)}
>
<Icon
className={cn("h-3.5 w-3.5 shrink-0", config.color, config.spin && "animate-spin")}
/>
<span className="min-w-0 flex-1 truncate text-xs">{task.name}</span>
{task.status === "pending" && (
<button
className="shrink-0 opacity-0 group-hover:opacity-100 transition-opacity"
onClick={(e) => {
e.stopPropagation();
queueTask.mutate({ taskId: task.id });
}}
title="Queue task"
>
<Play className="h-3 w-3 text-muted-foreground hover:text-foreground" />
</button>
)}
</div>
);
}

View File

@@ -0,0 +1 @@
export { PipelineTab } from "./PipelineTab";

View File

@@ -0,0 +1,71 @@
import { forwardRef, useState, useCallback } from "react";
import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
interface CommentFormProps {
onSubmit: (body: string) => void;
onCancel: () => void;
placeholder?: string;
submitLabel?: string;
}
export const CommentForm = forwardRef<HTMLTextAreaElement, CommentFormProps>(
function CommentForm(
{ onSubmit, onCancel, placeholder = "Write a comment...", submitLabel = "Comment" },
ref
) {
const [body, setBody] = useState("");
const handleSubmit = useCallback(() => {
const trimmed = body.trim();
if (!trimmed) return;
onSubmit(trimmed);
setBody("");
}, [body, onSubmit]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
handleSubmit();
}
if (e.key === "Escape") {
onCancel();
}
},
[handleSubmit, onCancel]
);
return (
<div className="space-y-2">
<Textarea
ref={ref}
value={body}
onChange={(e) => setBody(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
className="min-h-[60px] text-xs resize-none"
rows={2}
/>
<div className="flex items-center justify-between">
<span className="text-[10px] text-muted-foreground">
Cmd+Enter to submit, Esc to cancel
</span>
<div className="flex gap-1.5">
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={onCancel}>
Cancel
</Button>
<Button
size="sm"
className="h-7 text-xs"
onClick={handleSubmit}
disabled={!body.trim()}
>
{submitLabel}
</Button>
</div>
</div>
</div>
);
}
);

View File

@@ -0,0 +1,72 @@
import { Check, RotateCcw } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { ReviewComment } from "./types";
interface CommentThreadProps {
comments: ReviewComment[];
onResolve: (commentId: string) => void;
onUnresolve: (commentId: string) => void;
}
export function CommentThread({ comments, onResolve, onUnresolve }: CommentThreadProps) {
return (
<div className="space-y-2">
{comments.map((comment) => (
<div
key={comment.id}
className={`rounded border p-2.5 text-xs space-y-1.5 ${
comment.resolved
? "border-green-200 dark:border-green-900 bg-green-50/50 dark:bg-green-950/10"
: "border-border bg-card"
}`}
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5">
<span className="font-semibold text-foreground">{comment.author}</span>
<span className="text-muted-foreground">
{formatTime(comment.createdAt)}
</span>
{comment.resolved && (
<span className="flex items-center gap-0.5 text-green-600 text-[10px] font-medium">
<Check className="h-3 w-3" />
Resolved
</span>
)}
</div>
<div>
{comment.resolved ? (
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5 text-[10px]"
onClick={() => onUnresolve(comment.id)}
>
<RotateCcw className="h-3 w-3 mr-0.5" />
Reopen
</Button>
) : (
<Button
variant="ghost"
size="sm"
className="h-6 px-1.5 text-[10px]"
onClick={() => onResolve(comment.id)}
>
<Check className="h-3 w-3 mr-0.5" />
Resolve
</Button>
)}
</div>
</div>
<p className="text-foreground/90 leading-relaxed whitespace-pre-wrap">
{comment.body}
</p>
</div>
))}
</div>
);
}
function formatTime(iso: string): string {
const d = new Date(iso);
return d.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" });
}

View File

@@ -0,0 +1,38 @@
import type { FileDiff, DiffLine, ReviewComment } from "./types";
import { FileCard } from "./FileCard";
interface DiffViewerProps {
files: FileDiff[];
comments: ReviewComment[];
onAddComment: (
filePath: string,
lineNumber: number,
lineType: DiffLine["type"],
body: string,
) => void;
onResolveComment: (commentId: string) => void;
onUnresolveComment: (commentId: string) => void;
}
export function DiffViewer({
files,
comments,
onAddComment,
onResolveComment,
onUnresolveComment,
}: DiffViewerProps) {
return (
<div className="space-y-4">
{files.map((file) => (
<FileCard
key={file.newPath}
file={file}
comments={comments.filter((c) => c.filePath === file.newPath)}
onAddComment={onAddComment}
onResolveComment={onResolveComment}
onUnresolveComment={onUnresolveComment}
/>
))}
</div>
);
}

View File

@@ -0,0 +1,86 @@
import { useState } from "react";
import { ChevronDown, ChevronRight, Plus, Minus } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import type { FileDiff, DiffLine, ReviewComment } from "./types";
import { HunkRows } from "./HunkRows";
interface FileCardProps {
file: FileDiff;
comments: ReviewComment[];
onAddComment: (
filePath: string,
lineNumber: number,
lineType: DiffLine["type"],
body: string,
) => void;
onResolveComment: (commentId: string) => void;
onUnresolveComment: (commentId: string) => void;
}
export function FileCard({
file,
comments,
onAddComment,
onResolveComment,
onUnresolveComment,
}: FileCardProps) {
const [expanded, setExpanded] = useState(true);
const commentCount = comments.length;
return (
<div className="rounded-lg border border-border overflow-hidden">
{/* File header */}
<button
className="flex w-full items-center gap-2 px-3 py-2 bg-muted/50 hover:bg-muted text-left text-sm font-mono transition-colors"
onClick={() => setExpanded(!expanded)}
>
{expanded ? (
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
) : (
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
)}
<span className="truncate flex-1">{file.newPath}</span>
<span className="flex items-center gap-2 shrink-0 text-xs">
{file.additions > 0 && (
<span className="flex items-center gap-0.5 text-green-600">
<Plus className="h-3 w-3" />
{file.additions}
</span>
)}
{file.deletions > 0 && (
<span className="flex items-center gap-0.5 text-red-600">
<Minus className="h-3 w-3" />
{file.deletions}
</span>
)}
{commentCount > 0 && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
{commentCount}
</Badge>
)}
</span>
</button>
{/* Diff content */}
{expanded && (
<div className="overflow-x-auto">
<table className="w-full text-xs font-mono border-collapse">
<tbody>
{file.hunks.map((hunk, hi) => (
<HunkRows
key={hi}
hunk={hunk}
filePath={file.newPath}
comments={comments}
onAddComment={onAddComment}
onResolveComment={onResolveComment}
onUnresolveComment={onUnresolveComment}
/>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,86 @@
import { useState, useCallback } from "react";
import type { DiffLine, ReviewComment } from "./types";
import { LineWithComments } from "./LineWithComments";
interface HunkRowsProps {
hunk: { header: string; lines: DiffLine[] };
filePath: string;
comments: ReviewComment[];
onAddComment: (
filePath: string,
lineNumber: number,
lineType: DiffLine["type"],
body: string,
) => void;
onResolveComment: (commentId: string) => void;
onUnresolveComment: (commentId: string) => void;
}
export function HunkRows({
hunk,
filePath,
comments,
onAddComment,
onResolveComment,
onUnresolveComment,
}: HunkRowsProps) {
const [commentingLine, setCommentingLine] = useState<{
lineNumber: number;
lineType: DiffLine["type"];
} | null>(null);
const handleSubmitComment = useCallback(
(body: string) => {
if (!commentingLine) return;
onAddComment(
filePath,
commentingLine.lineNumber,
commentingLine.lineType,
body,
);
setCommentingLine(null);
},
[commentingLine, filePath, onAddComment],
);
return (
<>
{/* Hunk header */}
<tr>
<td
colSpan={3}
className="px-3 py-1 text-muted-foreground bg-blue-50 dark:bg-blue-950/30 text-[11px] select-none"
>
{hunk.header}
</td>
</tr>
{hunk.lines.map((line, li) => {
const lineKey = line.newLineNumber ?? line.oldLineNumber ?? li;
const lineComments = comments.filter(
(c) => c.lineNumber === lineKey && c.lineType === line.type,
);
const isCommenting =
commentingLine?.lineNumber === lineKey &&
commentingLine?.lineType === line.type;
return (
<LineWithComments
key={`${line.type}-${lineKey}-${li}`}
line={line}
lineKey={lineKey}
lineComments={lineComments}
isCommenting={isCommenting}
onStartComment={() =>
setCommentingLine({ lineNumber: lineKey, lineType: line.type })
}
onCancelComment={() => setCommentingLine(null)}
onSubmitComment={handleSubmitComment}
onResolveComment={onResolveComment}
onUnresolveComment={onUnresolveComment}
/>
);
})}
</>
);
}

View File

@@ -0,0 +1,138 @@
import { useRef, useEffect } from "react";
import { MessageSquarePlus } from "lucide-react";
import type { DiffLine, ReviewComment } from "./types";
import { CommentThread } from "./CommentThread";
import { CommentForm } from "./CommentForm";
interface LineWithCommentsProps {
line: DiffLine;
lineKey: number;
lineComments: ReviewComment[];
isCommenting: boolean;
onStartComment: () => void;
onCancelComment: () => void;
onSubmitComment: (body: string) => void;
onResolveComment: (commentId: string) => void;
onUnresolveComment: (commentId: string) => void;
}
export function LineWithComments({
line,
lineKey,
lineComments,
isCommenting,
onStartComment,
onCancelComment,
onSubmitComment,
onResolveComment,
onUnresolveComment,
}: LineWithCommentsProps) {
const formRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
if (isCommenting) {
formRef.current?.focus();
}
}, [isCommenting]);
const bgClass =
line.type === "added"
? "bg-green-50 dark:bg-green-950/20"
: line.type === "removed"
? "bg-red-50 dark:bg-red-950/20"
: "";
const gutterBgClass =
line.type === "added"
? "bg-green-100 dark:bg-green-950/40"
: line.type === "removed"
? "bg-red-100 dark:bg-red-950/40"
: "bg-muted/30";
const prefix =
line.type === "added" ? "+" : line.type === "removed" ? "-" : " ";
const textColorClass =
line.type === "added"
? "text-green-800 dark:text-green-300"
: line.type === "removed"
? "text-red-800 dark:text-red-300"
: "";
return (
<>
<tr
className={`group ${bgClass} hover:brightness-95 dark:hover:brightness-110`}
>
{/* Line numbers */}
<td
className={`w-[72px] min-w-[72px] select-none text-right text-muted-foreground pr-1 ${gutterBgClass} align-top`}
>
<div className="flex items-center justify-end gap-0">
<span className="w-8 inline-block text-right text-[11px] leading-5">
{line.oldLineNumber ?? ""}
</span>
<span className="w-8 inline-block text-right text-[11px] leading-5">
{line.newLineNumber ?? ""}
</span>
</div>
</td>
{/* Comment button gutter */}
<td className={`w-6 min-w-6 ${gutterBgClass} align-top`}>
<button
className="opacity-0 group-hover:opacity-100 transition-opacity p-0.5 hover:text-blue-600"
onClick={onStartComment}
title="Add comment"
>
<MessageSquarePlus className="h-3.5 w-3.5" />
</button>
</td>
{/* Code content */}
<td className="pl-1 pr-3 align-top">
<pre
className={`leading-5 whitespace-pre-wrap break-all ${textColorClass}`}
>
<span className="select-none text-muted-foreground/60">
{prefix}
</span>
{line.content}
</pre>
</td>
</tr>
{/* Existing comments on this line */}
{lineComments.length > 0 && (
<tr>
<td
colSpan={3}
className="px-3 py-2 bg-muted/20 border-y border-border/50"
>
<CommentThread
comments={lineComments}
onResolve={onResolveComment}
onUnresolve={onUnresolveComment}
/>
</td>
</tr>
)}
{/* Inline comment form */}
{isCommenting && (
<tr>
<td
colSpan={3}
className="px-3 py-2 bg-blue-50/50 dark:bg-blue-950/20 border-y border-blue-200 dark:border-blue-900"
>
<CommentForm
ref={formRef}
onSubmit={onSubmitComment}
onCancel={onCancelComment}
/>
</td>
</tr>
)}
</>
);
}

View File

@@ -0,0 +1,176 @@
import { useState } from "react";
import {
Loader2,
ExternalLink,
Square,
RotateCcw,
CircleDot,
CircleX,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { trpc } from "@/lib/trpc";
import { toast } from "sonner";
interface PreviewPanelProps {
initiativeId: string;
phaseId?: string;
projectId: string;
branch: string;
}
export function PreviewPanel({
initiativeId,
phaseId,
projectId,
branch,
}: PreviewPanelProps) {
const [activePreviewId, setActivePreviewId] = useState<string | null>(null);
// Check for existing previews for this initiative
const previewsQuery = trpc.listPreviews.useQuery(
{ initiativeId },
{ refetchInterval: activePreviewId ? 3000 : false },
);
const existingPreview = previewsQuery.data?.find(
(p) => p.phaseId === phaseId || (!phaseId && p.initiativeId === initiativeId),
);
const previewStatusQuery = trpc.getPreviewStatus.useQuery(
{ previewId: activePreviewId ?? existingPreview?.id ?? "" },
{
enabled: !!(activePreviewId ?? existingPreview?.id),
refetchInterval: 3000,
},
);
const preview = previewStatusQuery.data ?? existingPreview;
const startMutation = trpc.startPreview.useMutation({
onSuccess: (data) => {
setActivePreviewId(data.id);
toast.success(`Preview running at http://localhost:${data.port}`);
},
onError: (err) => {
toast.error(`Preview failed: ${err.message}`);
},
});
const stopMutation = trpc.stopPreview.useMutation({
onSuccess: () => {
setActivePreviewId(null);
toast.success("Preview stopped");
previewsQuery.refetch();
},
onError: (err) => {
toast.error(`Failed to stop preview: ${err.message}`);
},
});
const handleStart = () => {
startMutation.mutate({ initiativeId, phaseId, projectId, branch });
};
const handleStop = () => {
const id = activePreviewId ?? existingPreview?.id;
if (id) {
stopMutation.mutate({ previewId: id });
}
};
// Building state
if (startMutation.isPending) {
return (
<div className="flex items-center gap-3 rounded-lg border border-blue-200 dark:border-blue-900 bg-blue-50 dark:bg-blue-950/20 px-4 py-3">
<Loader2 className="h-4 w-4 animate-spin text-blue-600 dark:text-blue-400" />
<div className="flex-1">
<p className="text-sm font-medium text-blue-700 dark:text-blue-300">
Building preview...
</p>
<p className="text-xs text-blue-600/70 dark:text-blue-400/70">
Building containers and starting services
</p>
</div>
</div>
);
}
// Running state
if (preview && (preview.status === "running" || preview.status === "building")) {
const url = `http://localhost:${preview.port}`;
const isBuilding = preview.status === "building";
return (
<div
className={`flex items-center gap-3 rounded-lg border px-4 py-3 ${
isBuilding
? "border-blue-200 dark:border-blue-900 bg-blue-50 dark:bg-blue-950/20"
: "border-green-200 dark:border-green-900 bg-green-50 dark:bg-green-950/20"
}`}
>
{isBuilding ? (
<Loader2 className="h-4 w-4 animate-spin text-blue-600 dark:text-blue-400" />
) : (
<CircleDot className="h-4 w-4 text-green-600 dark:text-green-400" />
)}
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-foreground">
{isBuilding ? "Building..." : "Preview running"}
</p>
{!isBuilding && (
<a
href={url}
target="_blank"
rel="noopener noreferrer"
className="text-xs text-blue-600 dark:text-blue-400 hover:underline flex items-center gap-1"
>
{url}
<ExternalLink className="h-3 w-3" />
</a>
)}
</div>
<Button
size="sm"
variant="outline"
onClick={handleStop}
disabled={stopMutation.isPending}
className="shrink-0"
>
<Square className="h-3 w-3 mr-1" />
Stop
</Button>
</div>
);
}
// Failed state
if (preview && preview.status === "failed") {
return (
<div className="flex items-center gap-3 rounded-lg border border-red-200 dark:border-red-900 bg-red-50 dark:bg-red-950/20 px-4 py-3">
<CircleX className="h-4 w-4 text-red-600 dark:text-red-400" />
<div className="flex-1">
<p className="text-sm font-medium text-red-700 dark:text-red-300">
Preview failed
</p>
</div>
<Button size="sm" variant="outline" onClick={handleStart}>
<RotateCcw className="h-3 w-3 mr-1" />
Retry
</Button>
</div>
);
}
// No preview — show start button
return (
<Button
size="sm"
variant="outline"
onClick={handleStart}
disabled={startMutation.isPending}
>
<ExternalLink className="h-3.5 w-3.5 mr-1" />
Start Preview
</Button>
);
}

View File

@@ -0,0 +1,213 @@
import {
Check,
X,
MessageSquare,
GitBranch,
FileCode,
Plus,
Minus,
Circle,
CheckCircle2,
} from "lucide-react";
import { Button } from "@/components/ui/button";
import { Badge } from "@/components/ui/badge";
import type { FileDiff, ReviewComment, ReviewStatus } from "./types";
interface ReviewSidebarProps {
title: string;
description: string;
author: string;
status: ReviewStatus;
sourceBranch: string;
targetBranch: string;
files: FileDiff[];
comments: ReviewComment[];
onApprove: () => void;
onRequestChanges: () => void;
onFileClick: (filePath: string) => void;
}
export function ReviewSidebar({
title,
description,
author,
status,
sourceBranch,
targetBranch,
files,
comments,
onApprove,
onRequestChanges,
onFileClick,
}: ReviewSidebarProps) {
const unresolvedCount = comments.filter((c) => !c.resolved).length;
const resolvedCount = comments.filter((c) => c.resolved).length;
const totalAdditions = files.reduce((s, f) => s + f.additions, 0);
const totalDeletions = files.reduce((s, f) => s + f.deletions, 0);
return (
<div className="space-y-5">
{/* Review info */}
<div className="space-y-3">
<div className="flex items-start justify-between gap-2">
<h3 className="text-sm font-semibold leading-tight">{title}</h3>
<StatusBadge status={status} />
</div>
<p className="text-xs text-muted-foreground leading-relaxed">
{description}
</p>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className="font-medium text-foreground">{author}</span>
</div>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground font-mono">
<GitBranch className="h-3 w-3" />
<span>{sourceBranch}</span>
<span className="text-muted-foreground/50">&rarr;</span>
<span>{targetBranch}</span>
</div>
</div>
{/* Actions */}
<div className="space-y-2">
{status === "pending" && (
<>
<Button
className="w-full"
size="sm"
onClick={onApprove}
disabled={unresolvedCount > 0}
>
<Check className="h-3.5 w-3.5 mr-1" />
{unresolvedCount > 0
? `Resolve ${unresolvedCount} thread${unresolvedCount > 1 ? "s" : ""} first`
: "Approve"}
</Button>
<Button
className="w-full"
variant="outline"
size="sm"
onClick={onRequestChanges}
>
<X className="h-3.5 w-3.5 mr-1" />
Request Changes
</Button>
</>
)}
{status === "approved" && (
<div className="flex items-center gap-2 rounded-md bg-green-50 dark:bg-green-950/20 border border-green-200 dark:border-green-900 px-3 py-2 text-xs text-green-700 dark:text-green-400">
<Check className="h-4 w-4" />
<span className="font-medium">Approved</span>
</div>
)}
{status === "changes_requested" && (
<div className="flex items-center gap-2 rounded-md bg-orange-50 dark:bg-orange-950/20 border border-orange-200 dark:border-orange-900 px-3 py-2 text-xs text-orange-700 dark:text-orange-400">
<X className="h-4 w-4" />
<span className="font-medium">Changes Requested</span>
</div>
)}
</div>
{/* Comment summary */}
<div className="space-y-2">
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Discussions
</h4>
<div className="flex items-center gap-3 text-xs">
<span className="flex items-center gap-1 text-muted-foreground">
<MessageSquare className="h-3 w-3" />
{comments.length} comment{comments.length !== 1 ? "s" : ""}
</span>
{resolvedCount > 0 && (
<span className="flex items-center gap-1 text-green-600">
<CheckCircle2 className="h-3 w-3" />
{resolvedCount} resolved
</span>
)}
{unresolvedCount > 0 && (
<span className="flex items-center gap-1 text-orange-600">
<Circle className="h-3 w-3" />
{unresolvedCount} open
</span>
)}
</div>
</div>
{/* Stats */}
<div className="space-y-2">
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Changes
</h4>
<div className="flex items-center gap-3 text-xs">
<span className="flex items-center gap-1">
<FileCode className="h-3 w-3 text-muted-foreground" />
{files.length} file{files.length !== 1 ? "s" : ""}
</span>
<span className="flex items-center gap-0.5 text-green-600">
<Plus className="h-3 w-3" />
{totalAdditions}
</span>
<span className="flex items-center gap-0.5 text-red-600">
<Minus className="h-3 w-3" />
{totalDeletions}
</span>
</div>
</div>
{/* File list */}
<div className="space-y-1">
<h4 className="text-xs font-semibold text-muted-foreground uppercase tracking-wider">
Files
</h4>
{files.map((file) => {
const fileCommentCount = comments.filter(
(c) => c.filePath === file.newPath
).length;
return (
<button
key={file.newPath}
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-xs hover:bg-accent/50 transition-colors group"
onClick={() => onFileClick(file.newPath)}
>
<FileCode className="h-3 w-3 text-muted-foreground shrink-0" />
<span className="truncate flex-1 font-mono text-[11px]">
{file.newPath.split("/").pop()}
</span>
<span className="flex items-center gap-1.5 shrink-0">
{fileCommentCount > 0 && (
<span className="flex items-center gap-0.5 text-muted-foreground">
<MessageSquare className="h-2.5 w-2.5" />
{fileCommentCount}
</span>
)}
<span className="text-green-600 text-[10px]">+{file.additions}</span>
<span className="text-red-600 text-[10px]">-{file.deletions}</span>
</span>
</button>
);
})}
</div>
</div>
);
}
function StatusBadge({ status }: { status: ReviewStatus }) {
if (status === "approved") {
return (
<Badge className="bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 border-green-200 dark:border-green-800 text-[10px]">
Approved
</Badge>
);
}
if (status === "changes_requested") {
return (
<Badge className="bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200 border-orange-200 dark:border-orange-800 text-[10px]">
Changes Requested
</Badge>
);
}
return (
<Badge variant="secondary" className="text-[10px]">
Pending Review
</Badge>
);
}

View File

@@ -0,0 +1,197 @@
import { useState, useCallback, useMemo, useRef } from "react";
import { toast } from "sonner";
import { trpc } from "@/lib/trpc";
import { parseUnifiedDiff } from "./parse-diff";
import { DiffViewer } from "./DiffViewer";
import { ReviewSidebar } from "./ReviewSidebar";
import { PreviewPanel } from "./PreviewPanel";
import type { ReviewComment, ReviewStatus, DiffLine } from "./types";
interface ReviewTabProps {
initiativeId: string;
}
export function ReviewTab({ initiativeId }: ReviewTabProps) {
const [comments, setComments] = useState<ReviewComment[]>([]);
const [status, setStatus] = useState<ReviewStatus>("pending");
const fileRefs = useRef<Map<string, HTMLDivElement>>(new Map());
// Fetch phases for this initiative
const phasesQuery = trpc.listPhases.useQuery({ initiativeId });
const pendingReviewPhases = useMemo(
() => (phasesQuery.data ?? []).filter((p) => p.status === "pending_review"),
[phasesQuery.data],
);
// Select first pending review phase
const [selectedPhaseId, setSelectedPhaseId] = useState<string | null>(null);
const activePhaseId = selectedPhaseId ?? pendingReviewPhases[0]?.id ?? null;
// Fetch projects for this initiative (needed for preview)
const projectsQuery = trpc.getInitiativeProjects.useQuery({ initiativeId });
const firstProjectId = projectsQuery.data?.[0]?.id ?? null;
// Fetch diff for active phase
const diffQuery = trpc.getPhaseReviewDiff.useQuery(
{ phaseId: activePhaseId! },
{ enabled: !!activePhaseId },
);
const approveMutation = trpc.approvePhaseReview.useMutation({
onSuccess: () => {
setStatus("approved");
toast.success("Phase approved and merged");
phasesQuery.refetch();
},
onError: (err) => {
toast.error(err.message);
},
});
const files = useMemo(() => {
if (!diffQuery.data?.rawDiff) return [];
return parseUnifiedDiff(diffQuery.data.rawDiff);
}, [diffQuery.data?.rawDiff]);
const handleAddComment = useCallback(
(filePath: string, lineNumber: number, lineType: DiffLine["type"], body: string) => {
const newComment: ReviewComment = {
id: `c${Date.now()}`,
filePath,
lineNumber,
lineType,
body,
author: "you",
createdAt: new Date().toISOString(),
resolved: false,
};
setComments((prev) => [...prev, newComment]);
toast.success("Comment added");
},
[]
);
const handleResolveComment = useCallback((commentId: string) => {
setComments((prev) =>
prev.map((c) => (c.id === commentId ? { ...c, resolved: true } : c))
);
}, []);
const handleUnresolveComment = useCallback((commentId: string) => {
setComments((prev) =>
prev.map((c) => (c.id === commentId ? { ...c, resolved: false } : c))
);
}, []);
const handleApprove = useCallback(() => {
if (!activePhaseId) return;
approveMutation.mutate({ phaseId: activePhaseId });
}, [activePhaseId, approveMutation]);
const handleRequestChanges = useCallback(() => {
setStatus("changes_requested");
toast("Changes requested", {
description: "The agent will be notified about the requested changes.",
});
}, []);
const handleFileClick = useCallback((filePath: string) => {
const el = fileRefs.current.get(filePath);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "start" });
}
}, []);
if (pendingReviewPhases.length === 0) {
return (
<div className="flex h-64 items-center justify-center text-muted-foreground">
<p>No phases pending review</p>
</div>
);
}
const activePhaseName = diffQuery.data?.phaseName ?? pendingReviewPhases.find(p => p.id === activePhaseId)?.name ?? "Phase";
const sourceBranch = diffQuery.data?.sourceBranch ?? "";
return (
<div className="space-y-4">
{/* Phase selector if multiple pending */}
{pendingReviewPhases.length > 1 && (
<div className="flex gap-2">
{pendingReviewPhases.map((phase) => (
<button
key={phase.id}
onClick={() => setSelectedPhaseId(phase.id)}
className={`rounded-md border px-3 py-1.5 text-sm transition-colors ${
phase.id === activePhaseId
? "border-primary bg-primary/10 font-medium"
: "border-border hover:bg-muted"
}`}
>
{phase.name}
</button>
))}
</div>
)}
{/* Preview panel */}
{firstProjectId && sourceBranch && (
<PreviewPanel
initiativeId={initiativeId}
phaseId={activePhaseId ?? undefined}
projectId={firstProjectId}
branch={sourceBranch}
/>
)}
{diffQuery.isLoading ? (
<div className="flex h-64 items-center justify-center text-muted-foreground">
Loading diff...
</div>
) : (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_300px]">
{/* Left: Diff */}
<div className="min-w-0">
<div className="flex items-center justify-between border-b border-border pb-3 mb-4">
<h2 className="text-lg font-semibold">Review: {activePhaseName}</h2>
<span className="text-xs text-muted-foreground">
{comments.filter((c) => !c.resolved).length} unresolved thread
{comments.filter((c) => !c.resolved).length !== 1 ? "s" : ""}
</span>
</div>
{files.length === 0 ? (
<div className="flex h-32 items-center justify-center text-muted-foreground">
No changes in this phase
</div>
) : (
<DiffViewer
files={files}
comments={comments}
onAddComment={handleAddComment}
onResolveComment={handleResolveComment}
onUnresolveComment={handleUnresolveComment}
/>
)}
</div>
{/* Right: Sidebar */}
<div className="w-full lg:w-[300px]">
<ReviewSidebar
title={`Phase: ${activePhaseName}`}
description={`Review changes from phase "${activePhaseName}" before merging into the initiative branch.`}
author="system"
status={status}
sourceBranch={sourceBranch}
targetBranch={diffQuery.data?.targetBranch ?? ""}
files={files}
comments={comments}
onApprove={handleApprove}
onRequestChanges={handleRequestChanges}
onFileClick={handleFileClick}
/>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,202 @@
import { parseUnifiedDiff } from "./parse-diff";
import type { ReviewComment, ReviewSummary } from "./types";
const RAW_DIFF = `diff --git a/src/agent/output-handler.ts b/src/agent/output-handler.ts
index 7aeabe5..7ca616a 100644
--- a/src/agent/output-handler.ts
+++ b/src/agent/output-handler.ts
@@ -74,6 +74,8 @@ interface ClaudeCliResult {
}
export class OutputHandler {
+ private filePositions = new Map<string, number>();
+
constructor(
private repository: AgentRepository,
private eventBus?: EventBus,
@@ -101,6 +103,43 @@ export class OutputHandler {
}
}
+ /**
+ * Read complete lines from a file, avoiding partial lines that might still be writing.
+ * This eliminates race conditions when agents are still writing output.
+ */
+ private async readCompleteLines(filePath: string, fromPosition: number = 0): Promise<{ content: string; lastPosition: number }> {
+ try {
+ const fs = await import('node:fs/promises');
+ const content = await fs.readFile(filePath, 'utf-8');
+
+ if (fromPosition >= content.length) {
+ return { content: '', lastPosition: fromPosition };
+ }
+
+ // Get content from our last read position
+ const newContent = content.slice(fromPosition);
+
+ // Split into lines
+ const lines = newContent.split('\\n');
+
+ // If file doesn't end with newline, last element is potentially incomplete
+ // Only process complete lines (all but the last, unless file ends with \\n)
+ const hasTrailingNewline = newContent.endsWith('\\n');
+ const completeLines = hasTrailingNewline ? lines : lines.slice(0, -1);
+
+ // Calculate new position (only count complete lines)
+ const completeLinesContent = completeLines.join('\\n') + (completeLines.length > 0 && hasTrailingNewline ? '\\n' : '');
+ const newPosition = fromPosition + Buffer.byteLength(completeLinesContent, 'utf-8');
+
+ return {
+ content: completeLinesContent,
+ lastPosition: newPosition
+ };
+ } catch {
+ return { content: '', lastPosition: fromPosition };
+ }
+ }
+
/**
* Handle a standardized stream event from a parser.
*/
@@ -213,12 +252,27 @@ export class OutputHandler {
if (!signalText) {
try {
const outputFilePath = active?.outputFilePath ?? '';
- if (outputFilePath && await this.validateSignalFile(outputFilePath)) {
- const fileContent = await readFile(outputFilePath, 'utf-8');
+ if (outputFilePath) {
+ // Read only complete lines from the file, avoiding race conditions
+ const lastPosition = this.filePositions.get(agentId) || 0;
+ const { content: fileContent, lastPosition: newPosition } = await this.readCompleteLines(outputFilePath, lastPosition);
+
if (fileContent.trim()) {
+ this.filePositions.set(agentId, newPosition);
await this.processAgentOutput(agentId, fileContent, provider, getAgentWorkdir);
return;
}
+
+ // If no new complete lines, but file might still be writing, try again with validation
+ if (await this.validateSignalFile(outputFilePath)) {
+ const fullContent = await readFile(outputFilePath, 'utf-8');
+ if (fullContent.trim() && fullContent.length > newPosition) {
+ // File is complete and has content beyond what we've read
+ this.filePositions.delete(agentId); // Clean up tracking
+ await this.processAgentOutput(agentId, fullContent, provider, getAgentWorkdir);
+ return;
+ }
+ }
}
} catch { /* file empty or missing */ }
diff --git a/src/agent/manager.ts b/src/agent/manager.ts
index a1b2c3d..e4f5g6h 100644
--- a/src/agent/manager.ts
+++ b/src/agent/manager.ts
@@ -145,6 +145,18 @@ export class MultiProviderAgentManager {
return agent;
}
+ /**
+ * Check if an agent has a valid completion signal that indicates
+ * it finished successfully, even if process monitoring missed it.
+ */
+ private async checkSignalCompletion(agent: AgentInfo): Promise<boolean> {
+ const signalPath = this.getSignalPath(agent);
+ if (!signalPath) return false;
+ const signal = await this.readSignalFile(signalPath);
+ if (!signal) return false;
+ return ['done', 'questions', 'error'].includes(signal.status);
+ }
+
/**
* Reconcile agent states after a server restart.
* Checks which agents are still alive and updates their status.
@@ -160,8 +172,16 @@ export class MultiProviderAgentManager {
if (isAlive) {
this.monitorAgent(agent);
} else {
- // Agent process is gone — mark as crashed
- await this.outputHandler.handleAgentError(agent.id, 'Agent process terminated unexpectedly');
+ // Agent process is gone — check signal before marking as crashed
+ const hasValidSignal = await this.checkSignalCompletion(agent);
+ if (hasValidSignal) {
+ // Agent completed normally but we missed the signal
+ this.logger.info({ agentId: agent.id }, 'Agent has valid completion signal, processing...');
+ await this.outputHandler.handleCompletion(agent.id, agent.provider);
+ } else {
+ // Truly crashed
+ await this.outputHandler.handleAgentError(agent.id, 'Agent process terminated unexpectedly');
+ }
}
}
}
`;
const DUMMY_COMMENTS: ReviewComment[] = [
{
id: "c1",
filePath: "src/agent/output-handler.ts",
lineNumber: 77,
lineType: "added",
body: "Consider using a WeakMap here to avoid memory leaks if agent references are garbage collected. Though since we clean up in handleAgentError and completion, this is probably fine.",
author: "agent:review-bot",
createdAt: "2026-02-09T10:30:00Z",
resolved: false,
},
{
id: "c2",
filePath: "src/agent/output-handler.ts",
lineNumber: 112,
lineType: "added",
body: "Dynamic import of fs/promises on every call is wasteful. This should be a top-level import since it's a Node built-in and always available.",
author: "agent:review-bot",
createdAt: "2026-02-09T10:30:00Z",
resolved: false,
},
{
id: "c3",
filePath: "src/agent/output-handler.ts",
lineNumber: 131,
lineType: "added",
body: "Bug: `Buffer.byteLength` gives byte length but `content.slice()` works on character offsets. If the file contains multi-byte characters, the position tracking will drift. Use character length consistently, or switch to byte-based reads with `fs.read()`.",
author: "agent:review-bot",
createdAt: "2026-02-09T10:31:00Z",
resolved: false,
},
{
id: "c4",
filePath: "src/agent/manager.ts",
lineNumber: 156,
lineType: "added",
body: "Good approach. Checking the signal file before marking as crashed eliminates the race condition where the process exits before we can read its output.",
author: "agent:review-bot",
createdAt: "2026-02-09T10:32:00Z",
resolved: true,
},
{
id: "c5",
filePath: "src/agent/manager.ts",
lineNumber: 180,
lineType: "added",
body: "The log message says 'processing...' but doesn't indicate what processing means. Should clarify that this triggers handleCompletion which will update the agent's status based on the signal content.",
author: "agent:review-bot",
createdAt: "2026-02-09T10:33:00Z",
resolved: false,
},
];
const files = parseUnifiedDiff(RAW_DIFF);
export const DUMMY_REVIEW: ReviewSummary = {
id: "review-1",
title: "fix(agent): Eliminate race condition in completion handling",
description:
"Introduces incremental file position tracking to avoid reading partial lines from agent output files. Also adds signal.json checking during reconciliation to prevent false crash marking when agents complete between process checks.",
author: "agent:slim-wildebeest",
status: "pending",
comments: DUMMY_COMMENTS,
files,
createdAt: "2026-02-09T10:00:00Z",
sourceBranch: "fix/completion-race-condition",
targetBranch: "main",
};

View File

@@ -0,0 +1 @@
export { ReviewTab } from "./ReviewTab";

View File

@@ -0,0 +1,93 @@
import type { FileDiff, DiffHunk, DiffLine } from "./types";
/**
* Parse a unified diff string into structured FileDiff objects.
*/
export function parseUnifiedDiff(raw: string): FileDiff[] {
const files: FileDiff[] = [];
const fileChunks = raw.split(/^diff --git /m).filter(Boolean);
for (const chunk of fileChunks) {
const lines = chunk.split("\n");
// Extract paths from first line: "a/path b/path"
const headerMatch = lines[0]?.match(/^a\/(.+?) b\/(.+)$/);
if (!headerMatch) continue;
const oldPath = headerMatch[1];
const newPath = headerMatch[2];
const hunks: DiffHunk[] = [];
let additions = 0;
let deletions = 0;
let i = 1;
// Skip to first hunk header
while (i < lines.length && !lines[i].startsWith("@@")) {
i++;
}
while (i < lines.length) {
const hunkMatch = lines[i].match(
/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@(.*)$/
);
if (!hunkMatch) {
i++;
continue;
}
const oldStart = parseInt(hunkMatch[1], 10);
const oldCount = parseInt(hunkMatch[2] ?? "1", 10);
const newStart = parseInt(hunkMatch[3], 10);
const newCount = parseInt(hunkMatch[4] ?? "1", 10);
const header = lines[i];
const hunkLines: DiffLine[] = [];
let oldLine = oldStart;
let newLine = newStart;
i++;
while (i < lines.length && !lines[i].startsWith("@@") && !lines[i].startsWith("diff --git ")) {
const line = lines[i];
if (line.startsWith("+")) {
hunkLines.push({
type: "added",
content: line.slice(1),
oldLineNumber: null,
newLineNumber: newLine,
});
newLine++;
additions++;
} else if (line.startsWith("-")) {
hunkLines.push({
type: "removed",
content: line.slice(1),
oldLineNumber: oldLine,
newLineNumber: null,
});
oldLine++;
deletions++;
} else if (line.startsWith(" ") || line === "") {
hunkLines.push({
type: "context",
content: line.startsWith(" ") ? line.slice(1) : line,
oldLineNumber: oldLine,
newLineNumber: newLine,
});
oldLine++;
newLine++;
} else {
// Likely "\ No newline at end of file" or similar
i++;
continue;
}
i++;
}
hunks.push({ header, oldStart, oldCount, newStart, newCount, lines: hunkLines });
}
files.push({ oldPath, newPath, hunks, additions, deletions });
}
return files;
}

View File

@@ -0,0 +1,49 @@
export interface DiffHunk {
header: string;
oldStart: number;
oldCount: number;
newStart: number;
newCount: number;
lines: DiffLine[];
}
export interface DiffLine {
type: "added" | "removed" | "context";
content: string;
oldLineNumber: number | null;
newLineNumber: number | null;
}
export interface FileDiff {
oldPath: string;
newPath: string;
hunks: DiffHunk[];
additions: number;
deletions: number;
}
export interface ReviewComment {
id: string;
filePath: string;
lineNumber: number; // new-side line number (or old-side for deletions)
lineType: "added" | "removed" | "context";
body: string;
author: string;
createdAt: string;
resolved: boolean;
}
export type ReviewStatus = "pending" | "approved" | "changes_requested";
export interface ReviewSummary {
id: string;
title: string;
description: string;
author: string;
status: ReviewStatus;
comments: ReviewComment[];
files: FileDiff[];
createdAt: string;
sourceBranch: string;
targetBranch: string;
}

View File

@@ -0,0 +1,36 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2",
{
variants: {
variant: {
default:
"border-transparent bg-primary text-primary-foreground hover:bg-primary/80",
secondary:
"border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80",
destructive:
"border-transparent bg-destructive text-destructive-foreground hover:bg-destructive/80",
outline: "text-foreground",
},
},
defaultVariants: {
variant: "default",
},
}
)
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return (
<div className={cn(badgeVariants({ variant }), className)} {...props} />
)
}
export { Badge, badgeVariants }

View File

@@ -0,0 +1,56 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-destructive-foreground hover:bg-destructive/90",
outline:
"border border-input bg-background hover:bg-accent hover:text-accent-foreground",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost: "hover:bg-accent hover:text-accent-foreground",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-10 px-4 py-2",
sm: "h-9 rounded-md px-3",
lg: "h-11 rounded-md px-8",
icon: "h-10 w-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
export interface ButtonProps
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
VariantProps<typeof buttonVariants> {
asChild?: boolean
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, asChild = false, ...props }, ref) => {
const Comp = asChild ? Slot : "button"
return (
<Comp
className={cn(buttonVariants({ variant, size, className }))}
ref={ref}
{...props}
/>
)
}
)
Button.displayName = "Button"
export { Button, buttonVariants }

View File

@@ -0,0 +1,79 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Card = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"rounded-lg border bg-card text-card-foreground shadow-sm",
className
)}
{...props}
/>
))
Card.displayName = "Card"
const CardHeader = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex flex-col space-y-1.5 p-6", className)}
{...props}
/>
))
CardHeader.displayName = "CardHeader"
const CardTitle = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
"text-2xl font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
CardTitle.displayName = "CardTitle"
const CardDescription = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
CardDescription.displayName = "CardDescription"
const CardContent = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div ref={ref} className={cn("p-6 pt-0", className)} {...props} />
))
CardContent.displayName = "CardContent"
const CardFooter = React.forwardRef<
HTMLDivElement,
React.HTMLAttributes<HTMLDivElement>
>(({ className, ...props }, ref) => (
<div
ref={ref}
className={cn("flex items-center p-6 pt-0", className)}
{...props}
/>
))
CardFooter.displayName = "CardFooter"
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }

View File

@@ -0,0 +1,120 @@
import * as React from "react"
import * as DialogPrimitive from "@radix-ui/react-dialog"
import { X } from "lucide-react"
import { cn } from "@/lib/utils"
const Dialog = DialogPrimitive.Root
const DialogTrigger = DialogPrimitive.Trigger
const DialogPortal = DialogPrimitive.Portal
const DialogClose = DialogPrimitive.Close
const DialogOverlay = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Overlay>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Overlay
ref={ref}
className={cn(
"fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0",
className
)}
{...props}
/>
))
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
const DialogContent = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
>(({ className, children, ...props }, ref) => (
<DialogPortal>
<DialogOverlay />
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg",
className
)}
{...props}
>
{children}
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
<X className="h-4 w-4" />
<span className="sr-only">Close</span>
</DialogPrimitive.Close>
</DialogPrimitive.Content>
</DialogPortal>
))
DialogContent.displayName = DialogPrimitive.Content.displayName
const DialogHeader = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col space-y-1.5 text-center sm:text-left",
className
)}
{...props}
/>
)
DialogHeader.displayName = "DialogHeader"
const DialogFooter = ({
className,
...props
}: React.HTMLAttributes<HTMLDivElement>) => (
<div
className={cn(
"flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2",
className
)}
{...props}
/>
)
DialogFooter.displayName = "DialogFooter"
const DialogTitle = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Title>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Title
ref={ref}
className={cn(
"text-lg font-semibold leading-none tracking-tight",
className
)}
{...props}
/>
))
DialogTitle.displayName = DialogPrimitive.Title.displayName
const DialogDescription = React.forwardRef<
React.ElementRef<typeof DialogPrimitive.Description>,
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description>
>(({ className, ...props }, ref) => (
<DialogPrimitive.Description
ref={ref}
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
))
DialogDescription.displayName = DialogPrimitive.Description.displayName
export {
Dialog,
DialogPortal,
DialogOverlay,
DialogClose,
DialogTrigger,
DialogContent,
DialogHeader,
DialogFooter,
DialogTitle,
DialogDescription,
}

View File

@@ -0,0 +1,198 @@
import * as React from "react"
import * as DropdownMenuPrimitive from "@radix-ui/react-dropdown-menu"
import { Check, ChevronRight, Circle } from "lucide-react"
import { cn } from "@/lib/utils"
const DropdownMenu = DropdownMenuPrimitive.Root
const DropdownMenuTrigger = DropdownMenuPrimitive.Trigger
const DropdownMenuGroup = DropdownMenuPrimitive.Group
const DropdownMenuPortal = DropdownMenuPrimitive.Portal
const DropdownMenuSub = DropdownMenuPrimitive.Sub
const DropdownMenuRadioGroup = DropdownMenuPrimitive.RadioGroup
const DropdownMenuSubTrigger = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubTrigger>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}
>(({ className, inset, children, ...props }, ref) => (
<DropdownMenuPrimitive.SubTrigger
ref={ref}
className={cn(
"flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none focus:bg-accent data-[state=open]:bg-accent [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
>
{children}
<ChevronRight className="ml-auto" />
</DropdownMenuPrimitive.SubTrigger>
))
DropdownMenuSubTrigger.displayName =
DropdownMenuPrimitive.SubTrigger.displayName
const DropdownMenuSubContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.SubContent>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.SubContent>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.SubContent
ref={ref}
className={cn(
"z-50 min-w-[8rem] overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
))
DropdownMenuSubContent.displayName =
DropdownMenuPrimitive.SubContent.displayName
const DropdownMenuContent = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Content>
>(({ className, sideOffset = 4, ...props }, ref) => (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-50 max-h-[var(--radix-dropdown-menu-content-available-height)] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-dropdown-menu-content-transform-origin]",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
))
DropdownMenuContent.displayName = DropdownMenuPrimitive.Content.displayName
const DropdownMenuItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Item
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuItem.displayName = DropdownMenuPrimitive.Item.displayName
const DropdownMenuCheckboxItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.CheckboxItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.CheckboxItem>
>(({ className, children, checked, ...props }, ref) => (
<DropdownMenuPrimitive.CheckboxItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
checked={checked}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
))
DropdownMenuCheckboxItem.displayName =
DropdownMenuPrimitive.CheckboxItem.displayName
const DropdownMenuRadioItem = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.RadioItem>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.RadioItem>
>(({ className, children, ...props }, ref) => (
<DropdownMenuPrimitive.RadioItem
ref={ref}
className={cn(
"relative flex cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none transition-colors focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<Circle className="h-2 w-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
))
DropdownMenuRadioItem.displayName = DropdownMenuPrimitive.RadioItem.displayName
const DropdownMenuLabel = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}
>(({ className, inset, ...props }, ref) => (
<DropdownMenuPrimitive.Label
ref={ref}
className={cn(
"px-2 py-1.5 text-sm font-semibold",
inset && "pl-8",
className
)}
{...props}
/>
))
DropdownMenuLabel.displayName = DropdownMenuPrimitive.Label.displayName
const DropdownMenuSeparator = React.forwardRef<
React.ElementRef<typeof DropdownMenuPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof DropdownMenuPrimitive.Separator>
>(({ className, ...props }, ref) => (
<DropdownMenuPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
DropdownMenuSeparator.displayName = DropdownMenuPrimitive.Separator.displayName
const DropdownMenuShortcut = ({
className,
...props
}: React.HTMLAttributes<HTMLSpanElement>) => {
return (
<span
className={cn("ml-auto text-xs tracking-widest opacity-60", className)}
{...props}
/>
)
}
DropdownMenuShortcut.displayName = "DropdownMenuShortcut"
export {
DropdownMenu,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuGroup,
DropdownMenuPortal,
DropdownMenuSub,
DropdownMenuSubContent,
DropdownMenuSubTrigger,
DropdownMenuRadioGroup,
}

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Input = React.forwardRef<HTMLInputElement, React.ComponentProps<"input">>(
({ className, type, ...props }, ref) => {
return (
<input
type={type}
className={cn(
"flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
}
)
Input.displayName = "Input"
export { Input }

View File

@@ -0,0 +1,24 @@
import * as React from "react"
import * as LabelPrimitive from "@radix-ui/react-label"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const labelVariants = cva(
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
)
const Label = React.forwardRef<
React.ElementRef<typeof LabelPrimitive.Root>,
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
VariantProps<typeof labelVariants>
>(({ className, ...props }, ref) => (
<LabelPrimitive.Root
ref={ref}
className={cn(labelVariants(), className)}
{...props}
/>
))
Label.displayName = LabelPrimitive.Root.displayName
export { Label }

View File

@@ -0,0 +1,158 @@
import * as React from "react"
import * as SelectPrimitive from "@radix-ui/react-select"
import { Check, ChevronDown, ChevronUp } from "lucide-react"
import { cn } from "@/lib/utils"
const Select = SelectPrimitive.Root
const SelectGroup = SelectPrimitive.Group
const SelectValue = SelectPrimitive.Value
const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background data-[placeholder]:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1",
className
)}
{...props}
>
{children}
<SelectPrimitive.Icon asChild>
<ChevronDown className="h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
))
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
const SelectScrollUpButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollUpButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronUp className="h-4 w-4" />
</SelectPrimitive.ScrollUpButton>
))
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
const SelectScrollDownButton = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
>(({ className, ...props }, ref) => (
<SelectPrimitive.ScrollDownButton
ref={ref}
className={cn(
"flex cursor-default items-center justify-center py-1",
className
)}
{...props}
>
<ChevronDown className="h-4 w-4" />
</SelectPrimitive.ScrollDownButton>
))
SelectScrollDownButton.displayName =
SelectPrimitive.ScrollDownButton.displayName
const SelectContent = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Content>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
>(({ className, children, position = "popper", ...props }, ref) => (
<SelectPrimitive.Portal>
<SelectPrimitive.Content
ref={ref}
className={cn(
"relative z-50 max-h-[--radix-select-content-available-height] min-w-[8rem] overflow-y-auto overflow-x-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 origin-[--radix-select-content-transform-origin]",
position === "popper" &&
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
className
)}
position={position}
{...props}
>
<SelectScrollUpButton />
<SelectPrimitive.Viewport
className={cn(
"p-1",
position === "popper" &&
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]"
)}
>
{children}
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectPrimitive.Content>
</SelectPrimitive.Portal>
))
SelectContent.displayName = SelectPrimitive.Content.displayName
const SelectLabel = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Label>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Label
ref={ref}
className={cn("py-1.5 pl-8 pr-2 text-sm font-semibold", className)}
{...props}
/>
))
SelectLabel.displayName = SelectPrimitive.Label.displayName
const SelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
>(({ className, children, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
className
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
<SelectPrimitive.ItemIndicator>
<Check className="h-4 w-4" />
</SelectPrimitive.ItemIndicator>
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
))
SelectItem.displayName = SelectPrimitive.Item.displayName
const SelectSeparator = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Separator>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
>(({ className, ...props }, ref) => (
<SelectPrimitive.Separator
ref={ref}
className={cn("-mx-1 my-1 h-px bg-muted", className)}
{...props}
/>
))
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
export {
Select,
SelectGroup,
SelectValue,
SelectTrigger,
SelectContent,
SelectLabel,
SelectItem,
SelectSeparator,
SelectScrollUpButton,
SelectScrollDownButton,
}

View File

@@ -0,0 +1,13 @@
import { Toaster as SonnerToaster } from "sonner";
export function Toaster() {
return (
<SonnerToaster
position="bottom-right"
richColors
toastOptions={{
className: "font-sans",
}}
/>
);
}

View File

@@ -0,0 +1,22 @@
import * as React from "react"
import { cn } from "@/lib/utils"
const Textarea = React.forwardRef<
HTMLTextAreaElement,
React.ComponentProps<"textarea">
>(({ className, ...props }, ref) => {
return (
<textarea
className={cn(
"flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-base ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 md:text-sm",
className
)}
ref={ref}
{...props}
/>
)
})
Textarea.displayName = "Textarea"
export { Textarea }

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 { useLiveUpdates } from './useLiveUpdates.js';
export { useRefineAgent } from './useRefineAgent.js';
export { useSubscriptionWithErrorHandling } from './useSubscriptionWithErrorHandling.js';
export type {
RefineAgentState,
SpawnRefineAgentOptions,
UseRefineAgentResult,
} from './useRefineAgent.js';

View File

@@ -0,0 +1,137 @@
import { useRef, useCallback, useEffect, useState } from "react";
import { trpc } from "@/lib/trpc";
import { toast } from "sonner";
interface UseAutoSaveOptions {
debounceMs?: number;
onSaved?: () => void;
onError?: (error: Error) => void;
}
export function useAutoSave({ debounceMs = 1000, onSaved, onError }: UseAutoSaveOptions = {}) {
const [lastError, setLastError] = useState<Error | null>(null);
const [retryCount, setRetryCount] = useState(0);
const utils = trpc.useUtils();
const updateMutation = trpc.updatePage.useMutation({
onMutate: async (variables) => {
// Cancel any outgoing refetches
await utils.getPage.cancel({ id: variables.id });
// Snapshot the previous value
const previousPage = utils.getPage.getData({ id: variables.id });
// Optimistically update the page in cache
if (previousPage) {
const optimisticUpdate = {
...previousPage,
...(variables.title !== undefined && { title: variables.title }),
...(variables.content !== undefined && { content: variables.content }),
updatedAt: new Date().toISOString(),
};
utils.getPage.setData({ id: variables.id }, optimisticUpdate);
}
return { previousPage };
},
onSuccess: () => {
setLastError(null);
setRetryCount(0);
onSaved?.();
},
onError: (error, variables, context) => {
// Revert optimistic update
if (context?.previousPage) {
utils.getPage.setData({ id: variables.id }, context.previousPage);
}
setLastError(error);
onError?.(error);
},
// Invalidation handled globally by MutationCache
});
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingRef = useRef<{
id: string;
title?: string;
content?: string | null;
} | null>(null);
const flush = useCallback(async () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
if (pendingRef.current) {
const data = pendingRef.current;
pendingRef.current = null;
try {
await updateMutation.mutateAsync(data);
return;
} catch (error) {
// Retry logic for transient failures
if (retryCount < 2 && error instanceof Error) {
setRetryCount(prev => prev + 1);
pendingRef.current = data; // Restore data for retry
// Exponential backoff: 1s, 2s
const delay = 1000 * Math.pow(2, retryCount);
setTimeout(() => void flush(), delay);
return;
}
// Final failure - show user feedback
toast.error(`Failed to save: ${error instanceof Error ? error.message : 'Unknown error'}`, {
action: {
label: 'Retry',
onClick: () => {
setRetryCount(0);
pendingRef.current = data;
void flush();
},
},
});
throw error;
}
}
return Promise.resolve();
}, [updateMutation, retryCount]);
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,
lastError,
hasError: lastError !== null,
retryCount,
};
}

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,49 @@
import { toast } from 'sonner';
import { trpc } from '@/lib/trpc';
import { useSubscriptionWithErrorHandling } from './useSubscriptionWithErrorHandling';
export interface LiveUpdateRule {
/** Event type prefix to match, e.g. 'task:' or 'agent:' */
prefix: string;
/** tRPC query keys to invalidate when a matching event arrives */
invalidate: string[];
}
/**
* Opens a single `onEvent` SSE subscription and routes events to query invalidations
* based on prefix-matching rules. Drops heartbeat events silently.
*
* Encapsulates error toast + reconnect config so pages don't duplicate boilerplate.
*/
export function useLiveUpdates(rules: LiveUpdateRule[]) {
const utils = trpc.useUtils();
return useSubscriptionWithErrorHandling(
() => trpc.onEvent.useSubscription(undefined),
{
onData: (event) => {
// Drop heartbeats and malformed events (missing type)
if (!event?.type || event.type === '__heartbeat__') return;
for (const rule of rules) {
if (event.type.startsWith(rule.prefix)) {
for (const key of rule.invalidate) {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
void (utils as any)[key]?.invalidate();
}
}
}
},
onError: (error) => {
toast.error('Live updates disconnected. Refresh to reconnect.', {
id: 'sub-error',
duration: Infinity,
});
console.error('Live updates subscription error:', error);
},
onStarted: () => toast.dismiss('sub-error'),
autoReconnect: true,
maxReconnectAttempts: 5,
},
);
}

View File

@@ -0,0 +1,124 @@
import { useCallback } from 'react';
import { toast } from 'sonner';
import type { TRPCClientError } from '@trpc/client';
/**
* Options for configuring optimistic mutations
*/
export interface OptimisticMutationOptions<TData, TVariables, TContext = unknown> {
/** Function to apply optimistic update */
onOptimisticUpdate?: (variables: TVariables) => TContext;
/** Function to revert optimistic update on error */
onRevert?: (context: TContext | undefined) => void;
/** Function to clean up after mutation settles */
onCleanup?: () => void;
/** Success toast message */
successMessage?: string;
/** Error toast message (will be appended with actual error) */
errorMessage?: string;
/** Whether to show toast notifications */
showToasts?: boolean;
}
/**
* Higher-order function that wraps a tRPC mutation with optimistic updates
*/
export function useOptimisticMutation<TData, TVariables, TContext = unknown>(
mutation: any, // tRPC mutation
options: OptimisticMutationOptions<TData, TVariables, TContext> = {}
) {
const {
onOptimisticUpdate,
onRevert,
onCleanup,
successMessage,
errorMessage,
showToasts = true,
} = options;
const mutate = useCallback(
(variables: TVariables) => {
let context: TContext | undefined;
return mutation.mutate(variables, {
onMutate: async (vars: TVariables) => {
if (onOptimisticUpdate) {
context = onOptimisticUpdate(vars);
}
return { context };
},
onSuccess: () => {
if (successMessage && showToasts) {
toast.success(successMessage);
}
},
onError: (error: TRPCClientError<any>) => {
if (onRevert && context !== undefined) {
onRevert(context);
}
if (showToasts) {
const message = errorMessage
? `${errorMessage}: ${error.message}`
: `Error: ${error.message}`;
toast.error(message);
}
},
onSettled: () => {
if (onCleanup) {
onCleanup();
}
},
});
},
[mutation, onOptimisticUpdate, onRevert, onCleanup, successMessage, errorMessage, showToasts]
);
const mutateAsync = useCallback(
async (variables: TVariables): Promise<TData> => {
let context: TContext | undefined;
try {
if (onOptimisticUpdate) {
context = onOptimisticUpdate(variables);
}
const result = await mutation.mutateAsync(variables);
if (successMessage && showToasts) {
toast.success(successMessage);
}
return result;
} catch (error) {
if (onRevert && context !== undefined) {
onRevert(context);
}
if (showToasts) {
const message = errorMessage && error instanceof Error
? `${errorMessage}: ${error.message}`
: `Error: ${error instanceof Error ? error.message : 'Unknown error'}`;
toast.error(message);
}
throw error;
} finally {
if (onCleanup) {
onCleanup();
}
}
},
[mutation, onOptimisticUpdate, onRevert, onCleanup, successMessage, errorMessage, showToasts]
);
return {
mutate,
mutateAsync,
isPending: mutation.isPending,
error: mutation.error,
isError: mutation.isError,
isSuccess: mutation.isSuccess,
reset: mutation.reset,
};
}

View File

@@ -0,0 +1,149 @@
import { useRef, useCallback, useEffect, useState } from "react";
import { trpc } from "@/lib/trpc";
import { toast } from "sonner";
interface UsePhaseAutoSaveOptions {
debounceMs?: number;
onSaved?: () => void;
onError?: (error: Error) => void;
}
export function usePhaseAutoSave({ debounceMs = 1000, onSaved, onError }: UsePhaseAutoSaveOptions = {}) {
const [lastError, setLastError] = useState<Error | null>(null);
const [retryCount, setRetryCount] = useState(0);
const utils = trpc.useUtils();
const updateMutation = trpc.updatePhase.useMutation({
onMutate: async (variables) => {
// Cancel any outgoing refetches
await utils.getPhase.cancel({ id: variables.id });
await utils.listPhases.cancel();
// Snapshot previous values
const previousPhase = utils.getPhase.getData({ id: variables.id });
const previousPhases = utils.listPhases.getData();
// Optimistically update phase in cache
if (previousPhase) {
const optimisticUpdate = {
...previousPhase,
...(variables.content !== undefined && { content: variables.content }),
updatedAt: new Date().toISOString(),
};
utils.getPhase.setData({ id: variables.id }, optimisticUpdate);
// Also update in the phases list if present
if (previousPhases) {
utils.listPhases.setData(undefined,
previousPhases.map(phase =>
phase.id === variables.id ? optimisticUpdate : phase
)
);
}
}
return { previousPhase, previousPhases };
},
onSuccess: () => {
setLastError(null);
setRetryCount(0);
onSaved?.();
},
onError: (error, variables, context) => {
// Revert optimistic updates
if (context?.previousPhase) {
utils.getPhase.setData({ id: variables.id }, context.previousPhase);
}
if (context?.previousPhases) {
utils.listPhases.setData(undefined, context.previousPhases);
}
setLastError(error);
onError?.(error);
},
// Invalidation handled globally by MutationCache
});
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingRef = useRef<{
id: string;
content?: string | null;
} | null>(null);
const flush = useCallback(async () => {
if (timerRef.current) {
clearTimeout(timerRef.current);
timerRef.current = null;
}
if (pendingRef.current) {
const data = pendingRef.current;
pendingRef.current = null;
try {
await updateMutation.mutateAsync(data);
return;
} catch (error) {
// Retry logic for transient failures
if (retryCount < 2 && error instanceof Error) {
setRetryCount(prev => prev + 1);
pendingRef.current = data; // Restore data for retry
// Exponential backoff: 1s, 2s
const delay = 1000 * Math.pow(2, retryCount);
setTimeout(() => void flush(), delay);
return;
}
// Final failure - show user feedback
toast.error(`Failed to save phase: ${error instanceof Error ? error.message : 'Unknown error'}`, {
action: {
label: 'Retry',
onClick: () => {
setRetryCount(0);
pendingRef.current = data;
void flush();
},
},
});
throw error;
}
}
return Promise.resolve();
}, [updateMutation, retryCount]);
const save = useCallback(
(id: string, data: { 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) {
const data = pendingRef.current;
pendingRef.current = null;
updateMutation.mutate(data);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return {
save,
flush,
isSaving: updateMutation.isPending,
lastError,
hasError: lastError !== null,
retryCount,
};
}

View File

@@ -0,0 +1,284 @@
import { useCallback, useMemo, useRef } from 'react';
import { trpc } from '@/lib/trpc';
import type { PendingQuestions, ChangeSet } from '@codewalk-district/shared';
export type RefineAgentState = 'none' | 'running' | 'waiting' | 'completed' | 'crashed';
type RefineAgent = NonNullable<ReturnType<typeof trpc.getActiveRefineAgent.useQuery>['data']>;
export interface SpawnRefineAgentOptions {
initiativeId: string;
instruction?: string;
}
export interface UseRefineAgentResult {
/** Current refine agent for the initiative */
agent: RefineAgent | null;
/** Current state of the refine agent */
state: RefineAgentState;
/** Questions from the agent (when state is 'waiting') */
questions: PendingQuestions | null;
/** Latest applied change set (when state is 'completed') */
changeSet: ChangeSet | 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;
};
/** Stop the current agent (kills process, clears questions) */
stop: {
mutate: () => void;
isPending: boolean;
};
/** Dismiss the current agent (sets userDismissedAt so it disappears) */
dismiss: () => void;
/** 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.
*/
export function useRefineAgent(initiativeId: string): UseRefineAgentResult {
const utils = trpc.useUtils();
// Query only the active refine agent for this initiative (server-side filtered)
const agentQuery = trpc.getActiveRefineAgent.useQuery({ initiativeId });
const agent = agentQuery.data ?? null;
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 change sets from DB when completed
const changeSetsQuery = trpc.listChangeSets.useQuery(
{ agentId: agent?.id ?? '' },
{ enabled: state === 'completed' && !!agent },
);
// Fetch result when completed
const resultQuery = trpc.getAgentResult.useQuery(
{ id: agent?.id ?? '' },
{ enabled: state === 'completed' && !!agent },
);
// Get latest applied change set
const changeSet = useMemo(() => {
if (!changeSetsQuery.data || changeSetsQuery.data.length === 0) return null;
return changeSetsQuery.data.find((cs) => cs.status === 'applied') ?? null;
}, [changeSetsQuery.data]);
const result = useMemo(() => {
if (!resultQuery.data?.success || !resultQuery.data.message) return null;
return resultQuery.data.message;
}, [resultQuery.data]);
// Spawn mutation
const spawnMutation = trpc.spawnArchitectRefine.useMutation({
onMutate: async ({ initiativeId, instruction }) => {
// Cancel outgoing refetches
await utils.listAgents.cancel();
// Snapshot previous value
const previousAgents = utils.listAgents.getData();
// Optimistically add a temporary agent
const tempAgent = {
id: `temp-${Date.now()}`,
name: 'refine',
mode: 'refine' as const,
status: 'running' as const,
initiativeId,
taskId: null,
phaseId: null,
provider: 'claude',
accountId: null,
instruction: instruction || null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
userDismissedAt: null,
completedAt: null,
};
utils.listAgents.setData(undefined, (old = []) => [tempAgent, ...old]);
return { previousAgents };
},
onSuccess: () => {
// Agent will appear in the list after invalidation
},
onError: (err, variables, context) => {
// Revert optimistic update
if (context?.previousAgents) {
utils.listAgents.setData(undefined, context.previousAgents);
}
},
onSettled: () => {
void utils.listAgents.invalidate();
},
});
// Resume mutation
const resumeMutation = trpc.resumeAgent.useMutation({
onSuccess: () => {
void utils.listAgents.invalidate();
},
});
// Stop mutation — kills process and clears pending questions
const stopMutation = trpc.stopAgent.useMutation({
onSuccess: () => {
void utils.listAgents.invalidate();
void utils.listWaitingAgents.invalidate();
},
});
// Dismiss mutation — sets userDismissedAt so agent disappears from the list
const dismissMutation = trpc.dismissAgent.useMutation({
onMutate: async ({ id }) => {
// Cancel outgoing refetches
await utils.listAgents.cancel();
await utils.getActiveRefineAgent.cancel({ initiativeId });
// Snapshot previous values
const previousAgents = utils.listAgents.getData();
const previousRefineAgent = utils.getActiveRefineAgent.getData({ initiativeId });
// Optimistically remove the agent from the list
utils.listAgents.setData(undefined, (old = []) =>
old.filter(a => a.id !== id)
);
// Optimistically clear the active refine agent so the banner disappears immediately
utils.getActiveRefineAgent.setData({ initiativeId }, null);
return { previousAgents, previousRefineAgent };
},
onSuccess: () => {},
onError: (err, variables, context) => {
// Revert optimistic updates
if (context?.previousAgents) {
utils.listAgents.setData(undefined, context.previousAgents);
}
if (context?.previousRefineAgent !== undefined) {
utils.getActiveRefineAgent.setData({ initiativeId }, context.previousRefineAgent);
}
},
onSettled: () => {
void utils.listAgents.invalidate();
void utils.getActiveRefineAgent.invalidate({ initiativeId });
},
});
// Keep mutation functions in refs so the returned spawn/resume objects are
// stable across renders.
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 stopMutateRef = useRef(stopMutation.mutate);
stopMutateRef.current = stopMutation.mutate;
const dismissMutateRef = useRef(dismissMutation.mutate);
dismissMutateRef.current = dismissMutation.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 stopFn = useCallback(() => {
const a = agentRef.current;
if (a) {
stopMutateRef.current({ id: a.id });
}
}, []);
const stop = useMemo(() => ({
mutate: stopFn,
isPending: stopMutation.isPending,
}), [stopFn, stopMutation.isPending]);
const dismiss = useCallback(() => {
const a = agentRef.current;
if (a) {
dismissMutateRef.current({ id: a.id });
}
}, []);
const refresh = useCallback(() => {
void utils.getActiveRefineAgent.invalidate({ initiativeId });
void utils.listChangeSets.invalidate();
}, [utils, initiativeId]);
const isLoading = agentQuery.isLoading ||
(state === 'waiting' && questionsQuery.isLoading) ||
(state === 'completed' && (resultQuery.isLoading || changeSetsQuery.isLoading));
return {
agent,
state,
questions: questionsQuery.data ?? null,
changeSet,
result,
spawn,
resume,
stop,
dismiss,
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.onEvent.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,
}));
},
};
}

167
apps/web/src/index.css Normal file
View File

@@ -0,0 +1,167 @@
@tailwind base;
@tailwind components;
@tailwind utilities;
@layer base {
:root {
--background: 0 0% 100%;
--foreground: 0 0% 3.9%;
--card: 0 0% 100%;
--card-foreground: 0 0% 3.9%;
--popover: 0 0% 100%;
--popover-foreground: 0 0% 3.9%;
--primary: 0 0% 9%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 96.1%;
--secondary-foreground: 0 0% 9%;
--muted: 0 0% 96.1%;
--muted-foreground: 0 0% 45.1%;
--accent: 0 0% 96.1%;
--accent-foreground: 0 0% 9%;
--destructive: 0 84.2% 60.2%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 89.8%;
--input: 0 0% 89.8%;
--ring: 0 0% 3.9%;
--radius: 0.5rem;
}
.dark {
--background: 0 0% 3.9%;
--foreground: 0 0% 98%;
--card: 0 0% 3.9%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 3.9%;
--popover-foreground: 0 0% 98%;
--primary: 0 0% 98%;
--primary-foreground: 0 0% 9%;
--secondary: 0 0% 14.9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 14.9%;
--muted-foreground: 0 0% 63.9%;
--accent: 0 0% 14.9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 62.8% 30.6%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 14.9%;
--input: 0 0% 14.9%;
--ring: 0 0% 83.1%;
}
}
@layer base {
* {
@apply border-border;
}
body {
@apply bg-background text-foreground;
margin: 0;
min-width: 320px;
height: 100vh;
overflow: hidden;
}
}
/* 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); }
/* Inline code styling — remove prose backtick pseudo-elements */
.ProseMirror :not(pre) > code {
background-color: hsl(var(--muted));
padding: 0.15em 0.35em;
border-radius: 0.25rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.875em;
}
.ProseMirror :not(pre) > code::before,
.ProseMirror :not(pre) > code::after {
content: none;
}
.dark .ProseMirror :not(pre) > code {
background-color: hsl(var(--muted));
}
/* Code block styling */
.ProseMirror pre {
background-color: hsl(var(--muted));
padding: 1rem;
border-radius: 0.375rem;
overflow-x: auto;
}
.ProseMirror pre code {
background: none;
padding: 0;
border-radius: 0;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.875em;
color: inherit;
}
.ProseMirror pre code::before,
.ProseMirror pre code::after {
content: none;
}

Some files were not shown because too many files have changed in this diff Show More