diff --git a/pygitweb/board_refs.py b/pygitweb/board_refs.py
new file mode 100644
index 0000000..8e8d5d8
--- /dev/null
+++ b/pygitweb/board_refs.py
@@ -0,0 +1,154 @@
+"""Client-side reference manifest for board task @ / # lookups."""
+
+from __future__ import annotations
+
+import os
+from datetime import UTC, datetime, timedelta, timezone
+from typing import TypedDict
+from urllib.parse import quote
+
+from pygit2 import Oid, Repository, Tag
+
+from pygittools.merge import MR_REF_PREFIX, get_merge_request_by_oid
+from pygitweb.config import settings
+from pygitweb.git_helpers import get_commit_history
+from pygitweb.tasks import TASK_REF_PREFIX, get_board_tasks_grouped
+
+BOARD_REF_MANIFEST_COMMIT_LIMIT = 100
+
+
+class BoardRefManifestEntry(TypedDict):
+	name: str
+	insert: str
+	url: str
+	tags: str
+	snippet: str
+	created: str
+
+
+class BoardRefManifest(TypedDict):
+	commits: list[BoardRefManifestEntry]
+	refs: list[BoardRefManifestEntry]
+
+
+def _format_epoch(epoch: int | None, tz_offset: int | None = None) -> str:
+	if epoch is None:
+		return ""
+	try:
+		tz = timezone(timedelta(seconds=tz_offset * 60)) if tz_offset is not None else UTC
+		return datetime.fromtimestamp(epoch, tz=tz).strftime("%Y-%m-%d %H:%M:%S")
+	except (ValueError, OSError):
+		return ""
+
+
+def _project_url(project: str) -> str:
+	return f"/project/{quote(project, safe='/')}"
+
+
+def _board_task_url(project: str, board_name: str, task_ref: str) -> str:
+	base = f"{_project_url(project)}/board/"
+	qs = f"b={quote(board_name, safe='')}&task={quote(task_ref, safe='')}"
+	return f"{base}?{qs}" if board_name != "Tasks" else f"{base}?task={quote(task_ref, safe='')}"
+
+
+def _task_display_id(ref: str) -> str:
+	slug = ref.removeprefix(TASK_REF_PREFIX)
+	if slug.startswith("task_"):
+		return slug.removeprefix("task_")
+	return slug
+
+
+def _manifest_commits(project: str) -> list[BoardRefManifestEntry]:
+	project_url = _project_url(project)
+	entries: list[BoardRefManifestEntry] = []
+	for commit in get_commit_history(project, ref=None, max_count=BOARD_REF_MANIFEST_COMMIT_LIMIT):
+		oid = str(commit.get("oid") or "")
+		if not oid:
+			continue
+		short = oid[:7]
+		subject = str(commit.get("subject") or "")
+		author = str(commit.get("author") or "")
+		created = _format_epoch(commit.get("author_epoch"), commit.get("author_tz"))
+		search_tags = " ".join(part.lower() for part in (oid, short, subject, author) if part)
+		entries.append({
+			"name": f"{short} {subject}".strip(),
+			"insert": short,
+			"url": f"{project_url}?a=commit&h={quote(oid, safe='')}",
+			"tags": search_tags,
+			"snippet": author,
+			"created": created,
+		})
+	return entries
+
+
+def _manifest_board_tasks(project: str, board_name: str) -> list[BoardRefManifestEntry]:
+	entries: list[BoardRefManifestEntry] = []
+	seen_refs: set[str] = set()
+	try:
+		columns = get_board_tasks_grouped(project, board_name)
+	except KeyError:
+		return entries
+	for column in columns:
+		for task in column["tasks"]:
+			task_ref = str(task.get("ref") or "")
+			if not task_ref or task_ref in seen_refs:
+				continue
+			seen_refs.add(task_ref)
+			task_id = str(task.get("id") or _task_display_id(task_ref))
+			title = str(task.get("title") or "Untitled")
+			slug = task_ref.removeprefix("refs/tags/")
+			created = str(task.get("updated_at") or task.get("created_at") or "")
+			search_tags = " ".join(
+				part.lower() for part in (task_id, slug, task_ref, title, str(task.get("status") or "")) if part
+			)
+			entries.append({
+				"name": title,
+				"insert": task_id,
+				"url": _board_task_url(project, board_name, task_ref),
+				"tags": search_tags,
+				"snippet": f"task {task_id}",
+				"created": created,
+			})
+	return entries
+
+
+def _manifest_merge_requests(project: str) -> list[BoardRefManifestEntry]:
+	project_url = _project_url(project)
+	repo = Repository(os.path.join(settings.PROJECTROOT, project))
+	entries: list[BoardRefManifestEntry] = []
+	for ref_name in repo.references:
+		if not ref_name.startswith(MR_REF_PREFIX):
+			continue
+		try:
+			oid = repo.references[ref_name].resolve().target
+			obj = repo[oid]
+			if not isinstance(obj, Tag):
+				continue
+			mr = get_merge_request_by_oid(repo, Oid(hex=str(oid)))
+		except (ValueError, KeyError, TypeError):
+			continue
+		slug = ref_name.removeprefix(MR_REF_PREFIX)
+		title = mr.title or slug
+		created = mr.updated_at.isoformat() if mr.updated_at else ""
+		search_tags = " ".join(
+			part.lower() for part in (slug, ref_name, title, mr.description or "", mr.ours or "") if part
+		)
+		entries.append({
+			"name": title,
+			"insert": slug,
+			"url": f"{project_url}?a=tag&h={quote(ref_name, safe='')}",
+			"tags": search_tags,
+			"snippet": f"merge request {slug}",
+			"created": created,
+		})
+	return entries
+
+
+def board_ref_manifest(project: str, board_name: str) -> BoardRefManifest:
+	refs = _manifest_board_tasks(project, board_name)
+	refs.extend(_manifest_merge_requests(project))
+	refs.sort(key=lambda entry: (entry["created"], entry["name"]), reverse=True)
+	return {
+		"commits": _manifest_commits(project),
+		"refs": refs,
+	}
diff --git a/pygitweb/board_refs_test.py b/pygitweb/board_refs_test.py
new file mode 100644
index 0000000..3b87e87
--- /dev/null
+++ b/pygitweb/board_refs_test.py
@@ -0,0 +1,106 @@
+from __future__ import annotations
+
+from pathlib import Path
+from unittest.mock import patch
+
+import pytest
+from fastapi.testclient import TestClient
+from pygit2 import Oid, Signature, init_repository
+
+from pygittools.merge import MR_REF_PREFIX, MergeRequest
+from pygittools.tasks import BOARD_REF_PREFIX, EMPTY_TREE_OID_HEX, TASK_REF_PREFIX, Board, Task
+from pygitweb.board_refs import board_ref_manifest
+from pygitweb.config import settings
+from pygitweb.main import app
+
+EMPTY_TREE_OID = Oid(hex=EMPTY_TREE_OID_HEX)
+SIG = Signature("tester", "tester@example.com")
+
+
+@pytest.fixture
+def manifest_env(tmp_path: Path) -> dict[str, str | TestClient]:
+	root = tmp_path / "manifest"
+	repo_dir = root / "demo"
+	repo_dir.mkdir(parents=True)
+	repo = init_repository(str(repo_dir), bare=False)
+
+	index = repo.index
+	(repo_dir / "README").write_text("hello\n", encoding="utf-8")
+	index.add("README")
+	index.write()
+	tree = index.write_tree()
+	first = repo.create_commit("refs/heads/main", SIG, SIG, "Initial commit", tree, [])
+	second_tree = index.write_tree()
+	second = repo.create_commit("refs/heads/main", SIG, SIG, "Second commit", second_tree, [first])
+	repo.set_head(second)
+
+	board_ref = f"{BOARD_REF_PREFIX}Tasks"
+	board = Board(EMPTY_TREE_OID, board_ref, tagger="", description="Main")
+	task_ref = f"{TASK_REF_PREFIX}task_1782144988054"
+	task = Task(
+		EMPTY_TREE_OID,
+		task_ref,
+		tagger="",
+		title="Ref lookup task",
+		description="See @8054",
+		status=Task.Status.TODO,
+	)
+	task_oid = task.write(repo)
+	board.tasks = [str(task_oid)]
+	board.update_message()
+	board.write(repo)
+
+	mr_ref = f"{MR_REF_PREFIX}aaaabbbbccccdddd"
+	mr = MergeRequest(
+		target=second,
+		opener=SIG,
+		ours="refs/heads/main",
+		title="Test MR",
+		description="Merge me",
+		name=mr_ref,
+	)
+	mr.write(repo)
+
+	with (
+		patch.object(settings, "PROJECTROOT", str(root)),
+		patch.object(settings, "PROJECTS_LIST", str(root)),
+		patch.object(settings, "STRICT_EXPORT", False),
+		patch.object(settings, "EXPORT_OK", ""),
+		patch.object(settings, "AUTH", False),
+	):
+		yield {"client": TestClient(app), "project": "demo", "task_ref": task_ref, "mr_ref": mr_ref}
+
+
+def test_board_ref_manifest_includes_commits_tasks_and_merge_requests(
+	manifest_env: dict[str, str | TestClient],
+) -> None:
+	manifest = board_ref_manifest(str(manifest_env["project"]), "Tasks")
+
+	assert len(manifest["commits"]) == 2
+	assert "Second commit" in manifest["commits"][0]["name"]
+	assert manifest["commits"][0]["insert"] == manifest["commits"][0]["name"].split()[0]
+
+	task_entries = [entry for entry in manifest["refs"] if "1782144988054" in entry["tags"]]
+	assert len(task_entries) == 1
+	assert task_entries[0]["insert"] == "1782144988054"
+	assert task_entries[0]["name"] == "Ref lookup task"
+	assert "task=refs" in task_entries[0]["url"]
+	assert "task_1782144988054" in task_entries[0]["url"]
+
+	mr_entries = [entry for entry in manifest["refs"] if entry["insert"] == "aaaabbbbccccdddd"]
+	assert len(mr_entries) == 1
+	assert mr_entries[0]["name"] == "Test MR"
+	assert "a=tag" in mr_entries[0]["url"]
+
+
+def test_board_ref_manifest_route(manifest_env: dict[str, str | TestClient]) -> None:
+	client = manifest_env["client"]
+	assert isinstance(client, TestClient)
+	resp = client.get(
+		f"/project/{manifest_env['project']}/board-ref-manifest",
+		params={"b": "Tasks"},
+	)
+	assert resp.status_code == 200
+	body = resp.json()
+	assert len(body["commits"]) == 2
+	assert any(entry["insert"] == "1782144988054" for entry in body["refs"])
diff --git a/pygitweb/main.py b/pygitweb/main.py
index 402eebd..30d4bbb 100644
--- a/pygitweb/main.py
+++ b/pygitweb/main.py
@@ -60,6 +60,7 @@ from pygitweb.auth import (
 	require_permission,
 )
 from pygitweb.auth_config import auth_config, is_auth_configured
