mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
feat(studio): notify when a turn ends or the model stops to ask
The kernel has had these notifications since the CLI: a turn finishing, a stop for approval, a question. Studio's host wired them and then never gave anyone a way to turn them on — the only switch was a hand-edited config.toml, which is why they read as missing. Three things were wrong beyond the missing screen. The host decided at boot whether to wrap a runtime at all, so "notify me" meant "notify me after a restart"; every runtime is wrapped now and the sink reads a holder the settings endpoint stores into, which is what makes the block honest in saying it applies immediately. The bodies were English literals in a window set to Chinese; they come from the catalogue now, like the tray's. And a kernel reached over the network registers no route at all, because a notification fired there lands on the server's desktop rather than the watcher's. The block is one switch with three branches, drawn rather than greyed out of existence, and it draws nothing where the kernel has no window to notify from. Tests: the sink delivers once the switch flips on a runtime built while it was off, stops when it flips back, and answers in the window's language; the block writes what was asked and keeps what the host answered back. Also splits the test suite by what needs a private module registry. `vi.mock` cannot share one — a module another file already imported unmocked stays unmocked — and format.test.ts began failing in the full run while passing alone, which is exactly that. Those files run isolated; the rest keep the shared worker. Which files they are is read out of the sources, never listed. 19.4s to 8.8s, verified under --sequence.shuffle.
This commit is contained in:
1 parent
6b391c1cff
commit
71a63d325d
27 files changed
+516
-78
No files matched your search
@@ -277,7 +277,7 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
|
||||
// This window is the only client of its kernel, so a system notification
|
||||
// reaches the person who asked for it. Every pane gets the same wrapper,
|
||||
// not just the one the launch started with.
|
||||
notifications := hostNotifications(cfg)
|
||||
notifications, notifySet := hostNotifications(cfg)
|
||||
// One fold behind the status icon: every pane's events on the way through,
|
||||
// so the tray surface answers from what the panes did rather than from a
|
||||
// count the shell kept for itself.
|
||||
@@ -308,18 +308,19 @@ func assemble(ctx context.Context, logs, handshakeTo io.Writer, shell shellIdent
|
||||
boot.SetBrowserHost(browserHost.Dial)
|
||||
hubCfg := hostServeConfig(cfg.Serve)
|
||||
hub := serve.NewHub(serve.HubOptions{
|
||||
Serve: hubCfg,
|
||||
Surface: surface.Desktop,
|
||||
Page: page,
|
||||
Grant: grantHostCapabilities,
|
||||
DecorateSink: decorate,
|
||||
Tray: &studioTray{tracker: tracker},
|
||||
BrowserHost: browserHost,
|
||||
Asks: asks,
|
||||
Remote: remotehost.New(ctx, version, asks),
|
||||
OnClose: func(rt *serve.Runtime) { tracker.Drop(paneKey(rt.Events)) },
|
||||
Install: studioInstall(shell),
|
||||
Update: studioUpdateHost(shell, handshakeTo),
|
||||
Serve: hubCfg,
|
||||
Surface: surface.Desktop,
|
||||
Page: page,
|
||||
Grant: grantHostCapabilities,
|
||||
DecorateSink: decorate,
|
||||
Tray: &studioTray{tracker: tracker},
|
||||
Notifications: notifySet,
|
||||
BrowserHost: browserHost,
|
||||
Asks: asks,
|
||||
Remote: remotehost.New(ctx, version, asks),
|
||||
OnClose: func(rt *serve.Runtime) { tracker.Drop(paneKey(rt.Events)) },
|
||||
Install: studioInstall(shell),
|
||||
Update: studioUpdateHost(shell, handshakeTo),
|
||||
})
|
||||
srv := serve.New(built.Controller, bc, hubCfg)
|
||||
srv.SetPaneSink(paneSink)
|
||||
@@ -345,15 +346,18 @@ func hostServeConfig(cfg config.ServeConfig) config.ServeConfig {
|
||||
return cfg
|
||||
}
|
||||
|
||||
// hostNotifications is the sink wrapper every runtime gets. Off unless the
|
||||
// shared [notifications] config asks for it, so the CLI and this window answer
|
||||
// to one setting rather than each growing its own.
|
||||
func hostNotifications(cfg *config.Config) func(event.Sink) event.Sink {
|
||||
if cfg == nil || !cfg.Notifications.Enabled {
|
||||
return func(sink event.Sink) event.Sink { return sink }
|
||||
// hostNotifications is the sink wrapper every runtime gets, plus the holder the
|
||||
// settings surface writes into. Every runtime is wrapped whatever the switch
|
||||
// says today, because deciding here would make "notify me" mean "notify me
|
||||
// after a restart". The shared [notifications] config is the one setting this
|
||||
// window and the CLI both answer to.
|
||||
func hostNotifications(cfg *config.Config) (func(event.Sink) event.Sink, *notify.Settings) {
|
||||
set := notify.NewSettings(config.NotificationsConfig{})
|
||||
if cfg != nil {
|
||||
set.Store(cfg.Notifications)
|
||||
}
|
||||
sender := notify.NewPlatformSender()
|
||||
return func(sink event.Sink) event.Sink { return notify.NewSink(sink, sender, cfg.Notifications) }
|
||||
return func(sink event.Sink) event.Sink { return notify.NewSink(sink, sender, i18n.M, set) }, set
|
||||
}
|
||||
|
||||
// studioTray answers for a shell that can put an icon back whenever the setting
|
||||
|
||||
@@ -398,19 +398,27 @@ func TestRunServesUntilTheParentLetsGo(t *testing.T) {
|
||||
|
||||
// Notifications are the shared [notifications] setting, not a shell of its own:
|
||||
// a window that announced turns the CLI would have kept quiet about would be a
|
||||
// second policy nobody set.
|
||||
func TestHostNotifiesOnlyWhereTheSharedSettingAsksForIt(t *testing.T) {
|
||||
// second policy nobody set. The switch is read from the holder on every event,
|
||||
// so every runtime is wrapped whatever the setting says today — deciding it at
|
||||
// boot is what made "notify me" mean "notify me after a restart".
|
||||
func TestHostNotificationsCarryTheSharedSettingLive(t *testing.T) {
|
||||
plain := serve.NewBroadcaster()
|
||||
off := hostNotifications(&config.Config{})
|
||||
if got := off(plain); got != event.Sink(plain) {
|
||||
t.Error("notifications are off, and the sink was still wrapped")
|
||||
}
|
||||
on := hostNotifications(&config.Config{Notifications: config.NotificationsConfig{Enabled: true}})
|
||||
if got := on(plain); got == event.Sink(plain) {
|
||||
t.Error("notifications are on, and nothing was wrapped to send them")
|
||||
}
|
||||
if nilCfg := hostNotifications(nil); nilCfg(plain) != event.Sink(plain) {
|
||||
t.Error("no config at all must not turn notifications on")
|
||||
for _, cfg := range []*config.Config{
|
||||
{},
|
||||
{Notifications: config.NotificationsConfig{Enabled: true}},
|
||||
nil,
|
||||
} {
|
||||
wrap, set := hostNotifications(cfg)
|
||||
if got := wrap(plain); got == event.Sink(plain) {
|
||||
t.Fatalf("runtime was left unwrapped, so the switch could never reach it (cfg %+v)", cfg)
|
||||
}
|
||||
want := config.NotificationsConfig{}
|
||||
if cfg != nil {
|
||||
want = cfg.Notifications
|
||||
}
|
||||
if got := set.Load(); got != want {
|
||||
t.Errorf("holder = %+v, want the shared setting %+v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -11,13 +11,22 @@ reviewed: 2026-09-22
|
||||
|
||||
`pnpm test` runs one worker per thread and reuses it across files (`isolate:
|
||||
false`). Re-importing React, the port and `app.css` once per file was most of
|
||||
the run; sharing the registry cut it roughly in half.
|
||||
the run; sharing the registry took it from about 19s to about 9s.
|
||||
|
||||
What that costs: module-level state outlives a test file. A test that needs a
|
||||
clean module makes one — a fresh instance, an explicit reset — rather than
|
||||
assuming the file boundary gave it one. `pnpm vitest run --sequence.shuffle`
|
||||
is how that assumption gets caught.
|
||||
|
||||
`vi.mock` is the one thing that cannot share a registry: a module another file
|
||||
already imported unmocked stays unmocked, and the mock silently does nothing.
|
||||
|
||||
- Those files run in a second, isolated project.
|
||||
- Which files they are is read out of the sources at config load, never listed.
|
||||
A list is what stops matching the moment somebody writes the next one.
|
||||
- A config load that finds no test files fails rather than quietly covering
|
||||
nothing.
|
||||
|
||||
`css: true` is load-bearing: the guards under `src/styles` read `app.css`
|
||||
through `?raw`, and a stubbed stylesheet hands them an empty string, which
|
||||
passes on nothing.
|
||||
@@ -248,6 +248,10 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "hooks.save", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "tray.icon", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "tray.close-to-tray", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "notify.enabled", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "notify.turn-done", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "notify.approval", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "notify.ask", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "wallpaper.remove", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "wallpaper.change", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "theme.activate", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
|
||||
@@ -469,6 +469,16 @@ export const EN: Record<string, string> = {
|
||||
"没有未经验证的部分": "nothing left unverified",
|
||||
"未验证": "not verified",
|
||||
"交付验收": "delivery receipt",
|
||||
"通知": "Notifications",
|
||||
"只在你没盯着窗口的时候有用:一轮跑完、停下来等批准、或者模型反过来问你,都可以让系统提醒一次。": "Only useful while you are not watching: a turn finishing, a stop for approval, or the model asking you something can each raise one system alert.",
|
||||
"发送系统通知": "Send system notifications",
|
||||
"窗口在后台时由系统弹出提醒。立即生效,正在跑的会话下一次事件就按新设置来": "The system raises the alert while this window is in the background. Immediate: a running session follows the new setting from its next event.",
|
||||
"这一轮结束时": "When the turn ends",
|
||||
"回答写完、或者这一轮失败了": "The answer is written, or the turn failed",
|
||||
"有操作等待批准时": "When something waits for approval",
|
||||
"模型要动的东西超出了已经给出的授权,正停在那里等你": "The model wants to touch something outside what it was already granted, and is stopped there waiting for you",
|
||||
"模型提问时": "When the model asks you something",
|
||||
"模型停下来问你一个问题,没有答案它就不会往下走": "The model stopped to ask a question and will not go on without an answer",
|
||||
"引用回复": "quoting a reply",
|
||||
"引用第 {n} 轮回复": "quoting turn {n}",
|
||||
"本轮已发送 / 正在接收(估算)的 token": "tokens sent this turn / arriving now (estimated)",
|
||||
|
||||
@@ -98,6 +98,7 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"该协议不支持由端点自行搜索": "This protocol has no wire format for a search the endpoint runs itself",
|
||||
"这台服务器不允许修改模型来源": "This server does not allow editing model sources",
|
||||
"打不开这个网页:{error}": "That page could not be opened: {error}",
|
||||
"通知设置没能保存:{error}": "The notification settings could not be saved: {error}",
|
||||
"这个系统没有可用的文件夹选择框,请直接填写路径。": "This system has no folder picker available; type the path instead.",
|
||||
"打不开文件夹选择框:{error}": "The folder picker could not be opened: {error}",
|
||||
"名称「{name}」不能用来存放密钥:密钥槽位由名称推导,而它不能以数字开头。改一个以字母开头的名称即可,密钥本身没有问题。": "The name “{name}” cannot hold a key: the credential slot is derived from it, and a slot may not start with a digit. Use a name starting with a letter — the key itself is fine.",
|
||||
|
||||
@@ -92,6 +92,7 @@ const SAID: Record<string, string> = {
|
||||
// ── 来源:连接与授权 ─────────────────────────────────────────────
|
||||
"provider.editing_disabled": "这台服务器不允许修改模型来源",
|
||||
"browser.open_failed": "打不开这个网页:{error}",
|
||||
"notifications.rejected": "通知设置没能保存:{error}",
|
||||
"picker.unsupported": "这个系统没有可用的文件夹选择框,请直接填写路径。",
|
||||
"picker.failed": "打不开文件夹选择框:{error}",
|
||||
"provider.bad_key_slot": "名称「{name}」不能用来存放密钥:密钥槽位由名称推导,而它不能以数字开头。改一个以字母开头的名称即可,密钥本身没有问题。",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { PlanAction } from "./session";
|
||||
import { HttpError } from "./port";
|
||||
import type { AccountState, AgentPort, ChangeDiff, Completion, CompletionItem, DeviceGrant, VersionHub, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, UsageReport, MemoryEntry, WorkspaceInfo, WorkspaceChanges, Attachment, DroppedRef, Queue, QueueItem, Queued, TrayPrefs } from "./port";
|
||||
import type { AccountState, AgentPort, ChangeDiff, Completion, CompletionItem, DeviceGrant, VersionHub, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, SessionEntry, SessionStatus, WalletReading, MemoryCatalog, MemoryEdit, UsageReport, MemoryEntry, WorkspaceInfo, WorkspaceChanges, Attachment, DroppedRef, Queue, QueueItem, Queued, NotifyPrefs, TrayPrefs } from "./port";
|
||||
import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
|
||||
import { MockTheme } from "./mock_theme";
|
||||
import { SCRIPT, mockMsgIndex, mockTurnStart } from "./fixture";
|
||||
@@ -570,6 +570,17 @@ export class MockPort extends MockTheme implements AgentPort {
|
||||
|
||||
// The fixture has a window with an icon, because the state worth designing
|
||||
// is the one where both switches mean something.
|
||||
private notifications: NotifyPrefs = { enabled: false, turnDone: true, approval: true, ask: true };
|
||||
|
||||
async notifyPrefs(): Promise<NotifyPrefs | null> {
|
||||
return { ...this.notifications };
|
||||
}
|
||||
|
||||
async setNotifyPrefs(next: NotifyPrefs): Promise<NotifyPrefs | null> {
|
||||
this.notifications = { ...next };
|
||||
return { ...this.notifications };
|
||||
}
|
||||
|
||||
private tray: TrayPrefs = { icon: true, live: true, closeToTray: false };
|
||||
|
||||
async trayPrefs(): Promise<TrayPrefs | null> {
|
||||
|
||||
@@ -112,6 +112,15 @@ export interface QueueCapacity {
|
||||
|
||||
/** What the window says about its own status icon. null where there is no
|
||||
* window under the page — the same build runs in a browser tab. */
|
||||
/** The desktop notifications this window sends on the machine's behalf.
|
||||
* enabled gates the other three, which are what is worth interrupting for. */
|
||||
export interface NotifyPrefs {
|
||||
enabled: boolean;
|
||||
turnDone: boolean;
|
||||
approval: boolean;
|
||||
ask: boolean;
|
||||
}
|
||||
|
||||
export interface TrayPrefs {
|
||||
// What the setting asks for.
|
||||
icon: boolean;
|
||||
@@ -200,6 +209,10 @@ export interface AgentPort {
|
||||
// null in a browser tab: there is no window to keep running.
|
||||
trayPrefs(): Promise<TrayPrefs | null>;
|
||||
setTrayPrefs(icon: boolean, closeToTray: boolean): Promise<TrayPrefs | null>;
|
||||
// Null where this kernel has no window to notify from: one reached over the
|
||||
// network would fire on its own machine, not the watcher's.
|
||||
notifyPrefs(): Promise<NotifyPrefs | null>;
|
||||
setNotifyPrefs(next: NotifyPrefs): Promise<NotifyPrefs | null>;
|
||||
// null when the config file reads. Everything else here writes to it.
|
||||
configProblem(): Promise<ConfigProblem | null>;
|
||||
repairConfig(): Promise<ConfigRepair>;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { PLAN_ACTIONS, type PlanAction } from "./session";
|
||||
import type { AccountState, AgentPort, Appearance, CompactionSettings, Completion, DeviceGrant, ProviderProbe, UpdateProgress, VersionHub, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, SessionEntry, SessionStatus, WalletReading, HookDryRun, HookEntry, MemoryCatalog, MemoryEdit, MemoryEntry, UsageReport, McpDraft, PluginExport, Queue, Queued, TrayPrefs, WorkspaceInfo } from "./port";
|
||||
import type { AccountState, AgentPort, Appearance, CompactionSettings, Completion, DeviceGrant, ProviderProbe, UpdateProgress, VersionHub, ApprovalMode, ApprovalVerdict, Checkpoint, RewindPlan, RewindResult, RewindScope, HistoryMessage, HostTodo, BrowserTab, ModelEntry, Preset, ProviderSetup, RoleAssignments, SessionEntry, SessionStatus, WalletReading, HookDryRun, HookEntry, MemoryCatalog, MemoryEdit, MemoryEntry, UsageReport, McpDraft, PluginExport, Queue, Queued, NotifyPrefs, TrayPrefs, WorkspaceInfo } from "./port";
|
||||
import { HttpError, type Attachment, type ChangeDiff, type DroppedRef, type WorkspaceFile, type WorkspaceFiles, type WorkspaceChanges } from "./port";
|
||||
import { SseTheme } from "./sse_theme";
|
||||
import type { StoragePlan, StorageState } from "./storage";
|
||||
@@ -278,6 +278,29 @@ export class SsePort extends SseTheme implements AgentPort {
|
||||
// A kernel with no window registers no tray routes at all, which is what a
|
||||
// browser tab reads as "there is no icon here" — the same null this panel
|
||||
// has always had to render.
|
||||
async notifyPrefs(): Promise<NotifyPrefs | null> {
|
||||
return this.notifyCall(await fetch("/notifications", { credentials: "same-origin" }));
|
||||
}
|
||||
|
||||
async setNotifyPrefs(next: NotifyPrefs): Promise<NotifyPrefs | null> {
|
||||
return this.notifyCall(
|
||||
await fetch("/notifications", {
|
||||
method: "PUT",
|
||||
credentials: "same-origin",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(next),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// 404 is "this kernel has no window", which is a fact about the host and not
|
||||
// a failure: the panel draws nothing rather than an error.
|
||||
private async notifyCall(res: Response): Promise<NotifyPrefs | null> {
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) await SsePort.fail("/notifications", res);
|
||||
return (await res.json()) as NotifyPrefs;
|
||||
}
|
||||
|
||||
private async trayCall(res: Response): Promise<TrayPrefs | null> {
|
||||
if (res.status === 404) return null;
|
||||
if (!res.ok) await SsePort.fail("/tray/prefs", res);
|
||||
|
||||
@@ -5,6 +5,7 @@ import { MONO_FAMILIES, UI_FAMILIES, installed, readSizeOf, readSteps } from "./
|
||||
import { STORAGE as LANG_KEY, t } from "../i18n";
|
||||
import { pct } from "../i18n/format";
|
||||
import { reason } from "../i18n/kernel";
|
||||
import { Notifications } from "./Notifications";
|
||||
import { Switch } from "./Switch";
|
||||
import { setShowsReceipt, showsReceipt } from "../state/session";
|
||||
|
||||
@@ -611,6 +612,8 @@ export function Appearance({ port, theme, onTheme, contrast, onContrast, weight,
|
||||
</section>
|
||||
)}
|
||||
|
||||
<Notifications port={port} />
|
||||
|
||||
{/* What makes this page read as a console is that all of it arrives at
|
||||
once, not that any one control is obscure. The summary says what is
|
||||
folded and whether it has been changed — a disclosure hiding a value
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, waitFor } from "@testing-library/react";
|
||||
import "./testkit";
|
||||
import { Notifications } from "./Notifications";
|
||||
import type { AgentPort, NotifyPrefs } from "../port/port";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const OWED: NotifyPrefs = { enabled: false, turnDone: true, approval: true, ask: true };
|
||||
|
||||
function fakePort(answer: NotifyPrefs | null) {
|
||||
let held = answer;
|
||||
const set = vi.fn(async (next: NotifyPrefs) => {
|
||||
held = next;
|
||||
return held;
|
||||
});
|
||||
return { port: { notifyPrefs: async () => held, setNotifyPrefs: set } as unknown as AgentPort, set };
|
||||
}
|
||||
|
||||
const box = async (answer: NotifyPrefs | null) => {
|
||||
const { port, set } = fakePort(answer);
|
||||
const view = render(<Notifications port={port} />);
|
||||
await waitFor(() => expect(view.container.querySelector("section, .none")).toBeDefined());
|
||||
return { view, set };
|
||||
};
|
||||
|
||||
describe("the notifications block", () => {
|
||||
it("draws nothing where the kernel has no window to notify from", async () => {
|
||||
const { view } = await box(null);
|
||||
await waitFor(() => expect(view.container.querySelector("#set-notify")).toBeNull());
|
||||
});
|
||||
|
||||
it("keeps the three occasions as branches of the one switch", async () => {
|
||||
const { view } = await box(OWED);
|
||||
await waitFor(() => expect(view.container.querySelector("#set-notify")).not.toBeNull());
|
||||
const branches = [...view.container.querySelectorAll(".lrow.subrow")];
|
||||
expect(branches.length).toBe(3);
|
||||
// Off, so the branches are drawn as not currently doing anything rather
|
||||
// than removed — a control that vanishes is one nobody knows exists.
|
||||
expect(branches.every((b) => b.hasAttribute("data-off"))).toBe(true);
|
||||
});
|
||||
|
||||
it("writes what the person asked for and keeps what the host answered", async () => {
|
||||
const { view, set } = await box(OWED);
|
||||
await waitFor(() => expect(view.container.querySelector('[data-action="notify.enabled"]')).not.toBeNull());
|
||||
(view.container.querySelector('[data-action="notify.enabled"]') as HTMLElement).click();
|
||||
await waitFor(() => expect(set).toHaveBeenCalledWith({ ...OWED, enabled: true }));
|
||||
await waitFor(() => expect(view.container.querySelector(".lrow.subrow")?.hasAttribute("data-off")).toBe(false));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,74 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { AgentPort, NotifyPrefs } from "../port/port";
|
||||
import { t } from "../i18n";
|
||||
import { ApplyNote } from "./Group";
|
||||
import { Switch } from "./Switch";
|
||||
|
||||
/** What this machine is allowed to interrupt for. A kernel with no window
|
||||
* answers nothing and this draws nothing: a notification fired there would
|
||||
* land on the server's desktop, not on the watcher's. */
|
||||
export function Notifications({ port }: { port: AgentPort }) {
|
||||
const [prefs, setPrefs] = useState<NotifyPrefs | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
port.notifyPrefs().then((p) => live && setPrefs(p)).catch(() => live && setPrefs(null));
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
}, [port]);
|
||||
|
||||
// The window answers with what is true afterwards, not with what was asked:
|
||||
// the same read is what a second window would get.
|
||||
const flip = useCallback(
|
||||
(patch: Partial<NotifyPrefs>) => {
|
||||
if (!prefs) return;
|
||||
port.setNotifyPrefs({ ...prefs, ...patch }).then((got) => got && setPrefs(got)).catch(() => {});
|
||||
},
|
||||
[port, prefs],
|
||||
);
|
||||
|
||||
// A settings page must not grow a heading for a switch it cannot show.
|
||||
if (!prefs) return null;
|
||||
return (
|
||||
<section className="grp" id="set-notify" data-setting="notify">
|
||||
<div className="grp-hd">
|
||||
<h3>{t("通知")}</h3>
|
||||
</div>
|
||||
<p className="hint">{t("只在你没盯着窗口的时候有用:一轮跑完、停下来等批准、或者模型反过来问你,都可以让系统提醒一次。")}</p>
|
||||
<div className="grp-items">
|
||||
<div className="lrow">
|
||||
<span className="tx">
|
||||
<span className="lb">{t("发送系统通知")}</span>
|
||||
<span className="ds">{t("窗口在后台时由系统弹出提醒。立即生效,正在跑的会话下一次事件就按新设置来")}</span>
|
||||
</span>
|
||||
<Switch data-action="notify.enabled" on={prefs.enabled} label={t("发送系统通知")} onClick={() => flip({ enabled: !prefs.enabled })} />
|
||||
</div>
|
||||
{/* Drawn as branches of the first rather than as a rule you discover by
|
||||
watching three switches grey out together. */}
|
||||
<div className="lrow subrow" data-off={prefs.enabled ? undefined : ""}>
|
||||
<span className="tx">
|
||||
<span className="lb">{t("这一轮结束时")}</span>
|
||||
<span className="ds">{t("回答写完、或者这一轮失败了")}</span>
|
||||
</span>
|
||||
<Switch data-action="notify.turn-done" on={prefs.turnDone} busy={!prefs.enabled} label={t("这一轮结束时")} onClick={() => flip({ turnDone: !prefs.turnDone })} />
|
||||
</div>
|
||||
<div className="lrow subrow" data-off={prefs.enabled ? undefined : ""}>
|
||||
<span className="tx">
|
||||
<span className="lb">{t("有操作等待批准时")}</span>
|
||||
<span className="ds">{t("模型要动的东西超出了已经给出的授权,正停在那里等你")}</span>
|
||||
</span>
|
||||
<Switch data-action="notify.approval" on={prefs.approval} busy={!prefs.enabled} label={t("有操作等待批准时")} onClick={() => flip({ approval: !prefs.approval })} />
|
||||
</div>
|
||||
<div className="lrow subrow" data-off={prefs.enabled ? undefined : ""}>
|
||||
<span className="tx">
|
||||
<span className="lb">{t("模型提问时")}</span>
|
||||
<span className="ds">{t("模型停下来问你一个问题,没有答案它就不会往下走")}</span>
|
||||
</span>
|
||||
<Switch data-action="notify.ask" on={prefs.ask} busy={!prefs.enabled} label={t("模型提问时")} onClick={() => flip({ ask: !prefs.ask })} />
|
||||
</div>
|
||||
</div>
|
||||
<ApplyNote id="notify" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -229,6 +229,9 @@ export const SETTINGS: SettingEntry[] = [
|
||||
|
||||
{ section: "appearance", anchor: "language", title: "语言", scope: "machine", apply: "restart", keywords: ["中文", "english", "界面语言"] },
|
||||
{ section: "appearance", anchor: "window", title: "窗口", scope: "machine", apply: "immediate", keywords: ["托盘", "关闭行为"] },
|
||||
// Immediate because the endpoint hands the holder every running sink reads,
|
||||
// not because the file was written: writing it is what a restart would need.
|
||||
{ section: "appearance", anchor: "notify", title: "通知", scope: "machine", apply: "immediate", keywords: ["提醒", "notification", "系统通知", "结束", "批准", "提问"] },
|
||||
{ section: "appearance", anchor: "size", title: "大小", scope: "machine", apply: "immediate", keywords: ["缩放", "字号"] },
|
||||
{ section: "appearance", anchor: "font", title: "字体", scope: "machine", apply: "immediate", keywords: ["等宽", "mono"] },
|
||||
{ section: "appearance", anchor: "wallpaper", title: "壁纸", scope: "machine", apply: "immediate", keywords: ["背景", "图片"] },
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import react from "@vitejs/plugin-react";
|
||||
import { globSync, readFileSync } from "node:fs";
|
||||
|
||||
interface ProxyEvents {
|
||||
on(event: "proxyReq", cb: (req: { setHeader(k: string, v: string): void }) => void): void;
|
||||
@@ -23,10 +24,17 @@ const ROUTES = [
|
||||
"/extensions", "/themes", "/plugins", "/surfaces",
|
||||
"/fork", "/summarize", "/forget", "/bypass", "/auto-approve-tools",
|
||||
"/permissions", "/sandbox", "/context", "/storage", "/tray", "/browser", "/browser-host", "/asks", "/update",
|
||||
"/host",
|
||||
"/host", "/notifications",
|
||||
"/slash", "/workspaces", "/welcome", "/usage", "/config", "/studio",
|
||||
];
|
||||
|
||||
// Test files that mock a module. Read, never listed: the next one somebody
|
||||
// writes has to land in the isolated project by itself.
|
||||
const MOCKERS = globSync("src/**/*.test.{ts,tsx}", { cwd: import.meta.dirname })
|
||||
.map((f) => f.replaceAll("\\", "/"))
|
||||
.filter((f) => readFileSync(`${import.meta.dirname}/${f}`, "utf8").includes("vi.mock("));
|
||||
if (MOCKERS.length === 0) throw new Error("vite.config: found no test files; the isolation split would silently cover nothing");
|
||||
|
||||
// REASONIX_SERVE points at a running `reasonix serve`; without it the app boots
|
||||
// on MockPort so the UI can be developed with no Go process at all.
|
||||
export default defineConfig(({ mode }) => {
|
||||
@@ -39,13 +47,20 @@ export default defineConfig(({ mode }) => {
|
||||
// on nothing. Processing it is what makes that guard able to fail.
|
||||
//
|
||||
// One worker per thread, reused across files: a fresh module registry per
|
||||
// file meant re-importing React, the port and app.css 119 times, which was
|
||||
// file meant re-importing React, the port and app.css once each, which was
|
||||
// most of the run. The cost is that module-level state now outlives a file,
|
||||
// so a test that needs a clean one has to make it, not assume it.
|
||||
//
|
||||
// A file calling vi.mock cannot share that registry — a module another file
|
||||
// already imported unmocked stays unmocked — so those run isolated. Which
|
||||
// files they are is read from the sources rather than listed, because a list
|
||||
// is what silently stops matching when somebody adds the next one.
|
||||
test: {
|
||||
css: true,
|
||||
pool: "threads",
|
||||
isolate: false,
|
||||
projects: [
|
||||
{ extends: true, test: { name: "shared", pool: "threads", isolate: false, exclude: [...MOCKERS, "**/node_modules/**", "**/dist/**"] } },
|
||||
{ extends: true, test: { name: "mocked", pool: "threads", isolate: true, include: MOCKERS } },
|
||||
],
|
||||
},
|
||||
server: {
|
||||
port: 5273,
|
||||
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/i18n"
|
||||
"reasonix/internal/notify"
|
||||
"reasonix/internal/stats"
|
||||
"reasonix/internal/trajectory"
|
||||
@@ -32,7 +33,7 @@ func TestAuditCapabilitiesAreForwardedByEveryWrapper(t *testing.T) {
|
||||
event.Sync(event.Discard),
|
||||
event.Coalesce(event.Discard, time.Millisecond),
|
||||
control.NewGoalUsageTee(event.Discard),
|
||||
notify.NewSink(event.Discard, nil, config.NotificationsConfig{}),
|
||||
notify.NewSink(event.Discard, nil, i18n.English, notify.NewSettings(config.NotificationsConfig{})),
|
||||
&trajectory.Recorder{},
|
||||
&stats.Recorder{},
|
||||
}
|
||||
|
||||
+1
-11
@@ -404,16 +404,6 @@ func loadResumableSession(path string) (*agent.Session, error) {
|
||||
return agent.LoadSession(path)
|
||||
}
|
||||
|
||||
var newNotificationSender = func() notify.Sender { return notify.NewPlatformSender() }
|
||||
|
||||
// withNotifications adds system notifications to CLI event streams when configured.
|
||||
func withNotifications(sink event.Sink, cfg *config.Config) event.Sink {
|
||||
if cfg == nil || !cfg.Notifications.Enabled {
|
||||
return sink
|
||||
}
|
||||
return notify.NewSink(sink, newNotificationSender(), cfg.Notifications)
|
||||
}
|
||||
|
||||
// registerContinueFlag registers --continue with its -c shorthand. The
|
||||
// shorthand must go through BoolP (pflag shorthand), not BoolVar: BoolVar
|
||||
// registers "c" as a long flag name, which leaves "-c" unparseable
|
||||
@@ -656,7 +646,7 @@ func runAgent(args []string, version string) int {
|
||||
reporter.RecordRecovery(ctrl.DrainRecoveryMetrics())
|
||||
completion := classifyRunCompletion(runErr)
|
||||
if cfg != nil {
|
||||
notify.SendEvent(newNotificationSender(), cfg.Notifications, event.Event{
|
||||
notify.SendEvent(newNotificationSender(), i18n.M, cfg.Notifications, event.Event{
|
||||
Kind: event.TurnDone,
|
||||
Err: runErr,
|
||||
Outcome: completion.outcome,
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package cli
|
||||
|
||||
import (
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/i18n"
|
||||
"reasonix/internal/notify"
|
||||
)
|
||||
|
||||
var newNotificationSender = func() notify.Sender { return notify.NewPlatformSender() }
|
||||
|
||||
// withNotifications adds system notifications to CLI event streams when configured.
|
||||
// The CLI reads its config once per process, so the holder never moves after it
|
||||
// is made; a window that can flip the switch mid-run stores into its own.
|
||||
func withNotifications(sink event.Sink, cfg *config.Config) event.Sink {
|
||||
if cfg == nil || !cfg.Notifications.Enabled {
|
||||
return sink
|
||||
}
|
||||
return notify.NewSink(sink, newNotificationSender(), i18n.M, notify.NewSettings(cfg.Notifications))
|
||||
}
|
||||
@@ -133,6 +133,11 @@ type Messages struct {
|
||||
RecoveryPlanAfterFmt string // compact proposed-plan line, one %s
|
||||
RecoveryTaskGrantChoices string // Auto Guard list with a current-task semantic grant
|
||||
SandboxEscapeApprovalChoices string // approval choice list for OS sandbox escape prompts
|
||||
NotifyTitle string // desktop notification title
|
||||
NotifyTurnDone string // desktop notification: the turn finished
|
||||
NotifyTurnFailed string // desktop notification: the turn failed
|
||||
NotifyApproval string // desktop notification: something is waiting to be approved
|
||||
NotifyAsk string // desktop notification: the model asked a question
|
||||
ApprovalNeededFmt string // notification text for a pending approval, tool only
|
||||
ApprovalNeededWithSubjectFmt string // notification text for a pending approval with subject
|
||||
ToolApprovalSourceFmt string // "Source: %s" / "来源: %s"
|
||||
|
||||
@@ -115,6 +115,11 @@ var English = Messages{
|
||||
RecoveryPlanAfterFmt: "Proposed plan: %s",
|
||||
RecoveryTaskGrantChoices: "1. Continue once\n2. Continue and allow similar actions in this task\n3. Try another approach",
|
||||
SandboxEscapeApprovalChoices: "1. Allow once\n2. Use real environment for this session\n3. Deny\nChoose [1/2/3] (y/a/n also work)",
|
||||
NotifyTitle: "Reasonix",
|
||||
NotifyTurnDone: "The turn finished",
|
||||
NotifyTurnFailed: "The turn failed",
|
||||
NotifyApproval: "Waiting for your approval",
|
||||
NotifyAsk: "The model asked you something",
|
||||
ApprovalNeededFmt: "approval needed: %s",
|
||||
ApprovalNeededWithSubjectFmt: "approval needed: %s %s",
|
||||
ToolApprovalSourceFmt: "Source: %s",
|
||||
|
||||
@@ -116,6 +116,11 @@ var Chinese = Messages{
|
||||
RecoveryPlanAfterFmt: "新计划:%s",
|
||||
RecoveryTaskGrantChoices: "1. 继续一次\n2. 继续,并在本任务内允许同类操作\n3. 换个方案",
|
||||
SandboxEscapeApprovalChoices: "1. 允许一次\n2. 本会话使用真实环境\n3. 拒绝\n选择 [1/2/3](兼容 y/a/n)",
|
||||
NotifyTitle: "Reasonix",
|
||||
NotifyTurnDone: "这一轮结束了",
|
||||
NotifyTurnFailed: "这一轮失败了",
|
||||
NotifyApproval: "有操作在等你批准",
|
||||
NotifyAsk: "模型问了你一个问题",
|
||||
ApprovalNeededFmt: "需要审批:%s",
|
||||
ApprovalNeededWithSubjectFmt: "需要审批:%s %s",
|
||||
ToolApprovalSourceFmt: "来源: %s",
|
||||
|
||||
@@ -112,6 +112,11 @@ var ChineseTraditional = Messages{
|
||||
RecoveryPlanAfterFmt: "新計畫:%s",
|
||||
RecoveryTaskGrantChoices: "1. 繼續一次\n2. 繼續,並在本任務內允許同類操作\n3. 換個方案",
|
||||
SandboxEscapeApprovalChoices: "1. 允許一次\n2. 本會話使用真實環境\n3. 拒絕\n選擇 [1/2/3](相容 y/a/n)",
|
||||
NotifyTitle: "Reasonix",
|
||||
NotifyTurnDone: "這一輪結束了",
|
||||
NotifyTurnFailed: "這一輪失敗了",
|
||||
NotifyApproval: "有操作在等你核准",
|
||||
NotifyAsk: "模型問了你一個問題",
|
||||
ApprovalNeededFmt: "需要核准:%s",
|
||||
ApprovalNeededWithSubjectFmt: "需要核准:%s %s",
|
||||
ToolApprovalSourceFmt: "來源: %s",
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"sync/atomic"
|
||||
|
||||
"reasonix/internal/config"
|
||||
)
|
||||
|
||||
// Settings is what a sink reads on every event, so a window flipping the switch
|
||||
// reaches the next turn rather than the next launch. One holder is shared by
|
||||
// every runtime a host builds: the setting belongs to the machine, not to a
|
||||
// session, and a second copy is a second answer.
|
||||
type Settings struct {
|
||||
held atomic.Pointer[config.NotificationsConfig]
|
||||
}
|
||||
|
||||
// NewSettings starts a holder at what the config said when it was read.
|
||||
func NewSettings(cfg config.NotificationsConfig) *Settings {
|
||||
s := &Settings{}
|
||||
s.Store(cfg)
|
||||
return s
|
||||
}
|
||||
|
||||
// Store replaces what every sink reading this holder will answer to next.
|
||||
func (s *Settings) Store(cfg config.NotificationsConfig) {
|
||||
if s == nil {
|
||||
return
|
||||
}
|
||||
s.held.Store(&cfg)
|
||||
}
|
||||
|
||||
// Load answers the current setting. A nil holder notifies about nothing, which
|
||||
// is what a runtime built without one is asking for.
|
||||
func (s *Settings) Load() config.NotificationsConfig {
|
||||
if s == nil {
|
||||
return config.NotificationsConfig{}
|
||||
}
|
||||
if cfg := s.held.Load(); cfg != nil {
|
||||
return *cfg
|
||||
}
|
||||
return config.NotificationsConfig{}
|
||||
}
|
||||
+19
-12
@@ -3,6 +3,7 @@ package notify
|
||||
import (
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/i18n"
|
||||
)
|
||||
|
||||
// Message is the user-visible payload sent to the platform notifier.
|
||||
@@ -16,17 +17,20 @@ type Sender interface {
|
||||
Send(Message) error
|
||||
}
|
||||
|
||||
// Sink forwards every event to inner and mirrors configured attention events to sender.
|
||||
// Sink forwards every event to inner and mirrors configured attention events to
|
||||
// sender. It holds the setting rather than a copy of it, so a runtime built
|
||||
// while notifications were off still delivers once they are turned on.
|
||||
type Sink struct {
|
||||
event.AuditForwarder
|
||||
inner event.Sink
|
||||
sender Sender
|
||||
cfg config.NotificationsConfig
|
||||
say i18n.Messages
|
||||
set *Settings
|
||||
}
|
||||
|
||||
// NewSink wraps an existing event sink with best-effort notification delivery.
|
||||
func NewSink(inner event.Sink, sender Sender, cfg config.NotificationsConfig) *Sink {
|
||||
return &Sink{AuditForwarder: event.AuditForwarder{Inner: inner}, inner: inner, sender: sender, cfg: cfg}
|
||||
func NewSink(inner event.Sink, sender Sender, say i18n.Messages, set *Settings) *Sink {
|
||||
return &Sink{AuditForwarder: event.AuditForwarder{Inner: inner}, inner: inner, sender: sender, say: say, set: set}
|
||||
}
|
||||
|
||||
// Emit preserves the underlying event stream before attempting notification side effects.
|
||||
@@ -34,35 +38,38 @@ func (s *Sink) Emit(e event.Event) {
|
||||
if s.inner != nil {
|
||||
s.inner.Emit(e)
|
||||
}
|
||||
SendEvent(s.sender, s.cfg, e)
|
||||
SendEvent(s.sender, s.say, s.set.Load(), e)
|
||||
}
|
||||
|
||||
// SendEvent applies the same notification rules for paths that do not emit through Sink.
|
||||
func SendEvent(sender Sender, cfg config.NotificationsConfig, e event.Event) {
|
||||
func SendEvent(sender Sender, say i18n.Messages, cfg config.NotificationsConfig, e event.Event) {
|
||||
if !cfg.Enabled || sender == nil {
|
||||
return
|
||||
}
|
||||
if msg, ok := message(cfg, e); ok {
|
||||
if msg, ok := message(say, cfg, e); ok {
|
||||
_ = sender.Send(msg)
|
||||
}
|
||||
}
|
||||
|
||||
func message(cfg config.NotificationsConfig, e event.Event) (Message, bool) {
|
||||
// A notification is the one thing this process says outside its own window, so
|
||||
// it says it in the language the window is set to rather than the kernel's log
|
||||
// English.
|
||||
func message(say i18n.Messages, cfg config.NotificationsConfig, e event.Event) (Message, bool) {
|
||||
switch e.Kind {
|
||||
case event.TurnDone:
|
||||
if cfg.TurnDone {
|
||||
if e.Err != nil {
|
||||
return Message{Title: "Reasonix", Body: "Turn failed"}, true
|
||||
return Message{Title: say.NotifyTitle, Body: say.NotifyTurnFailed}, true
|
||||
}
|
||||
return Message{Title: "Reasonix", Body: "Turn finished"}, true
|
||||
return Message{Title: say.NotifyTitle, Body: say.NotifyTurnDone}, true
|
||||
}
|
||||
case event.ApprovalRequest:
|
||||
if cfg.ApprovalRequest {
|
||||
return Message{Title: "Reasonix", Body: "Approval needed"}, true
|
||||
return Message{Title: say.NotifyTitle, Body: say.NotifyApproval}, true
|
||||
}
|
||||
case event.AskRequest:
|
||||
if cfg.AskRequest {
|
||||
return Message{Title: "Reasonix", Body: "Question needs your answer"}, true
|
||||
return Message{Title: say.NotifyTitle, Body: say.NotifyAsk}, true
|
||||
}
|
||||
}
|
||||
return Message{}, false
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/i18n"
|
||||
)
|
||||
|
||||
var errTestFailure = errors.New("failed")
|
||||
@@ -30,7 +31,7 @@ type recordSender struct {
|
||||
func TestSinkForwardsProtocolRecoveryWithoutNotification(t *testing.T) {
|
||||
inner := &recordSink{}
|
||||
sender := &recordSender{}
|
||||
sink := NewSink(inner, sender, config.NotificationsConfig{Enabled: true, TurnDone: true})
|
||||
sink := NewSink(inner, sender, i18n.English, NewSettings(config.NotificationsConfig{Enabled: true, TurnDone: true}))
|
||||
|
||||
event.RecordProtocolRecovery(sink, event.ProtocolRecoveryAudit{Kind: event.ProtocolRecoveryMissingReasoningFallback})
|
||||
|
||||
@@ -50,12 +51,12 @@ func (s *recordSender) Send(m Message) error {
|
||||
func TestSinkForwardsEventsAndSendsConfiguredNotifications(t *testing.T) {
|
||||
inner := &recordSink{}
|
||||
sender := &recordSender{}
|
||||
sink := NewSink(inner, sender, config.NotificationsConfig{
|
||||
sink := NewSink(inner, sender, i18n.English, NewSettings(config.NotificationsConfig{
|
||||
Enabled: true,
|
||||
TurnDone: true,
|
||||
ApprovalRequest: true,
|
||||
AskRequest: true,
|
||||
})
|
||||
}))
|
||||
|
||||
sink.Emit(event.Event{Kind: event.ApprovalRequest})
|
||||
sink.Emit(event.Event{Kind: event.AskRequest})
|
||||
@@ -67,13 +68,13 @@ func TestSinkForwardsEventsAndSendsConfiguredNotifications(t *testing.T) {
|
||||
if len(sender.messages) != 3 {
|
||||
t.Fatalf("notifications = %d, want 3", len(sender.messages))
|
||||
}
|
||||
if sender.messages[0].Body != "Approval needed" {
|
||||
if sender.messages[0].Body != i18n.English.NotifyApproval {
|
||||
t.Errorf("approval notification body = %q", sender.messages[0].Body)
|
||||
}
|
||||
if sender.messages[1].Body != "Question needs your answer" {
|
||||
if sender.messages[1].Body != i18n.English.NotifyAsk {
|
||||
t.Errorf("ask notification body = %q", sender.messages[1].Body)
|
||||
}
|
||||
if sender.messages[2].Body != "Turn finished" {
|
||||
if sender.messages[2].Body != i18n.English.NotifyTurnDone {
|
||||
t.Errorf("turn notification body = %q", sender.messages[2].Body)
|
||||
}
|
||||
}
|
||||
@@ -81,12 +82,12 @@ func TestSinkForwardsEventsAndSendsConfiguredNotifications(t *testing.T) {
|
||||
func TestSinkSkipsNotificationsWhenDisabled(t *testing.T) {
|
||||
inner := &recordSink{}
|
||||
sender := &recordSender{}
|
||||
sink := NewSink(inner, sender, config.NotificationsConfig{
|
||||
sink := NewSink(inner, sender, i18n.English, NewSettings(config.NotificationsConfig{
|
||||
Enabled: false,
|
||||
TurnDone: true,
|
||||
ApprovalRequest: true,
|
||||
AskRequest: true,
|
||||
})
|
||||
}))
|
||||
|
||||
sink.Emit(event.Event{Kind: event.TurnDone})
|
||||
|
||||
@@ -101,12 +102,12 @@ func TestSinkSkipsNotificationsWhenDisabled(t *testing.T) {
|
||||
func TestSendEventUsesSameNotificationRules(t *testing.T) {
|
||||
sender := &recordSender{}
|
||||
|
||||
SendEvent(sender, config.NotificationsConfig{Enabled: true, TurnDone: true}, event.Event{Kind: event.TurnDone})
|
||||
SendEvent(sender, i18n.English, config.NotificationsConfig{Enabled: true, TurnDone: true}, event.Event{Kind: event.TurnDone})
|
||||
|
||||
if len(sender.messages) != 1 {
|
||||
t.Fatalf("notifications = %d, want 1", len(sender.messages))
|
||||
}
|
||||
if sender.messages[0].Body != "Turn finished" {
|
||||
if sender.messages[0].Body != i18n.English.NotifyTurnDone {
|
||||
t.Errorf("notification body = %q", sender.messages[0].Body)
|
||||
}
|
||||
}
|
||||
@@ -114,24 +115,24 @@ func TestSendEventUsesSameNotificationRules(t *testing.T) {
|
||||
func TestTurnDoneWithErrorSendsFailureNotification(t *testing.T) {
|
||||
sender := &recordSender{}
|
||||
|
||||
SendEvent(sender, config.NotificationsConfig{Enabled: true, TurnDone: true}, event.Event{Kind: event.TurnDone, Err: errTestFailure})
|
||||
SendEvent(sender, i18n.English, config.NotificationsConfig{Enabled: true, TurnDone: true}, event.Event{Kind: event.TurnDone, Err: errTestFailure})
|
||||
|
||||
if len(sender.messages) != 1 {
|
||||
t.Fatalf("notifications = %d, want 1", len(sender.messages))
|
||||
}
|
||||
if sender.messages[0].Body != "Turn failed" {
|
||||
if sender.messages[0].Body != i18n.English.NotifyTurnFailed {
|
||||
t.Errorf("notification body = %q", sender.messages[0].Body)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSinkHonorsPerEventConfig(t *testing.T) {
|
||||
sender := &recordSender{}
|
||||
sink := NewSink(&recordSink{}, sender, config.NotificationsConfig{
|
||||
sink := NewSink(&recordSink{}, sender, i18n.English, NewSettings(config.NotificationsConfig{
|
||||
Enabled: true,
|
||||
TurnDone: false,
|
||||
ApprovalRequest: true,
|
||||
AskRequest: false,
|
||||
})
|
||||
}))
|
||||
|
||||
sink.Emit(event.Event{Kind: event.TurnDone})
|
||||
sink.Emit(event.Event{Kind: event.ApprovalRequest})
|
||||
@@ -140,7 +141,46 @@ func TestSinkHonorsPerEventConfig(t *testing.T) {
|
||||
if len(sender.messages) != 1 {
|
||||
t.Fatalf("notifications = %d, want 1", len(sender.messages))
|
||||
}
|
||||
if sender.messages[0].Body != "Approval needed" {
|
||||
if sender.messages[0].Body != i18n.English.NotifyApproval {
|
||||
t.Errorf("notification body = %q", sender.messages[0].Body)
|
||||
}
|
||||
}
|
||||
|
||||
// The switch says "notify me", not "notify me after a restart". A runtime built
|
||||
// while the setting was off is the ordinary case — the window is up before
|
||||
// anybody opens settings — so it is the one that has to start delivering.
|
||||
func TestSinkBuiltWhileOffDeliversOnceTurnedOn(t *testing.T) {
|
||||
sender := &recordSender{}
|
||||
set := NewSettings(config.NotificationsConfig{})
|
||||
sink := NewSink(&recordSink{}, sender, i18n.English, set)
|
||||
|
||||
sink.Emit(event.Event{Kind: event.TurnDone})
|
||||
if len(sender.messages) != 0 {
|
||||
t.Fatalf("notified while off: %+v", sender.messages)
|
||||
}
|
||||
|
||||
set.Store(config.NotificationsConfig{Enabled: true, TurnDone: true})
|
||||
sink.Emit(event.Event{Kind: event.TurnDone})
|
||||
if len(sender.messages) != 1 {
|
||||
t.Fatalf("notifications after turning it on = %d, want 1", len(sender.messages))
|
||||
}
|
||||
|
||||
set.Store(config.NotificationsConfig{})
|
||||
sink.Emit(event.Event{Kind: event.TurnDone})
|
||||
if len(sender.messages) != 1 {
|
||||
t.Fatalf("kept notifying after it was turned off = %d, want 1", len(sender.messages))
|
||||
}
|
||||
}
|
||||
|
||||
// A notification leaves the window, so it is said in the language the window is
|
||||
// set to rather than the kernel's log English.
|
||||
func TestNotificationSpeaksTheWindowsLanguage(t *testing.T) {
|
||||
sender := &recordSender{}
|
||||
SendEvent(sender, i18n.Chinese, config.NotificationsConfig{Enabled: true, TurnDone: true}, event.Event{Kind: event.TurnDone})
|
||||
if len(sender.messages) != 1 || sender.messages[0].Body != i18n.Chinese.NotifyTurnDone {
|
||||
t.Fatalf("notification body = %+v", sender.messages)
|
||||
}
|
||||
if sender.messages[0].Body == i18n.English.NotifyTurnDone {
|
||||
t.Fatal("notification answered in English for a Chinese window")
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,7 @@ import (
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/fileutil"
|
||||
"reasonix/internal/notify"
|
||||
"reasonix/internal/provider"
|
||||
"reasonix/internal/surface"
|
||||
"reasonix/internal/update"
|
||||
@@ -148,6 +149,10 @@ type HubOptions struct {
|
||||
// BrowserHost draws hosted browser views in the window behind this hub. Nil
|
||||
// leaves its routes unregistered, and browsers are launched instead.
|
||||
BrowserHost *BrowserHost
|
||||
// Notifications is the holder every sink this hub decorates reads, and what
|
||||
// the settings surface writes into. Nil leaves those routes unregistered:
|
||||
// a kernel reached over the network would fire on its own machine.
|
||||
Notifications *notify.Settings
|
||||
// Tray is the window behind this hub, where there is one. Nil leaves the
|
||||
// tray routes unregistered rather than answering for an icon that does not
|
||||
// exist — a networked server has no window to put one on.
|
||||
@@ -483,6 +488,7 @@ func (h *Hub) Handler() http.Handler {
|
||||
mux.HandleFunc("POST /remotes/open", h.openRemoteRuntime)
|
||||
h.registerTreeRoutes(mux)
|
||||
h.registerTrayRoutes(mux)
|
||||
h.registerNotifyRoutes(mux)
|
||||
h.registerBrowserHostRoutes(mux)
|
||||
h.registerStudioVersionRoutes(mux)
|
||||
h.registerUpdateRoutes(mux)
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package serve
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
|
||||
"reasonix/internal/config"
|
||||
)
|
||||
|
||||
// NotifyPrefs is what a window can be asked about the desktop notifications it
|
||||
// sends on this machine's behalf. Enabled gates the other three, which are what
|
||||
// is worth being interrupted for.
|
||||
type NotifyPrefs struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
TurnDone bool `json:"turnDone"`
|
||||
Approval bool `json:"approval"`
|
||||
Ask bool `json:"ask"`
|
||||
}
|
||||
|
||||
const codeNotifyRejected = "notifications.rejected"
|
||||
|
||||
// registerNotifyRoutes is registered only where a window holds the sender. A
|
||||
// kernel reached over the network would fire on its own machine rather than the
|
||||
// watcher's, so it answers for no switch at all.
|
||||
func (h *Hub) registerNotifyRoutes(mux *http.ServeMux) {
|
||||
if h.opts.Notifications == nil {
|
||||
return
|
||||
}
|
||||
mux.HandleFunc("GET /notifications", h.readNotifyPrefs)
|
||||
mux.HandleFunc("PUT /notifications", h.writeNotifyPrefs)
|
||||
}
|
||||
|
||||
func (h *Hub) readNotifyPrefs(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, h.NotifyPrefs())
|
||||
}
|
||||
|
||||
// NotifyPrefs reads the durable answer rather than what the holder happens to
|
||||
// carry: the file is what survives a launch, and the holder is a projection of
|
||||
// it onto the sinks already running.
|
||||
func (h *Hub) NotifyPrefs() NotifyPrefs {
|
||||
if h.opts.Notifications == nil {
|
||||
return NotifyPrefs{}
|
||||
}
|
||||
cfg := config.LoadForEdit(config.UserConfigPath()).Notifications
|
||||
return NotifyPrefs{Enabled: cfg.Enabled, TurnDone: cfg.TurnDone, Approval: cfg.ApprovalRequest, Ask: cfg.AskRequest}
|
||||
}
|
||||
|
||||
// SetNotifyPrefs persists the switches and hands the holder the same answer, so
|
||||
// the runtimes already built deliver by them on their next event instead of at
|
||||
// the next launch.
|
||||
func (h *Hub) SetNotifyPrefs(next NotifyPrefs) (NotifyPrefs, error) {
|
||||
want := config.NotificationsConfig{
|
||||
Enabled: next.Enabled,
|
||||
TurnDone: next.TurnDone,
|
||||
ApprovalRequest: next.Approval,
|
||||
AskRequest: next.Ask,
|
||||
}
|
||||
path := config.UserConfigPath()
|
||||
edit := config.LoadForEdit(path)
|
||||
edit.Notifications = want
|
||||
if err := edit.SaveTo(path); err != nil {
|
||||
return NotifyPrefs{}, err
|
||||
}
|
||||
// The sinks already running act on it now rather than at the next launch:
|
||||
// that is what the switch says, so that is what it has to do.
|
||||
h.opts.Notifications.Store(want)
|
||||
return h.NotifyPrefs(), nil
|
||||
}
|
||||
|
||||
func (h *Hub) writeNotifyPrefs(w http.ResponseWriter, r *http.Request) {
|
||||
var body NotifyPrefs
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
||||
missingField(w, "enabled")
|
||||
return
|
||||
}
|
||||
got, err := h.SetNotifyPrefs(body)
|
||||
if err != nil {
|
||||
refuse(w, http.StatusInternalServerError, codeNotifyRejected, err.Error(), nil)
|
||||
return
|
||||
}
|
||||
writeJSON(w, got)
|
||||
}
|
||||
Reference in new issue
Block a user