Merge branch 'codex/work-folders-pi-refresh' into codex/work-folders-storybook-refresh

* codex/work-folders-pi-refresh:
  fix(runner): keep healthy native sessions alive (#13261)
  feat(connections): add AgentMail inboxes and email tasks (#13256)
  fix: stop completion reviews caused by policy upgrades (#13266)
  fix(ci): avoid empty pnpm caches from lockfile refresh (#13267)
  feat: accept a base64-encoded Cloud UI snippet (#13245)
  ci: use reserved AWS capacity for post-merge cloud verification (#13257)
  Fix Codex API key authentication in tests and runs (#13260)
  refactor: remove automatic productivity reviews (#13263)
  fix: continue conversations after confirmed remote runner stop (#13254)
  ci: cache Rust dependencies used by post-merge typecheck (#13259)
  fix(ui): restore task page archive shortcut (#13253)
  feat(ui): show one rolling runner activity per commentary group (#13255)
  fix(ui): hide retry countdown after execution starts (#13258)
  test: isolate native controller takeover fixture (#13212)
This commit is contained in:
Dotta committed 2026-09-11 17:15:49 -05:00
commit 24a7ab22f6
201 files changed
+62608 -3880

No files matched your search

+3 -2
View File
@@ -1054,15 +1054,16 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
visibleEnvironmentIds: environmentList.map((environment) => environment.id),
});
const adapterConfig = buildAdapterConfigForTest(adapterConfigPatch);
const agentId = isCreate ? undefined : props.agent.id;
if (props.compactTestFeedback) {
const providerAdapter = adapterType === "paperclip_runner"
? adapterConfig.provider === "codex" ? "codex_local"
: adapterConfig.provider === "acpx" && adapterConfig.acpxAgent === "claude" ? "claude_local"
: adapterType
: adapterType;
return testAgentSetup({ companyId: selectedCompanyId, adapterType, providerAdapter, adapterConfig, environmentId });
return testAgentSetup({ companyId: selectedCompanyId, adapterType, providerAdapter, adapterConfig, agentId, environmentId });
}
return agentsApi.testEnvironment(selectedCompanyId, adapterType, { adapterConfig, environmentId });
return agentsApi.testEnvironment(selectedCompanyId, adapterType, { adapterConfig, agentId, environmentId });
},
});
const [testActionPending, setTestActionPending] = useState(false);
+120
View File
@@ -0,0 +1,120 @@
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import type { Agent } from "@paperclipai/shared";
import { toolsApi } from "@/api/tools";
import { queryKeys } from "@/lib/queryKeys";
import { AgentMultiSelect } from "@/components/AgentMultiSelect";
import { RadioCardGroup } from "@/components/ui/radio-card";
export function EmailConnectionAccess({
companyId,
connectionId,
agents,
}: {
companyId: string;
connectionId: string;
agents: Agent[];
}) {
const cache = useQueryClient();
const grants = useQuery({
queryKey: queryKeys.tools.connectionGrants(connectionId),
queryFn: () => toolsApi.listConnectionGrants(connectionId),
});
const installs = useQuery({
queryKey: queryKeys.tools.connectionInstalls(connectionId),
queryFn: () => toolsApi.getConnectionInstalls(connectionId),
});
const save = useMutation({
mutationFn: (
next: Array<{ targetType: "company" | "agent"; targetId: string }>,
) => toolsApi.putConnectionInstalls(connectionId, next),
onSuccess: () => {
void cache.invalidateQueries({
queryKey: queryKeys.tools.connectionInstalls(connectionId),
});
},
});
if (grants.isLoading || installs.isLoading)
return <p className="text-sm text-muted-foreground">Loading access…</p>;
if (grants.error || installs.error)
return (
<p role="alert" className="text-sm text-destructive">
Connection access could not be loaded.
</p>
);
const active = grants.data?.grants.filter((g) => g.status === "active") ?? [];
const everyone = active.some((g) => g.kind === "organization");
const personal = active.find((g) => g.kind === "user");
const humanLabel = everyone
? "Any human in the company"
: personal
? personal.subjectUserId === grants.data?.currentUserId
? "Just me"
: "Only the credential owner"
: "Access revoked";
const allAgents =
installs.data?.installs.some((i) => i.targetType === "company") ?? false;
const selected = new Set(
installs.data?.installs
.filter((i) => i.targetType === "agent")
.map((i) => i.targetId),
);
const disabled = !grants.data?.capabilities.canConfigure || save.isPending;
return (
<div className="space-y-8">
<section className="space-y-3">
<h2 className="text-sm font-semibold">
Which humans can use this credential?
</h2>
<p className="text-sm">{humanLabel}</p>
</section>
<section className="space-y-4">
<h2 className="text-sm font-semibold">
Which agents can use this connection?
</h2>
<RadioCardGroup
ariaLabel="Which agents can use this connection"
value={allAgents ? "all" : "selected"}
disabled={disabled}
className="sm:grid-cols-2"
options={[
{ value: "selected", title: "Just agents I pick" },
{ value: "all", title: "Any agent" },
]}
onValueChange={(value) =>
save.mutate(
value === "all"
? [{ targetType: "company", targetId: companyId }]
: Array.from(selected, (targetId) => ({
targetType: "agent",
targetId,
})),
)
}
/>
{!allAgents && (
<AgentMultiSelect
agents={agents.filter((a) => a.status !== "terminated")}
selectedAgentIds={selected}
disabled={disabled}
onSave={(ids) =>
save.mutate(
Array.from(ids, (targetId) => ({
targetType: "agent",
targetId,
})),
)
}
/>
)}
<p className="text-xs text-muted-foreground">
Removing an assigned agent stops receiving and sending from its inbox.
</p>
{save.error && (
<p role="alert" className="text-sm text-destructive">
{save.error.message}
</p>
)}
</section>
</div>
);
}
+152
View File
@@ -0,0 +1,152 @@
import { createContext, useContext, type ReactNode } from "react";
import { useQuery } from "@tanstack/react-query";
import { Mail, Paperclip } from "lucide-react";
import type {
EmailMessage,
EmailPublicationSummary,
EmailThreadSummary,
} from "@paperclipai/shared";
import { emailApi } from "@/api/email";
import { issuesApi } from "@/api/issues";
import { useChatConnectorsEnabled } from "@/hooks/useChatConnectorsEnabled";
const EmailContext = createContext<EmailThreadSummary | null>(null);
export function EmailThreadProvider({
companyId,
issueId,
children,
}: {
companyId: string;
issueId: string;
children: ReactNode;
}) {
const { enabled } = useChatConnectorsEnabled();
const thread = useQuery({
queryKey: ["email-thread", companyId, issueId],
queryFn: () => emailApi.thread(companyId, issueId),
enabled,
refetchInterval: enabled ? 3000 : false,
});
return (
<EmailContext.Provider value={thread.data ?? null}>
{children}
</EmailContext.Provider>
);
}
export function useEmailComment(commentId: string) {
const thread = useContext(EmailContext);
const message = thread?.messages.find((m) => m.commentId === commentId);
return message && thread ? (
<EmailMessageCard
message={message}
publication={thread.publications.find(
(p) => p.providerMessageId === message.providerMessageId,
)}
issueId={thread.issueId}
/>
) : null;
}
export function EmailMessageCard({
message,
publication,
issueId,
}: {
message: EmailMessage;
publication?: EmailPublicationSummary;
issueId: string;
}) {
const attachments = useQuery({
queryKey: ["email-attachments", issueId],
queryFn: () => issuesApi.listAttachments(issueId),
enabled: message.attachmentIds.length > 0,
});
return (
<article
aria-label={
message.direction === "inbound" ? "Email received" : "Email sent"
}
className="space-y-4 rounded-xl border border-border bg-card p-5"
>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="flex items-center gap-2 text-sm font-semibold">
<Mail className="size-4" />
{message.direction === "inbound" ? "Email received" : "Email sent"}
</span>
<span className="text-xs text-muted-foreground">
{new Date(message.timestamp).toLocaleString()}
</span>
</div>
<div className="space-y-1">
<p className="text-sm font-medium">
{message.from}
{message.direction === "inbound" && (
<span className="ml-2 rounded-md bg-muted px-2 py-1 text-xs text-muted-foreground">
External
</span>
)}
</p>
<p className="break-words text-xs text-muted-foreground">
To: {message.to.join(", ")}
</p>
{!!message.cc?.length && (
<p className="break-words text-xs text-muted-foreground">
Cc: {message.cc.join(", ")}
</p>
)}
<p className="text-sm font-semibold">{message.subject}</p>
</div>
<div className="whitespace-pre-wrap break-words text-sm">
{message.text || "(No text body)"}
</div>
{!!message.attachmentIds.length && (
<div className="flex flex-wrap gap-2">
{message.attachmentIds.map((id) => {
const attachment = attachments.data?.find((a) => a.id === id);
return (
<a
key={id}
href={`/api/attachments/${id}/content`}
target="_blank"
rel="noreferrer"
className="flex items-center gap-2 rounded-md border border-border px-3 py-2 text-xs"
>
<Paperclip className="size-3.5" />
{attachment?.originalFilename ?? "Open attachment"}
</a>
);
})}
</div>
)}
<details className="text-xs text-muted-foreground">
<summary className="cursor-pointer">Email details</summary>
<div className="space-y-2 pt-3">
{!!message.bcc?.length && <p>Bcc: {message.bcc.join(", ")}</p>}
<p className="break-all">Message ID: {message.providerMessageId}</p>
{message.fullText !== message.text && (
<div className="whitespace-pre-wrap break-words">
{message.fullText}
</div>
)}
</div>
</details>
{publication && (
<p className="border-t border-border pt-3 text-xs text-muted-foreground">
{publication.outcome === "delivered"
? "Delivered"
: publication.outcome === "failed"
? "Delivery failed"
: publication.outcome === "uncertain"
? "Delivery uncertain"
: publication.outcome === "queued"
? "Queued"
: "Sent"}
{publication.error ? ` · ${publication.error}` : ""}
</p>
)}
{attachments.error && (
<p role="alert" className="text-xs text-destructive">
Attachments could not be loaded.
</p>
)}
</article>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { AlertTriangle } from "lucide-react";
export function EmailSafetyNotice() {
return (
<div
role="note"
className="space-y-3 rounded-lg border border-border bg-muted/30 p-4"
>
<div className="flex items-start gap-2">
<AlertTriangle className="size-4 shrink-0 text-(--status-agent-paused)" />
<div className="space-y-1">
<p className="text-sm font-medium">
Anyone can email an unrestricted inbox
</p>
<p className="text-sm text-muted-foreground">
Incoming email can create tasks and trigger agent work. Set up an
allowlist in AgentMail to limit who can contact this inbox.
</p>
<p className="text-xs text-muted-foreground">
Paperclip does not verify sender restrictions. AgentMail controls
new messages and replies separately; check both lists.
</p>
</div>
</div>
<div className="flex flex-wrap gap-4 text-sm">
<a
href="https://console.agentmail.to"
target="_blank"
rel="noreferrer"
className="underline underline-offset-4"
>
Open AgentMail ↗
</a>
<a
href="https://docs.agentmail.to/knowledge-base/allowlists-blocklists"
target="_blank"
rel="noreferrer"
className="text-muted-foreground underline underline-offset-4"
>
Set up allowlists ↗
</a>
</div>
</div>
);
}
+139
View File
@@ -0,0 +1,139 @@
import { EmailMessageCard } from "./EmailMessageCard";
import { useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { emailApi } from "@/api/email";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import type { EmailPublicationSummary } from "@paperclipai/shared";
// Email actions belong to the agent's task conversation. Only surface mail
// without a task comment yet and delivery outcomes that need attention here.
export function EmailTaskActivity({
companyId,
issueId,
}: {
companyId: string;
issueId: string;
}) {
const cache = useQueryClient();
const threadKey = ["email-thread", companyId, issueId];
const thread = useQuery({
queryKey: threadKey,
queryFn: () => emailApi.thread(companyId, issueId),
refetchInterval: 3000,
});
const data = thread.data;
const messages = data?.messages.filter((m) => !m.commentId) ?? [];
const publications = data?.publications.filter(
(p) => !p.providerMessageId || p.outcome === "uncertain",
) ?? [];
if (!thread.error && !messages.length && !publications.length) return null;
return (
<div className="space-y-3">
{thread.error && (
<p role="alert" className="text-sm text-destructive">
{thread.error.message}
</p>
)}
{messages.map((m) => (
<EmailMessageCard
key={m.id}
issueId={issueId}
message={m}
publication={data?.publications.find(
(p) => p.providerMessageId === m.providerMessageId,
)}
/>
))}
{publications.map((p) => (
<EmailDelivery
key={p.id}
companyId={companyId}
publication={p}
onResolved={() => {
void cache.invalidateQueries({ queryKey: threadKey });
}}
/>
))}
</div>
);
}
function EmailDelivery({
companyId,
publication: p,
onResolved,
}: {
companyId: string;
publication: EmailPublicationSummary;
onResolved: () => void;
}) {
const [messageId, setMessageId] = useState("");
const resolve = useMutation({
mutationFn: (outcome: "sent" | "failed") =>
emailApi.resolve(companyId, p.id, outcome, messageId || undefined),
onSuccess: onResolved,
});
return (
<div className="space-y-2 text-xs text-muted-foreground">
{p.request && !p.providerMessageId && (
<article
aria-label="Email send intent"
className="space-y-3 rounded-lg border border-border p-4"
>
<p className="font-semibold">{p.request.subject ?? "Email reply"}</p>
{p.request.to && <p>To: {p.request.to.join(", ")}</p>}
<div className="whitespace-pre-wrap break-words text-sm text-foreground">
{p.request.text}
</div>
</article>
)}
<p>
Email {p.outcome}
{p.error ? ` — ${p.error}` : ""}
</p>
{p.outcome === "uncertain" && (
<details>
<summary className="cursor-pointer">
Resolve delivery after checking AgentMail
</summary>
<div className="space-y-2 py-2">
<p>
Confirm the outcome in AgentMail before resolving. This action
does not resend.
</p>
<Input
aria-label="Provider message ID"
value={messageId}
onChange={(e) => setMessageId(e.target.value)}
placeholder="Provider message ID"
/>
<div className="flex gap-2">
<Button
size="sm"
variant="outline"
disabled={!messageId || resolve.isPending}
onClick={() => resolve.mutate("sent")}
>
Confirm sent
</Button>
<Button
size="sm"
variant="outline"
disabled={resolve.isPending}
onClick={() => resolve.mutate("failed")}
>
Confirm not sent
</Button>
</div>
{resolve.error && (
<p role="alert" className="text-destructive">
{resolve.error.message}
</p>
)}
</div>
</details>
)}
</div>
);
}
+7 -1
View File
@@ -1,4 +1,5 @@
import { TaskChatPausedTakeover, type TaskComposerPause } from "./task-chat/TaskChatPausedTakeover";
import { useEmailComment } from "./EmailMessageCard";
import { AssistantRuntimeProvider } from "@assistant-ui/react";
import type {
ReasoningMessagePart,
@@ -3488,7 +3489,12 @@ function CompactSystemNoticeRow({
);
}
function SystemNoticeCommentRow({
function SystemNoticeCommentRow(props: { message: ThreadMessage; anchorId?: string }) {
const custom = props.message.metadata.custom as Record<string, unknown>;
const email = useEmailComment(typeof custom.commentId === "string" ? custom.commentId : props.message.id);
return email ?? <SystemNoticeCommentContent {...props} />;
}
function SystemNoticeCommentContent({
message,
anchorId,
}: {
@@ -193,4 +193,42 @@ describe("IssueMonitorBanner / IssueMonitorComposerStrip rendering", () => {
flushSync(() => root.unmount());
});
it("removes both countdowns and Check now when the retry starts, then shows a newly scheduled retry", () => {
const root = createRoot(container);
const issue = {
status: "in_progress",
scheduledRetry: {
status: "scheduled_retry",
scheduledRetryAt: new Date(NOW.getTime() - 2 * 60_000).toISOString(),
scheduledRetryAttempt: 1,
},
} as Issue;
const render = (next: Issue) => flushSync(() => root.render(
<>
<IssueMonitorBanner issue={next} onCheckNow={vi.fn()} />
<IssueMonitorComposerStrip issue={next} onCheckNow={vi.fn()} />
</>,
));
render(issue);
expect(container.textContent).toContain("Overdue by 2m");
for (const status of ["queued", "running"] as const) {
const promoted = { ...issue, scheduledRetry: { ...issue.scheduledRetry!, status } };
render(promoted);
expect(hasVisibleMonitorSurface(promoted)).toBe(false);
expect(container.textContent).toBe("");
expect(container.querySelector("button")).toBeNull();
expect(vi.getTimerCount()).toBe(0);
}
render({ ...issue, scheduledRetry: { ...issue.scheduledRetry!, scheduledRetryAt: new Date(NOW.getTime() + 5 * 60_000).toISOString() } });
expect(container.textContent).toContain("Resumes in 5m");
render({ ...issue, status: "done" });
expect(container.textContent).toBe("");
expect(vi.getTimerCount()).toBe(0);
flushSync(() => root.unmount());
});
});
+1 -18
View File
@@ -2,7 +2,7 @@ import { requiresExecutionReconciliation } from "@paperclipai/shared";
import type { ReactNode } from "react";
import type { ExternalObjectSummary, Issue, IssueRecoveryAction } from "@paperclipai/shared";
import { Link } from "@/lib/router";
import { Archive, Eye, Flag } from "lucide-react";
import { Archive, Flag } from "lucide-react";
import {
createIssueDetailPath,
rememberIssueDetailLocationState,
@@ -20,7 +20,6 @@ import {
type RecoveryLivenessContext,
} from "../lib/recovery-lineage";
import { StatusIcon } from "./StatusIcon";
import { productivityReviewTriggerLabel } from "./ProductivityReviewBadge";
import { hasAssignedBacklogBlocker } from "../lib/issue-blockers";
import { ExternalObjectStatusSummary } from "./ExternalObjectStatusSummary";
import { Badge } from "@/components/ui/badge";
@@ -192,19 +191,6 @@ export function IssueRow({
);
const selectedStatusClass = selected ? "!text-muted-foreground !border-muted-foreground" : undefined;
const detailState = withIssueDetailHeaderSeed(issueLinkState, issue);
const productivityReview = issue.productivityReview ?? null;
const productivityReviewIndicator = productivityReview ? (
<span
className={cn(
"inline-flex h-4 w-4 shrink-0 items-center justify-center rounded-full border border-amber-500/40 bg-amber-500/10 text-amber-600 dark:text-amber-300",
selected ? "border-muted-foreground text-muted-foreground" : null,
)}
title={`Productivity review: ${productivityReviewTriggerLabel(productivityReview.trigger)}`}
aria-label="Productivity review open"
>
<Eye className="h-2.5 w-2.5" aria-hidden />
</span>
) : null;
const hasChecklistStep = checklistStepNumber !== null;
const checklistStep = hasChecklistStep ? (
<span className="shrink-0 font-mono text-xs text-muted-foreground" aria-hidden="true">
@@ -305,7 +291,6 @@ export function IssueRow({
className={selectedStatusClass}
/>
)}
{productivityReviewIndicator}
{parkedBlockerIndicator}
</span>
@@ -401,7 +386,6 @@ export function IssueRow({
</Link>
<span className="flex shrink-0 items-center gap-1 pt-px sm:hidden">
{mobileLeading ?? <StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} size="md" className={selectedStatusClass} />}
{productivityReviewIndicator}
{parkedBlockerIndicator}
</span>
<span className="flex min-w-0 flex-1 flex-col gap-1 sm:contents">
@@ -475,7 +459,6 @@ export function IssueRow({
<>
<span className="hidden shrink-0 items-center gap-1 sm:inline-flex">
<StatusIcon status={issue.status} blockerAttention={issue.blockerAttention} size="md" className={selectedStatusClass} />
{productivityReviewIndicator}
</span>
{checklistStep}
<span className="shrink-0 font-mono text-xs text-muted-foreground">
@@ -1,77 +0,0 @@
import { Eye } from "lucide-react";
import type { IssueProductivityReview } from "@paperclipai/shared";
import { Link } from "../lib/router";
import { cn } from "../lib/utils";
import { createIssueDetailPath } from "../lib/issueDetailBreadcrumb";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
const TRIGGER_LABELS: Record<string, string> = {
no_comment_streak: "No-comment streak",
long_active_duration: "Long active duration",
high_churn: "High churn",
};
const REVIEW_STATUS_LABELS: Record<string, string> = {
todo: "Open",
in_progress: "In progress",
in_review: "In review",
blocked: "Blocked",
backlog: "Open",
};
export function productivityReviewTriggerLabel(
trigger: IssueProductivityReview["trigger"],
): string {
if (!trigger) return "Productivity review";
return TRIGGER_LABELS[trigger] ?? "Productivity review";
}
export function ProductivityReviewBadge({
review,
className,
hideLabel = false,
}: {
review: IssueProductivityReview;
className?: string;
hideLabel?: boolean;
}) {
const label = productivityReviewTriggerLabel(review.trigger);
const reviewIdentifier = review.reviewIdentifier ?? review.reviewIssueId.slice(0, 8);
const reviewPath = createIssueDetailPath(review.reviewIdentifier ?? review.reviewIssueId);
const statusLabel = REVIEW_STATUS_LABELS[review.status] ?? review.status.replace(/_/g, " ");
return (
<Tooltip>
<TooltipTrigger asChild>
<Link
to={reviewPath}
className={cn(
"inline-flex items-center gap-1 rounded-full border border-amber-500/40 bg-amber-500/10 px-2 py-0.5 text-(length:--text-nano) font-medium text-amber-700 dark:text-amber-300 shrink-0 hover:bg-amber-500/20 transition-colors",
className,
)}
aria-label={`Under review · productivity review ${reviewIdentifier} (${label})`}
>
<Eye className="h-3 w-3" aria-hidden />
{hideLabel ? null : <span>Under review</span>}
</Link>
</TooltipTrigger>
<TooltipContent>
<div className="space-y-1 text-xs">
<div className="font-semibold">Productivity review open</div>
<div>
<span className="text-muted-foreground">Trigger:</span> {label}
</div>
{typeof review.noCommentStreak === "number" && review.noCommentStreak > 0 ? (
<div>
<span className="text-muted-foreground">No-comment streak:</span>{" "}
{review.noCommentStreak} runs
</div>
) : null}
<div>
<span className="text-muted-foreground">Review:</span> {reviewIdentifier} ({statusLabel})
</div>
</div>
</TooltipContent>
</Tooltip>
);
}
+1 -1
View File
@@ -988,7 +988,7 @@ describe("TaskChatThread runtime transcript selection", () => {
);
const revealUsage = () => {
const summary = container.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-phase-summary"]',
'[data-testid="task-chat-activity-phase-toggle"]',
);
expect(summary).not.toBeNull();
if (summary?.getAttribute("aria-expanded") !== "true") {
+3 -1
View File
@@ -58,6 +58,7 @@ export function TrustPresetSection({
projectCandidates = [],
issueCandidates = [],
candidatesLoading,
allowSingleIssue = true,
}: {
permissions: Partial<AgentPermissions> | null | undefined;
onChange: (permissions: Partial<AgentPermissions>) => void;
@@ -66,6 +67,7 @@ export function TrustPresetSection({
projectCandidates?: LowTrustBoundaryCandidate[];
issueCandidates?: LowTrustBoundaryCandidate[];
candidatesLoading?: boolean;
allowSingleIssue?: boolean;
}) {
const [policyOpen, setPolicyOpen] = useState(false);
const preset = getTrustPreset(permissions);
@@ -158,7 +160,7 @@ export function TrustPresetSection({
>
<option value="project">Project</option>
<option value="root_issue">Root issue</option>
<option value="issue">Issue</option>
{allowSingleIssue && <option value="issue">Issue</option>}
</select>
</Field>
<Field label={BOUNDARY_TARGET_LABELS[targetType]}>
@@ -13,6 +13,7 @@ const providerNames: Record<ChatProvider, string> = {
discord: "Discord",
"microsoft-teams": "Microsoft Teams",
telegram: "Telegram",
agentmail: "AgentMail",
};
export function AgentChannelsPanel({
@@ -39,7 +40,7 @@ export function AgentChannelsPanel({
<div>
<h2 className="text-lg font-semibold">Channels</h2>
<p className="mt-1 text-sm text-muted-foreground">
Provider identities that let people chat with this agent.
Chat and email identities connected to this agent.
</p>
</div>
<Button asChild size="sm">
@@ -53,9 +54,9 @@ export function AgentChannelsPanel({
<p className="text-sm text-muted-foreground">Loading channels…</p>
) : endpoints.length === 0 ? (
<div className="rounded-lg border border-dashed border-border p-5">
<p className="text-sm font-medium">No chat channels connected</p>
<p className="text-sm font-medium">No channels connected</p>
<p className="mt-1 text-sm text-muted-foreground">
Connect Slack, GitHub, Discord, Microsoft Teams, or Telegram from
Connect AgentMail, Slack, GitHub, Discord, Microsoft Teams, or Telegram from
Connectors.
</p>
<Button asChild className="mt-3" variant="outline" size="sm">
@@ -37,6 +37,7 @@ const providerNames: Record<ChatProvider, string> = {
discord: "Discord",
"microsoft-teams": "Microsoft Teams",
telegram: "Telegram",
agentmail: "AgentMail",
};
type PublicationFeedback = {
@@ -128,7 +129,7 @@ type ConnectedTaskProps = {
export function ExternallyConnectedTaskBanner(props: ConnectedTaskProps) {
const { binding } = useIssueChatBinding(props.companyId, props.issueId);
if (!binding) return null;
if (!binding || binding.provider === "agentmail") return null;
return (
<ConnectedTaskComposer
key={boardSendDraftKey(
@@ -1,4 +1,5 @@
import { useCallback, useContext, useState, type ReactNode } from "react";
import { useEmailComment } from "@/components/EmailMessageCard";
import type { IssueAttachment } from "@paperclipai/shared";
import { IssueGalleryContext } from "@/context/IssueGalleryContext";
import { cn } from "@/lib/utils";
@@ -140,7 +141,11 @@ function uniqueAttachmentRefs(refs: AttachmentRef[]): AttachmentRef[] {
);
}
export function TaskChatBubble({
export function TaskChatBubble(props: TaskChatBubbleProps) {
const email = useEmailComment(props.item.id);
return email ?? <TaskChatBubbleContent {...props} />;
}
function TaskChatBubbleContent({
item,
animateEntry = true,
queuedAction,
@@ -6,7 +6,7 @@ import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { ThemeProvider } from "@/context/ThemeContext";
import { MemoryRouter } from "@/lib/router";
import type { TaskChatProtocolItem, TaskChatProviderActivityItem } from "./task-chat-model";
import { TaskChatProtocolActivityRow } from "./TaskChatProtocolActivityRow";
import { TaskChatProtocolActivityRow, TaskChatProtocolActivityDetails } from "./TaskChatProtocolActivityRow";
describe("TaskChatProtocolActivityRow", () => {
let container: HTMLDivElement;
@@ -119,6 +119,20 @@ describe("TaskChatProtocolActivityRow", () => {
expect(container.querySelector('[data-testid="task-chat-workspace-change-details"] pre')).toBeNull();
});
it.each(["javascript:alert(1)", "java\nscript:alert(1)", "data:text/html,unsafe", "file:///etc/passwd", "vbscript:unsafe"])("rejects unsafe resource URLs in rows and details: %s", (href) => {
const item: TaskChatProtocolItem = { id: "unsafe-resource", kind: "protocol", surface: "resource", resourceKind: "document", title: "Notes", subtitle: "Document", href };
render(item);
expect(container.querySelector("a")).toBeNull();
act(() => root.render(<TaskChatProtocolActivityDetails item={item} />));
expect(container.querySelector("a")).toBeNull();
expect(container.textContent).toContain("Notes");
});
it.each(["https://example.com/report", "http://example.com/report", "/documents/notes", "#notes"])("allows safe resource links in details: %s", (href) => {
act(() => root.render(<TaskChatProtocolActivityDetails item={{ id: "resource", kind: "protocol", surface: "resource", resourceKind: "document", title: "Notes", subtitle: "Document", href }} />));
expect(container.querySelector("a")?.getAttribute("href")).toBe(href);
});
it("renders durable resources as direct compact links", () => {
render({
id: "resource",
@@ -35,7 +35,8 @@ function StatusIcon({ status }: { status: "running" | "completed" | "failed" | "
return <Circle className="h-3.5 w-3.5 text-muted-foreground" aria-hidden />;
}
function stepStatusIcon(status: TaskChatProtocolStep["status"]) {
function stepStatusIcon(status: TaskChatProtocolStep["status"], neutral = false) {
if (neutral && (status === "blocked" || status === "failed")) return <Circle className="h-3 w-3 text-muted-foreground" aria-hidden />;
if (status === "in_progress") return <Loader2 className="h-3 w-3 animate-spin text-(--status-agent-running)" aria-hidden />;
if (status === "completed") return <Check className="h-3 w-3 text-(--status-task-icon-done)" aria-hidden />;
if (status === "blocked" || status === "failed") return <X className="h-3 w-3 text-destructive" aria-hidden />;
@@ -125,7 +126,7 @@ function ResearchDetails({ item }: { item: TaskChatProviderActivityItem }) {
);
}
function ProviderDetails({ item }: { item: TaskChatProviderActivityItem }) {
function ProviderDetails({ item, neutral = false }: { item: TaskChatProviderActivityItem; neutral?: boolean }) {
if (item.family === "research") return <ResearchDetails item={item} />;
return (
<div className="flex min-w-0 flex-col gap-2">
@@ -133,7 +134,7 @@ function ProviderDetails({ item }: { item: TaskChatProviderActivityItem }) {
<ol className="flex flex-col gap-1" aria-label="Plan steps">
{item.steps.map((step) => (
<li className="flex min-w-0 items-start gap-2" key={step.id}>
<span className="mt-0.5 shrink-0">{stepStatusIcon(step.status)}</span>
<span className="mt-0.5 shrink-0">{stepStatusIcon(step.status, neutral)}</span>
<span className={cn("min-w-0", step.status === "completed" && "text-muted-foreground line-through")}>{step.label}</span>
</li>
))}
@@ -226,11 +227,11 @@ function WorkspaceFileDetails({ item }: { item: TaskChatWorkspaceFileItem }) {
);
}
function detailContent(item: TaskChatProtocolItem): ReactNode | null {
function detailContent(item: TaskChatProtocolItem, neutral = false): ReactNode | null {
switch (item.surface) {
case "provider_activity": {
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} /> : null;
return expandable ? <ProviderDetails item={item} neutral={neutral} /> : null;
}
case "workspace_change": return <WorkspaceChangeDetails item={item} />;
case "workspace_file": return <WorkspaceFileDetails item={item} />;
@@ -242,6 +243,32 @@ function detailContent(item: TaskChatProtocolItem): ReactNode | null {
}
}
function safeActivityHref(value: string | null | undefined): string | undefined {
if (!value) return undefined;
try {
// A fixed HTTPS base accepts relative app paths and fragments as well as
// external HTTP(S) resources, but never executable or local-file schemes.
const url = new URL(value, "https://paperclip.invalid");
return url.protocol === "https:" || url.protocol === "http:" ? value : undefined;
} catch {
return undefined;
}
}
export function hasTaskChatProtocolActivityDetails(item: TaskChatProtocolItem): boolean {
if (item.surface === "resource") return Boolean(safeActivityHref(item.href));
return detailContent(item) !== null || (item.surface === "provider_activity" && Boolean(item.summary?.trim()));
}
export function TaskChatProtocolActivityDetails({ item, neutral = false }: { item: TaskChatProtocolItem; neutral?: boolean }) {
const detail = detailContent(item, neutral);
if (item.surface === "resource") {
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}</>;
}
function itemStatus(item: TaskChatProtocolItem): "running" | "completed" | "failed" | "interrupted" | "informational" {
if (item.surface === "provider_activity") return item.status;
if (item.surface === "workspace_change") return item.complete ? "completed" : "running";
@@ -292,9 +319,10 @@ export function TaskChatProtocolActivityRow({ item }: { item: TaskChatProtocolIt
</>
);
if (item.surface === "resource" && item.href) {
const resourceHref = item.surface === "resource" ? safeActivityHref(item.href) : undefined;
if (resourceHref) {
return (
<a className="group/activity -mx-1.5 flex min-h-6 w-full min-w-0 max-w-full items-center gap-2 overflow-hidden rounded-sm px-1.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground" href={item.href} data-testid="task-chat-protocol-activity-row">
<a className="group/activity -mx-1.5 flex min-h-6 w-full min-w-0 max-w-full items-center gap-2 overflow-hidden rounded-sm px-1.5 py-1 text-xs text-muted-foreground transition-colors hover:bg-muted/60 hover:text-foreground" href={resourceHref} data-testid="task-chat-protocol-activity-row">
{row}
</a>
);
@@ -0,0 +1,253 @@
// @vitest-environment jsdom
import { act } from "react";
import { createRoot, type Root } from "react-dom/client";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { TaskChatThreadView } from "./TaskChatThreadView";
import { ThemeProvider } from "@/context/ThemeContext";
import { MemoryRouter } from "@/lib/router";
import { TaskChatRunnerActivityGroup } from "./TaskChatRunnerActivityGroup";
import { TaskChatExpansionState } from "./expansion-state";
import type {
TaskChatActivityPhaseItem,
TaskChatToolItem,
} from "./task-chat-model";
const motion = vi.hoisted(() => ({ reduced: false }));
vi.mock("motion/react", async (original) => ({
...(await original<typeof import("motion/react")>()),
useReducedMotion: () => motion.reduced,
}));
const tool = (
id: string,
status: TaskChatToolItem["status"] = "in_progress",
): TaskChatToolItem => ({
id,
kind: "tool",
name: "exec_command",
target: `command-${id}`,
status,
detail: `output-${id}`,
});
describe("TaskChatRunnerActivityGroup", () => {
let container: HTMLDivElement;
let root: Root;
let memory: Map<string, boolean>;
beforeEach(() => {
container = document.createElement("div");
document.body.appendChild(container);
root = createRoot(container);
memory = new Map();
motion.reduced = false;
});
afterEach(() => {
act(() => root.unmount());
container.remove();
});
const render = (
items: TaskChatActivityPhaseItem["items"],
host = "live",
active = true,
) =>
act(() =>
root.render(
<TaskChatExpansionState.Provider value={memory}>
<TaskChatRunnerActivityGroup
key={host}
item={{
id: "commentary:phase",
kind: "activity_phase",
items,
active,
summary: "",
}}
/>
</TaskChatExpansionState.Provider>,
),
);
const toggle = () =>
container.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-activity-phase-toggle"]',
)!;
const viewport = () =>
container.querySelector('[data-testid="task-chat-activity-viewport"]')!;
it("rolls to each new item once while status and token updates keep the current row mounted", () => {
render([tool("one")]);
const first = viewport().querySelector('[data-activity-row="one"]');
render([tool("one", "completed")]);
expect(viewport().querySelector('[data-activity-row="one"]')).toBe(first);
expect(container.querySelector(".runner-activity-roll-in")).toBeNull();
render([tool("one", "completed"), tool("two")]);
expect(viewport().querySelectorAll("[data-activity-row]")).toHaveLength(2);
const outgoing = container.querySelector(".runner-activity-roll-out")!;
expect(outgoing.getAttribute("aria-hidden")).toBe("true");
const current = container.querySelector(".runner-activity-roll-in");
render([
tool("one", "completed"),
{ ...tool("two"), detail: "more output" },
]);
expect(container.querySelector(".runner-activity-roll-in")).toBe(current);
act(() =>
outgoing.dispatchEvent(
new Event("webkitAnimationEnd", { bubbles: true }),
),
);
expect(viewport().querySelectorAll("[data-activity-row]")).toHaveLength(1);
expect(viewport().textContent).toContain("command-two");
});
it("updates provider reasoning text in place without restarting motion", () => {
render([
{
id: "thought",
kind: "thinking",
lines: ["First", "Check"],
streaming: true,
},
]);
const line = viewport().querySelector("[data-activity-row]");
render([
{
id: "thought",
kind: "thinking",
lines: ["First", "Checking the file"],
streaming: true,
},
]);
expect(viewport().querySelector("[data-activity-row]")).toBe(line);
expect(viewport().textContent).toContain("Checking the file");
expect(viewport().textContent).not.toContain("First");
expect(container.querySelector(".runner-activity-roll-in")).toBeNull();
});
it("preserves expanded history and item detail across appends and live-to-history remount", () => {
render([tool("one", "completed")]);
act(() => toggle().click());
const row = container.querySelector<HTMLButtonElement>("li button")!;
act(() => row.click());
expect(container.textContent).toContain("output-one");
render([tool("one", "completed"), tool("two")]);
expect(container.querySelectorAll("li")).toHaveLength(2);
expect(container.querySelector("li button")).toBe(row);
render(
[tool("one", "completed"), tool("two", "completed")],
"history",
false,
);
expect(toggle().getAttribute("aria-expanded")).toBe("true");
expect(container.querySelectorAll("li")).toHaveLength(2);
expect(container.textContent).toContain("output-one");
act(() => toggle().click());
expect(viewport().textContent).toContain("command-two");
});
it("keeps failures discoverable after later activity, with neutral detail and no X", () => {
render([tool("failed", "failed"), tool("next")]);
expect(toggle().textContent).toContain("1 failed");
act(() => toggle().click());
expect(container.querySelector("li")?.textContent).toContain("failed");
act(() => container.querySelector<HTMLButtonElement>("li button")!.click());
expect(container.textContent).toContain("output-failed");
expect(container.querySelector(".lucide-x, .text-destructive")).toBeNull();
});
it("uses the same group and expansion memory in the real persisted thread renderer", () => {
render([tool("one", "failed"), tool("two")]);
act(() => toggle().click());
act(() => container.querySelector<HTMLButtonElement>("li button")!.click());
act(() =>
root.render(
<TaskChatExpansionState.Provider value={memory}>
<MemoryRouter>
<ThemeProvider>
<TaskChatThreadView
scroll={false}
items={[
{
id: "saved",
kind: "message",
author: "agent",
text: "Finished",
attachedTurn: {
id: "turn",
kind: "turn",
settled: true,
standaloneHeader: true,
summary: { toolCount: 2, added: 0, removed: 0 },
items: [
{
id: "commentary:phase",
kind: "activity_phase",
active: false,
summary: "Ran commands",
items: [
tool("one", "failed"),
tool("two", "completed"),
],
},
],
},
},
]}
/>
</ThemeProvider>
</MemoryRouter>
</TaskChatExpansionState.Provider>,
),
);
expect(toggle().getAttribute("aria-expanded")).toBe("true");
expect(container.querySelectorAll("li")).toHaveLength(2);
expect(container.textContent).toContain("output-one");
expect(container.textContent).toContain("Finished");
expect(container.querySelector(".text-destructive,.lucide-x")).toBeNull();
act(() => toggle().click());
expect(viewport().textContent).toContain("command-two");
expect(toggle().textContent).toContain("1 failed");
});
it("does not offer empty disclosures for sparse activities", () => {
render([
{ id: "thinking-empty", kind: "thinking", lines: [], streaming: true },
{ id: "tool-empty", kind: "tool", name: "read", status: "completed" },
{
id: "provider-empty",
kind: "protocol",
surface: "provider_activity",
family: "wait",
status: "running",
title: "Wait",
eventType: "wait.started",
details: [],
steps: [],
links: [],
children: [],
},
]);
act(() => toggle().click());
expect(container.querySelectorAll("li")).toHaveLength(3);
expect(
container.querySelectorAll(
"li button, li [aria-expanded], li [aria-controls]",
),
).toHaveLength(0);
expect(
container.querySelector(
'[data-testid="task-chat-runner-activity-detail"]',
),
).toBeNull();
});
it("replaces immediately with reduced motion", () => {
motion.reduced = true;
render([tool("one")]);
render([tool("one", "completed"), tool("two")]);
expect(viewport().querySelectorAll("[data-activity-row]")).toHaveLength(1);
expect(
container.querySelector(
".runner-activity-roll-in, .runner-activity-roll-out",
),
).toBeNull();
});
});
@@ -0,0 +1,405 @@
import { useId, useState } from "react";
import {
Brain,
ChevronDown,
ChevronRight,
CirclePause,
Gauge,
} from "lucide-react";
import { useReducedMotion } from "motion/react";
import { MarkdownBody } from "@/components/MarkdownBody";
import { cn } from "@/lib/utils";
import { useTaskChatExpansion } from "./expansion-state";
import type { TaskChatActivityPhaseItem } from "./task-chat-model";
import {
protocolActivityIsRunning,
protocolActivityLabel,
protocolActivityPresentation,
} from "./task-chat-activity-presentation";
import {
TaskChatProtocolActivityDetails,
hasTaskChatProtocolActivityDetails,
} from "./TaskChatProtocolActivityRow";
import { TaskChatUsageReadout } from "./TaskChatUsageReadout";
import { toolActivityPresentation } from "./tool-taxonomy";
type Activity = TaskChatActivityPhaseItem["items"][number];
function presentation(item: Activity, active: boolean) {
if (item.kind === "tool") {
const tool = toolActivityPresentation({
name: item.rawName ?? item.name,
target: item.target,
});
const running =
active && (item.status === "pending" || item.status === "in_progress");
return {
icon: tool.icon,
label:
item.status === "failed"
? tool.failedLabel
: item.status === "interrupted"
? tool.interruptedLabel
: running
? tool.runningLabel
: tool.completedLabel,
target: item.target,
mono: true,
running,
};
}
if (item.kind === "thinking") {
const running = active && Boolean(item.streaming);
return {
icon: Brain,
label: running ? "Thinking" : "Thought",
target: item.lines
.filter((line) => line.trim())
.at(-1)
?.trim(),
mono: false,
running,
};
}
if (item.kind === "protocol") {
const protocol = protocolActivityPresentation(item);
if (!protocol) return null;
return {
icon: protocol.icon,
label: protocolActivityLabel(item, protocol),
target: protocol.detail,
mono: true,
running: active && protocolActivityIsRunning(item),
};
}
if (item.kind === "marker")
return {
icon: CirclePause,
label: item.label,
target: item.detail,
mono: false,
running: false,
};
const { used, size, inputTokens, outputTokens, costUsd } = item.usage;
const usage = [
size > 0
? `${used.toLocaleString()}/${size.toLocaleString()} ctx`
: undefined,
inputTokens != null || outputTokens != null
? `↑${(inputTokens ?? 0).toLocaleString()} ↓${(outputTokens ?? 0).toLocaleString()}`
: undefined,
costUsd != null ? `$${costUsd.toFixed(4)}` : undefined,
]
.filter(Boolean)
.join(" · ");
return {
icon: Gauge,
label: item.label ?? "Token usage",
target: usage || item.detail,
mono: false,
running: false,
};
}
function isFailure(item: Activity) {
return (
(item.kind === "tool" && item.status === "failed") ||
(item.kind === "protocol" &&
item.surface === "provider_activity" &&
item.status === "failed") ||
(item.kind === "marker" && item.tone === "error")
);
}
function ActivityContent({
item,
active,
}: {
item: Activity;
active: boolean;
}) {
const row = presentation(item, active);
if (!row) return null;
const Icon = row.icon;
return (
<span
className="flex min-w-0 flex-1 items-center gap-2"
data-activity-row={item.id}
data-activity-family={
item.kind === "protocol"
? item.surface === "provider_activity"
? item.family
: item.surface
: undefined
}
>
<span
className="flex size-5 shrink-0 items-center justify-center"
data-activity-icon
>
<Icon className="size-3.5" aria-hidden="true" />
</span>
<span className="flex min-w-0 flex-1 items-center gap-2 overflow-hidden whitespace-nowrap">
<span
className={cn(
"max-w-full shrink-0 truncate text-xs",
row.running && "text-foreground",
)}
>
{row.label}
</span>
{row.target ? (
<span
title={row.target}
className={cn(
"truncate text-xs text-muted-foreground",
row.mono && "font-mono",
)}
>
{row.target}
</span>
) : null}
</span>
</span>
);
}
/** Only a new logical activity moves; token and status updates stay mounted. */
function RollingActivity({
item,
active,
}: {
item: Activity;
active: boolean;
}) {
const reducedMotion = useReducedMotion();
const [frame, setFrame] = useState({
current: item,
previous: null as Activity | null,
});
if (frame.current.id !== item.id)
setFrame({ current: item, previous: reducedMotion ? null : frame.current });
else if (frame.current !== item) setFrame({ ...frame, current: item });
return (
<span
className="relative flex h-8 min-w-0 flex-1 items-center overflow-hidden"
aria-live="polite"
aria-atomic="true"
data-testid="task-chat-activity-viewport"
>
{frame.previous && !reducedMotion ? (
<span
key={`exit-${item.id}`}
className="runner-activity-roll-out absolute inset-0 flex items-center"
aria-hidden="true"
onAnimationEnd={() =>
setFrame((current) =>
current.current.id === item.id
? { ...current, previous: null }
: current,
)
}
>
<ActivityContent item={frame.previous} active={false} />
</span>
) : null}
<span
key={item.id}
className={cn(
"relative flex w-full min-w-0 items-center",
frame.previous && !reducedMotion && "runner-activity-roll-in",
)}
>
<ActivityContent item={item} active={active} />
</span>
</span>
);
}
function ActivityDetails({ item }: { item: Activity }) {
if (item.kind === "thinking")
return <MarkdownBody softBreaks>{item.lines.join("\n")}</MarkdownBody>;
if (item.kind === "usage") return <TaskChatUsageReadout item={item} />;
if (item.kind === "protocol")
return <TaskChatProtocolActivityDetails item={item} neutral />;
if (item.kind === "marker")
return (
<p className="whitespace-pre-wrap break-words">
{item.detail ?? item.label}
</p>
);
return (
<>
{item.target ? (
<p className="break-all font-mono">{item.target}</p>
) : null}
{item.detail ? (
<pre className="whitespace-pre-wrap break-words font-mono">
{item.detail}
</pre>
) : null}
{item.decision ? <p>Permission {item.decision}</p> : null}
{item.diff ? (
<p className="break-all font-mono">
{item.diff.path} · +{item.diff.added} −{item.diff.removed}
</p>
) : null}
</>
);
}
function hasActivityDetails(item: Activity): boolean {
if (item.kind === "protocol") return hasTaskChatProtocolActivityDetails(item);
if (item.kind === "thinking") return item.lines.some((line) => line.trim());
if (item.kind === "tool")
return Boolean(
item.target?.trim() || item.detail?.trim() || item.diff || item.decision,
);
if (item.kind === "marker") return Boolean(item.detail?.trim());
return Boolean(
item.detail ||
item.usage.size > 0 ||
item.usage.inputTokens != null ||
item.usage.outputTokens != null ||
item.usage.costUsd != null,
);
}
function ExpandedActivity({
item,
active,
}: {
item: Activity;
active: boolean;
}) {
const [open, setOpen] = useTaskChatExpansion(
`runner-detail:${item.id}`,
false,
);
const detailId = useId();
const expandable = hasActivityDetails(item);
const content = <ActivityContent item={item} active={active} />;
return (
<li className="min-w-0" data-activity-item-id={item.id}>
{expandable ? (
<button
type="button"
className="flex h-8 w-full min-w-0 items-center gap-2 rounded-sm text-left text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
onClick={() => setOpen(!open)}
aria-expanded={open}
aria-controls={open ? detailId : undefined}
>
{content}
<ChevronRight
className={cn("size-3.5 shrink-0", open && "rotate-90")}
aria-hidden="true"
/>
</button>
) : (
<div className="flex h-8 w-full min-w-0 items-center gap-2 text-muted-foreground">
{content}
</div>
)}
{expandable && open ? (
<div
id={detailId}
className="flex min-w-0 flex-col gap-2 overflow-hidden rounded-md bg-muted/40 p-3 text-xs leading-relaxed text-muted-foreground"
data-testid="task-chat-runner-activity-detail"
>
<ActivityDetails item={item} />
</div>
) : null}
</li>
);
}
/** One rolling activity between commentary messages, with persistent optional history. */
export function TaskChatRunnerActivityGroup({
item,
defaultExpanded = false,
}: {
item: TaskChatActivityPhaseItem;
defaultExpanded?: boolean;
}) {
const [expanded, setExpanded] = useTaskChatExpansion(
item.id,
defaultExpanded,
);
const historyId = useId();
const activities = item.items.filter(
(activity) => presentation(activity, false) !== null,
);
const latest = activities.at(-1);
const failures = activities.filter(isFailure).length;
const countLabel = `${activities.length} ${activities.length === 1 ? "activity" : "activities"}`;
return (
<section
className="flex min-w-0 flex-col gap-2"
data-testid="task-chat-activity-phase"
data-activity-group={item.id}
data-expanded={expanded}
>
{item.interstitial ? (
<div
className="min-w-0 text-sm text-foreground/90"
data-testid="task-chat-phase-interstitial"
>
<MarkdownBody softBreaks linkIssueReferences>
{item.interstitial.text}
</MarkdownBody>
</div>
) : null}
{latest ? (
<div className="min-w-0">
<button
type="button"
className="flex min-h-8 w-full min-w-0 items-center gap-2 rounded-sm text-left text-muted-foreground hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
data-testid="task-chat-activity-phase-toggle"
onClick={() => setExpanded(!expanded)}
aria-expanded={expanded}
aria-controls={expanded ? historyId : undefined}
aria-label={`${expanded ? "Collapse" : "Expand"} ${countLabel}`}
>
{expanded ? (
<span className="flex min-h-8 flex-1 items-center gap-2 text-xs">
<span className="flex size-5 shrink-0 items-center justify-center">
<ChevronDown className="size-3.5" aria-hidden="true" />
</span>
<span>{countLabel}</span>
</span>
) : (
<RollingActivity item={latest} active={item.active} />
)}
{failures > 0 ? (
<span className="shrink-0 text-xs text-muted-foreground">
{failures} failed
</span>
) : null}
<span className="flex shrink-0 items-center gap-1 text-xs">
{expanded ? "Collapse" : activities.length}
{!expanded ? (
<ChevronRight className="size-3.5" aria-hidden="true" />
) : null}
</span>
</button>
{expanded ? (
<ol
id={historyId}
className="flex min-w-0 flex-col gap-1"
aria-label="Activity history"
data-testid="task-chat-runner-activity-list"
>
{activities.map((activity, index) => (
<ExpandedActivity
key={activity.id}
item={activity}
active={item.active && index === activities.length - 1}
/>
))}
</ol>
) : null}
</div>
) : null}
</section>
);
}
@@ -242,39 +242,14 @@ describe("TaskChatRunnerTurn", () => {
detail: "STREAM-1\n",
},
]);
expect(
container.querySelector('[data-testid="task-chat-phase-interstitial"]')
?.textContent,
).toContain("Running the exact command now.");
expect(
container.querySelector('[data-testid="task-chat-phase-summary"]')
?.textContent,
).toContain("Ran a command");
expect(
container.querySelector('[data-testid="task-chat-current-activity"]')
?.textContent,
).toContain("Running a command");
expect(container.textContent).toContain("STREAM-$i");
const identity = container.querySelector(
'[data-testid="task-chat-agent-identity"]',
);
const activity = container.querySelector(
'[data-testid="task-chat-current-activity"]',
);
const timeline = container.querySelector(
'[data-testid="task-chat-turn-timeline"]',
);
expect(identity?.compareDocumentPosition(timeline!)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
expect(timeline?.compareDocumentPosition(activity!)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
expect(
container
.querySelector('[data-testid="task-chat-runner-identity-row"]')
?.classList.contains("pt-2"),
).toBe(true);
const commentary = container.querySelector('[data-testid="task-chat-phase-interstitial"]');
const activity = container.querySelector('[data-testid="task-chat-activity-viewport"]');
expect(commentary?.textContent).toContain("Running the exact command now.");
expect(activity?.textContent).toContain("Running a command");
expect(activity?.textContent).toContain("STREAM-$i");
expect(container.querySelector('[data-testid="task-chat-current-activity"]')).toBeNull();
expect(commentary?.compareDocumentPosition(activity!)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
});
it("keeps the timer at the top and moves resumed Thinking below completed activity", () => {
@@ -303,23 +278,13 @@ describe("TaskChatRunnerTurn", () => {
},
]);
const header = container.querySelector(
'[data-testid="task-chat-turn-status-header"]',
);
const timeline = container.querySelector(
'[data-testid="task-chat-turn-timeline"]',
);
const activity = container.querySelector(
'[data-testid="task-chat-current-activity"]',
);
const header = container.querySelector('[data-testid="task-chat-turn-status-header"]');
const activity = container.querySelector('[data-testid="task-chat-activity-viewport"]');
expect(header?.textContent).toContain("Working for");
expect(activity?.textContent).toBe("Thinking");
expect(header?.compareDocumentPosition(timeline!)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
expect(timeline?.compareDocumentPosition(activity!)).toBe(
Node.DOCUMENT_POSITION_FOLLOWING,
);
expect(header?.compareDocumentPosition(activity!)).toBe(Node.DOCUMENT_POSITION_FOLLOWING);
expect(container.querySelector('[data-testid="task-chat-current-activity"]')).toBeNull();
});
it("keeps earlier commentary mounted when later commentary streams", () => {
@@ -384,19 +349,19 @@ describe("TaskChatRunnerTurn", () => {
"I’ve found the rendering seam.",
);
expect(
container.querySelector('[data-testid="task-chat-thinking"]'),
container.querySelector('[data-testid="task-chat-runner-activity-list"]'),
).toBeNull();
act(() =>
container
.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-phase-summary"]',
'[data-testid="task-chat-activity-phase-toggle"]',
)
?.click(),
);
expect(
container.querySelector('[data-testid="task-chat-thinking"]')
container.querySelector('[data-testid="task-chat-runner-activity-list"]')
?.textContent,
).toContain("Reasoning");
).toContain("Thought");
});
it("keeps the latest provider-authored reasoning line visible while activity is folded", () => {
@@ -412,14 +377,23 @@ describe("TaskChatRunnerTurn", () => {
]);
const ticker = container.querySelector(
'[data-testid="task-chat-reasoning-ticker"]',
'[data-testid="task-chat-activity-viewport"]',
);
expect(ticker?.textContent).toContain("Checking the steering path.");
expect(
container.querySelector('[data-testid="task-chat-thinking"]'),
container.querySelector('[data-testid="task-chat-runner-activity-list"]'),
).toBeNull();
});
it("keeps a visible fallback when completion tools are filtered before the final reply", () => {
render([{ id: "finish", kind: "tool", name: "paperclip_finish", status: "in_progress" }]);
expect(container.querySelector('[data-testid="task-chat-current-activity"]')?.textContent).toContain("Thinking");
expect(container.querySelector('[data-testid="task-chat-activity-phase"]')).toBeNull();
render([{ id: "finish-provider", kind: "protocol", surface: "provider_activity", family: "tool_execution", eventType: "tool.execution.started", status: "running", title: "Finish", details: [{ label: "Name", value: "paperclip_finish" }], steps: [], links: [], children: [] }]);
expect(container.querySelector('[data-testid="task-chat-current-activity"]')?.textContent).toContain("Thinking");
expect(container.querySelector('[data-testid="task-chat-activity-phase"]')).toBeNull();
});
it("surfaces native activity transport failure while retrying", () => {
act(() =>
root.render(
@@ -482,20 +456,20 @@ describe("TaskChatRunnerTurn", () => {
);
expect(rows).toHaveLength(2);
expect(rows[0]?.textContent).toContain("First phase.");
expect(rows[0]?.textContent).toContain("Read a file");
expect(rows[0]?.textContent).toContain("Read file");
expect(rows[1]?.textContent).toContain("Second phase.");
expect(rows[1]?.textContent).toContain("Ran a command");
act(() =>
rows[0]
?.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-phase-summary"]',
'[data-testid="task-chat-activity-phase-toggle"]',
)
?.click(),
);
expect(
rows[0]
?.querySelector('[data-testid="task-chat-tool-icon"]')
?.parentElement?.classList.contains("w-5"),
?.querySelector('[data-activity-icon] svg')
?.parentElement?.classList.contains("size-5"),
).toBe(true);
});
@@ -524,65 +498,17 @@ describe("TaskChatRunnerTurn", () => {
act(() =>
container
.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-phase-summary"]',
'[data-testid="task-chat-activity-phase-toggle"]',
)
?.click(),
);
const history = container.querySelector(
'[data-testid="task-chat-runner-activity-list"]',
);
const rail = container.querySelector(
'[data-testid="task-chat-runner-activity-rail"]',
)?.parentElement;
expect(rail?.classList.contains("pl-6")).toBe(true);
expect(rail?.classList.contains("ml-4")).toBe(true);
expect(history?.textContent).toContain("Inspect the current card.");
expect(history?.textContent).toContain(
"Keep the canonical revision atomic.",
);
const thinkingRows = history?.querySelectorAll(
'[data-testid="task-chat-thinking"]',
);
expect(thinkingRows).toHaveLength(2);
expect(thinkingRows?.[0]?.textContent).not.toContain("Reasoning");
expect(thinkingRows?.[0]?.querySelector(".shimmer-text")).toBeNull();
expect(
thinkingRows?.[0]
?.querySelector('[data-testid="task-chat-thinking-icon"]')
?.classList.contains("text-(--status-agent-running)"),
).toBe(false);
expect(thinkingRows?.[1]?.textContent).toContain("Reasoning detail…");
expect(thinkingRows?.[1]?.querySelector(".shimmer-text")).not.toBeNull();
expect(
thinkingRows?.[1]
?.querySelector('[data-testid="task-chat-thinking-icon"]')
?.classList.contains("text-(--status-agent-running)"),
).toBe(true);
expect(thinkingRows?.[0]?.classList.contains("text-xs")).toBe(true);
expect(thinkingRows?.[0]?.classList.contains("font-normal")).toBe(true);
expect(
thinkingRows?.[0]
?.querySelector('[data-testid="task-chat-thinking-icon"]')
?.parentElement?.classList.contains("w-5"),
).toBe(true);
expect(
thinkingRows?.[0]
?.querySelector('[data-testid="task-chat-thinking-icon"]')
?.parentElement?.classList.contains("justify-center"),
).toBe(true);
expect(
thinkingRows?.[0]?.querySelector(
'[data-testid="task-chat-thinking-text"]',
)?.textContent,
).toContain("Inspect the current card.");
expect(
thinkingRows?.[0]?.querySelector(".task-chat-reasoning-markdown"),
).toBeNull();
expect(
thinkingRows?.[1]
?.querySelector("button")
?.classList.contains("font-normal"),
).toBe(true);
const history = container.querySelector('[data-testid="task-chat-runner-activity-list"]');
expect(container.querySelector('[data-testid="task-chat-runner-activity-rail"]')).toBeNull();
expect(history?.querySelectorAll("li")).toHaveLength(2);
expect(history?.textContent).toContain("ThoughtInspect the current card.");
expect(history?.textContent).toContain("ThinkingKeep the canonical revision atomic.");
expect(container.querySelector('[data-testid="task-chat-runner-activity-detail"]')).toBeNull();
});
it("renders only the current reasoning block as active", () => {
@@ -613,29 +539,14 @@ describe("TaskChatRunnerTurn", () => {
act(() =>
container
.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-phase-summary"]',
'[data-testid="task-chat-activity-phase-toggle"]',
)
?.click(),
);
const oldReasoning = container.querySelector(
'[data-activity-item-id="old-reasoning"]',
);
expect(oldReasoning?.textContent).toBe("Inspect the current card.");
expect(oldReasoning?.querySelector(".shimmer-text")).toBeNull();
expect(
oldReasoning?.querySelector('[data-testid="task-chat-thinking-text"]'),
).not.toBeNull();
expect(container.querySelector('[data-activity-item-id="old-reasoning"]')?.textContent).toContain("ThoughtInspect the current card.");
expect(container.querySelector('[data-activity-item-id="current-reasoning"]')?.textContent).toContain("ThinkingVerify the updated state.");
const currentReasoning = container.querySelector(
'[data-activity-item-id="current-reasoning"]',
);
expect(currentReasoning?.textContent).toContain("Reasoning…");
expect(
currentReasoning
?.querySelector('[data-testid="task-chat-thinking-icon"]')
?.classList.contains("text-(--status-agent-running)"),
).toBe(true);
});
it("does not let a textless reasoning lifecycle remove sticky commentary", () => {
@@ -663,11 +574,11 @@ describe("TaskChatRunnerTurn", () => {
?.textContent,
).toContain("Old commentary");
expect(
container.querySelector('[data-testid="task-chat-phase-summary"]'),
container.querySelector('[data-testid="task-chat-current-activity"]'),
).toBeNull();
expect(
container.querySelector(
'[data-testid="task-chat-current-activity-label"]',
'[data-testid="task-chat-activity-viewport"]',
)?.textContent,
).toBe("Thinking");
});
@@ -698,9 +609,9 @@ describe("TaskChatRunnerTurn", () => {
container.querySelector('[data-testid="task-chat-live-plan-preview"]'),
).toBeNull();
const disclosure = container.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-phase-summary"]',
'[data-testid="task-chat-activity-phase-toggle"]',
);
expect(disclosure?.getAttribute("aria-label")).toContain("Expand activity");
expect(disclosure?.getAttribute("aria-label")).toContain("Expand 1 activity");
act(() => disclosure?.click());
const history = container.querySelector(
'[data-testid="task-chat-runner-activity-list"]',
@@ -711,8 +622,8 @@ describe("TaskChatRunnerTurn", () => {
).not.toBeNull();
expect(
history
?.querySelector('[data-testid="task-chat-protocol-activity-icon"]')
?.parentElement?.classList.contains("w-5"),
?.querySelector('[data-activity-icon] svg')
?.parentElement?.classList.contains("size-5"),
).toBe(true);
});
@@ -737,13 +648,13 @@ describe("TaskChatRunnerTurn", () => {
]);
const activity = container.querySelector(
'[data-testid="task-chat-current-activity"]',
'[data-testid="task-chat-activity-viewport"]',
);
expect(activity?.textContent).toContain("Searching the web");
expect(activity?.textContent).toContain(
"site:openai.com model guide GPT-5.4",
);
expect(activity?.getAttribute("data-activity-family")).toBe("research");
expect(activity?.querySelector("[data-activity-family]")?.getAttribute("data-activity-family")).toBe("research");
});
it("has a purpose-built current-activity presentation for every provider family", () => {
@@ -877,9 +788,9 @@ describe("TaskChatRunnerTurn", () => {
},
]);
const activity = container.querySelector(
'[data-testid="task-chat-current-activity"]',
'[data-testid="task-chat-activity-viewport"]',
);
expect(activity?.getAttribute("data-activity-family"), entry.family).toBe(
expect(activity?.querySelector("[data-activity-family]")?.getAttribute("data-activity-family"), entry.family).toBe(
entry.family,
);
expect(activity?.textContent, entry.family).toContain(entry.expected);
@@ -904,12 +815,12 @@ describe("TaskChatRunnerTurn", () => {
});
render([provider("failed")]);
expect(
container.querySelector('[data-testid="task-chat-current-activity"]')
container.querySelector('[data-testid="task-chat-activity-viewport"]')
?.textContent,
).toContain("Web search failed");
render([provider("interrupted")]);
expect(
container.querySelector('[data-testid="task-chat-current-activity"]')
container.querySelector('[data-testid="task-chat-activity-viewport"]')
?.textContent,
).toContain("Web search stopped");
});
@@ -929,14 +840,6 @@ describe("TaskChatRunnerTurn", () => {
patchArtifactRef: null,
},
]);
expect(
container.querySelector('[data-testid="task-chat-current-activity"]')
?.textContent,
).toContain("Editing files");
expect(
container.querySelector('[data-testid="task-chat-current-activity"]')
?.textContent,
).toContain("2 files");
const card = container.querySelector(
'[data-testid="task-chat-workspace-change"]',
);
@@ -962,16 +865,15 @@ describe("TaskChatRunnerTurn", () => {
},
]);
const activity = container.querySelector(
'[data-testid="task-chat-current-activity"]',
'[data-testid="task-chat-activity-viewport"]',
);
expect(activity?.textContent).toContain("Referenced a file");
expect(activity?.textContent).toContain("ui/src/App.tsx:42");
expect(activity?.classList.contains("px-1")).toBe(true);
const icon = activity?.querySelector(
'[data-testid="task-chat-current-activity-icon"]',
'[data-activity-icon] svg',
);
expect(icon).not.toBeNull();
expect(icon?.parentElement?.classList.contains("w-5")).toBe(true);
expect(icon?.parentElement?.classList.contains("size-5")).toBe(true);
expect(icon?.parentElement?.classList.contains("justify-center")).toBe(
true,
);
@@ -1203,9 +1105,9 @@ describe("TaskChatRunnerTurn", () => {
?.textContent,
).toContain("Worked for");
expect(
container.querySelector('[data-testid="task-chat-phase-summary"]')
container.querySelector('[data-testid="task-chat-activity-phase-toggle"]')
?.textContent,
).toContain("Reasoning");
).toContain("Thought");
});
it("keeps final text mounted through a transient replay gap", () => {
@@ -1313,14 +1215,14 @@ describe("TaskChatRunnerTurn", () => {
act(() =>
container
.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-phase-summary"]',
'[data-testid="task-chat-activity-phase-toggle"]',
)
?.click(),
);
expect(
container
.querySelector('[data-testid="task-chat-marker-icon"]')
?.parentElement?.classList.contains("w-5"),
.querySelector('[data-activity-icon] svg')
?.parentElement?.classList.contains("size-5"),
).toBe(true);
});
@@ -1504,9 +1406,9 @@ describe("TaskChatRunnerTurn", () => {
"commentary-4:phase",
],
);
expect(rows[0]?.textContent).toContain("Read 2 files");
expect(rows[0]?.textContent).toContain("Read file");
expect(rows[2]?.textContent).toContain("Questions answered");
expect(rows[3]?.textContent).toContain("Used a tool");
expect(rows[3]?.textContent).toContain("Searched the web");
expect(rows[5]?.textContent).toContain("Ran a command");
const worked = container.querySelector(
'[data-testid="task-chat-turn-status-header"]',
@@ -1562,7 +1464,7 @@ describe("TaskChatRunnerTurn", () => {
]);
const disclosure = container.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-phase-summary"]',
'[data-testid="task-chat-activity-phase-toggle"]',
);
expect(disclosure?.getAttribute("aria-expanded")).toBe("false");
expect(
@@ -1595,7 +1497,7 @@ describe("TaskChatRunnerTurn", () => {
];
render(items);
const disclosure = container.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-phase-summary"]',
'[data-testid="task-chat-activity-phase-toggle"]',
);
act(() => disclosure?.click());
expect(disclosure?.getAttribute("aria-expanded")).toBe("true");
@@ -1606,7 +1508,7 @@ describe("TaskChatRunnerTurn", () => {
);
const settled = container.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-phase-summary"]',
'[data-testid="task-chat-activity-phase-toggle"]',
);
expect(settled?.getAttribute("aria-expanded")).toBe("true");
expect(
@@ -1630,13 +1532,13 @@ describe("TaskChatRunnerTurn", () => {
act(() =>
container
.querySelector<HTMLButtonElement>(
'[data-testid="task-chat-phase-summary"]',
'[data-testid="task-chat-activity-phase-toggle"]',
)
?.click(),
);
expect(
container
.querySelector('[data-testid="task-chat-phase-summary"]')
.querySelector('[data-testid="task-chat-activity-phase-toggle"]')
?.getAttribute("aria-expanded"),
).toBe("true");
@@ -1644,7 +1546,7 @@ describe("TaskChatRunnerTurn", () => {
expect(
container
.querySelector('[data-testid="task-chat-phase-summary"]')
.querySelector('[data-testid="task-chat-activity-phase-toggle"]')
?.getAttribute("aria-expanded"),
).toBe("false");
});
@@ -1,35 +1,19 @@
import { useRef, useState, type ComponentType, type SVGProps } from "react";
import { useRef } from "react";
import type { ExecutionProjection } from "@paperclipai/shared";
import { Brain, OctagonX } from "lucide-react";
import { MarkdownBody } from "@/components/MarkdownBody";
import { useSecondTick } from "@/hooks/useSecondTick";
import { cn } from "@/lib/utils";
import type {
TaskChatItem,
TaskChatMessageItem,
TaskChatMarkerItem,
TaskChatProtocolItem,
TaskChatProviderActivityItem,
TaskChatRuntimeRequestDecision,
TaskChatRuntimeRequestItem,
TaskChatThinkingItem,
TaskChatToolItem,
} from "./task-chat-model";
import { TaskChatAgentIdentity, TaskChatBubble } from "./TaskChatBubble";
import { TaskChatBubbleActions } from "./TaskChatBubbleActions";
import { formatTaskChatTimestamp } from "./task-chat-adapter";
import { TaskChatActivityPhase } from "./TaskChatActivityPhase";
import { TaskChatProtocolActivityRow } from "./TaskChatProtocolActivityRow";
import { TaskChatRunnerActivityGroup } from "./TaskChatRunnerActivityGroup";
import { TaskChatProtocolCard } from "./TaskChatProtocolCard";
import { TaskChatPlanPreviewCard } from "./TaskChatPlanPreviewCard";
import { TaskChatThinking } from "./TaskChatThinking";
import { TaskChatToolCard } from "./TaskChatToolCard";
import { TaskChatUsageReadout } from "./TaskChatUsageReadout";
import {
protocolActivityIsRunning,
protocolActivityLabel,
protocolActivityPresentation,
} from "./task-chat-activity-presentation";
import {
buildTurnTimelineRows,
isTerminalRunStatus,
@@ -37,26 +21,6 @@ import {
paperclipRunnerFinalResponse,
paperclipRunnerTimelineItems,
} from "./transcript-adapter";
import { toolTaxonomy } from "./tool-taxonomy";
function lastOf<T extends TaskChatItem>(
items: readonly TaskChatItem[],
predicate: (item: TaskChatItem) => item is T,
): T | undefined {
for (let index = items.length - 1; index >= 0; index -= 1) {
const item = items[index];
if (predicate(item)) return item;
}
return undefined;
}
function isHeadlineProtocolActivity(
item: TaskChatItem,
): item is TaskChatProtocolItem {
return (
item.kind === "protocol" && protocolActivityPresentation(item) !== null
);
}
function currentActivityStatusItems(
items: readonly TaskChatItem[],
@@ -79,131 +43,6 @@ function currentActivityStatusItems(
return items.slice(boundaryIndex + 1);
}
type FoldedNarration =
| { kind: "commentary"; item: TaskChatMessageItem; order: number }
| {
kind: "reasoning";
item: TaskChatThinkingItem;
line: string | null;
lineIndex: number;
order: number;
};
function latestFoldedNarration(
items: readonly TaskChatItem[],
): FoldedNarration | null {
let latest: FoldedNarration | null = null;
for (const [index, item] of items.entries()) {
const order =
item.kind === "message" || item.kind === "thinking"
? (item.transcriptIndex ?? index)
: -1;
if (item.kind === "message" && item.interstitial && item.text.trim()) {
if (!latest || order >= latest.order)
latest = { kind: "commentary", item, order };
continue;
}
if (item.kind !== "thinking") continue;
let lineIndex = -1;
for (
let candidate = item.lines.length - 1;
candidate >= 0;
candidate -= 1
) {
if (item.lines[candidate]?.trim()) {
lineIndex = candidate;
break;
}
}
if (!latest || order >= latest.order) {
latest = {
kind: "reasoning",
item,
line: lineIndex < 0 ? null : item.lines[lineIndex]!.trim(),
lineIndex,
order,
};
}
}
return latest;
}
function FoldedReasoningTicker({
logicalKey,
text,
}: {
logicalKey: string;
text: string;
}) {
const [ticker, setTicker] = useState({
logicalKey,
motionKey: 0,
current: text,
exiting: null as string | null,
});
if (ticker.logicalKey !== logicalKey) {
setTicker({
logicalKey,
motionKey: ticker.motionKey + 1,
current: text,
exiting: ticker.current,
});
} else if (ticker.current !== text) {
// Token fragments update the mounted line. Only a new logical line moves
// the ticker, so streaming text does not restart the animation per token.
setTicker({ ...ticker, current: text });
}
return (
<div
className="flex min-w-0 gap-2 px-1 py-1.5"
data-testid="task-chat-reasoning-ticker"
>
<div className="flex shrink-0 items-center">
<Brain className="h-3.5 w-3.5 text-muted-foreground/50" aria-hidden />
</div>
<div className="relative h-5 min-w-0 flex-1 overflow-hidden">
{ticker.exiting !== null ? (
<span
key={`out-${ticker.motionKey}`}
className="cot-line-exit absolute inset-x-0 truncate text-(length:--text-compact) italic leading-5 text-muted-foreground"
onAnimationEnd={() =>
setTicker((current) => ({ ...current, exiting: null }))
}
>
{ticker.exiting}
</span>
) : null}
<span
key={`in-${ticker.motionKey}`}
className={cn(
"absolute inset-x-0 truncate text-(length:--text-compact) italic leading-5 text-muted-foreground",
ticker.motionKey > 0 && "cot-line-enter",
)}
aria-live="polite"
aria-atomic="true"
>
{ticker.current}
</span>
</div>
</div>
);
}
function FoldedLiveNarration({
narration,
}: {
narration: Extract<FoldedNarration, { kind: "reasoning" }>;
}) {
if (!narration.line) return null;
return (
<FoldedReasoningTicker
logicalKey={`${narration.item.id}:${narration.lineIndex}`}
text={narration.line}
/>
);
}
function formatCompactDuration(ms: number | null): string | null {
if (ms == null || !Number.isFinite(ms)) return null;
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
@@ -222,74 +61,6 @@ function terminalStatusFailed(status: string): boolean {
);
}
function RunnerActivityTimeline({ items }: { items: readonly TaskChatItem[] }) {
if (items.length === 0) return null;
return (
<div className="relative ml-4 min-w-0 pl-6">
<span
className="absolute inset-y-1 left-0 w-px bg-border/70"
aria-hidden
data-testid="task-chat-runner-activity-rail"
/>
<ol
className="flex min-w-0 flex-col gap-2 py-1"
aria-label="Run activity"
data-testid="task-chat-runner-activity-list"
>
{items.map((item, index) => (
<li className="min-w-0" key={item.id} data-activity-item-id={item.id}>
{item.kind === "message" ? (
<div
className="min-w-0 px-1 text-sm text-foreground/90"
data-testid="task-chat-activity-commentary"
>
<MarkdownBody softBreaks linkIssueReferences>
{item.text}
</MarkdownBody>
</div>
) : item.kind === "thinking" ? (
<TaskChatThinking
item={item}
active={Boolean(item.streaming) && index === items.length - 1}
defaultOpen={false}
rowClassName="mx-0 px-0"
/>
) : item.kind === "tool" ? (
<TaskChatToolCard item={item} />
) : item.kind === "usage" ? (
<TaskChatUsageReadout item={item} />
) : item.kind === "marker" ? (
<RunnerActivityMarker item={item} />
) : item.kind === "protocol" ? (
<TaskChatProtocolActivityRow item={item} />
) : null}
</li>
))}
</ol>
</div>
);
}
function RunnerActivityMarker({ item }: { item: TaskChatMarkerItem }) {
return (
<div className="flex min-h-6 min-w-0 items-center gap-2 py-1 text-xs text-destructive">
<span className="flex w-5 shrink-0 items-center justify-center">
<OctagonX
className="h-3.5 w-3.5 shrink-0"
aria-hidden
data-testid="task-chat-marker-icon"
/>
</span>
<span className="shrink-0 font-medium">{item.label}</span>
{item.detail ? (
<span className="min-w-0 truncate text-muted-foreground">
{item.detail}
</span>
) : null}
</div>
);
}
function RunnerTurnStatus({
status,
startedAtMs,
@@ -336,90 +107,11 @@ function RunnerTurnStatus({
);
}
function RunnerCurrentActivityTail({
items,
status,
}: {
items: readonly TaskChatItem[];
status: string;
}) {
function RunnerCurrentActivityTail({ status }: { status: string }) {
if (isTerminalRunStatus(status)) return null;
const activity = lastOf<
TaskChatThinkingItem | TaskChatToolItem | TaskChatProtocolItem
>(
items,
(
item,
): item is TaskChatThinkingItem | TaskChatToolItem | TaskChatProtocolItem =>
item.kind === "thinking" ||
item.kind === "tool" ||
isHeadlineProtocolActivity(item),
);
let Icon: ComponentType<SVGProps<SVGSVGElement>> | null = null;
let label = "Thinking";
let detail: string | undefined;
let family: string | undefined;
let active = true;
if (activity?.kind === "tool") {
const taxonomy = toolTaxonomy(activity.rawName ?? activity.name);
Icon = taxonomy.icon;
label = taxonomy.verbLabel;
detail = activity.target;
active = activity.status === "pending" || activity.status === "in_progress";
} else if (activity?.kind === "protocol") {
const presentation = protocolActivityPresentation(activity);
if (presentation) {
Icon = presentation.icon;
label = protocolActivityLabel(activity, presentation);
detail = presentation.detail;
active = protocolActivityIsRunning(activity);
family =
activity.surface === "provider_activity"
? activity.family
: activity.surface;
}
}
return (
<div
className="mt-2 flex min-h-8 min-w-0 items-center gap-2 px-1 py-1 text-xs text-muted-foreground"
data-testid="task-chat-current-activity"
data-activity-family={family}
data-turn-position="tail"
>
{Icon ? (
<span className="flex w-5 shrink-0 items-center justify-center">
<Icon
className={cn(
"h-3.5 w-3.5 shrink-0",
active && "text-(--status-agent-running)",
)}
aria-hidden
data-testid="task-chat-current-activity-icon"
/>
</span>
) : null}
<span className="flex min-w-0 flex-1 items-baseline gap-2">
<span
className={cn(
"shrink-0 font-normal",
active && "shimmer-text shimmer-text-muted",
)}
aria-live="polite"
aria-atomic="true"
data-testid="task-chat-current-activity-label"
>
{label}
</span>
{detail ? (
<span className="min-w-0 truncate font-mono text-(length:--text-micro)">
{detail}
</span>
) : null}
</span>
</div>
);
return <div className="mt-2 flex min-h-8 min-w-0 items-center gap-2 px-1 py-1 text-xs text-muted-foreground" data-testid="task-chat-current-activity" data-turn-position="tail">
<span className="shimmer-text shimmer-text-muted" aria-live="polite" data-testid="task-chat-current-activity-label">Thinking</span>
</div>;
}
export function TaskChatRunnerTurn({
@@ -455,8 +147,6 @@ export function TaskChatRunnerTurn({
) => void | Promise<void>;
}) {
const terminal = isTerminalRunStatus(status);
const narration = latestFoldedNarration(items);
const currentActivityItems = currentActivityStatusItems(items);
const yielded = items.some(
(item) =>
item.kind === "protocol" &&
@@ -503,6 +193,7 @@ export function TaskChatRunnerTurn({
}
const final = finalRef.current.item;
const timelineItems = paperclipRunnerTimelineItems(items);
const currentActivityItems = currentActivityStatusItems(timelineItems);
const timelineRows = buildTurnTimelineRows(
omitProgressRepeatedByResponse(timelineItems, final?.text),
!terminal,
@@ -531,17 +222,9 @@ export function TaskChatRunnerTurn({
continuedAfterSteering={continuedAfterSteering}
/>
</div>
{!terminal && narration?.kind === "reasoning" && !final ? (
<div
className="flex min-w-0 flex-col py-1"
data-testid="task-chat-live-narration"
>
<FoldedLiveNarration narration={narration} />
</div>
) : null}
{activityUnavailable ? (
<div
className="px-1 py-1 text-xs text-destructive"
className="px-1 py-1 text-xs text-muted-foreground"
role="status"
data-testid="task-chat-activity-unavailable"
>
@@ -562,16 +245,7 @@ export function TaskChatRunnerTurn({
data-thread-anchor={row.id}
>
{row.kind === "activity_phase" ? (
<TaskChatActivityPhase
item={row}
appearance="runner"
autoOpen={false}
childrenClassName="pl-0"
renderChild={() => null}
renderChildren={(children) => (
<RunnerActivityTimeline items={children} />
)}
/>
<TaskChatRunnerActivityGroup item={row} />
) : row.kind === "plan_document" ? (
<TaskChatPlanPreviewCard
source={{ kind: "saved", document: row.document }}
@@ -604,7 +278,7 @@ export function TaskChatRunnerTurn({
/>
</div>
) : null}
{!final ? <RunnerCurrentActivityTail items={currentActivityItems} status={status} /> : null}
{!final && currentActivityItems.length === 0 ? <RunnerCurrentActivityTail status={status} /> : null}
</div>
);
}
@@ -15,6 +15,7 @@ import { TaskChatMarker } from "./TaskChatMarker";
import { TaskChatStatusPill } from "./TaskChatStatusPill";
import { TaskChatToolCard } from "./TaskChatToolCard";
import { TaskChatUsageReadout } from "./TaskChatUsageReadout";
import { TaskChatRunnerActivityGroup } from "./TaskChatRunnerActivityGroup";
import { TaskChatActivityPhase } from "./TaskChatActivityPhase";
import { TaskChatThinking } from "./TaskChatThinking";
import { TaskMessageScroller } from "./TaskMessageScroller";
@@ -192,6 +193,7 @@ function renderItem(
case "usage":
return <TaskChatUsageReadout item={item} />;
case "activity_phase":
if (activityAppearance === "runner") return <TaskChatRunnerActivityGroup item={item} />;
return (
<TaskChatActivityPhase
item={item}
@@ -1231,6 +1231,7 @@ export function paperclipRunnerTimelineItems(
return parsed.filter(
(item) =>
activityIds.has(item.id) ||
(item.kind === "thinking" && Boolean(item.streaming)) ||
item.kind === "plan_document" ||
(item.kind === "protocol" && item.surface === "runtime_request"),
);