blob: eab1867db0bb293e1f3bad5cce2131ad2c574e28 [file] [log] [blame]
philip.zeyliger26bc6592025-06-30 20:15:30 -07001/* eslint-disable @typescript-eslint/no-explicit-any */
bankseanc5147482025-06-29 00:41:58 +00002import { html, render } from "lit";
Sean McCullough86b56862025-04-18 13:04:03 -07003import { unsafeHTML } from "lit/directives/unsafe-html.js";
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00004import { customElement, property, state } from "lit/decorators.js";
philip.zeyliger6d3de482025-06-10 19:38:14 -07005import { AgentMessage, State } from "../types";
Sean McCullough8d93e362025-04-27 23:32:18 +00006import { marked, MarkedOptions, Renderer, Tokens } from "marked";
philip.zeyliger7c1a6872025-06-16 03:54:37 +00007import type mermaid from "mermaid";
Philip Zeyliger53ab2452025-06-04 17:49:33 +00008import DOMPurify from "dompurify";
philip.zeyliger7c1a6872025-06-16 03:54:37 +00009
10// Mermaid is loaded dynamically - see loadMermaid() function
11declare global {
12 interface Window {
13 mermaid?: typeof mermaid;
14 }
15}
16
17// Mermaid hash will be injected at build time
18declare const __MERMAID_HASH__: string;
19
20// Load Mermaid dynamically
21let mermaidLoadPromise: Promise<any> | null = null;
22
23function loadMermaid(): Promise<typeof mermaid> {
24 if (mermaidLoadPromise) {
25 return mermaidLoadPromise;
26 }
27
28 if (window.mermaid) {
29 return Promise.resolve(window.mermaid);
30 }
31
32 mermaidLoadPromise = new Promise((resolve, reject) => {
33 // Get the Mermaid hash from build-time constant
34 const mermaidHash = __MERMAID_HASH__;
35
36 // Try to load the external Mermaid bundle
37 const script = document.createElement("script");
38 script.onload = () => {
39 // The Mermaid bundle should set window.mermaid
40 if (window.mermaid) {
41 resolve(window.mermaid);
42 } else {
43 reject(new Error("Mermaid not loaded from external bundle"));
44 }
45 };
46 script.onerror = (error) => {
47 console.warn("Failed to load external Mermaid bundle:", error);
48 reject(new Error("Mermaid external bundle failed to load"));
49 };
50
51 // Don't set type="module" since we're using IIFE format
52 script.src = `./static/mermaid-standalone-${mermaidHash}.js`;
53 document.head.appendChild(script);
54 });
55
56 return mermaidLoadPromise;
57}
Sean McCullough86b56862025-04-18 13:04:03 -070058import "./sketch-tool-calls";
bankseanc5147482025-06-29 00:41:58 +000059import { SketchTailwindElement } from "./sketch-tailwind-element";
60
Sean McCullough86b56862025-04-18 13:04:03 -070061@customElement("sketch-timeline-message")
bankseanc5147482025-06-29 00:41:58 +000062export class SketchTimelineMessage extends SketchTailwindElement {
Sean McCullough86b56862025-04-18 13:04:03 -070063 @property()
Sean McCulloughd9f13372025-04-21 15:08:49 -070064 message: AgentMessage;
Sean McCullough86b56862025-04-18 13:04:03 -070065
66 @property()
philip.zeyliger6d3de482025-06-10 19:38:14 -070067 state: State;
68
69 @property()
Sean McCulloughd9f13372025-04-21 15:08:49 -070070 previousMessage: AgentMessage;
Sean McCullough86b56862025-04-18 13:04:03 -070071
Sean McCullough2deac842025-04-21 18:17:57 -070072 @property()
73 open: boolean = false;
74
Philip Zeyligerb8a8f352025-06-02 07:39:37 -070075 @property()
76 firstMessageIndex: number = 0;
77
David Crawshaw4b644682025-06-26 17:15:10 +000078 @property({ type: Boolean, reflect: true, attribute: "compactpadding" })
79 compactPadding: boolean = false;
80
Philip Zeyliger16fa8b42025-05-02 04:28:16 +000081 @state()
82 showInfo: boolean = false;
83
bankseanc5147482025-06-29 00:41:58 +000084 // Styles have been converted to Tailwind classes applied directly to HTML elements
85 // since this component now extends SketchTailwindElement which disables shadow DOM
Sean McCullough86b56862025-04-18 13:04:03 -070086
Sean McCullough8d93e362025-04-27 23:32:18 +000087 // Track mermaid diagrams that need rendering
88 private mermaidDiagrams = new Map();
89
Sean McCullough86b56862025-04-18 13:04:03 -070090 constructor() {
91 super();
philip.zeyliger7c1a6872025-06-16 03:54:37 +000092 // Mermaid will be initialized lazily when first needed
Sean McCullough86b56862025-04-18 13:04:03 -070093 }
94
95 // See https://lit.dev/docs/components/lifecycle/
96 connectedCallback() {
97 super.connectedCallback();
bankseanc5147482025-06-29 00:41:58 +000098 this.ensureGlobalStyles();
99 }
100
101 // Ensure global styles are injected when component is used
102 private ensureGlobalStyles() {
103 if (!document.querySelector("#sketch-timeline-message-styles")) {
104 const floatingMessageStyles = document.createElement("style");
105 floatingMessageStyles.id = "sketch-timeline-message-styles";
106 floatingMessageStyles.textContent = this.getGlobalStylesContent();
107 document.head.appendChild(floatingMessageStyles);
108 }
109 }
110
111 // Get the global styles content
112 private getGlobalStylesContent(): string {
113 return `
114 .floating-message {
115 background-color: rgba(31, 41, 55, 1);
116 color: white;
117 padding: 4px 10px;
118 border-radius: 4px;
119 font-size: 12px;
120 font-family: system-ui, sans-serif;
121 box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
122 pointer-events: none;
123 transition: all 0.3s ease;
124 }
125
126 .floating-message.success {
127 background-color: rgba(34, 197, 94, 0.9);
128 }
129
130 .floating-message.error {
131 background-color: rgba(239, 68, 68, 0.9);
132 }
133
134 /* Comprehensive markdown content styling */
135 .markdown-content h1 {
136 font-size: 1.875rem;
137 font-weight: 700;
138 margin: 1rem 0 0.5rem 0;
139 line-height: 1.25;
140 }
141
142 .markdown-content h2 {
143 font-size: 1.5rem;
144 font-weight: 600;
145 margin: 0.875rem 0 0.5rem 0;
146 line-height: 1.25;
147 }
148
149 .markdown-content h3 {
150 font-size: 1.25rem;
151 font-weight: 600;
152 margin: 0.75rem 0 0.375rem 0;
153 line-height: 1.375;
154 }
155
156 .markdown-content h4 {
157 font-size: 1.125rem;
158 font-weight: 600;
159 margin: 0.625rem 0 0.375rem 0;
160 line-height: 1.375;
161 }
162
163 .markdown-content h5 {
164 font-size: 1rem;
165 font-weight: 600;
166 margin: 0.5rem 0 0.25rem 0;
167 line-height: 1.5;
168 }
169
170 .markdown-content h6 {
171 font-size: 0.875rem;
172 font-weight: 600;
173 margin: 0.5rem 0 0.25rem 0;
174 line-height: 1.5;
175 }
176
177 .markdown-content h1:first-child,
178 .markdown-content h2:first-child,
179 .markdown-content h3:first-child,
180 .markdown-content h4:first-child,
181 .markdown-content h5:first-child,
182 .markdown-content h6:first-child {
183 margin-top: 0;
184 }
185
186 .markdown-content p {
187 margin: 0.25rem 0;
188 }
189
190 .markdown-content p:first-child {
191 margin-top: 0;
192 }
193
194 .markdown-content p:last-child {
195 margin-bottom: 0;
196 }
197
198 .markdown-content a {
199 color: inherit;
200 text-decoration: underline;
201 }
202
203 .markdown-content ul,
204 .markdown-content ol {
205 padding-left: 1.5rem;
206 margin: 0.5rem 0;
207 }
208
209 .markdown-content ul {
210 list-style-type: disc;
211 }
212
213 .markdown-content ol {
214 list-style-type: decimal;
215 }
216
217 .markdown-content li {
218 margin: 0.25rem 0;
219 }
220
221 .markdown-content blockquote {
222 border-left: 3px solid rgba(0, 0, 0, 0.2);
223 padding-left: 1rem;
224 margin-left: 0.5rem;
225 font-style: italic;
226 color: rgba(0, 0, 0, 0.7);
227 }
228
229 .markdown-content strong {
230 font-weight: 700;
231 }
232
233 .markdown-content em {
234 font-style: italic;
235 }
236
237 .markdown-content hr {
238 border: none;
239 border-top: 1px solid rgba(0, 0, 0, 0.1);
240 margin: 1rem 0;
241 }
242
243 /* User message specific markdown styling */
244 sketch-timeline-message .bg-blue-500 .markdown-content a {
245 color: #fff;
246 text-decoration: underline;
247 }
248
249 sketch-timeline-message .bg-blue-500 .markdown-content blockquote {
250 border-left: 3px solid rgba(255, 255, 255, 0.4);
251 color: rgba(255, 255, 255, 0.9);
252 }
253
254 sketch-timeline-message .bg-blue-500 .markdown-content hr {
255 border-top: 1px solid rgba(255, 255, 255, 0.3);
256 }
257
258 /* Code block styling within markdown */
259 .markdown-content pre,
260 .markdown-content code {
261 font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
262 background: rgba(0, 0, 0, 0.05);
263 border-radius: 4px;
264 padding: 2px 4px;
265 overflow-x: auto;
266 max-width: 100%;
267 word-break: break-all;
268 box-sizing: border-box;
269 }
270
271 .markdown-content pre {
272 padding: 8px 12px;
273 margin: 0.5rem 0;
274 line-height: 1.4;
275 }
276
277 .markdown-content pre code {
278 background: transparent;
279 padding: 0;
280 }
281
282 /* User message code styling */
283 sketch-timeline-message .bg-blue-500 .markdown-content pre,
284 sketch-timeline-message .bg-blue-500 .markdown-content code {
285 background: rgba(255, 255, 255, 0.2);
286 color: white;
287 }
288
289 sketch-timeline-message .bg-blue-500 .markdown-content pre code {
290 background: transparent;
291 }
292
293 /* Code block containers */
294 .code-block-container {
295 position: relative;
296 margin: 8px 0;
297 border-radius: 6px;
298 overflow: hidden;
299 background: rgba(0, 0, 0, 0.05);
300 }
301
302 sketch-timeline-message .bg-blue-500 .code-block-container {
303 background: rgba(255, 255, 255, 0.2);
304 }
305
306 .code-block-header {
307 display: flex;
308 justify-content: space-between;
309 align-items: center;
310 padding: 4px 8px;
311 background: rgba(0, 0, 0, 0.1);
312 font-size: 12px;
313 }
314
315 sketch-timeline-message .bg-blue-500 .code-block-header {
316 background: rgba(255, 255, 255, 0.2);
317 color: white;
318 }
319
320 .code-language {
321 font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
322 font-size: 11px;
323 font-weight: 500;
324 }
325
326 .code-copy-button {
327 background: transparent;
328 border: none;
329 cursor: pointer;
330 padding: 2px;
331 border-radius: 3px;
332 display: flex;
333 align-items: center;
334 justify-content: center;
335 opacity: 0.7;
336 transition: all 0.15s ease;
337 }
338
339 .code-copy-button:hover {
340 opacity: 1;
341 background: rgba(0, 0, 0, 0.1);
342 }
343
344 sketch-timeline-message .bg-blue-500 .code-copy-button:hover {
345 background: rgba(255, 255, 255, 0.2);
346 }
347
348 .code-block-container pre {
349 margin: 0;
350 padding: 8px;
351 background: transparent;
352 }
353
354 .code-block-container code {
355 background: transparent;
356 padding: 0;
357 display: block;
358 width: 100%;
359 }
360
361 /* Mermaid diagram styling */
362 .mermaid-container {
363 margin: 1rem 0;
364 padding: 0.5rem;
365 background-color: #f8f8f8;
366 border-radius: 4px;
367 overflow-x: auto;
368 }
369
370 .mermaid {
371 text-align: center;
372 }
373
374 /* Print styles */
375 @media print {
376 .floating-message,
377 .commit-diff-button,
378 button[title="Copy to clipboard"],
379 button[title="Show message details"] {
380 display: none !important;
381 }
382 }
383`;
Sean McCullough86b56862025-04-18 13:04:03 -0700384 }
Autoformatterdded2d62025-04-28 00:27:21 +0000385
Sean McCullough8d93e362025-04-27 23:32:18 +0000386 // After the component is updated and rendered, render any mermaid diagrams
387 updated(changedProperties: Map<string, unknown>) {
388 super.updated(changedProperties);
389 this.renderMermaidDiagrams();
390 }
Autoformatterdded2d62025-04-28 00:27:21 +0000391
Sean McCullough8d93e362025-04-27 23:32:18 +0000392 // Render mermaid diagrams after the component is updated
393 renderMermaidDiagrams() {
394 // Add a small delay to ensure the DOM is fully rendered
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000395 setTimeout(async () => {
Sean McCullough8d93e362025-04-27 23:32:18 +0000396 // Find all mermaid containers in our shadow root
Sean McCulloughf6e1dfe2025-07-03 14:59:40 -0700397 const containers = this.querySelectorAll(".mermaid");
Sean McCullough8d93e362025-04-27 23:32:18 +0000398 if (!containers || containers.length === 0) return;
Autoformatterdded2d62025-04-28 00:27:21 +0000399
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000400 try {
401 // Load mermaid dynamically
402 const mermaidLib = await loadMermaid();
Autoformatterdded2d62025-04-28 00:27:21 +0000403
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000404 // Initialize mermaid with specific config (only once per load)
405 mermaidLib.initialize({
406 startOnLoad: false,
407 suppressErrorRendering: true,
408 theme: "default",
409 securityLevel: "loose", // Allows more flexibility but be careful with user-generated content
410 fontFamily: "monospace",
411 });
Autoformatterdded2d62025-04-28 00:27:21 +0000412
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000413 // Process each mermaid diagram
414 containers.forEach((container) => {
415 const id = container.id;
416 const code = container.textContent || "";
417 if (!code || !id) return; // Use return for forEach instead of continue
418
419 try {
420 // Clear any previous content
421 container.innerHTML = code;
422
423 // Render the mermaid diagram using promise
424 mermaidLib
425 .render(`${id}-svg`, code)
426 .then(({ svg }) => {
427 container.innerHTML = svg;
428 })
429 .catch((err) => {
430 console.error("Error rendering mermaid diagram:", err);
431 // Show the original code as fallback
432 container.innerHTML = `<pre>${code}</pre>`;
433 });
434 } catch (err) {
435 console.error("Error processing mermaid diagram:", err);
436 // Show the original code as fallback
437 container.innerHTML = `<pre>${code}</pre>`;
438 }
439 });
440 } catch (err) {
441 console.error("Error loading mermaid:", err);
442 // Show the original code as fallback for all diagrams
443 containers.forEach((container) => {
444 const code = container.textContent || "";
Sean McCullough8d93e362025-04-27 23:32:18 +0000445 container.innerHTML = `<pre>${code}</pre>`;
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000446 });
447 }
Sean McCullough8d93e362025-04-27 23:32:18 +0000448 }, 100); // Small delay to ensure DOM is ready
449 }
Sean McCullough86b56862025-04-18 13:04:03 -0700450
451 // See https://lit.dev/docs/components/lifecycle/
452 disconnectedCallback() {
453 super.disconnectedCallback();
454 }
455
Philip Zeyligerd9acaa72025-07-14 14:51:55 -0700456 // Add post-sanitization button replacement
457 private addCopyButtons(html: string): string {
458 return html.replace(
459 /<span class="copy-button-placeholder"><\/span>/g,
460 `<button class="code-copy-button" title="Copy code">
461 <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">
462 <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
463 <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
464 </svg>
465 </button>`,
466 );
467 }
468
469 // Event delegation handler for code copy functionality
470 private handleCodeCopy(event: Event) {
471 const button = event.target as HTMLElement;
472 if (!button.classList.contains("code-copy-button")) return;
473
474 event.stopPropagation();
475
476 // Find the code element using DOM traversal
477 const header = button.closest(".code-block-header");
478 const codeElement = header?.nextElementSibling?.querySelector("code");
479 if (!codeElement) return;
480
481 // Read the text directly from DOM (automatically unescapes HTML)
482 const codeText = codeElement.textContent || "";
483
484 // Copy to clipboard with visual feedback
485 navigator.clipboard
486 .writeText(codeText)
487 .then(() => {
488 // Show success feedback (icon change + floating message)
489 const originalHTML = button.innerHTML;
490 button.innerHTML = `<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">
491 <path d="M20 6L9 17l-5-5"></path>
492 </svg>`;
493 this.showFloatingMessage(
494 "Copied!",
495 button.getBoundingClientRect(),
496 "success",
497 );
498 setTimeout(() => (button.innerHTML = originalHTML), 2000);
499 })
500 .catch((err) => {
501 console.error("Failed to copy code:", err);
502 this.showFloatingMessage(
503 "Failed to copy!",
504 button.getBoundingClientRect(),
505 "error",
506 );
507 });
508 }
509
Sean McCullough86b56862025-04-18 13:04:03 -0700510 renderMarkdown(markdownContent: string): string {
511 try {
Sean McCullough8d93e362025-04-27 23:32:18 +0000512 // Create a custom renderer
513 const renderer = new Renderer();
514 const originalCodeRenderer = renderer.code.bind(renderer);
Autoformatterdded2d62025-04-28 00:27:21 +0000515
Pokey Rulea10f1512025-05-15 13:53:26 +0000516 // Override the code renderer to handle mermaid diagrams and add copy buttons
Autoformatterdded2d62025-04-28 00:27:21 +0000517 renderer.code = function ({ text, lang, escaped }: Tokens.Code): string {
518 if (lang === "mermaid") {
Sean McCullough8d93e362025-04-27 23:32:18 +0000519 // Generate a unique ID for this diagram
520 const id = `mermaid-diagram-${Math.random().toString(36).substring(2, 10)}`;
Autoformatterdded2d62025-04-28 00:27:21 +0000521
Sean McCullough8d93e362025-04-27 23:32:18 +0000522 // Just create the container and mermaid div - we'll render it in the updated() lifecycle method
523 return `<div class="mermaid-container">
524 <div class="mermaid" id="${id}">${text}</div>
525 </div>`;
526 }
Pokey Rulea10f1512025-05-15 13:53:26 +0000527
Philip Zeyliger0d092842025-06-09 18:57:12 -0700528 // For regular code blocks, call the original renderer to get properly escaped HTML
529 const originalCodeHtml = originalCodeRenderer({ text, lang, escaped });
530
531 // Extract the code content from the original HTML to add our custom wrapper
532 // The original renderer returns: <pre><code class="language-x">escapedText</code></pre>
533 const codeMatch = originalCodeHtml.match(
534 /<pre><code[^>]*>([\s\S]*?)<\/code><\/pre>/,
535 );
536 if (!codeMatch) {
537 // Fallback to original if we can't parse it
538 return originalCodeHtml;
539 }
540
541 const escapedText = codeMatch[1];
Pokey Rulea10f1512025-05-15 13:53:26 +0000542 const langClass = lang ? ` class="language-${lang}"` : "";
543
Philip Zeyligerd9acaa72025-07-14 14:51:55 -0700544 // Use placeholder instead of actual button - will be replaced after sanitization
Pokey Rulea10f1512025-05-15 13:53:26 +0000545 return `<div class="code-block-container">
546 <div class="code-block-header">
547 ${lang ? `<span class="code-language">${lang}</span>` : ""}
Philip Zeyligerd9acaa72025-07-14 14:51:55 -0700548 <span class="copy-button-placeholder"></span>
Pokey Rulea10f1512025-05-15 13:53:26 +0000549 </div>
Philip Zeyligerd9acaa72025-07-14 14:51:55 -0700550 <pre><code${langClass}>${escapedText}</code></pre>
Pokey Rulea10f1512025-05-15 13:53:26 +0000551 </div>`;
Sean McCullough8d93e362025-04-27 23:32:18 +0000552 };
Autoformatterdded2d62025-04-28 00:27:21 +0000553
Philip Zeyliger53ab2452025-06-04 17:49:33 +0000554 // Set markdown options for proper code block highlighting
Sean McCullough86b56862025-04-18 13:04:03 -0700555 const markedOptions: MarkedOptions = {
556 gfm: true, // GitHub Flavored Markdown
557 breaks: true, // Convert newlines to <br>
558 async: false,
Autoformatterdded2d62025-04-28 00:27:21 +0000559 renderer: renderer,
Sean McCullough86b56862025-04-18 13:04:03 -0700560 };
Philip Zeyliger53ab2452025-06-04 17:49:33 +0000561
562 // Parse markdown and sanitize the output HTML with DOMPurify
563 const htmlOutput = marked.parse(markdownContent, markedOptions) as string;
Philip Zeyligerd9acaa72025-07-14 14:51:55 -0700564 const sanitizedOutput = DOMPurify.sanitize(htmlOutput, {
Philip Zeyliger53ab2452025-06-04 17:49:33 +0000565 // Allow common HTML elements that are safe
566 ALLOWED_TAGS: [
567 "p",
568 "br",
569 "strong",
570 "em",
571 "b",
572 "i",
573 "u",
574 "s",
575 "code",
576 "pre",
577 "h1",
578 "h2",
579 "h3",
580 "h4",
581 "h5",
582 "h6",
583 "ul",
584 "ol",
585 "li",
586 "blockquote",
587 "a",
588 "div",
589 "span", // For mermaid diagrams and code blocks
590 "svg",
591 "g",
592 "path",
593 "rect",
594 "circle",
595 "text",
596 "line",
597 "polygon", // For mermaid SVG
598 "button", // For code copy buttons
599 ],
600 ALLOWED_ATTR: [
601 "href",
602 "title",
603 "target",
604 "rel", // For links
605 "class",
606 "id", // For styling and functionality
607 "data-*", // For code copy buttons
608 // SVG attributes for mermaid diagrams
609 "viewBox",
610 "width",
611 "height",
612 "xmlns",
613 "fill",
614 "stroke",
615 "stroke-width",
616 "d",
617 "x",
618 "y",
619 "x1",
620 "y1",
621 "x2",
622 "y2",
623 "cx",
624 "cy",
625 "r",
626 "rx",
627 "ry",
628 "points",
629 "transform",
630 "text-anchor",
631 "font-size",
632 "font-family",
633 ],
634 // Allow data attributes for functionality
635 ALLOW_DATA_ATTR: true,
636 // Keep whitespace for code formatting
637 KEEP_CONTENT: true,
638 });
Philip Zeyligerd9acaa72025-07-14 14:51:55 -0700639
640 // Add copy buttons after sanitization
641 return this.addCopyButtons(sanitizedOutput);
Sean McCullough86b56862025-04-18 13:04:03 -0700642 } catch (error) {
643 console.error("Error rendering markdown:", error);
Philip Zeyliger53ab2452025-06-04 17:49:33 +0000644 // Fallback to sanitized plain text if markdown parsing fails
645 return DOMPurify.sanitize(markdownContent);
Sean McCullough86b56862025-04-18 13:04:03 -0700646 }
647 }
648
649 /**
650 * Format timestamp for display
651 */
652 formatTimestamp(
653 timestamp: string | number | Date | null | undefined,
654 defaultValue: string = "",
655 ): string {
656 if (!timestamp) return defaultValue;
657 try {
658 const date = new Date(timestamp);
659 if (isNaN(date.getTime())) return defaultValue;
660
661 // Format: Mar 13, 2025 09:53:25 AM
662 return date.toLocaleString("en-US", {
663 month: "short",
664 day: "numeric",
665 year: "numeric",
666 hour: "numeric",
667 minute: "2-digit",
668 second: "2-digit",
669 hour12: true,
670 });
philip.zeyliger26bc6592025-06-30 20:15:30 -0700671 } catch {
Sean McCullough86b56862025-04-18 13:04:03 -0700672 return defaultValue;
673 }
674 }
675
676 formatNumber(
677 num: number | null | undefined,
678 defaultValue: string = "0",
679 ): string {
680 if (num === undefined || num === null) return defaultValue;
681 try {
682 return num.toLocaleString();
philip.zeyliger26bc6592025-06-30 20:15:30 -0700683 } catch {
Sean McCullough86b56862025-04-18 13:04:03 -0700684 return String(num);
685 }
686 }
687 formatCurrency(
688 num: number | string | null | undefined,
689 defaultValue: string = "$0.00",
690 isMessageLevel: boolean = false,
691 ): string {
692 if (num === undefined || num === null) return defaultValue;
693 try {
694 // Use 4 decimal places for message-level costs, 2 for totals
695 const decimalPlaces = isMessageLevel ? 4 : 2;
696 return `$${parseFloat(String(num)).toFixed(decimalPlaces)}`;
philip.zeyliger26bc6592025-06-30 20:15:30 -0700697 } catch {
Sean McCullough86b56862025-04-18 13:04:03 -0700698 return defaultValue;
699 }
700 }
701
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000702 // Format duration from nanoseconds to a human-readable string
703 _formatDuration(nanoseconds: number | null | undefined): string {
704 if (!nanoseconds) return "0s";
705
706 const seconds = nanoseconds / 1e9;
707
708 if (seconds < 60) {
709 return `${seconds.toFixed(1)}s`;
710 } else if (seconds < 3600) {
711 const minutes = Math.floor(seconds / 60);
712 const remainingSeconds = seconds % 60;
713 return `${minutes}min ${remainingSeconds.toFixed(0)}s`;
714 } else {
715 const hours = Math.floor(seconds / 3600);
716 const remainingSeconds = seconds % 3600;
717 const minutes = Math.floor(remainingSeconds / 60);
718 return `${hours}h ${minutes}min`;
719 }
720 }
721
Sean McCullough86b56862025-04-18 13:04:03 -0700722 showCommit(commitHash: string) {
Sean McCullough71941bd2025-04-18 13:31:48 -0700723 this.dispatchEvent(
724 new CustomEvent("show-commit-diff", {
725 bubbles: true,
726 composed: true,
727 detail: { commitHash },
728 }),
729 );
Sean McCullough86b56862025-04-18 13:04:03 -0700730 }
731
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000732 _toggleInfo(e: Event) {
733 e.stopPropagation();
734 this.showInfo = !this.showInfo;
735 }
736
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +0000737 copyToClipboard(text: string, event: Event) {
738 const element = event.currentTarget as HTMLElement;
739 const rect = element.getBoundingClientRect();
740
741 navigator.clipboard
742 .writeText(text)
743 .then(() => {
744 this.showFloatingMessage("Copied!", rect, "success");
745 })
746 .catch((err) => {
747 console.error("Failed to copy text: ", err);
748 this.showFloatingMessage("Failed to copy!", rect, "error");
749 });
750 }
751
752 showFloatingMessage(
753 message: string,
754 targetRect: DOMRect,
755 type: "success" | "error",
756 ) {
757 // Create floating message element
758 const floatingMsg = document.createElement("div");
759 floatingMsg.textContent = message;
760 floatingMsg.className = `floating-message ${type}`;
761
762 // Position it near the clicked element
763 // Position just above the element
764 const top = targetRect.top - 30;
765 const left = targetRect.left + targetRect.width / 2 - 40;
766
767 floatingMsg.style.position = "fixed";
768 floatingMsg.style.top = `${top}px`;
769 floatingMsg.style.left = `${left}px`;
770 floatingMsg.style.zIndex = "9999";
771
772 // Add to document body
773 document.body.appendChild(floatingMsg);
774
775 // Animate in
776 floatingMsg.style.opacity = "0";
777 floatingMsg.style.transform = "translateY(10px)";
778
779 setTimeout(() => {
780 floatingMsg.style.opacity = "1";
781 floatingMsg.style.transform = "translateY(0)";
782 }, 10);
783
784 // Remove after animation
785 setTimeout(() => {
786 floatingMsg.style.opacity = "0";
787 floatingMsg.style.transform = "translateY(-10px)";
788
789 setTimeout(() => {
790 document.body.removeChild(floatingMsg);
791 }, 300);
792 }, 1500);
793 }
794
philip.zeyliger6d3de482025-06-10 19:38:14 -0700795 // Format GitHub repository URL to org/repo format
796 formatGitHubRepo(url) {
797 if (!url) return null;
798
799 // Common GitHub URL patterns
800 const patterns = [
801 // HTTPS URLs
802 /https:\/\/github\.com\/([^/]+)\/([^/\s.]+)(?:\.git)?/,
803 // SSH URLs
804 /git@github\.com:([^/]+)\/([^/\s.]+)(?:\.git)?/,
805 // Git protocol
806 /git:\/\/github\.com\/([^/]+)\/([^/\s.]+)(?:\.git)?/,
807 ];
808
809 for (const pattern of patterns) {
810 const match = url.match(pattern);
811 if (match) {
812 return {
813 formatted: `${match[1]}/${match[2]}`,
814 url: `https://github.com/${match[1]}/${match[2]}`,
815 owner: match[1],
816 repo: match[2],
817 };
818 }
819 }
820
821 return null;
822 }
823
824 // Generate GitHub branch URL if linking is enabled
825 getGitHubBranchLink(branchName) {
826 if (!this.state?.link_to_github || !branchName) {
827 return null;
828 }
829
830 const github = this.formatGitHubRepo(this.state?.git_origin);
831 if (!github) {
832 return null;
833 }
834
835 return `https://github.com/${github.owner}/${github.repo}/tree/${branchName}`;
836 }
837
Sean McCullough86b56862025-04-18 13:04:03 -0700838 render() {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000839 // Calculate if this is an end of turn message with no parent conversation ID
840 const isEndOfTurn =
841 this.message?.end_of_turn && !this.message?.parent_conversation_id;
842
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700843 const isPreCompaction =
844 this.message?.idx !== undefined &&
845 this.message.idx < this.firstMessageIndex;
846
bankseanc5147482025-06-29 00:41:58 +0000847 // Dynamic classes based on message type and state
848 const messageClasses = [
849 "relative mb-1.5 flex flex-col w-full", // base message styles
850 isEndOfTurn ? "mb-4" : "", // end-of-turn spacing
851 isPreCompaction ? "opacity-85 border-l-2 border-gray-300" : "", // pre-compaction styling
852 ]
853 .filter(Boolean)
854 .join(" ");
855
856 const bubbleContainerClasses = [
bankseandc27c392025-07-11 18:36:14 -0700857 "flex-1 flex text-ellipsis",
bankseanc5147482025-06-29 00:41:58 +0000858 this.compactPadding ? "max-w-full" : "max-w-[calc(100%-160px)]",
859 this.message?.type === "user" ? "justify-end" : "justify-start",
860 ]
861 .filter(Boolean)
862 .join(" ");
863
864 const messageContentClasses = [
865 "relative px-2.5 py-1.5 rounded-xl shadow-sm max-w-full w-fit min-w-min break-words word-break-words",
866 // User message styling
867 this.message?.type === "user"
868 ? "bg-blue-500 text-white rounded-br-sm"
869 : // Agent/tool/error message styling
870 "bg-gray-100 text-black rounded-bl-sm",
871 ]
872 .filter(Boolean)
873 .join(" ");
874
Sean McCullough86b56862025-04-18 13:04:03 -0700875 return html`
bankseanc5147482025-06-29 00:41:58 +0000876 <div class="${messageClasses}">
877 <div class="flex relative w-full">
878 <!-- Left metadata area -->
879 <div
880 class="${this.compactPadding
881 ? "hidden"
882 : "flex-none w-20 px-1 py-0.5 text-right text-xs text-gray-500 self-start"}"
883 ></div>
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000884
885 <!-- Message bubble -->
bankseanc5147482025-06-29 00:41:58 +0000886 <div class="${bubbleContainerClasses}">
887 <div class="${messageContentClasses}">
888 <div class="relative">
889 <div
890 class="absolute top-1 right-1 z-10 opacity-0 hover:opacity-100 transition-opacity duration-200 flex gap-1.5"
891 >
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000892 ${copyButton(this.message?.content)}
893 <button
bankseanc5147482025-06-29 00:41:58 +0000894 class="bg-transparent border-none ${this.message?.type ===
895 "user"
896 ? "text-white/80 hover:bg-white/15"
897 : "text-black/60 hover:bg-black/8"} cursor-pointer p-0.5 rounded-full flex items-center justify-center w-6 h-6 transition-all duration-150"
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000898 title="Show message details"
899 @click=${this._toggleInfo}
Sean McCullough71941bd2025-04-18 13:31:48 -0700900 >
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000901 <svg
902 xmlns="http://www.w3.org/2000/svg"
903 width="16"
904 height="16"
905 viewBox="0 0 24 24"
906 fill="none"
907 stroke="currentColor"
908 stroke-width="2"
909 stroke-linecap="round"
910 stroke-linejoin="round"
911 >
912 <circle cx="12" cy="12" r="10"></circle>
913 <line x1="12" y1="16" x2="12" y2="12"></line>
914 <line x1="12" y1="8" x2="12.01" y2="8"></line>
915 </svg>
916 </button>
917 </div>
918 ${this.message?.content
919 ? html`
bankseanc5147482025-06-29 00:41:58 +0000920 <div
921 class="overflow-x-auto mb-0 font-sans py-0.5 select-text cursor-text text-sm leading-relaxed text-left min-w-[200px] box-border mx-auto markdown-content"
Philip Zeyligerd9acaa72025-07-14 14:51:55 -0700922 @click=${this.handleCodeCopy}
bankseanc5147482025-06-29 00:41:58 +0000923 >
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000924 ${unsafeHTML(
925 this.renderMarkdown(this.message?.content),
926 )}
927 </div>
928 `
929 : ""}
930
931 <!-- End of turn indicator inside the bubble -->
932 ${isEndOfTurn && this.message?.elapsed
933 ? html`
bankseanc5147482025-06-29 00:41:58 +0000934 <div
935 class="block text-xs ${this.message?.type === "user"
936 ? "text-white/70"
937 : "text-gray-500"} py-0.5 mt-2 text-right italic"
938 >
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000939 end of turn
940 (${this._formatDuration(this.message?.elapsed)})
941 </div>
942 `
943 : ""}
944
945 <!-- Info panel that can be toggled -->
946 ${this.showInfo
947 ? html`
bankseanc5147482025-06-29 00:41:58 +0000948 <div
949 class="mt-2 p-2 ${this.message?.type === "user"
950 ? "bg-white/15 border-l-2 border-white/20"
951 : "bg-black/5 border-l-2 border-black/10"} rounded-md text-xs transition-all duration-200"
952 >
953 <div class="mb-1 flex">
954 <span class="font-bold mr-1 min-w-[60px]">Type:</span>
955 <span class="flex-1">${this.message?.type}</span>
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000956 </div>
bankseanc5147482025-06-29 00:41:58 +0000957 <div class="mb-1 flex">
958 <span class="font-bold mr-1 min-w-[60px]">Time:</span>
959 <span class="flex-1">
960 ${this.formatTimestamp(this.message?.timestamp, "")}
961 </span>
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000962 </div>
963 ${this.message?.elapsed
964 ? html`
bankseanc5147482025-06-29 00:41:58 +0000965 <div class="mb-1 flex">
966 <span class="font-bold mr-1 min-w-[60px]"
967 >Duration:</span
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000968 >
bankseanc5147482025-06-29 00:41:58 +0000969 <span class="flex-1">
970 ${this._formatDuration(this.message?.elapsed)}
971 </span>
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000972 </div>
973 `
974 : ""}
975 ${this.message?.usage
976 ? html`
bankseanc5147482025-06-29 00:41:58 +0000977 <div class="mb-1 flex">
978 <span class="font-bold mr-1 min-w-[60px]"
979 >Tokens:</span
980 >
981 <span class="flex-1">
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000982 ${this.message?.usage
983 ? html`
984 <div>
985 Input:
986 ${this.formatNumber(
987 this.message?.usage?.input_tokens ||
988 0,
989 )}
990 </div>
991 ${this.message?.usage
992 ?.cache_creation_input_tokens
993 ? html`
994 <div>
995 Cache creation:
996 ${this.formatNumber(
997 this.message?.usage
998 ?.cache_creation_input_tokens,
999 )}
1000 </div>
1001 `
1002 : ""}
1003 ${this.message?.usage
1004 ?.cache_read_input_tokens
1005 ? html`
1006 <div>
1007 Cache read:
1008 ${this.formatNumber(
1009 this.message?.usage
1010 ?.cache_read_input_tokens,
1011 )}
1012 </div>
1013 `
1014 : ""}
1015 <div>
1016 Output:
1017 ${this.formatNumber(
1018 this.message?.usage?.output_tokens,
1019 )}
1020 </div>
1021 <div>
1022 Cost:
1023 ${this.formatCurrency(
1024 this.message?.usage?.cost_usd,
1025 )}
1026 </div>
1027 `
1028 : "N/A"}
1029 </span>
1030 </div>
1031 `
1032 : ""}
1033 ${this.message?.conversation_id
1034 ? html`
bankseanc5147482025-06-29 00:41:58 +00001035 <div class="mb-1 flex">
1036 <span class="font-bold mr-1 min-w-[60px]"
1037 >Conversation ID:</span
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001038 >
bankseanc5147482025-06-29 00:41:58 +00001039 <span
1040 class="flex-1 font-mono text-xs break-all"
1041 >
1042 ${this.message?.conversation_id}
1043 </span>
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001044 </div>
1045 `
1046 : ""}
1047 </div>
1048 `
1049 : ""}
1050 </div>
1051
1052 <!-- Tool calls - only shown for agent messages -->
1053 ${this.message?.type === "agent"
1054 ? html`
1055 <sketch-tool-calls
1056 .toolCalls=${this.message?.tool_calls}
1057 .open=${this.open}
1058 ></sketch-tool-calls>
1059 `
1060 : ""}
1061
bankseanc5147482025-06-29 00:41:58 +00001062 <!-- Commits section -->
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001063 ${this.message?.commits
1064 ? html`
bankseanc5147482025-06-29 00:41:58 +00001065 <div class="mt-2.5">
1066 <div
1067 class="bg-green-100 text-green-800 font-medium text-xs py-1.5 px-2.5 rounded-2xl mb-2 text-center shadow-sm"
1068 >
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001069 ${this.message.commits.length} new
1070 commit${this.message.commits.length > 1 ? "s" : ""}
1071 detected
1072 </div>
1073 ${this.message.commits.map((commit) => {
1074 return html`
bankseanc5147482025-06-29 00:41:58 +00001075 <div
Sean McCulloughfd67b012025-07-01 14:58:23 -07001076 class="text-sm bg-gray-100 rounded-lg overflow-hidden mb-1.5 shadow-sm p-1.5 px-2 flex items-center gap-2"
bankseanc5147482025-06-29 00:41:58 +00001077 >
Philip Zeyliger72682df2025-04-23 13:09:46 -07001078 <span
bankseanc5147482025-06-29 00:41:58 +00001079 class="text-blue-600 font-bold font-mono cursor-pointer no-underline bg-blue-600/10 py-0.5 px-1 rounded hover:bg-blue-600/20"
Philip Zeyliger72682df2025-04-23 13:09:46 -07001080 title="Click to copy: ${commit.hash}"
1081 @click=${(e) =>
1082 this.copyToClipboard(
1083 commit.hash.substring(0, 8),
1084 e,
1085 )}
1086 >
Pokey Rule7be879f2025-04-23 15:30:15 +01001087 ${commit.hash.substring(0, 8)}
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +00001088 </span>
1089 ${commit.pushed_branch
philip.zeyliger6d3de482025-06-10 19:38:14 -07001090 ? (() => {
1091 const githubLink = this.getGitHubBranchLink(
1092 commit.pushed_branch,
1093 );
1094 return html`
bankseanc5147482025-06-29 00:41:58 +00001095 <div class="flex items-center gap-1.5">
philip.zeyliger6d3de482025-06-10 19:38:14 -07001096 <span
bankseanc5147482025-06-29 00:41:58 +00001097 class="text-green-600 font-medium cursor-pointer font-mono bg-green-600/10 py-0.5 px-1 rounded hover:bg-green-600/20"
philip.zeyliger6d3de482025-06-10 19:38:14 -07001098 title="Click to copy: ${commit.pushed_branch}"
1099 @click=${(e) =>
1100 this.copyToClipboard(
1101 commit.pushed_branch,
1102 e,
1103 )}
1104 >${commit.pushed_branch}</span
1105 >
cbroebbdee42025-06-20 09:57:44 +00001106 <span
bankseanc5147482025-06-29 00:41:58 +00001107 class="opacity-70 flex items-center hover:opacity-100"
cbroebbdee42025-06-20 09:57:44 +00001108 @click=${(e) => {
1109 e.stopPropagation();
1110 this.copyToClipboard(
1111 commit.pushed_branch,
1112 e,
1113 );
1114 }}
1115 >
philip.zeyliger6d3de482025-06-10 19:38:14 -07001116 <svg
1117 xmlns="http://www.w3.org/2000/svg"
1118 width="14"
1119 height="14"
1120 viewBox="0 0 24 24"
1121 fill="none"
1122 stroke="currentColor"
1123 stroke-width="2"
1124 stroke-linecap="round"
1125 stroke-linejoin="round"
bankseanc5147482025-06-29 00:41:58 +00001126 class="align-middle"
philip.zeyliger6d3de482025-06-10 19:38:14 -07001127 >
1128 <rect
1129 x="9"
1130 y="9"
1131 width="13"
1132 height="13"
1133 rx="2"
1134 ry="2"
1135 ></rect>
1136 <path
1137 d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"
1138 ></path>
1139 </svg>
1140 </span>
1141 ${githubLink
1142 ? html`
1143 <a
1144 href="${githubLink}"
1145 target="_blank"
1146 rel="noopener noreferrer"
bankseanc5147482025-06-29 00:41:58 +00001147 class="text-gray-600 no-underline flex items-center transition-colors duration-200 hover:text-blue-600"
philip.zeyliger6d3de482025-06-10 19:38:14 -07001148 title="Open ${commit.pushed_branch} on GitHub"
1149 @click=${(e) =>
1150 e.stopPropagation()}
1151 >
1152 <svg
bankseanc5147482025-06-29 00:41:58 +00001153 class="w-3.5 h-3.5"
philip.zeyliger6d3de482025-06-10 19:38:14 -07001154 viewBox="0 0 16 16"
1155 width="14"
1156 height="14"
1157 >
1158 <path
1159 fill="currentColor"
1160 d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"
1161 />
1162 </svg>
1163 </a>
1164 `
1165 : ""}
1166 </div>
1167 `;
1168 })()
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +00001169 : ``}
bankseanc5147482025-06-29 00:41:58 +00001170 <span
1171 class="text-sm text-gray-700 flex-grow truncate"
Sean McCullough71941bd2025-04-18 13:31:48 -07001172 >
bankseanc5147482025-06-29 00:41:58 +00001173 ${commit.subject}
1174 </span>
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +00001175 <button
bankseanc5147482025-06-29 00:41:58 +00001176 class="py-0.5 px-2 border-0 rounded bg-blue-600 text-white text-xs cursor-pointer transition-all duration-200 block ml-auto hover:bg-blue-700"
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +00001177 @click=${() => this.showCommit(commit.hash)}
1178 >
1179 View Diff
1180 </button>
Sean McCullough86b56862025-04-18 13:04:03 -07001181 </div>
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001182 `;
1183 })}
1184 </div>
1185 `
1186 : ""}
1187 </div>
1188 </div>
1189
bankseanc5147482025-06-29 00:41:58 +00001190 <!-- Right metadata area -->
1191 <div
1192 class="${this.compactPadding
1193 ? "hidden"
1194 : "flex-none w-20 px-1 py-0.5 text-left text-xs text-gray-500 self-start"}"
1195 ></div>
Sean McCullough86b56862025-04-18 13:04:03 -07001196 </div>
bankseancad67b02025-06-27 21:57:05 +00001197
1198 <!-- User name for user messages - positioned outside and below the bubble -->
1199 ${this.message?.type === "user" && this.state?.git_username
1200 ? html`
bankseanc5147482025-06-29 00:41:58 +00001201 <div
1202 class="flex justify-end mt-1 ${this.compactPadding
1203 ? ""
1204 : "pr-20"}"
1205 >
1206 <div class="text-xs text-gray-600 italic text-right">
bankseanb7ec9c82025-07-09 10:16:39 -07001207 ${this.state?.link_to_github
1208 ? html`@<a
1209 class="no-underline hover:underline"
1210 href="${this.state.link_to_github}"
1211 title="${this.state.git_username} on GitHub"
1212 >${this.state.git_username}</a
1213 >`
1214 : ""}
bankseanc5147482025-06-29 00:41:58 +00001215 </div>
bankseancad67b02025-06-27 21:57:05 +00001216 </div>
1217 `
1218 : ""}
Sean McCullough86b56862025-04-18 13:04:03 -07001219 </div>
1220 `;
1221 }
1222}
1223
Sean McCullough71941bd2025-04-18 13:31:48 -07001224function copyButton(textToCopy: string) {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001225 // SVG for copy icon (two overlapping rectangles)
1226 const copyIcon = html`<svg
1227 xmlns="http://www.w3.org/2000/svg"
1228 width="16"
1229 height="16"
1230 viewBox="0 0 24 24"
1231 fill="none"
1232 stroke="currentColor"
1233 stroke-width="2"
1234 stroke-linecap="round"
1235 stroke-linejoin="round"
1236 >
1237 <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
1238 <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
1239 </svg>`;
1240
1241 // SVG for success check mark
1242 const successIcon = html`<svg
1243 xmlns="http://www.w3.org/2000/svg"
1244 width="16"
1245 height="16"
1246 viewBox="0 0 24 24"
1247 fill="none"
1248 stroke="currentColor"
1249 stroke-width="2"
1250 stroke-linecap="round"
1251 stroke-linejoin="round"
1252 >
1253 <path d="M20 6L9 17l-5-5"></path>
1254 </svg>`;
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +00001255
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001256 const ret = html`<button
bankseanc5147482025-06-29 00:41:58 +00001257 class="bg-transparent border-none cursor-pointer p-0.5 rounded-full flex items-center justify-center w-6 h-6 transition-all duration-150"
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +00001258 title="Copy to clipboard"
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001259 @click=${(e: Event) => {
1260 e.stopPropagation();
1261 const copyButton = e.currentTarget as HTMLButtonElement;
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001262 const originalInnerHTML = copyButton.innerHTML;
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001263 navigator.clipboard
1264 .writeText(textToCopy)
1265 .then(() => {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001266 copyButton.innerHTML = "";
1267 const successElement = document.createElement("div");
1268 copyButton.appendChild(successElement);
1269 render(successIcon, successElement);
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001270 setTimeout(() => {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001271 copyButton.innerHTML = originalInnerHTML;
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001272 }, 2000);
1273 })
1274 .catch((err) => {
1275 console.error("Failed to copy text: ", err);
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001276 setTimeout(() => {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001277 copyButton.innerHTML = originalInnerHTML;
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001278 }, 2000);
1279 });
1280 }}
1281 >
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001282 ${copyIcon}
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001283 </button>`;
Sean McCullough86b56862025-04-18 13:04:03 -07001284
Sean McCullough71941bd2025-04-18 13:31:48 -07001285 return ret;
Sean McCullough86b56862025-04-18 13:04:03 -07001286}
1287
bankseanc5147482025-06-29 00:41:58 +00001288// Global styles are now injected in the component's connectedCallback() method
1289// to ensure they are added when the component is actually used, not at module load time
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +00001290
Sean McCullough86b56862025-04-18 13:04:03 -07001291declare global {
1292 interface HTMLElementTagNameMap {
1293 "sketch-timeline-message": SketchTimelineMessage;
1294 }
1295}