Skip to content

Commit d2838ef

Browse files
Merge pull request #175 from laststance/feat/source-repo-multi-select-filter
feat(filter): multi-select source-repo include filter
2 parents e4a51b2 + 306e3f3 commit d2838ef

20 files changed

Lines changed: 1320 additions & 171 deletions

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,5 +65,8 @@ e2e/.snapshot/
6565
e2e/test-results/
6666
e2e/playwright-report/
6767
playwright/.cache/
68+
69+
# understand-anything knowledge-graph cache (local tool artifact)
70+
.understand-anything/
6871
.claude/goal.json
6972
.claude/goal-notes.md

src/renderer/src/components/layout/MainContent.browser.test.tsx

Lines changed: 111 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,13 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
44
import { render } from 'vitest-browser-react'
55

66
import { TooltipProvider } from '@/renderer/src/components/ui/tooltip'
7-
import type { BulkDeleteResult, Skill, SkillName } from '@/shared/types'
7+
import type {
8+
AgentId,
9+
BulkDeleteResult,
10+
Skill,
11+
SkillName,
12+
SymlinkInfo,
13+
} from '@/shared/types'
814
import { repositoryId, tombstoneId } from '@/shared/types'
915

1016
const mockGetAll = vi.fn()
@@ -184,6 +190,34 @@ function makeSourceSkill(name: string, source: string): Skill {
184190
}
185191
}
186192

