Add userDismissedAt field to agents schema
This commit is contained in:
@@ -1,2 +1,3 @@
|
||||
export type { AppRouter } from './trpc.js';
|
||||
export type { Initiative, Phase, Plan, Task, Agent, Message, PendingQuestions, QuestionItem, SubscriptionEvent } from './types.js';
|
||||
export type { Initiative, Phase, Plan, Task, Agent, Message, PendingQuestions, QuestionItem, SubscriptionEvent, Project } from './types.js';
|
||||
export { sortByPriorityAndQueueTime, type SortableItem } from './utils.js';
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
export type { Initiative, Phase, Plan, Task, Agent, Message } from '../../../src/db/schema.js';
|
||||
export type { Initiative, Phase, Plan, Task, Agent, Message, Page, Project, Account } from '../../../src/db/schema.js';
|
||||
export type { PendingQuestions, QuestionItem } from '../../../src/agent/types.js';
|
||||
|
||||
/**
|
||||
|
||||
37
packages/shared/src/utils.ts
Normal file
37
packages/shared/src/utils.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* Shared utility functions that can be used across frontend and backend.
|
||||
*/
|
||||
|
||||
export interface SortableItem {
|
||||
priority: 'low' | 'medium' | 'high';
|
||||
createdAt: Date | string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Priority order mapping for sorting (higher number = higher priority)
|
||||
*/
|
||||
const PRIORITY_ORDER = {
|
||||
high: 3,
|
||||
medium: 2,
|
||||
low: 1,
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* Sorts items by priority (high to low) then by queue time (oldest first).
|
||||
* This ensures high-priority items come first, but within the same priority,
|
||||
* items are processed in FIFO order.
|
||||
*/
|
||||
export function sortByPriorityAndQueueTime<T extends SortableItem>(items: T[]): T[] {
|
||||
return [...items].sort((a, b) => {
|
||||
// First sort by priority (high to low)
|
||||
const priorityDiff = PRIORITY_ORDER[b.priority] - PRIORITY_ORDER[a.priority];
|
||||
if (priorityDiff !== 0) {
|
||||
return priorityDiff;
|
||||
}
|
||||
|
||||
// Within same priority, sort by creation time (oldest first - FIFO)
|
||||
const aTime = typeof a.createdAt === 'string' ? new Date(a.createdAt) : a.createdAt;
|
||||
const bTime = typeof b.createdAt === 'string' ? new Date(b.createdAt) : b.createdAt;
|
||||
return aTime.getTime() - bTime.getTime();
|
||||
});
|
||||
}
|
||||
@@ -15,6 +15,14 @@
|
||||
"@radix-ui/react-label": "^2.1.8",
|
||||
"@tanstack/react-query": "^5.75.0",
|
||||
"@tanstack/react-router": "^1.158.0",
|
||||
"@tiptap/extension-link": "^3.19.0",
|
||||
"@tiptap/extension-placeholder": "^3.19.0",
|
||||
"@tiptap/extension-table": "^3.19.0",
|
||||
"@tiptap/html": "^3.19.0",
|
||||
"@tiptap/pm": "^3.19.0",
|
||||
"@tiptap/react": "^3.19.0",
|
||||
"@tiptap/starter-kit": "^3.19.0",
|
||||
"@tiptap/suggestion": "^3.19.0",
|
||||
"@trpc/client": "^11.9.0",
|
||||
"@trpc/react-query": "^11.9.0",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
@@ -23,7 +31,8 @@
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"tippy.js": "^6.3.7"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.16",
|
||||
|
||||
177
packages/web/src/components/AgentOutputViewer.tsx
Normal file
177
packages/web/src/components/AgentOutputViewer.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { ArrowDown, Pause, Play, AlertCircle } from "lucide-react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { useSubscriptionWithErrorHandling } from "@/hooks";
|
||||
|
||||
interface AgentOutputViewerProps {
|
||||
agentId: string;
|
||||
agentName?: string;
|
||||
}
|
||||
|
||||
export function AgentOutputViewer({ agentId, agentName }: AgentOutputViewerProps) {
|
||||
const [output, setOutput] = useState<string[]>([]);
|
||||
const [follow, setFollow] = useState(true);
|
||||
const containerRef = useRef<HTMLPreElement>(null);
|
||||
|
||||
// Load initial/historical output
|
||||
const outputQuery = trpc.getAgentOutput.useQuery(
|
||||
{ id: agentId },
|
||||
{
|
||||
refetchOnWindowFocus: false,
|
||||
}
|
||||
);
|
||||
|
||||
// Subscribe to live output with error handling
|
||||
const subscription = useSubscriptionWithErrorHandling(
|
||||
() => trpc.onAgentOutput.useSubscription({ agentId }),
|
||||
{
|
||||
onData: (event) => {
|
||||
// event is TrackedEnvelope<{ agentId: string; data: string }>
|
||||
// event.data is the inner data object
|
||||
const payload = event.data as { agentId: string; data: string };
|
||||
setOutput((prev) => [...prev, payload.data]);
|
||||
},
|
||||
onError: (error) => {
|
||||
console.error('Agent output subscription error:', error);
|
||||
},
|
||||
autoReconnect: true,
|
||||
maxReconnectAttempts: 3,
|
||||
}
|
||||
);
|
||||
|
||||
// Set initial output when query loads
|
||||
useEffect(() => {
|
||||
if (outputQuery.data) {
|
||||
// Split NDJSON content into chunks for display
|
||||
// Each line might be a JSON event, so we just display raw for now
|
||||
const lines = outputQuery.data.split("\n").filter(Boolean);
|
||||
// Extract text from JSONL events for display
|
||||
const textChunks: string[] = [];
|
||||
for (const line of lines) {
|
||||
try {
|
||||
const event = JSON.parse(line);
|
||||
if (event.type === "assistant" && Array.isArray(event.message?.content)) {
|
||||
// Claude CLI stream-json: complete assistant messages with content blocks
|
||||
for (const block of event.message.content) {
|
||||
if (block.type === "text" && block.text) {
|
||||
textChunks.push(block.text);
|
||||
}
|
||||
}
|
||||
} else if (event.type === "stream_event" && event.event?.delta?.text) {
|
||||
// Legacy streaming format: granular text deltas
|
||||
textChunks.push(event.event.delta.text);
|
||||
} else if (event.type === "result" && event.result) {
|
||||
// Don't add result text since it duplicates the content
|
||||
}
|
||||
} catch {
|
||||
// Not JSON, display as-is
|
||||
textChunks.push(line + "\n");
|
||||
}
|
||||
}
|
||||
setOutput(textChunks);
|
||||
}
|
||||
}, [outputQuery.data]);
|
||||
|
||||
// Reset output when agent changes
|
||||
useEffect(() => {
|
||||
setOutput([]);
|
||||
setFollow(true);
|
||||
}, [agentId]);
|
||||
|
||||
// Auto-scroll to bottom when following
|
||||
useEffect(() => {
|
||||
if (follow && containerRef.current) {
|
||||
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
||||
}
|
||||
}, [output, follow]);
|
||||
|
||||
// Handle scroll to detect user scrolling up
|
||||
function handleScroll() {
|
||||
if (!containerRef.current) return;
|
||||
const { scrollTop, scrollHeight, clientHeight } = containerRef.current;
|
||||
const isAtBottom = scrollHeight - scrollTop - clientHeight < 50;
|
||||
if (!isAtBottom && follow) {
|
||||
setFollow(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Jump to bottom
|
||||
function scrollToBottom() {
|
||||
if (containerRef.current) {
|
||||
containerRef.current.scrollTop = containerRef.current.scrollHeight;
|
||||
setFollow(true);
|
||||
}
|
||||
}
|
||||
|
||||
const isLoading = outputQuery.isLoading;
|
||||
const hasOutput = output.length > 0;
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-[600px] rounded-lg border overflow-hidden">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between border-b bg-zinc-900 px-4 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-zinc-100">
|
||||
{agentName ? `Output: ${agentName}` : "Agent Output"}
|
||||
</span>
|
||||
{subscription.error && (
|
||||
<div className="flex items-center gap-1 text-red-400" title={subscription.error.message}>
|
||||
<AlertCircle className="h-3 w-3" />
|
||||
<span className="text-xs">Connection error</span>
|
||||
</div>
|
||||
)}
|
||||
{subscription.isConnecting && (
|
||||
<span className="text-xs text-yellow-400">Connecting...</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setFollow(!follow)}
|
||||
className="h-7 text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800"
|
||||
>
|
||||
{follow ? (
|
||||
<>
|
||||
<Pause className="mr-1 h-3 w-3" />
|
||||
Following
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="mr-1 h-3 w-3" />
|
||||
Paused
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{!follow && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={scrollToBottom}
|
||||
className="h-7 text-zinc-400 hover:text-zinc-100 hover:bg-zinc-800"
|
||||
>
|
||||
<ArrowDown className="mr-1 h-3 w-3" />
|
||||
Jump to bottom
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Output content */}
|
||||
<pre
|
||||
ref={containerRef}
|
||||
onScroll={handleScroll}
|
||||
className="flex-1 overflow-y-auto bg-zinc-900 p-4 font-mono text-sm text-zinc-100 whitespace-pre-wrap"
|
||||
>
|
||||
{isLoading ? (
|
||||
<span className="text-zinc-500">Loading output...</span>
|
||||
) : !hasOutput ? (
|
||||
<span className="text-zinc-500">No output yet...</span>
|
||||
) : (
|
||||
output.join("")
|
||||
)}
|
||||
</pre>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -10,9 +10,9 @@ import {
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { toast } from "sonner";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { ProjectPicker } from "./ProjectPicker";
|
||||
|
||||
interface CreateInitiativeDialogProps {
|
||||
open: boolean;
|
||||
@@ -24,7 +24,7 @@ export function CreateInitiativeDialog({
|
||||
onOpenChange,
|
||||
}: CreateInitiativeDialogProps) {
|
||||
const [name, setName] = useState("");
|
||||
const [description, setDescription] = useState("");
|
||||
const [projectIds, setProjectIds] = useState<string[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
@@ -44,7 +44,7 @@ export function CreateInitiativeDialog({
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName("");
|
||||
setDescription("");
|
||||
setProjectIds([]);
|
||||
setError(null);
|
||||
}
|
||||
}, [open]);
|
||||
@@ -54,7 +54,7 @@ export function CreateInitiativeDialog({
|
||||
setError(null);
|
||||
createMutation.mutate({
|
||||
name: name.trim(),
|
||||
description: description.trim() || undefined,
|
||||
projectIds: projectIds.length > 0 ? projectIds : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -81,19 +81,13 @@ export function CreateInitiativeDialog({
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="initiative-description">
|
||||
Description{" "}
|
||||
<Label>
|
||||
Projects{" "}
|
||||
<span className="text-muted-foreground font-normal">
|
||||
(optional)
|
||||
</span>
|
||||
</Label>
|
||||
<Textarea
|
||||
id="initiative-description"
|
||||
placeholder="Brief description of the initiative..."
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
rows={3}
|
||||
/>
|
||||
<ProjectPicker value={projectIds} onChange={setProjectIds} />
|
||||
</div>
|
||||
{error && (
|
||||
<p className="text-sm text-destructive">{error}</p>
|
||||
|
||||
48
packages/web/src/components/ExecutionTab.tsx
Normal file
48
packages/web/src/components/ExecutionTab.tsx
Normal file
@@ -0,0 +1,48 @@
|
||||
import {
|
||||
ExecutionProvider,
|
||||
PhaseActions,
|
||||
PhasesList,
|
||||
ProgressSidebar,
|
||||
TaskModal,
|
||||
type PhaseData,
|
||||
} from "@/components/execution";
|
||||
|
||||
interface ExecutionTabProps {
|
||||
initiativeId: string;
|
||||
phases: PhaseData[];
|
||||
phasesLoading: boolean;
|
||||
phasesLoaded: boolean;
|
||||
}
|
||||
|
||||
export function ExecutionTab({
|
||||
initiativeId,
|
||||
phases,
|
||||
phasesLoading,
|
||||
phasesLoaded,
|
||||
}: ExecutionTabProps) {
|
||||
return (
|
||||
<ExecutionProvider>
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_340px]">
|
||||
{/* Left column: Phases */}
|
||||
<div className="space-y-0">
|
||||
<div className="flex items-center justify-between border-b border-border pb-3">
|
||||
<h2 className="text-lg font-semibold">Phases</h2>
|
||||
<PhaseActions initiativeId={initiativeId} phases={phases} />
|
||||
</div>
|
||||
|
||||
<PhasesList
|
||||
initiativeId={initiativeId}
|
||||
phases={phases}
|
||||
phasesLoading={phasesLoading}
|
||||
phasesLoaded={phasesLoaded}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Right column: Progress + Decisions */}
|
||||
<ProgressSidebar phases={phases} />
|
||||
</div>
|
||||
|
||||
<TaskModal />
|
||||
</ExecutionProvider>
|
||||
);
|
||||
}
|
||||
@@ -1,52 +1,118 @@
|
||||
import { ChevronLeft } from "lucide-react";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { useState } from "react";
|
||||
import { ChevronLeft, Pencil, Check } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { ProjectPicker } from "./ProjectPicker";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { toast } from "sonner";
|
||||
|
||||
export interface InitiativeHeaderProps {
|
||||
initiative: {
|
||||
id: string;
|
||||
name: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
projects?: Array<{ id: string; name: string; url: string }>;
|
||||
onBack: () => void;
|
||||
}
|
||||
|
||||
export function InitiativeHeader({
|
||||
initiative,
|
||||
projects,
|
||||
onBack,
|
||||
}: InitiativeHeaderProps) {
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
{/* Top bar: back button + actions placeholder */}
|
||||
<div className="flex items-center justify-between">
|
||||
<Button variant="ghost" size="sm" onClick={onBack}>
|
||||
<ChevronLeft className="mr-1 h-4 w-4" />
|
||||
Back to Dashboard
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" disabled>
|
||||
Actions
|
||||
</Button>
|
||||
</div>
|
||||
const [editing, setEditing] = useState(false);
|
||||
const [editIds, setEditIds] = useState<string[]>([]);
|
||||
|
||||
{/* Initiative metadata card */}
|
||||
<Card>
|
||||
<CardContent className="p-6">
|
||||
<div className="flex flex-col gap-2">
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-bold">{initiative.name}</h1>
|
||||
<StatusBadge status={initiative.status} />
|
||||
</div>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Created: {new Date(initiative.createdAt).toLocaleDateString()}
|
||||
{" | "}
|
||||
Updated: {new Date(initiative.updatedAt).toLocaleDateString()}
|
||||
</p>
|
||||
const utils = trpc.useUtils();
|
||||
const updateMutation = trpc.updateInitiativeProjects.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.getInitiative.invalidate({ id: initiative.id });
|
||||
setEditing(false);
|
||||
toast.success("Projects updated");
|
||||
},
|
||||
onError: (err) => {
|
||||
toast.error(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
function startEditing() {
|
||||
setEditIds(projects?.map((p) => p.id) ?? []);
|
||||
setEditing(true);
|
||||
}
|
||||
|
||||
function saveProjects() {
|
||||
if (editIds.length === 0) {
|
||||
toast.error("At least one project is required");
|
||||
return;
|
||||
}
|
||||
updateMutation.mutate({
|
||||
initiativeId: initiative.id,
|
||||
projectIds: editIds,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8" onClick={onBack}>
|
||||
<ChevronLeft className="h-4 w-4" />
|
||||
</Button>
|
||||
<h1 className="text-xl font-semibold">{initiative.name}</h1>
|
||||
<StatusBadge status={initiative.status} />
|
||||
{!editing && projects && projects.length > 0 && (
|
||||
<>
|
||||
{projects.map((p) => (
|
||||
<Badge key={p.id} variant="outline" className="text-xs font-normal">
|
||||
{p.name}
|
||||
</Badge>
|
||||
))}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={startEditing}
|
||||
>
|
||||
<Pencil className="h-3 w-3" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{!editing && (!projects || projects.length === 0) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-xs text-muted-foreground"
|
||||
onClick={startEditing}
|
||||
>
|
||||
+ Add projects
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{editing && (
|
||||
<div className="ml-11 max-w-sm space-y-2">
|
||||
<ProjectPicker value={editIds} onChange={setEditIds} />
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={saveProjects}
|
||||
disabled={editIds.length === 0 || updateMutation.isPending}
|
||||
>
|
||||
<Check className="mr-1 h-3 w-3" />
|
||||
{updateMutation.isPending ? "Saving..." : "Save"}
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
variant="outline"
|
||||
onClick={() => setEditing(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,20 +1,5 @@
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
function formatRelativeTime(isoDate: string): string {
|
||||
const now = Date.now();
|
||||
const then = new Date(isoDate).getTime();
|
||||
const diffMs = now - then;
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHr / 24);
|
||||
|
||||
if (diffSec < 60) return "just now";
|
||||
if (diffMin < 60) return `${diffMin} min ago`;
|
||||
if (diffHr < 24) return `${diffHr}h ago`;
|
||||
return `${diffDay}d ago`;
|
||||
}
|
||||
import { cn, formatRelativeTime } from "@/lib/utils";
|
||||
|
||||
interface MessageCardProps {
|
||||
agentName: string;
|
||||
|
||||
65
packages/web/src/components/ProjectPicker.tsx
Normal file
65
packages/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>
|
||||
);
|
||||
}
|
||||
125
packages/web/src/components/RefineSpawnDialog.tsx
Normal file
125
packages/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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
110
packages/web/src/components/RegisterProjectDialog.tsx
Normal file
110
packages/web/src/components/RegisterProjectDialog.tsx
Normal file
@@ -0,0 +1,110 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { Label } from "@/components/ui/label";
|
||||
import { toast } from "sonner";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
|
||||
interface RegisterProjectDialogProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function RegisterProjectDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
}: RegisterProjectDialogProps) {
|
||||
const [name, setName] = useState("");
|
||||
const [url, setUrl] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const registerMutation = trpc.registerProject.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.listProjects.invalidate();
|
||||
onOpenChange(false);
|
||||
toast.success("Project registered");
|
||||
},
|
||||
onError: (err) => {
|
||||
setError(err.message);
|
||||
},
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setName("");
|
||||
setUrl("");
|
||||
setError(null);
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
registerMutation.mutate({
|
||||
name: name.trim(),
|
||||
url: url.trim(),
|
||||
});
|
||||
}
|
||||
|
||||
const canSubmit =
|
||||
name.trim().length > 0 &&
|
||||
url.trim().length > 0 &&
|
||||
!registerMutation.isPending;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Register Project</DialogTitle>
|
||||
<DialogDescription>
|
||||
Register a git repository as a project.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="project-name">Name</Label>
|
||||
<Input
|
||||
id="project-name"
|
||||
placeholder="e.g. my-app"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="project-url">Repository URL</Label>
|
||||
<Input
|
||||
id="project-url"
|
||||
placeholder="e.g. https://github.com/org/repo.git"
|
||||
value={url}
|
||||
onChange={(e) => setUrl(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{error && <p className="text-sm text-destructive">{error}</p>}
|
||||
<DialogFooter>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button type="submit" disabled={!canSubmit}>
|
||||
{registerMutation.isPending ? "Registering..." : "Register"}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -7,59 +7,42 @@ import {
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import { toast } from "sonner";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { useSpawnMutation } from "@/hooks/useSpawnMutation";
|
||||
|
||||
interface SpawnArchitectDropdownProps {
|
||||
initiativeId: string;
|
||||
initiativeName: string;
|
||||
initiativeName?: string;
|
||||
}
|
||||
|
||||
export function SpawnArchitectDropdown({
|
||||
initiativeId,
|
||||
initiativeName,
|
||||
}: SpawnArchitectDropdownProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [successText, setSuccessText] = useState<string | null>(null);
|
||||
|
||||
const discussMutation = trpc.spawnArchitectDiscuss.useMutation({
|
||||
onSuccess: () => {
|
||||
setOpen(false);
|
||||
setSuccessText("Spawned!");
|
||||
setTimeout(() => setSuccessText(null), 2000);
|
||||
toast.success("Architect spawned");
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to spawn architect");
|
||||
},
|
||||
const handleSuccess = () => {
|
||||
setOpen(false);
|
||||
setSuccessText("Spawned!");
|
||||
setTimeout(() => setSuccessText(null), 2000);
|
||||
};
|
||||
|
||||
const discussSpawn = useSpawnMutation(trpc.spawnArchitectDiscuss.useMutation, {
|
||||
onSuccess: handleSuccess,
|
||||
});
|
||||
|
||||
const breakdownMutation = trpc.spawnArchitectBreakdown.useMutation({
|
||||
onSuccess: () => {
|
||||
setOpen(false);
|
||||
setSuccessText("Spawned!");
|
||||
setTimeout(() => setSuccessText(null), 2000);
|
||||
toast.success("Architect spawned");
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Failed to spawn architect");
|
||||
},
|
||||
const breakdownSpawn = useSpawnMutation(trpc.spawnArchitectBreakdown.useMutation, {
|
||||
onSuccess: handleSuccess,
|
||||
});
|
||||
|
||||
const isPending = discussMutation.isPending || breakdownMutation.isPending;
|
||||
const isPending = discussSpawn.isSpawning || breakdownSpawn.isSpawning;
|
||||
|
||||
function handleDiscuss() {
|
||||
discussMutation.mutate({
|
||||
name: initiativeName + "-discuss",
|
||||
initiativeId,
|
||||
});
|
||||
discussSpawn.spawn({ initiativeId });
|
||||
}
|
||||
|
||||
function handleBreakdown() {
|
||||
breakdownMutation.mutate({
|
||||
name: initiativeName + "-breakdown",
|
||||
initiativeId,
|
||||
});
|
||||
breakdownSpawn.spawn({ initiativeId });
|
||||
}
|
||||
|
||||
return (
|
||||
|
||||
76
packages/web/src/components/StatusDot.tsx
Normal file
76
packages/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}`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} from "@/components/ui/dialog";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { StatusDot } from "@/components/StatusDot";
|
||||
|
||||
/** Serialized Task shape as returned by tRPC (Date serialized to string over JSON) */
|
||||
export interface SerializedTask {
|
||||
@@ -117,7 +118,7 @@ export function TaskDetailModal({
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<span>{dep.name}</span>
|
||||
<StatusBadge status={dep.status} />
|
||||
<StatusDot status={dep.status} size="md" />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
@@ -137,7 +138,7 @@ export function TaskDetailModal({
|
||||
className="flex items-center gap-2 text-sm"
|
||||
>
|
||||
<span>{dep.name}</span>
|
||||
<StatusBadge status={dep.status} />
|
||||
<StatusDot status={dep.status} size="md" />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
186
packages/web/src/components/editor/BlockSelectionExtension.ts
Normal file
186
packages/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;
|
||||
},
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
191
packages/web/src/components/editor/ContentProposalReview.tsx
Normal file
191
packages/web/src/components/editor/ContentProposalReview.tsx
Normal file
@@ -0,0 +1,191 @@
|
||||
import { useState, useCallback } from "react";
|
||||
import { Check, ChevronDown, ChevronRight, AlertTriangle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { markdownToTiptapJson } from "@/lib/markdown-to-tiptap";
|
||||
|
||||
interface ContentProposal {
|
||||
pageId: string;
|
||||
pageTitle: string;
|
||||
summary: string;
|
||||
markdown: string;
|
||||
}
|
||||
|
||||
interface ContentProposalReviewProps {
|
||||
proposals: ContentProposal[];
|
||||
agentCreatedAt: Date;
|
||||
agentId: string;
|
||||
onDismiss: () => void;
|
||||
}
|
||||
|
||||
export function ContentProposalReview({
|
||||
proposals,
|
||||
agentCreatedAt,
|
||||
agentId,
|
||||
onDismiss,
|
||||
}: ContentProposalReviewProps) {
|
||||
const [accepted, setAccepted] = useState<Set<string>>(new Set());
|
||||
const utils = trpc.useUtils();
|
||||
const updatePageMutation = trpc.updatePage.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.listPages.invalidate();
|
||||
void utils.getPage.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
const dismissMutation = trpc.dismissAgent.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.listAgents.invalidate();
|
||||
onDismiss();
|
||||
},
|
||||
});
|
||||
|
||||
const handleAccept = useCallback(
|
||||
async (proposal: ContentProposal) => {
|
||||
const tiptapJson = markdownToTiptapJson(proposal.markdown);
|
||||
await updatePageMutation.mutateAsync({
|
||||
id: proposal.pageId,
|
||||
content: JSON.stringify(tiptapJson),
|
||||
});
|
||||
setAccepted((prev) => new Set(prev).add(proposal.pageId));
|
||||
},
|
||||
[updatePageMutation],
|
||||
);
|
||||
|
||||
const handleAcceptAll = useCallback(async () => {
|
||||
for (const proposal of proposals) {
|
||||
if (!accepted.has(proposal.pageId)) {
|
||||
const tiptapJson = markdownToTiptapJson(proposal.markdown);
|
||||
await updatePageMutation.mutateAsync({
|
||||
id: proposal.pageId,
|
||||
content: JSON.stringify(tiptapJson),
|
||||
});
|
||||
setAccepted((prev) => new Set(prev).add(proposal.pageId));
|
||||
}
|
||||
}
|
||||
}, [proposals, accepted, updatePageMutation]);
|
||||
|
||||
const allAccepted = proposals.every((p) => accepted.has(p.pageId));
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card p-4 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold">
|
||||
Agent Proposals ({proposals.length})
|
||||
</h3>
|
||||
<div className="flex gap-2">
|
||||
{!allAccepted && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAcceptAll}
|
||||
disabled={updatePageMutation.isPending}
|
||||
>
|
||||
Accept All
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => dismissMutation.mutate({ id: agentId })}
|
||||
disabled={dismissMutation.isPending}
|
||||
>
|
||||
{dismissMutation.isPending ? "Dismissing..." : "Dismiss"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{proposals.map((proposal) => (
|
||||
<ProposalCard
|
||||
key={proposal.pageId}
|
||||
proposal={proposal}
|
||||
isAccepted={accepted.has(proposal.pageId)}
|
||||
agentCreatedAt={agentCreatedAt}
|
||||
onAccept={() => handleAccept(proposal)}
|
||||
isAccepting={updatePageMutation.isPending}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
interface ProposalCardProps {
|
||||
proposal: ContentProposal;
|
||||
isAccepted: boolean;
|
||||
agentCreatedAt: Date;
|
||||
onAccept: () => void;
|
||||
isAccepting: boolean;
|
||||
}
|
||||
|
||||
function ProposalCard({
|
||||
proposal,
|
||||
isAccepted,
|
||||
agentCreatedAt,
|
||||
onAccept,
|
||||
isAccepting,
|
||||
}: ProposalCardProps) {
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
// Check if page was modified since agent started
|
||||
const pageQuery = trpc.getPage.useQuery({ id: proposal.pageId });
|
||||
const pageUpdatedAt = pageQuery.data?.updatedAt;
|
||||
const isStale =
|
||||
pageUpdatedAt && new Date(pageUpdatedAt) > agentCreatedAt;
|
||||
|
||||
return (
|
||||
<div className="rounded border border-border p-3 space-y-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1 min-w-0">
|
||||
<button
|
||||
className="flex items-center gap-1 text-sm font-medium hover:text-foreground/80"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="h-3.5 w-3.5 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="h-3.5 w-3.5 shrink-0" />
|
||||
)}
|
||||
{proposal.pageTitle}
|
||||
</button>
|
||||
<p className="text-xs text-muted-foreground mt-0.5 pl-5">
|
||||
{proposal.summary}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{isAccepted ? (
|
||||
<div className="flex items-center gap-1 text-xs text-green-600 shrink-0">
|
||||
<Check className="h-3.5 w-3.5" />
|
||||
Accepted
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onAccept}
|
||||
disabled={isAccepting}
|
||||
className="shrink-0"
|
||||
>
|
||||
Accept
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{isStale && !isAccepted && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-yellow-600 pl-5">
|
||||
<AlertTriangle className="h-3 w-3" />
|
||||
Content was modified since agent started
|
||||
</div>
|
||||
)}
|
||||
|
||||
{expanded && (
|
||||
<div className="pl-5 pt-1">
|
||||
<div className="prose prose-sm max-w-none rounded bg-muted/50 p-3 text-xs overflow-auto max-h-64">
|
||||
<pre className="whitespace-pre-wrap text-xs">{proposal.markdown}</pre>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
348
packages/web/src/components/editor/ContentTab.tsx
Normal file
348
packages/web/src/components/editor/ContentTab.tsx
Normal file
@@ -0,0 +1,348 @@
|
||||
import { useState, useCallback, useRef, useEffect } from "react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { useAutoSave } from "@/hooks/useAutoSave";
|
||||
import { TiptapEditor } from "./TiptapEditor";
|
||||
import { PageTitleProvider } from "./PageTitleContext";
|
||||
import { PageTree } from "./PageTree";
|
||||
import { RefineAgentPanel } from "./RefineAgentPanel";
|
||||
import { Skeleton } from "@/components/Skeleton";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
interface ContentTabProps {
|
||||
initiativeId: string;
|
||||
initiativeName: string;
|
||||
}
|
||||
|
||||
interface DeleteConfirmation {
|
||||
pageId: string;
|
||||
redo: () => void;
|
||||
}
|
||||
|
||||
export function ContentTab({ initiativeId, initiativeName }: ContentTabProps) {
|
||||
const utils = trpc.useUtils();
|
||||
const handleSaved = useCallback(() => {
|
||||
void utils.listPages.invalidate({ initiativeId });
|
||||
}, [utils, initiativeId]);
|
||||
const { save, flush, isSaving } = useAutoSave({ onSaved: handleSaved });
|
||||
|
||||
// Get or create root page
|
||||
const rootPageQuery = trpc.getRootPage.useQuery({ initiativeId });
|
||||
const allPagesQuery = trpc.listPages.useQuery({ initiativeId });
|
||||
const createPageMutation = trpc.createPage.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.listPages.invalidate({ initiativeId });
|
||||
},
|
||||
});
|
||||
const deletePageMutation = trpc.deletePage.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.listPages.invalidate({ initiativeId });
|
||||
},
|
||||
});
|
||||
|
||||
const updateInitiativeMutation = trpc.updateInitiative.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.getInitiative.invalidate({ id: initiativeId });
|
||||
},
|
||||
});
|
||||
const initiativeNameTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingInitiativeNameRef = useRef<string | null>(null);
|
||||
|
||||
const [activePageId, setActivePageId] = useState<string | null>(null);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState<DeleteConfirmation | null>(null);
|
||||
const [pageTitle, setPageTitle] = useState("");
|
||||
|
||||
// Keep a ref to the current editor so subpage creation can insert links
|
||||
const editorRef = useRef<Editor | null>(null);
|
||||
|
||||
// Resolve active page: use explicit selection, or fallback to root
|
||||
const resolvedActivePageId =
|
||||
activePageId ?? rootPageQuery.data?.id ?? null;
|
||||
|
||||
const isRootPage = resolvedActivePageId != null && resolvedActivePageId === rootPageQuery.data?.id;
|
||||
|
||||
// Fetch active page content
|
||||
const activePageQuery = trpc.getPage.useQuery(
|
||||
{ id: resolvedActivePageId! },
|
||||
{ enabled: !!resolvedActivePageId },
|
||||
);
|
||||
|
||||
const handleEditorUpdate = useCallback(
|
||||
(json: string) => {
|
||||
if (resolvedActivePageId) {
|
||||
save(resolvedActivePageId, { content: json });
|
||||
}
|
||||
},
|
||||
[resolvedActivePageId, save],
|
||||
);
|
||||
|
||||
// Sync title from server when active page changes
|
||||
useEffect(() => {
|
||||
if (activePageQuery.data) {
|
||||
setPageTitle(isRootPage ? initiativeName : activePageQuery.data.title);
|
||||
}
|
||||
}, [activePageQuery.data?.id, isRootPage, initiativeName]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const handleTitleChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const newTitle = e.target.value;
|
||||
setPageTitle(newTitle);
|
||||
if (isRootPage) {
|
||||
// Debounce initiative name updates
|
||||
pendingInitiativeNameRef.current = newTitle;
|
||||
if (initiativeNameTimerRef.current) {
|
||||
clearTimeout(initiativeNameTimerRef.current);
|
||||
}
|
||||
initiativeNameTimerRef.current = setTimeout(() => {
|
||||
pendingInitiativeNameRef.current = null;
|
||||
updateInitiativeMutation.mutate({ id: initiativeId, name: newTitle });
|
||||
initiativeNameTimerRef.current = null;
|
||||
}, 1000);
|
||||
} else if (resolvedActivePageId) {
|
||||
save(resolvedActivePageId, { title: newTitle });
|
||||
}
|
||||
},
|
||||
[isRootPage, resolvedActivePageId, save, initiativeId, updateInitiativeMutation],
|
||||
);
|
||||
|
||||
// Flush pending initiative name save on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (initiativeNameTimerRef.current) {
|
||||
clearTimeout(initiativeNameTimerRef.current);
|
||||
initiativeNameTimerRef.current = null;
|
||||
}
|
||||
if (pendingInitiativeNameRef.current != null) {
|
||||
updateInitiativeMutation.mutate({ id: initiativeId, name: pendingInitiativeNameRef.current });
|
||||
pendingInitiativeNameRef.current = null;
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const handleTitleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
// Focus the Tiptap editor below
|
||||
const el = (e.target as HTMLElement)
|
||||
.closest(".flex-1")
|
||||
?.querySelector<HTMLElement>("[contenteditable]");
|
||||
el?.focus();
|
||||
}
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleCreateChild = useCallback(
|
||||
(parentPageId: string) => {
|
||||
createPageMutation.mutate({
|
||||
initiativeId,
|
||||
parentPageId,
|
||||
title: "Untitled",
|
||||
});
|
||||
},
|
||||
[initiativeId, createPageMutation],
|
||||
);
|
||||
|
||||
const handleNavigate = useCallback((pageId: string) => {
|
||||
setActivePageId(pageId);
|
||||
}, []);
|
||||
|
||||
// Slash command: /subpage — creates a page and inserts a link at cursor
|
||||
const handleSubpageCreate = useCallback(
|
||||
async (editor: Editor) => {
|
||||
editorRef.current = editor;
|
||||
try {
|
||||
const page = await createPageMutation.mutateAsync({
|
||||
initiativeId,
|
||||
parentPageId: resolvedActivePageId,
|
||||
title: "Untitled",
|
||||
});
|
||||
// Insert page link at current cursor position
|
||||
editor.commands.insertPageLink({ pageId: page.id });
|
||||
// Wait for auto-save to persist the link before navigating
|
||||
await flush();
|
||||
// Update the query cache so navigating back shows content with the link
|
||||
utils.getPage.setData(
|
||||
{ id: resolvedActivePageId! },
|
||||
(old) => old ? { ...old, content: JSON.stringify(editor.getJSON()) } : undefined,
|
||||
);
|
||||
// Navigate directly to the newly created subpage
|
||||
setActivePageId(page.id);
|
||||
} catch {
|
||||
// Mutation errors surfaced via React Query state
|
||||
}
|
||||
},
|
||||
[initiativeId, resolvedActivePageId, createPageMutation, flush, utils],
|
||||
);
|
||||
|
||||
// Detect when a page link is deleted from the editor (already undone by plugin)
|
||||
const handlePageLinkDeleted = useCallback(
|
||||
(pageId: string, redo: () => void) => {
|
||||
// Don't prompt for pages that don't exist in our tree
|
||||
const allPages = allPagesQuery.data ?? [];
|
||||
const exists = allPages.some((p) => p.id === pageId);
|
||||
if (!exists) {
|
||||
// Page doesn't exist — redo the deletion so the stale link is removed
|
||||
redo();
|
||||
return;
|
||||
}
|
||||
|
||||
setDeleteConfirm({ pageId, redo });
|
||||
},
|
||||
[allPagesQuery.data],
|
||||
);
|
||||
|
||||
const confirmDeleteSubpage = useCallback(() => {
|
||||
if (deleteConfirm) {
|
||||
// Re-delete the page link from the editor, then delete the page from DB
|
||||
deleteConfirm.redo();
|
||||
deletePageMutation.mutate({ id: deleteConfirm.pageId });
|
||||
setDeleteConfirm(null);
|
||||
}
|
||||
}, [deleteConfirm, deletePageMutation]);
|
||||
|
||||
const dismissDeleteConfirm = useCallback(() => {
|
||||
setDeleteConfirm(null);
|
||||
}, []);
|
||||
|
||||
const allPages = allPagesQuery.data ?? [];
|
||||
|
||||
// Loading
|
||||
if (rootPageQuery.isLoading) {
|
||||
return (
|
||||
<div className="flex gap-4">
|
||||
<Skeleton className="h-64 w-48" />
|
||||
<Skeleton className="h-64 flex-1" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error — server likely needs restart or migration hasn't applied
|
||||
if (rootPageQuery.isError) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
|
||||
<AlertCircle className="h-6 w-6 text-destructive" />
|
||||
<p className="text-sm text-destructive">
|
||||
Failed to load editor: {rootPageQuery.error.message}
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Make sure the backend server is running with the latest code.
|
||||
</p>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void rootPageQuery.refetch()}
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageTitleProvider pages={allPages}>
|
||||
<div className="flex gap-4 pt-4">
|
||||
{/* Page tree sidebar */}
|
||||
<div className="w-48 shrink-0 border-r border-border pr-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
|
||||
Pages
|
||||
</span>
|
||||
</div>
|
||||
<PageTree
|
||||
pages={allPages}
|
||||
activePageId={resolvedActivePageId ?? ""}
|
||||
onNavigate={handleNavigate}
|
||||
onCreateChild={handleCreateChild}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Editor area */}
|
||||
<div className="flex-1 min-w-0">
|
||||
{/* Refine agent panel — sits above editor */}
|
||||
<RefineAgentPanel initiativeId={initiativeId} />
|
||||
|
||||
{resolvedActivePageId && (
|
||||
<>
|
||||
{(isSaving || updateInitiativeMutation.isPending) && (
|
||||
<div className="flex justify-end mb-2">
|
||||
<span className="text-xs text-muted-foreground">
|
||||
Saving...
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{activePageQuery.isSuccess && (
|
||||
<input
|
||||
value={pageTitle}
|
||||
onChange={handleTitleChange}
|
||||
onKeyDown={handleTitleKeyDown}
|
||||
placeholder="Untitled"
|
||||
className="w-full text-3xl font-bold bg-transparent border-none outline-none placeholder:text-muted-foreground/40 pl-11 mb-2"
|
||||
/>
|
||||
)}
|
||||
{activePageQuery.isSuccess && (
|
||||
<TiptapEditor
|
||||
key={resolvedActivePageId}
|
||||
pageId={resolvedActivePageId}
|
||||
content={activePageQuery.data?.content ?? null}
|
||||
onUpdate={handleEditorUpdate}
|
||||
onPageLinkClick={handleNavigate}
|
||||
onSubpageCreate={handleSubpageCreate}
|
||||
onPageLinkDeleted={handlePageLinkDeleted}
|
||||
/>
|
||||
)}
|
||||
{activePageQuery.isLoading && (
|
||||
<Skeleton className="h-64 w-full" />
|
||||
)}
|
||||
{activePageQuery.isError && (
|
||||
<div className="flex items-center gap-2 py-4 text-sm text-destructive">
|
||||
<AlertCircle className="h-4 w-4" />
|
||||
Failed to load page: {activePageQuery.error.message}
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete subpage confirmation dialog */}
|
||||
<Dialog
|
||||
open={deleteConfirm !== null}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) dismissDeleteConfirm();
|
||||
}}
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete subpage?</DialogTitle>
|
||||
<DialogDescription>
|
||||
You removed the link to “{allPages.find((p) => p.id === deleteConfirm?.pageId)?.title ?? "Untitled"}”.
|
||||
Do you also want to delete the subpage and all its content?
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={dismissDeleteConfirm}>
|
||||
Keep subpage
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={confirmDeleteSubpage}>
|
||||
Delete subpage
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</PageTitleProvider>
|
||||
</>
|
||||
);
|
||||
}
|
||||
52
packages/web/src/components/editor/PageBreadcrumb.tsx
Normal file
52
packages/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>
|
||||
);
|
||||
}
|
||||
75
packages/web/src/components/editor/PageLinkExtension.tsx
Normal file
75
packages/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
packages/web/src/components/editor/PageTitleContext.tsx
Normal file
26
packages/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
packages/web/src/components/editor/PageTree.tsx
Normal file
119
packages/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>
|
||||
);
|
||||
}
|
||||
142
packages/web/src/components/editor/RefineAgentPanel.tsx
Normal file
142
packages/web/src/components/editor/RefineAgentPanel.tsx
Normal file
@@ -0,0 +1,142 @@
|
||||
import { useCallback } from "react";
|
||||
import { Loader2, AlertCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { QuestionForm } from "@/components/QuestionForm";
|
||||
import { ContentProposalReview } from "./ContentProposalReview";
|
||||
import { RefineSpawnDialog } from "../RefineSpawnDialog";
|
||||
import { useRefineAgent } from "@/hooks";
|
||||
|
||||
interface RefineAgentPanelProps {
|
||||
initiativeId: string;
|
||||
}
|
||||
|
||||
export function RefineAgentPanel({ initiativeId }: RefineAgentPanelProps) {
|
||||
// All agent logic is now encapsulated in the hook
|
||||
const { state, agent, questions, proposals, spawn, resume, refresh } = useRefineAgent(initiativeId);
|
||||
|
||||
// spawn.mutate and resume.mutate are stable (ref-backed in useRefineAgent),
|
||||
// so these callbacks won't change on every render.
|
||||
const handleSpawn = useCallback((instruction?: string) => {
|
||||
spawn.mutate({
|
||||
initiativeId,
|
||||
instruction,
|
||||
});
|
||||
}, [initiativeId, spawn.mutate]);
|
||||
|
||||
const handleAnswerSubmit = useCallback(
|
||||
(answers: Record<string, string>) => {
|
||||
resume.mutate(answers);
|
||||
},
|
||||
[resume.mutate],
|
||||
);
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
refresh();
|
||||
}, [refresh]);
|
||||
|
||||
// No active agent — show spawn button
|
||||
if (state === "none") {
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<RefineSpawnDialog
|
||||
triggerText="Refine with Agent"
|
||||
title="Refine Initiative Content"
|
||||
description="An agent will review all pages and suggest improvements. Optionally tell it what to focus on."
|
||||
instructionPlaceholder="What should the agent focus on? (optional)"
|
||||
isSpawning={spawn.isPending}
|
||||
error={spawn.error?.message}
|
||||
onSpawn={handleSpawn}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Running
|
||||
if (state === "running") {
|
||||
return (
|
||||
<div className="mb-3 flex items-center gap-2 rounded-lg border border-border bg-card px-3 py-2">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin text-primary" />
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Architect is refining...
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Waiting for input — show inline questions
|
||||
if (state === "waiting" && questions) {
|
||||
return (
|
||||
<div className="mb-3 rounded-lg border border-border bg-card p-4">
|
||||
<h3 className="text-sm font-semibold mb-3">Agent has questions</h3>
|
||||
<QuestionForm
|
||||
questions={questions.questions}
|
||||
onSubmit={handleAnswerSubmit}
|
||||
onCancel={() => {
|
||||
// Can't cancel mid-question — just dismiss
|
||||
}}
|
||||
isSubmitting={resume.isPending}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Completed with proposals
|
||||
if (state === "completed" && proposals && proposals.length > 0) {
|
||||
return (
|
||||
<div className="mb-3">
|
||||
<ContentProposalReview
|
||||
proposals={proposals}
|
||||
agentCreatedAt={new Date(agent!.createdAt)}
|
||||
agentId={agent!.id}
|
||||
onDismiss={handleDismiss}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Completed without proposals (or generic result)
|
||||
if (state === "completed") {
|
||||
return (
|
||||
<div className="mb-3 flex items-center gap-2 rounded-lg border border-border bg-card px-3 py-2">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
Agent completed — no changes proposed.
|
||||
</span>
|
||||
<Button variant="ghost" size="sm" onClick={handleDismiss}>
|
||||
Dismiss
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Crashed
|
||||
if (state === "crashed") {
|
||||
return (
|
||||
<div className="mb-3 rounded-lg border border-destructive/50 bg-destructive/5 px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<AlertCircle className="h-3.5 w-3.5 text-destructive" />
|
||||
<span className="text-sm text-destructive">Agent crashed</span>
|
||||
<RefineSpawnDialog
|
||||
triggerText="Retry"
|
||||
title="Refine Initiative Content"
|
||||
description="An agent will review all pages and suggest improvements."
|
||||
instructionPlaceholder="What should the agent focus on? (optional)"
|
||||
isSpawning={spawn.isPending}
|
||||
error={spawn.error?.message}
|
||||
onSpawn={handleSpawn}
|
||||
trigger={
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="ml-auto"
|
||||
>
|
||||
Retry
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
88
packages/web/src/components/editor/SlashCommandList.tsx
Normal file
88
packages/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";
|
||||
121
packages/web/src/components/editor/SlashCommands.ts
Normal file
121
packages/web/src/components/editor/SlashCommands.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { Extension } from "@tiptap/react";
|
||||
import { ReactRenderer } from "@tiptap/react";
|
||||
import Suggestion from "@tiptap/suggestion";
|
||||
import tippy, { type Instance as TippyInstance } from "tippy.js";
|
||||
import {
|
||||
slashCommandItems,
|
||||
type SlashCommandItem,
|
||||
} from "./slash-command-items";
|
||||
import { SlashCommandList, type SlashCommandListRef } from "./SlashCommandList";
|
||||
|
||||
export const SlashCommands = Extension.create({
|
||||
name: "slashCommands",
|
||||
|
||||
addStorage() {
|
||||
return {
|
||||
onSubpageCreate: null as ((editor: unknown) => void) | null,
|
||||
};
|
||||
},
|
||||
|
||||
addOptions() {
|
||||
return {
|
||||
suggestion: {
|
||||
char: "/",
|
||||
startOfLine: false,
|
||||
command: ({
|
||||
editor,
|
||||
range,
|
||||
props,
|
||||
}: {
|
||||
editor: ReturnType<typeof import("@tiptap/react").useEditor>;
|
||||
range: { from: number; to: number };
|
||||
props: SlashCommandItem;
|
||||
}) => {
|
||||
// Delete the slash command text
|
||||
editor.chain().focus().deleteRange(range).run();
|
||||
// Execute the selected command
|
||||
props.action(editor);
|
||||
},
|
||||
items: ({ query }: { query: string }): SlashCommandItem[] => {
|
||||
return slashCommandItems.filter((item) =>
|
||||
item.label.toLowerCase().includes(query.toLowerCase()),
|
||||
);
|
||||
},
|
||||
render: () => {
|
||||
let component: ReactRenderer<SlashCommandListRef> | null = null;
|
||||
let popup: TippyInstance[] | null = null;
|
||||
|
||||
return {
|
||||
onStart: (props: {
|
||||
editor: ReturnType<typeof import("@tiptap/react").useEditor>;
|
||||
clientRect: (() => DOMRect | null) | null;
|
||||
items: SlashCommandItem[];
|
||||
command: (item: SlashCommandItem) => void;
|
||||
}) => {
|
||||
component = new ReactRenderer(SlashCommandList, {
|
||||
props: {
|
||||
items: props.items,
|
||||
command: props.command,
|
||||
},
|
||||
editor: props.editor,
|
||||
});
|
||||
|
||||
const getReferenceClientRect = props.clientRect;
|
||||
|
||||
popup = tippy("body", {
|
||||
getReferenceClientRect: getReferenceClientRect as () => DOMRect,
|
||||
appendTo: () => document.body,
|
||||
content: component.element,
|
||||
showOnCreate: true,
|
||||
interactive: true,
|
||||
trigger: "manual",
|
||||
placement: "bottom-start",
|
||||
});
|
||||
},
|
||||
|
||||
onUpdate: (props: {
|
||||
items: SlashCommandItem[];
|
||||
command: (item: SlashCommandItem) => void;
|
||||
clientRect: (() => DOMRect | null) | null;
|
||||
}) => {
|
||||
component?.updateProps({
|
||||
items: props.items,
|
||||
command: props.command,
|
||||
});
|
||||
|
||||
if (popup?.[0]) {
|
||||
popup[0].setProps({
|
||||
getReferenceClientRect:
|
||||
props.clientRect as unknown as () => DOMRect,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
onKeyDown: (props: { event: KeyboardEvent }) => {
|
||||
if (props.event.key === "Escape") {
|
||||
popup?.[0]?.hide();
|
||||
return true;
|
||||
}
|
||||
|
||||
return component?.ref?.onKeyDown(props) ?? false;
|
||||
},
|
||||
|
||||
onExit: () => {
|
||||
popup?.[0]?.destroy();
|
||||
component?.destroy();
|
||||
},
|
||||
};
|
||||
},
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
addProseMirrorPlugins() {
|
||||
return [
|
||||
Suggestion({
|
||||
editor: this.editor,
|
||||
...this.options.suggestion,
|
||||
}),
|
||||
];
|
||||
},
|
||||
});
|
||||
361
packages/web/src/components/editor/TiptapEditor.tsx
Normal file
361
packages/web/src/components/editor/TiptapEditor.tsx
Normal file
@@ -0,0 +1,361 @@
|
||||
import { useState, useEffect, useRef, useCallback } from "react";
|
||||
import { useEditor, EditorContent, Extension } from "@tiptap/react";
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import { GripVertical, Plus } from "lucide-react";
|
||||
import StarterKit from "@tiptap/starter-kit";
|
||||
import Placeholder from "@tiptap/extension-placeholder";
|
||||
import Link from "@tiptap/extension-link";
|
||||
import { Table, TableRow, TableCell, TableHeader } from "@tiptap/extension-table";
|
||||
import { Plugin, PluginKey, NodeSelection, TextSelection } from "@tiptap/pm/state";
|
||||
import { Fragment, Slice, type Node as PmNode } from "@tiptap/pm/model";
|
||||
import { SlashCommands } from "./SlashCommands";
|
||||
import { PageLinkExtension } from "./PageLinkExtension";
|
||||
import {
|
||||
BlockSelectionExtension,
|
||||
blockSelectionKey,
|
||||
getBlockRange,
|
||||
} from "./BlockSelectionExtension";
|
||||
|
||||
interface TiptapEditorProps {
|
||||
content: string | null;
|
||||
onUpdate: (json: string) => void;
|
||||
pageId: string;
|
||||
onPageLinkClick?: (pageId: string) => void;
|
||||
onSubpageCreate?: (
|
||||
editor: Editor,
|
||||
) => void;
|
||||
onPageLinkDeleted?: (pageId: string, redo: () => void) => void;
|
||||
}
|
||||
|
||||
export function TiptapEditor({
|
||||
content,
|
||||
onUpdate,
|
||||
pageId,
|
||||
onPageLinkClick,
|
||||
onSubpageCreate,
|
||||
onPageLinkDeleted,
|
||||
}: TiptapEditorProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const onPageLinkDeletedRef = useRef(onPageLinkDeleted);
|
||||
onPageLinkDeletedRef.current = onPageLinkDeleted;
|
||||
const blockIndexRef = useRef<number | null>(null);
|
||||
const savedBlockSelRef = useRef<{ anchorIndex: number; headIndex: number } | null>(null);
|
||||
|
||||
const editor = useEditor(
|
||||
{
|
||||
extensions: [
|
||||
StarterKit,
|
||||
Table.configure({ resizable: true, cellMinWidth: 50 }),
|
||||
TableRow,
|
||||
TableCell,
|
||||
TableHeader,
|
||||
Placeholder.configure({
|
||||
includeChildren: true,
|
||||
placeholder: ({ node }) => {
|
||||
if (node.type.name === 'heading') {
|
||||
return `Heading ${node.attrs.level}`;
|
||||
}
|
||||
return "Type '/' for commands...";
|
||||
},
|
||||
}),
|
||||
Link.configure({
|
||||
openOnClick: false,
|
||||
}),
|
||||
SlashCommands,
|
||||
PageLinkExtension,
|
||||
BlockSelectionExtension,
|
||||
// Detect pageLink node deletions by comparing old/new doc state
|
||||
Extension.create({
|
||||
name: "pageLinkDeletionDetector",
|
||||
addStorage() {
|
||||
return { skipDetection: false };
|
||||
},
|
||||
addProseMirrorPlugins() {
|
||||
const tiptapEditor = this.editor;
|
||||
return [
|
||||
new Plugin({
|
||||
key: new PluginKey("pageLinkDeletionDetector"),
|
||||
appendTransaction(_transactions, oldState, newState) {
|
||||
if (oldState.doc.eq(newState.doc)) return null;
|
||||
|
||||
const oldLinks = new Set<string>();
|
||||
oldState.doc.descendants((node) => {
|
||||
if (node.type.name === "pageLink" && node.attrs.pageId) {
|
||||
oldLinks.add(node.attrs.pageId);
|
||||
}
|
||||
});
|
||||
|
||||
const newLinks = new Set<string>();
|
||||
newState.doc.descendants((node) => {
|
||||
if (node.type.name === "pageLink" && node.attrs.pageId) {
|
||||
newLinks.add(node.attrs.pageId);
|
||||
}
|
||||
});
|
||||
|
||||
for (const removedPageId of oldLinks) {
|
||||
if (!newLinks.has(removedPageId)) {
|
||||
// Fire async to avoid dispatching during appendTransaction
|
||||
setTimeout(() => {
|
||||
if (tiptapEditor.storage.pageLinkDeletionDetector.skipDetection) {
|
||||
tiptapEditor.storage.pageLinkDeletionDetector.skipDetection = false;
|
||||
return;
|
||||
}
|
||||
// Undo the deletion immediately so the link reappears
|
||||
tiptapEditor.commands.undo();
|
||||
// Pass a redo function so the caller can re-delete if confirmed
|
||||
onPageLinkDeletedRef.current?.(
|
||||
removedPageId,
|
||||
() => {
|
||||
tiptapEditor.storage.pageLinkDeletionDetector.skipDetection = true;
|
||||
tiptapEditor.commands.redo();
|
||||
},
|
||||
);
|
||||
}, 0);
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
},
|
||||
}),
|
||||
];
|
||||
},
|
||||
}),
|
||||
],
|
||||
content: content ? JSON.parse(content) : undefined,
|
||||
onUpdate: ({ editor: e }) => {
|
||||
onUpdate(JSON.stringify(e.getJSON()));
|
||||
},
|
||||
editorProps: {
|
||||
attributes: {
|
||||
class:
|
||||
"prose prose-sm prose-p:my-1 prose-headings:mb-1 prose-headings:mt-3 prose-ul:my-1 prose-ol:my-1 prose-li:my-0.5 prose-blockquote:my-1 prose-pre:my-1 prose-hr:my-2 dark:prose-invert max-w-none focus:outline-none min-h-[400px] pl-11 pr-4 py-1",
|
||||
},
|
||||
},
|
||||
},
|
||||
[pageId],
|
||||
);
|
||||
|
||||
// Wire the onSubpageCreate callback into editor storage
|
||||
useEffect(() => {
|
||||
if (editor && onSubpageCreate) {
|
||||
editor.storage.slashCommands.onSubpageCreate = (ed: Editor) => {
|
||||
onSubpageCreate(ed);
|
||||
};
|
||||
}
|
||||
}, [editor, onSubpageCreate]);
|
||||
|
||||
// Handle page link clicks via custom event
|
||||
const handlePageLinkClick = useCallback(
|
||||
(e: Event) => {
|
||||
const detail = (e as CustomEvent).detail;
|
||||
if (detail?.pageId && onPageLinkClick) {
|
||||
onPageLinkClick(detail.pageId);
|
||||
}
|
||||
},
|
||||
[onPageLinkClick],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const el = containerRef.current;
|
||||
if (!el) return;
|
||||
el.addEventListener("page-link-click", handlePageLinkClick);
|
||||
return () =>
|
||||
el.removeEventListener("page-link-click", handlePageLinkClick);
|
||||
}, [handlePageLinkClick]);
|
||||
|
||||
// Floating drag handle: track which block the mouse is over
|
||||
const [handlePos, setHandlePos] = useState<{ top: number; height: number } | null>(null);
|
||||
const blockElRef = useRef<HTMLElement | null>(null);
|
||||
|
||||
const onMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
// If hovering the handle itself, keep current position
|
||||
if ((e.target as HTMLElement).closest("[data-block-handle-row]")) return;
|
||||
|
||||
const editorEl = containerRef.current?.querySelector(".ProseMirror");
|
||||
if (!editorEl || !editor) return;
|
||||
|
||||
// Walk from event target up to a direct child of .ProseMirror
|
||||
let target = e.target as HTMLElement;
|
||||
while (target && target !== editorEl && target.parentElement !== editorEl) {
|
||||
target = target.parentElement!;
|
||||
}
|
||||
|
||||
if (target && target !== editorEl && target.parentElement === editorEl) {
|
||||
blockElRef.current = target;
|
||||
const editorRect = editorEl.getBoundingClientRect();
|
||||
const blockRect = target.getBoundingClientRect();
|
||||
setHandlePos({
|
||||
top: blockRect.top - editorRect.top,
|
||||
height: blockRect.height,
|
||||
});
|
||||
|
||||
// Track top-level block index for block selection
|
||||
try {
|
||||
const pos = editor.view.posAtDOM(target, 0);
|
||||
blockIndexRef.current = editor.view.state.doc.resolve(pos).index(0);
|
||||
} catch {
|
||||
blockIndexRef.current = null;
|
||||
}
|
||||
}
|
||||
// Don't clear — only onMouseLeave clears
|
||||
}, [editor]);
|
||||
|
||||
const onMouseLeave = useCallback(() => {
|
||||
setHandlePos(null);
|
||||
blockElRef.current = null;
|
||||
blockIndexRef.current = null;
|
||||
}, []);
|
||||
|
||||
// Click on drag handle → select block (Shift+click extends)
|
||||
const onHandleClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!editor) return;
|
||||
const idx = blockIndexRef.current;
|
||||
if (idx == null) return;
|
||||
|
||||
// Use saved state from mousedown (PM may have cleared it due to focus change)
|
||||
const existing = savedBlockSelRef.current;
|
||||
|
||||
let newSel;
|
||||
if (e.shiftKey && existing) {
|
||||
newSel = { anchorIndex: existing.anchorIndex, headIndex: idx };
|
||||
} else {
|
||||
newSel = { anchorIndex: idx, headIndex: idx };
|
||||
}
|
||||
|
||||
const tr = editor.view.state.tr.setMeta(blockSelectionKey, newSel);
|
||||
tr.setMeta("blockSelectionInternal", true);
|
||||
editor.view.dispatch(tr);
|
||||
// Refocus editor so Shift+Arrow keys reach PM's handleKeyDown
|
||||
editor.view.focus();
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
// Add a new empty paragraph below the hovered block
|
||||
const onHandleAdd = useCallback(() => {
|
||||
if (!editor || !blockElRef.current) return;
|
||||
const view = editor.view;
|
||||
try {
|
||||
const pos = view.posAtDOM(blockElRef.current, 0);
|
||||
const $pos = view.state.doc.resolve(pos);
|
||||
const after = $pos.after($pos.depth);
|
||||
const paragraph = view.state.schema.nodes.paragraph.create();
|
||||
const tr = view.state.tr.insert(after, paragraph);
|
||||
// Place cursor inside the new paragraph
|
||||
tr.setSelection(TextSelection.create(tr.doc, after + 1));
|
||||
view.dispatch(tr);
|
||||
view.focus();
|
||||
} catch {
|
||||
// posAtDOM can throw if the element isn't in the editor
|
||||
}
|
||||
}, [editor]);
|
||||
|
||||
// Initiate ProseMirror-native drag when handle is dragged
|
||||
const onHandleDragStart = useCallback(
|
||||
(e: React.DragEvent) => {
|
||||
if (!editor || !blockElRef.current) return;
|
||||
|
||||
const view = editor.view;
|
||||
const el = blockElRef.current;
|
||||
// Use saved state from mousedown (PM may have cleared it due to focus change)
|
||||
const bsel = savedBlockSelRef.current;
|
||||
|
||||
try {
|
||||
// Multi-block drag: if block selection is active and hovered block is in range
|
||||
if (bsel && blockIndexRef.current != null) {
|
||||
const from = Math.min(bsel.anchorIndex, bsel.headIndex);
|
||||
const to = Math.max(bsel.anchorIndex, bsel.headIndex);
|
||||
|
||||
if (blockIndexRef.current >= from && blockIndexRef.current <= to) {
|
||||
const blockRange = getBlockRange(view.state, bsel);
|
||||
if (blockRange) {
|
||||
const nodes: PmNode[] = [];
|
||||
let idx = 0;
|
||||
view.state.doc.forEach((node) => {
|
||||
if (idx >= from && idx <= to) nodes.push(node);
|
||||
idx++;
|
||||
});
|
||||
|
||||
const sel = TextSelection.create(
|
||||
view.state.doc,
|
||||
blockRange.fromPos,
|
||||
blockRange.toPos,
|
||||
);
|
||||
const tr = view.state.tr.setSelection(sel);
|
||||
tr.setMeta("blockSelectionInternal", true);
|
||||
view.dispatch(tr);
|
||||
|
||||
view.dragging = {
|
||||
slice: new Slice(Fragment.from(nodes), 0, 0),
|
||||
move: true,
|
||||
};
|
||||
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setDragImage(el, 0, 0);
|
||||
e.dataTransfer.setData("application/x-pm-drag", "true");
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Single-block drag (existing behavior)
|
||||
const pos = view.posAtDOM(el, 0);
|
||||
const $pos = view.state.doc.resolve(pos);
|
||||
const before = $pos.before($pos.depth);
|
||||
|
||||
const sel = NodeSelection.create(view.state.doc, before);
|
||||
view.dispatch(view.state.tr.setSelection(sel));
|
||||
|
||||
view.dragging = { slice: sel.content(), move: true };
|
||||
|
||||
e.dataTransfer.effectAllowed = "move";
|
||||
e.dataTransfer.setDragImage(el, 0, 0);
|
||||
e.dataTransfer.setData("application/x-pm-drag", "true");
|
||||
} catch {
|
||||
// posAtDOM can throw if the element isn't in the editor
|
||||
}
|
||||
},
|
||||
[editor],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="relative"
|
||||
onMouseMove={onMouseMove}
|
||||
onMouseLeave={onMouseLeave}
|
||||
>
|
||||
{handlePos && (
|
||||
<div
|
||||
data-block-handle-row
|
||||
className="absolute left-0 flex items-start z-10"
|
||||
style={{ top: handlePos.top + 1 }}
|
||||
>
|
||||
<div
|
||||
onClick={onHandleAdd}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
className="flex items-center justify-center w-5 h-6 cursor-pointer rounded hover:bg-muted"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5 text-muted-foreground/60" />
|
||||
</div>
|
||||
<div
|
||||
data-drag-handle
|
||||
draggable
|
||||
onMouseDown={() => {
|
||||
if (editor) {
|
||||
savedBlockSelRef.current = blockSelectionKey.getState(editor.view.state) ?? null;
|
||||
}
|
||||
}}
|
||||
onClick={onHandleClick}
|
||||
onDragStart={onHandleDragStart}
|
||||
className="flex items-center justify-center w-5 h-6 cursor-grab rounded hover:bg-muted"
|
||||
>
|
||||
<GripVertical className="h-3.5 w-3.5 text-muted-foreground/60" />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
<EditorContent editor={editor} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
86
packages/web/src/components/editor/slash-command-items.ts
Normal file
86
packages/web/src/components/editor/slash-command-items.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import type { Editor } from "@tiptap/react";
|
||||
|
||||
export interface SlashCommandItem {
|
||||
label: string;
|
||||
icon: string;
|
||||
description: string;
|
||||
/** If true, the action reads onSubpageCreate from editor.storage.slashCommands */
|
||||
isSubpage?: boolean;
|
||||
action: (editor: Editor) => void;
|
||||
}
|
||||
|
||||
export const slashCommandItems: SlashCommandItem[] = [
|
||||
{
|
||||
label: "Heading 1",
|
||||
icon: "H1",
|
||||
description: "Large heading",
|
||||
action: (editor) =>
|
||||
editor.chain().focus().toggleHeading({ level: 1 }).run(),
|
||||
},
|
||||
{
|
||||
label: "Heading 2",
|
||||
icon: "H2",
|
||||
description: "Medium heading",
|
||||
action: (editor) =>
|
||||
editor.chain().focus().toggleHeading({ level: 2 }).run(),
|
||||
},
|
||||
{
|
||||
label: "Heading 3",
|
||||
icon: "H3",
|
||||
description: "Small heading",
|
||||
action: (editor) =>
|
||||
editor.chain().focus().toggleHeading({ level: 3 }).run(),
|
||||
},
|
||||
{
|
||||
label: "Bullet List",
|
||||
icon: "UL",
|
||||
description: "Unordered list",
|
||||
action: (editor) => editor.chain().focus().toggleBulletList().run(),
|
||||
},
|
||||
{
|
||||
label: "Numbered List",
|
||||
icon: "OL",
|
||||
description: "Ordered list",
|
||||
action: (editor) => editor.chain().focus().toggleOrderedList().run(),
|
||||
},
|
||||
{
|
||||
label: "Code Block",
|
||||
icon: "<>",
|
||||
description: "Code snippet",
|
||||
action: (editor) => editor.chain().focus().toggleCodeBlock().run(),
|
||||
},
|
||||
{
|
||||
label: "Quote",
|
||||
icon: "\"",
|
||||
description: "Block quote",
|
||||
action: (editor) => editor.chain().focus().toggleBlockquote().run(),
|
||||
},
|
||||
{
|
||||
label: "Divider",
|
||||
icon: "---",
|
||||
description: "Horizontal rule",
|
||||
action: (editor) => editor.chain().focus().setHorizontalRule().run(),
|
||||
},
|
||||
{
|
||||
label: "Table",
|
||||
icon: "T#",
|
||||
description: "Insert a table",
|
||||
action: (editor) =>
|
||||
editor.chain().focus().insertTable({ rows: 3, cols: 3, withHeaderRow: true }).run(),
|
||||
},
|
||||
{
|
||||
label: "Subpage",
|
||||
icon: "\uD83D\uDCC4",
|
||||
description: "Create a linked subpage",
|
||||
isSubpage: true,
|
||||
action: (editor) => {
|
||||
const callback = editor.storage.slashCommands
|
||||
?.onSubpageCreate as
|
||||
| ((editor: Editor) => void)
|
||||
| undefined;
|
||||
if (callback) {
|
||||
callback(editor);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
90
packages/web/src/components/execution/BreakdownSection.tsx
Normal file
90
packages/web/src/components/execution/BreakdownSection.tsx
Normal file
@@ -0,0 +1,90 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Loader2, Sparkles } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { useSpawnMutation } from "@/hooks/useSpawnMutation";
|
||||
|
||||
interface BreakdownSectionProps {
|
||||
initiativeId: string;
|
||||
phasesLoaded: boolean;
|
||||
phases: Array<{ status: string }>;
|
||||
}
|
||||
|
||||
export function BreakdownSection({
|
||||
initiativeId,
|
||||
phasesLoaded,
|
||||
phases,
|
||||
}: BreakdownSectionProps) {
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
// Breakdown agent tracking
|
||||
const agentsQuery = trpc.listAgents.useQuery();
|
||||
const allAgents = agentsQuery.data ?? [];
|
||||
const breakdownAgent = useMemo(() => {
|
||||
const candidates = allAgents
|
||||
.filter(
|
||||
(a) =>
|
||||
a.mode === "breakdown" &&
|
||||
a.taskId === initiativeId &&
|
||||
["running", "waiting_for_input", "idle"].includes(a.status),
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
);
|
||||
return candidates[0] ?? null;
|
||||
}, [allAgents, initiativeId]);
|
||||
|
||||
const isBreakdownRunning = breakdownAgent?.status === "running";
|
||||
|
||||
const breakdownSpawn = useSpawnMutation(trpc.spawnArchitectBreakdown.useMutation, {
|
||||
onSuccess: () => {
|
||||
void utils.listAgents.invalidate();
|
||||
},
|
||||
showToast: false, // We show our own error UI
|
||||
});
|
||||
|
||||
const handleBreakdown = useCallback(() => {
|
||||
breakdownSpawn.spawn({ initiativeId });
|
||||
}, [initiativeId, breakdownSpawn]);
|
||||
|
||||
// Don't render if we have phases
|
||||
if (phasesLoaded && phases.length > 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Don't render during loading
|
||||
if (!phasesLoaded) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="py-8 text-center space-y-3">
|
||||
<p className="text-muted-foreground">No phases yet</p>
|
||||
{isBreakdownRunning ? (
|
||||
<div className="flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
||||
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||
Breaking down initiative...
|
||||
</div>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleBreakdown}
|
||||
disabled={breakdownSpawn.isSpawning}
|
||||
className="gap-1.5"
|
||||
>
|
||||
<Sparkles className="h-3.5 w-3.5" />
|
||||
{breakdownSpawn.isSpawning
|
||||
? "Starting..."
|
||||
: "Break Down Initiative"}
|
||||
</Button>
|
||||
)}
|
||||
{breakdownSpawn.isError && (
|
||||
<p className="text-xs text-destructive">
|
||||
{breakdownSpawn.error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
150
packages/web/src/components/execution/ExecutionContext.tsx
Normal file
150
packages/web/src/components/execution/ExecutionContext.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import { createContext, useContext, useState, useCallback, useMemo, ReactNode } from "react";
|
||||
import type { SerializedTask } from "@/components/TaskRow";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface TaskCounts {
|
||||
complete: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface FlatTaskEntry {
|
||||
task: SerializedTask;
|
||||
phaseName: string;
|
||||
agentName: string | null;
|
||||
blockedBy: Array<{ name: string; status: string }>;
|
||||
dependents: Array<{ name: string; status: string }>;
|
||||
}
|
||||
|
||||
export interface PhaseData {
|
||||
id: string;
|
||||
initiativeId: string;
|
||||
number: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
createdAt: string | Date;
|
||||
updatedAt: string | Date;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Context
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ExecutionContextValue {
|
||||
// Task selection
|
||||
selectedTaskId: string | null;
|
||||
setSelectedTaskId: (taskId: string | null) => void;
|
||||
|
||||
// Task counts by phase
|
||||
taskCountsByPhase: Record<string, TaskCounts>;
|
||||
handleTaskCounts: (phaseId: string, counts: TaskCounts) => void;
|
||||
|
||||
// Tasks by phase
|
||||
tasksByPhase: Record<string, FlatTaskEntry[]>;
|
||||
handleRegisterTasks: (phaseId: string, entries: FlatTaskEntry[]) => void;
|
||||
|
||||
// Derived data
|
||||
allFlatTasks: FlatTaskEntry[];
|
||||
selectedEntry: FlatTaskEntry | null;
|
||||
tasksComplete: number;
|
||||
tasksTotal: number;
|
||||
}
|
||||
|
||||
const ExecutionContext = createContext<ExecutionContextValue | null>(null);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface ExecutionProviderProps {
|
||||
children: ReactNode;
|
||||
}
|
||||
|
||||
export function ExecutionProvider({ children }: ExecutionProviderProps) {
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||
const [taskCountsByPhase, setTaskCountsByPhase] = useState<
|
||||
Record<string, TaskCounts>
|
||||
>({});
|
||||
const [tasksByPhase, setTasksByPhase] = useState<
|
||||
Record<string, FlatTaskEntry[]>
|
||||
>({});
|
||||
|
||||
const handleTaskCounts = useCallback(
|
||||
(phaseId: string, counts: TaskCounts) => {
|
||||
setTaskCountsByPhase((prev) => {
|
||||
if (
|
||||
prev[phaseId]?.complete === counts.complete &&
|
||||
prev[phaseId]?.total === counts.total
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return { ...prev, [phaseId]: counts };
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleRegisterTasks = useCallback(
|
||||
(phaseId: string, entries: FlatTaskEntry[]) => {
|
||||
setTasksByPhase((prev) => {
|
||||
if (prev[phaseId] === entries) return prev;
|
||||
return { ...prev, [phaseId]: entries };
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const allFlatTasks = useMemo(
|
||||
() => Object.values(tasksByPhase).flat(),
|
||||
[tasksByPhase]
|
||||
);
|
||||
|
||||
const selectedEntry = useMemo(
|
||||
() => selectedTaskId
|
||||
? allFlatTasks.find((e) => e.task.id === selectedTaskId) ?? null
|
||||
: null,
|
||||
[selectedTaskId, allFlatTasks]
|
||||
);
|
||||
|
||||
const { tasksComplete, tasksTotal } = useMemo(() => {
|
||||
const allTaskCounts = Object.values(taskCountsByPhase);
|
||||
return {
|
||||
tasksComplete: allTaskCounts.reduce((s, c) => s + c.complete, 0),
|
||||
tasksTotal: allTaskCounts.reduce((s, c) => s + c.total, 0),
|
||||
};
|
||||
}, [taskCountsByPhase]);
|
||||
|
||||
const value: ExecutionContextValue = {
|
||||
selectedTaskId,
|
||||
setSelectedTaskId,
|
||||
taskCountsByPhase,
|
||||
handleTaskCounts,
|
||||
tasksByPhase,
|
||||
handleRegisterTasks,
|
||||
allFlatTasks,
|
||||
selectedEntry,
|
||||
tasksComplete,
|
||||
tasksTotal,
|
||||
};
|
||||
|
||||
return (
|
||||
<ExecutionContext.Provider value={value}>
|
||||
{children}
|
||||
</ExecutionContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Hook
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function useExecutionContext() {
|
||||
const context = useContext(ExecutionContext);
|
||||
if (!context) {
|
||||
throw new Error("useExecutionContext must be used within ExecutionProvider");
|
||||
}
|
||||
return context;
|
||||
}
|
||||
60
packages/web/src/components/execution/PhaseActions.tsx
Normal file
60
packages/web/src/components/execution/PhaseActions.tsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import { useCallback, useMemo } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
|
||||
interface PhaseActionsProps {
|
||||
initiativeId: string;
|
||||
phases: Array<{ id: string; status: string }>;
|
||||
}
|
||||
|
||||
export function PhaseActions({ initiativeId, phases }: PhaseActionsProps) {
|
||||
const queuePhaseMutation = trpc.queuePhase.useMutation();
|
||||
|
||||
// Breakdown agent tracking for status display
|
||||
const agentsQuery = trpc.listAgents.useQuery();
|
||||
const allAgents = agentsQuery.data ?? [];
|
||||
const breakdownAgent = useMemo(() => {
|
||||
const candidates = allAgents
|
||||
.filter(
|
||||
(a) =>
|
||||
a.mode === "breakdown" &&
|
||||
a.taskId === initiativeId &&
|
||||
["running", "waiting_for_input", "idle"].includes(a.status),
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
);
|
||||
return candidates[0] ?? null;
|
||||
}, [allAgents, initiativeId]);
|
||||
|
||||
const isBreakdownRunning = breakdownAgent?.status === "running";
|
||||
const hasPendingPhases = phases.some((p) => p.status === "pending");
|
||||
|
||||
const handleQueueAll = useCallback(() => {
|
||||
const pendingPhases = phases.filter((p) => p.status === "pending");
|
||||
for (const phase of pendingPhases) {
|
||||
queuePhaseMutation.mutate({ phaseId: phase.id });
|
||||
}
|
||||
}, [phases, queuePhaseMutation]);
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
{isBreakdownRunning && (
|
||||
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Breaking down...
|
||||
</div>
|
||||
)}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!hasPendingPhases}
|
||||
onClick={handleQueueAll}
|
||||
>
|
||||
Queue All
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
137
packages/web/src/components/execution/PhaseWithTasks.tsx
Normal file
137
packages/web/src/components/execution/PhaseWithTasks.tsx
Normal file
@@ -0,0 +1,137 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { PhaseAccordion } from "@/components/PhaseAccordion";
|
||||
import { PlanTasksFetcher } from "./PlanTasksFetcher";
|
||||
import type { SerializedTask } from "@/components/TaskRow";
|
||||
import type { TaskCounts, FlatTaskEntry } from "./ExecutionContext";
|
||||
import { sortByPriorityAndQueueTime } from "@codewalk-district/shared";
|
||||
|
||||
interface PhaseWithTasksProps {
|
||||
phase: {
|
||||
id: string;
|
||||
initiativeId: string;
|
||||
number: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
defaultExpanded: boolean;
|
||||
onTaskClick: (taskId: string) => void;
|
||||
onTaskCounts: (phaseId: string, counts: TaskCounts) => void;
|
||||
registerTasks: (phaseId: string, entries: FlatTaskEntry[]) => void;
|
||||
}
|
||||
|
||||
export function PhaseWithTasks({
|
||||
phase,
|
||||
defaultExpanded,
|
||||
onTaskClick,
|
||||
onTaskCounts,
|
||||
registerTasks,
|
||||
}: PhaseWithTasksProps) {
|
||||
const plansQuery = trpc.listPlans.useQuery({ phaseId: phase.id });
|
||||
const depsQuery = trpc.getPhaseDependencies.useQuery({ phaseId: phase.id });
|
||||
|
||||
const plans = plansQuery.data ?? [];
|
||||
const planIds = plans.map((p) => p.id);
|
||||
|
||||
return (
|
||||
<PhaseWithTasksInner
|
||||
phase={phase}
|
||||
planIds={planIds}
|
||||
plansLoaded={plansQuery.isSuccess}
|
||||
phaseDependencyIds={depsQuery.data?.dependencies ?? []}
|
||||
defaultExpanded={defaultExpanded}
|
||||
onTaskClick={onTaskClick}
|
||||
onTaskCounts={onTaskCounts}
|
||||
registerTasks={registerTasks}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
interface PhaseWithTasksInnerProps {
|
||||
phase: PhaseWithTasksProps["phase"];
|
||||
planIds: string[];
|
||||
plansLoaded: boolean;
|
||||
phaseDependencyIds: string[];
|
||||
defaultExpanded: boolean;
|
||||
onTaskClick: (taskId: string) => void;
|
||||
onTaskCounts: (phaseId: string, counts: TaskCounts) => void;
|
||||
registerTasks: (phaseId: string, entries: FlatTaskEntry[]) => void;
|
||||
}
|
||||
|
||||
function PhaseWithTasksInner({
|
||||
phase,
|
||||
planIds,
|
||||
plansLoaded,
|
||||
phaseDependencyIds: _phaseDependencyIds,
|
||||
defaultExpanded,
|
||||
onTaskClick,
|
||||
onTaskCounts,
|
||||
registerTasks,
|
||||
}: PhaseWithTasksInnerProps) {
|
||||
const [planTasks, setPlanTasks] = useState<Record<string, SerializedTask[]>>(
|
||||
{},
|
||||
);
|
||||
|
||||
const handlePlanTasks = useCallback(
|
||||
(planId: string, tasks: SerializedTask[]) => {
|
||||
setPlanTasks((prev) => {
|
||||
if (prev[planId] === tasks) return prev;
|
||||
return { ...prev, [planId]: tasks };
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Propagate derived counts and entries outside the setState updater
|
||||
// to avoid synchronous setState-inside-setState cascades.
|
||||
useEffect(() => {
|
||||
const allTasks = Object.values(planTasks).flat();
|
||||
const complete = allTasks.filter(
|
||||
(t) => t.status === "completed",
|
||||
).length;
|
||||
onTaskCounts(phase.id, { complete, total: allTasks.length });
|
||||
|
||||
const entries: FlatTaskEntry[] = allTasks.map((task) => ({
|
||||
task,
|
||||
phaseName: `Phase ${phase.number}: ${phase.name}`,
|
||||
agentName: null,
|
||||
blockedBy: [],
|
||||
dependents: [],
|
||||
}));
|
||||
registerTasks(phase.id, entries);
|
||||
}, [planTasks, phase.id, phase.number, phase.name, onTaskCounts, registerTasks]);
|
||||
|
||||
const allTasks = planIds.flatMap((pid) => planTasks[pid] ?? []);
|
||||
const sortedTasks = sortByPriorityAndQueueTime(allTasks);
|
||||
const taskEntries = sortedTasks.map((task) => ({
|
||||
task,
|
||||
agentName: null as string | null,
|
||||
blockedBy: [] as Array<{ name: string; status: string }>,
|
||||
}));
|
||||
|
||||
const phaseDeps: Array<{ name: string; status: string }> = [];
|
||||
|
||||
return (
|
||||
<>
|
||||
{plansLoaded &&
|
||||
planIds.map((planId) => (
|
||||
<PlanTasksFetcher
|
||||
key={planId}
|
||||
planId={planId}
|
||||
onTasks={handlePlanTasks}
|
||||
/>
|
||||
))}
|
||||
|
||||
<PhaseAccordion
|
||||
phase={phase}
|
||||
tasks={taskEntries}
|
||||
defaultExpanded={defaultExpanded}
|
||||
phaseDependencies={phaseDeps}
|
||||
onTaskClick={onTaskClick}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
74
packages/web/src/components/execution/PhasesList.tsx
Normal file
74
packages/web/src/components/execution/PhasesList.tsx
Normal file
@@ -0,0 +1,74 @@
|
||||
import { Skeleton } from "@/components/Skeleton";
|
||||
import { useExecutionContext, type PhaseData } from "./ExecutionContext";
|
||||
import { PhaseWithTasks } from "./PhaseWithTasks";
|
||||
import { BreakdownSection } from "./BreakdownSection";
|
||||
|
||||
interface PhasesListProps {
|
||||
initiativeId: string;
|
||||
phases: PhaseData[];
|
||||
phasesLoading: boolean;
|
||||
phasesLoaded: boolean;
|
||||
}
|
||||
|
||||
export function PhasesList({
|
||||
initiativeId,
|
||||
phases,
|
||||
phasesLoading,
|
||||
phasesLoaded,
|
||||
}: PhasesListProps) {
|
||||
const { setSelectedTaskId, handleTaskCounts, handleRegisterTasks } =
|
||||
useExecutionContext();
|
||||
|
||||
const firstIncompletePhaseIndex = phases.findIndex(
|
||||
(p) => p.status !== "completed",
|
||||
);
|
||||
|
||||
if (phasesLoading) {
|
||||
return (
|
||||
<div className="space-y-1 pt-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (phasesLoaded && phases.length === 0) {
|
||||
return (
|
||||
<BreakdownSection
|
||||
initiativeId={initiativeId}
|
||||
phasesLoaded={phasesLoaded}
|
||||
phases={phases}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{phasesLoaded &&
|
||||
phases.map((phase, idx) => {
|
||||
const serializedPhase = {
|
||||
id: phase.id,
|
||||
initiativeId: phase.initiativeId,
|
||||
number: phase.number,
|
||||
name: phase.name,
|
||||
description: phase.description,
|
||||
status: phase.status,
|
||||
createdAt: String(phase.createdAt),
|
||||
updatedAt: String(phase.updatedAt),
|
||||
};
|
||||
|
||||
return (
|
||||
<PhaseWithTasks
|
||||
key={phase.id}
|
||||
phase={serializedPhase}
|
||||
defaultExpanded={idx === firstIncompletePhaseIndex}
|
||||
onTaskClick={setSelectedTaskId}
|
||||
onTaskCounts={handleTaskCounts}
|
||||
registerTasks={handleRegisterTasks}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
20
packages/web/src/components/execution/PlanTasksFetcher.tsx
Normal file
20
packages/web/src/components/execution/PlanTasksFetcher.tsx
Normal file
@@ -0,0 +1,20 @@
|
||||
import { useEffect } from "react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import type { SerializedTask } from "@/components/TaskRow";
|
||||
|
||||
interface PlanTasksFetcherProps {
|
||||
planId: string;
|
||||
onTasks: (planId: string, tasks: SerializedTask[]) => void;
|
||||
}
|
||||
|
||||
export function PlanTasksFetcher({ planId, onTasks }: PlanTasksFetcherProps) {
|
||||
const tasksQuery = trpc.listTasks.useQuery({ planId });
|
||||
|
||||
useEffect(() => {
|
||||
if (tasksQuery.data) {
|
||||
onTasks(planId, tasksQuery.data as unknown as SerializedTask[]);
|
||||
}
|
||||
}, [tasksQuery.data, planId, onTasks]);
|
||||
|
||||
return null;
|
||||
}
|
||||
28
packages/web/src/components/execution/ProgressSidebar.tsx
Normal file
28
packages/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
packages/web/src/components/execution/TaskModal.tsx
Normal file
34
packages/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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
9
packages/web/src/components/execution/index.ts
Normal file
9
packages/web/src/components/execution/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
export { ExecutionProvider, useExecutionContext } from "./ExecutionContext";
|
||||
export { BreakdownSection } from "./BreakdownSection";
|
||||
export { PhaseActions } from "./PhaseActions";
|
||||
export { PhasesList } from "./PhasesList";
|
||||
export { PhaseWithTasks } from "./PhaseWithTasks";
|
||||
export { PlanTasksFetcher } from "./PlanTasksFetcher";
|
||||
export { ProgressSidebar } from "./ProgressSidebar";
|
||||
export { TaskModal } from "./TaskModal";
|
||||
export type { TaskCounts, FlatTaskEntry, PhaseData } from "./ExecutionContext";
|
||||
18
packages/web/src/hooks/index.ts
Normal file
18
packages/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 { useRefineAgent } from './useRefineAgent.js';
|
||||
export { useSubscriptionWithErrorHandling } from './useSubscriptionWithErrorHandling.js';
|
||||
|
||||
export type {
|
||||
RefineAgentState,
|
||||
ContentProposal,
|
||||
SpawnRefineAgentOptions,
|
||||
UseRefineAgentResult,
|
||||
} from './useRefineAgent.js';
|
||||
68
packages/web/src/hooks/useAutoSave.ts
Normal file
68
packages/web/src/hooks/useAutoSave.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import { useRef, useCallback, useEffect } from "react";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
|
||||
interface UseAutoSaveOptions {
|
||||
debounceMs?: number;
|
||||
onSaved?: () => void;
|
||||
}
|
||||
|
||||
export function useAutoSave({ debounceMs = 1000, onSaved }: UseAutoSaveOptions = {}) {
|
||||
const updateMutation = trpc.updatePage.useMutation({ onSuccess: onSaved });
|
||||
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const pendingRef = useRef<{
|
||||
id: string;
|
||||
title?: string;
|
||||
content?: string | null;
|
||||
} | null>(null);
|
||||
|
||||
const flush = useCallback(() => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
timerRef.current = null;
|
||||
}
|
||||
if (pendingRef.current) {
|
||||
const data = pendingRef.current;
|
||||
pendingRef.current = null;
|
||||
const promise = updateMutation.mutateAsync(data);
|
||||
// Prevent unhandled rejection when called from debounce timer
|
||||
promise.catch(() => {});
|
||||
return promise;
|
||||
}
|
||||
return Promise.resolve();
|
||||
}, [updateMutation]);
|
||||
|
||||
const save = useCallback(
|
||||
(id: string, data: { title?: string; content?: string | null }) => {
|
||||
pendingRef.current = { id, ...data };
|
||||
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
|
||||
timerRef.current = setTimeout(() => void flush(), debounceMs);
|
||||
},
|
||||
[debounceMs, flush],
|
||||
);
|
||||
|
||||
// Flush on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (timerRef.current) {
|
||||
clearTimeout(timerRef.current);
|
||||
}
|
||||
if (pendingRef.current) {
|
||||
// Fire off the save — mutation will complete asynchronously
|
||||
const data = pendingRef.current;
|
||||
pendingRef.current = null;
|
||||
updateMutation.mutate(data);
|
||||
}
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return {
|
||||
save,
|
||||
flush,
|
||||
isSaving: updateMutation.isPending,
|
||||
};
|
||||
}
|
||||
157
packages/web/src/hooks/useDebounce.ts
Normal file
157
packages/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,
|
||||
};
|
||||
}
|
||||
253
packages/web/src/hooks/useRefineAgent.ts
Normal file
253
packages/web/src/hooks/useRefineAgent.ts
Normal file
@@ -0,0 +1,253 @@
|
||||
import { useMemo, useCallback, useRef } from 'react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import type { Agent, PendingQuestions } from '@codewalk-district/shared';
|
||||
|
||||
export type RefineAgentState = 'none' | 'running' | 'waiting' | 'completed' | 'crashed';
|
||||
|
||||
export interface ContentProposal {
|
||||
pageId: string;
|
||||
pageTitle: string;
|
||||
summary: string;
|
||||
markdown: string;
|
||||
}
|
||||
|
||||
export interface SpawnRefineAgentOptions {
|
||||
initiativeId: string;
|
||||
instruction?: string;
|
||||
}
|
||||
|
||||
export interface UseRefineAgentResult {
|
||||
/** Current refine agent for the initiative */
|
||||
agent: Agent | null;
|
||||
/** Current state of the refine agent */
|
||||
state: RefineAgentState;
|
||||
/** Questions from the agent (when state is 'waiting') */
|
||||
questions: PendingQuestions | null;
|
||||
/** Parsed content proposals (when state is 'completed') */
|
||||
proposals: ContentProposal[] | null;
|
||||
/** Raw result message (when state is 'completed') */
|
||||
result: string | null;
|
||||
/** Mutation for spawning a new refine agent */
|
||||
spawn: {
|
||||
mutate: (options: SpawnRefineAgentOptions) => void;
|
||||
isPending: boolean;
|
||||
error: Error | null;
|
||||
};
|
||||
/** Mutation for resuming agent with answers */
|
||||
resume: {
|
||||
mutate: (answers: Record<string, string>) => void;
|
||||
isPending: boolean;
|
||||
error: Error | null;
|
||||
};
|
||||
/** Whether any queries are loading */
|
||||
isLoading: boolean;
|
||||
/** Function to refresh agent data */
|
||||
refresh: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing refine agents for a specific initiative.
|
||||
*
|
||||
* Encapsulates the logic for finding, spawning, and interacting with refine agents
|
||||
* that analyze and suggest improvements to initiative content.
|
||||
*
|
||||
* @param initiativeId - The ID of the initiative to manage refine agents for
|
||||
* @returns Object with agent state, mutations, and helper functions
|
||||
*
|
||||
* @example
|
||||
* ```tsx
|
||||
* function RefineSection({ initiativeId }: { initiativeId: string }) {
|
||||
* const {
|
||||
* state,
|
||||
* agent,
|
||||
* questions,
|
||||
* proposals,
|
||||
* spawn,
|
||||
* resume,
|
||||
* refresh
|
||||
* } = useRefineAgent(initiativeId);
|
||||
*
|
||||
* const handleSpawn = () => {
|
||||
* spawn.mutate({
|
||||
* initiativeId,
|
||||
* instruction: 'Focus on clarity and structure'
|
||||
* });
|
||||
* };
|
||||
*
|
||||
* if (state === 'none') {
|
||||
* return (
|
||||
* <button onClick={handleSpawn} disabled={spawn.isPending}>
|
||||
* Start Refine Agent
|
||||
* </button>
|
||||
* );
|
||||
* }
|
||||
*
|
||||
* if (state === 'waiting' && questions) {
|
||||
* return (
|
||||
* <QuestionForm
|
||||
* questions={questions.questions}
|
||||
* onSubmit={(answers) => resume.mutate(answers)}
|
||||
* isSubmitting={resume.isPending}
|
||||
* />
|
||||
* );
|
||||
* }
|
||||
*
|
||||
* if (state === 'completed' && proposals) {
|
||||
* return <ProposalReview proposals={proposals} onDismiss={refresh} />;
|
||||
* }
|
||||
*
|
||||
* return <div>Agent is {state}...</div>;
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export function useRefineAgent(initiativeId: string): UseRefineAgentResult {
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
// Query all agents and find the active refine agent
|
||||
const agentsQuery = trpc.listAgents.useQuery();
|
||||
const agents = agentsQuery.data ?? [];
|
||||
|
||||
const agent = useMemo(() => {
|
||||
// Find the most recent refine agent for this initiative
|
||||
const candidates = agents
|
||||
.filter(
|
||||
(a) =>
|
||||
a.mode === 'refine' &&
|
||||
a.initiativeId === initiativeId &&
|
||||
['running', 'waiting_for_input', 'idle', 'crashed'].includes(a.status) &&
|
||||
!a.userDismissedAt, // Exclude dismissed agents
|
||||
)
|
||||
.sort(
|
||||
(a, b) =>
|
||||
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
|
||||
);
|
||||
return candidates[0] ?? null;
|
||||
}, [agents, initiativeId]);
|
||||
|
||||
const state: RefineAgentState = useMemo(() => {
|
||||
if (!agent) return 'none';
|
||||
switch (agent.status) {
|
||||
case 'running':
|
||||
return 'running';
|
||||
case 'waiting_for_input':
|
||||
return 'waiting';
|
||||
case 'idle':
|
||||
return 'completed';
|
||||
case 'crashed':
|
||||
return 'crashed';
|
||||
default:
|
||||
return 'none';
|
||||
}
|
||||
}, [agent]);
|
||||
|
||||
// Fetch questions when waiting for input
|
||||
const questionsQuery = trpc.getAgentQuestions.useQuery(
|
||||
{ id: agent?.id ?? '' },
|
||||
{ enabled: state === 'waiting' && !!agent },
|
||||
);
|
||||
|
||||
// Fetch result when completed
|
||||
const resultQuery = trpc.getAgentResult.useQuery(
|
||||
{ id: agent?.id ?? '' },
|
||||
{ enabled: state === 'completed' && !!agent },
|
||||
);
|
||||
|
||||
// Parse proposals from result
|
||||
const { proposals, result } = useMemo(() => {
|
||||
if (!resultQuery.data?.success || !resultQuery.data.message) {
|
||||
return { proposals: null, result: null };
|
||||
}
|
||||
|
||||
const message = resultQuery.data.message;
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(message);
|
||||
if (parsed.proposals && Array.isArray(parsed.proposals)) {
|
||||
const proposals: ContentProposal[] = parsed.proposals.map(
|
||||
(p: { pageId: string; title?: string; pageTitle?: string; summary: string; body?: string; markdown?: string }) => ({
|
||||
pageId: p.pageId,
|
||||
pageTitle: p.pageTitle ?? p.title ?? '',
|
||||
summary: p.summary,
|
||||
markdown: p.markdown ?? p.body ?? '',
|
||||
}),
|
||||
);
|
||||
return { proposals, result: message };
|
||||
}
|
||||
} catch {
|
||||
// Not JSON — treat as regular result
|
||||
}
|
||||
|
||||
return { proposals: null, result: message };
|
||||
}, [resultQuery.data]);
|
||||
|
||||
// Spawn mutation
|
||||
const spawnMutation = trpc.spawnArchitectRefine.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.listAgents.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
// Resume mutation
|
||||
const resumeMutation = trpc.resumeAgent.useMutation({
|
||||
onSuccess: () => {
|
||||
void utils.listAgents.invalidate();
|
||||
},
|
||||
});
|
||||
|
||||
// Keep mutation functions in refs so the returned spawn/resume objects are
|
||||
// stable across renders. tRPC mutation objects change identity every render,
|
||||
// which cascades into unstable callbacks → unstable props → Radix Dialog
|
||||
// re-renders that trigger the React 19 compose-refs infinite loop.
|
||||
const spawnMutateRef = useRef(spawnMutation.mutate);
|
||||
spawnMutateRef.current = spawnMutation.mutate;
|
||||
const agentRef = useRef(agent);
|
||||
agentRef.current = agent;
|
||||
const resumeMutateRef = useRef(resumeMutation.mutate);
|
||||
resumeMutateRef.current = resumeMutation.mutate;
|
||||
|
||||
const spawnFn = useCallback(({ initiativeId, instruction }: SpawnRefineAgentOptions) => {
|
||||
spawnMutateRef.current({
|
||||
initiativeId,
|
||||
instruction: instruction?.trim() || undefined,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const spawn = useMemo(() => ({
|
||||
mutate: spawnFn,
|
||||
isPending: spawnMutation.isPending,
|
||||
error: spawnMutation.error,
|
||||
}), [spawnFn, spawnMutation.isPending, spawnMutation.error]);
|
||||
|
||||
const resumeFn = useCallback((answers: Record<string, string>) => {
|
||||
const a = agentRef.current;
|
||||
if (a) {
|
||||
resumeMutateRef.current({ id: a.id, answers });
|
||||
}
|
||||
}, []);
|
||||
|
||||
const resume = useMemo(() => ({
|
||||
mutate: resumeFn,
|
||||
isPending: resumeMutation.isPending,
|
||||
error: resumeMutation.error,
|
||||
}), [resumeFn, resumeMutation.isPending, resumeMutation.error]);
|
||||
|
||||
const refresh = useCallback(() => {
|
||||
void utils.listAgents.invalidate();
|
||||
}, [utils]);
|
||||
|
||||
const isLoading = agentsQuery.isLoading ||
|
||||
(state === 'waiting' && questionsQuery.isLoading) ||
|
||||
(state === 'completed' && resultQuery.isLoading);
|
||||
|
||||
return {
|
||||
agent,
|
||||
state,
|
||||
questions: questionsQuery.data ?? null,
|
||||
proposals,
|
||||
result,
|
||||
spawn,
|
||||
resume,
|
||||
isLoading,
|
||||
refresh,
|
||||
};
|
||||
}
|
||||
49
packages/web/src/hooks/useSpawnMutation.ts
Normal file
49
packages/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
packages/web/src/hooks/useSubscriptionWithErrorHandling.ts
Normal file
180
packages/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.subscribeToEvents.useSubscription>,
|
||||
options: UseSubscriptionWithErrorHandlingOptions = {}
|
||||
) {
|
||||
const {
|
||||
autoReconnect = true,
|
||||
reconnectDelay = 1000,
|
||||
maxReconnectAttempts = 5,
|
||||
enabled = true,
|
||||
} = options;
|
||||
|
||||
const [state, setState] = useState<SubscriptionState>({
|
||||
isConnected: false,
|
||||
isConnecting: false,
|
||||
error: null,
|
||||
reconnectAttempts: 0,
|
||||
lastEventId: null,
|
||||
});
|
||||
|
||||
// Store callbacks in refs so they never appear in effect deps.
|
||||
// Callers pass inline arrows that change identity every render —
|
||||
// putting them in deps causes setState → re-render → new callback → effect re-fire → infinite loop.
|
||||
const callbacksRef = useRef(options);
|
||||
callbacksRef.current = options;
|
||||
|
||||
const reconnectAttemptsRef = useRef(0);
|
||||
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const mountedRef = useRef(true);
|
||||
|
||||
// Clear reconnect timeout on unmount
|
||||
useEffect(() => {
|
||||
mountedRef.current = true;
|
||||
return () => {
|
||||
mountedRef.current = false;
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
}
|
||||
callbacksRef.current.onStopped?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const scheduleReconnect = useCallback(() => {
|
||||
if (!autoReconnect || reconnectAttemptsRef.current >= maxReconnectAttempts || !mountedRef.current) {
|
||||
return;
|
||||
}
|
||||
|
||||
reconnectTimeoutRef.current = setTimeout(() => {
|
||||
if (mountedRef.current) {
|
||||
reconnectAttemptsRef.current += 1;
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isConnecting: true,
|
||||
reconnectAttempts: reconnectAttemptsRef.current,
|
||||
}));
|
||||
}
|
||||
}, reconnectDelay);
|
||||
}, [autoReconnect, maxReconnectAttempts, reconnectDelay]);
|
||||
|
||||
const subscriptionResult = subscription();
|
||||
|
||||
// Handle subscription state changes.
|
||||
// Only depends on primitive/stable values — never on caller callbacks.
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setState(prev => {
|
||||
if (!prev.isConnected && !prev.isConnecting && prev.error === null) return prev;
|
||||
return { ...prev, isConnected: false, isConnecting: false, error: null };
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
if (subscriptionResult.status === 'pending') {
|
||||
setState(prev => {
|
||||
if (prev.isConnecting && prev.error === null) return prev;
|
||||
return { ...prev, isConnecting: true, error: null };
|
||||
});
|
||||
callbacksRef.current.onStarted?.();
|
||||
} else if (subscriptionResult.status === 'error') {
|
||||
const error = subscriptionResult.error instanceof Error
|
||||
? subscriptionResult.error
|
||||
: new Error('Subscription error');
|
||||
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isConnected: false,
|
||||
isConnecting: false,
|
||||
error,
|
||||
}));
|
||||
|
||||
callbacksRef.current.onError?.(error);
|
||||
scheduleReconnect();
|
||||
} else if (subscriptionResult.status === 'success') {
|
||||
reconnectAttemptsRef.current = 0;
|
||||
setState(prev => {
|
||||
if (prev.isConnected && !prev.isConnecting && prev.error === null && prev.reconnectAttempts === 0) return prev;
|
||||
return { ...prev, isConnected: true, isConnecting: false, error: null, reconnectAttempts: 0 };
|
||||
});
|
||||
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
}
|
||||
}, [enabled, subscriptionResult.status, subscriptionResult.error, scheduleReconnect]);
|
||||
|
||||
// Handle incoming data
|
||||
useEffect(() => {
|
||||
if (subscriptionResult.data) {
|
||||
setState(prev => ({ ...prev, lastEventId: subscriptionResult.data.id }));
|
||||
callbacksRef.current.onData?.(subscriptionResult.data);
|
||||
}
|
||||
}, [subscriptionResult.data]);
|
||||
|
||||
return {
|
||||
...state,
|
||||
/** Manually trigger a reconnection attempt */
|
||||
reconnect: () => {
|
||||
if (mountedRef.current) {
|
||||
reconnectAttemptsRef.current = 0;
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
isConnecting: true,
|
||||
error: null,
|
||||
reconnectAttempts: 0,
|
||||
}));
|
||||
}
|
||||
},
|
||||
/** Reset error state and reconnection attempts */
|
||||
reset: () => {
|
||||
if (reconnectTimeoutRef.current) {
|
||||
clearTimeout(reconnectTimeoutRef.current);
|
||||
reconnectTimeoutRef.current = null;
|
||||
}
|
||||
reconnectAttemptsRef.current = 0;
|
||||
setState(prev => ({
|
||||
...prev,
|
||||
error: null,
|
||||
reconnectAttempts: 0,
|
||||
isConnecting: false,
|
||||
}));
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -60,3 +60,71 @@
|
||||
min-height: 100vh;
|
||||
}
|
||||
}
|
||||
|
||||
/* Notion-style page link blocks inside the editor */
|
||||
.page-link-block {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
padding: 0.2rem 0.25rem;
|
||||
border-radius: 0.25rem;
|
||||
cursor: pointer;
|
||||
color: hsl(var(--foreground));
|
||||
font-size: 0.9375rem;
|
||||
line-height: 1.4;
|
||||
transition: background-color 0.15s;
|
||||
}
|
||||
|
||||
.page-link-block:hover {
|
||||
background-color: hsl(var(--muted));
|
||||
}
|
||||
|
||||
.page-link-block svg {
|
||||
color: hsl(var(--muted-foreground));
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Block selection highlight */
|
||||
.ProseMirror .block-selected {
|
||||
background-color: hsl(var(--primary) / 0.08);
|
||||
border-radius: 0.25rem;
|
||||
box-shadow: 0.375rem 0 0 hsl(var(--primary) / 0.08), -0.375rem 0 0 hsl(var(--primary) / 0.08);
|
||||
}
|
||||
.dark .ProseMirror .block-selected {
|
||||
background-color: hsl(var(--primary) / 0.12);
|
||||
box-shadow: 0.375rem 0 0 hsl(var(--primary) / 0.12), -0.375rem 0 0 hsl(var(--primary) / 0.12);
|
||||
}
|
||||
|
||||
/* Hide cursor and text selection during block selection mode */
|
||||
.ProseMirror.has-block-selection {
|
||||
caret-color: transparent;
|
||||
}
|
||||
.ProseMirror.has-block-selection *::selection {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
/* Notion-style placeholder on empty blocks */
|
||||
.ProseMirror .is-empty::before {
|
||||
color: hsl(var(--muted-foreground));
|
||||
content: attr(data-placeholder);
|
||||
float: left;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Table wrapper overflow */
|
||||
.ProseMirror .tableWrapper { overflow-x: auto; margin: 1em 0; }
|
||||
|
||||
/* Cell positioning for resize handles */
|
||||
.ProseMirror table td, .ProseMirror table th { position: relative; min-width: 50px; vertical-align: top; }
|
||||
|
||||
/* Column resize handle */
|
||||
.ProseMirror .column-resize-handle { position: absolute; right: -2px; top: 0; bottom: -2px; width: 4px; background-color: hsl(var(--primary) / 0.4); pointer-events: none; z-index: 20; }
|
||||
|
||||
/* Resize cursor */
|
||||
.ProseMirror.resize-cursor { cursor: col-resize; }
|
||||
|
||||
/* Selected cell highlight */
|
||||
.ProseMirror td.selectedCell, .ProseMirror th.selectedCell { background-color: hsl(var(--primary) / 0.08); }
|
||||
.dark .ProseMirror td.selectedCell, .dark .ProseMirror th.selectedCell { background-color: hsl(var(--primary) / 0.15); }
|
||||
|
||||
@@ -2,7 +2,9 @@ import { Link } from '@tanstack/react-router'
|
||||
|
||||
const navItems = [
|
||||
{ label: 'Initiatives', to: '/initiatives' },
|
||||
{ label: 'Agents', to: '/agents' },
|
||||
{ label: 'Inbox', to: '/inbox' },
|
||||
{ label: 'Settings', to: '/settings' },
|
||||
] as const
|
||||
|
||||
export function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
|
||||
217
packages/web/src/lib/markdown-to-tiptap.ts
Normal file
217
packages/web/src/lib/markdown-to-tiptap.ts
Normal file
@@ -0,0 +1,217 @@
|
||||
/**
|
||||
* Markdown to Tiptap JSON converter.
|
||||
*
|
||||
* Converts agent-produced markdown back into Tiptap JSON for page updates.
|
||||
* Uses @tiptap/html's generateJSON to parse HTML into Tiptap nodes.
|
||||
*/
|
||||
|
||||
import { generateJSON } from '@tiptap/html';
|
||||
import StarterKit from '@tiptap/starter-kit';
|
||||
import { Table, TableRow, TableCell, TableHeader } from '@tiptap/extension-table';
|
||||
|
||||
/**
|
||||
* Convert markdown string to Tiptap JSON document.
|
||||
*/
|
||||
export function markdownToTiptapJson(markdown: string): object {
|
||||
const html = markdownToHtml(markdown);
|
||||
return generateJSON(html, [StarterKit, Table, TableRow, TableCell, TableHeader]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Simple markdown → HTML converter covering StarterKit nodes.
|
||||
* Handles: headings, paragraphs, bold, italic, code, code blocks,
|
||||
* bullet lists, ordered lists, blockquotes, links, horizontal rules, tables.
|
||||
*/
|
||||
function markdownToHtml(md: string): string {
|
||||
// Normalize line endings
|
||||
let text = md.replace(/\r\n/g, '\n');
|
||||
|
||||
// Code blocks (fenced)
|
||||
text = text.replace(/```(\w*)\n([\s\S]*?)```/g, (_match, lang, code) => {
|
||||
const escaped = escapeHtml(code.replace(/\n$/, ''));
|
||||
const langAttr = lang ? ` class="language-${lang}"` : '';
|
||||
return `<pre><code${langAttr}>${escaped}</code></pre>`;
|
||||
});
|
||||
|
||||
// Split into lines for block-level processing
|
||||
const lines = text.split('\n');
|
||||
const htmlLines: string[] = [];
|
||||
let i = 0;
|
||||
|
||||
while (i < lines.length) {
|
||||
const line = lines[i];
|
||||
|
||||
// Skip lines inside pre blocks (already handled)
|
||||
if (line.startsWith('<pre>')) {
|
||||
let block = line;
|
||||
while (i < lines.length && !lines[i].includes('</pre>')) {
|
||||
i++;
|
||||
block += '\n' + lines[i];
|
||||
}
|
||||
htmlLines.push(block);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Horizontal rule
|
||||
if (/^---+$/.test(line.trim())) {
|
||||
htmlLines.push('<hr>');
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Headings
|
||||
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (headingMatch) {
|
||||
const level = headingMatch[1].length;
|
||||
htmlLines.push(`<h${level}>${inlineMarkdown(headingMatch[2])}</h${level}>`);
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Blockquote
|
||||
if (line.startsWith('> ')) {
|
||||
const quoteLines: string[] = [];
|
||||
while (i < lines.length && lines[i].startsWith('> ')) {
|
||||
quoteLines.push(lines[i].slice(2));
|
||||
i++;
|
||||
}
|
||||
htmlLines.push(`<blockquote><p>${inlineMarkdown(quoteLines.join(' '))}</p></blockquote>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Unordered list
|
||||
if (/^[-*]\s+/.test(line)) {
|
||||
const items: string[] = [];
|
||||
while (i < lines.length && /^[-*]\s+/.test(lines[i])) {
|
||||
items.push(lines[i].replace(/^[-*]\s+/, ''));
|
||||
i++;
|
||||
}
|
||||
const lis = items.map((item) => `<li><p>${inlineMarkdown(item)}</p></li>`).join('');
|
||||
htmlLines.push(`<ul>${lis}</ul>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Ordered list
|
||||
if (/^\d+\.\s+/.test(line)) {
|
||||
const items: string[] = [];
|
||||
while (i < lines.length && /^\d+\.\s+/.test(lines[i])) {
|
||||
items.push(lines[i].replace(/^\d+\.\s+/, ''));
|
||||
i++;
|
||||
}
|
||||
const lis = items.map((item) => `<li><p>${inlineMarkdown(item)}</p></li>`).join('');
|
||||
htmlLines.push(`<ol>${lis}</ol>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Table: current line has | and next line is a separator row
|
||||
if (line.includes('|') && i + 1 < lines.length && /^\s*\|?\s*[-:]+[-| :]*$/.test(lines[i + 1])) {
|
||||
const headerCells = parseTableRow(line);
|
||||
i += 2; // skip header + separator
|
||||
|
||||
const bodyRows: string[][] = [];
|
||||
while (i < lines.length && lines[i].includes('|') && lines[i].trim() !== '') {
|
||||
bodyRows.push(parseTableRow(lines[i]));
|
||||
i++;
|
||||
}
|
||||
|
||||
const ths = headerCells.map((c) => `<th>${inlineMarkdown(c)}</th>`).join('');
|
||||
const thead = `<thead><tr>${ths}</tr></thead>`;
|
||||
|
||||
let tbody = '';
|
||||
if (bodyRows.length > 0) {
|
||||
const trs = bodyRows
|
||||
.map((row) => {
|
||||
const tds = row.map((c) => `<td>${inlineMarkdown(c)}</td>`).join('');
|
||||
return `<tr>${tds}</tr>`;
|
||||
})
|
||||
.join('');
|
||||
tbody = `<tbody>${trs}</tbody>`;
|
||||
}
|
||||
|
||||
htmlLines.push(`<table>${thead}${tbody}</table>`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Empty line
|
||||
if (line.trim() === '') {
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Paragraph (collect consecutive non-empty, non-block lines)
|
||||
const paraLines: string[] = [];
|
||||
while (
|
||||
i < lines.length &&
|
||||
lines[i].trim() !== '' &&
|
||||
!lines[i].startsWith('#') &&
|
||||
!lines[i].startsWith('> ') &&
|
||||
!/^[-*]\s+/.test(lines[i]) &&
|
||||
!/^\d+\.\s+/.test(lines[i]) &&
|
||||
!/^---+$/.test(lines[i].trim()) &&
|
||||
!lines[i].startsWith('<pre>') &&
|
||||
!lines[i].startsWith('```') &&
|
||||
!isTableStart(lines, i)
|
||||
) {
|
||||
paraLines.push(lines[i]);
|
||||
i++;
|
||||
}
|
||||
if (paraLines.length > 0) {
|
||||
htmlLines.push(`<p>${inlineMarkdown(paraLines.join(' '))}</p>`);
|
||||
} else {
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
return htmlLines.join('');
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if lines[i] starts a markdown table (has | and next line is separator).
|
||||
*/
|
||||
function isTableStart(lines: string[], i: number): boolean {
|
||||
return (
|
||||
lines[i].includes('|') &&
|
||||
i + 1 < lines.length &&
|
||||
/^\s*\|?\s*[-:]+[-| :]*$/.test(lines[i + 1])
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a markdown table row: strip leading/trailing pipes, split on |, trim cells.
|
||||
*/
|
||||
function parseTableRow(line: string): string[] {
|
||||
let trimmed = line.trim();
|
||||
if (trimmed.startsWith('|')) trimmed = trimmed.slice(1);
|
||||
if (trimmed.endsWith('|')) trimmed = trimmed.slice(0, -1);
|
||||
return trimmed.split('|').map((c) => c.trim());
|
||||
}
|
||||
|
||||
/**
|
||||
* Process inline markdown: bold, italic, inline code, links.
|
||||
*/
|
||||
function inlineMarkdown(text: string): string {
|
||||
let result = escapeHtml(text);
|
||||
|
||||
// Inline code (must come before bold/italic to avoid conflicts)
|
||||
result = result.replace(/`([^`]+)`/g, '<code>$1</code>');
|
||||
|
||||
// Bold
|
||||
result = result.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
||||
|
||||
// Italic
|
||||
result = result.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
||||
|
||||
// Links [text](url)
|
||||
result = result.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
@@ -4,3 +4,40 @@ import { twMerge } from "tailwind-merge";
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs));
|
||||
}
|
||||
|
||||
/**
|
||||
* Format a date as relative time (e.g., "2 minutes ago", "1 hour ago")
|
||||
*/
|
||||
export function formatRelativeTime(dateString: string): string {
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffInMs = now.getTime() - date.getTime();
|
||||
const diffInSeconds = Math.floor(diffInMs / 1000);
|
||||
|
||||
if (diffInSeconds < 60) {
|
||||
return diffInSeconds <= 1 ? "just now" : `${diffInSeconds} seconds ago`;
|
||||
}
|
||||
|
||||
const diffInMinutes = Math.floor(diffInSeconds / 60);
|
||||
if (diffInMinutes < 60) {
|
||||
return diffInMinutes === 1 ? "1 minute ago" : `${diffInMinutes} minutes ago`;
|
||||
}
|
||||
|
||||
const diffInHours = Math.floor(diffInMinutes / 60);
|
||||
if (diffInHours < 24) {
|
||||
return diffInHours === 1 ? "1 hour ago" : `${diffInHours} hours ago`;
|
||||
}
|
||||
|
||||
const diffInDays = Math.floor(diffInHours / 24);
|
||||
if (diffInDays < 30) {
|
||||
return diffInDays === 1 ? "1 day ago" : `${diffInDays} days ago`;
|
||||
}
|
||||
|
||||
const diffInMonths = Math.floor(diffInDays / 30);
|
||||
if (diffInMonths < 12) {
|
||||
return diffInMonths === 1 ? "1 month ago" : `${diffInMonths} months ago`;
|
||||
}
|
||||
|
||||
const diffInYears = Math.floor(diffInMonths / 12);
|
||||
return diffInYears === 1 ? "1 year ago" : `${diffInYears} years ago`;
|
||||
}
|
||||
|
||||
@@ -9,26 +9,50 @@
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
import { Route as rootRouteImport } from './routes/__root'
|
||||
import { Route as SettingsRouteImport } from './routes/settings'
|
||||
import { Route as InboxRouteImport } from './routes/inbox'
|
||||
import { Route as AgentsRouteImport } from './routes/agents'
|
||||
import { Route as IndexRouteImport } from './routes/index'
|
||||
import { Route as SettingsIndexRouteImport } from './routes/settings/index'
|
||||
import { Route as InitiativesIndexRouteImport } from './routes/initiatives/index'
|
||||
import { Route as SettingsHealthRouteImport } from './routes/settings/health'
|
||||
import { Route as InitiativesIdRouteImport } from './routes/initiatives/$id'
|
||||
|
||||
const SettingsRoute = SettingsRouteImport.update({
|
||||
id: '/settings',
|
||||
path: '/settings',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const InboxRoute = InboxRouteImport.update({
|
||||
id: '/inbox',
|
||||
path: '/inbox',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const AgentsRoute = AgentsRouteImport.update({
|
||||
id: '/agents',
|
||||
path: '/agents',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const IndexRoute = IndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const SettingsIndexRoute = SettingsIndexRouteImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => SettingsRoute,
|
||||
} as any)
|
||||
const InitiativesIndexRoute = InitiativesIndexRouteImport.update({
|
||||
id: '/initiatives/',
|
||||
path: '/initiatives/',
|
||||
getParentRoute: () => rootRouteImport,
|
||||
} as any)
|
||||
const SettingsHealthRoute = SettingsHealthRouteImport.update({
|
||||
id: '/health',
|
||||
path: '/health',
|
||||
getParentRoute: () => SettingsRoute,
|
||||
} as any)
|
||||
const InitiativesIdRoute = InitiativesIdRouteImport.update({
|
||||
id: '/initiatives/$id',
|
||||
path: '/initiatives/$id',
|
||||
@@ -37,40 +61,84 @@ const InitiativesIdRoute = InitiativesIdRouteImport.update({
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/agents': typeof AgentsRoute
|
||||
'/inbox': typeof InboxRoute
|
||||
'/settings': typeof SettingsRouteWithChildren
|
||||
'/initiatives/$id': typeof InitiativesIdRoute
|
||||
'/settings/health': typeof SettingsHealthRoute
|
||||
'/initiatives/': typeof InitiativesIndexRoute
|
||||
'/settings/': typeof SettingsIndexRoute
|
||||
}
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/agents': typeof AgentsRoute
|
||||
'/inbox': typeof InboxRoute
|
||||
'/initiatives/$id': typeof InitiativesIdRoute
|
||||
'/settings/health': typeof SettingsHealthRoute
|
||||
'/initiatives': typeof InitiativesIndexRoute
|
||||
'/settings': typeof SettingsIndexRoute
|
||||
}
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRouteImport
|
||||
'/': typeof IndexRoute
|
||||
'/agents': typeof AgentsRoute
|
||||
'/inbox': typeof InboxRoute
|
||||
'/settings': typeof SettingsRouteWithChildren
|
||||
'/initiatives/$id': typeof InitiativesIdRoute
|
||||
'/settings/health': typeof SettingsHealthRoute
|
||||
'/initiatives/': typeof InitiativesIndexRoute
|
||||
'/settings/': typeof SettingsIndexRoute
|
||||
}
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/inbox' | '/initiatives/$id' | '/initiatives/'
|
||||
fullPaths:
|
||||
| '/'
|
||||
| '/agents'
|
||||
| '/inbox'
|
||||
| '/settings'
|
||||
| '/initiatives/$id'
|
||||
| '/settings/health'
|
||||
| '/initiatives/'
|
||||
| '/settings/'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/inbox' | '/initiatives/$id' | '/initiatives'
|
||||
id: '__root__' | '/' | '/inbox' | '/initiatives/$id' | '/initiatives/'
|
||||
to:
|
||||
| '/'
|
||||
| '/agents'
|
||||
| '/inbox'
|
||||
| '/initiatives/$id'
|
||||
| '/settings/health'
|
||||
| '/initiatives'
|
||||
| '/settings'
|
||||
id:
|
||||
| '__root__'
|
||||
| '/'
|
||||
| '/agents'
|
||||
| '/inbox'
|
||||
| '/settings'
|
||||
| '/initiatives/$id'
|
||||
| '/settings/health'
|
||||
| '/initiatives/'
|
||||
| '/settings/'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
AgentsRoute: typeof AgentsRoute
|
||||
InboxRoute: typeof InboxRoute
|
||||
SettingsRoute: typeof SettingsRouteWithChildren
|
||||
InitiativesIdRoute: typeof InitiativesIdRoute
|
||||
InitiativesIndexRoute: typeof InitiativesIndexRoute
|
||||
}
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/settings': {
|
||||
id: '/settings'
|
||||
path: '/settings'
|
||||
fullPath: '/settings'
|
||||
preLoaderRoute: typeof SettingsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/inbox': {
|
||||
id: '/inbox'
|
||||
path: '/inbox'
|
||||
@@ -78,6 +146,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof InboxRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/agents': {
|
||||
id: '/agents'
|
||||
path: '/agents'
|
||||
fullPath: '/agents'
|
||||
preLoaderRoute: typeof AgentsRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
@@ -85,6 +160,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof IndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/settings/': {
|
||||
id: '/settings/'
|
||||
path: '/'
|
||||
fullPath: '/settings/'
|
||||
preLoaderRoute: typeof SettingsIndexRouteImport
|
||||
parentRoute: typeof SettingsRoute
|
||||
}
|
||||
'/initiatives/': {
|
||||
id: '/initiatives/'
|
||||
path: '/initiatives'
|
||||
@@ -92,6 +174,13 @@ declare module '@tanstack/react-router' {
|
||||
preLoaderRoute: typeof InitiativesIndexRouteImport
|
||||
parentRoute: typeof rootRouteImport
|
||||
}
|
||||
'/settings/health': {
|
||||
id: '/settings/health'
|
||||
path: '/health'
|
||||
fullPath: '/settings/health'
|
||||
preLoaderRoute: typeof SettingsHealthRouteImport
|
||||
parentRoute: typeof SettingsRoute
|
||||
}
|
||||
'/initiatives/$id': {
|
||||
id: '/initiatives/$id'
|
||||
path: '/initiatives/$id'
|
||||
@@ -102,9 +191,25 @@ declare module '@tanstack/react-router' {
|
||||
}
|
||||
}
|
||||
|
||||
interface SettingsRouteChildren {
|
||||
SettingsHealthRoute: typeof SettingsHealthRoute
|
||||
SettingsIndexRoute: typeof SettingsIndexRoute
|
||||
}
|
||||
|
||||
const SettingsRouteChildren: SettingsRouteChildren = {
|
||||
SettingsHealthRoute: SettingsHealthRoute,
|
||||
SettingsIndexRoute: SettingsIndexRoute,
|
||||
}
|
||||
|
||||
const SettingsRouteWithChildren = SettingsRoute._addFileChildren(
|
||||
SettingsRouteChildren,
|
||||
)
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
AgentsRoute: AgentsRoute,
|
||||
InboxRoute: InboxRoute,
|
||||
SettingsRoute: SettingsRouteWithChildren,
|
||||
InitiativesIdRoute: InitiativesIdRoute,
|
||||
InitiativesIndexRoute: InitiativesIndexRoute,
|
||||
}
|
||||
|
||||
196
packages/web/src/routes/agents.tsx
Normal file
196
packages/web/src/routes/agents.tsx
Normal file
@@ -0,0 +1,196 @@
|
||||
import { useState } from "react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { AlertCircle, RefreshCw } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Card } from "@/components/ui/card";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Skeleton } from "@/components/Skeleton";
|
||||
import { toast } from "sonner";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { AgentOutputViewer } from "@/components/AgentOutputViewer";
|
||||
import { formatRelativeTime } from "@/lib/utils";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useSubscriptionWithErrorHandling } from "@/hooks";
|
||||
|
||||
export const Route = createFileRoute("/agents")({
|
||||
component: AgentsPage,
|
||||
});
|
||||
|
||||
function AgentsPage() {
|
||||
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
|
||||
|
||||
// Live updates: invalidate agents on agent events with robust error handling
|
||||
const utils = trpc.useUtils();
|
||||
const subscription = useSubscriptionWithErrorHandling(
|
||||
() => trpc.onAgentUpdate.useSubscription(undefined),
|
||||
{
|
||||
onData: () => {
|
||||
void utils.listAgents.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Live updates disconnected. Refresh to reconnect.", {
|
||||
id: "sub-error",
|
||||
duration: Infinity,
|
||||
});
|
||||
console.error('Agent updates subscription error:', error);
|
||||
},
|
||||
onStarted: () => {
|
||||
// Clear any existing error toasts when reconnecting
|
||||
toast.dismiss("sub-error");
|
||||
},
|
||||
autoReconnect: true,
|
||||
maxReconnectAttempts: 5,
|
||||
}
|
||||
);
|
||||
|
||||
// Data fetching
|
||||
const agentsQuery = trpc.listAgents.useQuery();
|
||||
|
||||
// Handlers
|
||||
function handleRefresh() {
|
||||
void utils.listAgents.invalidate();
|
||||
}
|
||||
|
||||
// Loading state
|
||||
if (agentsQuery.isLoading) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Skeleton className="h-6 w-20" />
|
||||
<Skeleton className="h-5 w-8 rounded-full" />
|
||||
</div>
|
||||
<Skeleton className="h-8 w-20" />
|
||||
</div>
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[300px_1fr]">
|
||||
<div className="space-y-2">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<Card key={i} className="p-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-2 w-2 rounded-full" />
|
||||
<Skeleton className="h-4 w-24" />
|
||||
</div>
|
||||
</Card>
|
||||
))}
|
||||
</div>
|
||||
<Skeleton className="h-96 rounded-lg" />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (agentsQuery.isError) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-12">
|
||||
<AlertCircle className="h-8 w-8 text-destructive" />
|
||||
<p className="text-sm text-destructive">
|
||||
Failed to load agents: {agentsQuery.error?.message ?? "Unknown error"}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const agents = agentsQuery.data ?? [];
|
||||
const selectedAgent = selectedAgentId
|
||||
? agents.find((a) => a.id === selectedAgentId)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-lg font-semibold">Agents</h1>
|
||||
<Badge variant="secondary">{agents.length}</Badge>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh}>
|
||||
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Two-column layout */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[300px_1fr]">
|
||||
{/* Left: Agent List */}
|
||||
<div className="space-y-2">
|
||||
{agents.length === 0 ? (
|
||||
<div className="rounded-lg border border-dashed p-8 text-center">
|
||||
<p className="text-sm text-muted-foreground">No agents found</p>
|
||||
</div>
|
||||
) : (
|
||||
agents.map((agent) => (
|
||||
<Card
|
||||
key={agent.id}
|
||||
className={cn(
|
||||
"cursor-pointer p-3 transition-colors hover:bg-muted/50",
|
||||
selectedAgentId === agent.id && "bg-muted"
|
||||
)}
|
||||
onClick={() => setSelectedAgentId(agent.id)}
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2 min-w-0">
|
||||
<StatusDot status={agent.status} />
|
||||
<span className="truncate text-sm font-medium">
|
||||
{agent.name}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1.5 shrink-0">
|
||||
<Badge variant="outline" className="text-xs">
|
||||
{agent.provider}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{agent.mode}
|
||||
</Badge>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-muted-foreground">
|
||||
{formatRelativeTime(String(agent.createdAt))}
|
||||
</div>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Output Viewer */}
|
||||
{selectedAgent ? (
|
||||
<AgentOutputViewer agentId={selectedAgent.id} agentName={selectedAgent.name} />
|
||||
) : (
|
||||
<div className="flex items-center justify-center rounded-lg border border-dashed p-8">
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Select an agent to view output
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Components
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function StatusDot({ status }: { status: string }) {
|
||||
const colors: Record<string, string> = {
|
||||
running: "bg-green-500",
|
||||
waiting_for_input: "bg-yellow-500",
|
||||
idle: "bg-zinc-400",
|
||||
stopped: "bg-zinc-400",
|
||||
crashed: "bg-red-500",
|
||||
};
|
||||
return (
|
||||
<span
|
||||
className={cn("h-2 w-2 rounded-full shrink-0", colors[status] ?? "bg-zinc-400")}
|
||||
title={status}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -8,6 +8,7 @@ import { toast } from "sonner";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { InboxList } from "@/components/InboxList";
|
||||
import { QuestionForm } from "@/components/QuestionForm";
|
||||
import { formatRelativeTime } from "@/lib/utils";
|
||||
|
||||
export const Route = createFileRoute("/inbox")({
|
||||
component: InboxPage,
|
||||
@@ -328,17 +329,3 @@ function InboxPage() {
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatRelativeTime(isoDate: string): string {
|
||||
const now = Date.now();
|
||||
const then = new Date(isoDate).getTime();
|
||||
const diffMs = now - then;
|
||||
const diffSec = Math.floor(diffMs / 1000);
|
||||
const diffMin = Math.floor(diffSec / 60);
|
||||
const diffHr = Math.floor(diffMin / 60);
|
||||
const diffDay = Math.floor(diffHr / 24);
|
||||
|
||||
if (diffSec < 60) return "just now";
|
||||
if (diffMin < 60) return `${diffMin} min ago`;
|
||||
if (diffHr < 24) return `${diffHr}h ago`;
|
||||
return `${diffDay}d ago`;
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -6,247 +6,87 @@ import { Skeleton } from "@/components/Skeleton";
|
||||
import { toast } from "sonner";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { InitiativeHeader } from "@/components/InitiativeHeader";
|
||||
import { ProgressPanel } from "@/components/ProgressPanel";
|
||||
import { PhaseAccordion } from "@/components/PhaseAccordion";
|
||||
import { DecisionList } from "@/components/DecisionList";
|
||||
import { TaskDetailModal } from "@/components/TaskDetailModal";
|
||||
import type { SerializedTask } from "@/components/TaskRow";
|
||||
import { ContentTab } from "@/components/editor/ContentTab";
|
||||
import { ExecutionTab } from "@/components/ExecutionTab";
|
||||
import { useSubscriptionWithErrorHandling } from "@/hooks";
|
||||
|
||||
export const Route = createFileRoute("/initiatives/$id")({
|
||||
component: InitiativeDetailPage,
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Aggregated task counts reported upward from PhaseWithTasks */
|
||||
interface TaskCounts {
|
||||
complete: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** Flat task entry with metadata needed for the modal */
|
||||
interface FlatTaskEntry {
|
||||
task: SerializedTask;
|
||||
phaseName: string;
|
||||
agentName: string | null;
|
||||
blockedBy: Array<{ name: string; status: string }>;
|
||||
dependents: Array<{ name: string; status: string }>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PhaseWithTasks — solves the "hooks inside loops" problem
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface PhaseWithTasksProps {
|
||||
phase: {
|
||||
id: string;
|
||||
initiativeId: string;
|
||||
number: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
defaultExpanded: boolean;
|
||||
onTaskClick: (taskId: string) => void;
|
||||
onTaskCounts: (phaseId: string, counts: TaskCounts) => void;
|
||||
registerTasks: (phaseId: string, entries: FlatTaskEntry[]) => void;
|
||||
}
|
||||
|
||||
function PhaseWithTasks({
|
||||
phase,
|
||||
defaultExpanded,
|
||||
onTaskClick,
|
||||
onTaskCounts,
|
||||
registerTasks,
|
||||
}: PhaseWithTasksProps) {
|
||||
// Fetch all plans for this phase
|
||||
const plansQuery = trpc.listPlans.useQuery({ phaseId: phase.id });
|
||||
|
||||
// Fetch phase dependencies
|
||||
const depsQuery = trpc.getPhaseDependencies.useQuery({ phaseId: phase.id });
|
||||
|
||||
const plans = plansQuery.data ?? [];
|
||||
const planIds = plans.map((p) => p.id);
|
||||
|
||||
return (
|
||||
<PhaseWithTasksInner
|
||||
phase={phase}
|
||||
planIds={planIds}
|
||||
plansLoaded={plansQuery.isSuccess}
|
||||
phaseDependencyIds={depsQuery.data?.dependencies ?? []}
|
||||
defaultExpanded={defaultExpanded}
|
||||
onTaskClick={onTaskClick}
|
||||
onTaskCounts={onTaskCounts}
|
||||
registerTasks={registerTasks}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Inner component that fetches tasks for each plan — needs stable hook count
|
||||
// Since planIds array changes, we fetch tasks per plan inside yet another child
|
||||
interface PhaseWithTasksInnerProps {
|
||||
phase: PhaseWithTasksProps["phase"];
|
||||
planIds: string[];
|
||||
plansLoaded: boolean;
|
||||
phaseDependencyIds: string[];
|
||||
defaultExpanded: boolean;
|
||||
onTaskClick: (taskId: string) => void;
|
||||
onTaskCounts: (phaseId: string, counts: TaskCounts) => void;
|
||||
registerTasks: (phaseId: string, entries: FlatTaskEntry[]) => void;
|
||||
}
|
||||
|
||||
function PhaseWithTasksInner({
|
||||
phase,
|
||||
planIds,
|
||||
plansLoaded,
|
||||
phaseDependencyIds: _phaseDependencyIds,
|
||||
defaultExpanded,
|
||||
onTaskClick,
|
||||
onTaskCounts,
|
||||
registerTasks,
|
||||
}: PhaseWithTasksInnerProps) {
|
||||
// We can't call useQuery in a loop, so we render PlanTasksFetcher per plan
|
||||
// and aggregate the results
|
||||
const [planTasks, setPlanTasks] = useState<Record<string, SerializedTask[]>>(
|
||||
{},
|
||||
);
|
||||
|
||||
const handlePlanTasks = useCallback(
|
||||
(planId: string, tasks: SerializedTask[]) => {
|
||||
setPlanTasks((prev) => {
|
||||
// Skip if unchanged (same reference)
|
||||
if (prev[planId] === tasks) return prev;
|
||||
const next = { ...prev, [planId]: tasks };
|
||||
|
||||
// Aggregate all tasks across plans
|
||||
const allTasks = Object.values(next).flat();
|
||||
const complete = allTasks.filter(
|
||||
(t) => t.status === "completed",
|
||||
).length;
|
||||
|
||||
// Report counts up
|
||||
onTaskCounts(phase.id, { complete, total: allTasks.length });
|
||||
|
||||
// Register flat entries for the modal lookup
|
||||
const entries: FlatTaskEntry[] = allTasks.map((task) => ({
|
||||
task,
|
||||
phaseName: `Phase ${phase.number}: ${phase.name}`,
|
||||
agentName: null, // No agent info from task data alone
|
||||
blockedBy: [], // Simplified: no dependency lookup per task in v1
|
||||
dependents: [],
|
||||
}));
|
||||
registerTasks(phase.id, entries);
|
||||
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[phase.id, phase.number, phase.name, onTaskCounts, registerTasks],
|
||||
);
|
||||
|
||||
// Build task entries for PhaseAccordion
|
||||
const allTasks = planIds.flatMap((pid) => planTasks[pid] ?? []);
|
||||
const taskEntries = allTasks.map((task) => ({
|
||||
task,
|
||||
agentName: null as string | null,
|
||||
blockedBy: [] as Array<{ name: string; status: string }>,
|
||||
}));
|
||||
|
||||
// Phase-level dependencies (empty for now — would need to resolve IDs to names)
|
||||
const phaseDeps: Array<{ name: string; status: string }> = [];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Hidden fetchers — one per plan */}
|
||||
{plansLoaded &&
|
||||
planIds.map((planId) => (
|
||||
<PlanTasksFetcher
|
||||
key={planId}
|
||||
planId={planId}
|
||||
onTasks={handlePlanTasks}
|
||||
/>
|
||||
))}
|
||||
|
||||
<PhaseAccordion
|
||||
phase={phase}
|
||||
tasks={taskEntries}
|
||||
defaultExpanded={defaultExpanded}
|
||||
phaseDependencies={phaseDeps}
|
||||
onTaskClick={onTaskClick}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PlanTasksFetcher — fetches tasks for a single plan (stable hook count)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface PlanTasksFetcherProps {
|
||||
planId: string;
|
||||
onTasks: (planId: string, tasks: SerializedTask[]) => void;
|
||||
}
|
||||
|
||||
function PlanTasksFetcher({ planId, onTasks }: PlanTasksFetcherProps) {
|
||||
const tasksQuery = trpc.listTasks.useQuery({ planId });
|
||||
|
||||
// Report tasks upward via useEffect (not during render) to avoid
|
||||
// setState-during-render loops when the parent re-renders on state update.
|
||||
useEffect(() => {
|
||||
if (tasksQuery.data) {
|
||||
onTasks(planId, tasksQuery.data as unknown as SerializedTask[]);
|
||||
}
|
||||
}, [tasksQuery.data, planId, onTasks]);
|
||||
|
||||
return null; // Render nothing — this is a data-fetching component
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main Page Component
|
||||
// ---------------------------------------------------------------------------
|
||||
type Tab = "content" | "execution";
|
||||
|
||||
function InitiativeDetailPage() {
|
||||
const { id } = Route.useParams();
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = useState<Tab>("content");
|
||||
|
||||
// Live updates: invalidate detail queries on task/phase and agent events
|
||||
// Live updates: keep subscriptions at page level so they work across both tabs
|
||||
const utils = trpc.useUtils();
|
||||
trpc.onTaskUpdate.useSubscription(undefined, {
|
||||
onData: () => {
|
||||
void utils.listPhases.invalidate();
|
||||
void utils.listTasks.invalidate();
|
||||
void utils.listPlans.invalidate();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Live updates disconnected. Refresh to reconnect.", {
|
||||
id: "sub-error",
|
||||
duration: Infinity,
|
||||
});
|
||||
},
|
||||
});
|
||||
trpc.onAgentUpdate.useSubscription(undefined, {
|
||||
onData: () => {
|
||||
void utils.listAgents.invalidate();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Live updates disconnected. Refresh to reconnect.", {
|
||||
id: "sub-error",
|
||||
duration: Infinity,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// State
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||
const [taskCountsByPhase, setTaskCountsByPhase] = useState<
|
||||
Record<string, TaskCounts>
|
||||
>({});
|
||||
const [tasksByPhase, setTasksByPhase] = useState<
|
||||
Record<string, FlatTaskEntry[]>
|
||||
>({});
|
||||
// Task updates subscription with robust error handling
|
||||
useSubscriptionWithErrorHandling(
|
||||
() => trpc.onTaskUpdate.useSubscription(undefined),
|
||||
{
|
||||
onData: () => {
|
||||
void utils.listPhases.invalidate();
|
||||
void utils.listTasks.invalidate();
|
||||
void utils.listPlans.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Live updates disconnected. Refresh to reconnect.", {
|
||||
id: "sub-error",
|
||||
duration: Infinity,
|
||||
});
|
||||
console.error('Task updates subscription error:', error);
|
||||
},
|
||||
onStarted: () => toast.dismiss("sub-error"),
|
||||
autoReconnect: true,
|
||||
maxReconnectAttempts: 5,
|
||||
}
|
||||
);
|
||||
|
||||
// Agent updates subscription with robust error handling
|
||||
useSubscriptionWithErrorHandling(
|
||||
() => trpc.onAgentUpdate.useSubscription(undefined),
|
||||
{
|
||||
onData: () => {
|
||||
void utils.listAgents.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Live updates disconnected. Refresh to reconnect.", {
|
||||
id: "sub-error",
|
||||
duration: Infinity,
|
||||
});
|
||||
console.error('Agent updates subscription error:', error);
|
||||
},
|
||||
onStarted: () => toast.dismiss("sub-error"),
|
||||
autoReconnect: true,
|
||||
maxReconnectAttempts: 5,
|
||||
}
|
||||
);
|
||||
|
||||
// Page updates subscription with robust error handling
|
||||
useSubscriptionWithErrorHandling(
|
||||
() => trpc.onPageUpdate.useSubscription(undefined),
|
||||
{
|
||||
onData: () => {
|
||||
void utils.listPages.invalidate();
|
||||
void utils.getPage.invalidate();
|
||||
void utils.getRootPage.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Live updates disconnected. Refresh to reconnect.", {
|
||||
id: "sub-error",
|
||||
duration: Infinity,
|
||||
});
|
||||
console.error('Page updates subscription error:', error);
|
||||
},
|
||||
onStarted: () => toast.dismiss("sub-error"),
|
||||
autoReconnect: true,
|
||||
maxReconnectAttempts: 5,
|
||||
}
|
||||
);
|
||||
|
||||
// tRPC queries
|
||||
const initiativeQuery = trpc.getInitiative.useQuery({ id });
|
||||
@@ -255,94 +95,20 @@ function InitiativeDetailPage() {
|
||||
{ enabled: !!initiativeQuery.data },
|
||||
);
|
||||
|
||||
// tRPC mutations
|
||||
const queueTaskMutation = trpc.queueTask.useMutation();
|
||||
const queuePhaseMutation = trpc.queuePhase.useMutation();
|
||||
|
||||
// Callbacks for PhaseWithTasks
|
||||
const handleTaskCounts = useCallback(
|
||||
(phaseId: string, counts: TaskCounts) => {
|
||||
setTaskCountsByPhase((prev) => {
|
||||
if (
|
||||
prev[phaseId]?.complete === counts.complete &&
|
||||
prev[phaseId]?.total === counts.total
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return { ...prev, [phaseId]: counts };
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleRegisterTasks = useCallback(
|
||||
(phaseId: string, entries: FlatTaskEntry[]) => {
|
||||
setTasksByPhase((prev) => {
|
||||
if (prev[phaseId] === entries) return prev;
|
||||
return { ...prev, [phaseId]: entries };
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Derived data
|
||||
const phases = phasesQuery.data ?? [];
|
||||
const phasesComplete = phases.filter(
|
||||
(p) => p.status === "completed",
|
||||
).length;
|
||||
|
||||
const allTaskCounts = Object.values(taskCountsByPhase);
|
||||
const tasksComplete = allTaskCounts.reduce((s, c) => s + c.complete, 0);
|
||||
const tasksTotal = allTaskCounts.reduce((s, c) => s + c.total, 0);
|
||||
|
||||
// Find selected task across all phases
|
||||
const allFlatTasks = Object.values(tasksByPhase).flat();
|
||||
const selectedEntry = selectedTaskId
|
||||
? allFlatTasks.find((e) => e.task.id === selectedTaskId) ?? null
|
||||
: null;
|
||||
|
||||
// Determine which phase should be expanded by default (first non-completed)
|
||||
const firstIncompletePhaseIndex = phases.findIndex(
|
||||
(p) => p.status !== "completed",
|
||||
);
|
||||
|
||||
// Queue all pending phases
|
||||
const handleQueueAll = useCallback(() => {
|
||||
const pendingPhases = phases.filter((p) => p.status === "pending");
|
||||
for (const phase of pendingPhases) {
|
||||
queuePhaseMutation.mutate({ phaseId: phase.id });
|
||||
}
|
||||
}, [phases, queuePhaseMutation]);
|
||||
|
||||
// Queue a single task
|
||||
const handleQueueTask = useCallback(
|
||||
(taskId: string) => {
|
||||
queueTaskMutation.mutate({ taskId });
|
||||
setSelectedTaskId(null);
|
||||
},
|
||||
[queueTaskMutation],
|
||||
);
|
||||
|
||||
// Loading state
|
||||
if (initiativeQuery.isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header skeleton */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-4 w-4" />
|
||||
<Skeleton className="h-7 w-64" />
|
||||
<Skeleton className="h-5 w-20" />
|
||||
</div>
|
||||
|
||||
{/* Two-column grid skeleton */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_340px]">
|
||||
{/* Left: phase accordion skeletons */}
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-12 w-full rounded border" />
|
||||
<Skeleton className="h-12 w-full rounded border" />
|
||||
</div>
|
||||
|
||||
{/* Right: ProgressPanel + DecisionList skeletons */}
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-24 w-full rounded" />
|
||||
<Skeleton className="h-20 w-full rounded" />
|
||||
@@ -376,109 +142,59 @@ function InitiativeDetailPage() {
|
||||
const initiative = initiativeQuery.data;
|
||||
if (!initiative) return null;
|
||||
|
||||
// tRPC serializes Date to string over JSON — cast to wire format
|
||||
const serializedInitiative = {
|
||||
id: initiative.id,
|
||||
name: initiative.name,
|
||||
status: initiative.status,
|
||||
createdAt: String(initiative.createdAt),
|
||||
updatedAt: String(initiative.updatedAt),
|
||||
};
|
||||
|
||||
const hasPendingPhases = phases.some((p) => p.status === "pending");
|
||||
const projects = (initiative as { projects?: Array<{ id: string; name: string; url: string }> }).projects;
|
||||
|
||||
const phases = phasesQuery.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
{/* Header */}
|
||||
<InitiativeHeader
|
||||
initiative={serializedInitiative}
|
||||
projects={projects}
|
||||
onBack={() => navigate({ to: "/initiatives" })}
|
||||
/>
|
||||
|
||||
{/* Two-column layout */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_340px]">
|
||||
{/* Left column: Phases */}
|
||||
<div className="space-y-0">
|
||||
{/* Section header */}
|
||||
<div className="flex items-center justify-between border-b border-border pb-3">
|
||||
<h2 className="text-lg font-semibold">Phases</h2>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!hasPendingPhases}
|
||||
onClick={handleQueueAll}
|
||||
>
|
||||
Queue All
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Phase loading */}
|
||||
{phasesQuery.isLoading && (
|
||||
<div className="space-y-1 pt-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Phases list */}
|
||||
{phasesQuery.isSuccess && phases.length === 0 && (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
No phases yet
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phasesQuery.isSuccess &&
|
||||
phases.map((phase, idx) => {
|
||||
// tRPC serializes Date to string over JSON — cast to wire format
|
||||
const serializedPhase = {
|
||||
id: phase.id,
|
||||
initiativeId: phase.initiativeId,
|
||||
number: phase.number,
|
||||
name: phase.name,
|
||||
description: phase.description,
|
||||
status: phase.status,
|
||||
createdAt: String(phase.createdAt),
|
||||
updatedAt: String(phase.updatedAt),
|
||||
};
|
||||
|
||||
return (
|
||||
<PhaseWithTasks
|
||||
key={phase.id}
|
||||
phase={serializedPhase}
|
||||
defaultExpanded={idx === firstIncompletePhaseIndex}
|
||||
onTaskClick={setSelectedTaskId}
|
||||
onTaskCounts={handleTaskCounts}
|
||||
registerTasks={handleRegisterTasks}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Right column: Progress + Decisions */}
|
||||
<div className="space-y-6">
|
||||
<ProgressPanel
|
||||
phasesComplete={phasesComplete}
|
||||
phasesTotal={phases.length}
|
||||
tasksComplete={tasksComplete}
|
||||
tasksTotal={tasksTotal}
|
||||
/>
|
||||
|
||||
<DecisionList decisions={[]} />
|
||||
</div>
|
||||
{/* Tab bar */}
|
||||
<div className="flex gap-1 border-b border-border">
|
||||
<button
|
||||
onClick={() => setActiveTab("content")}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === "content"
|
||||
? "border-primary text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Content
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("execution")}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === "execution"
|
||||
? "border-primary text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Execution
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Task Detail Modal */}
|
||||
<TaskDetailModal
|
||||
task={selectedEntry?.task ?? null}
|
||||
phaseName={selectedEntry?.phaseName ?? ""}
|
||||
agentName={selectedEntry?.agentName ?? null}
|
||||
dependencies={selectedEntry?.blockedBy ?? []}
|
||||
dependents={selectedEntry?.dependents ?? []}
|
||||
onClose={() => setSelectedTaskId(null)}
|
||||
onQueueTask={handleQueueTask}
|
||||
onStopTask={() => setSelectedTaskId(null)}
|
||||
/>
|
||||
{/* Tab content */}
|
||||
{activeTab === "content" && <ContentTab initiativeId={id} initiativeName={initiative.name} />}
|
||||
{activeTab === "execution" && (
|
||||
<ExecutionTab
|
||||
initiativeId={id}
|
||||
phases={phases}
|
||||
phasesLoading={phasesQuery.isLoading}
|
||||
phasesLoaded={phasesQuery.isSuccess}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
35
packages/web/src/routes/settings.tsx
Normal file
35
packages/web/src/routes/settings.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import { createFileRoute, Link, Outlet } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/settings')({
|
||||
component: SettingsLayout,
|
||||
})
|
||||
|
||||
const settingsTabs = [
|
||||
{ label: 'Health Check', to: '/settings/health' },
|
||||
] as const
|
||||
|
||||
function SettingsLayout() {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h1 className="text-2xl font-bold tracking-tight">Settings</h1>
|
||||
</div>
|
||||
<nav className="flex gap-1 border-b border-border">
|
||||
{settingsTabs.map((tab) => (
|
||||
<Link
|
||||
key={tab.to}
|
||||
to={tab.to}
|
||||
className="px-4 py-2 text-sm font-medium border-b-2 border-transparent text-muted-foreground transition-colors hover:text-foreground"
|
||||
activeProps={{
|
||||
className:
|
||||
'px-4 py-2 text-sm font-medium border-b-2 border-primary text-foreground',
|
||||
}}
|
||||
>
|
||||
{tab.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<Outlet />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
383
packages/web/src/routes/settings/health.tsx
Normal file
383
packages/web/src/routes/settings/health.tsx
Normal file
@@ -0,0 +1,383 @@
|
||||
import { createFileRoute } from '@tanstack/react-router'
|
||||
import {
|
||||
CheckCircle2,
|
||||
XCircle,
|
||||
AlertTriangle,
|
||||
RefreshCw,
|
||||
Server,
|
||||
} from 'lucide-react'
|
||||
import { trpc } from '@/lib/trpc'
|
||||
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Skeleton } from '@/components/Skeleton'
|
||||
|
||||
export const Route = createFileRoute('/settings/health')({
|
||||
component: HealthCheckPage,
|
||||
})
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatUptime(seconds: number): string {
|
||||
const d = Math.floor(seconds / 86400)
|
||||
const h = Math.floor((seconds % 86400) / 3600)
|
||||
const m = Math.floor((seconds % 3600) / 60)
|
||||
const s = Math.floor(seconds % 60)
|
||||
|
||||
const parts: string[] = []
|
||||
if (d > 0) parts.push(`${d}d`)
|
||||
if (h > 0) parts.push(`${h}h`)
|
||||
if (m > 0) parts.push(`${m}m`)
|
||||
if (s > 0 || parts.length === 0) parts.push(`${s}s`)
|
||||
return parts.join(' ')
|
||||
}
|
||||
|
||||
function formatResetTime(isoDate: string): string {
|
||||
const now = Date.now()
|
||||
const target = new Date(isoDate).getTime()
|
||||
const diffMs = target - now
|
||||
if (diffMs <= 0) return 'now'
|
||||
|
||||
const totalMinutes = Math.floor(diffMs / 60_000)
|
||||
const totalHours = Math.floor(totalMinutes / 60)
|
||||
const totalDays = Math.floor(totalHours / 24)
|
||||
|
||||
if (totalDays > 0) {
|
||||
const remainingHours = totalHours - totalDays * 24
|
||||
return `in ${totalDays}d ${remainingHours}h`
|
||||
}
|
||||
const remainingMinutes = totalMinutes - totalHours * 60
|
||||
return `in ${totalHours}h ${remainingMinutes}m`
|
||||
}
|
||||
|
||||
function capitalize(s: string): string {
|
||||
return s.charAt(0).toUpperCase() + s.slice(1)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Usage bar
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function UsageBar({
|
||||
label,
|
||||
utilization,
|
||||
resetsAt,
|
||||
}: {
|
||||
label: string
|
||||
utilization: number
|
||||
resetsAt: string | null
|
||||
}) {
|
||||
const color =
|
||||
utilization >= 90
|
||||
? 'bg-destructive'
|
||||
: utilization >= 70
|
||||
? 'bg-yellow-500'
|
||||
: 'bg-green-500'
|
||||
const resetText = resetsAt ? formatResetTime(resetsAt) : null
|
||||
return (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="w-20 shrink-0 text-muted-foreground">{label}</span>
|
||||
<div className="h-2 flex-1 rounded-full bg-muted">
|
||||
<div
|
||||
className={`h-2 rounded-full ${color}`}
|
||||
style={{ width: `${Math.min(utilization, 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="w-12 shrink-0 text-right">
|
||||
{utilization.toFixed(0)}%
|
||||
</span>
|
||||
{resetText && (
|
||||
<span className="shrink-0 text-muted-foreground">
|
||||
resets {resetText}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Page component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function HealthCheckPage() {
|
||||
const healthQuery = trpc.systemHealthCheck.useQuery(undefined, {
|
||||
refetchInterval: 30_000,
|
||||
})
|
||||
|
||||
const { data, isLoading, isError, error, refetch } = healthQuery
|
||||
|
||||
// Loading state
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-end">
|
||||
<Skeleton className="h-9 w-24" />
|
||||
</div>
|
||||
<Skeleton className="h-32 w-full" />
|
||||
<Skeleton className="h-48 w-full" />
|
||||
<Skeleton className="h-32 w-full" />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Error state
|
||||
if (isError) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center gap-4 py-12">
|
||||
<XCircle className="h-8 w-8 text-destructive" />
|
||||
<p className="text-sm text-destructive">
|
||||
Failed to load health check: {error?.message ?? 'Unknown error'}
|
||||
</p>
|
||||
<Button variant="outline" size="sm" onClick={() => void refetch()}>
|
||||
Retry
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) return null
|
||||
|
||||
const { server, accounts, projects } = data
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Refresh button */}
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => void refetch()}
|
||||
>
|
||||
<RefreshCw className="mr-2 h-4 w-4" />
|
||||
Refresh
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Server Status */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="flex items-center gap-2 text-lg">
|
||||
<Server className="h-5 w-5" />
|
||||
Server Status
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="flex items-center gap-3">
|
||||
<CheckCircle2 className="h-5 w-5 text-green-500" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">Running</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Uptime: {formatUptime(server.uptime)}
|
||||
{server.startedAt && (
|
||||
<>
|
||||
{' '}
|
||||
· Started{' '}
|
||||
{new Date(server.startedAt).toLocaleString()}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Accounts */}
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-lg font-semibold">Accounts</h2>
|
||||
{accounts.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-6">
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
No accounts configured. Use{' '}
|
||||
<code className="rounded bg-muted px-1 py-0.5 text-xs">
|
||||
cw account add
|
||||
</code>{' '}
|
||||
to register one.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
accounts.map((account) => (
|
||||
<AccountCard key={account.id} account={account} />
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Projects */}
|
||||
<div className="space-y-3">
|
||||
<h2 className="text-lg font-semibold">Projects</h2>
|
||||
{projects.length === 0 ? (
|
||||
<Card>
|
||||
<CardContent className="py-6">
|
||||
<p className="text-center text-sm text-muted-foreground">
|
||||
No projects registered yet.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
) : (
|
||||
projects.map((project) => (
|
||||
<Card key={project.id}>
|
||||
<CardContent className="flex items-center gap-3 py-4">
|
||||
{project.repoExists ? (
|
||||
<CheckCircle2 className="h-5 w-5 shrink-0 text-green-500" />
|
||||
) : (
|
||||
<XCircle className="h-5 w-5 shrink-0 text-destructive" />
|
||||
)}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">{project.name}</p>
|
||||
<p className="truncate text-xs text-muted-foreground">
|
||||
{project.url}
|
||||
</p>
|
||||
</div>
|
||||
<span className="shrink-0 text-xs text-muted-foreground">
|
||||
{project.repoExists ? 'Clone found' : 'Clone missing'}
|
||||
</span>
|
||||
</CardContent>
|
||||
</Card>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Account card
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
type AccountData = {
|
||||
id: string
|
||||
email: string
|
||||
provider: string
|
||||
credentialsValid: boolean
|
||||
tokenValid: boolean
|
||||
tokenExpiresAt: string | null
|
||||
subscriptionType: string | null
|
||||
error: string | null
|
||||
usage: {
|
||||
five_hour: { utilization: number; resets_at: string | null } | null
|
||||
seven_day: { utilization: number; resets_at: string | null } | null
|
||||
seven_day_sonnet: { utilization: number; resets_at: string | null } | null
|
||||
seven_day_opus: { utilization: number; resets_at: string | null } | null
|
||||
extra_usage: {
|
||||
is_enabled: boolean
|
||||
monthly_limit: number | null
|
||||
used_credits: number | null
|
||||
utilization: number | null
|
||||
} | null
|
||||
} | null
|
||||
isExhausted: boolean
|
||||
exhaustedUntil: string | null
|
||||
lastUsedAt: string | null
|
||||
agentCount: number
|
||||
activeAgentCount: number
|
||||
}
|
||||
|
||||
function AccountCard({ account }: { account: AccountData }) {
|
||||
const statusIcon = !account.credentialsValid ? (
|
||||
<XCircle className="h-5 w-5 shrink-0 text-destructive" />
|
||||
) : account.isExhausted ? (
|
||||
<AlertTriangle className="h-5 w-5 shrink-0 text-yellow-500" />
|
||||
) : (
|
||||
<CheckCircle2 className="h-5 w-5 shrink-0 text-green-500" />
|
||||
)
|
||||
|
||||
const statusText = !account.credentialsValid
|
||||
? 'Invalid credentials'
|
||||
: account.isExhausted
|
||||
? `Exhausted until ${account.exhaustedUntil ? new Date(account.exhaustedUntil).toLocaleTimeString() : 'unknown'}`
|
||||
: 'Available'
|
||||
|
||||
const usage = account.usage
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardContent className="space-y-3 py-4">
|
||||
{/* Header row */}
|
||||
<div className="flex items-start gap-3">
|
||||
{statusIcon}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="text-sm font-medium">{account.email}</span>
|
||||
<Badge variant="outline">{account.provider}</Badge>
|
||||
{account.subscriptionType && (
|
||||
<Badge variant="secondary">
|
||||
{capitalize(account.subscriptionType)}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-1 flex items-center gap-3 text-xs text-muted-foreground">
|
||||
<span>
|
||||
{account.agentCount} agent{account.agentCount !== 1 ? 's' : ''}{' '}
|
||||
({account.activeAgentCount} active)
|
||||
</span>
|
||||
<span>{statusText}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Usage bars */}
|
||||
{usage && (
|
||||
<div className="space-y-1.5 pl-8">
|
||||
{usage.five_hour && (
|
||||
<UsageBar
|
||||
label="Session (5h)"
|
||||
utilization={usage.five_hour.utilization}
|
||||
resetsAt={usage.five_hour.resets_at}
|
||||
/>
|
||||
)}
|
||||
{usage.seven_day && (
|
||||
<UsageBar
|
||||
label="Weekly (7d)"
|
||||
utilization={usage.seven_day.utilization}
|
||||
resetsAt={usage.seven_day.resets_at}
|
||||
/>
|
||||
)}
|
||||
{usage.seven_day_sonnet &&
|
||||
usage.seven_day_sonnet.utilization > 0 && (
|
||||
<UsageBar
|
||||
label="Sonnet (7d)"
|
||||
utilization={usage.seven_day_sonnet.utilization}
|
||||
resetsAt={usage.seven_day_sonnet.resets_at}
|
||||
/>
|
||||
)}
|
||||
{usage.seven_day_opus && usage.seven_day_opus.utilization > 0 && (
|
||||
<UsageBar
|
||||
label="Opus (7d)"
|
||||
utilization={usage.seven_day_opus.utilization}
|
||||
resetsAt={usage.seven_day_opus.resets_at}
|
||||
/>
|
||||
)}
|
||||
{usage.extra_usage && usage.extra_usage.is_enabled && (
|
||||
<div className="flex items-center gap-2 text-xs">
|
||||
<span className="w-20 shrink-0 text-muted-foreground">
|
||||
Extra usage
|
||||
</span>
|
||||
<span>
|
||||
${((usage.extra_usage.used_credits ?? 0) / 100).toFixed(2)}{' '}
|
||||
used
|
||||
{usage.extra_usage.monthly_limit != null && (
|
||||
<>
|
||||
{' '}
|
||||
/ ${(usage.extra_usage.monthly_limit / 100).toFixed(
|
||||
2
|
||||
)}{' '}
|
||||
limit
|
||||
</>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{account.error && (
|
||||
<p className="pl-8 text-xs text-destructive">{account.error}</p>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
7
packages/web/src/routes/settings/index.tsx
Normal file
7
packages/web/src/routes/settings/index.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
import { createFileRoute, redirect } from '@tanstack/react-router'
|
||||
|
||||
export const Route = createFileRoute('/settings/')({
|
||||
beforeLoad: () => {
|
||||
throw redirect({ to: '/settings/health' })
|
||||
},
|
||||
})
|
||||
1
packages/web/tsconfig.app.tsbuildinfo
Normal file
1
packages/web/tsconfig.app.tsbuildinfo
Normal file
@@ -0,0 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/routetree.gen.ts","./src/router.tsx","./src/vite-env.d.ts","./src/components/actionmenu.tsx","./src/components/agentoutputviewer.tsx","./src/components/createinitiativedialog.tsx","./src/components/decisionlist.tsx","./src/components/dependencyindicator.tsx","./src/components/errorboundary.tsx","./src/components/executiontab.tsx","./src/components/freetextinput.tsx","./src/components/inboxlist.tsx","./src/components/initiativecard.tsx","./src/components/initiativeheader.tsx","./src/components/initiativelist.tsx","./src/components/messagecard.tsx","./src/components/optiongroup.tsx","./src/components/phaseaccordion.tsx","./src/components/progressbar.tsx","./src/components/progresspanel.tsx","./src/components/projectpicker.tsx","./src/components/questionform.tsx","./src/components/refinespawndialog.tsx","./src/components/registerprojectdialog.tsx","./src/components/skeleton.tsx","./src/components/spawnarchitectdropdown.tsx","./src/components/statusbadge.tsx","./src/components/statusdot.tsx","./src/components/taskdetailmodal.tsx","./src/components/taskrow.tsx","./src/components/editor/blockselectionextension.ts","./src/components/editor/contentproposalreview.tsx","./src/components/editor/contenttab.tsx","./src/components/editor/pagebreadcrumb.tsx","./src/components/editor/pagelinkextension.tsx","./src/components/editor/pagetitlecontext.tsx","./src/components/editor/pagetree.tsx","./src/components/editor/refineagentpanel.tsx","./src/components/editor/slashcommandlist.tsx","./src/components/editor/slashcommands.ts","./src/components/editor/tiptapeditor.tsx","./src/components/editor/slash-command-items.ts","./src/components/execution/breakdownsection.tsx","./src/components/execution/executioncontext.tsx","./src/components/execution/phaseactions.tsx","./src/components/execution/phasewithtasks.tsx","./src/components/execution/phaseslist.tsx","./src/components/execution/plantasksfetcher.tsx","./src/components/execution/progresssidebar.tsx","./src/components/execution/taskmodal.tsx","./src/components/execution/index.ts","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/sonner.tsx","./src/components/ui/textarea.tsx","./src/hooks/index.ts","./src/hooks/useautosave.ts","./src/hooks/usedebounce.ts","./src/hooks/userefineagent.ts","./src/hooks/usespawnmutation.ts","./src/hooks/usesubscriptionwitherrorhandling.ts","./src/layouts/applayout.tsx","./src/lib/markdown-to-tiptap.ts","./src/lib/trpc.ts","./src/lib/utils.ts","./src/routes/__root.tsx","./src/routes/agents.tsx","./src/routes/inbox.tsx","./src/routes/index.tsx","./src/routes/settings.tsx","./src/routes/initiatives/$id.tsx","./src/routes/initiatives/index.tsx","./src/routes/settings/health.tsx","./src/routes/settings/index.tsx"],"errors":true,"version":"5.9.3"}
|
||||
Reference in New Issue
Block a user