diff --git a/README.md b/README.md index f605ceaae6..0e5e0c451b 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,6 @@ See [Tuning your reviews](https://loopover.ai/docs/tuning) for the full flag, se | Miner package | [`@loopover/miner`](packages/loopover-miner/README.md) — local foundation CLI for the autonomous miner runtime | | API | [API browser](https://loopover.ai/api) and [OpenAPI JSON](https://api.loopover.ai/openapi.json) | | GitHub App | [Setup docs](https://loopover.ai/docs/github-app) — self-hosting is the only currently available path | -| Browser extension | [Extension page](https://loopover.ai/extension) | ## MCP Install diff --git a/apps/loopover-ui/content/docs/maintainer-install-trust.mdx b/apps/loopover-ui/content/docs/maintainer-install-trust.mdx index 8dba6c6297..34df960244 100644 --- a/apps/loopover-ui/content/docs/maintainer-install-trust.mdx +++ b/apps/loopover-ui/content/docs/maintainer-install-trust.mdx @@ -114,27 +114,6 @@ Preview every public output path before enabling it. The same public-safety boun For the full boundary, keep [Privacy & security](/docs/privacy-security) as the source of truth. For AI-written text, use the [AI summaries policy](/docs/ai-summaries) before posting generated copy. -## Browser extension states - -The extension is a maintainer review aid. It should make state and scope obvious instead of implying that a contributor or public viewer can see private packets. - - no repo context, no private packet -Signed in without repo scope - -> prompt for authorized GitHub App installation or browser session -Authorized maintainer on PR page - -> public-safe PR panel + private maintainer blockers -Unauthorized viewer or stale session - -> public-safe state only, no private blockers -API unavailable or stale data - -> degraded state with retry guidance, never guessed scores`} -/> - -UI, frontend, browser-extension, or GitHub-overlay pull requests need maintainer-reviewable screenshots or a short recording that shows the relevant states. A checked template box is not enough evidence. - ## Audit expectations A healthy installation should leave an audit trail that maintainers can reason about without exposing repository source or contributor secrets. @@ -142,7 +121,6 @@ A healthy installation should leave an audit trail that maintainers can reason a - Installation health shows permissions and webhook readiness. - Settings preview shows the exact public copy before posting. - Command previews identify the maintainer action that produced them. -- Extension sessions are scoped to authorized review context. - Failures are inspectable through diagnostics instead of silent public output. ## CI checks are not reviewer approval @@ -156,7 +134,7 @@ If the repo enables **LoopOver Orb Review Agent**, document which blockers are e Maintainers should request changes or close PRs that misuse LoopOver output. The tool is a contribution operating layer, not a guarantee that work deserves merge. - Reject PRs with no linked issue, no reproduction, or no validation evidence. -- Reject UI or extension PRs that omit screenshots or recordings of changed flows. +- Reject UI PRs that omit screenshots or recordings of changed flows. - Reject copied snippets that leak private scoring, reward, trust, wallet, or hotkey text. - Reject duplicated work when the PR does not explain overlap and maintainer value. - Reject generated broad rewrites that are not scoped to the issue acceptance criteria. @@ -164,4 +142,4 @@ Maintainers should request changes or close PRs that misuse LoopOver output. The ## Next docs -Continue with [Maintainer workflow](/docs/maintainer-workflow) for daily PR review, [Troubleshooting](/docs/troubleshooting) for install diagnostics, and [Browser extension](/extension) for overlay behavior. +Continue with [Maintainer workflow](/docs/maintainer-workflow) for daily PR review and [Troubleshooting](/docs/troubleshooting) for install diagnostics. diff --git a/apps/loopover-ui/public/sitemap.xml b/apps/loopover-ui/public/sitemap.xml index da9be0522c..3e368c5a3e 100644 --- a/apps/loopover-ui/public/sitemap.xml +++ b/apps/loopover-ui/public/sitemap.xml @@ -4,7 +4,6 @@ https://loopover.ai/miners https://loopover.ai/maintainers https://loopover.ai/agents - https://loopover.ai/extension https://loopover.ai/roadmap https://loopover.ai/docs https://loopover.ai/docs/beta-onboarding diff --git a/apps/loopover-ui/src/components/site/command-palette.tsx b/apps/loopover-ui/src/components/site/command-palette.tsx index ebb7c6f962..4f2c63878c 100644 --- a/apps/loopover-ui/src/components/site/command-palette.tsx +++ b/apps/loopover-ui/src/components/site/command-palette.tsx @@ -66,7 +66,6 @@ const DEFAULT_ITEMS: PaletteItem[] = [ { label: "Privacy & security", to: "/docs/privacy-security", group: "Docs" }, { label: "Troubleshooting", to: "/docs/troubleshooting", group: "Docs" }, { label: "API reference", to: "/api", group: "Reference" }, - { label: "Browser extension", to: "/extension", group: "Reference" }, { label: "Changelog", to: "/changelog", group: "Reference" }, { label: "Roadmap", to: "/roadmap", group: "Reference" }, ]; diff --git a/apps/loopover-ui/src/components/site/site-footer.tsx b/apps/loopover-ui/src/components/site/site-footer.tsx index 4247976a75..c2ca8a4d31 100644 --- a/apps/loopover-ui/src/components/site/site-footer.tsx +++ b/apps/loopover-ui/src/components/site/site-footer.tsx @@ -15,7 +15,6 @@ const cols = [ { to: "/miners", label: "For miners" }, { to: "/maintainers", label: "For maintainers" }, { to: "/agents", label: "For coding agents" }, - { to: "/extension", label: "Browser extension" }, ], }, { diff --git a/apps/loopover-ui/src/routeTree.gen.ts b/apps/loopover-ui/src/routeTree.gen.ts index 77d7a40ec0..1409d48c93 100644 --- a/apps/loopover-ui/src/routeTree.gen.ts +++ b/apps/loopover-ui/src/routeTree.gen.ts @@ -14,7 +14,6 @@ import { Route as MinersRouteImport } from './routes/miners' import { Route as MaintainersRouteImport } from './routes/maintainers' import { Route as InstallRouteImport } from './routes/install' import { Route as FairnessRouteImport } from './routes/fairness' -import { Route as ExtensionRouteImport } from './routes/extension' import { Route as DocsRouteImport } from './routes/docs' import { Route as ChangelogRouteImport } from './routes/changelog' import { Route as AppRouteImport } from './routes/app' @@ -69,11 +68,6 @@ const FairnessRoute = FairnessRouteImport.update({ path: '/fairness', getParentRoute: () => rootRouteImport, } as any) -const ExtensionRoute = ExtensionRouteImport.update({ - id: '/extension', - path: '/extension', - getParentRoute: () => rootRouteImport, -} as any) const DocsRoute = DocsRouteImport.update({ id: '/docs', path: '/docs', @@ -223,7 +217,6 @@ export interface FileRoutesByFullPath { '/app': typeof AppRouteWithChildren '/changelog': typeof ChangelogRoute '/docs': typeof DocsRouteWithChildren - '/extension': typeof ExtensionRoute '/fairness': typeof FairnessRoute '/install': typeof InstallRouteWithChildren '/maintainers': typeof MaintainersRoute @@ -256,7 +249,6 @@ export interface FileRoutesByTo { '/': typeof IndexRoute '/agents': typeof AgentsRoute '/changelog': typeof ChangelogRoute - '/extension': typeof ExtensionRoute '/fairness': typeof FairnessRoute '/maintainers': typeof MaintainersRoute '/miners': typeof MinersRoute @@ -292,7 +284,6 @@ export interface FileRoutesById { '/app': typeof AppRouteWithChildren '/changelog': typeof ChangelogRoute '/docs': typeof DocsRouteWithChildren - '/extension': typeof ExtensionRoute '/fairness': typeof FairnessRoute '/install': typeof InstallRouteWithChildren '/maintainers': typeof MaintainersRoute @@ -330,7 +321,6 @@ export interface FileRouteTypes { | '/app' | '/changelog' | '/docs' - | '/extension' | '/fairness' | '/install' | '/maintainers' @@ -363,7 +353,6 @@ export interface FileRouteTypes { | '/' | '/agents' | '/changelog' - | '/extension' | '/fairness' | '/maintainers' | '/miners' @@ -398,7 +387,6 @@ export interface FileRouteTypes { | '/app' | '/changelog' | '/docs' - | '/extension' | '/fairness' | '/install' | '/maintainers' @@ -435,7 +423,6 @@ export interface RootRouteChildren { AppRoute: typeof AppRouteWithChildren ChangelogRoute: typeof ChangelogRoute DocsRoute: typeof DocsRouteWithChildren - ExtensionRoute: typeof ExtensionRoute FairnessRoute: typeof FairnessRoute InstallRoute: typeof InstallRouteWithChildren MaintainersRoute: typeof MaintainersRoute @@ -481,13 +468,6 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof FairnessRouteImport parentRoute: typeof rootRouteImport } - '/extension': { - id: '/extension' - path: '/extension' - fullPath: '/extension' - preLoaderRoute: typeof ExtensionRouteImport - parentRoute: typeof rootRouteImport - } '/docs': { id: '/docs' path: '/docs' @@ -769,7 +749,6 @@ const rootRouteChildren: RootRouteChildren = { AppRoute: AppRouteWithChildren, ChangelogRoute: ChangelogRoute, DocsRoute: DocsRouteWithChildren, - ExtensionRoute: ExtensionRoute, FairnessRoute: FairnessRoute, InstallRoute: InstallRouteWithChildren, MaintainersRoute: MaintainersRoute, diff --git a/apps/loopover-ui/src/routes/extension.test.tsx b/apps/loopover-ui/src/routes/extension.test.tsx deleted file mode 100644 index 959d0fa0bb..0000000000 --- a/apps/loopover-ui/src/routes/extension.test.tsx +++ /dev/null @@ -1,98 +0,0 @@ -import { fireEvent, render, screen, waitFor } from "@testing-library/react"; -import { beforeEach, describe, expect, it, vi } from "vitest"; - -// #6824: create-token used to swallow clipboard rejections and still toast success. Mock the toast -// and api layers so the regression can assert the exact success/failure branch without a live API. -const { success, error } = vi.hoisted(() => ({ success: vi.fn(), error: vi.fn() })); -vi.mock("sonner", () => ({ toast: { success, error } })); - -const { apiFetch } = vi.hoisted(() => ({ apiFetch: vi.fn() })); -vi.mock("@/lib/api/request", () => ({ - apiFetch: (...args: unknown[]) => apiFetch(...args), -})); -vi.mock("@/lib/api/origin", () => ({ - getApiOrigin: () => "https://api.example.test", -})); - -import { ExtensionTokenButton } from "./extension"; - -const TOKEN = "ext_test_token_6824"; - -function mockClipboard(writeText: () => Promise) { - const spy = vi.fn(writeText); - Object.defineProperty(navigator, "clipboard", { - value: { writeText: spy }, - configurable: true, - writable: true, - }); - return spy; -} - -describe("ExtensionTokenButton clipboard honesty (#6824)", () => { - beforeEach(() => { - vi.clearAllMocks(); - apiFetch.mockResolvedValue({ - ok: true, - data: { token: TOKEN }, - status: 200, - durationMs: 1, - message: undefined, - }); - }); - - it("toasts success only when the auto-copy after create actually writes", async () => { - const writeText = mockClipboard(() => Promise.resolve()); - render(); - - fireEvent.click(screen.getByRole("button", { name: "Create extension token" })); - - await waitFor(() => expect(writeText).toHaveBeenCalledWith(TOKEN)); - await waitFor(() => - expect(success).toHaveBeenCalledWith("Extension token created", { - description: "Copied to clipboard.", - }), - ); - expect(error).not.toHaveBeenCalled(); - expect(screen.getByTestId("extension-token-value").textContent).toBe(TOKEN); - expect(screen.getByRole("button", { name: "Copy extension token" })).toBeTruthy(); - }); - - it("does not claim clipboard success when writeText rejects after create", async () => { - // The exact gap: `.catch(() => undefined)` discarded the rejection, then success still fired. - mockClipboard(() => Promise.reject(new Error("denied"))); - render(); - - fireEvent.click(screen.getByRole("button", { name: "Create extension token" })); - - await waitFor(() => - expect(error).toHaveBeenCalledWith("Couldn't copy extension token", expect.any(Object)), - ); - expect(success).not.toHaveBeenCalled(); - // Token must still render so the user can recover via the manual copy button. - expect(screen.getByTestId("extension-token-value").textContent).toBe(TOKEN); - expect(screen.getByRole("button", { name: "Copy extension token" })).toBeTruthy(); - }); - - it("manual Copy button copies the displayed token and reports failure honestly", async () => { - mockClipboard(() => Promise.resolve()); - render(); - fireEvent.click(screen.getByRole("button", { name: "Create extension token" })); - await waitFor(() => screen.getByRole("button", { name: "Copy extension token" })); - vi.clearAllMocks(); - - const writeText = mockClipboard(() => Promise.resolve()); - fireEvent.click(screen.getByRole("button", { name: "Copy extension token" })); - await waitFor(() => expect(writeText).toHaveBeenCalledWith(TOKEN)); - await waitFor(() => - expect(success).toHaveBeenCalledWith("Extension token copied", expect.any(Object)), - ); - - vi.clearAllMocks(); - mockClipboard(() => Promise.reject(new Error("denied"))); - fireEvent.click(screen.getByRole("button", { name: "Copy extension token" })); - await waitFor(() => - expect(error).toHaveBeenCalledWith("Couldn't copy extension token", expect.any(Object)), - ); - expect(success).not.toHaveBeenCalled(); - }); -}); diff --git a/apps/loopover-ui/src/routes/extension.tsx b/apps/loopover-ui/src/routes/extension.tsx deleted file mode 100644 index 1d018c4fd3..0000000000 --- a/apps/loopover-ui/src/routes/extension.tsx +++ /dev/null @@ -1,338 +0,0 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { Copy, Download, Lock, Shield, GitPullRequestArrow } from "lucide-react"; -import { useState } from "react"; -import { motion } from "motion/react"; -import { toast } from "sonner"; - -import { Section, Eyebrow, Callout } from "@/components/site/primitives"; -import { BoundaryBadge, StatusPill } from "@/components/site/control-primitives"; -import { Reveal } from "@/components/site/reveal"; -import { apiFetch } from "@/lib/api/request"; -import { getApiOrigin } from "@/lib/api/origin"; -import { cn } from "@/lib/utils"; - -/** Write `text` to the clipboard or throw when the Clipboard API is missing / rejects. */ -async function writeClipboardText(text: string): Promise { - if (typeof navigator === "undefined" || !navigator.clipboard?.writeText) { - throw new Error("Clipboard API unavailable"); - } - await navigator.clipboard.writeText(text); -} - -export const Route = createFileRoute("/extension")({ - head: () => ({ - meta: [ - { title: "Browser extension — LoopOver" }, - { - name: "description", - content: - "Private maintainer overlays on github.com. Never shown to PR authors or the public — reviewability context where you already work.", - }, - { property: "og:title", content: "LoopOver · maintainer browser extension" }, - { - property: "og:description", - content: - "A private overlay on GitHub PR pages that surfaces miner context, scoreability projections, and reviewability hints.", - }, - { property: "og:url", content: "/extension" }, - ], - links: [{ rel: "canonical", href: "/extension" }], - }), - component: ExtensionPage, -}); - -function ExtensionPage() { - return ( - <> -
-
-
- Self-hosted package -

- Maintainer overlays on the GitHub you already use. -

-

- The LoopOver browser extension surfaces miner context, scoreability projections, and - reviewability hints on PR pages — visible only to the maintainer running it. Nothing - is injected into the page or shown to the PR author. -

- -
- Maintainer-only - - Live API integration -
-
- - - - -
-
- -
-
- {[ - { - icon: , - title: "Never shown publicly", - body: "Overlays render in your browser only. The DOM injection never reaches the PR author or other visitors.", - }, - { - icon: , - title: "No source upload", - body: "The extension uses the existing private API. It reads metadata you already have permission to see — nothing more.", - }, - { - icon: , - title: "Lives where you work", - body: "No tab-switching to a dashboard. Context appears next to the diff, the review tab, and the files-changed view.", - }, - ].map((f) => ( -
-
- {f.icon} -
-

{f.title}

-

{f.body}

-
- ))} -
-
- -
- - Privacy posture. The extension does not read the PR diff, post comments, - or open issues. It calls the same private LoopOver API endpoints you already use, then - renders the response in your local DOM. Its permission boundary is storage, GitHub PR - pages, and the configured LoopOver API origin. Extension tokens are scoped to pull - context, stored in browser local storage rather than sync storage, and cleared on logout, - expiry, or revoked-session responses. GitHub personal access tokens are rejected. - -
- - ); -} - -const EXTENSION_PANELS = [ - { - label: "Reviewability", - badge: "live", - rows: [ - { k: "endpoint", v: "/v1/extension/pull-context" }, - { k: "auth", v: "extension session" }, - { k: "boundary", v: "private" }, - ], - }, - { - label: "Contributor", - badge: "cached", - rows: [ - { k: "profile", v: "backend" }, - { k: "history", v: "read-only" }, - { k: "public post", v: "never" }, - ], - }, - { - label: "Install", - badge: "manual", - rows: [ - { k: "package", v: "Manifest V3" }, - { k: "store", v: "out of scope" }, - { k: "source", v: "bundled" }, - ], - }, -] as const; - -// Exported for unit tests — the create-token path used to swallow clipboard failures and report -// false success (#6824). Keeping the button extractable lets the regression suite drive it without -// mounting the full marketing page / router. -export function ExtensionTokenButton() { - const [token, setToken] = useState(""); - const [busy, setBusy] = useState(false); - - const copyToken = async (value: string, { created }: { created: boolean }) => { - try { - await writeClipboardText(value); - toast.success(created ? "Extension token created" : "Extension token copied", { - description: created - ? "Copied to clipboard." - : "The token is ready to paste into the extension.", - }); - } catch { - // Match the app.runs / CodeBlock copy-fail channel: never claim success when writeText rejected. - toast.error("Couldn't copy extension token", { - description: created - ? "Token was created — use the Copy button next to it, or select the token and copy manually." - : "Select the token and copy it manually.", - }); - } - }; - - return ( -
- - {token && ( -
- - {token} - - -
- )} -
- ); -} - -function OverlayDemo() { - const [active, setActive] = useState(0); - return ( -
- {/* faux github header */} -
-
- - - - - github.com/jsonbored/loopover/pull/1218 - -
-
- {/* faux PR body */} -
-
- - Tighten queue-cap copy - - open - -
-
-
- jsonbored wants to merge 1 commit into main from feat/queue-copy -
-
-
+12
-
−4
-
1 file
-
-
-
- checks · 4 passing · 0 failing -
-
- - {/* loopover overlay panel */} - -
-
- - G - - loopover overlay -
- - you only - -
-
- {EXTENSION_PANELS.map((p, i) => ( - - ))} -
- -
- - {EXTENSION_PANELS[active].label} - - - {EXTENSION_PANELS[active].badge} - -
-
- {EXTENSION_PANELS[active].rows.map((r) => ( -
-
{r.k}
-
{r.v}
-
- ))} -
-
-
-
-
-
- ); -} diff --git a/apps/loopover-ui/src/routes/roadmap.tsx b/apps/loopover-ui/src/routes/roadmap.tsx index b01d80b82f..1b1accfd08 100644 --- a/apps/loopover-ui/src/routes/roadmap.tsx +++ b/apps/loopover-ui/src/routes/roadmap.tsx @@ -55,11 +55,11 @@ const ROADMAP_ITEMS: Array<{ "MCP doctor/status/init-client clarity, last-good decision packs, recommendation-change explanations, and command-copy flows for miners.", }, { - title: "Phase 2: maintainer trust and browser extension", + title: "Phase 2: maintainer trust", status: "planned", issue: 235, description: - "Maintainer trust checklist, install health next actions, screenshot-backed extension states, and private/public rendering checks.", + "Maintainer trust checklist, install health next actions, and private/public rendering checks.", }, { title: "Phase 3: repo owner intake console", @@ -88,7 +88,7 @@ const ROADMAP_ITEMS: Array<{ const BUILT_TITLES = new Set([ "Phase 0: stabilize while shipping", "Phase 1: miner command center", - "Phase 2: maintainer trust and browser extension", + "Phase 2: maintainer trust", "Phase 3: repo owner intake console", "Phase 4: adoption analytics and launch system", "Phase 5: ecosystem distribution", @@ -96,9 +96,9 @@ const BUILT_TITLES = new Set([ const LINK_MAP: Record = { "Phase 1: miner command center": { to: "/app/miner", label: "Open miner dashboard" }, - "Phase 2: maintainer trust and browser extension": { - to: "/extension", - label: "Open extension page", + "Phase 2: maintainer trust": { + to: "/app/maintainer", + label: "Open maintainer console", }, "Phase 3: repo owner intake console": { to: "/app/repos", label: "Open repos console" }, "Phase 4: adoption analytics and launch system": {