// The deal room (0079).
//
// ONE ROOM PER NEGOTIATION, THREE ROLES IN IT. The requester who owns the
// deal and the two Legal roles work the supplier's markup together: the
// changed paragraphs, a conversation anchored to them, and an analysis
// window beside them. The auditor holds no tab here — the record they read
// is the same tables, through the record. A paragraph can be opened on the
// CLAUSE WORKBENCH — the pair (as it stood / as they propose) with the
// advice on the record gathered around it.
//
// WHAT THE SCREEN DOES NOT DO, the negotiation surfaces' three rules:
//
//   · It never checks a permission. Who may talk in which room is the
//     schema's sentence; this offers the act and shows the refusal in the
//     database's own words.
//   · It never fetches broadly and filters for permission. Every read here
//     takes no parameter; showing one room filters what the rule ALREADY
//     returned.
//   · Advice is labelled advice, everywhere it appears. The analysis window
//     says on its face that it moved nothing and gates nothing, and an
//     absent analysis renders its recorded reason, never a blank.
//
// LIVE-NESS: POLLING FOR THE ROOM'S RECORDS, THE EDITOR'S CHANNEL FOR THE
// DOCUMENT (S292/S293, Phase 4). The room refreshes every twenty seconds
// through the API endpoint list. For live co-editing, the self-hosted
// OnlyOffice Document Server manages document synchronization over its own
// appliance channel while custody stays in our append-only save record.

const { useState, useEffect, useRef } = React;

// How recent a recorded visit is before the screen stops calling the person
// "here". Presentation, not a control: the record keeps every visit.
const HERE_MINUTES = 10;
const POLL_MS = 20000;

// ── Reading the room ──────────────────────────────────────────────────────
// Fetched once here and handed down, the useNegotiationRecord shape. Nine
// calls rather than one wide one: each is its own rule.
function useDealRoomRecord() {
  const negotiations = usePane(() => API.negotiations());
  const rounds       = usePane(() => API.negotiationRounds());
  const deals        = usePane(() => API.deals());
  const analysis     = usePane(() => API.roundAnalysis());
  const comments     = usePane(() => API.dealRoomComments());
  const analyses     = usePane(() => API.dealRoomAnalyses());
  const visits       = usePane(() => API.dealRoomVisits());
  const people       = usePane(() => API.people());
  // The authored playbook beside the room (0081), the same read the
  // negotiate desk makes — and DELIBERATELY kept out of the loading and
  // failure aggregation below: these moves are advice, they gate nothing,
  // and a refusal to show advice must not take the room down with it.
  const positionMoves = usePane(() => API.positionMoves());
  // The AI-6 register — what a model proposed sending back and what a named
  // lawyer made of it. Advice like the playbook above: kept out of the
  // aggregation, because a refusal to show advice must not take the room
  // down with it.
  const drafts = usePane(() => API.negotiationDrafts());
  // The declared compliance concerns and the record of every check (0120).
  // Advice for the same reason, and out of the aggregation for the same
  // reason.
  const concerns = usePane(() => API.complianceConcerns());
  const complianceChecks = usePane(() => API.complianceAssessments());
  // The editable working copy and its save history (0130). OUT OF THE
  // AGGREGATION for the advice panes' reason turned around: this is not
  // advice, but a room whose conversation is fine and whose working copy
  // cannot be read is still a usable room, and taking the whole room down
  // over it would lose the record people came for.
  const workingDocuments = usePane(() => API.workingDocuments());
  const workingSaves = usePane(() => API.workingDocumentSaves());
  // The signature ceremony (0040, 0137). OUT OF THE AGGREGATION for the
  // working copy's reason: a room whose conversation is fine and whose
  // envelope strip cannot be read is still a usable room, and taking the
  // whole room down over it would lose the record people came for.
  const envelopes = usePane(() => API.envelopes());
  const envelopeRecipients = usePane(() => API.envelopeRecipients());

  const panes = [negotiations, rounds, deals, analysis, comments, analyses,
                 visits, people];
  // 'loading' means the FIRST load only. A reload flips its pane back to
  // 'loading', and the conversation reloads every POLL_MS — read naively,
  // every poll would blank the pane to <Loading/>, unmounting the open Room;
  // the Room re-enters on mount, reloads the visits, and the pane flashes in
  // a visit-recording loop. Once everything has arrived, later reloads
  // refresh in place on the rows each pane keeps while it refetches.
  const everLoaded = useRef(false);
  if (panes.every((p) => p.status !== 'loading')) everLoaded.current = true;
  return {
    negotiations, rounds, deals, analysis, comments, analyses, visits, people,
    positionMoves, drafts, concerns, complianceChecks,
    workingDocuments, workingSaves, envelopes, envelopeRecipients,
    reloadWorkingDocument: () => [workingDocuments, workingSaves, rounds]
      .forEach((p) => p.reload()),
    reloadSignatures: () => [envelopes, envelopeRecipients]
      .forEach((p) => p.reload()),
    loading: !everLoaded.current,
    failed: panes.find((p) => p.status === 'failed') || null,
    reloadAll: () => panes.forEach((p) => p.reload()),
    // The conversation moves faster than the record around it, so only the
    // living surfaces are polled; the rounds and the deal list reload when
    // an act reloads everything.
    reloadConversation: () => [comments, analyses, visits]
      .forEach((p) => p.reload()),
  };
}

// ── Who is here ───────────────────────────────────────────────────────────
// Derived on every render from recorded visits: a name is "here" while its
// newest visit is younger than HERE_MINUTES. Nothing stores a live state.
function presenceOf(visits, negotiationId) {
  const newest = new Map();
  for (const v of visits) {
    if (v.negotiation_id !== negotiationId) continue;
    const at = new Date(v.entered_at).getTime();
    if (!newest.has(v.person) || at > newest.get(v.person)) {
      newest.set(v.person, at);
    }
  }
  const cutoff = Date.now() - HERE_MINUTES * 60000;
  const here = [], earlier = [];
  for (const [person, at] of newest) {
    (at >= cutoff ? here : earlier).push({ person, at });
  }
  here.sort((a, b) => b.at - a.at);
  earlier.sort((a, b) => b.at - a.at);
  return { here, earlier };
}

function WhoIsHere({ visits, negotiationId }) {
  const { here, earlier } = presenceOf(visits, negotiationId);
  return (
    <div className="flex items-center gap-2 flex-wrap">
      {here.length === 0
        ? <span className="caption">nobody else has opened this room in the
            last {HERE_MINUTES} minutes</span>
        : here.map((p) => (
            <span className="chip chip-ok" key={p.person}
                  title={`opened the room ${since(new Date(p.at).toISOString())} ago`}>
              {p.person}
            </span>
          ))}
      {earlier.length > 0 && (
        <span className="caption">
          earlier: {earlier.slice(0, 6).map((p) => p.person).join(', ')}
        </span>
      )}
    </div>
  );
}

// ── One comment, with its replies ─────────────────────────────────────────
// A margin bubble beside the document — the working shape Mike asked for
// (S286): who spoke, on which words, what they said, and exactly one way to
// respond. Display names, because colleagues talk to people, not addresses;
// the address stays in the tooltip, where the record can still be checked.
function Comment({ comment, replies, people, onError, onChanged, depth }) {
  const [replying, setReplying] = useState(false);
  const [busy, setBusy] = useState(false);

  const name = (person) =>
    (people.find((p) => p.person === person) || {}).display_name || person;

  const act = async (call) => {
    setBusy(true); onError(null);
    const r = await call();
    setBusy(false);
    if (!r.ok) { onError(r.reason); return; }
    setReplying(false);
    onChanged();
  };

  return (
    <div className={depth > 0 ? 'mt-2 pl-3 border-l hair' : 'panel p-2.5 mb-2'}
         data-testid="deal-comment">
      <div className="flex items-baseline justify-between gap-2">
        <div className="caption font-mono truncate" title={comment.author}>
          {name(comment.author)} · {since(comment.commented_at)}
        </div>
        {depth === 0 && (
          comment.state === 'resolved'
            // chip-std, not chip-gone: struck-through is the superseded
            // library's ink, and a resolved thread is settled, not gone.
            ? <span className="flex items-center gap-1.5">
                <span className="chip chip-std"
                      title={`resolved by ${comment.resolved_by}`}>resolved</span>
                <ActButton className="btn btn-sm" disabled={busy}
                        data-testid="reopen-comment"
                        onClick={() => act(() => API.reopenDealComment({
                          comment_id: comment.comment_id }))}>
                  reopen
                </ActButton>
              </span>
            : <ActButton className="btn btn-sm" disabled={busy}
                      data-testid="resolve-comment"
                      onClick={() => act(() => API.resolveDealComment({
                        comment_id: comment.comment_id }))}>
                resolve
              </ActButton>
        )}
      </div>

      {comment.to_person && (
        <div className="caption" style={{ color: 'var(--accent)' }}
             title={comment.to_person}>
          for {name(comment.to_person)}
        </div>
      )}

      {comment.quoted_text && (
        // The words being talked about, set as a quotation — accent rule and
        // italics, never a box that reads as somewhere to type.
        <div className="mt-1.5 pl-2 font-serif italic"
             style={{ fontSize: 13, color: 'var(--mute)',
                      borderLeft: '2px solid var(--accent)' }}>
          “{comment.quoted_text}”
        </div>
      )}

      <div className="text-[13px] mt-1.5" style={{ color: 'var(--ink)' }}>
        {comment.note}
      </div>

      {replies.map((r) => (
        <Comment key={r.comment_id} comment={r} replies={[]} people={people}
                 onError={onError} onChanged={onChanged} depth={depth + 1} />
      ))}

      {depth === 0 && !(comment.state === 'resolved') && (
        replying
          ? <CommentForm compact people={people} onError={onError}
              onPosted={() => { setReplying(false); onChanged(); }}
              onCancel={() => setReplying(false)}
              fixed={{
                negotiation_id: comment.negotiation_id,
                round_no: comment.round_no,
                paragraph_index: comment.paragraph_index,
                parent_comment_id: comment.comment_id,
              }} />
          : <ActButton className="btn btn-sm mt-2"
                    onClick={() => setReplying(true)}>reply</ActButton>
      )}
    </div>
  );
}

