AgentSharePanel.tsx25.4 KBView on GitHub
'use client';

import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { Check, ChevronDown, Eye } from 'lucide-react';
import { useCallback, useMemo, useState } from 'react';

import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { InviteField } from '@/modules/sharing/components/InviteField';
import type { AgentShareRow } from '@/modules/sharing/types';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { FormActions, FormError } from '@/components/ui/field';
import { useSession } from '@/modules/auth/utils/auth-client';
import { PersonAvatar } from '@/components/ui/person-avatar';
import { useViewAs } from '@/modules/sharing/view-as';
import { useTRPC } from '@/providers/query-provider';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';

/**
 * Sharing an AGENT, which is a different question from sharing a file.
 *
 * ── WHY THIS IS NOT `SharePanel` WITH A DIFFERENT TITLE ─────────────────────
 *
 * On a document, adding somebody is the whole act: the grant lands and they can open
 * it. On an agent it is only the FIRST half. The share writes a grant on the agent
 * document and one row saying they were asked — and nothing at all in their space. The
 * second half happens when they accept, dispatched as them, and it writes real things:
 * trigger blocks in their playbook, starting files in their folder.
 *
 * So a person on this panel is in one of several states that a file's audience simply
 * does not have, and the states are not cosmetic. Somebody who was invited three days
 * ago and never accepted looks, in a grant list, exactly like somebody the agent is
 * running for — and the owner concludes the team is covered when half of it is not.
 * That is the whole reason for the chips: **Invited · Set up · Setup failed**.
 *
 * ── WHAT EACH CONTROL DOES, AND WHAT IT DOES NOT ────────────────────────────
 *
 * `Resend` is gated to once an hour and the server owns the gate — the row says when
 * the last one went, so the control is a decision rather than a button you press twice
 * because nothing visibly happened. `Retry` re-dispatches the same setup; it is
 * idempotent by construction, so a retry after a partial run converges rather than
 * writing a second copy of every trigger. `Remove` revokes the grant and takes the
 * trigger blocks out, and LEAVES THE FILES — which the confirm says, because "what
 * happens to the log it wrote about me" is the question being asked underneath.
 *
 * ── AND WHY VIEW-AS IS A TAB ────────────────────────────────────────────────
 *
 * It has been three things: an eye welded to the Share button, then a line inside a
 * row's menu, then an icon on every row. All three failed the same way — it is not a
 * property of a person, it is a MODE you enter, and a mode hidden among per-row
 * controls is one nobody finds and nobody can be told about. A tab can carry the one
 * sentence that explains what the mode does, which none of the other three had room
 * for. Same reasoning as Publish: an act that changes what the whole screen means gets
 * its own tab, not a chevron on a row.
 *
 * ── AND WHY IT IS OTHERWISE THE FILE PANEL, TO THE PIXEL ────────────────────
 *
 * Everything above is a difference in the DATA. None of it is a reason for the panel
 * to be a different shape, and it used to be: an agent identity card at the top, a
 * paragraph of explanation at the bottom, and "Add people" as a footer button opening
 * a SECOND popover with a picker in it — so the common act cost two clicks and a layer,
 * on the one surface where sharing is most likely to be a new thing to the reader.
 *
 * It now opens on the same search field, over the same rows, in the same order. What is
 * agent-specific is the CHIP on a row and the Retry/Resend beside it, which is exactly
 * the part a file's audience cannot express.
 *
 * Design: `.design-sharing/ShareAgent.dc.html`;
 * apps/server/docs/sharing.md §3.2 F (items 13-16).
 */

/**
 * An agent has two standings, not four.
 *
 * The file ladder — view / comment / edit / manage — is about a DOCUMENT, and it says
 * nothing a person sharing an agent is asking. "Can comment on an agent" is not a
 * thing; what you actually decide is whether somebody just runs it or can also see and
 * change everything inside it. So the two roles the underlying grant already supports
 * are relabelled for what they mean HERE, and the middle two are not offered.
 *
 * `manager` is the one that may re-share and read every file the agent owns, which is
 * exactly "admin on this agent". `viewer` is everyone else.
 */
const AGENT_ROLES = [
  { value: 'viewer', label: 'Member', hint: 'Runs the agent as themselves.' },
  { value: 'manager', label: 'Admin', hint: 'Sees everything in the agent, and can share it.' },
] as const;

type AgentRole = (typeof AGENT_ROLES)[number]['value'];

/**
 * The empty list, once, at module scope.
 *
 * `data?.rows ?? []` mints a fresh array on every render, which is a prop that changes
 * forever: the `present` memo below recomputes every render and `InviteField`'s own
 * memos bust with it. `SharePanel` documents the same trap and guards against it.
 */
