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
23 changes: 14 additions & 9 deletions src/components/extensions/extension-detail.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ import { useAgentStore } from "@/stores/agent-store";
import { useAuditStore } from "@/stores/audit-store";
import {
agentsInScope,
enabledAgentSet,
findCliChildren,
instancesInScope,
pickSourceInstance,
Expand Down Expand Up @@ -581,14 +582,16 @@ export function ExtensionDetail() {
{t("detail.agents")}
</h4>
<div className="flex flex-wrap gap-1">
{agentsInScope(group, scope).map((agent) => (
<span
key={agent}
className="inline-flex rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
>
{agentDisplayName(agent)}
</span>
))}
{agentsInScope(group, scope, enabledAgentSet(agents)).map(
(agent) => (
<span
key={agent}
className="inline-flex rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary"
>
{agentDisplayName(agent)}
</span>
),
)}
</div>
</div>

Expand Down Expand Up @@ -989,7 +992,9 @@ export function ExtensionDetail() {
// own manifests naming a package that is gone.
const shipped = isVendorBaseline(
group.pack,
agents.flatMap((a) => a.capabilities?.vendor_baseline_packs ?? []),
agents.flatMap(
(a) => a.capabilities?.vendor_baseline_packs ?? [],
),
);
return (
<button
Expand Down
24 changes: 18 additions & 6 deletions src/components/extensions/extension-table.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ import { useScope } from "@/hooks/use-scope";
import type { ExtensionKind, GroupedExtension } from "@/lib/types";
import { agentDisplayName, sortAgentNames } from "@/lib/types";
import { useAgentStore } from "@/stores/agent-store";
import { agentsInScope } from "@/stores/extension-helpers";
import { agentsInScope, enabledAgentSet } from "@/stores/extension-helpers";
import { useExtensionStore } from "@/stores/extension-store";
import { toast } from "@/stores/toast-store";

Expand All @@ -34,6 +34,8 @@ export function ExtensionTable({
const { t } = useTranslation("extensions");
const { t: tc } = useTranslation("common");
const agentOrder = useAgentStore((s) => s.agentOrder);
const agents = useAgentStore((s) => s.agents);
const enabledAgents = useMemo(() => enabledAgentSet(agents), [agents]);
const { scope } = useScope();
const navigate = useNavigate();
// Subscribe to trigger re-render; accessed via getState() in cell renderers
Expand All @@ -44,7 +46,8 @@ export function ExtensionTable({
// Subscribe to trigger re-render; accessed via getState() in cell renderers
useExtensionStore((s) => s.updateStatuses);
const toggle = useExtensionStore((s) => s.toggle);
// biome-ignore lint/correctness/useExhaustiveDependencies: `scope` is a trigger sentinel — cell renderers read scope-dependent filter results via getState(); listing it forces a column rebuild on scope change.
// `scope` and `enabledAgents` are in the dep list because the badge cell
// renders through them; the rest of the cell renderers read via getState().
const columns = useMemo(
() => [
col.display({
Expand Down Expand Up @@ -127,12 +130,12 @@ export function ExtensionTable({
col.accessor("agents", {
header: () => t("table.headers.agent"),
// Badges show the agents present in the ACTIVE scope (union in All
// mode) so a project view never claims a global-only agent has a
// copy here. Group identity/`agents` stays the full union.
// mode) and still switched on, matching the rule that decides which
// rows exist at all. Group identity/`agents` stays the full union.
cell: (info) => (
<div className="flex items-end gap-1">
{sortAgentNames(
agentsInScope(info.row.original, scope),
agentsInScope(info.row.original, scope, enabledAgents),
agentOrder,
).map((name) => (
<div
Expand Down Expand Up @@ -210,7 +213,16 @@ export function ExtensionTable({
],
// selectedIds, updateStatuses accessed via getState() inside cell renderers
// to avoid recomputing columns on every selection/status change.
[agentOrder, selectAll, clearSelection, toggleSelected, toggle, scope, t],
[
agentOrder,
enabledAgents,
selectAll,
clearSelection,
toggleSelected,
toggle,
scope,
t,
],
);
const sorting = useExtensionStore((s) => s.tableSorting) as SortingState;
const setStoreSorting = useExtensionStore((s) => s.setTableSorting);
Expand Down
80 changes: 47 additions & 33 deletions src/pages/audit.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -18,15 +18,16 @@ import { TrustBadge } from "@/components/shared/trust-badge";
import { useScope } from "@/hooks/use-scope";
import { api } from "@/lib/invoke";
import type { ConfigScope, Extension } from "@/lib/types";
import {
extensionGroupKey,
formatRelativeTime,
type TrustTier,
trustTier,
} from "@/lib/types";
import { formatRelativeTime, type TrustTier, trustTier } from "@/lib/types";
import { isWeb, webSelectStyle } from "@/lib/web-select";
import { useAgentStore } from "@/stores/agent-store";
import { useAuditStore } from "@/stores/audit-store";
import { buildGroups } from "@/stores/extension-store";
import {
buildGroups,
enabledAgentSet,
groupHasEnabledAgent,
groupKeyById,
} from "@/stores/extension-store";
import { useScopeStore } from "@/stores/scope-store";
import {
AUDIT_RULES,
Expand Down Expand Up @@ -79,6 +80,7 @@ export default function AuditPage() {
const [allExtensions, setAllExtensions] = useState<Extension[]>([]);
const [extensionsReady, setExtensionsReady] = useState(false);
const { scope } = useScope();
const agents = useAgentStore((s) => s.agents);

// Close any expanded finding row when the user switches scope — the
// previously-open extension may not exist in the new scope.
Expand Down Expand Up @@ -140,15 +142,20 @@ export default function AuditPage() {
return map;
}, [allExtensions]);

// Map extension ID → groupKey for audit deduplication.
// Group by extensionGroupKey (same as extensions page).
const groupKeyMap = useMemo(() => {
const map = new Map<string, string>();
for (const ext of allExtensions) {
map.set(ext.id, extensionGroupKey(ext));
}
return map;
}, [allExtensions]);
// The rows this page is allowed to report on: the Extensions list's own
// grouping, minus groups that live only on switched-off agents.
const visibleGroups = useMemo(() => {
const enabled = enabledAgentSet(agents);
const groups = buildGroups(allExtensions);
return enabled
? groups.filter((g) => groupHasEnabledAgent(g, enabled))
: groups;
}, [allExtensions, agents]);

const groupKeyMap = useMemo(
() => groupKeyById(visibleGroups),
[visibleGroups],
);

// Map extension ID → scope (used by the scope filter on scopedResults).
const scopeMap = useMemo(() => {
Expand All @@ -159,26 +166,33 @@ export default function AuditPage() {
return map;
}, [allExtensions]);

// Apply the global scope filter to raw audit results before any other
// derivation (counts, sorting, grouping). In All-scopes mode every result
// passes; otherwise we keep only results whose extension lives in the
// selected scope.
const scopedResults = useMemo(() => {
if (scope.type === "all") return results;
return results.filter((r) => {
const extScope = scopeMap.get(r.extension_id);
if (!extScope) return false;
if (scope.type === "global") return extScope.type === "global";
return extScope.type === "project" && extScope.path === scope.path;
});
}, [results, scope, scopeMap]);
// Narrow the raw audit results once, before any other derivation (counts,
// sorting, grouping), to the ones this page reports on: the extension still
// exists and is visible, and it lives in the selected scope. Doing it here
// is what keeps the header's two numbers describing the same set — a result
// whose extension is gone has no row to attach to, and used to be counted
// anyway under its raw ID.
const scopedResults = useMemo(
() =>
results.filter((r) => {
if (!groupKeyMap.has(r.extension_id)) return false;
if (scope.type === "all") return true;
const extScope = scopeMap.get(r.extension_id);
if (!extScope) return false;
if (scope.type === "global") return extScope.type === "global";
return extScope.type === "project" && extScope.path === scope.path;
}),
[results, scope, scopeMap, groupKeyMap],
);

// Count extensions that actually have audit results
// Extensions that actually have audit results, counted the same way the
// rows below are grouped.
const totalExtensions = useMemo(() => {
const auditedIds = new Set(scopedResults.map((r) => r.extension_id));
return buildGroups(allExtensions.filter((e) => auditedIds.has(e.id)))
.length;
}, [allExtensions, scopedResults]);
return visibleGroups.filter((g) =>
g.instances.some((i) => auditedIds.has(i.id)),
).length;
}, [visibleGroups, scopedResults]);

const sortedResults = useMemo(
() =>
Expand Down
71 changes: 33 additions & 38 deletions src/pages/overview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,15 +17,15 @@ import { useNavigate } from "react-router-dom";
import { AgentCard } from "@/components/shared/agent-card";
import { api } from "@/lib/invoke";
import type { AgentDetail, DashboardStats } from "@/lib/types";
import {
agentDisplayName,
extensionGroupKey,
formatRelativeTime,
sortAgents,
} from "@/lib/types";
import { agentDisplayName, formatRelativeTime, sortAgents } from "@/lib/types";
import { useAgentStore } from "@/stores/agent-store";
import { useAuditStore } from "@/stores/audit-store";
import { buildGroups, useExtensionStore } from "@/stores/extension-store";
import {
buildGroups,
enabledAgentSet,
groupHasEnabledAgent,
useExtensionStore,
} from "@/stores/extension-store";
import { toast } from "@/stores/toast-store";

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -228,26 +228,17 @@ export default function OverviewPage() {
// Show skeleton until both extensions (fetched in App.tsx) and local data are ready.
const initialLoaded = localReady && extHasFetched;

// Filter extensions to only those belonging to enabled agents
const enabledAgentNames = useMemo(
() => new Set(agents.filter((a) => a.enabled).map((a) => a.name)),
[agents],
);
const visibleExtensions = useMemo(
() =>
extensions.filter(
(e) =>
e.agents.length === 0 ||
e.agents.some((a) => enabledAgentNames.has(a)),
),
[extensions, enabledAgentNames],
);

// Group extensions so identical skills across agents count as one
const visibleGroups = useMemo(
() => buildGroups(visibleExtensions),
[visibleExtensions],
);
// Group extensions so identical skills across agents count as one, then drop
// the groups that live only on agents the user switched off. Grouping first
// and filtering after is what the Extensions list and the Audit page do —
// same helper, same order — so the three headline counts agree.
const visibleGroups = useMemo(() => {
const enabled = enabledAgentSet(agents);
const groups = buildGroups(extensions);
return enabled
? groups.filter((g) => groupHasEnabledAgent(g, enabled))
: groups;
}, [extensions, agents]);

// Dashboard stats — derived client-side from grouped extension data
const stats = useMemo<DashboardStats | null>(() => {
Expand Down Expand Up @@ -377,36 +368,40 @@ export default function OverviewPage() {
// not the time each individual entry was added.
const accurateKinds = new Set(["skill", "plugin", "cli"]);
const seenExtNames = new Set<string>();
for (const ext of visibleExtensions) {
if (!accurateKinds.has(ext.kind)) continue;
if (seenExtNames.has(ext.name)) continue;
seenExtNames.add(ext.name);
for (const g of visibleGroups) {
const ext = g.instances[0];
if (!accurateKinds.has(g.kind)) continue;
if (seenExtNames.has(g.name)) continue;
seenExtNames.add(g.name);
items.push({
type: "extension",
kind: ext.kind,
label: ext.name,
kind: g.kind,
label: g.name,
sublabel: t("activity.extensionInstalled", {
kind: ext.kind.toUpperCase(),
time: formatRelativeTime(ext.installed_at),
kind: g.kind.toUpperCase(),
time: formatRelativeTime(g.installed_at),
}),
timestamp: new Date(ext.installed_at).getTime(),
timestamp: new Date(g.installed_at).getTime(),
// Pass scope through the URL (see config-items comment above for why
// setScope + navigate in the same handler races and loses the nav).
// The group's own key is what the Extensions list matches on — an
// `extensionGroupKey(ext)` recomputed from one instance misses the
// sibling merge buildGroups applies, and lands on nothing.
onSelect: () => {
const scopeParam =
ext.scope.type === "global"
? ""
: `&scope=${encodeURIComponent(ext.scope.path)}`;
navigate(
`/extensions?groupKey=${encodeURIComponent(extensionGroupKey(ext))}${scopeParam}`,
`/extensions?groupKey=${encodeURIComponent(g.groupKey)}${scopeParam}`,
);
},
});
}

items.sort((a, b) => b.timestamp - a.timestamp);
return items.slice(0, 20);
}, [visibleExtensions, navigate, t]);
}, [visibleGroups, navigate, t]);

const hasActivity =
agentActivityItems.length > 0 || extensionActivityItems.length > 0;
Expand Down
Loading
Loading