CompositePlaybookDocument.tsx15.7 KBView on GitHub

Introduced 1 production defect in 180 days, median 11 days to fix.

'use client';

import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { useQuery, useMutation } from '@tanstack/react-query';
import { useNavigate, useBlocker } from 'react-router';
import type { Editor } from '@tiptap/core';
import { CheckCircle, Loader2, Save } from 'lucide-react';
import { MarkdownEditor, type MarkdownEditorHandle } from '@/components/markdown-editor';
import { useTRPC, trpcClient } from '@/providers/query-provider';
import { useScopedInput, useTargetUserId } from '@/modules/administeredUser';
import {
  AlertDialog,
  AlertDialogAction,
  AlertDialogCancel,
  AlertDialogContent,
  AlertDialogDescription,
  AlertDialogFooter,
  AlertDialogHeader,
  AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { captureRouteError } from '@/lib/sentry';
import { createCompositePlaybookExtensions, createPlaybookSlashCommands } from './playbookExtensions';
import { useSubagentCreation } from './useSubagentCreation';
import { useDocumentCreation } from './useDocumentCreation';
import { resolveReferencePath } from './references';
import { mergeComposite, splitComposite, type PMNode } from './composite-merge';
import { ErrorPanel, type PlaybookIssue } from './PlaybookDocument';
import { brainDocumentPath } from '@/modules/brain/brain-routes';

type SaveState = 'idle' | 'saving' | 'error' | 'success';

interface CompositePlaybookDocumentProps {
  /** AOP slug — resolves to its user + org PLAYBOOK.md rows. */
  aop: string;
  className?: string;
  /**
   * When provided, the save controls render into this element via a portal
   * (lets a parent place them on the tabs row) instead of an inline toolbar.
   */
  toolbarContainer?: HTMLElement | null;
}

/**
 * Unified composite playbook editor.
 *
 * Loads the AOP's user + org PLAYBOOK.md, merges them into one document grouped
 * by section (Global, each Stage, …) with bordered Org / User sub-cards, and
 * edits them as a single document. Unlike the per-scope editor this is NOT
 * Y.js-collaborative — it has no autosave; a Save button (or Cmd/Ctrl-S) splits
 * the merged doc and writes both rows via `aop.saveCompositePlaybook`. Navigating
 * away with unsaved edits is blocked with a confirm.
 */
export function CompositePlaybookDocument({ aop, className, toolbarContainer }: CompositePlaybookDocumentProps) {
  const trpc = useTRPC();
  const navigate = useNavigate();
  const scoped = useScopedInput();
  const targetUserId = useTargetUserId();

  const { data, isPending, error } = useQuery({
    ...trpc.aop.getCompositePlaybook.queryOptions(scoped({ aopId: aop })),
    // Playbook content is authoritative server state that agents and other
    // sessions mutate. Always fetch it fresh when the editor opens — never
    // render a cached (possibly empty/stale) copy without a network call. Focus
    // refetches are disabled so a background reload can't reset the editor while
    // you're working.
    staleTime: 0,
    refetchOnMount: 'always',
    refetchOnWindowFocus: false,
  });

  const composite = useMemo<PMNode | null>(
    () => (data ? mergeComposite(data.userJson as PMNode, (data.orgJson as PMNode | null) ?? null) : null),
    [data],
  );

  const editorRef = useRef<MarkdownEditorHandle>(null);
  const hydratedRef = useRef(false);
  const suppressDirtyRef = useRef(false);
  // The exact composite object last handed to setContent — dedupes redundant
  // re-seeds (the seed effect can fire more often than `composite` changes).
  const seededCompositeRef = useRef<PMNode | null>(null);

  const [isDirty, setIsDirty] = useState(false);
  const [saveState, setSaveState] = useState<SaveState>('idle');
  const [compileIssues, setCompileIssues] = useState<PlaybookIssue[]>([]);
  // Set when hydrating the editor from the loaded content throws. Surfaces a
  // visible error instead of a silently-empty editor — the blank editor is a
  // data-loss trap (a save would write the empty doc over good content).
  const [renderError, setRenderError] = useState(false);

  // Mirror `isDirty` in a ref so the navigation blocker can read the live value.
  // The subagent flow saves (clearing dirty) and navigates in the same tick,
  // before React commits the state update — a boolean blocker would still see
  // the stale `true` and wrongly prompt "Unsaved changes".
  const isDirtyRef = useRef(false);
  const setDirty = useCallback((value: boolean) => {
    isDirtyRef.current = value;
    setIsDirty(value);
  }, []);

  useEffect(() => {
    hydratedRef.current = false;
    seededCompositeRef.current = null;
    setDirty(false);
    setSaveState('idle');
    setCompileIssues([]);
    setRenderError(false);
  }, [aop, setDirty]);

  const { mutateAsync: saveComposite } = useMutation({
    ...trpc.aop.saveCompositePlaybook.mutationOptions(),
  });

  const onOpenReference = useCallback(
    async (refPath: string) => {
      const target = resolveReferencePath(refPath);
      // Resolved by PATH, which the server resolves against the session user.
      // Without the scope, following a reference out of a teammate's playbook
      // would land on the admin's own copy of that resource.
      const doc = await trpcClient.documents.getDoc.query({
        documentType: 'playbook_resource',
        path: target,
        omitContent: true,
        targetUserId,
      });
      if (doc?.id) navigate(brainDocumentPath(doc.id, targetUserId));
    },
    [navigate, targetUserId],
  );

  const onOpenDocument = useCallback(
    (documentId: string) => {
      if (documentId) navigate(brainDocumentPath(documentId, targetUserId));
    },
    [navigate, targetUserId],
  );

  // `handleSave` is defined below; route the subagent flow's save through a ref
  // so it can persist the playbook (and the inserted ref) before navigating.
  const saveRef = useRef<() => Promise<void>>(async () => {});
  const persistPlaybook = useCallback(() => saveRef.current(), []);
  const { onCreateSubagent, subagentDialog } = useSubagentCreation({
    aopId: aop,
    orgAopId: data?.orgAopId ?? undefined,
    baseScope: 'user',
    ownerUserId: targetUserId,
    save: persistPlaybook,
  });

  const { onCreateDocument, documentDialog } = useDocumentCreation({
    aopId: aop,
    baseScope: 'user',
    ownerUserId: targetUserId,
    save: persistPlaybook,
  });

  const extraExtensions = useMemo(
    () =>
      createCompositePlaybookExtensions({
        onOpenReference,
        onOpenDocument,
        aopId: aop,
        // This editor only mounts on `/agents/playbook`, which mounts the member
        // picker and its banner, so the picker IS the owner here.
        ownerUserId: targetUserId ?? null,
        onCreateSubagent,
        onCreateDocument,
      }),
    [onOpenReference, onOpenDocument, aop, targetUserId, onCreateSubagent, onCreateDocument],
  );

  const extraSlashCommands = useMemo(
    () => createPlaybookSlashCommands({ onCreateSubagent, onCreateDocument }),
    [onCreateSubagent, onCreateDocument],
  );

  // Seed the editor with the merged JSON, suppressing the dirty flag the
  // resulting update would otherwise raise. Re-runs when fresh server data
  // arrives (e.g. the always-on refetch), but never clobbers unsaved local
  // edits — once the user has typed, their `isDirty` content wins until saved.
  const seed = useCallback(
    (editor: Editor) => {
      if (!composite) return;
      if (hydratedRef.current && isDirtyRef.current) return;
      // Dedupe: only seed a given composite object once (the effect below can
      // fire more often than `composite` actually changes).
      if (seededCompositeRef.current === composite) return;
      const target = composite;
      seededCompositeRef.current = target;
      suppressDirtyRef.current = true;
      // Defer setContent out of React's render/commit phase. TipTap renders its
      // React NodeViews (the playbook's section/scope cards — i.e. all visible
      // content) via `flushSync`, which React 18/19 refuses when called from
      // inside a lifecycle ("cannot flush when React is already rendering"). Run
      // synchronously in an effect and the node views never mount → the editor
      // shows empty despite a fully-populated doc. A microtask runs it just
      // after commit, when flushSync is allowed.
      queueMicrotask(() => {
        if (editor.isDestroyed) {
          suppressDirtyRef.current = false;
          return;
        }
        try {
          editor.commands.setContent(target as Parameters<typeof editor.commands.setContent>[0]);
          hydratedRef.current = true;
          setDirty(false);
          setRenderError(false);
        } catch (err) {
          // Hydration failed (e.g. a schema violation in the loaded doc). Do NOT
          // mark hydrated — a save from this blank editor would clobber the
          // stored content. Allow a retry and surface the error loudly.
          seededCompositeRef.current = null;
          setRenderError(true);
          captureRouteError(err, {
            scope: 'CompositePlaybookDocument.seed',
            aop,
            compositeChildren: target.content?.length ?? 0,
          });
        } finally {
          suppressDirtyRef.current = false;
        }
      });
    },
    [composite, aop, setDirty],
  );

  // Seed when the editor mounts after data resolves, and re-seed whenever fresh
  // server data arrives (the always-on refetch). `seed` itself no-ops when the
  // user has unsaved edits, so a background refetch can't discard their work.
  useEffect(() => {
    const editor = editorRef.current?.editor;
    if (editor && composite) seed(editor);
  }, [composite, seed]);

  const handleSave = useCallback(async () => {
    const editor = editorRef.current?.editor;
    if (!editor || !data) return;
    // Never write from an editor that never hydrated — its content is the empty
    // default, and saving it would overwrite the stored playbook with a blank
    // doc. The server enforces the same guard; this avoids the round-trip.
    if (!hydratedRef.current) return;
    setSaveState('saving');
    // Pass the original user doc so the entry/exit criteria hidden on user stage
    // cards are restored (hidden, not deleted) when writing back.
    const { userJson, orgJson } = splitComposite(
      editor.getJSON() as unknown as PMNode,
      data.userJson as PMNode,
    );
    type SaveInput = Parameters<typeof saveComposite>[0];
    try {
      const res = await saveComposite(
        scoped({
          aopId: aop,
          userJson: userJson as SaveInput['userJson'],
          orgJson: data.orgDocId ? (orgJson as SaveInput['orgJson']) : null,
        }),
      );
      const issues: PlaybookIssue[] = [
        ...(res.user?.errors ?? []),
        ...(res.user?.warnings ?? []),
        ...(res.org?.errors ?? []),
        ...(res.org?.warnings ?? []),
      ] as PlaybookIssue[];
      setCompileIssues(issues);
      if (!res.ok) {
        setSaveState('error');
        return;
      }
      setDirty(false);
      setSaveState('success');
      setTimeout(() => setSaveState('idle'), 2000);
    } catch {
      setSaveState('error');
    }
  }, [aop, data, saveComposite, scoped, setDirty]);

  // Keep the subagent flow's save pointed at the latest handleSave.
  useEffect(() => {
    saveRef.current = handleSave;
  }, [handleSave]);

  // Cmd+S / Ctrl+S keyboard shortcut.
  useEffect(() => {
    const handler = (e: KeyboardEvent) => {
      if ((e.metaKey || e.ctrlKey) && e.key === 's') {
        e.preventDefault();
        if (isDirty) void handleSave();
      }
    };
    document.addEventListener('keydown', handler);
    return () => document.removeEventListener('keydown', handler);
  }, [handleSave, isDirty]);

  // ── Unsaved-changes guards ───────────────────────────────────────────────────
  // In-app navigation: block with a dialog while there are unsaved edits. Read
  // the dirty flag from a ref (not the boolean) so a save+navigate in the same
  // tick — like the subagent flow — sees the just-cleared value.
  const blocker = useBlocker(useCallback(() => isDirtyRef.current, []));
  const isLeaveBlocked = blocker.state === 'blocked';

  // Tab close / refresh: native beforeunload prompt.
  useEffect(() => {
    if (!isDirty) return;
    const handler = (e: BeforeUnloadEvent) => {
      e.preventDefault();
      e.returnValue = '';
    };
    window.addEventListener('beforeunload', handler);
    return () => window.removeEventListener('beforeunload', handler);
  }, [isDirty]);

  const toolbar = (
    <>
      {isDirty && saveState === 'idle' && (
        <span className="text-xs text-yellow-600 dark:text-yellow-400">Unsaved changes</span>
      )}
      {saveState === 'success' && compileIssues.length === 0 && (
        <span className="flex items-center gap-1 text-xs text-green-600 dark:text-green-400">
          <CheckCircle className="h-3.5 w-3.5" />
          Saved
        </span>
      )}
      <Button
        size="sm"
        className="inline-flex h-7 items-center gap-1.5 rounded-full bg-action px-3 text-xs font-medium text-action-foreground hover:bg-action hover:opacity-90"
        onClick={handleSave}
        disabled={saveState === 'saving' || !isDirty}
      >
        {saveState === 'saving' ? <Loader2 className="h-3 w-3 animate-spin" /> : <Save className="h-3 w-3" />}
        {saveState === 'saving' ? 'Saving…' : 'Save'}
      </Button>
    </>
  );

  if (isPending) {
    return <div className="text-muted-foreground p-6 text-sm">Loading playbook…</div>;
  }

  if (error || !data) {
    return (
      <div className="m-6 rounded border border-red-500 bg-red-50 p-4 text-sm text-red-900 dark:bg-red-950/30 dark:text-red-200">
        Failed to load the playbook. Please refresh.
      </div>
    );
  }

  return (
    <div className={cn('flex min-h-0 flex-col', className)}>
      {toolbarContainer ? (
        createPortal(toolbar, toolbarContainer)
      ) : (
        <div className="flex shrink-0 items-center justify-end gap-2 border-b px-3 py-1.5">{toolbar}</div>
      )}

      {renderError && (
        <div className="m-6 rounded border border-red-500 bg-red-50 p-4 text-sm text-red-900 dark:bg-red-950/30 dark:text-red-200">
          The playbook loaded but failed to render. Your saved content is safe — please refresh the
          page. If this keeps happening, contact support.
        </div>
      )}

      <MarkdownEditor
        key=[redacted]
        ref={editorRef}
        placeholder="Type # or / for a trigger or integration, @ to reference a resource…"
        className="min-h-0 flex-1 overflow-auto"
        extraExtensions={extraExtensions}
        extraSlashCommands={extraSlashCommands}
        onEditorCreated={(editor) => seed(editor)}
        onChange={() => {
          if (suppressDirtyRef.current) return;
          setDirty(true);
        }}
      />
      {compileIssues.length > 0 && <ErrorPanel issues={compileIssues} />}
      {subagentDialog}
      {documentDialog}

      <AlertDialog
        open={isLeaveBlocked}
        onOpenChange={(open) => {
          if (!open && blocker.state === 'blocked') blocker.reset();
        }}
      >
        <AlertDialogContent>
          <AlertDialogHeader>
            <AlertDialogTitle>Unsaved changes</AlertDialogTitle>
            <AlertDialogDescription>
              You have unsaved playbook changes. Leave without saving?
            </AlertDialogDescription>
          </AlertDialogHeader>
          <AlertDialogFooter>
            <AlertDialogCancel onClick={() => blocker.reset?.()}>Stay</AlertDialogCancel>
            <AlertDialogAction onClick={() => blocker.proceed?.()}>
              Leave without saving
            </AlertDialogAction>
          </AlertDialogFooter>
        </AlertDialogContent>
      </AlertDialog>
    </div>
  );
}