PlaybookDocument.tsx23.9 KBView on GitHub

Introduced 3 production defects 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 } from '@tanstack/react-query';
import { useNavigate } from 'react-router';
import { AlertTriangle, CheckCircle, ChevronDown, ChevronRight, ClipboardCopy, Loader2, Save, XCircle } from 'lucide-react';
import { Document, type DocumentHandle } from '@/modules/documents/document';
import { useTRPC, trpcClient } from '@/providers/query-provider';
import type { FlushSuccess, FlushError } from '@/modules/documents/yjs';
import { Button } from '@/components/ui/button';
import { cn } from '@/lib/utils';
import { createPlaybookExtensions, createPlaybookSlashCommands } from './playbookExtensions';
import { useSubagentCreation } from './useSubagentCreation';
import { useDocumentCreation } from './useDocumentCreation';
import { resolveReferencePath } from './references';
import { useTargetUserId } from '@/modules/administeredUser';
import type {
  CompiledPlaybook,
  CompiledBlock,
  CompiledNode,
  CompiledGlobalSection,
  CompiledStageSection,
  CompiledBeforeMeetingBlock,
  CompiledCronBlock,
  CompiledFieldChangeBlock,
  PlaybookManifest,
} from './compiled-playbook-types';
import { brainDocumentPath } from '@/modules/brain/brain-routes';

// ─── Types ────────────────────────────────────────────────────────────────────

export interface PlaybookIssue {
  code: string;
  message: string;
  severity: 'error' | 'warning';
  path?: string;
  detail?: string;
  xmlSnippet?: string;
}

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

interface PlaybookDocumentProps {
  /** AOP slug — resolves to `user/playbooks/{aop}` or `organisation/playbooks/{aop}` when isOrgAop. */
  aop: string;
  /** When true, loads from organisation/playbooks/{aop}/PLAYBOOK.md instead of user/playbooks. */
  isOrgAop?: boolean;
  /** Called when compiled_playbook is loaded or updated — used to lift state for external display. */
  onCompiledPlaybook?: (compiled: CompiledPlaybook | null) => void;
  className?: string;
  /**
   * When provided, the save controls (Unsaved/Saved indicator + Save button)
   * render into this element via a portal instead of an inline toolbar — lets a
   * parent place them on the same row as the playbook tabs.
   */
  toolbarContainer?: HTMLElement | null;
  /** When provided, renders a link in the subtitle to navigate to the other level playbook. */
  onNavigateToOther?: () => void;
  /** When provided, bypasses the documents.getDoc lookup and uses this ID directly. */
  documentId?: string;
  /**
   * The playbook's owner. Takes precedence over the administered-user picker for
   * every scoped call this editor makes. Pass it wherever the caller supplies
   * `documentId`, which addresses a document the picker knows nothing about.
   */
  ownerUserId?: string;
  /** Dynamic `@` mention options — replaces the static default list when provided. */
  referenceOptions?: import('./references').ReferenceOption[];
  /** Custom search for `[[` suggestions — should be scoped to this AOP's docs. */
  searchFileLinks?: (query: string) => Promise<{ id: string; label: string; path: string; documentType: string; description: string | null; lastOpened: Date | string | null; updatedAt: Date | string }[]>;
}

// ─── Compiled playbook panel ──────────────────────────────────────────────────

function NodeList({ nodes }: { nodes: CompiledNode[] }) {
  if (!nodes?.length) return <span className="text-muted-foreground italic">empty</span>;
  return (
    <ul className="ml-3 space-y-0.5">
      {nodes.map((n, i) => (
        <li key={i} className="font-mono text-xs">
          {n.type === 'ref' ? (
            <span className="text-blue-600 dark:text-blue-400">&lt;ref id=&quot;{n.id}&quot;/&gt;</span>
          ) : (
            <span className="text-foreground/70">&ldquo;{String(n.content ?? '').slice(0, 80)}&rdquo;</span>
          )}
        </li>
      ))}
    </ul>
  );
}

function TriggerBlock({ label, block }: { label: string; block?: CompiledBlock | null }) {
  if (!block) return null;
  return (
    <div className="mb-1">
      <span className="text-xs font-semibold text-foreground/80">{label}:</span>
      <NodeList nodes={block.nodes} />
    </div>
  );
}

