FN-6932: repair dashboard copy assertions
Repair dashboard copy and keep changed-only assertions aligned with current navigation behavior. - Restore TaskChat tool-call pluralization and inline result/error casing. - Replace JSX entity defaults in Settings helper copy with literal apostrophes. - Refresh dashboard tests for embedded navigation destinations and theme option parity. - Add a patch changeset for the dashboard copy repairs. Files changed: .changeset/fn-6932-dashboard-copy-repairs.md | 7 ++ packages/dashboard/app/components/TaskChatTab.tsx | 27 ++++++- .../app/components/__tests__/App.test.tsx | 94 +++++++++++++++++----- .../components/__tests__/ThemeSelector.test.tsx | 7 +- .../settings/sections/GlobalGeneralSection.tsx | 2 +- .../components/settings/sections/MergeSection.tsx | 12 +-- .../settings/sections/SchedulingSection.tsx | 2 +- .../settings/sections/WorktreesSection.tsx | 4 +- 8 files changed, 119 insertions(+), 36 deletions(-) Fusion-Task-Id: FN-6932 Fusion-Task-Lineage: 262f40e3-5a04-49c7-b2d4-8122d88ac488
This commit is contained in:
7
.changeset/fn-6932-dashboard-copy-repairs.md
Normal file
7
.changeset/fn-6932-dashboard-copy-repairs.md
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
---
|
||||||
|
"@runfusion/fusion": patch
|
||||||
|
---
|
||||||
|
|
||||||
|
summary: Restore dashboard Settings helper copy and TaskChat tool-call labels.
|
||||||
|
category: fix
|
||||||
|
dev: Repairs changed-only dashboard assertions for navigation, pause routes, TaskChat, and theme selector parity.
|
||||||
@@ -244,14 +244,33 @@ function formatEntryLabel(entry: AgentLogEntry, t: TFunction<"app">): string {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
FNXC:TaskChat 2026-06-22-03:05:
|
||||||
|
Tool-call group copy intentionally preserves the pre-i18n grammar contract because the test i18n instance interpolates defaults without plural suffix resolution.
|
||||||
|
Keep plural branches in source for deterministic "1 tool call" / "N tool calls" and use lowercase completion labels only for the inline "Tool call → result/error" kicker; detail headers remain capitalized below.
|
||||||
|
*/
|
||||||
function formatCompletionLabel(entry: AgentLogEntry, t: TFunction<"app">): string {
|
function formatCompletionLabel(entry: AgentLogEntry, t: TFunction<"app">): string {
|
||||||
return entry.type === "tool_error" ? t("taskChat.error", "Error") : t("taskChat.result", "Result");
|
return entry.type === "tool_error" ? t("taskChat.errorInline", "error") : t("taskChat.resultInline", "result");
|
||||||
}
|
}
|
||||||
|
|
||||||
const TOOL_NAME_SUMMARY_LIMIT = 5;
|
const TOOL_NAME_SUMMARY_LIMIT = 5;
|
||||||
|
|
||||||
function formatToolCallCount(count: number, t: TFunction<"app">): string {
|
function formatToolCallCount(count: number, t: TFunction<"app">): string {
|
||||||
return t("taskChat.toolCallCount", "{{count}} tool call", { count });
|
return count === 1
|
||||||
|
? t("taskChat.toolCallCount", "{{count}} tool call", { count })
|
||||||
|
: t("taskChat.toolCallCountPlural", "{{count}} tool calls", { count });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatErrorCount(count: number, t: TFunction<"app">): string {
|
||||||
|
return count === 1
|
||||||
|
? t("taskChat.errorCount", "{{count}} error", { count })
|
||||||
|
: t("taskChat.errorCountPlural", "{{count}} errors", { count });
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatEntryCount(count: number, t: TFunction<"app">): string {
|
||||||
|
return count === 1
|
||||||
|
? t("taskChat.entryCount", "{{count}} entry", { count })
|
||||||
|
: t("taskChat.entryCountPlural", "{{count}} entries", { count });
|
||||||
}
|
}
|
||||||
|
|
||||||
function getToolInvocationEntries(entries: AgentLogEntry[]): AgentLogEntry[] {
|
function getToolInvocationEntries(entries: AgentLogEntry[]): AgentLogEntry[] {
|
||||||
@@ -414,7 +433,7 @@ function TaskChatToolGroup({ entries }: { entries: AgentLogEntry[] }) {
|
|||||||
) : null}
|
) : null}
|
||||||
{errorCount > 0 ? (
|
{errorCount > 0 ? (
|
||||||
<span className="task-chat-tool-group-error-count">
|
<span className="task-chat-tool-group-error-count">
|
||||||
{t("taskChat.errorCount", "{{count}} error", { count: errorCount })}
|
{formatErrorCount(errorCount, t)}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</summary>
|
</summary>
|
||||||
@@ -825,7 +844,7 @@ export function TaskChatTab({ task, projectId, active, addToast, onTaskUpdated,
|
|||||||
<div>
|
<div>
|
||||||
<div className="task-chat-role-label">{item.label}</div>
|
<div className="task-chat-role-label">{item.label}</div>
|
||||||
<div className="task-chat-group-meta">
|
<div className="task-chat-group-meta">
|
||||||
<span>{t("taskChat.entryCount", "{{count}} entry", { count: item.entries.length })}</span>
|
<span>{formatEntryCount(item.entries.length, t)}</span>
|
||||||
{relativeTime ? (
|
{relativeTime ? (
|
||||||
<span className="task-chat-timestamp" data-testid="task-chat-group-time">
|
<span className="task-chat-timestamp" data-testid="task-chat-group-time">
|
||||||
{relativeTime}
|
{relativeTime}
|
||||||
|
|||||||
@@ -25,7 +25,12 @@ const defaultSettings: Settings = {
|
|||||||
buildCommand: "",
|
buildCommand: "",
|
||||||
capacityRiskBannerEnabled: false,
|
capacityRiskBannerEnabled: false,
|
||||||
capacityRiskTodoThreshold: 20,
|
capacityRiskTodoThreshold: 20,
|
||||||
experimentalFeatures: { insights: true, skillsView: true, agentsView: true, memoryView: true, evalsView: true },
|
/*
|
||||||
|
* FNXC:DashboardTests 2026-06-22-03:38:
|
||||||
|
* App.test.tsx keeps legacy Header view-toggle coverage unless a test explicitly opts into the left-sidebar default.
|
||||||
|
* The product now enables leftSidebarNav by default, so the test fixture must set the flag false instead of accidentally hiding Header controls.
|
||||||
|
*/
|
||||||
|
experimentalFeatures: { insights: true, skillsView: true, agentsView: true, memoryView: true, evalsView: true, leftSidebarNav: false },
|
||||||
};
|
};
|
||||||
|
|
||||||
const mockAgentStats = {
|
const mockAgentStats = {
|
||||||
@@ -623,6 +628,22 @@ async function waitForAppShell(): Promise<void> {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
|
/*
|
||||||
|
* FNXC:DashboardTests 2026-06-22-03:47:
|
||||||
|
* App.test.tsx runs beside other dashboard specs in the same Vitest process, so reset API mock implementations as well as call counts to prevent cross-file implementation leakage.
|
||||||
|
*/
|
||||||
|
vi.mocked(fetchSettings).mockResolvedValue({ ...defaultSettings });
|
||||||
|
vi.mocked(updateSettings).mockResolvedValue({ ...defaultSettings });
|
||||||
|
vi.mocked(fetchGlobalSettings).mockResolvedValue({ modelOnboardingComplete: true });
|
||||||
|
vi.mocked(fetchAuthStatus).mockResolvedValue({
|
||||||
|
providers: [
|
||||||
|
{ id: "anthropic", name: "Anthropic", authenticated: true },
|
||||||
|
{ id: "github", name: "GitHub", authenticated: true },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
vi.mocked(fetchModels).mockResolvedValue({ models: [], favoriteProviders: [], favoriteModels: [] });
|
||||||
|
vi.mocked(fetchScripts).mockResolvedValue({ build: "npm run build", test: "pnpm test" });
|
||||||
|
vi.mocked(runScript).mockResolvedValue({ sessionId: "sess-script-1", command: "echo hello" });
|
||||||
__resetShellHostContextForTests();
|
__resetShellHostContextForTests();
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
mockSubscribeSse.mockReset();
|
mockSubscribeSse.mockReset();
|
||||||
@@ -1758,8 +1779,17 @@ describe("App mission wiring", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe("App auto-open Settings on unauthenticated", () => {
|
describe("App auto-open Settings on unauthenticated", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.mocked(fetchAuthStatus).mockResolvedValue({
|
||||||
|
providers: [
|
||||||
|
{ id: "anthropic", name: "Anthropic", authenticated: false },
|
||||||
|
{ id: "github", name: "GitHub", authenticated: false },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it("auto-opens onboarding modal when all providers are unauthenticated and onboarding not complete", async () => {
|
it("auto-opens onboarding modal when all providers are unauthenticated and onboarding not complete", async () => {
|
||||||
// fetchGlobalSettings returns {} by default (modelOnboardingComplete is undefined)
|
vi.mocked(fetchGlobalSettings).mockResolvedValue({});
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
// Wait for the auth status check and global settings check
|
// Wait for the auth status check and global settings check
|
||||||
@@ -1791,9 +1821,7 @@ describe("App auto-open Settings on unauthenticated", () => {
|
|||||||
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
await waitFor(() => expect(fetchSettings).toHaveBeenCalled());
|
||||||
|
|
||||||
// Authentication section should be active — auth status is fetched when section is active
|
// Authentication section should be active — auth status is fetched when section is active
|
||||||
await waitFor(() => {
|
expect(await screen.findByText("Anthropic")).toBeTruthy();
|
||||||
expect(screen.getByText("Anthropic")).toBeTruthy();
|
|
||||||
});
|
|
||||||
expect(screen.getByText("GitHub")).toBeTruthy();
|
expect(screen.getByText("GitHub")).toBeTruthy();
|
||||||
|
|
||||||
// Onboarding modal should NOT be open
|
// Onboarding modal should NOT be open
|
||||||
@@ -1882,7 +1910,7 @@ describe("App auto-open Settings on unauthenticated", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("re-opening Settings via gear icon defaults to Authentication tab after closing onboarding", async () => {
|
it("re-opening Settings via gear icon defaults to Authentication tab after closing onboarding", async () => {
|
||||||
// fetchGlobalSettings returns {} by default → onboarding opens
|
vi.mocked(fetchGlobalSettings).mockResolvedValue({});
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
// Wait for onboarding to auto-open
|
// Wait for onboarding to auto-open
|
||||||
@@ -1949,6 +1977,13 @@ describe("OnboardingResumeCard", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("renders onboarding modal when in resumable state and modal is open", async () => {
|
it("renders onboarding modal when in resumable state and modal is open", async () => {
|
||||||
|
vi.mocked(fetchGlobalSettings).mockResolvedValue({});
|
||||||
|
vi.mocked(fetchAuthStatus).mockResolvedValue({
|
||||||
|
providers: [
|
||||||
|
{ id: "anthropic", name: "Anthropic", authenticated: false },
|
||||||
|
{ id: "github", name: "GitHub", authenticated: false },
|
||||||
|
],
|
||||||
|
});
|
||||||
// Set up localStorage with resumable state
|
// Set up localStorage with resumable state
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
STORAGE_KEY,
|
STORAGE_KEY,
|
||||||
@@ -2459,7 +2494,7 @@ describe("App view switching", () => {
|
|||||||
// Override the default mock to exclude agentsView
|
// Override the default mock to exclude agentsView
|
||||||
vi.mocked(fetchSettings).mockResolvedValue({
|
vi.mocked(fetchSettings).mockResolvedValue({
|
||||||
...defaultSettings,
|
...defaultSettings,
|
||||||
experimentalFeatures: { insights: true, skillsView: true }, // no agentsView
|
experimentalFeatures: { ...defaultSettings.experimentalFeatures, insights: true, skillsView: true }, // no agentsView
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<App />);
|
render(<App />);
|
||||||
@@ -2620,7 +2655,7 @@ describe("App view switching", () => {
|
|||||||
it("keeps insights view button visible after graduation from experimental flags", async () => {
|
it("keeps insights view button visible after graduation from experimental flags", async () => {
|
||||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
...defaultSettings,
|
...defaultSettings,
|
||||||
experimentalFeatures: { insights: false },
|
experimentalFeatures: { ...defaultSettings.experimentalFeatures, insights: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<App />);
|
render(<App />);
|
||||||
@@ -2646,13 +2681,13 @@ describe("App view switching", () => {
|
|||||||
|
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
await waitFor(() => {
|
expect(screen.queryByTitle("Board view")).toBeNull();
|
||||||
expect(screen.getByTestId("sidebar-nav-board")).toBeTruthy();
|
expect(document.querySelector(".insights-view")).toBeNull();
|
||||||
});
|
expect(document.querySelector(".board")).toBeNull();
|
||||||
|
|
||||||
resolveSettings?.({
|
resolveSettings?.({
|
||||||
...defaultSettings,
|
...defaultSettings,
|
||||||
experimentalFeatures: {},
|
experimentalFeatures: { leftSidebarNav: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
@@ -2666,7 +2701,7 @@ describe("App view switching", () => {
|
|||||||
it("keeps memory view button visible after graduation from experimental flags", async () => {
|
it("keeps memory view button visible after graduation from experimental flags", async () => {
|
||||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
...defaultSettings,
|
...defaultSettings,
|
||||||
experimentalFeatures: { memoryView: false, insights: true },
|
experimentalFeatures: { ...defaultSettings.experimentalFeatures, memoryView: false, insights: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<App />);
|
render(<App />);
|
||||||
@@ -2682,7 +2717,7 @@ describe("App view switching", () => {
|
|||||||
localStorage.setItem(taskViewStorageKey(), "memory");
|
localStorage.setItem(taskViewStorageKey(), "memory");
|
||||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
...defaultSettings,
|
...defaultSettings,
|
||||||
experimentalFeatures: { memoryView: false },
|
experimentalFeatures: { ...defaultSettings.experimentalFeatures, memoryView: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<App />);
|
render(<App />);
|
||||||
@@ -2703,7 +2738,7 @@ describe("App view switching", () => {
|
|||||||
localStorage.setItem(taskViewStorageKey(), "goalsView");
|
localStorage.setItem(taskViewStorageKey(), "goalsView");
|
||||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
...defaultSettings,
|
...defaultSettings,
|
||||||
experimentalFeatures: { goalsView: true },
|
experimentalFeatures: { ...defaultSettings.experimentalFeatures, goalsView: true },
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<App />);
|
render(<App />);
|
||||||
@@ -2719,7 +2754,7 @@ describe("App view switching", () => {
|
|||||||
localStorage.setItem(taskViewStorageKey(), "goalsView");
|
localStorage.setItem(taskViewStorageKey(), "goalsView");
|
||||||
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
(fetchSettings as ReturnType<typeof vi.fn>).mockResolvedValueOnce({
|
||||||
...defaultSettings,
|
...defaultSettings,
|
||||||
experimentalFeatures: { goalsView: false },
|
experimentalFeatures: { ...defaultSettings.experimentalFeatures, goalsView: false },
|
||||||
});
|
});
|
||||||
|
|
||||||
render(<App />);
|
render(<App />);
|
||||||
@@ -2775,6 +2810,11 @@ describe("App GitHub import", () => {
|
|||||||
|
|
||||||
describe("App Planning Mode", () => {
|
describe("App Planning Mode", () => {
|
||||||
it("opens Planning Mode as an embedded view from the sidebar destination", async () => {
|
it("opens Planning Mode as an embedded view from the sidebar destination", async () => {
|
||||||
|
localStorage.setItem("kb-dashboard-view-mode", "project");
|
||||||
|
vi.mocked(fetchSettings).mockResolvedValueOnce({
|
||||||
|
...defaultSettings,
|
||||||
|
experimentalFeatures: { ...defaultSettings.experimentalFeatures, leftSidebarNav: true },
|
||||||
|
});
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
const planningNavItem = await screen.findByTestId("sidebar-nav-planning");
|
const planningNavItem = await screen.findByTestId("sidebar-nav-planning");
|
||||||
@@ -2788,6 +2828,11 @@ describe("App Planning Mode", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("closes Planning Mode embedded view back to the board", async () => {
|
it("closes Planning Mode embedded view back to the board", async () => {
|
||||||
|
localStorage.setItem("kb-dashboard-view-mode", "project");
|
||||||
|
vi.mocked(fetchSettings).mockResolvedValueOnce({
|
||||||
|
...defaultSettings,
|
||||||
|
experimentalFeatures: { ...defaultSettings.experimentalFeatures, leftSidebarNav: true },
|
||||||
|
});
|
||||||
render(<App />);
|
render(<App />);
|
||||||
|
|
||||||
fireEvent.click(await screen.findByTestId("sidebar-nav-planning"));
|
fireEvent.click(await screen.findByTestId("sidebar-nav-planning"));
|
||||||
@@ -4070,6 +4115,9 @@ describe("App board branch filters", () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it("composes with search and does not affect list view tasks", async () => {
|
it("composes with search and does not affect list view tasks", async () => {
|
||||||
|
localStorage.setItem("kb-dashboard-view-mode", "project");
|
||||||
|
localStorage.setItem(taskViewStorageKey(), "board");
|
||||||
|
vi.mocked(fetchSettings).mockResolvedValue({ ...defaultSettings });
|
||||||
mockUseTasks.mockImplementation(() => ({
|
mockUseTasks.mockImplementation(() => ({
|
||||||
tasks: [
|
tasks: [
|
||||||
makeTask("FN-4", "Alpha Search", "feature/a", "main"),
|
makeTask("FN-4", "Alpha Search", "feature/a", "main"),
|
||||||
@@ -4216,8 +4264,14 @@ describe("App shell connection status plumbing", () => {
|
|||||||
expect(screen.queryByTestId("shell-connection-status-button")).toBeNull();
|
expect(screen.queryByTestId("shell-connection-status-button")).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders shell connection status for mobile shell host in mobile More sheet only", async () => {
|
it("keeps shell connection status out of compact mobile header chrome", async () => {
|
||||||
mockUseViewportMode.mockReturnValue("mobile");
|
mockUseViewportMode.mockReturnValue("mobile");
|
||||||
|
localStorage.setItem("kb-dashboard-view-mode", "project");
|
||||||
|
vi.mocked(fetchSettings).mockResolvedValueOnce({
|
||||||
|
...defaultSettings,
|
||||||
|
experimentalFeatures: { ...defaultSettings.experimentalFeatures, leftSidebarNav: true },
|
||||||
|
});
|
||||||
|
vi.mocked(fetchGlobalSettings).mockResolvedValueOnce({ modelOnboardingComplete: true });
|
||||||
mockShellHostContextValue.host = { kind: "mobile-shell", mode: "remote", connectionId: "p1", serverUrl: "https://fusion.example.com" };
|
mockShellHostContextValue.host = { kind: "mobile-shell", mode: "remote", connectionId: "p1", serverUrl: "https://fusion.example.com" };
|
||||||
mockGetShellConnectionNativeResult.mockResolvedValueOnce({
|
mockGetShellConnectionNativeResult.mockResolvedValueOnce({
|
||||||
hostKind: "mobile-shell",
|
hostKind: "mobile-shell",
|
||||||
@@ -4232,13 +4286,11 @@ describe("App shell connection status plumbing", () => {
|
|||||||
|
|
||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
expect(mockGetShellConnectionNativeResult).toHaveBeenCalledWith(mockShellHostContextValue.host);
|
expect(mockGetShellConnectionNativeResult).toHaveBeenCalledWith(mockShellHostContextValue.host);
|
||||||
expect(screen.getByTestId("mobile-nav-tab-more")).toBeInTheDocument();
|
expect(screen.getByTestId("mobile-view-toggle")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(screen.queryByTestId("shell-connection-status-button")).toBeNull();
|
expect(screen.queryByTestId("shell-connection-status-button")).toBeNull();
|
||||||
fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
|
expect(screen.queryByTestId("mobile-more-shell-connection")).toBeNull();
|
||||||
expect(screen.getAllByTestId("shell-connection-status-button")).toHaveLength(1);
|
|
||||||
expect(screen.getByTestId("mobile-more-shell-connection")).toBeInTheDocument();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it("keeps desktop shell connection status in header and out of mobile sheet", async () => {
|
it("keeps desktop shell connection status in header and out of mobile sheet", async () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, it, expect, vi } from "vitest";
|
import { describe, it, expect, vi } from "vitest";
|
||||||
import { render, screen, fireEvent } from "@testing-library/react";
|
import { render, screen, fireEvent, within } from "@testing-library/react";
|
||||||
import { COLOR_THEMES } from "@fusion/core";
|
import { COLOR_THEMES } from "@fusion/core";
|
||||||
import { ThemeSelector } from "../ThemeSelector";
|
import { ThemeSelector } from "../ThemeSelector";
|
||||||
import { COLOR_THEMES as THEME_OPTIONS } from "../themeOptions";
|
import { COLOR_THEMES as THEME_OPTIONS } from "../themeOptions";
|
||||||
@@ -81,10 +81,15 @@ describe("ThemeSelector", () => {
|
|||||||
// FNXC:Theme 2026-06-22-09:30: Assert the accessibility invariant — every theme in the
|
// FNXC:Theme 2026-06-22-09:30: Assert the accessibility invariant — every theme in the
|
||||||
// shared COLOR_THEMES list renders an accessibly-labeled option — instead of a frozen
|
// shared COLOR_THEMES list renders an accessibly-labeled option — instead of a frozen
|
||||||
// hardcoded label list that drifts whenever themes are renamed/added (e.g. FN-6813 mono variants).
|
// hardcoded label list that drifts whenever themes are renamed/added (e.g. FN-6813 mono variants).
|
||||||
|
const colorThemeGroup = screen.getByRole("radiogroup", { name: "Color theme" });
|
||||||
|
const themeButtons = within(colorThemeGroup).getAllByRole("button");
|
||||||
|
|
||||||
|
expect(themeButtons).toHaveLength(THEME_OPTIONS.length);
|
||||||
for (const theme of THEME_OPTIONS) {
|
for (const theme of THEME_OPTIONS) {
|
||||||
expect(screen.getByLabelText(`${theme.label} theme`)).toBeDefined();
|
expect(screen.getByLabelText(`${theme.label} theme`)).toBeDefined();
|
||||||
}
|
}
|
||||||
expect(THEME_OPTIONS.map((theme) => theme.value)).toEqual([...COLOR_THEMES]);
|
expect(THEME_OPTIONS.map((theme) => theme.value)).toEqual([...COLOR_THEMES]);
|
||||||
|
expect(screen.getByLabelText("Default theme").getAttribute("aria-pressed")).toBe("true");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders every shared swatch class from themeOptions", () => {
|
it("renders every shared swatch class from themeOptions", () => {
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ export function GlobalGeneralSection({ scopeBanner, form, setForm, globalTrackin
|
|||||||
<input id="fnBinaryCheckEnabled" type="checkbox" checked={form.fnBinaryCheckEnabled !== false} onChange={(e) => setForm((f) => ({ ...f, fnBinaryCheckEnabled: e.target.checked }))}/>{t("settings.globalGeneral.checkForThe", " Check for the ")}<code>fn</code>{t("settings.globalGeneral.cLIBinaryOnPATH", " CLI binary on PATH ")}</label>
|
<input id="fnBinaryCheckEnabled" type="checkbox" checked={form.fnBinaryCheckEnabled !== false} onChange={(e) => setForm((f) => ({ ...f, fnBinaryCheckEnabled: e.target.checked }))}/>{t("settings.globalGeneral.checkForThe", " Check for the ")}<code>fn</code>{t("settings.globalGeneral.cLIBinaryOnPATH", " CLI binary on PATH ")}</label>
|
||||||
<small>{t("settings.globalGeneral.whenEnabledTheDashboardProbesForAGlobally", " When enabled, the dashboard probes for a globally-installed")}{" "}
|
<small>{t("settings.globalGeneral.whenEnabledTheDashboardProbesForAGlobally", " When enabled, the dashboard probes for a globally-installed")}{" "}
|
||||||
<code>fn</code> / <code>fusion</code>{t("settings.globalGeneral.cLIBySpawning", " CLI by spawning")}{" "}
|
<code>fn</code> / <code>fusion</code>{t("settings.globalGeneral.cLIBySpawning", " CLI by spawning")}{" "}
|
||||||
<code><bin> --version</code>{t("settings.globalGeneral.disableThisIfYourLocalDevProcessIs", ". Disable this if your local dev process is the source of truth and you don't want any outdated globally-installed binary executed during the probe. ")}</small>
|
<code><bin> --version</code>{t("settings.globalGeneral.disableThisIfYourLocalDevProcessIs", ". Disable this if your local dev process is the source of truth and you don't want any outdated globally-installed binary executed during the probe. ")}</small>
|
||||||
</div>
|
</div>
|
||||||
<h4 className="settings-section-heading settings-section-heading--spaced">{t("settings.globalGeneral.updates", "Updates")}</h4>
|
<h4 className="settings-section-heading settings-section-heading--spaced">{t("settings.globalGeneral.updates", "Updates")}</h4>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
|
|||||||
@@ -129,7 +129,7 @@ export function MergeSection({ scopeBanner, form, setForm, integrationBranchOpti
|
|||||||
</select>
|
</select>
|
||||||
<details className="settings-option-details">
|
<details className="settings-option-details">
|
||||||
<summary>{t("settings.merge.moreDetails", "More details")}</summary>
|
<summary>{t("settings.merge.moreDetails", "More details")}</summary>
|
||||||
<small>{t("settings.merge.aIModeMergesTheTaskBranchIntoAn", " AI mode merges the task branch into an isolated clean-room checkout at the target branch's tip, has an AI reviewer audit the squash (with corrective retries \u2014 advisory concerns land with a logged warning, an unfixable correctness concern hard-fails), then fast-forwards the target branch and syncs your local checkout (AI reconciles a conflicting restore). Each task merges to its own target branch, or the default integration branch. ")}<strong>{t("settings.merge.theLegacyMergeSettingsBelowDoNotApply", "The legacy merge settings below do not apply while AI merge is on.")}</strong>
|
<small>{t("settings.merge.aIModeMergesTheTaskBranchIntoAn", " AI mode merges the task branch into an isolated clean-room checkout at the target branch's tip, has an AI reviewer audit the squash (with corrective retries \u2014 advisory concerns land with a logged warning, an unfixable correctness concern hard-fails), then fast-forwards the target branch and syncs your local checkout (AI reconciles a conflicting restore). Each task merges to its own target branch, or the default integration branch. ")}<strong>{t("settings.merge.theLegacyMergeSettingsBelowDoNotApply", "The legacy merge settings below do not apply while AI merge is on.")}</strong>
|
||||||
</small>
|
</small>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
@@ -137,7 +137,7 @@ export function MergeSection({ scopeBanner, form, setForm, integrationBranchOpti
|
|||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="mergerMaxReviewPasses">{t("settings.merge.maxAIReviewPasses", "Max AI review passes")}</label>
|
<label htmlFor="mergerMaxReviewPasses">{t("settings.merge.maxAIReviewPasses", "Max AI review passes")}</label>
|
||||||
<input id="mergerMaxReviewPasses" type="number" min={0} max={10} value={form.merger?.maxReviewPasses ?? 3} onChange={(e) => setForm((f) => ({ ...f, merger: { ...(f.merger ?? {}), maxReviewPasses: e.target.value === "" ? undefined : Number(e.target.value) } }))}/>
|
<input id="mergerMaxReviewPasses" type="number" min={0} max={10} value={form.merger?.maxReviewPasses ?? 3} onChange={(e) => setForm((f) => ({ ...f, merger: { ...(f.merger ?? {}), maxReviewPasses: e.target.value === "" ? undefined : Number(e.target.value) } }))}/>
|
||||||
<small>{t("settings.merge.aICorrectiveRoundsBeforeLandingTheBestResult", "AI corrective rounds before landing the best result (advisory concern) or hard-failing (unfixable correctness concern). Default 3. The reviewer uses your project's reviewer/validator model.")}</small>
|
<small>{t("settings.merge.aICorrectiveRoundsBeforeLandingTheBestResult", "AI corrective rounds before landing the best result (advisory concern) or hard-failing (unfixable correctness concern). Default 3. The reviewer uses your project's reviewer/validator model.")}</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="mergerAllowDirtyLocalCheckoutSync" className="checkbox-label">
|
<label htmlFor="mergerAllowDirtyLocalCheckoutSync" className="checkbox-label">
|
||||||
@@ -214,7 +214,7 @@ export function MergeSection({ scopeBanner, form, setForm, integrationBranchOpti
|
|||||||
<summary>{t("settings.merge.moreDetails", "More details")}</summary>
|
<summary>{t("settings.merge.moreDetails", "More details")}</summary>
|
||||||
<small>{t("settings.merge.theCanonicalBranchFusionMergesTasksIntoAnd", " The canonical branch Fusion merges tasks into and uses as the reference for all ahead/behind / overlap / pre-rebase computations. Leave on ")}<em>{t("settings.merge.autoDetect", "auto-detect")}</em>{t("settings.merge.toResolveViaTheStandardCascade", " to resolve via the standard cascade (")}<code>integrationBranch</code>{t("settings.merge.legacy", " \u2192 legacy ")}<code>baseBranch</code> →
|
<small>{t("settings.merge.theCanonicalBranchFusionMergesTasksIntoAnd", " The canonical branch Fusion merges tasks into and uses as the reference for all ahead/behind / overlap / pre-rebase computations. Leave on ")}<em>{t("settings.merge.autoDetect", "auto-detect")}</em>{t("settings.merge.toResolveViaTheStandardCascade", " to resolve via the standard cascade (")}<code>integrationBranch</code>{t("settings.merge.legacy", " \u2192 legacy ")}<code>baseBranch</code> →
|
||||||
<code>origin/HEAD</code>{t("settings.merge.symbolicRefFallback", " symbolic ref \u2192 fallback ")}<code>main</code>{t("settings.merge.pickALocalBranchFromTheDropdownCommon", "). Pick a local branch from the dropdown \u2014 common integration names like ")}<code>main</code>,
|
<code>origin/HEAD</code>{t("settings.merge.symbolicRefFallback", " symbolic ref \u2192 fallback ")}<code>main</code>{t("settings.merge.pickALocalBranchFromTheDropdownCommon", "). Pick a local branch from the dropdown \u2014 common integration names like ")}<code>main</code>,
|
||||||
<code>master</code>, <code>trunk</code>{t("settings.merge.and", ", and ")}<code>develop</code>{t("settings.merge.areListedFirstOrChoose", " are listed first \u2014 or choose ")}<em>{t("settings.merge.custom", "Custom\u2026")}</em>{t("settings.merge.toTypeABranchThatDoesnAposT", " to type a branch that doesn't exist locally yet. Applies to both direct merges and pull-request mode; individual tasks can still override via task metadata. ")}</small>
|
<code>master</code>, <code>trunk</code>{t("settings.merge.and", ", and ")}<code>develop</code>{t("settings.merge.areListedFirstOrChoose", " are listed first \u2014 or choose ")}<em>{t("settings.merge.custom", "Custom\u2026")}</em>{t("settings.merge.toTypeABranchThatDoesnAposT", " to type a branch that doesn't exist locally yet. Applies to both direct merges and pull-request mode; individual tasks can still override via task metadata. ")}</small>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
{form.mergeStrategy !== "pull-request" && (form.merger?.mode ?? "ai") !== "ai" && (<>
|
{form.mergeStrategy !== "pull-request" && (form.merger?.mode ?? "ai") !== "ai" && (<>
|
||||||
@@ -230,7 +230,7 @@ export function MergeSection({ scopeBanner, form, setForm, integrationBranchOpti
|
|||||||
</select>
|
</select>
|
||||||
<details className="settings-option-details">
|
<details className="settings-option-details">
|
||||||
<summary>{t("settings.merge.moreDetails", "More details")}</summary>
|
<summary>{t("settings.merge.moreDetails", "More details")}</summary>
|
||||||
<small>{t("settings.merge.autoKeepsTodayAposSSquashBehaviorFor", " Auto keeps today's squash behavior for branches with zero or one substantive commit, but switches multi-substantive branches to a history-preserving rebase-and-merge path. Individual tasks can override this in PROMPT.md with ")}<code>**Direct Merge Commit Strategy:** auto|always-squash|always-rebase</code>.
|
<small>{t("settings.merge.autoKeepsTodayAposSSquashBehaviorFor", " Auto keeps today's squash behavior for branches with zero or one substantive commit, but switches multi-substantive branches to a history-preserving rebase-and-merge path. Individual tasks can override this in PROMPT.md with ")}<code>**Direct Merge Commit Strategy:** auto|always-squash|always-rebase</code>.
|
||||||
</small>
|
</small>
|
||||||
</details>
|
</details>
|
||||||
</div>
|
</div>
|
||||||
@@ -245,7 +245,7 @@ export function MergeSection({ scopeBanner, form, setForm, integrationBranchOpti
|
|||||||
</select>
|
</select>
|
||||||
<small>{t("settings.merge.autoMergeRunsInTheTaskWorktreeBy", " Auto-merge runs in the task worktree by default. Switch to the legacy project-root path only if you need the pre-FN-5279 fallback; worktrunk-managed projects still defer to worktrunk. ")}</small>
|
<small>{t("settings.merge.autoMergeRunsInTheTaskWorktreeBy", " Auto-merge runs in the task worktree by default. Switch to the legacy project-root path only if you need the pre-FN-5279 fallback; worktrunk-managed projects still defer to worktrunk. ")}</small>
|
||||||
{(form.mergeIntegrationWorktree ?? "reuse-task-worktree") !== "reuse-task-worktree" && (<div className="settings-warning-banner" role="alert" aria-live="polite" data-testid="merge-integration-worktree-warning">
|
{(form.mergeIntegrationWorktree ?? "reuse-task-worktree") !== "reuse-task-worktree" && (<div className="settings-warning-banner" role="alert" aria-live="polite" data-testid="merge-integration-worktree-warning">
|
||||||
<strong>{t("settings.merge.legacyIntegrationBranchMode", "Legacy integration-branch mode.")}</strong>{" "}{t("settings.merge.autoMergeWillRunRebaseConflictResolutionAnd", " Auto-merge will run rebase, conflict resolution, and squash commits inside the project root (the user's checked-out integration-branch worktree) instead of the task worktree. Fusion assumes that directory is already on the integration branch and clean; if it isn't, merges may fail or touch the user's working tree. Reuse-task-worktree is the recommended default (FN-5279). Switch back unless you have a specific reason to opt in (FN-5348). ")}</div>)}
|
<strong>{t("settings.merge.legacyIntegrationBranchMode", "Legacy integration-branch mode.")}</strong>{" "}{t("settings.merge.autoMergeWillRunRebaseConflictResolutionAnd", " Auto-merge will run rebase, conflict resolution, and squash commits inside the project root (the user's checked-out integration-branch worktree) instead of the task worktree. Fusion assumes that directory is already on the integration branch and clean; if it isn't, merges may fail or touch the user's working tree. Reuse-task-worktree is the recommended default (FN-5279). Switch back unless you have a specific reason to opt in (FN-5348). ")}</div>)}
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="mergeAdvanceAutoSync">{t("settings.merge.autoSyncProjectCheckoutAfterMerge", "Auto-sync project checkout after merge")}</label>
|
<label htmlFor="mergeAdvanceAutoSync">{t("settings.merge.autoSyncProjectCheckoutAfterMerge", "Auto-sync project checkout after merge")}</label>
|
||||||
@@ -370,7 +370,7 @@ export function MergeSection({ scopeBanner, form, setForm, integrationBranchOpti
|
|||||||
<option value="warn">{t("settings.merge.warnDefaultLogFindingsContinue", "Warn (default; log findings, continue)")}</option>
|
<option value="warn">{t("settings.merge.warnDefaultLogFindingsContinue", "Warn (default; log findings, continue)")}</option>
|
||||||
<option value="off">{t("settings.merge.offSkipAudit", "Off (skip audit)")}</option>
|
<option value="off">{t("settings.merge.offSkipAudit", "Off (skip audit)")}</option>
|
||||||
</select>
|
</select>
|
||||||
<small>{t("settings.merge.controlsThePostMergeAuditGate", " Controls the post-merge audit gate. ")}<strong>{t("settings.merge.warn", "Warn")}</strong>{t("settings.merge.defaultLogsFindingsButAutoCompletesTheMerge", " (default) logs findings but auto-completes the merge. ")}<strong>{t("settings.merge.block", "Block")}</strong>{t("settings.merge.isTheStricterOptInModeThatRefuses", " is the stricter opt-in mode that refuses to auto-complete merges with duplicate-subject or touched-file overlap risks. ")}<strong>{t("settings.merge.off", "Off")}</strong>{t("settings.merge.skipsTheAuditEntirelySwitchingToOffIs", " skips the audit entirely. Switching to Off is recommended only if you trust your branches don't silently drop edits. ")}</small>
|
<small>{t("settings.merge.controlsThePostMergeAuditGate", " Controls the post-merge audit gate. ")}<strong>{t("settings.merge.warn", "Warn")}</strong>{t("settings.merge.defaultLogsFindingsButAutoCompletesTheMerge", " (default) logs findings but auto-completes the merge. ")}<strong>{t("settings.merge.block", "Block")}</strong>{t("settings.merge.isTheStricterOptInModeThatRefuses", " is the stricter opt-in mode that refuses to auto-complete merges with duplicate-subject or touched-file overlap risks. ")}<strong>{t("settings.merge.off", "Off")}</strong>{t("settings.merge.skipsTheAuditEntirelySwitchingToOffIs", " skips the audit entirely. Switching to Off is recommended only if you trust your branches don't silently drop edits. ")}</small>
|
||||||
</div>
|
</div>
|
||||||
</>)}
|
</>)}
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export function SchedulingSection({ scopeBanner, form, setForm, globalMaxConcurr
|
|||||||
const num = Number(val);
|
const num = Number(val);
|
||||||
setForm((f) => ({ ...f, taskStuckTimeoutMs: val && num > 0 ? num * 60000 : undefined }));
|
setForm((f) => ({ ...f, taskStuckTimeoutMs: val && num > 0 ? num * 60000 : undefined }));
|
||||||
}}/>
|
}}/>
|
||||||
<small>{t("settings.scheduling.timeoutInMinutesForDetectingStuckTasksWhen", "Timeout in minutes for detecting stuck tasks. When a task's agent session shows no activity for longer than this duration, the task is terminated and retried. Leave empty to disable. Suggested: 10.")}</small>
|
<small>{t("settings.scheduling.timeoutInMinutesForDetectingStuckTasksWhen", "Timeout in minutes for detecting stuck tasks. When a task's agent session shows no activity for longer than this duration, the task is terminated and retried. Leave empty to disable. Suggested: 10.")}</small>
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group">
|
<div className="form-group">
|
||||||
<label htmlFor="staleHighFanoutBlockerAgeThresholdMs">{t("settings.scheduling.staleHighFanOutEscalationHours", "Stale High Fan-out Escalation (hours)")}</label>
|
<label htmlFor="staleHighFanoutBlockerAgeThresholdMs">{t("settings.scheduling.staleHighFanOutEscalationHours", "Stale High Fan-out Escalation (hours)")}</label>
|
||||||
|
|||||||
@@ -97,7 +97,7 @@ export function WorktreesSection({ scopeBanner, form, setForm, gitRemotes, workt
|
|||||||
onFailure: f.worktrunk?.onFailure ?? "fail",
|
onFailure: f.worktrunk?.onFailure ?? "fail",
|
||||||
},
|
},
|
||||||
}))}/>{t("settings.worktrees.enableWorktrunkIntegration", " Enable worktrunk integration ")}</label>
|
}))}/>{t("settings.worktrees.enableWorktrunkIntegration", " Enable worktrunk integration ")}</label>
|
||||||
<small>{t("settings.worktrees.disabledByDefaultOptInWhenEnabledFusion", " Disabled by default (opt-in). When enabled, Fusion shells out to ")}<code>worktrunk</code>{t("settings.worktrees.forWorktreeCreateSyncPruneAndRemoveOperations", " for worktree create, sync, prune, and remove operations and follows worktrunk's directory layout. ")}</small>
|
<small>{t("settings.worktrees.disabledByDefaultOptInWhenEnabledFusion", " Disabled by default (opt-in). When enabled, Fusion shells out to ")}<code>worktrunk</code>{t("settings.worktrees.forWorktreeCreateSyncPruneAndRemoveOperations", " for worktree create, sync, prune, and remove operations and follows worktrunk's directory layout. ")}</small>
|
||||||
{!worktrunkInstallVerified && form.worktrunk?.enabled !== true && (<small className="settings-muted">{t("settings.worktrees.installTheWorktrunkBinaryBelowToEnableThis", "Install the worktrunk binary below to enable this integration.")}</small>)}
|
{!worktrunkInstallVerified && form.worktrunk?.enabled !== true && (<small className="settings-muted">{t("settings.worktrees.installTheWorktrunkBinaryBelowToEnableThis", "Install the worktrunk binary below to enable this integration.")}</small>)}
|
||||||
</div>
|
</div>
|
||||||
<div className="form-group" data-testid="worktrunk-install-affordance">
|
<div className="form-group" data-testid="worktrunk-install-affordance">
|
||||||
@@ -148,7 +148,7 @@ export function WorktreesSection({ scopeBanner, form, setForm, gitRemotes, workt
|
|||||||
<option value="fallback-native">{t("settings.worktrees.fallBackToFusionsNativeWorktreeBackend", "Fall back to Fusion's native worktree backend")}</option>
|
<option value="fallback-native">{t("settings.worktrees.fallBackToFusionsNativeWorktreeBackend", "Fall back to Fusion's native worktree backend")}</option>
|
||||||
</select>
|
</select>
|
||||||
<small>
|
<small>
|
||||||
<code>fail</code>{t("settings.worktrees.stopsOnWorktrunkErrorsForExplicitOperatorRecovery", " stops on worktrunk errors for explicit operator recovery; ")}<code>fallback-native</code>{t("settings.worktrees.keepsProgressMovingBySwitchingToFusionApos", " keeps progress moving by switching to Fusion's built-in worktree backend. ")}</small>
|
<code>fail</code>{t("settings.worktrees.stopsOnWorktrunkErrorsForExplicitOperatorRecovery", " stops on worktrunk errors for explicit operator recovery; ")}<code>fallback-native</code>{t("settings.worktrees.keepsProgressMovingBySwitchingToFusionApos", " keeps progress moving by switching to Fusion's built-in worktree backend. ")}</small>
|
||||||
</div>
|
</div>
|
||||||
</>);
|
</>);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user