mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 22:13:59 +01:00
## Thinking Path > - Paperclip lets people manage agent work through the board. > - The board reads task, agent, settings, plugin, and run data from the server. > - A deploy can interrupt those reads after content is already visible. > - Local error branches can replace that content with an error or show raw codes. > - This pull request moves the remaining reads to the shared recovery behavior. > - The query-error guard prevents new local error patterns from returning. ## Linked Issues or Issue Description **What happened?** Loaded lists, settings, task panels, and manual reads could clear their content or show errors during a server restart. Some reads also disabled the shared retry policy. **Expected behavior** Keep loaded content during a temporary failure. Show the shared connection banner. Retry reads after recovery. Replace content after a real access denial or missing resource, with readable copy. **Steps to reproduce** Open a task, settings page, or run log. Return 503 with `tenant_app_unavailable` from the API. Restore the API and check that content refreshes. Repeat with 403 and 404 to check the failure state. Related merged work: Refs #15754, Refs #15772, Refs #17960. This change covers remaining reads and enforcement. Browser persistence and the outage browser suite follow separately. ## What Changed - Repair an existing PostgreSQL deadline fixture at UTC boundaries. A daily cap defers a heartbeat until both its original interval and the next UTC day allow it. Keep the counted run in its seeded day and cover noon, just before midnight and the first second after midnight with explicit clocks and real PostgreSQL. Production scheduling is unchanged. - Integrate the saved list, settings, shell, task, and manual-read recovery changes against current master. Preserve the current task, Inbox, and run-log structure. - Add `useResilientFetch` for component-local reads. Preserve loaded data, cancel stale requests, and retry through the shared connectivity policy. Keep browser and pairing capabilities in memory. - Retain run log chunks across stream changes and catch up after recovery without duplicate chunks or stalled requests. - Keep genuine authorization and missing-resource failures visible. Translate known single-word codes such as `forbidden`. Show known skills and disconnected-app failures before sibling loading states. Keep Retry bound to its failed request. - Make the query-error guard enforce by default and run it in the CI policy job. Document the narrow exceptions and the read recovery contract. - Invalidate pending OAuth discovery when the operator leaves setup or its context changes. Keep reserved-popup cleanup bound to the current attempt. ## Verification - Current read head `09d1db29dea` is merge-ready: all 59 check runs are completed (54 successful, three skipped, two neutral), the legacy Snyk status is successful, and both required `ci / verify` and `ci / e2e` gates pass. Greptile Apex reviewed this exact head at 5/5 with no unresolved threads; the branch has no merge conflicts. The current CI run is `41748339602`. - Current read head `09d1db29dea` includes master `683fa4725e9`. The latest change touches only the existing server deadline test fixture. Application source remains the previously verified `86376fa751e` revision: all 60 affected skills/disconnected-app tests and both application UI typechecks passed, along with token gates and the enforcing query-error guard. Those UI suites were not repeated for this test-only repair. - The fixture repair passes all seven deadline integration cases and the complete server typecheck, including the server test layer. The old assertion deterministically fails at 23:59:06.888 UTC; its one-second-old run also fails the first-second-of-day probe. The corrected fixture passes noon and both midnight probes. The permanent three-case coverage uses seeded 2099 clocks. - Twelve new regressions fail against the previous implementation. Both skills variants and disconnected-app pages show either request's 403 or 429 while the sibling pauses or remains fetching. Permission failures stay visible. Retry stays usable for its failed request and recovers independently. An initial test-fixture query-key typo was corrected before the passing run. The finding is replied to and resolved. Greptile reviewed prior application revision `86376fa751e` at 5/5 with no new actionable findings or unresolved threads. The current test-only head has now received its own Apex 5/5 review with no unresolved threads, and its CI is successful as recorded above. - The preceding OAuth fix passed all 237 setup tests on both application branches. Its ten regressions prove that Back, Cancel, unmount, or context changes retire initial discovery without creating a connection, caching old identities, navigating an abandoned popup, or closing a newer attempt. - Earlier master integration `e992f770339` passed all 309 tests across eight affected Slack, recovery, retry, submission and task files, plus UI typecheck, token gates, enforcing query-error scan and seven guard tests. The integration preserves Slack workspace reconnect and shared recovery, readable execution errors, source-inspection company scoping, and current submission behavior. Newly inherited retained-file recovery controls keep cached data through transient failures and hide it after definitive denial. - The preceding cache head `97c0bc3966f` passed 1,038 focused cache/write/editor/account/task/API tests. All 22 built-UI outage/write/loading cases passed in 4.7 minutes on exact browser head `b7deaaee5da`, with strict checking of all three specs. The latest skills/app rendering fix is covered by its affected component suites; those broader cases were not repeated for this separate read path. - Historical broader UI, CLI and build results and unresolved aggregate server/DB/adapter failures remain in the PR discussion. Interrupted CI runs were audited and recorded separately; they are not counted as passes. Controlled local outages do not prove hosted authentication or a real Cloud restart/deploy. Real hosted acceptance remains required after merge and deployment. Commands for the latest fix: ```sh pnpm --filter @paperclipai/ui exec vitest run src/pages/CompanySkills.test.tsx src/pages/apps/AppNotConnected.test.tsx pnpm --filter @paperclipai/ui typecheck pnpm check:token-gates node scripts/check-query-error-rendering.mjs --enforce --summary ``` ## Risks This migration touches many read surfaces. The main risks are initial loading, request cancellation, retained authorization state, and run-log paging. The tests cover those states. There are no server, schema, dependency, or API contract changes. The follow-up browser suite and hosted deploy acceptance are required before release acceptance. Recovered commits retain their original authors. If squash-merging, preserve `Co-Authored-By: Bender (Fable) <bender@agents.paperclip.ing>` and the Paperclip trailer. ## Model Used OpenAI `gpt-6.1-sol` through Codex, with xhigh reasoning effort, tool use, and code execution. The context window size is not exposed. Saved source commits keep their original authorship. ## 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 described the issue in-PR following the bug report template - [x] I have not referenced internal/instance-local Paperclip issues or links - [x] My branch name describes the change and contains no internal ticket id - [x] The affected local tests pass; broader limitations are recorded above - [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 risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: devinfoley <139239+devinfoley@users.noreply.github.com> Co-authored-by: Paperclip <noreply@paperclip.ing> Co-authored-by: Bender (Fable) <bender@agents.paperclip.ing> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1994 lines
91 KiB
TypeScript
1994 lines
91 KiB
TypeScript
import { SpekoProviderSetup } from "@/components/voice/SpekoProviderSetup";
|
||
import { NativeVoiceConversation } from "@/components/voice/NativeVoiceConversation";
|
||
import { buildSlackAppManifest, defaultSlackAppConfiguration } from "@paperclipai/shared";
|
||
import { SlackAppDetails, SlackSetupAdvanced } from "./SlackAppDetails";
|
||
import { SlackSetup } from "./slack/SlackSetup";
|
||
import { isProvisionedSlackSetup, slackSetupState } from "./slack/setup-state";
|
||
import { slackConversationUrl } from "./slack/conversation-url";
|
||
import { useAccountIdentity } from "@/api/companies-query";
|
||
import { defaultSlackAppName } from "./slack-app-name";
|
||
import { GitHubChatSetup } from "./GitHubChatSetup";
|
||
import { GitHubAgentTrustWarning } from "@/components/GitHubAgentTrustWarning";
|
||
import { SetupWizardFooter } from "@/components/SetupWizard";
|
||
import { ChatSetupNavigation } from "@/components/chat/ChatSetupNavigation";
|
||
import { SlackIdentityStep } from "./SlackIdentityStep";
|
||
import { PhotonConnectStep } from "./PhotonConnectStep";
|
||
import { EmailEndpointSetup } from "./EmailEndpointSetup";
|
||
import {
|
||
useEffect,
|
||
useMemo,
|
||
useRef,
|
||
useState,
|
||
type ChangeEvent,
|
||
type Dispatch,
|
||
type SetStateAction,
|
||
} from "react";
|
||
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||
import { useQueryView } from "@/components/QueryView";
|
||
import { AlertTriangle, CheckCircle2, Copy, CircleHelp, ExternalLink, Eye, EyeOff, Hammer, Loader2 } from "lucide-react";
|
||
import { AgentAvatar } from "@/components/AgentAvatar";
|
||
import { AgentCharacter } from "@/components/AgentCharacter";
|
||
import { AgentSelect } from "@/components/AgentMultiSelect";
|
||
import { Button } from "@/components/ui/button";
|
||
import { Input } from "@/components/ui/input";
|
||
import { Dialog, DialogTrigger, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter } from "@/components/ui/dialog";
|
||
import { Tooltip, TooltipTrigger, TooltipContent } from "@/components/ui/tooltip";
|
||
import { Textarea } from "@/components/ui/textarea";
|
||
import { useBreadcrumbs } from "@/context/BreadcrumbContext";
|
||
import { useCompany } from "@/context/CompanyContext";
|
||
import { useToast } from "@/context/ToastContext";
|
||
import { agentsApi } from "@/api/agents";
|
||
import { instanceSettingsApi } from "@/api/instanceSettings";
|
||
import {
|
||
chatEndpointsApi,
|
||
type ChatEndpoint,
|
||
type ChatProvider,
|
||
type ChatEndpointSetupAction,
|
||
} from "@/api/chatEndpoints";
|
||
import { useNavigate, useSearchParams } from "@/lib/router";
|
||
import { queryKeys } from "@/lib/queryKeys";
|
||
import { copyTextToClipboard } from "@/lib/clipboard";
|
||
import { useCopyAction } from "@/lib/use-copy-action";
|
||
import { isAgentStatusInvokable, slackAppConfigurationSchema, type SlackAppConfiguration } from "@paperclipai/shared";
|
||
import { sanitizedSetupErrorMessage } from "./chat-setup-error";
|
||
import {
|
||
createGitHubPrivateKeyReadGuard,
|
||
readGitHubPrivateKeyFile,
|
||
} from "./github-private-key-file";
|
||
import { describeError } from "@/api/errors";
|
||
|
||
const providerNames: Record<ChatProvider, string> = {
|
||
speko: "Speko",
|
||
agentmail: "AgentMail",
|
||
slack: "Slack",
|
||
github: "GitHub",
|
||
discord: "Discord",
|
||
"microsoft-teams": "Microsoft Teams",
|
||
telegram: "Telegram",
|
||
"imessage-photon": "iMessage Photon",
|
||
};
|
||
|
||
const knownProviders = new Set(Object.keys(providerNames));
|
||
|
||
function isProvider(value: string | null): value is ChatProvider {
|
||
return value !== null && knownProviders.has(value);
|
||
}
|
||
|
||
function publicOrigin(value: string | null | undefined): string | null {
|
||
if (!value) return null;
|
||
try {
|
||
return new URL(value).origin;
|
||
} catch {
|
||
return null;
|
||
}
|
||
}
|
||
|
||
export function isChatEndpointRepairing(
|
||
endpoint: Pick<
|
||
ChatEndpoint,
|
||
"provider" | "status" | "providerAccountId" | "botExternalId"
|
||
> | null,
|
||
resumeEndpointId: string | null,
|
||
reconnectRequested: boolean,
|
||
): boolean {
|
||
if (!resumeEndpointId || !endpoint) return false;
|
||
const recoveringStatus =
|
||
endpoint.status === "attention" || endpoint.status === "revoked";
|
||
// A secret-only GitHub draft affected by setup trouble has no App identity
|
||
// or reusable App credentials. It must remain first-time setup, where App ID
|
||
// and private key are required, rather than offering a misleading reconnect.
|
||
if (
|
||
endpoint.provider === "github" &&
|
||
recoveringStatus &&
|
||
!endpoint.providerAccountId &&
|
||
!endpoint.botExternalId
|
||
) {
|
||
return false;
|
||
}
|
||
return (
|
||
recoveringStatus ||
|
||
(reconnectRequested &&
|
||
(endpoint.status === "active" || endpoint.status === "paused"))
|
||
);
|
||
}
|
||
|
||
export function ChatConnectionPurpose({ provider, onChat, onTools, slackLabels }: {
|
||
provider: ChatProvider;
|
||
onChat: () => void;
|
||
onTools: () => void;
|
||
slackLabels?: string[];
|
||
}) {
|
||
const { setBreadcrumbs } = useBreadcrumbs();
|
||
useEffect(() => {
|
||
setBreadcrumbs([{ label: "Connectors", href: "/apps" }, { label: "Choose connection" }]);
|
||
return () => setBreadcrumbs([]);
|
||
}, [setBreadcrumbs]);
|
||
return (
|
||
<div className="max-w-2xl space-y-6">
|
||
<ChatSetupNavigation labels={provider === "slack" ? slackLabels ?? ["Choose agent", "Connect"] : undefined} step={0} availableStep={0} onSelect={onChat} />
|
||
<div>
|
||
<h1 className="text-xl font-bold">Choose how to connect</h1>
|
||
<p className="mt-1 text-sm text-muted-foreground">
|
||
What should this {providerNames[provider]} connection do?
|
||
</p>
|
||
</div>
|
||
<div className="grid gap-3">
|
||
<button
|
||
type="button"
|
||
className="flex items-center gap-4 rounded-xl border border-border p-4 text-left hover:bg-accent/40"
|
||
onClick={onChat}
|
||
>
|
||
<AgentAvatar size={48} />
|
||
<span>
|
||
<span className="block text-sm font-semibold">
|
||
Chat with an agent
|
||
</span>
|
||
<span className="mt-1 block text-sm text-muted-foreground">
|
||
People in {providerNames[provider]} can start and continue
|
||
Paperclip tasks.
|
||
</span>
|
||
</span>
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className="flex items-center gap-4 rounded-xl border border-border p-4 text-left hover:bg-accent/40"
|
||
onClick={onTools}
|
||
>
|
||
<span className="flex size-12 shrink-0 items-center justify-center text-muted-foreground" aria-hidden="true">
|
||
<Hammer className="size-8" />
|
||
</span>
|
||
<span>
|
||
<span className="block text-sm font-semibold">
|
||
Use this connection as an agent tool
|
||
</span>
|
||
<span className="mt-1 block text-sm text-muted-foreground">
|
||
Let agents use {providerNames[provider]} actions and data while
|
||
they work.
|
||
</span>
|
||
</span>
|
||
</button>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export function ChatEndpointSetup() {
|
||
const [params] = useSearchParams();
|
||
if (params.get("provider") === "github") return <GitHubChatSetup />;
|
||
return params.get("provider") === "agentmail" ? <EmailEndpointSetup /> : <ChatSdkEndpointSetup />;
|
||
}
|
||
|
||
function ChatSdkEndpointSetup() {
|
||
const { userId, settled: identitySettled } = useAccountIdentity();
|
||
const [params, setParams] = useSearchParams();
|
||
const navigate = useNavigate();
|
||
const queryClient = useQueryClient();
|
||
const { selectedCompanyId } = useCompany();
|
||
const { setBreadcrumbs } = useBreadcrumbs();
|
||
const { pushToast } = useToast();
|
||
const provider = isProvider(params.get("provider"))
|
||
? (params.get("provider") as ChatProvider)
|
||
: null;
|
||
const toolHref = params.get("toolHref") || "/apps";
|
||
const preselectedAgent = params.get("agentId") ?? "";
|
||
const resumeEndpointId = params.get("resume") ?? "";
|
||
const reconnectRequested = params.get("reconnect") === "1";
|
||
const [purpose, setPurpose] = useState<"choice" | "chat">(
|
||
params.get("purpose") === "chat" ? "chat" : "choice",
|
||
);
|
||
const [agentId, setAgentId] = useState(preselectedAgent);
|
||
const [slackCredentialsReady, setSlackCredentialsReady] = useState(params.get("stage") === "credentials");
|
||
const [viewedStep, setViewedStep] = useState<number | null>(null);
|
||
const [slackIdentityReady, setSlackIdentityReady] = useState(false);
|
||
const [automaticBusy, setAutomaticBusy] = useState(false);
|
||
const [managedAutoStart, setManagedAutoStart] = useState(Boolean(resumeEndpointId));
|
||
const [slackDraft, setSlackDraft] = useState<{ agentId: string; app: SlackAppConfiguration } | null>(null);
|
||
const [endpoint, setEndpoint] = useState<ChatEndpoint | null>(null);
|
||
const [credentials, setCredentials] = useState<Record<string, string>>({});
|
||
const [generatedWebhookSecret, setGeneratedWebhookSecret] = useState("");
|
||
const [setupError, setSetupError] = useState<string | null>(null);
|
||
|
||
useEffect(() => {
|
||
setBreadcrumbs([
|
||
{ label: "Connectors", href: "/apps" },
|
||
{ label: "Connect chat" },
|
||
]);
|
||
return () => setBreadcrumbs([]);
|
||
}, [setBreadcrumbs]);
|
||
|
||
const agentsQuery = useQuery({
|
||
queryKey: ["chat-endpoint-setup-agents", selectedCompanyId],
|
||
queryFn: () => agentsApi.list(selectedCompanyId!),
|
||
enabled: Boolean(selectedCompanyId),
|
||
});
|
||
const resumeQuery = useQuery({
|
||
queryKey: ["chat-endpoint-setup-resume", resumeEndpointId],
|
||
queryFn: () => chatEndpointsApi.get(resumeEndpointId),
|
||
enabled: Boolean(resumeEndpointId),
|
||
refetchInterval: query => ["creating", "install", "credentials_saved"].includes(query.state.data?.setup?.slackRegistration?.status ?? "") ? 1_500 : false,
|
||
});
|
||
useEffect(() => {
|
||
if (!resumeQuery.data) return;
|
||
setEndpoint(current => {
|
||
if (isProvisionedSlackSetup(current?.setup?.slackSetupMethod) && !current?.providerAccountId && resumeQuery.data.providerAccountId) setViewedStep(null);
|
||
return resumeQuery.data;
|
||
});
|
||
setAgentId(resumeQuery.data.assignedAgentId);
|
||
setPurpose("chat");
|
||
}, [resumeQuery.data]);
|
||
const githubVerificationQuery = useQuery({
|
||
queryKey: ["chat-endpoint-github-webhook-verification", endpoint?.id],
|
||
queryFn: () => chatEndpointsApi.get(endpoint!.id),
|
||
enabled: Boolean(
|
||
provider === "github" &&
|
||
endpoint?.id &&
|
||
endpoint.setup?.step === "provider_setup" &&
|
||
endpoint.setup?.webhookSecretConfigured &&
|
||
!endpoint.setup.webhookVerifiedAt,
|
||
),
|
||
refetchInterval: 1_500,
|
||
});
|
||
useEffect(() => {
|
||
if (
|
||
!githubVerificationQuery.data ||
|
||
provider !== "github" ||
|
||
!endpoint ||
|
||
endpoint.id !== githubVerificationQuery.data.id ||
|
||
endpoint.setup?.step !== "provider_setup" ||
|
||
!endpoint.setup?.webhookSecretConfigured ||
|
||
endpoint.setup.webhookVerifiedAt
|
||
)
|
||
return;
|
||
setEndpoint(githubVerificationQuery.data);
|
||
}, [endpoint, githubVerificationQuery.data, provider]);
|
||
const experimentalSettingsQuery = useQuery({
|
||
queryKey: queryKeys.instance.experimentalSettings,
|
||
queryFn: () => instanceSettingsApi.getExperimental(),
|
||
enabled: endpoint?.setup?.step === "test",
|
||
});
|
||
const experimentalSettingsView = useQueryView(experimentalSettingsQuery);
|
||
const activeAgents = useMemo(
|
||
() =>
|
||
(agentsQuery.data ?? []).filter((agent) =>
|
||
isAgentStatusInvokable(agent.status),
|
||
),
|
||
[agentsQuery.data],
|
||
);
|
||
const selectedAgent = agentsQuery.data?.find(agent => agent.id === agentId);
|
||
const slackDetails = slackDraft?.agentId === agentId ? slackDraft.app
|
||
: endpoint?.setup?.slackApp ?? defaultSlackAppConfiguration(selectedAgent?.name ?? endpoint?.assignedAgentName ?? "");
|
||
const slackDetailsValidation = slackAppConfigurationSchema.safeParse(slackDetails);
|
||
const slackDetailsEditable = !automaticBusy && (!endpoint || endpoint.status === "draft" && !endpoint.botExternalId
|
||
&& (!endpoint.setup?.slackRegistration || endpoint.setup.slackRegistration.status === "failed"));
|
||
const syncEndpointSnapshot = (
|
||
next: ChatEndpoint,
|
||
onlyIfStillVisible = false,
|
||
) => {
|
||
if (next.setup?.slackSetupMethod === "managed" && endpoint && endpoint.setup?.slackSetupMethod !== "managed") setManagedAutoStart(true);
|
||
if (isProvisionedSlackSetup(next.setup?.slackSetupMethod) && next.setup?.slackRegistration?.status === "configured"
|
||
&& endpoint?.setup?.slackRegistration?.status !== "configured") setViewedStep(null);
|
||
setEndpoint((visible) =>
|
||
onlyIfStillVisible && visible?.id !== next.id ? visible : next,
|
||
);
|
||
queryClient.setQueryData(["chat-endpoint-setup-resume", next.id], next);
|
||
queryClient.setQueryData(queryKeys.chatEndpoints.detail(next.id), next);
|
||
if (next.provider === "github") {
|
||
queryClient.setQueryData(
|
||
["chat-endpoint-github-webhook-verification", next.id],
|
||
next,
|
||
);
|
||
}
|
||
};
|
||
const slackChoices = useQuery({
|
||
queryKey: ["slack-setup-options", selectedCompanyId],
|
||
queryFn: () => chatEndpointsApi.slackSetupOptions(selectedCompanyId!),
|
||
enabled: provider === "slack" && Boolean(selectedCompanyId),
|
||
});
|
||
const slackChoicesView = useQueryView(slackChoices);
|
||
const createEndpoint = useMutation({
|
||
mutationFn: ({ ownApp = false }: { ownApp?: boolean; exit?: boolean }) => {
|
||
if (provider === "slack" && !slackChoices.data) throw new Error("Slack setup is still loading. Try again.");
|
||
return chatEndpointsApi.create(selectedCompanyId!, {
|
||
provider: provider!,
|
||
assignedAgentId: agentId,
|
||
...(provider === "slack" ? { slackSetupMethod: ownApp ? "automatic" : slackChoices.data!.defaultMethod, slackApp: slackDetailsValidation.success ? slackDetailsValidation.data : slackDetails } : {}),
|
||
});
|
||
},
|
||
onSuccess: (next, { exit }) => {
|
||
setManagedAutoStart(!exit);
|
||
setViewedStep(exit ? 0 : null);
|
||
syncEndpointSnapshot(next);
|
||
if (next.provider === "imessage-photon" || next.provider === "slack" || next.provider === "speko") {
|
||
const resumed = new URLSearchParams(params);
|
||
resumed.set("resume", next.id);
|
||
setParams(resumed, { replace: true });
|
||
}
|
||
if (exit) navigate("/apps");
|
||
},
|
||
onError: (error) =>
|
||
pushToast({
|
||
title: "Couldn't start setup",
|
||
body: describeError(error).body,
|
||
tone: "error",
|
||
}),
|
||
});
|
||
const saveSlackDetails = useMutation({
|
||
scope: { id: `slack-app-details:${endpoint?.id}` },
|
||
mutationFn: (details: SlackAppConfiguration) => chatEndpointsApi.update(endpoint!.id, { slackApp: details }),
|
||
onSuccess: next => syncEndpointSnapshot(next),
|
||
});
|
||
const persistSlackDetails = async () => {
|
||
if (endpoint && slackDetailsEditable && slackDetailsValidation.success
|
||
&& JSON.stringify(slackDetailsValidation.data) !== JSON.stringify(endpoint.setup?.slackApp))
|
||
await saveSlackDetails.mutateAsync(slackDetailsValidation.data);
|
||
};
|
||
const continueFromAgent = async (exit: boolean) => {
|
||
if (exit && provider !== "slack") { navigate("/apps"); return; }
|
||
if (!endpoint) {
|
||
if (!agentId) { if (exit) navigate("/apps"); return; }
|
||
createEndpoint.mutate({ exit });
|
||
return;
|
||
}
|
||
try { if (provider === "slack") await persistSlackDetails(); }
|
||
catch { return; }
|
||
if (exit) navigate("/apps"); else { setManagedAutoStart(true); setViewedStep(null); }
|
||
};
|
||
const useOwnSlackApp = useMutation({
|
||
mutationFn: async () => {
|
||
if (!endpoint) return createEndpoint.mutateAsync({ ownApp: true });
|
||
await persistSlackDetails();
|
||
return chatEndpointsApi.update(endpoint.id, { slackSetupMethod: "automatic" });
|
||
},
|
||
onSuccess: next => { setManagedAutoStart(false); syncEndpointSnapshot(next); setViewedStep(1); },
|
||
onError: () => pushToast({ title: "Couldn't switch Slack setup", body: "Try again.", tone: "error" }),
|
||
});
|
||
const setupAction = useMutation({
|
||
mutationFn: async ({
|
||
action,
|
||
values,
|
||
}: {
|
||
action: ChatEndpointSetupAction;
|
||
values?: Record<string, string>;
|
||
}) => {
|
||
const endpointId = endpoint!.id;
|
||
try {
|
||
return await chatEndpointsApi.setup(endpointId, provider === "imessage-photon" ? {
|
||
action,
|
||
...(values?.projectSecret ? { credentials: { projectSecret: values.projectSecret } } : {}),
|
||
...(values?.projectId && values.allocation === "shared" ? { photon: { allocation: "shared" as const, projectId: values.projectId } } : values?.projectId && values?.lineId ? { photon: { allocation: "dedicated" as const, projectId: values.projectId, lineId: values.lineId } } : {}),
|
||
} : { action, credentials: values });
|
||
} catch (error) {
|
||
// Another open tab may have completed verification, or the successful
|
||
// response may have been lost. Read the canonical state before retrying.
|
||
if (action === "verify") {
|
||
const current = await chatEndpointsApi.get(endpointId).catch(() => null);
|
||
if (current?.setup?.step === "test" || current?.setup?.step === "complete") return current;
|
||
}
|
||
throw error;
|
||
}
|
||
},
|
||
onMutate: () => setSetupError(null),
|
||
onSuccess: (next) => {
|
||
setSetupError(null);
|
||
setViewedStep(null);
|
||
syncEndpointSnapshot(next);
|
||
setCredentials({});
|
||
if (next.setup?.step !== "test" && next.setup?.step !== "complete") setSlackIdentityReady(false);
|
||
},
|
||
onError: (error, variables) =>
|
||
setSetupError(sanitizedSetupErrorMessage(error, variables.values)),
|
||
});
|
||
const generateSetupSecret = useMutation({
|
||
mutationFn: () => chatEndpointsApi.generateSetupSecret(endpoint!.id),
|
||
onMutate: async () => {
|
||
const endpointId = endpoint!.id;
|
||
await Promise.all([
|
||
queryClient.cancelQueries({
|
||
queryKey: ["chat-endpoint-github-webhook-verification", endpointId],
|
||
exact: true,
|
||
}),
|
||
queryClient.cancelQueries({
|
||
queryKey: ["chat-endpoint-setup-resume", endpointId],
|
||
exact: true,
|
||
}),
|
||
queryClient.cancelQueries({
|
||
queryKey: queryKeys.chatEndpoints.detail(endpointId),
|
||
exact: true,
|
||
}),
|
||
]);
|
||
return { endpointId };
|
||
},
|
||
onSuccess: async ({ webhookSecret }, _variables, context) => {
|
||
const endpointId = context.endpointId;
|
||
const markRotated = (current: ChatEndpoint) => ({
|
||
...current,
|
||
setup: {
|
||
...current.setup,
|
||
step: "provider_setup" as const,
|
||
webhookSecretConfigured: true,
|
||
webhookVerifiedAt: null,
|
||
},
|
||
});
|
||
|
||
queryClient.removeQueries({
|
||
queryKey: ["chat-endpoint-github-webhook-verification", endpointId],
|
||
exact: true,
|
||
});
|
||
setGeneratedWebhookSecret(webhookSecret);
|
||
setEndpoint((current) =>
|
||
current && current.id === endpointId ? markRotated(current) : current,
|
||
);
|
||
queryClient.setQueryData<ChatEndpoint>(
|
||
["chat-endpoint-setup-resume", endpointId],
|
||
(current) => (current ? markRotated(current) : current),
|
||
);
|
||
queryClient.setQueryData<ChatEndpoint>(
|
||
queryKeys.chatEndpoints.detail(endpointId),
|
||
(current) => (current ? markRotated(current) : current),
|
||
);
|
||
|
||
try {
|
||
const current = await chatEndpointsApi.get(endpointId);
|
||
syncEndpointSnapshot(current, true);
|
||
} catch {
|
||
// Keep the one-time secret copyable. Verification polling will retry the
|
||
// canonical endpoint read without restoring a pre-rotation snapshot.
|
||
}
|
||
},
|
||
onError: (error) =>
|
||
pushToast({
|
||
title: "Couldn't generate webhook secret",
|
||
body: describeError(error).body,
|
||
tone: "error",
|
||
}),
|
||
});
|
||
const testConnection = useMutation({
|
||
mutationFn: () => provider === "slack" ? chatEndpointsApi.finishSlackSetup(endpoint!.id) : chatEndpointsApi.test(endpoint!.id),
|
||
onSuccess: (next) => {
|
||
syncEndpointSnapshot(next);
|
||
if (next.status === "active") navigate(`/apps/chat/${next.id}/settings`);
|
||
},
|
||
onError: (error) =>
|
||
pushToast({
|
||
title: provider === "slack" ? "Couldn't finish setup" : "Test not complete",
|
||
body: describeError(error).body,
|
||
tone: "error",
|
||
}),
|
||
});
|
||
const finishSetup = () => {
|
||
if (provider === "slack" && endpoint?.setup?.step === "complete") {
|
||
navigate(`/apps/chat/${endpoint.id}/settings`);
|
||
return;
|
||
}
|
||
testConnection.mutate();
|
||
};
|
||
|
||
const repairing = isChatEndpointRepairing(
|
||
endpoint,
|
||
resumeEndpointId,
|
||
reconnectRequested,
|
||
);
|
||
const isSlack = provider === "slack";
|
||
const slackNavigation = slackSetupState({ endpoint, repairing, credentialsReady: slackCredentialsReady, identityReady: slackIdentityReady, defaultMethod: slackChoices.data?.defaultMethod });
|
||
const automaticSlack = isSlack && slackNavigation.provisioned;
|
||
const automaticSlackComplete = automaticSlack && slackNavigation.complete;
|
||
const tryStep = isSlack ? slackNavigation.finalStage : 2;
|
||
const availableStep = isSlack ? slackNavigation.availableStage : !endpoint ? 0
|
||
: !repairing && (endpoint.setup?.step === "test" || endpoint.setup?.step === "complete") ? tryStep : 1;
|
||
const step = Math.min(viewedStep ?? availableStep, availableStep);
|
||
const agentStepBusy = automaticBusy || createEndpoint.isPending || saveSlackDetails.isPending || useOwnSlackApp.isPending;
|
||
const slackVerificationQuery = useQuery({
|
||
queryKey: ["chat-endpoint-slack-webhook-verification", endpoint?.id],
|
||
queryFn: () => chatEndpointsApi.get(endpoint!.id),
|
||
enabled: Boolean(isSlack && step === 3 && endpoint?.setup?.step === "provider_setup" &&
|
||
!endpoint.setup.webhookVerifiedAt && !setupAction.isPending),
|
||
refetchInterval: 1_500,
|
||
});
|
||
const slackVerificationView = useQueryView(slackVerificationQuery);
|
||
useEffect(() => {
|
||
const next = slackVerificationQuery.data;
|
||
if (!next || !endpoint || next.id !== endpoint.id || setupAction.isPending ||
|
||
endpoint.setup?.step !== "provider_setup" || endpoint.setup.webhookVerifiedAt ||
|
||
!next.setup?.webhookVerifiedAt) return;
|
||
setEndpoint(next);
|
||
}, [slackVerificationQuery.data, endpoint, setupAction.isPending]);
|
||
const autoVerificationAttempt = useRef<string | null>(null);
|
||
useEffect(() => {
|
||
if (!isSlack || step !== 3 || endpoint?.setup?.step !== "provider_setup" ||
|
||
!endpoint.setup.webhookVerifiedAt || setupAction.isPending) return;
|
||
const attempt = `${endpoint.id}:${endpoint.setup.webhookVerifiedAt}`;
|
||
if (autoVerificationAttempt.current === attempt) return;
|
||
autoVerificationAttempt.current = attempt;
|
||
setupAction.mutate({ action: "verify" });
|
||
}, [isSlack, step, endpoint, setupAction]);
|
||
|
||
if (!provider)
|
||
return (
|
||
<p className="text-sm text-destructive">
|
||
This chat provider is not supported.
|
||
</p>
|
||
);
|
||
if (!selectedCompanyId)
|
||
return (
|
||
<p className="text-sm text-muted-foreground">
|
||
Select an organization to connect chat.
|
||
</p>
|
||
);
|
||
|
||
if (purpose === "choice" && provider !== "speko") {
|
||
return <ChatConnectionPurpose provider={provider} slackLabels={slackNavigation.labels} onChat={() => setPurpose("chat")} onTools={() => navigate(toolHref)} />;
|
||
}
|
||
|
||
return (
|
||
<div className={automaticSlackComplete && step === tryStep ? "mx-auto w-full max-w-2xl space-y-6" : "max-w-2xl space-y-6"}>
|
||
<ChatSetupNavigation
|
||
labels={isSlack ? slackNavigation.labels : undefined}
|
||
step={isSlack ? slackNavigation.toVisible(step) : step}
|
||
availableStep={isSlack ? slackNavigation.toVisible(availableStep) : availableStep}
|
||
disabled={agentStepBusy || setupAction.isPending || generateSetupSecret.isPending || testConnection.isPending}
|
||
onSelect={value => setViewedStep(isSlack ? slackNavigation.toStage(value) : value)}
|
||
/>
|
||
<div className="min-w-0 space-y-6">
|
||
{step === 0 ? (
|
||
<>
|
||
<div>
|
||
<h1 className="text-xl font-bold">
|
||
Which agent do you want to chat with?
|
||
</h1>
|
||
<p className="mt-1 text-sm text-muted-foreground">
|
||
This agent is permanent for the connection. Connect another
|
||
channel to represent a different agent.
|
||
</p>
|
||
</div>
|
||
{endpoint ? (
|
||
<Input aria-label="Assigned agent" value={endpoint.assignedAgentName ?? selectedAgent?.name ?? agentId} readOnly />
|
||
) : <AgentSelect
|
||
agents={activeAgents}
|
||
value={agentId}
|
||
onChange={setAgentId}
|
||
placeholder="Choose an active agent"
|
||
emptyMessage="No active agents are available."
|
||
/>}
|
||
{isSlack && (selectedAgent || endpoint) && <SlackSetupAdvanced>
|
||
<SlackAppDetails value={slackDetails} readOnly={!slackDetailsEditable || agentStepBusy}
|
||
onChange={app => setSlackDraft({ agentId, app })}
|
||
onBlur={() => void persistSlackDetails().catch(() => {})} />
|
||
{saveSlackDetails.error && <p role="alert" className="text-sm text-destructive">Couldn't save the Slack app details. Try again before continuing.</p>}
|
||
{slackChoices.data?.defaultMethod === "managed" && (!endpoint || endpoint.setup?.slackSetupMethod === "managed") && !endpoint?.setup?.slackRegistration && <Button
|
||
variant="link" className="h-auto p-0 text-muted-foreground"
|
||
disabled={agentStepBusy || !slackDetailsValidation.success}
|
||
onClick={() => useOwnSlackApp.mutate()}
|
||
>Use your own app</Button>}
|
||
</SlackSetupAdvanced>}
|
||
{isSlack && !endpoint && !slackChoices.data && ((slackChoicesView.kind === "error")
|
||
? <div role="alert" className="text-sm text-destructive">Couldn't load Slack setup. <Button variant="link" disabled={slackChoices.isFetching} onClick={() => void slackChoices.refetch()}>Retry</Button></div>
|
||
: <p role="status" className="flex items-center gap-2 text-sm text-muted-foreground"><Loader2 className="size-4 animate-spin" />Loading Slack…</p>)}
|
||
{provider === "github" && <GitHubAgentTrustWarning agent={selectedAgent} />}
|
||
<SetupWizardFooter disabled={agentStepBusy || isSlack && !endpoint && Boolean(agentId) && !slackChoices.data} onSaveExit={() => void continueFromAgent(true)}>
|
||
<Button
|
||
disabled={!agentId || agentStepBusy || isSlack && (!slackDetailsValidation.success || !endpoint && !slackChoices.data)}
|
||
onClick={() => void continueFromAgent(false)}
|
||
>
|
||
{createEndpoint.isPending && (
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
)}
|
||
Continue
|
||
</Button>
|
||
</SetupWizardFooter>
|
||
</>
|
||
) : null}
|
||
{endpoint && !(automaticSlackComplete && step === tryStep) && (
|
||
<div hidden={step !== 1 && !(isSlack && (step === 2 || step === 3))} className="space-y-6">
|
||
{setupError ? (
|
||
<div
|
||
role="alert"
|
||
className="rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm text-destructive"
|
||
>
|
||
<p className="font-medium">Connection failed</p>
|
||
<p className="mt-1">{setupError}</p>
|
||
</div>
|
||
) : null}
|
||
<ProviderConnectStep
|
||
key={`${provider}:${endpoint.id}`}
|
||
provider={provider}
|
||
managedAutoStart={managedAutoStart && step !== 0}
|
||
slackStage={step === 1 ? "app" : step === 3 ? "finish" : "credentials"}
|
||
onSlackCredentialsContinue={() => setViewedStep(3)}
|
||
onSlackVerificationContinue={() => setViewedStep(4)}
|
||
slackVerificationError={slackVerificationView.kind === "error"}
|
||
onSlackAppCreated={() => {
|
||
setSlackCredentialsReady(true);
|
||
setViewedStep(2);
|
||
const resumed = new URLSearchParams(params);
|
||
resumed.set("resume", endpoint.id);
|
||
resumed.set("stage", "credentials");
|
||
setParams(resumed, { replace: true });
|
||
}}
|
||
agentName={selectedAgent?.name ?? endpoint.assignedAgentName}
|
||
endpoint={endpoint}
|
||
onEndpointSaved={syncEndpointSnapshot}
|
||
credentials={credentials}
|
||
setCredentials={setCredentials}
|
||
repairing={repairing}
|
||
pending={setupAction.isPending}
|
||
onAutomaticBusy={setAutomaticBusy}
|
||
generatedWebhookSecret={generatedWebhookSecret}
|
||
generatingSetupSecret={generateSetupSecret.isPending}
|
||
onGenerateSetupSecret={() => generateSetupSecret.mutate()}
|
||
onAction={(action, values) =>
|
||
setupAction.mutate({ action, values })
|
||
}
|
||
/>
|
||
</div>
|
||
)}
|
||
{endpoint && isSlack && !automaticSlack && step === 4 && (
|
||
<SlackIdentityStep
|
||
endpointId={endpoint.id}
|
||
command={endpoint.setup?.slackApp?.command ?? endpoint.setup?.command ?? "/paperclip"}
|
||
testStartedAt={endpoint.setup?.testStartedAt}
|
||
onSaveExit={() => navigate("/apps")}
|
||
onConnected={() => {
|
||
setSlackIdentityReady(true);
|
||
setViewedStep(5);
|
||
}}
|
||
/>
|
||
)}
|
||
{endpoint && step === tryStep && provider === "speko" && (
|
||
<div className="space-y-5">
|
||
<h1 className="text-xl font-bold">Test your Speko voice connection</h1>
|
||
<p className="text-sm text-muted-foreground">Ask the agent to do some work, add a spoken follow-up, and listen for its result. Accepted work stays in Paperclip when you end the call.</p>
|
||
<NativeVoiceConversation companyId={endpoint.companyId} endpointId={endpoint.id} agentName={selectedAgent?.name ?? endpoint.assignedAgentName} />
|
||
<SetupWizardFooter onSaveExit={() => navigate("/apps")}><Button disabled={testConnection.isPending} onClick={() => testConnection.mutate()}>Verify test conversation</Button></SetupWizardFooter>
|
||
</div>
|
||
)}
|
||
{endpoint && step === tryStep && provider !== "speko" && (!automaticSlack || automaticSlackComplete) && (
|
||
automaticSlack ? <div className="flex flex-col items-center gap-6 py-8 text-center">
|
||
<AgentCharacter
|
||
agent={selectedAgent ?? { id: endpoint.assignedAgentId, name: endpoint.assignedAgentName }}
|
||
size={256} state="success" trackingScope="page"
|
||
label={`${selectedAgent?.name ?? endpoint.assignedAgentName} avatar`}
|
||
/>
|
||
<div className="flex flex-col items-center gap-3">
|
||
<h1 className="flex items-center gap-2 text-2xl font-bold">
|
||
<CheckCircle2 aria-hidden="true" className="size-6 text-(--status-task-done)" />
|
||
Success
|
||
</h1>
|
||
<p className="flex flex-wrap items-center justify-center gap-2 text-base text-muted-foreground">
|
||
<span>You can now talk to {selectedAgent?.name ?? endpoint.assignedAgentName} in Slack</span>
|
||
<img src="/brands/apps/slack.svg" alt="" aria-hidden="true" className="size-6" />
|
||
</p>
|
||
<Button asChild variant="link"><a href={slackConversationUrl(endpoint, identitySettled ? userId : null)} target="_blank" rel="noopener noreferrer">Open Slack <ExternalLink className="size-4" /></a></Button>
|
||
</div>
|
||
<Button disabled={testConnection.isPending} onClick={finishSetup}>
|
||
{testConnection.isPending && <Loader2 className="size-4 animate-spin" />}Done
|
||
</Button>
|
||
</div> : <TryStep
|
||
endpointId={endpoint.id}
|
||
provider={provider}
|
||
agentName={selectedAgent?.name ?? endpoint.assignedAgentName}
|
||
botLabel={endpoint.botLabel}
|
||
botUsername={endpoint.botUsername}
|
||
slackAccount={endpoint.setup?.slackAccount}
|
||
photonAllocation={endpoint.photonAllocation}
|
||
providerUrl={endpoint.setup?.slackAccount?.dmChannelId && endpoint.providerAccountId ? `https://app.slack.com/client/${encodeURIComponent(endpoint.providerAccountId)}/${encodeURIComponent(endpoint.setup.slackAccount.dmChannelId)}` : endpoint.setup?.providerUrl}
|
||
guestIsolationState={
|
||
experimentalSettingsQuery.isPending || experimentalSettingsView.kind === "reconnecting"
|
||
? "loading"
|
||
: experimentalSettingsView.kind === "error"
|
||
? "unknown"
|
||
: experimentalSettingsQuery.data?.enableIsolatedWorkspaces ===
|
||
true
|
||
? "enabled"
|
||
: "disabled"
|
||
}
|
||
pending={testConnection.isPending}
|
||
onOpenAccess={() => navigate(`/apps/chat/${endpoint.id}/access`)}
|
||
onTest={finishSetup}
|
||
onSaveExit={() => navigate("/apps")}
|
||
/>
|
||
)}
|
||
{step !== 0 && !isSlack && provider !== "speko" && <div className="flex justify-start">
|
||
<Button className="text-muted-foreground" variant="ghost" onClick={() => navigate("/apps")}>
|
||
Save & exit
|
||
</Button>
|
||
</div>}
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ProviderConnectStep({
|
||
provider,
|
||
slackStage,
|
||
onSlackCredentialsContinue,
|
||
onSlackVerificationContinue,
|
||
slackVerificationError,
|
||
onSlackAppCreated,
|
||
agentName,
|
||
endpoint,
|
||
credentials,
|
||
setCredentials,
|
||
repairing,
|
||
pending,
|
||
onAutomaticBusy,
|
||
managedAutoStart = false,
|
||
onEndpointSaved,
|
||
generatedWebhookSecret,
|
||
generatingSetupSecret,
|
||
onGenerateSetupSecret,
|
||
onAction,
|
||
}: {
|
||
provider: ChatProvider;
|
||
slackStage: "app" | "credentials" | "finish";
|
||
onSlackCredentialsContinue: () => void;
|
||
onSlackVerificationContinue: () => void;
|
||
slackVerificationError: boolean;
|
||
onSlackAppCreated: () => void;
|
||
agentName: string;
|
||
endpoint: ChatEndpoint;
|
||
credentials: Record<string, string>;
|
||
setCredentials: Dispatch<SetStateAction<Record<string, string>>>;
|
||
repairing: boolean;
|
||
pending: boolean;
|
||
managedAutoStart?: boolean;
|
||
onAutomaticBusy: (busy: boolean) => void;
|
||
onEndpointSaved: (endpoint: ChatEndpoint) => void;
|
||
generatedWebhookSecret: string;
|
||
generatingSetupSecret: boolean;
|
||
onGenerateSetupSecret: () => void;
|
||
onAction: (
|
||
action: ChatEndpointSetupAction,
|
||
values?: Record<string, string>,
|
||
) => void;
|
||
}) {
|
||
const { userId, settled: identitySettled } = useAccountIdentity();
|
||
const { pushToast } = useToast();
|
||
const navigate = useNavigate();
|
||
const slackBotToken = (credentials.botToken ?? "").trim();
|
||
const slackBotTokenInvalid = provider === "slack" && slackBotToken.length > 0 &&
|
||
!slackBotToken.startsWith("xoxb-");
|
||
const slackSigningSecretHasTokenPrefix = provider === "slack" &&
|
||
/^x[a-z0-9]*-/i.test((credentials.signingSecret ?? "").trim());
|
||
const slackCredentialsSaved = provider === "slack" && Boolean(endpoint.providerAccountId);
|
||
const continueWithSavedSlackCredentials = slackCredentialsSaved && !repairing &&
|
||
!slackBotToken && !credentials.signingSecret?.trim();
|
||
const reportCopyFailure = () =>
|
||
pushToast({
|
||
title: "Couldn't copy to clipboard",
|
||
body: "Select and copy the value manually.",
|
||
tone: "error",
|
||
});
|
||
const field = (key: string, label: string, type = "password") => (
|
||
<label className="grid gap-2 text-sm font-medium">
|
||
{label}
|
||
<Input
|
||
type={type}
|
||
value={credentials[key] ?? ""}
|
||
onChange={(event) =>
|
||
setCredentials({ ...credentials, [key]: event.target.value })
|
||
}
|
||
/>
|
||
</label>
|
||
);
|
||
const openProviderSetup = (fallback: string) =>
|
||
window.open(
|
||
endpoint.setup?.authorizationUrl ??
|
||
endpoint.setup?.providerUrl ??
|
||
fallback,
|
||
"_blank",
|
||
"noopener,noreferrer",
|
||
);
|
||
const endpointValue = (label: string, value: string | null | undefined) => (
|
||
<div className="grid gap-2">
|
||
<p className="text-sm font-medium">{label}</p>
|
||
<div className="rounded-lg border border-border bg-muted p-3 font-mono text-xs break-all">
|
||
{value ??
|
||
"This endpoint is unavailable. Check the server's public URL."}
|
||
</div>
|
||
</div>
|
||
);
|
||
const [manifestCopied, setManifestCopied] = useState(false);
|
||
// A hook rather than a sticky boolean: this step stays mounted when the
|
||
// secret is regenerated, so a latched "copied" would keep vouching for a
|
||
// value the reader never copied. The status resets itself, and a refused
|
||
// clipboard reads as a failure instead of a success.
|
||
const webhookSecretCopy = useCopyAction();
|
||
const [openingSlackApp, setOpeningSlackApp] = useState(false);
|
||
const slackAdvanceTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||
useEffect(() => {
|
||
setOpeningSlackApp(false);
|
||
return () => {
|
||
if (slackAdvanceTimer.current !== null) {
|
||
clearTimeout(slackAdvanceTimer.current);
|
||
slackAdvanceTimer.current = null;
|
||
}
|
||
};
|
||
}, [slackStage]);
|
||
const [privateKeyVisible, setPrivateKeyVisible] = useState(false);
|
||
const [privateKeyFileError, setPrivateKeyFileError] = useState<string | null>(
|
||
null,
|
||
);
|
||
const [privateKeyFileLoaded, setPrivateKeyFileLoaded] = useState(false);
|
||
const [privateKeyFileLoading, setPrivateKeyFileLoading] = useState(false);
|
||
const privateKeyFileInputRef = useRef<HTMLInputElement>(null);
|
||
const privateKeyReadGuard = useRef(createGitHubPrivateKeyReadGuard()).current;
|
||
useEffect(
|
||
() => () => {
|
||
privateKeyReadGuard.invalidate();
|
||
},
|
||
[privateKeyReadGuard],
|
||
);
|
||
const loadPrivateKeyFile = async (event: ChangeEvent<HTMLInputElement>) => {
|
||
const file = event.currentTarget.files?.[0];
|
||
event.currentTarget.value = "";
|
||
if (!file) return;
|
||
const readRevision = privateKeyReadGuard.start();
|
||
setPrivateKeyFileError(null);
|
||
setPrivateKeyFileLoaded(false);
|
||
setPrivateKeyFileLoading(true);
|
||
try {
|
||
const privateKey = await readGitHubPrivateKeyFile(file);
|
||
if (!privateKeyReadGuard.isCurrent(readRevision)) return;
|
||
setCredentials((current) => ({ ...current, privateKey }));
|
||
setPrivateKeyVisible(false);
|
||
setPrivateKeyFileLoaded(true);
|
||
} catch (error) {
|
||
if (!privateKeyReadGuard.isCurrent(readRevision)) return;
|
||
setPrivateKeyFileError(
|
||
describeError(error).body,
|
||
);
|
||
} finally {
|
||
if (privateKeyReadGuard.isCurrent(readRevision)) {
|
||
setPrivateKeyFileLoading(false);
|
||
}
|
||
}
|
||
};
|
||
const replacePrivateKey = (privateKey: string) => {
|
||
privateKeyReadGuard.invalidate();
|
||
setPrivateKeyFileError(null);
|
||
setPrivateKeyFileLoaded(false);
|
||
setPrivateKeyFileLoading(false);
|
||
setCredentials((current) => ({ ...current, privateKey }));
|
||
};
|
||
const slackApp = endpoint.setup?.slackApp ?? {
|
||
...defaultSlackAppConfiguration(agentName),
|
||
...(endpoint.setup?.command ? { command: endpoint.setup.command } : {}),
|
||
};
|
||
const automaticSlack = isProvisionedSlackSetup(endpoint.setup?.slackSetupMethod);
|
||
const slackValidation = slackAppConfigurationSchema.safeParse(slackApp);
|
||
const saveSlackAndExit = () => navigate("/apps");
|
||
const slackAppName = slackApp.appName.trim();
|
||
const slackBotName = slackApp.botName.trim();
|
||
const slackCommand = slackApp.command.trim();
|
||
const slackWebhookUrl =
|
||
endpoint.setup?.webhookUrl ?? "<paperclip-webhook-url>";
|
||
const slackManifest = JSON.stringify(buildSlackAppManifest({
|
||
app: { appName: slackAppName, botName: slackBotName, command: slackCommand }, agentName,
|
||
webhookUrl: slackWebhookUrl,
|
||
...(automaticSlack && endpoint.setup?.slackOAuthCallbackUri ? { redirectUri: endpoint.setup.slackOAuthCallbackUri } : {}),
|
||
}), null, 2);
|
||
const slackCreateUrl = `https://api.slack.com/apps?new_app=1&manifest_json=${encodeURIComponent(slackManifest)}`;
|
||
useEffect(() => setManifestCopied(false), [slackManifest]);
|
||
const teamsClientId =
|
||
credentials.clientId?.trim() || "<application-client-id>";
|
||
const teamsManifestSettings = JSON.stringify(
|
||
{
|
||
bots: [
|
||
{
|
||
botId: teamsClientId,
|
||
scopes: ["personal", "team", "groupChat"],
|
||
supportsFiles: true,
|
||
isNotificationOnly: false,
|
||
commandLists: [
|
||
{
|
||
scopes: ["personal", "groupChat"],
|
||
commands: [
|
||
{
|
||
title: "/status",
|
||
description: "Show the active Paperclip task status",
|
||
},
|
||
{
|
||
title: "/new",
|
||
description: "Start a new Paperclip task in this chat",
|
||
},
|
||
{
|
||
title: "/close",
|
||
description: "Close the active chat conversation",
|
||
},
|
||
],
|
||
},
|
||
],
|
||
},
|
||
],
|
||
webApplicationInfo: {
|
||
id: teamsClientId,
|
||
resource: "https://paperclip.ing",
|
||
},
|
||
authorization: {
|
||
permissions: {
|
||
resourceSpecific: [
|
||
{ name: "ChannelMessage.Read.Group", type: "Application" },
|
||
{ name: "ChatMessage.Read.Chat", type: "Application" },
|
||
],
|
||
},
|
||
},
|
||
},
|
||
null,
|
||
2,
|
||
);
|
||
if (provider === "speko") return <SpekoProviderSetup onSaveExit={() => navigate("/apps")} agentName={agentName} credentials={credentials} onChange={setCredentials} onConnect={(values) => onAction(repairing ? "reconnect" : "configure", values)} callbackUrl={endpoint.setup?.webhookUrl} pending={pending} repairing={repairing} signingSecretConfigured={endpoint.setup?.webhookSecretConfigured} />;
|
||
if (provider === "imessage-photon") return <PhotonConnectStep endpoint={endpoint} agentName={agentName} repairing={repairing} pending={pending} onAction={onAction} />;
|
||
if (provider === "discord") {
|
||
const applicationId = credentials.applicationId?.trim() ?? "";
|
||
const guildId = credentials.guildId?.trim() ?? "";
|
||
const installUrl = applicationId
|
||
? `https://discord.com/oauth2/authorize?client_id=${encodeURIComponent(applicationId)}&permissions=309237763136&scope=bot${guildId ? `&guild_id=${encodeURIComponent(guildId)}&disable_guild_select=true` : ""}`
|
||
: null;
|
||
return (
|
||
<div className="space-y-5">
|
||
<div>
|
||
<h1 className="text-xl font-bold">Connect {agentName} to Discord</h1>
|
||
<p className="mt-1 text-sm text-muted-foreground">
|
||
{repairing
|
||
? "Reconnect verifies this same Discord application and server installation. It does not add or remove the bot from the server. Leave fields blank to reuse saved credentials."
|
||
: "Create one dedicated Discord application and bot for this Paperclip agent."}
|
||
</p>
|
||
</div>
|
||
<ol className="list-decimal space-y-2 pl-5 text-sm">
|
||
<li>
|
||
In Discord Developer Portal, create an application. Copy its
|
||
Application ID from General Information.
|
||
</li>
|
||
<li>
|
||
Open Bot, create the bot, enable Message Content Intent, then reset
|
||
and copy its token.
|
||
</li>
|
||
<li>
|
||
Enable Developer Mode in Discord, right-click the target server, and
|
||
copy its Server ID.
|
||
</li>
|
||
<li>
|
||
Enter those values below, then use the generated install link to add
|
||
the bot to that server.
|
||
</li>
|
||
</ol>
|
||
<Button
|
||
variant="outline"
|
||
onClick={() =>
|
||
openProviderSetup("https://discord.com/developers/applications")
|
||
}
|
||
>
|
||
Open Discord Developer Portal <ExternalLink />
|
||
</Button>
|
||
{field("applicationId", "Application ID", "text")}
|
||
{field("guildId", "Server ID", "text")}
|
||
{field("botToken", "Bot token")}
|
||
{installUrl && (
|
||
<Button asChild variant="outline">
|
||
<a href={installUrl} target="_blank" rel="noreferrer">
|
||
Install bot in this server <ExternalLink />
|
||
</a>
|
||
</Button>
|
||
)}
|
||
<p className="text-sm text-muted-foreground">
|
||
The install link grants only View Channels, Send Messages, Create
|
||
Public Threads, Send Messages in Threads, Read Message History, Add
|
||
Reactions, Embed Links, and Attach Files. Paperclip still requires
|
||
each discovered channel to be enabled in Access.
|
||
</p>
|
||
<Button
|
||
disabled={
|
||
(!repairing &&
|
||
(!credentials.applicationId ||
|
||
!credentials.guildId ||
|
||
!credentials.botToken)) ||
|
||
pending
|
||
}
|
||
onClick={() =>
|
||
onAction(repairing ? "reconnect" : "configure", credentials)
|
||
}
|
||
>
|
||
{pending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||
{repairing ? "Reconnect Discord bot" : "Connect Discord bot"}
|
||
</Button>
|
||
</div>
|
||
);
|
||
}
|
||
if (provider === "telegram")
|
||
return (
|
||
<div className="space-y-5">
|
||
<div>
|
||
<h1 className="text-xl font-bold">Create {agentName} in Telegram</h1>
|
||
<p className="mt-1 text-sm text-muted-foreground">
|
||
{repairing
|
||
? "Reconnect verifies this same BotFather bot and automatically refreshes its Paperclip webhook and command menu. It does not recreate the bot or change its chat memberships. Leave the token blank to reuse the saved credential."
|
||
: "Create a bot with BotFather, then paste the token it gives you."}
|
||
</p>
|
||
</div>
|
||
<ol className="list-decimal space-y-2 pl-5 text-sm">
|
||
<li>
|
||
Open BotFather and send <code>/newbot</code>.
|
||
</li>
|
||
<li>Enter the bot display name.</li>
|
||
<li>
|
||
Choose an available username ending in <code>bot</code>.
|
||
</li>
|
||
</ol>
|
||
<p className="rounded-md border border-border bg-muted/40 p-3 text-sm text-muted-foreground">
|
||
Paperclip works with Telegram's default bot privacy mode and
|
||
registers its command menu automatically. In a group, ordinary
|
||
mentions are not delivered to bots: start or continue work with{" "}
|
||
<code>/task@bot_username <request></code>, or reply directly to
|
||
a message from the bot.
|
||
</p>
|
||
<Button
|
||
variant="outline"
|
||
onClick={() => openProviderSetup("https://t.me/BotFather")}
|
||
>
|
||
Open BotFather <ExternalLink />
|
||
</Button>
|
||
{field("botToken", "Bot token")}
|
||
{(!endpoint.setup?.webhookUrl || automaticSlack && !endpoint.setup?.slackOAuthCallbackUri?.startsWith("https://")) && (
|
||
<p className="text-sm text-destructive">
|
||
Configure a public HTTPS URL for this Paperclip instance before
|
||
connecting Telegram.
|
||
</p>
|
||
)}
|
||
<Button
|
||
disabled={
|
||
(!repairing && !credentials.botToken) ||
|
||
!endpoint.setup?.webhookUrl ||
|
||
pending
|
||
}
|
||
onClick={() =>
|
||
onAction(repairing ? "reconnect" : "configure", credentials)
|
||
}
|
||
>
|
||
{pending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||
{repairing ? "Reconnect bot" : "Connect bot"}
|
||
</Button>
|
||
</div>
|
||
);
|
||
if (provider === "microsoft-teams")
|
||
return (
|
||
<div className="space-y-5">
|
||
<div>
|
||
<h1 className="text-xl font-bold">
|
||
Connect {agentName} to Microsoft Teams
|
||
</h1>
|
||
<p className="mt-1 text-sm text-muted-foreground">
|
||
{repairing
|
||
? "Reconnect verifies this same Microsoft app, tenant, and bot identity. It does not upload or reinstall the Teams app. Leave fields blank to reuse saved credentials."
|
||
: "Use your own Microsoft app credentials for this bot."}
|
||
</p>
|
||
</div>
|
||
<p className="rounded-md border border-border bg-muted/40 p-3 text-sm text-muted-foreground">
|
||
This setup requires a Microsoft 365 work or school organization where
|
||
you can register an Entra app, create an Azure Bot, and upload or
|
||
install a Teams app. Personal or free Teams accounts at teams.live.com
|
||
cannot complete this setup. This release supports Microsoft 365
|
||
commercial cloud tenants only; GCC, GCC High, DoD, and Microsoft 365
|
||
operated by 21Vianet are not supported yet.
|
||
</p>
|
||
<ol className="list-decimal space-y-2 pl-5 text-sm">
|
||
<li>
|
||
In Microsoft Entra, create a single-tenant app registration. Copy
|
||
its Application (client) ID and Directory (tenant) ID, then create a
|
||
client secret and copy its value.
|
||
</li>
|
||
<li>
|
||
In Azure, create an Azure Bot. Choose Single Tenant, use that
|
||
Application ID, set its messaging endpoint to the Paperclip URL
|
||
below, and add the Microsoft Teams channel.
|
||
</li>
|
||
<li>
|
||
In Teams Developer Portal, create an app, add a bot with the same
|
||
Application ID, then apply the manifest settings shown below. The
|
||
block binds the Teams resource-specific consent permissions to that
|
||
Entra app; these are not Microsoft Graph permissions in Entra. These
|
||
permissions let the installed app receive every message in a team or
|
||
group chat without an @mention, so describe that access to
|
||
installers. Download the package and install it in the target team
|
||
or group chat.
|
||
</li>
|
||
</ol>
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button asChild variant="outline">
|
||
<a
|
||
href="https://entra.microsoft.com/#view/Microsoft_AAD_RegisteredApps/ApplicationsListBlade"
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
>
|
||
Open Microsoft Entra <ExternalLink />
|
||
</a>
|
||
</Button>
|
||
<Button asChild variant="outline">
|
||
<a
|
||
href="https://portal.azure.com/#create/Microsoft.AzureBot"
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
>
|
||
Create Azure Bot <ExternalLink />
|
||
</a>
|
||
</Button>
|
||
<Button asChild variant="outline">
|
||
<a
|
||
href="https://dev.teams.microsoft.com/apps"
|
||
target="_blank"
|
||
rel="noreferrer"
|
||
>
|
||
Open Teams Developer Portal <ExternalLink />
|
||
</a>
|
||
</Button>
|
||
</div>
|
||
{endpointValue(
|
||
"Paperclip messaging endpoint",
|
||
endpoint.setup?.messagingEndpoint,
|
||
)}
|
||
{field("clientId", "Application / Client ID", "text")}
|
||
{field("tenantId", "Directory / Tenant ID", "text")}
|
||
{field("clientSecret", "Client secret value")}
|
||
<section className="space-y-3 rounded-lg border border-border p-4">
|
||
<div>
|
||
<h2 className="text-sm font-semibold">
|
||
Microsoft portal field map
|
||
</h2>
|
||
<p className="mt-1 text-sm text-muted-foreground">
|
||
Use these exact portal sections and reuse the same Application ID
|
||
in all three places.
|
||
</p>
|
||
</div>
|
||
<ol className="list-decimal space-y-3 pl-5 text-sm">
|
||
<li>
|
||
<strong>Microsoft Entra admin center · App registrations</strong>:
|
||
select <strong>New registration</strong>, choose{" "}
|
||
<strong>
|
||
Accounts in this organizational directory only (Single tenant)
|
||
</strong>
|
||
, then select <strong>Register</strong>. Copy{" "}
|
||
<strong>Application (client) ID</strong> and{" "}
|
||
<strong>Directory (tenant) ID</strong>. Under{" "}
|
||
<strong>Certificates & secrets · Client secrets</strong>,
|
||
select <strong>New client secret</strong> and copy its{" "}
|
||
<strong>Value</strong>, not its Secret ID.
|
||
</li>
|
||
<li>
|
||
<strong>Azure · Create Azure Bot</strong>: set{" "}
|
||
<strong>Microsoft App ID</strong> to{" "}
|
||
<strong>Single Tenant</strong>, set <strong>Creation type</strong>{" "}
|
||
to <strong>Use existing app registration</strong>, and enter the
|
||
Application ID and Tenant ID above. After creation, open{" "}
|
||
<strong>Settings · Configuration</strong> and paste the Paperclip{" "}
|
||
<strong>Messaging endpoint</strong>; then open{" "}
|
||
<strong>Settings · Channels</strong> and enable{" "}
|
||
<strong>Microsoft Teams</strong>.
|
||
</li>
|
||
<li>
|
||
<strong>Teams Developer Portal · Apps</strong>: select{" "}
|
||
<strong>New app</strong>. Under{" "}
|
||
<strong>Configure · App features · Bot</strong>, add an existing
|
||
bot using the same Application ID; enable{" "}
|
||
<strong>Personal</strong>, <strong>Team</strong>, and{" "}
|
||
<strong>Group chat</strong> scopes plus file support. Under{" "}
|
||
<strong>Configure · Permissions</strong>, add the two RSC{" "}
|
||
<strong>Application</strong> permissions shown below. Complete the
|
||
required app details and icons, explain that the app can receive
|
||
every message in an installed team or group chat, then download
|
||
the app package.
|
||
</li>
|
||
<li>
|
||
<strong>Microsoft Teams · Apps · Manage your apps</strong>: select{" "}
|
||
<strong>Upload an app · Upload a custom app</strong>, choose the
|
||
downloaded package, and install it in each intended personal chat,
|
||
group chat, or team. One team install covers its standard
|
||
channels. Private and shared channels require a separate app
|
||
installation and are not supported by this release. If upload is
|
||
unavailable, a Teams administrator must enable or approve custom
|
||
apps.
|
||
</li>
|
||
</ol>
|
||
</section>
|
||
<label className="grid gap-2 text-sm font-medium">
|
||
Required Teams app manifest block
|
||
<Textarea
|
||
className="min-h-80 font-mono text-xs"
|
||
readOnly
|
||
value={teamsManifestSettings}
|
||
/>
|
||
</label>
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button
|
||
variant="outline"
|
||
disabled={!credentials.clientId?.trim()}
|
||
onClick={() => {
|
||
void copyTextToClipboard(teamsManifestSettings).then(
|
||
() => setManifestCopied(true),
|
||
reportCopyFailure,
|
||
);
|
||
}}
|
||
>
|
||
{manifestCopied
|
||
? "Manifest settings copied"
|
||
: "Copy manifest settings"}
|
||
</Button>
|
||
</div>
|
||
<p className="text-sm text-muted-foreground">
|
||
Enter the Application / Client ID above before copying so the block
|
||
contains the real bot identity. This block contains the
|
||
Paperclip-specific fields to verify in Developer Portal or merge into
|
||
a complete Teams app manifest. It is not a complete app package;
|
||
Developer Portal supplies the remaining required metadata and packages
|
||
the manifest with your app icons.
|
||
</p>
|
||
<p className="text-sm text-muted-foreground">
|
||
Paperclip does not use Teams single sign-on in this release. The
|
||
copied <code>webApplicationInfo</code> entry only associates the RSC
|
||
permissions with the same Entra Application ID. Its nonempty resource
|
||
is an RSC placeholder; you do not need to register an Entra
|
||
Application ID URI or add delegated Microsoft Graph permissions.
|
||
</p>
|
||
<p className="text-sm text-muted-foreground">
|
||
The two application RSC permissions let the bot receive every message,
|
||
without an @mention, in each team or group chat where it is installed.
|
||
Paperclip retains and acts only on messages admitted by your Paperclip
|
||
reach and access rules. Make this provider access clear in the app
|
||
description shown to installers.
|
||
</p>
|
||
<p className="text-sm text-muted-foreground">
|
||
This release supports personal chats, group chats, and standard team
|
||
channels—not private channels. <code>supportsFiles: true</code>{" "}
|
||
enables native file receipt and consent-based sending in personal
|
||
chats; channel and group-chat files need a separate Microsoft Graph
|
||
connection and are not ingested here.
|
||
</p>
|
||
{!endpoint.setup?.messagingEndpoint && (
|
||
<p className="text-sm text-destructive">
|
||
Configure a public HTTPS URL for this Paperclip instance before
|
||
connecting Microsoft Teams.
|
||
</p>
|
||
)}
|
||
<Button
|
||
disabled={
|
||
(!repairing &&
|
||
(!credentials.clientId ||
|
||
!credentials.tenantId ||
|
||
!credentials.clientSecret)) ||
|
||
!endpoint.setup?.messagingEndpoint ||
|
||
pending
|
||
}
|
||
onClick={() =>
|
||
onAction(repairing ? "reconnect" : "configure", credentials)
|
||
}
|
||
>
|
||
{pending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||
{repairing
|
||
? "Reconnect Microsoft app"
|
||
: "Verify Microsoft credentials"}
|
||
</Button>
|
||
</div>
|
||
);
|
||
if (provider === "github")
|
||
return (
|
||
<div className="space-y-5">
|
||
<div>
|
||
<h1 className="text-xl font-bold">
|
||
{repairing
|
||
? "Reconnect GitHub App"
|
||
: "Create or connect a GitHub App"}
|
||
</h1>
|
||
<p className="mt-1 text-sm text-muted-foreground">
|
||
{repairing
|
||
? "Reconnect verifies this same App and installation, then updates its webhook URL, secret, and secure delivery settings. It does not reinstall the App or change repository access. Leave App ID and private key blank to reuse saved credentials. Keep Webhooks · Active enabled in GitHub; send a test conversation after reconnecting."
|
||
: "Configure its webhook and permissions, then verify the App with Paperclip."}
|
||
</p>
|
||
</div>
|
||
{!repairing && (
|
||
<ol className="list-decimal space-y-2 pl-5 text-sm">
|
||
<li>
|
||
Under the target user or organization, create a new GitHub App.
|
||
Give it a globally unique name (34 characters or fewer), use the
|
||
Paperclip homepage URL below, and leave user authorization off.
|
||
</li>
|
||
<li>
|
||
Keep <strong>Webhooks · Active</strong> on. Enter the Paperclip
|
||
webhook URL and the Paperclip-generated webhook secret below, and
|
||
keep <strong>Enable SSL verification</strong> selected.
|
||
</li>
|
||
<li>
|
||
Under Repository permissions, set <strong>Issues</strong> and{" "}
|
||
<strong>Pull requests</strong> to{" "}
|
||
<strong>Read & write</strong>. Leave every other permission at
|
||
its default; Metadata remains read-only.
|
||
</li>
|
||
<li>
|
||
Subscribe to <strong>Issue comment</strong> (
|
||
<code>issue_comment</code>),{" "}
|
||
<strong>Pull request review comment</strong> (
|
||
<code>pull_request_review_comment</code>). GitHub sends{" "}
|
||
<code>installation</code> and{" "}
|
||
<code>installation_repositories</code> to every GitHub App
|
||
automatically; they are not selectable here.
|
||
</li>
|
||
<li>
|
||
Choose <strong>Only on this account</strong>, create the App, copy
|
||
its App ID, generate one private key, then install it on the
|
||
selected repositories.
|
||
</li>
|
||
</ol>
|
||
)}
|
||
{endpointValue(
|
||
"Paperclip homepage URL",
|
||
publicOrigin(endpoint.setup?.webhookUrl),
|
||
)}
|
||
{endpointValue("Paperclip webhook URL", endpoint.setup?.webhookUrl)}
|
||
<Button
|
||
variant="outline"
|
||
onClick={() =>
|
||
window.open(
|
||
repairing
|
||
? "https://github.com/settings/apps"
|
||
: (endpoint.setup?.authorizationUrl ??
|
||
"https://github.com/settings/apps/new"),
|
||
"_blank",
|
||
"noopener,noreferrer",
|
||
)
|
||
}
|
||
>
|
||
{repairing ? "Open GitHub App settings" : "Open new GitHub App form"}{" "}
|
||
<ExternalLink />
|
||
</Button>
|
||
{field("appId", "GitHub App ID", "text")}
|
||
<div className="grid gap-2 text-sm font-medium">
|
||
<label htmlFor="github-private-key">Private key (PEM)</label>
|
||
<div className="relative">
|
||
{privateKeyVisible ? (
|
||
<Textarea
|
||
id="github-private-key"
|
||
className="min-h-24 pr-11 font-mono text-xs"
|
||
value={credentials.privateKey ?? ""}
|
||
onChange={(event) => replacePrivateKey(event.target.value)}
|
||
/>
|
||
) : (
|
||
<Input
|
||
id="github-private-key"
|
||
type="password"
|
||
className="pr-11 font-mono text-xs"
|
||
value={credentials.privateKey ?? ""}
|
||
onChange={(event) => replacePrivateKey(event.target.value)}
|
||
onPaste={(event) => {
|
||
event.preventDefault();
|
||
replacePrivateKey(event.clipboardData.getData("text"));
|
||
}}
|
||
/>
|
||
)}
|
||
<Button
|
||
type="button"
|
||
variant="ghost"
|
||
size="icon"
|
||
className="absolute right-1 top-1"
|
||
aria-label={
|
||
privateKeyVisible ? "Hide private key" : "Show private key"
|
||
}
|
||
onClick={() => setPrivateKeyVisible((visible) => !visible)}
|
||
>
|
||
{privateKeyVisible ? <EyeOff /> : <Eye />}
|
||
</Button>
|
||
</div>
|
||
<input
|
||
ref={privateKeyFileInputRef}
|
||
type="file"
|
||
accept=".pem,.key,application/x-pem-file,application/pkcs8,text/plain"
|
||
className="hidden"
|
||
aria-label="Choose GitHub App private key file"
|
||
onChange={loadPrivateKeyFile}
|
||
/>
|
||
<div>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={() => privateKeyFileInputRef.current?.click()}
|
||
>
|
||
Choose .pem file
|
||
</Button>
|
||
</div>
|
||
{privateKeyFileError ? (
|
||
<p role="alert" className="text-sm text-destructive">
|
||
{privateKeyFileError}
|
||
</p>
|
||
) : null}
|
||
{privateKeyFileLoading ? (
|
||
<p
|
||
role="status"
|
||
aria-live="polite"
|
||
className="text-sm text-muted-foreground"
|
||
>
|
||
Reading private key file…
|
||
</p>
|
||
) : privateKeyFileLoaded ? (
|
||
<p
|
||
role="status"
|
||
aria-live="polite"
|
||
className="text-sm text-muted-foreground"
|
||
>
|
||
Private key loaded. It stays in this form until you connect.
|
||
</p>
|
||
) : null}
|
||
</div>
|
||
<div className="grid gap-2">
|
||
<p className="text-sm font-medium">Webhook secret</p>
|
||
{generatedWebhookSecret ? (
|
||
<>
|
||
<Input
|
||
aria-label="Generated webhook secret"
|
||
className="font-mono text-xs"
|
||
readOnly
|
||
value={generatedWebhookSecret}
|
||
/>
|
||
<div className="flex flex-wrap gap-2">
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={() => {
|
||
// Both signals, and each covers the other's blind spot.
|
||
// The toast is the loud one, the way this step's other two
|
||
// copy buttons report failure — but the provider dedupes an
|
||
// identical toast inside 3.5s, so a reader who clicks twice
|
||
// on a blocked clipboard would see nothing the second time.
|
||
// The inline state answers every click.
|
||
void webhookSecretCopy
|
||
.copy(generatedWebhookSecret)
|
||
.then((status) => {
|
||
if (status === "failed") reportCopyFailure();
|
||
});
|
||
}}
|
||
>
|
||
{webhookSecretCopy.copied
|
||
? "Webhook secret copied"
|
||
: webhookSecretCopy.failed
|
||
? "Couldn’t copy — select it manually"
|
||
: "Copy webhook secret"}
|
||
</Button>
|
||
</div>
|
||
<p className="text-sm text-muted-foreground">
|
||
Copy this value now. Paperclip will not show it again.
|
||
</p>
|
||
</>
|
||
) : (
|
||
<p className="text-sm text-muted-foreground">
|
||
{endpoint.setup?.webhookSecretConfigured
|
||
? "A webhook secret is configured and cannot be shown again."
|
||
: "Generate the secret in Paperclip, then paste it into the GitHub App."}
|
||
</p>
|
||
)}
|
||
<div>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
disabled={generatingSetupSecret}
|
||
onClick={onGenerateSetupSecret}
|
||
>
|
||
{generatingSetupSecret && (
|
||
<Loader2 className="h-4 w-4 animate-spin" />
|
||
)}
|
||
{endpoint.setup?.webhookSecretConfigured
|
||
? "Regenerate webhook secret"
|
||
: "Generate webhook secret"}
|
||
</Button>
|
||
</div>
|
||
{endpoint.setup?.webhookSecretConfigured && (
|
||
<p className="text-sm text-muted-foreground">
|
||
{endpoint.providerAccountId || endpoint.botExternalId
|
||
? "Regenerating immediately invalidates GitHub webhook signatures until you replace the secret in the GitHub App settings."
|
||
: "Generating another secret replaces the previous value. Paste the newest value into GitHub before continuing."}
|
||
</p>
|
||
)}
|
||
{endpoint.setup?.webhookSecretConfigured && (
|
||
<p
|
||
className={`text-sm ${endpoint.setup.webhookVerifiedAt ? "text-foreground" : "text-muted-foreground"}`}
|
||
>
|
||
{endpoint.setup.webhookVerifiedAt
|
||
? "GitHub has verified this webhook."
|
||
: "Waiting for GitHub to deliver its signed webhook ping…"}
|
||
</p>
|
||
)}
|
||
</div>
|
||
{(!endpoint.setup?.webhookUrl || automaticSlack && !endpoint.setup?.slackOAuthCallbackUri?.startsWith("https://")) && (
|
||
<p className="text-sm text-destructive">
|
||
Configure a public HTTPS URL for this Paperclip instance before
|
||
connecting GitHub.
|
||
</p>
|
||
)}
|
||
<Button
|
||
disabled={
|
||
(!repairing && (!credentials.appId || !credentials.privateKey)) ||
|
||
!endpoint.setup?.webhookSecretConfigured ||
|
||
!endpoint.setup?.webhookVerifiedAt ||
|
||
!endpoint.setup?.webhookUrl ||
|
||
privateKeyFileLoading ||
|
||
generatingSetupSecret ||
|
||
pending
|
||
}
|
||
onClick={() =>
|
||
onAction(repairing ? "reconnect" : "configure", credentials)
|
||
}
|
||
>
|
||
{pending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||
{repairing ? "Reconnect and verify" : "Connect and verify"}
|
||
</Button>
|
||
</div>
|
||
);
|
||
const slackRequestUrlInstructions = <ol className="list-decimal space-y-2 pl-5 text-sm">
|
||
<li><a className="underline underline-offset-4" href={endpoint.setup?.slackRegistration?.appId ? `https://api.slack.com/apps/${encodeURIComponent(endpoint.setup.slackRegistration.appId)}/event-subscriptions` : "https://api.slack.com/apps"} target="_blank" rel="noopener noreferrer">Open Slack app Settings <ExternalLink className="inline size-3" /></a> and choose <strong>{slackApp.appName}</strong>.</li>
|
||
<li>Choose <strong>Event Subscriptions</strong>.</li>
|
||
<li>Beside the prefilled <strong>Request URL</strong>, click <strong>Retry</strong> if it isn't verified. Save changes if Slack asks.</li>
|
||
</ol>;
|
||
if (slackStage === "finish")
|
||
return (
|
||
<div className="space-y-5">
|
||
<div>
|
||
<h1 className="text-xl font-bold">{automaticSlack ? "Send a message to your agent" : "Verify Slack connection"}</h1>
|
||
{!automaticSlack && <p className="mt-1 text-sm text-muted-foreground">Slack needs to confirm that it can reach your Paperclip instance.</p>}
|
||
</div>
|
||
{automaticSlack ? <Button asChild variant="outline"><a href={slackConversationUrl(endpoint, identitySettled ? userId : null)} target="_blank" rel="noopener noreferrer">Open Slack <ExternalLink className="size-4" /></a></Button> : slackRequestUrlInstructions}
|
||
{endpoint.setup?.webhookVerifiedAt ? (
|
||
<p role="status" className="flex items-center gap-2 text-sm">
|
||
<CheckCircle2 className="size-4 text-(--status-task-done)" />
|
||
Your Slack connection is working.{pending ? automaticSlack ? " Finishing setup…" : " Opening the message test…" : ""}
|
||
</p>
|
||
) : slackVerificationError ? (
|
||
<p role="alert" className="text-sm text-destructive">Couldn't check verification. We'll keep trying; check your connection if this continues.</p>
|
||
) : (
|
||
<p role="status" className="flex items-center gap-2 text-sm text-muted-foreground">
|
||
<Loader2 className="size-4 animate-spin" /> {automaticSlack ? "Waiting for your message…" : "Waiting for Slack. We’ll continue automatically."}
|
||
</p>
|
||
)}
|
||
<SlackSetupAdvanced label={automaticSlack ? "Didn’t work?" : "Troubleshooting"}>
|
||
{automaticSlack && slackRequestUrlInstructions}
|
||
<p className="text-muted-foreground">If the Request URL is missing or different, paste this URL into Event Subscriptions. If verification fails, check that your public HTTPS server is reachable and your Signing Secret is correct.</p>
|
||
{endpointValue("Paperclip webhook URL", endpoint.setup?.webhookUrl)}
|
||
</SlackSetupAdvanced>
|
||
<div className="flex items-center justify-between gap-3">
|
||
<Button variant="ghost" className="text-muted-foreground" onClick={() => navigate("/apps")}>Save & exit</Button>
|
||
{(!automaticSlack || endpoint.setup?.webhookVerifiedAt) && <Button disabled={pending || !endpoint.setup?.webhookVerifiedAt} onClick={() =>
|
||
endpoint.setup?.step === "provider_setup" ? onAction("verify") : onSlackVerificationContinue()
|
||
}>
|
||
{pending && <Loader2 className="size-4 animate-spin" />}
|
||
Continue
|
||
</Button>}
|
||
</div>
|
||
</div>
|
||
);
|
||
|
||
return (
|
||
<div className="space-y-5">
|
||
{(!endpoint.setup?.webhookUrl || automaticSlack && !endpoint.setup?.slackOAuthCallbackUri?.startsWith("https://")) && (
|
||
<div role="alert" className="flex items-start gap-3 rounded-lg border border-destructive/30 bg-destructive/10 p-4">
|
||
<AlertTriangle className="size-5 shrink-0 text-destructive" />
|
||
<div className="space-y-1">
|
||
<p className="text-sm font-semibold">Public HTTPS URL required</p>
|
||
<p className="text-sm">
|
||
Slack needs a public HTTPS URL to send messages to Paperclip.
|
||
Configure one for this instance before creating or connecting your Slack app.
|
||
</p>
|
||
<a
|
||
href="https://docs.paperclip.ing/reference/deploy/https/"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
className="text-sm underline underline-offset-4"
|
||
>
|
||
Learn how to set up HTTPS
|
||
</a>
|
||
</div>
|
||
</div>
|
||
)}
|
||
<div>
|
||
<h1 className="text-xl font-bold">{endpoint.setup?.slackSetupMethod === "managed" ? "Connect to Slack" : slackStage === "app" ? automaticSlack ? "App configuration access token" : "Create a Slack app" : automaticSlack ? "Install Slack app" : "Add Slack credentials"}</h1>
|
||
{repairing && !automaticSlack && (
|
||
<p className="mt-1 text-sm text-muted-foreground">
|
||
Reconnect verifies or replaces credentials for this same Slack app. It does not reinstall the app or change its workspace or channel membership. Leave credentials blank to reuse the saved values.
|
||
</p>
|
||
)}
|
||
</div>
|
||
<div hidden={slackStage !== "app" || automaticSlack} className="space-y-5">
|
||
<div className="space-y-3 text-sm">
|
||
<div className="flex justify-end">
|
||
<Dialog>
|
||
<DialogTrigger asChild>
|
||
<Button variant="link" className="h-auto p-0 text-xs text-muted-foreground underline underline-offset-4">
|
||
View Slack App Manifest
|
||
</Button>
|
||
</DialogTrigger>
|
||
<DialogContent className="sm:max-w-2xl">
|
||
<DialogHeader>
|
||
<DialogTitle>Slack app manifest</DialogTitle>
|
||
<DialogDescription>
|
||
Generated from your app name, bot name, and slash command. Edit those fields to update the manifest.
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<Textarea
|
||
aria-label="Slack app manifest"
|
||
className="h-80 font-mono text-xs"
|
||
readOnly
|
||
value={slackManifest}
|
||
/>
|
||
<DialogFooter>
|
||
<Button
|
||
variant="outline"
|
||
disabled={!slackValidation.success}
|
||
onClick={() => {
|
||
void copyTextToClipboard(slackManifest).then(
|
||
() => setManifestCopied(true),
|
||
reportCopyFailure,
|
||
);
|
||
}}
|
||
>
|
||
{manifestCopied ? "Manifest copied" : "Copy manifest"}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</div>
|
||
</div>
|
||
{!automaticSlack && <div className="flex flex-wrap items-center justify-between gap-3 pt-3">
|
||
<Button variant="ghost" className="text-muted-foreground" onClick={() => void saveSlackAndExit()}>
|
||
Save & exit
|
||
</Button>
|
||
<div className="ml-auto flex flex-wrap items-center justify-end gap-2">
|
||
<Button variant="outline" disabled={openingSlackApp || !endpoint.setup?.webhookUrl || !slackValidation.success} onClick={onSlackAppCreated}>
|
||
I already created the app
|
||
</Button>
|
||
{!repairing && (
|
||
<Button
|
||
disabled={openingSlackApp || !endpoint.setup?.webhookUrl || !slackValidation.success}
|
||
onClick={() => {
|
||
window.open(slackCreateUrl, "_blank", "noopener,noreferrer");
|
||
setOpeningSlackApp(true);
|
||
// Let Slack open before changing the step behind its new tab.
|
||
slackAdvanceTimer.current = setTimeout(() => {
|
||
slackAdvanceTimer.current = null;
|
||
setOpeningSlackApp(false);
|
||
onSlackAppCreated();
|
||
}, 1000);
|
||
}}
|
||
>
|
||
Create Slack app <ExternalLink />
|
||
</Button>
|
||
)}
|
||
</div>
|
||
</div>}
|
||
</div>
|
||
{automaticSlack && <SlackSetup
|
||
key={endpoint.id}
|
||
autoStart={managedAutoStart}
|
||
reconnect={repairing}
|
||
endpoint={endpoint} stage={slackStage} disabled={!endpoint.setup?.webhookUrl || !endpoint.setup?.slackOAuthCallbackUri?.startsWith("https://") || !slackValidation.success}
|
||
saveDetails={async () => {}}
|
||
onBusy={onAutomaticBusy} onSaved={onEndpointSaved} onContinue={onSlackAppCreated}
|
||
onManual={async existing => {
|
||
onEndpointSaved(await chatEndpointsApi.update(endpoint.id, { slackSetupMethod: existing ? "existing" : "manual" }));
|
||
if (existing) onSlackAppCreated();
|
||
}}
|
||
onSaveExit={() => void saveSlackAndExit()}
|
||
/>}
|
||
{!automaticSlack && <div hidden={slackStage !== "credentials"} className="space-y-5">
|
||
<p className="text-sm">
|
||
Now you need to find two secrets. They are in two different screens on Slack.
|
||
</p>
|
||
{slackCredentialsSaved && !repairing && (
|
||
<p className="text-sm text-muted-foreground">Your credentials are saved. Leave the fields blank to keep them, or enter replacements.</p>
|
||
)}
|
||
<section className="space-y-3">
|
||
<h2 className="text-sm font-semibold"><label htmlFor="slack-bot-token">Bot User OAuth Token</label></h2>
|
||
<ul id="slack-bot-token-help" className="list-disc space-y-1 pl-5 text-sm">
|
||
<li>
|
||
<a href="https://api.slack.com/apps" target="_blank" rel="noopener noreferrer" className="underline underline-offset-4">
|
||
Open Slack app Settings <ExternalLink className="inline size-3" />
|
||
</a> and choose <strong>{slackApp.appName}</strong>.
|
||
</li>
|
||
<li>Choose <strong>OAuth & Permissions</strong></li>
|
||
<li>Copy and paste your <strong>Bot OAuth Token</strong></li>
|
||
</ul>
|
||
<Input
|
||
id="slack-bot-token"
|
||
type="password"
|
||
placeholder={slackCredentialsSaved ? "Saved — leave blank to keep" : undefined}
|
||
value={credentials.botToken ?? ""}
|
||
aria-invalid={slackBotTokenInvalid || undefined}
|
||
aria-describedby={`slack-bot-token-help${slackBotTokenInvalid ? " slack-bot-token-warning" : ""}`}
|
||
onChange={(event) => setCredentials({ ...credentials, botToken: event.target.value })}
|
||
/>
|
||
{slackBotTokenInvalid && (
|
||
<p id="slack-bot-token-warning" role="alert" className="text-sm text-destructive">
|
||
Your Bot User OAuth Token must start with <code>xoxb-</code>. Copy it from <strong>OAuth & Permissions</strong>.
|
||
</p>
|
||
)}
|
||
</section>
|
||
<section className="space-y-3">
|
||
<h2 className="text-sm font-semibold"><label htmlFor="slack-signing-secret">Signing Secret</label></h2>
|
||
<ul id="slack-signing-secret-help" className="list-disc space-y-1 pl-5 text-sm">
|
||
<li>
|
||
<a href="https://api.slack.com/apps" target="_blank" rel="noopener noreferrer" className="underline underline-offset-4">
|
||
Open Slack app Settings <ExternalLink className="inline size-3" />
|
||
</a> and choose <strong>{slackApp.appName}</strong>.
|
||
</li>
|
||
<li>Choose <strong>Basic Information</strong></li>
|
||
<li>Copy and paste your <strong>Signing Secret</strong></li>
|
||
</ul>
|
||
<Input
|
||
id="slack-signing-secret"
|
||
type="password"
|
||
placeholder={slackCredentialsSaved ? "Saved — leave blank to keep" : undefined}
|
||
value={credentials.signingSecret ?? ""}
|
||
aria-invalid={slackSigningSecretHasTokenPrefix || undefined}
|
||
aria-describedby={`slack-signing-secret-help${slackSigningSecretHasTokenPrefix ? " slack-signing-secret-warning" : ""}`}
|
||
onChange={(event) => setCredentials({ ...credentials, signingSecret: event.target.value })}
|
||
/>
|
||
{slackSigningSecretHasTokenPrefix && (
|
||
<p id="slack-signing-secret-warning" role="alert" className="text-sm text-destructive">
|
||
Use the <strong>Signing Secret</strong>, NOT an app or bot token. The Signing Secret has no token prefix.
|
||
</p>
|
||
)}
|
||
</section>
|
||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||
<Button variant="ghost" className="text-muted-foreground" onClick={() => navigate("/apps")}>
|
||
Save & exit
|
||
</Button>
|
||
<Button
|
||
className="ml-auto"
|
||
disabled={
|
||
(!repairing && !slackCredentialsSaved && (!slackBotToken || !credentials.signingSecret?.trim())) ||
|
||
!endpoint.setup?.webhookUrl || !slackValidation.success ||
|
||
pending || slackSigningSecretHasTokenPrefix || slackBotTokenInvalid
|
||
}
|
||
onClick={() => continueWithSavedSlackCredentials
|
||
? onSlackCredentialsContinue()
|
||
: onAction(repairing || slackCredentialsSaved ? "reconnect" : "configure", credentials)}
|
||
>
|
||
{pending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||
{continueWithSavedSlackCredentials ? "Continue" : repairing || slackCredentialsSaved ? "Reconnect Slack app" : "Connect Slack app"}
|
||
</Button>
|
||
</div>
|
||
</div>}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function TryStep({
|
||
endpointId,
|
||
provider,
|
||
agentName,
|
||
botLabel,
|
||
botUsername,
|
||
slackAccount,
|
||
photonAllocation,
|
||
providerUrl,
|
||
guestIsolationState,
|
||
pending,
|
||
onOpenAccess,
|
||
onTest,
|
||
onSaveExit,
|
||
}: {
|
||
endpointId: string;
|
||
provider: ChatProvider;
|
||
agentName: string;
|
||
botLabel?: string | null;
|
||
botUsername?: string | null;
|
||
slackAccount?: import("@paperclipai/shared").SlackAccountState;
|
||
photonAllocation?: "dedicated" | "shared";
|
||
providerUrl?: string | null;
|
||
guestIsolationState: "loading" | "enabled" | "disabled" | "unknown";
|
||
pending: boolean;
|
||
onOpenAccess: () => void;
|
||
onTest: () => void;
|
||
onSaveExit: () => void;
|
||
}) {
|
||
const [commandCopied, setCommandCopied] = useState(false);
|
||
const [commandCopyError, setCommandCopyError] = useState(false);
|
||
const principalsQuery = useQuery({
|
||
queryKey: queryKeys.chatEndpoints.principals(endpointId),
|
||
queryFn: () => chatEndpointsApi.listPrincipals(endpointId),
|
||
enabled: provider !== "slack",
|
||
refetchInterval: 1_500,
|
||
});
|
||
const principalsView = useQueryView(principalsQuery);
|
||
const principalsLoaded = principalsView.kind === "ready" || principalsView.kind === "stale";
|
||
const principalsFailed = principalsView.kind === "error";
|
||
const [numberCopied, setNumberCopied] = useState(false);
|
||
const [copyError, setCopyError] = useState<string | null>(null);
|
||
const identities = principalsQuery.data ?? [];
|
||
const unlinkedIdentities = identities.filter(
|
||
(identity) => identity.status !== "linked",
|
||
);
|
||
const freshConversationInstruction =
|
||
provider === "imessage-photon" ? "send a fresh message to your Photon number" : provider === "telegram"
|
||
? "start a fresh conversation with /new and send the test message again"
|
||
: provider === "github"
|
||
? "start a new issue or pull request conversation and mention the agent again"
|
||
: provider === "microsoft-teams"
|
||
? "start a new channel post and mention the agent again"
|
||
: "send a new root mention to the agent";
|
||
const identityGuidance = provider === "slack" ? null : provider === "imessage-photon" && principalsLoaded && (identities.length === 0 || unlinkedIdentities.length > 0)
|
||
? { tone: "info" as const, title: "Link your Messages identity", body: "Send one message to discover your phone number or Apple account address, then link that exact identity in Access. Send a fresh request after linking; earlier messages do not start work." }
|
||
: principalsFailed
|
||
? {
|
||
tone: "warning" as const,
|
||
title: "Identity readiness could not be checked",
|
||
body: `Review Access before expecting an agent reply. After linking the account you are testing, ${freshConversationInstruction}.`,
|
||
}
|
||
: !principalsLoaded || guestIsolationState === "loading"
|
||
? null
|
||
: identities.length === 0
|
||
? guestIsolationState === "disabled"
|
||
? {
|
||
tone: "warning" as const,
|
||
title: "Link the account you’re testing",
|
||
body:
|
||
provider === "telegram"
|
||
? "Tap Start in Telegram to discover your account; the welcome does not start an agent run. Link the account privately in Access, then return and send the test message."
|
||
: `Your first ${providerNames[provider]} message discovers the external account, but isolated guest work is off, so it cannot safely start ${agentName}. Send it once, link that account privately in Access, then ${freshConversationInstruction}.`,
|
||
}
|
||
: {
|
||
tone: "info" as const,
|
||
title: "Your first message identifies your account",
|
||
body:
|
||
provider === "telegram"
|
||
? "Tap Start in Telegram to discover your account. Until linked, it is a restricted guest and still needs a sandbox-backed isolated run; test that path intentionally, or link it in Access and then send the test message."
|
||
: `Until linked, the account is a restricted guest and still needs a sandbox-backed isolated run. Test that guest path intentionally, or link the account in Access and then ${freshConversationInstruction}.`,
|
||
}
|
||
: unlinkedIdentities.length > 0
|
||
? guestIsolationState === "disabled"
|
||
? {
|
||
tone: "warning" as const,
|
||
title: "Link the account you’re testing",
|
||
body: `An observed external account is unlinked, and isolated guest work is off, so it cannot safely start ${agentName}. Link the account in Access, then ${freshConversationInstruction}; Paperclip does not replay the refused request.`,
|
||
}
|
||
: {
|
||
tone: "info" as const,
|
||
title: "Unlinked identity detected",
|
||
body: `An unlinked account is a restricted guest and still needs a sandbox-backed isolated run. Test guest access intentionally, or link the account in Access and then ${freshConversationInstruction}.`,
|
||
}
|
||
: null;
|
||
const providerBotUsername = botUsername?.replace(/^@/, "");
|
||
const normalizedBotUsername =
|
||
provider === "github"
|
||
? providerBotUsername?.replace(/\[bot\]$/i, "")
|
||
: providerBotUsername;
|
||
const botMention = normalizedBotUsername
|
||
? `@${normalizedBotUsername}`
|
||
: (botLabel ?? agentName);
|
||
const slackTestMessage = slackAccount?.status === "linked" ? "you there?" : `${botMention.startsWith("@") ? botMention : `@${botMention}`} you there?`;
|
||
const instructions =
|
||
provider === "imessage-photon" ? [
|
||
photonAllocation === "shared" ? "In your Photon project, enroll your sender in Users and find its assigned number in Get started. Send a fresh message to that number from Apple Messages." : `Open Apple Messages and send a fresh message to ${botUsername ?? botLabel ?? "the dedicated number"}.`,
|
||
"Link the discovered sender to a Paperclip person in Access, then send a fresh request.",
|
||
"Wait for the agent’s actual reply. Setup completes after that reply is delivered.",
|
||
...(photonAllocation === "shared" ? ["This Pro-compatible channel supports DMs only. Group messages cannot start work."] : ["For a group: add the number in Messages, send a message, enable the discovered group in Settings, then send a fresh request."]),
|
||
] : provider === "discord"
|
||
? [
|
||
"Open a text channel where the bot is installed.",
|
||
`Mention ${botMention} in a new root message.`,
|
||
`Reply once inside ${agentName}'s new Discord thread.`,
|
||
]
|
||
: provider === "telegram"
|
||
? [
|
||
"Open the bot's private chat.",
|
||
"Tap Start.",
|
||
"Send “Help me test this”.",
|
||
]
|
||
: provider === "github"
|
||
? [
|
||
"Open an installed issue or pull request.",
|
||
`Mention ${botMention} in a comment.`,
|
||
"Add another comment to continue the same task.",
|
||
]
|
||
: provider === "microsoft-teams"
|
||
? [
|
||
"Open an installed channel and start a new post.",
|
||
`Mention ${botMention} in the post.`,
|
||
"Reply once beneath the post.",
|
||
]
|
||
: [
|
||
`Open a channel and invite ${botMention} if needed.`,
|
||
`Mention ${botMention} in a new channel message.`,
|
||
`Reply once in ${agentName}'s thread.`,
|
||
];
|
||
return (
|
||
<div className="space-y-5">
|
||
<div>
|
||
<h1 className="text-xl font-bold">
|
||
Try {agentName} in {providerNames[provider]}
|
||
</h1>
|
||
{provider !== "slack" && <p className="mt-1 text-sm text-muted-foreground">
|
||
Complete this real conversation to finish setup.
|
||
</p>}
|
||
</div>
|
||
{provider !== "slack" && (!principalsLoaded || guestIsolationState === "loading") &&
|
||
!principalsFailed ? (
|
||
<p role="status" className="text-sm text-muted-foreground">
|
||
Checking identity and guest readiness…
|
||
</p>
|
||
) : null}
|
||
{identityGuidance ? (
|
||
<div
|
||
role={identityGuidance.tone === "warning" ? "alert" : "status"}
|
||
className={
|
||
identityGuidance.tone === "warning"
|
||
? "rounded-lg border border-destructive/30 bg-destructive/10 p-4 text-sm"
|
||
: "rounded-lg border border-border bg-muted/30 p-4 text-sm"
|
||
}
|
||
>
|
||
<h2 className="font-medium">{identityGuidance.title}</h2>
|
||
<p className="mt-1 text-muted-foreground">{identityGuidance.body}</p>
|
||
<Button
|
||
className="mt-3"
|
||
size="sm"
|
||
variant="outline"
|
||
onClick={onOpenAccess}
|
||
>
|
||
Review identity access
|
||
</Button>
|
||
</div>
|
||
) : null}
|
||
{provider === "imessage-photon" && botUsername && <div className="space-y-2"><Button variant="outline" onClick={() => { void copyTextToClipboard(botUsername).then(() => { setNumberCopied(true); setCopyError(null); }, () => setCopyError("Could not copy the number. Select it in the instructions below.")); }}>{numberCopied ? "Number copied" : `Copy ${botUsername}`}</Button>{copyError && <p role="alert" className="text-sm text-destructive">{copyError}</p>}</div>}
|
||
{provider === "slack" ? (
|
||
<>
|
||
<ol className="list-decimal space-y-4 pl-5 text-sm">
|
||
<li>{slackAccount?.status === "linked" ? <Button asChild variant="outline"><a href={providerUrl ?? "https://app.slack.com"} target="_blank" rel="noopener noreferrer">{slackAccount.dmChannelId ? "Open your Slack DM" : "Open Slack"}<ExternalLink /></a></Button> : <>Open a channel and invite {botMention} if needed.</>}</li>
|
||
<li>
|
||
<div className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-border p-3">
|
||
<code>{slackTestMessage}</code>
|
||
<Button size="sm" variant="ghost" onClick={() => {
|
||
void copyTextToClipboard(slackTestMessage).then(() => { setCommandCopied(true); setCommandCopyError(false); }, () => setCommandCopyError(true));
|
||
}}><Copy className="size-4" />{commandCopied ? "Copied" : "Copy message"}</Button>
|
||
</div>
|
||
{slackAccount?.status !== "linked" && <p className="mt-2 text-muted-foreground">Select the bot from Slack’s @mention suggestions.</p>}
|
||
</li>
|
||
<li>Continue the conversation in the thread.</li>
|
||
</ol>
|
||
{commandCopyError && <p role="alert" className="text-sm text-destructive">Couldn't copy. Select and copy the command above.</p>}
|
||
<SetupWizardFooter onSaveExit={onSaveExit} disabled={pending}>
|
||
<Button disabled={pending} onClick={onTest}>{pending && <Loader2 className="size-4 animate-spin" />}Done</Button>
|
||
</SetupWizardFooter>
|
||
</>
|
||
) : <>
|
||
<ol className="list-decimal space-y-2 pl-5 text-sm">
|
||
{instructions.map((item) => <li key={item}>{item}</li>)}
|
||
</ol>
|
||
<div className="flex flex-wrap gap-2">
|
||
{providerUrl && (
|
||
<Button asChild variant="outline">
|
||
<a href={providerUrl} target="_blank" rel="noopener noreferrer">
|
||
Open {providerNames[provider]} <ExternalLink />
|
||
</a>
|
||
</Button>
|
||
)}
|
||
<Button disabled={pending} onClick={onTest}>
|
||
{pending && <Loader2 className="h-4 w-4 animate-spin" />}
|
||
I've sent the test message
|
||
</Button>
|
||
</div>
|
||
</>}
|
||
</div>
|
||
);
|
||
}
|