function Section({ title, children, defaultOpen = false }: { title: string; children: React.ReactNode; defaultOpen?: boolean }) {
  const [open, setOpen] = useState(defaultOpen);
  return (
    <div className="border-b border-border/40 last:border-0">
      <button
        type="button"
        onClick={() => setOpen(o => !o)}
        className="flex w-full items-center gap-1.5 px-3 py-2 text-left text-xs font-semibold hover:bg-muted/30 transition-colors"
      >
        {open ? <ChevronDown className="h-3 w-3 shrink-0" /> : <ChevronRight className="h-3 w-3 shrink-0" />}
        {title}
      </button>
      {open && <div className="px-4 pb-3 pt-1">{children}</div>}
    </div>
  );
}

function GlobalSectionBlocks({ g }: { g: CompiledGlobalSection }) {
  return (
    <>
      {g.crossCuttingProse && (
        <div className="mb-2">
          <span className="font-semibold">Cross-cutting:</span>
          <p className="mt-0.5 text-foreground/70 italic">{g.crossCuttingProse.slice(0, 120)}…</p>
        </div>
      )}
      <TriggerBlock label="trigger:any" block={g.anyBlock} />
      {Object.entries(g.eventBlocks).map(([type, block]) => (
        <TriggerBlock key=[redacted] label={`trigger:${type}`} block={block} />
      ))}
      {g.beforeMeetingBlocks.map((b: CompiledBeforeMeetingBlock, i: number) => (
        <TriggerBlock key={i} label={`trigger:before-meeting minutes=${b.minutes}`} block={b} />
      ))}
      {g.cronBlocks.map((b: CompiledCronBlock, i: number) => (
        <TriggerBlock key={i} label={`trigger:cron "${b.schedule}" (${b.timezone})`} block={b} />
      ))}
      {g.fieldChangeBlocks.map((b: CompiledFieldChangeBlock, i: number) => (
        <TriggerBlock key={i} label={`trigger:field-change ${b.field}→${b.toValue ?? '*'}`} block={b} />
      ))}
    </>
  );
}

function StageSectionBlocks({ stage }: { stage: CompiledStageSection }) {
  return (
    <>
      {stage.entry && <div className="mb-1"><span className="font-semibold">Entry:</span> {stage.entry}</div>}
      {stage.exit && <div className="mb-1"><span className="font-semibold">Exit:</span> {stage.exit}</div>}
      <TriggerBlock label="trigger:any" block={stage.anyBlock} />
      {Object.entries(stage.eventBlocks).map(([type, block]) => (
        <TriggerBlock key=[redacted] label={`trigger:${type}`} block={block} />
      ))}
      {stage.beforeMeetingBlocks.map((b: CompiledBeforeMeetingBlock, i: number) => (
        <TriggerBlock key={i} label={`trigger:before-meeting minutes=${b.minutes}`} block={b} />
      ))}
      {stage.cronBlocks.map((b: CompiledCronBlock, i: number) => (
        <TriggerBlock key={i} label={`trigger:cron "${b.schedule}"`} block={b} />
      ))}
      {stage.fieldChangeBlocks.map((b: CompiledFieldChangeBlock, i: number) => (
        <TriggerBlock key={i} label={`trigger:field-change ${b.field}→${b.toValue ?? '*'}`} block={b} />
      ))}
    </>
  );
}

function ManifestSection({ manifest }: { manifest: PlaybookManifest }) {
  return (
    <div className="space-y-1.5">
      <div><span className="font-semibold">Event types:</span> {manifest.eventTypes.map(e => e.type).join(', ') || '—'}</div>
      <div><span className="font-semibold">Field watchers:</span> {manifest.fieldWatchers.map(w => `${w.field}→${w.toValue ?? '*'}`).join(', ') || '—'}</div>
      <div><span className="font-semibold">Cron schedules:</span> {manifest.cronSchedules.map(c => `${c.expression} (${c.timezone})`).join(', ') || '—'}</div>
      <div><span className="font-semibold">Before-meeting:</span> {manifest.beforeMeetingConfigs.map(b => `${b.minutes}min${b.stage ? ` @ ${b.stage}` : ''}`).join(', ') || '—'}</div>
    </div>
  );
}

