diff --git a/Cargo.lock b/Cargo.lock index 16dd6717..b311b735 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -518,7 +518,7 @@ dependencies = [ [[package]] name = "botloft-app" -version = "0.13.0" +version = "0.14.0" dependencies = [ "dirs 7.0.0", "serde", @@ -535,7 +535,7 @@ dependencies = [ [[package]] name = "botloft-cloud" -version = "0.13.0" +version = "0.14.0" dependencies = [ "anyhow", "axum", @@ -565,7 +565,7 @@ dependencies = [ [[package]] name = "botloft-core" -version = "0.13.0" +version = "0.14.0" dependencies = [ "deunicode", "serde", @@ -577,7 +577,7 @@ dependencies = [ [[package]] name = "botloft-setup" -version = "0.13.0" +version = "0.14.0" dependencies = [ "serde", "tauri", @@ -588,7 +588,7 @@ dependencies = [ [[package]] name = "botloft-store" -version = "0.13.0" +version = "0.14.0" dependencies = [ "botloft-core", "rusqlite", @@ -601,7 +601,7 @@ dependencies = [ [[package]] name = "botloftd" -version = "0.13.0" +version = "0.14.0" dependencies = [ "aes-gcm", "anyhow", diff --git a/Cargo.toml b/Cargo.toml index 9ccf6302..459a91a4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -4,7 +4,7 @@ members = ["crates/botloft-core", "crates/botloft-store", "crates/botloft-cloud" default-members = ["crates/botloft-core", "crates/botloft-store", "crates/botloft-cloud", "crates/botloftd"] [workspace.package] -version = "0.13.0" +version = "0.14.0" edition = "2024" license = "FSL-1.1-ALv2" repository = "https://github.com/httpsphl/botloft" diff --git a/app/package.json b/app/package.json index 392e4d2f..2e249e6d 100644 --- a/app/package.json +++ b/app/package.json @@ -1,7 +1,7 @@ { "name": "botloft-app", "private": true, - "version": "0.13.0", + "version": "0.14.0", "license": "FSL-1.1-ALv2", "type": "module", "packageManager": "pnpm@12.4.1", diff --git a/app/src/features/account/AccountArea.tsx b/app/src/features/account/AccountArea.tsx index dc2167ac..a545cfd9 100644 --- a/app/src/features/account/AccountArea.tsx +++ b/app/src/features/account/AccountArea.tsx @@ -18,7 +18,6 @@ import { useApp, useHost } from "../../store/context"; import { POPOVER, POPOVER_ITEM } from "../../ui/surface"; import { attempt } from "../../ui/toast"; import { useDismiss } from "../../ui/useDismiss"; -import { SettingsDialog } from "./SettingsDialog"; import { UsageDialog } from "./UsageDialog"; const RELEASES = "https://github.com/httpsphl/botloft/releases"; @@ -35,7 +34,8 @@ export function AccountArea() { const account = useApp((state) => state.system?.account ?? null); const signedOut = useApp((state) => state.system?.claudeSignedIn === false); const [open, setOpen] = useState(false); - const [dialog, setDialog] = useState<"usage" | "settings" | null>(null); + const [dialog, setDialog] = useState<"usage" | null>(null); + const openPage = useApp((state) => state.openPage); const root = useRef(null); useDismiss( @@ -48,7 +48,11 @@ export function AccountArea() { const detail = describe(account?.claude ?? null, signedOut, a); const show = (which: "usage" | "settings") => { setOpen(false); - setDialog(which); + if (which === "settings") { + openPage("settings"); + } else { + setDialog(which); + } }; return ( @@ -79,7 +83,6 @@ export function AccountArea() { {dialog === "usage" && setDialog(null)} />} - {dialog === "settings" && setDialog(null)} />} ); } diff --git a/app/src/features/account/AccountSettings.tsx b/app/src/features/account/AccountSettings.tsx new file mode 100644 index 00000000..cdd8e885 --- /dev/null +++ b/app/src/features/account/AccountSettings.tsx @@ -0,0 +1,19 @@ +// Settings, "Account and phone": the account that keeps the copies and +// connects the phone (spec 27.6), and the phones connected to it (spec 28.7). + +import { useT } from "../../i18n"; +import { CloudAccount } from "./CloudAccount"; +import { MobilePhones } from "./MobilePhones"; +import { Section } from "./settingsParts"; + +export function AccountSettings() { + const cloud = useT().account.cloud; + return ( + <> +
+ +
+ + + ); +} diff --git a/app/src/features/account/BackupSettings.tsx b/app/src/features/account/BackupSettings.tsx index 377caa3f..279d4553 100644 --- a/app/src/features/account/BackupSettings.tsx +++ b/app/src/features/account/BackupSettings.tsx @@ -12,22 +12,17 @@ import { Button } from "../../ui/Button"; import { Callout } from "../../ui/Callout"; import { BackupRestore } from "./BackupRestore"; import { backupError } from "./backupError"; -import { CloudAccount } from "./CloudAccount"; -import { MobilePhones } from "./MobilePhones"; +import { CloudBackup } from "./CloudBackup"; import { PassphraseFields, usePassphrase } from "./PassphraseFields"; import { Section } from "./settingsParts"; type Outcome = { saved: string } | { notSaved: true } | { failed: string } | null; -export function BackupSettings() { +export function BackupSettings({ goToAccount }: { goToAccount(): void }) { const b = useT().account.backup; - const cloud = useT().account.cloud; return ( <> -
- -
- +

{b.exportIntro}

diff --git a/app/src/features/account/CloudBackup.tsx b/app/src/features/account/CloudBackup.tsx new file mode 100644 index 00000000..165b7bd2 --- /dev/null +++ b/app/src/features/account/CloudBackup.tsx @@ -0,0 +1,45 @@ +// Settings, "Backup": the copies in the cloud (spec 27): send one, keep them +// up to date by themselves, bring one back. They need the account, which +// is in "Account and phone". + +import { useState } from "react"; +import { useT } from "../../i18n"; +import { Button } from "../../ui/Button"; +import { CloudAuto } from "./CloudAuto"; +import { CloudCopies } from "./CloudCopies"; +import { CloudUpload } from "./CloudUpload"; +import { Section } from "./settingsParts"; +import { useCloud } from "./useCloud"; + +export function CloudBackup({ goToAccount }: { goToAccount(): void }) { + const s = useT().account.settings; + const { status, refresh } = useCloud(); + const [version, setVersion] = useState(0); + // No cloud set up in this Botloft: the Account page says so. + if (!status?.url) { + return null; + } + return ( +
+ {status.signedIn ? ( + <> + { + setVersion((current) => current + 1); + void refresh(); + }} + /> + + + + ) : ( + <> +

{s.backupNeedsAccount}

+ + + )} +
+ ); +} diff --git a/app/src/features/account/CloudSignedIn.tsx b/app/src/features/account/CloudSignedIn.tsx index 7c53ad52..1e0d8546 100644 --- a/app/src/features/account/CloudSignedIn.tsx +++ b/app/src/features/account/CloudSignedIn.tsx @@ -1,5 +1,5 @@ // The account when this computer is in (spec 27.6): who, how much is used, -// the copies, and the way out. +// and the way out. The copies are in Backup (CloudBackup.tsx). import { useState } from "react"; import { useT } from "../../i18n"; @@ -10,9 +10,6 @@ import { Button } from "../../ui/Button"; import { Callout } from "../../ui/Callout"; import { Confirm } from "../../ui/Confirm"; import { backupError } from "./backupError"; -import { CloudAuto } from "./CloudAuto"; -import { CloudCopies } from "./CloudCopies"; -import { CloudUpload } from "./CloudUpload"; export function CloudSignedIn({ status, @@ -23,7 +20,6 @@ export function CloudSignedIn({ }) { const t = useT().account.cloud; const api = useApi(); - const [version, setVersion] = useState(0); const [removing, setRemoving] = useState(false); const [asked, setAsked] = useState(false); const [problem, setProblem] = useState(null); @@ -59,15 +55,6 @@ export function CloudSignedIn({ - { - setVersion((current) => current + 1); - void refresh(); - }} - /> - - -
{asked ? (

diff --git a/app/src/features/account/SettingsDialog.tsx b/app/src/features/account/SettingsDialog.tsx deleted file mode 100644 index 7ace8b21..00000000 --- a/app/src/features/account/SettingsDialog.tsx +++ /dev/null @@ -1,103 +0,0 @@ -// Settings (spec 15.1), in parts listed on the left: general (what -// Botloft does in the background, the language), the chat, notifications, -// the look, what was archived, backups, and the versions for when -// something needs reporting. - -import { - Archive, - Bell, - DatabaseBackup, - Info, - type LucideIcon, - MessageSquare, - Palette, - Plug, - SlidersHorizontal, -} from "lucide-react"; -import { useId, useState } from "react"; -import { useT } from "../../i18n"; -import { Dialog } from "../../ui/Dialog"; -import { ConnectedToolsSettings } from "../connections/ConnectedToolsSettings"; -import { AboutSettings } from "./AboutSettings"; -import { AlertsSettings } from "./AlertsSettings"; -import { AppearanceSettings } from "./AppearanceSettings"; -import { ArchivedSettings } from "./ArchivedSettings"; -import { BackupSettings } from "./BackupSettings"; -import { ChatSettings } from "./ChatSettings"; -import { GeneralSettings } from "./GeneralSettings"; - -type Page = - | "general" - | "chat" - | "alerts" - | "appearance" - | "archived" - | "tools" - | "backup" - | "about"; - -const PAGES: { id: Page; icon: LucideIcon }[] = [ - { id: "general", icon: SlidersHorizontal }, - { id: "chat", icon: MessageSquare }, - { id: "alerts", icon: Bell }, - { id: "appearance", icon: Palette }, - { id: "archived", icon: Archive }, - { id: "tools", icon: Plug }, - { id: "backup", icon: DatabaseBackup }, - { id: "about", icon: Info }, -]; - -export function SettingsDialog({ onClose }: { onClose(): void }) { - const s = useT().account.settings; - const [page, setPage] = useState("general"); - const id = useId(); - - return ( -

-
-
- {PAGES.map(({ id: each, icon: Icon }) => { - const selected = each === page; - return ( - - ); - })} -
-
- {page === "general" && } - {page === "chat" && } - {page === "alerts" && } - {page === "appearance" && } - {page === "archived" && } - {page === "tools" && } - {page === "backup" && } - {page === "about" && } -
-
-
- ); -} diff --git a/app/src/features/account/SettingsPage.tsx b/app/src/features/account/SettingsPage.tsx new file mode 100644 index 00000000..e733220c --- /dev/null +++ b/app/src/features/account/SettingsPage.tsx @@ -0,0 +1,208 @@ +// Settings (spec 15.1) as a page of its own: a search on top, the parts on +// the left in four groups (every day, account and connections, your data, +// Botloft) and the part open on the right. + +import { + Archive, + Bell, + DatabaseBackup, + Info, + type LucideIcon, + MessageSquare, + Palette, + Plug, + Search, + SlidersHorizontal, + Smartphone, +} from "lucide-react"; +import { useId, useMemo, useState } from "react"; +import { useT } from "../../i18n"; +import { ConnectedToolsSettings } from "../connections/ConnectedToolsSettings"; +import { AboutSettings } from "./AboutSettings"; +import { AccountSettings } from "./AccountSettings"; +import { AlertsSettings } from "./AlertsSettings"; +import { AppearanceSettings } from "./AppearanceSettings"; +import { ArchivedSettings } from "./ArchivedSettings"; +import { BackupSettings } from "./BackupSettings"; +import { ChatSettings } from "./ChatSettings"; +import { GeneralSettings } from "./GeneralSettings"; +import { type Page, plain, search, settingsIndex } from "./settingsIndex"; + +type GroupId = "groupDaily" | "groupConnect" | "groupData" | "groupBotloft"; + +const GROUPS: { id: GroupId; pages: { id: Page; icon: LucideIcon }[] }[] = [ + { + id: "groupDaily", + pages: [ + { id: "general", icon: SlidersHorizontal }, + { id: "appearance", icon: Palette }, + { id: "chat", icon: MessageSquare }, + { id: "alerts", icon: Bell }, + ], + }, + { + id: "groupConnect", + pages: [ + { id: "account", icon: Smartphone }, + { id: "tools", icon: Plug }, + ], + }, + { + id: "groupData", + pages: [ + { id: "backup", icon: DatabaseBackup }, + { id: "archived", icon: Archive }, + ], + }, + { id: "groupBotloft", pages: [{ id: "about", icon: Info }] }, +]; + +export function SettingsPage() { + const t = useT(); + const s = t.account.settings; + const [page, setPage] = useState("general"); + const [query, setQuery] = useState(""); + const id = useId(); + const names: Record = { + general: s.general, + appearance: s.appearance, + chat: s.chat, + alerts: s.alerts, + account: s.account, + tools: s.tools, + backup: s.backup, + archived: s.archived, + about: s.about, + }; + const entries = useMemo(() => settingsIndex(t), [t]); + const found = search(entries, query, names); + const searching = plain(query) !== ""; + + const open = (to: Page) => { + setPage(to); + setQuery(""); + }; + + return ( +
+
+
+

{s.title}

+ +
+ +
+ + +
+ {searching ? ( + + ) : ( + <> +

{names[page]}

+ {page === "general" && } + {page === "appearance" && } + {page === "chat" && } + {page === "alerts" && } + {page === "account" && } + {page === "tools" && } + {page === "backup" && open("account")} />} + {page === "archived" && } + {page === "about" && } + + )} +
+
+
+
+ ); +} + +function Results({ + found, + query, + names, + open, +}: { + found: ReturnType; + query: string; + names: Record; + open(page: Page): void; +}) { + const s = useT().account.settings; + if (found.length === 0) { + return

{s.searchNone(query.trim())}

; + } + return ( +
    + {found.map((entry) => ( +
  • + +
  • + ))} +
+ ); +} diff --git a/app/src/features/account/account.test.tsx b/app/src/features/account/account.test.tsx index 5a72fcd2..2bcb39db 100644 --- a/app/src/features/account/account.test.tsx +++ b/app/src/features/account/account.test.tsx @@ -92,7 +92,7 @@ describe("account area", () => { renderApp(); await openAccount(); fireEvent.click(screen.getByRole("menuitem", { name: "Settings" })); - const dialog = screen.getByRole("dialog", { name: "Settings" }); + const dialog = screen.getByRole("region", { 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"); @@ -101,11 +101,11 @@ describe("account area", () => { within(dialog).getByRole("button", { name: "Language: System language: English" }), ); fireEvent.click(within(dialog).getByRole("option", { name: "Español" })); - expect(screen.getByRole("dialog", { name: "Configuración" })).toBeDefined(); + expect(screen.getByRole("region", { name: "Configuración" })).toBeDefined(); fireEvent.click(screen.getByRole("button", { name: "Idioma: Español" })); fireEvent.click(screen.getByRole("option", { name: /Idioma del sistema/ })); fireEvent.click(screen.getByRole("tab", { name: "About" })); - expect(screen.getByRole("dialog", { name: "Settings" }).textContent).toContain("Botloft 0.1.0"); + expect(screen.getByRole("region", { name: "Settings" }).textContent).toContain("Botloft 0.1.0"); }); test("the setup screens keep the language menu, since they have no sidebar", async () => { diff --git a/app/src/features/account/archived.test.tsx b/app/src/features/account/archived.test.tsx index edcdea9c..ed8fff3d 100644 --- a/app/src/features/account/archived.test.tsx +++ b/app/src/features/account/archived.test.tsx @@ -10,7 +10,7 @@ afterEach(cleanup); async function openArchived() { fireEvent.click(await screen.findByRole("button", { name: /Ana Lima/ })); fireEvent.click(screen.getByRole("menuitem", { name: "Settings" })); - const dialog = screen.getByRole("dialog", { name: "Settings" }); + const dialog = screen.getByRole("region", { name: "Settings" }); fireEvent.click(within(dialog).getByRole("tab", { name: "Archived" })); return dialog; } diff --git a/app/src/features/account/autoBackup.test.tsx b/app/src/features/account/autoBackup.test.tsx index 8c0f10e5..c5a2e763 100644 --- a/app/src/features/account/autoBackup.test.tsx +++ b/app/src/features/account/autoBackup.test.tsx @@ -23,13 +23,14 @@ async function signedIn() { await crewOpened("Ops"); 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: "Backup" })); + const dialog = screen.getByRole("region", { name: "Settings" }); + fireEvent.click(within(dialog).getByRole("tab", { name: "Account and phone" })); type(await within(dialog).findByLabelText("Your e-mail"), "ana@exemplo.com"); fireEvent.click(within(dialog).getByRole("button", { name: "Send me the link" })); await within(dialog).findByText(/We sent a link/); act(() => openFakeCloudLink(fake)); await within(dialog).findByText("Signed in as ana@exemplo.com"); + fireEvent.click(within(dialog).getByRole("tab", { name: "Backup" })); // The block asks the daemon for its status, so it is there a moment later. await within(dialog).findByText("Automatic copies"); return { fake, dialog }; diff --git a/app/src/features/account/backup.test.tsx b/app/src/features/account/backup.test.tsx index b833311f..4a0801a8 100644 --- a/app/src/features/account/backup.test.tsx +++ b/app/src/features/account/backup.test.tsx @@ -17,7 +17,7 @@ async function openBackup() { await crewOpened("Ops"); fireEvent.click(await screen.findByRole("button", { name: /Ana Lima/ })); fireEvent.click(screen.getByRole("menuitem", { name: "Settings" })); - const dialog = screen.getByRole("dialog", { name: "Settings" }); + const dialog = screen.getByRole("region", { name: "Settings" }); fireEvent.click(within(dialog).getByRole("tab", { name: "Backup" })); return { fake, host, dialog }; } diff --git a/app/src/features/account/cloud.test.tsx b/app/src/features/account/cloud.test.tsx index 047701dd..348f058b 100644 --- a/app/src/features/account/cloud.test.tsx +++ b/app/src/features/account/cloud.test.tsx @@ -20,8 +20,8 @@ async function openBackup() { await crewOpened("Ops"); 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: "Backup" })); + const dialog = screen.getByRole("region", { name: "Settings" }); + fireEvent.click(within(dialog).getByRole("tab", { name: "Account and phone" })); return { fake, host, dialog }; } @@ -36,6 +36,9 @@ async function signIn(fake: FakeBotloft, dialog: HTMLElement) { await within(dialog).findByText("Signed in as ana@exemplo.com"); } +const tab = (dialog: HTMLElement, name: string) => + fireEvent.click(within(dialog).getByRole("tab", { name })); + /** Types the passphrase twice in the cloud block, the first pair of fields. */ function sendCopy(dialog: HTMLElement) { const [first] = within(dialog).getAllByLabelText("Passphrase"); @@ -69,6 +72,7 @@ describe("the account and the copies in the cloud", () => { test("a copy goes up, is listed, and comes back with its passphrase as a light one", async () => { const { fake, host, dialog } = await openBackup(); await signIn(fake, dialog); + tab(dialog, "Backup"); expect(await within(dialog).findByText("No copies yet.")).toBeDefined(); sendCopy(dialog); @@ -76,9 +80,11 @@ describe("the account and the copies in the cloud", () => { const upload = fake.calls.find((each) => each.method === "cloud.upload"); expect(upload?.params).toEqual({ passphrase: "correct horse" }); expect(await within(dialog).findByText(/47 KB$/)).toBeDefined(); - expect(within(dialog).getByText(/47 KB of 200 MB used/)).toBeDefined(); + tab(dialog, "Account and phone"); + expect(await within(dialog).findByText(/47 KB of 200 MB used/)).toBeDefined(); + tab(dialog, "Backup"); - fireEvent.click(within(dialog).getByRole("button", { name: "Restore" })); + fireEvent.click(await within(dialog).findByRole("button", { name: "Restore" })); const field = await within(dialog).findByLabelText("The copy's passphrase"); type(field, FAKE_PASSPHRASE); fireEvent.click(within(dialog).getByRole("button", { name: "Open" })); @@ -91,6 +97,8 @@ describe("the account and the copies in the cloud", () => { test("a copy is deleted only after the owner confirms", async () => { const { fake, dialog } = await openBackup(); await signIn(fake, dialog); + tab(dialog, "Backup"); + await within(dialog).findByText("No copies yet."); sendCopy(dialog); await within(dialog).findByText("Copy sent (47 KB)."); diff --git a/app/src/features/account/mobile.test.tsx b/app/src/features/account/mobile.test.tsx index 05a0b0f9..5123c640 100644 --- a/app/src/features/account/mobile.test.tsx +++ b/app/src/features/account/mobile.test.tsx @@ -16,8 +16,8 @@ async function openBackup() { await crewOpened("Ops"); 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: "Backup" })); + const dialog = screen.getByRole("region", { name: "Settings" }); + fireEvent.click(within(dialog).getByRole("tab", { name: "Account and phone" })); return { fake, dialog }; } diff --git a/app/src/features/account/prefs.test.tsx b/app/src/features/account/prefs.test.tsx index 39e8d5ce..9b3e95f3 100644 --- a/app/src/features/account/prefs.test.tsx +++ b/app/src/features/account/prefs.test.tsx @@ -24,7 +24,7 @@ async function scout() { 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" }); + const dialog = screen.getByRole("region", { name: "Settings" }); fireEvent.click(within(dialog).getByRole("tab", { name: page })); return dialog; } @@ -37,9 +37,9 @@ describe("the app's own choices", () => { 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" })); + openBot("Scout"); - const field = screen.getByLabelText("Message to Scout"); + const field = await screen.findByLabelText("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" }); diff --git a/app/src/features/account/settings.test.tsx b/app/src/features/account/settings.test.tsx index 2cd46024..6360125f 100644 --- a/app/src/features/account/settings.test.tsx +++ b/app/src/features/account/settings.test.tsx @@ -21,7 +21,7 @@ function renderApp(fake = new FakeBotloft(), host = new FakeHost()) { async function openSettings() { fireEvent.click(await screen.findByRole("button", { name: /Ana Lima/ })); fireEvent.click(screen.getByRole("menuitem", { name: "Settings" })); - return screen.getByRole("dialog", { name: "Settings" }); + return screen.getByRole("region", { name: "Settings" }); } describe("settings", () => { @@ -184,3 +184,59 @@ describe("closing the window", () => { ).toBeDefined(); }); }); + +describe("the page of settings", () => { + test("has its parts in groups, and the account is not in Backup", async () => { + renderApp(); + const page = await openSettings(); + for (const group of ["Every day", "Account and connections", "Your data", "Botloft"]) { + expect(within(page).getByText(group)).toBeDefined(); + } + const tabs = within(page) + .getAllByRole("tab") + .map((tab) => tab.textContent); + expect(tabs).toEqual([ + "General", + "Appearance", + "Chat", + "Notifications", + "Account and phone", + "Connected tools", + "Backup", + "Archived", + "About", + ]); + + fireEvent.click(within(page).getByRole("tab", { name: "Backup" })); + expect(within(page).queryByLabelText("Your e-mail")).toBeNull(); + expect(await within(page).findByText("Copies in the cloud need your account.")).toBeDefined(); + fireEvent.click(within(page).getByRole("button", { name: "Go to Account and phone" })); + expect(await within(page).findByLabelText("Your e-mail")).toBeDefined(); + expect( + within(page).getByRole("tab", { name: "Account and phone" }).getAttribute("aria-selected"), + ).toBe("true"); + }); + + test("a search finds a setting by its name and takes the owner to its page", async () => { + renderApp(); + const page = await openSettings(); + const box = within(page).getByRole("searchbox", { name: "Search settings" }); + fireEvent.change(box, { target: { value: "THEME" } }); + const results = within(page).getByRole("list", { name: "Search settings" }); + expect(results.textContent).toContain("In Appearance"); + fireEvent.click(within(results).getByRole("button", { name: /Theme/ })); + expect( + within(page).getByRole("tab", { name: "Appearance" }).getAttribute("aria-selected"), + ).toBe("true"); + expect((box as HTMLInputElement).value).toBe(""); + + fireEvent.change(box, { target: { value: "zzzz" } }); + expect(within(page).getByText('Nothing in Settings matches "zzzz".')).toBeDefined(); + }); + + test("opens before the first crew too, where a copy can be brought back", async () => { + renderApp(new FakeBotloft()); + const page = await openSettings(); + expect(within(page).getByRole("tab", { name: "Backup" })).toBeDefined(); + }); +}); diff --git a/app/src/features/account/settingsIndex.ts b/app/src/features/account/settingsIndex.ts new file mode 100644 index 00000000..454d1591 --- /dev/null +++ b/app/src/features/account/settingsIndex.ts @@ -0,0 +1,62 @@ +// What the search in Settings looks through: each setting's name and the +// page it is on, in the words of the current language. + +import type { useT } from "../../i18n"; +import { SYSTEM } from "../../lib/system"; + +export type Page = + | "general" + | "appearance" + | "chat" + | "alerts" + | "account" + | "tools" + | "backup" + | "archived" + | "about"; + +export interface Entry { + page: Page; + label: string; +} + +/** Accents and case do not matter when looking. */ +export const plain = (text: string) => + text.normalize("NFD").replace(/\p{M}/gu, "").toLowerCase().trim(); + +export function settingsIndex(t: ReturnType): Entry[] { + const s = t.account.settings; + const entry = (page: Page, ...labels: string[]): Entry[] => + labels.map((label) => ({ page, label })); + return [ + ...entry( + "general", + s.language, + s.background, + s.keepWorking, + s.tray, + s.startWithSystem(SYSTEM), + s.openAtSignIn(SYSTEM), + s.keepAwake, + ), + ...entry("appearance", s.theme, s.size, s.lessMotion), + ...entry("chat", s.enterSends, s.followBot, s.approvalWait), + ...entry("alerts", s.notifyNeeds, s.notifyDone, s.markReplies, s.sound, s.appSounds), + ...entry("account", t.account.cloud.title, t.account.mobile.title, t.account.mobile.connect), + ...entry("tools", s.tools), + ...entry("backup", t.account.backup.exportTitle, t.account.backup.importTitle, s.copiesTitle), + ...entry("archived", s.archived), + ...entry("about", s.about, s.checkUpdates), + ]; +} + +/** The entries that match `query`, the page's own name counting too. */ +export function search(entries: Entry[], query: string, names: Record): Entry[] { + const wanted = plain(query); + if (wanted === "") { + return []; + } + return entries.filter( + (entry) => plain(entry.label).includes(wanted) || plain(names[entry.page]).includes(wanted), + ); +} diff --git a/app/src/features/bots/BotView.tsx b/app/src/features/bots/BotView.tsx index 44e52f97..3f847f4b 100644 --- a/app/src/features/bots/BotView.tsx +++ b/app/src/features/bots/BotView.tsx @@ -4,7 +4,7 @@ import { useT } from "../../i18n"; import type { Bot, BotFile, Crew } from "../../lib/protocol.gen"; import { useWindowVisible } from "../../shell/visibility"; import { routinesOf } from "../../store/app"; -import { useApp } from "../../store/context"; +import { useApi, useApp } from "../../store/context"; import { PanelClosing, PanelRestored } from "../../ui/panelMotion"; import { type Tab, Tabs, tabId } from "../../ui/Tabs"; import { useStable } from "../../ui/useStable"; @@ -85,11 +85,14 @@ export function BotView({ bot, crew }: { bot: Bot; crew: Crew }) { const visible = useWindowVisible(); const replyAt = useApp((state) => state.replyAt[bot.id]); const markSeen = useApp((state) => state.markSeen); + const api = useApi(); useEffect(() => { if (visible && replyAt !== undefined) { markSeen(bot.id); + // A phone connected to this computer stops marking it new (spec 28.12). + api.call("chat.read", { botId: bot.id }).catch(() => {}); } - }, [bot.id, visible, replyAt, markSeen]); + }, [bot.id, visible, replyAt, markSeen, api]); // A browser at rest is open, but the bot is not using it (spec 21.2). const browsing = useApp((state) => { const browser = state.browsers[bot.id]; diff --git a/app/src/features/chat/ChatView.tsx b/app/src/features/chat/ChatView.tsx index 66c30df3..e9df281a 100644 --- a/app/src/features/chat/ChatView.tsx +++ b/app/src/features/chat/ChatView.tsx @@ -7,6 +7,7 @@ import { memo, type ReactNode, useCallback, + useEffect, useLayoutEffect, useMemo, useRef, @@ -50,6 +51,7 @@ export const ChatView = memo(function ChatView({ bot, stopped }: { bot: Bot; sto const files = useFiles(); const scroller = useRef(null); const listRef = useRef(null); + const content = useRef(null); /** The search result already shown, so it is not scrolled to again. */ const shownFocus = useRef(null); const atEnd = useRef(true); @@ -66,8 +68,9 @@ export const ChatView = memo(function ChatView({ bot, stopped }: { bot: Bot; sto const openedAt = useMemo(() => Date.now(), [bot.id]); useSharedSound(chat.items, openedAt); const last = chat.items.at(-1); + const working = bot.state === "busy"; /** Changes whenever the end of the chat does. */ - const end = `${chat.items.length}/${last?.id}/${last?.updatedAt}/${chat.draft.length}`; + const end = `${chat.items.length}/${last?.id}/${last?.updatedAt}/${chat.draft.length}/${working}`; const seenTail = useRef(""); useLayoutEffect(() => { @@ -84,6 +87,23 @@ export const ChatView = memo(function ChatView({ bot, stopped }: { bot: Bot; sto } }, [end]); + // Whatever makes the chat taller (the dots of a bot that starts, a card + // opening, an image loading) keeps the end in view for who is reading it. + useEffect(() => { + const element = content.current; + if (!element || typeof ResizeObserver === "undefined") { + return; + } + const observer = new ResizeObserver(() => { + const scrolled = scroller.current; + if (scrolled && atEnd.current) { + scrolled.scrollTop = scrolled.scrollHeight; + } + }); + observer.observe(element); + return () => observer.disconnect(); + }, []); + const onSent = useCallback(() => { atEnd.current = true; }, []); @@ -93,10 +113,11 @@ export const ChatView = memo(function ChatView({ bot, stopped }: { bot: Bot; sto if (element) { anchor.current = element.scrollHeight - element.scrollTop; } + // Whoever loads older items is reading up the chat, not following it. + atEnd.current = false; void chat.loadOlder(); }; - const working = bot.state === "busy"; const live: Live | undefined = chat.draft || working ? { draft: chat.draft, working } : undefined; const rows = useMemo(() => chatRows(chat.items), [chat.items]); const tail = rows.at(-1); @@ -181,7 +202,7 @@ export const ChatView = memo(function ChatView({ bot, stopped }: { bot: Bot; sto element.scrollHeight - element.scrollTop - element.clientHeight < STICKY_PX; }} > -
+
{chat.error && ( {chat.error} diff --git a/app/src/features/chat/follow.test.tsx b/app/src/features/chat/follow.test.tsx new file mode 100644 index 00000000..6c6378d2 --- /dev/null +++ b/app/src/features/chat/follow.test.tsx @@ -0,0 +1,55 @@ +import { act, cleanup, fireEvent, screen } from "@testing-library/react"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { FakeBotloft } from "../../lib/fake"; +import { crewOpened, openBot, renderApp } from "../../test/app"; + +afterEach(() => { + cleanup(); + vi.unstubAllGlobals(); +}); + +describe("following the end of the chat", () => { + test("keeps the end in view as the chat grows, and leaves who scrolled up", async () => { + let grew = () => {}; + vi.stubGlobal( + "ResizeObserver", + class { + constructor(callback: ResizeObserverCallback) { + grew = () => callback([], this as unknown as ResizeObserver); + } + observe() {} + disconnect() {} + }, + ); + const fake = new FakeBotloft(); + const ops = fake.addCrew("Ops"); + const scout = fake.addBot(ops.id, "Scout", "Finds sources"); + fake.setBotState(scout.id, "idle"); + renderApp(fake); + await crewOpened("Ops"); + openBot("Scout"); + const list = await screen.findByRole("list", { name: "Messages" }); + const scroller = list.closest(".overflow-y-auto") as HTMLElement; + let height = 5000; + Object.defineProperty(scroller, "scrollHeight", { get: () => height }); + + // The dots of a bot that starts working make the chat taller. + act(() => fake.setBotState(scout.id, "busy")); + act(() => grew()); + expect(scroller.scrollTop).toBe(5000); + + // Reading up the chat: it stays where it is. + scroller.scrollTop = 0; + fireEvent.scroll(scroller); + height = 6000; + act(() => grew()); + expect(scroller.scrollTop).toBe(0); + + // Back at the end: it follows again. + scroller.scrollTop = 5990; + fireEvent.scroll(scroller); + height = 7000; + act(() => grew()); + expect(scroller.scrollTop).toBe(7000); + }); +}); diff --git a/app/src/features/connections/connections.test.tsx b/app/src/features/connections/connections.test.tsx index 91b658a1..6f4eecac 100644 --- a/app/src/features/connections/connections.test.tsx +++ b/app/src/features/connections/connections.test.tsx @@ -21,7 +21,7 @@ async function openTools() { await crewOpened("Ops"); fireEvent.click(await screen.findByRole("button", { name: /Ana Lima/ })); fireEvent.click(screen.getByRole("menuitem", { name: "Settings" })); - const dialog = screen.getByRole("dialog", { name: "Settings" }); + const dialog = screen.getByRole("region", { name: "Settings" }); fireEvent.click(within(dialog).getByRole("tab", { name: "Connected tools" })); return { fake, scout, dialog }; } @@ -105,7 +105,6 @@ describe("connected tools", () => { await waitFor(() => expect(screen.queryByRole("dialog", { name: "Connect a tool" })).toBeNull(), ); - fireEvent.click(within(dialog).getByRole("button", { name: "Close" })); openBot("Scout"); await screen.findByRole("region", { name: "Chat with Scout" }); diff --git a/app/src/i18n/en/account.ts b/app/src/i18n/en/account.ts index 26cb9996..c63c902c 100644 --- a/app/src/i18n/en/account.ts +++ b/app/src/i18n/en/account.ts @@ -98,7 +98,7 @@ export const account = { } as Record, }, cloud: { - title: "Account and cloud copies", + title: "Account", noCloud: "Cloud copies are not set up in this Botloft yet.", intro: "An account keeps copies of your crews in the cloud, so you can bring them back on another computer. Only your e-mail and the copies are kept, and nobody can open a copy without your passphrase.", @@ -178,10 +178,10 @@ export const account = { mobile: { title: "Phone", intro: - "Approve your bots' requests and answer their questions from anywhere. Chats and files stay only on this computer.", + "Approve your bots' requests, answer their questions and talk with them from anywhere. Files stay only on this computer.", needAccount: "Sign in to the account above to use the phone.", warning: - "Whoever holds a connected phone, unlocked, can allow requests until you disconnect it.", + "Whoever holds a connected phone, unlocked, can read your bots' conversations, write to them and allow requests until you disconnect it. A PIN on the phone helps; you can turn it on there.", connect: "Connect a phone", starting: "Preparing…", qrLabel: "Code to scan with your phone", @@ -283,6 +283,17 @@ export const account = { about: "About", backup: "Backup", tools: "Connected tools", + account: "Account and phone", + groupDaily: "Every day", + groupConnect: "Account and connections", + groupData: "Your data", + groupBotloft: "Botloft", + search: "Search settings", + searchNone: (query: string) => `Nothing in Settings matches "${query}".`, + searchIn: (page: string) => `In ${page}`, + backupNeedsAccount: "Copies in the cloud need your account.", + goToAccount: "Go to Account and phone", + copiesTitle: "Copies in the cloud", claudeCode: (version: string) => `Claude Code ${version}`, botloft: (version: string) => `Botloft ${version}`, checkUpdates: "Check for updates", diff --git a/app/src/i18n/en/phone.ts b/app/src/i18n/en/phone.ts index eb98d41b..81a06e7e 100644 --- a/app/src/i18n/en/phone.ts +++ b/app/src/i18n/en/phone.ts @@ -5,7 +5,7 @@ export const phone = { pair: { title: "Connect this phone", intro: - "This phone will approve your bots' requests and answer their questions, from anywhere. Chats and files stay on your computer.", + "This phone will approve your bots' requests and answer their questions and talk with them, from anywhere. Files stay on your computer.", nameLabel: "Name of this phone", connect: "Connect", connecting: "Connecting…", @@ -13,7 +13,7 @@ export const phone = { `Check that your computer shows ${code} too. Then press Connect on the computer.`, waiting: "Waiting for you to accept on the computer…", notALink: - "To connect a phone, point its camera at the code on your computer: Settings, Backup, Phone.", + "To connect a phone, point its camera at the code on your computer: Settings, Account and phone.", expired: "The code ran out or was already used. Ask for a new one on the computer.", offline: "Could not reach Botloft. Check the connection and try again.", failed: "Could not connect. Ask for a new code on the computer.", @@ -29,6 +29,59 @@ export const phone = { thisPhone: "This phone", inCrew: (crew: string) => `in ${crew}`, }, + tabs: { requests: "Requests", chats: "Chats", newReply: "new reply" }, + chats: { + empty: "No bots yet", + emptyBody: "When you create a bot on your computer, it shows up here.", + loading: "Loading the bots…", + working: "working…", + kind: { + owner: "You", + message: "Message", + reply: "Replied", + tool: "Used", + approval: "Asks to use", + question: "Asks", + notice: "Notice", + }, + inCrew: (crew: string) => `in ${crew}`, + allCrews: "All", + crewsLabel: "Crews", + bots: (n: number) => `${n} ${n === 1 ? "bot" : "bots"}`, + back: "Chats", + loadingChat: "Loading the conversation…", + nothing: "Nothing here yet. Write the first message.", + older: "See older", + loadingOlder: "Loading…", + busy: (bot: string) => `${bot} is working…`, + from: (name: string) => `From ${name}`, + cut: "Cut: too long for a phone. The whole text is on your computer.", + failedTurn: "The turn failed", + tool: (summary: string) => `Used: ${summary}`, + approvalLine: (summary: string) => `Asked to: ${summary}`, + questionLine: (text: string) => `Asked: ${text}`, + composer: { + label: (bot: string) => `Message to ${bot}`, + placeholder: "Write a message", + send: "Send", + tooLong: "Too long to send from the phone.", + }, + pending: { + sending: "Sending…", + sent: "Sent. The bot will see it when it can.", + notSent: "Not sent.", + reasons: { + offline: "Check the connection and try again.", + timeout: "The computer did not answer. Check it is on and try again.", + invalid: "The bot cannot take this text.", + not_found: "This bot is gone.", + rate_limited: "Too many messages in a minute. Wait a bit.", + failed: "Something went wrong. Try again.", + } as Record, + }, + reach: + "A phone that is connected and unlocked can read your bots' conversations and write to them.", + }, approval: { asks: (bot: string) => `${bot} asks to`, explanationBy: (bot: string) => `${bot} wrote this`, @@ -77,10 +130,44 @@ export const phone = { cancel: "Cancel", install: "To get notices, add Botloft to your Home Screen: tap Share, then Add to Home Screen.", }, + lock: { + title: "Botloft is locked", + pinLabel: "PIN", + unlock: "Unlock", + unlocking: "Opening…", + wrong: (left: number) => `Wrong PIN. ${left} ${left === 1 ? "try" : "tries"} left.`, + wait: (seconds: number) => `Wait ${seconds} s before trying again.`, + wiped: "Too many wrong PINs: this phone was disconnected. Connect it again from your computer.", + forgot: "I forgot the PIN", + forgotTitle: "Forgot the PIN?", + forgotText: + "This phone will disconnect, and you connect it again from your computer. Nothing is lost: the conversations stay on the computer.", + forgotConfirm: "Disconnect this phone", + }, + pin: { + title: "PIN lock", + why: "With a PIN, whoever picks up this phone unlocked cannot read your conversations or approve requests. Without it, the phone stays open to whoever holds it.", + on: "The PIN is on. The app locks a minute after you leave it.", + off: "The PIN is off.", + turnOn: "Turn on the PIN", + turnOff: "Turn off the PIN", + newPin: "New PIN (6 to 10 numbers)", + again: "Repeat the PIN", + current: "Current PIN", + mismatch: "The two PINs are different.", + invalid: "Use 6 to 10 numbers.", + wrongPin: "Wrong PIN.", + save: "Save", + cancel: "Cancel", + old: "To turn on the PIN, connect this phone again: ask for a new code on your computer.", + nudgeTitle: "Protect this phone with a PIN?", + nudgeYes: "Protect with a PIN", + nudgeNo: "Not now", + }, cut: { revokedTitle: "This phone was disconnected", leftTitle: "Phone disconnected", - body: "To use it again, connect it from Botloft on your computer: Settings, Backup, Phone.", + body: "To use it again, connect it from Botloft on your computer: Settings, Account and phone.", ok: "OK", }, }; diff --git a/app/src/i18n/es/account.ts b/app/src/i18n/es/account.ts index 7c376c25..f178dbf4 100644 --- a/app/src/i18n/es/account.ts +++ b/app/src/i18n/es/account.ts @@ -98,7 +98,7 @@ export const account: Messages["account"] = { } as Record, }, cloud: { - title: "Cuenta y copias en la nube", + title: "Cuenta", noCloud: "Las copias en la nube aún no están configuradas en este Botloft.", intro: "La cuenta guarda copias de tus equipos en la nube, para que las recuperes en otro ordenador. Solo se guardan tu correo y las copias, y nadie abre una copia sin tu contraseña.", @@ -180,10 +180,10 @@ export const account: Messages["account"] = { mobile: { title: "Móvil", intro: - "Aprueba las solicitudes de tus bots y responde a sus preguntas desde cualquier lugar. Las conversaciones y los archivos se quedan solo en este ordenador.", + "Aprueba las solicitudes de tus bots, responde a sus preguntas y habla con ellos desde cualquier lugar. Los archivos se quedan solo en este ordenador.", needAccount: "Inicia sesión en la cuenta de arriba para usar el móvil.", warning: - "Quien tenga un móvil conectado y desbloqueado puede permitir solicitudes hasta que lo desconectes.", + "Quien tenga un móvil conectado y desbloqueado puede leer las conversaciones de tus bots, escribirles y permitir solicitudes hasta que lo desconectes. Un PIN en el móvil ayuda; lo activas allí.", connect: "Conectar un móvil", starting: "Preparando…", qrLabel: "Código para escanear con el móvil", @@ -286,6 +286,17 @@ export const account: Messages["account"] = { about: "Acerca de", backup: "Copia de seguridad", tools: "Herramientas conectadas", + account: "Cuenta y móvil", + groupDaily: "Cada día", + groupConnect: "Cuenta y conexiones", + groupData: "Tus datos", + groupBotloft: "Botloft", + search: "Buscar en los ajustes", + searchNone: (query: string) => `Nada en los ajustes coincide con "${query}".`, + searchIn: (page: string) => `En ${page}`, + backupNeedsAccount: "Las copias en la nube necesitan tu cuenta.", + goToAccount: "Ir a Cuenta y móvil", + copiesTitle: "Copias en la nube", claudeCode: (version: string) => `Claude Code ${version}`, botloft: (version: string) => `Botloft ${version}`, checkUpdates: "Buscar actualizaciones", diff --git a/app/src/i18n/es/phone.ts b/app/src/i18n/es/phone.ts index 05acadc5..9cdce08c 100644 --- a/app/src/i18n/es/phone.ts +++ b/app/src/i18n/es/phone.ts @@ -6,7 +6,7 @@ export const phone: Messages["phone"] = { pair: { title: "Conectar este móvil", intro: - "Este móvil aprobará las solicitudes de tus bots y responderá a sus preguntas, desde cualquier lugar. Las conversaciones y los archivos se quedan en tu ordenador.", + "Este móvil aprobará las solicitudes de tus bots, responderá a sus preguntas y hablará con ellos, desde cualquier lugar. Los archivos se quedan en tu ordenador.", nameLabel: "Nombre de este móvil", connect: "Conectar", connecting: "Conectando…", @@ -14,7 +14,7 @@ export const phone: Messages["phone"] = { `Comprueba que el ordenador muestre ${code} también. Luego pulsa Conectar en el ordenador.`, waiting: "Esperando a que aceptes en el ordenador…", notALink: - "Para conectar un móvil, apunta su cámara al código que aparece en el ordenador: Ajustes, Copia de seguridad, Móvil.", + "Para conectar un móvil, apunta su cámara al código que aparece en el ordenador: Ajustes, Cuenta y móvil.", expired: "El código venció o ya se usó. Pide uno nuevo en el ordenador.", offline: "No se pudo hablar con Botloft. Comprueba la conexión e inténtalo de nuevo.", failed: "No se pudo conectar. Pide un código nuevo en el ordenador.", @@ -30,6 +30,59 @@ export const phone: Messages["phone"] = { thisPhone: "Este móvil", inCrew: (crew: string) => `en ${crew}`, }, + tabs: { requests: "Solicitudes", chats: "Conversaciones", newReply: "respuesta nueva" }, + chats: { + empty: "Aún no hay bots", + emptyBody: "Cuando crees un bot en el ordenador, aparece aquí.", + loading: "Cargando los bots…", + working: "trabajando…", + kind: { + owner: "Tú", + message: "Mensaje", + reply: "Respondió", + tool: "Usó", + approval: "Pide usar", + question: "Pregunta", + notice: "Aviso", + }, + inCrew: (crew: string) => `en ${crew}`, + allCrews: "Todos", + crewsLabel: "Equipos", + bots: (n: number) => `${n} ${n === 1 ? "bot" : "bots"}`, + back: "Conversaciones", + loadingChat: "Cargando la conversación…", + nothing: "Nada aquí todavía. Escribe el primer mensaje.", + older: "Ver más antiguas", + loadingOlder: "Cargando…", + busy: (bot: string) => `${bot} está trabajando…`, + from: (name: string) => `De ${name}`, + cut: "Cortado: demasiado grande para el móvil. El texto entero está en tu ordenador.", + failedTurn: "El turno falló", + tool: (summary: string) => `Usó: ${summary}`, + approvalLine: (summary: string) => `Pidió: ${summary}`, + questionLine: (text: string) => `Preguntó: ${text}`, + composer: { + label: (bot: string) => `Mensaje para ${bot}`, + placeholder: "Escribe un mensaje", + send: "Enviar", + tooLong: "Demasiado grande para enviar desde el móvil.", + }, + pending: { + sending: "Enviando…", + sent: "Enviado. El bot lo verá cuando pueda.", + notSent: "No enviado.", + reasons: { + offline: "Revisa la conexión e inténtalo de nuevo.", + timeout: "El ordenador no respondió. Mira si está encendido e inténtalo de nuevo.", + invalid: "El bot no acepta este texto.", + not_found: "Este bot ya no existe.", + rate_limited: "Demasiados mensajes en un minuto. Espera un poco.", + failed: "Algo salió mal. Inténtalo de nuevo.", + } as Record, + }, + reach: + "Un móvil conectado y desbloqueado puede leer las conversaciones de tus bots y escribirles.", + }, approval: { asks: (bot: string) => `${bot} pide`, explanationBy: (bot: string) => `${bot} escribió esto`, @@ -79,10 +132,46 @@ export const phone: Messages["phone"] = { install: "Para recibir avisos, añade Botloft a la pantalla de inicio: toca Compartir y luego Añadir a pantalla de inicio.", }, + lock: { + title: "Botloft está bloqueado", + pinLabel: "PIN", + unlock: "Desbloquear", + unlocking: "Abriendo…", + wrong: (left: number) => + `PIN incorrecto. ${left === 1 ? "Queda 1 intento" : `Quedan ${left} intentos`}.`, + wait: (seconds: number) => `Espera ${seconds} s antes de intentarlo de nuevo.`, + wiped: + "Demasiados PIN incorrectos: este móvil fue desconectado. Conéctalo de nuevo desde el ordenador.", + forgot: "Olvidé el PIN", + forgotTitle: "¿Olvidaste el PIN?", + forgotText: + "Este móvil se desconectará y lo conectas de nuevo desde el ordenador. No se pierde nada: las conversaciones se quedan en el ordenador.", + forgotConfirm: "Desconectar este móvil", + }, + pin: { + title: "Bloqueo con PIN", + why: "Con un PIN, quien coja este móvil desbloqueado no puede leer tus conversaciones ni aprobar solicitudes. Sin él, el móvil queda abierto para quien lo tenga en la mano.", + on: "El PIN está activado. La app se bloquea un minuto después de salir de ella.", + off: "El PIN está desactivado.", + turnOn: "Activar el PIN", + turnOff: "Desactivar el PIN", + newPin: "Nuevo PIN (de 6 a 10 números)", + again: "Repite el PIN", + current: "PIN actual", + mismatch: "Los dos PIN son distintos.", + invalid: "Usa de 6 a 10 números.", + wrongPin: "PIN incorrecto.", + save: "Guardar", + cancel: "Cancelar", + old: "Para activar el PIN, conecta este móvil de nuevo: pide un código nuevo en el ordenador.", + nudgeTitle: "¿Proteger este móvil con un PIN?", + nudgeYes: "Proteger con un PIN", + nudgeNo: "Ahora no", + }, cut: { revokedTitle: "Este móvil fue desconectado", leftTitle: "Móvil desconectado", - body: "Para usarlo de nuevo, conéctalo desde Botloft en el ordenador: Ajustes, Copia de seguridad, Móvil.", + body: "Para usarlo de nuevo, conéctalo desde Botloft en el ordenador: Ajustes, Cuenta y móvil.", ok: "OK", }, }; diff --git a/app/src/i18n/pt-BR/account.ts b/app/src/i18n/pt-BR/account.ts index 501b8663..33338c58 100644 --- a/app/src/i18n/pt-BR/account.ts +++ b/app/src/i18n/pt-BR/account.ts @@ -98,7 +98,7 @@ export const account: Messages["account"] = { } as Record, }, cloud: { - title: "Conta e cópias na nuvem", + title: "Conta", noCloud: "As cópias na nuvem ainda não estão configuradas neste Botloft.", intro: "A conta guarda cópias das suas equipes na nuvem, para você trazê-las de volta em outro computador. Só o seu e-mail e as cópias ficam guardados, e ninguém abre uma cópia sem a sua senha.", @@ -180,10 +180,10 @@ export const account: Messages["account"] = { mobile: { title: "Celular", intro: - "Aprove os pedidos dos seus bots e responda às perguntas deles de qualquer lugar. As conversas e os arquivos ficam só neste computador.", + "Aprove os pedidos dos seus bots, responda às perguntas deles e converse com eles de qualquer lugar. Os arquivos ficam só neste computador.", needAccount: "Entre na conta acima para usar o celular.", warning: - "Quem estiver com um celular conectado e desbloqueado pode permitir pedidos até você desconectá-lo.", + "Quem estiver com um celular conectado e desbloqueado pode ler as conversas dos seus bots, escrever para eles e permitir pedidos até você desconectá-lo. Um PIN no celular ajuda; você liga nele.", connect: "Conectar um celular", starting: "Preparando…", qrLabel: "Código para ler com o celular", @@ -284,6 +284,17 @@ export const account: Messages["account"] = { about: "Sobre", backup: "Cópia de segurança", tools: "Ferramentas conectadas", + account: "Conta e celular", + groupDaily: "No dia a dia", + groupConnect: "Conta e conexões", + groupData: "Seus dados", + groupBotloft: "Botloft", + search: "Buscar nas configurações", + searchNone: (query: string) => `Nada nas configurações combina com "${query}".`, + searchIn: (page: string) => `Em ${page}`, + backupNeedsAccount: "As cópias na nuvem precisam da sua conta.", + goToAccount: "Ir para Conta e celular", + copiesTitle: "Cópias na nuvem", claudeCode: (version: string) => `Claude Code ${version}`, botloft: (version: string) => `Botloft ${version}`, checkUpdates: "Procurar atualizações", diff --git a/app/src/i18n/pt-BR/phone.ts b/app/src/i18n/pt-BR/phone.ts index 97bba213..3a1e5513 100644 --- a/app/src/i18n/pt-BR/phone.ts +++ b/app/src/i18n/pt-BR/phone.ts @@ -6,7 +6,7 @@ export const phone: Messages["phone"] = { pair: { title: "Conectar este celular", intro: - "Este celular vai aprovar os pedidos dos seus bots e responder às perguntas deles, de qualquer lugar. As conversas e os arquivos ficam no seu computador.", + "Este celular vai aprovar os pedidos dos seus bots responder às perguntas deles e conversar com eles, de qualquer lugar. Os arquivos ficam no seu computador.", nameLabel: "Nome deste celular", connect: "Conectar", connecting: "Conectando…", @@ -14,7 +14,7 @@ export const phone: Messages["phone"] = { `Confira se o computador mostra ${code} também. Depois aperte Conectar no computador.`, waiting: "Esperando você aceitar no computador…", notALink: - "Para conectar um celular, aponte a câmera dele para o código que aparece no computador: Configurações, Cópia de segurança, Celular.", + "Para conectar um celular, aponte a câmera dele para o código que aparece no computador: Configurações, Conta e celular.", expired: "O código venceu ou já foi usado. Peça um novo no computador.", offline: "Não foi possível falar com o Botloft. Confira a conexão e tente de novo.", failed: "Não foi possível conectar. Peça um novo código no computador.", @@ -30,6 +30,59 @@ export const phone: Messages["phone"] = { thisPhone: "Este celular", inCrew: (crew: string) => `em ${crew}`, }, + tabs: { requests: "Pedidos", chats: "Conversas", newReply: "resposta nova" }, + chats: { + empty: "Nenhum bot ainda", + emptyBody: "Quando você criar um bot no computador, ele aparece aqui.", + loading: "Carregando os bots…", + working: "trabalhando…", + kind: { + owner: "Você", + message: "Mensagem", + reply: "Respondeu", + tool: "Usou", + approval: "Pede para usar", + question: "Pergunta", + notice: "Aviso", + }, + inCrew: (crew: string) => `em ${crew}`, + allCrews: "Todas", + crewsLabel: "Equipes", + bots: (n: number) => `${n} ${n === 1 ? "bot" : "bots"}`, + back: "Conversas", + loadingChat: "Carregando a conversa…", + nothing: "Nada aqui ainda. Escreva a primeira mensagem.", + older: "Ver mais antigas", + loadingOlder: "Carregando…", + busy: (bot: string) => `${bot} está trabalhando…`, + from: (name: string) => `De ${name}`, + cut: "Cortado: grande demais para o celular. O texto inteiro está no seu computador.", + failedTurn: "A vez falhou", + tool: (summary: string) => `Usou: ${summary}`, + approvalLine: (summary: string) => `Pediu para: ${summary}`, + questionLine: (text: string) => `Perguntou: ${text}`, + composer: { + label: (bot: string) => `Mensagem para ${bot}`, + placeholder: "Escreva uma mensagem", + send: "Enviar", + tooLong: "Grande demais para enviar pelo celular.", + }, + pending: { + sending: "Enviando…", + sent: "Enviada. O bot vai ver quando puder.", + notSent: "Não enviada.", + reasons: { + offline: "Veja a conexão e tente de novo.", + timeout: "O computador não respondeu. Veja se está ligado e tente de novo.", + invalid: "O bot não aceita este texto.", + not_found: "Este bot não existe mais.", + rate_limited: "Mensagens demais em um minuto. Espere um pouco.", + failed: "Algo deu errado. Tente de novo.", + } as Record, + }, + reach: + "Um celular conectado e desbloqueado pode ler as conversas dos seus bots e escrever para eles.", + }, approval: { asks: (bot: string) => `${bot} pede para`, explanationBy: (bot: string) => `${bot} escreveu isto`, @@ -78,10 +131,45 @@ export const phone: Messages["phone"] = { install: "Para receber avisos, ponha o Botloft na tela inicial: toque em Compartilhar e depois em Adicionar à Tela de Início.", }, + lock: { + title: "O Botloft está trancado", + pinLabel: "PIN", + unlock: "Destrancar", + unlocking: "Abrindo…", + wrong: (left: number) => + `PIN errado. ${left === 1 ? "Resta 1 tentativa" : `Restam ${left} tentativas`}.`, + wait: (seconds: number) => `Espere ${seconds} s antes de tentar de novo.`, + wiped: "Muitos PINs errados: este celular foi desconectado. Conecte de novo pelo computador.", + forgot: "Esqueci o PIN", + forgotTitle: "Esqueceu o PIN?", + forgotText: + "Este celular vai se desconectar, e você conecta de novo pelo computador. Nada se perde: as conversas ficam no computador.", + forgotConfirm: "Desconectar este celular", + }, + pin: { + title: "Trava com PIN", + why: "Com um PIN, quem pegar este celular desbloqueado não consegue ler suas conversas nem aprovar pedidos. Sem ele, o celular fica aberto para quem o segurar.", + on: "O PIN está ligado. O app tranca um minuto depois de você sair dele.", + off: "O PIN está desligado.", + turnOn: "Ligar o PIN", + turnOff: "Desligar o PIN", + newPin: "Novo PIN (de 6 a 10 números)", + again: "Repita o PIN", + current: "PIN atual", + mismatch: "Os dois PINs são diferentes.", + invalid: "Use de 6 a 10 números.", + wrongPin: "PIN errado.", + save: "Salvar", + cancel: "Cancelar", + old: "Para ligar o PIN, conecte este celular de novo: peça um código novo no computador.", + nudgeTitle: "Proteger este celular com um PIN?", + nudgeYes: "Proteger com um PIN", + nudgeNo: "Agora não", + }, cut: { revokedTitle: "Este celular foi desconectado", leftTitle: "Celular desconectado", - body: "Para usar de novo, conecte pelo Botloft no computador: Configurações, Cópia de segurança, Celular.", + body: "Para usar de novo, conecte pelo Botloft no computador: Configurações, Conta e celular.", ok: "OK", }, }; diff --git a/app/src/lib/fakeChat.ts b/app/src/lib/fakeChat.ts index 671a22cf..46622576 100644 --- a/app/src/lib/fakeChat.ts +++ b/app/src/lib/fakeChat.ts @@ -178,8 +178,9 @@ export class FakeChat { }); } - handlers(): Pick { + handlers(): Pick { return { + "chat.read": () => null, "chat.history": ({ botId, before, limit, until }) => { this.fake.bot(botId, false); const mine = this.items.filter((item) => item.botId === botId); diff --git a/app/src/lib/protocol.gen.ts b/app/src/lib/protocol.gen.ts index 218e2f5a..00645196 100644 --- a/app/src/lib/protocol.gen.ts +++ b/app/src/lib/protocol.gen.ts @@ -1453,15 +1453,39 @@ atComputer: boolean, }; */ export type QuestionCard = { questionId: QuestionId, bot: CardBot, crew: string, createdAt: number, text: string, options: Array, }; +/** + * A bot in the phone's list of conversations (spec 28.12). + */ +export type ChatLine = { botId: BotId, name: string, color: string, crew: string, state: BotState, +/** + * When the bot last finished a reply, in Unix milliseconds. + */ +lastReplyAt?: number, +/** + * The last thing in the chat, on one line. + */ +last?: Activity, +/** + * Up to when the owner read this chat on the computer, so the phone + * does not mark as new what was read there. + */ +readAt?: number, }; + +/** + * One thing in a conversation, as the phone shows it (spec 28.12): the + * text, and never the output of a tool. + */ +export type PhoneItem = { "kind": "you", id: ChatItemId, at: number, text: string, cut: boolean, } | { "kind": "bot_message", id: ChatItemId, at: number, from: string, text: string, cut: boolean, } | { "kind": "reply", id: ChatItemId, at: number, text: string, cut: boolean, } | { "kind": "tool", id: ChatItemId, at: number, summary: string, } | { "kind": "approval", id: ChatItemId, at: number, approvalId: ApprovalId, summary: string, status: ApprovalStatus, } | { "kind": "question", id: ChatItemId, at: number, questionId: QuestionId, text: string, status: QuestionStatus, } | { "kind": "failed", id: ChatItemId, at: number, error?: string, } | { "kind": "notice", id: ChatItemId, at: number, level: NoticeLevel, text: string, }; + /** * What the computer sends the phone, sealed (spec 28.3). */ -export type ToPhone = { "t": "snapshot", approvals: Array, questions: Array, } | { "t": "approval.open", card: ApprovalCard, } | { "t": "approval.closed", approvalId: ApprovalId, status: ApprovalStatus, } | { "t": "question.open", card: QuestionCard, } | { "t": "question.closed", questionId: QuestionId, status: QuestionStatus, }; +export type ToPhone = { "t": "snapshot", approvals: Array, questions: Array, } | { "t": "approval.open", card: ApprovalCard, } | { "t": "approval.closed", approvalId: ApprovalId, status: ApprovalStatus, } | { "t": "question.open", card: QuestionCard, } | { "t": "question.closed", questionId: QuestionId, status: QuestionStatus, } | { "t": "chats", bots: Array, first: boolean, } | { "t": "history", req: number, botId: BotId, items: Array, more: boolean, done: boolean, } | { "t": "sent", clientId: string, ok: boolean, reason?: string, } | { "t": "item", botId: BotId, item: PhoneItem, } | { "t": "live", botId: BotId, text: string, } | { "t": "state", botId: BotId, state: BotState, } | { "t": "line", bot: ChatLine, } | { "t": "read", botId: BotId, upto: number, }; /** * What the phone sends the computer, sealed (spec 28.3). */ -export type FromPhone = { "t": "sync" } | { "t": "approval.answer", approvalId: ApprovalId, allow: boolean, note?: string, } | { "t": "question.answer", questionId: QuestionId, answer: string, } | { "t": "question.dismiss", questionId: QuestionId, }; +export type FromPhone = { "t": "sync" } | { "t": "approval.answer", approvalId: ApprovalId, allow: boolean, note?: string, } | { "t": "question.answer", questionId: QuestionId, answer: string, } | { "t": "question.dismiss", questionId: QuestionId, } | { "t": "chats" } | { "t": "history", req: number, botId: BotId, before?: ChatItemId, } | { "t": "send", clientId: string, botId: BotId, text: string, } | { "t": "watch", botId?: BotId, }; export type ApprovalItem = { approvalId: ApprovalId, toolName: string, summary: string, /** @@ -2136,6 +2160,7 @@ export interface RpcMethods { "bots.delete": { params: BotsDeleteParams; result: BotDeleted }; "bots.restart": { params: BotsRestartParams; result: Bot }; "chat.history": { params: ChatHistoryParams; result: Array }; + "chat.read": { params: BotIdParams; result: null }; "approvals.answer": { params: ApprovalsAnswerParams; result: Approval }; "messages.send": { params: MessagesSendParams; result: Message }; "messages.list": { params: MessagesListParams; result: Array }; diff --git a/app/src/phone/ChatItems.tsx b/app/src/phone/ChatItems.tsx new file mode 100644 index 00000000..e8ef3ae0 --- /dev/null +++ b/app/src/phone/ChatItems.tsx @@ -0,0 +1,127 @@ +// The things in an open conversation (spec 28.12): the owner's words on the +// right, the bot's on the left, tool calls as one quiet line, and a request +// or a question that waits as its card, with the same buttons as the inbox. + +import { useT } from "../i18n"; +import type { PhoneItem } from "../lib/protocol.gen"; +import { Callout } from "../ui/Callout"; +import { ApprovalView } from "./ApprovalView"; +import type { PhoneApi, PhoneState } from "./client"; +import { PhoneMarkdown } from "./parts"; +import { QuestionView } from "./QuestionView"; +import type { Pending } from "./talk"; + +function Cut() { + return

{useT().phone.chats.cut}

; +} + +function Mine({ text, cut, note }: { text: string; cut?: boolean; note?: string }) { + return ( +
+

+ {text} +

+ {cut && } + {note && {note}} +
+ ); +} + +export function ChatItem({ + item, + api, + state, +}: { + item: PhoneItem; + api: PhoneApi; + state: PhoneState; +}) { + const t = useT().phone; + switch (item.kind) { + case "you": + return ; + case "bot_message": + return ( +
+ {t.chats.from(item.from)} +

{item.text}

+ {item.cut && } +
+ ); + case "reply": + return ( +
+ {item.text} + {item.cut && } +
+ ); + case "tool": + return

{t.chats.tool(item.summary)}

; + case "approval": { + const card = state.approvals.find((one) => one.approvalId === item.approvalId); + if (card) { + return ( + api.answerApproval(card.approvalId, allow, note)} + /> + ); + } + return ( +

+ {t.chats.approvalLine(item.summary)} + {item.status !== "pending" && ` — ${t.approval.ended[item.status]}`} +

+ ); + } + case "question": { + const card = state.questions.find((one) => one.questionId === item.questionId); + if (card) { + return ( + api.answerQuestion(card.questionId, text)} + dismiss={() => api.dismissQuestion(card.questionId)} + /> + ); + } + const ended = (t.question.ended as Record)[item.status]; + return ( +

+ {t.chats.questionLine(item.text)} + {ended && ` — ${ended}`} +

+ ); + } + case "failed": + return ( +

+ {t.chats.failedTurn} + {item.error ? `: ${item.error}` : ""} +

+ ); + case "notice": + return ; + default: + return null; + } +} + +/** What the owner wrote that the computer has not shown back yet. */ +export function PendingItem({ pending }: { pending: Pending }) { + const c = useT().phone.chats; + const reason = c.pending.reasons[pending.reason ?? "failed"] ?? c.pending.reasons.failed; + const note = + pending.status === "sending" + ? c.pending.sending + : pending.status === "sent" + ? c.pending.sent + : `${c.pending.notSent} ${reason}`; + return ( +
+ +
+ ); +} diff --git a/app/src/phone/ChatView.tsx b/app/src/phone/ChatView.tsx new file mode 100644 index 00000000..a0152b7d --- /dev/null +++ b/app/src/phone/ChatView.tsx @@ -0,0 +1,166 @@ +// One conversation (spec 28.12): the newest items at the bottom, older ones +// a tap away, the reply as it is written, and the box to write in. + +import { useEffect, useRef, useState } from "react"; +import { useT } from "../i18n"; +import { ChatItem, PendingItem } from "./ChatItems"; +import type { PhoneApi, PhoneState } from "./client"; +import { BotDot, PhoneButton, PhoneMarkdown } from "./parts"; +import { SEND_MAX } from "./talk"; + +/** How far from the end still counts as reading the end. */ +const FOLLOW_PX = 120; + +export function ChatView({ api, state, back }: { api: PhoneApi; state: PhoneState; back(): void }) { + const c = useT().phone.chats; + const { convo } = state; + const line = state.chats.find((one) => one.botId === convo?.botId); + const [text, setText] = useState(""); + const [tooLong, setTooLong] = useState(false); + const column = useRef(null); + /** The owner is at the end of the conversation, so what comes is followed. */ + const atEnd = useRef(true); + const failed = convo?.pending.find((pending) => pending.status === "failed"); + + // Whatever makes the conversation taller (a new item, the reply as it is + // written, "working…", a card) keeps its end in view for whoever is + // reading the end; someone who scrolled up is left where they are. + const open = convo !== null; + useEffect(() => { + const node = column.current; + if (!open || !node) { + return; + } + const onScroll = () => { + const page = document.documentElement; + atEnd.current = page.scrollHeight - window.scrollY - window.innerHeight < FOLLOW_PX; + }; + const follow = () => { + if (atEnd.current) { + window.scrollTo({ top: document.documentElement.scrollHeight }); + } + }; + atEnd.current = true; + window.addEventListener("scroll", onScroll, { passive: true }); + const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(follow); + observer?.observe(node); + follow(); + return () => { + window.removeEventListener("scroll", onScroll); + observer?.disconnect(); + }; + }, [open]); + + // A message that did not go comes back to the box, if the box is empty. + const failedText = failed?.text; + useEffect(() => { + if (failedText !== undefined) { + setText((now) => (now === "" ? failedText : now)); + } + }, [failedText]); + + if (!convo) { + return null; + } + const name = line?.name ?? ""; + const submit = async () => { + const bytes = new TextEncoder().encode(text.trim()).length; + if (bytes === 0) { + return; + } + if (bytes > SEND_MAX) { + setTooLong(true); + return; + } + setTooLong(false); + atEnd.current = true; + const body = text; + setText(""); + await api.write(body); + }; + + return ( +
+
+ + {c.back} + + {line && } +
+

{name}

+ {line && {c.inCrew(line.crew)}} +
+
+ +
+ {convo.more && ( + { + atEnd.current = false; + void api.olderItems(); + }} + disabled={convo.older} + > + {convo.older ? c.loadingOlder : c.older} + + )} + {!convo.loaded &&

{c.loadingChat}

} + {convo.loaded && convo.items.length === 0 && convo.pending.length === 0 && ( +

{c.nothing}

+ )} + {convo.items.map((item) => ( + + ))} + {convo.pending.map((pending) => ( + + ))} + {convo.live !== "" && ( +
+ {convo.live} +
+ )} + {convo.working && convo.live === "" && ( +

+ {c.busy(name)} +

+ )} +
+ +
{ + event.preventDefault(); + void submit(); + }} + > + {tooLong && ( +

+ {c.composer.tooLong} +

+ )} +
+