blob: c746686998e64ee252aaff1642c10e74a10fc7fe [file] [log] [blame]
Philip Zeyligere08c7ff2025-06-06 13:22:12 -07001import { css, html, LitElement } from "lit";
2import { 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";
Philip Zeyligere08c7ff2025-06-06 13:22:12 -07008
9@customElement("mobile-chat")
10export class MobileChat extends LitElement {
11 @property({ type: Array })
12 messages: AgentMessage[] = [];
13
14 @property({ type: Boolean })
15 isThinking = false;
16
17 private scrollContainer = createRef<HTMLDivElement>();
18
19 static styles = css`
20 :host {
21 display: block;
22 height: 100%;
23 overflow: hidden;
24 }
25
26 .chat-container {
27 height: 100%;
28 overflow-y: auto;
29 padding: 16px;
30 display: flex;
31 flex-direction: column;
32 gap: 16px;
33 scroll-behavior: smooth;
34 -webkit-overflow-scrolling: touch;
35 }
36
37 .message {
38 display: flex;
39 flex-direction: column;
40 max-width: 85%;
41 word-wrap: break-word;
42 }
43
44 .message.user {
45 align-self: flex-end;
46 align-items: flex-end;
47 }
48
49 .message.assistant {
50 align-self: flex-start;
51 align-items: flex-start;
52 }
53
54 .message-bubble {
55 padding: 8px 12px;
56 border-radius: 18px;
57 font-size: 16px;
58 line-height: 1.4;
59 }
60
61 .message.user .message-bubble {
62 background-color: #007bff;
63 color: white;
64 border-bottom-right-radius: 6px;
65 }
66
67 .message.assistant .message-bubble {
68 background-color: #f1f3f4;
69 color: #333;
70 border-bottom-left-radius: 6px;
71 }
72
philip.zeyliger41682632025-06-09 22:23:25 +000073 .message.error .message-bubble {
74 background-color: #ffebee;
75 color: #d32f2f;
76 border-radius: 18px;
77 }
78
Philip Zeyligere08c7ff2025-06-06 13:22:12 -070079 .thinking-message {
80 align-self: flex-start;
81 align-items: flex-start;
82 max-width: 85%;
83 }
84
85 .thinking-bubble {
86 background-color: #f1f3f4;
87 padding: 16px;
88 border-radius: 18px;
89 border-bottom-left-radius: 6px;
90 display: flex;
91 align-items: center;
92 gap: 8px;
93 }
94
95 .thinking-text {
96 color: #6c757d;
97 font-style: italic;
98 }
99
100 .thinking-dots {
101 display: flex;
102 gap: 3px;
103 }
104
105 .thinking-dot {
106 width: 6px;
107 height: 6px;
108 border-radius: 50%;
109 background-color: #6c757d;
110 animation: thinking 1.4s ease-in-out infinite both;
111 }
112
Autoformatterf825e692025-06-07 04:19:43 +0000113 .thinking-dot:nth-child(1) {
114 animation-delay: -0.32s;
115 }
116 .thinking-dot:nth-child(2) {
117 animation-delay: -0.16s;
118 }
119 .thinking-dot:nth-child(3) {
120 animation-delay: 0;
121 }
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700122
123 @keyframes thinking {
Autoformatterf825e692025-06-07 04:19:43 +0000124 0%,
125 80%,
126 100% {
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700127 transform: scale(0.8);
128 opacity: 0.5;
129 }
130 40% {
131 transform: scale(1);
132 opacity: 1;
133 }
134 }
135
136 .empty-state {
137 flex: 1;
138 display: flex;
139 align-items: center;
140 justify-content: center;
141 color: #6c757d;
142 font-style: italic;
143 text-align: center;
144 padding: 32px;
145 }
Philip Zeyliger5f778942025-06-07 00:05:20 +0000146
147 /* Markdown content styling for mobile */
148 .markdown-content {
149 line-height: 1.5;
150 word-wrap: break-word;
151 overflow-wrap: break-word;
152 }
153
154 .markdown-content p {
155 margin: 0.3em 0;
156 }
157
158 .markdown-content p:first-child {
159 margin-top: 0;
160 }
161
162 .markdown-content p:last-child {
163 margin-bottom: 0;
164 }
165
166 .markdown-content h1,
167 .markdown-content h2,
168 .markdown-content h3,
169 .markdown-content h4,
170 .markdown-content h5,
171 .markdown-content h6 {
172 margin: 0.5em 0 0.3em 0;
173 font-weight: bold;
174 }
175
Autoformatterf825e692025-06-07 04:19:43 +0000176 .markdown-content h1 {
177 font-size: 1.2em;
178 }
179 .markdown-content h2 {
180 font-size: 1.15em;
181 }
182 .markdown-content h3 {
183 font-size: 1.1em;
184 }
Philip Zeyliger5f778942025-06-07 00:05:20 +0000185 .markdown-content h4,
186 .markdown-content h5,
Autoformatterf825e692025-06-07 04:19:43 +0000187 .markdown-content h6 {
188 font-size: 1.05em;
189 }
Philip Zeyliger5f778942025-06-07 00:05:20 +0000190
191 .markdown-content code {
192 background-color: rgba(0, 0, 0, 0.08);
193 padding: 2px 4px;
194 border-radius: 3px;
Autoformatterf825e692025-06-07 04:19:43 +0000195 font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace;
Philip Zeyliger5f778942025-06-07 00:05:20 +0000196 font-size: 0.9em;
197 }
198
199 .markdown-content pre {
200 background-color: rgba(0, 0, 0, 0.08);
201 padding: 8px;
202 border-radius: 6px;
203 margin: 0.5em 0;
204 overflow-x: auto;
205 font-size: 0.9em;
206 }
207
208 .markdown-content pre code {
209 background: none;
210 padding: 0;
211 }
212
213 .markdown-content ul,
214 .markdown-content ol {
215 margin: 0.5em 0;
216 padding-left: 1.2em;
217 }
218
219 .markdown-content li {
220 margin: 0.2em 0;
221 }
222
223 .markdown-content blockquote {
224 border-left: 3px solid rgba(0, 0, 0, 0.2);
225 margin: 0.5em 0;
226 padding-left: 0.8em;
227 font-style: italic;
228 }
229
230 .markdown-content a {
231 color: inherit;
232 text-decoration: underline;
233 }
234
235 .markdown-content strong,
236 .markdown-content b {
237 font-weight: bold;
238 }
239
240 .markdown-content em,
241 .markdown-content i {
242 font-style: italic;
243 }
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700244 `;
245
246 updated(changedProperties: Map<string, any>) {
247 super.updated(changedProperties);
Autoformatterf825e692025-06-07 04:19:43 +0000248 if (
249 changedProperties.has("messages") ||
250 changedProperties.has("isThinking")
251 ) {
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700252 this.scrollToBottom();
253 }
254 }
255
256 private scrollToBottom() {
257 // Use requestAnimationFrame to ensure DOM is updated
258 requestAnimationFrame(() => {
259 if (this.scrollContainer.value) {
Autoformatterf825e692025-06-07 04:19:43 +0000260 this.scrollContainer.value.scrollTop =
261 this.scrollContainer.value.scrollHeight;
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700262 }
263 });
264 }
265
266 private formatTime(timestamp: string): string {
267 const date = new Date(timestamp);
Autoformatterf825e692025-06-07 04:19:43 +0000268 return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700269 }
270
271 private getMessageRole(message: AgentMessage): string {
Autoformatterf825e692025-06-07 04:19:43 +0000272 if (message.type === "user") {
273 return "user";
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700274 }
philip.zeyliger41682632025-06-09 22:23:25 +0000275 if (message.type === "error") {
276 return "error";
277 }
Autoformatterf825e692025-06-07 04:19:43 +0000278 return "assistant";
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700279 }
280
281 private getMessageText(message: AgentMessage): string {
Autoformatterf825e692025-06-07 04:19:43 +0000282 return message.content || "";
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700283 }
284
285 private shouldShowMessage(message: AgentMessage): boolean {
Philip Zeyliger8bc681b2025-06-10 02:03:02 +0000286 // Filter out hidden messages (subconversations) like the regular UI does
287 if (message.hide_output) {
288 return false;
289 }
290
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700291 // Show user, agent, and error messages with content
Autoformatterf825e692025-06-07 04:19:43 +0000292 return (
293 (message.type === "user" ||
294 message.type === "agent" ||
295 message.type === "error") &&
296 message.content &&
297 message.content.trim().length > 0
298 );
Philip Zeyliger5f778942025-06-07 00:05:20 +0000299 }
300
301 private renderMarkdown(markdownContent: string): string {
302 try {
303 // Create a custom renderer for mobile-optimized rendering
304 const renderer = new Renderer();
Autoformatterf825e692025-06-07 04:19:43 +0000305
Philip Zeyliger5f778942025-06-07 00:05:20 +0000306 // Override code renderer to simplify for mobile
Autoformatterf825e692025-06-07 04:19:43 +0000307 renderer.code = function ({
308 text,
309 lang,
310 }: {
311 text: string;
312 lang?: string;
313 }): string {
Philip Zeyliger5f778942025-06-07 00:05:20 +0000314 const langClass = lang ? ` class="language-${lang}"` : "";
315 return `<pre><code${langClass}>${text}</code></pre>`;
316 };
317
318 // Set markdown options for mobile
319 const markedOptions: MarkedOptions = {
320 gfm: true, // GitHub Flavored Markdown
321 breaks: true, // Convert newlines to <br>
322 async: false,
323 renderer: renderer,
324 };
325
326 // Parse markdown and sanitize the output HTML
327 const htmlOutput = marked.parse(markdownContent, markedOptions) as string;
328 return DOMPurify.sanitize(htmlOutput, {
329 ALLOWED_TAGS: [
Autoformatterf825e692025-06-07 04:19:43 +0000330 "p",
331 "br",
332 "strong",
333 "em",
334 "b",
335 "i",
336 "u",
337 "s",
338 "code",
339 "pre",
340 "h1",
341 "h2",
342 "h3",
343 "h4",
344 "h5",
345 "h6",
346 "ul",
347 "ol",
348 "li",
349 "blockquote",
350 "a",
Philip Zeyliger5f778942025-06-07 00:05:20 +0000351 ],
352 ALLOWED_ATTR: ["href", "title", "target", "rel", "class"],
353 KEEP_CONTENT: true,
354 });
355 } catch (error) {
356 console.error("Error rendering markdown:", error);
357 // Fallback to sanitized plain text if markdown parsing fails
358 return DOMPurify.sanitize(markdownContent);
359 }
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700360 }
361
362 render() {
Autoformatterf825e692025-06-07 04:19:43 +0000363 const displayMessages = this.messages.filter((msg) =>
364 this.shouldShowMessage(msg),
365 );
366
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700367 return html`
368 <div class="chat-container" ${ref(this.scrollContainer)}>
Autoformatterf825e692025-06-07 04:19:43 +0000369 ${displayMessages.length === 0
370 ? html`
371 <div class="empty-state">Start a conversation with Sketch...</div>
372 `
373 : displayMessages.map((message) => {
374 const role = this.getMessageRole(message);
375 const text = this.getMessageText(message);
376 const timestamp = message.timestamp;
377
378 return html`
379 <div class="message ${role}">
380 <div class="message-bubble">
381 ${role === "assistant"
382 ? html`<div class="markdown-content">
383 ${unsafeHTML(this.renderMarkdown(text))}
384 </div>`
385 : text}
386 </div>
387 </div>
388 `;
389 })}
390 ${this.isThinking
391 ? html`
392 <div class="thinking-message">
393 <div class="thinking-bubble">
394 <span class="thinking-text">Sketch is thinking</span>
395 <div class="thinking-dots">
396 <div class="thinking-dot"></div>
397 <div class="thinking-dot"></div>
398 <div class="thinking-dot"></div>
399 </div>
400 </div>
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700401 </div>
Autoformatterf825e692025-06-07 04:19:43 +0000402 `
403 : ""}
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700404 </div>
405 `;
406 }
407}