blob: a45ed5c656b8363bf8eafb3d17d44dcc0e9721ae [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%;
bankseanc5147482025-06-29 00:41:58 +0000267 box-sizing: border-box;
banksean02693402025-07-17 17:10:10 +0000268 /* Reset word breaking for code blocks - they should not wrap */
269 word-break: normal;
270 overflow-wrap: normal;
271 white-space: nowrap;
272 }
273
274 /* Ensure proper word breaking for all markdown content EXCEPT code blocks */
275 .markdown-content {
276 overflow-wrap: break-word;
277 word-wrap: break-word;
278 word-break: break-word;
279 hyphens: auto;
280 max-width: 100%;
281 }
282
283 /* Handle long URLs and unbreakable strings in text content */
284 .markdown-content a,
285 .markdown-content span:not(.code-language),
286 .markdown-content p {
287 overflow-wrap: break-word;
288 word-wrap: break-word;
289 word-break: break-word;
bankseanc5147482025-06-29 00:41:58 +0000290 }
291
292 .markdown-content pre {
293 padding: 8px 12px;
294 margin: 0.5rem 0;
295 line-height: 1.4;
banksean02693402025-07-17 17:10:10 +0000296 /* Ensure code blocks don't inherit word breaking */
297 word-break: normal;
298 overflow-wrap: normal;
299 white-space: nowrap;
bankseanc5147482025-06-29 00:41:58 +0000300 }
301
302 .markdown-content pre code {
303 background: transparent;
304 padding: 0;
banksean02693402025-07-17 17:10:10 +0000305 /* Ensure inline code in pre blocks doesn't inherit word breaking */
306 word-break: normal;
307 overflow-wrap: normal;
308 white-space: pre;
bankseanc5147482025-06-29 00:41:58 +0000309 }
310
311 /* User message code styling */
312 sketch-timeline-message .bg-blue-500 .markdown-content pre,
313 sketch-timeline-message .bg-blue-500 .markdown-content code {
314 background: rgba(255, 255, 255, 0.2);
315 color: white;
316 }
317
318 sketch-timeline-message .bg-blue-500 .markdown-content pre code {
319 background: transparent;
320 }
321
322 /* Code block containers */
323 .code-block-container {
324 position: relative;
325 margin: 8px 0;
326 border-radius: 6px;
banksean02693402025-07-17 17:10:10 +0000327 overflow-x: auto;
328 overflow-y: hidden;
bankseanc5147482025-06-29 00:41:58 +0000329 background: rgba(0, 0, 0, 0.05);
banksean02693402025-07-17 17:10:10 +0000330 max-width: 100%;
331 width: 100%;
bankseanc5147482025-06-29 00:41:58 +0000332 }
333
334 sketch-timeline-message .bg-blue-500 .code-block-container {
335 background: rgba(255, 255, 255, 0.2);
336 }
337
338 .code-block-header {
339 display: flex;
340 justify-content: space-between;
341 align-items: center;
342 padding: 4px 8px;
343 background: rgba(0, 0, 0, 0.1);
344 font-size: 12px;
345 }
346
347 sketch-timeline-message .bg-blue-500 .code-block-header {
348 background: rgba(255, 255, 255, 0.2);
349 color: white;
350 }
351
352 .code-language {
353 font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
354 font-size: 11px;
355 font-weight: 500;
356 }
357
358 .code-copy-button {
359 background: transparent;
360 border: none;
361 cursor: pointer;
362 padding: 2px;
363 border-radius: 3px;
364 display: flex;
365 align-items: center;
366 justify-content: center;
367 opacity: 0.7;
368 transition: all 0.15s ease;
369 }
370
371 .code-copy-button:hover {
372 opacity: 1;
373 background: rgba(0, 0, 0, 0.1);
374 }
375
376 sketch-timeline-message .bg-blue-500 .code-copy-button:hover {
377 background: rgba(255, 255, 255, 0.2);
378 }
379
380 .code-block-container pre {
381 margin: 0;
382 padding: 8px;
383 background: transparent;
384 }
385
386 .code-block-container code {
387 background: transparent;
388 padding: 0;
389 display: block;
390 width: 100%;
391 }
392
393 /* Mermaid diagram styling */
394 .mermaid-container {
395 margin: 1rem 0;
396 padding: 0.5rem;
397 background-color: #f8f8f8;
398 border-radius: 4px;
399 overflow-x: auto;
400 }
401
402 .mermaid {
403 text-align: center;
404 }
405
406 /* Print styles */
407 @media print {
408 .floating-message,
409 .commit-diff-button,
410 button[title="Copy to clipboard"],
411 button[title="Show message details"] {
412 display: none !important;
413 }
414 }
415`;
Sean McCullough86b56862025-04-18 13:04:03 -0700416 }
Autoformatterdded2d62025-04-28 00:27:21 +0000417
Sean McCullough8d93e362025-04-27 23:32:18 +0000418 // After the component is updated and rendered, render any mermaid diagrams
419 updated(changedProperties: Map<string, unknown>) {
420 super.updated(changedProperties);
421 this.renderMermaidDiagrams();
422 }
Autoformatterdded2d62025-04-28 00:27:21 +0000423
Sean McCullough8d93e362025-04-27 23:32:18 +0000424 // Render mermaid diagrams after the component is updated
425 renderMermaidDiagrams() {
426 // Add a small delay to ensure the DOM is fully rendered
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000427 setTimeout(async () => {
Sean McCullough8d93e362025-04-27 23:32:18 +0000428 // Find all mermaid containers in our shadow root
Sean McCulloughf6e1dfe2025-07-03 14:59:40 -0700429 const containers = this.querySelectorAll(".mermaid");
Sean McCullough8d93e362025-04-27 23:32:18 +0000430 if (!containers || containers.length === 0) return;
Autoformatterdded2d62025-04-28 00:27:21 +0000431
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000432 try {
433 // Load mermaid dynamically
434 const mermaidLib = await loadMermaid();
Autoformatterdded2d62025-04-28 00:27:21 +0000435
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000436 // Initialize mermaid with specific config (only once per load)
437 mermaidLib.initialize({
438 startOnLoad: false,
439 suppressErrorRendering: true,
440 theme: "default",
441 securityLevel: "loose", // Allows more flexibility but be careful with user-generated content
442 fontFamily: "monospace",
443 });
Autoformatterdded2d62025-04-28 00:27:21 +0000444
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000445 // Process each mermaid diagram
446 containers.forEach((container) => {
447 const id = container.id;
448 const code = container.textContent || "";
449 if (!code || !id) return; // Use return for forEach instead of continue
450
451 try {
452 // Clear any previous content
453 container.innerHTML = code;
454
455 // Render the mermaid diagram using promise
456 mermaidLib
457 .render(`${id}-svg`, code)
458 .then(({ svg }) => {
459 container.innerHTML = svg;
460 })
461 .catch((err) => {
462 console.error("Error rendering mermaid diagram:", err);
463 // Show the original code as fallback
464 container.innerHTML = `<pre>${code}</pre>`;
465 });
466 } catch (err) {
467 console.error("Error processing mermaid diagram:", err);
468 // Show the original code as fallback
469 container.innerHTML = `<pre>${code}</pre>`;
470 }
471 });
472 } catch (err) {
473 console.error("Error loading mermaid:", err);
474 // Show the original code as fallback for all diagrams
475 containers.forEach((container) => {
476 const code = container.textContent || "";
Sean McCullough8d93e362025-04-27 23:32:18 +0000477 container.innerHTML = `<pre>${code}</pre>`;
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000478 });
479 }
Sean McCullough8d93e362025-04-27 23:32:18 +0000480 }, 100); // Small delay to ensure DOM is ready
481 }
Sean McCullough86b56862025-04-18 13:04:03 -0700482
483 // See https://lit.dev/docs/components/lifecycle/
484 disconnectedCallback() {
485 super.disconnectedCallback();
486 }
487
Philip Zeyligerd9acaa72025-07-14 14:51:55 -0700488 // Add post-sanitization button replacement
489 private addCopyButtons(html: string): string {
490 return html.replace(
491 /<span class="copy-button-placeholder"><\/span>/g,
492 `<button class="code-copy-button" title="Copy code">
banksean2cc75632025-07-17 17:10:17 +0000493 <svg class="code-copy-button" 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">
Philip Zeyligerd9acaa72025-07-14 14:51:55 -0700494 <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
495 <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
496 </svg>
497 </button>`,
498 );
499 }
500
501 // Event delegation handler for code copy functionality
502 private handleCodeCopy(event: Event) {
503 const button = event.target as HTMLElement;
504 if (!button.classList.contains("code-copy-button")) return;
505
506 event.stopPropagation();
507
508 // Find the code element using DOM traversal
509 const header = button.closest(".code-block-header");
510 const codeElement = header?.nextElementSibling?.querySelector("code");
511 if (!codeElement) return;
512
513 // Read the text directly from DOM (automatically unescapes HTML)
514 const codeText = codeElement.textContent || "";
515
516 // Copy to clipboard with visual feedback
517 navigator.clipboard
518 .writeText(codeText)
519 .then(() => {
520 // Show success feedback (icon change + floating message)
521 const originalHTML = button.innerHTML;
522 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">
523 <path d="M20 6L9 17l-5-5"></path>
524 </svg>`;
525 this.showFloatingMessage(
526 "Copied!",
527 button.getBoundingClientRect(),
528 "success",
529 );
530 setTimeout(() => (button.innerHTML = originalHTML), 2000);
531 })
532 .catch((err) => {
533 console.error("Failed to copy code:", err);
534 this.showFloatingMessage(
535 "Failed to copy!",
536 button.getBoundingClientRect(),
537 "error",
538 );
539 });
540 }
541
Sean McCullough86b56862025-04-18 13:04:03 -0700542 renderMarkdown(markdownContent: string): string {
543 try {
Sean McCullough8d93e362025-04-27 23:32:18 +0000544 // Create a custom renderer
545 const renderer = new Renderer();
546 const originalCodeRenderer = renderer.code.bind(renderer);
Autoformatterdded2d62025-04-28 00:27:21 +0000547
Pokey Rulea10f1512025-05-15 13:53:26 +0000548 // Override the code renderer to handle mermaid diagrams and add copy buttons
Autoformatterdded2d62025-04-28 00:27:21 +0000549 renderer.code = function ({ text, lang, escaped }: Tokens.Code): string {
550 if (lang === "mermaid") {
Sean McCullough8d93e362025-04-27 23:32:18 +0000551 // Generate a unique ID for this diagram
552 const id = `mermaid-diagram-${Math.random().toString(36).substring(2, 10)}`;
Autoformatterdded2d62025-04-28 00:27:21 +0000553
Sean McCullough8d93e362025-04-27 23:32:18 +0000554 // Just create the container and mermaid div - we'll render it in the updated() lifecycle method
555 return `<div class="mermaid-container">
556 <div class="mermaid" id="${id}">${text}</div>
557 </div>`;
558 }
Pokey Rulea10f1512025-05-15 13:53:26 +0000559
Philip Zeyliger0d092842025-06-09 18:57:12 -0700560 // For regular code blocks, call the original renderer to get properly escaped HTML
561 const originalCodeHtml = originalCodeRenderer({ text, lang, escaped });
562
563 // Extract the code content from the original HTML to add our custom wrapper
564 // The original renderer returns: <pre><code class="language-x">escapedText</code></pre>
565 const codeMatch = originalCodeHtml.match(
566 /<pre><code[^>]*>([\s\S]*?)<\/code><\/pre>/,
567 );
568 if (!codeMatch) {
569 // Fallback to original if we can't parse it
570 return originalCodeHtml;
571 }
572
573 const escapedText = codeMatch[1];
Pokey Rulea10f1512025-05-15 13:53:26 +0000574 const langClass = lang ? ` class="language-${lang}"` : "";
575
Philip Zeyligerd9acaa72025-07-14 14:51:55 -0700576 // Use placeholder instead of actual button - will be replaced after sanitization
Pokey Rulea10f1512025-05-15 13:53:26 +0000577 return `<div class="code-block-container">
578 <div class="code-block-header">
579 ${lang ? `<span class="code-language">${lang}</span>` : ""}
Philip Zeyligerd9acaa72025-07-14 14:51:55 -0700580 <span class="copy-button-placeholder"></span>
Pokey Rulea10f1512025-05-15 13:53:26 +0000581 </div>
Philip Zeyligerd9acaa72025-07-14 14:51:55 -0700582 <pre><code${langClass}>${escapedText}</code></pre>
Pokey Rulea10f1512025-05-15 13:53:26 +0000583 </div>`;
Sean McCullough8d93e362025-04-27 23:32:18 +0000584 };
Autoformatterdded2d62025-04-28 00:27:21 +0000585
Philip Zeyliger53ab2452025-06-04 17:49:33 +0000586 // Set markdown options for proper code block highlighting
Sean McCullough86b56862025-04-18 13:04:03 -0700587 const markedOptions: MarkedOptions = {
588 gfm: true, // GitHub Flavored Markdown
589 breaks: true, // Convert newlines to <br>
590 async: false,
Autoformatterdded2d62025-04-28 00:27:21 +0000591 renderer: renderer,
Sean McCullough86b56862025-04-18 13:04:03 -0700592 };
Philip Zeyliger53ab2452025-06-04 17:49:33 +0000593
594 // Parse markdown and sanitize the output HTML with DOMPurify
595 const htmlOutput = marked.parse(markdownContent, markedOptions) as string;
Philip Zeyligerd9acaa72025-07-14 14:51:55 -0700596 const sanitizedOutput = DOMPurify.sanitize(htmlOutput, {
Philip Zeyliger53ab2452025-06-04 17:49:33 +0000597 // Allow common HTML elements that are safe
598 ALLOWED_TAGS: [
599 "p",
600 "br",
601 "strong",
602 "em",
603 "b",
604 "i",
605 "u",
606 "s",
607 "code",
608 "pre",
609 "h1",
610 "h2",
611 "h3",
612 "h4",
613 "h5",
614 "h6",
615 "ul",
616 "ol",
617 "li",
618 "blockquote",
619 "a",
620 "div",
621 "span", // For mermaid diagrams and code blocks
622 "svg",
623 "g",
624 "path",
625 "rect",
626 "circle",
627 "text",
628 "line",
629 "polygon", // For mermaid SVG
630 "button", // For code copy buttons
631 ],
632 ALLOWED_ATTR: [
633 "href",
634 "title",
635 "target",
636 "rel", // For links
637 "class",
638 "id", // For styling and functionality
639 "data-*", // For code copy buttons
640 // SVG attributes for mermaid diagrams
641 "viewBox",
642 "width",
643 "height",
644 "xmlns",
645 "fill",
646 "stroke",
647 "stroke-width",
648 "d",
649 "x",
650 "y",
651 "x1",
652 "y1",
653 "x2",
654 "y2",
655 "cx",
656 "cy",
657 "r",
658 "rx",
659 "ry",
660 "points",
661 "transform",
662 "text-anchor",
663 "font-size",
664 "font-family",
665 ],
666 // Allow data attributes for functionality
667 ALLOW_DATA_ATTR: true,
668 // Keep whitespace for code formatting
669 KEEP_CONTENT: true,
670 });
Philip Zeyligerd9acaa72025-07-14 14:51:55 -0700671
672 // Add copy buttons after sanitization
673 return this.addCopyButtons(sanitizedOutput);
Sean McCullough86b56862025-04-18 13:04:03 -0700674 } catch (error) {
675 console.error("Error rendering markdown:", error);
Philip Zeyliger53ab2452025-06-04 17:49:33 +0000676 // Fallback to sanitized plain text if markdown parsing fails
677 return DOMPurify.sanitize(markdownContent);
Sean McCullough86b56862025-04-18 13:04:03 -0700678 }
679 }
680
681 /**
682 * Format timestamp for display
683 */
684 formatTimestamp(
685 timestamp: string | number | Date | null | undefined,
686 defaultValue: string = "",
687 ): string {
688 if (!timestamp) return defaultValue;
689 try {
690 const date = new Date(timestamp);
691 if (isNaN(date.getTime())) return defaultValue;
692
693 // Format: Mar 13, 2025 09:53:25 AM
694 return date.toLocaleString("en-US", {
695 month: "short",
696 day: "numeric",
697 year: "numeric",
698 hour: "numeric",
699 minute: "2-digit",
700 second: "2-digit",
701 hour12: true,
702 });
philip.zeyliger26bc6592025-06-30 20:15:30 -0700703 } catch {
Sean McCullough86b56862025-04-18 13:04:03 -0700704 return defaultValue;
705 }
706 }
707
708 formatNumber(
709 num: number | null | undefined,
710 defaultValue: string = "0",
711 ): string {
712 if (num === undefined || num === null) return defaultValue;
713 try {
714 return num.toLocaleString();
philip.zeyliger26bc6592025-06-30 20:15:30 -0700715 } catch {
Sean McCullough86b56862025-04-18 13:04:03 -0700716 return String(num);
717 }
718 }
719 formatCurrency(
720 num: number | string | null | undefined,
721 defaultValue: string = "$0.00",
722 isMessageLevel: boolean = false,
723 ): string {
724 if (num === undefined || num === null) return defaultValue;
725 try {
726 // Use 4 decimal places for message-level costs, 2 for totals
727 const decimalPlaces = isMessageLevel ? 4 : 2;
728 return `$${parseFloat(String(num)).toFixed(decimalPlaces)}`;
philip.zeyliger26bc6592025-06-30 20:15:30 -0700729 } catch {
Sean McCullough86b56862025-04-18 13:04:03 -0700730 return defaultValue;
731 }
732 }
733
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000734 // Format duration from nanoseconds to a human-readable string
735 _formatDuration(nanoseconds: number | null | undefined): string {
736 if (!nanoseconds) return "0s";
737
738 const seconds = nanoseconds / 1e9;
739
740 if (seconds < 60) {
741 return `${seconds.toFixed(1)}s`;
742 } else if (seconds < 3600) {
743 const minutes = Math.floor(seconds / 60);
744 const remainingSeconds = seconds % 60;
745 return `${minutes}min ${remainingSeconds.toFixed(0)}s`;
746 } else {
747 const hours = Math.floor(seconds / 3600);
748 const remainingSeconds = seconds % 3600;
749 const minutes = Math.floor(remainingSeconds / 60);
750 return `${hours}h ${minutes}min`;
751 }
752 }
753
Sean McCullough86b56862025-04-18 13:04:03 -0700754 showCommit(commitHash: string) {
Sean McCullough71941bd2025-04-18 13:31:48 -0700755 this.dispatchEvent(
756 new CustomEvent("show-commit-diff", {
757 bubbles: true,
758 composed: true,
759 detail: { commitHash },
760 }),
761 );
Sean McCullough86b56862025-04-18 13:04:03 -0700762 }
763
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000764 _toggleInfo(e: Event) {
765 e.stopPropagation();
766 this.showInfo = !this.showInfo;
767 }
768
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +0000769 copyToClipboard(text: string, event: Event) {
770 const element = event.currentTarget as HTMLElement;
771 const rect = element.getBoundingClientRect();
772
773 navigator.clipboard
774 .writeText(text)
775 .then(() => {
776 this.showFloatingMessage("Copied!", rect, "success");
777 })
778 .catch((err) => {
779 console.error("Failed to copy text: ", err);
780 this.showFloatingMessage("Failed to copy!", rect, "error");
781 });
782 }
783
784 showFloatingMessage(
785 message: string,
786 targetRect: DOMRect,
787 type: "success" | "error",
788 ) {
789 // Create floating message element
790 const floatingMsg = document.createElement("div");
791 floatingMsg.textContent = message;
792 floatingMsg.className = `floating-message ${type}`;
793
794 // Position it near the clicked element
795 // Position just above the element
796 const top = targetRect.top - 30;
797 const left = targetRect.left + targetRect.width / 2 - 40;
798
799 floatingMsg.style.position = "fixed";
800 floatingMsg.style.top = `${top}px`;
801 floatingMsg.style.left = `${left}px`;
802 floatingMsg.style.zIndex = "9999";
803
804 // Add to document body
805 document.body.appendChild(floatingMsg);
806
807 // Animate in
808 floatingMsg.style.opacity = "0";
809 floatingMsg.style.transform = "translateY(10px)";
810
811 setTimeout(() => {
812 floatingMsg.style.opacity = "1";
813 floatingMsg.style.transform = "translateY(0)";
814 }, 10);
815
816 // Remove after animation
817 setTimeout(() => {
818 floatingMsg.style.opacity = "0";
819 floatingMsg.style.transform = "translateY(-10px)";
820
821 setTimeout(() => {
822 document.body.removeChild(floatingMsg);
823 }, 300);
824 }, 1500);
825 }
826
philip.zeyliger6d3de482025-06-10 19:38:14 -0700827 // Format GitHub repository URL to org/repo format
828 formatGitHubRepo(url) {
829 if (!url) return null;
830
831 // Common GitHub URL patterns
832 const patterns = [
833 // HTTPS URLs
834 /https:\/\/github\.com\/([^/]+)\/([^/\s.]+)(?:\.git)?/,
835 // SSH URLs
836 /git@github\.com:([^/]+)\/([^/\s.]+)(?:\.git)?/,
837 // Git protocol
838 /git:\/\/github\.com\/([^/]+)\/([^/\s.]+)(?:\.git)?/,
839 ];
840
841 for (const pattern of patterns) {
842 const match = url.match(pattern);
843 if (match) {
844 return {
845 formatted: `${match[1]}/${match[2]}`,
846 url: `https://github.com/${match[1]}/${match[2]}`,
847 owner: match[1],
848 repo: match[2],
849 };
850 }
851 }
852
853 return null;
854 }
855
856 // Generate GitHub branch URL if linking is enabled
857 getGitHubBranchLink(branchName) {
858 if (!this.state?.link_to_github || !branchName) {
859 return null;
860 }
861
862 const github = this.formatGitHubRepo(this.state?.git_origin);
863 if (!github) {
864 return null;
865 }
866
867 return `https://github.com/${github.owner}/${github.repo}/tree/${branchName}`;
868 }
869
Sean McCullough86b56862025-04-18 13:04:03 -0700870 render() {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000871 // Calculate if this is an end of turn message with no parent conversation ID
872 const isEndOfTurn =
873 this.message?.end_of_turn && !this.message?.parent_conversation_id;
874
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700875 const isPreCompaction =
876 this.message?.idx !== undefined &&
877 this.message.idx < this.firstMessageIndex;
878
bankseanc5147482025-06-29 00:41:58 +0000879 // Dynamic classes based on message type and state
880 const messageClasses = [
881 "relative mb-1.5 flex flex-col w-full", // base message styles
882 isEndOfTurn ? "mb-4" : "", // end-of-turn spacing
883 isPreCompaction ? "opacity-85 border-l-2 border-gray-300" : "", // pre-compaction styling
884 ]
885 .filter(Boolean)
886 .join(" ");
887
888 const bubbleContainerClasses = [
bankseandc27c392025-07-11 18:36:14 -0700889 "flex-1 flex text-ellipsis",
bankseanc5147482025-06-29 00:41:58 +0000890 this.compactPadding ? "max-w-full" : "max-w-[calc(100%-160px)]",
891 this.message?.type === "user" ? "justify-end" : "justify-start",
892 ]
893 .filter(Boolean)
894 .join(" ");
895
896 const messageContentClasses = [
banksean02693402025-07-17 17:10:10 +0000897 "relative px-2.5 py-1.5 rounded-xl shadow-sm min-w-min",
bankseanc5147482025-06-29 00:41:58 +0000898 // User message styling
899 this.message?.type === "user"
900 ? "bg-blue-500 text-white rounded-br-sm"
901 : // Agent/tool/error message styling
902 "bg-gray-100 text-black rounded-bl-sm",
903 ]
904 .filter(Boolean)
905 .join(" ");
906
Sean McCullough86b56862025-04-18 13:04:03 -0700907 return html`
bankseanc5147482025-06-29 00:41:58 +0000908 <div class="${messageClasses}">
909 <div class="flex relative w-full">
910 <!-- Left metadata area -->
911 <div
912 class="${this.compactPadding
913 ? "hidden"
914 : "flex-none w-20 px-1 py-0.5 text-right text-xs text-gray-500 self-start"}"
915 ></div>
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000916
917 <!-- Message bubble -->
banksean02693402025-07-17 17:10:10 +0000918 <div
919 class="${bubbleContainerClasses}"
920 style="${this.compactPadding
921 ? ""
922 : "max-width: calc(100% - 160px);"}"
923 >
924 <div
925 class="${messageContentClasses}"
926 style="max-width: 100%; overflow: hidden; width: fit-content; min-width: 200px;"
927 @click=${this.handleCodeCopy}
928 >
bankseanc5147482025-06-29 00:41:58 +0000929 <div class="relative">
930 <div
931 class="absolute top-1 right-1 z-10 opacity-0 hover:opacity-100 transition-opacity duration-200 flex gap-1.5"
932 >
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000933 ${copyButton(this.message?.content)}
934 <button
bankseanc5147482025-06-29 00:41:58 +0000935 class="bg-transparent border-none ${this.message?.type ===
936 "user"
937 ? "text-white/80 hover:bg-white/15"
938 : "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 +0000939 title="Show message details"
940 @click=${this._toggleInfo}
Sean McCullough71941bd2025-04-18 13:31:48 -0700941 >
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000942 <svg
943 xmlns="http://www.w3.org/2000/svg"
944 width="16"
945 height="16"
946 viewBox="0 0 24 24"
947 fill="none"
948 stroke="currentColor"
949 stroke-width="2"
950 stroke-linecap="round"
951 stroke-linejoin="round"
952 >
953 <circle cx="12" cy="12" r="10"></circle>
954 <line x1="12" y1="16" x2="12" y2="12"></line>
955 <line x1="12" y1="8" x2="12.01" y2="8"></line>
956 </svg>
957 </button>
958 </div>
959 ${this.message?.content
960 ? html`
bankseanc5147482025-06-29 00:41:58 +0000961 <div
banksean02693402025-07-17 17:10:10 +0000962 class="mb-0 font-sans py-0.5 select-text cursor-text text-sm leading-relaxed text-left box-border markdown-content"
963 style="max-width: 100%; overflow-wrap: break-word; word-wrap: break-word; word-break: break-word; hyphens: auto;"
Philip Zeyligerd9acaa72025-07-14 14:51:55 -0700964 @click=${this.handleCodeCopy}
banksean2cc75632025-07-17 17:10:17 +0000965 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"
bankseanc5147482025-06-29 00:41:58 +0000966 >
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000967 ${unsafeHTML(
968 this.renderMarkdown(this.message?.content),
969 )}
970 </div>
971 `
972 : ""}
973
974 <!-- End of turn indicator inside the bubble -->
975 ${isEndOfTurn && this.message?.elapsed
976 ? html`
bankseanc5147482025-06-29 00:41:58 +0000977 <div
978 class="block text-xs ${this.message?.type === "user"
979 ? "text-white/70"
980 : "text-gray-500"} py-0.5 mt-2 text-right italic"
981 >
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000982 end of turn
983 (${this._formatDuration(this.message?.elapsed)})
984 </div>
985 `
986 : ""}
987
988 <!-- Info panel that can be toggled -->
989 ${this.showInfo
990 ? html`
bankseanc5147482025-06-29 00:41:58 +0000991 <div
992 class="mt-2 p-2 ${this.message?.type === "user"
993 ? "bg-white/15 border-l-2 border-white/20"
994 : "bg-black/5 border-l-2 border-black/10"} rounded-md text-xs transition-all duration-200"
995 >
996 <div class="mb-1 flex">
997 <span class="font-bold mr-1 min-w-[60px]">Type:</span>
998 <span class="flex-1">${this.message?.type}</span>
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000999 </div>
bankseanc5147482025-06-29 00:41:58 +00001000 <div class="mb-1 flex">
1001 <span class="font-bold mr-1 min-w-[60px]">Time:</span>
1002 <span class="flex-1">
1003 ${this.formatTimestamp(this.message?.timestamp, "")}
1004 </span>
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001005 </div>
1006 ${this.message?.elapsed
1007 ? html`
bankseanc5147482025-06-29 00:41:58 +00001008 <div class="mb-1 flex">
1009 <span class="font-bold mr-1 min-w-[60px]"
1010 >Duration:</span
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001011 >
bankseanc5147482025-06-29 00:41:58 +00001012 <span class="flex-1">
1013 ${this._formatDuration(this.message?.elapsed)}
1014 </span>
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001015 </div>
1016 `
1017 : ""}
1018 ${this.message?.usage
1019 ? html`
bankseanc5147482025-06-29 00:41:58 +00001020 <div class="mb-1 flex">
1021 <span class="font-bold mr-1 min-w-[60px]"
1022 >Tokens:</span
1023 >
1024 <span class="flex-1">
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001025 ${this.message?.usage
1026 ? html`
1027 <div>
1028 Input:
1029 ${this.formatNumber(
1030 this.message?.usage?.input_tokens ||
1031 0,
1032 )}
1033 </div>
1034 ${this.message?.usage
1035 ?.cache_creation_input_tokens
1036 ? html`
1037 <div>
1038 Cache creation:
1039 ${this.formatNumber(
1040 this.message?.usage
1041 ?.cache_creation_input_tokens,
1042 )}
1043 </div>
1044 `
1045 : ""}
1046 ${this.message?.usage
1047 ?.cache_read_input_tokens
1048 ? html`
1049 <div>
1050 Cache read:
1051 ${this.formatNumber(
1052 this.message?.usage
1053 ?.cache_read_input_tokens,
1054 )}
1055 </div>
1056 `
1057 : ""}
1058 <div>
1059 Output:
1060 ${this.formatNumber(
1061 this.message?.usage?.output_tokens,
1062 )}
1063 </div>
1064 <div>
1065 Cost:
1066 ${this.formatCurrency(
1067 this.message?.usage?.cost_usd,
1068 )}
1069 </div>
1070 `
1071 : "N/A"}
1072 </span>
1073 </div>
1074 `
1075 : ""}
1076 ${this.message?.conversation_id
1077 ? html`
bankseanc5147482025-06-29 00:41:58 +00001078 <div class="mb-1 flex">
1079 <span class="font-bold mr-1 min-w-[60px]"
1080 >Conversation ID:</span
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001081 >
bankseanc5147482025-06-29 00:41:58 +00001082 <span
1083 class="flex-1 font-mono text-xs break-all"
1084 >
1085 ${this.message?.conversation_id}
1086 </span>
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001087 </div>
1088 `
1089 : ""}
1090 </div>
1091 `
1092 : ""}
1093 </div>
1094
1095 <!-- Tool calls - only shown for agent messages -->
1096 ${this.message?.type === "agent"
1097 ? html`
1098 <sketch-tool-calls
1099 .toolCalls=${this.message?.tool_calls}
1100 .open=${this.open}
1101 ></sketch-tool-calls>
1102 `
1103 : ""}
1104
bankseanc5147482025-06-29 00:41:58 +00001105 <!-- Commits section -->
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001106 ${this.message?.commits
1107 ? html`
bankseanc5147482025-06-29 00:41:58 +00001108 <div class="mt-2.5">
1109 <div
1110 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"
1111 >
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001112 ${this.message.commits.length} new
1113 commit${this.message.commits.length > 1 ? "s" : ""}
1114 detected
1115 </div>
1116 ${this.message.commits.map((commit) => {
1117 return html`
bankseanc5147482025-06-29 00:41:58 +00001118 <div
Sean McCulloughfd67b012025-07-01 14:58:23 -07001119 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 +00001120 >
Philip Zeyliger72682df2025-04-23 13:09:46 -07001121 <span
bankseanc5147482025-06-29 00:41:58 +00001122 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 -07001123 title="Click to copy: ${commit.hash}"
1124 @click=${(e) =>
1125 this.copyToClipboard(
1126 commit.hash.substring(0, 8),
1127 e,
1128 )}
1129 >
Pokey Rule7be879f2025-04-23 15:30:15 +01001130 ${commit.hash.substring(0, 8)}
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +00001131 </span>
1132 ${commit.pushed_branch
philip.zeyliger6d3de482025-06-10 19:38:14 -07001133 ? (() => {
1134 const githubLink = this.getGitHubBranchLink(
1135 commit.pushed_branch,
1136 );
1137 return html`
bankseanc5147482025-06-29 00:41:58 +00001138 <div class="flex items-center gap-1.5">
philip.zeyliger6d3de482025-06-10 19:38:14 -07001139 <span
bankseanc5147482025-06-29 00:41:58 +00001140 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 -07001141 title="Click to copy: ${commit.pushed_branch}"
1142 @click=${(e) =>
1143 this.copyToClipboard(
1144 commit.pushed_branch,
1145 e,
1146 )}
1147 >${commit.pushed_branch}</span
1148 >
cbroebbdee42025-06-20 09:57:44 +00001149 <span
bankseanc5147482025-06-29 00:41:58 +00001150 class="opacity-70 flex items-center hover:opacity-100"
cbroebbdee42025-06-20 09:57:44 +00001151 @click=${(e) => {
1152 e.stopPropagation();
1153 this.copyToClipboard(
1154 commit.pushed_branch,
1155 e,
1156 );
1157 }}
1158 >
philip.zeyliger6d3de482025-06-10 19:38:14 -07001159 <svg
1160 xmlns="http://www.w3.org/2000/svg"
1161 width="14"
1162 height="14"
1163 viewBox="0 0 24 24"
1164 fill="none"
1165 stroke="currentColor"
1166 stroke-width="2"
1167 stroke-linecap="round"
1168 stroke-linejoin="round"
bankseanc5147482025-06-29 00:41:58 +00001169 class="align-middle"
philip.zeyliger6d3de482025-06-10 19:38:14 -07001170 >
1171 <rect
1172 x="9"
1173 y="9"
1174 width="13"
1175 height="13"
1176 rx="2"
1177 ry="2"
1178 ></rect>
1179 <path
1180 d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"
1181 ></path>
1182 </svg>
1183 </span>
1184 ${githubLink
1185 ? html`
1186 <a
1187 href="${githubLink}"
1188 target="_blank"
1189 rel="noopener noreferrer"
bankseanc5147482025-06-29 00:41:58 +00001190 class="text-gray-600 no-underline flex items-center transition-colors duration-200 hover:text-blue-600"
philip.zeyliger6d3de482025-06-10 19:38:14 -07001191 title="Open ${commit.pushed_branch} on GitHub"
1192 @click=${(e) =>
1193 e.stopPropagation()}
1194 >
1195 <svg
bankseanc5147482025-06-29 00:41:58 +00001196 class="w-3.5 h-3.5"
philip.zeyliger6d3de482025-06-10 19:38:14 -07001197 viewBox="0 0 16 16"
1198 width="14"
1199 height="14"
1200 >
1201 <path
1202 fill="currentColor"
1203 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"
1204 />
1205 </svg>
1206 </a>
1207 `
1208 : ""}
1209 </div>
1210 `;
1211 })()
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +00001212 : ``}
bankseanc5147482025-06-29 00:41:58 +00001213 <span
1214 class="text-sm text-gray-700 flex-grow truncate"
Sean McCullough71941bd2025-04-18 13:31:48 -07001215 >
bankseanc5147482025-06-29 00:41:58 +00001216 ${commit.subject}
1217 </span>
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +00001218 <button
bankseanc5147482025-06-29 00:41:58 +00001219 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 +00001220 @click=${() => this.showCommit(commit.hash)}
1221 >
1222 View Diff
1223 </button>
Sean McCullough86b56862025-04-18 13:04:03 -07001224 </div>
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001225 `;
1226 })}
1227 </div>
1228 `
1229 : ""}
1230 </div>
1231 </div>
1232
bankseanc5147482025-06-29 00:41:58 +00001233 <!-- Right metadata area -->
1234 <div
1235 class="${this.compactPadding
1236 ? "hidden"
1237 : "flex-none w-20 px-1 py-0.5 text-left text-xs text-gray-500 self-start"}"
1238 ></div>
Sean McCullough86b56862025-04-18 13:04:03 -07001239 </div>
bankseancad67b02025-06-27 21:57:05 +00001240
1241 <!-- User name for user messages - positioned outside and below the bubble -->
1242 ${this.message?.type === "user" && this.state?.git_username
1243 ? html`
bankseanc5147482025-06-29 00:41:58 +00001244 <div
1245 class="flex justify-end mt-1 ${this.compactPadding
1246 ? ""
1247 : "pr-20"}"
1248 >
1249 <div class="text-xs text-gray-600 italic text-right">
bankseanb7ec9c82025-07-09 10:16:39 -07001250 ${this.state?.link_to_github
1251 ? html`@<a
1252 class="no-underline hover:underline"
1253 href="${this.state.link_to_github}"
1254 title="${this.state.git_username} on GitHub"
1255 >${this.state.git_username}</a
1256 >`
1257 : ""}
bankseanc5147482025-06-29 00:41:58 +00001258 </div>
bankseancad67b02025-06-27 21:57:05 +00001259 </div>
1260 `
1261 : ""}
Sean McCullough86b56862025-04-18 13:04:03 -07001262 </div>
1263 `;
1264 }
1265}
1266
Sean McCullough71941bd2025-04-18 13:31:48 -07001267function copyButton(textToCopy: string) {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001268 // SVG for copy icon (two overlapping rectangles)
1269 const copyIcon = html`<svg
1270 xmlns="http://www.w3.org/2000/svg"
1271 width="16"
1272 height="16"
1273 viewBox="0 0 24 24"
1274 fill="none"
1275 stroke="currentColor"
1276 stroke-width="2"
1277 stroke-linecap="round"
1278 stroke-linejoin="round"
1279 >
1280 <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
1281 <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
1282 </svg>`;
1283
1284 // SVG for success check mark
1285 const successIcon = html`<svg
1286 xmlns="http://www.w3.org/2000/svg"
1287 width="16"
1288 height="16"
1289 viewBox="0 0 24 24"
1290 fill="none"
1291 stroke="currentColor"
1292 stroke-width="2"
1293 stroke-linecap="round"
1294 stroke-linejoin="round"
1295 >
1296 <path d="M20 6L9 17l-5-5"></path>
1297 </svg>`;
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +00001298
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001299 const ret = html`<button
bankseanc5147482025-06-29 00:41:58 +00001300 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 +00001301 title="Copy to clipboard"
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001302 @click=${(e: Event) => {
1303 e.stopPropagation();
1304 const copyButton = e.currentTarget as HTMLButtonElement;
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001305 const originalInnerHTML = copyButton.innerHTML;
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001306 navigator.clipboard
1307 .writeText(textToCopy)
1308 .then(() => {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001309 copyButton.innerHTML = "";
1310 const successElement = document.createElement("div");
1311 copyButton.appendChild(successElement);
1312 render(successIcon, successElement);
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001313 setTimeout(() => {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001314 copyButton.innerHTML = originalInnerHTML;
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001315 }, 2000);
1316 })
1317 .catch((err) => {
1318 console.error("Failed to copy text: ", err);
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001319 setTimeout(() => {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001320 copyButton.innerHTML = originalInnerHTML;
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001321 }, 2000);
1322 });
1323 }}
1324 >
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001325 ${copyIcon}
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001326 </button>`;
Sean McCullough86b56862025-04-18 13:04:03 -07001327
Sean McCullough71941bd2025-04-18 13:31:48 -07001328 return ret;
Sean McCullough86b56862025-04-18 13:04:03 -07001329}
1330
bankseanc5147482025-06-29 00:41:58 +00001331// Global styles are now injected in the component's connectedCallback() method
1332// to ensure they are added when the component is actually used, not at module load time
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +00001333
Sean McCullough86b56862025-04-18 13:04:03 -07001334declare global {
1335 interface HTMLElementTagNameMap {
1336 "sketch-timeline-message": SketchTimelineMessage;
1337 }
1338}