blob: 0c3bc48eaa581b677bb1b9feb631118fdaaa93dd [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
philip.zeyliger851d2bf2025-06-16 03:10:10 +0000275 case "multiplechoice":
276 const question = input.question || "Multiple choice question";
277 const options = input.responseOptions || [];
278 if (options.length > 0) {
279 const optionsList = options.map((opt) => opt.caption).join(", ");
280 return `${question} [${optionsList}]`;
281 }
282 return question;
283
284 case "done":
285 return "Task completion checklist";
286
287 default:
288 // For unknown tools, show first part of input
289 const inputStr = JSON.stringify(input);
290 return inputStr.length > 50
291 ? inputStr.substring(0, 50) + "..."
292 : inputStr;
293 }
philip.zeyliger26bc6592025-06-30 20:15:30 -0700294 /* eslint-enable no-case-declarations */
295 } catch {
philip.zeyliger851d2bf2025-06-16 03:10:10 +0000296 return "Tool call";
297 }
298 }
299
philip.zeyliger26bc6592025-06-30 20:15:30 -0700300 private getToolDuration(_toolCall: any): string {
philip.zeyliger851d2bf2025-06-16 03:10:10 +0000301 // Don't show duration for mobile
302 return "";
303 }
304
Philip Zeyliger61a0f672025-06-21 15:33:18 -0700305 disconnectedCallback() {
306 super.disconnectedCallback();
307 if (this.scrollContainer.value) {
308 this.scrollContainer.value.removeEventListener(
309 "scroll",
310 this.handleScroll.bind(this),
311 );
312 }
313 }
314
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700315 render() {
Autoformatterf825e692025-06-07 04:19:43 +0000316 const displayMessages = this.messages.filter((msg) =>
317 this.shouldShowMessage(msg),
318 );
319
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700320 return html`
banksean23a35b82025-07-20 21:18:31 +0000321 <div class="block h-full overflow-hidden">
322 <div
323 class="h-full overflow-y-auto p-4 flex flex-col gap-4 scroll-smooth"
324 style="-webkit-overflow-scrolling: touch;"
325 ${ref(this.scrollContainer)}
326 >
327 ${displayMessages.length === 0
328 ? html`
329 <div
330 class="empty-state flex-1 flex items-center justify-center text-gray-500 italic text-center p-8"
331 >
332 Start a conversation with Sketch...
333 </div>
334 `
335 : displayMessages.map((message) => {
336 const role = this.getMessageRole(message);
337 const text = this.getMessageText(message);
338 // const timestamp = message.timestamp; // Unused for mobile layout
Autoformatterf825e692025-06-07 04:19:43 +0000339
banksean23a35b82025-07-20 21:18:31 +0000340 return html`
341 <div
342 class="message ${role} flex flex-col max-w-[85%] break-words ${role ===
343 "user"
344 ? "self-end items-end"
345 : "self-start items-start"}"
346 >
347 <div
348 class="message-bubble px-3 py-2 rounded-[18px] text-base leading-relaxed ${role ===
349 "user"
350 ? "bg-blue-500 text-white rounded-br-[6px]"
351 : role === "error"
352 ? "bg-red-50 text-red-700"
353 : "bg-gray-100 text-gray-800 rounded-bl-[6px]"}"
354 >
355 ${role === "assistant"
356 ? html`<div class="leading-6 break-words">
357 <style>
358 .markdown-content p {
359 margin: 0.3em 0;
360 }
361 .markdown-content p:first-child {
362 margin-top: 0;
363 }
364 .markdown-content p:last-child {
365 margin-bottom: 0;
366 }
367 .markdown-content h1,
368 .markdown-content h2,
369 .markdown-content h3,
370 .markdown-content h4,
371 .markdown-content h5,
372 .markdown-content h6 {
373 margin: 0.5em 0 0.3em 0;
374 font-weight: bold;
375 }
376 .markdown-content h1 {
377 font-size: 1.2em;
378 }
379 .markdown-content h2 {
380 font-size: 1.15em;
381 }
382 .markdown-content h3 {
383 font-size: 1.1em;
384 }
385 .markdown-content h4,
386 .markdown-content h5,
387 .markdown-content h6 {
388 font-size: 1.05em;
389 }
390 .markdown-content code {
391 background-color: rgba(0, 0, 0, 0.08);
392 padding: 2px 4px;
393 border-radius: 3px;
394 font-family:
395 Monaco, Menlo, "Ubuntu Mono", monospace;
396 font-size: 0.9em;
397 }
398 .markdown-content pre {
399 background-color: rgba(0, 0, 0, 0.08);
400 padding: 8px;
401 border-radius: 6px;
402 margin: 0.5em 0;
403 overflow-x: auto;
404 font-size: 0.9em;
405 }
406 .markdown-content pre code {
407 background: none;
408 padding: 0;
409 }
410 .markdown-content ul,
411 .markdown-content ol {
412 margin: 0.5em 0;
413 padding-left: 1.2em;
414 }
415 .markdown-content li {
416 margin: 0.2em 0;
417 }
418 .markdown-content blockquote {
419 border-left: 3px solid rgba(0, 0, 0, 0.2);
420 margin: 0.5em 0;
421 padding-left: 0.8em;
422 font-style: italic;
423 }
424 .markdown-content a {
425 color: inherit;
426 text-decoration: underline;
427 }
428 .markdown-content strong,
429 .markdown-content b {
430 font-weight: bold;
431 }
432 .markdown-content em,
433 .markdown-content i {
434 font-style: italic;
435 }
436 </style>
437 <div class="markdown-content">
438 ${unsafeHTML(this.renderMarkdown(text))}
439 </div>
440 </div>`
441 : text}
442 ${this.renderToolCalls(message)}
443 </div>
444 </div>
445 `;
446 })}
447 ${this.isThinking
448 ? html`
449 <div
450 class="thinking-message flex flex-col max-w-[85%] break-words self-start items-start"
451 >
452 <div
453 class="bg-gray-100 p-4 rounded-[18px] rounded-bl-[6px] flex items-center gap-2"
454 >
455 <span class="thinking-text text-gray-500 italic"
456 >Sketch is thinking</span
457 >
458 <div class="thinking-dots flex gap-1">
459 <div
460 class="w-1.5 h-1.5 rounded-full bg-gray-500 thinking-dot"
461 ></div>
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>
Autoformatterf825e692025-06-07 04:19:43 +0000469 </div>
470 </div>
banksean23a35b82025-07-20 21:18:31 +0000471 `
472 : ""}
473 </div>
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700474 </div>
Philip Zeyliger61a0f672025-06-21 15:33:18 -0700475
476 ${this.showJumpToBottom
477 ? html`
478 <button
banksean23a35b82025-07-20 21:18:31 +0000479 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 -0700480 @click=${this.jumpToBottom}
481 aria-label="Jump to bottom"
482 >
483 ↓ Jump to bottom
484 </button>
485 `
486 : ""}
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700487 `;
488 }
489}