fix(roadmap): sync roadmap features with task lifecycle (#1791)

* feat(roadmap): sync roadmap features with task lifecycle

When a roadmap feature is linked to a task (via linkedSpecId), the feature
now automatically updates when the task is completed, deleted, or archived.
Previously, features would show a broken "Go to Task" button pointing to
non-existent tasks.

- Add taskOutcome field to RoadmapFeature type
- Hook into task status changes (IPC listener) for real-time sync
- Update linked features on task deletion (main process)
- Update linked features on task archival (main process)
- Add startup reconciliation to catch missed updates
- Show status badges instead of broken "Go to Task" buttons
- Use AUTO_BUILD_PATHS constants and writeFileAtomicSync for consistency
- Add i18n translations (en/fr) for task outcome labels

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(roadmap): address PR review findings

- Extract shared updateRoadmapFeatureOutcome utility with file locking
  and retry logic (eliminates duplication between crud-handlers and
  project-store, matches established roadmap-handlers pattern)
- Fix stale Zustand state read in useIpc.ts — re-read state after
  markFeatureDoneBySpecId mutation to persist correct data
- Add .catch() to saveRoadmap call in useIpc.ts for error handling
- Add Archive icon for archived outcome in PhaseCard (consistency with
  FeatureCard, SortableFeatureCard, and FeatureDetailPanel)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(roadmap): address follow-up PR review findings

- Fix relative path bug: use path.join(project.path, AUTO_BUILD_PATHS)
  instead of path.join(autoBuildPath, 'roadmap') which produced relative
  paths causing roadmap updates to silently fail
- Allow taskOutcome transitions on already-done features (e.g.,
  completed→deleted) by relaxing the status check condition
- Extract withFileLock into shared file-lock.ts module so roadmap-utils
  and roadmap-handlers use the same lock map for cross-module coordination
- Show Trash2 icon for deleted tasks in PhaseCard instead of misleading
  green checkmark (visual distinction from completed)
- Remove unused writeFileAtomicSync import from crud-handlers.ts

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* refactor(roadmap): extract TaskOutcome type and shared badge component

- Extract TaskOutcome type alias in shared/types/roadmap.ts, replacing
  inline union types across 5 locations (follows codebase convention)
- Create TaskOutcomeBadge shared component with consistent icon/color
  per outcome: completed=CheckCircle2/green, archived=Archive/green,
  deleted=Trash2/muted — eliminates duplicated rendering logic across
  SortableFeatureCard, FeatureCard, FeatureDetailPanel, PhaseCard
- Use text-muted-foreground for deleted outcome instead of misleading
  green success styling in all views

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(roadmap): revert feature state when task is unarchived

When unarchiveTasks() is called, linked roadmap features are now reverted
from status='done'/taskOutcome='archived' back to status='in_progress'
with taskOutcome cleared. Without this, unarchived tasks left their
roadmap features permanently stuck in the archived state.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix(roadmap): preserve original status on outcome update and fix deletion ordering

- Save previous_status before overwriting to 'done' so unarchive restores
  the correct original status instead of always defaulting to 'in_progress'
- Move roadmap feature update after hasErrors check in task deletion so
  roadmap is only updated on successful deletion

* update to .md

* fix(roadmap): round-trip previous_status and add backend completed handling

- Add previousStatus to RoadmapFeature interface so it survives
  renderer-initiated saves through the ROADMAP_SAVE handler
- Map previous_status in both ROADMAP_GET and ROADMAP_SAVE handlers
- Add backend-side roadmap update on PR creation so completed outcome
  is handled server-side like deleted and archived outcomes

* fix(roadmap): preserve previousStatus in renderer and guard empty task list

- Add previousStatus preservation to markFeatureDoneBySpecId so renderer
  path matches backend behavior for unarchive revert
- Guard reconcileLinkedFeatures against empty task arrays to prevent
  falsely marking all linked features as deleted
- Fix broken code fence in CLAUDE.md (2 backticks → 3)

* fix(roadmap): clear taskOutcome when feature is moved away from done

When dragging a feature out of the 'done' column via Kanban, clear
taskOutcome and previousStatus so stale outcome badges don't persist.

* fix(roadmap): clear task_outcome in IPC handler and add test coverage

- ROADMAP_UPDATE_FEATURE handler now clears task_outcome and
  previous_status when status moves away from done, matching the
  renderer store behavior
- Add tests for markFeatureDoneBySpecId (previousStatus preservation,
  taskOutcome setting, feature isolation)
- Add tests for updateFeatureStatus clearing taskOutcome/previousStatus

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Andy
2026-02-12 13:04:57 +01:00
committed by GitHub
parent 3791b37bbd
commit 115576e85d
18 changed files with 506 additions and 83 deletions
+15 -48
View File
@@ -40,6 +40,18 @@ Auto Claude is a desktop application (+ CLI) where users describe a goal and AI
**PR target** — Always target the `develop` branch for PRs to AndyMik90/Auto-Claude, NOT `main`.
## Work Approach
**Investigate before speculating** — Always read the actual code before proposing root causes. Spawn agents to grep and read relevant source files before forming any hypothesis. Never guess at causes without evidence from the codebase.
**Spawn agents for complex tasks** — When tackling complex tasks, spawn sub-agents/agent teams immediately rather than trying to handle everything in a single context window. Never attempt to analyze large codebases or multiple features monolithically.
**Minimal fixes only** — Prefer the simplest approach (e.g., prompt-only changes, single guard clause) before suggesting multi-component solutions. If the user asks for X, implement X — don't bundle additional fixes they didn't request.
## Known Gotchas
**Electron path resolution** — For bug fixes in the Electron app, always check path resolution differences between dev and production builds (`app.isPackaged`, `process.resourcesPath`). Paths that work in dev often break when Electron is bundled for production — verify both contexts.
## Project Structure
```
@@ -98,30 +110,6 @@ cd apps/backend && uv venv && uv pip install -r requirements.txt
cd apps/frontend && npm install
```
### Backend
```bash
cd apps/backend
python spec_runner.py --interactive # Create spec interactively
python spec_runner.py --task "description" # Create from task
python run.py --spec 001 # Run autonomous build
python run.py --spec 001 --qa # Run QA validation
python run.py --spec 001 --merge # Merge completed build
python run.py --list # List all specs
```
### Frontend
```bash
cd apps/frontend
npm run dev # Dev mode (Electron + Vite HMR)
npm run build # Production build
npm run test # Vitest unit tests
npm run test:watch # Vitest watch mode
npm run lint # Biome check
npm run lint:fix # Biome auto-fix
npm run typecheck # TypeScript strict check
npm run package # Package for distribution
```
### Testing
| Stack | Command | Tool |
@@ -145,30 +133,7 @@ See [RELEASE.md](RELEASE.md) for full release process.
Client: `apps/backend/core/client.py``create_client()` returns a configured `ClaudeSDKClient` with security hooks, tool permissions, and MCP server integration.
Model and thinking level are user-configurable (via the Electron UI settings or CLI override). Use `phase_config.py` helpers to resolve the correct values:
```python
from core.client import create_client
from phase_config import get_phase_model, get_phase_thinking_budget
# Resolve model/thinking from user settings (Electron UI or CLI override)
phase_model = get_phase_model(spec_dir, "coding", cli_model=None)
phase_thinking = get_phase_thinking_budget(spec_dir, "coding", cli_thinking=None)
client = create_client(
project_dir=project_dir,
spec_dir=spec_dir,
model=phase_model,
agent_type="coder", # planner | coder | qa_reviewer | qa_fixer
max_thinking_tokens=phase_thinking,
)
# Run agent session (uses context manager + run_agent_session helper)
async with client:
status, response = await run_agent_session(client, prompt, spec_dir)
```
Working examples: `agents/planner.py`, `agents/coder.py`, `qa/reviewer.py`, `qa/fixer.py`, `spec/`
Model and thinking level are user-configurable (via the Electron UI settings or CLI override). Use `phase_config.py` helpers to resolve the correct values
### Agent Prompts (`apps/backend/prompts/`)
@@ -323,6 +288,8 @@ cd apps/backend && python run.py --spec 001
# Desktop app
npm start # Production build + run
npm run dev # Development mode with HMR
npm run dev:debug # Debug mode with verbose output
npm run dev:mcp # Electron MCP server for AI debugging
# Project data: .auto-claude/specs/ (gitignored)
```
@@ -27,32 +27,7 @@ import { AgentManager } from "../agent";
import { debugLog, debugError } from "../../shared/utils/debug-logger";
import { safeSendToRenderer } from "./utils";
import { writeFileWithRetry, readFileWithRetry } from "../utils/atomic-file";
/**
* Simple in-process file lock to serialize read-modify-write operations.
* Prevents concurrent IPC calls from causing lost updates on the same file.
*/
const fileLocks = new Map<string, Promise<void>>();
async function withFileLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
// Wait for any existing lock on this file
while (fileLocks.has(filepath)) {
await fileLocks.get(filepath);
}
let resolve: (() => void) | undefined;
const lockPromise = new Promise<void>((r) => {
resolve = r;
});
fileLocks.set(filepath, lockPromise);
try {
return await fn();
} finally {
fileLocks.delete(filepath);
resolve?.();
}
}
import { withFileLock } from "../utils/file-lock";
/**
* Read feature settings from the settings file
@@ -221,6 +196,8 @@ export function registerRoadmapHandlers(
acceptanceCriteria: feature.acceptance_criteria || [],
userStories: feature.user_stories || [],
linkedSpecId: feature.linked_spec_id,
taskOutcome: feature.task_outcome,
previousStatus: feature.previous_status,
competitorInsightIds: (feature.competitor_insight_ids as string[]) || undefined,
})),
status: rawRoadmap.status || "draft",
@@ -432,6 +409,8 @@ export function registerRoadmapHandlers(
acceptance_criteria: feature.acceptanceCriteria || [],
user_stories: feature.userStories || [],
linked_spec_id: feature.linkedSpecId,
task_outcome: feature.taskOutcome,
previous_status: feature.previousStatus,
competitor_insight_ids: feature.competitorInsightIds,
}));
@@ -491,6 +470,10 @@ export function registerRoadmapHandlers(
}
feature.status = status;
if (status !== 'done') {
delete feature.task_outcome;
delete feature.previous_status;
}
roadmap.metadata = roadmap.metadata || {};
roadmap.metadata.updated_at = new Date().toISOString();
@@ -1,9 +1,10 @@
import { ipcMain, nativeImage } from 'electron';
import { IPC_CHANNELS, AUTO_BUILD_PATHS, getSpecsDir, VALID_THINKING_LEVELS, sanitizeThinkingLevel } from '../../../shared/constants';
import type { IPCResult, Task, TaskMetadata } from '../../../shared/types';
import type { IPCResult, Task, TaskMetadata, TaskOutcome } from '../../../shared/types';
import path from 'path';
import { execFileSync } from 'child_process';
import { existsSync, readFileSync, writeFileSync, mkdirSync, readdirSync, Dirent } from 'fs';
import { updateRoadmapFeatureOutcome } from '../../utils/roadmap-utils';
import { projectStore } from '../../project-store';
import { titleGenerator } from '../../title-generator';
import { AgentManager } from '../../agent';
@@ -103,6 +104,19 @@ function truncateToTitle(description: string): string {
return title;
}
/**
* Update a linked roadmap feature when a task is deleted.
* Delegates to shared utility with file locking and retry.
*/
async function updateLinkedRoadmapFeature(
projectPath: string,
specId: string,
taskOutcome: TaskOutcome
): Promise<void> {
const roadmapFile = path.join(projectPath, AUTO_BUILD_PATHS.ROADMAP_DIR, AUTO_BUILD_PATHS.ROADMAP_FILE);
await updateRoadmapFeatureOutcome(roadmapFile, [specId], taskOutcome, '[TASK_CRUD]');
}
/**
* Register task CRUD (Create, Read, Update, Delete) handlers
*/
@@ -411,6 +425,13 @@ export function registerTaskCRUDHandlers(agentManager: AgentManager): void {
};
}
// Update any linked roadmap feature (only after successful deletion)
try {
await updateLinkedRoadmapFeature(project.path, task.specId, 'deleted');
} catch (err) {
console.warn('[TASK_DELETE] Failed to update linked roadmap feature:', err);
}
return { success: true };
}
);
@@ -11,6 +11,7 @@ import { getConfiguredPythonPath, PythonEnvManager, pythonEnvManager as pythonEn
import { getEffectiveSourcePath } from '../../updater/path-resolver';
import { getBestAvailableProfileEnv } from '../../rate-limit-detector';
import { findTaskAndProject } from './shared';
import { updateRoadmapFeatureOutcome } from '../../utils/roadmap-utils';
import { parsePythonCommand } from '../../python-detector';
import { getToolPath } from '../../cli-tool-manager';
import { promisify } from 'util';
@@ -3351,6 +3352,14 @@ export function registerWorktreeHandlers(
task.specId,
debug
);
// Update linked roadmap feature on backend (complements renderer-side handling)
if (project.path && task.specId) {
const roadmapFile = path.join(project.path, AUTO_BUILD_PATHS.ROADMAP_DIR, AUTO_BUILD_PATHS.ROADMAP_FILE);
updateRoadmapFeatureOutcome(roadmapFile, [task.specId], 'completed', '[PR_CREATE]').catch((err) => {
debug('Failed to update roadmap feature after PR creation:', err);
});
}
} else if (result.alreadyExists) {
debug('PR already exists, not updating task status');
}
+20
View File
@@ -9,6 +9,7 @@ import { getTaskWorktreeDir } from './worktree-paths';
import { findAllSpecPaths } from './utils/spec-path-helpers';
import { ensureAbsolutePath } from './utils/path-helpers';
import { writeFileAtomicSync } from './utils/atomic-file';
import { updateRoadmapFeatureOutcome, revertRoadmapFeatureOutcome } from './utils/roadmap-utils';
interface TabState {
openProjectIds: string[];
@@ -809,12 +810,25 @@ export class ProjectStore {
}
}
// Update linked roadmap features for archived tasks
this.updateRoadmapForArchivedTasks(project, taskIds);
// Invalidate cache since task metadata changed
this.invalidateTasksCache(projectId);
return !hasErrors;
}
/**
* Update roadmap features linked to archived tasks
*/
private updateRoadmapForArchivedTasks(project: Project, taskIds: string[]): void {
const roadmapFile = path.join(project.path, AUTO_BUILD_PATHS.ROADMAP_DIR, AUTO_BUILD_PATHS.ROADMAP_FILE);
updateRoadmapFeatureOutcome(roadmapFile, taskIds, 'archived', '[ProjectStore]').catch((err) => {
console.warn('[ProjectStore] Failed to update roadmap for archived tasks:', err);
});
}
/**
* Unarchive tasks by removing archivedAt from their metadata
* @param projectId - Project ID
@@ -867,6 +881,12 @@ export class ProjectStore {
}
}
// Revert linked roadmap features from 'archived' back to 'in_progress'
const roadmapFile = path.join(project.path, AUTO_BUILD_PATHS.ROADMAP_DIR, AUTO_BUILD_PATHS.ROADMAP_FILE);
revertRoadmapFeatureOutcome(roadmapFile, taskIds, '[ProjectStore]').catch((err) => {
console.warn('[ProjectStore] Failed to revert roadmap for unarchived tasks:', err);
});
// Invalidate cache since task metadata changed
this.invalidateTasksCache(projectId);
+27
View File
@@ -0,0 +1,27 @@
/**
* In-process file lock for serializing read-modify-write operations.
* Prevents concurrent IPC calls from causing lost updates on the same file.
*
* Shared across all modules to ensure a single lock map coordinates access.
*/
const fileLocks = new Map<string, Promise<void>>();
export async function withFileLock<T>(filepath: string, fn: () => Promise<T>): Promise<T> {
while (fileLocks.has(filepath)) {
await fileLocks.get(filepath);
}
let resolve: (() => void) | undefined;
const lockPromise = new Promise<void>((r) => {
resolve = r;
});
fileLocks.set(filepath, lockPromise);
try {
return await fn();
} finally {
fileLocks.delete(filepath);
resolve?.();
}
}
@@ -0,0 +1,110 @@
/**
* Shared roadmap file utilities for updating feature outcomes.
*
* Used by task deletion (crud-handlers.ts) and archival (project-store.ts)
* to update linked roadmap features when tasks change state.
*/
import { existsSync } from 'fs';
import { readFileWithRetry, writeFileWithRetry } from './atomic-file';
import { withFileLock } from './file-lock';
import type { TaskOutcome } from '../../shared/types/roadmap';
/**
* Update roadmap features on disk when linked tasks change state.
*
* Finds features matching the given specIds and sets their status to 'done'
* with the specified taskOutcome. Uses file locking and retry logic to
* prevent concurrent write races.
*
* @param roadmapFile - Absolute path to roadmap.json
* @param specIds - Spec IDs to match against feature.linked_spec_id / linkedSpecId
* @param taskOutcome - The outcome to set on matched features
* @param logPrefix - Prefix for log messages (e.g., '[TASK_CRUD]')
*/
export async function updateRoadmapFeatureOutcome(
roadmapFile: string,
specIds: string[],
taskOutcome: TaskOutcome,
logPrefix = '[Roadmap]'
): Promise<void> {
if (!existsSync(roadmapFile)) return;
const specIdSet = new Set(specIds);
await withFileLock(roadmapFile, async () => {
try {
const content = await readFileWithRetry(roadmapFile, { encoding: 'utf-8' });
const roadmap = JSON.parse(content as string);
if (!roadmap.features || !Array.isArray(roadmap.features)) return;
let changed = false;
for (const feature of roadmap.features) {
const linkedId = feature.linked_spec_id || feature.linkedSpecId;
if (linkedId && specIdSet.has(linkedId) && (feature.status !== 'done' || feature.task_outcome !== taskOutcome)) {
if (feature.status !== 'done') {
feature.previous_status = feature.status;
}
feature.status = 'done';
feature.task_outcome = taskOutcome;
changed = true;
}
}
if (changed) {
roadmap.metadata = roadmap.metadata || {};
roadmap.metadata.updated_at = new Date().toISOString();
await writeFileWithRetry(roadmapFile, JSON.stringify(roadmap, null, 2));
console.log(`${logPrefix} Updated roadmap features for ${specIds.length} task(s) with outcome: ${taskOutcome}`);
}
} catch (err) {
console.warn(`${logPrefix} Failed to update roadmap for tasks [${specIds.join(', ')}]:`, err);
}
});
}
/**
* Revert roadmap features when a task is unarchived.
*
* Finds features matching the given specIds that have taskOutcome='archived',
* resets their status to 'in_progress' and removes taskOutcome.
*/
export async function revertRoadmapFeatureOutcome(
roadmapFile: string,
specIds: string[],
logPrefix = '[Roadmap]'
): Promise<void> {
if (!existsSync(roadmapFile)) return;
const specIdSet = new Set(specIds);
await withFileLock(roadmapFile, async () => {
try {
const content = await readFileWithRetry(roadmapFile, { encoding: 'utf-8' });
const roadmap = JSON.parse(content as string);
if (!roadmap.features || !Array.isArray(roadmap.features)) return;
let changed = false;
for (const feature of roadmap.features) {
const linkedId = feature.linked_spec_id || feature.linkedSpecId;
if (linkedId && specIdSet.has(linkedId) && feature.task_outcome === 'archived') {
feature.status = feature.previous_status || 'in_progress';
delete feature.task_outcome;
delete feature.previous_status;
changed = true;
}
}
if (changed) {
roadmap.metadata = roadmap.metadata || {};
roadmap.metadata.updated_at = new Date().toISOString();
await writeFileWithRetry(roadmapFile, JSON.stringify(roadmap, null, 2));
console.log(`${logPrefix} Reverted roadmap features for ${specIds.length} unarchived task(s)`);
}
} catch (err) {
console.warn(`${logPrefix} Failed to revert roadmap for tasks [${specIds.join(', ')}]:`, err);
}
});
}
@@ -499,6 +499,113 @@ describe('Roadmap Store', () => {
const state = useRoadmapStore.getState();
expect(state.roadmap?.features[0].status).toBe('in_progress');
});
it('should clear taskOutcome and previousStatus when moving away from done', () => {
const features = [createTestFeature({
id: 'feature-1',
status: 'done' as RoadmapFeatureStatus,
taskOutcome: 'completed',
previousStatus: 'in_progress' as RoadmapFeatureStatus
})];
const roadmap = createTestRoadmap({ features });
useRoadmapStore.setState({ roadmap });
useRoadmapStore.getState().updateFeatureStatus('feature-1', 'in_progress');
const state = useRoadmapStore.getState();
expect(state.roadmap?.features[0].status).toBe('in_progress');
expect(state.roadmap?.features[0].taskOutcome).toBeUndefined();
expect(state.roadmap?.features[0].previousStatus).toBeUndefined();
});
it('should preserve taskOutcome when status remains done', () => {
const features = [createTestFeature({
id: 'feature-1',
status: 'done' as RoadmapFeatureStatus,
taskOutcome: 'completed'
})];
const roadmap = createTestRoadmap({ features });
useRoadmapStore.setState({ roadmap });
useRoadmapStore.getState().updateFeatureStatus('feature-1', 'done');
const state = useRoadmapStore.getState();
expect(state.roadmap?.features[0].taskOutcome).toBe('completed');
});
});
describe('markFeatureDoneBySpecId', () => {
it('should mark feature as done with taskOutcome', () => {
const features = [createTestFeature({
id: 'feature-1',
linkedSpecId: 'spec-001',
status: 'in_progress' as RoadmapFeatureStatus
})];
const roadmap = createTestRoadmap({ features });
useRoadmapStore.setState({ roadmap });
useRoadmapStore.getState().markFeatureDoneBySpecId('spec-001', 'completed');
const state = useRoadmapStore.getState();
expect(state.roadmap?.features[0].status).toBe('done');
expect(state.roadmap?.features[0].taskOutcome).toBe('completed');
});
it('should preserve previousStatus before overwriting to done', () => {
const features = [createTestFeature({
id: 'feature-1',
linkedSpecId: 'spec-001',
status: 'planned' as RoadmapFeatureStatus
})];
const roadmap = createTestRoadmap({ features });
useRoadmapStore.setState({ roadmap });
useRoadmapStore.getState().markFeatureDoneBySpecId('spec-001', 'archived');
const state = useRoadmapStore.getState();
expect(state.roadmap?.features[0].status).toBe('done');
expect(state.roadmap?.features[0].taskOutcome).toBe('archived');
expect(state.roadmap?.features[0].previousStatus).toBe('planned');
});
it('should not overwrite previousStatus if already done', () => {
const features = [createTestFeature({
id: 'feature-1',
linkedSpecId: 'spec-001',
status: 'done' as RoadmapFeatureStatus,
taskOutcome: 'completed',
previousStatus: 'in_progress' as RoadmapFeatureStatus
})];
const roadmap = createTestRoadmap({ features });
useRoadmapStore.setState({ roadmap });
useRoadmapStore.getState().markFeatureDoneBySpecId('spec-001', 'archived');
const state = useRoadmapStore.getState();
expect(state.roadmap?.features[0].taskOutcome).toBe('archived');
expect(state.roadmap?.features[0].previousStatus).toBe('in_progress');
});
it('should not affect features with different linkedSpecId', () => {
const features = [
createTestFeature({ id: 'feature-1', linkedSpecId: 'spec-001', status: 'in_progress' as RoadmapFeatureStatus }),
createTestFeature({ id: 'feature-2', linkedSpecId: 'spec-002', status: 'planned' as RoadmapFeatureStatus })
];
const roadmap = createTestRoadmap({ features });
useRoadmapStore.setState({ roadmap });
useRoadmapStore.getState().markFeatureDoneBySpecId('spec-001', 'completed');
const state = useRoadmapStore.getState();
expect(state.roadmap?.features[1].status).toBe('planned');
expect(state.roadmap?.features[1].taskOutcome).toBeUndefined();
});
});
describe('updateFeatureLinkedSpec', () => {
@@ -10,6 +10,7 @@ import {
TooltipTrigger
} from './ui/tooltip';
import { Play, ExternalLink, TrendingUp, Layers, ThumbsUp } from 'lucide-react';
import { TaskOutcomeBadge, getTaskOutcomeColorClass } from './roadmap/TaskOutcomeBadge';
import {
ROADMAP_PRIORITY_COLORS,
ROADMAP_PRIORITY_LABELS,
@@ -120,7 +121,14 @@ export function SortableFeatureCard({
<h3 className="font-medium text-sm leading-snug line-clamp-2">{feature.title}</h3>
</div>
<div className="shrink-0">
{feature.linkedSpecId ? (
{feature.taskOutcome ? (
<Badge
variant="outline"
className={`text-[10px] px-1.5 py-0 ${getTaskOutcomeColorClass(feature.taskOutcome)}`}
>
<TaskOutcomeBadge outcome={feature.taskOutcome} size="sm" />
</Badge>
) : feature.linkedSpecId ? (
<Button
variant="outline"
size="sm"
@@ -1,4 +1,5 @@
import { ExternalLink, Play, TrendingUp } from 'lucide-react';
import { TaskOutcomeBadge, getTaskOutcomeColorClass } from './TaskOutcomeBadge';
import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
import { Card } from '../ui/card';
@@ -18,6 +19,7 @@ export function FeatureCard({
onGoToTask,
hasCompetitorInsight = false,
}: FeatureCardProps) {
return (
<Card className="p-4 hover:bg-muted/50 cursor-pointer transition-colors" onClick={onClick}>
<div className="flex items-start justify-between">
@@ -53,7 +55,11 @@ export function FeatureCard({
<h3 className="font-medium">{feature.title}</h3>
<p className="text-sm text-muted-foreground line-clamp-2">{feature.description}</p>
</div>
{feature.linkedSpecId ? (
{feature.taskOutcome ? (
<Badge variant="outline" className={`text-xs ${getTaskOutcomeColorClass(feature.taskOutcome)}`}>
<TaskOutcomeBadge outcome={feature.taskOutcome} size="md" />
</Badge>
) : feature.linkedSpecId ? (
<Button
variant="outline"
size="sm"
@@ -12,6 +12,7 @@ import {
TrendingUp,
Trash2,
} from 'lucide-react';
import { TaskOutcomeBadge } from './TaskOutcomeBadge';
import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
import { Card } from '../ui/card';
@@ -214,7 +215,13 @@ export function FeatureDetailPanel({
</ScrollArea>
{/* Actions */}
{feature.linkedSpecId ? (
{feature.taskOutcome ? (
<div className="shrink-0 p-4 border-t border-border">
<div className="flex items-center justify-center gap-2 py-2">
<TaskOutcomeBadge outcome={feature.taskOutcome} size="lg" />
</div>
</div>
) : feature.linkedSpecId ? (
<div className="shrink-0 p-4 border-t border-border">
<Button className="w-full" onClick={() => onGoToTask(feature.linkedSpecId!)}>
<ExternalLink className="h-4 w-4 mr-2" />
@@ -1,4 +1,5 @@
import { CheckCircle2, Circle, ExternalLink, Play, TrendingUp } from 'lucide-react';
import { TaskOutcomeBadge } from './TaskOutcomeBadge';
import { Badge } from '../ui/badge';
import { Button } from '../ui/button';
import { Card } from '../ui/card';
@@ -104,7 +105,11 @@ export function PhaseCard({
<TrendingUp className="h-3 w-3 text-primary flex-shrink-0" />
)}
</div>
{feature.status === 'done' ? (
{feature.taskOutcome ? (
<span className="flex-shrink-0">
<TaskOutcomeBadge outcome={feature.taskOutcome} size="lg" showLabel={false} />
</span>
) : feature.status === 'done' ? (
<CheckCircle2 className="h-4 w-4 text-success flex-shrink-0" />
) : feature.linkedSpecId ? (
<Button
@@ -0,0 +1,60 @@
import { useTranslation } from 'react-i18next';
import { Archive, CheckCircle2, Trash2 } from 'lucide-react';
import type { TaskOutcome } from '../../../shared/types';
interface TaskOutcomeConfig {
icon: typeof CheckCircle2;
label: string;
colorClass: string;
}
function useTaskOutcomeConfig(outcome: TaskOutcome): TaskOutcomeConfig {
const { t } = useTranslation('common');
switch (outcome) {
case 'completed':
return { icon: CheckCircle2, label: t('roadmap.taskCompleted'), colorClass: 'text-success' };
case 'archived':
return { icon: Archive, label: t('roadmap.taskArchived'), colorClass: 'text-success' };
case 'deleted':
return { icon: Trash2, label: t('roadmap.taskDeleted'), colorClass: 'text-muted-foreground' };
}
}
export type TaskOutcomeBadgeSize = 'sm' | 'md' | 'lg';
const ICON_SIZES: Record<TaskOutcomeBadgeSize, string> = {
sm: 'h-2.5 w-2.5',
md: 'h-3 w-3',
lg: 'h-4 w-4',
};
interface TaskOutcomeBadgeProps {
outcome: TaskOutcome;
size?: TaskOutcomeBadgeSize;
showLabel?: boolean;
}
/**
* Renders a consistent task outcome icon + label across all roadmap views.
* Returns the icon and label as inline elements (caller wraps in Badge/div as needed).
*/
export function TaskOutcomeBadge({ outcome, size = 'md', showLabel = true }: TaskOutcomeBadgeProps) {
const config = useTaskOutcomeConfig(outcome);
const Icon = config.icon;
const iconSize = ICON_SIZES[size];
return (
<span className={`inline-flex items-center gap-0.5 ${config.colorClass}`}>
<Icon className={iconSize} />
{showLabel && <span>{config.label}</span>}
</span>
);
}
/**
* Returns the color class for a task outcome (for use in parent wrapper styling).
*/
export function getTaskOutcomeColorClass(outcome: TaskOutcome): string {
return outcome === 'deleted' ? 'text-muted-foreground border-muted-foreground/50' : 'text-success border-success/50';
}
@@ -212,6 +212,19 @@ export function useIpcListeners(): void {
// Filter by project to prevent multi-project interference
if (!isTaskForCurrentProject(projectId)) return;
queueUpdate(taskId, { status, reviewReason });
// Sync roadmap feature when task completes
if (status === 'done' || status === 'pr_created') {
useRoadmapStore.getState().markFeatureDoneBySpecId(taskId);
// Re-read state after mutation to get updated roadmap
const rm = useRoadmapStore.getState().roadmap;
const currentProjectId = useProjectStore.getState().activeProjectId || useProjectStore.getState().selectedProjectId;
if (rm && currentProjectId) {
window.electronAPI.saveRoadmap(currentProjectId, rm).catch((err) => {
console.error('[useIpc] Failed to persist roadmap after task completion:', err);
});
}
}
}
);
@@ -5,6 +5,7 @@ import type {
RoadmapFeature,
RoadmapFeatureStatus,
RoadmapGenerationStatus,
TaskOutcome,
FeatureSource
} from '../../shared/types';
@@ -59,7 +60,7 @@ interface RoadmapState {
setGenerationStatus: (status: RoadmapGenerationStatus) => void;
setCurrentProjectId: (projectId: string | null) => void;
updateFeatureStatus: (featureId: string, status: RoadmapFeatureStatus) => void;
markFeatureDoneBySpecId: (specId: string) => void;
markFeatureDoneBySpecId: (specId: string, taskOutcome?: TaskOutcome) => void;
updateFeatureLinkedSpec: (featureId: string, specId: string) => void;
deleteFeature: (featureId: string) => void;
clearRoadmap: () => void;
@@ -116,7 +117,9 @@ export const useRoadmapStore = create<RoadmapState>((set) => ({
if (!state.roadmap) return state;
const updatedFeatures = state.roadmap.features.map((feature) =>
feature.id === featureId ? { ...feature, status } : feature
feature.id === featureId
? { ...feature, status, ...(status !== 'done' ? { taskOutcome: undefined, previousStatus: undefined } : {}) }
: feature
);
return {
@@ -129,13 +132,13 @@ export const useRoadmapStore = create<RoadmapState>((set) => ({
}),
// Mark feature as done when its linked task completes
markFeatureDoneBySpecId: (specId: string) =>
markFeatureDoneBySpecId: (specId: string, taskOutcome: TaskOutcome = 'completed') =>
set((state) => {
if (!state.roadmap) return state;
const updatedFeatures = state.roadmap.features.map((feature) =>
feature.linkedSpecId === specId
? { ...feature, status: 'done' as RoadmapFeatureStatus }
? { ...feature, status: 'done' as RoadmapFeatureStatus, taskOutcome, previousStatus: feature.status !== 'done' ? feature.status : feature.previousStatus }
: feature
);
@@ -261,6 +264,67 @@ export const useRoadmapStore = create<RoadmapState>((set) => ({
}
}));
/**
* Reconcile roadmap features with their linked tasks.
* Catches cases where tasks were completed/deleted before this fix was deployed,
* or if the app crashed mid-operation.
*/
async function reconcileLinkedFeatures(projectId: string, roadmap: Roadmap): Promise<void> {
const store = useRoadmapStore.getState();
// Find features that have a linkedSpecId but aren't done yet (or are done without taskOutcome)
const featuresNeedingReconciliation = roadmap.features.filter(
(f) => f.linkedSpecId && (f.status !== 'done' || !f.taskOutcome)
);
if (featuresNeedingReconciliation.length === 0) return;
// Fetch current tasks for the project
const tasksResult = await window.electronAPI.getTasks(projectId);
if (!tasksResult.success || !tasksResult.data) return;
// Guard against empty task list (e.g., specs directory temporarily inaccessible)
// to avoid falsely marking all linked features as 'deleted'
if (tasksResult.data.length === 0 && featuresNeedingReconciliation.length > 0) return;
const taskMap = new Map(tasksResult.data.map((t) => [t.specId || t.id, t]));
let hasChanges = false;
for (const feature of featuresNeedingReconciliation) {
const task = taskMap.get(feature.linkedSpecId!);
if (!task) {
// Task no longer exists → mark as done with deleted outcome
if (feature.status !== 'done' || feature.taskOutcome !== 'deleted') {
store.markFeatureDoneBySpecId(feature.linkedSpecId!, 'deleted');
hasChanges = true;
}
} else if (task.status === 'done' || task.status === 'pr_created') {
// Task is completed → mark feature as done
if (feature.status !== 'done' || !feature.taskOutcome) {
store.markFeatureDoneBySpecId(feature.linkedSpecId!, 'completed');
hasChanges = true;
}
} else if (task.metadata?.archivedAt) {
// Task is archived → mark feature as done with archived outcome
if (feature.status !== 'done' || feature.taskOutcome !== 'archived') {
store.markFeatureDoneBySpecId(feature.linkedSpecId!, 'archived');
hasChanges = true;
}
}
}
if (hasChanges) {
const updatedRoadmap = useRoadmapStore.getState().roadmap;
if (updatedRoadmap) {
console.log('[Roadmap] Reconciled linked features with task states');
window.electronAPI.saveRoadmap(projectId, updatedRoadmap).catch((err) => {
console.error('[Roadmap] Failed to save reconciled roadmap:', err);
});
}
}
}
// Helper functions for loading roadmap
export async function loadRoadmap(projectId: string): Promise<void> {
const store = useRoadmapStore.getState();
@@ -325,6 +389,9 @@ export async function loadRoadmap(projectId: string): Promise<void> {
});
}
// Reconcile features with linked tasks that may have been completed/deleted
await reconcileLinkedFeatures(projectId, migratedRoadmap);
// Extract and set competitor analysis separately if present
if (migratedRoadmap.competitorAnalysis) {
store.setCompetitorAnalysis(migratedRoadmap.competitorAnalysis);
@@ -594,6 +594,11 @@
"step3": "Click \"Authenticate\" to complete login",
"footer": "The account will be available once you complete authentication."
},
"roadmap": {
"taskCompleted": "Completed",
"taskDeleted": "Deleted",
"taskArchived": "Archived"
},
"roadmapGeneration": {
"progress": "Progress",
"elapsed": "Elapsed: {{time}}",
@@ -594,6 +594,11 @@
"step3": "Cliquez sur « Authentifier » pour terminer la connexion",
"footer": "Le compte sera disponible une fois l'authentification terminée."
},
"roadmap": {
"taskCompleted": "Terminé",
"taskDeleted": "Supprimé",
"taskArchived": "Archivé"
},
"roadmapGeneration": {
"progress": "Progression",
"elapsed": "Écoulé : {{time}}",
@@ -69,6 +69,7 @@ export interface CompetitorAnalysis {
export type RoadmapFeaturePriority = 'must' | 'should' | 'could' | 'wont';
export type RoadmapFeatureStatus = 'under_review' | 'planned' | 'in_progress' | 'done';
export type TaskOutcome = 'completed' | 'deleted' | 'archived';
export type RoadmapPhaseStatus = 'planned' | 'in_progress' | 'completed';
export type RoadmapStatus = 'draft' | 'active' | 'archived';
@@ -122,6 +123,8 @@ export interface RoadmapFeature {
acceptanceCriteria: string[];
userStories: string[];
linkedSpecId?: string;
taskOutcome?: TaskOutcome;
previousStatus?: RoadmapFeatureStatus;
competitorInsightIds?: string[];
// External integration fields
source?: FeatureSource;