From 38db3827870960f466be89afbc49f91238d46144 Mon Sep 17 00:00:00 2001 From: Adam Malczewski Date: Thu, 25 Jun 2026 10:55:51 +0900 Subject: 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. --- src/features/settings/index.ts | 9 ++ src/features/settings/logic/view-model.test.ts | 61 ++++++++++++++ src/features/settings/logic/view-model.ts | 60 ++++++++++++++ src/features/settings/ui/ChatLimitField.svelte | 104 ++++++++++++++++++++++++ src/features/settings/ui/ChatLimitField.test.ts | 89 ++++++++++++++++++++ 5 files changed, 323 insertions(+) create mode 100644 src/features/settings/index.ts create mode 100644 src/features/settings/logic/view-model.test.ts create mode 100644 src/features/settings/logic/view-model.ts create mode 100644 src/features/settings/ui/ChatLimitField.svelte create mode 100644 src/features/settings/ui/ChatLimitField.test.ts (limited to 'src/features/settings') diff --git a/src/features/settings/index.ts b/src/features/settings/index.ts new file mode 100644 index 0000000..69e7cd0 --- /dev/null +++ b/src/features/settings/index.ts @@ -0,0 +1,9 @@ +export type { ChatLimitParse, ChatLimitSaveResult, SaveChatLimit } from "./logic/view-model"; +export { chatLimitChanged, parseChatLimit } from "./logic/view-model"; +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)", +} as const; 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; + +// ── 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; +} 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 @@ + + +
+ Chat limit +
+ { + if (e.key === "Enter") handleSave(); + }} + aria-label="Chat limit" + /> + +
+ {#if error} +

{error}

+ {:else if justSaved && !dirty} +

Saved{savedValue !== null ? `: ${savedValue}.` : "."}

+ {:else} +

+ 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. +

+ {/if} +
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; +} { + 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"); + }); +}); -- cgit v1.2.3