-
-
-
-
-
-
- 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)}