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