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:
Lukas May
2026-03-03 13:08:05 +01:00
parent 96386e1c3d
commit 411700d37d
13 changed files with 151 additions and 17 deletions

View File

@@ -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' };
}

View File

@@ -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),
}));
}),

View File

@@ -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);

View File

@@ -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 && (

View File

@@ -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 };

View File

@@ -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 && (

View File

@@ -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>
))}

View File

@@ -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"

View File

@@ -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>

View File

@@ -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>
);
}

View File

@@ -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.

View File

@@ -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 |

View File

@@ -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