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