Skip to content
Closed
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
21 changes: 6 additions & 15 deletions src/__tests__/unit/settings-link-migration.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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/<section>; 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["']/);
});
});
9 changes: 4 additions & 5 deletions src/__tests__/unit/settings-routes-shape.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)', () => {
Expand Down
99 changes: 73 additions & 26 deletions src/app/plugins/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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<PluginTabCounts>(
() => readCachedPluginCounts(),
);
const [liveCounts, setLiveCounts] = useState<PluginTabCounts>({});
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<number | undefined>(undefined);
const [mcpCount, setMcpCount] = useState<number | undefined>(undefined);
const [cliCount, setCliCount] = useState<number | undefined>(undefined);
const handleSkillsCounts = (counts: Record<SkillSource, number>) => {
// 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<SkillSource, number>) => {
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<PluginFilter, number | undefined> = {
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
Expand Down Expand Up @@ -184,14 +237,10 @@ export default function ExtensionsPage() {
);
}
if (filter === "mcp") {
return <McpManager ref={mcpRef} variant="embedded" onCountChange={setMcpCount} search={search} />;
return <McpManager ref={mcpRef} variant="embedded" onCountChange={handleMcpCount} search={search} />;
}
return <CliToolsManager ref={cliRef} variant="embedded" onCountChange={setCliCount} search={search} />;
// 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 <CliToolsManager ref={cliRef} variant="embedded" onCountChange={handleCliCount} search={search} />;
}, [filter, cwd, activeSessionId, search, handleSkillsCounts, handleMcpCount, handleCliCount]);

return (
<div className="flex h-full flex-col">
Expand All @@ -216,11 +265,9 @@ export default function ExtensionsPage() {
<TabsTrigger key={key} value={key}>
<CodePilotIcon name={meta.icon} size="md" className="text-inherit" aria-hidden />
{t(meta.labelKey)}
{typeof count === "number" && (
<span className="tabular-nums text-xs text-muted-foreground">
{count}
</span>
)}
<span className="inline-block min-w-[1.5rem] text-center tabular-nums text-xs text-muted-foreground">
{typeof count === "number" ? count : "–"}
</span>
</TabsTrigger>
);
})}
Expand Down
54 changes: 12 additions & 42 deletions src/app/settings/page.tsx
Original file line number Diff line number Diff line change
@@ -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/<hash>
* - 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/<section>`.
*/

import { useEffect } from "react";
import { useRouter } from "next/navigation";

const SECTION_HASH_TO_PATH: Record<string, string> = {
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");
}
45 changes: 43 additions & 2 deletions src/components/settings/OverviewSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -141,8 +141,9 @@ export function OverviewSection() {
{t("settings.overviewDesc" as TranslationKey)}
</p>
</div>
<div className="rounded-lg border border-dashed border-border/50 bg-card/50 p-10 text-center">
<p className="text-xs text-muted-foreground">{isZh ? "加载中…" : "Loading…"}</p>
<div aria-busy="true" aria-live="polite" className="space-y-8">
<span className="sr-only">{t("common.loading")}</span>
<OverviewDashboardSkeleton />
</div>
</div>
);
Expand Down Expand Up @@ -390,3 +391,43 @@ export function OverviewSection() {
</div>
);
}


/**
* 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 (
<>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4" aria-hidden>
{Array.from({ length: 6 }, (_, i) => (
<div
key={i}
className="rounded-lg border border-border/50 bg-card p-5 flex flex-col gap-3 h-full min-h-[9.5rem] animate-pulse"
>
<div className="flex items-center gap-2">
<div className="size-4 rounded bg-muted" />
<div className="h-4 w-24 rounded bg-muted" />
</div>
<div className="space-y-1.5 flex-1">
<div className="h-3 w-3/4 rounded bg-muted/70" />
<div className="h-3 w-1/2 rounded bg-muted/70" />
</div>
<div className="h-7 w-28 rounded bg-muted/60" />
</div>
))}
</div>
<div
className="rounded-lg border border-border/50 bg-card p-5 min-h-64 animate-pulse"
aria-hidden
>
<div className="h-4 w-40 rounded bg-muted" />
<div className="mt-1.5 h-3 w-56 rounded bg-muted/70" />
<div className="mt-4 h-40 rounded bg-muted/40" />
</div>
</>
);
}
12 changes: 11 additions & 1 deletion src/components/settings/useOverviewData.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -114,7 +117,7 @@ const initialState: OverviewState = {
};

export function useOverviewData(): OverviewState {
const [state, setState] = useState<OverviewState>(initialState);
const [state, setState] = useState<OverviewState>(() => lastKnownOverview ?? initialState);

const fetchAll = useCallback(async () => {
try {
Expand Down Expand Up @@ -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
Expand All @@ -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 };
}
}, []);

Expand Down
Loading
Loading