From 65054a5f4aeb2badae57cf425d13a142a85afc2d Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Sun, 30 Aug 2026 14:17:12 +0300 Subject: [PATCH] feat(ui): gate the pull-request surface on GitHub, move the account into it, and GitHub sign-in into Integrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../github/GitHubAccountControl.tsx | 161 ++++++++++++++++ .../components/layout/ContextPanelRail.tsx | 15 +- packages/ui/src/components/layout/Header.tsx | 176 ------------------ .../sections/git-identities/GitPage.tsx | 4 +- .../integrations/GitHubIntegration.tsx | 72 +++++++ .../integrations/IntegrationsPage.tsx | 22 ++- .../sections/integrations/LinearSettings.tsx | 9 - .../sections/openchamber/GitHubSettings.tsx | 57 ++++-- .../sections/openchamber/OpenChamberPage.tsx | 11 -- .../session/GitHubIntegrationDialog.tsx | 2 +- .../session/GitHubIssuePickerDialog.tsx | 2 +- .../session/GitHubPrPickerDialog.tsx | 2 +- .../views/git/PullRequestSection.tsx | 96 +++++++--- packages/ui/src/hooks/useKeyboardShortcuts.ts | 2 + .../i18n/messages/linear-integration.i18n.ts | 38 +++- packages/ui/src/lib/settings/metadata.ts | 4 +- packages/ui/src/lib/settings/search.ts | 16 +- packages/ui/src/lib/surfaces/DOCUMENTATION.md | 4 +- packages/ui/src/lib/surfaces/registry.test.ts | 15 +- packages/ui/src/lib/surfaces/registry.ts | 25 ++- 20 files changed, 461 insertions(+), 272 deletions(-) create mode 100644 packages/ui/src/components/github/GitHubAccountControl.tsx create mode 100644 packages/ui/src/components/sections/integrations/GitHubIntegration.tsx diff --git a/packages/ui/src/components/github/GitHubAccountControl.tsx b/packages/ui/src/components/github/GitHubAccountControl.tsx new file mode 100644 index 00000000..2f8a6ca9 --- /dev/null +++ b/packages/ui/src/components/github/GitHubAccountControl.tsx @@ -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[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['github'], + accountId: string, +): Promise => { + 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 ? ( + {login + ) : ( + + ); + + if (accounts.length <= 1) { + return ( +
+ {avatar} +
+ ); + } + + return ( + + + + + + + {t('header.github.accountsTitle')} + + + {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 ( + { + if (!isCurrent) { + void switchAccount(account.id); + } + }} + > + {accountUser?.avatarUrl ? ( + {accountUser.login + ) : ( +
+ +
+ )} + + + {accountUser?.name?.trim() || accountUser?.login || 'GitHub'} + + {accountUser?.login ? ( + + {accountUser.login} + · + {sourceLabel} + + ) : null} + + {isCurrent ? : null} +
+ ); + })} +
+
+ ); +}; diff --git a/packages/ui/src/components/layout/ContextPanelRail.tsx b/packages/ui/src/components/layout/ContextPanelRail.tsx index f1504a45..deb92627 100644 --- a/packages/ui/src/components/layout/ContextPanelRail.tsx +++ b/packages/ui/src/components/layout/ContextPanelRail.tsx @@ -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) => { diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index 7c1f3d3e..43126229 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -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[number]>; - githubAvatarUrl: string | null; - githubLogin: string | null; - isSwitchingGitHubAccount: boolean; - handleGitHubAccountSwitch: (accountId: string) => Promise; -}; - -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 ( - - - - - - - {t('header.github.accountsTitle')} - - - {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 ( - { - if (!isCurrent) { - void handleGitHubAccountSwitch(account.id); - } - }} - > - {accountUser?.avatarUrl ? ( - {accountUser.login - ) : ( -
- -
- )} - - - {accountUser?.name?.trim() || accountUser?.login || 'GitHub'} - - {accountUser?.login ? ( - - {accountUser.login} - · - {sourceLabel} - - ) : null} - - {isCurrent ? : null} -
- ); - })} -
-
- ); - } - - return ( -
- {githubAvatarUrl ? ( - {githubLogin - ) : ( - - )} -
- ); -}); - 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(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} - ); diff --git a/packages/ui/src/components/sections/git-identities/GitPage.tsx b/packages/ui/src/components/sections/git-identities/GitPage.tsx index d90d1a8f..31060e83 100644 --- a/packages/ui/src/components/sections/git-identities/GitPage.tsx +++ b/packages/ui/src/components/sections/git-identities/GitPage.tsx @@ -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 > - - openEditor('new')}> {t('settings.common.badge.new')} diff --git a/packages/ui/src/components/sections/integrations/GitHubIntegration.tsx b/packages/ui/src/components/sections/integrations/GitHubIntegration.tsx new file mode 100644 index 00000000..466de0f7 --- /dev/null +++ b/packages/ui/src/components/sections/integrations/GitHubIntegration.tsx @@ -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 ( + +
+ +
+ +
+
+
+ {t('settings.integrations.github.title')} +
+

+ {t('settings.integrations.github.description')} +

+
+ + {statusLabel} + + +
+ + + +
+
+ ); +}; diff --git a/packages/ui/src/components/sections/integrations/IntegrationsPage.tsx b/packages/ui/src/components/sections/integrations/IntegrationsPage.tsx index 8bbf2cab..a3b5956d 100644 --- a/packages/ui/src/components/sections/integrations/IntegrationsPage.tsx +++ b/packages/ui/src/components/sections/integrations/IntegrationsPage.tsx @@ -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 = ({ 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 ( = ({ description={t('settings.page.integrations.description')} showSaveStatus > - {hasLinear ? : null} + {hasBuiltIn ? ( + + {hasGitHub ? : null} + {hasLinear ? : null} + + ) : null} diff --git a/packages/ui/src/components/sections/integrations/LinearSettings.tsx b/packages/ui/src/components/sections/integrations/LinearSettings.tsx index fc71aadb..98bda06c 100644 --- a/packages/ui/src/components/sections/integrations/LinearSettings.tsx +++ b/packages/ui/src/components/sections/integrations/LinearSettings.tsx @@ -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 ( - { @@ -345,6 +337,5 @@ export const LinearSettings: React.FC = () => { - ); }; diff --git a/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx b/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx index e2a06bda..c72dc48b 100644 --- a/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx +++ b/packages/ui/src/components/sections/openchamber/GitHubSettings.tsx @@ -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 = ({ 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 = ( <> -
{connected ? (
@@ -445,10 +444,12 @@ export const GitHubSettings: React.FC = () => {
)} - + + ); - {ghCli?.available && !ghCli?.active && (!ghCli.user || ghCli.disabled) && ( - + const ghCliSection = ghCli?.available && !ghCli?.active && (!ghCli.user || ghCli.disabled) + ? ( + <>
@@ -499,8 +500,40 @@ export const GitHubSettings: React.FC = () => {
+ + ) + : null; + + if (embedded) { + return ( +
+ {accountSection} + {ghCliSection ? ( +
+ {t('settings.github.page.ghCli.title')} + {ghCliSection} +
+ ) : null} +
+ ); + } + + return ( + <> + + {accountSection} + + + {ghCliSection ? ( + + {ghCliSection} - )} + ) : null} ); }; diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx index d1959d72..d9d7ee93 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx @@ -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 = ({ section }) => return ; case 'git': return ; - case 'github': - return ; case 'notifications': return ; case 'voice': @@ -233,14 +230,6 @@ const GitSectionContent: React.FC = () => { return ; }; -// GitHub section: Connect account for PR/issue workflows -const GitHubSectionContent: React.FC = () => { - if (isVSCodeRuntime()) { - return null; - } - return ; -}; - // Notifications section: Native browser notifications const NotificationSectionContent: React.FC = () => { return ; diff --git a/packages/ui/src/components/session/GitHubIntegrationDialog.tsx b/packages/ui/src/components/session/GitHubIntegrationDialog.tsx index 409348ef..966ff6ea 100644 --- a/packages/ui/src/components/session/GitHubIntegrationDialog.tsx +++ b/packages/ui/src/components/session/GitHubIntegrationDialog.tsx @@ -284,7 +284,7 @@ export function GitHubIntegrationDialog({ const isGitHubConnected = githubAuthChecked && githubAuthStatus?.connected === true; const openGitHubSettings = () => { - setSettingsPage('github'); + setSettingsPage('integrations'); setSettingsDialogOpen(true); }; diff --git a/packages/ui/src/components/session/GitHubIssuePickerDialog.tsx b/packages/ui/src/components/session/GitHubIssuePickerDialog.tsx index 2db1e456..a861ac7b 100644 --- a/packages/ui/src/components/session/GitHubIssuePickerDialog.tsx +++ b/packages/ui/src/components/session/GitHubIssuePickerDialog.tsx @@ -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]); diff --git a/packages/ui/src/components/session/GitHubPrPickerDialog.tsx b/packages/ui/src/components/session/GitHubPrPickerDialog.tsx index c423503a..fcd0b716 100644 --- a/packages/ui/src/components/session/GitHubPrPickerDialog.tsx +++ b/packages/ui/src/components/session/GitHubPrPickerDialog.tsx @@ -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]); diff --git a/packages/ui/src/components/views/git/PullRequestSection.tsx b/packages/ui/src/components/views/git/PullRequestSection.tsx index 1a767916..20c9d87d 100644 --- a/packages/ui/src/components/views/git/PullRequestSection.tsx +++ b/packages/ui/src/components/views/git/PullRequestSection.tsx @@ -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 (
-
{t('gitView.pullRequest.title')}
+
+
{t('gitView.pullRequest.title')}
+ +
{t('gitView.pullRequest.availableOnFeatureBranches')}
@@ -1450,7 +1483,7 @@ export const PullRequestSection: React.FC<{ return (
-
+
{pr ? (
-
- {isLoading ? : null} +
+ {pr && showWalkthroughAction ? ( + + ) : null} - + {isLoading || isManualRefreshing + ? + : } +

{t('gitView.pr.actions.refresh')}

+
{pr ? ( -
+
{prStatusText} {checks ? ( @@ -1508,23 +1561,6 @@ export const PullRequestSection: React.FC<{ ) : null}
- {showWalkthroughAction ? ( - - ) : null} {canMerge && pr.draft && pr.state === 'open' ? ( diff --git a/packages/ui/src/hooks/useKeyboardShortcuts.ts b/packages/ui/src/hooks/useKeyboardShortcuts.ts index 3eff8676..bb069914 100644 --- a/packages/ui/src/hooks/useKeyboardShortcuts.ts +++ b/packages/ui/src/hooks/useKeyboardShortcuts.ts @@ -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) { diff --git a/packages/ui/src/lib/i18n/messages/linear-integration.i18n.ts b/packages/ui/src/lib/i18n/messages/linear-integration.i18n.ts index 529a6aba..44deca07 100644 --- a/packages/ui/src/lib/i18n/messages/linear-integration.i18n.ts +++ b/packages/ui/src/lib/i18n/messages/linear-integration.i18n.ts @@ -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', diff --git a/packages/ui/src/lib/settings/metadata.ts b/packages/ui/src/lib/settings/metadata.ts index 4fc3c8d6..8603434d 100644 --- a/packages/ui/src/lib/settings/metadata.ts +++ b/packages/ui/src/lib/settings/metadata.ts @@ -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 = { diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts index d428ec62..14d0caf2 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -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, }, { diff --git a/packages/ui/src/lib/surfaces/DOCUMENTATION.md b/packages/ui/src/lib/surfaces/DOCUMENTATION.md index dbdf65f3..8ae0254e 100644 --- a/packages/ui/src/lib/surfaces/DOCUMENTATION.md +++ b/packages/ui/src/lib/surfaces/DOCUMENTATION.md @@ -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. diff --git a/packages/ui/src/lib/surfaces/registry.test.ts b/packages/ui/src/lib/surfaces/registry.test.ts index e60a1af3..db4876b1 100644 --- a/packages/ui/src/lib/surfaces/registry.test.ts +++ b/packages/ui/src/lib/surfaces/registry.test.ts @@ -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', () => { diff --git a/packages/ui/src/lib/surfaces/registry.ts b/packages/ui/src/lib/surfaces/registry.ts index 9a0a8e10..f4dc0ee9 100644 --- a/packages/ui/src/lib/surfaces/registry.ts +++ b/packages/ui/src/lib/surfaces/registry.ts @@ -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); }