// ── Saying something ──────────────────────────────────────────────────────
// `fixed` carries the anchor — the negotiation, optionally the round, the
// paragraph, the quoted words, the parent. The person typing chooses only
// their words and, optionally, a colleague to address. The compact shape
// stacks inside a margin bubble; the full shape is the room thread's one
// composer. Both post the same act.
function CommentForm({ people, fixed, quoted, compact, onError, onPosted,
                       onCancel }) {
  const [note, setNote] = useState('');
  const [toPerson, setToPerson] = useState('');
  const [busy, setBusy] = useState(false);

  const post = async () => {
    setBusy(true); onError(null);
    const r = await API.postDealComment({
      ...fixed,
      quoted_text: quoted || fixed.quoted_text || null,
      to_person: toPerson.trim() || null,
      note: note.trim(),
    });
    setBusy(false);
    if (!r.ok) { onError(r.reason); return; }
    setNote(''); setToPerson('');
    onPosted();
  };

  const quotation = quoted && (
    <div className="mb-2 pl-2 font-serif italic"
         style={{ fontSize: 13, color: 'var(--mute)',
                  borderLeft: '2px solid var(--accent)' }}>
      “{quoted}”
    </div>
  );

  const addressedNote = toPerson.trim() !== '' && (
    <div className="caption mt-1">
      An addressed comment lands on that person's waiting list and in
      their daily digest — the same panel everything else that waits on
      them arrives on.
    </div>
  );

  if (compact) {
    return (
      <div className="mt-2">
        {quotation}
        <input className="w-full" style={{ padding: '5px 8px' }} autoFocus
               aria-label="Your comment"
               placeholder="your words" data-testid="comment-note"
               value={note} onChange={(e) => setNote(e.target.value)}
               onKeyDown={(e) => {
                 if (e.key === 'Enter' && note.trim() && !busy) post();
               }} />
        <input className="w-full font-mono mt-1.5" style={{ padding: '5px 8px' }}
               aria-label="Address this comment to a colleague (optional)"
               list="deal-room-people" placeholder="for colleague@… (optional)"
               value={toPerson} onChange={(e) => setToPerson(e.target.value)} />
        <div className="flex gap-1.5 mt-1.5">
          <ActButton className="btn btn-sm btn-primary" data-testid="post-comment"
                  disabled={busy || !note.trim()} onClick={post}>✓ say it</ActButton>
          {onCancel && (
            <button className="btn btn-sm" onClick={onCancel}>cancel</button>
          )}
        </div>
        <datalist id="deal-room-people">
          {people.map((p) => <option key={p.person} value={p.person} />)}
        </datalist>
        {addressedNote}
      </div>
    );
  }

  return (
    <div className="mt-2">
      {quotation}
      <div className="flex gap-2 items-end flex-wrap">
        <div className="flex-1" style={{ minWidth: 220 }}>
          <label className="caption">Your words</label>
          <input aria-label="Your words" className="mt-1 w-full" style={{ padding: '5px 8px' }}
                 data-testid="comment-note"
                 value={note} onChange={(e) => setNote(e.target.value)} />
        </div>
        <div style={{ width: 200 }}>
          <label className="caption">Address to (optional)</label>
          <input aria-label="Address to (optional)" className="mt-1 w-full font-mono" style={{ padding: '5px 8px' }}
                 list="deal-room-people" placeholder="colleague@…"
                 value={toPerson} onChange={(e) => setToPerson(e.target.value)} />
        </div>
        <ActButton className="btn btn-primary" disabled={busy || !note.trim()}
                style={{ whiteSpace: 'nowrap' }}
                data-testid="post-comment"
                onClick={post}>✓ say it</ActButton>
      </div>
      <datalist id="deal-room-people">
        {people.map((p) => <option key={p.person} value={p.person} />)}
      </datalist>
      {addressedNote}
    </div>
  );
}

