Skip to content

Commit 49beef4

Browse files
Merge pull request #152 from laststance/codex/add-gstack-filters
[codex] Add G-Stack skill filter
2 parents 215f017 + 9b822f5 commit 49beef4

16 files changed

Lines changed: 252 additions & 64 deletions

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ out/
77
build/
88
.vite/
99
storybook-static/
10+
static-storybook/
1011

1112
# Logs
1213
logs/

e2e/spec/hide-agents.e2e.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,8 @@ import { _electron } from '@playwright/test'
55

66
import { test, expect } from '../fixtures/electron-app'
77
import { isSnapshotOffline } from '../fixtures/isolated-home'
8-
import { readSettingsFile, writeSettingsFile } from '../helpers/settings-file'
98
import { getStoreState, waitForInitialScan } from '../helpers/redux'
9+
import { readSettingsFile, writeSettingsFile } from '../helpers/settings-file'
1010

1111
/**
1212
* PR #144 — Hide unused agents from the sidebar.

e2e/spec/regression.e2e.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -872,10 +872,9 @@ test('skills list scroll position survives a background refetch (regression 5619
872872
// ABSENCE of unmount — there's nothing positive to poll for, only the
873873
// STABILITY of `scrollTop`. requestAnimationFrame is the synchronous
874874
// analogue: dispatch → reducer → React commit → next paint.
875-
await appWindow.evaluate(
876-
() =>
877-
new Promise<void>((resolve) => requestAnimationFrame(() => resolve())),
878-
)
875+
await appWindow.evaluate(async () => {
876+
await new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
877+
})
879878

880879
const scrollTopAfter = await appWindow.evaluate(() => {
881880
const scroller = document.querySelector<HTMLElement>(

e2e/spec/sync.e2e.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -157,7 +157,7 @@ test('per-agent preview narrows scope and echoes forAgent', async ({
157157
// `{ agentId }` from the renderer, so wiring this through a click
158158
// would test more than the boundary we care about. The thunk + slice
159159
// path is already covered by the global test above.
160-
const previewRaw = await appWindow.evaluate(() =>
160+
const previewRaw = await appWindow.evaluate(async () =>
161161
window.electron.sync.preview({ agentId: 'cursor' }),
162162
)
163163
const preview = previewRaw as {

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
"storybook": "storybook dev -p 6006",
2222
"storybook:build": "storybook build",
2323
"typecheck": "tsc --noEmit",
24-
"lint": "eslint .",
24+
"lint": "eslint . --max-warnings 0",
2525
"lint:fix": "eslint . --fix",
2626
"fallow:dead-code": "pnpm exec fallow dead-code",
2727
"validate": "run-p lint test typecheck fallow:dead-code storybook:build",

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

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -469,10 +469,10 @@ describe('MainContent handleConfirmBulk — uniform delete pipeline', () => {
469469
})
470470
})
471471

472-
describe('MainContent SkillTypeFilter dropdown (Orphan option)', () => {
473-
// Pins the agent-only Orphan affordance contract: the dropdown is gated by
474-
// `selectedAgentId` (source view never offers it), and selecting Orphan
475-
// narrows the visible list to skills whose source dir vanished.
472+
describe('MainContent SkillTypeFilter dropdown options', () => {
473+
// Pins agent-only type filters: the dropdown is gated by `selectedAgentId`
474+
// (source view never offers it), and each option writes the Redux state that
475+
// selectors use to narrow the visible list.
476476

477477
it('renders the Orphan radio item with a destructive dot when an agent is selected', async () => {
478478
const { screen, store } = await renderMainContent()
@@ -513,6 +513,41 @@ describe('MainContent SkillTypeFilter dropdown (Orphan option)', () => {
513513
).not.toBeNull()
514514
})
515515

516+
it('renders the G-Stack radio item with a sky dot when an agent is selected', async () => {
517+
const { screen, store } = await renderMainContent()
518+
const { fetchAgents } =
519+
await import('@/renderer/src/redux/slices/agentsSlice')
520+
const { selectAgent } = await import('@/renderer/src/redux/slices/uiSlice')
521+
522+
store.dispatch(
523+
fetchAgents.fulfilled(
524+
[
525+
{
526+
id: 'cursor',
527+
name: 'Cursor',
528+
path: '/Users/me/.cursor/skills' as never,
529+
exists: true,
530+
skillCount: 0,
531+
localSkillCount: 0,
532+
},
533+
],
534+
'req-id',
535+
),
536+
)
537+
store.dispatch(selectAgent('cursor'))
538+
539+
// Open the dropdown — G-Stack sits beside Symlinked/Local as a type filter.
540+
await screen.getByRole('button', { name: /^All$/ }).click()
541+
542+
const gstackItem = screen.getByRole('menuitemradio', { name: /G-Stack/i })
543+
await expect.element(gstackItem).toBeInTheDocument()
544+
const dot = gstackItem.element().querySelector('.bg-gstack')
545+
expect(
546+
dot,
547+
'G-Stack menu item should contain a span with bg-gstack',
548+
).not.toBeNull()
549+
})
550+
516551
it('selecting Orphan narrows visible list to skills with isOrphan=true', async () => {
517552
const { screen, store } = await renderMainContent()
518553
const { fetchAgents } =

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,7 @@ const SKILL_TYPE_FILTER_OPTIONS: {
108108
{ value: 'all', label: 'All' },
109109
{ value: 'symlinked', label: 'Symlinked', dotClass: 'bg-success' },
110110
{ value: 'local', label: 'Local', dotClass: 'bg-emerald-400' },
111+
{ value: 'gstack', label: 'G-Stack', dotClass: 'bg-gstack' },
111112
{ value: 'orphan', label: 'Orphan', dotClass: 'bg-destructive' },
112113
]
113114

src/renderer/src/components/skills/skillItemHelpers.ts

Lines changed: 2 additions & 43 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { GSTACK_BADGE_AGENT_IDS } from '@/shared/constants'
1+
import { isGStackManagedForAgent } from '@/renderer/src/utils/gstackSkill'
22
import type { AgentId, Skill, SymlinkInfo } from '@/shared/types'
33

44
/**
@@ -37,31 +37,6 @@ export interface SkillItemVisibility {
3737
*/
3838
export type SkillVisibilityInput = Pick<Skill, 'symlinks' | 'isOrphan'>
3939

