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:
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 }[] {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
Reference in New Issue
Block a user