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>
  );
}