feat(FN-3020): merge fusion/fn-3020

Commits merged:
- test(FN-3020): complete Step 2 — lock quick-chat mobile tool-call scoping
- feat(FN-3020): complete Step 1 — scope quick-chat mobile tool-call row styles
- feat(FN-3020): complete Step 4 — document compact quick-chat tool-call rows
- fix(FN-3020): restore custom provider type compatibility for typecheck
- test(FN-3020): complete Step 2 — add quick-chat mobile tool-call regressions
- feat(FN-3020): complete Step 1 — keep compact tool-call rows single-line

Files changed:
docs/dashboard-guide.md                            |  1 +
 .../quick-chat-tool-calls-mobile-layout.test.ts    | 46 ++++++++++++++++++++++
 .../app/components/CustomProvidersSection.tsx      | 25 +++++++++++-
 packages/dashboard/app/components/QuickChatFAB.css | 37 +++++++++++++----
 packages/dashboard/app/components/QuickChatFAB.tsx |  7 ++--
 .../app/components/__tests__/QuickChatFAB.test.tsx | 18 +++++++--
 6 files changed, 119 insertions(+), 15 deletions(-)

Fusion-Task-Id: FN-3020
This commit is contained in:
Fusion
2026-04-30 00:08:16 -07:00
committed by gsxdsm
parent 18d97ca028
commit 493ca91d63
6 changed files with 119 additions and 15 deletions

View File

@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { loadAllAppCss } from "../test/cssFixture";
function extractMobileMediaBlocks(content: string): string {
const blocks: string[] = [];
const regex = /@media\s*\(\s*max-width:\s*768px\s*\)\s*\{/g;
let match: RegExpExecArray | null;
while ((match = regex.exec(content)) !== null) {
const startIdx = match.index + match[0].length;
let braceCount = 1;
let endIdx = startIdx;
while (braceCount > 0 && endIdx < content.length) {
if (content[endIdx] === "{") braceCount += 1;
if (content[endIdx] === "}") braceCount -= 1;
endIdx += 1;
}
if (braceCount === 0) {
blocks.push(content.slice(startIdx, endIdx - 1));
}
}
return blocks.join("\n");
}
describe("quick-chat tool-call mobile layout css", () => {
const css = loadAllAppCss();
const mobileCss = extractMobileMediaBlocks(css);
it("keeps grouped quick-chat tool-call summary on a single horizontal row in mobile media blocks", () => {
const summaryRules = [...mobileCss.matchAll(/\.quick-chat-panel\s+\.chat-tool-calls-group-summary\s*\{[^}]*\}/g)].map((m) => m[0]);
expect(summaryRules.length).toBeGreaterThan(0);
expect(summaryRules.some((rule) => /flex-wrap:\s*nowrap/.test(rule))).toBe(true);
expect(summaryRules.some((rule) => /flex-direction:\s*row/.test(rule))).toBe(true);
expect(summaryRules.some((rule) => /align-items:\s*center/.test(rule))).toBe(true);
});
it("keeps quick-chat scoped text tokens non-wrapping so ChatView mobile stacking cannot override them", () => {
const scopedNoWrapBlock = /\.quick-chat-panel\s+\.chat-tool-calls-names,\s*\n\s*\.quick-chat-panel\s+\.chat-tool-call-name,\s*\n\s*\.quick-chat-panel\s+\.chat-tool-call-status-text,\s*\n\s*\.quick-chat-panel\s+\.chat-tool-calls-group-status\s*\{[^}]*white-space:\s*nowrap[^}]*\}/m;
expect(scopedNoWrapBlock.test(mobileCss)).toBe(true);
const scopedSummaryRules = [...mobileCss.matchAll(/\.quick-chat-panel\s+\.chat-tool-calls-group-summary\s*\{[^}]*\}/g)].map((m) => m[0]);
expect(scopedSummaryRules.length).toBeGreaterThan(0);
expect(scopedSummaryRules.every((rule) => !/flex-direction:\s*column/.test(rule))).toBe(true);
});
});

View File

