blob: 9a0b78aa45a9a681ae12e3b97896bd9b05952417 [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";
bankseanbdc68892025-07-28 17:28:13 -07008import "./sketch-tool-calls";
9import "./sketch-external-message";
10import { SketchTailwindElement } from "./sketch-tailwind-element";
philip.zeyliger7c1a6872025-06-16 03:54:37 +000011
12// Mermaid is loaded dynamically - see loadMermaid() function
13declare global {
14 interface Window {
15 mermaid?: typeof mermaid;
16 }
17}
18
19// Mermaid hash will be injected at build time
20declare const __MERMAID_HASH__: string;
21
22// Load Mermaid dynamically
23let mermaidLoadPromise: Promise<any> | null = null;
24
25function loadMermaid(): Promise<typeof mermaid> {
26 if (mermaidLoadPromise) {
27 return mermaidLoadPromise;
28 }
29
30 if (window.mermaid) {
31 return Promise.resolve(window.mermaid);
32 }
33
34 mermaidLoadPromise = new Promise((resolve, reject) => {
35 // Get the Mermaid hash from build-time constant
36 const mermaidHash = __MERMAID_HASH__;
37
38 // Try to load the external Mermaid bundle
39 const script = document.createElement("script");
40 script.onload = () => {
41 // The Mermaid bundle should set window.mermaid
42 if (window.mermaid) {
43 resolve(window.mermaid);
44 } else {
45 reject(new Error("Mermaid not loaded from external bundle"));
46 }
47 };
48 script.onerror = (error) => {
49 console.warn("Failed to load external Mermaid bundle:", error);
50 reject(new Error("Mermaid external bundle failed to load"));
51 };
52
53 // Don't set type="module" since we're using IIFE format
54 script.src = `./static/mermaid-standalone-${mermaidHash}.js`;
55 document.head.appendChild(script);
56 });
57
58 return mermaidLoadPromise;
59}
bankseanc5147482025-06-29 00:41:58 +000060
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
banksean3eaa4332025-07-19 02:19:06 +0000406 /* Dark mode styles */
407 .dark .markdown-content pre,
408 .dark .markdown-content code {
409 background: rgba(255, 255, 255, 0.1);
410 color: #e5e7eb;
411 }
412
413 .dark .markdown-content blockquote {
414 border-left: 3px solid rgba(255, 255, 255, 0.2);
415 color: rgba(255, 255, 255, 0.7);
416 }
417
418 .dark .markdown-content hr {
419 border-top: 1px solid rgba(255, 255, 255, 0.2);
420 }
421
422 .dark .code-block-container {
423 background: rgba(255, 255, 255, 0.1);
424 }
425
426 .dark .code-block-header {
427 background: rgba(255, 255, 255, 0.15);
428 color: #e5e7eb;
429 }
430
431 .dark .code-copy-button:hover {
432 background: rgba(255, 255, 255, 0.1);
433 }
434
435 .dark .mermaid-container {
436 background-color: #374151;
437 border: 1px solid #4b5563;
438 }
439
bankseanc5147482025-06-29 00:41:58 +0000440 /* Print styles */
441 @media print {
442 .floating-message,
443 .commit-diff-button,
444 button[title="Copy to clipboard"],
445 button[title="Show message details"] {
446 display: none !important;
447 }
448 }
449`;
Sean McCullough86b56862025-04-18 13:04:03 -0700450 }
Autoformatterdded2d62025-04-28 00:27:21 +0000451
Sean McCullough8d93e362025-04-27 23:32:18 +0000452 // After the component is updated and rendered, render any mermaid diagrams
453 updated(changedProperties: Map<string, unknown>) {
454 super.updated(changedProperties);
455 this.renderMermaidDiagrams();
456 }
Autoformatterdded2d62025-04-28 00:27:21 +0000457
Sean McCullough8d93e362025-04-27 23:32:18 +0000458 // Render mermaid diagrams after the component is updated
459 renderMermaidDiagrams() {
460 // Add a small delay to ensure the DOM is fully rendered
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000461 setTimeout(async () => {
Sean McCullough8d93e362025-04-27 23:32:18 +0000462 // Find all mermaid containers in our shadow root
Sean McCulloughf6e1dfe2025-07-03 14:59:40 -0700463 const containers = this.querySelectorAll(".mermaid");
Sean McCullough8d93e362025-04-27 23:32:18 +0000464 if (!containers || containers.length === 0) return;
Autoformatterdded2d62025-04-28 00:27:21 +0000465
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000466 try {
467 // Load mermaid dynamically
468 const mermaidLib = await loadMermaid();
Autoformatterdded2d62025-04-28 00:27:21 +0000469
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000470 // Initialize mermaid with specific config (only once per load)
471 mermaidLib.initialize({
472 startOnLoad: false,
473 suppressErrorRendering: true,
474 theme: "default",
475 securityLevel: "loose", // Allows more flexibility but be careful with user-generated content
476 fontFamily: "monospace",
477 });
Autoformatterdded2d62025-04-28 00:27:21 +0000478
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000479 // Process each mermaid diagram
480 containers.forEach((container) => {
481 const id = container.id;
482 const code = container.textContent || "";
483 if (!code || !id) return; // Use return for forEach instead of continue
484
485 try {
486 // Clear any previous content
487 container.innerHTML = code;
488
489 // Render the mermaid diagram using promise
490 mermaidLib
491 .render(`${id}-svg`, code)
492 .then(({ svg }) => {
493 container.innerHTML = svg;
494 })
495 .catch((err) => {
496 console.error("Error rendering mermaid diagram:", err);
497 // Show the original code as fallback
498 container.innerHTML = `<pre>${code}</pre>`;
499 });
500 } catch (err) {
501 console.error("Error processing mermaid diagram:", err);
502 // Show the original code as fallback
503 container.innerHTML = `<pre>${code}</pre>`;
504 }
505 });
506 } catch (err) {
507 console.error("Error loading mermaid:", err);
508 // Show the original code as fallback for all diagrams
509 containers.forEach((container) => {
510 const code = container.textContent || "";
Sean McCullough8d93e362025-04-27 23:32:18 +0000511 container.innerHTML = `<pre>${code}</pre>`;
philip.zeyliger7c1a6872025-06-16 03:54:37 +0000512 });
513 }
Sean McCullough8d93e362025-04-27 23:32:18 +0000514 }, 100); // Small delay to ensure DOM is ready
515 }
Sean McCullough86b56862025-04-18 13:04:03 -0700516
517 // See https://lit.dev/docs/components/lifecycle/
518 disconnectedCallback() {
519 super.disconnectedCallback();
520 }
521
Philip Zeyligerd9acaa72025-07-14 14:51:55 -0700522 // Add post-sanitization button replacement
523 private addCopyButtons(html: string): string {
524 return html.replace(
525 /<span class="copy-button-placeholder"><\/span>/g,
526 `<button class="code-copy-button" title="Copy code">
banksean2cc75632025-07-17 17:10:17 +0000527 <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 -0700528 <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
529 <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
530 </svg>
531 </button>`,
532 );
533 }
534
535 // Event delegation handler for code copy functionality
536 private handleCodeCopy(event: Event) {
537 const button = event.target as HTMLElement;
538 if (!button.classList.contains("code-copy-button")) return;
539
540 event.stopPropagation();
541
542 // Find the code element using DOM traversal
543 const header = button.closest(".code-block-header");
544 const codeElement = header?.nextElementSibling?.querySelector("code");
545 if (!codeElement) return;
546
547 // Read the text directly from DOM (automatically unescapes HTML)
548 const codeText = codeElement.textContent || "";
549
550 // Copy to clipboard with visual feedback
551 navigator.clipboard
552 .writeText(codeText)
553 .then(() => {
554 // Show success feedback (icon change + floating message)
555 const originalHTML = button.innerHTML;
556 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">
557 <path d="M20 6L9 17l-5-5"></path>
558 </svg>`;
559 this.showFloatingMessage(
560 "Copied!",
561 button.getBoundingClientRect(),
562 "success",
563 );
564 setTimeout(() => (button.innerHTML = originalHTML), 2000);
565 })
566 .catch((err) => {
567 console.error("Failed to copy code:", err);
568 this.showFloatingMessage(
569 "Failed to copy!",
570 button.getBoundingClientRect(),
571 "error",
572 );
573 });
574 }
575
Sean McCullough86b56862025-04-18 13:04:03 -0700576 renderMarkdown(markdownContent: string): string {
577 try {
Sean McCullough8d93e362025-04-27 23:32:18 +0000578 // Create a custom renderer
579 const renderer = new Renderer();
580 const originalCodeRenderer = renderer.code.bind(renderer);
Autoformatterdded2d62025-04-28 00:27:21 +0000581
Pokey Rulea10f1512025-05-15 13:53:26 +0000582 // Override the code renderer to handle mermaid diagrams and add copy buttons
Autoformatterdded2d62025-04-28 00:27:21 +0000583 renderer.code = function ({ text, lang, escaped }: Tokens.Code): string {
584 if (lang === "mermaid") {
Sean McCullough8d93e362025-04-27 23:32:18 +0000585 // Generate a unique ID for this diagram
586 const id = `mermaid-diagram-${Math.random().toString(36).substring(2, 10)}`;
Autoformatterdded2d62025-04-28 00:27:21 +0000587
Sean McCullough8d93e362025-04-27 23:32:18 +0000588 // Just create the container and mermaid div - we'll render it in the updated() lifecycle method
589 return `<div class="mermaid-container">
590 <div class="mermaid" id="${id}">${text}</div>
591 </div>`;
592 }
Pokey Rulea10f1512025-05-15 13:53:26 +0000593
Philip Zeyliger0d092842025-06-09 18:57:12 -0700594 // For regular code blocks, call the original renderer to get properly escaped HTML
595 const originalCodeHtml = originalCodeRenderer({ text, lang, escaped });
596
597 // Extract the code content from the original HTML to add our custom wrapper
598 // The original renderer returns: <pre><code class="language-x">escapedText</code></pre>
599 const codeMatch = originalCodeHtml.match(
600 /<pre><code[^>]*>([\s\S]*?)<\/code><\/pre>/,
601 );
602 if (!codeMatch) {
603 // Fallback to original if we can't parse it
604 return originalCodeHtml;
605 }
606
607 const escapedText = codeMatch[1];
Pokey Rulea10f1512025-05-15 13:53:26 +0000608 const langClass = lang ? ` class="language-${lang}"` : "";
609
Philip Zeyligerd9acaa72025-07-14 14:51:55 -0700610 // Use placeholder instead of actual button - will be replaced after sanitization
Pokey Rulea10f1512025-05-15 13:53:26 +0000611 return `<div class="code-block-container">
612 <div class="code-block-header">
613 ${lang ? `<span class="code-language">${lang}</span>` : ""}
Philip Zeyligerd9acaa72025-07-14 14:51:55 -0700614 <span class="copy-button-placeholder"></span>
Pokey Rulea10f1512025-05-15 13:53:26 +0000615 </div>
Philip Zeyligerd9acaa72025-07-14 14:51:55 -0700616 <pre><code${langClass}>${escapedText}</code></pre>
Pokey Rulea10f1512025-05-15 13:53:26 +0000617 </div>`;
Sean McCullough8d93e362025-04-27 23:32:18 +0000618 };
Autoformatterdded2d62025-04-28 00:27:21 +0000619
Philip Zeyliger53ab2452025-06-04 17:49:33 +0000620 // Set markdown options for proper code block highlighting
Sean McCullough86b56862025-04-18 13:04:03 -0700621 const markedOptions: MarkedOptions = {
622 gfm: true, // GitHub Flavored Markdown
623 breaks: true, // Convert newlines to <br>
624 async: false,
Autoformatterdded2d62025-04-28 00:27:21 +0000625 renderer: renderer,
Sean McCullough86b56862025-04-18 13:04:03 -0700626 };
Philip Zeyliger53ab2452025-06-04 17:49:33 +0000627
628 // Parse markdown and sanitize the output HTML with DOMPurify
629 const htmlOutput = marked.parse(markdownContent, markedOptions) as string;
Philip Zeyligerd9acaa72025-07-14 14:51:55 -0700630 const sanitizedOutput = DOMPurify.sanitize(htmlOutput, {
Philip Zeyliger53ab2452025-06-04 17:49:33 +0000631 // Allow common HTML elements that are safe
632 ALLOWED_TAGS: [
633 "p",
634 "br",
635 "strong",
636 "em",
637 "b",
638 "i",
639 "u",
640 "s",
641 "code",
642 "pre",
643 "h1",
644 "h2",
645 "h3",
646 "h4",
647 "h5",
648 "h6",
649 "ul",
650 "ol",
651 "li",
652 "blockquote",
653 "a",
654 "div",
655 "span", // For mermaid diagrams and code blocks
656 "svg",
657 "g",
658 "path",
659 "rect",
660 "circle",
661 "text",
662 "line",
663 "polygon", // For mermaid SVG
664 "button", // For code copy buttons
665 ],
666 ALLOWED_ATTR: [
667 "href",
668 "title",
669 "target",
670 "rel", // For links
671 "class",
672 "id", // For styling and functionality
673 "data-*", // For code copy buttons
674 // SVG attributes for mermaid diagrams
675 "viewBox",
676 "width",
677 "height",
678 "xmlns",
679 "fill",
680 "stroke",
681 "stroke-width",
682 "d",
683 "x",
684 "y",
685 "x1",
686 "y1",
687 "x2",
688 "y2",
689 "cx",
690 "cy",
691 "r",
692 "rx",
693 "ry",
694 "points",
695 "transform",
696 "text-anchor",
697 "font-size",
698 "font-family",
699 ],
700 // Allow data attributes for functionality
701 ALLOW_DATA_ATTR: true,
702 // Keep whitespace for code formatting
703 KEEP_CONTENT: true,
704 });
Philip Zeyligerd9acaa72025-07-14 14:51:55 -0700705
706 // Add copy buttons after sanitization
707 return this.addCopyButtons(sanitizedOutput);
Sean McCullough86b56862025-04-18 13:04:03 -0700708 } catch (error) {
709 console.error("Error rendering markdown:", error);
Philip Zeyliger53ab2452025-06-04 17:49:33 +0000710 // Fallback to sanitized plain text if markdown parsing fails
711 return DOMPurify.sanitize(markdownContent);
Sean McCullough86b56862025-04-18 13:04:03 -0700712 }
713 }
714
715 /**
716 * Format timestamp for display
717 */
718 formatTimestamp(
719 timestamp: string | number | Date | null | undefined,
720 defaultValue: string = "",
721 ): string {
722 if (!timestamp) return defaultValue;
723 try {
724 const date = new Date(timestamp);
725 if (isNaN(date.getTime())) return defaultValue;
726
727 // Format: Mar 13, 2025 09:53:25 AM
728 return date.toLocaleString("en-US", {
729 month: "short",
730 day: "numeric",
731 year: "numeric",
732 hour: "numeric",
733 minute: "2-digit",
734 second: "2-digit",
735 hour12: true,
736 });
philip.zeyliger26bc6592025-06-30 20:15:30 -0700737 } catch {
Sean McCullough86b56862025-04-18 13:04:03 -0700738 return defaultValue;
739 }
740 }
741
742 formatNumber(
743 num: number | null | undefined,
744 defaultValue: string = "0",
745 ): string {
746 if (num === undefined || num === null) return defaultValue;
747 try {
748 return num.toLocaleString();
philip.zeyliger26bc6592025-06-30 20:15:30 -0700749 } catch {
Sean McCullough86b56862025-04-18 13:04:03 -0700750 return String(num);
751 }
752 }
753 formatCurrency(
754 num: number | string | null | undefined,
755 defaultValue: string = "$0.00",
756 isMessageLevel: boolean = false,
757 ): string {
758 if (num === undefined || num === null) return defaultValue;
759 try {
760 // Use 4 decimal places for message-level costs, 2 for totals
761 const decimalPlaces = isMessageLevel ? 4 : 2;
762 return `$${parseFloat(String(num)).toFixed(decimalPlaces)}`;
philip.zeyliger26bc6592025-06-30 20:15:30 -0700763 } catch {
Sean McCullough86b56862025-04-18 13:04:03 -0700764 return defaultValue;
765 }
766 }
767
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000768 // Format duration from nanoseconds to a human-readable string
769 _formatDuration(nanoseconds: number | null | undefined): string {
770 if (!nanoseconds) return "0s";
771
772 const seconds = nanoseconds / 1e9;
773
774 if (seconds < 60) {
775 return `${seconds.toFixed(1)}s`;
776 } else if (seconds < 3600) {
777 const minutes = Math.floor(seconds / 60);
778 const remainingSeconds = seconds % 60;
779 return `${minutes}min ${remainingSeconds.toFixed(0)}s`;
780 } else {
781 const hours = Math.floor(seconds / 3600);
782 const remainingSeconds = seconds % 3600;
783 const minutes = Math.floor(remainingSeconds / 60);
784 return `${hours}h ${minutes}min`;
785 }
786 }
787
Sean McCullough86b56862025-04-18 13:04:03 -0700788 showCommit(commitHash: string) {
Sean McCullough71941bd2025-04-18 13:31:48 -0700789 this.dispatchEvent(
790 new CustomEvent("show-commit-diff", {
791 bubbles: true,
792 composed: true,
793 detail: { commitHash },
794 }),
795 );
Sean McCullough86b56862025-04-18 13:04:03 -0700796 }
797
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000798 _toggleInfo(e: Event) {
799 e.stopPropagation();
800 this.showInfo = !this.showInfo;
801 }
802
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +0000803 copyToClipboard(text: string, event: Event) {
804 const element = event.currentTarget as HTMLElement;
805 const rect = element.getBoundingClientRect();
806
807 navigator.clipboard
808 .writeText(text)
809 .then(() => {
810 this.showFloatingMessage("Copied!", rect, "success");
811 })
812 .catch((err) => {
813 console.error("Failed to copy text: ", err);
814 this.showFloatingMessage("Failed to copy!", rect, "error");
815 });
816 }
817
818 showFloatingMessage(
819 message: string,
820 targetRect: DOMRect,
821 type: "success" | "error",
822 ) {
823 // Create floating message element
824 const floatingMsg = document.createElement("div");
825 floatingMsg.textContent = message;
826 floatingMsg.className = `floating-message ${type}`;
827
828 // Position it near the clicked element
829 // Position just above the element
830 const top = targetRect.top - 30;
831 const left = targetRect.left + targetRect.width / 2 - 40;
832
833 floatingMsg.style.position = "fixed";
834 floatingMsg.style.top = `${top}px`;
835 floatingMsg.style.left = `${left}px`;
836 floatingMsg.style.zIndex = "9999";
837
838 // Add to document body
839 document.body.appendChild(floatingMsg);
840
841 // Animate in
842 floatingMsg.style.opacity = "0";
843 floatingMsg.style.transform = "translateY(10px)";
844
845 setTimeout(() => {
846 floatingMsg.style.opacity = "1";
847 floatingMsg.style.transform = "translateY(0)";
848 }, 10);
849
850 // Remove after animation
851 setTimeout(() => {
852 floatingMsg.style.opacity = "0";
853 floatingMsg.style.transform = "translateY(-10px)";
854
855 setTimeout(() => {
856 document.body.removeChild(floatingMsg);
857 }, 300);
858 }, 1500);
859 }
860
philip.zeyliger6d3de482025-06-10 19:38:14 -0700861 // Format GitHub repository URL to org/repo format
862 formatGitHubRepo(url) {
863 if (!url) return null;
864
865 // Common GitHub URL patterns
866 const patterns = [
867 // HTTPS URLs
868 /https:\/\/github\.com\/([^/]+)\/([^/\s.]+)(?:\.git)?/,
869 // SSH URLs
870 /git@github\.com:([^/]+)\/([^/\s.]+)(?:\.git)?/,
871 // Git protocol
872 /git:\/\/github\.com\/([^/]+)\/([^/\s.]+)(?:\.git)?/,
873 ];
874
875 for (const pattern of patterns) {
876 const match = url.match(pattern);
877 if (match) {
878 return {
879 formatted: `${match[1]}/${match[2]}`,
880 url: `https://github.com/${match[1]}/${match[2]}`,
881 owner: match[1],
882 repo: match[2],
883 };
884 }
885 }
886
887 return null;
888 }
889
890 // Generate GitHub branch URL if linking is enabled
891 getGitHubBranchLink(branchName) {
892 if (!this.state?.link_to_github || !branchName) {
893 return null;
894 }
895
896 const github = this.formatGitHubRepo(this.state?.git_origin);
897 if (!github) {
898 return null;
899 }
900
901 return `https://github.com/${github.owner}/${github.repo}/tree/${branchName}`;
902 }
903
Sean McCullough86b56862025-04-18 13:04:03 -0700904 render() {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000905 // Calculate if this is an end of turn message with no parent conversation ID
906 const isEndOfTurn =
907 this.message?.end_of_turn && !this.message?.parent_conversation_id;
908
Philip Zeyligerb8a8f352025-06-02 07:39:37 -0700909 const isPreCompaction =
910 this.message?.idx !== undefined &&
911 this.message.idx < this.firstMessageIndex;
912
bankseanc5147482025-06-29 00:41:58 +0000913 // Dynamic classes based on message type and state
914 const messageClasses = [
915 "relative mb-1.5 flex flex-col w-full", // base message styles
916 isEndOfTurn ? "mb-4" : "", // end-of-turn spacing
917 isPreCompaction ? "opacity-85 border-l-2 border-gray-300" : "", // pre-compaction styling
918 ]
919 .filter(Boolean)
920 .join(" ");
921
922 const bubbleContainerClasses = [
bankseandc27c392025-07-11 18:36:14 -0700923 "flex-1 flex text-ellipsis",
bankseanc5147482025-06-29 00:41:58 +0000924 this.compactPadding ? "max-w-full" : "max-w-[calc(100%-160px)]",
925 this.message?.type === "user" ? "justify-end" : "justify-start",
926 ]
927 .filter(Boolean)
928 .join(" ");
929
930 const messageContentClasses = [
bankseanbdc68892025-07-28 17:28:13 -0700931 "relative px-2.5 py-1.5 min-w-min",
bankseanc5147482025-06-29 00:41:58 +0000932 // User message styling
933 this.message?.type === "user"
bankseanbdc68892025-07-28 17:28:13 -0700934 ? "rounded-xl shadow-sm bg-blue-500 text-white rounded-br-sm"
935 : this.message?.type === "agent" // Agent/tool/error message styling
936 ? "rounded-xl shadow-sm bg-gray-100 dark:bg-gray-800 text-black dark:text-gray-100 rounded-bl-sm"
937 : this.message?.type === "external" // External message styling
938 ? "bg-white dark:bg-gray-800 text-black dark:text-gray-100"
939 : "", // default styling for other types
bankseanc5147482025-06-29 00:41:58 +0000940 ]
941 .filter(Boolean)
942 .join(" ");
943
Sean McCullough86b56862025-04-18 13:04:03 -0700944 return html`
bankseanc5147482025-06-29 00:41:58 +0000945 <div class="${messageClasses}">
946 <div class="flex relative w-full">
947 <!-- Left metadata area -->
948 <div
949 class="${this.compactPadding
950 ? "hidden"
banksean3eaa4332025-07-19 02:19:06 +0000951 : "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 +0000952 ></div>
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000953
954 <!-- Message bubble -->
banksean02693402025-07-17 17:10:10 +0000955 <div
956 class="${bubbleContainerClasses}"
957 style="${this.compactPadding
958 ? ""
959 : "max-width: calc(100% - 160px);"}"
960 >
961 <div
962 class="${messageContentClasses}"
963 style="max-width: 100%; overflow: hidden; width: fit-content; min-width: 200px;"
964 @click=${this.handleCodeCopy}
965 >
bankseanc5147482025-06-29 00:41:58 +0000966 <div class="relative">
967 <div
968 class="absolute top-1 right-1 z-10 opacity-0 hover:opacity-100 transition-opacity duration-200 flex gap-1.5"
969 >
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000970 ${copyButton(this.message?.content)}
971 <button
bankseanc5147482025-06-29 00:41:58 +0000972 class="bg-transparent border-none ${this.message?.type ===
973 "user"
974 ? "text-white/80 hover:bg-white/15"
banksean3eaa4332025-07-19 02:19:06 +0000975 : "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 +0000976 title="Show message details"
977 @click=${this._toggleInfo}
Sean McCullough71941bd2025-04-18 13:31:48 -0700978 >
Philip Zeyliger16fa8b42025-05-02 04:28:16 +0000979 <svg
980 xmlns="http://www.w3.org/2000/svg"
981 width="16"
982 height="16"
983 viewBox="0 0 24 24"
984 fill="none"
985 stroke="currentColor"
986 stroke-width="2"
987 stroke-linecap="round"
988 stroke-linejoin="round"
989 >
990 <circle cx="12" cy="12" r="10"></circle>
991 <line x1="12" y1="16" x2="12" y2="12"></line>
992 <line x1="12" y1="8" x2="12.01" y2="8"></line>
993 </svg>
994 </button>
995 </div>
996 ${this.message?.content
997 ? html`
bankseanc5147482025-06-29 00:41:58 +0000998 <div
banksean02693402025-07-17 17:10:10 +0000999 class="mb-0 font-sans py-0.5 select-text cursor-text text-sm leading-relaxed text-left box-border markdown-content"
1000 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 -07001001 @click=${this.handleCodeCopy}
banksean2cc75632025-07-17 17:10:17 +00001002 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 +00001003 >
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001004 ${unsafeHTML(
1005 this.renderMarkdown(this.message?.content),
1006 )}
1007 </div>
1008 `
1009 : ""}
1010
1011 <!-- End of turn indicator inside the bubble -->
1012 ${isEndOfTurn && this.message?.elapsed
1013 ? html`
bankseanc5147482025-06-29 00:41:58 +00001014 <div
1015 class="block text-xs ${this.message?.type === "user"
1016 ? "text-white/70"
banksean3eaa4332025-07-19 02:19:06 +00001017 : "text-gray-500 dark:text-gray-400"} py-0.5 mt-2 text-right italic"
bankseanc5147482025-06-29 00:41:58 +00001018 >
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001019 end of turn
1020 (${this._formatDuration(this.message?.elapsed)})
1021 </div>
1022 `
1023 : ""}
1024
1025 <!-- Info panel that can be toggled -->
1026 ${this.showInfo
1027 ? html`
bankseanc5147482025-06-29 00:41:58 +00001028 <div
1029 class="mt-2 p-2 ${this.message?.type === "user"
1030 ? "bg-white/15 border-l-2 border-white/20"
banksean3eaa4332025-07-19 02:19:06 +00001031 : "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 +00001032 >
1033 <div class="mb-1 flex">
1034 <span class="font-bold mr-1 min-w-[60px]">Type:</span>
1035 <span class="flex-1">${this.message?.type}</span>
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001036 </div>
bankseanc5147482025-06-29 00:41:58 +00001037 <div class="mb-1 flex">
1038 <span class="font-bold mr-1 min-w-[60px]">Time:</span>
1039 <span class="flex-1">
1040 ${this.formatTimestamp(this.message?.timestamp, "")}
1041 </span>
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001042 </div>
1043 ${this.message?.elapsed
1044 ? html`
bankseanc5147482025-06-29 00:41:58 +00001045 <div class="mb-1 flex">
1046 <span class="font-bold mr-1 min-w-[60px]"
1047 >Duration:</span
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001048 >
bankseanc5147482025-06-29 00:41:58 +00001049 <span class="flex-1">
1050 ${this._formatDuration(this.message?.elapsed)}
1051 </span>
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001052 </div>
1053 `
1054 : ""}
1055 ${this.message?.usage
1056 ? html`
bankseanc5147482025-06-29 00:41:58 +00001057 <div class="mb-1 flex">
1058 <span class="font-bold mr-1 min-w-[60px]"
1059 >Tokens:</span
1060 >
1061 <span class="flex-1">
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001062 ${this.message?.usage
1063 ? html`
1064 <div>
1065 Input:
1066 ${this.formatNumber(
1067 this.message?.usage?.input_tokens ||
1068 0,
1069 )}
1070 </div>
1071 ${this.message?.usage
1072 ?.cache_creation_input_tokens
1073 ? html`
1074 <div>
1075 Cache creation:
1076 ${this.formatNumber(
1077 this.message?.usage
1078 ?.cache_creation_input_tokens,
1079 )}
1080 </div>
1081 `
1082 : ""}
1083 ${this.message?.usage
1084 ?.cache_read_input_tokens
1085 ? html`
1086 <div>
1087 Cache read:
1088 ${this.formatNumber(
1089 this.message?.usage
1090 ?.cache_read_input_tokens,
1091 )}
1092 </div>
1093 `
1094 : ""}
1095 <div>
1096 Output:
1097 ${this.formatNumber(
1098 this.message?.usage?.output_tokens,
1099 )}
1100 </div>
1101 <div>
1102 Cost:
1103 ${this.formatCurrency(
1104 this.message?.usage?.cost_usd,
1105 )}
1106 </div>
1107 `
1108 : "N/A"}
1109 </span>
1110 </div>
1111 `
1112 : ""}
1113 ${this.message?.conversation_id
1114 ? html`
bankseanc5147482025-06-29 00:41:58 +00001115 <div class="mb-1 flex">
1116 <span class="font-bold mr-1 min-w-[60px]"
1117 >Conversation ID:</span
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001118 >
bankseanc5147482025-06-29 00:41:58 +00001119 <span
1120 class="flex-1 font-mono text-xs break-all"
1121 >
1122 ${this.message?.conversation_id}
1123 </span>
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001124 </div>
1125 `
1126 : ""}
1127 </div>
1128 `
1129 : ""}
1130 </div>
1131
1132 <!-- Tool calls - only shown for agent messages -->
1133 ${this.message?.type === "agent"
1134 ? html`
1135 <sketch-tool-calls
1136 .toolCalls=${this.message?.tool_calls}
1137 .open=${this.open}
1138 ></sketch-tool-calls>
1139 `
1140 : ""}
1141
bankseanbdc68892025-07-28 17:28:13 -07001142 <!-- External messages -->
1143 ${this.message?.type === "external"
1144 ? html`
1145 <sketch-external-message
1146 .message=${this.message?.external_message}
1147 .open=${this.open}
1148 ></sketch-external-message>
1149 `
1150 : ""}
1151
bankseanc5147482025-06-29 00:41:58 +00001152 <!-- Commits section -->
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001153 ${this.message?.commits
1154 ? html`
bankseanc5147482025-06-29 00:41:58 +00001155 <div class="mt-2.5">
1156 <div
1157 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"
1158 >
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001159 ${this.message.commits.length} new
1160 commit${this.message.commits.length > 1 ? "s" : ""}
1161 detected
1162 </div>
1163 ${this.message.commits.map((commit) => {
1164 return html`
bankseanc5147482025-06-29 00:41:58 +00001165 <div
banksean3eaa4332025-07-19 02:19:06 +00001166 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 +00001167 >
Philip Zeyliger72682df2025-04-23 13:09:46 -07001168 <span
bankseanc5147482025-06-29 00:41:58 +00001169 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 -07001170 title="Click to copy: ${commit.hash}"
1171 @click=${(e) =>
1172 this.copyToClipboard(
1173 commit.hash.substring(0, 8),
1174 e,
1175 )}
1176 >
Pokey Rule7be879f2025-04-23 15:30:15 +01001177 ${commit.hash.substring(0, 8)}
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +00001178 </span>
1179 ${commit.pushed_branch
philip.zeyliger6d3de482025-06-10 19:38:14 -07001180 ? (() => {
1181 const githubLink = this.getGitHubBranchLink(
1182 commit.pushed_branch,
1183 );
1184 return html`
bankseanc5147482025-06-29 00:41:58 +00001185 <div class="flex items-center gap-1.5">
philip.zeyliger6d3de482025-06-10 19:38:14 -07001186 <span
bankseanc5147482025-06-29 00:41:58 +00001187 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 -07001188 title="Click to copy: ${commit.pushed_branch}"
1189 @click=${(e) =>
1190 this.copyToClipboard(
1191 commit.pushed_branch,
1192 e,
1193 )}
1194 >${commit.pushed_branch}</span
1195 >
cbroebbdee42025-06-20 09:57:44 +00001196 <span
bankseanc5147482025-06-29 00:41:58 +00001197 class="opacity-70 flex items-center hover:opacity-100"
cbroebbdee42025-06-20 09:57:44 +00001198 @click=${(e) => {
1199 e.stopPropagation();
1200 this.copyToClipboard(
1201 commit.pushed_branch,
1202 e,
1203 );
1204 }}
1205 >
philip.zeyliger6d3de482025-06-10 19:38:14 -07001206 <svg
1207 xmlns="http://www.w3.org/2000/svg"
1208 width="14"
1209 height="14"
1210 viewBox="0 0 24 24"
1211 fill="none"
1212 stroke="currentColor"
1213 stroke-width="2"
1214 stroke-linecap="round"
1215 stroke-linejoin="round"
bankseanc5147482025-06-29 00:41:58 +00001216 class="align-middle"
philip.zeyliger6d3de482025-06-10 19:38:14 -07001217 >
1218 <rect
1219 x="9"
1220 y="9"
1221 width="13"
1222 height="13"
1223 rx="2"
1224 ry="2"
1225 ></rect>
1226 <path
1227 d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"
1228 ></path>
1229 </svg>
1230 </span>
1231 ${githubLink
1232 ? html`
1233 <a
1234 href="${githubLink}"
1235 target="_blank"
1236 rel="noopener noreferrer"
banksean3eaa4332025-07-19 02:19:06 +00001237 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 -07001238 title="Open ${commit.pushed_branch} on GitHub"
1239 @click=${(e) =>
1240 e.stopPropagation()}
1241 >
1242 <svg
bankseanc5147482025-06-29 00:41:58 +00001243 class="w-3.5 h-3.5"
philip.zeyliger6d3de482025-06-10 19:38:14 -07001244 viewBox="0 0 16 16"
1245 width="14"
1246 height="14"
1247 >
1248 <path
1249 fill="currentColor"
1250 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"
1251 />
1252 </svg>
1253 </a>
1254 `
1255 : ""}
1256 </div>
1257 `;
1258 })()
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +00001259 : ``}
bankseanc5147482025-06-29 00:41:58 +00001260 <span
banksean3eaa4332025-07-19 02:19:06 +00001261 class="text-sm text-gray-700 dark:text-gray-300 flex-grow truncate"
Sean McCullough71941bd2025-04-18 13:31:48 -07001262 >
bankseanc5147482025-06-29 00:41:58 +00001263 ${commit.subject}
1264 </span>
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +00001265 <button
bankseanc5147482025-06-29 00:41:58 +00001266 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 +00001267 @click=${() => this.showCommit(commit.hash)}
1268 >
1269 View Diff
1270 </button>
Sean McCullough86b56862025-04-18 13:04:03 -07001271 </div>
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001272 `;
1273 })}
1274 </div>
1275 `
1276 : ""}
1277 </div>
1278 </div>
1279
bankseanc5147482025-06-29 00:41:58 +00001280 <!-- Right metadata area -->
1281 <div
1282 class="${this.compactPadding
1283 ? "hidden"
banksean3eaa4332025-07-19 02:19:06 +00001284 : "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 +00001285 ></div>
Sean McCullough86b56862025-04-18 13:04:03 -07001286 </div>
bankseancad67b02025-06-27 21:57:05 +00001287
1288 <!-- User name for user messages - positioned outside and below the bubble -->
1289 ${this.message?.type === "user" && this.state?.git_username
1290 ? html`
bankseanc5147482025-06-29 00:41:58 +00001291 <div
1292 class="flex justify-end mt-1 ${this.compactPadding
1293 ? ""
1294 : "pr-20"}"
1295 >
banksean3eaa4332025-07-19 02:19:06 +00001296 <div
1297 class="text-xs text-gray-600 dark:text-gray-400 italic text-right"
1298 >
bankseanb7ec9c82025-07-09 10:16:39 -07001299 ${this.state?.link_to_github
1300 ? html`@<a
1301 class="no-underline hover:underline"
1302 href="${this.state.link_to_github}"
1303 title="${this.state.git_username} on GitHub"
1304 >${this.state.git_username}</a
1305 >`
1306 : ""}
bankseanc5147482025-06-29 00:41:58 +00001307 </div>
bankseancad67b02025-06-27 21:57:05 +00001308 </div>
1309 `
1310 : ""}
Sean McCullough86b56862025-04-18 13:04:03 -07001311 </div>
1312 `;
1313 }
1314}
1315
Sean McCullough71941bd2025-04-18 13:31:48 -07001316function copyButton(textToCopy: string) {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001317 // SVG for copy icon (two overlapping rectangles)
1318 const copyIcon = html`<svg
1319 xmlns="http://www.w3.org/2000/svg"
1320 width="16"
1321 height="16"
1322 viewBox="0 0 24 24"
1323 fill="none"
1324 stroke="currentColor"
1325 stroke-width="2"
1326 stroke-linecap="round"
1327 stroke-linejoin="round"
1328 >
1329 <rect x="9" y="9" width="13" height="13" rx="2" ry="2"></rect>
1330 <path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"></path>
1331 </svg>`;
1332
1333 // SVG for success check mark
1334 const successIcon = html`<svg
1335 xmlns="http://www.w3.org/2000/svg"
1336 width="16"
1337 height="16"
1338 viewBox="0 0 24 24"
1339 fill="none"
1340 stroke="currentColor"
1341 stroke-width="2"
1342 stroke-linecap="round"
1343 stroke-linejoin="round"
1344 >
1345 <path d="M20 6L9 17l-5-5"></path>
1346 </svg>`;
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +00001347
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001348 const ret = html`<button
bankseanc5147482025-06-29 00:41:58 +00001349 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 +00001350 title="Copy to clipboard"
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001351 @click=${(e: Event) => {
1352 e.stopPropagation();
1353 const copyButton = e.currentTarget as HTMLButtonElement;
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001354 const originalInnerHTML = copyButton.innerHTML;
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001355 navigator.clipboard
1356 .writeText(textToCopy)
1357 .then(() => {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001358 copyButton.innerHTML = "";
1359 const successElement = document.createElement("div");
1360 copyButton.appendChild(successElement);
1361 render(successIcon, successElement);
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001362 setTimeout(() => {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001363 copyButton.innerHTML = originalInnerHTML;
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001364 }, 2000);
1365 })
1366 .catch((err) => {
1367 console.error("Failed to copy text: ", err);
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001368 setTimeout(() => {
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001369 copyButton.innerHTML = originalInnerHTML;
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001370 }, 2000);
1371 });
1372 }}
1373 >
Philip Zeyliger16fa8b42025-05-02 04:28:16 +00001374 ${copyIcon}
Sean McCulloughec3ad1a2025-04-18 13:55:16 -07001375 </button>`;
Sean McCullough86b56862025-04-18 13:04:03 -07001376
Sean McCullough71941bd2025-04-18 13:31:48 -07001377 return ret;
Sean McCullough86b56862025-04-18 13:04:03 -07001378}
1379
bankseanc5147482025-06-29 00:41:58 +00001380// Global styles are now injected in the component's connectedCallback() method
1381// to ensure they are added when the component is actually used, not at module load time
Philip Zeyliger37dc4cf2025-04-23 12:58:52 +00001382
Sean McCullough86b56862025-04-18 13:04:03 -07001383declare global {
1384 interface HTMLElementTagNameMap {
1385 "sketch-timeline-message": SketchTimelineMessage;
1386 }
1387}