diff --git a/frontend/packages/agent/src/Root.tsx b/frontend/packages/agent/src/Root.tsx index 7126b129..d6a7249f 100644 --- a/frontend/packages/agent/src/Root.tsx +++ b/frontend/packages/agent/src/Root.tsx @@ -1,7 +1,9 @@ // — root of the Preact rewrite. Wires `Header` + `StatusChips` -// to the real `/api/state` snapshot via `useAgentState`. Still a slice, -// not the whole page — see main.tsx's file comment for what's left -// (the live SSE stream, login flow, inbox/todos, term input, overflow). +// to the real `/api/state` snapshot via `useAgentState`. Covers +// everything `app.js` did, including the `needs_login_idle`/ +// `needs_login_in_progress` recovery flow (`LoginFlow`) — an agent with +// no credentials has no other web-UI path back online, so this isn't +// optional polish. import { useRef, useState } from 'preact/hooks'; import type { BadgeTone } from '@hive/shared/badge.js'; import { Header } from './components/Header.js'; @@ -13,6 +15,7 @@ import { InboxPanel } from './components/InboxPanel.js'; import { TodosPanel } from './components/TodosPanel.js'; import { TermInput } from './components/TermInput.js'; import { OverflowMenu } from './components/OverflowMenu.js'; +import { LoginFlow } from './components/LoginFlow.js'; import { useAgentState } from './hooks/useAgentState.js'; import { useTodos } from './hooks/useTodos.js'; import { fmtAge, fmtTokens } from './lib/format.js'; @@ -163,6 +166,9 @@ export function Root() { />
+ {state.status === 'needs_login_idle' || state.status === 'needs_login_in_progress' ? ( + + ) : null}
{termInput} diff --git a/frontend/packages/agent/src/components/LoginFlow.tsx b/frontend/packages/agent/src/components/LoginFlow.tsx new file mode 100644 index 00000000..83fe45a7 --- /dev/null +++ b/frontend/packages/agent/src/components/LoginFlow.tsx @@ -0,0 +1,161 @@ +// — the `needs_login_idle` / `needs_login_in_progress` +// overlay. Ports app.js's `renderNeedsLoginIdle`/`renderLoginInProgress` +// as real functional UI, not just a status label: an agent whose +// credentials got rotated (or hit `/logout`) has NO other way back +// online, so this is load-bearing, not cosmetic. Reuses agent.css's +// existing `.agent-status-overlay`/`.status-needs-login`/`.btn-login`/ +// `.loginform`/`.loginform-reveal` rules verbatim (same class names as +// the old markup) — no component-local CSS needed, matching Header.tsx's +// same reuse pattern. +import { useState } from 'preact/hooks'; +import type { SessionView } from '../types.js'; +import { postLoginStart, postLoginCode, postLoginCancel } from '../lib/loginAction.js'; + +function LoginIdle({ onStarted }: { onStarted: () => void }) { + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + + function submit(e: Event) { + e.preventDefault(); + setBusy(true); + setError(null); + postLoginStart().then((r) => { + setBusy(false); + if (r.ok) onStarted(); + else setError(r.detail ?? 'failed'); + }); + } + + return ( + <> + +

+ No Claude session in ~/.claude/. The harness is up but the turn loop is + paused until you log in. +

+
+ +
+ {error ?

error: {error}

: null} +

+ Spawns claude auth login over plain stdio pipes. The OAuth URL will appear + here when claude emits it; paste the resulting code back into the form below. +

