summaryrefslogtreecommitdiffhomepage
path: root/src/features/settings/ui
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-06-25 10:55:51 +0900
committerAdam Malczewski <[email protected]>2026-06-25 10:55:51 +0900
commit38db3827870960f466be89afbc49f91238d46144 (patch)
tree24cb1b896dfadc31e72552dbe67f00530881242e /src/features/settings/ui
parent17ce47987e673b6618454d033885b17b2a01912e (diff)
downloaddispatch-web-38db3827870960f466be89afbc49f91238d46144.tar.gz
dispatch-web-38db3827870960f466be89afbc49f91238d46144.zip
feat: workspaces shell + cwd-lsp rename + mcp/settings/system-prompt features + app wiring
- workspaces: URL-driven conversation grouping (home listing at /, routing, store, http adapter, WorkspaceCard) wired into the App.svelte shell - rename features/workspace -> features/cwd-lsp (the cwd/lsp status feature) - new features: mcp (status view), settings (chat-limit field), system-prompt (prompt builder), all rendered via the generic surface host - chat: store + ChatView updates - tabs: tabs-store updates - app wiring: ErrorModal (full-screen error surface), app/App.svelte + store.svelte This commit makes HEAD typecheck clean for the first time: the prior HEAD (c95cc77) imported features/settings from app/App.svelte but never committed the feature, so only the full working tree was green.
Diffstat (limited to 'src/features/settings/ui')
-rw-r--r--src/features/settings/ui/ChatLimitField.svelte104
-rw-r--r--src/features/settings/ui/ChatLimitField.test.ts89
2 files changed, 193 insertions, 0 deletions
diff --git a/src/features/settings/ui/ChatLimitField.svelte b/src/features/settings/ui/ChatLimitField.svelte
new file mode 100644
index 0000000..9b9911a
--- /dev/null
+++ b/src/features/settings/ui/ChatLimitField.svelte
@@ -0,0 +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";
+
+ 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);
+
+ $effect(() => {
+ const incoming = String(chatLimit);
+ untrack(() => {
+ if (value === lastSeed) value = incoming;
+ lastSeed = incoming;
+ });
+ });
+
+ 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;
+ }
+ }
+
+ 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}
+</div>
diff --git a/src/features/settings/ui/ChatLimitField.test.ts b/src/features/settings/ui/ChatLimitField.test.ts
new file mode 100644
index 0000000..73bc449
--- /dev/null
+++ b/src/features/settings/ui/ChatLimitField.test.ts
@@ -0,0 +1,89 @@
+import { render, screen } from "@testing-library/svelte";
+import userEvent from "@testing-library/user-event";
+import { describe, expect, it } from "vitest";
+import type { ChatLimitSaveResult } from "../logic/view-model";
+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>;
+} {
+ 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("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");
+
+ 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 } });
+
+ const input = screen.getByLabelText("Chat limit");
+ await user.clear(input);
+ await user.type(input, "abc");
+
+ 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 },
+ });
+
+ 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();
+ });
+
+ 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" }));
+
+ // The save port receives the clamped value (the view-model clamps before save).
+ expect(save.saves).toEqual([10]);
+ expect(input).toHaveValue("10");
+ });
+});