diff --git a/src-tauri/capabilities/main.json b/src-tauri/capabilities/main.json index 2195cacf..73f0a8bc 100644 --- a/src-tauri/capabilities/main.json +++ b/src-tauri/capabilities/main.json @@ -53,6 +53,18 @@ "allow": [ { "url": "https://generativelanguage.googleapis.com/**" + }, + { + "url": "https://api.github.com/**" + }, + { + "url": "https://github.com/**" + }, + { + "url": "https://api.githubcopilot.com/**" + }, + { + "url": "https://copilot-proxy.githubusercontent.com/**" } ] }, diff --git a/src-tauri/src/commands/copilot_auth.rs b/src-tauri/src/commands/copilot_auth.rs new file mode 100644 index 00000000..0454f538 --- /dev/null +++ b/src-tauri/src/commands/copilot_auth.rs @@ -0,0 +1,389 @@ +use serde::{Deserialize, Serialize}; +use tauri::command; + +const GITHUB_DEVICE_CODE_URL: &str = "https://github.com/login/device/code"; +const GITHUB_OAUTH_TOKEN_URL: &str = "https://github.com/login/oauth/access_token"; +const GITHUB_USER_URL: &str = "https://api.github.com/user"; +const COPILOT_TOKEN_URL: &str = "https://api.github.com/copilot_internal/v2/token"; +const COPILOT_MODELS_URL: &str = "https://api.githubcopilot.com/models"; + +const GITHUB_CLIENT_ID: &str = "Ov23liyskE1hUrlbL9M4"; + +#[derive(Debug, Serialize, Deserialize)] +pub struct DeviceFlowResponse { + pub device_code: String, + pub user_code: String, + pub verification_uri: String, + pub expires_in: u64, + pub interval: u64, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct OAuthTokenResponse { + pub access_token: Option, + pub token_type: Option, + pub scope: Option, + pub error: Option, + pub error_description: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CopilotTokenResponse { + pub token: String, + pub expires_at: i64, + pub refresh_in: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CopilotAuthStatus { + pub authenticated: bool, + pub github_username: Option, + pub copilot_token_expires_at: Option, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CopilotModel { + pub id: String, + pub name: String, + pub version: Option, + pub is_default: Option, +} + +#[derive(Debug, Deserialize)] +struct GitHubUser { + login: String, +} + +#[command] +pub async fn copilot_start_device_flow() -> Result { + let client = reqwest::Client::new(); + + let response = client + .post(GITHUB_DEVICE_CODE_URL) + .header("Accept", "application/json") + .form(&[("client_id", GITHUB_CLIENT_ID), ("scope", "read:user")]) + .send() + .await + .map_err(|e| format!("Failed to start device flow: {e}"))?; + + if !response.status().is_success() { + let error_text = response.text().await.unwrap_or_default(); + return Err(format!("GitHub API error: {error_text}")); + } + + response + .json::() + .await + .map_err(|e| format!("Failed to parse device flow response: {e}")) +} + +#[command] +pub async fn copilot_poll_device_auth(device_code: String) -> Result { + let client = reqwest::Client::new(); + + let response = client + .post(GITHUB_OAUTH_TOKEN_URL) + .header("Accept", "application/json") + .form(&[ + ("client_id", GITHUB_CLIENT_ID), + ("device_code", &device_code), + ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), + ]) + .send() + .await + .map_err(|e| format!("Failed to poll for token: {e}"))?; + + response + .json::() + .await + .map_err(|e| format!("Failed to parse token response: {e}")) +} + +async fn fetch_github_username(github_token: &str) -> Option { + let client = reqwest::Client::new(); + + let response = client + .get(GITHUB_USER_URL) + .header("Authorization", format!("token {github_token}")) + .header("Accept", "application/json") + .header("User-Agent", "Athas/1.0.0") + .send() + .await + .ok()?; + + if !response.status().is_success() { + return None; + } + + let user: GitHubUser = response.json().await.ok()?; + Some(user.login) +} + +#[command] +pub async fn copilot_get_copilot_token( + app: tauri::AppHandle, + github_token: String, +) -> Result { + let client = reqwest::Client::new(); + + let response = client + .get(COPILOT_TOKEN_URL) + .header("Authorization", format!("token {github_token}")) + .header("Accept", "application/json") + .header("Editor-Version", "vscode/1.96.0") + .header("Editor-Plugin-Version", "copilot-chat/0.24.0") + .header("User-Agent", "GitHubCopilotChat/0.24.0") + .send() + .await + .map_err(|e| format!("Failed to get Copilot token: {e}"))?; + + if !response.status().is_success() { + let status = response.status(); + let error_text = response.text().await.unwrap_or_default(); + + if status.as_u16() == 401 { + return Err("GitHub token is invalid or expired".to_string()); + } + if status.as_u16() == 403 { + return Err( + "No active Copilot subscription found. Please subscribe at github.com/features/copilot" + .to_string(), + ); + } + + return Err(format!("Copilot API error ({status}): {error_text}")); + } + + let token_response = response + .json::() + .await + .map_err(|e| format!("Failed to parse Copilot token: {e}"))?; + + let username = fetch_github_username(&github_token).await; + store_copilot_tokens(&app, &github_token, &token_response, username.as_deref()).await?; + + Ok(token_response) +} + +async fn store_copilot_tokens( + app: &tauri::AppHandle, + github_token: &str, + copilot_token: &CopilotTokenResponse, + username: Option<&str>, +) -> Result<(), String> { + use tauri_plugin_store::StoreExt; + + let store = app + .store("secure.json") + .map_err(|e| format!("Failed to access store: {e}"))?; + + store.set( + "copilot_github_token", + serde_json::Value::String(github_token.to_string()), + ); + store.set( + "copilot_access_token", + serde_json::Value::String(copilot_token.token.clone()), + ); + store.set( + "copilot_token_expires_at", + serde_json::Value::Number(copilot_token.expires_at.into()), + ); + + if let Some(name) = username { + store.set( + "copilot_github_username", + serde_json::Value::String(name.to_string()), + ); + } + + store + .save() + .map_err(|e| format!("Failed to save tokens: {e}"))?; + + Ok(()) +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct StoredCopilotTokens { + pub github_token: String, + pub access_token: String, + pub expires_at: i64, + pub username: Option, +} + +#[command] +pub async fn copilot_get_stored_tokens( + app: tauri::AppHandle, +) -> Result, String> { + use tauri_plugin_store::StoreExt; + + let store = app + .store("secure.json") + .map_err(|e| format!("Failed to access store: {e}"))?; + + let github_token = store + .get("copilot_github_token") + .and_then(|v| v.as_str().map(String::from)); + let access_token = store + .get("copilot_access_token") + .and_then(|v| v.as_str().map(String::from)); + let expires_at = store + .get("copilot_token_expires_at") + .and_then(|v| v.as_i64()); + let username = store + .get("copilot_github_username") + .and_then(|v| v.as_str().map(String::from)); + + match (github_token, access_token, expires_at) { + (Some(github_token), Some(access_token), Some(expires_at)) => Ok(Some(StoredCopilotTokens { + github_token, + access_token, + expires_at, + username, + })), + _ => Ok(None), + } +} + +#[command] +pub async fn copilot_refresh_token(app: tauri::AppHandle) -> Result { + let tokens = copilot_get_stored_tokens(app.clone()) + .await? + .ok_or("No stored tokens found")?; + + copilot_get_copilot_token(app, tokens.github_token).await +} + +#[command] +pub async fn copilot_check_auth_status(app: tauri::AppHandle) -> Result { + let tokens = copilot_get_stored_tokens(app).await?; + + match tokens { + Some(stored) => { + let now = chrono::Utc::now().timestamp(); + let authenticated = stored.expires_at > now; + + Ok(CopilotAuthStatus { + authenticated, + github_username: stored.username, + copilot_token_expires_at: Some(stored.expires_at), + }) + } + None => Ok(CopilotAuthStatus { + authenticated: false, + github_username: None, + copilot_token_expires_at: None, + }), + } +} + +#[command] +pub async fn copilot_sign_out(app: tauri::AppHandle) -> Result<(), String> { + use tauri_plugin_store::StoreExt; + + let store = app + .store("secure.json") + .map_err(|e| format!("Failed to access store: {e}"))?; + + let _ = store.delete("copilot_github_token"); + let _ = store.delete("copilot_access_token"); + let _ = store.delete("copilot_token_expires_at"); + let _ = store.delete("copilot_github_username"); + let _ = store.delete("copilot_enterprise_uri"); + + store + .save() + .map_err(|e| format!("Failed to save store: {e}"))?; + + Ok(()) +} + +#[command] +pub async fn copilot_list_models(app: tauri::AppHandle) -> Result, String> { + let tokens = copilot_get_stored_tokens(app.clone()) + .await? + .ok_or("Not authenticated with Copilot")?; + + let now = chrono::Utc::now().timestamp(); + + let token = if tokens.expires_at <= now { + let refreshed = copilot_refresh_token(app).await?; + refreshed.token + } else { + tokens.access_token + }; + + let client = reqwest::Client::new(); + + let response = client + .get(COPILOT_MODELS_URL) + .header("Authorization", format!("Bearer {token}")) + .header("Accept", "application/json") + .header("Editor-Version", "vscode/1.96.0") + .header("Editor-Plugin-Version", "copilot-chat/0.24.0") + .header("Copilot-Integration-Id", "vscode-chat") + .header("User-Agent", "GitHubCopilotChat/0.24.0") + .send() + .await + .map_err(|e| format!("Failed to list models: {e}"))?; + + if !response.status().is_success() { + let error_text = response.text().await.unwrap_or_default(); + return Err(format!("Failed to list models: {error_text}")); + } + + #[derive(Deserialize)] + struct ModelsResponse { + data: Option>, + models: Option>, + } + + let models_response: ModelsResponse = response + .json() + .await + .map_err(|e| format!("Failed to parse models: {e}"))?; + + Ok(models_response + .data + .or(models_response.models) + .unwrap_or_default()) +} + +#[command] +pub async fn copilot_set_enterprise_uri( + app: tauri::AppHandle, + uri: Option, +) -> Result<(), String> { + use tauri_plugin_store::StoreExt; + + let store = app + .store("secure.json") + .map_err(|e| format!("Failed to access store: {e}"))?; + + match uri { + Some(u) => store.set("copilot_enterprise_uri", serde_json::Value::String(u)), + None => { + let _ = store.delete("copilot_enterprise_uri"); + } + } + + store + .save() + .map_err(|e| format!("Failed to save store: {e}"))?; + + Ok(()) +} + +#[command] +pub async fn copilot_get_enterprise_uri(app: tauri::AppHandle) -> Result, String> { + use tauri_plugin_store::StoreExt; + + let store = app + .store("secure.json") + .map_err(|e| format!("Failed to access store: {e}"))?; + + Ok(store + .get("copilot_enterprise_uri") + .and_then(|v| v.as_str().map(String::from))) +} diff --git a/src-tauri/src/commands/mod.rs b/src-tauri/src/commands/mod.rs index 3a1fffbb..eeb18f77 100644 --- a/src-tauri/src/commands/mod.rs +++ b/src-tauri/src/commands/mod.rs @@ -2,6 +2,7 @@ pub mod ai_tokens; pub mod chat_history; pub mod claude; pub mod cli; +pub mod copilot_auth; pub mod extensions; pub mod font; pub mod format; @@ -21,6 +22,7 @@ pub use ai_tokens::*; pub use chat_history::*; pub use claude::*; pub use cli::*; +pub use copilot_auth::*; pub use extensions::*; pub use font::*; pub use format::*; diff --git a/src-tauri/src/main.rs b/src-tauri/src/main.rs index f4aae9f6..a66a3b81 100644 --- a/src-tauri/src/main.rs +++ b/src-tauri/src/main.rs @@ -305,6 +305,17 @@ fn main() { store_ai_provider_token, get_ai_provider_token, remove_ai_provider_token, + // Copilot auth commands + copilot_start_device_flow, + copilot_poll_device_auth, + copilot_get_copilot_token, + copilot_get_stored_tokens, + copilot_refresh_token, + copilot_check_auth_status, + copilot_sign_out, + copilot_list_models, + copilot_set_enterprise_uri, + copilot_get_enterprise_uri, // Chat history commands init_chat_database, save_chat, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index 20e4b7c1..35dde706 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -33,15 +33,11 @@ } ], "security": { - "csp": "default-src 'self' ipc: http://ipc.localhost; connect-src 'self' https://athas.dev https://*.athas.dev https://api.anthropic.com https://api.openai.com https://generativelanguage.googleapis.com https://*.githubusercontent.com; img-src 'self' asset: http://asset.localhost data: blob:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval'", - "capabilities": [ - "main-capability" - ], + "csp": "default-src 'self' ipc: http://ipc.localhost; connect-src 'self' https://athas.dev https://*.athas.dev https://api.anthropic.com https://api.openai.com https://generativelanguage.googleapis.com https://*.githubusercontent.com https://api.github.com https://api.githubcopilot.com https://copilot-proxy.githubusercontent.com; img-src 'self' asset: http://asset.localhost data: blob:; font-src 'self' data:; style-src 'self' 'unsafe-inline'; script-src 'self' 'unsafe-inline' 'unsafe-eval'", + "capabilities": ["main-capability"], "assetProtocol": { "enable": true, - "scope": [ - "**" - ] + "scope": ["**"] } }, "macOSPrivateApi": true @@ -57,9 +53,7 @@ "icons/icon.icns", "icons/icon.ico" ], - "resources": [ - "../src/extensions/bundled" - ], + "resources": ["../src/extensions/bundled"], "macOS": { "frameworks": [], "minimumSystemVersion": "10.15", @@ -72,9 +66,7 @@ "plugins": { "deep-link": { "desktop": { - "schemes": [ - "athas" - ] + "schemes": ["athas"] } }, "fs": { diff --git a/src/features/ai/components/chat/ai-chat.tsx b/src/features/ai/components/chat/ai-chat.tsx index ac0d2c84..2d22db75 100644 --- a/src/features/ai/components/chat/ai-chat.tsx +++ b/src/features/ai/components/chat/ai-chat.tsx @@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core"; import { memo, useCallback, useEffect, useRef } from "react"; import ApiKeyModal from "@/features/ai/components/api-key-modal"; import { parseMentionsAndLoadFiles } from "@/features/ai/lib/file-mentions"; +import { useCopilotAuthStore } from "@/features/ai/store/copilot-auth-store"; import type { AIChatProps, Message } from "@/features/ai/types/ai-chat"; import type { ClaudeStatus } from "@/features/ai/types/claude"; import { getAvailableProviders, setClaudeCodeAvailability } from "@/features/ai/types/providers"; @@ -30,9 +31,18 @@ const AIChat = memo(function AIChat({ const chatState = useChatState(); const chatActions = useChatActions(); + // Subscribe to Copilot auth state to re-check API keys when it changes + const copilotIsAuthenticated = useCopilotAuthStore((state) => state.isAuthenticated); + const checkCopilotAuthStatus = useCopilotAuthStore((state) => state.checkAuthStatus); + const messagesEndRef = useRef(null); const abortControllerRef = useRef(null); + // Check Copilot auth status on mount to load models if already authenticated + useEffect(() => { + checkCopilotAuthStatus(); + }, [checkCopilotAuthStatus]); + useEffect(() => { if (activeBuffer) { chatActions.autoSelectBuffer(activeBuffer.id); @@ -42,7 +52,12 @@ const AIChat = memo(function AIChat({ useEffect(() => { chatActions.checkApiKey(settings.aiProviderId); chatActions.checkAllProviderApiKeys(); - }, [settings.aiProviderId, chatActions.checkApiKey, chatActions.checkAllProviderApiKeys]); + }, [ + settings.aiProviderId, + copilotIsAuthenticated, + chatActions.checkApiKey, + chatActions.checkAllProviderApiKeys, + ]); useEffect(() => { const checkClaudeCodeStatus = async () => { @@ -404,7 +419,9 @@ details: ${errorDetails || mainError} return (
diff --git a/src/features/ai/components/github-copilot-settings.tsx b/src/features/ai/components/github-copilot-settings.tsx index 6d0e8a3d..f0ab26f1 100644 --- a/src/features/ai/components/github-copilot-settings.tsx +++ b/src/features/ai/components/github-copilot-settings.tsx @@ -1,73 +1,292 @@ -import { AlertCircle, Zap } from "lucide-react"; +import { open } from "@tauri-apps/plugin-shell"; +import { + AlertCircle, + Check, + Copy, + ExternalLink, + Loader2, + LogOut, + RefreshCw, + Zap, +} from "lucide-react"; +import { useCallback, useEffect, useRef, useState } from "react"; +import { useCopilotAuthStore } from "@/features/ai/store/copilot-auth-store"; import { useUIState } from "@/stores/ui-state-store"; import Button from "@/ui/button"; const GitHubCopilotSettings = () => { - // Get data from stores const { isGitHubCopilotSettingsVisible, setIsGitHubCopilotSettingsVisible } = useUIState(); + const { + stage, + userCode, + verificationUri, + expiresAt, + pollInterval, + isAuthenticated, + availableModels, + error, + startSignIn, + pollForAuth, + cancelSignIn, + signOut, + checkAuthStatus, + } = useCopilotAuthStore(); + + const [copied, setCopied] = useState(false); + const [timeRemaining, setTimeRemaining] = useState(null); + const pollIntervalRef = useRef(null); + const isVisible = isGitHubCopilotSettingsVisible; const onClose = () => setIsGitHubCopilotSettingsVisible(false); + useEffect(() => { + if (isVisible) { + checkAuthStatus(); + } + }, [isVisible, checkAuthStatus]); + + useEffect(() => { + if (stage === "polling" && expiresAt) { + pollIntervalRef.current = setInterval(async () => { + const success = await pollForAuth(); + if (success) { + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + } + } + }, pollInterval * 1000); + + return () => { + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + } + }; + } + }, [stage, pollInterval, pollForAuth, expiresAt]); + + useEffect(() => { + if (expiresAt && (stage === "awaiting_code" || stage === "polling")) { + const timer = setInterval(() => { + const remaining = Math.max(0, Math.floor((expiresAt - Date.now()) / 1000)); + setTimeRemaining(remaining); + + if (remaining === 0) { + clearInterval(timer); + cancelSignIn(); + } + }, 1000); + + return () => clearInterval(timer); + } + setTimeRemaining(null); + }, [expiresAt, stage, cancelSignIn]); + + const handleCopyCode = useCallback(async () => { + if (userCode) { + await navigator.clipboard.writeText(userCode); + setCopied(true); + setTimeout(() => setCopied(false), 2000); + } + }, [userCode]); + + const handleOpenGitHub = useCallback(() => { + if (verificationUri) { + open(verificationUri); + } + }, [verificationUri]); + + const handleSignIn = useCallback(async () => { + await startSignIn(); + }, [startSignIn]); + + const handleSignOut = useCallback(async () => { + await signOut(); + }, [signOut]); + + const handleCancel = useCallback(() => { + cancelSignIn(); + if (pollIntervalRef.current) { + clearInterval(pollIntervalRef.current); + } + }, [cancelSignIn]); + if (!isVisible) { return null; } - return ( -
-
- {/* Header */} -
- -

GitHub Copilot Integration

-
- + const formatTime = (seconds: number) => { + const mins = Math.floor(seconds / 60); + const secs = seconds % 60; + return `${mins}:${secs.toString().padStart(2, "0")}`; + }; + + const renderContent = () => { + if (isAuthenticated && stage === "authenticated") { + return ( +
+
+ + Connected to GitHub Copilot +
+ + {availableModels.length > 0 && ( +
+
Available Models
+
+ {availableModels.slice(0, 8).map((model) => ( + + {model.name || model.id} + + ))} + {availableModels.length > 8 && ( + + +{availableModels.length - 8} more + + )} +
+
+ )} + +
+ + +
+ ); + } - {/* Content */} -
+ if (stage === "awaiting_code" || stage === "polling" || stage === "exchanging_token") { + return ( +
- GitHub Copilot integration uses official GitHub authentication through the code Editor. + Enter this code on GitHub to authorize Athas:
- {/* Coming Soon Notice */} -
-
- - Coming Soon -
-
- GitHub Copilot integration is currently in development. GitHub Copilot does not - support API key authentication. It requires OAuth-based authentication through - official GitHub channels. +
+
+ {userCode || "--------"}
+ +
- {/* Information */} -
-
- How GitHub Copilot authentication works: + + +
+ {stage === "exchanging_token" ? ( + <> + + Completing authorization... + + ) : ( + <> + + Waiting for authorization... + {timeRemaining !== null && ( + ({formatTime(timeRemaining)}) + )} + + )} +
+ + +
+ ); + } + + if (stage === "error") { + return ( +
+
+ +
+
Authentication Failed
+
{error}
-
    -
  • Requires a GitHub Copilot subscription
  • -
  • Uses OAuth authentication with GitHub
  • -
  • Integrates through official IDE extensions
  • -
  • Does not support standalone API keys
  • -
- {/* Actions */}
- +
+ ); + } + + return ( +
+
+ Sign in with your GitHub account to use Copilot models. Requires an active GitHub Copilot + subscription. +
+ +
+
What you get:
+
    +
  • Access to GPT-5, Claude, Gemini and more
  • +
  • Usage based on your Copilot plan
  • +
  • Secure OAuth authentication
  • +
+
+ +
+ +
+
+ ); + }; + + return ( +
+
+
+ +

GitHub Copilot

+
+ +
+ +
{renderContent()}
); diff --git a/src/features/ai/components/selectors/model-selector-dropdown.tsx b/src/features/ai/components/selectors/model-selector-dropdown.tsx index 85b4acf7..4acc0a30 100644 --- a/src/features/ai/components/selectors/model-selector-dropdown.tsx +++ b/src/features/ai/components/selectors/model-selector-dropdown.tsx @@ -1,6 +1,8 @@ -import { Check, ChevronDown, Key, Search } from "lucide-react"; +import { Check, ChevronDown, Key, LogIn, Search } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; +import { useCopilotAuthStore } from "@/features/ai/store/copilot-auth-store"; import { getAvailableProviders } from "@/features/ai/types/providers"; +import { useUIState } from "@/stores/ui-state-store"; import { cn } from "@/utils/cn"; interface ModelSelectorDropdownProps { @@ -28,6 +30,18 @@ export function ModelSelectorDropdown({ const inputRef = useRef(null); const providers = getAvailableProviders(); + const copilotAuth = useCopilotAuthStore(); + const { setIsGitHubCopilotSettingsVisible } = useUIState(); + + const isProviderReady = useCallback( + (providerId: string, requiresApiKey: boolean, requiresAuth?: boolean) => { + if (requiresAuth && providerId === "copilot") { + return copilotAuth.isAuthenticated; + } + return !requiresApiKey || hasApiKey(providerId); + }, + [hasApiKey, copilotAuth.isAuthenticated], + ); const filteredItems = useMemo(() => { const items: Array<{ @@ -37,14 +51,19 @@ export function ModelSelectorDropdown({ modelId?: string; modelName?: string; requiresApiKey?: boolean; - hasKey?: boolean; + requiresAuth?: boolean; + isReady?: boolean; }> = []; const searchLower = search.toLowerCase(); for (const provider of providers) { const providerMatches = provider.name.toLowerCase().includes(searchLower); - const providerHasKey = !provider.requiresApiKey || hasApiKey(provider.id); + const providerIsReady = isProviderReady( + provider.id, + provider.requiresApiKey, + provider.requiresAuth, + ); const matchingModels = provider.models.filter( (model) => providerMatches || @@ -58,11 +77,11 @@ export function ModelSelectorDropdown({ providerId: provider.id, providerName: provider.name, requiresApiKey: provider.requiresApiKey, - hasKey: providerHasKey, + requiresAuth: provider.requiresAuth, + isReady: providerIsReady, }); - // Only show models if provider has API key or doesn't require one - if (providerHasKey) { + if (providerIsReady) { const modelsToShow = search ? matchingModels : provider.models; for (const model of modelsToShow) { items.push({ @@ -78,7 +97,7 @@ export function ModelSelectorDropdown({ } return items; - }, [providers, search, hasApiKey]); + }, [providers, search, isProviderReady]); const selectableItems = useMemo( () => filteredItems.filter((item) => item.type === "model"), @@ -132,12 +151,6 @@ export function ModelSelectorDropdown({ [isOpen, selectableItems, selectedIndex, onSelect], ); - const handleApiKeyClick = (e: React.MouseEvent) => { - e.stopPropagation(); - onOpenSettings(); - setIsOpen(false); - }; - let selectableIndex = -1; return ( @@ -161,8 +174,8 @@ export function ModelSelectorDropdown({ ref={dropdownRef} onKeyDown={handleKeyDown} className={cn( - "absolute right-0 bottom-full z-[9999] mb-1", - "max-h-[400px] w-[280px] overflow-hidden", + "absolute bottom-full left-0 z-[9999] mb-1", + "max-h-[400px] min-w-[280px] overflow-hidden", "rounded-lg border border-border bg-primary-bg shadow-xl", )} > @@ -187,6 +200,16 @@ export function ModelSelectorDropdown({ ) : ( filteredItems.map((item) => { if (item.type === "provider") { + const handleProviderAction = (e: React.MouseEvent) => { + e.stopPropagation(); + if (item.requiresAuth && item.providerId === "copilot") { + setIsGitHubCopilotSettingsVisible(true); + } else { + onOpenSettings(); + } + setIsOpen(false); + }; + return (
{item.providerName} - {item.requiresApiKey && !item.hasKey && ( + {!item.isReady && item.requiresAuth && ( + + )} + {!item.isReady && item.requiresApiKey && !item.requiresAuth && (
); }; + +const AuthProviderRow = ({ + providerId, + providerName, +}: { + providerId: string; + providerName: string; +}) => { + const { setIsGitHubCopilotSettingsVisible } = useUIState(); + const copilotAuth = useCopilotAuthStore(); + + if (providerId === "copilot") { + const handleClick = () => { + setIsGitHubCopilotSettingsVisible(true); + }; + + if (copilotAuth.isAuthenticated) { + const description = copilotAuth.githubUsername + ? `Signed in as @${copilotAuth.githubUsername}` + : "Connected to GitHub"; + + return ( + +
+
+ + Connected +
+ + +
+
+ ); + } + + return ( + + + + ); + } + + return ( + +
+ Coming Soon +
+
+ ); +}; diff --git a/src/features/settings/components/tabs/extensions-settings.tsx b/src/features/settings/components/tabs/extensions-settings.tsx index f622f191..d9238c72 100644 --- a/src/features/settings/components/tabs/extensions-settings.tsx +++ b/src/features/settings/components/tabs/extensions-settings.tsx @@ -50,7 +50,7 @@ const ExtensionRow = ({
- {extension.name} + {extension.name} {getCategoryLabel(extension.category)} @@ -58,7 +58,7 @@ const ExtensionRow = ({ v{extension.version} )}
-
+
{extension.publisher && by {extension.publisher}} {extension.publisher && extension.extensions && extension.extensions.length > 0 && ( · @@ -75,7 +75,7 @@ const ExtensionRow = ({
{extension.isBundled ? ( - Built-in + Built-in ) : isInstalling ? (
@@ -84,7 +84,7 @@ const ExtensionRow = ({ ) : extension.isInstalled ? (