diff --git a/CHANGELOG.md b/CHANGELOG.md index 626e0e8082..8b4e8aee84 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -34,6 +34,7 @@ ### 🔧 Bug Fixes +- **fix(home topology): restore live in-flight request pulse** ([#3507]): the animated "pulse" edges in the home Provider Topology panel went dead after PR #3401 unified request visibility, because `activeRequests` was hardcoded to `[]`. Re-wired to `useLiveRequests()` (the existing WebSocket hook on port 20129) so that every pending/running request drives the animation in real time. A pure `selectActiveRequests` mapping helper was extracted to `home/topologyUtils.ts` with 5 unit tests. - **Electron desktop**: launch the peer-stamping `server-ws.mjs` entrypoint so local-only routes (AgentBridge, MCP, services) no longer return 403 LOCAL_ONLY (#3386) - **Provider Topology**: stop flagging healthy providers as errored based on stale historical failures; use current request status (#3619) - **OpenCode Free**: fetch the live model catalog from the provider's `modelsUrl` for the no-auth model picker instead of serving a stale hardcoded list (#3611) diff --git a/src/app/(dashboard)/dashboard/HomePageClient.tsx b/src/app/(dashboard)/dashboard/HomePageClient.tsx index 5e40a2d73c..f6d3fa56bb 100644 --- a/src/app/(dashboard)/dashboard/HomePageClient.tsx +++ b/src/app/(dashboard)/dashboard/HomePageClient.tsx @@ -13,6 +13,8 @@ import { useNotificationStore } from "@/store/notificationStore"; import { copyToClipboard } from "@/shared/utils/clipboard"; import { getProviderDisplayLabel } from "@/shared/utils/providerDisplayLabel"; import { useIsElectron, useOpenExternal } from "@/shared/hooks/useElectron"; +import { useLiveRequests } from "@/hooks/useLiveDashboard"; +import { selectActiveRequests } from "../home/topologyUtils"; const ProviderTopology = dynamic(() => import("../home/ProviderTopology"), { ssr: false }); const ProviderQuotaWidget = dynamic(() => import("../home/ProviderQuotaWidget"), { ssr: false }); @@ -114,6 +116,9 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { Array<{ id?: string; prefix?: string; name?: string }> >([]); + // Live in-flight requests for Provider Topology pulse animation (#3507) + const { activeRequests: liveActiveRequests } = useLiveRequests(); + const [versionInfo, setVersionInfo] = useState(null); const [updating, setUpdating] = useState(false); @@ -1178,7 +1183,7 @@ export default function HomePageClient({ machineId }: HomePageClientProps) { diff --git a/src/app/(dashboard)/home/topologyUtils.ts b/src/app/(dashboard)/home/topologyUtils.ts new file mode 100644 index 0000000000..ef731f72f8 --- /dev/null +++ b/src/app/(dashboard)/home/topologyUtils.ts @@ -0,0 +1,27 @@ +/** + * Pure helpers for the home-page Provider Topology panel. + */ + +/** Minimal shape expected by */ +export interface TopologyActiveRequest { + provider: string; + model: string; +} + +/** Minimal in-flight request shape (subset of LiveRequest from useLiveRequests) */ +interface InFlightRequest { + provider: string; + model: string; +} + +/** + * Maps an array of in-flight LiveRequest entries to the flat + * { provider, model }[] shape consumed by . + * + * The input is expected to contain only pending/running entries — the + * useLiveRequests hook already filters out completed and failed requests + * before exposing them via `activeRequests`. + */ +export function selectActiveRequests(requests: InFlightRequest[]): TopologyActiveRequest[] { + return requests.map(({ provider, model }) => ({ provider, model })); +} diff --git a/tests/unit/topology-active-requests-3507.test.ts b/tests/unit/topology-active-requests-3507.test.ts new file mode 100644 index 0000000000..17de320151 --- /dev/null +++ b/tests/unit/topology-active-requests-3507.test.ts @@ -0,0 +1,72 @@ +/** + * #3507 — the home Provider Topology pulse was dead because activeRequests was + * hardcoded to []. Fix: map the in-flight LiveRequest entries (pending/running) + * from useLiveRequests to the { provider, model }[] shape expected by + * . + * + * selectActiveRequests() is a pure mapping function — no React required. + */ +import test from "node:test"; +import assert from "node:assert/strict"; + +import { selectActiveRequests } from "../../src/app/(dashboard)/home/topologyUtils.ts"; + +// ── helpers ────────────────────────────────────────────────────────────────── + +function makeRequest( + overrides: Partial<{ + id: string; + model: string; + provider: string; + status: "pending" | "running" | "success" | "error"; + }> +) { + return { + id: "req-1", + model: "gpt-4", + provider: "openai", + timestamp: Date.now(), + status: "pending" as const, + ...overrides, + }; +} + +// ── tests ───────────────────────────────────────────────────────────────────── + +test("selectActiveRequests: maps pending requests to {provider, model}", () => { + const input = [makeRequest({ id: "a", provider: "anthropic", model: "claude-opus-4", status: "pending" })]; + const result = selectActiveRequests(input); + assert.deepEqual(result, [{ provider: "anthropic", model: "claude-opus-4" }]); +}); + +test("selectActiveRequests: maps running requests to {provider, model}", () => { + const input = [makeRequest({ id: "b", provider: "openai", model: "gpt-4o", status: "running" })]; + const result = selectActiveRequests(input); + assert.deepEqual(result, [{ provider: "openai", model: "gpt-4o" }]); +}); + +test("selectActiveRequests: returns empty array for empty input", () => { + assert.deepEqual(selectActiveRequests([]), []); +}); + +test("selectActiveRequests: maps multiple concurrent in-flight requests", () => { + const input = [ + makeRequest({ id: "c1", provider: "anthropic", model: "claude-sonnet-4", status: "pending" }), + makeRequest({ id: "c2", provider: "gemini", model: "gemini-2.5-pro", status: "running" }), + ]; + const result = selectActiveRequests(input); + assert.deepEqual(result, [ + { provider: "anthropic", model: "claude-sonnet-4" }, + { provider: "gemini", model: "gemini-2.5-pro" }, + ]); +}); + +test("selectActiveRequests: only extracts provider and model fields (not id/timestamp/etc)", () => { + const input = [makeRequest({ id: "d", provider: "groq", model: "llama3-70b", status: "running" })]; + const result = selectActiveRequests(input); + assert.equal(Object.keys(result[0]).length, 2); + assert.ok("provider" in result[0]); + assert.ok("model" in result[0]); + assert.ok(!("id" in result[0])); + assert.ok(!("timestamp" in result[0])); +});