blob: bd3a9e3a7104fbcf7083e334292427dbd95ded41 [file] [log] [blame]
philip.zeyliger26bc6592025-06-30 20:15:30 -07001/* eslint-disable @typescript-eslint/no-explicit-any */
Philip Zeyligere08c7ff2025-06-06 13:22:12 -07002import { css, html, LitElement } from "lit";
3import { customElement, property, state } from "lit/decorators.js";
Philip Zeyliger5f778942025-06-07 00:05:20 +00004import { unsafeHTML } from "lit/directives/unsafe-html.js";
Philip Zeyligere08c7ff2025-06-06 13:22:12 -07005import { AgentMessage } from "../types";
6import { createRef, ref } from "lit/directives/ref.js";
Philip Zeyliger5f778942025-06-07 00:05:20 +00007import { marked, MarkedOptions, Renderer } from "marked";
8import DOMPurify from "dompurify";
Philip Zeyligere08c7ff2025-06-06 13:22:12 -07009
10@customElement("mobile-chat")
11export class MobileChat extends LitElement {
12 @property({ type: Array })
13 messages: AgentMessage[] = [];
14
15 @property({ type: Boolean })
16 isThinking = false;
17
18 private scrollContainer = createRef<HTMLDivElement>();
19
Philip Zeyliger61a0f672025-06-21 15:33:18 -070020 @state()
21 private showJumpToBottom = false;
22
Philip Zeyligere08c7ff2025-06-06 13:22:12 -070023 static styles = css`
24 :host {
25 display: block;
26 height: 100%;
27 overflow: hidden;
28 }
29
30 .chat-container {
31 height: 100%;
32 overflow-y: auto;
33 padding: 16px;
34 display: flex;
35 flex-direction: column;
36 gap: 16px;
37 scroll-behavior: smooth;
38 -webkit-overflow-scrolling: touch;
39 }
40
41 .message {
42 display: flex;
43 flex-direction: column;
44 max-width: 85%;
45 word-wrap: break-word;
46 }
47
48 .message.user {
49 align-self: flex-end;
50 align-items: flex-end;
51 }
52
53 .message.assistant {
54 align-self: flex-start;
55 align-items: flex-start;
56 }
57
58 .message-bubble {
59 padding: 8px 12px;
60 border-radius: 18px;
61 font-size: 16px;
62 line-height: 1.4;
63 }
64
65 .message.user .message-bubble {
66 background-color: #007bff;
67 color: white;
68 border-bottom-right-radius: 6px;
69 }
70
71 .message.assistant .message-bubble {
72 background-color: #f1f3f4;
73 color: #333;
74 border-bottom-left-radius: 6px;
75 }
76
philip.zeyliger41682632025-06-09 22:23:25 +000077 .message.error .message-bubble {
78 background-color: #ffebee;
79 color: #d32f2f;
80 border-radius: 18px;
81 }
82
Philip Zeyligere08c7ff2025-06-06 13:22:12 -070083 .thinking-message {
84 align-self: flex-start;
85 align-items: flex-start;
86 max-width: 85%;
87 }
88
89 .thinking-bubble {
90 background-color: #f1f3f4;
91 padding: 16px;
92 border-radius: 18px;
93 border-bottom-left-radius: 6px;
94 display: flex;
95 align-items: center;
96 gap: 8px;
97 }
98
99 .thinking-text {
100 color: #6c757d;
101 font-style: italic;
102 }
103
104 .thinking-dots {
105 display: flex;
106 gap: 3px;
107 }
108
109 .thinking-dot {
110 width: 6px;
111 height: 6px;
112 border-radius: 50%;
113 background-color: #6c757d;
114 animation: thinking 1.4s ease-in-out infinite both;
115 }
116
Autoformatterf825e692025-06-07 04:19:43 +0000117 .thinking-dot:nth-child(1) {
118 animation-delay: -0.32s;
119 }
120 .thinking-dot:nth-child(2) {
121 animation-delay: -0.16s;
122 }
123 .thinking-dot:nth-child(3) {
124 animation-delay: 0;
125 }
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700126
127 @keyframes thinking {
Autoformatterf825e692025-06-07 04:19:43 +0000128 0%,
129 80%,
130 100% {
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700131 transform: scale(0.8);
132 opacity: 0.5;
133 }
134 40% {
135 transform: scale(1);
136 opacity: 1;
137 }
138 }
139
140 .empty-state {
141 flex: 1;
142 display: flex;
143 align-items: center;
144 justify-content: center;
145 color: #6c757d;
146 font-style: italic;
147 text-align: center;
148 padding: 32px;
149 }
Philip Zeyliger5f778942025-06-07 00:05:20 +0000150
151 /* Markdown content styling for mobile */
152 .markdown-content {
153 line-height: 1.5;
154 word-wrap: break-word;
155 overflow-wrap: break-word;
156 }
157
158 .markdown-content p {
159 margin: 0.3em 0;
160 }
161
162 .markdown-content p:first-child {
163 margin-top: 0;
164 }
165
166 .markdown-content p:last-child {
167 margin-bottom: 0;
168 }
169
170 .markdown-content h1,
171 .markdown-content h2,
172 .markdown-content h3,
173 .markdown-content h4,
174 .markdown-content h5,
175 .markdown-content h6 {
176 margin: 0.5em 0 0.3em 0;
177 font-weight: bold;
178 }
179
Autoformatterf825e692025-06-07 04:19:43 +0000180 .markdown-content h1 {
181 font-size: 1.2em;
182 }
183 .markdown-content h2 {
184 font-size: 1.15em;
185 }
186 .markdown-content h3 {
187 font-size: 1.1em;
188 }
Philip Zeyliger5f778942025-06-07 00:05:20 +0000189 .markdown-content h4,
190 .markdown-content h5,
Autoformatterf825e692025-06-07 04:19:43 +0000191 .markdown-content h6 {
192 font-size: 1.05em;
193 }
Philip Zeyliger5f778942025-06-07 00:05:20 +0000194
195 .markdown-content code {
196 background-color: rgba(0, 0, 0, 0.08);
197 padding: 2px 4px;
198 border-radius: 3px;
Autoformatterf825e692025-06-07 04:19:43 +0000199 font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace;
Philip Zeyliger5f778942025-06-07 00:05:20 +0000200 font-size: 0.9em;
201 }
202
203 .markdown-content pre {
204 background-color: rgba(0, 0, 0, 0.08);
205 padding: 8px;
206 border-radius: 6px;
207 margin: 0.5em 0;
208 overflow-x: auto;
209 font-size: 0.9em;
210 }
211
212 .markdown-content pre code {
213 background: none;
214 padding: 0;
215 }
216
217 .markdown-content ul,
218 .markdown-content ol {
219 margin: 0.5em 0;
220 padding-left: 1.2em;
221 }
222
223 .markdown-content li {
224 margin: 0.2em 0;
225 }
226
227 .markdown-content blockquote {
228 border-left: 3px solid rgba(0, 0, 0, 0.2);
229 margin: 0.5em 0;
230 padding-left: 0.8em;
231 font-style: italic;
232 }
233
234 .markdown-content a {
235 color: inherit;
236 text-decoration: underline;
237 }
238
239 .markdown-content strong,
240 .markdown-content b {
241 font-weight: bold;
242 }
243
244 .markdown-content em,
245 .markdown-content i {
246 font-style: italic;
247 }
philip.zeyliger851d2bf2025-06-16 03:10:10 +0000248
249 /* Tool calls styling for mobile */
250 .tool-calls {
251 margin-top: 12px;
252 display: flex;
253 flex-direction: column;
254 gap: 6px;
255 }
256
257 .tool-call-item {
258 background-color: rgba(0, 0, 0, 0.04);
259 border-radius: 8px;
260 padding: 6px 8px;
261 font-size: 12px;
262 font-family: monospace;
263 line-height: 1.3;
264 display: flex;
265 align-items: center;
266 gap: 6px;
267 }
268
269 .tool-status-icon {
270 flex-shrink: 0;
271 font-size: 14px;
272 }
273
274 .tool-name {
275 font-weight: bold;
276 color: #333;
277 flex-shrink: 0;
278 margin-right: 2px;
279 }
280
281 .tool-summary {
282 color: #555;
283 flex-grow: 1;
284 overflow: hidden;
285 text-overflow: ellipsis;
286 white-space: nowrap;
287 }
288
289 .tool-duration {
290 font-size: 10px;
291 color: #888;
292 flex-shrink: 0;
293 margin-left: 4px;
294 }
Philip Zeyliger61a0f672025-06-21 15:33:18 -0700295
296 .jump-to-bottom {
297 position: fixed;
298 bottom: 70px;
299 left: 50%;
300 transform: translateX(-50%);
301 background-color: rgba(0, 0, 0, 0.6);
302 color: white;
303 border: none;
304 border-radius: 12px;
305 padding: 4px 8px;
306 font-size: 11px;
307 font-weight: 400;
308 cursor: pointer;
309 box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
310 z-index: 1100;
311 transition: all 0.15s ease;
312 display: flex;
313 align-items: center;
314 gap: 4px;
315 opacity: 0.8;
316 }
317
318 .jump-to-bottom:hover {
319 background-color: rgba(0, 0, 0, 0.8);
320 transform: translateX(-50%) translateY(-1px);
321 opacity: 1;
322 box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
323 }
324
325 .jump-to-bottom:active {
326 transform: translateX(-50%) translateY(0);
327 }
328
329 .jump-to-bottom.hidden {
330 opacity: 0;
331 pointer-events: none;
332 transform: translateX(-50%) translateY(10px);
333 }
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700334 `;
335
336 updated(changedProperties: Map<string, any>) {
337 super.updated(changedProperties);
Autoformatterf825e692025-06-07 04:19:43 +0000338 if (
339 changedProperties.has("messages") ||
340 changedProperties.has("isThinking")
341 ) {
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700342 this.scrollToBottom();
343 }
Philip Zeyliger61a0f672025-06-21 15:33:18 -0700344
345 // Set up scroll listener if not already done
346 if (this.scrollContainer.value && !this.scrollContainer.value.onscroll) {
347 this.scrollContainer.value.addEventListener(
348 "scroll",
349 this.handleScroll.bind(this),
350 );
351 }
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700352 }
353
354 private scrollToBottom() {
355 // Use requestAnimationFrame to ensure DOM is updated
356 requestAnimationFrame(() => {
357 if (this.scrollContainer.value) {
Autoformatterf825e692025-06-07 04:19:43 +0000358 this.scrollContainer.value.scrollTop =
359 this.scrollContainer.value.scrollHeight;
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700360 }
361 });
362 }
363
Philip Zeyliger61a0f672025-06-21 15:33:18 -0700364 private handleScroll() {
365 if (!this.scrollContainer.value) return;
366
367 const container = this.scrollContainer.value;
368 const isAtBottom =
369 container.scrollTop + container.clientHeight >=
370 container.scrollHeight - 50; // 50px tolerance
371
372 this.showJumpToBottom = !isAtBottom;
373 }
374
375 private jumpToBottom() {
376 this.scrollToBottom();
377 }
378
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700379 private formatTime(timestamp: string): string {
380 const date = new Date(timestamp);
Autoformatterf825e692025-06-07 04:19:43 +0000381 return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700382 }
383
384 private getMessageRole(message: AgentMessage): string {
Autoformatterf825e692025-06-07 04:19:43 +0000385 if (message.type === "user") {
386 return "user";
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700387 }
philip.zeyliger41682632025-06-09 22:23:25 +0000388 if (message.type === "error") {
389 return "error";
390 }
Autoformatterf825e692025-06-07 04:19:43 +0000391 return "assistant";
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700392 }
393
394 private getMessageText(message: AgentMessage): string {
Autoformatterf825e692025-06-07 04:19:43 +0000395 return message.content || "";
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700396 }
397
398 private shouldShowMessage(message: AgentMessage): boolean {
Philip Zeyliger8bc681b2025-06-10 02:03:02 +0000399 // Filter out hidden messages (subconversations) like the regular UI does
400 if (message.hide_output) {
401 return false;
402 }
403
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700404 // Show user, agent, and error messages with content
Autoformatterf825e692025-06-07 04:19:43 +0000405 return (
406 (message.type === "user" ||
407 message.type === "agent" ||
408 message.type === "error") &&
409 message.content &&
410 message.content.trim().length > 0
411 );
Philip Zeyliger5f778942025-06-07 00:05:20 +0000412 }
413
414 private renderMarkdown(markdownContent: string): string {
415 try {
416 // Create a custom renderer for mobile-optimized rendering
417 const renderer = new Renderer();
Autoformatterf825e692025-06-07 04:19:43 +0000418
Philip Zeyliger5f778942025-06-07 00:05:20 +0000419 // Override code renderer to simplify for mobile
Autoformatterf825e692025-06-07 04:19:43 +0000420 renderer.code = function ({
421 text,
422 lang,
423 }: {
424 text: string;
425 lang?: string;
426 }): string {
Philip Zeyliger5f778942025-06-07 00:05:20 +0000427 const langClass = lang ? ` class="language-${lang}"` : "";
428 return `<pre><code${langClass}>${text}</code></pre>`;
429 };
430
431 // Set markdown options for mobile
432 const markedOptions: MarkedOptions = {
433 gfm: true, // GitHub Flavored Markdown
434 breaks: true, // Convert newlines to <br>
435 async: false,
436 renderer: renderer,
437 };
438
439 // Parse markdown and sanitize the output HTML
440 const htmlOutput = marked.parse(markdownContent, markedOptions) as string;
441 return DOMPurify.sanitize(htmlOutput, {
442 ALLOWED_TAGS: [
Autoformatterf825e692025-06-07 04:19:43 +0000443 "p",
444 "br",
445 "strong",
446 "em",
447 "b",
448 "i",
449 "u",
450 "s",
451 "code",
452 "pre",
453 "h1",
454 "h2",
455 "h3",
456 "h4",
457 "h5",
458 "h6",
459 "ul",
460 "ol",
461 "li",
462 "blockquote",
463 "a",
Philip Zeyliger5f778942025-06-07 00:05:20 +0000464 ],
465 ALLOWED_ATTR: ["href", "title", "target", "rel", "class"],
466 KEEP_CONTENT: true,
467 });
468 } catch (error) {
469 console.error("Error rendering markdown:", error);
470 // Fallback to sanitized plain text if markdown parsing fails
471 return DOMPurify.sanitize(markdownContent);
472 }
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700473 }
474
philip.zeyliger851d2bf2025-06-16 03:10:10 +0000475 private renderToolCalls(message: AgentMessage) {
476 if (!message.tool_calls || message.tool_calls.length === 0) {
477 return "";
478 }
479
480 return html`
481 <div class="tool-calls">
482 ${message.tool_calls.map((toolCall) => {
philip.zeyliger851d2bf2025-06-16 03:10:10 +0000483 const summary = this.getToolSummary(toolCall);
philip.zeyliger851d2bf2025-06-16 03:10:10 +0000484
485 return html`
486 <div class="tool-call-item ${toolCall.name}">
487 <span class="tool-name">${toolCall.name}</span>
488 <span class="tool-summary">${summary}</span>
489 </div>
490 `;
491 })}
492 </div>
493 `;
494 }
495
philip.zeyliger26bc6592025-06-30 20:15:30 -0700496 private getToolStatusIcon(_toolCall: any): string {
philip.zeyliger851d2bf2025-06-16 03:10:10 +0000497 // Don't show status icons for mobile
498 return "";
499 }
500
501 private getToolSummary(toolCall: any): string {
502 try {
503 const input = JSON.parse(toolCall.input || "{}");
504
philip.zeyliger26bc6592025-06-30 20:15:30 -0700505 /* eslint-disable no-case-declarations */
philip.zeyliger851d2bf2025-06-16 03:10:10 +0000506 switch (toolCall.name) {
507 case "bash":
508 const command = input.command || "";
509 const isBackground = input.background === true;
510 const bgPrefix = isBackground ? "[bg] " : "";
511 return (
512 bgPrefix +
513 (command.length > 40 ? command.substring(0, 40) + "..." : command)
514 );
515
516 case "patch":
517 const path = input.path || "unknown";
518 const patchCount = (input.patches || []).length;
519 return `${path}: ${patchCount} edit${patchCount > 1 ? "s" : ""}`;
520
521 case "think":
522 const thoughts = input.thoughts || "";
523 const firstLine = thoughts.split("\n")[0] || "";
524 return firstLine.length > 50
525 ? firstLine.substring(0, 50) + "..."
526 : firstLine;
527
528 case "keyword_search":
529 const query = input.query || "";
530 return query.length > 50 ? query.substring(0, 50) + "..." : query;
531
532 case "browser_navigate":
533 return input.url || "";
534
535 case "browser_take_screenshot":
536 return "Taking screenshot";
537
538 case "browser_click":
539 return `Click: ${input.selector || ""}`;
540
541 case "browser_type":
542 const text = input.text || "";
543 return `Type: ${text.length > 30 ? text.substring(0, 30) + "..." : text}`;
544
545 case "todo_write":
546 const tasks = input.tasks || [];
547 return `${tasks.length} task${tasks.length > 1 ? "s" : ""}`;
548
549 case "todo_read":
550 return "Read todo list";
551
552 case "set-slug":
553 return `Slug: "${input.slug || ""}"`;
554
555 case "multiplechoice":
556 const question = input.question || "Multiple choice question";
557 const options = input.responseOptions || [];
558 if (options.length > 0) {
559 const optionsList = options.map((opt) => opt.caption).join(", ");
560 return `${question} [${optionsList}]`;
561 }
562 return question;
563
564 case "done":
565 return "Task completion checklist";
566
567 default:
568 // For unknown tools, show first part of input
569 const inputStr = JSON.stringify(input);
570 return inputStr.length > 50
571 ? inputStr.substring(0, 50) + "..."
572 : inputStr;
573 }
philip.zeyliger26bc6592025-06-30 20:15:30 -0700574 /* eslint-enable no-case-declarations */
575 } catch {
philip.zeyliger851d2bf2025-06-16 03:10:10 +0000576 return "Tool call";
577 }
578 }
579
philip.zeyliger26bc6592025-06-30 20:15:30 -0700580 private getToolDuration(_toolCall: any): string {
philip.zeyliger851d2bf2025-06-16 03:10:10 +0000581 // Don't show duration for mobile
582 return "";
583 }
584
Philip Zeyliger61a0f672025-06-21 15:33:18 -0700585 disconnectedCallback() {
586 super.disconnectedCallback();
587 if (this.scrollContainer.value) {
588 this.scrollContainer.value.removeEventListener(
589 "scroll",
590 this.handleScroll.bind(this),
591 );
592 }
593 }
594
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700595 render() {
Autoformatterf825e692025-06-07 04:19:43 +0000596 const displayMessages = this.messages.filter((msg) =>
597 this.shouldShowMessage(msg),
598 );
599
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700600 return html`
601 <div class="chat-container" ${ref(this.scrollContainer)}>
Autoformatterf825e692025-06-07 04:19:43 +0000602 ${displayMessages.length === 0
603 ? html`
604 <div class="empty-state">Start a conversation with Sketch...</div>
605 `
606 : displayMessages.map((message) => {
607 const role = this.getMessageRole(message);
608 const text = this.getMessageText(message);
philip.zeyliger26bc6592025-06-30 20:15:30 -0700609 // const timestamp = message.timestamp; // Unused for mobile layout
Autoformatterf825e692025-06-07 04:19:43 +0000610
611 return html`
612 <div class="message ${role}">
613 <div class="message-bubble">
614 ${role === "assistant"
615 ? html`<div class="markdown-content">
616 ${unsafeHTML(this.renderMarkdown(text))}
617 </div>`
618 : text}
philip.zeyliger851d2bf2025-06-16 03:10:10 +0000619 ${this.renderToolCalls(message)}
Autoformatterf825e692025-06-07 04:19:43 +0000620 </div>
621 </div>
622 `;
623 })}
624 ${this.isThinking
625 ? html`
626 <div class="thinking-message">
627 <div class="thinking-bubble">
628 <span class="thinking-text">Sketch is thinking</span>
629 <div class="thinking-dots">
630 <div class="thinking-dot"></div>
631 <div class="thinking-dot"></div>
632 <div class="thinking-dot"></div>
633 </div>
634 </div>
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700635 </div>
Autoformatterf825e692025-06-07 04:19:43 +0000636 `
637 : ""}
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700638 </div>
Philip Zeyliger61a0f672025-06-21 15:33:18 -0700639
640 ${this.showJumpToBottom
641 ? html`
642 <button
643 class="jump-to-bottom"
644 @click=${this.jumpToBottom}
645 aria-label="Jump to bottom"
646 >
647 ↓ Jump to bottom
648 </button>
649 `
650 : ""}
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700651 `;
652 }
653}