diff --git a/CHANGELOG.md b/CHANGELOG.md
index bfe9920..ee5096a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,7 @@ version tags such as `v0.3.0`.
## Unreleased
+- Move User/Remote classification for collection imports into one collection-header control that applies to all actionable pending children, blocks mixed-type collection requests until explicitly resolved, and preserves per-child selection and import requests.
- Add collection-level Import Review selection that selects or clears every eligible child in one collection without changing blocked, imported, system, conflicted, or unresolved-type children.
- Recognize copied installer skills as one Installed Source Collection when their
valid v3 GitHub provenance uses a shared package-level `pluginName`.
diff --git a/apps/desktop/src/App.import-candidates.test.js b/apps/desktop/src/App.import-candidates.test.js
index 95e9d4f..7a16985 100644
--- a/apps/desktop/src/App.import-candidates.test.js
+++ b/apps/desktop/src/App.import-candidates.test.js
@@ -9,8 +9,10 @@ import {
collectionEligibleGroupIds,
collectionSelectionState,
collectionSkillCountLabel,
+ collectionTypeChoiceState,
importCandidateGroupLocationCount,
importCandidateGroupTabs,
+ importReviewSelectableGroups,
normalizeGithubSkillCollectionPreviewResult,
normalizeImportCollections,
normalizeImportCandidateGroup,
@@ -19,9 +21,12 @@ import {
selectedImportCandidates,
selectedImportCollectionRequests,
selectImportCandidateVariant,
+ toggleImportCandidateGroup,
toggleImportCandidateGroupSelection,
toggleImportCollectionSelection,
+ toggleImportReviewSelection,
updateImportCandidateGroupType,
+ updateImportCollectionType,
visibleImportCandidates,
workspaceSkillTabs
} from './importCandidates.js';
@@ -468,7 +473,7 @@ test('toggles every eligible child in one complete collection without changing b
variants: [{
id: 'variant-beta',
candidate: { name: 'beta', import_status: 'importable', is_selected: true },
- selected_type: 'remote'
+ selected_type: 'user'
}]
},
{
@@ -519,7 +524,6 @@ test('toggles every eligible child in one complete collection without changing b
children: [
{ group_id: 'skill-alpha', variant_id: 'variant-alpha', import_status: 'importable' },
{ group_id: 'skill-beta', variant_id: 'variant-beta', import_status: 'importable' },
- { group_id: 'skill-needs-type', variant_id: 'variant-needs-type', import_status: 'importable', requires_type_review: true },
{ group_id: 'skill-conflict', variant_id: 'variant-conflict', import_status: 'importable', conflict: 'managed target' },
{ group_id: 'skill-imported', variant_id: 'variant-imported', import_status: 'imported' }
]
@@ -558,7 +562,7 @@ test('collection selection uses the complete collection rather than a filtered c
{
id: 'skill-hidden-by-filter',
selected_variant_id: 'variant-hidden',
- variants: [{ id: 'variant-hidden', candidate: { name: 'hidden', import_status: 'importable', is_selected: false }, selected_type: 'remote' }]
+ variants: [{ id: 'variant-hidden', candidate: { name: 'hidden', import_status: 'importable', is_selected: false }, selected_type: 'user' }]
},
{
id: 'skill-standalone',
@@ -581,6 +585,227 @@ test('collection selection uses the complete collection rather than a filtered c
assert.equal(collectionSelectionState(toggled, completeCollection).selectedCount, 2);
});
+test('collection type choice resolves and selects every actionable child without changing excluded or unrelated groups', () => {
+ const groups = normalizeImportCandidateGroups([
+ {
+ id: 'skill-unresolved',
+ name: 'unresolved',
+ selected_variant_id: 'variant-unresolved',
+ variants: [{
+ id: 'variant-unresolved',
+ candidate: { name: 'unresolved', import_status: 'importable', is_selected: false },
+ requires_type_review: true,
+ selected_type: null
+ }]
+ },
+ {
+ id: 'skill-explicit',
+ name: 'explicit',
+ selected_variant_id: 'variant-explicit',
+ variants: [{
+ id: 'variant-explicit',
+ candidate: { name: 'explicit', import_status: 'importable', is_selected: false },
+ selected_type: 'remote'
+ }]
+ },
+ {
+ id: 'skill-imported',
+ name: 'imported',
+ selected_variant_id: 'variant-imported',
+ variants: [{
+ id: 'variant-imported',
+ candidate: { name: 'imported', import_status: 'imported', is_selected: false },
+ selected_type: 'remote'
+ }]
+ },
+ {
+ id: 'skill-system',
+ name: 'system',
+ selected_variant_id: 'variant-system',
+ variants: [{
+ id: 'variant-system',
+ candidate: { name: 'system', import_status: 'system', is_selected: false },
+ selected_type: null
+ }]
+ },
+ {
+ id: 'skill-conflict',
+ name: 'conflict',
+ selected_variant_id: 'variant-conflict',
+ variants: [{
+ id: 'variant-conflict',
+ candidate: { name: 'conflict', import_status: 'importable', conflict: 'managed target', is_selected: false },
+ selected_type: 'user'
+ }]
+ },
+ {
+ id: 'skill-other-collection',
+ name: 'other',
+ selected_variant_id: 'variant-other',
+ variants: [{
+ id: 'variant-other',
+ candidate: { name: 'other', import_status: 'importable', is_selected: false },
+ selected_type: 'user'
+ }]
+ },
+ {
+ id: 'skill-standalone',
+ name: 'standalone',
+ selected_variant_id: 'variant-standalone',
+ variants: [{
+ id: 'variant-standalone',
+ candidate: { name: 'standalone', import_status: 'importable', is_selected: false },
+ selected_type: 'remote'
+ }]
+ }
+ ]);
+ const collection = normalizeImportCollections([{
+ id: 'collection-one',
+ source_kind: 'git_worktree',
+ preview_id: 'preview-one',
+ canonical_worktree_root: '/Users/example/collection-one',
+ children: [
+ { group_id: 'skill-unresolved', variant_id: 'variant-unresolved', relative_path: 'skills/unresolved', import_status: 'importable', requires_type_review: true },
+ { group_id: 'skill-explicit', variant_id: 'variant-explicit', relative_path: 'skills/explicit', import_status: 'importable', selected_type: 'remote' },
+ { group_id: 'skill-imported', variant_id: 'variant-imported', relative_path: 'skills/imported', import_status: 'imported', selected_type: 'user' },
+ { group_id: 'skill-system', variant_id: 'variant-system', relative_path: 'skills/system', import_status: 'system' },
+ { group_id: 'skill-conflict', variant_id: 'variant-conflict', relative_path: 'skills/conflict', import_status: 'importable', conflict: 'managed target' }
+ ]
+ }])[0];
+
+ assert.deepEqual(collectionTypeChoiceState(groups, collection), {
+ actionableGroupIds: new Set(['skill-unresolved', 'skill-explicit']),
+ actionableCount: 2,
+ selectedType: null,
+ required: true
+ });
+ const mixedExplicitGroups = toggleImportCandidateGroup(
+ updateImportCandidateGroupType(groups, 'skill-unresolved', 'user'),
+ 'skill-explicit'
+ );
+ assert.deepEqual(collectionTypeChoiceState(mixedExplicitGroups, collection), {
+ actionableGroupIds: new Set(['skill-unresolved', 'skill-explicit']),
+ actionableCount: 2,
+ selectedType: null,
+ required: true
+ });
+ assert.deepEqual(collectionSelectionState(mixedExplicitGroups, collection), {
+ eligibleGroupIds: new Set(),
+ eligibleCount: 0,
+ selectedCount: 0,
+ allSelected: false,
+ indeterminate: false
+ });
+ assert.deepEqual(selectedImportCollectionRequests(mixedExplicitGroups, [collection]), []);
+ assert.deepEqual(
+ importReviewSelectableGroups(mixedExplicitGroups, [collection]).map((group) => group.id),
+ ['skill-other-collection', 'skill-standalone']
+ );
+ const toggledReview = toggleImportReviewSelection(mixedExplicitGroups, [collection]);
+ assert.equal(toggledReview[0].isSelected, true);
+ assert.equal(toggledReview[1].isSelected, true);
+ assert.equal(toggledReview[5].isSelected, true);
+ assert.equal(toggledReview[6].isSelected, true);
+ assert.deepEqual(selectedImportCandidates(mixedExplicitGroups, [{
+ ...collection,
+ sourceKind: 'installed_source'
+ }]), []);
+
+ const asUser = updateImportCollectionType(groups, collection, 'user');
+ assert.deepEqual(asUser.slice(0, 2).map((group) => ({
+ selected: group.isSelected,
+ type: group.variants[0].selectedType
+ })), [
+ { selected: true, type: 'user' },
+ { selected: true, type: 'user' }
+ ]);
+ assert.deepEqual(asUser.slice(2).map((group) => ({
+ selected: group.isSelected,
+ type: group.variants[0].selectedType
+ })), [
+ { selected: false, type: 'remote' },
+ { selected: false, type: 'user' },
+ { selected: false, type: 'user' },
+ { selected: false, type: 'user' },
+ { selected: false, type: 'remote' }
+ ]);
+ assert.deepEqual(collectionTypeChoiceState(asUser, collection), {
+ actionableGroupIds: new Set(['skill-unresolved', 'skill-explicit']),
+ actionableCount: 2,
+ selectedType: 'user',
+ required: false
+ });
+ assert.deepEqual(collectionSelectionState(asUser, collection), {
+ eligibleGroupIds: new Set(['skill-unresolved', 'skill-explicit']),
+ eligibleCount: 2,
+ selectedCount: 2,
+ allSelected: true,
+ indeterminate: false
+ });
+
+ const manuallyDeselected = toggleImportCandidateGroup(asUser, 'skill-unresolved');
+ assert.equal(collectionTypeChoiceState(manuallyDeselected, collection).selectedType, 'user');
+ assert.equal(collectionSelectionState(manuallyDeselected, collection).selectedCount, 1);
+
+ const asRemote = updateImportCollectionType(manuallyDeselected, collection, 'remote');
+ assert.deepEqual(asRemote.slice(0, 2).map((group) => ({
+ selected: group.isSelected,
+ type: group.variants[0].selectedType
+ })), [
+ { selected: true, type: 'remote' },
+ { selected: true, type: 'remote' }
+ ]);
+ assert.deepEqual(selectedImportCollectionRequests(asRemote, [collection])[0].selections, [
+ { relativePath: 'skills/unresolved', groupId: 'skill-unresolved', variantId: 'variant-unresolved', skillType: 'remote' },
+ { relativePath: 'skills/explicit', groupId: 'skill-explicit', variantId: 'variant-explicit', skillType: 'remote' }
+ ]);
+});
+
+test('collection type choice uses the complete collection and preserves Rust managed child types', () => {
+ const groups = normalizeImportCandidateGroups([
+ {
+ id: 'skill-visible',
+ selected_variant_id: 'variant-visible',
+ variants: [{ id: 'variant-visible', candidate: { name: 'visible', import_status: 'importable', is_selected: false }, selected_type: null }]
+ },
+ {
+ id: 'skill-hidden',
+ selected_variant_id: 'variant-hidden',
+ variants: [{ id: 'variant-hidden', candidate: { name: 'hidden', import_status: 'importable', is_selected: false }, selected_type: null }]
+ },
+ {
+ id: 'skill-imported',
+ selected_variant_id: 'variant-imported',
+ variants: [{ id: 'variant-imported', candidate: { name: 'imported', import_status: 'imported', is_selected: false }, selected_type: 'remote' }]
+ }
+ ]);
+ const completeCollection = normalizeImportCollections([{
+ id: 'collection-filtered',
+ children: [
+ { group_id: 'skill-visible', variant_id: 'variant-visible', import_status: 'importable' },
+ { group_id: 'skill-hidden', variant_id: 'variant-hidden', import_status: 'importable' },
+ { group_id: 'skill-imported', variant_id: 'variant-imported', import_status: 'imported', selected_type: 'user' }
+ ]
+ }])[0];
+
+ const updated = updateImportCollectionType(groups, completeCollection, 'remote');
+ assert.deepEqual(updated.slice(0, 2).map((group) => group.variants[0].selectedType), ['remote', 'remote']);
+ assert.deepEqual(updated.slice(0, 2).map((group) => group.isSelected), [true, true]);
+ assert.equal(updated[2].variants[0].selectedType, 'remote');
+
+ const importedState = collectionChildTypeState(updated[2], completeCollection.children[2]);
+ assert.equal(importedState.childType, 'user');
+ assert.equal(importedState.readOnlyLabel, 'Managed as User');
+ assert.deepEqual(collectionTypeChoiceState([], {
+ children: [completeCollection.children[2]]
+ }), {
+ actionableGroupIds: new Set(),
+ actionableCount: 0,
+ selectedType: null,
+ required: false
+ });
+});
+
test('normalizes a GitHub collection without inventing a local worktree root', () => {
const collections = normalizeImportCollections([{
id: 'github-collection-demo',
@@ -758,10 +983,8 @@ test('collection child type state keeps mixed importable review actionable and b
assert.equal(mixedState.canSelect, false);
assert.equal(mixedState.needsTypeChoice, true);
- const resolvedState = collectionChildTypeState(mixedGroup, {
- ...mixedChild,
- selectedType: 'remote'
- });
+ const resolvedGroup = updateImportCandidateGroupType([mixedGroup], 'skill-mixed', 'remote')[0];
+ const resolvedState = collectionChildTypeState(resolvedGroup, mixedChild);
assert.equal(resolvedState.canSelect, true);
assert.equal(resolvedState.needsTypeChoice, false);
diff --git a/apps/desktop/src/App.jsx b/apps/desktop/src/App.jsx
index 89cfe54..0cfcc5f 100644
--- a/apps/desktop/src/App.jsx
+++ b/apps/desktop/src/App.jsx
@@ -57,8 +57,9 @@ import {
selectImportCandidateVariant,
toggleImportCollectionSelection,
toggleImportCandidateGroup,
- toggleImportCandidateGroupSelection,
- updateImportCandidateGroupType
+ toggleImportReviewSelection,
+ updateImportCandidateGroupType,
+ updateImportCollectionType
} from './importCandidates.js';
import {
browserImportScanOptions,
@@ -1465,7 +1466,7 @@ export default function App() {
function toggleAllImportCandidates() {
setImportReview((current) => ({
...current,
- candidates: toggleImportCandidateGroupSelection(current.candidates)
+ candidates: toggleImportReviewSelection(current.candidates, current.collections)
}));
}
@@ -4537,6 +4538,12 @@ export default function App() {
candidates: toggleImportCollectionSelection(current.candidates, collection)
}))
}
+ onCollectionTypeChange={(collection, skillType) =>
+ setImportReview((current) => ({
+ ...current,
+ candidates: updateImportCollectionType(current.candidates, collection, skillType)
+ }))
+ }
onToggleSelected={(group) =>
updateImportCandidateGroup(group.id, (groups) => toggleImportCandidateGroup(groups, group.id))
}
diff --git a/apps/desktop/src/cardLayout.test.js b/apps/desktop/src/cardLayout.test.js
index 6b29b09..e792c66 100644
--- a/apps/desktop/src/cardLayout.test.js
+++ b/apps/desktop/src/cardLayout.test.js
@@ -544,7 +544,7 @@ test('import review opens before scanning and exposes staged accessible progress
assert.match(progressSource, /importScanProgressDetail/);
});
-test('collection review keeps child selection and type controls inside one expandable card', () => {
+test('collection review keeps child selection and collection type controls inside one expandable card', () => {
const collectionSource = appSource.match(
/function CollectionReviewCard\(\{(?
[\s\S]*?)\n\}\n\nfunction WorkspaceSkillTabs/
)?.groups.body || '';
@@ -557,28 +557,58 @@ test('collection review keeps child selection and type controls inside one expan
const collectionRule = css.match(/\.collectionReviewCard\s*\{(?[^}]*)\}/s)?.groups.body || '';
const childRule = css.match(/\.collectionChildRow\s*\{(?[^}]*)\}/s)?.groups.body || '';
const collectionActionsRule = css.match(/\.collectionReviewActions\s*\{(?[^}]*)\}/s)?.groups.body || '';
+ const collectionTypeRule = css.match(/\.collectionReviewTypeAction\s*\{(?[^}]*)\}/s)?.groups.body || '';
+ const requiredCollectionTypeRule = css.match(
+ /\.collectionReviewTypeAction\.required\s*\{(?[^}]*)\}/s
+ )?.groups.body || '';
assert.match(collectionSource, /aria-controls={disclosureId}/);
assert.match(collectionSource, /collection\.children\.map/);
assert.match(collectionSource, /collectionSelectionState\(groups, selectionCollection\)/);
+ assert.match(collectionSource, /collectionTypeChoiceState\(groups, selectionCollection\)/);
assert.match(collectionSource, /Import as/);
+ assert.match(collectionSource, /Required<\/strong>/);
+ assert.match(collectionSource, /Choose one type for every pending skill in this collection/);
+ assert.match(collectionSource, /aria-describedby=\{typeState\.required \? typeHelpId : undefined\}/);
+ assert.match(collectionSource, /aria-labelledby=\{typeLabelId\}/);
+ assert.match(collectionSource, /aria-required=\{typeState\.required\}/);
+ assert.match(collectionSource, /role="radiogroup"/);
+ assert.match(collectionSource, /role="radio"/);
+ assert.match(collectionSource, /aria-checked=\{typeState\.selectedType === 'user'\}/);
+ assert.match(collectionSource, /aria-checked=\{typeState\.selectedType === 'remote'\}/);
+ assert.match(collectionSource, /onCollectionTypeChange\(selectionCollection, 'user'\)/);
+ assert.match(collectionSource, /onCollectionTypeChange\(selectionCollection, 'remote'\)/);
+ assert.match(collectionSource, /typeState\.actionableCount > 0/);
+ assert.doesNotMatch(collectionSource, /No pending skills/);
+ assert.match(collectionSource, /const selected = !typeState\.required/);
+ assert.match(collectionSource, /disabled=\{!canSelect \|\| typeState\.required \|\| selectionDisabled\}/);
+ assert.match(appSource, /const selectableCount = importReviewSelectableGroups\(groups, collections\)\.length/);
+ assert.match(appSource, /selectedImportCollectionRequests\(groups, collections\)/);
assert.match(collectionCheckboxSource, /checkboxRef\.current\.indeterminate = indeterminate/);
assert.match(collectionCheckboxSource, /aria-label=\{`Select all eligible skills in \$\{collectionName\}`\}/);
assert.match(collectionCheckboxSource, /aria-describedby=\{ariaDescribedBy\}/);
assert.match(collectionCheckboxSource, /disabled=\{disabled\}/);
assert.match(candidateReviewListSource, /selectionCollection=\{collections\.find/);
assert.match(candidateReviewListSource, /onToggleCollectionSelected/);
+ assert.match(candidateReviewListSource, /onCollectionTypeChange/);
+ assert.match(appSource, /updateImportCollectionType\(current\.candidates, collection, skillType\)/);
+ assert.match(appComponentSource, /toggleImportReviewSelection\(current\.candidates, current\.collections\)/);
assert.match(collectionSource, /onToggleSelected\(group\)/);
- assert.match(collectionSource, /onTypeChange\(group, 'user'\)/);
- assert.match(collectionSource, /onTypeChange\(group, 'remote'\)/);
- assert.match(collectionSource, /collectionChildTypeState\(group, child\)/);
assert.match(collectionSource, /collectionChildTypeState\(group, child\)/);
assert.match(collectionSource, /collection\.sourceKind === 'installed_source'/);
assert.doesNotMatch(collectionSource, /disabled=\{!canClassifyImportCandidateGroup\(group\)\}/);
+ assert.doesNotMatch(collectionSource, /aria-label=\{`\$\{child\.name\} skill type`\}/);
+ assert.doesNotMatch(collectionSource, /onTypeChange\(group/);
+ assert.match(collectionSource, /readOnlyLabel \?/);
assert.match(collectionSource, /relativePath/);
assert.match(collectionRule, /min-width:\s*0;/);
assert.match(collectionActionsRule, /display:\s*flex;/);
+ assert.match(collectionTypeRule, /width:\s*248px;/);
+ assert.match(collectionTypeRule, /box-sizing:\s*border-box;/);
+ assert.match(requiredCollectionTypeRule, /border-color:\s*var\(--skillbox-amber-border\);/);
+ assert.match(requiredCollectionTypeRule, /background:\s*var\(--skillbox-surface-orange\);/);
assert.match(css, /\.candidateCheck input:indeterminate \+ span/);
assert.match(childRule, /grid-template-columns:\s*28px minmax\(0,\s*1fr\) auto;/);
});
diff --git a/apps/desktop/src/components/importReview.jsx b/apps/desktop/src/components/importReview.jsx
index 1711358..825e290 100644
--- a/apps/desktop/src/components/importReview.jsx
+++ b/apps/desktop/src/components/importReview.jsx
@@ -7,6 +7,7 @@ import {
collectionChildTypeState,
collectionSelectionState,
collectionSkillCountLabel,
+ collectionTypeChoiceState,
filterImportCandidateGroups,
filterImportCandidateGroupsByQuery,
filterImportCollectionsByQuery,
@@ -14,9 +15,12 @@ import {
importCandidateGroupLocationCount,
importCandidateGroupStatus,
importCandidateGroupTabs,
+ importReviewSelectableGroups,
isSelectableImportCandidateGroup,
selectedImportCandidate,
- selectedImportCandidateVariant
+ selectedImportCandidateVariant,
+ selectedImportCandidates,
+ selectedImportCollectionRequests
} from '../importCandidates.js';
import {
candidateImportSourcePaths,
@@ -209,6 +213,7 @@ export function ImportReview({
scanProgress = null,
onClose,
onImport,
+ onCollectionTypeChange,
onRetry,
onToggleAll,
onToggleCollectionSelected,
@@ -219,10 +224,10 @@ export function ImportReview({
subtitle = 'Confirm each skill type before SkillBox copies it into the managed store.',
title = 'Import Review'
}) {
- const selectableCount = groups.filter(isSelectableImportCandidateGroup).length;
- const selectedCount = groups.filter(
- (group) => group.isSelected && isSelectableImportCandidateGroup(group)
- ).length;
+ const selectableCount = importReviewSelectableGroups(groups, collections).length;
+ const selectedCount = selectedImportCandidates(groups, collections).length
+ + selectedImportCollectionRequests(groups, collections)
+ .reduce((count, request) => count + request.selections.length, 0);
const isAllSelected = selectableCount > 0 && selectedCount === selectableCount;
return (
@@ -267,6 +272,7 @@ export function ImportReview({
candidate.id === collection.id) || collection}
selectionDisabled={selectionDisabled}
/>
@@ -440,10 +447,10 @@ function CollectionSelectionCheckbox({
function CollectionReviewCard({
collection,
groups,
+ onCollectionTypeChange,
onSelectVariant,
onToggleCollectionSelected,
onToggleSelected,
- onTypeChange,
selectionCollection,
selectionDisabled
}) {
@@ -452,7 +459,10 @@ function CollectionReviewCard({
const isGithubRemote = collection.sourceKind === 'github_remote';
const disclosureId = `${collection.id}-children`;
const selectionSummaryId = `${collection.id}-selection-summary`;
+ const typeLabelId = `${collection.id}-type-label`;
+ const typeHelpId = `${collection.id}-type-help`;
const selectionState = collectionSelectionState(groups, selectionCollection);
+ const typeState = collectionTypeChoiceState(groups, selectionCollection);
const shortSha = collection.reviewedHeadSha ? collection.reviewedHeadSha.slice(0, 8) : 'uncommitted';
return (
@@ -486,19 +496,60 @@ function CollectionReviewCard({
)}
-
onToggleCollectionSelected(selectionCollection)}
- />
-
- {selectionState.eligibleCount === 0
- ? 'No eligible skills'
- : `${selectionState.selectedCount} of ${selectionState.eligibleCount} eligible selected`}
-
+ {typeState.actionableCount > 0 ? (
+
+
+ Import as
+ {typeState.required ? Required : null}
+
+ {typeState.required ? (
+
Choose one type for every pending skill in this collection.
+ ) : null}
+
+
+
+
+
+ ) : null}
+
+ onToggleCollectionSelected(selectionCollection)}
+ />
+
+ {selectionState.eligibleCount === 0
+ ? 'No eligible skills'
+ : `${selectionState.selectedCount} of ${selectionState.eligibleCount} eligible selected`}
+
+