blob: ef707bd00c08599e8b8a716edb77ae2c54313dcb [file] [log] [blame]
Sean McCullough86b56862025-04-18 13:04:03 -07001import { css, html, LitElement } from "lit";
2import { unsafeHTML } from "lit/directives/unsafe-html.js";
3import { customElement, property } from "lit/decorators.js";
Sean McCulloughd9f13372025-04-21 15:08:49 -07004import { AgentMessage } from "../types";
Sean McCullough86b56862025-04-18 13:04:03 -07005import { marked, MarkedOptions } from "marked";
6import "./sketch-tool-calls";
7@customElement("sketch-timeline-message")
8export class SketchTimelineMessage extends LitElement {
9 @property()
Sean McCulloughd9f13372025-04-21 15:08:49 -070010 message: AgentMessage;
Sean McCullough86b56862025-04-18 13:04:03 -070011
12 @property()
Sean McCulloughd9f13372025-04-21 15:08:49 -070013 previousMessage: AgentMessage;
Sean McCullough86b56862025-04-18 13:04:03 -070014
Sean McCullough2deac842025-04-21 18:17:57 -070015 @property()
16 open: boolean = false;
17
Sean McCullough86b56862025-04-18 13:04:03 -070018 // See https://lit.dev/docs/components/styles/ for how lit-element handles CSS.
19 // Note that these styles only apply to the scope of this web component's
20 // shadow DOM node, so they won't leak out or collide with CSS declared in
21 // other components or the containing web page (...unless you want it to do that).
22 static styles = css`
23 .message {
24 position: relative;
25 margin-bottom: 5px;
26 padding-left: 30px;
27 }
28
29 .message-icon {
30 position: absolute;
31 left: 10px;
32 top: 0;
33 transform: translateX(-50%);
34 width: 16px;
35 height: 16px;
36 border-radius: 3px;
37 text-align: center;
38 line-height: 16px;
39 color: #fff;
40 font-size: 10px;
41 }
42
43 .message-content {
44 position: relative;
45 padding: 5px 10px;
46 background: #fff;
47 border-radius: 3px;
48 box-shadow: 0 1px 2px rgba(0, 0, 0, 0.05);
49 border-left: 3px solid transparent;
50 }
51
52 /* Copy button styles */
53 .message-text-container,
54 .tool-result-container {
55 position: relative;
56 }
57
58 .message-actions {
59 position: absolute;
60 top: 5px;
61 right: 5px;
62 z-index: 10;
63 opacity: 0;
64 transition: opacity 0.2s ease;
65 }
66
67 .message-text-container:hover .message-actions,
68 .tool-result-container:hover .message-actions {
69 opacity: 1;
70 }
71
72 .copy-button {
73 background-color: rgba(255, 255, 255, 0.9);
74 border: 1px solid #ddd;
75 border-radius: 4px;
76 color: #555;
77 cursor: pointer;
78 font-size: 12px;
79 padding: 2px 8px;
80 transition: all 0.2s ease;
81 }
82
83 .copy-button:hover {
84 background-color: #f0f0f0;
85 color: #333;
86 }
87
88 /* Removed arrow decoration for a more compact look */
89
90 .message-header {
91 display: flex;
92 flex-wrap: wrap;
93 gap: 5px;
94 margin-bottom: 3px;
95 font-size: 12px;
96 }
97
98 .message-timestamp {
99 font-size: 10px;
100 color: #888;
101 font-style: italic;
102 margin-left: 3px;
103 }
104
105 .message-usage {
106 font-size: 10px;
107 color: #888;
108 margin-left: 3px;
109 }
110
111 .conversation-id {
112 font-family: monospace;
113 font-size: 12px;
114 padding: 2px 4px;
115 background-color: #f0f0f0;
116 border-radius: 3px;
117 margin-left: auto;
118 }
119
120 .parent-info {
121 font-size: 11px;
122 opacity: 0.8;
123 }
124
125 .subconversation {
126 border-left: 2px solid transparent;
127 padding-left: 5px;
128 margin-left: 20px;
129 transition: margin-left 0.3s ease;
130 }
131
132 .message-text {
133 overflow-x: auto;
134 margin-bottom: 3px;
135 font-family: monospace;
136 padding: 3px 5px;
137 background: rgb(236, 236, 236);
138 border-radius: 6px;
139 user-select: text;
140 cursor: text;
141 -webkit-user-select: text;
142 -moz-user-select: text;
143 -ms-user-select: text;
144 font-size: 13px;
145 line-height: 1.3;
146 }
147
148 .tool-details {
149 margin-top: 3px;
150 padding-top: 3px;
151 border-top: 1px dashed #e0e0e0;
152 font-size: 12px;
153 }
154
155 .tool-name {
156 font-size: 12px;
157 font-weight: bold;
158 margin-bottom: 2px;
159 background: #f0f0f0;
160 padding: 2px 4px;
161 border-radius: 2px;
162 display: flex;
163 align-items: center;
164 gap: 3px;
165 }
166
167 .tool-input,
168 .tool-result {
169 margin-top: 2px;
170 padding: 3px 5px;
171 background: #f7f7f7;
172 border-radius: 2px;
173 font-family: monospace;
174 font-size: 12px;
175 overflow-x: auto;
176 white-space: pre;
177 line-height: 1.3;
178 user-select: text;
179 cursor: text;
180 -webkit-user-select: text;
181 -moz-user-select: text;
182 -ms-user-select: text;
183 }
184
185 .tool-result {
186 max-height: 300px;
187 overflow-y: auto;
188 }
189
190 .usage-info {
191 margin-top: 10px;
192 padding-top: 10px;
193 border-top: 1px dashed #e0e0e0;
194 font-size: 12px;
195 color: #666;
196 }
197
198 /* Custom styles for IRC-like experience */
199 .user .message-content {
200 border-left-color: #2196f3;
201 }
202
203 .agent .message-content {
204 border-left-color: #4caf50;
205 }
206
207 .tool .message-content {
208 border-left-color: #ff9800;
209 }
210
211 .error .message-content {
212 border-left-color: #f44336;
213 }
214
215 /* Make message type display bold but without the IRC-style markers */
216 .message-type {
217 font-weight: bold;
218 }
219
220 /* Commit message styling */
221 .message.commit {
222 background-color: #f0f7ff;
223 border-left: 4px solid #0366d6;
224 }
225
226 .commits-container {
227 margin-top: 10px;
228 padding: 5px;
229 }
230
231 .commits-header {
232 font-weight: bold;
233 margin-bottom: 5px;
234 color: #24292e;
235 }
236
237 .commit-boxes-row {
238 display: flex;
239 flex-wrap: wrap;
240 gap: 8px;
241 margin-top: 8px;
242 }
243
244 .commit-box {
245 border: 1px solid #d1d5da;
246 border-radius: 4px;
247 overflow: hidden;
248 background-color: #ffffff;
249 box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
250 max-width: 100%;
251 display: flex;
252 flex-direction: column;
253 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700254
Sean McCullough86b56862025-04-18 13:04:03 -0700255 .commit-preview {
256 padding: 8px 12px;
257 cursor: pointer;
258 font-family: monospace;
259 background-color: #f6f8fa;
260 border-bottom: 1px dashed #d1d5da;
261 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700262
Sean McCullough86b56862025-04-18 13:04:03 -0700263 .commit-preview:hover {
264 background-color: #eef2f6;
265 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700266
Sean McCullough86b56862025-04-18 13:04:03 -0700267 .commit-hash {
268 color: #0366d6;
269 font-weight: bold;
270 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700271
Sean McCullough86b56862025-04-18 13:04:03 -0700272 .commit-details {
273 padding: 8px 12px;
274 max-height: 200px;
275 overflow-y: auto;
276 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700277
Sean McCullough86b56862025-04-18 13:04:03 -0700278 .commit-details pre {
279 margin: 0;
280 white-space: pre-wrap;
281 word-break: break-word;
282 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700283
Sean McCullough86b56862025-04-18 13:04:03 -0700284 .commit-details.is-hidden {
285 display: none;
286 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700287
Sean McCullough86b56862025-04-18 13:04:03 -0700288 .pushed-branch {
289 color: #28a745;
290 font-weight: 500;
291 margin-left: 6px;
292 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700293
Sean McCullough86b56862025-04-18 13:04:03 -0700294 .commit-diff-button {
295 padding: 6px 12px;
296 border: 1px solid #ccc;
297 border-radius: 3px;
298 background-color: #f7f7f7;
299 color: #24292e;
300 font-size: 12px;
301 cursor: pointer;
302 transition: all 0.2s ease;
303 margin: 8px 12px;
304 display: block;
305 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700306
Sean McCullough86b56862025-04-18 13:04:03 -0700307 .commit-diff-button:hover {
308 background-color: #e7e7e7;
309 border-color: #aaa;
310 }
Sean McCullough71941bd2025-04-18 13:31:48 -0700311
Sean McCullough86b56862025-04-18 13:04:03 -0700312 /* Tool call cards */
313 .tool-call-cards-container {
314 display: flex;
315 flex-direction: column;
316 gap: 8px;
317 margin-top: 8px;
318 }
319
320 /* Message type styles */
321
322 .user .message-icon {
323 background-color: #2196f3;
324 }
325
326 .agent .message-icon {
327 background-color: #4caf50;
328 }
329
330 .tool .message-icon {
331 background-color: #ff9800;
332 }
333
334 .error .message-icon {
335 background-color: #f44336;
336 }
337
338 .end-of-turn {
339 margin-bottom: 15px;
340 }
341
342 .end-of-turn::after {
343 content: "End of Turn";
344 position: absolute;
345 left: 15px;
346 bottom: -10px;
347 transform: translateX(-50%);
348 font-size: 10px;
349 color: #666;
350 background: #f0f0f0;
351 padding: 1px 4px;
352 border-radius: 3px;
353 }
354
355 .markdown-content {
356 box-sizing: border-box;
357 min-width: 200px;
358 margin: 0 auto;
359 }
360
361 .markdown-content p {
362 margin-block-start: 0.5em;
363 margin-block-end: 0.5em;
364 }
365 `;
366
367 constructor() {
368 super();
369 }
370
371 // See https://lit.dev/docs/components/lifecycle/
372 connectedCallback() {
373 super.connectedCallback();
374 }
375
376 // See https://lit.dev/docs/components/lifecycle/
377 disconnectedCallback() {
378 super.disconnectedCallback();
379 }
380
381 renderMarkdown(markdownContent: string): string {
382 try {
383 // Set markdown options for proper code block highlighting and safety
384 const markedOptions: MarkedOptions = {
385 gfm: true, // GitHub Flavored Markdown
386 breaks: true, // Convert newlines to <br>
387 async: false,
388 // DOMPurify is recommended for production, but not included in this implementation
389 };
390 return marked.parse(markdownContent, markedOptions) as string;
391 } catch (error) {
392 console.error("Error rendering markdown:", error);
393 // Fallback to plain text if markdown parsing fails
394 return markdownContent;
395 }
396 }
397
398 /**
399 * Format timestamp for display
400 */
401 formatTimestamp(
402 timestamp: string | number | Date | null | undefined,
403 defaultValue: string = "",
404 ): string {
405 if (!timestamp) return defaultValue;
406 try {
407 const date = new Date(timestamp);
408 if (isNaN(date.getTime())) return defaultValue;
409
410 // Format: Mar 13, 2025 09:53:25 AM
411 return date.toLocaleString("en-US", {
412 month: "short",
413 day: "numeric",
414 year: "numeric",
415 hour: "numeric",
416 minute: "2-digit",
417 second: "2-digit",
418 hour12: true,
419 });
420 } catch (e) {
421 return defaultValue;
422 }
423 }
424
425 formatNumber(
426 num: number | null | undefined,
427 defaultValue: string = "0",
428 ): string {
429 if (num === undefined || num === null) return defaultValue;
430 try {
431 return num.toLocaleString();
432 } catch (e) {
433 return String(num);
434 }
435 }
436 formatCurrency(
437 num: number | string | null | undefined,
438 defaultValue: string = "$0.00",
439 isMessageLevel: boolean = false,
440 ): string {
441 if (num === undefined || num === null) return defaultValue;
442 try {
443 // Use 4 decimal places for message-level costs, 2 for totals
444 const decimalPlaces = isMessageLevel ? 4 : 2;
445 return `$${parseFloat(String(num)).toFixed(decimalPlaces)}`;
446 } catch (e) {
447 return defaultValue;
448 }
449 }
450
451 showCommit(commitHash: string) {
Sean McCullough71941bd2025-04-18 13:31:48 -0700452 this.dispatchEvent(
453 new CustomEvent("show-commit-diff", {
454 bubbles: true,
455 composed: true,
456 detail: { commitHash },
457 }),
458 );
Sean McCullough86b56862025-04-18 13:04:03 -0700459 }
460
461 render() {
462 return html`
463 <div
464 class="message ${this.message?.type} ${this.message?.end_of_turn
465 ? "end-of-turn"
466 : ""}"
467 >
468 ${this.previousMessage?.type != this.message?.type
469 ? html`<div class="message-icon">
470 ${this.message?.type.toUpperCase()[0]}
471 </div>`
472 : ""}
473 <div class="message-content">
474 <div class="message-header">
475 <span class="message-type">${this.message?.type}</span>
Sean McCullough71941bd2025-04-18 13:31:48 -0700476 <span class="message-timestamp"
477 >${this.formatTimestamp(this.message?.timestamp)}
478 ${this.message?.elapsed
479 ? html`(${(this.message?.elapsed / 1e9).toFixed(2)}s)`
480 : ""}</span
481 >
482 ${this.message?.usage
483 ? html` <span class="message-usage">
484 <span title="Input tokens"
485 >In: ${this.message?.usage?.input_tokens}</span
486 >
487 ${this.message?.usage?.cache_read_input_tokens > 0
488 ? html`<span title="Cache tokens"
489 >[Cache:
490 ${this.formatNumber(
491 this.message?.usage?.cache_read_input_tokens,
492 )}]</span
493 >`
494 : ""}
495 <span title="Output tokens"
496 >Out: ${this.message?.usage?.output_tokens}</span
497 >
498 <span title="Message cost"
499 >(${this.formatCurrency(
500 this.message?.usage?.cost_usd,
501 )})</span
502 >
503 </span>`
504 : ""}
Sean McCullough86b56862025-04-18 13:04:03 -0700505 </div>
506 <div class="message-text-container">
507 <div class="message-actions">
508 ${copyButton(this.message?.content)}
509 </div>
510 ${this.message?.content
511 ? html`
512 <div class="message-text markdown-content">
513 ${unsafeHTML(this.renderMarkdown(this.message?.content))}
514 </div>
515 `
516 : ""}
517 </div>
518 <sketch-tool-calls
519 .toolCalls=${this.message?.tool_calls}
Sean McCullough2deac842025-04-21 18:17:57 -0700520 .open=${this.open}
Sean McCullough86b56862025-04-18 13:04:03 -0700521 ></sketch-tool-calls>
522 ${this.message?.commits
523 ? html`
524 <div class="commits-container">
525 <div class="commits-header">
Sean McCullough71941bd2025-04-18 13:31:48 -0700526 ${this.message.commits.length} new
527 commit${this.message.commits.length > 1 ? "s" : ""} detected
Sean McCullough86b56862025-04-18 13:04:03 -0700528 </div>
529 ${this.message.commits.map((commit) => {
530 return html`
531 <div class="commit-boxes-row">
532 <div class="commit-box">
533 <div class="commit-preview">
Sean McCullough71941bd2025-04-18 13:31:48 -0700534 <span class="commit-hash"
535 >${commit.hash.substring(0, 8)}</span
536 >
Sean McCullough86b56862025-04-18 13:04:03 -0700537 ${commit.subject}
538 <span class="pushed-branch"
Sean McCullough71941bd2025-04-18 13:31:48 -0700539 >→ pushed to ${commit.pushed_branch}</span
540 >
Sean McCullough86b56862025-04-18 13:04:03 -0700541 </div>
542 <div class="commit-details is-hidden">
543 <pre>${commit.body}</pre>
544 </div>
Sean McCullough71941bd2025-04-18 13:31:48 -0700545 <button
546 class="commit-diff-button"
547 @click=${() => this.showCommit(commit.hash)}
548 >
549 View Changes
550 </button>
Sean McCullough86b56862025-04-18 13:04:03 -0700551 </div>
552 </div>
553 `;
554 })}
555 </div>
556 `
557 : ""}
558 </div>
559 </div>
560 `;
561 }
562}
563
Sean McCullough71941bd2025-04-18 13:31:48 -0700564function copyButton(textToCopy: string) {
Sean McCullough86b56862025-04-18 13:04:03 -0700565 // Add click event listener to handle copying
Sean McCulloughec3ad1a2025-04-18 13:55:16 -0700566 const ret = html`<button
567 class="copy-button"
568 title="Copy text to clipboard"
569 @click=${(e: Event) => {
570 e.stopPropagation();
571 const copyButton = e.currentTarget as HTMLButtonElement;
572 navigator.clipboard
573 .writeText(textToCopy)
574 .then(() => {
575 copyButton.textContent = "Copied!";
576 setTimeout(() => {
577 copyButton.textContent = "Copy";
578 }, 2000);
579 })
580 .catch((err) => {
581 console.error("Failed to copy text: ", err);
582 copyButton.textContent = "Failed";
583 setTimeout(() => {
584 copyButton.textContent = "Copy";
585 }, 2000);
586 });
587 }}
588 >
589 Copy
590 </button>`;
Sean McCullough86b56862025-04-18 13:04:03 -0700591
Sean McCullough71941bd2025-04-18 13:31:48 -0700592 return ret;
Sean McCullough86b56862025-04-18 13:04:03 -0700593}
594
595declare global {
596 interface HTMLElementTagNameMap {
597 "sketch-timeline-message": SketchTimelineMessage;
598 }
599}