mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 01:41:42 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - When an agent's selected AI account is unavailable, the issue thread shows a `connection_intent` card. A person must reconnect the account or pick another connection. > - If the issue assignment changes while that card is still pending, the server expires it. This is the ownership-change path in `expireConnectionIntentsForOwnershipChange`. > - The expired card showed the reason "The task assignment changed". That text is wrong in two ways. It does not say that the connection request was never answered, and it does not say the connection is still missing. > - Worse, the reason never reached the screen at all: the expired branch of the card hard-codes "This request is no longer active.", so any guidance the server stores is discarded. My first round of this PR claimed the opposite, which was incorrect — the UI change is required for the fix to be visible. > - Issue #14040 reported the symptom: users saw a run fail with "assignment" blame and no hint that a connection needed attention. It happened on both `claude_local` and `codex_local` agents, so the message — not one adapter — is the defect. > - This pull request therefore fixes both ends: the server stores a sentence that names what happened and what to do next, per kind of request, and the card prints the stored reason instead of a generic string. > - The issue asks for a larger behavior change (hold reassignment until the card resolves). That needs a product discussion, so this PR keeps the scope to the message and where it is displayed. > - The benefit is that the card tells the truth: the blocker is the connection, not the assignment, and it says so on screen. ## Linked Issues or Issue Description - Fixes: #14040 ## What Changed - `server/src/services/issue-thread-interactions.ts`: `expireConnectionIntentsForOwnershipChange` now selects each pending row's payload before expiring it and writes a per-request reason: "The task was reassigned before the connection request was answered, so the AI account is still not connected. Reconnect it or choose an available connection." for AI-account intents, and the same sentence naming "the `<serviceName>` connection" for tool intents. The subject is chosen from `payload.purpose === "ai"`, falling back to "the requested connection" for a payload that carries neither, because this one path expires both kinds and the recovery step differs. - `ui/src/features/connections/ConnectionIntentInteractionBody.tsx`: the expired branch now renders `interaction.result?.reason` and keeps "This request is no longer active." only as the fallback for results stored before this change. The `superseded` outcome keeps its own wording. This component is the shared body for both the compact task card and the full thread card, so both views get the reason. - `server/src/__tests__/issues-service.test.ts`: the expiry test now expires a tool (`Notion`) and an AI (`claude_local`) intent in the same run and asserts both stored reasons, so neither wording can regress into the other. - `ui/src/features/connections/ConnectionIntentInteractionBody.test.tsx`: new test renders an expired card whose result carries a reason and asserts the reason is on screen and the generic fallback string is not. ## Verification - `npx vitest run server/src/__tests__/issues-service.test.ts ui/src/features/connections/ConnectionIntentInteractionBody.test.tsx` — 151/151 passed (127 server, 24 UI; 33.4s, Node 22.22.2, local). My first revision of this body cited 168 for the same set; that count came from a run on the wrong branch, and 151 is what this command prints on `fix/connection-intent-expiry-reason`. - The pinned server test drives the real path: it inserts pending `connection_intent` rows plus a live `toolOauthStates` row, reassigns the issue through `svc.update(...)`, then asserts both expired reasons and the deleted OAuth state row. - Mutation checks: forcing the AI subject for every row makes the expiry test fail on the tool-row assertion (`1 failed | 126 skipped`, `expected [ { …29 fields } ] to deeply equal [ ObjectContaining{…} ]`), and deleting the `result.reason` branch in the card fails exactly `shows the stored reason on an expired request` (`1 failed | 23 passed`). Both revert clean. The reverse wording mutation (tool subject forced onto AI rows) was not run, so that half of the test is pinned by its assertion rather than by a demonstrated mutation. - No schema, wire-format, or payload change. Only the free-text `result.reason` value differs and the card now reads the field it was already given. ## Risks - Low risk. The value is display text on a terminal interaction outcome. No code parses the old string, and no stored data is migrated; cards that expired before this change keep their old text and now render it as the stored reason they carry. - The deeper request in #14040 (do not expire a pending `connection_intent` on reassignment) is intentionally out of scope here and needs a maintainer decision. ## Model Used - Anthropic Claude (Opus 4.x, agentic CLI coding assistant with file edit and shell tool use) assisted with the code change, the test updates, and this PR text. A human reviewed the diff and the issue context before submission. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes: #` / `Refs: #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge
579 lines
27 KiB
TypeScript
579 lines
27 KiB
TypeScript
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import {
|
||
CheckCircle2,
|
||
Clock,
|
||
Loader2,
|
||
Plug,
|
||
RotateCcw,
|
||
XCircle,
|
||
} from "lucide-react";
|
||
import { AI_CONNECTION_CAPABILITIES, aiProviderSchema, type AiAuthMethod, type ConnectionIntentInteraction } from "@paperclipai/shared";
|
||
import { AgentMailIntentSetup } from "./AgentMailIntentSetup";
|
||
import { connectionIntentsApi } from "@/api/connection-intents";
|
||
import { aiConnectionsApi } from "@/api/ai-connections";
|
||
import { agentsApi } from "@/api/agents";
|
||
import { AiConnectionCredentialStep } from "@/components/ai-connections/AiConnectionCredentialStep";
|
||
import { AiProviderSetup } from "@/components/ai-connections/AiProviderSetup";
|
||
import { defaultAiConnectionName } from "@/components/ai-connections/model";
|
||
import { AppLogo } from "@/pages/apps/AppLogo";
|
||
import { AgentAvatar } from "@/components/AgentAvatar";
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
DialogTrigger,
|
||
} from "@/components/ui/dialog";
|
||
import {
|
||
ConnectionSetupFlow,
|
||
type ConnectionSetupCompletion,
|
||
type ConnectionSetupFlowProps,
|
||
} from "./ConnectionSetupFlow";
|
||
|
||
export interface ConnectionIntentInteractionBodyProps {
|
||
interaction: ConnectionIntentInteraction;
|
||
currentUserId?: string | null;
|
||
addresseeLabel: string;
|
||
addresseeName?: string;
|
||
renderSetup?: (props: ConnectionSetupFlowProps) => ReactNode;
|
||
}
|
||
|
||
export function ConnectionIntentInteractionBody({
|
||
interaction,
|
||
currentUserId,
|
||
addresseeLabel,
|
||
addresseeName,
|
||
renderSetup,
|
||
}: ConnectionIntentInteractionBodyProps) {
|
||
const [open, setOpen] = useState(false);
|
||
const [adoptionConnectionId, setAdoptionConnectionId] = useState<string | null>(null);
|
||
const focusTargetRef = useRef<HTMLDivElement>(null);
|
||
const setupGeneration = useRef(0);
|
||
const generation = setupGeneration.current;
|
||
const closeSetup = () => {
|
||
setupGeneration.current += 1;
|
||
selectAiAccountMutation.reset();
|
||
setOpen(false);
|
||
};
|
||
const queryClient = useQueryClient();
|
||
const isAddressee = Boolean(
|
||
currentUserId && interaction.addresseeUserId === currentUserId,
|
||
);
|
||
const isPending = interaction.status === "pending";
|
||
const isAi = interaction.payload.purpose === "ai";
|
||
const provider = isAi ? aiProviderSchema.safeParse(interaction.payload.serviceSlug).data : undefined;
|
||
const serviceName = provider ? AI_CONNECTION_CAPABILITIES[provider].name : interaction.payload.serviceName;
|
||
const isEmail = interaction.payload.purpose === "channel" && interaction.payload.serviceSlug === "agentmail";
|
||
const accessRequest = interaction.payload.accessRequest;
|
||
const agentQuery = useQuery({
|
||
queryKey: ["agents", "detail", interaction.payload.requestingAgentId, interaction.companyId],
|
||
queryFn: () => agentsApi.get(interaction.payload.requestingAgentId, interaction.companyId),
|
||
enabled: Boolean(accessRequest) && isPending,
|
||
});
|
||
const focusTargetId = `connection-intent-focus-target-${interaction.id}`;
|
||
|
||
const invalidateTask = async (
|
||
updatedInteraction?: ConnectionIntentInteraction,
|
||
) => {
|
||
if (updatedInteraction) {
|
||
queryClient.setQueriesData<ConnectionIntentInteraction[]>(
|
||
{ queryKey: ["issues", "interactions"] },
|
||
(current) =>
|
||
current?.map((candidate) =>
|
||
candidate.id === updatedInteraction.id
|
||
? updatedInteraction
|
||
: candidate,
|
||
),
|
||
);
|
||
}
|
||
await Promise.all([
|
||
// Task routes may key these caches by either UUID or human identifier.
|
||
// Prefix invalidation reaches the mounted task without requiring that
|
||
// routing identity to leak into the reusable interaction card.
|
||
queryClient.invalidateQueries({ queryKey: ["issues", "interactions"] }),
|
||
queryClient.invalidateQueries({ queryKey: ["issues", "detail"] }),
|
||
]);
|
||
};
|
||
const returnFocusToCard = () => {
|
||
// Completing an intent can move it from the composer takeover to the
|
||
// durable timeline. That replaces this component instance, so its ref can
|
||
// be cleared before focus restoration runs. Retry for a few paint frames
|
||
// and resolve the stable interaction-specific target from the new host.
|
||
const focusCurrentTarget = (remainingAttempts: number) => {
|
||
window.requestAnimationFrame(() => {
|
||
const target =
|
||
document.getElementById(focusTargetId) ?? focusTargetRef.current;
|
||
target?.focus();
|
||
if (remainingAttempts > 1) {
|
||
focusCurrentTarget(remainingAttempts - 1);
|
||
}
|
||
});
|
||
};
|
||
focusCurrentTarget(3);
|
||
};
|
||
|
||
const setupQuery = useQuery({
|
||
queryKey: ["connection-intent", interaction.id, "setup-options"],
|
||
queryFn: () => connectionIntentsApi.setupOptions(interaction.id),
|
||
enabled: isAddressee && isPending,
|
||
refetchInterval: isPending && (open || interaction.payload.phase === "authorizing") ? 2_000 : false,
|
||
});
|
||
|
||
useEffect(() => {
|
||
const current = setupQuery.data?.interaction;
|
||
if (current && current.status !== "pending" && isPending) {
|
||
void invalidateTask(current);
|
||
setOpen(false);
|
||
returnFocusToCard();
|
||
}
|
||
}, [setupQuery.data?.interaction, isPending]);
|
||
|
||
const completeMutation = useMutation({
|
||
mutationFn: (connectionId: string) =>
|
||
connectionIntentsApi.complete(interaction.id, connectionId),
|
||
onSuccess: async (updatedInteraction) => {
|
||
await invalidateTask(updatedInteraction);
|
||
setOpen(false);
|
||
returnFocusToCard();
|
||
},
|
||
});
|
||
const adoptMutation = useMutation({
|
||
mutationFn: (connectionId: string) => agentsApi.adoptAiConnection(
|
||
interaction.payload.requestingAgentId, interaction.id, connectionId, interaction.companyId,
|
||
),
|
||
onSuccess: async (updatedInteraction) => {
|
||
await invalidateTask(updatedInteraction);
|
||
setAdoptionConnectionId(null);
|
||
setOpen(false);
|
||
returnFocusToCard();
|
||
},
|
||
});
|
||
const declineMutation = useMutation({
|
||
mutationFn: () => connectionIntentsApi.decline(interaction.id),
|
||
onSuccess: async (updatedInteraction) => {
|
||
await invalidateTask(updatedInteraction);
|
||
setOpen(false);
|
||
returnFocusToCard();
|
||
},
|
||
});
|
||
const phaseMutation = useMutation({
|
||
mutationFn: (phase: ConnectionIntentInteraction["payload"]["phase"]) =>
|
||
connectionIntentsApi.setPhase(interaction.id, phase),
|
||
onSuccess: invalidateTask,
|
||
});
|
||
const mutatePhase = phaseMutation.mutate;
|
||
const handlePhaseChange = useCallback(
|
||
(phase: ConnectionIntentInteraction["payload"]["phase"]) =>
|
||
mutatePhase(phase),
|
||
[mutatePhase],
|
||
);
|
||
|
||
const finishNewConnection = async (completion: ConnectionSetupCompletion) => {
|
||
// A completed credential save survives cancellation, but an abandoned form
|
||
// must not accept the task request (even if a new form has since opened).
|
||
if (isAi && generation !== setupGeneration.current) {
|
||
await setupQuery.refetch();
|
||
return;
|
||
}
|
||
if (completion.resolvedByCallback) {
|
||
// A browser message cannot establish authorization. Read the durable result.
|
||
const verified = await setupQuery.refetch();
|
||
if (verified.data?.interaction.status !== "accepted") return;
|
||
await invalidateTask(verified.data.interaction);
|
||
setOpen(false);
|
||
returnFocusToCard();
|
||
return;
|
||
}
|
||
if (setupQuery.data?.aiConnectionRequiresAdoption) {
|
||
setAdoptionConnectionId(completion.connectionId);
|
||
} else {
|
||
completeMutation.mutate(completion.connectionId);
|
||
}
|
||
};
|
||
|
||
const selectAiAccountMutation = useMutation({
|
||
mutationFn: async (result: { connectionId: string; grantId: string; method: AiAuthMethod; generation: number }) => {
|
||
if (result.generation !== setupGeneration.current) {
|
||
await setupQuery.refetch();
|
||
return;
|
||
}
|
||
const binding = setupQuery.data?.aiConnection;
|
||
const previous = setupQuery.data?.aiRepair?.connection;
|
||
if (binding && previous && result.connectionId !== previous.id) {
|
||
if (binding.mode === "responsible_user") {
|
||
await aiConnectionsApi.setDefault(interaction.companyId, result.grantId);
|
||
} else {
|
||
const agent = await agentsApi.get(interaction.payload.requestingAgentId, interaction.companyId);
|
||
const current = agent.runtimeConfig.aiConnection;
|
||
if (!current || current.mode === "responsible_user" || current.mode === "router" || current.connectionId !== previous.id || current.grantId !== previous.grantId) {
|
||
throw new Error("The agent’s AI connection changed. Reload the task and try again.");
|
||
}
|
||
if (result.generation !== setupGeneration.current) return;
|
||
await agentsApi.update(agent.id, {
|
||
runtimeConfig: { ...agent.runtimeConfig, aiConnection: { ...binding, method: result.method, connectionId: result.connectionId, grantId: result.grantId } },
|
||
}, interaction.companyId);
|
||
}
|
||
await queryClient.invalidateQueries({ queryKey: ["ai-connections", interaction.companyId] });
|
||
}
|
||
if (result.generation === setupGeneration.current) await finishNewConnection(result);
|
||
},
|
||
});
|
||
|
||
const setupProps: ConnectionSetupFlowProps | null = setupQuery.data ? {
|
||
host: "dialog",
|
||
upstreamServiceName: interaction.payload.upstreamService?.name,
|
||
serviceSlug: interaction.payload.serviceSlug.startsWith("connection:") ? undefined : interaction.payload.serviceSlug,
|
||
configuredConnection: interaction.payload.serviceSlug.startsWith("connection:") ? setupQuery.data.existingConnections[0] : undefined,
|
||
requestedAgentId: setupQuery.data.requestedAgentId,
|
||
aiConnection: setupQuery.data.aiConnection,
|
||
interactionId: interaction.id,
|
||
existingConnections: setupQuery.data.existingConnections,
|
||
onUseExisting: async (connectionId) => { await completeMutation.mutateAsync(connectionId); },
|
||
onComplete: (completion) => { void finishNewConnection(completion); },
|
||
onOAuthDeclined: () => declineMutation.mutate(),
|
||
onPhaseChange: handlePhaseChange,
|
||
onCancel: () => { closeSetup(); returnFocusToCard(); },
|
||
} : null;
|
||
|
||
const resultOutcome = interaction.result?.outcome;
|
||
const status =
|
||
interaction.status === "accepted"
|
||
? {
|
||
icon: CheckCircle2,
|
||
title: accessRequest ? `${serviceName} access granted` : interaction.payload.upstreamService ? "External provider connected" : `${serviceName} connected`,
|
||
body: accessRequest ? null : interaction.payload.upstreamService ? `${interaction.payload.requestingAgentName} can now verify and authorize ${interaction.payload.upstreamService.name} through this provider. The app is not yet verified.` : isAi ? "This agent can now use the connection." : `${interaction.payload.requestingAgentName} can use this connection on the continuation run.`,
|
||
}
|
||
: interaction.status === "cancelled"
|
||
? {
|
||
icon: XCircle,
|
||
title: "Connection request withdrawn",
|
||
body: "This request is no longer active.",
|
||
}
|
||
: interaction.status === "rejected"
|
||
? {
|
||
icon: XCircle,
|
||
title: accessRequest ? "Access declined" : "Connection declined",
|
||
body: accessRequest ? null : isAi ? "The task still needs a working AI connection before it can run." : `${interaction.payload.requestingAgentName} was notified and can continue without it.`,
|
||
}
|
||
: interaction.status === "expired"
|
||
? {
|
||
icon: Clock,
|
||
title:
|
||
resultOutcome === "superseded"
|
||
? "Request superseded"
|
||
: "Connection request expired",
|
||
body:
|
||
resultOutcome === "superseded"
|
||
? "This request was replaced. Use the latest connection card instead."
|
||
: // The server stores the reason an expiry happened and what to
|
||
// do next; without it the card only says the request is gone.
|
||
interaction.result?.reason?.trim()
|
||
|| "This request is no longer active.",
|
||
}
|
||
: null;
|
||
const StatusIcon = status?.icon;
|
||
|
||
if (status && StatusIcon) {
|
||
return (
|
||
<div
|
||
id={focusTargetId}
|
||
ref={focusTargetRef}
|
||
tabIndex={-1}
|
||
data-testid="connection-intent-focus-target"
|
||
>
|
||
<div
|
||
className="flex items-start gap-3"
|
||
data-testid="connection-intent-terminal"
|
||
>
|
||
<StatusIcon className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground" />
|
||
<div>
|
||
<p className="font-medium text-foreground">{status.title}</p>
|
||
{status.body ? <p className="mt-1 text-sm text-muted-foreground">{status.body}</p> : null}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
if (!isAddressee) {
|
||
return (
|
||
<div
|
||
id={focusTargetId}
|
||
ref={focusTargetRef}
|
||
tabIndex={-1}
|
||
data-testid="connection-intent-focus-target"
|
||
>
|
||
<div
|
||
className="flex items-start gap-3"
|
||
data-testid="connection-intent-waiting"
|
||
>
|
||
<Clock className="mt-0.5 h-5 w-5 shrink-0 text-muted-foreground" />
|
||
<div>
|
||
<p className="font-medium text-foreground">
|
||
Waiting for {addresseeLabel}
|
||
</p>
|
||
{!accessRequest ? <p className="mt-1 text-sm text-muted-foreground">
|
||
Only the addressed person can choose an identity or authorize this
|
||
connection.
|
||
</p> : null}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const needsRetry = interaction.payload.phase === "needs_retry";
|
||
const authorizing = interaction.payload.phase === "authorizing";
|
||
|
||
if (accessRequest) {
|
||
const busy = completeMutation.isPending || declineMutation.isPending;
|
||
return <div id={focusTargetId} ref={focusTargetRef} tabIndex={-1} data-testid="connection-intent-focus-target">
|
||
<div data-testid="connection-intent-access-request" className="space-y-3 rounded-lg border border-border bg-card p-4">
|
||
<div className="flex items-center gap-3">
|
||
<AgentAvatar agent={agentQuery.data ?? { id: interaction.payload.requestingAgentId, name: interaction.payload.requestingAgentName }} size={32} label={interaction.payload.requestingAgentName} />
|
||
<div className="min-w-0 flex-1">
|
||
<p className="text-sm font-medium text-foreground">Grant {interaction.payload.requestingAgentName} access to “{accessRequest.connectionName}”?</p>
|
||
</div>
|
||
<AppLogo name={interaction.payload.serviceName} logoUrl={interaction.payload.serviceLogoUrl} darkLogoUrl={interaction.payload.serviceDarkLogoUrl} size={32} />
|
||
</div>
|
||
<ul aria-label="Tool permissions" className="max-h-48 space-y-2 overflow-y-auto text-xs">
|
||
{accessRequest.tools.map(tool => <li key={tool.catalogEntryId} className="flex items-start justify-between gap-3">
|
||
<span className="min-w-0 break-all font-mono text-foreground">{tool.toolName}</span>
|
||
<span className="shrink-0 text-muted-foreground">{tool.permission === "allowed" ? "Allowed" : "Ask first"}</span>
|
||
</li>)}
|
||
</ul>
|
||
{setupQuery.isError || completeMutation.isError || declineMutation.isError ? <p role="alert" className="text-sm text-destructive">{(completeMutation.error ?? declineMutation.error ?? setupQuery.error)?.message ?? "Couldn’t update this access request."}</p> : null}
|
||
{setupQuery.data?.canGrantAccess === false ? <p role="status" className="text-sm text-muted-foreground">Connection manager required.</p> : null}
|
||
<div className="flex items-center justify-between gap-2">
|
||
<Button size="sm" variant="ghost" disabled={busy} onClick={() => declineMutation.mutate()}>Not now</Button>
|
||
<Button size="sm" disabled={busy || !setupQuery.data?.canGrantAccess} onClick={() => completeMutation.mutate(accessRequest.connectionId)}>
|
||
{completeMutation.isPending ? "Granting access…" : "Grant access"}
|
||
</Button>
|
||
</div>
|
||
</div>
|
||
</div>;
|
||
}
|
||
|
||
const repair = setupQuery.data?.aiRepair;
|
||
const selectedReady = repair && setupQuery.data?.existingConnections.some((connection) => connection.id === repair.connection.id);
|
||
const readyForAdoption = setupQuery.data?.aiConnectionRequiresAdoption
|
||
? adoptionConnectionId ?? (selectedReady ? repair.connection.id : null)
|
||
: null;
|
||
const setupContent = setupQuery.isLoading ? (
|
||
<div className="flex min-h-48 items-center justify-center gap-2 text-sm text-muted-foreground">
|
||
<Loader2 className="h-4 w-4 animate-spin" /> Loading
|
||
connection options…
|
||
</div>
|
||
) : setupQuery.isError ? (
|
||
<div className="py-8 text-center">
|
||
<p className="font-medium text-foreground">
|
||
Couldn’t load connection setup
|
||
</p>
|
||
<p className="mt-1 text-sm text-muted-foreground">
|
||
{setupQuery.error instanceof Error
|
||
? setupQuery.error.message
|
||
: "Try again."}
|
||
</p>
|
||
<Button
|
||
className="mt-4"
|
||
variant="outline"
|
||
onClick={() => setupQuery.refetch()}
|
||
>
|
||
Try again
|
||
</Button>
|
||
</div>
|
||
) : setupProps ? (
|
||
renderSetup ? renderSetup(setupProps) : <ConnectionSetupFlow {...setupProps} />
|
||
) : null;
|
||
const aiConnection = setupQuery.data?.aiConnection;
|
||
const needsOwnAiConnection = isAi && (!repair || setupQuery.data?.aiConnectionRequiresAdoption) && aiConnection?.mode === "responsible_user";
|
||
const aiProviderName = aiConnection ? AI_CONNECTION_CAPABILITIES[aiConnection.provider].name : serviceName;
|
||
const inlineContent = setupQuery.isLoading || setupQuery.isError ? setupContent
|
||
: readyForAdoption ? <div className="space-y-3">
|
||
<p className="text-sm">
|
||
Use your {aiProviderName} connection for {interaction.payload.requestingAgentName}? This replaces the agent’s existing authentication
|
||
with each responsible person’s own account. The model stays the same.
|
||
</p>
|
||
<Button disabled={adoptMutation.isPending} onClick={() => adoptMutation.mutate(readyForAdoption)}>
|
||
{adoptMutation.isPending ? "Checking connection…" : "Use connection and continue"}
|
||
</Button>
|
||
</div>
|
||
: selectedReady ? <div className="space-y-3">
|
||
<p className="text-sm">{repair.connection.name} is ready.</p>
|
||
<Button disabled={completeMutation.isPending} onClick={() => completeMutation.mutate(repair.connection.id)}>
|
||
{completeMutation.isPending ? "Continuing…" : "Continue task"}
|
||
</Button>
|
||
</div>
|
||
: repair ? repair.canReconnect ? repair.connection.routing ? <AiProviderSetup
|
||
companyId={interaction.companyId}
|
||
agentId={interaction.payload.requestingAgentId}
|
||
reconnect={repair.connection}
|
||
onComplete={(binding) => selectAiAccountMutation.mutate({ connectionId: binding.connectionId, grantId: binding.grantId, method: binding.method ?? "api_key", generation })}
|
||
onCancel={() => { closeSetup(); returnFocusToCard(); }}
|
||
/> : <AiConnectionCredentialStep
|
||
companyId={interaction.companyId}
|
||
provider={repair.connection.provider}
|
||
initialMethod={repair.connection.method}
|
||
fixedMethod={false}
|
||
connectionId={repair.connection.id}
|
||
name={repair.connection.name}
|
||
nameForMethod={(method) => defaultAiConnectionName(addresseeName ?? addresseeLabel, repair.connection.provider, method)}
|
||
hideName
|
||
ownership={repair.connection.ownership}
|
||
agentIds={[interaction.payload.requestingAgentId]}
|
||
allAgents={false}
|
||
onComplete={(result) => selectAiAccountMutation.mutate({ ...result, generation })}
|
||
onCancel={() => { closeSetup(); returnFocusToCard(); }}
|
||
/> : <p role="status" className="text-sm text-muted-foreground">
|
||
{repair.connection.ownership === "personal" ? `${repair.connection.ownerName ?? "The account owner"} must reconnect ${repair.connection.name}.` : `The account owner must reconnect ${repair.connection.name}.`}
|
||
{" "}You can continue here once it is restored.
|
||
</p>
|
||
: aiConnection && aiConnection.mode !== "responsible_user"
|
||
? <p role="status" className="text-sm text-muted-foreground">The selected account is no longer available to you. Ask its owner to restore access, or choose an available AI connection in the agent’s settings.</p>
|
||
: aiConnection ? <AiConnectionCredentialStep
|
||
companyId={interaction.companyId}
|
||
provider={aiConnection.provider}
|
||
name={defaultAiConnectionName(addresseeName ?? addresseeLabel, aiConnection.provider, "subscription")}
|
||
hideName
|
||
nameForMethod={(method) => defaultAiConnectionName(addresseeName ?? addresseeLabel, aiConnection.provider, method)}
|
||
ownership="personal"
|
||
agentIds={[interaction.payload.requestingAgentId]}
|
||
allAgents={false}
|
||
onComplete={(result) => selectAiAccountMutation.mutate({ ...result, generation })}
|
||
onCancel={() => { closeSetup(); returnFocusToCard(); }}
|
||
/> : setupContent;
|
||
|
||
return (
|
||
<div
|
||
id={focusTargetId}
|
||
ref={focusTargetRef}
|
||
tabIndex={-1}
|
||
data-testid="connection-intent-focus-target"
|
||
>
|
||
<div data-testid="connection-intent-actions">
|
||
<div className="flex items-start gap-3">
|
||
<AppLogo
|
||
name={interaction.payload.serviceName}
|
||
logoUrl={interaction.payload.serviceLogoUrl}
|
||
darkLogoUrl={interaction.payload.serviceDarkLogoUrl}
|
||
size={40}
|
||
/>
|
||
<div>
|
||
<p className="font-medium text-foreground">
|
||
{readyForAdoption ? `Use your ${aiProviderName} account` : needsOwnAiConnection ? `Connect your ${aiProviderName} account` : isAi ? `${aiProviderName} authentication required` : `${interaction.payload.requestingAgentName} needs ${interaction.payload.serviceName}`}
|
||
</p>
|
||
<p className="mt-1 text-sm text-muted-foreground">
|
||
{readyForAdoption
|
||
? `Your account is connected. Use it for ${interaction.payload.requestingAgentName} to resume this task.`
|
||
: needsOwnAiConnection
|
||
? `${interaction.payload.requestingAgentName} needs your own AI connection. Connect here and the task will resume automatically.`
|
||
: interaction.payload.purpose === "ai"
|
||
? "This task can’t run until the agent has a valid AI connection. Connect here and the task will resume automatically."
|
||
: isEmail ? "Connect AgentMail to create an email address for this agent."
|
||
: "Connect your identity or reuse an eligible connection. Access is added only for this agent."}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{needsRetry ? (
|
||
<p className="mt-4 flex items-center gap-2 text-sm text-destructive">
|
||
<RotateCcw className="h-4 w-4" />
|
||
Authorization didn’t finish. Your previous choices are safe; try
|
||
again.
|
||
</p>
|
||
) : null}
|
||
|
||
{isEmail ? setupQuery.isLoading || setupQuery.isError ? <>
|
||
{setupContent}
|
||
<Button type="button" variant="ghost" disabled={declineMutation.isPending} onClick={() => declineMutation.mutate()}>Not now</Button>
|
||
</> : <AgentMailIntentSetup
|
||
companyId={interaction.companyId}
|
||
agentId={interaction.payload.requestingAgentId}
|
||
requestId={interaction.id}
|
||
savedCredentialId={setupQuery.data?.emailSetup?.credentialConnectionId}
|
||
readyConnectionId={setupQuery.data?.emailSetup?.readyConnectionId}
|
||
onComplete={async connectionId => { await completeMutation.mutateAsync(connectionId); }}
|
||
onDecline={() => declineMutation.mutate()}
|
||
declining={declineMutation.isPending}
|
||
/> : <div className="mt-4 flex flex-wrap justify-end gap-2">
|
||
{!isAi && <Button
|
||
type="button"
|
||
variant="ghost"
|
||
disabled={declineMutation.isPending || completeMutation.isPending || authorizing}
|
||
onClick={() => declineMutation.mutate()}
|
||
>
|
||
Not now
|
||
</Button>}
|
||
{isAi ? <Button type="button" disabled={completeMutation.isPending || adoptMutation.isPending || selectAiAccountMutation.isPending} onClick={() => open ? closeSetup() : setOpen(true)}>
|
||
<Plug className="h-4 w-4" />{open ? "Close setup" : readyForAdoption ? "Continue setup" : needsOwnAiConnection ? `Connect ${aiProviderName}` : "Fix connection"}
|
||
</Button> : <Dialog open={open} onOpenChange={setOpen}>
|
||
<DialogTrigger asChild>
|
||
<Button type="button">
|
||
{authorizing ? (
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
) : (
|
||
<Plug className="h-4 w-4" />
|
||
)}
|
||
{authorizing
|
||
? "Continue setup"
|
||
: needsRetry
|
||
? "Try again"
|
||
: setupQuery.data?.existingConnections.length ? "Connect / Use existing" : "Connect"}
|
||
</Button>
|
||
</DialogTrigger>
|
||
<DialogContent
|
||
className="max-h-(--sz-85vh) overflow-y-auto sm:max-w-3xl"
|
||
showCloseButton={false}
|
||
onCloseAutoFocus={(event) => {
|
||
event.preventDefault();
|
||
focusTargetRef.current?.focus();
|
||
}}
|
||
>
|
||
<DialogHeader className="sr-only">
|
||
<DialogTitle>
|
||
Connect {interaction.payload.serviceName}
|
||
</DialogTitle>
|
||
<DialogDescription>
|
||
Complete connection setup without leaving this task.
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
{setupContent}
|
||
</DialogContent>
|
||
</Dialog>}
|
||
</div>}
|
||
{isAi && open ? <div className="mt-4 border-t border-border pt-4" data-testid="ai-connection-inline-repair">{inlineContent}</div> : null}
|
||
|
||
{(!isEmail && completeMutation.isError) ||
|
||
(selectAiAccountMutation.isError && selectAiAccountMutation.variables?.generation === generation) ||
|
||
adoptMutation.isError ||
|
||
declineMutation.isError ||
|
||
phaseMutation.isError ? (
|
||
<p className="mt-3 text-sm text-destructive" role="alert">
|
||
{(completeMutation.error ??
|
||
selectAiAccountMutation.error ??
|
||
adoptMutation.error ??
|
||
declineMutation.error ??
|
||
phaseMutation.error) instanceof Error
|
||
? (
|
||
completeMutation.error ??
|
||
selectAiAccountMutation.error ??
|
||
adoptMutation.error ??
|
||
declineMutation.error ??
|
||
phaseMutation.error
|
||
)?.message
|
||
: "Couldn’t update this connection request."}
|
||
</p>
|
||
) : null}
|
||
{selectAiAccountMutation.isError && selectAiAccountMutation.variables?.generation === generation && (
|
||
<Button className="mt-3" onClick={() => selectAiAccountMutation.mutate(selectAiAccountMutation.variables!)}>
|
||
Retry using this connection
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|