diff --git a/dashboard/src/v2/hooks/use-update-status.ts b/dashboard/src/v2/hooks/use-update-status.ts new file mode 100644 index 0000000000..821fb8554e --- /dev/null +++ b/dashboard/src/v2/hooks/use-update-status.ts @@ -0,0 +1,36 @@ +import { useEffect, useMemo, useState } from "preact/hooks"; +import { fetchUpdateStatus, type UpdateStatus } from "../lib/system-api.js"; + +export interface UseUpdateStatusResult { + status: UpdateStatus | null; + updateAvailable: boolean; + latestVersion: string | null; +} + +export function useUpdateStatus(): UseUpdateStatusResult { + const [status, setStatus] = useState(null); + + useEffect(() => { + let cancelled = false; + + void fetchUpdateStatus() + .then((nextStatus) => { + if (!cancelled) { + setStatus(nextStatus); + } + }) + .catch(() => undefined); + + return () => { + cancelled = true; + }; + }, []); + + const updateAvailable = status?.updateAvailable === true; + const latestVersion = status?.latestVersion ?? null; + + return useMemo( + () => ({ status, updateAvailable, latestVersion }), + [status, updateAvailable, latestVersion], + ); +} diff --git a/docs-web/architecture/dashboard-architecture.md b/docs-web/architecture/dashboard-architecture.md index 686bae7456..3a899d8e24 100644 --- a/docs-web/architecture/dashboard-architecture.md +++ b/docs-web/architecture/dashboard-architecture.md @@ -103,6 +103,7 @@ State is managed with a mix of: - `useDashboardRuntimeData()` — live execution data. - `useRealtimeResource()` — generic WebSocket subscription wrapper. - `useProjectData()` — active project / sprint. + - `useUpdateStatus()` — one-shot system update availability from `/api/system/update-status`. - `useSprints()`, `usePreviewSessions()`, `useChatPageData()`, `useSettingsPageState()`, `useMemoryPageData()`, `useOverviewPageData()`, `useExecutionTimeline()`, `useProgressiveList()`. Hooks own their own subscription lifecycle — they subscribe on mount, unsubscribe on unmount. diff --git a/docs-web/content/docs/architecture-dashboard-architecture.mdx b/docs-web/content/docs/architecture-dashboard-architecture.mdx index 1bec56e16e..2217365ae3 100644 --- a/docs-web/content/docs/architecture-dashboard-architecture.mdx +++ b/docs-web/content/docs/architecture-dashboard-architecture.mdx @@ -103,6 +103,7 @@ State is managed with a mix of: - `useDashboardRuntimeData()` — live execution data. - `useRealtimeResource()` — generic WebSocket subscription wrapper. - `useProjectData()` — active project / sprint. + - `useUpdateStatus()` — one-shot system update availability from `/api/system/update-status`. - `useSprints()`, `usePreviewSessions()`, `useChatPageData()`, `useSettingsPageState()`, `useMemoryPageData()`, `useOverviewPageData()`, `useExecutionTimeline()`, `useProgressiveList()`. Hooks own their own subscription lifecycle — they subscribe on mount, unsubscribe on unmount. diff --git a/docs/architecture/dashboard-resource-layer.md b/docs/architecture/dashboard-resource-layer.md index 8c46bf8176..a671c07b48 100644 --- a/docs/architecture/dashboard-resource-layer.md +++ b/docs/architecture/dashboard-resource-layer.md @@ -36,6 +36,7 @@ Data fetching is governed by a unified resource layer rather than ad-hoc `useEff - Direct websocket payload updates (where the event contains the full updated resource) are batched using `requestAnimationFrame`. This coalesces bursts of updates into at most one render per animation frame, preventing the main thread from saturating during high-frequency realtime events. - In-flight project-level requests are abort-safe: if a shared project-level request is aborted by its initial caller unmounting, subsequent callers automatically retry instead of inheriting a poisoned aborted promise. Cache entries are populated only from successful, non-aborted fetches. - Dashboard API reads are non-cacheable at the HTTP boundary. The shared frontend JSON helper sends `cache: "no-store"` by default, and backend `/api/*`, `/health`, and `/ready` responses carry no-store headers so browser and Electron sessions always request live runtime data. +- One-shot system reads that do not need realtime invalidation stay in narrow v2 hooks. `useUpdateStatus()` loads `/api/system/update-status` once on mount through the typed `fetchUpdateStatus` client helper, derives `updateAvailable` and `latestVersion` from the returned contract, and treats fetch failures as a quiet "no update" state for shell consumers. - Project effective-settings reads are coalesced per project while a request is in flight and then stabilized through the effective-settings cache. This keeps layout chrome, sprint rows, and active pages from starting duplicate `/settings/effective` requests during the same mount burst. Explicit settings reloads use `cache: "reload"` after save/reset so the page reflects persisted settings instead of a warmed effective-settings payload. - Dashboard agent preset listing returns existing SQLite presets immediately and schedules markdown/source synchronization in a throttled background task. First-time projects with no presets still await the initial sync so default agents are seeded, while internal planning/MCP callers continue to use the strict `listAgentPresets` path that awaits sync and source decoration. diff --git a/tests/dashboard/hooks/use-update-status.test.tsx b/tests/dashboard/hooks/use-update-status.test.tsx new file mode 100644 index 0000000000..18a5798db4 --- /dev/null +++ b/tests/dashboard/hooks/use-update-status.test.tsx @@ -0,0 +1,84 @@ +/** @vitest-environment jsdom */ +import { renderHook, waitFor } from "@testing-library/preact"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { useUpdateStatus } from "../../../dashboard/src/v2/hooks/use-update-status.js"; +import { fetchUpdateStatus, type UpdateStatus } from "../../../dashboard/src/v2/lib/system-api.js"; + +vi.mock("../../../dashboard/src/v2/lib/system-api.js", () => ({ + fetchUpdateStatus: vi.fn(), +})); + +const makeUpdateStatus = (overrides: Partial): UpdateStatus => ({ + currentVersion: "0.9.3", + latestVersion: null, + updateAvailable: false, + releaseUrl: "https://github.com/codeux-ai/codeux/releases/latest", + downloadTargets: { + npm: { + kind: "npm", + label: "npm", + url: "https://www.npmjs.com/package/@codeuxai/codeux", + }, + electron: { + kind: "electron", + label: "Desktop app", + url: "https://github.com/codeux-ai/codeux/releases/latest", + }, + }, + checkedAt: "2026-07-09T00:00:00.000Z", + ...overrides, +}); + +describe("useUpdateStatus", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("exposes available updates with the latest version", async () => { + vi.mocked(fetchUpdateStatus).mockResolvedValueOnce(makeUpdateStatus({ + latestVersion: "0.9.4", + updateAvailable: true, + })); + + const { result } = renderHook(() => useUpdateStatus()); + + await waitFor(() => { + expect(result.current.updateAvailable).toBe(true); + }); + + expect(result.current.latestVersion).toBe("0.9.4"); + expect(result.current.status?.updateAvailable).toBe(true); + expect(fetchUpdateStatus).toHaveBeenCalledTimes(1); + }); + + it("treats an up-to-date payload as no update available", async () => { + vi.mocked(fetchUpdateStatus).mockResolvedValueOnce(makeUpdateStatus({ + latestVersion: "0.9.3", + updateAvailable: false, + })); + + const { result } = renderHook(() => useUpdateStatus()); + + await waitFor(() => { + expect(result.current.status).not.toBeNull(); + }); + + expect(result.current.updateAvailable).toBe(false); + expect(result.current.latestVersion).toBe("0.9.3"); + expect(fetchUpdateStatus).toHaveBeenCalledTimes(1); + }); + + it("treats a rejected fetch as no update available", async () => { + vi.mocked(fetchUpdateStatus).mockRejectedValueOnce(new Error("network unavailable")); + + const { result } = renderHook(() => useUpdateStatus()); + + await waitFor(() => { + expect(fetchUpdateStatus).toHaveBeenCalledTimes(1); + }); + + expect(result.current.status).toBeNull(); + expect(result.current.updateAvailable).toBe(false); + expect(result.current.latestVersion).toBeNull(); + }); +});