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’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}’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`;
}