From 5cd1a6a1e0cc8f5b910aa1e3d8dab9cb1fe06627 Mon Sep 17 00:00:00 2001 From: KevinYoung-Kw Date: Thu, 20 Aug 2026 02:30:19 +0000 Subject: [PATCH] fix(ui): reserve plugin tab counts and overview skeleton Stop plugin pills from growing when counts arrive, and keep Settings overview geometry stable while data loads. --- .../unit/settings-link-migration.test.ts | 21 +--- .../unit/settings-routes-shape.test.ts | 9 +- src/app/plugins/page.tsx | 99 +++++++++++---- src/app/settings/page.tsx | 54 ++------ src/components/settings/OverviewSection.tsx | 45 ++++++- src/components/settings/useOverviewData.ts | 12 +- src/lib/plugin-tab-counts.ts | 117 ++++++++++++++++++ 7 files changed, 266 insertions(+), 91 deletions(-) create mode 100644 src/lib/plugin-tab-counts.ts diff --git a/src/__tests__/unit/settings-link-migration.test.ts b/src/__tests__/unit/settings-link-migration.test.ts index e4f97268f..ca8eadf94 100644 --- a/src/__tests__/unit/settings-link-migration.test.ts +++ b/src/__tests__/unit/settings-link-migration.test.ts @@ -130,24 +130,15 @@ describe('Settings link migration — no bare hash navigation in active code', ( } }); - it('the /settings root page still preserves hash compat for external deep links', () => { - // External docs / past chat sessions still hand out /settings#providers. - // The redirect must keep handling that — but only at the root page, - // never at internal callers. + it('the /settings root page server-redirects to overview', () => { + // Server redirect removes the client empty-tick. Internal callers already + // use /settings/
; this page must stay import-free. const root = readFileSync( path.resolve(__dirname, '../../app/settings/page.tsx'), 'utf-8', ); - assert.match(root, /window\.location\.hash/); - assert.match(root, /router\.replace/); - // The hash → route table must include at least the four high-traffic - // sections so no important external link 404s. - for (const section of ['providers', 'models', 'runtime', 'assistant']) { - assert.match( - root, - new RegExp(`\\b${section}\\b[\\s\\S]{0,80}/settings/${section}`), - `hash-redirect table must map "${section}" → /settings/${section}`, - ); - } + assert.match(root, /\bredirect\s*\(/); + assert.match(root, /\/settings\/overview/); + assert.doesNotMatch(root, /["']use client["']/); }); }); diff --git a/src/__tests__/unit/settings-routes-shape.test.ts b/src/__tests__/unit/settings-routes-shape.test.ts index b2b0c9908..30a8b472e 100644 --- a/src/__tests__/unit/settings-routes-shape.test.ts +++ b/src/__tests__/unit/settings-routes-shape.test.ts @@ -106,12 +106,11 @@ describe('Settings route-level split', () => { `/settings/page.tsx must not import ${section} — it is a redirect-only page`, ); } - // The hash → route redirect must exist for legacy /settings#providers etc. - assert.match(root, /useRouter\(\)/); - assert.match(root, /window\.location\.hash/); - assert.match(root, /router\.replace/); - // Default fallback when no hash is present must point at /settings/overview. + // Server redirect — no client empty-tick (`return null` + router.replace). + assert.match(root, /from\s+["']next\/navigation["']/); + assert.match(root, /\bredirect\s*\(/); assert.match(root, /\/settings\/overview/); + assert.doesNotMatch(root, /["']use client["']/); }); it('SettingsSidebar uses pathname + Link (not hash + history.replaceState)', () => { diff --git a/src/app/plugins/page.tsx b/src/app/plugins/page.tsx index af0ccd2c4..3c89dd407 100644 --- a/src/app/plugins/page.tsx +++ b/src/app/plugins/page.tsx @@ -26,7 +26,7 @@ * 3. SkillsManager re-fetches when those props change. */ -import { useEffect, useMemo, useRef, useState } from "react"; +import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { @@ -51,6 +51,13 @@ import { TabsList, TabsTrigger, } from "@/components/ui/tabs"; +import { + hydratePluginCountsFromSession, + prefetchPluginTabCounts, + readCachedPluginCounts, + writeCachedPluginCounts, + type PluginTabCounts, +} from "@/lib/plugin-tab-counts"; import { Dialog, DialogContent, @@ -118,27 +125,73 @@ export default function ExtensionsPage() { // stale-search flash. https://react.dev/learn/you-might-not-need-an-effect const [search, setSearch] = useState(""); const [prevFilter, setPrevFilter] = useState(filter); + const [prefetchCounts, setPrefetchCounts] = useState( + () => readCachedPluginCounts(), + ); + const [liveCounts, setLiveCounts] = useState({}); if (filter !== prevFilter) { setPrevFilter(filter); setSearch(""); + // Drop the tab we left so a stale search-filtered total cannot + // paint when we come back; inactive pills use unfiltered last-known. + setLiveCounts((prev) => { + if (prev[prevFilter] === undefined) return prev; + const next = { ...prev }; + delete next[prevFilter]; + return next; + }); } // Per-filter counts for the Tab labels ("Skills 35 / MCP 9 / CLI 11"). - // Each manager reports its own count via callback when mounted; the - // host caches the last known number so Tabs don't flash back to "?" - // when the user switches tabs. `undefined` means "not yet known" — - // Tabs omit the number rather than render a misleading "0". - const [skillsCount, setSkillsCount] = useState(undefined); - const [mcpCount, setMcpCount] = useState(undefined); - const [cliCount, setCliCount] = useState(undefined); - const handleSkillsCounts = (counts: Record) => { + // Prefetch all three on mount so unvisited pills are not blank. + // `undefined` = not yet known — we still render a reserved slot with + // a muted en-dash, never omit the span and never flash a "0". + // Active tab prefers the mounted manager (search-filtered); inactive + // tabs keep the unfiltered prefetch / last-known number. + useEffect(() => { + const stored = hydratePluginCountsFromSession(); + if (stored.skills != null || stored.mcp != null || stored.cli != null) { + setPrefetchCounts((prev) => ({ ...stored, ...prev })); + } + let cancelled = false; + prefetchPluginTabCounts({ cwd, sessionId: activeSessionId }).then((next) => { + if (cancelled) return; + setPrefetchCounts((prev) => ({ ...prev, ...next })); + }); + return () => { + cancelled = true; + }; + // Mount-only: do not re-prefetch when cwd fallback arrives. + // SkillsManager already refetches on cwd; a second host GET would + // add another wave. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + const rememberUnfiltered = useCallback((key: PluginFilter, value: number) => { + writeCachedPluginCounts({ [key]: value }); + setPrefetchCounts((prev) => (prev[key] === value ? prev : { ...prev, [key]: value })); + }, []); + + const handleSkillsCounts = useCallback((counts: Record) => { const total = Object.values(counts).reduce((sum, n) => sum + n, 0); - setSkillsCount(total); - }; + setLiveCounts((prev) => (prev.skills === total ? prev : { ...prev, skills: total })); + if (!search) rememberUnfiltered("skills", total); + }, [search, rememberUnfiltered]); + + const handleMcpCount = useCallback((count: number) => { + setLiveCounts((prev) => (prev.mcp === count ? prev : { ...prev, mcp: count })); + if (!search) rememberUnfiltered("mcp", count); + }, [search, rememberUnfiltered]); + + const handleCliCount = useCallback((count: number) => { + setLiveCounts((prev) => (prev.cli === count ? prev : { ...prev, cli: count })); + if (!search) rememberUnfiltered("cli", count); + }, [search, rememberUnfiltered]); + const filterCounts: Record = { - skills: skillsCount, - mcp: mcpCount, - cli: cliCount, + skills: filter === "skills" && liveCounts.skills !== undefined ? liveCounts.skills : prefetchCounts.skills, + mcp: filter === "mcp" && liveCounts.mcp !== undefined ? liveCounts.mcp : prefetchCounts.mcp, + cli: filter === "cli" && liveCounts.cli !== undefined ? liveCounts.cli : prefetchCounts.cli, }; // Imperative refs into each manager so the per-tab action bar can @@ -184,14 +237,10 @@ export default function ExtensionsPage() { ); } if (filter === "mcp") { - return ; + return ; } - return ; - // handleSkillsCounts identity changes per render, but it only flows - // into a child useEffect that re-fires harmlessly. Keeping it out of - // deps would cause stale closure on setSkillsCount. (deps are complete now — - // no suppression needed.) - }, [filter, cwd, activeSessionId, search]); + return ; + }, [filter, cwd, activeSessionId, search, handleSkillsCounts, handleMcpCount, handleCliCount]); return (
@@ -216,11 +265,9 @@ export default function ExtensionsPage() { {t(meta.labelKey)} - {typeof count === "number" && ( - - {count} - - )} + + {typeof count === "number" ? count : "–"} + ); })} diff --git a/src/app/settings/page.tsx b/src/app/settings/page.tsx index 6a0410cf0..41298db45 100644 --- a/src/app/settings/page.tsx +++ b/src/app/settings/page.tsx @@ -1,50 +1,20 @@ -"use client"; +import { redirect } from "next/navigation"; /** - * /settings root — pure client redirect, no section imports. + * /settings root — server redirect, no section imports. * * Memory contract: this page must NOT import any settings section. The - * Overview dashboard moved to /settings/overview specifically so old hash - * deep links (`/settings#providers`, `/settings#models`, …) can land here - * and bounce to the right route WITHOUT first paying the OverviewSection - * compile cost (which transitively pulls - * `useOverviewData → @/lib/runtime/effective`'s provider catalog + model - * discovery + runtime resolver into the dev graph). See - * `src/__tests__/unit/settings-routes-shape.test.ts` and - * `settings-link-migration.test.ts`. + * Overview dashboard lives at /settings/overview so this file stays a + * pure bounce (see `src/__tests__/unit/settings-routes-shape.test.ts` + * and `settings-link-migration.test.ts`). * - * Behavior: - * - URL has hash matching a known section → router.replace to /settings/ - * - URL has no hash (or unknown hash) → router.replace to /settings/overview + * A server `redirect()` from `next/navigation` avoids the client-side + * empty tick (`return null` + `router.replace`) that flashed a blank + * page before /settings/overview painted. + * + * Legacy `/settings#section` hashes are not sent to the server; current + * in-app links use `/settings/
`. */ - -import { useEffect } from "react"; -import { useRouter } from "next/navigation"; - -const SECTION_HASH_TO_PATH: Record = { - overview: "/settings/overview", - general: "/settings/general", - appearance: "/settings/appearance", - providers: "/settings/providers", - models: "/settings/models", - runtime: "/settings/runtime", - health: "/settings/health", - usage: "/settings/usage", - assistant: "/settings/assistant", - tasks: "/settings/tasks", - bridge: "/settings/bridge", - about: "/settings/about", -}; - export default function SettingsRootRedirectPage() { - const router = useRouter(); - - useEffect(() => { - if (typeof window === "undefined") return; - const hash = window.location.hash.replace("#", ""); - const target = SECTION_HASH_TO_PATH[hash] ?? "/settings/overview"; - router.replace(target); - }, [router]); - - return null; + redirect("/settings/overview"); } diff --git a/src/components/settings/OverviewSection.tsx b/src/components/settings/OverviewSection.tsx index 36ae989f4..aae6c6933 100644 --- a/src/components/settings/OverviewSection.tsx +++ b/src/components/settings/OverviewSection.tsx @@ -141,8 +141,9 @@ export function OverviewSection() { {t("settings.overviewDesc" as TranslationKey)}

-
-

{isZh ? "加载中…" : "Loading…"}

+
+ {t("common.loading")} +
); @@ -390,3 +391,43 @@ export function OverviewSection() { ); } + + +/** + * Reserved-geometry loading stand-in for the 6-card grid + heatmap. + * Matches OverviewCard chrome (`rounded-lg border … p-5`) and Usage's + * `h-64` reserved chart block so the page does not expand when data lands. + * Checklist is omitted until loaded (avoids a false "all done" from zeros). + */ +function OverviewDashboardSkeleton() { + return ( + <> +
+ {Array.from({ length: 6 }, (_, i) => ( +
+
+
+
+
+
+
+
+
+
+
+ ))} +
+
+
+
+
+
+ + ); +} diff --git a/src/components/settings/useOverviewData.ts b/src/components/settings/useOverviewData.ts index 775b91290..d3aee0b6d 100644 --- a/src/components/settings/useOverviewData.ts +++ b/src/components/settings/useOverviewData.ts @@ -92,6 +92,9 @@ export interface OverviewState { providers: ProviderModelGroup[]; } +/** Last successful snapshot so a revisit paints real numbers instantly. */ +let lastKnownOverview: OverviewState | null = null; + const initialState: OverviewState = { loading: true, agentRuntime: "claude-code-sdk", @@ -114,7 +117,7 @@ const initialState: OverviewState = { }; export function useOverviewData(): OverviewState { - const [state, setState] = useState(initialState); + const [state, setState] = useState(() => lastKnownOverview ?? initialState); const fetchAll = useCallback(async () => { try { @@ -240,6 +243,7 @@ export function useOverviewData(): OverviewState { // First paint: everything except the per-provider manual counts. setState(next); + lastKnownOverview = next; // Phase 2 (non-blocking): per-provider deep fetch for manual_enabled / // manual_hidden counts. A slow / large provider list can't hold up the @@ -263,9 +267,15 @@ export function useOverviewData(): OverviewState { }), ); setState((prev) => ({ ...prev, modelsManualEnabled: manualEnabled, modelsManualHidden: manualHidden })); + lastKnownOverview = { + ...(lastKnownOverview ?? next), + modelsManualEnabled: manualEnabled, + modelsManualHidden: manualHidden, + }; } } catch { setState((prev) => ({ ...prev, loading: false })); + if (lastKnownOverview) lastKnownOverview = { ...lastKnownOverview, loading: false }; } }, []); diff --git a/src/lib/plugin-tab-counts.ts b/src/lib/plugin-tab-counts.ts new file mode 100644 index 000000000..8898072ac --- /dev/null +++ b/src/lib/plugin-tab-counts.ts @@ -0,0 +1,117 @@ +/** + * Lightweight /plugins tab-count helpers. + * + * The unified ExtensionsPage only mounts the active manager, so Skills / + * MCP / CLI pills would otherwise stay blank until the user visits each + * tab. These GETs reuse the same endpoints the managers already hit and + * cache the last known totals so a revisit paints immediately. + */ + +import { BUILTIN_MCP_CATALOG } from "@/lib/builtin-mcp-catalog"; + +export const PLUGIN_COUNTS_STORAGE_KEY = "codepilot.plugins.counts"; + +export type PluginTabCounts = { + skills?: number; + mcp?: number; + cli?: number; +}; + +let memoryCache: PluginTabCounts = {}; + +function isFiniteCount(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value >= 0; +} + +function sanitizeCounts(raw: unknown): PluginTabCounts { + if (!raw || typeof raw !== "object") return {}; + const src = raw as Record; + const next: PluginTabCounts = {}; + if (isFiniteCount(src.skills)) next.skills = src.skills; + if (isFiniteCount(src.mcp)) next.mcp = src.mcp; + if (isFiniteCount(src.cli)) next.cli = src.cli; + return next; +} + +function persistCache() { + if (typeof sessionStorage === "undefined") return; + try { + sessionStorage.setItem(PLUGIN_COUNTS_STORAGE_KEY, JSON.stringify(memoryCache)); + } catch { + // quota / private mode — memory cache still works for this session + } +} + +export function hydratePluginCountsFromSession(): PluginTabCounts { + if (typeof sessionStorage === "undefined") return { ...memoryCache }; + try { + const raw = sessionStorage.getItem(PLUGIN_COUNTS_STORAGE_KEY); + if (raw) { + memoryCache = { ...sanitizeCounts(JSON.parse(raw)), ...memoryCache }; + } + } catch { + // ignore malformed cache + } + return { ...memoryCache }; +} + +export function readCachedPluginCounts(): PluginTabCounts { + return { ...memoryCache }; +} + +export function writeCachedPluginCounts(partial: PluginTabCounts): PluginTabCounts { + memoryCache = { ...memoryCache, ...sanitizeCounts(partial) }; + persistCache(); + return { ...memoryCache }; +} + +export async function fetchSkillsCount(opts?: { + cwd?: string; + sessionId?: string; +}): Promise { + const params = new URLSearchParams(); + if (opts?.cwd) params.set("cwd", opts.cwd); + if (opts?.sessionId) params.set("sessionId", opts.sessionId); + const qs = params.toString(); + const res = await fetch(`/api/skills${qs ? `?${qs}` : ""}`); + if (!res.ok) return undefined; + const data = await res.json(); + return Array.isArray(data?.skills) ? data.skills.length : undefined; +} + +export async function fetchMcpCount(): Promise { + const res = await fetch("/api/plugins/mcp"); + if (!res.ok) return undefined; + const data = await res.json(); + if (!data?.mcpServers || typeof data.mcpServers !== "object") return undefined; + return BUILTIN_MCP_CATALOG.length + Object.keys(data.mcpServers).length; +} + +export async function fetchCliCount(): Promise { + const res = await fetch("/api/cli-tools/installed"); + if (!res.ok) return undefined; + const data = await res.json(); + const tools = Array.isArray(data?.tools) ? data.tools : []; + const extra = Array.isArray(data?.extra) ? data.extra : []; + const custom = Array.isArray(data?.custom) ? data.custom : []; + const catalogInstalled = tools.filter( + (t: { status?: string }) => t && t.status !== "not_installed", + ).length; + return catalogInstalled + extra.length + custom.length; +} + +export async function prefetchPluginTabCounts(opts?: { + cwd?: string; + sessionId?: string; +}): Promise { + const [skills, mcp, cli] = await Promise.all([ + fetchSkillsCount(opts).catch(() => undefined), + fetchMcpCount().catch(() => undefined), + fetchCliCount().catch(() => undefined), + ]); + const next: PluginTabCounts = {}; + if (isFiniteCount(skills)) next.skills = skills; + if (isFiniteCount(mcp)) next.mcp = mcp; + if (isFiniteCount(cli)) next.cli = cli; + return writeCachedPluginCounts(next); +}