diff --git a/src/cognit/mcp/assets/index.html b/src/cognit/mcp/assets/index.html index bf88a94..27dc3eb 100644 --- a/src/cognit/mcp/assets/index.html +++ b/src/cognit/mcp/assets/index.html @@ -9,7 +9,7 @@ - +
diff --git a/src/cognit/mcp/assets/quiz_mcp.js b/src/cognit/mcp/assets/quiz_mcp.js index 565ca4f..41e2565 100644 --- a/src/cognit/mcp/assets/quiz_mcp.js +++ b/src/cognit/mcp/assets/quiz_mcp.js @@ -45,6 +45,17 @@ let view = null; // waiting | answering | results | published // client-side answer to reveal.) let examMode = localStorage.getItem("cognit.examMode") === "1"; +// GitHub deep-linking for anchors: repo blob base derived from the PR url, plus the +// PR's head branch (both injected into by the server). Empty in tests/demos. +const PR_URL = document.body.dataset.prUrl || ""; +const BRANCH = document.body.dataset.branch || ""; +const REPO_URL = PR_URL.replace(/\/pull\/\d+.*$/, ""); // → https://github.com/owner/repo +function githubFileUrl(path, startLine, endLine) { + if (!REPO_URL || !BRANCH) return null; + const frag = startLine === endLine ? `#L${startLine}` : `#L${startLine}-L${endLine}`; + return `${REPO_URL}/blob/${BRANCH}/${path}${frag}`; +} + // ── small DOM helper ──────────────────────────────────────────── function el(tag, attrs = {}, children = []) { const node = document.createElement(tag); @@ -359,15 +370,35 @@ function renderAnchor(q) { if (!a || !a.path) return null; const range = a.start_line === a.end_line ? `${a.start_line}` : `${a.start_line}–${a.end_line}`; const body = el("div", { class: "codepanel__body" }); - const panel = el("div", { class: "codepanel codepanel--inline" }, [ - el("div", { class: "codepanel__summary" }, [ - el("span", { class: "codepanel__file", text: a.path }), + // Default to plain text; upgrade to a GitHub link only once we've confirmed the path + // is actually one of the PR's changed files — so a hallucinated/non-diff anchor path + // never produces a broken (404) link. The link targets the file on the PR's branch. + const fileEl = el("span", { class: "codepanel__file", text: a.path }); + const href = githubFileUrl(a.path, a.start_line, a.end_line); + if (href) { + ensureChangedFiles().then((files) => { + if (!files.some((f) => fileMatchesAnchor(f, a.path))) return; // not in the diff → no link + const link = el("a", { + class: "codepanel__file", href, target: "_blank", rel: "noopener", + title: `Open ${a.path} on GitHub`, text: a.path, + onclick: (e) => e.stopPropagation(), // follow the link without toggling the panel + }); + fileEl.replaceWith(link); + }); + } + const details = el("details", { class: "codepanel" }, [ // collapsible, closed by default + el("summary", { class: "codepanel__summary" }, [ + el("span", { class: "codepanel__icon", "aria-hidden": "true", text: "▸" }), + fileEl, el("span", { class: "codepanel__lines", text: `:${range}` }), ]), body, ]); - loadHunk(a.path, a, body); // fetch + render immediately; inline, no click needed - return panel; + let loaded = false; + details.addEventListener("toggle", () => { + if (details.open && !loaded) { loaded = true; loadHunk(a.path, a, body); } // fetch on first expand + }); + return details; } const DETERMINISTIC = new Set(["mcq", "tf", "mermaid"]); diff --git a/src/cognit/mcp/assets/styles.css b/src/cognit/mcp/assets/styles.css index cd5cc86..8d89c93 100644 --- a/src/cognit/mcp/assets/styles.css +++ b/src/cognit/mcp/assets/styles.css @@ -819,6 +819,7 @@ textarea.open:focus { border-color: var(--blue); outline: none; box-shadow: 0 0 @media (prefers-reduced-motion: reduce) { .gen__spinner { animation: none; } .term::after { animation: none; } + .codepanel__icon { transition: none; } } /* inline code context (anchors) ──────────────────────────────────── */ @@ -833,11 +834,18 @@ textarea.open:focus { border-color: var(--blue); outline: none; box-shadow: 0 0 align-items: baseline; gap: 4px; padding: 8px 12px; + cursor: pointer; font-family: var(--mono); font-size: 12px; color: var(--fg-mute); -} -.codepanel__file { color: var(--fg); font-weight: 500; } + list-style: none; + user-select: none; +} +.codepanel__summary::-webkit-details-marker { display: none; } +.codepanel__icon { color: var(--fg-faint); transition: transform 0.12s ease; display: inline-block; } +.codepanel[open] > .codepanel__summary .codepanel__icon { transform: rotate(90deg); } +.codepanel__file { color: var(--fg); font-weight: 500; text-decoration: none; } +a.codepanel__file:hover { color: var(--blue); text-decoration: underline; } .codepanel__lines { color: var(--fg-faint); } .codepanel__body { border-top: 1px solid var(--border-mute); } .codepanel pre.diff { diff --git a/src/cognit/mcp/launch.py b/src/cognit/mcp/launch.py index 1edc3b9..26d6027 100644 --- a/src/cognit/mcp/launch.py +++ b/src/cognit/mcp/launch.py @@ -76,6 +76,7 @@ def build_launch_spec( "COGNIT_HTTP_PORT": str(port), "COGNIT_SNAPSHOT_PATH": str(snapshot_path), "COGNIT_REPO_ROOT": str(repo_root), + "COGNIT_BRANCH": branch, # so the browser can deep-link anchors to files on this branch } argv = [ "claude", diff --git a/src/cognit/mcp/server.py b/src/cognit/mcp/server.py index da32168..d165315 100644 --- a/src/cognit/mcp/server.py +++ b/src/cognit/mcp/server.py @@ -161,7 +161,13 @@ def file_diff(path: str) -> str: def _start_web( - state: QuizState, *, llm: LLMClient, pr_url: str, port: int, diffs: _DiffProvider + state: QuizState, + *, + llm: LLMClient, + pr_url: str, + port: int, + diffs: _DiffProvider, + branch: str, ) -> None: app = build_web_app( state, @@ -170,6 +176,7 @@ def _start_web( diff_section=lambda path: do_file_diff(path, diffs.sections()), changed_files=lambda: list(diffs.sections()), pr_url=pr_url, + branch=branch, ) url = f"http://127.0.0.1:{port}" @@ -213,6 +220,7 @@ def main() -> None: pr_number = int(os.environ["COGNIT_PR_NUMBER"]) port = int(os.environ["COGNIT_HTTP_PORT"]) snapshot = Path(os.environ["COGNIT_SNAPSHOT_PATH"]) + branch = os.environ.get("COGNIT_BRANCH", "") state = QuizState(pr_number=pr_number, snapshot_path=snapshot) # Best-effort early collision detection: probe the port so an obvious conflict @@ -229,7 +237,7 @@ def main() -> None: threading.Thread( target=_start_web, args=(state,), - kwargs={"llm": llm, "pr_url": pr_url, "port": port, "diffs": diffs}, + kwargs={"llm": llm, "pr_url": pr_url, "port": port, "diffs": diffs, "branch": branch}, daemon=True, ).start() _build_mcp(state, llm, diffs).run() diff --git a/src/cognit/mcp/web.py b/src/cognit/mcp/web.py index 3350741..e9b61e8 100644 --- a/src/cognit/mcp/web.py +++ b/src/cognit/mcp/web.py @@ -41,6 +41,7 @@ def build_web_app( diff_section: Callable[[str], str] | None = None, changed_files: Callable[[], list[str]] | None = None, pr_url: str = "", + branch: str = "", ) -> FastAPI: """Browser projection over `state`. @@ -56,11 +57,13 @@ def build_web_app( app = FastAPI() app.mount("/static", StaticFiles(directory=str(_ASSETS_DIR)), name="static") pr_url_attr = _html.escape(pr_url, quote=True) + branch_attr = _html.escape(branch, quote=True) index_html = ( (_ASSETS_DIR / "index.html") .read_text() .replace("__PR__", str(state.pr_number)) .replace("__PR_URL_ATTR__", pr_url_attr) + .replace("__BRANCH_ATTR__", branch_attr) ) @app.get("/state") diff --git a/tests/mcp/test_launch.py b/tests/mcp/test_launch.py index 6616081..866a1f8 100644 --- a/tests/mcp/test_launch.py +++ b/tests/mcp/test_launch.py @@ -29,6 +29,7 @@ def test_launch_spec_has_confined_flags(tmp_path: Path): assert spec.env["COGNIT_HTTP_PORT"] == "8123" assert spec.env["COGNIT_SNAPSHOT_PATH"] == str(tmp_path / "s.json") assert spec.env["COGNIT_REPO_ROOT"] == str(tmp_path) + assert spec.env["COGNIT_BRANCH"] == "feat/x" def test_mcp_config_points_at_cognit_module(tmp_path: Path): diff --git a/tests/mcp/test_web.py b/tests/mcp/test_web.py index 753cfa0..cd97dc2 100644 --- a/tests/mcp/test_web.py +++ b/tests/mcp/test_web.py @@ -131,6 +131,33 @@ def fake_grade() -> Results: t.join(timeout=2) +def test_index_includes_branch_for_github_links(tmp_path: Path) -> None: + state = QuizState(pr_number=7, snapshot_path=tmp_path / "s.json") + state.set_quiz( + Quiz( + pr_number=7, + questions=[ + MCQQuestion(id="q1", prompt="p", options=["A", "B"], answer="A", explanation="x") + ], + ) + ) + app = build_web_app( + state, + post_comment=lambda b: "http://c/1", + branch="feat/cool-thing", + pr_url="https://github.com/o/r/pull/7", + ) + port = _free_port() + server, t = _serve(app, port) + try: + html = httpx.get(f"http://127.0.0.1:{port}/").text + assert 'data-branch="feat/cool-thing"' in html + assert "__BRANCH_ATTR__" not in html # placeholder templated out + finally: + server.should_exit = True + t.join(timeout=2) + + def test_grade_endpoint_501_when_unavailable(client): # the default fixture app wires no `grade` callable c, _state, _ = client