Files
PaperClipAI/ui/src/components/task-chat/TaskChatTurn.tsx
T
Devin FoleyandPaperclip 4b2bd6563d fix(chat): hide obsolete execution status and system notices (#14874)
## Thinking Path

> - Paperclip lets people oversee agent work through task conversations.
> - Conversations should show failures and waits that still affect the
task.
> - Old run errors and recovery notices remained visible after later
work or task completion.
> - These messages looked current even when no action remained.
> - This change hides obsolete execution status while preserving the
responses and activity.
> - The full diagnostic record stays available in run history.

## Linked Issues or Issue Description

**What happened?**

Task chat kept showing “Run failed”, “Stopped”, and “Waiting to resume”
after the execution had been superseded or the task had finished. Stored
system notices also remained in the conversation. Completed tasks
disabled Retry but kept the error message.

**Expected behavior**

Hide system status that no longer applies. Keep the latest unresolved
failure, active recovery holds, and useful recovery actions visible.
Preserve messages, files, questions, session boundaries, and inspectable
activity.

**Steps to reproduce**

1. Let a task run fail, then record a pre-start recovery wait.
2. Complete a later attempt or mark the task done.
3. Open the task conversation. Before this change, the old error and
wait remain visible.

**Paperclip version or commit**

Reproduced in component tests against `0f9e9be408`.

**Deployment mode**

Task chat in local or hosted deployments; both legacy adapters and the
native runner.

Related: #14857 and #14869 address execution recovery. This PR addresses
the remaining conversation presentation. Searched GitHub for historical
chat status, historical errors, and “Waiting to resume”; no duplicate PR
found.

## What Changed

- Determine status relevance from task state, attempt order, successor
evidence, and recovery state. Time alone does not hide errors.
- Hide obsolete run markers and stored execution notices. Require run or
recovery provenance, so unrelated system updates such as child-task
blockers remain visible. Keep errors from the latest failed attempt
actionable.
- Keep unresolved execution holds visible. A refused pre-start retry
does not replace a real attempt, and another agent’s work does not
resolve a run-specific error.
- Show historical activity without Worked/Stopped labels. Keep
historical failures out of the current turn’s summary.
- Anchor activity to visible comments so removing a notice cannot remove
the response or activity with it.
- Document the presentation rules and cover both runner modes and both
task presentation modes.

## Verification

- 334 focused component and status-policy tests passed across four
files, including the child-task relay regressions.
- `pnpm build` passed. The UI build also passed after the final
presentation changes.
- `pnpm exec vitest run --project @paperclipai/ui`: 667 files and 7,157
tests passed. Subsequent focused tests cover the final activity-anchor,
live-successor, and notice-provenance changes.
- `pnpm -r typecheck` passed. UI typecheck and build passed again after
the review fix.
- `pnpm test:run` completed its general-server phase with 14,716 tests
passed, 17 failed, and 87 skipped; it stopped before later phases. The
failures occurred in four unchanged server suites: chat channels, email
channels, company skills, and runtime skill cache. A targeted rerun
reproduced missing bundled skill paths and `EACCES` during
cache-directory rename on macOS. All Linux CI suites pass for the final
commit, including these server suites.
- Design token gates and diff checks pass.
- All 53 checks pass on commit `7af9753859`; two optional Storybook
checks are skipped. [Final CI
run](https://github.com/paperclipai/paperclip/actions/runs/36931968751)
includes build, full typecheck, all server and workspace test shards,
all eight end-to-end shards, runner verification, and the canary dry
run.
- Greptile scores the final commit at 5/5. No review threads remain
unresolved. The branch is current with `master` and has no merge
conflicts.

## Risks

This changes presentation only. It does not change execution, recovery,
stored comments, or run history. The main risk is hiding a current
diagnostic too early. Tests cover active holds, refused retries,
different agents, missing timestamps and provenance, live successors,
preserved responses, and the current retry target.

The base branch has a dependency override/lockfile mismatch. Local
installation used the same resolution fallback as CI, then restored the
tracked lockfile. No dependency changes are included.

## Model Used

OpenAI Codex (GPT-6). The exact runtime model identifier and context
window are not exposed in this session. Used reasoning, repository
inspection, code execution, and regression tests.

## 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
- [ ] I have run tests locally and they pass — changed-code tests pass;
unrelated full-suite failures are documented above
- [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-10-01 15:18:47 -07:00

296 lines
11 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 { useState, type ReactNode } from "react";
import { cn } from "@/lib/utils";
import { useStreamlinedTaskChatPresentation } from "./presentation-mode";
import { Check, ChevronRight, X } from "lucide-react";
import { MarkdownBody } from "@/components/MarkdownBody";
import type {
TaskChatTurnItem,
TaskChatTurnChildItem,
} from "./task-chat-model";
import { TaskChatStatusPill } from "./TaskChatStatusPill";
import { TaskChatAgentIdentity } from "./TaskChatBubble";
interface TaskChatTurnProps {
item: TaskChatTurnItem;
renderChild: (child: TaskChatTurnChildItem) => ReactNode;
/**
* When the settled turn is attached to its reply bubble (round 9), the
* bubble's timestamp leads the summary line — "2:34 PM · ✓ Worked · 38s" —
* always visible, in the slot the hover-only timestamp used to occupy.
*/
timestampPrefix?: string;
/**
* Content rendered on the header row (PAP-413: the copy/👍/👎 action
* cluster). The inspection caret and timestamp stay left; actions sit at the
* stable right edge, outside the expandable fold.
*/
leading?: ReactNode;
}
/** Metric segments after the label: "38s · 3 tools · +34 −3 · 12.3k tokens". */
export function turnSummaryMetrics(
summary: TaskChatTurnItem["summary"],
): string {
const parts: string[] = [];
if (summary.durationLabel) parts.push(summary.durationLabel);
if (summary.toolCount > 0)
parts.push(
`${summary.toolCount} tool${summary.toolCount === 1 ? "" : "s"}`,
);
if (summary.added > 0 || summary.removed > 0)
parts.push(`+${summary.added} −${summary.removed}`);
if (summary.tokensLabel) parts.push(summary.tokensLabel);
return parts.join(" · ");
}
/** "✓ Worked · 38s · 3 tools · +34 −3 · 12.3k tokens" (parts omitted when unknown). */
export function turnSummaryText(summary: TaskChatTurnItem["summary"]): string {
const metrics = turnSummaryMetrics(summary);
const label = summary.failed ? "Stopped" : "Worked";
return metrics ? `${label} · ${metrics}` : label;
}
/**
* One agent turn's activity behind a single expandable header row. Paperclip
* Runner turns opt into the chronological-timeline branch below instead.
*
* While the turn is live and carries `liveStatus`, that status line (whimsy or
* taxonomy gerund + elapsed + tokens) IS the turn: one parent row, collapsed
* by default, owning all activity (PAP-354). Expanding nests the chronological
* tool/thinking history underneath — each row keeping its own expand — and new
* rows append live while open. On settle the header morphs in place into the
* "✓ Worked · …" summary, preserving the expand state, so the running line
* reads as being replaced by the summary in the same slot.
*
* Both directions of the fold reuse the .tc-turn-fold grid-rows motion
* (--motion-turn-fold, zeroed under prefers-reduced-motion). The fold class
* only transitions on state CHANGE, so first paint in the folded state (e.g. a
* turn that loads already settled) never animates. A live turn without
* `liveStatus` (harness fixtures) renders its children expanded with no header
* and folds when it settles.
*/
export function TaskChatTurn({
item,
renderChild,
timestampPrefix,
leading,
}: TaskChatTurnProps) {
const streamlined = useStreamlinedTaskChatPresentation();
const parentRow = !item.settled && item.liveStatus != null;
// The new Paperclip Runner task surface owns one durable chronological
// timeline. The Worked/Stopped row is its stable header, so it stays directly
// below the preceding human bubble and above commentary, activity phases,
// request receipts, and plan artifacts. The classic task interface continues
// to use the run-wide fold below.
if (item.standaloneHeader) {
return (
<div
data-testid="task-chat-turn"
data-settled={item.settled ? "true" : "false"}
>
{item.agentName || !item.historical ? <div
className="flex min-h-8 w-full min-w-0 items-center gap-2 pb-1 pt-1.5 text-sm text-muted-foreground"
data-testid="task-chat-turn-summary"
data-turn-position="identity"
>
{item.agentName ? (
<TaskChatAgentIdentity
agentName={item.agentName}
agentIcon={item.agentIcon} agent={item.agent}
/>
) : null}
{!item.historical ? <span className="min-w-0 truncate">
{item.continuedAfterSteering ? "Continued after steering · " : ""}
{item.summary.durationLabel
? `${item.summary.failed ? "Stopped" : "Worked"} for ${item.summary.durationLabel}`
: item.summary.failed
? "Stopped"
: "Worked"}
</span> : null}
</div> : null}
{item.items.length > 0 ? (
<div
className="flex min-w-0 flex-col gap-2 py-1"
data-testid="task-chat-turn-timeline"
>
{item.items.map((child) => (
<div
className="min-w-0"
key={child.id}
data-testid="task-chat-turn-timeline-row"
data-timeline-row-id={child.id}
data-thread-anchor={child.id}
>
{renderChild(child)}
</div>
))}
</div>
) : null}
{item.finalResponse ? (
<div className="w-full" data-testid="task-chat-final-response">
<div
className="break-words px-1 py-2 text-sm text-foreground"
data-testid="task-chat-agent-bubble"
>
<MarkdownBody softBreaks linkIssueReferences>
{item.finalResponse.text}
</MarkdownBody>
</div>
</div>
) : null}
</div>
);
}
const persistentItems = item.settled
? item.items.filter(
(child) =>
child.kind === "protocol" && child.surface === "runtime_request",
)
: [];
const persistentItemIds = new Set(persistentItems.map((child) => child.id));
const foldedItems =
persistentItems.length > 0
? item.items.filter((child) => !persistentItemIds.has(child.id))
: item.items;
// Parent-row live turns and settled turns start as their one-line header;
// only the headerless legacy live turn starts expanded.
const [open, setOpen] = useState(
() => !item.settled && item.liveStatus == null,
);
// Historical folds can contain thousands of tool/reasoning rows. Mount them
// on first inspection, then retain them for closing motion and child state.
const [historyMounted, setHistoryMounted] = useState(open);
if (open && !historyMounted) setHistoryMounted(true);
const [prevSettled, setPrevSettled] = useState(item.settled);
const [wasParentRow, setWasParentRow] = useState(parentRow);
// Render-adjusted state (not effects) so transitions land in the same
// commit — no flash of the wrong fold state.
if (parentRow && !wasParentRow) setWasParentRow(true);
// Live → settled while mounted: a parent-row turn keeps its expand state
// (the header morphs in place); a headerless expanded turn folds (animated
// via CSS unless reduced motion).
if (item.settled !== prevSettled) {
setPrevSettled(item.settled);
if (item.settled && !wasParentRow) setOpen(false);
}
const expandable = foldedItems.length > 0;
const folded = (item.settled || parentRow) && !open;
const SummaryIcon = item.summary.failed ? X : Check;
const showSettledHeader = item.settled && (!item.historical || expandable);
const header = showSettledHeader ? (
<button
type="button"
onClick={() => setOpen((o) => !o)}
aria-expanded={open}
className={cn(
"group flex w-full items-center gap-2 px-1 text-muted-foreground transition-colors hover:text-foreground",
item.standaloneHeader
? "border-b border-border/70 py-2 text-sm"
: "py-0.5 text-xs",
streamlined && "min-w-0",
)}
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}
{!item.standaloneHeader && !item.historical ? (
<SummaryIcon className="h-3.5 w-3.5 shrink-0" />
) : null}
<span>
{item.historical ? "Activity" : item.standaloneHeader && item.summary.durationLabel
? `${item.summary.failed ? "Stopped" : "Worked"} for ${item.summary.durationLabel}`
: item.summary.failed
? "Stopped"
: "Worked"}
</span>
{!item.standaloneHeader && turnSummaryMetrics(item.summary) ? (
// Time/tools/tokens is demoted, not deleted (PAP-502): it stays in the
// DOM (and the accessible tree) but fades in only on hover/focus so the
// settled line reads as "2:34 PM · ✓ Worked" at rest. Revealed too when
// the fold is open, so the metrics don't vanish while you read below.
<span
className="tc-turn-metrics"
data-visible={open ? "true" : "false"}
>
<span className="min-w-0 overflow-hidden whitespace-nowrap font-mono text-(length:--text-micro)">
{turnSummaryMetrics(item.summary)}
</span>
</span>
) : null}
<ChevronRight
className={cn(
"h-3 w-3 shrink-0 transition-transform",
item.standaloneHeader && "ml-auto",
open ? "rotate-90" : null,
)}
aria-hidden
/>
</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"}
>
{leading ? (
streamlined ? (
<div className="flex w-full items-center justify-between gap-2">
<div className="min-w-0">{header}</div>
<div className="shrink-0">{leading}</div>
</div>
) : (
<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">
{historyMounted && foldedItems.map((child) => (
<div key={child.id}>{renderChild(child)}</div>
))}
</div>
</div>
</div>
{persistentItems.length > 0 ? (
<div
className="flex flex-col gap-2 pt-2"
data-testid="task-chat-turn-persistent-history"
>
{persistentItems.map((child) => (
<div key={child.id}>{renderChild(child)}</div>
))}
</div>
) : null}
</div>
);
}