MomentFenceNode.tsx9.8 KBView on GitHub 'use client';
import type { MarkdownParseHelpers, MarkdownToken } from '@tiptap/core';
import { NodeViewWrapper, ReactNodeViewRenderer } from '@tiptap/react';
import type { NodeViewProps } from '@tiptap/react';
import { Node } from '@tiptap/core';
import {
MeetingRecordingPlayer,
type RecordingPlayerHandle,
} from '@/modules/documents/recording/MeetingRecordingPlayer';
import type { MomentTurn, MomentVerdict } from '@/modules/documents/coaching/moment-fence';
import { clockToSeconds, providerUrlAtSecond } from '@/modules/documents/recording/timestamps';
import { decodeFenceAttr, safeHref } from '@/modules/documents/coaching/fence-attrs';
import { parseMoment } from '@/modules/documents/coaching/moment-fence';
import { ConversationBadge } from '@/modules/threads/thread/components/ConversationBadge';
import { useRef } from 'react';
/**
* Renders a ```moment fenced block as a coaching moment — prior context, the key exchange with an
* accent border, the buyer's immediate reaction, and a one-sentence coaching line beneath.
*
* The body is kept verbatim on the node so it round-trips losslessly to markdown; a body that
* fails to parse renders raw rather than a broken card.
*/
/** Tints for the highlighted moment — a soft wash plus a left rule, per the reference design. */
const MOMENT_ROW: Record<MomentVerdict, string> = {
good: 'border-l-2 border-emerald-500 bg-emerald-500/10',
bad: 'border-l-2 border-red-500 bg-red-500/10',
questionable: 'border-l-2 border-amber-500 bg-amber-500/10',
};
const TONE_TEXT: Record<MomentVerdict, string> = {
good: 'text-emerald-700 dark:text-emerald-400',
bad: 'text-red-700 dark:text-red-400',
questionable: 'text-amber-700 dark:text-amber-400',
};
/**
* One transcript line: timestamp gutter, speaker label, utterance. Laid out as a transcript
* reader rather than a chat log so the eye can scan down the times.
*/
function TranscriptLine({
turn,
highlighted,
verdict,
onSeek,
}: {
turn: MomentTurn;
highlighted?: boolean;
verdict: MomentVerdict;
/** Jump the card's player to this line. Absent when the moment has no playable recording. */
onSeek?: (seconds: number) => void;
}) {
const at = clockToSeconds(turn.at);
const seekable = !!onSeek && at !== null;
return (
<div className="flex gap-3 px-3 py-1.5">
{/* The timestamp is the control: clicking it plays the call from that line. Rendered as a
button only when there is something to seek, so a moment with no recording does not
offer a dead affordance. */}
<button
type="button"
disabled={!seekable}
onClick={seekable ? () => onSeek!(at!) : undefined}
title={seekable ? `Play from ${turn.at}` : undefined}
className={`w-11 shrink-0 pt-0.5 text-left text-xs tabular-nums ${
highlighted ? `font-semibold ${TONE_TEXT[verdict]}` : 'text-muted-foreground'
} ${seekable ? 'cursor-pointer underline-offset-2 hover:underline' : 'cursor-default'}`}
>
{turn.at ?? ''}
</button>
<div className="min-w-0 flex-1">
{turn.speaker ? (
<div
className={`text-xs font-semibold uppercase tracking-wide ${
highlighted ? 'text-foreground' : 'text-muted-foreground'
}`}
>
{turn.speaker}
</div>
) : null}
<p className="text-sm leading-relaxed text-foreground">{turn.text}</p>
</div>
</div>
);
}
function MomentView({ node }: NodeViewProps) {
const body = (node.attrs.body as string) ?? '';
const moment = parseMoment(body);
// The body is agent-authored document content, so the link is untrusted input.
const recording = safeHref(moment?.recording);
const playerRef = useRef<RecordingPlayerHandle>(null);
// The card can play the call only when the fence names the meeting. Documents written
// before `externalId:` existed carry a link and nothing else, and still render — they just
// get the old anchor instead of a player, rather than an empty frame.
const externalId = moment?.externalId;
const momentAt = clockToSeconds(moment?.at);
const seek = externalId ? (seconds: number) => playerRef.current?.seekTo(seconds) : undefined;
if (!moment) {
return (
<NodeViewWrapper as="div" className="my-3" contentEditable={false}>
<pre className="rounded-md border border-border bg-muted p-3 font-mono text-xs">{body}</pre>
</NodeViewWrapper>
);
}
return (
<NodeViewWrapper
as="div"
className="my-4 overflow-hidden rounded-lg border border-border bg-background"
contentEditable={false}
>
<div className="border-b border-border px-3 py-2.5">
<div className="flex items-start justify-between gap-3">
{/* Deal › event › date. The deal uses the same badge as the open-email header, so a
conversation looks and behaves the same wherever it turns up. `linkable={false}`:
the deal is a fact about the call this moment came from, not something to rewire
from inside a report — see ConversationBadge's `linkable` doc. */}
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted-foreground">
{moment.conversationId ? (
<ConversationBadge conversationId={moment.conversationId} linkable={false} />
) : (
<span className="font-semibold text-foreground">{moment.deal}</span>
)}
<span aria-hidden>›</span>
<span>{moment.meeting}</span>
{moment.date ? (
<>
<span aria-hidden>›</span>
<span className="tabular-nums">{moment.date}</span>
</>
) : null}
</div>
{/* Only shown when there is no player. With one, the same link lives in the player's
own footer, where it is built from the provider the SERVER resolved rather than
from a guess about what the stored URL is. */}
{recording && !externalId ? (
<a
href={providerUrlAtSecond(recording, moment.provider ?? null, momentAt) ?? recording}
target="_blank"
rel="noreferrer"
className="shrink-0 cursor-pointer rounded-md border border-border px-2.5 py-1 text-sm font-medium text-muted-foreground hover:bg-muted hover:text-foreground"
>
Open recording{moment.at ? ` · ${moment.at}` : ''}
</a>
) : null}
</div>
{/* The criterion is this card's title — it says what the moment is an example OF — so it is
sized as one. It used to inherit the metadata row's text-xs and read as a footnote. */}
{moment.criterion || moment.criterionName ? (
<div className="mt-2 flex items-baseline gap-2">
{moment.criterion ? (
<span className="rounded bg-muted px-1.5 py-0.5 text-xs font-medium text-muted-foreground">
{moment.criterion}
</span>
) : null}
{moment.criterionName ? (
<span className="text-sm font-semibold text-foreground">{moment.criterionName}</span>
) : null}
</div>
) : null}
</div>
{externalId ? (
<div className="border-b border-border p-3">
<MeetingRecordingPlayer
ref={playerRef}
externalId={externalId}
atSeconds={momentAt}
fallbackUrl={recording ?? null}
compact
/>
</div>
) : null}
<div className="py-1">
{moment.context.map((turn, i) => (
<TranscriptLine key=[redacted] turn={turn} verdict={moment.verdict} onSeek={seek} />
))}
{/* The key moment: washed background + left rule, with the coaching note directly
beneath it rather than at the foot of the card — the point of the component. */}
<div className={`my-1 ${MOMENT_ROW[moment.verdict]}`}>
{moment.moment.map((turn, i) => (
<TranscriptLine
key=[redacted]
turn={turn}
highlighted
verdict={moment.verdict}
onSeek={seek}
/>
))}
<p className={`px-3 pb-2 pl-14 text-sm font-semibold ${TONE_TEXT[moment.verdict]}`}>
Coaching note: {moment.coaching}
</p>
</div>
{moment.reaction.map((turn, i) => (
<TranscriptLine key=[redacted] turn={turn} verdict={moment.verdict} onSeek={seek} />
))}
</div>
</NodeViewWrapper>
);
}
const FENCE_RE = /^```moment[ \t]*\r?\n([\s\S]*?)\r?\n```[ \t]*(?:\r?\n|$)/;
export const MomentFenceNode = Node.create({
name: 'momentBlock',
group: 'block',
atom: true,
selectable: true,
addAttributes() {
return { body: { default: '' } };
},
parseHTML() {
return [
{
tag: 'div[data-moment-block]',
getAttrs: (el) => ({
body: decodeFenceAttr((el as HTMLElement).getAttribute('data-moment-block')),
}),
},
];
},
renderHTML({ node }) {
return ['div', { 'data-moment-block': encodeURIComponent((node.attrs.body as string) ?? '') }];
},
addNodeView() {
return ReactNodeViewRenderer(MomentView);
},
markdownTokenizer: {
name: 'momentBlock',
level: 'block',
start(src: string) {
const idx = src.indexOf('```moment');
return idx >= 0 ? idx : -1;
},
tokenize(src: string): MarkdownToken | undefined {
const match = FENCE_RE.exec(src);
if (!match) return undefined;
return { type: 'momentBlock', raw: match[0], body: match[1] };
},
},
parseMarkdown(token=[redacted], helpers: MarkdownParseHelpers) {
return helpers.createNode('momentBlock', { body: token.body ?? '' }, []);
},
renderMarkdown(node: { attrs?: Record<string, unknown> }) {
return '```moment\n' + ((node.attrs?.body as string) ?? '') + '\n```';
},
});