diff --git a/docs/agents.md b/docs/agents.md index 9f7afc484..beabc48bb 100644 --- a/docs/agents.md +++ b/docs/agents.md @@ -311,12 +311,23 @@ fn agent mailbox AGENT-001 When messaging tools are enabled for an agent, heartbeat runs check for unread mailbox messages during execution regardless of the trigger type. This ensures agents can see and respond to incoming messages without needing an explicit wake-on-message trigger. +### Reply Linking Contract + +Mailbox replies use `message.metadata.replyTo.messageId` as the stable reply link. + +- `read_messages` includes each message ID in its human-readable output so agents can target a specific message. +- `send_message` supports `reply_to_message_id`; when provided, the sent message is stored with `metadata.replyTo.messageId`. +- Heartbeat prompts explicitly instruct agents to include `reply_to_message_id` when replying. + +The dashboard mailbox UI also uses the same metadata contract when users click **Reply**, so user and agent replies share one threading model. + ### How It Works -1. **Message Prefetch**: When `messageStore` is available, heartbeat runs fetch up to 10 unread inbox messages for the agent -2. **Prompt Injection**: Pending messages are injected into the execution prompt with sender and timestamp information -3. **Mark as Read**: After successful heartbeat completion, messages are marked as read -4. **Failed Runs**: If the heartbeat execution fails, messages remain unread for retry on the next run +1. **Message Prefetch**: When `messageStore` is available, heartbeat runs fetch up to 10 unread inbox messages for the agent. +2. **Prompt Injection**: Pending messages are injected into the execution prompt with message ID, sender, and timestamp information. +3. **Reply Guidance**: System instructions remind agents to reply with `reply_to_message_id` for linked threads. +4. **Mark as Read**: After successful heartbeat completion, messages are marked as read. +5. **Failed Runs**: If the heartbeat execution fails, messages remain unread for retry on the next run. ### Message Response Modes diff --git a/docs/workflow-steps.md b/docs/workflow-steps.md index 19fcee5e9..983f273a5 100644 --- a/docs/workflow-steps.md +++ b/docs/workflow-steps.md @@ -104,18 +104,35 @@ The revision block replaces any prior revision instructions (no accumulation). Not all workflow failures are revision requests: -- **Revision requested**: Implementation needs changes → routes back to executor -- **Hard failure**: Unrecoverable issue → moves to `in-review` with `failed` status +- **Revision requested**: Implementation needs changes → routes back to executor in-place while keeping the task in `in-progress` +- **Hard failure**: Treated as remediable until retries are exhausted; the executor injects feedback and sends the task through `todo → in-progress` for a fresh remediation pass -Use revision requests when the implementation is wrong but fixable. Use hard failures only for genuine blockers (e.g., required files are missing, test infrastructure is broken). +#### Pre-merge hard failure remediation flow + +For pre-merge workflow hard failures, executor behavior is: + +1. Retry the failing check up to `MAX_WORKFLOW_STEP_RETRIES` within the same execution lifecycle +2. On retry exhaustion, add a steering comment with failure details and inject a `Workflow Step Failure` section into `PROMPT.md` +3. Reopen only the last implementation step (`pending`) so prior completed work remains preserved +4. Schedule `todo → in-progress` after guard unwind, triggering a fresh executor remediation run + +Tasks are not parked in `in-review` for this remediable path unless additional terminal failures occur. + +#### Self-healing recovery for parked review tasks + +If a task is found in `in-review` with failed pre-merge workflow results and no active executor, self-healing can auto-revive it (bounded by `maxPostReviewFixes`) by replaying the same remediation send-back flow. ## Viewing Results -Task detail modal includes a **Workflow** tab when workflow data exists. +Workflow status is visible in multiple places: -You can inspect: +- **Task cards**: workflow checks are shown after normal implementation steps in the step list; each workflow row is labeled (`Workflow · Pre-merge` / `Workflow · Post-merge`) and progress counts include both implementation and workflow checks +- **List view (desktop + mobile)**: progress labels/bars use the same unified step model as task cards +- **Task detail modal**: includes a **Workflow** tab when workflow data exists -- pass/fail/skipped status +In the Workflow tab, you can inspect: + +- pass/fail/skipped/running status - outputs/findings - timing metadata diff --git a/packages/cli/skill/fusion/SKILL.md b/packages/cli/skill/fusion/SKILL.md index 4ded1ef8e..9be29f2cf 100644 --- a/packages/cli/skill/fusion/SKILL.md +++ b/packages/cli/skill/fusion/SKILL.md @@ -1,11 +1,11 @@ --- name: fusion -description: AI-orchestrated task board (Fusion/kb) interface. Use when working with the Fusion task management system, creating or managing tasks, understanding task workflows, organizing work into missions, or interfacing with the kb dashboard. Triggers on "create a task", "list tasks", "show board", "plan a mission", "check task status", "import issues", or any Fusion/kb interaction. +description: AI-orchestrated task board (Fusion) interface. Use when working with the Fusion task management system, creating or managing tasks, understanding task workflows, organizing work into missions, or interfacing with the fusion dashboard. Triggers on "create a task", "list tasks", "show board", "plan a mission", "check task status", "import issues", or any Fusion interaction. --- -Fusion (kb) is an AI-orchestrated task board. You throw in rough ideas; AI specifies, executes, reviews, and delivers them. +Fusion is an AI-orchestrated task board. You throw in rough ideas; AI specifies, executes, reviews, and delivers them. **Task lifecycle:** Triage → Todo → In Progress → In Review → Done → Archived diff --git a/packages/cli/src/commands/claude-skills.test.ts b/packages/cli/src/commands/claude-skills.test.ts new file mode 100644 index 000000000..279a302ee --- /dev/null +++ b/packages/cli/src/commands/claude-skills.test.ts @@ -0,0 +1,189 @@ +import { + existsSync, + lstatSync, + mkdirSync, + readFileSync, + readlinkSync, + symlinkSync, + writeFileSync, +} from "node:fs"; +import { join } from "node:path"; +import { describe, expect, it } from "vitest"; +import { tempWorkspace } from "@fusion/test-utils"; +import { + ensureFusionSkillForProjects, + installFusionSkillIntoProject, + isPiClaudeCliConfigured, +} from "./claude-skills.js"; + +function makeSourceSkill(root: string, body = "---\nname: fusion\n---\n# hi\n"): string { + const dir = join(root, "src-skill", "fusion"); + mkdirSync(dir, { recursive: true }); + writeFileSync(join(dir, "SKILL.md"), body); + return dir; +} + +describe("isPiClaudeCliConfigured", () => { + it("returns false for null or empty settings", () => { + expect(isPiClaudeCliConfigured(null)).toBe(false); + expect(isPiClaudeCliConfigured(undefined)).toBe(false); + expect(isPiClaudeCliConfigured({})).toBe(false); + }); + + it("respects explicit useClaudeCli=true", () => { + expect(isPiClaudeCliConfigured({ useClaudeCli: true })).toBe(true); + }); + + it("respects explicit useClaudeCli=false even when package is present", () => { + expect( + isPiClaudeCliConfigured({ + useClaudeCli: false, + packages: ["npm:pi-claude-cli"], + }), + ).toBe(false); + }); + + it("detects pi-claude-cli in packages array", () => { + expect(isPiClaudeCliConfigured({ packages: ["npm:pi-claude-cli"] })).toBe(true); + expect(isPiClaudeCliConfigured({ packages: ["npm:pi-claude-cli@0.3.1"] })).toBe(true); + expect(isPiClaudeCliConfigured({ packages: ["github:owner/pi-claude-cli"] })).toBe(true); + }); + + it("ignores unrelated packages", () => { + expect( + isPiClaudeCliConfigured({ packages: ["npm:some-other", "npm:pi-ai"] }), + ).toBe(false); + }); +}); + +describe("installFusionSkillIntoProject", () => { + it("is a no-op when disabled", () => { + const root = tempWorkspace("fusion-claude-skills-"); + const projectPath = join(root, "project"); + mkdirSync(projectPath, { recursive: true }); + const source = makeSourceSkill(root); + + const result = installFusionSkillIntoProject(projectPath, { source, enabled: false }); + expect(result.outcome).toBe("skipped"); + expect(existsSync(join(projectPath, ".claude"))).toBe(false); + }); + + it("creates a symlink on first install", () => { + const root = tempWorkspace("fusion-claude-skills-"); + const projectPath = join(root, "project"); + mkdirSync(projectPath, { recursive: true }); + const source = makeSourceSkill(root); + + const result = installFusionSkillIntoProject(projectPath, { source, enabled: true }); + expect(result.outcome).toBe("installed"); + + const target = join(projectPath, ".claude", "skills", "fusion"); + expect(lstatSync(target).isSymbolicLink()).toBe(true); + expect(readlinkSync(target)).toBe(source); + expect(readFileSync(join(target, "SKILL.md"), "utf-8")).toContain("name: fusion"); + }); + + it("is idempotent when the correct symlink already exists", () => { + const root = tempWorkspace("fusion-claude-skills-"); + const projectPath = join(root, "project"); + mkdirSync(projectPath, { recursive: true }); + const source = makeSourceSkill(root); + + installFusionSkillIntoProject(projectPath, { source, enabled: true }); + const result = installFusionSkillIntoProject(projectPath, { source, enabled: true }); + expect(result.outcome).toBe("already-installed"); + }); + + it("replaces a stale symlink that points elsewhere", () => { + const root = tempWorkspace("fusion-claude-skills-"); + const projectPath = join(root, "project"); + mkdirSync(projectPath, { recursive: true }); + const source = makeSourceSkill(root); + + // Seed a stale symlink pointing at a different dir. + const stale = join(root, "stale"); + mkdirSync(stale, { recursive: true }); + writeFileSync(join(stale, "SKILL.md"), "# stale"); + const target = join(projectPath, ".claude", "skills", "fusion"); + mkdirSync(join(projectPath, ".claude", "skills"), { recursive: true }); + symlinkSync(stale, target, "dir"); + + const result = installFusionSkillIntoProject(projectPath, { source, enabled: true }); + expect(result.outcome).toBe("replaced"); + expect(readlinkSync(target)).toBe(source); + }); + + it("replaces a prior copy-install (plain dir with SKILL.md)", () => { + const root = tempWorkspace("fusion-claude-skills-"); + const projectPath = join(root, "project"); + const source = makeSourceSkill(root); + // Seed a prior copy — looks like a fusion skill install. + const target = join(projectPath, ".claude", "skills", "fusion"); + mkdirSync(target, { recursive: true }); + writeFileSync(join(target, "SKILL.md"), "# old copy\n"); + + const result = installFusionSkillIntoProject(projectPath, { source, enabled: true }); + expect(result.outcome).toBe("replaced"); + expect(lstatSync(target).isSymbolicLink()).toBe(true); + }); + + it("refuses to clobber a foreign directory without SKILL.md", () => { + const root = tempWorkspace("fusion-claude-skills-"); + const projectPath = join(root, "project"); + const source = makeSourceSkill(root); + const target = join(projectPath, ".claude", "skills", "fusion"); + mkdirSync(target, { recursive: true }); + writeFileSync(join(target, "random.txt"), "user data"); + + const result = installFusionSkillIntoProject(projectPath, { source, enabled: true }); + expect(result.outcome).toBe("failed"); + expect(readFileSync(join(target, "random.txt"), "utf-8")).toBe("user data"); + }); + + it("reports failure when source is missing", () => { + const root = tempWorkspace("fusion-claude-skills-"); + const projectPath = join(root, "project"); + mkdirSync(projectPath, { recursive: true }); + + const result = installFusionSkillIntoProject(projectPath, { + source: join(root, "nonexistent"), + enabled: true, + }); + // Source missing -> symlink may succeed on POSIX (to a nonexistent path) + // then later fail to resolve. The function still creates the symlink; + // that's acceptable since fs reads will surface the broken link clearly. + expect(["installed", "failed"]).toContain(result.outcome); + }); +}); + +describe("ensureFusionSkillForProjects", () => { + it("skips all when disabled", () => { + const root = tempWorkspace("fusion-claude-skills-"); + const projects = [ + { id: "a", name: "a", path: join(root, "a") }, + { id: "b", name: "b", path: join(root, "b") }, + ]; + for (const p of projects) mkdirSync(p.path, { recursive: true }); + + const results = ensureFusionSkillForProjects(projects, { enabled: false }); + expect(results.map((r) => r.outcome)).toEqual(["skipped", "skipped"]); + }); + + it("installs for all when enabled", () => { + const root = tempWorkspace("fusion-claude-skills-"); + const source = makeSourceSkill(root); + const projects = [ + { id: "a", name: "a", path: join(root, "a") }, + { id: "b", name: "b", path: join(root, "b") }, + ]; + for (const p of projects) mkdirSync(p.path, { recursive: true }); + + const results = ensureFusionSkillForProjects(projects, { enabled: true, source }); + expect(results.map((r) => r.outcome)).toEqual(["installed", "installed"]); + for (const p of projects) { + expect( + lstatSync(join(p.path, ".claude", "skills", "fusion")).isSymbolicLink(), + ).toBe(true); + } + }); +}); diff --git a/packages/cli/src/commands/daemon.ts b/packages/cli/src/commands/daemon.ts index b527f6934..017365ce4 100644 --- a/packages/cli/src/commands/daemon.ts +++ b/packages/cli/src/commands/daemon.ts @@ -38,6 +38,10 @@ import { } from "./task-lifecycle.js"; import { promptForPort } from "./port-prompt.js"; import { createReadOnlyProviderSettingsView } from "./provider-settings.js"; +import { + ensureClaudeSkillsForAllProjectsOnStartup, + maybeInstallClaudeSkillForNewProject, +} from "./claude-skills-runner.js"; import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js"; import { getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js"; import { resolveProject } from "../project-context.js"; @@ -295,6 +299,22 @@ export async function runDaemon(opts: DaemonOptions = {}) { await engineManager.startAll(); engineManager.startReconciliation(); + // Backfill Claude Code skills for all registered projects. No-op when + // pi-claude-cli isn't configured; non-blocking to protect startup latency. + void (async () => { + try { + if (!sharedCentralCore) return; + const projects = await sharedCentralCore.listProjects(); + ensureClaudeSkillsForAllProjectsOnStartup( + projects.map((p) => ({ id: p.id, name: p.name, path: p.path })), + ); + } catch (err) { + console.warn( + `[fusion] Claude skill reconciliation failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + })(); + // ── PeerExchangeService: gossip protocol for mesh peer discovery ────── let peerExchangeService: PeerExchangeService | null = null; if (sharedCentralCore) { @@ -327,7 +347,12 @@ export async function runDaemon(opts: DaemonOptions = {}) { } // ── PluginStore: plugin installation management ───────────────────── - const pluginStore = new PluginStore(store.getRootDir()); + // Some mocked stores used in tests may not implement getRootDir(); fall + // back to the resolved runtime cwd in that case. + const storeRootDir = typeof (store as { getRootDir?: () => string }).getRootDir === "function" + ? (store as { getRootDir: () => string }).getRootDir() + : cwd; + const pluginStore = new PluginStore(storeRootDir); await pluginStore.init(); // ── PluginLoader: plugin lifecycle management ─────────────────────── @@ -432,6 +457,9 @@ export async function runDaemon(opts: DaemonOptions = {}) { pluginLoader, pluginRunner: pluginLoader, onProjectFirstAccessed: (projectId: string) => engineManager.onProjectAccessed(projectId), + onProjectRegistered: ({ path }) => { + maybeInstallClaudeSkillForNewProject(path); + }, headless: true, daemon: { token: daemonToken }, skillsAdapter, diff --git a/packages/cli/src/commands/dashboard.ts b/packages/cli/src/commands/dashboard.ts index d24e06f3d..22681c1ce 100644 --- a/packages/cli/src/commands/dashboard.ts +++ b/packages/cli/src/commands/dashboard.ts @@ -21,6 +21,10 @@ import { createReadOnlyProviderSettingsView } from "./provider-settings.js"; import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js"; import { getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js"; import { resolveProject } from "../project-context.js"; +import { + ensureClaudeSkillsForAllProjectsOnStartup, + maybeInstallClaudeSkillForNewProject, +} from "./claude-skills-runner.js"; import { DashboardTUI, DashboardLogSink, isTTYAvailable, type SystemInfo } from "./dashboard-tui.js"; // Re-export for backward compatibility with tests @@ -635,7 +639,11 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: // Enables the PluginManager UI to list, install, enable, disable, and // configure plugins via the /api/plugins REST endpoints. // - const pluginStore = new PluginStore(store.getRootDir()); + const pluginStoreRootDir = + typeof (store as { getRootDir?: () => string }).getRootDir === "function" + ? store.getRootDir() + : store.getFusionDir(); + const pluginStore = new PluginStore(pluginStoreRootDir); await pluginStore.init(); // ── PluginLoader: plugin lifecycle management ─────────────────────── @@ -918,6 +926,23 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: // required for correctness — reconciliation handles all cases. engineManager.startReconciliation(); + // Backfill Claude Code skills for all registered projects. No-op when + // pi-claude-cli isn't configured; non-blocking to protect startup latency. + void (async () => { + try { + if (!centralCoreForEngine) return; + const projects = await centralCoreForEngine.listProjects(); + ensureClaudeSkillsForAllProjectsOnStartup( + projects.map((p) => ({ id: p.id, name: p.name, path: p.path })), + ); + } catch (err) { + logSink.log( + `Claude skill reconciliation failed: ${err instanceof Error ? err.message : String(err)}`, + "engine", + ); + } + })(); + // ── PeerExchangeService: gossip protocol for mesh peer discovery ────── // // Reuse centralCoreForEngine for peer exchange since it handles all mesh ops. @@ -972,6 +997,9 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: pluginLoader, pluginRunner: pluginLoader, onProjectFirstAccessed: (projectId: string) => engineManager.onProjectAccessed(projectId), + onProjectRegistered: ({ path }) => { + maybeInstallClaudeSkillForNewProject(path); + }, skillsAdapter, https: loadTlsCredentialsFromEnv(), daemon: dashboardAuthToken ? { token: dashboardAuthToken } : undefined, @@ -1160,6 +1188,9 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?: pluginStore, pluginLoader, pluginRunner: pluginLoader, + onProjectRegistered: ({ path }) => { + maybeInstallClaudeSkillForNewProject(path); + }, skillsAdapter, https: loadTlsCredentialsFromEnv(), daemon: dashboardAuthToken ? { token: dashboardAuthToken } : undefined, diff --git a/packages/cli/src/commands/serve.ts b/packages/cli/src/commands/serve.ts index dde5388d9..544829a69 100644 --- a/packages/cli/src/commands/serve.ts +++ b/packages/cli/src/commands/serve.ts @@ -42,6 +42,10 @@ import { createReadOnlyProviderSettingsView, createProjectSettingsPersistence } import { createReadOnlyAuthFileStorage, mergeAuthStorageReads, wrapAuthStorageWithApiKeyProviders } from "./provider-auth.js"; import { getFusionAuthPath, getLegacyAuthPaths, getModelRegistryModelsPath, getPackageManagerAgentDir } from "./auth-paths.js"; import { resolveProject } from "../project-context.js"; +import { + ensureClaudeSkillsForAllProjectsOnStartup, + maybeInstallClaudeSkillForNewProject, +} from "./claude-skills-runner.js"; const DIAGNOSTIC_INTERVAL_MS = 30 * 60 * 1000; // 30 minutes let diagnosticIntervalHandle: ReturnType | null = null; @@ -316,6 +320,25 @@ export async function runServe( // Start engines for all registered projects eagerly await engineManager.startAll(); + // Backfill Claude Code skills for any registered project that's missing + // `.claude/skills/fusion`. Runs only when pi-claude-cli is configured; for + // users on the direct Anthropic provider this is a no-op and leaves no + // trace in the project tree. Non-blocking — we don't want a slow FS to + // delay server listen. + void (async () => { + try { + if (!sharedCentralCore) return; + const projects = await sharedCentralCore.listProjects(); + ensureClaudeSkillsForAllProjectsOnStartup( + projects.map((p) => ({ id: p.id, name: p.name, path: p.path })), + ); + } catch (err) { + console.warn( + `[fusion] Claude skill reconciliation failed: ${err instanceof Error ? err.message : String(err)}`, + ); + } + })(); + // Start background reconciliation to detect and start engines for projects // registered after startup (without requiring headless node API access). // This ensures project task execution starts from backend runtime alone. @@ -373,7 +396,11 @@ export async function runServe( // internally for task-execution plugin hooks. These instances here serve the // HTTP plugin-management API routes and are intentionally separate. // - const pluginStore = new PluginStore(store.getRootDir()); + const pluginStoreRootDir = + typeof (store as { getRootDir?: () => string }).getRootDir === "function" + ? store.getRootDir() + : store.getFusionDir(); + const pluginStore = new PluginStore(pluginStoreRootDir); await pluginStore.init(); // ── PluginLoader: plugin lifecycle management ─────────────────────── @@ -589,6 +616,11 @@ export async function runServe( pluginLoader, pluginRunner: pluginLoader, onProjectFirstAccessed: (projectId: string) => engineManager.onProjectAccessed(projectId), + onProjectRegistered: ({ path }) => { + // Fire-and-forget: install the fusion Claude-skill when pi-claude-cli + // is configured. The runner logs its own outcome and swallows errors. + maybeInstallClaudeSkillForNewProject(path); + }, headless: true, skillsAdapter, daemon: daemonToken ? { token: daemonToken } : undefined, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index a5e08a6e0..2749a20b1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -1,5 +1,5 @@ -export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, EXECUTION_MODES, DEFAULT_EXECUTION_MODE } from "./types.js"; -export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskDetail, InboxTask, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js"; +export { COLUMNS, COLUMN_LABELS, COLUMN_DESCRIPTIONS, VALID_TRANSITIONS, DEFAULT_SETTINGS, DEFAULT_GLOBAL_SETTINGS, DEFAULT_PROJECT_SETTINGS, GLOBAL_SETTINGS_KEYS, PROJECT_SETTINGS_KEYS, isGlobalSettingsKey, isProjectSettingsKey, THINKING_LEVELS, THEME_MODES, COLOR_THEMES, WORKFLOW_STEP_TEMPLATES, AGENT_PERMISSIONS, agentToConfigSnapshot, diffConfigSnapshots, isEphemeralAgent, hasAgentIdentity, CheckoutConflictError, EXECUTION_MODES, DEFAULT_EXECUTION_MODE, validateMessageMetadata } from "./types.js"; +export type { Column, IssueInfo, IssueState, PrInfo, PrStatus, Task, TaskAttachment, TaskComment, TaskCommentInput, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, TaskDocumentWithTask, TaskCreateInput, TaskDetail, InboxTask, AgentLogEntry, AgentLogType, AgentRole, BoardConfig, MergeDetails, MergeResult, Settings, GlobalSettings, ProjectSettings, SettingsScope, DaemonTokenSettings, TaskStep, StepStatus, TaskLogEntry, RunMutationContext, ActivityLogEntry, ActivityEventType, ThinkingLevel, ThemeMode, ColorTheme, ExecutionMode, PlanningQuestion, PlanningSummary, PlanningResponse, PlanningQuestionType, ArchivedTaskEntry, BatchStatusRequest, BatchStatusResponse, BatchStatusEntry, BatchStatusResult, ModelPreset, WorkflowStep, WorkflowStepMode, WorkflowStepPhase, WorkflowStepInput, WorkflowStepResult, WorkflowStepTemplate, Agent, OrgTreeNode, AgentState, AgentDetail, AgentCreateInput, AgentUpdateInput, AgentApiKey, AgentApiKeyCreateResult, AgentCapability, AgentPromptTemplate, AgentPromptsConfig, AgentPermission, TaskAssignSource, AgentAccessState, AgentHeartbeatConfig, AgentBudgetConfig, AgentBudgetStatus, InstructionsBundleConfig, MessageResponseMode, AgentHeartbeatEvent, AgentHeartbeatRun, BlockedStateSnapshot, HeartbeatInvocationSource, AgentTaskSession, AgentRating, AgentRatingSummary, AgentRatingInput, AgentConfigSnapshot, RevisionFieldDiff, AgentConfigRevision, AgentStats, ReflectionTrigger, ReflectionMetrics, AgentReflection, AgentPerformanceSummary, NtfyNotificationEvent, SteeringComment, ParticipantType, MessageType, Message, MessageCreateInput, MessageFilter, MessageMetadata, MessageReplyReference, Mailbox, CheckoutLease, RunAuditDomain, RunAuditEvent, RunAuditEventInput, RunAuditEventFilter } from "./types.js"; export { AGENT_VALID_TRANSITIONS } from "./types.js"; export { BUILTIN_AGENT_PROMPTS, diff --git a/packages/core/src/message-store.test.ts b/packages/core/src/message-store.test.ts index 514451546..8bb477f53 100644 --- a/packages/core/src/message-store.test.ts +++ b/packages/core/src/message-store.test.ts @@ -80,6 +80,44 @@ describe("MessageStore", () => { expect(message.metadata).toEqual({ taskId: "FN-001", priority: "high" }); }); + it("persists reply link metadata through storage roundtrip", () => { + const original = store.sendMessage({ + fromId: "user-1", + fromType: "user", + toId: "agent-1", + toType: "agent", + content: "Can you help?", + type: "user-to-agent", + }); + + const reply = store.sendMessage({ + fromId: "agent-1", + fromType: "agent", + toId: "user-1", + toType: "user", + content: "Sure", + type: "agent-to-user", + metadata: { replyTo: { messageId: original.id } }, + }); + + expect(reply.metadata).toEqual({ replyTo: { messageId: original.id } }); + expect(store.getMessage(reply.id)?.metadata).toEqual({ replyTo: { messageId: original.id } }); + }); + + it("rejects malformed reply metadata", () => { + expect(() => { + store.sendMessage({ + fromId: "agent-1", + fromType: "agent", + toId: "user-1", + toType: "user", + content: "Bad metadata", + type: "agent-to-user", + metadata: { replyTo: { messageId: "" } }, + }); + }).toThrow("metadata.replyTo.messageId must be a non-empty string"); + }); + it("returns null for non-existent message", () => { const result = store.getMessage("msg-nonexistent"); expect(result).toBeNull(); diff --git a/packages/core/src/message-store.ts b/packages/core/src/message-store.ts index 27de5f631..3fc0d3398 100644 --- a/packages/core/src/message-store.ts +++ b/packages/core/src/message-store.ts @@ -14,14 +14,7 @@ import { EventEmitter } from "node:events"; import { randomUUID } from "node:crypto"; import type { Database } from "./db.js"; import { fromJson, toJsonNullable } from "./db.js"; -import type { - Message, - MessageCreateInput, - MessageFilter, - MessageType, - Mailbox, - ParticipantType, -} from "./types.js"; +import { validateMessageMetadata, type Message, type MessageCreateInput, type MessageFilter, type MessageType, type Mailbox, type ParticipantType } from "./types.js"; // ── Event Types ───────────────────────────────────────────────────── @@ -112,7 +105,7 @@ export class MessageStore extends EventEmitter { content: row.content, type: row.type as MessageType, read: row.read === 1, - metadata: fromJson>(row.metadata), + metadata: fromJson(row.metadata), createdAt: row.createdAt, updatedAt: row.updatedAt, }; @@ -126,6 +119,8 @@ export class MessageStore extends EventEmitter { * @returns The created message */ sendMessage(input: MessageCreateInput): Message { + validateMessageMetadata(input.metadata); + const now = new Date().toISOString(); const messageId = `msg-${randomUUID().slice(0, 8)}`; diff --git a/packages/core/src/settings-schema.ts b/packages/core/src/settings-schema.ts index 97769a584..90bbc4534 100644 --- a/packages/core/src/settings-schema.ts +++ b/packages/core/src/settings-schema.ts @@ -29,6 +29,7 @@ export const DEFAULT_GLOBAL_SETTINGS = { favoriteModels: undefined, openrouterModelSync: true, modelOnboardingComplete: undefined, + useClaudeCli: undefined, // Global baseline lanes for per-role model selection executionGlobalProvider: undefined, executionGlobalModelId: undefined, diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index 692a83221..fd05b308a 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -967,6 +967,17 @@ export interface GlobalSettings { * false/undefined, the dashboard will auto-open the onboarding modal. * Also set to true when the user explicitly dismisses onboarding. */ modelOnboardingComplete?: boolean; + /** When true, route AI model calls through the locally-installed Claude CLI + * via the `pi-claude-cli` pi extension (instead of the direct Anthropic + * API). Enabling this also causes Fusion to symlink its skill into each + * project's `.claude/skills/fusion/` on `fn init`, `fn project add`, + * dashboard project creation, and server startup — so the skill is + * available inside Claude Code sessions that pi spawns. + * + * When left undefined, detection falls back to scanning the `packages` + * array in the agent settings for `"npm:pi-claude-cli"` (legacy signal). + * Setting this field explicitly (true/false) always wins. */ + useClaudeCli?: boolean; /** Global baseline AI model provider for task execution (executor agent). * This is the global lane that project-level `executionProvider` can override. * Must be set together with `executionGlobalModelId`. Falls back to @@ -2904,6 +2915,18 @@ export type ParticipantType = "agent" | "user" | "system"; /** Message types/categories */ export type MessageType = "agent-to-agent" | "agent-to-user" | "user-to-agent" | "system"; +/** Stable metadata contract for linking a reply to an earlier message. */ +export interface MessageReplyReference { + /** ID of the message this one is replying to. */ + messageId: string; +} + +/** Optional metadata attached to mailbox messages. */ +export interface MessageMetadata extends Record { + /** Optional link to the original message when this message is a reply. */ + replyTo?: MessageReplyReference; +} + /** Message record stored in the system */ export interface Message { /** Unique identifier */ @@ -2923,7 +2946,7 @@ export interface Message { /** Whether the recipient has read this message */ read: boolean; /** Optional extra data */ - metadata?: Record; + metadata?: MessageMetadata; /** ISO-8601 timestamp of creation */ createdAt: string; /** ISO-8601 timestamp of last update */ @@ -2945,7 +2968,7 @@ export interface MessageCreateInput { /** Message category */ type: MessageType; /** Optional extra data */ - metadata?: Record; + metadata?: MessageMetadata; } /** Filter options for querying messages */ @@ -2960,6 +2983,21 @@ export interface MessageFilter { offset?: number; } +/** Validate mailbox metadata, including reply-link contract when present. */ +export function validateMessageMetadata(metadata: MessageMetadata | undefined): void { + if (!metadata || metadata.replyTo === undefined) { + return; + } + + if (typeof metadata.replyTo !== "object" || metadata.replyTo === null || Array.isArray(metadata.replyTo)) { + throw new Error("metadata.replyTo must be an object"); + } + + if (typeof metadata.replyTo.messageId !== "string" || metadata.replyTo.messageId.trim().length === 0) { + throw new Error("metadata.replyTo.messageId must be a non-empty string"); + } +} + /** Mailbox summary for a participant */ export interface Mailbox { /** Owner identifier */ diff --git a/packages/dashboard/app/App.tsx b/packages/dashboard/app/App.tsx index 66b223738..2f4de64d0 100644 --- a/packages/dashboard/app/App.tsx +++ b/packages/dashboard/app/App.tsx @@ -54,6 +54,7 @@ import { NodeProvider, useNodeContext } from "./context/NodeContext"; import type { AiSessionSummary } from "./api"; import { fetchAiSession, fetchUnreadCount, reportDashboardPerf } from "./api"; import { getScopedItem, setScopedItem } from "./utils/projectStorage"; +import { subscribeSse } from "./sse-bus"; const SETUP_WARNING_DISMISSED_KEY = "kb-setup-warning-dismissed"; @@ -212,11 +213,10 @@ function AppInner() { const viewportMode = useViewportMode(); const isMobile = viewportMode === "mobile"; - // App-level mailbox unread count state (used for header badge) + // App-level mailbox unread count state (used for header/mobile nav badges) const [mailboxUnreadCount, setMailboxUnreadCount] = useState(0); - // Initial fetch of mailbox unread count - useEffect(() => { + const refreshMailboxUnreadCount = useCallback(() => { fetchUnreadCount(currentProject?.id) .then((data: { unreadCount: number }) => { setMailboxUnreadCount(data.unreadCount); @@ -226,6 +226,26 @@ function AppInner() { }); }, [currentProject?.id]); + // Initial fetch + live updates from mailbox SSE events. + useEffect(() => { + refreshMailboxUnreadCount(); + + const params = new URLSearchParams(); + if (currentProject?.id) { + params.set("projectId", currentProject.id); + } + const query = params.size > 0 ? `?${params.toString()}` : ""; + + return subscribeSse(`/api/events${query}`, { + events: { + "message:sent": refreshMailboxUnreadCount, + "message:received": refreshMailboxUnreadCount, + "message:read": refreshMailboxUnreadCount, + "message:deleted": refreshMailboxUnreadCount, + }, + }); + }, [currentProject?.id, refreshMailboxUnreadCount]); + // Nodes management is an overlay view (not a modal), so it stays local to App. const [nodesOpen, setNodesOpen] = useState(false); const [missionResumeSessionId, setMissionResumeSessionId] = useState(undefined); diff --git a/packages/dashboard/app/api.ts b/packages/dashboard/app/api.ts index 695e8b4c1..7e7adaf5d 100644 --- a/packages/dashboard/app/api.ts +++ b/packages/dashboard/app/api.ts @@ -24,6 +24,7 @@ import type { TaskDocumentWithTask, Message, + MessageMetadata, MessageType, ParticipantType, NodeConfig, @@ -6131,7 +6132,7 @@ export interface SendMessageInput { toType: ParticipantType; content: string; type: MessageType; - metadata?: Record; + metadata?: MessageMetadata; } /** Fetch inbox messages for the current user. */ diff --git a/packages/dashboard/app/components/ListView.tsx b/packages/dashboard/app/components/ListView.tsx index f93873934..921a84799 100644 --- a/packages/dashboard/app/components/ListView.tsx +++ b/packages/dashboard/app/components/ListView.tsx @@ -1,6 +1,6 @@ import { useState, useCallback, useMemo, Fragment, useEffect, useRef } from "react"; import { LayoutGrid, List as ListIcon, ArrowUpDown, ArrowUp, ArrowDown, Link, Columns3, EyeOff, Eye, ChevronRight } from "lucide-react"; -import type { Task, TaskDetail, Column, TaskStep, TaskCreateInput } from "@fusion/core"; +import type { Task, TaskDetail, Column, TaskCreateInput } from "@fusion/core"; import { COLUMN_LABELS, COLUMNS } from "@fusion/core"; import { batchUpdateTaskModels } from "../api"; import type { ModelInfo } from "../api"; @@ -10,6 +10,7 @@ import { isTaskStuck } from "../utils/taskStuck"; import type { ToastType } from "../hooks/useToast"; import { useViewportMode } from "../hooks/useViewportMode"; import { getScopedItem, setScopedItem } from "../utils/projectStorage"; +import { getUnifiedTaskProgress } from "../utils/taskProgress"; const COLUMN_COLOR_MAP: Record = { triage: "var(--triage)", @@ -132,16 +133,17 @@ interface ListViewProps { lastFetchTimeMs?: number; } -function getStepProgress(steps: TaskStep[]): string { - if (steps.length === 0) return "-"; - const done = steps.filter((s) => s.status === "done").length; - return `${done}/${steps.length}`; -} +function getTaskProgress(task: Task): { label: string; percent: number; hasProgress: boolean } { + const progress = getUnifiedTaskProgress(task); + if (progress.total === 0) { + return { label: "-", percent: 0, hasProgress: false }; + } -function getStepProgressPercent(steps: TaskStep[]): number { - if (steps.length === 0) return 0; - const done = steps.filter((s) => s.status === "done").length; - return (done / steps.length) * 100; + return { + label: `${progress.completed}/${progress.total}`, + percent: (progress.completed / progress.total) * 100, + hasProgress: true, + }; } export function ListView({ @@ -816,7 +818,8 @@ export function ListView({ (task.column === "in-progress" || ACTIVE_STATUSES.has(task.status as string)); const hasStatus = typeof task.status === "string" && task.status.trim().length > 0; const hasDependencies = Boolean(task.dependencies && task.dependencies.length > 0); - const hasSteps = task.steps.length > 0; + const taskProgress = getTaskProgress(task); + const hasProgress = taskProgress.hasProgress; const isSelectionMode = selectedTaskIds.size > 0; return ( @@ -858,25 +861,25 @@ export function ListView({
{task.title || task.description}
- {(hasDependencies || hasSteps) && ( + {(hasDependencies || hasProgress) && (
{hasDependencies && ( {task.dependencies.length} )} - {hasSteps && ( + {hasProgress && (
- {getStepProgress(task.steps)} + {taskProgress.label}
)}
@@ -1071,22 +1074,24 @@ export function ListView({ )} {visibleColumns.has("progress") && ( - {task.steps.length > 0 ? ( -
-
-
+ {(() => { + const taskProgress = getTaskProgress(task); + if (!taskProgress.hasProgress) return "-"; + return ( +
+
+
+
+ {taskProgress.label}
- {getStepProgress(task.steps)} -
- ) : ( - "-" - )} + ); + })()} )} diff --git a/packages/dashboard/app/components/MailboxModal.tsx b/packages/dashboard/app/components/MailboxModal.tsx index 97b0f0a13..ccb97d212 100644 --- a/packages/dashboard/app/components/MailboxModal.tsx +++ b/packages/dashboard/app/components/MailboxModal.tsx @@ -75,6 +75,11 @@ function messageTypeLabel(type: MessageType): string { } } +function messagePreview(content: string, max = 80): string { + if (content.length <= max) return content; + return `${content.slice(0, max)}…`; +} + // ── Component ───────────────────────────────────────────────────────────── export function MailboxModal({ @@ -93,6 +98,7 @@ export function MailboxModal({ const [conversationMessages, setConversationMessages] = useState([]); const [showComposer, setShowComposer] = useState(false); const [composeRecipient, setComposeRecipient] = useState<{ id: string; type: ParticipantType } | null>(null); + const [composeReplyContext, setComposeReplyContext] = useState<{ messageId: string; preview: string } | null>(null); const [selectedAgentId, setSelectedAgentId] = useState(null); const [agentSubTab, setAgentSubTab] = useState<"inbox" | "outbox">("inbox"); const [agentMailbox, setAgentMailbox] = useState(null); @@ -267,12 +273,17 @@ export function MailboxModal({ const handleReply = useCallback((message: Message) => { setComposeRecipient({ id: message.fromId, type: message.fromType }); + setComposeReplyContext({ + messageId: message.id, + preview: messagePreview(message.content, 120), + }); setShowComposer(true); }, []); const handleMessageSent = useCallback(() => { setShowComposer(false); setComposeRecipient(null); + setComposeReplyContext(null); addToast?.("Message sent", "success"); // Refresh current tab if (activeTab === "outbox") loadOutbox(); @@ -286,12 +297,14 @@ export function MailboxModal({ } else { setComposeRecipient(null); } + setComposeReplyContext(null); setShowComposer(true); }, [activeTab, selectedAgentId]); const handleComposeCancel = useCallback(() => { setShowComposer(false); setComposeRecipient(null); + setComposeReplyContext(null); }, []); if (!isOpen) return null; @@ -332,7 +345,7 @@ export function MailboxModal({ {activeTab === "inbox" && unreadCount > 0 && ( )} {activeTab === "inbox" && unreadCount > 0 && ( )} diff --git a/packages/dashboard/app/components/ModelOnboardingModal.tsx b/packages/dashboard/app/components/ModelOnboardingModal.tsx index 4ba0b7696..d5253c8f4 100644 --- a/packages/dashboard/app/components/ModelOnboardingModal.tsx +++ b/packages/dashboard/app/components/ModelOnboardingModal.tsx @@ -493,6 +493,7 @@ export function ModelOnboardingModal({ const [authActionInProgress, setAuthActionInProgress] = useState(null); const [availableModels, setAvailableModels] = useState([]); const [selectedModel, setSelectedModel] = useState(""); + const [useClaudeCli, setUseClaudeCli] = useState(false); const [saving, setSaving] = useState(false); const [apiKeyInputs, setApiKeyInputs] = useState>({}); const [apiKeyErrors, setApiKeyErrors] = useState>({}); @@ -1150,13 +1151,20 @@ export function ModelOnboardingModal({ } } + // Only write useClaudeCli when the user explicitly opted in; leaving + // the field undefined keeps the legacy packages-array fallback in play + // for anyone who had pi-claude-cli installed before this toggle existed. + if (useClaudeCli) { + updates.useClaudeCli = true; + } + await updateGlobalSettings(updates); // Mark onboarding as completed (preserves state for completion timestamp) markOnboardingCompleted(); } catch { // Best-effort: continue even if save fails } - }, [selectedModel, availableModels, updateGlobalSettings, markOnboardingCompleted]); + }, [selectedModel, availableModels, useClaudeCli, updateGlobalSettings, markOnboardingCompleted]); // Complete onboarding const handleComplete = useCallback(async () => { @@ -1752,6 +1760,39 @@ export function ModelOnboardingModal({
)}
+ + {/* Claude CLI routing toggle. + Opt-in: we only flip useClaudeCli=true when the user ticks + this. The backend install hooks (fn init, dashboard project + add, server startup) will then symlink the fusion skill into + every project's .claude/skills/fusion so Claude Code can see + fn_* tools. */} +
+

+ Use the Claude CLI? (Optional) +

+ + +

+ If you already have Claude CLI installed and an active + Claude subscription, Fusion can send its model calls through + the CLI instead of the Anthropic API — using your existing + quota. Requires the pi-claude-cli pi extension. + Fusion will also install its skill into each project's{" "} + .claude/skills/fusion/ so Claude Code can use + Fusion's tools directly. You can toggle this later in + Settings → Global Models. +

+
+
)} diff --git a/packages/dashboard/app/components/SettingsModal.tsx b/packages/dashboard/app/components/SettingsModal.tsx index 79f73d5d6..05a8b529b 100644 --- a/packages/dashboard/app/components/SettingsModal.tsx +++ b/packages/dashboard/app/components/SettingsModal.tsx @@ -1224,6 +1224,28 @@ export function SettingsModal({ to skip the initial API call and use only the built-in model list.
+ + {/* --- Claude CLI routing --- */} +

Claude CLI

+
+ + + When enabled, Fusion sends model calls to your locally-installed Claude CLI instead + of the direct Anthropic API — useful if you already have a Claude subscription and + want to use its quota. Requires pi-claude-cli installed as a pi + extension. Fusion will also install its skill into each project's{" "} + .claude/skills/fusion/ so Claude Code sessions can use the{" "} + fn_* tools natively. + +
); } diff --git a/packages/dashboard/app/components/TaskCard.test.tsx b/packages/dashboard/app/components/TaskCard.test.tsx index 25bf7e07d..7b4beaa77 100644 --- a/packages/dashboard/app/components/TaskCard.test.tsx +++ b/packages/dashboard/app/components/TaskCard.test.tsx @@ -82,6 +82,86 @@ describe("TaskCard", () => { expect(container.querySelector(".card-status-badge")).toBeNull(); }); + it("renders unified progress counts for task steps + workflow checks", () => { + render( + , + ); + + expect(screen.getByText("2/5")).toBeDefined(); + expect(screen.getByText("5 steps")).toBeDefined(); + }); + + it("renders workflow checks after normal steps with mapped statuses", () => { + const { container } = render( + , + ); + + const stepNames = Array.from(container.querySelectorAll(".card-step-name")).map((el) => el.textContent); + expect(stepNames).toEqual([ + "Step 0", + "Step 1", + "Browser Verification", + "Frontend UX Design", + "WS-003", + ]); + + const dots = container.querySelectorAll(".card-step-dot"); + expect(dots[2]?.className).toContain("card-step-dot--done"); + expect(dots[3]?.className).toContain("card-step-dot--failed"); + expect(dots[4]?.className).toContain("card-step-dot--pending"); + + const workflowBadges = Array.from(container.querySelectorAll(".card-step-workflow-badge")).map((el) => el.textContent); + expect(workflowBadges).toEqual([ + "Workflow · Pre-merge", + "Workflow · Pre-merge", + "Workflow · Pre-merge", + ]); + }); + it("shows drop indicator on file dragover and removes on dragleave", () => { const { container } = render( , diff --git a/packages/dashboard/app/components/TaskCard.tsx b/packages/dashboard/app/components/TaskCard.tsx index 65c5fba89..20d2cfc3b 100644 --- a/packages/dashboard/app/components/TaskCard.tsx +++ b/packages/dashboard/app/components/TaskCard.tsx @@ -10,6 +10,7 @@ import { useBadgeWebSocket } from "../hooks/useBadgeWebSocket"; import { getFreshBatchData } from "../hooks/useBatchBadgeFetch"; import { useTaskDiffStats } from "../hooks/useTaskDiffStats"; import { isTaskStuck } from "../utils/taskStuck"; +import { getUnifiedTaskProgress } from "../utils/taskProgress"; import type { ToastType } from "../hooks/useToast"; // ── Mission title caching ─────────────────────────────────────────────────── @@ -74,6 +75,15 @@ const EDITABLE_COLUMNS: Set = new Set(["triage", "todo"]); const ACTIVE_STATUSES = new Set(["planning", "researching", "executing", "finalizing", "merging", "specifying"]); +const COLUMN_PROGRESS_COLOR_MAP: Record = { + triage: "var(--triage)", + todo: "var(--todo)", + "in-progress": "var(--in-progress)", + "in-review": "var(--in-review)", + done: "var(--done)", + archived: "var(--text-muted)", +}; + interface TaskCardProps { task: Task; projectId?: string; @@ -124,6 +134,32 @@ function areTaskDependenciesEqual(previous: string[], next: string[]): boolean { return previous.every((dependency, index) => dependency === next[index]); } +function areTaskWorkflowStepIdsEqual(previous?: string[], next?: string[]): boolean { + if (!previous && !next) return true; + if (!previous || !next) return false; + if (previous.length !== next.length) return false; + return previous.every((stepId, index) => stepId === next[index]); +} + +function areTaskWorkflowResultsEqual(previous?: Task["workflowStepResults"], next?: Task["workflowStepResults"]): boolean { + if (!previous && !next) return true; + if (!previous || !next) return false; + if (previous.length !== next.length) return false; + return previous.every((result, index) => { + const nextResult = next[index]; + if (!nextResult) return false; + return ( + result.workflowStepId === nextResult.workflowStepId && + result.workflowStepName === nextResult.workflowStepName && + result.phase === nextResult.phase && + result.status === nextResult.status && + result.output === nextResult.output && + result.startedAt === nextResult.startedAt && + result.completedAt === nextResult.completedAt + ); + }); +} + /** * Lightweight comparison for attachment metadata (not file content). * Compares counts and top-level fields that affect card rendering. @@ -215,6 +251,8 @@ function areTaskCardPropsEqual(previous: TaskCardProps, next: TaskCardProps): bo areCommentsEqual(previousTask.comments, nextTask.comments) && areTaskDependenciesEqual(previousTask.dependencies, nextTask.dependencies) && areTaskStepsEqual(previousTask.steps, nextTask.steps) && + areTaskWorkflowStepIdsEqual(previousTask.enabledWorkflowSteps, nextTask.enabledWorkflowSteps) && + areTaskWorkflowResultsEqual(previousTask.workflowStepResults, nextTask.workflowStepResults) && areTaskBadgeInfosEqual(previousTask.prInfo, nextTask.prInfo) && areTaskBadgeInfosEqual(previousTask.issueInfo, nextTask.issueInfo) ); @@ -486,6 +524,10 @@ function TaskCardComponent({ const canEdit = EDITABLE_COLUMNS.has(task.column) && !isAgentActive && !isPaused && !queued && onUpdateTask; const hasGitHubBadge = Boolean(task.prInfo || task.issueInfo); const isAgentNameLoading = Boolean(task.assignedAgentId && agentName === null); + const unifiedProgress = useMemo( + () => getUnifiedTaskProgress(task), + [task.steps, task.enabledWorkflowSteps, task.workflowStepResults], + ); useEffect(() => { if (!hasGitHubBadge || !isInViewport) { @@ -930,9 +972,8 @@ function TaskCardComponent({
{truncate(task.title, MAX_TITLE_LENGTH) || truncate(task.description, MAX_TITLE_LENGTH) || task.id}
- {task.steps.length > 0 && (() => { - const completedSteps = task.steps.filter((s) => s.status === "done" || s.status === "skipped").length; - const totalSteps = task.steps.length; + {unifiedProgress.total > 0 && (() => { + const progressPercent = (unifiedProgress.completed / unifiedProgress.total) * 100; return ( <>
@@ -940,12 +981,12 @@ function TaskCardComponent({
- {completedSteps}/{totalSteps} + {unifiedProgress.completed}/{unifiedProgress.total}