FN-7153: relabel project card engine controls

Project dashboard cards now describe engine lifecycle actions directly.

- Change project card Pause/Resume controls to Stop engine/Start engine while preserving existing behavior.
- Replace the pause icon with a stop-square glyph for the stop action.
- Update project card i18n resources, generated resource types, tests, and the release changeset.

Files changed:
 .changeset/fn-7153-stop-engine-label.md            |   7 ++
 packages/dashboard/app/components/ProjectCard.tsx  |  20 ++--
 .../app/components/__tests__/ProjectCard.test.tsx  | 103 ++++++++++++++++++---
 .../__tests__/ProjectThemeTokens.test.tsx          |   1 +
 packages/i18n/locales/en/app.json                  |  10 +-
 packages/i18n/locales/es/app.json                  |  10 +-
 packages/i18n/locales/fr/app.json                  |  10 +-
 packages/i18n/locales/ko/app.json                  |  10 +-
 packages/i18n/locales/zh-CN/app.json               |  10 +-
 packages/i18n/locales/zh-TW/app.json               |  10 +-
 packages/i18n/src/resources.d.ts                   |  74 +++++++++++++--
 11 files changed, 208 insertions(+), 57 deletions(-)

Fusion-Task-Id: FN-7153

Fusion-Task-Lineage: 14a4efe7-7538-425e-9759-ded550438df2

Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
gsxdsm
2026-06-27 19:54:22 -07:00
parent 6ca51185f3
commit bb89e1ec3c
11 changed files with 208 additions and 57 deletions

View File

@@ -0,0 +1,7 @@
---
"@runfusion/fusion": patch
---
summary: Project Dashboard cards now say "Stop engine"/"Start engine" instead of "Pause"/"Resume".
category: feature
dev: Relabels ProjectCard pause/resume controls; pauseProject already stops the engine, so behavior is unchanged. i18n projectCard.* keys updated (en) with empty-string fallback for other locales.

View File

