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>
This commit is contained in:
scotttong 2026-08-06 18:26:44 -07:00 committed by GitHub
parent cfed36ea6b
commit ea83c5c822
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 334 additions and 58 deletions

View File

@ -40,6 +40,36 @@ export function agentBubbleDateLabel(date: Date | string | undefined): string {
return formatShortDate(date);
}
/**
* Copy-to-clipboard icon button shared by every agent-bubble footer the
* conference-room {@link AgentBubbleActionRow} and the redesigned task thread's
* {@link TaskChatBubbleActions} (PAP-413). Single-sourced so both footers keep
* identical sizing, radius, and the "copied ✓" feedback affordance rather than
* re-declaring the same button markup on each surface.
*/
export function BubbleCopyButton({ copyText }: { copyText: string }) {
const [copied, setCopied] = useState(false);
return (
<button
type="button"
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
title="Copy message"
aria-label="Copy message"
onClick={() => {
void copyTextToClipboard(copyText)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(() => {});
}}
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</button>
);
}
/**
* Shared agent-bubble action row copy · 👍 · 👎 · timestamp · menu.
*
@ -79,26 +109,9 @@ export function AgentBubbleActionRow({
menuItems?: ReactNode;
className?: string;
}) {
const [copied, setCopied] = useState(false);
return (
<div className={cn("mt-2 flex items-center gap-1", className)}>
<button
type="button"
className="inline-flex h-7 w-7 items-center justify-center rounded-md text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
title="Copy message"
aria-label="Copy message"
onClick={() => {
void copyTextToClipboard(copyText)
.then(() => {
setCopied(true);
setTimeout(() => setCopied(false), 2000);
})
.catch(() => {});
}}
>
{copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</button>
<BubbleCopyButton copyText={copyText} />
{feedback ? (
<IssueChatFeedbackButtons
activeVote={feedback.activeVote}

View File

@ -19,9 +19,12 @@ import { TaskChatDescriptionBubble } from "@/components/task-chat/TaskChatDescri
import type {
TaskChatInteractionItem,
TaskChatItem,
TaskChatMessageItem,
TaskChatTurnItem,
} from "@/components/task-chat/task-chat-model";
import { TaskChatInteractionCard } from "@/components/task-chat/TaskChatInteractionCard";
import { TaskChatBubbleActions } from "@/components/task-chat/TaskChatBubbleActions";
import type { FeedbackVoteValue } from "@paperclipai/shared";
import { TaskChatThreadView, taskChatContentKey } from "@/components/task-chat/TaskChatThreadView";
import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer";
import { useWindowAutoFollow } from "@/components/task-chat/useWindowAutoFollow";
@ -108,6 +111,10 @@ export function TaskChatThread(props: TaskChatThreadProps) {
threadHeader,
workModeChanges,
issueBrief,
feedbackVotes,
feedbackDataSharingPreference = "prompt",
feedbackTermsUrl = null,
onVote,
} = props;
const linkedRunMetaById = useMemo(() => {
@ -388,6 +395,43 @@ export function TaskChatThread(props: TaskChatThreadProps) {
);
}, [orderedEntries, runs, liveRun, transcriptByRun, linkedRunMetaById, lastCommentIdByRun, hasBrief]);
// Feedback votes keyed by the comment they target (targetType
// "issue_comment"), mirroring IssueChatThread — the redesign attaches the
// 👍/👎 state to each agent bubble by its comment id (PAP-413).
const feedbackVoteByTargetId = useMemo(() => {
const map = new Map<string, FeedbackVoteValue>();
for (const feedbackVote of feedbackVotes ?? []) {
if (feedbackVote.targetType !== "issue_comment") continue;
map.set(feedbackVote.targetId, feedbackVote.vote);
}
return map;
}, [feedbackVotes]);
// copy · 👍 · 👎 cluster for an agent bubble's footer line (PAP-413). Human
// and system bubbles get nothing; copy is always available, and the feedback
// buttons render only when the host wired a vote handler.
const renderMessageActions = useCallback(
(item: TaskChatMessageItem) => {
if (item.author !== "agent" || item.optimistic) return null;
return (
<TaskChatBubbleActions
copyText={item.text}
feedback={
onVote
? {
activeVote: feedbackVoteByTargetId.get(item.id) ?? null,
sharingPreference: feedbackDataSharingPreference,
termsUrl: feedbackTermsUrl,
onVote: (vote, options) => onVote(item.id, vote, options),
}
: null
}
/>
);
},
[onVote, feedbackVoteByTargetId, feedbackDataSharingPreference, feedbackTermsUrl],
);
const renderInteraction = useCallback(
(item: TaskChatInteractionItem) => (
<TaskChatInteractionCard
@ -450,6 +494,7 @@ export function TaskChatThread(props: TaskChatThreadProps) {
header={threadHeader}
renderInteraction={renderInteraction}
renderBrief={issueBrief ? () => <TaskChatDescriptionBubble brief={issueBrief} /> : undefined}
renderMessageActions={renderMessageActions}
scroll={!isMobile}
/>
)}

View File

@ -209,3 +209,62 @@ describe("TaskChatBubble footer line (round 9)", () => {
expect(plain).toHaveLength(0);
});
});
describe("TaskChatBubble footer actions (PAP-413)", () => {
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(item: TaskChatMessageItem, opts?: { attachedTurn?: ReactNode; actions?: ReactNode }) {
flushSync(() =>
root!.render(
<ThemeProvider>
<TaskChatBubble item={item} attachedTurn={opts?.attachedTurn} actions={opts?.actions} />
</ThemeProvider>,
),
);
}
const actions = <div data-testid="fake-actions">actions</div>;
it("hands actions to the attached turn (not this wrapper) so they ride the summary row", () => {
render(
{ id: "m1", kind: "message", author: "agent", authorName: "CEO", text: "Done.", timestamp: "2:34 PM" },
{ attachedTurn: <div data-testid="fake-attached-turn">2:34 PM · Worked</div>, actions },
);
const slot = container.querySelector('[data-testid="task-chat-bubble-attached-turn"]');
expect(slot).not.toBeNull();
// The turn owns the footer line; the bubble no longer places actions beside
// it (they ride the turn's `leading` slot, anchored to the summary line).
expect(slot?.querySelector('[data-testid="fake-attached-turn"]')).not.toBeNull();
expect(slot?.querySelector('[data-testid="fake-actions"]')).toBeNull();
});
it("leads a runless agent reply with actions, timestamp trailing", () => {
render(
{ id: "m1", kind: "message", author: "agent", authorName: "CEO", text: "Done.", timestamp: "2:34 PM" },
{ actions },
);
expect(container.querySelector('[data-testid="fake-actions"]')).not.toBeNull();
const stamp = [...container.querySelectorAll("span")].find((el) => el.textContent === "2:34 PM");
expect(stamp).not.toBeNull();
});
it("omits the actions row entirely when none are supplied (human bubble)", () => {
render({ id: "m1", kind: "message", author: "human", text: "Hi", timestamp: "2:34 PM" });
expect(container.querySelector('[data-testid="fake-actions"]')).toBeNull();
const stamp = [...container.querySelectorAll("span")].find((el) => el.textContent === "2:34 PM");
expect(stamp).not.toBeNull();
});
});

View File

@ -25,6 +25,13 @@ interface TaskChatBubbleProps {
* Supplied by TaskChatThreadView when `item.attachedTurn` is set.
*/
attachedTurn?: ReactNode;
/**
* copy · 👍 · 👎 controls for an agent bubble's footer line (PAP-413).
* Rendered here only for a runless reply (leading the bare timestamp); when
* an attached turn is present it owns these via its `leading` slot instead,
* so this bubble skips them. Human/system bubbles pass nothing.
*/
actions?: ReactNode;
}
function initialsForName(name: string) {
@ -41,7 +48,7 @@ function initialsForName(name: string) {
* bubble with an avatar author header (the agent's assigned icon + name · mode
* chip); system notices are centered and recede.
*/
export function TaskChatBubble({ item, attachedTurn }: TaskChatBubbleProps) {
export function TaskChatBubble({ item, attachedTurn, actions }: TaskChatBubbleProps) {
if (item.interstitial) {
// Interstitial updates are ephemeral (PAP-361): while streaming the text
// lives on the live parent row's line (TaskChatStatusItem.selfTalk), and
@ -146,10 +153,24 @@ export function TaskChatBubble({ item, attachedTurn }: TaskChatBubbleProps) {
</span>
) : attachedTurn ? (
// The settled turn takes over the footer line: timestamp + "✓ Worked"
// summary, always visible; expanding stretches beneath the bubble.
// summary, always visible; expanding stretches beneath the bubble. The
// copy/👍/👎 actions (PAP-413) ride the turn's summary row via its
// `leading` slot — not this wrapper — so they stay anchored to the
// summary line when the tool history expands beneath it.
<div className="self-stretch" data-testid="task-chat-bubble-attached-turn">
{attachedTurn}
</div>
) : actions ? (
// Agent reply without run activity: the actions still lead the footer,
// with the always-visible timestamp trailing (PAP-413).
<div className="flex items-center gap-1">
{actions}
{item.timestamp ? (
<span className="px-1 text-(length:--text-micro) text-muted-foreground">
{item.timestamp}
</span>
) : null}
</div>
) : item.timestamp ? (
// Timestamps are always visible (round 9) — no longer hover-revealed.
<span className="px-1 text-(length:--text-micro) text-muted-foreground">

View File

@ -0,0 +1,50 @@
import type {
FeedbackDataSharingPreference,
FeedbackVoteValue,
} from "@paperclipai/shared";
import {
BubbleCopyButton,
IssueChatFeedbackButtons,
} from "@/components/AgentBubbleActionRow";
/** Feedback-vote wiring for an agent bubble, resolved per comment by the host. */
export interface TaskChatBubbleFeedback {
activeVote: FeedbackVoteValue | null;
sharingPreference: FeedbackDataSharingPreference;
termsUrl: string | null;
onVote: (
vote: FeedbackVoteValue,
options?: { allowSharing?: boolean; reason?: string },
) => Promise<void>;
}
/**
* Compact copy · 👍 · 👎 cluster prepended to an agent bubble's footer line
* (PAP-413), leading the "✓ Worked · …" turn summary (or the bare timestamp
* when the reply had no run activity). It reuses the shared
* {@link BubbleCopyButton} and {@link IssueChatFeedbackButtons} so the
* redesigned task thread speaks the same footer language as the conference
* room's {@link AgentBubbleActionRow} without re-declaring their markup; the
* timestamp stays owned by the summary/bubble, so it is not duplicated here.
*/
export function TaskChatBubbleActions({
copyText,
feedback,
}: {
copyText: string;
feedback?: TaskChatBubbleFeedback | null;
}) {
return (
<div className="flex items-center gap-0.5" data-testid="task-chat-bubble-actions">
<BubbleCopyButton copyText={copyText} />
{feedback ? (
<IssueChatFeedbackButtons
activeVote={feedback.activeVote}
sharingPreference={feedback.sharingPreference}
termsUrl={feedback.termsUrl}
onVote={feedback.onVote}
/>
) : null}
</div>
);
}

View File

@ -1,6 +1,10 @@
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
import type { TaskChatInteractionItem, TaskChatItem } from "./task-chat-model";
import type {
TaskChatInteractionItem,
TaskChatItem,
TaskChatMessageItem,
} from "./task-chat-model";
import { TaskChatTurn } from "./TaskChatTurn";
import { TaskChatBubble } from "./TaskChatBubble";
import { TaskChatMarker } from "./TaskChatMarker";
@ -30,6 +34,12 @@ interface TaskChatThreadViewProps {
* nothing without it.
*/
renderBrief?: () => ReactNode;
/**
* Renders the copy/👍/👎 action cluster prepended to an agent bubble's footer
* line (PAP-413). The live thread binds it to the feedback-vote API; harness
* fixtures omit it and the bubbles render actionless.
*/
renderMessageActions?: (item: TaskChatMessageItem) => ReactNode;
className?: string;
/** When false, render the list without the scroll container (e.g. previews). */
scroll?: boolean;
@ -40,23 +50,33 @@ function renderItem(
onApprovalDecision?: (statusItemId: string, optionId: string) => void,
renderInteraction?: (item: TaskChatInteractionItem) => ReactNode,
renderBrief?: () => ReactNode,
renderMessageActions?: (item: TaskChatMessageItem) => ReactNode,
) {
switch (item.kind) {
case "message":
case "message": {
// Compute the actions once: the bubble renders them for a runless reply
// (footer = actions + timestamp), while an attached turn hands them to
// TaskChatTurn's `leading` slot so they ride the summary line and stay
// put when the tool history expands (PAP-413). The two paths are mutually
// exclusive at runtime, so only one host ever mounts the node.
const actions = renderMessageActions?.(item);
return (
<TaskChatBubble
item={item}
actions={actions}
attachedTurn={
item.attachedTurn ? (
<TaskChatTurn
item={item.attachedTurn}
timestampPrefix={item.timestamp}
leading={actions}
renderChild={(child) => renderItem(child, onApprovalDecision)}
/>
) : undefined
}
/>
);
}
case "marker":
return <TaskChatMarker item={item} />;
case "thinking":
@ -107,6 +127,7 @@ export function TaskChatThreadView({
onApprovalDecision,
renderInteraction,
renderBrief,
renderMessageActions,
className,
scroll = true,
}: TaskChatThreadViewProps) {
@ -118,7 +139,9 @@ export function TaskChatThreadView({
</div>
) : null}
{items.map((item) => (
<div key={item.id}>{renderItem(item, onApprovalDecision, renderInteraction, renderBrief)}</div>
<div key={item.id}>
{renderItem(item, onApprovalDecision, renderInteraction, renderBrief, renderMessageActions)}
</div>
))}
</div>
);

View File

@ -226,6 +226,28 @@ describe("TaskChatTurn", () => {
expect(fold()?.getAttribute("data-folded")).toBe("false");
});
it("rides the `leading` slot on the header row, anchored above the fold (PAP-413)", () => {
flushSync(() => {
root.render(
<TaskChatTurn
item={SETTLED}
leading={<div data-testid="turn-lead">actions</div>}
renderChild={(c) => <span>{c.id}</span>}
/>,
);
});
const lead = container.querySelector('[data-testid="turn-lead"]');
expect(lead).not.toBeNull();
// It sits beside the summary button on the header row — NOT inside the
// expandable fold — so expanding the tool history can't drag it down.
expect(fold()?.contains(lead!)).toBe(false);
expect(summaryBtn()).not.toBeNull();
// Expanding still works and the leading slot stays put outside the fold.
flushSync(() => summaryBtn()!.click());
expect(fold()?.getAttribute("data-folded")).toBe("false");
expect(fold()?.contains(lead!)).toBe(false);
});
it("leads the settled summary with the bubble timestamp when attached (round 9)", () => {
flushSync(() => {
root.render(

View File

@ -13,6 +13,13 @@ interface TaskChatTurnProps {
* always visible, in the slot the hover-only timestamp used to occupy.
*/
timestampPrefix?: string;
/**
* Content rendered on the header row, leading the summary line (PAP-413: the
* copy/👍/👎 action cluster). It sits beside the summary button NOT inside
* the expandable fold so it stays anchored to the summary line when the
* tool history expands beneath it, instead of drifting to the fold's center.
*/
leading?: ReactNode;
}
/** Metric segments after the label: "38s · 3 tools · +34 3 · 12.3k tokens". */
@ -50,7 +57,7 @@ export function turnSummaryText(summary: TaskChatTurnItem["summary"]): string {
* `liveStatus` (harness fixtures) renders its children expanded with no header
* and folds when it settles.
*/
export function TaskChatTurn({ item, renderChild, timestampPrefix }: TaskChatTurnProps) {
export function TaskChatTurn({ item, renderChild, timestampPrefix, leading }: TaskChatTurnProps) {
const parentRow = !item.settled && item.liveStatus != null;
// Parent-row live turns and settled turns start as their one-line header;
// only the headerless legacy live turn starts expanded.
@ -73,40 +80,52 @@ export function TaskChatTurn({ item, renderChild, timestampPrefix }: TaskChatTur
const folded = (item.settled || parentRow) && !open;
const SummaryIcon = item.summary.failed ? X : Check;
const header = item.settled ? (
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="group flex items-center gap-2 px-1 py-0.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
data-testid="task-chat-turn-summary"
>
{timestampPrefix ? (
<>
<span className="text-(length:--text-micro)">{timestampPrefix}</span>
<span aria-hidden className="text-(length:--text-micro)">·</span>
</>
) : null}
<SummaryIcon className="h-3.5 w-3.5 shrink-0" />
<span>{item.summary.failed ? "Stopped" : "Worked"}</span>
{turnSummaryMetrics(item.summary) ? (
<span className="font-mono text-(length:--text-micro)">{turnSummaryMetrics(item.summary)}</span>
) : null}
<ChevronRight className={cn("h-3 w-3 shrink-0 transition-transform", open ? "rotate-90" : null)} />
</button>
) : parentRow ? (
// The pill renders the expand button itself, wrapped around only the
// gerund status line — the interstitial row above stays outside the
// hover/click target (PAP-376). Without activity there is no
// chevron/button.
<TaskChatStatusPill
item={item.liveStatus!}
chevronOpen={expandable ? open : undefined}
onToggle={expandable ? () => setOpen((o) => !o) : undefined}
/>
) : null;
return (
<div data-testid="task-chat-turn" data-settled={item.settled ? "true" : "false"}>
{item.settled ? (
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className="group flex items-center gap-2 px-1 py-0.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
data-testid="task-chat-turn-summary"
>
{timestampPrefix ? (
<>
<span className="text-(length:--text-micro)">{timestampPrefix}</span>
<span aria-hidden className="text-(length:--text-micro)">·</span>
</>
) : null}
<SummaryIcon className="h-3.5 w-3.5 shrink-0" />
<span>{item.summary.failed ? "Stopped" : "Worked"}</span>
{turnSummaryMetrics(item.summary) ? (
<span className="font-mono text-(length:--text-micro)">{turnSummaryMetrics(item.summary)}</span>
) : null}
<ChevronRight className={cn("h-3 w-3 shrink-0 transition-transform", open ? "rotate-90" : null)} />
</button>
) : parentRow ? (
// The pill renders the expand button itself, wrapped around only the
// gerund status line — the interstitial row above stays outside the
// hover/click target (PAP-376). Without activity there is no
// chevron/button.
<TaskChatStatusPill
item={item.liveStatus!}
chevronOpen={expandable ? open : undefined}
onToggle={expandable ? () => setOpen((o) => !o) : undefined}
/>
) : null}
{leading ? (
// Actions ride the header row (items-center matches them to the summary
// line), and the fold below is a separate sibling — so expanding the
// tool history never moves the actions off the summary line (PAP-413).
<div className="flex items-center gap-1">
{leading}
{header}
</div>
) : (
header
)}
<div className="tc-turn-fold" data-folded={folded ? "true" : "false"} aria-hidden={folded}>
<div>
<div className="flex flex-col gap-2 pt-1">

View File

@ -8,8 +8,32 @@ import {
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 } from "@/components/task-chat/task-chat-model";
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;
@ -210,7 +234,7 @@ export function TaskChatLab() {
</div>
) : (
<div className="mx-auto flex min-h-0 w-full max-w-2xl flex-1 flex-col">
<TaskChatThreadView items={items} />
<TaskChatThreadView items={items} renderMessageActions={labMessageActions} />
</div>
)}
</div>