AgentInviteScreen.tsx7.4 KBView on GitHub 'use client';
import { AlertCircle, Check, Loader2 } from 'lucide-react';
import { AgentAvatar } from '@/components/icons/agent-avatar';
import { Button } from '@/components/ui/button';
import { FormError } from '@/components/ui/field';
import { cn } from '@/lib/utils';
/**
* "Somebody shared an agent with you." The screen where you decide.
*
* ── WHY THERE IS A SCREEN HERE AT ALL ───────────────────────────────────────
*
* Sharing an agent writes NOTHING in the recipient's space. It writes a grant on the
* agent document — enough for them to read what it does — and one invite row, and then it
* stops. Everything that would touch their account (trigger blocks in their playbook, the
* starting files it needs) happens only after this screen, dispatched as THEM.
*
* That is not ceremony. An agent that grades your discovery calls and keeps a log your
* manager can read is a different product from one you agreed to: being enrolled in it by
* a colleague, silently, is the thing this screen exists to make impossible.
*
* ── SO THE THREE BULLETS ARE THE WHOLE POINT ────────────────────────────────
*
* What it does, when it runs, where it writes, and anything it will later ASK to share.
* They are read off the agent's own instructions and config, never composed here and
* never guessed: a consent screen that paraphrases is a consent screen that can be wrong.
* The onward-sharing line matters most and is deliberately last, where a reader's eye
* stops before the buttons — "will ask whether your manager may read the log" is the
* sentence somebody would want to have seen.
*
* ── AND WHY ACCEPT BECOMES A PROGRESS LIST, NOT A SPINNER ───────────────────
*
* Accepting dispatches a real run in the recipient's own chat that writes real things
* into their space. A spinner says "wait"; the step list says WHAT is being done to your
* account, which is the same information the bullets promised, now in the past tense.
* A failure keeps its message and offers a retry — the run is idempotent by construction,
* so a retry after a partial run converges rather than duplicating triggers.
*
* Design: `.design-sharing/Invite.dc.html`; apps/server/docs/sharing.md §3.2 G.
*/
export type AgentInviteStatus =
| 'invited'
| 'accepted'
| 'configured'
| 'failed'
| 'declined';
export interface AgentInviteFact {
/**
* `does` / `runs` / `writes` are settled facts and carry a tick. `asks` is a question
* the agent will put to you LATER and carries the attention glyph, because it is the
* one line on the screen that is not yet decided.
*/
kind: 'runs' | 'writes' | 'asks';
text: string;
}
export interface AgentSetupStep {
label: string;
state: 'pending' | 'done' | 'failed';
}
export interface AgentInvite {
shareId: string;
agentId: string;
agentName: string;
avatar?: string | null;
sharedByName: string | null;
/** One sentence, from the agent's own instructions. */
summary: string;
facts: readonly AgentInviteFact[];
status: AgentInviteStatus;
steps?: readonly AgentSetupStep[];
failureMessage?: string | null;
}
export function AgentInviteScreen({
invite,
onAccept,
onDecline,
onRetry,
busy = false,
className,
}: {
invite: AgentInvite;
onAccept: () => void;
onDecline: () => void;
onRetry: () => void;
busy?: boolean;
className?: string;
}) {
const sharer = invite.sharedByName ?? 'A colleague';
const settled = invite.status === 'accepted' || invite.status === 'configured';
return (
<div className={cn('bg-raised border-border overflow-hidden rounded-xl border', className)}>
<div className="flex items-center gap-2.5 px-5 pt-4.5 pb-3.5">
<AgentAvatar agentId={invite.agentId} avatar={invite.avatar} className="size-8 shrink-0" />
<div className="min-w-0">
<h2 className="truncate text-base font-medium">{invite.agentName}</h2>
<p className="text-muted-foreground truncate text-xs">Shared by {sharer}</p>
</div>
</div>
<p className="px-5 pb-3.5 text-[13px] leading-relaxed">{invite.summary}</p>
{settled || invite.status === 'failed' ? (
<SetupProgress steps={invite.steps ?? []} failureMessage={invite.failureMessage} />
) : (
<ul className="flex flex-col gap-2.5 px-5 pb-3.5">
{invite.facts.map((fact) => (
<li key=[redacted] className="flex items-start gap-2.5">
{fact.kind === 'asks' ? (
<AlertCircle className="text-primary mt-0.5 size-3.5 shrink-0" aria-hidden />
) : (
<Check className="mt-0.5 size-3.5 shrink-0 text-emerald-700" aria-hidden />
)}
<span className="text-[13px] leading-relaxed">{fact.text}</span>
</li>
))}
</ul>
)}
<div className="border-foreground/[0.07] flex items-center justify-end gap-2 border-t px-5 py-3">
{invite.status === 'failed' ? (
<Button size="sm" className="cursor-pointer" disabled={busy} onClick={onRetry}>
Try setting it up again
</Button>
) : settled ? (
// Nothing to decide any more. The screen stays as the record of what happened,
// which is where somebody comes back to when they want to know what it changed.
<span className="text-muted-foreground text-xs">
{invite.status === 'configured' ? 'Set up for you.' : 'Setting it up…'}
</span>
) : (
<>
<Button
size="sm"
variant="ghost"
className="cursor-pointer"
disabled={busy}
onClick={onDecline}
>
Decline
</Button>
<Button size="sm" className="cursor-pointer" disabled={busy} onClick={onAccept}>
Accept and set up for me
</Button>
</>
)}
</div>
</div>
);
}
/**
* What accepting actually did, one line per step as it lands.
*
* The steps come from the run, not from a script here: a client-side checklist that
* "completes" on a timer is a lie told in the same shape as the truth.
*/
function SetupProgress({
steps,
failureMessage,
}: {
steps: readonly AgentSetupStep[];
failureMessage?: string | null;
}) {
return (
<div className="flex flex-col gap-2.5 px-5 pb-3.5">
{steps.map((step) => (
<div key=[redacted] className="flex items-start gap-2.5">
{step.state === 'done' ? (
<Check className="mt-0.5 size-3.5 shrink-0 text-emerald-700" aria-hidden />
) : step.state === 'failed' ? (
<AlertCircle className="text-destructive mt-0.5 size-3.5 shrink-0" aria-hidden />
) : (
<Loader2 className="text-muted-foreground mt-0.5 size-3.5 shrink-0 animate-spin" aria-hidden />
)}
<span
className={cn(
'text-[13px] leading-relaxed',
step.state === 'pending' && 'text-muted-foreground',
)}
>
{step.label}
</span>
</div>
))}
{/* The reason, in the panel where it happened — never a toast. A setup failure is
something the reader has to act on, and the retry is two inches below it. */}
{failureMessage ? <FormError>{failureMessage}</FormError> : null}
</div>
);
}