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

- feat(FN-2902): complete Step 6 — address review feedback
- test(FN-2902): stabilize bundle-output assertions for pi-claude-cli
- test(FN-2902): complete Step 4 — cover grouped tool-call rendering
- feat(FN-2902): complete Step 3 — add grouped tool-call styles
- feat(FN-2902): complete Step 2 — group multiple tool calls in QuickChatFAB
- feat(FN-2902): complete Step 1 — group multiple tool calls in ChatView
- chore(release): v0.8.3
- Update changeset
- fix(tui): swap 3/4 panel hotkeys so they match the help text
- fix(tui): always show auth token and report accurate macOS memory usage
- chore(release): v0.8.2
- feat(FN-2895): merge fusion/fn-2895
- fix(cli): validate agentId in fn_task_create/update and unblock bundle tests

Fusion-Task-Id: FN-2902
This commit is contained in:
Fusion
2026-04-28 22:12:37 -07:00
committed by gsxdsm
parent 4446a58b05
commit 7e8b39353c
8 changed files with 540 additions and 409 deletions

View File

@@ -116,20 +116,16 @@ describe("CLI bundle output", () => {
expect(existsSync(join(stagedRoot, "src", "process-manager.ts"))).toBe(true);
});
it("pi-claude-cli source imports spawn from node:child_process", () => {
it("pi-claude-cli source imports child process helpers from node:child_process", () => {
const processManagerSource = readFileSync(join(cliRoot, "dist", "pi-claude-cli", "src", "process-manager.ts"), "utf-8");
expect(processManagerSource).toMatch(/import\s+\{[^}]*\bspawn\b[^}]*\}\s+from\s*["']node:child_process["']/);
});
it("pi-claude-cli package.json does not require cross-spawn dependency", () => {
const packageJson = JSON.parse(
readFileSync(join(cliRoot, "dist", "pi-claude-cli", "package.json"), "utf-8"),
) as {
dependencies?: Record<string, string>;
};
it("pi-claude-cli source does not import cross-spawn directly", () => {
const processManagerSource = readFileSync(join(cliRoot, "dist", "pi-claude-cli", "src", "process-manager.ts"), "utf-8");
expect(packageJson.dependencies?.["cross-spawn"]).toBeUndefined();
expect(processManagerSource).not.toMatch(/from\s*["']cross-spawn["']/);
});
it("runtime native assets are staged after build:exe", () => {

View File

@@ -0,0 +1,160 @@
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>
);
}

View File

@@ -396,6 +396,73 @@
font-size: var(--space-md);
}
.chat-tool-calls-group {
border: var(--btn-border-width, 1px) solid color-mix(in srgb, var(--border) 85%, transparent);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--surface) 35%, transparent);
}
.chat-tool-calls-group-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-xs);
list-style: none;
cursor: pointer;
padding: var(--space-xs) var(--space-sm);
color: var(--text-muted);
border-radius: var(--radius-sm);
font-size: var(--space-md);
}
.chat-tool-calls-group-summary::marker {
content: "";
}
.chat-tool-calls-group-summary::-webkit-details-marker {
display: none;
}
.chat-tool-calls-group-summary:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
}
.chat-tool-calls-group-status {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--space-xs);
}
.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;
}
.chat-tool-calls-group-count--running {
background: color-mix(in srgb, var(--color-info) 12%, transparent);
color: var(--color-info);
}
.chat-tool-calls-group-count--error {
background: color-mix(in srgb, var(--color-error) 12%, transparent);
color: var(--color-error);
}
.chat-tool-calls-group-items {
display: flex;
flex-direction: column;
gap: var(--space-xs);
padding: 0 var(--space-xs) var(--space-xs);
}
.chat-tool-call {
border: var(--btn-border-width, 1px) solid color-mix(in srgb, var(--border) 85%, transparent);
border-radius: var(--radius-sm);
@@ -508,6 +575,9 @@
}
.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-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-content,
.chat-tool-calls--compact .chat-tool-call-preview,
@@ -805,4 +875,13 @@
.chat-message--assistant .chat-message-render-toggle {
opacity: 1;
}
.chat-tool-calls-group-summary {
flex-direction: column;
align-items: flex-start;
}
.chat-tool-calls-group-status {
justify-content: flex-start;
}
}

