diff --git a/desktop/src/apps/ObservatoryApp.test.tsx b/desktop/src/apps/ObservatoryApp.test.tsx
index 1a02a64e..2bd32250 100644
--- a/desktop/src/apps/ObservatoryApp.test.tsx
+++ b/desktop/src/apps/ObservatoryApp.test.tsx
@@ -232,6 +232,58 @@ describe("ObservatoryApp", () => {
});
});
+ it("surfaces an error when a steer post is rejected by the server", async () => {
+ const fetchMock = mockFetch({
+ "GET /api/observatory/fleet": { ok: true, body: fleetBody },
+ "GET /api/observatory/throttle": { ok: true, body: { global: null, lanes: {} } },
+ "POST /api/observatory/pause": { ok: false, status: 403, body: { detail: "forbidden" } },
+ });
+ vi.stubGlobal("fetch", fetchMock);
+ render();
+ await flush();
+
+ fireEvent.click(screen.getByRole("button", { name: /pause queue/i }));
+ await flush();
+
+ await waitFor(() =>
+ expect(screen.getByText(/could not update the pause state/i)).toBeTruthy(),
+ );
+ });
+
+ it("clears the steer error after a subsequent successful post", async () => {
+ let pauseOk = false;
+ const fetchMock = vi.fn().mockImplementation((input: string, init?: RequestInit) => {
+ const method = (init?.method ?? "GET").toUpperCase();
+ if (input === "/api/observatory/fleet") {
+ return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(fleetBody) });
+ }
+ if (input === "/api/observatory/throttle") {
+ return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve({ global: null, lanes: {} }) });
+ }
+ if (method === "POST" && input === "/api/observatory/pause") {
+ const ok = pauseOk;
+ pauseOk = true; // first attempt fails, the next succeeds
+ return Promise.resolve({ ok, status: ok ? 200 : 403, json: () => Promise.resolve({}) });
+ }
+ throw new Error(`Unmocked fetch: ${method} ${input}`);
+ });
+ vi.stubGlobal("fetch", fetchMock);
+ render();
+ await flush();
+
+ fireEvent.click(screen.getByRole("button", { name: /pause queue/i }));
+ await flush();
+ await waitFor(() =>
+ expect(screen.getByText(/could not update the pause state/i)).toBeTruthy(),
+ );
+
+ fireEvent.click(screen.getByRole("button", { name: /pause queue/i }));
+ await flush();
+ await waitFor(() =>
+ expect(screen.queryByText(/could not update the pause state/i)).toBeNull(),
+ );
+ });
+
it("shows the idle empty state when no agents are working", async () => {
vi.stubGlobal(
"fetch",
diff --git a/desktop/src/apps/ObservatoryApp.tsx b/desktop/src/apps/ObservatoryApp.tsx
index ca05eb62..fe566da1 100644
--- a/desktop/src/apps/ObservatoryApp.tsx
+++ b/desktop/src/apps/ObservatoryApp.tsx
@@ -1,5 +1,5 @@
-import { useState, useEffect, useCallback } from "react";
-import { Radar, Pause, Play, Loader2, CircleDot, Minus, Plus } from "lucide-react";
+import { useState, useEffect, useCallback, useRef } from "react";
+import { Radar, Pause, Play, Loader2, CircleDot, Minus, Plus, AlertCircle } from "lucide-react";
import { Switch } from "@/components/ui";
interface HeldCard {
@@ -94,6 +94,7 @@ export function ObservatoryApp({ windowId: _windowId }: { windowId: string }) {
const [laneCaps, setLaneCaps] = useState>({});
const [loading, setLoading] = useState(true);
const [busy, setBusy] = useState(null);
+ const [steerError, setSteerError] = useState(null);
const load = useCallback(async (opts?: { silent?: boolean }) => {
if (!opts?.silent) setLoading(true);
@@ -128,6 +129,32 @@ export function ObservatoryApp({ windowId: _windowId }: { windowId: string }) {
return () => clearInterval(id);
}, [load]);
+ // Shared write path for every steer control. Posts the change, surfaces a
+ // visible error if the server rejects it (so an optimistic value is not left
+ // standing silently), and always reconciles against the server. A sequence
+ // guard ensures only the latest write controls the banner: steer controls are
+ // not fully serialized (pause and a cap change use different busy scopes), so
+ // an earlier slow response must not overwrite a newer one's result.
+ const steerSeq = useRef(0);
+ const postSteer = useCallback(
+ async (url: string, body: object, failMsg: string) => {
+ const seq = ++steerSeq.current;
+ try {
+ const res = await fetch(url, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(body),
+ });
+ if (seq === steerSeq.current) setSteerError(res.ok ? null : failMsg);
+ } catch {
+ if (seq === steerSeq.current) setSteerError(failMsg);
+ } finally {
+ await load({ silent: true });
+ }
+ },
+ [load],
+ );
+
const setScope = useCallback(
async (scope: string, paused: boolean) => {
setBusy(scope);
@@ -140,40 +167,28 @@ export function ObservatoryApp({ windowId: _windowId }: { windowId: string }) {
lanes: { ...prev.lanes, [scope]: paused },
},
);
- try {
- await fetch("/api/observatory/pause", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ scope, paused }),
- });
- await load({ silent: true });
- } catch {
- await load({ silent: true });
- } finally {
- setBusy(null);
- }
+ await postSteer(
+ "/api/observatory/pause",
+ { scope, paused },
+ "Could not update the pause state.",
+ );
+ setBusy(null);
},
- [load],
+ [postSteer],
);
const setGlobalCap = useCallback(
async (next: number | null) => {
setBusy("cap");
setCap(next); // optimistic; reconciled on the next poll
- try {
- await fetch("/api/observatory/throttle", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ scope: "global", max_concurrent: next }),
- });
- await load({ silent: true });
- } catch {
- await load({ silent: true });
- } finally {
- setBusy(null);
- }
+ await postSteer(
+ "/api/observatory/throttle",
+ { scope: "global", max_concurrent: next },
+ "Could not update the concurrency cap.",
+ );
+ setBusy(null);
},
- [load],
+ [postSteer],
);
const setLaneCap = useCallback(
@@ -185,20 +200,14 @@ export function ObservatoryApp({ windowId: _windowId }: { windowId: string }) {
else copy[handle] = next;
return copy;
});
- try {
- await fetch("/api/observatory/throttle", {
- method: "POST",
- headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ scope: handle, max_concurrent: next }),
- });
- await load({ silent: true });
- } catch {
- await load({ silent: true });
- } finally {
- setBusy(null);
- }
+ await postSteer(
+ "/api/observatory/throttle",
+ { scope: handle, max_concurrent: next },
+ `Could not update the cap for ${handle}.`,
+ );
+ setBusy(null);
},
- [load],
+ [postSteer],
);
return (
@@ -234,6 +243,23 @@ export function ObservatoryApp({ windowId: _windowId }: { windowId: string }) {
)}
+ {steerError && (
+
+
+ {steerError}
+
+
+ )}
+
{/* Steer: global concurrency cap (volume knob alongside the pause switch) */}