Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 84 additions & 0 deletions apps/web/src/app/(app)/claw/components/AgentCardConnectPrompt.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
'use client';

import { useEffect, useState } from 'react';
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useKiloClawStatus } from '@/hooks/useKiloClaw';
import { AgentCardIcon } from './icons/AgentCardIcon';

// One-time, dismissible prompt shown after first sign-in inviting the user to
// connect Agentcard. It is purely opt-in: nothing happens unless the user
// clicks Connect (which kicks off the OAuth flow). Dismissal is remembered in
// localStorage so we never nag a user who has said "Not now".
const DISMISS_KEY = 'kiloclaw:agentcard-connect-prompt:dismissed';

export function AgentCardConnectPrompt() {
const { data: status, isLoading } = useKiloClawStatus();
const [open, setOpen] = useState(false);

useEffect(() => {
if (isLoading || !status) return;
// Only prompt once the user has a live instance (skips onboarding), and
// never if they're already connected or have dismissed the prompt before.
if (!status.status) return;
if (status.agentcardOAuthConnected) return;
try {
if (localStorage.getItem(DISMISS_KEY)) return;
} catch {
// localStorage unavailable (e.g. privacy mode) — just don't prompt.
return;
}
setOpen(true);
}, [isLoading, status]);

function dismiss() {
try {
localStorage.setItem(DISMISS_KEY, String(Date.now()));
} catch {
// ignore — worst case the prompt shows again next session.
}
setOpen(false);
}

// Connect from the dashboard; return the user here afterward.
const connectUrl = '/api/integrations/agentcard/connect?returnTo=%2Fclaw';

return (
<Dialog open={open} onOpenChange={next => (next ? setOpen(true) : dismiss())}>
<DialogContent>
<DialogHeader>
<div className="mb-1 flex items-center gap-2">
<AgentCardIcon className="h-6 w-auto shrink-0" />
<DialogTitle>Connect Agentcard?</DialogTitle>
</div>
<DialogDescription>
Give your agent the ability to create and spend virtual debit cards, with per-task spend
limits enforced by Agentcard. You authenticate with your own Agentcard account — Kilo
never sees a long-lived key.
</DialogDescription>
</DialogHeader>
<div className="rounded-md border border-amber-500/20 bg-amber-500/5 px-3 py-2">
<p className="text-amber-400 text-xs font-medium">
Warning: this can permit your agent to spend real money. Use caution.
</p>
</div>
<DialogFooter>
<Button variant="outline" size="sm" onClick={dismiss}>
Not now
</Button>
<Button asChild size="sm" onClick={() => setOpen(false)}>
<Link href={connectUrl}>Connect Agentcard</Link>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
13 changes: 9 additions & 4 deletions apps/web/src/app/(app)/claw/components/ClawSettingsPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { toast } from 'sonner';
import { Settings } from 'lucide-react';
import { usePathname, useRouter, useSearchParams } from 'next/navigation';
import { resolveGoogleOAuthFeedback } from './google-oauth-feedback';
import { resolveAgentCardOAuthFeedback } from './agentcard-oauth-feedback';
import { TRPCClientError } from '@trpc/client';
import type { KiloClawDashboardStatus } from '@/lib/kiloclaw/types';
import { useKiloClawStatus, useKiloClawMutations, useKiloClawMyPin } from '@/hooks/useKiloClaw';
Expand Down Expand Up @@ -183,10 +184,13 @@ function ClawSettingsWithStatus({
// bounces to onboarding. Wait for status so `shouldRedirect` is meaningful.
useEffect(() => {
if (oauthFeedbackHandledRef.current || isLoading) return;
const feedback = resolveGoogleOAuthFeedback(
searchParams.get('success'),
searchParams.get('error')
);
// AgentCard routes tag their redirects with provider=agentcard; everything
// else (Google) keeps the existing behavior. The codes overlap, so the
// marker is what disambiguates which copy to show.
const feedback =
searchParams.get('provider') === 'agentcard'
? resolveAgentCardOAuthFeedback(searchParams.get('success'), searchParams.get('error'))
: resolveGoogleOAuthFeedback(searchParams.get('success'), searchParams.get('error'));
if (!feedback) return;
oauthFeedbackHandledRef.current = true;

Expand All @@ -203,6 +207,7 @@ function ClawSettingsWithStatus({
const next = new URLSearchParams(searchParams);
next.delete('success');
next.delete('error');
next.delete('provider');
const query = next.toString();
router.replace(query ? `${pathname}?${query}` : pathname, { scroll: false });
}
Expand Down
Loading