diff --git a/packages/web/src/components/AccountCard.tsx b/packages/web/src/components/AccountCard.tsx new file mode 100644 index 0000000..4270098 --- /dev/null +++ b/packages/web/src/components/AccountCard.tsx @@ -0,0 +1,199 @@ +import { CheckCircle2, XCircle, AlertTriangle } from "lucide-react"; +import { Card, CardContent } from "@/components/ui/card"; +import { Badge } from "@/components/ui/badge"; + +function formatResetTime(isoDate: string): string { + const now = Date.now(); + const target = new Date(isoDate).getTime(); + const diffMs = target - now; + if (diffMs <= 0) return "now"; + + const totalMinutes = Math.floor(diffMs / 60_000); + const totalHours = Math.floor(totalMinutes / 60); + const totalDays = Math.floor(totalHours / 24); + + if (totalDays > 0) { + const remainingHours = totalHours - totalDays * 24; + return `in ${totalDays}d ${remainingHours}h`; + } + const remainingMinutes = totalMinutes - totalHours * 60; + return `in ${totalHours}h ${remainingMinutes}m`; +} + +function capitalize(s: string): string { + return s.charAt(0).toUpperCase() + s.slice(1); +} + +function UsageBar({ + label, + utilization, + resetsAt, +}: { + label: string; + utilization: number; + resetsAt: string | null; +}) { + const color = + utilization >= 90 + ? "bg-destructive" + : utilization >= 70 + ? "bg-yellow-500" + : "bg-green-500"; + const resetText = resetsAt ? formatResetTime(resetsAt) : null; + return ( +
+ {label} +
+
+
+ + {utilization.toFixed(0)}% + + {resetText && ( + + resets {resetText} + + )} +
+ ); +} + +export type AccountData = { + id: string; + email: string; + provider: string; + credentialsValid: boolean; + tokenValid: boolean; + tokenExpiresAt: string | null; + subscriptionType: string | null; + error: string | null; + usage: { + five_hour: { utilization: number; resets_at: string | null } | null; + seven_day: { utilization: number; resets_at: string | null } | null; + seven_day_sonnet: { + utilization: number; + resets_at: string | null; + } | null; + seven_day_opus: { utilization: number; resets_at: string | null } | null; + extra_usage: { + is_enabled: boolean; + monthly_limit: number | null; + used_credits: number | null; + utilization: number | null; + } | null; + } | null; + isExhausted: boolean; + exhaustedUntil: string | null; + lastUsedAt: string | null; + agentCount: number; + activeAgentCount: number; +}; + +export function AccountCard({ account }: { account: AccountData }) { + const statusIcon = !account.credentialsValid ? ( + + ) : account.isExhausted ? ( + + ) : ( + + ); + + const statusText = !account.credentialsValid + ? "Invalid credentials" + : account.isExhausted + ? `Exhausted until ${account.exhaustedUntil ? new Date(account.exhaustedUntil).toLocaleTimeString() : "unknown"}` + : "Available"; + + const usage = account.usage; + + return ( + + + {/* Header row */} +
+ {statusIcon} +
+
+ {account.email} + {account.provider} + {account.subscriptionType && ( + + {capitalize(account.subscriptionType)} + + )} +
+
+ + {account.agentCount} agent + {account.agentCount !== 1 ? "s" : ""} ( + {account.activeAgentCount} active) + + {statusText} +
+
+
+ + {/* Usage bars */} + {usage && ( +
+ {usage.five_hour && ( + + )} + {usage.seven_day && ( + + )} + {usage.seven_day_sonnet && + usage.seven_day_sonnet.utilization > 0 && ( + + )} + {usage.seven_day_opus && usage.seven_day_opus.utilization > 0 && ( + + )} + {usage.extra_usage && usage.extra_usage.is_enabled && ( +
+ + Extra usage + + + ${((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 + + )} + +
+ )} +
+ )} + + {/* Error message */} + {account.error && ( +

{account.error}

+ )} +
+
+ ); +} diff --git a/packages/web/src/components/ActionMenu.tsx b/packages/web/src/components/ActionMenu.tsx index 3adcf29..e4c4d0f 100644 --- a/packages/web/src/components/ActionMenu.tsx +++ b/packages/web/src/components/ActionMenu.tsx @@ -16,11 +16,8 @@ interface ActionMenuProps { } export function ActionMenu({ initiativeId, onDelete }: ActionMenuProps) { - const utils = trpc.useUtils(); - const archiveMutation = trpc.updateInitiative.useMutation({ onSuccess: () => { - utils.listInitiatives.invalidate(); onDelete?.(); toast.success("Initiative archived"); }, diff --git a/packages/web/src/components/AgentActions.tsx b/packages/web/src/components/AgentActions.tsx new file mode 100644 index 0000000..ab56186 --- /dev/null +++ b/packages/web/src/components/AgentActions.tsx @@ -0,0 +1,73 @@ +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Button } from "@/components/ui/button"; +import { MoreHorizontal } from "lucide-react"; + +interface AgentActionsProps { + agentId: string; + status: string; + isDismissed: boolean; + onStop: (id: string) => void; + onDelete: (id: string) => void; + onDismiss: (id: string) => void; + onGoToInbox: () => void; +} + +export function AgentActions({ + agentId, + status, + isDismissed, + onStop, + onDelete, + onDismiss, + onGoToInbox, +}: AgentActionsProps) { + return ( + + + + + + {status === "waiting_for_input" && ( + <> + + Go to Inbox + + + + )} + {(status === "running" || status === "waiting_for_input") && ( + onStop(agentId)}> + Stop + + )} + {!isDismissed && + ["stopped", "crashed", "idle"].includes(status) && ( + onDismiss(agentId)}> + Dismiss + + )} + {(isDismissed || + ["stopped", "crashed", "idle"].includes(status)) && ( + <> + + onDelete(agentId)} + > + Delete + + + )} + + + ); +} diff --git a/packages/web/src/components/AgentOutputViewer.tsx b/packages/web/src/components/AgentOutputViewer.tsx index 7a6d661..f47eae8 100644 --- a/packages/web/src/components/AgentOutputViewer.tsx +++ b/packages/web/src/components/AgentOutputViewer.tsx @@ -4,69 +4,17 @@ import { Badge } from "@/components/ui/badge"; import { ArrowDown, Pause, Play, AlertCircle } from "lucide-react"; import { trpc } from "@/lib/trpc"; import { useSubscriptionWithErrorHandling } from "@/hooks"; +import { + type ParsedMessage, + getMessageStyling, + parseAgentOutput, +} from "@/lib/parse-agent-output"; interface AgentOutputViewerProps { agentId: string; agentName?: string; } -function formatToolCall(toolUse: any): string { - const { name, input } = toolUse; - - if (name === 'Bash') { - return `$ ${input.command}${input.description ? '\n# ' + input.description : ''}`; - } - - if (name === 'Read') { - return `šŸ“„ Read: ${input.file_path}${input.offset ? ` (lines ${input.offset}-${input.offset + (input.limit || 10)})` : ''}`; - } - - if (name === 'Edit') { - return `āœļø Edit: ${input.file_path}\n${input.old_string.substring(0, 100)}${input.old_string.length > 100 ? '...' : ''}\n→ ${input.new_string.substring(0, 100)}${input.new_string.length > 100 ? '...' : ''}`; - } - - if (name === 'Write') { - return `šŸ“ Write: ${input.file_path} (${input.content.length} chars)`; - } - - if (name === 'Task') { - return `šŸ¤– ${input.subagent_type}: ${input.description}\n${input.prompt?.substring(0, 200)}${input.prompt && input.prompt.length > 200 ? '...' : ''}`; - } - - // Generic fallback - return `${name}: ${JSON.stringify(input, null, 2)}`; -} - -function getMessageStyling(type: ParsedMessage['type']): string { - switch (type) { - case 'system': - return 'mb-1'; - case 'text': - return 'mb-1'; - case 'tool_call': - return 'mb-2'; - case 'tool_result': - return 'mb-2'; - case 'error': - return 'mb-2'; - case 'session_end': - return 'mb-2'; - default: - return 'mb-1'; - } -} - -interface ParsedMessage { - type: 'text' | 'system' | 'tool_call' | 'tool_result' | 'session_end' | 'error'; - content: string; - meta?: { - toolName?: string; - isError?: boolean; - cost?: number; - duration?: number; - }; -} - export function AgentOutputViewer({ agentId, agentName }: AgentOutputViewerProps) { const [messages, setMessages] = useState([]); const [follow, setFollow] = useState(true); @@ -101,100 +49,7 @@ export function AgentOutputViewer({ agentId, agentName }: AgentOutputViewerProps // Set initial output when query loads useEffect(() => { if (outputQuery.data) { - const lines = outputQuery.data.split("\n").filter(Boolean); - const parsedMessages: ParsedMessage[] = []; - - for (const line of lines) { - try { - const event = JSON.parse(line); - - // System initialization - if (event.type === "system" && event.session_id) { - parsedMessages.push({ - type: 'system', - content: `Session started: ${event.session_id}` - }); - } - - // Assistant messages with text and tool calls - else if (event.type === "assistant" && Array.isArray(event.message?.content)) { - for (const block of event.message.content) { - if (block.type === "text" && block.text) { - parsedMessages.push({ - type: 'text', - content: block.text - }); - } else if (block.type === "tool_use") { - parsedMessages.push({ - type: 'tool_call', - content: formatToolCall(block), - meta: { toolName: block.name } - }); - } - } - } - - // User messages with tool results - else if (event.type === "user" && Array.isArray(event.message?.content)) { - for (const block of event.message.content) { - if (block.type === "tool_result") { - const rawContent = block.content; - const output = typeof rawContent === 'string' - ? rawContent - : Array.isArray(rawContent) - ? rawContent.map((c: any) => c.text ?? JSON.stringify(c)).join('\n') - : event.tool_use_result?.stdout || ''; - const stderr = event.tool_use_result?.stderr; - - if (stderr) { - parsedMessages.push({ - type: 'error', - content: stderr, - meta: { isError: true } - }); - } else if (output) { - const displayOutput = output.length > 1000 ? - output.substring(0, 1000) + '\n... (truncated)' : output; - parsedMessages.push({ - type: 'tool_result', - content: displayOutput - }); - } - } - } - } - - // Legacy streaming format - else if (event.type === "stream_event" && event.event?.delta?.text) { - parsedMessages.push({ - type: 'text', - content: event.event.delta.text - }); - } - - // Session completion - else if (event.type === "result") { - parsedMessages.push({ - type: 'session_end', - content: event.is_error ? 'Session failed' : 'Session completed', - meta: { - isError: event.is_error, - cost: event.total_cost_usd, - duration: event.duration_ms - } - }); - } - - } catch { - // Not JSON, display as-is - parsedMessages.push({ - type: 'error', - content: line, - meta: { isError: true } - }); - } - } - setMessages(parsedMessages); + setMessages(parseAgentOutput(outputQuery.data)); } }, [outputQuery.data]); @@ -233,7 +88,7 @@ export function AgentOutputViewer({ agentId, agentName }: AgentOutputViewerProps const hasOutput = messages.length > 0; return ( -
+
{/* Header */}
diff --git a/packages/web/src/components/CreateInitiativeDialog.tsx b/packages/web/src/components/CreateInitiativeDialog.tsx index a6244d0..17c4c9c 100644 --- a/packages/web/src/components/CreateInitiativeDialog.tsx +++ b/packages/web/src/components/CreateInitiativeDialog.tsx @@ -30,13 +30,31 @@ export function CreateInitiativeDialog({ const utils = trpc.useUtils(); const createMutation = trpc.createInitiative.useMutation({ + onMutate: async ({ name }) => { + await utils.listInitiatives.cancel(); + const previousInitiatives = utils.listInitiatives.getData(); + const tempInitiative = { + id: `temp-${Date.now()}`, + name: name.trim(), + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + mergeRequiresApproval: true, + mergeTarget: 'main', + projects: [], + }; + utils.listInitiatives.setData(undefined, (old = []) => [tempInitiative, ...old]); + return { previousInitiatives }; + }, onSuccess: () => { - utils.listInitiatives.invalidate(); onOpenChange(false); toast.success("Initiative created"); }, - onError: (err) => { + onError: (err, _variables, context) => { + if (context?.previousInitiatives) { + utils.listInitiatives.setData(undefined, context.previousInitiatives); + } setError(err.message); + toast.error("Failed to create initiative"); }, }); diff --git a/packages/web/src/components/ExecutionTab.tsx b/packages/web/src/components/ExecutionTab.tsx index c17e373..56e2974 100644 --- a/packages/web/src/components/ExecutionTab.tsx +++ b/packages/web/src/components/ExecutionTab.tsx @@ -1,17 +1,27 @@ +import { useState, useMemo, useRef, useEffect } from "react"; +import { toast } from "sonner"; +import { trpc } from "@/lib/trpc"; +import { topologicalSortPhases, type DependencyEdge } from "@codewalk-district/shared"; import { ExecutionProvider, PhaseActions, - PhasesList, - ProgressSidebar, + BreakdownSection, TaskModal, type PhaseData, } from "@/components/execution"; +import { PhaseSidebarItem } from "@/components/execution/PhaseSidebarItem"; +import { + PhaseDetailPanel, + PhaseDetailEmpty, +} from "@/components/execution/PhaseDetailPanel"; +import { Skeleton } from "@/components/Skeleton"; interface ExecutionTabProps { initiativeId: string; phases: PhaseData[]; phasesLoading: boolean; phasesLoaded: boolean; + dependencyEdges: DependencyEdge[]; } export function ExecutionTab({ @@ -19,30 +29,295 @@ export function ExecutionTab({ phases, phasesLoading, phasesLoaded, + dependencyEdges, }: ExecutionTabProps) { + // Topological sort + const sortedPhases = useMemo( + () => topologicalSortPhases(phases, dependencyEdges), + [phases, dependencyEdges], + ); + + // Build dependency name map from bulk edges + const depNamesByPhase = useMemo(() => { + const map = new Map(); + const phaseIndex = new Map(sortedPhases.map((p, i) => [p.id, i + 1])); + for (const edge of dependencyEdges) { + const depIdx = phaseIndex.get(edge.dependsOnPhaseId); + if (!depIdx) continue; + const existing = map.get(edge.phaseId) ?? []; + existing.push(`Phase ${depIdx}`); + map.set(edge.phaseId, existing); + } + return map; + }, [dependencyEdges, sortedPhases]); + + // Decompose agent tracking: map phaseId → most recent active decompose agent + const agentsQuery = trpc.listAgents.useQuery(); + const allAgents = agentsQuery.data ?? []; + + // Default to first incomplete phase + const firstIncompleteId = useMemo(() => { + const found = sortedPhases.find((p) => p.status !== "completed"); + return found?.id ?? sortedPhases[0]?.id ?? null; + }, [sortedPhases]); + + const [selectedPhaseId, setSelectedPhaseId] = useState(null); + const [isAddingPhase, setIsAddingPhase] = useState(false); + + const deletePhase = trpc.deletePhase.useMutation({ + onSuccess: () => { + setSelectedPhaseId(null); + toast.success("Phase deleted"); + }, + onError: () => { + toast.error("Failed to delete phase"); + }, + }); + + const createPhase = trpc.createPhase.useMutation({ + onSuccess: () => { + setIsAddingPhase(false); + toast.success("Phase created"); + }, + onError: () => { + toast.error("Failed to create phase"); + }, + }); + + function handleStartAdd() { + setIsAddingPhase(true); + } + + function handleConfirmAdd(name: string) { + const trimmed = name.trim(); + if (!trimmed) { + setIsAddingPhase(false); + return; + } + createPhase.mutate({ initiativeId, name: trimmed }); + } + + function handleCancelAdd() { + setIsAddingPhase(false); + } + + // Resolve actual selected ID (use default if none explicitly selected) + const activePhaseId = selectedPhaseId ?? firstIncompleteId; + const activePhase = sortedPhases.find((p) => p.id === activePhaseId) ?? null; + const activeDisplayIndex = activePhase + ? sortedPhases.indexOf(activePhase) + 1 + : 0; + + // Fetch all tasks for the initiative in one query (for sidebar counts) + const allTasksQuery = trpc.listInitiativeTasks.useQuery( + { initiativeId }, + { enabled: phasesLoaded && sortedPhases.length > 0 }, + ); + const allTasks = allTasksQuery.data ?? []; + + // Group tasks and counts by phaseId + const { taskCountsByPhase, tasksByPhase } = useMemo(() => { + const counts: Record = {}; + const grouped: Record = {}; + for (const task of allTasks) { + const pid = task.phaseId; + if (!pid) continue; + if (!counts[pid]) counts[pid] = { complete: 0, total: 0 }; + counts[pid].total++; + if (task.status === "completed") counts[pid].complete++; + if (!grouped[pid]) grouped[pid] = []; + grouped[pid].push(task); + } + return { taskCountsByPhase: counts, tasksByPhase: grouped }; + }, [allTasks]); + + // Map phaseId → most recent active decompose agent + const decomposeAgentByPhase = useMemo(() => { + const map = new Map(); + // Build taskId → phaseId lookup from allTasks + const taskPhaseMap = new Map(); + for (const t of allTasks) { + if (t.phaseId) taskPhaseMap.set(t.id, t.phaseId); + } + const candidates = allAgents.filter( + (a) => + a.mode === "decompose" && + a.initiativeId === initiativeId && + ["running", "waiting_for_input", "idle"].includes(a.status) && + !a.userDismissedAt, + ); + for (const agent of candidates) { + const phaseId = taskPhaseMap.get(agent.taskId ?? ""); + if (!phaseId) continue; + const existing = map.get(phaseId); + if ( + !existing || + new Date(agent.createdAt).getTime() > + new Date(existing.createdAt).getTime() + ) { + map.set(phaseId, agent); + } + } + return map; + }, [allAgents, allTasks, initiativeId]); + + // Phase IDs that have zero tasks (eligible for breakdown) + const phasesWithoutTasks = useMemo( + () => + sortedPhases + .filter((p) => !taskCountsByPhase[p.id]?.total) + .map((p) => p.id), + [sortedPhases, taskCountsByPhase], + ); + + // Build display indices map for PhaseDetailPanel + const allDisplayIndices = useMemo( + () => new Map(sortedPhases.map((p, i) => [p.id, i + 1])), + [sortedPhases], + ); + + // No phases yet and not adding — show breakdown section + if (phasesLoaded && sortedPhases.length === 0 && !isAddingPhase) { + return ( + + + + + ); + } + + const nextNumber = sortedPhases.length + 1; + return ( -
- {/* Left column: Phases */} +
+ {/* Left: Phase sidebar */}
-

Phases

- +

+ Phases +

+
- + {phasesLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) : ( +
+ {sortedPhases.map((phase, index) => ( + setSelectedPhaseId(phase.id)} + /> + ))} + {isAddingPhase && ( + + )} +
+ )}
- {/* Right column: Progress + Decisions */} - + {/* Right: Phase detail */} +
+ {activePhase ? ( + deletePhase.mutate({ id: activePhase.id })} + decomposeAgent={decomposeAgentByPhase.get(activePhase.id) ?? null} + /> + ) : ( + + )} +
); } + +/** Editable placeholder entry that looks like a PhaseSidebarItem */ +function NewPhaseEntry({ + number, + onConfirm, + onCancel, +}: { + number: number; + onConfirm: (name: string) => void; + onCancel: () => void; +}) { + const [name, setName] = useState(""); + const inputRef = useRef(null); + + useEffect(() => { + inputRef.current?.focus(); + }, []); + + function handleBlur() { + const trimmed = name.trim(); + if (trimmed) { + onConfirm(trimmed); + } else { + onCancel(); + } + } + + return ( +
+
+ Phase {number}: + setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + const trimmed = name.trim(); + if (trimmed) onConfirm(trimmed); + else onCancel(); + } + if (e.key === "Escape") onCancel(); + }} + onBlur={handleBlur} + /> +
+
0/0 tasks
+
+ ); +} diff --git a/packages/web/src/components/InboxDetailPanel.tsx b/packages/web/src/components/InboxDetailPanel.tsx new file mode 100644 index 0000000..85d1666 --- /dev/null +++ b/packages/web/src/components/InboxDetailPanel.tsx @@ -0,0 +1,171 @@ +import { Link } from "@tanstack/react-router"; +import { ChevronLeft } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { QuestionForm } from "@/components/QuestionForm"; +import { formatRelativeTime } from "@/lib/utils"; + +interface InboxDetailPanelProps { + agent: { + id: string; + name: string; + status: string; + taskId: string | null; + updatedAt: string; + }; + message: { + id: string; + content: string; + requiresResponse: boolean; + } | null; + questions: + | { + id: string; + question: string; + options: any; + multiSelect: boolean; + }[] + | null; + isLoadingQuestions: boolean; + questionsError: string | null; + onBack: () => void; + onSubmitAnswers: (answers: Record) => void; + onDismissQuestions: () => void; + onDismissMessage: () => void; + isSubmitting: boolean; + isDismissingQuestions: boolean; + isDismissingMessage: boolean; + submitError: string | null; + dismissMessageError: string | null; +} + +export function InboxDetailPanel({ + agent, + message, + questions, + isLoadingQuestions, + questionsError, + onBack, + onSubmitAnswers, + onDismissQuestions, + onDismissMessage, + isSubmitting, + isDismissingQuestions, + isDismissingMessage, + submitError, + dismissMessageError, +}: InboxDetailPanelProps) { + return ( +
+ {/* Mobile back button */} + + + {/* Detail Header */} +
+
+

+ {agent.name}{" "} + + → You + +

+ + {formatRelativeTime(agent.updatedAt)} + +
+

+ Task:{" "} + {agent.taskId ? ( + + {agent.taskId} + + ) : ( + "\u2014" + )} +

+ {agent.taskId && ( + + View in context → + + )} +
+ + {/* Question Form or Notification Content */} + {isLoadingQuestions && ( +
+ Loading questions... +
+ )} + + {questionsError && ( +
+ Failed to load questions: {questionsError} +
+ )} + + {questions && questions.length > 0 && ( + + )} + + {submitError && ( +

Error: {submitError}

+ )} + + {/* Notification message (no questions / requiresResponse=false) */} + {message && !message.requiresResponse && !isLoadingQuestions && ( +
+

{message.content}

+
+ +
+ {dismissMessageError && ( +

+ Error: {dismissMessageError} +

+ )} +
+ )} + + {/* No questions and requires response -- message content only */} + {message && + message.requiresResponse && + questions && + questions.length === 0 && + !isLoadingQuestions && ( +
+

{message.content}

+

+ Waiting for structured questions... +

+
+ )} +
+ ); +} diff --git a/packages/web/src/components/InboxList.tsx b/packages/web/src/components/InboxList.tsx index 45d6c0f..b1e186a 100644 --- a/packages/web/src/components/InboxList.tsx +++ b/packages/web/src/components/InboxList.tsx @@ -49,6 +49,7 @@ export function InboxList({ const [sort, setSort] = useState("newest"); // Join agents with their latest message (match message.senderId to agent.id) + // Also include agents with waiting_for_input status even if they don't have messages const joined = useMemo(() => { const latestByAgent = new Map(); @@ -64,7 +65,19 @@ export function InboxList({ for (const agent of agents) { const msg = latestByAgent.get(agent.id); if (msg) { + // Agent has a message entries.push({ agent, message: msg }); + } else if (agent.status === 'waiting_for_input') { + // Agent is waiting for input but has no message - create a placeholder message for questions + const placeholderMessage: Message = { + id: `questions-${agent.id}`, + senderId: agent.id, + content: "Agent has questions that need answers", + requiresResponse: true, + status: "pending", + createdAt: agent.updatedAt, // Use agent's updated time + }; + entries.push({ agent, message: placeholderMessage }); } } diff --git a/packages/web/src/components/InitiativeHeader.tsx b/packages/web/src/components/InitiativeHeader.tsx index b320a18..be07038 100644 --- a/packages/web/src/components/InitiativeHeader.tsx +++ b/packages/web/src/components/InitiativeHeader.tsx @@ -25,10 +25,8 @@ export function InitiativeHeader({ const [editing, setEditing] = useState(false); const [editIds, setEditIds] = useState([]); - const utils = trpc.useUtils(); const updateMutation = trpc.updateInitiativeProjects.useMutation({ onSuccess: () => { - utils.getInitiative.invalidate({ id: initiative.id }); setEditing(false); toast.success("Projects updated"); }, diff --git a/packages/web/src/components/PhaseAccordion.tsx b/packages/web/src/components/PhaseAccordion.tsx index 4fa6020..fb144ec 100644 --- a/packages/web/src/components/PhaseAccordion.tsx +++ b/packages/web/src/components/PhaseAccordion.tsx @@ -3,14 +3,14 @@ import { ChevronDown, ChevronRight } from "lucide-react"; import { StatusBadge } from "@/components/StatusBadge"; import { DependencyIndicator } from "@/components/DependencyIndicator"; import { TaskRow, type SerializedTask } from "@/components/TaskRow"; +import { PhaseContentEditor } from "@/components/editor/PhaseContentEditor"; /** Phase shape as returned by tRPC (Date fields serialized to string over JSON) */ interface SerializedPhase { id: string; initiativeId: string; - number: number; name: string; - description: string | null; + content: string | null; status: string; createdAt: string; updatedAt: string; @@ -59,9 +59,9 @@ export function PhaseAccordion({ )} - {/* Phase number + name */} + {/* Phase name */} - Phase {phase.number}: {phase.name} + {phase.name} {/* Task count */} @@ -82,9 +82,10 @@ export function PhaseAccordion({ /> )} - {/* Expanded task list */} + {/* Expanded content editor + task list */} {expanded && (
+ {tasks.map((entry, idx) => ( ) => void; onCancel: () => void; + onDismiss?: () => void; isSubmitting?: boolean; + isDismissing?: boolean; } export function QuestionForm({ questions, onSubmit, onCancel, + onDismiss, isSubmitting = false, + isDismissing = false, }: QuestionFormProps) { const [answers, setAnswers] = useState>(() => { const initial: Record = {}; @@ -75,13 +79,22 @@ export function QuestionForm({ + {onDismiss && ( + + )} diff --git a/packages/web/src/components/RegisterProjectDialog.tsx b/packages/web/src/components/RegisterProjectDialog.tsx index cc6b0d2..33291b5 100644 --- a/packages/web/src/components/RegisterProjectDialog.tsx +++ b/packages/web/src/components/RegisterProjectDialog.tsx @@ -26,11 +26,8 @@ export function RegisterProjectDialog({ const [url, setUrl] = useState(""); const [error, setError] = useState(null); - const utils = trpc.useUtils(); - const registerMutation = trpc.registerProject.useMutation({ onSuccess: () => { - utils.listProjects.invalidate(); onOpenChange(false); toast.success("Project registered"); }, diff --git a/packages/web/src/components/StatusBadge.tsx b/packages/web/src/components/StatusBadge.tsx index 114d68d..a440632 100644 --- a/packages/web/src/components/StatusBadge.tsx +++ b/packages/web/src/components/StatusBadge.tsx @@ -9,6 +9,7 @@ const statusStyles: Record = { // Phase statuses pending: "bg-gray-100 text-gray-800 hover:bg-gray-100/80 border-gray-200", + approved: "bg-amber-100 text-amber-800 hover:bg-amber-100/80 border-amber-200", in_progress: "bg-blue-100 text-blue-800 hover:bg-blue-100/80 border-blue-200", blocked: "bg-red-100 text-red-800 hover:bg-red-100/80 border-red-200", }; diff --git a/packages/web/src/components/editor/BlockDragHandle.tsx b/packages/web/src/components/editor/BlockDragHandle.tsx new file mode 100644 index 0000000..f4276fb --- /dev/null +++ b/packages/web/src/components/editor/BlockDragHandle.tsx @@ -0,0 +1,238 @@ +import { useState, useRef, useCallback } from "react"; +import type { Editor } from "@tiptap/react"; +import { GripVertical, Plus } from "lucide-react"; +import { NodeSelection, TextSelection } from "@tiptap/pm/state"; +import { Fragment, Slice, type Node as PmNode } from "@tiptap/pm/model"; +import { + blockSelectionKey, + getBlockRange, +} from "./BlockSelectionExtension"; + +interface BlockDragHandleProps { + editor: Editor | null; + children: React.ReactNode; +} + +export function BlockDragHandle({ editor, children }: BlockDragHandleProps) { + const blockIndexRef = useRef(null); + const savedBlockSelRef = useRef<{ + anchorIndex: number; + headIndex: number; + } | null>(null); + const blockElRef = useRef(null); + const [handlePos, setHandlePos] = useState<{ + top: number; + height: number; + } | null>(null); + + // Track which block the mouse is over + const onMouseMove = useCallback( + (e: React.MouseEvent) => { + // If hovering the handle itself, keep current position + if ((e.target as HTMLElement).closest("[data-block-handle-row]")) return; + + const editorEl = (e.currentTarget as HTMLElement).querySelector( + ".ProseMirror", + ); + if (!editorEl || !editor) return; + + // Walk from event target up to a direct child of .ProseMirror + let target = e.target as HTMLElement; + while ( + target && + target !== editorEl && + target.parentElement !== editorEl + ) { + target = target.parentElement!; + } + + if ( + target && + target !== editorEl && + target.parentElement === editorEl + ) { + blockElRef.current = target; + const editorRect = editorEl.getBoundingClientRect(); + const blockRect = target.getBoundingClientRect(); + setHandlePos({ + top: blockRect.top - editorRect.top, + height: blockRect.height, + }); + + // Track top-level block index for block selection + try { + const pos = editor.view.posAtDOM(target, 0); + blockIndexRef.current = editor.view.state.doc + .resolve(pos) + .index(0); + } catch { + blockIndexRef.current = null; + } + } + // Don't clear -- only onMouseLeave clears + }, + [editor], + ); + + const onMouseLeave = useCallback(() => { + setHandlePos(null); + blockElRef.current = null; + blockIndexRef.current = null; + }, []); + + // Click on drag handle -> select block (Shift+click extends) + const onHandleClick = useCallback( + (e: React.MouseEvent) => { + if (!editor) return; + const idx = blockIndexRef.current; + if (idx == null) return; + + // Use saved state from mousedown (PM may have cleared it due to focus change) + const existing = savedBlockSelRef.current; + + let newSel; + if (e.shiftKey && existing) { + newSel = { anchorIndex: existing.anchorIndex, headIndex: idx }; + } else { + newSel = { anchorIndex: idx, headIndex: idx }; + } + + const tr = editor.view.state.tr.setMeta(blockSelectionKey, newSel); + tr.setMeta("blockSelectionInternal", true); + editor.view.dispatch(tr); + // Refocus editor so Shift+Arrow keys reach PM's handleKeyDown + editor.view.focus(); + }, + [editor], + ); + + // Add a new empty paragraph below the hovered block + const onHandleAdd = useCallback(() => { + if (!editor || !blockElRef.current) return; + const view = editor.view; + try { + const pos = view.posAtDOM(blockElRef.current, 0); + const $pos = view.state.doc.resolve(pos); + const after = $pos.after($pos.depth); + const paragraph = view.state.schema.nodes.paragraph.create(); + const tr = view.state.tr.insert(after, paragraph); + // Place cursor inside the new paragraph + tr.setSelection(TextSelection.create(tr.doc, after + 1)); + view.dispatch(tr); + view.focus(); + } catch { + // posAtDOM can throw if the element isn't in the editor + } + }, [editor]); + + // Initiate ProseMirror-native drag when handle is dragged + const onHandleDragStart = useCallback( + (e: React.DragEvent) => { + if (!editor || !blockElRef.current) return; + + const view = editor.view; + const el = blockElRef.current; + // Use saved state from mousedown (PM may have cleared it due to focus change) + const bsel = savedBlockSelRef.current; + + try { + // Multi-block drag: if block selection is active and hovered block is in range + if (bsel && blockIndexRef.current != null) { + const from = Math.min(bsel.anchorIndex, bsel.headIndex); + const to = Math.max(bsel.anchorIndex, bsel.headIndex); + + if ( + blockIndexRef.current >= from && + blockIndexRef.current <= to + ) { + const blockRange = getBlockRange(view.state, bsel); + if (blockRange) { + const nodes: PmNode[] = []; + let idx = 0; + view.state.doc.forEach((node) => { + if (idx >= from && idx <= to) nodes.push(node); + idx++; + }); + + const sel = TextSelection.create( + view.state.doc, + blockRange.fromPos, + blockRange.toPos, + ); + const tr = view.state.tr.setSelection(sel); + tr.setMeta("blockSelectionInternal", true); + view.dispatch(tr); + + view.dragging = { + slice: new Slice(Fragment.from(nodes), 0, 0), + move: true, + }; + + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setDragImage(el, 0, 0); + e.dataTransfer.setData("application/x-pm-drag", "true"); + return; + } + } + } + + // Single-block drag (existing behavior) + const pos = view.posAtDOM(el, 0); + const $pos = view.state.doc.resolve(pos); + const before = $pos.before($pos.depth); + + const sel = NodeSelection.create(view.state.doc, before); + view.dispatch(view.state.tr.setSelection(sel)); + + view.dragging = { slice: sel.content(), move: true }; + + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setDragImage(el, 0, 0); + e.dataTransfer.setData("application/x-pm-drag", "true"); + } catch { + // posAtDOM can throw if the element isn't in the editor + } + }, + [editor], + ); + + return ( +
+ {handlePos && ( +
+
e.preventDefault()} + className="flex items-center justify-center w-5 h-6 cursor-pointer rounded hover:bg-muted" + > + +
+
{ + 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" + > + +
+
+ )} + {children} +
+ ); +} diff --git a/packages/web/src/components/editor/ContentProposalReview.tsx b/packages/web/src/components/editor/ContentProposalReview.tsx index cc9bf8a..3652dd9 100644 --- a/packages/web/src/components/editor/ContentProposalReview.tsx +++ b/packages/web/src/components/editor/ContentProposalReview.tsx @@ -1,4 +1,4 @@ -import { useState, useCallback } from "react"; +import { useState, useCallback, useMemo } from "react"; import { Check, ChevronDown, ChevronRight, AlertTriangle } from "lucide-react"; import { Button } from "@/components/ui/button"; import { trpc } from "@/lib/trpc"; @@ -18,33 +18,51 @@ export function ContentProposalReview({ onDismiss, }: ContentProposalReviewProps) { const [accepted, setAccepted] = useState>(new Set()); + const [acceptError, setAcceptError] = useState(null); const utils = trpc.useUtils(); const acceptMutation = trpc.acceptProposal.useMutation({ + onMutate: async ({ id }) => { + await utils.listProposals.cancel({ agentId }); + const previousProposals = utils.listProposals.getData({ agentId }); + utils.listProposals.setData({ agentId }, (old = []) => + old.map(p => p.id === id ? { ...p, status: 'accepted' as const } : p) + ); + return { previousProposals }; + }, onSuccess: () => { - void utils.listProposals.invalidate(); - void utils.listPages.invalidate(); - void utils.getPage.invalidate(); - void utils.listAgents.invalidate(); + setAcceptError(null); + }, + onError: (err, _variables, context) => { + if (context?.previousProposals) { + utils.listProposals.setData({ agentId }, context.previousProposals); + } + setAcceptError(err.message); }, }); const acceptAllMutation = trpc.acceptAllProposals.useMutation({ - onSuccess: () => { - void utils.listProposals.invalidate(); - void utils.listPages.invalidate(); - void utils.getPage.invalidate(); - void utils.listAgents.invalidate(); - onDismiss(); + onSuccess: (result) => { + if (result.failed > 0) { + setAcceptError(`${result.failed} proposal(s) failed: ${result.errors.join('; ')}`); + } else { + setAcceptError(null); + onDismiss(); + } }, }); const dismissAllMutation = trpc.dismissAllProposals.useMutation({ - onSuccess: () => { - void utils.listProposals.invalidate(); - void utils.listAgents.invalidate(); - // Note: onDismiss() is not called here because the backend auto-dismiss - // will set userDismissedAt when all proposals are resolved + onMutate: async () => { + await utils.listProposals.cancel({ agentId }); + const previousProposals = utils.listProposals.getData({ agentId }); + utils.listProposals.setData({ agentId }, []); + return { previousProposals }; + }, + onError: (_err, _variables, context) => { + if (context?.previousProposals) { + utils.listProposals.setData({ agentId }, context.previousProposals); + } }, }); @@ -64,6 +82,20 @@ export function ContentProposalReview({ dismissAllMutation.mutate({ agentId }); }, [dismissAllMutation, agentId]); + // Batch-fetch page updatedAt timestamps for staleness check (eliminates N+1) + const pageTargetIds = useMemo(() => { + const ids = new Set(); + for (const p of proposals) { + if (p.targetType === 'page' && p.targetId) ids.add(p.targetId); + } + return [...ids]; + }, [proposals]); + + const pageUpdatedAtMap = trpc.getPageUpdatedAtMap.useQuery( + { ids: pageTargetIds }, + { enabled: pageTargetIds.length > 0 }, + ); + const allAccepted = proposals.every((p) => accepted.has(p.id) || p.status === 'accepted'); return ( @@ -94,6 +126,13 @@ export function ContentProposalReview({
+ {acceptError && ( +
+ + {acceptError} +
+ )} +
{proposals.map((proposal) => ( handleAccept(proposal)} isAccepting={acceptMutation.isPending} /> @@ -114,6 +156,7 @@ interface ProposalCardProps { proposal: Proposal; isAccepted: boolean; agentCreatedAt: Date; + pageUpdatedAt: string | null; onAccept: () => void; isAccepting: boolean; } @@ -122,17 +165,12 @@ function ProposalCard({ proposal, isAccepted, agentCreatedAt, + pageUpdatedAt, onAccept, isAccepting, }: ProposalCardProps) { const [expanded, setExpanded] = useState(false); - // Check if target page was modified since agent started (page proposals only) - const pageQuery = trpc.getPage.useQuery( - { id: proposal.targetId ?? '' }, - { enabled: proposal.targetType === 'page' && !!proposal.targetId }, - ); - const pageUpdatedAt = pageQuery.data?.updatedAt; const isStale = proposal.targetType === 'page' && pageUpdatedAt && new Date(pageUpdatedAt) > agentCreatedAt; diff --git a/packages/web/src/components/editor/ContentTab.tsx b/packages/web/src/components/editor/ContentTab.tsx index 35bb919..dcb8ff5 100644 --- a/packages/web/src/components/editor/ContentTab.tsx +++ b/packages/web/src/components/editor/ContentTab.tsx @@ -7,16 +7,9 @@ import { TiptapEditor } from "./TiptapEditor"; import { PageTitleProvider } from "./PageTitleContext"; import { PageTree } from "./PageTree"; import { RefineAgentPanel } from "./RefineAgentPanel"; +import { DeleteSubpageDialog } from "./DeleteSubpageDialog"; import { Skeleton } from "@/components/Skeleton"; import { Button } from "@/components/ui/button"; -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, - DialogDescription, - DialogFooter, -} from "@/components/ui/dialog"; interface ContentTabProps { initiativeId: string; @@ -30,30 +23,14 @@ interface DeleteConfirmation { 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 }); + const { save, flush, isSaving } = useAutoSave(); // Get or create root page const rootPageQuery = trpc.getRootPage.useQuery({ initiativeId }); const allPagesQuery = trpc.listPages.useQuery({ initiativeId }); - const createPageMutation = trpc.createPage.useMutation({ - 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 createPageMutation = trpc.createPage.useMutation(); + const deletePageMutation = trpc.deletePage.useMutation(); + const updateInitiativeMutation = trpc.updateInitiative.useMutation(); const initiativeNameTimerRef = useRef | null>(null); const pendingInitiativeNameRef = useRef(null); @@ -158,7 +135,7 @@ export function ContentTab({ initiativeId, initiativeName }: ContentTabProps) { setActivePageId(pageId); }, []); - // Slash command: /subpage — creates a page and inserts a link at cursor + // Slash command: /subpage -- creates a page and inserts a link at cursor const handleSubpageCreate = useCallback( async (editor: Editor) => { editorRef.current = editor; @@ -193,7 +170,7 @@ export function ContentTab({ initiativeId, initiativeName }: ContentTabProps) { 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 + // Page doesn't exist -- redo the deletion so the stale link is removed redo(); return; } @@ -228,7 +205,7 @@ export function ContentTab({ initiativeId, initiativeName }: ContentTabProps) { ); } - // Error — server likely needs restart or migration hasn't applied + // Error -- server likely needs restart or migration hasn't applied if (rootPageQuery.isError) { return (
@@ -271,7 +248,7 @@ export function ContentTab({ initiativeId, initiativeName }: ContentTabProps) { {/* Editor area */}
- {/* Refine agent panel — sits above editor */} + {/* Refine agent panel -- sits above editor */} {resolvedActivePageId && ( @@ -295,7 +272,7 @@ export function ContentTab({ initiativeId, initiativeName }: ContentTabProps) { {activePageQuery.isSuccess && ( {/* Delete subpage confirmation dialog */} - { - if (!open) dismissDeleteConfirm(); - }} - > - - - Delete subpage? - - 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? - - - - - - - - + pageName={ + allPages.find((p) => p.id === deleteConfirm?.pageId)?.title ?? + "Untitled" + } + onConfirm={confirmDeleteSubpage} + onCancel={dismissDeleteConfirm} + /> ); diff --git a/packages/web/src/components/editor/DeleteSubpageDialog.tsx b/packages/web/src/components/editor/DeleteSubpageDialog.tsx new file mode 100644 index 0000000..2499bf9 --- /dev/null +++ b/packages/web/src/components/editor/DeleteSubpageDialog.tsx @@ -0,0 +1,50 @@ +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, + DialogDescription, + DialogFooter, +} from "@/components/ui/dialog"; + +interface DeleteSubpageDialogProps { + open: boolean; + pageName: string; + onConfirm: () => void; + onCancel: () => void; +} + +export function DeleteSubpageDialog({ + open, + pageName, + onConfirm, + onCancel, +}: DeleteSubpageDialogProps) { + return ( + { + if (!isOpen) onCancel(); + }} + > + + + Delete subpage? + + You removed the link to “{pageName}”. Do you also want + to delete the subpage and all its content? + + + + + + + + + ); +} diff --git a/packages/web/src/components/editor/PageLinkDeletionDetector.ts b/packages/web/src/components/editor/PageLinkDeletionDetector.ts new file mode 100644 index 0000000..8e0ef37 --- /dev/null +++ b/packages/web/src/components/editor/PageLinkDeletionDetector.ts @@ -0,0 +1,66 @@ +import { Extension } from "@tiptap/react"; +import { Plugin, PluginKey } from "@tiptap/pm/state"; +import type { MutableRefObject } from "react"; + +export function createPageLinkDeletionDetector( + onPageLinkDeletedRef: MutableRefObject< + ((pageId: string, redo: () => void) => void) | undefined + >, +) { + return Extension.create({ + name: "pageLinkDeletionDetector", + addStorage() { + return { skipDetection: false }; + }, + addProseMirrorPlugins() { + const tiptapEditor = this.editor; + return [ + new Plugin({ + key: new PluginKey("pageLinkDeletionDetector"), + appendTransaction(_transactions, oldState, newState) { + if (oldState.doc.eq(newState.doc)) return null; + + const oldLinks = new Set(); + oldState.doc.descendants((node) => { + if (node.type.name === "pageLink" && node.attrs.pageId) { + oldLinks.add(node.attrs.pageId); + } + }); + + const newLinks = new Set(); + 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; + }, + }), + ]; + }, + }); +} diff --git a/packages/web/src/components/editor/PhaseContentEditor.tsx b/packages/web/src/components/editor/PhaseContentEditor.tsx new file mode 100644 index 0000000..71eb328 --- /dev/null +++ b/packages/web/src/components/editor/PhaseContentEditor.tsx @@ -0,0 +1,47 @@ +import { useCallback } from "react"; +import { trpc } from "@/lib/trpc"; +import { usePhaseAutoSave } from "@/hooks/usePhaseAutoSave"; +import { TiptapEditor } from "./TiptapEditor"; +import { Skeleton } from "@/components/Skeleton"; + +interface PhaseContentEditorProps { + phaseId: string; + initiativeId?: string; +} + +export function PhaseContentEditor({ phaseId }: PhaseContentEditorProps) { + const { save, isSaving } = usePhaseAutoSave(); + + const phaseQuery = trpc.getPhase.useQuery({ id: phaseId }); + + const handleUpdate = useCallback( + (json: string) => { + save(phaseId, { content: json }); + }, + [phaseId, save], + ); + + if (phaseQuery.isLoading) { + return ; + } + + if (phaseQuery.isError) { + return null; + } + + return ( +
+ {isSaving && ( +
+ Saving... +
+ )} + +
+ ); +} diff --git a/packages/web/src/components/editor/RefineAgentPanel.tsx b/packages/web/src/components/editor/RefineAgentPanel.tsx index cd7e376..695793a 100644 --- a/packages/web/src/components/editor/RefineAgentPanel.tsx +++ b/packages/web/src/components/editor/RefineAgentPanel.tsx @@ -12,7 +12,7 @@ interface RefineAgentPanelProps { export function RefineAgentPanel({ initiativeId }: RefineAgentPanelProps) { // All agent logic is now encapsulated in the hook - const { state, agent, questions, proposals, spawn, resume, dismiss, refresh } = useRefineAgent(initiativeId); + const { state, agent, questions, proposals, spawn, resume, stop, dismiss, refresh } = useRefineAgent(initiativeId); // spawn.mutate and resume.mutate are stable (ref-backed in useRefineAgent), // so these callbacks won't change on every render. @@ -87,7 +87,9 @@ export function RefineAgentPanel({ initiativeId }: RefineAgentPanelProps) { onCancel={() => { // Can't cancel mid-question — just dismiss }} + onDismiss={() => stop.mutate()} isSubmitting={resume.isPending} + isDismissing={stop.isPending} />
); diff --git a/packages/web/src/components/editor/SlashCommands.ts b/packages/web/src/components/editor/SlashCommands.ts index 485be1f..affd092 100644 --- a/packages/web/src/components/editor/SlashCommands.ts +++ b/packages/web/src/components/editor/SlashCommands.ts @@ -14,6 +14,7 @@ export const SlashCommands = Extension.create({ addStorage() { return { onSubpageCreate: null as ((editor: unknown) => void) | null, + hideSubpage: false, }; }, @@ -36,10 +37,14 @@ export const SlashCommands = Extension.create({ // Execute the selected command props.action(editor); }, - items: ({ query }: { query: string }): SlashCommandItem[] => { - return slashCommandItems.filter((item) => + items: ({ query, editor }: { query: string; editor: ReturnType }): SlashCommandItem[] => { + let items = slashCommandItems.filter((item) => item.label.toLowerCase().includes(query.toLowerCase()), ); + if (editor.storage.slashCommands?.hideSubpage) { + items = items.filter((item) => !item.isSubpage); + } + return items; }, render: () => { let component: ReactRenderer | null = null; diff --git a/packages/web/src/components/editor/TiptapEditor.tsx b/packages/web/src/components/editor/TiptapEditor.tsx index 91b6144..1d1f310 100644 --- a/packages/web/src/components/editor/TiptapEditor.tsx +++ b/packages/web/src/components/editor/TiptapEditor.tsx @@ -1,25 +1,21 @@ -import { useState, useEffect, useRef, useCallback } from "react"; -import { useEditor, EditorContent, Extension } from "@tiptap/react"; +import { useEffect, useRef, useCallback } from "react"; +import { useEditor, EditorContent } 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"; +import { BlockSelectionExtension } from "./BlockSelectionExtension"; +import { createPageLinkDeletionDetector } from "./PageLinkDeletionDetector"; +import { BlockDragHandle } from "./BlockDragHandle"; interface TiptapEditorProps { content: string | null; onUpdate: (json: string) => void; - pageId: string; + entityId: string; + enablePageLinks?: boolean; onPageLinkClick?: (pageId: string) => void; onSubpageCreate?: ( editor: Editor, @@ -30,7 +26,8 @@ interface TiptapEditorProps { export function TiptapEditor({ content, onUpdate, - pageId, + entityId, + enablePageLinks = true, onPageLinkClick, onSubpageCreate, onPageLinkDeleted, @@ -38,89 +35,38 @@ export function TiptapEditor({ const containerRef = useRef(null); const onPageLinkDeletedRef = useRef(onPageLinkDeleted); onPageLinkDeletedRef.current = onPageLinkDeleted; - const blockIndexRef = useRef(null); - const savedBlockSelRef = useRef<{ anchorIndex: number; headIndex: number } | null>(null); + + const pageLinkDeletionDetector = createPageLinkDeletionDetector(onPageLinkDeletedRef); + + const baseExtensions = [ + StarterKit, + Table.configure({ resizable: true, cellMinWidth: 50 }), + TableRow, + TableCell, + TableHeader, + Placeholder.configure({ + includeChildren: true, + placeholder: ({ node }) => { + if (node.type.name === 'heading') { + return `Heading ${node.attrs.level}`; + } + return "Type '/' for commands..."; + }, + }), + Link.configure({ + openOnClick: false, + }), + SlashCommands, + BlockSelectionExtension, + ]; + + const extensions = enablePageLinks + ? [...baseExtensions, PageLinkExtension, pageLinkDeletionDetector] + : baseExtensions; const editor = useEditor( { - extensions: [ - 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(); - oldState.doc.descendants((node) => { - if (node.type.name === "pageLink" && node.attrs.pageId) { - oldLinks.add(node.attrs.pageId); - } - }); - - const newLinks = new Set(); - 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; - }, - }), - ]; - }, - }), - ], + extensions, content: content ? JSON.parse(content) : undefined, onUpdate: ({ editor: e }) => { onUpdate(JSON.stringify(e.getJSON())); @@ -132,17 +78,20 @@ export function TiptapEditor({ }, }, }, - [pageId], + [entityId], ); // Wire the onSubpageCreate callback into editor storage useEffect(() => { - if (editor && onSubpageCreate) { - editor.storage.slashCommands.onSubpageCreate = (ed: Editor) => { - onSubpageCreate(ed); - }; + if (editor) { + if (onSubpageCreate) { + editor.storage.slashCommands.onSubpageCreate = (ed: Editor) => { + onSubpageCreate(ed); + }; + } + editor.storage.slashCommands.hideSubpage = !enablePageLinks; } - }, [editor, onSubpageCreate]); + }, [editor, onSubpageCreate, enablePageLinks]); // Handle page link clicks via custom event const handlePageLinkClick = useCallback( @@ -163,199 +112,11 @@ export function TiptapEditor({ 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(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 ( -
- {handlePos && ( -
-
e.preventDefault()} - className="flex items-center justify-center w-5 h-6 cursor-pointer rounded hover:bg-muted" - > - -
-
{ - 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" - > - -
-
- )} - +
+ + +
); } diff --git a/packages/web/src/components/editor/slash-command-items.ts b/packages/web/src/components/editor/slash-command-items.ts index bb48a67..a51029f 100644 --- a/packages/web/src/components/editor/slash-command-items.ts +++ b/packages/web/src/components/editor/slash-command-items.ts @@ -43,6 +43,12 @@ export const slashCommandItems: SlashCommandItem[] = [ description: "Ordered list", action: (editor) => editor.chain().focus().toggleOrderedList().run(), }, + { + label: "Inline Code", + icon: "`c`", + description: "Inline code (Cmd+E)", + action: (editor) => editor.chain().focus().toggleCode().run(), + }, { label: "Code Block", icon: "<>", diff --git a/packages/web/src/components/execution/BreakdownSection.tsx b/packages/web/src/components/execution/BreakdownSection.tsx index 533496d..3950992 100644 --- a/packages/web/src/components/execution/BreakdownSection.tsx +++ b/packages/web/src/components/execution/BreakdownSection.tsx @@ -1,22 +1,23 @@ import { useCallback, useMemo } from "react"; -import { Loader2, Sparkles } from "lucide-react"; +import { Loader2, Plus, Sparkles } from "lucide-react"; import { Button } from "@/components/ui/button"; import { trpc } from "@/lib/trpc"; import { useSpawnMutation } from "@/hooks/useSpawnMutation"; +import { ContentProposalReview } from "@/components/editor/ContentProposalReview"; interface BreakdownSectionProps { initiativeId: string; phasesLoaded: boolean; phases: Array<{ status: string }>; + onAddPhase?: () => void; } export function BreakdownSection({ initiativeId, phasesLoaded, phases, + onAddPhase, }: BreakdownSectionProps) { - const utils = trpc.useUtils(); - // Breakdown agent tracking const agentsQuery = trpc.listAgents.useQuery(); const allAgents = agentsQuery.data ?? []; @@ -25,7 +26,7 @@ export function BreakdownSection({ .filter( (a) => a.mode === "breakdown" && - a.taskId === initiativeId && + a.initiativeId === initiativeId && ["running", "waiting_for_input", "idle"].includes(a.status), ) .sort( @@ -37,27 +38,55 @@ export function BreakdownSection({ const isBreakdownRunning = breakdownAgent?.status === "running"; + // Query proposals when we have a completed breakdown agent + const proposalsQuery = trpc.listProposals.useQuery( + { agentId: breakdownAgent?.id ?? "" }, + { enabled: !!breakdownAgent && breakdownAgent.status === "idle" }, + ); + const pendingProposals = useMemo( + () => (proposalsQuery.data ?? []).filter((p) => p.status === "pending"), + [proposalsQuery.data], + ); + + const dismissMutation = trpc.dismissAgent.useMutation(); + const breakdownSpawn = useSpawnMutation(trpc.spawnArchitectBreakdown.useMutation, { - onSuccess: () => { - void utils.listAgents.invalidate(); - }, - showToast: false, // We show our own error UI + showToast: false, }); const handleBreakdown = useCallback(() => { breakdownSpawn.spawn({ initiativeId }); }, [initiativeId, breakdownSpawn]); - // Don't render if we have phases - if (phasesLoaded && phases.length > 0) { - return null; - } + const handleDismiss = useCallback(() => { + if (!breakdownAgent) return; + dismissMutation.mutate({ id: breakdownAgent.id }); + }, [breakdownAgent, dismissMutation]); // Don't render during loading if (!phasesLoaded) { return null; } + // If phases exist and no pending proposals to review, hide section + if (phases.length > 0 && pendingProposals.length === 0) { + return null; + } + + // Show proposal review when breakdown agent completed with pending proposals + if (breakdownAgent?.status === "idle" && pendingProposals.length > 0) { + return ( +
+ +
+ ); + } + return (

No phases yet

@@ -67,18 +96,34 @@ export function BreakdownSection({ Breaking down initiative...
) : ( - +
+ + {onAddPhase && ( + <> + or + + + )} +
)} {breakdownSpawn.isError && (

@@ -87,4 +132,4 @@ export function BreakdownSection({ )}

); -} \ No newline at end of file +} diff --git a/packages/web/src/components/execution/ExecutionContext.tsx b/packages/web/src/components/execution/ExecutionContext.tsx index 100c69e..1c7e291 100644 --- a/packages/web/src/components/execution/ExecutionContext.tsx +++ b/packages/web/src/components/execution/ExecutionContext.tsx @@ -21,9 +21,8 @@ export interface FlatTaskEntry { export interface PhaseData { id: string; initiativeId: string; - number: number; name: string; - description: string | null; + content: string | null; status: string; createdAt: string | Date; updatedAt: string | Date; diff --git a/packages/web/src/components/execution/PhaseActions.tsx b/packages/web/src/components/execution/PhaseActions.tsx index 17a39c2..bbb98ff 100644 --- a/packages/web/src/components/execution/PhaseActions.tsx +++ b/packages/web/src/components/execution/PhaseActions.tsx @@ -1,60 +1,73 @@ import { useCallback, useMemo } from "react"; -import { Loader2 } from "lucide-react"; +import { Loader2, Plus, Sparkles } from "lucide-react"; import { Button } from "@/components/ui/button"; import { trpc } from "@/lib/trpc"; interface PhaseActionsProps { initiativeId: string; phases: Array<{ id: string; status: string }>; + onAddPhase: () => void; + phasesWithoutTasks: string[]; + decomposeAgentByPhase: Map; } -export function PhaseActions({ initiativeId, phases }: PhaseActionsProps) { - const queuePhaseMutation = trpc.queuePhase.useMutation(); +export function PhaseActions({ + onAddPhase, + phasesWithoutTasks, + decomposeAgentByPhase, +}: PhaseActionsProps) { + const decomposeMutation = trpc.spawnArchitectDecompose.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]); + // Phases eligible for breakdown: no tasks AND no active decompose agent + const eligiblePhaseIds = useMemo( + () => phasesWithoutTasks.filter((id) => !decomposeAgentByPhase.has(id)), + [phasesWithoutTasks, decomposeAgentByPhase], + ); - 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 }); + // Count of phases currently being decomposed + const activeDecomposeCount = useMemo(() => { + let count = 0; + for (const [, agent] of decomposeAgentByPhase) { + if (agent.status === "running" || agent.status === "waiting_for_input") { + count++; + } } - }, [phases, queuePhaseMutation]); + return count; + }, [decomposeAgentByPhase]); + + const handleBreakdownAll = useCallback(() => { + for (const phaseId of eligiblePhaseIds) { + decomposeMutation.mutate({ phaseId }); + } + }, [eligiblePhaseIds, decomposeMutation]); return (
- {isBreakdownRunning && ( + {activeDecomposeCount > 0 && (
- Breaking down... + Decomposing ({activeDecomposeCount})
)} +
); -} \ No newline at end of file +} diff --git a/packages/web/src/components/execution/PhaseDetailPanel.tsx b/packages/web/src/components/execution/PhaseDetailPanel.tsx new file mode 100644 index 0000000..3ca135d --- /dev/null +++ b/packages/web/src/components/execution/PhaseDetailPanel.tsx @@ -0,0 +1,369 @@ +import { useEffect, useState, useRef, useMemo, useCallback } from "react"; +import { Loader2, MoreHorizontal, Plus, Sparkles, Trash2, X } from "lucide-react"; +import { toast } from "sonner"; +import { trpc } from "@/lib/trpc"; +import { StatusBadge } from "@/components/StatusBadge"; +import { TaskRow, type SerializedTask } from "@/components/TaskRow"; +import { PhaseContentEditor } from "@/components/editor/PhaseContentEditor"; +import { ContentProposalReview } from "@/components/editor/ContentProposalReview"; +import { Skeleton } from "@/components/Skeleton"; +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { sortByPriorityAndQueueTime } from "@codewalk-district/shared"; +import { useExecutionContext, type FlatTaskEntry } from "./ExecutionContext"; + +interface PhaseDetailPanelProps { + phase: { + id: string; + initiativeId: string; + name: string; + content: string | null; + status: string; + }; + phases: Array<{ + id: string; + name: string; + status: string; + }>; + displayIndex: number; + allDisplayIndices: Map; + initiativeId: string; + tasks: SerializedTask[]; + tasksLoading: boolean; + onDelete?: () => void; + decomposeAgent: { + id: string; + status: string; + createdAt: string | Date; + } | null; +} + +export function PhaseDetailPanel({ + phase, + phases, + displayIndex, + allDisplayIndices, + initiativeId, + tasks, + tasksLoading, + onDelete, + decomposeAgent, +}: PhaseDetailPanelProps) { + const { setSelectedTaskId, handleTaskCounts, handleRegisterTasks } = + useExecutionContext(); + const [isEditingTitle, setIsEditingTitle] = useState(false); + const [editName, setEditName] = useState(phase.name); + const inputRef = useRef(null); + + const updatePhase = trpc.updatePhase.useMutation(); + + function startEditing() { + setEditName(phase.name); + setIsEditingTitle(true); + setTimeout(() => inputRef.current?.select(), 0); + } + + function saveTitle() { + const trimmed = editName.trim(); + if (!trimmed || trimmed === phase.name) { + setEditName(phase.name); + setIsEditingTitle(false); + return; + } + updatePhase.mutate( + { id: phase.id, name: trimmed }, + { + onSuccess: () => { + setIsEditingTitle(false); + toast.success("Phase renamed"); + }, + onError: () => { + setEditName(phase.name); + setIsEditingTitle(false); + toast.error("Failed to rename phase"); + }, + }, + ); + } + + function cancelEditing() { + setEditName(phase.name); + setIsEditingTitle(false); + } + + const addDependency = trpc.createPhaseDependency.useMutation({ + onSuccess: () => toast.success("Dependency added"), + onError: () => toast.error("Failed to add dependency"), + }); + + const removeDependency = trpc.removePhaseDependency.useMutation({ + onSuccess: () => toast.success("Dependency removed"), + onError: () => toast.error("Failed to remove dependency"), + }); + + const depsQuery = trpc.getPhaseDependencies.useQuery({ phaseId: phase.id }); + const dependencyIds = depsQuery.data?.dependencies ?? []; + + // Resolve dependency IDs to phase objects + const resolvedDeps = dependencyIds + .map((depId) => phases.find((p) => p.id === depId)) + .filter(Boolean) as Array<{ id: string; name: string; status: string }>; + + // Phases available to add as dependencies (exclude self + already-added) + const availableDeps = useMemo( + () => phases.filter((p) => p.id !== phase.id && !dependencyIds.includes(p.id)), + [phases, phase.id, dependencyIds], + ); + + // Propagate task counts and entries to ExecutionContext + useEffect(() => { + const complete = tasks.filter((t) => t.status === "completed").length; + handleTaskCounts(phase.id, { complete, total: tasks.length }); + + const entries: FlatTaskEntry[] = tasks.map((task) => ({ + task, + phaseName: `Phase ${displayIndex}: ${phase.name}`, + agentName: null, + blockedBy: [], + dependents: [], + })); + handleRegisterTasks(phase.id, entries); + }, [tasks, phase.id, displayIndex, phase.name, handleTaskCounts, handleRegisterTasks]); + + // --- Proposals for decompose agent --- + const proposalsQuery = trpc.listProposals.useQuery( + { agentId: decomposeAgent?.id ?? "" }, + { enabled: !!decomposeAgent && decomposeAgent.status === "idle" }, + ); + const pendingProposals = useMemo( + () => (proposalsQuery.data ?? []).filter((p) => p.status === "pending"), + [proposalsQuery.data], + ); + + // --- Decompose spawn --- + const decomposeMutation = trpc.spawnArchitectDecompose.useMutation(); + + const handleDecompose = useCallback(() => { + decomposeMutation.mutate({ phaseId: phase.id }); + }, [phase.id, decomposeMutation]); + + // --- Dismiss handler for proposal review --- + const dismissMutation = trpc.dismissAgent.useMutation(); + const handleDismissDecompose = useCallback(() => { + if (!decomposeAgent) return; + dismissMutation.mutate({ id: decomposeAgent.id }); + }, [decomposeAgent, dismissMutation]); + + const sortedTasks = sortByPriorityAndQueueTime(tasks); + const hasTasks = tasks.length > 0; + const isDecomposeRunning = + decomposeAgent?.status === "running" || + decomposeAgent?.status === "waiting_for_input"; + const showBreakdownButton = + !decomposeAgent && !hasTasks; + const showProposals = + decomposeAgent?.status === "idle" && pendingProposals.length > 0; + + return ( +
+ {/* Header */} +
+ {isEditingTitle ? ( +
+ Phase {displayIndex}: + setEditName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") saveTitle(); + if (e.key === "Escape") cancelEditing(); + }} + onBlur={saveTitle} + /> +
+ ) : ( +

+ Phase {displayIndex}: {phase.name} +

+ )} + + + {/* Breakdown button in header */} + {showBreakdownButton && ( + + )} + + {/* Running indicator in header */} + {isDecomposeRunning && ( +
+ + Breaking down... +
+ )} + + + + + + + { + if (window.confirm(`Delete "${phase.name}"? All tasks in this phase will also be deleted.`)) { + onDelete?.(); + } + }} + > + + Delete Phase + + + +
+ + {/* Tiptap Editor */} + + + {/* Dependencies */} +
+
+

+ Dependencies +

+ {availableDeps.length > 0 && ( + + + + + + {availableDeps.map((p) => ( + + addDependency.mutate({ + phaseId: phase.id, + dependsOnPhaseId: p.id, + }) + } + > + Phase {allDisplayIndices.get(p.id) ?? "?"}: {p.name} + + ))} + + + )} +
+ {resolvedDeps.length === 0 ? ( +

No dependencies

+ ) : ( +
+ {resolvedDeps.map((dep) => ( +
+ + {dep.status === "completed" ? "\u25CF" : "\u25CB"} + + + Phase {allDisplayIndices.get(dep.id) ?? "?"}: {dep.name} + + + +
+ ))} +
+ )} +
+ + {/* Decompose proposals */} + {showProposals && ( + + )} + + {/* Tasks */} +
+

+ Tasks ({tasks.filter((t) => t.status === "completed").length}/ + {tasks.length}) +

+ {tasksLoading ? ( +
+ {Array.from({ length: 3 }).map((_, i) => ( + + ))} +
+ ) : sortedTasks.length === 0 ? ( +

No tasks yet

+ ) : ( +
+ {sortedTasks.map((task, idx) => ( + setSelectedTaskId(task.id)} + /> + ))} +
+ )} +
+
+ ); +} + +export function PhaseDetailEmpty() { + return ( +
+

Select a phase to view details

+
+ ); +} diff --git a/packages/web/src/components/execution/PhaseSidebarItem.tsx b/packages/web/src/components/execution/PhaseSidebarItem.tsx new file mode 100644 index 0000000..6f4ea1d --- /dev/null +++ b/packages/web/src/components/execution/PhaseSidebarItem.tsx @@ -0,0 +1,57 @@ +import { StatusBadge } from "@/components/StatusBadge"; +import { cn } from "@/lib/utils"; + +interface PhaseSidebarItemProps { + phase: { + id: string; + name: string; + status: string; + }; + displayIndex: number; + taskCount: { complete: number; total: number }; + dependencies: string[]; + isSelected: boolean; + onClick: () => void; +} + +export function PhaseSidebarItem({ + phase, + displayIndex, + taskCount, + dependencies, + isSelected, + onClick, +}: PhaseSidebarItemProps) { + return ( + + ); +} diff --git a/packages/web/src/components/execution/PhaseWithTasks.tsx b/packages/web/src/components/execution/PhaseWithTasks.tsx index 7d5671a..4adaea3 100644 --- a/packages/web/src/components/execution/PhaseWithTasks.tsx +++ b/packages/web/src/components/execution/PhaseWithTasks.tsx @@ -9,9 +9,8 @@ interface PhaseWithTasksProps { phase: { id: string; initiativeId: string; - number: number; name: string; - description: string | null; + content: string | null; status: string; createdAt: string; updatedAt: string; @@ -78,13 +77,13 @@ function PhaseWithTasksInner({ const entries: FlatTaskEntry[] = tasks.map((task) => ({ task, - phaseName: `Phase ${phase.number}: ${phase.name}`, + phaseName: phase.name, agentName: null, blockedBy: [], dependents: [], })); registerTasks(phase.id, entries); - }, [tasks, phase.id, phase.number, phase.name, onTaskCounts, registerTasks]); + }, [tasks, phase.id, phase.name, onTaskCounts, registerTasks]); const sortedTasks = sortByPriorityAndQueueTime(tasks); const taskEntries = sortedTasks.map((task) => ({ @@ -108,4 +107,4 @@ function PhaseWithTasksInner({ onTaskClick={onTaskClick} /> ); -} \ No newline at end of file +} diff --git a/packages/web/src/components/execution/PhasesList.tsx b/packages/web/src/components/execution/PhasesList.tsx index 934ea25..5076e0c 100644 --- a/packages/web/src/components/execution/PhasesList.tsx +++ b/packages/web/src/components/execution/PhasesList.tsx @@ -50,9 +50,8 @@ export function PhasesList({ const serializedPhase = { id: phase.id, initiativeId: phase.initiativeId, - number: phase.number, name: phase.name, - description: phase.description, + content: phase.content, status: phase.status, createdAt: String(phase.createdAt), updatedAt: String(phase.updatedAt), diff --git a/packages/web/src/components/execution/index.ts b/packages/web/src/components/execution/index.ts index b3a53da..bbc4d07 100644 --- a/packages/web/src/components/execution/index.ts +++ b/packages/web/src/components/execution/index.ts @@ -1,8 +1,7 @@ export { ExecutionProvider, useExecutionContext } from "./ExecutionContext"; export { BreakdownSection } from "./BreakdownSection"; export { PhaseActions } from "./PhaseActions"; -export { PhasesList } from "./PhasesList"; -export { PhaseWithTasks } from "./PhaseWithTasks"; -export { ProgressSidebar } from "./ProgressSidebar"; +export { PhaseSidebarItem } from "./PhaseSidebarItem"; +export { PhaseDetailPanel, PhaseDetailEmpty } from "./PhaseDetailPanel"; export { TaskModal } from "./TaskModal"; -export type { TaskCounts, FlatTaskEntry, PhaseData } from "./ExecutionContext"; \ No newline at end of file +export type { TaskCounts, FlatTaskEntry, PhaseData } from "./ExecutionContext"; diff --git a/packages/web/src/components/pipeline/PipelineGraph.tsx b/packages/web/src/components/pipeline/PipelineGraph.tsx new file mode 100644 index 0000000..0a0e340 --- /dev/null +++ b/packages/web/src/components/pipeline/PipelineGraph.tsx @@ -0,0 +1,37 @@ +import type { PipelineColumn } from "@codewalk-district/shared"; +import { PipelineStageColumn } from "./PipelineStageColumn"; +import type { SerializedTask } from "@/components/TaskRow"; + +interface PipelineGraphProps { + columns: PipelineColumn<{ + id: string; + name: string; + status: string; + createdAt: string | Date; + }>[]; + tasksByPhase: Record; +} + +export function PipelineGraph({ columns, tasksByPhase }: PipelineGraphProps) { + return ( +
+
+ {columns.map((column, idx) => ( +
+ {/* Connector arrow between columns */} + {idx > 0 && ( +
+
+
+
+ )} + +
+ ))} +
+
+ ); +} diff --git a/packages/web/src/components/pipeline/PipelinePhaseGroup.tsx b/packages/web/src/components/pipeline/PipelinePhaseGroup.tsx new file mode 100644 index 0000000..57be680 --- /dev/null +++ b/packages/web/src/components/pipeline/PipelinePhaseGroup.tsx @@ -0,0 +1,52 @@ +import { Play } from "lucide-react"; +import { StatusDot } from "@/components/StatusDot"; +import { trpc } from "@/lib/trpc"; +import { sortByPriorityAndQueueTime } from "@codewalk-district/shared"; +import { PipelineTaskCard } from "./PipelineTaskCard"; +import type { SerializedTask } from "@/components/TaskRow"; + +interface PipelinePhaseGroupProps { + phase: { + id: string; + name: string; + status: string; + }; + tasks: SerializedTask[]; +} + +export function PipelinePhaseGroup({ phase, tasks }: PipelinePhaseGroupProps) { + const queuePhase = trpc.queuePhase.useMutation(); + const sorted = sortByPriorityAndQueueTime(tasks); + + return ( +
+ {/* Header */} +
+ + + {phase.name} + + {phase.status === "pending" && ( + + )} +
+ + {/* Tasks */} +
+ {sorted.length === 0 ? ( +

No tasks

+ ) : ( + sorted.map((task) => ( + + )) + )} +
+
+ ); +} diff --git a/packages/web/src/components/pipeline/PipelineStageColumn.tsx b/packages/web/src/components/pipeline/PipelineStageColumn.tsx new file mode 100644 index 0000000..0d36242 --- /dev/null +++ b/packages/web/src/components/pipeline/PipelineStageColumn.tsx @@ -0,0 +1,25 @@ +import { PipelinePhaseGroup } from "./PipelinePhaseGroup"; +import type { SerializedTask } from "@/components/TaskRow"; + +interface PipelineStageColumnProps { + phases: Array<{ + id: string; + name: string; + status: string; + }>; + tasksByPhase: Record; +} + +export function PipelineStageColumn({ phases, tasksByPhase }: PipelineStageColumnProps) { + return ( +
+ {phases.map((phase) => ( + + ))} +
+ ); +} diff --git a/packages/web/src/components/pipeline/PipelineTab.tsx b/packages/web/src/components/pipeline/PipelineTab.tsx new file mode 100644 index 0000000..319306b --- /dev/null +++ b/packages/web/src/components/pipeline/PipelineTab.tsx @@ -0,0 +1,114 @@ +import { useEffect, useMemo } from "react"; +import { Loader2 } from "lucide-react"; +import { trpc } from "@/lib/trpc"; +import { + groupPhasesByDependencyLevel, + type DependencyEdge, +} from "@codewalk-district/shared"; +import { + ExecutionProvider, + useExecutionContext, + TaskModal, + BreakdownSection, + type PhaseData, + type FlatTaskEntry, +} from "@/components/execution"; +import type { SerializedTask } from "@/components/TaskRow"; +import { PipelineGraph } from "./PipelineGraph"; + +interface PipelineTabProps { + initiativeId: string; + phases: PhaseData[]; + phasesLoading: boolean; +} + +export function PipelineTab({ initiativeId, phases, phasesLoading }: PipelineTabProps) { + return ( + + + + + ); +} + +function PipelineTabInner({ initiativeId, phases, phasesLoading }: PipelineTabProps) { + const { handleRegisterTasks, handleTaskCounts } = useExecutionContext(); + + // Fetch all tasks for the initiative + const tasksQuery = trpc.listInitiativeTasks.useQuery( + { initiativeId }, + { enabled: phases.length > 0 }, + ); + const allTasks = (tasksQuery.data ?? []) as SerializedTask[]; + + // Fetch dependency edges + const depsQuery = trpc.listInitiativePhaseDependencies.useQuery( + { initiativeId }, + { enabled: phases.length > 0 }, + ); + const dependencyEdges: DependencyEdge[] = depsQuery.data ?? []; + + // Group tasks by phaseId + const tasksByPhase = useMemo(() => { + const map: Record = {}; + for (const task of allTasks) { + if (task.phaseId) { + if (!map[task.phaseId]) map[task.phaseId] = []; + map[task.phaseId].push(task); + } + } + return map; + }, [allTasks]); + + // Compute pipeline columns + const columns = useMemo( + () => groupPhasesByDependencyLevel(phases, dependencyEdges), + [phases, dependencyEdges], + ); + + // Register tasks with ExecutionContext for TaskModal + useEffect(() => { + for (const phase of phases) { + const phaseTasks = tasksByPhase[phase.id] ?? []; + const entries: FlatTaskEntry[] = phaseTasks.map((task) => ({ + task, + phaseName: phase.name, + agentName: null, + blockedBy: [], + dependents: [], + })); + handleRegisterTasks(phase.id, entries); + handleTaskCounts(phase.id, { + complete: phaseTasks.filter((t) => t.status === "completed").length, + total: phaseTasks.length, + }); + } + }, [phases, tasksByPhase, handleRegisterTasks, handleTaskCounts]); + + // Empty state + if (!phasesLoading && phases.length === 0) { + return ( + + ); + } + + // Loading + if (phasesLoading || tasksQuery.isLoading) { + return ( +
+ + Loading pipeline... +
+ ); + } + + return ; +} diff --git a/packages/web/src/components/pipeline/PipelineTaskCard.tsx b/packages/web/src/components/pipeline/PipelineTaskCard.tsx new file mode 100644 index 0000000..f4ba25e --- /dev/null +++ b/packages/web/src/components/pipeline/PipelineTaskCard.tsx @@ -0,0 +1,49 @@ +import { CheckCircle2, Loader2, Clock, Ban, Play, AlertTriangle } from "lucide-react"; +import { cn } from "@/lib/utils"; +import { trpc } from "@/lib/trpc"; +import { useExecutionContext } from "@/components/execution"; +import type { SerializedTask } from "@/components/TaskRow"; + +const statusConfig: Record = { + pending: { icon: Clock, color: "text-muted-foreground" }, + pending_approval: { icon: AlertTriangle, color: "text-yellow-500" }, + in_progress: { icon: Loader2, color: "text-blue-500", spin: true }, + completed: { icon: CheckCircle2, color: "text-green-500" }, + blocked: { icon: Ban, color: "text-red-500" }, +}; + +interface PipelineTaskCardProps { + task: SerializedTask; +} + +export function PipelineTaskCard({ task }: PipelineTaskCardProps) { + const { setSelectedTaskId } = useExecutionContext(); + const queueTask = trpc.queueTask.useMutation(); + + const config = statusConfig[task.status] ?? statusConfig.pending; + const Icon = config.icon; + + return ( +
setSelectedTaskId(task.id)} + > + + {task.name} + {task.status === "pending" && ( + + )} +
+ ); +} diff --git a/packages/web/src/components/pipeline/index.ts b/packages/web/src/components/pipeline/index.ts new file mode 100644 index 0000000..7e4cff3 --- /dev/null +++ b/packages/web/src/components/pipeline/index.ts @@ -0,0 +1 @@ +export { PipelineTab } from "./PipelineTab"; diff --git a/packages/web/src/components/review/CommentForm.tsx b/packages/web/src/components/review/CommentForm.tsx new file mode 100644 index 0000000..79e942a --- /dev/null +++ b/packages/web/src/components/review/CommentForm.tsx @@ -0,0 +1,71 @@ +import { forwardRef, useState, useCallback } from "react"; +import { Button } from "@/components/ui/button"; +import { Textarea } from "@/components/ui/textarea"; + +interface CommentFormProps { + onSubmit: (body: string) => void; + onCancel: () => void; + placeholder?: string; + submitLabel?: string; +} + +export const CommentForm = forwardRef( + function CommentForm( + { onSubmit, onCancel, placeholder = "Write a comment...", submitLabel = "Comment" }, + ref + ) { + const [body, setBody] = useState(""); + + const handleSubmit = useCallback(() => { + const trimmed = body.trim(); + if (!trimmed) return; + onSubmit(trimmed); + setBody(""); + }, [body, onSubmit]); + + const handleKeyDown = useCallback( + (e: React.KeyboardEvent) => { + if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) { + e.preventDefault(); + handleSubmit(); + } + if (e.key === "Escape") { + onCancel(); + } + }, + [handleSubmit, onCancel] + ); + + return ( +
+