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