Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 23 additions & 3 deletions app/src/features/account/ChatSettings.tsx
Original file line number Diff line number Diff line change
@@ -1,14 +1,24 @@
// Settings, "Chat": how a message is sent, and whether the panel beside
// the chat follows what the bot starts doing (spec 15.1).
// Settings, "Chat": how a message is sent, whether the panel beside the
// chat follows what the bot starts doing (spec 15.1), and how long a
// request for permission waits for the owner (spec 10.1).

import { useT } from "../../i18n";
import { prefs, usePref } from "../../shell/prefs";
import { Section, Toggle } from "./settingsParts";
import { Choices } from "../../ui/Choices";
import { Field, Section, Toggle } from "./settingsParts";
import { useDaemonSettings } from "./useDaemonSettings";

/** The waits offered, in minutes; one set by hand in config.toml joins them. */
const WAITS = [15, 30, 60, 120, 240, 480];

export function ChatSettings() {
const s = useT().account.settings;
const enterSends = usePref(prefs.enterSends);
const followBot = usePref(prefs.followBot);
const { settings, change } = useDaemonSettings();
const current = settings?.approvalWaitMinutes;
const waits =
current && !WAITS.includes(current) ? [...WAITS, current].sort((a, b) => a - b) : WAITS;
return (
<Section title={s.chat}>
<Toggle
Expand All @@ -23,6 +33,16 @@ export function ChatSettings() {
checked={followBot}
onChange={prefs.followBot.set}
/>
{settings && (
<Field label={s.approvalWait} hint={s.approvalWaitHint}>
<Choices<number>
label={s.approvalWait}
value={settings.approvalWaitMinutes}
options={waits.map((minutes) => ({ value: minutes, label: s.waitFor(minutes) }))}
onChange={(minutes) => change({ approvalWaitMinutes: minutes })}
/>
</Field>
)}
</Section>
);
}
23 changes: 23 additions & 0 deletions app/src/features/account/prefs.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,26 @@ describe("the app's own choices", () => {
expect(document.documentElement.dataset.motion).toBeUndefined();
});
});

