blob: 8a4f7d57c6a7e0ef6b456ffeac620f3a288bdc3c [file] [log] [blame]
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001import { css, html, LitElement, render } from "lit";
Sean McCullough86b56862025-04-18 13:04:03 -07002import { unsafeHTML } from "lit/directives/unsafe-html.js";
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00003import { customElement, property, state } from "lit/decorators.js";
Sean McCulloughd9f13372025-04-21 15:08:49 -07004import { AgentMessage } from "../types";
Sean McCullough8d93e362025-04-27 23:32:18 +00005import { marked, MarkedOptions, Renderer, Tokens } from "marked";
6import mermaid from "mermaid";
Sean McCullough86b56862025-04-18 13:04:03 -07007import "./sketch-tool-calls";
8@customElement("sketch-timeline-message")
9export class SketchTimelineMessage extends LitElement {
10 @property()
Sean McCulloughd9f13372025-04-21 15:08:49 -070011 message: AgentMessage;
Sean McCullough86b56862025-04-18 13:04:03 -070012
13 @property()
Sean McCulloughd9f13372025-04-21 15:08:49 -070014 previousMessage: AgentMessage;
Sean McCullough86b56862025-04-18 13:04:03 -070015
Sean McCullough2deac842025-04-21 18:17:57 -070016 @property()
17 open: boolean = false;
18
Philip Zeyliger16fa8b42025-05-02 04:28:16 +000019 @state()
20 showInfo: boolean = false;
21
Sean McCullough86b56862025-04-18 13:04:03 -070022 // See https://lit.dev/docs/components/styles/ for how lit-element handles CSS.
23 // Note that these styles only apply to the scope of this web component's
24 // shadow DOM node, so they won't leak out or collide with CSS declared in
25 // other components or the containing web page (...unless you want it to do that).
26 static styles = css`
27 .message {
28 position: relative;
Philip Zeyliger16fa8b42025-05-02 04:28:16 +000029 margin-bottom: 6px;
30 display: flex;
31 flex-direction: column;
32 width: 100%;
Sean McCullough86b56862025-04-18 13:04:03 -070033 }
34
Philip Zeyliger16fa8b42025-05-02 04:28:16 +000035 .message-container {
36 display: flex;
37 position: relative;
38 width: 100%;
39 }
40
41 .message-metadata-left {
42 flex: 0 0 80px;
43 padding: 3px 5px;
44 text-align: right;
45 font-size: 11px;
46 color: #777;
47 align-self: flex-start;
48 }
49
50 .message-metadata-right {
51 flex: 0 0 80px;
52 padding: 3px 5px;
53 text-align: left;
54 font-size: 11px;
55 color: #777;
56 align-self: flex-start;
57 }
58
59 .message-bubble-container {
60 flex: 1;
61 display: flex;
62 max-width: calc(100% - 160px);
Philip Zeyligere31d2a92025-05-11 15:22:35 -070063 overflow: hidden;
64 text-overflow: ellipsis;
Philip Zeyliger16fa8b42025-05-02 04:28:16 +000065 }
66
67 .user .message-bubble-container {
68 justify-content: flex-end;
69 }
70
71 .agent .message-bubble-container,
72 .tool .message-bubble-container,
73 .error .message-bubble-container {
74 justify-content: flex-start;
Sean McCullough86b56862025-04-18 13:04:03 -070075 }
76
77 .message-content {
78 position: relative;
Philip Zeyliger16fa8b42025-05-02 04:28:16 +000079 padding: 6px 10px;
80 border-radius: 12px;
81 box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
Philip Zeyligere31d2a92025-05-11 15:22:35 -070082 max-width: 100%;
Philip Zeyliger16fa8b42025-05-02 04:28:16 +000083 width: fit-content;
84 min-width: min-content;
Philip Zeyligere31d2a92025-05-11 15:22:35 -070085 overflow-wrap: break-word;
86 word-break: break-word;
Philip Zeyliger16fa8b42025-05-02 04:28:16 +000087 }
88
89 /* User message styling */
90 .user .message-content {
91 background-color: #2196f3;
92 color: white;
93 border-bottom-right-radius: 5px;
94 }
95
96 /* Agent message styling */
97 .agent .message-content,
98 .tool .message-content,
99 .error .message-content {
100 background-color: #f1f1f1;
101 color: black;
102 border-bottom-left-radius: 5px;
Sean McCullough86b56862025-04-18 13:04:03 -0700103 }
104
105 /* Copy button styles */
106 .message-text-container,
107 .tool-result-container {
108 position: relative;
109 }
110
111 .message-actions {
112 position: absolute;
113 top: 5px;
114 right: 5px;
115 z-index: 10;
116 opacity: 0;
117 transition: opacity 0.2s ease;
118 }
119
120 .message-text-container:hover .message-actions,
121 .tool-result-container:hover .message-actions {
122 opacity: 1;
123 }
124
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000125 .message-actions {
Sean McCullough86b56862025-04-18 13:04:03 -0700126 display: flex;
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000127 gap: 6px;
128 }
129
130 .copy-icon,
131 .info-icon {
132 background-color: transparent;
133 border: none;
134 color: rgba(0, 0, 0, 0.6);
135 cursor: pointer;
136 padding: 3px;
137 border-radius: 50%;
138 display: flex;
139 align-items: center;
140 justify-content: center;
141 width: 24px;
142 height: 24px;
143 transition: all 0.15s ease;
144 }
145
146 .user .copy-icon,
147 .user .info-icon {
148 color: rgba(255, 255, 255, 0.8);
149 }
150
151 .copy-icon:hover,
152 .info-icon:hover {
153 background-color: rgba(0, 0, 0, 0.08);
154 }
155
156 .user .copy-icon:hover,
157 .user .info-icon:hover {
158 background-color: rgba(255, 255, 255, 0.15);
159 }
160
161 /* Message metadata styling */
162 .message-type {
163 font-weight: bold;
164 font-size: 11px;
Sean McCullough86b56862025-04-18 13:04:03 -0700165 }
166
167 .message-timestamp {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000168 display: block;
Sean McCullough86b56862025-04-18 13:04:03 -0700169 font-size: 10px;
170 color: #888;
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000171 margin-top: 2px;
172 }
173
174 .message-duration {
175 display: block;
176 font-size: 10px;
177 color: #888;
178 margin-top: 2px;
Sean McCullough86b56862025-04-18 13:04:03 -0700179 }
180
181 .message-usage {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000182 display: block;
Sean McCullough86b56862025-04-18 13:04:03 -0700183 font-size: 10px;
184 color: #888;
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000185 margin-top: 3px;
Sean McCullough86b56862025-04-18 13:04:03 -0700186 }
187
188 .conversation-id {
189 font-family: monospace;
190 font-size: 12px;
191 padding: 2px 4px;
Sean McCullough86b56862025-04-18 13:04:03 -0700192 margin-left: auto;
193 }
194
195 .parent-info {
196 font-size: 11px;
197 opacity: 0.8;
198 }
199
200 .subconversation {
201 border-left: 2px solid transparent;
202 padding-left: 5px;
203 margin-left: 20px;
204 transition: margin-left 0.3s ease;
205 }
206
207 .message-text {
208 overflow-x: auto;
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000209 margin-bottom: 0;
210 font-family: sans-serif;
211 padding: 2px 0;
Sean McCullough86b56862025-04-18 13:04:03 -0700212 user-select: text;
213 cursor: text;
214 -webkit-user-select: text;
215 -moz-user-select: text;
216 -ms-user-select: text;
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000217 font-size: 14px;
218 line-height: 1.35;
219 text-align: left;
220 }
221
222 /* Style for code blocks within messages */
223 .message-text pre,
224 .message-text code {
225 font-family: monospace;
226 background: rgba(0, 0, 0, 0.05);
227 border-radius: 4px;
228 padding: 2px 4px;
229 overflow-x: auto;
230 max-width: 100%;
231 white-space: pre-wrap; /* Allow wrapping for very long lines */
232 word-break: break-all; /* Break words at any character */
233 box-sizing: border-box; /* Include padding in width calculation */
234 }
235
Pokey Rulea10f1512025-05-15 13:53:26 +0000236 /* Code block container styles */
237 .code-block-container {
238 position: relative;
239 margin: 8px 0;
240 border-radius: 6px;
241 overflow: hidden;
242 background: rgba(0, 0, 0, 0.05);
243 }
244
245 .user .code-block-container {
246 background: rgba(255, 255, 255, 0.2);
247 }
248
249 .code-block-header {
250 display: flex;
251 justify-content: space-between;
252 align-items: center;
253 padding: 4px 8px;
254 background: rgba(0, 0, 0, 0.1);
255 font-size: 12px;
256 }
257
258 .user .code-block-header {
259 background: rgba(255, 255, 255, 0.2);
260 color: white;
261 }
262
263 .code-language {
264 font-family: monospace;
265 font-size: 11px;
266 font-weight: 500;
267 }
268
269 .code-copy-button {
270 background: transparent;
271 border: none;
272 color: inherit;
273 cursor: pointer;
274 padding: 2px;
275 border-radius: 3px;
276 display: flex;
277 align-items: center;
278 justify-content: center;
279 opacity: 0.7;
280 transition: all 0.15s ease;
281 }
282
283 .code-copy-button:hover {
284 opacity: 1;
285 background: rgba(0, 0, 0, 0.1);
286 }
287
288 .user .code-copy-button:hover {
289 background: rgba(255, 255, 255, 0.2);
290 }
291
292 .code-block-container pre {
293 margin: 0;
294 padding: 8px;
295 background: transparent;
296 }
297
298 .code-block-container code {
299 background: transparent;
300 padding: 0;
301 display: block;
302 width: 100%;
303 }
304
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000305 .user .message-text pre,
306 .user .message-text code {
307 background: rgba(255, 255, 255, 0.2);
308 color: white;
Sean McCullough86b56862025-04-18 13:04:03 -0700309 }
310
311 .tool-details {
312 margin-top: 3px;
313 padding-top: 3px;
314 border-top: 1px dashed #e0e0e0;
315 font-size: 12px;
316 }
317
318 .tool-name {
319 font-size: 12px;
320 font-weight: bold;
321 margin-bottom: 2px;
322 background: #f0f0f0;
323 padding: 2px 4px;
324 border-radius: 2px;
325 display: flex;
326 align-items: center;
327 gap: 3px;
328 }
329
330 .tool-input,
331 .tool-result {
332 margin-top: 2px;
333 padding: 3px 5px;
334 background: #f7f7f7;
335 border-radius: 2px;
336 font-family: monospace;
337 font-size: 12px;
338 overflow-x: auto;
339 white-space: pre;
340 line-height: 1.3;
341 user-select: text;
342 cursor: text;
343 -webkit-user-select: text;
344 -moz-user-select: text;
345 -ms-user-select: text;
346 }
347
348 .tool-result {
349 max-height: 300px;
350 overflow-y: auto;
351 }
352
353 .usage-info {
354 margin-top: 10px;
355 padding-top: 10px;
356 border-top: 1px dashed #e0e0e0;
357 font-size: 12px;
358 color: #666;
359 }
360
361 /* Custom styles for IRC-like experience */
362 .user .message-content {
363 border-left-color: #2196f3;
364 }
365
366 .agent .message-content {
367 border-left-color: #4caf50;
368 }
369
370 .tool .message-content {
371 border-left-color: #ff9800;
372 }
373
374 .error .message-content {
375 border-left-color: #f44336;
376 }
377
378 /* Make message type display bold but without the IRC-style markers */
379 .message-type {
380 font-weight: bold;
381 }
382
383 /* Commit message styling */
Sean McCullough86b56862025-04-18 13:04:03 -0700384 .commits-container {
385 margin-top: 10px;
Sean McCullough86b56862025-04-18 13:04:03 -0700386 }
387
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000388 .commit-notification {
389 background-color: #e8f5e9;
390 color: #2e7d32;
391 font-weight: 500;
392 font-size: 12px;
393 padding: 6px 10px;
394 border-radius: 10px;
395 margin-bottom: 8px;
396 text-align: center;
397 box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
Sean McCullough86b56862025-04-18 13:04:03 -0700398 }
399
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000400 .commit-card {
401 background-color: #f5f5f5;
402 border-radius: 8px;
Sean McCullough86b56862025-04-18 13:04:03 -0700403 overflow: hidden;
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000404 margin-bottom: 6px;
405 box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
406 padding: 6px 8px;
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +0000407 display: flex;
408 align-items: center;
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000409 gap: 8px;
Sean McCullough86b56862025-04-18 13:04:03 -0700410 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700411
Sean McCullough86b56862025-04-18 13:04:03 -0700412 .commit-hash {
413 color: #0366d6;
414 font-weight: bold;
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000415 font-family: monospace;
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +0000416 cursor: pointer;
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +0000417 text-decoration: none;
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000418 background-color: rgba(3, 102, 214, 0.08);
419 padding: 2px 5px;
420 border-radius: 4px;
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +0000421 }
422
423 .commit-hash:hover {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000424 background-color: rgba(3, 102, 214, 0.15);
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +0000425 }
426
427 .commit-branch {
428 color: #28a745;
429 font-weight: 500;
430 cursor: pointer;
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000431 font-family: monospace;
432 background-color: rgba(40, 167, 69, 0.08);
433 padding: 2px 5px;
434 border-radius: 4px;
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +0000435 }
436
437 .commit-branch:hover {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000438 background-color: rgba(40, 167, 69, 0.15);
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +0000439 }
440
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000441 .commit-subject {
442 font-size: 13px;
443 color: #333;
444 flex-grow: 1;
445 overflow: hidden;
446 text-overflow: ellipsis;
447 white-space: nowrap;
Sean McCullough86b56862025-04-18 13:04:03 -0700448 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700449
Sean McCullough86b56862025-04-18 13:04:03 -0700450 .commit-diff-button {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000451 padding: 3px 8px;
452 border: none;
453 border-radius: 4px;
454 background-color: #0366d6;
455 color: white;
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +0000456 font-size: 11px;
Sean McCullough86b56862025-04-18 13:04:03 -0700457 cursor: pointer;
458 transition: all 0.2s ease;
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000459 display: block;
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +0000460 margin-left: auto;
Sean McCullough86b56862025-04-18 13:04:03 -0700461 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700462
Sean McCullough86b56862025-04-18 13:04:03 -0700463 .commit-diff-button:hover {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000464 background-color: #0256b4;
Sean McCullough86b56862025-04-18 13:04:03 -0700465 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700466
Sean McCullough86b56862025-04-18 13:04:03 -0700467 /* Tool call cards */
468 .tool-call-cards-container {
469 display: flex;
470 flex-direction: column;
471 gap: 8px;
472 margin-top: 8px;
473 }
474
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000475 /* Error message specific styling */
476 .error .message-content {
477 background-color: #ffebee;
478 border-left: 3px solid #f44336;
Sean McCullough86b56862025-04-18 13:04:03 -0700479 }
480
481 .end-of-turn {
482 margin-bottom: 15px;
483 }
484
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000485 .end-of-turn-indicator {
486 display: block;
487 font-size: 11px;
488 color: #777;
489 padding: 2px 0;
490 margin-top: 8px;
491 text-align: right;
492 font-style: italic;
493 }
494
495 .user .end-of-turn-indicator {
496 color: rgba(255, 255, 255, 0.7);
497 }
498
499 /* Message info panel styling */
500 .message-info-panel {
501 margin-top: 8px;
502 padding: 8px;
503 background-color: rgba(0, 0, 0, 0.03);
504 border-radius: 6px;
505 font-size: 12px;
506 transition: all 0.2s ease;
507 border-left: 2px solid rgba(0, 0, 0, 0.1);
508 }
509
510 .user .message-info-panel {
511 background-color: rgba(255, 255, 255, 0.15);
512 border-left: 2px solid rgba(255, 255, 255, 0.2);
513 }
514
515 .info-row {
516 margin-bottom: 3px;
517 display: flex;
518 }
519
520 .info-label {
521 font-weight: bold;
522 margin-right: 5px;
523 min-width: 60px;
524 }
525
526 .info-value {
527 flex: 1;
528 }
529
530 .conversation-id {
531 font-family: monospace;
Sean McCullough86b56862025-04-18 13:04:03 -0700532 font-size: 10px;
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000533 word-break: break-all;
Sean McCullough86b56862025-04-18 13:04:03 -0700534 }
535
536 .markdown-content {
537 box-sizing: border-box;
538 min-width: 200px;
539 margin: 0 auto;
540 }
541
542 .markdown-content p {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000543 margin-block-start: 0.3em;
544 margin-block-end: 0.3em;
545 }
546
547 .markdown-content p:first-child {
548 margin-block-start: 0;
549 }
550
551 .markdown-content p:last-child {
552 margin-block-end: 0;
553 }
554
555 /* Styling for markdown elements */
556 .markdown-content a {
557 color: inherit;
558 text-decoration: underline;
559 }
560
561 .user .markdown-content a {
562 color: #fff;
563 text-decoration: underline;
564 }
565
566 .markdown-content ul,
567 .markdown-content ol {
568 padding-left: 1.5em;
569 margin: 0.5em 0;
570 }
571
572 .markdown-content blockquote {
573 border-left: 3px solid rgba(0, 0, 0, 0.2);
574 padding-left: 1em;
575 margin-left: 0.5em;
576 font-style: italic;
577 }
578
579 .user .markdown-content blockquote {
580 border-left: 3px solid rgba(255, 255, 255, 0.4);
Sean McCullough86b56862025-04-18 13:04:03 -0700581 }
Autoformatterdded2d62025-04-28 00:27:21 +0000582
Sean McCullough8d93e362025-04-27 23:32:18 +0000583 /* Mermaid diagram styling */
584 .mermaid-container {
585 margin: 1em 0;
586 padding: 0.5em;
587 background-color: #f8f8f8;
588 border-radius: 4px;
589 overflow-x: auto;
590 }
Autoformatterdded2d62025-04-28 00:27:21 +0000591
Sean McCullough8d93e362025-04-27 23:32:18 +0000592 .mermaid {
593 text-align: center;
594 }
Sean McCullough86b56862025-04-18 13:04:03 -0700595 `;
596
Sean McCullough8d93e362025-04-27 23:32:18 +0000597 // Track mermaid diagrams that need rendering
598 private mermaidDiagrams = new Map();
599
Sean McCullough86b56862025-04-18 13:04:03 -0700600 constructor() {
601 super();
Sean McCullough8d93e362025-04-27 23:32:18 +0000602 // Initialize mermaid with specific config
603 mermaid.initialize({
604 startOnLoad: false,
Sean McCulloughf98d7302025-04-27 17:44:06 -0700605 suppressErrorRendering: true,
Autoformatterdded2d62025-04-28 00:27:21 +0000606 theme: "default",
607 securityLevel: "loose", // Allows more flexibility but be careful with user-generated content
608 fontFamily: "monospace",
Sean McCullough8d93e362025-04-27 23:32:18 +0000609 });
Sean McCullough86b56862025-04-18 13:04:03 -0700610 }
611
612 // See https://lit.dev/docs/components/lifecycle/
613 connectedCallback() {
614 super.connectedCallback();
615 }
Autoformatterdded2d62025-04-28 00:27:21 +0000616
Sean McCullough8d93e362025-04-27 23:32:18 +0000617 // After the component is updated and rendered, render any mermaid diagrams
618 updated(changedProperties: Map<string, unknown>) {
619 super.updated(changedProperties);
620 this.renderMermaidDiagrams();
Pokey Rulea10f1512025-05-15 13:53:26 +0000621 this.setupCodeBlockCopyButtons();
Sean McCullough8d93e362025-04-27 23:32:18 +0000622 }
Autoformatterdded2d62025-04-28 00:27:21 +0000623
Sean McCullough8d93e362025-04-27 23:32:18 +0000624 // Render mermaid diagrams after the component is updated
625 renderMermaidDiagrams() {
626 // Add a small delay to ensure the DOM is fully rendered
627 setTimeout(() => {
628 // Find all mermaid containers in our shadow root
Autoformatterdded2d62025-04-28 00:27:21 +0000629 const containers = this.shadowRoot?.querySelectorAll(".mermaid");
Sean McCullough8d93e362025-04-27 23:32:18 +0000630 if (!containers || containers.length === 0) return;
Autoformatterdded2d62025-04-28 00:27:21 +0000631
Sean McCullough8d93e362025-04-27 23:32:18 +0000632 // Process each mermaid diagram
Autoformatterdded2d62025-04-28 00:27:21 +0000633 containers.forEach((container) => {
Sean McCullough8d93e362025-04-27 23:32:18 +0000634 const id = container.id;
Autoformatterdded2d62025-04-28 00:27:21 +0000635 const code = container.textContent || "";
Sean McCullough8d93e362025-04-27 23:32:18 +0000636 if (!code || !id) return; // Use return for forEach instead of continue
Autoformatterdded2d62025-04-28 00:27:21 +0000637
Sean McCullough8d93e362025-04-27 23:32:18 +0000638 try {
639 // Clear any previous content
640 container.innerHTML = code;
Autoformatterdded2d62025-04-28 00:27:21 +0000641
Sean McCullough8d93e362025-04-27 23:32:18 +0000642 // Render the mermaid diagram using promise
Autoformatterdded2d62025-04-28 00:27:21 +0000643 mermaid
644 .render(`${id}-svg`, code)
Sean McCullough8d93e362025-04-27 23:32:18 +0000645 .then(({ svg }) => {
646 container.innerHTML = svg;
647 })
Autoformatterdded2d62025-04-28 00:27:21 +0000648 .catch((err) => {
649 console.error("Error rendering mermaid diagram:", err);
Sean McCullough8d93e362025-04-27 23:32:18 +0000650 // Show the original code as fallback
651 container.innerHTML = `<pre>${code}</pre>`;
652 });
653 } catch (err) {
Autoformatterdded2d62025-04-28 00:27:21 +0000654 console.error("Error processing mermaid diagram:", err);
Sean McCullough8d93e362025-04-27 23:32:18 +0000655 // Show the original code as fallback
656 container.innerHTML = `<pre>${code}</pre>`;
657 }
658 });
659 }, 100); // Small delay to ensure DOM is ready
660 }
Sean McCullough86b56862025-04-18 13:04:03 -0700661
Pokey Rulea10f1512025-05-15 13:53:26 +0000662 // Setup code block copy buttons after component is updated
663 setupCodeBlockCopyButtons() {
664 setTimeout(() => {
665 // Find all copy buttons in code blocks
666 const copyButtons =
667 this.shadowRoot?.querySelectorAll(".code-copy-button");
668 if (!copyButtons || copyButtons.length === 0) return;
669
670 // Add click event listener to each button
671 copyButtons.forEach((button) => {
672 button.addEventListener("click", (e) => {
673 e.stopPropagation();
674 const codeId = (button as HTMLElement).dataset.codeId;
675 if (!codeId) return;
676
677 const codeElement = this.shadowRoot?.querySelector(`#${codeId}`);
678 if (!codeElement) return;
679
680 const codeText = codeElement.textContent || "";
681 const buttonRect = button.getBoundingClientRect();
682
683 // Copy code to clipboard
684 navigator.clipboard
685 .writeText(codeText)
686 .then(() => {
687 // Show success indicator
688 const originalHTML = button.innerHTML;
689 button.innerHTML = `
690 <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
691 <path d="M20 6L9 17l-5-5"></path>
692 </svg>
693 `;
694
695 // Display floating message
696 this.showFloatingMessage("Copied!", buttonRect, "success");
697
698 // Reset button after delay
699 setTimeout(() => {
700 button.innerHTML = originalHTML;
701 }, 2000);
702 })
703 .catch((err) => {
704 console.error("Failed to copy code:", err);
705 this.showFloatingMessage("Failed to copy!", buttonRect, "error");
706 });
707 });
708 });
709 }, 100); // Small delay to ensure DOM is ready
710 }
711
Sean McCullough86b56862025-04-18 13:04:03 -0700712 // See https://lit.dev/docs/components/lifecycle/
713 disconnectedCallback() {
714 super.disconnectedCallback();
715 }
716
717 renderMarkdown(markdownContent: string): string {
718 try {
Sean McCullough8d93e362025-04-27 23:32:18 +0000719 // Create a custom renderer
720 const renderer = new Renderer();
721 const originalCodeRenderer = renderer.code.bind(renderer);
Autoformatterdded2d62025-04-28 00:27:21 +0000722
Pokey Rulea10f1512025-05-15 13:53:26 +0000723 // Override the code renderer to handle mermaid diagrams and add copy buttons
Autoformatterdded2d62025-04-28 00:27:21 +0000724 renderer.code = function ({ text, lang, escaped }: Tokens.Code): string {
725 if (lang === "mermaid") {
Sean McCullough8d93e362025-04-27 23:32:18 +0000726 // Generate a unique ID for this diagram
727 const id = `mermaid-diagram-${Math.random().toString(36).substring(2, 10)}`;
Autoformatterdded2d62025-04-28 00:27:21 +0000728
Sean McCullough8d93e362025-04-27 23:32:18 +0000729 // Just create the container and mermaid div - we'll render it in the updated() lifecycle method
730 return `<div class="mermaid-container">
731 <div class="mermaid" id="${id}">${text}</div>
732 </div>`;
733 }
Pokey Rulea10f1512025-05-15 13:53:26 +0000734
735 // For regular code blocks, add a copy button
736 const id = `code-block-${Math.random().toString(36).substring(2, 10)}`;
737 const langClass = lang ? ` class="language-${lang}"` : "";
738
739 return `<div class="code-block-container">
740 <div class="code-block-header">
741 ${lang ? `<span class="code-language">${lang}</span>` : ""}
742 <button class="code-copy-button" title="Copy code" data-code-id="${id}">
743 <svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
744 <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
745 <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
746 </svg>
747 </button>
748 </div>
749 <pre><code id="${id}"${langClass}>${text}</code></pre>
750 </div>`;
Sean McCullough8d93e362025-04-27 23:32:18 +0000751 };
Autoformatterdded2d62025-04-28 00:27:21 +0000752
Sean McCullough86b56862025-04-18 13:04:03 -0700753 // Set markdown options for proper code block highlighting and safety
754 const markedOptions: MarkedOptions = {
755 gfm: true, // GitHub Flavored Markdown
756 breaks: true, // Convert newlines to <br>
757 async: false,
Autoformatterdded2d62025-04-28 00:27:21 +0000758 renderer: renderer,
Sean McCullough86b56862025-04-18 13:04:03 -0700759 // DOMPurify is recommended for production, but not included in this implementation
760 };
761 return marked.parse(markdownContent, markedOptions) as string;
762 } catch (error) {
763 console.error("Error rendering markdown:", error);
764 // Fallback to plain text if markdown parsing fails
765 return markdownContent;
766 }
767 }
768
769 /**
770 * Format timestamp for display
771 */
772 formatTimestamp(
773 timestamp: string | number | Date | null | undefined,
774 defaultValue: string = "",
775 ): string {
776 if (!timestamp) return defaultValue;
777 try {
778 const date = new Date(timestamp);
779 if (isNaN(date.getTime())) return defaultValue;
780
781 // Format: Mar 13, 2025 09:53:25 AM
782 return date.toLocaleString("en-US", {
783 month: "short",
784 day: "numeric",
785 year: "numeric",
786 hour: "numeric",
787 minute: "2-digit",
788 second: "2-digit",
789 hour12: true,
790 });
791 } catch (e) {
792 return defaultValue;
793 }
794 }
795
796 formatNumber(
797 num: number | null | undefined,
798 defaultValue: string = "0",
799 ): string {
800 if (num === undefined || num === null) return defaultValue;
801 try {
802 return num.toLocaleString();
803 } catch (e) {
804 return String(num);
805 }
806 }
807 formatCurrency(
808 num: number | string | null | undefined,
809 defaultValue: string = "$0.00",
810 isMessageLevel: boolean = false,
811 ): string {
812 if (num === undefined || num === null) return defaultValue;
813 try {
814 // Use 4 decimal places for message-level costs, 2 for totals
815 const decimalPlaces = isMessageLevel ? 4 : 2;
816 return `$${parseFloat(String(num)).toFixed(decimalPlaces)}`;
817 } catch (e) {
818 return defaultValue;
819 }
820 }
821
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000822 // Format duration from nanoseconds to a human-readable string
823 _formatDuration(nanoseconds: number | null | undefined): string {
824 if (!nanoseconds) return "0s";
825
826 const seconds = nanoseconds / 1e9;
827
828 if (seconds < 60) {
829 return `${seconds.toFixed(1)}s`;
830 } else if (seconds < 3600) {
831 const minutes = Math.floor(seconds / 60);
832 const remainingSeconds = seconds % 60;
833 return `${minutes}min ${remainingSeconds.toFixed(0)}s`;
834 } else {
835 const hours = Math.floor(seconds / 3600);
836 const remainingSeconds = seconds % 3600;
837 const minutes = Math.floor(remainingSeconds / 60);
838 return `${hours}h ${minutes}min`;
839 }
840 }
841
Sean McCullough86b56862025-04-18 13:04:03 -0700842 showCommit(commitHash: string) {
Sean McCullough71941bd2025-04-18 13:31:48 -0700843 this.dispatchEvent(
844 new CustomEvent("show-commit-diff", {
845 bubbles: true,
846 composed: true,
847 detail: { commitHash },
848 }),
849 );
Sean McCullough86b56862025-04-18 13:04:03 -0700850 }
851
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000852 _toggleInfo(e: Event) {
853 e.stopPropagation();
854 this.showInfo = !this.showInfo;
855 }
856
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +0000857 copyToClipboard(text: string, event: Event) {
858 const element = event.currentTarget as HTMLElement;
859 const rect = element.getBoundingClientRect();
860
861 navigator.clipboard
862 .writeText(text)
863 .then(() => {
864 this.showFloatingMessage("Copied!", rect, "success");
865 })
866 .catch((err) => {
867 console.error("Failed to copy text: ", err);
868 this.showFloatingMessage("Failed to copy!", rect, "error");
869 });
870 }
871
872 showFloatingMessage(
873 message: string,
874 targetRect: DOMRect,
875 type: "success" | "error",
876 ) {
877 // Create floating message element
878 const floatingMsg = document.createElement("div");
879 floatingMsg.textContent = message;
880 floatingMsg.className = `floating-message ${type}`;
881
882 // Position it near the clicked element
883 // Position just above the element
884 const top = targetRect.top - 30;
885 const left = targetRect.left + targetRect.width / 2 - 40;
886
887 floatingMsg.style.position = "fixed";
888 floatingMsg.style.top = `${top}px`;
889 floatingMsg.style.left = `${left}px`;
890 floatingMsg.style.zIndex = "9999";
891
892 // Add to document body
893 document.body.appendChild(floatingMsg);
894
895 // Animate in
896 floatingMsg.style.opacity = "0";
897 floatingMsg.style.transform = "translateY(10px)";
898
899 setTimeout(() => {
900 floatingMsg.style.opacity = "1";
901 floatingMsg.style.transform = "translateY(0)";
902 }, 10);
903
904 // Remove after animation
905 setTimeout(() => {
906 floatingMsg.style.opacity = "0";
907 floatingMsg.style.transform = "translateY(-10px)";
908
909 setTimeout(() => {
910 document.body.removeChild(floatingMsg);
911 }, 300);
912 }, 1500);
913 }
914
Sean McCullough86b56862025-04-18 13:04:03 -0700915 render() {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000916 // Calculate if this is an end of turn message with no parent conversation ID
917 const isEndOfTurn =
918 this.message?.end_of_turn && !this.message?.parent_conversation_id;
919
Sean McCullough86b56862025-04-18 13:04:03 -0700920 return html`
921 <div
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000922 class="message ${this.message?.type} ${isEndOfTurn
Sean McCullough86b56862025-04-18 13:04:03 -0700923 ? "end-of-turn"
924 : ""}"
925 >
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000926 <div class="message-container">
927 <!-- Left area (empty for simplicity) -->
928 <div class="message-metadata-left"></div>
929
930 <!-- Message bubble -->
931 <div class="message-bubble-container">
932 <div class="message-content">
933 <div class="message-text-container">
934 <div class="message-actions">
935 ${copyButton(this.message?.content)}
936 <button
937 class="info-icon"
938 title="Show message details"
939 @click=${this._toggleInfo}
Sean McCullough71941bd2025-04-18 13:31:48 -0700940 >
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000941 <svg
942 xmlns="http://www.w3.org/2000/svg"
943 width="16"
944 height="16"
945 viewBox="0 0 24 24"
946 fill="none"
947 stroke="currentColor"
948 stroke-width="2"
949 stroke-linecap="round"
950 stroke-linejoin="round"
951 >
952 <circle cx="12" cy="12" r="10"></circle>
953 <line x1="12" y1="16" x2="12" y2="12"></line>
954 <line x1="12" y1="8" x2="12.01" y2="8"></line>
955 </svg>
956 </button>
957 </div>
958 ${this.message?.content
959 ? html`
960 <div class="message-text markdown-content">
961 ${unsafeHTML(
962 this.renderMarkdown(this.message?.content),
963 )}
964 </div>
965 `
966 : ""}
967
968 <!-- End of turn indicator inside the bubble -->
969 ${isEndOfTurn && this.message?.elapsed
970 ? html`
971 <div class="end-of-turn-indicator">
972 end of turn
973 (${this._formatDuration(this.message?.elapsed)})
974 </div>
975 `
976 : ""}
977
978 <!-- Info panel that can be toggled -->
979 ${this.showInfo
980 ? html`
981 <div class="message-info-panel">
982 <div class="info-row">
983 <span class="info-label">Type:</span>
984 <span class="info-value">${this.message?.type}</span>
985 </div>
986 <div class="info-row">
987 <span class="info-label">Time:</span>
988 <span class="info-value"
989 >${this.formatTimestamp(
990 this.message?.timestamp,
991 "",
992 )}</span
993 >
994 </div>
995 ${this.message?.elapsed
996 ? html`
997 <div class="info-row">
998 <span class="info-label">Duration:</span>
999 <span class="info-value"
1000 >${this._formatDuration(
1001 this.message?.elapsed,
1002 )}</span
1003 >
1004 </div>
1005 `
1006 : ""}
1007 ${this.message?.usage
1008 ? html`
1009 <div class="info-row">
1010 <span class="info-label">Tokens:</span>
1011 <span class="info-value">
1012 ${this.message?.usage
1013 ? html`
1014 <div>
1015 Input:
1016 ${this.formatNumber(
1017 this.message?.usage?.input_tokens ||
1018 0,
1019 )}
1020 </div>
1021 ${this.message?.usage
1022 ?.cache_creation_input_tokens
1023 ? html`
1024 <div>
1025 Cache creation:
1026 ${this.formatNumber(
1027 this.message?.usage
1028 ?.cache_creation_input_tokens,
1029 )}
1030 </div>
1031 `
1032 : ""}
1033 ${this.message?.usage
1034 ?.cache_read_input_tokens
1035 ? html`
1036 <div>
1037 Cache read:
1038 ${this.formatNumber(
1039 this.message?.usage
1040 ?.cache_read_input_tokens,
1041 )}
1042 </div>
1043 `
1044 : ""}
1045 <div>
1046 Output:
1047 ${this.formatNumber(
1048 this.message?.usage?.output_tokens,
1049 )}
1050 </div>
1051 <div>
1052 Cost:
1053 ${this.formatCurrency(
1054 this.message?.usage?.cost_usd,
1055 )}
1056 </div>
1057 `
1058 : "N/A"}
1059 </span>
1060 </div>
1061 `
1062 : ""}
1063 ${this.message?.conversation_id
1064 ? html`
1065 <div class="info-row">
1066 <span class="info-label">Conversation ID:</span>
1067 <span class="info-value conversation-id"
1068 >${this.message?.conversation_id}</span
1069 >
1070 </div>
1071 `
1072 : ""}
1073 </div>
1074 `
1075 : ""}
1076 </div>
1077
1078 <!-- Tool calls - only shown for agent messages -->
1079 ${this.message?.type === "agent"
1080 ? html`
1081 <sketch-tool-calls
1082 .toolCalls=${this.message?.tool_calls}
1083 .open=${this.open}
1084 ></sketch-tool-calls>
1085 `
1086 : ""}
1087
1088 <!-- Commits section (redesigned as bubbles) -->
1089 ${this.message?.commits
1090 ? html`
1091 <div class="commits-container">
1092 <div class="commit-notification">
1093 ${this.message.commits.length} new
1094 commit${this.message.commits.length > 1 ? "s" : ""}
1095 detected
1096 </div>
1097 ${this.message.commits.map((commit) => {
1098 return html`
1099 <div class="commit-card">
Philip Zeyliger72682df2025-04-23 13:09:46 -07001100 <span
1101 class="commit-hash"
1102 title="Click to copy: ${commit.hash}"
1103 @click=${(e) =>
1104 this.copyToClipboard(
1105 commit.hash.substring(0, 8),
1106 e,
1107 )}
1108 >
Pokey Rule7be879f2025-04-23 15:30:15 +01001109 ${commit.hash.substring(0, 8)}
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +00001110 </span>
1111 ${commit.pushed_branch
1112 ? html`
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001113 <span
1114 class="commit-branch pushed-branch"
1115 title="Click to copy: ${commit.pushed_branch}"
1116 @click=${(e) =>
1117 this.copyToClipboard(
1118 commit.pushed_branch,
1119 e,
1120 )}
1121 >${commit.pushed_branch}</span
1122 >
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +00001123 `
1124 : ``}
1125 <span class="commit-subject"
1126 >${commit.subject}</span
Sean McCullough71941bd2025-04-18 13:31:48 -07001127 >
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +00001128 <button
1129 class="commit-diff-button"
1130 @click=${() => this.showCommit(commit.hash)}
1131 >
1132 View Diff
1133 </button>
Sean McCullough86b56862025-04-18 13:04:03 -07001134 </div>
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001135 `;
1136 })}
1137 </div>
1138 `
1139 : ""}
1140 </div>
1141 </div>
1142
1143 <!-- Right side (empty for consistency) -->
1144 <div class="message-metadata-right"></div>
Sean McCullough86b56862025-04-18 13:04:03 -07001145 </div>
1146 </div>
1147 `;
1148 }
1149}
1150
Sean McCullough71941bd2025-04-18 13:31:48 -07001151function copyButton(textToCopy: string) {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001152 // Use an icon of overlapping rectangles for copy
1153 const buttonClass = "copy-icon";
1154
1155 // SVG for copy icon (two overlapping rectangles)
1156 const copyIcon = html`<svg
1157 xmlns="http://www.w3.org/2000/svg"
1158 width="16"
1159 height="16"
1160 viewBox="0 0 24 24"
1161 fill="none"
1162 stroke="currentColor"
1163 stroke-width="2"
1164 stroke-linecap="round"
1165 stroke-linejoin="round"
1166 >
1167 <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
1168 <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
1169 </svg>`;
1170
1171 // SVG for success check mark
1172 const successIcon = html`<svg
1173 xmlns="http://www.w3.org/2000/svg"
1174 width="16"
1175 height="16"
1176 viewBox="0 0 24 24"
1177 fill="none"
1178 stroke="currentColor"
1179 stroke-width="2"
1180 stroke-linecap="round"
1181 stroke-linejoin="round"
1182 >
1183 <path d="M20 6L9 17l-5-5"></path>
1184 </svg>`;
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +00001185
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001186 const ret = html`<button
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +00001187 class="${buttonClass}"
1188 title="Copy to clipboard"
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001189 @click=${(e: Event) => {
1190 e.stopPropagation();
1191 const copyButton = e.currentTarget as HTMLButtonElement;
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001192 const originalInnerHTML = copyButton.innerHTML;
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001193 navigator.clipboard
1194 .writeText(textToCopy)
1195 .then(() => {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001196 copyButton.innerHTML = "";
1197 const successElement = document.createElement("div");
1198 copyButton.appendChild(successElement);
1199 render(successIcon, successElement);
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001200 setTimeout(() => {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001201 copyButton.innerHTML = originalInnerHTML;
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001202 }, 2000);
1203 })
1204 .catch((err) => {
1205 console.error("Failed to copy text: ", err);
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001206 setTimeout(() => {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001207 copyButton.innerHTML = originalInnerHTML;
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001208 }, 2000);
1209 });
1210 }}
1211 >
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001212 ${copyIcon}
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001213 </button>`;
Sean McCullough86b56862025-04-18 13:04:03 -07001214
Sean McCullough71941bd2025-04-18 13:31:48 -07001215 return ret;
Sean McCullough86b56862025-04-18 13:04:03 -07001216}
1217
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +00001218// Create global styles for floating messages
1219const floatingMessageStyles = document.createElement("style");
1220floatingMessageStyles.textContent = `
1221 .floating-message {
1222 background-color: rgba(0, 0, 0, 0.8);
1223 color: white;
1224 padding: 5px 10px;
1225 border-radius: 4px;
1226 font-size: 12px;
1227 font-family: system-ui, sans-serif;
1228 box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
1229 pointer-events: none;
1230 transition: opacity 0.3s ease, transform 0.3s ease;
1231 }
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +00001232
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +00001233 .floating-message.success {
1234 background-color: rgba(40, 167, 69, 0.9);
1235 }
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +00001236
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +00001237 .floating-message.error {
1238 background-color: rgba(220, 53, 69, 0.9);
1239 }
Philip Zeyligere31d2a92025-05-11 15:22:35 -07001240
1241 /* Style for code, pre elements, and tool components to ensure proper wrapping/truncation */
1242 pre, code, sketch-tool-calls, sketch-tool-card, sketch-tool-card-bash {
1243 white-space: nowrap;
1244 overflow: hidden;
1245 text-overflow: ellipsis;
1246 max-width: 100%;
1247 }
1248
1249 /* Special rule for the message content container */
1250 .message-content {
1251 max-width: 100% !important;
1252 overflow: hidden !important;
1253 }
1254
1255 /* Ensure tool call containers don't overflow */
1256 ::slotted(sketch-tool-calls) {
1257 max-width: 100%;
1258 width: 100%;
1259 overflow-wrap: break-word;
1260 word-break: break-word;
1261 }
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +00001262`;
1263document.head.appendChild(floatingMessageStyles);
1264
Sean McCullough86b56862025-04-18 13:04:03 -07001265declare global {
1266 interface HTMLElementTagNameMap {
1267 "sketch-timeline-message": SketchTimelineMessage;
1268 }
1269}