Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 8 additions & 10 deletions components/MessageRow.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -748,6 +748,10 @@ export function MessageRow({
const assistantCopyText = message.role === "assistant" ? getCopyableAssistantText(message) : "";
const assistantDurationText = getAssistantDurationText(message);
const showAssistantCopyButton = !isStreaming && !!assistantCopyText;
const isErrorContextMessage = message.isContext
|| message.stopReason === STOP_REASON_INJECTED
|| text.startsWith(SYSTEM_PREFIX)
|| text.startsWith(SYSTEM_MESSAGE_PREFIX);
const hasStructuredCommandResponse =
!!message.isCommandResponse &&
(!!message.reasoning || (
Expand Down Expand Up @@ -816,11 +820,12 @@ export function MessageRow({

if (message.isError && (message.role === "system" || message.role === "assistant")) {
const errorText = text || "Unknown error";
const showAssistantErrorCopyButton = message.role === "assistant" && !isStreaming && !isErrorContextMessage;
return (
<div className="flex justify-center py-2">
<div className="max-w-[85%] rounded-lg border border-destructive/30 bg-destructive/5 px-4 py-2 text-xs leading-[1.75rem] text-destructive-foreground whitespace-pre-wrap break-words">
<div>{errorText}</div>
{message.role === "assistant" && !isStreaming ? <AssistantCopyButton text={assistantCopyText || errorText} durationText={assistantDurationText} /> : null}
{showAssistantErrorCopyButton ? <AssistantCopyButton text={assistantCopyText || errorText} durationText={assistantDurationText} /> : null}
</div>
</div>
);
Expand Down Expand Up @@ -975,17 +980,10 @@ export function MessageRow({

const zenCollapsible = !isUser && zenMode && zenGroupCollapsible;
const streamingLayoutActive = isStreaming || freezeStreamingLayout;
const hasWideAssistantBlock = assistantBlocks.some((block) => block.width && block.width !== "bubble");
const renderAssistantBlock = (block: AssistantBlock) => {
let widthClass = "self-start w-fit max-w-full min-w-0";
if (block.width === "chat") {
if (block.width === "chat" || block.width === "message") {
widthClass = "w-full min-w-0";
} else if (block.width === "message") {
widthClass = hasWideAssistantBlock
? "w-[85%] md:w-[75%] max-w-full min-w-0"
: "w-full min-w-0";
} else if (hasWideAssistantBlock) {
widthClass = "self-start w-fit max-w-[85%] md:max-w-[75%] min-w-0";
}

return (
Expand All @@ -1007,7 +1005,7 @@ export function MessageRow({
style={collapsedZenSibling ? { marginBottom: "-0.75rem", transition: `margin-bottom ${ZEN_SLIDE_MS}ms ease-out` } : { transition: `margin-bottom ${ZEN_SLIDE_MS}ms ease-out` }}
>
<div
className={`${isUser ? "max-w-[85%] md:max-w-[75%]" : hasWideAssistantBlock ? "w-full" : "max-w-[85%] md:max-w-[75%]"} min-w-0 ${isUser ? "px-4 py-2.5 text-primary-foreground" : ""}`}
className={`${isUser ? "max-w-[85%] md:max-w-[75%]" : "w-full"} min-w-0 ${isUser ? "px-4 py-2.5 text-primary-foreground" : ""}`}
style={isUser ? {
borderRadius: SQUIRCLE_RADIUS,
background: "oklch(from var(--primary) l c h / 0.85)",
Expand Down
145 changes: 113 additions & 32 deletions components/ToolCallPill.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,12 @@
"use client";

import { useCallback, useEffect, useState } from "react";
import type { CSSProperties } from "react";
import type {
CSSProperties,
KeyboardEvent as ReactKeyboardEvent,
MouseEvent as ReactMouseEvent,
TouchEvent as ReactTouchEvent,
} from "react";
import { getToolDisplay, parseArgs } from "@mc/lib/toolDisplay";
import { SubagentActivityFeed } from "@mc/components/SubagentActivityFeed";
import { SlideContent } from "@mc/components/SlideContent";
Expand All @@ -13,10 +18,10 @@ import {
SPAWN_TOOL_NAME,
SQUIRCLE_RADIUS,
TOOL_CALL_BUBBLE_BG,
TOOL_CALL_BUBBLE_TEXT,
TOOL_CALL_BUBBLE_MUTED,
TOOL_CALL_BUBBLE_BORDER,
TOOL_CALL_BUBBLE_BORDER_ERROR,
TOOL_CALL_BUBBLE_TEXT,
TOOL_CALL_BUBBLE_MUTED,
TOOL_CALL_BUBBLE_SHADOW,
} from "@mc/lib/constants";
import { useSwipeAction } from "@mc/hooks/useSwipeAction";
Expand Down Expand Up @@ -45,26 +50,37 @@ type ToolBubbleStyle = CSSProperties & {
"--foreground"?: string;
"--card-foreground"?: string;
"--muted-foreground"?: string;
"--border"?: string;
};

function getToolBubbleStyle(resultError?: boolean): ToolBubbleStyle {
function getToolBubbleStyle(expanded: boolean): ToolBubbleStyle {
return {
background: "transparent",
borderTop: `1px solid ${expanded ? "var(--border)" : "transparent"}`,
borderBottom: `1px solid ${expanded ? "var(--border)" : "transparent"}`,
boxShadow: TOOL_CALL_BUBBLE_SHADOW,
color: TOOL_CALL_BUBBLE_TEXT,
"--foreground": TOOL_CALL_BUBBLE_TEXT,
"--card-foreground": TOOL_CALL_BUBBLE_TEXT,
"--muted-foreground": TOOL_CALL_BUBBLE_MUTED,
};
}

function getSpawnBubbleStyle(resultError?: boolean): ToolBubbleStyle {
return {
borderRadius: `${SQUIRCLE_RADIUS}px`,
background: TOOL_CALL_BUBBLE_BG,
border: resultError ? `1px solid ${TOOL_CALL_BUBBLE_BORDER_ERROR}` : "none",
border: `1px solid ${resultError ? TOOL_CALL_BUBBLE_BORDER_ERROR : TOOL_CALL_BUBBLE_BORDER}`,
boxShadow: TOOL_CALL_BUBBLE_SHADOW,
color: TOOL_CALL_BUBBLE_TEXT,
"--foreground": TOOL_CALL_BUBBLE_TEXT,
"--card-foreground": TOOL_CALL_BUBBLE_TEXT,
"--muted-foreground": TOOL_CALL_BUBBLE_MUTED,
"--border": TOOL_CALL_BUBBLE_BORDER,
};
}

// ── Shared icon helpers ──────────────────────────────────────────────────────

const ICON_CLS = "inline-block mr-1.5 align-[-1px] shrink-0 opacity-35";
const ICON_CLS = "relative top-[1px] inline-block mr-1.5 shrink-0 opacity-35";

function StatusIcon({ status, resultError }: { status?: string; resultError?: boolean }) {
if (status === "running") return (
Expand Down Expand Up @@ -118,15 +134,62 @@ function ToolIcon({ icon }: { icon: string }) {
function Chevron({ open }: { open: boolean }) {
return (
<svg
width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"
className="ml-auto shrink-0 opacity-25 transition-transform duration-200"
width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.25" strokeLinecap="round" strokeLinejoin="round"
className="ml-1 shrink-0 opacity-35 transition-transform duration-200"
style={{ transform: open ? "rotate(0deg)" : "rotate(-90deg)" }}
>
<path d="m6 9 6 6 6-6" />
</svg>
);
}

function BottomChevronButton({ onToggle, label }: { onToggle: () => void; label: string }) {
return (
<button
type="button"
aria-label={label}
onClick={onToggle}
className="absolute left-1/2 z-10 flex h-5 w-16 -translate-x-1/2 cursor-pointer items-center justify-center text-foreground/45"
style={{ background: "var(--background)", bottom: "-0.625rem" }}
>
<svg
width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round"
>
<path d="m18 15-6-6-6 6" />
</svg>
</button>
);
}

function hasSelectedTextWithin(target: HTMLElement) {
if (typeof window === "undefined" || typeof window.getSelection !== "function") return false;
const selection = window.getSelection();
if (!selection || selection.isCollapsed || selection.toString().trim().length === 0) return false;

for (let i = 0; i < selection.rangeCount; i += 1) {
const range = selection.getRangeAt(i);
if (target.contains(range.commonAncestorContainer)) return true;
}

return false;
}

function handleToggleKeyDown(event: ReactKeyboardEvent<HTMLElement>, onToggle: () => void) {
if (event.key !== "Enter" && event.key !== " ") return;
event.preventDefault();
onToggle();
}

function handleToggleMouseUp(event: ReactMouseEvent<HTMLElement>, onToggle: () => void) {
if (event.button !== 0) return;
if (hasSelectedTextWithin(event.currentTarget)) return;
onToggle();
}

function handleToggleTouchEnd(_event: ReactTouchEvent<HTMLElement>, onToggle: () => void) {
onToggle();
}

// ── Main export ──────────────────────────────────────────────────────────────

export function ToolCallPill({ name, args, status, result, resultError, toolCallId, subagentStore, isPinned, onPin, onUnpin }: ToolCallPillProps) {
Expand Down Expand Up @@ -159,25 +222,33 @@ export function ToolCallPill({ name, args, status, result, resultError, toolCall
const hasVisibleResult = !!(result && !isEdit);
const hasContent = hasVisibleArgs || hasVisibleResult;
const hasStatusIcon = status === "running" || resultError;
const bubbleStyle = getToolBubbleStyle(resultError);
const bubbleStyle = getToolBubbleStyle(open);
const toggleOpen = () => setOpen((v) => !v);

return (
<div className="w-fit max-w-full overflow-hidden font-mono" style={bubbleStyle}>
<button
type="button"
onClick={hasContent ? () => setOpen((v) => !v) : undefined}
className={`w-full rounded-[inherit] text-left px-4 py-2.5 text-xs font-normal overflow-hidden text-ellipsis whitespace-nowrap max-w-full flex items-center ${hasContent ? "cursor-pointer" : "cursor-default"}`}
<div className={`relative w-fit max-w-full overflow-visible font-mono ${open ? "mb-5" : ""}`} style={bubbleStyle}>
<div
role={hasContent ? "button" : undefined}
tabIndex={hasContent ? 0 : undefined}
aria-expanded={hasContent ? open : undefined}
onKeyDown={hasContent ? (event) => handleToggleKeyDown(event, toggleOpen) : undefined}
onMouseUp={hasContent ? (event) => handleToggleMouseUp(event, toggleOpen) : undefined}
onTouchEnd={hasContent ? (event) => handleToggleTouchEnd(event, toggleOpen) : undefined}
className={`w-full px-4 py-2.5 text-left text-xs font-normal overflow-hidden text-ellipsis whitespace-nowrap max-w-full flex items-center select-text ${hasContent ? "cursor-pointer" : "cursor-default"}`}
>
{hasStatusIcon ? <StatusIcon status={status} resultError={resultError} /> : <ToolIcon icon={display.icon} />}
Comment thread
sourcery-ai[bot] marked this conversation as resolved.
{isEdit ? <><span className="font-medium opacity-70">edit</span>&nbsp;<span className="truncate">{display.label}</span></> : isRead ? <><span className="font-medium opacity-70">read</span>&nbsp;<span className="truncate">{display.label}</span></> : <span className="truncate">{display.label}</span>}
{status === "running" && <span className="ml-1.5 opacity-45 shrink-0">running...</span>}
{hasContent && <Chevron open={open} />}
</button>
{status === "running" && <span className="ml-1.5 opacity-45 shrink-0">running...</span>}
</div>
{hasContent && (
<SlideContent open={open}>
<div className="overflow-hidden text-xs leading-[1.5]">
<div
className="overflow-hidden text-xs leading-[1.5] select-text"
onMouseUp={(event) => handleToggleMouseUp(event, toggleOpen)}
>
{args && !isRead && !isGateway && (
<div className="border-t border-border px-4 py-2.5">
<div className="px-4 py-2.5">
{(() => {
if (isEdit) {
try {
Expand Down Expand Up @@ -230,14 +301,17 @@ export function ToolCallPill({ name, args, status, result, resultError, toolCall
</div>
)}
{result && !isEdit && (
<div className="border-t border-border px-4 py-2.5">
<div className="px-4 py-2.5">
<span className="text-2xs font-normal opacity-45">Result</span>
<pre className={`mt-1 whitespace-pre-wrap break-words ${result.split("\n").length > 8 ? "max-h-[10rem] overflow-y-auto scrollbar-hide" : "overflow-hidden"}`}>{result}</pre>
<pre className="mt-1 whitespace-pre-wrap break-words overflow-visible">{result}</pre>
</div>
)}
</div>
</SlideContent>
)}
{hasContent && open && (
<BottomChevronButton onToggle={toggleOpen} label="Collapse tool call" />
)}
</div>
);
}
Expand Down Expand Up @@ -294,6 +368,8 @@ function SpawnPill({
}, [toolCallId, childSessionKey, subagentStore]);

const hasFeed = !!subagentStore;
const canToggle = !isPinned;
const visibleOpen = open && canToggle;

// Animate open on mount for streaming case only (not history)
useEffect(() => {
Expand All @@ -311,11 +387,12 @@ function SpawnPill({
}, [isPinned, onPin, onUnpin, toolCallId, childSessionKey, task, model]),
{ disabled: !hasFeed }
);
const toggleOpen = () => setOpen((v) => !v);

return (
<div
className="w-full overflow-hidden relative font-mono"
style={getToolBubbleStyle(resultError)}
className="relative w-full overflow-hidden font-mono"
style={getSpawnBubbleStyle(resultError)}
{...handlers}
>
{/* Swipe action indicator (behind content) */}
Expand All @@ -335,23 +412,27 @@ function SpawnPill({
transition: animating ? "transform 200ms ease-out" : "none",
}}
>
<button
type="button"
onClick={() => setOpen((v) => !v)}
className="w-full rounded-[inherit] cursor-pointer px-4 py-2.5 text-left text-xs font-normal"
<div
role={canToggle ? "button" : undefined}
tabIndex={canToggle ? 0 : undefined}
aria-expanded={canToggle ? visibleOpen : undefined}
onKeyDown={canToggle ? (event) => handleToggleKeyDown(event, toggleOpen) : undefined}
onMouseUp={canToggle ? (event) => handleToggleMouseUp(event, toggleOpen) : undefined}
onTouchEnd={canToggle ? (event) => handleToggleTouchEnd(event, toggleOpen) : undefined}
className={`w-full px-4 py-2.5 text-left text-xs font-normal select-text ${canToggle ? "cursor-pointer" : "cursor-default"}`}
>
<div className="flex items-center gap-1">
<StatusIcon status={status} resultError={resultError} />
{!status || status === "success" ? <ToolIcon icon="robot" /> : null}
<span className="truncate">{task || "spawn agent"}</span>
<Chevron open={visibleOpen} />
{isPinned && <PinIcon pinned />}
<Chevron open={open && !isPinned} />
</div>
{model && (
<div className="text-2xs font-normal mt-0.5 ml-[18px] opacity-55">{model}</div>
<div className="text-2xs text-muted-foreground/40 font-normal mt-0.5 ml-[18px]">{model}</div>
)}
</button>
<SlideContent open={open && !isPinned}>
</div>
<SlideContent open={visibleOpen}>
{hasFeed && (
<SubagentActivityFeed getEntries={getEntries} storeVersion={subagentStore.versionRef} />
)}
Expand Down
2 changes: 1 addition & 1 deletion next-env.d.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
import "./.next/types/routes.d.ts";
import "./.next/dev/types/routes.d.ts";

// NOTE: This file should not be edited
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
26 changes: 26 additions & 0 deletions tests/MessageRow.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -287,6 +287,32 @@ describe("MessageRow", () => {
expect(screen.queryByRole("button", { name: /copy contents/i })).not.toBeInTheDocument();
});

it("hides the copy button for assistant error context messages", () => {
const message: Message = {
role: "assistant",
content: [{ type: "text", text: "System: [error] Context sync failed" }],
id: "test-copy-error-context",
isError: true,
stopReason: "injected",
};

render(<MessageRow message={message} isStreaming={false} />);
expect(screen.getByText("System: [error] Context sync failed")).toBeInTheDocument();
expect(screen.queryByRole("button", { name: /copy contents/i })).not.toBeInTheDocument();
});

it("shows the copy button for non-context assistant error messages", () => {
const message: Message = {
role: "assistant",
content: [{ type: "text", text: "Network request failed" }],
id: "test-copy-error-assistant",
isError: true,
};

render(<MessageRow message={message} isStreaming={false} />);
expect(screen.getByRole("button", { name: /copy contents/i })).toBeInTheDocument();
});

it("slides in thinking blocks when they first appear", async () => {
const message: Message = {
role: "assistant",
Expand Down
Loading