2026-03-13 22:29:30 -05:00
import { useEffect , useMemo , useRef , useState , type ChangeEvent } from "react" ;
2026-02-19 14:02:29 -06:00
import { useMutation , useQueryClient } from "@tanstack/react-query" ;
2026-03-13 22:29:30 -05:00
import type {
CompanyPortabilityCollisionStrategy ,
CompanyPortabilityExportResult ,
CompanyPortabilityPreviewRequest ,
CompanyPortabilityPreviewResult ,
CompanyPortabilitySource ,
} from "@paperclipai/shared" ;
2026-02-19 14:02:29 -06:00
import { useCompany } from "../context/CompanyContext" ;
import { useBreadcrumbs } from "../context/BreadcrumbContext" ;
2026-03-13 22:29:30 -05:00
import { useToast } from "../context/ToastContext" ;
2026-02-19 14:02:29 -06:00
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-13 22:29:30 -05:00
import { Settings , Check , Download , Github , Link2 , Upload } from "lucide-react" ;
2026-02-26 16:30:12 -06:00
import { CompanyPatternIcon } from "../components/CompanyPatternIcon" ;
2026-03-06 08:42:28 -06:00
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-06 08:42:28 -06:00
const {
companies ,
selectedCompany ,
selectedCompanyId ,
setSelectedCompanyId
} = useCompany ( ) ;
2026-02-19 14:02:29 -06:00
const { setBreadcrumbs } = useBreadcrumbs ( ) ;
2026-03-13 22:29:30 -05:00
const { pushToast } = useToast ( ) ;
2026-02-19 14:02:29 -06:00
const queryClient = useQueryClient ( ) ;
2026-03-13 22:29:30 -05:00
const packageInputRef = useRef < HTMLInputElement | null > ( null ) ;
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-03-13 22:29:30 -05:00
const [ packageIncludeCompany , setPackageIncludeCompany ] = useState ( true ) ;
const [ packageIncludeAgents , setPackageIncludeAgents ] = useState ( true ) ;
const [ importSourceMode , setImportSourceMode ] = useState < "github" | "url" | "local" > ( "github" ) ;
const [ importUrl , setImportUrl ] = useState ( "" ) ;
const [ importTargetMode , setImportTargetMode ] = useState < "existing" | "new" > ( "existing" ) ;
const [ newCompanyName , setNewCompanyName ] = useState ( "" ) ;
const [ collisionStrategy , setCollisionStrategy ] = useState < CompanyPortabilityCollisionStrategy > ( "rename" ) ;
const [ localPackage , setLocalPackage ] = useState < {
rootPath : string | null ;
files : Record < string , string > ;
} | null > ( null ) ;
const [ importPreview , setImportPreview ] = useState < CompanyPortabilityPreviewResult | null > ( null ) ;
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 ? ? "" ) ) ;
2026-03-13 22:29:30 -05:00
const packageInclude = useMemo (
( ) = > ( {
company : packageIncludeCompany ,
agents : packageIncludeAgents
} ) ,
[ packageIncludeAgents , packageIncludeCompany ]
) ;
const importSource = useMemo < CompanyPortabilitySource | null > ( ( ) = > {
if ( importSourceMode === "local" ) {
if ( ! localPackage || Object . keys ( localPackage . files ) . length === 0 ) return null ;
return {
type : "inline" ,
rootPath : localPackage.rootPath ,
files : localPackage.files
} ;
}
const trimmed = importUrl . trim ( ) ;
if ( ! trimmed ) return null ;
return importSourceMode === "github"
? { type : "github" , url : trimmed }
: { type : "url" , url : trimmed } ;
} , [ importSourceMode , importUrl , localPackage ] ) ;
const importPayload = useMemo < CompanyPortabilityPreviewRequest | null > ( ( ) = > {
if ( ! importSource ) return null ;
return {
source : importSource ,
include : packageInclude ,
target :
importTargetMode === "new"
? {
mode : "new_company" ,
newCompanyName : newCompanyName.trim ( ) || null
}
: {
mode : "existing_company" ,
companyId : selectedCompanyId !
} ,
agents : "all" ,
collisionStrategy
} ;
} , [
collisionStrategy ,
importSource ,
importTargetMode ,
newCompanyName ,
packageInclude ,
selectedCompanyId
] ) ;
2026-02-26 16:30:12 -06:00
const generalMutation = useMutation ( {
2026-03-06 08:42:28 -06:00
mutationFn : ( data : {
name : string ;
description : string | null ;
brandColor : string | null ;
} ) = > companiesApi . update ( selectedCompanyId ! , data ) ,
2026-02-26 16:30:12 -06:00
onSuccess : ( ) = > {
queryClient . invalidateQueries ( { queryKey : queryKeys.companies.all } ) ;
2026-03-06 08:42:28 -06:00
}
2026-02-26 16:30:12 -06:00
} ) ;
2026-02-19 14:02:29 -06:00
const settingsMutation = useMutation ( {
mutationFn : ( requireApproval : boolean ) = >
companiesApi . update ( selectedCompanyId ! , {
2026-03-06 08:42:28 -06:00
requireBoardApprovalForNewAgents : requireApproval
2026-02-19 14:02:29 -06:00
} ) ,
onSuccess : ( ) = > {
queryClient . invalidateQueries ( { queryKey : queryKeys.companies.all } ) ;
2026-03-06 08:42:28 -06:00
}
2026-02-19 14:02:29 -06:00
} ) ;
2026-03-13 22:29:30 -05:00
const exportMutation = useMutation ( {
mutationFn : ( ) = >
companiesApi . exportBundle ( selectedCompanyId ! , {
include : packageInclude
} ) ,
onSuccess : async ( exported ) = > {
await downloadCompanyPackage ( exported ) ;
pushToast ( {
tone : "success" ,
title : "Company package exported" ,
body : ` ${ exported . rootPath } .tar downloaded with ${ Object . keys ( exported . files ) . length } file ${ Object . keys ( exported . files ) . length === 1 ? "" : "s" } . `
} ) ;
if ( exported . warnings . length > 0 ) {
pushToast ( {
tone : "warn" ,
title : "Export completed with warnings" ,
body : exported.warnings [ 0 ]
} ) ;
}
} ,
onError : ( err ) = > {
pushToast ( {
tone : "error" ,
title : "Export failed" ,
body : err instanceof Error ? err . message : "Failed to export company package"
} ) ;
}
} ) ;
const previewImportMutation = useMutation ( {
mutationFn : ( payload : CompanyPortabilityPreviewRequest ) = >
companiesApi . importPreview ( payload ) ,
onSuccess : ( preview ) = > {
setImportPreview ( preview ) ;
if ( preview . errors . length > 0 ) {
pushToast ( {
tone : "warn" ,
title : "Import preview found issues" ,
body : preview.errors [ 0 ]
} ) ;
return ;
}
pushToast ( {
tone : "success" ,
title : "Import preview ready" ,
body : ` ${ preview . plan . agentPlans . length } agent action ${ preview . plan . agentPlans . length === 1 ? "" : "s" } planned. `
} ) ;
} ,
onError : ( err ) = > {
setImportPreview ( null ) ;
pushToast ( {
tone : "error" ,
title : "Import preview failed" ,
body : err instanceof Error ? err . message : "Failed to preview company package"
} ) ;
}
} ) ;
const importPackageMutation = useMutation ( {
mutationFn : ( payload : CompanyPortabilityPreviewRequest ) = >
companiesApi . importBundle ( payload ) ,
onSuccess : async ( result ) = > {
await Promise . all ( [
queryClient . invalidateQueries ( { queryKey : queryKeys.companies.all } ) ,
queryClient . invalidateQueries ( { queryKey : queryKeys.companies.stats } ) ,
queryClient . invalidateQueries ( { queryKey : queryKeys.agents.list ( result . company . id ) } ) ,
queryClient . invalidateQueries ( { queryKey : queryKeys.org ( result . company . id ) } )
] ) ;
if ( importTargetMode === "new" ) {
setSelectedCompanyId ( result . company . id ) ;
}
pushToast ( {
tone : "success" ,
title : "Company package imported" ,
body : ` ${ result . agents . filter ( ( agent ) = > agent . action !== "skipped" ) . length } agent ${ result . agents . filter ( ( agent ) = > agent . action !== "skipped" ) . length === 1 ? "" : "s" } applied. `
} ) ;
if ( result . warnings . length > 0 ) {
pushToast ( {
tone : "warn" ,
title : "Import completed with warnings" ,
body : result.warnings [ 0 ]
} ) ;
}
setImportPreview ( null ) ;
setLocalPackage ( null ) ;
setImportUrl ( "" ) ;
} ,
onError : ( err ) = > {
pushToast ( {
tone : "error" ,
title : "Import failed" ,
body : err instanceof Error ? err . message : "Failed to import company package"
} ) ;
}
} ) ;
2026-02-23 14:41:21 -06:00
const inviteMutation = useMutation ( {
mutationFn : ( ) = >
2026-03-07 18:19:06 -06:00
accessApi . createOpenClawInvitePrompt ( selectedCompanyId ! ) ,
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-06 08:42:28 -06:00
const onboardingTextLink =
invite . onboardingTextUrl ? ?
invite . onboardingTextPath ? ?
` /api/invites/ ${ invite . token } /onboarding.txt ` ;
2026-03-05 12:10:01 -06:00
const absoluteUrl = onboardingTextLink . startsWith ( "http" )
? onboardingTextLink
: ` ${ base } ${ onboardingTextLink } ` ;
2026-03-05 12:37:56 -06:00
setSnippetCopied ( false ) ;
setSnippetCopyDelightId ( 0 ) ;
2026-03-06 10:00:34 -06:00
let snippet : string ;
2026-03-05 12:37:56 -06:00
try {
const manifest = await accessApi . getInviteOnboarding ( invite . token ) ;
2026-03-06 10:00:34 -06:00
snippet = buildAgentSnippet ( {
onboardingTextUrl : absoluteUrl ,
connectionCandidates :
manifest . onboarding . connectivity ? . connectionCandidates ? ? null ,
testResolutionUrl :
manifest . onboarding . connectivity ? . testResolutionEndpoint ? . url ? ?
null
} ) ;
2026-03-05 12:37:56 -06:00
} catch {
2026-03-06 10:00:34 -06:00
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 */
2026-03-05 12:37:56 -06:00
}
2026-03-06 08:42:28 -06:00
queryClient . invalidateQueries ( {
queryKey : queryKeys.sidebarBadges ( selectedCompanyId ! )
} ) ;
2026-02-23 14:41:21 -06:00
} ,
onError : ( err ) = > {
2026-03-06 08:42:28 -06:00
setInviteError (
err instanceof Error ? err . message : "Failed to create invite"
) ;
}
2026-02-23 14:41:21 -06:00
} ) ;
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-13 22:29:30 -05:00
useEffect ( ( ) = > {
setImportPreview ( null ) ;
} , [
collisionStrategy ,
importSourceMode ,
importTargetMode ,
importUrl ,
localPackage ,
newCompanyName ,
packageIncludeAgents ,
packageIncludeCompany ,
selectedCompanyId
] ) ;
2026-03-02 10:31:54 -06:00
const archiveMutation = useMutation ( {
mutationFn : ( {
companyId ,
2026-03-06 08:42:28 -06:00
nextCompanyId
2026-03-02 10:31:54 -06:00
} : {
companyId : string ;
nextCompanyId : string | null ;
} ) = > companiesApi . archive ( companyId ) . then ( ( ) = > ( { nextCompanyId } ) ) ,
onSuccess : async ( { nextCompanyId } ) = > {
if ( nextCompanyId ) {
setSelectedCompanyId ( nextCompanyId ) ;
}
2026-03-06 08:42:28 -06:00
await queryClient . invalidateQueries ( {
queryKey : queryKeys.companies.all
} ) ;
await queryClient . invalidateQueries ( {
queryKey : queryKeys.companies.stats
} ) ;
}
2026-03-02 10:31:54 -06:00
} ) ;
2026-02-23 14:41:21 -06:00
2026-02-19 14:02:29 -06:00
useEffect ( ( ) = > {
setBreadcrumbs ( [
{ label : selectedCompany?.name ? ? "Company" , href : "/dashboard" } ,
2026-03-06 08:42:28 -06:00
{ label : "Settings" }
2026-02-19 14:02:29 -06:00
] ) ;
} , [ 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 ,
2026-03-06 08:42:28 -06:00
brandColor : brandColor || null
2026-02-26 16:30:12 -06:00
} ) ;
}
2026-03-13 22:29:30 -05:00
async function handleChooseLocalPackage (
event : ChangeEvent < HTMLInputElement >
) {
const selection = event . target . files ;
if ( ! selection || selection . length === 0 ) {
setLocalPackage ( null ) ;
return ;
}
try {
const parsed = await readLocalPackageSelection ( selection ) ;
setLocalPackage ( parsed ) ;
pushToast ( {
tone : "success" ,
title : "Local package loaded" ,
body : ` ${ Object . keys ( parsed . files ) . length } markdown file ${ Object . keys ( parsed . files ) . length === 1 ? "" : "s" } ready for preview. `
} ) ;
} catch ( err ) {
setLocalPackage ( null ) ;
pushToast ( {
tone : "error" ,
title : "Failed to read local package" ,
body : err instanceof Error ? err . message : "Could not read selected files"
} ) ;
} finally {
event . target . value = "" ;
}
}
function handlePreviewImport() {
if ( ! importPayload ) {
pushToast ( {
tone : "warn" ,
title : "Source required" ,
body :
importSourceMode === "local"
? "Choose a local folder with COMPANY.md before previewing."
: "Enter a company package URL before previewing."
} ) ;
return ;
}
previewImportMutation . mutate ( importPayload ) ;
}
function handleApplyImport() {
if ( ! importPayload ) {
pushToast ( {
tone : "warn" ,
title : "Source required" ,
body :
importSourceMode === "local"
? "Choose a local folder with COMPANY.md before importing."
: "Enter a company package URL before importing."
} ) ;
return ;
}
importPackageMutation . mutate ( importPayload ) ;
}
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 >
2026-03-06 08:42:28 -06:00
< Field
label = "Description"
hint = "Optional description shown in the company profile."
>
2026-02-26 16:30:12 -06:00
< 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" >
2026-03-06 08:42:28 -06:00
< Field
label = "Brand color"
hint = "Sets the hue for the company icon. Leave empty for auto-generated color."
>
2026-02-26 16:30:12 -06:00
< 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-07 18:50:25 -06:00
Generate an OpenClaw agent invite snippet .
2026-03-05 12:10:01 -06:00
< / span >
2026-03-07 18:19:06 -06:00
< HintIcon text = "Creates a short-lived OpenClaw agent invite and renders a copy-ready prompt." / >
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" >
2026-03-06 08:42:28 -06:00
< Button
size = "sm"
onClick = { ( ) = > inviteMutation . mutate ( ) }
disabled = { inviteMutation . isPending }
>
{ inviteMutation . isPending
? "Generating..."
2026-03-07 18:19:06 -06:00
: "Generate OpenClaw Invite Prompt" }
2026-03-05 12:10:01 -06:00
< / Button >
< / div >
2026-03-06 08:42:28 -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-06 08:42:28 -06:00
< div className = "text-xs text-muted-foreground" >
2026-03-07 18:19:06 -06:00
OpenClaw Invite Prompt
2026-03-06 08:42:28 -06:00
< / div >
2026-03-05 12:37:56 -06:00
{ snippetCopied && (
2026-03-06 08:42:28 -06:00
< span
key = { snippetCopyDelightId }
className = "flex items-center gap-1 text-xs text-green-600 animate-pulse"
>
2026-03-05 12:37:56 -06:00
< 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 ) ;
2026-03-06 08:42:28 -06:00
} catch {
/* clipboard may not be available */
}
2026-03-05 12:37:56 -06:00
} }
>
{ 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-13 22:29:30 -05:00
{ /* Import / Export */ }
< div className = "space-y-4" >
< div className = "text-xs font-medium text-muted-foreground uppercase tracking-wide" >
Company Packages
< / div >
< div className = "space-y-4 rounded-md border border-border px-4 py-4" >
< div className = "flex items-start justify-between gap-4" >
< div className = "space-y-1" >
< div className = "text-sm font-medium" > Export markdown package < / div >
< p className = "text-xs text-muted-foreground" >
Download a markdown - first company package as a single tar file .
< / p >
< / div >
< Button
size = "sm"
onClick = { ( ) = > exportMutation . mutate ( ) }
disabled = {
exportMutation . isPending ||
( ! packageIncludeCompany && ! packageIncludeAgents )
}
>
< Download className = "mr-1 h-3.5 w-3.5" / >
{ exportMutation . isPending ? "Exporting..." : "Export package" }
< / Button >
< / div >
< div className = "grid gap-3 md:grid-cols-2" >
< label className = "flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm" >
< input
type = "checkbox"
checked = { packageIncludeCompany }
onChange = { ( e ) = > setPackageIncludeCompany ( e . target . checked ) }
/ >
Include company metadata
< / label >
< label className = "flex items-center gap-2 rounded-md border border-border px-3 py-2 text-sm" >
< input
type = "checkbox"
checked = { packageIncludeAgents }
onChange = { ( e ) = > setPackageIncludeAgents ( e . target . checked ) }
/ >
Include agents
< / label >
< / div >
{ exportMutation . data && (
< div className = "rounded-md border border-border bg-muted/20 p-3" >
< div className = "text-xs font-medium uppercase tracking-wide text-muted-foreground" >
Last export
< / div >
< div className = "mt-2 text-sm" >
{ exportMutation . data . rootPath } . tar with { " " }
{ Object . keys ( exportMutation . data . files ) . length } file
{ Object . keys ( exportMutation . data . files ) . length === 1 ? "" : "s" } .
< / div >
< div className = "mt-2 flex flex-wrap gap-2 text-xs text-muted-foreground" >
{ Object . keys ( exportMutation . data . files ) . map ( ( filePath ) = > (
< span
key = { filePath }
className = "rounded-full border border-border px-2 py-0.5"
>
{ filePath }
< / span >
) ) }
< / div >
{ exportMutation . data . warnings . length > 0 && (
< div className = "mt-3 space-y-1 text-xs text-amber-700" >
{ exportMutation . data . warnings . map ( ( warning ) = > (
< div key = { warning } > { warning } < / div >
) ) }
< / div >
) }
< / div >
) }
< / div >
< div className = "space-y-4 rounded-md border border-border px-4 py-4" >
< div className = "space-y-1" >
< div className = "text-sm font-medium" > Import company package < / div >
< p className = "text-xs text-muted-foreground" >
Preview a GitHub repo , direct COMPANY . md URL , or local folder before applying it .
< / p >
< / div >
< div className = "grid gap-2 md:grid-cols-3" >
< button
type = "button"
className = { ` rounded-md border px-3 py-2 text-left text-sm transition-colors ${
importSourceMode === "github"
? "border-foreground bg-accent"
: "border-border hover:bg-accent/50"
} ` }
onClick = { ( ) = > setImportSourceMode ( "github" ) }
>
< div className = "flex items-center gap-2" >
< Github className = "h-4 w-4" / >
GitHub repo
< / div >
< / button >
< button
type = "button"
className = { ` rounded-md border px-3 py-2 text-left text-sm transition-colors ${
importSourceMode === "url"
? "border-foreground bg-accent"
: "border-border hover:bg-accent/50"
} ` }
onClick = { ( ) = > setImportSourceMode ( "url" ) }
>
< div className = "flex items-center gap-2" >
< Link2 className = "h-4 w-4" / >
Direct URL
< / div >
< / button >
< button
type = "button"
className = { ` rounded-md border px-3 py-2 text-left text-sm transition-colors ${
importSourceMode === "local"
? "border-foreground bg-accent"
: "border-border hover:bg-accent/50"
} ` }
onClick = { ( ) = > setImportSourceMode ( "local" ) }
>
< div className = "flex items-center gap-2" >
< Upload className = "h-4 w-4" / >
Local folder
< / div >
< / button >
< / div >
{ importSourceMode === "local" ? (
< div className = "rounded-md border border-dashed border-border px-3 py-3" >
< input
ref = { packageInputRef }
type = "file"
multiple
className = "hidden"
// @ts-expect-error webkitdirectory is supported by Chromium-based browsers
webkitdirectory = ""
onChange = { handleChooseLocalPackage }
/ >
< div className = "flex flex-wrap items-center gap-2" >
< Button
size = "sm"
variant = "outline"
onClick = { ( ) = > packageInputRef . current ? . click ( ) }
>
Choose folder
< / Button >
{ localPackage && (
< span className = "text-xs text-muted-foreground" >
{ localPackage . rootPath ? ? "package" } with { " " }
{ Object . keys ( localPackage . files ) . length } markdown file
{ Object . keys ( localPackage . files ) . length === 1 ? "" : "s" }
< / span >
) }
< / div >
{ ! localPackage && (
< p className = "mt-2 text-xs text-muted-foreground" >
Select a folder that contains COMPANY . md and any referenced
AGENTS . md files .
< / p >
) }
< / div >
) : (
< Field
label = { importSourceMode === "github" ? "GitHub URL" : "Package URL" }
hint = {
importSourceMode === "github"
? "Repo root, tree path, or blob URL to COMPANY.md. Unpinned refs warn but do not block."
: "Point directly at COMPANY.md or a directory that contains it."
}
>
< input
className = "w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
type = "text"
value = { importUrl }
placeholder = {
importSourceMode === "github"
? "https://github.com/owner/repo/tree/main/company"
: "https://example.com/company/COMPANY.md"
}
onChange = { ( e ) = > setImportUrl ( e . target . value ) }
/ >
< / Field >
) }
< div className = "grid gap-3 md:grid-cols-2" >
< Field
label = "Target"
hint = "Import into this company or create a new one from the package."
>
< select
className = "w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
value = { importTargetMode }
onChange = { ( e ) = >
setImportTargetMode ( e . target . value as "existing" | "new" )
}
>
< option value = "existing" >
Existing company : { selectedCompany . name }
< / option >
< option value = "new" > Create new company < / option >
< / select >
< / Field >
< Field
label = "Collision strategy"
hint = "Controls what happens when imported agent slugs already exist."
>
< select
className = "w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
value = { collisionStrategy }
onChange = { ( e ) = >
setCollisionStrategy (
e . target . value as CompanyPortabilityCollisionStrategy
)
}
>
< option value = "rename" > Rename imported agents < / option >
< option value = "skip" > Skip existing agents < / option >
< option value = "replace" > Replace existing agents < / option >
< / select >
< / Field >
< / div >
{ importTargetMode === "new" && (
< Field
label = "New company name"
hint = "Optional override. Leave blank to use the package name."
>
< input
className = "w-full rounded-md border border-border bg-transparent px-2.5 py-1.5 text-sm outline-none"
type = "text"
value = { newCompanyName }
onChange = { ( e ) = > setNewCompanyName ( e . target . value ) }
placeholder = "Imported Company"
/ >
< / Field >
) }
< div className = "flex flex-wrap items-center gap-2" >
< Button
size = "sm"
variant = "outline"
onClick = { handlePreviewImport }
disabled = {
previewImportMutation . isPending ||
( ! packageIncludeCompany && ! packageIncludeAgents )
}
>
{ previewImportMutation . isPending ? "Previewing..." : "Preview import" }
< / Button >
< Button
size = "sm"
onClick = { handleApplyImport }
disabled = {
importPackageMutation . isPending ||
previewImportMutation . isPending ||
! ! ( importPreview && importPreview . errors . length > 0 ) ||
( ! packageIncludeCompany && ! packageIncludeAgents )
}
>
{ importPackageMutation . isPending ? "Importing..." : "Apply import" }
< / Button >
< / div >
{ importPreview && (
< div className = "space-y-3 rounded-md border border-border bg-muted/20 p-3" >
< div className = "grid gap-2 md:grid-cols-2" >
< div className = "rounded-md border border-border bg-background/70 px-3 py-2" >
< div className = "text-xs uppercase tracking-wide text-muted-foreground" >
Company action
< / div >
< div className = "mt-1 text-sm font-medium" >
{ importPreview . plan . companyAction }
< / div >
< / div >
< div className = "rounded-md border border-border bg-background/70 px-3 py-2" >
< div className = "text-xs uppercase tracking-wide text-muted-foreground" >
Agent actions
< / div >
< div className = "mt-1 text-sm font-medium" >
{ importPreview . plan . agentPlans . length }
< / div >
< / div >
< / div >
{ importPreview . plan . agentPlans . length > 0 && (
< div className = "space-y-2" >
{ importPreview . plan . agentPlans . map ( ( agentPlan ) = > (
< div
key = { agentPlan . slug }
className = "rounded-md border border-border bg-background/70 px-3 py-2"
>
< div className = "flex items-center justify-between gap-2 text-sm" >
< span className = "font-medium" >
{ agentPlan . slug } { "->" } { agentPlan . plannedName }
< / span >
< span className = "rounded-full border border-border px-2 py-0.5 text-xs uppercase tracking-wide text-muted-foreground" >
{ agentPlan . action }
< / span >
< / div >
{ agentPlan . reason && (
< div className = "mt-1 text-xs text-muted-foreground" >
{ agentPlan . reason }
< / div >
) }
< / div >
) ) }
< / div >
) }
{ importPreview . requiredSecrets . length > 0 && (
< div className = "space-y-1" >
< div className = "text-xs uppercase tracking-wide text-muted-foreground" >
Required secrets
< / div >
{ importPreview . requiredSecrets . map ( ( secret ) = > (
< div
key = { ` ${ secret . agentSlug ? ? "company" } : ${ secret . key } ` }
className = "text-xs text-muted-foreground"
>
{ secret . key }
{ secret . agentSlug ? ` for ${ secret . agentSlug } ` : "" }
< / div >
) ) }
< / div >
) }
{ importPreview . warnings . length > 0 && (
< div className = "space-y-1 rounded-md border border-amber-300/60 bg-amber-50/60 px-3 py-2 text-xs text-amber-700" >
{ importPreview . warnings . map ( ( warning ) = > (
< div key = { warning } > { warning } < / div >
) ) }
< / div >
) }
{ importPreview . errors . length > 0 && (
< div className = "space-y-1 rounded-md border border-destructive/40 bg-destructive/5 px-3 py-2 text-xs text-destructive" >
{ importPreview . errors . map ( ( error ) = > (
< div key = { error } > { error } < / div >
) ) }
< / div >
) }
< / div >
) }
< / div >
< / div >
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" >
2026-03-06 08:42:28 -06:00
Archive this company to hide it from the sidebar . This persists in
the database .
2026-03-02 10:31:54 -06:00
< / p >
< div className = "flex items-center gap-2" >
< Button
size = "sm"
2026-03-05 13:12:07 -06:00
variant = "destructive"
2026-03-06 08:42:28 -06:00
disabled = {
archiveMutation . isPending ||
selectedCompany . status === "archived"
}
2026-03-02 10:31:54 -06:00
onClick = { ( ) = > {
if ( ! selectedCompanyId ) return ;
const confirmed = window . confirm (
2026-03-06 08:42:28 -06:00
` Archive company " ${ selectedCompany . name } "? It will be hidden from the sidebar. `
2026-03-02 10:31:54 -06:00
) ;
if ( ! confirmed ) return ;
2026-03-06 08:42:28 -06:00
const nextCompanyId =
companies . find (
( company ) = >
company . id !== selectedCompanyId &&
company . status !== "archived"
) ? . id ? ? null ;
archiveMutation . mutate ( {
companyId : selectedCompanyId ,
nextCompanyId
} ) ;
2026-03-02 10:31:54 -06:00
} }
>
{ archiveMutation . isPending
? "Archiving..."
: selectedCompany . status === "archived"
2026-03-06 08:42:28 -06:00
? "Already archived"
: "Archive company" }
2026-03-02 10:31:54 -06:00
< / 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-13 22:29:30 -05:00
async function readLocalPackageSelection ( fileList : FileList ) : Promise < {
rootPath : string | null ;
files : Record < string , string > ;
} > {
const files : Record < string , string > = { } ;
let rootPath : string | null = null ;
for ( const file of Array . from ( fileList ) ) {
const relativePath =
( file as File & { webkitRelativePath? : string } ) . webkitRelativePath ? . replace (
/\\/g ,
"/"
) || file . name ;
if ( ! relativePath . endsWith ( ".md" ) ) continue ;
const topLevel = relativePath . split ( "/" ) [ 0 ] ? ? null ;
if ( ! rootPath && topLevel ) rootPath = topLevel ;
files [ relativePath ] = await file . text ( ) ;
}
if ( Object . keys ( files ) . length === 0 ) {
throw new Error ( "No markdown files were found in the selected folder." ) ;
}
return { rootPath , files } ;
}
async function downloadCompanyPackage (
exported : CompanyPortabilityExportResult
) : Promise < void > {
const tarBytes = createTarArchive ( exported . files , exported . rootPath ) ;
const tarBuffer = new ArrayBuffer ( tarBytes . byteLength ) ;
new Uint8Array ( tarBuffer ) . set ( tarBytes ) ;
const blob = new Blob (
[ tarBuffer ] ,
{
type : "application/x-tar"
}
) ;
const url = URL . createObjectURL ( blob ) ;
const anchor = document . createElement ( "a" ) ;
anchor . href = url ;
anchor . download = ` ${ exported . rootPath } .tar ` ;
document . body . appendChild ( anchor ) ;
anchor . click ( ) ;
anchor . remove ( ) ;
window . setTimeout ( ( ) = > URL . revokeObjectURL ( url ) , 1000 ) ;
}
function createTarArchive (
files : Record < string , string > ,
rootPath : string
) : Uint8Array {
const encoder = new TextEncoder ( ) ;
const chunks : Uint8Array [ ] = [ ] ;
for ( const [ relativePath , contents ] of Object . entries ( files ) ) {
const tarPath = ` ${ rootPath } / ${ relativePath } ` . replace ( /\\/g , "/" ) ;
const body = encoder . encode ( contents ) ;
chunks . push ( buildTarHeader ( tarPath , body . length ) ) ;
chunks . push ( body ) ;
const remainder = body . length % 512 ;
if ( remainder > 0 ) {
chunks . push ( new Uint8Array ( 512 - remainder ) ) ;
}
}
chunks . push ( new Uint8Array ( 1024 ) ) ;
const totalLength = chunks . reduce ( ( sum , chunk ) = > sum + chunk . length , 0 ) ;
const archive = new Uint8Array ( totalLength ) ;
let offset = 0 ;
for ( const chunk of chunks ) {
archive . set ( chunk , offset ) ;
offset += chunk . length ;
}
return archive ;
}
function buildTarHeader ( pathname : string , size : number ) : Uint8Array {
const header = new Uint8Array ( 512 ) ;
writeTarString ( header , 0 , 100 , pathname ) ;
writeTarOctal ( header , 100 , 8 , 0 o644 ) ;
writeTarOctal ( header , 108 , 8 , 0 ) ;
writeTarOctal ( header , 116 , 8 , 0 ) ;
writeTarOctal ( header , 124 , 12 , size ) ;
writeTarOctal ( header , 136 , 12 , Math . floor ( Date . now ( ) / 1000 ) ) ;
for ( let i = 148 ; i < 156 ; i += 1 ) {
header [ i ] = 32 ;
}
header [ 156 ] = "0" . charCodeAt ( 0 ) ;
writeTarString ( header , 257 , 6 , "ustar" ) ;
writeTarString ( header , 263 , 2 , "00" ) ;
const checksum = header . reduce ( ( sum , byte ) = > sum + byte , 0 ) ;
writeTarChecksum ( header , checksum ) ;
return header ;
}
function writeTarString (
target : Uint8Array ,
offset : number ,
length : number ,
value : string
) {
const encoded = new TextEncoder ( ) . encode ( value ) ;
target . set ( encoded . slice ( 0 , length ) , offset ) ;
}
function writeTarOctal (
target : Uint8Array ,
offset : number ,
length : number ,
value : number
) {
const stringValue = value . toString ( 8 ) . padStart ( length - 1 , "0" ) ;
writeTarString ( target , offset , length - 1 , stringValue ) ;
target [ offset + length - 1 ] = 0 ;
}
function writeTarChecksum ( target : Uint8Array , checksum : number ) {
const stringValue = checksum . toString ( 8 ) . padStart ( 6 , "0" ) ;
writeTarString ( target , 148 , 6 , stringValue ) ;
target [ 154 ] = 0 ;
target [ 155 ] = 32 ;
}
2026-03-05 12:52:39 -06:00
function buildAgentSnippet ( input : AgentSnippetInput ) {
const candidateUrls = buildCandidateOnboardingUrls ( input ) ;
2026-03-05 16:13:43 -06:00
const resolutionTestUrl = buildResolutionTestUrl ( input ) ;
2026-03-05 12:37:56 -06:00
2026-03-06 08:42:28 -06:00
const candidateList =
candidateUrls . length > 0
? candidateUrls . map ( ( u ) = > ` - ${ u } ` ) . join ( "\n" )
: "- (No candidate URLs available yet.)" ;
2026-03-05 12:52:39 -06:00
2026-03-06 08:42:28 -06:00
const connectivityBlock =
candidateUrls . length === 0
? ` No candidate URLs are available. Ask your user to configure a reachable hostname in Paperclip, then retry.
2026-03-05 16:13:43 -06:00
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 `
2026-03-06 08:42:28 -06:00
: ` If none are reachable, ask your user to add a reachable hostname in Paperclip, restart, and retry.
2026-03-05 16:13:43 -06:00
Suggested command :
- pnpm paperclipai allowed - hostname < host >
Then verify with : curl - fsS < base - url > / api / health ` ;
2026-03-05 12:52:39 -06:00
2026-03-05 16:13:43 -06:00
const resolutionLine = resolutionTestUrl
2026-03-07 15:39:12 -06:00
? ` \ 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. `
2026-03-05 16:13:43 -06:00
: "" ;
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 .
2026-03-07 15:39:12 -06:00
You also need to send a gateway URL that Paperclip can access . Check your config and include that URL . $ { resolutionLine }
2026-03-05 16:13:43 -06:00
-- -
2026-03-07 15:39:12 -06:00
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"] \`
2026-03-07 17:05:36 -06:00
- 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.
2026-03-05 13:05:04 -06:00
2026-03-07 15:39:12 -06:00
Do NOT use \ ` /v1/responses \` or \` /hooks/* \` in this join flow.
2026-03-06 08:42:28 -06:00
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 .
2026-03-05 16:13:43 -06:00
` ;
2026-03-05 12:52:39 -06:00
}
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 ) ;
2026-03-06 08:42:28 -06:00
const testPath = onboardingUrl . pathname . replace (
/\/onboarding\.txt$/ ,
"/test-resolution"
) ;
2026-03-05 13:05:04 -06:00
return ` ${ onboardingUrl . origin } ${ testPath } ` ;
} catch {
return null ;
}
}