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:
parent
cfed36ea6b
commit
ea83c5c822
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in New Issue