blob: 96671558541a7299bbd4ee33a7f4ccf01fb5601c [file] [log] [blame]
Sean McCullough86b56862025-04-18 13:04:03 -07001import { css, html, LitElement, PropertyValues } from "lit";
Philip Zeyliger73db6052025-04-23 13:09:07 -07002import { customElement, property, state, query } from "lit/decorators.js";
Sean McCullough86b56862025-04-18 13:04:03 -07003
4@customElement("sketch-chat-input")
5export class SketchChatInput extends LitElement {
Philip Zeyliger73db6052025-04-23 13:09:07 -07006 @state()
Sean McCullough86b56862025-04-18 13:04:03 -07007 content: string = "";
8
Pokey Rule339b56e2025-05-15 14:48:07 +00009 @state()
10 isDraggingOver: boolean = false;
11
Pokey Rule044a62e2025-05-16 10:40:59 +000012 @state()
13 uploadsInProgress: number = 0;
14
15 @state()
16 showUploadInProgressMessage: boolean = false;
17
Sean McCullough86b56862025-04-18 13:04:03 -070018 // See https://lit.dev/docs/components/styles/ for how lit-element handles CSS.
19 // Note that these styles only apply to the scope of this web component's
20 // shadow DOM node, so they won't leak out or collide with CSS declared in
21 // other components or the containing web page (...unless you want it to do that).
22 static styles = css`
23 /* Chat styles - exactly matching timeline.css */
24 .chat-container {
Sean McCullough86b56862025-04-18 13:04:03 -070025 width: 100%;
26 background: #f0f0f0;
27 padding: 15px;
Sean McCullough86b56862025-04-18 13:04:03 -070028 min-height: 40px; /* Ensure minimum height */
Pokey Rule339b56e2025-05-15 14:48:07 +000029 position: relative;
Sean McCullough86b56862025-04-18 13:04:03 -070030 }
31
32 .chat-input-wrapper {
33 display: flex;
34 max-width: 1200px;
35 margin: 0 auto;
36 gap: 10px;
37 }
38
39 #chatInput {
40 flex: 1;
41 padding: 12px;
42 border: 1px solid #ddd;
43 border-radius: 4px;
Sean McCullough07b3e392025-04-21 22:51:14 +000044 resize: vertical;
Sean McCullough86b56862025-04-18 13:04:03 -070045 font-family: monospace;
46 font-size: 12px;
47 min-height: 40px;
Sean McCullough07b3e392025-04-21 22:51:14 +000048 max-height: 300px;
Sean McCullough86b56862025-04-18 13:04:03 -070049 background: #f7f7f7;
Sean McCullough5164eee2025-04-21 18:20:23 -070050 overflow-y: auto;
Sean McCullough07b3e392025-04-21 22:51:14 +000051 box-sizing: border-box; /* Ensure padding is included in height calculation */
52 line-height: 1.4; /* Consistent line height for better height calculation */
Sean McCullough86b56862025-04-18 13:04:03 -070053 }
54
55 #sendChatButton {
56 background-color: #2196f3;
57 color: white;
58 border: none;
59 border-radius: 4px;
60 padding: 0 20px;
61 cursor: pointer;
62 font-weight: 600;
Pokey Rule97188fc2025-04-23 15:50:04 +010063 align-self: center;
64 height: 40px;
Sean McCullough86b56862025-04-18 13:04:03 -070065 }
66
67 #sendChatButton:hover {
68 background-color: #0d8bf2;
69 }
Pokey Rule339b56e2025-05-15 14:48:07 +000070
Pokey Rule044a62e2025-05-16 10:40:59 +000071 #sendChatButton:disabled {
72 background-color: #b0b0b0;
73 cursor: not-allowed;
74 }
75
Pokey Rule339b56e2025-05-15 14:48:07 +000076 /* Drop zone styling */
77 .drop-zone-overlay {
78 position: absolute;
79 top: 0;
80 left: 0;
81 right: 0;
82 bottom: 0;
83 background-color: rgba(33, 150, 243, 0.1);
84 border: 2px dashed #2196f3;
85 border-radius: 4px;
86 display: flex;
87 justify-content: center;
88 align-items: center;
89 z-index: 10;
90 pointer-events: none;
91 }
92
Pokey Rule044a62e2025-05-16 10:40:59 +000093 .drop-zone-message,
94 .upload-progress-message {
Pokey Rule339b56e2025-05-15 14:48:07 +000095 background-color: #ffffff;
96 padding: 15px 20px;
97 border-radius: 4px;
98 font-weight: 600;
99 box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
100 }
Pokey Rule044a62e2025-05-16 10:40:59 +0000101
102 .upload-progress-message {
103 position: absolute;
104 bottom: 70px;
105 left: 50%;
106 transform: translateX(-50%);
107 background-color: #fff9c4;
108 border: 1px solid #fbc02d;
109 z-index: 20;
110 font-size: 14px;
111 animation: fadeIn 0.3s ease-in-out;
112 }
113
114 @keyframes fadeIn {
115 from {
116 opacity: 0;
117 transform: translateX(-50%) translateY(10px);
118 }
119 to {
120 opacity: 1;
121 transform: translateX(-50%) translateY(0);
122 }
123 }
Sean McCullough86b56862025-04-18 13:04:03 -0700124 `;
125
126 constructor() {
127 super();
Philip Zeyliger73db6052025-04-23 13:09:07 -0700128 this._handleDiffComment = this._handleDiffComment.bind(this);
Josh Bleecher Snyderbd52faf2025-06-02 21:21:37 +0000129 this._handleTodoComment = this._handleTodoComment.bind(this);
Pokey Rule339b56e2025-05-15 14:48:07 +0000130 this._handleDragOver = this._handleDragOver.bind(this);
131 this._handleDragEnter = this._handleDragEnter.bind(this);
132 this._handleDragLeave = this._handleDragLeave.bind(this);
133 this._handleDrop = this._handleDrop.bind(this);
Sean McCullough86b56862025-04-18 13:04:03 -0700134 }
135
Sean McCullough86b56862025-04-18 13:04:03 -0700136 connectedCallback() {
137 super.connectedCallback();
Philip Zeyliger73db6052025-04-23 13:09:07 -0700138 window.addEventListener("diff-comment", this._handleDiffComment);
Josh Bleecher Snyderbd52faf2025-06-02 21:21:37 +0000139 window.addEventListener("todo-comment", this._handleTodoComment);
Philip Zeyliger73db6052025-04-23 13:09:07 -0700140 }
141
Pokey Rule339b56e2025-05-15 14:48:07 +0000142 // Utility function to handle file uploads (used by both paste and drop handlers)
143 private async _uploadFile(file: File, insertPosition: number) {
144 // Insert a placeholder at the cursor position
145 const textBefore = this.content.substring(0, insertPosition);
146 const textAfter = this.content.substring(insertPosition);
147
Pokey Rule044a62e2025-05-16 10:40:59 +0000148 // Add a loading indicator with a visual cue
149 const loadingText = `[🔄 Uploading ${file.name}...]`;
Pokey Rule339b56e2025-05-15 14:48:07 +0000150 this.content = `${textBefore}${loadingText}${textAfter}`;
151
Pokey Rule044a62e2025-05-16 10:40:59 +0000152 // Increment uploads in progress counter
153 this.uploadsInProgress++;
154
Pokey Rule339b56e2025-05-15 14:48:07 +0000155 // Adjust spacing immediately to show loading indicator
156 requestAnimationFrame(() => this.adjustChatSpacing());
157
158 try {
159 // Create a FormData object to send the file
160 const formData = new FormData();
161 formData.append("file", file);
162
163 // Upload the file to the server using a relative path
164 const response = await fetch("./upload", {
165 method: "POST",
166 body: formData,
167 });
168
169 if (!response.ok) {
170 throw new Error(`Upload failed: ${response.statusText}`);
171 }
172
173 const data = await response.json();
174
175 // Replace the loading placeholder with the actual file path
176 this.content = this.content.replace(loadingText, `[${data.path}]`);
177
Pokey Rule339b56e2025-05-15 14:48:07 +0000178 return data.path;
179 } catch (error) {
180 console.error("Failed to upload file:", error);
181
182 // Replace loading indicator with error message
183 const errorText = `[Upload failed: ${error.message}]`;
184 this.content = this.content.replace(loadingText, errorText);
185
186 // Adjust spacing to show error message
187 requestAnimationFrame(() => {
188 this.adjustChatSpacing();
189 this.chatInput.focus();
190 });
191
192 throw error;
Pokey Rule044a62e2025-05-16 10:40:59 +0000193 } finally {
194 // Always decrement the counter, even if there was an error
195 this.uploadsInProgress--;
Pokey Rule339b56e2025-05-15 14:48:07 +0000196 }
197 }
198
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000199 // Handle paste events for files (including images)
200 private _handlePaste = async (event: ClipboardEvent) => {
201 // Check if the clipboard contains files
202 if (event.clipboardData && event.clipboardData.files.length > 0) {
203 const file = event.clipboardData.files[0];
204
205 // Handle the file upload (for any file type, not just images)
206 event.preventDefault(); // Prevent default paste behavior
207
Pokey Rule339b56e2025-05-15 14:48:07 +0000208 // Get the current cursor position
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000209 const cursorPos = this.chatInput.selectionStart;
Pokey Rule339b56e2025-05-15 14:48:07 +0000210 await this._uploadFile(file, cursorPos);
211 }
212 };
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000213
Pokey Rule339b56e2025-05-15 14:48:07 +0000214 // Handle drag events for file drop operation
215 private _handleDragOver(event: DragEvent) {
216 event.preventDefault(); // Necessary to allow dropping
217 event.stopPropagation();
218 }
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000219
Pokey Rule339b56e2025-05-15 14:48:07 +0000220 private _handleDragEnter(event: DragEvent) {
221 event.preventDefault();
222 event.stopPropagation();
223 this.isDraggingOver = true;
224 }
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000225
Pokey Rule339b56e2025-05-15 14:48:07 +0000226 private _handleDragLeave(event: DragEvent) {
227 event.preventDefault();
228 event.stopPropagation();
229 // Only set to false if we're leaving the container (not entering a child element)
230 if (event.target === this.renderRoot.querySelector(".chat-container")) {
231 this.isDraggingOver = false;
232 }
233 }
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000234
Pokey Rule339b56e2025-05-15 14:48:07 +0000235 private _handleDrop = async (event: DragEvent) => {
236 event.preventDefault();
237 event.stopPropagation();
238 this.isDraggingOver = false;
239
240 // Check if the dataTransfer contains files
241 if (event.dataTransfer && event.dataTransfer.files.length > 0) {
242 // Process all dropped files
243 for (let i = 0; i < event.dataTransfer.files.length; i++) {
244 const file = event.dataTransfer.files[i];
245 try {
246 // For the first file, insert at the cursor position
247 // For subsequent files, append at the end of the content
248 const insertPosition =
249 i === 0 ? this.chatInput.selectionStart : this.content.length;
250 await this._uploadFile(file, insertPosition);
251
252 // Add a space between multiple files
253 if (i < event.dataTransfer.files.length - 1) {
254 this.content += " ";
255 }
256 } catch (error) {
257 // Error already handled in _uploadFile
258 console.error("Failed to process dropped file:", error);
259 // Continue with the next file
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000260 }
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000261 }
262 }
263 };
264
Philip Zeyliger73db6052025-04-23 13:09:07 -0700265 private _handleDiffComment(event: CustomEvent) {
266 const { comment } = event.detail;
267 if (!comment) return;
268
269 if (this.content != "") {
270 this.content += "\n\n";
271 }
272 this.content += comment;
273 requestAnimationFrame(() => this.adjustChatSpacing());
Sean McCullough86b56862025-04-18 13:04:03 -0700274 }
275
Josh Bleecher Snyderbd52faf2025-06-02 21:21:37 +0000276 private _handleTodoComment(event: CustomEvent) {
277 const { comment } = event.detail;
278 if (!comment) return;
279
280 if (this.content != "") {
281 this.content += "\n\n";
282 }
283 this.content += comment;
284 requestAnimationFrame(() => this.adjustChatSpacing());
285 }
286
Sean McCullough86b56862025-04-18 13:04:03 -0700287 // See https://lit.dev/docs/components/lifecycle/
288 disconnectedCallback() {
289 super.disconnectedCallback();
Philip Zeyliger73db6052025-04-23 13:09:07 -0700290 window.removeEventListener("diff-comment", this._handleDiffComment);
Josh Bleecher Snyderbd52faf2025-06-02 21:21:37 +0000291 window.removeEventListener("todo-comment", this._handleTodoComment);
Pokey Rule339b56e2025-05-15 14:48:07 +0000292
293 // Clean up drag and drop event listeners
294 const container = this.renderRoot.querySelector(".chat-container");
295 if (container) {
296 container.removeEventListener("dragover", this._handleDragOver);
297 container.removeEventListener("dragenter", this._handleDragEnter);
298 container.removeEventListener("dragleave", this._handleDragLeave);
299 container.removeEventListener("drop", this._handleDrop);
300 }
301
302 // Clean up paste event listener
303 if (this.chatInput) {
304 this.chatInput.removeEventListener("paste", this._handlePaste);
305 }
Sean McCullough86b56862025-04-18 13:04:03 -0700306 }
307
308 sendChatMessage() {
Pokey Rule044a62e2025-05-16 10:40:59 +0000309 // Prevent sending if there are uploads in progress
310 if (this.uploadsInProgress > 0) {
311 console.log(
312 `Message send prevented: ${this.uploadsInProgress} uploads in progress`,
313 );
Philip Zeyliger73db6052025-04-23 13:09:07 -0700314
Pokey Rule044a62e2025-05-16 10:40:59 +0000315 // Show message to user
316 this.showUploadInProgressMessage = true;
317
318 // Hide the message after 3 seconds
319 setTimeout(() => {
320 this.showUploadInProgressMessage = false;
321 }, 3000);
322
323 return;
324 }
325
326 // Only send if there's actual content (not just whitespace)
327 if (this.content.trim()) {
328 const event = new CustomEvent("send-chat", {
329 detail: { message: this.content },
330 bubbles: true,
331 composed: true,
332 });
333 this.dispatchEvent(event);
334
335 // TODO(philip?): Ideally we only clear the content if the send is successful.
336 this.content = ""; // Clear content after sending
337 }
Sean McCullough86b56862025-04-18 13:04:03 -0700338 }
339
340 adjustChatSpacing() {
Sean McCullough07b3e392025-04-21 22:51:14 +0000341 if (!this.chatInput) return;
Sean McCullough5164eee2025-04-21 18:20:23 -0700342
Sean McCullough07b3e392025-04-21 22:51:14 +0000343 // Reset height to minimal value to correctly calculate scrollHeight
Sean McCullough5164eee2025-04-21 18:20:23 -0700344 this.chatInput.style.height = "auto";
345
Sean McCullough07b3e392025-04-21 22:51:14 +0000346 // Get the scroll height (content height)
347 const scrollHeight = this.chatInput.scrollHeight;
Sean McCullough5164eee2025-04-21 18:20:23 -0700348
Sean McCullough07b3e392025-04-21 22:51:14 +0000349 // Set the height to match content (up to max-height which is handled by CSS)
350 this.chatInput.style.height = `${scrollHeight}px`;
Sean McCullough86b56862025-04-18 13:04:03 -0700351 }
352
Philip Zeyliger73db6052025-04-23 13:09:07 -0700353 async _sendChatClicked() {
Sean McCullough86b56862025-04-18 13:04:03 -0700354 this.sendChatMessage();
355 this.chatInput.focus(); // Refocus the input after sending
Sean McCullough07b3e392025-04-21 22:51:14 +0000356 // Reset height after sending a message
357 requestAnimationFrame(() => this.adjustChatSpacing());
Sean McCullough86b56862025-04-18 13:04:03 -0700358 }
359
360 _chatInputKeyDown(event: KeyboardEvent) {
361 // Send message if Enter is pressed without Shift key
362 if (event.key === "Enter" && !event.shiftKey) {
363 event.preventDefault(); // Prevent default newline
364 this.sendChatMessage();
365 }
366 }
367
368 _chatInputChanged(event) {
369 this.content = event.target.value;
Sean McCullough07b3e392025-04-21 22:51:14 +0000370 // Use requestAnimationFrame to ensure DOM updates have completed
Sean McCullough86b56862025-04-18 13:04:03 -0700371 requestAnimationFrame(() => this.adjustChatSpacing());
372 }
373
374 @query("#chatInput")
Sean McCullough07b3e392025-04-21 22:51:14 +0000375 chatInput: HTMLTextAreaElement;
Sean McCullough86b56862025-04-18 13:04:03 -0700376
377 protected firstUpdated(): void {
378 if (this.chatInput) {
379 this.chatInput.focus();
Sean McCullough07b3e392025-04-21 22:51:14 +0000380 // Initialize the input height
381 this.adjustChatSpacing();
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000382
383 // Add paste event listener for image handling
384 this.chatInput.addEventListener("paste", this._handlePaste);
Pokey Rule339b56e2025-05-15 14:48:07 +0000385
386 // Add drag and drop event listeners
387 const container = this.renderRoot.querySelector(".chat-container");
388 if (container) {
389 container.addEventListener("dragover", this._handleDragOver);
390 container.addEventListener("dragenter", this._handleDragEnter);
391 container.addEventListener("dragleave", this._handleDragLeave);
392 container.addEventListener("drop", this._handleDrop);
393 }
Sean McCullough86b56862025-04-18 13:04:03 -0700394 }
Josh Bleecher Snydere2c7f722025-05-01 21:58:41 +0000395
396 // Add window.onload handler to ensure the input is focused when the page fully loads
397 window.addEventListener(
398 "load",
399 () => {
400 if (this.chatInput) {
401 this.chatInput.focus();
402 }
403 },
404 { once: true },
405 );
Sean McCullough86b56862025-04-18 13:04:03 -0700406 }
407
408 render() {
409 return html`
410 <div class="chat-container">
411 <div class="chat-input-wrapper">
412 <textarea
413 id="chatInput"
414 placeholder="Type your message here and press Enter to send..."
415 autofocus
416 @keydown="${this._chatInputKeyDown}"
417 @input="${this._chatInputChanged}"
418 .value=${this.content || ""}
419 ></textarea>
Pokey Rule044a62e2025-05-16 10:40:59 +0000420 <button
421 @click="${this._sendChatClicked}"
422 id="sendChatButton"
423 ?disabled=${this.uploadsInProgress > 0}
424 >
425 ${this.uploadsInProgress > 0 ? "Uploading..." : "Send"}
Sean McCullough86b56862025-04-18 13:04:03 -0700426 </button>
427 </div>
Pokey Rule339b56e2025-05-15 14:48:07 +0000428 ${this.isDraggingOver
429 ? html`
430 <div class="drop-zone-overlay">
431 <div class="drop-zone-message">Drop files here</div>
432 </div>
433 `
434 : ""}
Pokey Rule044a62e2025-05-16 10:40:59 +0000435 ${this.showUploadInProgressMessage
436 ? html`
437 <div class="upload-progress-message">
438 Please wait for file upload to complete before sending
439 </div>
440 `
441 : ""}
Sean McCullough86b56862025-04-18 13:04:03 -0700442 </div>
443 `;
444 }
445}
446
447declare global {
448 interface HTMLElementTagNameMap {
449 "sketch-chat-input": SketchChatInput;
450 }
451}