Skip to content

Commit 8378040

Browse files
authored
ui: improve flow ai agent
ui: improve flow ai agent
2 parents 09e2e07 + b41c34d commit 8378040

5 files changed

Lines changed: 77 additions & 23 deletions

File tree

ui/src/components/Sidebar.tsx

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -57,9 +57,24 @@ interface SideItem {
5757
}
5858

5959
export function SidebarContent({ onNavigate }: { onNavigate?: () => void }) {
60+
// Agent config drives whether the Agent zone is usable. Shares the
61+
// ["agent-config"] cache key with TopBar/NowPage — one fetch, zero extra
62+
// load. enable===false means the agent is deliberately off.
63+
const configQ = useQuery({
64+
queryKey: ["agent-config"],
65+
queryFn: api.getAgentConfig,
66+
staleTime: 60_000,
67+
retry: 1,
68+
});
69+
const agentOff = configQ.data?.enable === false;
70+
71+
// The agent status route (/api/agent/status) is only mounted when the agent
72+
// is enabled, so skip the poll when it's off — otherwise it 404s and the
73+
// runbooks hint flickers. Disabled query → data undefined → runbooks off.
6074
const statusQ = useQuery({
6175
queryKey: ["status"],
6276
queryFn: api.status,
77+
enabled: !agentOff,
6378
staleTime: 30_000,
6479
retry: 1,
6580
});
@@ -149,6 +164,22 @@ export function SidebarContent({ onNavigate }: { onNavigate?: () => void }) {
149164
{ to: "/settings", label: "Settings", icon: Settings },
150165
];
151166

167+
// When the agent is disabled (agent.enable=false) every Agent view and the
168+
// agent-backed Runbooks tool are non-functional. Dim + lock them with a
169+
// clear hint (visible-with-hint, audit I4) so they read as disabled instead
170+
// of navigating to empty/erroring pages.
171+
const AGENT_OFF_HINT =
172+
"AI agent is disabled — set agent.enable to use these views";
173+
const applyAgentOff = (items: SideItem[]): SideItem[] =>
174+
agentOff
175+
? items.map((it) => ({
176+
...it,
177+
dim: true,
178+
locked: true,
179+
dimTitle: AGENT_OFF_HINT,
180+
}))
181+
: items;
182+
152183
return (
153184
// force-dark: the rail keeps its dark identity in BOTH themes — the
154185
// CSS variables are re-pinned on this subtree (see index.css).
@@ -157,8 +188,8 @@ export function SidebarContent({ onNavigate }: { onNavigate?: () => void }) {
157188

158189
<nav aria-label="Primary" className="dark-scroll flex-1 overflow-y-auto px-2 py-2">
159190
<Zone title="Respond" items={respond} onNavigate={onNavigate} />
160-
<Zone title="Agent" items={agent} onNavigate={onNavigate} />
161-
<Zone title="Tools" items={tools} onNavigate={onNavigate} />
191+
<Zone title="Agent" items={applyAgentOff(agent)} onNavigate={onNavigate} />
192+
<Zone title="Tools" items={applyAgentOff(tools)} onNavigate={onNavigate} />
162193
<Zone title="Manage" items={manage} onNavigate={onNavigate} />
163194
</nav>
164195
</div>

ui/src/components/TopBar.tsx

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -38,18 +38,24 @@ export function TopBar({ title, subtitle, actions }: Props) {
3838
const { open } = useOpenIncidentCount();
3939
const { theme, toggle } = useTheme();
4040

41-
const liveness = useQuery({
42-
queryKey: ["status-pulse"],
43-
queryFn: api.status,
44-
refetchInterval: () => (document.hidden ? false : 30_000),
45-
retry: 1,
46-
});
4741
const config = useQuery({
4842
queryKey: ["agent-config"],
4943
queryFn: api.getAgentConfig,
5044
staleTime: 60_000,
5145
retry: 1,
5246
});
47+
// Only poll the agent liveness endpoint when the agent is actually enabled.
48+
// /api/agent/status does not exist when agent.enable=false, so polling it
49+
// would 404 forever and the chip would show "reconnecting…" for a deployment
50+
// that is simply running without the agent. Gate on the config: undefined
51+
// (still loading) keeps polling; an explicit false stops it.
52+
const liveness = useQuery({
53+
queryKey: ["status-pulse"],
54+
queryFn: api.status,
55+
enabled: config.data?.enable !== false,
56+
refetchInterval: () => (document.hidden ? false : 30_000),
57+
retry: 1,
58+
});
5359

5460
return (
5561
<header
@@ -153,7 +159,12 @@ function AgentChip({
153159
let dot = "bg-ink-400";
154160
let label = "agent";
155161

156-
if (livenessLoading || configLoading) {
162+
if (agentEnabled === false) {
163+
// Config says the agent is off — this is a deliberate state, not an
164+
// error. A 404 from the (unmounted) status endpoint must NOT surface as
165+
// "reconnecting…"/"unreachable", so this check comes first.
166+
label = "agent off";
167+
} else if (livenessLoading || configLoading) {
157168
label = "connecting…";
158169
dot = "motion-safe:animate-pulse bg-ink-300";
159170
} else if (livenessError) {
@@ -166,8 +177,6 @@ function AgentChip({
166177
cls = "border-sev-warn/40 bg-sev-warn/15 text-sev-warn";
167178
dot = "motion-safe:animate-pulse bg-sev-warn";
168179
}
169-
} else if (agentEnabled === false) {
170-
label = "agent off";
171180
} else if (mode) {
172181
label = mode;
173182
if (mode === "detect") {

ui/src/lib/api.ts

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -36,9 +36,15 @@ export function clearSecret() {
3636
// longer unlocked by a static token here — it rides the SSO session (the RBAC
3737
// admin user). Throws ApiError(401) when the secret is rejected; other errors
3838
// propagate unchanged.
39+
//
40+
// We verify against /api/admin/config/agent (getAgentConfig), NOT
41+
// /api/agent/status: the config endpoint is always mounted and gateway-secret
42+
// gated, whereas the agent status route only exists when agent.enable=true.
43+
// Verifying against status coupled sign-in to the agent being on, so an
44+
// alert-router deployment with a gateway secret but no agent could not log in.
3945
export async function signIn(value: string): Promise<void> {
4046
setSecret(value.trim());
41-
await api.status();
47+
await api.getAgentConfig();
4248
}
4349

4450
// AUTH_EXPIRED_EVENT fires when a request that carried a secret comes back

ui/src/lib/auth.tsx

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,8 +27,9 @@ interface Props {
2727
// probed via the deployment org's whoami. After the OIDC callback redirects
2828
// to "/", a held session opens the app directly instead of re-prompting; or
2929
// - on an OSS/community binary, the X-Gateway-Secret gateway secret, verified
30-
// against /api/agent/status. The gateway secret is OSS-only — a licensed
31-
// binary never offers it; its sign-in is the built-in admin / SSO.
30+
// against /api/admin/config/agent (always mounted + secret-gated, so it
31+
// works whether or not the agent is enabled). The gateway secret is OSS-only
32+
// — a licensed binary never offers it; its sign-in is the built-in admin / SSO.
3233
// A bad/absent credential AND no session fall through to the sign-in screen.
3334
// Transient network errors deliberately do NOT trap the user (kept
3435
// behavior). Mid-session OSS secret rotation is handled by <ReauthModal>.
@@ -41,7 +42,7 @@ export function AuthGate({ children }: Props) {
4142
let alive = true;
4243
resolveInitialAuth({
4344
hasSecret: () => Boolean(getSecret()),
44-
checkSecret: () => api.status(),
45+
checkSecret: () => api.getAgentConfig(),
4546
deploymentOrg: () => api.getSSODeployment().then((d) => d.org),
4647
probeSession: (org) => getSsoSession(org),
4748
}).then((state) => {

ui/src/pages/NowPage.tsx

Lines changed: 15 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -62,18 +62,21 @@ export function NowPage() {
6262
staleTime: 15_000,
6363
});
6464
// Same keys as TopBar's chip queries — one cache entry, zero extra load.
65-
const status = useQuery({
66-
queryKey: ["status-pulse"],
67-
queryFn: api.status,
68-
refetchInterval: () => (document.hidden ? false : 30_000),
69-
retry: 1,
70-
});
7165
const config = useQuery({
7266
queryKey: ["agent-config"],
7367
queryFn: api.getAgentConfig,
7468
staleTime: 60_000,
7569
retry: 1,
7670
});
71+
// Skip the agent liveness poll when the agent is disabled — the status
72+
// route is unmounted in that case, so polling it would 404 every 30s.
73+
const status = useQuery({
74+
queryKey: ["status-pulse"],
75+
queryFn: api.status,
76+
enabled: config.data?.enable !== false,
77+
refetchInterval: () => (document.hidden ? false : 30_000),
78+
retry: 1,
79+
});
7780

7881
const sorted = useMemo(() => {
7982
const list = [...(incidents.data ?? [])];
@@ -163,6 +166,10 @@ export function NowPage() {
163166
? ("warn" as const)
164167
: ("info" as const)
165168
: undefined;
169+
// When the agent is off the liveness query is disabled (stays "pending"),
170+
// so the status skeleton/error blocks must be suppressed — the agent-off
171+
// pill above already communicates the state.
172+
const agentOn = config.data?.enable !== false;
166173

167174
return (
168175
<>
@@ -538,14 +545,14 @@ function AgentPulse({
538545
</div>
539546
)}
540547

541-
{status.isPending && (
548+
{agentOn && status.isPending && (
542549
<div aria-hidden className="grid grid-cols-3 gap-2">
543550
<div className="sk h-12" />
544551
<div className="sk h-12" />
545552
<div className="sk h-12" />
546553
</div>
547554
)}
548-
{status.isError && (
555+
{agentOn && status.isError && (
549556
<RetryableError
550557
context="Couldn't load agent status"
551558
error={status.error}

0 commit comments

Comments
 (0)