Files
PaperClipAI/ui/src/components/task-chat/TaskChatLiveTail.test.tsx
T
DottaandPaperclip 9cfa7fd2d1 fix(ui): share compact live and saved activity across runners (#13421)
## Thinking Path

> - Paperclip helps people manage AI agents and inspect their work.
> - Task threads show live activity and saved transcripts from several
runners.
> - Legacy runs used a separate activity renderer that expanded tool
cards as commands arrived.
> - A compact live row makes ongoing work easier to follow.
> - This PR shares the native runner activity group across live and
saved legacy turns.
> - Both paths now use the same labels, icon gutter, animation, and
history controls.

## Linked Issues or Issue Description

Related: Refs #13255, which introduced rolling native-runner activity
groups.

**What happened?**

During a legacy CLI run, each new command added an expanded tool card
under an activity heading. The original legacy parity story rendered a
completed turn, so it did not exercise this live path.

**Expected behavior**

Show one current activity line per commentary group. Roll that line
forward when a new activity starts. Keep tool icons aligned on the left,
use friendly labels, and show history only when expanded.

**Steps to reproduce**

1. Start a task with the Codex local adapter using the CLI engine.
2. Ask it to read two files in separate tool calls and run a test
command.
3. Watch the activity feed while the run is active, then expand its
history.

**Paperclip version or commit**

The live behavior was reproduced on
bdee5ebb21b2d09280e49c88bc329435da1b07c8. This update includes both live
and saved rendering fixes.

**Deployment mode**

Built from source with the local test-drive command and a real Codex CLI
agent.

## What Changed

- Use `TaskChatRunnerActivityGroup` for both saved legacy phases and
`TaskChatLiveTail`.
- Align the legacy Working spinner with the shared activity icon gutter.
- Cover streamed reasoning updates, successive commands, image labels,
hidden details, and explicit history expansion in tests.
- Feed raw legacy transcript events through the real adapter and live
renderer in Storybook. Add live, expanded, narrow, light, and completed
stories with the final reply preserved.

## Verification

- Passed 195 targeted tests covering the live tail, status pill, shared
activity group, native turn, and full task thread.
- Passed UI typechecking, token gates, UI build, and Storybook build
before submission.
- Observed a real Codex CLI run while it read separate files and ran
tests. The current activity stayed at one 32-pixel row without an
accumulated tool list; the spinner and activity icon centers aligned.
- Compared native and legacy Storybooks: identical rolling animation,
fixed height, persistent expansion, truncated long labels, and correct
light and completed states.
- Full repository `pnpm -r typecheck` and `pnpm build` passed after
replaying the branch on current master, including the Rust runner. The
full `pnpm test:run` suite is still running.

## Risks

- Legacy activity now starts collapsed. Users can expand each group and
each row to inspect the same transcript details.
- Live runtime request cards must retain their timeline positions.
Existing task-thread and native-runner regression tests cover this
boundary.
- No API, database, or transcript format changes.

## Model Used

- OpenAI GPT-6 through Codex for the live-path fix, tests, and browser
verification. Capabilities used: reasoning, tool use, local code
execution, and image inspection. The exact deployment ID and
context-window size are not exposed in this session.
- The original saved-turn change recorded OpenAI Codex with GPT-5.6; its
exact variant and context size were not recorded.

## 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
- [x] 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-15 16:40:32 -05:00

253 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// @vitest-environment jsdom
import { flushSync } from "react-dom";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import type { TranscriptEntry } from "@/adapters";
import { ThemeProvider } from "@/context/ThemeContext";
import { MemoryRouter } from "@/lib/router";
import { TaskChatLiveTail } from "./TaskChatLiveTail";
import { transcriptToTaskChatItems } from "./transcript-adapter";
import type { TaskChatItem } from "./task-chat-model";
const TS = "2026-08-08T12:00:00.000Z";
describe("TaskChatLiveTail", () => {
let container: HTMLDivElement;
let root: Root | null = null;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
});
afterEach(() => {
flushSync(() => root?.unmount());
root = null;
container.remove();
});
function render(items: TaskChatItem[], emptyMessage?: string) {
flushSync(() =>
root!.render(
<MemoryRouter>
<ThemeProvider>
<TaskChatLiveTail items={items} emptyMessage={emptyMessage} />
</ThemeProvider>
</MemoryRouter>,
),
);
}
function parse(entries: TranscriptEntry[], running = true) {
return transcriptToTaskChatItems(entries, { runId: "run-1", running });
}
const toggle = () => container.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-activity-phase-toggle"]',
)!;
const viewport = () => container.querySelector(
'[data-testid="task-chat-activity-viewport"]',
)!;
const expandFirstDetail = () => {
flushSync(() => toggle().click());
flushSync(() => container.querySelector<HTMLButtonElement>("li button")!.click());
};
it("renders streamed reply markdown with one compact activity, without old tool cards", () => {
const items = parse([
{ kind: "assistant", ts: TS, text: "Looking into the failing test." },
{ kind: "tool_call", ts: TS, name: "Read", toolUseId: "t1", input: { file_path: "src/app.ts" } },
{ kind: "tool_result", ts: TS, toolUseId: "t1", content: "ok", isError: false },
]);
render(items);
expect(container.querySelector('[data-testid="task-chat-phase-interstitial"]')?.textContent).toContain(
"Looking into the failing test.",
);
expect(toggle().getAttribute("aria-expanded")).toBe("false");
expect(viewport().textContent).toContain("Read a file");
expect(viewport().textContent).toContain("src/app.ts");
expect(container.querySelector('[data-testid="task-chat-tool-card"]')).toBeNull();
expect(container.querySelector('[data-testid="task-chat-phase-child-rail"]')).toBeNull();
});
it("keeps tool diff bodies out of the activity feed", () => {
const items = parse([
{ kind: "tool_call", ts: TS, name: "Edit", toolUseId: "t1", input: { file_path: "a.ts" } },
{ kind: "diff", ts: TS, changeType: "add", text: "const x = 1;" },
{ kind: "diff", ts: TS, changeType: "remove", text: "const x = 0;" },
]);
render(items);
expect(toggle().getAttribute("aria-expanded")).toBe("false");
expect(container.textContent).not.toContain("const x = 1;");
expect(container.textContent).not.toContain("+1 −1");
expandFirstDetail();
expect(container.textContent).toContain("+1 −1");
expect(container.textContent).not.toContain("const x = 1;");
expect(container.querySelector('[data-testid="task-chat-runner-activity-detail"]')).not.toBeNull();
});
it("drops the debug plumbing kinds RunTranscriptView surfaced", () => {
// Feed the exact noise the board flagged: init row, stdout/stderr/system
// dumps, and a result line — interleaved with real content. None of the
// noise may reach the DOM; only the assistant text + tool row survive.
const items = parse([
{ kind: "init", ts: TS, model: "claude-opus", sessionId: "sess-INITMARKER" },
{ kind: "system", ts: TS, text: "SYSTEMNOISE hint about the environment" },
{ kind: "stdout", ts: TS, text: "STDOUTNOISE raw log line" },
{ kind: "stderr", ts: TS, text: "STDERRNOISE warning" },
{ kind: "assistant", ts: TS, text: "Here is the real reply." },
{ kind: "tool_call", ts: TS, name: "Bash", toolUseId: "t1", input: { command: "pnpm test" } },
{
kind: "result",
ts: TS,
text: "RESULTNOISE",
inputTokens: 10,
outputTokens: 5,
cachedTokens: 0,
costUsd: 0.01,
subtype: "success",
isError: false,
errors: [],
},
]);
render(items);
expect(container.querySelector('[data-testid="task-chat-phase-interstitial"]')?.textContent).toContain(
"Here is the real reply.",
);
expect(toggle().getAttribute("aria-expanded")).toBe("false");
flushSync(() => toggle().click());
const text = container.textContent ?? "";
expect(text).toContain("Here is the real reply.");
expect(text).toContain("pnpm test");
// No debug plumbing, no RunTranscriptView chrome.
for (const noise of [
"INITMARKER",
"SYSTEMNOISE",
"STDOUTNOISE",
"STDERRNOISE",
"RESULTNOISE",
"Streaming",
"LOG LINES",
"SYSTEM MESSAGES",
]) {
expect(text).not.toContain(noise);
}
});
it("updates only the latest reasoning line in place, with full text available on expansion", () => {
const entries: TranscriptEntry[] = [
{ kind: "thinking", ts: TS, itemId: "reasoning", text: "First line\nChecking" },
];
render(parse(entries));
const row = viewport().querySelector("[data-activity-row]");
entries.push({ kind: "thinking", ts: TS, itemId: "reasoning", text: " the file", delta: true });
render(parse(entries));
expect(viewport().querySelector("[data-activity-row]")).toBe(row);
expect(viewport().textContent).toContain("Checking the file");
expect(container.textContent).not.toContain("First line");
expect(container.querySelector(".runner-activity-roll-in")).toBeNull();
expect(toggle().getAttribute("aria-expanded")).toBe("false");
expandFirstDetail();
expect(container.textContent).toContain("First line");
expect(container.textContent).toContain("Checking the file");
expect(container.querySelector('[data-testid="task-chat-phase-child-rail"]')).toBeNull();
});
it("rolls successive CLI commands and image calls through one row without accumulating history", () => {
const entries: TranscriptEntry[] = [
{ kind: "assistant", ts: TS, text: "Checking the fix.", channel: "progress" },
{ kind: "tool_call", ts: TS, name: "exec_command", toolUseId: "t1", input: { command: "git status" } },
];
render(parse(entries));
const first = viewport().querySelector("[data-activity-row]");
entries.push({ kind: "tool_result", ts: TS, toolUseId: "t1", content: "clean", isError: false });
render(parse(entries));
expect(viewport().querySelector("[data-activity-row]")).toBe(first);
expect(viewport().textContent).toContain("Ran a command");
expect(container.querySelector(".runner-activity-roll-in")).toBeNull();
entries.push({ kind: "tool_call", ts: TS, name: "Bash", toolUseId: "t2", input: { command: "pnpm test" } });
render(parse(entries));
expect(container.querySelector(".runner-activity-roll-in")?.textContent).toContain("Running a command");
const outgoing = container.querySelector(".runner-activity-roll-out")!;
expect(outgoing.getAttribute("aria-hidden")).toBe("true");
flushSync(() => outgoing.dispatchEvent(new Event("webkitAnimationEnd", { bubbles: true })));
expect(viewport().querySelectorAll("[data-activity-row]")).toHaveLength(1);
expect(container.textContent).not.toContain("git status");
expect(container.querySelector('[data-testid="task-chat-runner-activity-list"]')).toBeNull();
entries.push(
{ kind: "tool_result", ts: TS, toolUseId: "t2", content: "passed", isError: false },
{ kind: "tool_call", ts: TS, name: "image_generation", toolUseId: "t3", input: { file_path: "preview.png" } },
);
render(parse(entries));
expect(container.querySelector(".runner-activity-roll-in")?.textContent).toContain("Generating an image");
flushSync(() => toggle().click());
expect(container.querySelectorAll("li")).toHaveLength(3);
expect(container.textContent).toContain("git status");
expect(container.textContent).toContain("pnpm test");
entries.push({ kind: "tool_result", ts: TS, toolUseId: "t3", content: "created", isError: false });
render(parse(entries));
expect(toggle().getAttribute("aria-expanded")).toBe("true");
expect(container.textContent).toContain("Generated an image");
});
it("bounds long tool targets and wraps the full value only when expanded", () => {
const longPath =
"/Users/dotta/paperclip/instances/default/companies/company-id/codex/home/skills/paperclip/references/API-reference.md";
const items = parse([
{
kind: "tool_call",
ts: TS,
name: "Read",
toolUseId: "long-read",
input: { file_path: longPath },
},
]);
const renderedTarget = items
.flatMap((item) =>
item.kind === "activity_phase" ? item.items : [item],
)
.find((item) => item.kind === "tool")?.target;
render(items);
const collapsedTarget = viewport().querySelector("[title]");
expect(collapsedTarget?.classList.contains("truncate")).toBe(true);
expect(toggle().getAttribute("aria-expanded")).toBe("false");
expect(renderedTarget).toBeTruthy();
expect(collapsedTarget?.textContent).toBe(renderedTarget);
expandFirstDetail();
const expandedTarget = container.querySelector(
'[data-testid="task-chat-runner-activity-detail"] p',
);
expect(toggle().getAttribute("aria-expanded")).toBe("true");
expect(expandedTarget?.textContent).toBe(renderedTarget);
expect(
expandedTarget?.classList.contains("break-all"),
).toBe(true);
});
it("shows the empty message when nothing renderable has streamed yet", () => {
render([], "Waiting to start...");
expect(container.textContent).toContain("Waiting to start...");
});
it("renders nothing (not even the empty message) once content exists", () => {
const items = parse([{ kind: "assistant", ts: TS, text: "streaming…" }]);
render(items, "Waiting to start...");
expect(container.textContent).not.toContain("Waiting to start...");
expect(container.textContent).toContain("streaming…");
});
});