blob: e559d6a2c7e82886caac817918b455102aa3fef2 [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
73 .thinking-message {
74 align-self: flex-start;
75 align-items: flex-start;
76 max-width: 85%;
77 }
78
79 .thinking-bubble {
80 background-color: #f1f3f4;
81 padding: 16px;
82 border-radius: 18px;
83 border-bottom-left-radius: 6px;
84 display: flex;
85 align-items: center;
86 gap: 8px;
87 }
88
89 .thinking-text {
90 color: #6c757d;
91 font-style: italic;
92 }
93
94 .thinking-dots {
95 display: flex;
96 gap: 3px;
97 }
98
99 .thinking-dot {
100 width: 6px;
101 height: 6px;
102 border-radius: 50%;
103 background-color: #6c757d;
104 animation: thinking 1.4s ease-in-out infinite both;
105 }
106
Autoformatterf825e692025-06-07 04:19:43 +0000107 .thinking-dot:nth-child(1) {
108 animation-delay: -0.32s;
109 }
110 .thinking-dot:nth-child(2) {
111 animation-delay: -0.16s;
112 }
113 .thinking-dot:nth-child(3) {
114 animation-delay: 0;
115 }
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700116
117 @keyframes thinking {
Autoformatterf825e692025-06-07 04:19:43 +0000118 0%,
119 80%,
120 100% {
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700121 transform: scale(0.8);
122 opacity: 0.5;
123 }
124 40% {
125 transform: scale(1);
126 opacity: 1;
127 }
128 }
129
130 .empty-state {
131 flex: 1;
132 display: flex;
133 align-items: center;
134 justify-content: center;
135 color: #6c757d;
136 font-style: italic;
137 text-align: center;
138 padding: 32px;
139 }
Philip Zeyliger5f778942025-06-07 00:05:20 +0000140
141 /* Markdown content styling for mobile */
142 .markdown-content {
143 line-height: 1.5;
144 word-wrap: break-word;
145 overflow-wrap: break-word;
146 }
147
148 .markdown-content p {
149 margin: 0.3em 0;
150 }
151
152 .markdown-content p:first-child {
153 margin-top: 0;
154 }
155
156 .markdown-content p:last-child {
157 margin-bottom: 0;
158 }
159
160 .markdown-content h1,
161 .markdown-content h2,
162 .markdown-content h3,
163 .markdown-content h4,
164 .markdown-content h5,
165 .markdown-content h6 {
166 margin: 0.5em 0 0.3em 0;
167 font-weight: bold;
168 }
169
Autoformatterf825e692025-06-07 04:19:43 +0000170 .markdown-content h1 {
171 font-size: 1.2em;
172 }
173 .markdown-content h2 {
174 font-size: 1.15em;
175 }
176 .markdown-content h3 {
177 font-size: 1.1em;
178 }
Philip Zeyliger5f778942025-06-07 00:05:20 +0000179 .markdown-content h4,
180 .markdown-content h5,
Autoformatterf825e692025-06-07 04:19:43 +0000181 .markdown-content h6 {
182 font-size: 1.05em;
183 }
Philip Zeyliger5f778942025-06-07 00:05:20 +0000184
185 .markdown-content code {
186 background-color: rgba(0, 0, 0, 0.08);
187 padding: 2px 4px;
188 border-radius: 3px;
Autoformatterf825e692025-06-07 04:19:43 +0000189 font-family: "Monaco", "Menlo", "Ubuntu Mono", monospace;
Philip Zeyliger5f778942025-06-07 00:05:20 +0000190 font-size: 0.9em;
191 }
192
193 .markdown-content pre {
194 background-color: rgba(0, 0, 0, 0.08);
195 padding: 8px;
196 border-radius: 6px;
197 margin: 0.5em 0;
198 overflow-x: auto;
199 font-size: 0.9em;
200 }
201
202 .markdown-content pre code {
203 background: none;
204 padding: 0;
205 }
206
207 .markdown-content ul,
208 .markdown-content ol {
209 margin: 0.5em 0;
210 padding-left: 1.2em;
211 }
212
213 .markdown-content li {
214 margin: 0.2em 0;
215 }
216
217 .markdown-content blockquote {
218 border-left: 3px solid rgba(0, 0, 0, 0.2);
219 margin: 0.5em 0;
220 padding-left: 0.8em;
221 font-style: italic;
222 }
223
224 .markdown-content a {
225 color: inherit;
226 text-decoration: underline;
227 }
228
229 .markdown-content strong,
230 .markdown-content b {
231 font-weight: bold;
232 }
233
234 .markdown-content em,
235 .markdown-content i {
236 font-style: italic;
237 }
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700238 `;
239
240 updated(changedProperties: Map<string, any>) {
241 super.updated(changedProperties);
Autoformatterf825e692025-06-07 04:19:43 +0000242 if (
243 changedProperties.has("messages") ||
244 changedProperties.has("isThinking")
245 ) {
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700246 this.scrollToBottom();
247 }
248 }
249
250 private scrollToBottom() {
251 // Use requestAnimationFrame to ensure DOM is updated
252 requestAnimationFrame(() => {
253 if (this.scrollContainer.value) {
Autoformatterf825e692025-06-07 04:19:43 +0000254 this.scrollContainer.value.scrollTop =
255 this.scrollContainer.value.scrollHeight;
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700256 }
257 });
258 }
259
260 private formatTime(timestamp: string): string {
261 const date = new Date(timestamp);
Autoformatterf825e692025-06-07 04:19:43 +0000262 return date.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700263 }
264
265 private getMessageRole(message: AgentMessage): string {
Autoformatterf825e692025-06-07 04:19:43 +0000266 if (message.type === "user") {
267 return "user";
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700268 }
Autoformatterf825e692025-06-07 04:19:43 +0000269 return "assistant";
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700270 }
271
272 private getMessageText(message: AgentMessage): string {
Autoformatterf825e692025-06-07 04:19:43 +0000273 return message.content || "";
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700274 }
275
276 private shouldShowMessage(message: AgentMessage): boolean {
277 // Show user, agent, and error messages with content
Autoformatterf825e692025-06-07 04:19:43 +0000278 return (
279 (message.type === "user" ||
280 message.type === "agent" ||
281 message.type === "error") &&
282 message.content &&
283 message.content.trim().length > 0
284 );
Philip Zeyliger5f778942025-06-07 00:05:20 +0000285 }
286
287 private renderMarkdown(markdownContent: string): string {
288 try {
289 // Create a custom renderer for mobile-optimized rendering
290 const renderer = new Renderer();
Autoformatterf825e692025-06-07 04:19:43 +0000291
Philip Zeyliger5f778942025-06-07 00:05:20 +0000292 // Override code renderer to simplify for mobile
Autoformatterf825e692025-06-07 04:19:43 +0000293 renderer.code = function ({
294 text,
295 lang,
296 }: {
297 text: string;
298 lang?: string;
299 }): string {
Philip Zeyliger5f778942025-06-07 00:05:20 +0000300 const langClass = lang ? ` class="language-${lang}"` : "";
301 return `<pre><code${langClass}>${text}</code></pre>`;
302 };
303
304 // Set markdown options for mobile
305 const markedOptions: MarkedOptions = {
306 gfm: true, // GitHub Flavored Markdown
307 breaks: true, // Convert newlines to <br>
308 async: false,
309 renderer: renderer,
310 };
311
312 // Parse markdown and sanitize the output HTML
313 const htmlOutput = marked.parse(markdownContent, markedOptions) as string;
314 return DOMPurify.sanitize(htmlOutput, {
315 ALLOWED_TAGS: [
Autoformatterf825e692025-06-07 04:19:43 +0000316 "p",
317 "br",
318 "strong",
319 "em",
320 "b",
321 "i",
322 "u",
323 "s",
324 "code",
325 "pre",
326 "h1",
327 "h2",
328 "h3",
329 "h4",
330 "h5",
331 "h6",
332 "ul",
333 "ol",
334 "li",
335 "blockquote",
336 "a",
Philip Zeyliger5f778942025-06-07 00:05:20 +0000337 ],
338 ALLOWED_ATTR: ["href", "title", "target", "rel", "class"],
339 KEEP_CONTENT: true,
340 });
341 } catch (error) {
342 console.error("Error rendering markdown:", error);
343 // Fallback to sanitized plain text if markdown parsing fails
344 return DOMPurify.sanitize(markdownContent);
345 }
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700346 }
347
348 render() {
Autoformatterf825e692025-06-07 04:19:43 +0000349 const displayMessages = this.messages.filter((msg) =>
350 this.shouldShowMessage(msg),
351 );
352
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700353 return html`
354 <div class="chat-container" ${ref(this.scrollContainer)}>
Autoformatterf825e692025-06-07 04:19:43 +0000355 ${displayMessages.length === 0
356 ? html`
357 <div class="empty-state">Start a conversation with Sketch...</div>
358 `
359 : displayMessages.map((message) => {
360 const role = this.getMessageRole(message);
361 const text = this.getMessageText(message);
362 const timestamp = message.timestamp;
363
364 return html`
365 <div class="message ${role}">
366 <div class="message-bubble">
367 ${role === "assistant"
368 ? html`<div class="markdown-content">
369 ${unsafeHTML(this.renderMarkdown(text))}
370 </div>`
371 : text}
372 </div>
373 </div>
374 `;
375 })}
376 ${this.isThinking
377 ? html`
378 <div class="thinking-message">
379 <div class="thinking-bubble">
380 <span class="thinking-text">Sketch is thinking</span>
381 <div class="thinking-dots">
382 <div class="thinking-dot"></div>
383 <div class="thinking-dot"></div>
384 <div class="thinking-dot"></div>
385 </div>
386 </div>
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700387 </div>
Autoformatterf825e692025-06-07 04:19:43 +0000388 `
389 : ""}
Philip Zeyligere08c7ff2025-06-06 13:22:12 -0700390 </div>
391 `;
392 }
393}