channel-link-proposals-panel.tsx5.3 KBView on GitHub
'use client';

import { Link2, Loader2, Sparkles } from 'lucide-react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import type { inferRouterOutputs } from '@trpc/server';
import { useTRPC } from '@/providers/query-provider';
import { Skeleton } from '@/components/ui/skeleton';
import type { AppRouter } from '@zero/server/trpc';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { toast } from 'sonner';

/**
 * Channels the linking ladder could not decide, offered for a one-click human decision.
 * (design: apps/server/docs/channel-sync-architecture.md §3.2 step 11, Phase 10)
 *
 * The design's governing asymmetry is that syncing everything is cheap and safe while a link
 * inferred from a guess writes contamination into the CRM. So when the evidence points at more
 * than one deal — or at a deal only because of how somebody typed the channel's name — the ladder
 * refuses to choose and records `link_state: 'ambiguous'` with its candidates. This panel is the
 * other half of that decision: a question recorded and never asked is the same as no question.
 *
 * Two deliberate omissions:
 *  - `pending` channels are NOT shown. An unlinked container with no evidence is a resting state,
 *    not a to-do, and a list of them is a list nobody can act on.
 *  - There is no "let the AI pick" button here. Suggesting a deal with a model is a separate,
 *    explicitly-invoked action on the channel itself, and it still only suggests.
 */

type LinkProposals = inferRouterOutputs<AppRouter>['channels']['linkProposals'];
type LinkProposal = LinkProposals['proposals'][number];

interface ChannelLinkProposalsPanelProps {
  className?: string;
}

export function ChannelLinkProposalsPanel({ className }: ChannelLinkProposalsPanelProps) {
  const trpc = useTRPC();
  const queryClient = useQueryClient();

  const proposalsQuery = trpc.channels.linkProposals.queryOptions({ ambiguousOnly: true });
  const { data, isLoading, isError } = useQuery(proposalsQuery);

  const invalidate = () =>
    queryClient.invalidateQueries({ queryKey=[redacted] });

  const { mutate: resolveLink, isPending: isResolving } = useMutation(
    trpc.channels.resolveLink.mutationOptions({
      onSuccess: () => {
        // Recorded as `manual`, which the ladder will never override — so the row leaves this
        // list permanently rather than reappearing on the next sweep.
        void invalidate();
      },
      onError: (error) => toast.error(`Could not link: ${error.message}`),
    }),
  );

  const { mutate: sweep, isPending: isSweeping } = useMutation(
    trpc.channels.sweepLinks.mutationOptions({
      onSuccess: () => {
        void invalidate();
      },
      onError: (error) => toast.error(`Re-check failed: ${error.message}`),
    }),
  );

  if (isLoading) {
    return (
      <div className={cn('bg-popover space-y-3 rounded-lg border p-4', className)}>
        <Skeleton className="h-4 w-40" />
        <Skeleton className="h-3 w-64" />
      </div>
    );
  }

  // A failed lookup is not a sync problem and must not take the connections screen with it.
  if (isError || !data) return null;

  const ambiguous = data.proposals;
  if (ambiguous.length === 0) return null;

  return (
    <div className={cn('bg-popover space-y-3 rounded-lg border p-4', className)}>
      <div className="flex items-center justify-between gap-3">
        <div className="min-w-0">
          <p className="text-sm font-medium">Which deal do these belong to?</p>
          <p className="text-muted-foreground text-xs">
            Cedar found more than one match and won&apos;t guess. Pick one and it stays picked.
          </p>
        </div>
        <Button
          size="sm"
          variant="ghost"
          className="shrink-0 cursor-pointer"
          onClick={() => sweep({})}
          disabled={isSweeping}
        >
          {isSweeping ? <Loader2 className="size-4 animate-spin" /> : <Sparkles className="size-4" />}
          {isSweeping ? 'Re-checking…' : 'Re-check'}
        </Button>
      </div>

      {ambiguous.map((proposal: LinkProposal) => (
        <div key=[redacted] className="space-y-2 rounded-lg border p-3">
          <div className="min-w-0">
            <p className="truncate text-sm font-medium">
              {proposal.containerName ?? proposal.externalId}
            </p>
            <p className="text-muted-foreground text-xs">{proposal.verdict.reason}</p>
          </div>
          <div className="flex flex-wrap gap-2">
            {proposal.verdict.candidates.map((candidate) => (
              <Button
                key=[redacted]
                size="sm"
                variant="secondary"
                className="cursor-pointer"
                disabled={isResolving}
                onClick={() =>
                  resolveLink({
                    channel: proposal.channel,
                    containerKey=[redacted],
                    conversationId: candidate.conversationId,
                  })
                }
              >
                <Link2 className="size-4" />
                {candidate.conversationName ?? candidate.conversationId}
              </Button>
            ))}
          </div>
        </div>
      ))}
    </div>
  );
}