diff --git a/src/main/services/team/TeamDataService.ts b/src/main/services/team/TeamDataService.ts index ef75b4ea..ff7102a4 100644 --- a/src/main/services/team/TeamDataService.ts +++ b/src/main/services/team/TeamDataService.ts @@ -27,8 +27,6 @@ import { randomUUID } from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; -import { gitIdentityResolver } from '../parsing/GitIdentityResolver'; - import { atomicWriteAsync } from './atomicWrite'; import { extractLeadSessionMessagesFromJsonl } from './leadSessionMessageExtractor'; import { buildTaskChangePresenceDescriptor } from './taskChangePresenceUtils'; @@ -691,10 +689,6 @@ export class TeamDataService { ); mark('resolveMembers'); - // Enrich members with git branch when it differs from lead's branch - await this.enrichMemberBranches(members, config); - mark('enrichBranches'); - mark('syncComments'); let processes: TeamProcess[] = []; @@ -714,9 +708,9 @@ export class TeamDataService { 'sentMessages' )} membersMeta=${msSince('metaMembers')} kanban=${msSince('kanbanState')} kanbanGc=${msSince( 'kanbanGc' - )} resolveMembers=${msSince('resolveMembers')} enrichBranches=${msSince( - 'enrichBranches' - )} syncComments=${msSince('syncComments')} processes=${msSince('processes')}` + )} resolveMembers=${msSince('resolveMembers')} syncComments=${msSince('syncComments')} processes=${msSince( + 'processes' + )}` ); } @@ -811,68 +805,6 @@ export class TeamDataService { } } - /** - * Enriches members with gitBranch when their cwd differs from the lead's. - * Mutates members in-place for efficiency (called right after resolveMembers). - */ - private async enrichMemberBranches( - members: ResolvedTeamMember[], - config: TeamConfig - ): Promise { - // Determine lead's cwd — prefer explicit member entry, fall back to config.projectPath - const leadEntry = config.members?.find((m) => isLeadMember(m)); - const leadCwd = leadEntry?.cwd ?? config.projectPath; - if (!leadCwd) return; - - const withTimeout = async (p: Promise, ms: number): Promise => { - let timer: NodeJS.Timeout | null = null; - try { - return await Promise.race([ - p, - new Promise((_resolve, reject) => { - timer = setTimeout(() => reject(new Error('timeout')), ms); - }), - ]); - } finally { - if (timer) clearTimeout(timer); - } - }; - - let leadBranch: string | null = null; - try { - // Git can hang on some Windows setups (network drives, locked repos, credential prompts). - // Branch is best-effort; never block team:getData on it. - leadBranch = await withTimeout(gitIdentityResolver.getBranch(path.normalize(leadCwd)), 2000); - } catch { - // Lead cwd may not be a git repo — skip enrichment entirely - return; - } - - const candidates = members.filter((m) => m.cwd && m.cwd !== leadCwd); - if (candidates.length === 0) return; - - const concurrency = process.platform === 'win32' ? 4 : 8; - for (let i = 0; i < candidates.length; i += concurrency) { - const batch = candidates.slice(i, i + concurrency); - await Promise.all( - batch.map(async (member) => { - if (!member.cwd) return; - try { - const branch = await withTimeout( - gitIdentityResolver.getBranch(path.normalize(member.cwd)), - 2000 - ); - if (branch && branch !== leadBranch) { - member.gitBranch = branch; - } - } catch { - // Member cwd may not be a git repo — skip silently - } - }) - ); - } - } - /** * Ensures a member exists in members.meta.json. * Members can appear in the UI from three sources (see TeamMemberResolver): diff --git a/src/main/services/team/TeamInboxReader.ts b/src/main/services/team/TeamInboxReader.ts index 563a634c..a020e29a 100644 --- a/src/main/services/team/TeamInboxReader.ts +++ b/src/main/services/team/TeamInboxReader.ts @@ -44,7 +44,8 @@ export class TeamInboxReader { return entries .filter((name) => name.endsWith('.json') && !name.startsWith('.')) - .map((name) => name.replace(/\.json$/, '')); + .map((name) => name.replace(/\.json$/, '')) + .filter((name) => name !== '*'); } async getMessagesFor(teamName: string, member: string): Promise { diff --git a/src/renderer/components/extensions/ExtensionStoreView.tsx b/src/renderer/components/extensions/ExtensionStoreView.tsx index 46c816a7..32e7711c 100644 --- a/src/renderer/components/extensions/ExtensionStoreView.tsx +++ b/src/renderer/components/extensions/ExtensionStoreView.tsx @@ -32,6 +32,7 @@ export const ExtensionStoreView = (): React.JSX.Element => { const tabId = useTabIdOptional(); const { fetchPluginCatalog, + fetchCliStatus, fetchApiKeys, fetchSkillsCatalog, mcpBrowse, @@ -40,11 +41,14 @@ export const ExtensionStoreView = (): React.JSX.Element => { mcpBrowseLoading, skillsLoading, cliStatus, + cliStatusLoading, + openDashboard, sessions, projects, } = useStore( useShallow((s) => ({ fetchPluginCatalog: s.fetchPluginCatalog, + fetchCliStatus: s.fetchCliStatus, fetchApiKeys: s.fetchApiKeys, fetchSkillsCatalog: s.fetchSkillsCatalog, mcpBrowse: s.mcpBrowse, @@ -53,6 +57,8 @@ export const ExtensionStoreView = (): React.JSX.Element => { mcpBrowseLoading: s.mcpBrowseLoading, skillsLoading: s.skillsLoading, cliStatus: s.cliStatus, + cliStatusLoading: s.cliStatusLoading, + openDashboard: s.openDashboard, sessions: s.sessions, projects: s.projects, })) @@ -115,6 +121,10 @@ export const ExtensionStoreView = (): React.JSX.Element => { void fetchPluginCatalog(projectPath ?? undefined); }, [fetchPluginCatalog, projectPath]); + useEffect(() => { + void fetchCliStatus(); + }, [fetchCliStatus]); + // Fetch MCP installed state on mount useEffect(() => { void mcpFetchInstalled(projectPath ?? undefined); @@ -139,6 +149,71 @@ export const ExtensionStoreView = (): React.JSX.Element => { }, [fetchPluginCatalog, fetchSkillsCatalog, mcpBrowse, mcpFetchInstalled, projectPath]); const isRefreshing = pluginCatalogLoading || mcpBrowseLoading || skillsLoading; + const cliStatusBanner = useMemo(() => { + if (cliStatusLoading || cliStatus === null) { + return ( +
+ +
+

