feat(FN-1856): rename chat agent to Fusion and add model indicator tag

- Rename chat agent from generic label to 'Fusion' in ChatView header
- Add model indicator tag displaying the current AI model name
- Fix GPT model name formatting to use proper display name
- Add CSS styling for the model tag pill component
- Add changeset for minor version bump of @gsxdsm/fusion
- Update and add ChatView tests for Fusion name and model tag
This commit is contained in:
Fusion
2026-04-15 22:35:35 -07:00
committed by gsxdsm
parent e5db683c42
commit 93f8bb0cef
4 changed files with 292 additions and 7 deletions

View File

@@ -35,6 +35,72 @@ function formatRelativeTime(dateStr: string): string {
return date.toLocaleDateString();
}
/**
* Format a model provider and ID into a human-readable tag.
* Returns null if provider or modelId is missing/empty.
*/
function formatModelTag(provider?: string | null, modelId?: string | null): string | null {
if (!provider || !modelId) return null;
// Handle known provider/model patterns
const normalizedModel = modelId.toLowerCase();
// Claude models: "claude-sonnet-4-5" -> "Claude Sonnet 4.5"
if (normalizedModel.includes("claude")) {
let formatted = modelId
.replace(/^claude[- ]/i, "Claude ")
.replace(/sonnet[- ](\d+)[- ](\d+)/i, "Sonnet $1.$2")
.replace(/sonnet[- ](\d+)/i, "Sonnet $1")
.replace(/haiku[- ](\d+)/i, "Haiku $1")
.replace(/opus[- ](\d+)/i, "Opus $1")
.replace(/sonnet/i, "Sonnet")
.replace(/haiku/i, "Haiku")
.replace(/opus/i, "Opus")
.replace(/-/g, " ")
.trim();
// Fix double spaces
formatted = formatted.replace(/\s+/g, " ");
return formatted.length > 30 ? formatted.slice(0, 30) + "…" : formatted;
}
// OpenAI models: "gpt-4o" -> "GPT-4o", "gpt-4-turbo" -> "GPT-4 Turbo"
if (normalizedModel.includes("gpt") || normalizedModel.includes("openai")) {
// Format GPT model names: handle special cases first, then capitalize
// Note: We don't replace hyphens globally because special cases preserve them
const formatted = modelId
.replace(/^gpt-4-turbo$/i, "GPT-4 Turbo")
.replace(/^gpt-4o-mini$/i, "GPT-4o Mini")
.replace(/^gpt-4o$/i, "GPT-4o")
.replace(/^gpt-4$/i, "GPT-4")
.replace(/^gpt-o1-preview$/i, "GPT-o1 Preview")
.replace(/^gpt-o1-mini$/i, "GPT-o1 Mini")
.replace(/^gpt-o1$/i, "GPT-o1")
.replace(/^gpt/i, "GPT") // Capitalize remaining GPT prefix
.trim();
return formatted.length > 30 ? formatted.slice(0, 30) + "…" : formatted;
}
// Gemini models: "gemini-2.5-pro" -> "Gemini 2.5 Pro"
if (normalizedModel.includes("gemini")) {
let formatted = modelId
.replace(/^gemini[- ]/i, "Gemini ")
.replace(/pro[- ](\d+)[- ](\d+)/i, "Pro $1.$2")
.replace(/pro[- ](\d+)/i, "Pro $1")
.replace(/-/g, " ")
.replace(/\s+/g, " ")
.trim();
return formatted.length > 30 ? formatted.slice(0, 30) + "…" : formatted;
}
// Generic fallback: capitalize first letter, replace hyphens with spaces
let formatted = modelId
.replace(/-/g, " ")
.replace(/^\w/, (c) => c.toUpperCase())
.replace(/\s+/g, " ")
.trim();
return formatted.length > 30 ? formatted.slice(0, 30) + "…" : formatted;
}
/**
* Constant agent ID for the built-in kb agent.
* The chat system always uses createKbAgent with CHAT_SYSTEM_PROMPT regardless
@@ -350,7 +416,7 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
{session.lastMessagePreview || "No messages"}
</div>
<div className="chat-session-meta">
<span>{agentsMap.get(session.agentId)?.name || (session.agentId === KB_AGENT_ID ? "AI Assistant" : session.agentId.slice(0, 30))}</span>
<span>{agentsMap.get(session.agentId)?.name || (session.agentId === KB_AGENT_ID ? "Fusion" : session.agentId.slice(0, 30))}</span>
<span>{session.updatedAt ? formatRelativeTime(session.updatedAt) : ""}</span>
</div>
</div>
@@ -420,8 +486,14 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
)}
<Bot size={16} />
<span className="chat-thread-header-title">
{activeSession?.title || agentsMap.get(activeSession?.agentId ?? "")?.name || activeSession?.agentId || "Chat"}
{activeSession?.agentId === KB_AGENT_ID
? "Fusion"
: activeSession?.title || agentsMap.get(activeSession?.agentId ?? "")?.name || activeSession?.agentId || "Chat"}
</span>
{activeSession && (() => {
const modelTag = formatModelTag(activeSession.modelProvider, activeSession.modelId);
return modelTag ? <span className="chat-model-tag">{modelTag}</span> : null;
})()}
</div>
{/* Messages */}
@@ -445,7 +517,11 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
{message.role === "assistant" && (
<div className="chat-message-avatar">
<Bot size={14} />
<span>Assistant</span>
<span>Fusion</span>
{activeSession && (() => {
const modelTag = formatModelTag(activeSession.modelProvider, activeSession.modelId);
return modelTag ? <span className="chat-model-tag">{modelTag}</span> : null;
})()}
</div>
)}
<div className="chat-message-content">{message.content}</div>
@@ -462,7 +538,11 @@ export function ChatView({ projectId, addToast }: ChatViewProps) {
<div className="chat-message chat-message--assistant chat-message--streaming">
<div className="chat-message-avatar">
<Bot size={14} />
<span>Assistant</span>
<span>Fusion</span>
{activeSession && (() => {
const modelTag = formatModelTag(activeSession.modelProvider, activeSession.modelId);
return modelTag ? <span className="chat-model-tag">{modelTag}</span> : null;
})()}
</div>
<div className="chat-message-content">{streamingText}</div>
{streamingThinking && (

View File

@@ -426,7 +426,7 @@ describe("ChatView", () => {
expect(within(dialog!).getByText("Delete Conversation?")).toBeInTheDocument();
});
it("shows AI Assistant label for kb agent sessions in sidebar", () => {
it("shows Fusion label for kb agent sessions in sidebar", () => {
setupMockChat({
sessions: [{ id: "session-001", agentId: "__kb_agent__", status: "active", title: "My Chat", updatedAt: "2026-04-08T00:00:00.000Z" }],
filteredSessions: [{ id: "session-001", agentId: "__kb_agent__", status: "active", title: "My Chat", updatedAt: "2026-04-08T00:00:00.000Z" }],
@@ -435,8 +435,8 @@ describe("ChatView", () => {
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const sessionItem = screen.getByTestId("chat-session-session-001");
// Should show "AI Assistant" instead of "__kb_agent__"
expect(within(sessionItem).getByText("AI Assistant")).toBeInTheDocument();
// Should show "Fusion" instead of "__kb_agent__"
expect(within(sessionItem).getByText("Fusion")).toBeInTheDocument();
});
it("shows agent ID for non-kb agent sessions in sidebar", () => {
@@ -451,6 +451,195 @@ describe("ChatView", () => {
// Should show the agent ID (truncated to 30 chars)
expect(within(sessionItem).getByText("my-custom-agent")).toBeInTheDocument();
});
it("shows model tag in thread header when session has model", () => {
setupMockChat({
activeSession: {
id: "session-001",
agentId: "__kb_agent__",
status: "active",
title: "Test Chat",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
updatedAt: "2026-04-08T00:00:00.000Z",
},
messages: [
{ id: "msg-001", sessionId: "session-001", role: "user", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" },
{ id: "msg-002", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:01:00.000Z" },
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const modelTag = document.querySelector(".chat-model-tag");
expect(modelTag).toBeInTheDocument();
expect(modelTag?.textContent).toContain("Claude");
});
it("does not show model tag when session has no model", () => {
setupMockChat({
activeSession: {
id: "session-001",
agentId: "__kb_agent__",
status: "active",
title: "Test Chat",
updatedAt: "2026-04-08T00:00:00.000Z",
},
messages: [
{ id: "msg-001", sessionId: "session-001", role: "user", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" },
{ id: "msg-002", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:01:00.000Z" },
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const modelTag = document.querySelector(".chat-model-tag");
expect(modelTag).not.toBeInTheDocument();
});
it("shows model tag in message avatar when session has model", () => {
setupMockChat({
activeSession: {
id: "session-001",
agentId: "__kb_agent__",
status: "active",
title: "Test Chat",
modelProvider: "openai",
modelId: "gpt-4o",
updatedAt: "2026-04-08T00:00:00.000Z",
},
messages: [
{ id: "msg-001", sessionId: "session-001", role: "user", content: "Hello", createdAt: "2026-04-08T00:00:00.000Z" },
{ id: "msg-002", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:01:00.000Z" },
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
// Find the avatar with "Fusion" text
const avatars = document.querySelectorAll(".chat-message-avatar");
expect(avatars.length).toBeGreaterThan(0);
// Check that one avatar has the model tag
const avatarWithModelTag = Array.from(avatars).find((avatar) =>
avatar.querySelector(".chat-model-tag"),
);
expect(avatarWithModelTag).toBeTruthy();
expect(avatarWithModelTag?.querySelector(".chat-model-tag")?.textContent).toContain("GPT");
});
});
describe("formatModelTag helper function", () => {
// Import the function for testing - we'll test it via the UI behavior instead
// The function is not exported, so we test it indirectly through the component
it("formats claude-sonnet-4-5 model ID correctly", () => {
setupMockChat({
activeSession: {
id: "session-001",
agentId: "__kb_agent__",
status: "active",
title: "Test",
modelProvider: "anthropic",
modelId: "claude-sonnet-4-5",
updatedAt: "2026-04-08T00:00:00.000Z",
},
messages: [
{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" },
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const modelTag = document.querySelector(".chat-model-tag");
expect(modelTag?.textContent).toContain("Claude Sonnet");
});
it("formats gpt-4o model ID correctly", () => {
setupMockChat({
activeSession: {
id: "session-001",
agentId: "__kb_agent__",
status: "active",
title: "Test",
modelProvider: "openai",
modelId: "gpt-4o",
updatedAt: "2026-04-08T00:00:00.000Z",
},
messages: [
{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" },
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const modelTag = document.querySelector(".chat-model-tag");
expect(modelTag?.textContent).toContain("GPT-4o");
});
it("formats gemini-2.5-pro model ID correctly", () => {
setupMockChat({
activeSession: {
id: "session-001",
agentId: "__kb_agent__",
status: "active",
title: "Test",
modelProvider: "google",
modelId: "gemini-2.5-pro",
updatedAt: "2026-04-08T00:00:00.000Z",
},
messages: [
{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" },
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const modelTag = document.querySelector(".chat-model-tag");
expect(modelTag?.textContent).toContain("Gemini");
});
it("returns null when modelId is missing", () => {
setupMockChat({
activeSession: {
id: "session-001",
agentId: "__kb_agent__",
status: "active",
title: "Test",
modelProvider: "anthropic",
updatedAt: "2026-04-08T00:00:00.000Z",
},
messages: [
{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" },
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const modelTag = document.querySelector(".chat-model-tag");
expect(modelTag).not.toBeInTheDocument();
});
it("returns null when provider is missing", () => {
setupMockChat({
activeSession: {
id: "session-001",
agentId: "__kb_agent__",
status: "active",
title: "Test",
modelId: "claude-sonnet-4-5",
updatedAt: "2026-04-08T00:00:00.000Z",
},
messages: [
{ id: "msg-001", sessionId: "session-001", role: "assistant", content: "Hi!", createdAt: "2026-04-08T00:00:00.000Z" },
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const modelTag = document.querySelector(".chat-model-tag");
expect(modelTag).not.toBeInTheDocument();
});
});
describe("ChatView CSS — nested flexbox scrolling fix", () => {

View File

@@ -28424,6 +28424,17 @@ html .column.drag-over * {
color: var(--text-secondary);
}
.chat-model-tag {
font-size: 11px;
padding: 1px 6px;
border-radius: 4px;
background: var(--surface-bg, rgba(255, 255, 255, 0.08));
color: var(--text-secondary);
margin-left: 6px;
white-space: nowrap;
vertical-align: middle;
}
.chat-message-content {
white-space: pre-wrap;
}