feat: Show detailing status in initiative overview and phase sidebar
Add 'detailing' activity state derived from active detail agents (mode=detail, status running/waiting_for_input). Initiative cards show pulsing "Detailing" indicator. Phase sidebar items show spinner during active detailing and "Review changes" when the agent finishes.
This commit is contained in:
@@ -5,7 +5,17 @@
|
||||
import type { Initiative, Phase } from '../../db/schema.js';
|
||||
import type { InitiativeActivity, InitiativeActivityState } from '@codewalk-district/shared';
|
||||
|
||||
export function deriveInitiativeActivity(initiative: Initiative, phases: Phase[]): InitiativeActivity {
|
||||
export interface ActiveDetailAgent {
|
||||
initiativeId: string;
|
||||
mode: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export function deriveInitiativeActivity(
|
||||
initiative: Initiative,
|
||||
phases: Phase[],
|
||||
activeDetailAgents?: ActiveDetailAgent[],
|
||||
): InitiativeActivity {
|
||||
const phasesTotal = phases.length;
|
||||
const phasesCompleted = phases.filter(p => p.status === 'completed').length;
|
||||
const base = { phasesTotal, phasesCompleted };
|
||||
@@ -43,5 +53,15 @@ export function deriveInitiativeActivity(initiative: Initiative, phases: Phase[]
|
||||
return { ...base, state: 'ready', activePhase: { id: approved.id, name: approved.name } };
|
||||
}
|
||||
|
||||
// Check for active detail agents (detailing trumps planning)
|
||||
const detailing = activeDetailAgents?.some(
|
||||
a => a.initiativeId === initiative.id
|
||||
&& a.mode === 'detail'
|
||||
&& (a.status === 'running' || a.status === 'waiting_for_input'),
|
||||
);
|
||||
if (detailing) {
|
||||
return { ...base, state: 'detailing' };
|
||||
}
|
||||
|
||||
return { ...base, state: 'planning' };
|
||||
}
|
||||
|
||||
@@ -68,17 +68,27 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) {
|
||||
? await repo.findByStatus(input.status)
|
||||
: await repo.findAll();
|
||||
|
||||
// Fetch active detail agents once for all initiatives
|
||||
const allAgents = ctx.agentManager ? await ctx.agentManager.list() : [];
|
||||
const activeDetailAgents = allAgents
|
||||
.filter(a =>
|
||||
a.mode === 'detail'
|
||||
&& (a.status === 'running' || a.status === 'waiting_for_input')
|
||||
&& !a.userDismissedAt,
|
||||
)
|
||||
.map(a => ({ initiativeId: a.initiativeId ?? '', mode: a.mode ?? '', status: a.status }));
|
||||
|
||||
if (ctx.phaseRepository) {
|
||||
const phaseRepo = ctx.phaseRepository;
|
||||
return Promise.all(initiatives.map(async (init) => {
|
||||
const phases = await phaseRepo.findByInitiativeId(init.id);
|
||||
return { ...init, activity: deriveInitiativeActivity(init, phases) };
|
||||
return { ...init, activity: deriveInitiativeActivity(init, phases, activeDetailAgents) };
|
||||
}));
|
||||
}
|
||||
|
||||
return initiatives.map(init => ({
|
||||
...init,
|
||||
activity: deriveInitiativeActivity(init, []),
|
||||
activity: deriveInitiativeActivity(init, [], activeDetailAgents),
|
||||
}));
|
||||
}),
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ import { TRPCError } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import type { Task } from '../../db/schema.js';
|
||||
import type { ProcedureBuilder } from '../trpc.js';
|
||||
import { requirePhaseDispatchManager, requireTaskRepository } from './_helpers.js';
|
||||
import { requirePhaseDispatchManager, requirePhaseRepository, requireTaskRepository } from './_helpers.js';
|
||||
|
||||
export function phaseDispatchProcedures(publicProcedure: ProcedureBuilder) {
|
||||
return {
|
||||
@@ -18,6 +18,22 @@ export function phaseDispatchProcedures(publicProcedure: ProcedureBuilder) {
|
||||
return { success: true };
|
||||
}),
|
||||
|
||||
queueAllPhases: publicProcedure
|
||||
.input(z.object({ initiativeId: z.string().min(1) }))
|
||||
.mutation(async ({ ctx, input }) => {
|
||||
const phaseDispatchManager = requirePhaseDispatchManager(ctx);
|
||||
const phaseRepo = requirePhaseRepository(ctx);
|
||||
const phases = await phaseRepo.findByInitiativeId(input.initiativeId);
|
||||
let queued = 0;
|
||||
for (const phase of phases) {
|
||||
if (phase.status === 'approved') {
|
||||
await phaseDispatchManager.queuePhase(phase.id);
|
||||
queued++;
|
||||
}
|
||||
}
|
||||
return { success: true, queued };
|
||||
}),
|
||||
|
||||
dispatchNextPhase: publicProcedure
|
||||
.mutation(async ({ ctx }) => {
|
||||
const phaseDispatchManager = requirePhaseDispatchManager(ctx);
|
||||
|
||||
@@ -232,6 +232,7 @@ export function ExecutionTab({
|
||||
dependencies={depNamesByPhase.get(phase.id) ?? []}
|
||||
isSelected={phase.id === activePhaseId}
|
||||
onClick={() => setSelectedPhaseId(phase.id)}
|
||||
detailAgent={detailAgentByPhase.get(phase.id) ?? null}
|
||||
/>
|
||||
))}
|
||||
{isAddingPhase && (
|
||||
|
||||
@@ -33,6 +33,7 @@ function activityVisual(state: string): { label: string; variant: StatusVariant;
|
||||
switch (state) {
|
||||
case "executing": return { label: "Executing", variant: "active", pulse: true };
|
||||
case "pending_review": return { label: "Pending Review", variant: "warning", pulse: true };
|
||||
case "detailing": return { label: "Detailing", variant: "active", pulse: true };
|
||||
case "ready": return { label: "Ready", variant: "active", pulse: false };
|
||||
case "blocked": return { label: "Blocked", variant: "error", pulse: false };
|
||||
case "complete": return { label: "Complete", variant: "success", pulse: false };
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { StatusBadge } from "@/components/StatusBadge";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -12,6 +13,7 @@ interface PhaseSidebarItemProps {
|
||||
dependencies: string[];
|
||||
isSelected: boolean;
|
||||
onClick: () => void;
|
||||
detailAgent?: { status: string } | null;
|
||||
}
|
||||
|
||||
export function PhaseSidebarItem({
|
||||
@@ -21,7 +23,35 @@ export function PhaseSidebarItem({
|
||||
dependencies,
|
||||
isSelected,
|
||||
onClick,
|
||||
detailAgent,
|
||||
}: PhaseSidebarItemProps) {
|
||||
const isDetailing =
|
||||
detailAgent?.status === "running" ||
|
||||
detailAgent?.status === "waiting_for_input";
|
||||
const detailDone = detailAgent?.status === "idle";
|
||||
|
||||
function renderTaskStatus() {
|
||||
if (isDetailing) {
|
||||
return (
|
||||
<span className="flex items-center gap-1 text-status-active-fg">
|
||||
<Loader2 className="h-3 w-3 animate-spin" />
|
||||
Detailing…
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (detailDone && taskCount.total === 0) {
|
||||
return <span className="text-status-warning-fg">Review changes</span>;
|
||||
}
|
||||
if (taskCount.total === 0) {
|
||||
return <span>Needs decomposition</span>;
|
||||
}
|
||||
return (
|
||||
<span>
|
||||
{taskCount.complete}/{taskCount.total} tasks
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
className={cn(
|
||||
@@ -40,11 +70,7 @@ export function PhaseSidebarItem({
|
||||
</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>
|
||||
{renderTaskStatus()}
|
||||
</div>
|
||||
|
||||
{dependencies.length > 0 && (
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { PipelineColumn } from "@codewalk-district/shared";
|
||||
import { useMemo } from "react";
|
||||
import type { PipelineColumn, DependencyEdge } from "@codewalk-district/shared";
|
||||
import { PipelineStageColumn } from "./PipelineStageColumn";
|
||||
import type { SerializedTask } from "@/components/TaskRow";
|
||||
|
||||
@@ -10,9 +11,28 @@ interface PipelineGraphProps {
|
||||
createdAt: string | Date;
|
||||
}>[];
|
||||
tasksByPhase: Record<string, SerializedTask[]>;
|
||||
dependencyEdges: DependencyEdge[];
|
||||
}
|
||||
|
||||
export function PipelineGraph({ columns, tasksByPhase }: PipelineGraphProps) {
|
||||
export function PipelineGraph({ columns, tasksByPhase, dependencyEdges }: PipelineGraphProps) {
|
||||
// Build a set of phase IDs whose dependencies are all completed
|
||||
const blockedPhaseIds = useMemo(() => {
|
||||
const phaseStatusMap = new Map<string, string>();
|
||||
for (const col of columns) {
|
||||
for (const phase of col.phases) {
|
||||
phaseStatusMap.set(phase.id, phase.status);
|
||||
}
|
||||
}
|
||||
const blocked = new Set<string>();
|
||||
for (const edge of dependencyEdges) {
|
||||
const depStatus = phaseStatusMap.get(edge.dependsOnPhaseId);
|
||||
if (depStatus !== "completed") {
|
||||
blocked.add(edge.phaseId);
|
||||
}
|
||||
}
|
||||
return blocked;
|
||||
}, [columns, dependencyEdges]);
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto pb-4">
|
||||
<div className="flex min-w-max items-start gap-0">
|
||||
@@ -28,6 +48,7 @@ export function PipelineGraph({ columns, tasksByPhase }: PipelineGraphProps) {
|
||||
<PipelineStageColumn
|
||||
phases={column.phases}
|
||||
tasksByPhase={tasksByPhase}
|
||||
blockedPhaseIds={blockedPhaseIds}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@@ -12,11 +12,13 @@ interface PipelinePhaseGroupProps {
|
||||
status: string;
|
||||
};
|
||||
tasks: SerializedTask[];
|
||||
isBlocked: boolean;
|
||||
}
|
||||
|
||||
export function PipelinePhaseGroup({ phase, tasks }: PipelinePhaseGroupProps) {
|
||||
export function PipelinePhaseGroup({ phase, tasks, isBlocked }: PipelinePhaseGroupProps) {
|
||||
const queuePhase = trpc.queuePhase.useMutation();
|
||||
const sorted = sortByPriorityAndQueueTime(tasks);
|
||||
const canExecute = phase.status === "approved" && !isBlocked;
|
||||
|
||||
return (
|
||||
<div className="rounded-lg border border-border bg-card overflow-hidden">
|
||||
@@ -26,7 +28,7 @@ export function PipelinePhaseGroup({ phase, tasks }: PipelinePhaseGroupProps) {
|
||||
<span className="min-w-0 flex-1 truncate text-sm font-medium">
|
||||
{phase.name}
|
||||
</span>
|
||||
{phase.status === "pending" && (
|
||||
{canExecute && (
|
||||
<button
|
||||
onClick={() => queuePhase.mutate({ phaseId: phase.id })}
|
||||
title="Queue phase"
|
||||
|
||||
@@ -8,9 +8,10 @@ interface PipelineStageColumnProps {
|
||||
status: string;
|
||||
}>;
|
||||
tasksByPhase: Record<string, SerializedTask[]>;
|
||||
blockedPhaseIds: Set<string>;
|
||||
}
|
||||
|
||||
export function PipelineStageColumn({ phases, tasksByPhase }: PipelineStageColumnProps) {
|
||||
export function PipelineStageColumn({ phases, tasksByPhase, blockedPhaseIds }: PipelineStageColumnProps) {
|
||||
return (
|
||||
<div className="flex w-64 shrink-0 flex-col gap-3">
|
||||
{phases.map((phase) => (
|
||||
@@ -18,6 +19,7 @@ export function PipelineStageColumn({ phases, tasksByPhase }: PipelineStageColum
|
||||
key={phase.id}
|
||||
phase={phase}
|
||||
tasks={tasksByPhase[phase.id] ?? []}
|
||||
isBlocked={blockedPhaseIds.has(phase.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useMemo } from "react";
|
||||
import { Loader2 } from "lucide-react";
|
||||
import { Loader2, Play } from "lucide-react";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { trpc } from "@/lib/trpc";
|
||||
import {
|
||||
groupPhasesByDependencyLevel,
|
||||
@@ -70,6 +71,14 @@ function PipelineTabInner({ initiativeId, phases, phasesLoading }: PipelineTabPr
|
||||
[phases, dependencyEdges],
|
||||
);
|
||||
|
||||
// Count how many phases are approved and ready to execute
|
||||
const approvedCount = useMemo(
|
||||
() => phases.filter((p) => p.status === "approved").length,
|
||||
[phases],
|
||||
);
|
||||
|
||||
const queueAll = trpc.queueAllPhases.useMutation();
|
||||
|
||||
// Register tasks with ExecutionContext for TaskModal
|
||||
useEffect(() => {
|
||||
for (const phase of phases) {
|
||||
@@ -110,5 +119,29 @@ function PipelineTabInner({ initiativeId, phases, phasesLoading }: PipelineTabPr
|
||||
);
|
||||
}
|
||||
|
||||
return <PipelineGraph columns={columns} tasksByPhase={tasksByPhase} />;
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{approvedCount > 0 && (
|
||||
<div className="flex items-center gap-3">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={() => queueAll.mutate({ initiativeId })}
|
||||
disabled={queueAll.isPending}
|
||||
>
|
||||
{queueAll.isPending ? (
|
||||
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
|
||||
) : (
|
||||
<Play className="mr-1.5 h-3.5 w-3.5" />
|
||||
)}
|
||||
Execute {approvedCount === 1 ? "1 phase" : `${approvedCount} phases`}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
<PipelineGraph
|
||||
columns={columns}
|
||||
tasksByPhase={tasksByPhase}
|
||||
dependencyEdges={dependencyEdges}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -180,4 +180,4 @@ Configured in `src/lib/trpc.ts`. Uses `@trpc/react-query` with TanStack Query fo
|
||||
|
||||
`listInitiatives` returns an `activity` field on each initiative, computed server-side from phase statuses via `deriveInitiativeActivity()` in `apps/server/trpc/routers/initiative-activity.ts`. This eliminates per-card N+1 `listPhases` queries.
|
||||
|
||||
Activity states (priority order): `pending_review` > `executing` > `blocked` > `complete` > `ready` > `planning` > `idle` > `archived`. Each state maps to a `StatusVariant` + pulse animation in `InitiativeCard`'s `activityVisual()` function.
|
||||
Activity states (priority order): `pending_review` > `executing` > `blocked` > `complete` > `ready` > `detailing` > `planning` > `idle` > `archived`. Each state maps to a `StatusVariant` + pulse animation in `InitiativeCard`'s `activityVisual()` function. The `detailing` state is derived from active detail agents (mode='detail', status running/waiting_for_input) and shows a pulsing indigo dot. `PhaseSidebarItem` also shows a spinner when a detail agent is active for its phase.
|
||||
|
||||
@@ -111,6 +111,7 @@ Each procedure uses `require*Repository(ctx)` helpers that throw `TRPCError(INTE
|
||||
| Procedure | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| queuePhase | mutation | Queue approved phase |
|
||||
| queueAllPhases | mutation | Queue all approved phases for initiative |
|
||||
| dispatchNextPhase | mutation | Start next ready phase |
|
||||
| getPhaseQueueState | query | Queue state |
|
||||
| createChildTasks | mutation | Create tasks from detail parent |
|
||||
|
||||
@@ -6,6 +6,7 @@ export type ExecutionMode = 'yolo' | 'review_per_phase';
|
||||
export type InitiativeActivityState =
|
||||
| 'idle' // Active but no phases
|
||||
| 'planning' // All phases pending (no work started)
|
||||
| 'detailing' // Detail agent actively decomposing phases into tasks
|
||||
| 'ready' // Phases approved, waiting to execute
|
||||
| 'executing' // At least one phase in_progress
|
||||
| 'pending_review' // At least one phase pending_review
|
||||
|
||||
Reference in New Issue
Block a user