diff --git a/hub/src/api/telegram-webhook.ts b/hub/src/api/telegram-webhook.ts index ccfae46..733d1b9 100644 --- a/hub/src/api/telegram-webhook.ts +++ b/hub/src/api/telegram-webhook.ts @@ -156,6 +156,9 @@ async function safeSend(chatId: number | string, text: string): Promise { * variants for each photo; largest is the one we want. */ function pickLargestPhoto(photos: z.infer[]): z.infer { + // Contract: callers gate on `msg.photo.length > 0` before calling. Assert it + // explicitly instead of hiding the precondition behind a non-null assertion. + if (photos.length === 0) throw new Error("pickLargestPhoto: empty photo list"); let best = photos[0]!; let bestArea = best.width * best.height; for (const p of photos.slice(1)) { @@ -276,12 +279,19 @@ async function dispatchInbound( targetSessionId = orch; // Lazy-pin as NON-explicit so a later explicit `/session` repo choice // still wins, and the user is never silently promoted off the orchestrator. - try { - await setTelegramDefaultSession(user.id, orch, false); - user.telegram_default_session_id = orch; // keep the in-memory row coherent - user.telegram_default_explicit = false; - } catch { - /* swallow β€” dispatch still proceeds against the resolved id */ + // Skip the write when the pin is ALREADY orchestrator + non-explicit + // (IN-07) β€” otherwise every inbound message from an orchestrator user + // re-writes the same row. + const alreadyPinned = + user.telegram_default_session_id === orch && user.telegram_default_explicit !== true; + if (!alreadyPinned) { + try { + await setTelegramDefaultSession(user.id, orch, false); + user.telegram_default_session_id = orch; // keep the in-memory row coherent + user.telegram_default_explicit = false; + } catch { + /* swallow β€” dispatch still proceeds against the resolved id */ + } } } } diff --git a/hub/src/db/dal.ts b/hub/src/db/dal.ts index c7afea1..f8e8d88 100644 --- a/hub/src/db/dal.ts +++ b/hub/src/db/dal.ts @@ -1663,7 +1663,8 @@ export async function clearTelegramChatId(userId: string): Promise { await sql` UPDATE users SET telegram_chat_id = NULL, - telegram_default_session_id = NULL + telegram_default_session_id = NULL, + telegram_default_explicit = false WHERE id = ${userId} `; } diff --git a/hub/src/telegram/commands.ts b/hub/src/telegram/commands.ts index cf02038..4a7c952 100644 --- a/hub/src/telegram/commands.ts +++ b/hub/src/telegram/commands.ts @@ -292,7 +292,10 @@ export async function listUserSessionsForPicker(userId: string): Promise s.status === "online" || s.status === "thinking"; + // When `rows` is omitted (legacy caller), keep the legacy unconditional legend. + if (!page || page.some(isOnline)) legend.push("🟒 = launched"); if (defaultId) legend.push("βœ“ = current default"); let anyOrchestrator = false; if (rows) { - const page = rows.slice(offset, offset + PAGE_SIZE); - if (page.some((s) => s.is_orchestrator)) { + if (page!.some((s) => s.is_orchestrator)) { legend.push("🧭 = orchestrator (root folder)"); anyOrchestrator = true; } diff --git a/web/src/App.tsx b/web/src/App.tsx index 1461bdf..8f2332d 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useCallback } from 'react' +import { useState, useEffect, useCallback, useRef } from 'react' import { useAuth } from './hooks/useAuth' import { useProfile } from './hooks/useProfile' import { Login } from './pages/Login' @@ -80,8 +80,12 @@ function resolveHashWithRedirects(): string { return canonical } +// Pure: reads the (already-redirect-resolved) hash and maps it to a Route. +// Redirect resolution is a side-effect (history.replaceState) and lives in +// `resolveHashWithRedirects`, called once at module load and on every +// hashchange β€” never inside a render-phase state initializer. function getRoute(): Route { - const hash = resolveHashWithRedirects() + const hash = window.location.hash || '#/' if (hash.startsWith('#/auth/callback')) return 'auth-callback' if (hash.startsWith('#/login')) return 'login' if (hash.startsWith('#/tasks')) return 'tasks' @@ -94,6 +98,12 @@ function getRoute(): Route { return 'home' } +// Resolve legacy-hash redirects ONCE at module load (mirrors the pathname +// normalize above). After this, `getRoute()` can read the canonical hash purely. +if (typeof window !== 'undefined') { + resolveHashWithRedirects() +} + function getGridTabId(): string | undefined { const hash = window.location.hash const m = hash.match(/[?&]grid_tab=([^&]+)/) @@ -107,6 +117,12 @@ export default function App() { const [route, setRoute] = useState(getRoute) const [gridTabId, setGridTabId] = useState(getGridTabId) const [licenseRequired, setLicenseRequired] = useState(false) + // Latch so a burst of concurrent 401s (every in-flight hubFetch fires + // authEventHandler) triggers signOut() β€” and its single POST /api/auth/logout + // β€” exactly once. Reset once token+user have cleared so a later genuine + // re-login can sign out again. Does NOT change the self-terminating behavior: + // signOut still fires, just not N times per dead-credential burst. + const signingOut = useRef(false) useEffect(() => { const hubUrl = import.meta.env.VITE_HUB_URL || '' @@ -118,7 +134,7 @@ export default function App() { // Hash-based routing useEffect(() => { - const onHashChange = () => { setRoute(getRoute()); setGridTabId(getGridTabId()) } + const onHashChange = () => { resolveHashWithRedirects(); setRoute(getRoute()); setGridTabId(getGridTabId()) } window.addEventListener('hashchange', onHashChange) return () => window.removeEventListener('hashchange', onHashChange) }, []) @@ -135,7 +151,8 @@ export default function App() { // the redirect is preserved. Guarded by route so we don't loop while // already on login/auth-callback. apiLogout() inside signOut uses a // bare fetch (NOT hubFetch), so it can't re-fire this handler. - if (route !== 'login' && route !== 'auth-callback') { + if (!signingOut.current && route !== 'login' && route !== 'auth-callback') { + signingOut.current = true signOut() } } else if (kind === 'license_required') { @@ -155,7 +172,14 @@ export default function App() { // shows Login in the meantime. useEffect(() => { if (!profileLoading && !profile && (token || user)) { - signOut() + if (!signingOut.current) { + signingOut.current = true + signOut() + } + } else if (!token && !user) { + // Credential fully cleared (or never present) β€” release the latch so a + // future re-login can sign out again on its own dead-credential event. + signingOut.current = false } }, [profileLoading, profile, token, user, signOut]) diff --git a/web/src/components/ChatLayout.tsx b/web/src/components/ChatLayout.tsx index f70347c..a18dcf7 100644 --- a/web/src/components/ChatLayout.tsx +++ b/web/src/components/ChatLayout.tsx @@ -189,7 +189,6 @@ export function ChatLayout({ token, user, signOut, onNavigate }: Props) { onToggleCollapsed={toggleCollapsed} token={token} subscribe={subscribe} - launchSession={sessionsHook.launchSession} cloneHere={sessionsHook.cloneHere} /> diff --git a/web/src/components/Sidebar.tsx b/web/src/components/Sidebar.tsx index acbb8e0..a3b913a 100644 --- a/web/src/components/Sidebar.tsx +++ b/web/src/components/Sidebar.tsx @@ -29,8 +29,7 @@ interface Props { token?: string | null /** Hub WS subscribe β€” forwarded to PendingLocalRepoPrompt/CreateGithubRepoModal for progress. */ subscribe?: (handler: (msg: any) => void) => () => void - /** Phase 08.5 launch-flow helpers (from useSessions). Optional so existing callers keep compiling. */ - launchSession?: (id: string, body?: { cli_kind?: 'claude' | 'codex'; local_path?: string }) => Promise<{ ok: boolean; error?: string; detail?: string }> + /** Phase 08.5 launch-flow helper (from useSessions). Optional so existing callers keep compiling. */ cloneHere?: (id: string, targetRoot: string) => Promise<{ ok: boolean; error?: string; target_path?: string }> } @@ -42,10 +41,9 @@ export function Sidebar({ collapsed = false, onToggleCollapsed, token = null, subscribe, - // launchSession is kept in Props (callers still pass it) but the sidebar no - // longer renders offline rows or re-launch buttons β€” offline sessions are - // launched from Settings β†’ Supervisor, not here. The sidebar is active-only. - launchSession: _launchSession, + // The sidebar no longer renders offline rows or re-launch buttons β€” offline + // sessions are launched from Settings β†’ Supervisor, not here. The sidebar is + // active-only, so the old `launchSession` prop has been dropped. cloneHere, }: Props) { const [cloneModal, setCloneModal] = useState<{ sessionId: string; repoLabel: string } | null>(null) diff --git a/web/src/hooks/useProfile.ts b/web/src/hooks/useProfile.ts index 1a01a66..976674f 100644 --- a/web/src/hooks/useProfile.ts +++ b/web/src/hooks/useProfile.ts @@ -19,7 +19,7 @@ export function useProfile(token: string | null) { const [loading, setLoading] = useState(true) const fetchProfile = useCallback(async () => { - if (!token) return + if (!token) { setLoading(false); return } try { const p = await hubFetch(token, '/api/profile') setProfile(p) diff --git a/web/src/pages/settings/ConnectionsTab.tsx b/web/src/pages/settings/ConnectionsTab.tsx index d1f8d7a..374bb90 100644 --- a/web/src/pages/settings/ConnectionsTab.tsx +++ b/web/src/pages/settings/ConnectionsTab.tsx @@ -23,10 +23,6 @@ interface Props { } export function ConnectionsTab({ token }: Props) { - useEffect(() => { - console.log("[tab:settings:connections] mounted"); - return () => console.log("[tab:settings:connections] unmounted"); - }, []); return (
diff --git a/web/src/pages/settings/CredentialsTab.tsx b/web/src/pages/settings/CredentialsTab.tsx index f12c6a4..52c1655 100644 --- a/web/src/pages/settings/CredentialsTab.tsx +++ b/web/src/pages/settings/CredentialsTab.tsx @@ -27,10 +27,6 @@ interface Props { } export function CredentialsTab({ token }: Props) { - useEffect(() => { - console.log("[tab:settings:credentials] mounted"); - return () => console.log("[tab:settings:credentials] unmounted"); - }, []); return (
diff --git a/web/src/pages/settings/OrchestratorTab.tsx b/web/src/pages/settings/OrchestratorTab.tsx index ba43725..62514f2 100644 --- a/web/src/pages/settings/OrchestratorTab.tsx +++ b/web/src/pages/settings/OrchestratorTab.tsx @@ -6,9 +6,9 @@ * primitives. Data logic (GET/PUT /api/orchestrator, POST start/stop) is * preserved verbatim β€” only the markup changed. */ -import { useEffect, useState } from "react"; +import { useEffect, useRef, useState } from "react"; import { hubFetch } from "../../lib/api"; -import { Card, Button, Field, StatusPill, Modal } from "../../components/ui"; +import { Card, Button, Field, StatusPill, Modal, EmptyState } from "../../components/ui"; type OrchestratorSnapshot = { enabled: boolean; @@ -23,35 +23,65 @@ export function OrchestratorTab({ token }: { token: string }) { const [name, setName] = useState("Orchestrator"); const [instructions, setInstructions] = useState(""); const [err, setErr] = useState(null); + // Separate from `err`: set ONLY when the initial load fails so we can render a + // Retry empty-state instead of an infinite "Loading…" spinner (WR-05). + const [loadError, setLoadError] = useState(null); + const [loading, setLoading] = useState(true); const [busy, setBusy] = useState(false); const [showEnableModal, setShowEnableModal] = useState(false); const [savedFlash, setSavedFlash] = useState(false); - async function refresh() { + // Unmount guards: bail on setState / clear a dangling savedFlash timer if the + // user swaps settings tabs mid-request (WR-04). + const aliveRef = useRef(true); + const flashTimer = useRef | null>(null); + + // refresh() reloads the snapshot. `initial` distinguishes the mount load + // (drives loading/loadError β†’ Retry state) from action-triggered reloads + // (whose failures surface as `err` so a successful start/stop isn't masked). + async function refresh(initial = false) { try { const r = await hubFetch(token, "/api/orchestrator"); + if (!aliveRef.current) return; setSnap(r); setName(r.name); setInstructions(r.custom_instructions ?? ""); + setLoadError(null); } catch (e: any) { - setErr(e?.message ?? "load failed"); + if (!aliveRef.current) return; + const msg = e?.message ?? "load failed"; + if (initial) setLoadError(msg); + else setErr(msg); + } finally { + if (aliveRef.current && initial) setLoading(false); } } - useEffect(() => { void refresh(); }, []); + useEffect(() => { + aliveRef.current = true; + void refresh(true); + return () => { + aliveRef.current = false; + if (flashTimer.current) clearTimeout(flashTimer.current); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); async function patch(body: Partial<{ enabled: boolean; name: string; custom_instructions: string | null }>) { setBusy(true); setErr(null); try { const r = await hubFetch(token, "/api/orchestrator", { method: "PUT", json: body }); + if (!aliveRef.current) return; setSnap(r); setName(r.name); setInstructions(r.custom_instructions ?? ""); - setSavedFlash(true); setTimeout(() => setSavedFlash(false), 1200); + setSavedFlash(true); + if (flashTimer.current) clearTimeout(flashTimer.current); + flashTimer.current = setTimeout(() => { if (aliveRef.current) setSavedFlash(false); }, 1200); } catch (e: any) { - setErr(e?.message ?? "save failed"); + if (aliveRef.current) setErr(e?.message ?? "save failed"); } finally { - setBusy(false); + if (aliveRef.current) setBusy(false); } } @@ -61,9 +91,9 @@ export function OrchestratorTab({ token }: { token: string }) { await hubFetch(token, "/api/orchestrator/start", { method: "POST", json: {} }); await refresh(); } catch (e: any) { - setErr(e?.message ?? "start failed"); + if (aliveRef.current) setErr(e?.message ?? "start failed"); } finally { - setBusy(false); + if (aliveRef.current) setBusy(false); } } @@ -73,15 +103,23 @@ export function OrchestratorTab({ token }: { token: string }) { await hubFetch(token, "/api/orchestrator/stop", { method: "POST", json: {} }); await refresh(); } catch (e: any) { - setErr(e?.message ?? "stop failed"); + if (aliveRef.current) setErr(e?.message ?? "stop failed"); } finally { - setBusy(false); + if (aliveRef.current) setBusy(false); } } return (
- {!snap ? ( + {!snap && loadError ? ( + + { setLoadError(null); setLoading(true); void refresh(true); } }} + /> + + ) : !snap || loading ? (
Loading…
) : ( diff --git a/web/src/pages/settings/ProfileTab.tsx b/web/src/pages/settings/ProfileTab.tsx index 4d00d15..513d612 100644 --- a/web/src/pages/settings/ProfileTab.tsx +++ b/web/src/pages/settings/ProfileTab.tsx @@ -28,10 +28,6 @@ interface Props { } export function ProfileTab({ token, profile, onUpdateProfile }: Props) { - useEffect(() => { - console.log("[tab:settings:profile] mounted"); - return () => console.log("[tab:settings:profile] unmounted"); - }, []); return (
@@ -410,18 +406,27 @@ function TelegramCard({ token }: { token: string }) { const { sessions } = useSessions(token); const safeSessions = Array.isArray(sessions) ? sessions : []; + // Tracks mount state so the async initial load can't setState after unmount + // (user swaps settings tab before /status resolves). + const aliveRef = useRef(true); + const refresh = async () => { try { const r = await hubFetch(token, "/api/telegram/status"); - setStatus(r); + if (aliveRef.current) setStatus(r); } catch (e: any) { - setError(e?.message || "Failed to load Telegram status"); + if (aliveRef.current) setError(e?.message || "Failed to load Telegram status"); } finally { - setLoading(false); + if (aliveRef.current) setLoading(false); } }; - useEffect(() => { void refresh(); }, []); + useEffect(() => { + aliveRef.current = true; + void refresh(); + return () => { aliveRef.current = false; }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); const startLink = async () => { setBusy(true); @@ -433,7 +438,7 @@ function TelegramCard({ token }: { token: string }) { { method: "POST", json: {} }, ); setLinkCode(r); - if (r.deepLink) window.open(r.deepLink, "_blank"); + if (r.deepLink) window.open(r.deepLink, "_blank", "noopener,noreferrer"); } catch (e: any) { setError(e?.status === 503 ? "Telegram bridge isn't configured on this server." : e?.message || "Failed to start link"); } finally { diff --git a/web/src/pages/settings/PromptsTab.tsx b/web/src/pages/settings/PromptsTab.tsx index f3cde15..65c9a1a 100644 --- a/web/src/pages/settings/PromptsTab.tsx +++ b/web/src/pages/settings/PromptsTab.tsx @@ -22,10 +22,6 @@ interface Props { } export function PromptsTab({ token }: Props) { - useEffect(() => { - console.log("[tab:settings:prompts] mounted"); - return () => console.log("[tab:settings:prompts] unmounted"); - }, []); return (
diff --git a/web/src/pages/settings/UsageTab.tsx b/web/src/pages/settings/UsageTab.tsx index d7aa66d..ad24b72 100644 --- a/web/src/pages/settings/UsageTab.tsx +++ b/web/src/pages/settings/UsageTab.tsx @@ -36,10 +36,6 @@ interface Props { } export function UsageTab({ token, profile, onUpdateProfile }: Props) { - useEffect(() => { - console.log("[tab:settings:usage] mounted"); - return () => console.log("[tab:settings:usage] unmounted"); - }, []); const [summary, setSummary] = useState(null); const [error, setError] = useState(null);