/** * Single-source-of-truth hook for the brief textarea in GenerateDialog. * * Replaces the previous tangle of state + refs + parallel effects with one * explicit state machine: * * placeholder ─► ai-loading ─► ai-ready * │ │ │ * └─────────────┴────────────┴─► user-edited ◄──► chip-applied * ▲ │ * └────keystroke───┘ * * • placeholder — deterministic string template built from doc context. * Shown the moment the popup opens; nothing async. * • ai-loading — the mount-effect kicked off `client.content.brief()`. * UI shows a spinner. briefText is still the placeholder. * • ai-ready — AI brief returned and replaced briefText. User has not * edited. * • user-edited — user typed in the textarea. Typeahead is enabled. * • chip-applied — user clicked a typeahead chip. Text was replaced with * the chip's prompt; typeahead suppressed so we don't * immediately re-fetch. Any subsequent textarea keystroke * flips back to user-edited. * * AI replacements (mount-effect) are ignored once the user has interacted * (status ∈ {user-edited, chip-applied}) — their work always wins. * * Typeahead chips are populated only when status === 'user-edited' and the * brief is long enough to be a real query (≥8 chars). One debounced fetch * per ~500ms of input idle. * * The hook owns: * - briefText state * - briefStatus state * - typeaheadChips + typeaheadLoading state * - the fire-once mount-effect ref * - the typeahead debounce timer * * The component owns: * - rendering the textarea, spinner, chips * - calling setBriefText on every textarea change * - everything else (modality, brand picker, generate button, etc.) */ import { useCallback, useEffect, useRef, useState } from 'react'; import type { ContentBriefParams, ContentConcept, LaminaClient } from '@uselamina/sdk'; import { patchDialogState, readDialogState, type BriefCache, type CachedBriefStatus, } from './dialogStore.js'; import { hashDocForBrief } from './hashDocForBrief.js'; const TYPEAHEAD_DEBOUNCE_MS = 500; const TYPEAHEAD_MIN_LENGTH = 8; const FIELD_LABELS: Record = { heroImage: 'hero image', mainImage: 'main image', thumbnail: 'thumbnail', ogImage: 'social preview image', coverImage: 'cover image', poster: 'poster', avatar: 'avatar', logo: 'logo', icon: 'icon', banner: 'banner', background: 'background image', }; const TYPE_LABELS: Record = { product: 'product', post: 'blog post', blogPost: 'blog post', article: 'article', page: 'page', landingPage: 'landing page', category: 'category', author: 'author', event: 'event', project: 'project', }; function humaniseSlug(slug: string): string { return slug.replace(/([A-Z])/g, ' $1').toLowerCase().trim(); } /** * Deterministic placeholder built from doc + field context. Used as the * initial textarea value (before the AI brief lands) and as the fallback * if the AI call fails. */ function buildPlaceholderBrief(ctx: { documentType?: string; documentTitle?: string; fieldName?: string; fieldDescription?: string; }): string { const parts: string[] = []; const fieldLabel = ctx.fieldName ? FIELD_LABELS[ctx.fieldName] ?? humaniseSlug(ctx.fieldName) : null; const typeLabel = ctx.documentType ? TYPE_LABELS[ctx.documentType] ?? humaniseSlug(ctx.documentType) : null; if (fieldLabel) { parts.push(fieldLabel.charAt(0).toUpperCase() + fieldLabel.slice(1)); } if (typeLabel && ctx.documentTitle) { parts.push(`for ${typeLabel}: ${ctx.documentTitle}`); } else if (ctx.documentTitle) { parts.push(`for ${ctx.documentTitle}`); } else if (typeLabel) { parts.push(`for ${typeLabel}`); } if (ctx.fieldDescription) { parts.push(`(${ctx.fieldDescription})`); } return parts.join(' '); } export type BriefStatus = | 'placeholder' | 'ai-loading' | 'ai-ready' | 'user-edited' | 'chip-applied'; export interface UseDocumentBriefArgs { client: LaminaClient; /** * Sanity document _id (drafts.foo or foo). Together with `fieldName`, * forms the persistence scope for the brief cache. When either is missing, * the hook still works fully — it just doesn't persist across reopens. */ documentId?: string; documentType?: string; documentTitle?: string; documentExcerpt?: string; fieldName?: string; fieldDescription?: string; /** Full Sanity document; sent to the server as `metadata.document`. */ fullDocument?: Record; /** Current modality (caller may override the auto-derived one). */ modality?: string; /** Sanity asset type from the asset-source props (`'image' | 'file'`). */ assetType?: string; /** When set, included in the brief request as brandProfileId. */ selectedBrandId?: string; /** * Whether typeahead should be enabled. Typically `true` when the dialog's * generation state is idle (not currently running). Caller passes * `(state.status === 'idle' || state.status === 'failed')` or similar. */ typeaheadEnabled: boolean; } export interface UseDocumentBriefResult { briefText: string; /** * Setter for the textarea. Wire this to `