Files
PaperClipAI/ui/src/components/task-chat/TaskChatStatusPill.test.tsx
T
DottaandPaperclip eb9f954bae fix(ui): stabilize steered chat activity presentation (#13246)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The task chat shows live agent work and user steering.
> - Steered messages showed a second timestamp that did not match normal
chat messages.
> - Folded live work also changed height as new commands arrived.
> - Status carets and dots did not use the same vertical alignment.
> - This pull request gives these states one stable presentation.
> - The benefit is a task chat that is easier to read while an agent
works.

## Linked Issues or Issue Description

**What happened?**

Steered chat messages showed a separate queued or steered timestamp.
Folded live activity could grow and shrink. Some carets and status dots
did not align vertically.

**Expected behavior**

Steered messages use the normal timestamp. Folded live activity shows
one latest line until the user opens it. Carets and dots use the same
vertical center.

**Steps to reproduce**

1. Open a task chat with a running agent.
2. Steer the chat and inspect the message timestamp.
3. Keep live activity folded while new commands arrive.
4. Compare the caret with the status dot in the Reconnecting state.

**Paperclip version or commit**

Current `master` before this change.

**Deployment mode**

Local development mode.

## What Changed

- Use the normal timestamp for steered chat messages.
- Keep folded live activity to one line and replace it with the latest
activity.
- Keep the full activity timeline available after explicit expansion.
- Vertically align carets and status dots, including Reconnecting.
- Add focused component tests and Storybook review states.

## Verification

- `pnpm --filter @paperclipai/ui exec vitest run
src/components/task-chat/TaskChatRunnerTurn.test.tsx
src/components/task-chat/TaskChatStatusPill.test.tsx
src/components/task-chat/task-chat-adapter.test.ts` (63 tests passed)
- `pnpm --filter @paperclipai/ui typecheck`
- `pnpm check:token-gates`
- `pnpm --filter @paperclipai/ui build-storybook`

## Risks

- Low risk. The change is limited to task chat presentation and its
tests.
- A live activity item can replace the folded text. The expanded
timeline keeps the full history.

> This fix does not overlap with planned work in `ROADMAP.md`.

## Model Used

- OpenAI Codex. The runtime did not expose the exact model ID or context
window. The agent used reasoning, tool use, and code execution.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-09-11 21:26:44 -05:00

322 lines
14 KiB
TypeScript

// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { lineScrollOffset, TaskChatStatusPill } from "./TaskChatStatusPill";
import { whimsyWord, WHIMSY_ROTATE_MS } from "./status-whimsy";
import type { TaskChatStatusItem } from "./task-chat-model";
function liveStatus(overrides: Partial<TaskChatStatusItem> = {}): TaskChatStatusItem {
return {
id: "run-42:status",
kind: "status",
status: "running",
label: "Running",
startedAtMs: Date.now(),
...overrides,
};
}
describe("TaskChatStatusPill whimsy", () => {
let container: HTMLDivElement;
let root: Root;
beforeEach(() => {
vi.useFakeTimers();
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
act(() => root.unmount());
container.remove();
document.documentElement.style.removeProperty("--motion-interstitial-dwell");
document.documentElement.style.removeProperty("--motion-line-scroll");
vi.useRealTimers();
});
const render = (item: TaskChatStatusItem) => {
act(() => {
root.render(<TaskChatStatusPill item={item} />);
});
};
it("swaps the generic Running label for a deterministic whimsical word", () => {
const item = liveStatus();
render(item);
const expected = whimsyWord(item.id, 0);
expect(container.textContent).toContain(`${expected}…`);
expect(container.textContent).not.toContain("Running…");
});
it("rotates the word roughly every 10s while live", () => {
const item = liveStatus();
render(item);
const first = whimsyWord(item.id, 0);
expect(container.textContent).toContain(`${first}…`);
act(() => {
vi.advanceTimersByTime(WHIMSY_ROTATE_MS + 100);
});
const second = whimsyWord(item.id, WHIMSY_ROTATE_MS + 100);
expect(second).not.toBe(first);
expect(container.textContent).toContain(`${second}…`);
expect(container.textContent).not.toContain(`${first}…`);
});
it("keeps the informative taxonomy verb when the tail identifies a real tool", () => {
render(
liveStatus({
label: "Running a command",
detail: "Terminal · ls -la",
toolName: "Bash",
}),
);
expect(container.textContent).toContain("Running a command…");
expect(container.textContent).toContain("Terminal · ls -la");
});
it("centers the reconnecting caret and status dot in equal lead slots", () => {
act(() => {
root.render(
<TaskChatStatusPill
item={liveStatus({ label: "Reconnecting" })}
chevronOpen={false}
onToggle={() => undefined}
/>,
);
});
const caret = container.querySelector(
'[data-testid="task-chat-status-caret-slot"]',
);
const dot = container.querySelector(
'[data-testid="task-chat-status-dot-slot"]',
);
for (const slot of [caret, dot]) {
expect(slot?.classList.contains("h-3.5")).toBe(true);
expect(slot?.classList.contains("w-3.5")).toBe(true);
expect(slot?.classList.contains("items-center")).toBe(true);
expect(slot?.classList.contains("justify-center")).toBe(true);
}
});
it("keeps Queued copy untouched", () => {
render(liveStatus({ label: "Queued", detail: "Waiting to start" }));
expect(container.textContent).toContain("Queued…");
});
it("keeps elapsed and token readouts alongside the whimsical word", () => {
const item = liveStatus({ tokens: { used: 18240, size: 200000 } });
render(item);
act(() => {
vi.advanceTimersByTime(1200);
});
expect(container.textContent).toContain("1.2s");
expect(container.textContent).toContain("18,240/200,000 ctx");
});
it("permanently reserves the interstitial row while live so layout never jumps (round 9)", () => {
const item = liveStatus({ tokens: { used: 18240, size: 200000 } });
render(item);
// No message streaming: the row is still mounted as an empty one-line
// slot (same height as when text occupies it) — nothing above shifts when
// a message later appears.
const row = container.querySelector('[data-testid="task-chat-interstitial-row"]');
expect(row).not.toBeNull();
expect(container.querySelector('[data-testid="task-chat-live-self-talk"]')).toBeNull();
// The empty slot keeps the 1lh viewport height.
expect(row?.querySelector(".tc-line-scroll")).not.toBeNull();
});
it("mounts a streaming interstitial as its own row above the status line (PAP-361 amended)", () => {
const item = liveStatus({
label: "Responding",
selfTalk: "I'll extend the ipRateLimit helper with a per-account bucket.",
tokens: { used: 18240, size: 200000 },
});
render(item);
act(() => {
vi.advanceTimersByTime(1200);
});
const row = container.querySelector('[data-testid="task-chat-interstitial-row"]');
const line = container.querySelector('[data-testid="task-chat-live-self-talk"]');
expect(row).not.toBeNull();
expect(line?.textContent).toBe("I'll extend the ipRateLimit helper with a per-account bucket.");
// The row sits DIRECTLY ABOVE the status line — never sharing it: the
// gerund rotation runs uninterrupted below (no "Responding…" copy), with
// meta and pulse dot in place.
expect(row?.nextElementSibling?.textContent).toContain(`${whimsyWord(item.id, 1200)}…`);
expect(row?.contains(line)).toBe(true);
expect(container.textContent).not.toContain("Responding…");
expect(container.textContent).toContain("1.2s");
expect(container.textContent).toContain("18,240/200,000 ctx");
expect(container.querySelector(".animate-pulse")).not.toBeNull();
// Viewport + transition classes: 1lh clip outside, transform-only inner.
expect(line?.className).toContain("tc-line-scroll");
expect(line?.firstElementChild?.className).toContain("tc-line-scroll-inner");
});
it("holds the finished update through the dwell, then slides it away (PAP-376)", () => {
document.documentElement.style.setProperty("--motion-interstitial-dwell", "4000ms");
document.documentElement.style.setProperty("--motion-line-scroll", "240ms");
render(liveStatus({ label: "Responding", selfTalk: "Almost there." }));
render(liveStatus({}));
// selfTalk gone → held as a static single-line readout while the dwell
// runs.
act(() => {
vi.advanceTimersByTime(3900);
});
let line = container.querySelector('[data-testid="task-chat-live-self-talk"]');
expect(line?.textContent).toBe("Almost there.");
expect(line?.getAttribute("data-streaming")).toBeNull();
expect(line?.getAttribute("data-leaving")).toBeNull();
expect(line?.firstElementChild?.className).toContain("truncate");
// Dwell expired with no successor: the line slides up out of the 1lh
// viewport with the transition re-enabled…
act(() => {
vi.advanceTimersByTime(200);
});
line = container.querySelector('[data-testid="task-chat-live-self-talk"]');
expect(line?.getAttribute("data-leaving")).toBe("true");
const inner = line?.firstElementChild as HTMLElement;
expect(inner.style.transform).toContain("-1");
expect(inner.style.transition).toBe("");
// …and unmounts back to the reserved empty slot; gerund + dot carry on.
act(() => {
vi.advanceTimersByTime(300);
});
expect(container.querySelector('[data-testid="task-chat-live-self-talk"]')).toBeNull();
const row = container.querySelector('[data-testid="task-chat-interstitial-row"]');
expect(row).not.toBeNull();
expect(row?.querySelector(".tc-line-scroll")).not.toBeNull();
expect(container.querySelector(".animate-pulse")).not.toBeNull();
});
it("swaps a new update straight in after the held line has slid away", () => {
document.documentElement.style.setProperty("--motion-interstitial-dwell", "4000ms");
render(liveStatus({ label: "Responding", selfTalk: "First." }));
render(liveStatus({}));
act(() => {
vi.advanceTimersByTime(4100);
});
expect(container.querySelector('[data-testid="task-chat-live-self-talk"]')).toBeNull();
render(liveStatus({ label: "Responding", selfTalk: "Second." }));
const line = container.querySelector('[data-testid="task-chat-live-self-talk"]');
expect(line?.textContent).toBe("Second.");
expect(line?.getAttribute("data-streaming")).toBe("true");
});
it("renders the interstitial text without a leading icon (PAP-376)", () => {
render(liveStatus({ label: "Responding", selfTalk: "No icon on this row." }));
const row = container.querySelector('[data-testid="task-chat-interstitial-row"]');
expect(row?.textContent).toBe("No icon on this row.");
expect(row?.querySelector("svg")).toBeNull();
// The empty lead slot still column-aligns the text with the status label.
expect(row?.firstElementChild?.getAttribute("aria-hidden")).toBe("true");
});
it("streams the tail line without a transition once the enter slide settles", () => {
render(liveStatus({ label: "Responding", selfTalk: "Streaming along nicely." }));
act(() => {
vi.advanceTimersByTime(50);
});
const line = container.querySelector('[data-testid="task-chat-live-self-talk"]');
expect(line?.getAttribute("data-streaming")).toBe("true");
// Post-enter (--motion-line-scroll reads 0ms in jsdom) the inline
// transition is disabled: mid-stream wraps snap instead of gliding.
const inner = line?.firstElementChild as HTMLElement;
expect(inner.style.transition).toBe("none");
expect(inner.className).not.toContain("truncate");
});
it("respects the minimum dwell between swaps and collapses bursts latest-wins", () => {
document.documentElement.style.setProperty("--motion-interstitial-dwell", "4000ms");
// Update A streams and finishes → held.
render(liveStatus({ label: "Responding", selfTalk: "Update A." }));
render(liveStatus({}));
const innerBefore = container.querySelector(".tc-line-scroll-inner");
act(() => {
vi.advanceTimersByTime(1000);
});
// Update B arrives 1s after A swapped in: inside the dwell → queued.
render(liveStatus({ label: "Responding", selfTalk: "Update B." }));
act(() => {
vi.advanceTimersByTime(2000);
});
expect(container.textContent).toContain("Update A.");
// Update C supersedes B while still queued (latest-wins queue of one).
render(liveStatus({ label: "Responding", selfTalk: "Update C." }));
act(() => {
vi.advanceTimersByTime(900);
});
// 3.9s since A swapped in — still inside the dwell.
expect(container.textContent).toContain("Update A.");
expect(container.textContent).not.toContain("Update B.");
act(() => {
vi.advanceTimersByTime(200);
});
// Dwell expired → only the NEWEST pending update swaps in; B never shows.
const line = container.querySelector('[data-testid="task-chat-live-self-talk"]');
expect(line?.textContent).toBe("Update C.");
expect(container.textContent).not.toContain("Update A.");
expect(container.textContent).not.toContain("Update B.");
// The swap remounts the line (new key) so the slide plays exactly then.
expect(container.querySelector(".tc-line-scroll-inner")).not.toBe(innerBefore);
// C was still streaming at swap time → it keeps tracking the tail.
expect(line?.getAttribute("data-streaming")).toBe("true");
});
it("swaps a queued update in as held when its stream already ended", () => {
document.documentElement.style.setProperty("--motion-interstitial-dwell", "4000ms");
render(liveStatus({ label: "Responding", selfTalk: "Update A." }));
render(liveStatus({}));
act(() => {
vi.advanceTimersByTime(1000);
});
// B streams and ENDS while A's dwell is still running.
render(liveStatus({ label: "Responding", selfTalk: "Update B, done already." }));
render(liveStatus({}));
act(() => {
vi.advanceTimersByTime(3100);
});
const line = container.querySelector('[data-testid="task-chat-live-self-talk"]');
expect(line?.textContent).toBe("Update B, done already.");
expect(line?.getAttribute("data-streaming")).toBeNull();
expect(line?.firstElementChild?.className).toContain("truncate");
});
it("clears the held line when the turn ends", () => {
render(liveStatus({ label: "Responding", selfTalk: "Held across the turn?" }));
render(liveStatus({}));
expect(container.textContent).toContain("Held across the turn?");
// Turn settles into a non-live status: the row (and held text) is gone;
// a later live turn must not resurrect the old update.
render(liveStatus({ status: "interrupted", label: "Interrupted" }));
act(() => {
vi.advanceTimersByTime(50);
});
expect(container.querySelector('[data-testid="task-chat-interstitial-row"]')).toBeNull();
render(liveStatus({}));
act(() => {
vi.advanceTimersByTime(50);
});
expect(container.textContent).not.toContain("Held across the turn?");
expect(container.querySelector('[data-testid="task-chat-live-self-talk"]')).toBeNull();
});
});
describe("lineScrollOffset", () => {
it("translates up one line-height per wrapped line", () => {
expect(lineScrollOffset(16, 16)).toBe(0); // one line — no shift
expect(lineScrollOffset(32, 16)).toBe(1);
expect(lineScrollOffset(48.5, 16)).toBe(2); // sub-pixel scrollHeight rounds
});
it("never goes negative and guards bad measurements", () => {
expect(lineScrollOffset(0, 16)).toBe(0);
expect(lineScrollOffset(32, 0)).toBe(0);
expect(lineScrollOffset(Number.NaN, 16)).toBe(0);
});
});