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