mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 00:26:28 +01:00
feat(task-chat): show web searches, model switches, waits and verification checks as dedicated rows (#19262)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents run through the native runner. The runner turns harness activity into typed canonical events such as `research.*`, `model.route.changed`, `wait.*` and the run's own verification checks. > - The task chat flattened these events into generic tool rows. A web search showed as "Ran Research eventType: research.started, summary: …". The run's verification checks were dropped completely. > - Live tests found a second break: the Rust runner core forwarded Codex `webSearch` items without their query, action or results. The canonical `research.*` events therefore had `query: null`. > - This pull request keeps the full payload for these families (as #19252 did for plans), adds small dedicated rows, and keeps the web-search fields through the durable runner. > - The benefit is that users see what the agent searched for, which model actually ran, how long a wait lasts, and whether each check passed, without opening the run log. ## Linked Issues or Issue Description Refs #19252 (same pattern for the plan card) **Subsystem affected** Task chat activity rows (UI), the native run-event transcript mapping, and the Rust runner core Codex normalizer. **Problem or motivation** Provider activity that the runner already maps to canonical events reaches the task chat as unreadable generic rows. Verification checks never appear. For Codex, the web-search query and action are lost before they reach the canonical event. **Proposed solution** Keep `research`, `model_identity`, `artifact`, `review` and `wait` events as `provider_activity` entries with their full payload. Project the runner's `verification` items as a new `verification` family. Render dedicated rows: a web search with its quoted query and source-domain chips, a model switch as `from → to · reason`, a wait with a live countdown, and green/red check lines. Keep bounded web-search and sleep fields in the Rust normalizer. **Alternatives considered** A separate "Checks" block beside the final answer. We kept the checks in the activity group, because the group summary already leads with "Check failed" when a check fails. ## What Changed - `ui/src/components/transcript/native-run-events.ts`: keep research, model, artifact, review and wait events as `provider_activity` with the full payload. Map `item.completed` items of kind `verification` to the new `verification` family. Context, hook, safety and delegation stay on the generic path, because other work items own them. - `packages/adapter-utils/src/types.ts`, `ui/src/components/task-chat/task-chat-model.ts`, `ui/src/lib/issue-chat-messages.ts`: add the `verification` provider-activity family. - `ui/src/components/task-chat/task-chat-activity-presentation.ts`: add labels: "Searched the web “query”", "Opened a web page host/path", "Switched models a → b · reason", "Waiting 30s" / "Waited 30s", and "Check passed" / "Check failed" / "Check not run". - `ui/src/components/task-chat/TaskChatRunnerActivityGroup.tsx` and `TaskChatProtocolActivityRow.tsx`: show up to three source-domain chips under a finished search, a live countdown for a running wait, and a check line with a tinted icon and its detail. - `ui/src/components/task-chat/completed-activity-summary.ts`: the group summary leads with "Check failed" when a check fails, and says "Switched models" for a reroute. - `ui/src/components/task-chat/transcript-adapter.ts`: verification detail keys, and a `startedAt` timestamp that stays across lifecycle merges (used by the countdown). - `packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs`: keep bounded `query`, `action` and http(s) `results` for Codex `webSearch` items, and `durationMs` for `sleep` items. Other item types do not change. - `ui/storybook/stories/tasks/task-chat-provider-activity.stories.tsx`: new story. Its fixtures are persisted run events that go through the production transcript path. ## Verification - `pnpm --filter @paperclipai/ui exec vitest run src/components/transcript src/components/task-chat src/components/TaskChatThread.test.tsx src/adapters/paperclip-runner src/lib`: 275 files and 3669 tests pass. - `cargo test -p paperclip-runner-core`: all tests pass, including the new `keeps_bounded_web_search_and_sleep_identity`. - UI typecheck is clean. `pnpm check:token-gates` is clean. `cargo fmt --check` is clean. - Storybook: open "Tasks and conversations / … / Task chat / Provider activity". It shows a settled run, a live search, a live wait countdown and a narrow layout. - Live test on a local worktree instance with a real Codex agent (gpt-5.6-sol, Codex app-server 0.160). The task asked for a web search, a parser with tests, a pause, and two checks. The task chat showed "Searched the web “ISO 8601 week date first week Thursday conversion calendar date”" and two green check rows ("node --test week-date.test.js · 8 tests passed; 0 failed.", "node --check week-date.js"). The group summary read "Ran checks". Before the runner fix, the same search had `query: null`. Not verified live: - The source-domain chips. Codex 0.160 sends `results: null`, because it keeps search results on the server. The chips appear only when a provider reports sources. Unit tests and the story cover them. - The wait countdown and the model-switch row. In the live run Codex paused with a shell `sleep`, not a native `sleep` item, and no reroute occurred. Unit tests and the story cover both. - Claude. Its WebSearch and WebFetch calls arrive as generic tool executions, because ACPX drops the tool metadata. They show as tool rows, as before. The ACPX `_meta` work is separate. ## Risks - Low risk. The change only affects how canonical events display. The generic path stays for the families that this PR does not change. - The Rust normalizer now persists the web-search query and the source URLs/titles in the run events. This data was already intended for the canonical `research.v1` event, and it is bounded (4,000 characters per text field, 64 results, http(s) URLs only). - A search that Codex reports with `action: other` and no query shows as "Searched the web" with no subject. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Claude Opus 5.5 (`claude-opus-5-5`) in Claude Code, with tool use (shell, file edit, browser automation) and extended reasoning. ## 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 - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
053ae3920f
commit
b0fa2ef9bf
16 files changed
+909
-32
No files matched your search
@@ -9,6 +9,7 @@ import {
|
||||
} from "lucide-react";
|
||||
import { MarkdownBody } from "@/components/MarkdownBody";
|
||||
import { WorkspaceFileLink } from "@/components/WorkspaceFileLink";
|
||||
import { useSecondTick } from "@/hooks/useSecondTick";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useTaskChatShimmer } from "./TaskChatShimmer";
|
||||
import type {
|
||||
@@ -20,6 +21,7 @@ import type {
|
||||
TaskChatWorkspaceFileItem,
|
||||
} from "./task-chat-model";
|
||||
import {
|
||||
durationLabel,
|
||||
protocolActivityIsRunning,
|
||||
protocolActivityLabel,
|
||||
protocolActivityPresentation,
|
||||
@@ -28,6 +30,7 @@ import {
|
||||
|
||||
const COMPACT_RESEARCH_RESULT_LIMIT = 5;
|
||||
const COMPACT_WORKSPACE_FILE_LIMIT = 8;
|
||||
const RESEARCH_SOURCE_PREVIEW_LIMIT = 3;
|
||||
|
||||
function StatusIcon({ status }: { status: "running" | "completed" | "failed" | "interrupted" | "informational" }) {
|
||||
if (status === "running") return <Loader2 className="h-3.5 w-3.5 animate-spin text-(--status-agent-running)" aria-hidden />;
|
||||
@@ -228,9 +231,29 @@ function WorkspaceFileDetails({ item }: { item: TaskChatWorkspaceFileItem }) {
|
||||
);
|
||||
}
|
||||
|
||||
/** One-line outcome for a check: its detail, or why it did not run. */
|
||||
export function verificationOutcome(item: TaskChatProviderActivityItem): string | undefined {
|
||||
const detail = item.details.find((entry) => entry.label === "Detail")?.value;
|
||||
if (detail) return detail;
|
||||
const reason = item.details.find((entry) => entry.label === "Reason Code")?.value;
|
||||
return reason ? reason.replaceAll("_", " ").replace(/^\w/, (letter) => letter.toUpperCase()) : undefined;
|
||||
}
|
||||
|
||||
/** The full check: command and untruncated outcome, wrapped. */
|
||||
function VerificationDetails({ item }: { item: TaskChatProviderActivityItem }) {
|
||||
const outcome = verificationOutcome(item);
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col gap-1.5" data-testid="task-chat-verification-details">
|
||||
{item.summary ? <p className="min-w-0 break-all font-mono text-foreground">{item.summary}</p> : null}
|
||||
{outcome ? <p className="min-w-0 whitespace-pre-wrap break-words">{outcome}</p> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function detailContent(item: TaskChatProtocolItem, neutral = false): ReactNode | null {
|
||||
switch (item.surface) {
|
||||
case "provider_activity": {
|
||||
if (item.family === "verification") return verificationOutcome(item) ? <VerificationDetails item={item} /> : null;
|
||||
const expandable = item.details.length > 0 || item.steps.length > 0 || item.links.length > 0 || item.children.length > 0 || Boolean(item.output) || Boolean(item.outputTruncated);
|
||||
return expandable ? <ProviderDetails item={item} neutral={neutral} /> : null;
|
||||
}
|
||||
@@ -257,6 +280,8 @@ function safeActivityHref(value: string | null | undefined): string | undefined
|
||||
}
|
||||
|
||||
export function hasTaskChatProtocolActivityDetails(item: TaskChatProtocolItem): boolean {
|
||||
// A check expands only when it has an outcome that may not fit on one line.
|
||||
if (item.surface === "provider_activity" && item.family === "verification") return detailContent(item) !== null;
|
||||
if (item.surface === "resource") return Boolean(safeActivityHref(item.href));
|
||||
return detailContent(item) !== null || (item.surface === "provider_activity" && Boolean(item.summary?.trim()));
|
||||
}
|
||||
@@ -267,7 +292,55 @@ export function TaskChatProtocolActivityDetails({ item, neutral = false }: { ite
|
||||
const href = safeActivityHref(item.href);
|
||||
return href ? <a href={href} className="text-foreground underline">{item.title}</a> : <p>{item.title}</p>;
|
||||
}
|
||||
return <>{item.surface === "provider_activity" && item.summary ? <p className="whitespace-pre-wrap break-words">{item.summary}</p> : null}{detail}</>;
|
||||
return <>{item.surface === "provider_activity" && item.family !== "verification" && item.summary ? <p className="whitespace-pre-wrap break-words">{item.summary}</p> : null}{detail}</>;
|
||||
}
|
||||
|
||||
/** Source domains under a finished web search, so results are visible without expanding. */
|
||||
export function ResearchSourceStrip({ item }: { item: TaskChatProviderActivityItem }) {
|
||||
const seen = new Set<string>();
|
||||
const sources = item.links.flatMap((link) => {
|
||||
const host = sourceHostname(link.href);
|
||||
if (seen.has(host)) return [];
|
||||
seen.add(host);
|
||||
return [{ ...link, host }];
|
||||
});
|
||||
if (sources.length === 0) return null;
|
||||
const visible = sources.slice(0, RESEARCH_SOURCE_PREVIEW_LIMIT);
|
||||
const hidden = item.links.length - visible.length;
|
||||
return (
|
||||
<div className="ml-7 flex min-w-0 flex-wrap items-center gap-1 pb-0.5" data-testid="task-chat-research-sources">
|
||||
{visible.map((source) => (
|
||||
<a
|
||||
key={source.href}
|
||||
className="inline-flex min-w-0 max-w-full items-center gap-1 rounded-full border border-border px-2 py-0.5 text-(length:--text-nano) text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground"
|
||||
href={source.href}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
title={source.label}
|
||||
>
|
||||
<span className="truncate">{source.host}</span>
|
||||
</a>
|
||||
))}
|
||||
{hidden > 0 ? <span className="text-(length:--text-nano) text-muted-foreground">+{hidden} more</span> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Live "12s left" for an intentional wait with a planned duration. */
|
||||
export function WaitCountdown({ item }: { item: TaskChatProviderActivityItem }) {
|
||||
useSecondTick(true);
|
||||
const planned = Number(item.details.find((detail) => detail.label === "Planned Duration Ms")?.value);
|
||||
const startedAtMs = item.startedAt ? Date.parse(item.startedAt) : Number.NaN;
|
||||
if (!Number.isFinite(planned) || !Number.isFinite(startedAtMs)) return null;
|
||||
const remaining = Math.max(0, planned - (Date.now() - startedAtMs));
|
||||
return <span className="shrink-0 tabular-nums" data-testid="task-chat-wait-countdown">{durationLabel(String(remaining))} left</span>;
|
||||
}
|
||||
|
||||
export function verificationTone(item: TaskChatProviderActivityItem): string | undefined {
|
||||
const status = item.details.find((detail) => detail.label === "Status")?.value;
|
||||
if (status === "passed") return "text-(--status-task-icon-done)";
|
||||
if (status === "failed") return "text-destructive";
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function itemStatus(item: TaskChatProtocolItem): "running" | "completed" | "failed" | "interrupted" | "informational" {
|
||||
@@ -304,6 +377,9 @@ export function TaskChatProtocolActivityRow({ item }: { item: TaskChatProtocolIt
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const verification = item.surface === "provider_activity" && item.family === "verification" ? item : null;
|
||||
const runningWait = item.surface === "provider_activity" && item.family === "wait" && item.status === "running" ? item : null;
|
||||
const research = item.surface === "provider_activity" && item.family === "research" && item.status !== "running" ? item : null;
|
||||
const detail = detailContent(item);
|
||||
const expandable = detail !== null;
|
||||
const Icon = presentation.icon;
|
||||
@@ -313,11 +389,17 @@ export function TaskChatProtocolActivityRow({ item }: { item: TaskChatProtocolIt
|
||||
const row = (
|
||||
<>
|
||||
<span className="flex w-5 shrink-0 items-center justify-center">
|
||||
<Icon className="h-3.5 w-3.5 shrink-0" aria-hidden data-testid="task-chat-protocol-activity-icon" />
|
||||
<Icon className={cn("h-3.5 w-3.5 shrink-0", verification && verificationTone(verification))} aria-hidden data-testid="task-chat-protocol-activity-icon" />
|
||||
</span>
|
||||
<span data-conversation-tool-running={active && shimmer && item.surface === "provider_activity" && item.family === "tool_execution" ? "true" : undefined} className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden">
|
||||
<span className={cn("shrink-0 font-medium", active && shimmer && "shimmer-text shimmer-text-muted")}>{label}{active ? "…" : ""}</span>
|
||||
{presentation.detail ? <span className="task-chat-collapsed-line-fade min-w-0 flex-1 font-mono text-(length:--text-micro)">{presentation.detail}</span> : null}
|
||||
{verification && verificationOutcome(verification) ? (
|
||||
<span className="min-w-0 shrink truncate" data-testid="task-chat-verification-detail">
|
||||
{verificationOutcome(verification)}
|
||||
</span>
|
||||
) : null}
|
||||
{runningWait ? <WaitCountdown item={runningWait} /> : null}
|
||||
</span>
|
||||
<span className="ml-auto flex shrink-0 items-center gap-2">
|
||||
{expandable ? (
|
||||
@@ -326,7 +408,7 @@ export function TaskChatProtocolActivityRow({ item }: { item: TaskChatProtocolIt
|
||||
aria-hidden
|
||||
/>
|
||||
) : null}
|
||||
<StatusIcon status={status} />
|
||||
{verification ? null : <StatusIcon status={status} />}
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
@@ -359,6 +441,7 @@ export function TaskChatProtocolActivityRow({ item }: { item: TaskChatProtocolIt
|
||||
return (
|
||||
<div className="flex min-w-0 flex-col text-xs" data-testid="task-chat-protocol-activity-row" data-activity-family={item.surface === "provider_activity" ? item.family : item.surface}>
|
||||
{activityRow}
|
||||
{research && !open ? <ResearchSourceStrip item={research} /> : null}
|
||||
{open && detail ? (
|
||||
<div id={detailId} className="ml-2.5 mt-0.5 border-l-2 border-border py-1 pl-2.5" data-testid="task-chat-protocol-activity-detail">
|
||||
{detail}
|
||||
|
||||
Reference in new issue
Block a user