AgentShareMenu.tsx19.1 KBView on GitHub 'use client';
import { useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { AlertTriangle, CopyPlus, Link2, Share2, User, Users, UserMinus } from 'lucide-react';
import { toast } from 'sonner';
import { useTRPC } from '@/providers/query-provider';
import type { AgentScope } from '@/modules/agents/types';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { Skeleton } from '@/components/ui/skeleton';
import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { cn } from '@/lib/utils';
/**
* The server's `SharedMemoryFile`, declared here rather than re-exported.
*
* `@zero/server/agent-workspace` maps to services/agent-workspace/types.ts, which
* carries the workspace WIRE types; the sharing payloads live beside their service
* in sharing.ts and are not on that entry point. Restating the two shapes the menu
* consumes is cheaper than widening the package export for one screen.
*/
export interface SharedMemoryFilePreview {
/** Filename including the `.md`, e.g. `corrections.md`. */
name: string;
chars: number;
/** The VERBATIM text. The preview IS the consent — see AgentPublishDialogBody. */
text: string;
}
export interface AgentPublishPreview {
agentId: string;
name: string;
/** Where the org copy will land. */
targetPath: string;
alreadyPublished: boolean;
/** `files:` grants that would break for every teammate. Non-empty ⇒ refuse. */
grantViolations: string[];
/** Every memory file the author COULD share. None travels unless ticked. */
memoryFiles: SharedMemoryFilePreview[];
}
// ── Scope chip ───────────────────────────────────────────────────────────────
/**
* Personal vs Team, beside the agent name.
*
* The path is the ACL: a `user/` doc is yours alone and an `organisation/` doc is
* the whole team's. The chip is the only place that distinction is visible before
* you open the share menu, so it is a chip and not a menu item.
*/
export function AgentScopeChip({ scope, className }: { scope: AgentScope; className?: string }) {
const isOrg = scope === 'org';
return (
<Badge variant="secondary" className={cn('gap-1', className)}>
{isOrg ? <Users className="h-3 w-3" /> : <User className="h-3 w-3" />}
{isOrg ? 'Team' : 'Personal'}
</Badge>
);
}
// ── Publish ──────────────────────────────────────────────────────────────────
interface AgentPublishDialogBodyProps {
preview?: AgentPublishPreview;
isLoading?: boolean;
isPublishing?: boolean;
errorMessage?: string | null;
/** Receives the memory filenames the author ticked — `[]` is the default. */
onPublish: (includeMemoryFiles: string[]) => void;
onCancel: () => void;
}
/**
* What publishing WOULD share, shown before anything is written.
*
* Two rules this body exists to enforce, both of them about consent:
*
* 1. **A grant violation blocks the publish.** A `files:` grant naming a `user/`
* path produces an org agent that fails for everyone but its author. Every
* offending path is listed, because fixing one of four and being refused again
* is the worst version of this.
* 2. **Memory is opt-in, per file, with the exact text on screen.** `corrections.md`
* is one person's record of their own mistakes and may quote deal specifics they
* never meant to publish. A summary or a file count is not consent; the text is.
*/
export function AgentPublishDialogBody({
preview,
isLoading,
isPublishing,
errorMessage,
onPublish,
onCancel,
}: AgentPublishDialogBodyProps) {
// Selection lives HERE, not in the caller, so that "starts empty" is a property
// of the component rather than of whoever remembered to pass an empty array.
const [selected, setSelected] = useState<string[]>([]);
const toggle = (name: string) =>
setSelected((prev) => (prev.includes(name) ? prev.filter((n) => n !== name) : [...prev, name]));
if (isLoading || !preview) {
return (
<div className="flex flex-col gap-2">
<Skeleton className="h-5 w-2/3" />
<Skeleton className="h-24 w-full" />
</div>
);
}
const blocked = preview.grantViolations.length > 0;
return (
<div className="flex flex-col gap-4">
<div className="text-muted-foreground flex flex-col gap-1 text-sm">
<p>
Teammates pick this agent up on their next read. Your own copy stays private and keeps
overriding the team copy for you.
</p>
<p className="font-mono text-xs">{preview.targetPath}</p>
{preview.alreadyPublished && (
<p className="text-xs">A team copy already exists — publishing replaces it.</p>
)}
</div>
{blocked && (
<div className="border-destructive/40 bg-destructive/10 text-destructive flex flex-col gap-1.5 rounded-md border px-3 py-2 text-sm">
<span className="flex items-center gap-2 font-medium">
<AlertTriangle className="h-4 w-4 shrink-0" />
This agent cannot be published yet
</span>
<span className="text-xs">
These file grants point into your personal namespace, so the agent would fail for
every teammate. Widen or remove them, then publish.
</span>
<ul className="flex flex-col gap-0.5 pl-6 font-mono text-xs">
{preview.grantViolations.map((path) => (
<li key=[redacted] className="break-all">
{path}
</li>
))}
</ul>
</div>
)}
<div className="flex flex-col gap-2">
<div>
<h4 className="text-sm font-medium">Memory files</h4>
<p className="text-muted-foreground text-xs">
Nothing here is shared unless you tick it. Memory is your own record of your own
corrections and may quote deal specifics. Teammates learn separately either way — one
agent document, one memory folder each.
</p>
</div>
{preview.memoryFiles.length === 0 ? (
<p className="text-muted-foreground text-sm">This agent has no memory files to share.</p>
) : (
<div className="flex flex-col gap-2">
{preview.memoryFiles.map((file) => {
const inputId = `share-memory-${file.name}`;
return (
<div key=[redacted] className="border-border flex flex-col gap-1.5 rounded-md border px-3 py-2">
<div className="flex items-center gap-2.5">
<Checkbox
id={inputId}
checked={selected.includes(file.name)}
onCheckedChange={() => toggle(file.name)}
/>
<Label htmlFor={inputId} className="font-mono text-xs">
{file.name}
</Label>
<span className="text-muted-foreground ml-auto shrink-0 text-xs tabular-nums">
{file.chars} chars
</span>
</div>
{/* The EXACT text, not a summary. Consent to share a paraphrase is
not consent to share what the file actually says. */}
<pre className="bg-muted/40 text-muted-foreground max-h-40 overflow-auto whitespace-pre-wrap rounded-md px-2 py-1.5 text-xs">
{file.text}
</pre>
</div>
);
})}
</div>
)}
</div>
{errorMessage && <p className="text-destructive text-sm">{errorMessage}</p>}
<DialogFooter>
<Button variant="outline" onClick={onCancel} disabled={isPublishing}>
Cancel
</Button>
<Button onClick={() => onPublish(selected)} disabled={blocked || isPublishing}>
{preview.alreadyPublished ? 'Republish to team' : 'Publish to team'}
</Button>
</DialogFooter>
</div>
);
}
// ── Duplicate ────────────────────────────────────────────────────────────────
interface AgentDuplicateDialogBodyProps {
defaultName: string;
isDuplicating?: boolean;
errorMessage?: string | null;
onDuplicate: (newName: string) => void;
onCancel: () => void;
}
/** A copy is a NEW agent: new id, new output namespace, and an empty memory folder. */
export function AgentDuplicateDialogBody({
defaultName,
isDuplicating,
errorMessage,
onDuplicate,
onCancel,
}: AgentDuplicateDialogBodyProps) {
const [name, setName] = useState(defaultName);
const trimmed = name.trim();
return (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1.5">
<Label htmlFor="duplicate-agent-name">Name</Label>
<Input
id="duplicate-agent-name"
value={name}
onChange={(e) => setName(e.target.value)}
autoComplete="off"
/>
</div>
{/* Said out loud, because the alternative is a new agent that arrives
pre-loaded with conclusions learned against a different context. */}
<p className="text-muted-foreground text-sm">
The copy starts with an empty memory folder. Nothing this agent has learned travels with
it — memory was learned against this agent, and inheriting it would pre-load the copy with
someone else{"'"}s wrong conclusions. Runs and outputs do not travel either: they are keyed
by the agent id, and the copy mints a new one.
</p>
{errorMessage && <p className="text-destructive text-sm">{errorMessage}</p>}
<DialogFooter>
<Button variant="outline" onClick={onCancel} disabled={isDuplicating}>
Cancel
</Button>
<Button onClick={() => onDuplicate(trimmed)} disabled={!trimmed || isDuplicating}>
Duplicate
</Button>
</DialogFooter>
</div>
);
}
// ── Unpublish ────────────────────────────────────────────────────────────────
interface AgentUnpublishDialogBodyProps {
agentName: string;
isUnpublishing?: boolean;
errorMessage?: string | null;
onUnpublish: () => void;
onCancel: () => void;
}
/** Deleting the org copy. Loud about what goes, and equally loud about what stays. */
export function AgentUnpublishDialogBody({
agentName,
isUnpublishing,
errorMessage,
onUnpublish,
onCancel,
}: AgentUnpublishDialogBodyProps) {
return (
<div className="flex flex-col gap-4">
<p className="text-sm">
Teammates lose access to {agentName}. Your own copy stays exactly where it is.
</p>
{/* Keyed by agent_id, not by the document, so removing the document removes
none of them. Users assume a delete cascades; here it does not. */}
<p className="text-muted-foreground text-sm">
Untouched: every run already recorded, every file the agent produced, and every
teammate{"'"}s memory folder. Those are keyed by the agent id rather than by the shared
document, so removing the document leaves them intact.
</p>
{errorMessage && <p className="text-destructive text-sm">{errorMessage}</p>}
<DialogFooter>
<Button variant="outline" onClick={onCancel} disabled={isUnpublishing}>
Cancel
</Button>
<Button variant="destructive" onClick={onUnpublish} disabled={isUnpublishing}>
Unpublish
</Button>
</DialogFooter>
</div>
);
}
// ── The menu ─────────────────────────────────────────────────────────────────
type OpenDialog = 'publish' | 'duplicate' | 'unpublish' | null;
interface AgentShareMenuProps {
agentId: string;
agentName: string;
scope: AgentScope;
/** The parent owns navigation: a duplicate is a different agent at a different URL. */
onDuplicated?: (result: { agentId: string; name: string; path: string }) => void;
onPublished?: () => void;
onUnpublished?: () => void;
className?: string;
}
/**
* The workspace header menu — Publish to team · Duplicate · Copy link — plus the
* Personal/Team chip.
*
* Publish never fires straight from the menu item: it opens `previewPublish` first,
* which writes nothing and answers the two questions the author cannot otherwise
* see (would this break for teammates, and what exactly of mine would be shared).
*/
export function AgentShareMenu({
agentId,
agentName,
scope,
onDuplicated,
onPublished,
onUnpublished,
className,
}: AgentShareMenuProps) {
const trpc = useTRPC();
const queryClient = useQueryClient();
const [open, setOpen] = useState<OpenDialog>(null);
const invalidate = () => {
void queryClient.invalidateQueries({ queryKey=[redacted] agentId }) });
void queryClient.invalidateQueries({
queryKey=[redacted] agentId }),
});
};
// Read-only and only while the dialog is open: it is a preview of a write, not a
// page load, and running it on mount would probe memory for every agent opened.
const { data: preview, isLoading: previewLoading } = useQuery({
...trpc.agent.previewPublish.queryOptions({ agentId }),
enabled: open === 'publish',
});
const publish = useMutation(
trpc.agent.publishToOrg.mutationOptions({
onSuccess: (result: { sharedMemory?: unknown[] } | undefined) => {
const shared = result?.sharedMemory?.length ?? 0;
toast.success(
shared > 0
? `Published to your team with ${shared} memory file${shared === 1 ? '' : 's'}.`
: 'Published to your team. No memory was shared.',
);
setOpen(null);
invalidate();
onPublished?.();
},
}),
);
const duplicate = useMutation(
trpc.agent.duplicate.mutationOptions({
onSuccess: (result: { agentId: string; name: string; path: string }) => {
toast.success(`Duplicated as ${result.name}. Its memory folder starts empty.`);
setOpen(null);
invalidate();
onDuplicated?.(result);
},
}),
);
const unpublish = useMutation(
trpc.agent.unpublishFromOrg.mutationOptions({
onSuccess: () => {
toast.success('Removed from your team. Runs, outputs and memory are untouched.');
setOpen(null);
invalidate();
onUnpublished?.();
},
}),
);
const copyLink = async () => {
const url = typeof window === 'undefined' ? '' : window.location.href;
try {
await navigator.clipboard?.writeText(url);
toast.success('Link copied.');
} catch {
// A denied clipboard permission is not an error worth a red toast — the user
// can still copy the address bar — but silence would read as a broken button.
toast.info(url);
}
};
return (
<div className={cn('flex items-center gap-2', className)}>
<AgentScopeChip scope={scope} />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-1.5" aria-label="Share agent">
<Share2 className="h-3.5 w-3.5" />
Share
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-56">
<DropdownMenuItem onSelect={() => setOpen('publish')}>
<Users className="mr-2 h-4 w-4" />
{scope === 'org' ? 'Republish to team' : 'Publish to team'}
</DropdownMenuItem>
{scope === 'org' && (
<DropdownMenuItem onSelect={() => setOpen('unpublish')}>
<UserMinus className="mr-2 h-4 w-4" />
Unpublish from team
</DropdownMenuItem>
)}
<DropdownMenuSeparator />
<DropdownMenuItem onSelect={() => setOpen('duplicate')}>
<CopyPlus className="mr-2 h-4 w-4" />
Duplicate
</DropdownMenuItem>
<DropdownMenuItem onSelect={() => void copyLink()}>
<Link2 className="mr-2 h-4 w-4" />
Copy link
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Dialog open={open === 'publish'} onOpenChange={(v) => !v && setOpen(null)}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle>Publish {agentName} to your team</DialogTitle>
<DialogDescription>
Everything below is a preview. Nothing is written until you publish.
</DialogDescription>
</DialogHeader>
<AgentPublishDialogBody
// Remount on reopen so a previous run of this dialog cannot leave ticks
// behind: consent is per publish, not per browser tab.
key=[redacted] ?? 'loading'}
preview={preview as AgentPublishPreview | undefined}
isLoading={previewLoading}
isPublishing={publish.isPending}
errorMessage={publish.error?.message ?? null}
onPublish={(includeMemoryFiles) =>
publish.mutate({ agentId, includeMemoryFiles })
}
onCancel={() => setOpen(null)}
/>
</DialogContent>
</Dialog>
<Dialog open={open === 'duplicate'} onOpenChange={(v) => !v && setOpen(null)}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Duplicate {agentName}</DialogTitle>
<DialogDescription>A copy with its own id, outputs and memory.</DialogDescription>
</DialogHeader>
<AgentDuplicateDialogBody
defaultName={`${agentName} copy`}
isDuplicating={duplicate.isPending}
errorMessage={duplicate.error?.message ?? null}
onDuplicate={(newName) => duplicate.mutate({ agentId, newName })}
onCancel={() => setOpen(null)}
/>
</DialogContent>
</Dialog>
<Dialog open={open === 'unpublish'} onOpenChange={(v) => !v && setOpen(null)}>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>Unpublish {agentName}</DialogTitle>
<DialogDescription>Removes the team copy of this agent.</DialogDescription>
</DialogHeader>
<AgentUnpublishDialogBody
agentName={agentName}
isUnpublishing={unpublish.isPending}
errorMessage={unpublish.error?.message ?? null}
onUnpublish={() => unpublish.mutate({ agentId })}
onCancel={() => setOpen(null)}
/>
</DialogContent>
</Dialog>
</div>
);
}