refactor(settings): scope the settings project selector to settings
Picking a project in Settings called setActiveProject, which relocates the chat, the session list, the file tree and the Git surface. Reading another project's MCP servers or agents moved the user's whole app. It had to, because the configuration stores resolved the directory themselves from the active project and held one flat list. Each of them now takes an explicit directory — omitted still means the active project, so every caller outside Settings is unchanged — and keys loaded data by directory next to a flat mirror of the active project. Chat, autocompletes and pickers keep reading that mirror; a load for another directory writes only the map. A failed load restores that directory's previous list. Settings resolves its own directory through useSettingsDirectory, backed by a session-local settingsProjectPath that follows the active project until the user picks something else.
This commit is contained in:
@@ -20,6 +20,19 @@ import { filterSkillsByRuntimeFlags } from './skillVisibility';
|
||||
// project selector (and Commands/Agents). Falling back only to the session
|
||||
// directory misses repository-local `.agents/skills` when the client directory
|
||||
// is unset or points elsewhere while an active project exists.
|
||||
/**
|
||||
* Directory a call operates on. Settings can browse another project without
|
||||
* moving the app, so every entry point takes one; omitting it means the project
|
||||
* the app is currently on.
|
||||
*/
|
||||
const resolveDirectory = (directory?: string | null): string | null => {
|
||||
if (directory !== undefined) {
|
||||
const trimmed = directory?.trim();
|
||||
return trimmed ? trimmed : null;
|
||||
}
|
||||
return getRequestDirectory();
|
||||
};
|
||||
|
||||
const getRequestDirectory = (): string | null => {
|
||||
try {
|
||||
const projectsStore = useProjectsStore.getState();
|
||||
@@ -144,24 +157,27 @@ interface SkillDetail {
|
||||
|
||||
interface SkillsStore {
|
||||
selectedSkillName: string | null;
|
||||
/** Skills of the project the app is on. Chat and autocompletes read this one. */
|
||||
skills: DiscoveredSkill[];
|
||||
/** Every directory loaded so far, including the ambient one. */
|
||||
skillsByDirectory: Record<string, DiscoveredSkill[]>;
|
||||
isLoading: boolean;
|
||||
skillDraft: SkillDraft | null;
|
||||
|
||||
setSelectedSkill: (name: string | null) => void;
|
||||
setSkillDraft: (draft: SkillDraft | null) => void;
|
||||
loadSkills: () => Promise<boolean>;
|
||||
getSkillDetail: (name: string) => Promise<SkillDetail | null>;
|
||||
createSkill: (config: SkillConfig) => Promise<boolean>;
|
||||
updateSkill: (name: string, config: Partial<SkillConfig>) => Promise<boolean>;
|
||||
renameSkill: (name: string, newName: string) => Promise<boolean>;
|
||||
deleteSkill: (name: string) => Promise<boolean>;
|
||||
getSkillByName: (name: string) => DiscoveredSkill | undefined;
|
||||
|
||||
loadSkills: (directory?: string | null) => Promise<boolean>;
|
||||
getSkillDetail: (name: string, directory?: string | null) => Promise<SkillDetail | null>;
|
||||
createSkill: (config: SkillConfig, directory?: string | null) => Promise<boolean>;
|
||||
updateSkill: (name: string, config: Partial<SkillConfig>, directory?: string | null) => Promise<boolean>;
|
||||
renameSkill: (name: string, newName: string, directory?: string | null) => Promise<boolean>;
|
||||
deleteSkill: (name: string, directory?: string | null) => Promise<boolean>;
|
||||
getSkillByName: (name: string, directory?: string | null) => DiscoveredSkill | undefined;
|
||||
|
||||
// Supporting files
|
||||
readSupportingFile: (skillName: string, filePath: string) => Promise<string | null>;
|
||||
writeSupportingFile: (skillName: string, filePath: string, content: string) => Promise<boolean>;
|
||||
deleteSupportingFile: (skillName: string, filePath: string) => Promise<boolean>;
|
||||
readSupportingFile: (skillName: string, filePath: string, directory?: string | null) => Promise<string | null>;
|
||||
writeSupportingFile: (skillName: string, filePath: string, content: string, directory?: string | null) => Promise<boolean>;
|
||||
deleteSupportingFile: (skillName: string, filePath: string, directory?: string | null) => Promise<boolean>;
|
||||
}
|
||||
|
||||
declare global {
|
||||
@@ -186,12 +202,16 @@ export const invalidateSkillsLoadCache = (directory: string | null = getRequestD
|
||||
};
|
||||
|
||||
const upsertSkillLocal = (
|
||||
set: (state: Partial<SkillsStore>) => void,
|
||||
set: (updater: (state: SkillsStore) => Partial<SkillsStore>) => void,
|
||||
get: () => SkillsStore,
|
||||
name: string,
|
||||
config: Partial<SkillConfig>,
|
||||
directory: string | null,
|
||||
) => {
|
||||
const existing = get().skills.find((skill) => skill.name === name);
|
||||
const cacheKey = getSkillsCacheKey(directory);
|
||||
const isAmbient = cacheKey === getSkillsCacheKey(getRequestDirectory());
|
||||
const current = get().skillsByDirectory[cacheKey] ?? [];
|
||||
const existing = current.find((skill) => skill.name === name);
|
||||
const path = config.targetPath ?? existing?.path ?? '';
|
||||
const nextSkill: DiscoveredSkill = {
|
||||
...existing,
|
||||
@@ -202,11 +222,16 @@ const upsertSkillLocal = (
|
||||
description: config.description ?? existing?.description ?? '',
|
||||
group: parseSkillGroup(path),
|
||||
};
|
||||
const skills = get().skills;
|
||||
const nextSkills = skills.some((skill) => skill.name === name)
|
||||
? skills.map((skill) => (skill.name === name ? nextSkill : skill))
|
||||
: [...skills, nextSkill];
|
||||
set({ skills: nextSkills });
|
||||
const nextSkills = current.some((skill) => skill.name === name)
|
||||
? current.map((skill) => (skill.name === name ? nextSkill : skill))
|
||||
: [...current, nextSkill];
|
||||
set((state) => {
|
||||
const next: Partial<SkillsStore> = {
|
||||
skillsByDirectory: { ...state.skillsByDirectory, [cacheKey]: nextSkills },
|
||||
};
|
||||
if (isAmbient) next.skills = nextSkills;
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const removeSkillLocal = (
|
||||
@@ -230,12 +255,27 @@ const SLOW_HEALTH_POLL_BASE_MS = 800;
|
||||
const SLOW_HEALTH_POLL_INCREMENT_MS = 200;
|
||||
const SLOW_HEALTH_POLL_MAX_MS = 2000;
|
||||
|
||||
const EMPTY_SKILLS: DiscoveredSkill[] = [];
|
||||
|
||||
/**
|
||||
* Skills of one project. Returns a stored array so components can select it
|
||||
* directly; an omitted directory means the project the app is on.
|
||||
*/
|
||||
export const selectSkillsForDirectory = (
|
||||
state: Pick<SkillsStore, 'skillsByDirectory'>,
|
||||
directory?: string | null,
|
||||
): DiscoveredSkill[] => {
|
||||
const cacheKey = getSkillsCacheKey(resolveDirectory(directory));
|
||||
return state.skillsByDirectory[cacheKey] ?? EMPTY_SKILLS;
|
||||
};
|
||||
|
||||
export const useSkillsStore = create<SkillsStore>()(
|
||||
devtools(
|
||||
persist(
|
||||
(set, get) => ({
|
||||
selectedSkillName: null,
|
||||
skills: [],
|
||||
skillsByDirectory: {},
|
||||
isLoading: false,
|
||||
skillDraft: null,
|
||||
|
||||
@@ -247,12 +287,13 @@ export const useSkillsStore = create<SkillsStore>()(
|
||||
set({ skillDraft: draft });
|
||||
},
|
||||
|
||||
loadSkills: async () => {
|
||||
const directory = getRequestDirectory();
|
||||
loadSkills: async (requestedDirectory?: string | null) => {
|
||||
const directory = resolveDirectory(requestedDirectory);
|
||||
const cacheKey = getSkillsCacheKey(directory);
|
||||
const isAmbient = cacheKey === getSkillsCacheKey(getRequestDirectory());
|
||||
const now = Date.now();
|
||||
const loadedAt = skillsLastLoadedAt.get(cacheKey) ?? 0;
|
||||
const hasCachedSkills = get().skills.length > 0;
|
||||
const hasCachedSkills = (get().skillsByDirectory[cacheKey] ?? (isAmbient ? get().skills : [])).length > 0;
|
||||
|
||||
if (hasCachedSkills && now - loadedAt < SKILLS_LOAD_CACHE_TTL_MS) {
|
||||
return true;
|
||||
@@ -265,7 +306,9 @@ export const useSkillsStore = create<SkillsStore>()(
|
||||
|
||||
const request = (async () => {
|
||||
set({ isLoading: true });
|
||||
const previousSkills = get().skills;
|
||||
// Failure must never look like an empty project. The mirror is the
|
||||
// fallback so a directory loaded before this map existed still counts.
|
||||
const previousSkills = get().skillsByDirectory[cacheKey] ?? (isAmbient ? get().skills : []);
|
||||
let lastError: unknown = null;
|
||||
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
@@ -306,7 +349,14 @@ export const useSkillsStore = create<SkillsStore>()(
|
||||
data.externalSkills ?? null,
|
||||
);
|
||||
|
||||
set({ skills: visibleSkills, isLoading: false });
|
||||
set((state) => {
|
||||
const next: Partial<SkillsStore> = {
|
||||
skillsByDirectory: { ...state.skillsByDirectory, [cacheKey]: visibleSkills },
|
||||
isLoading: false,
|
||||
};
|
||||
if (isAmbient) next.skills = visibleSkills;
|
||||
return next;
|
||||
});
|
||||
skillsLastLoadedAt.set(cacheKey, Date.now());
|
||||
return true;
|
||||
} catch (error) {
|
||||
@@ -317,7 +367,14 @@ export const useSkillsStore = create<SkillsStore>()(
|
||||
}
|
||||
|
||||
console.error("Failed to load skills:", lastError);
|
||||
set({ skills: previousSkills, isLoading: false });
|
||||
set((state) => {
|
||||
const next: Partial<SkillsStore> = {
|
||||
skillsByDirectory: { ...state.skillsByDirectory, [cacheKey]: previousSkills },
|
||||
isLoading: false,
|
||||
};
|
||||
if (isAmbient) next.skills = previousSkills;
|
||||
return next;
|
||||
});
|
||||
return false;
|
||||
})();
|
||||
|
||||
@@ -329,9 +386,9 @@ export const useSkillsStore = create<SkillsStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
getSkillDetail: async (name: string) => {
|
||||
getSkillDetail: async (name: string, requestedDirectory?: string | null) => {
|
||||
try {
|
||||
const directory = getRequestDirectory();
|
||||
const directory = resolveDirectory(requestedDirectory);
|
||||
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
|
||||
|
||||
const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`, {
|
||||
@@ -347,7 +404,7 @@ export const useSkillsStore = create<SkillsStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
createSkill: async (config: SkillConfig) => {
|
||||
createSkill: async (config: SkillConfig, requestedDirectory?: string | null) => {
|
||||
try {
|
||||
const skillConfig: Record<string, unknown> = {
|
||||
name: config.name,
|
||||
@@ -359,7 +416,7 @@ export const useSkillsStore = create<SkillsStore>()(
|
||||
if (config.source) skillConfig.source = config.source;
|
||||
if (config.supportingFiles) skillConfig.supportingFiles = config.supportingFiles;
|
||||
|
||||
const directory = getRequestDirectory();
|
||||
const directory = resolveDirectory(requestedDirectory);
|
||||
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
|
||||
|
||||
const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(config.name)}${queryParams}`, {
|
||||
@@ -380,12 +437,12 @@ export const useSkillsStore = create<SkillsStore>()(
|
||||
invalidateSkillsLoadCache(directory);
|
||||
|
||||
if (payload?.requiresManualRestart) {
|
||||
upsertSkillLocal(set, get, config.name, config);
|
||||
upsertSkillLocal(set, get, config.name, config, directory);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (noteDeferredRestartFromPayload(payload, 'skills', { id: config.name })) {
|
||||
upsertSkillLocal(set, get, config.name, config);
|
||||
upsertSkillLocal(set, get, config.name, config, directory);
|
||||
emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE });
|
||||
return true;
|
||||
}
|
||||
@@ -399,7 +456,7 @@ export const useSkillsStore = create<SkillsStore>()(
|
||||
return true;
|
||||
}
|
||||
|
||||
const loaded = await get().loadSkills();
|
||||
const loaded = await get().loadSkills(directory);
|
||||
if (loaded) {
|
||||
emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE });
|
||||
}
|
||||
@@ -409,7 +466,7 @@ export const useSkillsStore = create<SkillsStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
updateSkill: async (name: string, config: Partial<SkillConfig>) => {
|
||||
updateSkill: async (name: string, config: Partial<SkillConfig>, requestedDirectory?: string | null) => {
|
||||
try {
|
||||
const skillConfig: Record<string, unknown> = {};
|
||||
|
||||
@@ -418,7 +475,7 @@ export const useSkillsStore = create<SkillsStore>()(
|
||||
if (config.supportingFiles !== undefined) skillConfig.supportingFiles = config.supportingFiles;
|
||||
if (config.targetPath !== undefined) skillConfig.targetPath = config.targetPath;
|
||||
|
||||
const directory = getRequestDirectory();
|
||||
const directory = resolveDirectory(requestedDirectory);
|
||||
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
|
||||
|
||||
const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`, {
|
||||
@@ -439,12 +496,12 @@ export const useSkillsStore = create<SkillsStore>()(
|
||||
invalidateSkillsLoadCache(directory);
|
||||
|
||||
if (payload?.requiresManualRestart) {
|
||||
upsertSkillLocal(set, get, name, config);
|
||||
upsertSkillLocal(set, get, name, config, directory);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (noteDeferredRestartFromPayload(payload, 'skills', { id: name })) {
|
||||
upsertSkillLocal(set, get, name, config);
|
||||
upsertSkillLocal(set, get, name, config, directory);
|
||||
emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE });
|
||||
return true;
|
||||
}
|
||||
@@ -458,7 +515,7 @@ export const useSkillsStore = create<SkillsStore>()(
|
||||
return true;
|
||||
}
|
||||
|
||||
const loaded = await get().loadSkills();
|
||||
const loaded = await get().loadSkills(directory);
|
||||
if (loaded) {
|
||||
emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE });
|
||||
}
|
||||
@@ -468,11 +525,11 @@ export const useSkillsStore = create<SkillsStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
renameSkill: async (name: string, newName: string) => {
|
||||
renameSkill: async (name: string, newName: string, requestedDirectory?: string | null) => {
|
||||
startConfigUpdate("Renaming skill...");
|
||||
let requiresReload = false;
|
||||
try {
|
||||
const directory = getRequestDirectory();
|
||||
const directory = resolveDirectory(requestedDirectory);
|
||||
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
|
||||
|
||||
const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`, {
|
||||
@@ -501,7 +558,7 @@ export const useSkillsStore = create<SkillsStore>()(
|
||||
return true;
|
||||
}
|
||||
|
||||
const loaded = await get().loadSkills();
|
||||
const loaded = await get().loadSkills(directory);
|
||||
if (loaded) {
|
||||
emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE });
|
||||
}
|
||||
@@ -515,9 +572,9 @@ export const useSkillsStore = create<SkillsStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
deleteSkill: async (name: string) => {
|
||||
deleteSkill: async (name: string, requestedDirectory?: string | null) => {
|
||||
try {
|
||||
const directory = getRequestDirectory();
|
||||
const directory = resolveDirectory(requestedDirectory);
|
||||
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
|
||||
|
||||
const response = await runtimeFetch(`/api/config/skills/${encodeURIComponent(name)}${queryParams}`, {
|
||||
@@ -553,7 +610,7 @@ export const useSkillsStore = create<SkillsStore>()(
|
||||
return true;
|
||||
}
|
||||
|
||||
const loaded = await get().loadSkills();
|
||||
const loaded = await get().loadSkills(directory);
|
||||
if (loaded) {
|
||||
emitConfigChange("skills", { source: CONFIG_EVENT_SOURCE });
|
||||
}
|
||||
@@ -568,14 +625,13 @@ export const useSkillsStore = create<SkillsStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
getSkillByName: (name: string) => {
|
||||
const { skills } = get();
|
||||
return skills.find((s) => s.name === name);
|
||||
getSkillByName: (name: string, requestedDirectory?: string | null) => {
|
||||
return selectSkillsForDirectory(get(), requestedDirectory).find((skill) => skill.name === name);
|
||||
},
|
||||
|
||||
readSupportingFile: async (skillName: string, filePath: string) => {
|
||||
readSupportingFile: async (skillName: string, filePath: string, requestedDirectory?: string | null) => {
|
||||
try {
|
||||
const directory = getRequestDirectory();
|
||||
const directory = resolveDirectory(requestedDirectory);
|
||||
const queryParams = directory ? `&directory=${encodeURIComponent(directory)}` : '';
|
||||
|
||||
const response = await runtimeFetch(
|
||||
@@ -593,9 +649,9 @@ export const useSkillsStore = create<SkillsStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
writeSupportingFile: async (skillName: string, filePath: string, content: string) => {
|
||||
writeSupportingFile: async (skillName: string, filePath: string, content: string, requestedDirectory?: string | null) => {
|
||||
try {
|
||||
const directory = getRequestDirectory();
|
||||
const directory = resolveDirectory(requestedDirectory);
|
||||
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
|
||||
|
||||
const response = await runtimeFetch(
|
||||
@@ -616,9 +672,9 @@ export const useSkillsStore = create<SkillsStore>()(
|
||||
}
|
||||
},
|
||||
|
||||
deleteSupportingFile: async (skillName: string, filePath: string) => {
|
||||
deleteSupportingFile: async (skillName: string, filePath: string, requestedDirectory?: string | null) => {
|
||||
try {
|
||||
const directory = getRequestDirectory();
|
||||
const directory = resolveDirectory(requestedDirectory);
|
||||
const queryParams = directory ? `?directory=${encodeURIComponent(directory)}` : '';
|
||||
|
||||
const response = await runtimeFetch(
|
||||
|
||||
Reference in New Issue
Block a user