const NO_ROWS: AgentShareRow[] = [];

/** `commenter` and `editor` are not offered, but a grant written elsewhere can hold one. */
function agentRoleLabel(role: string | null): string {
  return role === 'manager' || role === 'owner' ? 'Admin' : 'Member';
}

export function AgentSharePanel({ agentId, className }: { agentId: string; className?: string }) {
  const trpc = useTRPC();
  const queryClient = useQueryClient();
  const { data: session } = useSession();
  const { enter: viewAs } = useViewAs();
  const [error, setError] = useState<string | null>(null);
  /** The invite field is searching or holds a pick, so the results replace the list. */
  const [inviting, setInviting] = useState(false);
  const [view, setView] = useState<'share' | 'perspective'>('share');
  /**
   * A `Remove` that has been asked for and not yet confirmed (§3.2 F16).
   *
   * Held by user id rather than by row: the confirm renders under the row it is about,
   * and the row is redrawn on every refetch of `agentSharing.list`.
   */
  const [removing, setRemoving] = useState<string | null>(null);

  const listOptions = trpc.agentSharing.list.queryOptions({ agentId });
  const { data, isPending } = useQuery(listOptions);
  const { data: directory } = useQuery(trpc.organisation.listMembers.queryOptions({}));

  const refresh = () => {
    setError(null);
    void queryClient.invalidateQueries({ queryKey=[redacted] });
  };
  const fail = (err: unknown) =>
    setError(err instanceof Error ? err.message : 'That did not work.');
  const settle = { onSuccess: refresh, onError: fail };

  const { mutate: share } = useMutation({
    ...trpc.agentSharing.share.mutationOptions(),
    ...settle,
  });
  const { mutate: resend } = useMutation({
    ...trpc.agentSharing.resend.mutationOptions(),
    ...settle,
  });
  const { mutate: retry } = useMutation({
    ...trpc.agentSharing.retry.mutationOptions(),
    ...settle,
  });
  const { mutate: unshare } = useMutation({
    ...trpc.agentSharing.unshare.mutationOptions(),
    ...settle,
  });

  const rows = data?.rows ?? NO_ROWS;

  /**
   * The viewer's OWN row, when they are on the list rather than the owner of it.
   *
   * The panel used to hardcode "Owner" for `session.user` and then render `rows`
   * underneath — but `listAgentShares` does not exclude the caller, so anybody the
   * agent had been shared WITH saw themselves twice: once labelled Owner, which they
   * are not, and once as a share row offering to remove them. It also put two entries
   * with the same `userId` into `InviteField`, which keys on it.
   */
  const selfShare = useMemo(
    () => (session?.user ? (rows.find((row) => row.userId === session.user.id) ?? null) : null),
    [rows, session],
  );

  /**
   * Stable, because `InviteField` fires it from an effect keyed on the callback itself.
   * A fresh lambda every render would re-run that effect every render.
   *
   * An open confirm closes when the field takes over the list: the rows are replaced by
   * the search results, and a confirm about a row nobody can see would reappear under it
   * minutes later, attached to a click the reader has forgotten making.
   */
  const onInviteActive = useCallback((active: boolean) => {
    setInviting(active);
    if (active) setRemoving(null);
  }, []);

  /**
   * Who the field must show as already here — the viewer included, because adding
   * yourself to an agent you already own is the one result that could never do
   * anything.
   */
  const present = useMemo(
    () => [
      // Only when the viewer is NOT already one of the rows below — otherwise the same
      // person is in the list twice, under two different labels, and `InviteField`
      // keys its rows on `userId`.
      ...(session?.user && !selfShare
        ? [
            {
              userId: session.user.id,
              name: session.user.name ?? null,
              email: session.user.email ?? null,
              image: session.user.image ?? null,
              roleLabel: 'Owner',
            },
          ]
        : []),
      ...rows.map((row) => ({
        userId: row.userId,
        name: row.name,
        email: row.email,
        image: row.image,
        roleLabel: agentRoleLabel(row.role),
      })),
    ],
    [rows, session, selfShare],
  );

  if (isPending) {
    return (
      <div className={cn('flex flex-col gap-2 p-3', className)}>
        {[0, 1, 2].map((index) => (
          <div key=[redacted] className="bg-muted h-12 animate-pulse rounded" />
        ))}
      </div>
    );
  }

  return (
    <div className={cn('-my-1 flex flex-col', className)}>
      <Tabs
        type="underline"
        value={view}
        onValueChange={(next) => setView(next === 'perspective' ? 'perspective' : 'share')}
      >
        <TabsList className="w-full justify-start gap-2 px-2">
          <TabsTrigger value="share" className="text-xs">
            Share
          </TabsTrigger>
          <TabsTrigger value="perspective" className="text-xs">
            View as
          </TabsTrigger>
        </TabsList>
      </Tabs>

      {view === 'perspective' ? (
        <PerspectiveTab
          rows={rows}
          onViewAs={(row) => viewAs({ userId: row.userId, name: row.name ?? row.email ?? null })}
        />
      ) : (
        <>
          {/* The same field, at the same place, opening the same list. It replaces an "Add
          people" button in the footer that opened a second popover: two clicks and a
          layer for the act the panel is FOR. */}
          <InviteField
            directory={directory?.members ?? []}
            people={present}
            roles={AGENT_ROLES.map((role) => ({ value: role.value, label: role.label }))}
            defaultRole="viewer"
            onAdd={(member, role) => share({ agentId, toUserId: member.id, role })}
            onInviteGuest={(email, role) => share({ agentId, toEmail: email, role })}
            onActiveChange={onInviteActive}
          />

          {/* Replaced by the results, not hidden behind them: the people you already share
          with would otherwise be on the surface twice, once in each. */}
          <div className="px-1.5 pb-1">
            {/* You are a person this agent runs for, so you are a row — not the words "Only
            you.", which is a sentence standing where a list should be. */}
            {inviting || !session?.user || selfShare ? null : (
              <div className="hover:bg-hover flex items-center gap-2 rounded-lg px-2 py-1.5">
                <PersonAvatar
                  userId={session.user.id}
                  name={session.user.name ?? null}
                  email={session.user.email ?? null}
                  size={20}
                  className="shrink-0"
                />
                <div className="flex min-w-0 flex-1 flex-col">
                  <span className="truncate text-sm">
                    {session.user.name ?? session.user.email}
                    <span className="text-muted-foreground"> (You)</span>
                  </span>
                  {session.user.name && session.user.email ? (
                    <span className="text-muted-foreground truncate text-xs">
                      {session.user.email}
                    </span>
                  ) : null}
                </div>
                <span className="text-muted-foreground shrink-0 pr-1.5 text-xs">Owner</span>
              </div>
            )}

            {inviting
              ? null
              : rows.map((row) => (
                  <div key=[redacted]
                    <PersonMenu
                      name={row.name ?? row.email ?? row.userId}
                      image={row.image}
                      second={statusLine(row)}
                      failed={row.status === 'failed'}
                      userId={row.userId}
                      email={row.email}
                      label={agentRoleLabel(row.role)}
                      status={row.status}
                      canResend={row.canResend}
                      onPickRole={(role) => share({ agentId, toUserId: row.userId, role })}
                      onResend={() => resend({ shareId: row.shareId })}
                      onRetry={() => retry({ shareId: row.shareId })}
                      // Nothing is written here. `Remove` ASKS, and the sentence it
                      // opens is the only place the reader is told what survives.
                      onRemove={() => setRemoving(row.userId)}
                    />

                    {removing === row.userId ? (
                      <UnshareConfirm
                        name={row.name ?? row.email ?? row.userId}
                        onCancel={() => setRemoving(null)}
                        onConfirm={() => {
                          setRemoving(null);
                          unshare({ agentId, toUserId: row.userId });
                        }}
                      />
                    ) : null}
                  </div>
                ))}
          </div>

          {error ? <FormError className="mx-3.5 mb-2">{error}</FormError> : null}
        </>
      )}
    </div>
  );
}

