From 7717fc2b6d24a97357af843532fe8d568565a43a Mon Sep 17 00:00:00 2001 From: RealZST Date: Thu, 30 Apr 2026 16:18:06 +0300 Subject: [PATCH 01/27] feat(scope): add useScopeStore + useScope hook + URL/localStorage sync 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) --- package-lock.json | 130 ++++++++++++++++++++++++++ package.json | 1 + src/components/layout/app-shell.tsx | 46 ++++++++- src/hooks/use-scope.ts | 43 +++++++++ src/lib/__tests__/scope-store.test.ts | 74 +++++++++++++++ src/lib/__tests__/use-scope.test.tsx | 58 ++++++++++++ src/stores/scope-store.ts | 78 ++++++++++++++++ 7 files changed, 429 insertions(+), 1 deletion(-) create mode 100644 src/hooks/use-scope.ts create mode 100644 src/lib/__tests__/scope-store.test.ts create mode 100644 src/lib/__tests__/use-scope.test.tsx create mode 100644 src/stores/scope-store.ts diff --git a/package-lock.json b/package-lock.json index 3264fd5..eb82b25 100644 --- a/package-lock.json +++ b/package-lock.json @@ -32,6 +32,7 @@ "devDependencies": { "@biomejs/biome": "2.4.10", "@tailwindcss/vite": "^4.0.0", + "@testing-library/react": "^16.3.2", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.0.0", @@ -339,6 +340,16 @@ "@babel/core": "^7.0.0-0" } }, + "node_modules/@babel/runtime": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.2.tgz", + "integrity": "sha512-JiDShH45zKHWyGe4ZNVRrCjBz8Nh9TMmZG1kh4QTK8hCBTWBi8Da+i7s1fJw7/lYpM4ccepSNfqzZ/QvABBi5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, "node_modules/@babel/template": { "version": "7.28.6", "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", @@ -2095,12 +2106,68 @@ "@tauri-apps/api": "^2.10.1" } }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, "node_modules/@tootallnate/quickjs-emscripten": { "version": "0.23.0", "resolved": "https://registry.npmjs.org/@tootallnate/quickjs-emscripten/-/quickjs-emscripten-0.23.0.tgz", "integrity": "sha512-C5Mc6rdnsaJDjO3UpGW/CQTHtCKaYlScZTly4JIu97Jxo/odCiH0ITnDXSJPTOrEKk/ycSZ0AOgTmkDtkOsvIA==", "license": "MIT" }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT" + }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2238,6 +2305,7 @@ "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", "dev": true, "license": "MIT", + "peer": true, "peerDependencies": { "@types/react": "^19.2.0" } @@ -2437,6 +2505,16 @@ "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", "license": "Python-2.0" }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, "node_modules/assertion-error": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", @@ -2983,6 +3061,13 @@ "license": "BSD-3-Clause", "peer": true }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT" + }, "node_modules/electron-to-chromium": { "version": "1.5.331", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.331.tgz", @@ -3933,6 +4018,16 @@ "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" } }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "bin": { + "lz-string": "bin/bin.js" + } + }, "node_modules/magic-string": { "version": "0.30.21", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", @@ -4774,6 +4869,34 @@ "node": "^10 || ^12 || >=14" } }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/pretty-format/node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, "node_modules/progress": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", @@ -4909,6 +5032,13 @@ "react": "^19.2.4" } }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT" + }, "node_modules/react-markdown": { "version": "10.1.0", "resolved": "https://registry.npmjs.org/react-markdown/-/react-markdown-10.1.0.tgz", diff --git a/package.json b/package.json index 7fd7215..71f9fb6 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "devDependencies": { "@biomejs/biome": "2.4.10", "@tailwindcss/vite": "^4.0.0", + "@testing-library/react": "^16.3.2", "@types/react": "^19.0.0", "@types/react-dom": "^19.0.0", "@vitejs/plugin-react": "^4.0.0", diff --git a/src/components/layout/app-shell.tsx b/src/components/layout/app-shell.tsx index 2066af8..35111e5 100644 --- a/src/components/layout/app-shell.tsx +++ b/src/components/layout/app-shell.tsx @@ -1,7 +1,9 @@ import { getCurrentWindow } from "@tauri-apps/api/window"; import { useEffect, useRef } from "react"; -import { Outlet, useLocation } from "react-router-dom"; +import { Outlet, useLocation, useSearchParams } from "react-router-dom"; import { ToastContainer } from "@/components/shared/toast-container"; +import { useProjectStore } from "@/stores/project-store"; +import { useScopeStore } from "@/stores/scope-store"; import { Sidebar } from "./sidebar"; const INTERACTIVE = "a, button, input, select, textarea, [role='button']"; @@ -13,6 +15,48 @@ export function AppShell() { mainRef.current?.scrollTo(0, 0); }, []); + const [searchParams, setSearchParams] = useSearchParams(); + const projects = useProjectStore((s) => s.projects); + const projectsLoaded = useProjectStore((s) => !s.loading); + const scopeHydrated = useScopeStore((s) => s.hydrated); + const scope = useScopeStore((s) => s.current); + + // Effect 1: load projects on first mount if not already loaded + useEffect(() => { + if ( + useProjectStore.getState().projects.length === 0 && + !useProjectStore.getState().loading + ) { + useProjectStore.getState().loadProjects(); + } + }, []); + + // Effect 2: hydrate scope-store once after projects load + useEffect(() => { + if (!projectsLoaded || scopeHydrated) return; + const urlScope = searchParams.get("scope"); + useScopeStore.getState().hydrate(urlScope, projects); + }, [projectsLoaded, projects, searchParams, scopeHydrated]); + + // Effect 3: keep URL in sync with store (covers programmatic setScope from + // stores that can't use the useScope hook, e.g. project-store.removeProject + // in Task 10). Without this, the URL would drift stale after such calls. + useEffect(() => { + if (!scopeHydrated) return; + const expected = + scope.type === "global" + ? null + : scope.type === "all" + ? "all" + : scope.path; + const current = searchParams.get("scope"); + if (current === expected) return; + const params = new URLSearchParams(searchParams); + if (expected == null) params.delete("scope"); + else params.set("scope", expected); + setSearchParams(params, { replace: true }); + }, [scope, scopeHydrated, searchParams, setSearchParams]); + // Window dragging — anywhere outside
and interactive elements useEffect(() => { const onMouseDown = (e: MouseEvent) => { diff --git a/src/hooks/use-scope.ts b/src/hooks/use-scope.ts new file mode 100644 index 0000000..eec6ff6 --- /dev/null +++ b/src/hooks/use-scope.ts @@ -0,0 +1,43 @@ +import { useCallback } from "react"; +import { useNavigate, useSearchParams } from "react-router-dom"; +import { type ScopeValue, useScopeStore } from "@/stores/scope-store"; + +function scopeToUrlValue(scope: ScopeValue): string | null { + if (scope.type === "global") return null; // default → no param + if (scope.type === "all") return "all"; + return scope.path; +} + +function computeScopeId(scope: ScopeValue): string { + if (scope.type === "all") return "all"; + if (scope.type === "global") return "global"; + return scope.path; +} + +export function useScope() { + const scope = useScopeStore((s) => s.current); + const setScopeStore = useScopeStore((s) => s.setScope); + const [searchParams] = useSearchParams(); + const navigate = useNavigate(); + + const setScope = useCallback( + (next: ScopeValue) => { + setScopeStore(next); + // Mirror to URL via replace (don't pollute browser history with scope changes) + const params = new URLSearchParams(searchParams); + const urlValue = scopeToUrlValue(next); + if (urlValue == null) params.delete("scope"); + else params.set("scope", urlValue); + const search = params.toString(); + navigate({ search: search ? `?${search}` : "" }, { replace: true }); + }, + [setScopeStore, searchParams, navigate], + ); + + return { + scope, + scopeId: computeScopeId(scope), + isAll: scope.type === "all", + setScope, + }; +} diff --git a/src/lib/__tests__/scope-store.test.ts b/src/lib/__tests__/scope-store.test.ts new file mode 100644 index 0000000..07946c5 --- /dev/null +++ b/src/lib/__tests__/scope-store.test.ts @@ -0,0 +1,74 @@ +import { beforeEach, describe, expect, it } from "vitest"; +import type { Project } from "@/lib/types"; +import { useScopeStore } from "@/stores/scope-store"; + +const makeProject = (name: string, path: string): Project => ({ + id: name, + name, + path, + created_at: "2026-01-01T00:00:00Z", + exists: true, +}); + +beforeEach(() => { + localStorage.clear(); + useScopeStore.setState({ current: { type: "global" }, hydrated: false }); +}); + +describe("scope-store hydrate", () => { + it("uses URL scope when valid project path", () => { + const projects = [makeProject("alpha", "/Users/me/alpha")]; + useScopeStore.getState().hydrate("/Users/me/alpha", projects); + expect(useScopeStore.getState().current).toEqual({ + type: "project", + name: "alpha", + path: "/Users/me/alpha", + }); + }); + + it("uses URL 'global' value", () => { + useScopeStore.getState().hydrate("global", []); + expect(useScopeStore.getState().current).toEqual({ type: "global" }); + }); + + it("uses URL 'all' when projects exist", () => { + useScopeStore.getState().hydrate("all", [makeProject("a", "/p")]); + expect(useScopeStore.getState().current).toEqual({ type: "all" }); + }); + + it("coerces URL 'all' to global when no projects", () => { + useScopeStore.getState().hydrate("all", []); + expect(useScopeStore.getState().current).toEqual({ type: "global" }); + }); + + it("falls back to localStorage when URL is null", () => { + localStorage.setItem( + "HK_SCOPE_LAST_USED", + JSON.stringify({ type: "project", name: "beta", path: "/b" }), + ); + useScopeStore.getState().hydrate(null, [makeProject("beta", "/b")]); + expect(useScopeStore.getState().current).toEqual({ + type: "project", + name: "beta", + path: "/b", + }); + }); + + it("falls back to global when localStorage is invalid", () => { + localStorage.setItem("HK_SCOPE_LAST_USED", "not-json{{"); + useScopeStore.getState().hydrate(null, []); + expect(useScopeStore.getState().current).toEqual({ type: "global" }); + }); + + it("sets hydrated true after hydrate", () => { + useScopeStore.getState().hydrate(null, []); + expect(useScopeStore.getState().hydrated).toBe(true); + }); + + it("writes the resolved value back to localStorage", () => { + useScopeStore.getState().hydrate("all", []); // coerces to global + expect(localStorage.getItem("HK_SCOPE_LAST_USED")).toBe( + JSON.stringify({ type: "global" }), + ); + }); +}); diff --git a/src/lib/__tests__/use-scope.test.tsx b/src/lib/__tests__/use-scope.test.tsx new file mode 100644 index 0000000..c5a331d --- /dev/null +++ b/src/lib/__tests__/use-scope.test.tsx @@ -0,0 +1,58 @@ +import { act, renderHook } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { beforeEach, describe, expect, it } from "vitest"; +import { useScope } from "@/hooks/use-scope"; +import { useScopeStore } from "@/stores/scope-store"; + +beforeEach(() => { + localStorage.clear(); + useScopeStore.setState({ current: { type: "global" }, hydrated: true }); +}); + +const wrapper = ({ children }: { children: React.ReactNode }) => ( + {children} +); + +describe("useScope", () => { + it("returns current scope from store", () => { + const { result } = renderHook(() => useScope(), { wrapper }); + expect(result.current.scope).toEqual({ type: "global" }); + expect(result.current.scopeId).toBe("global"); + expect(result.current.isAll).toBe(false); + }); + + it("setScope updates store and writes localStorage", () => { + const { result } = renderHook(() => useScope(), { wrapper }); + act(() => { + result.current.setScope({ + type: "project", + name: "alpha", + path: "/p/alpha", + }); + }); + expect(useScopeStore.getState().current).toEqual({ + type: "project", + name: "alpha", + path: "/p/alpha", + }); + expect(localStorage.getItem("HK_SCOPE_LAST_USED")).toBe( + JSON.stringify({ type: "project", name: "alpha", path: "/p/alpha" }), + ); + }); + + it("scopeId returns 'all' for all scope", () => { + useScopeStore.setState({ current: { type: "all" }, hydrated: true }); + const { result } = renderHook(() => useScope(), { wrapper }); + expect(result.current.scopeId).toBe("all"); + expect(result.current.isAll).toBe(true); + }); + + it("scopeId returns project path for project scope", () => { + useScopeStore.setState({ + current: { type: "project", name: "x", path: "/p/x" }, + hydrated: true, + }); + const { result } = renderHook(() => useScope(), { wrapper }); + expect(result.current.scopeId).toBe("/p/x"); + }); +}); diff --git a/src/stores/scope-store.ts b/src/stores/scope-store.ts new file mode 100644 index 0000000..b886efe --- /dev/null +++ b/src/stores/scope-store.ts @@ -0,0 +1,78 @@ +import { create } from "zustand"; +import type { Project } from "@/lib/types"; + +const LS_KEY = "HK_SCOPE_LAST_USED"; + +export type ScopeValue = + | { type: "all" } + | { type: "global" } + | { type: "project"; name: string; path: string }; + +interface ScopeState { + current: ScopeValue; + hydrated: boolean; + setScope: (scope: ScopeValue) => void; + hydrate: (urlScope: string | null, projects: Project[]) => void; +} + +function parseUrlScope( + urlScope: string | null, + projects: Project[], +): ScopeValue | null { + if (!urlScope) return null; + if (urlScope === "all") { + return projects.length > 0 ? { type: "all" } : null; + } + if (urlScope === "global") return { type: "global" }; + const proj = projects.find((p) => p.path === urlScope); + if (proj) return { type: "project", name: proj.name, path: proj.path }; + return null; +} + +function readLocalStorage(projects: Project[]): ScopeValue | null { + try { + const raw = localStorage.getItem(LS_KEY); + if (!raw) return null; + const parsed = JSON.parse(raw) as ScopeValue; + if (parsed.type === "global") return parsed; + if (parsed.type === "all") { + return projects.length > 0 ? parsed : null; + } + if (parsed.type === "project") { + const proj = projects.find((p) => p.path === parsed.path); + return proj + ? { type: "project", name: proj.name, path: proj.path } + : null; + } + return null; + } catch { + localStorage.removeItem(LS_KEY); + return null; + } +} + +function writeLocalStorage(scope: ScopeValue) { + try { + localStorage.setItem(LS_KEY, JSON.stringify(scope)); + } catch { + // ignore (private mode / quota) + } +} + +export const useScopeStore = create((set) => ({ + current: { type: "global" }, + hydrated: false, + + setScope(scope) { + writeLocalStorage(scope); + set({ current: scope }); + }, + + hydrate(urlScope, projects) { + const fromUrl = parseUrlScope(urlScope, projects); + const fromLs = readLocalStorage(projects); + const resolved: ScopeValue = fromUrl ?? fromLs ?? { type: "global" }; + writeLocalStorage(resolved); + set({ current: resolved, hydrated: true }); + }, +})); From 255a09aef6110381b313f25ec519bc86ac8921fa Mon Sep 17 00:00:00 2001 From: RealZST Date: Thu, 30 Apr 2026 16:25:27 +0300 Subject: [PATCH 02/27] fix(scope): gate scope hydration on project-store load completion MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/components/layout/app-shell.tsx | 2 +- src/lib/__tests__/scope-store.test.ts | 14 ++++++++++ src/lib/__tests__/use-scope.test.tsx | 39 +++++++++++++++++++++++++-- src/stores/project-store.ts | 6 +++-- 4 files changed, 56 insertions(+), 5 deletions(-) diff --git a/src/components/layout/app-shell.tsx b/src/components/layout/app-shell.tsx index 35111e5..99b5fbe 100644 --- a/src/components/layout/app-shell.tsx +++ b/src/components/layout/app-shell.tsx @@ -17,7 +17,7 @@ export function AppShell() { const [searchParams, setSearchParams] = useSearchParams(); const projects = useProjectStore((s) => s.projects); - const projectsLoaded = useProjectStore((s) => !s.loading); + const projectsLoaded = useProjectStore((s) => s.loaded); const scopeHydrated = useScopeStore((s) => s.hydrated); const scope = useScopeStore((s) => s.current); diff --git a/src/lib/__tests__/scope-store.test.ts b/src/lib/__tests__/scope-store.test.ts index 07946c5..e1af1fe 100644 --- a/src/lib/__tests__/scope-store.test.ts +++ b/src/lib/__tests__/scope-store.test.ts @@ -71,4 +71,18 @@ describe("scope-store hydrate", () => { JSON.stringify({ type: "global" }), ); }); + + it("URL with unknown project path falls through to global when project list is empty", () => { + useScopeStore.getState().hydrate("/Users/me/gone", []); + expect(useScopeStore.getState().current).toEqual({ type: "global" }); + }); + + it("localStorage with deleted project falls back to global", () => { + localStorage.setItem( + "HK_SCOPE_LAST_USED", + JSON.stringify({ type: "project", name: "old", path: "/p/old" }), + ); + useScopeStore.getState().hydrate(null, []); + expect(useScopeStore.getState().current).toEqual({ type: "global" }); + }); }); diff --git a/src/lib/__tests__/use-scope.test.tsx b/src/lib/__tests__/use-scope.test.tsx index c5a331d..12fa825 100644 --- a/src/lib/__tests__/use-scope.test.tsx +++ b/src/lib/__tests__/use-scope.test.tsx @@ -1,5 +1,11 @@ -import { act, renderHook } from "@testing-library/react"; -import { MemoryRouter } from "react-router-dom"; +import { + act, + fireEvent, + render, + renderHook, + screen, +} from "@testing-library/react"; +import { MemoryRouter, useLocation } from "react-router-dom"; import { beforeEach, describe, expect, it } from "vitest"; import { useScope } from "@/hooks/use-scope"; import { useScopeStore } from "@/stores/scope-store"; @@ -55,4 +61,33 @@ describe("useScope", () => { const { result } = renderHook(() => useScope(), { wrapper }); expect(result.current.scopeId).toBe("/p/x"); }); + + it("setScope writes scope to URL via replace", () => { + // Render with a starting URL so we can observe what changes + let currentSearch = ""; + const Probe = () => { + const location = useLocation(); + currentSearch = location.search; + return null; + }; + const Harness = () => { + const { setScope } = useScope(); + return ( + <> + + + + + ); + }; + render( + + + , + ); + fireEvent.click(screen.getByText("set-all")); + expect(currentSearch).toBe("?scope=all"); + fireEvent.click(screen.getByText("set-global")); + expect(currentSearch).toBe(""); // global → param removed + }); }); diff --git a/src/stores/project-store.ts b/src/stores/project-store.ts index 948a382..3eaf3eb 100644 --- a/src/stores/project-store.ts +++ b/src/stores/project-store.ts @@ -5,6 +5,7 @@ import type { Project } from "@/lib/types"; interface ProjectState { projects: Project[]; loading: boolean; + loaded: boolean; loadProjects: () => Promise; addProject: (path: string) => Promise; @@ -14,15 +15,16 @@ interface ProjectState { export const useProjectStore = create((set) => ({ projects: [], loading: false, + loaded: false, async loadProjects() { set({ loading: true }); try { const projects = await api.listProjects(); - set({ projects, loading: false }); + set({ projects, loading: false, loaded: true }); } catch (e) { console.error("Failed to load projects:", e); - set({ loading: false }); + set({ loading: false, loaded: true }); } }, From e352b58c4139dbb3550e859c85ff92a8391f1a39 Mon Sep 17 00:00:00 2001 From: RealZST Date: Thu, 30 Apr 2026 16:31:09 +0300 Subject: [PATCH 03/27] feat(scope): add Sidebar ScopeSwitcher component + dropdown Custom dropdown built without Radix/Headless UI (codebase doesn't have either; existing dropdowns are native 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) --- .../extensions/extension-filters.tsx | 49 +----------------- src/components/extensions/extension-table.tsx | 10 +++- src/pages/extensions.tsx | 4 -- .../__tests__/extension-helpers.test.ts | 51 ++++++++++++++++++- src/stores/extension-helpers.ts | 21 ++++++-- src/stores/extension-store.ts | 21 ++------ 6 files changed, 81 insertions(+), 75 deletions(-) diff --git a/src/components/extensions/extension-filters.tsx b/src/components/extensions/extension-filters.tsx index 3d315ab..ade0f62 100644 --- a/src/components/extensions/extension-filters.tsx +++ b/src/components/extensions/extension-filters.tsx @@ -1,14 +1,8 @@ import { clsx } from "clsx"; import { Search, X } from "lucide-react"; import { useMemo } from "react"; -import { - agentDisplayName, - type ExtensionKind, - scopeKey, - scopeLabel, - sortAgents, -} from "@/lib/types"; import { isDesktop } from "@/lib/transport"; +import { agentDisplayName, type ExtensionKind, sortAgents } from "@/lib/types"; import { useAgentStore } from "@/stores/agent-store"; import { useExtensionStore } from "@/stores/extension-store"; @@ -74,8 +68,6 @@ export function ExtensionFilters() { const setKindFilter = useExtensionStore((s) => s.setKindFilter); const agentFilter = useExtensionStore((s) => s.agentFilter); const setAgentFilter = useExtensionStore((s) => s.setAgentFilter); - const scopeFilter = useExtensionStore((s) => s.scopeFilter); - const setScopeFilter = useExtensionStore((s) => s.setScopeFilter); const searchQuery = useExtensionStore((s) => s.searchQuery); const setSearchQuery = useExtensionStore((s) => s.setSearchQuery); const packFilter = useExtensionStore((s) => s.packFilter); @@ -91,19 +83,6 @@ export function ExtensionFilters() { } return counts; }, [grouped, extensions]); - /** Distinct scopes present in the current extension list, preserving - * first-seen order so "Global" stays leading. */ - const availableScopes = useMemo(() => { - const seen = new Set(); - const result: { key: string; label: string }[] = []; - for (const ext of extensions) { - const key = scopeKey(ext.scope); - if (seen.has(key)) continue; - seen.add(key); - result.push({ key, label: scopeLabel(ext.scope) }); - } - return result; - }, [extensions]); const agents = useAgentStore((s) => s.agents); const agentOrder = useAgentStore((s) => s.agentOrder); const enabledAgents = useMemo( @@ -138,16 +117,11 @@ export function ExtensionFilters() { {resultCount} result{resultCount !== 1 ? "s" : ""} - {(kindFilter || - agentFilter || - scopeFilter || - packFilter || - searchQuery) && ( + {(kindFilter || agentFilter || packFilter || searchQuery) && ( + )} + + ); +} diff --git a/src/lib/__tests__/scope-target-field.test.tsx b/src/lib/__tests__/scope-target-field.test.tsx new file mode 100644 index 0000000..29e2572 --- /dev/null +++ b/src/lib/__tests__/scope-target-field.test.tsx @@ -0,0 +1,92 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { ScopeTargetField } from "@/components/shared/scope-target-field"; +import { useProjectStore } from "@/stores/project-store"; +import { useScopeStore } from "@/stores/scope-store"; + +beforeEach(() => { + useScopeStore.setState({ current: { type: "global" }, hydrated: true }); + useProjectStore.setState({ projects: [], loading: false, loaded: true }); +}); + +const wrap = (ui: React.ReactNode) => ( + {ui} +); + +describe("ScopeTargetField", () => { + it("renders a hint (no picker) in single-scope mode", () => { + useScopeStore.setState({ current: { type: "global" }, hydrated: true }); + render(wrap( {}} />)); + expect(screen.getByText("Global")).toBeTruthy(); + expect(screen.queryByRole("combobox")).toBeNull(); + }); + + it("renders a project hint in project scope", () => { + useScopeStore.setState({ + current: { type: "project", name: "alpha", path: "/p/alpha" }, + hydrated: true, + }); + render(wrap( {}} />)); + expect(screen.getByText("alpha")).toBeTruthy(); + }); + + it("renders a required dropdown in All-scopes mode", () => { + useScopeStore.setState({ current: { type: "all" }, hydrated: true }); + useProjectStore.setState({ + projects: [ + { id: "alpha", name: "alpha", path: "/p/alpha", created_at: "", exists: true }, + ], + loading: false, + loaded: true, + }); + render(wrap( {}} />)); + const select = screen.getByLabelText(/install to scope/i) as HTMLSelectElement; + expect(select).toBeTruthy(); + expect(select.value).toBe(""); + }); + + it("calls onChange with selected scope", () => { + useScopeStore.setState({ current: { type: "all" }, hydrated: true }); + useProjectStore.setState({ + projects: [ + { id: "alpha", name: "alpha", path: "/p/alpha", created_at: "", exists: true }, + ], + loading: false, + loaded: true, + }); + const onChange = vi.fn(); + render(wrap()); + fireEvent.change(screen.getByLabelText(/install to scope/i), { + target: { value: "global" }, + }); + expect(onChange).toHaveBeenCalledWith({ type: "global" }); + }); + + it("shows smart-default 'Use X' shortcut when value is null and smartDefault provided", () => { + useScopeStore.setState({ current: { type: "all" }, hydrated: true }); + useProjectStore.setState({ + projects: [ + { id: "alpha", name: "alpha", path: "/p/alpha", created_at: "", exists: true }, + ], + loading: false, + loaded: true, + }); + const onChange = vi.fn(); + render( + wrap( + , + ), + ); + fireEvent.click(screen.getByText(/use alpha/i)); + expect(onChange).toHaveBeenCalledWith({ + type: "project", + name: "alpha", + path: "/p/alpha", + }); + }); +}); diff --git a/src/pages/marketplace.tsx b/src/pages/marketplace.tsx index 8cd5fe4..f5ff15b 100644 --- a/src/pages/marketplace.tsx +++ b/src/pages/marketplace.tsx @@ -21,19 +21,23 @@ import { useEffect, useRef, useState } from "react"; import { InstallDialog } from "@/components/extensions/install-dialog"; import { AgentMascot } from "@/components/shared/agent-mascot/agent-mascot"; import { Hint } from "@/components/shared/hint"; +import { ScopeTargetField } from "@/components/shared/scope-target-field"; import { useScope } from "@/hooks/use-scope"; import { useScrollPassthrough } from "@/hooks/use-scroll-passthrough"; +import { canInstallAtScope } from "@/lib/agent-capabilities"; import { humanizeError } from "@/lib/errors"; import { agentDisplayName, type ConfigScope, type MarketplaceItem, + scopeKey, type SkillAuditInfo, sortAgents, } from "@/lib/types"; import { useAgentStore } from "@/stores/agent-store"; import { useExtensionStore } from "@/stores/extension-store"; import { useMarketplaceStore } from "@/stores/marketplace-store"; +import type { ScopeValue } from "@/stores/scope-store"; import { toast } from "@/stores/toast-store"; /** Extract install-related section from README markdown. @@ -225,11 +229,14 @@ export default function MarketplacePage() { } = useMarketplaceStore(); const { agents, fetch: fetchAgents, agentOrder } = useAgentStore(); const extensions = useExtensionStore((s) => s.extensions); - const { scope } = useScope(); - // scope.type === "all" is impossible in single-scope mode; in All-scopes mode - // Task 9 will supply a picker. For Task 8, narrow with a placeholder. - const targetScope: ConfigScope = - scope.type === "all" ? { type: "global" } : scope; + const { scope, isAll } = useScope(); + const [installTargetScope, setInstallTargetScope] = + useState(null); + // In single-scope mode, the active scope IS the install target. In All-scopes + // mode, the user must pick a scope via ScopeTargetField (null until picked). + const effectiveTarget: ConfigScope | null = isAll + ? installTargetScope + : (scope as ConfigScope); const [installed, setInstalled] = useState>(new Set()); const [justInstalled, setJustInstalled] = useState>(new Set()); const [error, setError] = useState(null); @@ -237,13 +244,22 @@ export default function MarketplacePage() { const [installMode, setInstallMode] = useState<"git" | "local">("git"); const detailPanelRef = useRef(null); - const isItemInstalled = (item: MarketplaceItem, agentName: string) => { + const isItemInstalled = ( + item: MarketplaceItem, + agentName: string, + activeScope: ScopeValue, + ) => { const key = `${item.id}:${agentName}`; if (installed.has(key)) return true; + const targetKey = + activeScope.type === "all" + ? null + : scopeKey(activeScope as ConfigScope); + return extensions.some((ext) => { if (!ext.agents.includes(agentName)) return false; - + if (item.kind === "skill") { if (!["skill", "plugin"].includes(ext.kind)) return false; } else { @@ -266,16 +282,23 @@ export default function MarketplacePage() { extSource.toLowerCase() === itemSourceLower || (ext.pack ?? "").toLowerCase() === itemSourceLower; + let nameMatches: boolean; if (item.kind === "skill") { // Match strictly by name. The scanner sometimes classifies individual // items in a collection repo (e.g. github/awesome-copilot) as kind=plugin, // so "same source URL + kind=plugin" doesn't reliably mean the whole repo // is installed — it could be just one sibling. See PR #21 discussion. const targetName = item.skill_id && item.skill_id.length > 0 ? item.skill_id : item.name; - return ext.name.toLowerCase() === targetName.toLowerCase() && matchSource; + nameMatches = ext.name.toLowerCase() === targetName.toLowerCase(); + } else { + nameMatches = ext.name.toLowerCase() === item.name.toLowerCase(); } + if (!nameMatches || !matchSource) return false; - return ext.name.toLowerCase() === item.name.toLowerCase() && matchSource; + // Scope-aware: in single-scope mode only count installs in the active + // scope. In All-scopes mode (targetKey === null) any scope counts. + if (targetKey === null) return true; + return scopeKey(ext.scope) === targetKey; }); }; @@ -302,7 +325,11 @@ export default function MarketplacePage() { search(); }, 300); }; - const handleInstall = async (item: MarketplaceItem, targetAgent?: string) => { + const handleInstall = async ( + item: MarketplaceItem, + targetAgent: string | undefined, + targetScope: ConfigScope, + ) => { setError(null); try { const result = await install(item, targetAgent, targetScope); @@ -698,24 +725,59 @@ export default function MarketplacePage() { {/* Install to agents */} {detectedAgents.length > 0 && selectedItem.kind === "skill" && (
-

- Install to Agent -

+
+

+ Install to Agent +

+ +
{detectedAgents.map((agent) => { const key = `${selectedItem.id}:${agent.name}`; - const isInstalled = isItemInstalled(selectedItem, agent.name); + // ConfigScope ⊂ ScopeValue (ScopeValue adds the "all" + // variant), so we can pass either through + // canInstallAtScope's ScopeValue parameter. + const targetScopeForCheck: ScopeValue = + effectiveTarget ?? scope; + const capabilityOk = canInstallAtScope( + agent.name, + "skill", + targetScopeForCheck, + ); + const isInstalled = isItemInstalled( + selectedItem, + agent.name, + targetScopeForCheck, + ); const isFlashing = justInstalled.has(key); const isInstallingThis = installing === key; const isInstallingAny = installing?.startsWith(`${selectedItem.id}:`) ?? false; + const disabled = + !effectiveTarget || + isInstallingAny || + isInstalled || + !capabilityOk; return ( + + ) : hasFilters ? (

{kindFilter === "skill" ? "No skills match your filters." diff --git a/src/pages/audit.tsx b/src/pages/audit.tsx index 7256a2b..ceb49c9 100644 --- a/src/pages/audit.tsx +++ b/src/pages/audit.tsx @@ -20,6 +20,7 @@ import type { ConfigScope, Extension } from "@/lib/types"; import { extensionGroupKey, formatRelativeTime, + scopeLabel, type TrustTier, trustTier, } from "@/lib/types"; @@ -266,6 +267,14 @@ export default function AuditPage() { return filtered; }, [groupedResults, searchQuery, tierFilter]); + // Scope-aware empty state: when the user has scoped to a specific project + // but no audit findings exist in that scope (yet results exist elsewhere), + // surface a focused empty state instead of the generic filter UI. + const isProjectScopeEmpty = + scope.type === "project" && + scopedResults.length === 0 && + results.length > 0; + const scrollToExtensionResult = useCallback( (extensionId: string) => { const group = groupedResults.find( @@ -468,20 +477,34 @@ export default function AuditPage() {

)} - {filteredResults.length === 0 && results.length > 0 && !loading && ( -
- No extensions match your filters. - + {isProjectScopeEmpty && !loading && ( +
+

+ No audit findings in {scopeLabel(scope as ConfigScope)} +

+

+ Either nothing is installed in this scope, or all extensions + passed audit. +

)} + {!isProjectScopeEmpty && + filteredResults.length === 0 && + results.length > 0 && + !loading && ( +
+ No extensions match your filters. + +
+ )} {extensionsReady && filteredResults.map((group) => { const { primaryId } = group; From 453d723b8141da160591b732c7fabf0b18f7ff6f Mon Sep 17 00:00:00 2001 From: RealZST Date: Thu, 30 Apr 2026 20:35:44 +0300 Subject: [PATCH 15/27] chore(scope): a11y polish on switcher and dialogs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/components/layout/scope-switcher-menu.tsx | 49 ++++++++++++++++++- src/lib/__tests__/scope-switcher.test.tsx | 24 +++++++++ src/pages/marketplace.tsx | 9 ++-- 3 files changed, 77 insertions(+), 5 deletions(-) diff --git a/src/components/layout/scope-switcher-menu.tsx b/src/components/layout/scope-switcher-menu.tsx index 0b7bb5c..c42427d 100644 --- a/src/components/layout/scope-switcher-menu.tsx +++ b/src/components/layout/scope-switcher-menu.tsx @@ -1,4 +1,5 @@ import { Check, Folder, Globe, LayoutGrid, Plus } from "lucide-react"; +import { useEffect, useMemo, useState } from "react"; import { useScope } from "@/hooks/use-scope"; import { openDirectoryPicker } from "@/lib/dialog"; import { useProjectStore } from "@/stores/project-store"; @@ -12,6 +13,10 @@ interface MenuItem { icon: React.ElementType; } +const ADD_PROJECT_KEY = "__add_project__"; + +type NavigableItem = MenuItem | { key: typeof ADD_PROJECT_KEY }; + export function ScopeSwitcherMenu({ onClose }: { onClose: () => void }) { const { scope, setScope } = useScope(); const projects = useProjectStore((s) => s.projects); @@ -75,6 +80,44 @@ export function ScopeSwitcherMenu({ onClose }: { onClose: () => void }) { const allItem = items.find((i) => i.key === "all"); const restItems = items.filter((i) => i.key !== "all"); + // Flat list of every selectable row in render order, used for ↑/↓ keyboard + // navigation. The Add Project virtual row is appended at the end. + const navigableItems = useMemo(() => { + const list: NavigableItem[] = []; + if (allItem) list.push(allItem); + for (const it of restItems) list.push(it); + list.push({ key: ADD_PROJECT_KEY }); + return list; + }, [allItem, restItems]); + + const [activeIndex, setActiveIndex] = useState(0); + + useEffect(() => { + const onKey = (e: KeyboardEvent) => { + if (e.key === "ArrowDown") { + e.preventDefault(); + setActiveIndex((i) => Math.min(i + 1, navigableItems.length - 1)); + } else if (e.key === "ArrowUp") { + e.preventDefault(); + setActiveIndex((i) => Math.max(i - 1, 0)); + } else if (e.key === "Enter") { + e.preventDefault(); + const item = navigableItems[activeIndex]; + if (!item) return; + if (item.key === ADD_PROJECT_KEY) handleAddProject(); + else handleSelect(item as MenuItem); + } + }; + document.addEventListener("keydown", onKey); + return () => document.removeEventListener("keydown", onKey); + // handleSelect / handleAddProject are stable enough for this scope — + // including activeIndex + navigableItems is sufficient to pick up + // changes that affect dispatch. + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [activeIndex, navigableItems]); + + const activeKey = navigableItems[activeIndex]?.key; + // Render helper: JSX requires a CapitalCase identifier for components, so // we alias item.icon to a local PascalCase variable before using it as JSX. const renderOption = (item: MenuItem) => { @@ -84,8 +127,9 @@ export function ScopeSwitcherMenu({ onClose }: { onClose: () => void }) { key={item.key} role="option" aria-selected={isCurrent(item)} + data-active={activeKey === item.key ? "true" : undefined} onClick={() => handleSelect(item)} - className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-accent" + className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-accent data-[active=true]:bg-accent" > {item.label} @@ -109,7 +153,8 @@ export function ScopeSwitcherMenu({ onClose }: { onClose: () => void }) {
-
)} @@ -520,14 +533,6 @@ export default function AuditPage() { ); const passedCount = applicableRules.length - failedRules.length; - // In All-scopes mode each row gets a ScopeBadge so the user - // can tell which scope a finding belongs to. In single-scope - // mode the scope is implicit so we hide the badge. - const groupScope = - scope.type === "all" - ? scopeMap.get(group.primaryId) - : undefined; - // Clean extensions: minimal row if (!hasFindings) { return ( @@ -545,7 +550,6 @@ export default function AuditPage() { {group.name} - {groupScope && }
Clean
@@ -571,7 +575,6 @@ export default function AuditPage() { className={`text-muted-foreground transition-transform duration-200 ${isOpen ? "rotate-90" : ""}`} /> {group.name} - {groupScope && } {group.findings.length}{" "} {group.findings.length === 1 ? "finding" : "findings"} diff --git a/src/pages/marketplace.tsx b/src/pages/marketplace.tsx index f8665a9..3382ea6 100644 --- a/src/pages/marketplace.tsx +++ b/src/pages/marketplace.tsx @@ -729,11 +729,25 @@ export default function MarketplacePage() {

Install to Agent

- + {/* Single-scope mode: render the inline "· 📁 name" hint + * next to the header to save vertical space. All-scopes + * mode renders the picker on its own row below since the + * dropdown doesn't fit alongside the header. */} + {!isAll && ( + + )} + {isAll && ( +
+ +
+ )}
{detectedAgents.map((agent) => { const key = `${selectedItem.id}:${agent.name}`; @@ -783,14 +797,12 @@ export default function MarketplacePage() { ) } className={clsx( - "flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-[background-color,border-color] duration-300", + "flex items-center gap-1.5 rounded-lg border px-3 py-1.5 text-xs font-medium transition-[background-color,border-color] duration-300 disabled:opacity-50 disabled:cursor-not-allowed disabled:hover:bg-primary/10 disabled:hover:border-border", isFlashing ? "border-primary/40 bg-primary/20 text-foreground" : isInstalled ? "border-primary/20 bg-primary/10 text-foreground" : "border-border bg-primary/10 text-foreground hover:bg-primary/20 hover:border-ring", - (isInstallingThis || isInstalled) && - "disabled:opacity-50", )} >
From 85fa94d5d87b560d2d295d0febeafa34b11692a7 Mon Sep 17 00:00:00 2001 From: RealZST Date: Fri, 1 May 2026 12:58:58 +0300 Subject: [PATCH 19/27] =?UTF-8?q?fix(panels):=20collapse=20Agents=20file?= =?UTF-8?q?=20previews=20on=20scope/page=20change=20+=20restore=20focus=20?= =?UTF-8?q?on=20Overview=E2=86=92Agents=20handoff?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- src/components/agents/config-file-entry.tsx | 21 ++++++++++----- src/pages/agents.tsx | 30 ++++++++++++++++++++- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/src/components/agents/config-file-entry.tsx b/src/components/agents/config-file-entry.tsx index 77db3cd..a628e5d 100644 --- a/src/components/agents/config-file-entry.tsx +++ b/src/components/agents/config-file-entry.tsx @@ -61,6 +61,11 @@ export function ConfigFileEntry({ file }: { file: AgentConfigFile }) { // already force-opened so this row is mounted. Scroll it into view, flash a // ring for ~1.5s, then clear the pending state so a subsequent navigation // to the same file re-triggers the effect. + // + // We clear pendingFocusFile *inside* the rAF (after the scroll fires) so the + // store update doesn't cause a synchronous re-run that cancels our own rAF. + // The highlight timer is split into its own effect so re-renders triggered + // by the store update can't kill the 1.5s ring before it shows. useEffect(() => { if (pendingFocusFile !== file.path) return; const el = buttonRef.current; @@ -70,15 +75,19 @@ export function ConfigFileEntry({ file }: { file: AgentConfigFile }) { const raf = requestAnimationFrame(() => { el.scrollIntoView({ behavior: "smooth", block: "center" }); setHighlight(true); + setPendingFocusFile(null); }); - const timer = setTimeout(() => setHighlight(false), 1500); - setPendingFocusFile(null); - return () => { - cancelAnimationFrame(raf); - clearTimeout(timer); - }; + return () => cancelAnimationFrame(raf); }, [pendingFocusFile, file.path, setPendingFocusFile]); + // Clear the highlight 1.5s after it turns on. Independent of pendingFocusFile + // so a same-frame store update doesn't cancel the timer prematurely. + useEffect(() => { + if (!highlight) return; + const timer = setTimeout(() => setHighlight(false), 1500); + return () => clearTimeout(timer); + }, [highlight]); + const scopePath = file.custom_id != null ? file.path diff --git a/src/pages/agents.tsx b/src/pages/agents.tsx index d38e0de..d7aaca8 100644 --- a/src/pages/agents.tsx +++ b/src/pages/agents.tsx @@ -1,7 +1,8 @@ -import { useEffect } from "react"; +import { useEffect, useRef } from "react"; import { useSearchParams } from "react-router-dom"; import { AgentDetail } from "@/components/agents/agent-detail"; import { AgentList } from "@/components/agents/agent-list"; +import { useScope } from "@/hooks/use-scope"; import { useAgentConfigStore } from "@/stores/agent-config-store"; import { useScopeStore } from "@/stores/scope-store"; @@ -14,6 +15,7 @@ export default function AgentsPage() { const setPendingFocusFile = useAgentConfigStore( (s) => s.setPendingFocusFile, ); + const { scope } = useScope(); const [searchParams, setSearchParams] = useSearchParams(); useEffect(() => { @@ -21,6 +23,32 @@ export default function AgentsPage() { fetch(); }, [fetch, hydrated]); + // When the user switches scope (e.g., via the Sidebar ScopeSwitcher), collapse + // all expanded file previews and drop any pending focus signal — the file + // visible just before the switch may not exist (or differ) in the new scope. + const prevScopeRef = useRef(scope); + useEffect(() => { + if (prevScopeRef.current !== scope) { + useAgentConfigStore.setState({ + expandedFiles: new Set(), + pendingFocusFile: null, + }); + prevScopeRef.current = scope; + } + }, [scope]); + + // Collapse expansions when leaving the page so revisiting starts clean. + // expandedFiles lives in zustand (persists across remounts) — without this, + // navigating to Extensions and back would keep an old preview pane open. + useEffect(() => { + return () => { + useAgentConfigStore.setState({ + expandedFiles: new Set(), + pendingFocusFile: null, + }); + }; + }, []); + useEffect(() => { const agent = searchParams.get("agent"); const file = searchParams.get("file"); From a6b7eb633add82a920b0036e8ff76be1d46ba542 Mon Sep 17 00:00:00 2001 From: RealZST Date: Fri, 1 May 2026 12:59:11 +0300 Subject: [PATCH 20/27] feat(install): scope picker for NewSkillsDialog + Extensions panel hygiene MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../extensions/new-skills-dialog.tsx | 26 +++++++++++++++--- src/pages/extensions.tsx | 27 ++++++++++++++----- 2 files changed, 44 insertions(+), 9 deletions(-) diff --git a/src/components/extensions/new-skills-dialog.tsx b/src/components/extensions/new-skills-dialog.tsx index 806c052..2d3b6c9 100644 --- a/src/components/extensions/new-skills-dialog.tsx +++ b/src/components/extensions/new-skills-dialog.tsx @@ -1,7 +1,9 @@ import { Download, Loader2, Package } from "lucide-react"; import { useEffect, useRef, useState } from "react"; +import { ScopeTargetField } from "@/components/shared/scope-target-field"; import { useFocusTrap } from "@/hooks/use-focus-trap"; -import type { NewRepoSkill } from "@/lib/types"; +import { useScope } from "@/hooks/use-scope"; +import type { ConfigScope, NewRepoSkill } from "@/lib/types"; import { agentDisplayName, sortAgents } from "@/lib/types"; import { useAgentStore } from "@/stores/agent-store"; import { toast } from "@/stores/toast-store"; @@ -12,6 +14,7 @@ interface NewSkillsDialogProps { url: string, skillIds: string[], targetAgents: string[], + targetScope: ConfigScope, ) => Promise; onDismiss: () => void; onClose: () => void; @@ -29,6 +32,13 @@ export function NewSkillsDialog({ ); const [selectedAgents, setSelectedAgents] = useState>(new Set()); const [installing, setInstalling] = useState(false); + const { scope, isAll } = useScope(); + // Single-scope mode: the active scope is the install target. All-scopes + // mode: user must pick via ScopeTargetField (null until picked). + const [pickedScope, setPickedScope] = useState(null); + const effectiveTarget: ConfigScope | null = isAll + ? pickedScope + : (scope as ConfigScope); const agents = useAgentStore((s) => s.agents); const agentOrder = useAgentStore((s) => s.agentOrder); @@ -96,6 +106,7 @@ export function NewSkillsDialog({ }; const handleInstall = async () => { + if (!effectiveTarget) return; setInstalling(true); try { const targetAgents = [...selectedAgents]; @@ -105,7 +116,7 @@ export function NewSkillsDialog({ ); if (selectedSkills.length === 0) continue; const skillIds = selectedSkills.map((s) => s.skill_id); - await onInstall(url, skillIds, targetAgents); + await onInstall(url, skillIds, targetAgents, effectiveTarget); } onClose(); } catch (e: unknown) { @@ -117,7 +128,8 @@ export function NewSkillsDialog({ }; const selectedCount = selected.size; - const canInstall = selectedCount > 0 && selectedAgents.size > 0; + const canInstall = + selectedCount > 0 && selectedAgents.size > 0 && effectiveTarget !== null; return (
+ {/* Scope picker (All-scopes mode) / scope hint (single-scope mode) */} +
+ +
+ {/* Agent selection */} {detectedAgents.length > 0 && (
diff --git a/src/pages/extensions.tsx b/src/pages/extensions.tsx index 1f49173..a69c090 100644 --- a/src/pages/extensions.tsx +++ b/src/pages/extensions.tsx @@ -6,7 +6,6 @@ import { ExtensionFilters } from "@/components/extensions/extension-filters"; import { ExtensionTable } from "@/components/extensions/extension-table"; import { NewSkillsDialog } from "@/components/extensions/new-skills-dialog"; import { useScope } from "@/hooks/use-scope"; -import type { ConfigScope } from "@/lib/types"; import { useAgentStore } from "@/stores/agent-store"; import { useExtensionStore } from "@/stores/extension-store"; import { useScopeStore } from "@/stores/scope-store"; @@ -97,10 +96,26 @@ export default function ExtensionsPage() { const data = useExtensionStore((s) => s.filtered()); const batchMode = selectedIds.size > 0; const { scope } = useScope(); - // scope.type === "all" is impossible in single-scope mode; in All-scopes mode - // Task 9 will supply a picker. For Task 8, narrow with a placeholder. - const targetScope: ConfigScope = - scope.type === "all" ? { type: "global" } : scope; + + // When the user switches scope (e.g., via the Sidebar ScopeSwitcher), the + // currently-selected extension may not exist in the new scope. Close the + // detail panel rather than leaving it showing a row from the previous scope. + const prevScopeRef = useRef(scope); + useEffect(() => { + if (prevScopeRef.current !== scope) { + setSelectedId(null); + prevScopeRef.current = scope; + } + }, [scope, setSelectedId]); + + // Close the detail panel when leaving the page so revisiting starts clean. + // selectedId lives in zustand (persists across remounts) — without this, + // navigating to Agents and back would keep an old row open. + useEffect(() => { + return () => { + useExtensionStore.setState({ selectedId: null }); + }; + }, []); const fetchAgents = useAgentStore((s) => s.fetch); const didFetchRef = useRef(false); @@ -273,7 +288,7 @@ export default function ExtensionsPage() { {showNewSkills && newRepoSkills.length > 0 && ( { + onInstall={async (url, skillIds, targetAgents, targetScope) => { await installNewRepoSkills( url, skillIds, From 05ac559191f169451198f220962a0969c1b2d92e Mon Sep 17 00:00:00 2001 From: RealZST Date: Fri, 1 May 2026 12:59:40 +0300 Subject: [PATCH 21/27] refactor(scope): re-style Sidebar ScopeSwitcher to match nav links + 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) --- src/components/layout/scope-switcher-menu.tsx | 45 ++++++++----------- src/components/layout/scope-switcher.tsx | 17 +++---- src/components/layout/sidebar.tsx | 6 +-- 3 files changed, 27 insertions(+), 41 deletions(-) diff --git a/src/components/layout/scope-switcher-menu.tsx b/src/components/layout/scope-switcher-menu.tsx index c42427d..0aabea1 100644 --- a/src/components/layout/scope-switcher-menu.tsx +++ b/src/components/layout/scope-switcher-menu.tsx @@ -1,10 +1,9 @@ -import { Check, Folder, Globe, LayoutGrid, Plus } from "lucide-react"; +import { Check, Folder, Plus } from "lucide-react"; import { useEffect, useMemo, useState } from "react"; +import { useNavigate } from "react-router-dom"; import { useScope } from "@/hooks/use-scope"; -import { openDirectoryPicker } from "@/lib/dialog"; import { useProjectStore } from "@/stores/project-store"; import type { ScopeValue } from "@/stores/scope-store"; -import { toast } from "@/stores/toast-store"; interface MenuItem { key: string; @@ -20,7 +19,7 @@ type NavigableItem = MenuItem | { key: typeof ADD_PROJECT_KEY }; export function ScopeSwitcherMenu({ onClose }: { onClose: () => void }) { const { scope, setScope } = useScope(); const projects = useProjectStore((s) => s.projects); - const addProject = useProjectStore((s) => s.addProject); + const navigate = useNavigate(); const items: MenuItem[] = []; if (projects.length > 0) { @@ -28,14 +27,14 @@ export function ScopeSwitcherMenu({ onClose }: { onClose: () => void }) { key: "all", scope: { type: "all" }, label: "All scopes", - icon: LayoutGrid, + icon: Folder, }); } items.push({ key: "global", scope: { type: "global" }, label: "Global", - icon: Globe, + icon: Folder, }); for (const p of projects) { items.push({ @@ -58,22 +57,9 @@ export function ScopeSwitcherMenu({ onClose }: { onClose: () => void }) { onClose(); }; - const handleAddProject = async () => { - const path = await openDirectoryPicker(); - if (!path) return; - try { - await addProject(path); - const fresh = useProjectStore - .getState() - .projects.find((p) => p.path === path); - if (fresh) { - setScope({ type: "project", name: fresh.name, path: fresh.path }); - toast.success(`Project '${fresh.name}' added and selected`); - } - onClose(); - } catch (e) { - toast.error(`Failed to add project: ${String(e)}`); - } + const handleAddProject = () => { + navigate("/settings"); + onClose(); }; // Group items: All scopes | (sep) | Global + projects | (sep) | Add Project @@ -90,7 +76,14 @@ export function ScopeSwitcherMenu({ onClose }: { onClose: () => void }) { return list; }, [allItem, restItems]); - const [activeIndex, setActiveIndex] = useState(0); + const [activeIndex, setActiveIndex] = useState(() => { + // Start with the currently selected scope highlighted, so opening the + // menu doesn't visually jump to "All scopes" regardless of state. + const idx = navigableItems.findIndex( + (item) => item.key !== ADD_PROJECT_KEY && isCurrent(item as MenuItem), + ); + return idx >= 0 ? idx : 0; + }); useEffect(() => { const onKey = (e: KeyboardEvent) => { @@ -129,7 +122,7 @@ export function ScopeSwitcherMenu({ onClose }: { onClose: () => void }) { aria-selected={isCurrent(item)} data-active={activeKey === item.key ? "true" : undefined} onClick={() => handleSelect(item)} - className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-sm hover:bg-accent data-[active=true]:bg-accent" + className="flex w-full items-center gap-2 rounded-lg px-2 py-1.5 text-sm hover:bg-accent data-[active=true]:bg-accent" > {item.label} @@ -141,7 +134,7 @@ export function ScopeSwitcherMenu({ onClose }: { onClose: () => void }) { return (
{allItem && ( <> @@ -154,7 +147,7 @@ export function ScopeSwitcherMenu({ onClose }: { onClose: () => void }) { {open && setOpen(false)} />}
diff --git a/src/components/layout/sidebar.tsx b/src/components/layout/sidebar.tsx index b086e72..a83f8c6 100644 --- a/src/components/layout/sidebar.tsx +++ b/src/components/layout/sidebar.tsx @@ -77,10 +77,8 @@ export function Sidebar() {
- - {/* Branding divider */} -
+
)}
diff --git a/src/components/extensions/install-dialog.tsx b/src/components/extensions/install-dialog.tsx index 8c09de1..f9263f4 100644 --- a/src/components/extensions/install-dialog.tsx +++ b/src/components/extensions/install-dialog.tsx @@ -139,11 +139,7 @@ export function InstallDialog({ open, mode, onClose }: InstallDialogProps) { }; const handleInstallAction = async () => { - if ( - !source.trim() || - selectedAgents.size === 0 || - !installTargetScope - ) { + if (!source.trim() || selectedAgents.size === 0 || !installTargetScope) { return; } setLoading(true); diff --git a/src/components/layout/scope-switcher.tsx b/src/components/layout/scope-switcher.tsx index 94195eb..ee0700c 100644 --- a/src/components/layout/scope-switcher.tsx +++ b/src/components/layout/scope-switcher.tsx @@ -45,7 +45,11 @@ export function ScopeSwitcher() { > {label} - + {open && setOpen(false)} />}
diff --git a/src/lib/__tests__/scope-target-field.test.tsx b/src/lib/__tests__/scope-target-field.test.tsx index 29e2572..0ed3dee 100644 --- a/src/lib/__tests__/scope-target-field.test.tsx +++ b/src/lib/__tests__/scope-target-field.test.tsx @@ -10,9 +10,7 @@ beforeEach(() => { useProjectStore.setState({ projects: [], loading: false, loaded: true }); }); -const wrap = (ui: React.ReactNode) => ( - {ui} -); +const wrap = (ui: React.ReactNode) => {ui}; describe("ScopeTargetField", () => { it("renders a hint (no picker) in single-scope mode", () => { @@ -35,13 +33,21 @@ describe("ScopeTargetField", () => { useScopeStore.setState({ current: { type: "all" }, hydrated: true }); useProjectStore.setState({ projects: [ - { id: "alpha", name: "alpha", path: "/p/alpha", created_at: "", exists: true }, + { + id: "alpha", + name: "alpha", + path: "/p/alpha", + created_at: "", + exists: true, + }, ], loading: false, loaded: true, }); render(wrap( {}} />)); - const select = screen.getByLabelText(/install to scope/i) as HTMLSelectElement; + const select = screen.getByLabelText( + /install to scope/i, + ) as HTMLSelectElement; expect(select).toBeTruthy(); expect(select.value).toBe(""); }); @@ -50,7 +56,13 @@ describe("ScopeTargetField", () => { useScopeStore.setState({ current: { type: "all" }, hydrated: true }); useProjectStore.setState({ projects: [ - { id: "alpha", name: "alpha", path: "/p/alpha", created_at: "", exists: true }, + { + id: "alpha", + name: "alpha", + path: "/p/alpha", + created_at: "", + exists: true, + }, ], loading: false, loaded: true, @@ -67,7 +79,13 @@ describe("ScopeTargetField", () => { useScopeStore.setState({ current: { type: "all" }, hydrated: true }); useProjectStore.setState({ projects: [ - { id: "alpha", name: "alpha", path: "/p/alpha", created_at: "", exists: true }, + { + id: "alpha", + name: "alpha", + path: "/p/alpha", + created_at: "", + exists: true, + }, ], loading: false, loaded: true, diff --git a/src/lib/__tests__/types.test.ts b/src/lib/__tests__/types.test.ts index 04466b2..f608bd2 100644 --- a/src/lib/__tests__/types.test.ts +++ b/src/lib/__tests__/types.test.ts @@ -211,7 +211,9 @@ describe("extensionGroupKey", () => { ...fooInAlpha, scope: { type: "project", name: "beta", path: "/Users/me/beta" }, }; - expect(extensionGroupKey(fooInAlpha)).not.toBe(extensionGroupKey(fooInBeta)); + expect(extensionGroupKey(fooInAlpha)).not.toBe( + extensionGroupKey(fooInBeta), + ); }); }); diff --git a/src/lib/__tests__/use-scope.test.tsx b/src/lib/__tests__/use-scope.test.tsx index 12fa825..e4eb9a4 100644 --- a/src/lib/__tests__/use-scope.test.tsx +++ b/src/lib/__tests__/use-scope.test.tsx @@ -76,7 +76,9 @@ describe("useScope", () => { <> - + ); }; diff --git a/src/lib/agent-capabilities.ts b/src/lib/agent-capabilities.ts index f0643a7..42a7147 100644 --- a/src/lib/agent-capabilities.ts +++ b/src/lib/agent-capabilities.ts @@ -14,13 +14,13 @@ import type { ScopeValue } from "@/stores/scope-store"; // // Keep in sync when adapters change project-level declarations. const PROJECT_INSTALL_SUPPORT: Record> = { - claude: new Set(["skill", "mcp", "hook", "cli"]), - codex: new Set(["skill"]), // MCP/hook adapter completion deferred (v2) - cursor: new Set(["skill", "mcp", "hook"]), - windsurf: new Set(["skill", "mcp", "hook"]), - gemini: new Set(["skill"]), // MCP/hook adapter completion deferred (v2) - antigravity: new Set(["skill"]), // MCP/hook adapter completion deferred (v2) - copilot: new Set(["skill"]), // MCP adapter completion deferred (v2) + claude: new Set(["skill", "mcp", "hook", "cli"]), + codex: new Set(["skill"]), // MCP/hook adapter completion deferred (v2) + cursor: new Set(["skill", "mcp", "hook"]), + windsurf: new Set(["skill", "mcp", "hook"]), + gemini: new Set(["skill"]), // MCP/hook adapter completion deferred (v2) + antigravity: new Set(["skill"]), // MCP/hook adapter completion deferred (v2) + copilot: new Set(["skill"]), // MCP adapter completion deferred (v2) }; /** Whether the agent's adapter declares project-level support for this kind. diff --git a/src/lib/invoke.ts b/src/lib/invoke.ts index 3054589..a258e63 100644 --- a/src/lib/invoke.ts +++ b/src/lib/invoke.ts @@ -302,7 +302,12 @@ export const api = { label: string, category: string, ): Promise { - return transport("update_custom_config_path", { id, path, label, category }); + return transport("update_custom_config_path", { + id, + path, + label, + category, + }); }, removeCustomConfigPath(id: number): Promise { diff --git a/src/pages/agents.tsx b/src/pages/agents.tsx index d7aaca8..ac4f5cc 100644 --- a/src/pages/agents.tsx +++ b/src/pages/agents.tsx @@ -12,9 +12,7 @@ export default function AgentsPage() { const loading = useAgentConfigStore((s) => s.loading); const selectAgent = useAgentConfigStore((s) => s.selectAgent); const expandFile = useAgentConfigStore((s) => s.expandFile); - const setPendingFocusFile = useAgentConfigStore( - (s) => s.setPendingFocusFile, - ); + const setPendingFocusFile = useAgentConfigStore((s) => s.setPendingFocusFile); const { scope } = useScope(); const [searchParams, setSearchParams] = useSearchParams(); @@ -73,9 +71,7 @@ export default function AgentsPage() { ]); if (!hydrated) { - return ( -
Loading...
- ); + return
Loading...
; } return ( diff --git a/src/pages/audit.tsx b/src/pages/audit.tsx index e02c506..4c32f7b 100644 --- a/src/pages/audit.tsx +++ b/src/pages/audit.tsx @@ -325,9 +325,7 @@ export default function AuditPage() { } if (!hydrated) { - return ( -
Loading...
- ); + return
Loading...
; } return ( diff --git a/src/pages/extensions.tsx b/src/pages/extensions.tsx index a69c090..fdcc3a1 100644 --- a/src/pages/extensions.tsx +++ b/src/pages/extensions.tsx @@ -127,9 +127,7 @@ export default function ExtensionsPage() { }, [fetch, fetchAgents, hydrated]); if (!hydrated) { - return ( -
Loading...
- ); + return
Loading...
; } return ( diff --git a/src/pages/marketplace.tsx b/src/pages/marketplace.tsx index 3382ea6..f6e3411 100644 --- a/src/pages/marketplace.tsx +++ b/src/pages/marketplace.tsx @@ -253,9 +253,7 @@ export default function MarketplacePage() { if (installed.has(key)) return true; const targetKey = - activeScope.type === "all" - ? null - : scopeKey(activeScope as ConfigScope); + activeScope.type === "all" ? null : scopeKey(activeScope as ConfigScope); return extensions.some((ext) => { if (!ext.agents.includes(agentName)) return false; @@ -266,7 +264,10 @@ export default function MarketplacePage() { if (ext.kind !== item.kind) return false; } - const extUrl = ext.install_meta?.url_resolved ?? ext.install_meta?.url ?? ext.source.url; + const extUrl = + ext.install_meta?.url_resolved ?? + ext.install_meta?.url ?? + ext.source.url; let extSource = ""; if (extUrl) { const match = extUrl.match(/github\.com\/([^/]+\/[^/]+)/); @@ -288,7 +289,8 @@ export default function MarketplacePage() { // items in a collection repo (e.g. github/awesome-copilot) as kind=plugin, // so "same source URL + kind=plugin" doesn't reliably mean the whole repo // is installed — it could be just one sibling. See PR #21 discussion. - const targetName = item.skill_id && item.skill_id.length > 0 ? item.skill_id : item.name; + const targetName = + item.skill_id && item.skill_id.length > 0 ? item.skill_id : item.name; nameMatches = ext.name.toLowerCase() === targetName.toLowerCase(); } else { nameMatches = ext.name.toLowerCase() === item.name.toLowerCase(); @@ -808,7 +810,11 @@ export default function MarketplacePage() {
- + {agentDisplayName(agent.name)} {isInstalled ? ( @@ -817,9 +823,15 @@ export default function MarketplacePage() { className="animate-scale-in text-primary shrink-0" /> ) : isInstallingThis ? ( - + ) : ( - + )} ); diff --git a/src/pages/overview.tsx b/src/pages/overview.tsx index b1c36bb..486d05d 100644 --- a/src/pages/overview.tsx +++ b/src/pages/overview.tsx @@ -141,9 +141,7 @@ function QuickAction({ {label} - - {sublabel} - + {sublabel}
); diff --git a/src/pages/settings.tsx b/src/pages/settings.tsx index 824b1a1..feca85f 100644 --- a/src/pages/settings.tsx +++ b/src/pages/settings.tsx @@ -383,7 +383,11 @@ export default function SettingsPage() {
setProjectPathInput(e.target.value)} onKeyDown={(e) => { From e61120c8b25204a6f7147726e67efa87eafefc61 Mon Sep 17 00:00:00 2001 From: RealZST Date: Fri, 1 May 2026 15:24:36 +0300 Subject: [PATCH 25/27] fix(scope): Overview deep links carry scope through to Agents/Extensions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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=, 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) --- .gitignore | 1 + .../extensions/extension-detail.tsx | 15 ++- src/components/layout/app-shell.tsx | 1 + src/hooks/use-scope.ts | 39 +++---- src/lib/__tests__/use-scope.test.tsx | 45 ++------ src/pages/agents.tsx | 39 ++++++- src/pages/extensions.tsx | 106 +++++++++++++----- src/pages/overview.tsx | 39 +++++-- 8 files changed, 181 insertions(+), 104 deletions(-) diff --git a/.gitignore b/.gitignore index b64f9cf..de5dd15 100644 --- a/.gitignore +++ b/.gitignore @@ -8,6 +8,7 @@ docs/ .superpowers/ .claude/ +.worktrees/ branding/ .impeccable.md .env* diff --git a/src/components/extensions/extension-detail.tsx b/src/components/extensions/extension-detail.tsx index 783f0b9..70b5dda 100644 --- a/src/components/extensions/extension-detail.tsx +++ b/src/components/extensions/extension-detail.tsx @@ -16,7 +16,6 @@ import { DetailHeader } from "@/components/extensions/detail-header"; import { DetailPaths } from "@/components/extensions/detail-paths"; import { PermissionDetail } from "@/components/extensions/permission-detail"; import { SkillFileSection } from "@/components/extensions/skill-file-section"; -import { useScope } from "@/hooks/use-scope"; import { api } from "@/lib/invoke"; import { isDesktop } from "@/lib/transport"; import type { ConfigScope, ExtensionContent as ExtContent } from "@/lib/types"; @@ -59,8 +58,14 @@ export function ExtensionDetail() { const [loadingContent, setLoadingContent] = useState(false); const agents = useAgentStore((s) => s.agents); const agentOrder = useAgentStore((s) => s.agentOrder); - const { scope } = useScope(); - const projectScopeBlocked = scope.type === "project"; + // Cross-agent install (install_to_agent) needs a source instance to copy + // from; v1 service::install_to_agent has no target_scope param so it uses + // the source's scope implicitly. Without a global instance there's no + // scope-safe source — we block. v2 will add target_scope and lift this gate. + const globalSourceInstance = group?.instances.find( + (i) => i.scope.type === "global", + ); + const projectScopeBlocked = !globalSourceInstance; const [deploying, setDeploying] = useState(null); const [activeInstanceId, setActiveInstanceId] = useState(null); const [showDelete, setShowDelete] = useState(false); @@ -404,9 +409,9 @@ export function ExtensionDetail() { seen.add(child.name + child.kind); await installToAgent(child.id, agent.name); } - } else { + } else if (globalSourceInstance) { await installToAgent( - group.instances[0].id, + globalSourceInstance.id, agent.name, ); } diff --git a/src/components/layout/app-shell.tsx b/src/components/layout/app-shell.tsx index 99b5fbe..f99e857 100644 --- a/src/components/layout/app-shell.tsx +++ b/src/components/layout/app-shell.tsx @@ -57,6 +57,7 @@ export function AppShell() { setSearchParams(params, { replace: true }); }, [scope, scopeHydrated, searchParams, setSearchParams]); + // Window dragging — anywhere outside
and interactive elements useEffect(() => { const onMouseDown = (e: MouseEvent) => { diff --git a/src/hooks/use-scope.ts b/src/hooks/use-scope.ts index eec6ff6..4848533 100644 --- a/src/hooks/use-scope.ts +++ b/src/hooks/use-scope.ts @@ -1,12 +1,4 @@ -import { useCallback } from "react"; -import { useNavigate, useSearchParams } from "react-router-dom"; -import { type ScopeValue, useScopeStore } from "@/stores/scope-store"; - -function scopeToUrlValue(scope: ScopeValue): string | null { - if (scope.type === "global") return null; // default → no param - if (scope.type === "all") return "all"; - return scope.path; -} +import { useScopeStore, type ScopeValue } from "@/stores/scope-store"; function computeScopeId(scope: ScopeValue): string { if (scope.type === "all") return "all"; @@ -14,25 +6,20 @@ function computeScopeId(scope: ScopeValue): string { return scope.path; } +/** Read + write the current scope. setScope only mutates the store; URL + * sync is handled by AppShell Effect 3 (store → URL). + * + * Why no inline navigate(): a previous version of this hook called + * navigate({ search: ... }) inside setScope to mirror the URL eagerly, + * but that fought any *follow-up* navigate() in the same tick — e.g. + * Overview's "click an agent file" handler does `setScope(file.scope); + * navigate('/agents?...')`, and React Router would batch the two and + * drop the second navigate. Letting the AppShell effect handle URL + * sync asynchronously sidesteps the conflict and gives us a single + * authoritative direction (store → URL). */ export function useScope() { const scope = useScopeStore((s) => s.current); - const setScopeStore = useScopeStore((s) => s.setScope); - const [searchParams] = useSearchParams(); - const navigate = useNavigate(); - - const setScope = useCallback( - (next: ScopeValue) => { - setScopeStore(next); - // Mirror to URL via replace (don't pollute browser history with scope changes) - const params = new URLSearchParams(searchParams); - const urlValue = scopeToUrlValue(next); - if (urlValue == null) params.delete("scope"); - else params.set("scope", urlValue); - const search = params.toString(); - navigate({ search: search ? `?${search}` : "" }, { replace: true }); - }, - [setScopeStore, searchParams, navigate], - ); + const setScope = useScopeStore((s) => s.setScope); return { scope, diff --git a/src/lib/__tests__/use-scope.test.tsx b/src/lib/__tests__/use-scope.test.tsx index e4eb9a4..7d7b769 100644 --- a/src/lib/__tests__/use-scope.test.tsx +++ b/src/lib/__tests__/use-scope.test.tsx @@ -1,11 +1,5 @@ -import { - act, - fireEvent, - render, - renderHook, - screen, -} from "@testing-library/react"; -import { MemoryRouter, useLocation } from "react-router-dom"; +import { act, renderHook } from "@testing-library/react"; +import { MemoryRouter } from "react-router-dom"; import { beforeEach, describe, expect, it } from "vitest"; import { useScope } from "@/hooks/use-scope"; import { useScopeStore } from "@/stores/scope-store"; @@ -62,34 +56,9 @@ describe("useScope", () => { expect(result.current.scopeId).toBe("/p/x"); }); - it("setScope writes scope to URL via replace", () => { - // Render with a starting URL so we can observe what changes - let currentSearch = ""; - const Probe = () => { - const location = useLocation(); - currentSearch = location.search; - return null; - }; - const Harness = () => { - const { setScope } = useScope(); - return ( - <> - - - - - ); - }; - render( - - - , - ); - fireEvent.click(screen.getByText("set-all")); - expect(currentSearch).toBe("?scope=all"); - fireEvent.click(screen.getByText("set-global")); - expect(currentSearch).toBe(""); // global → param removed - }); + // Note: URL sync used to live inside useScope.setScope but was moved to + // AppShell's Effect 3 (store → URL) so a follow-up navigate() in the same + // tick (e.g. Overview's "click an agent file" handler) doesn't fight a + // search-params-only navigate from the hook. The hook is now a thin + // store-only wrapper; AppShell owns URL mirroring. }); diff --git a/src/pages/agents.tsx b/src/pages/agents.tsx index ac4f5cc..565dfab 100644 --- a/src/pages/agents.tsx +++ b/src/pages/agents.tsx @@ -4,7 +4,8 @@ import { AgentDetail } from "@/components/agents/agent-detail"; import { AgentList } from "@/components/agents/agent-list"; import { useScope } from "@/hooks/use-scope"; import { useAgentConfigStore } from "@/stores/agent-config-store"; -import { useScopeStore } from "@/stores/scope-store"; +import { useProjectStore } from "@/stores/project-store"; +import { type ScopeValue, useScopeStore } from "@/stores/scope-store"; export default function AgentsPage() { const hydrated = useScopeStore((s) => s.hydrated); @@ -13,7 +14,8 @@ export default function AgentsPage() { const selectAgent = useAgentConfigStore((s) => s.selectAgent); const expandFile = useAgentConfigStore((s) => s.expandFile); const setPendingFocusFile = useAgentConfigStore((s) => s.setPendingFocusFile); - const { scope } = useScope(); + const { scope, setScope } = useScope(); + const projects = useProjectStore((s) => s.projects); const [searchParams, setSearchParams] = useSearchParams(); useEffect(() => { @@ -51,6 +53,36 @@ export default function AgentsPage() { const agent = searchParams.get("agent"); const file = searchParams.get("file"); if (!loading && agent) { + // Apply incoming scope FIRST so the file (which may belong to a + // different scope than the user's current one — e.g. clicking a + // global file from Overview while in a project scope) actually + // renders in the list. Only does work for *deep links* (presence of + // `agent` query param); a plain visit to /agents preserves the + // user's current scope selection. + const urlScope = searchParams.get("scope"); + const targetScope: ScopeValue = + urlScope == null + ? { type: "global" } + : urlScope === "all" + ? { type: "all" } + : ((): ScopeValue => { + const proj = projects.find((p) => p.path === urlScope); + return proj + ? { type: "project", name: proj.name, path: proj.path } + : { type: "global" }; + })(); + const sameScope = + targetScope.type === scope.type && + (targetScope.type !== "project" || + (scope.type === "project" && targetScope.path === scope.path)); + if (!sameScope) { + setScope(targetScope); + // Sync prevScopeRef immediately so the scope-change cleanup effect + // (line 28) does NOT clear the expandedFiles + pendingFocusFile we + // are about to set below — without this, the deep-link's focus + // signal is wiped on the next render. + prevScopeRef.current = targetScope; + } selectAgent(agent); if (file) { // expandFile opens the file's preview pane; pendingFocusFile is what @@ -64,6 +96,9 @@ export default function AgentsPage() { }, [ loading, searchParams, + scope, + setScope, + projects, selectAgent, expandFile, setPendingFocusFile, diff --git a/src/pages/extensions.tsx b/src/pages/extensions.tsx index fdcc3a1..a5d87ba 100644 --- a/src/pages/extensions.tsx +++ b/src/pages/extensions.tsx @@ -8,7 +8,8 @@ import { NewSkillsDialog } from "@/components/extensions/new-skills-dialog"; import { useScope } from "@/hooks/use-scope"; import { useAgentStore } from "@/stores/agent-store"; import { useExtensionStore } from "@/stores/extension-store"; -import { useScopeStore } from "@/stores/scope-store"; +import { useProjectStore } from "@/stores/project-store"; +import { type ScopeValue, useScopeStore } from "@/stores/scope-store"; import { toast } from "@/stores/toast-store"; export default function ExtensionsPage() { @@ -24,15 +25,30 @@ export default function ExtensionsPage() { const allGrouped = useExtensionStore((s) => s.grouped); const extensions = useExtensionStore((s) => s.extensions); - const pendingNameRef = useRef(searchParams.get("name")); - const pendingGroupKeyRef = useRef(searchParams.get("groupKey")); + // Read deep-link targets reactively from searchParams (not via useRef + // captured at first render). Ref-captured values get lost across React 18 + // StrictMode dev unmount/remount cycles: 1st mount clears the ref after + // applying; the unmount cleanup further below resets selectedId to null; + // 2nd mount sees a null ref so can't re-apply, leaving the panel empty. + // Reading from searchParams directly lets the effect recover on every + // mount as long as the URL still carries the target. + const groupKeyParam = searchParams.get("groupKey"); + const nameParam = searchParams.get("name"); + const { scope, setScope } = useScope(); + const projects = useProjectStore((s) => s.projects); + // Forward-declared so the didApplyRef block below can sync it when an + // incoming deep-link forces a scope switch (mirrors agents.tsx pattern). + const prevScopeRef = useRef(scope); // Apply query params synchronously on first render to avoid filter-change flash. + // (Scope sync is handled separately in the useEffect below, not here, because + // calling setScope() in render triggers React's "Cannot update a component + // (ScopeSwitcher) while rendering a different component" warning.) const didApplyRef = useRef(false); if (!didApplyRef.current) { const agent = searchParams.get("agent"); if (agent) setAgentFilter(agent); - if (pendingNameRef.current || pendingGroupKeyRef.current) { + if (nameParam || groupKeyParam) { setKindFilter(null); setAgentFilter(null); setPackFilter(null); @@ -41,35 +57,68 @@ export default function ExtensionsPage() { didApplyRef.current = true; } - // Match the extension once data is available and scroll to it + // Apply incoming scope from a deep link (Overview → Extensions). Pairs with + // the prevScopeRef cleanup effect further below which uses getState() (not + // closure) to compare scope, so the sync we do here isn't undone by the + // cleanup running with a stale closure. + const didApplyScopeRef = useRef(false); + useEffect(() => { + if (didApplyScopeRef.current) return; + if (!nameParam && !groupKeyParam) { + didApplyScopeRef.current = true; + return; + } + didApplyScopeRef.current = true; + const urlScope = searchParams.get("scope"); + const targetScope: ScopeValue = + urlScope == null + ? { type: "global" } + : urlScope === "all" + ? { type: "all" } + : ((): ScopeValue => { + const proj = projects.find((p) => p.path === urlScope); + return proj + ? { type: "project", name: proj.name, path: proj.path } + : { type: "global" }; + })(); + const sameScope = + targetScope.type === scope.type && + (targetScope.type !== "project" || + (scope.type === "project" && targetScope.path === scope.path)); + if (!sameScope) { + setScope(targetScope); + prevScopeRef.current = targetScope; + } + }, [scope, setScope, projects, searchParams]); + + // Match the extension once data is available and scroll to it. Reads + // groupKey/name from searchParams (not refs) so the effect recovers + // correctly when re-mounted, e.g. by React StrictMode dev double-mount or + // an unmount-cleanup that reset selectedId. setSelectedId is idempotent so + // re-firing on the same target is harmless. const [scrollToId, setScrollToId] = useState(null); useEffect(() => { if (extensions.length === 0) return; + if (!groupKeyParam && !nameParam) return; const groups = allGrouped(); - - const gk = pendingGroupKeyRef.current; - if (gk) { - const match = groups.find((g) => g.groupKey === gk); + if (groupKeyParam) { + const match = groups.find((g) => g.groupKey === groupKeyParam); if (match) { setSelectedId(match.groupKey); setScrollToId(match.groupKey); - pendingGroupKeyRef.current = null; - pendingNameRef.current = null; } return; } - - const name = pendingNameRef.current; - if (!name) return; - const match = groups.find( - (g) => g.name.toLowerCase() === name.toLowerCase(), - ); - if (match) { - setSelectedId(match.groupKey); - setScrollToId(match.groupKey); - pendingNameRef.current = null; + if (nameParam) { + const match = groups.find( + (g) => g.name.toLowerCase() === nameParam.toLowerCase(), + ); + if (match) { + setSelectedId(match.groupKey); + setScrollToId(match.groupKey); + } } - }, [extensions, allGrouped, setSelectedId]); + }, [extensions, allGrouped, setSelectedId, groupKeyParam, nameParam]); // Individual selectors — prevents unrelated state changes from causing re-renders const loading = useExtensionStore((s) => s.loading); const fetch = useExtensionStore((s) => s.fetch); @@ -95,16 +144,21 @@ export default function ExtensionsPage() { }, [updateStatuses, grouped]); const data = useExtensionStore((s) => s.filtered()); const batchMode = selectedIds.size > 0; - const { scope } = useScope(); // When the user switches scope (e.g., via the Sidebar ScopeSwitcher), the // currently-selected extension may not exist in the new scope. Close the // detail panel rather than leaving it showing a row from the previous scope. - const prevScopeRef = useRef(scope); + // + // Uses useScopeStore.getState().current rather than the closure `scope` — + // the deep-link Scope handling effect above runs in the same commit and + // syncs prevScopeRef.current to the new scope. If we compared against the + // closure scope (still old at that moment), this cleanup would see a + // mismatch and undo the sync, clearing the selectedId we're about to set. useEffect(() => { - if (prevScopeRef.current !== scope) { + const latestScope = useScopeStore.getState().current; + if (prevScopeRef.current !== latestScope) { setSelectedId(null); - prevScopeRef.current = scope; + prevScopeRef.current = latestScope; } }, [scope, setSelectedId]); diff --git a/src/pages/overview.tsx b/src/pages/overview.tsx index 486d05d..d25600a 100644 --- a/src/pages/overview.tsx +++ b/src/pages/overview.tsx @@ -71,7 +71,10 @@ interface ActivityItem { label: string; sublabel: string; timestamp: number; - navigateTo: string; + /** Click handler that should setScope (so the destination page sees the + * right scope) BEFORE navigating. Overview is scope-agnostic, so deep + * links must carry their own scope context. */ + onSelect: () => void; } function formatTerminalCount(value: number) { @@ -334,14 +337,26 @@ export default function OverviewPage() { label: cfg.file_name, sublabel: `${agentDisplayName(agent.name)} \u00B7 Modified ${formatRelativeTime(cfg.modified_at)}`, timestamp: new Date(cfg.modified_at).getTime(), - navigateTo: `/agents?agent=${agent.name}&file=${encodeURIComponent(cfg.path)}`, + // Pass the file's scope through the URL so Agents lands in the + // right scope (Agents reads ?scope= and applies it locally). Doing + // setScope + navigate in the same event handler races: React 18 + // batches both updates and the router update gets dropped. + onSelect: () => { + const scopeParam = + cfg.scope.type === "global" + ? "" + : `&scope=${encodeURIComponent(cfg.scope.path)}`; + navigate( + `/agents?agent=${agent.name}&file=${encodeURIComponent(cfg.path)}${scopeParam}`, + ); + }, }); } } items.sort((a, b) => b.timestamp - a.timestamp); return items.slice(0, 20); - }, [agentConfigs]); + }, [agentConfigs, navigate]); // ----------------------------------------------------------------------- // Section A-right: Recent Extensions (recently installed) @@ -367,13 +382,23 @@ export default function OverviewPage() { label: ext.name, sublabel: `${ext.kind.toUpperCase()} · Installed ${formatRelativeTime(ext.installed_at)}`, timestamp: new Date(ext.installed_at).getTime(), - navigateTo: `/extensions?groupKey=${encodeURIComponent(extensionGroupKey(ext))}`, + // Pass scope through the URL (see config-items comment above for why + // setScope + navigate in the same handler races and loses the nav). + onSelect: () => { + const scopeParam = + ext.scope.type === "global" + ? "" + : `&scope=${encodeURIComponent(ext.scope.path)}`; + navigate( + `/extensions?groupKey=${encodeURIComponent(extensionGroupKey(ext))}${scopeParam}`, + ); + }, }); } items.sort((a, b) => b.timestamp - a.timestamp); return items.slice(0, 20); - }, [visibleExtensions]); + }, [visibleExtensions, navigate]); const hasActivity = agentActivityItems.length > 0 || extensionActivityItems.length > 0; @@ -547,7 +572,7 @@ export default function OverviewPage() { agentActivityItems.map((item, i) => (