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:
committed by
GitHub
parent
1de0ebd4fc
commit
74511abfda
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
},
|
||||
}));
|
||||
Reference in New Issue
Block a user