From 50366132443405eb01a7a8840235e10782205813 Mon Sep 17 00:00:00 2001 From: Phelipe Lorran <112786693+httpsphl@users.noreply.github.com> Date: Tue, 29 Sep 2026 19:01:56 -0300 Subject: [PATCH 1/3] feat(app): settings in parts, with Enter to send, panels that follow the bot and less motion as choices Settings lists its parts on the left: General (the background switches and the language), Chat, Appearance and About. Chat gets "Enter sends the message" (off, Ctrl+Enter sends and Enter starts a line) and "Open the browser and screens when a bot starts using them"; Appearance gets "Less motion", which marks the root so motion-less.css and reducedMotion() still the window as Windows would. The app's own choices share one prefs module in localStorage. --- app/src/App.tsx | 2 + .../features/account/AppearanceSettings.tsx | 42 +++++++ .../features/account/BackgroundSettings.tsx | 40 ------ app/src/features/account/ChatSettings.tsx | 28 +++++ app/src/features/account/GeneralSettings.tsx | 59 +++++++++ app/src/features/account/SettingsDialog.tsx | 115 +++++++++--------- app/src/features/account/account.test.tsx | 3 + app/src/features/account/prefs.test.tsx | 78 ++++++++++++ app/src/features/account/settings.test.tsx | 9 +- app/src/features/bots/useFollowBot.ts | 8 +- app/src/features/chat/ChatComposer.tsx | 14 ++- app/src/features/onboarding/Welcome.tsx | 4 +- app/src/features/onboarding/link.ts | 4 +- app/src/i18n/en/account.ts | 11 ++ app/src/i18n/en/chat.ts | 1 + app/src/i18n/es/account.ts | 12 ++ app/src/i18n/es/chat.ts | 1 + app/src/i18n/pt-BR/account.ts | 12 ++ app/src/i18n/pt-BR/chat.ts | 1 + app/src/index.css | 1 + app/src/main.tsx | 7 +- app/src/motion-less.css | 25 ++++ app/src/shell/closing.ts | 51 +------- app/src/shell/motionRoot.ts | 16 +++ app/src/shell/prefs.ts | 71 +++++++++++ app/src/shell/zoom.test.tsx | 1 + app/src/ui/motion.ts | 10 +- docs/spec.md | 8 +- 28 files changed, 471 insertions(+), 163 deletions(-) create mode 100644 app/src/features/account/AppearanceSettings.tsx delete mode 100644 app/src/features/account/BackgroundSettings.tsx create mode 100644 app/src/features/account/ChatSettings.tsx create mode 100644 app/src/features/account/GeneralSettings.tsx create mode 100644 app/src/features/account/prefs.test.tsx create mode 100644 app/src/motion-less.css create mode 100644 app/src/shell/motionRoot.ts create mode 100644 app/src/shell/prefs.ts diff --git a/app/src/App.tsx b/app/src/App.tsx index dba635fe..7a6a0fd1 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -4,6 +4,7 @@ import { type Connect, createLink } from "./features/onboarding/link"; import { Onboarding } from "./features/onboarding/Onboarding"; import type { Host } from "./lib/host"; import { useCloseBehavior } from "./shell/closing"; +import { useMotionRoot } from "./shell/motionRoot"; import { TitleBar } from "./shell/TitleBar"; import { useThemeRoot } from "./shell/theme"; import { Workspace } from "./shell/Workspace"; @@ -13,6 +14,7 @@ import { Toaster } from "./ui/toast"; export function App({ host, connect }: { host: Host; connect: Connect }) { useThemeRoot(); + useMotionRoot(); useZoomRoot(host); useCloseBehavior(host); const [link] = useState(() => createLink(host, connect)); diff --git a/app/src/features/account/AppearanceSettings.tsx b/app/src/features/account/AppearanceSettings.tsx new file mode 100644 index 00000000..6c68bccf --- /dev/null +++ b/app/src/features/account/AppearanceSettings.tsx @@ -0,0 +1,42 @@ +// Settings, "Appearance": theme, size and motion (spec 15.3). + +import { useT } from "../../i18n"; +import { prefs, usePref } from "../../shell/prefs"; +import { setTheme, type ThemeChoice, useTheme } from "../../shell/theme"; +import { DEFAULT_ZOOM, setZoom, useZoom, ZOOM_LEVELS, type ZoomLevel } from "../../shell/zoom"; +import { Choices } from "../../ui/Choices"; +import { Field, Section, Toggle } from "./settingsParts"; + +export function AppearanceSettings() { + const t = useT(); + const s = t.account.settings; + const { choice: theme } = useTheme(); + const zoom = useZoom(); + const lessMotion = usePref(prefs.lessMotion); + const themes: { value: ThemeChoice; label: string }[] = [ + { value: "system", label: s.themes.system }, + { value: "light", label: s.themes.light }, + { value: "dark", label: s.themes.dark }, + ]; + const sizes = ZOOM_LEVELS.map((level) => ({ + value: level, + label: t.shell.zoom.level(Math.round(level * 100), level === DEFAULT_ZOOM), + })); + + return ( +
+ + + + + label={s.size} value={zoom} options={sizes} onChange={setZoom} /> + + +
+ ); +} diff --git a/app/src/features/account/BackgroundSettings.tsx b/app/src/features/account/BackgroundSettings.tsx deleted file mode 100644 index a422e603..00000000 --- a/app/src/features/account/BackgroundSettings.tsx +++ /dev/null @@ -1,40 +0,0 @@ -// Settings, "In the background" (spec 15.1): whether the bots keep working -// after the window closes, whether Botloft starts with Windows, and -// whether the computer stays awake while they work. - -import { useT } from "../../i18n"; -import { setWhenClosed, useWhenClosed } from "../../shell/closing"; -import { Section, Toggle } from "./settingsParts"; -import { useDaemonSettings } from "./useDaemonSettings"; - -export function BackgroundSettings() { - const s = useT().account.settings; - const whenClosed = useWhenClosed(); - const { settings, change } = useDaemonSettings(); - const startWithWindows = settings?.startWithWindows ?? true; - - return ( -
- setWhenClosed(on ? "keep" : "stop")} - /> - change({ startWithWindows: on })} - /> - change({ keepAwake: on })} - /> -
- ); -} diff --git a/app/src/features/account/ChatSettings.tsx b/app/src/features/account/ChatSettings.tsx new file mode 100644 index 00000000..5c5ec162 --- /dev/null +++ b/app/src/features/account/ChatSettings.tsx @@ -0,0 +1,28 @@ +// Settings, "Chat": how a message is sent, and whether the panel beside +// the chat follows what the bot starts doing (spec 15.1). + +import { useT } from "../../i18n"; +import { prefs, usePref } from "../../shell/prefs"; +import { Section, Toggle } from "./settingsParts"; + +export function ChatSettings() { + const s = useT().account.settings; + const enterSends = usePref(prefs.enterSends); + const followBot = usePref(prefs.followBot); + return ( +
+ + +
+ ); +} diff --git a/app/src/features/account/GeneralSettings.tsx b/app/src/features/account/GeneralSettings.tsx new file mode 100644 index 00000000..9af526ec --- /dev/null +++ b/app/src/features/account/GeneralSettings.tsx @@ -0,0 +1,59 @@ +// Settings, "General": what Botloft does in the background (spec 15.1) +// and the language. + +import { + LOCALES, + type LocaleChoice, + setLocaleChoice, + systemLocale, + useLocale, + useT, +} from "../../i18n"; +import { prefs, usePref } from "../../shell/prefs"; +import { Choices } from "../../ui/Choices"; +import { Section, Toggle } from "./settingsParts"; +import { useDaemonSettings } from "./useDaemonSettings"; + +export function GeneralSettings() { + const t = useT(); + const s = t.account.settings; + const whenClosed = usePref(prefs.whenClosed); + const { settings, change } = useDaemonSettings(); + const startWithWindows = settings?.startWithWindows ?? true; + const { choice: locale } = useLocale(); + const systemName = LOCALES.find((entry) => entry.id === systemLocale())?.name ?? "English"; + const languages: { value: LocaleChoice; label: string }[] = [ + { value: "system", label: t.shell.language.system(systemName) }, + ...LOCALES.map((entry) => ({ value: entry.id as LocaleChoice, label: entry.name })), + ]; + + return ( + <> +
+ prefs.whenClosed.set(on ? "keep" : "stop")} + /> + change({ startWithWindows: on })} + /> + change({ keepAwake: on })} + /> +
+
+ +
+ + ); +} diff --git a/app/src/features/account/SettingsDialog.tsx b/app/src/features/account/SettingsDialog.tsx index 58e5366b..2fb9b702 100644 --- a/app/src/features/account/SettingsDialog.tsx +++ b/app/src/features/account/SettingsDialog.tsx @@ -1,66 +1,71 @@ -// Settings (spec 15.1): whether Botloft works in the background, the -// theme, size and language, which used to sit in the title bar, and the -// versions for when something needs reporting. +// Settings (spec 15.1), in parts listed on the left: general (what +// Botloft does in the background, the language), the chat, the look, and +// the versions for when something needs reporting. -import { - LOCALES, - type LocaleChoice, - setLocaleChoice, - systemLocale, - useLocale, - useT, -} from "../../i18n"; -import { setTheme, type ThemeChoice, useTheme } from "../../shell/theme"; -import { DEFAULT_ZOOM, setZoom, useZoom, ZOOM_LEVELS, type ZoomLevel } from "../../shell/zoom"; -import { Choices } from "../../ui/Choices"; +import { Info, type LucideIcon, MessageSquare, Palette, SlidersHorizontal } from "lucide-react"; +import { useId, useState } from "react"; +import { useT } from "../../i18n"; import { Dialog } from "../../ui/Dialog"; import { AboutSettings } from "./AboutSettings"; -import { BackgroundSettings } from "./BackgroundSettings"; -import { Field, Section } from "./settingsParts"; +import { AppearanceSettings } from "./AppearanceSettings"; +import { ChatSettings } from "./ChatSettings"; +import { GeneralSettings } from "./GeneralSettings"; -export function SettingsDialog({ onClose }: { onClose(): void }) { - const t = useT(); - const s = t.account.settings; - const { choice: theme } = useTheme(); - const zoom = useZoom(); - const { choice: locale } = useLocale(); - const systemName = LOCALES.find((entry) => entry.id === systemLocale())?.name ?? "English"; +type Page = "general" | "chat" | "appearance" | "about"; + +const PAGES: { id: Page; icon: LucideIcon }[] = [ + { id: "general", icon: SlidersHorizontal }, + { id: "chat", icon: MessageSquare }, + { id: "appearance", icon: Palette }, + { id: "about", icon: Info }, +]; - const themes: { value: ThemeChoice; label: string }[] = [ - { value: "system", label: s.themes.system }, - { value: "light", label: s.themes.light }, - { value: "dark", label: s.themes.dark }, - ]; - const sizes = ZOOM_LEVELS.map((level) => ({ - value: level, - label: t.shell.zoom.level(Math.round(level * 100), level === DEFAULT_ZOOM), - })); - const languages: { value: LocaleChoice; label: string }[] = [ - { value: "system", label: t.shell.language.system(systemName) }, - ...LOCALES.map((entry) => ({ value: entry.id as LocaleChoice, label: entry.name })), - ]; +export function SettingsDialog({ onClose }: { onClose(): void }) { + const s = useT().account.settings; + const [page, setPage] = useState("general"); + const id = useId(); return ( -
- -
- - - - - label={s.size} value={zoom} options={sizes} onChange={setZoom} /> - -
-
- -
- +
+
+ {PAGES.map(({ id: each, icon: Icon }) => { + const selected = each === page; + return ( + + ); + })} +
+
+ {page === "general" && } + {page === "chat" && } + {page === "appearance" && } + {page === "about" && } +
); diff --git a/app/src/features/account/account.test.tsx b/app/src/features/account/account.test.tsx index d4871001..4d17b882 100644 --- a/app/src/features/account/account.test.tsx +++ b/app/src/features/account/account.test.tsx @@ -90,11 +90,14 @@ describe("account area", () => { await openAccount(); fireEvent.click(screen.getByRole("menuitem", { name: "Settings" })); const dialog = screen.getByRole("dialog", { name: "Settings" }); + fireEvent.click(within(dialog).getByRole("tab", { name: "Appearance" })); fireEvent.click(within(dialog).getByRole("radio", { name: "Dark" })); expect(document.documentElement.dataset.theme).toBe("dark"); + fireEvent.click(within(dialog).getByRole("tab", { name: "General" })); fireEvent.click(within(dialog).getByRole("radio", { name: "Español" })); expect(screen.getByRole("dialog", { name: "Configuración" })).toBeDefined(); fireEvent.click(screen.getByRole("radio", { name: /Idioma del sistema/ })); + fireEvent.click(screen.getByRole("tab", { name: "About" })); expect(screen.getByRole("dialog", { name: "Settings" }).textContent).toContain("Botloft 0.1.0"); }); diff --git a/app/src/features/account/prefs.test.tsx b/app/src/features/account/prefs.test.tsx new file mode 100644 index 00000000..50535b70 --- /dev/null +++ b/app/src/features/account/prefs.test.tsx @@ -0,0 +1,78 @@ +import { act, cleanup, fireEvent, screen, within } from "@testing-library/react"; +import { afterEach, describe, expect, test } from "vitest"; +import { FakeBotloft } from "../../lib/fake"; +import { prefs, resetPrefs } from "../../shell/prefs"; +import { crewOpened, openBot, renderApp } from "../../test/app"; +import { reducedMotion } from "../../ui/motion"; + +afterEach(() => { + cleanup(); + resetPrefs(); +}); + +async function scout() { + const fake = new FakeBotloft(); + const ops = fake.addCrew("Ops"); + const bot = fake.addBot(ops.id, "Scout", "Finds sources"); + fake.setBotState(bot.id, "idle"); + renderApp(fake); + await crewOpened("Ops"); + openBot("Scout"); + await screen.findByRole("list", { name: "Messages" }); + return { fake, bot }; +} + +async function openSettings(page: string) { + fireEvent.click(await screen.findByRole("button", { name: /Ana Lima/ })); + fireEvent.click(screen.getByRole("menuitem", { name: "Settings" })); + const dialog = screen.getByRole("dialog", { name: "Settings" }); + fireEvent.click(within(dialog).getByRole("tab", { name: page })); + return dialog; +} + +const sends = (fake: FakeBotloft) => fake.calls.filter((call) => call.method === "messages.send"); + +describe("the app's own choices", () => { + test("Ctrl+Enter sends and Enter starts a line, when chosen", async () => { + const { fake } = await scout(); + const dialog = await openSettings("Chat"); + fireEvent.click(within(dialog).getByRole("switch", { name: "Enter sends the message" })); + expect(dialog.textContent).toContain("Enter starts a new line, and Ctrl+Enter sends."); + fireEvent.click(within(dialog).getByRole("button", { name: "Close" })); + + const field = screen.getByLabelText("Message to Scout"); + fireEvent.change(field, { target: { value: "two\nlines" } }); + expect(screen.getByText("Ctrl+Enter to send, Enter for a new line")).toBeDefined(); + fireEvent.keyDown(field, { key: "Enter" }); + expect(sends(fake)).toHaveLength(0); + fireEvent.keyDown(field, { key: "Enter", ctrlKey: true }); + const chat = screen.getByRole("list", { name: "Messages" }); + expect(await within(chat).findByText(/two\s+lines/)).toBeDefined(); + expect(sends(fake)).toHaveLength(1); + }); + + test("the browser and the screens stay behind their buttons, when chosen", async () => { + prefs.followBot.set(false); + const { fake, bot } = await scout(); + act(() => { + fake.browser.open(bot.id, "https://example.com/", "Example"); + fake.screens.draft(bot.id, "C:\\Work\\home.html", "

Home"); + }); + expect(screen.queryByRole("complementary", { name: "Scout's browser" })).toBeNull(); + expect( + screen.getByRole("button", { name: "Show browser: Scout is using the browser" }), + ).toBeDefined(); + expect(screen.queryByRole("complementary", { name: /screens/i })).toBeNull(); + }); + + test("less motion stills the window", async () => { + renderApp(new FakeBotloft()); + const dialog = await openSettings("Appearance"); + expect(reducedMotion()).toBe(false); + fireEvent.click(within(dialog).getByRole("switch", { name: "Less motion" })); + expect(document.documentElement.dataset.motion).toBe("less"); + expect(reducedMotion()).toBe(true); + fireEvent.click(within(dialog).getByRole("switch", { name: "Less motion" })); + expect(document.documentElement.dataset.motion).toBeUndefined(); + }); +}); diff --git a/app/src/features/account/settings.test.tsx b/app/src/features/account/settings.test.tsx index cf4be40d..58bd4ff4 100644 --- a/app/src/features/account/settings.test.tsx +++ b/app/src/features/account/settings.test.tsx @@ -5,11 +5,11 @@ import type { Client } from "../../lib/client"; import { FakeBotloft } from "../../lib/fake"; import { FakeHost, FakeUpdate } from "../../lib/fakeHost"; import { RpcError } from "../../lib/rpc"; -import { setWhenClosed } from "../../shell/closing"; +import { prefs, resetPrefs } from "../../shell/prefs"; afterEach(() => { cleanup(); - setWhenClosed("keep"); + resetPrefs(); }); function renderApp(fake = new FakeBotloft(), host = new FakeHost()) { @@ -74,6 +74,7 @@ describe("settings", () => { test("about looks for a new version on request", async () => { const { host } = renderApp(); const dialog = await openSettings(); + fireEvent.click(within(dialog).getByRole("tab", { name: "About" })); fireEvent.click(within(dialog).getByRole("button", { name: "Check for updates" })); expect(await within(dialog).findByText("You have the latest version.")).toBeDefined(); host.update = new FakeUpdate("9.9.9"); @@ -96,7 +97,7 @@ describe("closing the window", () => { test("stops the bots, with the window out of sight first, when chosen", async () => { const { host } = renderApp(); await screen.findByRole("heading", { name: "Welcome to Botloft" }); - setWhenClosed("stop"); + prefs.whenClosed.set("stop"); await act(() => host.requestClose()); expect(host.hidden).toBe(true); expect(host.stops).toBe(1); @@ -104,7 +105,7 @@ describe("closing the window", () => { }); test("opening Botloft again starts the bots it stopped", async () => { - setWhenClosed("stop"); + prefs.whenClosed.set("stop"); const host = new FakeHost(); host.status = { state: "stopped", port: 45710, home: "C:\\data" }; let finish: () => void = () => {}; diff --git a/app/src/features/bots/useFollowBot.ts b/app/src/features/bots/useFollowBot.ts index 0ae6a9b7..bcb84263 100644 --- a/app/src/features/bots/useFollowBot.ts +++ b/app/src/features/bots/useFollowBot.ts @@ -3,10 +3,12 @@ // hand opens the browser (spec 21.8), and a screen it starts writing opens // the design area (spec 22.5), whatever panel was open. Each opens once per // start; closing it leaves the button's dot. The browser in the owner's -// hands is never taken away. +// hands is never taken away. The owner can turn this off in Settings +// (`prefs.followBot`): then only the buttons' dots say so. import { useEffect, useRef } from "react"; import type { Bot } from "../../lib/protocol.gen"; +import { prefs } from "../../shell/prefs"; import { useApp } from "../../store/context"; export type Followed = "browser" | "screens"; @@ -44,7 +46,7 @@ export function useFollowBot({ const was = before.current ?? { browsing, asking: false }; before.current = { browsing, asking }; if ((asking && !was.asking) || (browsing && !was.browsing)) { - if (side !== "browser") { + if (side !== "browser" && prefs.followBot.get()) { open("browser"); } } @@ -57,7 +59,7 @@ export function useFollowBot({ return; } drawn.current.add(path); - if (side === "screens" || (held && side === "browser")) { + if (side === "screens" || (held && side === "browser") || !prefs.followBot.get()) { return; } open("screens"); diff --git a/app/src/features/chat/ChatComposer.tsx b/app/src/features/chat/ChatComposer.tsx index 2fc1a641..fc524195 100644 --- a/app/src/features/chat/ChatComposer.tsx +++ b/app/src/features/chat/ChatComposer.tsx @@ -1,5 +1,7 @@ // Writing to the bot: text, and files picked, pasted or dropped on the -// chat. Enter sends and Shift+Enter starts a new line (spec 15.1). +// chat. Enter sends and Shift+Enter starts a new line, or, if the owner +// chose so in Settings, Ctrl+Enter sends and Enter starts a line (spec +// 15.1). import { ArrowUp, File as FileIcon, Paperclip, X } from "lucide-react"; import { @@ -16,6 +18,7 @@ import { useT } from "../../i18n"; import { errorText } from "../../lib/api"; import { fileSize } from "../../lib/format"; import { type Bot, FIELD_LIMITS } from "../../lib/protocol.gen"; +import { prefs, usePref } from "../../shell/prefs"; import { useApi } from "../../store/context"; import { rememberImage } from "./images"; import { ModelPicker } from "./ModelPicker"; @@ -126,8 +129,13 @@ export function ChatComposer({ } }; + const enterSends = usePref(prefs.enterSends); const onKeyDown = (event: KeyboardEvent) => { - if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) { + if (event.key !== "Enter" || event.nativeEvent.isComposing) { + return; + } + const withCtrl = event.ctrlKey || event.metaKey; + if (enterSends ? !event.shiftKey : withCtrl) { event.preventDefault(); void send(); } @@ -200,7 +208,7 @@ export function ChatComposer({ > {tooLong ? t.chat.composer.tooLong(text.length, FIELD_LIMITS.message) - : text && t.chat.composer.keys} + : text && (enterSends ? t.chat.composer.keys : t.chat.composer.keysWithCtrl)}