Repository: fmaclen/hollama (ts)

Issue:
"Run" button should be disabled if the server is disconnected, no model is chosen or has no prompt
- The existing button is located in [`sessions/[id]/+page.svelte`](https://github.com/fmaclen/hollama/blob/main/src/routes/sessions/%5Bid%5D/%2Bpage.svelte#L196)
- Right now it's only disabled if it has `!prompt`
- The only [server check we do](https://github.com/fmaclen/hollama/blob/main/src/routes/%2Bpage.svelte#L13-L40) is in the **Settings** view and the server status not globally available.
- This is the current Typescript definition of [`Settings`](https://github.com/fmaclen/hollama/blob/main/src/lib/store.ts#L30-L34).
- We could probably save the server status to the `settingsStore` so we can access it from the **Sessions** view.
- Currently, we only check the status of the server whenever [`ollamaServer`](https://github.com/fmaclen/hollama/blob/main/src/routes/%2Bpage.svelte#L82C1-L83C1) changes. 
  - That means that if the server went _offline_ after starting a new session the status would still appear as _online_.
  - This is not ideal because submitting the form to an _offline_ server would cause an error but we should handle that behavior in #49 
- Should add a new test for this behavior in [`sessions.test.ts`](https://github.com/fmaclen/hollama/blob/main/tests/session.test.ts)
When visiting `/`, redirect to "Settings" only when `ollamaServer` is offline
Blocked by #21
REF #70

```typescript
	beforeUpdate(() => {
		if (!$settingsStore?.ollamaServer) {
			// FIXME:
			// This should only rediect to /settings if the server is not connected
			goto('/settings');
		} else {
			goto('/sessions');
...
```
Update list of available models when creating a "New Session"

Pull request:
fix: Disable Run button if server is disconnected or no model is chosen
Closes #21 
Closes #80 
Closes #105 

Patch:
diff --git a/src/lib/ollama.ts b/src/lib/ollama.ts
index df48d29..9d30f30 100644
--- a/src/lib/ollama.ts
+++ b/src/lib/ollama.ts
@@ -1,3 +1,6 @@
+import { get } from 'svelte/store';
+import { settingsStore } from '$lib/store';
+
 export type OllamaModel = {
 	name: string;
 	model: string;
@@ -17,3 +20,13 @@ export type OllamaModel = {
 export type OllamaTagResponse = {
 	models: OllamaModel[];
 };
+
+export async function ollamaTags() {
+	const settings = get(settingsStore);
+	if (!settings) throw new Error('No Ollama server specified');
+
+	const response = await fetch(`${settings.ollamaServer}/api/tags`);
+	if (!response.ok) throw new Error('Failed to fetch Ollama tags');
+
+	return response.json() as Promise<OllamaTagResponse>;
+}
diff --git a/src/routes/sessions/[id]/+page.svelte b/src/routes/sessions/[id]/+page.svelte
index 9c992bd..f82eac7 100644
--- a/src/routes/sessions/[id]/+page.svelte
+++ b/src/routes/sessions/[id]/+page.svelte
@@ -6,6 +6,7 @@
 
 	import { loadKnowledge, type Knowledge } from '$lib/knowledge';
 	import { settingsStore, knowledgeStore } from '$lib/store';
+	import { ollamaTags } from '$lib/ollama';
 	import {
 		saveSession,
 		type Message,
@@ -31,6 +32,7 @@
 	import ButtonCopy from '$lib/components/ButtonCopy.svelte';
 	import ButtonDelete from '$lib/components/ButtonDelete.svelte';
 	import Metadata from '$lib/components/Metadata.svelte';
+	import Badge from '$lib/components/Badge.svelte';
 	import Head from '$lib/components/Head.svelte';
 
 	export let data: PageData;
@@ -48,6 +50,7 @@
 	let shouldFocusTextarea = false;
 
 	const shouldConfirmDeletion = writable(false);
+	const currentSessionId = writable(data.id);
 
 	$: session = loadSession(data.id);
 	$: isNewSession = !session?.messages.length;
@@ -56,6 +59,21 @@
 	$: if ($settingsStore?.ollamaModel) session.model = $settingsStore.ollamaModel;
 	$: knowledge = knowledgeId ? loadKnowledge(knowledgeId) : null;
 	$: shouldFocusTextarea = !isPromptFullscreen;
+	$: {
+		if (session?.id !== $currentSessionId) {
+			getModelsList();
+			$currentSessionId = session.id;
+		}
+	}
+
+	async function getModelsList() {
+		if (!$settingsStore) return;
+		try {
+			$settingsStore.ollamaModels = (await ollamaTags()).models;
+		} catch {
+			$settingsStore.ollamaModels = [];
+		}
+	}
 
 	async function handleSubmit() {
 		if (!prompt) return;
@@ -267,7 +285,15 @@
 								<FieldTextEditor label="Prompt" {handleSubmit} bind:value={prompt} />
 							{:else}
 								<Field name="prompt">
-									<svelte:fragment slot="label">Prompt</svelte:fragment>
+									<svelte:fragment slot="label">
+										Prompt
+
+										{#if !$settingsStore?.ollamaModels.length}
+											<a class="ml-auto" href="/settings" data-testid="disconnected-server">
+												<Badge variant="warning">disconnected</Badge>
+											</a>
+										{/if}
+									</svelte:fragment>
 									<textarea
 										name="prompt"
 										class="prompt-editor__textarea"
@@ -280,7 +306,13 @@
 						{/key}
 
 						<div class="flex w-full">
-							<ButtonSubmit {handleSubmit} hasMetaKey={isPromptFullscreen} disabled={!prompt}>
+							<ButtonSubmit
+								{handleSubmit}
+								hasMetaKey={isPromptFullscreen}
+								disabled={!prompt ||
+									!$settingsStore?.ollamaModels.length ||
+									!$settingsStore?.ollamaModel}
+							>
 								Run
 							</ButtonSubmit>
 
diff --git a/src/routes/settings/+page.svelte b/src/routes/settings/+page.svelte
index 9ddcbab..c8c9cc2 100644
--- a/src/routes/settings/+page.svelte
+++ b/src/routes/settings/+page.svelte
@@ -9,7 +9,7 @@
 	import Fieldset from '$lib/components/Fieldset.svelte';
 	import FieldInput from '$lib/components/FieldInput.svelte';
 
-	import type { OllamaTagResponse } from '$lib/ollama';
+	import { ollamaTags, type OllamaTagResponse } from '$lib/ollama';
 	import { LOCAL_STORAGE_PREFIX, settingsStore, StorageKey } from '$lib/store';
 	import Head from '$lib/components/Head.svelte';
 
@@ -31,11 +31,7 @@
 
 	async function getModelsList(): Promise<void> {
 		try {
-			if (!ollamaServer) throw new Error('No server provided');
-
-			const response = await fetch(`${ollamaServer}/api/tags`);
-			const data = (await response.json()) as OllamaTagResponse;
-			ollamaTagResponse = data;
+			ollamaTagResponse = await ollamaTags();
 			serverStatus = 'connected';
 		} catch {
 			ollamaTagResponse = null;


Test patch:
diff --git a/tests/session.test.ts b/tests/session.test.ts
index 2cfe345..fbea49f 100644
--- a/tests/session.test.ts
+++ b/tests/session.test.ts
@@ -442,6 +442,29 @@ test.describe('Session', () => {
 		await expect(page.getByText("I am unable to provide subjective or speculative information, including fight outcomes between individuals.")).not.toBeVisible();
 	});
 
+	test('handles errors when fetching models', async ({ page }) => {
+		await page.goto('/');
+		await page.getByText('Sessions', { exact: true }).click();
+		await page.getByTestId('new-session').click();
+
+		await expect(page.getByTestId('disconnected-server')).not.toBeVisible();
+		
+		await page.getByLabel('Model').selectOption(MOCK_API_TAGS_RESPONSE.models[0].name);
+		await promptTextarea.fill('Who would win in a fight between Emma Watson and Jessica Alba?');
+		await expect(page.getByText('Run')).toBeEnabled();
+
+		// Mock a net::ERR_CONNECTION_REFUSED
+		await page.route('**/tags', async (route) => {
+			await route.abort('failed');
+		});
+		await page.getByTestId('new-session').click();		
+		await expect(page.getByTestId('disconnected-server')).toBeVisible();
+		await expect(page.getByText('Run')).toBeDisabled();
+
+		await promptTextarea.fill('Who would win in a fight between Emma Watson and Jessica Alba?');
+		await expect(page.getByText('Run')).toBeDisabled();
+	})
+
 	test.skip('auto-scrolls to the bottom when new messages are added', async ({ page }) => {
 		// TODO: Implement the test
 	});