Repository: fmaclen/hollama
Pull request #88 (2024-07-13 20:43:07)

fix: prompt editor behavior is more predictable
- Allow `Shift+Enter` for new lines in non-editor prompt
- Switches from full-screen editor to default size after submission
- Focuses on prompt field after completion


Patch:
diff --git a/src/lib/components/ButtonSubmit.svelte b/src/lib/components/ButtonSubmit.svelte
index 2a907b0..9302a39 100644
--- a/src/lib/components/ButtonSubmit.svelte
+++ b/src/lib/components/ButtonSubmit.svelte
@@ -18,6 +18,6 @@
 
 <style lang="scss">
 	.tag {
-		@apply ml-2.5 inline-flex items-center rounded-md border border-neutral-50/50 px-2.5 py-0.5 text-xs font-semibold transition-colors;
+		@apply ml-2.5 inline-flex items-center rounded-md border border-shade-0/50 px-2.5 py-0.5 text-xs font-semibold;
 	}
 </style>
diff --git a/src/lib/components/Header.svelte b/src/lib/components/Header.svelte
index d0f10d1..7563896 100644
--- a/src/lib/components/Header.svelte
+++ b/src/lib/components/Header.svelte
@@ -10,7 +10,7 @@
 
 <style>
 	.header {
-		@apply flex items-center justify-between border-b p-4 bg-shade-0;
+		@apply flex items-center justify-between border-b p-4 bg-shade-1;
 		@apply lg:p-6;
 	}
 </style>
diff --git a/src/routes/sessions/[id]/+page.svelte b/src/routes/sessions/[id]/+page.svelte
index d8e802d..294c79d 100644
--- a/src/routes/sessions/[id]/+page.svelte
+++ b/src/routes/sessions/[id]/+page.svelte
@@ -1,5 +1,5 @@
 <script lang="ts">
-	import { tick } from 'svelte';
+	import { afterUpdate, tick } from 'svelte';
 	import { goto } from '$app/navigation';
 	import { Brain, StopCircle, Trash2, UnfoldVertical } from 'lucide-svelte';
 
@@ -31,7 +31,9 @@
 	let prompt: string;
 	let promptCached: string;
 	let abortController: AbortController;
+	let promptTextarea: HTMLTextAreaElement;
 	let isPromptFullscreen = false;
+	let shouldFocusTextarea = false;
 
 	let knowledgeId: string;
 	let knowledge: Knowledge | null;
@@ -42,6 +44,14 @@
 	$: session && scrollToBottom();
 	$: if ($settingsStore?.ollamaModel) session.model = $settingsStore.ollamaModel;
 	$: knowledge = knowledgeId ? loadKnowledge(knowledgeId) : null;
