blob: 26ef963274aded6952880de988519e1aea646309 [file] [log] [blame]
Philip Zeyligere08c7ff2025-06-06 13:22:12 -07001import { css, html, LitElement } from "lit";
2import { customElement, property, state } from "lit/decorators.js";
Philip Zeyliger5f778942025-06-07 00:05:20 +00003import { unsafeHTML } from "lit/directives/unsafe-html.js";
Philip Zeyligere08c7ff2025-06-06 13:22:12 -07004import { AgentMessage } from "../types";
5import { createRef, ref } from "lit/directives/ref.js";
Philip Zeyliger5f778942025-06-07 00:05:20 +00006import { marked, MarkedOptions, Renderer } from "marked";
7import DOMPurify from "dompurify";
Philip Zeyligere08c7ff2025-06-06 13:22:12 -07008
9@customElement("mobile-chat")
10export class MobileChat extends LitElement {
11 @property({ type: Array })
12 messages: AgentMessage[] = [];
13
14 @property({ type: Boolean })
15 isThinking = false;
16
17 private scrollContainer = createRef<HTMLDivElement>();
18
Philip Zeyliger61a0f672025-06-21 15:33:18 -070019 @state()
20 private showJumpToBottom = false;
21
Philip Zeyligere08c7ff2025-06-06 13:22:12 -070022 static styles = css`
23 :host {
24 display: block;
25 height: 100%;
26 overflow: hidden;
27 }
28
29 .chat-container {
30 height: 100%;
31 overflow-y: auto;
32 padding: 16px;
33 display: flex;
34 flex-direction: column;
35 gap: 16px;
36 scroll-behavior: smooth;
37 -webkit-overflow-scrolling: touch;
38 }
39
40 .message {
41 display: flex;
42 flex-direction: column;
43 max-width: 85%;
44 word-wrap: break-word;
45 }
46
47 .message.user {
48 align-self: flex-end;
49 align-items: flex-end;
50 }
51
52 .message.assistant {
53 align-self: flex-start;
54 align-items: flex-start;
55 }
56
57 .message-bubble {
58 padding: 8px 12px;
59 border-radius: 18px;
60 font-size: 16px;
61 line-height: 1.4;
62 }
63
64 .message.user .message-bubble {
65 background-color: #007bff;
66 color: white;
67 border-bottom-right-radius: 6px;
68 }
69
70 .message.assistant .message-bubble {
71 background-color: #f1f3f4;
72 color: #333;
73 border-bottom-left-radius: 6px;
74 }
75
philip.zeyliger41682632025-06-09 22:23:25 +000076 .message.error .message-bubble {
77 background-color: #ffebee;
78 color: #d32f2f;
79 border-radius: 18px;
80 }
81
Philip Zeyligere08c7ff2025-06-06 13:22:12 -070082 .thinking-message {
83 align-self: flex-start;
84 align-items: flex-start;
85 max-width: 85%;
86 }
87
88 .thinking-bubble {
89 background-color: #f1f3f4;
90 padding: 16px;
91 border-radius: 18px;
92 border-bottom-left-radius: 6px;
93 display: flex;
94 align-items: center;
95 gap: 8px;
96 }
97
98 .thinking-text {
99 color: #6c757d;
100 font-style: italic;
101 }
102
103 .thinking-dots {
104 display: flex;
105 gap: 3px;
106 }
107
108 .thinking-dot {
109 width: 6px;
110 height: 6px;
111 border-radius: 50%;
112 background-color: #6c757d;
113 animation: thinking 1.4s ease-in-out infinite both;
114 }
115
Autoformatterf825e692025-06-07 04:19:43 +0000116 .thinking-dot:nth-child(1) {
117 animation-delay: -0.32s;
118 }
119 .thinking-dot:nth-child(2) {
120 animation-delay: -0.16s;
121 }
122 .thinking-dot:nth-child(3) {
123 animation-delay: 0;
124 }
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700125
126 @keyframes thinking {
Autoformatterf825e692025-06-07 04:19:43 +0000127 0%,
128 80%,
129 100% {
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700130 transform: scale(0.8);
131 opacity: 0.5;
132 }
133 40% {
134 transform: scale(1);
135 opacity: 1;
136 }
137 }
138
139 .empty-state {
140 flex: 1;
141 display: flex;
142 align-items: center;
143 justify-content: center;
144 color: #6c757d;
145 font-style: italic;
146 text-align: center;
147 padding: 32px;
148 }
Philip Zeyliger5f778942025-06-07 00:05:20 +0000149
150 /* Markdown content styling for mobile */
151 .markdown-content {
152 line-height: 1.5;
153 word-wrap: break-word;
154 overflow-wrap: break-word;
155 }
156
157 .markdown-content p {
158 margin: 0.3em 0;
159 }
160
161 .markdown-content p:first-child {
162 margin-top: 0;
163 }
164
165 .markdown-content p:last-child {
166 margin-bottom: 0;
167 }
168
169 .markdown-content h1,
170 .markdown-content h2,
171 .markdown-content h3,
172 .markdown-content h4,
173 .markdown-content h5,
174 .markdown-content h6 {
175 margin: 0.5em 0 0.3em 0;
176 font-weight: bold;
177 }
178
Autoformatterf825e692025-06-07 04:19:43 +0000179 .markdown-content h1 {
180 font-size: 1.2em;
181 }
182 .markdown-content h2 {
183 font-size: 1.15em;
184 }
185 .markdown-content h3 {
186 font-size: 1.1em;
187 }
Philip Zeyliger5f778942025-06-07 00:05:20 +0000188 .markdown-content h4,
189 .markdown-content h5,
Autoformatterf825e692025-06-07 04:19:43 +0000190 .markdown-content h6 {
191 font-size: 1.05em;
192 }
Philip Zeyliger5f778942025-06-07 00:05:20 +0000193
194 .markdown-content code {
195 background-color: rgba(0, 0, 0, 0.08);
196 padding: 2px 4px;
197 border-radius: 3px;
Autoformatterf825e692025-06-07 04:19:43 +0000198 font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace;
Philip Zeyliger5f778942025-06-07 00:05:20 +0000199 font-size: 0.9em;
200 }
201
202 .markdown-content pre {
203 background-color: rgba(0, 0, 0, 0.08);
204 padding: 8px;
205 border-radius: 6px;
206 margin: 0.5em 0;
207 overflow-x: auto;
208 font-size: 0.9em;
209 }
210
211 .markdown-content pre code {
212 background: none;
213 padding: 0;
214 }
215
216 .markdown-content ul,
217 .markdown-content ol {
218 margin: 0.5em 0;
219 padding-left: 1.2em;
220 }
221
222 .markdown-content li {
223 margin: 0.2em 0;
224 }
225
226 .markdown-content blockquote {
227 border-left: 3px solid rgba(0, 0, 0, 0.2);
228 margin: 0.5em 0;
229 padding-left: 0.8em;
230 font-style: italic;
231 }
232
233 .markdown-content a {
234 color: inherit;
235 text-decoration: underline;
236 }
237
238 .markdown-content strong,
239 .markdown-content b {
240 font-weight: bold;
241 }
242
243 .markdown-content em,
244 .markdown-content i {
245 font-style: italic;
246 }
philip.zeyliger851d2bf2025-06-16 03:10:10 +0000247
248 /* Tool calls styling for mobile */
249 .tool-calls {
250 margin-top: 12px;
251 display: flex;
252 flex-direction: column;
253 gap: 6px;
254 }
255
256 .tool-call-item {
257 background-color: rgba(0, 0, 0, 0.04);
258 border-radius: 8px;
259 padding: 6px 8px;
260 font-size: 12px;
261 font-family: monospace;
262 line-height: 1.3;
263 display: flex;
264 align-items: center;
265 gap: 6px;
266 }
267
268 .tool-status-icon {
269 flex-shrink: 0;
270 font-size: 14px;
271 }
272
273 .tool-name {
274 font-weight: bold;
275 color: #333;
276 flex-shrink: 0;
277 margin-right: 2px;
278 }
279
280 .tool-summary {
281 color: #555;
282 flex-grow: 1;
283 overflow: hidden;
284 text-overflow: ellipsis;
285 white-space: nowrap;
286 }
287
288 .tool-duration {
289 font-size: 10px;
290 color: #888;
291 flex-shrink: 0;
292 margin-left: 4px;
293 }
Philip Zeyliger61a0f672025-06-21 15:33:18 -0700294
295 .jump-to-bottom {
296 position: fixed;
297 bottom: 70px;
298 left: 50%;
299 transform: translateX(-50%);
300 background-color: rgba(0, 0, 0, 0.6);
301 color: white;
302 border: none;
303 border-radius: 12px;
304 padding: 4px 8px;
305 font-size: 11px;
306 font-weight: 400;
307 cursor: pointer;
308 box-shadow: 0 1px 4px rgba(0, 0, 0, 0.2);
309 z-index: 1100;
310 transition: all 0.15s ease;
311 display: flex;
312 align-items: center;
313 gap: 4px;
314 opacity: 0.8;
315 }
316
317 .jump-to-bottom:hover {
318 background-color: rgba(0, 0, 0, 0.8);
319 transform: translateX(-50%) translateY(-1px);
320 opacity: 1;
321 box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
322 }
323
324 .jump-to-bottom:active {
325 transform: translateX(-50%) translateY(0);
326 }
327
328 .jump-to-bottom.hidden {
329 opacity: 0;
330 pointer-events: none;
331 transform: translateX(-50%) translateY(10px);
332 }
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700333 `;
334
335 updated(changedProperties: Map<string, any>) {
336 super.updated(changedProperties);
Autoformatterf825e692025-06-07 04:19:43 +0000337 if (
338 changedProperties.has("messages") ||
339 changedProperties.has("isThinking")
340 ) {
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700341 this.scrollToBottom();
342 }
Philip Zeyliger61a0f672025-06-21 15:33:18 -0700343
344 // Set up scroll listener if not already done
345 if (this.scrollContainer.value && !this.scrollContainer.value.onscroll) {
346 this.scrollContainer.value.addEventListener(
347 "scroll",
348 this.handleScroll.bind(this),
349 );
350 }
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700351 }
352
353 private scrollToBottom() {
354 // Use requestAnimationFrame to ensure DOM is updated
355 requestAnimationFrame(() => {
356 if (this.scrollContainer.value) {
Autoformatterf825e692025-06-07 04:19:43 +0000357 this.scrollContainer.value.scrollTop =
358 this.scrollContainer.value.scrollHeight;
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700359 }
360 });
361 }
362
Philip Zeyliger61a0f672025-06-21 15:33:18 -0700363 private handleScroll() {
364 if (!this.scrollContainer.value) return;
365
366 const container = this.scrollContainer.value;
367 const isAtBottom =
368 container.scrollTop + container.clientHeight >=
369 container.scrollHeight - 50; // 50px tolerance
370
371 this.showJumpToBottom = !isAtBottom;
372 }
373
374 private jumpToBottom() {
375 this.scrollToBottom();
376 }
377
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700378 private formatTime(timestamp: string): string {
379 const date = new Date(timestamp);
Autoformatterf825e692025-06-07 04:19:43 +0000380 return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700381 }
382
383 private getMessageRole(message: AgentMessage): string {
Autoformatterf825e692025-06-07 04:19:43 +0000384 if (message.type === "user") {
385 return "user";
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700386 }
philip.zeyliger41682632025-06-09 22:23:25 +0000387 if (message.type === "error") {
388 return "error";
389 }
Autoformatterf825e692025-06-07 04:19:43 +0000390 return "assistant";
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700391 }
392
393 private getMessageText(message: AgentMessage): string {
Autoformatterf825e692025-06-07 04:19:43 +0000394 return message.content || "";
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700395 }
396
397 private shouldShowMessage(message: AgentMessage): boolean {
Philip Zeyliger8bc681b2025-06-10 02:03:02 +0000398 // Filter out hidden messages (subconversations) like the regular UI does
399 if (message.hide_output) {
400 return false;
401 }
402
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700403 // Show user, agent, and error messages with content
Autoformatterf825e692025-06-07 04:19:43 +0000404 return (
405 (message.type === "user" ||
406 message.type === "agent" ||
407 message.type === "error") &&
408 message.content &&
409 message.content.trim().length > 0
410 );
Philip Zeyliger5f778942025-06-07 00:05:20 +0000411 }
412
413 private renderMarkdown(markdownContent: string): string {
414 try {
415 // Create a custom renderer for mobile-optimized rendering
416 const renderer = new Renderer();
Autoformatterf825e692025-06-07 04:19:43 +0000417
Philip Zeyliger5f778942025-06-07 00:05:20 +0000418 // Override code renderer to simplify for mobile
Autoformatterf825e692025-06-07 04:19:43 +0000419 renderer.code = function ({
420 text,
421 lang,
422 }: {
423 text: string;
424 lang?: string;
425 }): string {
Philip Zeyliger5f778942025-06-07 00:05:20 +0000426 const langClass = lang ? ` class="language-${lang}"` : "";
427 return `<pre><code${langClass}>${text}</code></pre>`;
428 };
429
430 // Set markdown options for mobile
431 const markedOptions: MarkedOptions = {
432 gfm: true, // GitHub Flavored Markdown
433 breaks: true, // Convert newlines to <br>
434 async: false,
435 renderer: renderer,
436 };
437
438 // Parse markdown and sanitize the output HTML
439 const htmlOutput = marked.parse(markdownContent, markedOptions) as string;
440 return DOMPurify.sanitize(htmlOutput, {
441 ALLOWED_TAGS: [
Autoformatterf825e692025-06-07 04:19:43 +0000442 "p",
443 "br",
444 "strong",
445 "em",
446 "b",
447 "i",
448 "u",
449 "s",
450 "code",
451 "pre",
452 "h1",
453 "h2",
454 "h3",
455 "h4",
456 "h5",
457 "h6",
458 "ul",
459 "ol",
460 "li",
461 "blockquote",
462 "a",
Philip Zeyliger5f778942025-06-07 00:05:20 +0000463 ],
464 ALLOWED_ATTR: ["href", "title", "target", "rel", "class"],
465 KEEP_CONTENT: true,
466 });
467 } catch (error) {
468 console.error("Error rendering markdown:", error);
469 // Fallback to sanitized plain text if markdown parsing fails
470 return DOMPurify.sanitize(markdownContent);
471 }
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700472 }
473
philip.zeyliger851d2bf2025-06-16 03:10:10 +0000474 private renderToolCalls(message: AgentMessage) {
475 if (!message.tool_calls || message.tool_calls.length === 0) {
476 return "";
477 }
478
479 return html`
480 <div class="tool-calls">
481 ${message.tool_calls.map((toolCall) => {
482 const statusIcon = this.getToolStatusIcon(toolCall);
483 const summary = this.getToolSummary(toolCall);
484 const duration = this.getToolDuration(toolCall);
485
486 return html`
487 <div class="tool-call-item ${toolCall.name}">
488 <span class="tool-name">${toolCall.name}</span>
489 <span class="tool-summary">${summary}</span>
490 </div>
491 `;
492 })}
493 </div>
494 `;
495 }
496
497 private getToolStatusIcon(toolCall: any): string {
498 // Don't show status icons for mobile
499 return "";
500 }
501
502 private getToolSummary(toolCall: any): string {
503 try {
504 const input = JSON.parse(toolCall.input || "{}");
505
506 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 }
574 } catch (e) {
575 return "Tool call";
576 }
577 }
578
579 private getToolDuration(toolCall: any): string {
580 // Don't show duration for mobile
581 return "";
582 }
583
Philip Zeyliger61a0f672025-06-21 15:33:18 -0700584 disconnectedCallback() {
585 super.disconnectedCallback();
586 if (this.scrollContainer.value) {
587 this.scrollContainer.value.removeEventListener(
588 "scroll",
589 this.handleScroll.bind(this),
590 );
591 }
592 }
593
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700594 render() {
Autoformatterf825e692025-06-07 04:19:43 +0000595 const displayMessages = this.messages.filter((msg) =>
596 this.shouldShowMessage(msg),
597 );
598
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700599 return html`
600 <div class="chat-container" ${ref(this.scrollContainer)}>
Autoformatterf825e692025-06-07 04:19:43 +0000601 ${displayMessages.length === 0
602 ? html`
603 <div class="empty-state">Start a conversation with Sketch...</div>
604 `
605 : displayMessages.map((message) => {
606 const role = this.getMessageRole(message);
607 const text = this.getMessageText(message);
608 const timestamp = message.timestamp;
609
610 return html`
611 <div class="message ${role}">
612 <div class="message-bubble">
613 ${role === "assistant"
614 ? html`<div class="markdown-content">
615 ${unsafeHTML(this.renderMarkdown(text))}
616 </div>`
617 : text}
philip.zeyliger851d2bf2025-06-16 03:10:10 +0000618 ${this.renderToolCalls(message)}
Autoformatterf825e692025-06-07 04:19:43 +0000619 </div>
620 </div>
621 `;
622 })}
623 ${this.isThinking
624 ? html`
625 <div class="thinking-message">
626 <div class="thinking-bubble">
627 <span class="thinking-text">Sketch is thinking</span>
628 <div class="thinking-dots">
629 <div class="thinking-dot"></div>
630 <div class="thinking-dot"></div>
631 <div class="thinking-dot"></div>
632 </div>
633 </div>
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700634 </div>
Autoformatterf825e692025-06-07 04:19:43 +0000635 `
636 : ""}
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700637 </div>
Philip Zeyliger61a0f672025-06-21 15:33:18 -0700638
639 ${this.showJumpToBottom
640 ? html`
641 <button
642 class="jump-to-bottom"
643 @click=${this.jumpToBottom}
644 aria-label="Jump to bottom"
645 >
646 ↓ Jump to bottom
647 </button>
648 `
649 : ""}
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700650 `;
651 }
652}