/**
 * Whose copy of this agent am I looking at?
 *
 * ── WHY THIS NEEDS A WHOLE TAB TO SAY ONE THING ─────────────────────────────
 *
 * Because the one thing is not obvious and cannot be inferred from a control. A shared
 * agent is not one agent that several people can see — accepting it writes trigger
 * blocks into the recipient's OWN playbook and starting files into their OWN folder,
 * as them. So "Ravi's copy" has different triggers, different files and different runs
 * from yours, and nothing on the screen tells you that until you have looked at one.
 * That sentence is the feature; a chevron on a row had nowhere to put it.
 *
 * It is READ-ONLY, structurally and deliberately — see `view-as.tsx`. The server
 * refuses `asUserId` on every mutation, and the UI hides mutation affordances rather
 * than disabling them, so what you get is their view and not their keyboard.
 */
function PerspectiveTab({
  rows,
  onViewAs,
}: {
  rows: readonly {
    userId: string;
    name: string | null;
    email: string | null;
    image: string | null;
    status: string;
  }[];
  onViewAs: (row: { userId: string; name: string | null; email: string | null }) => void;
}) {
  return (
    <div className="flex flex-col">
      <p className="text-muted-foreground px-3.5 pb-1.5 pt-2.5 text-xs">
        Accepting this agent writes triggers into each person&rsquo;s own playbook and files into
        their own folder. Pick somebody to see the agent as they have it — their triggers, their
        config and their runs. You can read it; nothing here changes it.
      </p>

      <div className="px-1.5 pb-1.5">
        {rows.length === 0 ? (
          <p className="text-muted-foreground px-2 py-1.5 text-xs">
            Nobody else has this agent yet.
          </p>
        ) : (
          rows.map((row) => (
            <button
              key=[redacted]
              type="button"
              onClick={() => onViewAs(row)}
              className="hover:bg-hover flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-left"
            >
              <PersonAvatar
                userId={row.userId}
                name={row.name}
                email={row.email}
                image={row.image}
                size={20}
                className="shrink-0"
              />
              <div className="flex min-w-0 flex-1 flex-col">
                <span className="truncate text-sm">{row.name ?? row.email ?? row.userId}</span>
                {row.status === 'invited' ? (
                  // Their copy exists as an invitation and nothing else yet, which is
                  // exactly the thing worth seeing before you ask why it is empty.
                  <span className="text-muted-foreground truncate text-xs">Not set up yet</span>
                ) : null}
              </div>
              <Eye className="text-muted-foreground size-3.5 shrink-0" aria-hidden />
            </button>
          ))
        )}
      </div>
    </div>
  );
}

