import type { AuthNeededItem } from "../../../timeline"; import type { SendMessageInput } from "@opengeni/sdk"; import { ArrowUpRightIcon, BookMarkedIcon, CheckIcon, Loader2Icon, LockIcon } from "lucide-react"; import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react"; import { useAppContext } from "../../app-context"; import { Button } from "../../../ui/button"; import { Input } from "../../../ui/input"; import { ListRow, ListRowSkeleton, RowList } from "../../../ui/list-row"; import { LogoTile } from "../../../ui/logo-tile"; import { Notice } from "../../../ui/notice"; import { RowButton } from "../../../ui/page-actions"; import { userErrorText } from "../../../ui/api-error"; import { hasWorkspacePermission } from "../../lib/permissions"; import { repositoryDisplayName } from "../../lib/session-tools"; import { cn } from "../../lib/utils"; import { isPersonalWorkspace } from "../../lib/managed-self-context"; import { clearGitHubInstallRequest, readGitHubInstallRequest, subscribeGitHubInstallRequests, } from "../../lib/github-install-request"; import type { GitHubAppInfo, GitHubRepository, ResourceRef } from "../../types"; import { capabilityLogoSource } from "./capability-logo-source"; import { SessionCapabilityFrame } from "../../../components/session-capability-frame"; import { GITHUB_CARD_MORE_SIZE, GITHUB_CARD_PAGE_SIZE, GITHUB_CARD_SEARCH_THRESHOLD, gitHubRepositoryChatState, matchesRepositorySearch, orderRepositoriesForChat, repositoryUseMessage, revokedGitHubRepositoryResources, usingRepositoriesLabel, type ChatSendContext, type GitHubRepositoryChatState, } from "./session-github-repositories"; import { usePage } from "../../lib/navigation"; type RepositoryResource = Extract; /** Refresh on return to the tab: quickly while setup is pending, rarely after. */ const AWAITING_REFRESH_MS = 3_000; const SETTLED_REFRESH_MS = 60_000; /** A trip to GitHub from this card stays "pending" this long. */ const AWAITING_GITHUB_MS = 15 * 60_000; const lastForegroundRefresh = new Map(); export type SessionGitHubCapabilityCardProps = { item: AuthNeededItem; workspaceId: string; sessionId: string; /** The chat's mounted resources; repository rows are additive and immutable. */ resources?: readonly ResourceRef[] | undefined; /** What a composer Send would carry now, so this Send follows the same rules. */ sendContext?: (() => ChatSendContext) | undefined; /** Re-read the session after a human attach or setup lands. */ onConfigured?: (() => Promise) | undefined; }; /** * The GitHub App card in a conversation. Connecting is the workspace's GitHub * App binding; using a repository in this chat is an ordinary human message * that carries the repository resource, the same request the composer * repository picker sends. The agent can request this card but never attach. */ export function SessionGitHubCapabilityCard({ item, workspaceId, sessionId, resources, sendContext, onConfigured, }: SessionGitHubCapabilityCardProps) { const page = usePage(); const context = useAppContext(); const refreshGitHub = context.refreshGitHub; const recommendation = item.capability!; // A status read by Connect wins until the shared workspace status changes. const [observed, setObserved] = useState<{ status: GitHubAppInfo; over: GitHubAppInfo | null; } | null>(null); const status = observed && observed.over === context.githubStatus ? observed.status : context.githubStatus; const [expanded, setExpanded] = useState(false); const [busy, setBusy] = useState(false); const [connectError, setConnectError] = useState(null); const opener = useRef(null); const cardRef = useRef(null); const inFlight = useRef(false); const requestSequence = useRef(0); const active = useRef(true); // When this card last sent the person to GitHub, so the return refreshes promptly. const sentToGitHubAt = useRef(0); useEffect(() => { active.current = true; return () => { active.current = false; }; }, []); const bound = status?.status === "bound"; const ownerApprovalPending = useGitHubInstallRequest(workspaceId, bound); const accessKnown = context.accessContext !== null && context.accessContext !== undefined; const can = (permission: string) => !accessKnown || hasWorkspacePermission(context.accessContext, workspaceId, permission); const canUseGitHub = can("github:use"); const canMessage = can("sessions:control"); // Only the server mints the signed connect link, and only for a principal // that may manage this workspace's GitHub App. const canManage = Boolean(status?.linkUrl); const personal = isPersonalWorkspace( context.workspaces?.find((entry) => entry.id === workspaceId) ?? null, null, ); // Re-sync from GitHub only while a connect or repository change is pending // (the composer picker's explicit refresh); otherwise re-read stored rows. const canSyncGitHub = can("github:manage"); const awaitingChange = !bound || context.githubRepos.length === 0; const repoBusy = context.repoBusy; useEffect(() => { if (!canUseGitHub) return; const onForeground = () => { if (document.visibilityState === "hidden" || repoBusy) return; const now = Date.now(); const awaiting = awaitingChange || now - sentToGitHubAt.current < AWAITING_GITHUB_MS; const interval = awaiting ? AWAITING_REFRESH_MS : SETTLED_REFRESH_MS; if (now - (lastForegroundRefresh.get(workspaceId) ?? 0) < interval) return; lastForegroundRefresh.set(workspaceId, now); // Another tab or popup may have connected GitHub or changed the // repositories it shares. A failed refresh keeps the last snapshot. void refreshGitHub(workspaceId, undefined, { sync: awaiting && canSyncGitHub }).catch( () => {}, ); }; window.addEventListener("focus", onForeground); document.addEventListener("visibilitychange", onForeground); return () => { window.removeEventListener("focus", onForeground); document.removeEventListener("visibilitychange", onForeground); }; }, [awaitingChange, canSyncGitHub, canUseGitHub, refreshGitHub, repoBusy, workspaceId]); useEffect(() => { const onPageShow = (event: PageTransitionEvent) => { if (!event.persisted || !inFlight.current) return; // A cancelled authorization can restore this exact React tree from the // back/forward cache. Its prior request must not navigate or settle a // newer attempt after the card becomes usable again. requestSequence.current += 1; inFlight.current = false; setBusy(false); void refreshGitHub(workspaceId); }; window.addEventListener("pageshow", onPageShow); return () => window.removeEventListener("pageshow", onPageShow); }, [refreshGitHub, workspaceId]); async function connect() { if (inFlight.current) return; const sequence = ++requestSequence.current; inFlight.current = true; setBusy(true); setConnectError(null); let navigating = false; try { const next = await context.client.getGitHubApp(workspaceId, { returnPath: `/workspaces/${encodeURIComponent(workspaceId)}/sessions/${encodeURIComponent(sessionId)}`, }); if (!active.current || sequence !== requestSequence.current) return; if (next.status === "bound") { setObserved({ status: next, over: context.githubStatus }); void context.refreshGitHub(workspaceId); return; } if (!next.linkUrl) throw new Error( next.configured ? "Your account cannot manage this workspace's GitHub connection." : "GitHub is not configured on this deployment.", ); navigating = true; sentToGitHubAt.current = Date.now(); page.assign(next.linkUrl); } catch (failure) { navigating = false; if (active.current && sequence === requestSequence.current) { setConnectError(`Couldn't start GitHub setup. ${userErrorText(failure, "Try again.")}`); setExpanded(true); } } finally { if (sequence === requestSequence.current && !navigating) { inFlight.current = false; if (active.current) setBusy(false); } } } const catalogItem = context.workspaceCapabilityCatalog.find( (entry) => entry.id === recommendation.id, ); const logo = capabilityLogoSource( catalogItem ?? { id: recommendation.id, logoAssetPath: null }, (path) => context.client.catalogAssetUrl(path), ); const chooser = useRepositoryChooser({ status, workspaceId, sessionId, resources, sendContext, onConfigured, cardRef, onOpenedGitHub: () => { sentToGitHubAt.current = Date.now(); }, }); const unavailable = !bound ? !canUseGitHub ? "You don't have access to this workspace's GitHub connection." : status?.configured === false ? status.setupMode === "platform" ? "GitHub isn't available on this deployment right now." : "GitHub isn't set up on this deployment yet." : status && status.linkUrl === null ? "Only workspace admins can connect GitHub." : null : null; const unavailableNote = !bound ? !canUseGitHub ? "Ask a workspace admin for GitHub access." : status?.configured === false ? status.setupMode === "platform" ? "Try again later, or use a public repository URL from the composer." : "An operator adds the GitHub App settings to the API and worker. This card updates when GitHub is connected." : status && status.linkUrl === null ? "Ask a workspace admin to connect GitHub. This card updates when they do." : null : null; return ( void connect()} onClose={() => setExpanded(false)} busy={busy} opener={opener} cardRef={cardRef} details={ bound ? ( ) : null } >

Choose the account and repositories to share with this workspace on GitHub, then return to this conversation.

{connectError ? {connectError} : null}
); } /** * True while this browser has an unanswered GitHub install request for the * workspace. Cleared as soon as the workspace's GitHub connection exists, which * the card already notices through its normal status refresh. */ function useGitHubInstallRequest(workspaceId: string, bound: boolean): boolean { const [requestedAt, setRequestedAt] = useState(() => readGitHubInstallRequest(workspaceId)); useEffect(() => { setRequestedAt(readGitHubInstallRequest(workspaceId)); return subscribeGitHubInstallRequests(() => setRequestedAt(readGitHubInstallRequest(workspaceId)), ); }, [workspaceId]); useEffect(() => { if (bound) clearGitHubInstallRequest(workspaceId); }, [bound, workspaceId]); return !bound && requestedAt !== null; } type RepositoryRow = { repository: GitHubRepository; state: GitHubRepositoryChatState; }; type RepositoryChooser = ReturnType; function useRepositoryChooser({ status, workspaceId, sessionId, resources, sendContext, onConfigured, cardRef, onOpenedGitHub, }: { status: GitHubAppInfo | null; workspaceId: string; sessionId: string; resources: readonly ResourceRef[] | undefined; sendContext: (() => ChatSendContext) | undefined; onConfigured: (() => Promise) | undefined; cardRef: RefObject; onOpenedGitHub: () => void; }) { const page = usePage(); const context = useAppContext(); const repositories = context.githubRepos; const [optimistic, setOptimistic] = useState([]); const [attaching, setAttaching] = useState(null); const [attachError, setAttachError] = useState(null); // What happened to the last accepted message, in words for the live region. const [outcome, setOutcome] = useState(null); const [confirming, setConfirming] = useState(null); const [linkError, setLinkError] = useState(null); const [openingInstallation, setOpeningInstallation] = useState(null); // One exact request per repository until it is accepted: a retry after an // unknown outcome resends the same input under the same idempotency key. const pendingInputs = useRef(new Map()); const inFlight = useRef(false); const alive = useRef(true); useEffect(() => { alive.current = true; return () => { alive.current = false; }; }, []); // Accepted attaches show at once; the re-read session replaces them. const mounted = useMemo(() => { const durable = [...(resources ?? [])]; return [ ...durable, ...optimistic.filter( (pending) => !durable.some( (resource) => resource.kind === "repository" && resource.githubRepositoryId === pending.githubRepositoryId && resource.githubInstallationId === pending.githubInstallationId, ), ), ]; }, [optimistic, resources]); const accountLabel = useCallback( (installationId: number) => status?.installations.find((installation) => installation.installationId === installationId) ?.accountLogin ?? "another GitHub account", [status], ); // Rows already in the chat when the card appeared lead; a repository used // from this card stays where it was clicked instead of jumping to the top. const [leading] = useState( () => new Set( (resources ?? []).flatMap((resource) => resource.kind === "repository" && resource.githubRepositoryId !== undefined ? [`${resource.githubInstallationId}:${resource.githubRepositoryId}`] : [], ), ), ); const rows: RepositoryRow[] = useMemo(() => { const states = new Map( repositories.map((repository) => [ repository.id, gitHubRepositoryChatState(repository, mounted, accountLabel, repositories), ]), ); return orderRepositoriesForChat(repositories, (repository) => leading.has(`${repository.installationId}:${repository.id}`), ).map((repository) => ({ repository, state: states.get(repository.id)! })); }, [accountLabel, leading, mounted, repositories]); // A catalog mid-refresh is unknown, never proof that access was removed. const catalogSettled = context.githubCatalogReady && !context.repoBusy; const revoked = useMemo( () => revokedGitHubRepositoryResources(mounted, repositories, catalogSettled), [catalogSettled, mounted, repositories], ); const attachedNames = rows .filter((row) => row.state.kind === "attached") .map((row) => row.repository.fullName); async function attach( repository: GitHubRepository, resource: RepositoryResource, confirmed = false, ) { if (inFlight.current) return; setAttachError(null); const chat = sendContext?.() ?? { blocked: null, awaitingHuman: false, extras: {} }; if (chat.blocked) { setAttachError(chat.blocked); return; } // Like a composer Send, this replaces a request the chat is waiting on. if (chat.awaitingHuman && !confirmed) { setConfirming(repository.id); return; } setConfirming(null); inFlight.current = true; setAttaching(repository.id); const input = pendingInputs.current.get(repository.id) ?? { text: repositoryUseMessage(repository), resources: [resource], ...chat.extras, clientEventId: crypto.randomUUID(), }; pendingInputs.current.set(repository.id, input); let accepted: Awaited>; try { accepted = await context.client.sendMessage(workspaceId, sessionId, input); } catch (failure) { inFlight.current = false; if (alive.current) { setAttaching(null); setAttachError( `Couldn't add ${repository.fullName} to this chat. ${userErrorText(failure, "Try again.")}`, ); } return; } pendingInputs.current.delete(repository.id); inFlight.current = false; if (!alive.current) return; setAttaching(null); setOptimistic((current) => [...current, resource]); const routing = (accepted.payload as { routing?: unknown } | null)?.routing; setOutcome( routing === "queued_for_execution" ? `Queued. The agent picks up ${repository.fullName} on its next turn.` : routing === "accepted_for_steering" ? `Sent. ${repository.fullName} replaced the request this chat was waiting on.` : null, ); // The pressed button becomes "In this chat" on the next render; keep focus // in the card, where the status line announces the change. requestAnimationFrame(() => { const focused = document.activeElement; if (!focused || focused === document.body) cardRef.current?.focus(); }); // The message is accepted; a failed re-read only delays the session view. await onConfigured?.().catch(() => {}); } async function openGitHub(installationId: number) { if (openingInstallation !== null) return; // Open the tab inside the click so it is not blocked, then mint a fresh // link: links carry ten-minute signed state. The chat stays open here. const tab = page.openTab(); setOpeningInstallation(installationId); setLinkError(null); try { const { openGitHubInstallationSettings } = await import("../../lib/github-app-connect"); await openGitHubInstallationSettings(context.client, workspaceId, installationId, (url) => tab ? tab.location.replace(url) : page.assign(url), ); onOpenedGitHub(); } catch (failure) { tab?.close(); if (alive.current) setLinkError(`Couldn't open GitHub. ${userErrorText(failure, "Try again.")}`); } finally { if (alive.current) setOpeningInstallation(null); } } return { rows, revoked, loading: repositories.length === 0 && (!context.githubCatalogReady || context.repoBusy), loadFailed: context.githubStatusFailed && !context.githubCatalogReady && repositories.length === 0, retry: () => void context.refreshGitHub(workspaceId), installations: (status?.installations ?? []).filter( (installation) => installation.lifecycle === "active", ), attaching, attachError, outcome, confirming, cancelConfirm: () => setConfirming(null), linkError, openingInstallation, statusLabel: usingRepositoriesLabel(attachedNames), accountLabel, attach: (repository: GitHubRepository, resource: RepositoryResource, confirmed = false) => void attach(repository, resource, confirmed), openGitHub: (installationId: number) => void openGitHub(installationId), }; } function GitHubRepositoryPanel({ chooser, canMessage, canManage, personal, }: { chooser: RepositoryChooser; canMessage: boolean; canManage: boolean; personal: boolean; }) { const [query, setQuery] = useState(""); const [visible, setVisible] = useState(GITHUB_CARD_PAGE_SIZE); const listRef = useRef(null); const total = chooser.rows.length; const matches = chooser.rows.filter((row) => matchesRepositorySearch(row.repository, query)); const shown = matches.slice(0, visible); const hidden = matches.length - shown.length; const available = chooser.rows.filter((row) => row.state.kind === "available"); const groups = repositoryGroups(shown, chooser.accountLabel); const confirmRow = chooser.confirming === null ? null : (chooser.rows.find((row) => row.repository.id === chooser.confirming) ?? null); // With exactly one usable repository the row's action is the card's primary one. const single = available.length === 1 && total === 1; const githubLinks = canManage ? ( <> {chooser.installations.map((installation) => ( ))} ) : null; return (
{chooser.loading ? ( chooser.loadFailed ? ( Retry } > Couldn't load this workspace's repositories. ) : ( ) ) : total === 0 && chooser.revoked.length === 0 ? (

No repositories shared yet

{canManage ? "GitHub isn't sharing any repositories with this workspace. Choose which ones it can use." : "GitHub isn't sharing any repositories with this workspace. Ask a workspace admin to choose some on GitHub."}

{canManage ? (
{chooser.installations.map((installation) => ( ))}
) : null}
) : ( <>

{canMessage ? "Use a repository in this chat" : "Repositories"}

{total} {total === 1 ? "repository" : "repositories"}
{total >= GITHUB_CARD_SEARCH_THRESHOLD ? ( { setQuery(event.target.value); setVisible(GITHUB_CARD_PAGE_SIZE); }} className="my-2 h-8 text-xs" /> ) : null} {chooser.attachError ? ( {chooser.attachError} ) : null} {chooser.revoked.length > 0 ? ( {chooser.revoked.map((resource) => ( } />} title={repositoryDisplayName(resource)} disabled disabledReason={ canManage ? "GitHub no longer shares this repository with the workspace. Choose repositories on GitHub to restore it." : "GitHub no longer shares this repository with the workspace. Ask a workspace admin to restore it." } indicator={{ kind: "unavailable", label: "Access removed" }} /> ))} ) : null} {confirmRow ? ( Cancel } > This chat is waiting for your answer. Using {confirmRow.repository.fullName} now replaces that request, like sending a message. ) : null}
{groups.map((group) => (
{group.label ? (
{group.label}
) : null} {group.note ? (

{group.note}

) : null} {group.rows.map(({ repository, state }) => ( chooser.attach(repository, resource)} /> ))}
))}
{query.trim() && matches.length === 0 ? (

No repositories match “{query.trim()}”.

) : null} {hidden > 0 ? ( ) : null}

{chooser.outcome}

{!canMessage ? "Only people who can message this chat can add a repository to it." : personal ? "Using a repository adds it to this chat only." : "Shared with everyone in this workspace. Using one adds it to this chat only."}

{githubLinks ? (

Missing one? {githubLinks}

) : (

Missing one? Ask a workspace admin to share it on GitHub.

)}
)} {chooser.linkError ? (

{chooser.linkError}

) : null}
); } // Locked while another attach is in flight: focusable (so focus is not lost) // but visibly and functionally inactive. const LOCKED = "aria-disabled:cursor-not-allowed aria-disabled:opacity-50"; function RepositoryListRow({ repository, state, explained, canMessage, primary, busy, locked, onUse, }: { repository: GitHubRepository; state: GitHubRepositoryChatState; /** The group already says why these rows can't be used here. */ explained: boolean; canMessage: boolean; primary: boolean; busy: boolean; locked: boolean; onUse: (resource: RepositoryResource) => void; }) { const meta = [repository.defaultBranch, repository.private ? null : "Public"].filter(Boolean); const titleAddon = repository.private ? ( ) : null; const common = { leading: } />, title: repository.fullName, titleAddon, meta, }; if (state.kind === "blocked") { return ( ); } if (state.kind === "attached") { return (