Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
13 changes: 11 additions & 2 deletions packages/components/src/components/chat/chat-landing.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<UnifiedProjectOption | null>(null);
Expand Down Expand Up @@ -632,10 +637,12 @@ export function UnifiedProjectSelectorView({
<FolderPlus className="h-4 w-4 shrink-0 text-muted-foreground" />
<span>{t('chat.contextSwitch.addProject', 'Add a local project')}</span>
</DropdownMenuItem>
<DropdownMenuItem onSelect={onConnectGitRepo}>
<ArrowUpRight className="h-4 w-4 shrink-0 text-muted-foreground" />
<span>{t('repos.connectMore', 'Connect more GitHub projects')}</span>
</DropdownMenuItem>
{githubIntegrationAvailable ? (
<DropdownMenuItem onSelect={onConnectGitRepo}>
<ArrowUpRight className="h-4 w-4 shrink-0 text-muted-foreground" />
<span>{t('repos.connectMore', 'Connect more GitHub projects')}</span>
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
</DropdownMenu>
{selectedPrivateSharing ? (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand All @@ -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;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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({
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -203,6 +203,7 @@ import {
getPullRequestRepoFullName,
getSessionGitHubState,
} from '@/lib/session-github-state';
import { useAppCapability } from '@/lib/app-platform';
import {
resolveMachineDotlodyPath,
resolveSessionWorkspacePath,
Expand Down Expand Up @@ -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;
Expand Down
25 changes: 21 additions & 4 deletions packages/components/src/lib/session-github-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
17 changes: 12 additions & 5 deletions packages/components/tests/session-chat-input-submission.test.tsx
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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<typeof SessionChatInputArea>) =>
createElement(TestCloudPlatformProvider, null, createElement(SessionChatInputArea, props));
import { initI18n } from '../src/i18n';

(
Expand Down Expand Up @@ -159,7 +166,7 @@ describe('SessionChatInputArea submission feedback', () => {

await act(async () => {
root?.render(
createElement(SessionChatInputArea, {
renderInputArea({
session: {
id: 'session-role-permission',
userId: 'user-1',
Expand Down Expand Up @@ -218,7 +225,7 @@ describe('SessionChatInputArea submission feedback', () => {

await act(async () => {
root?.render(
createElement(SessionChatInputArea, {
renderInputArea({
session: {
id: 'session-feedback',
userId: 'user-1',
Expand Down Expand Up @@ -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',
Expand Down Expand Up @@ -367,7 +374,7 @@ describe('SessionChatInputArea submission feedback', () => {

await act(async () => {
root?.render(
createElement(SessionChatInputArea, {
renderInputArea({
session: {
id: 'session-limit',
userId: 'user-1',
Expand Down
68 changes: 68 additions & 0 deletions packages/components/tests/session-github-state-capability.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}
): 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> = {}): 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);
});
});
Loading