File size: 5,514 Bytes
a763505
c44fab6
 
a763505
c44fab6
0bb4dfa
 
 
 
 
 
 
 
 
 
 
 
 
 
 
c44fab6
0bb4dfa
 
 
 
a763505
 
 
 
 
 
 
 
 
 
 
0bb4dfa
11bf9b7
 
 
 
 
a763505
 
 
 
 
 
 
 
11bf9b7
 
 
0bb4dfa
 
a763505
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
11bf9b7
 
a763505
c44fab6
a763505
 
 
 
 
0bb4dfa
 
 
 
c44fab6
 
0bb4dfa
 
 
 
 
 
 
a763505
 
 
 
 
 
 
 
 
 
 
0bb4dfa
a763505
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
0bb4dfa
 
c44fab6
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
import React, { useCallback } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { ArrowRight } from 'lucide-react';

const PALETTE = [
  { color: '#6366F1', bg: '#EEF2FF' },   // indigo
  { color: '#059669', bg: '#ECFDF5' },   // emerald
  { color: '#D97706', bg: '#FFFBEB' },   // amber
  { color: '#DC2626', bg: '#FEE2E2' },   // red
  { color: '#0891B2', bg: '#ECFEFF' },   // cyan
  { color: '#7C3AED', bg: '#F5F3FF' },   // violet
  { color: '#0D9488', bg: '#F0FDFA' },   // teal
  { color: '#DB2777', bg: '#FDF2F8' },   // pink
  { color: '#65A30D', bg: '#F7FEE7' },   // lime
];

function colorForIdx(idx) {
  return PALETTE[idx % PALETTE.length];
}

/**
 * Generic participant bubble. The CCAI demo can have up to 9 active
 * participants, so we colorize by their index in the active roster
 * rather than the original A/B scheme.
 *
 * Conversation-tracking enhancements:
 *   - "→ Addressee" arrow chip in the speaker line when this message
 *     is aimed at a specific other participant. The chip is clickable
 *     and scrolls the chat to the addressee's most recent message
 *     before this one (with a brief flash highlight).
 *   - "Replying to: X, Y" pill above the bubble whenever the orchestrator
 *     told this participant they had open threads owed before they spoke.
 *   - Light left-indent + thread line when a message is a direct reply to
 *     the immediately previous bubble - cheap visual threading without
 *     full nesting.
 */
// Green tone used for in-the-loop human participants. Overrides the
// rotating palette so a human's bubble always reads as "the human
// one" no matter where they fall in the active roster ordering.
const HUMAN_TONE = { color: '#16A34A', bg: '#F0FDF4' };

export default function MessageBubble({
  message,
  idx,
  messageIdx,
  prevMessage,
  participantNameById,
  showResponseTime,
}) {
  const isHuman = message.kind === 'human'
    || (message.model_display === 'Human participant');
  const tone = isHuman ? HUMAN_TONE : colorForIdx(idx);
  const initial = (message.speaker_name || '?').charAt(0).toUpperCase();
  const elapsed = message.elapsed_seconds;

  const addresseeId = message.addressed_to || null;
  const addresseeName = addresseeId
    ? (participantNameById?.[addresseeId] || addresseeId)
    : null;

  const replyingToNames = (message.replying_to || [])
    .map((pid) => participantNameById?.[pid])
    .filter(Boolean);

  // Direct reply to the immediately previous participant message gets
  // a light indent + thread line. Skips orchestrator messages.
  const isDirectReply =
    !!addresseeId &&
    prevMessage &&
    prevMessage.role === 'participant' &&
    prevMessage.speaker_id === addresseeId;

  const onAddresseeClick = useCallback(() => {
    if (!addresseeId) return;
    const all = document.querySelectorAll('[data-msg-idx][data-speaker-id]');
    let candidate = null;
    for (const el of all) {
      const eIdx = parseInt(el.getAttribute('data-msg-idx'), 10);
      if (Number.isNaN(eIdx)) continue;
      if (eIdx >= messageIdx) break;
      if (el.getAttribute('data-speaker-id') === addresseeId) {
        candidate = el;
      }
    }
    if (candidate) {
      candidate.scrollIntoView({ behavior: 'smooth', block: 'center' });
      candidate.classList.remove('ccai-flash-highlight');
      void candidate.offsetWidth;
      candidate.classList.add('ccai-flash-highlight');
      setTimeout(
        () => candidate.classList.remove('ccai-flash-highlight'),
        1500,
      );
    }
  }, [addresseeId, messageIdx]);

  const rowClassName =
    'message-row ccai-message-row' +
    (isDirectReply ? ' ccai-message-row-reply' : '') +
    (isHuman ? ' ccai-message-row-human' : '');

  return (
    <div
      className={rowClassName}
      data-msg-idx={messageIdx}
      data-speaker-id={message.speaker_id || ''}
    >
      <div
        className="avatar"
        style={{ background: tone.color, borderRadius: '50%' }}
      >
        {initial}
      </div>
      <div
        className="message-bubble ccai-bubble"
        style={{
          background: tone.bg,
          border: `1px solid ${tone.color}33`,
        }}
      >
        {replyingToNames.length > 0 && (
          <div
            className="ccai-replying-to-pill"
            style={{
              borderColor: `${tone.color}66`,
              color: tone.color,
            }}
          >
            Replying to: {replyingToNames.join(', ')}
          </div>
        )}
        <div className="message-speaker" style={{ color: tone.color }}>
          <span>{message.speaker_name}</span>
          {addresseeName && (
            <span className="ccai-addressee-wrap">
              <ArrowRight
                size={12}
                strokeWidth={2.5}
                className="ccai-addressee-arrow"
              />
              <button
                type="button"
                className="ccai-addressee-link"
                style={{ color: tone.color }}
                onClick={onAddresseeClick}
                title={`Jump to ${addresseeName}'s most recent message`}
              >
                {addresseeName}
              </button>
            </span>
          )}
        </div>
        <ReactMarkdown remarkPlugins={[remarkGfm]}>
          {message.text}
        </ReactMarkdown>
        {showResponseTime && elapsed > 0 && (
          <div className="message-elapsed">{elapsed.toFixed(1)}s</div>
        )}
      </div>
    </div>
  );
}