/**
 * One shared person: the whole row, and one control on it.
 *
 * ── WHY THE STATE IS A LINE AND NOT A CHIP BESIDE A BUTTON ──────────────────
 *
 * The row used to carry, at its right edge, a `Resend` button AND an `Invited` chip AND
 * a role control — three things saying two facts, in the last 140px of a 400px panel.
 * The FACT (where this invite stands) is now the row's second line, where a person's
 * address goes on every other panel; the ACTS (resend, retry, change what they are,
 * remove) are in the menu the row opens, because that is what a menu is for.
 *
 * The distinction that survives is the one worth keeping: somebody invited three days
 * ago and never set up must not read the same as somebody the agent is running for, or
 * the owner concludes the team is covered when half of it is not.
 */
function PersonMenu({
  name,
  image,
  second,
  failed,
  userId,
  email,
  label,
  status,
  canResend,
  onPickRole,
  onResend,
  onRetry,
  onRemove,
}: {
  name: string;
  image: string | null;
  second: string;
  /** A failed setup says why, in the destructive colour, where the address would be. */
  failed: boolean;
  userId: string;
  email: string | null;
  label: string;
  status: string;
  canResend: boolean;
  onPickRole: (role: AgentRole) => void;
  onResend: () => void;
  onRetry: () => void;
  onRemove: () => void;
}) {
  const [open, setOpen] = useState(false);
  const item = cn(
    'hover:bg-hover mx-1.5 flex cursor-pointer items-center gap-2',
    'w-[calc(100%-0.75rem)] rounded-lg py-1.5 pl-2 pr-3 text-left text-sm',
  );

  return (
    <Popover open={open} onOpenChange={setOpen}>
      <PopoverTrigger asChild>
        <button
          type="button"
          aria-label={`${name} — ${label}`}
          className="hover:bg-hover flex w-full cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5"
        >
          <PersonAvatar
            userId={userId}
            name={name}
            email={email}
            image={image}
            size={20}
            className="shrink-0"
          />
          <div className="flex min-w-0 flex-1 flex-col text-left">
            <span className="truncate text-sm">{name}</span>
            <span
              className={cn(
                'truncate text-xs',
                failed ? 'text-destructive' : 'text-muted-foreground',
              )}
            >
              {second}
            </span>
          </div>
          <span className="text-muted-foreground flex shrink-0 items-center gap-0.5 pr-0.5 text-xs">
            {label}
            <ChevronDown className="size-3" aria-hidden />
          </span>
        </button>
      </PopoverTrigger>
      <PopoverContent align="end" sideOffset={6} className="w-[15rem] p-0">
        <div className="py-1.5">
          {AGENT_ROLES.map((role) => (
            <button
              key=[redacted]
              type="button"
              onClick={() => {
                setOpen(false);
                onPickRole(role.value);
              }}
              className={item}
            >
              <span className="flex min-w-0 flex-1 flex-col">
                <span className="truncate">{role.label}</span>
                <span className="text-muted-foreground truncate text-xs">{role.hint}</span>
              </span>
              <Check
                aria-hidden
                className={cn('size-3.5 shrink-0', role.label === label ? '' : 'opacity-0')}
              />
            </button>
          ))}

          {status === 'failed' || status === 'invited' ? (
            <>
              <div className="bg-seam my-1.5 h-px" />
              {status === 'failed' ? (
                <button
                  type="button"
                  onClick={() => {
                    setOpen(false);
                    onRetry();
                  }}
                  className={item}
                >
                  Retry setup
                </button>
              ) : (
                // The server owns the hour gate; the row's second line already says when
                // the last one went, which is the fact that makes a refusal legible.
                <button
                  type="button"
                  disabled={!canResend}
                  onClick={() => {
                    setOpen(false);
                    onResend();
                  }}
                  className={cn(item, !canResend && 'cursor-not-allowed opacity-50')}
                >
                  Resend invite
                </button>
              )}
            </>
          ) : null}

          <div className="bg-seam my-1.5 h-px" />
          <button
            type="button"
            onClick={() => {
              setOpen(false);
              onRemove();
            }}
            className={cn(item, 'text-destructive')}
          >
            Remove
          </button>
        </div>
      </PopoverContent>
    </Popover>
  );
}

