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:
@@ -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", () => {
|
||||
|
||||
160
packages/dashboard/app/components/ChatToolCalls.tsx
Normal file
160
packages/dashboard/app/components/ChatToolCalls.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user