Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 3 additions & 9 deletions frontend/src/components/layout/AppSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,7 @@ import { PipelineView } from '@/components/layout/PipelineView';
import { useState, useEffect, useCallback } from 'react';
import { sprintsService, type Sprint } from '@/services/sprints';
import { realtimeService } from '@/services/realtime';

const PHASE_URL_SUFFIX: Record<string, string> = {
INCEPTION: '',
CONSTRUCTION: '/construction',
REVIEW: '/review',
};
import { getSprintPhasePath } from '@/lib/sprintPhaseNavigation';

export function AppSidebar() {
const navigate = useNavigate();
Expand Down Expand Up @@ -45,9 +40,8 @@ export function AppSidebar() {
realtimeService.on('agent.error', () => loadSprint()),
realtimeService.on('sprint.phaseChanged', (data: { phase?: string }) => {
loadSprint();
if (data.phase && PHASE_URL_SUFFIX[data.phase] !== undefined) {
navigate(`/project/${projectId}/sprint/${sprintId}${PHASE_URL_SUFFIX[data.phase]}`);
}
const nextPath = data.phase ? getSprintPhasePath(projectId, sprintId, data.phase) : null;
if (nextPath) navigate(nextPath);
}),
];
return () => unsubs.forEach((unsub) => unsub());
Expand Down
24 changes: 14 additions & 10 deletions frontend/src/components/observability/PhaseBlock.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ interface Props {
isFuturePhase: boolean;
isUnlocked: boolean;
agentStatus?: string | null;
activeStageKey?: string | null;
progress: SprintProgress | null;
sprint: Sprint | null;
taskStatuses?: TaskAgentStatus[];
Expand All @@ -33,6 +34,7 @@ export function PhaseBlock({
isFuturePhase,
isUnlocked,
agentStatus,
activeStageKey,
progress,
taskStatuses = [],
prUrl,
Expand Down Expand Up @@ -116,22 +118,24 @@ export function PhaseBlock({
{/* Rangée principale — avec flèches entre les steps */}
<div className="flex flex-wrap items-center gap-1">
{config.mainSteps.map((step, idx) => {
const isFirstIncompleteMandatory =
isCurrentPhase &&
agentStatus === 'running' &&
step.mandatory &&
!stepsDone.has(step.key) &&
!config.mainSteps.some(
(s) => s.mandatory && !stepsDone.has(s.key) && config.mainSteps.indexOf(s) < idx,
);
const isActive = activeStageKey
? activeStageKey === step.key
: isCurrentPhase &&
agentStatus === 'running' &&
step.mandatory &&
!stepsDone.has(step.key) &&
!config.mainSteps.some(
(s) =>
s.mandatory && !stepsDone.has(s.key) && config.mainSteps.indexOf(s) < idx,
);
return (
<div key={step.key} className="flex items-center gap-1">
{idx > 0 && <span className="text-muted-foreground/40 text-xs">→</span>}
<StepNode
step={step}
isDone={stepsDone.has(step.key)}
isSkipped={stepsSkipped.has(step.key)}
isActive={isFirstIncompleteMandatory}
isActive={isActive}
isCurrentPhase={isCurrentPhase}
isPastPhase={isPastPhase}
mandatoryBg={config.mandatoryBg}
Expand All @@ -151,7 +155,7 @@ export function PhaseBlock({
step={step}
isDone={stepsDone.has(step.key)}
isSkipped={stepsSkipped.has(step.key)}
isActive={false}
isActive={activeStageKey === step.key}
isCurrentPhase={isCurrentPhase}
isPastPhase={isPastPhase}
mandatoryBg={config.mandatoryBg}
Expand Down
81 changes: 80 additions & 1 deletion frontend/src/components/observability/ProjectDiagram.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,75 @@ import type { ProjectAgentInfo, SprintProgress, VelocityMetrics } from '@/hooks/
import type { Sprint } from '@/services/sprints';
import type { TaskAgentStatus } from '@/services/agents';

function hasConstructionEvidence(
sprint: Sprint | null,
progress: SprintProgress | null,
taskStatuses: TaskAgentStatus[],
): boolean {
return Boolean(
sprint &&
(sprint.phase === 'CONSTRUCTION' ||
sprint.currentAgentType === 'construction-orchestrator' ||
sprint.currentAgentType === 'construction' ||
sprint.branch ||
sprint.prUrl ||
(progress?.codeFileCount ?? 0) > 0 ||
taskStatuses.length > 0),
);
}

function getEffectivePhase(
sprint: Sprint | null,
progress: SprintProgress | null,
taskStatuses: TaskAgentStatus[],
): PhaseKey | undefined {
if (!sprint) return undefined;
if (sprint.phase === 'INCEPTION' && hasConstructionEvidence(sprint, progress, taskStatuses)) {
return 'CONSTRUCTION';
}
return sprint.phase as PhaseKey;
}

function getEffectivePhaseStatus(
sprint: Sprint | null,
progress: SprintProgress | null,
taskStatuses: TaskAgentStatus[],
): string | null {
if (!sprint) return null;
if (sprint.phaseStatus) return sprint.phaseStatus;
if (sprint.phase === 'INCEPTION' && (progress?.taskCount ?? 0) > 0) return 'ready_for_transition';
if (
hasConstructionEvidence(sprint, progress, taskStatuses) &&
(sprint.prUrl ||
(taskStatuses.length > 0 && taskStatuses.every((t) => t.executionStatus === 'SUCCEEDED')))
) {
return 'ready_for_transition';
}
return null;
}

function normalizeStageKey(stage: string | null | undefined): string | null {
return stage ? stage.replaceAll('-', '_') : null;
}

function getActiveStageKey(
phase: PhaseKey,
sprint: Sprint | null,
effectivePhase: PhaseKey | undefined,
effectivePhaseStatus: string | null,
): string | null {
if (!sprint || effectivePhase !== phase || effectivePhaseStatus === 'ready_for_transition') {
return null;
}
return normalizeStageKey(sprint.currentStage);
}

function getStepsDone(
phase: PhaseKey,
progress: SprintProgress | null,
sprint: Sprint | null,
taskStatuses: TaskAgentStatus[],
effectivePhaseStatus: string | null,
): Set<string> {
const done = new Set<string>();
if (!sprint) return done;
Expand All @@ -25,6 +89,9 @@ function getStepsDone(
done.add('workflow_planning');
done.add('units_generation');
}
if (sprint.phase === 'INCEPTION' && effectivePhaseStatus === 'ready_for_transition') {
done.add('units_generation');
}
}

if (phase === 'CONSTRUCTION') {
Expand All @@ -35,6 +102,9 @@ function getStepsDone(
taskStatuses.length > 0 && taskStatuses.every((t) => t.executionStatus === 'SUCCEEDED');
if (hasRunningOrDone || progress?.codeFileCount) done.add('code_generation');
if (allDone || sprint.prUrl) done.add('build_and_test');
if (phase === 'CONSTRUCTION' && effectivePhaseStatus === 'ready_for_transition') {
done.add('build_and_test');
}
}

if (phase === 'REVIEW') {
Expand Down Expand Up @@ -78,7 +148,8 @@ export function ProjectDiagram({
}: Props) {
const { project, sprint, progress, taskStatuses } = info;
const agentStatus = sprint?.currentAgentStatus;
const currentPhase = sprint?.phase as PhaseKey | undefined;
const currentPhase = getEffectivePhase(sprint ?? null, progress, taskStatuses);
const effectivePhaseStatus = getEffectivePhaseStatus(sprint ?? null, progress, taskStatuses);
const currentPhaseIdx = currentPhase ? PHASE_ORDER.indexOf(currentPhase) : -1;

const handleClick = () => {
Expand Down Expand Up @@ -149,8 +220,15 @@ export function ProjectDiagram({
progress,
sprint ?? null,
phase.key === 'CONSTRUCTION' ? taskStatuses : [],
effectivePhaseStatus,
);
const stepsSkipped = getStepsSkipped(phase.key, stepsDone, isPastPhase, phase.steps);
const activeStageKey = getActiveStageKey(
phase.key,
sprint ?? null,
currentPhase,
effectivePhaseStatus,
);
return (
<div key={phase.key}>
{phaseIdx > 0 && (
Expand All @@ -169,6 +247,7 @@ export function ProjectDiagram({
sprint !== null && (currentPhaseIdx === -1 || currentPhaseIdx >= phaseIdx)
}
agentStatus={agentStatus}
activeStageKey={activeStageKey}
progress={progress}
sprint={sprint ?? null}
taskStatuses={phase.key === 'CONSTRUCTION' ? taskStatuses : []}
Expand Down
10 changes: 9 additions & 1 deletion frontend/src/lib/observability/fetchProjectInfos.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,15 @@ export async function fetchProjectInfos(l2: L2Intelligence): Promise<ProjectAgen
/* graph not available yet */
}

if (latest.phase === 'CONSTRUCTION') {
const hasConstructionEvidence =
latest.phase === 'CONSTRUCTION' ||
latest.currentAgentType === 'construction-orchestrator' ||
latest.currentAgentType === 'construction' ||
Boolean(latest.branch) ||
Boolean(latest.prUrl) ||
(progress?.codeFileCount ?? 0) > 0;

if (hasConstructionEvidence) {
try {
const { tasks } = await agentsService.getTaskAgentStatuses(project.id, latest.id);
taskStatuses = tasks;
Expand Down
16 changes: 16 additions & 0 deletions frontend/src/lib/sprintPhaseNavigation.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
const PHASE_URL_SUFFIX: Record<string, string> = {
INCEPTION: '',
CONSTRUCTION: '/construction',
REVIEW: '/review',
COMPLETED: '/review',
};

export function getSprintPhasePath(
projectId: string,
sprintId: string,
phase: string,
): string | null {
const suffix = PHASE_URL_SUFFIX[phase];
if (suffix === undefined) return null;
return `/project/${projectId}/sprint/${sprintId}${suffix}`;
}
56 changes: 53 additions & 3 deletions frontend/src/pages/ConstructionPage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useState, useEffect, useCallback } from 'react';
import { useNavigate } from 'react-router-dom';
import { useSprint } from '@/contexts/SprintContext';
import { useAuth } from '@/contexts/AuthContext';
import { usePresence } from '@/hooks/usePresence';
Expand Down Expand Up @@ -59,8 +60,10 @@ import type { StructuredAnswer } from '@/services/questions';
import { TaskSettingsDialog } from '@/components/settings/TaskSettingsDialog';
import { TaskActionsMenu } from '@/components/domain/TaskActionsMenu';
import type { Task } from '@/services/tasks';
import { getSprintPhasePath } from '@/lib/sprintPhaseNavigation';

export default function ConstructionPage() {
const navigate = useNavigate();
const { user } = useAuth();
const { sprint, tasks, codeFiles, questions, projectId, sprintId, reload } = useSprint();

Expand Down Expand Up @@ -127,6 +130,35 @@ export default function ConstructionPage() {
.catch(() => {});
}, [projectId, sprintId, sprint?.phase]);

// Backfill older sprints that started construction before the Sprint
// workflow-state contract existed. If construction evidence already exists,
// normalize the UI-owned phase so the dashboard no longer shows Inception as active.
useEffect(() => {
if (!projectId || !sprintId || !sprint || sprint.phase !== 'INCEPTION') return;
const hasConstructionEvidence =
sprint.currentAgentType === 'construction-orchestrator' ||
sprint.currentAgentType === 'construction' ||
codeFiles.length > 0 ||
Boolean(sprint.branch);
if (!hasConstructionEvidence) return;

sprintsService
.update(projectId, sprintId, {
phase: 'CONSTRUCTION',
currentStage: sprint.currentStage || 'code-generation',
phaseStatus: sprint.phaseStatus || 'active',
})
.then(() => {
realtimeService.send('broadcastToDocument', {
documentId: `sprint:${sprintId}`,
action: 'sprint.phaseChanged',
data: { phase: 'CONSTRUCTION', sprintId },
});
return reload();
})
.catch(() => {});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maybe put a console.warn

}, [codeFiles.length, projectId, reload, sprint, sprintId]);

// Reload on agent artifacts
useEffect(() => {
if (agentStatus.artifactsUpdated > 0) reload();
Expand Down Expand Up @@ -170,8 +202,20 @@ export default function ConstructionPage() {
setStartingConstruction(true);
setShowBranchSelector(false);
try {
// Persist branch on sprint so subsequent runs skip the BranchSelector
await sprintsService.update(projectId, sprintId, { branch, baseBranch });
// Construction kick-off is a UI-owned phase transition. Normalize older
// sprints that reached this page before workflow state fields existed.
await sprintsService.update(projectId, sprintId, {
phase: 'CONSTRUCTION',
currentStage: 'code-generation',
phaseStatus: 'active',
branch,
baseBranch,
});
realtimeService.send('broadcastToDocument', {
documentId: `sprint:${sprintId}`,
action: 'sprint.phaseChanged',
data: { phase: 'CONSTRUCTION', sprintId },
});
const result = await agentsService.startWorkflow(projectId, {
phase: 'construction-orchestrator',
sprintId,
Expand Down Expand Up @@ -283,7 +327,11 @@ export default function ConstructionPage() {
const handleApprovePhase = async () => {
setApprovingPhase(true);
try {
await sprintsService.update(projectId, sprintId, { phase: 'REVIEW' });
await sprintsService.update(projectId, sprintId, {
phase: 'REVIEW',
currentStage: null,
phaseStatus: 'active',
});
realtimeService.send('broadcastToDocument', {
documentId: `sprint:${sprintId}`,
action: 'sprint.phaseChanged',
Expand All @@ -293,6 +341,8 @@ export default function ConstructionPage() {
.create(sprintId, { type: 'phase_changed', title: 'Moved to Review phase', userName })
.catch(() => {});
await reload();
const nextPath = getSprintPhasePath(projectId, sprintId, 'REVIEW');
if (nextPath) navigate(nextPath);
} catch (err) {
console.error('Failed to approve phase:', err);
} finally {
Expand Down
11 changes: 10 additions & 1 deletion frontend/src/pages/InceptionPage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useCallback, useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useSprint } from '@/contexts/SprintContext';
import { useAuth } from '@/contexts/AuthContext';
import { usePresence } from '@/hooks/usePresence';
Expand Down Expand Up @@ -50,6 +51,7 @@ import { CollaborativeTextarea } from '@/components/CollaborativeTextarea';
import { AiModifyModal } from '@/components/AiModifyModal';
import { AgentStartErrorBanner } from '@/components/AgentStartErrorBanner';
import { extractAgentStartError, type AgentStartError } from '@/lib/agentStartError';
import { getSprintPhasePath } from '@/lib/sprintPhaseNavigation';
import {
AlertCircle,
ArrowRight,
Expand All @@ -68,6 +70,7 @@ import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';

export default function InceptionPage() {
const navigate = useNavigate();
const { user } = useAuth();
const {
sprint,
Expand Down Expand Up @@ -302,6 +305,8 @@ export default function InceptionPage() {
})
.catch(() => {});
await reload();
const nextPath = getSprintPhasePath(projectId, sprintId, 'CONSTRUCTION');
if (nextPath) navigate(nextPath);
} catch (err) {
console.error('Start over failed:', err);
}
Expand All @@ -311,7 +316,11 @@ export default function InceptionPage() {
const handleApprovePhase = async () => {
setApprovingPhase(true);
try {
await sprintsService.update(projectId, sprintId, { phase: 'CONSTRUCTION' });
await sprintsService.update(projectId, sprintId, {
phase: 'CONSTRUCTION',
currentStage: null,
phaseStatus: 'active',
});
realtimeService.send('broadcastToDocument', {
documentId: `sprint:${sprintId}`,
action: 'sprint.phaseChanged',
Expand Down
Loading
Loading