CellRefChip.tsx11.6 KBView on GitHub 'use client';
/**
* A `[[type: id]]` token, rendered as a live chip.
*
* The dispatch below is the whole point of the design's step 10: a conversation cell and a
* conversation `@mention` in a prose paragraph are LITERALLY the same component, so they
* cannot drift — same data hook, same label rules, same markup. `doc` reuses
* `FileLinkChipContent` and `event` reuses `EventChipContent` for the same reason. The
* kinds with no existing chip (task, draft, person, company) share one `RefChip`
* presentation so they are uniform with each other by construction rather than by
* convention.
*
* Clicking opens the referenced object as the selected artifact — the same entry point
* `MentionChip` uses (`setSelectedArtifact({ kind, id })`), so a draft cell opens the draft's
* thread and a conversation cell opens the full ConversationView.
*
* Resolution is always live (never frozen at write time), pending renders a skeleton, and
* an unresolvable id renders a dimmed "missing" chip. Nothing here throws: a table is often
* the audit trail of a fan-out, and a deleted row must not take the grid down with it.
*/
import type { ComponentType } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Building2, FileEdit, ListChecks, User } from 'lucide-react';
import { cn } from '@/lib/utils';
import { ConversationChipContent } from '@/modules/agentCanvas/extensions/ConversationChip';
import type { ContextKind } from '@/modules/cedar-os/src/store/messages/MessageTypes';
import { REF_CHIP_CLASS, REF_CHIP_MISSING_CLASS } from '@/modules/documents/chip-styles';
import { FileLinkChipContent } from '@/modules/documents/file-link/FileLinkChip';
import { EventChipContent } from '@/modules/documents/mention/EventChip';
import { useCedarStore } from '@/modules/store';
import { useTRPC } from '@/providers/query-provider';
import { linkifyText } from './cell-links';
import type { CellChipRef, CellSegment } from './cell-refs';
export interface CellRefChipProps {
/** Named `cellRef` rather than `ref`, which React reserves as the ref prop. */
cellRef: CellChipRef;
className?: string;
}
export function CellRefChip({ cellRef, className }: CellRefChipProps) {
const { refType, refId } = cellRef;
if (refType === 'conversation') {
return (
<ConversationChipContent
conversationId={refId}
className={className}
onOpen={(id) => openArtifact('conversation', id)}
/>
);
}
if (refType === 'doc') {
return <FileLinkChipContent documentId={refId} className={className} />;
}
if (refType === 'event') {
// No live surface takes a bare event id, so an event cell is a label, not a link.
// `EventChipContent` renders a non-interactive span by default, which is exactly right
// — a chip that looks clickable and does nothing is worse than a plain chip.
//
// `title` is deliberately NOT the refId. Passing it rendered a truncated UUID styled as a
// name, which is precisely the "leak an internal id as if it were content" that
// `TableSharePreview` calls out. Omitted, the chip falls back to "Event" — the honest
// rendering, and the dimmed missing-reference treatment this module promises.
return <EventChipContent className={className} />;
}
if (refType === 'task') return <TaskRefChip taskId={refId} className={className} />;
if (refType === 'draft') return <DraftRefChip draftId={refId} className={className} />;
if (refType === 'person') return <PersonRefChip contactId={refId} className={className} />;
return <CompanyRefChip companyId={refId} className={className} />;
}
/**
* A cell value's text runs interleaved with its live chips.
*
* Shared by the editable display and by `BoundCell` so the two cannot drift on the one thing
* that is easy to get wrong: a chip is keyed by POSITION, because the same reference may
* legitimately appear twice in one cell.
*/
export function CellSegments({
segments,
wrap = false,
}: {
segments: CellSegment[];
/** Let the text between chips break onto further lines instead of being clipped. */
wrap?: boolean;
}) {
return (
<>
{segments.map((segment, position) =>
segment.kind === 'text' ? (
<span
key=[redacted]
className={wrap ? 'whitespace-pre-wrap break-words' : 'truncate'}
>
<CellText text={segment.text} />
</span>
) : (
<CellRefChip key=[redacted] cellRef={segment.ref} />
),
)}
</>
);
}
/**
* One text run, with any link in it rendered as a link.
*
* ── Why the pointer events stop here ──
*
* In the grid a mousedown means "select this cell" and a double click means "open the editor".
* Neither is what someone aiming at a URL asked for, so all three are stopped before they reach
* the cell shell — the same three `OutputTargetPill` stops for the same reason. The click itself
* is NOT prevented: the anchor is a real anchor, so middle-click, cmd-click and "copy link
* address" all work, which is most of the point of having one.
*
* Styling matches `CardFieldValue`'s url/email fields, so an address looks the same in a kanban
* card as it does in the grid the card was made from.
*/
function CellText({ text }: { text: string }) {
const runs = linkifyText(text);
if (runs.length === 1 && runs[0].kind === 'plain') return <>{text}</>;
return (
<>
{runs.map((run, position) =>
run.kind === 'plain' ? (
<span key=[redacted]
) : (
<a
key=[redacted]
href={run.href}
// An address opens the mail client in place; a page opens beside the grid rather
// than navigating away from a table someone is in the middle of reading.
target={run.href?.startsWith('mailto:') ? undefined : '_blank'}
rel="noreferrer noopener"
title={run.text}
onMouseDown={(event) => event.stopPropagation()}
onDoubleClick={(event) => event.stopPropagation()}
onClick={(event) => event.stopPropagation()}
className="text-primary cursor-pointer underline-offset-2 hover:underline"
>
{run.text}
</a>
),
)}
</>
);
}
/** `setSelectedArtifact` outside a render path — the same call `MentionChip` makes on click. */
function openArtifact(kind: ContextKind, id: string): void {
useCedarStore.getState().setSelectedArtifact({ kind, id });
}
type RefChipState = 'pending' | 'resolved' | 'missing';
interface RefChipProps {
icon: ComponentType<{ className?: string }>;
label: string;
state: RefChipState;
onOpen?: () => void;
className?: string;
}
/**
* The shared presentation for every chip kind that has no editor counterpart. One
* component so `task`, `draft`, `person` and `company` cannot diverge in padding, size or
* missing-state treatment — a uniform render path, not four near-copies.
*
* A `<span role="button">` rather than a `<button>`, mirroring `MentionChip`: a chip is an
* inline run inside a cell (and, for the shared chips, inside a ProseMirror node view),
* where a nested interactive element is the thing to avoid.
*/
function RefChip({ icon: Icon, label, state, onOpen, className }: RefChipProps) {
return (
<span
{...(onOpen ? { role: 'button', tabIndex: 0 } : {})}
title={label}
onClick={
onOpen &&
((event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
onOpen();
})
}
onKeyDown={
onOpen &&
((event: React.KeyboardEvent) => {
if (event.key !== 'Enter' && event.key !== ' ') return;
event.preventDefault();
onOpen();
})
}
className={cn(
REF_CHIP_CLASS,
onOpen && 'cursor-pointer',
state === 'missing' && REF_CHIP_MISSING_CLASS,
className,
)}
>
<Icon className="text-muted-foreground h-3 w-3 shrink-0" />
{state === 'pending' ? (
<span className="h-3 w-16 animate-pulse rounded bg-muted-foreground/20" />
) : (
<span className="truncate">{label}</span>
)}
</span>
);
}
/** `isPending` is only meaningful while the query is enabled; `isError` covers the 404. */
function resolutionState(isPending: boolean, isError: boolean, resolved: boolean): RefChipState {
if (resolved) return 'resolved';
if (isError) return 'missing';
return isPending ? 'pending' : 'missing';
}
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
function TaskRefChip({ taskId, className }: { taskId: string; className?: string }) {
const trpc = useTRPC();
const isUuid = UUID_RE.test(taskId);
const { data, isPending, isError } = useQuery({
...trpc.userTasks.getTaskById.queryOptions({ taskId }),
enabled: isUuid,
retry: false,
});
const description = (data as { task?: { description?: string | null } } | undefined)?.task
?.description;
return (
<RefChip
icon={ListChecks}
label={description?.trim() || (isUuid ? 'Missing task' : taskId)}
state={isUuid ? resolutionState(isPending, isError, !!description) : 'missing'}
onOpen={isUuid ? () => openArtifact('task', taskId) : undefined}
className={className}
/>
);
}
function DraftRefChip({ draftId, className }: { draftId: string; className?: string }) {
const trpc = useTRPC();
const { data, isPending, isError } = useQuery({
...trpc.drafts.get.queryOptions({ id: draftId }),
enabled: !!draftId,
retry: false,
});
const draft = data as { subject?: string; threadId?: string } | undefined;
const subject = draft?.subject?.trim();
// A draft has no ContextKind of its own — it opens through the email thread it belongs
// to, which is what every other draft-review surface already does.
const threadId = draft?.threadId;
return (
<RefChip
icon={FileEdit}
label={subject || (isError ? 'Missing draft' : 'Draft')}
state={resolutionState(isPending, isError, !!draft)}
onOpen={threadId ? () => openArtifact('email_thread', threadId) : undefined}
className={className}
/>
);
}
function PersonRefChip({ contactId, className }: { contactId: string; className?: string }) {
const trpc = useTRPC();
const isUuid = UUID_RE.test(contactId);
const { data, isPending, isError } = useQuery({
...trpc.crm.getContact.queryOptions({ id: contactId }),
enabled: isUuid,
retry: false,
});
const contact = data as
| { personEmail?: string | null; person?: { name?: string | null } | null }
| undefined;
const label = contact?.person?.name?.trim() || contact?.personEmail?.trim();
return (
<RefChip
icon={User}
label={label || (isUuid ? 'Missing person' : contactId)}
state={isUuid ? resolutionState(isPending, isError, !!label) : 'missing'}
className={className}
/>
);
}
function CompanyRefChip({ companyId, className }: { companyId: string; className?: string }) {
const trpc = useTRPC();
const isUuid = UUID_RE.test(companyId);
const { data, isPending, isError } = useQuery({
...trpc.crm.getCompany.queryOptions({ id: companyId }),
enabled: isUuid,
retry: false,
});
const relationship = data as
| { companyDomain?: string | null; company?: { name?: string | null } | null }
| undefined;
const label = relationship?.company?.name?.trim() || relationship?.companyDomain?.trim();
return (
<RefChip
icon={Building2}
label={label || (isUuid ? 'Missing company' : companyId)}
state={isUuid ? resolutionState(isPending, isError, !!label) : 'missing'}
className={className}
/>
);
}