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
|
||||
});
|
||||
|
||||
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();
|
||||
|
||||
|
||||
@@ -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
|
||||
* 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> {
|
||||
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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 <AgentAvatar agent={avatarAgent} className="task-chat-avatar" />;
|
||||
/*
|
||||
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 (
|
||||
<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 {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
Reference in New Issue
Block a user