Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions hub/src/api/telegram-webhook.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,9 @@ async function safeSend(chatId: number | string, text: string): Promise<void> {
* variants for each photo; largest is the one we want.
*/
function pickLargestPhoto(photos: z.infer<typeof PhotoSize>[]): z.infer<typeof PhotoSize> {
// 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)) {
Expand Down Expand Up @@ -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 */
}
}
}
}
Expand Down
3 changes: 2 additions & 1 deletion hub/src/db/dal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1663,7 +1663,8 @@ export async function clearTelegramChatId(userId: string): Promise<void> {
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}
`;
}
Expand Down
5 changes: 4 additions & 1 deletion hub/src/telegram/commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,7 +292,10 @@ export async function listUserSessionsForPicker(userId: string): Promise<PickerS
github_repo: null,
last_activity_ms: null,
};
return [synthetic, ...filtered];
// The synthetic row counts AGAINST the 200-row cap (not on top of it):
// prepend then slice(0, 200) so a user already at the cap never gets 201
// rows and the picker's "(X of N)" count stays consistent with the cap.
return [synthetic, ...filtered].slice(0, 200);
}
} catch {
/* swallow — fall back to the unmodified list */
Expand Down
11 changes: 8 additions & 3 deletions hub/src/telegram/session-picker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -263,12 +263,17 @@ export function renderPickerText(opts: {
"Tap a button to set it as your default.",
];
const legend: string[] = [];
legend.push("🟢 = launched");
// Only show the 🟢 legend entry when the visible page actually contains a
// launched (online/thinking) row — otherwise an all-offline page documents a
// marker that isn't present (IN-05).
const page = rows ? rows.slice(offset, offset + PAGE_SIZE) : null;
const isOnline = (s: PickerSessionRow): boolean => 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;
}
Expand Down
34 changes: 29 additions & 5 deletions web/src/App.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand Down Expand Up @@ -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'
Expand All @@ -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=([^&]+)/)
Expand All @@ -107,6 +117,12 @@ export default function App() {
const [route, setRoute] = useState<Route>(getRoute)
const [gridTabId, setGridTabId] = useState<string | undefined>(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 || ''
Expand All @@ -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)
}, [])
Expand All @@ -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') {
Expand All @@ -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])

Expand Down
1 change: 0 additions & 1 deletion web/src/components/ChatLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -189,7 +189,6 @@ export function ChatLayout({ token, user, signOut, onNavigate }: Props) {
onToggleCollapsed={toggleCollapsed}
token={token}
subscribe={subscribe}
launchSession={sessionsHook.launchSession}
cloneHere={sessionsHook.cloneHere}
/>
</div>
Expand Down
10 changes: 4 additions & 6 deletions web/src/components/Sidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }>
}

Expand All @@ -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)
Expand Down
2 changes: 1 addition & 1 deletion web/src/hooks/useProfile.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Profile>(token, '/api/profile')
setProfile(p)
Expand Down
4 changes: 0 additions & 4 deletions web/src/pages/settings/ConnectionsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="px-4 md:px-6 lg:px-8 py-5 w-full max-w-7xl mx-auto space-y-5">
<RootsEditor token={token} />
Expand Down
4 changes: 0 additions & 4 deletions web/src/pages/settings/CredentialsTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="px-4 md:px-6 lg:px-8 py-5 w-full max-w-7xl mx-auto space-y-5">
<ApiKeyCard token={token} />
Expand Down
64 changes: 51 additions & 13 deletions web/src/pages/settings/OrchestratorTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -23,35 +23,65 @@ export function OrchestratorTab({ token }: { token: string }) {
const [name, setName] = useState("Orchestrator");
const [instructions, setInstructions] = useState("");
const [err, setErr] = useState<string | null>(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<string | null>(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<ReturnType<typeof setTimeout> | 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<OrchestratorSnapshot>(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<OrchestratorSnapshot>(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);
}
}

Expand All @@ -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);
}
}

Expand All @@ -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 (
<div className="px-4 md:px-6 lg:px-8 py-5 w-full max-w-5xl mx-auto space-y-5">
{!snap ? (
{!snap && loadError ? (
<Card>
<EmptyState
title="Couldn't load orchestrator"
description={loadError}
action={{ label: "Retry", onClick: () => { setLoadError(null); setLoading(true); void refresh(true); } }}
/>
</Card>
) : !snap || loading ? (
<div className="text-sm text-[var(--text-muted)]">Loading…</div>
) : (
<Card className="space-y-4">
Expand Down
23 changes: 14 additions & 9 deletions web/src/pages/settings/ProfileTab.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<div className="px-4 md:px-6 lg:px-8 py-5 w-full max-w-5xl mx-auto space-y-5">
<IdentityCard token={token} profile={profile} onUpdateProfile={onUpdateProfile} />
Expand Down Expand Up @@ -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<TelegramStatus>(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);
Expand All @@ -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 {
Expand Down
Loading
Loading