+ + ); +} + +function LoginInProgress({ session, onDone }: { session: SessionView | null; onDone: () => void }) { + const [code, setCode] = useState(''); + const [reveal, setReveal] = useState(false); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const finished = session?.finished ?? false; + + function submitCode(e: Event) { + e.preventDefault(); + if (!code) return; + setBusy(true); + setError(null); + // Success: the next `/api/state` poll flips `status` back to + // `online` (or leaves it `needs_login_in_progress` on a bad code, + // with the process's own stderr showing up in `output` below) — + // nothing local to update here either way. + postLoginCode(code).then((r) => { + setBusy(false); + if (!r.ok) setError(r.detail ?? 'failed'); + }); + } + + function cancel(e: Event) { + e.preventDefault(); + setBusy(true); + postLoginCancel().then(() => { + setBusy(false); + onDone(); + }); + } + + return ( + <> + + {session?.url ? ( + <> +

+ ▶{' '} + + {session.url} + +

+

+ open this URL in a browser, complete the OAuth flow, paste the resulting code below. +

+ + ) : ( +

waiting for claude to emit an OAuth URL on stdout… (output below)

+ )} + {!finished ? ( +
+ setCode((e.target as HTMLInputElement).value)} + /> + + +
+ ) : null} +
+ +
+ {finished ? ( + + ) : null} + {error ?

error: {error}

: null} +

output

+
{session?.output || ''}
+ + ); +} + +export function LoginFlow({ + status, + session, + onRefresh, +}: { + status: 'needs_login_idle' | 'needs_login_in_progress'; + session: SessionView | null; + onRefresh: () => void; +}) { + return ( +
+ {status === 'needs_login_idle' ? ( + + ) : ( + + )} +
+ ); +} diff --git a/frontend/packages/agent/src/lib/loginAction.ts b/frontend/packages/agent/src/lib/loginAction.ts new file mode 100644 index 00000000..afc4d0f9 --- /dev/null +++ b/frontend/packages/agent/src/lib/loginAction.ts @@ -0,0 +1,28 @@ +// POST `/login/start` / `/login/code` / `/login/cancel` — same-origin +// (this agent's own backend, `hive-agent/src/web_ui/auth.rs`), so a +// plain `fetch` is safe (unlike `pauseAction.ts`'s dashboard-origin +// actions). Same `{ ok, detail }` shape and `redirect: 'manual'` / +// broad-2xx-4xx-treated-as-ok contract as `modelEffort.ts` — none of +// these three routes actually redirect today, but the same tolerant +// check costs nothing and keeps every same-origin POST in this package +// behaving identically. +async function post(path: string, body?: URLSearchParams): Promise<{ ok: boolean; detail?: string }> { + try { + const resp = await fetch(path, { + method: 'POST', + headers: body ? { 'Content-Type': 'application/x-www-form-urlencoded' } : undefined, + body, + redirect: 'manual', + }); + const ok = resp.ok || resp.type === 'opaqueredirect' || (resp.status >= 200 && resp.status < 400); + if (ok) return { ok: true }; + const detail = await resp.text().catch(() => ''); + return { ok: false, detail: `http ${resp.status}${detail ? ' — ' + detail : ''}` }; + } catch (err) { + return { ok: false, detail: err instanceof Error ? err.message : String(err) }; + } +} + +export const postLoginStart = () => post('login/start'); +export const postLoginCode = (code: string) => post('login/code', new URLSearchParams({ code })); +export const postLoginCancel = () => post('login/cancel'); diff --git a/frontend/packages/agent/src/types.ts b/frontend/packages/agent/src/types.ts index 3a2d5b25..28f631ea 100644 --- a/frontend/packages/agent/src/types.ts +++ b/frontend/packages/agent/src/types.ts @@ -10,6 +10,7 @@ export interface AgentState { qualified_label: string; dashboard_port: number; status: 'online' | 'rate_limited' | 'needs_login_idle' | 'needs_login_in_progress'; + session: SessionView | null; turn_state: string; turn_state_since: number; model: string; @@ -25,6 +26,16 @@ export interface AgentState { inbox: InboxRow[]; } +// Mirrors `hive_agent::web_ui::state::SessionView` — populated only +// while `status === 'needs_login_in_progress'` (the in-flight +// `claude auth login` session's streamed output). +export interface SessionView { + url: string | null; + output: string; + finished: boolean; + exit_note: string | null; +} + export interface TokenUsage { input_tokens?: number; output_tokens?: number;