Files
PaperClipAI/ui/src/pages/TaskChatLab.tsx
T
scotttongandClaude Opus 4.8 ea83c5c822 feat(task-chat): bring back copy/👍/👎 actions on the agent bubble footer (#11025)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - The task view shows a threaded conversation between the user and the
agent, with agent replies rendered as bubbles that carry a "✓ Worked · N
tools" summary line.
> - The conference-room chat already offers per-message copy and
thumbs-up / thumbs-down feedback, but the redesigned task thread dropped
these controls from the agent bubble footer.
> - Users lose a quick way to copy an agent reply or send feedback on
it, and the redesign silently ignored the feedback-vote props it was
already given.
> - This pull request prepends a copy · thumbs-up · thumbs-down cluster
to the bubble summary line and wires the existing feedback-vote props
through.
> - The benefit is a consistent feedback surface across both chat views,
with no new API.

## Linked Issues or Issue Description

No public GitHub issue exists for this change; the underlying issue is
described inline below following the feature template
(`.github/ISSUE_TEMPLATE/feature_request.yml`).

#### Problem or motivation

The redesigned task thread renders each agent reply with a "✓ Worked · N
tools · <timestamp>" summary line, but it dropped the copy and thumbs-up
/ thumbs-down controls that the conference-room chat still shows. Users
can no longer copy an agent reply or vote feedback from the task thread.
The redesign component already received `feedbackVotes` and `onVote`
props but ignored them.

#### Proposed solution

Prepend a copy · 👍 · 👎 cluster to the summary line, leading the
always-visible timestamp, reusing the shared `IssueChatFeedbackButtons`
so both chat views speak the same feedback language. Anchor the cluster
to the turn's summary row (a sibling of the expandable tool-history
fold) so it stays on the summary line whether the tool history is
collapsed or expanded.

#### Alternatives considered

Placing the cluster inside the expandable fold — rejected because
expanding the tool history then re-centered the actions to the middle of
the tall fold.

#### Roadmap alignment

UI polish to the task thread; no core-roadmap overlap.

## What Changed

- Add `TaskChatBubbleActions`: a copy · thumbs-up · thumbs-down cluster
built on the shared `IssueChatFeedbackButtons`.
- Render the cluster on the agent bubble's "✓ Worked · …" summary line,
leading the timestamp; runless agent replies get the same cluster with
the timestamp trailing. Human and system bubbles are unchanged.
- Add a `leading` slot to `TaskChatTurn` so the actions sit on the
summary row, a sibling of the tool-history fold, and stay anchored when
the fold expands.
- Wire the redesign to the `feedbackVotes` / `onVote` props it already
received.
- Add a demo binding in the `TaskChatLab` dev harness.

## Verification

- `pnpm check:token-gates` — 3/3 gates CLEAN.
- `pnpm typecheck` — clean across all packages.
- `cd ui && pnpm vitest run
src/components/task-chat/TaskChatBubble.test.tsx
src/components/task-chat/TaskChatTurn.test.tsx` — 31/31 pass.
- Manual: open a task thread, confirm the copy / 👍 / 👎 cluster shows on
the agent bubble summary line before the timestamp, copy works, votes
toggle, and the cluster stays on the summary line when the tool history
is expanded.

Visual change: snapshot baselines are intentionally not updated, per the
`doc/design/DECISION-SHEET.md` entry "Per-change snapshot verification
demoted to dormant (Jul 13 2026)".

## Risks

Low risk. UI-only change scoped to the redesigned task-chat bubble
footer. It reuses an existing shared feedback component and existing
vote props; no API, schema, or server change. Human and system bubbles
are untouched.

## Model Used

Claude Opus 4.8 (Anthropic), model id `claude-opus-4-8`, extended
thinking enabled, tool use.

## 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: Claude Opus 4.8 <noreply@anthropic.com>
2026-08-06 18:26:44 -07:00

