fix(FN-6950): protect global settings and task chat icons
This commit is contained in:
3
.changeset/global-settings-task-chat-icons.md
Normal file
3
.changeset/global-settings-task-chat-icons.md
Normal file
@@ -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.
|
||||||
@@ -219,6 +219,19 @@ describe("GlobalSettingsStore", () => {
|
|||||||
expect(settings.themeMode).toBe("dark"); // preserved default
|
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 () => {
|
it("round-trips cliOnboardingCompletedAt without changing setupComplete", async () => {
|
||||||
await store.init();
|
await store.init();
|
||||||
|
|
||||||
|
|||||||
@@ -147,6 +147,24 @@ export class GlobalSettingsStore {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async readRawForUpdate(): Promise<Record<string, unknown>> {
|
||||||
|
if (!existsSync(this.settingsPath)) {
|
||||||
|
return {};
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const raw = await readFile(this.settingsPath, "utf-8");
|
||||||
|
return JSON.parse(raw) as Record<string, unknown>;
|
||||||
|
} 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
|
* Read global settings. Returns cached value if available, otherwise reads
|
||||||
* from disk and caches the result. This avoids repeated filesystem reads for
|
* from disk and caches the result. This avoids repeated filesystem reads for
|
||||||
@@ -181,7 +199,7 @@ export class GlobalSettingsStore {
|
|||||||
*/
|
*/
|
||||||
async updateSettings(patch: Partial<GlobalSettings> & Record<string, unknown>): Promise<GlobalSettings> {
|
async updateSettings(patch: Partial<GlobalSettings> & Record<string, unknown>): Promise<GlobalSettings> {
|
||||||
return this.withLock(async () => {
|
return this.withLock(async () => {
|
||||||
const raw = await this.readRaw();
|
const raw = await this.readRawForUpdate();
|
||||||
|
|
||||||
// Apply null-as-delete semantics: null means "remove this field"
|
// Apply null-as-delete semantics: null means "remove this field"
|
||||||
// Merge order: defaults → raw (disk) → patch
|
// Merge order: defaults → raw (disk) → patch
|
||||||
|
|||||||
@@ -124,10 +124,6 @@ FN-6425 requires the chat expand control to stay inside the chat view as an icon
|
|||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
.task-chat-avatar {
|
|
||||||
flex: 0 0 auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.task-chat-provider-icon {
|
.task-chat-provider-icon {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
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);
|
color: var(--text-muted);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.task-chat-provider-icon--fallback {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
.task-chat-provider-icon .provider-icon {
|
.task-chat-provider-icon .provider-icon {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import type { AgentLogEntry, AgentRole, SteeringComment, Task, TaskDetail } from
|
|||||||
import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
|
import React, { useCallback, useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
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 { useTranslation } from "react-i18next";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import { addSteeringComment, refineTask } from "../api";
|
import { addSteeringComment, refineTask } from "../api";
|
||||||
@@ -11,7 +11,6 @@ import type { ToastType } from "../hooks/useToast";
|
|||||||
import { getErrorMessage } from "@fusion/core";
|
import { getErrorMessage } from "@fusion/core";
|
||||||
import { linkifyFilePaths } from "../utils/filePathLinkify";
|
import { linkifyFilePaths } from "../utils/filePathLinkify";
|
||||||
import { formatRelativeTimeAgo } from "../utils/relativeTimeAgo";
|
import { formatRelativeTimeAgo } from "../utils/relativeTimeAgo";
|
||||||
import { AgentAvatar } from "./AgentAvatar";
|
|
||||||
import { ProviderIcon } from "./ProviderIcon";
|
import { ProviderIcon } from "./ProviderIcon";
|
||||||
import { clampChatInputHeight, resolveChatInputOverflowY } from "../utils/chatInputAutosize";
|
import { clampChatInputHeight, resolveChatInputOverflowY } from "../utils/chatInputAutosize";
|
||||||
import { markdownComponents } from "./AgentLogViewer";
|
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 {
|
function parseModelMarker(entry: AgentLogEntry): TaskChatModelInfo | null {
|
||||||
if (entry.type !== "text") return null;
|
if (entry.type !== "text") return null;
|
||||||
const match = entry.text.match(/^(?:Triage|Executor|Reviewer) using model: (.+?)\/(.+)$/);
|
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",
|
FNXC:TaskDetailChat 2026-06-23-00:42:
|
||||||
name: label,
|
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.
|
||||||
icon: getRoleIcon(role),
|
*/
|
||||||
};
|
const title = `${label}: model provider unknown`;
|
||||||
return <AgentAvatar agent={avatarAgent} className="task-chat-avatar" />;
|
return (
|
||||||
|
<span className="task-chat-provider-icon task-chat-provider-icon--fallback" title={title} aria-label={title}>
|
||||||
|
<Cpu size={18} aria-hidden="true" />
|
||||||
|
</span>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getEntryKey(entry: AgentLogEntry, index: number): string {
|
function getEntryKey(entry: AgentLogEntry, index: number): string {
|
||||||
|
|||||||
@@ -423,6 +423,7 @@ describe("TaskChatTab", () => {
|
|||||||
expect(screen.getByText("Merger")).toBeTruthy();
|
expect(screen.getByText("Merger")).toBeTruthy();
|
||||||
expect(screen.getByText("Agent")).toBeTruthy();
|
expect(screen.getByText("Agent")).toBeTruthy();
|
||||||
expect(screen.getByText("legacy output")).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", () => {
|
it("renders provider icons for task chat roles from task model overrides", () => {
|
||||||
|
|||||||
Reference in New Issue
Block a user