Governed Generation — Server-Status UI Fixes Implementation Plan¶
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Fix the Governed Generation tool's server-status UI so users can trigger RunPod cold starts (Bug 1) and no longer see a false "Server unreachable" chip during the initial poll window (Bug 2).
Architecture: Two surgical edits. useServerStatus in generationApi.ts changes its return type from ServerStatus | null to { status, hasFetched } and preserves the last-known status on transient post-fetch errors. GovernedGenerationTool/index.tsx consumes the new hook shape, maps six chip states, enables the Generate button on 'ready' | 'loading' | 'serverless', and renders a cold-start caption under the button during loading / serverless.
Tech Stack: React 19, TypeScript, MUI 7, Zustand. Existing test infra: Vitest with React Testing Library (store + compiler tests only; no hook/component tests — we follow the existing pattern and verify via type-check + browser).
Spec: docs/superpowers/specs/2026-04-18-server-status-ui-fixes-design.md
Branch: feature/phon-42-server-status-ui-fixes (off develop)
Ticket: PHON-42 (Bug, High; parent PHON-4)
File inventory¶
Modified:
packages/web/frontend/src/lib/generationApi.ts—useServerStatusreturn shape and error handling.packages/web/frontend/src/components/tools/GovernedGenerationTool/index.tsx— hook consumption, chip mapping, button enable rule, cold-start caption.
Unchanged:
packages/web/frontend/src/types/governance.ts—ServerStatus['status']union already includes'serverless'.- Workers proxy (
packages/web/workers/src/routes/generation.ts) — already emits correct states. - Store, compiler, all other tools.
Task 1: Update useServerStatus hook¶
Files:
- Modify: packages/web/frontend/src/lib/generationApi.ts (lines 92–122)
- [ ] Step 1.1: Replace hook return type and implementation
Find this block in packages/web/frontend/src/lib/generationApi.ts (currently lines 92–122):
/**
* Hook that polls the generation server status every 5 seconds.
* Returns current status or null if unreachable.
*/
export function useServerStatus(pollIntervalMs = 5000): ServerStatus | null {
const [status, setStatus] = useState<ServerStatus | null>(null);
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
let cancelled = false;
async function poll() {
try {
const s = await fetchServerStatus();
if (!cancelled) setStatus(s);
} catch {
if (!cancelled) setStatus(null);
}
}
poll();
intervalRef.current = setInterval(poll, pollIntervalMs);
return () => {
cancelled = true;
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [pollIntervalMs]);
return status;
}
Replace with:
/**
* Hook that polls the generation server status every 5 seconds.
*
* Returns `{ status, hasFetched }`:
* - `hasFetched` is `false` until the first successful response, then `true` forever.
* - Transient poll errors after the first success preserve the last-known `status`
* rather than flipping back to `null`. This avoids flickering the UI between
* "known state" and "unreachable" on one-off network hiccups.
* - The UI uses `hasFetched` to distinguish "we're still checking" from "we had
* a response and then lost it."
*/
export interface ServerStatusState {
status: ServerStatus | null;
hasFetched: boolean;
}
export function useServerStatus(pollIntervalMs = 5000): ServerStatusState {
const [state, setState] = useState<ServerStatusState>({ status: null, hasFetched: false });
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
useEffect(() => {
let cancelled = false;
async function poll() {
try {
const s = await fetchServerStatus();
if (!cancelled) setState({ status: s, hasFetched: true });
} catch {
if (cancelled) return;
setState((prev) =>
prev.hasFetched
// Preserve last-known status on transient post-success errors
? prev
// Pre-success failures keep us in the "still checking" state rather
// than flashing "unreachable" on the initial render before the
// first poll completes.
: { status: null, hasFetched: false },
);
}
}
poll();
intervalRef.current = setInterval(poll, pollIntervalMs);
return () => {
cancelled = true;
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [pollIntervalMs]);
return state;
}
- [ ] Step 1.2: Type-check
Run: cd packages/web/frontend && npm run type-check
Expected: fails because GovernedGenerationTool/index.tsx still consumes the old return type. This is expected — we fix it in Task 2.
- [ ] Step 1.3: Do NOT commit yet
Don't commit until Task 2 restores the type-check. The two edits belong in a single logical change.
Task 2: Update GovernedGenerationTool/index.tsx¶
Files:
- Modify: packages/web/frontend/src/components/tools/GovernedGenerationTool/index.tsx (lines 41–46, 136–151, 175–195)
- [ ] Step 2.1: Replace hook consumption and derived state
Find this block (currently lines 41–46):
const snapshot = useConstraintStore((s) => s.snapshot);
const serverStatus = useServerStatus();
const serverReady = serverStatus?.status === 'ready';
Replace with:
const snapshot = useConstraintStore((s) => s.snapshot);
const { status: serverStatus, hasFetched: serverStatusHasFetched } = useServerStatus();
const canGenerate =
serverStatus?.status === 'ready' ||
serverStatus?.status === 'loading' ||
serverStatus?.status === 'serverless';
const showColdStartHint =
serverStatus?.status === 'loading' || serverStatus?.status === 'serverless';
- [ ] Step 2.2: Replace server-status chip rendering
Find the current two-branch chip block (currently lines 136–151):
{/* Server status */}
{serverStatus && (
<Box sx={{ mb: 2 }}>
<Chip
size="small"
label={serverStatus.status === 'ready' ? 'Server ready' : serverStatus.status === 'loading' ? 'Model loading...' : 'Server error'}
color={serverReady ? 'success' : serverStatus.status === 'loading' ? 'warning' : 'error'}
variant="outlined"
/>
</Box>
)}
{!serverStatus && (
<Box sx={{ mb: 2 }}>
<Chip size="small" label="Server unreachable" color="error" variant="outlined" />
</Box>
)}
Replace with:
{/* Server status — six derived states */}
<Box sx={{ mb: 2 }}>
{!serverStatusHasFetched && (
<Chip size="small" label="Checking server…" variant="outlined" />
)}
{serverStatusHasFetched && serverStatus?.status === 'ready' && (
<Chip size="small" label="Server ready" color="success" variant="outlined" />
)}
{serverStatusHasFetched && serverStatus?.status === 'loading' && (
<Chip size="small" label="Worker starting…" color="warning" variant="outlined" />
)}
{serverStatusHasFetched && serverStatus?.status === 'serverless' && (
<Chip size="small" label="Server idle" color="warning" variant="outlined" />
)}
{serverStatusHasFetched && serverStatus?.status === 'error' && (
<Chip size="small" label="Server error" color="error" variant="outlined" />
)}
{serverStatusHasFetched && serverStatus == null && (
<Chip size="small" label="Server unreachable" color="error" variant="outlined" />
)}
</Box>
Note: serverStatus == null with hasFetched === true covers the post-success fetch failure, which shouldn't normally occur because the hook preserves prior status — but we render a sensible chip if it ever does (belt-and-braces for future hook refactors).
- [ ] Step 2.3: Update Generate button disabled rule
Find this line (currently line 180):
disabled={loading || !prompt.trim() || !serverReady}
Replace with:
disabled={loading || !prompt.trim() || !canGenerate}
- [ ] Step 2.4: Add cold-start helper caption under the Generate button
Find this block (currently around lines 187–194, immediately after the Stack closing tag for the Generate button):
{error && (
<Typography variant="body2" color="error" sx={{ mt: 1 }}>{error}</Typography>
)}
{statusMessage && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 1, fontStyle: 'italic' }}>
{statusMessage}
</Typography>
)}
Replace with (adds the cold-start caption above the existing error/statusMessage lines):
{showColdStartHint && !loading && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 1, fontStyle: 'italic' }}>
First request after idle may take ~60s while a GPU worker spins up.
</Typography>
)}
{error && (
<Typography variant="body2" color="error" sx={{ mt: 1 }}>{error}</Typography>
)}
{statusMessage && (
<Typography variant="body2" color="text.secondary" sx={{ mt: 1, fontStyle: 'italic' }}>
{statusMessage}
</Typography>
)}
The !loading guard keeps the caption from fighting with the Workers-proxy-emitted statusMessage ("Connecting to GPU…") during an active request.
- [ ] Step 2.5: Type-check
Run: cd packages/web/frontend && npm run type-check
Expected: passes — the hook shape and the consumer are now consistent.
- [ ] Step 2.6: Lint
Run: cd packages/web/frontend && npm run lint
Expected: passes (max-warnings 50). Fix any issues inline.
- [ ] Step 2.7: Run existing tests
Run: cd packages/web/frontend && npm test -- --run
Expected: all 19 tests pass (store + compiler tests unchanged; they don't touch the hook or the component).
- [ ] Step 2.8: Production build
Run: cd packages/web/frontend && npm run build
Expected: clean build.
- [ ] Step 2.9: Commit both edits together
git add packages/web/frontend/src/lib/generationApi.ts packages/web/frontend/src/components/tools/GovernedGenerationTool/index.tsx
git commit -m "$(cat <<'EOF'
fix(web): enable cold-start Generate button and fix initial server-status chip
PHON-42. Two related UI bugs in the Governed Generation tool:
Bug 1: the Generate button was disabled on every server status except
'ready', so users couldn't fire the first /api/generate-single request
that actually wakes a cold RunPod worker. Enable on 'ready', 'loading',
and 'serverless'; add a caption under the button during loading /
serverless warning users the first request may take ~60s.
Bug 2: useServerStatus initialized status=null and the UI rendered
"Server unreachable" whenever !serverStatus — true both before the
first poll completes and after a fetch failure. Return {status,
hasFetched} and preserve last-known status on post-success transient
errors; UI now renders "Checking server…" during the pre-fetch
window and only "Server unreachable" if we had a response once and
then lost it.
No backend changes. Workers proxy already emits the correct status
strings and the cold-start SSE warning.
EOF
)"
Task 3: Verification¶
Files: none (verification-only)
- [ ] Step 3.1: Manual browser check on local dev server
Dev server should still be running on http://localhost:3000. If not, start it:
cd /Users/jneumann/Repos/PhonoLex/packages/web/frontend
npm run dev
Open the Governed Generation tool and verify:
- On first render, the status chip reads "Checking server…" (grey/default outline), Generate button is disabled.
-
If FastAPI isn't running locally (port 8000 refused), the hook's first poll fails → chip stays at "Checking server…" until it does get a response. That's the correct behavior for local dev without the generation server — we're no longer flashing "Server unreachable" unnecessarily.
-
[ ] Step 3.2: Manual browser check against staging
Optionally point dev frontend at staging to exercise the full lifecycle:
cd /Users/jneumann/Repos/PhonoLex/packages/web/frontend
VITE_GENERATION_API_URL=https://staging-api.phonolex.com npm run dev
Walk through the states:
- Initial load: "Checking server…"
- After ~5s the first poll lands. If RunPod is cold → chip = "Server idle" (yellow), Generate button enabled, caption visible under button reading "First request after idle may take ~60s...".
- If RunPod is warming up → chip = "Worker starting…" (yellow), same button/caption behavior.
- Once ready → chip = "Server ready" (green), caption hidden.
-
Click Generate — SSE
statusMessage("Connecting to GPU…") takes over. Cold-start caption hides (because!loadingguard). -
[ ] Step 3.3: Confirm commit chain
Run: git -C /Users/jneumann/Repos/PhonoLex log --oneline origin/develop..HEAD
Expected: three commits on top of origin/develop:
docs: design spec for PHON-42 server-status UI fixes(committed at end of brainstorming)docs: implementation plan for PHON-42 server-status UI fixes(committed at end of plan writing)-
fix(web): enable cold-start Generate button and fix initial server-status chip -
[ ] Step 3.4: Ready for review
Stop here. Do not push or open a PR without explicit user approval — memory feedback_no_premature_merge applies.
Post-merge follow-ups¶
- Transition PHON-42 → In Review → Done via Jira MCP when the PR merges.
- Update
reference_jira_backlog.mdmemory to mark PHON-42 Done. - If cold-start latency remains a user-experience issue after this lands, consider a RunPod min-workers setting or a pre-warm request on tool mount. Track separately under PHON-7 (Operations maturity).