summaryrefslogtreecommitdiffhomepage
path: root/src/features/settings/logic
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/logic
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/logic')
-rw-r--r--src/features/settings/logic/view-model.test.ts61
-rw-r--r--src/features/settings/logic/view-model.ts60
2 files changed, 121 insertions, 0 deletions
diff --git a/src/features/settings/logic/view-model.test.ts b/src/features/settings/logic/view-model.test.ts
new file mode 100644
index 0000000..65fcae9
--- /dev/null
+++ b/src/features/settings/logic/view-model.test.ts
@@ -0,0 +1,61 @@
+import { describe, expect, it } from "vitest";
+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) });
+ });
+});
+
+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);
+ });
+});
diff --git a/src/features/settings/logic/view-model.ts b/src/features/settings/logic/view-model.ts
new file mode 100644
index 0000000..caf76ee
--- /dev/null
+++ b/src/features/settings/logic/view-model.ts
@@ -0,0 +1,60 @@
+import { normalizeChatLimit } from "../../../core/chunks";
+
+/**
+ * Pure core for the settings feature — zero DOM, zero effects, zero Svelte.
+ *
+ * The settings feature exposes FE-local, user-tunable settings. Currently the
+ * chat limit (max loaded chunks per conversation; the policy lives in
+ * `core/chunks/trim.ts`): this module is the view-model seam that parses +
+ * validates a typed value into a normalized limit, so the UI can disable submit
+ * and show an error on garbage input. The bound constants + normalization are
+ * OWNED by `core/chunks` (the single source of truth); this module never
+ * redefines them.
+ */
+
+// ── Injected port (consumer-defines-port; the composition root adapts the
+// store's localStorage persistence to this shape). ──────────────────────────
+
+/** Outcome of persisting a chat-limit setting. */
+export type ChatLimitSaveResult =
+ | { readonly ok: true; readonly chatLimit: number }
+ | { readonly ok: false; readonly error: string };
+
+export type SaveChatLimit = (value: number) => Promise<ChatLimitSaveResult>;
+
+// ── chat-limit parse / validate ───────────────────────────────────────────────
+
+/** Result of parsing a typed chat-limit string. */
+export type ChatLimitParse =
+ | { 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
+ * [MIN_CHAT_LIMIT, MAX_CHAT_LIMIT] via `normalizeChatLimit`). Empty or
+ * non-numeric input is an ERROR (so the UI can disable submit + message), NOT a
+ * silent default — the default only applies at boot for an unset key, never
+ * 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) };
+}
+
+/**
+ * Whether saving `typed` would change the `current` chat limit. A no-op save
+ * (empty/invalid, or equal after normalization) should be disabled. This is the
+ * 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;
+}