blob: cd2985a10e8925ad3e756e95a89852768d1b32df [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 }
251
252 .commit-preview {
253 padding: 8px 12px;
254 cursor: pointer;
255 font-family: monospace;
256 background-color: #f6f8fa;
257 border-bottom: 1px dashed #d1d5da;
258 }
259
260 .commit-preview:hover {
261 background-color: #eef2f6;
262 }
263
264 .commit-hash {
265 color: #0366d6;
266 font-weight: bold;
267 }
268
269 .commit-details {
270 padding: 8px 12px;
271 max-height: 200px;
272 overflow-y: auto;
273 }
274
275 .commit-details pre {
276 margin: 0;
277 white-space: pre-wrap;
278 word-break: break-word;
279 }
280
281 .commit-details.is-hidden {
282 display: none;
283 }
284
285 .pushed-branch {
286 color: #28a745;
287 font-weight: 500;
288 margin-left: 6px;
289 }
290
291 .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 }
303
304 .commit-diff-button:hover {
305 background-color: #e7e7e7;
306 border-color: #aaa;
307 }
308
309 /* 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) {
449 this.dispatchEvent(new CustomEvent("show-commit-diff", {bubbles: true, composed: true, detail: {commitHash}}))
450 }
451
452 render() {
453 return html`
454 <div
455 class="message ${this.message?.type} ${this.message?.end_of_turn
456 ? "end-of-turn"
457 : ""}"
458 >
459 ${this.previousMessage?.type != this.message?.type
460 ? html`<div class="message-icon">
461 ${this.message?.type.toUpperCase()[0]}
462 </div>`
463 : ""}
464 <div class="message-content">
465 <div class="message-header">
466 <span class="message-type">${this.message?.type}</span>
467 <span class="message-timestamp">${this.formatTimestamp(this.message?.timestamp)} ${this.message?.elapsed ? html`(${(this.message?.elapsed / 1e9).toFixed(2)}s)` : ''}</span>
468 ${this.message?.usage ? html`
469 <span class="message-usage">
470 <span title="Input tokens">In: ${this.message?.usage?.input_tokens}</span>
471 ${this.message?.usage?.cache_read_input_tokens > 0 ? html`<span title="Cache tokens">[Cache: ${this.formatNumber(this.message?.usage?.cache_read_input_tokens)}]</span>` : ""}
472 <span title="Output tokens">Out: ${this.message?.usage?.output_tokens}</span>
473 <span title="Message cost">(${this.formatCurrency(this.message?.usage?.cost_usd)})</span>
474 </span>` : ''}
475 </div>
476 <div class="message-text-container">
477 <div class="message-actions">
478 ${copyButton(this.message?.content)}
479 </div>
480 ${this.message?.content
481 ? html`
482 <div class="message-text markdown-content">
483 ${unsafeHTML(this.renderMarkdown(this.message?.content))}
484 </div>
485 `
486 : ""}
487 </div>
488 <sketch-tool-calls
489 .toolCalls=${this.message?.tool_calls}
490 ></sketch-tool-calls>
491 ${this.message?.commits
492 ? html`
493 <div class="commits-container">
494 <div class="commits-header">
495 ${this.message.commits.length} new commit${this.message.commits.length > 1 ? "s" : ""} detected
496 </div>
497 ${this.message.commits.map((commit) => {
498 return html`
499 <div class="commit-boxes-row">
500 <div class="commit-box">
501 <div class="commit-preview">
502 <span class="commit-hash">${commit.hash.substring(0, 8)}</span>
503 ${commit.subject}
504 <span class="pushed-branch"
505 >→ pushed to ${commit.pushed_branch}</span>
506 </div>
507 <div class="commit-details is-hidden">
508 <pre>${commit.body}</pre>
509 </div>
510 <button class="commit-diff-button" @click=${() => this.showCommit(commit.hash)}>View Changes</button>
511 </div>
512 </div>
513 `;
514 })}
515 </div>
516 `
517 : ""}
518 </div>
519 </div>
520 `;
521 }
522}
523
524function copyButton(textToCopy: string) {
525 // Add click event listener to handle copying
526 const ret = html`<button class="copy-button" title="Copy text to clipboard" @click=${(e: Event) => {
527 e.stopPropagation();
528 const copyButton = e.currentTarget as HTMLButtonElement;
529 navigator.clipboard
530 .writeText(textToCopy)
531 .then(() => {
532 copyButton.textContent = "Copied!";
533 setTimeout(() => {
534 copyButton.textContent = "Copy";
535 }, 2000);
536 })
537 .catch((err) => {
538 console.error("Failed to copy text: ", err);
539 copyButton.textContent = "Failed";
540 setTimeout(() => {
541 copyButton.textContent = "Copy";
542 }, 2000);
543 });
544 }}>Copy</button`;
545
546 return ret
547}
548
549declare global {
550 interface HTMLElementTagNameMap {
551 "sketch-timeline-message": SketchTimelineMessage;
552 }
553}