export function CompiledPlaybookPanel({ compiled }: { compiled: CompiledPlaybook }) {
  return (
    <div className="border-t border-border bg-muted/20 text-xs">
      <div className="flex items-center justify-between border-b border-border/60 px-3 py-2">
        <span className="font-semibold text-foreground">Compiled Playbook</span>
        <span className="text-muted-foreground">
          {new Date(compiled.compiledAt).toLocaleTimeString()}
          {' · '}{compiled.allRefIds.length} refs
        </span>
      </div>

      <Section title={`Routing Manifest — ${compiled.manifest.eventTypes.length} event types, ${compiled.manifest.fieldWatchers.length} watchers`} defaultOpen>
        <ManifestSection manifest={compiled.manifest} />
      </Section>

      <Section title={`Always Loaded — ${compiled.alwaysLoaded.length} docs`}>
        <ul className="space-y-0.5">
          {compiled.alwaysLoaded.map(r => (
            <li key=[redacted] className="font-mono text-blue-600 dark:text-blue-400">&lt;ref id=&quot;{r.id}&quot;/&gt;</li>
          ))}
        </ul>
      </Section>

      <Section title={`On Demand — ${compiled.onDemand.length} docs`}>
        <ul className="space-y-0.5">
          {compiled.onDemand.map(r => (
            <li key=[redacted] className="font-mono text-blue-600 dark:text-blue-400">
              &lt;ref id=&quot;{r.id}&quot;{r.when ? ` when="${r.when}"` : ''}/&gt;
            </li>
          ))}
        </ul>
      </Section>

      <Section title="Global Section" defaultOpen>
        <GlobalSectionBlocks g={compiled.global} />
      </Section>

      {Object.entries(compiled.stages).map(([stageId, stage]) => (
        <Section key=[redacted] title={`Stage: ${stageId}${stage.label && stage.label !== stageId ? ` — ${stage.label}` : ''}`}>
          <StageSectionBlocks stage={stage} />
        </Section>
      ))}
    </div>
  );
}

// ─── Error panel ─────────────────────────────────────────────────────────────

export function ErrorPanel({ issues }: { issues: PlaybookIssue[] }) {
  const [copied, setCopied] = useState(false);
  const errors = issues.filter((i) => i.severity === 'error');
  const warnings = issues.filter((i) => i.severity === 'warning');

  const handleCopy = useCallback(async () => {
    const lines: string[] = [
      `The playbook failed to compile with ${errors.length} error${errors.length !== 1 ? 's' : ''}:\n`,
    ];
    errors.forEach((issue, i) => {
      lines.push(`${i + 1}. [${issue.code}]${issue.path ? ` — ${issue.path}` : ''}`);
      lines.push(`   ${issue.message}`);
      if (issue.xmlSnippet) {
        lines.push(`   \`\`\`xml`);
        lines.push(`   ${issue.xmlSnippet}`);
        lines.push(`   \`\`\``);
      }
      lines.push('');
    });
    await navigator.clipboard.writeText(lines.join('\n'));
    setCopied(true);
    setTimeout(() => setCopied(false), 2000);
  }, [errors]);

  return (
    <div className={cn('shrink-0 border-t p-3', errors.length > 0 ? 'border-red-200 bg-red-50 dark:border-red-900/40 dark:bg-red-950/20' : 'border-yellow-200 bg-yellow-50 dark:border-yellow-900/40 dark:bg-yellow-950/20')}>
      <div className="mb-2 flex items-center justify-between gap-2">
        <div className="flex items-center gap-1.5">
          {errors.length > 0
            ? <XCircle className="h-3.5 w-3.5 shrink-0 text-red-600 dark:text-red-400" />
            : <AlertTriangle className="h-3.5 w-3.5 shrink-0 text-yellow-600 dark:text-yellow-400" />}
          <span className={cn('text-xs font-medium', errors.length > 0 ? 'text-red-700 dark:text-red-300' : 'text-yellow-700 dark:text-yellow-300')}>
            {errors.length > 0
              ? <>Playbook has {errors.length} error{errors.length !== 1 ? 's' : ''} — fix to save <span className="font-normal opacity-70">(compiled_playbook not updated)</span></>
              : <>Playbook saved with {warnings.length} warning{warnings.length !== 1 ? 's' : ''}</>}
          </span>
        </div>
        {errors.length > 0 && (
        <Button
          size="sm"
          variant="ghost"
          className="h-6 gap-1 px-2 text-xs text-red-700 hover:bg-red-100 dark:text-red-300 dark:hover:bg-red-900/30"
          onClick={handleCopy}
        >
          <ClipboardCopy className="h-3 w-3" />
          {copied ? 'Copied!' : 'Copy errors'}
        </Button>
        )}
      </div>
      <div className="flex flex-col gap-1.5">
        {[...errors, ...warnings].map((issue, i) => (
          <div key={i} className="text-xs">
            <div className="flex items-start gap-1.5">
              <span
                className={cn(
                  'shrink-0 font-medium',
                  issue.severity === 'error'
                    ? 'text-red-600 dark:text-red-400'
                    : 'text-yellow-600 dark:text-yellow-400',
                )}
              >
                {issue.severity === 'error' ? '✗' : '⚠'}
              </span>
              <span
                className={cn(
                  'font-mono font-semibold',
                  issue.severity === 'error'
                    ? 'text-red-700 dark:text-red-300'
                    : 'text-yellow-700 dark:text-yellow-300',
                )}
              >
                [{issue.code}]
              </span>
              {issue.path && (
                <span className="text-muted-foreground opacity-70">{issue.path}</span>
              )}
            </div>
            <p
              className={cn(
                'pl-5',
                issue.severity === 'error'
                  ? 'text-red-700 dark:text-red-300'
                  : 'text-yellow-700 dark:text-yellow-300',
              )}
            >
              {issue.message}
            </p>
          </div>
        ))}
      </div>
    </div>
  );
}

