From da4152264cb904e44eb71d17a36b26d4fc1f714f Mon Sep 17 00:00:00 2001 From: Lukas May Date: Mon, 9 Feb 2026 22:33:40 +0100 Subject: [PATCH] feat(web): Pipeline visualization, phase content editing, and review tab Pipeline view groups phases by dependency depth with DAG visualization. Phase detail panel with Tiptap rich content editor and auto-save. Code review tab with diff viewer and comment threads (dummy data). Centralized live updates hook replaces scattered subscription boilerplate. Extract agent output parsing into shared utility. Inbox detail panel, account cards, and agent action components. --- packages/web/src/components/AccountCard.tsx | 199 ++++++++++ packages/web/src/components/ActionMenu.tsx | 3 - packages/web/src/components/AgentActions.tsx | 73 ++++ .../web/src/components/AgentOutputViewer.tsx | 159 +------- .../src/components/CreateInitiativeDialog.tsx | 22 +- packages/web/src/components/ExecutionTab.tsx | 303 +++++++++++++- .../web/src/components/InboxDetailPanel.tsx | 171 ++++++++ packages/web/src/components/InboxList.tsx | 13 + .../web/src/components/InitiativeHeader.tsx | 2 - .../web/src/components/PhaseAccordion.tsx | 11 +- packages/web/src/components/QuestionForm.tsx | 17 +- .../src/components/RegisterProjectDialog.tsx | 3 - packages/web/src/components/StatusBadge.tsx | 1 + .../src/components/editor/BlockDragHandle.tsx | 238 +++++++++++ .../editor/ContentProposalReview.tsx | 82 ++-- .../web/src/components/editor/ContentTab.tsx | 74 +--- .../components/editor/DeleteSubpageDialog.tsx | 50 +++ .../editor/PageLinkDeletionDetector.ts | 66 ++++ .../components/editor/PhaseContentEditor.tsx | 47 +++ .../components/editor/RefineAgentPanel.tsx | 4 +- .../src/components/editor/SlashCommands.ts | 9 +- .../src/components/editor/TiptapEditor.tsx | 341 +++------------- .../components/editor/slash-command-items.ts | 6 + .../components/execution/BreakdownSection.tsx | 95 +++-- .../components/execution/ExecutionContext.tsx | 3 +- .../src/components/execution/PhaseActions.tsx | 81 ++-- .../components/execution/PhaseDetailPanel.tsx | 369 ++++++++++++++++++ .../components/execution/PhaseSidebarItem.tsx | 57 +++ .../components/execution/PhaseWithTasks.tsx | 9 +- .../src/components/execution/PhasesList.tsx | 3 +- .../web/src/components/execution/index.ts | 7 +- .../src/components/pipeline/PipelineGraph.tsx | 37 ++ .../pipeline/PipelinePhaseGroup.tsx | 52 +++ .../pipeline/PipelineStageColumn.tsx | 25 ++ .../src/components/pipeline/PipelineTab.tsx | 114 ++++++ .../components/pipeline/PipelineTaskCard.tsx | 49 +++ packages/web/src/components/pipeline/index.ts | 1 + .../web/src/components/review/CommentForm.tsx | 71 ++++ .../src/components/review/CommentThread.tsx | 72 ++++ .../web/src/components/review/DiffViewer.tsx | 38 ++ .../web/src/components/review/FileCard.tsx | 86 ++++ .../web/src/components/review/HunkRows.tsx | 86 ++++ .../components/review/LineWithComments.tsx | 138 +++++++ .../src/components/review/ReviewSidebar.tsx | 213 ++++++++++ .../web/src/components/review/ReviewTab.tsx | 104 +++++ .../web/src/components/review/dummy-data.ts | 202 ++++++++++ packages/web/src/components/review/index.ts | 1 + .../web/src/components/review/parse-diff.ts | 93 +++++ packages/web/src/components/review/types.ts | 49 +++ packages/web/src/hooks/index.ts | 1 + packages/web/src/hooks/useAutoSave.ts | 87 ++++- packages/web/src/hooks/useLiveUpdates.ts | 49 +++ .../web/src/hooks/useOptimisticMutation.ts | 124 ++++++ packages/web/src/hooks/usePhaseAutoSave.ts | 149 +++++++ packages/web/src/hooks/useRefineAgent.ts | 129 ++++-- .../hooks/useSubscriptionWithErrorHandling.ts | 2 +- packages/web/src/index.css | 36 ++ packages/web/src/lib/invalidation.ts | 124 ++++++ packages/web/src/lib/parse-agent-output.ts | 168 ++++++++ packages/web/src/main.tsx | 23 +- packages/web/src/routes/agents.tsx | 277 +++++++++---- packages/web/src/routes/inbox.tsx | 215 ++++------ packages/web/src/routes/initiatives/$id.tsx | 143 +++---- packages/web/src/routes/initiatives/index.tsx | 22 +- packages/web/src/routes/settings/health.tsx | 213 +--------- packages/web/tsconfig.app.tsbuildinfo | 2 +- 66 files changed, 4487 insertions(+), 1226 deletions(-) create mode 100644 packages/web/src/components/AccountCard.tsx create mode 100644 packages/web/src/components/AgentActions.tsx create mode 100644 packages/web/src/components/InboxDetailPanel.tsx create mode 100644 packages/web/src/components/editor/BlockDragHandle.tsx create mode 100644 packages/web/src/components/editor/DeleteSubpageDialog.tsx create mode 100644 packages/web/src/components/editor/PageLinkDeletionDetector.ts create mode 100644 packages/web/src/components/editor/PhaseContentEditor.tsx create mode 100644 packages/web/src/components/execution/PhaseDetailPanel.tsx create mode 100644 packages/web/src/components/execution/PhaseSidebarItem.tsx create mode 100644 packages/web/src/components/pipeline/PipelineGraph.tsx create mode 100644 packages/web/src/components/pipeline/PipelinePhaseGroup.tsx create mode 100644 packages/web/src/components/pipeline/PipelineStageColumn.tsx create mode 100644 packages/web/src/components/pipeline/PipelineTab.tsx create mode 100644 packages/web/src/components/pipeline/PipelineTaskCard.tsx create mode 100644 packages/web/src/components/pipeline/index.ts create mode 100644 packages/web/src/components/review/CommentForm.tsx create mode 100644 packages/web/src/components/review/CommentThread.tsx create mode 100644 packages/web/src/components/review/DiffViewer.tsx create mode 100644 packages/web/src/components/review/FileCard.tsx create mode 100644 packages/web/src/components/review/HunkRows.tsx create mode 100644 packages/web/src/components/review/LineWithComments.tsx create mode 100644 packages/web/src/components/review/ReviewSidebar.tsx create mode 100644 packages/web/src/components/review/ReviewTab.tsx create mode 100644 packages/web/src/components/review/dummy-data.ts create mode 100644 packages/web/src/components/review/index.ts create mode 100644 packages/web/src/components/review/parse-diff.ts create mode 100644 packages/web/src/components/review/types.ts create mode 100644 packages/web/src/hooks/useLiveUpdates.ts create mode 100644 packages/web/src/hooks/useOptimisticMutation.ts create mode 100644 packages/web/src/hooks/usePhaseAutoSave.ts create mode 100644 packages/web/src/lib/invalidation.ts create mode 100644 packages/web/src/lib/parse-agent-output.ts 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 ( +
+