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.
This commit is contained in:
Lukas May
2026-02-09 22:33:40 +01:00
parent 47c3162581
commit da4152264c
66 changed files with 4487 additions and 1226 deletions

View File

@@ -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 (
<div className="flex items-center gap-2 text-xs">
<span className="w-20 shrink-0 text-muted-foreground">{label}</span>
<div className="h-2 flex-1 rounded-full bg-muted">
<div
className={`h-2 rounded-full ${color}`}
style={{ width: `${Math.min(utilization, 100)}%` }}
/>
</div>
<span className="w-12 shrink-0 text-right">
{utilization.toFixed(0)}%
</span>
{resetText && (
<span className="shrink-0 text-muted-foreground">
resets {resetText}
</span>
)}
</div>
);
}
export type AccountData = {
id: string;
email: string;
provider: string;
credentialsValid: boolean;
tokenValid: boolean;
tokenExpiresAt: string | null;
subscriptionType: string | null;
error: string | null;
usage: {
five_hour: { utilization: number; resets_at: string | null } | null;
seven_day: { utilization: number; resets_at: string | null } | null;
seven_day_sonnet: {
utilization: number;
resets_at: string | null;
} | null;
seven_day_opus: { utilization: number; resets_at: string | null } | null;
extra_usage: {
is_enabled: boolean;
monthly_limit: number | null;
used_credits: number | null;
utilization: number | null;
} | null;
} | null;
isExhausted: boolean;
exhaustedUntil: string | null;
lastUsedAt: string | null;
agentCount: number;
activeAgentCount: number;
};
export function AccountCard({ account }: { account: AccountData }) {
const statusIcon = !account.credentialsValid ? (
<XCircle className="h-5 w-5 shrink-0 text-destructive" />
) : account.isExhausted ? (
<AlertTriangle className="h-5 w-5 shrink-0 text-yellow-500" />
) : (
<CheckCircle2 className="h-5 w-5 shrink-0 text-green-500" />
);
const statusText = !account.credentialsValid
? "Invalid credentials"
: account.isExhausted
? `Exhausted until ${account.exhaustedUntil ? new Date(account.exhaustedUntil).toLocaleTimeString() : "unknown"}`
: "Available";
const usage = account.usage;
return (
<Card>
<CardContent className="space-y-3 py-4">
{/* Header row */}
<div className="flex items-start gap-3">
{statusIcon}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium">{account.email}</span>
<Badge variant="outline">{account.provider}</Badge>
{account.subscriptionType && (
<Badge variant="secondary">
{capitalize(account.subscriptionType)}
</Badge>
)}
</div>
<div className="mt-1 flex items-center gap-3 text-xs text-muted-foreground">
<span>
{account.agentCount} agent
{account.agentCount !== 1 ? "s" : ""} (
{account.activeAgentCount} active)
</span>
<span>{statusText}</span>
</div>
</div>
</div>
{/* Usage bars */}
{usage && (
<div className="space-y-1.5 pl-8">
{usage.five_hour && (
<UsageBar
label="Session (5h)"
utilization={usage.five_hour.utilization}
resetsAt={usage.five_hour.resets_at}
/>
)}
{usage.seven_day && (
<UsageBar
label="Weekly (7d)"
utilization={usage.seven_day.utilization}
resetsAt={usage.seven_day.resets_at}
/>
)}
{usage.seven_day_sonnet &&
usage.seven_day_sonnet.utilization > 0 && (
<UsageBar
label="Sonnet (7d)"
utilization={usage.seven_day_sonnet.utilization}
resetsAt={usage.seven_day_sonnet.resets_at}
/>
)}
{usage.seven_day_opus && usage.seven_day_opus.utilization > 0 && (
<UsageBar
label="Opus (7d)"
utilization={usage.seven_day_opus.utilization}
resetsAt={usage.seven_day_opus.resets_at}
/>
)}
{usage.extra_usage && usage.extra_usage.is_enabled && (
<div className="flex items-center gap-2 text-xs">
<span className="w-20 shrink-0 text-muted-foreground">
Extra usage
</span>
<span>
${((usage.extra_usage.used_credits ?? 0) / 100).toFixed(2)}{" "}
used
{usage.extra_usage.monthly_limit != null && (
<>
{" "}
/ $
{(usage.extra_usage.monthly_limit / 100).toFixed(2)} limit
</>
)}
</span>
</div>
)}
</div>
)}
{/* Error message */}
{account.error && (
<p className="pl-8 text-xs text-destructive">{account.error}</p>
)}
</CardContent>
</Card>
);
}

View File

