2026-02-26 16:30:12 -06:00
|
|
|
import { useEffect, useState } from "react";
|
2026-02-19 14:02:29 -06:00
|
|
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
|
|
|
import { useCompany } from "../context/CompanyContext";
|
|
|
|
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
|
|
|
|
import { companiesApi } from "../api/companies";
|
2026-02-23 14:41:21 -06:00
|
|
|
import { accessApi } from "../api/access";
|
2026-02-19 14:02:29 -06:00
|
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
|
|
|
import { Button } from "@/components/ui/button";
|
2026-03-05 13:12:07 -06:00
|
|
|
import { Settings, Check } from "lucide-react";
|
2026-02-26 16:30:12 -06:00
|
|
|
import { CompanyPatternIcon } from "../components/CompanyPatternIcon";
|
|
|
|
|
import { Field, ToggleField, HintIcon } from "../components/agent-config-primitives";
|
2026-02-19 14:02:29 -06:00
|
|
|
|
2026-03-05 12:52:39 -06:00
|
|
|
type AgentSnippetInput = {
|
2026-03-05 12:37:56 -06:00
|
|
|
onboardingTextUrl: string;
|
|
|
|
|
connectionCandidates?: string[] | null;
|
2026-03-05 13:05:04 -06:00
|
|
|
testResolutionUrl?: string | null;
|
2026-03-05 12:37:56 -06:00
|
|
|
};
|
|
|
|
|
|
2026-02-19 14:02:29 -06:00
|
|
|
export function CompanySettings() {
|
2026-03-02 10:31:54 -06:00
|
|
|
const { companies, selectedCompany, selectedCompanyId, setSelectedCompanyId } = useCompany();
|
2026-02-19 14:02:29 -06:00
|
|
|
const { setBreadcrumbs } = useBreadcrumbs();
|
|
|
|
|
const queryClient = useQueryClient();
|
2026-02-26 16:30:12 -06:00
|
|
|
|
|
|
|
|
// General settings local state
|
|
|
|
|
const [companyName, setCompanyName] = useState("");
|
|
|
|
|
const [description, setDescription] = useState("");
|
|
|
|
|
const [brandColor, setBrandColor] = useState("");
|
|
|
|
|
|
|
|
|
|
// Sync local state from selected company
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!selectedCompany) return;
|
|
|
|
|
setCompanyName(selectedCompany.name);
|
|
|
|
|
setDescription(selectedCompany.description ?? "");
|
|
|
|
|
setBrandColor(selectedCompany.brandColor ?? "");
|
|
|
|
|
}, [selectedCompany]);
|
|
|
|
|
|
2026-02-23 14:41:21 -06:00
|
|
|
const [inviteError, setInviteError] = useState<string | null>(null);
|
2026-03-05 12:37:56 -06:00
|
|
|
const [inviteSnippet, setInviteSnippet] = useState<string | null>(null);
|
|
|
|
|
const [snippetCopied, setSnippetCopied] = useState(false);
|
|
|
|
|
const [snippetCopyDelightId, setSnippetCopyDelightId] = useState(0);
|
2026-02-19 14:02:29 -06:00
|
|
|
|
2026-02-26 16:30:12 -06:00
|
|
|
const generalDirty =
|
|
|
|
|
!!selectedCompany &&
|
|
|
|
|
(companyName !== selectedCompany.name ||
|
|
|
|
|
description !== (selectedCompany.description ?? "") ||
|
|
|
|
|
brandColor !== (selectedCompany.brandColor ?? ""));
|
|
|
|
|
|
|
|
|
|
const generalMutation = useMutation({
|
|
|
|
|
mutationFn: (data: { name: string; description: string | null; brandColor: string | null }) =>
|
|
|
|
|
companiesApi.update(selectedCompanyId!, data),
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-19 14:02:29 -06:00
|
|
|
const settingsMutation = useMutation({
|
|
|
|
|
mutationFn: (requireApproval: boolean) =>
|
|
|
|
|
companiesApi.update(selectedCompanyId!, {
|
|
|
|
|
requireBoardApprovalForNewAgents: requireApproval,
|
|
|
|
|
}),
|
|
|
|
|
onSuccess: () => {
|
|
|
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2026-02-23 14:41:21 -06:00
|
|
|
const inviteMutation = useMutation({
|
|
|
|
|
mutationFn: () =>
|
|
|
|
|
accessApi.createCompanyInvite(selectedCompanyId!, {
|
2026-03-05 12:10:01 -06:00
|
|
|
allowedJoinTypes: "agent",
|
2026-02-26 16:30:12 -06:00
|
|
|
expiresInHours: 72,
|
2026-02-23 14:41:21 -06:00
|
|
|
}),
|
2026-03-05 12:00:38 -06:00
|
|
|
onSuccess: async (invite) => {
|
2026-02-23 14:41:21 -06:00
|
|
|
setInviteError(null);
|
|
|
|
|
const base = window.location.origin.replace(/\/+$/, "");
|
2026-03-05 12:10:01 -06:00
|
|
|
const onboardingTextLink = invite.onboardingTextUrl
|
|
|
|
|
?? invite.onboardingTextPath
|
|
|
|
|
?? `/api/invites/${invite.token}/onboarding.txt`;
|
|
|
|
|
const absoluteUrl = onboardingTextLink.startsWith("http")
|
|
|
|
|
? onboardingTextLink
|
|
|
|
|
: `${base}${onboardingTextLink}`;
|
2026-03-05 12:37:56 -06:00
|
|
|
setSnippetCopied(false);
|
|
|
|
|
setSnippetCopyDelightId(0);
|
|
|
|
|
try {
|
|
|
|
|
const manifest = await accessApi.getInviteOnboarding(invite.token);
|
2026-03-05 12:52:39 -06:00
|
|
|
setInviteSnippet(buildAgentSnippet({
|
2026-03-05 12:37:56 -06:00
|
|
|
onboardingTextUrl: absoluteUrl,
|
|
|
|
|
connectionCandidates: manifest.onboarding.connectivity?.connectionCandidates ?? null,
|
2026-03-05 13:05:04 -06:00
|
|
|
testResolutionUrl: manifest.onboarding.connectivity?.testResolutionEndpoint?.url ?? null,
|
2026-03-05 12:37:56 -06:00
|
|
|
}));
|
|
|
|
|
} catch {
|
2026-03-05 12:52:39 -06:00
|
|
|
setInviteSnippet(buildAgentSnippet({
|
2026-03-05 12:37:56 -06:00
|
|
|
onboardingTextUrl: absoluteUrl,
|
|
|
|
|
connectionCandidates: null,
|
2026-03-05 13:05:04 -06:00
|
|
|
testResolutionUrl: null,
|
2026-03-05 12:37:56 -06:00
|
|
|
}));
|
|
|
|
|
}
|
2026-02-23 14:41:21 -06:00
|
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.sidebarBadges(selectedCompanyId!) });
|
|
|
|
|
},
|
|
|
|
|
onError: (err) => {
|
|
|
|
|
setInviteError(err instanceof Error ? err.message : "Failed to create invite");
|
|
|
|
|
},
|
|
|
|
|
});
|
2026-03-05 12:10:01 -06:00
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
setInviteError(null);
|
2026-03-05 12:37:56 -06:00
|
|
|
setInviteSnippet(null);
|
|
|
|
|
setSnippetCopied(false);
|
|
|
|
|
setSnippetCopyDelightId(0);
|
2026-03-05 12:10:01 -06:00
|
|
|
}, [selectedCompanyId]);
|
2026-03-02 10:31:54 -06:00
|
|
|
const archiveMutation = useMutation({
|
|
|
|
|
mutationFn: ({
|
|
|
|
|
companyId,
|
|
|
|
|
nextCompanyId,
|
|
|
|
|
}: {
|
|
|
|
|
companyId: string;
|
|
|
|
|
nextCompanyId: string | null;
|
|
|
|
|
}) => companiesApi.archive(companyId).then(() => ({ nextCompanyId })),
|
|
|
|
|
onSuccess: async ({ nextCompanyId }) => {
|
|
|
|
|
if (nextCompanyId) {
|
|
|
|
|
setSelectedCompanyId(nextCompanyId);
|
|
|
|
|
}
|
|
|
|
|
await queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
|
|
|
|
await queryClient.invalidateQueries({ queryKey: queryKeys.companies.stats });
|
|
|
|
|
},
|
|
|
|
|
});
|
2026-02-23 14:41:21 -06:00
|
|
|
|
2026-02-19 14:02:29 -06:00
|
|
|
useEffect(() => {
|
|
|
|
|
setBreadcrumbs([
|
|
|
|
|
{ label: selectedCompany?.name ?? "Company", href: "/dashboard" },
|
|
|
|
|
{ label: "Settings" },
|
|
|
|
|
]);
|
|
|
|
|
}, [setBreadcrumbs, selectedCompany?.name]);
|
|
|
|
|
|
|
|
|
|
if (!selectedCompany) {
|
|
|
|
|
return (
|
|
|
|
|
<div className="text-sm text-muted-foreground">
|
|
|
|
|
No company selected. Select a company from the switcher above.
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-26 16:30:12 -06:00
|
|
|
function handleSaveGeneral() {
|
|
|
|
|
generalMutation.mutate({
|
|
|
|
|
name: companyName.trim(),
|
|
|
|
|
description: description.trim() || null,
|
|
|
|
|
brandColor: brandColor || null,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-19 14:02:29 -06:00
|
|
|
return (
|
|
|
|
|
<div className="max-w-2xl space-y-6">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Settings className="h-5 w-5 text-muted-foreground" />
|
|
|
|
|
<h1 className="text-lg font-semibold">Company Settings</h1>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-02-26 16:30:12 -06:00
|
|
|
{/* General */}
|
2026-02-19 14:02:29 -06:00
|
|
|
<div className="space-y-4">
|
|
|
|
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
2026-02-26 16:30:12 -06:00
|
|
|
General
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-3 rounded-md border border-border px-4 py-4">
|
|
|
|
|
<Field label="Company name" hint="The display name for your company.">
|
|
|
|
|
<input
|
|
|
|
|
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
|
|
|
|
|
type="text"
|
|
|
|
|
value={companyName}
|
|
|
|
|
onChange={(e) => setCompanyName(e.target.value)}
|
|
|
|
|
/>
|
|
|
|
|
</Field>
|
|
|
|
|
<Field label="Description" hint="Optional description shown in the company profile.">
|
|
|
|
|
<input
|
|
|
|
|
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
|
|
|
|
|
type="text"
|
|
|
|
|
value={description}
|
|
|
|
|
placeholder="Optional company description"
|
|
|
|
|
onChange={(e) => setDescription(e.target.value)}
|
|
|
|
|
/>
|
|
|
|
|
</Field>
|
2026-02-19 14:02:29 -06:00
|
|
|
</div>
|
2026-02-26 16:30:12 -06:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Appearance */}
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
|
|
|
|
Appearance
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-3 rounded-md border border-border px-4 py-4">
|
|
|
|
|
<div className="flex items-start gap-4">
|
|
|
|
|
<div className="shrink-0">
|
|
|
|
|
<CompanyPatternIcon
|
|
|
|
|
companyName={companyName || selectedCompany.name}
|
|
|
|
|
brandColor={brandColor || null}
|
|
|
|
|
className="rounded-[14px]"
|
|
|
|
|
/>
|
2026-02-19 14:02:29 -06:00
|
|
|
</div>
|
2026-02-26 16:30:12 -06:00
|
|
|
<div className="flex-1 space-y-2">
|
|
|
|
|
<Field label="Brand color" hint="Sets the hue for the company icon. Leave empty for auto-generated color.">
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<input
|
|
|
|
|
type="color"
|
|
|
|
|
value={brandColor || "#6366f1"}
|
|
|
|
|
onChange={(e) => setBrandColor(e.target.value)}
|
|
|
|
|
className="h-8 w-8 cursor-pointer rounded border border-border bg-transparent p-0"
|
|
|
|
|
/>
|
|
|
|
|
<input
|
|
|
|
|
type="text"
|
|
|
|
|
value={brandColor}
|
|
|
|
|
onChange={(e) => {
|
|
|
|
|
const v = e.target.value;
|
|
|
|
|
if (v === "" || /^#[0-9a-fA-F]{0,6}$/.test(v)) {
|
|
|
|
|
setBrandColor(v);
|
|
|
|
|
}
|
|
|
|
|
}}
|
|
|
|
|
placeholder="Auto"
|
|
|
|
|
className="w-28 rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm font-mono outline-none"
|
|
|
|
|
/>
|
|
|
|
|
{brandColor && (
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
onClick={() => setBrandColor("")}
|
|
|
|
|
className="text-xs text-muted-foreground"
|
|
|
|
|
>
|
|
|
|
|
Clear
|
|
|
|
|
</Button>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</Field>
|
2026-02-19 14:02:29 -06:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-02-26 16:30:12 -06:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Save button for General + Appearance */}
|
|
|
|
|
{generalDirty && (
|
|
|
|
|
<div className="flex items-center gap-2">
|
2026-02-19 14:02:29 -06:00
|
|
|
<Button
|
|
|
|
|
size="sm"
|
2026-02-26 16:30:12 -06:00
|
|
|
onClick={handleSaveGeneral}
|
|
|
|
|
disabled={generalMutation.isPending || !companyName.trim()}
|
2026-02-19 14:02:29 -06:00
|
|
|
>
|
2026-02-26 16:30:12 -06:00
|
|
|
{generalMutation.isPending ? "Saving..." : "Save changes"}
|
2026-02-19 14:02:29 -06:00
|
|
|
</Button>
|
2026-02-26 16:30:12 -06:00
|
|
|
{generalMutation.isSuccess && (
|
|
|
|
|
<span className="text-xs text-muted-foreground">Saved</span>
|
|
|
|
|
)}
|
|
|
|
|
{generalMutation.isError && (
|
|
|
|
|
<span className="text-xs text-destructive">
|
|
|
|
|
{generalMutation.error instanceof Error
|
|
|
|
|
? generalMutation.error.message
|
|
|
|
|
: "Failed to save"}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
{/* Hiring */}
|
|
|
|
|
<div className="space-y-4">
|
|
|
|
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
|
|
|
|
Hiring
|
|
|
|
|
</div>
|
|
|
|
|
<div className="rounded-md border border-border px-4 py-3">
|
|
|
|
|
<ToggleField
|
|
|
|
|
label="Require board approval for new hires"
|
|
|
|
|
hint="New agent hires stay pending until approved by board."
|
|
|
|
|
checked={!!selectedCompany.requireBoardApprovalForNewAgents}
|
|
|
|
|
onChange={(v) => settingsMutation.mutate(v)}
|
|
|
|
|
/>
|
2026-02-19 14:02:29 -06:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-02-23 14:41:21 -06:00
|
|
|
|
2026-02-26 16:30:12 -06:00
|
|
|
{/* Invites */}
|
2026-02-23 14:41:21 -06:00
|
|
|
<div className="space-y-4">
|
|
|
|
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
|
|
|
|
Invites
|
|
|
|
|
</div>
|
|
|
|
|
<div className="space-y-3 rounded-md border border-border px-4 py-4">
|
2026-02-26 16:30:12 -06:00
|
|
|
<div className="flex items-center gap-1.5">
|
2026-03-05 12:10:01 -06:00
|
|
|
<span className="text-xs text-muted-foreground">
|
2026-03-05 13:12:07 -06:00
|
|
|
Generate an agent snippet for join flows.
|
2026-03-05 12:10:01 -06:00
|
|
|
</span>
|
2026-03-05 13:12:07 -06:00
|
|
|
<HintIcon text="Creates an agent-only invite (72h) and renders a copy-ready snippet." />
|
2026-02-23 14:41:21 -06:00
|
|
|
</div>
|
2026-03-05 12:10:01 -06:00
|
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
|
|
|
<Button size="sm" onClick={() => inviteMutation.mutate()} disabled={inviteMutation.isPending}>
|
2026-03-05 13:12:07 -06:00
|
|
|
{inviteMutation.isPending ? "Generating..." : "Generate agent snippet"}
|
2026-03-05 12:10:01 -06:00
|
|
|
</Button>
|
|
|
|
|
</div>
|
2026-02-23 14:41:21 -06:00
|
|
|
{inviteError && <p className="text-sm text-destructive">{inviteError}</p>}
|
2026-03-05 12:37:56 -06:00
|
|
|
{inviteSnippet && (
|
|
|
|
|
<div className="rounded-md border border-border bg-muted/30 p-2">
|
|
|
|
|
<div className="flex items-center justify-between gap-2">
|
2026-03-05 12:52:39 -06:00
|
|
|
<div className="text-xs text-muted-foreground">Agent Snippet</div>
|
2026-03-05 12:37:56 -06:00
|
|
|
{snippetCopied && (
|
|
|
|
|
<span key={snippetCopyDelightId} className="flex items-center gap-1 text-xs text-green-600 animate-pulse">
|
|
|
|
|
<Check className="h-3 w-3" />
|
|
|
|
|
Copied
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="mt-1 space-y-1.5">
|
|
|
|
|
<textarea
|
2026-03-05 12:52:39 -06:00
|
|
|
className="h-[28rem] w-full rounded-md border border-border bg-background px-2 py-1.5 font-mono text-xs outline-none"
|
2026-03-05 12:37:56 -06:00
|
|
|
value={inviteSnippet}
|
|
|
|
|
readOnly
|
|
|
|
|
/>
|
|
|
|
|
<div className="flex justify-end">
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
|
|
|
|
variant="ghost"
|
|
|
|
|
onClick={async () => {
|
|
|
|
|
try {
|
|
|
|
|
await navigator.clipboard.writeText(inviteSnippet);
|
|
|
|
|
setSnippetCopied(true);
|
|
|
|
|
setSnippetCopyDelightId((prev) => prev + 1);
|
|
|
|
|
setTimeout(() => setSnippetCopied(false), 2000);
|
|
|
|
|
} catch { /* clipboard may not be available */ }
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{snippetCopied ? "Copied snippet" : "Copy snippet"}
|
|
|
|
|
</Button>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2026-02-23 14:41:21 -06:00
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-03-02 10:31:54 -06:00
|
|
|
|
2026-03-05 13:12:07 -06:00
|
|
|
{/* Danger Zone */}
|
2026-03-02 10:31:54 -06:00
|
|
|
<div className="space-y-4">
|
2026-03-05 13:12:07 -06:00
|
|
|
<div className="text-xs font-medium text-destructive uppercase tracking-wide">
|
|
|
|
|
Danger Zone
|
2026-03-02 10:31:54 -06:00
|
|
|
</div>
|
2026-03-05 13:12:07 -06:00
|
|
|
<div className="space-y-3 rounded-md border border-destructive/40 bg-destructive/5 px-4 py-4">
|
2026-03-02 10:31:54 -06:00
|
|
|
<p className="text-sm text-muted-foreground">
|
|
|
|
|
Archive this company to hide it from the sidebar. This persists in the database.
|
|
|
|
|
</p>
|
|
|
|
|
<div className="flex items-center gap-2">
|
|
|
|
|
<Button
|
|
|
|
|
size="sm"
|
2026-03-05 13:12:07 -06:00
|
|
|
variant="destructive"
|
2026-03-02 10:31:54 -06:00
|
|
|
disabled={archiveMutation.isPending || selectedCompany.status === "archived"}
|
|
|
|
|
onClick={() => {
|
|
|
|
|
if (!selectedCompanyId) return;
|
|
|
|
|
const confirmed = window.confirm(
|
|
|
|
|
`Archive company "${selectedCompany.name}"? It will be hidden from the sidebar.`,
|
|
|
|
|
);
|
|
|
|
|
if (!confirmed) return;
|
|
|
|
|
const nextCompanyId = companies.find((company) =>
|
|
|
|
|
company.id !== selectedCompanyId && company.status !== "archived")?.id ?? null;
|
|
|
|
|
archiveMutation.mutate({ companyId: selectedCompanyId, nextCompanyId });
|
|
|
|
|
}}
|
|
|
|
|
>
|
|
|
|
|
{archiveMutation.isPending
|
|
|
|
|
? "Archiving..."
|
|
|
|
|
: selectedCompany.status === "archived"
|
|
|
|
|
? "Already archived"
|
|
|
|
|
: "Archive company"}
|
|
|
|
|
</Button>
|
|
|
|
|
{archiveMutation.isError && (
|
|
|
|
|
<span className="text-xs text-destructive">
|
|
|
|
|
{archiveMutation.error instanceof Error
|
|
|
|
|
? archiveMutation.error.message
|
|
|
|
|
: "Failed to archive company"}
|
|
|
|
|
</span>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
2026-02-19 14:02:29 -06:00
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-03-05 12:37:56 -06:00
|
|
|
|
2026-03-05 12:52:39 -06:00
|
|
|
function buildAgentSnippet(input: AgentSnippetInput) {
|
|
|
|
|
const candidateUrls = buildCandidateOnboardingUrls(input);
|
|
|
|
|
const lines = ["You're invited to join a Paperclip organization.", ""];
|
2026-03-05 12:37:56 -06:00
|
|
|
|
2026-03-05 12:52:39 -06:00
|
|
|
lines.push("The URLs you should try are:");
|
|
|
|
|
if (candidateUrls.length > 0) {
|
|
|
|
|
for (const candidate of candidateUrls) {
|
|
|
|
|
lines.push(`- ${candidate}`);
|
|
|
|
|
}
|
|
|
|
|
} else {
|
|
|
|
|
lines.push("- (No candidate URLs available yet.)");
|
|
|
|
|
}
|
2026-03-05 12:37:56 -06:00
|
|
|
|
2026-03-05 12:52:39 -06:00
|
|
|
lines.push(
|
|
|
|
|
"",
|
|
|
|
|
"Connectivity guidance:",
|
|
|
|
|
"If you are running on a different machine than Paperclip, Paperclip must be reachable at one of the hostnames used above.",
|
|
|
|
|
"Verify the hostname works from your runtime with: GET <base-url>/api/health",
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (candidateUrls.length === 0) {
|
|
|
|
|
lines.push(
|
|
|
|
|
"",
|
|
|
|
|
"No candidate URLs are available. Ask your user to configure a reachable hostname in Paperclip, then retry.",
|
|
|
|
|
"Suggested steps:",
|
|
|
|
|
"- choose a hostname that resolves to the Paperclip host from your runtime",
|
|
|
|
|
"- run: pnpm paperclipai allowed-hostname <host>",
|
|
|
|
|
"- restart Paperclip",
|
|
|
|
|
"- verify with: curl -fsS http://<host>:3100/api/health",
|
|
|
|
|
"- regenerate this invite snippet",
|
|
|
|
|
);
|
|
|
|
|
} else {
|
|
|
|
|
lines.push(
|
|
|
|
|
"",
|
|
|
|
|
"If none are reachable, ask your user to add a reachable hostname in Paperclip, restart, and retry.",
|
|
|
|
|
"Suggested command:",
|
|
|
|
|
"- pnpm paperclipai allowed-hostname <host>",
|
|
|
|
|
"Then verify with: curl -fsS <base-url>/api/health",
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 13:05:04 -06:00
|
|
|
const resolutionTestUrl = buildResolutionTestUrl(input);
|
|
|
|
|
lines.push(
|
|
|
|
|
"",
|
|
|
|
|
"For your \"agentName\", just use your own agent name (OpenClaw or whatever your name is) and send it directly.",
|
|
|
|
|
"You also need to send a webhook URL that Paperclip can access. Check your config and include that URL.",
|
|
|
|
|
);
|
|
|
|
|
if (resolutionTestUrl) {
|
|
|
|
|
lines.push(
|
|
|
|
|
`If you need to test callback reachability, call: ${resolutionTestUrl}?url=<urlencoded-webhook-url>`,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-03-05 12:52:39 -06:00
|
|
|
return `${lines.join("\n")}\n`;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function buildCandidateOnboardingUrls(input: AgentSnippetInput): string[] {
|
2026-03-05 12:37:56 -06:00
|
|
|
const candidates = (input.connectionCandidates ?? [])
|
|
|
|
|
.map((candidate) => candidate.trim())
|
|
|
|
|
.filter(Boolean);
|
2026-03-05 12:52:39 -06:00
|
|
|
const urls = new Set<string>();
|
|
|
|
|
let onboardingUrl: URL | null = null;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
onboardingUrl = new URL(input.onboardingTextUrl);
|
|
|
|
|
urls.add(onboardingUrl.toString());
|
|
|
|
|
} catch {
|
|
|
|
|
const trimmed = input.onboardingTextUrl.trim();
|
|
|
|
|
if (trimmed) {
|
|
|
|
|
urls.add(trimmed);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-05 12:37:56 -06:00
|
|
|
|
2026-03-05 12:52:39 -06:00
|
|
|
if (!onboardingUrl) {
|
2026-03-05 12:37:56 -06:00
|
|
|
for (const candidate of candidates) {
|
2026-03-05 12:52:39 -06:00
|
|
|
urls.add(candidate);
|
2026-03-05 12:37:56 -06:00
|
|
|
}
|
2026-03-05 12:52:39 -06:00
|
|
|
return Array.from(urls);
|
2026-03-05 12:37:56 -06:00
|
|
|
}
|
|
|
|
|
|
2026-03-05 12:52:39 -06:00
|
|
|
const onboardingPath = `${onboardingUrl.pathname}${onboardingUrl.search}`;
|
|
|
|
|
for (const candidate of candidates) {
|
|
|
|
|
try {
|
|
|
|
|
const base = new URL(candidate);
|
|
|
|
|
urls.add(`${base.origin}${onboardingPath}`);
|
|
|
|
|
} catch {
|
|
|
|
|
urls.add(candidate);
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-03-05 12:37:56 -06:00
|
|
|
|
2026-03-05 12:52:39 -06:00
|
|
|
return Array.from(urls);
|
2026-03-05 12:37:56 -06:00
|
|
|
}
|
2026-03-05 13:05:04 -06:00
|
|
|
|
|
|
|
|
function buildResolutionTestUrl(input: AgentSnippetInput): string | null {
|
|
|
|
|
const explicit = input.testResolutionUrl?.trim();
|
|
|
|
|
if (explicit) return explicit;
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
const onboardingUrl = new URL(input.onboardingTextUrl);
|
|
|
|
|
const testPath = onboardingUrl.pathname.replace(/\/onboarding\.txt$/, "/test-resolution");
|
|
|
|
|
return `${onboardingUrl.origin}${testPath}`;
|
|
|
|
|
} catch {
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
}
|