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 (
-