Skip to content

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.tsuseServerStatus return 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.tsServerStatus['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:

  1. On first render, the status chip reads "Checking server…" (grey/default outline), Generate button is disabled.
  2. 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.

  3. [ ] 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:

  1. Initial load: "Checking server…"
  2. 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...".
  3. If RunPod is warming up → chip = "Worker starting…" (yellow), same button/caption behavior.
  4. Once ready → chip = "Server ready" (green), caption hidden.
  5. Click Generate — SSE statusMessage ("Connecting to GPU…") takes over. Cold-start caption hides (because !loading guard).

  6. [ ] 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:

  1. docs: design spec for PHON-42 server-status UI fixes (committed at end of brainstorming)
  2. docs: implementation plan for PHON-42 server-status UI fixes (committed at end of plan writing)
  3. fix(web): enable cold-start Generate button and fix initial server-status chip

  4. [ ] 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.md memory 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).