@@ -1,7 +1,7 @@
import { memo, useCallback, useState } from "react";
import type { TFunction } from "i18next";
import { useTranslation } from "react-i18next";
import { Play, Pause, Trash2, Folder, ArrowRight } from "lucide-react";
import { Play, Square, Trash2, Folder, ArrowRight } from "lucide-react";
import "./ProjectCard.css";
import type { RegisteredProject, ProjectHealth } from "@fusion/core";
import type { ProjectNodeAvailability } from "../api";
@@ -219,27 +219,31 @@ function ProjectCardInner({
</div>
<div className="project-card-actions">
{/*
* FNXC:ProjectCardEngineControls 2026-06-27-00:00:
* ProjectEngineManager.pauseProject already calls engine.stop(), so the card action names the engine-lifecycle action as Stop engine/Start engine while preserving the active/paused project status model and pauseProject/resumeProject wiring.
*/}
{isPaused ? (
<button
className="project-card-action project-card-action-resume"
onClick={handleResume}
disabled={isLoading}
title={t("projectCard.resumeProject", "Resume project")}
aria-label={t("projectCard.resumeProject", "Resume project")}
title={t("projectCard.resumeProject", "Start engine")}
aria-label={t("projectCard.resumeProject", "Start engine")}
>
<Play size={14} />
<span>{t("projectCard.resume", "Resume")}</span>
<span>{t("projectCard.resume", "Start engine")}</span>
</button>
) : (
<button
className="project-card-action project-card-action-pause"
onClick={handlePause}
disabled={isLoading || isInitializing}
title={isInitializing ? t("projectCard.cannotPauseWhileInitializing", "Cannot pause while initializing") : t("projectCard.pauseProject", "Pause project")}
aria-label={t("projectCard.pauseProject", "Pause project")}
title={isInitializing ? t("projectCard.cannotPauseWhileInitializing", "Cannot stop engine while initializing") : t("projectCard.pauseProject", "Stop engine")}
aria-label={t("projectCard.pauseProject", "Stop engine")}
>
<Pause size={14} />
<span>{t("projectCard.pause", "Pause")}</span>
<Square size={14} />
<span>{t("projectCard.pause", "Stop engine")}</span>
</button>
)}

View File

@@ -6,7 +6,8 @@ import type { RegisteredProject, ProjectHealth, ProjectStatus } from "@fusion/co
// Mock lucide-react to avoid SVG rendering issues in test env
vi.mock("lucide-react", () => ({
Play: () => <span data-testid="play-icon">▶</span>,
Pause: () => <span data-testid="pause-icon">⏸</span>,
Pause: () => <span data-testid="status-pause-icon">⏸</span>,
Square: () => <span data-testid="stop-icon">■</span>,
AlertCircle: () => <span data-testid="alert-icon">⚠</span>,
Loader2: () => <span data-testid="loader-icon">⟳</span>,
MoreHorizontal: () => null,
@@ -366,7 +367,7 @@ describe("ProjectCard", () => {
expect(onSelect).toHaveBeenCalledWith(project);
});
it("calls onPause when pause button is clicked", () => {
it("calls onPause when stop-engine button is clicked", () => {
const onPause = vi.fn();
const project = makeProject({ status: "active" });
@@ -381,11 +382,11 @@ describe("ProjectCard", () => {
/>
);
fireEvent.click(screen.getByLabelText("Pause project"));
fireEvent.click(screen.getByLabelText("Stop engine"));
expect(onPause).toHaveBeenCalledWith(project);
});
it("calls onResume when resume button is clicked", () => {
it("calls onResume when start-engine button is clicked", () => {
const onResume = vi.fn();
const project = makeProject({ status: "paused" });
@@ -400,10 +401,88 @@ describe("ProjectCard", () => {
/>
);
fireEvent.click(screen.getByLabelText("Resume project"));
fireEvent.click(screen.getByLabelText("Start engine"));
expect(onResume).toHaveBeenCalledWith(project);
});
it("labels engine lifecycle controls across active, errored, paused, and initializing states", () => {
const activePause = vi.fn();
const activeProject = makeProject({ status: "active" });
const activeRender = render(
<ProjectCard
project={activeProject}
health={makeHealth({ status: "active" })}
onSelect={noop}
onPause={activePause}
onResume={noop}
onRemove={noop}
/>
);
const activeStop = screen.getByRole("button", { name: "Stop engine" });
expect(activeStop).toHaveAttribute("title", "Stop engine");
expect(screen.getByTestId("stop-icon")).toBeDefined();
const stalePauseLabel = ["Pause", "project"].join(" ");
const staleResumeLabel = ["Resume", "project"].join(" ");
expect(screen.queryByLabelText(stalePauseLabel)).toBeNull();
expect(screen.queryByLabelText(staleResumeLabel)).toBeNull();
fireEvent.click(activeStop);
expect(activePause).toHaveBeenCalledWith(activeProject);
activeRender.unmount();
const erroredPause = vi.fn();
const erroredProject = makeProject({ status: "errored" });
const erroredRender = render(
<ProjectCard
project={erroredProject}
health={makeHealth({ status: "errored" })}
onSelect={noop}
onPause={erroredPause}
onResume={noop}
onRemove={noop}
/>
);
fireEvent.click(screen.getByRole("button", { name: "Stop engine" }));
expect(erroredPause).toHaveBeenCalledWith(erroredProject);
erroredRender.unmount();
const pausedResume = vi.fn();
const pausedProject = makeProject({ status: "paused" });
const pausedRender = render(
<ProjectCard
project={pausedProject}
health={makeHealth({ status: "paused" })}
onSelect={noop}
onPause={noop}
onResume={pausedResume}
onRemove={noop}
/>
);
const pausedStart = screen.getByRole("button", { name: "Start engine" });
expect(pausedStart).toHaveAttribute("title", "Start engine");
expect(screen.getByTestId("play-icon")).toBeDefined();
fireEvent.click(pausedStart);
expect(pausedResume).toHaveBeenCalledWith(pausedProject);
pausedRender.unmount();
render(
<ProjectCard
project={makeProject({ status: "initializing" })}
health={makeHealth({ status: "initializing" })}
onSelect={noop}
onPause={noop}
onResume={noop}
onRemove={noop}
/>
);
const initializingStop = screen.getByRole("button", { name: "Stop engine" });
expect(initializingStop).toBeDisabled();
expect(initializingStop).toHaveAttribute("title", "Cannot stop engine while initializing");
});
it("calls onRemove when remove button is clicked", () => {
const onRemove = vi.fn();
const project = makeProject();
@@ -454,7 +533,7 @@ describe("ProjectCard", () => {
expect(container.querySelector(".is-armed")).not.toBeNull();
});
it("disables pause button when initializing", () => {
it("disables stop-engine button when initializing", () => {
const { container } = render(
<ProjectCard
project={makeProject({ status: "initializing" })}
@@ -466,10 +545,10 @@ describe("ProjectCard", () => {
/>
);
// Find the pause button by its title attribute
const pauseButton = container.querySelector('button[title="Cannot pause while initializing"]');
expect(pauseButton).not.toBeNull();
expect(pauseButton).toBeDisabled();
// Find the stop-engine button by its title attribute
const stopEngineButton = container.querySelector('button[title="Cannot stop engine while initializing"]');
expect(stopEngineButton).not.toBeNull();
expect(stopEngineButton).toBeDisabled();
});
it("disables all buttons when isLoading is true", () => {
@@ -485,7 +564,7 @@ describe("ProjectCard", () => {
/>
);
expect(screen.getByLabelText("Pause project")).toBeDisabled();
expect(screen.getByLabelText("Stop engine")).toBeDisabled();
expect(screen.getByLabelText("Open project")).toBeDisabled();
expect(screen.getByLabelText("Remove project")).toBeDisabled();
});
@@ -536,7 +615,7 @@ describe("ProjectCard", () => {
/>
);
fireEvent.click(screen.getByLabelText("Pause project"));
fireEvent.click(screen.getByLabelText("Stop engine"));
expect(onPause).toHaveBeenCalled();
expect(onSelect).not.toHaveBeenCalled();
});

View File

@@ -23,6 +23,7 @@ import type { ProjectInfo } from "../../../app/api";
vi.mock("lucide-react", () => ({
Play: () => <span>Play</span>,
Pause: () => <span>Pause</span>,
Square: () => <span>Square</span>,
AlertCircle: () => <span>AlertCircle</span>,
Loader2: () => <span>Loader2</span>,
ChevronDown: () => <span>ChevronDown</span>,

View File

@@ -4837,7 +4837,7 @@
"projectCard": {
"activeTasks": "Active Tasks",
"agents": "Agents",
"cannotPauseWhileInitializing": "Cannot pause while initializing",
"cannotPauseWhileInitializing": "Cannot stop engine while initializing",
"completed": "Completed",
"confirm": "Confirm",
"confirmRemove": "Confirm remove",
@@ -4857,11 +4857,11 @@
"noHealthData": "No health data available",
"open": "Open",
"openProject": "Open project",
"pause": "Pause",
"pauseProject": "Pause project",
"pause": "Stop engine",
"pauseProject": "Stop engine",
"removeProject": "Remove project",
"resume": "Resume",
"resumeProject": "Resume project"
"resume": "Start engine",
"resumeProject": "Start engine"
},
"projectDetection": {
"editName": "Edit name",

View File

@@ -4827,7 +4827,7 @@
"projectCard": {
"activeTasks": "Tareas activas",
"agents": "Agentes",
"cannotPauseWhileInitializing": "No se puede pausar mientras se inicializa",
"cannotPauseWhileInitializing": "",
"completed": "Completado",
"confirm": "Confirmar",
"confirmRemove": "Confirmar eliminación",
@@ -4847,11 +4847,11 @@
"noHealthData": "No hay datos de salud disponibles",
"open": "Abrir",
"openProject": "Abrir proyecto",
"pause": "Pausar",
"pauseProject": "Pausar proyecto",
"pause": "",
"pauseProject": "",
"removeProject": "Eliminar proyecto",
"resume": "Reanudar",
"resumeProject": "Reanudar proyecto"
"resume": "",
"resumeProject": ""
},
"projectDetection": {
"editName": "Editar nombre",

View File

@@ -4827,7 +4827,7 @@
"projectCard": {
"activeTasks": "Tâches actives",
"agents": "Agents",
"cannotPauseWhileInitializing": "Impossible de mettre en pause lors de l'initialisation",
"cannotPauseWhileInitializing": "",
"completed": "Complétée",
"confirm": "Confirmer",
"confirmRemove": "Confirmer la suppression",
@@ -4847,11 +4847,11 @@
"noHealthData": "Aucune donnée de santé disponible",
"open": "Ouvrir",
"openProject": "Ouvrir le projet",
"pause": "Pause",
"pauseProject": "Mettre le projet en pause",
"pause": "",
"pauseProject": "",
"removeProject": "Supprimer le projet",
"resume": "Reprendre",
"resumeProject": "Reprendre le projet"
"resume": "",
"resumeProject": ""
},
"projectDetection": {
"editName": "Modifier le nom",

View File

@@ -4827,7 +4827,7 @@
"projectCard": {
"activeTasks": "활성 작업",
"agents": "에이전트",
"cannotPauseWhileInitializing": "초기화 중에는 일시 중지할 수 없습니다",
"cannotPauseWhileInitializing": "",
"completed": "완료됨",
"confirm": "확인",
"confirmRemove": "제거 확인",
@@ -4847,11 +4847,11 @@
"noHealthData": "사용 가능한 상태 데이터가 없습니다",
"open": "열기",
"openProject": "프로젝트 열기",
"pause": "일시 중지",
"pauseProject": "프로젝트 일시 중지",
"pause": "",
"pauseProject": "",
"removeProject": "프로젝트 제거",
"resume": "재개",
"resumeProject": "프로젝트 재개"
"resume": "",
"resumeProject": ""
},
"projectDetection": {
"editName": "이름 편집",

View File

@@ -4827,7 +4827,7 @@
"projectCard": {
"activeTasks": "活跃任务",
"agents": "代理",
"cannotPauseWhileInitializing": "初始化时无法暂停",
"cannotPauseWhileInitializing": "",
"completed": "已完成",
"confirm": "确认",
"confirmRemove": "确认删除",
@@ -4847,11 +4847,11 @@
"noHealthData": "没有可用的健康数据",
"open": "打开",
"openProject": "打开项目",
"pause": "暂停",
"pauseProject": "暂停项目",
"pause": "",
"pauseProject": "",
"removeProject": "删除项目",
"resume": "恢复",
"resumeProject": "恢复项目"
"resume": "",
"resumeProject": ""
},
"projectDetection": {
"editName": "编辑名称",

View File

@@ -4827,7 +4827,7 @@
"projectCard": {
"activeTasks": "活躍任務",
"agents": "代理",
"cannotPauseWhileInitializing": "初始化時無法暫停",
"cannotPauseWhileInitializing": "",
"completed": "已完成",
"confirm": "確認",
"confirmRemove": "確認移除",
@@ -4847,11 +4847,11 @@
"noHealthData": "沒有可用的健康資料",
"open": "打開",
"openProject": "打開專案",
"pause": "暫停",
"pauseProject": "暫停專案",
"pause": "",
"pauseProject": "",
"removeProject": "移除專案",
"resume": "恢復",
"resumeProject": "恢復專案"
"resume": "",
"resumeProject": ""
},
"projectDetection": {
"editName": "編輯名稱",

View File

@@ -1230,6 +1230,7 @@ export default interface Resources {
"cancelButton": "Cancel",
"clearConversationFailed": "Failed to clear conversation",
"closeQuickChat": "Close quick chat",
"contextWindowAria": "Estimated {{used}} of {{total}} context tokens",
"conversationArchived": "Conversation archived",
"conversationDeleted": "Conversation deleted",
"conversationName": "Conversation name",
@@ -1533,6 +1534,8 @@ export default interface Resources {
"maxConcurrent": "Max concurrent tasks",
"maxTriageConcurrent": "Max triage concurrent",
"maxWorktrees": "Max worktrees",
"runningGlobal": "{{count}} running (all projects)",
"runningProject": "{{count}} running (this project)",
"title": "Concurrency"
},
"engine": {
@@ -1761,7 +1764,8 @@ export default interface Resources {
"system": "System",
"team": "Team",
"tokens": "Tokens",
"tools": "Tools"
"tools": "Tools",
"workflows": "Workflows"
},
"team": {
"agent": "Agent",
@@ -1819,6 +1823,26 @@ export default interface Resources {
"sessions": "Sessions",
"summaryTitle": "Summary",
"toolCalls": "Tool calls"
},
"workflows": {
"completedByWorkflow": "Tasks done by workflow",
"cost": "Cost",
"done": "Tasks done",
"empty": "No workflow analytics have been recorded for this range yet.",
"files": "Files changed",
"filesChanged": "Files changed",
"inProgress": "In progress",
"inReview": "In review",
"noChartData": "No non-zero values for this chart yet.",
"tableTitle": "Per-workflow breakdown",
"tasksCompleted": "Tasks done",
"tokens": "Tokens",
"tokensByWorkflow": "Tokens by workflow",
"totalCost": "Estimated cost",
"totalTokens": "Total tokens",
"totalsTitle": "Workflow totals",
"unknownWorkflow": "(unknown workflow)",
"workflow": "Workflow"
}
},
"comments": {
@@ -2318,6 +2342,7 @@ export default interface Resources {
"daysAgo_one": "{{count}}d ago",
"daysAgo_other": "{{count}}d ago",
"engineControls": "Engine controls",
"engineControlsClose": "Close engine controls",
"escalated": "Escalated",
"escalatedSuffix": " (escalated)",
"hideProjectDir": "Hide project directory",
@@ -4814,7 +4839,7 @@ export default interface Resources {
"projectCard": {
"activeTasks": "Active Tasks",
"agents": "Agents",
"cannotPauseWhileInitializing": "Cannot pause while initializing",
"cannotPauseWhileInitializing": "Cannot stop engine while initializing",
"completed": "Completed",
"confirm": "Confirm",
"confirmRemove": "Confirm remove",
@@ -4834,11 +4859,11 @@ export default interface Resources {
"nodeAvailability": "Project node availability",
"open": "Open",
"openProject": "Open project",
"pause": "Pause",
"pauseProject": "Pause project",
"pause": "Stop engine",
"pauseProject": "Stop engine",
"removeProject": "Remove project",
"resume": "Resume",
"resumeProject": "Resume project"
"resume": "Start engine",
"resumeProject": "Start engine"
},
"projectDetection": {
"editName": "Edit name",
@@ -5936,6 +5961,12 @@ export default interface Resources {
"keepLocal": "Keep Local",
"keepRemote": "Keep Remote",
"loading": "Loading…",
"mcp": {
"globalDescription": "Configure MCP servers shared by all projects. Project settings may override or disable these servers by name.",
"globalTitle": "Global MCP servers",
"projectDescription": "Configure project-specific MCP servers, overrides, and disabled inherited servers.",
"projectTitle": "Project MCP servers"
},
"memory": {
"03": "0 3 * * *",
"agentsGetMemorySearchMemoryGetAndMemory": "Agents get memory_search, memory_get, and memory_append tools. Search defaults to qmd with a local file fallback.",
@@ -6070,12 +6101,12 @@ export default interface Resources {
"origin": "origin",
"personalAccessToken": "Personal access token",
"pickALocalBranchFromTheDropdownCommon": "). Pick a local branch from the dropdown — common integration names like ",
"positiveIntegerRetryCapForAutoMergeConflict": "Positive integer retry cap for auto-merge conflict resolution before a task parks for human recovery. Default 3.",
"planApprovalMode": "Plan approval mode",
"planApprovalModeAutoApproveAll": "Auto-approve all tasks",
"planApprovalModeHelp": "Project-wide override for the planning approval gate. Leave on workflow to use each workflow's Require plan approval setting, or force all approved specs to bypass or wait for manual approval.",
"planApprovalModeRequireAll": "Require approval for all tasks",
"planApprovalModeWorkflow": "Use workflow setting",
"positiveIntegerRetryCapForAutoMergeConflict": "Positive integer retry cap for auto-merge conflict resolution before a task parks for human recovery. Default 3.",
"postMergeAuditMode": "Post-merge audit mode",
"pushRemote": "Push Remote",
"pushToRemoteAfterMerge": " Push to remote after merge ",
@@ -6143,6 +6174,8 @@ export default interface Resources {
"global": "Global setting",
"project": "Project setting"
},
"globalMcp": "MCP Servers",
"mcp": "MCP Servers",
"prompts": "Prompts",
"secrets": "Secrets",
"tooltip": {
@@ -7567,6 +7600,32 @@ export default interface Resources {
"summary": {
"heading": "Summary"
},
"summaryTab": {
"agentWorkHeading": "Work done by agents",
"cachedTokens": "Cached",
"changedHeading": "What changed",
"commit": "Commit",
"completedSteps": "Completed steps",
"completionHeading": "Completion summary",
"cost": "Cost",
"costUnavailable": "No pricing for this model",
"deletions": "Removed",
"filesChanged": "Files",
"inputTokens": "Input",
"insertions": "Added",
"model": "Model",
"noAgentWork": "No completed steps or workflow results are available for this task.",
"noChangedFiles": "No changed-file list is available for this task.",
"noCompletionSummary": "No completion summary was recorded for this task.",
"noTokenUsage": "No token usage recorded for this task yet.",
"outputTokens": "Output",
"retries": "Agents retried this task {{count}} time{{plural}}.",
"tokenCostHeading": "Token usage & cost",
"totalCost": "Total cost",
"totalTokens": "Total",
"unknownModel": "(unknown)",
"workflowResults": "Workflow results"
},
"tabs": {
"changes": "Changes",
"chat": "Chat",
@@ -7579,6 +7638,7 @@ export default interface Resources {
"review": "Review",
"routing": "Routing",
"stats": "Stats",
"summary": "Summary",
"terminal": "Terminal",
"workflow": "Workflow"
},