blob: ae6ec866ac71a95140480a77d0369ce9db57524d [file] [log] [blame]
Sean McCulloughb3795922025-06-27 01:59:41 +00001import { html } from "lit";
philip.zeyliger26bc6592025-06-30 20:15:30 -07002import { customElement, state, query } from "lit/decorators.js";
Sean McCulloughb3795922025-06-27 01:59:41 +00003import { SketchTailwindElement } from "./sketch-tailwind-element.js";
Sean McCullough86b56862025-04-18 13:04:03 -07004
5@customElement("sketch-chat-input")
Sean McCulloughb3795922025-06-27 01:59:41 +00006export class SketchChatInput extends SketchTailwindElement {
Philip Zeyliger73db6052025-04-23 13:09:07 -07007 @state()
Sean McCullough86b56862025-04-18 13:04:03 -07008 content: string = "";
9
Pokey Rule339b56e2025-05-15 14:48:07 +000010 @state()
11 isDraggingOver: boolean = false;
12
Pokey Rule044a62e2025-05-16 10:40:59 +000013 @state()
14 uploadsInProgress: number = 0;
15
16 @state()
17 showUploadInProgressMessage: boolean = false;
18
Sean McCullough86b56862025-04-18 13:04:03 -070019 constructor() {
20 super();
Philip Zeyliger73db6052025-04-23 13:09:07 -070021 this._handleDiffComment = this._handleDiffComment.bind(this);
Josh Bleecher Snyderbd52faf2025-06-02 21:21:37 +000022 this._handleTodoComment = this._handleTodoComment.bind(this);
Pokey Rule339b56e2025-05-15 14:48:07 +000023 this._handleDragOver = this._handleDragOver.bind(this);
24 this._handleDragEnter = this._handleDragEnter.bind(this);
25 this._handleDragLeave = this._handleDragLeave.bind(this);
26 this._handleDrop = this._handleDrop.bind(this);
Sean McCullough86b56862025-04-18 13:04:03 -070027 }
28
Sean McCullough86b56862025-04-18 13:04:03 -070029 connectedCallback() {
30 super.connectedCallback();
Philip Zeyliger73db6052025-04-23 13:09:07 -070031 window.addEventListener("diff-comment", this._handleDiffComment);
Josh Bleecher Snyderbd52faf2025-06-02 21:21:37 +000032 window.addEventListener("todo-comment", this._handleTodoComment);
Philip Zeyliger73db6052025-04-23 13:09:07 -070033 }
34
Pokey Rule339b56e2025-05-15 14:48:07 +000035 // Utility function to handle file uploads (used by both paste and drop handlers)
36 private async _uploadFile(file: File, insertPosition: number) {
37 // Insert a placeholder at the cursor position
38 const textBefore = this.content.substring(0, insertPosition);
39 const textAfter = this.content.substring(insertPosition);
40
Pokey Rule044a62e2025-05-16 10:40:59 +000041 // Add a loading indicator with a visual cue
42 const loadingText = `[🔄 Uploading ${file.name}...]`;
Pokey Rule339b56e2025-05-15 14:48:07 +000043 this.content = `${textBefore}${loadingText}${textAfter}`;
44
Pokey Rule044a62e2025-05-16 10:40:59 +000045 // Increment uploads in progress counter
46 this.uploadsInProgress++;
47
Pokey Rule339b56e2025-05-15 14:48:07 +000048 // Adjust spacing immediately to show loading indicator
49 requestAnimationFrame(() => this.adjustChatSpacing());
50
51 try {
52 // Create a FormData object to send the file
53 const formData = new FormData();
54 formData.append("file", file);
55
56 // Upload the file to the server using a relative path
57 const response = await fetch("./upload", {
58 method: "POST",
59 body: formData,
60 });
61
62 if (!response.ok) {
63 throw new Error(`Upload failed: ${response.statusText}`);
64 }
65
66 const data = await response.json();
67
68 // Replace the loading placeholder with the actual file path
69 this.content = this.content.replace(loadingText, `[${data.path}]`);
70
Pokey Rule339b56e2025-05-15 14:48:07 +000071 return data.path;
72 } catch (error) {
73 console.error("Failed to upload file:", error);
74
75 // Replace loading indicator with error message
76 const errorText = `[Upload failed: ${error.message}]`;
77 this.content = this.content.replace(loadingText, errorText);
78
79 // Adjust spacing to show error message
80 requestAnimationFrame(() => {
81 this.adjustChatSpacing();
82 this.chatInput.focus();
83 });
84
85 throw error;
Pokey Rule044a62e2025-05-16 10:40:59 +000086 } finally {
87 // Always decrement the counter, even if there was an error
88 this.uploadsInProgress--;
Pokey Rule339b56e2025-05-15 14:48:07 +000089 }
90 }
91
Philip Zeyligerf84e88c2025-05-14 23:19:01 +000092 // Handle paste events for files (including images)
93 private _handlePaste = async (event: ClipboardEvent) => {
94 // Check if the clipboard contains files
95 if (event.clipboardData && event.clipboardData.files.length > 0) {
96 const file = event.clipboardData.files[0];
97
98 // Handle the file upload (for any file type, not just images)
99 event.preventDefault(); // Prevent default paste behavior
100
Pokey Rule339b56e2025-05-15 14:48:07 +0000101 // Get the current cursor position
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000102 const cursorPos = this.chatInput.selectionStart;
Pokey Rule339b56e2025-05-15 14:48:07 +0000103 await this._uploadFile(file, cursorPos);
104 }
105 };
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000106
Pokey Rule339b56e2025-05-15 14:48:07 +0000107 // Handle drag events for file drop operation
108 private _handleDragOver(event: DragEvent) {
109 event.preventDefault(); // Necessary to allow dropping
110 event.stopPropagation();
111 }
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000112
Pokey Rule339b56e2025-05-15 14:48:07 +0000113 private _handleDragEnter(event: DragEvent) {
114 event.preventDefault();
115 event.stopPropagation();
116 this.isDraggingOver = true;
117 }
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000118
Pokey Rule339b56e2025-05-15 14:48:07 +0000119 private _handleDragLeave(event: DragEvent) {
120 event.preventDefault();
121 event.stopPropagation();
122 // Only set to false if we're leaving the container (not entering a child element)
Sean McCulloughb3795922025-06-27 01:59:41 +0000123 if (event.target === this.querySelector(".chat-container")) {
Pokey Rule339b56e2025-05-15 14:48:07 +0000124 this.isDraggingOver = false;
125 }
126 }
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000127
Pokey Rule339b56e2025-05-15 14:48:07 +0000128 private _handleDrop = async (event: DragEvent) => {
129 event.preventDefault();
130 event.stopPropagation();
131 this.isDraggingOver = false;
132
133 // Check if the dataTransfer contains files
134 if (event.dataTransfer && event.dataTransfer.files.length > 0) {
135 // Process all dropped files
136 for (let i = 0; i < event.dataTransfer.files.length; i++) {
137 const file = event.dataTransfer.files[i];
138 try {
139 // For the first file, insert at the cursor position
140 // For subsequent files, append at the end of the content
141 const insertPosition =
142 i === 0 ? this.chatInput.selectionStart : this.content.length;
143 await this._uploadFile(file, insertPosition);
144
145 // Add a space between multiple files
146 if (i < event.dataTransfer.files.length - 1) {
147 this.content += " ";
148 }
149 } catch (error) {
150 // Error already handled in _uploadFile
151 console.error("Failed to process dropped file:", error);
152 // Continue with the next file
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000153 }
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000154 }
155 }
156 };
157
Philip Zeyliger73db6052025-04-23 13:09:07 -0700158 private _handleDiffComment(event: CustomEvent) {
159 const { comment } = event.detail;
160 if (!comment) return;
161
162 if (this.content != "") {
163 this.content += "\n\n";
164 }
165 this.content += comment;
166 requestAnimationFrame(() => this.adjustChatSpacing());
Sean McCullough86b56862025-04-18 13:04:03 -0700167 }
168
Josh Bleecher Snyderbd52faf2025-06-02 21:21:37 +0000169 private _handleTodoComment(event: CustomEvent) {
170 const { comment } = event.detail;
171 if (!comment) return;
172
173 if (this.content != "") {
174 this.content += "\n\n";
175 }
176 this.content += comment;
177 requestAnimationFrame(() => this.adjustChatSpacing());
178 }
179
Sean McCullough86b56862025-04-18 13:04:03 -0700180 // See https://lit.dev/docs/components/lifecycle/
181 disconnectedCallback() {
182 super.disconnectedCallback();
Philip Zeyliger73db6052025-04-23 13:09:07 -0700183 window.removeEventListener("diff-comment", this._handleDiffComment);
Josh Bleecher Snyderbd52faf2025-06-02 21:21:37 +0000184 window.removeEventListener("todo-comment", this._handleTodoComment);
Pokey Rule339b56e2025-05-15 14:48:07 +0000185
186 // Clean up drag and drop event listeners
Sean McCulloughb3795922025-06-27 01:59:41 +0000187 const container = this.querySelector(".chat-container");
Pokey Rule339b56e2025-05-15 14:48:07 +0000188 if (container) {
189 container.removeEventListener("dragover", this._handleDragOver);
190 container.removeEventListener("dragenter", this._handleDragEnter);
191 container.removeEventListener("dragleave", this._handleDragLeave);
192 container.removeEventListener("drop", this._handleDrop);
193 }
194
195 // Clean up paste event listener
196 if (this.chatInput) {
197 this.chatInput.removeEventListener("paste", this._handlePaste);
198 }
Sean McCullough86b56862025-04-18 13:04:03 -0700199 }
200
201 sendChatMessage() {
Pokey Rule044a62e2025-05-16 10:40:59 +0000202 // Prevent sending if there are uploads in progress
203 if (this.uploadsInProgress > 0) {
204 console.log(
205 `Message send prevented: ${this.uploadsInProgress} uploads in progress`,
206 );
Philip Zeyliger73db6052025-04-23 13:09:07 -0700207
Pokey Rule044a62e2025-05-16 10:40:59 +0000208 // Show message to user
209 this.showUploadInProgressMessage = true;
210
211 // Hide the message after 3 seconds
212 setTimeout(() => {
213 this.showUploadInProgressMessage = false;
214 }, 3000);
215
216 return;
217 }
218
219 // Only send if there's actual content (not just whitespace)
220 if (this.content.trim()) {
221 const event = new CustomEvent("send-chat", {
222 detail: { message: this.content },
223 bubbles: true,
224 composed: true,
225 });
226 this.dispatchEvent(event);
227
228 // TODO(philip?): Ideally we only clear the content if the send is successful.
229 this.content = ""; // Clear content after sending
230 }
Sean McCullough86b56862025-04-18 13:04:03 -0700231 }
232
233 adjustChatSpacing() {
Sean McCullough07b3e392025-04-21 22:51:14 +0000234 if (!this.chatInput) return;
Sean McCullough5164eee2025-04-21 18:20:23 -0700235
Sean McCullough07b3e392025-04-21 22:51:14 +0000236 // Reset height to minimal value to correctly calculate scrollHeight
Sean McCullough5164eee2025-04-21 18:20:23 -0700237 this.chatInput.style.height = "auto";
238
Sean McCullough07b3e392025-04-21 22:51:14 +0000239 // Get the scroll height (content height)
240 const scrollHeight = this.chatInput.scrollHeight;
Sean McCullough5164eee2025-04-21 18:20:23 -0700241
Sean McCullough07b3e392025-04-21 22:51:14 +0000242 // Set the height to match content (up to max-height which is handled by CSS)
243 this.chatInput.style.height = `${scrollHeight}px`;
Sean McCullough86b56862025-04-18 13:04:03 -0700244 }
245
Philip Zeyliger73db6052025-04-23 13:09:07 -0700246 async _sendChatClicked() {
Sean McCullough86b56862025-04-18 13:04:03 -0700247 this.sendChatMessage();
248 this.chatInput.focus(); // Refocus the input after sending
Sean McCullough07b3e392025-04-21 22:51:14 +0000249 // Reset height after sending a message
250 requestAnimationFrame(() => this.adjustChatSpacing());
Sean McCullough86b56862025-04-18 13:04:03 -0700251 }
252
253 _chatInputKeyDown(event: KeyboardEvent) {
254 // Send message if Enter is pressed without Shift key
255 if (event.key === "Enter" && !event.shiftKey) {
256 event.preventDefault(); // Prevent default newline
257 this.sendChatMessage();
258 }
259 }
260
261 _chatInputChanged(event) {
262 this.content = event.target.value;
Sean McCullough07b3e392025-04-21 22:51:14 +0000263 // Use requestAnimationFrame to ensure DOM updates have completed
Sean McCullough86b56862025-04-18 13:04:03 -0700264 requestAnimationFrame(() => this.adjustChatSpacing());
265 }
266
267 @query("#chatInput")
Sean McCullough07b3e392025-04-21 22:51:14 +0000268 chatInput: HTMLTextAreaElement;
Sean McCullough86b56862025-04-18 13:04:03 -0700269
270 protected firstUpdated(): void {
271 if (this.chatInput) {
272 this.chatInput.focus();
Sean McCullough07b3e392025-04-21 22:51:14 +0000273 // Initialize the input height
274 this.adjustChatSpacing();
Philip Zeyligerf84e88c2025-05-14 23:19:01 +0000275
276 // Add paste event listener for image handling
277 this.chatInput.addEventListener("paste", this._handlePaste);
Pokey Rule339b56e2025-05-15 14:48:07 +0000278
279 // Add drag and drop event listeners
Sean McCulloughb3795922025-06-27 01:59:41 +0000280 const container = this.querySelector(".chat-container");
Pokey Rule339b56e2025-05-15 14:48:07 +0000281 if (container) {
282 container.addEventListener("dragover", this._handleDragOver);
283 container.addEventListener("dragenter", this._handleDragEnter);
284 container.addEventListener("dragleave", this._handleDragLeave);
285 container.addEventListener("drop", this._handleDrop);
286 }
Sean McCullough86b56862025-04-18 13:04:03 -0700287 }
Josh Bleecher Snydere2c7f722025-05-01 21:58:41 +0000288
289 // Add window.onload handler to ensure the input is focused when the page fully loads
290 window.addEventListener(
291 "load",
292 () => {
293 if (this.chatInput) {
294 this.chatInput.focus();
295 }
296 },
297 { once: true },
298 );
Sean McCullough86b56862025-04-18 13:04:03 -0700299 }
300
301 render() {
302 return html`
banksean3eaa4332025-07-19 02:19:06 +0000303 <div
304 class="chat-container w-full bg-gray-100 dark:bg-gray-800 p-4 min-h-[40px] relative"
305 >
Sean McCulloughb3795922025-06-27 01:59:41 +0000306 <div class="chat-input-wrapper flex max-w-6xl mx-auto gap-2.5">
Sean McCullough86b56862025-04-18 13:04:03 -0700307 <textarea
308 id="chatInput"
309 placeholder="Type your message here and press Enter to send..."
310 autofocus
311 @keydown="${this._chatInputKeyDown}"
312 @input="${this._chatInputChanged}"
313 .value=${this.content || ""}
banksean3eaa4332025-07-19 02:19:06 +0000314 class="flex-1 p-3 border border-gray-300 dark:border-gray-600 rounded resize-y font-mono text-xs min-h-[40px] max-h-[300px] bg-gray-50 dark:bg-gray-700 text-gray-900 dark:text-gray-100 overflow-y-auto box-border leading-relaxed"
Sean McCullough86b56862025-04-18 13:04:03 -0700315 ></textarea>
Pokey Rule044a62e2025-05-16 10:40:59 +0000316 <button
317 @click="${this._sendChatClicked}"
318 id="sendChatButton"
319 ?disabled=${this.uploadsInProgress > 0}
banksean3eaa4332025-07-19 02:19:06 +0000320 class="bg-blue-500 hover:bg-blue-600 disabled:bg-gray-400 dark:disabled:bg-gray-600 disabled:cursor-not-allowed text-white border-none rounded px-5 cursor-pointer font-semibold self-center h-10"
Pokey Rule044a62e2025-05-16 10:40:59 +0000321 >
322 ${this.uploadsInProgress > 0 ? "Uploading..." : "Send"}
Sean McCullough86b56862025-04-18 13:04:03 -0700323 </button>
324 </div>
Pokey Rule339b56e2025-05-15 14:48:07 +0000325 ${this.isDraggingOver
326 ? html`
Sean McCulloughb3795922025-06-27 01:59:41 +0000327 <div
328 class="drop-zone-overlay absolute inset-0 bg-blue-500/10 border-2 border-dashed border-blue-500 rounded flex justify-center items-center z-10 pointer-events-none"
329 >
330 <div
banksean3eaa4332025-07-19 02:19:06 +0000331 class="drop-zone-message bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 p-4 rounded font-semibold shadow-lg"
Sean McCulloughb3795922025-06-27 01:59:41 +0000332 >
333 Drop files here
334 </div>
Pokey Rule339b56e2025-05-15 14:48:07 +0000335 </div>
336 `
337 : ""}
Pokey Rule044a62e2025-05-16 10:40:59 +0000338 ${this.showUploadInProgressMessage
339 ? html`
Sean McCulloughb3795922025-06-27 01:59:41 +0000340 <div
banksean3eaa4332025-07-19 02:19:06 +0000341 class="upload-progress-message absolute bottom-[70px] left-1/2 transform -translate-x-1/2 bg-yellow-50 dark:bg-yellow-900 border border-yellow-400 dark:border-yellow-600 text-yellow-900 dark:text-yellow-100 z-20 text-sm px-5 py-4 rounded font-semibold shadow-lg animate-fade-in"
Sean McCulloughb3795922025-06-27 01:59:41 +0000342 >
Pokey Rule044a62e2025-05-16 10:40:59 +0000343 Please wait for file upload to complete before sending
344 </div>
345 `
346 : ""}
Sean McCullough86b56862025-04-18 13:04:03 -0700347 </div>
348 `;
349 }
350}
351
352declare global {
353 interface HTMLElementTagNameMap {
354 "sketch-chat-input": SketchChatInput;
355 }
356}