// ─── Main component ───────────────────────────────────────────────────────────

/**
 * Renders a real, persisted Playbook document with manual-save semantics.
 *
 * - Auto-save debounce is disabled (`manualFlushOnly`).
 * - A Save button (and Cmd/Ctrl+S) triggers `forceFlush()`.
 * - After each flush the `playbook.compile` hook result is read; if there are
 *   errors they are shown below the editor.
 * - Bold and italic are disabled in the editor (playbook XML has no inline
 *   formatting).
 *
 * `documents.getDoc` find-or-creates the user-scoped row at
 * `user/playbooks/{aop}`, then `<Document>` binds the Y.js editor with the
 * playbook extensions (`#trigger` callouts + `@` references). Clicking a `@`
 * reference find-or-creates its target doc and opens it in `/brain`.
 */
export function PlaybookDocument({ aop, isOrgAop = false, onCompiledPlaybook, className, toolbarContainer, onNavigateToOther, documentId: documentIdProp, ownerUserId, referenceOptions, searchFileLinks }: PlaybookDocumentProps) {
  const trpc = useTRPC();
  const navigate = useNavigate();
  const pickerUserId = useTargetUserId();
  // A component addressed by DOCUMENT is not addressed by the picker, and the two
  // can disagree: the playground hands this editor one teammate's playbook while
  // the picker still holds whoever was selected last. The document wins. Reference
  // lookup, subagent creation and the editor extensions all have to run against the
  // owner of the playbook actually on screen, or they write to the wrong teammate.
  // The ambient read stays as the fallback for the administered surfaces, which
  // mount `AdministeredUserBar` and resolve their document FROM the picker.
  const targetUserId = ownerUserId ?? pickerUserId;
  const path = isOrgAop
    ? `organisation/playbooks/${aop}/PLAYBOOK.md`
    : `user/playbooks/${aop}/PLAYBOOK.md`;

  const { data, isPending, error } = useQuery({
    ...trpc.documents.getDoc.queryOptions({ documentType: 'playbook', path, omitContent: true }),
    staleTime: 2 * 60 * 1000,
    enabled: !documentIdProp,
  });

  const docRef = useRef<DocumentHandle | null>(null);

  const [isDirty, setIsDirty] = useState(false);
  const [saveState, setSaveState] = useState<SaveState>('idle');
  const [compileIssues, setCompileIssues] = useState<PlaybookIssue[]>([]);
  // Seed compiled playbook from document metadata once the query resolves.
  useEffect(() => {
    const fromMeta = (data?.metadata as Record<string, unknown> | null)?.compiled_playbook;
    const compiled = (fromMeta as CompiledPlaybook | null) ?? null;
    if (compiled) onCompiledPlaybook?.(compiled);
  }, [data?.metadata, onCompiledPlaybook]);

  const handleFlushEnd = useCallback((result: FlushSuccess | FlushError) => {
    // Compile issues ride the same hook payload on BOTH outcomes. A playbook
    // with errors is refused by the server — `playbook.compile` returns
    // `abortPersist` — so reading them only on the success branch would blank
    // the error panel on exactly the saves that have errors to report.
    const compileHook = result.hooks?.find((h) => h.name === 'playbook.compile');
    const payload = compileHook?.payload as
      | { errors?: PlaybookIssue[]; warnings?: PlaybookIssue[]; compiled?: CompiledPlaybook }
      | undefined;
    const issues: PlaybookIssue[] = [
      ...(payload?.errors ?? []),
      ...(payload?.warnings ?? []),
    ];
    if (compileHook) setCompileIssues(issues);
    if (payload?.compiled) {
      onCompiledPlaybook?.(payload.compiled);
    }
    if ('error' in result) {
      setSaveState('error');
      return;
    }
    setSaveState('success');
    // Reset success indicator after 2 s.
    setTimeout(() => setSaveState('idle'), 2000);
  }, []);

  const handleSave = useCallback(async () => {
    if (!docRef.current) return;
    setSaveState('saving');
    await docRef.current.forceFlush();
    // `handleFlushEnd` will update state; if it doesn't fire (no dirty bytes),
    // just return to idle.
    setSaveState((current) => (current === 'saving' ? 'idle' : current));
  }, []);

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

  const onOpenReference = useCallback(
    async (refPath: string) => {
      const target = resolveReferencePath(refPath);
      // Resolved by PATH, which the server resolves against the session user, so
      // it carries the administered scope for the same reason the composite
      // editor's does.
      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],
  );

  const { onCreateSubagent, subagentDialog } = useSubagentCreation({
    aopId: isOrgAop ? undefined : aop,
    orgAopId: isOrgAop ? aop : undefined,
    baseScope: isOrgAop ? 'org' : 'user',
    ownerUserId: targetUserId,
    save: handleSave,
  });

  const { onCreateDocument, documentDialog } = useDocumentCreation({
    aopId: isOrgAop ? undefined : aop,
    baseScope: isOrgAop ? 'org' : 'user',
    ownerUserId: targetUserId,
    save: handleSave,
  });

  const extraExtensions = useMemo(
    () =>
      createPlaybookExtensions({
        onOpenReference,
        onOpenDocument,
        aopId: aop,
        ownerUserId: targetUserId ?? null,
        referenceOptions,
        searchFileLinks,
        onCreateSubagent,
        onCreateDocument,
      }),
    [
      onOpenReference,
      onOpenDocument,
      aop,
      targetUserId,
      referenceOptions,
      searchFileLinks,
      onCreateSubagent,
      onCreateDocument,
    ],
  );

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

  const resolvedDocumentId = documentIdProp ?? data?.id;

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

  if (!resolvedDocumentId || (!documentIdProp && error)) {
    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>
    );
  }

  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>
      )}
      {/* The server took the request and kept the old revision — a refused
          compile, or a failed POST. Either way the edits are still only in this
          tab, and silence here would read as "saved". */}
      {saveState === 'error' && (
        <span className="flex items-center gap-1 text-xs text-red-600 dark:text-red-400">
          <XCircle className="h-3.5 w-3.5" />
          Not 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>
    </>
  );

  return (
    <div className={cn('flex min-h-0 flex-col', className)}>
      {/* Save toolbar — portaled onto the tabs row when a container is provided,
          otherwise rendered inline above the editor. */}
      {toolbarContainer ? (
        createPortal(toolbar, toolbarContainer)
      ) : (
        <div className="flex shrink-0 items-center justify-end gap-2 border-b px-3 py-1.5">{toolbar}</div>
      )}

      <p className="shrink-0 border-b px-4 py-2 text-xs text-muted-foreground">
        {isOrgAop ? (
          <>
            Org playbook — runs once per event alongside the rep. Put shared processes, stage definitions, and company context every rep needs.
            {onNavigateToOther && (
              <> For rep-specific behaviors, <button type="button" onClick={onNavigateToOther} className="underline hover:text-foreground">put them in the user playbook →</button></>
            )}
          </>
        ) : (
          <>
            User playbook — runs on every event for this rep. Put anything specific to how you work, draft, and follow up.
            {onNavigateToOther && (
              <> For org level processes that should run once across the team, <button type="button" onClick={onNavigateToOther} className="underline hover:text-foreground">put them in the org playbook →</button></>
            )}
          </>
        )}
      </p>

      <Document
        ref={docRef}
        documentId={resolvedDocumentId}
        extraExtensions={extraExtensions}
        extraSlashCommands={extraSlashCommands}
        placeholder="Type # or / for a trigger, an integration, or a new board — @ references a resource…"
        className="min-h-0 flex-1 overflow-auto"
        manualFlushOnly
        onDirtyChange={setIsDirty}
        onFlushEnd={handleFlushEnd}
      />
      {compileIssues.length > 0 && <ErrorPanel issues={compileIssues} />}
      {subagentDialog}
      {documentDialog}
    </div>
  );
}