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

+1
View File
@@ -223,6 +223,7 @@ export const agentsApi = {
type: string,
data: {
adapterConfig: Record<string, unknown>;
agentId?: string;
testCredentials?: Record<string, string>;
environmentId?: string | null;
},
+3 -1
View File
@@ -12,7 +12,7 @@ export type {
} from "@paperclipai/shared";
export type ChatProvider =
"slack" | "github" | "discord" | "microsoft-teams" | "telegram";
"slack" | "github" | "discord" | "microsoft-teams" | "telegram" | "agentmail";
export type ChatEndpointStatus =
| "draft"
| "verifying"
@@ -83,6 +83,8 @@ export interface ChatIdentityLinkPreview {
}
export interface ChatEndpoint {
publicationMode?: "automatic" | "explicit";
externalExecutionPolicy?: "restricted" | "agent";
id: string;
companyId: string;
provider: ChatProvider;
+65
View File
@@ -0,0 +1,65 @@
import { api } from "./client";
import type {
EmailConnectionInput,
ToolConnection,
EmailEndpointSummary,
EmailPublicationSummary,
EmailThreadSummary,
EmailSendInput,
EmailEndpointSetupInput,
} from "@paperclipai/shared";
export const emailApi = {
connect: (companyId: string, input: EmailConnectionInput) =>
api.post<ToolConnection>(
`/companies/${companyId}/email/connections`,
input,
),
inspectSaved: (companyId: string, connectionId: string) =>
api.post<{
scope: { scope_type: string };
inboxes: { inbox_id: string }[];
domains: { domain_id: string; domain: string; status: string }[];
}>(`/companies/${companyId}/email/connections/${connectionId}/inspect`, {}),
list: (companyId: string) =>
api.get<EmailEndpointSummary[]>(`/companies/${companyId}/email/inboxes`),
setup: (companyId: string, input: EmailEndpointSetupInput) =>
api.post<EmailEndpointSummary>(
`/companies/${companyId}/email/inboxes`,
input,
),
inspect: (companyId: string, apiKey: string) =>
api.post<{
inboxes: { inbox_id: string }[];
domains: { domain_id: string; domain: string; status: string }[];
}>(`/companies/${companyId}/email/inspect`, { apiKey }),
control: (id: string, action: "pause" | "resume" | "remove") =>
api.post<EmailEndpointSummary>(`/email/inboxes/${id}/control`, { action }),
reconnect: (
id: string,
apiKey: string,
receiveMode: "websocket" | "webhook",
) =>
api.post<EmailEndpointSummary>(`/email/inboxes/${id}/reconnect`, {
apiKey,
receiveMode,
}),
resolve: (
companyId: string,
id: string,
outcome: "sent" | "failed",
providerMessageId?: string,
) =>
api.post<EmailPublicationSummary>(
`/companies/${companyId}/email/deliveries/${id}/resolve`,
{ outcome, providerMessageId },
),
thread: (companyId: string, issueId: string) =>
api.get<EmailThreadSummary | null>(
`/companies/${companyId}/email/tasks/${issueId}`,
),
send: (companyId: string, input: EmailSendInput) =>
api.post<EmailPublicationSummary>(
`/companies/${companyId}/email/send`,
input,
),
};
+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"),
);
+16
View File
@@ -2924,3 +2924,19 @@ span.paperclip-mention-chip[data-mention-kind="external-object"] {
.task-chat-loading-shell .animate-pulse {
animation: none;
}
/* The runner keeps one fixed-height activity row between commentary updates. */
@keyframes runner-activity-roll-in {
from { transform: translateY(100%); opacity: 0; }
to { transform: translateY(0); opacity: 1; }
}
@keyframes runner-activity-roll-out {
from { transform: translateY(0); opacity: 1; }
to { transform: translateY(-100%); opacity: 0; }
}
.runner-activity-roll-in { animation: runner-activity-roll-in var(--motion-line-scroll) var(--motion-ease-standard) both; }
.runner-activity-roll-out { animation: runner-activity-roll-out var(--motion-line-scroll) var(--motion-ease-standard) both; }
@media (prefers-reduced-motion: reduce) {
.runner-activity-roll-in { animation: none; }
.runner-activity-roll-out { display: none; }
}
+3
View File
@@ -185,6 +185,9 @@ describe("attentionIsNewToday", () => {
});
describe("sourceMeta + severityStyle", () => {
it("uses a generic task label for persisted legacy productivity decisions", () => {
expect(sourceMeta("productivity_review").label).toBe("Task");
});
it("labels every catalog source kind", () => {
const kinds: AttentionSourceKind[] = [
"approval",
+2 -1
View File
@@ -53,7 +53,8 @@ const SOURCE_META: Record<AttentionSourceKind, SourceMeta> = {
issue_thread_interaction: { label: "Decision requested" },
join_request: { label: "Join request" },
recovery_action: { label: "Recovery" },
productivity_review: { label: "Productivity review" },
// Read compatibility for persisted decisions from the retired feature.
productivity_review: { label: "Task" },
blocker_attention: { label: "Blocked dependency" },
review: { label: "Review" },
failed_run: { label: "Failed run" },
+29
View File
@@ -140,6 +140,35 @@ describe("deriveMonitorState", () => {
expect(deriveMonitorState(issue("2026-07-17T19:59:00.000Z"), now).state).toBe("overdue");
});
it.each(["queued", "running", "cancelled"] as const)("ignores a %s retry's historical start time", (status) => {
const issue = {
status: "in_progress",
scheduledRetry: {
status,
scheduledRetryAt: "2026-07-17T19:58:00.000Z",
scheduledRetryAttempt: 1,
},
};
expect(deriveMonitorState(issue, now)).toMatchObject({ state: "none", nextCheckAt: null });
// A separate, explicitly scheduled monitor must still be visible.
expect(deriveMonitorState({ ...issue, monitorNextCheckAt: "2026-07-17T20:05:00.000Z" }, now))
.toMatchObject({ state: "scheduled", source: "monitor" });
});
it("keeps overdue warnings for retries that have not been promoted", () => {
expect(deriveMonitorState({
scheduledRetry: { status: "scheduled_retry", scheduledRetryAt: "2026-07-17T19:58:00.000Z" },
}, now)).toMatchObject({ state: "overdue", source: "scheduled-retry" });
});
it.each(["done", "cancelled"])("ignores stale monitor and retry schedules on %s tasks", (status) => {
const scheduledRetry = { status: "scheduled_retry" as const, scheduledRetryAt: "2026-07-17T19:58:00.000Z" };
expect(deriveMonitorState({ status, scheduledRetry }, now)).toMatchObject({ state: "none", nextCheckAt: null });
expect(deriveMonitorState({ status, monitorNextCheckAt: scheduledRetry.scheduledRetryAt }, now))
.toMatchObject({ state: "none", nextCheckAt: null });
});
it("derives cleared, none, and scheduled retry states", () => {
expect(
deriveMonitorState({ executionState: { monitor: { status: "cleared", attemptCount: 2 } } }, now),
+14 -10
View File
@@ -27,6 +27,7 @@ type ScheduledRetry = {
};
export interface MonitorIssueLike {
status?: string;
executionState?: { monitor?: MonitorDetails | null } | null;
executionPolicy?: { monitor?: MonitorPolicy | null } | null;
monitorNextCheckAt?: MonitorDate | null;
@@ -173,25 +174,28 @@ export function formatMonitorAbsoluteFull(
}
export function deriveMonitorState(issue: MonitorIssueLike, now: MonitorDate = new Date()): DerivedMonitorState {
if (issue.status === "done" || issue.status === "cancelled") {
return { state: "none", source: "none", nextCheckAt: null, attemptCount: 0, serviceName: null };
}
const runtimeMonitor = issue.executionState?.monitor ?? null;
const policyMonitor = issue.executionPolicy?.monitor ?? null;
const scheduledRetry = issue.scheduledRetry ?? null;
const retryIsActive =
scheduledRetry?.status === "scheduled_retry" ||
scheduledRetry?.status === "queued" ||
scheduledRetry?.status === "running";
// Promotion preserves scheduledRetryAt as history. Once queued or running,
// the retry is no longer waiting for that timestamp and cannot be overdue.
const retryIsScheduled = scheduledRetry?.status === "scheduled_retry";
const nextCheckAt =
runtimeMonitor?.nextCheckAt ??
issue.monitorNextCheckAt ??
policyMonitor?.nextCheckAt ??
(retryIsActive ? scheduledRetry?.scheduledRetryAt : null) ??
(retryIsScheduled ? scheduledRetry?.scheduledRetryAt : null) ??
null;
const hasMonitor = runtimeMonitor !== null || policyMonitor !== null || issue.monitorNextCheckAt != null;
const source = hasMonitor ? "monitor" : retryIsActive ? "scheduled-retry" : "none";
const source = hasMonitor ? "monitor" : retryIsScheduled ? "scheduled-retry" : "none";
const attemptCount =
runtimeMonitor?.attemptCount ??
(hasMonitor ? issue.monitorAttemptCount : null) ??
(retryIsActive ? scheduledRetry?.scheduledRetryAttempt : null) ??
(retryIsScheduled ? scheduledRetry?.scheduledRetryAttempt : null) ??
0;
const serviceName = runtimeMonitor?.serviceName ?? policyMonitor?.serviceName ?? null;
@@ -199,11 +203,11 @@ export function deriveMonitorState(issue: MonitorIssueLike, now: MonitorDate = n
return { state: "cleared", source, nextCheckAt, attemptCount, serviceName };
}
if (!hasMonitor && !retryIsActive) {
if (!hasMonitor && !retryIsScheduled) {
return { state: "none", source, nextCheckAt: null, attemptCount: 0, serviceName: null };
}
if (!nextCheckAt) {
return { state: retryIsActive || attemptCount > 1 ? "retrying" : "scheduled", source, nextCheckAt, attemptCount, serviceName };
return { state: retryIsScheduled || attemptCount > 1 ? "retrying" : "scheduled", source, nextCheckAt, attemptCount, serviceName };
}
const deltaMs = toTimestamp(nextCheckAt) - toTimestamp(now);
@@ -214,7 +218,7 @@ export function deriveMonitorState(issue: MonitorIssueLike, now: MonitorDate = n
return { state: "due-now", source, nextCheckAt, attemptCount, serviceName };
}
return {
state: retryIsActive || attemptCount > 1 ? "retrying" : "scheduled",
state: retryIsScheduled || attemptCount > 1 ? "retrying" : "scheduled",
source,
nextCheckAt,
attemptCount,
+2
View File
@@ -4,6 +4,7 @@ const testEnvironment = vi.hoisted(() => vi.fn());
vi.mock("../api/agents", () => ({ agentsApi: { testEnvironment } }));
const input = {
companyId: "company-1",
agentId: "agent-1",
adapterType: "paperclip_runner",
providerAdapter: "claude_local",
environmentId: "sandbox-1",
@@ -48,6 +49,7 @@ it("does not report a connection when runtime readiness passes but provider auth
"company-1",
"claude_local",
{
agentId: "agent-1",
environmentId: "sandbox-1",
adapterConfig: { ...input.adapterConfig, engine: "cli" },
},
+2
View File
@@ -9,11 +9,13 @@ export async function testAgentSetup(input: {
adapterType: string;
providerAdapter: string;
adapterConfig: Record<string, unknown>;
agentId?: string;
testCredentials?: Record<string, string>;
environmentId: string | null;
}): Promise<AdapterEnvironmentTestResult> {
const payload = {
adapterConfig: input.adapterConfig,
...(input.agentId ? { agentId: input.agentId } : {}),
...(input.testCredentials ? { testCredentials: input.testCredentials } : {}),
environmentId: input.environmentId,
};
+9
View File
@@ -24,6 +24,15 @@ describe("trust-policy-ui low-trust boundary helpers", () => {
expect(restored.trustPreset).toBe("standard");
});
it("clears containment across a JSON permissions patch when restoring standard trust", () => {
const lowTrust = setSingleLowTrustBoundaryTarget(null, "company-1", { type: "root_issue", id: "issue-1" });
const patch = JSON.parse(JSON.stringify(buildPermissionsForTrustPreset(lowTrust, "standard")));
const persisted = { ...lowTrust, ...patch };
expect(persisted.trustPreset).toBe("standard");
expect(persisted.authorizationPolicy).toEqual({});
expect(getLowTrustBoundary(persisted)).toBeNull();
});
it("writes one project boundary with mode and company id", () => {
const permissions = setSingleLowTrustBoundaryTarget(null, "company-1", {
type: "project",
+3 -3
View File
@@ -69,9 +69,9 @@ export function buildPermissionsForTrustPreset(
return {
...current,
trustPreset: DEFAULT_TRUST_PRESET,
...(Object.keys(nextPolicy).length > 0
? { authorizationPolicy: nextPolicy }
: { authorizationPolicy: undefined }),
// Send an explicit empty policy: undefined disappears in JSON, leaving the
// prior low-trust boundary intact when the permissions endpoint merges.
authorizationPolicy: nextPolicy,
};
}
+8
View File
@@ -2,6 +2,7 @@ import { TaskDetailTasksPanel } from "@/components/task-detail/TaskDetailTasksPa
import { FileTree } from "@/components/FileTree";
import { SavedProviderKeySelect } from "../components/onboarding/SavedProviderKeySelect";
import { RepositoryEditor } from "@/components/RepositoryEditor";
import { TaskChatRunnerActivityGroup } from "@/components/task-chat/TaskChatRunnerActivityGroup";
import { TaskChatMarker } from "@/components/task-chat/TaskChatMarker";
import { TaskChatComposer } from "@/components/task-chat/TaskChatComposer";
import { TaskTreeControlDialog, TaskTreeControlMenuItems } from "@/components/TaskTreeControls";
@@ -626,6 +627,13 @@ export function DesignGuide() {
{/* ============================================================ */}
{/* TYPOGRAPHY */}
{/* ============================================================ */}
<Section title="Runner activity">
<TaskChatRunnerActivityGroup item={{ id: "design-runner-activity", kind: "activity_phase", active: true, summary: "", interstitial: { id: "design-runner-commentary", kind: "message", author: "agent", text: "I’ll inspect the activity feed and check the layout.", interstitial: true }, items: [
{ id: "design-runner-read", kind: "tool", name: "read", target: "TaskChatRunnerTurn.tsx", status: "completed", detail: "Found the activity groups." },
{ id: "design-runner-check", kind: "tool", name: "exec_command", target: "pnpm check:token-gates", status: "in_progress" },
] }} />
</Section>
<Section title="Typography">
<div className="space-y-3">
<h2 className="text-xl font-bold">Page Title — text-xl font-bold</h2>
+5 -2
View File
@@ -2644,7 +2644,7 @@ describe("IssueDetail", () => {
});
});
it("keeps inbox archive actions scoped to an inbox-origin task", async () => {
it("archives a task-page issue with y and returns to the inbox", async () => {
mockLocation.state = createIssueDetailLocationState(
"Tasks",
"/issues/all",
@@ -2680,7 +2680,10 @@ describe("IssueDetail", () => {
document.dispatchEvent(
new KeyboardEvent("keydown", { key: "y", bubbles: true }),
);
expect(mockIssuesApi.archiveFromInbox).not.toHaveBeenCalled();
await waitForAssertion(() => {
expect(mockIssuesApi.archiveFromInbox).toHaveBeenCalledWith("issue-1");
expect(mockNavigate).toHaveBeenCalledWith("/inbox", { replace: true });
});
});
it("arms the inbox archive shortcut only for the selected inbox row", async () => {
+6 -23
View File
@@ -1,5 +1,7 @@
import type { TaskComposerPause } from "../components/task-chat/TaskChatPausedTakeover";
import { TaskDetailTasksPanel } from "@/components/task-detail/TaskDetailTasksPanel";
import { EmailThreadProvider } from "../components/EmailMessageCard";
import { EmailTaskActivity } from "../components/EmailTaskActivity";
import { TaskChatScrollNavigation } from "@/components/task-chat/scroll-navigation";
import {
memo,
@@ -92,7 +94,6 @@ import {
readIssueDetailHeaderSeed,
withIssueDetailHeaderSeed,
rememberIssueDetailLocationState,
shouldArmIssueDetailInboxQuickArchive,
} from "../lib/issueDetailBreadcrumb";
import {
resolveIssueActiveRun,
@@ -233,7 +234,6 @@ import { ScrollToBottom } from "../components/ScrollToBottom";
import { StatusIcon } from "../components/StatusIcon";
import { PriorityIcon } from "../components/PriorityIcon";
import { SHOW_TASK_PRIORITY_UI } from "../lib/ui-flags";
import { ProductivityReviewBadge } from "../components/ProductivityReviewBadge";
import { Identity } from "../components/Identity";
import {
PluginSlotMount,
@@ -306,7 +306,6 @@ import {
Check,
ChevronRight,
Copy,
Eye,
EyeOff,
ScanEye,
Flag,
@@ -2292,6 +2291,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
hash: scrollLocation.hash,
}}
>
<EmailThreadProvider companyId={companyId} issueId={issueId}>
<ThreadComponent
key={issueId}
initialHistoryPending={
@@ -2451,6 +2451,7 @@ const IssueDetailChatTab = memo(function IssueDetailChatTab({
externalReferences={externalReferences}
linkCaseReferences={linkCaseReferences}
/>
</EmailThreadProvider>
</TaskChatScrollNavigation.Provider>
)}
</div>
@@ -5667,10 +5668,7 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
const goToInboxShortcutArmedRef = useRef(false);
const goToInboxShortcutTimeoutRef = useRef<number | null>(null);
const canQuickArchiveFromInbox =
keyboardShortcutsEnabled &&
(!streamlinedUiEnabled ||
(isFromInbox && shouldArmIssueDetailInboxQuickArchive(location.state))) &&
!issue?.hiddenAt;
keyboardShortcutsEnabled && !issue?.hiddenAt;
useEffect(() => {
if (!issue?.id || !canQuickArchiveFromInbox) return;
@@ -6912,21 +6910,6 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
</Link>
)}
{issue.productivityReview ? (
<ProductivityReviewBadge review={issue.productivityReview} />
) : null}
{issue.originKind === "issue_productivity_review" ? (
<Badge
variant="outline"
className="border-amber-500/40 bg-amber-500/10 text-(length:--text-nano) text-amber-700 dark:text-amber-300"
title="This task is a productivity review."
>
<Eye className="h-3 w-3" />
Productivity review
</Badge>
) : null}
{issue.originKind === "task_watchdog" ? (
<Badge
variant="outline"
@@ -7697,7 +7680,7 @@ export function IssueDetail({ tasksTab }: { tasksTab?: TaskSidePanelProps["tasks
)}
{resolvedDetailTab === "chat" ? (
<IssueDetailChatTab
threadHeader={taskChatThreadHeader}
threadHeader={<>{taskChatThreadHeader}{instanceExperimentalSettings?.enableChatConnectors && <EmailTaskActivity key={issue.id} companyId={issue.companyId} issueId={issue.id} />}</>}
issueBrief={
// Suppress the seeded-description bubble for the onboarding first
// task: its description is agent instructions, not something the
@@ -1,4 +1,17 @@
import { describe, expect, it } from "vitest";
// @vitest-environment jsdom
import { describe, expect, it, vi } from "vitest";
import { createElement } from "react";
import { createRoot } from "react-dom/client";
import { flushSync } from "react-dom";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import type { Agent } from "@paperclipai/shared";
import { queryKeys } from "../../lib/queryKeys";
import { AgentSkillsTab } from "./AgentSkillsTab";
import { TooltipProvider } from "../../components/ui/tooltip";
vi.mock("@/lib/router", () => ({ Link: ({ children }: { children: unknown }) => children }));
vi.mock("./AgentSkillRow", () => ({ AgentSkillRow: ({ variant, data }: { variant: string; data: { key: string } }) =>
createElement("div", { "data-skill": data.key, "data-variant": variant }) }));
import { toDesiredSkillPayload } from "./AgentSkillsTab";
describe("toDesiredSkillPayload", () => {
@@ -17,3 +30,31 @@ describe("toDesiredSkillPayload", () => {
]);
});
});
it("removes a connector from editable library rows when its automatic assignment arrives", async () => {
const client = new QueryClient({ defaultOptions: { queries: { staleTime: Infinity, retry: false } } });
const agent = { id: "agent-1", companyId: "company-1", adapterType: "codex_local", adapterConfig: {} } as Agent;
const key = "paperclipai/paperclip/agentmail";
const snapshot = { adapterType: "codex_local", supported: true, mode: "ephemeral", desiredSkills: [], entries: [], warnings: [] };
client.setQueryData(queryKeys.agents.skills(agent.id), snapshot);
client.setQueryData(queryKeys.companySkills.list(agent.companyId), [{ id: "skill-1", key, name: "agentmail", categories: [], sourceKind: "bundled", sourceType: "bundled" }]);
client.setQueryData(queryKeys.instance.experimentalSettings, { enableBetaSkills: false });
const container = document.createElement("div");
document.body.append(container);
const root = createRoot(container);
try {
flushSync(() => root.render(createElement(QueryClientProvider, { client }, createElement(TooltipProvider, { children: createElement(AgentSkillsTab, { agent, companyId: agent.companyId }) }))));
expect(container.querySelector(`[data-skill="${key}"][data-variant="available"]`)).not.toBeNull();
client.setQueryData(queryKeys.agents.skills(agent.id), { ...snapshot, desiredSkills: [key], entries: [{ key, runtimeName: "agentmail", desired: true, managed: true, readOnly: true, state: "configured" }] });
await vi.waitFor(() => {
expect(container.querySelector(`[data-skill="${key}"][data-variant="available"]`)).toBeNull();
expect(container.querySelector(`[data-skill="${key}"][data-variant="enabled"]`)).toBeNull();
expect(container.textContent).toContain("Automatic and detected skills");
});
} finally {
flushSync(() => root.unmount());
client.clear();
container.remove();
}
});
+5 -5
View File
@@ -95,7 +95,7 @@ export function AgentSkillsTab({ agent, companyId }: { agent: Agent; companyId?:
const paperclipCoreSkill = useMemo(
() => (companySkills ?? []).find((skill) => skill.key === PAPERCLIP_CORE_SKILL_KEY) ?? null,
[companySkills],
[companySkills, skillSnapshot],
);
// Seeded releases (release_id IS NOT NULL) for the paperclip core skill. Only
@@ -230,7 +230,7 @@ export function AgentSkillsTab({ agent, companyId }: { agent: Agent; companyId?:
// Library skills → row models (the store's visual language, tuned for rows).
const libraryRows = useMemo<AgentSkillRowData[]>(
() =>
(companySkills ?? []).map((skill) => ({
(companySkills ?? []).filter((skill) => !(skillSnapshot?.entries ?? []).some((entry) => entry.key === skill.key && entry.readOnly)).map((skill) => ({
key: skill.key,
name: skill.name,
icon: {
@@ -251,14 +251,14 @@ export function AgentSkillsTab({ agent, companyId }: { agent: Agent; companyId?:
description: skill.description,
categories: skill.categories,
})),
[companySkills],
[companySkills, skillSnapshot],
);
// Adapter-detected, user-installed / unmanaged skills → read-only rows.
const detectedRows = useMemo<AgentSkillRowData[]>(
() =>
(skillSnapshot?.entries ?? [])
.filter((entry) => isReadOnlyUnmanagedSkillEntry(entry, companySkillKeys))
.filter((entry) => (entry.readOnly && entry.desired) || isReadOnlyUnmanagedSkillEntry(entry, companySkillKeys))
.map((entry) => ({
key: entry.key,
name: entry.runtimeName ?? entry.key,
@@ -518,7 +518,7 @@ export function AgentSkillsTab({ agent, companyId }: { agent: Agent; companyId?:
)}
/>
<span className="text-xs font-medium text-muted-foreground">
Detected on adapter (read-only)
Automatic and detected skills (read-only)
</span>
<span className="text-xs text-muted-foreground/70">{filteredDetected.length}</span>
</CollapsibleTrigger>
+6 -1
View File
@@ -1,3 +1,5 @@
import { EmailConnectionAccess } from "@/components/EmailConnectionAccess";
import { EmailConnectionInboxes } from "./chat/EmailEndpointSetup";
import { useEffect, useMemo, useRef, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { Check, Loader2, Pencil } from "lucide-react";
@@ -566,6 +568,8 @@ export function AppDetail() {
: permissionsLoading
? <ToolsLoading />
: <div className="space-y-10">
{connection.config?.provider === "agentmail" && <EmailConnectionInboxes companyId={connection.companyId} connectionId={connection.id} canConfigure={grantsQuery.data?.capabilities?.canConfigure ?? false} />}
{connection.config?.provider === "agentmail" ? <EmailConnectionAccess companyId={connection.companyId} connectionId={connection.id} agents={agents} /> : <>
<IdentitiesSection
appName={appName}
credentialPolicy={connection.credentialPolicy}
@@ -621,6 +625,7 @@ export function AppDetail() {
onSetActionPermission={(id, next) => apply(actionPermissionMutation(id, next, enabledIds, askFirstIds))}
onReviewQuarantined={reviewQuarantined}
/>
</>}
</div>
)}
</div>
@@ -709,7 +714,7 @@ function AppDetailHeader({
)}
<div className="mt-2 flex flex-wrap items-center gap-2">
<StatusBadge status={status} />
{actionCount !== null && (
{connection.config?.provider !== "agentmail" && actionCount !== null && (
<span className="text-xs text-muted-foreground">
{actionCount} {actionCount === 1 ? "action" : "actions"} available
</span>
+4 -4
View File
@@ -109,7 +109,6 @@ type ConnectionRemovalTarget = {
function chatProviderForSlug(slug: string): ChatProvider | null {
const method = getAppStoreDefinition(slug)?.methods.find(
(candidate) =>
candidate.transport === "chat_sdk" &&
candidate.purpose === "channel" &&
candidate.provider,
);
@@ -351,7 +350,7 @@ export function Browse() {
const definition = getAppStoreDefinition(appDefinitionSlug(entry));
return (
chatConnectorsEnabled ||
!definition?.methods.some((method) => method.transport === "chat_sdk") ||
!definition?.methods.some((method) => method.purpose === "channel") ||
appSupportsToolCatalogSetup(definition)
);
});
@@ -535,6 +534,7 @@ export function Browse() {
discord: "Discord",
"microsoft-teams": "Microsoft Teams",
telegram: "Telegram",
agentmail: "AgentMail",
} as const;
target = {
key: `chat:${endpoint.provider}`,
@@ -737,7 +737,7 @@ export function Browse() {
);
}
function ConnectorCard({
export function ConnectorCard({
row,
allConnections,
userProfileById,
@@ -850,7 +850,7 @@ function ConnectorCard({
onNavigate(`/apps/chat/${endpoint.id}/settings`)
}
>
{endpoint.assignedAgentName} · Chat
{endpoint.assignedAgentName} · {endpoint.provider === "agentmail" ? "Email" : "Chat"}
</button>
<p className="truncate text-xs text-muted-foreground">
{endpoint.providerAccountLabel ??
@@ -1,3 +1,4 @@
import { EmailEndpointSettings } from "./EmailEndpointSetup";
import { useEffect, useMemo, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
@@ -47,6 +48,7 @@ const tabItems = tabs.map((value) => ({
label: value[0].toUpperCase() + value.slice(1),
}));
const providerNames: Record<ChatProvider, string> = {
agentmail: "AgentMail",
slack: "Slack",
github: "GitHub",
discord: "Discord",
@@ -58,6 +60,7 @@ const providerLifecycleGuidance: Record<
ChatProvider,
{ reconnect: string; remove: string }
> = {
agentmail: { reconnect: "Reconnect the same email inbox.", remove: "Disconnect email and retain task history." },
slack: {
reconnect:
"Reconnect verifies or replaces credentials for this same Slack app. It does not reinstall the app or change its workspace or channel membership.",
@@ -258,6 +261,7 @@ export function ChatEndpointDetail() {
</Button>
</div>
);
if (endpoint.provider === "agentmail") return <EmailEndpointSettings endpointId={endpoint.id} companyId={endpoint.companyId} />;
const setupIncomplete =
endpoint.setup?.step !== "complete" &&
["draft", "verifying", "attention", "revoked"].includes(endpoint.status);
@@ -1,3 +1,4 @@
import { EmailEndpointSetup } from "./EmailEndpointSetup";
import {
useEffect,
useMemo,
@@ -35,6 +36,7 @@ import {
} from "./github-private-key-file";
const providerNames: Record<ChatProvider, string> = {
agentmail: "AgentMail",
slack: "Slack",
github: "GitHub",
discord: "Discord",
@@ -122,6 +124,10 @@ function SetupRail({ step }: { step: number }) {
}
export function ChatEndpointSetup() {
const [params] = useSearchParams();
return params.get("provider") === "agentmail" ? <EmailEndpointSetup /> : <ChatSdkEndpointSetup />;
}
function ChatSdkEndpointSetup() {
const [params] = useSearchParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
@@ -13,6 +13,7 @@ const providerNames: Record<ChatProvider, string> = {
discord: "Discord",
"microsoft-teams": "Microsoft Teams",
telegram: "Telegram",
agentmail: "AgentMail",
};
export function ChatIdentityConfirm() {
@@ -0,0 +1,845 @@
import { useEffect, useState } from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
ArrowLeft,
ArrowRight,
Check,
AlertTriangle,
Mail,
} from "lucide-react";
import { useCompany } from "@/context/CompanyContext";
import { useNavigate, useSearchParams, Link } from "@/lib/router";
import { agentsApi } from "@/api/agents";
import { issuesApi } from "@/api/issues";
import { projectsApi } from "@/api/projects";
import { toolsApi } from "@/api/tools";
import { emailApi } from "@/api/email";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import { RadioCardGroup } from "@/components/ui/radio-card";
import { Avatar, AvatarFallback } from "@/components/ui/avatar";
import { AgentIcon } from "@/components/AgentIconPicker";
import { SearchableSelect } from "@/components/SearchableSelect";
import { AccessStep } from "@/features/connections/ConnectionSetupFlow";
import { TrustPresetSection } from "@/components/TrustPresetSection";
import { EmailSafetyNotice } from "@/components/EmailSafetyNotice";
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import {
getTrustPreset,
getLowTrustBoundary,
lowTrustBoundaryHasScope,
} from "@/lib/trust-policy-ui";
import { queryKeys } from "@/lib/queryKeys";
import type {
AgentPermissions,
EmailEndpointSummary,
} from "@paperclipai/shared";
const selectClass =
"w-full rounded-md border border-input bg-background px-3 py-2 text-sm";
export function EmailEndpointSetup() {
const { selectedCompanyId } = useCompany();
const [params] = useSearchParams();
const navigate = useNavigate();
const cache = useQueryClient();
const companyId = selectedCompanyId ?? "";
const [connectionId, setConnectionId] = useState(
params.get("connectionId") ?? "",
);
const [step, setStep] = useState(params.get("connectionId") ? 3 : 0);
const [agentId, setAgentId] = useState(params.get("agentId") ?? "");
const [grantKind, setGrantKind] = useState<"user" | "organization" | "agent">(
"user",
);
const [agentAccess, setAgentAccess] = useState<"specific" | "all">(
"specific",
);
const [agentIds, setAgentIds] = useState<Set<string>>(
new Set(params.get("agentId") ? [params.get("agentId")!] : []),
);
const [apiKey, setApiKey] = useState("");
const [requestId] = useState(() => crypto.randomUUID());
const [addressMode, setAddressMode] = useState("new");
const [inboxId, setInboxId] = useState("");
const [username, setUsername] = useState("");
const [domain, setDomain] = useState("agentmail.to");
const [mode, setMode] = useState<"websocket" | "webhook">("websocket");
const [trustOpen, setTrustOpen] = useState(false);
const [permissions, setPermissions] = useState<Partial<AgentPermissions>>({});
const agents = useQuery({
queryKey: queryKeys.agents.list(companyId),
queryFn: () => agentsApi.list(companyId),
enabled: !!companyId,
});
const projects = useQuery({
queryKey: queryKeys.projects.list(companyId),
queryFn: () => projectsApi.list(companyId),
enabled: !!companyId && trustOpen,
});
const boundaryIssues = useQuery({
queryKey: ["email-boundary-issues", companyId],
queryFn: () => issuesApi.list(companyId),
enabled: !!companyId && trustOpen,
});
const chosen = agents.data?.find((a) => a.id === agentId);
const lowTrust = getTrustPreset(chosen?.permissions) === "low_trust_review";
const scoped = lowTrustBoundaryHasScope(
getLowTrustBoundary(chosen?.permissions),
);
const inspected = useQuery({
queryKey: ["email-credential-inspect", companyId, connectionId],
queryFn: () => emailApi.inspectSaved(companyId, connectionId),
enabled: !!companyId && !!connectionId && step >= 3,
retry: false,
});
const inboxes = useQuery({
queryKey: ["email-inboxes", companyId],
queryFn: () => emailApi.list(companyId),
enabled: !!companyId,
});
const scopedKey = inspected.data?.scope.scope_type === "inbox";
useEffect(() => {
if (scopedKey) {
setAddressMode("existing");
setInboxId(inspected.data?.inboxes[0]?.inbox_id ?? "");
}
}, [scopedKey, inspected.data]);
const connect = useMutation({
mutationFn: () =>
emailApi.connect(companyId, {
apiKey,
grantKind: grantKind === "organization" ? "organization" : "user",
allAgents: agentAccess === "all",
agentIds: [...agentIds],
idempotencyKey: requestId,
}),
onSuccess: (result) => {
setApiKey("");
setConnectionId(result.id);
setStep(2);
void cache.invalidateQueries({
queryKey: queryKeys.tools.connections(companyId),
});
},
});
const agentDetail = useQuery({
queryKey: queryKeys.agents.detail(agentId),
queryFn: () => agentsApi.get(agentId),
enabled: !!agentId && trustOpen,
});
const trust = useMutation({
mutationFn: () =>
agentsApi.updatePermissions(
agentId,
{
...permissions,
canCreateAgents: permissions.canCreateAgents ?? false,
canCreateSkills: permissions.canCreateSkills ?? true,
canAssignTasks: agentDetail.data?.access?.canAssignTasks ?? false,
},
companyId,
),
onSuccess: () => {
setTrustOpen(false);
void cache.invalidateQueries({
queryKey: queryKeys.agents.list(companyId),
});
},
});
const setup = useMutation({
mutationFn: () =>
emailApi.setup(companyId, {
assignedAgentId: agentId,
credentialConnectionId: connectionId,
...(addressMode === "existing" ? { inboxId } : { username, domain }),
receiveMode: mode,
idempotencyKey: requestId,
}),
onSuccess: () => {
void cache.invalidateQueries({ queryKey: ["email-inboxes", companyId] });
void cache.invalidateQueries({
queryKey: queryKeys.tools.connectionInstalls(connectionId),
});
setStep(6);
},
});
const address =
addressMode === "existing" ? inboxId : `${username}@${domain}`;
const labels =
step < 3
? ["Access", "API key", "Connected"]
: ["Agent", "Email address", "Review"];
const current = step < 3 ? step : Math.min(step - 3, 2);
const error = connect.error ?? setup.error ?? inspected.error ?? agents.error;
const trustNotice = chosen && (
<div
className="space-y-3 rounded-lg border border-border bg-muted/30 p-4"
role={lowTrust && scoped ? "note" : "alert"}
>
<p className="flex items-center gap-2 text-sm font-medium">
{lowTrust && scoped ? (
<Check className="size-4" />
) : (
<AlertTriangle className="size-4 text-(--status-agent-paused)" />
)}
{lowTrust
? scoped
? "Low-trust review configured"
: "Low trust needs a work boundary"
: `${chosen.name} is not a low-trust agent`}
</p>
<p className="text-sm text-muted-foreground">
{lowTrust
? "Email tasks stay inside the configured project or root task boundary. Output is quarantined for trusted review."
: "Email can contain malicious instructions. We recommend Low-trust review to limit the agent’s access to Paperclip work."}
</p>
<p className="text-xs text-muted-foreground">Low-trust execution also requires isolated workspaces and an active sandbox environment in the agent’s runtime settings.</p>
<Button
size="sm"
variant="outline"
onClick={() => {
setPermissions(chosen.permissions);
setTrustOpen(true);
}}
>
{lowTrust ? "Review trust settings" : "Configure low trust"}
</Button>
</div>
);
return (
<div className="mx-auto max-w-3xl space-y-6 p-6">
<header className="flex items-center justify-between gap-3">
<h1 className="text-xl font-bold">
{step < 3
? "Connect AgentMail"
: step === 6
? "Your agent’s email is ready"
: "Give an agent an email address"}
</h1>
<Button
variant="ghost"
onClick={() =>
navigate(
connectionId ? `/apps/${connectionId}/permissions` : "/apps",
)
}
>
{step === 6 ? "Close" : "Cancel"}
</Button>
</header>
{step !== 6 && (
<nav aria-label="Setup progress">
<ol className="flex gap-4">
{labels.map((label, i) => (
<li
className="flex flex-1 items-center gap-2 text-sm"
key={label}
>
<span
className={
i <= current
? "flex size-7 shrink-0 items-center justify-center rounded-full bg-foreground text-background"
: "flex size-7 shrink-0 items-center justify-center rounded-full border border-border text-muted-foreground"
}
>
{i < current ? <Check className="size-4" /> : i + 1}
</span>
<span aria-current={i === current ? "step" : undefined}>
{label}
</span>
</li>
))}
</ol>
</nav>
)}
{step === 0 && (
<AccessStep
companyId={companyId}
authKind="api_key"
grantKinds={["user", "organization"]}
grantKind={grantKind}
setGrantKind={setGrantKind}
installChoice={agentAccess}
setInstallChoice={setAgentAccess}
installAgentIds={agentIds}
setInstallAgentIds={setAgentIds}
onBack={() => navigate("/apps")}
onContinue={() => setStep(1)}
submitLabel="Continue"
/>
)}
{step === 1 && (
<form
className="space-y-5"
onSubmit={(e) => {
e.preventDefault();
connect.mutate();
}}
>
<section className="space-y-4 rounded-xl border border-border p-6">
<h2 className="text-lg font-semibold">
Add your AgentMail API key
</h2>
<Label htmlFor="email-api-key">API key</Label>
<Input
id="email-api-key"
type="password"
autoComplete="off"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder="Paste your AgentMail API key"
/>
<a
href="https://console.agentmail.to"
target="_blank"
rel="noreferrer"
className="text-sm underline"
>
Get a key in AgentMail ↗
</a>
</section>
<div className="flex justify-between">
<Button type="button" variant="ghost" onClick={() => setStep(0)}>
Back
</Button>
<Button disabled={!apiKey.trim() || connect.isPending}>
{connect.isPending ? "Connecting…" : "Connect AgentMail"}
</Button>
</div>
</form>
)}
{step === 2 && (
<section className="space-y-5 rounded-xl border border-border p-6">
<h2 className="text-lg font-semibold">AgentMail is connected</h2>
<p className="text-sm text-muted-foreground">
Next, give an agent an email address from Permissions.
</p>
<div className="flex justify-end">
<Button
onClick={() => navigate(`/apps/${connectionId}/permissions`)}
>
Open permissions <ArrowRight className="size-4" />
</Button>
</div>
</section>
)}
{step === 3 && (
<>
<section className="space-y-4 rounded-xl border border-border p-6">
<h2 className="text-lg font-semibold">
Who should handle this inbox?
</h2>
<p className="text-sm text-muted-foreground">
Incoming email will create tasks assigned to this agent.
</p>
<Label>Agent</Label>
<SearchableSelect
value={agentId}
placeholder="Choose an agent"
searchPlaceholder="Search all agents…"
emptyMessage="No agents found."
groups={[
{
id: "agents",
options: (agents.data ?? [])
.filter(
(a) =>
!["terminated", "pending_approval"].includes(a.status),
)
.map((a) => ({
key: a.id,
value: a.id,
label: a.name,
icon: a.icon,
})),
},
]}
onValueChange={(id, option) => {
setAgentId(id);
setUsername(
option.label
.toLowerCase()
.replace(/[^a-z0-9._-]+/g, "-")
.slice(0, 64),
);
}}
renderValue={(option) =>
option && (
<span className="flex items-center gap-2">
<Avatar size="sm">
<AvatarFallback>
<AgentIcon icon={String(option.icon ?? "bot")} />
</AvatarFallback>
</Avatar>
{option.label}
</span>
)
}
/>
<p className="text-xs text-muted-foreground">
Activating this inbox also adds the agent to this connection’s
allowed agents.
</p>
</section>
{trustNotice}
</>
)}
{step === 4 && (
<>
<section className="space-y-5 rounded-xl border border-border p-6">
<h2 className="text-lg font-semibold">
Choose {chosen?.name}’s email address
</h2>
<RadioCardGroup
ariaLabel="Email address source"
value={addressMode}
onValueChange={setAddressMode}
options={[
{
value: "new",
title: "Create a new address",
disabled: scopedKey,
},
{ value: "existing", title: "Use an existing inbox" },
]}
/>
{addressMode === "new" ? (
<div className="space-y-2">
<Label htmlFor="email-name">Email address</Label>
<div className="flex items-center gap-2">
<Input
id="email-name"
value={username}
onChange={(e) => setUsername(e.target.value.toLowerCase())}
/>
<span className="text-sm text-muted-foreground">
@{domain}
</span>
</div>
</div>
) : (
<div className="space-y-2">
<Label htmlFor="email-existing">Available inbox</Label>
<select
id="email-existing"
className={selectClass}
value={inboxId}
onChange={(e) => setInboxId(e.target.value)}
>
<option value="">Choose an inbox</option>
{inspected.data?.inboxes.map((i) => (
<option
key={i.inbox_id}
disabled={inboxes.data?.some(
(e) =>
e.address === i.inbox_id && e.status !== "archived",
)}
value={i.inbox_id}
>
{i.inbox_id}
{inboxes.data?.some((e) => e.address === i.inbox_id)
? " — already assigned"
: ""}
</option>
))}
</select>
</div>
)}
<details className="border-t border-border pt-4">
<summary className="cursor-pointer text-sm text-muted-foreground">
Advanced options
</summary>
<div className="space-y-4 pt-4">
{addressMode === "new" && (
<>
<Label htmlFor="email-domain">Domain</Label>
<select
id="email-domain"
value={domain}
onChange={(e) => setDomain(e.target.value)}
className={selectClass}
>
<option>agentmail.to</option>
{inspected.data?.domains
.filter((d) => d.status === "VERIFIED")
.map((d) => (
<option key={d.domain_id}>{d.domain}</option>
))}
</select>
<a
className="text-sm underline"
href="https://docs.agentmail.to/custom-domains"
target="_blank"
rel="noreferrer"
>
Set up a custom domain in AgentMail ↗
</a>
</>
)}
<Label htmlFor="email-mode">Receiving</Label>
<select
id="email-mode"
value={mode}
onChange={(e) => setMode(e.target.value as typeof mode)}
className={selectClass}
>
<option value="websocket">
Live connection — works locally
</option>
<option value="webhook">
Webhook — requires public HTTPS
</option>
</select>
</div>
</details>
</section>
<EmailSafetyNotice />
</>
)}
{step === 5 && (
<>
<EmailSafetyNotice />
{trustNotice}
<section className="space-y-4 rounded-xl border border-border p-6">
<h2 className="text-lg font-semibold">
Ready to start receiving email?
</h2>
<p className="text-lg font-semibold">{address}</p>
<p className="text-sm">
Assigned to {chosen?.name} ·{" "}
{mode === "websocket" ? "Live connection" : "Signed webhook"}
</p>
<p className="text-sm text-muted-foreground">
New conversations create tasks. Replies stay in the same task.
Task comments stay internal.
</p>
</section>
</>
)}
{step === 6 && (
<section className="space-y-5 rounded-xl border border-border p-6">
<p className="flex items-center gap-2 text-sm">
<Check className="size-4" />
Receiving email for {chosen?.name}
</p>
<p className="text-lg font-semibold">{setup.data?.address}</p>
<EmailSafetyNotice />
<Button onClick={() => navigate(`/apps/${connectionId}/permissions`)}>
Back to permissions
</Button>
</section>
)}
{step >= 3 && step <= 5 && (
<div className="flex justify-between border-t border-border pt-5">
<Button
variant="ghost"
onClick={() =>
step === 3
? navigate(`/apps/${connectionId}/permissions`)
: setStep(step - 1)
}
>
<ArrowLeft className="size-4" />
Back
</Button>
<Button
disabled={
!chosen ||
(lowTrust && !scoped) ||
(step >= 4 &&
(!inspected.data ||
(addressMode === "existing"
? !inboxId
: !/^[a-z0-9][a-z0-9._-]*$/.test(username)))) ||
setup.isPending
}
onClick={() => (step === 5 ? setup.mutate() : setStep(step + 1))}
>
{setup.isPending
? "Activating…"
: step === 5
? addressMode === "new"
? "Create email address"
: "Connect email address"
: step === 4
? "Review email address"
: "Continue"}
<ArrowRight className="size-4" />
</Button>
</div>
)}
{error && (
<p role="alert" className="text-sm text-destructive">
{error.message}
</p>
)}
<Dialog open={trustOpen} onOpenChange={setTrustOpen}>
<DialogContent className="max-h-screen overflow-y-auto sm:max-w-2xl">
<DialogHeader>
<DialogTitle>Trust settings · {chosen?.name}</DialogTitle>
<DialogDescription>
Changes apply to all of this agent’s work. Use a dedicated email
agent if its other tasks need broader access.
</DialogDescription>
</DialogHeader>
<TrustPresetSection
permissions={permissions}
onChange={setPermissions}
companyId={companyId}
projectCandidates={(projects.data ?? []).map((p) => ({
id: p.id,
label: p.name,
}))}
issueCandidates={(boundaryIssues.data ?? []).map((issue) => ({
id: issue.id,
label: `${issue.identifier} · ${issue.title}`,
}))}
allowSingleIssue={false}
candidatesLoading={projects.isPending || boundaryIssues.isPending}
/>
<p className="text-xs text-muted-foreground">
Low trust limits Paperclip access; it does not sandbox the runtime.
Review filesystem, tool, and secret access separately.
</p>
{(trust.error || projects.error || boundaryIssues.error) && (
<p role="alert" className="text-sm text-destructive">
{(trust.error ?? projects.error ?? boundaryIssues.error)?.message}
</p>
)}
<DialogFooter>
<Button variant="ghost" onClick={() => setTrustOpen(false)}>
Cancel
</Button>
<Button
disabled={
trust.isPending ||
agentDetail.isPending ||
!!agentDetail.error ||
(getTrustPreset(permissions) === "low_trust_review" &&
!lowTrustBoundaryHasScope(getLowTrustBoundary(permissions)))
}
onClick={() => trust.mutate()}
>
Save trust settings
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}
export function EmailConnectionInboxes({
companyId,
connectionId,
canConfigure,
}: {
companyId: string;
connectionId: string;
canConfigure: boolean;
}) {
const query = useQuery({
queryKey: ["email-inboxes", companyId],
queryFn: () => emailApi.list(companyId),
refetchInterval: 10_000,
});
const connections = useQuery({
queryKey: queryKeys.tools.connections(companyId),
queryFn: () => toolsApi.listConnections(companyId),
});
const children = new Set(
connections.data?.connections
.filter((c) => c.config?.credentialConnectionId === connectionId)
.map((c) => c.id),
);
const inboxes =
query.data?.filter(
(i) => i.connectionId === connectionId || children.has(i.connectionId),
) ?? [];
return (
<section className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-4 rounded-xl border border-border p-6">
<div className="space-y-1">
<h2 className="text-lg font-semibold">
Give an agent an email address
</h2>
<p className="text-sm text-muted-foreground">
Each email conversation becomes a task.
</p>
</div>
{canConfigure && (
<Button asChild size="lg">
<Link
to={`/apps/chat/connect?provider=agentmail&connectionId=${connectionId}`}
>
Give an agent an email address
</Link>
</Button>
)}
</div>
{inboxes.map((i) => (
<div
key={i.id}
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border p-4"
>
<Link
className="text-sm underline"
to={`/apps/chat/${i.id}/settings`}
>
{i.address}
</Link>
<span className="text-xs text-muted-foreground">
{i.lastError ??
(i.status === "active" ? "Receiving email" : i.status)}
</span>
</div>
))}
{!!inboxes.length && <EmailSafetyNotice />}
{query.error && (
<p role="alert" className="text-sm text-destructive">
{query.error.message}
</p>
)}
</section>
);
}
export function EmailEndpointSettings({
endpointId,
companyId,
}: {
endpointId: string;
companyId: string;
}) {
const cache = useQueryClient();
const query = useQuery({
queryKey: ["email-inboxes", companyId],
queryFn: () => emailApi.list(companyId),
refetchInterval: 10_000,
});
const inbox = query.data?.find(
(row: EmailEndpointSummary) => row.id === endpointId,
);
const [removed, setRemoved] = useState(false);
const [replacementKey, setReplacementKey] = useState("");
const [receiveMode, setReceiveMode] = useState<"websocket" | "webhook" | "">(
"",
);
const reconnect = useMutation({
mutationFn: () =>
emailApi.reconnect(
endpointId,
replacementKey,
receiveMode || inbox!.receiveMode,
),
onSuccess: () => {
setReplacementKey("");
},
onSettled: () => {
void cache.invalidateQueries({ queryKey: ["email-inboxes", companyId] });
},
});
const control = useMutation({
mutationFn: (action: "pause" | "resume" | "remove") =>
emailApi.control(endpointId, action),
onSuccess: (result) => {
setRemoved(result.status === "archived");
void cache.invalidateQueries({ queryKey: ["email-inboxes", companyId] });
},
});
if (removed)
return <p>Inbox disconnected. Email history remains in its tasks.</p>;
if (!inbox)
return (
<p role={query.error ? "alert" : undefined}>
{query.error?.message ?? "Loading email inbox…"}
</p>
);
return (
<div className="max-w-xl space-y-4">
<h1 className="text-xl font-bold">{inbox.address}</h1>
<p className="text-sm text-muted-foreground">
{inbox.status} ·{" "}
{inbox.receiveMode === "websocket" ? "Live connection" : "Webhook"}
</p>
<p className="text-sm text-muted-foreground">
Last mail check: {inbox.lastSyncAt ? new Date(inbox.lastSyncAt).toLocaleString() : "Not checked yet"}
</p>
<p className="text-sm">
Each email conversation is a task. Task comments stay internal; use
Email reply to send.
</p>
{inbox.lastError && (
<p role="alert" className="text-sm text-destructive">
{inbox.lastError}
</p>
)}
<div className="flex gap-2">
<Button
variant="outline"
disabled={control.isPending}
onClick={() =>
control.mutate(inbox.status === "active" ? "pause" : "resume")
}
>
{inbox.status === "active" ? "Pause" : "Resume"}
</Button>
<Button
variant="outline"
disabled={control.isPending}
onClick={() => control.mutate("remove")}
>
Disconnect inbox
</Button>
</div>
<div className="space-y-2">
<Label htmlFor="email-reconnect-key">
Reconnect this inbox with a new API key
</Label>
<Input
id="email-reconnect-key"
type="password"
autoComplete="off"
value={replacementKey}
onChange={(e) => setReplacementKey(e.target.value)}
/>
<Label htmlFor="email-reconnect-mode">Receiving mode</Label>
<select
id="email-reconnect-mode"
className={selectClass}
value={receiveMode || inbox.receiveMode}
onChange={(e) =>
setReceiveMode(e.target.value as "websocket" | "webhook")
}
>
<option value="websocket">Live connection</option>
<option value="webhook">Webhook</option>
</select>
<Button
variant="outline"
disabled={!replacementKey || reconnect.isPending}
onClick={() => reconnect.mutate()}
>
Reconnect inbox
</Button>
</div>
{reconnect.error && (
<p role="alert" className="text-sm text-destructive">
{reconnect.error.message}
</p>
)}
{control.error && (
<p role="alert" className="text-sm text-destructive">
{control.error.message}
</p>
)}
</div>
);
}
@@ -72,7 +72,7 @@ describe("chat connector UI contract", () => {
it("lists every supported provider in the agent channel empty state", () => {
const panel = source("../../../components/chat/AgentChannelsPanel.tsx");
expect(panel).toContain(
"Connect Slack, GitHub, Discord, Microsoft Teams, or Telegram from",
"Connect AgentMail, Slack, GitHub, Discord, Microsoft Teams, or Telegram from",
);
});