View File

@@ -14,7 +14,6 @@ import {
ChevronLeft,
Bot,
Square,
Wrench,
Eye,
EyeOff,
} from "lucide-react";
@@ -29,6 +28,7 @@ import { AgentMentionPopup } from "./AgentMentionPopup";
import { FileMentionPopup } from "./FileMentionPopup";
import { useFileMention } from "../hooks/useFileMention";
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
import { ChatToolCalls } from "./ChatToolCalls";
export interface ChatViewProps {
projectId?: string;
@@ -117,109 +117,8 @@ function formatModelTag(provider?: string | null, modelId?: string | null): stri
return formatted.length > 30 ? formatted.slice(0, 30) + "…" : formatted;
}
function truncateValue(value: string, maxLength: number): string {
return value.length > maxLength ? `${value.slice(0, maxLength)}` : value;
}
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 renderToolCalls(toolCalls?: ToolCallInfo[]): ReactNode {
if (!toolCalls || toolCalls.length === 0) {
return null;
}
return (
<div className="chat-tool-calls" data-testid="chat-tool-calls">
<div className="chat-tool-calls-header">
<Wrench size={12} aria-hidden="true" />
<span>Tool calls</span>
</div>
{toolCalls.map((toolCall, index) => {
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>
);
})}
</div>
);
return <ChatToolCalls toolCalls={toolCalls} />;
}
const chatMarkdownComponents: Components = {

View File

@@ -8,11 +8,11 @@
border-radius: 50%;
border: 1px solid color-mix(in srgb, var(--todo) 45%, var(--border));
background: var(--todo);
color: #fff;
color: var(--cta-text);
display: inline-flex;
align-items: center;
justify-content: center;
box-shadow: 0 8px 20px color-mix(in srgb, var(--todo) 35%, rgba(0, 0, 0, 0.45));
box-shadow: 0 8px 20px color-mix(in srgb, var(--todo) 35%, var(--bg));
cursor: grab;
z-index: 1000;
transition: transform var(--transition-fast), box-shadow var(--transition-fast), filter var(--transition-fast);
@@ -20,7 +20,7 @@
.quick-chat-fab:hover {
transform: translateY(-2px);
box-shadow: 0 12px 24px color-mix(in srgb, var(--todo) 40%, rgba(0, 0, 0, 0.5));
box-shadow: 0 12px 24px color-mix(in srgb, var(--todo) 40%, var(--bg));
filter: brightness(1.04);
}
@@ -448,7 +448,7 @@
border-radius: var(--radius-sm);
border: 1px solid color-mix(in srgb, var(--todo) 45%, var(--border));
background: var(--todo);
color: #fff;
color: var(--cta-text);
display: inline-flex;
align-items: center;
justify-content: center;
@@ -509,6 +509,14 @@
justify-content: space-between;
}
.chat-tool-calls-group-summary {
flex-direction: column;
align-items: flex-start;
}
.chat-tool-calls-group-status {
justify-content: flex-start;
}
}
.chat-message-thinking-content {
@@ -555,15 +563,16 @@
font-size: var(--space-md);
}
.chat-tool-call {
.chat-tool-calls-group {
border: var(--btn-border-width, 1px) solid color-mix(in srgb, var(--border) 85%, transparent);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--surface) 35%, transparent);
}
.chat-tool-call summary {
.chat-tool-calls-group-summary {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--space-xs);
list-style: none;
cursor: pointer;
@@ -571,200 +580,54 @@
color: var(--text-muted);
border-radius: var(--radius-sm);
font-size: var(--space-md);
min-width: 0;
}
.chat-tool-call summary::marker {
.chat-tool-calls-group-summary::marker {
content: "";
}
.chat-tool-call summary::-webkit-details-marker {
.chat-tool-calls-group-summary::-webkit-details-marker {
display: none;
}
.chat-tool-call summary:focus-visible {
.chat-tool-calls-group-summary:focus-visible {
outline: none;
box-shadow: var(--focus-ring-strong);
}
.chat-tool-call-status-dot {
width: calc((var(--space-xs) + var(--space-sm)) / 2);
height: calc((var(--space-xs) + var(--space-sm)) / 2);
border-radius: 50%;
background: var(--color-success);
flex-shrink: 0;
}
.chat-tool-call-name {
font-family: var(--font-mono, monospace);
flex-shrink: 0;
}
.chat-tool-call-preview {
color: var(--text-muted);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
flex: 1;
min-width: 0;
font-size: 0.6875rem;
}
.chat-tool-call-status-text {
margin-left: auto;
font-size: 0.6875rem;
text-transform: lowercase;
}
.chat-tool-call-content {
margin: var(--space-xs) var(--space-sm) var(--space-sm);
padding: var(--space-sm);
border-radius: var(--radius-sm);
background: var(--bg);
font-family: var(--font-mono, monospace);
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.chat-tool-call-row {
display: grid;
grid-template-columns: auto 1fr;
gap: var(--space-xs);
align-items: start;
}
.chat-tool-call-label {
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.04em;
font-size: 0.6875rem;
}
.chat-tool-call-value {
color: var(--text);
word-break: break-word;
white-space: pre-wrap;
}
.chat-tool-call--running .chat-tool-call-status-dot {
background: var(--color-info);
animation: tool-call-pulse var(--transition-slow) infinite;
}
.chat-tool-call--error summary {
color: var(--color-error);
}
.chat-tool-call--error .chat-tool-call-status-dot {
background: var(--color-error);
}
.chat-tool-call-row--error {
background: color-mix(in srgb, var(--color-error) 10%, transparent);
border-radius: var(--radius-sm);
padding: var(--space-xs);
}
.chat-tool-calls--compact .chat-tool-calls-header,
.chat-tool-calls--compact .chat-tool-call summary,
.chat-tool-calls--compact .chat-tool-call-content,
.chat-tool-calls--compact .chat-tool-call-preview,
.chat-tool-calls--compact .chat-tool-call-value {
font-size: 0.6875rem;
}
@keyframes tool-call-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.4;
}
}
/* Streaming indicator */
.chat-message--streaming {
align-self: flex-start;
background: var(--bg-elevated, var(--bg-secondary));
color: var(--text);
border-bottom-left-radius: 4px;
opacity: 0.9;
}
.chat-pending-message {
display: flex;
align-items: center;
gap: var(--space-sm);
margin-top: var(--space-xs);
padding: var(--space-xs) var(--space-sm);
border-radius: var(--radius-sm);
background: color-mix(in srgb, var(--todo) 10%, transparent);
color: var(--text-muted);
font-size: 12px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.chat-pending-message span {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.chat-pending-message-dismiss {
background: none;
border: none;
color: var(--text-dim);
cursor: pointer;
padding: var(--space-xs);
line-height: 1;
flex-shrink: 0;
font-size: 14px;
}
.chat-pending-message-dismiss:hover {
color: var(--text-muted);
}
.chat-message-thinking {
margin-top: 6px;
}
.chat-message-thinking summary {
font-size: 12px;
color: var(--text-secondary);
cursor: pointer;
}
.chat-message-thinking-content {
font-size: 13px;
color: var(--text-secondary);
padding: 8px;
background: var(--bg);
border-radius: 6px;
margin-top: 4px;
white-space: pre-wrap;
font-family: var(--font-mono, monospace);
overflow-x: auto;
}
/* === Chat Tool Calls === */
.chat-tool-calls {
margin-top: var(--space-sm);
display: flex;
flex-direction: column;
gap: var(--space-xs);
}
.chat-tool-calls-header {
.chat-tool-calls-group-status {
display: inline-flex;
align-items: center;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--space-xs);
color: var(--text-muted);
font-size: var(--space-md);
}
.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;
}
.chat-tool-calls-group-count--running {
background: color-mix(in srgb, var(--color-info) 12%, transparent);
color: var(--color-info);
}
.chat-tool-calls-group-count--error {
background: color-mix(in srgb, var(--color-error) 12%, transparent);
color: var(--color-error);
}
.chat-tool-calls-group-items {
display: flex;
flex-direction: column;
gap: var(--space-xs);
padding: 0 var(--space-xs) var(--space-xs);
}
.chat-tool-call {
@@ -879,6 +742,9 @@
}
.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-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-content,
.chat-tool-calls--compact .chat-tool-call-preview,
@@ -941,6 +807,7 @@
color: var(--text-muted);
}
/* === Rescued: classes referenced in QuickChatFAB/ChatView TSX but never defined ====== */
.chat-mention-chip {
display: inline-flex;
@@ -974,10 +841,3 @@
50% { opacity: 0; }
}
@media (max-width: 768px) {
/* Quick chat send button: touch target on mobile */
.quick-chat-panel-input button {
min-width: 36px;
min-height: 36px;
}
}

View File

@@ -12,7 +12,7 @@ import {
import ReactMarkdown from "react-markdown";
import remarkGfm from "remark-gfm";
import type { Components } from "react-markdown";
import { Eye, EyeOff, MessageSquare, Send, Square, Wrench, X } from "lucide-react";
import { Eye, EyeOff, MessageSquare, Send, Square, X } from "lucide-react";
import { fetchModels, type Agent, type ModelInfo } from "../api";
import { CustomModelDropdown } from "./CustomModelDropdown";
import { AgentMentionPopup } from "./AgentMentionPopup";
@@ -21,6 +21,7 @@ import { useAgents } from "../hooks/useAgents";
import { FileMentionPopup } from "./FileMentionPopup";
import { useFileMention } from "../hooks/useFileMention";
import { useMobileKeyboard } from "../hooks/useMobileKeyboard";
import { ChatToolCalls } from "./ChatToolCalls";
interface QuickChatFABProps {
projectId?: string;
@@ -81,109 +82,8 @@ function formatModelTagName(modelInfo: ModelInfo | null, parsedSelection: Parsed
.trim();
}
function truncateValue(value: string, maxLength: number): string {
return value.length > maxLength ? `${value.slice(0, maxLength)}` : value;
}
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 renderToolCalls(toolCalls?: ToolCallInfo[], compact = false): ReactNode {
if (!toolCalls || toolCalls.length === 0) {
return null;
}
return (
<div className={`chat-tool-calls${compact ? " chat-tool-calls--compact" : ""}`} data-testid="chat-tool-calls">
<div className="chat-tool-calls-header">
<Wrench size={12} aria-hidden="true" />
<span>Tool calls</span>
</div>
{toolCalls.map((toolCall, index) => {
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>
);
})}
</div>
);
return <ChatToolCalls toolCalls={toolCalls} compact={compact} />;
}
const quickChatMarkdownComponents: Components = {

View File

@@ -549,6 +549,158 @@ describe("ChatView", () => {
expect(preview).toHaveTextContent("path=foo.ts");
});
it("renders grouped summary for multiple completed tool calls and keeps it collapsed", () => {
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,
result: "contents",
status: "completed",
},
{
toolName: "grep",
isError: false,
result: "matches",
status: "completed",
},
],
createdAt: "2026-04-08T00:01:00.000Z",
},
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const group = document.querySelector(".chat-tool-calls-group") as HTMLDetailsElement | null;
expect(group).toBeInTheDocument();
expect(group?.open).toBe(false);
expect(screen.getByText("2 tool calls")).toBeInTheDocument();
expect(screen.getByText("2 completed")).toBeInTheDocument();
});
it("auto-opens grouped tool calls when any tool call is running", () => {
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: "Running",
toolCalls: [
{
toolName: "read",
isError: false,
status: "running",
},
{
toolName: "grep",
isError: false,
result: "done",
status: "completed",
},
],
createdAt: "2026-04-08T00:01:00.000Z",
},
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const group = document.querySelector(".chat-tool-calls-group") as HTMLDetailsElement | null;
expect(group).toBeInTheDocument();
expect(group?.open).toBe(true);
});
it("shows grouped tool-call status breakdown", () => {
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: "grep",
isError: false,
status: "running",
},
{
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 completed")).toBeInTheDocument();
expect(screen.getByText("1 running")).toBeInTheDocument();
expect(screen.getByText("1 error")).toBeInTheDocument();
});
it("expands grouped tool calls to reveal individual tool items", async () => {
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,
result: "contents",
status: "completed",
},
{
toolName: "grep",
isError: false,
result: "matches",
status: "completed",
},
],
createdAt: "2026-04-08T00:01:00.000Z",
},
],
});
render(<ChatView projectId="proj-123" addToast={vi.fn()} />);
const group = document.querySelector(".chat-tool-calls-group") as HTMLDetailsElement;
expect(group.open).toBe(false);
const summary = group.querySelector(".chat-tool-calls-group-summary") as HTMLElement;
await userEvent.click(summary);
expect(group.open).toBe(true);
expect(screen.getByText("read")).toBeInTheDocument();
expect(screen.getByText("grep")).toBeInTheDocument();
});
it("completed tool calls are collapsed by default", () => {
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" },

View File

@@ -995,10 +995,91 @@ describe("QuickChatFAB", () => {
expect(preview).toHaveTextContent("result: contents");
});
it("shows streaming tool calls during generation", async () => {
it("renders grouped summary for multiple tool calls in compact mode", 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"));
await waitFor(() => {
expect(screen.getByText("2 tool calls")).toBeInTheDocument();
});
const toolCallsContainer = document.querySelector(".chat-tool-calls") as HTMLElement | null;
expect(toolCallsContainer).toHaveClass("chat-tool-calls--compact");
expect(screen.getByText("2 completed")).toBeInTheDocument();
});
it("expands grouped tool calls to reveal individual quick chat tool items", 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(() => {
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);
const summary = group.querySelector(".chat-tool-calls-group-summary") as HTMLElement;
fireEvent.click(summary);
expect(group.open).toBe(true);
expect(screen.getByText("read")).toBeInTheDocument();
expect(screen.getByText("grep")).toBeInTheDocument();
});
it("streaming grouped tool calls auto-expand when a tool is running", async () => {
mockStreamChatResponse.mockImplementation((_sessionId, _content, handlers) => {
handlers.onText?.("Still working");
handlers.onToolStart?.({ toolName: "read", args: { path: "foo.ts" } });
handlers.onToolEnd?.({ toolName: "read", isError: false, result: "contents" });
handlers.onToolStart?.({ toolName: "grep", args: { pattern: "foo" } });
return {
close: vi.fn(),
@@ -1019,9 +1100,13 @@ describe("QuickChatFAB", () => {
await waitFor(() => {
const streamingMessage = screen.getByTestId("quick-chat-streaming-message");
expect(within(streamingMessage).getByText("read")).toBeInTheDocument();
expect(within(streamingMessage).getByText("2 tool calls")).toBeInTheDocument();
expect(within(streamingMessage).getByText("1 completed")).toBeInTheDocument();
expect(within(streamingMessage).getByText("1 running")).toBeInTheDocument();
const group = streamingMessage.querySelector(".chat-tool-calls-group") as HTMLDetailsElement | null;
expect(group).toBeTruthy();
expect(group?.open).toBe(true);
expect(streamingMessage.querySelector(".chat-tool-call--running")).toBeTruthy();
expect(streamingMessage.querySelector(".chat-tool-call-preview")).toHaveTextContent("path=foo.ts");
});
});