blob: 74381cc7b9ce4309e6be6bf0b19d089fbd50b721 [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
19 static styles = css`
20 :host {
21 display: block;
22 height: 100%;
23 overflow: hidden;
24 }
25
26 .chat-container {
27 height: 100%;
28 overflow-y: auto;
29 padding: 16px;
30 display: flex;
31 flex-direction: column;
32 gap: 16px;
33 scroll-behavior: smooth;
34 -webkit-overflow-scrolling: touch;
35 }
36
37 .message {
38 display: flex;
39 flex-direction: column;
40 max-width: 85%;
41 word-wrap: break-word;
42 }
43
44 .message.user {
45 align-self: flex-end;
46 align-items: flex-end;
47 }
48
49 .message.assistant {
50 align-self: flex-start;
51 align-items: flex-start;
52 }
53
54 .message-bubble {
55 padding: 8px 12px;
56 border-radius: 18px;
57 font-size: 16px;
58 line-height: 1.4;
59 }
60
61 .message.user .message-bubble {
62 background-color: #007bff;
63 color: white;
64 border-bottom-right-radius: 6px;
65 }
66
67 .message.assistant .message-bubble {
68 background-color: #f1f3f4;
69 color: #333;
70 border-bottom-left-radius: 6px;
71 }
72
philip.zeyliger41682632025-06-09 22:23:25 +000073 .message.error .message-bubble {
74 background-color: #ffebee;
75 color: #d32f2f;
76 border-radius: 18px;
77 }
78
Philip Zeyligere08c7ff2025-06-06 13:22:12 -070079 .thinking-message {
80 align-self: flex-start;
81 align-items: flex-start;
82 max-width: 85%;
83 }
84
85 .thinking-bubble {
86 background-color: #f1f3f4;
87 padding: 16px;
88 border-radius: 18px;
89 border-bottom-left-radius: 6px;
90 display: flex;
91 align-items: center;
92 gap: 8px;
93 }
94
95 .thinking-text {
96 color: #6c757d;
97 font-style: italic;
98 }
99
100 .thinking-dots {
101 display: flex;
102 gap: 3px;
103 }
104
105 .thinking-dot {
106 width: 6px;
107 height: 6px;
108 border-radius: 50%;
109 background-color: #6c757d;
110 animation: thinking 1.4s ease-in-out infinite both;
111 }
112
Autoformatterf825e692025-06-07 04:19:43 +0000113 .thinking-dot:nth-child(1) {
114 animation-delay: -0.32s;
115 }
116 .thinking-dot:nth-child(2) {
117 animation-delay: -0.16s;
118 }
119 .thinking-dot:nth-child(3) {
120 animation-delay: 0;
121 }
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700122
123 @keyframes thinking {
Autoformatterf825e692025-06-07 04:19:43 +0000124 0%,
125 80%,
126 100% {
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700127 transform: scale(0.8);
128 opacity: 0.5;
129 }
130 40% {
131 transform: scale(1);
132 opacity: 1;
133 }
134 }
135
136 .empty-state {
137 flex: 1;
138 display: flex;
139 align-items: center;
140 justify-content: center;
141 color: #6c757d;
142 font-style: italic;
143 text-align: center;
144 padding: 32px;
145 }
Philip Zeyliger5f778942025-06-07 00:05:20 +0000146
147 /* Markdown content styling for mobile */
148 .markdown-content {
149 line-height: 1.5;
150 word-wrap: break-word;
151 overflow-wrap: break-word;
152 }
153
154 .markdown-content p {
155 margin: 0.3em 0;
156 }
157
158 .markdown-content p:first-child {
159 margin-top: 0;
160 }
161
162 .markdown-content p:last-child {
163 margin-bottom: 0;
164 }
165
166 .markdown-content h1,
167 .markdown-content h2,
168 .markdown-content h3,
169 .markdown-content h4,
170 .markdown-content h5,
171 .markdown-content h6 {
172 margin: 0.5em 0 0.3em 0;
173 font-weight: bold;
174 }
175
Autoformatterf825e692025-06-07 04:19:43 +0000176 .markdown-content h1 {
177 font-size: 1.2em;
178 }
179 .markdown-content h2 {
180 font-size: 1.15em;
181 }
182 .markdown-content h3 {
183 font-size: 1.1em;
184 }
Philip Zeyliger5f778942025-06-07 00:05:20 +0000185 .markdown-content h4,
186 .markdown-content h5,
Autoformatterf825e692025-06-07 04:19:43 +0000187 .markdown-content h6 {
188 font-size: 1.05em;
189 }
Philip Zeyliger5f778942025-06-07 00:05:20 +0000190
191 .markdown-content code {
192 background-color: rgba(0, 0, 0, 0.08);
193 padding: 2px 4px;
194 border-radius: 3px;
Autoformatterf825e692025-06-07 04:19:43 +0000195 font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace;
Philip Zeyliger5f778942025-06-07 00:05:20 +0000196 font-size: 0.9em;
197 }
198
199 .markdown-content pre {
200 background-color: rgba(0, 0, 0, 0.08);
201 padding: 8px;
202 border-radius: 6px;
203 margin: 0.5em 0;
204 overflow-x: auto;
205 font-size: 0.9em;
206 }
207
208 .markdown-content pre code {
209 background: none;
210 padding: 0;
211 }
212
213 .markdown-content ul,
214 .markdown-content ol {
215 margin: 0.5em 0;
216 padding-left: 1.2em;
217 }
218
219 .markdown-content li {
220 margin: 0.2em 0;
221 }
222
223 .markdown-content blockquote {
224 border-left: 3px solid rgba(0, 0, 0, 0.2);
225 margin: 0.5em 0;
226 padding-left: 0.8em;
227 font-style: italic;
228 }
229
230 .markdown-content a {
231 color: inherit;
232 text-decoration: underline;
233 }
234
235 .markdown-content strong,
236 .markdown-content b {
237 font-weight: bold;
238 }
239
240 .markdown-content em,
241 .markdown-content i {
242 font-style: italic;
243 }
philip.zeyliger851d2bf2025-06-16 03:10:10 +0000244
245 /* Tool calls styling for mobile */
246 .tool-calls {
247 margin-top: 12px;
248 display: flex;
249 flex-direction: column;
250 gap: 6px;
251 }
252
253 .tool-call-item {
254 background-color: rgba(0, 0, 0, 0.04);
255 border-radius: 8px;
256 padding: 6px 8px;
257 font-size: 12px;
258 font-family: monospace;
259 line-height: 1.3;
260 display: flex;
261 align-items: center;
262 gap: 6px;
263 }
264
265 .tool-status-icon {
266 flex-shrink: 0;
267 font-size: 14px;
268 }
269
270 .tool-name {
271 font-weight: bold;
272 color: #333;
273 flex-shrink: 0;
274 margin-right: 2px;
275 }
276
277 .tool-summary {
278 color: #555;
279 flex-grow: 1;
280 overflow: hidden;
281 text-overflow: ellipsis;
282 white-space: nowrap;
283 }
284
285 .tool-duration {
286 font-size: 10px;
287 color: #888;
288 flex-shrink: 0;
289 margin-left: 4px;
290 }
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700291 `;
292
293 updated(changedProperties: Map<string, any>) {
294 super.updated(changedProperties);
Autoformatterf825e692025-06-07 04:19:43 +0000295 if (
296 changedProperties.has("messages") ||
297 changedProperties.has("isThinking")
298 ) {
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700299 this.scrollToBottom();
300 }
301 }
302
303 private scrollToBottom() {
304 // Use requestAnimationFrame to ensure DOM is updated
305 requestAnimationFrame(() => {
306 if (this.scrollContainer.value) {
Autoformatterf825e692025-06-07 04:19:43 +0000307 this.scrollContainer.value.scrollTop =
308 this.scrollContainer.value.scrollHeight;
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700309 }
310 });
311 }
312
313 private formatTime(timestamp: string): string {
314 const date = new Date(timestamp);
Autoformatterf825e692025-06-07 04:19:43 +0000315 return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700316 }
317
318 private getMessageRole(message: AgentMessage): string {
Autoformatterf825e692025-06-07 04:19:43 +0000319 if (message.type === "user") {
320 return "user";
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700321 }
philip.zeyliger41682632025-06-09 22:23:25 +0000322 if (message.type === "error") {
323 return "error";
324 }
Autoformatterf825e692025-06-07 04:19:43 +0000325 return "assistant";
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700326 }
327
328 private getMessageText(message: AgentMessage): string {
Autoformatterf825e692025-06-07 04:19:43 +0000329 return message.content || "";
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700330 }
331
332 private shouldShowMessage(message: AgentMessage): boolean {
Philip Zeyliger8bc681b2025-06-10 02:03:02 +0000333 // Filter out hidden messages (subconversations) like the regular UI does
334 if (message.hide_output) {
335 return false;
336 }
337
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700338 // Show user, agent, and error messages with content
Autoformatterf825e692025-06-07 04:19:43 +0000339 return (
340 (message.type === "user" ||
341 message.type === "agent" ||
342 message.type === "error") &&
343 message.content &&
344 message.content.trim().length > 0
345 );
Philip Zeyliger5f778942025-06-07 00:05:20 +0000346 }
347
348 private renderMarkdown(markdownContent: string): string {
349 try {
350 // Create a custom renderer for mobile-optimized rendering
351 const renderer = new Renderer();
Autoformatterf825e692025-06-07 04:19:43 +0000352
Philip Zeyliger5f778942025-06-07 00:05:20 +0000353 // Override code renderer to simplify for mobile
Autoformatterf825e692025-06-07 04:19:43 +0000354 renderer.code = function ({
355 text,
356 lang,
357 }: {
358 text: string;
359 lang?: string;
360 }): string {
Philip Zeyliger5f778942025-06-07 00:05:20 +0000361 const langClass = lang ? ` class="language-${lang}"` : "";
362 return `<pre><code${langClass}>${text}</code></pre>`;
363 };
364
365 // Set markdown options for mobile
366 const markedOptions: MarkedOptions = {
367 gfm: true, // GitHub Flavored Markdown
368 breaks: true, // Convert newlines to <br>
369 async: false,
370 renderer: renderer,
371 };
372
373 // Parse markdown and sanitize the output HTML
374 const htmlOutput = marked.parse(markdownContent, markedOptions) as string;
375 return DOMPurify.sanitize(htmlOutput, {
376 ALLOWED_TAGS: [
Autoformatterf825e692025-06-07 04:19:43 +0000377 "p",
378 "br",
379 "strong",
380 "em",
381 "b",
382 "i",
383 "u",
384 "s",
385 "code",
386 "pre",
387 "h1",
388 "h2",
389 "h3",
390 "h4",
391 "h5",
392 "h6",
393 "ul",
394 "ol",
395 "li",
396 "blockquote",
397 "a",
Philip Zeyliger5f778942025-06-07 00:05:20 +0000398 ],
399 ALLOWED_ATTR: ["href", "title", "target", "rel", "class"],
400 KEEP_CONTENT: true,
401 });
402 } catch (error) {
403 console.error("Error rendering markdown:", error);
404 // Fallback to sanitized plain text if markdown parsing fails
405 return DOMPurify.sanitize(markdownContent);
406 }
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700407 }
408
philip.zeyliger851d2bf2025-06-16 03:10:10 +0000409 private renderToolCalls(message: AgentMessage) {
410 if (!message.tool_calls || message.tool_calls.length === 0) {
411 return "";
412 }
413
414 return html`
415 <div class="tool-calls">
416 ${message.tool_calls.map((toolCall) => {
417 const statusIcon = this.getToolStatusIcon(toolCall);
418 const summary = this.getToolSummary(toolCall);
419 const duration = this.getToolDuration(toolCall);
420
421 return html`
422 <div class="tool-call-item ${toolCall.name}">
423 <span class="tool-name">${toolCall.name}</span>
424 <span class="tool-summary">${summary}</span>
425 </div>
426 `;
427 })}
428 </div>
429 `;
430 }
431
432 private getToolStatusIcon(toolCall: any): string {
433 // Don't show status icons for mobile
434 return "";
435 }
436
437 private getToolSummary(toolCall: any): string {
438 try {
439 const input = JSON.parse(toolCall.input || "{}");
440
441 switch (toolCall.name) {
442 case "bash":
443 const command = input.command || "";
444 const isBackground = input.background === true;
445 const bgPrefix = isBackground ? "[bg] " : "";
446 return (
447 bgPrefix +
448 (command.length > 40 ? command.substring(0, 40) + "..." : command)
449 );
450
451 case "patch":
452 const path = input.path || "unknown";
453 const patchCount = (input.patches || []).length;
454 return `${path}: ${patchCount} edit${patchCount > 1 ? "s" : ""}`;
455
456 case "think":
457 const thoughts = input.thoughts || "";
458 const firstLine = thoughts.split("\n")[0] || "";
459 return firstLine.length > 50
460 ? firstLine.substring(0, 50) + "..."
461 : firstLine;
462
463 case "keyword_search":
464 const query = input.query || "";
465 return query.length > 50 ? query.substring(0, 50) + "..." : query;
466
467 case "browser_navigate":
468 return input.url || "";
469
470 case "browser_take_screenshot":
471 return "Taking screenshot";
472
473 case "browser_click":
474 return `Click: ${input.selector || ""}`;
475
476 case "browser_type":
477 const text = input.text || "";
478 return `Type: ${text.length > 30 ? text.substring(0, 30) + "..." : text}`;
479
480 case "todo_write":
481 const tasks = input.tasks || [];
482 return `${tasks.length} task${tasks.length > 1 ? "s" : ""}`;
483
484 case "todo_read":
485 return "Read todo list";
486
487 case "set-slug":
488 return `Slug: "${input.slug || ""}"`;
489
490 case "multiplechoice":
491 const question = input.question || "Multiple choice question";
492 const options = input.responseOptions || [];
493 if (options.length > 0) {
494 const optionsList = options.map((opt) => opt.caption).join(", ");
495 return `${question} [${optionsList}]`;
496 }
497 return question;
498
499 case "done":
500 return "Task completion checklist";
501
502 default:
503 // For unknown tools, show first part of input
504 const inputStr = JSON.stringify(input);
505 return inputStr.length > 50
506 ? inputStr.substring(0, 50) + "..."
507 : inputStr;
508 }
509 } catch (e) {
510 return "Tool call";
511 }
512 }
513
514 private getToolDuration(toolCall: any): string {
515 // Don't show duration for mobile
516 return "";
517 }
518
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700519 render() {
Autoformatterf825e692025-06-07 04:19:43 +0000520 const displayMessages = this.messages.filter((msg) =>
521 this.shouldShowMessage(msg),
522 );
523
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700524 return html`
525 <div class="chat-container" ${ref(this.scrollContainer)}>
Autoformatterf825e692025-06-07 04:19:43 +0000526 ${displayMessages.length === 0
527 ? html`
528 <div class="empty-state">Start a conversation with Sketch...</div>
529 `
530 : displayMessages.map((message) => {
531 const role = this.getMessageRole(message);
532 const text = this.getMessageText(message);
533 const timestamp = message.timestamp;
534
535 return html`
536 <div class="message ${role}">
537 <div class="message-bubble">
538 ${role === "assistant"
539 ? html`<div class="markdown-content">
540 ${unsafeHTML(this.renderMarkdown(text))}
541 </div>`
542 : text}
philip.zeyliger851d2bf2025-06-16 03:10:10 +0000543 ${this.renderToolCalls(message)}
Autoformatterf825e692025-06-07 04:19:43 +0000544 </div>
545 </div>
546 `;
547 })}
548 ${this.isThinking
549 ? html`
550 <div class="thinking-message">
551 <div class="thinking-bubble">
552 <span class="thinking-text">Sketch is thinking</span>
553 <div class="thinking-dots">
554 <div class="thinking-dot"></div>
555 <div class="thinking-dot"></div>
556 <div class="thinking-dot"></div>
557 </div>
558 </div>
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700559 </div>
Autoformatterf825e692025-06-07 04:19:43 +0000560 `
561 : ""}
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700562 </div>
563 `;
564 }
565}