From 263b53352762636d854e4589c103b36631b64873 Mon Sep 17 00:00:00 2001 From: Krzysztof Wende Date: Mon, 23 Mar 2026 20:02:58 +0100 Subject: [PATCH 1/4] Refine tool pill expansion and selection behavior --- components/SubagentActivityFeed.tsx | 2 +- components/ToolCallPill.tsx | 126 ++++++++++++++++++++-------- tests/ToolCallPill.test.tsx | 49 +++++++++++ 3 files changed, 142 insertions(+), 35 deletions(-) create mode 100644 tests/ToolCallPill.test.tsx diff --git a/components/SubagentActivityFeed.tsx b/components/SubagentActivityFeed.tsx index 80b5d5f..3d75644 100644 --- a/components/SubagentActivityFeed.tsx +++ b/components/SubagentActivityFeed.tsx @@ -48,7 +48,7 @@ export function SubagentActivityFeed({ getEntries, storeVersion }: SubagentActiv
{entries.length === 0 && status === "active" && (
diff --git a/components/ToolCallPill.tsx b/components/ToolCallPill.tsx index 7ce4527..5f05938 100644 --- a/components/ToolCallPill.tsx +++ b/components/ToolCallPill.tsx @@ -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"; @@ -11,12 +16,8 @@ import { isReadTool, isGatewayTool, 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_SHADOW, } from "@mc/lib/constants"; import { useSwipeAction } from "@mc/hooks/useSwipeAction"; @@ -48,23 +49,22 @@ type ToolBubbleStyle = CSSProperties & { "--border"?: string; }; -function getToolBubbleStyle(resultError?: boolean): ToolBubbleStyle { +function getToolBubbleStyle(expanded: boolean): ToolBubbleStyle { return { - borderRadius: `${SQUIRCLE_RADIUS}px`, - background: TOOL_CALL_BUBBLE_BG, - border: resultError ? `1px solid ${TOOL_CALL_BUBBLE_BORDER_ERROR}` : "none", + background: "transparent", + borderTop: expanded ? "1px solid #E0E0E0" : "none", + borderBottom: expanded ? "1px solid #E0E0E0" : "none", 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 ( @@ -118,8 +118,8 @@ function ToolIcon({ icon }: { icon: string }) { function Chevron({ open }: { open: boolean }) { return ( @@ -127,6 +127,46 @@ function Chevron({ open }: { open: boolean }) { ); } +function BottomChevronButton({ onToggle, label }: { onToggle: () => void; label: string }) { + return ( + + ); +} + +function hasSelectedText() { + if (typeof window === "undefined" || typeof window.getSelection !== "function") return false; + const selection = window.getSelection(); + return !!selection && !selection.isCollapsed && selection.toString().trim().length > 0; +} + +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 (hasSelectedText()) 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 +199,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 +278,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 && ( + + )}
); } @@ -311,11 +362,12 @@ function SpawnPill({ }, [isPinned, onPin, onUnpin, toolCallId, childSessionKey, task, model]), { disabled: !hasFeed } ); + const toggleOpen = () => setOpen((v) => !v); return (
{/* Swipe action indicator (behind content) */} @@ -329,33 +381,39 @@ function SpawnPill({ )} {/* Sliding content */}
- +
{hasFeed && ( )} + {open && !isPinned && ( + + )}
); diff --git a/tests/ToolCallPill.test.tsx b/tests/ToolCallPill.test.tsx new file mode 100644 index 0000000..ae7265c --- /dev/null +++ b/tests/ToolCallPill.test.tsx @@ -0,0 +1,49 @@ +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" }); + + getSelectionMock.mockReturnValue({ + isCollapsed: false, + toString: () => "line one", + } as unknown as Selection); + + const resultContent = screen.getByText((content, 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" }); + }); +}); From 89f03c49dc6d2c8ae1cfbf030e334df5f8917bf9 Mon Sep 17 00:00:00 2001 From: Krzysztof Wende Date: Mon, 23 Mar 2026 20:06:56 +0100 Subject: [PATCH 2/4] Fix unused parameter in tool pill test --- tests/ToolCallPill.test.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/ToolCallPill.test.tsx b/tests/ToolCallPill.test.tsx index ae7265c..6c93afc 100644 --- a/tests/ToolCallPill.test.tsx +++ b/tests/ToolCallPill.test.tsx @@ -34,7 +34,7 @@ describe("ToolCallPill", () => { toString: () => "line one", } as unknown as Selection); - const resultContent = screen.getByText((content, node) => node?.textContent === "line one\nline two" && node.tagName === "PRE"); + const resultContent = screen.getByText((_, node) => node?.textContent === "line one\nline two" && node.tagName === "PRE"); fireEvent.mouseUp(resultContent, { button: 0 }); expect(slideGrid).toHaveStyle({ gridTemplateRows: "1fr" }); From 5bd36635cb6370ddefcb40bb60ea4d14803c7cb8 Mon Sep 17 00:00:00 2001 From: Krzysztof Wende Date: Mon, 23 Mar 2026 20:13:06 +0100 Subject: [PATCH 3/4] Fix tool pill selection handling and pinned toggle state --- components/ToolCallPill.tsx | 46 ++++++++++++++++++++++--------------- tests/ToolCallPill.test.tsx | 18 +++++++++++++++ 2 files changed, 46 insertions(+), 18 deletions(-) diff --git a/components/ToolCallPill.tsx b/components/ToolCallPill.tsx index 5f05938..cb1e6fb 100644 --- a/components/ToolCallPill.tsx +++ b/components/ToolCallPill.tsx @@ -52,13 +52,14 @@ type ToolBubbleStyle = CSSProperties & { function getToolBubbleStyle(expanded: boolean): ToolBubbleStyle { return { background: "transparent", - borderTop: expanded ? "1px solid #E0E0E0" : "none", - borderBottom: expanded ? "1px solid #E0E0E0" : "none", + borderTop: expanded ? "1px solid var(--border)" : "none", + borderBottom: expanded ? "1px solid var(--border)" : "none", 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": "var(--border)", }; } @@ -134,7 +135,7 @@ function BottomChevronButton({ onToggle, label }: { onToggle: () => void; label: 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: "#FAFAFA", bottom: "-0.625rem" }} + style={{ background: "var(--background)", bottom: "-0.625rem" }} > void; label: ); } -function hasSelectedText() { +function hasSelectedTextWithin(target: HTMLElement) { if (typeof window === "undefined" || typeof window.getSelection !== "function") return false; const selection = window.getSelection(); - return !!selection && !selection.isCollapsed && selection.toString().trim().length > 0; + 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) { @@ -159,7 +167,7 @@ function handleToggleKeyDown(event: ReactKeyboardEvent, onToggle: ( function handleToggleMouseUp(event: ReactMouseEvent, onToggle: () => void) { if (event.button !== 0) return; - if (hasSelectedText()) return; + if (hasSelectedTextWithin(event.currentTarget)) return; onToggle(); } @@ -345,6 +353,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(() => { @@ -366,8 +376,8 @@ function SpawnPill({ return (
{/* Swipe action indicator (behind content) */} @@ -387,31 +397,31 @@ function SpawnPill({ }} >
handleToggleKeyDown(event, toggleOpen)} - onMouseUp={(event) => handleToggleMouseUp(event, toggleOpen)} - onTouchEnd={(event) => handleToggleTouchEnd(event, toggleOpen)} - className="w-full cursor-pointer px-4 py-1.5 text-left text-xs font-normal select-text" + 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-1.5 text-left text-xs font-normal select-text ${canToggle ? "cursor-pointer" : "cursor-default"}`} >
{!status || status === "success" ? : null} {task || "spawn agent"} - + {isPinned && }
{model && (
{model}
)}
- + {hasFeed && ( )} - {open && !isPinned && ( + {visibleOpen && ( )}
diff --git a/tests/ToolCallPill.test.tsx b/tests/ToolCallPill.test.tsx index 6c93afc..cb196e4 100644 --- a/tests/ToolCallPill.test.tsx +++ b/tests/ToolCallPill.test.tsx @@ -29,8 +29,26 @@ describe("ToolCallPill", () => { 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); From aa2b451ee50bf61ae20bed460efcbc0d7a40a5d1 Mon Sep 17 00:00:00 2001 From: Krzysztof Wende Date: Mon, 23 Mar 2026 20:43:39 +0100 Subject: [PATCH 4/4] Restore full-width assistant messages and rounded subagent pill --- components/MessageRow.tsx | 18 ++++++------- components/SubagentActivityFeed.tsx | 2 +- components/ToolCallPill.tsx | 41 +++++++++++++++++++---------- next-env.d.ts | 2 +- tests/MessageRow.test.tsx | 26 ++++++++++++++++++ 5 files changed, 63 insertions(+), 26 deletions(-) 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` }} >
{entries.length === 0 && status === "active" && (
diff --git a/components/ToolCallPill.tsx b/components/ToolCallPill.tsx index cb1e6fb..37f3906 100644 --- a/components/ToolCallPill.tsx +++ b/components/ToolCallPill.tsx @@ -16,6 +16,10 @@ import { isReadTool, isGatewayTool, SPAWN_TOOL_NAME, + SQUIRCLE_RADIUS, + TOOL_CALL_BUBBLE_BG, + TOOL_CALL_BUBBLE_BORDER, + TOOL_CALL_BUBBLE_BORDER_ERROR, TOOL_CALL_BUBBLE_TEXT, TOOL_CALL_BUBBLE_MUTED, TOOL_CALL_BUBBLE_SHADOW, @@ -46,20 +50,31 @@ type ToolBubbleStyle = CSSProperties & { "--foreground"?: string; "--card-foreground"?: string; "--muted-foreground"?: string; - "--border"?: string; }; function getToolBubbleStyle(expanded: boolean): ToolBubbleStyle { return { background: "transparent", - borderTop: expanded ? "1px solid var(--border)" : "none", - borderBottom: expanded ? "1px solid var(--border)" : "none", + 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: `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": "var(--border)", }; } @@ -219,7 +234,7 @@ export function ToolCallPill({ name, args, status, result, resultError, toolCall 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-1.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"}`} + 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 ? : } {isEdit ? <>edit {display.label} : isRead ? <>read {display.label} : {display.label}} @@ -233,7 +248,7 @@ export function ToolCallPill({ name, args, status, result, resultError, toolCall onMouseUp={(event) => handleToggleMouseUp(event, toggleOpen)} > {args && !isRead && !isGateway && ( -
+
{(() => { if (isEdit) { try { @@ -286,7 +301,7 @@ export function ToolCallPill({ name, args, status, result, resultError, toolCall
)} {result && !isEdit && ( -
+
Result
{result}
@@ -376,8 +391,8 @@ function SpawnPill({ return (
{/* Swipe action indicator (behind content) */} @@ -391,6 +406,7 @@ function SpawnPill({ )} {/* Sliding content */}
handleToggleKeyDown(event, toggleOpen) : undefined} onMouseUp={canToggle ? (event) => handleToggleMouseUp(event, toggleOpen) : undefined} onTouchEnd={canToggle ? (event) => handleToggleTouchEnd(event, toggleOpen) : undefined} - className={`w-full px-4 py-1.5 text-left text-xs font-normal select-text ${canToggle ? "cursor-pointer" : "cursor-default"}`} + className={`w-full px-4 py-2.5 text-left text-xs font-normal select-text ${canToggle ? "cursor-pointer" : "cursor-default"}`} >
@@ -413,7 +429,7 @@ function SpawnPill({ {isPinned && }
{model && ( -
{model}
+
{model}
)}
@@ -421,9 +437,6 @@ function SpawnPill({ )} - {visibleOpen && ( - - )}
); 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",