@@ -15,8 +15,29 @@ type ProviderApiType = CustomProvider["apiType"];
const API_TYPES: ProviderApiType[] = ["openai-compatible", "anthropic-compatible"];
function normalizeProviders(result: Awaited<ReturnType<typeof fetchCustomProviders>>): CustomProvider[] {
if (Array.isArray(result)) return result;
return Array.isArray(result.providers) ? result.providers : [];
const legacyProviders = Array.isArray(result)
? result
: Array.isArray((result as { providers?: unknown }).providers)
? (result as { providers: (typeof result)[number][] }).providers
: [];
return legacyProviders.map((provider) => {
if ("apiType" in provider) {
return provider as unknown as CustomProvider;
}
return {
id: provider.id,
name: provider.name ?? provider.id,
baseUrl: provider.baseUrl,
...(provider.apiKey ? { apiKey: provider.apiKey } : {}),
apiType: provider.api === "anthropic-messages" ? "anthropic-compatible" : "openai-compatible",
models: (provider.models ?? []).map((model) => ({
id: model.id,
name: model.name ?? model.id,
})),
} satisfies CustomProvider;
});
}
function parseModels(modelsInput: string): { id: string; name: string }[] {

View File

@@ -703,16 +703,25 @@
min-width: 0;
}
.chat-tool-calls-group-summary {
flex-direction: column;
align-items: flex-start;
.quick-chat-panel .chat-tool-calls-group-summary {
flex-wrap: nowrap;
flex-direction: row;
align-items: center;
}
.chat-tool-calls-group-status {
justify-content: flex-start;
.quick-chat-panel .chat-tool-calls-names,
.quick-chat-panel .chat-tool-call-name,
.quick-chat-panel .chat-tool-call-status-text,
.quick-chat-panel .chat-tool-calls-group-status {
white-space: nowrap;
}
.quick-chat-panel .chat-tool-calls-group-status {
justify-content: flex-end;
}
}
.chat-message-thinking-content {
font-size: 11px;
color: var(--text-tertiary);
@@ -774,6 +783,7 @@
border-radius: var(--radius-sm);
font-size: var(--space-md);
min-width: 0;
white-space: nowrap;
}
.chat-tool-calls-group-summary::marker {
@@ -789,13 +799,18 @@
box-shadow: var(--focus-ring-strong);
}
.chat-tool-calls-count {
white-space: nowrap;
flex-shrink: 0;
}
.chat-tool-calls-names {
color: var(--text-dim);
font-family: var(--font-mono, monospace);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
flex: 1 1 auto;
min-width: 0;
font-size: 0.6875rem;
}
@@ -805,6 +820,7 @@
font-size: 0.6875rem;
color: var(--text-dim);
flex-shrink: 0;
white-space: nowrap;
}
.chat-tool-calls-group > .chat-tool-call {
@@ -832,6 +848,7 @@
border-radius: var(--radius-sm);
font-size: var(--space-md);
min-width: 0;
white-space: nowrap;
}
.chat-tool-call summary::marker {
@@ -857,7 +874,11 @@
.chat-tool-call-name {
font-family: var(--font-mono, monospace);
flex-shrink: 0;
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-tool-call-preview {
@@ -874,6 +895,8 @@
margin-left: auto;
font-size: 0.6875rem;
text-transform: lowercase;
white-space: nowrap;
flex-shrink: 0;
}
.chat-tool-call-content {

View File

@@ -128,13 +128,14 @@ function renderToolCalls(toolCalls?: ToolCallInfo[], compact = false): ReactNode
const isError = toolCall.status === "completed" && toolCall.isError;
const argsSummary = formatToolArgsSummary(toolCall.args);
const resultSummary = formatToolResultSummary(toolCall.result);
const summaryPreview = isRunning
const baseSummaryPreview = isRunning
? argsSummary
: resultSummary
? `result: ${resultSummary}`
: argsSummary
? `args: ${argsSummary}`
: null;
const summaryPreview = compact ? null : baseSummaryPreview;
const statusLabel = isRunning ? "running" : isError ? "error" : "completed";
return (
@@ -145,7 +146,7 @@ function renderToolCalls(toolCalls?: ToolCallInfo[], compact = false): ReactNode
>
<summary>
<span className="chat-tool-call-status-dot" aria-hidden="true" />
<span className="chat-tool-call-name">{toolCall.toolName}</span>
<span className="chat-tool-call-name" title={toolCall.toolName}>{toolCall.toolName}</span>
{summaryPreview && <span className="chat-tool-call-preview" title={summaryPreview}>{summaryPreview}</span>}
<span className="chat-tool-call-status-text">{statusLabel}</span>
</summary>
@@ -200,7 +201,7 @@ function renderToolCalls(toolCalls?: ToolCallInfo[], compact = false): ReactNode
<details className={`chat-tool-calls-group${compact ? " chat-tool-calls-group--compact" : ""}`} data-testid="chat-tool-calls-group" open={hasRunning}>
<summary className="chat-tool-calls-group-summary">
<Wrench size={12} aria-hidden="true" />
<span>{toolCalls.length} tool calls</span>
<span className="chat-tool-calls-count">{toolCalls.length} tool calls</span>
<span className="chat-tool-calls-names" title={namesSummary}>{namesSummary}</span>
{statusSummary && <span className="chat-tool-calls-group-status">{statusSummary}</span>}
</summary>

View File

@@ -1064,7 +1064,7 @@ describe("QuickChatFAB", () => {
});
});
it("renders tool calls in quick chat messages", async () => {
it("renders compact tool calls in quick chat messages with single-row essentials", async () => {
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
setTimeout(() => {
handlers.onText?.("Used read tool");
@@ -1095,8 +1095,14 @@ describe("QuickChatFAB", () => {
expect(screen.getByText("Tool calls")).toBeInTheDocument();
});
const preview = document.querySelector(".chat-tool-call-preview");
expect(preview).toHaveTextContent("result: contents");
const toolCallSummary = document.querySelector(".chat-tool-call summary");
expect(toolCallSummary).toBeTruthy();
if (!toolCallSummary) return;
expect(within(toolCallSummary as HTMLElement).getByText("read")).toBeInTheDocument();
expect(within(toolCallSummary as HTMLElement).getByText("completed")).toBeInTheDocument();
expect((toolCallSummary as HTMLElement).querySelector(".chat-tool-call-status-dot")).toBeTruthy();
expect((toolCallSummary as HTMLElement).querySelector(".chat-tool-call-preview")).toBeNull();
});
it("renders multiple tool calls collapsed in quick chat", async () => {
@@ -1133,6 +1139,12 @@ describe("QuickChatFAB", () => {
const toolCallsContainer = document.querySelector(".chat-tool-calls") as HTMLElement | null;
expect(toolCallsContainer).toHaveClass("chat-tool-calls--compact");
expect(screen.getByText("read, grep")).toBeInTheDocument();
const groupSummary = document.querySelector(".chat-tool-calls-group-summary") as HTMLElement | null;
expect(groupSummary).toBeTruthy();
if (!groupSummary) return;
expect(within(groupSummary).getByText("2 tool calls")).toBeInTheDocument();
expect((groupSummary.querySelector(".chat-tool-calls-names") as HTMLElement | null)?.textContent).toContain("read, grep");
});
it("compact group class applied in quick chat", async () => {