+	$: shouldFocusTextarea = !isPromptFullscreen;
+
+	afterUpdate(() => {
+		if (shouldFocusTextarea && promptTextarea) {
+			promptTextarea.focus();
+			shouldFocusTextarea = false;
+		}
+	});
 
 	async function scrollToBottom() {
 		if (!messageWindow) return;
@@ -73,12 +83,16 @@
 		session.messages = [...session.messages, message];
 		completion = '';
 		promptCached = '';
+		shouldFocusTextarea = true;
 		saveSession({ ...session, context });
 	}
 
 	async function handleSubmit() {
 		if (!prompt) return;
 
+		// Reset the prompt editor to its default state
+		isPromptFullscreen = false;
+
 		let knowledgeContext: Message | null = null;
 		if (knowledge) {
 			knowledgeContext = {
@@ -143,6 +157,13 @@
 		// Remove the "incomplete" AI response
 		session.messages = session.messages.slice(0, -1);
 	}
+
+	function handleKeyDown(event: KeyboardEvent) {
+		if (event.shiftKey) return;
+		if (event.key !== 'Enter') return;
+		event.preventDefault();
+		handleSubmit();
+	}
 </script>
 
 <div class="session">
@@ -218,13 +239,21 @@
 							{:else}
 								<Field name="prompt">
 									<svelte:fragment slot="label">Prompt</svelte:fragment>
-									<textarea name="prompt" class="prompt-editor__textarea" bind:value={prompt} on:keydown={(e) => e.key === 'Enter' && handleSubmit()} />
+									<textarea
+										name="prompt"
+										class="prompt-editor__textarea"
+										bind:this={promptTextarea}
+										bind:value={prompt}
+										on:keydown={handleKeyDown}
+									/>
 								</Field>
 							{/if}
 						{/key}
 
 						<div class="flex w-full">
-							<ButtonSubmit {handleSubmit} hasMetaKey={isPromptFullscreen} disabled={!prompt}>Run</ButtonSubmit>
+							<ButtonSubmit {handleSubmit} hasMetaKey={isPromptFullscreen} disabled={!prompt}>
+								Run
+							</ButtonSubmit>
 
 							{#if isLastMessageFromUser}
 								<div class="ml-2">
@@ -266,6 +295,11 @@
 		@apply h-max flex-grow;
 	}
 
+	.prompt-editor {
+		@apply sticky bottom-0 z-10 mx-auto flex w-full flex-col border-t;
+		@apply 2xl:max-w-[80ch] 2xl:rounded-t-lg 2xl:border-l 2xl:border-r;
+	}
+
 	.prompt-editor__tools {
 		@apply grid grid-cols-[1fr,1fr] items-end gap-x-4;
 	}
@@ -275,21 +309,17 @@
 		@apply lg:gap-x-2;
 	}
 
-	.prompt-editor {
-		@apply sticky bottom-0 z-10 mx-auto flex w-full flex-col border-t;
-		@apply 2xl:max-w-[80ch] 2xl:rounded-t-lg 2xl:border-l 2xl:border-r;
-	}
 
 	.prompt-editor--fullscreen {
 		@apply min-h-[60dvh];
 	}
 
 	.prompt-editor__form {
-		@apply h-full overflow-y-auto bg-shade-0;
+		@apply h-full overflow-y-auto bg-shade-1;
 	}
 
 	.prompt-editor__toggle {
-		@apply border-b bg-shade-0;
+		@apply border-b bg-shade-1;
 		@apply hover:bg-shade-2 active:bg-shade-2;
 		@apply 2xl:rounded-t-lg;
 	}
diff --git a/tailwind.config.js b/tailwind.config.js
index 8a87ca8..006a2de 100644
--- a/tailwind.config.js
+++ b/tailwind.config.js
@@ -24,7 +24,7 @@ const config = {
 			colors: {
 				accent: 'hsl(var(--color-primary))',
 				shade: {
-					0: 'hsl(var(--color-shade-0))',
+					0: 'hsl(var(--color-shade-0) / <alpha-value>)',
 					1: 'hsl(var(--color-shade-1))',
 					2: 'hsl(var(--color-shade-2))',
 					3: 'hsl(var(--color-shade-3))',


Test patch:
diff --git a/tests/session.test.ts b/tests/session.test.ts
index 0519a5d..8d64180 100644
--- a/tests/session.test.ts
+++ b/tests/session.test.ts
@@ -39,9 +39,13 @@ test.describe('Session', () => {
 		await expect(page.locator('article', { hasText: 'I am unable to provide subjective or speculative information, including fight outcomes between individuals.' })).not.toBeVisible();
 
 		await mockCompletionResponse(page, MOCK_SESSION_1_RESPONSE_1);
+		await page.keyboard.press('Shift+Enter');
+		await expect(modelNameLocator).toHaveText('New session');
+
 		await page.keyboard.press('Enter');
 		await expect(page.locator('article', { hasText: 'I am unable to provide subjective or speculative information, including fight outcomes between individuals.' })).toBeVisible();
 		await expect(newPromptHelp).not.toBeVisible();
+		await expect(modelNameLocator).not.toHaveText('New session');
 
 		await expect(page.locator('article', { hasText: 'No problem! If you have any other questions or would like to discuss something else, feel free to ask' })).not.toBeVisible();
 		await expect(page.locator('article', { hasText: "I understand, it's okay" })).not.toBeVisible();
@@ -52,7 +56,7 @@ test.describe('Session', () => {
 		await page.locator('.prompt-editor__toggle').click();
 		await expect(promptTextarea).not.toBeVisible();
 		await expect(textEditorLocator(page, 'Prompt')).toBeVisible();
-		
+
 		// Submit the form in fullscreen prompt editor with keyboard shortcut (Cmd/Ctrl + Enter)
 		await textEditorLocator(page, 'Prompt').fill("I understand, it's okay");
 		await submitWithKeyboardShortcut(page);
@@ -284,7 +288,6 @@ test.describe('Session', () => {
 		await page.goto('/');
 		await chooseModelFromSettings(page, MOCK_API_TAGS_RESPONSE.models[0].name);
 		await page.getByText('Sessions', { exact: true }).click();
-		await mockCompletionResponse(page, MOCK_SESSION_1_RESPONSE_1);
 		await page.getByTestId('new-session').click();
 		await promptTextarea.fill('Who would win in a fight between Emma Watson and Jessica Alba?');
 		await expect(textEditorLocator(page, 'Prompt')).not.toBeVisible();
@@ -306,6 +309,16 @@ test.describe('Session', () => {
 		await expect(promptEditor).not.toHaveClass(/ prompt-editor--fullscreen/);
 		await expect(promptTextarea).toBeVisible();
 		await expect(promptTextarea).toHaveValue('Nevermind...');
+
+		// Switch to fullscreen again
+		await promptEditorToggle.click();
+		await expect(promptEditor).toHaveClass(/ prompt-editor--fullscreen/);
+		await expect(promptTextarea).not.toBeVisible();
+
+		// Submit the form and check the prompt is reset
+		await page.getByText('Run').click();
+		await expect(promptTextarea).toBeVisible();
+		await expect(promptEditor).not.toHaveClass(/ prompt-editor--fullscreen/);
 	});
 
 	test.skip('handles API error when generating AI response', async ({ page }) => {