fix: cloud favorite status priority and runtime lifecycle
- deriveStatus: prioritize favorite lifecycle over local config presence
- autoEnableCloudFavorites: skip unloaded items to prevent re-loading
- load/unload: sync agent/command favorites to ~/.claude/{agents,commands}/
- /hub toggle: trigger MCP reconnection and agent definition refresh
- cloud-enabled menu: trigger load for newly checked items
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
9cdff19cb7
commit
02ffda9d4c
|
|
@ -7,6 +7,8 @@ import {
|
||||||
unloadFavoriteItem,
|
unloadFavoriteItem,
|
||||||
type FavoriteItemWithStatus,
|
type FavoriteItemWithStatus,
|
||||||
} from '../../costrict/favorite/favorite.js';
|
} from '../../costrict/favorite/favorite.js';
|
||||||
|
import { useSetAppState } from '../../state/AppState.js';
|
||||||
|
import { getOriginalCwd } from '../../bootstrap/state.js';
|
||||||
import type { LocalJSXCommandCall } from '../../types/command.js';
|
import type { LocalJSXCommandCall } from '../../types/command.js';
|
||||||
|
|
||||||
function CloudEnabledMenu({
|
function CloudEnabledMenu({
|
||||||
|
|
@ -14,6 +16,7 @@ function CloudEnabledMenu({
|
||||||
}: {
|
}: {
|
||||||
onDone: (result?: string, options?: { display?: 'skip' | 'system' | 'user' }) => void;
|
onDone: (result?: string, options?: { display?: 'skip' | 'system' | 'user' }) => void;
|
||||||
}): React.ReactNode {
|
}): React.ReactNode {
|
||||||
|
const setAppState = useSetAppState();
|
||||||
const [items, setItems] = useState<FavoriteItemWithStatus[] | null>(null);
|
const [items, setItems] = useState<FavoriteItemWithStatus[] | null>(null);
|
||||||
const previousSlugs = useRef<Set<string>>(new Set());
|
const previousSlugs = useRef<Set<string>>(new Set());
|
||||||
const isFirstChange = useRef(true);
|
const isFirstChange = useRef(true);
|
||||||
|
|
@ -27,7 +30,7 @@ function CloudEnabledMenu({
|
||||||
onDone('No cloud favorites found', { display: 'system' });
|
onDone('No cloud favorites found', { display: 'system' });
|
||||||
} else {
|
} else {
|
||||||
setItems(data);
|
setItems(data);
|
||||||
previousSlugs.current = new Set(data.map(item => item.slug));
|
previousSlugs.current = new Set(data.filter(item => item.status === 'Active').map(item => item.slug));
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch((error: unknown) => {
|
.catch((error: unknown) => {
|
||||||
|
|
@ -75,11 +78,42 @@ function CloudEnabledMenu({
|
||||||
|
|
||||||
if (toLoad.length === 0 && toUnload.length === 0) return;
|
if (toLoad.length === 0 && toUnload.length === 0) return;
|
||||||
|
|
||||||
|
const hasMcpChanges = [...toLoad, ...toUnload].some(item => item.itemType === 'mcp');
|
||||||
|
const hasAgentChanges = [...toLoad, ...toUnload].some(item => item.itemType === 'agent');
|
||||||
|
|
||||||
const results = await Promise.allSettled([
|
const results = await Promise.allSettled([
|
||||||
...toLoad.map(item => loadFavoriteItem(item.slug)),
|
...toLoad.map(item => loadFavoriteItem(item.slug)),
|
||||||
...toUnload.map(item => unloadFavoriteItem(item.slug)),
|
...toUnload.map(item => unloadFavoriteItem(item.slug)),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
// Trigger MCP reconnection if any MCP servers were added or removed
|
||||||
|
if (hasMcpChanges) {
|
||||||
|
setAppState(prev => ({
|
||||||
|
...prev,
|
||||||
|
mcp: {
|
||||||
|
...prev.mcp,
|
||||||
|
pluginReconnectKey: prev.mcp.pluginReconnectKey + 1,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refresh agent definitions if any agents were added or removed
|
||||||
|
if (hasAgentChanges) {
|
||||||
|
const { getAgentDefinitionsWithOverrides, getActiveAgentsFromList } = await import(
|
||||||
|
'@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js'
|
||||||
|
);
|
||||||
|
getAgentDefinitionsWithOverrides.cache?.clear?.();
|
||||||
|
const freshAgentDefs = await getAgentDefinitionsWithOverrides(getOriginalCwd());
|
||||||
|
setAppState(prev => ({
|
||||||
|
...prev,
|
||||||
|
agentDefinitions: {
|
||||||
|
...freshAgentDefs,
|
||||||
|
allAgents: freshAgentDefs.allAgents,
|
||||||
|
activeAgents: getActiveAgentsFromList(freshAgentDefs.allAgents),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
const failures = results.filter(r => r.status === 'rejected') as PromiseRejectedResult[];
|
const failures = results.filter(r => r.status === 'rejected') as PromiseRejectedResult[];
|
||||||
if (failures.length > 0) {
|
if (failures.length > 0) {
|
||||||
const reasons = failures.map(r => (r.reason instanceof Error ? r.reason.message : String(r.reason)));
|
const reasons = failures.map(r => (r.reason instanceof Error ? r.reason.message : String(r.reason)));
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ import { skillChangeDetector } from '../../utils/skills/skillChangeDetector.js'
|
||||||
import { parseFrontmatter } from '../../utils/frontmatterParser.js'
|
import { parseFrontmatter } from '../../utils/frontmatterParser.js'
|
||||||
import { logForDebugging } from '../../utils/debug.js'
|
import { logForDebugging } from '../../utils/debug.js'
|
||||||
import type { McpServerConfig } from '../../services/mcp/types.js'
|
import type { McpServerConfig } from '../../services/mcp/types.js'
|
||||||
|
import { clearAgentDefinitionsCache } from '@claude-code-best/builtin-tools/tools/AgentTool/loadAgentsDir.js'
|
||||||
|
|
||||||
const FAVORITE_PAGE_SIZE = 20
|
const FAVORITE_PAGE_SIZE = 20
|
||||||
const FAVORITE_MAX_PAGES = 20
|
const FAVORITE_MAX_PAGES = 20
|
||||||
|
|
@ -112,6 +113,14 @@ function skillsDir() {
|
||||||
return path.join(getClaudeConfigHomeDir(), 'skills')
|
return path.join(getClaudeConfigHomeDir(), 'skills')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function agentsDir() {
|
||||||
|
return path.join(getClaudeConfigHomeDir(), 'agents')
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandsDir() {
|
||||||
|
return path.join(getClaudeConfigHomeDir(), 'commands')
|
||||||
|
}
|
||||||
|
|
||||||
async function readState(): Promise<FavoriteState> {
|
async function readState(): Promise<FavoriteState> {
|
||||||
try {
|
try {
|
||||||
const text = await readFile(favoriteStatePath(), 'utf-8')
|
const text = await readFile(favoriteStatePath(), 'utf-8')
|
||||||
|
|
@ -288,6 +297,16 @@ function deriveStatus(
|
||||||
activeMcpNames: Set<string>,
|
activeMcpNames: Set<string>,
|
||||||
): FavoriteStatus {
|
): FavoriteStatus {
|
||||||
if (!state) return 'Cloud'
|
if (!state) return 'Cloud'
|
||||||
|
|
||||||
|
// Prioritize favorite lifecycle over local config presence
|
||||||
|
switch (state.lifecycle) {
|
||||||
|
case 'active':
|
||||||
|
return 'Active'
|
||||||
|
case 'unloaded':
|
||||||
|
return 'Unloaded'
|
||||||
|
}
|
||||||
|
|
||||||
|
// For 'downloaded' state, fallback to checking local config
|
||||||
switch (state.itemType) {
|
switch (state.itemType) {
|
||||||
case 'skill':
|
case 'skill':
|
||||||
if (activeSkillSlugs.has(state.slug)) return 'Active'
|
if (activeSkillSlugs.has(state.slug)) return 'Active'
|
||||||
|
|
@ -302,12 +321,8 @@ function deriveStatus(
|
||||||
if (activeMcpNames.has(state.slug)) return 'Active'
|
if (activeMcpNames.has(state.slug)) return 'Active'
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
switch (state.lifecycle) {
|
|
||||||
case 'unloaded':
|
return 'Downloaded'
|
||||||
return 'Unloaded'
|
|
||||||
default:
|
|
||||||
return 'Downloaded'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function persistInstalledItem(item: FavoriteItem) {
|
async function persistInstalledItem(item: FavoriteItem) {
|
||||||
|
|
@ -528,6 +543,7 @@ async function addItemToConfig(item: FavoriteItem, localPath: string) {
|
||||||
content,
|
content,
|
||||||
mdPath,
|
mdPath,
|
||||||
)
|
)
|
||||||
|
// Persist to global config for state tracking
|
||||||
saveGlobalConfig(cfg => ({
|
saveGlobalConfig(cfg => ({
|
||||||
...cfg,
|
...cfg,
|
||||||
agents: {
|
agents: {
|
||||||
|
|
@ -538,6 +554,11 @@ async function addItemToConfig(item: FavoriteItem, localPath: string) {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
// Also copy to ~/.claude/agents/ so the file-based loader picks it up
|
||||||
|
const agentDestDir = agentsDir()
|
||||||
|
await mkdir(agentDestDir, { recursive: true })
|
||||||
|
await copyFile(mdPath, path.join(agentDestDir, `${item.slug}.md`))
|
||||||
|
clearAgentDefinitionsCache()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'command': {
|
case 'command': {
|
||||||
|
|
@ -547,6 +568,7 @@ async function addItemToConfig(item: FavoriteItem, localPath: string) {
|
||||||
content,
|
content,
|
||||||
mdPath,
|
mdPath,
|
||||||
)
|
)
|
||||||
|
// Persist to global config for state tracking
|
||||||
saveGlobalConfig(cfg => ({
|
saveGlobalConfig(cfg => ({
|
||||||
...cfg,
|
...cfg,
|
||||||
commands: {
|
commands: {
|
||||||
|
|
@ -557,6 +579,12 @@ async function addItemToConfig(item: FavoriteItem, localPath: string) {
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
// Also copy to ~/.claude/commands/ so the file-based loader picks it up
|
||||||
|
const commandDestDir = commandsDir()
|
||||||
|
await mkdir(commandDestDir, { recursive: true })
|
||||||
|
await copyFile(mdPath, path.join(commandDestDir, `${item.slug}.md`))
|
||||||
|
clearCommandsCache()
|
||||||
|
skillChangeDetector.notify()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'mcp': {
|
case 'mcp': {
|
||||||
|
|
@ -603,6 +631,9 @@ async function removeItemFromConfig(
|
||||||
delete agents[slug]
|
delete agents[slug]
|
||||||
return { ...cfg, agents }
|
return { ...cfg, agents }
|
||||||
})
|
})
|
||||||
|
// Also remove from ~/.claude/agents/ so the file-based loader stops serving it
|
||||||
|
await rm(path.join(agentsDir(), `${slug}.md`), { force: true })
|
||||||
|
clearAgentDefinitionsCache()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'command': {
|
case 'command': {
|
||||||
|
|
@ -611,6 +642,10 @@ async function removeItemFromConfig(
|
||||||
delete commands[slug]
|
delete commands[slug]
|
||||||
return { ...cfg, commands }
|
return { ...cfg, commands }
|
||||||
})
|
})
|
||||||
|
// Also remove from ~/.claude/commands/ so the file-based loader stops serving it
|
||||||
|
await rm(path.join(commandsDir(), `${slug}.md`), { force: true })
|
||||||
|
clearCommandsCache()
|
||||||
|
skillChangeDetector.notify()
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
case 'mcp': {
|
case 'mcp': {
|
||||||
|
|
@ -799,13 +834,16 @@ export async function unloadFavoriteItem(slugOrId: string) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Auto-enable all cloud favorite items that are not already active.
|
* Auto-enable cloud favorite items that are neither active nor explicitly
|
||||||
* Runs in the background so slow network or many items never blocks startup.
|
* unloaded. Items the user previously unloaded should stay disabled across
|
||||||
|
* restarts; only newly-downloaded or never-configured items are activated.
|
||||||
*/
|
*/
|
||||||
export async function autoEnableCloudFavorites(): Promise<void> {
|
export async function autoEnableCloudFavorites(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
const items = await listFavoriteItems()
|
const items = await listFavoriteItems()
|
||||||
const toEnable = items.filter(item => item.status !== 'Active')
|
const toEnable = items.filter(
|
||||||
|
item => item.status !== 'Active' && item.status !== 'Unloaded',
|
||||||
|
)
|
||||||
if (toEnable.length === 0) return
|
if (toEnable.length === 0) return
|
||||||
|
|
||||||
await Promise.allSettled(toEnable.map(item => loadFavoriteItem(item.slug)))
|
await Promise.allSettled(toEnable.map(item => loadFavoriteItem(item.slug)))
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user