blob: 1edaff2fa0cdfca0603c171233c9b5d72329abd6 [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
236 .user .message-text pre,
237 .user .message-text code {
238 background: rgba(255, 255, 255, 0.2);
239 color: white;
Sean McCullough86b56862025-04-18 13:04:03 -0700240 }
241
242 .tool-details {
243 margin-top: 3px;
244 padding-top: 3px;
245 border-top: 1px dashed #e0e0e0;
246 font-size: 12px;
247 }
248
249 .tool-name {
250 font-size: 12px;
251 font-weight: bold;
252 margin-bottom: 2px;
253 background: #f0f0f0;
254 padding: 2px 4px;
255 border-radius: 2px;
256 display: flex;
257 align-items: center;
258 gap: 3px;
259 }
260
261 .tool-input,
262 .tool-result {
263 margin-top: 2px;
264 padding: 3px 5px;
265 background: #f7f7f7;
266 border-radius: 2px;
267 font-family: monospace;
268 font-size: 12px;
269 overflow-x: auto;
270 white-space: pre;
271 line-height: 1.3;
272 user-select: text;
273 cursor: text;
274 -webkit-user-select: text;
275 -moz-user-select: text;
276 -ms-user-select: text;
277 }
278
279 .tool-result {
280 max-height: 300px;
281 overflow-y: auto;
282 }
283
284 .usage-info {
285 margin-top: 10px;
286 padding-top: 10px;
287 border-top: 1px dashed #e0e0e0;
288 font-size: 12px;
289 color: #666;
290 }
291
292 /* Custom styles for IRC-like experience */
293 .user .message-content {
294 border-left-color: #2196f3;
295 }
296
297 .agent .message-content {
298 border-left-color: #4caf50;
299 }
300
301 .tool .message-content {
302 border-left-color: #ff9800;
303 }
304
305 .error .message-content {
306 border-left-color: #f44336;
307 }
308
309 /* Make message type display bold but without the IRC-style markers */
310 .message-type {
311 font-weight: bold;
312 }
313
314 /* Commit message styling */
Sean McCullough86b56862025-04-18 13:04:03 -0700315 .commits-container {
316 margin-top: 10px;
Sean McCullough86b56862025-04-18 13:04:03 -0700317 }
318
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000319 .commit-notification {
320 background-color: #e8f5e9;
321 color: #2e7d32;
322 font-weight: 500;
323 font-size: 12px;
324 padding: 6px 10px;
325 border-radius: 10px;
326 margin-bottom: 8px;
327 text-align: center;
328 box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
Sean McCullough86b56862025-04-18 13:04:03 -0700329 }
330
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000331 .commit-card {
332 background-color: #f5f5f5;
333 border-radius: 8px;
Sean McCullough86b56862025-04-18 13:04:03 -0700334 overflow: hidden;
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000335 margin-bottom: 6px;
336 box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
337 padding: 6px 8px;
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +0000338 display: flex;
339 align-items: center;
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000340 gap: 8px;
Sean McCullough86b56862025-04-18 13:04:03 -0700341 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700342
Sean McCullough86b56862025-04-18 13:04:03 -0700343 .commit-hash {
344 color: #0366d6;
345 font-weight: bold;
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000346 font-family: monospace;
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +0000347 cursor: pointer;
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +0000348 text-decoration: none;
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000349 background-color: rgba(3, 102, 214, 0.08);
350 padding: 2px 5px;
351 border-radius: 4px;
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +0000352 }
353
354 .commit-hash:hover {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000355 background-color: rgba(3, 102, 214, 0.15);
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +0000356 }
357
358 .commit-branch {
359 color: #28a745;
360 font-weight: 500;
361 cursor: pointer;
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000362 font-family: monospace;
363 background-color: rgba(40, 167, 69, 0.08);
364 padding: 2px 5px;
365 border-radius: 4px;
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +0000366 }
367
368 .commit-branch:hover {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000369 background-color: rgba(40, 167, 69, 0.15);
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +0000370 }
371
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000372 .commit-subject {
373 font-size: 13px;
374 color: #333;
375 flex-grow: 1;
376 overflow: hidden;
377 text-overflow: ellipsis;
378 white-space: nowrap;
Sean McCullough86b56862025-04-18 13:04:03 -0700379 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700380
Sean McCullough86b56862025-04-18 13:04:03 -0700381 .commit-diff-button {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000382 padding: 3px 8px;
383 border: none;
384 border-radius: 4px;
385 background-color: #0366d6;
386 color: white;
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +0000387 font-size: 11px;
Sean McCullough86b56862025-04-18 13:04:03 -0700388 cursor: pointer;
389 transition: all 0.2s ease;
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000390 display: block;
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +0000391 margin-left: auto;
Sean McCullough86b56862025-04-18 13:04:03 -0700392 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700393
Sean McCullough86b56862025-04-18 13:04:03 -0700394 .commit-diff-button:hover {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000395 background-color: #0256b4;
Sean McCullough86b56862025-04-18 13:04:03 -0700396 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700397
Sean McCullough86b56862025-04-18 13:04:03 -0700398 /* Tool call cards */
399 .tool-call-cards-container {
400 display: flex;
401 flex-direction: column;
402 gap: 8px;
403 margin-top: 8px;
404 }
405
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000406 /* Error message specific styling */
407 .error .message-content {
408 background-color: #ffebee;
409 border-left: 3px solid #f44336;
Sean McCullough86b56862025-04-18 13:04:03 -0700410 }
411
412 .end-of-turn {
413 margin-bottom: 15px;
414 }
415
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000416 .end-of-turn-indicator {
417 display: block;
418 font-size: 11px;
419 color: #777;
420 padding: 2px 0;
421 margin-top: 8px;
422 text-align: right;
423 font-style: italic;
424 }
425
426 .user .end-of-turn-indicator {
427 color: rgba(255, 255, 255, 0.7);
428 }
429
430 /* Message info panel styling */
431 .message-info-panel {
432 margin-top: 8px;
433 padding: 8px;
434 background-color: rgba(0, 0, 0, 0.03);
435 border-radius: 6px;
436 font-size: 12px;
437 transition: all 0.2s ease;
438 border-left: 2px solid rgba(0, 0, 0, 0.1);
439 }
440
441 .user .message-info-panel {
442 background-color: rgba(255, 255, 255, 0.15);
443 border-left: 2px solid rgba(255, 255, 255, 0.2);
444 }
445
446 .info-row {
447 margin-bottom: 3px;
448 display: flex;
449 }
450
451 .info-label {
452 font-weight: bold;
453 margin-right: 5px;
454 min-width: 60px;
455 }
456
457 .info-value {
458 flex: 1;
459 }
460
461 .conversation-id {
462 font-family: monospace;
Sean McCullough86b56862025-04-18 13:04:03 -0700463 font-size: 10px;
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000464 word-break: break-all;
Sean McCullough86b56862025-04-18 13:04:03 -0700465 }
466
467 .markdown-content {
468 box-sizing: border-box;
469 min-width: 200px;
470 margin: 0 auto;
471 }
472
473 .markdown-content p {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000474 margin-block-start: 0.3em;
475 margin-block-end: 0.3em;
476 }
477
478 .markdown-content p:first-child {
479 margin-block-start: 0;
480 }
481
482 .markdown-content p:last-child {
483 margin-block-end: 0;
484 }
485
486 /* Styling for markdown elements */
487 .markdown-content a {
488 color: inherit;
489 text-decoration: underline;
490 }
491
492 .user .markdown-content a {
493 color: #fff;
494 text-decoration: underline;
495 }
496
497 .markdown-content ul,
498 .markdown-content ol {
499 padding-left: 1.5em;
500 margin: 0.5em 0;
501 }
502
503 .markdown-content blockquote {
504 border-left: 3px solid rgba(0, 0, 0, 0.2);
505 padding-left: 1em;
506 margin-left: 0.5em;
507 font-style: italic;
508 }
509
510 .user .markdown-content blockquote {
511 border-left: 3px solid rgba(255, 255, 255, 0.4);
Sean McCullough86b56862025-04-18 13:04:03 -0700512 }
Autoformatterdded2d62025-04-28 00:27:21 +0000513
Sean McCullough8d93e362025-04-27 23:32:18 +0000514 /* Mermaid diagram styling */
515 .mermaid-container {
516 margin: 1em 0;
517 padding: 0.5em;
518 background-color: #f8f8f8;
519 border-radius: 4px;
520 overflow-x: auto;
521 }
Autoformatterdded2d62025-04-28 00:27:21 +0000522
Sean McCullough8d93e362025-04-27 23:32:18 +0000523 .mermaid {
524 text-align: center;
525 }
Sean McCullough86b56862025-04-18 13:04:03 -0700526 `;
527
Sean McCullough8d93e362025-04-27 23:32:18 +0000528 // Track mermaid diagrams that need rendering
529 private mermaidDiagrams = new Map();
530
Sean McCullough86b56862025-04-18 13:04:03 -0700531 constructor() {
532 super();
Sean McCullough8d93e362025-04-27 23:32:18 +0000533 // Initialize mermaid with specific config
534 mermaid.initialize({
535 startOnLoad: false,
Sean McCulloughf98d7302025-04-27 17:44:06 -0700536 suppressErrorRendering: true,
Autoformatterdded2d62025-04-28 00:27:21 +0000537 theme: "default",
538 securityLevel: "loose", // Allows more flexibility but be careful with user-generated content
539 fontFamily: "monospace",
Sean McCullough8d93e362025-04-27 23:32:18 +0000540 });
Sean McCullough86b56862025-04-18 13:04:03 -0700541 }
542
543 // See https://lit.dev/docs/components/lifecycle/
544 connectedCallback() {
545 super.connectedCallback();
546 }
Autoformatterdded2d62025-04-28 00:27:21 +0000547
Sean McCullough8d93e362025-04-27 23:32:18 +0000548 // After the component is updated and rendered, render any mermaid diagrams
549 updated(changedProperties: Map<string, unknown>) {
550 super.updated(changedProperties);
551 this.renderMermaidDiagrams();
552 }
Autoformatterdded2d62025-04-28 00:27:21 +0000553
Sean McCullough8d93e362025-04-27 23:32:18 +0000554 // Render mermaid diagrams after the component is updated
555 renderMermaidDiagrams() {
556 // Add a small delay to ensure the DOM is fully rendered
557 setTimeout(() => {
558 // Find all mermaid containers in our shadow root
Autoformatterdded2d62025-04-28 00:27:21 +0000559 const containers = this.shadowRoot?.querySelectorAll(".mermaid");
Sean McCullough8d93e362025-04-27 23:32:18 +0000560 if (!containers || containers.length === 0) return;
Autoformatterdded2d62025-04-28 00:27:21 +0000561
Sean McCullough8d93e362025-04-27 23:32:18 +0000562 // Process each mermaid diagram
Autoformatterdded2d62025-04-28 00:27:21 +0000563 containers.forEach((container) => {
Sean McCullough8d93e362025-04-27 23:32:18 +0000564 const id = container.id;
Autoformatterdded2d62025-04-28 00:27:21 +0000565 const code = container.textContent || "";
Sean McCullough8d93e362025-04-27 23:32:18 +0000566 if (!code || !id) return; // Use return for forEach instead of continue
Autoformatterdded2d62025-04-28 00:27:21 +0000567
Sean McCullough8d93e362025-04-27 23:32:18 +0000568 try {
569 // Clear any previous content
570 container.innerHTML = code;
Autoformatterdded2d62025-04-28 00:27:21 +0000571
Sean McCullough8d93e362025-04-27 23:32:18 +0000572 // Render the mermaid diagram using promise
Autoformatterdded2d62025-04-28 00:27:21 +0000573 mermaid
574 .render(`${id}-svg`, code)
Sean McCullough8d93e362025-04-27 23:32:18 +0000575 .then(({ svg }) => {
576 container.innerHTML = svg;
577 })
Autoformatterdded2d62025-04-28 00:27:21 +0000578 .catch((err) => {
579 console.error("Error rendering mermaid diagram:", err);
Sean McCullough8d93e362025-04-27 23:32:18 +0000580 // Show the original code as fallback
581 container.innerHTML = `<pre>${code}</pre>`;
582 });
583 } catch (err) {
Autoformatterdded2d62025-04-28 00:27:21 +0000584 console.error("Error processing mermaid diagram:", err);
Sean McCullough8d93e362025-04-27 23:32:18 +0000585 // Show the original code as fallback
586 container.innerHTML = `<pre>${code}</pre>`;
587 }
588 });
589 }, 100); // Small delay to ensure DOM is ready
590 }
Sean McCullough86b56862025-04-18 13:04:03 -0700591
592 // See https://lit.dev/docs/components/lifecycle/
593 disconnectedCallback() {
594 super.disconnectedCallback();
595 }
596
597 renderMarkdown(markdownContent: string): string {
598 try {
Sean McCullough8d93e362025-04-27 23:32:18 +0000599 // Create a custom renderer
600 const renderer = new Renderer();
601 const originalCodeRenderer = renderer.code.bind(renderer);
Autoformatterdded2d62025-04-28 00:27:21 +0000602
Sean McCullough8d93e362025-04-27 23:32:18 +0000603 // Override the code renderer to handle mermaid diagrams
Autoformatterdded2d62025-04-28 00:27:21 +0000604 renderer.code = function ({ text, lang, escaped }: Tokens.Code): string {
605 if (lang === "mermaid") {
Sean McCullough8d93e362025-04-27 23:32:18 +0000606 // Generate a unique ID for this diagram
607 const id = `mermaid-diagram-${Math.random().toString(36).substring(2, 10)}`;
Autoformatterdded2d62025-04-28 00:27:21 +0000608
Sean McCullough8d93e362025-04-27 23:32:18 +0000609 // Just create the container and mermaid div - we'll render it in the updated() lifecycle method
610 return `<div class="mermaid-container">
611 <div class="mermaid" id="${id}">${text}</div>
612 </div>`;
613 }
614 // Default rendering for other code blocks
615 return originalCodeRenderer({ text, lang, escaped });
616 };
Autoformatterdded2d62025-04-28 00:27:21 +0000617
Sean McCullough86b56862025-04-18 13:04:03 -0700618 // Set markdown options for proper code block highlighting and safety
619 const markedOptions: MarkedOptions = {
620 gfm: true, // GitHub Flavored Markdown
621 breaks: true, // Convert newlines to <br>
622 async: false,
Autoformatterdded2d62025-04-28 00:27:21 +0000623 renderer: renderer,
Sean McCullough86b56862025-04-18 13:04:03 -0700624 // DOMPurify is recommended for production, but not included in this implementation
625 };
626 return marked.parse(markdownContent, markedOptions) as string;
627 } catch (error) {
628 console.error("Error rendering markdown:", error);
629 // Fallback to plain text if markdown parsing fails
630 return markdownContent;
631 }
632 }
633
634 /**
635 * Format timestamp for display
636 */
637 formatTimestamp(
638 timestamp: string | number | Date | null | undefined,
639 defaultValue: string = "",
640 ): string {
641 if (!timestamp) return defaultValue;
642 try {
643 const date = new Date(timestamp);
644 if (isNaN(date.getTime())) return defaultValue;
645
646 // Format: Mar 13, 2025 09:53:25 AM
647 return date.toLocaleString("en-US", {
648 month: "short",
649 day: "numeric",
650 year: "numeric",
651 hour: "numeric",
652 minute: "2-digit",
653 second: "2-digit",
654 hour12: true,
655 });
656 } catch (e) {
657 return defaultValue;
658 }
659 }
660
661 formatNumber(
662 num: number | null | undefined,
663 defaultValue: string = "0",
664 ): string {
665 if (num === undefined || num === null) return defaultValue;
666 try {
667 return num.toLocaleString();
668 } catch (e) {
669 return String(num);
670 }
671 }
672 formatCurrency(
673 num: number | string | null | undefined,
674 defaultValue: string = "$0.00",
675 isMessageLevel: boolean = false,
676 ): string {
677 if (num === undefined || num === null) return defaultValue;
678 try {
679 // Use 4 decimal places for message-level costs, 2 for totals
680 const decimalPlaces = isMessageLevel ? 4 : 2;
681 return `$${parseFloat(String(num)).toFixed(decimalPlaces)}`;
682 } catch (e) {
683 return defaultValue;
684 }
685 }
686
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000687 // Format duration from nanoseconds to a human-readable string
688 _formatDuration(nanoseconds: number | null | undefined): string {
689 if (!nanoseconds) return "0s";
690
691 const seconds = nanoseconds / 1e9;
692
693 if (seconds < 60) {
694 return `${seconds.toFixed(1)}s`;
695 } else if (seconds < 3600) {
696 const minutes = Math.floor(seconds / 60);
697 const remainingSeconds = seconds % 60;
698 return `${minutes}min ${remainingSeconds.toFixed(0)}s`;
699 } else {
700 const hours = Math.floor(seconds / 3600);
701 const remainingSeconds = seconds % 3600;
702 const minutes = Math.floor(remainingSeconds / 60);
703 return `${hours}h ${minutes}min`;
704 }
705 }
706
Sean McCullough86b56862025-04-18 13:04:03 -0700707 showCommit(commitHash: string) {
Sean McCullough71941bd2025-04-18 13:31:48 -0700708 this.dispatchEvent(
709 new CustomEvent("show-commit-diff", {
710 bubbles: true,
711 composed: true,
712 detail: { commitHash },
713 }),
714 );
Sean McCullough86b56862025-04-18 13:04:03 -0700715 }
716
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000717 _toggleInfo(e: Event) {
718 e.stopPropagation();
719 this.showInfo = !this.showInfo;
720 }
721
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +0000722 copyToClipboard(text: string, event: Event) {
723 const element = event.currentTarget as HTMLElement;
724 const rect = element.getBoundingClientRect();
725
726 navigator.clipboard
727 .writeText(text)
728 .then(() => {
729 this.showFloatingMessage("Copied!", rect, "success");
730 })
731 .catch((err) => {
732 console.error("Failed to copy text: ", err);
733 this.showFloatingMessage("Failed to copy!", rect, "error");
734 });
735 }
736
737 showFloatingMessage(
738 message: string,
739 targetRect: DOMRect,
740 type: "success" | "error",
741 ) {
742 // Create floating message element
743 const floatingMsg = document.createElement("div");
744 floatingMsg.textContent = message;
745 floatingMsg.className = `floating-message ${type}`;
746
747 // Position it near the clicked element
748 // Position just above the element
749 const top = targetRect.top - 30;
750 const left = targetRect.left + targetRect.width / 2 - 40;
751
752 floatingMsg.style.position = "fixed";
753 floatingMsg.style.top = `${top}px`;
754 floatingMsg.style.left = `${left}px`;
755 floatingMsg.style.zIndex = "9999";
756
757 // Add to document body
758 document.body.appendChild(floatingMsg);
759
760 // Animate in
761 floatingMsg.style.opacity = "0";
762 floatingMsg.style.transform = "translateY(10px)";
763
764 setTimeout(() => {
765 floatingMsg.style.opacity = "1";
766 floatingMsg.style.transform = "translateY(0)";
767 }, 10);
768
769 // Remove after animation
770 setTimeout(() => {
771 floatingMsg.style.opacity = "0";
772 floatingMsg.style.transform = "translateY(-10px)";
773
774 setTimeout(() => {
775 document.body.removeChild(floatingMsg);
776 }, 300);
777 }, 1500);
778 }
779
Sean McCullough86b56862025-04-18 13:04:03 -0700780 render() {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000781 // Calculate if this is an end of turn message with no parent conversation ID
782 const isEndOfTurn =
783 this.message?.end_of_turn && !this.message?.parent_conversation_id;
784
Sean McCullough86b56862025-04-18 13:04:03 -0700785 return html`
786 <div
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000787 class="message ${this.message?.type} ${isEndOfTurn
Sean McCullough86b56862025-04-18 13:04:03 -0700788 ? "end-of-turn"
789 : ""}"
790 >
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000791 <div class="message-container">
792 <!-- Left area (empty for simplicity) -->
793 <div class="message-metadata-left"></div>
794
795 <!-- Message bubble -->
796 <div class="message-bubble-container">
797 <div class="message-content">
798 <div class="message-text-container">
799 <div class="message-actions">
800 ${copyButton(this.message?.content)}
801 <button
802 class="info-icon"
803 title="Show message details"
804 @click=${this._toggleInfo}
Sean McCullough71941bd2025-04-18 13:31:48 -0700805 >
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000806 <svg
807 xmlns="http://www.w3.org/2000/svg"
808 width="16"
809 height="16"
810 viewBox="0 0 24 24"
811 fill="none"
812 stroke="currentColor"
813 stroke-width="2"
814 stroke-linecap="round"
815 stroke-linejoin="round"
816 >
817 <circle cx="12" cy="12" r="10"></circle>
818 <line x1="12" y1="16" x2="12" y2="12"></line>
819 <line x1="12" y1="8" x2="12.01" y2="8"></line>
820 </svg>
821 </button>
822 </div>
823 ${this.message?.content
824 ? html`
825 <div class="message-text markdown-content">
826 ${unsafeHTML(
827 this.renderMarkdown(this.message?.content),
828 )}
829 </div>
830 `
831 : ""}
832
833 <!-- End of turn indicator inside the bubble -->
834 ${isEndOfTurn && this.message?.elapsed
835 ? html`
836 <div class="end-of-turn-indicator">
837 end of turn
838 (${this._formatDuration(this.message?.elapsed)})
839 </div>
840 `
841 : ""}
842
843 <!-- Info panel that can be toggled -->
844 ${this.showInfo
845 ? html`
846 <div class="message-info-panel">
847 <div class="info-row">
848 <span class="info-label">Type:</span>
849 <span class="info-value">${this.message?.type}</span>
850 </div>
851 <div class="info-row">
852 <span class="info-label">Time:</span>
853 <span class="info-value"
854 >${this.formatTimestamp(
855 this.message?.timestamp,
856 "",
857 )}</span
858 >
859 </div>
860 ${this.message?.elapsed
861 ? html`
862 <div class="info-row">
863 <span class="info-label">Duration:</span>
864 <span class="info-value"
865 >${this._formatDuration(
866 this.message?.elapsed,
867 )}</span
868 >
869 </div>
870 `
871 : ""}
872 ${this.message?.usage
873 ? html`
874 <div class="info-row">
875 <span class="info-label">Tokens:</span>
876 <span class="info-value">
877 ${this.message?.usage
878 ? html`
879 <div>
880 Input:
881 ${this.formatNumber(
882 this.message?.usage?.input_tokens ||
883 0,
884 )}
885 </div>
886 ${this.message?.usage
887 ?.cache_creation_input_tokens
888 ? html`
889 <div>
890 Cache creation:
891 ${this.formatNumber(
892 this.message?.usage
893 ?.cache_creation_input_tokens,
894 )}
895 </div>
896 `
897 : ""}
898 ${this.message?.usage
899 ?.cache_read_input_tokens
900 ? html`
901 <div>
902 Cache read:
903 ${this.formatNumber(
904 this.message?.usage
905 ?.cache_read_input_tokens,
906 )}
907 </div>
908 `
909 : ""}
910 <div>
911 Output:
912 ${this.formatNumber(
913 this.message?.usage?.output_tokens,
914 )}
915 </div>
916 <div>
917 Cost:
918 ${this.formatCurrency(
919 this.message?.usage?.cost_usd,
920 )}
921 </div>
922 `
923 : "N/A"}
924 </span>
925 </div>
926 `
927 : ""}
928 ${this.message?.conversation_id
929 ? html`
930 <div class="info-row">
931 <span class="info-label">Conversation ID:</span>
932 <span class="info-value conversation-id"
933 >${this.message?.conversation_id}</span
934 >
935 </div>
936 `
937 : ""}
938 </div>
939 `
940 : ""}
941 </div>
942
943 <!-- Tool calls - only shown for agent messages -->
944 ${this.message?.type === "agent"
945 ? html`
946 <sketch-tool-calls
947 .toolCalls=${this.message?.tool_calls}
948 .open=${this.open}
949 ></sketch-tool-calls>
950 `
951 : ""}
952
953 <!-- Commits section (redesigned as bubbles) -->
954 ${this.message?.commits
955 ? html`
956 <div class="commits-container">
957 <div class="commit-notification">
958 ${this.message.commits.length} new
959 commit${this.message.commits.length > 1 ? "s" : ""}
960 detected
961 </div>
962 ${this.message.commits.map((commit) => {
963 return html`
964 <div class="commit-card">
Philip Zeyliger72682df2025-04-23 13:09:46 -0700965 <span
966 class="commit-hash"
967 title="Click to copy: ${commit.hash}"
968 @click=${(e) =>
969 this.copyToClipboard(
970 commit.hash.substring(0, 8),
971 e,
972 )}
973 >
Pokey Rule7be879f2025-04-23 15:30:15 +0100974 ${commit.hash.substring(0, 8)}
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +0000975 </span>
976 ${commit.pushed_branch
977 ? html`
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000978 <span
979 class="commit-branch pushed-branch"
980 title="Click to copy: ${commit.pushed_branch}"
981 @click=${(e) =>
982 this.copyToClipboard(
983 commit.pushed_branch,
984 e,
985 )}
986 >${commit.pushed_branch}</span
987 >
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +0000988 `
989 : ``}
990 <span class="commit-subject"
991 >${commit.subject}</span
Sean McCullough71941bd2025-04-18 13:31:48 -0700992 >
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +0000993 <button
994 class="commit-diff-button"
995 @click=${() => this.showCommit(commit.hash)}
996 >
997 View Diff
998 </button>
Sean McCullough86b56862025-04-18 13:04:03 -0700999 </div>
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001000 `;
1001 })}
1002 </div>
1003 `
1004 : ""}
1005 </div>
1006 </div>
1007
1008 <!-- Right side (empty for consistency) -->
1009 <div class="message-metadata-right"></div>
Sean McCullough86b56862025-04-18 13:04:03 -07001010 </div>
1011 </div>
1012 `;
1013 }
1014}
1015
Sean McCullough71941bd2025-04-18 13:31:48 -07001016function copyButton(textToCopy: string) {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001017 // Use an icon of overlapping rectangles for copy
1018 const buttonClass = "copy-icon";
1019
1020 // SVG for copy icon (two overlapping rectangles)
1021 const copyIcon = html`<svg
1022 xmlns="http://www.w3.org/2000/svg"
1023 width="16"
1024 height="16"
1025 viewBox="0 0 24 24"
1026 fill="none"
1027 stroke="currentColor"
1028 stroke-width="2"
1029 stroke-linecap="round"
1030 stroke-linejoin="round"
1031 >
1032 <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
1033 <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
1034 </svg>`;
1035
1036 // SVG for success check mark
1037 const successIcon = html`<svg
1038 xmlns="http://www.w3.org/2000/svg"
1039 width="16"
1040 height="16"
1041 viewBox="0 0 24 24"
1042 fill="none"
1043 stroke="currentColor"
1044 stroke-width="2"
1045 stroke-linecap="round"
1046 stroke-linejoin="round"
1047 >
1048 <path d="M20 6L9 17l-5-5"></path>
1049 </svg>`;
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +00001050
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001051 const ret = html`<button
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +00001052 class="${buttonClass}"
1053 title="Copy to clipboard"
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001054 @click=${(e: Event) => {
1055 e.stopPropagation();
1056 const copyButton = e.currentTarget as HTMLButtonElement;
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001057 const originalInnerHTML = copyButton.innerHTML;
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001058 navigator.clipboard
1059 .writeText(textToCopy)
1060 .then(() => {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001061 copyButton.innerHTML = "";
1062 const successElement = document.createElement("div");
1063 copyButton.appendChild(successElement);
1064 render(successIcon, successElement);
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001065 setTimeout(() => {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001066 copyButton.innerHTML = originalInnerHTML;
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001067 }, 2000);
1068 })
1069 .catch((err) => {
1070 console.error("Failed to copy text: ", err);
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001071 setTimeout(() => {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001072 copyButton.innerHTML = originalInnerHTML;
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001073 }, 2000);
1074 });
1075 }}
1076 >
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001077 ${copyIcon}
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001078 </button>`;
Sean McCullough86b56862025-04-18 13:04:03 -07001079
Sean McCullough71941bd2025-04-18 13:31:48 -07001080 return ret;
Sean McCullough86b56862025-04-18 13:04:03 -07001081}
1082
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +00001083// Create global styles for floating messages
1084const floatingMessageStyles = document.createElement("style");
1085floatingMessageStyles.textContent = `
1086 .floating-message {
1087 background-color: rgba(0, 0, 0, 0.8);
1088 color: white;
1089 padding: 5px 10px;
1090 border-radius: 4px;
1091 font-size: 12px;
1092 font-family: system-ui, sans-serif;
1093 box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
1094 pointer-events: none;
1095 transition: opacity 0.3s ease, transform 0.3s ease;
1096 }
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +00001097
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +00001098 .floating-message.success {
1099 background-color: rgba(40, 167, 69, 0.9);
1100 }
Josh Bleecher Snyder4d544932025-05-07 13:33:53 +00001101
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +00001102 .floating-message.error {
1103 background-color: rgba(220, 53, 69, 0.9);
1104 }
Philip Zeyligere31d2a92025-05-11 15:22:35 -07001105
1106 /* Style for code, pre elements, and tool components to ensure proper wrapping/truncation */
1107 pre, code, sketch-tool-calls, sketch-tool-card, sketch-tool-card-bash {
1108 white-space: nowrap;
1109 overflow: hidden;
1110 text-overflow: ellipsis;
1111 max-width: 100%;
1112 }
1113
1114 /* Special rule for the message content container */
1115 .message-content {
1116 max-width: 100% !important;
1117 overflow: hidden !important;
1118 }
1119
1120 /* Ensure tool call containers don't overflow */
1121 ::slotted(sketch-tool-calls) {
1122 max-width: 100%;
1123 width: 100%;
1124 overflow-wrap: break-word;
1125 word-break: break-word;
1126 }
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +00001127`;
1128document.head.appendChild(floatingMessageStyles);
1129
Sean McCullough86b56862025-04-18 13:04:03 -07001130declare global {
1131 interface HTMLElementTagNameMap {
1132 "sketch-timeline-message": SketchTimelineMessage;
1133 }
1134}