summaryrefslogtreecommitdiffhomepage
path: root/src/features/settings
diff options
context:
space:
mode:
Diffstat (limited to 'src/features/settings')
-rw-r--r--src/features/settings/index.ts4
-rw-r--r--src/features/settings/logic/view-model.test.ts104
-rw-r--r--src/features/settings/logic/view-model.ts32
-rw-r--r--src/features/settings/ui/ChatLimitField.svelte186
-rw-r--r--src/features/settings/ui/ChatLimitField.test.ts128
5 files changed, 227 insertions, 227 deletions
diff --git a/src/features/settings/index.ts b/src/features/settings/index.ts
index 69e7cd0..3942e97 100644
--- a/src/features/settings/index.ts
+++ b/src/features/settings/index.ts
@@ -4,6 +4,6 @@ export { default as ChatLimitField } from "./ui/ChatLimitField.svelte";
/** Public module manifest — aggregated by the shell's "Loaded Modules" view. */
export const manifest = {
- name: "settings",
- description: "FE-local settings (chat limit)",
+ name: "settings",
+ description: "FE-local settings (chat limit)",
} as const;
diff --git a/src/features/settings/logic/view-model.test.ts b/src/features/settings/logic/view-model.test.ts
index 65fcae9..93c4786 100644
--- a/src/features/settings/logic/view-model.test.ts
+++ b/src/features/settings/logic/view-model.test.ts
@@ -3,59 +3,59 @@ import { MAX_CHAT_LIMIT, MIN_CHAT_LIMIT } from "../../../core/chunks";
import { chatLimitChanged, parseChatLimit } from "./view-model";
describe("parseChatLimit", () => {
- it("parses a plain integer", () => {
- expect(parseChatLimit("256")).toEqual({ ok: true, value: 256 });
- expect(parseChatLimit("100")).toEqual({ ok: true, value: 100 });
- });
-
- it("trims surrounding whitespace", () => {
- expect(parseChatLimit(" 50 ")).toEqual({ ok: true, value: 50 });
- });
-
- it("floors a decimal", () => {
- expect(parseChatLimit("100.9")).toEqual({ ok: true, value: 100 });
- });
-
- it("clamps below the floor to MIN_CHAT_LIMIT", () => {
- expect(parseChatLimit("0")).toEqual({ ok: true, value: MIN_CHAT_LIMIT });
- expect(parseChatLimit("-5")).toEqual({ ok: true, value: MIN_CHAT_LIMIT });
- expect(parseChatLimit("5")).toEqual({ ok: true, value: MIN_CHAT_LIMIT });
- });
-
- it("clamps above the ceiling to MAX_CHAT_LIMIT", () => {
- expect(parseChatLimit("99999999")).toEqual({ ok: true, value: MAX_CHAT_LIMIT });
- });
-
- it("rejects an empty string", () => {
- expect(parseChatLimit("")).toEqual({ ok: false, error: expect.any(String) });
- expect(parseChatLimit(" ")).toEqual({ ok: false, error: expect.any(String) });
- });
-
- it("rejects non-numeric input", () => {
- expect(parseChatLimit("abc")).toEqual({ ok: false, error: expect.any(String) });
- expect(parseChatLimit("twelve")).toEqual({ ok: false, error: expect.any(String) });
- });
+ it("parses a plain integer", () => {
+ expect(parseChatLimit("256")).toEqual({ ok: true, value: 256 });
+ expect(parseChatLimit("100")).toEqual({ ok: true, value: 100 });
+ });
+
+ it("trims surrounding whitespace", () => {
+ expect(parseChatLimit(" 50 ")).toEqual({ ok: true, value: 50 });
+ });
+
+ it("floors a decimal", () => {
+ expect(parseChatLimit("100.9")).toEqual({ ok: true, value: 100 });
+ });
+
+ it("clamps below the floor to MIN_CHAT_LIMIT", () => {
+ expect(parseChatLimit("0")).toEqual({ ok: true, value: MIN_CHAT_LIMIT });
+ expect(parseChatLimit("-5")).toEqual({ ok: true, value: MIN_CHAT_LIMIT });
+ expect(parseChatLimit("5")).toEqual({ ok: true, value: MIN_CHAT_LIMIT });
+ });
+
+ it("clamps above the ceiling to MAX_CHAT_LIMIT", () => {
+ expect(parseChatLimit("99999999")).toEqual({ ok: true, value: MAX_CHAT_LIMIT });
+ });
+
+ it("rejects an empty string", () => {
+ expect(parseChatLimit("")).toEqual({ ok: false, error: expect.any(String) });
+ expect(parseChatLimit(" ")).toEqual({ ok: false, error: expect.any(String) });
+ });
+
+ it("rejects non-numeric input", () => {
+ expect(parseChatLimit("abc")).toEqual({ ok: false, error: expect.any(String) });
+ expect(parseChatLimit("twelve")).toEqual({ ok: false, error: expect.any(String) });
+ });
});
describe("chatLimitChanged", () => {
- it("is false when the typed value normalizes to the current limit", () => {
- expect(chatLimitChanged("256", 256)).toBe(false);
- });
-
- it("is true when the typed value differs", () => {
- expect(chatLimitChanged("100", 256)).toBe(true);
- expect(chatLimitChanged("256", 100)).toBe(true);
- });
-
- it("is false for empty / invalid input (nothing submittable)", () => {
- expect(chatLimitChanged("", 256)).toBe(false);
- expect(chatLimitChanged("abc", 256)).toBe(false);
- });
-
- it("is false when the typed value clamps to the current limit", () => {
- // 5 clamps to MIN (10); current is already 10 → no change.
- expect(chatLimitChanged("5", MIN_CHAT_LIMIT)).toBe(false);
- // A huge value clamps to MAX; current is already MAX → no change.
- expect(chatLimitChanged("99999999", MAX_CHAT_LIMIT)).toBe(false);
- });
+ it("is false when the typed value normalizes to the current limit", () => {
+ expect(chatLimitChanged("256", 256)).toBe(false);
+ });
+
+ it("is true when the typed value differs", () => {
+ expect(chatLimitChanged("100", 256)).toBe(true);
+ expect(chatLimitChanged("256", 100)).toBe(true);
+ });
+
+ it("is false for empty / invalid input (nothing submittable)", () => {
+ expect(chatLimitChanged("", 256)).toBe(false);
+ expect(chatLimitChanged("abc", 256)).toBe(false);
+ });
+
+ it("is false when the typed value clamps to the current limit", () => {
+ // 5 clamps to MIN (10); current is already 10 → no change.
+ expect(chatLimitChanged("5", MIN_CHAT_LIMIT)).toBe(false);
+ // A huge value clamps to MAX; current is already MAX → no change.
+ expect(chatLimitChanged("99999999", MAX_CHAT_LIMIT)).toBe(false);
+ });
});
diff --git a/src/features/settings/logic/view-model.ts b/src/features/settings/logic/view-model.ts
index caf76ee..73f6d17 100644
--- a/src/features/settings/logic/view-model.ts
+++ b/src/features/settings/logic/view-model.ts
@@ -17,8 +17,8 @@ import { normalizeChatLimit } from "../../../core/chunks";
/** Outcome of persisting a chat-limit setting. */
export type ChatLimitSaveResult =
- | { readonly ok: true; readonly chatLimit: number }
- | { readonly ok: false; readonly error: string };
+ | { readonly ok: true; readonly chatLimit: number }
+ | { readonly ok: false; readonly error: string };
export type SaveChatLimit = (value: number) => Promise<ChatLimitSaveResult>;
@@ -26,8 +26,8 @@ export type SaveChatLimit = (value: number) => Promise<ChatLimitSaveResult>;
/** Result of parsing a typed chat-limit string. */
export type ChatLimitParse =
- | { readonly ok: true; readonly value: number }
- | { readonly ok: false; readonly error: string };
+ | { readonly ok: true; readonly value: number }
+ | { readonly ok: false; readonly error: string };
/**
* Parse a typed chat-limit string into a normalized limit (floored + clamped to
@@ -37,15 +37,15 @@ export type ChatLimitParse =
* from user typing.
*/
export function parseChatLimit(raw: string): ChatLimitParse {
- const trimmed = raw.trim();
- if (trimmed.length === 0) {
- return { ok: false, error: "Enter a number." };
- }
- const n = Number(trimmed);
- if (!Number.isFinite(n)) {
- return { ok: false, error: "Must be a number." };
- }
- return { ok: true, value: normalizeChatLimit(n) };
+ const trimmed = raw.trim();
+ if (trimmed.length === 0) {
+ return { ok: false, error: "Enter a number." };
+ }
+ const n = Number(trimmed);
+ if (!Number.isFinite(n)) {
+ return { ok: false, error: "Must be a number." };
+ }
+ return { ok: true, value: normalizeChatLimit(n) };
}
/**
@@ -54,7 +54,7 @@ export function parseChatLimit(raw: string): ChatLimitParse {
* dirty-check for the input — it must NOT mutate or clamp, only compare.
*/
export function chatLimitChanged(typed: string, current: number): boolean {
- const parsed = parseChatLimit(typed);
- if (!parsed.ok) return false;
- return parsed.value !== current;
+ const parsed = parseChatLimit(typed);
+ if (!parsed.ok) return false;
+ return parsed.value !== current;
}
diff --git a/src/features/settings/ui/ChatLimitField.svelte b/src/features/settings/ui/ChatLimitField.svelte
index 9b9911a..502212f 100644
--- a/src/features/settings/ui/ChatLimitField.svelte
+++ b/src/features/settings/ui/ChatLimitField.svelte
@@ -1,104 +1,104 @@
<script lang="ts">
- import { untrack } from "svelte";
- import { MAX_CHAT_LIMIT, MIN_CHAT_LIMIT } from "../../../core/chunks";
- import { chatLimitChanged, parseChatLimit, type SaveChatLimit } from "../logic/view-model";
+ import { untrack } from "svelte";
+ import { MAX_CHAT_LIMIT, MIN_CHAT_LIMIT } from "../../../core/chunks";
+ import { chatLimitChanged, parseChatLimit, type SaveChatLimit } from "../logic/view-model";
- let {
- chatLimit,
- save,
- }: {
- /** The persisted chat limit (max loaded chunks per conversation). */
- chatLimit: number;
- save: SaveChatLimit;
- } = $props();
+ let {
+ chatLimit,
+ save,
+ }: {
+ /** The persisted chat limit (max loaded chunks per conversation). */
+ chatLimit: number;
+ save: SaveChatLimit;
+ } = $props();
- // Seed from the prop; the $effect below re-seeds on external changes (a live
- // apply from elsewhere) but only while the field is untouched, so an in-flight
- // change can't clobber what the user typed.
- let value = $state("");
- let lastSeed = $state("");
- let saving = $state(false);
- let error = $state<string | null>(null);
- let justSaved = $state(false);
- let savedValue = $state<number | null>(null);
+ // Seed from the prop; the $effect below re-seeds on external changes (a live
+ // apply from elsewhere) but only while the field is untouched, so an in-flight
+ // change can't clobber what the user typed.
+ let value = $state("");
+ let lastSeed = $state("");
+ let saving = $state(false);
+ let error = $state<string | null>(null);
+ let justSaved = $state(false);
+ let savedValue = $state<number | null>(null);
- $effect(() => {
- const incoming = String(chatLimit);
- untrack(() => {
- if (value === lastSeed) value = incoming;
- lastSeed = incoming;
- });
- });
+ $effect(() => {
+ const incoming = String(chatLimit);
+ untrack(() => {
+ if (value === lastSeed) value = incoming;
+ lastSeed = incoming;
+ });
+ });
- const dirty = $derived(chatLimitChanged(value, chatLimit));
+ const dirty = $derived(chatLimitChanged(value, chatLimit));
- async function handleSave() {
- if (saving || !dirty) return;
- const parsed = parseChatLimit(value);
- if (!parsed.ok) {
- error = parsed.error;
- return;
- }
- saving = true;
- error = null;
- justSaved = false;
- const result = await save(parsed.value);
- saving = false;
- if (result.ok) {
- justSaved = true;
- savedValue = result.chatLimit;
- // Reflect the clamped / persisted value back into the input immediately
- // (the prop will also re-assert it via the effect above).
- value = String(result.chatLimit);
- lastSeed = value;
- } else {
- error = result.error;
- }
- }
+ async function handleSave() {
+ if (saving || !dirty) return;
+ const parsed = parseChatLimit(value);
+ if (!parsed.ok) {
+ error = parsed.error;
+ return;
+ }
+ saving = true;
+ error = null;
+ justSaved = false;
+ const result = await save(parsed.value);
+ saving = false;
+ if (result.ok) {
+ justSaved = true;
+ savedValue = result.chatLimit;
+ // Reflect the clamped / persisted value back into the input immediately
+ // (the prop will also re-assert it via the effect above).
+ value = String(result.chatLimit);
+ lastSeed = value;
+ } else {
+ error = result.error;
+ }
+ }
- function onInput() {
- justSaved = false;
- error = null;
- }
+ function onInput() {
+ justSaved = false;
+ error = null;
+ }
</script>
<div class="flex flex-col gap-1">
- <span class="text-xs font-semibold uppercase opacity-60">Chat limit</span>
- <div class="flex items-center gap-2">
- <input
- type="text"
- inputmode="numeric"
- class="input input-bordered input-sm w-full font-mono text-xs"
- placeholder={String(chatLimit)}
- bind:value
- disabled={saving}
- oninput={onInput}
- onkeydown={(e) => {
- if (e.key === "Enter") handleSave();
- }}
- aria-label="Chat limit"
- />
- <button
- type="button"
- class="btn btn-primary btn-sm"
- disabled={saving || !dirty}
- onclick={handleSave}
- >
- {#if saving}
- <span class="loading loading-spinner loading-xs"></span>
- {:else}
- Set
- {/if}
- </button>
- </div>
- {#if error}
- <p class="text-xs text-error">{error}</p>
- {:else if justSaved && !dirty}
- <p class="text-xs text-success">Saved{savedValue !== null ? `: ${savedValue}.` : "."}</p>
- {:else}
- <p class="text-xs opacity-50">
- Max loaded chunks per conversation ({MIN_CHAT_LIMIT}–{MAX_CHAT_LIMIT}). Lowering it unloads
- older history; raise it and use "Show earlier" to page back in.
- </p>
- {/if}
+ <span class="text-xs font-semibold uppercase opacity-60">Chat limit</span>
+ <div class="flex items-center gap-2">
+ <input
+ type="text"
+ inputmode="numeric"
+ class="input input-bordered input-sm w-full font-mono text-xs"
+ placeholder={String(chatLimit)}
+ bind:value
+ disabled={saving}
+ oninput={onInput}
+ onkeydown={(e) => {
+ if (e.key === "Enter") handleSave();
+ }}
+ aria-label="Chat limit"
+ />
+ <button
+ type="button"
+ class="btn btn-primary btn-sm"
+ disabled={saving || !dirty}
+ onclick={handleSave}
+ >
+ {#if saving}
+ <span class="loading loading-spinner loading-xs"></span>
+ {:else}
+ Set
+ {/if}
+ </button>
+ </div>
+ {#if error}
+ <p class="text-xs text-error">{error}</p>
+ {:else if justSaved && !dirty}
+ <p class="text-xs text-success">Saved{savedValue !== null ? `: ${savedValue}.` : "."}</p>
+ {:else}
+ <p class="text-xs opacity-50">
+ Max loaded chunks per conversation ({MIN_CHAT_LIMIT}–{MAX_CHAT_LIMIT}). Lowering it unloads
+ older history; raise it and use "Show earlier" to page back in.
+ </p>
+ {/if}
</div>
diff --git a/src/features/settings/ui/ChatLimitField.test.ts b/src/features/settings/ui/ChatLimitField.test.ts
index 73bc449..8f8da25 100644
--- a/src/features/settings/ui/ChatLimitField.test.ts
+++ b/src/features/settings/ui/ChatLimitField.test.ts
@@ -6,84 +6,84 @@ import ChatLimitField from "./ChatLimitField.svelte";
// A fake save that resolves ok with the value it was given.
function fakeSave(): {
- saves: number[];
- last: number | null;
- impl: (value: number) => Promise<ChatLimitSaveResult>;
+ saves: number[];
+ last: number | null;
+ impl: (value: number) => Promise<ChatLimitSaveResult>;
} {
- const saves: number[] = [];
- return {
- saves,
- last: null,
- impl: async (value: number) => {
- saves.push(value);
- return { ok: true, chatLimit: value };
- },
- };
+ const saves: number[] = [];
+ return {
+ saves,
+ last: null,
+ impl: async (value: number) => {
+ saves.push(value);
+ return { ok: true, chatLimit: value };
+ },
+ };
}
describe("ChatLimitField", () => {
- it("seeds the input from the persisted limit and disables Set while unchanged", () => {
- render(ChatLimitField, { props: { chatLimit: 256, save: fakeSave().impl } });
- expect(screen.getByLabelText("Chat limit")).toHaveValue("256");
- expect(screen.getByRole("button", { name: "Set" })).toBeDisabled();
- });
+ it("seeds the input from the persisted limit and disables Set while unchanged", () => {
+ render(ChatLimitField, { props: { chatLimit: 256, save: fakeSave().impl } });
+ expect(screen.getByLabelText("Chat limit")).toHaveValue("256");
+ expect(screen.getByRole("button", { name: "Set" })).toBeDisabled();
+ });
- it("enables Set when the typed value differs (regression: number-binding coercion)", async () => {
- const user = userEvent.setup();
- const save = fakeSave();
- render(ChatLimitField, { props: { chatLimit: 256, save: save.impl } });
+ it("enables Set when the typed value differs (regression: number-binding coercion)", async () => {
+ const user = userEvent.setup();
+ const save = fakeSave();
+ render(ChatLimitField, { props: { chatLimit: 256, save: save.impl } });
- const input = screen.getByLabelText("Chat limit");
- await user.clear(input);
- await user.type(input, "10");
+ const input = screen.getByLabelText("Chat limit");
+ await user.clear(input);
+ await user.type(input, "10");
- expect(screen.getByRole("button", { name: "Set" })).toBeEnabled();
- });
+ expect(screen.getByRole("button", { name: "Set" })).toBeEnabled();
+ });
- it("keeps Set disabled for non-numeric input", async () => {
- const user = userEvent.setup();
- const save = fakeSave();
- render(ChatLimitField, { props: { chatLimit: 256, save: save.impl } });
+ it("keeps Set disabled for non-numeric input", async () => {
+ const user = userEvent.setup();
+ const save = fakeSave();
+ render(ChatLimitField, { props: { chatLimit: 256, save: save.impl } });
- const input = screen.getByLabelText("Chat limit");
- await user.clear(input);
- await user.type(input, "abc");
+ const input = screen.getByLabelText("Chat limit");
+ await user.clear(input);
+ await user.type(input, "abc");
- expect(screen.getByRole("button", { name: "Set" })).toBeDisabled();
- });
+ expect(screen.getByRole("button", { name: "Set" })).toBeDisabled();
+ });
- it("clicking Set calls save with the parsed value and shows the confirmation", async () => {
- const user = userEvent.setup();
- const save = fakeSave();
- const { rerender } = render(ChatLimitField, {
- props: { chatLimit: 256, save: save.impl },
- });
+ it("clicking Set calls save with the parsed value and shows the confirmation", async () => {
+ const user = userEvent.setup();
+ const save = fakeSave();
+ const { rerender } = render(ChatLimitField, {
+ props: { chatLimit: 256, save: save.impl },
+ });
- const input = screen.getByLabelText("Chat limit");
- await user.clear(input);
- await user.type(input, "50");
- await user.click(screen.getByRole("button", { name: "Set" }));
+ const input = screen.getByLabelText("Chat limit");
+ await user.clear(input);
+ await user.type(input, "50");
+ await user.click(screen.getByRole("button", { name: "Set" }));
- expect(save.saves).toEqual([50]);
- // In the real app the reactive `chatLimit` prop updates to the saved value
- // (the store sets it synchronously), which clears `dirty` and reveals the
- // "Saved" badge. Rerender simulates that propagation.
- rerender({ chatLimit: 50, save: save.impl });
- expect(screen.getByText(/Saved/i)).toBeInTheDocument();
- });
+ expect(save.saves).toEqual([50]);
+ // In the real app the reactive `chatLimit` prop updates to the saved value
+ // (the store sets it synchronously), which clears `dirty` and reveals the
+ // "Saved" badge. Rerender simulates that propagation.
+ rerender({ chatLimit: 50, save: save.impl });
+ expect(screen.getByText(/Saved/i)).toBeInTheDocument();
+ });
- it("clamps a below-floor value when saving", async () => {
- const user = userEvent.setup();
- const save = fakeSave();
- render(ChatLimitField, { props: { chatLimit: 256, save: save.impl } });
+ it("clamps a below-floor value when saving", async () => {
+ const user = userEvent.setup();
+ const save = fakeSave();
+ render(ChatLimitField, { props: { chatLimit: 256, save: save.impl } });
- const input = screen.getByLabelText("Chat limit");
- await user.clear(input);
- await user.type(input, "5"); // clamps to MIN (10)
- await user.click(screen.getByRole("button", { name: "Set" }));
+ const input = screen.getByLabelText("Chat limit");
+ await user.clear(input);
+ await user.type(input, "5"); // clamps to MIN (10)
+ await user.click(screen.getByRole("button", { name: "Set" }));
- // The save port receives the clamped value (the view-model clamps before save).
- expect(save.saves).toEqual([10]);
- expect(input).toHaveValue("10");
- });
+ // The save port receives the clamped value (the view-model clamps before save).
+ expect(save.saves).toEqual([10]);
+ expect(input).toHaveValue("10");
+ });
});