feat(ui): gate the pull-request surface on GitHub, move the account into it, and GitHub sign-in into Integrations
The pull-request rail icon now appears only while GitHub is connected (OAuth or gh CLI), like Linear; Linear sits after the walkthrough in the default rail order. The GitHub account avatar and switcher leave the header for the pull-request panel, where the walkthrough, refresh, and account controls share one row and one height, and the account stays visible on the panel's empty state. A manual refresh keeps its spinner on screen long enough to read as work done. GitHub sign-in moves from Settings → Git to Settings → Integrations → Built-in integrations as a card before Linear; search and the connect buttons follow it.
This commit is contained in:
@@ -0,0 +1,161 @@
|
||||
import React from 'react';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import type { GitHubAuthStatus } from '@/lib/api/types';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||
|
||||
type GitHubAccount = NonNullable<GitHubAuthStatus['accounts']>[number];
|
||||
|
||||
const AVATAR_CLASS = 'flex h-6 w-6 items-center justify-center overflow-hidden rounded-full border border-border/60 bg-muted/80';
|
||||
|
||||
const activateAccount = async (
|
||||
github: ReturnType<typeof useRuntimeAPIs>['github'],
|
||||
accountId: string,
|
||||
): Promise<GitHubAuthStatus> => {
|
||||
if (github) {
|
||||
return github.authActivate(accountId);
|
||||
}
|
||||
const response = await runtimeFetch('/api/github/auth/activate', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json', Accept: 'application/json' },
|
||||
body: JSON.stringify({ accountId }),
|
||||
});
|
||||
// SAFETY: the route is ours and answers the auth status shape (plus an
|
||||
// `error` string on failure) on every response; a non-ok status throws below.
|
||||
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;
|
||||
};
|
||||
|
||||
/**
|
||||
* The connected GitHub account: an avatar, and a switcher when more than one
|
||||
* account is signed in (OAuth and `gh` CLI logins). Renders nothing while
|
||||
* GitHub is disconnected — connecting happens in Settings → Integrations.
|
||||
*/
|
||||
export const GitHubAccountControl: React.FC<{ className?: string }> = ({ className }) => {
|
||||
const { t } = useI18n();
|
||||
const { github } = useRuntimeAPIs();
|
||||
const status = useGitHubAuthStore((state) => state.status);
|
||||
const setStatus = useGitHubAuthStore((state) => state.setStatus);
|
||||
const [isSwitching, setIsSwitching] = React.useState(false);
|
||||
|
||||
const switchAccount = React.useCallback(async (accountId: string) => {
|
||||
if (!accountId || isSwitching) return;
|
||||
setIsSwitching(true);
|
||||
try {
|
||||
setStatus(await activateAccount(github, accountId));
|
||||
} catch (error) {
|
||||
console.error('Failed to switch GitHub account:', error);
|
||||
} finally {
|
||||
setIsSwitching(false);
|
||||
}
|
||||
}, [github, isSwitching, setStatus]);
|
||||
|
||||
if (!status?.connected) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const login = status.user?.login ?? null;
|
||||
const avatarUrl = status.user?.avatarUrl ?? null;
|
||||
const accounts: GitHubAccount[] = status.accounts ?? [];
|
||||
const title = login ? t('header.github.connectedWithLogin', { login }) : t('header.github.connected');
|
||||
const avatar = avatarUrl ? (
|
||||
<img
|
||||
src={avatarUrl}
|
||||
alt={login ? t('header.github.avatarWithLogin', { login }) : t('header.github.avatar')}
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<Icon name="github-fill" className="h-3.5 w-3.5 text-foreground" />
|
||||
);
|
||||
|
||||
if (accounts.length <= 1) {
|
||||
return (
|
||||
<div className={cn(AVATAR_CLASS, className)} title={title}>
|
||||
{avatar}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(AVATAR_CLASS, 'p-0 hover:ring-2 hover:ring-primary/40 disabled:opacity-50', className)}
|
||||
title={title}
|
||||
disabled={isSwitching}
|
||||
>
|
||||
{avatar}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
<DropdownMenuLabel className="typography-ui-header font-semibold text-foreground">
|
||||
{t('header.github.accountsTitle')}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{accounts.map((account) => {
|
||||
const accountUser = account.user;
|
||||
const isCurrent = Boolean(account.current);
|
||||
const sourceLabel = account.source === 'gh-cli'
|
||||
? t('header.github.accountSource.cli')
|
||||
: t('header.github.accountSource.oauth');
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={account.id}
|
||||
className="gap-2"
|
||||
disabled={isSwitching}
|
||||
onSelect={() => {
|
||||
if (!isCurrent) {
|
||||
void switchAccount(account.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{accountUser?.avatarUrl ? (
|
||||
<img
|
||||
src={accountUser.avatarUrl}
|
||||
alt={accountUser.login ? t('header.github.avatarWithLogin', { login: accountUser.login }) : t('header.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">
|
||||
<Icon name="github-fill" className="h-3 w-3 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate typography-ui-label text-foreground">
|
||||
{accountUser?.name?.trim() || accountUser?.login || 'GitHub'}
|
||||
</span>
|
||||
{accountUser?.login ? (
|
||||
<span className="truncate typography-micro text-muted-foreground">
|
||||
<span className="font-mono">{accountUser.login}</span>
|
||||
<span className="mx-1 opacity-50">·</span>
|
||||
<span>{sourceLabel}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{isCurrent ? <Icon name="check" className="h-4 w-4 text-primary" /> : null}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
};
|
||||
@@ -35,6 +35,7 @@ import {
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
|
||||
import { useGitStatus } from '@/stores/useGitStore';
|
||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||
import { useLinearAuthStore } from '@/stores/useLinearAuthStore';
|
||||
import { normalizeContextPanelDirectoryKey, useUIStore } from '@/stores/useUIStore';
|
||||
import { ContextRailSurfacesDialog } from './ContextRailSurfacesDialog';
|
||||
@@ -171,6 +172,8 @@ export const ContextPanelRail: React.FC = () => {
|
||||
const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled);
|
||||
const linearAuthChecked = useLinearAuthStore((state) => state.hasChecked);
|
||||
const linearConnected = useLinearAuthStore((state) => state.status?.connected === true);
|
||||
const githubAuthChecked = useGitHubAuthStore((state) => state.hasChecked);
|
||||
const githubConnected = useGitHubAuthStore((state) => state.status?.connected === true);
|
||||
const { screenWidth } = useDeviceInfo();
|
||||
const gitStatus = useGitStatus(directoryKey || null);
|
||||
|
||||
@@ -268,9 +271,12 @@ export const ContextPanelRail: React.FC = () => {
|
||||
screenWidth,
|
||||
tabs,
|
||||
linearConnected,
|
||||
githubConnected,
|
||||
});
|
||||
}, [contextRailHiddenSurfaces, contextRailOrder, linearConnected, planModeEnabled, screenWidth, tabs]);
|
||||
}, [contextRailHiddenSurfaces, contextRailOrder, githubConnected, linearConnected, planModeEnabled, screenWidth, tabs]);
|
||||
|
||||
// A surface whose integration disconnected closes rather than lingering as
|
||||
// an active panel with no rail icon.
|
||||
React.useEffect(() => {
|
||||
if (!directoryKey || !linearAuthChecked || linearConnected || activeMode !== 'linear') {
|
||||
return;
|
||||
@@ -278,6 +284,13 @@ export const ContextPanelRail: React.FC = () => {
|
||||
closeContextPanel(directoryKey);
|
||||
}, [activeMode, closeContextPanel, directoryKey, linearAuthChecked, linearConnected]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!directoryKey || !githubAuthChecked || githubConnected || activeMode !== 'pr') {
|
||||
return;
|
||||
}
|
||||
closeContextPanel(directoryKey);
|
||||
}, [activeMode, closeContextPanel, directoryKey, githubAuthChecked, githubConnected]);
|
||||
|
||||
const [isSurfacesDialogOpen, setIsSurfacesDialogOpen] = React.useState(false);
|
||||
|
||||
const handleDragEnd = React.useCallback((event: DragEndEvent) => {
|
||||
|
||||
@@ -9,7 +9,6 @@ import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuLabel,
|
||||
DropdownMenuSeparator,
|
||||
DropdownMenuTrigger,
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
@@ -30,7 +29,6 @@ import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||
import { streamPerfCount } from '@/stores/utils/streamDebug';
|
||||
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
|
||||
|
||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useDesktopWindowControlsLayout } from '@/hooks/useDesktopWindowControlsLayout';
|
||||
import { ContextUsageDisplay } from '@/components/ui/ContextUsageDisplay';
|
||||
@@ -45,7 +43,6 @@ import {
|
||||
|
||||
import {
|
||||
} from '@/components/ui/collapsible';
|
||||
import type { GitHubAuthStatus } from '@/lib/api/types';
|
||||
import type { SessionContextUsage } from '@/stores/types/sessionTypes';
|
||||
import { DesktopHostSwitcherDialog } from '@/components/desktop/DesktopHostSwitcher';
|
||||
import { OpenInAppButton } from '@/components/desktop/OpenInAppButton';
|
||||
@@ -123,132 +120,6 @@ const HeaderIconActionButton = React.memo(function HeaderIconActionButton({
|
||||
);
|
||||
});
|
||||
|
||||
type DesktopGitHubControlProps = {
|
||||
isMobile: boolean;
|
||||
githubAuthStatus: GitHubAuthStatus | null;
|
||||
githubAccounts: Array<NonNullable<GitHubAuthStatus['accounts']>[number]>;
|
||||
githubAvatarUrl: string | null;
|
||||
githubLogin: string | null;
|
||||
isSwitchingGitHubAccount: boolean;
|
||||
handleGitHubAccountSwitch: (accountId: string) => Promise<void>;
|
||||
};
|
||||
|
||||
const DesktopGitHubControl = React.memo(function DesktopGitHubControl({
|
||||
isMobile,
|
||||
githubAuthStatus,
|
||||
githubAccounts,
|
||||
githubAvatarUrl,
|
||||
githubLogin,
|
||||
isSwitchingGitHubAccount,
|
||||
handleGitHubAccountSwitch,
|
||||
}: DesktopGitHubControlProps) {
|
||||
const { t } = useI18n();
|
||||
if (!githubAuthStatus?.connected || isMobile) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (githubAccounts.length > 1) {
|
||||
return (
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
DESKTOP_HEADER_ICON_BUTTON_CLASS,
|
||||
'h-7 w-7 overflow-hidden rounded-full border border-border/60 bg-muted/80 p-0'
|
||||
)}
|
||||
title={githubLogin ? t('header.github.connectedWithLogin', { login: githubLogin }) : t('header.github.connected')}
|
||||
disabled={isSwitchingGitHubAccount}
|
||||
>
|
||||
{githubAvatarUrl ? (
|
||||
<img
|
||||
src={githubAvatarUrl}
|
||||
alt={githubLogin ? t('header.github.avatarWithLogin', { login: githubLogin }) : t('header.github.avatar')}
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<Icon name="github-fill" className="h-3.5 w-3.5 text-foreground" />
|
||||
)}
|
||||
</button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end" className="w-64">
|
||||
<DropdownMenuLabel className="typography-ui-header font-semibold text-foreground">
|
||||
{t('header.github.accountsTitle')}
|
||||
</DropdownMenuLabel>
|
||||
<DropdownMenuSeparator />
|
||||
{githubAccounts.map((account) => {
|
||||
const accountUser = account.user;
|
||||
const isCurrent = Boolean(account.current);
|
||||
const sourceLabel = account.source === 'gh-cli'
|
||||
? t('header.github.accountSource.cli')
|
||||
: t('header.github.accountSource.oauth');
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
key={account.id}
|
||||
className="gap-2"
|
||||
disabled={isSwitchingGitHubAccount}
|
||||
onSelect={() => {
|
||||
if (!isCurrent) {
|
||||
void handleGitHubAccountSwitch(account.id);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{accountUser?.avatarUrl ? (
|
||||
<img
|
||||
src={accountUser.avatarUrl}
|
||||
alt={accountUser.login ? t('header.github.avatarWithLogin', { login: accountUser.login }) : t('header.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">
|
||||
<Icon name="github-fill" className="h-3 w-3 text-muted-foreground" />
|
||||
</div>
|
||||
)}
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className="truncate typography-ui-label text-foreground">
|
||||
{accountUser?.name?.trim() || accountUser?.login || 'GitHub'}
|
||||
</span>
|
||||
{accountUser?.login ? (
|
||||
<span className="truncate typography-micro text-muted-foreground">
|
||||
<span className="font-mono">{accountUser.login}</span>
|
||||
<span className="mx-1 opacity-50">·</span>
|
||||
<span>{sourceLabel}</span>
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{isCurrent ? <Icon name="check" className="h-4 w-4 text-primary" /> : null}
|
||||
</DropdownMenuItem>
|
||||
);
|
||||
})}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="app-region-no-drag flex h-7 w-7 items-center justify-center overflow-hidden rounded-full border border-border/60 bg-muted/80"
|
||||
title={githubLogin ? t('header.github.connectedWithLogin', { login: githubLogin }) : t('header.github.connected')}
|
||||
>
|
||||
{githubAvatarUrl ? (
|
||||
<img
|
||||
src={githubAvatarUrl}
|
||||
alt={githubLogin ? t('header.github.avatarWithLogin', { login: githubLogin }) : t('header.github.avatar')}
|
||||
className="h-full w-full object-cover"
|
||||
loading="lazy"
|
||||
referrerPolicy="no-referrer"
|
||||
/>
|
||||
) : (
|
||||
<Icon name="github-fill" className="h-3.5 w-3.5 text-foreground" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
type DesktopServicesMenuProps = {
|
||||
isDesktopApp: boolean;
|
||||
currentInstanceLabel: string;
|
||||
@@ -439,7 +310,6 @@ export const Header: React.FC = () => {
|
||||
const sessionTabsEnabled = useUIStore((state) => state.sessionTabsEnabled);
|
||||
|
||||
const getCurrentModel = useConfigStore((state) => state.getCurrentModel);
|
||||
const runtimeApis = useRuntimeAPIs();
|
||||
|
||||
const getContextUsage = useSessionUIStore((state) => state.getContextUsage);
|
||||
const isNewSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
|
||||
@@ -488,8 +358,6 @@ export const Header: React.FC = () => {
|
||||
const loadQuotaSettings = useQuotaStore((state) => state.loadSettings);
|
||||
|
||||
const { isMobile } = useDeviceInfo();
|
||||
const githubAuthStatus = useGitHubAuthStore((state) => state.status);
|
||||
const setGitHubAuthStatus = useGitHubAuthStore((state) => state.setStatus);
|
||||
|
||||
const headerRef = React.useRef<HTMLElement | null>(null);
|
||||
|
||||
@@ -571,10 +439,6 @@ export const Header: React.FC = () => {
|
||||
}
|
||||
}, [contextUsage, currentSessionId, isContextUsageResolvedForSession]);
|
||||
|
||||
const githubAvatarUrl = githubAuthStatus?.connected ? (githubAuthStatus.user?.avatarUrl ?? null) : null;
|
||||
const githubLogin = githubAuthStatus?.connected ? (githubAuthStatus.user?.login ?? null) : null;
|
||||
const githubAccounts = githubAuthStatus?.accounts ?? [];
|
||||
const [isSwitchingGitHubAccount, setIsSwitchingGitHubAccount] = React.useState(false);
|
||||
const [isDesktopServicesOpen, setIsDesktopServicesOpen] = React.useState(false);
|
||||
const [currentInstanceLabel, setCurrentInstanceLabel] = React.useState('Local');
|
||||
const [currentInstanceIsLocal, setCurrentInstanceIsLocal] = React.useState(true);
|
||||
@@ -1183,37 +1047,6 @@ export const Header: React.FC = () => {
|
||||
sessionDirectory,
|
||||
]);
|
||||
|
||||
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 runtimeFetch('/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]);
|
||||
|
||||
|
||||
|
||||
@@ -1482,15 +1315,6 @@ export const Header: React.FC = () => {
|
||||
onOpenRemoteUpdate={openRemoteInstanceUpdate}
|
||||
/>
|
||||
) : null}
|
||||
<DesktopGitHubControl
|
||||
isMobile={isMobile}
|
||||
githubAuthStatus={githubAuthStatus}
|
||||
githubAccounts={githubAccounts}
|
||||
githubAvatarUrl={githubAvatarUrl}
|
||||
githubLogin={githubLogin}
|
||||
isSwitchingGitHubAccount={isSwitchingGitHubAccount}
|
||||
handleGitHubAccountSwitch={handleGitHubAccountSwitch}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
|
||||
|
||||
@@ -19,7 +19,6 @@ import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger }
|
||||
import { useGitIdentitiesStore, type GitIdentityProfile, type DiscoveredGitCredential } from '@/stores/useGitIdentitiesStore';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
import { GitSettings } from '@/components/sections/openchamber/GitSettings';
|
||||
import { GitHubSettings } from '@/components/sections/openchamber/GitHubSettings';
|
||||
import { GitIdentityEditorDialog } from './GitIdentityEditorDialog';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import type { IconName } from "@/components/icon/icons";
|
||||
@@ -121,10 +120,9 @@ export const GitPage: React.FC = () => {
|
||||
title={t('settings.page.git.title')}
|
||||
showSaveStatus
|
||||
>
|
||||
<GitHubSettings />
|
||||
|
||||
<SettingsSection
|
||||
title={t('settings.gitIdentities.page.section.title')}
|
||||
divider={false}
|
||||
headerAction={(
|
||||
<Button size="sm" variant="outline" onClick={() => openEditor('new')}>
|
||||
<Icon name="add" className="w-3.5 h-3.5 mr-1" /> {t('settings.common.badge.new')}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
import React from 'react';
|
||||
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { GitHubSettings } from '@/components/sections/openchamber/GitHubSettings';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||
|
||||
/**
|
||||
* The GitHub row of Settings → Integrations → Built-in integrations: a
|
||||
* collapsible card whose body is the account/device-flow UI. Sign-in status
|
||||
* shows on the collapsed row so the page answers "am I connected?" at a
|
||||
* glance, like the Linear card beside it.
|
||||
*/
|
||||
export const GitHubIntegration: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const status = useGitHubAuthStore((state) => state.status);
|
||||
const isLoading = useGitHubAuthStore((state) => state.isLoading);
|
||||
const hasChecked = useGitHubAuthStore((state) => state.hasChecked);
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
const connected = status?.connected === true;
|
||||
const statusLabel = isLoading && !hasChecked
|
||||
? t('common.loading')
|
||||
: connected
|
||||
? (status?.user?.login?.trim() || t('settings.github.page.status.active'))
|
||||
: t('settings.integrations.github.status.notConnected');
|
||||
const statusClassName = connected
|
||||
? 'bg-[var(--status-success)]/15 text-[var(--status-success)]'
|
||||
: 'bg-[var(--surface-muted)] text-muted-foreground';
|
||||
|
||||
return (
|
||||
<Collapsible open={open} onOpenChange={setOpen}>
|
||||
<div
|
||||
data-settings-item="integrations.github"
|
||||
className="overflow-hidden rounded-xl border border-[var(--interactive-border)] bg-[var(--surface-elevated)]"
|
||||
>
|
||||
<CollapsibleTrigger
|
||||
className="flex w-full min-w-0 items-center gap-3 px-4 py-3 text-left hover:bg-[var(--interactive-hover)]/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--interactive-focus-ring)]"
|
||||
>
|
||||
<div className="flex size-10 shrink-0 items-center justify-center rounded-[10px] bg-[var(--surface-muted)]">
|
||||
<Icon name="github-fill" className="size-5 text-foreground" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate text-sm font-semibold text-foreground">
|
||||
{t('settings.integrations.github.title')}
|
||||
</div>
|
||||
<p className="mt-0.5 line-clamp-1 text-xs leading-snug text-muted-foreground">
|
||||
{t('settings.integrations.github.description')}
|
||||
</p>
|
||||
</div>
|
||||
<span
|
||||
aria-live="polite"
|
||||
className={cn('max-w-36 shrink-0 truncate rounded-full px-2 py-0.5 text-[10px] font-medium', statusClassName)}
|
||||
>
|
||||
{statusLabel}
|
||||
</span>
|
||||
<Icon
|
||||
name="arrow-down-s"
|
||||
className={cn(
|
||||
'size-4 shrink-0 text-muted-foreground transition-transform duration-150 ease-out motion-reduce:transition-none',
|
||||
open && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</CollapsibleTrigger>
|
||||
<CollapsibleContent className="border-t border-[var(--interactive-border)] px-4 py-4">
|
||||
<GitHubSettings embedded />
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
);
|
||||
};
|
||||
@@ -1,7 +1,10 @@
|
||||
import React from 'react';
|
||||
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
|
||||
import { SettingsSection } from '@/components/sections/shared/SettingsSection';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
|
||||
import { GitHubIntegration } from './GitHubIntegration';
|
||||
import { LinearSettings } from './LinearSettings';
|
||||
import { ThirdPartyIntegrationsSection } from './ThirdPartyIntegrationsSection';
|
||||
|
||||
@@ -15,7 +18,11 @@ export const IntegrationsPage: React.FC<IntegrationsPageProps> = ({
|
||||
onOpenPluginManager,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
// GitHub sign-in is an OpenChamber server feature; the VS Code extension
|
||||
// uses the editor's own GitHub session instead.
|
||||
const hasGitHub = !isVSCodeRuntime();
|
||||
const hasLinear = Boolean(getRegisteredRuntimeAPIs()?.linear);
|
||||
const hasBuiltIn = hasGitHub || hasLinear;
|
||||
|
||||
return (
|
||||
<SettingsPageLayout
|
||||
@@ -23,9 +30,20 @@ export const IntegrationsPage: React.FC<IntegrationsPageProps> = ({
|
||||
description={t('settings.page.integrations.description')}
|
||||
showSaveStatus
|
||||
>
|
||||
{hasLinear ? <LinearSettings /> : null}
|
||||
{hasBuiltIn ? (
|
||||
<SettingsSection
|
||||
title={t('settings.integrations.firstParty.title')}
|
||||
info={t('settings.integrations.firstParty.info')}
|
||||
divider={false}
|
||||
settingsItem="integrations.first-party"
|
||||
contentClassName="space-y-3"
|
||||
>
|
||||
{hasGitHub ? <GitHubIntegration /> : null}
|
||||
{hasLinear ? <LinearSettings /> : null}
|
||||
</SettingsSection>
|
||||
) : null}
|
||||
<ThirdPartyIntegrationsSection
|
||||
divider={hasLinear}
|
||||
divider={hasBuiltIn}
|
||||
onOpenProviderSetup={onOpenProviderSetup}
|
||||
onOpenPluginManager={onOpenPluginManager}
|
||||
/>
|
||||
|
||||
@@ -9,7 +9,6 @@ import { openExternalUrl } from '@/lib/url';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { focusDesktopWindow, isDesktopShell } from '@/lib/desktop';
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { SettingsSection } from '@/components/sections/shared/SettingsSection';
|
||||
import { LinearProjectMapping } from './LinearProjectMapping';
|
||||
import { LinearSessionComments } from './LinearSessionComments';
|
||||
|
||||
@@ -172,13 +171,6 @@ export const LinearSettings: React.FC = () => {
|
||||
const expanded = isWaiting || open;
|
||||
|
||||
return (
|
||||
<SettingsSection
|
||||
title={t('settings.integrations.firstParty.title')}
|
||||
info={t('settings.integrations.firstParty.info')}
|
||||
divider={false}
|
||||
settingsItem="integrations.first-party"
|
||||
contentClassName="space-y-3"
|
||||
>
|
||||
<Collapsible
|
||||
open={expanded}
|
||||
onOpenChange={(nextOpen) => {
|
||||
@@ -345,6 +337,5 @@ export const LinearSettings: React.FC = () => {
|
||||
</CollapsibleContent>
|
||||
</div>
|
||||
</Collapsible>
|
||||
</SettingsSection>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -34,7 +34,12 @@ type DeviceFlowCompleteResponse =
|
||||
| { connected: true; user: GitHubUser; scope?: string }
|
||||
| { connected: false; status?: string; error?: string };
|
||||
|
||||
export const GitHubSettings: React.FC = () => {
|
||||
type GitHubSettingsProps = {
|
||||
/** Rendered inside the Integrations card: no section chrome of its own. */
|
||||
embedded?: boolean;
|
||||
};
|
||||
|
||||
export const GitHubSettings: React.FC<GitHubSettingsProps> = ({ embedded = false }) => {
|
||||
const { t } = useI18n();
|
||||
const { isMobile } = useDeviceInfo();
|
||||
const runtimeGitHub = getRegisteredRuntimeAPIs()?.github;
|
||||
@@ -269,14 +274,8 @@ export const GitHubSettings: React.FC = () => {
|
||||
? t('settings.github.page.accountSource.cli')
|
||||
: t('settings.github.page.accountSource.oauth');
|
||||
|
||||
return (
|
||||
const accountSection = (
|
||||
<>
|
||||
<SettingsSection
|
||||
title={t('settings.github.page.oauth.title')}
|
||||
divider={false}
|
||||
settingsItem="git.github-account"
|
||||
info={t('settings.github.page.tooltip.connectAccount')}
|
||||
>
|
||||
<div className="rounded-lg bg-[var(--surface-elevated)]/70 overflow-hidden flex flex-col">
|
||||
{connected ? (
|
||||
<div className={cn("px-4 py-3", isMobile ? "flex flex-col gap-3" : "flex items-center justify-between gap-4")}>
|
||||
@@ -445,10 +444,12 @@ export const GitHubSettings: React.FC = () => {
|
||||
</div>
|
||||
)}
|
||||
|
||||
</SettingsSection>
|
||||
</>
|
||||
);
|
||||
|
||||
{ghCli?.available && !ghCli?.active && (!ghCli.user || ghCli.disabled) && (
|
||||
<SettingsSection title={t('settings.github.page.ghCli.title')}>
|
||||
const ghCliSection = ghCli?.available && !ghCli?.active && (!ghCli.user || ghCli.disabled)
|
||||
? (
|
||||
<>
|
||||
<div className="rounded-lg bg-[var(--surface-elevated)]/70 overflow-hidden">
|
||||
<div className={cn("px-4 py-3", isMobile ? "flex flex-col gap-3" : "flex items-center justify-between gap-4")}>
|
||||
<div className={cn("flex min-w-0 items-center gap-4", isMobile ? "w-full" : undefined)}>
|
||||
@@ -499,8 +500,40 @@ export const GitHubSettings: React.FC = () => {
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
: null;
|
||||
|
||||
if (embedded) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{accountSection}
|
||||
{ghCliSection ? (
|
||||
<div className="space-y-2">
|
||||
<SettingsGroupTitle>{t('settings.github.page.ghCli.title')}</SettingsGroupTitle>
|
||||
{ghCliSection}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<SettingsSection
|
||||
title={t('settings.github.page.oauth.title')}
|
||||
divider={false}
|
||||
settingsItem="git.github-account"
|
||||
info={t('settings.github.page.tooltip.connectAccount')}
|
||||
>
|
||||
{accountSection}
|
||||
</SettingsSection>
|
||||
|
||||
{ghCliSection ? (
|
||||
<SettingsSection title={t('settings.github.page.ghCli.title')}>
|
||||
{ghCliSection}
|
||||
</SettingsSection>
|
||||
)}
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -7,7 +7,6 @@ import { AppLinkSecuritySettings } from './AppLinkSecuritySettings';
|
||||
import { DefaultsSettings } from './DefaultsSettings';
|
||||
import { GitSettings } from './GitSettings';
|
||||
import { NotificationSettings } from './NotificationSettings';
|
||||
import { GitHubSettings } from './GitHubSettings';
|
||||
import { VoiceSettings } from './VoiceSettings';
|
||||
import { TunnelSettings } from './TunnelSettings';
|
||||
import { OpenCodeCliSettings } from './OpenCodeCliSettings';
|
||||
@@ -78,8 +77,6 @@ export const OpenChamberPage: React.FC<OpenChamberPageProps> = ({ section }) =>
|
||||
return <ShortcutsSectionContent />;
|
||||
case 'git':
|
||||
return <GitSectionContent />;
|
||||
case 'github':
|
||||
return <GitHubSectionContent />;
|
||||
case 'notifications':
|
||||
return <NotificationSectionContent />;
|
||||
case 'voice':
|
||||
@@ -233,14 +230,6 @@ const GitSectionContent: React.FC = () => {
|
||||
return <GitSettings />;
|
||||
};
|
||||
|
||||
// GitHub section: Connect account for PR/issue workflows
|
||||
const GitHubSectionContent: React.FC = () => {
|
||||
if (isVSCodeRuntime()) {
|
||||
return null;
|
||||
}
|
||||
return <GitHubSettings />;
|
||||
};
|
||||
|
||||
// Notifications section: Native browser notifications
|
||||
const NotificationSectionContent: React.FC = () => {
|
||||
return <NotificationSettings />;
|
||||
|
||||
@@ -284,7 +284,7 @@ export function GitHubIntegrationDialog({
|
||||
const isGitHubConnected = githubAuthChecked && githubAuthStatus?.connected === true;
|
||||
|
||||
const openGitHubSettings = () => {
|
||||
setSettingsPage('github');
|
||||
setSettingsPage('integrations');
|
||||
setSettingsDialogOpen(true);
|
||||
};
|
||||
|
||||
|
||||
@@ -230,7 +230,7 @@ export function GitHubIssuePickerDialog({
|
||||
const repoUrl = result?.repo?.url ?? null;
|
||||
|
||||
const openGitHubSettings = React.useCallback(() => {
|
||||
setSettingsPage('github');
|
||||
setSettingsPage('integrations');
|
||||
setSettingsDialogOpen(true);
|
||||
}, [setSettingsDialogOpen, setSettingsPage]);
|
||||
|
||||
|
||||
@@ -217,7 +217,7 @@ export function GitHubPrPickerDialog({
|
||||
const connected = githubAuthChecked ? result?.connected !== false : true;
|
||||
|
||||
const openGitHubSettings = React.useCallback(() => {
|
||||
setSettingsPage('github');
|
||||
setSettingsPage('integrations');
|
||||
setSettingsDialogOpen(true);
|
||||
}, [setSettingsDialogOpen, setSettingsPage]);
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ import { useDeviceInfo } from '@/lib/device';
|
||||
import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel';
|
||||
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
|
||||
import { Icon } from "@/components/icon/Icon";
|
||||
import { GitHubAccountControl } from '@/components/github/GitHubAccountControl';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useWalkthroughStore } from '@/stores/useWalkthroughStore';
|
||||
import { WALKTHROUGH_ACTION_CLASS } from '@/components/views/walkthrough/walkthroughAction';
|
||||
@@ -102,6 +103,10 @@ const getPrVisualState = (status: GitHubPullRequestStatus | null): 'draft' | 'op
|
||||
};
|
||||
|
||||
const PR_ACTION_REFRESH_DELAYS_MS = [2_000, 5_000] as const;
|
||||
// A manual refresh keeps its spinner visible at least this long: the request
|
||||
// often answers from the server cache within a few milliseconds, and a
|
||||
// spinner that never reaches the screen reads as "the button did nothing".
|
||||
const PR_MANUAL_REFRESH_MIN_SPIN_MS = 600;
|
||||
|
||||
const branchToTitle = (branch: string): string => {
|
||||
return branch
|
||||
@@ -337,7 +342,7 @@ export const PullRequestSection: React.FC<{
|
||||
const showWalkthroughAction = !isMobile && screenWidth >= 768 && !isVSCodeRuntime();
|
||||
|
||||
const openGitHubSettings = React.useCallback(() => {
|
||||
setSettingsPage('github');
|
||||
setSettingsPage('integrations');
|
||||
setSettingsDialogOpen(true);
|
||||
}, [setSettingsDialogOpen, setSettingsPage]);
|
||||
|
||||
@@ -1040,6 +1045,31 @@ export const PullRequestSection: React.FC<{
|
||||
await refreshPrStatus(prStatusKey, options);
|
||||
}, [prStatusKey, refreshPrStatus]);
|
||||
|
||||
const [isManualRefreshing, setIsManualRefreshing] = React.useState(false);
|
||||
const manualRefreshMountedRef = React.useRef(true);
|
||||
React.useEffect(() => {
|
||||
manualRefreshMountedRef.current = true;
|
||||
return () => {
|
||||
manualRefreshMountedRef.current = false;
|
||||
};
|
||||
}, []);
|
||||
const refreshManually = React.useCallback(async () => {
|
||||
if (isManualRefreshing) return;
|
||||
setIsManualRefreshing(true);
|
||||
const startedAt = Date.now();
|
||||
try {
|
||||
await refresh({ force: true });
|
||||
} finally {
|
||||
const remaining = PR_MANUAL_REFRESH_MIN_SPIN_MS - (Date.now() - startedAt);
|
||||
if (remaining > 0) {
|
||||
await new Promise((resolve) => window.setTimeout(resolve, remaining));
|
||||
}
|
||||
if (manualRefreshMountedRef.current) {
|
||||
setIsManualRefreshing(false);
|
||||
}
|
||||
}
|
||||
}, [isManualRefreshing, refresh]);
|
||||
|
||||
const scheduleActionRefresh = React.useCallback(() => {
|
||||
pendingActionRefreshTimersRef.current.forEach((timerId) => {
|
||||
window.clearTimeout(timerId);
|
||||
@@ -1406,7 +1436,10 @@ export const PullRequestSection: React.FC<{
|
||||
return (
|
||||
<section className="border-0 bg-transparent rounded-none">
|
||||
<div className="space-y-1 pt-3">
|
||||
<div className="typography-ui-header font-semibold text-foreground">{t('gitView.pullRequest.title')}</div>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="typography-ui-header font-semibold text-foreground">{t('gitView.pullRequest.title')}</div>
|
||||
<GitHubAccountControl />
|
||||
</div>
|
||||
<div className="typography-micro text-muted-foreground">
|
||||
{t('gitView.pullRequest.availableOnFeatureBranches')}
|
||||
</div>
|
||||
@@ -1450,7 +1483,7 @@ export const PullRequestSection: React.FC<{
|
||||
return (
|
||||
<section className={containerClassName}>
|
||||
<div className={headerClassName}>
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="@container/pr-actions flex items-start justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
{pr ? (
|
||||
<Button
|
||||
@@ -1472,27 +1505,47 @@ export const PullRequestSection: React.FC<{
|
||||
<span className="typography-meta text-muted-foreground truncate">#{pr.number}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
{isLoading ? <Icon name="loader-4" className="size-4 animate-spin text-muted-foreground" /> : null}
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{pr && showWalkthroughAction ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn('pr-actions__walkthrough-button h-7 shrink-0 gap-1.5 px-2', WALKTHROUGH_ACTION_CLASS)}
|
||||
onClick={() => {
|
||||
requestWalkthroughSource(directory, { kind: 'pr', number: pr.number });
|
||||
openContextSurface(directory, 'walkthrough');
|
||||
}}
|
||||
aria-label={t('walkthrough.action.open')}
|
||||
>
|
||||
<Icon name="route" className="size-4" />
|
||||
<span className="pr-actions__walkthrough-label typography-ui-label">
|
||||
{t('walkthrough.action.open')}
|
||||
</span>
|
||||
</Button>
|
||||
) : null}
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
className="inline-flex size-5 items-center justify-center rounded hover:bg-interactive-hover/60 disabled:opacity-40"
|
||||
disabled={isLoading}
|
||||
onClick={() => void refresh({ force: true })}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 w-7 px-0"
|
||||
disabled={isLoading || isManualRefreshing}
|
||||
onClick={() => void refreshManually()}
|
||||
aria-label={t('gitView.pr.actions.refreshAria')}
|
||||
>
|
||||
<Icon name="refresh" className="size-3.5 text-muted-foreground" />
|
||||
</button>
|
||||
{isLoading || isManualRefreshing
|
||||
? <Icon name="loader-4" className="size-4 animate-spin text-muted-foreground" />
|
||||
: <Icon name="refresh" className="size-4 text-muted-foreground" />}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent><p>{t('gitView.pr.actions.refresh')}</p></TooltipContent>
|
||||
</Tooltip>
|
||||
<GitHubAccountControl className="h-7 w-7" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{pr ? (
|
||||
<div className="@container/pr-actions flex min-w-0 items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 items-center justify-between gap-2">
|
||||
<div className="flex min-w-0 flex-wrap items-center gap-x-2 gap-y-1 typography-micro text-muted-foreground">
|
||||
<span style={{ color: prColorVar }}>{prStatusText}</span>
|
||||
{checks ? (
|
||||
@@ -1508,23 +1561,6 @@ export const PullRequestSection: React.FC<{
|
||||
) : null}
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1.5">
|
||||
{showWalkthroughAction ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className={cn('pr-actions__walkthrough-button h-7 shrink-0 gap-1.5 px-2', WALKTHROUGH_ACTION_CLASS)}
|
||||
onClick={() => {
|
||||
requestWalkthroughSource(directory, { kind: 'pr', number: pr.number });
|
||||
openContextSurface(directory, 'walkthrough');
|
||||
}}
|
||||
aria-label={t('walkthrough.action.open')}
|
||||
>
|
||||
<Icon name="route" className="size-4" />
|
||||
<span className="pr-actions__walkthrough-label typography-ui-label">
|
||||
{t('walkthrough.action.open')}
|
||||
</span>
|
||||
</Button>
|
||||
) : null}
|
||||
{canMerge && pr.draft && pr.state === 'open' ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
||||
@@ -30,6 +30,7 @@ import { readEmbeddedThemeSearchParams } from '@/contexts/theme-embedded-bootstr
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useGitHubAuthStore } from '@/stores/useGitHubAuthStore';
|
||||
import { useLinearAuthStore } from '@/stores/useLinearAuthStore';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { getCycledPrimaryAgentName } from '@/components/chat/mobileControlsUtils';
|
||||
@@ -507,6 +508,7 @@ export const useKeyboardShortcuts = () => {
|
||||
screenWidth: window.innerWidth,
|
||||
tabs: panel?.tabs ?? [],
|
||||
linearConnected: useLinearAuthStore.getState().status?.connected === true,
|
||||
githubConnected: useGitHubAuthStore.getState().status?.connected === true,
|
||||
});
|
||||
const target = visibleSurfaces[switchSurfaceDigit - 1];
|
||||
if (target) {
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
/** Linear first-party integration settings strings — merged into each locale's settings dictionary. */
|
||||
/** Built-in integration (GitHub, Linear) settings strings — merged into each locale's settings dictionary. */
|
||||
export const linearIntegrationI18n = {
|
||||
en: {
|
||||
'settings.integrations.github.title': 'GitHub',
|
||||
'settings.integrations.github.description': 'Connect a GitHub account for pull requests and issues.',
|
||||
'settings.integrations.github.status.notConnected': 'Not connected',
|
||||
'settings.integrations.firstParty.title': 'Built-in integrations',
|
||||
'settings.integrations.firstParty.info': 'Sign-ins for services that ship with OpenChamber. The login stays on this computer so web, desktop, and a paired phone share it.',
|
||||
'settings.integrations.linear.title': 'Linear',
|
||||
@@ -48,6 +51,9 @@ export const linearIntegrationI18n = {
|
||||
'settings.magicPrompts.page.group.linearIssueReview.description': 'Prompts used when starting a session from a Linear issue: visible user message + hidden instructions.',
|
||||
},
|
||||
de: {
|
||||
'settings.integrations.github.title': 'GitHub',
|
||||
'settings.integrations.github.description': 'GitHub-Konto für Pull Requests und Issues verbinden.',
|
||||
'settings.integrations.github.status.notConnected': 'Nicht verbunden',
|
||||
'settings.integrations.firstParty.title': 'Eingebaute Integrationen',
|
||||
'settings.integrations.firstParty.info': 'Anmeldungen für Dienste, die mit OpenChamber mitgeliefert werden. Die Anmeldung bleibt auf diesem Computer, damit Web, Desktop und ein gekoppeltes Telefon sie teilen.',
|
||||
'settings.integrations.linear.title': 'Linear',
|
||||
@@ -95,6 +101,9 @@ export const linearIntegrationI18n = {
|
||||
'settings.magicPrompts.page.group.linearIssueReview.description': 'Eingabeaufforderungen beim Start einer Sitzung aus einem Linear-Issue: sichtbare Benutzernachricht + versteckte Anweisungen.',
|
||||
},
|
||||
fr: {
|
||||
'settings.integrations.github.title': 'GitHub',
|
||||
'settings.integrations.github.description': 'Connecter un compte GitHub pour les pull requests et les issues.',
|
||||
'settings.integrations.github.status.notConnected': 'Non connecté',
|
||||
'settings.integrations.firstParty.title': 'Intégrations natives',
|
||||
'settings.integrations.firstParty.info': 'Connexions aux services fournis avec OpenChamber. La connexion reste sur cet ordinateur pour que le web, le bureau et un téléphone apparié la partagent.',
|
||||
'settings.integrations.linear.title': 'Linear',
|
||||
@@ -142,6 +151,9 @@ export const linearIntegrationI18n = {
|
||||
'settings.magicPrompts.page.group.linearIssueReview.description': 'Prompts utilisés au démarrage d’une session depuis un ticket Linear : message utilisateur visible + instructions masquées.',
|
||||
},
|
||||
es: {
|
||||
'settings.integrations.github.title': 'GitHub',
|
||||
'settings.integrations.github.description': 'Conecta una cuenta de GitHub para pull requests e issues.',
|
||||
'settings.integrations.github.status.notConnected': 'No conectado',
|
||||
'settings.integrations.firstParty.title': 'Integraciones nativas',
|
||||
'settings.integrations.firstParty.info': 'Inicios de sesión de los servicios incluidos en OpenChamber. El inicio de sesión se guarda en este ordenador para que la web, el escritorio y un teléfono emparejado lo compartan.',
|
||||
'settings.integrations.linear.title': 'Linear',
|
||||
@@ -189,6 +201,9 @@ export const linearIntegrationI18n = {
|
||||
'settings.magicPrompts.page.group.linearIssueReview.description': 'Prompts usados al iniciar una sesión desde un issue de Linear: mensaje visible del usuario e instrucciones ocultas.',
|
||||
},
|
||||
ja: {
|
||||
'settings.integrations.github.title': 'GitHub',
|
||||
'settings.integrations.github.description': 'プルリクエストと Issue のために GitHub アカウントを接続します。',
|
||||
'settings.integrations.github.status.notConnected': '未接続',
|
||||
'settings.integrations.firstParty.title': '標準連携',
|
||||
'settings.integrations.firstParty.info': 'OpenChamber に同梱されているサービスのログインです。このコンピュータに保存され、Web、デスクトップ、ペアリングしたスマホで共有されます。',
|
||||
'settings.integrations.linear.title': 'Linear',
|
||||
@@ -236,6 +251,9 @@ export const linearIntegrationI18n = {
|
||||
'settings.magicPrompts.page.group.linearIssueReview.description': 'Linear の Issue からセッションを開始するときに使うプロンプト: 表示ユーザーメッセージ + 非表示の指示。',
|
||||
},
|
||||
ko: {
|
||||
'settings.integrations.github.title': 'GitHub',
|
||||
'settings.integrations.github.description': '풀 리퀘스트와 이슈를 위해 GitHub 계정을 연결합니다.',
|
||||
'settings.integrations.github.status.notConnected': '연결되지 않음',
|
||||
'settings.integrations.firstParty.title': '기본 제공 통합',
|
||||
'settings.integrations.firstParty.info': 'OpenChamber에 포함된 서비스 로그인입니다. 이 컴퓨터에 저장되며 웹, 데스크톱, 페어링된 휴대폰이 공유합니다.',
|
||||
'settings.integrations.linear.title': 'Linear',
|
||||
@@ -283,6 +301,9 @@ export const linearIntegrationI18n = {
|
||||
'settings.magicPrompts.page.group.linearIssueReview.description': 'Linear 이슈로 세션을 시작할 때 쓰는 프롬프트입니다: 표시 사용자 메시지 + 숨겨진 지침.',
|
||||
},
|
||||
pl: {
|
||||
'settings.integrations.github.title': 'GitHub',
|
||||
'settings.integrations.github.description': 'Połącz konto GitHub dla pull requestów i issues.',
|
||||
'settings.integrations.github.status.notConnected': 'Nie połączono',
|
||||
'settings.integrations.firstParty.title': 'Wbudowane integracje',
|
||||
'settings.integrations.firstParty.info': 'Logowania do usług dostarczanych z OpenChamber. Zapisujemy je na tym komputerze, żeby przeglądarka, aplikacja desktopowa i sparowany telefon z nich korzystały.',
|
||||
'settings.integrations.linear.title': 'Linear',
|
||||
@@ -330,6 +351,9 @@ export const linearIntegrationI18n = {
|
||||
'settings.magicPrompts.page.group.linearIssueReview.description': 'Prompty używane przy starcie sesji ze zgłoszenia Linear: widoczna wiadomość użytkownika i ukryte instrukcje.',
|
||||
},
|
||||
'pt-BR': {
|
||||
'settings.integrations.github.title': 'GitHub',
|
||||
'settings.integrations.github.description': 'Conecte uma conta do GitHub para pull requests e issues.',
|
||||
'settings.integrations.github.status.notConnected': 'Não conectado',
|
||||
'settings.integrations.firstParty.title': 'Integrações nativas',
|
||||
'settings.integrations.firstParty.info': 'Logins dos serviços inclusos no OpenChamber. O login fica neste computador para que a web, o app desktop e um celular emparelhado o compartilhem.',
|
||||
'settings.integrations.linear.title': 'Linear',
|
||||
@@ -377,6 +401,9 @@ export const linearIntegrationI18n = {
|
||||
'settings.magicPrompts.page.group.linearIssueReview.description': 'Prompts usados ao iniciar uma sessão a partir de uma issue do Linear: mensagem visível do usuário e instruções ocultas.',
|
||||
},
|
||||
uk: {
|
||||
'settings.integrations.github.title': 'GitHub',
|
||||
'settings.integrations.github.description': 'Підключіть акаунт GitHub для pull request-ів та issues.',
|
||||
'settings.integrations.github.status.notConnected': 'Не підключено',
|
||||
'settings.integrations.firstParty.title': 'Вбудовані інтеграції',
|
||||
'settings.integrations.firstParty.info': 'Входи до сервісів, що входять до OpenChamber. Логін лишається на цьому комп’ютері, тож веб, десктоп і спарений телефон користуються одним обліковим записом.',
|
||||
'settings.integrations.linear.title': 'Linear',
|
||||
@@ -424,6 +451,9 @@ export const linearIntegrationI18n = {
|
||||
'settings.magicPrompts.page.group.linearIssueReview.description': 'Промпти для старту сесії з Linear issue: видиме повідомлення користувача та приховані інструкції.',
|
||||
},
|
||||
'zh-CN': {
|
||||
'settings.integrations.github.title': 'GitHub',
|
||||
'settings.integrations.github.description': '连接 GitHub 账号以处理拉取请求和议题。',
|
||||
'settings.integrations.github.status.notConnected': '未连接',
|
||||
'settings.integrations.firstParty.title': '内置集成',
|
||||
'settings.integrations.firstParty.info': 'OpenChamber 自带服务的登录。登录保存在这台电脑上,网页、桌面应用和已配对的手机会共用它。',
|
||||
'settings.integrations.linear.title': 'Linear',
|
||||
@@ -471,6 +501,9 @@ export const linearIntegrationI18n = {
|
||||
'settings.magicPrompts.page.group.linearIssueReview.description': '从 Linear Issue 开始会话时使用的提示词:可见用户消息 + 隐藏指令。',
|
||||
},
|
||||
'zh-TW': {
|
||||
'settings.integrations.github.title': 'GitHub',
|
||||
'settings.integrations.github.description': '連接 GitHub 帳號以處理拉取請求與議題。',
|
||||
'settings.integrations.github.status.notConnected': '未連接',
|
||||
'settings.integrations.firstParty.title': '內建整合',
|
||||
'settings.integrations.firstParty.info': 'OpenChamber 內建服務的登入。登入保存在這台電腦上,網頁、桌面應用程式和已配對的手機會共用它。',
|
||||
'settings.integrations.linear.title': 'Linear',
|
||||
@@ -518,6 +551,9 @@ export const linearIntegrationI18n = {
|
||||
'settings.magicPrompts.page.group.linearIssueReview.description': '從 Linear Issue 開始會話時使用的提示詞:可見使用者訊息 + 隱藏指令。',
|
||||
},
|
||||
tr: {
|
||||
'settings.integrations.github.title': 'GitHub',
|
||||
'settings.integrations.github.description': "Pull request ve issue'lar için bir GitHub hesabı bağlayın.",
|
||||
'settings.integrations.github.status.notConnected': 'Bağlı değil',
|
||||
'settings.integrations.firstParty.title': 'Yerleşik entegrasyonlar',
|
||||
'settings.integrations.firstParty.info': 'OpenChamber ile gelen hizmetlerin oturumları. Giriş bu bilgisayarda kalır; web, masaüstü ve eşlenen telefon paylaşır.',
|
||||
'settings.integrations.linear.title': 'Linear',
|
||||
|
||||
@@ -150,7 +150,7 @@ export const SETTINGS_PAGE_METADATA: readonly SettingsPageMeta[] = [
|
||||
title: 'Git',
|
||||
group: 'projects',
|
||||
kind: 'single',
|
||||
keywords: ['git', 'github', 'identity', 'identities', 'ssh', 'profiles', 'credentials', 'keys', 'commit', 'gitmoji', 'oauth', 'prs', 'issues'],
|
||||
keywords: ['git', 'identity', 'identities', 'ssh', 'profiles', 'credentials', 'keys', 'commit', 'gitmoji'],
|
||||
isAvailable: (ctx) => !ctx.isVSCode,
|
||||
},
|
||||
{
|
||||
@@ -202,7 +202,7 @@ export const SETTINGS_PAGE_METADATA: readonly SettingsPageMeta[] = [
|
||||
{ slug: 'voice', title: 'Voice', group: 'general', kind: 'single', keywords: ['tts', 'speech', 'voice'], isAvailable: (ctx) => !ctx.isVSCode },
|
||||
{ slug: 'tunnel', title: 'External Tunnel', group: 'projects', kind: 'single', keywords: ['tunnel', 'external', 'cloudflare', 'qr', 'remote', 'mobile', 'share'], isAvailable: (ctx) => !ctx.isVSCode },
|
||||
{ slug: 'about', title: 'About', group: 'general', kind: 'single', keywords: ['about', 'version', 'updates', 'release', 'changelog'], isAvailable: (ctx) => ctx.isMobile && !ctx.isVSCode },
|
||||
{ slug: 'integrations', title: 'Integrations', group: 'general', kind: 'single', keywords: ['integration', 'plugin', 'provider', 'oauth', 'claude', 'cursor', 'command code', 'connect', 'discord', 'telegram', 'messenger', 'linear'] },
|
||||
{ slug: 'integrations', title: 'Integrations', group: 'general', kind: 'single', keywords: ['integration', 'plugin', 'provider', 'oauth', 'claude', 'cursor', 'command code', 'connect', 'discord', 'telegram', 'messenger', 'github', 'linear'] },
|
||||
] as const;
|
||||
|
||||
const LEGACY_SIDEBAR_SECTION_TO_SETTINGS_SLUG: Record<SidebarSection, SettingsPageSlug> = {
|
||||
|
||||
@@ -527,12 +527,6 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
|
||||
// user to an empty spot on the page.
|
||||
isAvailable: (ctx) => !ctx.isVSCode && useUIStore.getState().agentMemoryFeatureAvailable,
|
||||
},
|
||||
{
|
||||
id: 'git.github-account',
|
||||
page: 'git',
|
||||
titleKey: 'settings.github.page.actions.connect',
|
||||
keywords: ['github', 'account', 'oauth', 'prs', 'issues'],
|
||||
},
|
||||
{
|
||||
id: 'git.identities',
|
||||
page: 'git',
|
||||
@@ -989,7 +983,15 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [
|
||||
page: 'integrations',
|
||||
titleKey: 'settings.integrations.firstParty.title',
|
||||
descriptionKey: 'settings.integrations.firstParty.info',
|
||||
keywords: ['built-in', 'first-party', 'native', 'linear'],
|
||||
keywords: ['built-in', 'first-party', 'native', 'github', 'linear'],
|
||||
isAvailable: (ctx) => !ctx.isVSCode,
|
||||
},
|
||||
{
|
||||
id: 'integrations.github',
|
||||
page: 'integrations',
|
||||
titleKey: 'settings.integrations.github.title',
|
||||
descriptionKey: 'settings.integrations.github.description',
|
||||
keywords: ['github', 'account', 'oauth', 'gh', 'cli', 'prs', 'pull request', 'issues', 'connect'],
|
||||
isAvailable: (ctx) => !ctx.isVSCode,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -27,7 +27,9 @@ edge (`components/layout/ContextPanelRail.tsx`) and rendered by
|
||||
configure button — `ContextRailSurfacesDialog`), drops the plan surface
|
||||
unless plan mode is enabled,
|
||||
drops the walkthrough on VS Code and below `WALKTHROUGH_MIN_WIDTH`, hides
|
||||
Linear unless a workspace is connected, and hides `has-content` surfaces
|
||||
Linear unless a workspace is connected, hides the pull-request surface
|
||||
unless GitHub is connected (OAuth or `gh` CLI — signed in from Settings →
|
||||
Integrations), and hides `has-content` surfaces
|
||||
until a tab of their mode exists. Both consumers use it so the digit shown
|
||||
on a rail badge always maps to the same surface the shortcut opens.
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ const baseOptions = {
|
||||
screenWidth: 1200,
|
||||
tabs: [],
|
||||
linearConnected: true,
|
||||
githubConnected: true,
|
||||
} as const;
|
||||
|
||||
describe('getVisibleContextRailSurfaces', () => {
|
||||
@@ -65,12 +66,16 @@ describe('getVisibleContextRailSurfaces', () => {
|
||||
expect(surfaces.slice(0, 2).map((surface) => surface.id)).toEqual(['git', 'context']);
|
||||
});
|
||||
|
||||
test('places Linear after Pull Request in the default order', () => {
|
||||
test('places Linear right after the walkthrough in the default order', () => {
|
||||
const ids = getVisibleContextRailSurfaces(baseOptions).map((surface) => surface.id);
|
||||
const pr = ids.indexOf('pr');
|
||||
const linear = ids.indexOf('linear');
|
||||
expect(pr).toBeGreaterThanOrEqual(0);
|
||||
expect(linear).toBe(pr + 1);
|
||||
const walkthrough = ids.indexOf('walkthrough');
|
||||
expect(walkthrough).toBeGreaterThanOrEqual(0);
|
||||
expect(ids.indexOf('linear')).toBe(walkthrough + 1);
|
||||
});
|
||||
|
||||
test('hides the pull request surface until GitHub is connected', () => {
|
||||
expect(getVisibleContextRailSurfaces({ ...baseOptions, githubConnected: false }).some((s) => s.id === 'pr')).toBe(false);
|
||||
expect(getVisibleContextRailSurfaces({ ...baseOptions, githubConnected: true }).some((s) => s.id === 'pr')).toBe(true);
|
||||
});
|
||||
|
||||
test('hides Linear until a workspace is connected', () => {
|
||||
|
||||
@@ -66,15 +66,6 @@ export const CONTEXT_SURFACES: readonly ContextSurfaceDescriptor[] = [
|
||||
labelKey: 'contextPanel.mode.pr',
|
||||
availability: 'always',
|
||||
},
|
||||
{
|
||||
id: 'linear',
|
||||
descriptionKey: 'contextRail.surface.linear.description',
|
||||
defaultWidthFraction: 0.45,
|
||||
mode: 'linear',
|
||||
icon: 'linear',
|
||||
labelKey: 'contextPanel.mode.linear',
|
||||
availability: 'always',
|
||||
},
|
||||
{
|
||||
id: 'diff',
|
||||
descriptionKey: 'contextRail.surface.diff.description',
|
||||
@@ -93,6 +84,15 @@ export const CONTEXT_SURFACES: readonly ContextSurfaceDescriptor[] = [
|
||||
labelKey: 'contextPanel.mode.walkthrough',
|
||||
availability: 'always',
|
||||
},
|
||||
{
|
||||
id: 'linear',
|
||||
descriptionKey: 'contextRail.surface.linear.description',
|
||||
defaultWidthFraction: 0.45,
|
||||
mode: 'linear',
|
||||
icon: 'linear',
|
||||
labelKey: 'contextPanel.mode.linear',
|
||||
availability: 'always',
|
||||
},
|
||||
{
|
||||
id: 'editor',
|
||||
descriptionKey: 'contextRail.surface.editor.description',
|
||||
@@ -206,6 +206,10 @@ type VisibleRailSurfacesOptions = {
|
||||
tabs: readonly { mode: ContextPanelMode }[];
|
||||
/** Linear's rail icon stays off until a workspace is connected. */
|
||||
linearConnected: boolean;
|
||||
/** The pull-request rail icon stays off until GitHub is connected (OAuth
|
||||
or a detected `gh` CLI login). GitHub is connected from Settings, so
|
||||
hiding the surface removes no entry point. */
|
||||
githubConnected: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -240,6 +244,9 @@ export const getVisibleContextRailSurfaces = (options: VisibleRailSurfacesOption
|
||||
if (surface.id === 'linear' && !options.linearConnected) {
|
||||
return false;
|
||||
}
|
||||
if (surface.id === 'pr' && !options.githubConnected) {
|
||||
return false;
|
||||
}
|
||||
if (surface.availability === 'has-content') {
|
||||
return options.tabs.some((tab) => tab.mode === surface.mode);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user