fix(ingest,ui): count each assistant message once, each turn once, and each event once
Three defects that all showed up as "the dashboard's numbers are wrong", plus the notification and brand work asked for alongside them. Tokens: Claude Code writes ONE JSONL line per content block of an assistant message, and every one of those lines repeats the same message-level `usage`. Both parsers summed them, so a tool-heavy session's tokens — and its cost — ran 1.5-2x high (one 4h session: $45.82 stored against $27.72 real). `lastUsageMsgId` counts a message once, carried across the transcript cache's incremental chunk boundaries. `replaceTokenUsage`'s high-water baselines mean the fixed parser cannot correct what is already stored, so `setTokenUsage` (exact, baselines cleared) is added for the deliberate re-parse path and `reconcileTokens` now covers every session with a transcript on disk rather than imported ones only. Turns: every `TurnDuration` row was stamped NOW while its dedup guard matched on the transcript's timestamp, so each hook re-inserted the session's whole turn history — 587k rows, 75% of a real events table, one session holding 68,747 copies of the same turns. The row now carries the transcript timestamp, which fixes the guard and the feed's ordering together. Same class of bug in the session metadata: `thinking_blocks` / `turn_count` / `total_turn_duration_ms` are assigned from the full parse instead of `+=`, since `extract()` returns whole-transcript totals on every call. Events: leftover checkout hook entries in `~/.claude/settings.json` double every event, every subagent row and every token figure. `stripLegacyHooks` has always removed them — but only inside `bootstrap()`, which `fastPathOk` skips on exactly the healthy installs where nothing else forces a full pass, so they survived indefinitely. `hasLegacyHooks` (pure fs) now blocks the fast path. A one-time repair at the first start after upgrading (`user_version` 3) deletes the duplicated `TurnDuration` rows and re-parses every transcript with exact writes. Marker 3 and not 2: an install that ran the earlier pass had its corrected rows re-inflated by whatever old build was still ingesting hooks. Router: switching a lane's AI Router while a pane is live only half-applies — Claude Code re-applies `env` to a running session but keeps the model it resolved at startup, so the old router's model name reaches the new endpoint and the next prompt fails with "model not found". The apply route reports `restart_required` / `run_id`, and the picker offers a Restart that respawns the pane from the lane id alone (replaying the old `--model` would re-create the mismatch). Notifications (#29): the two moments Claude is BLOCKED on the user now have their own switches and default to on — `onAwaitingInput` (a permission prompt or a question) and `onTurnComplete` (end of turn). Before, the first was unswitchable and the second hung off a preference that defaults to off, so the one thing a user walks away from went unannounced. Prefs shape/defaults/storage move to `client/src/lib/notifPrefs.ts`; Settings and the hook had private copies. Brand: the sidebar shows the SmartGift wordmark with the AgentHub caption under it, the collapsed rail shows the mark, and the favicon is vendored to match.
This commit is contained in:
@@ -54,7 +54,6 @@ import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
FolderOpen,
|
||||
Activity,
|
||||
BarChart3,
|
||||
Workflow,
|
||||
Play,
|
||||
@@ -92,6 +91,13 @@ const NAV_KEYS = [
|
||||
{ to: "/settings", icon: Settings, key: "nav:settings" },
|
||||
] as const;
|
||||
|
||||
// SmartGift wordmark, replacing the old text subtitle. Remote URLs: the logo is
|
||||
// owned by the brand, not vendored here — a machine with no network shows the
|
||||
// alt text instead.
|
||||
const BRAND_LOGO =
|
||||
"https://smg-services.s3.ap-southeast-1.amazonaws.com/smartgift/assets/logo/logo-main.png";
|
||||
const BRAND_MARK = "https://smartgift.vn/favicon.ico";
|
||||
|
||||
const STORAGE_KEY = "sidebar-collapsed";
|
||||
const STATS_STORAGE_KEY = "sidebar-connection-stats";
|
||||
const RECENT_EVENTS_CAP = 8;
|
||||
@@ -397,15 +403,23 @@ export function Sidebar({ wsConnected, collapsed, onToggle }: SidebarProps) {
|
||||
>
|
||||
{/* Brand */}
|
||||
<div className="px-3 py-4 border-b border-border flex-shrink-0">
|
||||
<div className={`flex items-center ${collapsed ? "justify-center" : "gap-3 px-2"}`}>
|
||||
<div className="w-8 h-8 rounded-lg bg-accent/20 flex items-center justify-center flex-shrink-0">
|
||||
<Activity className="w-4 h-4 text-accent" />
|
||||
</div>
|
||||
{!collapsed && (
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-sm font-semibold text-fg-primary truncate">{t("nav:brand")}</h1>
|
||||
<p className="text-[11px] text-fg-muted">{t("nav:brandSub")}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-center justify-center">
|
||||
{collapsed ? (
|
||||
<img src={BRAND_MARK} alt={t("nav:brandSub")} className="w-8 h-8 flex-shrink-0" />
|
||||
) : (
|
||||
<>
|
||||
<img src={BRAND_LOGO} alt={t("nav:brandSub")} className="w-full h-auto px-2" />
|
||||
{/* Wordmark under the logo: small caps + wide tracking so it reads
|
||||
as a caption to the logo rather than competing with it. The
|
||||
hairlines either side keep it centred on a narrow rail. */}
|
||||
<div className="mt-3 flex w-full items-center gap-2 px-1">
|
||||
<span className="h-px flex-1 bg-fg-muted/40" />
|
||||
<span className="text-[11px] font-semibold uppercase tracking-[0.2em] text-fg-primary">
|
||||
{t("nav:brand")}
|
||||
</span>
|
||||
<span className="h-px flex-1 bg-fg-muted/40" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -19,14 +19,19 @@ function renderSidebar(wsConnected: boolean, collapsed = false) {
|
||||
}
|
||||
|
||||
describe("Sidebar", () => {
|
||||
it("should render the brand name", () => {
|
||||
it("should render the brand logo in place of the text subtitle", () => {
|
||||
renderSidebar(true);
|
||||
expect(screen.getByText("Agent Dashboard")).toBeInTheDocument();
|
||||
// One wordmark in both themes — no light/dark variant.
|
||||
expect(screen.getByAltText("SmartGift Solution")).toHaveAttribute(
|
||||
"src",
|
||||
expect.stringContaining("logo-main.png")
|
||||
);
|
||||
});
|
||||
|
||||
it("should render the subtitle", () => {
|
||||
renderSidebar(true);
|
||||
expect(screen.getByText("SmartGift Solution")).toBeInTheDocument();
|
||||
it("should render the favicon mark when collapsed", () => {
|
||||
renderSidebar(true, true);
|
||||
const mark = screen.getByAltText("SmartGift Solution");
|
||||
expect(mark).toHaveAttribute("src", "https://smartgift.vn/favicon.ico");
|
||||
});
|
||||
|
||||
it("should render all navigation links", () => {
|
||||
|
||||
@@ -159,7 +159,7 @@ export function LaneRouterRow({ lane }: { lane: Lane }) {
|
||||
const [routers, setRouters] = useState<AiRouter[] | null>(null);
|
||||
const [current, setCurrent] = useState(lane.router_profile || "native");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [note, setNote] = useState<{ ok: boolean; text: string } | null>(null);
|
||||
const [note, setNote] = useState<{ ok: boolean; text: string; runId?: string } | null>(null);
|
||||
|
||||
// The polled lane list is the source of truth; a switch elsewhere (the lane
|
||||
// was reset to native by a profile delete, say) has to land here too.
|
||||
@@ -204,9 +204,17 @@ export function LaneRouterRow({ lane }: { lane: Lane }) {
|
||||
setBusy(true);
|
||||
setNote(null);
|
||||
try {
|
||||
await api.lanes.routerApply(laneId, name);
|
||||
const res = await api.lanes.routerApply(laneId, name);
|
||||
if (shownLane.current !== laneId) return;
|
||||
setNote({ ok: true, text: t("router.applied") });
|
||||
// A live pane adopted the new base URL/key but kept the model names it
|
||||
// resolved at startup, so it would send the OLD router's model to the NEW
|
||||
// endpoint and fail with "model not found". Only that case gets the
|
||||
// warning and the restart button.
|
||||
setNote(
|
||||
res.restart_required && res.run_id
|
||||
? { ok: false, text: t("router.appliedRestart"), runId: res.run_id }
|
||||
: { ok: true, text: t("router.applied") }
|
||||
);
|
||||
} catch (err) {
|
||||
if (shownLane.current !== laneId) return;
|
||||
setCurrent(previous);
|
||||
@@ -218,6 +226,28 @@ export function LaneRouterRow({ lane }: { lane: Lane }) {
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Kill the pane and open a fresh one for this lane. Started with the lane id
|
||||
* alone on purpose: the previous run's `--model` flag names a model of the
|
||||
* router the user just left, so replaying it would re-create the exact
|
||||
* mismatch this button exists to clear.
|
||||
*/
|
||||
const restart = async (runId: string) => {
|
||||
const laneId = lane.id;
|
||||
setBusy(true);
|
||||
try {
|
||||
await api.run.kill(runId);
|
||||
await api.run.start({ laneId });
|
||||
if (shownLane.current !== laneId) return;
|
||||
setNote({ ok: true, text: t("router.restarted") });
|
||||
} catch (err) {
|
||||
if (shownLane.current !== laneId) return;
|
||||
setNote({ ok: false, text: err instanceof Error ? err.message : String(err) });
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<label
|
||||
className="flex items-center gap-1.5 text-xs text-fg-muted"
|
||||
@@ -246,6 +276,19 @@ export function LaneRouterRow({ lane }: { lane: Lane }) {
|
||||
{note.ok ? "✓" : "⚠"}
|
||||
</span>
|
||||
)}
|
||||
{/* The one case with an action attached: restarting the pane is what
|
||||
actually makes the switch take effect, so it is a button and not
|
||||
another sentence the user has to act on somewhere else. */}
|
||||
{note?.runId && (
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
onClick={() => void restart(note.runId as string)}
|
||||
className="rounded border border-border px-1.5 py-0.5 text-[11px] text-fg-secondary hover:bg-surface-2 disabled:opacity-50"
|
||||
>
|
||||
{t("router.restart")}
|
||||
</button>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -136,7 +136,13 @@ beforeEach(() => {
|
||||
vi.mocked(api.lanes.mcpSync).mockReset().mockResolvedValue({ servers: [], profilesSeeded: [] });
|
||||
vi.mocked(api.lanes.routerApply)
|
||||
.mockReset()
|
||||
.mockResolvedValue({ tool: "claude", file: null, profile: "native" });
|
||||
.mockResolvedValue({
|
||||
tool: "claude",
|
||||
file: null,
|
||||
restart_required: false,
|
||||
run_id: null,
|
||||
profile: "native",
|
||||
});
|
||||
// Only the built-in profile by default, so the router radio stays on Native
|
||||
// and no test has to opt out of it.
|
||||
vi.mocked(api.aiRouters.list)
|
||||
|
||||
@@ -34,6 +34,10 @@ vi.mock("../../../lib/api", () => ({
|
||||
aiRouters: {
|
||||
list: vi.fn(),
|
||||
},
|
||||
run: {
|
||||
kill: vi.fn(),
|
||||
start: vi.fn(),
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -503,6 +507,8 @@ describe("LaneRouterRow — the Model picker", () => {
|
||||
tool: "claude",
|
||||
file: "/work/demo/.claude/settings.local.json",
|
||||
profile: "9router",
|
||||
restart_required: false,
|
||||
run_id: null,
|
||||
});
|
||||
render(<LaneRouterRow lane={makeLane()} />);
|
||||
|
||||
@@ -513,6 +519,53 @@ describe("LaneRouterRow — the Model picker", () => {
|
||||
expect(await screen.findByDisplayValue("9Router")).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("offers a restart when a live pane still holds the old router's models", async () => {
|
||||
// Claude Code re-applies `env` to a running session but keeps the model it
|
||||
// resolved at startup, so the switch is only half-applied until the pane is
|
||||
// restarted — the server says so and the row must act on it.
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
||||
vi.mocked(api.lanes.routerApply).mockResolvedValue({
|
||||
tool: "claude",
|
||||
file: "/work/demo/.claude/settings.local.json",
|
||||
profile: "9router",
|
||||
restart_required: true,
|
||||
run_id: "agenthub-lane-1",
|
||||
});
|
||||
vi.mocked(api.run.kill).mockResolvedValue({ ok: true });
|
||||
vi.mocked(api.run.start).mockResolvedValue({} as never);
|
||||
render(<LaneRouterRow lane={makeLane()} />);
|
||||
|
||||
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
|
||||
"9router",
|
||||
]);
|
||||
|
||||
const restart = await screen.findByRole("button", { name: "Restart" });
|
||||
await userEvent.click(restart);
|
||||
|
||||
await waitFor(() => expect(api.run.kill).toHaveBeenCalledWith("agenthub-lane-1"));
|
||||
// Started by lane id alone: replaying the old run's --model would name a
|
||||
// model of the router the user just left.
|
||||
expect(api.run.start).toHaveBeenCalledWith({ laneId: 1 });
|
||||
});
|
||||
|
||||
it("leaves the restart button out when no pane is running", async () => {
|
||||
vi.mocked(api.aiRouters.list).mockResolvedValue({ items: [native, nineRouter] });
|
||||
vi.mocked(api.lanes.routerApply).mockResolvedValue({
|
||||
tool: "claude",
|
||||
file: "/work/demo/.claude/settings.local.json",
|
||||
profile: "9router",
|
||||
restart_required: false,
|
||||
run_id: null,
|
||||
});
|
||||
render(<LaneRouterRow lane={makeLane()} />);
|
||||
|
||||
await userEvent.selectOptions(await screen.findByDisplayValue("Native (Claude / Codex)"), [
|
||||
"9router",
|
||||
]);
|
||||
await waitFor(() => expect(api.lanes.routerApply).toHaveBeenCalled());
|
||||
expect(screen.queryByRole("button", { name: "Restart" })).toBeNull();
|
||||
});
|
||||
|
||||
it("does not carry a pick made on one lane back to the lane selected before it", async () => {
|
||||
// One LaneRouterRow instance serves every lane (it lives in the Workspace's
|
||||
// lane-detail header), so its selected value is component state that
|
||||
@@ -523,6 +576,8 @@ describe("LaneRouterRow — the Model picker", () => {
|
||||
tool: "claude",
|
||||
file: "/work/b/.claude/settings.local.json",
|
||||
profile: "9router",
|
||||
restart_required: false,
|
||||
run_id: null,
|
||||
});
|
||||
const laneA = makeLane({ id: 1, router_profile: null });
|
||||
const laneB = makeLane({ id: 2, router_profile: null });
|
||||
|
||||
@@ -0,0 +1,87 @@
|
||||
/**
|
||||
* @file Unit tests for useNotifications — which websocket events raise a
|
||||
* browser notification, and which preference gates each one. Covers issue #29:
|
||||
* "Claude needs confirmation" and "Claude finished a turn" must fire out of the
|
||||
* box once notifications are enabled, since those are the two moments the user
|
||||
* is the blocker.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi, afterEach } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { useNotifications } from "../useNotifications";
|
||||
import { eventBus } from "../../lib/eventBus";
|
||||
import { NOTIF_KEY, defaultNotifPrefs, type NotifPrefs } from "../../lib/notifPrefs";
|
||||
import type { DashboardEvent } from "../../lib/types";
|
||||
|
||||
function enable(patch: Partial<NotifPrefs> = {}) {
|
||||
localStorage.setItem(
|
||||
NOTIF_KEY,
|
||||
JSON.stringify({ ...defaultNotifPrefs, enabled: true, ...patch })
|
||||
);
|
||||
}
|
||||
|
||||
function emit(event_type: string, summary: string) {
|
||||
eventBus.publish({
|
||||
type: "new_event",
|
||||
timestamp: new Date().toISOString(),
|
||||
data: { event_type, summary } as DashboardEvent,
|
||||
});
|
||||
}
|
||||
|
||||
let fetchMock: ReturnType<typeof vi.fn>;
|
||||
|
||||
describe("useNotifications", () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
// notify() posts to /api/push/send first and only falls back to a local
|
||||
// Notification when that throws, so the fetch spy is what we assert on.
|
||||
fetchMock = vi.fn().mockResolvedValue({ ok: true });
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
vi.stubGlobal(
|
||||
"Notification",
|
||||
Object.assign(vi.fn(), { permission: "granted" }) as unknown as typeof Notification
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function sentTitles() {
|
||||
return fetchMock.mock.calls
|
||||
.filter((c) => c[0] === "/api/push/send")
|
||||
.map((c) => JSON.parse((c[1] as RequestInit).body as string).title as string);
|
||||
}
|
||||
|
||||
it("notifies on a confirmation request and on the end of a turn by default", async () => {
|
||||
enable();
|
||||
renderHook(() => useNotifications());
|
||||
|
||||
emit("Notification", "Claude needs your permission to use Bash");
|
||||
emit("Stop", "Done");
|
||||
await vi.waitFor(() => expect(sentTitles()).toHaveLength(2));
|
||||
|
||||
expect(sentTitles()[0]).toBe("Claude needs your confirmation");
|
||||
expect(sentTitles()[1]).toBe("Claude Finished Responding");
|
||||
});
|
||||
|
||||
it("respects each gate independently", async () => {
|
||||
enable({ onAwaitingInput: false });
|
||||
renderHook(() => useNotifications());
|
||||
|
||||
emit("Notification", "needs permission");
|
||||
emit("Stop", "Done");
|
||||
await vi.waitFor(() => expect(sentTitles()).toHaveLength(1));
|
||||
expect(sentTitles()[0]).toBe("Claude Finished Responding");
|
||||
});
|
||||
|
||||
it("stays silent while notifications are disabled", async () => {
|
||||
renderHook(() => useNotifications());
|
||||
|
||||
emit("Notification", "needs permission");
|
||||
emit("Stop", "Done");
|
||||
await new Promise((r) => setTimeout(r, 20));
|
||||
expect(sentTitles()).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
@@ -52,56 +52,9 @@ import { useEffect } from "react";
|
||||
import i18n from "../i18n";
|
||||
import { eventBus } from "../lib/eventBus";
|
||||
import { subscribeToPush } from "../lib/push";
|
||||
import { loadNotifPrefs } from "../lib/notifPrefs";
|
||||
import type { WSMessage, Session, Agent, DashboardEvent } from "../lib/types";
|
||||
|
||||
const NOTIF_KEY = "agent-monitor-notifications";
|
||||
|
||||
/** User's browser-notification preferences, persisted to `localStorage` under
|
||||
* {@link NOTIF_KEY} (written by the Settings page's notifications panel). */
|
||||
interface NotifPrefs {
|
||||
/** Master switch; when false, no notification types fire regardless of the
|
||||
* per-event flags below. */
|
||||
enabled: boolean;
|
||||
onNewSession: boolean;
|
||||
onSessionError: boolean;
|
||||
onSessionComplete: boolean;
|
||||
onSubagentSpawn: boolean;
|
||||
}
|
||||
|
||||
/** Reads {@link NotifPrefs} from `localStorage`, merging over safe defaults so
|
||||
* a partial/older saved object (or none at all) still yields a valid result.
|
||||
* `enabled` defaults to false (opt-in) even in the "no saved value" branch,
|
||||
* while individual event toggles default to a sensible starting mix. */
|
||||
function loadPrefs(): NotifPrefs {
|
||||
try {
|
||||
const raw = localStorage.getItem(NOTIF_KEY);
|
||||
if (!raw)
|
||||
return {
|
||||
enabled: false,
|
||||
onNewSession: true,
|
||||
onSessionError: true,
|
||||
onSessionComplete: false,
|
||||
onSubagentSpawn: false,
|
||||
};
|
||||
return {
|
||||
enabled: false,
|
||||
onNewSession: true,
|
||||
onSessionError: true,
|
||||
onSessionComplete: false,
|
||||
onSubagentSpawn: false,
|
||||
...JSON.parse(raw),
|
||||
};
|
||||
} catch {
|
||||
return {
|
||||
enabled: false,
|
||||
onNewSession: true,
|
||||
onSessionError: true,
|
||||
onSessionComplete: false,
|
||||
onSubagentSpawn: false,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Shows a browser notification, preferring a server-relayed push (so it can
|
||||
* arrive even if this tab isn't the active one, or the browser is backgrounded)
|
||||
@@ -123,9 +76,9 @@ async function notify(title: string, body: string) {
|
||||
try {
|
||||
if ("serviceWorker" in navigator) {
|
||||
const registration = await navigator.serviceWorker.ready;
|
||||
await registration.showNotification(title, { body, icon: "/favicon.svg", silent: false });
|
||||
await registration.showNotification(title, { body, icon: "/favicon.ico", silent: false });
|
||||
} else {
|
||||
new Notification(title, { body, icon: "/favicon.svg" });
|
||||
new Notification(title, { body, icon: "/favicon.ico" });
|
||||
}
|
||||
} catch {
|
||||
// Silently ignore
|
||||
@@ -144,13 +97,13 @@ async function notify(title: string, body: string) {
|
||||
*/
|
||||
export function useNotifications() {
|
||||
useEffect(() => {
|
||||
const prefs = loadPrefs();
|
||||
const prefs = loadNotifPrefs();
|
||||
if (prefs.enabled && "Notification" in window && Notification.permission === "granted") {
|
||||
subscribeToPush().catch(() => {});
|
||||
}
|
||||
|
||||
return eventBus.subscribe((msg: WSMessage) => {
|
||||
const prefs = loadPrefs();
|
||||
const prefs = loadNotifPrefs();
|
||||
if (!prefs.enabled) return;
|
||||
|
||||
switch (msg.type) {
|
||||
@@ -183,7 +136,17 @@ export function useNotifications() {
|
||||
}
|
||||
case "new_event": {
|
||||
const ev = msg.data as DashboardEvent;
|
||||
if (ev.event_type === "Stop" && prefs.onSessionComplete) {
|
||||
// The two states where Claude is BLOCKED on the user get their own
|
||||
// flags (issue #29): `Notification` is a permission prompt or a
|
||||
// question, `Stop` is the end of a turn. Both used to hang off
|
||||
// `onSessionComplete` / nothing at all, so the one thing the user
|
||||
// actually waits for was either off by default or unswitchable.
|
||||
if (ev.event_type === "Notification" && prefs.onAwaitingInput) {
|
||||
notify(
|
||||
i18n.t("errors:notifications.awaitingInput"),
|
||||
ev.summary || i18n.t("errors:notifications.defaultBody")
|
||||
);
|
||||
} else if (ev.event_type === "Stop" && prefs.onTurnComplete) {
|
||||
notify(
|
||||
i18n.t("errors:notifications.finishedResponding"),
|
||||
ev.summary || i18n.t("errors:notifications.readyForInput")
|
||||
@@ -193,11 +156,6 @@ export function useNotifications() {
|
||||
i18n.t("errors:notifications.sessionCompleted"),
|
||||
ev.summary || i18n.t("errors:notifications.sessionClosed")
|
||||
);
|
||||
} else if (ev.event_type === "Notification") {
|
||||
notify(
|
||||
i18n.t("errors:notifications.defaultTitle"),
|
||||
ev.summary || i18n.t("errors:notifications.defaultBody")
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -7,16 +7,17 @@
|
||||
"goBack": "Go Back"
|
||||
},
|
||||
"notifications": {
|
||||
"awaitingInput": "Claude needs your confirmation",
|
||||
"defaultBody": "Notification",
|
||||
"defaultTitle": "Claude Code",
|
||||
"finishedResponding": "Claude Finished Responding",
|
||||
"newSession": "New Session",
|
||||
"readyForInput": "Ready for input",
|
||||
"sessionClosed": "Session closed",
|
||||
"sessionCompleted": "Session Completed",
|
||||
"sessionDefault": "Session ",
|
||||
"sessionError": "Session Error",
|
||||
"subagentSpawned": "Subagent Spawned",
|
||||
"finishedResponding": "Claude Finished Responding",
|
||||
"readyForInput": "Ready for input",
|
||||
"sessionCompleted": "Session Completed",
|
||||
"sessionClosed": "Session closed",
|
||||
"defaultTitle": "Claude Code",
|
||||
"defaultBody": "Notification"
|
||||
"subagentSpawned": "Subagent Spawned"
|
||||
},
|
||||
"toolLegend": {
|
||||
"read": "Read",
|
||||
|
||||
@@ -31,30 +31,31 @@
|
||||
"addLaneRepoHintAdopt": "An existing directory. The dashboard tracks it as-is — no worktree, no new branch.",
|
||||
"addLaneRepoLabel": "Source repository",
|
||||
"addLaneRepoLabelAdopt": "Directory",
|
||||
"addLaneSetupClose": "Close",
|
||||
"addLaneRouterCustom": "AI Router",
|
||||
"addLaneRouterEmptyHint": "No router profile saved yet — add one under Settings › AI Router.",
|
||||
"addLaneRouterHint": "Native talks to the vendor's own API. An AI Router profile is written into this lane's own config when it is created.",
|
||||
"addLaneRouterLabel": "Model endpoint",
|
||||
"addLaneRouterLoading": "Loading router profiles…",
|
||||
"addLaneRouterNative": "Native",
|
||||
"addLaneRouterProfileLabel": "Router profile",
|
||||
"addLaneSetupAgents": "Agents",
|
||||
"addLaneSetupClose": "Close",
|
||||
"addLaneSetupMcp": "MCP servers",
|
||||
"addLaneSetupProfile": "Profile",
|
||||
"addLaneSetupRouter": "Model endpoint",
|
||||
"addLaneSetupTitle": "Setup",
|
||||
"addLaneTitleLabel": "Title",
|
||||
"addLaneTitlePlaceholder": "Optional",
|
||||
"addLaneToolClaude": "Claude Code",
|
||||
"addLaneToolCodex": "Codex",
|
||||
"addLaneToolHint": "Which CLI drives this lane. Cannot be changed later.",
|
||||
"addLaneWriteDocsHint": "Opens the lane's terminal on creation and runs the /sg-all:sg-md-writing skill there.",
|
||||
"addLaneWriteDocsLabel": "Create/update readme file",
|
||||
"mode.repo": "Repo",
|
||||
"mode.repoHint": "Adopt this directory as a lane, as-is — no worktree, no new branch.",
|
||||
"mode.worktree": "Worktree",
|
||||
"mode.worktreeHint": "Provision a new git worktree + branch from a source repo.",
|
||||
"autoStage": "auto: {{stage}}",
|
||||
"browse.button": "Browse",
|
||||
"browse.empty": "This folder is empty.",
|
||||
"browse.select": "Select this folder",
|
||||
"browse.title": "Browse for a folder",
|
||||
"worktrees.heading_one": "{{count}} worktree",
|
||||
"worktrees.heading_other": "{{count}} worktrees",
|
||||
"addLaneTitlePlaceholder": "Optional",
|
||||
"autoStage": "auto: {{stage}}",
|
||||
"reorderHint": "Drag to reorder · Alt+←/→",
|
||||
"confirmRemoveCancel": "Cancel",
|
||||
"confirmRemoveConfirm": "Remove",
|
||||
"confirmRemoveMessage": "This action cannot be undone.",
|
||||
@@ -93,6 +94,9 @@
|
||||
"destructive.reset.title": "Reset managed worktree?",
|
||||
"destructive.warning.no-remote": "No Git remote is configured, so nothing here is backed up remotely. You can still proceed.",
|
||||
"emptyState": "No lanes yet. Create one from a working directory:",
|
||||
"features.archived": "archived",
|
||||
"features.live": "Live",
|
||||
"features.viewingArchived": "Viewing archived feature \"{{slug}}\" — the lane keeps running; this is a read-only snapshot.",
|
||||
"git.filesHeading_one": "{{count}} file changed",
|
||||
"git.filesHeading_other": "{{count}} files changed",
|
||||
"git.filesMore_one": "… and {{count}} more file",
|
||||
@@ -103,14 +107,25 @@
|
||||
"kind.managed": "managed",
|
||||
"laneDetail.hide": "Hide details",
|
||||
"laneDetail.show": "Lane details",
|
||||
"renameLabel": "Rename this lane",
|
||||
"locks.held_one": "{{count}} lock held",
|
||||
"locks.held_other": "{{count}} locks held",
|
||||
"mode.repo": "Repo",
|
||||
"mode.repoHint": "Adopt this directory as a lane, as-is — no worktree, no new branch.",
|
||||
"mode.worktree": "Worktree",
|
||||
"mode.worktreeHint": "Provision a new git worktree + branch from a source repo.",
|
||||
"moreActions": "More actions",
|
||||
"pipelinePicker.label": "Pipeline template",
|
||||
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" matches no node in \"{{pipeline}}\" — declare one of: {{nodes}}",
|
||||
"preflightError": "Could not load the current lane facts.",
|
||||
"preflightErrorWithMessage": "Could not load the current lane facts: {{message}}",
|
||||
"proof.ticketReport": "Task report",
|
||||
"renameLabel": "Rename this lane",
|
||||
"reorderHint": "Drag to reorder · Alt+←/→",
|
||||
"router.applied": "Applied.",
|
||||
"router.appliedRestart": "Applied, but the session running in this lane still uses the old models — restart it to take effect.",
|
||||
"router.label": "Model",
|
||||
"router.restart": "Restart",
|
||||
"router.restarted": "Session restarted with the new router.",
|
||||
"runtime.boot": "▶ up",
|
||||
"runtime.busy.down": "stopping…",
|
||||
"runtime.busy.up": "booting…",
|
||||
@@ -121,30 +136,18 @@
|
||||
"runtime.steppedAsideTitle": "Stepped aside from {{expected}}, which was already in use",
|
||||
"runtime.stop": "■ down",
|
||||
"runtime.toggleTitle": "Boot or stop this lane's own application stack (separate from its Claude run)",
|
||||
"splitView.emptyPane": "No lane selected for this pane.",
|
||||
"splitView.paneCount": "{{count}} pane",
|
||||
"splitView.paneLaneLabel": "Pane lane selector",
|
||||
"splitView.pickLane": "Pick a lane",
|
||||
"stageUndeclared": "not declared",
|
||||
"status.failed": "failed",
|
||||
"status.idle": "idle",
|
||||
"status.provisioning": "provisioning",
|
||||
"status.running": "running",
|
||||
"features.live": "Live",
|
||||
"features.archived": "archived",
|
||||
"features.viewingArchived": "Viewing archived feature \"{{slug}}\" — the lane keeps running; this is a read-only snapshot.",
|
||||
"proof.ticketReport": "Task report",
|
||||
"splitView.emptyPane": "No lane selected for this pane.",
|
||||
"splitView.paneLaneLabel": "Pane lane selector",
|
||||
"splitView.paneCount": "{{count}} pane",
|
||||
"splitView.pickLane": "Pick a lane",
|
||||
"statusDead": "DEAD",
|
||||
"title": "Lanes",
|
||||
"tooltipStart": "Spawn a conversation-mode run with no initial prompt; driven from CLI or via message",
|
||||
"addLaneRouterLabel": "Model endpoint",
|
||||
"addLaneRouterNative": "Native",
|
||||
"addLaneRouterCustom": "AI Router",
|
||||
"addLaneRouterProfileLabel": "Router profile",
|
||||
"addLaneRouterHint": "Native talks to the vendor's own API. An AI Router profile is written into this lane's own config when it is created.",
|
||||
"addLaneRouterEmptyHint": "No router profile saved yet — add one under Settings › AI Router.",
|
||||
"addLaneSetupRouter": "Model endpoint",
|
||||
"addLaneRouterLoading": "Loading router profiles…",
|
||||
"router.label": "Model",
|
||||
"router.applied": "Applied. A session already running in this lane keeps the old one until it restarts."
|
||||
"worktrees.heading_one": "{{count}} worktree",
|
||||
"worktrees.heading_other": "{{count}} worktrees"
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"agentBoard": "Kanban Board",
|
||||
"analytics": "Analytics",
|
||||
"avg": "avg",
|
||||
"brand": "Agent Dashboard",
|
||||
"brand": "AgentHub",
|
||||
"brandSub": "SmartGift Solution",
|
||||
"ccConfig": "Claude Config",
|
||||
"checkFailed": "Update check failed",
|
||||
|
||||
@@ -71,21 +71,23 @@
|
||||
"gcResult": "Reaped {{reaped}} process(es), capped {{capped}} log(s)"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Notifications",
|
||||
"awaitingInput": "Claude needs confirmation",
|
||||
"blocked": "Permission blocked",
|
||||
"description": "Browser notifications for important events. Requires permission.",
|
||||
"disabledInfo": "Notifications are disabled - enable to get alerts for session events",
|
||||
"enable": "Enable Browser Notifications",
|
||||
"granted": "Permission granted",
|
||||
"blocked": "Permission blocked",
|
||||
"required": "Permission required",
|
||||
"notifyWhen": "Notify me when...",
|
||||
"newSession": "New session starts",
|
||||
"notifyWhen": "Notify me when...",
|
||||
"required": "Permission required",
|
||||
"sendTest": "Send Test Notification",
|
||||
"sessionComplete": "Session completes",
|
||||
"sessionError": "Session errors",
|
||||
"subagentSpawned": "Subagent spawned",
|
||||
"testTitle": "Agent Monitor",
|
||||
"testBody": "Notifications are working!",
|
||||
"sendTest": "Send Test Notification",
|
||||
"disabledInfo": "Notifications are disabled - enable to get alerts for session events"
|
||||
"testTitle": "Agent Monitor",
|
||||
"title": "Notifications",
|
||||
"turnComplete": "Claude finishes a turn"
|
||||
},
|
||||
"import": {
|
||||
"title": "Import History",
|
||||
|
||||
@@ -7,16 +7,17 @@
|
||||
"goBack": "Quay lại"
|
||||
},
|
||||
"notifications": {
|
||||
"awaitingInput": "Claude cần bạn xác nhận",
|
||||
"defaultBody": "Thông báo",
|
||||
"defaultTitle": "Claude Code",
|
||||
"finishedResponding": "Claude đã phản hồi xong",
|
||||
"newSession": "Phiên mới",
|
||||
"readyForInput": "Sẵn sàng nhận đầu vào",
|
||||
"sessionClosed": "Phiên đã đóng",
|
||||
"sessionCompleted": "Phiên đã hoàn tất",
|
||||
"sessionDefault": "Phiên ",
|
||||
"sessionError": "Lỗi phiên",
|
||||
"subagentSpawned": "Đã tạo Subagent",
|
||||
"finishedResponding": "Claude đã phản hồi xong",
|
||||
"readyForInput": "Sẵn sàng nhận đầu vào",
|
||||
"sessionCompleted": "Phiên đã hoàn tất",
|
||||
"sessionClosed": "Phiên đã đóng",
|
||||
"defaultTitle": "Claude Code",
|
||||
"defaultBody": "Thông báo"
|
||||
"subagentSpawned": "Đã tạo Subagent"
|
||||
},
|
||||
"toolLegend": {
|
||||
"read": "Đọc",
|
||||
|
||||
@@ -31,30 +31,31 @@
|
||||
"addLaneRepoHintAdopt": "Một thư mục có sẵn. Dashboard theo dõi nguyên trạng — không tạo worktree, không tạo nhánh mới.",
|
||||
"addLaneRepoLabel": "Repo nguồn",
|
||||
"addLaneRepoLabelAdopt": "Thư mục",
|
||||
"addLaneSetupClose": "Đóng",
|
||||
"addLaneRouterCustom": "AI Router",
|
||||
"addLaneRouterEmptyHint": "Chưa lưu profile router nào — thêm ở Settings › AI Router.",
|
||||
"addLaneRouterHint": "Native là gọi thẳng API của hãng. Chọn profile AI Router thì cấu hình được ghi vào config riêng của lane này lúc tạo.",
|
||||
"addLaneRouterLabel": "Endpoint model",
|
||||
"addLaneRouterLoading": "Đang tải danh sách router…",
|
||||
"addLaneRouterNative": "Native",
|
||||
"addLaneRouterProfileLabel": "Profile router",
|
||||
"addLaneSetupAgents": "Agent",
|
||||
"addLaneSetupClose": "Đóng",
|
||||
"addLaneSetupMcp": "MCP server",
|
||||
"addLaneSetupProfile": "Profile",
|
||||
"addLaneSetupRouter": "Endpoint model",
|
||||
"addLaneSetupTitle": "Thiết lập",
|
||||
"addLaneTitleLabel": "Tiêu đề",
|
||||
"addLaneTitlePlaceholder": "Không bắt buộc",
|
||||
"addLaneToolClaude": "Claude Code",
|
||||
"addLaneToolCodex": "Codex",
|
||||
"addLaneToolHint": "CLI nào điều khiển lane này. Không thể thay đổi sau này.",
|
||||
"addLaneWriteDocsHint": "Khi tạo lane sẽ mở terminal của lane và chạy skill /sg-all:sg-md-writing tại đó.",
|
||||
"addLaneWriteDocsLabel": "Tạo/cập nhật file readme",
|
||||
"mode.repo": "Repo",
|
||||
"mode.repoHint": "Gắn thẳng thư mục này làm lane, nguyên trạng — không worktree, không nhánh mới.",
|
||||
"mode.worktree": "Worktree",
|
||||
"mode.worktreeHint": "Tạo worktree git mới + nhánh mới từ repo nguồn.",
|
||||
"autoStage": "tự động: {{stage}}",
|
||||
"browse.button": "Duyệt",
|
||||
"browse.empty": "Thư mục này trống.",
|
||||
"browse.select": "Chọn thư mục này",
|
||||
"browse.title": "Duyệt chọn thư mục",
|
||||
"worktrees.heading_one": "{{count}} worktree",
|
||||
"worktrees.heading_other": "{{count}} worktree",
|
||||
"addLaneTitlePlaceholder": "Không bắt buộc",
|
||||
"autoStage": "tự động: {{stage}}",
|
||||
"reorderHint": "Kéo để sắp xếp · Alt+←/→",
|
||||
"confirmRemoveCancel": "Hủy",
|
||||
"confirmRemoveConfirm": "Xóa",
|
||||
"confirmRemoveMessage": "Hành động này không thể hoàn tác.",
|
||||
@@ -93,6 +94,9 @@
|
||||
"destructive.reset.title": "Đặt lại worktree được quản lý?",
|
||||
"destructive.warning.no-remote": "Chưa cấu hình Git remote, nên không có bản sao lưu ở xa. Bạn vẫn có thể tiếp tục.",
|
||||
"emptyState": "Chưa có làn đường nào. Tạo một từ thư mục làm việc:",
|
||||
"features.archived": "đã lưu trữ",
|
||||
"features.live": "Phiên bản trực tiếp",
|
||||
"features.viewingArchived": "Xem tính năng đã lưu trữ \"{{slug}}\" — lane tiếp tục chạy; đây là ảnh chụp nhanh chỉ đọc.",
|
||||
"git.filesHeading_one": "{{count}} tệp thay đổi",
|
||||
"git.filesHeading_other": "{{count}} tệp thay đổi",
|
||||
"git.filesMore_one": "… và {{count}} tệp nữa",
|
||||
@@ -103,14 +107,25 @@
|
||||
"kind.managed": "được quản lý",
|
||||
"laneDetail.hide": "Ẩn chi tiết",
|
||||
"laneDetail.show": "Chi tiết lane",
|
||||
"renameLabel": "Đổi tên làn đường này",
|
||||
"locks.held_one": "Đang giữ {{count}} khóa",
|
||||
"locks.held_other": "Đang giữ {{count}} khóa",
|
||||
"mode.repo": "Repo",
|
||||
"mode.repoHint": "Gắn thẳng thư mục này làm lane, nguyên trạng — không worktree, không nhánh mới.",
|
||||
"mode.worktree": "Worktree",
|
||||
"mode.worktreeHint": "Tạo worktree git mới + nhánh mới từ repo nguồn.",
|
||||
"moreActions": "Thêm hành động",
|
||||
"pipelinePicker.label": "Mẫu pipeline",
|
||||
"pipelinePicker.stageMismatch": "Stage \"{{stage}}\" không khớp node nào trong \"{{pipeline}}\" — khai báo một trong: {{nodes}}",
|
||||
"preflightError": "Không thể tải trạng thái làn đường hiện tại.",
|
||||
"preflightErrorWithMessage": "Không thể tải trạng thái làn đường hiện tại: {{message}}",
|
||||
"proof.ticketReport": "Báo cáo nhiệm vụ",
|
||||
"renameLabel": "Đổi tên làn đường này",
|
||||
"reorderHint": "Kéo để sắp xếp · Alt+←/→",
|
||||
"router.applied": "Đã áp dụng.",
|
||||
"router.appliedRestart": "Đã áp dụng, nhưng phiên đang chạy trong lane này vẫn dùng model cũ — khởi động lại phiên để có hiệu lực.",
|
||||
"router.label": "Model",
|
||||
"router.restart": "Khởi động lại",
|
||||
"router.restarted": "Đã khởi động lại phiên với router mới.",
|
||||
"runtime.boot": "▶ chạy",
|
||||
"runtime.busy.down": "đang dừng…",
|
||||
"runtime.busy.up": "đang khởi động…",
|
||||
@@ -121,30 +136,18 @@
|
||||
"runtime.steppedAsideTitle": "Đã lùi khỏi {{expected}} vì cổng đó đang bận",
|
||||
"runtime.stop": "■ dừng",
|
||||
"runtime.toggleTitle": "Chạy hoặc dừng stack ứng dụng của lane này (khác với phiên Claude của nó)",
|
||||
"splitView.emptyPane": "Chưa chọn lane cho ô này.",
|
||||
"splitView.paneCount": "{{count}} ô",
|
||||
"splitView.paneLaneLabel": "Bộ chọn lane cho ô",
|
||||
"splitView.pickLane": "Chọn lane",
|
||||
"stageUndeclared": "chưa khai báo",
|
||||
"status.failed": "thất bại",
|
||||
"status.idle": "rảnh",
|
||||
"status.provisioning": "đang khởi tạo",
|
||||
"status.running": "đang chạy",
|
||||
"features.live": "Phiên bản trực tiếp",
|
||||
"features.archived": "đã lưu trữ",
|
||||
"features.viewingArchived": "Xem tính năng đã lưu trữ \"{{slug}}\" — lane tiếp tục chạy; đây là ảnh chụp nhanh chỉ đọc.",
|
||||
"proof.ticketReport": "Báo cáo nhiệm vụ",
|
||||
"splitView.emptyPane": "Chưa chọn lane cho ô này.",
|
||||
"splitView.paneLaneLabel": "Bộ chọn lane cho ô",
|
||||
"splitView.paneCount": "{{count}} ô",
|
||||
"splitView.pickLane": "Chọn lane",
|
||||
"statusDead": "ĐÃ CHẾT",
|
||||
"title": "Làn đường",
|
||||
"tooltipStart": "Tạo một lần chạy ở chế độ hội thoại mà không có lời nhắc ban đầu; được điều khiển từ CLI hoặc qua tin nhắn",
|
||||
"addLaneRouterLabel": "Endpoint model",
|
||||
"addLaneRouterNative": "Native",
|
||||
"addLaneRouterCustom": "AI Router",
|
||||
"addLaneRouterProfileLabel": "Profile router",
|
||||
"addLaneRouterHint": "Native là gọi thẳng API của hãng. Chọn profile AI Router thì cấu hình được ghi vào config riêng của lane này lúc tạo.",
|
||||
"addLaneRouterEmptyHint": "Chưa lưu profile router nào — thêm ở Settings › AI Router.",
|
||||
"addLaneSetupRouter": "Endpoint model",
|
||||
"addLaneRouterLoading": "Đang tải danh sách router…",
|
||||
"router.label": "Model",
|
||||
"router.applied": "Đã áp dụng. Session đang chạy trong lane này vẫn dùng router cũ cho tới khi khởi động lại."
|
||||
"worktrees.heading_one": "{{count}} worktree",
|
||||
"worktrees.heading_other": "{{count}} worktree"
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"agentBoard": "Bảng Kanban",
|
||||
"analytics": "Phân tích",
|
||||
"avg": "TB",
|
||||
"brand": "Bảng điều khiển Agent",
|
||||
"brand": "AgentHub",
|
||||
"brandSub": "SmartGift Solution",
|
||||
"ccConfig": "Cấu hình Claude",
|
||||
"checkFailed": "Kiểm tra cập nhật thất bại",
|
||||
|
||||
@@ -71,21 +71,23 @@
|
||||
"gcResult": "Đã dọn {{reaped}} tiến trình, cắt {{capped}} log"
|
||||
},
|
||||
"notifications": {
|
||||
"title": "Thông báo",
|
||||
"awaitingInput": "Claude cần xác nhận",
|
||||
"blocked": "Đã chặn quyền",
|
||||
"description": "Thông báo trình duyệt cho các sự kiện quan trọng. Cần cấp quyền.",
|
||||
"disabledInfo": "Thông báo đang tắt - bật để nhận cảnh báo sự kiện phiên",
|
||||
"enable": "Bật thông báo trình duyệt",
|
||||
"granted": "Đã cấp quyền",
|
||||
"blocked": "Đã chặn quyền",
|
||||
"required": "Cần cấp quyền",
|
||||
"notifyWhen": "Thông báo cho tôi khi...",
|
||||
"newSession": "Phiên mới bắt đầu",
|
||||
"notifyWhen": "Thông báo cho tôi khi...",
|
||||
"required": "Cần cấp quyền",
|
||||
"sendTest": "Gửi thông báo thử",
|
||||
"sessionComplete": "Phiên hoàn tất",
|
||||
"sessionError": "Phiên gặp lỗi",
|
||||
"subagentSpawned": "Subagent được tạo",
|
||||
"testTitle": "Agent Monitor",
|
||||
"testBody": "Thông báo đang hoạt động!",
|
||||
"sendTest": "Gửi thông báo thử",
|
||||
"disabledInfo": "Thông báo đang tắt - bật để nhận cảnh báo sự kiện phiên"
|
||||
"testTitle": "Agent Monitor",
|
||||
"title": "Thông báo",
|
||||
"turnComplete": "Claude trả lời xong"
|
||||
},
|
||||
"import": {
|
||||
"title": "Nhập lịch sử",
|
||||
|
||||
@@ -2498,7 +2498,14 @@ export const api = {
|
||||
* profile NAME comes from here.
|
||||
*/
|
||||
routerApply: (id: number, profile: string) =>
|
||||
request<{ tool: string; file: string | null; profile: string }>(`/lanes/${id}/router/apply`, {
|
||||
request<{
|
||||
tool: string;
|
||||
file: string | null;
|
||||
profile: string;
|
||||
/** A pane is live and keeps the old models until it restarts. */
|
||||
restart_required: boolean;
|
||||
run_id: string | null;
|
||||
}>(`/lanes/${id}/router/apply`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ profile }),
|
||||
}),
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
/**
|
||||
* @file Browser-notification preferences — the single definition of the shape,
|
||||
* the defaults, and the `localStorage` read/write. Settings (which edits them)
|
||||
* and `useNotifications` (which obeys them) both import from here; they used to
|
||||
* carry private copies, so adding a notification type meant editing the same
|
||||
* literal twice and a miss went unnoticed until a toggle did nothing.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
export const NOTIF_KEY = "agent-monitor-notifications";
|
||||
|
||||
export interface NotifPrefs {
|
||||
/** Master switch; when false no notification fires, whatever the flags say. */
|
||||
enabled: boolean;
|
||||
onNewSession: boolean;
|
||||
onSessionError: boolean;
|
||||
/** Claude is BLOCKED on the user — a permission prompt or a question. */
|
||||
onAwaitingInput: boolean;
|
||||
/** Claude finished its turn and is waiting for the next prompt (`Stop`). */
|
||||
onTurnComplete: boolean;
|
||||
/** The whole session closed (`SessionEnd`), not just one turn. */
|
||||
onSessionComplete: boolean;
|
||||
onSubagentSpawn: boolean;
|
||||
}
|
||||
|
||||
// The two "Claude is waiting for you" types default ON: they are the reason
|
||||
// notifications exist (issue #29 — the user walks away during a long run and
|
||||
// needs to know when they are the blocker). The rest stay as they were.
|
||||
export const defaultNotifPrefs: NotifPrefs = {
|
||||
enabled: false,
|
||||
onNewSession: true,
|
||||
onSessionError: true,
|
||||
onAwaitingInput: true,
|
||||
onTurnComplete: true,
|
||||
onSessionComplete: false,
|
||||
onSubagentSpawn: false,
|
||||
};
|
||||
|
||||
/** Reads prefs, merged over the defaults so a stored blob written by an older
|
||||
* build (missing the newer flags) still yields a complete object. */
|
||||
export function loadNotifPrefs(): NotifPrefs {
|
||||
try {
|
||||
const raw = localStorage.getItem(NOTIF_KEY);
|
||||
if (!raw) return { ...defaultNotifPrefs };
|
||||
return { ...defaultNotifPrefs, ...(JSON.parse(raw) as Partial<NotifPrefs>) };
|
||||
} catch {
|
||||
return { ...defaultNotifPrefs };
|
||||
}
|
||||
}
|
||||
|
||||
export function saveNotifPrefs(prefs: NotifPrefs) {
|
||||
try {
|
||||
localStorage.setItem(NOTIF_KEY, JSON.stringify(prefs));
|
||||
} catch {
|
||||
/* private mode / quota — the toggle still applies for this tab */
|
||||
}
|
||||
}
|
||||
@@ -71,6 +71,8 @@ import {
|
||||
AlertTriangle,
|
||||
RotateCcw,
|
||||
CheckCircle,
|
||||
HelpCircle,
|
||||
MessageSquare,
|
||||
XCircle,
|
||||
Server,
|
||||
Bell,
|
||||
@@ -109,6 +111,7 @@ import { isRemoteDataRefreshMessage } from "../lib/remoteDataEvents";
|
||||
import { sagiPrefs } from "../components/Sagi/prefs";
|
||||
import { fmt, fmtCost, getCurrentLocale } from "../lib/format";
|
||||
import { subscribeToPush, unsubscribeFromPush } from "../lib/push";
|
||||
import { loadNotifPrefs, saveNotifPrefs, type NotifPrefs } from "../lib/notifPrefs";
|
||||
import { Tip } from "../components/Tip";
|
||||
import { ImportHistory } from "../components/ImportHistory";
|
||||
import { RemoteSources } from "../components/RemoteSources";
|
||||
@@ -142,40 +145,6 @@ const SETTINGS_SECTIONS: {
|
||||
{ id: "about", labelKey: "about.title", Icon: Server },
|
||||
];
|
||||
|
||||
// ─── Notification preferences ───
|
||||
|
||||
const NOTIF_KEY = "agent-monitor-notifications";
|
||||
|
||||
interface NotifPrefs {
|
||||
enabled: boolean;
|
||||
onNewSession: boolean;
|
||||
onSessionError: boolean;
|
||||
onSessionComplete: boolean;
|
||||
onSubagentSpawn: boolean;
|
||||
}
|
||||
|
||||
const defaultNotif: NotifPrefs = {
|
||||
enabled: false,
|
||||
onNewSession: true,
|
||||
onSessionError: true,
|
||||
onSessionComplete: false,
|
||||
onSubagentSpawn: false,
|
||||
};
|
||||
|
||||
function loadNotifPrefs(): NotifPrefs {
|
||||
try {
|
||||
const raw = localStorage.getItem(NOTIF_KEY);
|
||||
if (!raw) return { ...defaultNotif };
|
||||
return { ...defaultNotif, ...JSON.parse(raw) };
|
||||
} catch {
|
||||
return { ...defaultNotif };
|
||||
}
|
||||
}
|
||||
|
||||
function saveNotifPrefs(prefs: NotifPrefs) {
|
||||
localStorage.setItem(NOTIF_KEY, JSON.stringify(prefs));
|
||||
}
|
||||
|
||||
// ─── Helpers ───
|
||||
|
||||
interface EditRow {
|
||||
@@ -1577,6 +1546,22 @@ export function Settings() {
|
||||
{t("notifications.notifyWhen")}
|
||||
</p>
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||
<div className="flex items-center gap-3 bg-surface-2 rounded-lg px-3.5 py-3">
|
||||
<HelpCircle className="w-4 h-4 text-status-warning flex-shrink-0" />
|
||||
<Toggle
|
||||
checked={notifPrefs.onAwaitingInput}
|
||||
onChange={(v) => updateNotifPrefs({ onAwaitingInput: v })}
|
||||
label={t("notifications.awaitingInput")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 bg-surface-2 rounded-lg px-3.5 py-3">
|
||||
<MessageSquare className="w-4 h-4 text-accent flex-shrink-0" />
|
||||
<Toggle
|
||||
checked={notifPrefs.onTurnComplete}
|
||||
onChange={(v) => updateNotifPrefs({ onTurnComplete: v })}
|
||||
label={t("notifications.turnComplete")}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-center gap-3 bg-surface-2 rounded-lg px-3.5 py-3">
|
||||
<Play className="w-4 h-4 text-status-success flex-shrink-0" />
|
||||
<Toggle
|
||||
|
||||
Reference in New Issue
Block a user