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
46 changes: 34 additions & 12 deletions src/components/extensions/new-skills-dialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,6 @@ import { Download, Loader2, Package } from "lucide-react";
import { useEffect, useRef, useState } from "react";
import { ScopeTargetField } from "@/components/shared/scope-target-field";
import { useFocusTrap } from "@/hooks/use-focus-trap";
import { useScope } from "@/hooks/use-scope";
import type { ConfigScope, NewRepoSkill } from "@/lib/types";
import { agentDisplayName, sortAgents } from "@/lib/types";
import { useAgentStore } from "@/stores/agent-store";
Expand Down Expand Up @@ -32,13 +31,10 @@ export function NewSkillsDialog({
);
const [selectedAgents, setSelectedAgents] = useState<Set<string>>(new Set());
const [installing, setInstalling] = useState(false);
const { scope, isAll } = useScope();
// Single-scope mode: the active scope is the install target. All-scopes
// mode: user must pick via ScopeTargetField (null until picked).
// The dialog appears unexpectedly after Check Updates discovery, so the
// active UI scope is not necessarily where the user wants these skills.
// Always require an explicit pick (no implicit "use current scope").
const [pickedScope, setPickedScope] = useState<ConfigScope | null>(null);
const effectiveTarget: ConfigScope | null = isAll
? pickedScope
: (scope as ConfigScope);

const agents = useAgentStore((s) => s.agents);
const agentOrder = useAgentStore((s) => s.agentOrder);
Expand Down Expand Up @@ -105,8 +101,19 @@ export function NewSkillsDialog({
}
};

const allSkillsSelected =
skills.length > 0 && selected.size === skills.length;

const toggleAllSkills = () => {
if (allSkillsSelected) {
setSelected(new Set());
} else {
setSelected(new Set(skills.map((s) => `${s.repo_url}::${s.skill_id}`)));
}
};

const handleInstall = async () => {
if (!effectiveTarget) return;
if (!pickedScope) return;
setInstalling(true);
try {
const targetAgents = [...selectedAgents];
Expand All @@ -116,7 +123,7 @@ export function NewSkillsDialog({
);
if (selectedSkills.length === 0) continue;
const skillIds = selectedSkills.map((s) => s.skill_id);
await onInstall(url, skillIds, targetAgents, effectiveTarget);
await onInstall(url, skillIds, targetAgents, pickedScope);
}
onClose();
} catch (e: unknown) {
Expand All @@ -129,7 +136,7 @@ export function NewSkillsDialog({

const selectedCount = selected.size;
const canInstall =
selectedCount > 0 && selectedAgents.size > 0 && effectiveTarget !== null;
selectedCount > 0 && selectedAgents.size > 0 && pickedScope !== null;

return (
<div
Expand Down Expand Up @@ -160,6 +167,17 @@ export function NewSkillsDialog({
</div>
</div>

{/* Select All — toggle all skills across all repo groups */}
<label className="mb-2 flex items-center gap-1.5 text-xs font-medium text-foreground cursor-pointer">
<input
type="checkbox"
checked={allSkillsSelected}
onChange={toggleAllSkills}
className="rounded border-border accent-primary"
/>
Select All ({skills.length})
</label>

{/* Skill list grouped by repo */}
<div className="space-y-4">
{[...grouped].map(([url, group]) => (
Expand Down Expand Up @@ -199,9 +217,13 @@ export function NewSkillsDialog({
))}
</div>

{/* Scope picker (All-scopes mode) / scope hint (single-scope mode) */}
{/* Scope picker — always shown, no implicit "use current scope" */}
<div className="mt-4">
<ScopeTargetField value={effectiveTarget} onChange={setPickedScope} />
<ScopeTargetField
value={pickedScope}
onChange={setPickedScope}
alwaysPick
/>
</div>

{/* Agent selection */}
Expand Down
8 changes: 7 additions & 1 deletion src/components/shared/scope-target-field.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -13,18 +13,24 @@ interface ScopeTargetFieldProps {
onChange: (scope: ConfigScope | null) => void;
/** Optional smart default to suggest in All-scopes mode. */
smartDefault?: ConfigScope;
/** When true, always render the picker — even in single-scope mode.
* Used by NewSkillsDialog where the dialog appears unexpectedly
* (post Check Updates discovery) and the active UI scope is not
* necessarily where the user wants the new skills installed. */
alwaysPick?: boolean;
}

export function ScopeTargetField({
value,
onChange,
smartDefault,
alwaysPick = false,
}: ScopeTargetFieldProps) {
const { scope } = useScope();
const projects = useProjectStore((s) => s.projects);

// Single-scope mode: render a static hint, no picker
if (scope.type !== "all") {
if (scope.type !== "all" && !alwaysPick) {
return (
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<Folder size={11} />
Expand Down
28 changes: 28 additions & 0 deletions src/lib/__tests__/scope-target-field.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,34 @@ describe("ScopeTargetField", () => {
expect(onChange).toHaveBeenCalledWith({ type: "global" });
});

it("renders the picker (not the hint) in single-scope mode when alwaysPick is set", () => {
useScopeStore.setState({
current: { type: "project", name: "alpha", path: "/p/alpha" },
hydrated: true,
});
useProjectStore.setState({
projects: [
{
id: "alpha",
name: "alpha",
path: "/p/alpha",
created_at: "",
exists: true,
},
],
loading: false,
loaded: true,
});
render(
wrap(<ScopeTargetField value={null} onChange={() => {}} alwaysPick />),
);
const select = screen.getByLabelText(
/install to scope/i,
) as HTMLSelectElement;
expect(select).toBeTruthy();
expect(select.value).toBe("");
});

it("shows smart-default 'Use X' shortcut when value is null and smartDefault provided", () => {
useScopeStore.setState({ current: { type: "all" }, hydrated: true });
useProjectStore.setState({
Expand Down
Loading