blob: b148203e2259d3ba1d0b25d0f6aeff5a9f62a94d [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);
63 }
64
65 .user .message-bubble-container {
66 justify-content: flex-end;
67 }
68
69 .agent .message-bubble-container,
70 .tool .message-bubble-container,
71 .error .message-bubble-container {
72 justify-content: flex-start;
Sean McCullough86b56862025-04-18 13:04:03 -070073 }
74
75 .message-content {
76 position: relative;
Philip Zeyliger16fa8b42025-05-02 04:28:16 +000077 padding: 6px 10px;
78 border-radius: 12px;
79 box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
80 max-width: 80%;
81 width: fit-content;
82 min-width: min-content;
83 }
84
85 /* User message styling */
86 .user .message-content {
87 background-color: #2196f3;
88 color: white;
89 border-bottom-right-radius: 5px;
90 }
91
92 /* Agent message styling */
93 .agent .message-content,
94 .tool .message-content,
95 .error .message-content {
96 background-color: #f1f1f1;
97 color: black;
98 border-bottom-left-radius: 5px;
Sean McCullough86b56862025-04-18 13:04:03 -070099 }
100
101 /* Copy button styles */
102 .message-text-container,
103 .tool-result-container {
104 position: relative;
105 }
106
107 .message-actions {
108 position: absolute;
109 top: 5px;
110 right: 5px;
111 z-index: 10;
112 opacity: 0;
113 transition: opacity 0.2s ease;
114 }
115
116 .message-text-container:hover .message-actions,
117 .tool-result-container:hover .message-actions {
118 opacity: 1;
119 }
120
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000121 .message-actions {
Sean McCullough86b56862025-04-18 13:04:03 -0700122 display: flex;
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000123 gap: 6px;
124 }
125
126 .copy-icon,
127 .info-icon {
128 background-color: transparent;
129 border: none;
130 color: rgba(0, 0, 0, 0.6);
131 cursor: pointer;
132 padding: 3px;
133 border-radius: 50%;
134 display: flex;
135 align-items: center;
136 justify-content: center;
137 width: 24px;
138 height: 24px;
139 transition: all 0.15s ease;
140 }
141
142 .user .copy-icon,
143 .user .info-icon {
144 color: rgba(255, 255, 255, 0.8);
145 }
146
147 .copy-icon:hover,
148 .info-icon:hover {
149 background-color: rgba(0, 0, 0, 0.08);
150 }
151
152 .user .copy-icon:hover,
153 .user .info-icon:hover {
154 background-color: rgba(255, 255, 255, 0.15);
155 }
156
157 /* Message metadata styling */
158 .message-type {
159 font-weight: bold;
160 font-size: 11px;
Sean McCullough86b56862025-04-18 13:04:03 -0700161 }
162
163 .message-timestamp {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000164 display: block;
Sean McCullough86b56862025-04-18 13:04:03 -0700165 font-size: 10px;
166 color: #888;
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000167 margin-top: 2px;
168 }
169
170 .message-duration {
171 display: block;
172 font-size: 10px;
173 color: #888;
174 margin-top: 2px;
Sean McCullough86b56862025-04-18 13:04:03 -0700175 }
176
177 .message-usage {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000178 display: block;
Sean McCullough86b56862025-04-18 13:04:03 -0700179 font-size: 10px;
180 color: #888;
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000181 margin-top: 3px;
Sean McCullough86b56862025-04-18 13:04:03 -0700182 }
183
184 .conversation-id {
185 font-family: monospace;
186 font-size: 12px;
187 padding: 2px 4px;
Sean McCullough86b56862025-04-18 13:04:03 -0700188 margin-left: auto;
189 }
190
191 .parent-info {
192 font-size: 11px;
193 opacity: 0.8;
194 }
195
196 .subconversation {
197 border-left: 2px solid transparent;
198 padding-left: 5px;
199 margin-left: 20px;
200 transition: margin-left 0.3s ease;
201 }
202
203 .message-text {
204 overflow-x: auto;
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000205 margin-bottom: 0;
206 font-family: sans-serif;
207 padding: 2px 0;
Sean McCullough86b56862025-04-18 13:04:03 -0700208 user-select: text;
209 cursor: text;
210 -webkit-user-select: text;
211 -moz-user-select: text;
212 -ms-user-select: text;
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000213 font-size: 14px;
214 line-height: 1.35;
215 text-align: left;
216 }
217
218 /* Style for code blocks within messages */
219 .message-text pre,
220 .message-text code {
221 font-family: monospace;
222 background: rgba(0, 0, 0, 0.05);
223 border-radius: 4px;
224 padding: 2px 4px;
225 overflow-x: auto;
226 max-width: 100%;
227 white-space: pre-wrap; /* Allow wrapping for very long lines */
228 word-break: break-all; /* Break words at any character */
229 box-sizing: border-box; /* Include padding in width calculation */
230 }
231
232 .user .message-text pre,
233 .user .message-text code {
234 background: rgba(255, 255, 255, 0.2);
235 color: white;
Sean McCullough86b56862025-04-18 13:04:03 -0700236 }
237
238 .tool-details {
239 margin-top: 3px;
240 padding-top: 3px;
241 border-top: 1px dashed #e0e0e0;
242 font-size: 12px;
243 }
244
245 .tool-name {
246 font-size: 12px;
247 font-weight: bold;
248 margin-bottom: 2px;
249 background: #f0f0f0;
250 padding: 2px 4px;
251 border-radius: 2px;
252 display: flex;
253 align-items: center;
254 gap: 3px;
255 }
256
257 .tool-input,
258 .tool-result {
259 margin-top: 2px;
260 padding: 3px 5px;
261 background: #f7f7f7;
262 border-radius: 2px;
263 font-family: monospace;
264 font-size: 12px;
265 overflow-x: auto;
266 white-space: pre;
267 line-height: 1.3;
268 user-select: text;
269 cursor: text;
270 -webkit-user-select: text;
271 -moz-user-select: text;
272 -ms-user-select: text;
273 }
274
275 .tool-result {
276 max-height: 300px;
277 overflow-y: auto;
278 }
279
280 .usage-info {
281 margin-top: 10px;
282 padding-top: 10px;
283 border-top: 1px dashed #e0e0e0;
284 font-size: 12px;
285 color: #666;
286 }
287
288 /* Custom styles for IRC-like experience */
289 .user .message-content {
290 border-left-color: #2196f3;
291 }
292
293 .agent .message-content {
294 border-left-color: #4caf50;
295 }
296
297 .tool .message-content {
298 border-left-color: #ff9800;
299 }
300
301 .error .message-content {
302 border-left-color: #f44336;
303 }
304
305 /* Make message type display bold but without the IRC-style markers */
306 .message-type {
307 font-weight: bold;
308 }
309
310 /* Commit message styling */
Sean McCullough86b56862025-04-18 13:04:03 -0700311 .commits-container {
312 margin-top: 10px;
Sean McCullough86b56862025-04-18 13:04:03 -0700313 }
314
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000315 .commit-notification {
316 background-color: #e8f5e9;
317 color: #2e7d32;
318 font-weight: 500;
319 font-size: 12px;
320 padding: 6px 10px;
321 border-radius: 10px;
322 margin-bottom: 8px;
323 text-align: center;
324 box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
Sean McCullough86b56862025-04-18 13:04:03 -0700325 }
326
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000327 .commit-card {
328 background-color: #f5f5f5;
329 border-radius: 8px;
Sean McCullough86b56862025-04-18 13:04:03 -0700330 overflow: hidden;
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000331 margin-bottom: 6px;
332 box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
333 padding: 6px 8px;
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +0000334 display: flex;
335 align-items: center;
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000336 gap: 8px;
Sean McCullough86b56862025-04-18 13:04:03 -0700337 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700338
Sean McCullough86b56862025-04-18 13:04:03 -0700339 .commit-hash {
340 color: #0366d6;
341 font-weight: bold;
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000342 font-family: monospace;
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +0000343 cursor: pointer;
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +0000344 text-decoration: none;
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000345 background-color: rgba(3, 102, 214, 0.08);
346 padding: 2px 5px;
347 border-radius: 4px;
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +0000348 }
349
350 .commit-hash:hover {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000351 background-color: rgba(3, 102, 214, 0.15);
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +0000352 }
353
354 .commit-branch {
355 color: #28a745;
356 font-weight: 500;
357 cursor: pointer;
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000358 font-family: monospace;
359 background-color: rgba(40, 167, 69, 0.08);
360 padding: 2px 5px;
361 border-radius: 4px;
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +0000362 }
363
364 .commit-branch:hover {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000365 background-color: rgba(40, 167, 69, 0.15);
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +0000366 }
367
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000368 .commit-subject {
369 font-size: 13px;
370 color: #333;
371 flex-grow: 1;
372 overflow: hidden;
373 text-overflow: ellipsis;
374 white-space: nowrap;
Sean McCullough86b56862025-04-18 13:04:03 -0700375 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700376
Sean McCullough86b56862025-04-18 13:04:03 -0700377 .commit-diff-button {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000378 padding: 3px 8px;
379 border: none;
380 border-radius: 4px;
381 background-color: #0366d6;
382 color: white;
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +0000383 font-size: 11px;
Sean McCullough86b56862025-04-18 13:04:03 -0700384 cursor: pointer;
385 transition: all 0.2s ease;
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000386 display: block;
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +0000387 margin-left: auto;
Sean McCullough86b56862025-04-18 13:04:03 -0700388 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700389
Sean McCullough86b56862025-04-18 13:04:03 -0700390 .commit-diff-button:hover {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000391 background-color: #0256b4;
Sean McCullough86b56862025-04-18 13:04:03 -0700392 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700393
Sean McCullough86b56862025-04-18 13:04:03 -0700394 /* Tool call cards */
395 .tool-call-cards-container {
396 display: flex;
397 flex-direction: column;
398 gap: 8px;
399 margin-top: 8px;
400 }
401
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000402 /* Error message specific styling */
403 .error .message-content {
404 background-color: #ffebee;
405 border-left: 3px solid #f44336;
Sean McCullough86b56862025-04-18 13:04:03 -0700406 }
407
408 .end-of-turn {
409 margin-bottom: 15px;
410 }
411
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000412 .end-of-turn-indicator {
413 display: block;
414 font-size: 11px;
415 color: #777;
416 padding: 2px 0;
417 margin-top: 8px;
418 text-align: right;
419 font-style: italic;
420 }
421
422 .user .end-of-turn-indicator {
423 color: rgba(255, 255, 255, 0.7);
424 }
425
426 /* Message info panel styling */
427 .message-info-panel {
428 margin-top: 8px;
429 padding: 8px;
430 background-color: rgba(0, 0, 0, 0.03);
431 border-radius: 6px;
432 font-size: 12px;
433 transition: all 0.2s ease;
434 border-left: 2px solid rgba(0, 0, 0, 0.1);
435 }
436
437 .user .message-info-panel {
438 background-color: rgba(255, 255, 255, 0.15);
439 border-left: 2px solid rgba(255, 255, 255, 0.2);
440 }
441
442 .info-row {
443 margin-bottom: 3px;
444 display: flex;
445 }
446
447 .info-label {
448 font-weight: bold;
449 margin-right: 5px;
450 min-width: 60px;
451 }
452
453 .info-value {
454 flex: 1;
455 }
456
457 .conversation-id {
458 font-family: monospace;
Sean McCullough86b56862025-04-18 13:04:03 -0700459 font-size: 10px;
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000460 word-break: break-all;
Sean McCullough86b56862025-04-18 13:04:03 -0700461 }
462
463 .markdown-content {
464 box-sizing: border-box;
465 min-width: 200px;
466 margin: 0 auto;
467 }
468
469 .markdown-content p {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000470 margin-block-start: 0.3em;
471 margin-block-end: 0.3em;
472 }
473
474 .markdown-content p:first-child {
475 margin-block-start: 0;
476 }
477
478 .markdown-content p:last-child {
479 margin-block-end: 0;
480 }
481
482 /* Styling for markdown elements */
483 .markdown-content a {
484 color: inherit;
485 text-decoration: underline;
486 }
487
488 .user .markdown-content a {
489 color: #fff;
490 text-decoration: underline;
491 }
492
493 .markdown-content ul,
494 .markdown-content ol {
495 padding-left: 1.5em;
496 margin: 0.5em 0;
497 }
498
499 .markdown-content blockquote {
500 border-left: 3px solid rgba(0, 0, 0, 0.2);
501 padding-left: 1em;
502 margin-left: 0.5em;
503 font-style: italic;
504 }
505
506 .user .markdown-content blockquote {
507 border-left: 3px solid rgba(255, 255, 255, 0.4);
Sean McCullough86b56862025-04-18 13:04:03 -0700508 }
Autoformatterdded2d62025-04-28 00:27:21 +0000509
Sean McCullough8d93e362025-04-27 23:32:18 +0000510 /* Mermaid diagram styling */
511 .mermaid-container {
512 margin: 1em 0;
513 padding: 0.5em;
514 background-color: #f8f8f8;
515 border-radius: 4px;
516 overflow-x: auto;
517 }
Autoformatterdded2d62025-04-28 00:27:21 +0000518
Sean McCullough8d93e362025-04-27 23:32:18 +0000519 .mermaid {
520 text-align: center;
521 }
Sean McCullough86b56862025-04-18 13:04:03 -0700522 `;
523
Sean McCullough8d93e362025-04-27 23:32:18 +0000524 // Track mermaid diagrams that need rendering
525 private mermaidDiagrams = new Map();
526
Sean McCullough86b56862025-04-18 13:04:03 -0700527 constructor() {
528 super();
Sean McCullough8d93e362025-04-27 23:32:18 +0000529 // Initialize mermaid with specific config
530 mermaid.initialize({
531 startOnLoad: false,
Sean McCulloughf98d7302025-04-27 17:44:06 -0700532 suppressErrorRendering: true,
Autoformatterdded2d62025-04-28 00:27:21 +0000533 theme: "default",
534 securityLevel: "loose", // Allows more flexibility but be careful with user-generated content
535 fontFamily: "monospace",
Sean McCullough8d93e362025-04-27 23:32:18 +0000536 });
Sean McCullough86b56862025-04-18 13:04:03 -0700537 }
538
539 // See https://lit.dev/docs/components/lifecycle/
540 connectedCallback() {
541 super.connectedCallback();
542 }
Autoformatterdded2d62025-04-28 00:27:21 +0000543
Sean McCullough8d93e362025-04-27 23:32:18 +0000544 // After the component is updated and rendered, render any mermaid diagrams
545 updated(changedProperties: Map<string, unknown>) {
546 super.updated(changedProperties);
547 this.renderMermaidDiagrams();
548 }
Autoformatterdded2d62025-04-28 00:27:21 +0000549
Sean McCullough8d93e362025-04-27 23:32:18 +0000550 // Render mermaid diagrams after the component is updated
551 renderMermaidDiagrams() {
552 // Add a small delay to ensure the DOM is fully rendered
553 setTimeout(() => {
554 // Find all mermaid containers in our shadow root
Autoformatterdded2d62025-04-28 00:27:21 +0000555 const containers = this.shadowRoot?.querySelectorAll(".mermaid");
Sean McCullough8d93e362025-04-27 23:32:18 +0000556 if (!containers || containers.length === 0) return;
Autoformatterdded2d62025-04-28 00:27:21 +0000557
Sean McCullough8d93e362025-04-27 23:32:18 +0000558 // Process each mermaid diagram
Autoformatterdded2d62025-04-28 00:27:21 +0000559 containers.forEach((container) => {
Sean McCullough8d93e362025-04-27 23:32:18 +0000560 const id = container.id;
Autoformatterdded2d62025-04-28 00:27:21 +0000561 const code = container.textContent || "";
Sean McCullough8d93e362025-04-27 23:32:18 +0000562 if (!code || !id) return; // Use return for forEach instead of continue
Autoformatterdded2d62025-04-28 00:27:21 +0000563
Sean McCullough8d93e362025-04-27 23:32:18 +0000564 try {
565 // Clear any previous content
566 container.innerHTML = code;
Autoformatterdded2d62025-04-28 00:27:21 +0000567
Sean McCullough8d93e362025-04-27 23:32:18 +0000568 // Render the mermaid diagram using promise
Autoformatterdded2d62025-04-28 00:27:21 +0000569 mermaid
570 .render(`${id}-svg`, code)
Sean McCullough8d93e362025-04-27 23:32:18 +0000571 .then(({ svg }) => {
572 container.innerHTML = svg;
573 })
Autoformatterdded2d62025-04-28 00:27:21 +0000574 .catch((err) => {
575 console.error("Error rendering mermaid diagram:", err);
Sean McCullough8d93e362025-04-27 23:32:18 +0000576 // Show the original code as fallback
577 container.innerHTML = `<pre>${code}</pre>`;
578 });
579 } catch (err) {
Autoformatterdded2d62025-04-28 00:27:21 +0000580 console.error("Error processing mermaid diagram:", err);
Sean McCullough8d93e362025-04-27 23:32:18 +0000581 // Show the original code as fallback
582 container.innerHTML = `<pre>${code}</pre>`;
583 }
584 });
585 }, 100); // Small delay to ensure DOM is ready
586 }
Sean McCullough86b56862025-04-18 13:04:03 -0700587
588 // See https://lit.dev/docs/components/lifecycle/
589 disconnectedCallback() {
590 super.disconnectedCallback();
591 }
592
593 renderMarkdown(markdownContent: string): string {
594 try {
Sean McCullough8d93e362025-04-27 23:32:18 +0000595 // Create a custom renderer
596 const renderer = new Renderer();
597 const originalCodeRenderer = renderer.code.bind(renderer);
Autoformatterdded2d62025-04-28 00:27:21 +0000598
Sean McCullough8d93e362025-04-27 23:32:18 +0000599 // Override the code renderer to handle mermaid diagrams
Autoformatterdded2d62025-04-28 00:27:21 +0000600 renderer.code = function ({ text, lang, escaped }: Tokens.Code): string {
601 if (lang === "mermaid") {
Sean McCullough8d93e362025-04-27 23:32:18 +0000602 // Generate a unique ID for this diagram
603 const id = `mermaid-diagram-${Math.random().toString(36).substring(2, 10)}`;
Autoformatterdded2d62025-04-28 00:27:21 +0000604
Sean McCullough8d93e362025-04-27 23:32:18 +0000605 // Just create the container and mermaid div - we'll render it in the updated() lifecycle method
606 return `<div class="mermaid-container">
607 <div class="mermaid" id="${id}">${text}</div>
608 </div>`;
609 }
610 // Default rendering for other code blocks
611 return originalCodeRenderer({ text, lang, escaped });
612 };
Autoformatterdded2d62025-04-28 00:27:21 +0000613
Sean McCullough86b56862025-04-18 13:04:03 -0700614 // Set markdown options for proper code block highlighting and safety
615 const markedOptions: MarkedOptions = {
616 gfm: true, // GitHub Flavored Markdown
617 breaks: true, // Convert newlines to <br>
618 async: false,
Autoformatterdded2d62025-04-28 00:27:21 +0000619 renderer: renderer,
Sean McCullough86b56862025-04-18 13:04:03 -0700620 // DOMPurify is recommended for production, but not included in this implementation
621 };
622 return marked.parse(markdownContent, markedOptions) as string;
623 } catch (error) {
624 console.error("Error rendering markdown:", error);
625 // Fallback to plain text if markdown parsing fails
626 return markdownContent;
627 }
628 }
629
630 /**
631 * Format timestamp for display
632 */
633 formatTimestamp(
634 timestamp: string | number | Date | null | undefined,
635 defaultValue: string = "",
636 ): string {
637 if (!timestamp) return defaultValue;
638 try {
639 const date = new Date(timestamp);
640 if (isNaN(date.getTime())) return defaultValue;
641
642 // Format: Mar 13, 2025 09:53:25 AM
643 return date.toLocaleString("en-US", {
644 month: "short",
645 day: "numeric",
646 year: "numeric",
647 hour: "numeric",
648 minute: "2-digit",
649 second: "2-digit",
650 hour12: true,
651 });
652 } catch (e) {
653 return defaultValue;
654 }
655 }
656
657 formatNumber(
658 num: number | null | undefined,
659 defaultValue: string = "0",
660 ): string {
661 if (num === undefined || num === null) return defaultValue;
662 try {
663 return num.toLocaleString();
664 } catch (e) {
665 return String(num);
666 }
667 }
668 formatCurrency(
669 num: number | string | null | undefined,
670 defaultValue: string = "$0.00",
671 isMessageLevel: boolean = false,
672 ): string {
673 if (num === undefined || num === null) return defaultValue;
674 try {
675 // Use 4 decimal places for message-level costs, 2 for totals
676 const decimalPlaces = isMessageLevel ? 4 : 2;
677 return `$${parseFloat(String(num)).toFixed(decimalPlaces)}`;
678 } catch (e) {
679 return defaultValue;
680 }
681 }
682
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000683 // Format duration from nanoseconds to a human-readable string
684 _formatDuration(nanoseconds: number | null | undefined): string {
685 if (!nanoseconds) return "0s";
686
687 const seconds = nanoseconds / 1e9;
688
689 if (seconds < 60) {
690 return `${seconds.toFixed(1)}s`;
691 } else if (seconds < 3600) {
692 const minutes = Math.floor(seconds / 60);
693 const remainingSeconds = seconds % 60;
694 return `${minutes}min ${remainingSeconds.toFixed(0)}s`;
695 } else {
696 const hours = Math.floor(seconds / 3600);
697 const remainingSeconds = seconds % 3600;
698 const minutes = Math.floor(remainingSeconds / 60);
699 return `${hours}h ${minutes}min`;
700 }
701 }
702
Sean McCullough86b56862025-04-18 13:04:03 -0700703 showCommit(commitHash: string) {
Sean McCullough71941bd2025-04-18 13:31:48 -0700704 this.dispatchEvent(
705 new CustomEvent("show-commit-diff", {
706 bubbles: true,
707 composed: true,
708 detail: { commitHash },
709 }),
710 );
Sean McCullough86b56862025-04-18 13:04:03 -0700711 }
712
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000713 _toggleInfo(e: Event) {
714 e.stopPropagation();
715 this.showInfo = !this.showInfo;
716 }
717
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +0000718 copyToClipboard(text: string, event: Event) {
719 const element = event.currentTarget as HTMLElement;
720 const rect = element.getBoundingClientRect();
721
722 navigator.clipboard
723 .writeText(text)
724 .then(() => {
725 this.showFloatingMessage("Copied!", rect, "success");
726 })
727 .catch((err) => {
728 console.error("Failed to copy text: ", err);
729 this.showFloatingMessage("Failed to copy!", rect, "error");
730 });
731 }
732
733 showFloatingMessage(
734 message: string,
735 targetRect: DOMRect,
736 type: "success" | "error",
737 ) {
738 // Create floating message element
739 const floatingMsg = document.createElement("div");
740 floatingMsg.textContent = message;
741 floatingMsg.className = `floating-message ${type}`;
742
743 // Position it near the clicked element
744 // Position just above the element
745 const top = targetRect.top - 30;
746 const left = targetRect.left + targetRect.width / 2 - 40;
747
748 floatingMsg.style.position = "fixed";
749 floatingMsg.style.top = `${top}px`;
750 floatingMsg.style.left = `${left}px`;
751 floatingMsg.style.zIndex = "9999";
752
753 // Add to document body
754 document.body.appendChild(floatingMsg);
755
756 // Animate in
757 floatingMsg.style.opacity = "0";
758 floatingMsg.style.transform = "translateY(10px)";
759
760 setTimeout(() => {
761 floatingMsg.style.opacity = "1";
762 floatingMsg.style.transform = "translateY(0)";
763 }, 10);
764
765 // Remove after animation
766 setTimeout(() => {
767 floatingMsg.style.opacity = "0";
768 floatingMsg.style.transform = "translateY(-10px)";
769
770 setTimeout(() => {
771 document.body.removeChild(floatingMsg);
772 }, 300);
773 }, 1500);
774 }
775
Sean McCullough86b56862025-04-18 13:04:03 -0700776 render() {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000777 // Calculate if this is an end of turn message with no parent conversation ID
778 const isEndOfTurn =
779 this.message?.end_of_turn && !this.message?.parent_conversation_id;
780
Sean McCullough86b56862025-04-18 13:04:03 -0700781 return html`
782 <div
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000783 class="message ${this.message?.type} ${isEndOfTurn
Sean McCullough86b56862025-04-18 13:04:03 -0700784 ? "end-of-turn"
785 : ""}"
786 >
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000787 <div class="message-container">
788 <!-- Left area (empty for simplicity) -->
789 <div class="message-metadata-left"></div>
790
791 <!-- Message bubble -->
792 <div class="message-bubble-container">
793 <div class="message-content">
794 <div class="message-text-container">
795 <div class="message-actions">
796 ${copyButton(this.message?.content)}
797 <button
798 class="info-icon"
799 title="Show message details"
800 @click=${this._toggleInfo}
Sean McCullough71941bd2025-04-18 13:31:48 -0700801 >
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000802 <svg
803 xmlns="http://www.w3.org/2000/svg"
804 width="16"
805 height="16"
806 viewBox="0 0 24 24"
807 fill="none"
808 stroke="currentColor"
809 stroke-width="2"
810 stroke-linecap="round"
811 stroke-linejoin="round"
812 >
813 <circle cx="12" cy="12" r="10"></circle>
814 <line x1="12" y1="16" x2="12" y2="12"></line>
815 <line x1="12" y1="8" x2="12.01" y2="8"></line>
816 </svg>
817 </button>
818 </div>
819 ${this.message?.content
820 ? html`
821 <div class="message-text markdown-content">
822 ${unsafeHTML(
823 this.renderMarkdown(this.message?.content),
824 )}
825 </div>
826 `
827 : ""}
828
829 <!-- End of turn indicator inside the bubble -->
830 ${isEndOfTurn && this.message?.elapsed
831 ? html`
832 <div class="end-of-turn-indicator">
833 end of turn
834 (${this._formatDuration(this.message?.elapsed)})
835 </div>
836 `
837 : ""}
838
839 <!-- Info panel that can be toggled -->
840 ${this.showInfo
841 ? html`
842 <div class="message-info-panel">
843 <div class="info-row">
844 <span class="info-label">Type:</span>
845 <span class="info-value">${this.message?.type}</span>
846 </div>
847 <div class="info-row">
848 <span class="info-label">Time:</span>
849 <span class="info-value"
850 >${this.formatTimestamp(
851 this.message?.timestamp,
852 "",
853 )}</span
854 >
855 </div>
856 ${this.message?.elapsed
857 ? html`
858 <div class="info-row">
859 <span class="info-label">Duration:</span>
860 <span class="info-value"
861 >${this._formatDuration(
862 this.message?.elapsed,
863 )}</span
864 >
865 </div>
866 `
867 : ""}
868 ${this.message?.usage
869 ? html`
870 <div class="info-row">
871 <span class="info-label">Tokens:</span>
872 <span class="info-value">
873 ${this.message?.usage
874 ? html`
875 <div>
876 Input:
877 ${this.formatNumber(
878 this.message?.usage?.input_tokens ||
879 0,
880 )}
881 </div>
882 ${this.message?.usage
883 ?.cache_creation_input_tokens
884 ? html`
885 <div>
886 Cache creation:
887 ${this.formatNumber(
888 this.message?.usage
889 ?.cache_creation_input_tokens,
890 )}
891 </div>
892 `
893 : ""}
894 ${this.message?.usage
895 ?.cache_read_input_tokens
896 ? html`
897 <div>
898 Cache read:
899 ${this.formatNumber(
900 this.message?.usage
901 ?.cache_read_input_tokens,
902 )}
903 </div>
904 `
905 : ""}
906 <div>
907 Output:
908 ${this.formatNumber(
909 this.message?.usage?.output_tokens,
910 )}
911 </div>
912 <div>
913 Cost:
914 ${this.formatCurrency(
915 this.message?.usage?.cost_usd,
916 )}
917 </div>
918 `
919 : "N/A"}
920 </span>
921 </div>
922 `
923 : ""}
924 ${this.message?.conversation_id
925 ? html`
926 <div class="info-row">
927 <span class="info-label">Conversation ID:</span>
928 <span class="info-value conversation-id"
929 >${this.message?.conversation_id}</span
930 >
931 </div>
932 `
933 : ""}
934 </div>
935 `
936 : ""}
937 </div>
938
939 <!-- Tool calls - only shown for agent messages -->
940 ${this.message?.type === "agent"
941 ? html`
942 <sketch-tool-calls
943 .toolCalls=${this.message?.tool_calls}
944 .open=${this.open}
945 ></sketch-tool-calls>
946 `
947 : ""}
948
949 <!-- Commits section (redesigned as bubbles) -->
950 ${this.message?.commits
951 ? html`
952 <div class="commits-container">
953 <div class="commit-notification">
954 ${this.message.commits.length} new
955 commit${this.message.commits.length > 1 ? "s" : ""}
956 detected
957 </div>
958 ${this.message.commits.map((commit) => {
959 return html`
960 <div class="commit-card">
Philip Zeyliger72682df2025-04-23 13:09:46 -0700961 <span
962 class="commit-hash"
963 title="Click to copy: ${commit.hash}"
964 @click=${(e) =>
965 this.copyToClipboard(
966 commit.hash.substring(0, 8),
967 e,
968 )}
969 >
Pokey Rule7be879f2025-04-23 15:30:15 +0100970 ${commit.hash.substring(0, 8)}
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +0000971 </span>
972 ${commit.pushed_branch
973 ? html`
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000974 <span
975 class="commit-branch pushed-branch"
976 title="Click to copy: ${commit.pushed_branch}"
977 @click=${(e) =>
978 this.copyToClipboard(
979 commit.pushed_branch,
980 e,
981 )}
982 >${commit.pushed_branch}</span
983 >
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +0000984 `
985 : ``}
986 <span class="commit-subject"
987 >${commit.subject}</span
Sean McCullough71941bd2025-04-18 13:31:48 -0700988 >
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +0000989 <button
990 class="commit-diff-button"
991 @click=${() => this.showCommit(commit.hash)}
992 >
993 View Diff
994 </button>
Sean McCullough86b56862025-04-18 13:04:03 -0700995 </div>
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000996 `;
997 })}
998 </div>
999 `
1000 : ""}
1001 </div>
1002 </div>
1003
1004 <!-- Right side (empty for consistency) -->
1005 <div class="message-metadata-right"></div>
Sean McCullough86b56862025-04-18 13:04:03 -07001006 </div>
1007 </div>
1008 `;
1009 }
1010}
1011
Sean McCullough71941bd2025-04-18 13:31:48 -07001012function copyButton(textToCopy: string) {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001013 // Use an icon of overlapping rectangles for copy
1014 const buttonClass = "copy-icon";
1015
1016 // SVG for copy icon (two overlapping rectangles)
1017 const copyIcon = html`<svg
1018 xmlns="http://www.w3.org/2000/svg"
1019 width="16"
1020 height="16"
1021 viewBox="0 0 24 24"
1022 fill="none"
1023 stroke="currentColor"
1024 stroke-width="2"
1025 stroke-linecap="round"
1026 stroke-linejoin="round"
1027 >
1028 <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
1029 <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
1030 </svg>`;
1031
1032 // SVG for success check mark
1033 const successIcon = html`<svg
1034 xmlns="http://www.w3.org/2000/svg"
1035 width="16"
1036 height="16"
1037 viewBox="0 0 24 24"
1038 fill="none"
1039 stroke="currentColor"
1040 stroke-width="2"
1041 stroke-linecap="round"
1042 stroke-linejoin="round"
1043 >
1044 <path d="M20 6L9 17l-5-5"></path>
1045 </svg>`;
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +00001046
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001047 const ret = html`<button
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +00001048 class="${buttonClass}"
1049 title="Copy to clipboard"
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001050 @click=${(e: Event) => {
1051 e.stopPropagation();
1052 const copyButton = e.currentTarget as HTMLButtonElement;
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001053 const originalInnerHTML = copyButton.innerHTML;
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001054 navigator.clipboard
1055 .writeText(textToCopy)
1056 .then(() => {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001057 copyButton.innerHTML = "";
1058 const successElement = document.createElement("div");
1059 copyButton.appendChild(successElement);
1060 render(successIcon, successElement);
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001061 setTimeout(() => {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001062 copyButton.innerHTML = originalInnerHTML;
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001063 }, 2000);
1064 })
1065 .catch((err) => {
1066 console.error("Failed to copy text: ", err);
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001067 setTimeout(() => {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001068 copyButton.innerHTML = originalInnerHTML;
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001069 }, 2000);
1070 });
1071 }}
1072 >
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001073 ${copyIcon}
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001074 </button>`;
Sean McCullough86b56862025-04-18 13:04:03 -07001075
Sean McCullough71941bd2025-04-18 13:31:48 -07001076 return ret;
Sean McCullough86b56862025-04-18 13:04:03 -07001077}
1078
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +00001079// Create global styles for floating messages
1080const floatingMessageStyles = document.createElement("style");
1081floatingMessageStyles.textContent = `
1082 .floating-message {
1083 background-color: rgba(0, 0, 0, 0.8);
1084 color: white;
1085 padding: 5px 10px;
1086 border-radius: 4px;
1087 font-size: 12px;
1088 font-family: system-ui, sans-serif;
1089 box-shadow: 0 2px 5px rgba(0, 0, 0, 0.2);
1090 pointer-events: none;
1091 transition: opacity 0.3s ease, transform 0.3s ease;
1092 }
1093
1094 .floating-message.success {
1095 background-color: rgba(40, 167, 69, 0.9);
1096 }
1097
1098 .floating-message.error {
1099 background-color: rgba(220, 53, 69, 0.9);
1100 }
1101`;
1102document.head.appendChild(floatingMessageStyles);
1103
Sean McCullough86b56862025-04-18 13:04:03 -07001104declare global {
1105 interface HTMLElementTagNameMap {
1106 "sketch-timeline-message": SketchTimelineMessage;
1107 }
1108}