blob: 9ce01fee96b0573baec5078b169a2711e9771320 [file] [log] [blame]
philip.zeyliger26bc6592025-06-30 20:15:30 -07001/* eslint-disable @typescript-eslint/no-explicit-any */
banksean23a35b82025-07-20 21:18:31 +00002import { html } from "lit";
Philip Zeyligere08c7ff2025-06-06 13:22:12 -07003import { 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";
banksean23a35b82025-07-20 21:18:31 +00009import { SketchTailwindElement } from "./sketch-tailwind-element";
Philip Zeyligere08c7ff2025-06-06 13:22:12 -070010
11@customElement("mobile-chat")
banksean23a35b82025-07-20 21:18:31 +000012export class MobileChat extends SketchTailwindElement {
Philip Zeyligere08c7ff2025-06-06 13:22:12 -070013 @property({ type: Array })
14 messages: AgentMessage[] = [];
15
16 @property({ type: Boolean })
17 isThinking = false;
18
19 private scrollContainer = createRef<HTMLDivElement>();
20
Philip Zeyliger61a0f672025-06-21 15:33:18 -070021 @state()
22 private showJumpToBottom = false;
23
banksean23a35b82025-07-20 21:18:31 +000024 connectedCallback() {
25 super.connectedCallback();
26 // Add animation styles to document head if not already present
27 if (!document.getElementById("mobile-chat-animations")) {
28 const style = document.createElement("style");
29 style.id = "mobile-chat-animations";
30 style.textContent = `
31 @keyframes thinking {
32 0%, 80%, 100% {
33 transform: scale(0.8);
34 opacity: 0.5;
35 }
36 40% {
37 transform: scale(1);
38 opacity: 1;
39 }
40 }
41 .thinking-dot {
42 animation: thinking 1.4s ease-in-out infinite both;
43 }
44 .thinking-dot:nth-child(1) { animation-delay: -0.32s; }
45 .thinking-dot:nth-child(2) { animation-delay: -0.16s; }
46 .thinking-dot:nth-child(3) { animation-delay: 0; }
47 `;
48 document.head.appendChild(style);
Philip Zeyligere08c7ff2025-06-06 13:22:12 -070049 }
banksean23a35b82025-07-20 21:18:31 +000050 }
Philip Zeyligere08c7ff2025-06-06 13:22:12 -070051
52 updated(changedProperties: Map<string, any>) {
53 super.updated(changedProperties);
Autoformatterf825e692025-06-07 04:19:43 +000054 if (
55 changedProperties.has("messages") ||
56 changedProperties.has("isThinking")
57 ) {
Philip Zeyligere08c7ff2025-06-06 13:22:12 -070058 this.scrollToBottom();
59 }
Philip Zeyliger61a0f672025-06-21 15:33:18 -070060
61 // Set up scroll listener if not already done
62 if (this.scrollContainer.value && !this.scrollContainer.value.onscroll) {
63 this.scrollContainer.value.addEventListener(
64 "scroll",
65 this.handleScroll.bind(this),
66 );
67 }
Philip Zeyligere08c7ff2025-06-06 13:22:12 -070068 }
69
70 private scrollToBottom() {
71 // Use requestAnimationFrame to ensure DOM is updated
72 requestAnimationFrame(() => {
73 if (this.scrollContainer.value) {
Autoformatterf825e692025-06-07 04:19:43 +000074 this.scrollContainer.value.scrollTop =
75 this.scrollContainer.value.scrollHeight;
Philip Zeyligere08c7ff2025-06-06 13:22:12 -070076 }
77 });
78 }
79
Philip Zeyliger61a0f672025-06-21 15:33:18 -070080 private handleScroll() {
81 if (!this.scrollContainer.value) return;
82
83 const container = this.scrollContainer.value;
84 const isAtBottom =
85 container.scrollTop + container.clientHeight >=
86 container.scrollHeight - 50; // 50px tolerance
87
88 this.showJumpToBottom = !isAtBottom;
89 }
90
91 private jumpToBottom() {
92 this.scrollToBottom();
93 }
94
Philip Zeyligere08c7ff2025-06-06 13:22:12 -070095 private formatTime(timestamp: string): string {
96 const date = new Date(timestamp);
Autoformatterf825e692025-06-07 04:19:43 +000097 return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
Philip Zeyligere08c7ff2025-06-06 13:22:12 -070098 }
99
100 private getMessageRole(message: AgentMessage): string {
Autoformatterf825e692025-06-07 04:19:43 +0000101 if (message.type === "user") {
102 return "user";
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700103 }
philip.zeyliger41682632025-06-09 22:23:25 +0000104 if (message.type === "error") {
105 return "error";
106 }
Autoformatterf825e692025-06-07 04:19:43 +0000107 return "assistant";
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700108 }
109
110 private getMessageText(message: AgentMessage): string {
Autoformatterf825e692025-06-07 04:19:43 +0000111 return message.content || "";
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700112 }
113
114 private shouldShowMessage(message: AgentMessage): boolean {
Philip Zeyliger8bc681b2025-06-10 02:03:02 +0000115 // Filter out hidden messages (subconversations) like the regular UI does
116 if (message.hide_output) {
117 return false;
118 }
119
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700120 // Show user, agent, and error messages with content
Autoformatterf825e692025-06-07 04:19:43 +0000121 return (
122 (message.type === "user" ||
123 message.type === "agent" ||
124 message.type === "error") &&
125 message.content &&
126 message.content.trim().length > 0
127 );
Philip Zeyliger5f778942025-06-07 00:05:20 +0000128 }
129
130 private renderMarkdown(markdownContent: string): string {
131 try {
132 // Create a custom renderer for mobile-optimized rendering
133 const renderer = new Renderer();
Autoformatterf825e692025-06-07 04:19:43 +0000134
Philip Zeyliger5f778942025-06-07 00:05:20 +0000135 // Override code renderer to simplify for mobile
Autoformatterf825e692025-06-07 04:19:43 +0000136 renderer.code = function ({
137 text,
138 lang,
139 }: {
140 text: string;
141 lang?: string;
142 }): string {
Philip Zeyliger5f778942025-06-07 00:05:20 +0000143 const langClass = lang ? ` class="language-${lang}"` : "";
144 return `<pre><code${langClass}>${text}</code></pre>`;
145 };
146
147 // Set markdown options for mobile
148 const markedOptions: MarkedOptions = {
149 gfm: true, // GitHub Flavored Markdown
150 breaks: true, // Convert newlines to <br>
151 async: false,
152 renderer: renderer,
153 };
154
155 // Parse markdown and sanitize the output HTML
156 const htmlOutput = marked.parse(markdownContent, markedOptions) as string;
157 return DOMPurify.sanitize(htmlOutput, {
158 ALLOWED_TAGS: [
Autoformatterf825e692025-06-07 04:19:43 +0000159 "p",
160 "br",
161 "strong",
162 "em",
163 "b",
164 "i",
165 "u",
166 "s",
167 "code",
168 "pre",
169 "h1",
170 "h2",
171 "h3",
172 "h4",
173 "h5",
174 "h6",
175 "ul",
176 "ol",
177 "li",
178 "blockquote",
179 "a",
Philip Zeyliger5f778942025-06-07 00:05:20 +0000180 ],
181 ALLOWED_ATTR: ["href", "title", "target", "rel", "class"],
182 KEEP_CONTENT: true,
183 });
184 } catch (error) {
185 console.error("Error rendering markdown:", error);
186 // Fallback to sanitized plain text if markdown parsing fails
187 return DOMPurify.sanitize(markdownContent);
188 }
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700189 }
190
philip.zeyliger851d2bf2025-06-16 03:10:10 +0000191 private renderToolCalls(message: AgentMessage) {
192 if (!message.tool_calls || message.tool_calls.length === 0) {
193 return "";
194 }
195
196 return html`
banksean23a35b82025-07-20 21:18:31 +0000197 <div class="mt-3 flex flex-col gap-1.5">
philip.zeyliger851d2bf2025-06-16 03:10:10 +0000198 ${message.tool_calls.map((toolCall) => {
philip.zeyliger851d2bf2025-06-16 03:10:10 +0000199 const summary = this.getToolSummary(toolCall);
philip.zeyliger851d2bf2025-06-16 03:10:10 +0000200
201 return html`
banksean23a35b82025-07-20 21:18:31 +0000202 <div
203 class="bg-black/[0.04] rounded-lg px-2 py-1.5 text-xs font-mono leading-snug flex items-center gap-1.5 ${toolCall.name}"
204 >
205 <span class="font-bold text-gray-800 flex-shrink-0 mr-0.5"
206 >${toolCall.name}</span
207 >
208 <span
209 class="text-gray-600 flex-grow overflow-hidden text-ellipsis whitespace-nowrap"
210 >${summary}</span
211 >
philip.zeyliger851d2bf2025-06-16 03:10:10 +0000212 </div>
213 `;
214 })}
215 </div>
216 `;
217 }
218
philip.zeyliger26bc6592025-06-30 20:15:30 -0700219 private getToolStatusIcon(_toolCall: any): string {
philip.zeyliger851d2bf2025-06-16 03:10:10 +0000220 // Don't show status icons for mobile
221 return "";
222 }
223
224 private getToolSummary(toolCall: any): string {
225 try {
226 const input = JSON.parse(toolCall.input || "{}");
227
philip.zeyliger26bc6592025-06-30 20:15:30 -0700228 /* eslint-disable no-case-declarations */
philip.zeyliger851d2bf2025-06-16 03:10:10 +0000229 switch (toolCall.name) {
230 case "bash":
231 const command = input.command || "";
232 const isBackground = input.background === true;
233 const bgPrefix = isBackground ? "[bg] " : "";
234 return (
235 bgPrefix +
236 (command.length > 40 ? command.substring(0, 40) + "..." : command)
237 );
238
239 case "patch":
240 const path = input.path || "unknown";
241 const patchCount = (input.patches || []).length;
242 return `${path}: ${patchCount} edit${patchCount > 1 ? "s" : ""}`;
243
244 case "think":
245 const thoughts = input.thoughts || "";
246 const firstLine = thoughts.split("\n")[0] || "";
247 return firstLine.length > 50
248 ? firstLine.substring(0, 50) + "..."
249 : firstLine;
250
251 case "keyword_search":
252 const query = input.query || "";
253 return query.length > 50 ? query.substring(0, 50) + "..." : query;
254
255 case "browser_navigate":
256 return input.url || "";
257
258 case "browser_take_screenshot":
259 return "Taking screenshot";
260
261 case "browser_click":
262 return `Click: ${input.selector || ""}`;
263
264 case "browser_type":
265 const text = input.text || "";
266 return `Type: ${text.length > 30 ? text.substring(0, 30) + "..." : text}`;
267
268 case "todo_write":
269 const tasks = input.tasks || [];
270 return `${tasks.length} task${tasks.length > 1 ? "s" : ""}`;
271
272 case "todo_read":
273 return "Read todo list";
274
275 case "set-slug":
276 return `Slug: "${input.slug || ""}"`;
277
278 case "multiplechoice":
279 const question = input.question || "Multiple choice question";
280 const options = input.responseOptions || [];
281 if (options.length > 0) {
282 const optionsList = options.map((opt) => opt.caption).join(", ");
283 return `${question} [${optionsList}]`;
284 }
285 return question;
286
287 case "done":
288 return "Task completion checklist";
289
290 default:
291 // For unknown tools, show first part of input
292 const inputStr = JSON.stringify(input);
293 return inputStr.length > 50
294 ? inputStr.substring(0, 50) + "..."
295 : inputStr;
296 }
philip.zeyliger26bc6592025-06-30 20:15:30 -0700297 /* eslint-enable no-case-declarations */
298 } catch {
philip.zeyliger851d2bf2025-06-16 03:10:10 +0000299 return "Tool call";
300 }
301 }
302
philip.zeyliger26bc6592025-06-30 20:15:30 -0700303 private getToolDuration(_toolCall: any): string {
philip.zeyliger851d2bf2025-06-16 03:10:10 +0000304 // Don't show duration for mobile
305 return "";
306 }
307
Philip Zeyliger61a0f672025-06-21 15:33:18 -0700308 disconnectedCallback() {
309 super.disconnectedCallback();
310 if (this.scrollContainer.value) {
311 this.scrollContainer.value.removeEventListener(
312 "scroll",
313 this.handleScroll.bind(this),
314 );
315 }
316 }
317
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700318 render() {
Autoformatterf825e692025-06-07 04:19:43 +0000319 const displayMessages = this.messages.filter((msg) =>
320 this.shouldShowMessage(msg),
321 );
322
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700323 return html`
banksean23a35b82025-07-20 21:18:31 +0000324 <div class="block h-full overflow-hidden">
325 <div
326 class="h-full overflow-y-auto p-4 flex flex-col gap-4 scroll-smooth"
327 style="-webkit-overflow-scrolling: touch;"
328 ${ref(this.scrollContainer)}
329 >
330 ${displayMessages.length === 0
331 ? html`
332 <div
333 class="empty-state flex-1 flex items-center justify-center text-gray-500 italic text-center p-8"
334 >
335 Start a conversation with Sketch...
336 </div>
337 `
338 : displayMessages.map((message) => {
339 const role = this.getMessageRole(message);
340 const text = this.getMessageText(message);
341 // const timestamp = message.timestamp; // Unused for mobile layout
Autoformatterf825e692025-06-07 04:19:43 +0000342
banksean23a35b82025-07-20 21:18:31 +0000343 return html`
344 <div
345 class="message ${role} flex flex-col max-w-[85%] break-words ${role ===
346 "user"
347 ? "self-end items-end"
348 : "self-start items-start"}"
349 >
350 <div
351 class="message-bubble px-3 py-2 rounded-[18px] text-base leading-relaxed ${role ===
352 "user"
353 ? "bg-blue-500 text-white rounded-br-[6px]"
354 : role === "error"
355 ? "bg-red-50 text-red-700"
356 : "bg-gray-100 text-gray-800 rounded-bl-[6px]"}"
357 >
358 ${role === "assistant"
359 ? html`<div class="leading-6 break-words">
360 <style>
361 .markdown-content p {
362 margin: 0.3em 0;
363 }
364 .markdown-content p:first-child {
365 margin-top: 0;
366 }
367 .markdown-content p:last-child {
368 margin-bottom: 0;
369 }
370 .markdown-content h1,
371 .markdown-content h2,
372 .markdown-content h3,
373 .markdown-content h4,
374 .markdown-content h5,
375 .markdown-content h6 {
376 margin: 0.5em 0 0.3em 0;
377 font-weight: bold;
378 }
379 .markdown-content h1 {
380 font-size: 1.2em;
381 }
382 .markdown-content h2 {
383 font-size: 1.15em;
384 }
385 .markdown-content h3 {
386 font-size: 1.1em;
387 }
388 .markdown-content h4,
389 .markdown-content h5,
390 .markdown-content h6 {
391 font-size: 1.05em;
392 }
393 .markdown-content code {
394 background-color: rgba(0, 0, 0, 0.08);
395 padding: 2px 4px;
396 border-radius: 3px;
397 font-family:
398 Monaco, Menlo, "Ubuntu Mono", monospace;
399 font-size: 0.9em;
400 }
401 .markdown-content pre {
402 background-color: rgba(0, 0, 0, 0.08);
403 padding: 8px;
404 border-radius: 6px;
405 margin: 0.5em 0;
406 overflow-x: auto;
407 font-size: 0.9em;
408 }
409 .markdown-content pre code {
410 background: none;
411 padding: 0;
412 }
413 .markdown-content ul,
414 .markdown-content ol {
415 margin: 0.5em 0;
416 padding-left: 1.2em;
417 }
418 .markdown-content li {
419 margin: 0.2em 0;
420 }
421 .markdown-content blockquote {
422 border-left: 3px solid rgba(0, 0, 0, 0.2);
423 margin: 0.5em 0;
424 padding-left: 0.8em;
425 font-style: italic;
426 }
427 .markdown-content a {
428 color: inherit;
429 text-decoration: underline;
430 }
431 .markdown-content strong,
432 .markdown-content b {
433 font-weight: bold;
434 }
435 .markdown-content em,
436 .markdown-content i {
437 font-style: italic;
438 }
439 </style>
440 <div class="markdown-content">
441 ${unsafeHTML(this.renderMarkdown(text))}
442 </div>
443 </div>`
444 : text}
445 ${this.renderToolCalls(message)}
446 </div>
447 </div>
448 `;
449 })}
450 ${this.isThinking
451 ? html`
452 <div
453 class="thinking-message flex flex-col max-w-[85%] break-words self-start items-start"
454 >
455 <div
456 class="bg-gray-100 p-4 rounded-[18px] rounded-bl-[6px] flex items-center gap-2"
457 >
458 <span class="thinking-text text-gray-500 italic"
459 >Sketch is thinking</span
460 >
461 <div class="thinking-dots flex gap-1">
462 <div
463 class="w-1.5 h-1.5 rounded-full bg-gray-500 thinking-dot"
464 ></div>
465 <div
466 class="w-1.5 h-1.5 rounded-full bg-gray-500 thinking-dot"
467 ></div>
468 <div
469 class="w-1.5 h-1.5 rounded-full bg-gray-500 thinking-dot"
470 ></div>
471 </div>
Autoformatterf825e692025-06-07 04:19:43 +0000472 </div>
473 </div>
banksean23a35b82025-07-20 21:18:31 +0000474 `
475 : ""}
476 </div>
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700477 </div>
Philip Zeyliger61a0f672025-06-21 15:33:18 -0700478
479 ${this.showJumpToBottom
480 ? html`
481 <button
banksean23a35b82025-07-20 21:18:31 +0000482 class="fixed bottom-[70px] left-1/2 transform -translate-x-1/2 bg-black/60 text-white border-none rounded-xl px-2 py-1 text-xs font-normal cursor-pointer shadow-sm z-[1100] transition-all duration-150 flex items-center gap-1 opacity-80 hover:bg-black/80 hover:-translate-y-px hover:opacity-100 hover:shadow-md active:translate-y-0"
Philip Zeyliger61a0f672025-06-21 15:33:18 -0700483 @click=${this.jumpToBottom}
484 aria-label="Jump to bottom"
485 >
486 ↓ Jump to bottom
487 </button>
488 `
489 : ""}
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700490 `;
491 }
492}