mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-16 15:15:35 +02:00
## 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>
296 lines
11 KiB
TypeScript
296 lines
11 KiB
TypeScript
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>
|
||
);
|
||
}
|