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
6 changes: 3 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@ version tags such as `v0.3.0`.

## Unreleased

- No unreleased changes.
- 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.

## 0.9.0

- Add one-fetch GitHub repository collection preview/apply with explicit child
selection, stale ref/tree validation, and per-skill managed provenance. This
Phase C change targets v0.9.0 and remains unreleased pending v0.9.0 release
qualification; collection-level update/rollback remains planned.
Phase C change shipped in v0.9.0; collection-level update/rollback remains
planned.
- Distinguish the shipped v0.8.0 local A+B work from the v0.9.0-targeted Phase C
implementation in the public roadmap and implementation status.
- Add one-fetch GitHub multi-skill collection preview/apply for repository and tree URLs.
Expand Down
135 changes: 135 additions & 0 deletions apps/desktop/src/App.import-candidates.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import {
filterImportCandidatesByQuery,
filterWorkspaceSkillCandidates,
collectionChildTypeState,
collectionEligibleGroupIds,
collectionSelectionState,
collectionSkillCountLabel,
importCandidateGroupLocationCount,
importCandidateGroupTabs,
Expand All @@ -18,6 +20,7 @@ import {
selectedImportCollectionRequests,
selectImportCandidateVariant,
toggleImportCandidateGroupSelection,
toggleImportCollectionSelection,
updateImportCandidateGroupType,
visibleImportCandidates,
workspaceSkillTabs
Expand Down Expand Up @@ -446,6 +449,138 @@ test('normalizes Git-backed collection children and submits one selected child r
}]);
});

test('toggles every eligible child in one complete collection without changing blocked or unrelated groups', () => {
const groups = normalizeImportCandidateGroups([
{
id: 'skill-alpha',
name: 'alpha',
selected_variant_id: 'variant-alpha',
variants: [{
id: 'variant-alpha',
candidate: { name: 'alpha', import_status: 'importable', is_selected: false },
selected_type: 'user'
}]
},
{
id: 'skill-beta',
name: 'beta',
selected_variant_id: 'variant-beta',
variants: [{
id: 'variant-beta',
candidate: { name: 'beta', import_status: 'importable', is_selected: true },
selected_type: 'remote'
}]
},
{
id: 'skill-needs-type',
name: 'needs-type',
selected_variant_id: 'variant-needs-type',
variants: [{
id: 'variant-needs-type',
candidate: { name: 'needs-type', import_status: 'importable', is_selected: false },
requires_type_review: true,
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-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-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'
}]
}
]);
const collection = normalizeImportCollections([{
id: 'collection-one',
source_kind: 'git_worktree',
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' }
]
}])[0];

assert.deepEqual([...collectionEligibleGroupIds(groups, collection)], ['skill-alpha', 'skill-beta']);
assert.deepEqual(collectionSelectionState(groups, collection), {
eligibleGroupIds: new Set(['skill-alpha', 'skill-beta']),
eligibleCount: 2,
selectedCount: 1,
allSelected: false,
indeterminate: true
});

const selected = toggleImportCollectionSelection(groups, collection);
assert.deepEqual(selected.map((group) => group.isSelected), [true, true, false, false, false, false]);
assert.deepEqual(collectionSelectionState(selected, collection), {
eligibleGroupIds: new Set(['skill-alpha', 'skill-beta']),
eligibleCount: 2,
selectedCount: 2,
allSelected: true,
indeterminate: false
});

const unselected = toggleImportCollectionSelection(selected, collection);
assert.deepEqual(unselected.map((group) => group.isSelected), [false, false, false, false, false, false]);
});

test('collection selection uses the complete collection rather than a filtered child subset', () => {
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: 'user' }]
},
{
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' }]
},
{
id: 'skill-standalone',
selected_variant_id: 'variant-standalone',
variants: [{ id: 'variant-standalone', candidate: { name: 'standalone', import_status: 'importable', is_selected: false }, selected_type: 'user' }]
}
]);
const completeCollection = normalizeImportCollections([{
id: 'collection-filtered',
children: [
{ group_id: 'skill-visible', variant_id: 'variant-visible', import_status: 'importable' },
{ group_id: 'skill-hidden-by-filter', variant_id: 'variant-hidden', import_status: 'importable' }
]
}])[0];
const visibleSubset = { ...completeCollection, children: [completeCollection.children[0]] };

