Files
PaperClipAI/ui/src/components/task-chat/TaskChatThreadView.tsx
T
DottaandPaperclip a0ac81e114 fix(ui): show task image results in compact thumbnail grids (#19626)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Task feeds show agent messages, documents, and files in time order.
> - Each image currently uses a full feed card.
> - A turn with seven screenshots makes the conversation hard to scan.
> - This pull request groups adjacent images from the same run in a
compact grid.
> - Users can scan the results and open each image in the existing
gallery.

## Linked Issues or Issue Description

**What existing behavior does this improve?**

Image results in task history and the live task feed.

**Current behavior**

Each image uses a separate full-width card. Native image upload receipt
messages can also separate related screenshots.

**Proposed behavior**

Adjacent images from one run share a wrapping thumbnail grid. New image
results join the grid. Documents, video, and authored messages keep
their positions. The grid opens the existing gallery.

**Reason and benefit**

Users can see a batch of screenshots without scrolling past one large
card for each file.

**Breaking changes**

The feed layout changes. The stored comments, files, APIs, and
permissions do not change.

Related public work: Refs #17228. Refs #8785. Refs #7667. These changes
supplied the artifact cards and media gallery that this pull request
reuses.

## What Changed

- Add one image grouping function for settled history, turn history, and
live results. Group only images with the same known run. Keep the first
row key and each image key when results arrive.
- Add a token-based grid that reuses the media artifact cards and image
gallery.
- Add an optional typed attachment-preparation marker to file
registration and chat-file reuse. Preserve the marker through comment
normalization. Use it with matching image, author, and run links to hide
generated receipts. Keep the exact historical sentence as a fallback for
old receipts.
- Add nine production component stories with seven sample-account
screenshots. Cover mobile, Classic, mixed results, single images,
missing images, streaming, gallery navigation, and the complete feed
projection.
- Add grouping and production-feed regressions. Add 18 browser cases
across light and dark themes.
- Improve contrast for the shared fallback label and gallery counter
after the accessibility checks found low contrast.
- Match alternate original-image paths when opening the gallery. Keep a
separate Open original link beside Download in the gallery.
- Record the feed layout in DESIGN.md.

## Verification

- Final qualification at `09b12381ce85bee6593050bd8a2fa18cbe2c45a6`:
[required CI
passed](https://github.com/paperclipai/paperclip/actions/runs/42317080212/job/128237094513),
including typecheck, build, all test lanes, E2E, and verify. The
snapshot has 58 successful check runs, three conditional skips, one
neutral advisory job, and a successful legacy Snyk status. No pending or
failed checks remain. Greptile and Paperclip Review both score this
exact head 5/5; all review threads are resolved.

- Current-head local checks: 544 focused UI tests passed across 11
files, with one existing skip. UI/shared typechecks and token gates
pass. All 18 focused Storybook browser cases passed after the CSS merge,
with retries disabled.
- Two database integration cases verify file registration and chat-file
reuse receipt markers. All 52 issue-validator tests and six upstream
default-model tests passed.
- Regressions failed before the fixes for unknown-run grouping, receipt
wording, and authored progress text with an image. The repair uses a
typed receipt marker and requires matching author, attachment, image,
and run links before hiding a generated receipt. All five review
findings are resolved.
- Browser checks cover loaded images, wrapping columns, overflow,
missing-image fallback, streaming stability, keyboard gallery
navigation, original-file links, and downloads. Fixtures use production
renderers. These checks do not prove deployed persistence or provider
behavior and do not compare visual baselines.
- Local limits: the preflight helper could not finish because Cargo was
absent. The earlier low-memory server typecheck and dependency-free
selector did not replace final-head CI. The now-passing final CI
supplies broad typecheck, build, server, Runner, and E2E coverage.
- Prior independent CI failures remain recorded: one Slack timer case
passed a single diagnosed rerun; its exact timer cause was not proved. A
later chat shard lost its self-hosted runner without a test assertion
failure. The current revision passes that shard and all applicable
gates. Master conflict resolution retained both image-grid and patch CSS
rules.

## Risks

- Images without a run ID remain separate. Messages and other resource
types end each batch.
- Receipt suppression requires a matching preparation marker or the
exact legacy sentence, plus matching attachment, image work product,
run, and author. Tests preserve human messages and native progress text
with attachments. The optional marker uses existing JSON metadata and
needs no database migration.
- Shared gallery and fallback text now have stronger contrast.

## Model Used

OpenAI GPT-6, through Codex. The agent used reasoning, repository tools,
code execution, and browser tests. The runtime did not expose a more
specific model ID or context window size.

## 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>
2026-11-06 18:42:22 -06:00

489 lines
19 KiB
TypeScript

import { groupImageResources } from "./image-resources";
import { TaskBrowserActivity } from "../task-side-panel/TaskBrowserActivity";
import { TaskChatProjectCreatedCard } from "./TaskChatProjectCreatedCard";
import { TaskChatSkillCreatedCard } from "./TaskChatSkillCreatedCard";
import { TaskChatTaskCreatedCard } from "./TaskChatTaskCreatedCard";
import { useMemo, type ReactNode } from "react";
import type { IssueAttachment } from "@paperclipai/shared";
import { cn } from "@/lib/utils";
import { useStreamlinedTaskChatPresentation } from "./presentation-mode";
import type {
TaskChatInteractionItem,
TaskChatItem,
TaskChatMessageItem,
TaskChatRuntimeRequestDecision,
TaskChatRuntimeRequestItem,
} from "./task-chat-model";
import { TaskChatTurn } from "./TaskChatTurn";
import { TaskChatBubble } from "./TaskChatBubble";
import { TaskChatMarker } from "./TaskChatMarker";
import { TaskChatStatusPill } from "./TaskChatStatusPill";
import { TaskChatToolCard } from "./TaskChatToolCard";
import { TaskChatUsageReadout } from "./TaskChatUsageReadout";
import { TaskChatSubagentTree } from "./TaskChatSubagentCard";
import { TaskChatRunnerActivityGroup } from "./TaskChatRunnerActivityGroup";
import { TaskChatActivityPhase } from "./TaskChatActivityPhase";
import { TaskChatThinking } from "./TaskChatThinking";
import { TaskMessageScroller, type ScrollingHistory } from "./TaskMessageScroller";
import { TaskChatProtocolCard } from "./TaskChatProtocolCard";
import { TaskChatProtocolActivityRow } from "./TaskChatProtocolActivityRow";
import { TaskChatPlanPreviewCard } from "./TaskChatPlanPreviewCard";
import { compactAdjacentErrors } from "./adjacent-errors";
import { CoordinatorChatTaskHandoff } from "@/components/coordinator/CoordinatorChatTaskHandoffs";
import { CoordinatorChatDecisionReceipt } from "@/components/coordinator/CoordinatorChatDecisionReceipts";
import { CoordinatorChatPendingQuestion } from "@/components/coordinator/CoordinatorChatPendingQuestions";
const EMPTY_ATTACHMENTS: IssueAttachment[] = [];
interface TaskChatThreadViewProps {
scrollingHistory?: ScrollingHistory;
items: TaskChatItem[];
/**
* Content rendered above the first message INSIDE the scroll viewport (the
* issue header when hosted by the live thread) — it scrolls away with the
* messages instead of staying pinned above the thread.
*/
header?: ReactNode;
onApprovalDecision?: (statusItemId: string, optionId: string) => void;
onRuntimeRequestDecision?: (
item: TaskChatRuntimeRequestItem,
decision: TaskChatRuntimeRequestDecision,
) => void | Promise<void>;
/**
* Renders an interleaved issue-thread interaction (the live thread supplies
* TaskChatInteractionCard bound to its accept/reject handlers). Interaction
* items render nothing without it — the harness has no control plane.
*/
renderInteraction?: (item: TaskChatInteractionItem) => ReactNode;
/**
* Renders the description-as-first-bubble placeholder (PAP-375). The live
* thread binds TaskChatDescriptionBubble to the issue; brief items render
* 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;
/** Renders an interrupt action beside a queued human message. */
renderQueuedAction?: (item: TaskChatMessageItem) => ReactNode;
/** Requeues a blocked task from its no-live-execution-path failure surfaces. */
onTryAgainNoLiveExecutionPath?: () => Promise<void> | void;
tryAgainNoLiveExecutionPathPending?: boolean;
onRetryFailedRun?: (runId: string) => Promise<void> | void;
retryFailedRunId?: string | null;
/** Content appended inside the transcript scroller after the settled thread. */
tail?: ReactNode;
/** Optional streaming-aware key when `tail` changes without changing `items`. */
contentKey?: unknown;
className?: string;
/** When false, render the list without the scroll container (e.g. previews). */
scroll?: boolean;
attachments?: IssueAttachment[];
onOpenSkill?: (skillId: string, name: string) => void;
onOpenBrowser?: (browserId: string) => void;
}
function renderItem(
item: TaskChatItem,
onApprovalDecision?: (statusItemId: string, optionId: string) => void,
renderInteraction?: (item: TaskChatInteractionItem) => ReactNode,
renderBrief?: () => ReactNode,
renderMessageActions?: (item: TaskChatMessageItem) => ReactNode,
renderQueuedAction?: (item: TaskChatMessageItem) => ReactNode,
onRuntimeRequestDecision?: (
item: TaskChatRuntimeRequestItem,
decision: TaskChatRuntimeRequestDecision,
) => void | Promise<void>,
activityAppearance: "classic" | "runner" = "classic",
onTryAgainNoLiveExecutionPath?: () => Promise<void> | void,
tryAgainNoLiveExecutionPathPending = false,
retryableMarkerId?: string,
onRetryFailedRun?: (runId: string) => Promise<void> | void,
retryFailedRunId?: string | null,
attachments: IssueAttachment[] = [],
onOpenSkill?: (skillId: string, name: string) => void,
onOpenBrowser?: (browserId: string) => void,
) {
switch (item.kind) {
case "pending_question": return <CoordinatorChatPendingQuestion item={item.question} />;
case "task_handoff": return <CoordinatorChatTaskHandoff handoff={item.handoff} animateEntry={item.animateEntry} />;
case "decision_receipt": return <CoordinatorChatDecisionReceipt receipt={item.receipt} />;
case "browser": return <TaskBrowserActivity browser={item.browser} label={item.label} onOpen={onOpenBrowser} />;
case "project_created": return <TaskChatProjectCreatedCard item={item} />;
case "skill_created": return <TaskChatSkillCreatedCard item={item} onOpen={onOpenSkill} />;
case "task_created": return <TaskChatTaskCreatedCard item={item} />;
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);
const attachedTurnItem = item.attachedTurn?.standaloneHeader
? {
...item.attachedTurn,
agentName: item.attachedTurn.agentName ?? item.authorName,
agentIcon: item.attachedTurn.agentIcon ?? item.agentIcon,
agent: item.attachedTurn.agent ?? item.agent,
}
: item.attachedTurn;
const turn = attachedTurnItem ? (
<TaskChatTurn
item={attachedTurnItem}
timestampPrefix={
attachedTurnItem.standaloneHeader ? undefined : item.timestamp
}
leading={attachedTurnItem.standaloneHeader ? undefined : actions}
renderChild={(child) =>
renderItem(
child,
onApprovalDecision,
undefined,
undefined,
undefined,
undefined,
onRuntimeRequestDecision,
item.attachedTurn?.standaloneHeader ? "runner" : "classic",
undefined,
false,
undefined,
undefined,
undefined,
attachments,
onOpenSkill,
onOpenBrowser,
)
}
/>
) : undefined;
return (
<TaskChatBubble
item={item}
// Hydration and live-to-durable reconciliation may move a logical
// message between parents. Mounting must never replay a fade.
animateEntry={false}
actions={
item.attachedTurn?.standaloneHeader
? actions
: item.attachedTurn
? undefined
: actions
}
queuedAction={renderQueuedAction?.(item)}
beforeTurn={item.attachedTurn?.standaloneHeader ? turn : undefined}
attachedTurn={item.attachedTurn?.standaloneHeader ? undefined : turn}
hideAgentIdentity={Boolean(item.attachedTurn?.standaloneHeader)}
onTryAgainNoLiveExecutionPath={onTryAgainNoLiveExecutionPath}
tryAgainNoLiveExecutionPathPending={
tryAgainNoLiveExecutionPathPending
}
attachments={attachments}
/>
);
}
case "marker":
return (
<TaskChatMarker
item={item}
onTryAgain={
item.id === retryableMarkerId && item.retryable !== false
? item.runId && onRetryFailedRun
? () => onRetryFailedRun(item.runId!)
: onTryAgainNoLiveExecutionPath
: undefined
}
tryAgainPending={
item.runId
? retryFailedRunId === item.runId
: tryAgainNoLiveExecutionPathPending
}
/>
);
case "thinking":
return <TaskChatThinking item={item} />;
case "tool":
return <TaskChatToolCard item={item} />;
case "status":
return (
<TaskChatStatusPill
item={item}
onApprovalDecision={(optionId) =>
onApprovalDecision?.(item.id, optionId)
}
/>
);
case "usage":
return <TaskChatUsageReadout item={item} />;
case "activity_phase":
// Legacy adapter transcripts and native runner transcripts now share the
// same compact activity treatment. Keeping this decision at the common
// renderer boundary also gives old persisted runs the current taxonomy,
// alignment, one-line targets, and collapsed-by-default behavior.
return <TaskChatRunnerActivityGroup item={item} />;
case "interaction":
return renderInteraction ? renderInteraction(item) : null;
case "plan_document":
return (
<TaskChatPlanPreviewCard
source={{ kind: "saved", document: item.document }}
testId={
item.placement === "fallback"
? "task-chat-plan-preview-fallback"
: "task-chat-plan-preview"
}
/>
);
case "brief":
return renderBrief ? renderBrief() : null;
case "subagent_group":
return <TaskChatSubagentTree subagents={item.subagents} />;
case "turn":
return (
<TaskChatTurn
item={item}
renderChild={(child) =>
renderItem(
child,
onApprovalDecision,
undefined,
undefined,
undefined,
undefined,
onRuntimeRequestDecision,
item.standaloneHeader ? "runner" : "classic",
undefined,
false,
undefined,
undefined,
undefined,
attachments,
onOpenSkill,
onOpenBrowser,
)
}
/>
);
case "protocol":
return (
<TaskChatProtocolCard
item={item}
onRuntimeRequestDecision={onRuntimeRequestDecision}
/>
);
default: {
// Exhaustiveness guard: a new item kind must add a branch above.
const _never: never = item;
return _never;
}
}
}
function isSystemLikeItem(item: TaskChatItem): boolean {
return (
item.kind === "marker" ||
(item.kind === "message" && item.author === "system")
);
}
export function taskChatItemSpacingClass(
item: TaskChatItem,
previousItem: TaskChatItem | null,
): string | undefined {
if (!previousItem) return undefined;
const currentIsSystemLike = isSystemLikeItem(item);
const previousIsSystemLike = isSystemLikeItem(previousItem);
if (currentIsSystemLike && previousIsSystemLike) return "mt-2";
if (currentIsSystemLike || previousIsSystemLike) return "mt-3";
if (item.kind === "turn" || previousItem.kind === "turn") return "mt-3";
if (item.kind === "interaction" || previousItem.kind === "interaction")
return "mt-4";
return "mt-6";
}
/**
* Presentational render layer for the redesigned task thread. Consumed by both
* the live thread (adapter over comment/run props) and the dev harness
* (synthetic fixtures). Owns no data fetching — it maps a normalized
* TaskChatItem[] onto the primitives inside the auto-follow scroller.
*/
export function TaskChatThreadView({
scrollingHistory,
items,
header,
onApprovalDecision,
onRuntimeRequestDecision,
renderInteraction,
renderBrief,
renderMessageActions,
renderQueuedAction,
onTryAgainNoLiveExecutionPath,
tryAgainNoLiveExecutionPathPending = false,
onRetryFailedRun,
retryFailedRunId = null,
tail,
contentKey,
className,
scroll = true,
attachments = EMPTY_ATTACHMENTS,
onOpenSkill,
onOpenBrowser,
}: TaskChatThreadViewProps) {
const streamlined = useStreamlinedTaskChatPresentation();
const { items: compactedItems, retryMarkers } = useMemo(() => compactAdjacentErrors(items), [items]);
const visibleItems = useMemo(() => groupImageResources(compactedItems), [compactedItems]);
const retryableMarkerId =
onRetryFailedRun || onTryAgainNoLiveExecutionPath
? [...visibleItems]
.reverse()
.find(
(item) => {
const marker = item.kind === "marker" ? item : retryMarkers.get(item.id);
return marker?.variant === "interrupted" &&
(marker.label === "Run failed" || marker.label === "Usage limit reached");
},
)?.id
: undefined;
// Streaming tail and header updates must not rebuild settled markdown/tool trees.
const history = useMemo(() => {
const renderVisibleItem = (item: TaskChatItem) => {
const marker = item.id === retryableMarkerId ? retryMarkers.get(item.id) : undefined;
const runId = marker?.retryable !== false ? marker?.runId : undefined;
const runRetry = !onTryAgainNoLiveExecutionPath && onRetryFailedRun && runId
? () => onRetryFailedRun(runId)
: undefined;
return renderItem(
item, onApprovalDecision, renderInteraction, renderBrief, renderMessageActions,
renderQueuedAction, onRuntimeRequestDecision, "classic",
runRetry ?? onTryAgainNoLiveExecutionPath,
runRetry ? retryFailedRunId === runId : tryAgainNoLiveExecutionPathPending,
retryableMarkerId, onRetryFailedRun, retryFailedRunId, attachments, onOpenSkill, onOpenBrowser,
);
};
const renderedItems = streamlined
? visibleItems.map((item) => ({ item, content: renderVisibleItem(item) }))
.filter((entry) => entry.content !== null)
: [];
return (
<>
{streamlined
? renderedItems.map(({ item, content }, index) => (
<div
key={
item.kind === "message" ? (item.renderKey ?? item.id) : item.id
}
data-conversation-typography=""
data-thread-anchor={
item.kind === "message" ? (item.renderKey ?? item.id) : item.id
}
id={item.kind === "message" ? `comment-${item.id}` : undefined}
className={taskChatItemSpacingClass(
item,
renderedItems[index - 1]?.item ?? null,
)}
data-thread-item-kind={
item.kind === "message" ? item.author : item.kind
}
>
{content}
</div>
))
: visibleItems.map((item, index) => (
<div
key={
item.kind === "message" ? (item.renderKey ?? item.id) : item.id
}
data-conversation-typography=""
data-thread-anchor={
item.kind === "message" ? (item.renderKey ?? item.id) : item.id
}
id={item.kind === "message" ? `comment-${item.id}` : undefined}
className={cn(
index > 0 &&
item.kind === "interaction" &&
item.interaction.status !== "pending" &&
"-mt-3",
)}
>
{renderVisibleItem(item)}
</div>
))}
</>
);
}, [
visibleItems, retryMarkers, streamlined, onApprovalDecision, onRuntimeRequestDecision,
renderInteraction, renderBrief, renderMessageActions, renderQueuedAction,
onTryAgainNoLiveExecutionPath, tryAgainNoLiveExecutionPathPending,
retryableMarkerId, onRetryFailedRun, retryFailedRunId, attachments, onOpenSkill, onOpenBrowser,
]);
const body = (
<div
className={cn(
"paperclip-mobile-thread mx-auto flex w-full max-w-(--tc-shell-max-w) flex-col px-1 py-3 md:px-4 md:py-4",
streamlined ? "md:px-0" : "gap-5",
className,
)}
>
{header ? (
<div
className={cn("flex flex-col gap-6", streamlined ? "pb-4" : "pb-2")}
data-testid="task-chat-thread-header"
>
{header}
</div>
) : null}
{history}
{tail ? <div data-conversation-typography="" className={streamlined ? "mt-4" : undefined}>{tail}</div> : null}
</div>
);
if (!scroll) return body;
return (
<TaskMessageScroller contentKey={contentKey ?? taskChatContentKey(items)} scrollingHistory={scrollingHistory}>
{body}
</TaskMessageScroller>
);
}
// Cheap content signature so streaming growth (text lengthening without the
// item count changing) still advances the auto-follow key. Shared by the
// shared message scroller on desktop and mobile (TaskChatThread).
function signatureOf(it: TaskChatItem): number {
if (it.kind === "message") return it.text.length + (it.attachedTurn ? 1 : 0);
if (it.kind === "thinking") return it.lines.reduce((n, l) => n + l.length, 0);
if (it.kind === "tool")
return (it.diff?.lines?.length ?? 0) + (it.status === "completed" ? 1 : 0);
if (it.kind === "plan_document") {
return it.document.body.length + it.document.latestRevisionNumber;
}
if (it.kind === "turn") {
if (it.settled) return 1 + (it.finalResponse?.text.length ?? 0);
// The live parent row's header changes (gerund ↔ tool-state flashes,
// streaming interstitial text growing) count too, so the collapsed
// single-line turn still advances the key.
const headerSig = it.liveStatus
? it.liveStatus.label.length +
(it.liveStatus.detail?.length ?? 0) +
(it.liveStatus.selfTalk?.length ?? 0)
: 0;
return it.items.reduce(
(n, child) => n + signatureOf(child),
it.items.length + headerSig + (it.finalResponse?.text.length ?? 0),
);
}
if (it.kind === "activity_phase") {
return it.items.reduce(
(n, child) => n + signatureOf(child),
it.summary.length + (it.interstitial?.text.length ?? 0),
);
}
return 1;
}
export function taskChatContentKey(items: TaskChatItem[]): number {
return items.reduce((acc, it) => acc + signatureOf(it), items.length);
}