andy-stack_vaultkeeper-ai/Components/ChatInput.svelte
Andrew Beal 6f5f72952c fix: improve submit button state handling in diff mode
Update submit button icon and behavior to correctly reflect state when diff
is open. Button now shows appropriate icon based on input content and
triggers correct action (stop vs suggestion) in diff mode.
2025-12-05 23:34:52 +00:00

516 lines
14 KiB
Svelte

<script lang="ts">
import { onDestroy, tick } from "svelte";
import { Platform, setIcon, type EventRef } from "obsidian";
import type { UserInputService } from "Services/UserInputService";
import type { ISearchState, SearchStateStore } from "Stores/SearchStateStore";
import { Resolve } from "Services/DependencyService";
import { Services } from "Services/Services";
import { isSearchTrigger, isSearchTriggerElement, fromInput, toNode, triggerToText } from "Enums/SearchTrigger";
import ChatSearchResults from "./ChatSearchResults.svelte";
import type { Writable } from "svelte/store";
import type { InputService } from "Services/InputService";
import UserInstruction from "./UserInstruction.svelte";
import DiffControls from "./DiffControls.svelte";
import type { EventService } from "Services/EventService";
import { Event } from "Enums/Event";
import type { DiffService } from "Services/DiffService";
export let hasNoApiKey: boolean;
export let isSubmitting: boolean;
export let editModeActive: boolean;
export let onsubmit: (userRequest: string, formattedRequest: string) => void;
export let ontoggleeditmode: () => void;
export let onstop: () => void;
const inputService: InputService = Resolve<InputService>(Services.InputService);
const userInputService: UserInputService = Resolve<UserInputService>(Services.UserInputService);
const searchStateStore: SearchStateStore = Resolve<SearchStateStore>(Services.SearchStateStore);
const diffService: DiffService = Resolve<DiffService>(Services.DiffService);
const eventService: EventService = Resolve<EventService>(Services.EventService);
const searchState: Writable<ISearchState> = searchStateStore.searchState;
let textareaElement: HTMLDivElement;
let userInstructionButton: HTMLButtonElement;
let submitButton: HTMLButtonElement;
let editModeButton: HTMLButtonElement;
let userInstructionActive = false;
let userRequest = "";
let diffOpen: boolean = false;
const diffOpenedRef: EventRef = eventService.on(Event.DiffOpened, () => { diffOpen = true; focusInput(); });
const diffClosedRef: EventRef = eventService.on(Event.DiffClosed, () => { diffOpen = false; focusInput(); });
onDestroy(() => {
eventService.offref(diffOpenedRef);
eventService.offref(diffClosedRef);
});
export function focusInput() {
tick().then(() => {
textareaElement?.focus();
});
}
$: if (userInstructionButton) {
setIcon(userInstructionButton, "user-round-pen");
}
$: if (submitButton) {
if (diffOpen) {
setIcon(submitButton, userRequest.trim() === "" ? "square" : "send-horizontal");
} else {
setIcon(submitButton, isSubmitting ? "square" : "send-horizontal");
}
}
$: if (editModeButton) {
setIcon(editModeButton, editModeActive ? "pencil" : "pencil-off");
}
function handleStop() {
onstop();
}
function handleSubmit() {
if (userRequest.trim() === "" || isSubmitting) {
return;
}
const result = requestFromInput();
onsubmit(result.request, result.formattedRequest);
}
function handleSuggestion() {
if (userRequest.trim() === "" || !diffOpen) {
return;
}
const suggestion = requestFromInput().formattedRequest;
diffService.onSuggest(suggestion);
}
function requestFromInput() {
const request = textareaElement.innerHTML;
const formattedRequest = triggerToText(request);
textareaElement.textContent = "";
userRequest = "";
if (Platform.isMobile) {
textareaElement.blur();
} else {
focusInput();
}
return { request: request, formattedRequest: formattedRequest };
}
function toggleEditMode() {
ontoggleeditmode();
}
async function handleKeydown(e: KeyboardEvent) {
userInstructionActive = false;
if ($searchState.active) {
await continueSearch(e);
return;
}
if (e.key === "Backspace") {
const position = inputService.getCursorPosition(textareaElement);
if (position === 0) {
return;
}
e.preventDefault();
const elementBeforeCursor = inputService.getElementBeforeCursor(textareaElement);
if (elementBeforeCursor && isSearchTriggerElement(elementBeforeCursor)) {
elementBeforeCursor.remove();
return;
}
inputService.deleteTextRange(position - 1, position, textareaElement);
return;
}
if (e.key === "Enter") {
if (e.shiftKey || Platform.isMobile) {
return;
}
e.preventDefault();
diffOpen ? handleSuggestion() : handleSubmit();
}
}
// Detect search triggers on character insertion
function handleBeforeInput(e: InputEvent) {
// This works reliably on both desktop and mobile (including virtual keyboards)
if (e.inputType === "insertText" && e.data && isSearchTrigger(e.data)) {
const position = inputService.getCursorPosition(textareaElement);
const trigger = fromInput(e.data);
searchStateStore.initializeSearch(trigger, position);
}
// Let the character insert, handleInput() will synchronize the search query
}
async function continueSearch(e: KeyboardEvent) {
if (!$searchState.trigger) {
searchStateStore.resetSearch();
return;
}
if (e.key === "Escape") {
searchStateStore.resetSearch();
e.preventDefault();
return;
}
if (e.key === "Enter") {
e.preventDefault();
handleSearchResultAcceptance();
return;
}
if (e.key === "Backspace" || e.key === "Delete") {
return;
}
if (e.key.startsWith("Arrow")) {
if (e.key === "ArrowUp") {
e.preventDefault();
searchStateStore.setSelectedResultToPrevious();
}
if (e.key === "ArrowDown") {
e.preventDefault();
searchStateStore.setSelectedResultToNext();
}
return;
}
// Only append printable characters to the query
if (inputService.isPrintableKey(e.key, e.ctrlKey, e.metaKey)) {
searchStateStore.appendToQuery(e.key);
userInputService.performSearch();
}
}
function handleSearchResultAcceptance(e?: MouseEvent) {
if ($searchState.selectedResult !== "" && $searchState.position != null && $searchState.trigger != null) {
const node = toNode($searchState.trigger, $searchState.selectedResult);
inputService.deleteTextRange($searchState.position, inputService.getCursorPosition(textareaElement), textareaElement);
inputService.insertElementAtCursor(node, textareaElement);
searchStateStore.resetSearch();
}
e?.preventDefault();
focusInput();
}
function handleInput() {
if (textareaElement) {
userRequest = textareaElement.textContent || "";
if (textareaElement.innerHTML !== textareaElement.textContent) {
if (inputService.hasUnauthorizedHTML(textareaElement)) {
inputService.sanitizeToPlainText(textareaElement);
}
}
// If in search mode, synchronize the query with actual text content
if ($searchState.active && $searchState.position !== null) {
const fullText = textareaElement.textContent || "";
const triggerPos = $searchState.position;
// Extract the query portion (everything after the trigger)
const actualQuery = fullText.substring(triggerPos + 1);
// Only update if the query has changed
if (actualQuery !== $searchState.query) {
searchStateStore.setQuery(actualQuery);
userInputService.performSearch();
}
}
if (userRequest.trim() === "") {
textareaElement.textContent = "";
}
}
}
function handlePaste(e: ClipboardEvent) {
e.preventDefault();
const plainText = inputService.getPlainTextFromClipboard(e.clipboardData);
if (!plainText) {
return;
}
inputService.insertTextAtCursor(plainText);
handleInput();
}
function handleCopy(e: ClipboardEvent) {
e.preventDefault();
const selection = window.getSelection();
if (!selection) {
return;
}
const selectedText = selection.toString();
e.clipboardData?.setData("text/plain", selectedText);
}
function handleDrop(e: DragEvent) {
e.preventDefault();
const plainText = e.dataTransfer?.getData("text/plain") || "";
if (!plainText) {
return;
}
inputService.insertTextAtCursor(plainText);
handleInput();
}
function handleCursorPositionChange() {
if (!$searchState.active || $searchState.position === null) {
return;
}
const currentPosition = inputService.getCursorPosition(textareaElement);
if (!inputService.isInSearchZone(currentPosition, $searchState.position)) {
searchStateStore.resetSearch();
}
}
function handleFocusOut() {
searchStateStore.resetSearch();
}
</script>
<div id="input-container" class:edit-mode={editModeActive}>
<div id="diff-controls-container" style:padding-top={diffOpen ? "var(--size-4-2)" : 0} style:display={diffOpen ? "inline" : "none"}>
<DiffControls/>
</div>
<div id="input-search-results-container" style:padding-top={$searchState.results.length > 0 ? "var(--size-4-2)" : 0}>
<ChatSearchResults searchState={$searchState} onResultAccept={handleSearchResultAcceptance}/>
</div>
<div id="user-instruction-container" style:padding-top={userInstructionActive ? "var(--size-4-2)" : 0}>
<UserInstruction focusInput={focusInput} bind:userInstructionActive={userInstructionActive}/>
</div>
<button
id="user-instruction-button"
class:instruction-active={userInstructionActive}
bind:this={userInstructionButton}
on:click={() => { userInstructionActive = !userInstructionActive; searchStateStore.resetSearch() }}
aria-label="User Instruction">
</button>
<div
id="input-field"
class:error={hasNoApiKey}
class:edit-mode={editModeActive && !hasNoApiKey}
bind:this={textareaElement}
contenteditable="plaintext-only"
on:keydown={handleKeydown}
on:beforeinput={handleBeforeInput}
on:input={handleInput}
on:paste={handlePaste}
on:copy={handleCopy}
on:drop={handleDrop}
on:click={handleCursorPositionChange}
on:keyup={handleCursorPositionChange}
on:focusout={handleFocusOut}
data-placeholder={diffOpen ? "Make a suggestion..." : "Type a message..."}
role="textbox"
aria-multiline="true"
tabindex="0">
</div>
<button
id="edit-mode-button"
class:edit-mode={editModeActive}
bind:this={editModeButton}
on:click={() => { toggleEditMode() }}
disabled={isSubmitting}
aria-label={editModeActive ? "Turn off Agent Mode" : "Turn on Agent Mode"}>
</button>
<button
id="submit-button"
class:edit-mode={editModeActive}
bind:this={submitButton}
on:click={() => {
if (diffOpen) {
userRequest.trim() === "" ? handleStop() : handleSuggestion();
} else {
isSubmitting ? handleStop() : handleSubmit();
}
}}
disabled={diffOpen ? false : !isSubmitting && userRequest.trim() === ""}
aria-label={diffOpen ? (userRequest.trim() === "" ? "Cancel" : "Make Suggestion") : (isSubmitting ? "Cancel" : "Send Message")}>
</button>
</div>
<style>
#input-container {
grid-row: 2;
grid-column: 1;
display: grid;
grid-template-rows: auto auto var(--size-4-3) 1fr var(--size-4-3);
grid-template-columns: var(--size-4-3) auto var(--size-4-2) 1fr var(--size-4-2) auto var(--size-4-2) auto var(--size-4-3);
border-radius: var(--modal-radius);
background-color: var(--background-primary);
}
#input-container.edit-mode {
border-color: var(--alt-interactive-accent);
transition: border-color 0.5s ease-out;
}
#diff-controls-container {
grid-row: 1;
grid-column: 2 / 9;
}
#input-search-results-container {
grid-row: 2;
grid-column: 2 / 9;
}
#user-instruction-container {
grid-row: 2;
grid-column: 2 / 9;
}
#user-instruction-button {
grid-row: 4;
grid-column: 2;
border-radius: var(--button-radius);
align-self: end;
transition-duration: 0.5s;
}
:global(.is-mobile) #user-instruction-button {
max-height: 2rem;
}
#user-instruction-button.instruction-active {
box-shadow: 0px 0px 4px 1px var(--color-accent);
}
#input-field {
grid-row: 4;
grid-column: 4;
height: 100%;
max-height: 30vh;
border-radius: var(--input-radius);
font-weight: var(--input-font-weight);
border-width: var(--input-border-width);
border-style: solid;
border-color: var(--background-modifier-border);
padding: var(--size-2-2) var(--size-2-3);
background-color: var(--background-primary);
font-family: var(--font-interface-theme);
resize: none;
overflow-y: auto;
overflow-x: hidden;
scroll-behavior: smooth;
color: var(--font-interface-theme);
transition: border-color 0.5s ease-out;
word-wrap: break-word;
white-space: pre-wrap;
}
:global(.is-mobile) #input-field {
align-content: end;
}
#input-field:focus {
border-color: var(--color-accent);
box-shadow: 0px 0px 4px 1px var(--color-accent);
transition: border-color 0.5s ease-out;
}
#input-field.edit-mode:focus {
border-color: var(--alt-interactive-accent);
box-shadow: 0px 0px 3px 1px var(--alt-interactive-accent);
transition: border-color 0.5s ease-out;
}
#input-field.error,
#input-field.error:focus {
border-color: var(--color-red);
box-shadow: 0px 0px 4px 1px var(--color-red);
transition: border-color 0.5s ease-out;
}
#input-field::-webkit-scrollbar {
display: none;
}
#input-field:empty::before {
content: attr(data-placeholder);
color: var(--text-muted);
opacity: 0.75;
pointer-events: none;
}
#input-field[contenteditable]:focus {
outline: none;
}
#edit-mode-button {
grid-row: 4;
grid-column: 6;
border-radius: var(--button-radius);
align-self: end;
transition-duration: 0.5s;
}
:global(.is-mobile) #edit-mode-button {
max-height: 2rem;
}
#submit-button {
grid-row: 4;
grid-column: 8;
border-radius: var(--button-radius);
padding-left: var(--size-4-5);
padding-right: var(--size-4-5);
align-self: end;
transition-duration: 0.5s;
background-color: var(--interactive-accent);
}
:global(.is-mobile) #submit-button {
max-height: 2rem;
}
#submit-button:not(:disabled):hover {
cursor: pointer;
background-color: var(--interactive-accent-hover);
}
#submit-button.edit-mode {
background-color: var(--alt-interactive-accent);
}
#submit-button.edit-mode:not(:disabled):hover {
cursor: pointer;
background-color: var(--alt-interactive-accent-hover);
}
</style>