assert.equal(visibleSubset.children.length, 1);
const toggled = toggleImportCollectionSelection(groups, completeCollection);
assert.deepEqual(toggled.map((group) => group.isSelected), [true, true, false]);
assert.equal(collectionSelectionState(toggled, completeCollection).selectedCount, 2);
});

test('normalizes a GitHub collection without inventing a local worktree root', () => {
const collections = normalizeImportCollections([{
id: 'github-collection-demo',
Expand Down
7 changes: 7 additions & 0 deletions apps/desktop/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ import {
selectedImportCollectionRequests,
selectedImportCandidates,
selectImportCandidateVariant,
toggleImportCollectionSelection,
toggleImportCandidateGroup,
toggleImportCandidateGroupSelection,
updateImportCandidateGroupType
Expand Down Expand Up @@ -4530,6 +4531,12 @@ export default function App() {
selectImportCandidateVariant(groups, group.id, variant.id)
)
}
onToggleCollectionSelected={(collection) =>
setImportReview((current) => ({
...current,
candidates: toggleImportCollectionSelection(current.candidates, collection)
}))
}
onToggleSelected={(group) =>
updateImportCandidateGroup(group.id, (groups) => toggleImportCandidateGroup(groups, group.id))
}
Expand Down
18 changes: 18 additions & 0 deletions apps/desktop/src/cardLayout.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -548,11 +548,27 @@ test('collection review keeps child selection and type controls inside one expan
const collectionSource = appSource.match(
/function CollectionReviewCard\(\{(?<body>[\s\S]*?)\n\}\n\nfunction WorkspaceSkillTabs/
)?.groups.body || '';
const collectionCheckboxSource = appSource.match(
/function CollectionSelectionCheckbox\(\{(?<body>[\s\S]*?)\n\}\n\nfunction CollectionReviewCard/
)?.groups.body || '';
const candidateReviewListSource = appSource.match(
/function CandidateReviewList\(\{(?<body>[\s\S]*?)\n\}\n\nfunction CollectionSelectionCheckbox/
)?.groups.body || '';
const collectionRule = css.match(/\.collectionReviewCard\s*\{(?<body>[^}]*)\}/s)?.groups.body || '';
const childRule = css.match(/\.collectionChildRow\s*\{(?<body>[^}]*)\}/s)?.groups.body || '';
const collectionActionsRule = css.match(/\.collectionReviewActions\s*\{(?<body>[^}]*)\}/s)?.groups.body || '';

assert.match(collectionSource, /aria-controls={disclosureId}/);
assert.match(collectionSource, /collection\.children\.map/);
assert.match(collectionSource, /collectionSelectionState\(groups, selectionCollection\)/);
assert.match(collectionSource, /<CollectionSelectionCheckbox/);
assert.match(collectionSource, /onToggleCollectionSelected\(selectionCollection\)/);
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(collectionSource, /onToggleSelected\(group\)/);
assert.match(collectionSource, /onTypeChange\(group, 'user'\)/);
assert.match(collectionSource, /onTypeChange\(group, 'remote'\)/);
Expand All @@ -562,6 +578,8 @@ test('collection review keeps child selection and type controls inside one expan
assert.doesNotMatch(collectionSource, /disabled=\{!canClassifyImportCandidateGroup\(group\)\}/);
assert.match(collectionSource, /relativePath/);
assert.match(collectionRule, /min-width:\s*0;/);
assert.match(collectionActionsRule, /display:\s*flex;/);
assert.match(css, /\.candidateCheck input:indeterminate \+ span/);
assert.match(childRule, /grid-template-columns:\s*28px minmax\(0,\s*1fr\) auto;/);
});

Expand Down
78 changes: 70 additions & 8 deletions apps/desktop/src/components/importReview.jsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import React, { useState } from 'react';
import React, { useEffect, useRef, useState } from 'react';
import { ChevronDown, LoaderCircle, MapPin, Search } from 'lucide-react';
import codexAppIcon from '../assets/codex-app-icon.png';
import codexCliIcon from '../assets/codex-cli-icon.png';
import {
canClassifyImportCandidateGroup,
collectionChildTypeState,
collectionSelectionState,
collectionSkillCountLabel,
filterImportCandidateGroups,
filterImportCandidateGroupsByQuery,
Expand Down Expand Up @@ -210,6 +211,7 @@ export function ImportReview({
onImport,
onRetry,
onToggleAll,
onToggleCollectionSelected,
onToggleSelected,
onSelectVariant,
onTypeChange,
Expand Down Expand Up @@ -266,8 +268,10 @@ export function ImportReview({
collections={collections}
groups={groups}
onSelectVariant={onSelectVariant}
onToggleCollectionSelected={onToggleCollectionSelected}
onToggleSelected={onToggleSelected}
onTypeChange={onTypeChange}
selectionDisabled={status === 'importing'}
/>
) : null}
</div>
Expand Down Expand Up @@ -316,7 +320,15 @@ function ImportScanProgress({ progress }) {
);
}

function CandidateReviewList({ collections, groups, onSelectVariant, onToggleSelected, onTypeChange }) {
function CandidateReviewList({
collections,
groups,
onSelectVariant,
onToggleCollectionSelected,
onToggleSelected,
onTypeChange,
selectionDisabled
}) {
const [activeTab, setActiveTab] = useState('all');
const [searchQuery, setSearchQuery] = useState('');
const searchedGroups = filterImportCandidateGroupsByQuery(groups, searchQuery);
Expand Down Expand Up @@ -366,8 +378,11 @@ function CandidateReviewList({ collections, groups, onSelectVariant, onToggleSel
groups={groups}
key={collection.id}
onSelectVariant={onSelectVariant}
onToggleCollectionSelected={onToggleCollectionSelected}
onToggleSelected={onToggleSelected}
onTypeChange={onTypeChange}
selectionCollection={collections.find((candidate) => candidate.id === collection.id) || collection}
selectionDisabled={selectionDisabled}
/>
))}
{standaloneGroups.length > 0 ? (
Expand All @@ -390,21 +405,54 @@ function CandidateReviewList({ collections, groups, onSelectVariant, onToggleSel
);
}

function CollectionSelectionCheckbox({
allSelected,
ariaDescribedBy,
collectionName,
disabled,
indeterminate,
onChange
}) {
const checkboxRef = useRef(null);

useEffect(() => {
if (checkboxRef.current) {
checkboxRef.current.indeterminate = indeterminate;
}
}, [indeterminate]);

return (
<label className="candidateCheck collectionReviewSelectAll">
<input
ref={checkboxRef}
aria-describedby={ariaDescribedBy}
aria-label={`Select all eligible skills in ${collectionName}`}
checked={allSelected}
disabled={disabled}
type="checkbox"
onChange={onChange}
/>
<span />
</label>
);
}

function CollectionReviewCard({
collection,
groups,
onSelectVariant,
onToggleCollectionSelected,
onToggleSelected,
onTypeChange
onTypeChange,
selectionCollection,
selectionDisabled
}) {
const [expanded, setExpanded] = useState(false);
const isInstalledSource = collection.sourceKind === 'installed_source';
const isGithubRemote = collection.sourceKind === 'github_remote';
const disclosureId = `${collection.id}-children`;
const selectedCount = collection.children.filter((child) => {
const group = groups.find((candidateGroup) => candidateGroup.id === child.groupId);
return group?.isSelected && group.selectedVariantId === child.variantId;
}).length;
const selectionSummaryId = `${collection.id}-selection-summary`;
const selectionState = collectionSelectionState(groups, selectionCollection);
const shortSha = collection.reviewedHeadSha ? collection.reviewedHeadSha.slice(0, 8) : 'uncommitted';

return (
Expand Down Expand Up @@ -437,7 +485,21 @@ function CollectionReviewCard({
</>
)}
</div>
<span className="collectionReviewSelection">{selectedCount} selected</span>
<div className="collectionReviewActions">
<CollectionSelectionCheckbox
allSelected={selectionState.allSelected}
ariaDescribedBy={selectionSummaryId}
collectionName={collection.displayName}
disabled={selectionDisabled || selectionState.eligibleCount === 0}
indeterminate={selectionState.indeterminate}
onChange={() => onToggleCollectionSelected(selectionCollection)}
/>
<span className="collectionReviewSelection" id={selectionSummaryId}>
{selectionState.eligibleCount === 0
? 'No eligible skills'
: `${selectionState.selectedCount} of ${selectionState.eligibleCount} eligible selected`}
</span>
</div>
</div>
<button
aria-controls={disclosureId}
Expand Down
Loading