describe("requests for permission", () => {
test("wait as long as the owner chooses, one set by hand included", async () => {
const fake = new FakeBotloft();
fake.settings = { ...fake.settings, approvalWaitMinutes: 45 };
renderApp(fake);
const dialog = await openSettings("Chat");
const waits = await within(dialog).findByRole("radiogroup", {
name: "How long a request for permission waits for you",
});
expect(
within(waits)
.getAllByRole("radio")
.map((radio) => radio.parentElement?.textContent),
).toEqual(["15 min", "30 min", "45 min", "1 hour", "2 hours", "4 hours", "8 hours"]);
expect((within(waits).getByRole("radio", { name: "45 min" }) as HTMLInputElement).checked).toBe(
true,
);
fireEvent.click(within(waits).getByRole("radio", { name: "4 hours" }));
await act(async () => {});
expect(fake.settings.approvalWaitMinutes).toBe(240);
});
});
8 changes: 6 additions & 2 deletions app/src/features/account/settings.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,11 @@ describe("settings", () => {
expect(dialog.textContent).toContain("the bots wait until you open Botloft");
fireEvent.click(awake);
await waitFor(() =>
expect(fake.settings).toEqual({ startWithWindows: false, keepAwake: false }),
expect(fake.settings).toEqual({
startWithWindows: false,
keepAwake: false,
approvalWaitMinutes: 60,
}),
);
expect(
fake.calls.filter((call) => call.method === "settings.update").map((c) => c.params),
Expand Down Expand Up @@ -123,7 +127,7 @@ describe("closing the window", () => {

test("the welcome screen says Botloft waits for the owner after a restart", async () => {
const fake = new FakeBotloft();
fake.settings = { startWithWindows: false, keepAwake: true };
fake.settings = { ...fake.settings, startWithWindows: false };
renderApp(fake);
expect(
await screen.findByText(
Expand Down
1 change: 1 addition & 0 deletions app/src/features/account/useDaemonSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,7 @@ export function useDaemonSettings(): {
setSettings({
startWithWindows: update.startWithWindows ?? before.startWithWindows,
keepAwake: update.keepAwake ?? before.keepAwake,
approvalWaitMinutes: update.approvalWaitMinutes ?? before.approvalWaitMinutes,
});
}
attempt(failed, async () => setSettings(await api.call("settings.update", update))).then(
Expand Down
9 changes: 9 additions & 0 deletions app/src/i18n/en/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,15 @@ export const account = {
followBot: "Open the browser and screens when a bot starts using them",
followBotOn: "The panel opens beside the chat, so you see what the bot does.",
followBotOff: "The panel's button gets a dot, and you open it when you want.",
approvalWait: "How long a request for permission waits for you",
approvalWaitHint:
"With no answer by then, the bot's request is denied and it goes on without it.",
waitFor: (minutes: number) =>
minutes < 60 || minutes % 60 !== 0
? `${minutes} min`
: minutes === 60
? "1 hour"
: `${minutes / 60} hours`,
lessMotion: "Less motion",
lessMotionHint: "A still window, as when the Windows animation effects are off.",
appearance: "Appearance",
Expand Down
8 changes: 8 additions & 0 deletions app/src/i18n/es/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,14 @@ export const account: Messages["account"] = {
followBot: "Abrir el navegador y las pantallas cuando un bot empiece a usarlos",
followBotOn: "El panel se abre junto al chat, para que veas lo que hace el bot.",
followBotOff: "El botón del panel muestra un punto, y lo abres cuando quieras.",
approvalWait: "Cuánto espera tu respuesta un pedido de permiso",
approvalWaitHint: "Sin respuesta en ese tiempo, el pedido del bot se deniega y sigue sin eso.",
waitFor: (minutes: number) =>
minutes < 60 || minutes % 60 !== 0
? `${minutes} min`
: minutes === 60
? "1 hora"
: `${minutes / 60} horas`,
lessMotion: "Menos animaciones",
lessMotionHint:
"La ventana queda quieta, como cuando los efectos de animación de Windows están desactivados.",
Expand Down
8 changes: 8 additions & 0 deletions app/src/i18n/pt-BR/account.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,14 @@ export const account: Messages["account"] = {
followBot: "Abrir o navegador e as telas quando um bot começar a usar",
followBotOn: "O painel abre ao lado da conversa, para você ver o que o bot faz.",
followBotOff: "O botão do painel ganha um ponto, e você abre quando quiser.",
approvalWait: "Quanto tempo um pedido de permissão espera por você",
approvalWaitHint: "Sem resposta nesse tempo, o pedido do bot é negado e ele segue sem isso.",
waitFor: (minutes: number) =>
minutes < 60 || minutes % 60 !== 0
? `${minutes} min`
: minutes === 60
? "1 hora"
: `${minutes / 60} horas`,
lessMotion: "Menos animações",
lessMotionHint:
"A janela fica parada, como quando os efeitos de animação do Windows estão desligados.",
Expand Down
3 changes: 2 additions & 1 deletion app/src/lib/fake.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ export class FakeBotloft implements BotloftApi {
};
/** How many times the app asked for a new Claude Code check. */
refreshes = 0;
settings: Settings = { startWithWindows: true, keepAwake: true };
settings: Settings = { startWithWindows: true, keepAwake: true, approvalWaitMinutes: 60 };
/** Every call, in order. */
readonly calls: { method: Method; params: unknown }[] = [];
readonly chat = new FakeChat(this);
Expand Down Expand Up @@ -217,6 +217,7 @@ export class FakeBotloft implements BotloftApi {
this.settings = {
startWithWindows: change.startWithWindows ?? this.settings.startWithWindows,
keepAwake: change.keepAwake ?? this.settings.keepAwake,
approvalWaitMinutes: change.approvalWaitMinutes ?? this.settings.approvalWaitMinutes,
};
return this.settings;
},
Expand Down
13 changes: 11 additions & 2 deletions app/src/lib/protocol.gen.ts
Original file line number Diff line number Diff line change
Expand Up @@ -721,12 +721,21 @@ startWithWindows: boolean,
/**
* The computer does not sleep while a bot works (spec 14).
*/
keepAwake: boolean, };
keepAwake: boolean,
/**
* How long a permission request waits for the owner before it is
* denied (spec 10.1).
*/
approvalWaitMinutes: number, };

/**
* The settings to change; the ones left out stay as they are.
*/
export type SettingsUpdateParams = { startWithWindows?: boolean, keepAwake?: boolean, };
export type SettingsUpdateParams = { startWithWindows?: boolean, keepAwake?: boolean,
/**
* From 1 to [`APPROVAL_WAIT_MAX_MINUTES`].
*/
approvalWaitMinutes?: number, };

/**
* When a routine runs (spec 20.2), kept as structure rather than cron text
Expand Down
10 changes: 10 additions & 0 deletions crates/botloft-core/src/protocol/settings.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,9 @@ pub struct Settings {
pub start_with_windows: bool,
/// The computer does not sleep while a bot works (spec 14).
pub keep_awake: bool,
/// How long a permission request waits for the owner before it is
/// denied (spec 10.1).
pub approval_wait_minutes: u32,
}

/// The settings to change; the ones left out stay as they are.
Expand All @@ -26,4 +29,11 @@ pub struct SettingsUpdateParams {
#[serde(default)]
#[cfg_attr(test, ts(optional))]
pub keep_awake: Option<bool>,
/// From 1 to [`APPROVAL_WAIT_MAX_MINUTES`].
#[serde(default)]
#[cfg_attr(test, ts(optional))]
pub approval_wait_minutes: Option<u32>,
}

/// The longest a permission request may wait for the owner: a day.
pub const APPROVAL_WAIT_MAX_MINUTES: u32 = 24 * 60;
2 changes: 1 addition & 1 deletion crates/botloftd/src/approvals/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,7 @@ pub(crate) async fn ask_with(
record: &pending.record,
settled: false,
};
let decision = tokio::time::timeout(daemon.bots.approval_timeout, waiting).await;
let decision = tokio::time::timeout(daemon.settings.approval_wait(), waiting).await;
guard.settled = true;
daemon.approvals.lock().remove(&pending.record.approval.id);
daemon.supervisor.approval_closed(bot, generation);
Expand Down
21 changes: 18 additions & 3 deletions crates/botloftd/src/service/settings.rs
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
//! `settings.get` and `settings.update` (spec 11.2): what the owner
//! changes in the app's Settings.

use botloft_core::protocol::{Settings, SettingsUpdateParams};
use botloft_core::protocol::{APPROVAL_WAIT_MAX_MINUTES, Settings, SettingsUpdateParams};
use tracing::info;

use super::{ApiError, ApiResult};
Expand All @@ -13,9 +13,20 @@ pub fn get(daemon: &Daemon) -> ApiResult<Settings> {
}

/// Saves the change in `config.toml` and applies it: the scheduled task
/// follows starting with Windows (spec 14), and keeping the computer
/// awake turns on or off at once.
/// follows starting with Windows (spec 14), keeping the computer awake
/// turns on or off at once, and so does a shorter wait for approvals. A
/// longer one reaches each bot when it restarts, as soon as nothing is in
/// progress: Claude Code reads the wait in `mcp.json` when it starts
/// (spec 10.1).
pub fn update(daemon: &Daemon, params: SettingsUpdateParams) -> ApiResult<Settings> {
if params
.approval_wait_minutes
.is_some_and(|minutes| !(1..=APPROVAL_WAIT_MAX_MINUTES).contains(&minutes))
{
return Err(ApiError::validation(format!(
"approvalWaitMinutes must be between 1 and {APPROVAL_WAIT_MAX_MINUTES}"
)));
}
let before = daemon.settings.get();
if let Some(start) = params.start_with_windows
&& start != before.start_with_windows
Expand All @@ -27,9 +38,13 @@ pub fn update(daemon: &Daemon, params: SettingsUpdateParams) -> ApiResult<Settin
.settings
.save(params)
.map_err(|err| ApiError::Settings(err.to_string()))?;
if after.approval_wait_minutes > before.approval_wait_minutes {
daemon.supervisor.workspace_settings_changed();
}
info!(
start_with_windows = after.start_with_windows,
keep_awake = after.keep_awake,
approval_wait_minutes = after.approval_wait_minutes,
"settings changed"
);
Ok(after)
Expand Down
Loading
Loading