/**
 * The one thing `Remove` does not say by itself: what SURVIVES it.
 *
 * ── WHY A CONFIRM AT ALL, ON A ROW THAT IS EASY TO PUT BACK ─────────────────
 *
 * Not because it is hard to undo — re-sharing is one search away. Because it is
 * asymmetric in a direction nobody can guess. `unshareAgent` revokes the grant and
 * takes the trigger blocks OUT of that person's playbook, and it deliberately leaves
 * every file the agent has already written in their space: a coaching log, a set of
 * outputs, whatever the agent's whole purpose was. "Remove" reads as "undo the share",
 * and half of what the share produced is not undone. The question actually being asked
 * underneath the click is "what happens to the log it wrote about me", and this is the
 * one surface that can answer it.
 *
 * ── WHY IT IS IN THE PANEL, UNDER THE ROW, AND NOT A TOAST OR A DIALOG ──────
 *
 * A toast is gone while the reader is still re-reading it (CLAUDE.md → Forms; R3), and
 * it arrives AFTER the act, which is the wrong side of a decision. A modal dialog puts
 * the sentence on a surface that hides the list it is about — the same list that says
 * whether this person is one the agent is actually running for. Under the row, the row
 * is still there to read, which is exactly the shape `SharePanel`'s "remove myself"
 * confirm and `GeneralAccess`'s count both take.
 *
 * Design: apps/server/docs/sharing.md §3.2 F16, R2/R3.
 */
function UnshareConfirm({
  name,
  onConfirm,
  onCancel,
}: {
  name: string;
  onConfirm: () => void;
  onCancel: () => void;
}) {
  return (
    <div className="flex flex-col gap-1.5 px-2 pb-2 pt-1">
      <p className="text-sm">
        {name}&rsquo;s triggers for this agent are removed. The files it already wrote in their
        space stay.
      </p>
      <FormActions>
        <Button
          size="sm"
          variant="destructive"
          className="h-6 cursor-pointer px-2.5 text-xs"
          onClick={onConfirm}
        >
          Remove
        </Button>
        <Button
          size="sm"
          variant="ghost"
          className="h-6 cursor-pointer px-2.5 text-xs"
          onClick={onCancel}
        >
          Cancel
        </Button>
      </FormActions>
    </div>
  );
}

/**
 * Where this invite stands, in one line.
 *
 * Was a chip AND a sentence. The chip said `Invited`, the line under it said
 * `Invited 3 days ago`, and the two sat either side of a `Resend` button — three
 * elements for one fact.
 */
function statusLine(row: {
  status: string;
  notifiedAt: string | null;
  statusMessage: string | null;
  email: string | null;
}): string {
  if (row.status === 'failed') return row.statusMessage ?? 'Setup failed';
  if (row.status === 'declined') return 'Declined';
  if (row.status === 'invited') {
    return row.notifiedAt ? `Invited ${relativeDay(row.notifiedAt)}` : 'Not yet invited';
  }
  return row.email ?? 'Running for them';
}

/** "3 days ago" — enough to tell a stale invite from a fresh one, and no more. */
function relativeDay(at: string): string {
  const days = Math.floor((Date.now() - new Date(at).getTime()) / 86_400_000);
  if (days <= 0) return 'today';
  if (days === 1) return 'yesterday';
  return `${days} days ago`;
}