// ── The internal chat ─────────────────────────────────────────────────────
// The room-level channel, worn as what it is: a conversation. Mine on the
// right, colleagues on the left, newest at the bottom, one input. Every
// message is still a recorded deal comment — same table, same policies,
// same waiting list when addressed — only the clothes changed.
function ChatPanel({ me, negotiation, comments, people, onError, onChanged }) {
  const [note, setNote] = useState('');
  const [toPerson, setToPerson] = useState('');
  const [busy, setBusy] = useState(false);
  const logRef = useRef(null);

  const name = (person) =>
    (people.find((p) => p.person === person) || {}).display_name || person;

  const messages = comments
    .filter((c) => (c.round_no === null || c.round_no === undefined)
      && c.negotiation_id === negotiation.negotiation_id)
    .sort((a, b) => a.comment_id - b.comment_id);
  const noteOf = (id) => {
    const parent = messages.find((m) => m.comment_id === id);
    return parent ? parent.note : null;
  };

  useEffect(() => {
    if (logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight;
  }, [messages.length]);

  const send = async () => {
    if (!note.trim() || busy) return;
    setBusy(true); onError(null);
    const r = await API.postDealComment({
      negotiation_id: negotiation.negotiation_id,
      to_person: toPerson.trim() || null,
      note: note.trim(),
    });
    setBusy(false);
    if (!r.ok) { onError(r.reason); return; }
    setNote(''); setToPerson('');
    onChanged();
  };

  return (
    <div className="panel p-4" data-testid="deal-chat">
      <PanelHead
        title="The room"
        sub="Talk that spans paragraphs. Every word here is on the deal's record — address someone and it reaches their waiting list too." />
      <div ref={logRef} style={{ maxHeight: 380, overflowY: 'auto' }}>
        {messages.length === 0 && (
          <div className="caption">Nothing has been said about this deal as a whole.</div>
        )}
        {messages.map((m) => {
          const mine = m.author === me.person;
          const quoted = m.parent_comment_id && noteOf(m.parent_comment_id);
          return (
            <div key={m.comment_id} className="mb-2.5"
                 style={{ textAlign: mine ? 'right' : 'left' }}>
              <div className="caption font-mono" title={m.author}>
                {name(m.author)} · {since(m.commented_at)}
                {m.to_person && (
                  <span className="ml-1.5" style={{ color: 'var(--accent)' }}
                        title={m.to_person}>for {name(m.to_person)}</span>
                )}
              </div>
              <div className="inline-block p-2 mt-0.5 text-[13px]"
                   style={{
                     maxWidth: '85%', textAlign: 'left',
                     background: mine
                       ? 'color-mix(in oklch, var(--ok), transparent 90%)'
                       : 'var(--sheet-2)',
                     border: '1px solid var(--line)', borderRadius: 6,
                   }}>
                {quoted && (
                  <div className="caption pl-2 mb-1"
                       style={{ borderLeft: '2px solid var(--line-2)' }}>
                    {quoted.length > 90 ? `${quoted.slice(0, 90)}…` : quoted}
                  </div>
                )}
                {m.note}
              </div>
            </div>
          );
        })}
      </div>
      <div className="flex gap-1.5 mt-3">
        <input className="flex-1" style={{ padding: '6px 9px' }}
               aria-label="Say something to the room"
               placeholder="say something to the room"
               data-testid="comment-note"
               value={note} onChange={(e) => setNote(e.target.value)}
               onKeyDown={(e) => { if (e.key === 'Enter') send(); }} />
        <input className="font-mono" style={{ padding: '6px 9px', width: 130 }}
               aria-label="Address this comment to a colleague (optional)"
               list="deal-room-people" placeholder="for…"
               value={toPerson} onChange={(e) => setToPerson(e.target.value)} />
        <ActButton className="btn btn-primary" style={{ whiteSpace: 'nowrap' }}
                data-testid="post-comment"
                disabled={busy || !note.trim()} onClick={send}>send</ActButton>
      </div>
      <datalist id="deal-room-people">
        {people.map((p) => <option key={p.person} value={p.person} />)}
      </datalist>
    </div>
  );
}

// ── One changed paragraph, with its margin ────────────────────────────────
// The paragraph is 0052's record: what the supplier's change said, copied
// verbatim when it was analysed. The document sits on the sheet; the
// conversation hangs in the margin beside it, always visible — the
// working-document shape, not a message board. Selecting words in the
// proposed half raises the two acts on the exact words: comment on them,
// or send them to the analysis window.
function ParagraphCard({ row, comments, people, onError, onChanged,
                         onHighlight, onWorkbench }) {
  const [composing, setComposing] = useState(null); // { quoted } | {} | null
  const [popover, setPopover] = useState(null);     // { x, y, text } | null
  const sheetRef = useRef(null);

  const mine = (c) => c.round_no === row.round_no
    && c.paragraph_index === row.paragraph_index;
  const roots = comments.filter((c) => mine(c) && !c.parent_comment_id)
    .sort((a, b) => a.comment_id - b.comment_id);
  const repliesOf = (root) => comments
    .filter((c) => c.parent_comment_id === root.comment_id)
    .sort((a, b) => a.comment_id - b.comment_id);

  const fixed = { negotiation_id: row.negotiation_id, round_no: row.round_no,
                  paragraph_index: row.paragraph_index };

  const took = (e) => {
    const selection = String(window.getSelection() || '').trim();
    if (!selection) { setPopover(null); return; }
    const sheet = sheetRef.current.getBoundingClientRect();
    setPopover({
      x: Math.min(e.clientX - sheet.left, sheet.width - 170),
      y: e.clientY - sheet.top + 14,
      text: selection,
    });
  };
  const done = () => {
    setPopover(null);
    const s = window.getSelection();
    if (s) s.removeAllRanges();
  };

  return (
    <div className="grid gap-3 mb-4"
         style={{ gridTemplateColumns: 'minmax(0, 1fr) 250px' }}
         data-testid="deal-paragraph">
      <div className="panel-2 p-3 relative" ref={sheetRef}
           onMouseDown={() => setPopover(null)}>
        <div className="flex items-baseline justify-between gap-3">
          <span className="font-mono text-[12.5px]">
            paragraph {row.paragraph_index}
            {row.category_key && <span className="ml-2 caption">{row.category_key}</span>}
          </span>
          <span className="caption flex items-baseline gap-2">
            <span>
              {row.matched_position
                ? `touches position ${row.matched_position}`
                : 'matched no position'}
              {row.author && ` · their author: ${row.author}`}
            </span>
            <button className="btn btn-sm" data-testid="open-workbench"
                    aria-label={`Open paragraph ${row.paragraph_index} on the workbench`}
                    onClick={() => onWorkbench(row.paragraph_index)}>
              workbench
            </button>
          </span>
        </div>

        <div className="mt-2 grid gap-3" style={{ gridTemplateColumns: '1fr 1fr' }}>
          <div>
            <div className="caption">was</div>
            <div className="font-serif text-[13.5px] mt-1"
                 style={{ color: 'var(--mute)', lineHeight: 1.55 }}>
              {row.original_text}
            </div>
          </div>
          <div>
            <div className="caption">they propose · select words to act on them</div>
            <div className="font-serif text-[13.5px] mt-1"
                 data-testid="proposed-text"
                 style={{ color: 'var(--ink)', lineHeight: 1.55, cursor: 'text' }}
                 onMouseUp={took}>
              {row.proposed_text}
            </div>
          </div>
        </div>

        {popover && (
          <div className="panel p-1 flex gap-1 absolute z-10"
               style={{ left: popover.x, top: popover.y }}
               onMouseDown={(e) => e.stopPropagation()}>
            <button className="btn btn-sm"
                    onClick={() => { setComposing({ quoted: popover.text }); done(); }}>
              comment
            </button>
            <button className="btn btn-sm"
                    onClick={() => { onHighlight({ ...fixed, quoted_text: popover.text }); done(); }}>
              analyse selection
            </button>
          </div>
        )}
      </div>

      <div>
        {roots.map((c) => (
          <Comment key={c.comment_id} comment={c} replies={repliesOf(c)}
                   people={people} onError={onError} onChanged={onChanged}
                   depth={0} />
        ))}
        {composing
          ? <div className="panel p-2.5">
              <CommentForm compact people={people} fixed={fixed}
                           quoted={composing.quoted} onError={onError}
                           onPosted={() => { setComposing(null); onChanged(); }}
                           onCancel={() => setComposing(null)} />
            </div>
          : <button className="btn btn-sm w-full"
                    style={{ borderStyle: 'dashed', color: 'var(--mute)' }}
                    data-testid="paragraph-thread"
                    onClick={() => setComposing({})}>
              + note on this paragraph
            </button>}
      </div>
    </div>
  );
}

// ── The analysis window ───────────────────────────────────────────────────
// AI advice on the record, labelled as advice on its face. The highlighted
// words arrive from a selection in a paragraph; the reply is the model's
// reading of them AGAINST THE RELATED RECORD — the open positions, the
// concessions, and the other changed paragraphs of the same document, which
// is where a cross-paragraph trade becomes visible. An absent analysis
// renders its recorded reason; the related-record report stands either way.
function AnalysisWindow({ negotiation, analyses, highlight, onClearHighlight,
                          onError, onAnalysed }) {
  const [busy, setBusy] = useState(false);

  const mine = analyses
    .filter((a) => a.negotiation_id === negotiation.negotiation_id)
    .sort((a, b) => b.analysis_id - a.analysis_id);

  return (
    <div className="advice-card" data-testid="analysis-window">
      <div className="advice-label">Analysis (advisory)</div>
      <div className="font-serif italic mt-1 mb-3"
           style={{ fontSize: 13.5, color: 'var(--mute)' }}>
        Advice on the record, in a different pen. Nothing here moved a
        position, wrote contract wording, or gated anything — and nothing
        here can.
      </div>

      {highlight
        ? (
          <div className="panel-2 p-3 mb-3" data-testid="pending-highlight">
            <div className="caption">
              highlighted in paragraph {highlight.paragraph_index}, round {highlight.round_no}
            </div>
            <div className="font-serif italic mt-1"
                 style={{ fontSize: 14, borderLeft: '2px solid var(--accent)', paddingLeft: 8 }}>
              “{highlight.quoted_text}”
            </div>
            <div className="flex gap-2 mt-2">
              <ActButton className="btn btn-primary" disabled={busy}
                      data-testid="ask-analysis"
                      onClick={async () => {
                        setBusy(true); onError(null);
                        const r = await API.analyseHighlight({
                          ...highlight,
                        });
                        setBusy(false);
                        if (!r.ok) { onError(r.reason); return; }
                        onClearHighlight();
                        onAnalysed();
                      }}>
                {busy ? 'asking…' : '✓ analyse against the record'}
              </ActButton>
              <button className="btn btn-sm" onClick={onClearHighlight}>
                never mind
              </button>
            </div>
            <div className="caption mt-2">
              The model is shown the highlight, the paragraph, and the related
              parts of this record — the open positions, the concessions, and
              the other changed paragraphs — so a trade that spans paragraphs
              is on the table. What it answers is recorded verbatim.
            </div>
          </div>
        )
        : (
          <div className="caption mb-3">
            Select words in a proposed paragraph to run the fixed analysis.
          </div>
        )}

      {mine.length === 0
        ? <Empty kicker="analyses"
                 line="Nothing has been analysed in this room."
                 sub="Each result — or its honest absence — is kept and reused until its document or governed context changes." />
        : mine.map((a) => <AnalysisCard key={a.analysis_id} a={a} />)}
      <div className="advice-foot">Advisory only — does not gate the record.</div>
    </div>
  );
}

function AnalysisCard({ a }) {
  const [showing, setShowing] = useState(false);
  const related = Array.isArray(a.related) ? a.related : [];
  const positions = related.filter((r) => r.kind === 'position');
  const concessions = related.filter((r) => r.kind === 'concession');
  const siblings = related.filter((r) => r.kind === 'paragraph');

  return (
    <div className="panel-2 p-3 mb-2" data-testid="analysis">
      <div className="flex items-baseline justify-between gap-3">
        <span className="font-mono text-[12.5px]">
          round {a.round_no} · paragraph {a.paragraph_index}
        </span>
        <span className="caption font-mono">
          {a.analysed_by} · {since(a.analysed_at)}
        </span>
      </div>

      <div className="font-serif italic mt-2"
           style={{ fontSize: 13.5, color: 'var(--mute)', borderLeft: '2px solid var(--accent)', paddingLeft: 8 }}>
        “{a.quoted_text}”
      </div>
      {Array.isArray(a.changed_because) && a.changed_because.length > 0 && (
        <div className="caption mt-1">
          {a.changed_because.join(' · ')}
        </div>
      )}

      {a.outcome === 'answered'
        ? (
          <div className="mt-2">
            <div className="tag" style={{ color: 'var(--advice)' }}>
              AI advice · {a.model}{a.model_version ? ` ${a.model_version}` : ''}
            </div>
            <div className="text-[13px] mt-1"
                 style={{ lineHeight: 1.6, color: 'var(--advice)' }}>
              {a.assessment}
            </div>
          </div>
        )
        : (
          <div className="mt-2">
            {/* AN ABSENCE IS AN OUTCOME. The recorded reason renders, never a
                blank, and never an error state — the room worked. */}
            <div className="tag" style={{ color: 'var(--mute-2)' }}>
              no model opinion
            </div>
            <div className="caption mt-1">{a.absent_reason}</div>
          </div>
        )}

      <button className="btn btn-sm mt-2" onClick={() => setShowing(!showing)}>
        {showing ? 'hide' : `what was on the table (${related.length})`}
      </button>
      {showing && (
        <div className="caption mt-2" style={{ lineHeight: 1.7 }}>
          {positions.length > 0 && (
            <div>positions: {positions.map((p) =>
              `${p.category_key} (${p.state}${p.current_rung != null ? `, rung ${p.current_rung}` : ''})`)
              .join(' · ')}</div>
          )}
          {concessions.length > 0 && (
            <div>concessions: {concessions.map((c) =>
              `${c.category_key} (${c.state})`).join(' · ')}</div>
          )}
          {siblings.length > 0 && (
            <div>other changed paragraphs: {siblings.map((s) =>
              `${s.paragraph_index}${s.category_key ? ` (${s.category_key})` : ''}`)
              .join(' · ')}</div>
          )}
          {related.length === 0 && (
            <div>Nothing else in the record stood beside this paragraph.</div>
          )}
        </div>
      )}
    </div>
  );
}

// ── The working copy (0130) ───────────────────────────────────────────────
//
// THE ONE THING THE ROOM COULD NEVER DO. Every other surface here is a way to
// TALK about the supplier's paper; this is where the paper itself is changed.
// A copy opens from a round, each version is saved whole, and closing it is
// terminal — work continues by opening a new copy, never by reopening this
// one, because a closed copy's bytes may already be a fingerprinted round.
//
// WHAT IS NOT HERE YET, said plainly rather than left to be discovered: there
// is no editor in this panel. A person takes the round's file from the
// dossier, changes it in their own word processor and saves the result back
// here, and every version they save is kept with its author and its hash.
// The co-editing surface is the next phase; this is the record it will write
// to, with a way in that does not depend on it.
//
// IT DECIDES NOTHING, like everything else in this room. Every act below is
// offered to everybody and refused by the database in its own words — the
// room's rule 1, and the reason there is no role check anywhere in here.
function WorkingCopy({ negotiation, rounds, record, onError }) {
  const { busy, run } = useActs();
  const [seedRound, setSeedRound] = useState('');

  const mine = record.workingDocuments.rows
    .filter((w) => w.negotiation_id === negotiation.negotiation_id)
    .sort((a, b) => b.working_document_id - a.working_document_id);
  const live = mine.find((w) => !w.closed_at) || null;
  const saves = live
    ? record.workingSaves.rows
        .filter((s) => s.working_document_id === live.working_document_id)
        .sort((a, b) => b.save_no - a.save_no)
    : [];

  const after = (r) => {
    if (!r.ok) { onError(r.reason); return; }
    record.reloadWorkingDocument();
  };

  const received = rounds.filter((r) => r.direction === 'received');

  return (
    <div className="panel p-3 mt-4" data-testid="working-copy">
      <div className="section-label mb-2">Working copy</div>

      {/* THE PANE'S OWN FAILURE, SHOWN HERE AND NOT AS A BLANK. It is out of
          the room's aggregation on purpose, so a refusal has to say so
          somewhere or it says nothing at all. */}
      {record.workingDocuments.status === 'failed' && (
        <div className="caption">
          The working copies could not be read: {record.workingDocuments.reason}
        </div>
      )}

      {!live && (
        <div>
          <div className="caption">
            No copy of this contract is open. Opening one takes the round's
            file as its first version; every version saved after that is kept.
          </div>
          {received.length > 0 && (
            <select className="mt-2 w-full" data-testid="working-copy-seed"
                    aria-label="Which round to open the copy from"
                    value={seedRound}
                    onChange={(e) => setSeedRound(e.target.value)}>
              <option value="">from our own paper (empty)</option>
              {received.map((r) => (
                <option key={r.round_no} value={r.round_no}>
                  from round {r.round_no} — as they sent it
                </option>
              ))}
            </select>
          )}
          <ActButton className="btn btn-primary mt-2"
                     data-testid="open-working-copy"
                     disabled={!!busy}
                     onClick={() => run('open', async () => after(
                       await API.openWorkingDocument(
                         negotiation.negotiation_id,
                         seedRound === '' ? null : Number(seedRound))))}>
            open a working copy
          </ActButton>
        </div>
      )}

      {live && (
        <div>
          <div className="caption">
            Opened by {live.opened_by}
            {live.opened_from_round != null
              ? ` from round ${live.opened_from_round}`
              : ' from our own paper'}.
          </div>

          {/* THE HASH IS THE DATABASE'S ARITHMETIC over the stored bytes, and
              it is shown for the same reason the dossier shows a round's:
              it is what makes a version a thing you can point at later. */}
          {live.latest_save_no == null
            ? <div className="caption mt-2">
                Nothing has been saved into it yet.
              </div>
            : <div className="mt-2">
                <div className="font-mono text-[11.5px]" style={{ color: 'var(--ink)' }}>
                  version {live.latest_save_no} · {live.latest_byte_count} bytes
                </div>
                <div className="caption font-mono mt-0.5 truncate"
                     title={live.latest_sha256}>
                  {live.latest_saved_by} · {live.latest_sha256}
                </div>
              </div>}

          <div className="mt-3 pt-2 border-t hair">
            <div className="flex items-center gap-3 flex-wrap">
              <input type="file" data-testid="working-copy-save" disabled={!!busy}
                     aria-label="Save a new version of the working copy"
                     onChange={(e) => {
                       const file = e.target.files && e.target.files[0];
                       e.target.value = '';
                       if (file) run('save', async () => after(
                         await API.saveWorkingDocument(
                           live.working_document_id, file)));
                     }} />
              <span className="caption">
                Saved as the next version. Nothing is overwritten.
              </span>
            </div>
          </div>

          {saves.length > 0 && (
            <div className="mt-3 pt-2 border-t hair">
              <div className="section-label mb-1">Every version</div>
              {saves.map((s) => (
                <div className="py-1 border-b hair" key={s.save_no}
                     data-testid="working-copy-version">
                  <div className="font-mono text-[11.5px]" style={{ color: 'var(--ink)' }}>
                    version {s.save_no}
                    {s.redacted_on ? ' · content removed' : ` · ${s.byte_count} bytes`}
                  </div>
                  <div className="caption font-mono mt-0.5 truncate"
                       title={s.redacted_on ? s.sha256_as_received : s.sha256}>
                    {s.saved_by} · {s.redacted_on ? s.sha256_as_received : s.sha256}
                  </div>
                </div>
              ))}
            </div>
          )}

          <div className="mt-3 pt-2 border-t hair flex gap-2 flex-wrap">
            {/* Phase 4 (S292/S293): Issue the active working copy into the evidence chain as a new round */}
            {(live.latest_save_no != null || saves.length > 0) && (
              <ActButton className="btn btn-sm btn-primary" disabled={!!busy}
                         data-testid="issue-working-copy"
                         onClick={() => run('issue', async () => {
                           const r = await API.issueWorkingDocument({
                             negotiation_id: negotiation.negotiation_id
                           });
                           if (!r.ok) { onError(r.reason); return; }
                           record.reloadWorkingDocument();
                           record.reloadConversation();
                         })}>
                issue as round {(rounds.reduce((m, r) => Math.max(m, r.round_no), 0) || 0) + 1}
              </ActButton>
            )}
            {/* TWO REASONS, AND THEY ARE DIFFERENT FACTS. `issued` means this
                version went to the other side; `abandoned` means it did not.
                A single "close" button would make the record unable to tell
                them apart. */}
            <ActButton className="btn btn-sm" disabled={!!busy}
                       data-testid="close-working-copy-issued"
                       onClick={() => run('close', async () => after(
                         await API.closeWorkingDocument({
                           working_document_id: live.working_document_id,
                           closed_reason: 'issued' })))}>
              close — sent to them
            </ActButton>
            <ActButton className="btn btn-sm" disabled={!!busy}
                       data-testid="close-working-copy-abandoned"
                       onClick={() => run('close', async () => after(
                         await API.closeWorkingDocument({
                           working_document_id: live.working_document_id,
                           closed_reason: 'abandoned' })))}>
              close — abandoned
            </ActButton>
          </div>
        </div>
      )}

      {/* THE CLOSED ONES STAY VISIBLE. A negotiation works through several
          copies, and "what did we send them in March" is answered here. */}
      {mine.filter((w) => w.closed_at).length > 0 && (
        <div className="mt-3 pt-2 border-t hair">
          <div className="section-label mb-1">Closed copies</div>
          {mine.filter((w) => w.closed_at).map((w) => (
            <div className="py-1 border-b hair" key={w.working_document_id}
                 data-testid="closed-working-copy">
              <div className="font-mono text-[11.5px]" style={{ color: 'var(--ink)' }}>
                {w.closed_reason === 'issued' ? 'sent to them' : 'abandoned'}
                {w.latest_save_no != null ? ` · ${w.latest_save_no + 1} versions` : ''}
              </div>
              <div className="caption font-mono mt-0.5 truncate"
                   title={w.latest_sha256 || ''}>
                {w.opened_by}{w.latest_sha256 ? ` · ${w.latest_sha256}` : ''}
              </div>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

// ── OnlyOffice Document Server Co-Editor (Phase 4, S292/S293) ─────────────
//
// THE LIVE CO-EDITING SURFACE. Connects to the self-hosted OnlyOffice Document
// Server appliance via JWT-signed DocsAPI configuration. Document custody stays
// in our append-only save table cw.working_document_save.
function OnlyOfficeEditor({ negotiation, onError }) {
  const [config, setConfig] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const editorRef = useRef(null);

  useEffect(() => {
    let unmounted = false;
    API.onlyofficeConfig(negotiation.negotiation_id).then((res) => {
      if (unmounted) return;
      setLoading(false);
      if (!res || res.error || res.status >= 400 || res.ok === false) {
        const msg = res?.reason || res?.error || 'Could not load OnlyOffice editor configuration';
        setError(msg);
        if (onError) onError(msg);
        return;
      }
      setConfig(res);
      if (window.DocsAPI && window.DocsAPI.DocEditor) {
        try {
          editorRef.current = new window.DocsAPI.DocEditor('onlyoffice-editor-frame', res);
        } catch (e) {
          console.error('OnlyOffice initialization error:', e);
        }
      }
    }).catch((err) => {
      if (unmounted) return;
      setLoading(false);
      setError(String(err));
      if (onError) onError(String(err));
    });

    return () => {
      unmounted = true;
      if (editorRef.current && typeof editorRef.current.destroyEditor === 'function') {
        try {
          editorRef.current.destroyEditor();
        } catch (_) {}
      }
    };
  }, [negotiation.negotiation_id]);

  return (
    <div className="onlyoffice-container mt-2" data-testid="onlyoffice-editor-container">
      <div className="flex items-center justify-between p-2 mb-3 bg-slate-50 border rounded hair">
        <span className="caption font-mono text-[12px]">
          OnlyOffice Live Co-Editor · Document custody: Clausewerk
        </span>
        <span className="caption text-[12px]">
          {config?.document?.key ? `Key: ${config.document.key.slice(0, 10)}…` : 'Append-only version stack'}
        </span>
      </div>

      {loading && <div className="caption p-4 text-center">Loading editor configuration…</div>}

      {error && (
        <div className="panel-2 p-4 text-center">
          <div className="font-serif text-[14px] text-amber-800 mb-1">Editor not ready</div>
          <div className="caption">{error}</div>
        </div>
      )}

      {!loading && !error && (
        <div id="onlyoffice-editor-frame"
             data-testid="onlyoffice-editor-frame"
             style={{ width: '100%', height: '620px', border: '1px solid var(--edge)', borderRadius: '4px', background: 'var(--sheet)' }}>
          {!window.DocsAPI && (
            <div className="p-8 text-center flex flex-col items-center justify-center h-full"
                 style={{ minHeight: '400px' }}>
              <div className="font-serif text-[17px] mb-2 font-medium" style={{ color: 'var(--ink)' }}>
                Live Co-Editing Surface
              </div>
              <div className="caption max-w-md mb-4" style={{ lineHeight: 1.5 }}>
                OnlyOffice Document Server integration protocol is active. When the self-hosted OnlyOffice Document Server is running, the full live Word editor embeds here with real-time co-editing and tracked changes.
              </div>
              <div className="font-mono text-[11.5px] p-2 bg-white rounded border hair" style={{ color: 'var(--mute)' }}>
                Config: {config?.document?.title} · Document Key: {config?.document?.key}
              </div>
            </div>
          )}
        </div>
      )}
    </div>
  );
}

// ── The signature ceremony ────────────────────────────────────────────────
//
// THE ACTS THAT HAD NO SCREEN. The adapter seam, the endpoints and the
// unattended poll were all built and tested (0137, S441/S442), and until this
// panel there was no way for a person to start or stop a ceremony — the exact
// shape `a-built-thing-has-a-way-in.test.mjs` exists to name.
//
// WHAT IT DOES NOT DO, said here rather than discovered: it never reports the
// ceremony as finished. The envelope's state moves only when a provider event
// is recorded, through the definer trigger and no other way, so what this
// draws is always the record and never a guess about what the provider is
// doing. A completion arrives on the unattended tick, not from this screen.
function SignatureCeremony({ negotiation, record, onError }) {
  const { busy, run } = useActs();
  const [recipients, setRecipients] = useState([
    { name: '', email: '', party: 'theirs' },
  ]);
  const [reason, setReason] = useState('');
  const [voiding, setVoiding] = useState(null);

  const mine = record.envelopes.rows
    .filter((e) => e.agreement_id === negotiation.agreement_id)
    .sort((a, b) => b.envelope_id - a.envelope_id);
  const live = mine.find((e) => e.state === 'sent') || null;
  const past = mine.filter((e) => e.state !== 'sent');
  const recipientsOf = (envelopeId) => record.envelopeRecipients.rows
    .filter((r) => r.envelope_id === envelopeId)
    .sort((a, b) => a.ordinal - b.ordinal);

  const after = (r) => {
    if (!r.ok) { onError(r.reason); return false; }
    record.reloadSignatures();
    return true;
  };

  const named = recipients.map((r) => ({
    name: r.name.trim(), email: r.email.trim(), party: r.party,
  }));
  const recipientsReady = named.length > 0
    && named.every((r) => r.name !== '' && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(r.email));

  // An unread envelope list is not an empty envelope list. In particular,
  // do not offer a second send while the first read is still in flight.
  if (record.envelopes.status === 'loading') {
    return <div className="panel p-3 mt-4" data-testid="signature-ceremony"><Loading /></div>;
  }
  if (record.envelopes.status === 'failed') {
    return (
      <div className="panel p-3 mt-4" data-testid="signature-ceremony">
        <LoadFailed reason={record.envelopes.reason} />
      </div>
    );
  }

  return (
    <div className="panel p-3 mt-4" data-testid="signature-ceremony">
      <div className="section-label mb-2">Signature</div>

      {!live && (
        <div>
          <div className="caption">
            Nothing is out for signature on this agreement. Sending takes the
            contract as it stands, records the bytes it sent by their hash, and
            names everybody it went to.
          </div>

          {recipients.map((r, i) => (
            <div className="flex gap-2 mt-2" key={i}>
              <input className="flex-1 min-w-0" type="text"
                     data-testid={`signer-name-${i}`}
                     aria-label={`Name of signer ${i + 1}`}
                     placeholder="who signs"
                     value={r.name}
                     onChange={(e) => setRecipients(recipients.map(
                       (x, j) => (j === i ? { ...x, name: e.target.value } : x)))} />
              <input className="flex-1 min-w-0" type="email"
                     data-testid={`signer-email-${i}`}
                     aria-label={`Email of signer ${i + 1}`}
                     placeholder="email"
                     value={r.email}
                     onChange={(e) => setRecipients(recipients.map(
                       (x, j) => (j === i ? { ...x, email: e.target.value } : x)))} />
              <select data-testid={`signer-party-${i}`}
                      aria-label={`Which side signer ${i + 1} is on`}
                      value={r.party}
                      onChange={(e) => setRecipients(recipients.map(
                        (x, j) => (j === i ? { ...x, party: e.target.value } : x)))}>
                <option value="theirs">theirs</option>
                <option value="ours">ours</option>
              </select>
              {recipients.length > 1 && (
                <button className="btn btn-sm"
                        data-testid={`drop-signer-${i}`}
                        aria-label={`Remove signer ${i + 1}`}
                        onClick={() => setRecipients(
                          recipients.filter((x, j) => j !== i))}>
                  ✕
                </button>
              )}
            </div>
          ))}

          <div className="flex gap-2 mt-2">
            <button className="btn btn-sm" data-testid="add-signer"
                    onClick={() => setRecipients(
                      [...recipients, { name: '', email: '', party: 'theirs' }])}>
              add another signer
            </button>
            <ActButton className="btn btn-primary btn-sm"
                       data-testid="send-for-signature"
                       disabled={!!busy || !recipientsReady}
                       onClick={() => run('send', async () => {
                         if (after(await API.sendForSignature({
                           agreement_id: negotiation.agreement_id,
                           recipients: named,
                         }))) setRecipients([{ name: '', email: '', party: 'theirs' }]);
                       })}>
              send for signature
            </ActButton>
          </div>
        </div>
      )}

      {live && (
        <div data-testid="envelope-live">
          <div className="caption">
            Out for signature with {live.provider} since {live.sent_at},
            sent by {live.sent_by}.
          </div>

          {/* THE HASH OF WHAT WAS SENT, for the working copy's reason: it is
              what makes the document a thing you can point at later. */}
          <div className="caption font-mono mt-1 truncate"
               title={live.document_sha256}>
            {live.document_sha256}
          </div>

          <div className="mt-2">
            {record.envelopeRecipients.status === 'loading'
              ? <Loading />
              : record.envelopeRecipients.status === 'failed'
              ? <LoadFailed reason={record.envelopeRecipients.reason} />
              : recipientsOf(live.envelope_id).length === 0
              ? <div className="caption">Nobody is recorded on this envelope.</div>
              : recipientsOf(live.envelope_id).map((r) => (
                  <div className="panel-2 px-3 py-2 mb-1.5"
                       key={`${r.envelope_id}-${r.ordinal}`}>
                    {r.name} <span className="caption">· {r.party}</span>
                  </div>
                ))}
          </div>

          {voiding !== live.envelope_id
            ? <button className="btn btn-sm mt-2" data-testid="void-envelope"
                      onClick={() => { setVoiding(live.envelope_id); setReason(''); }}>
                call it off…
              </button>
            : (
              <div className="panel-2 p-3 mt-2" style={{ borderColor: 'var(--warn)' }}>
                <div className="tag" style={{ color: 'var(--warn)' }}>
                  this cannot be undone
                </div>
                <div className="caption mt-1">
                  Calling it off voids the envelope with the provider and
                  records why. Sending again opens a new envelope.
                </div>
                <input className="w-full mt-2" type="text"
                       data-testid="void-reason"
                       aria-label="Why the ceremony is being called off"
                       placeholder="why"
                       value={reason}
                       onChange={(e) => setReason(e.target.value)} />
                <div className="flex gap-2 mt-2">
                  <button className="btn btn-sm"
                          onClick={() => setVoiding(null)}>back</button>
                  <ActButton className="btn btn-primary btn-sm"
                             data-testid="confirm-void"
                             disabled={!!busy || reason.trim() === ''}
                             onClick={() => run('void', async () => {
                               if (after(await API.voidEnvelope({
                                 envelope_id: live.envelope_id,
                                 reason: reason.trim(),
                               }))) setVoiding(null);
                             })}>
                    ✓ call it off
                  </ActButton>
                </div>
              </div>
            )}
        </div>
      )}

      {past.length > 0 && (
        <div className="mt-3" data-testid="envelope-history">
          <div className="section-label mb-1.5">Earlier envelopes</div>
          {past.map((e) => (
            <div className="panel-2 px-3 py-2 mb-1.5" key={e.envelope_id}>
              <div>
                {e.state} <span className="caption">· {e.provider} · {e.sent_at}</span>
              </div>
              <div className="caption truncate">
                {record.envelopeRecipients.status === 'loading'
                  ? '…'
                  : record.envelopeRecipients.status === 'failed'
                  ? <LoadFailed reason={record.envelopeRecipients.reason} />
                  : recipientsOf(e.envelope_id).map((r) => r.name).join(', ') || '—'}
              </div>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

// ── Recent activity ───────────────────────────────────────────────────────
// Derived from the recorded acts, merged and aged — never a second log.
function Activity({ negotiationId, comments, analyses, visits }) {
  const events = [
    ...comments.filter((c) => c.negotiation_id === negotiationId)
      .map((c) => ({ at: c.commented_at, what: `${c.author} commented`
        + (c.paragraph_index != null ? ` on paragraph ${c.paragraph_index}` : '') })),
    ...analyses.filter((a) => a.negotiation_id === negotiationId)
      .map((a) => ({ at: a.analysed_at,
        what: `${a.analysed_by} analysed paragraph ${a.paragraph_index}` })),
    ...visits.filter((v) => v.negotiation_id === negotiationId)
      .map((v) => ({ at: v.entered_at, what: `${v.person} opened the room` })),
  ].sort((a, b) => new Date(b.at) - new Date(a.at))
    // Consecutive identical acts collapse to the newest one — the record
    // keeps every visit, but a feed that repeats "opened the room" twelve
    // times is noise, not derivation.
    .filter((e, i, all) => i === 0 || e.what !== all[i - 1].what)
    .slice(0, 12);

  if (events.length === 0) return null;
  return (
    <div className="panel p-4 mt-6">
      <PanelHead title="Recent in this room"
                 sub="Derived from recorded acts — comments, analyses, visits — never a second log." />
      {events.map((e, i) => (
        <div className="flex items-baseline justify-between py-1 border-b hair"
             key={`${e.at}-${i}`}>
          <span className="text-[12.5px]" style={{ color: 'var(--mute)' }}>{e.what}</span>
          <span className="caption">{since(e.at)}</span>
        </div>
      ))}
    </div>
  );
}

// ── The document itself ───────────────────────────────────────────────────
// The round's received Word file, on the page: kept text as ink, the
// supplier's insertions in the accent, their deletions struck through —
// and the margin beside each paragraph carrying their comments (from
// inside the file) and ours (from the record). Working IN the document is
// the whole ask (S286): selection on the text raises comment / ask-the-AI
// on the exact words.
function DocumentSheet({ negotiation, round, comments, people, analysed,
                         onError, onChanged, onHighlight, onWorkbench,
                         absentFallback }) {
  const doc = usePane(
    () => API.dealRoomDocument(negotiation.negotiation_id, round),
    [negotiation.negotiation_id, round]);
  const [composing, setComposing] = useState(null); // {paragraph, quoted}|null
  const [popover, setPopover] = useState(null);     // {x, y, text, paragraph}
  const sheetRef = useRef(null);

  const body = doc.body || {};
  const documentHere = body.document || null;
  if (doc.status === 'loading' && !doc.body) {
    return <div className="caption">fetching the round's document…</div>;
  }
  if (doc.status === 'failed') return absentFallback(doc.reason);
  if (!documentHere) return absentFallback(body.absent_reason);

  const analysedAt = (index) =>
    analysed.find((r) => r.paragraph_index === index);
  const rootsAt = (index) => comments
    .filter((c) => c.round_no === round && c.paragraph_index === index
      && !c.parent_comment_id)
    .sort((a, b) => a.comment_id - b.comment_id);
  const repliesOf = (root) => comments
    .filter((c) => c.parent_comment_id === root.comment_id)
    .sort((a, b) => a.comment_id - b.comment_id);
  const supplierAt = (index) => documentHere.supplier_comments
    .filter((c) => c.paragraph_index === index);

  const took = (e, index) => {
    const selection = String(window.getSelection() || '').trim();
    if (!selection) { setPopover(null); return; }
    const sheet = sheetRef.current.getBoundingClientRect();
    setPopover({
      x: Math.min(e.clientX - sheet.left, sheet.width - 170),
      y: e.clientY - sheet.top + 14,
      text: selection, paragraph: index,
    });
  };
  const done = () => {
    setPopover(null);
    const s = window.getSelection();
    if (s) s.removeAllRanges();
  };

  return (
    <div className="relative" ref={sheetRef} data-testid="deal-document">
      <div className="flex items-baseline justify-between gap-3 flex-wrap mb-2">
        <div className="caption font-mono">
          {documentHere.filename || 'the received file'} · round {round} ·
          {' '}as received, changes theirs
        </div>
        {/* The carbon-copy legend, stated where the reader can see it.
            Deletions wear the superseded ink — struck and KEPT — never the
            error red: a supplier's deletion is a proposal, not a fault. */}
        <div className="redline-legend">
          <span><span className="redline-ins">insertions</span></span>
          <span><span className="redline-del">deletions</span></span>
          <span className="anchor-flag">comments</span>
        </div>
      </div>
      {documentHere.paragraphs.map((p) => {
        const meta = analysedAt(p.index);
        const roots = rootsAt(p.index);
        const supplier = supplierAt(p.index);
        const margin = roots.length > 0 || supplier.length > 0
          || (composing && composing.paragraph === p.index);
        return (
          <div key={p.index} className="grid gap-3"
               style={{ gridTemplateColumns: 'minmax(0, 1fr) 240px' }}>
            <div className="panel-2 px-3 py-2 mb-1.5"
                 onMouseDown={() => setPopover(null)}>
              <div className="font-serif text-[13.5px]"
                   data-testid="proposed-text"
                   style={{ color: 'var(--ink)', lineHeight: 1.6, cursor: 'text' }}
                   onMouseUp={(e) => took(e, p.index)}>
                {p.runs.length === 0 && <span>&nbsp;</span>}
                {p.runs.map((run, i) => run.kind === 'del'
                  ? <span key={i} className="redline-del"
                          title={p.author ? `struck by ${p.author}` : 'struck'}>{run.text}</span>
                  : run.kind === 'ins'
                    ? <span key={i} className="redline-ins"
                            title={p.author ? `inserted by ${p.author}` : 'inserted'}>{run.text}</span>
                    : <span key={i}>{run.text}</span>)}
              </div>
              <div className="caption mt-1 flex items-baseline justify-between gap-2">
                <span>
                  {meta && meta.category_key && <span className="mr-2">{meta.category_key}</span>}
                  {meta && (meta.matched_position
                    ? `touches position ${meta.matched_position}`
                    : 'matched no position')}
                </span>
                <button className="btn btn-sm" data-testid="open-workbench"
                        aria-label={`Open paragraph ${p.index} on the workbench`}
                        onClick={() => onWorkbench(p.index)}>
                  workbench
                </button>
              </div>
            </div>
            <div>
              {supplier.map((c) => (
                <div key={c.comment_id} className="panel p-2.5 mb-2"
                     style={{ borderLeft: '3px solid var(--pending)' }}
                     data-testid="supplier-comment">
                  <div className="caption font-mono">
                    {c.author} · <span className="chip chip-pending">supplier</span>
                  </div>
                  <div className="text-[13px] mt-1.5">{c.text}</div>
                </div>
              ))}
              {roots.map((c) => (
                <Comment key={c.comment_id} comment={c} replies={repliesOf(c)}
                         people={people} onError={onError} onChanged={onChanged}
                         depth={0} />
              ))}
              {composing && composing.paragraph === p.index && (
                <div className="panel p-2.5">
                  <CommentForm compact people={people}
                               fixed={{ negotiation_id: negotiation.negotiation_id,
                                        round_no: round,
                                        paragraph_index: p.index }}
                               quoted={composing.quoted} onError={onError}
                               onPosted={() => { setComposing(null); onChanged(); }}
                               onCancel={() => setComposing(null)} />
                </div>
              )}
              {!margin && <div />}
            </div>
          </div>
        );
      })}
      {popover && (
        <div className="panel p-1 flex gap-1 absolute z-10"
             style={{ left: popover.x, top: popover.y }}
             onMouseDown={(e) => e.stopPropagation()}>
          <button className="btn btn-sm"
                  onClick={() => {
                    setComposing({ paragraph: popover.paragraph,
                                   quoted: popover.text });
                    done();
                  }}>
            comment
          </button>
          <ActButton className="btn btn-sm"
                  onClick={() => {
                    onHighlight({ negotiation_id: negotiation.negotiation_id,
                                  round_no: round,
                                  paragraph_index: popover.paragraph,
                                  quoted_text: popover.text });
                    done();
                  }}>
            analyse selection
          </ActButton>
        </div>
      )}
    </div>
  );
}

// ── The clause workbench ──────────────────────────────────────────────────
// One paragraph, opened for judgement: the clause as it stood beside the
// supplier's markup of it, with the advice this product already keeps
// gathered around the pair — the model's plain reading (run through the
// analysis window, which stays in the margin), the register of proposed
// counter-language, and what this supplier's other contracts say close to
// this deal's paper. The room's three rules carry in unchanged: no
// permission is checked here, nothing gates, and advice is labelled advice.
//
// SINCE 0120 THE COMPLIANCE PANEL IS DRAWN — the register of concerns the
// company declared, the check act, and the record of every check with how
// much of the register each was read against. Until 0120 it was
// deliberately absent, because a panel with no measurement behind it would
// have been decoration claiming coverage never taken (the rack's rule).
function ClauseWorkbench({ negotiation, round, paragraphIndex, record,
                           onBack, onError, onHighlight }) {
  // The round's file, for the markup runs. Its own read, its own rule; an
  // absent file falls back to the analysed record of the same paragraph.
  const doc = usePane(
    () => API.dealRoomDocument(negotiation.negotiation_id, round),
    [negotiation.negotiation_id, round]);
  const [severity, setSeverity] = useState('Standard');
  const [purpose, setPurpose] = useState('');
  const [limits, setLimits] = useState('');
  const [drafting, setDrafting] = useState(false);
  const [said, setSaid] = useState(null);

  // The newest analysis of this paragraph — a re-run appends (0052), and the
  // bench shows what the newest run said, the Room's own rule.
  const row = record.analysis.rows
    .filter((r) => r.negotiation_id === negotiation.negotiation_id
      && r.round_no === round && r.paragraph_index === paragraphIndex)
    .sort((a, b) => b.analysis_id - a.analysis_id)[0] || null;

  const docPara = ((doc.body || {}).document || {}).paragraphs
    ? (doc.body.document.paragraphs || [])
        .find((p) => p.index === paragraphIndex) || null
    : null;

  const analyses = record.analyses.rows
    .filter((a) => a.negotiation_id === negotiation.negotiation_id
      && a.round_no === round && a.paragraph_index === paragraphIndex)
    .sort((a, b) => b.analysis_id - a.analysis_id);

  const proposals = (record.drafts.rows ?? [])
    .filter((p) => String(p.negotiation_id) === String(negotiation.negotiation_id)
      && p.paragraph_index === paragraphIndex);

  const proposedText = docPara
    ? docPara.runs.filter((r) => r.kind !== 'del').map((r) => r.text).join('')
    : (row ? row.proposed_text : '');

  const askForDraft = async () => {
    onError(null);
    setSaid(null);
    const answer = await API.draftCounter({
      analysis_id: row.analysis_id,
      severity,
      intended_purpose: purpose,
      known_limitations: limits,
    });
    if (!answer.ok) { onError(answer.reason); return; }
    setSaid(answer.body || {});
    setDrafting(false);
    setPurpose('');
    setLimits('');
    record.drafts.reload();
  };

  return (
    <div data-testid="clause-workbench">
      <button className="btn btn-sm mb-3" data-testid="workbench-back"
              onClick={onBack}>← the whole document</button>

      <PanelHead
        title={`Paragraph ${paragraphIndex} · round ${round}`}
        sub="The clause as it stood beside what the supplier proposes, with the advice on the record around the pair. Nothing on this bench moves a position, writes contract wording, or gates anything."
        right={row && (
          <span className="caption">
            {row.category_key && <span className="mr-2">{row.category_key}</span>}
            {row.matched_position
              ? `touches position ${row.matched_position}`
              : 'matched no position'}
          </span>
        )} />

      {/* ── The pair ──────────────────────────────────────────────────── */}
      <div className="mt-3 grid gap-3" style={{ gridTemplateColumns: '1fr 1fr' }}>
        <div className="panel-2 p-3">
          <div className="caption">as it stood</div>
          {row
            ? <div className="font-serif text-[13.5px] mt-1"
                   data-testid="workbench-original"
                   style={{ color: 'var(--mute)', lineHeight: 1.6 }}>
                {row.original_text}
              </div>
            : <div className="caption mt-1">
                This paragraph has no analysed record, so what it replaced is
                not on hand here. Run the round analysis on the negotiate tab
                and this side fills in.
              </div>}
        </div>
        <div className="panel-2 p-3">
          <div className="caption">as they propose · select words to act on them</div>
          <div className="font-serif text-[13.5px] mt-1"
               data-testid="workbench-proposed"
               style={{ color: 'var(--ink)', lineHeight: 1.6 }}>
            {docPara
              ? docPara.runs.map((run, i) => run.kind === 'del'
                  ? <span key={i} className="redline-del">{run.text}</span>
                  : run.kind === 'ins'
                    ? <span key={i} className="redline-ins">{run.text}</span>
                    : <span key={i}>{run.text}</span>)
              : row
                ? row.proposed_text
                : <span className="caption">
                    Neither the file nor an analysed record holds this
                    paragraph{doc.status === 'failed' ? ` — ${doc.reason}` : ''}.
                  </span>}
          </div>
        </div>
      </div>

      {/* ── The model's reading, run through the fixed window ─────────── */}
      <div className="advice-card mt-4" data-testid="workbench-reading">
        <div className="advice-label">The model's reading (advisory)</div>
        {proposedText
          ? <div className="mt-2">
              <ActButton className="btn btn-sm" data-testid="workbench-ask"
                onClick={() => onHighlight({
                  negotiation_id: negotiation.negotiation_id,
                  round_no: round,
                  paragraph_index: paragraphIndex,
                  quoted_text: proposedText.length > 600
                    ? proposedText.slice(0, 600) : proposedText,
                })}>
                analyse this whole paragraph
              </ActButton>
              <span className="caption ml-2">
                The fixed analysis opens beside this bench. Its saved result
                is reused until the paragraph or governed context changes.
              </span>
            </div>
          : null}
        {analyses.length === 0
          ? <div className="caption mt-2">
              This paragraph has not been analysed.
            </div>
          : analyses.map((a) => <AnalysisCard key={a.analysis_id} a={a} />)}
        <div className="advice-foot">Advisory only — does not gate the record.</div>
      </div>

      {/* ── Counter-language ──────────────────────────────────────────── */}
      <div className="panel p-4 mt-4" data-testid="workbench-counter">
        <PanelHead
          title="What we might send back"
          sub="A proposed reply lands in the review queue badged AI CANDIDATE, naming this paragraph — a named attorney approves, edits or rejects it. Nothing here reaches the other side." />
        {!row && (
          <div className="caption mt-2">
            A reply is drafted against the analysed record, and this paragraph
            has none yet.
          </div>
        )}
        {row && row.decided_position && (
          <div className="caption mt-2">
            The scorer placed this against a position we already hold, so the
            answer is that position — not new language.
          </div>
        )}
        {row && !row.decided_position && !drafting && (
          <ActButton className="btn mt-2" data-testid="workbench-draft"
                     onClick={() => { setSaid(null); setDrafting(true); }}>
            draft a reply
          </ActButton>
        )}
        {drafting && (
          <div className="panel-2 p-3 mt-3" data-testid="workbench-drafting-form">
            <div className="caption" style={{ lineHeight: 1.7 }}>
              Both answers below are written into the record permanently — they
              are what somebody knew when the fixed analysis ran.
            </div>
            <label className="block mt-3">
              <span className="caption">how serious this point is</span>
              <select className="input mt-1" value={severity}
                      aria-label="How serious this point is"
                      onChange={(e) => setSeverity(e.target.value)}>
                <option value="Standard">Standard</option>
                <option value="High">High</option>
              </select>
            </label>
            <label className="block mt-3">
              <span className="caption">what this draft is for</span>
              <textarea className="input mt-1" rows={2} value={purpose}
                        aria-label="What this draft is for"
                        onChange={(e) => setPurpose(e.target.value)} />
            </label>
            <label className="block mt-3">
              <span className="caption">what is known to be unreliable about it</span>
              <textarea className="input mt-1" rows={2} value={limits}
                        aria-label="What is known to be unreliable about it"
                        onChange={(e) => setLimits(e.target.value)} />
            </label>
            <div className="flex gap-2 mt-3">
              <ActButton className="btn btn-primary" onClick={askForDraft}>
                ask for a draft
              </ActButton>
              <button className="btn" onClick={() => setDrafting(false)}>
                cancel
              </button>
            </div>
          </div>
        )}
        {said && <WhatItDrafted said={said} />}
        <WhatItProposed proposals={proposals} />
      </div>

      <CompliancePanel negotiation={negotiation} round={round}
                       paragraphIndex={paragraphIndex} row={row}
                       record={record} onError={onError} />

      {/* ── The supplier's other paper ────────────────────────────────── */}
      <div className="panel p-4 mt-4" data-testid="workbench-echoes">
        <div className="caption mb-2">
          Measured over this deal's whole contract against the supplier's other
          contracts — not over this one paragraph alone.
        </div>
        <CrossContractEchoes agreementId={negotiation.agreement_id} />
      </div>
    </div>
  );
}

// ── The compliance panel (0120) ───────────────────────────────────────────
// The concerns the company's own Legal team declared for this paragraph's
// category, and the record of every check run against them. Two halves,
// never blended: the DECLARED CONCERNS are the deterministic half and draw
// whether or not any model exists; the model's OPINION arrives labelled, or
// its absence with its recorded reason. Every check says how much of the
// register it was read against, because "no concern is touched" and "no
// concern is declared" are different sentences. IT WARNS AND NEVER GATES —
// no control anywhere is disabled by what this panel says.
function CompliancePanel({ negotiation, round, paragraphIndex, row, record,
                           onError }) {
  const [busy, setBusy] = useState(false);
  const [said, setSaid] = useState(null);

  const category = row ? row.category_key : null;
  const concernsPane = record.concerns;
  const declared = (concernsPane.rows ?? [])
    .filter((c) => !c.retired_at
      && (c.category_key === null || c.category_key === category));

  const checks = (record.complianceChecks.rows ?? [])
    .filter((a) => a.negotiation_id === negotiation.negotiation_id
      && a.round_no === round && a.paragraph_index === paragraphIndex)
    .sort((a, b) => b.assessment_id - a.assessment_id);

  const run = async () => {
    setBusy(true); onError(null); setSaid(null);
    const r = await API.complianceCheck({
      negotiation_id: negotiation.negotiation_id,
      round_no: round,
      paragraph_index: paragraphIndex,
    });
    setBusy(false);
    if (!r.ok) { onError(r.reason); return; }
    setSaid(r.body || {});
    record.complianceChecks.reload();
  };

  return (
    <div className="advice-card mt-4" data-testid="workbench-compliance">
      <div className="advice-label">Compliance check (advisory)</div>
      <div className="font-serif italic mt-1 mb-2"
           style={{ fontSize: 13.5, color: 'var(--mute)' }}>
        Read against what this company declared it cares about — never law of
        the machine's own. Nothing here gates anything.
      </div>

      {/* The deterministic half: the register, drawn always. */}
      {concernsPane.status === 'failed'
        ? <div className="caption">
            The register could not be read: {concernsPane.reason}
          </div>
        : declared.length === 0
        ? <div className="caption" data-testid="no-concerns">
            No live concern is declared for
            {category ? ` the ${category} category` : ' this paragraph'} or for
            every category — a fact about the register, never a clean bill.
            Legal declares concerns on the governance pane.
          </div>
        : <div data-testid="declared-concerns">
            <div className="caption">
              {declared.length} declared concern{declared.length === 1 ? '' : 's'}
              {' '}attach{declared.length === 1 ? 'es' : ''} to this paragraph:
            </div>
            {declared.map((c) => (
              <div className="py-1 border-b hair" key={c.concern_id}>
                <span className="text-[12.5px]" style={{ color: 'var(--ink)' }}>
                  {c.title}
                </span>
                <span className="caption ml-2 font-mono">
                  {c.category_key || 'every category'}
                  {c.source ? ` · ${c.source}` : ''}
                </span>
              </div>
            ))}
          </div>}

      <div className="mt-2">
        <ActButton className="btn btn-sm" disabled={busy}
                   data-testid="run-compliance-check" onClick={run}>
          {busy ? 'checking…' : 'check this paragraph against them'}
        </ActButton>
      </div>

      {/* The check just run, from its own reply; the register below draws
          from the READ, so the two cannot drift apart for long. */}
      {said && (
        <div className="panel-2 p-3 mt-3" data-testid="compliance-said">
          <div className="caption font-mono">
            read against {said.concerns_sent} of {said.concerns_declared}
            {' '}declared concern{said.concerns_declared === 1 ? '' : 's'}
            {said.reused ? ' · saved result' : ''}
          </div>
          {Array.isArray(said.changed_because) && said.changed_because.length > 0 && (
            <div className="caption mt-1">{said.changed_because.join(' · ')}</div>
          )}
          {said.outcome === 'answered'
            ? <>
                <div className="tag mt-1" style={{ color: 'var(--advice)' }}>
                  AI advice · {said.model}
                  {said.model_version ? ` ${said.model_version}` : ''}
                </div>
                <div className="text-[13px] mt-1"
                     style={{ lineHeight: 1.6, color: 'var(--advice)' }}>
                  {said.assessment}
                </div>
              </>
            : <>
                <div className="tag mt-1" style={{ color: 'var(--mute-2)' }}>
                  no model opinion
                </div>
                <div className="caption mt-1">{said.absent_reason}</div>
              </>}
        </div>
      )}

      {/* The record of past checks of this paragraph. */}
      {checks.length > 0 && (
        <div className="mt-3" data-testid="compliance-record">
          <div className="section-label mb-1">Checks on the record</div>
          {checks.map((a) => (
            <div className="panel-2 p-2.5 mb-2" key={a.assessment_id}>
              <div className="flex items-baseline justify-between gap-2">
                <span className="caption font-mono">
                  {a.checked_by} · {since(a.checked_at)}
                </span>
                <span className="caption font-mono">
                  {a.concerns_sent} of {a.concerns_declared} concerns
                </span>
              </div>
              {Array.isArray(a.changed_because) && a.changed_because.length > 0 && (
                <div className="caption mt-1">{a.changed_because.join(' · ')}</div>
              )}
              {a.outcome === 'answered'
                ? <div className="text-[13px] mt-1"
                       style={{ lineHeight: 1.6, color: 'var(--advice)' }}>
                    {a.assessment}
                  </div>
                : <div className="caption mt-1">{a.absent_reason}</div>}
            </div>
          ))}
        </div>
      )}

      <div className="advice-foot">Advisory only — does not gate the record.</div>
    </div>
  );
}

// ── One room, opened ──────────────────────────────────────────────────────
function Room({ me, negotiation, record, onBack, onError }) {
  const [highlight, setHighlight] = useState(null);
  const [roundShown, setRoundShown] = useState(null);
  const [docMode, setDocMode] = useState('read'); // 'read' | 'edit'
  // The workbench: one paragraph opened for judgement in place of the whole
  // document. { round, paragraph } | null. Changing the shown round puts the
  // bench away — it belongs to the round it was opened on.
  const [bench, setBench] = useState(null);
  const entered = useRef(false);

  // Entering the room is a recorded act, once per opening — which is what
  // makes "who is here" a record rather than a guess.
  useEffect(() => {
    if (entered.current) return;
    entered.current = true;
    API.enterDealRoom({ negotiation_id: negotiation.negotiation_id })
      .then((r) => { if (r.ok) record.visits.reload(); });
  }, []);

  // The living surfaces refresh themselves while the room is open. Polling
  // through the API's fixed endpoint list — the one transport this page has.
  useEffect(() => {
    const t = setInterval(() => record.reloadConversation(), POLL_MS);
    return () => clearInterval(t);
  }, []);

  const deal = record.deals.rows
    .find((d) => d.agreement_id === negotiation.agreement_id);
  const analysedRows = record.analysis.rows
    .filter((r) => r.negotiation_id === negotiation.negotiation_id);
  const analysedRounds = [...new Set(analysedRows.map((r) => r.round_no))]
    .sort((a, b) => a - b);
  const round = roundShown ?? analysedRounds[analysedRounds.length - 1] ?? null;

  // The newest analysis of each paragraph of the shown round — a re-run
  // appends (0052), and the screen shows what the newest run said.
  const paragraphs = [];
  for (const row of analysedRows.filter((r) => r.round_no === round)
      .sort((a, b) => b.analysis_id - a.analysis_id)) {
    if (!paragraphs.some((p) => p.paragraph_index === row.paragraph_index)) {
      paragraphs.push(row);
    }
  }
  paragraphs.sort((a, b) => a.paragraph_index - b.paragraph_index);

  const comments = record.comments.rows
    .filter((c) => c.negotiation_id === negotiation.negotiation_id);

  // Every received round is showable — the document does not wait for an
  // analysis run, only its metadata does.
  const receivedRounds = record.rounds.rows
    .filter((r) => r.negotiation_id === negotiation.negotiation_id
      && r.direction === 'received')
    .map((r) => r.round_no);
  const rounds = [...new Set([...analysedRounds, ...receivedRounds])]
    .sort((a, b) => a - b);
  const shown = roundShown ?? rounds[rounds.length - 1] ?? round;

  // What the room shows when the round's file is not in the store: the
  // analysed paragraphs, which are the recorded half of the same document.
  const analysedCards = (reason) => (
    <div>
      {reason && (
        <div className="caption mb-3">
          The file itself is not on hand — {reason}. What follows is the
          analysed record of the same round.
        </div>
      )}
      {paragraphs.length === 0
        ? <Empty
            kicker="the paper"
            line="No analysed round to talk over yet."
            sub="Record what the supplier sent back on the negotiate tab, run the round analysis there, and the changed paragraphs appear here with the conversation beside them." />
        : paragraphs.map((row) => (
            <ParagraphCard key={`${row.round_no}-${row.paragraph_index}`}
                           row={row} comments={comments}
                           people={record.people.rows}
                           onError={onError}
                           onChanged={record.reloadConversation}
                           onHighlight={setHighlight}
                           onWorkbench={(paragraph) =>
                             setBench({ round: row.round_no, paragraph })} />
          ))}
    </div>
  );

  // The header's facts, each off the record it belongs to. The design's
  // header strip shows only what the record holds — a cell the record
  // cannot fill is absent, never invented.
  const myRounds = record.rounds.rows
    .filter((r) => r.negotiation_id === negotiation.negotiation_id)
    .sort((a, b) => a.round_no - b.round_no);
  const lastReceived = [...myRounds].reverse()
    .find((r) => r.direction === 'received');

  // The room's threads, grouped by their anchor — the design's left rail.
  const anchored = new Map();
  for (const c of comments) {
    if (c.parent_comment_id || c.paragraph_index === null
        || c.paragraph_index === undefined) continue;
    const key = `${c.round_no}·${c.paragraph_index}`;
    if (!anchored.has(key)) {
      anchored.set(key, { round: c.round_no, paragraph: c.paragraph_index,
                          total: 0, open: 0 });
    }
    const t = anchored.get(key);
    t.total += 1;
    if (c.state === 'open') t.open += 1;
  }
  const threads = [...anchored.values()]
    .sort((a, b) => a.round - b.round || a.paragraph - b.paragraph);

  // The newest recorded act, for the foot — the same derivation the
  // activity feed draws, taken at its head.
  const lastAct = [
    ...comments.map((c) => ({ at: c.commented_at, what: `${c.author} commented` })),
    ...record.analyses.rows
      .filter((a) => a.negotiation_id === negotiation.negotiation_id)
      .map((a) => ({ at: a.analysed_at, what: `${a.analysed_by} ran analysis` })),
    ...record.visits.rows
      .filter((v) => v.negotiation_id === negotiation.negotiation_id)
      .map((v) => ({ at: v.entered_at, what: `${v.person} opened the room` })),
  ].sort((a, b) => new Date(b.at) - new Date(a.at))[0];

  const openThreadCount = threads.reduce((n, t) => n + t.open, 0);

  return (
    <div>
      <button className="btn btn-sm mb-4" onClick={onBack}>← deal rooms</button>

      <div className="sheet-head">
        <div>
          <div className="sheet-kicker">Deal room — in the matter of</div>
          <h1 className="sheet-title mt-1" style={{ fontSize: 34 }}>
            {deal ? deal.counterparty : negotiation.agreement_id}
          </h1>
        </div>
        {/* The room's state, stamped: an open negotiation is work in
            progress — amber, conferring nothing (rule 2). Any other state
            is written in its own word. */}
        {negotiation.state === 'open'
          ? <span className="stamp stamp-pending" style={{ '--rot': '-3deg' }}>in progress</span>
          : negotiation.state
            ? <span className="stamp stamp-ink" style={{ '--rot': '-3deg' }}>{negotiation.state}</span>
            : null}
      </div>
      <div className="mt-3">
        <WhoIsHere visits={record.visits.rows}
                   negotiationId={negotiation.negotiation_id} />
      </div>
      <div>
        <div className="deal-meta mt-3">
          <div className="meta-cell">
            <div className="meta-label">Deal</div>
            <div className="meta-value">{negotiation.agreement_id}</div>
          </div>
          {deal && (
            <div className="meta-cell">
              <div className="meta-label">Counterparty</div>
              <div className="meta-value">{deal.counterparty}</div>
            </div>
          )}
          <div className="meta-cell">
            <div className="meta-label">Paper</div>
            <div className="meta-value">{negotiation.paper === 'ours' ? 'ours' : 'theirs'}</div>
          </div>
          <div className="meta-cell">
            <div className="meta-label">Rounds</div>
            <div className="meta-value">{myRounds.length === 0 ? 'none yet'
              : myRounds[myRounds.length - 1].round_no}</div>
          </div>
          {lastReceived && (
            <div className="meta-cell">
              <div className="meta-label">Last received</div>
              <div className="meta-value">
                round {lastReceived.round_no}{lastReceived.sent_on ? ` · ${lastReceived.sent_on}` : ''}
              </div>
            </div>
          )}
          <div className="meta-cell">
            <div className="meta-label">Room</div>
            <div className="meta-value">{negotiation.negotiation_id}</div>
          </div>
        </div>
      </div>

      <div className="mt-6 deal-room-grid">
        {/* ── The rail: the dossier and the threads ─────────────────────── */}
        <div className="min-w-0">
          <div className="panel p-3">
            <div className="section-label mb-2">Dossier</div>
            {myRounds.length === 0
              ? <div className="caption">Nothing has been exchanged yet.</div>
              : myRounds.map((r) => (
                  <div className="py-1.5 border-b hair" key={r.round_no}
                       data-testid="dossier-round">
                    <div className="font-mono text-[11.5px]" style={{ color: 'var(--ink)' }}>
                      round {r.round_no} · {r.direction === 'received' ? 'received' : 'sent'}
                    </div>
                    <div className="caption font-mono mt-0.5 truncate"
                         title={r.document_sha256}>
                      {r.sent_on ? `${r.sent_on} · ` : ''}{r.document_sha256}
                    </div>
                  </div>
                ))}
          </div>

          <WorkingCopy negotiation={negotiation} rounds={myRounds}
                       record={record} onError={onError} />

          <SignatureCeremony negotiation={negotiation} record={record}
                             onError={onError} />

          <div className="panel p-3 mt-4">
            <div className="section-label mb-2">Threads (by anchor)</div>
            {threads.length === 0
              ? <div className="caption">No thread is anchored to the paper yet.
                  Select words in the document to start one.</div>
              : threads.map((t) => (
                  <button key={`${t.round}-${t.paragraph}`}
                          className="w-full text-left py-1.5 border-b hair"
                          style={{ cursor: 'pointer' }}
                          data-testid="thread-anchor"
                          onClick={() => setRoundShown(t.round)}>
                    <span className="font-mono text-[11.5px]" style={{ color: 'var(--ink)' }}>
                      ¶ {t.paragraph} · round {t.round}
                    </span>
                    <span className="caption ml-2">
                      {t.total} {t.total === 1 ? 'comment' : 'comments'}
                    </span>
                    {t.open > 0
                      ? <span className="chip chip-pending ml-2">{t.open} open</span>
                      : <span className="chip chip-std ml-2">settled</span>}
                  </button>
                ))}
          </div>
        </div>

        {/* ── The paper itself ──────────────────────────────────────────── */}
        <div className="min-w-0">
          <div className="panel p-4">
            <PanelHead
              title="The document"
              sub={docMode === 'edit'
                ? "Live co-editing with OnlyOffice Document Server. Edits append to the working document save stack."
                : "The round as the supplier returned it — their changes and comments in place. Select words to comment or run the fixed analysis."}
              right={(
                <div className="flex items-center gap-2">
                  <div className="btn-group">
                    <button className={`btn btn-sm ${docMode === 'read' ? 'btn-primary' : ''}`}
                            data-testid="mode-read"
                            onClick={() => setDocMode('read')}>
                      read
                    </button>
                    <button className={`btn btn-sm ${docMode === 'edit' ? 'btn-primary' : ''}`}
                            data-testid="mode-edit"
                            onClick={() => setDocMode('edit')}>
                      edit
                    </button>
                  </div>
                  {docMode === 'read' && rounds.length > 1 && (
                    <select className="font-mono" style={{ padding: '4px 8px' }}
                            aria-label="Which round of the document to show"
                            value={shown ?? ''}
                            onChange={(e) => {
                              setRoundShown(Number(e.target.value));
                              setBench(null);
                            }}>
                      {rounds.map((r) => (
                        <option key={r} value={r}>round {r}</option>
                      ))}
                    </select>
                  )}
                </div>
              )} />
            {docMode === 'edit'
              ? <OnlyOfficeEditor negotiation={negotiation} onError={onError} />
              : bench
              ? <ClauseWorkbench negotiation={negotiation}
                                 round={bench.round}
                                 paragraphIndex={bench.paragraph}
                                 record={record}
                                 onBack={() => setBench(null)}
                                 onError={onError}
                                 onHighlight={setHighlight} />
              : shown === null || shown === undefined
              ? analysedCards(null)
              : <DocumentSheet negotiation={negotiation} round={shown}
                               comments={comments} people={record.people.rows}
                               analysed={paragraphs}
                               onError={onError}
                               onChanged={record.reloadConversation}
                               onHighlight={setHighlight}
                               onWorkbench={(paragraph) =>
                                 setBench({ round: shown, paragraph })}
                               absentFallback={analysedCards} />}
          </div>

          <Activity negotiationId={negotiation.negotiation_id}
                    comments={comments}
                    analyses={record.analyses.rows}
                    visits={record.visits.rows} />
        </div>

        {/* ── The margin: advice, the playbook, the talk ────────────────── */}
        <div className="min-w-0">
          <AnalysisWindow negotiation={negotiation}
                          analyses={record.analyses.rows}
                          highlight={highlight}
                          onClearHighlight={() => setHighlight(null)}
                          onError={onError}
                          onAnalysed={record.reloadConversation} />
          {/* The authored playbook — negotiate.jsx's own component, reused
              rather than copied, so the two screens cannot drift. */}
          <MovesAside negotiation={negotiation}
                      positionMoves={record.positionMoves} />
          <div className="mt-6">
            <ChatPanel me={me} negotiation={negotiation} comments={comments}
                       people={record.people.rows} onError={onError}
                       onChanged={record.reloadConversation} />
          </div>
        </div>
      </div>

      {/* ── The foot: the record's own summary of where things stand ────── */}
      <div className="foot-facts mt-6">
        <div className="fact-cell">
          <div className="fact-label">Last act</div>
          <div className="fact-value">
            {lastAct ? <>{lastAct.what} <span className="font-mono">· {since(lastAct.at)}</span></>
              : 'nothing has happened in this room yet'}
          </div>
        </div>
        <div className="fact-cell">
          <div className="fact-label">Open threads</div>
          <div className="fact-value">
            {openThreadCount === 0 ? 'none' : openThreadCount}
          </div>
        </div>
        <div className="fact-cell">
          <div className="fact-label">Analyses on the record</div>
          <div className="fact-value">
            {record.analyses.rows
              .filter((a) => a.negotiation_id === negotiation.negotiation_id).length}
          </div>
        </div>
      </div>
    </div>
  );
}

// ── The pane ──────────────────────────────────────────────────────────────
function DealRoomPane({ me }) {
  const record = useDealRoomRecord();
  const [showing, setShowing] = useAddressedRecord('deal-room');
  const [error, setError] = useState(null);

  // The counterparty is joined in at render time, so it is joined in HERE too
  // — searching a room by the company you are negotiating with is the first
  // thing anybody would try, and a filter that could not see the counterparty
  // would be searching a column the reader is not looking at.
  // ABOVE EVERY EARLY RETURN. A hook called after a conditional `return` runs
  // on some renders and not others, and React refuses the second one with
  // "Rendered more hooks than during the previous render" — the pane goes
  // blank. It reads `.rows` defensively because the record is still empty on
  // the render before the read lands.
  const rooms = (record.negotiations?.rows ?? []).map((n) => ({
    ...n,
    counterparty: ((record.deals?.rows ?? [])
      .find((d) => d.agreement_id === n.agreement_id) || {}).counterparty || '',
  }));
  const filter = useListFilter(rooms, {
    view: 'deal-room:rooms',
    fields: ['agreement_id', 'counterparty'],
    facet: 'paper',
  });

  if (record.loading) return <Loading />;
  if (record.failed) return <LoadFailed reason={record.failed.reason} />;

  const negotiation = record.negotiations.rows
    .find((n) => String(n.negotiation_id) === String(showing));

  if (showing && negotiation) {
    return (
      <div>
        {error && <RefusalNote reason={error} />}
        <Room me={me} negotiation={negotiation} record={record}
              onBack={() => setShowing(null)} onError={setError} />
      </div>
    );
  }

  const openThreads = record.comments.rows
    .filter((c) => !c.parent_comment_id && c.state === 'open');

  return (
    <div>
      <div className="sheet-head">
        <h1 className="sheet-title">Deal Rooms</h1>
        <span className="sheet-note">
          {record.negotiations.rows.length}
          {record.negotiations.rows.length === 1 ? ' room' : ' rooms'}
          {' · '}{openThreads.length} open threads
          {' · '}{record.analyses.rows.length} analyses on the record
        </span>
      </div>
      <div className="font-serif italic mt-2" style={{ fontSize: 14, color: 'var(--mute)' }}>
        Your active rooms and their recent activity. What you see is what the
        database returned for you.
      </div>

      {error && <RefusalNote reason={error} />}

      <div className="mt-6">
        <PanelHead
          title="The rooms"
          sub="One room per negotiation — the record with the conversation beside it. Nobody else's deals reach this browser."
          right={<FilterCount filter={filter} />} />
        <ListFilter filter={filter} testid="rooms"
                    placeholder="deal or counterparty"
                    facetLabel="either paper" />
        {filter.shown.length === 0 ? (
          filter.filtering
            ? <NoMatch kicker="deal rooms" noun="room" />
            : <Empty
                kicker="deal rooms"
                line="No negotiation is open, so there is no room to be in."
                sub="A deal room exists for every open negotiation. Open one on the negotiate tab and its room appears here." />
        ) : (
          <div className="panel">
            <table className="ledger">
              <thead>
                <tr>
                  <th>Deal ref.</th><th>Counterparty</th><th>Paper</th>
                  <th>Conversation</th><th>Presence</th><th>Opened</th>
                </tr>
              </thead>
              <tbody>
                {filter.shown.map((n) => {
                  const deal = record.deals.rows
                    .find((d) => d.agreement_id === n.agreement_id);
                  const talk = record.comments.rows
                    .filter((c) => c.negotiation_id === n.negotiation_id);
                  const open = talk.filter((c) => !c.parent_comment_id
                    && c.state === 'open').length;
                  const { here } = presenceOf(record.visits.rows, n.negotiation_id);
                  return (
                    <tr key={n.negotiation_id}
                        {...openableRow(() => setShowing(String(n.negotiation_id)),
                          `open the deal room for ${n.agreement_id}`)}>
                      <td className="mono">{n.agreement_id}</td>
                      <td>{deal ? deal.counterparty : '—'}</td>
                      <td className="mono">{n.paper === 'ours' ? 'ours' : 'theirs'}</td>
                      <td>
                        {talk.length === 0
                          ? <span className="caption">none yet</span>
                          : <>
                              {talk.length} comments
                              {open > 0 && (
                                <span className="chip chip-pending ml-2">{open} open</span>
                              )}
                            </>}
                      </td>
                      <td>
                        {here.length > 0
                          ? <span className="chip chip-ok">{here.length} here now</span>
                          : <span className="caption">—</span>}
                      </td>
                      <td className="mono">{n.opened_on ?? '—'}</td>
                    </tr>
                  );
                })}
              </tbody>
            </table>
          </div>
        )}
      </div>
    </div>
  );
}