250 lines
9.5 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.
import { useEffect, useMemo, useRef, useState } from "react";
import { cn } from "@/lib/utils";
import { Pause, Play, RotateCcw } from "lucide-react";
import {
TASK_CHAT_STATE_LIST,
type TaskChatStateId,
} from "@/components/task-chat/task-chat-states";
import { buildScenario } from "@/components/task-chat/task-chat-fixtures";
import { TaskChatThreadView } from "@/components/task-chat/TaskChatThreadView";
import { TaskChatPlanView } from "@/components/task-chat/TaskChatPlanView";
import { TaskChatBubbleActions } from "@/components/task-chat/TaskChatBubbleActions";
import { TweakPanel } from "@/components/task-chat/TweakPanel";
import type {
TaskChatItem,
TaskChatMessageItem,
} from "@/components/task-chat/task-chat-model";
/**
* Demo binding for the agent-bubble copy · 👍 · 👎 cluster (PAP-413). The live
* thread wires these to the feedback-vote API; here the votes no-op so the
* harness can show the footer without a control plane.
*/
function labMessageActions(item: TaskChatMessageItem) {
if (item.author !== "agent" || item.optimistic) return null;
return (
<TaskChatBubbleActions
copyText={item.text}
feedback={{
activeVote: null,
sharingPreference: "allowed",
termsUrl: null,
onVote: async () => {},
}}
/>
);
}
/** Replay ticks of selfTalk gap between scripted interstitial updates (~0.7s at 1×). */
const SELF_TALK_GAP_TICKS = 30;
/** A fixture's scripted interstitial updates: blank-line-separated segments. */
function selfTalkSegments(script: string): string[] {
return script
.split(/\n{2,}/)
.map((s) => s.trim())
.filter(Boolean);
}
/**
* The selfTalk visible at replay tick `tick`: each segment streams one char
* per tick as its own update, with a gap (selfTalk undefined — the real
* adapter clears it between assistant messages) before the next; after the
* last segment ends, selfTalk stays undefined so the pill's hold-until-
* superseded behavior (PAP-368) is visible in the lab.
*/
function selfTalkAtTick(segments: string[], tick: number): string | undefined {
let t = tick;
for (let i = 0; i < segments.length; i++) {
const seg = segments[i];
if (t <= seg.length) return seg.slice(0, t) || undefined;
t -= seg.length;
if (i === segments.length - 1) return undefined;
if (t <= SELF_TALK_GAP_TICKS) return undefined;
t -= SELF_TALK_GAP_TICKS;
}
return undefined;
}
/** Progressively reveal any streaming message / live-line interstitial text at `speed`. */
function useStreamingReplay(
baseItems: TaskChatItem[],
speed: number,
playToken: number,
): TaskChatItem[] {
const [chars, setChars] = useState<number>(Number.MAX_SAFE_INTEGER);
const streamingIndex = baseItems.findIndex(
(i) =>
(i.kind === "message" && i.streaming) ||
(i.kind === "turn" && !i.settled && i.liveStatus?.selfTalk != null),
);
const streamingItem = baseItems[streamingIndex];
const isInterstitial = streamingItem?.kind === "turn";
const fullText = useMemo(() => {
const it = baseItems[streamingIndex];
if (!it) return "";
if (it.kind === "message") return it.text;
if (it.kind === "turn") return it.liveStatus?.selfTalk ?? "";
return "";
}, [baseItems, streamingIndex]);
const segments = useMemo(
() => (isInterstitial ? selfTalkSegments(fullText) : []),
[isInterstitial, fullText],
);
useEffect(() => {
if (streamingIndex < 0) {
setChars(Number.MAX_SAFE_INTEGER);
return;
}
setChars(0);
let n = 0;
// One extra tick past the last segment lands on the trailing undefined
// (message ended) state.
const stopAt = isInterstitial
? segments.reduce((a, s) => a + s.length, 0) +
SELF_TALK_GAP_TICKS * Math.max(0, segments.length - 1) +
1
: fullText.length;
const baseIntervalMs = 24;
const interval = window.setInterval(() => {
n += 1;
setChars(n);
if (n >= stopAt) window.clearInterval(interval);
}, Math.max(4, baseIntervalMs / speed));
return () => window.clearInterval(interval);
}, [streamingIndex, fullText, isInterstitial, segments, speed, playToken]);
if (streamingIndex < 0) return baseItems;
return baseItems.map((it, idx) => {
if (idx !== streamingIndex) return it;
const shown = fullText.slice(0, chars);
const done = chars >= fullText.length;
if (it.kind === "message") return { ...it, text: shown, streaming: !done };
// Parent-row interstitial (PAP-361/368): reveal the scripted updates
// segment by segment; the pill holds each finished one until the next
// swaps in.
if (it.kind === "turn" && it.liveStatus) {
return { ...it, liveStatus: { ...it.liveStatus, selfTalk: selfTalkAtTick(segments, chars) } };
}
return it;
});
}
/**
* Dev harness for the Task Chat Redesign (route: /dev/task-chat-lab, behind the
* enableTaskChatRedesign flag). Drives the render layer into every inventory
* state via synthetic events — no live agent — and is also the human's
* post-baseline iteration cockpit: state switcher, streaming replay, a
* 0.1×–10× speed control, and the live motion tweak panel.
*/
export function TaskChatLab() {
const [selected, setSelected] = useState<TaskChatStateId>("agent-message");
const [speed, setSpeed] = useState(1);
const [playing, setPlaying] = useState(true);
const [playToken, setPlayToken] = useState(0);
const scenario = useMemo(() => buildScenario(selected), [selected]);
const replayItems = useStreamingReplay(scenario.items, speed, playToken);
const items = playing ? replayItems : scenario.items;
const targetRef = useRef<HTMLDivElement>(null);
const meta = TASK_CHAT_STATE_LIST.find((m) => m.id === selected)!;
return (
<div className="flex h-full min-h-0 flex-col">
<header className="border-b border-border px-4 py-2">
<h1 className="text-sm font-semibold">Task Chat Lab</h1>
<p className="text-xs text-muted-foreground">
Synthetic harness for the task chat redesign · every state renders here with no live agent.
</p>
</header>
<div className="flex min-h-0 flex-1">
{/* State switcher */}
<nav className="w-56 shrink-0 overflow-y-auto border-r border-border p-2" aria-label="States">
{(["live", "tier-b"] as const).map((tier) => (
<div key={tier} className="mb-3">
<p className="mb-1 px-1 text-(length:--text-nano) font-semibold uppercase tracking-wide text-muted-foreground">
{tier === "live" ? "Live states" : "Tier-B (synthetic)"}
</p>
<ul className="flex flex-col gap-0.5">
{TASK_CHAT_STATE_LIST.filter((m) => m.tier === tier).map((m) => (
<li key={m.id}>
<button
type="button"
data-state-id={m.id}
onClick={() => {
setSelected(m.id);
setPlayToken((t) => t + 1);
}}
className={cn(
"w-full rounded px-2 py-1 text-left text-xs",
selected === m.id ? "bg-primary text-primary-foreground" : "hover:bg-accent",
)}
>
{m.label}
</button>
</li>
))}
</ul>
</div>
))}
</nav>
{/* Stage */}
<main className="flex min-h-0 flex-1 flex-col">
<div className="flex items-center gap-3 border-b border-border px-4 py-2 text-xs">
<button
type="button"
onClick={() => setPlaying((p) => !p)}
className="flex items-center gap-1 rounded border border-border px-2 py-1 hover:bg-accent"
>
{playing ? <Pause className="h-3.5 w-3.5" /> : <Play className="h-3.5 w-3.5" />}
{playing ? "Pause" : "Play"}
</button>
<button
type="button"
onClick={() => setPlayToken((t) => t + 1)}
className="flex items-center gap-1 rounded border border-border px-2 py-1 hover:bg-accent"
>
<RotateCcw className="h-3.5 w-3.5" />
Replay
</button>
<label className="flex items-center gap-2">
<span className="text-muted-foreground">Speed</span>
<input
type="range"
min={0.1}
max={10}
step={0.1}
value={speed}
onChange={(e) => setSpeed(parseFloat(e.target.value))}
aria-label="Streaming speed"
className="w-32"
/>
<span className="w-10 tabular-nums">{speed.toFixed(1)}×</span>
</label>
<span className="ml-auto font-mono text-(length:--text-micro) text-muted-foreground">{meta.protocol}</span>
</div>
<div ref={targetRef} className="flex min-h-0 flex-1 flex-col" data-testid="task-chat-stage">
{scenario.surface === "plan" && scenario.plan ? (
<div className="mx-auto max-w-2xl px-4">
<TaskChatPlanView plan={scenario.plan} />
</div>
) : (
<div className="mx-auto flex min-h-0 w-full max-w-2xl flex-1 flex-col">
<TaskChatThreadView items={items} renderMessageActions={labMessageActions} />
</div>
)}
</div>
</main>
</div>
<TweakPanel />
</div>
);
}
export default TaskChatLab;