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"><ref id="{n.id}"/></span>
) : (
<span className="text-foreground/70">“{String(n.content ?? '').slice(0, 80)}”</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"><ref id="{r.id}"/></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">
<ref id="{r.id}"{r.when ? ` when="${r.when}"` : ''}/>
</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>
);
}