/**
 * ThreadForm — main component.
 * Conversational task-based form with agent/client views.
 */
'use client';
import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
import { useState, useCallback, useRef, useMemo, useImperativeHandle, forwardRef } from 'react';
import { KeyInfoDrawer } from './components/key_info_drawer.js';
import { cn } from './components/shared.js';
import { TaskCard } from './components/task_card.js';
import { AddQuestionDialog } from './components/add_question_dialog.js';
import { AgentComposeDialog, build_compose_from_check, build_compose_from_field } from './components/agent_compose_dialog.js';
import { CollectedDataView } from './components/collected_data_view.js';
import { ThreadPdfSidePanel } from './components/pdf_side_panel.js';
import { use_file_pipeline } from './hooks/use_file_pipeline.js';
import { extract_expectation } from '../../utils/expectation_extractor.js';
import { infer_mime_type } from '../../utils/format.js';
import { merge_send_back_message } from './utils/merge_send_back.js';
import { compute_file_decision_state } from './utils/file_decision_state.js';
/** Pick the best MIME type we can. Browsers can hand back empty string OR
 *  application/octet-stream for files dragged from email/Finder/etc., and the
 *  classification API (Gemini) rejects octet-stream outright. Sniff the
 *  extension as a fallback. */
function normalize_file_mime(file_type, file_name) {
    if (file_type && file_type !== 'application/octet-stream')
        return file_type;
    return infer_mime_type(file_name);
}
export const ThreadForm = forwardRef(function ThreadForm({ data, role, current_user, on_form_change, on_task_update, on_message_add, on_message_edit, on_message_delete, on_selection_change, on_send_form, on_close_form, on_export_data, on_request_analysis, on_request_reanalysis, file_manager, pdf_viewer, show_json_viewer, classification_api_url, validation_api_url, validation_rules_api_url, content_gate_api_url, resolution_api_url, response_extraction_api_url, available_document_types, available_tags, on_update_content_item, on_log, view_button_variant, show_info_icon, show_extracted_fields, render_collected_item_details, collected_data_locale, collected_data_currency_code, collected_data_truncate_chars, on_request_reextraction, 
// ── Key-Info drawer (H.7) ──
key_info_sections, key_info_form_data, on_key_info_form_data_change, field_metadata, on_metadata_change, show_confirm_action, key_info_title, key_info_default_expanded, field_action_components, enable_field_audit, audit_debounce_ms, key_info_field_columns, key_info_highlight_empty_required, task_number_offset, }, forwarded_ref) {
    const [expanded_tasks, set_expanded_tasks] = useState(() => {
        const first_open = data.tasks.find(t => t.status !== 'completed')?.task_id;
        return new Set(first_open ? [first_open] : []);
    });
    const [active_tab, set_active_tab] = useState('tasks');
    const [backoffice_running, set_backoffice_running] = useState(false);
    /**
     * Set of task_ids that currently have an aggregate resolver in-flight.
     * Drives the spinner badge inside SendBackMessage's Resolution Status panel
     * so the user sees that bullets may be stale while a recompute is running.
     */
    const [resolution_running, set_resolution_running] = useState(new Set());
    const [show_json, set_show_json] = useState(false);
    const [show_add_question, set_show_add_question] = useState(false);
    const [compose_context, set_compose_context] = useState(null);
    const [viewed_item, set_viewed_item] = useState(null);
    // Keep a ref to data for callbacks that need the latest state
    const data_ref = useRef(data);
    data_ref.current = data;
    // Key-Info drawer ref — delegates ThreadFormRef methods to the embedded view.
    const key_info_drawer_ref = useRef(null);
    useImperativeHandle(forwarded_ref, () => ({
        confirm_all_prefilled: () => key_info_drawer_ref.current?.confirm_all_prefilled() ?? [],
    }), []);
    const pipeline = use_file_pipeline({
        classification_api_url,
        validation_api_url,
        validation_rules_api_url,
        content_gate_api_url,
        response_extraction_api_url,
        document_types: available_document_types,
        available_tags,
        file_manager: file_manager?.callbacks,
        on_log,
    });
    // ── Derived state ──
    const pending_count = data.tasks.reduce((acc, task) => acc + task.thread.filter(m => m.type === 'ai_analysis' && m.ai_analysis?.items.some(i => i.review_status === 'pending')).length, 0);
    const review_tasks = data.tasks.filter(task => task.thread.some(m => m.type === 'ai_analysis' && m.ai_analysis?.items.some(i => i.review_status === 'pending')));
    // 1-based question numbers keyed on position in the full task list, so they
    // remain stable across tab filters (tasks vs review).
    // task_number_offset shifts every Q-pill by a fixed amount — pass 1 when
    // the KeyInfoDrawer occupies Q1 and tasks should start at Q2.
    const question_numbers = useMemo(() => new Map(data.tasks.map((t, i) => [t.task_id, i + 1 + (task_number_offset ?? 0)])), [data.tasks, task_number_offset]);
    /**
     * Set of source_content_ids whose spawned send-back task is now resolved
     * (status === 'completed'). Used to suppress the parent file's "Sent Back"
     * pill once the issue has been addressed in the child question.
     */
    const sent_back_resolved_ids = useMemo(() => {
        const ids = new Set();
        for (const t of data.tasks) {
            if (t.status !== 'completed')
                continue;
            const first = t.thread[0];
            if (first?.type === 'send_back' && first.send_back?.source_content_id) {
                ids.add(first.send_back.source_content_id);
            }
        }
        return ids;
    }, [data.tasks]);
    /**
     * Resolve a spawned task's back-reference to the file+issue it came from.
     * A spawned task's first message is a `send_back`; we look up the file's
     * originating task in the current form data.
     */
    const get_parent_ref = useCallback((task) => {
        const first = task.thread[0];
        if (!first || first.type !== 'send_back' || !first.send_back)
            return null;
        const { source_content_id, source_file, issues } = first.send_back;
        const parent_task = data.tasks.find(t => t.thread.some(m => m.content_items?.some(ci => ci.content_id === source_content_id)));
        if (!parent_task)
            return null;
        const rule_names = Array.from(new Set(issues.map(i => i.rule_name).filter((n) => !!n)));
        return {
            parent_task_id: parent_task.task_id,
            parent_question_number: question_numbers.get(parent_task.task_id) ?? 0,
            file_name: source_file?.file_name,
            rule_names,
        };
    }, [data.tasks, question_numbers]);
    /** Expand the target task and scroll to it with a brief highlight. */
    const handle_navigate_to_task = useCallback((task_id) => {
        set_expanded_tasks(prev => {
            if (prev.has(task_id))
                return prev;
            const next = new Set(prev);
            next.add(task_id);
            return next;
        });
        // Defer scroll so the expansion has rendered.
        setTimeout(() => {
            const el = document.getElementById(`task-${task_id}`);
            if (!el)
                return;
            el.scrollIntoView({ behavior: 'smooth', block: 'start' });
            el.classList.add('ring-2', 'ring-amber-300');
            setTimeout(() => el.classList.remove('ring-2', 'ring-amber-300'), 1500);
        }, 50);
    }, []);
    // ── Helpers ──
    /** Read a File as base64 (data portion only, no prefix). */
    const file_to_base64 = useCallback((file) => {
        return new Promise((resolve, reject) => {
            const reader = new FileReader();
            reader.onload = () => {
                const result = reader.result;
                // Strip "data:...;base64," prefix
                resolve(result.split(',')[1] || '');
            };
            reader.onerror = reject;
            reader.readAsDataURL(file);
        });
    }, []);
    const format_file_size = useCallback((bytes) => {
        if (bytes < 1024)
            return `${bytes}B`;
        if (bytes < 1048576)
            return `${Math.round(bytes / 1024)}KB`;
        return `${(bytes / 1048576).toFixed(1)}MB`;
    }, []);
    // ── Derived state for agent's backoffice action ──
    const pending_backoffice_items = useMemo(() => {
        const out = [];
        for (const task of data.tasks) {
            // Skip tasks the agent has already approved — files inside show "Resolved"
            // and there's no need to re-validate them.
            if (task.status === 'completed')
                continue;
            for (const msg of task.thread) {
                for (const ci of msg.content_items || []) {
                    if (ci.type !== 'file')
                        continue;
                    if (ci.backoffice_validated_at)
                        continue;
                    if (ci.replaced_by_content_id)
                        continue; // skip superseded files
                    if (ci.accepted)
                        continue; // skip files the agent already accepted
                    out.push(ci);
                }
            }
        }
        return out;
    }, [data.tasks]);
    // ── Callbacks ──
    /** Agent action: run backoffice validation on every file that hasn't had it yet. */
    const handle_run_backoffice = useCallback(async () => {
        if (backoffice_running || pending_backoffice_items.length === 0)
            return;
        set_backoffice_running(true);
        try {
            const results = await pipeline.run_backoffice_validation(pending_backoffice_items);
            const now = new Date().toISOString();
            let processed = 0;
            for (const item of pending_backoffice_items) {
                const r = results.get(item.content_id);
                // Only stamp backoffice_validated_at when the pipeline actually returned
                // results for this item — otherwise we'd silently mark failed/skipped
                // items as done and the agent couldn't retry.
                if (!r)
                    continue;
                const prev = (item.validation_rule_results ?? []);
                const new_backoffice = r.validation_results ?? [];
                const new_rule_ids = new Set(new_backoffice.map(n => n.rule_id));
                const kept = prev.filter(p => !(p.check_type === 'backoffice' && new_rule_ids.has(p.rule_id)));
                on_update_content_item?.(item.content_id, {
                    validation_rule_results: [...kept, ...new_backoffice],
                    backoffice_validated_at: now,
                });
                processed++;
            }
            const skipped = pending_backoffice_items.length - processed;
            on_log?.(`${current_user.name} (${current_user.id}): Ran backoffice validation on ${processed} file(s)${skipped > 0 ? ` (${skipped} skipped)` : ''}`);
            // Per-group periodic_coverage pass — runs after per-file backoffice so each
            // file's extracted_data is available. Run scoped to each task so the
            // resulting CoverageCard renders inside its owning task.
            // FIXME(periodic-coverage): session vars hardcoded to AU FY 2024-25 to match
            // the test-app's rental statement fixtures. Consumers should source these
            // from config, form fields, or a tax-year selector before production use.
            const coverage_ctx = {
                form_data: null,
                session: {
                    Beginning_Of_Tax_Year: '2024-07-01',
                    End_Of_Tax_Year: '2025-06-30',
                },
            };
            let any_coverage_results = false;
            for (const task of data_ref.current.tasks) {
                const task_file_items = task.thread
                    .flatMap(m => m.content_items ?? [])
                    .filter(i => i.type === 'file');
                if (task_file_items.length === 0)
                    continue;
                const cov = await pipeline.run_periodic_coverage_pass(task_file_items, coverage_ctx);
                // Persist on the task (overwrites any prior results — re-running
                // backoffice fully recomputes coverage for that task).
                on_task_update?.({ ...task, coverage_results: cov });
                if (cov.length === 0)
                    continue;
                any_coverage_results = true;
                for (const c of cov) {
                    if (c.skipped) {
                        on_log?.(`${current_user.name} (${current_user.id}): Periodic coverage [${c.rule_name}] on group "${c.group_key}" — skipped (period bounds did not resolve)`);
                    }
                    else if (c.gaps.length === 0) {
                        on_log?.(`${current_user.name} (${current_user.id}): Periodic coverage [${c.rule_name}] on group "${c.group_key}" — full coverage, no gaps`);
                    }
                    else {
                        const gap_summary = c.gaps.map(g => `${g.start}…${g.end}`).join(', ');
                        on_log?.(`${current_user.name} (${current_user.id}): Periodic coverage [${c.rule_name}] on group "${c.group_key}" — ${c.gaps.length} gap(s): ${gap_summary}`);
                    }
                }
            }
            if (!any_coverage_results) {
                on_log?.(`${current_user.name} (${current_user.id}): Periodic coverage check — no periodic_coverage rules matched any group's document_type`);
            }
        }
        catch (err) {
            console.error('[thread-form] Backoffice validation failed:', err);
        }
        finally {
            set_backoffice_running(false);
        }
    }, [backoffice_running, pending_backoffice_items, pipeline, on_update_content_item, on_log, current_user]);
    /**
     * Run the aggregate resolver (sum_match) for a clarification task.
     * Walks every client-uploaded file in the task, gathers extracted_fields
     * + classification, and posts to the resolution API. Emits one
     * resolution_verdict message + per-file doc_check messages, and updates
     * task status to completed/needs_attention based on the verdict.
     *
     * Called from:
     * - handle_send (after a file-bearing send) — passes fresh classifications
     *   from the just-uploaded files via fresh_cls_map + the b64 cache.
     * - handle_replacement_files (after a Replace Document upload finishes).
     * - handle_remove_file (after the user deletes a file from the task).
     *
     * Pass `data_snapshot` when the caller has a fresher view of the data
     * than data_ref.current (e.g. immediately after on_form_change).
     */
    const run_resolution = useCallback(async (task_id, opts = {}) => {
        if (!resolution_api_url)
            return;
        const source = opts.data_snapshot ?? data_ref.current;
        const parent_task = source.tasks.find(t => t.task_id === task_id);
        const send_back_msg = parent_task?.thread.find(m => m.type === 'send_back' && m.send_back);
        const issues_with_amount = send_back_msg?.send_back?.issues ?? [];
        const sb_issue = issues_with_amount.find(i => i.amount) ??
            issues_with_amount.find(i => /\$\s*\d+(?:[.,]\d+)?/.test(i.issue_description));
        if (!sb_issue)
            return;
        const text_amount = sb_issue.issue_description.match(/\$\s*\d+(?:[.,]\d+)?/);
        const text_date = sb_issue.issue_description.match(/\b\d{1,2}\s+[A-Za-z]+\s+\d{4}\s+(?:-|–|—|to)\s+\d{1,2}\s+[A-Za-z]+\s+\d{4}\b|\b\d{4}-\d{2}-\d{2}\s+(?:-|–|—|to)\s+\d{4}-\d{2}-\d{2}\b/);
        const issue_for_extract = {
            issue_id: sb_issue.issue_id ?? sb_issue.rule_id,
            issue_description: sb_issue.issue_description,
            amount: sb_issue.amount ?? text_amount?.[0],
            date: sb_issue.date ?? text_date?.[0],
        };
        const expectation = extract_expectation(issue_for_extract, { default_currency: 'AUD' });
        if (!expectation)
            return;
        // Walk the task thread and gather every client-uploaded file. Gate on
        // having at least one file — running with zero files would always fail
        // and isn't useful (the panel will keep its prior verdict, which gets
        // cleared by the bookkeeping below if the task is now empty of files).
        const messages = parent_task?.thread ?? [];
        const send_back_idx = messages.findIndex(m => m.type === 'send_back');
        const seen_file_ids = new Set();
        const aggregated = [];
        for (let i = 0; i < messages.length; i++) {
            if (i <= send_back_idx)
                continue;
            const m = messages[i];
            if (m.type !== 'content')
                continue;
            for (const ci of m.content_items ?? []) {
                if (ci.type !== 'file' || !ci.file?.file_id)
                    continue;
                if (seen_file_ids.has(ci.file.file_id))
                    continue;
                seen_file_ids.add(ci.file.file_id);
                const fresh = opts.fresh_cls_map?.get(ci.content_id);
                const cls = fresh ?? ci.classification_result;
                const ef = cls?.extracted_fields;
                aggregated.push({
                    content_id: ci.content_id,
                    file_id: ci.file.file_id,
                    file_name: ci.file.file_name,
                    mime_type: ci.file.mime_type,
                    ...(cls?.document_type ? { document_type: cls.document_type } : {}),
                    ...(cls?.document_nature ? { document_nature: cls.document_nature } : {}),
                    ...(ef ? { extracted_fields: ef } : {}),
                });
            }
        }
        if (aggregated.length === 0)
            return;
        on_log?.(`Aggregate resolver: running sum_match for "${sb_issue.issue_description}" (${aggregated.length} file${aggregated.length === 1 ? '' : 's'})`);
        set_resolution_running(prev => {
            const next = new Set(prev);
            next.add(task_id);
            return next;
        });
        try {
            // Resolve base64 for each file. Current-send files are in the
            // caller-supplied b64 cache (handle_send); prior-send files come
            // from file_manager.get_download_url + fetch.
            const fm = file_manager?.callbacks;
            const file_b64_resolved = new Map();
            await Promise.all(aggregated.map(async (f) => {
                const cached = opts.current_send_b64_by_content_id?.get(f.content_id);
                if (cached) {
                    file_b64_resolved.set(f.file_id, cached);
                    return;
                }
                if (!fm?.get_download_url)
                    return;
                try {
                    const url = fm.get_download_url(f.file_id, 'public');
                    if (!url)
                        return;
                    const blob_res = await fetch(url);
                    if (!blob_res.ok)
                        return;
                    const buf = await blob_res.arrayBuffer();
                    const bytes = new Uint8Array(buf);
                    let binary = '';
                    const chunk = 0x8000;
                    for (let i = 0; i < bytes.length; i += chunk) {
                        binary += String.fromCharCode.apply(null, Array.from(bytes.subarray(i, i + chunk)));
                    }
                    file_b64_resolved.set(f.file_id, btoa(binary));
                }
                catch (err) {
                    console.warn('[ThreadForm] Resolver: failed to fetch base64 for', f.file_name, err);
                }
            }));
            on_log?.(`Aggregate resolver: ${file_b64_resolved.size}/${aggregated.length} file(s) have file_b64 — sending to LLM`);
            const response_files = aggregated.map(f => ({
                file_id: f.file_id,
                file_name: f.file_name,
                ...(f.mime_type ? { mime_type: f.mime_type } : {}),
                ...(f.document_type ? { document_type: f.document_type } : {}),
                ...(f.document_nature ? { document_nature: f.document_nature } : {}),
                ...(f.extracted_fields ? { extracted_fields: f.extracted_fields } : {}),
                ...(file_b64_resolved.has(f.file_id) ? { file_b64: file_b64_resolved.get(f.file_id) } : {}),
            }));
            const res = await fetch(resolution_api_url, {
                method: 'POST',
                headers: { 'Content-Type': 'application/json' },
                body: JSON.stringify({ expectation, response_files }),
            });
            const data_resp = await res.json();
            if (!res.ok || data_resp?.success === false) {
                on_log?.(`Aggregate resolver: API error — skipping verdict`);
                return;
            }
            const check = data_resp.check;
            if (!check)
                return;
            on_log?.(`Aggregate resolver: ${check.passed ? 'passed' : 'failed'} — ${check.summary}`);
            on_message_add?.(task_id, {
                message_id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
                role: 'system',
                type: 'resolution_verdict',
                visibility: 'all',
                resolution_verdict: check,
                time: new Date().toISOString(),
            });
            const file_rel = check.file_relevance ?? {};
            for (const f of aggregated) {
                const r = file_rel[f.file_id];
                if (!r)
                    continue;
                const status = r.verdict === 'matches' ? 'passed' : 'issues';
                on_message_add?.(task_id, {
                    message_id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
                    role: 'system',
                    type: 'doc_check',
                    visibility: 'all',
                    doc_check: {
                        content_ref: f.content_id,
                        source_type: 'file',
                        status,
                        summary: r.reason,
                        relevance: r.verdict,
                        relevance_reason: r.reason,
                    },
                    time: new Date().toISOString(),
                });
            }
            // Re-read the task from data_ref so on_task_update receives the
            // most recent thread (verdict messages we just emitted may not yet
            // be reflected here, which is fine — task_update only mutates status).
            const latest_task = data_ref.current.tasks.find(t => t.task_id === task_id);
            if (latest_task) {
                const next_status = check.passed ? 'completed' : 'needs_attention';
                if (latest_task.status !== next_status) {
                    on_task_update?.({
                        ...latest_task,
                        status: next_status,
                        completed_at: check.passed ? new Date().toISOString() : undefined,
                    });
                }
            }
        }
        catch (err) {
            console.warn('[ThreadForm] resolution call failed', err);
        }
        finally {
            set_resolution_running(prev => {
                if (!prev.has(task_id))
                    return prev;
                const next = new Set(prev);
                next.delete(task_id);
                return next;
            });
        }
    }, [resolution_api_url, file_manager, on_message_add, on_task_update, on_log]);
    /** Combined send: text + files. Uploads files via file_manager, then triggers classification pipeline. */
    const handle_send = useCallback(async (task_id, text, files) => {
        const has_text = text.trim().length > 0;
        const has_files = files.length > 0;
        if (!has_text && !has_files)
            return;
        // Upload files via file_manager if available, otherwise create local-only references
        const content_items = [];
        const file_b64_map = new Map(); // content_id → base64
        for (const file of files) {
            const content_id = `ct_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
            let file_id;
            let file_size_str;
            if (file_manager?.callbacks?.upload) {
                try {
                    const attachment = await file_manager.callbacks.upload(file, {
                        entity_id: task_id,
                        entity_type: 'thread_task',
                        visibility: 'public',
                        uploaded_by: current_user.id,
                    });
                    file_id = attachment.file_id;
                    file_size_str = format_file_size(attachment.file_size);
                }
                catch (err) {
                    console.error('[ThreadForm] File upload failed:', err);
                    // Fall back to local ID
                    file_id = `f_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
                    file_size_str = format_file_size(file.size);
                }
            }
            else {
                file_id = `f_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
                file_size_str = format_file_size(file.size);
            }
            content_items.push({
                content_id,
                type: 'file',
                file: {
                    file_id,
                    file_name: file.name,
                    file_size: file_size_str,
                    mime_type: normalize_file_mime(file.type, file.name),
                },
            });
            // Read base64 for pipeline (classification + validation)
            if (classification_api_url) {
                try {
                    const b64 = await file_to_base64(file);
                    file_b64_map.set(content_id, b64);
                }
                catch {
                    console.error('[ThreadForm] Failed to read file as base64:', file.name);
                }
            }
        }
        const msg = {
            message_id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
            role: role,
            type: has_files ? 'content' : 'comment',
            visibility: 'all',
            content_text: has_text ? text.trim() : undefined,
            content_items: has_files ? content_items : undefined,
            user_name: current_user.name,
            user_id: current_user.id,
            time: new Date().toISOString(),
        };
        on_message_add?.(task_id, msg);
        // Build a post-add snapshot so the resolver definitely sees the just-uploaded
        // files, even if React hasn't re-rendered the parent (and refreshed data_ref)
        // by the time the pipeline finishes. Earlier this caused a race where new
        // files were missing from the resolver's file list — verdict reflected only
        // the prior batch and the new total looked off (e.g. $137.50 of $437.47 when
        // the new file should have brought it to $437.47 exactly).
        const post_add_snapshot = {
            ...data_ref.current,
            updated_at: msg.time,
            tasks: data_ref.current.tasks.map(t => t.task_id === task_id ? { ...t, thread: [...t.thread, msg] } : t),
        };
        // Trigger classification + validation pipeline for each file (async, non-blocking).
        // Capture each promise so the aggregate resolver below can await them and
        // read the classification result directly — closure-captured pipeline.classification_results
        // is stale by the time it runs.
        const pipeline_promises = [];
        if (classification_api_url) {
            on_log?.(`Upload path (compose): ${content_items.length} file(s) → triggering pipeline`);
            for (const item of content_items) {
                const b64 = file_b64_map.get(item.content_id);
                if (b64 && item.type === 'file') {
                    const p = pipeline.process_file(item, b64).then(result => {
                        // Inject doc_check and ai_analysis messages into the thread
                        if (result.doc_check) {
                            on_message_add?.(task_id, {
                                message_id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
                                role: 'system', type: 'doc_check', visibility: 'all',
                                doc_check: result.doc_check, time: new Date().toISOString(),
                            });
                        }
                        if (result.ai_result) {
                            on_message_add?.(task_id, {
                                message_id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
                                role: 'system', type: 'ai_analysis', visibility: 'agent_only',
                                ai_analysis: { items: [result.ai_result] }, time: new Date().toISOString(),
                            });
                        }
                        // Persist classification + validation results on the ContentItem.
                        // Keep the full ValidationRuleResult shape (issues, check_type, …)
                        // so UI-side normalizers work the same after a refresh.
                        if (result.classification || result.validation_results.length > 0) {
                            setTimeout(() => {
                                handle_update_content_item(item.content_id, {
                                    classification_result: result.classification || undefined,
                                    validation_rule_results: result.validation_results,
                                });
                            }, 100);
                        }
                        return { content_id: item.content_id, classification: result.classification };
                    }).catch(err => {
                        console.error('[ThreadForm] Pipeline error for', item.file?.file_name, err);
                        return { content_id: item.content_id, classification: null };
                    });
                    pipeline_promises.push(p);
                }
            }
        }
        // ── Aggregate resolver (sum_match) ──
        // Fire after pipeline_promises resolve so the just-uploaded files'
        // classifications are available for the resolver. Prior-send files
        // are read from data_ref via run_resolution.
        if (has_files) {
            (async () => {
                const fresh_results = await Promise.all(pipeline_promises);
                const fresh_cls_map = new Map();
                for (const r of fresh_results)
                    fresh_cls_map.set(r.content_id, r.classification);
                await run_resolution(task_id, {
                    fresh_cls_map,
                    current_send_b64_by_content_id: file_b64_map,
                    data_snapshot: post_add_snapshot,
                });
            })();
        }
        // Trigger text validation pipeline for text-only messages (async, non-blocking).
        // Run the content gate first so conversational replies never trigger validation
        // and never produce a client-visible "N issues found" banner. Client-authored
        // doc_checks go out as agent_only so any AI language stays on the agent's side.
        if (validation_api_url && has_text && !has_files && text.trim().length > 20) {
            pipeline.gate_text_content(text.trim()).then(gate => {
                on_log?.(`Content gate (${msg.role}): has_content=${gate.has_content}${gate.reason ? ` — ${gate.reason}` : ''}`);
                if (!gate.has_content)
                    return; // Skip — nothing worth validating
                pipeline.process_text(msg.message_id, text.trim()).then(result => {
                    if (result.doc_check) {
                        on_message_add?.(task_id, {
                            message_id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
                            role: 'system', type: 'doc_check',
                            visibility: msg.role === 'client' ? 'agent_only' : 'all',
                            doc_check: result.doc_check, time: new Date().toISOString(),
                        });
                    }
                }).catch(err => {
                    console.error('[ThreadForm] Text pipeline error:', err);
                });
            }).catch(err => {
                console.error('[ThreadForm] Content gate error:', err);
            });
        }
    }, [role, current_user, on_message_add, file_manager, classification_api_url, validation_api_url, resolution_api_url, pipeline, file_to_base64, format_file_size, on_log]);
    const handle_send_comment = useCallback((task_id, text) => {
        handle_send(task_id, text, []);
    }, [handle_send]);
    const handle_reply_to_issue = useCallback((task_id, source_content_id, file_name, rule_name, text) => {
        const msg = {
            message_id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
            role: role === 'agent' ? 'agent' : 'client',
            type: 'comment',
            visibility: 'all',
            content_text: text,
            reply_to: { source_content_id, file_name, rule_name },
            user_name: current_user.name,
            user_id: current_user.id,
            time: new Date().toISOString(),
        };
        on_message_add?.(task_id, msg);
    }, [role, current_user, on_message_add]);
    const handle_toggle_complete = useCallback((task_id) => {
        const task = data.tasks.find(t => t.task_id === task_id);
        if (!task)
            return;
        const was_completed = task.status === 'completed';
        const updated = {
            ...task,
            status: was_completed ? 'in_progress' : 'completed',
            completed_by: was_completed ? undefined : current_user.id,
            completed_at: was_completed ? undefined : new Date().toISOString(),
        };
        // Add status change message
        const status_msg = {
            message_id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
            role: 'system',
            type: 'status_change',
            visibility: 'all',
            status_change: { from: task.status, to: updated.status },
            user_name: current_user.name,
            user_id: current_user.id,
            time: new Date().toISOString(),
        };
        const with_msg = {
            ...updated,
            thread: [...updated.thread, status_msg],
        };
        on_task_update?.(with_msg);
        on_log?.(`${current_user.name} (${current_user.id}): Task "${task.title}" → ${updated.status}`);
    }, [data.tasks, current_user, on_task_update, on_log]);
    const handle_add_task = useCallback((task) => {
        // Fire form change with new task added
        const updated = {
            ...data,
            updated_at: new Date().toISOString(),
            tasks: [...data.tasks, task],
        };
        on_form_change?.(updated);
    }, [data, on_form_change]);
    const handle_view_file = useCallback((item) => {
        set_viewed_item(item);
    }, []);
    const handle_close_pdf = useCallback(() => {
        set_viewed_item(null);
    }, []);
    const viewed_file_url = viewed_item?.file
        ? file_manager?.callbacks?.get_download_url?.(viewed_item.file.file_id, 'public') || null
        : null;
    /** Upload a single file via file_manager, return a ContentItem. Used by MessageEditor. */
    const handle_upload_file = useCallback(async (file) => {
        const content_id = `ct_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
        let file_id;
        let file_size_str;
        if (file_manager?.callbacks?.upload) {
            const attachment = await file_manager.callbacks.upload(file, {
                entity_id: 'thread_edit',
                entity_type: 'thread_task',
                visibility: 'public',
                uploaded_by: current_user.id,
            });
            file_id = attachment.file_id;
            file_size_str = format_file_size(attachment.file_size);
        }
        else {
            file_id = `f_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
            file_size_str = format_file_size(file.size);
        }
        const item = {
            content_id,
            type: 'file',
            file: {
                file_id,
                file_name: file.name,
                file_size: file_size_str,
                mime_type: file.type || 'application/octet-stream',
            },
        };
        // Trigger pipeline for this file (fire-and-forget)
        if (classification_api_url) {
            on_log?.(`Upload path (edit): ${file.name} → triggering pipeline`);
            file_to_base64(file).then(b64 => {
                pipeline.process_file(item, b64).then(result => {
                    // Find the task containing this item and inject messages
                    for (const task of data.tasks) {
                        const has_item = task.thread.some(m => m.content_items?.some(ci => ci.content_id === content_id));
                        if (!has_item)
                            continue;
                        if (result.doc_check) {
                            on_message_add?.(task.task_id, {
                                message_id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
                                role: 'system', type: 'doc_check', visibility: 'all',
                                doc_check: result.doc_check, time: new Date().toISOString(),
                            });
                        }
                        if (result.ai_result) {
                            on_message_add?.(task.task_id, {
                                message_id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
                                role: 'system', type: 'ai_analysis', visibility: 'agent_only',
                                ai_analysis: { items: [result.ai_result] }, time: new Date().toISOString(),
                            });
                        }
                        break;
                    }
                    // Persist classification + validation on the item so results survive refresh.
                    if (result.classification || result.validation_results.length > 0) {
                        setTimeout(() => {
                            on_update_content_item?.(content_id, {
                                classification_result: result.classification || undefined,
                                validation_rule_results: result.validation_results,
                            });
                        }, 100);
                    }
                }).catch(err => console.error('[ThreadForm] Pipeline error:', err));
            }).catch(err => console.error('[ThreadForm] Base64 read error:', err));
        }
        return item;
    }, [file_manager, current_user, classification_api_url, pipeline, data.tasks, on_message_add, file_to_base64, format_file_size]);
    /** Remove a file from its parent message's content_items */
    const handle_remove_file = useCallback((item) => {
        // Close PDF panel if we're removing the viewed file
        if (viewed_item?.content_id === item.content_id) {
            set_viewed_item(null);
        }
        // Remove from file_manager if available
        if (item.file && file_manager?.callbacks?.remove) {
            file_manager.callbacks.remove(item.file.file_id, `ref_${item.file.file_id}`).catch(err => {
                console.error('[ThreadForm] Failed to remove file:', err);
            });
        }
        // Track which task held the removed file so we can re-run the resolver
        // for that task once the data update propagates.
        let owning_task_id;
        // Remove the content item from the message
        const updated = {
            ...data,
            updated_at: new Date().toISOString(),
            tasks: data.tasks.map(task => ({
                ...task,
                thread: task.thread.map(msg => {
                    if (!msg.content_items)
                        return msg;
                    const filtered = msg.content_items.filter(ci => ci.content_id !== item.content_id);
                    if (filtered.length === msg.content_items.length)
                        return msg; // not in this message
                    owning_task_id = task.task_id;
                    return {
                        ...msg,
                        content_items: filtered.length > 0 ? filtered : undefined,
                        // If no files left and no text, keep as comment type
                        type: filtered.length === 0 && msg.content_text ? 'comment' : msg.type,
                    };
                }),
            })),
        };
        on_form_change?.(updated);
        // Re-run the resolver for the owning clarification task so the panel
        // reflects the new file set. Pass the post-deletion snapshot directly
        // because data_ref hasn't been refreshed by the parent re-render yet.
        if (owning_task_id) {
            run_resolution(owning_task_id, { data_snapshot: updated });
        }
    }, [data, on_form_change, file_manager, viewed_item, run_resolution]);
    const handle_accept = useCallback((content_ref) => {
        // Update review_status in the data
        const updated = {
            ...data,
            updated_at: new Date().toISOString(),
            tasks: data.tasks.map(task => ({
                ...task,
                thread: task.thread.map(msg => {
                    if (msg.type !== 'ai_analysis' || !msg.ai_analysis)
                        return msg;
                    return {
                        ...msg,
                        ai_analysis: {
                            ...msg.ai_analysis,
                            items: msg.ai_analysis.items.map(item => item.content_ref === content_ref ? { ...item, review_status: 'accepted' } : item),
                        },
                    };
                }),
            })),
        };
        on_form_change?.(updated);
        on_log?.(`${current_user.name} (${current_user.id}): Accepted AI analysis for ${content_ref.slice(-5)}`);
    }, [data, on_form_change, current_user, on_log]);
    const handle_dismiss = useCallback((content_ref) => {
        const updated = {
            ...data,
            updated_at: new Date().toISOString(),
            tasks: data.tasks.map(task => ({
                ...task,
                thread: task.thread.map(msg => {
                    if (msg.type !== 'ai_analysis' || !msg.ai_analysis)
                        return msg;
                    return {
                        ...msg,
                        ai_analysis: {
                            ...msg.ai_analysis,
                            items: msg.ai_analysis.items.map(item => item.content_ref === content_ref ? { ...item, review_status: 'dismissed' } : item),
                        },
                    };
                }),
            })),
        };
        on_form_change?.(updated);
        on_log?.(`${current_user.name} (${current_user.id}): Dismissed AI analysis for ${content_ref.slice(-5)}`);
    }, [data, on_form_change, current_user, on_log]);
    const handle_send_to_client = useCallback((content_ref) => {
        // Find the AI result to build compose context
        for (const task of data.tasks) {
            for (const msg of task.thread) {
                if (msg.type !== 'ai_analysis' || !msg.ai_analysis)
                    continue;
                for (const item of msg.ai_analysis.items) {
                    if (item.content_ref !== content_ref)
                        continue;
                    // Build context from the first failing check or field issue
                    const failing_check = item.validation.checks.find(c => c.status !== 'passed');
                    if (failing_check) {
                        const file_name = task.thread
                            .flatMap(m => m.content_items || [])
                            .find(ci => ci.content_id === content_ref)?.file?.file_name || content_ref;
                        set_compose_context(build_compose_from_check(file_name, failing_check, content_ref, data.client.name));
                        return;
                    }
                    // Or from extracted data issue
                    for (const group of item.extracted_data || []) {
                        const field_with_issue = group.fields.find(f => f.issue);
                        if (field_with_issue) {
                            set_compose_context(build_compose_from_field(field_with_issue, group.category, content_ref, data.client.name));
                            return;
                        }
                    }
                }
            }
        }
    }, [data]);
    const handle_compose_send = useCallback((message, content_ref) => {
        // Find which task contains this content_ref and add an agent comment
        for (const task of data.tasks) {
            const has_ref = task.thread.some(m => m.ai_analysis?.items.some(i => i.content_ref === content_ref));
            if (!has_ref)
                continue;
            const comment_msg = {
                message_id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
                role: 'agent',
                type: 'comment',
                visibility: 'all',
                content_text: message,
                user_name: current_user.name,
                user_id: current_user.id,
                time: new Date().toISOString(),
            };
            on_message_add?.(task.task_id, comment_msg);
            // Mark as sent_to_client
            handle_accept(content_ref); // Reuse — sets review_status but we want 'sent_to_client'
            const updated = {
                ...data,
                tasks: data.tasks.map(t => ({
                    ...t,
                    thread: t.thread.map(msg => {
                        if (msg.type !== 'ai_analysis' || !msg.ai_analysis)
                            return msg;
                        return {
                            ...msg,
                            ai_analysis: {
                                ...msg.ai_analysis,
                                items: msg.ai_analysis.items.map(item => item.content_ref === content_ref
                                    ? { ...item, review_status: 'sent_to_client', sent_message_id: comment_msg.message_id }
                                    : item),
                            },
                        };
                    }),
                })),
            };
            on_form_change?.(updated);
            break;
        }
        set_compose_context(null);
    }, [data, current_user, on_message_add, on_form_change]);
    const handle_add_question = useCallback((_task_id) => {
        set_show_add_question(true);
    }, []);
    /** Agent sends back selected issues as a new task (Approach F) */
    const handle_send_back_issues = useCallback((source_item, issues, accepted_issue_keys) => {
        // Accept-all path — issues=[] and accepted_issue_keys has all clarification IDs.
        // Mark the source ContentItem as accepted so the Pending Review pill flips to
        // "Accepted" and no new task is created.
        if (issues.length === 0) {
            if (accepted_issue_keys.length > 0) {
                on_update_content_item?.(source_item.content_id, { accepted: true });
                const file_name = source_item.file?.file_name || 'Document';
                on_log?.(`${current_user.name} (${current_user.id}): Accepted client response for ${file_name}`);
            }
            return;
        }
        const make_id = (prefix) => `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
        const file_name = source_item.file?.file_name || 'Document';
        // Find the parent task that originally contained this file — its title becomes
        // the "original question" in the send-back title, and its task_id becomes the
        // merge key for routing this Send Back into an existing open clarifications
        // task (Y1 grouping — see docs/superpowers/specs/2026-04-30-send-back-merging-design.md).
        const parent_task = data_ref.current.tasks.find(t => t.thread.some(m => m.content_items?.some(ci => ci.content_id === source_item.content_id)));
        const parent_title = parent_task?.title || file_name;
        const parent_task_id = parent_task?.task_id;
        const send_back_data = {
            source_content_id: source_item.content_id,
            source_file: source_item.file,
            issues,
            history: [],
        };
        const send_back_msg = {
            message_id: make_id('msg'),
            role: 'agent',
            type: 'send_back',
            visibility: 'all',
            send_back: send_back_data,
            user_name: current_user.name,
            user_id: current_user.id,
            time: new Date().toISOString(),
        };
        // If we know the parent task, route through the merge helper so that
        // subsequent Send Backs from the same parent land in one open
        // clarifications task. Without a parent (legacy/orphan path), fall back to
        // creating a new task as before.
        let next_tasks;
        if (parent_task_id) {
            const build_new_task = (msg) => ({
                task_id: make_id('task'),
                title: `Clarifications regarding ${parent_title}`,
                description: '',
                status: 'needs_attention',
                sort_order: data.tasks.length + 1,
                created_at: new Date().toISOString(),
                created_by: current_user.id,
                thread: [msg],
                parent_task_id,
            });
            next_tasks = merge_send_back_message(data.tasks, parent_task_id, send_back_msg, build_new_task);
        }
        else {
            const new_task = {
                task_id: make_id('task'),
                title: `Clarifications regarding ${parent_title}`,
                description: '',
                status: 'needs_attention',
                sort_order: data.tasks.length + 1,
                created_at: new Date().toISOString(),
                created_by: current_user.id,
                thread: [send_back_msg],
            };
            next_tasks = [...data.tasks, new_task];
        }
        const updated = {
            ...data,
            updated_at: new Date().toISOString(),
            tasks: next_tasks,
        };
        on_form_change?.(updated);
        // Mark the source ContentItem as sent_back for pill display
        setTimeout(() => {
            on_update_content_item?.(source_item.content_id, { sent_back: true });
        }, 50);
        const total_child_count = issues.reduce((sum, iss) => sum + (iss.child_issues?.length ?? 1), 0);
        const has_groups = issues.some(iss => iss.child_issues && iss.child_issues.length > 0);
        const log_count_str = has_groups
            ? `${issues.length} group(s) covering ${total_child_count} issue(s)`
            : `${issues.length} issue(s)`;
        on_log?.(`${current_user.name} (${current_user.id}): Sent back ${log_count_str} for ${file_name}`);
    }, [data, current_user, on_form_change, on_update_content_item, on_log]);
    /** Agent submits a batch of per-rule decisions from the Pending Review panel.
     *  - 'accepted' rules → stamp rule_actions[rule_id] = 'accepted'
     *  - 'sent_back' rules → build one send_back message per rule and route
     *    through merge_send_back_message so they all land in one open
     *    clarifications task for the parent question (Y1 grouping).
     *  Both side effects (form_change + content_item update) run in the same
     *  synchronous turn so React 18 auto-batches them into a single render. */
    const handle_submit_decisions = useCallback((source_item, decisions) => {
        if (decisions.length === 0)
            return;
        const make_id = (prefix) => `${prefix}_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
        const file_name = source_item.file?.file_name || 'Document';
        const parent_task = data_ref.current.tasks.find(t => t.thread.some(m => m.content_items?.some(ci => ci.content_id === source_item.content_id)));
        const parent_title = parent_task?.title || file_name;
        const parent_task_id = parent_task?.task_id;
        let next_tasks = data.tasks;
        let send_back_index = 0;
        for (const d of decisions) {
            if (d.action !== 'sent_back')
                continue;
            send_back_index++;
            const child_issues = d.rule_result.issues.map(issue => ({
                issue_id: issue.issue_id,
                issue_description: issue.issue_description,
                date: issue.date,
                kind: issue.kind,
                amount: issue.amount,
            }));
            const send_back_payload = {
                rule_id: d.rule_result.rule_id,
                rule_name: d.rule_result.rule_name || d.rule_result.rule_id,
                issue_description: d.rule_result.summary || d.rule_result.rule_name || d.rule_result.rule_id,
                agent_feedback: d.comment ?? '',
                child_issues,
            };
            const send_back_data = {
                source_content_id: source_item.content_id,
                source_file: source_item.file,
                issues: [send_back_payload],
                history: [],
            };
            const send_back_msg = {
                message_id: make_id('msg'),
                role: 'agent',
                type: 'send_back',
                visibility: 'all',
                send_back: send_back_data,
                user_name: current_user.name,
                user_id: current_user.id,
                time: new Date().toISOString(),
            };
            if (parent_task_id) {
                const build_new_task = (msg) => ({
                    task_id: make_id('task'),
                    title: `Clarifications regarding ${parent_title}`,
                    description: '',
                    status: 'needs_attention',
                    sort_order: next_tasks.length + 1,
                    created_at: new Date().toISOString(),
                    created_by: current_user.id,
                    thread: [msg],
                    parent_task_id,
                });
                next_tasks = merge_send_back_message(next_tasks, parent_task_id, send_back_msg, build_new_task);
            }
            else {
                const new_task = {
                    task_id: make_id('task'),
                    title: `Clarifications regarding ${parent_title}`,
                    description: '',
                    status: 'needs_attention',
                    sort_order: next_tasks.length + 1,
                    created_at: new Date().toISOString(),
                    created_by: current_user.id,
                    thread: [send_back_msg],
                };
                next_tasks = [...next_tasks, new_task];
            }
        }
        // Stamp rule_actions and recompute derived flags atomically with the tasks change.
        const merged_rule_actions = {
            ...(source_item.rule_actions ?? {}),
        };
        for (const d of decisions) {
            merged_rule_actions[d.rule_id] = d.action;
        }
        const all_rules_with_issues = (source_item.validation_rule_results ?? [])
            .filter(r => r.issues.length > 0);
        const decision_state = compute_file_decision_state(merged_rule_actions, all_rules_with_issues);
        // Atomic update: tasks + per-file fields together.
        if (next_tasks !== data.tasks) {
            const updated = {
                ...data,
                updated_at: new Date().toISOString(),
                tasks: next_tasks,
            };
            on_form_change?.(updated);
        }
        on_update_content_item?.(source_item.content_id, {
            rule_actions: merged_rule_actions,
            accepted: decision_state.accepted,
            sent_back: decision_state.sent_back,
        });
        on_log?.(`${current_user.name} (${current_user.id}): Submitted ${decisions.length} decision(s) for ${file_name} (${send_back_index} send-back, ${decisions.length - send_back_index} accept)`);
    }, [data, current_user, on_form_change, on_update_content_item, on_log]);
    const handle_edit_message = useCallback((task_id, message_id, updated) => {
        on_message_edit?.(task_id, message_id, updated);
    }, [on_message_edit]);
    const handle_delete_message = useCallback((task_id, message_id) => {
        on_message_delete?.(task_id, message_id);
    }, [on_message_delete]);
    /** Delete a task from the form */
    const handle_delete_task = useCallback((task_id) => {
        const updated = {
            ...data,
            updated_at: new Date().toISOString(),
            tasks: data.tasks.filter(t => t.task_id !== task_id),
        };
        on_form_change?.(updated);
        const task_title = data.tasks.find(t => t.task_id === task_id)?.title || task_id;
        on_log?.(`${current_user.name} (${current_user.id}): Deleted task "${task_title}"`);
    }, [data, on_form_change, current_user, on_log]);
    /** Update a ContentItem's fields — delegates to prop (functional update, safe from stale closures) */
    const handle_update_content_item = useCallback((content_id, updates) => {
        on_update_content_item?.(content_id, updates);
    }, [on_update_content_item]);
    /** Agent edits a file's document type via the FileBar pencil. Updates the
     *  classification and re-runs backoffice validation against the rules of the
     *  newly selected document_type. Stale validation results from the prior type
     *  are cleared so the UI reflects the new rule set, not a mix. */
    const handle_change_document_type = useCallback(async (item, new_type_id) => {
        const old_classification = item.classification_result || pipeline.classification_results.get(item.content_id);
        const updated_classification = {
            // Sensible defaults if there was no classification yet (file uploaded as unknown).
            tags: [],
            confidence: 1,
            ...(old_classification ?? {}),
            document_type: new_type_id,
        };
        // 1. Persist the new doc type, clear stale validation results, and mark as
        //    needing revalidation (clearing backoffice_validated_at puts it back in
        //    the agent's "Run Backoffice (N)" pending pool while we re-run below).
        on_update_content_item?.(item.content_id, {
            classification_result: updated_classification,
            validation_rule_results: [],
            backoffice_validated_at: undefined,
            accepted: false,
            sent_back: false,
        });
        on_log?.(`${current_user.name} (${current_user.id}): Changed document type to "${new_type_id}" for ${item.file?.file_name ?? item.content_id}`);
        // 2. Re-run backoffice validation. Pass a synthetic item that carries the
        //    new classification so run_backoffice_validation reads the right
        //    document_type when looking up rules.
        const synthetic = { ...item, classification_result: updated_classification };
        try {
            const results_map = await pipeline.run_backoffice_validation([synthetic]);
            const r = results_map.get(item.content_id);
            if (r) {
                on_update_content_item?.(item.content_id, {
                    validation_rule_results: r.validation_results,
                    backoffice_validated_at: new Date().toISOString(),
                });
                on_log?.(`Re-ran ${r.validation_results.length} rule(s) for ${item.file?.file_name ?? item.content_id} under "${new_type_id}"`);
            }
        }
        catch (err) {
            console.error('[ThreadForm] Re-validation after doc-type change failed:', err);
            on_log?.(`Re-validation failed for ${item.file?.file_name ?? item.content_id} — see console`);
        }
    }, [pipeline, on_update_content_item, on_log, current_user]);
    /** Handle replacement files from clarification response — append to the original message and run pipeline */
    const handle_replacement_files = useCallback(async (source_content_id, files) => {
        if (files.length === 0)
            return;
        // Use ref for latest data to avoid stale closure issues with chained replacements
        const current_data = data_ref.current;
        // Find the task and message containing the source content item
        let found_task;
        let found_msg;
        for (const t of current_data.tasks) {
            const m = t.thread.find(m => m.content_items?.some(ci => ci.content_id === source_content_id));
            if (m) {
                found_task = t;
                found_msg = m;
                break;
            }
        }
        if (!found_task || !found_msg) {
            console.error('[ThreadForm] Could not find task/message for source content:', source_content_id);
            return;
        }
        // Convert FormFileAttachments to ContentItems, linking to the replaced file
        const new_items = files.map(f => ({
            content_id: `ct_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
            type: 'file',
            file: {
                file_id: f.file_id,
                file_name: f.file_name,
                file_size: format_file_size(f.file_size),
                mime_type: f.mime_type || 'application/octet-stream',
            },
            replaces_content_id: source_content_id,
        }));
        // Mark the original file as replaced, and append replacement files to the message
        const first_replacement_id = new_items[0].content_id;
        const updated_items = (found_msg.content_items || []).map(ci => ci.content_id === source_content_id
            ? { ...ci, replaced_by_content_id: first_replacement_id }
            : ci);
        updated_items.push(...new_items);
        on_message_edit?.(found_task.task_id, found_msg.message_id, {
            content_text: found_msg.content_text,
            content_items: updated_items,
        });
        on_log?.(`${current_user.name}: Uploaded ${files.length} replacement file${files.length > 1 ? 's' : ''}`);
        // Trigger classification + validation pipeline for each replacement file.
        // Collect each file's classification promise so we can run the aggregate
        // resolver once they all complete — replacement files participate in the
        // same sum_match check as fresh sends.
        const replacement_pipeline_promises = [];
        const replacement_b64_by_content_id = new Map();
        if (classification_api_url && file_manager?.callbacks?.get_download_url) {
            for (const item of new_items) {
                if (!item.file)
                    continue;
                const url = file_manager.callbacks.get_download_url(item.file.file_id, 'public');
                if (!url)
                    continue;
                try {
                    const response = await fetch(url);
                    const blob = await response.blob();
                    const b64 = await file_to_base64(new File([blob], item.file.file_name, { type: item.file.mime_type }));
                    replacement_b64_by_content_id.set(item.content_id, b64);
                    const p = pipeline.process_file(item, b64).then(result => {
                        if (result.doc_check) {
                            on_message_add?.(found_task.task_id, {
                                message_id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
                                role: 'system', type: 'doc_check', visibility: 'all',
                                doc_check: result.doc_check, time: new Date().toISOString(),
                            });
                        }
                        if (result.ai_result) {
                            on_message_add?.(found_task.task_id, {
                                message_id: `msg_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
                                role: 'system', type: 'ai_analysis', visibility: 'agent_only',
                                ai_analysis: { items: [result.ai_result] }, time: new Date().toISOString(),
                            });
                        }
                        if (result.classification || result.validation_results.length > 0) {
                            setTimeout(() => {
                                handle_update_content_item(item.content_id, {
                                    classification_result: result.classification || undefined,
                                    validation_rule_results: result.validation_results,
                                });
                            }, 100);
                        }
                        return { content_id: item.content_id, classification: result.classification };
                    }).catch(err => {
                        console.error('[ThreadForm] Pipeline error for replacement:', item.file?.file_name, err);
                        return { content_id: item.content_id, classification: null };
                    });
                    replacement_pipeline_promises.push(p);
                }
                catch (err) {
                    console.error('[ThreadForm] Failed to fetch replacement file for pipeline:', item.file.file_name, err);
                }
            }
        }
        // Once replacement classifications complete, re-run the aggregate
        // resolver so the panel reflects the new file set.
        if (replacement_pipeline_promises.length > 0) {
            (async () => {
                const fresh_results = await Promise.all(replacement_pipeline_promises);
                const fresh_cls_map = new Map();
                for (const r of fresh_results)
                    fresh_cls_map.set(r.content_id, r.classification);
                await run_resolution(found_task.task_id, {
                    fresh_cls_map,
                    current_send_b64_by_content_id: replacement_b64_by_content_id,
                });
            })();
        }
    }, [current_user, on_message_edit, on_message_add, file_manager, classification_api_url, pipeline, file_to_base64, format_file_size, handle_update_content_item, on_log, run_resolution]);
    /** Agent approves a client-completed task */
    const handle_approve_task = useCallback((task_id) => {
        const updated = {
            ...data,
            updated_at: new Date().toISOString(),
            tasks: data.tasks.map(t => t.task_id === task_id ? { ...t, agent_review: 'approved' } : t),
        };
        on_form_change?.(updated);
        const task_title = data.tasks.find(t => t.task_id === task_id)?.title || task_id;
        on_log?.(`${current_user.name} (${current_user.id}): Approved task "${task_title}"`);
    }, [data, on_form_change, current_user, on_log]);
    /** Agent renames a task title */
    const handle_rename_task = useCallback((task_id, title) => {
        const task = data.tasks.find(t => t.task_id === task_id);
        if (!task || task.title === title)
            return;
        const updated = { ...task, title };
        on_task_update?.(updated);
        on_log?.(`${current_user.name} (${current_user.id}): Renamed task → "${title}"`);
    }, [data.tasks, on_task_update, current_user, on_log]);
    /** Agent reopens a client-completed task */
    const handle_reopen_task = useCallback((task_id) => {
        const updated = {
            ...data,
            updated_at: new Date().toISOString(),
            tasks: data.tasks.map(t => t.task_id === task_id
                ? { ...t, status: 'in_progress', agent_review: 'reopened', reopened_by: current_user.id, completed_at: undefined, completed_by: undefined }
                : t),
        };
        on_form_change?.(updated);
        const task_title = data.tasks.find(t => t.task_id === task_id)?.title || task_id;
        on_log?.(`${current_user.name} (${current_user.id}): Reopened task "${task_title}"`);
    }, [data, current_user, on_form_change, on_log]);
    const handle_selection_change = useCallback((selections) => {
        // Build a map of all file ContentItems so we can walk replacement chains.
        // Toggling Keep on either end of a chain (replaced or replacement) keeps
        // both ends in sync — they represent the same logical document.
        const items_by_id = new Map();
        for (const task of data.tasks) {
            for (const msg of task.thread) {
                for (const ci of msg.content_items ?? []) {
                    if (ci.type === 'file')
                        items_by_id.set(ci.content_id, ci);
                }
            }
        }
        const collect_chain = (start_id) => {
            const seen = new Set();
            const queue = [start_id];
            while (queue.length > 0) {
                const id = queue.shift();
                if (seen.has(id))
                    continue;
                seen.add(id);
                const it = items_by_id.get(id);
                if (!it)
                    continue;
                if (it.replaces_content_id && !seen.has(it.replaces_content_id))
                    queue.push(it.replaces_content_id);
                if (it.replaced_by_content_id && !seen.has(it.replaced_by_content_id))
                    queue.push(it.replaced_by_content_id);
            }
            return Array.from(seen);
        };
        const expanded = {};
        for (const [id, value] of Object.entries(selections)) {
            for (const chain_id of collect_chain(id)) {
                expanded[chain_id] = value;
            }
        }
        on_selection_change?.(expanded);
        const updated = {
            ...data,
            collected_data_selections: { ...data.collected_data_selections, ...expanded },
        };
        on_form_change?.(updated);
    }, [data, on_form_change, on_selection_change]);
    // Build effective Keep state for every file across all tasks. Default selection
    // mirrors CollectedDataView: replaced originals default to unchecked; everything
    // else defaults to checked. Explicit `data.collected_data_selections` overrides
    // the default. Used for the file_bar checkbox and the Collected Data tab count.
    const keep_selections_effective = useMemo(() => {
        const explicit = data.collected_data_selections ?? {};
        const out = {};
        for (const task of data.tasks) {
            for (const msg of task.thread) {
                for (const ci of msg.content_items ?? []) {
                    if (ci.type !== 'file')
                        continue;
                    const default_selected = !ci.replaced_by_content_id;
                    out[ci.content_id] = explicit[ci.content_id] ?? default_selected;
                }
            }
        }
        return out;
    }, [data]);
    const kept_file_count = useMemo(() => Object.values(keep_selections_effective).filter(Boolean).length, [keep_selections_effective]);
    // ── Render ──
    const tasks_to_show = active_tab === 'review' ? review_tasks : data.tasks;
    // Client view gets a subtle orange wash so it's instantly distinguishable
    // from the agent (back-office) view at a glance — same content, different
    // role context.
    const is_client = role === 'client';
    return (_jsxs("div", { className: cn('flex flex-col h-full', is_client ? 'bg-[#FFF7ED]' : 'bg-[#F8FAFC]'), children: [is_client && (_jsx("div", { className: "h-0.5 bg-[#F97316] flex-shrink-0" })), _jsxs("div", { className: cn('px-5 py-3 border-b', is_client ? 'border-[#FED7AA] bg-[#FFFBEB]' : 'border-[#E2E8F0] bg-white'), children: [_jsxs("div", { className: "flex items-center justify-between", children: [_jsxs("div", { className: "flex items-center gap-2", children: [is_client && (_jsx("span", { className: "inline-flex items-center px-1.5 py-0.5 rounded text-[10px] font-semibold tracking-wide uppercase text-[#9A3412] bg-[#FED7AA]", title: "You are viewing this form as the client", children: "Client view" })), _jsxs("div", { children: [_jsx("h2", { className: "font-semibold text-[#0F172A]", children: data.title }), _jsxs("div", { className: "flex items-center gap-3 text-xs text-[#94A3B8] mt-0.5", children: [_jsx("span", { children: data.client.name }), _jsx("span", { children: "\u00B7" }), _jsx("span", { children: data.agents.map(a => a.name).join(', ') })] })] })] }), _jsxs("div", { className: "flex items-center gap-2", children: [role === 'agent' && (() => {
                                        const has_pending = pending_backoffice_items.length > 0 && !backoffice_running;
                                        return (_jsxs("button", { type: "button", onClick: handle_run_backoffice, disabled: backoffice_running || pending_backoffice_items.length === 0, className: cn('text-xs font-medium px-3 py-1.5 rounded cursor-pointer flex items-center gap-1.5 disabled:cursor-not-allowed', has_pending
                                                ? 'text-[#92400E] border border-[#F59E0B] bg-[#FEF3C7] hover:bg-[#FDE68A]'
                                                : 'text-[#334155] border border-[#CBD5E1] bg-white hover:bg-[#F8FAFC] disabled:text-[#CBD5E1] disabled:bg-[#F8FAFC]'), title: pending_backoffice_items.length === 0
                                                ? 'All files have had backoffice validation run'
                                                : `Run backoffice validation on ${pending_backoffice_items.length} file(s)`, children: [has_pending && (_jsxs("span", { className: "relative flex h-2 w-2 flex-shrink-0", children: [_jsx("span", { className: "absolute inline-flex h-full w-full rounded-full bg-[#F59E0B] opacity-75 animate-ping" }), _jsx("span", { className: "relative inline-flex h-2 w-2 rounded-full bg-[#F59E0B]" })] })), backoffice_running && (_jsxs("svg", { className: "w-3 h-3 animate-spin", xmlns: "http://www.w3.org/2000/svg", fill: "none", viewBox: "0 0 24 24", children: [_jsx("circle", { className: "opacity-25", cx: "12", cy: "12", r: "10", stroke: "currentColor", strokeWidth: "4" }), _jsx("path", { className: "opacity-75", fill: "currentColor", d: "M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" })] })), backoffice_running ? 'Running…' : `Run Backoffice${pending_backoffice_items.length > 0 ? ` (${pending_backoffice_items.length})` : ''}`] }));
                                    })(), role === 'agent' && show_json_viewer && (_jsx("button", { onClick: () => set_show_json(!show_json), className: "text-xs text-[#94A3B8] hover:text-[#64748B] cursor-pointer", children: show_json ? 'Hide JSON' : 'JSON' })), role === 'agent' && data.status !== 'completed' && on_send_form && (_jsx("button", { onClick: on_send_form, className: "text-xs font-medium text-white bg-[#2563EB] px-3 py-1.5 rounded hover:bg-[#1D4ED8] cursor-pointer", children: data.status === 'draft' ? 'Send to Client' : 'Resend Link' })), role === 'agent' && data.status !== 'completed' && data.status !== 'draft' && (_jsx("button", { onClick: on_close_form, className: "text-xs text-[#64748B] border border-[#E2E8F0] rounded px-3 py-1 hover:bg-[#F1F5F9] cursor-pointer", children: "Close Form" }))] })] }), role === 'agent' && (_jsx("div", { className: "flex gap-4 mt-3 border-b border-[#E2E8F0] -mx-5 px-5", children: ['tasks', 'review', 'collected'].map(tab => (_jsx("button", { onClick: () => set_active_tab(tab), className: cn('text-[13px] pb-2 transition-colors cursor-pointer', active_tab === tab
                                ? 'text-[#0F172A] font-medium border-b-2 border-[#2563EB]'
                                : 'text-[#94A3B8] hover:text-[#64748B]'), children: tab === 'tasks' ? 'Tasks' : tab === 'review' ? `Review${pending_count > 0 ? ` (${pending_count})` : ''}` : `Collected Data${kept_file_count > 0 ? ` (${kept_file_count})` : ''}` }, tab))) }))] }), _jsxs("div", { className: "flex-1 flex overflow-hidden", children: [_jsxs("div", { className: "flex-1 overflow-y-auto px-5 py-2", children: [(active_tab === 'tasks' || active_tab === 'review') && (_jsxs("div", { children: [_jsx(KeyInfoDrawer, { ref: key_info_drawer_ref, sections: key_info_sections, form_data: key_info_form_data ?? {}, on_form_data_change: on_key_info_form_data_change, field_metadata: field_metadata, on_metadata_change: on_metadata_change, show_confirm_action: show_confirm_action, title: key_info_title, default_expanded: key_info_default_expanded, field_action_components: field_action_components, enable_field_audit: enable_field_audit, audit_debounce_ms: audit_debounce_ms, field_columns: key_info_field_columns, highlight_empty_required: key_info_highlight_empty_required }), tasks_to_show.length === 0 && active_tab === 'review' && (_jsx("p", { className: "text-[13px] text-[#94A3B8] text-center py-8", children: "Nothing to review" })), tasks_to_show.map(task => (_jsx(TaskCard, { task: task, role: role, question_number: question_numbers.get(task.task_id), parent_ref: get_parent_ref(task), on_navigate_to_task: handle_navigate_to_task, user_name: current_user.name, user_id: current_user.id, expanded: active_tab === 'review' || expanded_tasks.has(task.task_id), on_toggle: () => set_expanded_tasks(prev => {
                                            const next = new Set(prev);
                                            if (next.has(task.task_id))
                                                next.delete(task.task_id);
                                            else
                                                next.add(task.task_id);
                                            return next;
                                        }), on_toggle_complete: handle_toggle_complete, on_delete_task: handle_delete_task, on_approve_task: handle_approve_task, on_reopen_task: handle_reopen_task, on_rename_task: handle_rename_task, on_send: handle_send, on_send_comment: handle_send_comment, on_add_question: handle_add_question, on_view_file: handle_view_file, on_remove_file: handle_remove_file, on_accept: handle_accept, on_edit_message: handle_edit_message, on_delete_message: handle_delete_message, upload_file: handle_upload_file, on_dismiss: handle_dismiss, on_send_to_client: handle_send_to_client, on_send_back_issues: handle_send_back_issues, on_submit_decisions: handle_submit_decisions, on_update_content_item: handle_update_content_item, file_statuses: pipeline.file_statuses, classification_results: pipeline.classification_results, validation_results: pipeline.validation_results, file_manager: file_manager?.callbacks, on_replacement_files: handle_replacement_files, view_button_variant: view_button_variant, show_info_icon: show_info_icon, sent_back_resolved_ids: sent_back_resolved_ids, resolving: resolution_running.has(task.task_id), available_document_types: available_document_types, on_change_document_type: handle_change_document_type, keep_selections: keep_selections_effective, on_keep_change: handle_selection_change, on_reply_to_issue: handle_reply_to_issue }, task.task_id))), active_tab === 'review' && review_tasks.length > 0 && (_jsxs("div", { className: "flex items-center gap-3 pt-4", children: [_jsx("button", { className: "text-xs text-[#2563EB] hover:underline cursor-pointer", children: "Accept all passed" }), _jsx("button", { className: "text-xs text-[#2563EB] hover:underline cursor-pointer", children: "Send all issues" })] }))] })), active_tab === 'collected' && (_jsx(CollectedDataView, { data: data, classification_results: pipeline.classification_results, on_selection_change: handle_selection_change, on_export: on_export_data, on_view_file: handle_view_file, show_extracted_fields: show_extracted_fields, render_item_details: render_collected_item_details, document_types: available_document_types, locale: collected_data_locale, currency_code: collected_data_currency_code, truncate_chars: collected_data_truncate_chars, role: role, on_request_reextraction: on_request_reextraction }))] }), viewed_item && (_jsx(ThreadPdfSidePanel, { item: viewed_item, file_url: viewed_file_url, PdfViewer: pdf_viewer || null, on_close: handle_close_pdf }))] }), show_json && (_jsxs("div", { className: "fixed right-0 top-0 bottom-0 w-[500px] bg-white border-l shadow-lg z-50 flex flex-col", children: [_jsxs("div", { className: "flex items-center justify-between px-4 py-3 border-b", children: [_jsx("span", { className: "text-sm font-medium", children: "JSON" }), _jsx("button", { onClick: () => set_show_json(false), className: "text-gray-400 hover:text-gray-600 cursor-pointer", children: "\u2715" })] }), _jsx("pre", { className: "flex-1 overflow-auto p-4 text-xs font-mono bg-gray-50", children: JSON.stringify(data, null, 2) })] })), _jsx(AddQuestionDialog, { open: show_add_question, on_close: () => set_show_add_question(false), on_add: handle_add_task, agent_id: current_user.id }), _jsx(AgentComposeDialog, { open: !!compose_context, context: compose_context || undefined, on_close: () => set_compose_context(null), on_send: handle_compose_send, agent_name: current_user.name })] }));
});
//# sourceMappingURL=thread_form.js.map