File size: 9,146 Bytes
7c5ed63
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
import { useState, type ReactNode } from 'react';

import type {
  ContentPart,
  ProjectedMessage,
  ToolCall,
} from '../../types';
import { ImagePreview } from '../shared/ImagePreview';
import { Pill } from '../shared/Pill';

interface MessageBubbleProps {
  message: ProjectedMessage;
}

export function MessageBubble({ message }: MessageBubbleProps) {
  const role = message.message.role;
  if (role === 'user') return <UserBubble m={message} />;
  if (role === 'assistant') return <AssistantBubble m={message} />;
  if (role === 'tool') return <ToolBubble m={message} />;
  return <SystemBubble m={message} />;
}

function baseClass(): string {
  return 'relative flex max-w-full min-w-0 flex-col border-l-[3px] bg-surface-1 px-3 py-2';
}

function UserBubble({ m }: { m: ProjectedMessage }) {
  const origin = m.message.origin;
  const originKind = origin?.kind;
  // Badge every origin that is not a plain user prompt. This covers
  // skill_activation, task (v2; v1: background_task), cron_job, cron_missed,
  // retry, system_trigger, injection, hook_result, compaction_summary, etc.
  const showsOriginBadge = originKind !== undefined && originKind !== 'user';
  return (
    <article className={baseClass()} style={{ borderLeftColor: 'var(--color-user)' }}>
      <header className="mb-1 flex items-center gap-2">
        <Pill tone="user" variant="solid">user</Pill>
        <span className="font-mono text-[10px] text-fg-3 tabular">line {m.lineNo}</span>
        {showsOriginBadge ? (
          <Pill tone="meta" variant="outline">{originKind}</Pill>
        ) : null}
        {m.message.isError ? <Pill tone="error" variant="outline">error</Pill> : null}
      </header>
      <MessageContent parts={m.message.content} />
    </article>
  );
}

function AssistantBubble({ m }: { m: ProjectedMessage }) {
  const thinkPart = m.message.content.find((p) => p.type === 'think');
  const think = thinkPart && thinkPart.type === 'think' ? thinkPart.think : undefined;
  const visibleParts = m.message.content.filter((p) => p.type !== 'think');
  const toolCalls = m.message.toolCalls;
  return (
    <article className={baseClass()} style={{ borderLeftColor: 'var(--color-assistant)' }}>
      <header className="mb-1 flex items-center gap-2">
        <Pill tone="assistant" variant="solid">assistant</Pill>
        <span className="font-mono text-[10px] text-fg-3 tabular">line {m.lineNo}</span>
        {think ? <Pill tone="config" variant="outline">think</Pill> : null}
        {toolCalls.length > 0 ? (
          <Pill tone="tools" variant="outline">
            {toolCalls.length} tool call{toolCalls.length > 1 ? 's' : ''}
          </Pill>
        ) : null}
        {m.message.partial ? <Pill tone="warning" variant="outline">partial</Pill> : null}
      </header>
      {think ? <ThinkBlock text={think} /> : null}
      <MessageContent parts={visibleParts} />
      {toolCalls.length > 0 ? (
        <div className="mt-2 space-y-1">
          {toolCalls.map((tc) => (
            <ToolCallCard key={tc.id} call={tc} />
          ))}
        </div>
      ) : null}
    </article>
  );
}

function ToolBubble({ m }: { m: ProjectedMessage }) {
  // Tool outputs are often huge (file contents, command stdout). Collapse
  // by default so the conversation flow stays readable. Errors open by
  // default — that's the case where the user actually needs to read.
  const [open, setOpen] = useState(m.message.isError === true);
  const totalChars = m.message.content.reduce((acc, p) => {
    if (p.type === 'text') return acc + p.text.length;
    return acc;
  }, 0);
  const preview = firstTextPreview(m.message.content);
  return (
    <article className={baseClass()} style={{ borderLeftColor: 'var(--color-tool)' }}>
      <button
        type="button"
        onClick={() => {
          setOpen((v) => !v);
        }}
        className="flex w-full items-center gap-2 text-left"
      >
        <span className="text-fg-3">{open ? '▾' : '▸'}</span>
        <Pill tone="tool" variant="solid">tool</Pill>
        {m.message.toolCallId ? (
          <span className="font-mono text-[11px] text-fg-1">
            call {m.message.toolCallId.slice(0, 12)}
          </span>
        ) : null}
        <span className="font-mono text-[10px] text-fg-3 tabular">line {m.lineNo}</span>
        {m.message.isError ? <Pill tone="error" variant="outline">error</Pill> : null}
        {!open ? (
          <span className="ml-1 flex min-w-0 flex-1 items-center gap-2 font-mono text-[11px] text-fg-3">
            <span className="truncate">{preview}</span>
            <span className="shrink-0 tabular">
              · {totalChars.toLocaleString()} chars
            </span>
          </span>
        ) : null}
      </button>
      {open ? (
        <div className="mt-2 max-h-[60vh] overflow-auto">
          <MessageContent parts={m.message.content} />
        </div>
      ) : null}
    </article>
  );
}

