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