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