193+
/**
194+
* Build an agent-local skill (real folder under ~/.<agent>/skills/, no source
195+
* repo) so repo-filter tests can exercise the "N local skills hidden" caveat.
196+
* @param name - Visible skill name.
197+
* @param agentId - Agent whose slot holds the local skill.
198+
* @returns Skill row with one local symlink slot and no source metadata.
199+
*/
200+
function makeAgentLocalSkill(name: string, agentId: AgentId): Skill {
201+
return {
202+
name: name as SkillName,
203+
description: '',
204+
path: `/home/user/.${agentId}/skills/${name}` as never,
205+
symlinkCount: 0,
206+
symlinks: [
207+
{
208+
agentId,
209+
agentName: agentId as SymlinkInfo['agentName'],
210+
linkPath: `/home/user/.${agentId}/skills/${name}` as never,
211+
targetPath: `/home/user/.${agentId}/skills/${name}` as never,
212+
status: 'valid',
213+
isLocal: true,
214+
},
215+
],
216+
isSource: false,
217+
isOrphan: false,
218+
}
219+
}
220+
187221
describe('MainContent bulk-select toggle button', () => {
188222
it('shows "Select" and aria-pressed=false by default', async () => {
189223
const { screen } = await renderMainContent()
@@ -469,6 +503,7 @@ describe('MainContent handleConfirmBulk — uniform delete pipeline', () => {
469503
skillNames: ['brainstorming' as SkillName, 'local-skill' as SkillName],
470504
agentId: null,
471505
agentName: null,
506+
sourceSummary: null,
472507
}),
473508
)
474509

@@ -706,14 +741,14 @@ describe('MainContent repo facet dropdown', () => {
706741
.getByRole('button', { name: /Filter by source repository/i })
707742
.click()
708743
await screen
709-
.getByRole('menuitemradio', {
744+
.getByRole('menuitemcheckbox', {
710745
name: /pbakaus\/impeccable, 1 skill/i,
711746
})
712747
.click()
713748

714-
expect(store.getState().ui.selectedSource).toBe(
749+
expect(store.getState().ui.selectedSources).toEqual([
715750
repositoryId('pbakaus/impeccable'),
716-
)
751+
])
717752
})
718753
})
719754

@@ -725,31 +760,31 @@ describe('MainContent filter pills (Agent + Source orthogonal)', () => {
725760

726761
it('renders the Source pill with repo name and clears state on click', async () => {
727762
const { screen, store } = await renderMainContent()
728-
const { setSelectedSource } =
763+
const { setSelectedSources } =
729764
await import('@/renderer/src/redux/slices/uiSlice')
730765

731766
// No source filter active: pill must not render.
732767
expect(screen.getByTestId('source-filter-pill').query()).toBeNull()
733768

734-
store.dispatch(setSelectedSource(repositoryId('vercel-labs/skills')))
769+
store.dispatch(setSelectedSources([repositoryId('vercel-labs/skills')]))
735770

736771
const pill = screen.getByTestId('source-filter-pill')
737772
await expect.element(pill).toBeInTheDocument()
738-
await expect.element(pill).toHaveTextContent(/Showing skills from/)
773+
await expect.element(pill).toHaveTextContent(/from/)
739774
await expect.element(pill).toHaveTextContent('vercel-labs/skills')
740775

741776
// Clear button inside the pill resets the slice field.
742777
await pill.getByRole('button', { name: /Clear/i }).click()
743778

744-
await expect.poll(() => store.getState().ui.selectedSource).toBeNull()
779+
await expect.poll(() => store.getState().ui.selectedSources).toEqual([])
745780
expect(screen.getByTestId('source-filter-pill').query()).toBeNull()
746781
})
747782

748783
it('Agent + Source pills both render when both filters are active', async () => {
749784
const { screen, store } = await renderMainContent()
750785
const { fetchAgents } =
751786
await import('@/renderer/src/redux/slices/agentsSlice')
752-
const { selectAgent, setSelectedSource } =
787+
const { selectAgent, setSelectedSources } =
753788
await import('@/renderer/src/redux/slices/uiSlice')
754789

755790
// Seed an agent fixture so MainContent's `agents.find(...)` resolves.
@@ -769,7 +804,7 @@ describe('MainContent filter pills (Agent + Source orthogonal)', () => {
769804
),
770805
)
771806
store.dispatch(selectAgent('claude-code'))
772-
store.dispatch(setSelectedSource(repositoryId('vercel-labs/skills')))
807+
store.dispatch(setSelectedSources([repositoryId('vercel-labs/skills')]))
773808

774809
await expect
775810
.element(screen.getByTestId('agent-filter-pill'))
@@ -783,7 +818,7 @@ describe('MainContent filter pills (Agent + Source orthogonal)', () => {
783818
const { screen, store } = await renderMainContent()
784819
const { fetchAgents } =
785820
await import('@/renderer/src/redux/slices/agentsSlice')
786-
const { selectAgent, setSelectedSource } =
821+
const { selectAgent, setSelectedSources } =
787822
await import('@/renderer/src/redux/slices/uiSlice')
788823

789824
store.dispatch(
@@ -802,21 +837,82 @@ describe('MainContent filter pills (Agent + Source orthogonal)', () => {
802837
),
803838
)
804839
store.dispatch(selectAgent('claude-code'))
805-
store.dispatch(setSelectedSource(repositoryId('vercel-labs/skills')))
840+
store.dispatch(setSelectedSources([repositoryId('vercel-labs/skills')]))
806841

807842
// Clear ONLY the source pill.
808843
await screen
809844
.getByTestId('source-filter-pill')
810845
.getByRole('button', { name: /Clear/i })
811846
.click()
812847

813-
// Agent pill must still be rendered with its label intact; selectedSource
814-
// must be null. This pins Issue 4 from the design review: source clear
848+
// Agent pill must still be rendered with its label intact; selectedSources
849+
// must be empty. This pins Issue 4 from the design review: source clear
815850
// does not bleed into agent state.
816-
await expect.poll(() => store.getState().ui.selectedSource).toBeNull()
851+
await expect.poll(() => store.getState().ui.selectedSources).toEqual([])
817852
expect(store.getState().ui.selectedAgentId).toBe('claude-code')
818853
await expect
819854
.element(screen.getByTestId('agent-filter-pill'))
820855
.toHaveTextContent('Claude Code')
821856
})
822857
})
858+
859+
describe('MainContent hidden-locals caveat', () => {
860+
// The repo include-filter hides source-less local skills. This inline caveat
861+
// tells the user how many were dropped so an empty-looking list is explained.
862+
863+
it('shows "N local skills hidden" when the repo filter suppresses source-less locals', async () => {
864+
// Arrange — cursor view with one repo skill and two source-less locals
865+
const { screen, store } = await renderMainContent()
866+
const { fetchSkills } =
867+
await import('@/renderer/src/redux/slices/skillsSlice')
868+
const { selectAgent, setSelectedSources } =
869+
await import('@/renderer/src/redux/slices/uiSlice')
870+
store.dispatch(
871+
fetchSkills.fulfilled(
872+
[
873+
makeSourceSkill('repo-skill', 'vercel-labs/skills'),
874+
makeAgentLocalSkill('local-one', 'cursor'),
875+
makeAgentLocalSkill('local-two', 'cursor'),
876+
],
877+
'req-id',
878+
),
879+
)
880+
store.dispatch(selectAgent('cursor'))
881+
882+
// Act — turn on the repo include-filter
883+
store.dispatch(setSelectedSources([repositoryId('vercel-labs/skills')]))
884+
885+
// Assert — both suppressed locals are reported, pluralized
886+
await expect
887+
.element(screen.getByText(/2 local skills hidden/))
888+
.toBeInTheDocument()
889+
})
890+
891+
it('omits the caveat when no repo filter is active', async () => {
892+
// Arrange — same source-less locals in the cursor view, filter left empty
893+
const { screen, store } = await renderMainContent()
894+
const { fetchSkills } =
895+
await import('@/renderer/src/redux/slices/skillsSlice')
896+
const { selectAgent } = await import('@/renderer/src/redux/slices/uiSlice')
897+
store.dispatch(
898+
fetchSkills.fulfilled(
899+
[
900+
makeAgentLocalSkill('local-one', 'cursor'),
901+
makeAgentLocalSkill('local-two', 'cursor'),
902+
],
903+
'req-id',
904+
),
905+
)
906+
store.dispatch(selectAgent('cursor'))
907+
908+
// Anchor on the always-rendered trigger so the toolbar is known to be live…
909+
await expect
910+
.element(
911+
screen.getByRole('button', { name: /Filter by source repository/i }),
912+
)
913+
.toBeInTheDocument()
914+
915+
// Assert — …then confirm the caveat is absent with nothing filtered out
916+
expect(screen.getByText(/local skills hidden/).query()).toBeNull()
917+
})
918+
})

0 commit comments

Comments
 (0)