diff options
| author | Adam Malczewski <[email protected]> | 2026-06-25 10:55:51 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-06-25 10:55:51 +0900 |
| commit | 38db3827870960f466be89afbc49f91238d46144 (patch) | |
| tree | 24cb1b896dfadc31e72552dbe67f00530881242e /src/features/settings/logic | |
| parent | 17ce47987e673b6618454d033885b17b2a01912e (diff) | |
| download | dispatch-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.ts | 61 | ||||
| -rw-r--r-- | src/features/settings/logic/view-model.ts | 60 |
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; +} |
