Add userDismissedAt field to agents schema
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
import { useState, useCallback, useEffect } from "react";
|
||||
import { useState } from "react";
|
||||
import { createFileRoute, useNavigate } from "@tanstack/react-router";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
@@ -6,247 +6,87 @@ import { Skeleton } from "@/components/Skeleton";
|
||||
import { toast } from "sonner";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import { InitiativeHeader } from "@/components/InitiativeHeader";
|
||||
import { ProgressPanel } from "@/components/ProgressPanel";
|
||||
import { PhaseAccordion } from "@/components/PhaseAccordion";
|
||||
import { DecisionList } from "@/components/DecisionList";
|
||||
import { TaskDetailModal } from "@/components/TaskDetailModal";
|
||||
import type { SerializedTask } from "@/components/TaskRow";
|
||||
import { ContentTab } from "@/components/editor/ContentTab";
|
||||
import { ExecutionTab } from "@/components/ExecutionTab";
|
||||
import { useSubscriptionWithErrorHandling } from "@/hooks";
|
||||
|
||||
export const Route = createFileRoute("/initiatives/$id")({
|
||||
component: InitiativeDetailPage,
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Aggregated task counts reported upward from PhaseWithTasks */
|
||||
interface TaskCounts {
|
||||
complete: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
/** Flat task entry with metadata needed for the modal */
|
||||
interface FlatTaskEntry {
|
||||
task: SerializedTask;
|
||||
phaseName: string;
|
||||
agentName: string | null;
|
||||
blockedBy: Array<{ name: string; status: string }>;
|
||||
dependents: Array<{ name: string; status: string }>;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PhaseWithTasks — solves the "hooks inside loops" problem
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface PhaseWithTasksProps {
|
||||
phase: {
|
||||
id: string;
|
||||
initiativeId: string;
|
||||
number: number;
|
||||
name: string;
|
||||
description: string | null;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
defaultExpanded: boolean;
|
||||
onTaskClick: (taskId: string) => void;
|
||||
onTaskCounts: (phaseId: string, counts: TaskCounts) => void;
|
||||
registerTasks: (phaseId: string, entries: FlatTaskEntry[]) => void;
|
||||
}
|
||||
|
||||
function PhaseWithTasks({
|
||||
phase,
|
||||
defaultExpanded,
|
||||
onTaskClick,
|
||||
onTaskCounts,
|
||||
registerTasks,
|
||||
}: PhaseWithTasksProps) {
|
||||
// Fetch all plans for this phase
|
||||
const plansQuery = trpc.listPlans.useQuery({ phaseId: phase.id });
|
||||
|
||||
// Fetch phase dependencies
|
||||
const depsQuery = trpc.getPhaseDependencies.useQuery({ phaseId: phase.id });
|
||||
|
||||
const plans = plansQuery.data ?? [];
|
||||
const planIds = plans.map((p) => p.id);
|
||||
|
||||
return (
|
||||
<PhaseWithTasksInner
|
||||
phase={phase}
|
||||
planIds={planIds}
|
||||
plansLoaded={plansQuery.isSuccess}
|
||||
phaseDependencyIds={depsQuery.data?.dependencies ?? []}
|
||||
defaultExpanded={defaultExpanded}
|
||||
onTaskClick={onTaskClick}
|
||||
onTaskCounts={onTaskCounts}
|
||||
registerTasks={registerTasks}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Inner component that fetches tasks for each plan — needs stable hook count
|
||||
// Since planIds array changes, we fetch tasks per plan inside yet another child
|
||||
interface PhaseWithTasksInnerProps {
|
||||
phase: PhaseWithTasksProps["phase"];
|
||||
planIds: string[];
|
||||
plansLoaded: boolean;
|
||||
phaseDependencyIds: string[];
|
||||
defaultExpanded: boolean;
|
||||
onTaskClick: (taskId: string) => void;
|
||||
onTaskCounts: (phaseId: string, counts: TaskCounts) => void;
|
||||
registerTasks: (phaseId: string, entries: FlatTaskEntry[]) => void;
|
||||
}
|
||||
|
||||
function PhaseWithTasksInner({
|
||||
phase,
|
||||
planIds,
|
||||
plansLoaded,
|
||||
phaseDependencyIds: _phaseDependencyIds,
|
||||
defaultExpanded,
|
||||
onTaskClick,
|
||||
onTaskCounts,
|
||||
registerTasks,
|
||||
}: PhaseWithTasksInnerProps) {
|
||||
// We can't call useQuery in a loop, so we render PlanTasksFetcher per plan
|
||||
// and aggregate the results
|
||||
const [planTasks, setPlanTasks] = useState<Record<string, SerializedTask[]>>(
|
||||
{},
|
||||
);
|
||||
|
||||
const handlePlanTasks = useCallback(
|
||||
(planId: string, tasks: SerializedTask[]) => {
|
||||
setPlanTasks((prev) => {
|
||||
// Skip if unchanged (same reference)
|
||||
if (prev[planId] === tasks) return prev;
|
||||
const next = { ...prev, [planId]: tasks };
|
||||
|
||||
// Aggregate all tasks across plans
|
||||
const allTasks = Object.values(next).flat();
|
||||
const complete = allTasks.filter(
|
||||
(t) => t.status === "completed",
|
||||
).length;
|
||||
|
||||
// Report counts up
|
||||
onTaskCounts(phase.id, { complete, total: allTasks.length });
|
||||
|
||||
// Register flat entries for the modal lookup
|
||||
const entries: FlatTaskEntry[] = allTasks.map((task) => ({
|
||||
task,
|
||||
phaseName: `Phase ${phase.number}: ${phase.name}`,
|
||||
agentName: null, // No agent info from task data alone
|
||||
blockedBy: [], // Simplified: no dependency lookup per task in v1
|
||||
dependents: [],
|
||||
}));
|
||||
registerTasks(phase.id, entries);
|
||||
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[phase.id, phase.number, phase.name, onTaskCounts, registerTasks],
|
||||
);
|
||||
|
||||
// Build task entries for PhaseAccordion
|
||||
const allTasks = planIds.flatMap((pid) => planTasks[pid] ?? []);
|
||||
const taskEntries = allTasks.map((task) => ({
|
||||
task,
|
||||
agentName: null as string | null,
|
||||
blockedBy: [] as Array<{ name: string; status: string }>,
|
||||
}));
|
||||
|
||||
// Phase-level dependencies (empty for now — would need to resolve IDs to names)
|
||||
const phaseDeps: Array<{ name: string; status: string }> = [];
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Hidden fetchers — one per plan */}
|
||||
{plansLoaded &&
|
||||
planIds.map((planId) => (
|
||||
<PlanTasksFetcher
|
||||
key={planId}
|
||||
planId={planId}
|
||||
onTasks={handlePlanTasks}
|
||||
/>
|
||||
))}
|
||||
|
||||
<PhaseAccordion
|
||||
phase={phase}
|
||||
tasks={taskEntries}
|
||||
defaultExpanded={defaultExpanded}
|
||||
phaseDependencies={phaseDeps}
|
||||
onTaskClick={onTaskClick}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// PlanTasksFetcher — fetches tasks for a single plan (stable hook count)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
interface PlanTasksFetcherProps {
|
||||
planId: string;
|
||||
onTasks: (planId: string, tasks: SerializedTask[]) => void;
|
||||
}
|
||||
|
||||
function PlanTasksFetcher({ planId, onTasks }: PlanTasksFetcherProps) {
|
||||
const tasksQuery = trpc.listTasks.useQuery({ planId });
|
||||
|
||||
// Report tasks upward via useEffect (not during render) to avoid
|
||||
// setState-during-render loops when the parent re-renders on state update.
|
||||
useEffect(() => {
|
||||
if (tasksQuery.data) {
|
||||
onTasks(planId, tasksQuery.data as unknown as SerializedTask[]);
|
||||
}
|
||||
}, [tasksQuery.data, planId, onTasks]);
|
||||
|
||||
return null; // Render nothing — this is a data-fetching component
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main Page Component
|
||||
// ---------------------------------------------------------------------------
|
||||
type Tab = "content" | "execution";
|
||||
|
||||
function InitiativeDetailPage() {
|
||||
const { id } = Route.useParams();
|
||||
const navigate = useNavigate();
|
||||
const [activeTab, setActiveTab] = useState<Tab>("content");
|
||||
|
||||
// Live updates: invalidate detail queries on task/phase and agent events
|
||||
// Live updates: keep subscriptions at page level so they work across both tabs
|
||||
const utils = trpc.useUtils();
|
||||
trpc.onTaskUpdate.useSubscription(undefined, {
|
||||
onData: () => {
|
||||
void utils.listPhases.invalidate();
|
||||
void utils.listTasks.invalidate();
|
||||
void utils.listPlans.invalidate();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Live updates disconnected. Refresh to reconnect.", {
|
||||
id: "sub-error",
|
||||
duration: Infinity,
|
||||
});
|
||||
},
|
||||
});
|
||||
trpc.onAgentUpdate.useSubscription(undefined, {
|
||||
onData: () => {
|
||||
void utils.listAgents.invalidate();
|
||||
},
|
||||
onError: () => {
|
||||
toast.error("Live updates disconnected. Refresh to reconnect.", {
|
||||
id: "sub-error",
|
||||
duration: Infinity,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
// State
|
||||
const [selectedTaskId, setSelectedTaskId] = useState<string | null>(null);
|
||||
const [taskCountsByPhase, setTaskCountsByPhase] = useState<
|
||||
Record<string, TaskCounts>
|
||||
>({});
|
||||
const [tasksByPhase, setTasksByPhase] = useState<
|
||||
Record<string, FlatTaskEntry[]>
|
||||
>({});
|
||||
// Task updates subscription with robust error handling
|
||||
useSubscriptionWithErrorHandling(
|
||||
() => trpc.onTaskUpdate.useSubscription(undefined),
|
||||
{
|
||||
onData: () => {
|
||||
void utils.listPhases.invalidate();
|
||||
void utils.listTasks.invalidate();
|
||||
void utils.listPlans.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Live updates disconnected. Refresh to reconnect.", {
|
||||
id: "sub-error",
|
||||
duration: Infinity,
|
||||
});
|
||||
console.error('Task updates subscription error:', error);
|
||||
},
|
||||
onStarted: () => toast.dismiss("sub-error"),
|
||||
autoReconnect: true,
|
||||
maxReconnectAttempts: 5,
|
||||
}
|
||||
);
|
||||
|
||||
// Agent updates subscription with robust error handling
|
||||
useSubscriptionWithErrorHandling(
|
||||
() => trpc.onAgentUpdate.useSubscription(undefined),
|
||||
{
|
||||
onData: () => {
|
||||
void utils.listAgents.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Live updates disconnected. Refresh to reconnect.", {
|
||||
id: "sub-error",
|
||||
duration: Infinity,
|
||||
});
|
||||
console.error('Agent updates subscription error:', error);
|
||||
},
|
||||
onStarted: () => toast.dismiss("sub-error"),
|
||||
autoReconnect: true,
|
||||
maxReconnectAttempts: 5,
|
||||
}
|
||||
);
|
||||
|
||||
// Page updates subscription with robust error handling
|
||||
useSubscriptionWithErrorHandling(
|
||||
() => trpc.onPageUpdate.useSubscription(undefined),
|
||||
{
|
||||
onData: () => {
|
||||
void utils.listPages.invalidate();
|
||||
void utils.getPage.invalidate();
|
||||
void utils.getRootPage.invalidate();
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error("Live updates disconnected. Refresh to reconnect.", {
|
||||
id: "sub-error",
|
||||
duration: Infinity,
|
||||
});
|
||||
console.error('Page updates subscription error:', error);
|
||||
},
|
||||
onStarted: () => toast.dismiss("sub-error"),
|
||||
autoReconnect: true,
|
||||
maxReconnectAttempts: 5,
|
||||
}
|
||||
);
|
||||
|
||||
// tRPC queries
|
||||
const initiativeQuery = trpc.getInitiative.useQuery({ id });
|
||||
@@ -255,94 +95,20 @@ function InitiativeDetailPage() {
|
||||
{ enabled: !!initiativeQuery.data },
|
||||
);
|
||||
|
||||
// tRPC mutations
|
||||
const queueTaskMutation = trpc.queueTask.useMutation();
|
||||
const queuePhaseMutation = trpc.queuePhase.useMutation();
|
||||
|
||||
// Callbacks for PhaseWithTasks
|
||||
const handleTaskCounts = useCallback(
|
||||
(phaseId: string, counts: TaskCounts) => {
|
||||
setTaskCountsByPhase((prev) => {
|
||||
if (
|
||||
prev[phaseId]?.complete === counts.complete &&
|
||||
prev[phaseId]?.total === counts.total
|
||||
) {
|
||||
return prev;
|
||||
}
|
||||
return { ...prev, [phaseId]: counts };
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleRegisterTasks = useCallback(
|
||||
(phaseId: string, entries: FlatTaskEntry[]) => {
|
||||
setTasksByPhase((prev) => {
|
||||
if (prev[phaseId] === entries) return prev;
|
||||
return { ...prev, [phaseId]: entries };
|
||||
});
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
// Derived data
|
||||
const phases = phasesQuery.data ?? [];
|
||||
const phasesComplete = phases.filter(
|
||||
(p) => p.status === "completed",
|
||||
).length;
|
||||
|
||||
const allTaskCounts = Object.values(taskCountsByPhase);
|
||||
const tasksComplete = allTaskCounts.reduce((s, c) => s + c.complete, 0);
|
||||
const tasksTotal = allTaskCounts.reduce((s, c) => s + c.total, 0);
|
||||
|
||||
// Find selected task across all phases
|
||||
const allFlatTasks = Object.values(tasksByPhase).flat();
|
||||
const selectedEntry = selectedTaskId
|
||||
? allFlatTasks.find((e) => e.task.id === selectedTaskId) ?? null
|
||||
: null;
|
||||
|
||||
// Determine which phase should be expanded by default (first non-completed)
|
||||
const firstIncompletePhaseIndex = phases.findIndex(
|
||||
(p) => p.status !== "completed",
|
||||
);
|
||||
|
||||
// Queue all pending phases
|
||||
const handleQueueAll = useCallback(() => {
|
||||
const pendingPhases = phases.filter((p) => p.status === "pending");
|
||||
for (const phase of pendingPhases) {
|
||||
queuePhaseMutation.mutate({ phaseId: phase.id });
|
||||
}
|
||||
}, [phases, queuePhaseMutation]);
|
||||
|
||||
// Queue a single task
|
||||
const handleQueueTask = useCallback(
|
||||
(taskId: string) => {
|
||||
queueTaskMutation.mutate({ taskId });
|
||||
setSelectedTaskId(null);
|
||||
},
|
||||
[queueTaskMutation],
|
||||
);
|
||||
|
||||
// Loading state
|
||||
if (initiativeQuery.isLoading) {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Header skeleton */}
|
||||
<div className="flex items-center gap-4">
|
||||
<Skeleton className="h-4 w-4" />
|
||||
<Skeleton className="h-7 w-64" />
|
||||
<Skeleton className="h-5 w-20" />
|
||||
</div>
|
||||
|
||||
{/* Two-column grid skeleton */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_340px]">
|
||||
{/* Left: phase accordion skeletons */}
|
||||
<div className="space-y-1">
|
||||
<Skeleton className="h-12 w-full rounded border" />
|
||||
<Skeleton className="h-12 w-full rounded border" />
|
||||
</div>
|
||||
|
||||
{/* Right: ProgressPanel + DecisionList skeletons */}
|
||||
<div className="space-y-6">
|
||||
<Skeleton className="h-24 w-full rounded" />
|
||||
<Skeleton className="h-20 w-full rounded" />
|
||||
@@ -376,109 +142,59 @@ function InitiativeDetailPage() {
|
||||
const initiative = initiativeQuery.data;
|
||||
if (!initiative) return null;
|
||||
|
||||
// tRPC serializes Date to string over JSON — cast to wire format
|
||||
const serializedInitiative = {
|
||||
id: initiative.id,
|
||||
name: initiative.name,
|
||||
status: initiative.status,
|
||||
createdAt: String(initiative.createdAt),
|
||||
updatedAt: String(initiative.updatedAt),
|
||||
};
|
||||
|
||||
const hasPendingPhases = phases.some((p) => p.status === "pending");
|
||||
const projects = (initiative as { projects?: Array<{ id: string; name: string; url: string }> }).projects;
|
||||
|
||||
const phases = phasesQuery.data ?? [];
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="space-y-3">
|
||||
{/* Header */}
|
||||
<InitiativeHeader
|
||||
initiative={serializedInitiative}
|
||||
projects={projects}
|
||||
onBack={() => navigate({ to: "/initiatives" })}
|
||||
/>
|
||||
|
||||
{/* Two-column layout */}
|
||||
<div className="grid grid-cols-1 gap-6 lg:grid-cols-[1fr_340px]">
|
||||
{/* Left column: Phases */}
|
||||
<div className="space-y-0">
|
||||
{/* Section header */}
|
||||
<div className="flex items-center justify-between border-b border-border pb-3">
|
||||
<h2 className="text-lg font-semibold">Phases</h2>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={!hasPendingPhases}
|
||||
onClick={handleQueueAll}
|
||||
>
|
||||
Queue All
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Phase loading */}
|
||||
{phasesQuery.isLoading && (
|
||||
<div className="space-y-1 pt-3">
|
||||
{Array.from({ length: 3 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-10 w-full" />
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Phases list */}
|
||||
{phasesQuery.isSuccess && phases.length === 0 && (
|
||||
<div className="py-8 text-center text-muted-foreground">
|
||||
No phases yet
|
||||
</div>
|
||||
)}
|
||||
|
||||
{phasesQuery.isSuccess &&
|
||||
phases.map((phase, idx) => {
|
||||
// tRPC serializes Date to string over JSON — cast to wire format
|
||||
const serializedPhase = {
|
||||
id: phase.id,
|
||||
initiativeId: phase.initiativeId,
|
||||
number: phase.number,
|
||||
name: phase.name,
|
||||
description: phase.description,
|
||||
status: phase.status,
|
||||
createdAt: String(phase.createdAt),
|
||||
updatedAt: String(phase.updatedAt),
|
||||
};
|
||||
|
||||
return (
|
||||
<PhaseWithTasks
|
||||
key={phase.id}
|
||||
phase={serializedPhase}
|
||||
defaultExpanded={idx === firstIncompletePhaseIndex}
|
||||
onTaskClick={setSelectedTaskId}
|
||||
onTaskCounts={handleTaskCounts}
|
||||
registerTasks={handleRegisterTasks}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Right column: Progress + Decisions */}
|
||||
<div className="space-y-6">
|
||||
<ProgressPanel
|
||||
phasesComplete={phasesComplete}
|
||||
phasesTotal={phases.length}
|
||||
tasksComplete={tasksComplete}
|
||||
tasksTotal={tasksTotal}
|
||||
/>
|
||||
|
||||
<DecisionList decisions={[]} />
|
||||
</div>
|
||||
{/* Tab bar */}
|
||||
<div className="flex gap-1 border-b border-border">
|
||||
<button
|
||||
onClick={() => setActiveTab("content")}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === "content"
|
||||
? "border-primary text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Content
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setActiveTab("execution")}
|
||||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
||||
activeTab === "execution"
|
||||
? "border-primary text-foreground"
|
||||
: "border-transparent text-muted-foreground hover:text-foreground"
|
||||
}`}
|
||||
>
|
||||
Execution
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Task Detail Modal */}
|
||||
<TaskDetailModal
|
||||
task={selectedEntry?.task ?? null}
|
||||
phaseName={selectedEntry?.phaseName ?? ""}
|
||||
agentName={selectedEntry?.agentName ?? null}
|
||||
dependencies={selectedEntry?.blockedBy ?? []}
|
||||
dependents={selectedEntry?.dependents ?? []}
|
||||
onClose={() => setSelectedTaskId(null)}
|
||||
onQueueTask={handleQueueTask}
|
||||
onStopTask={() => setSelectedTaskId(null)}
|
||||
/>
|
||||
{/* Tab content */}
|
||||
{activeTab === "content" && <ContentTab initiativeId={id} initiativeName={initiative.name} />}
|
||||
{activeTab === "execution" && (
|
||||
<ExecutionTab
|
||||
initiativeId={id}
|
||||
phases={phases}
|
||||
phasesLoading={phasesQuery.isLoading}
|
||||
phasesLoaded={phasesQuery.isSuccess}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user