mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-20 06:02:18 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Operators talk to their agents on the issue detail page. An experimental "Chat-Style Tasks" view (#10606) makes that page read as a conversation instead of a ticket form. > - The first release of that view shipped with a plain-text composer, no visible narration while an agent works, and a desktop-only layout. > - Users write formatted replies, paste screenshots, and follow long agent runs from their phones. The experimental view should support all of that before it can graduate. > - This pull request is the next iteration of the same experiment: a rich-text composer with attachments, live-turn narration on the status line, cleaner settled-turn history, and a mobile layout. > - The benefit is a chat view that feels alive while the agent works and stays readable after it finishes, on desktop and mobile, still fully behind the existing opt-in flag. ## Linked Issues or Issue Description Refs #49 (chat with agents is a much-wanted feature). Refs #10606 (the merged first release of the experimental chat-style task view; this PR iterates on it). Related PRs found in the dedup search: - #8228 — open PR that polishes the classic issue chat composer. It targets the flag-off legacy path; this PR only changes the flag-on experimental view. - #10466 — merged blockquote-recovery fix in the shared MarkdownEditor. This PR now reuses that editor inside the chat composer. **What existing behavior does this improve?** The experimental "Chat-Style Tasks" view on the issue detail page (Settings → Experimental, `enableTaskChatRedesign`, default off). **Subsystem affected** UI (issue detail page, chat-style task view). **Current behavior** With the experiment enabled, the composer is a plain textarea with no formatting, no attachment preview, and no mention support. While an agent runs, the status line shows only a static label, and the agent's narration text is hidden. Finished runs render one settled row per turn, so a run with many short turns produces a long list of near-duplicate "Worked" rows, and turns without a comment append at the bottom out of order. On mobile, the desktop bounded-height thread makes the page scroll poorly. **Proposed behavior** The composer uses the shared MarkdownEditor: markdown formatting, mentions, image paste with thumbnail previews, and non-image attachment chips. Sending posts on Cmd/Ctrl+Enter. While an agent runs, the status line rotates playful status words and surfaces the agent's own narration as short interstitial updates: each update holds for a minimum dwell, is replaced only when superseded, and slides through a one-line viewport with tokenized motion. Back-to-back settled turns coalesce into one "Worked" row with summed durations and re-derived tool counts, and comment-less settled turns insert chronologically at their run's start time. On mobile, the thread renders in the document flow with window auto-follow and a sticky safe-area composer; the desktop layout is unchanged. **Reason and benefit** The chat view is only convincing if it feels like a conversation with a working agent. Rich text and screenshots are table stakes for chat input. Live narration gives moment-to-moment feedback without opening transcripts. Coalesced history keeps long-running tasks readable. Mobile support lets operators follow runs away from their desks. **Breaking changes** None. Every change is gated behind the existing `enableTaskChatRedesign` flag, which is off by default. The flag-off page is unchanged. ## What Changed - `TaskChatComposer` swaps its textarea for the shared `MarkdownEditor`: markdown formatting, mentions, image paste with object-URL thumbnail previews (revoked on clear and unmount), and posting on Cmd/Ctrl+Enter. - Non-image attachments render as chips on a new shared `ui/attachment.tsx` primitive (adds the `@base-ui/react` dependency it builds on). - New `status-whimsy.ts`: deterministic rotation of playful status words on the live status line. - Live interstitial narration: the transcript adapter tags agent self-talk, and the live status line shows it as ephemeral one-line updates with a ~4s minimum dwell, hold-until-superseded replacement, and a slide transition driven by new `--motion-line-scroll` tokens (cataloged in `motion-tokens.ts`, which a test keeps 1:1 with `index.css`). Hover affordance applies only to the status line, with no leading icon. - Settled-turn history: `coalesceSettledTurns` merges back-to-back settled agent turns into one "Worked" row (summed per-run durations, tool counts re-derived from the merged turn); `assembleThreadItems` inserts comment-less settled turns chronologically at run start instead of appending them at the bottom; settled turns render tool rows only (the separate thinking block component is removed). - The "Worked" summary attaches to the reply timestamp row, and thread timestamps are always visible. - Mobile layout: the thread renders with `scroll={false}` in the page scroll, a new `useWindowAutoFollow` hook keeps the window pinned to new content, the composer is sticky with safe-area padding, and the editor uses 16px text so iOS does not zoom on focus. The desktop bounded chain is untouched. - New `TaskChatDescriptionBubble` renders the issue description as the first chat bubble, and `McpIcon` gives MCP tools a distinct icon. - `IssueDetail.test.tsx` stubs `TaskChatThread`: the composer's `@mdxeditor` dependency cannot load under jsdom's CSSOM, and the suite exercises the flag-off path. ## Verification - `pnpm check:token-gates` — 3/3 CLEAN. - `node scripts/check-task-chat-motion.mjs` — OK (30 files scanned, seams present). - `cd ui && npx tsc -b` — clean. - `cd ui && pnpm vitest run` — 3,482 of 3,483 tests pass locally. The one failure is the `IssueProperties.test.tsx` monitor-row time-formatting test, which is timezone-sensitive: it fails identically on unmodified `origin/master` in a non-UTC timezone and passes with `TZ=UTC`. It is not related to this change. - Manual: enable "Chat-Style Tasks" in Settings → Experimental and open an issue with an assigned agent. Comment to start a run: the status line rotates status words and shows the agent's narration as short held updates. After the run, consecutive turns fold into one "Worked" row under the reply timestamp. Paste an image into the composer to see a thumbnail chip; attach a non-image file to see a file chip; send with Cmd+Enter. Open the same issue in a narrow viewport to see the document-flow layout with the sticky composer. - Visual snapshot baselines are intentionally not updated: per `doc/design/DECISION-SHEET.md`, "Per-change snapshot verification demoted to dormant (Jul 13 2026)". ## Risks - The composer now loads the shared MarkdownEditor inside the chat view. The editor is already used across the app (issue descriptions, comments), so its behavior is well exercised; composer-specific handling (paste, attachments, submit keys) is covered by new tests. - The transcript adapter changes how live narration and settled turns are derived from run logs. Malformed or legacy logs degrade to generic rows rather than crashing, and the adapter suites cover the merge and ordering rules. - Object URLs for paste previews are revoked on send-clear and unmount to avoid leaks; jsdom environments without `URL.createObjectURL` are guarded. - All changes are behind the default-off `enableTaskChatRedesign` flag. Overall risk with the flag off is low. ## Model Used - Claude (Anthropic), model id `claude-fable-5` (Claude Fable 5), extended thinking enabled, agentic tool use (file editing, shell, test execution) via Claude Code / Claude Agent SDK. ## 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 - [x] 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> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
298 lines
13 KiB
TypeScript
298 lines
13 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("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);
|
|
});
|
|
});
|