mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 04:02:18 +01:00
feat(ui): round-7 polish — pulse dot persists while narrating (no Responding icon swap), thinking block flattened to Brain-led tool-row header with railed body inset
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
b3c082735c
commit
1178efda8c
4 files changed
+113
-30
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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 (
|
||||
<div className="tc-enter-status flex items-center gap-2 py-0.5 text-xs text-muted-foreground">
|
||||
{/* 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. */}
|
||||
<span className="flex h-3.5 w-3.5 shrink-0 items-center justify-center">
|
||||
{narrating && NarrateIcon ? (
|
||||
<NarrateIcon
|
||||
className="h-3.5 w-3.5 text-(--status-agent-running)"
|
||||
aria-hidden
|
||||
data-testid="task-chat-narrating-icon"
|
||||
/>
|
||||
) : (
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-2 w-2 animate-pulse rounded-full bg-(--status-agent-running)"
|
||||
/>
|
||||
)}
|
||||
<span
|
||||
aria-hidden
|
||||
className="h-2 w-2 animate-pulse rounded-full bg-(--status-agent-running)"
|
||||
/>
|
||||
</span>
|
||||
{!narrating && ToolIcon ? (
|
||||
<ToolIcon className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
||||
|
||||
@@ -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> = {}): 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(<TaskChatThinkingBlock item={item} />);
|
||||
});
|
||||
}
|
||||
|
||||
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.");
|
||||
});
|
||||
});
|
||||
@@ -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 (
|
||||
<div className="border-l-2 border-border pl-3 text-xs text-muted-foreground">
|
||||
<div className="flex flex-col text-xs text-muted-foreground">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
aria-expanded={open}
|
||||
className="flex items-center gap-1.5 py-0.5 transition-colors hover:text-foreground"
|
||||
className="group/think -mx-1.5 flex min-w-0 items-center gap-2 rounded-sm px-1.5 py-0.5 text-left transition-colors hover:bg-muted/60 hover:text-foreground"
|
||||
>
|
||||
<ChevronRight className={cn("h-3 w-3 shrink-0 transition-transform", open ? "rotate-90" : null)} aria-hidden />
|
||||
{BrainIcon ? <BrainIcon className="h-3.5 w-3.5 shrink-0" aria-hidden /> : null}
|
||||
{item.streaming ? (
|
||||
<span className="shimmer-text shimmer-text-muted font-medium">Thinking…</span>
|
||||
) : (
|
||||
<span className="font-medium">{settledLabel}</span>
|
||||
)}
|
||||
<ChevronRight
|
||||
className={cn(
|
||||
"ml-auto h-3 w-3 shrink-0 opacity-0 transition-[opacity,transform] group-hover/think:opacity-80",
|
||||
open ? "rotate-90 opacity-80" : null,
|
||||
)}
|
||||
aria-hidden
|
||||
/>
|
||||
</button>
|
||||
{open ? (
|
||||
<div className="space-y-1 pb-1 pt-0.5">
|
||||
<div className="ml-2.5 mt-0.5 space-y-1 border-l-2 border-border py-1 pl-2.5">
|
||||
{item.lines.map((line, i) => (
|
||||
<p
|
||||
key={i}
|
||||
|
||||
Reference in new issue
Block a user