mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-27 19:35:13 +01:00
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:
commit
24a7ab22f6
201 files changed
+62608
-3880
No files matched your search
@@ -223,6 +223,7 @@ export const agentsApi = {
|
||||
type: string,
|
||||
data: {
|
||||
adapterConfig: Record<string, unknown>;
|
||||
agentId?: string;
|
||||
testCredentials?: Record<string, string>;
|
||||
environmentId?: string | null;
|
||||
},
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
};
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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());
|
||||
});
|
||||
});
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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") {
|
||||
|
||||
@@ -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"),
|
||||
);
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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" },
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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" },
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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",
|
||||
);
|
||||
});
|
||||
|
||||
|
||||
Reference in new issue
Block a user