Multi-account GitHub auth + UI polish (model logos, markdown, scroll behavior) (#219)

* feat: display provider logos for favorite/recent models

Show provider logo next to model name in favorites and recents
Render provider logos in ModelControls, ModelMultiSelect, and ModelSelector lists
Maintain zero-logo state for other sections to avoid clutter

* feat: render user message as markdown instead of plain text

Render agent mentions as markdown links in user text
Apply inside list style for chat content to fix list rendering
Rely on SimpleMarkdownRenderer for consistent rendering

* fix(openchamber): adjust layout and overscroll behavior

Enable overscroll-auto on overlay containers for smoother scrolling
Move page content to full-width wrapper and preserve section borders
Show AboutSettings inside its own bordered block when visible

* feat: integrate GitHub auth status store and UI

Introduce GitHubAuthStore to track connection status and polling
Show GitHub avatar in header when connected
Guard issue/pr dialogs behind GitHub auth status and show notices

* feat: add GitHub multi-account support

Add API and UI flow to activate a GitHub account
Show and switch between multiple GitHub accounts in header
Persist and normalize accounts list with current selection
This commit is contained in:
Bohdan Triapitsyn
2026-01-26 11:28:55 +02:00
committed by GitHub
parent 1de0ebd4fc
commit 74511abfda
24 changed files with 1106 additions and 180 deletions
+229 -44
View File
@@ -350,6 +350,17 @@ pub struct GitHubUserSummary {
email: Option<String>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitHubAuthAccount {
id: String,
user: GitHubUserSummary,
#[serde(skip_serializing_if = "Option::is_none")]
scope: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
current: Option<bool>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
#[serde(rename_all = "camelCase")]
pub struct GitHubAuthStatus {
@@ -358,6 +369,8 @@ pub struct GitHubAuthStatus {
user: Option<GitHubUserSummary>,
#[serde(skip_serializing_if = "Option::is_none")]
scope: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
accounts: Option<Vec<GitHubAuthAccount>>,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
@@ -418,6 +431,10 @@ struct StoredAuth {
created_at: Option<u64>,
#[serde(skip_serializing_if = "Option::is_none")]
user: Option<GitHubUserSummary>,
#[serde(default, skip_serializing_if = "Option::is_none")]
account_id: Option<String>,
#[serde(default)]
current: bool,
}
#[derive(Debug, Deserialize)]
@@ -709,18 +726,141 @@ fn github_auth_path() -> Result<PathBuf, String> {
Ok(dir)
}
async fn read_auth_file() -> Option<StoredAuth> {
let path = github_auth_path().ok()?;
let bytes = fs::read(&path).await.ok()?;
serde_json::from_slice::<StoredAuth>(&bytes).ok()
fn resolve_account_id(auth: &StoredAuth) -> Option<String> {
if let Some(account_id) = auth.account_id.as_ref().map(|id| id.trim()).filter(|id| !id.is_empty()) {
return Some(account_id.to_string());
}
if let Some(user) = auth.user.as_ref() {
if !user.login.trim().is_empty() {
return Some(user.login.trim().to_string());
}
if let Some(id) = user.id {
return Some(id.to_string());
}
}
if !auth.access_token.trim().is_empty() {
return Some(format!("token:{}", &auth.access_token[..auth.access_token.len().min(8)]));
}
None
}
async fn write_auth_file(auth: &StoredAuth) -> Result<(), String> {
fn normalize_auth_list(list: &mut Vec<StoredAuth>) -> bool {
let mut changed = false;
let mut has_current = false;
for entry in list.iter_mut() {
if entry.account_id.is_none() {
entry.account_id = resolve_account_id(entry);
changed = true;
}
if entry.current && !has_current {
has_current = true;
} else if entry.current && has_current {
entry.current = false;
changed = true;
}
}
if !has_current {
if let Some(first) = list.first_mut() {
first.current = true;
changed = true;
}
}
changed
}
fn build_auth_accounts(list: &[StoredAuth]) -> Option<Vec<GitHubAuthAccount>> {
let mut accounts = Vec::new();
for entry in list.iter() {
let Some(user) = entry.user.clone() else { continue; };
let Some(id) = resolve_account_id(entry) else { continue; };
accounts.push(GitHubAuthAccount {
id,
user,
scope: entry.scope.clone(),
current: Some(entry.current),
});
}
if accounts.is_empty() {
None
} else {
Some(accounts)
}
}
async fn resolve_auth_status() -> Result<GitHubAuthStatus, String> {
let list = read_auth_list().await;
let accounts = build_auth_accounts(&list);
let current = list.iter().find(|entry| entry.current).cloned().or_else(|| list.first().cloned());
let Some(stored) = current else {
return Ok(GitHubAuthStatus {
connected: false,
user: None,
scope: None,
accounts,
});
};
if stored.access_token.trim().is_empty() {
let _ = clear_auth_file().await;
return Ok(GitHubAuthStatus {
connected: false,
user: None,
scope: None,
accounts: build_auth_accounts(&read_auth_list().await),
});
}
match fetch_me(&stored.access_token).await {
Ok(user) => Ok(GitHubAuthStatus {
connected: true,
user: Some(user),
scope: stored.scope,
accounts,
}),
Err(err) if err == "unauthorized" => {
let _ = clear_auth_file().await;
Ok(GitHubAuthStatus {
connected: false,
user: None,
scope: None,
accounts: build_auth_accounts(&read_auth_list().await),
})
}
Err(err) => Err(err),
}
}
async fn read_auth_list() -> Vec<StoredAuth> {
let path = match github_auth_path() {
Ok(path) => path,
Err(_) => return Vec::new(),
};
let bytes = match fs::read(&path).await {
Ok(bytes) => bytes,
Err(_) => return Vec::new(),
};
let mut list = if let Ok(list) = serde_json::from_slice::<Vec<StoredAuth>>(&bytes) {
list
} else if let Ok(entry) = serde_json::from_slice::<StoredAuth>(&bytes) {
vec![entry]
} else {
Vec::new()
};
let changed = normalize_auth_list(&mut list);
if changed {
let _ = persist_auth_list(&list).await;
}
list
}
async fn persist_auth_list(list: &Vec<StoredAuth>) -> Result<(), String> {
let path = github_auth_path()?;
if let Some(parent) = path.parent() {
let _ = fs::create_dir_all(parent).await;
}
let bytes = serde_json::to_vec_pretty(auth).map_err(|e| e.to_string())?;
let bytes = serde_json::to_vec_pretty(list).map_err(|e| e.to_string())?;
fs::write(&path, bytes).await.map_err(|e| e.to_string())?;
#[cfg(unix)]
@@ -736,16 +876,52 @@ async fn write_auth_file(auth: &StoredAuth) -> Result<(), String> {
Ok(())
}
async fn read_auth_file() -> Option<StoredAuth> {
let list = read_auth_list().await;
let current = list.iter().find(|entry| entry.current).cloned();
current.or_else(|| list.into_iter().next())
}
async fn write_auth_file(auth: &StoredAuth) -> Result<(), String> {
let mut list = read_auth_list().await;
let mut next = auth.clone();
next.current = true;
next.account_id = resolve_account_id(&next);
let account_id = next.account_id.clone();
if let Some(account_id) = account_id.as_ref() {
if let Some(index) = list.iter().position(|entry| entry.account_id.as_ref() == Some(account_id)) {
list[index] = next;
} else {
list.push(next);
}
} else {
list.push(next);
}
for entry in list.iter_mut() {
entry.current = account_id.is_some() && entry.account_id.as_ref() == account_id.as_ref();
}
persist_auth_list(&list).await
}
async fn clear_auth_file() -> bool {
let path = match github_auth_path() {
Ok(p) => p,
Err(_) => return false,
};
match fs::remove_file(&path).await {
Ok(_) => true,
Err(err) if err.kind() == std::io::ErrorKind::NotFound => true,
Err(_) => false,
let mut list = read_auth_list().await;
if list.is_empty() {
return true;
}
list.retain(|entry| !entry.current);
if list.is_empty() {
return fs::remove_file(&path).await.is_ok() || !path.exists();
}
normalize_auth_list(&mut list);
persist_auth_list(&list).await.is_ok()
}
fn read_string_setting(settings: &Value, key: &str) -> Option<String> {
@@ -995,40 +1171,7 @@ fn map_issue_labels(labels: Vec<IssueLabel>) -> Vec<GitHubIssueLabel> {
pub async fn github_auth_status(
_state: State<'_, DesktopRuntime>,
) -> Result<GitHubAuthStatus, String> {
let stored = read_auth_file().await;
let Some(stored) = stored else {
return Ok(GitHubAuthStatus {
connected: false,
user: None,
scope: None,
});
};
if stored.access_token.trim().is_empty() {
let _ = clear_auth_file().await;
return Ok(GitHubAuthStatus {
connected: false,
user: None,
scope: None,
});
}
match fetch_me(&stored.access_token).await {
Ok(user) => Ok(GitHubAuthStatus {
connected: true,
user: Some(user),
scope: stored.scope,
}),
Err(err) if err == "unauthorized" => {
let _ = clear_auth_file().await;
Ok(GitHubAuthStatus {
connected: false,
user: None,
scope: None,
})
}
Err(err) => Err(err),
}
resolve_auth_status().await
}
#[tauri::command]
@@ -1138,6 +1281,8 @@ pub async fn github_auth_complete(
.as_millis() as u64,
),
user: Some(user.clone()),
account_id: None,
current: true,
};
write_auth_file(&stored).await?;
@@ -1158,6 +1303,46 @@ pub async fn github_auth_disconnect(
Ok(GitHubDisconnectResult { removed })
}
#[tauri::command]
#[allow(non_snake_case)]
pub async fn github_auth_activate(
accountId: String,
_state: State<'_, DesktopRuntime>,
) -> Result<GitHubAuthStatus, String> {
let account_id = accountId.trim().to_string();
if account_id.is_empty() {
return Err("accountId is required".to_string());
}
let mut list = read_auth_list().await;
if list.is_empty() {
return Ok(GitHubAuthStatus {
connected: false,
user: None,
scope: None,
accounts: None,
});
}
let mut found = false;
for entry in list.iter_mut() {
let entry_id = resolve_account_id(entry);
if entry_id.as_deref() == Some(account_id.as_str()) {
entry.current = true;
found = true;
} else {
entry.current = false;
}
}
if !found {
return Err("GitHub account not found".to_string());
}
persist_auth_list(&list).await?;
resolve_auth_status().await
}
#[tauri::command]
pub async fn github_me(_state: State<'_, DesktopRuntime>) -> Result<GitHubUserSummary, String> {
let stored = read_auth_file().await;
+2 -1
View File
@@ -45,7 +45,7 @@ use commands::git::{
use commands::logs::fetch_desktop_logs;
use commands::github::{
github_auth_complete, github_auth_disconnect, github_auth_start, github_auth_status, github_me,
github_auth_activate, github_auth_complete, github_auth_disconnect, github_auth_start, github_auth_status, github_me,
github_issue_comments, github_issue_get, github_issues_list,
github_pr_context, github_prs_list,
github_pr_create, github_pr_merge, github_pr_ready, github_pr_status,
@@ -922,6 +922,7 @@ fn main() {
github_auth_start,
github_auth_complete,
github_auth_disconnect,
github_auth_activate,
github_me,
github_pr_status,
github_pr_create,
+5
View File
@@ -40,6 +40,11 @@ export const createDesktopGitHubAPI = (): GitHubAPI => ({
return { removed: Boolean(result?.removed) };
},
async authActivate(accountId: string): Promise<GitHubAuthStatus> {
const { safeInvoke } = await import('../lib/tauriCallbackManager');
return safeInvoke<GitHubAuthStatus>('github_auth_activate', { accountId }, { timeout: 8000 });
},
async me(): Promise<GitHubUserSummary> {
const { safeInvoke } = await import('../lib/tauriCallbackManager');
return safeInvoke<GitHubUserSummary>('github_me', {}, { timeout: 8000 });
+6
View File
@@ -29,6 +29,7 @@ import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { OnboardingScreen } from '@/components/onboarding/OnboardingScreen';
import { isCliAvailable } from '@/lib/desktop';
import { useUIStore } from '@/stores/useUIStore';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import type { RuntimeAPIs } from '@/lib/api/types';
const AboutDialogWrapper: React.FC = () => {
@@ -52,6 +53,7 @@ function App({ apis }: AppProps) {
const isSwitchingDirectory = useDirectoryStore((state) => state.isSwitchingDirectory);
const [showMemoryDebug, setShowMemoryDebug] = React.useState(false);
const { uiFont, monoFont } = useFontPreferences();
const refreshGitHubAuthStatus = useGitHubAuthStore((state) => state.refreshStatus);
const [isDesktopRuntime, setIsDesktopRuntime] = React.useState<boolean>(() => apis.runtime.isDesktop);
const [isVSCodeRuntime, setIsVSCodeRuntime] = React.useState<boolean>(() => apis.runtime.isVSCode);
const [cliAvailable, setCliAvailable] = React.useState<boolean>(() => {
@@ -69,6 +71,10 @@ function App({ apis }: AppProps) {
return () => registerRuntimeAPIs(null);
}, [apis]);
React.useEffect(() => {
void refreshGitHubAuthStatus(apis.github, { force: true });
}, [apis.github, refreshGitHubAuthStatus]);
React.useEffect(() => {
if (typeof document === 'undefined') {
return;
@@ -1945,6 +1945,8 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
const isSelected = currentProviderId === providerID && currentModelId === modelID;
const isFavorite = isFavoriteModel(providerID, modelID);
const showProviderLogo = keyPrefix === 'fav' || keyPrefix === 'recent';
return (
<div
key={`${keyPrefix}-${providerID}-${modelID}`}
@@ -1957,6 +1959,9 @@ export const ModelControls: React.FC<ModelControlsProps> = ({ className }) => {
onMouseEnter={() => setModelSelectedIndex(flatIndex)}
>
<div className="flex items-center gap-1.5 flex-1 min-w-0">
{showProviderLogo && (
<ProviderLogo providerId={providerID} className="h-3.5 w-3.5 flex-shrink-0" />
)}
<span className="font-medium truncate">
{getModelDisplayName(model)}
</span>
@@ -1,6 +1,7 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { SimpleMarkdownRenderer } from '../../MarkdownRenderer';
import type { Part } from '@opencode-ai/sdk/v2';
import type { AgentMentionInfo } from '../types';
@@ -53,7 +54,6 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
return null;
}
// Render content with optional agent mention link
const renderContent = () => {
if (!agentMention?.token || !textContent.includes(agentMention.token)) {
return textContent;
@@ -61,27 +61,14 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
const idx = textContent.indexOf(agentMention.token);
const before = textContent.slice(0, idx);
const after = textContent.slice(idx + agentMention.token.length);
return (
<>
{before}
<a
href={buildMentionUrl(agentMention.name)}
className="text-primary hover:underline"
target="_blank"
rel="noopener noreferrer"
onClick={(e) => e.stopPropagation()}
>
{agentMention.token}
</a>
{after}
</>
);
const mentionLink = `[${agentMention.token}](${buildMentionUrl(agentMention.name)})`;
return `${before}${mentionLink}${after}`;
};
return (
<div
className={cn(
"break-words whitespace-pre-wrap font-sans typography-markdown",
"font-sans typography-markdown",
!isExpanded && "line-clamp-3",
(isTruncated || isExpanded) && "cursor-pointer"
)}
@@ -89,7 +76,10 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
onClick={handleClick}
key={part.id || `${messageId}-user-text`}
>
{renderContent()}
<SimpleMarkdownRenderer
content={renderContent()}
className="text-foreground/90"
/>
</div>
);
};
+144 -1
View File
@@ -4,19 +4,29 @@ import {
TooltipContent,
TooltipTrigger,
} from '@/components/ui/tooltip';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { RiArrowLeftSLine, RiChat4Line, RiCodeLine, RiCommandLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiLayoutLeftLine, RiPlayListAddLine, RiQuestionLine, RiSettings3Line, RiTerminalBoxLine, type RemixiconComponentType } from '@remixicon/react';
import { RiArrowLeftSLine, RiChat4Line, RiCheckLine, RiCodeLine, RiCommandLine, RiFileTextLine, RiFolder6Line, RiGitBranchLine, RiGithubFill, RiLayoutLeftLine, RiPlayListAddLine, RiQuestionLine, RiSettings3Line, RiTerminalBoxLine, type RemixiconComponentType } from '@remixicon/react';
import { useUIStore, type MainTab } from '@/stores/useUIStore';
import { useUpdateStore } from '@/stores/useUpdateStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay';
import { useDeviceInfo } from '@/lib/device';
import { cn, getModifierLabel, hasModifier } from '@/lib/utils';
import { useDiffFileCount } from '@/components/views/DiffView';
import { McpDropdown } from '@/components/mcp/McpDropdown';
import type { GitHubAuthStatus } from '@/lib/api/types';
const normalize = (value: string): string => {
if (!value) return '';
@@ -78,6 +88,8 @@ export const Header: React.FC = () => {
const { isMobile } = useDeviceInfo();
const diffFileCount = useDiffFileCount();
const updateAvailable = useUpdateStore((state) => state.available);
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
const setGitHubAuthStatus = useGitHubAuthStore((state) => state.setStatus);
const headerRef = React.useRef<HTMLElement | null>(null);
@@ -111,6 +123,10 @@ export const Header: React.FC = () => {
const outputLimit = (limit && typeof limit.output === 'number' ? limit.output : 0);
const contextUsage = getContextUsage(contextLimit, outputLimit);
const isSessionSwitcherOpen = useUIStore((state) => state.isSessionSwitcherOpen);
const githubAvatarUrl = githubAuthStatus?.connected ? githubAuthStatus.user?.avatarUrl : null;
const githubLogin = githubAuthStatus?.connected ? githubAuthStatus.user?.login : null;
const githubAccounts = githubAuthStatus?.accounts ?? [];
const [isSwitchingGitHubAccount, setIsSwitchingGitHubAccount] = React.useState(false);
const currentSession = React.useMemo(() => {
if (!currentSessionId) return null;
@@ -127,6 +143,38 @@ export const Header: React.FC = () => {
const showPlanTab = planTabAvailable;
const lastPlanSessionKeyRef = React.useRef<string>('');
const handleGitHubAccountSwitch = React.useCallback(async (accountId: string) => {
if (!accountId || isSwitchingGitHubAccount) return;
setIsSwitchingGitHubAccount(true);
try {
const payload = runtimeApis.github
? await runtimeApis.github.authActivate(accountId)
: await (async () => {
const response = await fetch('/api/github/auth/activate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ accountId }),
});
const body = (await response.json().catch(() => null)) as
| (GitHubAuthStatus & { error?: string })
| null;
if (!response.ok || !body) {
throw new Error(body?.error || response.statusText);
}
return body;
})();
setGitHubAuthStatus(payload);
} catch (error) {
console.error('Failed to switch GitHub account:', error);
} finally {
setIsSwitchingGitHubAccount(false);
}
}, [isSwitchingGitHubAccount, runtimeApis.github, setGitHubAuthStatus]);
React.useEffect(() => {
let cancelled = false;
@@ -469,6 +517,101 @@ export const Header: React.FC = () => {
<p>Keyboard Shortcuts ({getModifierLabel()}+.)</p>
</TooltipContent>
</Tooltip>
{githubAuthStatus?.connected && !isMobile ? (
githubAccounts.length > 1 ? (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
type="button"
className={cn(
headerIconButtonClass,
'h-8 w-8 p-0 overflow-hidden rounded-full border border-border/60 bg-muted/80'
)}
title={githubLogin ? `GitHub: ${githubLogin}` : 'GitHub connected'}
disabled={isSwitchingGitHubAccount}
>
{githubAvatarUrl ? (
<img
src={githubAvatarUrl}
alt={githubLogin ? `${githubLogin} avatar` : 'GitHub avatar'}
className="h-full w-full object-cover"
loading="lazy"
referrerPolicy="no-referrer"
/>
) : (
<RiGithubFill className="h-4 w-4 text-muted-foreground" />
)}
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="w-64">
<DropdownMenuLabel className="typography-ui-header font-semibold text-foreground">
GitHub Accounts
</DropdownMenuLabel>
<DropdownMenuSeparator />
{githubAccounts.map((account) => {
const accountUser = account.user;
const isCurrent = Boolean(account.current);
return (
<DropdownMenuItem
key={account.id}
className="gap-2"
disabled={isCurrent || isSwitchingGitHubAccount}
onSelect={() => {
if (!isCurrent) {
void handleGitHubAccountSwitch(account.id);
}
}}
>
{accountUser?.avatarUrl ? (
<img
src={accountUser.avatarUrl}
alt={accountUser.login ? `${accountUser.login} avatar` : 'GitHub avatar'}
className="h-6 w-6 rounded-full border border-border/60 bg-muted object-cover"
loading="lazy"
referrerPolicy="no-referrer"
/>
) : (
<div className="flex h-6 w-6 items-center justify-center rounded-full border border-border/60 bg-muted">
<RiGithubFill className="h-3 w-3 text-muted-foreground" />
</div>
)}
<span className="flex min-w-0 flex-1 flex-col">
<span className="typography-ui-label text-foreground truncate">
{accountUser?.name?.trim() || accountUser?.login || 'GitHub'}
</span>
{accountUser?.login ? (
<span className="typography-micro text-muted-foreground truncate font-mono">
{accountUser.login}
</span>
) : null}
</span>
{isCurrent ? (
<RiCheckLine className="h-4 w-4 text-primary" />
) : null}
</DropdownMenuItem>
);
})}
</DropdownMenuContent>
</DropdownMenu>
) : (
<div
className="app-region-no-drag flex h-8 w-8 items-center justify-center overflow-hidden rounded-full border border-border/60 bg-muted/80"
title={githubLogin ? `GitHub: ${githubLogin}` : 'GitHub connected'}
>
{githubAvatarUrl ? (
<img
src={githubAvatarUrl}
alt={githubLogin ? `${githubLogin} avatar` : 'GitHub avatar'}
className="h-full w-full object-cover"
loading="lazy"
referrerPolicy="no-referrer"
/>
) : (
<RiGithubFill className="h-4 w-4 text-muted-foreground" />
)}
</div>
)
) : null}
</div>
</div>
);
@@ -254,6 +254,8 @@ export const ModelMultiSelect: React.FC<ModelMultiSelectProps> = ({
const metadata = getModelMetadata(providerID, modelID);
const contextTokens = formatTokens(metadata?.limit?.context);
const showProviderLogo = keyPrefix === 'fav' || keyPrefix === 'recent';
return (
<button
key={`${keyPrefix}-${key}`}
@@ -275,6 +277,9 @@ export const ModelMultiSelect: React.FC<ModelMultiSelectProps> = ({
)}
>
<div className="flex items-center gap-1.5 flex-1 min-w-0">
{showProviderLogo && (
<ProviderLogo providerId={providerID} className="h-3.5 w-3.5 flex-shrink-0" />
)}
<span className="font-medium truncate">
{getModelDisplayName(model)}
</span>
@@ -153,6 +153,8 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
const isSelected = providerId === provID && modelId === modID;
const isFavorite = isFavoriteModel(provID, modID);
const showProviderLogo = keyPrefix === 'fav' || keyPrefix === 'recent';
return (
<div
key={`${keyPrefix}-${provID}-${modID}`}
@@ -165,6 +167,9 @@ export const ModelSelector: React.FC<ModelSelectorProps> = ({
onMouseEnter={() => setSelectedIndex(flatIndex)}
>
<div className="flex items-center gap-1.5 flex-1 min-w-0">
{showProviderLogo && (
<ProviderLogo providerId={provID} className="h-3.5 w-3.5 flex-shrink-0" />
)}
<span className="font-medium truncate">
{getModelDisplayName(model)}
</span>
@@ -2,6 +2,8 @@ import React from 'react';
import { Button } from '@/components/ui/button';
import { toast } from 'sonner';
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import type { GitHubAuthStatus } from '@/lib/api/types';
import { RiGithubFill } from '@remixicon/react';
type GitHubUser = {
@@ -12,13 +14,6 @@ type GitHubUser = {
email?: string;
};
type AuthStatusResponse = {
connected: boolean;
user?: GitHubUser | null;
scope?: string;
error?: string;
};
type DeviceFlowStartResponse = {
deviceCode: string;
userCode: string;
@@ -35,6 +30,11 @@ type DeviceFlowCompleteResponse =
export const GitHubSettings: React.FC = () => {
const runtimeGitHub = getRegisteredRuntimeAPIs()?.github;
const status = useGitHubAuthStore((state) => state.status);
const isLoading = useGitHubAuthStore((state) => state.isLoading);
const hasChecked = useGitHubAuthStore((state) => state.hasChecked);
const refreshStatus = useGitHubAuthStore((state) => state.refreshStatus);
const setStatus = useGitHubAuthStore((state) => state.setStatus);
const openExternal = React.useCallback(async (url: string) => {
if (typeof window === 'undefined') {
@@ -60,9 +60,7 @@ export const GitHubSettings: React.FC = () => {
}
}, []);
const [isLoading, setIsLoading] = React.useState(true);
const [isBusy, setIsBusy] = React.useState(false);
const [status, setStatus] = React.useState<AuthStatusResponse | null>(null);
const [flow, setFlow] = React.useState<DeviceFlowStartResponse | null>(null);
const [pollIntervalMs, setPollIntervalMs] = React.useState<number | null>(null);
const pollTimerRef = React.useRef<number | null>(null);
@@ -75,41 +73,20 @@ export const GitHubSettings: React.FC = () => {
setPollIntervalMs(null);
}, []);
const refreshStatus = React.useCallback(async () => {
if (runtimeGitHub) {
const payload = await runtimeGitHub.authStatus();
setStatus(payload as AuthStatusResponse);
return payload as AuthStatusResponse;
}
const response = await fetch('/api/github/auth/status', {
method: 'GET',
headers: { Accept: 'application/json' },
});
const payload = (await response.json().catch(() => null)) as AuthStatusResponse | null;
if (!response.ok || !payload) {
throw new Error(payload?.error || response.statusText || 'Failed to load GitHub status');
}
setStatus(payload);
return payload;
}, [runtimeGitHub]);
React.useEffect(() => {
let mounted = true;
(async () => {
try {
await refreshStatus();
if (!hasChecked) {
await refreshStatus(runtimeGitHub);
}
} catch (error) {
console.warn('Failed to load GitHub auth status:', error);
} finally {
if (mounted) setIsLoading(false);
}
})();
return () => {
mounted = false;
stopPolling();
};
}, [refreshStatus, stopPolling]);
}, [hasChecked, refreshStatus, runtimeGitHub, stopPolling]);
const startConnect = React.useCallback(async () => {
setIsBusy(true);
@@ -178,13 +155,13 @@ export const GitHubSettings: React.FC = () => {
void (async () => {
try {
const result = await pollOnce(flow.deviceCode);
if (result.connected) {
toast.success('GitHub connected');
setFlow(null);
stopPolling();
await refreshStatus();
return;
}
if (result.connected) {
toast.success('GitHub connected');
setFlow(null);
stopPolling();
await refreshStatus(runtimeGitHub, { force: true });
return;
}
if (result.status === 'slow_down') {
setPollIntervalMs((prev) => (prev ? prev + 5000 : 5000));
@@ -207,7 +184,7 @@ export const GitHubSettings: React.FC = () => {
pollTimerRef.current = null;
}
};
}, [flow, pollIntervalMs, pollOnce, refreshStatus, stopPolling]);
}, [flow, pollIntervalMs, pollOnce, refreshStatus, runtimeGitHub, stopPolling]);
const disconnect = React.useCallback(async () => {
setIsBusy(true);
@@ -226,14 +203,46 @@ export const GitHubSettings: React.FC = () => {
}
}
toast.success('GitHub disconnected');
await refreshStatus();
await refreshStatus(runtimeGitHub, { force: true });
} catch (error) {
console.error('Failed to disconnect GitHub:', error);
toast.error('Failed to disconnect GitHub');
} finally {
setIsBusy(false);
}
}, [refreshStatus, stopPolling, runtimeGitHub]);
}, [refreshStatus, runtimeGitHub, stopPolling]);
const activateAccount = React.useCallback(async (accountId: string) => {
if (!accountId) return;
setIsBusy(true);
try {
const payload = runtimeGitHub
? await runtimeGitHub.authActivate(accountId)
: await (async () => {
const response = await fetch('/api/github/auth/activate', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify({ accountId }),
});
const body = (await response.json().catch(() => null)) as GitHubAuthStatus | { error?: string } | null;
if (!response.ok || !body) {
throw new Error((body as { error?: string } | null)?.error || response.statusText);
}
return body as GitHubAuthStatus;
})();
setStatus(payload);
toast.success('GitHub account switched');
} catch (error) {
console.error('Failed to switch GitHub account:', error);
toast.error('Failed to switch GitHub account');
} finally {
setIsBusy(false);
}
}, [runtimeGitHub, setStatus]);
if (isLoading) {
return null;
@@ -241,6 +250,7 @@ export const GitHubSettings: React.FC = () => {
const connected = Boolean(status?.connected);
const user = status?.user;
const accounts = status?.accounts ?? [];
return (
<div className="space-y-6">
@@ -296,6 +306,70 @@ export const GitHubSettings: React.FC = () => {
</div>
)}
{connected ? (
<div className="flex justify-end">
<Button variant="ghost" onClick={startConnect} disabled={isBusy}>
Add account
</Button>
</div>
) : null}
{accounts.length > 1 ? (
<div className="space-y-2 rounded-lg border bg-background/50 p-3">
<div className="typography-ui-label text-foreground">Accounts</div>
<div className="space-y-2">
{accounts.map((account) => {
const accountUser = account.user;
const isCurrent = Boolean(account.current);
return (
<div
key={account.id}
className="flex items-center justify-between gap-3 rounded-md border border-border/40 bg-background/70 px-3 py-2"
>
<div className="flex min-w-0 items-center gap-3">
{accountUser?.avatarUrl ? (
<img
src={accountUser.avatarUrl}
alt={accountUser.login ? `${accountUser.login} avatar` : 'GitHub avatar'}
className="h-8 w-8 shrink-0 rounded-full border border-border/60 bg-muted object-cover"
loading="lazy"
referrerPolicy="no-referrer"
/>
) : (
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full border border-border/60 bg-muted">
<RiGithubFill className="h-4 w-4 text-muted-foreground" />
</div>
)}
<div className="min-w-0">
<div className="typography-ui-label text-foreground truncate">
{accountUser?.name?.trim() || accountUser?.login || 'GitHub'}
</div>
{accountUser?.login ? (
<div className="typography-micro text-muted-foreground truncate font-mono">
{accountUser.login}
</div>
) : null}
</div>
</div>
{isCurrent ? (
<span className="typography-micro text-primary">Active</span>
) : (
<Button
size="sm"
variant="ghost"
onClick={() => activateAccount(account.id)}
disabled={isBusy}
>
Use
</Button>
)}
</div>
);
})}
</div>
</div>
) : null}
{flow ? (
<div className="space-y-3 rounded-lg border bg-background/50 p-3">
<div className="space-y-1">
@@ -28,20 +28,22 @@ export const OpenChamberPage: React.FC<OpenChamberPageProps> = ({ section }) =>
<ScrollableOverlay
keyboardAvoid
outerClassName="h-full"
className="openchamber-page-body mx-auto max-w-3xl space-y-3 p-3 sm:space-y-6 sm:p-6"
className="w-full"
>
<OpenChamberVisualSettings />
<div className="border-t border-border/40 pt-6">
<DefaultsSettings />
</div>
<div className="border-t border-border/40 pt-6">
<SessionRetentionSettings />
</div>
{showAbout && (
<div className="openchamber-page-body mx-auto max-w-3xl space-y-3 p-3 sm:space-y-6 sm:p-6">
<OpenChamberVisualSettings />
<div className="border-t border-border/40 pt-6">
<AboutSettings />
<DefaultsSettings />
</div>
)}
<div className="border-t border-border/40 pt-6">
<SessionRetentionSettings />
</div>
{showAbout && (
<div className="border-t border-border/40 pt-6">
<AboutSettings />
</div>
)}
</div>
</ScrollableOverlay>
);
}
@@ -70,9 +72,11 @@ export const OpenChamberPage: React.FC<OpenChamberPageProps> = ({ section }) =>
<ScrollableOverlay
keyboardAvoid
outerClassName="h-full"
className="openchamber-page-body mx-auto max-w-3xl space-y-6 p-3 sm:p-6"
className="w-full"
>
{renderSectionContent()}
<div className="openchamber-page-body mx-auto max-w-3xl space-y-6 p-3 sm:p-6">
{renderSectionContent()}
</div>
</ScrollableOverlay>
);
};
@@ -25,6 +25,7 @@ import { useConfigStore } from '@/stores/useConfigStore';
import { useMessageStore } from '@/stores/messageStore';
import { useContextStore } from '@/stores/contextStore';
import { useUIStore } from '@/stores/useUIStore';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import { opencodeClient } from '@/lib/opencode/client';
import { createWorktreeSessionForNewBranch } from '@/lib/worktreeSessionCreator';
import { generateBranchSlug } from '@/lib/git/branchNameGenerator';
@@ -70,6 +71,8 @@ export function GitHubIssuePickerDialog({
onOpenChange: (open: boolean) => void;
}) {
const { github } = useRuntimeAPIs();
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
const setSidebarSection = useUIStore((state) => state.setSidebarSection);
const activeProject = useProjectsStore((state) => state.getActiveProject());
@@ -94,6 +97,14 @@ export function GitHubIssuePickerDialog({
setError('No active project');
return;
}
if (githubAuthChecked && githubAuthStatus?.connected === false) {
setResult({ connected: false });
setIssues([]);
setHasMore(false);
setPage(1);
setError(null);
return;
}
if (!github?.issuesList) {
setResult(null);
setError('GitHub runtime API unavailable');
@@ -116,7 +127,7 @@ export function GitHubIssuePickerDialog({
} finally {
setIsLoading(false);
}
}, [github, projectDirectory]);
}, [github, githubAuthChecked, githubAuthStatus, projectDirectory]);
const loadMore = React.useCallback(async () => {
if (!projectDirectory) return;
@@ -156,7 +167,18 @@ export function GitHubIssuePickerDialog({
void refresh();
}, [open, refresh]);
const connected = Boolean(result?.connected);
React.useEffect(() => {
if (!open) return;
if (githubAuthChecked && githubAuthStatus?.connected === false) {
setResult({ connected: false });
setIssues([]);
setHasMore(false);
setPage(1);
setError(null);
}
}, [githubAuthChecked, githubAuthStatus, open]);
const connected = githubAuthChecked ? result?.connected !== false : true;
const repoUrl = result?.repo?.url ?? null;
const openGitHubSettings = React.useCallback(() => {
@@ -25,6 +25,7 @@ import { useConfigStore } from '@/stores/useConfigStore';
import { useMessageStore } from '@/stores/messageStore';
import { useContextStore } from '@/stores/contextStore';
import { useUIStore } from '@/stores/useUIStore';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import { opencodeClient } from '@/lib/opencode/client';
import { createWorktreeSessionForNewBranchExact } from '@/lib/worktreeSessionCreator';
import { gitFetch } from '@/lib/gitApi';
@@ -61,6 +62,8 @@ export function GitHubPullRequestPickerDialog({
onOpenChange: (open: boolean) => void;
}) {
const { github } = useRuntimeAPIs();
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
const setSidebarSection = useUIStore((state) => state.setSidebarSection);
const activeProject = useProjectsStore((state) => state.getActiveProject());
@@ -85,6 +88,14 @@ export function GitHubPullRequestPickerDialog({
setError('No active project');
return;
}
if (githubAuthChecked && githubAuthStatus?.connected === false) {
setResult({ connected: false });
setPrs([]);
setHasMore(false);
setPage(1);
setError(null);
return;
}
if (!github?.prsList) {
setResult(null);
setError('GitHub runtime API unavailable');
@@ -107,7 +118,7 @@ export function GitHubPullRequestPickerDialog({
} finally {
setIsLoading(false);
}
}, [github, projectDirectory]);
}, [github, githubAuthChecked, githubAuthStatus, projectDirectory]);
const loadMore = React.useCallback(async () => {
if (!projectDirectory) return;
@@ -148,7 +159,18 @@ export function GitHubPullRequestPickerDialog({
void refresh();
}, [open, refresh]);
const connected = Boolean(result?.connected);
React.useEffect(() => {
if (!open) return;
if (githubAuthChecked && githubAuthStatus?.connected === false) {
setResult({ connected: false });
setPrs([]);
setHasMore(false);
setPage(1);
setError(null);
}
}, [githubAuthChecked, githubAuthStatus, open]);
const connected = githubAuthChecked ? result?.connected !== false : true;
const repoUrl = result?.repo?.url ?? null;
const openGitHubSettings = React.useCallback(() => {
@@ -34,13 +34,13 @@ export const ScrollableOverlay = React.forwardRef<HTMLElement, ScrollableOverlay
return (
<div
className={cn("relative flex flex-col min-h-0 w-full overflow-hidden overscroll-none", outerClassName)}
className={cn("relative flex flex-col min-h-0 w-full overflow-hidden overscroll-auto", outerClassName)}
data-keyboard-avoid={keyboardAvoid ? "true" : undefined}
>
<Component
ref={containerRef as React.Ref<HTMLElement>}
className={cn(
"overlay-scrollbar-target overlay-scrollbar-container overscroll-none",
"overlay-scrollbar-target overlay-scrollbar-container overscroll-auto",
fillContainer ? "flex-1 min-h-0 w-full" : "flex-none w-full h-auto",
disableHorizontal ? "overflow-y-auto overflow-x-hidden" : "overflow-auto",
className
@@ -29,6 +29,7 @@ import { useUIStore } from '@/stores/useUIStore';
import { useMessageStore } from '@/stores/messageStore';
import { useSessionStore } from '@/stores/useSessionStore';
import { useConfigStore } from '@/stores/useConfigStore';
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
import type {
GitHubPullRequest,
GitHubCheckRun,
@@ -95,6 +96,8 @@ export const PullRequestSection: React.FC<{
baseBranch: string;
}> = ({ directory, branch, baseBranch }) => {
const { github } = useRuntimeAPIs();
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
const setSettingsDialogOpen = useUIStore((state) => state.setSettingsDialogOpen);
const setSidebarSection = useUIStore((state) => state.setSidebarSection);
const setActiveMainTab = useUIStore((state) => state.setActiveMainTab);
@@ -352,6 +355,12 @@ export const PullRequestSection: React.FC<{
const refresh = React.useCallback(async () => {
if (!canShow) return;
if (githubAuthChecked && githubAuthStatus?.connected === false) {
setStatus({ connected: false });
setError(null);
setIsLoading(false);
return;
}
if (!github?.prStatus) {
setStatus(null);
setError('GitHub runtime API unavailable');
@@ -371,7 +380,7 @@ export const PullRequestSection: React.FC<{
} finally {
setIsLoading(false);
}
}, [branch, canShow, directory, github]);
}, [branch, canShow, directory, github, githubAuthChecked, githubAuthStatus]);
React.useEffect(() => {
const snapshot = pullRequestDraftSnapshots.get(snapshotKey) ?? null;
@@ -382,6 +391,13 @@ export const PullRequestSection: React.FC<{
void refresh();
}, [branch, refresh, snapshotKey]);
React.useEffect(() => {
if (githubAuthChecked && githubAuthStatus?.connected === false) {
setStatus({ connected: false });
setError(null);
}
}, [githubAuthChecked, githubAuthStatus]);
React.useEffect(() => {
if (!directory || !branch) {
return;
@@ -505,6 +521,7 @@ export const PullRequestSection: React.FC<{
const checks = status?.checks ?? null;
const canMerge = Boolean(status?.canMerge);
const isConnected = Boolean(status?.connected);
const shouldShowConnectionNotice = githubAuthChecked && status?.connected === false;
return (
<Collapsible
@@ -534,7 +551,7 @@ export const PullRequestSection: React.FC<{
<CollapsibleContent>
<div className="border-t border-border/40">
<div className="flex flex-col gap-3 p-3">
{!isConnected ? (
{shouldShowConnectionNotice ? (
<div className="space-y-2">
<div className="typography-meta text-muted-foreground">
GitHub not connected. Connect your GitHub account in settings.
+2 -2
View File
@@ -572,13 +572,13 @@ html:not(.dark) .chat-scroll {
/* Fix list styling - override Tailwind reset */
.streamdown-content ul {
list-style-type: disc;
list-style-position: outside;
list-style-position: inside;
padding-left: 1.5em;
}
.streamdown-content ol {
list-style-type: decimal;
list-style-position: outside;
list-style-position: inside;
padding-left: 1.5em;
}
+9
View File
@@ -708,6 +708,14 @@ export type GitHubAuthStatus = {
connected: boolean;
user?: GitHubUserSummary | null;
scope?: string;
accounts?: GitHubAuthAccount[];
};
export type GitHubAuthAccount = {
id: string;
user: GitHubUserSummary;
scope?: string;
current?: boolean;
};
export type GitHubDeviceFlowStart = {
@@ -729,6 +737,7 @@ export interface GitHubAPI {
authStart(): Promise<GitHubDeviceFlowStart>;
authComplete(deviceCode: string): Promise<GitHubDeviceFlowComplete>;
authDisconnect(): Promise<{ removed: boolean }>;
authActivate(accountId: string): Promise<GitHubAuthStatus>;
me?(): Promise<GitHubUserSummary>;
prStatus(directory: string, branch: string): Promise<GitHubPullRequestStatus>;
@@ -0,0 +1,62 @@
import { create } from 'zustand';
import type { GitHubAuthStatus, RuntimeAPIs } from '@/lib/api/types';
type GitHubAuthStatusWithError = GitHubAuthStatus & { error?: string };
type GitHubAuthStore = {
status: GitHubAuthStatusWithError | null;
isLoading: boolean;
hasChecked: boolean;
setStatus: (status: GitHubAuthStatusWithError | null) => void;
refreshStatus: (
runtimeGitHub?: RuntimeAPIs['github'],
options?: { force?: boolean }
) => Promise<GitHubAuthStatusWithError | null>;
};
const fetchStatus = async (
runtimeGitHub?: RuntimeAPIs['github']
): Promise<GitHubAuthStatusWithError> => {
if (runtimeGitHub) {
const payload = await runtimeGitHub.authStatus();
return payload as GitHubAuthStatus;
}
const response = await fetch('/api/github/auth/status', {
method: 'GET',
headers: { Accept: 'application/json' },
});
const payload = (await response.json().catch(() => null)) as GitHubAuthStatusWithError | null;
if (!response.ok || !payload) {
throw new Error(payload?.error || response.statusText || 'Failed to load GitHub status');
}
return payload;
};
export const useGitHubAuthStore = create<GitHubAuthStore>((set, get) => ({
status: null,
isLoading: false,
hasChecked: false,
setStatus: (status) => set({ status, hasChecked: true }),
refreshStatus: async (runtimeGitHub, options) => {
const { hasChecked, status } = get();
if (hasChecked && !options?.force) {
return status;
}
set({ isLoading: true });
try {
const payload = await fetchStatus(runtimeGitHub);
set({ status: payload, isLoading: false, hasChecked: true });
return payload;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
set({
status: { connected: false, error: message },
isLoading: false,
hasChecked: true,
});
return null;
}
},
}));
+66 -4
View File
@@ -15,10 +15,12 @@ import {
import {
DEFAULT_GITHUB_CLIENT_ID,
DEFAULT_GITHUB_SCOPES,
activateGitHubAuth,
clearGitHubAuth,
exchangeDeviceCode,
fetchMe,
readGitHubAuth,
readGitHubAuthList,
startDeviceFlow,
writeGitHubAuth,
} from './githubAuth';
@@ -968,20 +970,38 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
case 'api:github/auth:status': {
const context = ctx?.context;
if (!context) return { id, type, success: false, error: 'Missing VS Code context' };
const stored = await readGitHubAuth(context);
const list = await readGitHubAuthList(context);
const accounts = list
.filter((entry) => entry.user && entry.accountId)
.map((entry) => ({
id: entry.accountId as string,
user: entry.user,
scope: entry.scope,
current: Boolean(entry.current),
}));
const stored = list.find((entry) => entry.current) || list[0];
if (!stored?.accessToken) {
return { id, type, success: true, data: { connected: false } };
return { id, type, success: true, data: { connected: false, accounts } };
}
try {
const user = await fetchMe(stored.accessToken);
return { id, type, success: true, data: { connected: true, user, scope: stored.scope } };
return { id, type, success: true, data: { connected: true, user, scope: stored.scope, accounts } };
} catch (error: unknown) {
const status = (error && typeof error === 'object' && 'status' in error) ? (error as { status?: number }).status : undefined;
const message = error instanceof Error ? error.message : String(error);
if (status === 401 || message === 'unauthorized') {
await clearGitHubAuth(context);
return { id, type, success: true, data: { connected: false } };
const updatedAccounts = (await readGitHubAuthList(context))
.filter((entry) => entry.user && entry.accountId)
.map((entry) => ({
id: entry.accountId as string,
user: entry.user,
scope: entry.scope,
current: Boolean(entry.current),
}));
return { id, type, success: true, data: { connected: false, accounts: updatedAccounts } };
}
return { id, type, success: false, error: message };
}
@@ -1055,6 +1075,48 @@ export async function handleBridgeMessage(message: BridgeRequest, ctx?: BridgeCo
return { id, type, success: true, data: { removed } };
}
case 'api:github/auth:activate': {
const context = ctx?.context;
if (!context) return { id, type, success: false, error: 'Missing VS Code context' };
const accountId = readStringField(payload, 'accountId');
if (!accountId) return { id, type, success: false, error: 'accountId is required' };
const activated = await activateGitHubAuth(context, accountId);
if (!activated) return { id, type, success: false, error: 'GitHub account not found' };
const list = await readGitHubAuthList(context);
const accounts = list
.filter((entry) => entry.user && entry.accountId)
.map((entry) => ({
id: entry.accountId as string,
user: entry.user,
scope: entry.scope,
current: Boolean(entry.current),
}));
const stored = list.find((entry) => entry.current) || list[0];
if (!stored?.accessToken) {
return { id, type, success: true, data: { connected: false, accounts } };
}
try {
const user = await fetchMe(stored.accessToken);
return { id, type, success: true, data: { connected: true, user, scope: stored.scope, accounts } };
} catch (error: unknown) {
const status = (error && typeof error === 'object' && 'status' in error) ? (error as { status?: number }).status : undefined;
const message = error instanceof Error ? error.message : String(error);
if (status === 401 || message === 'unauthorized') {
await clearGitHubAuth(context);
const updatedAccounts = (await readGitHubAuthList(context))
.filter((entry) => entry.user && entry.accountId)
.map((entry) => ({
id: entry.accountId as string,
user: entry.user,
scope: entry.scope,
current: Boolean(entry.current),
}));
return { id, type, success: true, data: { connected: false, accounts: updatedAccounts } };
}
return { id, type, success: false, error: message };
}
}
case 'api:github/me': {
const context = ctx?.context;
if (!context) return { id, type, success: false, error: 'Missing VS Code context' };
+106 -8
View File
@@ -17,6 +17,8 @@ type StoredAuth = {
tokenType?: string;
createdAt?: number;
user?: { login: string; id?: number; avatarUrl?: string };
accountId?: string;
current?: boolean;
};
type JsonRecord = Record<string, unknown>;
@@ -41,22 +43,91 @@ type TokenResponse = {
const authFilePath = (context: vscode.ExtensionContext) =>
path.join(context.globalStorageUri.fsPath, 'github-auth.json');
export const readGitHubAuth = async (context: vscode.ExtensionContext): Promise<StoredAuth | null> => {
const resolveAccountId = (auth: StoredAuth): string => {
if (typeof auth.accountId === 'string' && auth.accountId.trim()) {
return auth.accountId.trim();
}
if (auth.user?.login) {
return auth.user.login.trim();
}
if (typeof auth.user?.id === 'number') {
return String(auth.user.id);
}
if (auth.accessToken) {
return `token:${auth.accessToken.slice(0, 8)}`;
}
return '';
};
const normalizeAuthList = (list: StoredAuth[]): { list: StoredAuth[]; changed: boolean } => {
let changed = false;
let currentFound = false;
const normalized = list
.map((entry) => ({
...entry,
accountId: resolveAccountId(entry),
current: Boolean(entry.current),
}))
.filter((entry) => Boolean(entry.accessToken));
normalized.forEach((entry) => {
if (entry.current && !currentFound) {
currentFound = true;
} else if (entry.current && currentFound) {
entry.current = false;
changed = true;
}
});
if (!currentFound && normalized.length > 0) {
normalized[0].current = true;
changed = true;
}
return { list: normalized, changed };
};
export const readGitHubAuthList = async (context: vscode.ExtensionContext): Promise<StoredAuth[]> => {
try {
const raw = await fs.readFile(authFilePath(context), 'utf8');
const parsed = JSON.parse(raw);
if (!parsed || typeof parsed !== 'object') return null;
const token = typeof parsed.accessToken === 'string' ? parsed.accessToken : '';
if (!token) return null;
return parsed as StoredAuth;
if (!parsed) return [];
const list = Array.isArray(parsed) ? parsed : [parsed];
const { list: normalized, changed } = normalizeAuthList(list as StoredAuth[]);
if (changed) {
await fs.writeFile(authFilePath(context), JSON.stringify(normalized, null, 2), 'utf8');
}
return normalized;
} catch {
return null;
return [];
}
};
export const readGitHubAuth = async (context: vscode.ExtensionContext): Promise<StoredAuth | null> => {
const list = await readGitHubAuthList(context);
if (!list.length) return null;
return list.find((entry) => entry.current) ?? list[0] ?? null;
};
export const writeGitHubAuth = async (context: vscode.ExtensionContext, auth: StoredAuth): Promise<void> => {
const list = await readGitHubAuthList(context);
const next = {
...auth,
accountId: resolveAccountId(auth),
current: true,
};
const index = list.findIndex((entry) => entry.accountId === next.accountId);
if (index >= 0) {
list[index] = next;
} else {
list.push(next);
}
list.forEach((entry) => {
entry.current = entry.accountId === next.accountId;
});
await fs.mkdir(context.globalStorageUri.fsPath, { recursive: true });
await fs.writeFile(authFilePath(context), JSON.stringify(auth, null, 2), 'utf8');
await fs.writeFile(authFilePath(context), JSON.stringify(list, null, 2), 'utf8');
try {
// best-effort perms on unix
await fs.chmod(authFilePath(context), 0o600);
@@ -65,9 +136,36 @@ export const writeGitHubAuth = async (context: vscode.ExtensionContext, auth: St
}
};
export const activateGitHubAuth = async (context: vscode.ExtensionContext, accountId: string): Promise<boolean> => {
const list = await readGitHubAuthList(context);
if (!list.length) return false;
const id = accountId.trim();
if (!id) return false;
let found = false;
list.forEach((entry) => {
if (entry.accountId === id) {
entry.current = true;
found = true;
} else {
entry.current = false;
}
});
if (!found) return false;
await fs.writeFile(authFilePath(context), JSON.stringify(list, null, 2), 'utf8');
return true;
};
export const clearGitHubAuth = async (context: vscode.ExtensionContext): Promise<boolean> => {
try {
await fs.rm(authFilePath(context));
const list = await readGitHubAuthList(context);
if (!list.length) return true;
const remaining = list.filter((entry) => !entry.current);
if (!remaining.length) {
await fs.rm(authFilePath(context));
return true;
}
remaining[0].current = true;
await fs.writeFile(authFilePath(context), JSON.stringify(remaining, null, 2), 'utf8');
return true;
} catch (err: unknown) {
if (err && typeof err === 'object' && 'code' in err && (err as { code?: string }).code === 'ENOENT') return true;
+2
View File
@@ -26,6 +26,8 @@ export const createVSCodeGitHubAPI = (): GitHubAPI => ({
authComplete: async (deviceCode: string) =>
sendBridgeMessage<GitHubDeviceFlowComplete>('api:github/auth:complete', { deviceCode }),
authDisconnect: async () => sendBridgeMessage<{ removed: boolean }>('api:github/auth:disconnect'),
authActivate: async (accountId: string) =>
sendBridgeMessage<GitHubAuthStatus>('api:github/auth:activate', { accountId }),
me: async () => sendBridgeMessage<GitHubUserSummary>('api:github/me'),
prStatus: async (directory: string, branch: string) =>
+53 -5
View File
@@ -4022,15 +4022,16 @@ async function main(options = {}) {
app.get('/api/github/auth/status', async (_req, res) => {
try {
const { getGitHubAuth, getOctokitOrNull, clearGitHubAuth } = await getGitHubLibraries();
const { getGitHubAuth, getOctokitOrNull, clearGitHubAuth, getGitHubAuthAccounts } = await getGitHubLibraries();
const auth = getGitHubAuth();
const accounts = getGitHubAuthAccounts();
if (!auth?.accessToken) {
return res.json({ connected: false });
return res.json({ connected: false, accounts });
}
const octokit = getOctokitOrNull();
if (!octokit) {
return res.json({ connected: false });
return res.json({ connected: false, accounts });
}
let user = null;
@@ -4039,7 +4040,7 @@ async function main(options = {}) {
} catch (error) {
if (error?.status === 401) {
clearGitHubAuth();
return res.json({ connected: false });
return res.json({ connected: false, accounts: getGitHubAuthAccounts() });
}
}
@@ -4050,6 +4051,7 @@ async function main(options = {}) {
connected: true,
user: mergedUser,
scope: auth.scope,
accounts,
});
} catch (error) {
console.error('Failed to get GitHub auth status:', error);
@@ -4091,7 +4093,7 @@ async function main(options = {}) {
app.post('/api/github/auth/complete', async (req, res) => {
try {
const { getGitHubClientId, exchangeDeviceCode, setGitHubAuth } = await getGitHubLibraries();
const { getGitHubClientId, exchangeDeviceCode, setGitHubAuth, getGitHubAuthAccounts } = await getGitHubLibraries();
const clientId = getGitHubClientId();
if (!clientId) {
return res.status(400).json({
@@ -4137,6 +4139,7 @@ async function main(options = {}) {
connected: true,
user,
scope: typeof payload.scope === 'string' ? payload.scope : '',
accounts: getGitHubAuthAccounts(),
});
} catch (error) {
console.error('Failed to complete GitHub device flow:', error);
@@ -4144,6 +4147,51 @@ async function main(options = {}) {
}
});
app.post('/api/github/auth/activate', async (req, res) => {
try {
const { activateGitHubAuth, getGitHubAuth, getOctokitOrNull, clearGitHubAuth, getGitHubAuthAccounts } = await getGitHubLibraries();
const accountId = typeof req.body?.accountId === 'string' ? req.body.accountId : '';
if (!accountId) {
return res.status(400).json({ error: 'accountId is required' });
}
const activated = activateGitHubAuth(accountId);
if (!activated) {
return res.status(404).json({ error: 'GitHub account not found' });
}
const auth = getGitHubAuth();
const accounts = getGitHubAuthAccounts();
if (!auth?.accessToken) {
return res.json({ connected: false, accounts });
}
const octokit = getOctokitOrNull();
if (!octokit) {
return res.json({ connected: false, accounts });
}
let user = auth.user || null;
try {
user = await getGitHubUserSummary(octokit);
} catch (error) {
if (error?.status === 401) {
clearGitHubAuth();
return res.json({ connected: false, accounts: getGitHubAuthAccounts() });
}
}
return res.json({
connected: true,
user,
scope: auth.scope,
accounts,
});
} catch (error) {
console.error('Failed to activate GitHub account:', error);
return res.status(500).json({ error: error.message || 'Failed to activate GitHub account' });
}
});
app.delete('/api/github/auth', async (_req, res) => {
try {
const { clearGitHubAuth } = await getGitHubLibraries();
+180 -32
View File
@@ -61,58 +61,206 @@ function writeJsonFile(payload) {
}
}
export function getGitHubAuth() {
const data = readJsonFile();
if (!data) {
return null;
function resolveAccountId({ user, accessToken, accountId }) {
if (typeof accountId === 'string' && accountId.trim()) {
return accountId.trim();
}
const accessToken = typeof data.accessToken === 'string' ? data.accessToken : '';
if (!accessToken) {
return null;
if (user && typeof user.login === 'string' && user.login.trim()) {
return user.login.trim();
}
if (user && typeof user.id === 'number') {
return String(user.id);
}
if (typeof accessToken === 'string' && accessToken.trim()) {
return `token:${accessToken.slice(0, 8)}`;
}
return '';
}
function normalizeAuthEntry(entry) {
if (!entry || typeof entry !== 'object') return null;
const accessToken = typeof entry.accessToken === 'string' ? entry.accessToken : '';
if (!accessToken) return null;
const user = entry.user && typeof entry.user === 'object'
? {
login: typeof entry.user.login === 'string' ? entry.user.login : null,
avatarUrl: typeof entry.user.avatarUrl === 'string' ? entry.user.avatarUrl : null,
id: typeof entry.user.id === 'number' ? entry.user.id : null,
name: typeof entry.user.name === 'string' ? entry.user.name : null,
email: typeof entry.user.email === 'string' ? entry.user.email : null,
}
: null;
const accountId = resolveAccountId({
user,
accessToken,
accountId: typeof entry.accountId === 'string' ? entry.accountId : '',
});
return {
accessToken,
scope: typeof data.scope === 'string' ? data.scope : '',
tokenType: typeof data.tokenType === 'string' ? data.tokenType : 'bearer',
createdAt: typeof data.createdAt === 'number' ? data.createdAt : null,
user: data.user && typeof data.user === 'object'
? {
login: typeof data.user.login === 'string' ? data.user.login : null,
avatarUrl: typeof data.user.avatarUrl === 'string' ? data.user.avatarUrl : null,
id: typeof data.user.id === 'number' ? data.user.id : null,
name: typeof data.user.name === 'string' ? data.user.name : null,
email: typeof data.user.email === 'string' ? data.user.email : null,
}
: null,
scope: typeof entry.scope === 'string' ? entry.scope : '',
tokenType: typeof entry.tokenType === 'string' ? entry.tokenType : 'bearer',
createdAt: typeof entry.createdAt === 'number' ? entry.createdAt : null,
user,
current: Boolean(entry.current),
accountId,
};
}
export function setGitHubAuth({ accessToken, scope, tokenType, user }) {
function normalizeAuthList(raw) {
const list = (Array.isArray(raw) ? raw : [raw])
.map((entry) => normalizeAuthEntry(entry))
.filter(Boolean);
if (!list.length) {
return { list: [], changed: false };
}
let changed = false;
let currentFound = false;
list.forEach((entry) => {
if (entry.current && !currentFound) {
currentFound = true;
} else if (entry.current && currentFound) {
entry.current = false;
changed = true;
}
});
if (!currentFound && list[0]) {
list[0].current = true;
changed = true;
}
list.forEach((entry) => {
if (!entry.accountId) {
entry.accountId = resolveAccountId(entry);
changed = true;
}
});
return { list, changed };
}
function readAuthList() {
const data = readJsonFile();
if (!data) {
return [];
}
const { list, changed } = normalizeAuthList(data);
if (changed) {
writeJsonFile(list);
}
return list;
}
function writeAuthList(list) {
writeJsonFile(list);
}
export function getGitHubAuth() {
const list = readAuthList();
if (!list.length) {
return null;
}
const current = list.find((entry) => entry.current) || list[0];
if (!current?.accessToken) {
return null;
}
return current;
}
export function getGitHubAuthAccounts() {
const list = readAuthList();
return list
.filter((entry) => entry?.user && entry.accountId)
.map((entry) => ({
id: entry.accountId,
user: entry.user,
scope: entry.scope || '',
current: Boolean(entry.current),
}));
}
export function setGitHubAuth({ accessToken, scope, tokenType, user, accountId }) {
if (!accessToken || typeof accessToken !== 'string') {
throw new Error('accessToken is required');
}
writeJsonFile({
const normalizedUser = user && typeof user === 'object'
? {
login: typeof user.login === 'string' ? user.login : undefined,
avatarUrl: typeof user.avatarUrl === 'string' ? user.avatarUrl : undefined,
id: typeof user.id === 'number' ? user.id : undefined,
name: typeof user.name === 'string' ? user.name : undefined,
email: typeof user.email === 'string' ? user.email : undefined,
}
: undefined;
const resolvedAccountId = resolveAccountId({
user: normalizedUser,
accessToken,
accountId,
});
const list = readAuthList();
const existingIndex = list.findIndex((entry) => entry.accountId === resolvedAccountId);
const nextEntry = {
accessToken,
scope: typeof scope === 'string' ? scope : '',
tokenType: typeof tokenType === 'string' ? tokenType : 'bearer',
createdAt: Date.now(),
user: user && typeof user === 'object'
? {
login: typeof user.login === 'string' ? user.login : undefined,
avatarUrl: typeof user.avatarUrl === 'string' ? user.avatarUrl : undefined,
id: typeof user.id === 'number' ? user.id : undefined,
name: typeof user.name === 'string' ? user.name : undefined,
email: typeof user.email === 'string' ? user.email : undefined,
}
: undefined,
user: normalizedUser || null,
current: true,
accountId: resolvedAccountId,
};
if (existingIndex >= 0) {
list[existingIndex] = nextEntry;
} else {
list.push(nextEntry);
}
list.forEach((entry, index) => {
entry.current = index === (existingIndex >= 0 ? existingIndex : list.length - 1);
});
writeAuthList(list);
return nextEntry;
}
export function activateGitHubAuth(accountId) {
if (typeof accountId !== 'string' || !accountId.trim()) {
return false;
}
const list = readAuthList();
const index = list.findIndex((entry) => entry.accountId === accountId.trim());
if (index === -1) {
return false;
}
list.forEach((entry, idx) => {
entry.current = idx === index;
});
writeAuthList(list);
return true;
}
export function clearGitHubAuth() {
try {
if (fs.existsSync(STORAGE_FILE)) {
fs.unlinkSync(STORAGE_FILE);
const list = readAuthList();
if (!list.length) {
return true;
}
const remaining = list.filter((entry) => !entry.current);
if (!remaining.length) {
if (fs.existsSync(STORAGE_FILE)) {
fs.unlinkSync(STORAGE_FILE);
}
return true;
}
remaining.forEach((entry, index) => {
entry.current = index === 0;
});
writeAuthList(remaining);
return true;
} catch (error) {
console.error('Failed to clear GitHub auth file:', error);
+13
View File
@@ -67,6 +67,19 @@ export const createWebGitHubAPI = (): GitHubAPI => ({
return { removed: Boolean(payload?.removed) };
},
async authActivate(accountId: string): Promise<GitHubAuthStatus> {
const response = await fetch('/api/github/auth/activate', {
method: 'POST',
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
body: JSON.stringify({ accountId }),
});
const payload = await jsonOrNull<GitHubAuthStatus & { error?: string }>(response);
if (!response.ok || !payload) {
throw new Error(payload?.error || response.statusText || 'Failed to activate GitHub account');
}
return payload;
},
async me(): Promise<GitHubUserSummary> {
const response = await fetch('/api/github/me', { method: 'GET', headers: { Accept: 'application/json' } });
const payload = await jsonOrNull<GitHubUserSummary & { error?: string }>(response);