blob: 89c0a1c2fa53f6415f1496c5eacebe66d699dc62 [file] [log] [blame]
banksean23a35b82025-07-20 21:18:31 +00001import { html } from "lit";
Philip Zeyligere08c7ff2025-06-06 13:22:12 -07002import { 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";
banksean23a35b82025-07-20 21:18:31 +00008import { SketchTailwindElement } from "./sketch-tailwind-element";
Philip Zeyligere08c7ff2025-06-06 13:22:12 -07009
10@customElement("mobile-chat")
banksean23a35b82025-07-20 21:18:31 +000011export class MobileChat extends SketchTailwindElement {
Philip Zeyligere08c7ff2025-06-06 13:22:12 -070012 @property({ type: Array })
13 messages: AgentMessage[] = [];
14
15 @property({ type: Boolean })
16 isThinking = false;
17
18 private scrollContainer = createRef<HTMLDivElement>();
19
Philip Zeyliger61a0f672025-06-21 15:33:18 -070020 @state()
21 private showJumpToBottom = false;
22
banksean23a35b82025-07-20 21:18:31 +000023 connectedCallback() {
24 super.connectedCallback();
25 // Add animation styles to document head if not already present
26 if (!document.getElementById("mobile-chat-animations")) {
27 const style = document.createElement("style");
28 style.id = "mobile-chat-animations";
29 style.textContent = `
30 @keyframes thinking {
31 0%, 80%, 100% {
32 transform: scale(0.8);
33 opacity: 0.5;
34 }
35 40% {
36 transform: scale(1);
37 opacity: 1;
38 }
39 }
40 .thinking-dot {
41 animation: thinking 1.4s ease-in-out infinite both;
42 }
43 .thinking-dot:nth-child(1) { animation-delay: -0.32s; }
44 .thinking-dot:nth-child(2) { animation-delay: -0.16s; }
45 .thinking-dot:nth-child(3) { animation-delay: 0; }
46 `;
47 document.head.appendChild(style);
Philip Zeyligere08c7ff2025-06-06 13:22:12 -070048 }
banksean23a35b82025-07-20 21:18:31 +000049 }
Philip Zeyligere08c7ff2025-06-06 13:22:12 -070050
51 updated(changedProperties: Map<string, any>) {
52 super.updated(changedProperties);
Autoformatterf825e692025-06-07 04:19:43 +000053 if (
54 changedProperties.has("messages") ||
55 changedProperties.has("isThinking")
56 ) {
Philip Zeyligere08c7ff2025-06-06 13:22:12 -070057 this.scrollToBottom();
58 }
Philip Zeyliger61a0f672025-06-21 15:33:18 -070059
60 // Set up scroll listener if not already done
61 if (this.scrollContainer.value && !this.scrollContainer.value.onscroll) {
62 this.scrollContainer.value.addEventListener(
63 "scroll",
64 this.handleScroll.bind(this),
65 );
66 }
Philip Zeyligere08c7ff2025-06-06 13:22:12 -070067 }
68
69 private scrollToBottom() {
70 // Use requestAnimationFrame to ensure DOM is updated
71 requestAnimationFrame(() => {
72 if (this.scrollContainer.value) {
Autoformatterf825e692025-06-07 04:19:43 +000073 this.scrollContainer.value.scrollTop =
74 this.scrollContainer.value.scrollHeight;
Philip Zeyligere08c7ff2025-06-06 13:22:12 -070075 }
76 });
77 }
78
Philip Zeyliger61a0f672025-06-21 15:33:18 -070079 private handleScroll() {
80 if (!this.scrollContainer.value) return;
81
82 const container = this.scrollContainer.value;
83 const isAtBottom =
84 container.scrollTop + container.clientHeight >=
85 container.scrollHeight - 50; // 50px tolerance
86
87 this.showJumpToBottom = !isAtBottom;
88 }
89
90 private jumpToBottom() {
91 this.scrollToBottom();
92 }
93
Philip Zeyligere08c7ff2025-06-06 13:22:12 -070094 private formatTime(timestamp: string): string {
95 const date = new Date(timestamp);
Autoformatterf825e692025-06-07 04:19:43 +000096 return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
Philip Zeyligere08c7ff2025-06-06 13:22:12 -070097 }
98
99 private getMessageRole(message: AgentMessage): string {
Autoformatterf825e692025-06-07 04:19:43 +0000100 if (message.type === "user") {
101 return "user";
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700102 }
philip.zeyliger41682632025-06-09 22:23:25 +0000103 if (message.type === "error") {
104 return "error";
105 }
Autoformatterf825e692025-06-07 04:19:43 +0000106 return "assistant";
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700107 }
108
109 private getMessageText(message: AgentMessage): string {
Autoformatterf825e692025-06-07 04:19:43 +0000110 return message.content || "";
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700111 }
112
113 private shouldShowMessage(message: AgentMessage): boolean {
Philip Zeyliger8bc681b2025-06-10 02:03:02 +0000114 // Filter out hidden messages (subconversations) like the regular UI does
115 if (message.hide_output) {
116 return false;
117 }
118
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700119 // Show user, agent, and error messages with content
Autoformatterf825e692025-06-07 04:19:43 +0000120 return (
121 (message.type === "user" ||
122 message.type === "agent" ||
123 message.type === "error") &&
124 message.content &&
125 message.content.trim().length > 0
126 );
Philip Zeyliger5f778942025-06-07 00:05:20 +0000127 }
128
129 private renderMarkdown(markdownContent: string): string {
130 try {
131 // Create a custom renderer for mobile-optimized rendering
132 const renderer = new Renderer();
Autoformatterf825e692025-06-07 04:19:43 +0000133
Philip Zeyliger5f778942025-06-07 00:05:20 +0000134 // Override code renderer to simplify for mobile
Autoformatterf825e692025-06-07 04:19:43 +0000135 renderer.code = function ({
136 text,
137 lang,
138 }: {
139 text: string;
140 lang?: string;
141 }): string {
Philip Zeyliger5f778942025-06-07 00:05:20 +0000142 const langClass = lang ? ` class="language-${lang}"` : "";
143 return `<pre><code${langClass}>${text}</code></pre>`;
144 };
145
146 // Set markdown options for mobile
147 const markedOptions: MarkedOptions = {
148 gfm: true, // GitHub Flavored Markdown
149 breaks: true, // Convert newlines to <br>
150 async: false,
151 renderer: renderer,
152 };
153
154 // Parse markdown and sanitize the output HTML
155 const htmlOutput = marked.parse(markdownContent, markedOptions) as string;
156 return DOMPurify.sanitize(htmlOutput, {
157 ALLOWED_TAGS: [
Autoformatterf825e692025-06-07 04:19:43 +0000158 "p",
159 "br",
160 "strong",
161 "em",
162 "b",
163 "i",
164 "u",
165 "s",
166 "code",
167 "pre",
168 "h1",
169 "h2",
170 "h3",
171 "h4",
172 "h5",
173 "h6",
174 "ul",
175 "ol",
176 "li",
177 "blockquote",
178 "a",
Philip Zeyliger5f778942025-06-07 00:05:20 +0000179 ],
180 ALLOWED_ATTR: ["href", "title", "target", "rel", "class"],
181 KEEP_CONTENT: true,
182 });
183 } catch (error) {
184 console.error("Error rendering markdown:", error);
185 // Fallback to sanitized plain text if markdown parsing fails
186 return DOMPurify.sanitize(markdownContent);
187 }
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700188 }
189
philip.zeyliger851d2bf2025-06-16 03:10:10 +0000190 private renderToolCalls(message: AgentMessage) {
191 if (!message.tool_calls || message.tool_calls.length === 0) {
192 return "";
193 }
194
195 return html`
banksean23a35b82025-07-20 21:18:31 +0000196 <div class="mt-3 flex flex-col gap-1.5">
philip.zeyliger851d2bf2025-06-16 03:10:10 +0000197 ${message.tool_calls.map((toolCall) => {
philip.zeyliger851d2bf2025-06-16 03:10:10 +0000198 const summary = this.getToolSummary(toolCall);
philip.zeyliger851d2bf2025-06-16 03:10:10 +0000199
200 return html`
banksean23a35b82025-07-20 21:18:31 +0000201 <div
202 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}"
203 >
204 <span class="font-bold text-gray-800 flex-shrink-0 mr-0.5"
205 >${toolCall.name}</span
206 >
207 <span
208 class="text-gray-600 flex-grow overflow-hidden text-ellipsis whitespace-nowrap"
209 >${summary}</span
210 >
philip.zeyliger851d2bf2025-06-16 03:10:10 +0000211 </div>
212 `;
213 })}
214 </div>
215 `;
216 }
217
philip.zeyliger26bc6592025-06-30 20:15:30 -0700218 private getToolStatusIcon(_toolCall: any): string {
philip.zeyliger851d2bf2025-06-16 03:10:10 +0000219 // Don't show status icons for mobile
220 return "";
221 }
222
223 private getToolSummary(toolCall: any): string {
224 try {
225 const input = JSON.parse(toolCall.input || "{}");
226
philip.zeyliger26bc6592025-06-30 20:15:30 -0700227 /* eslint-disable no-case-declarations */
philip.zeyliger851d2bf2025-06-16 03:10:10 +0000228 switch (toolCall.name) {
229 case "bash":
230 const command = input.command || "";
231 const isBackground = input.background === true;
232 const bgPrefix = isBackground ? "[bg] " : "";
233 return (
234 bgPrefix +
235 (command.length > 40 ? command.substring(0, 40) + "..." : command)
236 );
237
238 case "patch":
239 const path = input.path || "unknown";
240 const patchCount = (input.patches || []).length;
241 return `${path}: ${patchCount} edit${patchCount > 1 ? "s" : ""}`;
242
243 case "think":
244 const thoughts = input.thoughts || "";
245 const firstLine = thoughts.split("\n")[0] || "";
246 return firstLine.length > 50
247 ? firstLine.substring(0, 50) + "..."
248 : firstLine;
249
250 case "keyword_search":
251 const query = input.query || "";
252 return query.length > 50 ? query.substring(0, 50) + "..." : query;
253
254 case "browser_navigate":
255 return input.url || "";
256
257 case "browser_take_screenshot":
258 return "Taking screenshot";
259
philip.zeyliger851d2bf2025-06-16 03:10:10 +0000260 case "todo_write":
261 const tasks = input.tasks || [];
262 return `${tasks.length} task${tasks.length > 1 ? "s" : ""}`;
263
264 case "todo_read":
265 return "Read todo list";
266
philip.zeyliger851d2bf2025-06-16 03:10:10 +0000267 case "done":
268 return "Task completion checklist";
269
270 default:
271 // For unknown tools, show first part of input
272 const inputStr = JSON.stringify(input);
273 return inputStr.length > 50
274 ? inputStr.substring(0, 50) + "..."
275 : inputStr;
276 }
philip.zeyliger26bc6592025-06-30 20:15:30 -0700277 /* eslint-enable no-case-declarations */
278 } catch {
philip.zeyliger851d2bf2025-06-16 03:10:10 +0000279 return "Tool call";
280 }
281 }
282
philip.zeyliger26bc6592025-06-30 20:15:30 -0700283 private getToolDuration(_toolCall: any): string {
philip.zeyliger851d2bf2025-06-16 03:10:10 +0000284 // Don't show duration for mobile
285 return "";
286 }
287
Philip Zeyliger61a0f672025-06-21 15:33:18 -0700288 disconnectedCallback() {
289 super.disconnectedCallback();
290 if (this.scrollContainer.value) {
291 this.scrollContainer.value.removeEventListener(
292 "scroll",
293 this.handleScroll.bind(this),
294 );
295 }
296 }
297
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700298 render() {
Autoformatterf825e692025-06-07 04:19:43 +0000299 const displayMessages = this.messages.filter((msg) =>
300 this.shouldShowMessage(msg),
301 );
302
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700303 return html`
banksean23a35b82025-07-20 21:18:31 +0000304 <div class="block h-full overflow-hidden">
305 <div
306 class="h-full overflow-y-auto p-4 flex flex-col gap-4 scroll-smooth"
307 style="-webkit-overflow-scrolling: touch;"
308 ${ref(this.scrollContainer)}
309 >
310 ${displayMessages.length === 0
311 ? html`
312 <div
313 class="empty-state flex-1 flex items-center justify-center text-gray-500 italic text-center p-8"
314 >
315 Start a conversation with Sketch...
316 </div>
317 `
318 : displayMessages.map((message) => {
319 const role = this.getMessageRole(message);
320 const text = this.getMessageText(message);
321 // const timestamp = message.timestamp; // Unused for mobile layout
Autoformatterf825e692025-06-07 04:19:43 +0000322
banksean23a35b82025-07-20 21:18:31 +0000323 return html`
324 <div
325 class="message ${role} flex flex-col max-w-[85%] break-words ${role ===
326 "user"
327 ? "self-end items-end"
328 : "self-start items-start"}"
329 >
330 <div
331 class="message-bubble px-3 py-2 rounded-[18px] text-base leading-relaxed ${role ===
332 "user"
333 ? "bg-blue-500 text-white rounded-br-[6px]"
334 : role === "error"
335 ? "bg-red-50 text-red-700"
336 : "bg-gray-100 text-gray-800 rounded-bl-[6px]"}"
337 >
338 ${role === "assistant"
339 ? html`<div class="leading-6 break-words">
340 <style>
341 .markdown-content p {
342 margin: 0.3em 0;
343 }
344 .markdown-content p:first-child {
345 margin-top: 0;
346 }
347 .markdown-content p:last-child {
348 margin-bottom: 0;
349 }
350 .markdown-content h1,
351 .markdown-content h2,
352 .markdown-content h3,
353 .markdown-content h4,
354 .markdown-content h5,
355 .markdown-content h6 {
356 margin: 0.5em 0 0.3em 0;
357 font-weight: bold;
358 }
359 .markdown-content h1 {
360 font-size: 1.2em;
361 }
362 .markdown-content h2 {
363 font-size: 1.15em;
364 }
365 .markdown-content h3 {
366 font-size: 1.1em;
367 }
368 .markdown-content h4,
369 .markdown-content h5,
370 .markdown-content h6 {
371 font-size: 1.05em;
372 }
373 .markdown-content code {
374 background-color: rgba(0, 0, 0, 0.08);
375 padding: 2px 4px;
376 border-radius: 3px;
377 font-family:
378 Monaco, Menlo, "Ubuntu Mono", monospace;
379 font-size: 0.9em;
380 }
381 .markdown-content pre {
382 background-color: rgba(0, 0, 0, 0.08);
383 padding: 8px;
384 border-radius: 6px;
385 margin: 0.5em 0;
386 overflow-x: auto;
387 font-size: 0.9em;
388 }
389 .markdown-content pre code {
390 background: none;
391 padding: 0;
392 }
393 .markdown-content ul,
394 .markdown-content ol {
395 margin: 0.5em 0;
396 padding-left: 1.2em;
397 }
398 .markdown-content li {
399 margin: 0.2em 0;
400 }
401 .markdown-content blockquote {
402 border-left: 3px solid rgba(0, 0, 0, 0.2);
403 margin: 0.5em 0;
404 padding-left: 0.8em;
405 font-style: italic;
406 }
407 .markdown-content a {
408 color: inherit;
409 text-decoration: underline;
410 }
411 .markdown-content strong,
412 .markdown-content b {
413 font-weight: bold;
414 }
415 .markdown-content em,
416 .markdown-content i {
417 font-style: italic;
418 }
419 </style>
420 <div class="markdown-content">
421 ${unsafeHTML(this.renderMarkdown(text))}
422 </div>
423 </div>`
424 : text}
425 ${this.renderToolCalls(message)}
426 </div>
427 </div>
428 `;
429 })}
430 ${this.isThinking
431 ? html`
432 <div
433 class="thinking-message flex flex-col max-w-[85%] break-words self-start items-start"
434 >
435 <div
436 class="bg-gray-100 p-4 rounded-[18px] rounded-bl-[6px] flex items-center gap-2"
437 >
438 <span class="thinking-text text-gray-500 italic"
439 >Sketch is thinking</span
440 >
441 <div class="thinking-dots flex gap-1">
442 <div
443 class="w-1.5 h-1.5 rounded-full bg-gray-500 thinking-dot"
444 ></div>
445 <div
446 class="w-1.5 h-1.5 rounded-full bg-gray-500 thinking-dot"
447 ></div>
448 <div
449 class="w-1.5 h-1.5 rounded-full bg-gray-500 thinking-dot"
450 ></div>
451 </div>
Autoformatterf825e692025-06-07 04:19:43 +0000452 </div>
453 </div>
banksean23a35b82025-07-20 21:18:31 +0000454 `
455 : ""}
456 </div>
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700457 </div>
Philip Zeyliger61a0f672025-06-21 15:33:18 -0700458
459 ${this.showJumpToBottom
460 ? html`
461 <button
banksean23a35b82025-07-20 21:18:31 +0000462 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 -0700463 @click=${this.jumpToBottom}
464 aria-label="Jump to bottom"
465 >
466 ↓ Jump to bottom
467 </button>
468 `
469 : ""}
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700470 `;
471 }
472}