ReportCanvasView.tsx8.2 KBView on GitHub /**
* ReportCanvasView — document canvas.
*
* Content is stored in viewConfig.narrative and persisted to the backend
* via updateCanvasViewConfig on change.
*
* Supports agent streaming: the agent writes via the write-document tool
* with scopeType 'user' or 'org' and scopeId = canvas.id. The existing
* doc-stream-start / doc-stream-chunk / doc-update processors route to
* activeCanvasStream and canvas.viewConfig.narrative automatically.
*
* Title is rendered inside the view following the OverviewDocTab convention
* (h2 above the editor), not in a separate CanvasHeader.
*/
import type { Canvas, ReportViewConfig } from '@/modules/canvas/types/canvas-types';
import { MarkdownEditor, MD_CONTENT_OPTS } from '@/components/markdown-editor';
import type { MarkdownEditorHandle } from '@/components/markdown-editor';
import { EventRefNode } from '@/modules/conversations/components/tiptap-extensions/EventRefNode';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { useCedarStore } from '@/modules/store';
import { useCallback, useEffect, useRef, useState } from 'react';
import { useTRPC } from '@/providers/query-provider';
import { useQuery } from '@tanstack/react-query';
import { getMeetingProviderPageUrl } from '@/modules/crm/utils/meeting-display';
import { ExternalLink, Loader2 } from 'lucide-react';
interface ReportCanvasViewProps {
canvas: Canvas;
}
export function ReportCanvasView({ canvas }: ReportCanvasViewProps) {
const updateCanvasViewConfig = useCedarStore((state) => state.updateCanvasViewConfig);
const saveCanvasViewConfig = useCedarStore((state) => state.saveCanvasViewConfig);
const isDirty = useCedarStore((state) => state.dirtyCanvasIds.has(canvas.id));
const isEphemeral = useCedarStore((state) => state.ephemeralCanvasIds.has(canvas.id));
const activeCanvasStream = useCedarStore((state) => state.activeCanvasStream);
const viewConfig = canvas.viewConfig as ReportViewConfig;
const narrative = viewConfig.narrative ?? '';
const editorRef = useRef<MarkdownEditorHandle>(null);
const isProgrammaticUpdate = useRef(false);
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
// Event dialog state — opened when user clicks [[event: uuid]] chips
const [openEventId, setOpenEventId] = useState<string | null>(null);
const trpc = useTRPC();
const { data: eventData, isLoading: isLoadingEvent } = useQuery({
...trpc.crm.getEventFullContent.queryOptions({ eventId: openEventId ?? '' }),
enabled: !!openEventId,
staleTime: 5 * 60 * 1000,
});
// What the stored recording URL points at differs by recorder, and Circleback's is a
// signed storage object that 403s a day after ingest — so the link out is the recorder's
// own page for the meeting, never the file.
const recordingPageUrl = eventData?.event ? getMeetingProviderPageUrl(eventData.event) : null;
// Auto-save when dirty (agent write or user edit marks canvas dirty)
useEffect(() => {
if (!isDirty || isEphemeral) return;
void saveCanvasViewConfig(canvas.id);
}, [isDirty, isEphemeral, canvas.id, saveCanvasViewConfig]);
// Load narrative into editor when the canvas switches
useEffect(() => {
const editor = editorRef.current?.editor;
if (!editor) return;
isProgrammaticUpdate.current = true;
editor.commands.setContent(narrative, MD_CONTENT_OPTS);
isProgrammaticUpdate.current = false;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [canvas.id]);
// Apply streaming content as chunks arrive from the agent
useEffect(() => {
if (!activeCanvasStream || activeCanvasStream.canvasId !== canvas.id) return;
const editor = editorRef.current?.editor;
if (!editor) return;
isProgrammaticUpdate.current = true;
editor.commands.setContent(activeCanvasStream.accumulatedContent, MD_CONTENT_OPTS);
isProgrammaticUpdate.current = false;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [activeCanvasStream]);
// After agent finishes streaming, sync the final narrative into the editor
useEffect(() => {
const editor = editorRef.current?.editor;
if (!editor || !narrative) return;
if (activeCanvasStream?.canvasId === canvas.id) return;
// The user's own edits round-trip through handleChange → updateCanvasViewConfig,
// which changes `narrative`. Without this guard, setContent would re-run on
// every keystroke and reset the cursor to the document end. Only apply when
// the incoming narrative differs from what the editor already holds (i.e. it
// originated from the agent, not the user).
if (editor.getMarkdown() === narrative) return;
isProgrammaticUpdate.current = true;
editor.commands.setContent(narrative, MD_CONTENT_OPTS);
isProgrammaticUpdate.current = false;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [narrative]);
const handleChange = useCallback(
(markdown: string) => {
if (isProgrammaticUpdate.current) return;
if (saveTimer.current) clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(() => {
updateCanvasViewConfig(canvas.id, { ...viewConfig, narrative: markdown });
}, 600);
},
[canvas.id, viewConfig, updateCanvasViewConfig],
);
// Listen for cedar:open-event bubbling up from EventRefNode chips
const handleEditorClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const handler = (ev: Event) => {
const eventId = (ev as CustomEvent).detail?.eventId as string | undefined;
if (eventId) setOpenEventId(eventId);
};
(e.currentTarget as HTMLElement).addEventListener('cedar:open-event', handler, { once: true });
}, []);
return (
<div className="flex h-full flex-col overflow-y-auto">
<div className="mx-auto flex w-full max-w-[75ch] flex-1 flex-col px-2.5 pb-2.5">
<h2 className="pt-3 text-xl font-bold">{canvas.title}</h2>
<MarkdownEditor
ref={editorRef}
placeholder="Start writing… (type / for commands)"
onChange={handleChange}
onClick={handleEditorClick}
extraExtensions={[EventRefNode]}
className="min-h-0 flex-1 cursor-text [&_h2]:!text-[1.0625rem] [&_h3]:!text-base [&_.ProseMirror]:!px-0"
/>
</div>
{/* Event detail dialog — opens when user clicks [[event: uuid]] chips */}
<Dialog open={!!openEventId} onOpenChange={(open) => { if (!open) setOpenEventId(null); }}>
<DialogContent className="max-w-lg">
<DialogHeader>
<DialogTitle className="truncate">
{isLoadingEvent ? 'Loading…' : eventData?.event?.title ?? 'Call'}
</DialogTitle>
</DialogHeader>
{isLoadingEvent ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : eventData?.event ? (
<div className="space-y-3 text-sm">
{eventData.event.aiNotes && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">AI Notes</p>
<p className="text-sm text-foreground leading-relaxed whitespace-pre-line line-clamp-6">
{eventData.event.aiNotes}
</p>
</div>
)}
{eventData.event.participants && eventData.event.participants.length > 0 && (
<div>
<p className="text-xs font-medium text-muted-foreground mb-1">Participants</p>
<p className="text-sm">{eventData.event.participants.map((p) => p.name || p.email).join(', ')}</p>
</div>
)}
{recordingPageUrl && (
<a
href={recordingPageUrl}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 text-xs text-blue-600 dark:text-blue-400 hover:underline"
>
<ExternalLink className="h-3 w-3" />
Open recording
</a>
)}
</div>
) : (
<p className="text-sm text-muted-foreground">Event not found.</p>
)}
</DialogContent>
</Dialog>
</div>
);
}