diff --git a/packages/components/src/components/chat/chat-landing.tsx b/packages/components/src/components/chat/chat-landing.tsx index 369bb4dcd..64215f019 100644 --- a/packages/components/src/components/chat/chat-landing.tsx +++ b/packages/components/src/components/chat/chat-landing.tsx @@ -4161,10 +4161,14 @@ function WorkspaceChatLanding({ const repo = freshRepositories?.find((r) => r.fullName === selectedRepo); return repo ? !repo.private : undefined; }, [freshRepositories, selectedRepo]); + // The `@issue` and `@pr` mention categories key off this name and resolve + // against the GitHub App; without the capability there is no App to ask. + const githubIntegrationAvailable = useAppCapability('githubIntegration'); const selectedLocalProjectGithubRepoFullName = useMemo(() => { + if (!githubIntegrationAvailable) return undefined; if (contextType !== 'local') return undefined; return resolveLocalProjectGithubRepoFullName(activeLocalGitState, repositories) ?? undefined; - }, [activeLocalGitState, contextType, repositories]); + }, [activeLocalGitState, contextType, githubIntegrationAvailable, repositories]); const preparationMachineId = useMemo(() => { if (!selectedAgent) return null; @@ -4270,9 +4274,14 @@ function WorkspaceChatLanding({ githubRepoFullName: selectedLocalProjectGithubRepoFullName, }; } - return { kind: 'github' as const, repoFullName: selectedRepo, isPublic: isSelectedRepoPublic }; + return { + kind: 'github' as const, + repoFullName: githubIntegrationAvailable ? selectedRepo : undefined, + isPublic: isSelectedRepoPublic, + }; }, [ contextType, + githubIntegrationAvailable, isSelectedRepoPublic, selectedLocalProject, selectedLocalProjectGithubRepoFullName, diff --git a/packages/components/src/components/chat/unified-project-selector.tsx b/packages/components/src/components/chat/unified-project-selector.tsx index a6b3c7f6e..aa3ecbea7 100644 --- a/packages/components/src/components/chat/unified-project-selector.tsx +++ b/packages/components/src/components/chat/unified-project-selector.tsx @@ -18,6 +18,7 @@ import { useConvexErrorMessage } from '@/hooks/use-convex-error-message'; import { useVisibleLocalProjects } from '@/hooks/use-visible-local-projects'; import { CachedAvatarImg } from '@/components/cached-avatar-img'; import { getSessionSharingDescription, ProjectShareDialog } from '@/components/session-sharing'; +import { useAppCapability } from '@/lib/app-platform'; import { getGitHubOwnerAvatarUrl } from '@/lib/github-avatar'; import { resolveLocalProjectSharingState, @@ -396,6 +397,10 @@ export function UnifiedProjectSelectorView({ renderLimit, }: UnifiedProjectSelectorViewProps) { const { t } = useTranslation(); + // "Connect more GitHub projects" opens the GitHub App install flow. A platform + // that declares no `githubIntegration` has no flow to open, and the entry then + // sits under a repo list that is empty for exactly that reason. + const githubIntegrationAvailable = useAppCapability('githubIntegration'); const [open, setOpen] = useState(false); const [query, setQuery] = useState(''); const [pendingProjectShare, setPendingProjectShare] = useState(null); @@ -632,10 +637,12 @@ export function UnifiedProjectSelectorView({ {t('chat.contextSwitch.addProject', 'Add a local project')} - - - {t('repos.connectMore', 'Connect more GitHub projects')} - + {githubIntegrationAvailable ? ( + + + {t('repos.connectMore', 'Connect more GitHub projects')} + + ) : null} {selectedPrivateSharing ? ( diff --git a/packages/components/src/components/sessions/session-chat-input-area.tsx b/packages/components/src/components/sessions/session-chat-input-area.tsx index 41bbbd12a..d1c68c15b 100644 --- a/packages/components/src/components/sessions/session-chat-input-area.tsx +++ b/packages/components/src/components/sessions/session-chat-input-area.tsx @@ -15,6 +15,7 @@ import { ArrowUp, Loader2 } from 'lucide-react'; import { Button } from '@/ui/button'; import type { AcpSessionSelectOption } from '@/components/shared/acp-session-select'; import { useSessionAgentRole } from '@/hooks/use-session-agent-role'; +import { useAppCapability } from '@/lib/app-platform'; import { buildAgentRoleFormValueFromRunConfig } from '@/lib/agent-role-form'; import { doesAgentRolePinPermissionMode } from '@/lib/composer-agent-roles'; import { resolvePermissionModeFace } from '@/lib/permission-mode-face'; @@ -1923,7 +1924,15 @@ export const SessionChatInputArea = memo( }), [effectiveWorkspaceId, localMachineId, session, sessionLocalProjectRootPath] ); - const repoFullName = useMemo(() => resolveSessionRepoFullName(session), [session]); + // `@issue`, `@pr` and the `#123` hydrator all key off this name, and all + // three resolve against the GitHub App. A platform without the + // `githubIntegration` capability has no App, so a local clone's remote must + // not enable them. + const githubIntegrationAvailable = useAppCapability('githubIntegration'); + const repoFullName = useMemo( + () => (githubIntegrationAvailable ? resolveSessionRepoFullName(session) : ''), + [githubIntegrationAvailable, session] + ); const codeCollabRequestedRole = useCodeCollabRequestedRole(); // Code Collab files live in the worktree owned by the top-level (parent) // session; child-session tabs share that same workspace. Look the space up diff --git a/packages/components/src/components/sessions/session-chat-interface.tsx b/packages/components/src/components/sessions/session-chat-interface.tsx index c16dd80fc..d8c5211ed 100644 --- a/packages/components/src/components/sessions/session-chat-interface.tsx +++ b/packages/components/src/components/sessions/session-chat-interface.tsx @@ -150,6 +150,7 @@ import { MessageSendStatusContext } from '../ai-gui/message-send-status-context' import { format, formatDistanceToNow } from 'date-fns'; import type { Locale } from 'date-fns'; import { enUS, zhCN } from 'date-fns/locale'; +import { useAppCapability } from '@/lib/app-platform'; import { getAppShareUrl } from '@/lib/app-location'; import { resolveSessionOpenInIdePathTarget } from '@/lib/session-open-in-ide-path'; import { @@ -2133,6 +2134,12 @@ export const SessionChatInterface = memo( return formatSessionDate(session.createdAt, localeObj) || session.id; }, [session, localeObj]); + // Every GitHub surface below — the info bar's six actions, the PR badge, the + // `@issue`/`@pr` mention categories, the `#123` hydrator — is reachable only + // through this state, and every one of them ends at the GitHub App. On a + // platform that declares no `githubIntegration` there is no App, so the + // repo identity a local clone happens to carry must not light them up. + const githubIntegrationAvailable = useAppCapability('githubIntegration'); const { repoFullName, latestPr, @@ -2142,8 +2149,8 @@ export const SessionChatInterface = memo( workspaceDirty, hasChanges, } = useMemo( - () => getSessionGitHubState(session, workspaceSession), - [session, workspaceSession] + () => getSessionGitHubState(session, workspaceSession, githubIntegrationAvailable), + [session, workspaceSession, githubIntegrationAvailable] ); const latestPrNumber = getPullRequestNumber(latestPr); const latestPrRepoFullName = getPullRequestRepoFullName(latestPr) ?? repoFullName; diff --git a/packages/components/src/components/sessions/session-conversation-diff-panel.tsx b/packages/components/src/components/sessions/session-conversation-diff-panel.tsx index 1b3b2ae42..9f23cf437 100644 --- a/packages/components/src/components/sessions/session-conversation-diff-panel.tsx +++ b/packages/components/src/components/sessions/session-conversation-diff-panel.tsx @@ -45,6 +45,7 @@ import { useSessionAllChangesDiffData } from './use-session-all-changes-diff-dat import { useSessionConversationDiffData } from './use-session-conversation-diff-data'; import { useGitHubReviewComments } from '@/hooks/use-github-review-comments'; import { withGitHubOperationTokenRetry, withGitHubTokenRetry } from '@/lib/github-token'; +import { useAppCapability } from '@/lib/app-platform'; import { getPullRequestNumber, getSessionGitHubState } from '@/lib/session-github-state'; import { SessionFileDiffNoticeCard } from './session-file-diff-notice-card'; import { DiffFileHeaderActions } from '@/ui/diff-viewer/diff-file-header-actions'; @@ -428,9 +429,14 @@ function SessionConversationDiffPanelImpl({ const { cacheKey, normalizedPaths, resolvedByPath, isDiffUnavailable } = isBaseMode ? allChangesDiffData : conversationDiffData; + // `commentsEnabled` and `prLinked` below are the only way a review comment can + // be drafted, and both post to the GitHub App. Without the capability there is + // no App, so the repo a local clone names must not open the draft. + const githubIntegrationAvailable = useAppCapability('githubIntegration'); const { repoFullName, latestPr } = useMemo( - () => getSessionGitHubState(session ?? null, workspaceSession ?? null), - [session, workspaceSession] + () => + getSessionGitHubState(session ?? null, workspaceSession ?? null, githubIntegrationAvailable), + [githubIntegrationAvailable, session, workspaceSession] ); const latestPrNumber = getPullRequestNumber(latestPr); const githubReviewComments = useGitHubReviewComments({ diff --git a/packages/components/src/components/sessions/session-detail.tsx b/packages/components/src/components/sessions/session-detail.tsx index cf08bfa3a..50a5063f4 100644 --- a/packages/components/src/components/sessions/session-detail.tsx +++ b/packages/components/src/components/sessions/session-detail.tsx @@ -203,6 +203,7 @@ import { getPullRequestRepoFullName, getSessionGitHubState, } from '@/lib/session-github-state'; +import { useAppCapability } from '@/lib/app-platform'; import { resolveMachineDotlodyPath, resolveSessionWorkspacePath, @@ -1560,9 +1561,11 @@ const SessionDetail = ({ return 'idle'; }, [activeSession, activeSessionLiveStatus]); useTabStatus(tabStatus); + const githubIntegrationAvailable = useAppCapability('githubIntegration'); const { latestPr, repoFullName, canShowGitHubActions } = useMemo( - () => getSessionGitHubState(activeTabSession, workspaceOwnerSession), - [activeTabSession, workspaceOwnerSession] + () => + getSessionGitHubState(activeTabSession, workspaceOwnerSession, githubIntegrationAvailable), + [activeTabSession, githubIntegrationAvailable, workspaceOwnerSession] ); const latestPrNumber = getPullRequestNumber(latestPr); const latestPrRepoFullName = getPullRequestRepoFullName(latestPr) ?? repoFullName; diff --git a/packages/components/src/lib/session-github-state.ts b/packages/components/src/lib/session-github-state.ts index 8577d8574..4ba9f90d2 100644 --- a/packages/components/src/lib/session-github-state.ts +++ b/packages/components/src/lib/session-github-state.ts @@ -73,14 +73,31 @@ export const resolveWorkspaceOwnerSession = ( workspaceSession?: SessionMeta | null ): SessionMeta | null => workspaceSession ?? session ?? null; +/** + * The GitHub identity and PR state a Session surface renders from. + * + * `gitHubIntegrationAvailable` is the `githubIntegration` platform capability, + * and it defaults to `true` so every existing caller keeps today's behaviour. + * Pass the capability where the answer decides what the user SEES: a local clone + * carries a GitHub remote whether or not the app can reach the GitHub App, and + * without this the repo name alone turns on "Create PR", the PR panel, the PR + * badge and the `@issue`/`@pr` mention categories on a platform that has no + * token to serve any of them. + */ export const getSessionGitHubState = ( session: SessionMeta | null | undefined, - workspaceSession?: SessionMeta | null + workspaceSession?: SessionMeta | null, + gitHubIntegrationAvailable = true ): SessionGitHubState => { const sourceSession = resolveWorkspaceOwnerSession(session, workspaceSession); - const repoFullName = - (resolveProjectGitHubRepo(sourceSession?.project) ?? sourceSession?.repoFullName)?.trim() ?? ''; - const latestPr = getLatestPullRequest(sourceSession); + const repoFullName = !gitHubIntegrationAvailable + ? '' + : ((resolveProjectGitHubRepo(sourceSession?.project) ?? sourceSession?.repoFullName)?.trim() ?? + ''); + // Null with the capability off, not merely unused: `latestPr` is what the diff + // panel turns into a PR number, and a review-comment fetch keyed on a number + // with no repo is a request that can only fail. + const latestPr = gitHubIntegrationAvailable ? getLatestPullRequest(sourceSession) : null; const latestPrState = latestPr?.url ? (sourceSession?.pullRequestState?.[latestPr.url] ?? null) : null; diff --git a/packages/components/tests/session-chat-input-submission.test.tsx b/packages/components/tests/session-chat-input-submission.test.tsx index c84081946..9ad6e8747 100644 --- a/packages/components/tests/session-chat-input-submission.test.tsx +++ b/packages/components/tests/session-chat-input-submission.test.tsx @@ -1,6 +1,6 @@ // @vitest-environment jsdom -import { act, createElement } from 'react'; +import { act, createElement, type ComponentProps } from 'react'; import { createRoot, type Root } from 'react-dom/client'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import type { AgentRole, AgentRoleId, SessionMeta } from '@lody/shared'; @@ -89,6 +89,13 @@ vi.mock('../src/hooks/use-code-collab-session-file-provider', () => ({ })); import { SessionChatInputArea } from '../src/components/sessions/session-chat-input-area'; +import { TestCloudPlatformProvider } from './test-platform'; + +// The composer now reads the `githubIntegration` platform capability, and +// `PlatformContext` deliberately has no default — a missing provider is a +// programming error rather than an implicit cloud fallback. +const renderInputArea = (props: ComponentProps) => + createElement(TestCloudPlatformProvider, null, createElement(SessionChatInputArea, props)); import { initI18n } from '../src/i18n'; ( @@ -159,7 +166,7 @@ describe('SessionChatInputArea submission feedback', () => { await act(async () => { root?.render( - createElement(SessionChatInputArea, { + renderInputArea({ session: { id: 'session-role-permission', userId: 'user-1', @@ -218,7 +225,7 @@ describe('SessionChatInputArea submission feedback', () => { await act(async () => { root?.render( - createElement(SessionChatInputArea, { + renderInputArea({ session: { id: 'session-feedback', userId: 'user-1', @@ -279,7 +286,7 @@ describe('SessionChatInputArea submission feedback', () => { await act(async () => { root?.render( - createElement(SessionChatInputArea, { + renderInputArea({ session: { id: 'session-mobile-keyboard-send', userId: 'user-1', @@ -367,7 +374,7 @@ describe('SessionChatInputArea submission feedback', () => { await act(async () => { root?.render( - createElement(SessionChatInputArea, { + renderInputArea({ session: { id: 'session-limit', userId: 'user-1', diff --git a/packages/components/tests/session-github-state-capability.test.ts b/packages/components/tests/session-github-state-capability.test.ts new file mode 100644 index 000000000..da33ae966 --- /dev/null +++ b/packages/components/tests/session-github-state-capability.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, it } from 'vitest'; +import type { SessionMeta, SessionId, SessionPullRequestMeta } from '@lody/shared'; + +import { getSessionGitHubState } from '../src/lib/session-github-state'; + +const createPullRequest = ( + overrides: Partial = {} +): SessionPullRequestMeta => ({ + url: 'https://github.com/loro-dev/lody/pull/1518', + number: 1518, + repository: 'loro-dev/lody', + branch: 'fix/example', + status: 'open', + reportedAt: '2026-03-27T10:00:00.000Z', + ...overrides, +}); + +const createSession = (overrides: Partial = {}): SessionMeta => + ({ + id: 'session-1' as SessionId, + machineId: 'machine-1', + userId: 'user-1', + createdAt: '2026-03-27T09:00:00.000Z', + cliType: 'builtin', + agentType: 'codex', + ...overrides, + }) as SessionMeta; + +// A local clone carries a GitHub remote whether or not the platform can reach +// the GitHub App, so the repo name alone must not decide what the user sees. +describe('getSessionGitHubState with gitHubIntegrationAvailable', () => { + const session = createSession({ + repoFullName: 'loro-dev/lody', + workspaceDirty: true, + pullRequests: [createPullRequest()], + }); + + it('defaults to available, so an existing caller is unchanged', () => { + const state = getSessionGitHubState(session, null); + expect(state.repoFullName).toBe('loro-dev/lody'); + expect(state.latestPr).not.toBeNull(); + expect(state.canShowGitHubActions).toBe(true); + expect(state.hasExistingPr).toBe(true); + }); + + it('drops the repo identity and the pull request when the capability is off', () => { + const state = getSessionGitHubState(session, null, false); + expect(state.repoFullName).toBe(''); + expect(state.latestPr).toBeNull(); + expect(state.latestPrState).toBeNull(); + expect(state.canShowGitHubActions).toBe(false); + expect(state.hasExistingPr).toBe(false); + }); + + it('keeps the change signals, which are not about GitHub', () => { + const state = getSessionGitHubState( + createSession({ + repoFullName: 'loro-dev/lody', + workspaceDirty: true, + diffStats: { allChange: { add: 3, del: 1 } }, + }), + null, + false + ); + expect(state.workspaceDirty).toBe(true); + expect(state.hasChanges).toBe(true); + }); +}); diff --git a/packages/components/tests/unified-project-selector-options.test.tsx b/packages/components/tests/unified-project-selector-options.test.tsx index dbba0c7c2..a87cc4dfe 100644 --- a/packages/components/tests/unified-project-selector-options.test.tsx +++ b/packages/components/tests/unified-project-selector-options.test.tsx @@ -10,6 +10,7 @@ import { type UnifiedLocalProjectOption, } from '../src/components/chat/unified-project-selector'; import { TooltipProvider } from '../src/ui/tooltip'; +import { TestCloudPlatformProvider } from './test-platform'; vi.mock('react-i18next', () => ({ useTranslation: () => ({ @@ -65,16 +66,18 @@ describe('UnifiedProjectSelectorView options', () => { it('renders the 20 most recent projects and searches the complete list', async () => { await act(async () => { root.render( - - - + + + + + ); }); @@ -119,16 +122,18 @@ describe('UnifiedProjectSelectorView options', () => { it('keeps every source visible when the caller has no shared recency ranking', async () => { await act(async () => { root.render( - - - + + + + + ); }); @@ -149,18 +154,20 @@ describe('UnifiedProjectSelectorView options', () => { it('includes recently used GitHub repositories in the bounded mixed list', async () => { await act(async () => { root.render( - - - + + + + + ); }); @@ -189,18 +196,20 @@ describe('UnifiedProjectSelectorView options', () => { it('reserves a source slot for a GitHub repository with no usage history', async () => { await act(async () => { root.render( - - - + + + + + ); }); diff --git a/packages/components/tests/unified-project-selector-sharing.test.tsx b/packages/components/tests/unified-project-selector-sharing.test.tsx index 7e6c612ac..31463604d 100644 --- a/packages/components/tests/unified-project-selector-sharing.test.tsx +++ b/packages/components/tests/unified-project-selector-sharing.test.tsx @@ -13,6 +13,7 @@ import { } from '@lody/shared'; import { UnifiedProjectSelector } from '../src/components/chat/unified-project-selector'; +import { TestCloudPlatformProvider } from './test-platform'; import type { LocalProjectVisibilityAccess } from '../src/lib/visible-local-project-index'; import type { MachineVisibilityAccess } from '../src/lib/visible-machine-index'; import { TooltipProvider } from '../src/ui/tooltip'; @@ -167,24 +168,26 @@ describe('UnifiedProjectSelector project sharing', () => { } = {}) { await act(async () => { root.render( - - - + + + + + ); }); return onShareWithTeam;