blob: abbb32914e6364b11e7d71f6737628928d433167 [file] [log] [blame]
bankseanc5147482025-06-29 00:41:58 +00001import { html, 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";
philip.zeyliger6d3de482025-06-10 19:38:14 -07004import { AgentMessage, State } from "../types";
Sean McCullough8d93e362025-04-27 23:32:18 +00005import { marked, MarkedOptions, Renderer, Tokens } from "marked";
philip.zeyliger7c1a6872025-06-16 03:54:37 +00006import type mermaid from "mermaid";
Philip Zeyliger53ab2452025-06-04 17:49:33 +00007import DOMPurify from "dompurify";
philip.zeyliger7c1a6872025-06-16 03:54:37 +00008
9// Mermaid is loaded dynamically - see loadMermaid() function
10declare global {
11 interface Window {
12 mermaid?: typeof mermaid;
13 }
14}
15
16// Mermaid hash will be injected at build time
17declare const __MERMAID_HASH__: string;
18
19// Load Mermaid dynamically
20let mermaidLoadPromise: Promise<any> | null = null;
21
22function loadMermaid(): Promise<typeof mermaid> {
23 if (mermaidLoadPromise) {
24 return mermaidLoadPromise;
25 }
26
27 if (window.mermaid) {
28 return Promise.resolve(window.mermaid);
29 }
30
31 mermaidLoadPromise = new Promise((resolve, reject) => {
32 // Get the Mermaid hash from build-time constant
33 const mermaidHash = __MERMAID_HASH__;
34
35 // Try to load the external Mermaid bundle
36 const script = document.createElement("script");
37 script.onload = () => {
38 // The Mermaid bundle should set window.mermaid
39 if (window.mermaid) {
40 resolve(window.mermaid);
41 } else {
42 reject(new Error("Mermaid not loaded from external bundle"));
43 }
44 };
45 script.onerror = (error) => {
46 console.warn("Failed to load external Mermaid bundle:", error);
47 reject(new Error("Mermaid external bundle failed to load"));
48 };
49
50 // Don't set type="module" since we're using IIFE format
51 script.src = `./static/mermaid-standalone-${mermaidHash}.js`;
52 document.head.appendChild(script);
53 });
54
55 return mermaidLoadPromise;
56}
Sean McCullough86b56862025-04-18 13:04:03 -070057import "./sketch-tool-calls";
bankseanc5147482025-06-29 00:41:58 +000058import { SketchTailwindElement } from "./sketch-tailwind-element";
59
Sean McCullough86b56862025-04-18 13:04:03 -070060@customElement("sketch-timeline-message")
bankseanc5147482025-06-29 00:41:58 +000061export class SketchTimelineMessage extends SketchTailwindElement {
Sean McCullough86b56862025-04-18 13:04:03 -070062 @property()
Sean McCulloughd9f13372025-04-21 15:08:49 -070063 message: AgentMessage;
Sean McCullough86b56862025-04-18 13:04:03 -070064
65 @property()
philip.zeyliger6d3de482025-06-10 19:38:14 -070066 state: State;
67
68 @property()
Sean McCulloughd9f13372025-04-21 15:08:49 -070069 previousMessage: AgentMessage;
Sean McCullough86b56862025-04-18 13:04:03 -070070
Sean McCullough2deac842025-04-21 18:17:57 -070071 @property()
72 open: boolean = false;
73
Philip Zeyligerb8a8f352025-06-02 07:39:37 -070074 @property()
75 firstMessageIndex: number = 0;
76
David Crawshaw4b644682025-06-26 17:15:10 +000077 @property({ type: Boolean, reflect: true, attribute: "compactpadding" })
78 compactPadding: boolean = false;
79
Philip Zeyliger16fa8b42025-05-02 04:28:16 +000080 @state()
81 showInfo: boolean = false;
82
bankseanc5147482025-06-29 00:41:58 +000083 // Styles have been converted to Tailwind classes applied directly to HTML elements
84 // since this component now extends SketchTailwindElement which disables shadow DOM
Sean McCullough86b56862025-04-18 13:04:03 -070085
Sean McCullough8d93e362025-04-27 23:32:18 +000086 // Track mermaid diagrams that need rendering
87 private mermaidDiagrams = new Map();
88
Sean McCullough86b56862025-04-18 13:04:03 -070089 constructor() {
90 super();
philip.zeyliger7c1a6872025-06-16 03:54:37 +000091 // Mermaid will be initialized lazily when first needed
Sean McCullough86b56862025-04-18 13:04:03 -070092 }
93
94 // See https://lit.dev/docs/components/lifecycle/
95 connectedCallback() {
96 super.connectedCallback();
bankseanc5147482025-06-29 00:41:58 +000097 this.ensureGlobalStyles();
98 }
99
100 // Ensure global styles are injected when component is used
101 private ensureGlobalStyles() {
102 if (!document.querySelector("#sketch-timeline-message-styles")) {
103 const floatingMessageStyles = document.createElement("style");
104 floatingMessageStyles.id = "sketch-timeline-message-styles";
105 floatingMessageStyles.textContent = this.getGlobalStylesContent();
106 document.head.appendChild(floatingMessageStyles);
107 }
108 }
109
110 // Get the global styles content
111 private getGlobalStylesContent(): string {
112 return `
113 .floating-message {
114 background-color: rgba(31, 41, 55, 1);
115 color: white;
116 padding: 4px 10px;
117 border-radius: 4px;
118 font-size: 12px;
119 font-family: system-ui, sans-serif;
120 box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
121 pointer-events: none;
122 transition: all 0.3s ease;
123 }
124
125 .floating-message.success {
126 background-color: rgba(34, 197, 94, 0.9);
127 }
128
129 .floating-message.error {
130 background-color: rgba(239, 68, 68, 0.9);
131 }
132
133 /* Comprehensive markdown content styling */
134 .markdown-content h1 {
135 font-size: 1.875rem;
136 font-weight: 700;
137 margin: 1rem 0 0.5rem 0;
138 line-height: 1.25;
139 }
140
141 .markdown-content h2 {
142 font-size: 1.5rem;
143 font-weight: 600;
144 margin: 0.875rem 0 0.5rem 0;
145 line-height: 1.25;
146 }
147
148 .markdown-content h3 {
149 font-size: 1.25rem;
150 font-weight: 600;
151 margin: 0.75rem 0 0.375rem 0;
152 line-height: 1.375;
153 }
154
155 .markdown-content h4 {
156 font-size: 1.125rem;
157 font-weight: 600;
158 margin: 0.625rem 0 0.375rem 0;
159 line-height: 1.375;
160 }
161
162 .markdown-content h5 {
163 font-size: 1rem;
164 font-weight: 600;
165 margin: 0.5rem 0 0.25rem 0;
166 line-height: 1.5;
167 }
168
169 .markdown-content h6 {
170 font-size: 0.875rem;
171 font-weight: 600;
172 margin: 0.5rem 0 0.25rem 0;
173 line-height: 1.5;
174 }
175
176 .markdown-content h1:first-child,
177 .markdown-content h2:first-child,
178 .markdown-content h3:first-child,
179 .markdown-content h4:first-child,
180 .markdown-content h5:first-child,
181 .markdown-content h6:first-child {
182 margin-top: 0;
183 }
184
185 .markdown-content p {
186 margin: 0.25rem 0;
187 }
188
189 .markdown-content p:first-child {
190 margin-top: 0;
191 }
192
193 .markdown-content p:last-child {
194 margin-bottom: 0;
195 }
196
197 .markdown-content a {
198 color: inherit;
199 text-decoration: underline;
200 }
201
202 .markdown-content ul,
203 .markdown-content ol {
204 padding-left: 1.5rem;
205 margin: 0.5rem 0;
206 }
207
208 .markdown-content ul {
209 list-style-type: disc;
210 }
211
212 .markdown-content ol {
213 list-style-type: decimal;
214 }
215
216 .markdown-content li {
217 margin: 0.25rem 0;
218 }
219
220 .markdown-content blockquote {
221 border-left: 3px solid rgba(0, 0, 0, 0.2);
222 padding-left: 1rem;
223 margin-left: 0.5rem;
224 font-style: italic;
225 color: rgba(0, 0, 0, 0.7);
226 }
227
228 .markdown-content strong {
229 font-weight: 700;
230 }
231
232 .markdown-content em {
233 font-style: italic;
234 }
235
236 .markdown-content hr {
237 border: none;
238 border-top: 1px solid rgba(0, 0, 0, 0.1);
239 margin: 1rem 0;
240 }
241
242 /* User message specific markdown styling */
243 sketch-timeline-message .bg-blue-500 .markdown-content a {
244 color: #fff;
245 text-decoration: underline;
246 }
247
248 sketch-timeline-message .bg-blue-500 .markdown-content blockquote {
249 border-left: 3px solid rgba(255, 255, 255, 0.4);
250 color: rgba(255, 255, 255, 0.9);
251 }
252
253 sketch-timeline-message .bg-blue-500 .markdown-content hr {
254 border-top: 1px solid rgba(255, 255, 255, 0.3);
255 }
256
257 /* Code block styling within markdown */
258 .markdown-content pre,
259 .markdown-content code {
260 font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
261 background: rgba(0, 0, 0, 0.05);
262 border-radius: 4px;
263 padding: 2px 4px;
264 overflow-x: auto;
265 max-width: 100%;
bankseanc5147482025-06-29 00:41:58 +0000266 box-sizing: border-box;
banksean02693402025-07-17 17:10:10 +0000267 /* Reset word breaking for code blocks - they should not wrap */
268 word-break: normal;
269 overflow-wrap: normal;
270 white-space: nowrap;
271 }
272
273 /* Ensure proper word breaking for all markdown content EXCEPT code blocks */
274 .markdown-content {
275 overflow-wrap: break-word;
276 word-wrap: break-word;
277 word-break: break-word;
278 hyphens: auto;
279 max-width: 100%;
280 }
281
282 /* Handle long URLs and unbreakable strings in text content */
283 .markdown-content a,
284 .markdown-content span:not(.code-language),
285 .markdown-content p {
286 overflow-wrap: break-word;
287 word-wrap: break-word;
288 word-break: break-word;
bankseanc5147482025-06-29 00:41:58 +0000289 }
290
291 .markdown-content pre {
292 padding: 8px 12px;
293 margin: 0.5rem 0;
294 line-height: 1.4;
banksean02693402025-07-17 17:10:10 +0000295 /* Ensure code blocks don't inherit word breaking */
296 word-break: normal;
297 overflow-wrap: normal;
298 white-space: nowrap;
bankseanc5147482025-06-29 00:41:58 +0000299 }
300
301 .markdown-content pre code {
302 background: transparent;
303 padding: 0;
banksean02693402025-07-17 17:10:10 +0000304 /* Ensure inline code in pre blocks doesn't inherit word breaking */
305 word-break: normal;
306 overflow-wrap: normal;
307 white-space: pre;
bankseanc5147482025-06-29 00:41:58 +0000308 }
309
310 /* User message code styling */
311 sketch-timeline-message .bg-blue-500 .markdown-content pre,
312 sketch-timeline-message .bg-blue-500 .markdown-content code {
313 background: rgba(255, 255, 255, 0.2);
314 color: white;
315 }
316
317 sketch-timeline-message .bg-blue-500 .markdown-content pre code {
318 background: transparent;
319 }
320
321 /* Code block containers */
322 .code-block-container {
323 position: relative;
324 margin: 8px 0;
325 border-radius: 6px;
banksean02693402025-07-17 17:10:10 +0000326 overflow-x: auto;
327 overflow-y: hidden;
bankseanc5147482025-06-29 00:41:58 +0000328 background: rgba(0, 0, 0, 0.05);
banksean02693402025-07-17 17:10:10 +0000329 max-width: 100%;
330 width: 100%;
bankseanc5147482025-06-29 00:41:58 +0000331 }
332
333 sketch-timeline-message .bg-blue-500 .code-block-container {
334 background: rgba(255, 255, 255, 0.2);
335 }
336
337 .code-block-header {
338 display: flex;
339 justify-content: space-between;
340 align-items: center;
341 padding: 4px 8px;
342 background: rgba(0, 0, 0, 0.1);
343 font-size: 12px;
344 }
345
346 sketch-timeline-message .bg-blue-500 .code-block-header {
347 background: rgba(255, 255, 255, 0.2);
348 color: white;
349 }
350
351 .code-language {
352 font-family: 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
353 font-size: 11px;
354 font-weight: 500;
355 }
356
357 .code-copy-button {
358 background: transparent;
359 border: none;
360 cursor: pointer;
361 padding: 2px;
362 border-radius: 3px;
363 display: flex;
364 align-items: center;
365 justify-content: center;
366 opacity: 0.7;
367 transition: all 0.15s ease;
368 }
369
370 .code-copy-button:hover {
371 opacity: 1;
372 background: rgba(0, 0, 0, 0.1);
373 }
374
375 sketch-timeline-message .bg-blue-500 .code-copy-button:hover {
376 background: rgba(255, 255, 255, 0.2);
377 }
378
379 .code-block-container pre {
380 margin: 0;
381 padding: 8px;
382 background: transparent;
383 }
384
385 .code-block-container code {
386 background: transparent;
387 padding: 0;
388 display: block;
389 width: 100%;
390 }
391
392 /* Mermaid diagram styling */
393 .mermaid-container {
394 margin: 1rem 0;
395 padding: 0.5rem;
396 background-color: #f8f8f8;
397 border-radius: 4px;
398 overflow-x: auto;
399 }
400
401 .mermaid {
402 text-align: center;
403 }
404
banksean3eaa4332025-07-19 02:19:06 +0000405 /* Dark mode styles */
406 .dark .markdown-content pre,
407 .dark .markdown-content code {
408 background: rgba(255, 255, 255, 0.1);
409 color: #e5e7eb;
410 }
411
412 .dark .markdown-content blockquote {
413 border-left: 3px solid rgba(255, 255, 255, 0.2);
414 color: rgba(255, 255, 255, 0.7);
415 }
416
417 .dark .markdown-content hr {
418 border-top: 1px solid rgba(255, 255, 255, 0.2);
419 }
420
421 .dark .code-block-container {
422 background: rgba(255, 255, 255, 0.1);
423 }
424
425 .dark .code-block-header {
426 background: rgba(255, 255, 255, 0.15);
427 color: #e5e7eb;
428 }
429
430 .dark .code-copy-button:hover {
431 background: rgba(255, 255, 255, 0.1);
432 }
433
434 .dark .mermaid-container {
435 background-color: #374151;
436 border: 1px solid #4b5563;
437 }
438
bankseanc5147482025-06-29 00:41:58 +0000439 /* Print styles */
440 @media print {
441 .floating-message,
442 .commit-diff-button,
443 button[title="Copy to clipboard"],
444 button[title="Show message details"] {
445 display: none !important;
446 }
447 }
448`;
Sean McCullough86b56862025-04-18 13:04:03 -0700449 }
Autoformatterdded2d62025-04-28 00:27:21 +0000450
Sean McCullough8d93e362025-04-27 23:32:18 +0000451 // After the component is updated and rendered, render any mermaid diagrams
452 updated(changedProperties: Map<string, unknown>) {
453 super.updated(changedProperties);
454 this.renderMermaidDiagrams();
455 }
Autoformatterdded2d62025-04-28 00:27:21 +0000456
Sean McCullough8d93e362025-04-27 23:32:18 +0000457 // Render mermaid diagrams after the component is updated
458 renderMermaidDiagrams() {
459 // Add a small delay to ensure the DOM is fully rendered
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000460 setTimeout(async () => {
Sean McCullough8d93e362025-04-27 23:32:18 +0000461 // Find all mermaid containers in our shadow root
Sean McCulloughf6e1dfe2025-07-03 14:59:40 -0700462 const containers = this.querySelectorAll(".mermaid");
Sean McCullough8d93e362025-04-27 23:32:18 +0000463 if (!containers || containers.length === 0) return;
Autoformatterdded2d62025-04-28 00:27:21 +0000464
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000465 try {
466 // Load mermaid dynamically
467 const mermaidLib = await loadMermaid();
Autoformatterdded2d62025-04-28 00:27:21 +0000468
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000469 // Initialize mermaid with specific config (only once per load)
470 mermaidLib.initialize({
471 startOnLoad: false,
472 suppressErrorRendering: true,
473 theme: "default",
474 securityLevel: "loose", // Allows more flexibility but be careful with user-generated content
475 fontFamily: "monospace",
476 });
Autoformatterdded2d62025-04-28 00:27:21 +0000477
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000478 // Process each mermaid diagram
479 containers.forEach((container) => {
480 const id = container.id;
481 const code = container.textContent || "";
482 if (!code || !id) return; // Use return for forEach instead of continue
483
484 try {
485 // Clear any previous content
486 container.innerHTML = code;
487
488 // Render the mermaid diagram using promise
489 mermaidLib
490 .render(`${id}-svg`, code)
491 .then(({ svg }) => {
492 container.innerHTML = svg;
493 })
494 .catch((err) => {
495 console.error("Error rendering mermaid diagram:", err);
496 // Show the original code as fallback
497 container.innerHTML = `<pre>${code}</pre>`;
498 });
499 } catch (err) {
500 console.error("Error processing mermaid diagram:", err);
501 // Show the original code as fallback
502 container.innerHTML = `<pre>${code}</pre>`;
503 }
504 });
505 } catch (err) {
506 console.error("Error loading mermaid:", err);
507 // Show the original code as fallback for all diagrams
508 containers.forEach((container) => {
509 const code = container.textContent || "";
Sean McCullough8d93e362025-04-27 23:32:18 +0000510 container.innerHTML = `<pre>${code}</pre>`;
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000511 });
512 }
Sean McCullough8d93e362025-04-27 23:32:18 +0000513 }, 100); // Small delay to ensure DOM is ready
514 }
Sean McCullough86b56862025-04-18 13:04:03 -0700515
516 // See https://lit.dev/docs/components/lifecycle/
517 disconnectedCallback() {
518 super.disconnectedCallback();
519 }
520
Philip Zeyligerd9acaa72025-07-14 14:51:55 -0700521 // Add post-sanitization button replacement
522 private addCopyButtons(html: string): string {
523 return html.replace(
524 /<span class="copy-button-placeholder"><\/span>/g,
525 `<button class="code-copy-button" title="Copy code">
banksean2cc75632025-07-17 17:10:17 +0000526 <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 -0700527 <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
528 <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
529 </svg>
530 </button>`,
531 );
532 }
533
534 // Event delegation handler for code copy functionality
535 private handleCodeCopy(event: Event) {
536 const button = event.target as HTMLElement;
537 if (!button.classList.contains("code-copy-button")) return;
538
539 event.stopPropagation();
540
541 // Find the code element using DOM traversal
542 const header = button.closest(".code-block-header");
543 const codeElement = header?.nextElementSibling?.querySelector("code");
544 if (!codeElement) return;
545
546 // Read the text directly from DOM (automatically unescapes HTML)
547 const codeText = codeElement.textContent || "";
548
549 // Copy to clipboard with visual feedback
550 navigator.clipboard
551 .writeText(codeText)
552 .then(() => {
553 // Show success feedback (icon change + floating message)
554 const originalHTML = button.innerHTML;
555 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">
556 <path d="M20 6L9 17l-5-5"></path>
557 </svg>`;
558 this.showFloatingMessage(
559 "Copied!",
560 button.getBoundingClientRect(),
561 "success",
562 );
563 setTimeout(() => (button.innerHTML = originalHTML), 2000);
564 })
565 .catch((err) => {
566 console.error("Failed to copy code:", err);
567 this.showFloatingMessage(
568 "Failed to copy!",
569 button.getBoundingClientRect(),
570 "error",
571 );
572 });
573 }
574
Sean McCullough86b56862025-04-18 13:04:03 -0700575 renderMarkdown(markdownContent: string): string {
576 try {
Sean McCullough8d93e362025-04-27 23:32:18 +0000577 // Create a custom renderer
578 const renderer = new Renderer();
579 const originalCodeRenderer = renderer.code.bind(renderer);
Autoformatterdded2d62025-04-28 00:27:21 +0000580
Pokey Rulea10f1512025-05-15 13:53:26 +0000581 // Override the code renderer to handle mermaid diagrams and add copy buttons
Autoformatterdded2d62025-04-28 00:27:21 +0000582 renderer.code = function ({ text, lang, escaped }: Tokens.Code): string {
583 if (lang === "mermaid") {
Sean McCullough8d93e362025-04-27 23:32:18 +0000584 // Generate a unique ID for this diagram
585 const id = `mermaid-diagram-${Math.random().toString(36).substring(2, 10)}`;
Autoformatterdded2d62025-04-28 00:27:21 +0000586
Sean McCullough8d93e362025-04-27 23:32:18 +0000587 // Just create the container and mermaid div - we'll render it in the updated() lifecycle method
588 return `<div class="mermaid-container">
589 <div class="mermaid" id="${id}">${text}</div>
590 </div>`;
591 }
Pokey Rulea10f1512025-05-15 13:53:26 +0000592
Philip Zeyliger0d092842025-06-09 18:57:12 -0700593 // For regular code blocks, call the original renderer to get properly escaped HTML
594 const originalCodeHtml = originalCodeRenderer({ text, lang, escaped });
595
596 // Extract the code content from the original HTML to add our custom wrapper
597 // The original renderer returns: <pre><code class="language-x">escapedText</code></pre>
598 const codeMatch = originalCodeHtml.match(
599 /<pre><code[^>]*>([\s\S]*?)<\/code><\/pre>/,
600 );
601 if (!codeMatch) {
602 // Fallback to original if we can't parse it
603 return originalCodeHtml;
604 }
605
606 const escapedText = codeMatch[1];
Pokey Rulea10f1512025-05-15 13:53:26 +0000607 const langClass = lang ? ` class="language-${lang}"` : "";
608
Philip Zeyligerd9acaa72025-07-14 14:51:55 -0700609 // Use placeholder instead of actual button - will be replaced after sanitization
Pokey Rulea10f1512025-05-15 13:53:26 +0000610 return `<div class="code-block-container">
611 <div class="code-block-header">
612 ${lang ? `<span class="code-language">${lang}</span>` : ""}
Philip Zeyligerd9acaa72025-07-14 14:51:55 -0700613 <span class="copy-button-placeholder"></span>
Pokey Rulea10f1512025-05-15 13:53:26 +0000614 </div>
Philip Zeyligerd9acaa72025-07-14 14:51:55 -0700615 <pre><code${langClass}>${escapedText}</code></pre>
Pokey Rulea10f1512025-05-15 13:53:26 +0000616 </div>`;
Sean McCullough8d93e362025-04-27 23:32:18 +0000617 };
Autoformatterdded2d62025-04-28 00:27:21 +0000618
Philip Zeyliger53ab2452025-06-04 17:49:33 +0000619 // Set markdown options for proper code block highlighting
Sean McCullough86b56862025-04-18 13:04:03 -0700620 const markedOptions: MarkedOptions = {
621 gfm: true, // GitHub Flavored Markdown
622 breaks: true, // Convert newlines to <br>
623 async: false,
Autoformatterdded2d62025-04-28 00:27:21 +0000624 renderer: renderer,
Sean McCullough86b56862025-04-18 13:04:03 -0700625 };
Philip Zeyliger53ab2452025-06-04 17:49:33 +0000626
627 // Parse markdown and sanitize the output HTML with DOMPurify
628 const htmlOutput = marked.parse(markdownContent, markedOptions) as string;
Philip Zeyligerd9acaa72025-07-14 14:51:55 -0700629 const sanitizedOutput = DOMPurify.sanitize(htmlOutput, {
Philip Zeyliger53ab2452025-06-04 17:49:33 +0000630 // Allow common HTML elements that are safe
631 ALLOWED_TAGS: [
632 "p",
633 "br",
634 "strong",
635 "em",
636 "b",
637 "i",
638 "u",
639 "s",
640 "code",
641 "pre",
642 "h1",
643 "h2",
644 "h3",
645 "h4",
646 "h5",
647 "h6",
648 "ul",
649 "ol",
650 "li",
651 "blockquote",
652 "a",
653 "div",
654 "span", // For mermaid diagrams and code blocks
655 "svg",
656 "g",
657 "path",
658 "rect",
659 "circle",
660 "text",
661 "line",
662 "polygon", // For mermaid SVG
663 "button", // For code copy buttons
664 ],
665 ALLOWED_ATTR: [
666 "href",
667 "title",
668 "target",
669 "rel", // For links
670 "class",
671 "id", // For styling and functionality
672 "data-*", // For code copy buttons
673 // SVG attributes for mermaid diagrams
674 "viewBox",
675 "width",
676 "height",
677 "xmlns",
678 "fill",
679 "stroke",
680 "stroke-width",
681 "d",
682 "x",
683 "y",
684 "x1",
685 "y1",
686 "x2",
687 "y2",
688 "cx",
689 "cy",
690 "r",
691 "rx",
692 "ry",
693 "points",
694 "transform",
695 "text-anchor",
696 "font-size",
697 "font-family",
698 ],
699 // Allow data attributes for functionality
700 ALLOW_DATA_ATTR: true,
701 // Keep whitespace for code formatting
702 KEEP_CONTENT: true,
703 });
Philip Zeyligerd9acaa72025-07-14 14:51:55 -0700704
705 // Add copy buttons after sanitization
706 return this.addCopyButtons(sanitizedOutput);
Sean McCullough86b56862025-04-18 13:04:03 -0700707 } catch (error) {
708 console.error("Error rendering markdown:", error);
Philip Zeyliger53ab2452025-06-04 17:49:33 +0000709 // Fallback to sanitized plain text if markdown parsing fails
710 return DOMPurify.sanitize(markdownContent);
Sean McCullough86b56862025-04-18 13:04:03 -0700711 }
712 }
713
714 /**
715 * Format timestamp for display
716 */
717 formatTimestamp(
718 timestamp: string | number | Date | null | undefined,
719 defaultValue: string = "",
720 ): string {
721 if (!timestamp) return defaultValue;
722 try {
723 const date = new Date(timestamp);
724 if (isNaN(date.getTime())) return defaultValue;
725
726 // Format: Mar 13, 2025 09:53:25 AM
727 return date.toLocaleString("en-US", {
728 month: "short",
729 day: "numeric",
730 year: "numeric",
731 hour: "numeric",
732 minute: "2-digit",
733 second: "2-digit",
734 hour12: true,
735 });
philip.zeyliger26bc6592025-06-30 20:15:30 -0700736 } catch {
Sean McCullough86b56862025-04-18 13:04:03 -0700737 return defaultValue;
738 }
739 }
740
741 formatNumber(
742 num: number | null | undefined,
743 defaultValue: string = "0",
744 ): string {
745 if (num === undefined || num === null) return defaultValue;
746 try {
747 return num.toLocaleString();
philip.zeyliger26bc6592025-06-30 20:15:30 -0700748 } catch {
Sean McCullough86b56862025-04-18 13:04:03 -0700749 return String(num);
750 }
751 }
752 formatCurrency(
753 num: number | string | null | undefined,
754 defaultValue: string = "$0.00",
755 isMessageLevel: boolean = false,
756 ): string {
757 if (num === undefined || num === null) return defaultValue;
758 try {
759 // Use 4 decimal places for message-level costs, 2 for totals
760 const decimalPlaces = isMessageLevel ? 4 : 2;
761 return `$${parseFloat(String(num)).toFixed(decimalPlaces)}`;
philip.zeyliger26bc6592025-06-30 20:15:30 -0700762 } catch {
Sean McCullough86b56862025-04-18 13:04:03 -0700763 return defaultValue;
764 }
765 }
766
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000767 // Format duration from nanoseconds to a human-readable string
768 _formatDuration(nanoseconds: number | null | undefined): string {
769 if (!nanoseconds) return "0s";
770
771 const seconds = nanoseconds / 1e9;
772
773 if (seconds < 60) {
774 return `${seconds.toFixed(1)}s`;
775 } else if (seconds < 3600) {
776 const minutes = Math.floor(seconds / 60);
777 const remainingSeconds = seconds % 60;
778 return `${minutes}min ${remainingSeconds.toFixed(0)}s`;
779 } else {
780 const hours = Math.floor(seconds / 3600);
781 const remainingSeconds = seconds % 3600;
782 const minutes = Math.floor(remainingSeconds / 60);
783 return `${hours}h ${minutes}min`;
784 }
785 }
786
Sean McCullough86b56862025-04-18 13:04:03 -0700787 showCommit(commitHash: string) {
Sean McCullough71941bd2025-04-18 13:31:48 -0700788 this.dispatchEvent(
789 new CustomEvent("show-commit-diff", {
790 bubbles: true,
791 composed: true,
792 detail: { commitHash },
793 }),
794 );
Sean McCullough86b56862025-04-18 13:04:03 -0700795 }
796
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000797 _toggleInfo(e: Event) {
798 e.stopPropagation();
799 this.showInfo = !this.showInfo;
800 }
801
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +0000802 copyToClipboard(text: string, event: Event) {
803 const element = event.currentTarget as HTMLElement;
804 const rect = element.getBoundingClientRect();
805
806 navigator.clipboard
807 .writeText(text)
808 .then(() => {
809 this.showFloatingMessage("Copied!", rect, "success");
810 })
811 .catch((err) => {
812 console.error("Failed to copy text: ", err);
813 this.showFloatingMessage("Failed to copy!", rect, "error");
814 });
815 }
816
817 showFloatingMessage(
818 message: string,
819 targetRect: DOMRect,
820 type: "success" | "error",
821 ) {
822 // Create floating message element
823 const floatingMsg = document.createElement("div");
824 floatingMsg.textContent = message;
825 floatingMsg.className = `floating-message ${type}`;
826
827 // Position it near the clicked element
828 // Position just above the element
829 const top = targetRect.top - 30;
830 const left = targetRect.left + targetRect.width / 2 - 40;
831
832 floatingMsg.style.position = "fixed";
833 floatingMsg.style.top = `${top}px`;
834 floatingMsg.style.left = `${left}px`;
835 floatingMsg.style.zIndex = "9999";
836
837 // Add to document body
838 document.body.appendChild(floatingMsg);
839
840 // Animate in
841 floatingMsg.style.opacity = "0";
842 floatingMsg.style.transform = "translateY(10px)";
843
844 setTimeout(() => {
845 floatingMsg.style.opacity = "1";
846 floatingMsg.style.transform = "translateY(0)";
847 }, 10);
848
849 // Remove after animation
850 setTimeout(() => {
851 floatingMsg.style.opacity = "0";
852 floatingMsg.style.transform = "translateY(-10px)";
853
854 setTimeout(() => {
855 document.body.removeChild(floatingMsg);
856 }, 300);
857 }, 1500);
858 }
859
philip.zeyliger6d3de482025-06-10 19:38:14 -0700860 // Format GitHub repository URL to org/repo format
861 formatGitHubRepo(url) {
862 if (!url) return null;
863
864 // Common GitHub URL patterns
865 const patterns = [
866 // HTTPS URLs
867 /https:\/\/github\.com\/([^/]+)\/([^/\s.]+)(?:\.git)?/,
868 // SSH URLs
869 /git@github\.com:([^/]+)\/([^/\s.]+)(?:\.git)?/,
870 // Git protocol
871 /git:\/\/github\.com\/([^/]+)\/([^/\s.]+)(?:\.git)?/,
872 ];
873
874 for (const pattern of patterns) {
875 const match = url.match(pattern);
876 if (match) {
877 return {
878 formatted: `${match[1]}/${match[2]}`,
879 url: `https://github.com/${match[1]}/${match[2]}`,
880 owner: match[1],
881 repo: match[2],
882 };
883 }
884 }
885
886 return null;
887 }
888
889 // Generate GitHub branch URL if linking is enabled
890 getGitHubBranchLink(branchName) {
891 if (!this.state?.link_to_github || !branchName) {
892 return null;
893 }
894
895 const github = this.formatGitHubRepo(this.state?.git_origin);
896 if (!github) {
897 return null;
898 }
899
900 return `https://github.com/${github.owner}/${github.repo}/tree/${branchName}`;
901 }
902
Sean McCullough86b56862025-04-18 13:04:03 -0700903 render() {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000904 // Calculate if this is an end of turn message with no parent conversation ID
905 const isEndOfTurn =
906 this.message?.end_of_turn && !this.message?.parent_conversation_id;
907
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700908 const isPreCompaction =
909 this.message?.idx !== undefined &&
910 this.message.idx < this.firstMessageIndex;
911
bankseanc5147482025-06-29 00:41:58 +0000912 // Dynamic classes based on message type and state
913 const messageClasses = [
914 "relative mb-1.5 flex flex-col w-full", // base message styles
915 isEndOfTurn ? "mb-4" : "", // end-of-turn spacing
916 isPreCompaction ? "opacity-85 border-l-2 border-gray-300" : "", // pre-compaction styling
917 ]
918 .filter(Boolean)
919 .join(" ");
920
921 const bubbleContainerClasses = [
bankseandc27c392025-07-11 18:36:14 -0700922 "flex-1 flex text-ellipsis",
bankseanc5147482025-06-29 00:41:58 +0000923 this.compactPadding ? "max-w-full" : "max-w-[calc(100%-160px)]",
924 this.message?.type === "user" ? "justify-end" : "justify-start",
925 ]
926 .filter(Boolean)
927 .join(" ");
928
929 const messageContentClasses = [
banksean02693402025-07-17 17:10:10 +0000930 "relative px-2.5 py-1.5 rounded-xl shadow-sm min-w-min",
bankseanc5147482025-06-29 00:41:58 +0000931 // User message styling
932 this.message?.type === "user"
933 ? "bg-blue-500 text-white rounded-br-sm"
934 : // Agent/tool/error message styling
banksean3eaa4332025-07-19 02:19:06 +0000935 "bg-gray-100 dark:bg-gray-800 text-black dark:text-gray-100 rounded-bl-sm",
bankseanc5147482025-06-29 00:41:58 +0000936 ]
937 .filter(Boolean)
938 .join(" ");
939
Sean McCullough86b56862025-04-18 13:04:03 -0700940 return html`
bankseanc5147482025-06-29 00:41:58 +0000941 <div class="${messageClasses}">
942 <div class="flex relative w-full">
943 <!-- Left metadata area -->
944 <div
945 class="${this.compactPadding
946 ? "hidden"
banksean3eaa4332025-07-19 02:19:06 +0000947 : "flex-none w-20 px-1 py-0.5 text-right text-xs text-gray-500 dark:text-gray-400 self-start"}"
bankseanc5147482025-06-29 00:41:58 +0000948 ></div>
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000949
950 <!-- Message bubble -->
banksean02693402025-07-17 17:10:10 +0000951 <div
952 class="${bubbleContainerClasses}"
953 style="${this.compactPadding
954 ? ""
955 : "max-width: calc(100% - 160px);"}"
956 >
957 <div
958 class="${messageContentClasses}"
959 style="max-width: 100%; overflow: hidden; width: fit-content; min-width: 200px;"
960 @click=${this.handleCodeCopy}
961 >
bankseanc5147482025-06-29 00:41:58 +0000962 <div class="relative">
963 <div
964 class="absolute top-1 right-1 z-10 opacity-0 hover:opacity-100 transition-opacity duration-200 flex gap-1.5"
965 >
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000966 ${copyButton(this.message?.content)}
967 <button
bankseanc5147482025-06-29 00:41:58 +0000968 class="bg-transparent border-none ${this.message?.type ===
969 "user"
970 ? "text-white/80 hover:bg-white/15"
banksean3eaa4332025-07-19 02:19:06 +0000971 : "text-black/60 dark:text-gray-400 hover:bg-black/8 dark:hover:bg-white/10"} 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 +0000972 title="Show message details"
973 @click=${this._toggleInfo}
Sean McCullough71941bd2025-04-18 13:31:48 -0700974 >
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000975 <svg
976 xmlns="http://www.w3.org/2000/svg"
977 width="16"
978 height="16"
979 viewBox="0 0 24 24"
980 fill="none"
981 stroke="currentColor"
982 stroke-width="2"
983 stroke-linecap="round"
984 stroke-linejoin="round"
985 >
986 <circle cx="12" cy="12" r="10"></circle>
987 <line x1="12" y1="16" x2="12" y2="12"></line>
988 <line x1="12" y1="8" x2="12.01" y2="8"></line>
989 </svg>
990 </button>
991 </div>
992 ${this.message?.content
993 ? html`
bankseanc5147482025-06-29 00:41:58 +0000994 <div
banksean02693402025-07-17 17:10:10 +0000995 class="mb-0 font-sans py-0.5 select-text cursor-text text-sm leading-relaxed text-left box-border markdown-content"
996 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 -0700997 @click=${this.handleCodeCopy}
banksean2cc75632025-07-17 17:10:17 +0000998 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 +0000999 >
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001000 ${unsafeHTML(
1001 this.renderMarkdown(this.message?.content),
1002 )}
1003 </div>
1004 `
1005 : ""}
1006
1007 <!-- End of turn indicator inside the bubble -->
1008 ${isEndOfTurn && this.message?.elapsed
1009 ? html`
bankseanc5147482025-06-29 00:41:58 +00001010 <div
1011 class="block text-xs ${this.message?.type === "user"
1012 ? "text-white/70"
banksean3eaa4332025-07-19 02:19:06 +00001013 : "text-gray-500 dark:text-gray-400"} py-0.5 mt-2 text-right italic"
bankseanc5147482025-06-29 00:41:58 +00001014 >
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001015 end of turn
1016 (${this._formatDuration(this.message?.elapsed)})
1017 </div>
1018 `
1019 : ""}
1020
1021 <!-- Info panel that can be toggled -->
1022 ${this.showInfo
1023 ? html`
bankseanc5147482025-06-29 00:41:58 +00001024 <div
1025 class="mt-2 p-2 ${this.message?.type === "user"
1026 ? "bg-white/15 border-l-2 border-white/20"
banksean3eaa4332025-07-19 02:19:06 +00001027 : "bg-black/5 dark:bg-white/5 border-l-2 border-black/10 dark:border-white/20"} rounded-md text-xs transition-all duration-200"
bankseanc5147482025-06-29 00:41:58 +00001028 >
1029 <div class="mb-1 flex">
1030 <span class="font-bold mr-1 min-w-[60px]">Type:</span>
1031 <span class="flex-1">${this.message?.type}</span>
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001032 </div>
bankseanc5147482025-06-29 00:41:58 +00001033 <div class="mb-1 flex">
1034 <span class="font-bold mr-1 min-w-[60px]">Time:</span>
1035 <span class="flex-1">
1036 ${this.formatTimestamp(this.message?.timestamp, "")}
1037 </span>
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001038 </div>
1039 ${this.message?.elapsed
1040 ? html`
bankseanc5147482025-06-29 00:41:58 +00001041 <div class="mb-1 flex">
1042 <span class="font-bold mr-1 min-w-[60px]"
1043 >Duration:</span
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001044 >
bankseanc5147482025-06-29 00:41:58 +00001045 <span class="flex-1">
1046 ${this._formatDuration(this.message?.elapsed)}
1047 </span>
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001048 </div>
1049 `
1050 : ""}
1051 ${this.message?.usage
1052 ? html`
bankseanc5147482025-06-29 00:41:58 +00001053 <div class="mb-1 flex">
1054 <span class="font-bold mr-1 min-w-[60px]"
1055 >Tokens:</span
1056 >
1057 <span class="flex-1">
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001058 ${this.message?.usage
1059 ? html`
1060 <div>
1061 Input:
1062 ${this.formatNumber(
1063 this.message?.usage?.input_tokens ||
1064 0,
1065 )}
1066 </div>
1067 ${this.message?.usage
1068 ?.cache_creation_input_tokens
1069 ? html`
1070 <div>
1071 Cache creation:
1072 ${this.formatNumber(
1073 this.message?.usage
1074 ?.cache_creation_input_tokens,
1075 )}
1076 </div>
1077 `
1078 : ""}
1079 ${this.message?.usage
1080 ?.cache_read_input_tokens
1081 ? html`
1082 <div>
1083 Cache read:
1084 ${this.formatNumber(
1085 this.message?.usage
1086 ?.cache_read_input_tokens,
1087 )}
1088 </div>
1089 `
1090 : ""}
1091 <div>
1092 Output:
1093 ${this.formatNumber(
1094 this.message?.usage?.output_tokens,
1095 )}
1096 </div>
1097 <div>
1098 Cost:
1099 ${this.formatCurrency(
1100 this.message?.usage?.cost_usd,
1101 )}
1102 </div>
1103 `
1104 : "N/A"}
1105 </span>
1106 </div>
1107 `
1108 : ""}
1109 ${this.message?.conversation_id
1110 ? html`
bankseanc5147482025-06-29 00:41:58 +00001111 <div class="mb-1 flex">
1112 <span class="font-bold mr-1 min-w-[60px]"
1113 >Conversation ID:</span
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001114 >
bankseanc5147482025-06-29 00:41:58 +00001115 <span
1116 class="flex-1 font-mono text-xs break-all"
1117 >
1118 ${this.message?.conversation_id}
1119 </span>
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001120 </div>
1121 `
1122 : ""}
1123 </div>
1124 `
1125 : ""}
1126 </div>
1127
1128 <!-- Tool calls - only shown for agent messages -->
1129 ${this.message?.type === "agent"
1130 ? html`
1131 <sketch-tool-calls
1132 .toolCalls=${this.message?.tool_calls}
1133 .open=${this.open}
1134 ></sketch-tool-calls>
1135 `
1136 : ""}
1137
bankseanc5147482025-06-29 00:41:58 +00001138 <!-- Commits section -->
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001139 ${this.message?.commits
1140 ? html`
bankseanc5147482025-06-29 00:41:58 +00001141 <div class="mt-2.5">
1142 <div
1143 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"
1144 >
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001145 ${this.message.commits.length} new
1146 commit${this.message.commits.length > 1 ? "s" : ""}
1147 detected
1148 </div>
1149 ${this.message.commits.map((commit) => {
1150 return html`
bankseanc5147482025-06-29 00:41:58 +00001151 <div
banksean3eaa4332025-07-19 02:19:06 +00001152 class="text-sm bg-gray-100 dark:bg-gray-800 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 +00001153 >
Philip Zeyliger72682df2025-04-23 13:09:46 -07001154 <span
bankseanc5147482025-06-29 00:41:58 +00001155 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 -07001156 title="Click to copy: ${commit.hash}"
1157 @click=${(e) =>
1158 this.copyToClipboard(
1159 commit.hash.substring(0, 8),
1160 e,
1161 )}
1162 >
Pokey Rule7be879f2025-04-23 15:30:15 +01001163 ${commit.hash.substring(0, 8)}
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +00001164 </span>
1165 ${commit.pushed_branch
philip.zeyliger6d3de482025-06-10 19:38:14 -07001166 ? (() => {
1167 const githubLink = this.getGitHubBranchLink(
1168 commit.pushed_branch,
1169 );
1170 return html`
bankseanc5147482025-06-29 00:41:58 +00001171 <div class="flex items-center gap-1.5">
philip.zeyliger6d3de482025-06-10 19:38:14 -07001172 <span
bankseanc5147482025-06-29 00:41:58 +00001173 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 -07001174 title="Click to copy: ${commit.pushed_branch}"
1175 @click=${(e) =>
1176 this.copyToClipboard(
1177 commit.pushed_branch,
1178 e,
1179 )}
1180 >${commit.pushed_branch}</span
1181 >
cbroebbdee42025-06-20 09:57:44 +00001182 <span
bankseanc5147482025-06-29 00:41:58 +00001183 class="opacity-70 flex items-center hover:opacity-100"
cbroebbdee42025-06-20 09:57:44 +00001184 @click=${(e) => {
1185 e.stopPropagation();
1186 this.copyToClipboard(
1187 commit.pushed_branch,
1188 e,
1189 );
1190 }}
1191 >
philip.zeyliger6d3de482025-06-10 19:38:14 -07001192 <svg
1193 xmlns="http://www.w3.org/2000/svg"
1194 width="14"
1195 height="14"
1196 viewBox="0 0 24 24"
1197 fill="none"
1198 stroke="currentColor"
1199 stroke-width="2"
1200 stroke-linecap="round"
1201 stroke-linejoin="round"
bankseanc5147482025-06-29 00:41:58 +00001202 class="align-middle"
philip.zeyliger6d3de482025-06-10 19:38:14 -07001203 >
1204 <rect
1205 x="9"
1206 y="9"
1207 width="13"
1208 height="13"
1209 rx="2"
1210 ry="2"
1211 ></rect>
1212 <path
1213 d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"
1214 ></path>
1215 </svg>
1216 </span>
1217 ${githubLink
1218 ? html`
1219 <a
1220 href="${githubLink}"
1221 target="_blank"
1222 rel="noopener noreferrer"
banksean3eaa4332025-07-19 02:19:06 +00001223 class="text-gray-600 dark:text-gray-400 no-underline flex items-center transition-colors duration-200 hover:text-blue-600 dark:hover:text-blue-400"
philip.zeyliger6d3de482025-06-10 19:38:14 -07001224 title="Open ${commit.pushed_branch} on GitHub"
1225 @click=${(e) =>
1226 e.stopPropagation()}
1227 >
1228 <svg
bankseanc5147482025-06-29 00:41:58 +00001229 class="w-3.5 h-3.5"
philip.zeyliger6d3de482025-06-10 19:38:14 -07001230 viewBox="0 0 16 16"
1231 width="14"
1232 height="14"
1233 >
1234 <path
1235 fill="currentColor"
1236 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"
1237 />
1238 </svg>
1239 </a>
1240 `
1241 : ""}
1242 </div>
1243 `;
1244 })()
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +00001245 : ``}
bankseanc5147482025-06-29 00:41:58 +00001246 <span
banksean3eaa4332025-07-19 02:19:06 +00001247 class="text-sm text-gray-700 dark:text-gray-300 flex-grow truncate"
Sean McCullough71941bd2025-04-18 13:31:48 -07001248 >
bankseanc5147482025-06-29 00:41:58 +00001249 ${commit.subject}
1250 </span>
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +00001251 <button
bankseanc5147482025-06-29 00:41:58 +00001252 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 +00001253 @click=${() => this.showCommit(commit.hash)}
1254 >
1255 View Diff
1256 </button>
Sean McCullough86b56862025-04-18 13:04:03 -07001257 </div>
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001258 `;
1259 })}
1260 </div>
1261 `
1262 : ""}
1263 </div>
1264 </div>
1265
bankseanc5147482025-06-29 00:41:58 +00001266 <!-- Right metadata area -->
1267 <div
1268 class="${this.compactPadding
1269 ? "hidden"
banksean3eaa4332025-07-19 02:19:06 +00001270 : "flex-none w-20 px-1 py-0.5 text-left text-xs text-gray-500 dark:text-gray-400 self-start"}"
bankseanc5147482025-06-29 00:41:58 +00001271 ></div>
Sean McCullough86b56862025-04-18 13:04:03 -07001272 </div>
bankseancad67b02025-06-27 21:57:05 +00001273
1274 <!-- User name for user messages - positioned outside and below the bubble -->
1275 ${this.message?.type === "user" && this.state?.git_username
1276 ? html`
bankseanc5147482025-06-29 00:41:58 +00001277 <div
1278 class="flex justify-end mt-1 ${this.compactPadding
1279 ? ""
1280 : "pr-20"}"
1281 >
banksean3eaa4332025-07-19 02:19:06 +00001282 <div
1283 class="text-xs text-gray-600 dark:text-gray-400 italic text-right"
1284 >
bankseanb7ec9c82025-07-09 10:16:39 -07001285 ${this.state?.link_to_github
1286 ? html`@<a
1287 class="no-underline hover:underline"
1288 href="${this.state.link_to_github}"
1289 title="${this.state.git_username} on GitHub"
1290 >${this.state.git_username}</a
1291 >`
1292 : ""}
bankseanc5147482025-06-29 00:41:58 +00001293 </div>
bankseancad67b02025-06-27 21:57:05 +00001294 </div>
1295 `
1296 : ""}
Sean McCullough86b56862025-04-18 13:04:03 -07001297 </div>
1298 `;
1299 }
1300}
1301
Sean McCullough71941bd2025-04-18 13:31:48 -07001302function copyButton(textToCopy: string) {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001303 // SVG for copy icon (two overlapping rectangles)
1304 const copyIcon = html`<svg
1305 xmlns="http://www.w3.org/2000/svg"
1306 width="16"
1307 height="16"
1308 viewBox="0 0 24 24"
1309 fill="none"
1310 stroke="currentColor"
1311 stroke-width="2"
1312 stroke-linecap="round"
1313 stroke-linejoin="round"
1314 >
1315 <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
1316 <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
1317 </svg>`;
1318
1319 // SVG for success check mark
1320 const successIcon = html`<svg
1321 xmlns="http://www.w3.org/2000/svg"
1322 width="16"
1323 height="16"
1324 viewBox="0 0 24 24"
1325 fill="none"
1326 stroke="currentColor"
1327 stroke-width="2"
1328 stroke-linecap="round"
1329 stroke-linejoin="round"
1330 >
1331 <path d="M20 6L9 17l-5-5"></path>
1332 </svg>`;
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +00001333
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001334 const ret = html`<button
bankseanc5147482025-06-29 00:41:58 +00001335 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 +00001336 title="Copy to clipboard"
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001337 @click=${(e: Event) => {
1338 e.stopPropagation();
1339 const copyButton = e.currentTarget as HTMLButtonElement;
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001340 const originalInnerHTML = copyButton.innerHTML;
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001341 navigator.clipboard
1342 .writeText(textToCopy)
1343 .then(() => {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001344 copyButton.innerHTML = "";
1345 const successElement = document.createElement("div");
1346 copyButton.appendChild(successElement);
1347 render(successIcon, successElement);
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001348 setTimeout(() => {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001349 copyButton.innerHTML = originalInnerHTML;
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001350 }, 2000);
1351 })
1352 .catch((err) => {
1353 console.error("Failed to copy text: ", err);
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001354 setTimeout(() => {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001355 copyButton.innerHTML = originalInnerHTML;
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001356 }, 2000);
1357 });
1358 }}
1359 >
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001360 ${copyIcon}
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001361 </button>`;
Sean McCullough86b56862025-04-18 13:04:03 -07001362
Sean McCullough71941bd2025-04-18 13:31:48 -07001363 return ret;
Sean McCullough86b56862025-04-18 13:04:03 -07001364}
1365
bankseanc5147482025-06-29 00:41:58 +00001366// Global styles are now injected in the component's connectedCallback() method
1367// to ensure they are added when the component is actually used, not at module load time
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +00001368
Sean McCullough86b56862025-04-18 13:04:03 -07001369declare global {
1370 interface HTMLElementTagNameMap {
1371 "sketch-timeline-message": SketchTimelineMessage;
1372 }
1373}