feat(FN-2926): enhance grouped tool-call UX across dashboard and TUI
- Inline grouped tool-call rendering in chat surfaces and update summary logic for clearer aggregation - Refine chat, quick FAB, settings, and agents UI styles/behavior with matching test coverage updates - Extend dashboard TUI state/controller flow with status and shortcut improvements - Update engine and dashboard route handling related to agent runs, settings memory sync, and remote access adapters Fusion-Task-Id: FN-2926
This commit is contained in:
@@ -101,9 +101,9 @@ describe("Status color CSS custom properties", () => {
|
|||||||
expect(css).not.toMatch(/--surface-(subtle|muted|emphasis|hover-strong):\s*rgba\(/);
|
expect(css).not.toMatch(/--surface-(subtle|muted|emphasis|hover-strong):\s*rgba\(/);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("uses --surface-hover token references without fallbacks", () => {
|
it("uses --surface-hover token references with tokenized fallback (no raw rgba)", () => {
|
||||||
expect(css).toContain("var(--surface-hover)");
|
expect(css).toContain("var(--surface-hover)");
|
||||||
expect(css).not.toMatch(/var\(--surface-hover,\s*/);
|
expect(css).toContain("var(--surface-hover, color-mix(in srgb, var(--surface) 55%, transparent))");
|
||||||
expect(css).not.toMatch(/var\(--surface-hover,\s*rgba\(/);
|
expect(css).not.toMatch(/var\(--surface-hover,\s*rgba\(/);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -1,160 +0,0 @@
|
|||||||
import type { ReactNode } from "react";
|
|
||||||
import { Wrench } from "lucide-react";
|
|
||||||
|
|
||||||
export interface ChatToolCallInfo {
|
|
||||||
toolName: string;
|
|
||||||
args?: Record<string, unknown>;
|
|
||||||
result?: unknown;
|
|
||||||
status: "running" | "completed";
|
|
||||||
isError?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ChatToolCallsProps {
|
|
||||||
toolCalls?: ChatToolCallInfo[];
|
|
||||||
compact?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
function truncateValue(value: string, maxLength: number): string {
|
|
||||||
if (value.length <= maxLength) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
|
|
||||||
return `${value.slice(0, maxLength)}…`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatToolArgsSummary(args?: Record<string, unknown>): string | null {
|
|
||||||
if (!args) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const entries = Object.entries(args);
|
|
||||||
if (entries.length === 0) return null;
|
|
||||||
|
|
||||||
return entries
|
|
||||||
.map(([key, value]) => {
|
|
||||||
let stringValue = "";
|
|
||||||
if (typeof value === "string") {
|
|
||||||
stringValue = value;
|
|
||||||
} else {
|
|
||||||
try {
|
|
||||||
stringValue = JSON.stringify(value);
|
|
||||||
} catch {
|
|
||||||
stringValue = String(value);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return `${key}=${truncateValue(stringValue, 50)}`;
|
|
||||||
})
|
|
||||||
.join(", ");
|
|
||||||
}
|
|
||||||
|
|
||||||
function formatToolResultSummary(result: unknown): string | null {
|
|
||||||
if (result === undefined) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (typeof result === "string") {
|
|
||||||
return truncateValue(result, 200);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
return truncateValue(JSON.stringify(result), 200);
|
|
||||||
} catch {
|
|
||||||
return truncateValue(String(result), 200);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function renderToolCallItem(toolCall: ChatToolCallInfo, index: number): ReactNode {
|
|
||||||
const isRunning = toolCall.status === "running";
|
|
||||||
const isError = toolCall.status === "completed" && toolCall.isError;
|
|
||||||
const argsSummary = formatToolArgsSummary(toolCall.args);
|
|
||||||
const resultSummary = formatToolResultSummary(toolCall.result);
|
|
||||||
const summaryPreview = isRunning
|
|
||||||
? argsSummary
|
|
||||||
: resultSummary
|
|
||||||
? `result: ${resultSummary}`
|
|
||||||
: argsSummary
|
|
||||||
? `args: ${argsSummary}`
|
|
||||||
: null;
|
|
||||||
const statusLabel = isRunning ? "running" : isError ? "error" : "completed";
|
|
||||||
|
|
||||||
return (
|
|
||||||
<details
|
|
||||||
key={`${toolCall.toolName}-${index}`}
|
|
||||||
className={`chat-tool-call${isRunning ? " chat-tool-call--running" : ""}${isError ? " chat-tool-call--error" : ""}`}
|
|
||||||
open={isRunning}
|
|
||||||
>
|
|
||||||
<summary>
|
|
||||||
<span className="chat-tool-call-status-dot" aria-hidden="true" />
|
|
||||||
<span className="chat-tool-call-name">{toolCall.toolName}</span>
|
|
||||||
{summaryPreview && (
|
|
||||||
<span className="chat-tool-call-preview" title={summaryPreview}>
|
|
||||||
{summaryPreview}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="chat-tool-call-status-text">{statusLabel}</span>
|
|
||||||
</summary>
|
|
||||||
<div className="chat-tool-call-content">
|
|
||||||
{argsSummary && (
|
|
||||||
<div className="chat-tool-call-row">
|
|
||||||
<span className="chat-tool-call-label">args</span>
|
|
||||||
<span className="chat-tool-call-value">{argsSummary}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{resultSummary && (
|
|
||||||
<div className={`chat-tool-call-row${isError ? " chat-tool-call-row--error" : ""}`}>
|
|
||||||
<span className="chat-tool-call-label">result</span>
|
|
||||||
<span className="chat-tool-call-value">{resultSummary}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</details>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ChatToolCalls({ toolCalls, compact = false }: ChatToolCallsProps): ReactNode {
|
|
||||||
if (!toolCalls || toolCalls.length === 0) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const className = `chat-tool-calls${compact ? " chat-tool-calls--compact" : ""}`;
|
|
||||||
|
|
||||||
if (toolCalls.length === 1) {
|
|
||||||
return (
|
|
||||||
<div className={className} data-testid="chat-tool-calls">
|
|
||||||
<div className="chat-tool-calls-header">
|
|
||||||
<Wrench size={12} aria-hidden="true" />
|
|
||||||
<span>Tool calls</span>
|
|
||||||
</div>
|
|
||||||
{renderToolCallItem(toolCalls[0], 0)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const completedCount = toolCalls.filter((toolCall) => toolCall.status === "completed" && !toolCall.isError).length;
|
|
||||||
const runningCount = toolCalls.filter((toolCall) => toolCall.status === "running").length;
|
|
||||||
const errorCount = toolCalls.filter((toolCall) => toolCall.status === "completed" && toolCall.isError).length;
|
|
||||||
const hasRunning = runningCount > 0;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className={className} data-testid="chat-tool-calls">
|
|
||||||
<details className="chat-tool-calls-group" open={hasRunning}>
|
|
||||||
<summary className="chat-tool-calls-group-summary">
|
|
||||||
<div className="chat-tool-calls-header">
|
|
||||||
<Wrench size={12} aria-hidden="true" />
|
|
||||||
<span>{toolCalls.length} tool calls</span>
|
|
||||||
</div>
|
|
||||||
<span className="chat-tool-calls-group-status">
|
|
||||||
{completedCount > 0 && <span className="chat-tool-calls-group-count">{completedCount} completed</span>}
|
|
||||||
{runningCount > 0 && (
|
|
||||||
<span className="chat-tool-calls-group-count chat-tool-calls-group-count--running">{runningCount} running</span>
|
|
||||||
)}
|
|
||||||
{errorCount > 0 && (
|
|
||||||
<span className="chat-tool-calls-group-count chat-tool-calls-group-count--error">{errorCount} error</span>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</summary>
|
|
||||||
<div className="chat-tool-calls-group-items">{toolCalls.map((toolCall, index) => renderToolCallItem(toolCall, index))}</div>
|
|
||||||
</details>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -404,14 +404,14 @@
|
|||||||
.chat-tool-calls-group-summary {
|
.chat-tool-calls-group-summary {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: var(--space-sm);
|
||||||
gap: var(--space-xs);
|
|
||||||
list-style: none;
|
list-style: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: var(--space-xs) var(--space-sm);
|
padding: var(--space-xs) var(--space-sm);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
font-size: var(--space-md);
|
font-size: var(--space-md);
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-tool-calls-group-summary::marker {
|
.chat-tool-calls-group-summary::marker {
|
||||||
@@ -427,39 +427,30 @@
|
|||||||
box-shadow: var(--focus-ring-strong);
|
box-shadow: var(--focus-ring-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-tool-calls-group-status {
|
.chat-tool-calls-names {
|
||||||
display: inline-flex;
|
color: var(--text-dim);
|
||||||
align-items: center;
|
font-family: var(--font-mono, monospace);
|
||||||
flex-wrap: wrap;
|
overflow: hidden;
|
||||||
justify-content: flex-end;
|
text-overflow: ellipsis;
|
||||||
gap: var(--space-xs);
|
white-space: nowrap;
|
||||||
}
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
.chat-tool-calls-group-count {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0 var(--space-xs);
|
|
||||||
border-radius: var(--radius-pill);
|
|
||||||
background: color-mix(in srgb, var(--color-success) 12%, transparent);
|
|
||||||
color: var(--color-success);
|
|
||||||
font-size: 0.6875rem;
|
font-size: 0.6875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-tool-calls-group-count--running {
|
.chat-tool-calls-group-status {
|
||||||
background: color-mix(in srgb, var(--color-info) 12%, transparent);
|
margin-left: auto;
|
||||||
color: var(--color-info);
|
font-size: 0.6875rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-tool-calls-group-count--error {
|
.chat-tool-calls-group > .chat-tool-call {
|
||||||
background: color-mix(in srgb, var(--color-error) 12%, transparent);
|
margin: 0 var(--space-sm) var(--space-xs) var(--space-sm);
|
||||||
color: var(--color-error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-tool-calls-group-items {
|
.chat-tool-calls-group > .chat-tool-call:last-child {
|
||||||
display: flex;
|
margin-bottom: var(--space-sm);
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-xs);
|
|
||||||
padding: 0 var(--space-xs) var(--space-xs);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-tool-call {
|
.chat-tool-call {
|
||||||
@@ -575,12 +566,14 @@
|
|||||||
|
|
||||||
.chat-tool-calls--compact .chat-tool-calls-header,
|
.chat-tool-calls--compact .chat-tool-calls-header,
|
||||||
.chat-tool-calls--compact .chat-tool-calls-group-summary,
|
.chat-tool-calls--compact .chat-tool-calls-group-summary,
|
||||||
|
.chat-tool-calls--compact .chat-tool-calls-names,
|
||||||
.chat-tool-calls--compact .chat-tool-calls-group-status,
|
.chat-tool-calls--compact .chat-tool-calls-group-status,
|
||||||
.chat-tool-calls--compact .chat-tool-calls-group-count,
|
|
||||||
.chat-tool-calls--compact .chat-tool-call summary,
|
.chat-tool-calls--compact .chat-tool-call summary,
|
||||||
.chat-tool-calls--compact .chat-tool-call-content,
|
.chat-tool-calls--compact .chat-tool-call-content,
|
||||||
.chat-tool-calls--compact .chat-tool-call-preview,
|
.chat-tool-calls--compact .chat-tool-call-preview,
|
||||||
.chat-tool-calls--compact .chat-tool-call-value {
|
.chat-tool-calls--compact .chat-tool-call-value,
|
||||||
|
.chat-tool-calls-group--compact .chat-tool-calls-group-summary,
|
||||||
|
.chat-tool-calls-group--compact .chat-tool-calls-names {
|
||||||
font-size: 0.6875rem;
|
font-size: 0.6875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
EyeOff,
|
EyeOff,
|
||||||
Paperclip,
|
Paperclip,
|
||||||
File,
|
File,
|
||||||
|
Wrench,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useChat, type ToolCallInfo } from "../hooks/useChat";
|
import { useChat, type ToolCallInfo } from "../hooks/useChat";
|
||||||
import { useViewportMode } from "./Header";
|
import { useViewportMode } from "./Header";
|
||||||
@@ -30,7 +31,6 @@ import { AgentMentionPopup } from "./AgentMentionPopup";
|
|||||||
import { FileMentionPopup } from "./FileMentionPopup";
|
import { FileMentionPopup } from "./FileMentionPopup";
|
||||||
import { useFileMention } from "../hooks/useFileMention";
|
import { useFileMention } from "../hooks/useFileMention";
|
||||||
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
||||||
import { ChatToolCalls } from "./ChatToolCalls";
|
|
||||||
|
|
||||||
export interface ChatViewProps {
|
export interface ChatViewProps {
|
||||||
projectId?: string;
|
projectId?: string;
|
||||||
@@ -119,8 +119,135 @@ function formatModelTag(provider?: string | null, modelId?: string | null): stri
|
|||||||
return formatted.length > 30 ? formatted.slice(0, 30) + "…" : formatted;
|
return formatted.length > 30 ? formatted.slice(0, 30) + "…" : formatted;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function truncateToolValue(value: string, maxLength: number): string {
|
||||||
|
if (value.length <= maxLength) return value;
|
||||||
|
return `${value.slice(0, maxLength)}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatToolArgsSummary(args?: Record<string, unknown>): string | null {
|
||||||
|
if (!args) return null;
|
||||||
|
const entries = Object.entries(args);
|
||||||
|
if (entries.length === 0) return null;
|
||||||
|
|
||||||
|
return entries
|
||||||
|
.map(([key, value]) => {
|
||||||
|
const stringValue =
|
||||||
|
typeof value === "string"
|
||||||
|
? value
|
||||||
|
: (() => {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return `${key}=${truncateToolValue(stringValue, 50)}`;
|
||||||
|
})
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatToolResultSummary(result: unknown): string | null {
|
||||||
|
if (result === undefined) return null;
|
||||||
|
if (typeof result === "string") return truncateToolValue(result, 200);
|
||||||
|
try {
|
||||||
|
return truncateToolValue(JSON.stringify(result), 200);
|
||||||
|
} catch {
|
||||||
|
return truncateToolValue(String(result), 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderToolCalls(toolCalls?: ToolCallInfo[]): ReactNode {
|
function renderToolCalls(toolCalls?: ToolCallInfo[]): ReactNode {
|
||||||
return <ChatToolCalls toolCalls={toolCalls} />;
|
if (!toolCalls || toolCalls.length === 0) return null;
|
||||||
|
|
||||||
|
const renderToolCallItem = (toolCall: ToolCallInfo, index: number) => {
|
||||||
|
const isRunning = toolCall.status === "running";
|
||||||
|
const isError = toolCall.status === "completed" && toolCall.isError;
|
||||||
|
const argsSummary = formatToolArgsSummary(toolCall.args);
|
||||||
|
const resultSummary = formatToolResultSummary(toolCall.result);
|
||||||
|
const summaryPreview = isRunning
|
||||||
|
? argsSummary
|
||||||
|
: resultSummary
|
||||||
|
? `result: ${resultSummary}`
|
||||||
|
: argsSummary
|
||||||
|
? `args: ${argsSummary}`
|
||||||
|
: null;
|
||||||
|
const statusLabel = isRunning ? "running" : isError ? "error" : "completed";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<details
|
||||||
|
key={`${toolCall.toolName}-${index}`}
|
||||||
|
className={`chat-tool-call${isRunning ? " chat-tool-call--running" : ""}${isError ? " chat-tool-call--error" : ""}`}
|
||||||
|
open={isRunning}
|
||||||
|
>
|
||||||
|
<summary>
|
||||||
|
<span className="chat-tool-call-status-dot" aria-hidden="true" />
|
||||||
|
<span className="chat-tool-call-name">{toolCall.toolName}</span>
|
||||||
|
{summaryPreview && (
|
||||||
|
<span className="chat-tool-call-preview" title={summaryPreview}>
|
||||||
|
{summaryPreview}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<span className="chat-tool-call-status-text">{statusLabel}</span>
|
||||||
|
</summary>
|
||||||
|
<div className="chat-tool-call-content">
|
||||||
|
{argsSummary && (
|
||||||
|
<div className="chat-tool-call-row">
|
||||||
|
<span className="chat-tool-call-label">args</span>
|
||||||
|
<span className="chat-tool-call-value">{argsSummary}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{resultSummary && (
|
||||||
|
<div className={`chat-tool-call-row${isError ? " chat-tool-call-row--error" : ""}`}>
|
||||||
|
<span className="chat-tool-call-label">result</span>
|
||||||
|
<span className="chat-tool-call-value">{resultSummary}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const className = "chat-tool-calls";
|
||||||
|
if (toolCalls.length === 1) {
|
||||||
|
return (
|
||||||
|
<div className={className} data-testid="chat-tool-calls">
|
||||||
|
<div className="chat-tool-calls-header">
|
||||||
|
<Wrench size={12} aria-hidden="true" />
|
||||||
|
<span>Tool calls</span>
|
||||||
|
</div>
|
||||||
|
{renderToolCallItem(toolCalls[0], 0)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const runningCount = toolCalls.filter((toolCall) => toolCall.status === "running").length;
|
||||||
|
const errorCount = toolCalls.filter((toolCall) => toolCall.status === "completed" && toolCall.isError).length;
|
||||||
|
const hasRunning = runningCount > 0;
|
||||||
|
const uniqueNames = Array.from(new Set(toolCalls.map((toolCall) => toolCall.toolName)));
|
||||||
|
const visibleNames = uniqueNames.slice(0, 5);
|
||||||
|
const overflowCount = Math.max(0, uniqueNames.length - visibleNames.length);
|
||||||
|
const namesSummary = overflowCount > 0
|
||||||
|
? `${visibleNames.join(", ")}, +${overflowCount} more`
|
||||||
|
: visibleNames.join(", ");
|
||||||
|
const statusSummary = hasRunning
|
||||||
|
? `(${runningCount} running)`
|
||||||
|
: errorCount > 0
|
||||||
|
? `(${errorCount} ${errorCount === 1 ? "error" : "errors"})`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className} data-testid="chat-tool-calls">
|
||||||
|
<details className="chat-tool-calls-group" 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-names" title={namesSummary}>{namesSummary}</span>
|
||||||
|
{statusSummary && <span className="chat-tool-calls-group-status">{statusSummary}</span>}
|
||||||
|
</summary>
|
||||||
|
{toolCalls.map((toolCall, index) => renderToolCallItem(toolCall, index))}
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const chatMarkdownComponents: Components = {
|
const chatMarkdownComponents: Components = {
|
||||||
|
|||||||
@@ -414,7 +414,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.quick-chat-message-render-toggle:hover {
|
.quick-chat-message-render-toggle:hover {
|
||||||
background: var(--surface-hover);
|
background: var(--surface-hover, color-mix(in srgb, var(--surface) 55%, transparent));
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -712,14 +712,14 @@
|
|||||||
.chat-tool-calls-group-summary {
|
.chat-tool-calls-group-summary {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
justify-content: space-between;
|
gap: var(--space-sm);
|
||||||
gap: var(--space-xs);
|
|
||||||
list-style: none;
|
list-style: none;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
padding: var(--space-xs) var(--space-sm);
|
padding: var(--space-xs) var(--space-sm);
|
||||||
color: var(--text-muted);
|
color: var(--text-muted);
|
||||||
border-radius: var(--radius-sm);
|
border-radius: var(--radius-sm);
|
||||||
font-size: var(--space-md);
|
font-size: var(--space-md);
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-tool-calls-group-summary::marker {
|
.chat-tool-calls-group-summary::marker {
|
||||||
@@ -735,39 +735,30 @@
|
|||||||
box-shadow: var(--focus-ring-strong);
|
box-shadow: var(--focus-ring-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-tool-calls-group-status {
|
.chat-tool-calls-names {
|
||||||
display: inline-flex;
|
color: var(--text-dim);
|
||||||
align-items: center;
|
font-family: var(--font-mono, monospace);
|
||||||
flex-wrap: wrap;
|
overflow: hidden;
|
||||||
justify-content: flex-end;
|
text-overflow: ellipsis;
|
||||||
gap: var(--space-xs);
|
white-space: nowrap;
|
||||||
}
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
.chat-tool-calls-group-count {
|
|
||||||
display: inline-flex;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0 var(--space-xs);
|
|
||||||
border-radius: var(--radius-pill);
|
|
||||||
background: color-mix(in srgb, var(--color-success) 12%, transparent);
|
|
||||||
color: var(--color-success);
|
|
||||||
font-size: 0.6875rem;
|
font-size: 0.6875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-tool-calls-group-count--running {
|
.chat-tool-calls-group-status {
|
||||||
background: color-mix(in srgb, var(--color-info) 12%, transparent);
|
margin-left: auto;
|
||||||
color: var(--color-info);
|
font-size: 0.6875rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-tool-calls-group-count--error {
|
.chat-tool-calls-group > .chat-tool-call {
|
||||||
background: color-mix(in srgb, var(--color-error) 12%, transparent);
|
margin: 0 var(--space-sm) var(--space-xs) var(--space-sm);
|
||||||
color: var(--color-error);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-tool-calls-group-items {
|
.chat-tool-calls-group > .chat-tool-call:last-child {
|
||||||
display: flex;
|
margin-bottom: var(--space-sm);
|
||||||
flex-direction: column;
|
|
||||||
gap: var(--space-xs);
|
|
||||||
padding: 0 var(--space-xs) var(--space-xs);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-tool-call {
|
.chat-tool-call {
|
||||||
@@ -883,12 +874,14 @@
|
|||||||
|
|
||||||
.chat-tool-calls--compact .chat-tool-calls-header,
|
.chat-tool-calls--compact .chat-tool-calls-header,
|
||||||
.chat-tool-calls--compact .chat-tool-calls-group-summary,
|
.chat-tool-calls--compact .chat-tool-calls-group-summary,
|
||||||
|
.chat-tool-calls--compact .chat-tool-calls-names,
|
||||||
.chat-tool-calls--compact .chat-tool-calls-group-status,
|
.chat-tool-calls--compact .chat-tool-calls-group-status,
|
||||||
.chat-tool-calls--compact .chat-tool-calls-group-count,
|
|
||||||
.chat-tool-calls--compact .chat-tool-call summary,
|
.chat-tool-calls--compact .chat-tool-call summary,
|
||||||
.chat-tool-calls--compact .chat-tool-call-content,
|
.chat-tool-calls--compact .chat-tool-call-content,
|
||||||
.chat-tool-calls--compact .chat-tool-call-preview,
|
.chat-tool-calls--compact .chat-tool-call-preview,
|
||||||
.chat-tool-calls--compact .chat-tool-call-value {
|
.chat-tool-calls--compact .chat-tool-call-value,
|
||||||
|
.chat-tool-calls-group--compact .chat-tool-calls-group-summary,
|
||||||
|
.chat-tool-calls-group--compact .chat-tool-calls-names {
|
||||||
font-size: 0.6875rem;
|
font-size: 0.6875rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import {
|
|||||||
import ReactMarkdown from "react-markdown";
|
import ReactMarkdown from "react-markdown";
|
||||||
import remarkGfm from "remark-gfm";
|
import remarkGfm from "remark-gfm";
|
||||||
import type { Components } from "react-markdown";
|
import type { Components } from "react-markdown";
|
||||||
import { Eye, EyeOff, MessageSquare, Paperclip, Send, Square, X } from "lucide-react";
|
import { Eye, EyeOff, MessageSquare, Paperclip, Send, Square, Wrench, X } from "lucide-react";
|
||||||
import { fetchModels, type Agent, type ModelInfo } from "../api";
|
import { fetchModels, type Agent, type ModelInfo } from "../api";
|
||||||
import { CustomModelDropdown } from "./CustomModelDropdown";
|
import { CustomModelDropdown } from "./CustomModelDropdown";
|
||||||
import { AgentMentionPopup } from "./AgentMentionPopup";
|
import { AgentMentionPopup } from "./AgentMentionPopup";
|
||||||
@@ -21,7 +21,6 @@ import { useAgents } from "../hooks/useAgents";
|
|||||||
import { FileMentionPopup } from "./FileMentionPopup";
|
import { FileMentionPopup } from "./FileMentionPopup";
|
||||||
import { useFileMention } from "../hooks/useFileMention";
|
import { useFileMention } from "../hooks/useFileMention";
|
||||||
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
|
||||||
import { ChatToolCalls } from "./ChatToolCalls";
|
|
||||||
|
|
||||||
interface PendingAttachment {
|
interface PendingAttachment {
|
||||||
file: File;
|
file: File;
|
||||||
@@ -88,8 +87,127 @@ function formatModelTagName(modelInfo: ModelInfo | null, parsedSelection: Parsed
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function truncateToolValue(value: string, maxLength: number): string {
|
||||||
|
if (value.length <= maxLength) return value;
|
||||||
|
return `${value.slice(0, maxLength)}…`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatToolArgsSummary(args?: Record<string, unknown>): string | null {
|
||||||
|
if (!args) return null;
|
||||||
|
const entries = Object.entries(args);
|
||||||
|
if (entries.length === 0) return null;
|
||||||
|
return entries
|
||||||
|
.map(([key, value]) => {
|
||||||
|
const stringValue = typeof value === "string" ? value : (() => {
|
||||||
|
try {
|
||||||
|
return JSON.stringify(value);
|
||||||
|
} catch {
|
||||||
|
return String(value);
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
return `${key}=${truncateToolValue(stringValue, 50)}`;
|
||||||
|
})
|
||||||
|
.join(", ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatToolResultSummary(result: unknown): string | null {
|
||||||
|
if (result === undefined) return null;
|
||||||
|
if (typeof result === "string") return truncateToolValue(result, 200);
|
||||||
|
try {
|
||||||
|
return truncateToolValue(JSON.stringify(result), 200);
|
||||||
|
} catch {
|
||||||
|
return truncateToolValue(String(result), 200);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function renderToolCalls(toolCalls?: ToolCallInfo[], compact = false): ReactNode {
|
function renderToolCalls(toolCalls?: ToolCallInfo[], compact = false): ReactNode {
|
||||||
return <ChatToolCalls toolCalls={toolCalls} compact={compact} />;
|
if (!toolCalls || toolCalls.length === 0) return null;
|
||||||
|
|
||||||
|
const renderToolCallItem = (toolCall: ToolCallInfo, index: number) => {
|
||||||
|
const isRunning = toolCall.status === "running";
|
||||||
|
const isError = toolCall.status === "completed" && toolCall.isError;
|
||||||
|
const argsSummary = formatToolArgsSummary(toolCall.args);
|
||||||
|
const resultSummary = formatToolResultSummary(toolCall.result);
|
||||||
|
const summaryPreview = isRunning
|
||||||
|
? argsSummary
|
||||||
|
: resultSummary
|
||||||
|
? `result: ${resultSummary}`
|
||||||
|
: argsSummary
|
||||||
|
? `args: ${argsSummary}`
|
||||||
|
: null;
|
||||||
|
const statusLabel = isRunning ? "running" : isError ? "error" : "completed";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<details
|
||||||
|
key={`${toolCall.toolName}-${index}`}
|
||||||
|
className={`chat-tool-call${isRunning ? " chat-tool-call--running" : ""}${isError ? " chat-tool-call--error" : ""}`}
|
||||||
|
open={isRunning}
|
||||||
|
>
|
||||||
|
<summary>
|
||||||
|
<span className="chat-tool-call-status-dot" aria-hidden="true" />
|
||||||
|
<span className="chat-tool-call-name">{toolCall.toolName}</span>
|
||||||
|
{summaryPreview && <span className="chat-tool-call-preview" title={summaryPreview}>{summaryPreview}</span>}
|
||||||
|
<span className="chat-tool-call-status-text">{statusLabel}</span>
|
||||||
|
</summary>
|
||||||
|
<div className="chat-tool-call-content">
|
||||||
|
{argsSummary && (
|
||||||
|
<div className="chat-tool-call-row">
|
||||||
|
<span className="chat-tool-call-label">args</span>
|
||||||
|
<span className="chat-tool-call-value">{argsSummary}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{resultSummary && (
|
||||||
|
<div className={`chat-tool-call-row${isError ? " chat-tool-call-row--error" : ""}`}>
|
||||||
|
<span className="chat-tool-call-label">result</span>
|
||||||
|
<span className="chat-tool-call-value">{resultSummary}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const className = `chat-tool-calls${compact ? " chat-tool-calls--compact" : ""}`;
|
||||||
|
if (toolCalls.length === 1) {
|
||||||
|
return (
|
||||||
|
<div className={className} data-testid="chat-tool-calls">
|
||||||
|
<div className="chat-tool-calls-header">
|
||||||
|
<Wrench size={12} aria-hidden="true" />
|
||||||
|
<span>Tool calls</span>
|
||||||
|
</div>
|
||||||
|
{renderToolCallItem(toolCalls[0], 0)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const runningCount = toolCalls.filter((toolCall) => toolCall.status === "running").length;
|
||||||
|
const errorCount = toolCalls.filter((toolCall) => toolCall.status === "completed" && toolCall.isError).length;
|
||||||
|
const hasRunning = runningCount > 0;
|
||||||
|
const uniqueNames = Array.from(new Set(toolCalls.map((toolCall) => toolCall.toolName)));
|
||||||
|
const visibleNames = uniqueNames.slice(0, 5);
|
||||||
|
const overflowCount = Math.max(0, uniqueNames.length - visibleNames.length);
|
||||||
|
const namesSummary = overflowCount > 0
|
||||||
|
? `${visibleNames.join(", ")}, +${overflowCount} more`
|
||||||
|
: visibleNames.join(", ");
|
||||||
|
const statusSummary = hasRunning
|
||||||
|
? `(${runningCount} running)`
|
||||||
|
: errorCount > 0
|
||||||
|
? `(${errorCount} ${errorCount === 1 ? "error" : "errors"})`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={className} data-testid="chat-tool-calls">
|
||||||
|
<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-names" title={namesSummary}>{namesSummary}</span>
|
||||||
|
{statusSummary && <span className="chat-tool-calls-group-status">{statusSummary}</span>}
|
||||||
|
</summary>
|
||||||
|
{toolCalls.map((toolCall, index) => renderToolCallItem(toolCall, index))}
|
||||||
|
</details>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const quickChatMarkdownComponents: Components = {
|
const quickChatMarkdownComponents: Components = {
|
||||||
|
|||||||
@@ -556,7 +556,7 @@ describe("ChatView", () => {
|
|||||||
expect(preview).toHaveTextContent("path=foo.ts");
|
expect(preview).toHaveTextContent("path=foo.ts");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders grouped summary for multiple completed tool calls and keeps it collapsed", () => {
|
it("collapses multiple tool calls into single summary line", () => {
|
||||||
setupMockChat({
|
setupMockChat({
|
||||||
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Tool Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Tool Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
||||||
messages: [
|
messages: [
|
||||||
@@ -586,11 +586,11 @@ describe("ChatView", () => {
|
|||||||
|
|
||||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||||
|
|
||||||
const group = document.querySelector(".chat-tool-calls-group") as HTMLDetailsElement | null;
|
const group = screen.getByTestId("chat-tool-calls-group") as HTMLDetailsElement;
|
||||||
expect(group).toBeInTheDocument();
|
expect(group).toBeInTheDocument();
|
||||||
expect(group?.open).toBe(false);
|
expect(group.open).toBe(false);
|
||||||
expect(screen.getByText("2 tool calls")).toBeInTheDocument();
|
expect(screen.getByText("2 tool calls")).toBeInTheDocument();
|
||||||
expect(screen.getByText("2 completed")).toBeInTheDocument();
|
expect(screen.getByText("read, grep")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("auto-opens grouped tool calls when any tool call is running", () => {
|
it("auto-opens grouped tool calls when any tool call is running", () => {
|
||||||
@@ -622,12 +622,12 @@ describe("ChatView", () => {
|
|||||||
|
|
||||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||||
|
|
||||||
const group = document.querySelector(".chat-tool-calls-group") as HTMLDetailsElement | null;
|
const group = screen.getByTestId("chat-tool-calls-group") as HTMLDetailsElement;
|
||||||
expect(group).toBeInTheDocument();
|
expect(group).toBeInTheDocument();
|
||||||
expect(group?.open).toBe(true);
|
expect(group.open).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it("shows grouped tool-call status breakdown", () => {
|
it("shows status counts in group summary", () => {
|
||||||
setupMockChat({
|
setupMockChat({
|
||||||
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Tool Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Tool Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
||||||
messages: [
|
messages: [
|
||||||
@@ -662,9 +662,40 @@ describe("ChatView", () => {
|
|||||||
|
|
||||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||||
|
|
||||||
expect(screen.getByText("1 completed")).toBeInTheDocument();
|
expect(screen.getByText("(1 running)")).toBeInTheDocument();
|
||||||
expect(screen.getByText("1 running")).toBeInTheDocument();
|
});
|
||||||
expect(screen.getByText("1 error")).toBeInTheDocument();
|
|
||||||
|
it("shows error count when there are errors and no running calls", () => {
|
||||||
|
setupMockChat({
|
||||||
|
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Tool Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: "msg-002",
|
||||||
|
sessionId: "session-001",
|
||||||
|
role: "assistant",
|
||||||
|
content: "Mixed",
|
||||||
|
toolCalls: [
|
||||||
|
{
|
||||||
|
toolName: "read",
|
||||||
|
isError: false,
|
||||||
|
result: "contents",
|
||||||
|
status: "completed",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
toolName: "write",
|
||||||
|
isError: true,
|
||||||
|
result: "failed",
|
||||||
|
status: "completed",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
createdAt: "2026-04-08T00:01:00.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("(1 error)")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("expands grouped tool calls to reveal individual tool items", async () => {
|
it("expands grouped tool calls to reveal individual tool items", async () => {
|
||||||
@@ -697,7 +728,7 @@ describe("ChatView", () => {
|
|||||||
|
|
||||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||||
|
|
||||||
const group = document.querySelector(".chat-tool-calls-group") as HTMLDetailsElement;
|
const group = screen.getByTestId("chat-tool-calls-group") as HTMLDetailsElement;
|
||||||
expect(group.open).toBe(false);
|
expect(group.open).toBe(false);
|
||||||
|
|
||||||
const summary = group.querySelector(".chat-tool-calls-group-summary") as HTMLElement;
|
const summary = group.querySelector(".chat-tool-calls-group-summary") as HTMLElement;
|
||||||
@@ -708,7 +739,7 @@ describe("ChatView", () => {
|
|||||||
expect(screen.getByText("grep")).toBeInTheDocument();
|
expect(screen.getByText("grep")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("completed tool calls are collapsed by default", () => {
|
it("single tool call renders without group wrapper", () => {
|
||||||
setupMockChat({
|
setupMockChat({
|
||||||
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Tool Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Tool Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
||||||
messages: [
|
messages: [
|
||||||
@@ -732,11 +763,39 @@ describe("ChatView", () => {
|
|||||||
|
|
||||||
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.queryByTestId("chat-tool-calls-group")).not.toBeInTheDocument();
|
||||||
const details = document.querySelector(".chat-tool-call") as HTMLDetailsElement | null;
|
const details = document.querySelector(".chat-tool-call") as HTMLDetailsElement | null;
|
||||||
expect(details).toBeInTheDocument();
|
expect(details).toBeInTheDocument();
|
||||||
expect(details?.open).toBe(false);
|
expect(details?.open).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("truncates tool names when more than 5 unique", () => {
|
||||||
|
setupMockChat({
|
||||||
|
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Tool Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: "msg-002",
|
||||||
|
sessionId: "session-001",
|
||||||
|
role: "assistant",
|
||||||
|
content: "Done",
|
||||||
|
toolCalls: [
|
||||||
|
{ toolName: "read", isError: false, status: "completed" },
|
||||||
|
{ toolName: "edit", isError: false, status: "completed" },
|
||||||
|
{ toolName: "bash", isError: false, status: "completed" },
|
||||||
|
{ toolName: "grep", isError: false, status: "completed" },
|
||||||
|
{ toolName: "write", isError: false, status: "completed" },
|
||||||
|
{ toolName: "list", isError: false, status: "completed" },
|
||||||
|
],
|
||||||
|
createdAt: "2026-04-08T00:01:00.000Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
|
||||||
|
|
||||||
|
expect(screen.getByText("read, edit, bash, grep, write, +1 more")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
it("running tool calls show running indicator", () => {
|
it("running tool calls show running indicator", () => {
|
||||||
setupMockChat({
|
setupMockChat({
|
||||||
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Tool Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
activeSession: { id: "session-001", agentId: "agent-001", status: "active", title: "Tool Chat", createdAt: "2026-04-08T00:00:00.000Z", updatedAt: "2026-04-08T00:00:00.000Z" },
|
||||||
|
|||||||
@@ -1005,7 +1005,7 @@ describe("QuickChatFAB", () => {
|
|||||||
expect(preview).toHaveTextContent("result: contents");
|
expect(preview).toHaveTextContent("result: contents");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("renders grouped summary for multiple tool calls in compact mode", async () => {
|
it("renders multiple tool calls collapsed in quick chat", async () => {
|
||||||
mockFetchChatMessages.mockResolvedValueOnce({
|
mockFetchChatMessages.mockResolvedValueOnce({
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
@@ -1038,7 +1038,38 @@ describe("QuickChatFAB", () => {
|
|||||||
|
|
||||||
const toolCallsContainer = document.querySelector(".chat-tool-calls") as HTMLElement | null;
|
const toolCallsContainer = document.querySelector(".chat-tool-calls") as HTMLElement | null;
|
||||||
expect(toolCallsContainer).toHaveClass("chat-tool-calls--compact");
|
expect(toolCallsContainer).toHaveClass("chat-tool-calls--compact");
|
||||||
expect(screen.getByText("2 completed")).toBeInTheDocument();
|
expect(screen.getByText("read, grep")).toBeInTheDocument();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("compact group class applied in quick chat", async () => {
|
||||||
|
mockFetchChatMessages.mockResolvedValueOnce({
|
||||||
|
messages: [
|
||||||
|
{
|
||||||
|
id: "msg-tools",
|
||||||
|
sessionId: "session-001",
|
||||||
|
role: "assistant",
|
||||||
|
content: "Used tools",
|
||||||
|
toolCalls: [
|
||||||
|
{ toolName: "read", status: "completed", isError: false, result: "contents" },
|
||||||
|
{ toolName: "grep", status: "completed", isError: false, result: "matches" },
|
||||||
|
],
|
||||||
|
metadata: {
|
||||||
|
toolCalls: [
|
||||||
|
{ toolName: "read", status: "completed", isError: false, result: "contents" },
|
||||||
|
{ toolName: "grep", status: "completed", isError: false, result: "matches" },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
createdAt: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
render(<QuickChatFAB addToast={addToast} projectId="proj-123" />);
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
||||||
|
|
||||||
|
const group = (await waitFor(() => screen.getByTestId("chat-tool-calls-group"))) as HTMLDetailsElement;
|
||||||
|
expect(group).toHaveClass("chat-tool-calls-group--compact");
|
||||||
});
|
});
|
||||||
|
|
||||||
it("expands grouped tool calls to reveal individual quick chat tool items", async () => {
|
it("expands grouped tool calls to reveal individual quick chat tool items", async () => {
|
||||||
@@ -1068,11 +1099,7 @@ describe("QuickChatFAB", () => {
|
|||||||
|
|
||||||
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
fireEvent.click(screen.getByTestId("quick-chat-fab"));
|
||||||
|
|
||||||
const group = (await waitFor(() => {
|
const group = (await waitFor(() => screen.getByTestId("chat-tool-calls-group"))) as HTMLDetailsElement;
|
||||||
const details = document.querySelector(".chat-tool-calls-group") as HTMLDetailsElement | null;
|
|
||||||
if (!details) throw new Error("group not rendered yet");
|
|
||||||
return details;
|
|
||||||
})) as HTMLDetailsElement;
|
|
||||||
|
|
||||||
expect(group.open).toBe(false);
|
expect(group.open).toBe(false);
|
||||||
|
|
||||||
@@ -1084,7 +1111,7 @@ describe("QuickChatFAB", () => {
|
|||||||
expect(screen.getByText("grep")).toBeInTheDocument();
|
expect(screen.getByText("grep")).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("streaming grouped tool calls auto-expand when a tool is running", async () => {
|
it("auto-opens group for running tool calls in quick chat", async () => {
|
||||||
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
|
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
|
||||||
handlers.onText?.("Still working");
|
handlers.onText?.("Still working");
|
||||||
handlers.onToolStart?.({ toolName: "read", args: { path: "foo.ts" } });
|
handlers.onToolStart?.({ toolName: "read", args: { path: "foo.ts" } });
|
||||||
@@ -1111,11 +1138,10 @@ describe("QuickChatFAB", () => {
|
|||||||
await waitFor(() => {
|
await waitFor(() => {
|
||||||
const streamingMessage = screen.getByTestId("quick-chat-streaming-message");
|
const streamingMessage = screen.getByTestId("quick-chat-streaming-message");
|
||||||
expect(within(streamingMessage).getByText("2 tool calls")).toBeInTheDocument();
|
expect(within(streamingMessage).getByText("2 tool calls")).toBeInTheDocument();
|
||||||
expect(within(streamingMessage).getByText("1 completed")).toBeInTheDocument();
|
expect(within(streamingMessage).getByText("(1 running)")).toBeInTheDocument();
|
||||||
expect(within(streamingMessage).getByText("1 running")).toBeInTheDocument();
|
const group = within(streamingMessage).getByTestId("chat-tool-calls-group") as HTMLDetailsElement;
|
||||||
const group = streamingMessage.querySelector(".chat-tool-calls-group") as HTMLDetailsElement | null;
|
|
||||||
expect(group).toBeTruthy();
|
expect(group).toBeTruthy();
|
||||||
expect(group?.open).toBe(true);
|
expect(group.open).toBe(true);
|
||||||
expect(streamingMessage.querySelector(".chat-tool-call--running")).toBeTruthy();
|
expect(streamingMessage.querySelector(".chat-tool-call--running")).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user