From a147a983d58fabd8b87187002ac8528428633794 Mon Sep 17 00:00:00 2001 From: gsxdsm Date: Tue, 23 Jun 2026 00:31:58 -0700 Subject: [PATCH] fix(FN-6950): protect global settings and task chat icons --- .changeset/global-settings-task-chat-icons.md | 3 ++ .../src/__tests__/global-settings.test.ts | 13 +++++++ packages/core/src/global-settings.ts | 20 ++++++++++- .../dashboard/app/components/TaskChatTab.css | 8 ++--- .../dashboard/app/components/TaskChatTab.tsx | 34 ++++++------------- .../components/__tests__/TaskChatTab.test.tsx | 1 + 6 files changed, 51 insertions(+), 28 deletions(-) create mode 100644 .changeset/global-settings-task-chat-icons.md diff --git a/.changeset/global-settings-task-chat-icons.md b/.changeset/global-settings-task-chat-icons.md new file mode 100644 index 0000000000..450819b392 --- /dev/null +++ b/.changeset/global-settings-task-chat-icons.md @@ -0,0 +1,3 @@ +"@runfusion/fusion": patch + +Prevent global settings updates from overwriting an existing unreadable settings file with defaults, and use provider/CPU icons in task chat agent headers. diff --git a/packages/core/src/__tests__/global-settings.test.ts b/packages/core/src/__tests__/global-settings.test.ts index 53637992af..a18cc5b5ea 100644 --- a/packages/core/src/__tests__/global-settings.test.ts +++ b/packages/core/src/__tests__/global-settings.test.ts @@ -219,6 +219,19 @@ describe("GlobalSettingsStore", () => { expect(settings.themeMode).toBe("dark"); // preserved default }); + it("does not overwrite an existing invalid settings file with defaults", async () => { + await mkdir(dir, { recursive: true }); + const settingsPath = join(dir, "settings.json"); + const invalidContents = '{"themeMode":"light",'; + await writeFile(settingsPath, invalidContents); + + await expect(store.updateSettings({ colorTheme: "shadcn-gray" })).rejects.toThrow( + /Refusing to update global settings/, + ); + + await expect(readFile(settingsPath, "utf-8")).resolves.toBe(invalidContents); + }); + it("round-trips cliOnboardingCompletedAt without changing setupComplete", async () => { await store.init(); diff --git a/packages/core/src/global-settings.ts b/packages/core/src/global-settings.ts index ee8197f578..02a1474c14 100644 --- a/packages/core/src/global-settings.ts +++ b/packages/core/src/global-settings.ts @@ -147,6 +147,24 @@ export class GlobalSettingsStore { } } + private async readRawForUpdate(): Promise> { + if (!existsSync(this.settingsPath)) { + return {}; + } + + try { + const raw = await readFile(this.settingsPath, "utf-8"); + return JSON.parse(raw) as Record; + } catch (error) { + /* + FNXC:SettingsPersistence 2026-06-23-00:37: + Existing global settings must never be overwritten with defaults because a read failed. Fail closed on update so a corrupt, partially-written, or temporarily unreadable ~/.fusion/settings.json can be inspected or recovered instead of being replaced by DEFAULT_GLOBAL_SETTINGS plus the new patch. + */ + const message = error instanceof Error ? error.message : String(error); + throw new Error(`Refusing to update global settings because ${this.settingsPath} could not be read as valid JSON: ${message}`); + } + } + /** * Read global settings. Returns cached value if available, otherwise reads * from disk and caches the result. This avoids repeated filesystem reads for @@ -181,7 +199,7 @@ export class GlobalSettingsStore { */ async updateSettings(patch: Partial & Record): Promise { return this.withLock(async () => { - const raw = await this.readRaw(); + const raw = await this.readRawForUpdate(); // Apply null-as-delete semantics: null means "remove this field" // Merge order: defaults → raw (disk) → patch diff --git a/packages/dashboard/app/components/TaskChatTab.css b/packages/dashboard/app/components/TaskChatTab.css index 5a049e14db..746a4a844c 100644 --- a/packages/dashboard/app/components/TaskChatTab.css +++ b/packages/dashboard/app/components/TaskChatTab.css @@ -124,10 +124,6 @@ FN-6425 requires the chat expand control to stay inside the chat view as an icon color: var(--text-muted); } -.task-chat-avatar { - flex: 0 0 auto; -} - .task-chat-provider-icon { display: inline-flex; align-items: center; @@ -140,6 +136,10 @@ FN-6425 requires the chat expand control to stay inside the chat view as an icon color: var(--text-muted); } +.task-chat-provider-icon--fallback { + color: var(--text-muted); +} + .task-chat-provider-icon .provider-icon { display: inline-flex; align-items: center; diff --git a/packages/dashboard/app/components/TaskChatTab.tsx b/packages/dashboard/app/components/TaskChatTab.tsx index 7d6ad1c702..31acdf6cb7 100644 --- a/packages/dashboard/app/components/TaskChatTab.tsx +++ b/packages/dashboard/app/components/TaskChatTab.tsx @@ -2,7 +2,7 @@ import type { AgentLogEntry, AgentRole, SteeringComment, Task, TaskDetail } from import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react"; import ReactMarkdown from "react-markdown"; import remarkGfm from "remark-gfm"; -import { ChevronDown, Loader2, Maximize2, Minimize2, Send } from "lucide-react"; +import { ChevronDown, Cpu, Loader2, Maximize2, Minimize2, Send } from "lucide-react"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; import { addSteeringComment, refineTask } from "../api"; @@ -11,7 +11,6 @@ import type { ToastType } from "../hooks/useToast"; import { getErrorMessage } from "@fusion/core"; import { linkifyFilePaths } from "../utils/filePathLinkify"; import { formatRelativeTimeAgo } from "../utils/relativeTimeAgo"; -import { AgentAvatar } from "./AgentAvatar"; import { ProviderIcon } from "./ProviderIcon"; import { clampChatInputHeight, resolveChatInputOverflowY } from "../utils/chatInputAutosize"; import { markdownComponents } from "./AgentLogViewer"; @@ -88,21 +87,6 @@ function getRoleLabel(role: AgentLogRole, t: TFunction<"app">): string { } } -function getRoleIcon(role: AgentLogRole): string | undefined { - switch (role) { - case "triage": - return "🧭"; - case "executor": - return "⚙️"; - case "reviewer": - return "🔎"; - case "merger": - return "🔀"; - default: - return undefined; - } -} - function parseModelMarker(entry: AgentLogEntry): TaskChatModelInfo | null { if (entry.type !== "text") return null; const match = entry.text.match(/^(?:Triage|Executor|Reviewer) using model: (.+?)\/(.+)$/); @@ -156,12 +140,16 @@ function TaskChatAgentIcon({ label, modelInfo, role }: { label: string; modelInf ); } - const avatarAgent = { - id: role ?? "agent", - name: label, - icon: getRoleIcon(role), - }; - return ; + /* + FNXC:TaskDetailChat 2026-06-23-00:42: + Task chat role headers should use provider logos whenever the role's model provider is known, and a neutral CPU fallback when it is not. Avoid role clip-art avatars so executor/reviewer/merger rows read as professional model execution blocks rather than cartoon agent identities. + */ + const title = `${label}: model provider unknown`; + return ( + + + ); } function getEntryKey(entry: AgentLogEntry, index: number): string { diff --git a/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx b/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx index c266e4aea5..f4fa1b6665 100644 --- a/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx +++ b/packages/dashboard/app/components/__tests__/TaskChatTab.test.tsx @@ -423,6 +423,7 @@ describe("TaskChatTab", () => { expect(screen.getByText("Merger")).toBeTruthy(); expect(screen.getByText("Agent")).toBeTruthy(); expect(screen.getByText("legacy output")).toBeTruthy(); + expect(screen.getAllByLabelText(/model provider unknown/)).toHaveLength(5); }); it("renders provider icons for task chat roles from task model overrides", () => {