blob: b5cf7e4d3ba9006532e77322337658a090459e5b [file] [log] [blame]
Philip Zeyliger2c4db092025-04-28 16:57:50 -07001import { css, html, LitElement } from "lit";
2import { customElement, property, state } from "lit/decorators.js";
3import { AgentMessage, State } from "../types";
4
5@customElement("sketch-restart-modal")
6export class SketchRestartModal extends LitElement {
7 @property({ type: Boolean })
8 open = false;
9
10 @property({ attribute: false })
11 containerState: State | null = null;
12
13 @property({ attribute: false })
14 messages: AgentMessage[] = [];
15
16 @state()
17 private restartType: "initial" | "current" | "other" = "current";
18
19 @state()
20 private customRevision = "";
21
22 @state()
23 private promptOption: "suggested" | "original" | "new" = "suggested";
24
25 @state()
26 private commitDescriptions: Record<string, string> = {
27 current: "",
28 initial: "",
29 };
30
31 @state()
32 private suggestedPrompt = "";
33
34 @state()
35 private originalPrompt = "";
36
37 @state()
38 private newPrompt = "";
39
40 @state()
41 private isLoading = false;
42
43 @state()
44 private isSuggestionLoading = false;
45
46 @state()
47 private isOriginalPromptLoading = false;
48
49 @state()
50 private errorMessage = "";
51
52 static styles = css`
53 :host {
54 display: block;
55 font-family:
56 system-ui,
57 -apple-system,
58 BlinkMacSystemFont,
59 "Segoe UI",
60 Roboto,
61 sans-serif;
62 }
63
64 .modal-description {
65 margin: 0 0 20px 0;
66 color: #555;
67 font-size: 14px;
68 line-height: 1.5;
69 }
70
71 .container-message {
72 margin: 10px 0;
73 padding: 8px 12px;
74 background-color: #f8f9fa;
75 border-left: 4px solid #6c757d;
76 color: #555;
77 font-size: 14px;
78 line-height: 1.5;
79 border-radius: 4px;
80 }
81
82 .modal-backdrop {
83 position: fixed;
84 top: 0;
85 left: 0;
86 width: 100%;
87 height: 100%;
88 background-color: rgba(0, 0, 0, 0.5);
89 z-index: 1000;
90 display: flex;
91 justify-content: center;
92 align-items: center;
93 opacity: 0;
94 pointer-events: none;
95 transition: opacity 0.2s ease-in-out;
96 }
97
98 .modal-backdrop.open {
99 opacity: 1;
100 pointer-events: auto;
101 }
102
103 .modal-container {
104 background: white;
105 border-radius: 8px;
106 box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
107 width: 600px;
108 max-width: 90%;
109 max-height: 90vh;
110 overflow-y: auto;
111 padding: 20px;
112 }
113
114 .modal-header {
115 display: flex;
116 justify-content: space-between;
117 align-items: center;
118 margin-bottom: 20px;
119 border-bottom: 1px solid #eee;
120 padding-bottom: 10px;
121 }
122
123 .modal-title {
124 font-size: 18px;
125 font-weight: 600;
126 margin: 0;
127 }
128
129 .close-button {
130 background: none;
131 border: none;
132 font-size: 18px;
133 cursor: pointer;
134 color: #666;
135 }
136
137 .close-button:hover {
138 color: #333;
139 }
140
141 .form-group {
142 margin-bottom: 16px;
143 }
144
145 .horizontal-radio-group {
146 display: flex;
147 flex-wrap: wrap;
148 gap: 16px;
149 margin-bottom: 16px;
150 }
151
152 .revision-option {
153 border: 1px solid #e0e0e0;
154 border-radius: 4px;
155 padding: 8px 12px;
156 min-width: 180px;
157 cursor: pointer;
158 transition: all 0.2s;
159 }
160
161 .revision-option label {
162 font-size: 0.9em;
163 font-weight: bold;
164 }
165
166 .revision-option:hover {
167 border-color: #2196f3;
168 background-color: #f5f9ff;
169 }
170
171 .revision-option.selected {
172 border-color: #2196f3;
173 background-color: #e3f2fd;
174 }
175
176 .revision-option input[type="radio"] {
177 margin-right: 8px;
178 }
179
180 .revision-description {
181 margin-top: 4px;
182 color: #666;
183 font-size: 0.8em;
184 font-family: monospace;
185 white-space: nowrap;
186 overflow: hidden;
187 text-overflow: ellipsis;
188 max-width: 200px;
189 }
190
191 .form-group label {
192 display: block;
193 margin-bottom: 8px;
194 font-weight: 500;
195 }
196
197 .radio-group {
198 margin-bottom: 8px;
199 }
200
201 .radio-option {
202 display: flex;
203 align-items: center;
204 margin-bottom: 8px;
205 }
206
207 .radio-option input {
208 margin-right: 8px;
209 }
210
211 .custom-revision {
212 margin-left: 24px;
213 margin-top: 8px;
214 width: calc(100% - 24px);
215 padding: 6px 8px;
216 border: 1px solid #ddd;
217 border-radius: 4px;
218 display: block;
219 }
220
221 .prompt-container {
222 position: relative;
223 margin-top: 16px;
224 }
225
226 .prompt-textarea {
227 display: block;
228 box-sizing: border-box;
229 width: 100%;
230 min-height: 120px;
231 padding: 8px;
232 border: 1px solid #ddd;
233 border-radius: 4px;
234 font-family: inherit;
235 resize: vertical;
236 background-color: white;
237 color: #333;
238 }
239
240 .prompt-textarea.disabled {
241 background-color: #f5f5f5;
242 color: #999;
243 cursor: not-allowed;
244 }
245
246 .actions {
247 display: flex;
248 justify-content: flex-end;
249 gap: 12px;
250 margin-top: 20px;
251 }
252
253 .btn {
254 padding: 8px 16px;
255 border-radius: 4px;
256 font-weight: 500;
257 cursor: pointer;
258 border: none;
259 }
260
261 .btn-cancel {
262 background: #f2f2f2;
263 color: #333;
264 }
265
266 .btn-restart {
267 background: #4caf50;
268 color: white;
269 }
270
271 .btn:disabled {
272 opacity: 0.6;
273 cursor: not-allowed;
274 }
275
276 .error-message {
277 color: #e53935;
278 margin-top: 16px;
279 font-size: 14px;
280 }
281
282 .loading-indicator {
283 display: inline-block;
284 margin-right: 8px;
285 margin-left: 8px;
286 width: 16px;
287 height: 16px;
288 border: 2px solid rgba(255, 255, 255, 0.3);
289 border-radius: 50%;
290 border-top-color: white;
291 animation: spin 1s linear infinite;
292 }
293
294 .prompt-container .loading-overlay {
295 position: absolute;
296 top: 0;
297 left: 0;
298 right: 0;
299 bottom: 0;
300 background: rgba(255, 255, 255, 0.7);
301 display: flex;
302 align-items: center;
303 justify-content: center;
304 z-index: 2;
305 border-radius: 4px;
306 }
307
308 .prompt-container .loading-overlay .loading-indicator {
309 width: 24px;
310 height: 24px;
311 border: 2px solid rgba(0, 0, 0, 0.1);
312 border-top-color: #2196f3;
313 border-radius: 50%;
314 animation: spin 1s linear infinite;
315 }
316
317 .radio-option .loading-indicator {
318 display: inline-block;
319 width: 12px;
320 height: 12px;
321 border: 1.5px solid rgba(0, 0, 0, 0.2);
322 border-top-color: #2196f3;
323 vertical-align: middle;
324 margin-left: 8px;
325 }
326
327 .radio-option .status-ready {
328 display: inline-block;
329 width: 16px;
330 height: 16px;
331 color: #4caf50;
332 margin-left: 8px;
333 font-weight: bold;
334 vertical-align: middle;
335 }
336
337 @keyframes spin {
338 to {
339 transform: rotate(360deg);
340 }
341 }
342 `;
343
344 constructor() {
345 super();
346 this.handleEscape = this.handleEscape.bind(this);
347 }
348
349 connectedCallback() {
350 super.connectedCallback();
351 document.addEventListener("keydown", this.handleEscape);
352 }
353
354 // Handle keyboard navigation
355 firstUpdated() {
356 if (this.shadowRoot) {
357 // Set up proper tab navigation by ensuring all focusable elements are included
358 const focusableElements =
359 this.shadowRoot.querySelectorAll('[tabindex="0"]');
360 if (focusableElements.length > 0) {
361 // Set initial focus when modal opens
362 (focusableElements[0] as HTMLElement).focus();
363 }
364 }
365 }
366
367 disconnectedCallback() {
368 super.disconnectedCallback();
369 document.removeEventListener("keydown", this.handleEscape);
370 }
371
372 handleEscape(e: KeyboardEvent) {
373 if (e.key === "Escape" && this.open) {
374 this.closeModal();
375 }
376 }
377
378 closeModal() {
379 this.open = false;
380 this.dispatchEvent(new CustomEvent("close"));
381 }
382
383 async loadCommitDescription(
384 revision: string,
385 target: "current" | "initial" | "other" = "other",
386 ) {
387 try {
388 const response = await fetch(
389 `./commit-description?revision=${encodeURIComponent(revision)}`,
390 );
391 if (!response.ok) {
392 throw new Error(
393 `Failed to load commit description: ${response.statusText}`,
394 );
395 }
396
397 const data = await response.json();
398
399 if (target === "other") {
400 // For custom revisions, update the customRevision directly
401 this.customRevision = `${revision.slice(0, 8)} - ${data.description}`;
402 } else {
403 // For known targets, update the commitDescriptions object
404 this.commitDescriptions = {
405 ...this.commitDescriptions,
406 [target]: data.description,
407 };
408 }
409 } catch (error) {
410 console.error(`Error loading commit description for ${revision}:`, error);
411 }
412 }
413
414 handleRevisionChange(e?: Event) {
415 if (e) {
416 const target = e.target as HTMLInputElement;
417 this.restartType = target.value as "initial" | "current" | "other";
418 }
419
420 // Load commit description for any custom revision if needed
421 if (
422 this.restartType === "other" &&
423 this.customRevision &&
424 !this.customRevision.includes(" - ")
425 ) {
426 this.loadCommitDescription(this.customRevision, "other");
427 }
428 }
429
430 handleCustomRevisionChange(e: Event) {
431 const target = e.target as HTMLInputElement;
432 this.customRevision = target.value;
433 }
434
435 handlePromptOptionChange(e: Event) {
436 const target = e.target as HTMLInputElement;
437 this.promptOption = target.value as "suggested" | "original" | "new";
438
439 if (
440 this.promptOption === "suggested" &&
441 !this.isSuggestionLoading &&
442 this.suggestedPrompt === ""
443 ) {
444 this.loadSuggestedPrompt();
445 } else if (
446 this.promptOption === "original" &&
447 !this.isOriginalPromptLoading &&
448 this.originalPrompt === ""
449 ) {
450 this.loadOriginalPrompt();
451 }
452 }
453
454 handleSuggestedPromptChange(e: Event) {
455 const target = e.target as HTMLTextAreaElement;
456 this.suggestedPrompt = target.value;
457 }
458
459 handleOriginalPromptChange(e: Event) {
460 const target = e.target as HTMLTextAreaElement;
461 this.originalPrompt = target.value;
462 }
463
464 handleNewPromptChange(e: Event) {
465 const target = e.target as HTMLTextAreaElement;
466 this.newPrompt = target.value;
467 }
468
469 async loadSuggestedPrompt() {
470 try {
471 this.isSuggestionLoading = true;
472 this.errorMessage = "";
473
474 const response = await fetch("./suggest-reprompt");
475 if (!response.ok) {
476 throw new Error(`Failed to load suggestion: ${response.statusText}`);
477 }
478
479 const data = await response.json();
480 this.suggestedPrompt = data.prompt;
481 } catch (error) {
482 console.error("Error loading suggested prompt:", error);
483 this.errorMessage =
484 error instanceof Error ? error.message : "Failed to load suggestion";
485 } finally {
486 this.isSuggestionLoading = false;
487 }
488 }
489
490 async loadOriginalPrompt() {
491 try {
492 this.isOriginalPromptLoading = true;
493 this.errorMessage = "";
494
495 // Get the first message index from the container state
496 const firstMessageIndex = this.containerState?.first_message_index || 0;
497
498 // Find the first user message after the first_message_index
499 let firstUserMessage = "";
500
501 if (this.messages && this.messages.length > 0) {
502 for (const msg of this.messages) {
503 // Only look at messages starting from first_message_index
504 if (msg.idx >= firstMessageIndex && msg.type === "user") {
505 // Simply use the content field if it's a string
506 if (typeof msg.content === "string") {
507 firstUserMessage = msg.content;
508 } else {
509 // Fallback to stringifying content field for any other type
510 firstUserMessage = JSON.stringify(msg.content);
511 }
512 break;
513 }
514 }
515 }
516
517 if (!firstUserMessage) {
518 console.warn("Could not find original user message", this.messages);
519 }
520
521 this.originalPrompt = firstUserMessage;
522 } catch (error) {
523 console.error("Error loading original prompt:", error);
524 this.errorMessage =
525 error instanceof Error
526 ? error.message
527 : "Failed to load original prompt";
528 } finally {
529 this.isOriginalPromptLoading = false;
530 }
531 }
532
533 async handleRestart() {
534 try {
535 this.isLoading = true;
536 this.errorMessage = "";
537
538 let revision = "";
539 switch (this.restartType) {
540 case "initial":
541 // We'll leave revision empty for this case, backend will handle it
542 break;
543 case "current":
544 // We'll leave revision empty for this case too, backend will use current HEAD
545 break;
546 case "other":
547 revision = this.customRevision.trim();
548 if (!revision) {
549 throw new Error("Please enter a valid revision");
550 }
551 break;
552 }
553
554 // Determine which prompt to use based on selected option
555 let initialPrompt = "";
556 switch (this.promptOption) {
557 case "suggested":
558 initialPrompt = this.suggestedPrompt.trim();
559 if (!initialPrompt && this.isSuggestionLoading) {
560 throw new Error(
561 "Suggested prompt is still loading. Please wait or choose another option.",
562 );
563 }
564 break;
565 case "original":
566 initialPrompt = this.originalPrompt.trim();
567 if (!initialPrompt && this.isOriginalPromptLoading) {
568 throw new Error(
569 "Original prompt is still loading. Please wait or choose another option.",
570 );
571 }
572 break;
573 case "new":
574 initialPrompt = this.newPrompt.trim();
575 break;
576 }
577
578 // Validate we have a prompt when needed
579 if (!initialPrompt && this.promptOption !== "new") {
580 throw new Error(
581 "Unable to get prompt text. Please enter a new prompt or try again.",
582 );
583 }
584
585 const response = await fetch("./restart", {
586 method: "POST",
587 headers: {
588 "Content-Type": "application/json",
589 },
590 body: JSON.stringify({
591 revision: revision,
592 initial_prompt: initialPrompt,
593 }),
594 });
595
596 if (!response.ok) {
597 const errorText = await response.text();
598 throw new Error(`Failed to restart: ${errorText}`);
599 }
600
601 // Reload the page after successful restart
602 window.location.reload();
603 } catch (error) {
604 console.error("Error restarting conversation:", error);
605 this.errorMessage =
606 error instanceof Error
607 ? error.message
608 : "Failed to restart conversation";
609 } finally {
610 this.isLoading = false;
611 }
612 }
613
614 updated(changedProperties: Map<string, any>) {
615 if (changedProperties.has("open") && this.open) {
616 // Reset form when opening
617 this.restartType = "current";
618 this.customRevision = "";
619 this.promptOption = "suggested";
620 this.suggestedPrompt = "";
621 this.originalPrompt = "";
622 this.newPrompt = "";
623 this.errorMessage = "";
624 this.commitDescriptions = {
625 current: "",
626 initial: "",
627 };
628
629 // Pre-load all available prompts and commit descriptions in the background
630 setTimeout(() => {
631 // Load prompt data
632 this.loadSuggestedPrompt();
633 this.loadOriginalPrompt();
634
635 // Load commit descriptions
636 this.loadCommitDescription("HEAD", "current");
637 if (this.containerState?.initial_commit) {
638 this.loadCommitDescription(
639 this.containerState.initial_commit,
640 "initial",
641 );
642 }
643
644 // Set focus to the first radio button for keyboard navigation
645 if (this.shadowRoot) {
646 const firstInput = this.shadowRoot.querySelector(
647 'input[type="radio"]',
648 ) as HTMLElement;
649 if (firstInput) {
650 firstInput.focus();
651 }
652 }
653 }, 0);
654 }
655 }
656
657 render() {
658 const inContainer = this.containerState?.in_container || false;
659
660 return html`
661 <div class="modal-backdrop ${this.open ? "open" : ""}">
662 <div class="modal-container">
663 <div class="modal-header">
664 <h2 class="modal-title">Restart Conversation</h2>
665 <button class="close-button" @click=${this.closeModal}>×</button>
666 </div>
667
668 <p class="modal-description">
669 Restarting the conversation hides the history from the agent. If you
670 want the agent to take a different direction, restart with a new
671 prompt.
672 </p>
673
674 <div class="form-group">
675 <label>Reset to which revision?</label>
676 <div class="horizontal-radio-group">
677 <div
678 class="revision-option ${this.restartType === "current"
679 ? "selected"
680 : ""}"
681 @click=${() => {
682 this.restartType = "current";
683 this.handleRevisionChange();
684 }}
685 >
686 <input
687 type="radio"
688 id="restart-current"
689 name="restart-type"
690 value="current"
691 ?checked=${this.restartType === "current"}
692 @change=${this.handleRevisionChange}
693 tabindex="0"
694 />
695 <label for="restart-current">Current HEAD</label>
696 ${this.commitDescriptions.current
697 ? html`<div class="revision-description">
698 ${this.commitDescriptions.current}
699 </div>`
700 : ""}
701 </div>
702
703 ${inContainer
704 ? html`
705 <div
706 class="revision-option ${this.restartType === "initial"
707 ? "selected"
708 : ""}"
709 @click=${() => {
710 this.restartType = "initial";
711 this.handleRevisionChange();
712 }}
713 >
714 <input
715 type="radio"
716 id="restart-initial"
717 name="restart-type"
718 value="initial"
719 ?checked=${this.restartType === "initial"}
720 @change=${this.handleRevisionChange}
721 tabindex="0"
722 />
723 <label for="restart-initial">Initial commit</label>
724 ${this.commitDescriptions.initial
725 ? html`<div class="revision-description">
726 ${this.commitDescriptions.initial}
727 </div>`
728 : ""}
729 </div>
730
731 <div
732 class="revision-option ${this.restartType === "other"
733 ? "selected"
734 : ""}"
735 @click=${() => {
736 this.restartType = "other";
737 this.handleRevisionChange();
738 }}
739 >
740 <input
741 type="radio"
742 id="restart-other"
743 name="restart-type"
744 value="other"
745 ?checked=${this.restartType === "other"}
746 @change=${this.handleRevisionChange}
747 tabindex="0"
748 />
749 <label for="restart-other">Other revision</label>
750 </div>
751 `
752 : html`
753 <div class="container-message">
754 Additional revision options are not available because
755 Sketch is not running inside a container.
756 </div>
757 `}
758 </div>
759
760 ${this.restartType === "other" && inContainer
761 ? html`
762 <input
763 type="text"
764 class="custom-revision"
765 placeholder="Enter commit hash"
766 .value=${this.customRevision}
767 @input=${this.handleCustomRevisionChange}
768 tabindex="0"
769 />
770 `
771 : ""}
772 </div>
773
774 <div class="form-group">
775 <label>Prompt options:</label>
776 <div class="radio-group">
777 <div class="radio-option">
778 <input
779 type="radio"
780 id="prompt-suggested"
781 name="prompt-type"
782 value="suggested"
783 ?checked=${this.promptOption === "suggested"}
784 @change=${this.handlePromptOptionChange}
785 tabindex="0"
786 />
787 <label for="prompt-suggested">
788 Suggest prompt based on history (default)
789 </label>
790 </div>
791
792 <div class="radio-option">
793 <input
794 type="radio"
795 id="prompt-original"
796 name="prompt-type"
797 value="original"
798 ?checked=${this.promptOption === "original"}
799 @change=${this.handlePromptOptionChange}
800 tabindex="0"
801 />
802 <label for="prompt-original"> Original prompt </label>
803 </div>
804
805 <div class="radio-option">
806 <input
807 type="radio"
808 id="prompt-new"
809 name="prompt-type"
810 value="new"
811 ?checked=${this.promptOption === "new"}
812 @change=${this.handlePromptOptionChange}
813 tabindex="0"
814 />
815 <label for="prompt-new">New prompt</label>
816 </div>
817 </div>
818 </div>
819
820 <div class="prompt-container">
821 ${this.promptOption === "suggested"
822 ? html`
823 <textarea
824 class="prompt-textarea${this.isSuggestionLoading
825 ? " disabled"
826 : ""}"
827 placeholder="Loading suggested prompt..."
828 .value=${this.suggestedPrompt}
829 ?disabled=${this.isSuggestionLoading}
830 @input=${this.handleSuggestedPromptChange}
831 tabindex="0"
832 ></textarea>
833 ${this.isSuggestionLoading
834 ? html`
835 <div class="loading-overlay">
836 <div class="loading-indicator"></div>
837 </div>
838 `
839 : ""}
840 `
841 : this.promptOption === "original"
842 ? html`
843 <textarea
844 class="prompt-textarea${this.isOriginalPromptLoading
845 ? " disabled"
846 : ""}"
847 placeholder="Loading original prompt..."
848 .value=${this.originalPrompt}
849 ?disabled=${this.isOriginalPromptLoading}
850 @input=${this.handleOriginalPromptChange}
851 tabindex="0"
852 ></textarea>
853 ${this.isOriginalPromptLoading
854 ? html`
855 <div class="loading-overlay">
856 <div class="loading-indicator"></div>
857 </div>
858 `
859 : ""}
860 `
861 : html`
862 <textarea
863 class="prompt-textarea"
864 placeholder="Enter a new prompt..."
865 .value=${this.newPrompt}
866 @input=${this.handleNewPromptChange}
867 tabindex="0"
868 ></textarea>
869 `}
870 </div>
871
872 ${this.errorMessage
873 ? html` <div class="error-message">${this.errorMessage}</div> `
874 : ""}
875
876 <div class="actions">
877 <button
878 class="btn btn-cancel"
879 @click=${this.closeModal}
880 ?disabled=${this.isLoading}
881 tabindex="0"
882 >
883 Cancel
884 </button>
885 <button
886 class="btn btn-restart"
887 @click=${this.handleRestart}
888 ?disabled=${this.isLoading}
889 tabindex="0"
890 >
891 ${this.isLoading
892 ? html`<span class="loading-indicator"></span>`
893 : ""}
894 Restart
895 </button>
896 </div>
897 </div>
898 </div>
899 `;
900 }
901}
902
903declare global {
904 interface HTMLElementTagNameMap {
905 "sketch-restart-modal": SketchRestartModal;
906 }
907}