@@ -16,11 +16,8 @@ interface ActionMenuProps {
} }
export function ActionMenu({ initiativeId, onDelete }: ActionMenuProps) { export function ActionMenu({ initiativeId, onDelete }: ActionMenuProps) {
const utils = trpc.useUtils();
const archiveMutation = trpc.updateInitiative.useMutation({ const archiveMutation = trpc.updateInitiative.useMutation({
onSuccess: () => { onSuccess: () => {
utils.listInitiatives.invalidate();
onDelete?.(); onDelete?.();
toast.success("Initiative archived"); toast.success("Initiative archived");
}, },

View File

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

View File

@@ -4,69 +4,17 @@ import { Badge } from "@/components/ui/badge";
import { ArrowDown, Pause, Play, AlertCircle } from "lucide-react"; import { ArrowDown, Pause, Play, AlertCircle } from "lucide-react";
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
import { useSubscriptionWithErrorHandling } from "@/hooks"; import { useSubscriptionWithErrorHandling } from "@/hooks";
import {
type ParsedMessage,
getMessageStyling,
parseAgentOutput,
} from "@/lib/parse-agent-output";
interface AgentOutputViewerProps { interface AgentOutputViewerProps {
agentId: string; agentId: string;
agentName?: 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) { export function AgentOutputViewer({ agentId, agentName }: AgentOutputViewerProps) {
const [messages, setMessages] = useState<ParsedMessage[]>([]); const [messages, setMessages] = useState<ParsedMessage[]>([]);
const [follow, setFollow] = useState(true); const [follow, setFollow] = useState(true);
@@ -101,100 +49,7 @@ export function AgentOutputViewer({ agentId, agentName }: AgentOutputViewerProps
// Set initial output when query loads // Set initial output when query loads
useEffect(() => { useEffect(() => {
if (outputQuery.data) { if (outputQuery.data) {
const lines = outputQuery.data.split("\n").filter(Boolean); setMessages(parseAgentOutput(outputQuery.data));
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);
} }
}, [outputQuery.data]); }, [outputQuery.data]);
@@ -233,7 +88,7 @@ export function AgentOutputViewer({ agentId, agentName }: AgentOutputViewerProps
const hasOutput = messages.length > 0; const hasOutput = messages.length > 0;
return ( return (
<div className="flex flex-col h-[600px] rounded-lg border overflow-hidden"> <div className="flex flex-col h-full rounded-lg border overflow-hidden">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between border-b bg-zinc-900 px-4 py-2"> <div className="flex items-center justify-between border-b bg-zinc-900 px-4 py-2">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">

View File

@@ -30,13 +30,31 @@ export function CreateInitiativeDialog({
const utils = trpc.useUtils(); const utils = trpc.useUtils();
const createMutation = trpc.createInitiative.useMutation({ 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: () => { onSuccess: () => {
utils.listInitiatives.invalidate();
onOpenChange(false); onOpenChange(false);
toast.success("Initiative created"); toast.success("Initiative created");
}, },
onError: (err) => { onError: (err, _variables, context) => {
if (context?.previousInitiatives) {
utils.listInitiatives.setData(undefined, context.previousInitiatives);
}
setError(err.message); setError(err.message);
toast.error("Failed to create initiative");
}, },
}); });

View File

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

View File

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

View File

@@ -49,6 +49,7 @@ export function InboxList({
const [sort, setSort] = useState<SortValue>("newest"); const [sort, setSort] = useState<SortValue>("newest");
// Join agents with their latest message (match message.senderId to agent.id) // 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 joined = useMemo(() => {
const latestByAgent = new Map<string, Message>(); const latestByAgent = new Map<string, Message>();
@@ -64,7 +65,19 @@ export function InboxList({
for (const agent of agents) { for (const agent of agents) {
const msg = latestByAgent.get(agent.id); const msg = latestByAgent.get(agent.id);
if (msg) { if (msg) {
// Agent has a message
entries.push({ agent, message: msg }); 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 });
} }
} }

View File

@@ -25,10 +25,8 @@ export function InitiativeHeader({
const [editing, setEditing] = useState(false); const [editing, setEditing] = useState(false);
const [editIds, setEditIds] = useState<string[]>([]); const [editIds, setEditIds] = useState<string[]>([]);
const utils = trpc.useUtils();
const updateMutation = trpc.updateInitiativeProjects.useMutation({ const updateMutation = trpc.updateInitiativeProjects.useMutation({
onSuccess: () => { onSuccess: () => {
utils.getInitiative.invalidate({ id: initiative.id });
setEditing(false); setEditing(false);
toast.success("Projects updated"); toast.success("Projects updated");
}, },

View File

@@ -3,14 +3,14 @@ import { ChevronDown, ChevronRight } from "lucide-react";
import { StatusBadge } from "@/components/StatusBadge"; import { StatusBadge } from "@/components/StatusBadge";
import { DependencyIndicator } from "@/components/DependencyIndicator"; import { DependencyIndicator } from "@/components/DependencyIndicator";
import { TaskRow, type SerializedTask } from "@/components/TaskRow"; 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) */ /** Phase shape as returned by tRPC (Date fields serialized to string over JSON) */
interface SerializedPhase { interface SerializedPhase {
id: string; id: string;
initiativeId: string; initiativeId: string;
number: number;
name: string; name: string;
description: string | null; content: string | null;
status: string; status: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@@ -59,9 +59,9 @@ export function PhaseAccordion({
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" /> <ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
)} )}
{/* Phase number + name */} {/* Phase name */}
<span className="min-w-0 flex-1 truncate font-medium"> <span className="min-w-0 flex-1 truncate font-medium">
Phase {phase.number}: {phase.name} {phase.name}
</span> </span>
{/* Task count */} {/* Task count */}
@@ -82,9 +82,10 @@ export function PhaseAccordion({
/> />
)} )}
{/* Expanded task list */} {/* Expanded content editor + task list */}
{expanded && ( {expanded && (
<div className="pb-3 pl-10 pr-4"> <div className="pb-3 pl-10 pr-4">
<PhaseContentEditor phaseId={phase.id} initiativeId={phase.initiativeId} />
{tasks.map((entry, idx) => ( {tasks.map((entry, idx) => (
<TaskRow <TaskRow
key={entry.task.id} key={entry.task.id}

View File

@@ -14,14 +14,18 @@ interface QuestionFormProps {
questions: QuestionFormQuestion[]; questions: QuestionFormQuestion[];
onSubmit: (answers: Record<string, string>) => void; onSubmit: (answers: Record<string, string>) => void;
onCancel: () => void; onCancel: () => void;
onDismiss?: () => void;
isSubmitting?: boolean; isSubmitting?: boolean;
isDismissing?: boolean;
} }
export function QuestionForm({ export function QuestionForm({
questions, questions,
onSubmit, onSubmit,
onCancel, onCancel,
onDismiss,
isSubmitting = false, isSubmitting = false,
isDismissing = false,
}: QuestionFormProps) { }: QuestionFormProps) {
const [answers, setAnswers] = useState<Record<string, string>>(() => { const [answers, setAnswers] = useState<Record<string, string>>(() => {
const initial: Record<string, string> = {}; const initial: Record<string, string> = {};
@@ -75,13 +79,22 @@ export function QuestionForm({
<Button <Button
variant="outline" variant="outline"
onClick={onCancel} onClick={onCancel}
disabled={isSubmitting} disabled={isSubmitting || isDismissing}
> >
Cancel Cancel
</Button> </Button>
{onDismiss && (
<Button
variant="destructive"
onClick={onDismiss}
disabled={isSubmitting || isDismissing}
>
{isDismissing ? "Dismissing..." : "Dismiss"}
</Button>
)}
<Button <Button
onClick={handleSubmit} onClick={handleSubmit}
disabled={!allAnswered || isSubmitting} disabled={!allAnswered || isSubmitting || isDismissing}
> >
{isSubmitting ? "Sending..." : "Send Answers"} {isSubmitting ? "Sending..." : "Send Answers"}
</Button> </Button>

View File

@@ -26,11 +26,8 @@ export function RegisterProjectDialog({
const [url, setUrl] = useState(""); const [url, setUrl] = useState("");
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const utils = trpc.useUtils();
const registerMutation = trpc.registerProject.useMutation({ const registerMutation = trpc.registerProject.useMutation({
onSuccess: () => { onSuccess: () => {
utils.listProjects.invalidate();
onOpenChange(false); onOpenChange(false);
toast.success("Project registered"); toast.success("Project registered");
}, },

View File

@@ -9,6 +9,7 @@ const statusStyles: Record<string, string> = {
// Phase statuses // Phase statuses
pending: "bg-gray-100 text-gray-800 hover:bg-gray-100/80 border-gray-200", 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", 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", blocked: "bg-red-100 text-red-800 hover:bg-red-100/80 border-red-200",
}; };

View File

@@ -0,0 +1,238 @@
import { useState, useRef, useCallback } from "react";
import type { Editor } from "@tiptap/react";
import { GripVertical, Plus } from "lucide-react";
import { NodeSelection, TextSelection } from "@tiptap/pm/state";
import { Fragment, Slice, type Node as PmNode } from "@tiptap/pm/model";
import {
blockSelectionKey,
getBlockRange,
} from "./BlockSelectionExtension";
interface BlockDragHandleProps {
editor: Editor | null;
children: React.ReactNode;
}
export function BlockDragHandle({ editor, children }: BlockDragHandleProps) {
const blockIndexRef = useRef<number | null>(null);
const savedBlockSelRef = useRef<{
anchorIndex: number;
headIndex: number;
} | null>(null);
const blockElRef = useRef<HTMLElement | null>(null);
const [handlePos, setHandlePos] = useState<{
top: number;
height: number;
} | null>(null);
// Track which block the mouse is over
const onMouseMove = useCallback(
(e: React.MouseEvent) => {
// If hovering the handle itself, keep current position
if ((e.target as HTMLElement).closest("[data-block-handle-row]")) return;
const editorEl = (e.currentTarget as HTMLElement).querySelector(
".ProseMirror",
);
if (!editorEl || !editor) return;
// Walk from event target up to a direct child of .ProseMirror
let target = e.target as HTMLElement;
while (
target &&
target !== editorEl &&
target.parentElement !== editorEl
) {
target = target.parentElement!;
}
if (
target &&
target !== editorEl &&
target.parentElement === editorEl
) {
blockElRef.current = target;
const editorRect = editorEl.getBoundingClientRect();
const blockRect = target.getBoundingClientRect();
setHandlePos({
top: blockRect.top - editorRect.top,
height: blockRect.height,
});
// Track top-level block index for block selection
try {
const pos = editor.view.posAtDOM(target, 0);
blockIndexRef.current = editor.view.state.doc
.resolve(pos)
.index(0);
} catch {
blockIndexRef.current = null;
}
}
// Don't clear -- only onMouseLeave clears
},
[editor],
);
const onMouseLeave = useCallback(() => {
setHandlePos(null);
blockElRef.current = null;
blockIndexRef.current = null;
}, []);
// Click on drag handle -> select block (Shift+click extends)
const onHandleClick = useCallback(
(e: React.MouseEvent) => {
if (!editor) return;
const idx = blockIndexRef.current;
if (idx == null) return;
// Use saved state from mousedown (PM may have cleared it due to focus change)
const existing = savedBlockSelRef.current;
let newSel;
if (e.shiftKey && existing) {
newSel = { anchorIndex: existing.anchorIndex, headIndex: idx };
} else {
newSel = { anchorIndex: idx, headIndex: idx };
}
const tr = editor.view.state.tr.setMeta(blockSelectionKey, newSel);
tr.setMeta("blockSelectionInternal", true);
editor.view.dispatch(tr);
// Refocus editor so Shift+Arrow keys reach PM's handleKeyDown
editor.view.focus();
},
[editor],
);
// Add a new empty paragraph below the hovered block
const onHandleAdd = useCallback(() => {
if (!editor || !blockElRef.current) return;
const view = editor.view;
try {
const pos = view.posAtDOM(blockElRef.current, 0);
const $pos = view.state.doc.resolve(pos);
const after = $pos.after($pos.depth);
const paragraph = view.state.schema.nodes.paragraph.create();
const tr = view.state.tr.insert(after, paragraph);
// Place cursor inside the new paragraph
tr.setSelection(TextSelection.create(tr.doc, after + 1));
view.dispatch(tr);
view.focus();
} catch {
// posAtDOM can throw if the element isn't in the editor
}
}, [editor]);
// Initiate ProseMirror-native drag when handle is dragged
const onHandleDragStart = useCallback(
(e: React.DragEvent) => {
if (!editor || !blockElRef.current) return;
const view = editor.view;
const el = blockElRef.current;
// Use saved state from mousedown (PM may have cleared it due to focus change)
const bsel = savedBlockSelRef.current;
try {
// Multi-block drag: if block selection is active and hovered block is in range
if (bsel && blockIndexRef.current != null) {
const from = Math.min(bsel.anchorIndex, bsel.headIndex);
const to = Math.max(bsel.anchorIndex, bsel.headIndex);
if (
blockIndexRef.current >= from &&
blockIndexRef.current <= to
) {
const blockRange = getBlockRange(view.state, bsel);
if (blockRange) {
const nodes: PmNode[] = [];
let idx = 0;
view.state.doc.forEach((node) => {
if (idx >= from && idx <= to) nodes.push(node);
idx++;
});
const sel = TextSelection.create(
view.state.doc,
blockRange.fromPos,
blockRange.toPos,
);
const tr = view.state.tr.setSelection(sel);
tr.setMeta("blockSelectionInternal", true);
view.dispatch(tr);
view.dragging = {
slice: new Slice(Fragment.from(nodes), 0, 0),
move: true,
};
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setDragImage(el, 0, 0);
e.dataTransfer.setData("application/x-pm-drag", "true");
return;
}
}
}
// Single-block drag (existing behavior)
const pos = view.posAtDOM(el, 0);
const $pos = view.state.doc.resolve(pos);
const before = $pos.before($pos.depth);
const sel = NodeSelection.create(view.state.doc, before);
view.dispatch(view.state.tr.setSelection(sel));
view.dragging = { slice: sel.content(), move: true };
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setDragImage(el, 0, 0);
e.dataTransfer.setData("application/x-pm-drag", "true");
} catch {
// posAtDOM can throw if the element isn't in the editor
}
},
[editor],
);
return (
<div
className="relative"
onMouseMove={onMouseMove}
onMouseLeave={onMouseLeave}
>
{handlePos && (
<div
data-block-handle-row
className="absolute left-0 flex items-start z-10"
style={{ top: handlePos.top + 1 }}
>
<div
onClick={onHandleAdd}
onMouseDown={(e) => e.preventDefault()}
className="flex items-center justify-center w-5 h-6 cursor-pointer rounded hover:bg-muted"
>
<Plus className="h-3.5 w-3.5 text-muted-foreground/60" />
</div>
<div
data-drag-handle
draggable
onMouseDown={() => {
if (editor) {
savedBlockSelRef.current =
blockSelectionKey.getState(editor.view.state) ?? null;
}
}}
onClick={onHandleClick}
onDragStart={onHandleDragStart}
className="flex items-center justify-center w-5 h-6 cursor-grab rounded hover:bg-muted"
>
<GripVertical className="h-3.5 w-3.5 text-muted-foreground/60" />
</div>
</div>
)}
{children}
</div>
);
}

View File

@@ -1,4 +1,4 @@
import { useState, useCallback } from "react"; import { useState, useCallback, useMemo } from "react";
import { Check, ChevronDown, ChevronRight, AlertTriangle } from "lucide-react"; import { Check, ChevronDown, ChevronRight, AlertTriangle } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
@@ -18,33 +18,51 @@ export function ContentProposalReview({
onDismiss, onDismiss,
}: ContentProposalReviewProps) { }: ContentProposalReviewProps) {
const [accepted, setAccepted] = useState<Set<string>>(new Set()); const [accepted, setAccepted] = useState<Set<string>>(new Set());
const [acceptError, setAcceptError] = useState<string | null>(null);
const utils = trpc.useUtils(); const utils = trpc.useUtils();
const acceptMutation = trpc.acceptProposal.useMutation({ 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: () => { onSuccess: () => {
void utils.listProposals.invalidate(); setAcceptError(null);
void utils.listPages.invalidate(); },
void utils.getPage.invalidate(); onError: (err, _variables, context) => {
void utils.listAgents.invalidate(); if (context?.previousProposals) {
utils.listProposals.setData({ agentId }, context.previousProposals);
}
setAcceptError(err.message);
}, },
}); });
const acceptAllMutation = trpc.acceptAllProposals.useMutation({ const acceptAllMutation = trpc.acceptAllProposals.useMutation({
onSuccess: () => { onSuccess: (result) => {
void utils.listProposals.invalidate(); if (result.failed > 0) {
void utils.listPages.invalidate(); setAcceptError(`${result.failed} proposal(s) failed: ${result.errors.join('; ')}`);
void utils.getPage.invalidate(); } else {
void utils.listAgents.invalidate(); setAcceptError(null);
onDismiss(); onDismiss();
}
}, },
}); });
const dismissAllMutation = trpc.dismissAllProposals.useMutation({ const dismissAllMutation = trpc.dismissAllProposals.useMutation({
onSuccess: () => { onMutate: async () => {
void utils.listProposals.invalidate(); await utils.listProposals.cancel({ agentId });
void utils.listAgents.invalidate(); const previousProposals = utils.listProposals.getData({ agentId });
// Note: onDismiss() is not called here because the backend auto-dismiss utils.listProposals.setData({ agentId }, []);
// will set userDismissedAt when all proposals are resolved 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.mutate({ agentId });
}, [dismissAllMutation, agentId]); }, [dismissAllMutation, agentId]);
// Batch-fetch page updatedAt timestamps for staleness check (eliminates N+1)
const pageTargetIds = useMemo(() => {
const ids = new Set<string>();
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'); const allAccepted = proposals.every((p) => accepted.has(p.id) || p.status === 'accepted');
return ( return (
@@ -94,6 +126,13 @@ export function ContentProposalReview({
</div> </div>
</div> </div>
{acceptError && (
<div className="flex items-center gap-1.5 text-xs text-destructive bg-destructive/10 rounded px-2 py-1.5">
<AlertTriangle className="h-3 w-3 shrink-0" />
<span>{acceptError}</span>
</div>
)}
<div className="space-y-2"> <div className="space-y-2">
{proposals.map((proposal) => ( {proposals.map((proposal) => (
<ProposalCard <ProposalCard
@@ -101,6 +140,9 @@ export function ContentProposalReview({
proposal={proposal} proposal={proposal}
isAccepted={accepted.has(proposal.id) || proposal.status === 'accepted'} isAccepted={accepted.has(proposal.id) || proposal.status === 'accepted'}
agentCreatedAt={agentCreatedAt} agentCreatedAt={agentCreatedAt}
pageUpdatedAt={proposal.targetType === 'page' && proposal.targetId
? pageUpdatedAtMap.data?.[proposal.targetId] ?? null
: null}
onAccept={() => handleAccept(proposal)} onAccept={() => handleAccept(proposal)}
isAccepting={acceptMutation.isPending} isAccepting={acceptMutation.isPending}
/> />
@@ -114,6 +156,7 @@ interface ProposalCardProps {
proposal: Proposal; proposal: Proposal;
isAccepted: boolean; isAccepted: boolean;
agentCreatedAt: Date; agentCreatedAt: Date;
pageUpdatedAt: string | null;
onAccept: () => void; onAccept: () => void;
isAccepting: boolean; isAccepting: boolean;
} }
@@ -122,17 +165,12 @@ function ProposalCard({
proposal, proposal,
isAccepted, isAccepted,
agentCreatedAt, agentCreatedAt,
pageUpdatedAt,
onAccept, onAccept,
isAccepting, isAccepting,
}: ProposalCardProps) { }: ProposalCardProps) {
const [expanded, setExpanded] = useState(false); 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 = const isStale =
proposal.targetType === 'page' && proposal.targetType === 'page' &&
pageUpdatedAt && new Date(pageUpdatedAt) > agentCreatedAt; pageUpdatedAt && new Date(pageUpdatedAt) > agentCreatedAt;

View File

@@ -7,16 +7,9 @@ import { TiptapEditor } from "./TiptapEditor";
import { PageTitleProvider } from "./PageTitleContext"; import { PageTitleProvider } from "./PageTitleContext";
import { PageTree } from "./PageTree"; import { PageTree } from "./PageTree";
import { RefineAgentPanel } from "./RefineAgentPanel"; import { RefineAgentPanel } from "./RefineAgentPanel";
import { DeleteSubpageDialog } from "./DeleteSubpageDialog";
import { Skeleton } from "@/components/Skeleton"; import { Skeleton } from "@/components/Skeleton";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
interface ContentTabProps { interface ContentTabProps {
initiativeId: string; initiativeId: string;
@@ -30,30 +23,14 @@ interface DeleteConfirmation {
export function ContentTab({ initiativeId, initiativeName }: ContentTabProps) { export function ContentTab({ initiativeId, initiativeName }: ContentTabProps) {
const utils = trpc.useUtils(); const utils = trpc.useUtils();
const handleSaved = useCallback(() => { const { save, flush, isSaving } = useAutoSave();
void utils.listPages.invalidate({ initiativeId });
}, [utils, initiativeId]);
const { save, flush, isSaving } = useAutoSave({ onSaved: handleSaved });
// Get or create root page // Get or create root page
const rootPageQuery = trpc.getRootPage.useQuery({ initiativeId }); const rootPageQuery = trpc.getRootPage.useQuery({ initiativeId });
const allPagesQuery = trpc.listPages.useQuery({ initiativeId }); const allPagesQuery = trpc.listPages.useQuery({ initiativeId });
const createPageMutation = trpc.createPage.useMutation({ const createPageMutation = trpc.createPage.useMutation();
onSuccess: () => { const deletePageMutation = trpc.deletePage.useMutation();
void utils.listPages.invalidate({ initiativeId }); const updateInitiativeMutation = trpc.updateInitiative.useMutation();
},
});
const deletePageMutation = trpc.deletePage.useMutation({
onSuccess: () => {
void utils.listPages.invalidate({ initiativeId });
},
});
const updateInitiativeMutation = trpc.updateInitiative.useMutation({
onSuccess: () => {
void utils.getInitiative.invalidate({ id: initiativeId });
},
});
const initiativeNameTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null); const initiativeNameTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const pendingInitiativeNameRef = useRef<string | null>(null); const pendingInitiativeNameRef = useRef<string | null>(null);
@@ -158,7 +135,7 @@ export function ContentTab({ initiativeId, initiativeName }: ContentTabProps) {
setActivePageId(pageId); 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( const handleSubpageCreate = useCallback(
async (editor: Editor) => { async (editor: Editor) => {
editorRef.current = editor; editorRef.current = editor;
@@ -193,7 +170,7 @@ export function ContentTab({ initiativeId, initiativeName }: ContentTabProps) {
const allPages = allPagesQuery.data ?? []; const allPages = allPagesQuery.data ?? [];
const exists = allPages.some((p) => p.id === pageId); const exists = allPages.some((p) => p.id === pageId);
if (!exists) { 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(); redo();
return; 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) { if (rootPageQuery.isError) {
return ( return (
<div className="flex flex-col items-center justify-center gap-3 py-12 text-center"> <div className="flex flex-col items-center justify-center gap-3 py-12 text-center">
@@ -271,7 +248,7 @@ export function ContentTab({ initiativeId, initiativeName }: ContentTabProps) {
{/* Editor area */} {/* Editor area */}
<div className="flex-1 min-w-0"> <div className="flex-1 min-w-0">
{/* Refine agent panel sits above editor */} {/* Refine agent panel -- sits above editor */}
<RefineAgentPanel initiativeId={initiativeId} /> <RefineAgentPanel initiativeId={initiativeId} />
{resolvedActivePageId && ( {resolvedActivePageId && (
@@ -295,7 +272,7 @@ export function ContentTab({ initiativeId, initiativeName }: ContentTabProps) {
{activePageQuery.isSuccess && ( {activePageQuery.isSuccess && (
<TiptapEditor <TiptapEditor
key={resolvedActivePageId} key={resolvedActivePageId}
pageId={resolvedActivePageId} entityId={resolvedActivePageId}
content={activePageQuery.data?.content ?? null} content={activePageQuery.data?.content ?? null}
onUpdate={handleEditorUpdate} onUpdate={handleEditorUpdate}
onPageLinkClick={handleNavigate} onPageLinkClick={handleNavigate}
@@ -318,30 +295,15 @@ export function ContentTab({ initiativeId, initiativeName }: ContentTabProps) {
</div> </div>
{/* Delete subpage confirmation dialog */} {/* Delete subpage confirmation dialog */}
<Dialog <DeleteSubpageDialog
open={deleteConfirm !== null} open={deleteConfirm !== null}
onOpenChange={(open) => { pageName={
if (!open) dismissDeleteConfirm(); allPages.find((p) => p.id === deleteConfirm?.pageId)?.title ??
}} "Untitled"
> }
<DialogContent> onConfirm={confirmDeleteSubpage}
<DialogHeader> onCancel={dismissDeleteConfirm}
<DialogTitle>Delete subpage?</DialogTitle> />
<DialogDescription>
You removed the link to &ldquo;{allPages.find((p) => p.id === deleteConfirm?.pageId)?.title ?? "Untitled"}&rdquo;.
Do you also want to delete the subpage and all its content?
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={dismissDeleteConfirm}>
Keep subpage
</Button>
<Button variant="destructive" onClick={confirmDeleteSubpage}>
Delete subpage
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</PageTitleProvider> </PageTitleProvider>
</> </>
); );

View File

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

View File

@@ -0,0 +1,66 @@
import { Extension } from "@tiptap/react";
import { Plugin, PluginKey } from "@tiptap/pm/state";
import type { MutableRefObject } from "react";
export function createPageLinkDeletionDetector(
onPageLinkDeletedRef: MutableRefObject<
((pageId: string, redo: () => void) => void) | undefined
>,
) {
return Extension.create({
name: "pageLinkDeletionDetector",
addStorage() {
return { skipDetection: false };
},
addProseMirrorPlugins() {
const tiptapEditor = this.editor;
return [
new Plugin({
key: new PluginKey("pageLinkDeletionDetector"),
appendTransaction(_transactions, oldState, newState) {
if (oldState.doc.eq(newState.doc)) return null;
const oldLinks = new Set<string>();
oldState.doc.descendants((node) => {
if (node.type.name === "pageLink" && node.attrs.pageId) {
oldLinks.add(node.attrs.pageId);
}
});
const newLinks = new Set<string>();
newState.doc.descendants((node) => {
if (node.type.name === "pageLink" && node.attrs.pageId) {
newLinks.add(node.attrs.pageId);
}
});
for (const removedPageId of oldLinks) {
if (!newLinks.has(removedPageId)) {
// Fire async to avoid dispatching during appendTransaction
setTimeout(() => {
if (
tiptapEditor.storage.pageLinkDeletionDetector.skipDetection
) {
tiptapEditor.storage.pageLinkDeletionDetector.skipDetection =
false;
return;
}
// Undo the deletion immediately so the link reappears
tiptapEditor.commands.undo();
// Pass a redo function so the caller can re-delete if confirmed
onPageLinkDeletedRef.current?.(removedPageId, () => {
tiptapEditor.storage.pageLinkDeletionDetector.skipDetection =
true;
tiptapEditor.commands.redo();
});
}, 0);
}
}
return null;
},
}),
];
},
});
}

View File

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

View File

@@ -12,7 +12,7 @@ interface RefineAgentPanelProps {
export function RefineAgentPanel({ initiativeId }: RefineAgentPanelProps) { export function RefineAgentPanel({ initiativeId }: RefineAgentPanelProps) {
// All agent logic is now encapsulated in the hook // 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), // spawn.mutate and resume.mutate are stable (ref-backed in useRefineAgent),
// so these callbacks won't change on every render. // so these callbacks won't change on every render.
@@ -87,7 +87,9 @@ export function RefineAgentPanel({ initiativeId }: RefineAgentPanelProps) {
onCancel={() => { onCancel={() => {
// Can't cancel mid-question — just dismiss // Can't cancel mid-question — just dismiss
}} }}
onDismiss={() => stop.mutate()}
isSubmitting={resume.isPending} isSubmitting={resume.isPending}
isDismissing={stop.isPending}
/> />
</div> </div>
); );

View File

@@ -14,6 +14,7 @@ export const SlashCommands = Extension.create({
addStorage() { addStorage() {
return { return {
onSubpageCreate: null as ((editor: unknown) => void) | null, onSubpageCreate: null as ((editor: unknown) => void) | null,
hideSubpage: false,
}; };
}, },
@@ -36,10 +37,14 @@ export const SlashCommands = Extension.create({
// Execute the selected command // Execute the selected command
props.action(editor); props.action(editor);
}, },
items: ({ query }: { query: string }): SlashCommandItem[] => { items: ({ query, editor }: { query: string; editor: ReturnType<typeof import("@tiptap/react").useEditor> }): SlashCommandItem[] => {
return slashCommandItems.filter((item) => let items = slashCommandItems.filter((item) =>
item.label.toLowerCase().includes(query.toLowerCase()), item.label.toLowerCase().includes(query.toLowerCase()),
); );
if (editor.storage.slashCommands?.hideSubpage) {
items = items.filter((item) => !item.isSubpage);
}
return items;
}, },
render: () => { render: () => {
let component: ReactRenderer<SlashCommandListRef> | null = null; let component: ReactRenderer<SlashCommandListRef> | null = null;

View File

@@ -1,25 +1,21 @@
import { useState, useEffect, useRef, useCallback } from "react"; import { useEffect, useRef, useCallback } from "react";
import { useEditor, EditorContent, Extension } from "@tiptap/react"; import { useEditor, EditorContent } from "@tiptap/react";
import type { Editor } from "@tiptap/react"; import type { Editor } from "@tiptap/react";
import { GripVertical, Plus } from "lucide-react";
import StarterKit from "@tiptap/starter-kit"; import StarterKit from "@tiptap/starter-kit";
import Placeholder from "@tiptap/extension-placeholder"; import Placeholder from "@tiptap/extension-placeholder";
import Link from "@tiptap/extension-link"; import Link from "@tiptap/extension-link";
import { Table, TableRow, TableCell, TableHeader } from "@tiptap/extension-table"; 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 { SlashCommands } from "./SlashCommands";
import { PageLinkExtension } from "./PageLinkExtension"; import { PageLinkExtension } from "./PageLinkExtension";
import { import { BlockSelectionExtension } from "./BlockSelectionExtension";
BlockSelectionExtension, import { createPageLinkDeletionDetector } from "./PageLinkDeletionDetector";
blockSelectionKey, import { BlockDragHandle } from "./BlockDragHandle";
getBlockRange,
} from "./BlockSelectionExtension";
interface TiptapEditorProps { interface TiptapEditorProps {
content: string | null; content: string | null;
onUpdate: (json: string) => void; onUpdate: (json: string) => void;
pageId: string; entityId: string;
enablePageLinks?: boolean;
onPageLinkClick?: (pageId: string) => void; onPageLinkClick?: (pageId: string) => void;
onSubpageCreate?: ( onSubpageCreate?: (
editor: Editor, editor: Editor,
@@ -30,7 +26,8 @@ interface TiptapEditorProps {
export function TiptapEditor({ export function TiptapEditor({
content, content,
onUpdate, onUpdate,
pageId, entityId,
enablePageLinks = true,
onPageLinkClick, onPageLinkClick,
onSubpageCreate, onSubpageCreate,
onPageLinkDeleted, onPageLinkDeleted,
@@ -38,89 +35,38 @@ export function TiptapEditor({
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const onPageLinkDeletedRef = useRef(onPageLinkDeleted); const onPageLinkDeletedRef = useRef(onPageLinkDeleted);
onPageLinkDeletedRef.current = onPageLinkDeleted; onPageLinkDeletedRef.current = onPageLinkDeleted;
const blockIndexRef = useRef<number | null>(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( const editor = useEditor(
{ {
extensions: [ extensions,
StarterKit,
Table.configure({ resizable: true, cellMinWidth: 50 }),
TableRow,
TableCell,
TableHeader,
Placeholder.configure({
includeChildren: true,
placeholder: ({ node }) => {
if (node.type.name === 'heading') {
return `Heading ${node.attrs.level}`;
}
return "Type '/' for commands...";
},
}),
Link.configure({
openOnClick: false,
}),
SlashCommands,
PageLinkExtension,
BlockSelectionExtension,
// Detect pageLink node deletions by comparing old/new doc state
Extension.create({
name: "pageLinkDeletionDetector",
addStorage() {
return { skipDetection: false };
},
addProseMirrorPlugins() {
const tiptapEditor = this.editor;
return [
new Plugin({
key: new PluginKey("pageLinkDeletionDetector"),
appendTransaction(_transactions, oldState, newState) {
if (oldState.doc.eq(newState.doc)) return null;
const oldLinks = new Set<string>();
oldState.doc.descendants((node) => {
if (node.type.name === "pageLink" && node.attrs.pageId) {
oldLinks.add(node.attrs.pageId);
}
});
const newLinks = new Set<string>();
newState.doc.descendants((node) => {
if (node.type.name === "pageLink" && node.attrs.pageId) {
newLinks.add(node.attrs.pageId);
}
});
for (const removedPageId of oldLinks) {
if (!newLinks.has(removedPageId)) {
// Fire async to avoid dispatching during appendTransaction
setTimeout(() => {
if (tiptapEditor.storage.pageLinkDeletionDetector.skipDetection) {
tiptapEditor.storage.pageLinkDeletionDetector.skipDetection = false;
return;
}
// Undo the deletion immediately so the link reappears
tiptapEditor.commands.undo();
// Pass a redo function so the caller can re-delete if confirmed
onPageLinkDeletedRef.current?.(
removedPageId,
() => {
tiptapEditor.storage.pageLinkDeletionDetector.skipDetection = true;
tiptapEditor.commands.redo();
},
);
}, 0);
}
}
return null;
},
}),
];
},
}),
],
content: content ? JSON.parse(content) : undefined, content: content ? JSON.parse(content) : undefined,
onUpdate: ({ editor: e }) => { onUpdate: ({ editor: e }) => {
onUpdate(JSON.stringify(e.getJSON())); onUpdate(JSON.stringify(e.getJSON()));
@@ -132,17 +78,20 @@ export function TiptapEditor({
}, },
}, },
}, },
[pageId], [entityId],
); );
// Wire the onSubpageCreate callback into editor storage // Wire the onSubpageCreate callback into editor storage
useEffect(() => { useEffect(() => {
if (editor && onSubpageCreate) { if (editor) {
editor.storage.slashCommands.onSubpageCreate = (ed: Editor) => { if (onSubpageCreate) {
onSubpageCreate(ed); 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 // Handle page link clicks via custom event
const handlePageLinkClick = useCallback( const handlePageLinkClick = useCallback(
@@ -163,199 +112,11 @@ export function TiptapEditor({
el.removeEventListener("page-link-click", handlePageLinkClick); el.removeEventListener("page-link-click", handlePageLinkClick);
}, [handlePageLinkClick]); }, [handlePageLinkClick]);
// Floating drag handle: track which block the mouse is over
const [handlePos, setHandlePos] = useState<{ top: number; height: number } | null>(null);
const blockElRef = useRef<HTMLElement | null>(null);
const onMouseMove = useCallback((e: React.MouseEvent) => {
// If hovering the handle itself, keep current position
if ((e.target as HTMLElement).closest("[data-block-handle-row]")) return;
const editorEl = containerRef.current?.querySelector(".ProseMirror");
if (!editorEl || !editor) return;
// Walk from event target up to a direct child of .ProseMirror
let target = e.target as HTMLElement;
while (target && target !== editorEl && target.parentElement !== editorEl) {
target = target.parentElement!;
}
if (target && target !== editorEl && target.parentElement === editorEl) {
blockElRef.current = target;
const editorRect = editorEl.getBoundingClientRect();
const blockRect = target.getBoundingClientRect();
setHandlePos({
top: blockRect.top - editorRect.top,
height: blockRect.height,
});
// Track top-level block index for block selection
try {
const pos = editor.view.posAtDOM(target, 0);
blockIndexRef.current = editor.view.state.doc.resolve(pos).index(0);
} catch {
blockIndexRef.current = null;
}
}
// Don't clear — only onMouseLeave clears
}, [editor]);
const onMouseLeave = useCallback(() => {
setHandlePos(null);
blockElRef.current = null;
blockIndexRef.current = null;
}, []);
// Click on drag handle → select block (Shift+click extends)
const onHandleClick = useCallback(
(e: React.MouseEvent) => {
if (!editor) return;
const idx = blockIndexRef.current;
if (idx == null) return;
// Use saved state from mousedown (PM may have cleared it due to focus change)
const existing = savedBlockSelRef.current;
let newSel;
if (e.shiftKey && existing) {
newSel = { anchorIndex: existing.anchorIndex, headIndex: idx };
} else {
newSel = { anchorIndex: idx, headIndex: idx };
}
const tr = editor.view.state.tr.setMeta(blockSelectionKey, newSel);
tr.setMeta("blockSelectionInternal", true);
editor.view.dispatch(tr);
// Refocus editor so Shift+Arrow keys reach PM's handleKeyDown
editor.view.focus();
},
[editor],
);
// Add a new empty paragraph below the hovered block
const onHandleAdd = useCallback(() => {
if (!editor || !blockElRef.current) return;
const view = editor.view;
try {
const pos = view.posAtDOM(blockElRef.current, 0);
const $pos = view.state.doc.resolve(pos);
const after = $pos.after($pos.depth);
const paragraph = view.state.schema.nodes.paragraph.create();
const tr = view.state.tr.insert(after, paragraph);
// Place cursor inside the new paragraph
tr.setSelection(TextSelection.create(tr.doc, after + 1));
view.dispatch(tr);
view.focus();
} catch {
// posAtDOM can throw if the element isn't in the editor
}
}, [editor]);
// Initiate ProseMirror-native drag when handle is dragged
const onHandleDragStart = useCallback(
(e: React.DragEvent) => {
if (!editor || !blockElRef.current) return;
const view = editor.view;
const el = blockElRef.current;
// Use saved state from mousedown (PM may have cleared it due to focus change)
const bsel = savedBlockSelRef.current;
try {
// Multi-block drag: if block selection is active and hovered block is in range
if (bsel && blockIndexRef.current != null) {
const from = Math.min(bsel.anchorIndex, bsel.headIndex);
const to = Math.max(bsel.anchorIndex, bsel.headIndex);
if (blockIndexRef.current >= from && blockIndexRef.current <= to) {
const blockRange = getBlockRange(view.state, bsel);
if (blockRange) {
const nodes: PmNode[] = [];
let idx = 0;
view.state.doc.forEach((node) => {
if (idx >= from && idx <= to) nodes.push(node);
idx++;
});
const sel = TextSelection.create(
view.state.doc,
blockRange.fromPos,
blockRange.toPos,
);
const tr = view.state.tr.setSelection(sel);
tr.setMeta("blockSelectionInternal", true);
view.dispatch(tr);
view.dragging = {
slice: new Slice(Fragment.from(nodes), 0, 0),
move: true,
};
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setDragImage(el, 0, 0);
e.dataTransfer.setData("application/x-pm-drag", "true");
return;
}
}
}
// Single-block drag (existing behavior)
const pos = view.posAtDOM(el, 0);
const $pos = view.state.doc.resolve(pos);
const before = $pos.before($pos.depth);
const sel = NodeSelection.create(view.state.doc, before);
view.dispatch(view.state.tr.setSelection(sel));
view.dragging = { slice: sel.content(), move: true };
e.dataTransfer.effectAllowed = "move";
e.dataTransfer.setDragImage(el, 0, 0);
e.dataTransfer.setData("application/x-pm-drag", "true");
} catch {
// posAtDOM can throw if the element isn't in the editor
}
},
[editor],
);
return ( return (
<div <div ref={containerRef}>
ref={containerRef} <BlockDragHandle editor={editor}>
className="relative" <EditorContent editor={editor} />
onMouseMove={onMouseMove} </BlockDragHandle>
onMouseLeave={onMouseLeave}
>
{handlePos && (
<div
data-block-handle-row
className="absolute left-0 flex items-start z-10"
style={{ top: handlePos.top + 1 }}
>
<div
onClick={onHandleAdd}
onMouseDown={(e) => e.preventDefault()}
className="flex items-center justify-center w-5 h-6 cursor-pointer rounded hover:bg-muted"
>
<Plus className="h-3.5 w-3.5 text-muted-foreground/60" />
</div>
<div
data-drag-handle
draggable
onMouseDown={() => {
if (editor) {
savedBlockSelRef.current = blockSelectionKey.getState(editor.view.state) ?? null;
}
}}
onClick={onHandleClick}
onDragStart={onHandleDragStart}
className="flex items-center justify-center w-5 h-6 cursor-grab rounded hover:bg-muted"
>
<GripVertical className="h-3.5 w-3.5 text-muted-foreground/60" />
</div>
</div>
)}
<EditorContent editor={editor} />
</div> </div>
); );
} }

View File

@@ -43,6 +43,12 @@ export const slashCommandItems: SlashCommandItem[] = [
description: "Ordered list", description: "Ordered list",
action: (editor) => editor.chain().focus().toggleOrderedList().run(), 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", label: "Code Block",
icon: "<>", icon: "<>",

View File

@@ -1,22 +1,23 @@
import { useCallback, useMemo } from "react"; 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 { Button } from "@/components/ui/button";
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
import { useSpawnMutation } from "@/hooks/useSpawnMutation"; import { useSpawnMutation } from "@/hooks/useSpawnMutation";
import { ContentProposalReview } from "@/components/editor/ContentProposalReview";
interface BreakdownSectionProps { interface BreakdownSectionProps {
initiativeId: string; initiativeId: string;
phasesLoaded: boolean; phasesLoaded: boolean;
phases: Array<{ status: string }>; phases: Array<{ status: string }>;
onAddPhase?: () => void;
} }
export function BreakdownSection({ export function BreakdownSection({
initiativeId, initiativeId,
phasesLoaded, phasesLoaded,
phases, phases,
onAddPhase,
}: BreakdownSectionProps) { }: BreakdownSectionProps) {
const utils = trpc.useUtils();
// Breakdown agent tracking // Breakdown agent tracking
const agentsQuery = trpc.listAgents.useQuery(); const agentsQuery = trpc.listAgents.useQuery();
const allAgents = agentsQuery.data ?? []; const allAgents = agentsQuery.data ?? [];
@@ -25,7 +26,7 @@ export function BreakdownSection({
.filter( .filter(
(a) => (a) =>
a.mode === "breakdown" && a.mode === "breakdown" &&
a.taskId === initiativeId && a.initiativeId === initiativeId &&
["running", "waiting_for_input", "idle"].includes(a.status), ["running", "waiting_for_input", "idle"].includes(a.status),
) )
.sort( .sort(
@@ -37,27 +38,55 @@ export function BreakdownSection({
const isBreakdownRunning = breakdownAgent?.status === "running"; 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, { const breakdownSpawn = useSpawnMutation(trpc.spawnArchitectBreakdown.useMutation, {
onSuccess: () => { showToast: false,
void utils.listAgents.invalidate();
},
showToast: false, // We show our own error UI
}); });
const handleBreakdown = useCallback(() => { const handleBreakdown = useCallback(() => {
breakdownSpawn.spawn({ initiativeId }); breakdownSpawn.spawn({ initiativeId });
}, [initiativeId, breakdownSpawn]); }, [initiativeId, breakdownSpawn]);
// Don't render if we have phases const handleDismiss = useCallback(() => {
if (phasesLoaded && phases.length > 0) { if (!breakdownAgent) return;
return null; dismissMutation.mutate({ id: breakdownAgent.id });
} }, [breakdownAgent, dismissMutation]);
// Don't render during loading // Don't render during loading
if (!phasesLoaded) { if (!phasesLoaded) {
return null; 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 (
<div className="py-4">
<ContentProposalReview
proposals={pendingProposals}
agentCreatedAt={new Date(breakdownAgent.createdAt)}
agentId={breakdownAgent.id}
onDismiss={handleDismiss}
/>
</div>
);
}
return ( return (
<div className="py-8 text-center space-y-3"> <div className="py-8 text-center space-y-3">
<p className="text-muted-foreground">No phases yet</p> <p className="text-muted-foreground">No phases yet</p>
@@ -67,18 +96,34 @@ export function BreakdownSection({
Breaking down initiative... Breaking down initiative...
</div> </div>
) : ( ) : (
<Button <div className="flex items-center justify-center gap-2">
variant="outline" <Button
size="sm" variant="outline"
onClick={handleBreakdown} size="sm"
disabled={breakdownSpawn.isSpawning} onClick={handleBreakdown}
className="gap-1.5" disabled={breakdownSpawn.isSpawning}
> className="gap-1.5"
<Sparkles className="h-3.5 w-3.5" /> >
{breakdownSpawn.isSpawning <Sparkles className="h-3.5 w-3.5" />
? "Starting..." {breakdownSpawn.isSpawning
: "Break Down Initiative"} ? "Starting..."
</Button> : "Break Down Initiative"}
</Button>
{onAddPhase && (
<>
<span className="text-xs text-muted-foreground">or</span>
<Button
variant="outline"
size="sm"
onClick={onAddPhase}
className="gap-1.5"
>
<Plus className="h-3.5 w-3.5" />
Add Phase
</Button>
</>
)}
</div>
)} )}
{breakdownSpawn.isError && ( {breakdownSpawn.isError && (
<p className="text-xs text-destructive"> <p className="text-xs text-destructive">
@@ -87,4 +132,4 @@ export function BreakdownSection({
)} )}
</div> </div>
); );
} }

View File

@@ -21,9 +21,8 @@ export interface FlatTaskEntry {
export interface PhaseData { export interface PhaseData {
id: string; id: string;
initiativeId: string; initiativeId: string;
number: number;
name: string; name: string;
description: string | null; content: string | null;
status: string; status: string;
createdAt: string | Date; createdAt: string | Date;
updatedAt: string | Date; updatedAt: string | Date;

View File

@@ -1,60 +1,73 @@
import { useCallback, useMemo } from "react"; import { useCallback, useMemo } from "react";
import { Loader2 } from "lucide-react"; import { Loader2, Plus, Sparkles } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
interface PhaseActionsProps { interface PhaseActionsProps {
initiativeId: string; initiativeId: string;
phases: Array<{ id: string; status: string }>; phases: Array<{ id: string; status: string }>;
onAddPhase: () => void;
phasesWithoutTasks: string[];
decomposeAgentByPhase: Map<string, { id: string; status: string }>;
} }
export function PhaseActions({ initiativeId, phases }: PhaseActionsProps) { export function PhaseActions({
const queuePhaseMutation = trpc.queuePhase.useMutation(); onAddPhase,
phasesWithoutTasks,
decomposeAgentByPhase,
}: PhaseActionsProps) {
const decomposeMutation = trpc.spawnArchitectDecompose.useMutation();
// Breakdown agent tracking for status display // Phases eligible for breakdown: no tasks AND no active decompose agent
const agentsQuery = trpc.listAgents.useQuery(); const eligiblePhaseIds = useMemo(
const allAgents = agentsQuery.data ?? []; () => phasesWithoutTasks.filter((id) => !decomposeAgentByPhase.has(id)),
const breakdownAgent = useMemo(() => { [phasesWithoutTasks, decomposeAgentByPhase],
const candidates = allAgents );
.filter(
(a) =>
a.mode === "breakdown" &&
a.taskId === initiativeId &&
["running", "waiting_for_input", "idle"].includes(a.status),
)
.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
return candidates[0] ?? null;
}, [allAgents, initiativeId]);
const isBreakdownRunning = breakdownAgent?.status === "running"; // Count of phases currently being decomposed
const hasPendingPhases = phases.some((p) => p.status === "pending"); const activeDecomposeCount = useMemo(() => {
let count = 0;
const handleQueueAll = useCallback(() => { for (const [, agent] of decomposeAgentByPhase) {
const pendingPhases = phases.filter((p) => p.status === "pending"); if (agent.status === "running" || agent.status === "waiting_for_input") {
for (const phase of pendingPhases) { count++;
queuePhaseMutation.mutate({ phaseId: phase.id }); }
} }
}, [phases, queuePhaseMutation]); return count;
}, [decomposeAgentByPhase]);
const handleBreakdownAll = useCallback(() => {
for (const phaseId of eligiblePhaseIds) {
decomposeMutation.mutate({ phaseId });
}
}, [eligiblePhaseIds, decomposeMutation]);
return ( return (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{isBreakdownRunning && ( {activeDecomposeCount > 0 && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground"> <div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" /> <Loader2 className="h-3 w-3 animate-spin" />
Breaking down... Decomposing ({activeDecomposeCount})
</div> </div>
)} )}
<Button
variant="ghost"
size="icon"
className="h-7 w-7"
onClick={onAddPhase}
title="Add phase"
>
<Plus className="h-4 w-4" />
</Button>
<Button <Button
variant="outline" variant="outline"
size="sm" size="sm"
disabled={!hasPendingPhases} disabled={eligiblePhaseIds.length === 0}
onClick={handleQueueAll} onClick={handleBreakdownAll}
className="gap-1.5"
> >
Queue All <Sparkles className="h-3.5 w-3.5" />
Breakdown All
</Button> </Button>
</div> </div>
); );
} }

View File

@@ -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<string, number>;
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<HTMLInputElement>(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 (
<div className="space-y-6">
{/* Header */}
<div className="flex items-center gap-3">
{isEditingTitle ? (
<div className="flex items-center gap-1">
<span className="text-lg font-semibold">Phase {displayIndex}:</span>
<input
ref={inputRef}
className="border-b border-border bg-transparent text-lg font-semibold outline-none focus:border-primary"
value={editName}
onChange={(e) => setEditName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") saveTitle();
if (e.key === "Escape") cancelEditing();
}}
onBlur={saveTitle}
/>
</div>
) : (
<h3
className="cursor-pointer text-lg font-semibold hover:text-primary"
onClick={startEditing}
title="Click to rename"
>
Phase {displayIndex}: {phase.name}
</h3>
)}
<StatusBadge status={phase.status} />
{/* Breakdown button in header */}
{showBreakdownButton && (
<Button
variant="outline"
size="sm"
onClick={handleDecompose}
disabled={decomposeMutation.isPending}
className="gap-1.5"
>
<Sparkles className="h-3.5 w-3.5" />
{decomposeMutation.isPending ? "Starting..." : "Breakdown"}
</Button>
)}
{/* Running indicator in header */}
{isDecomposeRunning && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Loader2 className="h-3 w-3 animate-spin" />
Breaking down...
</div>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="ml-auto h-7 w-7">
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
className="text-destructive"
onClick={() => {
if (window.confirm(`Delete "${phase.name}"? All tasks in this phase will also be deleted.`)) {
onDelete?.();
}
}}
>
<Trash2 className="mr-2 h-4 w-4" />
Delete Phase
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</div>
{/* Tiptap Editor */}
<PhaseContentEditor phaseId={phase.id} initiativeId={initiativeId} />
{/* Dependencies */}
<div>
<div className="mb-2 flex items-center gap-2">
<h4 className="text-sm font-medium text-muted-foreground">
Dependencies
</h4>
{availableDeps.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="h-5 w-5">
<Plus className="h-3 w-3" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
{availableDeps.map((p) => (
<DropdownMenuItem
key={p.id}
onClick={() =>
addDependency.mutate({
phaseId: phase.id,
dependsOnPhaseId: p.id,
})
}
>
Phase {allDisplayIndices.get(p.id) ?? "?"}: {p.name}
</DropdownMenuItem>
))}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{resolvedDeps.length === 0 ? (
<p className="text-xs text-muted-foreground">No dependencies</p>
) : (
<div className="space-y-1">
{resolvedDeps.map((dep) => (
<div
key={dep.id}
className="flex items-center gap-2 text-sm"
>
<span
className={
dep.status === "completed"
? "text-green-600"
: "text-muted-foreground"
}
>
{dep.status === "completed" ? "\u25CF" : "\u25CB"}
</span>
<span>
Phase {allDisplayIndices.get(dep.id) ?? "?"}: {dep.name}
</span>
<StatusBadge status={dep.status} className="text-[10px]" />
<button
className="ml-1 text-muted-foreground hover:text-destructive"
onClick={() =>
removeDependency.mutate({
phaseId: phase.id,
dependsOnPhaseId: dep.id,
})
}
title="Remove dependency"
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
)}
</div>
{/* Decompose proposals */}
{showProposals && (
<ContentProposalReview
proposals={pendingProposals as any}
agentCreatedAt={new Date(decomposeAgent!.createdAt)}
agentId={decomposeAgent!.id}
onDismiss={handleDismissDecompose}
/>
)}
{/* Tasks */}
<div>
<h4 className="mb-2 text-sm font-medium text-muted-foreground">
Tasks ({tasks.filter((t) => t.status === "completed").length}/
{tasks.length})
</h4>
{tasksLoading ? (
<div className="space-y-1">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</div>
) : sortedTasks.length === 0 ? (
<p className="text-sm text-muted-foreground">No tasks yet</p>
) : (
<div>
{sortedTasks.map((task, idx) => (
<TaskRow
key={task.id}
task={task}
agentName={null}
blockedBy={[]}
isLast={idx === sortedTasks.length - 1}
onClick={() => setSelectedTaskId(task.id)}
/>
))}
</div>
)}
</div>
</div>
);
}
export function PhaseDetailEmpty() {
return (
<div className="flex h-full items-center justify-center text-muted-foreground">
<p>Select a phase to view details</p>
</div>
);
}

View File

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

View File

@@ -9,9 +9,8 @@ interface PhaseWithTasksProps {
phase: { phase: {
id: string; id: string;
initiativeId: string; initiativeId: string;
number: number;
name: string; name: string;
description: string | null; content: string | null;
status: string; status: string;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
@@ -78,13 +77,13 @@ function PhaseWithTasksInner({
const entries: FlatTaskEntry[] = tasks.map((task) => ({ const entries: FlatTaskEntry[] = tasks.map((task) => ({
task, task,
phaseName: `Phase ${phase.number}: ${phase.name}`, phaseName: phase.name,
agentName: null, agentName: null,
blockedBy: [], blockedBy: [],
dependents: [], dependents: [],
})); }));
registerTasks(phase.id, entries); 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 sortedTasks = sortByPriorityAndQueueTime(tasks);
const taskEntries = sortedTasks.map((task) => ({ const taskEntries = sortedTasks.map((task) => ({
@@ -108,4 +107,4 @@ function PhaseWithTasksInner({
onTaskClick={onTaskClick} onTaskClick={onTaskClick}
/> />
); );
} }

View File

@@ -50,9 +50,8 @@ export function PhasesList({
const serializedPhase = { const serializedPhase = {
id: phase.id, id: phase.id,
initiativeId: phase.initiativeId, initiativeId: phase.initiativeId,
number: phase.number,
name: phase.name, name: phase.name,
description: phase.description, content: phase.content,
status: phase.status, status: phase.status,
createdAt: String(phase.createdAt), createdAt: String(phase.createdAt),
updatedAt: String(phase.updatedAt), updatedAt: String(phase.updatedAt),

View File

@@ -1,8 +1,7 @@
export { ExecutionProvider, useExecutionContext } from "./ExecutionContext"; export { ExecutionProvider, useExecutionContext } from "./ExecutionContext";
export { BreakdownSection } from "./BreakdownSection"; export { BreakdownSection } from "./BreakdownSection";
export { PhaseActions } from "./PhaseActions"; export { PhaseActions } from "./PhaseActions";
export { PhasesList } from "./PhasesList"; export { PhaseSidebarItem } from "./PhaseSidebarItem";
export { PhaseWithTasks } from "./PhaseWithTasks"; export { PhaseDetailPanel, PhaseDetailEmpty } from "./PhaseDetailPanel";
export { ProgressSidebar } from "./ProgressSidebar";
export { TaskModal } from "./TaskModal"; export { TaskModal } from "./TaskModal";
export type { TaskCounts, FlatTaskEntry, PhaseData } from "./ExecutionContext"; export type { TaskCounts, FlatTaskEntry, PhaseData } from "./ExecutionContext";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,104 @@
import { useState, useCallback, useRef } from "react";
import { toast } from "sonner";
import { DUMMY_REVIEW } from "./dummy-data";
import { DiffViewer } from "./DiffViewer";
import { ReviewSidebar } from "./ReviewSidebar";
import type { ReviewComment, ReviewStatus, DiffLine } from "./types";
interface ReviewTabProps {
initiativeId: string;
}
export function ReviewTab({ initiativeId: _initiativeId }: ReviewTabProps) {
const [comments, setComments] = useState<ReviewComment[]>(DUMMY_REVIEW.comments);
const [status, setStatus] = useState<ReviewStatus>(DUMMY_REVIEW.status);
const fileRefs = useRef<Map<string, HTMLDivElement>>(new Map());
const handleAddComment = useCallback(
(filePath: string, lineNumber: number, lineType: DiffLine["type"], body: string) => {
const newComment: ReviewComment = {
id: `c${Date.now()}`,
filePath,
lineNumber,
lineType,
body,
author: "you",
createdAt: new Date().toISOString(),
resolved: false,
};
setComments((prev) => [...prev, newComment]);
toast.success("Comment added");
},
[]
);
const handleResolveComment = useCallback((commentId: string) => {
setComments((prev) =>
prev.map((c) => (c.id === commentId ? { ...c, resolved: true } : c))
);
}, []);
const handleUnresolveComment = useCallback((commentId: string) => {
setComments((prev) =>
prev.map((c) => (c.id === commentId ? { ...c, resolved: false } : c))
);
}, []);
const handleApprove = useCallback(() => {
setStatus("approved");
toast.success("Review approved");
}, []);
const handleRequestChanges = useCallback(() => {
setStatus("changes_requested");
toast("Changes requested", {
description: "The agent will be notified about the requested changes.",
});
}, []);
const handleFileClick = useCallback((filePath: string) => {
const el = fileRefs.current.get(filePath);
if (el) {
el.scrollIntoView({ behavior: "smooth", block: "start" });
}
}, []);
return (
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_300px]">
{/* Left: Diff */}
<div className="min-w-0">
<div className="flex items-center justify-between border-b border-border pb-3 mb-4">
<h2 className="text-lg font-semibold">Review</h2>
<span className="text-xs text-muted-foreground">
{comments.filter((c) => !c.resolved).length} unresolved thread
{comments.filter((c) => !c.resolved).length !== 1 ? "s" : ""}
</span>
</div>
<DiffViewer
files={DUMMY_REVIEW.files}
comments={comments}
onAddComment={handleAddComment}
onResolveComment={handleResolveComment}
onUnresolveComment={handleUnresolveComment}
/>
</div>
{/* Right: Sidebar */}
<div className="w-full lg:w-[300px]">
<ReviewSidebar
title={DUMMY_REVIEW.title}
description={DUMMY_REVIEW.description}
author={DUMMY_REVIEW.author}
status={status}
sourceBranch={DUMMY_REVIEW.sourceBranch}
targetBranch={DUMMY_REVIEW.targetBranch}
files={DUMMY_REVIEW.files}
comments={comments}
onApprove={handleApprove}
onRequestChanges={handleRequestChanges}
onFileClick={handleFileClick}
/>
</div>
</div>
);
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,6 +7,7 @@
export { useAutoSave } from './useAutoSave.js'; export { useAutoSave } from './useAutoSave.js';
export { useDebounce, useDebounceWithImmediate } from './useDebounce.js'; export { useDebounce, useDebounceWithImmediate } from './useDebounce.js';
export { useLiveUpdates } from './useLiveUpdates.js';
export { useRefineAgent } from './useRefineAgent.js'; export { useRefineAgent } from './useRefineAgent.js';
export { useSubscriptionWithErrorHandling } from './useSubscriptionWithErrorHandling.js'; export { useSubscriptionWithErrorHandling } from './useSubscriptionWithErrorHandling.js';

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,11 @@
import { useMemo, useCallback, useRef } from 'react'; import { useCallback, useMemo, useRef } from 'react';
import { trpc } from '@/lib/trpc'; import { trpc } from '@/lib/trpc';
import type { Agent, PendingQuestions, Proposal } from '@codewalk-district/shared'; import type { PendingQuestions, Proposal } from '@codewalk-district/shared';
export type RefineAgentState = 'none' | 'running' | 'waiting' | 'completed' | 'crashed'; export type RefineAgentState = 'none' | 'running' | 'waiting' | 'completed' | 'crashed';
type RefineAgent = NonNullable<ReturnType<typeof trpc.getActiveRefineAgent.useQuery>['data']>;
export interface SpawnRefineAgentOptions { export interface SpawnRefineAgentOptions {
initiativeId: string; initiativeId: string;
instruction?: string; instruction?: string;
@@ -11,7 +13,7 @@ export interface SpawnRefineAgentOptions {
export interface UseRefineAgentResult { export interface UseRefineAgentResult {
/** Current refine agent for the initiative */ /** Current refine agent for the initiative */
agent: Agent | null; agent: RefineAgent | null;
/** Current state of the refine agent */ /** Current state of the refine agent */
state: RefineAgentState; state: RefineAgentState;
/** Questions from the agent (when state is 'waiting') */ /** Questions from the agent (when state is 'waiting') */
@@ -32,6 +34,11 @@ export interface UseRefineAgentResult {
isPending: boolean; isPending: boolean;
error: Error | null; error: Error | null;
}; };
/** Stop the current agent (kills process, clears questions) */
stop: {
mutate: () => void;
isPending: boolean;
};
/** Dismiss the current agent (sets userDismissedAt so it disappears) */ /** Dismiss the current agent (sets userDismissedAt so it disappears) */
dismiss: () => void; dismiss: () => void;
/** Whether any queries are loading */ /** Whether any queries are loading */
@@ -49,26 +56,9 @@ export interface UseRefineAgentResult {
export function useRefineAgent(initiativeId: string): UseRefineAgentResult { export function useRefineAgent(initiativeId: string): UseRefineAgentResult {
const utils = trpc.useUtils(); const utils = trpc.useUtils();
// Query all agents and find the active refine agent // Query only the active refine agent for this initiative (server-side filtered)
const agentsQuery = trpc.listAgents.useQuery(); const agentQuery = trpc.getActiveRefineAgent.useQuery({ initiativeId });
const agents = agentsQuery.data ?? []; const agent = agentQuery.data ?? null;
const agent = useMemo(() => {
// Find the most recent refine agent for this initiative
const candidates = agents
.filter(
(a) =>
a.mode === 'refine' &&
a.initiativeId === initiativeId &&
['running', 'waiting_for_input', 'idle', 'crashed'].includes(a.status) &&
!a.userDismissedAt, // Exclude dismissed agents
)
.sort(
(a, b) =>
new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime(),
);
return candidates[0] ?? null;
}, [agents, initiativeId]);
const state: RefineAgentState = useMemo(() => { const state: RefineAgentState = useMemo(() => {
if (!agent) return 'none'; if (!agent) return 'none';
@@ -118,7 +108,45 @@ export function useRefineAgent(initiativeId: string): UseRefineAgentResult {
// Spawn mutation // Spawn mutation
const spawnMutation = trpc.spawnArchitectRefine.useMutation({ const spawnMutation = trpc.spawnArchitectRefine.useMutation({
onMutate: async ({ initiativeId, instruction }) => {
// Cancel outgoing refetches
await utils.listAgents.cancel();
// Snapshot previous value
const previousAgents = utils.listAgents.getData();
// Optimistically add a temporary agent
const tempAgent = {
id: `temp-${Date.now()}`,
name: 'refine',
mode: 'refine' as const,
status: 'running' as const,
initiativeId,
taskId: null,
phaseId: null,
provider: 'claude',
accountId: null,
instruction: instruction || null,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
userDismissedAt: null,
completedAt: null,
};
utils.listAgents.setData(undefined, (old = []) => [tempAgent, ...old]);
return { previousAgents };
},
onSuccess: () => { onSuccess: () => {
// Agent will appear in the list after invalidation
},
onError: (err, variables, context) => {
// Revert optimistic update
if (context?.previousAgents) {
utils.listAgents.setData(undefined, context.previousAgents);
}
},
onSettled: () => {
void utils.listAgents.invalidate(); void utils.listAgents.invalidate();
}, },
}); });
@@ -130,14 +158,42 @@ export function useRefineAgent(initiativeId: string): UseRefineAgentResult {
}, },
}); });
// Stop mutation — kills process and clears pending questions
const stopMutation = trpc.stopAgent.useMutation({
onSuccess: () => {
void utils.listAgents.invalidate();
void utils.listWaitingAgents.invalidate();
},
});
// Dismiss mutation — sets userDismissedAt so agent disappears from the list // Dismiss mutation — sets userDismissedAt so agent disappears from the list
const dismissMutation = trpc.dismissAgent.useMutation({ const dismissMutation = trpc.dismissAgent.useMutation({
onMutate: async ({ id }) => {
// Cancel outgoing refetches
await utils.listAgents.cancel();
// Snapshot previous value
const previousAgents = utils.listAgents.getData();
// Optimistically remove the agent from the list
utils.listAgents.setData(undefined, (old = []) =>
old.filter(a => a.id !== id)
);
return { previousAgents };
},
onSuccess: () => { onSuccess: () => {
// Force immediate refetch of agents to update UI
void utils.listAgents.invalidate();
void utils.listAgents.refetch();
void utils.listProposals.invalidate(); void utils.listProposals.invalidate();
}, },
onError: (err, variables, context) => {
// Revert optimistic update
if (context?.previousAgents) {
utils.listAgents.setData(undefined, context.previousAgents);
}
},
onSettled: () => {
void utils.listAgents.invalidate();
},
}); });
// Keep mutation functions in refs so the returned spawn/resume objects are // Keep mutation functions in refs so the returned spawn/resume objects are
@@ -148,6 +204,8 @@ export function useRefineAgent(initiativeId: string): UseRefineAgentResult {
agentRef.current = agent; agentRef.current = agent;
const resumeMutateRef = useRef(resumeMutation.mutate); const resumeMutateRef = useRef(resumeMutation.mutate);
resumeMutateRef.current = resumeMutation.mutate; resumeMutateRef.current = resumeMutation.mutate;
const stopMutateRef = useRef(stopMutation.mutate);
stopMutateRef.current = stopMutation.mutate;
const dismissMutateRef = useRef(dismissMutation.mutate); const dismissMutateRef = useRef(dismissMutation.mutate);
dismissMutateRef.current = dismissMutation.mutate; dismissMutateRef.current = dismissMutation.mutate;
@@ -177,6 +235,18 @@ export function useRefineAgent(initiativeId: string): UseRefineAgentResult {
error: resumeMutation.error, error: resumeMutation.error,
}), [resumeFn, resumeMutation.isPending, resumeMutation.error]); }), [resumeFn, resumeMutation.isPending, resumeMutation.error]);
const stopFn = useCallback(() => {
const a = agentRef.current;
if (a) {
stopMutateRef.current({ id: a.id });
}
}, []);
const stop = useMemo(() => ({
mutate: stopFn,
isPending: stopMutation.isPending,
}), [stopFn, stopMutation.isPending]);
const dismiss = useCallback(() => { const dismiss = useCallback(() => {
const a = agentRef.current; const a = agentRef.current;
if (a) { if (a) {
@@ -185,11 +255,11 @@ export function useRefineAgent(initiativeId: string): UseRefineAgentResult {
}, []); }, []);
const refresh = useCallback(() => { const refresh = useCallback(() => {
void utils.listAgents.invalidate(); void utils.getActiveRefineAgent.invalidate({ initiativeId });
void utils.listProposals.invalidate(); void utils.listProposals.invalidate();
}, [utils]); }, [utils, initiativeId]);
const isLoading = agentsQuery.isLoading || const isLoading = agentQuery.isLoading ||
(state === 'waiting' && questionsQuery.isLoading) || (state === 'waiting' && questionsQuery.isLoading) ||
(state === 'completed' && (resultQuery.isLoading || proposalsQuery.isLoading)); (state === 'completed' && (resultQuery.isLoading || proposalsQuery.isLoading));
@@ -201,6 +271,7 @@ export function useRefineAgent(initiativeId: string): UseRefineAgentResult {
result, result,
spawn, spawn,
resume, resume,
stop,
dismiss, dismiss,
isLoading, isLoading,
refresh, refresh,

View File

@@ -36,7 +36,7 @@ interface SubscriptionState {
* and ensures proper cleanup on unmount. * and ensures proper cleanup on unmount.
*/ */
export function useSubscriptionWithErrorHandling( export function useSubscriptionWithErrorHandling(
subscription: () => ReturnType<typeof trpc.subscribeToEvents.useSubscription>, subscription: () => ReturnType<typeof trpc.onEvent.useSubscription>,
options: UseSubscriptionWithErrorHandlingOptions = {} options: UseSubscriptionWithErrorHandlingOptions = {}
) { ) {
const { const {

View File

@@ -128,3 +128,39 @@
/* Selected cell highlight */ /* Selected cell highlight */
.ProseMirror td.selectedCell, .ProseMirror th.selectedCell { background-color: hsl(var(--primary) / 0.08); } .ProseMirror td.selectedCell, .ProseMirror th.selectedCell { background-color: hsl(var(--primary) / 0.08); }
.dark .ProseMirror td.selectedCell, .dark .ProseMirror th.selectedCell { background-color: hsl(var(--primary) / 0.15); } .dark .ProseMirror td.selectedCell, .dark .ProseMirror th.selectedCell { background-color: hsl(var(--primary) / 0.15); }
/* Inline code styling — remove prose backtick pseudo-elements */
.ProseMirror :not(pre) > code {
background-color: hsl(var(--muted));
padding: 0.15em 0.35em;
border-radius: 0.25rem;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.875em;
}
.ProseMirror :not(pre) > code::before,
.ProseMirror :not(pre) > code::after {
content: none;
}
.dark .ProseMirror :not(pre) > code {
background-color: hsl(var(--muted));
}
/* Code block styling */
.ProseMirror pre {
background-color: hsl(var(--muted));
padding: 1rem;
border-radius: 0.375rem;
overflow-x: auto;
}
.ProseMirror pre code {
background: none;
padding: 0;
border-radius: 0;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 0.875em;
color: inherit;
}
.ProseMirror pre code::before,
.ProseMirror pre code::after {
content: none;
}

View File

@@ -0,0 +1,124 @@
import type { QueryClient } from "@tanstack/react-query";
import { MutationCache } from "@tanstack/react-query";
import type { AnyQueryProcedure, AnyMutationProcedure } from "@trpc/server";
import type { AppRouter } from "@codewalk-district/shared";
// Strip the [key: string] index signature from RouterRecord so keyof yields
// only the literal procedure names, not `string`.
type RemoveIndexSignature<T> = {
[K in keyof T as string extends K ? never : K]: T[K];
};
type Procedures = RemoveIndexSignature<AppRouter>;
type MutationName = {
[K in keyof Procedures]: Procedures[K] extends AnyMutationProcedure ? K : never;
}[keyof Procedures] & string;
type QueryName = {
[K in keyof Procedures]: Procedures[K] extends AnyQueryProcedure ? K : never;
}[keyof Procedures] & string;
/**
* Centralized invalidation map.
*
* Maps each tRPC mutation name to the query keys that should be invalidated
* when that mutation succeeds. This eliminates scattered `utils.listX.invalidate()`
* calls across every component.
*
* tRPC React Query encodes keys as arrays: the first element is a tuple like
* ["listAgents"], and the mutation key follows the same pattern. We match on
* the procedure name (the last segment of the tRPC path).
*/
const INVALIDATION_MAP: Partial<Record<MutationName, QueryName[]>> = {
// --- Agents ---
stopAgent: ["listAgents", "listWaitingAgents", "listMessages"],
deleteAgent: ["listAgents"],
dismissAgent: ["listAgents", "listProposals"],
resumeAgent: ["listAgents", "listWaitingAgents", "listMessages"],
respondToMessage: ["listWaitingAgents", "listMessages"],
// --- Architect spawns ---
spawnArchitectRefine: ["listAgents"],
spawnArchitectDiscuss: ["listAgents"],
spawnArchitectBreakdown: ["listAgents"],
spawnArchitectDecompose: ["listAgents", "listInitiativeTasks"],
// --- Initiatives ---
createInitiative: ["listInitiatives"],
updateInitiative: ["listInitiatives", "getInitiative"],
updateInitiativeProjects: ["getInitiative"],
// --- Phases ---
createPhase: ["listPhases", "listInitiativePhaseDependencies"],
deletePhase: ["listPhases", "listInitiativeTasks", "listInitiativePhaseDependencies"],
updatePhase: ["listPhases", "getPhase"],
approvePhase: ["listPhases", "listInitiativeTasks"],
queuePhase: ["listPhases"],
createPhaseDependency: ["getPhaseDependencies", "listInitiativePhaseDependencies"],
removePhaseDependency: ["getPhaseDependencies", "listInitiativePhaseDependencies"],
// --- Tasks ---
createPhaseTask: ["listPhaseTasks", "listInitiativeTasks", "listTasks"],
createInitiativeTask: ["listTasks", "listInitiativeTasks"],
createChildTasks: ["listTasks", "listInitiativeTasks", "listPhaseTasks"],
queueTask: ["listTasks", "listInitiativeTasks", "listPhaseTasks"],
approveTask: ["listTasks", "listInitiativeTasks", "listPhaseTasks", "listPendingApprovals"],
// --- Proposals ---
acceptProposal: ["listProposals", "listPages", "getPage", "listAgents", "listPhases", "listTasks"],
acceptAllProposals: ["listProposals", "listPages", "getPage", "listAgents", "listPhases", "listTasks"],
dismissAllProposals: ["listProposals", "listAgents"],
// --- Pages ---
updatePage: ["listPages", "getPage", "getRootPage"],
createPage: ["listPages", "getRootPage"],
deletePage: ["listPages", "getRootPage"],
// --- Projects ---
registerProject: ["listProjects"],
// --- Accounts ---
addAccount: ["listAccounts"],
removeAccount: ["listAccounts"],
refreshAccounts: ["listAccounts"],
markAccountExhausted: ["listAccounts"],
};
/**
* Extract the tRPC procedure name from a mutation key.
*
* tRPC v11 React Query keys look like: [["procedureName"], { type: "mutation" }]
* We want just "procedureName".
*/
function extractProcedureName(mutationKey: unknown): MutationName | null {
if (!Array.isArray(mutationKey)) return null;
const first = mutationKey[0];
if (Array.isArray(first) && typeof first[0] === "string") {
return first[0] as MutationName;
}
return null;
}
/**
* Creates a MutationCache with a global onSuccess handler that automatically
* invalidates the relevant queries for each tRPC mutation.
*/
export function createMutationCache(queryClient: QueryClient): MutationCache {
return new MutationCache({
onSuccess: (_data, _variables, _context, mutation) => {
const name = extractProcedureName(mutation.options.mutationKey);
if (!name) return;
const queriesToInvalidate = INVALIDATION_MAP[name];
if (!queriesToInvalidate) return;
for (const queryName of queriesToInvalidate) {
void queryClient.invalidateQueries({
queryKey: [[queryName]],
});
}
},
});
}

View File

@@ -0,0 +1,168 @@
export 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 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 ? "..." : ""}`;
}
return `${name}: ${JSON.stringify(input, null, 2)}`;
}
export 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";
}
}
export function parseAgentOutput(raw: string): ParsedMessage[] {
const lines = raw.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 },
});
}
}
return parsedMessages;
}

View File

@@ -2,18 +2,27 @@ import React, { useState } from 'react';
import ReactDOM from 'react-dom/client'; import ReactDOM from 'react-dom/client';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { trpc, createTRPCClient } from './lib/trpc'; import { trpc, createTRPCClient } from './lib/trpc';
import { createMutationCache } from './lib/invalidation';
import App from './App'; import App from './App';
import './index.css'; import './index.css';
function Root() { function Root() {
const [queryClient] = useState(() => new QueryClient({ const [queryClient] = useState(() => {
defaultOptions: { // We need a reference to the QueryClient inside createMutationCache,
queries: { // but it doesn't exist yet. Create a lazy holder.
refetchOnWindowFocus: false, let qc: QueryClient;
retry: 1, const mutationCache = createMutationCache(() => qc);
qc = new QueryClient({
mutationCache,
defaultOptions: {
queries: {
refetchOnWindowFocus: false,
retry: 1,
},
}, },
}, });
})); return qc;
});
const [trpcClient] = useState(createTRPCClient); const [trpcClient] = useState(createTRPCClient);
return ( return (

View File

@@ -1,5 +1,5 @@
import { useState } from "react"; import { useState } from "react";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { AlertCircle, RefreshCw } from "lucide-react"; import { AlertCircle, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
@@ -8,62 +8,99 @@ import { Skeleton } from "@/components/Skeleton";
import { toast } from "sonner"; import { toast } from "sonner";
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
import { AgentOutputViewer } from "@/components/AgentOutputViewer"; import { AgentOutputViewer } from "@/components/AgentOutputViewer";
import { AgentActions } from "@/components/AgentActions";
import { formatRelativeTime } from "@/lib/utils"; import { formatRelativeTime } from "@/lib/utils";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useSubscriptionWithErrorHandling } from "@/hooks"; import { StatusDot } from "@/components/StatusDot";
import { useLiveUpdates } from "@/hooks";
export const Route = createFileRoute("/agents")({ export const Route = createFileRoute("/agents")({
component: AgentsPage, component: AgentsPage,
}); });
type StatusFilter = "all" | "running" | "questions" | "exited" | "dismissed";
function AgentsPage() { function AgentsPage() {
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null); const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
const [filter, setFilter] = useState<StatusFilter>("all");
const navigate = useNavigate();
// Live updates: invalidate agents on agent events with robust error handling // Live updates
const utils = trpc.useUtils(); const utils = trpc.useUtils();
const subscription = useSubscriptionWithErrorHandling( useLiveUpdates([
() => trpc.onAgentUpdate.useSubscription(undefined), { prefix: 'agent:', invalidate: ['listAgents'] },
{ ]);
onData: () => {
void utils.listAgents.invalidate();
},
onError: (error) => {
toast.error("Live updates disconnected. Refresh to reconnect.", {
id: "sub-error",
duration: Infinity,
});
console.error('Agent updates subscription error:', error);
},
onStarted: () => {
// Clear any existing error toasts when reconnecting
toast.dismiss("sub-error");
},
autoReconnect: true,
maxReconnectAttempts: 5,
}
);
// Data fetching // Data
const agentsQuery = trpc.listAgents.useQuery(); const agentsQuery = trpc.listAgents.useQuery();
// Mutations
const stopMutation = trpc.stopAgent.useMutation({
onSuccess: (data) => {
toast.success(`Stopped ${data.name}`);
},
onError: (err) => toast.error(`Failed to stop: ${err.message}`),
});
const deleteMutation = trpc.deleteAgent.useMutation({
onSuccess: (data) => {
if (selectedAgentId) {
const deleted = agents.find(
(a) => a.name === data.name || a.id === selectedAgentId
);
if (deleted) setSelectedAgentId(null);
}
toast.success(`Deleted ${data.name}`);
},
onError: (err) => toast.error(`Failed to delete: ${err.message}`),
});
const dismissMutation = trpc.dismissAgent.useMutation({
onSuccess: (data) => {
toast.success(`Dismissed ${data.name}`);
},
onError: (err) => toast.error(`Failed to dismiss: ${err.message}`),
});
// Handlers // Handlers
function handleRefresh() { function handleRefresh() {
void utils.listAgents.invalidate(); void utils.listAgents.invalidate();
} }
function handleStop(id: string) {
stopMutation.mutate({ id });
}
function handleDelete(id: string) {
if (!window.confirm("Delete this agent? This cannot be undone.")) return;
setSelectedAgentId((prev) => (prev === id ? null : prev));
deleteMutation.mutate({ id });
}
function handleDismiss(id: string) {
dismissMutation.mutate({ id });
}
function handleGoToInbox() {
void navigate({ to: "/inbox" });
}
// Loading state // Loading state
if (agentsQuery.isLoading) { if (agentsQuery.isLoading) {
return ( return (
<div className="space-y-4"> <div
<div className="flex items-center justify-between"> style={{ height: "calc(100vh - 7rem)" }}
className="flex flex-col gap-4"
>
<div className="flex items-center justify-between shrink-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Skeleton className="h-6 w-20" /> <Skeleton className="h-6 w-20" />
<Skeleton className="h-5 w-8 rounded-full" /> <Skeleton className="h-5 w-8 rounded-full" />
</div> </div>
<Skeleton className="h-8 w-20" /> <Skeleton className="h-8 w-20" />
</div> </div>
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[300px_1fr]"> <div className="grid grid-cols-1 gap-4 lg:grid-cols-[320px_1fr] min-h-0 flex-1">
<div className="space-y-2"> <div className="space-y-2 overflow-hidden">
{Array.from({ length: 5 }).map((_, i) => ( {Array.from({ length: 5 }).map((_, i) => (
<Card key={i} className="p-3"> <Card key={i} className="p-3">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
@@ -73,7 +110,7 @@ function AgentsPage() {
</Card> </Card>
))} ))}
</div> </div>
<Skeleton className="h-96 rounded-lg" /> <Skeleton className="h-full rounded-lg" />
</div> </div>
</div> </div>
); );
@@ -85,7 +122,8 @@ function AgentsPage() {
<div className="flex flex-col items-center justify-center gap-4 py-12"> <div className="flex flex-col items-center justify-center gap-4 py-12">
<AlertCircle className="h-8 w-8 text-destructive" /> <AlertCircle className="h-8 w-8 text-destructive" />
<p className="text-sm text-destructive"> <p className="text-sm text-destructive">
Failed to load agents: {agentsQuery.error?.message ?? "Unknown error"} Failed to load agents:{" "}
{agentsQuery.error?.message ?? "Unknown error"}
</p> </p>
<Button variant="outline" size="sm" onClick={handleRefresh}> <Button variant="outline" size="sm" onClick={handleRefresh}>
Retry Retry
@@ -99,30 +137,96 @@ function AgentsPage() {
? agents.find((a) => a.id === selectedAgentId) ? agents.find((a) => a.id === selectedAgentId)
: null; : null;
// Filter counts
const counts = {
all: agents.filter((a) => !a.userDismissedAt).length,
running: agents.filter((a) => a.status === "running").length,
questions: agents.filter((a) => a.status === "waiting_for_input").length,
exited: agents.filter((a) =>
["stopped", "crashed", "idle"].includes(a.status)
).length,
dismissed: agents.filter((a) => a.userDismissedAt).length,
};
// Filter + sort
const filtered = agents
.filter((agent) => {
switch (filter) {
case "all":
return !agent.userDismissedAt;
case "running":
return agent.status === "running";
case "questions":
return agent.status === "waiting_for_input";
case "exited":
return ["stopped", "crashed", "idle"].includes(agent.status);
case "dismissed":
return !!agent.userDismissedAt;
}
})
.sort(
(a, b) =>
new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
);
const filterOptions: { value: StatusFilter; label: string }[] = [
{ value: "all", label: "All" },
{ value: "running", label: "Running" },
{ value: "questions", label: "Questions" },
{ value: "exited", label: "Exited" },
{ value: "dismissed", label: "Dismissed" },
];
return ( return (
<div className="space-y-4"> <div
{/* Header */} style={{ height: "calc(100vh - 7rem)" }}
<div className="flex items-center justify-between"> className="flex flex-col gap-4"
<div className="flex items-center gap-2"> >
<h1 className="text-lg font-semibold">Agents</h1> {/* Header + Filters */}
<Badge variant="secondary">{agents.length}</Badge> <div className="shrink-0 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<h1 className="text-lg font-semibold">Agents</h1>
<Badge variant="secondary">{agents.length}</Badge>
</div>
<Button variant="outline" size="sm" onClick={handleRefresh}>
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
Refresh
</Button>
</div>
<div className="flex items-center gap-1">
{filterOptions.map((opt) => (
<Button
key={opt.value}
variant={filter === opt.value ? "default" : "outline"}
size="sm"
className="h-7 px-2 text-xs"
onClick={() => setFilter(opt.value)}
>
{opt.label}
<Badge
variant="secondary"
className="ml-1.5 h-4 min-w-4 px-1 text-[10px]"
>
{counts[opt.value]}
</Badge>
</Button>
))}
</div> </div>
<Button variant="outline" size="sm" onClick={handleRefresh}>
<RefreshCw className="mr-1.5 h-3.5 w-3.5" />
Refresh
</Button>
</div> </div>
{/* Two-column layout */} {/* Two-panel layout */}
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[300px_1fr]"> <div className="grid grid-cols-1 gap-4 lg:grid-cols-[320px_1fr] min-h-0 flex-1">
{/* Left: Agent List */} {/* Left: Agent List */}
<div className="space-y-2"> <div className="overflow-y-auto space-y-2">
{agents.length === 0 ? ( {filtered.length === 0 ? (
<div className="rounded-lg border border-dashed p-8 text-center"> <div className="rounded-lg border border-dashed p-8 text-center">
<p className="text-sm text-muted-foreground">No agents found</p> <p className="text-sm text-muted-foreground">
No agents match this filter
</p>
</div> </div>
) : ( ) : (
agents.map((agent) => ( filtered.map((agent) => (
<Card <Card
key={agent.id} key={agent.id}
className={cn( className={cn(
@@ -133,7 +237,7 @@ function AgentsPage() {
> >
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0"> <div className="flex items-center gap-2 min-w-0">
<StatusDot status={agent.status} /> <StatusDot status={agent.status} size="sm" />
<span className="truncate text-sm font-medium"> <span className="truncate text-sm font-medium">
{agent.name} {agent.name}
</span> </span>
@@ -145,10 +249,35 @@ function AgentsPage() {
<Badge variant="secondary" className="text-xs"> <Badge variant="secondary" className="text-xs">
{agent.mode} {agent.mode}
</Badge> </Badge>
{/* Action dropdown */}
<div onClick={(e) => e.stopPropagation()}>
<AgentActions
agentId={agent.id}
status={agent.status}
isDismissed={!!agent.userDismissedAt}
onStop={handleStop}
onDelete={handleDelete}
onDismiss={handleDismiss}
onGoToInbox={handleGoToInbox}
/>
</div>
</div> </div>
</div> </div>
<div className="mt-1 text-xs text-muted-foreground"> <div className="mt-1 flex items-center justify-between">
{formatRelativeTime(String(agent.createdAt))} <span className="text-xs text-muted-foreground">
{formatRelativeTime(String(agent.updatedAt))}
</span>
{agent.status === "waiting_for_input" && (
<span
className="text-xs text-yellow-600 hover:underline cursor-pointer"
onClick={(e) => {
e.stopPropagation();
handleGoToInbox();
}}
>
Answer questions &rarr;
</span>
)}
</div> </div>
</Card> </Card>
)) ))
@@ -156,41 +285,21 @@ function AgentsPage() {
</div> </div>
{/* Right: Output Viewer */} {/* Right: Output Viewer */}
{selectedAgent ? ( <div className="min-h-0">
<AgentOutputViewer agentId={selectedAgent.id} agentName={selectedAgent.name} /> {selectedAgent ? (
) : ( <AgentOutputViewer
<div className="flex items-center justify-center rounded-lg border border-dashed p-8"> agentId={selectedAgent.id}
<p className="text-sm text-muted-foreground"> agentName={selectedAgent.name}
Select an agent to view output />
</p> ) : (
</div> <div className="flex h-full items-center justify-center rounded-lg border border-dashed">
)} <p className="text-sm text-muted-foreground">
Select an agent to view output
</p>
</div>
)}
</div>
</div> </div>
</div> </div>
); );
} }
// ---------------------------------------------------------------------------
// Components
// ---------------------------------------------------------------------------
function StatusDot({ status }: { status: string }) {
const colors: Record<string, string> = {
running: "bg-green-500",
waiting_for_input: "bg-yellow-500",
idle: "bg-zinc-400",
stopped: "bg-zinc-400",
crashed: "bg-red-500",
};
return (
<span
className={cn("h-2 w-2 rounded-full shrink-0", colors[status] ?? "bg-zinc-400")}
title={status}
/>
);
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

View File

@@ -1,14 +1,14 @@
import { useState } from "react"; import { useState } from "react";
import { createFileRoute, Link } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { AlertCircle, ChevronLeft } from "lucide-react"; import { AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card"; import { Card } from "@/components/ui/card";
import { Skeleton } from "@/components/Skeleton"; import { Skeleton } from "@/components/Skeleton";
import { toast } from "sonner"; import { toast } from "sonner";
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
import { InboxList } from "@/components/InboxList"; import { InboxList } from "@/components/InboxList";
import { QuestionForm } from "@/components/QuestionForm"; import { InboxDetailPanel } from "@/components/InboxDetailPanel";
import { formatRelativeTime } from "@/lib/utils"; import { useLiveUpdates } from "@/hooks";
export const Route = createFileRoute("/inbox")({ export const Route = createFileRoute("/inbox")({
component: InboxPage, component: InboxPage,
@@ -17,20 +17,12 @@ export const Route = createFileRoute("/inbox")({
function InboxPage() { function InboxPage() {
const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null); const [selectedAgentId, setSelectedAgentId] = useState<string | null>(null);
// Live updates: invalidate inbox queries on agent events // Single SSE stream for live updates
useLiveUpdates([
{ prefix: 'agent:', invalidate: ['listWaitingAgents', 'listMessages'] },
]);
const utils = trpc.useUtils(); const utils = trpc.useUtils();
trpc.onAgentUpdate.useSubscription(undefined, {
onData: () => {
void utils.listWaitingAgents.invalidate();
void utils.listMessages.invalidate();
},
onError: () => {
toast.error("Live updates disconnected. Refresh to reconnect.", {
id: "sub-error",
duration: Infinity,
});
},
});
// Data fetching // Data fetching
const agentsQuery = trpc.listWaitingAgents.useQuery(); const agentsQuery = trpc.listWaitingAgents.useQuery();
@@ -43,8 +35,6 @@ function InboxPage() {
// Mutations // Mutations
const resumeAgentMutation = trpc.resumeAgent.useMutation({ const resumeAgentMutation = trpc.resumeAgent.useMutation({
onSuccess: () => { onSuccess: () => {
void utils.listWaitingAgents.invalidate();
void utils.listMessages.invalidate();
setSelectedAgentId(null); setSelectedAgentId(null);
toast.success("Answer submitted"); toast.success("Answer submitted");
}, },
@@ -53,10 +43,18 @@ function InboxPage() {
}, },
}); });
const dismissQuestionsMutation = trpc.stopAgent.useMutation({
onSuccess: () => {
setSelectedAgentId(null);
toast.success("Questions dismissed");
},
onError: () => {
toast.error("Failed to dismiss questions");
},
});
const respondToMessageMutation = trpc.respondToMessage.useMutation({ const respondToMessageMutation = trpc.respondToMessage.useMutation({
onSuccess: () => { onSuccess: () => {
void utils.listWaitingAgents.invalidate();
void utils.listMessages.invalidate();
setSelectedAgentId(null); setSelectedAgentId(null);
toast.success("Response sent"); toast.success("Response sent");
}, },
@@ -95,6 +93,11 @@ function InboxPage() {
resumeAgentMutation.mutate({ id: selectedAgentId, answers }); resumeAgentMutation.mutate({ id: selectedAgentId, answers });
} }
function handleDismissQuestions() {
if (!selectedAgentId) return;
dismissQuestionsMutation.mutate({ id: selectedAgentId });
}
function handleDismiss() { function handleDismiss() {
if (!selectedMessage) return; if (!selectedMessage) return;
respondToMessageMutation.mutate({ respondToMessageMutation.mutate({
@@ -175,7 +178,7 @@ function InboxPage() {
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_400px]"> <div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_400px]">
{/* Left: Inbox List hidden on mobile when agent selected */} {/* Left: Inbox List -- hidden on mobile when agent selected */}
<div className={selectedAgent ? "hidden lg:block" : undefined}> <div className={selectedAgent ? "hidden lg:block" : undefined}>
<InboxList <InboxList
agents={serializedAgents} agents={serializedAgents}
@@ -188,128 +191,55 @@ function InboxPage() {
{/* Right: Detail Panel */} {/* Right: Detail Panel */}
{selectedAgent && ( {selectedAgent && (
<div className="space-y-4 rounded-lg border border-border p-4"> <InboxDetailPanel
{/* Mobile back button */} agent={{
<Button id: selectedAgent.id,
variant="ghost" name: selectedAgent.name,
size="sm" status: selectedAgent.status,
className="lg:hidden" taskId: selectedAgent.taskId,
onClick={() => setSelectedAgentId(null)} updatedAt: String(selectedAgent.updatedAt),
> }}
<ChevronLeft className="mr-1 h-4 w-4" /> message={
Back to list selectedMessage
</Button> ? {
id: selectedMessage.id,
{/* Detail Header */} content: selectedMessage.content,
<div className="border-b border-border pb-3"> requiresResponse: selectedMessage.requiresResponse,
<div className="flex items-center justify-between"> }
<h3 className="text-sm font-bold"> : null
{selectedAgent.name}{" "} }
<span className="font-normal text-muted-foreground"> questions={
You pendingQuestions
</span> ? pendingQuestions.questions.map((q) => ({
</h3>
<span className="text-xs text-muted-foreground">
{formatRelativeTime(String(selectedAgent.updatedAt))}
</span>
</div>
<p className="mt-1 text-xs text-muted-foreground">
Task:{" "}
{selectedAgent.taskId ? (
<Link
to="/initiatives"
className="text-primary hover:underline"
>
{selectedAgent.taskId}
</Link>
) : (
"—"
)}
</p>
{selectedAgent.taskId && (
<Link
to="/initiatives"
className="mt-1 inline-block text-xs text-primary hover:underline"
>
View in context
</Link>
)}
</div>
{/* Question Form or Notification Content */}
{questionsQuery.isLoading && (
<div className="py-4 text-center text-sm text-muted-foreground">
Loading questions...
</div>
)}
{questionsQuery.isError && (
<div className="py-4 text-center text-sm text-destructive">
Failed to load questions: {questionsQuery.error.message}
</div>
)}
{pendingQuestions &&
pendingQuestions.questions.length > 0 && (
<QuestionForm
questions={pendingQuestions.questions.map((q) => ({
id: q.id, id: q.id,
question: q.question, question: q.question,
options: q.options, options: q.options,
multiSelect: q.multiSelect, multiSelect: q.multiSelect,
}))} }))
onSubmit={handleSubmitAnswers} : null
onCancel={() => setSelectedAgentId(null)} }
isSubmitting={resumeAgentMutation.isPending} isLoadingQuestions={questionsQuery.isLoading}
/> questionsError={
)} questionsQuery.isError ? questionsQuery.error.message : null
}
{resumeAgentMutation.isError && ( onBack={() => setSelectedAgentId(null)}
<p className="text-sm text-destructive"> onSubmitAnswers={handleSubmitAnswers}
Error: {resumeAgentMutation.error.message} onDismissQuestions={handleDismissQuestions}
</p> onDismissMessage={handleDismiss}
)} isSubmitting={resumeAgentMutation.isPending}
isDismissingQuestions={dismissQuestionsMutation.isPending}
{/* Notification message (no questions / requiresResponse=false) */} isDismissingMessage={respondToMessageMutation.isPending}
{selectedMessage && submitError={
!selectedMessage.requiresResponse && resumeAgentMutation.isError
!questionsQuery.isLoading && ( ? resumeAgentMutation.error.message
<div className="space-y-3"> : null
<p className="text-sm">{selectedMessage.content}</p> }
<div className="flex justify-end"> dismissMessageError={
<Button respondToMessageMutation.isError
variant="outline" ? respondToMessageMutation.error.message
size="sm" : null
onClick={handleDismiss} }
disabled={respondToMessageMutation.isPending} />
>
{respondToMessageMutation.isPending
? "Dismissing..."
: "Dismiss"}
</Button>
</div>
{respondToMessageMutation.isError && (
<p className="text-sm text-destructive">
Error: {respondToMessageMutation.error.message}
</p>
)}
</div>
)}
{/* No questions and requires response — message content only */}
{selectedMessage &&
selectedMessage.requiresResponse &&
pendingQuestions &&
pendingQuestions.questions.length === 0 &&
!questionsQuery.isLoading && (
<div className="space-y-3">
<p className="text-sm">{selectedMessage.content}</p>
<p className="text-xs text-muted-foreground">
Waiting for structured questions...
</p>
</div>
)}
</div>
)} )}
{/* Empty detail panel placeholder */} {/* Empty detail panel placeholder */}
@@ -324,8 +254,3 @@ function InboxPage() {
</div> </div>
); );
} }
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------

View File

@@ -1,98 +1,42 @@
import { useState } from "react";
import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { AlertCircle } from "lucide-react"; import { AlertCircle } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/Skeleton"; import { Skeleton } from "@/components/Skeleton";
import { toast } from "sonner";
import { trpc } from "@/lib/trpc"; import { trpc } from "@/lib/trpc";
import { InitiativeHeader } from "@/components/InitiativeHeader"; import { InitiativeHeader } from "@/components/InitiativeHeader";
import { ContentTab } from "@/components/editor/ContentTab"; import { ContentTab } from "@/components/editor/ContentTab";
import { ExecutionTab } from "@/components/ExecutionTab"; import { ExecutionTab } from "@/components/ExecutionTab";
import { useSubscriptionWithErrorHandling } from "@/hooks"; import { ReviewTab } from "@/components/review";
import { PipelineTab } from "@/components/pipeline";
import { useLiveUpdates } from "@/hooks";
type Tab = "content" | "breakdown" | "execution" | "review";
const TABS: Tab[] = ["content", "breakdown", "execution", "review"];
export const Route = createFileRoute("/initiatives/$id")({ export const Route = createFileRoute("/initiatives/$id")({
component: InitiativeDetailPage, component: InitiativeDetailPage,
validateSearch: (search: Record<string, unknown>): { tab: Tab } => ({
tab: TABS.includes(search.tab as Tab) ? (search.tab as Tab) : "content",
}),
}); });
type Tab = "content" | "execution";
function InitiativeDetailPage() { function InitiativeDetailPage() {
const { id } = Route.useParams(); const { id } = Route.useParams();
const { tab: activeTab } = Route.useSearch();
const navigate = useNavigate(); const navigate = useNavigate();
const [activeTab, setActiveTab] = useState<Tab>("content");
// Live updates: keep subscriptions at page level so they work across both tabs // Single SSE stream for all live updates
const utils = trpc.useUtils(); useLiveUpdates([
{ prefix: 'task:', invalidate: ['listPhases', 'listTasks', 'listInitiativeTasks'] },
// Task updates subscription with robust error handling { prefix: 'phase:', invalidate: ['listPhases', 'listTasks', 'listInitiativePhaseDependencies'] },
useSubscriptionWithErrorHandling( { prefix: 'agent:', invalidate: ['listAgents'] },
() => trpc.onTaskUpdate.useSubscription(undefined), { prefix: 'page:', invalidate: ['listPages', 'getPage', 'getRootPage'] },
{ ]);
onData: () => {
void utils.listPhases.invalidate();
void utils.listTasks.invalidate();
},
onError: (error) => {
toast.error("Live updates disconnected. Refresh to reconnect.", {
id: "sub-error",
duration: Infinity,
});
console.error('Task updates subscription error:', error);
},
onStarted: () => toast.dismiss("sub-error"),
autoReconnect: true,
maxReconnectAttempts: 5,
}
);
// Agent updates subscription with robust error handling
useSubscriptionWithErrorHandling(
() => trpc.onAgentUpdate.useSubscription(undefined),
{
onData: () => {
void utils.listAgents.invalidate();
},
onError: (error) => {
toast.error("Live updates disconnected. Refresh to reconnect.", {
id: "sub-error",
duration: Infinity,
});
console.error('Agent updates subscription error:', error);
},
onStarted: () => toast.dismiss("sub-error"),
autoReconnect: true,
maxReconnectAttempts: 5,
}
);
// Page updates subscription with robust error handling
useSubscriptionWithErrorHandling(
() => trpc.onPageUpdate.useSubscription(undefined),
{
onData: () => {
void utils.listPages.invalidate();
void utils.getPage.invalidate();
void utils.getRootPage.invalidate();
},
onError: (error) => {
toast.error("Live updates disconnected. Refresh to reconnect.", {
id: "sub-error",
duration: Infinity,
});
console.error('Page updates subscription error:', error);
},
onStarted: () => toast.dismiss("sub-error"),
autoReconnect: true,
maxReconnectAttempts: 5,
}
);
// tRPC queries // tRPC queries
const initiativeQuery = trpc.getInitiative.useQuery({ id }); const initiativeQuery = trpc.getInitiative.useQuery({ id });
const phasesQuery = trpc.listPhases.useQuery( const phasesQuery = trpc.listPhases.useQuery({ initiativeId: id });
{ initiativeId: id }, const depsQuery = trpc.listInitiativePhaseDependencies.useQuery({ initiativeId: id });
{ enabled: !!initiativeQuery.data },
);
// Loading state // Loading state
if (initiativeQuery.isLoading) { if (initiativeQuery.isLoading) {
@@ -162,38 +106,45 @@ function InitiativeDetailPage() {
{/* Tab bar */} {/* Tab bar */}
<div className="flex gap-1 border-b border-border"> <div className="flex gap-1 border-b border-border">
<button {TABS.map((tab) => (
onClick={() => setActiveTab("content")} <button
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${ key={tab}
activeTab === "content" onClick={() =>
? "border-primary text-foreground" navigate({
: "border-transparent text-muted-foreground hover:text-foreground" search: { tab },
}`} replace: true,
> })
Content }
</button> className={`px-4 py-2 text-sm font-medium capitalize border-b-2 transition-colors ${
<button activeTab === tab
onClick={() => setActiveTab("execution")} ? "border-primary text-foreground"
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${ : "border-transparent text-muted-foreground hover:text-foreground"
activeTab === "execution" }`}
? "border-primary text-foreground" >
: "border-transparent text-muted-foreground hover:text-foreground" {tab}
}`} </button>
> ))}
Execution
</button>
</div> </div>
{/* Tab content */} {/* Tab content */}
{activeTab === "content" && <ContentTab initiativeId={id} initiativeName={initiative.name} />} {activeTab === "content" && <ContentTab initiativeId={id} initiativeName={initiative.name} />}
{activeTab === "execution" && ( {activeTab === "breakdown" && (
<ExecutionTab <ExecutionTab
initiativeId={id} initiativeId={id}
phases={phases} phases={phases}
phasesLoading={phasesQuery.isLoading} phasesLoading={phasesQuery.isLoading}
phasesLoaded={phasesQuery.isSuccess} phasesLoaded={phasesQuery.isSuccess}
dependencyEdges={depsQuery.data ?? []}
/> />
)} )}
{activeTab === "execution" && (
<PipelineTab
initiativeId={id}
phases={phases}
phasesLoading={phasesQuery.isLoading}
/>
)}
{activeTab === "review" && <ReviewTab initiativeId={id} />}
</div> </div>
); );
} }

View File

@@ -2,10 +2,9 @@ import { useState } from "react";
import { createFileRoute, useNavigate } from "@tanstack/react-router"; import { createFileRoute, useNavigate } from "@tanstack/react-router";
import { Plus } from "lucide-react"; import { Plus } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { toast } from "sonner";
import { trpc } from "@/lib/trpc";
import { InitiativeList } from "@/components/InitiativeList"; import { InitiativeList } from "@/components/InitiativeList";
import { CreateInitiativeDialog } from "@/components/CreateInitiativeDialog"; import { CreateInitiativeDialog } from "@/components/CreateInitiativeDialog";
import { useLiveUpdates } from "@/hooks";
export const Route = createFileRoute("/initiatives/")({ export const Route = createFileRoute("/initiatives/")({
component: DashboardPage, component: DashboardPage,
@@ -25,20 +24,11 @@ function DashboardPage() {
const [statusFilter, setStatusFilter] = useState<StatusFilter>("all"); const [statusFilter, setStatusFilter] = useState<StatusFilter>("all");
const [createDialogOpen, setCreateDialogOpen] = useState(false); const [createDialogOpen, setCreateDialogOpen] = useState(false);
// Live updates: invalidate dashboard queries on task/phase events // Single SSE stream for live updates
const utils = trpc.useUtils(); useLiveUpdates([
trpc.onTaskUpdate.useSubscription(undefined, { { prefix: 'task:', invalidate: ['listInitiatives', 'listPhases'] },
onData: () => { { prefix: 'phase:', invalidate: ['listInitiatives', 'listPhases'] },
void utils.listInitiatives.invalidate(); ]);
void utils.listPhases.invalidate();
},
onError: () => {
toast.error("Live updates disconnected. Refresh to reconnect.", {
id: "sub-error",
duration: Infinity,
});
},
});
return ( return (
<div className="space-y-6"> <div className="space-y-6">

View File

@@ -2,24 +2,19 @@ import { createFileRoute } from '@tanstack/react-router'
import { import {
CheckCircle2, CheckCircle2,
XCircle, XCircle,
AlertTriangle,
RefreshCw, RefreshCw,
Server, Server,
} from 'lucide-react' } from 'lucide-react'
import { trpc } from '@/lib/trpc' import { trpc } from '@/lib/trpc'
import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card' import { Card, CardHeader, CardTitle, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Skeleton } from '@/components/Skeleton' import { Skeleton } from '@/components/Skeleton'
import { AccountCard } from '@/components/AccountCard'
export const Route = createFileRoute('/settings/health')({ export const Route = createFileRoute('/settings/health')({
component: HealthCheckPage, component: HealthCheckPage,
}) })
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
function formatUptime(seconds: number): string { function formatUptime(seconds: number): string {
const d = Math.floor(seconds / 86400) const d = Math.floor(seconds / 86400)
const h = Math.floor((seconds % 86400) / 3600) const h = Math.floor((seconds % 86400) / 3600)
@@ -34,73 +29,6 @@ function formatUptime(seconds: number): string {
return parts.join(' ') return parts.join(' ')
} }
function formatResetTime(isoDate: string): string {
const now = Date.now()
const target = new Date(isoDate).getTime()
const diffMs = target - now
if (diffMs <= 0) return 'now'
const totalMinutes = Math.floor(diffMs / 60_000)
const totalHours = Math.floor(totalMinutes / 60)
const totalDays = Math.floor(totalHours / 24)
if (totalDays > 0) {
const remainingHours = totalHours - totalDays * 24
return `in ${totalDays}d ${remainingHours}h`
}
const remainingMinutes = totalMinutes - totalHours * 60
return `in ${totalHours}h ${remainingMinutes}m`
}
function capitalize(s: string): string {
return s.charAt(0).toUpperCase() + s.slice(1)
}
// ---------------------------------------------------------------------------
// Usage bar
// ---------------------------------------------------------------------------
function UsageBar({
label,
utilization,
resetsAt,
}: {
label: string
utilization: number
resetsAt: string | null
}) {
const color =
utilization >= 90
? 'bg-destructive'
: utilization >= 70
? 'bg-yellow-500'
: 'bg-green-500'
const resetText = resetsAt ? formatResetTime(resetsAt) : null
return (
<div className="flex items-center gap-2 text-xs">
<span className="w-20 shrink-0 text-muted-foreground">{label}</span>
<div className="h-2 flex-1 rounded-full bg-muted">
<div
className={`h-2 rounded-full ${color}`}
style={{ width: `${Math.min(utilization, 100)}%` }}
/>
</div>
<span className="w-12 shrink-0 text-right">
{utilization.toFixed(0)}%
</span>
{resetText && (
<span className="shrink-0 text-muted-foreground">
resets {resetText}
</span>
)}
</div>
)
}
// ---------------------------------------------------------------------------
// Page component
// ---------------------------------------------------------------------------
function HealthCheckPage() { function HealthCheckPage() {
const healthQuery = trpc.systemHealthCheck.useQuery(undefined, { const healthQuery = trpc.systemHealthCheck.useQuery(undefined, {
refetchInterval: 30_000, refetchInterval: 30_000,
@@ -242,142 +170,3 @@ function HealthCheckPage() {
</div> </div>
) )
} }
// ---------------------------------------------------------------------------
// Account card
// ---------------------------------------------------------------------------
type AccountData = {
id: string
email: string
provider: string
credentialsValid: boolean
tokenValid: boolean
tokenExpiresAt: string | null
subscriptionType: string | null
error: string | null
usage: {
five_hour: { utilization: number; resets_at: string | null } | null
seven_day: { utilization: number; resets_at: string | null } | null
seven_day_sonnet: { utilization: number; resets_at: string | null } | null
seven_day_opus: { utilization: number; resets_at: string | null } | null
extra_usage: {
is_enabled: boolean
monthly_limit: number | null
used_credits: number | null
utilization: number | null
} | null
} | null
isExhausted: boolean
exhaustedUntil: string | null
lastUsedAt: string | null
agentCount: number
activeAgentCount: number
}
function AccountCard({ account }: { account: AccountData }) {
const statusIcon = !account.credentialsValid ? (
<XCircle className="h-5 w-5 shrink-0 text-destructive" />
) : account.isExhausted ? (
<AlertTriangle className="h-5 w-5 shrink-0 text-yellow-500" />
) : (
<CheckCircle2 className="h-5 w-5 shrink-0 text-green-500" />
)
const statusText = !account.credentialsValid
? 'Invalid credentials'
: account.isExhausted
? `Exhausted until ${account.exhaustedUntil ? new Date(account.exhaustedUntil).toLocaleTimeString() : 'unknown'}`
: 'Available'
const usage = account.usage
return (
<Card>
<CardContent className="space-y-3 py-4">
{/* Header row */}
<div className="flex items-start gap-3">
{statusIcon}
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-2">
<span className="text-sm font-medium">{account.email}</span>
<Badge variant="outline">{account.provider}</Badge>
{account.subscriptionType && (
<Badge variant="secondary">
{capitalize(account.subscriptionType)}
</Badge>
)}
</div>
<div className="mt-1 flex items-center gap-3 text-xs text-muted-foreground">
<span>
{account.agentCount} agent{account.agentCount !== 1 ? 's' : ''}{' '}
({account.activeAgentCount} active)
</span>
<span>{statusText}</span>
</div>
</div>
</div>
{/* Usage bars */}
{usage && (
<div className="space-y-1.5 pl-8">
{usage.five_hour && (
<UsageBar
label="Session (5h)"
utilization={usage.five_hour.utilization}
resetsAt={usage.five_hour.resets_at}
/>
)}
{usage.seven_day && (
<UsageBar
label="Weekly (7d)"
utilization={usage.seven_day.utilization}
resetsAt={usage.seven_day.resets_at}
/>
)}
{usage.seven_day_sonnet &&
usage.seven_day_sonnet.utilization > 0 && (
<UsageBar
label="Sonnet (7d)"
utilization={usage.seven_day_sonnet.utilization}
resetsAt={usage.seven_day_sonnet.resets_at}
/>
)}
{usage.seven_day_opus && usage.seven_day_opus.utilization > 0 && (
<UsageBar
label="Opus (7d)"
utilization={usage.seven_day_opus.utilization}
resetsAt={usage.seven_day_opus.resets_at}
/>
)}
{usage.extra_usage && usage.extra_usage.is_enabled && (
<div className="flex items-center gap-2 text-xs">
<span className="w-20 shrink-0 text-muted-foreground">
Extra usage
</span>
<span>
${((usage.extra_usage.used_credits ?? 0) / 100).toFixed(2)}{' '}
used
{usage.extra_usage.monthly_limit != null && (
<>
{' '}
/ ${(usage.extra_usage.monthly_limit / 100).toFixed(
2
)}{' '}
limit
</>
)}
</span>
</div>
)}
</div>
)}
{/* Error message */}
{account.error && (
<p className="pl-8 text-xs text-destructive">{account.error}</p>
)}
</CardContent>
</Card>
)
}

View File

@@ -1 +1 @@
{"root":["./src/app.tsx","./src/main.tsx","./src/routetree.gen.ts","./src/router.tsx","./src/vite-env.d.ts","./src/components/actionmenu.tsx","./src/components/agentoutputviewer.tsx","./src/components/createinitiativedialog.tsx","./src/components/decisionlist.tsx","./src/components/dependencyindicator.tsx","./src/components/errorboundary.tsx","./src/components/executiontab.tsx","./src/components/freetextinput.tsx","./src/components/inboxlist.tsx","./src/components/initiativecard.tsx","./src/components/initiativeheader.tsx","./src/components/initiativelist.tsx","./src/components/messagecard.tsx","./src/components/optiongroup.tsx","./src/components/phaseaccordion.tsx","./src/components/progressbar.tsx","./src/components/progresspanel.tsx","./src/components/projectpicker.tsx","./src/components/questionform.tsx","./src/components/refinespawndialog.tsx","./src/components/registerprojectdialog.tsx","./src/components/skeleton.tsx","./src/components/spawnarchitectdropdown.tsx","./src/components/statusbadge.tsx","./src/components/statusdot.tsx","./src/components/taskdetailmodal.tsx","./src/components/taskrow.tsx","./src/components/editor/blockselectionextension.ts","./src/components/editor/contentproposalreview.tsx","./src/components/editor/contenttab.tsx","./src/components/editor/pagebreadcrumb.tsx","./src/components/editor/pagelinkextension.tsx","./src/components/editor/pagetitlecontext.tsx","./src/components/editor/pagetree.tsx","./src/components/editor/refineagentpanel.tsx","./src/components/editor/slashcommandlist.tsx","./src/components/editor/slashcommands.ts","./src/components/editor/tiptapeditor.tsx","./src/components/editor/slash-command-items.ts","./src/components/execution/breakdownsection.tsx","./src/components/execution/executioncontext.tsx","./src/components/execution/phaseactions.tsx","./src/components/execution/phasewithtasks.tsx","./src/components/execution/phaseslist.tsx","./src/components/execution/plantasksfetcher.tsx","./src/components/execution/progresssidebar.tsx","./src/components/execution/taskmodal.tsx","./src/components/execution/index.ts","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/sonner.tsx","./src/components/ui/textarea.tsx","./src/hooks/index.ts","./src/hooks/useautosave.ts","./src/hooks/usedebounce.ts","./src/hooks/userefineagent.ts","./src/hooks/usespawnmutation.ts","./src/hooks/usesubscriptionwitherrorhandling.ts","./src/layouts/applayout.tsx","./src/lib/markdown-to-tiptap.ts","./src/lib/trpc.ts","./src/lib/utils.ts","./src/routes/__root.tsx","./src/routes/agents.tsx","./src/routes/inbox.tsx","./src/routes/index.tsx","./src/routes/settings.tsx","./src/routes/initiatives/$id.tsx","./src/routes/initiatives/index.tsx","./src/routes/settings/health.tsx","./src/routes/settings/index.tsx"],"errors":true,"version":"5.9.3"} {"root":["./src/app.tsx","./src/main.tsx","./src/routetree.gen.ts","./src/router.tsx","./src/vite-env.d.ts","./src/components/accountcard.tsx","./src/components/actionmenu.tsx","./src/components/agentactions.tsx","./src/components/agentoutputviewer.tsx","./src/components/createinitiativedialog.tsx","./src/components/decisionlist.tsx","./src/components/dependencyindicator.tsx","./src/components/errorboundary.tsx","./src/components/executiontab.tsx","./src/components/freetextinput.tsx","./src/components/inboxdetailpanel.tsx","./src/components/inboxlist.tsx","./src/components/initiativecard.tsx","./src/components/initiativeheader.tsx","./src/components/initiativelist.tsx","./src/components/messagecard.tsx","./src/components/optiongroup.tsx","./src/components/phaseaccordion.tsx","./src/components/progressbar.tsx","./src/components/progresspanel.tsx","./src/components/projectpicker.tsx","./src/components/questionform.tsx","./src/components/refinespawndialog.tsx","./src/components/registerprojectdialog.tsx","./src/components/skeleton.tsx","./src/components/spawnarchitectdropdown.tsx","./src/components/statusbadge.tsx","./src/components/statusdot.tsx","./src/components/taskdetailmodal.tsx","./src/components/taskrow.tsx","./src/components/editor/blockdraghandle.tsx","./src/components/editor/blockselectionextension.ts","./src/components/editor/contentproposalreview.tsx","./src/components/editor/contenttab.tsx","./src/components/editor/deletesubpagedialog.tsx","./src/components/editor/pagebreadcrumb.tsx","./src/components/editor/pagelinkdeletiondetector.ts","./src/components/editor/pagelinkextension.tsx","./src/components/editor/pagetitlecontext.tsx","./src/components/editor/pagetree.tsx","./src/components/editor/phasecontenteditor.tsx","./src/components/editor/refineagentpanel.tsx","./src/components/editor/slashcommandlist.tsx","./src/components/editor/slashcommands.ts","./src/components/editor/tiptapeditor.tsx","./src/components/editor/slash-command-items.ts","./src/components/execution/breakdownsection.tsx","./src/components/execution/executioncontext.tsx","./src/components/execution/phaseactions.tsx","./src/components/execution/phasedetailpanel.tsx","./src/components/execution/phasesidebaritem.tsx","./src/components/execution/phasewithtasks.tsx","./src/components/execution/phaseslist.tsx","./src/components/execution/progresssidebar.tsx","./src/components/execution/taskmodal.tsx","./src/components/execution/index.ts","./src/components/pipeline/pipelinegraph.tsx","./src/components/pipeline/pipelinephasegroup.tsx","./src/components/pipeline/pipelinestagecolumn.tsx","./src/components/pipeline/pipelinetab.tsx","./src/components/pipeline/pipelinetaskcard.tsx","./src/components/pipeline/index.ts","./src/components/review/commentform.tsx","./src/components/review/commentthread.tsx","./src/components/review/diffviewer.tsx","./src/components/review/filecard.tsx","./src/components/review/hunkrows.tsx","./src/components/review/linewithcomments.tsx","./src/components/review/reviewsidebar.tsx","./src/components/review/reviewtab.tsx","./src/components/review/dummy-data.ts","./src/components/review/index.ts","./src/components/review/parse-diff.ts","./src/components/review/types.ts","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/card.tsx","./src/components/ui/dialog.tsx","./src/components/ui/dropdown-menu.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/components/ui/sonner.tsx","./src/components/ui/textarea.tsx","./src/hooks/index.ts","./src/hooks/useautosave.ts","./src/hooks/usedebounce.ts","./src/hooks/useliveupdates.ts","./src/hooks/useoptimisticmutation.ts","./src/hooks/usephaseautosave.ts","./src/hooks/userefineagent.ts","./src/hooks/usespawnmutation.ts","./src/hooks/usesubscriptionwitherrorhandling.ts","./src/layouts/applayout.tsx","./src/lib/_type-check-temp.ts","./src/lib/invalidation.ts","./src/lib/markdown-to-tiptap.ts","./src/lib/parse-agent-output.ts","./src/lib/trpc.ts","./src/lib/utils.ts","./src/routes/__root.tsx","./src/routes/agents.tsx","./src/routes/inbox.tsx","./src/routes/index.tsx","./src/routes/settings.tsx","./src/routes/initiatives/$id.tsx","./src/routes/initiatives/index.tsx","./src/routes/settings/health.tsx","./src/routes/settings/index.tsx"],"errors":true,"version":"5.9.3"}