web: the health answer is a context of its own

The shell's context held the health with everything else, so its poll every 30 seconds gave the whole value a new
identity and re-rendered every user of the shell. useHealth reads it from its own context; only the rail's status,
the offline banner, the setup checklist, the key form and the host settings follow the poll.
This commit is contained in:
feder-cr committed 2026-10-11 19:29:55 +02:00
1 parent bd1b5866cc
commit 3d0da4e090
8 files changed
+32 -21

No files matched your search

@@ -6,7 +6,7 @@ import type { ThemePreference } from "../../lib/theme";
import { useThemePreference } from "../../lib/use-theme";
import { cn } from "../../lib/utils";
import { ErrorAlert } from "../ErrorAlert";
import { useShell } from "../shell/attention";
import { useHealth } from "../shell/attention";
import { CheckAgainButton, CheckList, CheckListSkeleton } from "../setup/CheckList";
import { KeyForm } from "../setup/KeyForm";
import { Section } from "../setup/Section";
@@ -84,7 +84,7 @@ function Appearance() {
}
function About() {
const { health } = useShell();
const health = useHealth();
return (
<Section id="about" title="About" busy={health.loading && health.data === undefined}>
{health.error !== null && health.data === undefined ? <ErrorAlert error={health.error} title="The control plane does not answer" /> : null}
+2 -2
View File
@@ -7,7 +7,7 @@ import { api } from "../../lib/api";
import { keySavedMessage } from "../../lib/host-settings";
import { ErrorAlert } from "../ErrorAlert";
import { Field } from "../new-dot/Field";
import { useShell } from "../shell/attention";
import { useHealth } from "../shell/attention";
import { Button } from "../ui/button";
import { Input } from "../ui/input";
import { useAction } from "../ui";
@@ -18,7 +18,7 @@ import { useAction } from "../ui";
* answer, and the answer to saving one says how many running Dots were given it at once.
*/
export function KeyForm() {
const { health } = useShell();
const health = useHealth();
const [value, setValue] = useState("");
const [problem, setProblem] = useState<string | null>(null);
const [saved, setSaved] = useState<string | null>(null);
@@ -2,7 +2,7 @@
import { useState } from "react";
import { isReady, notReadyCount } from "../../lib/preflight";
import { useShell } from "../shell/attention";
import { useHealth } from "../shell/attention";
import { CheckAgainButton, CheckList } from "./CheckList";
import { KeyForm } from "./KeyForm";
import { Section } from "./Section";
@@ -16,7 +16,7 @@ import { useHostChecks } from "./use-host-checks";
*/
export function SetupChecklist() {
const checks = useHostChecks();
const { health } = useShell();
const health = useHealth();
const [needed, setNeeded] = useState<{ key: boolean } | null>(null);
const items = checks.items;
const ready = items !== null && isReady(items);
@@ -4,7 +4,7 @@ import { useCallback, useEffect, useRef } from "react";
import { api } from "../../lib/api";
import { isReady, preflightItems, type HealthResult, type HostResult, type PreflightItem } from "../../lib/preflight";
import { useResource } from "../ui";
import { useShell } from "../shell/attention";
import { useHealth } from "../shell/attention";
export interface HostChecks {
/** Null until both answers are in (or have failed). */
@@ -27,7 +27,7 @@ function reason(error: unknown): string {
* still not ready, the checks run again by themselves.
*/
export function useHostChecks(): HostChecks {
const { health } = useShell();
const health = useHealth();
const doctor = useResource(() => api.doctor(), "host:doctor");
const healthResult: HealthResult | null = health.error !== null ? { error: reason(health.error) } : health.data ? { health: health.data } : null;
+4 -4
View File
@@ -3,19 +3,19 @@
import { ServerIcon } from "lucide-react";
import Link from "next/link";
import { cn } from "../../lib/utils";
import { useShell } from "./attention";
import { useHealth } from "./attention";
import { StatusIcon } from "./StatusIcon";
type ApiState = "checking" | "ok" | "down";
function useApiState(): ApiState {
const { health } = useShell();
const health = useHealth();
return health.error !== null ? "down" : health.data ? "ok" : "checking";
}
/** Whether the control plane answers through the proxy, with its version (the shell asks every 30 seconds): an icon, said in full on hover. */
export function ApiStatus() {
const { health } = useShell();
const health = useHealth();
const state = useApiState();
const label = state === "checking" ? "API: checking" : state === "ok" ? "API: ok" : "API: unreachable";
const version = state === "ok" && health.data ? `v${health.data.version}` : null;
@@ -30,7 +30,7 @@ export function ApiStatus() {
/** While the control plane has no OpenRouter key: a link to where it is entered, on a line of its own under the statuses. */
export function MissingKeyNotice({ onNavigate }: { onNavigate?: () => void }) {
const { health } = useShell();
const health = useHealth();
const state = useApiState();
if (state !== "ok" || !health.data || health.data.openrouter_configured) return null;
return (
+16 -5
View File
@@ -26,8 +26,6 @@ const HEALTH_INTERVAL_MS = 30_000;
interface ShellData {
dots: Resource<Dot[]>;
approvals: Resource<Approval[]>;
/** The control plane's answer, asked every 30 seconds and again on `health.reload()` (after a key is saved). */
health: Resource<HealthResponse>;
attention: ReadonlyMap<string, DotAttention>;
/** What needs the person, across every Dot: waiting approvals, Dots in ERROR, failed tasks and channels to link again. The number on the rail's Inbox entry. */
needsYou: number;
@@ -42,6 +40,7 @@ interface ShellData {
}
const Context = createContext<ShellData | null>(null);
const HealthContext = createContext<Resource<HealthResponse> | null>(null);
/**
* What every page of the app shares: the Dots, the approvals that wait, the tasks that failed lately, the channels that
@@ -118,10 +117,22 @@ export function AttentionProvider({ children }: { children: ReactNode }) {
const dismiss = useCallback((dotId: string) => setLive((current) => dismissRestart(current, dotId)), []);
const value = useMemo<ShellData>(
() => ({ dots, approvals, health, attention, needsYou, failedTasks, relinks, dismissFailedTask, live, dismissRestart: dismiss }),
[dots, approvals, health, attention, needsYou, failedTasks, relinks, dismissFailedTask, live, dismiss],
() => ({ dots, approvals, attention, needsYou, failedTasks, relinks, dismissFailedTask, live, dismissRestart: dismiss }),
[dots, approvals, attention, needsYou, failedTasks, relinks, dismissFailedTask, live, dismiss],
);
return <Context.Provider value={value}>{children}</Context.Provider>;
// The health is a context of its own: its poll every 30 seconds re-renders what reads it, not every user of the shell.
return (
<HealthContext.Provider value={health}>
<Context.Provider value={value}>{children}</Context.Provider>
</HealthContext.Provider>
);
}
/** The control plane's answer, asked every 30 seconds and again on `reload()` (after a key is saved). */
export function useHealth(): Resource<HealthResponse> {
const value = useContext(HealthContext);
if (!value) throw new Error("useHealth must be used inside AttentionProvider");
return value;
}
export function useShell(): ShellData {
@@ -4,7 +4,7 @@ import { WifiOffIcon } from "lucide-react";
import { offlineOf } from "../../lib/offline";
import { useStreamStatus } from "../events";
import { Alert, AlertDescription, AlertTitle } from "../ui/alert";
import { useShell } from "./attention";
import { useHealth } from "./attention";
/**
* Says so, at the top of every page, when the control plane does not answer: the live stream is down or the health
@@ -12,7 +12,7 @@ import { useShell } from "./attention";
*/
export function OfflineBanner() {
const stream = useStreamStatus();
const { health } = useShell();
const health = useHealth();
const offline = offlineOf({ stream: stream.status, streamDetail: stream.detail, apiError: health.error });
if (offline === null) return null;
return (
+2 -2
View File
@@ -4,7 +4,7 @@ import userEvent from "@testing-library/user-event";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { EventStreamProvider } from "../src/components/events";
import { HostSettings } from "../src/components/settings/HostSettings";
import { AttentionProvider, useShell } from "../src/components/shell/attention";
import { AttentionProvider, useHealth } from "../src/components/shell/attention";
import { THEME_KEY } from "../src/lib/theme";
import { stubMatchMedia } from "./support/browser";
import { FakeControlPlane } from "./support/control-plane";
@@ -29,7 +29,7 @@ afterEach(() => {
/** What the shell's rail reads from the same health answer, to see that the page and the rail agree. */
function RailKey() {
const { health } = useShell();
const health = useHealth();
return <p data-testid="rail-key">{health.data ? String(health.data.openrouter_configured) : "unknown"}</p>;
}