diff --git a/components/MessageRow.tsx b/components/MessageRow.tsx index b35ea01..73d8a81 100644 --- a/components/MessageRow.tsx +++ b/components/MessageRow.tsx @@ -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 || ( @@ -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 (
{errorText}
- {message.role === "assistant" && !isStreaming ? : null} + {showAssistantErrorCopyButton ? : null}
); @@ -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 ( @@ -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` }} >
@@ -127,6 +143,53 @@ function Chevron({ open }: { open: boolean }) { ); } +function BottomChevronButton({ onToggle, label }: { onToggle: () => void; label: string }) { + return ( + + ); +} + +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, onToggle: () => void) { + if (event.key !== "Enter" && event.key !== " ") return; + event.preventDefault(); + onToggle(); +} + +function handleToggleMouseUp(event: ReactMouseEvent, onToggle: () => void) { + if (event.button !== 0) return; + if (hasSelectedTextWithin(event.currentTarget)) return; + onToggle(); +} + +function handleToggleTouchEnd(_event: ReactTouchEvent, onToggle: () => void) { + onToggle(); +} + // ── Main export ────────────────────────────────────────────────────────────── export function ToolCallPill({ name, args, status, result, resultError, toolCallId, subagentStore, isPinned, onPin, onUnpin }: ToolCallPillProps) { @@ -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 ( -
- + {status === "running" && running...} +
{hasContent && ( -
+
handleToggleMouseUp(event, toggleOpen)} + > {args && !isRead && !isGateway && ( -
+
{(() => { if (isEdit) { try { @@ -230,14 +301,17 @@ export function ToolCallPill({ name, args, status, result, resultError, toolCall
)} {result && !isEdit && ( -
+
Result -
 8 ? "max-h-[10rem] overflow-y-auto scrollbar-hide" : "overflow-hidden"}`}>{result}
+
{result}
)}
)} + {hasContent && open && ( + + )}
); } @@ -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(() => { @@ -311,11 +387,12 @@ function SpawnPill({ }, [isPinned, onPin, onUnpin, toolCallId, childSessionKey, task, model]), { disabled: !hasFeed } ); + const toggleOpen = () => setOpen((v) => !v); return (
{/* Swipe action indicator (behind content) */} @@ -335,23 +412,27 @@ function SpawnPill({ transition: animating ? "transform 200ms ease-out" : "none", }} > - - +
+ {hasFeed && ( )} diff --git a/next-env.d.ts b/next-env.d.ts index 9edff1c..c4b7818 100644 --- a/next-env.d.ts +++ b/next-env.d.ts @@ -1,6 +1,6 @@ /// /// -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. diff --git a/tests/MessageRow.test.tsx b/tests/MessageRow.test.tsx index 018d77b..03c7ad6 100644 --- a/tests/MessageRow.test.tsx +++ b/tests/MessageRow.test.tsx @@ -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(); + 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(); + expect(screen.getByRole("button", { name: /copy contents/i })).toBeInTheDocument(); + }); + it("slides in thinking blocks when they first appear", async () => { const message: Message = { role: "assistant", diff --git a/tests/ToolCallPill.test.tsx b/tests/ToolCallPill.test.tsx new file mode 100644 index 0000000..cb196e4 --- /dev/null +++ b/tests/ToolCallPill.test.tsx @@ -0,0 +1,67 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { fireEvent, render, screen } from "@testing-library/react"; +import { ToolCallPill } from "@mc/components/ToolCallPill"; +import { findSlideGrid } from "./utils/zenDom"; + +describe("ToolCallPill", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("collapses on mouse up without breaking text selection", () => { + const getSelectionMock = vi.spyOn(window, "getSelection"); + getSelectionMock.mockReturnValue(null); + + render( + + ); + + const header = screen.getByRole("button"); + const resultLabel = screen.getByText("Result"); + const slideGrid = findSlideGrid(resultLabel); + + expect(slideGrid).not.toBeNull(); + expect(slideGrid).toHaveStyle({ gridTemplateRows: "0fr" }); + + fireEvent.mouseUp(header, { button: 0 }); + expect(slideGrid).toHaveStyle({ gridTemplateRows: "1fr" }); + + const externalNode = document.createTextNode("external selection"); + document.body.appendChild(externalNode); + + getSelectionMock.mockReturnValue({ + isCollapsed: false, + rangeCount: 1, + getRangeAt: () => ({ commonAncestorContainer: externalNode }) as unknown as Range, + toString: () => "external selection", + } as unknown as Selection); + + fireEvent.mouseUp(header, { button: 0 }); + expect(slideGrid).toHaveStyle({ gridTemplateRows: "0fr" }); + + fireEvent.mouseUp(header, { button: 0 }); + expect(slideGrid).toHaveStyle({ gridTemplateRows: "1fr" }); + + getSelectionMock.mockReturnValue({ + isCollapsed: false, + rangeCount: 1, + getRangeAt: () => ({ commonAncestorContainer: resultLabel }) as unknown as Range, + toString: () => "line one", + } as unknown as Selection); + + const resultContent = screen.getByText((_, node) => node?.textContent === "line one\nline two" && node.tagName === "PRE"); + fireEvent.mouseUp(resultContent, { button: 0 }); + expect(slideGrid).toHaveStyle({ gridTemplateRows: "1fr" }); + + getSelectionMock.mockReturnValue({ + isCollapsed: true, + toString: () => "", + } as unknown as Selection); + + fireEvent.mouseUp(resultContent, { button: 0 }); + expect(slideGrid).toHaveStyle({ gridTemplateRows: "0fr" }); + }); +});