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
36 changes: 36 additions & 0 deletions dashboard/src/v2/hooks/use-update-status.ts
Original file line number Diff line number Diff line change
@@ -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<UpdateStatus | null>(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],
);
}
1 change: 1 addition & 0 deletions docs-web/architecture/dashboard-architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
1 change: 1 addition & 0 deletions docs/architecture/dashboard-resource-layer.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
84 changes: 84 additions & 0 deletions tests/dashboard/hooks/use-update-status.test.tsx
Original file line number Diff line number Diff line change
@@ -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>): 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();
});
});