function SystemBubble({ m }: { m: ProjectedMessage }) {
  return (
    <article className={baseClass()} style={{ borderLeftColor: 'var(--color-cat-config)' }}>
      <header className="mb-1 flex items-center gap-2">
        <Pill tone="config" variant="solid">system</Pill>
        <span className="font-mono text-[10px] text-fg-3 tabular">line {m.lineNo}</span>
      </header>
      <MessageContent parts={m.message.content} />
    </article>
  );
}

function ThinkBlock({ text }: { text: string }) {
  const [open, setOpen] = useState(false);
  return (
    <div className="mb-2 border border-border bg-surface-0">
      <button
        onClick={() => {
          setOpen((v) => !v);
        }}
        className="flex w-full items-center gap-2 px-2 py-1 text-left font-mono text-[11px] text-fg-2 hover:text-fg-1"
      >
        <span className="text-fg-3">{open ? '▾' : '▸'}</span>
        <span className="uppercase tracking-[0.08em]">thinking</span>
        <span className="text-fg-3 tabular">{text.length}ch</span>
      </button>
      {open ? (
        <pre className="border-t border-border px-2 py-1 whitespace-pre-wrap break-words font-mono text-[12px] text-fg-1">
          {text}
        </pre>
      ) : null}
    </div>
  );
}

function ToolCallCard({ call }: { call: ToolCall }) {
  const [open, setOpen] = useState(false);
  const argsStr = call.arguments ?? '';
  return (
    <div className="border border-border bg-surface-0">
      <button
        onClick={() => {
          setOpen((v) => !v);
        }}
        className="flex w-full items-center gap-2 px-2 py-1 text-left font-mono text-[11px] hover:bg-surface-2"
      >
        <span className="text-fg-3">{open ? '▾' : '▸'}</span>
        <Pill tone="tools" variant="soft">call</Pill>
        <span className="text-fg-0">{call.name}</span>
        <span className="truncate text-fg-3">{truncate(argsStr, 80)}</span>
        <span className="ml-auto text-fg-3 tabular text-[10px]">{call.id.slice(0, 10)}</span>
      </button>
      {open ? (
        <pre className="border-t border-border px-2 py-1 whitespace-pre-wrap break-words font-mono text-[12px] text-fg-1">
          {prettyJson(argsStr)}
        </pre>
      ) : null}
    </div>
  );
}

function MessageContent({ parts }: { parts: readonly ContentPart[] }): ReactNode {
  return (
    <div className="space-y-2">
      {parts.map((p, i) => {
        if (p.type === 'text') {
          return (
            <pre
              key={i}
              className="whitespace-pre-wrap break-words font-mono text-[12.5px] leading-[1.55] text-fg-0"
            >
              {p.text}
            </pre>
          );
        }
        if (p.type === 'think') {
          return <ThinkBlock key={i} text={p.think} />;
        }
        if (p.type === 'image_url') {
          return <ImagePreview key={i} url={p.imageUrl.url} />;
        }
        if (p.type === 'audio_url') {
          return (
            <div key={i} className="font-mono text-[11px] text-fg-2">
              [audio: {p.audioUrl.url}]
            </div>
          );
        }
        if (p.type === 'video_url') {
          return (
            <div key={i} className="font-mono text-[11px] text-fg-2">
              [video: {p.videoUrl.url ?? '—'}]
            </div>
          );
        }
        // Exhaustive — anything else is unexpected for ContentPart.
        return (
          <div key={i} className="font-mono text-[11px] text-fg-3">
            [{(p as { type: string }).type}]
          </div>
        );
      })}
    </div>
  );
}

function truncate(s: string, n: number): string {
  return s.length <= n ? s : s.slice(0, n) + '…';
}

function firstTextPreview(parts: readonly ContentPart[]): string {
  for (const p of parts) {
    if (p.type === 'text' && p.text.length > 0) {
      const firstLine = p.text.split('\n', 1)[0] ?? '';
      return truncate(firstLine, 100);
    }
    if (p.type === 'image_url') return '[image]';
    if (p.type === 'audio_url') return '[audio]';
    if (p.type === 'video_url') return '[video]';
  }
  return '(empty)';
}

function prettyJson(s: string): string {
  try {
    return JSON.stringify(JSON.parse(s), null, 2);
  } catch {
    return s;
  }
}