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