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