From 402e82ffd863f868806c7af887c84bc9e1b1f92e Mon Sep 17 00:00:00 2001 From: Pranav Singh Rajoria Date: Sun, 2 Aug 2026 20:15:21 +0530 Subject: [PATCH] fix(frontend): show why a repository never appeared, not an empty dropdown A repository connected through the GitHub App never showed up and the UI gave no reason. The backend was returning 500 (an installation created under a previous GitHub App can no longer mint a token), but every failure was swallowed: - the repository-list catch called setRepositories([]), so a 500 rendered as an empty, enabled dropdown showing only "-- Select Repository --", indistinguishable from "this installation genuinely has no repos"; - the installations catch returned { installations: [] }, routing the UI into the "no installations" branch and rendering an "Install App" button that told the user to do the thing they had just done; - nothing read the ?github=connected marker the gateway redirects back with, so a completed install only showed up on a later full reload. fetchAPI now throws an ApiError carrying the HTTP status and the gateway's machine-readable code. It still extends Error, so the ~17 callers that only read .message are unaffected. Both swallows are replaced with a visible alert plus Retry, matching the treatment ConnectedRepositories already used. A failed installations call can no longer render "Install App", and a failed config call no longer reads as "backend not configured". Repository connections with status 'revoked' render as "Reconnect required" with a reconnect action and are kept out of the picker; a missing or unrecognised status still reads as connected, so this degrades gracefully until the server-side status lands. 409 INSTALLATION_REVOKED gets plain-language copy, and any other non-2xx still produces a specific error rather than an empty dropdown. Also fixed while here: a refresh silently cleared a pending repository choice while leaving the project name filled, so "Create & Connect Project" would create a project with no repository and no warning; the choice is now preserved unless GitHub stopped offering that repository. Concurrent fetches are sequence-guarded so an older snapshot cannot land last and win. Co-Authored-By: Claude Opus 5 (1M context) --- .../app/dashboard/[workspaceId]/page.test.tsx | 316 +++++++++++++ .../src/app/dashboard/[workspaceId]/page.tsx | 430 ++++++++++++++---- frontend/src/app/dashboard/page.test.tsx | 39 +- frontend/src/app/dashboard/page.tsx | 51 ++- frontend/src/lib/api.test.ts | 120 +++++ frontend/src/lib/api.ts | 116 ++++- 6 files changed, 961 insertions(+), 111 deletions(-) create mode 100644 frontend/src/app/dashboard/[workspaceId]/page.test.tsx create mode 100644 frontend/src/lib/api.test.ts diff --git a/frontend/src/app/dashboard/[workspaceId]/page.test.tsx b/frontend/src/app/dashboard/[workspaceId]/page.test.tsx new file mode 100644 index 0000000..bcb41dc --- /dev/null +++ b/frontend/src/app/dashboard/[workspaceId]/page.test.tsx @@ -0,0 +1,316 @@ +import { fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +import { ApiError, INSTALLATION_REVOKED_CODE, INSTALLATION_REVOKED_MESSAGE } from "@/lib/api"; +import WorkspaceDetailPage from "./page"; + +const api = vi.hoisted(() => ({ + getWorkspace: vi.fn(), + getProjects: vi.fn(), + getGitHubIntegrationConfig: vi.fn(), + listGitHubInstallations: vi.fn(), + listGitHubRepositories: vi.fn(), + listLogProviderConnections: vi.fn(), + getWorkspaceUsage: vi.fn(), + getRepositoryConnection: vi.fn(), + createGitHubInstallSession: vi.fn(), +})); + +const auth = vi.hoisted(() => ({ getToken: vi.fn() })); +const nav = vi.hoisted(() => { + const replace = vi.fn(); + return { + replace, + // next/navigation returns a referentially stable router; mirror that. + router: { replace, push: vi.fn(), refresh: vi.fn() }, + searchParams: new URLSearchParams(), + }; +}); + +vi.mock("@clerk/nextjs", () => ({ + useAuth: () => ({ getToken: auth.getToken }), +})); + +vi.mock("next/navigation", () => ({ + useRouter: () => nav.router, + usePathname: () => "/dashboard/workspace-1", + useSearchParams: () => nav.searchParams, +})); + +// Only the network calls are stubbed; the pure status helpers stay real so the +// tests exercise the same branching the browser does. +vi.mock("@/lib/api", async (importOriginal) => ({ + ...(await importOriginal()), + ...api, +})); + +const workspace = { + id: "workspace-1", + name: "Production", + slug: "production", + owner_id: "user-1", + plan: "pro", + role: "owner", + created_at: "2026-07-13T00:00:00Z", + log_retention_days: 30, +}; + +const installation = { + id: "installation-1", + installation_id: 142620052, + account_login: "pranav8764", + account_type: "User", + repository_selection: "selected", + status: "active", + installed_at: "2026-07-13T00:00:00Z", +}; + +const repository = { + repository_id: 1, + owner: "pranav8764", + name: "ParkIntel", + full_name: "pranav8764/ParkIntel", + html_url: "https://github.com/pranav8764/ParkIntel", + clone_url: "https://github.com/pranav8764/ParkIntel.git", + default_branch: "main", + private: false, +}; + +/** + * `use()` reads `status`/`value` off an already-settled thenable. Next.js hands + * the route one; a bare Promise stays pending forever under jsdom. + */ +function settledParams(value: T): Promise { + return Object.assign(Promise.resolve(value), { status: "fulfilled", value }); +} + +function renderPage() { + return render(); +} + +beforeEach(() => { + vi.spyOn(console, "error").mockImplementation(() => undefined); + nav.replace.mockReset(); + nav.searchParams = new URLSearchParams(); + auth.getToken.mockResolvedValue("test-session-token"); + api.getWorkspace.mockResolvedValue({ workspace }); + api.getProjects.mockResolvedValue({ projects: [] }); + api.getGitHubIntegrationConfig.mockResolvedValue({ + install_url: "https://github.com/apps/archiatros-sre", + callback_url: "", + webhook_url: "", + configured: true, + }); + api.listGitHubInstallations.mockResolvedValue({ installations: [installation] }); + api.listGitHubRepositories.mockResolvedValue({ repositories: [repository] }); + api.listLogProviderConnections.mockResolvedValue({ connections: [] }); + api.getWorkspaceUsage.mockResolvedValue(null); + api.getRepositoryConnection.mockResolvedValue({ + repository_connection: null, + latest_ingestion_job: null, + }); +}); + +describe("workspace GitHub connection panel", () => { + it("lists only the repositories the installation grants", async () => { + renderPage(); + + const picker = await screen.findByLabelText("Repository"); + expect(picker).toHaveTextContent("pranav8764/ParkIntel"); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("surfaces a repository-list failure with a retry instead of an empty dropdown", async () => { + api.listGitHubRepositories.mockRejectedValueOnce( + new ApiError("failed to list repositories", 500, "LIST_REPOS_FAILED") + ); + renderPage(); + + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent("Repositories could not be loaded"); + expect(alert).toHaveTextContent("failed to list repositories"); + expect(alert).toHaveTextContent("HTTP 500 LIST_REPOS_FAILED"); + + // The misleading empty picker must be gone entirely. + expect(screen.queryByText("-- Select Repository --")).not.toBeInTheDocument(); + expect(screen.queryByLabelText("Repository")).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /Retry/ })); + + expect(await screen.findByLabelText("Repository")).toHaveTextContent("pranav8764/ParkIntel"); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("never offers to install the App when the installations call fails", async () => { + api.listGitHubInstallations.mockRejectedValueOnce( + new ApiError("failed to list installations", 500, "LIST_FAILED") + ); + renderPage(); + + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent("GitHub installations could not be loaded"); + expect(alert).toHaveTextContent("HTTP 500 LIST_FAILED"); + expect(screen.queryByRole("button", { name: /Install Archiatros App/ })).not.toBeInTheDocument(); + + fireEvent.click(screen.getByRole("button", { name: /Retry/ })); + + expect(await screen.findByLabelText("Repository")).toBeInTheDocument(); + }); + + it("explains a 409 INSTALLATION_REVOKED in plain language", async () => { + api.listGitHubRepositories.mockRejectedValue( + new ApiError("installation belongs to another app", 409, INSTALLATION_REVOKED_CODE) + ); + renderPage(); + + expect(await screen.findByRole("alert")).toHaveTextContent(INSTALLATION_REVOKED_MESSAGE); + }); + + it("distinguishes no installations from a failed installations call", async () => { + api.listGitHubInstallations.mockResolvedValue({ installations: [] }); + renderPage(); + + expect( + await screen.findByRole("button", { name: /Install Archiatros App/ }) + ).toBeInTheDocument(); + expect(screen.queryByRole("alert")).not.toBeInTheDocument(); + }); + + it("refetches after returning from a completed install", async () => { + nav.searchParams = new URLSearchParams("github=connected"); + renderPage(); + + expect(await screen.findByText(/GitHub App connected/)).toBeInTheDocument(); + await waitFor(() => expect(api.listGitHubInstallations).toHaveBeenCalledTimes(2)); + // The marker is cleared so a manual reload does not refetch again. + await waitFor(() => + expect(nav.replace).toHaveBeenCalledWith("/dashboard/workspace-1", { scroll: false }) + ); + }); + + it("keeps revoked repositories out of the picker", async () => { + api.listGitHubRepositories.mockResolvedValue({ + repositories: [ + repository, + { ...repository, repository_id: 2, full_name: "pranav8764/Removed", status: "revoked" }, + ], + }); + renderPage(); + + const picker = await screen.findByLabelText("Repository"); + expect(picker).toHaveTextContent("pranav8764/ParkIntel"); + expect(picker).not.toHaveTextContent("pranav8764/Removed"); + }); + + it("keeps a pending repository choice across a refresh", async () => { + renderPage(); + + const picker = (await screen.findByLabelText("Repository")) as HTMLSelectElement; + fireEvent.change(picker, { target: { value: "1" } }); + expect(screen.getByLabelText("Project Name")).toHaveValue("ParkIntel"); + + fireEvent.click(screen.getAllByRole("button", { name: /Refresh/ })[0]); + + await waitFor(() => expect(api.listGitHubRepositories).toHaveBeenCalledTimes(2)); + await waitFor(() => expect(screen.getByLabelText("Repository")).toHaveValue("1")); + }); + + it("drops a repository choice that GitHub no longer offers", async () => { + renderPage(); + + const picker = (await screen.findByLabelText("Repository")) as HTMLSelectElement; + fireEvent.change(picker, { target: { value: "1" } }); + + // Access removed in GitHub between the first load and the refresh. + api.listGitHubRepositories.mockResolvedValue({ repositories: [] }); + fireEvent.click(screen.getAllByRole("button", { name: /Refresh/ })[0]); + + expect( + await screen.findByText(/does not grant Archiatros access to any repository/) + ).toBeInTheDocument(); + expect(screen.queryByLabelText("Repository")).not.toBeInTheDocument(); + }); + + it("does not read a failed config call as an unconfigured backend", async () => { + api.getGitHubIntegrationConfig.mockRejectedValue( + new ApiError("failed to fetch GitHub configuration", 500, "GITHUB_CONFIG_FAILED") + ); + renderPage(); + + // The connection UI still works off the installations that did load. + expect(await screen.findByLabelText("Repository")).toBeInTheDocument(); + expect(screen.queryByText(/GitHub App is not configured on the backend/)).not.toBeInTheDocument(); + }); + + it("asks for a reconnect when the installation itself was revoked", async () => { + api.listGitHubInstallations.mockResolvedValue({ + installations: [{ ...installation, status: "revoked" }], + }); + renderPage(); + + const alert = await screen.findByRole("alert"); + expect(alert).toHaveTextContent("Reconnect required"); + expect(alert).toHaveTextContent("pranav8764"); + expect(screen.getByRole("button", { name: /Reconnect GitHub App/ })).toBeInTheDocument(); + // A revoked installation can never serve repositories, so it is not offered. + expect(screen.queryByLabelText("Repository")).not.toBeInTheDocument(); + expect(api.listGitHubRepositories).not.toHaveBeenCalled(); + }); +}); + +describe("project repository status", () => { + beforeEach(() => { + api.getProjects.mockResolvedValue({ + projects: [ + { + id: "project-1", + workspace_id: "workspace-1", + name: "ParkIntel", + description: "", + repo_url: "", + repo_provider: "github", + default_branch: "main", + status: "active", + indexing_status: "succeeded", + created_at: "2026-07-13T00:00:00Z", + updated_at: "2026-07-13T00:00:00Z", + }, + ], + }); + }); + + const connection = { + id: "conn-1", + workspace_id: "workspace-1", + project_id: "project-1", + full_name: "pranav8764/ParkIntel", + html_url: "https://github.com/pranav8764/ParkIntel", + selected_branch: "main", + status: "connected", + }; + + it("renders a revoked connection as Reconnect required with a reconnect action", async () => { + api.getRepositoryConnection.mockResolvedValue({ + repository_connection: { ...connection, status: "revoked" }, + latest_ingestion_job: null, + }); + renderPage(); + + expect(await screen.findByText("Reconnect required")).toBeInTheDocument(); + expect( + await screen.findByText(/Access to this repository was removed from the GitHub App/) + ).toBeInTheDocument(); + expect(screen.queryByText("Connected")).not.toBeInTheDocument(); + }); + + it("treats a missing status as connected", async () => { + api.getRepositoryConnection.mockResolvedValue({ + repository_connection: { ...connection, status: undefined }, + latest_ingestion_job: null, + }); + renderPage(); + + expect(await screen.findByText("Connected")).toBeInTheDocument(); + expect(screen.queryByText("Reconnect required")).not.toBeInTheDocument(); + }); +}); diff --git a/frontend/src/app/dashboard/[workspaceId]/page.tsx b/frontend/src/app/dashboard/[workspaceId]/page.tsx index 2c64b84..19f02c2 100644 --- a/frontend/src/app/dashboard/[workspaceId]/page.tsx +++ b/frontend/src/app/dashboard/[workspaceId]/page.tsx @@ -1,9 +1,10 @@ 'use client'; -import { use, useState, useEffect, useCallback } from 'react'; +import { use, useState, useEffect, useCallback, useRef, Suspense } from 'react'; import { useAuth } from '@clerk/nextjs'; import Link from 'next/link'; -import { Box, ArrowLeft, Loader2, GitBranch, Clock, ExternalLink, AlertCircle, RefreshCw, Send, FileCode2, ShieldCheck, Terminal, Search, GitPullRequest, LockKeyhole, Container, Clipboard, Trash2 } from 'lucide-react'; +import { usePathname, useRouter, useSearchParams } from 'next/navigation'; +import { Box, ArrowLeft, Loader2, GitBranch, Clock, ExternalLink, AlertCircle, RefreshCw, Send, FileCode2, ShieldCheck, Terminal, Search, GitPullRequest, LockKeyhole, Container, Clipboard, Trash2, CheckCircle2, Unplug } from 'lucide-react'; import { getProjects, createProject, @@ -42,10 +43,60 @@ import { scanDeploymentReadiness, generateDeploymentFiles, WorkspaceUsageSummary, - getWorkspaceUsage + getWorkspaceUsage, + isInstallationUsable, + isRevokedConnection, + repositoryConnectionHealth, + describeRequestError, + settle } from '@/lib/api'; import { ingestionBadge } from '@/lib/ingestion'; +/** Where a user reviews and changes which repositories the App can read. */ +const GITHUB_INSTALLATIONS_URL = 'https://github.com/settings/installations'; + +/** + * The recoverable-failure treatment used across the dashboard: an alert the + * user can read plus the action that retries the request. + */ +function InlineError({ + title, + message, + onRetry, + retryLabel = 'Retry', +}: { + title: string; + message: string; + onRetry: () => void; + retryLabel?: string; +}) { + return ( +
+ + + {title} + + {message} + +
+ ); +} + +/** Starts a fresh install so a revoked installation can be re-granted. */ +function ReconnectButton({ onClick, busy }: { onClick: () => void; busy: boolean }) { + return ( + + ); +} + const GithubIcon = ({ size = 20, className = "" }: { size?: number; className?: string }) => ( }) { +/** + * useSearchParams() suspends when the route is prerendered, so the reader has + * to sit under a Suspense boundary or `next build` fails the route. + */ +export default function WorkspaceDetailPage({ params }: { params: Promise<{ workspaceId: string }> }) { + return ( + + + + } + > + + + ); +} + +function WorkspaceDetail({ params }: { params: Promise<{ workspaceId: string }> }) { const { workspaceId } = use(params); const { getToken } = useAuth(); + const router = useRouter(); + const pathname = usePathname(); + const searchParams = useSearchParams(); + // The gateway 303-redirects here after a completed install. Without reading + // it the freshly connected repository only appeared on a later full reload. + const justConnected = searchParams.get('github') === 'connected'; const [workspace, setWorkspace] = useState(null); const [projects, setProjects] = useState([]); @@ -89,38 +164,113 @@ export default function WorkspaceDetail({ params }: { params: Promise<{ workspac const [startingInstall, setStartingInstall] = useState(false); const [installError, setInstallError] = useState(null); const [createError, setCreateError] = useState(null); + const [installationsError, setInstallationsError] = useState(null); + const [repoError, setRepoError] = useState(null); + const [connectionNotice, setConnectionNotice] = useState(false); const [logProviders, setLogProviders] = useState([]); const [usage, setUsage] = useState(null); + // Two fetches can be in flight at once (mount plus a post-install refetch, or + // a retry). Only the most recently started one may write state, otherwise an + // older snapshot can land last and win. + const workspaceRequest = useRef(0); + const repoRequest = useRef(0); + const fetchData = useCallback(async () => { + const request = ++workspaceRequest.current; try { const token = await getToken(); if (!token) return; + // Settled rather than swallowed: an empty result and a failed call lead + // to opposite screens, and conflating them told the user to install an + // App they had already installed. const [wsRes, projRes, configRes, instsRes, providersRes, usageRes] = await Promise.all([ getWorkspace(token, workspaceId), getProjects(token, workspaceId), - getGitHubIntegrationConfig(token).catch(e => { console.error(e); return null; }), - listGitHubInstallations(token, workspaceId).catch(e => { console.error(e); return { installations: [] }; }), + settle(getGitHubIntegrationConfig(token)), + settle(listGitHubInstallations(token, workspaceId)), listLogProviderConnections(token, workspaceId).catch(e => { console.error(e); return { connections: [] }; }), getWorkspaceUsage(token, workspaceId).catch(e => { console.error(e); return null; }) ]); + if (request !== workspaceRequest.current) return; setWorkspace(wsRes.workspace); setProjects(projRes.projects || []); - if (configRes) setGithubConfig(configRes); - if (instsRes) setInstallations(instsRes.installations || []); setLogProviders(providersRes.connections || []); setUsage(usageRes); - if (instsRes?.installations?.length > 0) { - setSelectedInst(instsRes.installations[0].id); + // A failed config call must not read as "not configured", which would + // hide the connection UI behind a backend-misconfiguration message. + if (configRes.ok) setGithubConfig(configRes.value); + else console.error('Error loading GitHub integration config:', configRes.error); + + if (instsRes.ok) { + const list = instsRes.value.installations || []; + setInstallations(list); + setInstallationsError(null); + // Selecting a revoked installation would only produce a failing + // repository call, so prefer one that can still mint a token. + const selectable = list.find(inst => isInstallationUsable(inst.status)); + setSelectedInst(selectable ? selectable.id : ''); + } else { + console.error('Error loading GitHub installations:', instsRes.error); + setInstallations([]); + setSelectedInst(''); + setInstallationsError( + describeRequestError(instsRes.error, 'GitHub installations could not be loaded.') + ); } } catch (error) { console.error('Error fetching workspace data:', error); } finally { - setLoading(false); - setLoadingConfig(false); + if (request === workspaceRequest.current) { + setLoading(false); + setLoadingConfig(false); + } + } + }, [getToken, workspaceId]); + + /** + * Reloads the repositories the selected installation grants. Callable from + * the Retry and Refresh actions, not only from the effect, so every recovery + * path on this screen is the same code. + */ + const loadRepos = useCallback(async (installationId: string) => { + const request = ++repoRequest.current; + if (!installationId) { + setRepositories([]); + setRepoError(null); + setLoadingRepos(false); + setSelectedRepoId(''); + setSelectedRepo(null); + return; + } + setLoadingRepos(true); + setRepoError(null); + try { + const token = await getToken(); + if (!token) throw new Error('Your session has expired. Sign in again.'); + const res = await listGitHubRepositories(token, workspaceId, installationId); + if (request !== repoRequest.current) return; + const next = res.repositories || []; + setRepositories(next); + // Keep an in-progress choice across a refresh, but drop it if GitHub no + // longer offers that repository — silently attaching a repo the user can + // no longer see would be worse than making them pick again. + setSelectedRepoId(current => (next.some(r => r.repository_id.toString() === current) ? current : '')); + setSelectedRepo(current => (current && next.some(r => r.repository_id === current.repository_id) ? current : null)); + } catch (e) { + if (request !== repoRequest.current) return; + console.error('Error loading repositories:', e); + // Clearing the list without an error is what rendered a failure as an + // empty, enabled dropdown. + setRepositories([]); + setSelectedRepoId(''); + setSelectedRepo(null); + setRepoError(describeRequestError(e, 'Repositories could not be loaded from GitHub.')); + } finally { + if (request === repoRequest.current) setLoadingRepos(false); } }, [getToken, workspaceId]); @@ -131,34 +281,37 @@ export default function WorkspaceDetail({ params }: { params: Promise<{ workspac fetchData(); }, [fetchData]); - // Load repositories when installation changes useEffect(() => { - async function loadRepos() { - if (!selectedInst) { - setRepositories([]); - return; - } - setLoadingRepos(true); - try { - const token = await getToken(); - if (!token) return; - const res = await listGitHubRepositories(token, workspaceId, selectedInst); - setRepositories(res.repositories || []); - setSelectedRepoId(''); - setSelectedRepo(null); - } catch (e) { - console.error('Error loading repositories:', e); - setRepositories([]); - } finally { - setLoadingRepos(false); - } - } - loadRepos(); - }, [selectedInst, getToken, workspaceId]); + // `connectionNotice` latches the one-shot: without it a router object that + // is not referentially stable would refetch on every render. + if (!justConnected || connectionNotice) return; + // Deferred so the state updates land outside the effect body, matching the + // pattern the rest of this app uses to satisfy react-hooks/set-state-in-effect. + const timer = setTimeout(() => { + setConnectionNotice(true); + void fetchData(); + // Drop the marker so a later reload does not refetch again. + router.replace(pathname, { scroll: false }); + }, 0); + return () => clearTimeout(timer); + }, [justConnected, connectionNotice, fetchData, router, pathname]); + + // Reload repositories whenever the selected installation changes. + useEffect(() => { + // eslint-disable-next-line react-hooks/set-state-in-effect + loadRepos(selectedInst); + }, [selectedInst, loadRepos]); + + const refreshRepos = useCallback(() => { void loadRepos(selectedInst); }, [loadRepos, selectedInst]); + + const usableInstallations = installations.filter(inst => isInstallationUsable(inst.status)); + const revokedInstallations = installations.filter(inst => !isInstallationUsable(inst.status)); + // Only repositories the App still grants access to may be attached. + const selectableRepositories = repositories.filter(repo => !isRevokedConnection(repo.status)); const handleSelectRepo = (repoIdStr: string) => { setSelectedRepoId(repoIdStr); - const repo = repositories.find(r => r.repository_id.toString() === repoIdStr); + const repo = selectableRepositories.find(r => r.repository_id.toString() === repoIdStr); setSelectedRepo(repo || null); if (repo) { setNewProjectName(repo.name); @@ -205,7 +358,7 @@ export default function WorkspaceDetail({ params }: { params: Promise<{ workspac console.error('Error attaching repository:', attachErr); setCreateError( `Project "${newProj.name}" was created, but the repository could not be attached: ` + - (attachErr instanceof Error ? attachErr.message : 'unknown error') + describeRequestError(attachErr, 'unknown error') ); } } @@ -220,7 +373,7 @@ export default function WorkspaceDetail({ params }: { params: Promise<{ workspac setProjects(projRes.projects || []); } catch (error) { console.error('Error creating project:', error); - setCreateError(error instanceof Error ? error.message : 'Project could not be created.'); + setCreateError(describeRequestError(error, 'Project could not be created.')); } finally { setIsCreating(false); } @@ -252,7 +405,7 @@ export default function WorkspaceDetail({ params }: { params: Promise<{ workspac const session = await createGitHubInstallSession(token, workspaceId); window.location.assign(session.install_url); } catch (error) { - setInstallError(error instanceof Error ? error.message : 'Failed to prepare GitHub installation.'); + setInstallError(describeRequestError(error, 'Failed to prepare GitHub installation.')); setStartingInstall(false); } } @@ -291,11 +444,26 @@ export default function WorkspaceDetail({ params }: { params: Promise<{ workspac

Connect GitHub repository to initialize AI context.

+ {connectionNotice && ( +

+ + GitHub App connected. The repositories you granted are listed below. +

+ )} + {!loadingConfig && githubConfig && !githubConfig.configured ? (
GitHub App is not configured on the backend. Please check your settings.
+ ) : installationsError ? ( + /* A failed call is not an absent installation: never offer to + install an App the user may already have installed. */ + ) : installations.length === 0 ? (

Attach Archiatros GitHub App to list your account/org repositories.

@@ -316,52 +484,116 @@ export default function WorkspaceDetail({ params }: { params: Promise<{ workspac ) : (
{createError && ( -
+
{createError}
)} -
- - -
+ {revokedInstallations.length > 0 && ( +
+ + + Reconnect required + + + {revokedInstallations.map(inst => inst.account_login).join(', ')} no longer grants Archiatros access. + Reconnect the GitHub App to use these repositories again. + + + {installError && {installError}} +
+ )} -
- - {loadingRepos ? ( -
- Loading repositories... + {usableInstallations.length > 0 && ( + <> +
+ +
- ) : ( - - )} -
+ +
+ + {loadingRepos ? ( +
+ Loading repositories... +
+ ) : repoError ? ( + + ) : selectableRepositories.length === 0 ? ( +
+ + This installation does not grant Archiatros access to any repository yet. + Choose repositories in GitHub, then refresh. + + +
+ ) : ( + <> + + + Only repositories you granted the App appear here. + + + + )} +
+ + )}
- +
- +