diff --git a/ui/src/components/task-chat/TaskChatStatusPill.test.tsx b/ui/src/components/task-chat/TaskChatStatusPill.test.tsx index ee7af9062a0..71f4ddce394 100644 --- a/ui/src/components/task-chat/TaskChatStatusPill.test.tsx +++ b/ui/src/components/task-chat/TaskChatStatusPill.test.tsx @@ -110,12 +110,13 @@ describe("TaskChatStatusPill whimsy", () => { const word = container.querySelector(".tc-shimmer-narrate"); expect(word).not.toBeNull(); expect(word?.textContent).toBe(`${whimsyWord(item.id, 1200)}…`); - // …and the pulse dot swaps to the Responding icon for the duration. - expect(container.querySelector('[data-testid="task-chat-narrating-icon"]')).not.toBeNull(); - expect(container.querySelector(".animate-pulse")).toBeNull(); + // …and the pulse dot stays put — the glint is the sole narration signal + // (PAP-359: no icon swap). + expect(container.querySelector('[data-testid="task-chat-narrating-icon"]')).toBeNull(); + expect(container.querySelector(".animate-pulse")).not.toBeNull(); }); - it("carries no glint or icon swap when not narrating", () => { + it("carries no glint when not narrating, dot always present", () => { render(liveStatus({ tokens: { used: 18240, size: 200000 } })); expect(container.querySelector(".tc-shimmer-narrate")).toBeNull(); expect(container.querySelector('[data-testid="task-chat-narrating-icon"]')).toBeNull(); diff --git a/ui/src/components/task-chat/TaskChatStatusPill.tsx b/ui/src/components/task-chat/TaskChatStatusPill.tsx index a3b126645a6..b49f64569e6 100644 --- a/ui/src/components/task-chat/TaskChatStatusPill.tsx +++ b/ui/src/components/task-chat/TaskChatStatusPill.tsx @@ -67,17 +67,16 @@ export function TaskChatStatusPill({ item, onApprovalDecision, chevronOpen }: Ta if (live) { const liveElapsed = liveElapsedLabel(liveElapsedMs ?? item.elapsedMs); - // Ambient self-talk (PAP-357): while an interstitial streams, nothing new - // appears to read — the gerund holds the line, the word glints - // (tc-shimmer-narrate) and the pulse dot swaps to the Responding icon. + // Ambient self-talk (PAP-357/PAP-359): while an interstitial streams, + // nothing new appears to read — the gerund holds the line and the word + // glints (tc-shimmer-narrate); the pulse dot stays put throughout. const narrating = item.narrating === true; - const NarrateIcon = statusLabelIcon("Responding"); const ToolIcon = item.toolName ? toolTaxonomy(item.toolName).icon : statusLabelIcon(item.label); // Whimsy fills gaps, never replaces signal: only the generic // "Running"/"Working" labels — and the narrating state, whose "Responding" - // is signalled by icon + glint instead of copy — swap for a deterministic + // is signalled by the glint instead of copy — swap for a deterministic // whimsical gerund (seeded by the run-scoped item id, rotating ~10s via // the live tick). const label = narrating || isGenericStatusLabel(item.label) @@ -85,21 +84,13 @@ export function TaskChatStatusPill({ item, onApprovalDecision, chevronOpen }: Ta : item.label; return (
- {/* Fixed-size lead slot: dot ↔ Responding icon swap without the label - moving — no layout shift when narration starts or stops. */} + {/* Fixed-size lead slot keeps the label from moving as tool icons + come and go; the pulse dot renders unconditionally. */} - {narrating && NarrateIcon ? ( - - ) : ( - - )} + {!narrating && ToolIcon ? ( diff --git a/ui/src/components/task-chat/TaskChatThinkingBlock.test.tsx b/ui/src/components/task-chat/TaskChatThinkingBlock.test.tsx new file mode 100644 index 00000000000..62fdeed16ce --- /dev/null +++ b/ui/src/components/task-chat/TaskChatThinkingBlock.test.tsx @@ -0,0 +1,80 @@ +// @vitest-environment jsdom + +import { act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { TaskChatThinkingBlock } from "./TaskChatThinkingBlock"; +import type { TaskChatThinkingItem } from "./task-chat-model"; + +function thinking(overrides: Partial = {}): TaskChatThinkingItem { + return { + id: "run-42:thought-1", + kind: "thinking", + lines: ["First, look at the failing test.", "The mock is stale."], + ...overrides, + }; +} + +describe("TaskChatThinkingBlock (PAP-359 tool-row grammar)", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(() => { + act(() => root.unmount()); + container.remove(); + }); + + function render(item: TaskChatThinkingItem) { + act(() => { + root.render(); + }); + } + + it("leads the header with the Brain icon in the tool-icon slot, no rail on the header", () => { + render(thinking({ collapsed: true, summaryLabel: "Thought for 4s" })); + const header = container.querySelector("button"); + expect(header).not.toBeNull(); + // Brain icon fills the same h-3.5/w-3.5 lead slot as tool-row icons. + const icon = header?.querySelector("svg.lucide-brain"); + expect(icon).not.toBeNull(); + expect(icon?.getAttribute("class")).toContain("h-3.5"); + // The old left rail is gone from the header level. + expect(header?.className).not.toContain("border-l-2"); + expect(container.firstElementChild?.className).not.toContain("border-l-2"); + expect(container.textContent).toContain("Thought for 4s"); + }); + + it("streams open with the shimmering Thinking… label and rails only the body", () => { + render(thinking({ streaming: true })); + const shimmer = container.querySelector(".shimmer-text"); + expect(shimmer?.textContent).toBe("Thinking…"); + // Body is expanded and keeps the recessed inset rail. + expect(container.textContent).toContain("First, look at the failing test."); + const body = container.querySelector("div.border-l-2"); + expect(body).not.toBeNull(); + expect(body?.contains(container.querySelector("button"))).toBe(false); + }); + + it("settled history arrives collapsed and toggles on click with aria-expanded", () => { + render(thinking({ collapsed: true, summaryLabel: "Thought for 4s" })); + const header = container.querySelector("button")!; + expect(header.getAttribute("aria-expanded")).toBe("false"); + expect(container.textContent).not.toContain("The mock is stale."); + act(() => { + header.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(header.getAttribute("aria-expanded")).toBe("true"); + expect(container.textContent).toContain("The mock is stale."); + act(() => { + header.dispatchEvent(new MouseEvent("click", { bubbles: true })); + }); + expect(header.getAttribute("aria-expanded")).toBe("false"); + expect(container.textContent).not.toContain("The mock is stale."); + }); +}); diff --git a/ui/src/components/task-chat/TaskChatThinkingBlock.tsx b/ui/src/components/task-chat/TaskChatThinkingBlock.tsx index 4e4d9432ec6..f0a157e2322 100644 --- a/ui/src/components/task-chat/TaskChatThinkingBlock.tsx +++ b/ui/src/components/task-chat/TaskChatThinkingBlock.tsx @@ -2,35 +2,46 @@ import { useState } from "react"; import { cn } from "@/lib/utils"; import { ChevronRight } from "lucide-react"; import type { TaskChatThinkingItem } from "./task-chat-model"; +import { statusLabelIcon } from "./tool-taxonomy"; /** - * Chain-of-thought (ACP agent_thought_chunk) as a flat left-railed block (v7): - * a chevron header whose label shimmers while streaming ("Thinking…") and - * settles to "Thought for Ns", over a recessed pre-wrap body. Streams open; + * Chain-of-thought (ACP agent_thought_chunk) in nested tool-row grammar + * (PAP-359): a Brain-led header at the same level as tool rows — no rail or + * extra indent on the header itself — whose label shimmers while streaming + * ("Thinking…") and settles to "Thought for Ns". Clicking toggles the recessed + * pre-wrap thought body (left-rail inset, like tool detail). Streams open; * settled history arrives collapsed and re-expands on click. No card chrome — * thinking is activity metadata, subordinate to real content. */ export function TaskChatThinkingBlock({ item }: { item: TaskChatThinkingItem }) { const [open, setOpen] = useState(!item.collapsed); const settledLabel = item.summaryLabel ?? "Thought process"; + const BrainIcon = statusLabelIcon("Thinking"); return ( -
+
{open ? ( -
+
{item.lines.map((line, i) => (