From e3246baf514ce54589c9c3df4e3710f9d9082365 Mon Sep 17 00:00:00 2001 From: Lukas May Date: Fri, 6 Mar 2026 13:32:37 +0100 Subject: [PATCH] feat: Show resolving_conflict activity state on initiative cards Add 'resolving_conflict' to InitiativeActivityState and detect active conflict agents (name starts with conflict-) in deriveInitiativeActivity. Conflict resolution takes priority over pending_review since the agent is actively working. - Add resolving_conflict to shared types and activity derivation - Include conflict agents in listInitiatives agent filter (name + mode) - Map resolving_conflict to urgent variant with pulse in InitiativeCard - Add merge: prefix to INITIATIVE_LIST_RULES for merge event routing - Add spawnConflictResolutionAgent to INVALIDATION_MAP - Add getActiveConflictAgent to detail page agent: SSE invalidation --- .../trpc/routers/initiative-activity.ts | 14 +++++++++++ apps/server/trpc/routers/initiative.ts | 6 ++--- apps/web/src/components/InitiativeCard.tsx | 11 +++++---- apps/web/src/components/StatusDot.tsx | 4 ++++ apps/web/src/hooks/useLiveUpdates.ts | 1 + apps/web/src/lib/invalidation.ts | 1 + docs/frontend.md | 2 +- packages/shared/src/types.ts | 23 ++++++++++--------- 8 files changed, 42 insertions(+), 20 deletions(-) diff --git a/apps/server/trpc/routers/initiative-activity.ts b/apps/server/trpc/routers/initiative-activity.ts index fc16b35..8bdbea8 100644 --- a/apps/server/trpc/routers/initiative-activity.ts +++ b/apps/server/trpc/routers/initiative-activity.ts @@ -9,6 +9,7 @@ export interface ActiveArchitectAgent { initiativeId: string; mode: string; status: string; + name?: string; } const MODE_TO_STATE: Record = { @@ -30,6 +31,18 @@ export function deriveInitiativeActivity( if (initiative.status === 'archived') { return { ...base, state: 'archived' }; } + + // Check for active conflict resolution agent — takes priority over pending_review + // because the agent is actively working to resolve merge conflicts + const conflictAgent = activeArchitectAgents?.find( + a => a.initiativeId === initiative.id + && a.name?.startsWith('conflict-') + && (a.status === 'running' || a.status === 'waiting_for_input'), + ); + if (conflictAgent) { + return { ...base, state: 'resolving_conflict' }; + } + if (initiative.status === 'pending_review') { return { ...base, state: 'pending_review' }; } @@ -41,6 +54,7 @@ export function deriveInitiativeActivity( // so architect agents (discuss/plan/detail/refine) surface activity const activeAgent = activeArchitectAgents?.find( a => a.initiativeId === initiative.id + && !a.name?.startsWith('conflict-') && (a.status === 'running' || a.status === 'waiting_for_input'), ); if (activeAgent) { diff --git a/apps/server/trpc/routers/initiative.ts b/apps/server/trpc/routers/initiative.ts index e28048b..1c317df 100644 --- a/apps/server/trpc/routers/initiative.ts +++ b/apps/server/trpc/routers/initiative.ts @@ -129,16 +129,16 @@ export function initiativeProcedures(publicProcedure: ProcedureBuilder) { : await repo.findAll(); } - // Fetch active architect agents once for all initiatives + // Fetch active agents once for all initiatives (architect + conflict) const ARCHITECT_MODES = ['discuss', 'plan', 'detail', 'refine']; const allAgents = ctx.agentManager ? await ctx.agentManager.list() : []; const activeArchitectAgents = allAgents .filter(a => - ARCHITECT_MODES.includes(a.mode ?? '') + (ARCHITECT_MODES.includes(a.mode ?? '') || a.name?.startsWith('conflict-')) && (a.status === 'running' || a.status === 'waiting_for_input') && !a.userDismissedAt, ) - .map(a => ({ initiativeId: a.initiativeId ?? '', mode: a.mode ?? '', status: a.status })); + .map(a => ({ initiativeId: a.initiativeId ?? '', mode: a.mode ?? '', status: a.status, name: a.name })); // Batch-fetch projects for all initiatives const projectRepo = ctx.projectRepository; diff --git a/apps/web/src/components/InitiativeCard.tsx b/apps/web/src/components/InitiativeCard.tsx index 6ab41ee..5cf86ce 100644 --- a/apps/web/src/components/InitiativeCard.tsx +++ b/apps/web/src/components/InitiativeCard.tsx @@ -32,11 +32,12 @@ export interface SerializedInitiative { function activityVisual(state: string): { label: string; variant: StatusVariant; pulse: boolean } { switch (state) { - case "executing": return { label: "Executing", variant: "active", pulse: true }; - case "pending_review": return { label: "Pending Review", variant: "warning", pulse: true }; - case "discussing": return { label: "Discussing", variant: "active", pulse: true }; - case "detailing": return { label: "Detailing", variant: "active", pulse: true }; - case "refining": return { label: "Refining", variant: "active", pulse: true }; + case "executing": return { label: "Executing", variant: "active", pulse: true }; + case "pending_review": return { label: "Pending Review", variant: "warning", pulse: true }; + case "discussing": return { label: "Discussing", variant: "active", pulse: true }; + case "detailing": return { label: "Detailing", variant: "active", pulse: true }; + case "refining": return { label: "Refining", variant: "active", pulse: true }; + case "resolving_conflict": return { label: "Resolving Conflict", variant: "urgent", 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 }; diff --git a/apps/web/src/components/StatusDot.tsx b/apps/web/src/components/StatusDot.tsx index f57b454..30e538b 100644 --- a/apps/web/src/components/StatusDot.tsx +++ b/apps/web/src/components/StatusDot.tsx @@ -45,6 +45,10 @@ export function mapEntityStatus(rawStatus: string): StatusVariant { case "medium": return "warning"; + // Urgent / conflict resolution + case "resolving_conflict": + return "urgent"; + // Error / failed case "crashed": case "blocked": diff --git a/apps/web/src/hooks/useLiveUpdates.ts b/apps/web/src/hooks/useLiveUpdates.ts index 5ab36f1..50908c7 100644 --- a/apps/web/src/hooks/useLiveUpdates.ts +++ b/apps/web/src/hooks/useLiveUpdates.ts @@ -24,6 +24,7 @@ export const INITIATIVE_LIST_RULES: LiveUpdateRule[] = [ { prefix: 'task:', invalidate: ['listInitiatives'] }, { prefix: 'phase:', invalidate: ['listInitiatives'] }, { prefix: 'agent:', invalidate: ['listInitiatives'] }, + { prefix: 'merge:', invalidate: ['listInitiatives'] }, ]; export function useLiveUpdates(rules: LiveUpdateRule[]) { diff --git a/apps/web/src/lib/invalidation.ts b/apps/web/src/lib/invalidation.ts index ae38d45..5c4538a 100644 --- a/apps/web/src/lib/invalidation.ts +++ b/apps/web/src/lib/invalidation.ts @@ -44,6 +44,7 @@ const INVALIDATION_MAP: Partial> = { spawnArchitectDiscuss: ["listAgents"], spawnArchitectPlan: ["listAgents"], spawnArchitectDetail: ["listAgents", "listInitiativeTasks"], + spawnConflictResolutionAgent: ["listAgents", "listInitiatives", "getInitiative"], // --- Initiatives --- createInitiative: ["listInitiatives"], diff --git a/docs/frontend.md b/docs/frontend.md index 6488640..dec5250 100644 --- a/docs/frontend.md +++ b/docs/frontend.md @@ -198,4 +198,4 @@ Components: `ChatSlideOver`, `ChatBubble`, `ChatInput`, `ChangeSetInline` in `sr `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): active architect agents > `pending_review` > `executing` > `blocked` > `complete` > `ready` > `planning` > `idle` > `archived`. Each state maps to a `StatusVariant` + pulse animation in `InitiativeCard`'s `activityVisual()` function. Active architect agents (modes: discuss, plan, detail, refine) are checked first — mapping to `discussing`, `detailing`, `detailing`, `refining` states respectively — so auto-spawned agents surface activity even when no phases exist yet. `PhaseSidebarItem` also shows a spinner when a detail agent is active for its phase. +Activity states (priority order): conflict agent > `archived` > active architect agents > `pending_review` > `executing` > `blocked` > `complete` > `ready` > `planning` > `idle`. Each state maps to a `StatusVariant` + pulse animation in `InitiativeCard`'s `activityVisual()` function. Active conflict agents (name starts with `conflict-`) are checked first — returning `resolving_conflict` (urgent variant, pulsing). Active architect agents (modes: discuss, plan, detail, refine) are checked next — mapping to `discussing`, `detailing`, `detailing`, `refining` states respectively — so auto-spawned agents surface activity even when no phases exist yet. `PhaseSidebarItem` also shows a spinner when a detail agent is active for its phase. diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index db8ef20..06859b0 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -4,17 +4,18 @@ export type { PendingQuestions, QuestionItem } from '../../../apps/server/agent/ export type ExecutionMode = 'yolo' | 'review_per_phase'; export type InitiativeActivityState = - | 'idle' // Active but no phases and no agents - | 'discussing' // Discuss agent actively scoping the initiative - | 'planning' // All phases pending (no work started) - | 'detailing' // Detail/plan agent actively decomposing phases into tasks - | 'refining' // Refine agent actively working on content - | 'ready' // Phases approved, waiting to execute - | 'executing' // At least one phase in_progress - | 'pending_review' // At least one phase pending_review - | 'blocked' // At least one phase blocked (none in_progress/pending_review) - | 'complete' // All phases completed - | 'archived'; // Initiative archived + | 'idle' // Active but no phases and no agents + | 'discussing' // Discuss agent actively scoping the initiative + | 'planning' // All phases pending (no work started) + | 'detailing' // Detail/plan agent actively decomposing phases into tasks + | 'refining' // Refine agent actively working on content + | 'resolving_conflict' // Conflict resolution agent actively fixing merge conflicts + | 'ready' // Phases approved, waiting to execute + | 'executing' // At least one phase in_progress + | 'pending_review' // At least one phase pending_review + | 'blocked' // At least one phase blocked (none in_progress/pending_review) + | 'complete' // All phases completed + | 'archived'; // Initiative archived export interface InitiativeActivity { state: InitiativeActivityState;