Checking Claude CLI availability

+

+ Extensions need Claude CLI to install plugins, run MCP servers, and validate auth. +

+
+
+ ); + } + + if (!cliStatus.installed) { + return ( +
+ +
+

Claude CLI is not available

+

+ Plugin installs are disabled until Claude CLI is installed. Open the Dashboard to + install it and retry. +

+
+ +
+ ); + } + + if (!cliStatus.authLoggedIn) { + return ( +
+ +
+

Claude CLI needs sign-in

+

+ Claude CLI was found + {cliStatus.installedVersion ? ` (${cliStatus.installedVersion})` : ''}, but plugin + installs are disabled until you sign in from the Dashboard. +

+
+ +
+ ); + } + + return ( +
+ +
+

Claude CLI is ready

+

+ Plugins can be installed from this page + {cliStatus.installedVersion ? ` using Claude CLI ${cliStatus.installedVersion}` : ''}. +

+
+
+ ); + }, [cliStatus, cliStatusLoading, openDashboard]); // Browser mode guard if (!api.plugins && !api.mcpRegistry && !api.skills) { @@ -156,6 +231,7 @@ export const ExtensionStoreView = (): React.JSX.Element => { return (
+ {cliStatusBanner}
{/* Header */}
diff --git a/src/renderer/components/extensions/common/InstallButton.tsx b/src/renderer/components/extensions/common/InstallButton.tsx index f723d25e..f7e7ee3b 100644 --- a/src/renderer/components/extensions/common/InstallButton.tsx +++ b/src/renderer/components/extensions/common/InstallButton.tsx @@ -37,9 +37,25 @@ export const InstallButton = ({ size = 'sm', errorMessage, }: InstallButtonProps) => { - const cliStatus = useStore(useShallow((s) => s.cliStatus)); - const cliMissing = cliStatus !== null && !cliStatus.installed; - const isDisabled = disabled || cliMissing; + const { cliStatus, cliStatusLoading } = useStore( + useShallow((s) => ({ + cliStatus: s.cliStatus, + cliStatusLoading: s.cliStatusLoading, + })) + ); + const cliUnknown = cliStatus === null; + const cliMissing = cliStatus?.installed === false; + const authMissing = cliStatus?.installed === true && !cliStatus.authLoggedIn; + const disableReason = cliStatusLoading + ? 'Checking Claude CLI status...' + : cliUnknown + ? 'Checking Claude CLI availability...' + : cliMissing + ? 'Claude CLI required. Install it from the Dashboard.' + : authMissing + ? 'Claude CLI is installed but not signed in. Open the Dashboard to sign in.' + : null; + const isDisabled = disabled || Boolean(disableReason); const [lastAction, setLastAction] = useState<'install' | 'uninstall' | null>(null); useEffect(() => { @@ -92,23 +108,30 @@ export const InstallButton = ({ ); - if (errorMessage) { + const tooltipMessage = disableReason ?? errorMessage; + + if (tooltipMessage) { return ( - - - - {retryButton} - - {errorMessage} - - +
+ + + + {retryButton} + + {tooltipMessage} + + + {errorMessage && !disableReason ? ( +

{errorMessage}

+ ) : null} +
); } return retryButton; } - // idle — wrap in tooltip when CLI missing + // idle — wrap in tooltip when install is unavailable const button = isInstalled ? (