feat: project scope as first-class extension dimension#30
Merged
Conversation
Foundation for promoting scope to a global navigation dimension.
useScopeStore holds the current ScopeValue ("all" | "global" | project)
and a hydrated flag. useScope() hook exposes the store value plus a
setScope that mirrors changes to ?scope= URL param (replace, not push)
and localStorage. Hydration happens once on app mount after projects
load: URL > localStorage > Global, with validation that drops stale
references to deleted projects.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The previous Effect 2 derived "projects loaded" from !loading, but
project-store's initial state is { loading: false, projects: [] }
β so on cold start, !loading was true before any load attempt. Effect 2
ran immediately with an empty projects array and dropped any URL- or
localStorage-referenced project as a stale reference, silently falling
back to Global and persisting the wrong value to localStorage.
Add an explicit loaded: boolean flag to useProjectStore (set true only
after loadProjects resolves, success or error) and gate Effect 2 on it.
Also add regression tests for the scope-store stale-reference branches
and a test for useScope's setScope URL writes.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Custom dropdown built without Radix/Headless UI (codebase doesn't have either; existing dropdowns are native <select> which can't render icons + sections + Add Project action). Trigger button shows current scope with icon + label + chevron; popover dropdown lists All scopes (when projects exist), Global, each project, with Add Project at the bottom. ESC and click-outside close the menu. Selecting a scope updates the store via useScope; Add Project opens openDirectoryPicker, persists via useProjectStore.addProject, then auto-switches to the new project. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Small icon+label pill rendered on rows when in All-scopes mode so users can tell which scope each row belongs to. Uses lucide Globe (for Global) or Folder (for project), with aria-label so screen readers announce "Scope: Global" / "Scope: project-X". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Removes the per-page scope <select> dropdown and the scopeFilter field/setter on useExtensionStore β both replaced by reading the global ScopeValue via useScope() at filter compute time. Rows in All-scopes mode now render a ScopeBadge so the user can see which scope each row belongs to. Cross-page handoff via ?scope= still works because the App-level hydrate consumes it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The per-agent scope pills (Global / per-project clickable filter row inside each agent's detail panel) are now redundant given the global sidebar scope switcher. Removes the activeScope local state, the useEffect that resets it, and the entire pills UI block. matchesScope now reads the global ScopeValue via useScope and filters config files accordingly. ExtensionsSummaryCard counts derive from the scope- filtered extensions list rather than the precomputed Rust counts. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Audit findings are filtered by the global scope: in single-scope mode only findings for extensions in that scope are shown; in All-scopes mode all findings appear with a ScopeBadge per row so the user knows which scope each finding belongs to. Lets users audit one project at a time when their installation grows. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
β¦7 adapters Two related changes bundled because they only ship together usefully: 1. New default trait method `skill_dir_for(&ConfigScope) -> Option<PathBuf>` composes existing `skill_dirs()` (Global) and `project_skill_dirs()` (Project) primitives. Mirrors mcp_config_path_for / hook_config_path_for added in PR #29. 2. Declare project_skill_dirs on the 6 adapters that previously inherited the empty default. As of December 2025, the Universal Agent Skills standard (SKILL.md format) is supported by all 7 agents: claude: .claude/skills (already declared) codex: .agents/skills (universal alias adopted by OpenAI) cursor: .cursor/skills (Cursor 2.4+) windsurf: .windsurf/skills gemini: .gemini/skills antigravity: .agent/skills (singular - Antigravity convention) copilot: .github/skills (canonical Copilot path) Each path comes from the agent's first-party documentation (links in inline comments). Without these declarations, project-scope skill install via marketplace/git/local would fail for 6 of 7 agents. Prerequisite for scope-aware skill install in Task 8. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds target_scope: &ConfigScope as the last argument and threads it
through scan + meta-write paths.
For project scope:
- Scan via scanner::scan_project_extensions (returns project-scoped
rows) and upsert via insert_extension. Deliberately avoids
sync_extensions_for_agent because its stale-removal would nuke
this agent's global rows when only project rows are scanned.
- Compute the row id via stable_id_with_scope_for so set_install_meta
+ update_pack land on the project-scoped row, not the unscoped
(global) row. Without this, project-scope installs silently fail
to record their provenance and the update-checker misses them.
Global path is unchanged: scan_adapter + sync_extensions_for_agent
+ stable_id_for, exactly as before.
Part 1 of 3 splitting the Task 8 PR. Part 2 (web/desktop handlers)
and part 3 (frontend invoke + stores) follow separately. Workspace
build will be temporarily broken between parts because callers don't
yet pass target_scope; the next commit fixes web + desktop.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Restores workspace compilation after part 1's post_install_sync signature change. For each install-from-source handler/command (NOT install_to_agent), three mechanical changes: 1. Add target_scope: ConfigScope to the request params (or as a tauri command arg). 2. Replace a.skill_dirs().into_iter().next() with a.skill_dir_for(&target_scope) so the install lands in the right directory for the chosen scope. 3. Pass &target_scope to service::post_install_sync(...) so the scope-aware row id is used for set_install_meta + update_pack. Web handlers updated: install_from_git, install_from_marketplace, install_from_local, install_scanned_skills, install_new_repo_skills, scan_git_repo (auto-install branch). Desktop commands updated: install_from_git, install_from_local, scan_git_repo, install_scanned_skills, install_new_repo_skills (in install.rs); install_from_marketplace (in marketplace.rs). install_to_agent (web + desktop) is intentionally unchanged -- cross-agent deploy stays global-only in v1; the UI gates project scope in Task 9. Plus dedupe-path fix in install_scanned_skills and install_new_repo_skills (both web + desktop): the "already-synced row" branch was calling scanner::stable_id_for(...) (unscoped) and silently writing install_meta to the wrong row for project installs. Now uses scanner::stable_id_with_scope_for(...) so the project-scoped row gets the meta. manager.rs is still untouched: its install functions take target_dir: &Path and copy files; scope is encoded into target_dir by the callers (handlers/commands) via skill_dir_for. Threading target_scope into manager.rs would be redundant. Part 2 of 3. Part 3 (frontend invoke + stores + agent-capabilities + caller updates) follows. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Completes the Task 8 split. Frontend changes: 1. src/lib/invoke.ts β 5 install API functions gain a required targetScope: ConfigScope parameter (installFromGit, installFromLocal, installFromMarketplace, installScannedSkills, installNewRepoSkills). Each forwards it to the backend payload. scanGitRepo also gains the parameter to match the 8b backend change to ScanGitRepoParams. installToAgent stays unchanged β cross-agent deploy is global-only in v1; Task 9 gates project scope at the UI. 2. src/stores/extension-store.ts β installNewRepoSkills gains targetScope. installToAgent unchanged. 3. src/stores/marketplace-store.ts β install action gains targetScope and forwards to api.installFromMarketplace. 4. NEW src/lib/agent-capabilities.ts β frontend table mirroring the per-adapter project_skill_dirs / project_mcp_config_relpath / project_hook_config_relpath declarations as a per-(agent, kind) capability lookup. canInstallAtScope(agent, kind, scope) returns true for non-project scopes; for project scope it consults the table. v1 effective behavior with kind="skill" is true for all 7 agents (Task 7 declared project_skill_dirs on each adapter); the gate exists for v2 cross-agent deploy which adds MCP/hook/CLI. 5. Caller updates β every site that previously called the 5 install APIs without scope now narrows ScopeValue to ConfigScope and passes it. install-dialog.tsx (installFromLocal, scanGitRepo, installScannedSkills), marketplace.tsx (marketplaceStore.install), extensions.tsx (installNewRepoSkills via NewSkillsDialog). Single-scope mode passes the active scope; All-scopes mode uses a global placeholder for now (Task 9 will replace with a picker). Why required and not optional: only the UI knows whether the user is in single-scope mode (use active scope) or All-scopes mode (use the picker value). Required forces every call site to make the choice consciously, eliminating the silent-fallback foot-gun. Cross-agent deploy (installToAgent) in project scope is deliberately out of scope for v1. See follow-up roadmap "project install-to-agent + adapter completion" for the work that re-enables it (Task 13 step 4). Part 3 of 3. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three changes:
1. ScopeTargetField β shared component used by marketplace + git/local
install-dialog. Renders a static "Β· π {scope}" hint in single-scope
mode and a required dropdown in All-scopes mode (with smart-default
"Use X" shortcut). In All-scopes mode the install buttons / Scan
stay disabled until the user picks a scope.
2. Per-(agent, kind) capability gate via canInstallAtScope() on
marketplace per-agent install buttons. In v1 the gate is effectively
a no-op for skill install β Task 7 declared project_skill_dirs on
all 7 adapters, so every detected agent stays enabled in project
scope. The gate is plumbing for v2 cross-agent deploy (which adds
MCP/hook/CLI to the mix).
3. Cross-agent deploy ("Install to Agent" buttons in Extensions detail)
is disabled in project scope for v1, with a tooltip explaining it's
a future feature. Reasoning: project-scope cross-agent deploy needs
scope-aware MCP/Hook/CLI target paths inside install_to_agent PLUS
MCP/hook adapter completion for codex/gemini/copilot/antigravity.
Both are tracked in the follow-up roadmap. (Project skill is DONE
in v1 via the regular install pipeline; this gate only blocks the
cross-agent variant.)
Marketplace isItemInstalled becomes scope-aware: green ShieldCheck
only renders when installed in the current scope, preventing
"installed in Global" from misleading the user when they're viewing
a project.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- removeProject in project-store auto-resets scope to Global if the removed project was currently selected, with an info toast - hydrate() resolves invalid URL ?scope= silently to localStorage or Global; AppShell Effect 3 then strips the invalid value from the URL via setSearchParams(replace) to keep the URL honest - Scope-aware pages (Extensions/Agents/Audit) show a skeleton until scope-store is hydrated, preventing requests against an undefined scope during the first paint Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the current scope is a specific project and that project has no
extensions configured, show a focused empty state ("No extensions
configured in {project}") with a CTA to Marketplace, instead of the
generic global empty state. All-scopes and Global modes keep the
existing global empty UX.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
ScopeSwitcher gains arrow-key navigation (β/β) inside the open dropdown with active-item highlight + Enter to select. ARIA roles and labels (listbox, option, aria-selected, aria-haspopup, aria-expanded) verified on the switcher; aria-disabled + title tooltip on install buttons that are disabled because no scope is selected in All-scopes mode. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Custom config paths are now created in the user's currently-selected scope instead of always Global. Schema v4 adds nullable `scope_json` to the `custom_config_paths` table; legacy NULL rows default to Global on read. End-to-end plumbing: - store: ALTER TABLE + 5-tuple list_custom_config_paths - desktop + web handlers: deserialize ConfigScope param, pass through - frontend invoke + store: addCustomConfigPath(.., targetScope: ConfigScope) - agent-detail: drop redundant "Add Project" button (Sidebar ScopeSwitcher now offers project picker), narrow scope.type === "all" β Global before calling addCustomPath since "all" is not a real install target. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
β¦bling sync Previous Check Updates flow blanket-skipped any project-scope skill on the assumption that project skills are owned by the user's own git repo. After v1 project skill install (PR #29) this is no longer true: skills installed via marketplace or git clone into a project carry install_meta and ARE HK-managed, so they should participate in update detection like Global ones. Add `service::is_update_eligible(ext)`: kind==Skill && (Global || install_meta.is_some()). Both desktop and web Check Updates now use it instead of the inline scope guard. Also fix update_extension sibling sync: previously hard-coded Global-only sibling matching, which would skip project copies during an update. Add `service::same_scope(a, b)` for structural scope equality and use it so a Global update no longer clobbers a project copy and vice versa. Tests: 4 new service-layer unit tests covering both helpers across Global / Project / different-project / different-kind branches. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
β¦+ scope-aware filter UX Per-row ScopeBadge took too much horizontal space and clipped on long project names. Replace it with explicit scope rows in the Extension detail panel (handles Phase C dedup: one group can span multiple scopes, so we list each unique scope as its own row). Other scope-aware UI improvements bundled in this cleanup pass: - Sources filter on Extensions page is now scope-aware: a project no longer shows packs that only exist in Global (and vice versa). Stale packFilter auto-clears when the value is no longer in scope. - Marketplace install panel uses a split layout in All-scopes mode so the long scope picker doesn't wrap the "Install to Agent" header. Disabled-button styling actually applies (was previously gated behind isInstallingThis||isInstalled). - Project-scope cross-agent install button gets an inline hint "global only (project soon)" β Cross-agent deploy is a v2 feature. - ScopeTargetField uses the Folder icon for all scopes (Global no longer Globe) for visual consistency with the Sidebar ScopeSwitcher. - Audit page: detail panel auto-closes on scope change; empty-state copy fixed (the unreachable "all extensions passed audit" half removed). Side cleanup: extract canonical <select> styling into src/lib/web-select.ts since multiple call sites had ad-hoc copies. extension-filters.tsx, scope-target-field.tsx, audit.tsx now all use the shared helper. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
β¦tore focus on OverviewβAgents handoff Two related panel-state hygiene issues: 1. Agents page: expandedFiles + pendingFocusFile live in the persistent zustand store, so they survived scope switches and page navigation. Symptom: switch from Global to a project that doesn't have the same file, the now-stale preview pane sticks around showing nothing or the wrong file. Same on returning to Agents from another page. Fix: prevScopeRef pattern clears expandedFiles + pendingFocusFile on scope change; unmount cleanup useEffect resets both on page leave. 2. config-file-entry: clicking an Agent Activity row in Overview should navigate to Agents AND scroll/highlight the target file. The scroll was firing but the highlight ring never showed. Root cause: setPendingFocusFile(null) ran *synchronously* inside the useEffect, triggering a re-render that ran the cleanup before the rAF fired. Move setPendingFocusFile(null) inside the rAF callback (after the scroll), and split the 1.5s highlight timer into its own effect so a re-render from the focus-clear can't cancel it. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
β¦giene NewSkillsDialog (the "More from Repos" prompt that lists skills available in already-installed repo packs) was the last install path that silently defaulted to Global in All-scopes mode while every other path forced the user to pick a scope. Wire ScopeTargetField into it so behavior is consistent: single-scope mode shows the inline scope hint; All-scopes mode shows the picker and disables Install until the user picks. Bundled (same file): Extensions page detail panel auto-closes on scope change and on page leave β matches the same fix applied to Agents and Audit pages. selectedId lives in zustand (persists across remounts), so without this navigating to Agents and back kept an old row open. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
β¦move above Settings
Initial ScopeSwitcher landed with a pill/border style and sat right under
the brand. Several issues surfaced once it was wired in:
- Pill style felt out of place next to flat nav links. Restyled to use
the same sidebar-foreground tokens as SidebarLink so it visually reads
as a navigation control.
- Position above the brand divider made it feel like a header element
rather than a utility next to Settings. Moved below the bottom
separator, between UpdateCard and Settings.
- All scopes used a unique LayoutGrid icon while everything else used
Folder. Made all icons Folder for consistency (the user can read the
label to distinguish; mixing icons added noise).
- Dropdown highlight was sharp-cornered (rounded vs container's
rounded-xl). Bumped to rounded-lg to match.
- activeIndex always started at 0 ("All scopes") regardless of the
current scope. Compute initial index from the current selection so
keyboard nav opens with the right row highlighted.
- "Add Project" used a Tauri-only directory picker that silently no-op'd
in web mode. Replaced with navigation to /settings.
- Dropdown had no max-height: 10+ projects could push items past the
viewport top. Add max-h-80 + overflow-y-auto (~6 projects fit before
scrolling kicks in).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
β¦m info to warning Toast system had only success / error / info. Several existing toasts fell into a fourth category β "not an error, but the system handled something the user should know about" β and were using info as a fallback, which renders in a neutral blue that visually reads like a status update rather than a "pay attention" signal. Add a warning variant: amber (oklch hue 75) tokens across all 6 theme blocks (3 themes Γ light/dark) + @theme mapping; AlertTriangle icon in ToastContainer; toast.warning(msg) shorthand. Reclassify 4 existing toast.info calls that are warning semantics: - project-store: "Project 'X' was removed, switched to Global" (the user's current scope was silently replaced) - scope-store: "Scope 'X' not found, using fallback" (URL pointed to a scope that no longer exists) - extension-store: single + plural "X is no longer available in the remote repository" (update was skipped because the source disappeared) Other toast.info call sites (theme switch, "you're up to date", etc.) remain info β they are pure status notifications, not warnings. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- store.rs: extract CustomConfigPathRow type alias for the 5-tuple returned by list_custom_config_paths (clippy::type_complexity, lifted by adding scope_json in v4 migration) - error.rs: switch test helper to std::io::Error::other() per Rust 1.94 clippy::io_other_error (pre-existing line, blocks clippy from running cleanly so fixed in this branch) - new-skills-dialog.tsx: collapse single-prop ScopeTargetField onto one line per biome format Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Run `biome format --write src/` to bring 21 files in line with the project's biome config. All changes are pure formatting: - Line wrapping for long ternary chains, ?? chains, transport() calls - Removal of column-alignment spaces in PROJECT_INSTALL_SUPPORT and similar tables - Indentation adjustments for nested && (...) JSX blocks No token order changes, no semantic changes. Most affected files predate this branch β biome wasn't being enforced in CI before, so main had accumulated drift. Applying the formatter once now keeps future diffs clean. Verified: tsc clean, 127/127 frontend tests pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Clicking a row in Overview's Agent Activity or Recently Installed
panels now correctly switches the destination page to the file/ext's
own scope before rendering, instead of getting silently filtered out
when the user's current sidebar scope doesn't match.
Implementation:
- Overview onClick now navigates with the target's scope encoded in the
URL (?scope=<project-path>, omitted for global). It does NOT call
setScope inline β the previous attempt did, but React 18 batched the
store update with the navigate() call and dropped the route change,
making it look like the click did nothing.
- useScope.setScope is now a thin pass-through to the zustand store
setter; URL-mirroring is delegated entirely to AppShell Effect 3.
The hook used to call navigate({ search }) inline, which raced any
follow-up navigate() in the same event handler.
- agents.tsx + extensions.tsx pick up ?scope= from the URL, resolve it
against the projects list, and call setScope before selecting the
agent / extension. prevScopeRef.current is pre-synced so the
scope-change cleanup effect doesn't undo the selection.
- extensions.tsx Match effect now reads groupKey/name reactively from
searchParams instead of capturing them in useRef on first render.
The ref-based approach broke under React 18 StrictMode dev: the
unmount cleanup reset selectedId to null, and the 2nd mount couldn't
re-apply because the ref had been cleared by the 1st mount's match.
Also fixes a related bug (Image #25 in PR review): cross-agent install
button was gated on the user's current sidebar scope, so a project-only
extension viewed in All-scopes mode showed clickable "Install to Agent"
buttons that would deploy with the wrong source. Gate now uses the
group's actual instance scopes.
Known smell β extensions.tsx accumulates several "didApply" refs and
fragile effect-ordering comments. Tracked for a follow-up cleanup.
.gitignore: add .worktrees/ (sibling worktree directories).
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Followup to e61120c. The fix-by-fix iteration there left: - duplicated scope-resolution + scopesEqual logic in two pages - 3 useEffects in extensions.tsx with fragile declaration-order coupling - a getState() escape hatch in the prevScopeRef cleanup - a "didApplyScopeRef" one-shot flag layered on top of a useRef-captured pendingGroupKey that broke under React 18 StrictMode dev double-mount - comment blocks longer than the code they explained Refactor: - Extract resolveDeepLinkScope(urlScope, projects) and scopesEqual(a, b) to scope-store.ts. Replaces two ~10-line inline blocks. - agents.tsx: drop the inline scope-resolution; use the new helpers. - extensions.tsx: collapse three deep-link-related effects into one. Drop didApplyScopeRef. Order: prevScopeRef cleanup first (declared before the deep-link effect), so the cleanup runs as a no-op on initial mount, then the deep-link effect can pre-sync prevScopeRef to the new scope. Cleanup no longer needs the getState() escape hatch. - extensions.tsx deep-link effect now calls setSearchParams({}) after applying, mirroring agents.tsx. Without this, every subsequent scope change (e.g. user clicks Sidebar ScopeSwitcher) would re-fire the effect (scope dep), see the still-present groupKey, and "restore" the deep-link's scope/selection β fighting the user in a loop. Net: -34 lines across 3 files, two effects shorter, no TS regressions, 126/126 frontend tests green. Manually verified: Agent Activity deep link, Recently Installed deep link, sidebar scope switch (with and without prior deep link), detail-panel auto-close on scope change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The single-scope mode hint rendered "Β· π Global" with a leading bullet character that was a leftover from an earlier separator design. Remove it so the hint reads as a clean "π Global" / "π <project name>". Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Adds project scope as a first-class dimension across the extension model, alongside Global. Users can switch between Global / Project / All-scopes via a Sidebar ScopeSwitcher; install / toggle / uninstall / Check Updates / audit all become scope-aware end-to-end.
Builds on PR #29 (project skill v1) by completing the scope-aware UX, fixing scope-related correctness bugs in update detection and sibling sync, and tightening the install dialogs.
What's in scope
Scope plumbing
Pages
Install flow
Update detection
UX polish
Test plan
Not exercised manually (low-risk or covered by tests):
Follow-ups (logged)
π€ Generated with Claude Code