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/ui/ChatLimitField.svelte | 104 ++++++++++++++++++++++++ src/features/settings/ui/ChatLimitField.test.ts | 89 ++++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 src/features/settings/ui/ChatLimitField.svelte create mode 100644 src/features/settings/ui/ChatLimitField.test.ts (limited to 'src/features/settings/ui') 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