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:
16
apps/web/components.json
Normal file
16
apps/web/components.json
Normal 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
12
apps/web/index.html
Normal 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
54
apps/web/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
6
apps/web/postcss.config.js
Normal file
6
apps/web/postcss.config.js
Normal file
@@ -0,0 +1,6 @@
|
||||
export default {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {},
|
||||
},
|
||||
};
|
||||
8
apps/web/src/App.tsx
Normal file
8
apps/web/src/App.tsx
Normal file
@@ -0,0 +1,8 @@
|
||||
import { RouterProvider } from '@tanstack/react-router'
|
||||
import { router } from './router'
|
||||
|
||||
function App() {
|
||||
return <RouterProvider router={router} />
|
||||
}
|
||||
|
||||
export default App
|
||||
207
apps/web/src/components/AccountCard.tsx
Normal file
207
apps/web/src/components/AccountCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
63
apps/web/src/components/ActionMenu.tsx
Normal file
63
apps/web/src/components/ActionMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
73
apps/web/src/components/AgentActions.tsx
Normal file
73
apps/web/src/components/AgentActions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
241
apps/web/src/components/AgentOutputViewer.tsx
Normal file
241
apps/web/src/components/AgentOutputViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
139
apps/web/src/components/ChangeSetBanner.tsx
Normal file
139
apps/web/src/components/ChangeSetBanner.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
168
apps/web/src/components/CreateInitiativeDialog.tsx
Normal file
168
apps/web/src/components/CreateInitiativeDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
96
apps/web/src/components/DecisionList.tsx
Normal file
96
apps/web/src/components/DecisionList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
apps/web/src/components/DependencyIndicator.tsx
Normal file
28
apps/web/src/components/DependencyIndicator.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
apps/web/src/components/ErrorBoundary.tsx
Normal file
54
apps/web/src/components/ErrorBoundary.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
326
apps/web/src/components/ExecutionTab.tsx
Normal file
326
apps/web/src/components/ExecutionTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
apps/web/src/components/FreeTextInput.tsx
Normal file
39
apps/web/src/components/FreeTextInput.tsx
Normal 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..."}
|
||||
/>
|
||||
);
|
||||
}
|
||||
171
apps/web/src/components/InboxDetailPanel.tsx
Normal file
171
apps/web/src/components/InboxDetailPanel.tsx
Normal 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">
|
||||
→ 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 →
|
||||
</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>
|
||||
);
|
||||
}
|
||||
195
apps/web/src/components/InboxList.tsx
Normal file
195
apps/web/src/components/InboxList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
152
apps/web/src/components/InitiativeCard.tsx
Normal file
152
apps/web/src/components/InitiativeCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
214
apps/web/src/components/InitiativeHeader.tsx
Normal file
214
apps/web/src/components/InitiativeHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
100
apps/web/src/components/InitiativeList.tsx
Normal file
100
apps/web/src/components/InitiativeList.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
68
apps/web/src/components/MessageCard.tsx
Normal file
68
apps/web/src/components/MessageCard.tsx
Normal 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">
|
||||
“{truncatePreview(preview)}”
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{formatRelativeTime(timestamp)}
|
||||
</span>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
155
apps/web/src/components/OptionGroup.tsx
Normal file
155
apps/web/src/components/OptionGroup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
apps/web/src/components/PhaseAccordion.tsx
Normal file
103
apps/web/src/components/PhaseAccordion.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
apps/web/src/components/ProgressBar.tsx
Normal file
34
apps/web/src/components/ProgressBar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
35
apps/web/src/components/ProgressPanel.tsx
Normal file
35
apps/web/src/components/ProgressPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
65
apps/web/src/components/ProjectPicker.tsx
Normal file
65
apps/web/src/components/ProjectPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
apps/web/src/components/QuestionForm.tsx
Normal file
104
apps/web/src/components/QuestionForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
125
apps/web/src/components/RefineSpawnDialog.tsx
Normal file
125
apps/web/src/components/RefineSpawnDialog.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
119
apps/web/src/components/RegisterProjectDialog.tsx
Normal file
119
apps/web/src/components/RegisterProjectDialog.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
apps/web/src/components/Skeleton.tsx
Normal file
11
apps/web/src/components/Skeleton.tsx
Normal 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)} />
|
||||
);
|
||||
}
|
||||
66
apps/web/src/components/SpawnArchitectDropdown.tsx
Normal file
66
apps/web/src/components/SpawnArchitectDropdown.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
39
apps/web/src/components/StatusBadge.tsx
Normal file
39
apps/web/src/components/StatusBadge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
apps/web/src/components/StatusDot.tsx
Normal file
76
apps/web/src/components/StatusDot.tsx
Normal 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}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
158
apps/web/src/components/TaskDetailModal.tsx
Normal file
158
apps/web/src/components/TaskDetailModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
103
apps/web/src/components/TaskRow.tsx
Normal file
103
apps/web/src/components/TaskRow.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
238
apps/web/src/components/editor/BlockDragHandle.tsx
Normal file
238
apps/web/src/components/editor/BlockDragHandle.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
186
apps/web/src/components/editor/BlockSelectionExtension.ts
Normal file
186
apps/web/src/components/editor/BlockSelectionExtension.ts
Normal 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;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
310
apps/web/src/components/editor/ContentTab.tsx
Normal file
310
apps/web/src/components/editor/ContentTab.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
50
apps/web/src/components/editor/DeleteSubpageDialog.tsx
Normal file
50
apps/web/src/components/editor/DeleteSubpageDialog.tsx
Normal 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 “{pageName}”. 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>
|
||||
);
|
||||
}
|
||||
52
apps/web/src/components/editor/PageBreadcrumb.tsx
Normal file
52
apps/web/src/components/editor/PageBreadcrumb.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
66
apps/web/src/components/editor/PageLinkDeletionDetector.ts
Normal file
66
apps/web/src/components/editor/PageLinkDeletionDetector.ts
Normal 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;
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
}
|
||||
75
apps/web/src/components/editor/PageLinkExtension.tsx
Normal file
75
apps/web/src/components/editor/PageLinkExtension.tsx
Normal 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);
|
||||
},
|
||||
});
|
||||
26
apps/web/src/components/editor/PageTitleContext.tsx
Normal file
26
apps/web/src/components/editor/PageTitleContext.tsx
Normal 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";
|
||||
}
|
||||
119
apps/web/src/components/editor/PageTree.tsx
Normal file
119
apps/web/src/components/editor/PageTree.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
apps/web/src/components/editor/PhaseContentEditor.tsx
Normal file
47
apps/web/src/components/editor/PhaseContentEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
155
apps/web/src/components/editor/RefineAgentPanel.tsx
Normal file
155
apps/web/src/components/editor/RefineAgentPanel.tsx
Normal 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;
|
||||
}
|
||||
88
apps/web/src/components/editor/SlashCommandList.tsx
Normal file
88
apps/web/src/components/editor/SlashCommandList.tsx
Normal 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";
|
||||
126
apps/web/src/components/editor/SlashCommands.ts
Normal file
126
apps/web/src/components/editor/SlashCommands.ts
Normal 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,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
122
apps/web/src/components/editor/TiptapEditor.tsx
Normal file
122
apps/web/src/components/editor/TiptapEditor.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
92
apps/web/src/components/editor/slash-command-items.ts
Normal file
92
apps/web/src/components/editor/slash-command-items.ts
Normal 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);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
149
apps/web/src/components/execution/ExecutionContext.tsx
Normal file
149
apps/web/src/components/execution/ExecutionContext.tsx
Normal 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;
|
||||
}
|
||||
87
apps/web/src/components/execution/PhaseActions.tsx
Normal file
87
apps/web/src/components/execution/PhaseActions.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
401
apps/web/src/components/execution/PhaseDetailPanel.tsx
Normal file
401
apps/web/src/components/execution/PhaseDetailPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
apps/web/src/components/execution/PhaseSidebarItem.tsx
Normal file
57
apps/web/src/components/execution/PhaseSidebarItem.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
110
apps/web/src/components/execution/PhaseWithTasks.tsx
Normal file
110
apps/web/src/components/execution/PhaseWithTasks.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
73
apps/web/src/components/execution/PhasesList.tsx
Normal file
73
apps/web/src/components/execution/PhasesList.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
133
apps/web/src/components/execution/PlanSection.tsx
Normal file
133
apps/web/src/components/execution/PlanSection.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
apps/web/src/components/execution/ProgressSidebar.tsx
Normal file
28
apps/web/src/components/execution/ProgressSidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
apps/web/src/components/execution/TaskModal.tsx
Normal file
34
apps/web/src/components/execution/TaskModal.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
7
apps/web/src/components/execution/index.ts
Normal file
7
apps/web/src/components/execution/index.ts
Normal 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";
|
||||
37
apps/web/src/components/pipeline/PipelineGraph.tsx
Normal file
37
apps/web/src/components/pipeline/PipelineGraph.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
52
apps/web/src/components/pipeline/PipelinePhaseGroup.tsx
Normal file
52
apps/web/src/components/pipeline/PipelinePhaseGroup.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
25
apps/web/src/components/pipeline/PipelineStageColumn.tsx
Normal file
25
apps/web/src/components/pipeline/PipelineStageColumn.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
114
apps/web/src/components/pipeline/PipelineTab.tsx
Normal file
114
apps/web/src/components/pipeline/PipelineTab.tsx
Normal 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} />;
|
||||
}
|
||||
49
apps/web/src/components/pipeline/PipelineTaskCard.tsx
Normal file
49
apps/web/src/components/pipeline/PipelineTaskCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
1
apps/web/src/components/pipeline/index.ts
Normal file
1
apps/web/src/components/pipeline/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { PipelineTab } from "./PipelineTab";
|
||||
71
apps/web/src/components/review/CommentForm.tsx
Normal file
71
apps/web/src/components/review/CommentForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
);
|
||||
72
apps/web/src/components/review/CommentThread.tsx
Normal file
72
apps/web/src/components/review/CommentThread.tsx
Normal 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" });
|
||||
}
|
||||
38
apps/web/src/components/review/DiffViewer.tsx
Normal file
38
apps/web/src/components/review/DiffViewer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
apps/web/src/components/review/FileCard.tsx
Normal file
86
apps/web/src/components/review/FileCard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
apps/web/src/components/review/HunkRows.tsx
Normal file
86
apps/web/src/components/review/HunkRows.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
138
apps/web/src/components/review/LineWithComments.tsx
Normal file
138
apps/web/src/components/review/LineWithComments.tsx
Normal 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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
176
apps/web/src/components/review/PreviewPanel.tsx
Normal file
176
apps/web/src/components/review/PreviewPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
213
apps/web/src/components/review/ReviewSidebar.tsx
Normal file
213
apps/web/src/components/review/ReviewSidebar.tsx
Normal 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">→</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>
|
||||
);
|
||||
}
|
||||
197
apps/web/src/components/review/ReviewTab.tsx
Normal file
197
apps/web/src/components/review/ReviewTab.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
202
apps/web/src/components/review/dummy-data.ts
Normal file
202
apps/web/src/components/review/dummy-data.ts
Normal 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",
|
||||
};
|
||||
1
apps/web/src/components/review/index.ts
Normal file
1
apps/web/src/components/review/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ReviewTab } from "./ReviewTab";
|
||||
93
apps/web/src/components/review/parse-diff.ts
Normal file
93
apps/web/src/components/review/parse-diff.ts
Normal 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;
|
||||
}
|
||||
49
apps/web/src/components/review/types.ts
Normal file
49
apps/web/src/components/review/types.ts
Normal 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;
|
||||
}
|
||||
36
apps/web/src/components/ui/badge.tsx
Normal file
36
apps/web/src/components/ui/badge.tsx
Normal 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 }
|
||||
56
apps/web/src/components/ui/button.tsx
Normal file
56
apps/web/src/components/ui/button.tsx
Normal 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 }
|
||||
79
apps/web/src/components/ui/card.tsx
Normal file
79
apps/web/src/components/ui/card.tsx
Normal 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 }
|
||||
120
apps/web/src/components/ui/dialog.tsx
Normal file
120
apps/web/src/components/ui/dialog.tsx
Normal 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,
|
||||
}
|
||||
198
apps/web/src/components/ui/dropdown-menu.tsx
Normal file
198
apps/web/src/components/ui/dropdown-menu.tsx
Normal 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,
|
||||
}
|
||||
22
apps/web/src/components/ui/input.tsx
Normal file
22
apps/web/src/components/ui/input.tsx
Normal 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 }
|
||||
24
apps/web/src/components/ui/label.tsx
Normal file
24
apps/web/src/components/ui/label.tsx
Normal 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 }
|
||||
158
apps/web/src/components/ui/select.tsx
Normal file
158
apps/web/src/components/ui/select.tsx
Normal 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,
|
||||
}
|
||||
13
apps/web/src/components/ui/sonner.tsx
Normal file
13
apps/web/src/components/ui/sonner.tsx
Normal file
@@ -0,0 +1,13 @@
|
||||
import { Toaster as SonnerToaster } from "sonner";
|
||||
|
||||
export function Toaster() {
|
||||
return (
|
||||
<SonnerToaster
|
||||
position="bottom-right"
|
||||
richColors
|
||||
toastOptions={{
|
||||
className: "font-sans",
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
22
apps/web/src/components/ui/textarea.tsx
Normal file
22
apps/web/src/components/ui/textarea.tsx
Normal 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 }
|
||||
18
apps/web/src/hooks/index.ts
Normal file
18
apps/web/src/hooks/index.ts
Normal 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';
|
||||
137
apps/web/src/hooks/useAutoSave.ts
Normal file
137
apps/web/src/hooks/useAutoSave.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
157
apps/web/src/hooks/useDebounce.ts
Normal file
157
apps/web/src/hooks/useDebounce.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
49
apps/web/src/hooks/useLiveUpdates.ts
Normal file
49
apps/web/src/hooks/useLiveUpdates.ts
Normal 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,
|
||||
},
|
||||
);
|
||||
}
|
||||
124
apps/web/src/hooks/useOptimisticMutation.ts
Normal file
124
apps/web/src/hooks/useOptimisticMutation.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
149
apps/web/src/hooks/usePhaseAutoSave.ts
Normal file
149
apps/web/src/hooks/usePhaseAutoSave.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
284
apps/web/src/hooks/useRefineAgent.ts
Normal file
284
apps/web/src/hooks/useRefineAgent.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
49
apps/web/src/hooks/useSpawnMutation.ts
Normal file
49
apps/web/src/hooks/useSpawnMutation.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
180
apps/web/src/hooks/useSubscriptionWithErrorHandling.ts
Normal file
180
apps/web/src/hooks/useSubscriptionWithErrorHandling.ts
Normal 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
167
apps/web/src/index.css
Normal 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
Reference in New Issue
Block a user