+from pygitweb.board_refs import BoardRefManifest, board_ref_manifest
 from pygitweb.change_queue import CHANGE_QUEUE
 from pygitweb.config import ACTIONS, get_loadavg, settings
 from pygitweb.dependencies import (
@@ -554,6 +555,15 @@ def project_board(
 	return HTMLResponse(f"{pre}{body}{POSTAMBLE}")
 
 
+@app.get("/project/{project:path}/board-ref-manifest")
+def project_board_ref_manifest(
+	project: ValidatedReadableProject,
+	board: Annotated[str, Query(alias="b")] = "Tasks",
+) -> BoardRefManifest:
+	"""JSON manifest of recent commits and board/MR refs for @ / # autocomplete."""
+	return board_ref_manifest(project, board)
+
+
 @app.get("/project/{project:path}/summary-refs")
 def project_summary_refs(project: ValidatedReadableProject) -> dict[str, list[dict[str, str]]]:
 	return {"options": summary_ref_options(project)}
diff --git a/pygitweb/static/board-refs.js b/pygitweb/static/board-refs.js
new file mode 100644
index 0000000..6308e2f
--- /dev/null
+++ b/pygitweb/static/board-refs.js
@@ -0,0 +1,302 @@
+(function () {
+  "use strict";
+
+  var REF_TOKEN =
+    /(?:^|[\s(])(#([0-9a-fA-F]{4,40})|@([A-Za-z0-9._-]+(?:\/[A-Za-z0-9._-]+)*))/g;
+  var SUGGEST_LIMIT = 12;
+
+  function projectPath(project) {
+    return (
+      "/project/" +
+      (project || "")
+        .split("/")
+        .map(function (segment) {
+          return encodeURIComponent(segment);
+        })
+        .join("/")
+    );
+  }
+
+  function escapeHtml(value) {
+    return String(value)
+      .replace(/&/g, "&amp;")
+      .replace(/</g, "&lt;")
+      .replace(/>/g, "&gt;")
+      .replace(/"/g, "&quot;");
+  }
+
+  function splitReferenceText(text) {
+    var parts = [];
+    var last = 0;
+    var match;
+    REF_TOKEN.lastIndex = 0;
+    while ((match = REF_TOKEN.exec(text)) !== null) {
+      var full = match[0];
+      var lead = full.charAt(0);
+      var token = lead === "#" || lead === "@" ? full : full.slice(1);
+      var tokenStart = match.index + full.length - token.length;
+      if (tokenStart > last) {
+        parts.push({ type: "text", value: text.slice(last, tokenStart) });
+      }
+      if (token.charAt(0) === "#") {
+        parts.push({ type: "commit", value: token.slice(1) });
+      } else {
+        parts.push({ type: "ref", value: token.slice(1) });
+      }
+      last = match.index + full.length;
+    }
+    if (last < text.length) {
+      parts.push({ type: "text", value: text.slice(last) });
+    }
+    return parts;
+  }
+
+  function shouldSkipTextNode(node) {
+    var parent = node.parentElement;
+    if (!parent) return true;
+    if (parent.closest("pre, code, a, .board-ref-link")) return true;
+    return false;
+  }
+
+  function resolveCommit(refId, manifest) {
+	if (!refId || !manifest || !manifest.commits) return null;
+    var needle = refId.toLowerCase();
+    var i;
+    var entry;
+    var parts;
+    var p;
+    for (i = 0; i < manifest.commits.length; i++) {
+      entry = manifest.commits[i];
+      if (entry.insert.toLowerCase() === needle) return entry;
+      parts = (entry.tags || "").split(/\s+/);
+      for (p = 0; p < parts.length; p++) {
+        if (parts[p].indexOf(needle) === 0) return entry;
+      }
+    }
+    return null;
+  }
+
+  function resolveRef(refId, manifest) {
+    if (!refId || !manifest || !manifest.refs) return null;
+    var needle = refId.toLowerCase();
+    var i;
+    var entry;
+    for (i = 0; i < manifest.refs.length; i++) {
+      entry = manifest.refs[i];
+      if (entry.insert.toLowerCase() === needle) return entry;
+    }
+    if (/^\d+$/.test(refId) && refId.length >= 4) {
+      for (i = 0; i < manifest.refs.length; i++) {
+        entry = manifest.refs[i];
+        if (entry.insert.slice(-refId.length) === refId) return entry;
+      }
+    }
+    for (i = 0; i < manifest.refs.length; i++) {
+      entry = manifest.refs[i];
+      if (entry.tags.indexOf(needle) !== -1) return entry;
+    }
+    return null;
+  }
+
+  function linkifyElement(root, manifest, project) {
+    if (!root || !project) return;
+    var walker = document.createTreeWalker(root, NodeFilter.SHOW_TEXT);
+    var textNodes = [];
+    var node;
+    while ((node = walker.nextNode())) {
+      if (!shouldSkipTextNode(node)) textNodes.push(node);
+    }
+    textNodes.forEach(function (textNode) {
+      var text = textNode.textContent || "";
+      var parts = splitReferenceText(text);
+      if (parts.length <= 1 && parts[0] && parts[0].type === "text") return;
+      var frag = document.createDocumentFragment();
+      parts.forEach(function (part) {
+        if (part.type === "text") {
+          frag.appendChild(document.createTextNode(part.value));
+          return;
+        }
+        if (part.type === "commit") {
+          var resolvedCommit = resolveCommit(part.value, manifest);
+          var commitHref =
+            (resolvedCommit && resolvedCommit.url) ||
+            projectPath(project) + "?a=commit&h=" + encodeURIComponent(part.value);
+          var commitLink = document.createElement("a");
+          commitLink.href = commitHref;
+          commitLink.className = "board-ref-link board-ref-commit";
+          commitLink.textContent = "#" + part.value;
+          if (resolvedCommit) commitLink.title = resolvedCommit.name;
+          frag.appendChild(commitLink);
+          return;
+        }
+        var resolved = resolveRef(part.value, manifest);
+        if (resolved) {
+          var refLink = document.createElement("a");
+          refLink.className = "board-ref-link board-ref-tag";
+          refLink.textContent = "@" + part.value;
+          refLink.href = resolved.url;
+          refLink.title = resolved.name;
+          frag.appendChild(refLink);
+        } else {
+          var unresolved = document.createElement("span");
+          unresolved.className = "board-ref-link board-ref-unresolved";
+          unresolved.textContent = "@" + part.value;
+          unresolved.title = "Unresolved reference";
+          frag.appendChild(unresolved);
+        }
+      });
+      textNode.parentNode.replaceChild(frag, textNode);
+    });
+  }
+
+  function filterManifestItems(items, query) {
+    var list = items || [];
+    var q = (query || "").toLowerCase();
+    if (!q) return list.slice(0, SUGGEST_LIMIT);
+    return list
+      .filter(function (item) {
+        return (
+          item.name.toLowerCase().indexOf(q) !== -1 || (item.tags || "").indexOf(q) !== -1
+        );
+      })
+      .slice(0, SUGGEST_LIMIT);
+  }
+
+  function loadManifest(project, board) {
+    var url =
+      projectPath(project) +
+      "/board-ref-manifest?" +
+      new URLSearchParams({ b: board || "Tasks" }).toString();
+    return fetch(url)
+      .then(function (res) {
+        return res.ok ? res.json() : { commits: [], refs: [] };
+      })
+      .then(function (data) {
+        return {
+          commits: Array.isArray(data.commits) ? data.commits : [],
+          refs: Array.isArray(data.refs) ? data.refs : [],
+        };
+      })
+      .catch(function () {
+        return { commits: [], refs: [] };
+      });
+  }
+
+  function attachAutocomplete(textarea, manifest) {
+    if (!textarea || textarea.dataset.boardRefAutocomplete === "1") return;
+    textarea.dataset.boardRefAutocomplete = "1";
+    var menu = document.createElement("div");
+    menu.className = "board-ref-suggest d-none";
+    document.body.appendChild(menu);
+
+    var activeTrigger = null;
+    var activeStart = -1;
+
+    function hideMenu() {
+      menu.classList.remove("show");
+      menu.classList.add("d-none");
+      menu.innerHTML = "";
+      activeTrigger = null;
+      activeStart = -1;
+    }
+
+    function positionMenu() {
+      var rect = textarea.getBoundingClientRect();
+      menu.style.left = rect.left + window.scrollX + "px";
+      menu.style.top = rect.bottom + window.scrollY + 4 + "px";
+      menu.style.minWidth = Math.max(rect.width, 280) + "px";
+    }
+
+    function currentToken() {
+      var value = textarea.value;
+      var pos = textarea.selectionStart || 0;
+      var before = value.slice(0, pos);
+      var match = before.match(/(^|[\s(])((@[A-Za-z0-9._/-]*)|(#[0-9a-fA-F]*))$/);
+      if (!match) return null;
+      var token = match[2];
+      return {
+        trigger: token.charAt(0),
+        query: token.slice(1),
+        start: before.length - token.length,
+      };
+    }
+
+    function renderMenu(items) {
+      menu.innerHTML = "";
+      if (!items.length) {
+        hideMenu();
+        return;
+      }
+      items.forEach(function (item) {
+        var row = document.createElement("button");
+        row.type = "button";
+        row.className = "board-ref-suggest-item";
+        row.innerHTML =
+          '<div class="board-ref-suggest-title">' +
+          escapeHtml(item.name) +
+          "</div>" +
+          '<div class="board-ref-suggest-snippet">' +
+          escapeHtml(item.snippet || item.insert || "") +
+          "</div>" +
+          (item.created
+            ? '<div class="board-ref-suggest-date">' + escapeHtml(item.created) + "</div>"
+            : "");
+        row.addEventListener("mousedown", function (e) {
+          e.preventDefault();
+          if (activeStart < 0 || !activeTrigger) return;
+          var insert = activeTrigger + item.insert;
+          var value = textarea.value;
+          var end = textarea.selectionStart || 0;
+          textarea.value = value.slice(0, activeStart) + insert + value.slice(end);
+          var caret = activeStart + insert.length;
+          textarea.setSelectionRange(caret, caret);
+          hideMenu();
+          textarea.dispatchEvent(new Event("input", { bubbles: true }));
+        });
+        menu.appendChild(row);
+      });
+      positionMenu();
+      menu.classList.remove("d-none");
+      menu.classList.add("show");
+    }
+
+    function refreshMenu() {
+      var token = currentToken();
+      if (!token) {
+        hideMenu();
+        return;
+      }
+      activeTrigger = token.trigger;
+      activeStart = token.start;
+      var items =
+        token.trigger === "#"
+          ? filterManifestItems(manifest.commits, token.query)
+          : filterManifestItems(manifest.refs, token.query);
+      renderMenu(items);
+    }
+
+    textarea.addEventListener("input", refreshMenu);
+    textarea.addEventListener("keyup", refreshMenu);
+    textarea.addEventListener("blur", function () {
+      window.setTimeout(hideMenu, 150);
+    });
+    textarea.addEventListener("keydown", function (e) {
+      if (e.key === "Escape") hideMenu();
+    });
+
+    document.addEventListener("mousedown", function (e) {
+      if (menu.classList.contains("d-none")) return;
+      if (menu.contains(e.target) || textarea.contains(e.target)) return;
+      hideMenu();
+    });
+  }
+
+  window.BoardRefs = {
+    loadManifest: loadManifest,
+    linkifyElement: linkifyElement,
+    attachAutocomplete: attachAutocomplete,
+    resolveRef: resolveRef,
+    resolveCommit: resolveCommit,
+    filterManifestItems: filterManifestItems,
+  };
+})();
diff --git a/pygitweb/static/board.js b/pygitweb/static/board.js
index 43ebe83..8f83ae2 100644
--- a/pygitweb/static/board.js
+++ b/pygitweb/static/board.js
@@ -5,6 +5,33 @@
   var ICONS_BASE = '/static/icons/';
   var ICON_LIST = ['cloud.svg', 'cloud-filled.svg', 'cloud-storm.svg', 'cloud-down.svg', 'cloud-up.svg', 'cloud-exclamation.svg'];
   var boardMarkdownConverter = null;
+  var boardRefManifest = null;
+
+  function refreshBoardRefManifest() {
+    var ctx = getBoardContext();
+    if (!ctx.project || !window.BoardRefs) return Promise.resolve(null);
+    return BoardRefs.loadManifest(ctx.project, ctx.board)
+      .then(function (manifest) {
+        boardRefManifest = manifest;
+        return manifest;
+      })
+      .catch(function () {
+        boardRefManifest = { commits: [], refs: [] };
+        return boardRefManifest;
+      });
+  }
+
+  function linkifyMarkdownElement(el) {
+    if (!el || !window.BoardRefs || !boardRefManifest) return;
+    var ctx = getBoardContext();
+    if (!ctx.project) return;
+    BoardRefs.linkifyElement(el, boardRefManifest, ctx.project);
+  }
+
+  function attachBoardRefAutocomplete(textarea) {
+    if (!textarea || !window.BoardRefs || !boardRefManifest) return;
+    BoardRefs.attachAutocomplete(textarea, boardRefManifest);
+  }
 
   function whenShowdownReady(cb) {
     if (typeof showdown !== 'undefined') {
@@ -64,6 +91,7 @@
       var html = markdownToHtml(md);
       if (html !== null) {
         el.innerHTML = html;
+        linkifyMarkdownElement(el);
       } else {
         el.textContent = md;
       }
@@ -109,7 +137,10 @@
   preloadIcons();
   function initBoard() {
     assignCloudIconToAllCards();
-    loadTasksForBoard();
+    refreshBoardRefManifest().then(function () {
+      loadTasksForBoard();
+      openTaskFromQueryString();
+    });
   }
   if (document.readyState === 'loading') {
     document.addEventListener('DOMContentLoaded', initBoard);
@@ -237,9 +268,21 @@
         });
         updateColumnBadge(column);
       });
+      refreshBoardRefManifest();
     }).catch(function() {});
   }
 
+  function openTaskFromQueryString() {
+    var taskRef = (new URLSearchParams(location.search)).get("task");
+    if (!taskRef) return;
+    window.setTimeout(function () {
+      var card = findBoardCardByTaskRef(taskRef);
+      if (card && !card.classList.contains("board-card-popout")) {
+        showCardPopout(card);
+      }
+    }, 0);
+  }
+
   function fetchBoardTasksJson() {
     var ctx = getBoardContext();
     if (!ctx.project) return Promise.resolve(null);
@@ -452,6 +495,8 @@
     if (!form || form.dataset.bound === '1') return;
     form.dataset.bound = '1';
     syncCommentFormVisibility(popoutCard);
+    var commentInput = form.querySelector('.board-card-comment-input');
+    attachBoardRefAutocomplete(commentInput);
     form.addEventListener('submit', function(e) {
       e.preventDefault();
       if (!canAddComment()) return;
@@ -649,6 +694,7 @@
         textarea.value = val;
         descEl.classList.add('d-none');
         descEl.parentNode.insertBefore(textarea, descEl);
+        attachBoardRefAutocomplete(textarea);
         textarea.focus();
         textarea.addEventListener('blur', commitDescription);
       });
@@ -753,7 +799,12 @@
     if (descFull && !Object.prototype.hasOwnProperty.call(descFull.dataset, 'boardMarkdown')) {
       setMarkdownElement(descFull, getMarkdownFromElement(descFull));
       syncCompactDescription(card);
+    } else if (descFull) {
+      linkifyMarkdownElement(descFull);
     }
+    document.querySelectorAll('.board-card-comment-content.board-card-markdown').forEach(function (el) {
+      if (card.contains(el)) linkifyMarkdownElement(el);
+    });
     loadCommentsForCard(card);
     var overlay = document.createElement('div');
     overlay.className = 'board-card-popout-overlay';
diff --git a/pygitweb/static/main.css b/pygitweb/static/main.css
index 5eb6e08..aef0f90 100644
--- a/pygitweb/static/main.css
+++ b/pygitweb/static/main.css
@@ -1168,6 +1168,74 @@ pre {
 	}
 }
 
+.board-ref-link {
+	color: var(--pgw-link);
+	text-decoration: none;
+	font-weight: 500;
+}
+
+.board-ref-link:hover {
+	text-decoration: underline;
+}
+
+.board-ref-unresolved {
+	color: var(--pgw-text-muted);
+	cursor: help;
+}
+
+.board-ref-suggest {
+	position: absolute;
+	z-index: 1070;
+	max-height: 70vh;
+	overflow-y: auto;
+	background: var(--pgw-bg-raised);
+	border: 1px solid var(--pgw-border);
+	border-radius: 0.375rem;
+	box-shadow: 0 0.25rem 0.75rem rgba(0, 0, 0, 0.12);
+	padding: 0;
+}
+
+.board-ref-suggest.show {
+	display: block;
+}
+
+.board-ref-suggest-item {
+	display: block;
+	width: 100%;
+	text-align: left;
+	border: 0;
+	background: transparent;
+	color: var(--pgw-text);
+	padding: 0.75rem 1rem;
+	border-bottom: 1px solid var(--pgw-border);
+	cursor: pointer;
+}
+
+.board-ref-suggest-item:last-child {
+	border-bottom: none;
+}
+
+.board-ref-suggest-item:hover {
+	background: var(--pgw-bg-muted);
+}
+
+.board-ref-suggest-title {
+	font-weight: 600;
+	margin-bottom: 0.25rem;
+}
+
+.board-ref-suggest-snippet {
+	font-size: 0.875rem;
+	color: var(--pgw-text-muted);
+	line-height: 1.4;
+}
+
+.board-ref-suggest-date {
+	font-size: 0.75rem;
+	color: var(--pgw-text-dim);
+	margin-top: 0.25rem;
+}
+
 /* Search page: input, status, and result lines */
 .search-status {
 	font-style: italic;
diff --git a/pygitweb/templates/board.html b/pygitweb/templates/board.html
index 97ebd89..edcf6dc 100644
--- a/pygitweb/templates/board.html
+++ b/pygitweb/templates/board.html
@@ -28,4 +28,5 @@
   {% endfor %}
 </div>
 
+<script src="/static/board-refs.js"></script>
 <script src="/static/board.js"></script>
