- {t("taskChat.entryCount", "{{count}} entry", { count: item.entries.length })}
+ {formatEntryCount(item.entries.length, t)}
{relativeTime ? (
{relativeTime}
diff --git a/packages/dashboard/app/components/__tests__/App.test.tsx b/packages/dashboard/app/components/__tests__/App.test.tsx
index 5f5e70e398..390d12bb95 100644
--- a/packages/dashboard/app/components/__tests__/App.test.tsx
+++ b/packages/dashboard/app/components/__tests__/App.test.tsx
@@ -25,7 +25,12 @@ const defaultSettings: Settings = {
buildCommand: "",
capacityRiskBannerEnabled: false,
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 = {
@@ -623,6 +628,22 @@ async function waitForAppShell(): Promise {
beforeEach(() => {
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();
localStorage.clear();
mockSubscribeSse.mockReset();
@@ -1758,8 +1779,17 @@ describe("App mission wiring", () => {
});
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 () => {
- // fetchGlobalSettings returns {} by default (modelOnboardingComplete is undefined)
+ vi.mocked(fetchGlobalSettings).mockResolvedValue({});
render();
// 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());
// Authentication section should be active — auth status is fetched when section is active
- await waitFor(() => {
- expect(screen.getByText("Anthropic")).toBeTruthy();
- });
+ expect(await screen.findByText("Anthropic")).toBeTruthy();
expect(screen.getByText("GitHub")).toBeTruthy();
// 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 () => {
- // fetchGlobalSettings returns {} by default → onboarding opens
+ vi.mocked(fetchGlobalSettings).mockResolvedValue({});
render();
// 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 () => {
+ 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
localStorage.setItem(
STORAGE_KEY,
@@ -2459,7 +2494,7 @@ describe("App view switching", () => {
// Override the default mock to exclude agentsView
vi.mocked(fetchSettings).mockResolvedValue({
...defaultSettings,
- experimentalFeatures: { insights: true, skillsView: true }, // no agentsView
+ experimentalFeatures: { ...defaultSettings.experimentalFeatures, insights: true, skillsView: true }, // no agentsView
});
render();
@@ -2620,7 +2655,7 @@ describe("App view switching", () => {
it("keeps insights view button visible after graduation from experimental flags", async () => {
(fetchSettings as ReturnType).mockResolvedValueOnce({
...defaultSettings,
- experimentalFeatures: { insights: false },
+ experimentalFeatures: { ...defaultSettings.experimentalFeatures, insights: false },
});
render();
@@ -2646,13 +2681,13 @@ describe("App view switching", () => {
render();
- await waitFor(() => {
- expect(screen.getByTestId("sidebar-nav-board")).toBeTruthy();
- });
+ expect(screen.queryByTitle("Board view")).toBeNull();
+ expect(document.querySelector(".insights-view")).toBeNull();
+ expect(document.querySelector(".board")).toBeNull();
resolveSettings?.({
...defaultSettings,
- experimentalFeatures: {},
+ experimentalFeatures: { leftSidebarNav: false },
});
await waitFor(() => {
@@ -2666,7 +2701,7 @@ describe("App view switching", () => {
it("keeps memory view button visible after graduation from experimental flags", async () => {
(fetchSettings as ReturnType).mockResolvedValueOnce({
...defaultSettings,
- experimentalFeatures: { memoryView: false, insights: true },
+ experimentalFeatures: { ...defaultSettings.experimentalFeatures, memoryView: false, insights: true },
});
render();
@@ -2682,7 +2717,7 @@ describe("App view switching", () => {
localStorage.setItem(taskViewStorageKey(), "memory");
(fetchSettings as ReturnType).mockResolvedValueOnce({
...defaultSettings,
- experimentalFeatures: { memoryView: false },
+ experimentalFeatures: { ...defaultSettings.experimentalFeatures, memoryView: false },
});
render();
@@ -2703,7 +2738,7 @@ describe("App view switching", () => {
localStorage.setItem(taskViewStorageKey(), "goalsView");
(fetchSettings as ReturnType).mockResolvedValueOnce({
...defaultSettings,
- experimentalFeatures: { goalsView: true },
+ experimentalFeatures: { ...defaultSettings.experimentalFeatures, goalsView: true },
});
render();
@@ -2719,7 +2754,7 @@ describe("App view switching", () => {
localStorage.setItem(taskViewStorageKey(), "goalsView");
(fetchSettings as ReturnType).mockResolvedValueOnce({
...defaultSettings,
- experimentalFeatures: { goalsView: false },
+ experimentalFeatures: { ...defaultSettings.experimentalFeatures, goalsView: false },
});
render();
@@ -2775,6 +2810,11 @@ describe("App GitHub import", () => {
describe("App Planning Mode", () => {
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();
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 () => {
+ localStorage.setItem("kb-dashboard-view-mode", "project");
+ vi.mocked(fetchSettings).mockResolvedValueOnce({
+ ...defaultSettings,
+ experimentalFeatures: { ...defaultSettings.experimentalFeatures, leftSidebarNav: true },
+ });
render();
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 () => {
+ localStorage.setItem("kb-dashboard-view-mode", "project");
+ localStorage.setItem(taskViewStorageKey(), "board");
+ vi.mocked(fetchSettings).mockResolvedValue({ ...defaultSettings });
mockUseTasks.mockImplementation(() => ({
tasks: [
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();
});
- 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");
+ 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" };
mockGetShellConnectionNativeResult.mockResolvedValueOnce({
hostKind: "mobile-shell",
@@ -4232,13 +4286,11 @@ describe("App shell connection status plumbing", () => {
await waitFor(() => {
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();
- fireEvent.click(screen.getByTestId("mobile-nav-tab-more"));
- expect(screen.getAllByTestId("shell-connection-status-button")).toHaveLength(1);
- expect(screen.getByTestId("mobile-more-shell-connection")).toBeInTheDocument();
+ expect(screen.queryByTestId("mobile-more-shell-connection")).toBeNull();
});
it("keeps desktop shell connection status in header and out of mobile sheet", async () => {
diff --git a/packages/dashboard/app/components/__tests__/ThemeSelector.test.tsx b/packages/dashboard/app/components/__tests__/ThemeSelector.test.tsx
index 9547525716..2e1a27de48 100644
--- a/packages/dashboard/app/components/__tests__/ThemeSelector.test.tsx
+++ b/packages/dashboard/app/components/__tests__/ThemeSelector.test.tsx
@@ -1,5 +1,5 @@
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 { ThemeSelector } from "../ThemeSelector";
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
// 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).
+ 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) {
expect(screen.getByLabelText(`${theme.label} theme`)).toBeDefined();
}
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", () => {
diff --git a/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx b/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx
index cc1ede8c6b..ccd9ee6556 100644
--- a/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx
+++ b/packages/dashboard/app/components/settings/sections/GlobalGeneralSection.tsx
@@ -39,7 +39,7 @@ export function GlobalGeneralSection({ scopeBanner, form, setForm, globalTrackin
setForm((f) => ({ ...f, fnBinaryCheckEnabled: e.target.checked }))}/>{t("settings.globalGeneral.checkForThe", " Check for the ")}fn{t("settings.globalGeneral.cLIBinaryOnPATH", " CLI binary on PATH ")}
{t("settings.globalGeneral.whenEnabledTheDashboardProbesForAGlobally", " When enabled, the dashboard probes for a globally-installed")}{" "}
fn / fusion{t("settings.globalGeneral.cLIBySpawning", " CLI by spawning")}{" "}
- <bin> --version{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. ")}
+ <bin> --version{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. ")}
{t("settings.globalGeneral.updates", "Updates")}
diff --git a/packages/dashboard/app/components/settings/sections/MergeSection.tsx b/packages/dashboard/app/components/settings/sections/MergeSection.tsx
index 44e30a3040..e558b846d4 100644
--- a/packages/dashboard/app/components/settings/sections/MergeSection.tsx
+++ b/packages/dashboard/app/components/settings/sections/MergeSection.tsx
@@ -129,7 +129,7 @@ export function MergeSection({ scopeBanner, form, setForm, integrationBranchOpti
{t("settings.merge.moreDetails", "More details")}
- {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. ")}{t("settings.merge.theLegacyMergeSettingsBelowDoNotApply", "The legacy merge settings below do not apply while AI merge is on.")}
+ {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. ")}{t("settings.merge.theLegacyMergeSettingsBelowDoNotApply", "The legacy merge settings below do not apply while AI merge is on.")}
setForm((f) => ({ ...f, merger: { ...(f.merger ?? {}), maxReviewPasses: e.target.value === "" ? undefined : Number(e.target.value) } }))}/>
- {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.")}
+ {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.")}
{form.mergeStrategy !== "pull-request" && (form.merger?.mode ?? "ai") !== "ai" && (<>
@@ -230,7 +230,7 @@ export function MergeSection({ scopeBanner, form, setForm, integrationBranchOpti
{t("settings.merge.moreDetails", "More details")}
- {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 ")}**Direct Merge Commit Strategy:** auto|always-squash|always-rebase.
+ {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 ")}**Direct Merge Commit Strategy:** auto|always-squash|always-rebase.
@@ -245,7 +245,7 @@ export function MergeSection({ scopeBanner, form, setForm, integrationBranchOpti
{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. ")}
{(form.mergeIntegrationWorktree ?? "reuse-task-worktree") !== "reuse-task-worktree" && (
- {t("settings.merge.legacyIntegrationBranchMode", "Legacy integration-branch mode.")}{" "}{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). ")}
)}
+ {t("settings.merge.legacyIntegrationBranchMode", "Legacy integration-branch mode.")}{" "}{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). ")})}
@@ -370,7 +370,7 @@ export function MergeSection({ scopeBanner, form, setForm, integrationBranchOpti
- {t("settings.merge.controlsThePostMergeAuditGate", " Controls the post-merge audit gate. ")}{t("settings.merge.warn", "Warn")}{t("settings.merge.defaultLogsFindingsButAutoCompletesTheMerge", " (default) logs findings but auto-completes the merge. ")}{t("settings.merge.block", "Block")}{t("settings.merge.isTheStricterOptInModeThatRefuses", " is the stricter opt-in mode that refuses to auto-complete merges with duplicate-subject or touched-file overlap risks. ")}{t("settings.merge.off", "Off")}{t("settings.merge.skipsTheAuditEntirelySwitchingToOffIs", " skips the audit entirely. Switching to Off is recommended only if you trust your branches don't silently drop edits. ")}
+ {t("settings.merge.controlsThePostMergeAuditGate", " Controls the post-merge audit gate. ")}{t("settings.merge.warn", "Warn")}{t("settings.merge.defaultLogsFindingsButAutoCompletesTheMerge", " (default) logs findings but auto-completes the merge. ")}{t("settings.merge.block", "Block")}{t("settings.merge.isTheStricterOptInModeThatRefuses", " is the stricter opt-in mode that refuses to auto-complete merges with duplicate-subject or touched-file overlap risks. ")}{t("settings.merge.off", "Off")}{t("settings.merge.skipsTheAuditEntirelySwitchingToOffIs", " skips the audit entirely. Switching to Off is recommended only if you trust your branches don't silently drop edits. ")}
>)}
diff --git a/packages/dashboard/app/components/settings/sections/SchedulingSection.tsx b/packages/dashboard/app/components/settings/sections/SchedulingSection.tsx
index 3b2e69e160..df2e859ad9 100644
--- a/packages/dashboard/app/components/settings/sections/SchedulingSection.tsx
+++ b/packages/dashboard/app/components/settings/sections/SchedulingSection.tsx
@@ -82,7 +82,7 @@ export function SchedulingSection({ scopeBanner, form, setForm, globalMaxConcurr
const num = Number(val);
setForm((f) => ({ ...f, taskStuckTimeoutMs: val && num > 0 ? num * 60000 : undefined }));
}}/>
- {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.")}
+ {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.")}
diff --git a/packages/dashboard/app/components/settings/sections/WorktreesSection.tsx b/packages/dashboard/app/components/settings/sections/WorktreesSection.tsx
index d1778f8e67..e8509a2cee 100644
--- a/packages/dashboard/app/components/settings/sections/WorktreesSection.tsx
+++ b/packages/dashboard/app/components/settings/sections/WorktreesSection.tsx
@@ -97,7 +97,7 @@ export function WorktreesSection({ scopeBanner, form, setForm, gitRemotes, workt
onFailure: f.worktrunk?.onFailure ?? "fail",
},
}))}/>{t("settings.worktrees.enableWorktrunkIntegration", " Enable worktrunk integration ")}
- {t("settings.worktrees.disabledByDefaultOptInWhenEnabledFusion", " Disabled by default (opt-in). When enabled, Fusion shells out to ")}worktrunk{t("settings.worktrees.forWorktreeCreateSyncPruneAndRemoveOperations", " for worktree create, sync, prune, and remove operations and follows worktrunk's directory layout. ")}
+ {t("settings.worktrees.disabledByDefaultOptInWhenEnabledFusion", " Disabled by default (opt-in). When enabled, Fusion shells out to ")}worktrunk{t("settings.worktrees.forWorktreeCreateSyncPruneAndRemoveOperations", " for worktree create, sync, prune, and remove operations and follows worktrunk's directory layout. ")}
{!worktrunkInstallVerified && form.worktrunk?.enabled !== true && ({t("settings.worktrees.installTheWorktrunkBinaryBelowToEnableThis", "Install the worktrunk binary below to enable this integration.")})}
@@ -148,7 +148,7 @@ export function WorktreesSection({ scopeBanner, form, setForm, gitRemotes, workt
- fail{t("settings.worktrees.stopsOnWorktrunkErrorsForExplicitOperatorRecovery", " stops on worktrunk errors for explicit operator recovery; ")}fallback-native{t("settings.worktrees.keepsProgressMovingBySwitchingToFusionApos", " keeps progress moving by switching to Fusion's built-in worktree backend. ")}
+ fail{t("settings.worktrees.stopsOnWorktrunkErrorsForExplicitOperatorRecovery", " stops on worktrunk errors for explicit operator recovery; ")}fallback-native{t("settings.worktrees.keepsProgressMovingBySwitchingToFusionApos", " keeps progress moving by switching to Fusion's built-in worktree backend. ")}