40-
/**
41-
* Path-segment matcher for `.../skills/gstack/...` on both POSIX and Windows
42-
* paths. Requires a `skills/` parent so a user-created directory literally
43-
* named `gstack` outside any agent's skills tree (e.g. `~/projects/gstack/foo`)
44-
* cannot trigger the badge — a finding raised by the Codex review.
45-
*/
46-
const GSTACK_SEGMENT_PATTERN = /[\\/]skills[\\/]gstack([\\/]|$)/i
47-
48-
/**
49-
* Detect whether a filesystem-like path points to a G-Stack-managed location.
50-
* @param candidatePath - Path candidate from symlink target or link path.
51-
* @returns
52-
* - `true`: Path contains a `skills/gstack/` segment.
53-
* - `false`: Empty path, or `gstack/` lives outside any `skills/` parent.
54-
* @example
55-
* isGStackBundlePath('/Users/me/.claude/skills/gstack/skill-a') // => true
56-
* @example
57-
* isGStackBundlePath('/Users/me/.agents/skills/task') // => false
58-
* @example
59-
* isGStackBundlePath('/Users/me/projects/gstack/skill-a') // => false
60-
*/
61-
function isGStackBundlePath(candidatePath: string): boolean {
62-
return GSTACK_SEGMENT_PATTERN.test(candidatePath)
63-
}
64-
6540
/**
6641
* Compute which action buttons to show on a SkillItem card.
6742
*
@@ -128,23 +103,7 @@ export function getSkillItemVisibility(
128103
const hasSkillInSelectedAgent = !!selectedAgentSymlink || isLocalSkill
129104
// Orphan handling — see Skill.isOrphan for why the Add button is gated.
130105
// Source: scanOrphanSymlinks() in src/main/services/skillScanner.ts.
131-
//
132-
// gStackPathCandidates — paths inspected for the `skills/gstack/` segment
133-
// that identifies a gstack-managed skill. `skillMdSymlinkTarget` is read
134-
// ONLY from the selected agent's slot (per-agent attribution): a sibling
135-
// agent's gstack-managed twin must not flip the badge on this agent's
136-
// unrelated local skill that happens to share a name.
137-
const gStackPathCandidates = [
138-
selectedAgentSymlink?.targetPath ?? '',
139-
selectedAgentSymlink?.linkPath ?? '',
140-
selectedLocalSkillInfo?.linkPath ?? '',
141-
selectedLocalSkillInfo?.skillMdSymlinkTarget ?? '',
142-
]
143-
const isGStackEligibleAgent =
144-
selectedAgentId !== null &&
145-
GSTACK_BADGE_AGENT_IDS.some((agentId) => agentId === selectedAgentId)
146-
const showGStackBadge =
147-
isGStackEligibleAgent && gStackPathCandidates.some(isGStackBundlePath)
106+
const showGStackBadge = isGStackManagedForAgent(skill, selectedAgentId)
148107

149108
return {
150109
// Delete is the primary cleanup action for orphans in global view —

src/renderer/src/components/skills/skillsListHelpers.test.ts

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,17 @@ describe('getEmptyListMessage', () => {
6464
).toBe('No symlinked skills for this agent')
6565
})
6666

67+
it('returns the G-Stack variant when type filter is gstack', () => {
68+
expect(
69+
getEmptyListMessage({
70+
searchQuery: '',
71+
selectedSource: null,
72+
selectedAgentId: 'cursor',
73+
skillTypeFilter: 'gstack',
74+
}),
75+
).toBe('No G-Stack skills for this agent')
76+
})
77+
6778
it('returns the agent-only message when selectedAgentId is set and type is all', () => {
6879
expect(
6980
getEmptyListMessage({

src/renderer/src/components/skills/skillsListHelpers.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,14 @@ interface EmptyMessageContext {
1010
skillTypeFilter: SkillTypeFilter
1111
}
1212

13+
const SKILL_TYPE_FILTER_LABELS = {
14+
all: 'all',
15+
symlinked: 'symlinked',
16+
local: 'local',
17+
gstack: 'G-Stack',
18+
orphan: 'orphan',
19+
} as const satisfies Record<SkillTypeFilter, string>
20+
1321
/**
1422
* Compute the empty-state message for SkillsList based on which filters are
1523
* currently narrowing the result. The skills list participates in four
@@ -34,7 +42,7 @@ interface EmptyMessageContext {
3442
* - When `searchQuery` is non-empty: `"No skills match your search"`
3543
* - When only `selectedSource` is set: `"No skills from <repo>"`
3644
* - When `selectedAgentId` is set AND `skillTypeFilter !== 'all'`:
37-
* `"No <symlinked|local> skills for this agent"`
45+
* `"No <symlinked|local|G-Stack|orphan> skills for this agent"`
3846
* - When only `selectedAgentId` is set: `"No skills installed for this agent"`
3947
* - Otherwise: `"No skills match your filter"`
4048
*
@@ -70,7 +78,8 @@ export function getEmptyListMessage(ctx: EmptyMessageContext): string {
7078
)
7179
.with(
7280
{ hasSelectedAgent: true, hasTypeNarrow: true },
73-
() => `No ${ctx.skillTypeFilter} skills for this agent`,
81+
() =>
82+
`No ${SKILL_TYPE_FILTER_LABELS[ctx.skillTypeFilter]} skills for this agent`,
7483
)
7584
.with(
7685
{ hasSelectedAgent: true },

0 commit comments

Comments
 (0)