mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 01:57:31 +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
@@ -619,6 +619,7 @@ export type ProviderActivityFamily =
|
||||
| "safety"
|
||||
| "terminal"
|
||||
| "wait"
|
||||
| "verification"
|
||||
| "provider_notice";
|
||||
|
||||
export type ProviderActivityStatus =
|
||||
|
||||
@@ -695,6 +695,64 @@ fn provider_performance_spans(params: &Value) -> Vec<Value> {
|
||||
}).collect()
|
||||
}
|
||||
|
||||
fn optional_bounded_text(value: Option<&Value>, max_chars: usize) -> Value {
|
||||
match value.and_then(Value::as_str) {
|
||||
Some(text) if !text.trim().is_empty() => Value::String(bounded_text(text, max_chars)),
|
||||
_ => Value::Null,
|
||||
}
|
||||
}
|
||||
|
||||
/// Bounded display fields for Codex activity items that become canonical
|
||||
/// research and wait events. Only typed, user-visible fields are kept; result
|
||||
/// entries keep an http(s) URL, title and snippet.
|
||||
fn codex_activity_item_fields(item_type: &str, provider_item: &Value) -> Option<Value> {
|
||||
match item_type {
|
||||
"webSearch" => {
|
||||
let action = provider_item
|
||||
.get("action")
|
||||
.filter(|value| value.is_object())
|
||||
.map(|action| {
|
||||
json!({
|
||||
"type": bounded_text(string(action.get("type")), 64),
|
||||
"query": optional_bounded_text(action.get("query"), MAX_TEXT_CHARS),
|
||||
"url": optional_bounded_text(action.get("url"), 8_192),
|
||||
"pattern": optional_bounded_text(action.get("pattern"), MAX_TEXT_CHARS),
|
||||
})
|
||||
});
|
||||
let results = provider_item
|
||||
.get("results")
|
||||
.and_then(Value::as_array)
|
||||
.map(|results| {
|
||||
results
|
||||
.iter()
|
||||
.take(64)
|
||||
.filter_map(|result| {
|
||||
let url = string(result.get("url"));
|
||||
if !(url.starts_with("http://") || url.starts_with("https://")) || url.len() > 8_192 {
|
||||
return None;
|
||||
}
|
||||
Some(json!({
|
||||
"url": url,
|
||||
"title": optional_bounded_text(result.get("title"), MAX_TEXT_CHARS),
|
||||
"snippet": optional_bounded_text(result.get("snippet"), MAX_TEXT_CHARS),
|
||||
"ref_id": optional_bounded_text(result.get("ref_id").or_else(|| result.get("refId")), 240),
|
||||
}))
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
});
|
||||
Some(json!({
|
||||
"query": optional_bounded_text(provider_item.get("query"), MAX_TEXT_CHARS),
|
||||
"action": action.unwrap_or(Value::Null),
|
||||
"results": results.map(Value::Array).unwrap_or(Value::Null),
|
||||
}))
|
||||
}
|
||||
"sleep" => Some(json!({
|
||||
"durationMs": provider_item.get("durationMs").and_then(Value::as_u64),
|
||||
})),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn normalize_codex_notification(method: &str, params: &Value) -> Vec<NormalizedProviderEvent> {
|
||||
let mut events = Vec::new();
|
||||
let push = |events: &mut Vec<NormalizedProviderEvent>,
|
||||
@@ -987,6 +1045,16 @@ pub fn normalize_codex_notification(method: &str, params: &Value) -> Vec<Normali
|
||||
.insert("item".to_owned(), json!({ "name": name }));
|
||||
}
|
||||
}
|
||||
// Web research and intentional waits are already user-visible
|
||||
// activity. Keep their bounded identity (query, action, cited
|
||||
// sources, planned duration) so the driver's canonical
|
||||
// `research.*` and `wait.*` events are not empty.
|
||||
if let Some(activity) = codex_activity_item_fields(item_type, provider_item) {
|
||||
payload
|
||||
.as_object_mut()
|
||||
.expect("item payload is an object")
|
||||
.insert("item".to_owned(), activity);
|
||||
}
|
||||
if !provider_phase.is_empty() {
|
||||
payload
|
||||
.as_object_mut()
|
||||
@@ -1826,6 +1894,44 @@ mod tests {
|
||||
assert!(!events[0].payload.to_string().contains("secret-value"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn keeps_bounded_web_search_and_sleep_identity() {
|
||||
let search = normalize_codex_notification(
|
||||
"item/completed",
|
||||
&json!({
|
||||
"item": {
|
||||
"id": "ws-1",
|
||||
"type": "webSearch",
|
||||
"query": "iso week date",
|
||||
"action": {"type": "search", "query": "iso week date", "queries": null, "token": "secret-value"},
|
||||
"results": [
|
||||
{"url": "https://example.com/a", "title": "A", "snippet": "first", "raw": "secret-value"},
|
||||
{"url": "file:///etc/passwd", "title": "local"},
|
||||
],
|
||||
},
|
||||
}),
|
||||
);
|
||||
assert_eq!(search[0].event_type, "item.completed");
|
||||
let item = &search[0].payload["item"];
|
||||
assert_eq!(item["query"], "iso week date");
|
||||
assert_eq!(item["action"]["type"], "search");
|
||||
assert_eq!(item["results"].as_array().map(Vec::len), Some(1));
|
||||
assert_eq!(item["results"][0]["url"], "https://example.com/a");
|
||||
assert!(!search[0].payload.to_string().contains("secret-value"));
|
||||
|
||||
let sleep = normalize_codex_notification(
|
||||
"item/started",
|
||||
&json!({"item": {"id": "sleep-1", "type": "sleep", "durationMs": 15000}}),
|
||||
);
|
||||
assert_eq!(sleep[0].payload["item"]["durationMs"], 15000);
|
||||
|
||||
let message = normalize_codex_notification(
|
||||
"item/completed",
|
||||
&json!({"item": {"id": "m-1", "type": "agentMessage", "text": "hi", "query": "x"}}),
|
||||
);
|
||||
assert!(message[0].payload.get("item").is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bounds_and_redacts_command_output() {
|
||||
let events = normalize_codex_notification(
|
||||
|
||||
@@ -61,7 +61,10 @@ describe("TaskChatProtocolActivityRow", () => {
|
||||
const row = container.querySelector<HTMLButtonElement>('[data-testid="task-chat-protocol-activity-row"] button');
|
||||
expect(container.querySelector("article")).toBeNull();
|
||||
expect(row?.textContent).toContain("Searched the web");
|
||||
expect(row?.textContent).toContain("smoked pork shoulder");
|
||||
expect(row?.textContent).toContain("“site:nextjs.org/docs/app/api-reference/config/eslint");
|
||||
const strip = container.querySelector('[data-testid="task-chat-research-sources"]');
|
||||
expect([...strip!.querySelectorAll("a")].map((link) => link.textContent)).toEqual(["nextjs.org"]);
|
||||
expect(strip?.textContent).toContain("+5 more");
|
||||
expect(
|
||||
row
|
||||
?.querySelector('[data-testid="task-chat-protocol-activity-icon"]')
|
||||
@@ -69,7 +72,7 @@ describe("TaskChatProtocolActivityRow", () => {
|
||||
).toBe(true);
|
||||
expect(
|
||||
row?.querySelector(".task-chat-collapsed-line-fade")?.textContent,
|
||||
).toContain("smoked pork shoulder");
|
||||
).toContain("site:nextjs.org");
|
||||
expect(row?.getAttribute("aria-expanded")).toBe("false");
|
||||
expect(row?.getAttribute("aria-controls")).toMatch(/^task-chat-protocol-activity-/);
|
||||
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Circle,
|
||||
CircleCheck,
|
||||
CircleHelp,
|
||||
Clock3,
|
||||
ExternalLink,
|
||||
@@ -76,6 +77,7 @@ const FAMILY_ICON = {
|
||||
safety: ShieldCheck,
|
||||
terminal: TerminalSquare,
|
||||
wait: Clock3,
|
||||
verification: CircleCheck,
|
||||
provider_notice: AlertTriangle,
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -10,6 +10,7 @@ import { TaskChatBubble } from "./TaskChatBubble";
|
||||
import { TaskChatExpansionState } from "./expansion-state";
|
||||
import type {
|
||||
TaskChatActivityPhaseItem,
|
||||
TaskChatProviderActivityItem,
|
||||
TaskChatToolItem,
|
||||
} from "./task-chat-model";
|
||||
|
||||
@@ -31,6 +32,27 @@ const tool = (
|
||||
detail: `output-${id}`,
|
||||
});
|
||||
|
||||
const providerItem = (
|
||||
id: string,
|
||||
family: TaskChatProviderActivityItem["family"],
|
||||
status: TaskChatProviderActivityItem["status"],
|
||||
details: TaskChatProviderActivityItem["details"],
|
||||
extra: Partial<TaskChatProviderActivityItem> = {},
|
||||
): TaskChatProviderActivityItem => ({
|
||||
id,
|
||||
kind: "protocol",
|
||||
surface: "provider_activity",
|
||||
family,
|
||||
eventType: `${family}.activity`,
|
||||
status,
|
||||
title: family,
|
||||
details,
|
||||
steps: [],
|
||||
links: [],
|
||||
children: [],
|
||||
...extra,
|
||||
});
|
||||
|
||||
describe("TaskChatRunnerActivityGroup", () => {
|
||||
let container: HTMLDivElement;
|
||||
let root: Root;
|
||||
@@ -334,4 +356,61 @@ describe("TaskChatRunnerActivityGroup", () => {
|
||||
),
|
||||
).toBeNull();
|
||||
});
|
||||
it("shows research sources, check outcomes and a wait countdown in expanded history", () => {
|
||||
const search = providerItem("search", "research", "completed", [
|
||||
{ label: "Action", value: "search" },
|
||||
{ label: "Query", value: "temporal week dates" },
|
||||
], {
|
||||
links: [
|
||||
{ label: "MDN", href: "https://developer.mozilla.org/a" },
|
||||
{ label: "MDN again", href: "https://developer.mozilla.org/b" },
|
||||
{ label: "TC39", href: "https://tc39.es/c" },
|
||||
],
|
||||
});
|
||||
const passed = providerItem("check-1", "verification", "completed", [
|
||||
{ label: "Status", value: "passed" },
|
||||
{ label: "Detail", value: "6 tests passed" },
|
||||
], { summary: "node --test" });
|
||||
const failed = providerItem("check-2", "verification", "failed", [
|
||||
{ label: "Status", value: "failed" },
|
||||
], { summary: "pnpm typecheck" });
|
||||
const skipped = providerItem("check-3", "verification", "informational", [
|
||||
{ label: "Status", value: "not_run" },
|
||||
{ label: "Reason Code", value: "policy_restricted" },
|
||||
], { summary: "pnpm test:e2e" });
|
||||
const longFailure = "x".repeat(300);
|
||||
const failedLong = providerItem("check-4", "verification", "failed", [
|
||||
{ label: "Status", value: "failed" },
|
||||
{ label: "Detail", value: longFailure },
|
||||
], { summary: "pnpm typecheck" });
|
||||
const wait = providerItem("wait", "wait", "running", [
|
||||
{ label: "Planned Duration Ms", value: "30000" },
|
||||
], { startedAt: new Date(Date.now() - 12_000).toISOString() });
|
||||
render([search, passed, failed, skipped, failedLong, wait], "live", false);
|
||||
act(() => toggle().click());
|
||||
|
||||
const strip = container.querySelector('[data-testid="task-chat-research-sources"]');
|
||||
expect([...strip!.querySelectorAll("a")].map((link) => link.textContent)).toEqual(["developer.mozilla.org", "tc39.es"]);
|
||||
expect(strip?.textContent).toContain("+1 more");
|
||||
expect(container.textContent).toContain("Searched the web“temporal week dates”");
|
||||
|
||||
const passedRow = container.querySelector('[data-activity-row="check-1"]')!;
|
||||
expect(passedRow.textContent).toContain("Check passed");
|
||||
expect(passedRow.textContent).toContain("6 tests passed");
|
||||
expect(passedRow.querySelector("[data-activity-icon] svg")?.getAttribute("class")).toContain("--status-task-icon-done");
|
||||
const failedRow = container.querySelector('[data-activity-row="check-2"]')!;
|
||||
expect(failedRow.textContent).toContain("Check failed");
|
||||
expect(failedRow.querySelector("[data-activity-icon] svg")?.getAttribute("class")).toContain("text-destructive");
|
||||
expect(failedRow.closest("button")).toBeNull();
|
||||
expect(container.querySelector('[data-activity-row="check-3"]')?.textContent).toContain("Policy restricted");
|
||||
|
||||
const longRow = container.querySelector<HTMLButtonElement>('[data-activity-item-id="check-4"] button')!;
|
||||
act(() => longRow.click());
|
||||
const detail = container.querySelector('[data-activity-item-id="check-4"] [data-testid="task-chat-verification-details"]');
|
||||
expect(detail?.textContent).toContain("pnpm typecheck");
|
||||
expect(detail?.textContent).toContain(longFailure);
|
||||
|
||||
expect(container.querySelector('[data-activity-row="wait"]')?.textContent).toContain("Waiting 30s");
|
||||
expect(container.querySelector('[data-testid="task-chat-wait-countdown"]')?.textContent).toMatch(/^1[78]s left$/);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useId, useState } from "react";
|
||||
import { useId, useState, type ReactNode } from "react";
|
||||
import { completedActivitySummary } from "./completed-activity-summary";
|
||||
import {
|
||||
Brain,
|
||||
@@ -18,16 +18,46 @@ import {
|
||||
protocolActivityPresentation,
|
||||
} from "./task-chat-activity-presentation";
|
||||
import {
|
||||
ResearchSourceStrip,
|
||||
TaskChatProtocolActivityDetails,
|
||||
WaitCountdown,
|
||||
hasTaskChatProtocolActivityDetails,
|
||||
verificationOutcome,
|
||||
verificationTone,
|
||||
} from "./TaskChatProtocolActivityRow";
|
||||
import { TaskChatUsageReadout } from "./TaskChatUsageReadout";
|
||||
import { toolActivityPresentation } from "./tool-taxonomy";
|
||||
import { toolActivityPresentation, type ToolIcon } from "./tool-taxonomy";
|
||||
import { useTaskChatShimmer } from "./TaskChatShimmer";
|
||||
|
||||
type Activity = TaskChatActivityPhaseItem["items"][number];
|
||||
|
||||
function presentation(item: Activity, active: boolean) {
|
||||
interface ActivityRowPresentation {
|
||||
icon: ToolIcon;
|
||||
label: string;
|
||||
target?: string;
|
||||
mono: boolean;
|
||||
running: boolean;
|
||||
/** Colors the leading icon, e.g. green or red for a verification check. */
|
||||
iconClassName?: string;
|
||||
/** Short plain-text outcome after the target, e.g. "6 tests passed". */
|
||||
trailing?: ReactNode;
|
||||
}
|
||||
|
||||
function providerTrailing(item: Extract<Activity, { kind: "protocol" }>): Pick<ActivityRowPresentation, "iconClassName" | "trailing"> {
|
||||
if (item.surface !== "provider_activity") return {};
|
||||
if (item.family === "verification") {
|
||||
return {
|
||||
iconClassName: verificationTone(item),
|
||||
trailing: verificationOutcome(item),
|
||||
};
|
||||
}
|
||||
if (item.family === "wait" && item.status === "running") {
|
||||
return { trailing: <WaitCountdown item={item} /> };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
function presentation(item: Activity, active: boolean): ActivityRowPresentation | null {
|
||||
if (item.kind === "tool") {
|
||||
const tool = toolActivityPresentation({
|
||||
name: item.rawName ?? item.name,
|
||||
@@ -72,6 +102,7 @@ function presentation(item: Activity, active: boolean) {
|
||||
target: protocol.detail,
|
||||
mono: true,
|
||||
running: active && protocolActivityIsRunning(item),
|
||||
...providerTrailing(item),
|
||||
};
|
||||
}
|
||||
if (item.kind === "marker")
|
||||
@@ -130,7 +161,7 @@ function ActivityContent({
|
||||
className="flex size-5 shrink-0 items-center justify-center"
|
||||
data-activity-icon
|
||||
>
|
||||
<Icon className="size-3.5" aria-hidden="true" />
|
||||
<Icon className={cn("size-3.5", row.iconClassName)} aria-hidden="true" />
|
||||
</span>
|
||||
<span data-conversation-tool-running={row.running && shimmer && (item.kind === "tool" || (item.kind === "protocol" && item.surface === "provider_activity" && item.family === "tool_execution")) ? "true" : undefined} className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden whitespace-nowrap">
|
||||
<span
|
||||
@@ -153,6 +184,11 @@ function ActivityContent({
|
||||
{row.target}
|
||||
</span>
|
||||
) : null}
|
||||
{row.trailing ? (
|
||||
<span className="min-w-0 truncate text-xs text-muted-foreground" data-testid="task-chat-activity-trailing">
|
||||
{row.trailing}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</span>
|
||||
);
|
||||
@@ -294,6 +330,9 @@ function ExpandedActivity({
|
||||
{content}
|
||||
</div>
|
||||
)}
|
||||
{!open && item.kind === "protocol" && item.surface === "provider_activity" && item.family === "research" && item.status !== "running" ? (
|
||||
<ResearchSourceStrip item={item} />
|
||||
) : null}
|
||||
{expandable && open ? (
|
||||
<div
|
||||
id={detailId}
|
||||
|
||||
@@ -186,4 +186,13 @@ describe("completedActivitySummary", () => {
|
||||
expect(result.label).not.toContain("Distinct provider message");
|
||||
});
|
||||
|
||||
it("leads with a failed check and names model switches", () => {
|
||||
const route = { ...provider("model_identity"), eventType: "model.route.changed" };
|
||||
const passed = { ...provider("verification"), id: "check-1" };
|
||||
const failed = { ...provider("verification", "failed"), id: "check-2" };
|
||||
expect(completedActivitySummary([route, passed]).fullLabel).toBe("Switched models, ran checks");
|
||||
expect(completedActivitySummary([route, passed, failed]).fullLabel).toBe("Check failed, switched models, ran checks");
|
||||
const skipped = { ...provider("verification", "informational"), id: "check-3", details: [{ label: "Status", value: "not_run" }] };
|
||||
expect(completedActivitySummary([skipped]).fullLabel).toBe("Skipped checks");
|
||||
});
|
||||
});
|
||||
@@ -80,6 +80,17 @@ export function completedActivitySummary(items: Activity[]) {
|
||||
notices.push({ summary, severity: providerNoticeSeverity(item), icon: p.icon, order });
|
||||
continue;
|
||||
}
|
||||
if (item.surface === "provider_activity" && item.family === "verification") {
|
||||
// A failed check stays visible even when the compact label truncates.
|
||||
if (item.status === "failed") add("Check failed", p.icon, -1);
|
||||
else if (item.details.find((entry) => entry.label === "Status")?.value === "not_run") add("Skipped checks", p.icon, order);
|
||||
else add("Ran checks", p.icon, order);
|
||||
continue;
|
||||
}
|
||||
if (item.surface === "provider_activity" && item.eventType === "model.route.changed") {
|
||||
add("Switched models", p.icon, order);
|
||||
continue;
|
||||
}
|
||||
const family =
|
||||
item.surface === "provider_activity" ? item.family : item.surface;
|
||||
const label =
|
||||
@@ -97,6 +108,7 @@ export function completedActivitySummary(items: Activity[]) {
|
||||
safety: "Reviewed safety",
|
||||
terminal: "Ran commands",
|
||||
wait: "Waited",
|
||||
verification: "Ran checks",
|
||||
workspace_change: "Worked on files",
|
||||
workspace_file: "Referenced files",
|
||||
resource: "Added resources",
|
||||
|
||||
@@ -2,6 +2,9 @@ import {
|
||||
AlertTriangle,
|
||||
BookOpen,
|
||||
Bot,
|
||||
CircleCheck,
|
||||
CircleDashed,
|
||||
CircleX,
|
||||
Box,
|
||||
Clock3,
|
||||
Database,
|
||||
@@ -9,6 +12,7 @@ import {
|
||||
FilePenLine,
|
||||
FileText,
|
||||
GitBranch,
|
||||
Globe,
|
||||
Info,
|
||||
ListChecks,
|
||||
PackageCheck,
|
||||
@@ -56,19 +60,54 @@ function meaningfulToolDetail(value: string | undefined): string | undefined {
|
||||
return value;
|
||||
}
|
||||
|
||||
/** Host and path without scheme or query, for one-line web activity. */
|
||||
export function compactUrl(href: string): string {
|
||||
try {
|
||||
const url = new URL(href);
|
||||
const path = url.pathname === "/" ? "" : url.pathname.replace(/\/$/, "");
|
||||
return `${url.hostname.replace(/^www\./, "")}${path}`;
|
||||
} catch {
|
||||
return href;
|
||||
}
|
||||
}
|
||||
|
||||
/** Whole-second duration such as "30s" or "2m 5s" from a millisecond detail. */
|
||||
export function durationLabel(value: string | undefined): string | undefined {
|
||||
if (!value) return undefined;
|
||||
const ms = Number(value);
|
||||
if (!Number.isFinite(ms) || ms < 0) return undefined;
|
||||
const totalSeconds = Math.round(ms / 1000);
|
||||
if (totalSeconds < 60) return `${totalSeconds}s`;
|
||||
const minutes = Math.floor(totalSeconds / 60);
|
||||
const seconds = totalSeconds % 60;
|
||||
return seconds === 0 ? `${minutes}m` : `${minutes}m ${seconds}s`;
|
||||
}
|
||||
|
||||
function modelRouteDetail(item: TaskChatProviderActivityItem): string | undefined {
|
||||
const from = providerDetail(item, "From Model") ?? providerDetail(item, "Requested Model");
|
||||
const to = providerDetail(item, "Effective Model");
|
||||
if (!to || to === "unknown") return undefined;
|
||||
const reason = providerDetail(item, "Reason");
|
||||
const route = from && from !== "unknown" && from !== to ? `${from} → ${to}` : to;
|
||||
return reason && reason !== "provider reroute" ? `${route} · ${reason}` : route;
|
||||
}
|
||||
|
||||
export function providerActivityPresentation(item: TaskChatProviderActivityItem): TaskChatActivityPresentation {
|
||||
const detail = item.summary
|
||||
?? providerDetail(item, "Query", "URL", "Target", "Name", "Reference", "Reason", "Summary");
|
||||
switch (item.family) {
|
||||
case "research": {
|
||||
const action = providerDetail(item, "Action")?.toLowerCase();
|
||||
const query = providerDetail(item, "Query");
|
||||
const url = providerDetail(item, "Url");
|
||||
if (action === "open_page") {
|
||||
return { icon: Search, runningLabel: "Opening a web page", completedLabel: "Opened a web page", failedLabel: "Couldn’t open the web page", interruptedLabel: "Stopped opening the web page", detail };
|
||||
return { icon: Globe, runningLabel: "Opening a web page", completedLabel: "Opened a web page", failedLabel: "Couldn’t open the web page", interruptedLabel: "Stopped opening the web page", detail: url ? compactUrl(url) : detail };
|
||||
}
|
||||
if (action === "find_in_page") {
|
||||
return { icon: Search, runningLabel: "Searching the page", completedLabel: "Searched the page", failedLabel: "Page search failed", interruptedLabel: "Page search stopped", detail };
|
||||
const pattern = providerDetail(item, "Pattern");
|
||||
return { icon: Search, runningLabel: "Searching the page", completedLabel: "Searched the page", failedLabel: "Page search failed", interruptedLabel: "Page search stopped", detail: [pattern ? `“${pattern}”` : undefined, url ? compactUrl(url) : undefined].filter(Boolean).join(" in ") || detail };
|
||||
}
|
||||
return { icon: Search, runningLabel: "Searching the web", completedLabel: "Searched the web", failedLabel: "Web search failed", interruptedLabel: "Web search stopped", detail };
|
||||
return { icon: Search, runningLabel: "Searching the web", completedLabel: "Searched the web", failedLabel: "Web search failed", interruptedLabel: "Web search stopped", detail: query ? `“${query}”` : detail };
|
||||
}
|
||||
case "tool_execution": {
|
||||
const name = providerDetail(item, "Name");
|
||||
@@ -115,7 +154,7 @@ export function providerActivityPresentation(item: TaskChatProviderActivityItem)
|
||||
}
|
||||
case "model_identity":
|
||||
return item.eventType === "model.route.changed"
|
||||
? { icon: Bot, runningLabel: "Switching models", completedLabel: "Switched models", failedLabel: "Model switch failed", interruptedLabel: "Model switch stopped", detail }
|
||||
? { icon: Bot, runningLabel: "Switching models", completedLabel: "Switched models", failedLabel: "Model switch failed", interruptedLabel: "Model switch stopped", detail: modelRouteDetail(item) ?? detail }
|
||||
: { icon: Bot, runningLabel: "Verifying the model", completedLabel: "Verified the model", failedLabel: "Model verification failed", interruptedLabel: "Model verification stopped", detail };
|
||||
case "context":
|
||||
return { icon: Database, runningLabel: "Compacting context", completedLabel: "Compacted context", failedLabel: "Context compaction failed", interruptedLabel: "Context compaction stopped", detail };
|
||||
@@ -139,8 +178,28 @@ export function providerActivityPresentation(item: TaskChatProviderActivityItem)
|
||||
return { icon: ShieldCheck, runningLabel: "Reviewing safety", completedLabel: "Reviewed safety", failedLabel: "Safety review failed", interruptedLabel: "Safety review stopped", detail };
|
||||
case "terminal":
|
||||
return { icon: TerminalSquare, runningLabel: "Sending terminal input", completedLabel: "Sent terminal input", failedLabel: "Terminal input failed", interruptedLabel: "Terminal input stopped", detail };
|
||||
case "wait":
|
||||
return { icon: Clock3, runningLabel: "Waiting", completedLabel: "Finished waiting", failedLabel: "Wait failed", interruptedLabel: "Wait stopped", detail };
|
||||
case "wait": {
|
||||
const planned = durationLabel(providerDetail(item, "Planned Duration Ms"));
|
||||
const elapsed = durationLabel(providerDetail(item, "Elapsed Duration Ms"));
|
||||
return {
|
||||
icon: Clock3,
|
||||
runningLabel: planned ? `Waiting ${planned}` : "Waiting",
|
||||
completedLabel: elapsed ?? planned ? `Waited ${elapsed ?? planned}` : "Finished waiting",
|
||||
failedLabel: "Wait failed",
|
||||
interruptedLabel: elapsed ? `Stopped waiting after ${elapsed}` : "Wait stopped",
|
||||
};
|
||||
}
|
||||
case "verification": {
|
||||
const status = providerDetail(item, "Status");
|
||||
return {
|
||||
icon: status === "passed" ? CircleCheck : status === "failed" ? CircleX : CircleDashed,
|
||||
runningLabel: "Running a check",
|
||||
completedLabel: status === "not_run" ? "Check not run" : "Check passed",
|
||||
failedLabel: "Check failed",
|
||||
interruptedLabel: "Check not run",
|
||||
detail: item.summary,
|
||||
};
|
||||
}
|
||||
case "provider_notice":
|
||||
return { icon: providerNoticeSeverity(item) === "info" ? Info : AlertTriangle, runningLabel: "Provider notice", completedLabel: "Provider notice", failedLabel: "Provider error", interruptedLabel: "Provider notice", detail };
|
||||
}
|
||||
|
||||
@@ -46,6 +46,7 @@ export type TaskChatProviderActivityFamily =
|
||||
| "safety"
|
||||
| "terminal"
|
||||
| "wait"
|
||||
| "verification"
|
||||
| "provider_notice";
|
||||
|
||||
export type TaskChatProviderActivityStatus =
|
||||
@@ -381,6 +382,8 @@ export interface TaskChatProviderActivityItem {
|
||||
outputTruncated?: boolean;
|
||||
/** Index of the latest provider event coalesced into this activity. */
|
||||
transcriptIndex?: number;
|
||||
/** When the provider first reported this activity (ISO timestamp). */
|
||||
startedAt?: string;
|
||||
}
|
||||
|
||||
export interface TaskChatWorkspaceChangeItem {
|
||||
|
||||
@@ -317,6 +317,7 @@ const PROVIDER_DETAIL_KEYS: Record<
|
||||
safety: ["status", "decision", "targetExecutionId", "summary"],
|
||||
terminal: ["origin", "inputClass", "byteCount"],
|
||||
wait: ["reason", "status", "plannedDurationMs", "elapsedDurationMs"],
|
||||
verification: ["status", "detail", "reasonCode"],
|
||||
provider_notice: [
|
||||
"severity",
|
||||
"category",
|
||||
@@ -377,7 +378,8 @@ function providerActivityItem(
|
||||
label: titleCaseKey(key),
|
||||
value: clip(
|
||||
value,
|
||||
entry.family === "provider_notice" && (key === "summary" || key === "message")
|
||||
(entry.family === "provider_notice" && (key === "summary" || key === "message")) ||
|
||||
(entry.family === "verification" && key === "detail")
|
||||
? 4000
|
||||
: key === "message" || key === "summary" || key === "reason" ? 320 : 160,
|
||||
),
|
||||
@@ -484,6 +486,7 @@ function providerActivityItem(
|
||||
output: rawOutput ? rawOutput.slice(0, PROTOCOL_OUTPUT_MAX) : undefined,
|
||||
outputTruncated,
|
||||
transcriptIndex: index,
|
||||
startedAt: entry.ts,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -513,7 +516,7 @@ function mergeProviderActivityItem(
|
||||
previous.family !== "tool_execution" ||
|
||||
incoming.family !== "tool_execution"
|
||||
) {
|
||||
return { ...incoming, id: previous.id };
|
||||
return { ...incoming, id: previous.id, startedAt: previous.startedAt ?? incoming.startedAt };
|
||||
}
|
||||
const details = new Map(
|
||||
previous.details.map((detail) => [detail.label, detail]),
|
||||
@@ -547,6 +550,7 @@ function mergeProviderActivityItem(
|
||||
return {
|
||||
...incoming,
|
||||
id: previous.id,
|
||||
startedAt: previous.startedAt ?? incoming.startedAt,
|
||||
summary: meaningfulProviderToolSummary(incoming.summary)
|
||||
? incoming.summary
|
||||
: previous.summary,
|
||||
|
||||
@@ -876,31 +876,138 @@ describe("nativeRunEventsToTranscript", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
it("projects provider-neutral activity without exposing provider envelopes", () => {
|
||||
expect(nativeRunEventsToTranscript([
|
||||
it("keeps web research as a provider activity with its sources", () => {
|
||||
const transcript = nativeRunEventsToTranscript([
|
||||
event(1, "research.started", {
|
||||
schema: "paperclip.research.v1",
|
||||
researchId: "research-1",
|
||||
action: "search",
|
||||
query: "current behavior",
|
||||
status: "running",
|
||||
sources: [],
|
||||
}),
|
||||
event(2, "research.completed", {
|
||||
schema: "paperclip.research.v1",
|
||||
researchId: "research-1",
|
||||
action: "search",
|
||||
query: "current behavior",
|
||||
status: "completed",
|
||||
sources: [{ sourceId: "s1", url: "https://example.com/a", title: "A", snippet: null }],
|
||||
}),
|
||||
]);
|
||||
expect(transcript).toEqual([
|
||||
expect.objectContaining({ kind: "provider_activity", family: "research", status: "running", summary: "current behavior" }),
|
||||
expect.objectContaining({
|
||||
kind: "provider_activity",
|
||||
family: "research",
|
||||
status: "completed",
|
||||
payload: expect.objectContaining({ sources: [expect.objectContaining({ url: "https://example.com/a" })] }),
|
||||
}),
|
||||
]);
|
||||
expect(transcript.some((entry) => entry.kind === "tool_call" || entry.kind === "tool_result")).toBe(false);
|
||||
const items = transcriptToTaskChatItems(transcript, { runId: RUN_ID, running: false });
|
||||
const research = items.filter((item) => item.kind === "protocol" && item.surface === "provider_activity");
|
||||
expect(research).toHaveLength(1);
|
||||
expect(research[0]).toEqual(expect.objectContaining({
|
||||
status: "completed",
|
||||
links: [expect.objectContaining({ href: "https://example.com/a" })],
|
||||
startedAt: "2026-08-25T18:00:01.000Z",
|
||||
}));
|
||||
});
|
||||
|
||||
it("does not invent a subject for a search the provider reported without a query", () => {
|
||||
expect(nativeRunEventsToTranscript([
|
||||
event(1, "research.completed", {
|
||||
schema: "paperclip.research.v1",
|
||||
researchId: "research-2",
|
||||
action: "other",
|
||||
query: null,
|
||||
url: null,
|
||||
pattern: null,
|
||||
status: "completed",
|
||||
sources: [],
|
||||
}),
|
||||
])).toEqual([expect.objectContaining({ kind: "provider_activity", family: "research", summary: "" })]);
|
||||
});
|
||||
|
||||
it("keeps model reroutes and waits as provider activity with their payload", () => {
|
||||
const transcript = nativeRunEventsToTranscript([
|
||||
event(1, "model.route.changed", {
|
||||
schema: "paperclip.model.route_changed.v1",
|
||||
routeId: "turn-1",
|
||||
provider: "openai",
|
||||
requestedModel: "gpt-a",
|
||||
fromModel: "gpt-a",
|
||||
effectiveModel: "gpt-b",
|
||||
reason: "capacity",
|
||||
}),
|
||||
event(2, "wait.started", {
|
||||
schema: "paperclip.wait.v1",
|
||||
waitId: "sleep-1",
|
||||
reason: "timer",
|
||||
status: "running",
|
||||
plannedDurationMs: 30000,
|
||||
elapsedDurationMs: null,
|
||||
}),
|
||||
]);
|
||||
expect(transcript).toEqual([
|
||||
expect.objectContaining({ kind: "provider_activity", family: "model_identity", status: "completed", payload: expect.objectContaining({ effectiveModel: "gpt-b" }) }),
|
||||
expect.objectContaining({ kind: "provider_activity", family: "wait", status: "running", payload: expect.objectContaining({ plannedDurationMs: 30000 }) }),
|
||||
]);
|
||||
});
|
||||
|
||||
it("projects the runner's verification items as check activity", () => {
|
||||
const transcript = nativeRunEventsToTranscript([
|
||||
itemEvent(1, "item.completed", "result:verification:1", {
|
||||
kind: "verification",
|
||||
text: "passed: node --test",
|
||||
verification: { commandOrCheck: "node --test", status: "passed", detail: "6 tests passed" },
|
||||
}),
|
||||
itemEvent(2, "item.completed", "result:verification:2", {
|
||||
kind: "verification",
|
||||
text: "failed: pnpm typecheck",
|
||||
verification: { commandOrCheck: "pnpm typecheck", status: "failed" },
|
||||
}),
|
||||
itemEvent(3, "item.completed", "result:verification:3", { kind: "verification", text: "malformed" }),
|
||||
itemEvent(4, "item.completed", "result:verification:4", {
|
||||
kind: "verification",
|
||||
text: "not_run: pnpm test:e2e",
|
||||
verification: { commandOrCheck: "pnpm test:e2e", status: "not_run", reasonCode: "policy_restricted", detail: "x".repeat(300) },
|
||||
}),
|
||||
]);
|
||||
expect(transcript).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "provider_activity",
|
||||
family: "verification",
|
||||
status: "completed",
|
||||
summary: "node --test",
|
||||
payload: expect.objectContaining({ verificationId: "result:verification:1", status: "passed", detail: "6 tests passed" }),
|
||||
}),
|
||||
expect.objectContaining({ kind: "provider_activity", family: "verification", status: "failed", summary: "pnpm typecheck" }),
|
||||
expect.objectContaining({ family: "verification", status: "informational", payload: expect.objectContaining({ reasonCode: "policy_restricted" }) }),
|
||||
]);
|
||||
const checks = transcriptToTaskChatItems(transcript, { runId: RUN_ID, running: false })
|
||||
.filter((item) => item.kind === "protocol" && item.surface === "provider_activity");
|
||||
expect(checks).toHaveLength(3);
|
||||
const skipped = checks[2];
|
||||
expect(skipped.kind === "protocol" && skipped.surface === "provider_activity" && skipped.details).toEqual(expect.arrayContaining([
|
||||
{ label: "Reason Code", value: "policy_restricted", mono: true },
|
||||
{ label: "Detail", value: "x".repeat(300), mono: false },
|
||||
]));
|
||||
});
|
||||
|
||||
it("leaves families owned by other surfaces on the generic activity path", () => {
|
||||
expect(nativeRunEventsToTranscript([
|
||||
event(1, "hook.completed", {
|
||||
schema: "paperclip.hook.v1",
|
||||
hookId: "hook-1",
|
||||
event: "PreToolUse",
|
||||
status: "completed",
|
||||
summary: "lint hook",
|
||||
}),
|
||||
])).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "tool_call",
|
||||
name: "research",
|
||||
toolUseId: "research:research-1",
|
||||
}),
|
||||
expect.objectContaining({
|
||||
kind: "tool_result",
|
||||
toolUseId: "research:research-1",
|
||||
content: "current behavior",
|
||||
}),
|
||||
expect.objectContaining({ kind: "tool_call", toolUseId: "hook:hook-1" }),
|
||||
expect.objectContaining({ kind: "tool_result", toolUseId: "hook:hook-1" }),
|
||||
]);
|
||||
});
|
||||
|
||||
@@ -916,8 +1023,9 @@ describe("nativeRunEventsToTranscript", () => {
|
||||
}),
|
||||
])).toEqual([
|
||||
expect.objectContaining({
|
||||
kind: "tool_call",
|
||||
toolUseId: "artifact:artifact-1",
|
||||
kind: "provider_activity",
|
||||
family: "artifact",
|
||||
status: "running",
|
||||
}),
|
||||
]);
|
||||
},
|
||||
|
||||
@@ -504,6 +504,92 @@ function providerActivityPresentation(
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Activity families that render as dedicated task-chat cards. They keep the
|
||||
* full canonical payload (sources, models, durations) instead of collapsing
|
||||
* into a one-line tool row. Families owned by other surfaces stay on the
|
||||
* generic path until they get their own presentation.
|
||||
*/
|
||||
const CARD_PROVIDER_ACTIVITY_FAMILIES: Partial<Record<ProviderActivityEventType, {
|
||||
family: Extract<TranscriptEntry, { kind: "provider_activity" }>["family"];
|
||||
title: string;
|
||||
}>> = {
|
||||
"research.started": { family: "research", title: "Research" },
|
||||
"research.progressed": { family: "research", title: "Research" },
|
||||
"research.completed": { family: "research", title: "Research" },
|
||||
"model.route.changed": { family: "model_identity", title: "Model" },
|
||||
"model.verification.updated": { family: "model_identity", title: "Model" },
|
||||
"artifact.viewed": { family: "artifact", title: "Artifact" },
|
||||
"artifact.generated": { family: "artifact", title: "Artifact" },
|
||||
"review.mode.changed": { family: "review", title: "Review mode" },
|
||||
"wait.started": { family: "wait", title: "Intentional wait" },
|
||||
"wait.completed": { family: "wait", title: "Intentional wait" },
|
||||
};
|
||||
|
||||
function cardProviderActivityEntry(
|
||||
event: HeartbeatRunEvent,
|
||||
payload: Record<string, unknown>,
|
||||
ts: string,
|
||||
): Extract<TranscriptEntry, { kind: "provider_activity" }> | null {
|
||||
const card = CARD_PROVIDER_ACTIVITY_FAMILIES[event.eventType as ProviderActivityEventType];
|
||||
if (!card) return null;
|
||||
const presentation = providerActivityPresentation(event, payload);
|
||||
if (!presentation) return null;
|
||||
const rawStatus = text(payload.status);
|
||||
const status: Extract<TranscriptEntry, { kind: "provider_activity" }>["status"] = presentation.failed
|
||||
? "failed"
|
||||
: rawStatus === "interrupted" || rawStatus === "cancelled"
|
||||
? "interrupted"
|
||||
: !presentation.terminal
|
||||
? "running"
|
||||
: "completed";
|
||||
return {
|
||||
kind: "provider_activity",
|
||||
ts,
|
||||
family: card.family,
|
||||
eventType: event.eventType,
|
||||
status,
|
||||
title: card.title,
|
||||
// No query, URL or reason: leave the row's label to speak for itself
|
||||
// rather than echoing a generic family title as if it were a subject.
|
||||
summary: presentation.summary === event.eventType ? "" : presentation.summary,
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The runner's own verification checks (one item per check in the proposed
|
||||
* run result) become check rows, so a failed check is visible where it ran.
|
||||
*/
|
||||
function verificationActivityEntry(
|
||||
payload: Record<string, unknown>,
|
||||
itemId: string | null,
|
||||
ts: string,
|
||||
): Extract<TranscriptEntry, { kind: "provider_activity" }> | null {
|
||||
const check = record(payload.verification);
|
||||
const commandOrCheck = text(check?.commandOrCheck)?.trim();
|
||||
if (!check || !commandOrCheck) return null;
|
||||
const status = verificationStatus(check.status);
|
||||
const detail = text(check.detail)?.trim() || null;
|
||||
const reasonCode = status === "not_run" ? text(check.reasonCode)?.trim() || null : null;
|
||||
return {
|
||||
kind: "provider_activity",
|
||||
ts,
|
||||
family: "verification",
|
||||
eventType: "verification.recorded",
|
||||
status: status === "passed" ? "completed" : status === "failed" ? "failed" : "informational",
|
||||
title: "Verification",
|
||||
summary: commandOrCheck,
|
||||
payload: {
|
||||
verificationId: itemId ?? `${commandOrCheck}:${ts}`,
|
||||
commandOrCheck,
|
||||
status,
|
||||
detail,
|
||||
reasonCode,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function timestamp(event: HeartbeatRunEvent, envelope: Record<string, unknown>): string {
|
||||
const emittedAt = text(envelope.emittedAt);
|
||||
if (emittedAt) return emittedAt;
|
||||
@@ -780,6 +866,12 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[]
|
||||
continue;
|
||||
}
|
||||
|
||||
if (event.eventType === "item.completed" && payload.kind === "verification") {
|
||||
const entry = verificationActivityEntry(payload, itemId, ts);
|
||||
if (entry) entries.push(entry);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Some provider transports expose their complete dynamic-tool lifecycle
|
||||
// directly as item.started/item.completed events and do not emit the
|
||||
// parallel tool.execution.* activity stream. Project those canonical
|
||||
@@ -864,6 +956,12 @@ export function nativeRunEventsToTranscript(events: readonly HeartbeatRunEvent[]
|
||||
continue;
|
||||
}
|
||||
|
||||
const cardActivity = cardProviderActivityEntry(event, payload, ts);
|
||||
if (cardActivity) {
|
||||
entries.push(cardActivity);
|
||||
continue;
|
||||
}
|
||||
|
||||
const providerActivity = providerActivityPresentation(event, payload);
|
||||
if (providerActivity) {
|
||||
if (!startedToolIds.has(providerActivity.id)) {
|
||||
|
||||
@@ -104,7 +104,7 @@ export interface IssueChatTranscriptEntry {
|
||||
cachedTokens?: number;
|
||||
costUsd?: number;
|
||||
changeType?: "add" | "remove" | "context" | "hunk" | "file_header" | "truncation";
|
||||
family?: "plan" | "tool_execution" | "research" | "delegation" | "model_identity" | "context" | "artifact" | "review" | "hook" | "memory" | "safety" | "terminal" | "wait" | "provider_notice";
|
||||
family?: "plan" | "tool_execution" | "research" | "delegation" | "model_identity" | "context" | "artifact" | "review" | "hook" | "memory" | "safety" | "terminal" | "wait" | "verification" | "provider_notice";
|
||||
eventType?: string;
|
||||
status?: "running" | "completed" | "failed" | "interrupted" | "informational" | "pending" | "resolved" | "expired" | "cancelled";
|
||||
title?: string;
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
import { useMemo } from "react";
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import type { HeartbeatRunEvent } from "@paperclipai/shared";
|
||||
import { nativeRunEventsToTranscript } from "@/components/transcript/native-run-events";
|
||||
import { TaskChatRunnerTurn } from "@/components/task-chat/TaskChatRunnerTurn";
|
||||
import { TaskChatThreadView } from "@/components/task-chat/TaskChatThreadView";
|
||||
import { TaskChatExpansionState } from "@/components/task-chat/expansion-state";
|
||||
import {
|
||||
buildTurnTimelineRows,
|
||||
transcriptToTaskChatItems,
|
||||
} from "@/components/task-chat/transcript-adapter";
|
||||
import type { TaskChatMessageItem, TaskChatTurnItem } from "@/components/task-chat/task-chat-model";
|
||||
|
||||
/**
|
||||
* Provider activity that the runner already maps to canonical events (web
|
||||
* research, model reroutes, intentional waits and the run's own verification
|
||||
* checks) rendered through the production path: persisted run events →
|
||||
* nativeRunEventsToTranscript → transcriptToTaskChatItems → runner turn.
|
||||
*/
|
||||
|
||||
const RUN_ID = "10000000-0000-4000-8000-0000000000a1";
|
||||
let baseMs = Date.parse("2026-11-05T16:20:00.000Z");
|
||||
let seqCounter = 0;
|
||||
function runEvent(
|
||||
eventType: string,
|
||||
payload: Record<string, unknown>,
|
||||
options: { itemId?: string; offsetMs?: number } = {},
|
||||
): HeartbeatRunEvent {
|
||||
const seq = ++seqCounter;
|
||||
const emittedAt = new Date(baseMs + (options.offsetMs ?? seq * 1500)).toISOString();
|
||||
return {
|
||||
id: seq,
|
||||
companyId: "10000000-0000-4000-8000-0000000000a2",
|
||||
runId: RUN_ID,
|
||||
agentId: "10000000-0000-4000-8000-0000000000a3",
|
||||
seq,
|
||||
eventType,
|
||||
stream: "system",
|
||||
level: "info",
|
||||
color: null,
|
||||
message: null,
|
||||
payload: {
|
||||
prpEvent: {
|
||||
schema: "paperclip.prp.event.v1",
|
||||
sourceEventId: `provider-activity-${seq}`,
|
||||
sourceSeq: seq,
|
||||
sourceInstanceId: "runner-story",
|
||||
sourceKind: "runner",
|
||||
runId: RUN_ID,
|
||||
normalizedSessionId: "session-story",
|
||||
eventType,
|
||||
schemaVersion: 1,
|
||||
priority: 1,
|
||||
emittedAt,
|
||||
...(options.itemId ? { itemId: options.itemId } : {}),
|
||||
payload,
|
||||
},
|
||||
},
|
||||
createdAt: new Date(emittedAt),
|
||||
};
|
||||
}
|
||||
|
||||
function message(itemId: string, text: string, channel: "commentary" | "final" = "commentary") {
|
||||
return runEvent("item.completed", { itemId, kind: "agentMessage", channel, text }, { itemId });
|
||||
}
|
||||
|
||||
function command(executionId: string, name: string, output: string, status: "completed" | "failed" = "completed") {
|
||||
const base = {
|
||||
schema: "paperclip.tool.execution.v1",
|
||||
executionId,
|
||||
transport: "process",
|
||||
operation: "execute",
|
||||
name,
|
||||
outputTruncated: false,
|
||||
outputDigest: null,
|
||||
};
|
||||
return [
|
||||
runEvent("tool.execution.started", { ...base, status: "running", output: null, outputBytes: 0 }),
|
||||
runEvent("tool.execution.completed", { ...base, status, output, outputBytes: output.length, exitCode: status === "failed" ? 1 : 0 }),
|
||||
];
|
||||
}
|
||||
|
||||
function webSearch(researchId: string, query: string, sources: Array<{ url: string; title: string; snippet?: string }>) {
|
||||
const base = { schema: "paperclip.research.v1", researchId, action: "search", query, url: null, pattern: null };
|
||||
return [
|
||||
runEvent("research.started", { ...base, status: "running", sources: [] }, { itemId: researchId }),
|
||||
runEvent("research.completed", {
|
||||
...base,
|
||||
status: "completed",
|
||||
sources: sources.map((source, index) => ({ sourceId: `${researchId}-${index}`, snippet: null, ...source })),
|
||||
}, { itemId: researchId }),
|
||||
];
|
||||
}
|
||||
|
||||
function openPage(researchId: string, url: string) {
|
||||
const base = { schema: "paperclip.research.v1", researchId, action: "open_page", query: null, url, pattern: null, sources: [] };
|
||||
return [
|
||||
runEvent("research.started", { ...base, status: "running" }, { itemId: researchId }),
|
||||
runEvent("research.completed", { ...base, status: "completed" }, { itemId: researchId }),
|
||||
];
|
||||
}
|
||||
|
||||
function verification(itemId: string, status: "passed" | "failed" | "not_run", commandOrCheck: string, detail?: string) {
|
||||
return runEvent("item.completed", {
|
||||
kind: "verification",
|
||||
text: `${status}: ${commandOrCheck}`,
|
||||
verification: { commandOrCheck, status, ...(detail ? { detail } : {}) },
|
||||
}, { itemId });
|
||||
}
|
||||
|
||||
function buildRunEvents(startMs: number): HeartbeatRunEvent[] {
|
||||
seqCounter = 0;
|
||||
baseMs = startMs;
|
||||
return [
|
||||
message("m1", "I’ll check how `Temporal.PlainDate.from` treats ISO week dates before touching the parser."),
|
||||
...webSearch("ws-1", "Temporal.PlainDate.from ISO 8601 week date support", [
|
||||
{ url: "https://tc39.es/proposal-temporal/docs/plaindate.html", title: "Temporal.PlainDate — TC39 proposal docs", snippet: "PlainDate.from() accepts ISO 8601 calendar dates; week dates are rejected." },
|
||||
{ url: "https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Temporal/PlainDate/from", title: "Temporal.PlainDate.from() - JavaScript | MDN", snippet: "Creates a new Temporal.PlainDate object from another object or a string." },
|
||||
{ url: "https://github.com/tc39/proposal-temporal/issues/2312", title: "Support ISO week-date strings in from() · Issue #2312", snippet: "Week dates such as 2026-W45-4 are intentionally out of scope." },
|
||||
{ url: "https://nodejs.org/en/blog/release/v24.0.0", title: "Node.js 24.0.0 release notes", snippet: "Temporal ships behind --harmony-temporal." },
|
||||
{ url: "https://stackoverflow.com/questions/77123456/parse-iso-week-date-javascript", title: "Parse an ISO week date in JavaScript - Stack Overflow" },
|
||||
]),
|
||||
...openPage("ws-2", "https://tc39.es/proposal-temporal/docs/plaindate.html"),
|
||||
runEvent("model.route.changed", {
|
||||
schema: "paperclip.model.route_changed.v1",
|
||||
routeId: "turn-1",
|
||||
provider: "openai",
|
||||
requestedModel: "gpt-5.6-sol",
|
||||
fromModel: "gpt-5.6-sol",
|
||||
effectiveModel: "gpt-5.6-luna",
|
||||
reason: "capacity",
|
||||
}),
|
||||
message("m2", "Week dates aren’t supported upstream, so I’ll parse them in `parseExportDate` and add tests."),
|
||||
...command("c1", "rg -n parseExportDate server/src", "server/src/export/worker.ts:42:export function parseExportDate("),
|
||||
...command("c2", "node --test server/src/export/worker.test.ts", "✔ parses calendar dates\n✖ parses ISO week dates\n\n5 passed, 1 failed", "failed"),
|
||||
runEvent("wait.started", {
|
||||
schema: "paperclip.wait.v1",
|
||||
waitId: "sleep-1",
|
||||
reason: "timer",
|
||||
status: "running",
|
||||
plannedDurationMs: 30_000,
|
||||
elapsedDurationMs: null,
|
||||
}, { itemId: "sleep-1" }),
|
||||
runEvent("wait.completed", {
|
||||
schema: "paperclip.wait.v1",
|
||||
waitId: "sleep-1",
|
||||
reason: "timer",
|
||||
status: "completed",
|
||||
plannedDurationMs: 30_000,
|
||||
elapsedDurationMs: 30_000,
|
||||
}, { itemId: "sleep-1" }),
|
||||
...command("c3", "node --test server/src/export/worker.test.ts", "✔ parses calendar dates\n✔ parses ISO week dates\n\n6 passed"),
|
||||
verification("v1", "passed", "node --test server/src/export/worker.test.ts", "6 tests passed"),
|
||||
verification("v2", "failed", "pnpm --filter @paperclipai/server typecheck", "2 errors in src/export/worker.ts"),
|
||||
verification("v3", "not_run", "pnpm test:e2e", "Browser suite not needed for a parser change"),
|
||||
message("final", "Week-date strings like `2026-W45-4` now parse to the matching calendar day. The worker tests pass; typecheck still reports two errors in the worker that predate this change.", "final"),
|
||||
];
|
||||
}
|
||||
|
||||
function useStoryItems(cutoff?: number) {
|
||||
return useMemo(() => {
|
||||
// Live stories end "now" so a running wait shows a real countdown.
|
||||
const events = buildRunEvents(cutoff == null ? Date.parse("2026-11-05T16:20:00.000Z") : Date.now() - cutoff * 1500 - 8_000);
|
||||
const visible = cutoff == null ? events : events.slice(0, cutoff);
|
||||
const transcript = nativeRunEventsToTranscript(visible);
|
||||
return transcriptToTaskChatItems(transcript, { runId: RUN_ID, running: cutoff != null });
|
||||
}, [cutoff]);
|
||||
}
|
||||
|
||||
function Frame({ children, narrow = false }: { children: React.ReactNode; narrow?: boolean }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-background text-foreground">
|
||||
<main className={narrow ? "mx-auto flex w-full max-w-sm flex-col gap-6 px-4 py-8" : "mx-auto flex w-full max-w-3xl flex-col gap-6 px-6 py-8"}>
|
||||
<div className="self-end rounded-xl bg-muted px-4 py-3 text-sm">
|
||||
The export worker rejects ISO week dates like 2026-W45-4. Can you fix the parser?
|
||||
</div>
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettledRun({ narrow = false }: { narrow?: boolean }) {
|
||||
const items = useStoryItems();
|
||||
const memory = useMemo(() => new Map<string, boolean>(), []);
|
||||
const rows = buildTurnTimelineRows(items, false);
|
||||
for (const row of rows) if (row.kind === "activity_phase") memory.set(row.id, true);
|
||||
const finalResponse = items.find(
|
||||
(item): item is TaskChatMessageItem => item.kind === "message" && item.channel === "final",
|
||||
);
|
||||
const turn: TaskChatTurnItem = {
|
||||
id: "provider-activity-turn",
|
||||
kind: "turn",
|
||||
settled: true,
|
||||
standaloneHeader: true,
|
||||
agentName: "Engineer",
|
||||
agentIcon: "code",
|
||||
items: rows,
|
||||
summary: { durationLabel: "2m 14s", toolCount: 3, added: 38, removed: 4 },
|
||||
finalResponse,
|
||||
};
|
||||
return (
|
||||
<Frame narrow={narrow}>
|
||||
<TaskChatExpansionState.Provider value={memory}>
|
||||
<TaskChatThreadView scroll={false} items={[turn]} />
|
||||
</TaskChatExpansionState.Provider>
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
|
||||
function LiveRun({ cutoff, narrow = false }: { cutoff: number; narrow?: boolean }) {
|
||||
const items = useStoryItems(cutoff);
|
||||
const memory = useMemo(() => new Map<string, boolean>(), []);
|
||||
for (const row of buildTurnTimelineRows(items, true)) if (row.kind === "activity_phase") memory.set(row.id, true);
|
||||
return (
|
||||
<Frame narrow={narrow}>
|
||||
<TaskChatExpansionState.Provider value={memory}>
|
||||
<TaskChatRunnerTurn
|
||||
runId={RUN_ID}
|
||||
agentName="Engineer"
|
||||
agentIcon="code"
|
||||
items={items}
|
||||
status="running"
|
||||
startedAtMs={Date.now() - 74_000}
|
||||
/>
|
||||
</TaskChatExpansionState.Provider>
|
||||
</Frame>
|
||||
);
|
||||
}
|
||||
|
||||
const meta = {
|
||||
id: "tasks-task-chat-provider-activity",
|
||||
title: "Tasks and conversations/Task creation and lifecycle/Tasks/Task chat/Provider activity",
|
||||
parameters: {
|
||||
layout: "fullscreen",
|
||||
docs: {
|
||||
description: {
|
||||
component:
|
||||
"Web research, model reroutes, intentional waits and verification checks from a Codex run. The fixtures are persisted run events and pass through nativeRunEventsToTranscript and transcriptToTaskChatItems, the live task-chat path.",
|
||||
},
|
||||
},
|
||||
paperclip: {
|
||||
capabilities: ["tasks/discussion", "runs-adapters/runs"],
|
||||
status: "current",
|
||||
implementation: "production",
|
||||
boundary: "fixtures",
|
||||
},
|
||||
},
|
||||
} satisfies Meta;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
|
||||
export const SettledExpanded: Story = {
|
||||
name: "01 · Settled run · expanded",
|
||||
render: () => <SettledRun />,
|
||||
};
|
||||
|
||||
export const LiveSearching: Story = {
|
||||
name: "02 · Live · searching the web",
|
||||
render: () => <LiveRun cutoff={2} />,
|
||||
};
|
||||
|
||||
export const LiveWaiting: Story = {
|
||||
name: "03 · Live · intentional wait",
|
||||
render: () => <LiveRun cutoff={12} />,
|
||||
};
|
||||
|
||||
export const SettledNarrow: Story = {
|
||||
name: "04 · Settled run · narrow",
|
||||
render: () => <SettledRun narrow />,
|
||||
};
|
||||
Reference in new issue
Block a user