7f893ac4ec
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Reliable execution depends on heartbeat routing, issue lifecycle semantics, telemetry, and a fast enough local verification loop to keep regressions visible > - The remaining commits on this branch were mostly server/runtime correctness fixes plus test and documentation follow-ups in that area > - Those changes are logically separate from the UI-focused issue-detail and workspace/navigation branches even when they touch overlapping issue APIs > - This pull request groups the execution reliability, heartbeat, telemetry, and tooling changes into one standalone branch > - The benefit is a focused review of the control-plane correctness work, including the follow-up fix that restored the implicit comment-reopen helpers after branch splitting ## What Changed - Hardened issue/heartbeat execution behavior, including self-review stage skipping, deferred mention wakes during active execution, stranded execution recovery, active-run scoping, assignee resolution, and blocked-to-todo wake resumption - Reduced noisy polling/logging overhead by trimming issue run payloads, compacting persisted run logs, silencing high-volume request logs, and capping heartbeat-run queries in dashboard/inbox surfaces - Expanded telemetry and status semantics with adapter/model fields on task completion plus clearer status guidance in docs/onboarding material - Updated test infrastructure and verification defaults with faster route-test module isolation, cheaper default `pnpm test`, e2e isolation from local state, and repo verification follow-ups - Included docs/release housekeeping from the branch and added a small follow-up commit restoring the implicit comment-reopen helpers that were dropped during branch reconstruction ## Verification - `pnpm vitest run server/src/__tests__/issue-comment-reopen-routes.test.ts server/src/__tests__/issue-telemetry-routes.test.ts` - `pnpm vitest run server/src/__tests__/http-log-policy.test.ts server/src/__tests__/heartbeat-run-log.test.ts server/src/__tests__/health.test.ts` - `server/src/__tests__/activity-service.test.ts`, `server/src/__tests__/heartbeat-comment-wake-batching.test.ts`, and `server/src/__tests__/heartbeat-process-recovery.test.ts` were attempted on this host but the embedded Postgres harness reported init-script/data-dir problems and skipped or failed to start, so they are noted as environment-limited ## Risks - Medium: this branch changes core issue/heartbeat routing and reopen/wakeup behavior, so regressions would affect agent execution flow rather than isolated UI polish - Because it also updates verification infrastructure, reviewers should pay attention to whether the new tests are asserting the right failure modes and not just reshaping harness behavior ## Model Used - OpenAI Codex coding agent (GPT-5-class runtime in Codex CLI; exact deployed model ID is not exposed in this environment), reasoning enabled, tool use and local code execution enabled ## 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) - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
736 lines
27 KiB
TypeScript
736 lines
27 KiB
TypeScript
import { ChangeEvent, useEffect, useState } from "react";
|
|
import { Link } from "@/lib/router";
|
|
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
|
import { DEFAULT_FEEDBACK_DATA_SHARING_TERMS_VERSION } from "@paperclipai/shared";
|
|
import { useCompany } from "../context/CompanyContext";
|
|
import { useBreadcrumbs } from "../context/BreadcrumbContext";
|
|
import { useToastActions } from "../context/ToastContext";
|
|
import { companiesApi } from "../api/companies";
|
|
import { accessApi } from "../api/access";
|
|
import { assetsApi } from "../api/assets";
|
|
import { queryKeys } from "../lib/queryKeys";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Settings, Check, Download, Upload } from "lucide-react";
|
|
import { CompanyPatternIcon } from "../components/CompanyPatternIcon";
|
|
import {
|
|
Field,
|
|
ToggleField,
|
|
HintIcon
|
|
} from "../components/agent-config-primitives";
|
|
|
|
type AgentSnippetInput = {
|
|
onboardingTextUrl: string;
|
|
connectionCandidates?: string[] | null;
|
|
testResolutionUrl?: string | null;
|
|
};
|
|
|
|
const FEEDBACK_TERMS_URL = import.meta.env.VITE_FEEDBACK_TERMS_URL?.trim() || "https://paperclip.ing/tos";
|
|
|
|
export function CompanySettings() {
|
|
const {
|
|
companies,
|
|
selectedCompany,
|
|
selectedCompanyId,
|
|
setSelectedCompanyId
|
|
} = useCompany();
|
|
const { setBreadcrumbs } = useBreadcrumbs();
|
|
const { pushToast } = useToastActions();
|
|
const queryClient = useQueryClient();
|
|
// General settings local state
|
|
const [companyName, setCompanyName] = useState("");
|
|
const [description, setDescription] = useState("");
|
|
const [brandColor, setBrandColor] = useState("");
|
|
const [logoUrl, setLogoUrl] = useState("");
|
|
const [logoUploadError, setLogoUploadError] = useState<string | null>(null);
|
|
|
|
// Sync local state from selected company
|
|
useEffect(() => {
|
|
if (!selectedCompany) return;
|
|
setCompanyName(selectedCompany.name);
|
|
setDescription(selectedCompany.description ?? "");
|
|
setBrandColor(selectedCompany.brandColor ?? "");
|
|
setLogoUrl(selectedCompany.logoUrl ?? "");
|
|
}, [selectedCompany]);
|
|
|
|
const [inviteError, setInviteError] = useState<string | null>(null);
|
|
const [inviteSnippet, setInviteSnippet] = useState<string | null>(null);
|
|
const [snippetCopied, setSnippetCopied] = useState(false);
|
|
const [snippetCopyDelightId, setSnippetCopyDelightId] = useState(0);
|
|
|
|
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 });
|
|
}
|
|
});
|
|
|
|
const settingsMutation = useMutation({
|
|
mutationFn: (requireApproval: boolean) =>
|
|
companiesApi.update(selectedCompanyId!, {
|
|
requireBoardApprovalForNewAgents: requireApproval
|
|
}),
|
|
onSuccess: () => {
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
|
}
|
|
});
|
|
|
|
const feedbackSharingMutation = useMutation({
|
|
mutationFn: (enabled: boolean) =>
|
|
companiesApi.update(selectedCompanyId!, {
|
|
feedbackDataSharingEnabled: enabled,
|
|
}),
|
|
onSuccess: (_company, enabled) => {
|
|
queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
|
pushToast({
|
|
title: enabled ? "Feedback sharing enabled" : "Feedback sharing disabled",
|
|
tone: "success",
|
|
});
|
|
},
|
|
onError: (err) => {
|
|
pushToast({
|
|
title: "Failed to update feedback sharing",
|
|
body: err instanceof Error ? err.message : "Unknown error",
|
|
tone: "error",
|
|
});
|
|
},
|
|
});
|
|
|
|
const inviteMutation = useMutation({
|
|
mutationFn: () =>
|
|
accessApi.createOpenClawInvitePrompt(selectedCompanyId!),
|
|
onSuccess: async (invite) => {
|
|
setInviteError(null);
|
|
const base = window.location.origin.replace(/\/+$/, "");
|
|
const onboardingTextLink =
|
|
invite.onboardingTextUrl ??
|
|
invite.onboardingTextPath ??
|
|
`/api/invites/${invite.token}/onboarding.txt`;
|
|
const absoluteUrl = onboardingTextLink.startsWith("http")
|
|
? onboardingTextLink
|
|
: `${base}${onboardingTextLink}`;
|
|
setSnippetCopied(false);
|
|
setSnippetCopyDelightId(0);
|
|
let snippet: string;
|
|
try {
|
|
const manifest = await accessApi.getInviteOnboarding(invite.token);
|
|
snippet = buildAgentSnippet({
|
|
onboardingTextUrl: absoluteUrl,
|
|
connectionCandidates:
|
|
manifest.onboarding.connectivity?.connectionCandidates ?? null,
|
|
testResolutionUrl:
|
|
manifest.onboarding.connectivity?.testResolutionEndpoint?.url ??
|
|
null
|
|
});
|
|
} catch {
|
|
snippet = buildAgentSnippet({
|
|
onboardingTextUrl: absoluteUrl,
|
|
connectionCandidates: null,
|
|
testResolutionUrl: null
|
|
});
|
|
}
|
|
setInviteSnippet(snippet);
|
|
try {
|
|
await navigator.clipboard.writeText(snippet);
|
|
setSnippetCopied(true);
|
|
setSnippetCopyDelightId((prev) => prev + 1);
|
|
setTimeout(() => setSnippetCopied(false), 2000);
|
|
} catch {
|
|
/* clipboard may not be available */
|
|
}
|
|
queryClient.invalidateQueries({
|
|
queryKey: queryKeys.sidebarBadges(selectedCompanyId!)
|
|
});
|
|
},
|
|
onError: (err) => {
|
|
setInviteError(
|
|
err instanceof Error ? err.message : "Failed to create invite"
|
|
);
|
|
}
|
|
});
|
|
|
|
const syncLogoState = (nextLogoUrl: string | null) => {
|
|
setLogoUrl(nextLogoUrl ?? "");
|
|
void queryClient.invalidateQueries({ queryKey: queryKeys.companies.all });
|
|
};
|
|
|
|
const logoUploadMutation = useMutation({
|
|
mutationFn: (file: File) =>
|
|
assetsApi
|
|
.uploadCompanyLogo(selectedCompanyId!, file)
|
|
.then((asset) => companiesApi.update(selectedCompanyId!, { logoAssetId: asset.assetId })),
|
|
onSuccess: (company) => {
|
|
syncLogoState(company.logoUrl);
|
|
setLogoUploadError(null);
|
|
}
|
|
});
|
|
|
|
const clearLogoMutation = useMutation({
|
|
mutationFn: () => companiesApi.update(selectedCompanyId!, { logoAssetId: null }),
|
|
onSuccess: (company) => {
|
|
setLogoUploadError(null);
|
|
syncLogoState(company.logoUrl);
|
|
}
|
|
});
|
|
|
|
function handleLogoFileChange(event: ChangeEvent<HTMLInputElement>) {
|
|
const file = event.target.files?.[0] ?? null;
|
|
event.currentTarget.value = "";
|
|
if (!file) return;
|
|
setLogoUploadError(null);
|
|
logoUploadMutation.mutate(file);
|
|
}
|
|
|
|
function handleClearLogo() {
|
|
clearLogoMutation.mutate();
|
|
}
|
|
|
|
useEffect(() => {
|
|
setInviteError(null);
|
|
setInviteSnippet(null);
|
|
setSnippetCopied(false);
|
|
setSnippetCopyDelightId(0);
|
|
}, [selectedCompanyId]);
|
|
|
|
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
|
|
});
|
|
}
|
|
});
|
|
|
|
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>
|
|
);
|
|
}
|
|
|
|
function handleSaveGeneral() {
|
|
generalMutation.mutate({
|
|
name: companyName.trim(),
|
|
description: description.trim() || null,
|
|
brandColor: brandColor || null
|
|
});
|
|
}
|
|
|
|
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>
|
|
|
|
{/* General */}
|
|
<div className="space-y-4">
|
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
|
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>
|
|
</div>
|
|
</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}
|
|
logoUrl={logoUrl || null}
|
|
brandColor={brandColor || null}
|
|
className="rounded-[14px]"
|
|
/>
|
|
</div>
|
|
<div className="flex-1 space-y-3">
|
|
<Field
|
|
label="Logo"
|
|
hint="Upload a PNG, JPEG, WEBP, GIF, or SVG logo image."
|
|
>
|
|
<div className="space-y-2">
|
|
<input
|
|
type="file"
|
|
accept="image/png,image/jpeg,image/webp,image/gif,image/svg+xml"
|
|
onChange={handleLogoFileChange}
|
|
className="w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none file:mr-4 file:rounded-md file:border-0 file:bg-muted file:px-2.5 file:py-1 file:text-xs"
|
|
/>
|
|
{logoUrl && (
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={handleClearLogo}
|
|
disabled={clearLogoMutation.isPending}
|
|
>
|
|
{clearLogoMutation.isPending ? "Removing..." : "Remove logo"}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
{(logoUploadMutation.isError || logoUploadError) && (
|
|
<span className="text-xs text-destructive">
|
|
{logoUploadError ??
|
|
(logoUploadMutation.error instanceof Error
|
|
? logoUploadMutation.error.message
|
|
: "Logo upload failed")}
|
|
</span>
|
|
)}
|
|
{clearLogoMutation.isError && (
|
|
<span className="text-xs text-destructive">
|
|
{clearLogoMutation.error.message}
|
|
</span>
|
|
)}
|
|
{logoUploadMutation.isPending && (
|
|
<span className="text-xs text-muted-foreground">Uploading logo...</span>
|
|
)}
|
|
</div>
|
|
</Field>
|
|
<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>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Save button for General + Appearance */}
|
|
{generalDirty && (
|
|
<div className="flex items-center gap-2">
|
|
<Button
|
|
size="sm"
|
|
onClick={handleSaveGeneral}
|
|
disabled={generalMutation.isPending || !companyName.trim()}
|
|
>
|
|
{generalMutation.isPending ? "Saving..." : "Save changes"}
|
|
</Button>
|
|
{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" data-testid="company-settings-team-section">
|
|
<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)}
|
|
toggleTestId="company-settings-team-approval-toggle"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-4">
|
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
|
Feedback Sharing
|
|
</div>
|
|
<div className="space-y-3 rounded-md border border-border px-4 py-4">
|
|
<ToggleField
|
|
label="Allow sharing voted AI outputs with Paperclip Labs"
|
|
hint="Only AI-generated outputs you explicitly vote on are eligible for feedback sharing."
|
|
checked={!!selectedCompany.feedbackDataSharingEnabled}
|
|
onChange={(enabled) => feedbackSharingMutation.mutate(enabled)}
|
|
/>
|
|
<p className="text-sm text-muted-foreground">
|
|
Votes are always saved locally. This setting controls whether voted AI outputs may also be marked for sharing with Paperclip Labs.
|
|
</p>
|
|
<div className="space-y-1 text-xs text-muted-foreground">
|
|
<div>
|
|
Terms version: {selectedCompany.feedbackDataSharingTermsVersion ?? DEFAULT_FEEDBACK_DATA_SHARING_TERMS_VERSION}
|
|
</div>
|
|
{selectedCompany.feedbackDataSharingConsentAt ? (
|
|
<div>
|
|
Enabled {new Date(selectedCompany.feedbackDataSharingConsentAt).toLocaleString()}
|
|
{selectedCompany.feedbackDataSharingConsentByUserId
|
|
? ` by ${selectedCompany.feedbackDataSharingConsentByUserId}`
|
|
: ""}
|
|
</div>
|
|
) : (
|
|
<div>Sharing is currently disabled.</div>
|
|
)}
|
|
{FEEDBACK_TERMS_URL ? (
|
|
<a
|
|
href={FEEDBACK_TERMS_URL}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="inline-flex text-foreground underline underline-offset-4"
|
|
>
|
|
Read our terms of service
|
|
</a>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Invites */}
|
|
<div className="space-y-4" data-testid="company-settings-invites-section">
|
|
<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">
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="text-xs text-muted-foreground">
|
|
Generate an OpenClaw agent invite snippet.
|
|
</span>
|
|
<HintIcon text="Creates a short-lived OpenClaw agent invite and renders a copy-ready prompt." />
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<Button
|
|
data-testid="company-settings-invites-generate-button"
|
|
size="sm"
|
|
onClick={() => inviteMutation.mutate()}
|
|
disabled={inviteMutation.isPending}
|
|
>
|
|
{inviteMutation.isPending
|
|
? "Generating..."
|
|
: "Generate OpenClaw Invite Prompt"}
|
|
</Button>
|
|
</div>
|
|
{inviteError && (
|
|
<p className="text-sm text-destructive">{inviteError}</p>
|
|
)}
|
|
{inviteSnippet && (
|
|
<div
|
|
className="rounded-md border border-border bg-muted/30 p-2"
|
|
data-testid="company-settings-invites-snippet"
|
|
>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<div className="text-xs text-muted-foreground">
|
|
OpenClaw Invite Prompt
|
|
</div>
|
|
{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
|
|
data-testid="company-settings-invites-snippet-textarea"
|
|
className="h-[28rem] w-full rounded-md border border-border bg-background px-2 py-1.5 font-mono text-xs outline-none"
|
|
value={inviteSnippet}
|
|
readOnly
|
|
/>
|
|
<div className="flex justify-end">
|
|
<Button
|
|
data-testid="company-settings-invites-copy-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>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Import / Export */}
|
|
<div className="space-y-4">
|
|
<div className="text-xs font-medium text-muted-foreground uppercase tracking-wide">
|
|
Company Packages
|
|
</div>
|
|
<div className="rounded-md border border-border px-4 py-4">
|
|
<p className="text-sm text-muted-foreground">
|
|
Import and export have moved to dedicated pages accessible from the{" "}
|
|
<a href="/org" className="underline hover:text-foreground">Org Chart</a> header.
|
|
</p>
|
|
<div className="mt-3 flex items-center gap-2">
|
|
<Button size="sm" variant="outline" asChild>
|
|
<Link to="/company/export">
|
|
<Download className="mr-1.5 h-3.5 w-3.5" />
|
|
Export
|
|
</Link>
|
|
</Button>
|
|
<Button size="sm" variant="outline" asChild>
|
|
<Link to="/company/import">
|
|
<Upload className="mr-1.5 h-3.5 w-3.5" />
|
|
Import
|
|
</Link>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Danger Zone */}
|
|
<div className="space-y-4">
|
|
<div className="text-xs font-medium text-destructive uppercase tracking-wide">
|
|
Danger Zone
|
|
</div>
|
|
<div className="space-y-3 rounded-md border border-destructive/40 bg-destructive/5 px-4 py-4">
|
|
<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"
|
|
variant="destructive"
|
|
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>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function buildAgentSnippet(input: AgentSnippetInput) {
|
|
const candidateUrls = buildCandidateOnboardingUrls(input);
|
|
const resolutionTestUrl = buildResolutionTestUrl(input);
|
|
|
|
const candidateList =
|
|
candidateUrls.length > 0
|
|
? candidateUrls.map((u) => `- ${u}`).join("\n")
|
|
: "- (No candidate URLs available yet.)";
|
|
|
|
const connectivityBlock =
|
|
candidateUrls.length === 0
|
|
? `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`
|
|
: `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`;
|
|
|
|
const resolutionLine = resolutionTestUrl
|
|
? `\nYou MUST test Paperclip-to-gateway reachability, call: ${resolutionTestUrl}?url=<urlencoded-gateway-url> (using the hostname that worked above). Do not assume your 172.x is necessarily reachable from Paperclip. Test it. `
|
|
: "";
|
|
|
|
return `You're invited to join a Paperclip organization.
|
|
|
|
The URLs you should try are:
|
|
${candidateList}
|
|
|
|
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
|
|
|
|
${connectivityBlock}
|
|
|
|
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 gateway URL that Paperclip can access. Check your config and include that URL.${resolutionLine}
|
|
|
|
---
|
|
|
|
This onboarding flow is for OpenClaw Gateway.
|
|
When you submit the join request, use:
|
|
- \`adapterType: "openclaw_gateway"\`
|
|
- \`agentDefaultsPayload.url\` as your \`ws://\` or \`wss://\` gateway URL
|
|
- \`agentDefaultsPayload.headers["x-openclaw-token"]\` with your gateway token
|
|
- (legacy accepted) \`agentDefaultsPayload.headers["x-openclaw-auth"]\`
|
|
- Keep device auth enabled (recommended). If \`devicePrivateKeyPem\` is omitted, Paperclip will generate and persist one during join so pairing approvals remain stable.
|
|
- Only use \`disableDeviceAuth=true\` for special environments where pairing cannot be completed.
|
|
|
|
Do NOT use \`/v1/responses\` or \`/hooks/*\` in this join flow.
|
|
|
|
Before you do anything, please respond to your user that you understand the instructions and you're going to work on them. Then do the step above in another session called "paperclip-onboarding" and then tell your user when you're done. Update your user in intermediate steps along the way so they know what's going on.
|
|
|
|
Then after you've connected to Paperclip (exchanged keys etc.) you MUST review and follow the onboarding instructions in onboarding.txt they give you.
|
|
|
|
`;
|
|
}
|
|
|
|
function buildCandidateOnboardingUrls(input: AgentSnippetInput): string[] {
|
|
const candidates = (input.connectionCandidates ?? [])
|
|
.map((candidate) => candidate.trim())
|
|
.filter(Boolean);
|
|
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);
|
|
}
|
|
}
|
|
|
|
if (!onboardingUrl) {
|
|
for (const candidate of candidates) {
|
|
urls.add(candidate);
|
|
}
|
|
return Array.from(urls);
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|
|
|
|
return Array.from(urls);
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|