diff --git a/packages/adapter-utils/src/types.ts b/packages/adapter-utils/src/types.ts index 5d3557da6e3..0eb9f21e0af 100644 --- a/packages/adapter-utils/src/types.ts +++ b/packages/adapter-utils/src/types.ts @@ -619,6 +619,7 @@ export type ProviderActivityFamily = | "safety" | "terminal" | "wait" + | "verification" | "provider_notice"; export type ProviderActivityStatus = diff --git a/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs b/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs index 390b8a69ca3..61ccdf894b4 100644 --- a/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs +++ b/packages/paperclip-runner/runner/crates/runner-core/src/provider_events.rs @@ -695,6 +695,64 @@ fn provider_performance_spans(params: &Value) -> Vec { }).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 { + 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::>() + }); + 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 { let mut events = Vec::new(); let push = |events: &mut Vec, @@ -987,6 +1045,16 @@ pub fn normalize_codex_notification(method: &str, params: &Value) -> Vec { const row = container.querySelector('[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-/); diff --git a/ui/src/components/task-chat/TaskChatProtocolActivityRow.tsx b/ui/src/components/task-chat/TaskChatProtocolActivityRow.tsx index db708d8f918..2c325c06b10 100644 --- a/ui/src/components/task-chat/TaskChatProtocolActivityRow.tsx +++ b/ui/src/components/task-chat/TaskChatProtocolActivityRow.tsx @@ -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 ; @@ -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 ( +
+ {item.summary ?

{item.summary}

: null} + {outcome ?

{outcome}

: null} +
+ ); +} + function detailContent(item: TaskChatProtocolItem, neutral = false): ReactNode | null { switch (item.surface) { case "provider_activity": { + if (item.family === "verification") return verificationOutcome(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 ? : 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 ? {item.title} :

{item.title}

; } - return <>{item.surface === "provider_activity" && item.summary ?

{item.summary}

: null}{detail}; + return <>{item.surface === "provider_activity" && item.family !== "verification" && item.summary ?

{item.summary}

: 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(); + 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 ( +
+ {visible.map((source) => ( + + {source.host} + + ))} + {hidden > 0 ? +{hidden} more : null} +
+ ); +} + +/** 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 {durationLabel(String(remaining))} left; +} + +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 ); } + 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 = ( <> - + {label}{active ? "…" : ""} {presentation.detail ? {presentation.detail} : null} + {verification && verificationOutcome(verification) ? ( + + {verificationOutcome(verification)} + + ) : null} + {runningWait ? : null} {expandable ? ( @@ -326,7 +408,7 @@ export function TaskChatProtocolActivityRow({ item }: { item: TaskChatProtocolIt aria-hidden /> ) : null} - + {verification ? null : } ); @@ -359,6 +441,7 @@ export function TaskChatProtocolActivityRow({ item }: { item: TaskChatProtocolIt return (
{activityRow} + {research && !open ? : null} {open && detail ? (
{detail} diff --git a/ui/src/components/task-chat/TaskChatProtocolCard.tsx b/ui/src/components/task-chat/TaskChatProtocolCard.tsx index 22cef131a63..cb73f151dd6 100644 --- a/ui/src/components/task-chat/TaskChatProtocolCard.tsx +++ b/ui/src/components/task-chat/TaskChatProtocolCard.tsx @@ -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; diff --git a/ui/src/components/task-chat/TaskChatRunnerActivityGroup.test.tsx b/ui/src/components/task-chat/TaskChatRunnerActivityGroup.test.tsx index a15aa78ceb2..480fcafbd2b 100644 --- a/ui/src/components/task-chat/TaskChatRunnerActivityGroup.test.tsx +++ b/ui/src/components/task-chat/TaskChatRunnerActivityGroup.test.tsx @@ -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 => ({ + 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('[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$/); + }); }); diff --git a/ui/src/components/task-chat/TaskChatRunnerActivityGroup.tsx b/ui/src/components/task-chat/TaskChatRunnerActivityGroup.tsx index e79ce53fda8..01b74d0122e 100644 --- a/ui/src/components/task-chat/TaskChatRunnerActivityGroup.tsx +++ b/ui/src/components/task-chat/TaskChatRunnerActivityGroup.tsx @@ -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): Pick { + 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: }; + } + 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 > -
)} + {!open && item.kind === "protocol" && item.surface === "provider_activity" && item.family === "research" && item.status !== "running" ? ( + + ) : null} {expandable && open ? (
{ 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"); + }); }); diff --git a/ui/src/components/task-chat/completed-activity-summary.ts b/ui/src/components/task-chat/completed-activity-summary.ts index 776faabbfb3..f3d6478484a 100644 --- a/ui/src/components/task-chat/completed-activity-summary.ts +++ b/ui/src/components/task-chat/completed-activity-summary.ts @@ -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", diff --git a/ui/src/components/task-chat/task-chat-activity-presentation.ts b/ui/src/components/task-chat/task-chat-activity-presentation.ts index a0cb58d0d07..e2fd51156ae 100644 --- a/ui/src/components/task-chat/task-chat-activity-presentation.ts +++ b/ui/src/components/task-chat/task-chat-activity-presentation.ts @@ -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 }; } diff --git a/ui/src/components/task-chat/task-chat-model.ts b/ui/src/components/task-chat/task-chat-model.ts index b2c877a26f3..30f55398dcc 100644 --- a/ui/src/components/task-chat/task-chat-model.ts +++ b/ui/src/components/task-chat/task-chat-model.ts @@ -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 { diff --git a/ui/src/components/task-chat/transcript-adapter.ts b/ui/src/components/task-chat/transcript-adapter.ts index 3757d5d5bda..9cdbfbeefeb 100644 --- a/ui/src/components/task-chat/transcript-adapter.ts +++ b/ui/src/components/task-chat/transcript-adapter.ts @@ -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, diff --git a/ui/src/components/transcript/native-run-events.test.ts b/ui/src/components/transcript/native-run-events.test.ts index dfdcd17722f..23f04b3468e 100644 --- a/ui/src/components/transcript/native-run-events.test.ts +++ b/ui/src/components/transcript/native-run-events.test.ts @@ -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", }), ]); }, diff --git a/ui/src/components/transcript/native-run-events.ts b/ui/src/components/transcript/native-run-events.ts index c1b7b757f0c..2b528d265bc 100644 --- a/ui/src/components/transcript/native-run-events.ts +++ b/ui/src/components/transcript/native-run-events.ts @@ -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["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, + ts: string, +): Extract | 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["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, + itemId: string | null, + ts: string, +): Extract | 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 { 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)) { diff --git a/ui/src/lib/issue-chat-messages.ts b/ui/src/lib/issue-chat-messages.ts index 26300dded54..e6df571fd62 100644 --- a/ui/src/lib/issue-chat-messages.ts +++ b/ui/src/lib/issue-chat-messages.ts @@ -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; diff --git a/ui/storybook/stories/tasks/task-chat-provider-activity.stories.tsx b/ui/storybook/stories/tasks/task-chat-provider-activity.stories.tsx new file mode 100644 index 00000000000..1910efaab01 --- /dev/null +++ b/ui/storybook/stories/tasks/task-chat-provider-activity.stories.tsx @@ -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, + 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 ( +
+
+
+ The export worker rejects ISO week dates like 2026-W45-4. Can you fix the parser? +
+ {children} +
+
+ ); +} + +function SettledRun({ narrow = false }: { narrow?: boolean }) { + const items = useStoryItems(); + const memory = useMemo(() => new Map(), []); + 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 ( + + + + + + ); +} + +function LiveRun({ cutoff, narrow = false }: { cutoff: number; narrow?: boolean }) { + const items = useStoryItems(cutoff); + const memory = useMemo(() => new Map(), []); + for (const row of buildTurnTimelineRows(items, true)) if (row.kind === "activity_phase") memory.set(row.id, true); + return ( + + + + + + ); +} + +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; + +export const SettledExpanded: Story = { + name: "01 · Settled run · expanded", + render: () => , +}; + +export const LiveSearching: Story = { + name: "02 · Live · searching the web", + render: () => , +}; + +export const LiveWaiting: Story = { + name: "03 · Live · intentional wait", + render: () => , +}; + +export const SettledNarrow: Story = { + name: "04 · Settled run · narrow", + render: () => , +};