Gradescope Comment Viewer

Groups annotation comments by rubric item on Gradescope

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Advertisement:

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

Advertisement:

// ==UserScript==
// @name         Gradescope Comment Viewer
// @namespace    https://www.gradescope.com
// @version      1.2
// @description  Groups annotation comments by rubric item on Gradescope
// @author       Justin Lyon
// @license      MIT
// @match        https://www.gradescope.com/courses/*/questions/*/submissions/*
// @grant        none
// @run-at       document-idle
// ==/UserScript==

(function () {
  "use strict";

  const path = window.location.pathname;
  const match = path.match(
    /\/courses\/(\d+)\/questions\/(\d+)\/submissions\/(\d+)/
  );
  if (!match) return;

  const [, courseId, questionId] = match;
  const baseUrl = `https://www.gradescope.com/courses/${courseId}/questions/${questionId}`;

  // --- Constants ---
  const MIN_WIDTH = 280;
  const MIN_HEIGHT = 200;
  const RESIZE_EDGE = 12;

  // --- Extract rubric data from the page's embedded JSON ---
  function extractPageData() {
    const raw = document.documentElement.innerHTML;
    const tmp = document.createElement("textarea");
    tmp.innerHTML = raw;
    const html = tmp.value;
    const rubricItems = [];
    const rubricGroups = [];

    function extractArray(source, key) {
      const marker = '"' + key + '":[';
      const start = source.indexOf(marker);
      if (start === -1) return null;
      const arrStart = start + marker.length - 1;
      let depth = 0;
      for (let i = arrStart; i < source.length; i++) {
        if (source[i] === "[") depth++;
        else if (source[i] === "]") {
          depth--;
          if (depth === 0) return source.slice(arrStart, i + 1);
        }
      }
      return null;
    }

    try {
      const itemsJson = extractArray(html, "rubric_items");
      if (itemsJson) rubricItems.push(...JSON.parse(itemsJson));
    } catch (_) { }
    try {
      const groupsJson = extractArray(html, "rubric_item_groups");
      if (groupsJson) rubricGroups.push(...JSON.parse(groupsJson));
    } catch (_) { }

    return { rubricItems, rubricGroups };
  }

  // --- Fetch annotation comments ---
  async function fetchComments() {
    const csrfMeta = document.querySelector('meta[name="csrf-token"]');
    const headers = {
      Accept: "application/json",
      "X-Requested-With": "XMLHttpRequest",
    };
    if (csrfMeta) headers["X-CSRF-Token"] = csrfMeta.content;

    const resp = await fetch(`${baseUrl}/submissions/annotation_comments`, {
      headers,
      credentials: "same-origin",
    });
    if (!resp.ok) throw new Error(`Failed to fetch comments: ${resp.status}`);
    return resp.json();
  }

  // --- Build grouped data structure ---
  function groupComments(comments, rubricItems, rubricGroups) {
    const groupMap = new Map();
    for (const g of rubricGroups) {
      groupMap.set(g.id, { ...g, items: [] });
    }

    const itemMap = new Map();
    for (const item of rubricItems) {
      const entry = { ...item, comments: [] };
      itemMap.set(item.id, entry);
      const group = groupMap.get(item.group_id);
      if (group) group.items.push(entry);
    }

    const ungrouped = [];

    for (const value of Object.values(comments)) {
      const [text, linkableId, linkableType] = value;
      if (linkableType === "RubricItem" && itemMap.has(linkableId)) {
        itemMap.get(linkableId).comments.push(text);
      } else if (
        linkableType === "RubricItemGroup" &&
        groupMap.has(linkableId)
      ) {
        const group = groupMap.get(linkableId);
        if (!group.groupComments) group.groupComments = [];
        group.groupComments.push(text);
      } else {
        ungrouped.push(text);
      }
    }

    const sortedGroups = [...groupMap.values()].sort(
      (a, b) => a.position - b.position
    );
    for (const g of sortedGroups) {
      g.items.sort((a, b) => a.position - b.position);
    }

    return { groups: sortedGroups, ungrouped };
  }

  // --- Dragging ---
  function makeDraggable(panel, handle) {
    let dx = 0,
      dy = 0,
      startX = 0,
      startY = 0;
    handle.style.cursor = "grab";
    handle.addEventListener("mousedown", (e) => {
      if (e.target.closest("button, input")) return;
      e.preventDefault();
      startX = e.clientX;
      startY = e.clientY;
      handle.style.cursor = "grabbing";
      const onMove = (e2) => {
        dx = e2.clientX - startX;
        dy = e2.clientY - startY;
        startX = e2.clientX;
        startY = e2.clientY;
        panel.style.top = panel.offsetTop + dy + "px";
        panel.style.left = panel.offsetLeft + dx + "px";
      };
      const onUp = () => {
        handle.style.cursor = "grab";
        document.removeEventListener("mousemove", onMove);
        document.removeEventListener("mouseup", onUp);
      };
      document.addEventListener("mousemove", onMove);
      document.addEventListener("mouseup", onUp);
    });
  }

  // --- Resizing from any edge ---
  function makeResizable(panel) {
    const EDGE = RESIZE_EDGE;
    let resizing = null;

    function getEdge(e) {
      const r = panel.getBoundingClientRect();
      const x = e.clientX, y = e.clientY;
      const edges = {
        top: y - r.top < EDGE,
        bottom: r.bottom - y < EDGE,
        left: x - r.left < EDGE,
        right: r.right - x < EDGE,
      };
      return edges;
    }

    function getCursor(edges) {
      if ((edges.top && edges.left) || (edges.bottom && edges.right)) return "nwse-resize";
      if ((edges.top && edges.right) || (edges.bottom && edges.left)) return "nesw-resize";
      if (edges.top || edges.bottom) return "ns-resize";
      if (edges.left || edges.right) return "ew-resize";
      return "";
    }

    panel.addEventListener("mousemove", (e) => {
      if (resizing) return;
      const edges = getEdge(e);
      const cursor = getCursor(edges);
      panel.style.cursor = cursor || "";
    });

    panel.addEventListener("mousedown", (e) => {
      const edges = getEdge(e);
      if (!edges.top && !edges.bottom && !edges.left && !edges.right) return;
      e.preventDefault();
      e.stopPropagation();
      const startX = e.clientX, startY = e.clientY;
      const rect = panel.getBoundingClientRect();
      resizing = { edges, startX, startY, startW: rect.width, startH: rect.height, startTop: rect.top, startLeft: rect.left };

      const onMove = (e2) => {
        const dx = e2.clientX - resizing.startX;
        const dy = e2.clientY - resizing.startY;
        if (resizing.edges.right) {
          panel.style.width = Math.max(MIN_WIDTH, resizing.startW + dx) + "px";
        }
        if (resizing.edges.left) {
          const newW = Math.max(MIN_WIDTH, resizing.startW - dx);
          panel.style.width = newW + "px";
          panel.style.left = resizing.startLeft + (resizing.startW - newW) + "px";
        }
        if (resizing.edges.bottom) {
          panel.style.height = Math.max(MIN_HEIGHT, resizing.startH + dy) + "px";
          panel.style.maxHeight = "none";
        }
        if (resizing.edges.top) {
          const newH = Math.max(MIN_HEIGHT, resizing.startH - dy);
          panel.style.height = newH + "px";
          panel.style.maxHeight = "none";
          panel.style.top = resizing.startTop + (resizing.startH - newH) + "px";
        }
      };
      const onUp = () => {
        resizing = null;
        document.removeEventListener("mousemove", onMove);
        document.removeEventListener("mouseup", onUp);
      };
      document.addEventListener("mousemove", onMove);
      document.addEventListener("mouseup", onUp);
    });
  }

  // --- Search with auto-expand ---
  function setupSearch(panel, content) {
    const searchBox = document.createElement("div");
    searchBox.className = "gs-cv-search";
    searchBox.innerHTML =
      '<input type="text" class="gs-cv-search-input" placeholder="Search comments..."><button class="gs-cv-search-clear">&times;</button>';
    const input = searchBox.querySelector("input");
    const clearBtn = searchBox.querySelector(".gs-cv-search-clear");
    clearBtn.onclick = () => {
      input.value = "";
      input.dispatchEvent(new Event("input"));
      input.focus();
    };

    // Ctrl+F inside panel focuses search
    panel.addEventListener("keydown", (e) => {
      if ((e.ctrlKey || e.metaKey) && e.key === "f") {
        e.preventDefault();
        e.stopPropagation();
        input.focus();
        input.select();
      }
    });

    function highlightText(el, q) {
      // Store original text if not already stored
      if (!el.dataset.originalText) el.dataset.originalText = el.textContent;
      const text = el.dataset.originalText;
      if (!q) { el.textContent = text; return false; }
      const lower = text.toLowerCase();
      const idx = lower.indexOf(q);
      if (idx === -1) { el.textContent = text; return false; }
      el.innerHTML = '';
      let pos = 0;
      let searchFrom = 0;
      while (true) {
        const i = lower.indexOf(q, searchFrom);
        if (i === -1) break;
        if (i > pos) el.appendChild(document.createTextNode(text.slice(pos, i)));
        const mark = document.createElement('mark');
        mark.className = 'gs-cv-mark';
        mark.textContent = text.slice(i, i + q.length);
        el.appendChild(mark);
        pos = i + q.length;
        searchFrom = pos;
      }
      if (pos < text.length) el.appendChild(document.createTextNode(text.slice(pos)));
      return true;
    }

    let searchTimeout;
    input.addEventListener("input", () => {
      clearTimeout(searchTimeout);
      searchTimeout = setTimeout(doSearch, 120);
    });

    function doSearch() {
      const q = input.value.trim().toLowerCase();
      const allComments = content.querySelectorAll(".gs-cv-comment");
      const allItemHeaders = content.querySelectorAll(".gs-cv-item-header");
      const allGroupHeaders = content.querySelectorAll(".gs-cv-group-header");
      const allItems = content.querySelectorAll(".gs-cv-item");
      const allGroups = content.querySelectorAll(".gs-cv-group");

      if (!q) {
        // Clear: restore all text, show everything
        allComments.forEach((c) => { highlightText(c, ''); c.style.display = ""; });
        allItemHeaders.forEach((h) => highlightText(h, ''));
        allGroupHeaders.forEach((h) => highlightText(h, ''));
        allItems.forEach((i) => (i.style.display = ""));
        allGroups.forEach((g) => (g.style.display = ""));
        return;
      }

      // Highlight and track matches in comments
      allComments.forEach((c) => {
        const matched = highlightText(c, q);
        c.style.display = matched ? "" : "none";
      });

      // Highlight item headers and check if they or their comments match
      allItems.forEach((itemEl) => {
        const headerEl = itemEl.querySelector(".gs-cv-item-header");
        const headerMatch = headerEl ? highlightText(headerEl, q) : false;
        const hasVisibleComment = itemEl.querySelector('.gs-cv-comment:not([style*="display: none"])');

        if (headerMatch) {
          // Header matches: show all its comments too
          itemEl.style.display = "";
          itemEl.querySelectorAll(".gs-cv-comment").forEach((c) => { c.style.display = ""; highlightText(c, q); });
          const itemBody = itemEl.querySelector(".gs-cv-item-body");
          if (itemBody) itemBody.classList.remove("gs-cv-collapsed");
        } else if (hasVisibleComment) {
          itemEl.style.display = "";
          const itemBody = itemEl.querySelector(".gs-cv-item-body");
          if (itemBody) itemBody.classList.remove("gs-cv-collapsed");
        } else {
          itemEl.style.display = "none";
        }
      });

      // Highlight group headers and check if they or children match
      allGroups.forEach((groupEl) => {
        const headerEl = groupEl.querySelector(".gs-cv-group-header");
        const headerMatch = headerEl ? highlightText(headerEl, q) : false;
        const hasVisibleChild = groupEl.querySelector('.gs-cv-item:not([style*="display: none"]), .gs-cv-comment:not([style*="display: none"])');

        if (headerMatch) {
          // Group header matches: show group and all children
          groupEl.style.display = "";
          groupEl.querySelectorAll(".gs-cv-item").forEach((i) => (i.style.display = ""));
          groupEl.querySelectorAll(".gs-cv-comment").forEach((c) => { c.style.display = ""; highlightText(c, q); });
          groupEl.querySelectorAll(".gs-cv-item-header").forEach((h) => highlightText(h, q));
          const groupBody = groupEl.querySelector(".gs-cv-group-body");
          if (groupBody) groupBody.classList.remove("gs-cv-collapsed");
          groupEl.querySelectorAll(".gs-cv-item-body").forEach((b) => b.classList.remove("gs-cv-collapsed"));
        } else if (hasVisibleChild) {
          groupEl.style.display = "";
          const groupBody = groupEl.querySelector(".gs-cv-group-body");
          if (groupBody) groupBody.classList.remove("gs-cv-collapsed");
        } else {
          groupEl.style.display = "none";
        }
      });
    }

    return searchBox;
  }

  // --- Render the panel ---
  function renderPanel(grouped) {
    const panel = document.createElement("div");
    panel.id = "gs-comment-viewer";
    panel.tabIndex = 0; // so it can receive keyboard events

    // Header
    const header = document.createElement("div");
    header.className = "gs-cv-header";

    const titleRow = document.createElement("div");
    titleRow.className = "gs-cv-title-row";

    const title = document.createElement("span");
    title.className = "gs-cv-title";
    title.textContent = "Comments";
    titleRow.appendChild(title);

    const btnGroup = document.createElement("div");
    btnGroup.className = "gs-cv-btn-group";

    const refreshBtn = document.createElement("button");
    refreshBtn.className = "gs-cv-btn gs-cv-refresh";
    refreshBtn.textContent = "Refresh";
    refreshBtn.title = "Reload comments";
    btnGroup.appendChild(refreshBtn);

    const expandAllBtn = document.createElement("button");
    expandAllBtn.className = "gs-cv-btn";
    expandAllBtn.textContent = "Expand All";
    expandAllBtn.title = "Expand all sections";
    btnGroup.appendChild(expandAllBtn);

    const collapseAllBtn = document.createElement("button");
    collapseAllBtn.className = "gs-cv-btn";
    collapseAllBtn.textContent = "Collapse All";
    collapseAllBtn.title = "Collapse all sections";
    btnGroup.appendChild(collapseAllBtn);

    const minimizeBtn = document.createElement("button");
    minimizeBtn.className = "gs-cv-btn gs-cv-minimize";
    minimizeBtn.innerHTML = "&#8722;"; // minus sign
    minimizeBtn.title = "Minimize";
    btnGroup.appendChild(minimizeBtn);

    const closeBtn = document.createElement("button");
    closeBtn.className = "gs-cv-btn gs-cv-close";
    closeBtn.innerHTML = "&times;";
    closeBtn.title = "Close";
    btnGroup.appendChild(closeBtn);

    titleRow.appendChild(btnGroup);
    header.appendChild(titleRow);
    panel.appendChild(header);

    // Content wrapper (search + scrollable content)
    const contentWrap = document.createElement("div");
    contentWrap.className = "gs-cv-content-wrap";

    const content = document.createElement("div");
    content.className = "gs-cv-content";

    // Search bar
    const searchBox = setupSearch(panel, content);
    contentWrap.appendChild(searchBox);

    // Track collapse state: key -> boolean (true = collapsed)
    const collapseState = new Map();

    function saveCollapseState() {
      content.querySelectorAll(".gs-cv-group").forEach((g) => {
        const key = "g:" + g.querySelector(".gs-cv-group-header")?.dataset.key;
        const body = g.querySelector(".gs-cv-group-body");
        if (body) collapseState.set(key, body.classList.contains("gs-cv-collapsed"));
      });
      content.querySelectorAll(".gs-cv-item").forEach((i) => {
        const key = "i:" + i.querySelector(".gs-cv-item-header")?.dataset.key;
        const body = i.querySelector(".gs-cv-item-body");
        if (body) collapseState.set(key, body.classList.contains("gs-cv-collapsed"));
      });
    }

    function buildContent(grouped) {
      saveCollapseState();
      content.innerHTML = "";
      let total = 0;

      function buildGroup(description, commentsList, items) {
        const commentCount =
          (commentsList?.length || 0) +
          (items || []).reduce((s, i) => s + i.comments.length, 0);
        if (commentCount === 0) return null;

        const groupKey = "g:" + description;
        const groupEl = document.createElement("div");
        groupEl.className = "gs-cv-group";

        const groupHeader = document.createElement("div");
        groupHeader.className = "gs-cv-group-header";
        groupHeader.dataset.key = description;
        groupHeader.textContent = `${description} (${commentCount})`;
        groupHeader.onclick = () =>
          groupBody.classList.toggle("gs-cv-collapsed");
        groupEl.appendChild(groupHeader);

        const groupBody = document.createElement("div");
        groupBody.className = "gs-cv-group-body";
        // Restore state: if known, use saved; if new section, expand (not collapsed)
        if (collapseState.has(groupKey)) {
          if (collapseState.get(groupKey)) groupBody.classList.add("gs-cv-collapsed");
        }

        if (commentsList?.length) {
          for (const c of commentsList) {
            groupBody.appendChild(makeCommentEl(c));
            total++;
          }
        }

        if (items) {
          for (const item of items) {
            if (item.comments.length === 0) continue;
            const itemKey = "i:" + item.description;
            const itemEl = document.createElement("div");
            itemEl.className = "gs-cv-item";

            const itemHeader = document.createElement("div");
            itemHeader.className = "gs-cv-item-header";
            itemHeader.dataset.key = item.description;
            itemHeader.textContent = `${item.description} (${item.comments.length})`;
            itemHeader.onclick = (e) => {
              e.stopPropagation();
              itemBody.classList.toggle("gs-cv-collapsed");
            };
            itemEl.appendChild(itemHeader);

            const itemBody = document.createElement("div");
            itemBody.className = "gs-cv-item-body";
            if (collapseState.has(itemKey)) {
              if (collapseState.get(itemKey)) itemBody.classList.add("gs-cv-collapsed");
            }
            for (const c of item.comments) {
              itemBody.appendChild(makeCommentEl(c));
              total++;
            }
            itemEl.appendChild(itemBody);
            groupBody.appendChild(itemEl);
          }
        }

        groupEl.appendChild(groupBody);
        return groupEl;
      }

      for (const group of grouped.groups) {
        const el = buildGroup(
          group.description,
          group.groupComments,
          group.items
        );
        if (el) content.appendChild(el);
      }

      if (grouped.ungrouped.length > 0) {
        const el = buildGroup("Unlinked Comments", grouped.ungrouped, null);
        if (el) content.appendChild(el);
      }

      return total;
    }

    let totalComments = buildContent(grouped);

    contentWrap.appendChild(content);
    panel.appendChild(contentWrap);

    // --- Refresh logic ---
    let refreshing = false;
    async function refreshComments() {
      if (refreshing) return;
      refreshing = true;
      refreshBtn.textContent = "...";
      try {
        const comments = await fetchComments();
        const { rubricItems, rubricGroups } = extractPageData();
        const newGrouped = groupComments(comments, rubricItems, rubricGroups);
        totalComments = buildContent(newGrouped);
        toggleBtn.textContent = `Comments (${totalComments})`;
        // Re-trigger search if active
        const searchInput = panel.querySelector(".gs-cv-search-input");
        if (searchInput && searchInput.value.trim()) {
          searchInput.dispatchEvent(new Event("input"));
        }
        console.log("[GS-CV] Refreshed:", totalComments, "comments");
      } catch (err) {
        console.error("[GS-CV] Refresh error:", err);
      }
      refreshBtn.textContent = "Refresh";
      refreshing = false;
    }

    refreshBtn.onclick = refreshComments;

    // --- Expand/Collapse All ---
    expandAllBtn.onclick = () => {
      content
        .querySelectorAll(".gs-cv-collapsed")
        .forEach((el) => el.classList.remove("gs-cv-collapsed"));
    };
    collapseAllBtn.onclick = () => {
      content
        .querySelectorAll(".gs-cv-group-body, .gs-cv-item-body")
        .forEach((el) => el.classList.add("gs-cv-collapsed"));
    };

    // --- Minimize (collapse to just the header bar) ---
    let minimized = false;
    minimizeBtn.onclick = () => {
      minimized = !minimized;
      contentWrap.style.display = minimized ? "none" : "";
      panel.querySelector(".gs-cv-resize-grip").style.display = minimized
        ? "none"
        : "";
      minimizeBtn.innerHTML = minimized ? "&#43;" : "&#8722;";
      minimizeBtn.title = minimized ? "Restore" : "Minimize";
      if (minimized) {
        panel.style.height = "auto";
        panel.style.maxHeight = "none";
      } else {
        panel.style.height = "";
        panel.style.maxHeight = "";
      }
    };

    // --- Close ---
    const toggleBtn = document.createElement("button");
    toggleBtn.id = "gs-cv-toggle";
    toggleBtn.textContent = `Comments (${totalComments})`;
    toggleBtn.onclick = () => {
      panel.style.display = "flex";
      toggleBtn.style.display = "none";
    };

    closeBtn.onclick = () => {
      panel.style.display = "none";
      toggleBtn.style.display = "block";
    };

    // --- Styles ---
    if (!document.getElementById("gs-cv-styles")) {
    const style = document.createElement("style");
    style.id = "gs-cv-styles";
    style.textContent = `
      #gs-comment-viewer {
        position: fixed; top: 60px; left: 72px; width: 400px; height: calc(100vh - 200px);
        background: #fff; border: 1px solid #ccc; border-radius: 8px;
        box-shadow: 0 4px 20px rgba(0,0,0,0.15); z-index: 99999;
        display: flex; flex-direction: column; font-family: -apple-system, BlinkMacSystemFont, sans-serif;
        font-size: 14px; color: #333; overflow: hidden;
      }
      .gs-cv-header {
        padding: 8px 12px; border-bottom: 1px solid #eee; background: #f8f9fa;
        border-radius: 8px 8px 0 0; flex-shrink: 0;
      }
      .gs-cv-title-row {
        display: flex; justify-content: space-between; align-items: center;
      }
      .gs-cv-title { font-weight: 600; font-size: 15px; margin-right: 4px; }
      .gs-cv-btn-group { display: flex; gap: 4px; }
      .gs-cv-btn {
        background: none; border: 1px solid #ddd; border-radius: 4px;
        padding: 2px 8px; font-size: 12px; cursor: pointer; color: #555;
      }
      .gs-cv-btn:hover { background: #e8e8e8; color: #000; }
      .gs-cv-minimize, .gs-cv-close { font-size: 16px; padding: 0 6px; font-weight: bold; }
      .gs-cv-content-wrap { display: flex; flex-direction: column; flex: 1; min-height: 0; }
      .gs-cv-search { padding: 6px 8px; border-bottom: 1px solid #eee; flex-shrink: 0; position: relative; }
      .gs-cv-search-input {
        width: 100%; box-sizing: border-box; padding: 6px 28px 6px 10px;
        border: 1px solid #ddd; border-radius: 4px; font-size: 13px; outline: none;
      }
      .gs-cv-search-input:focus { border-color: #4a90d9; box-shadow: 0 0 0 2px rgba(74,144,217,0.2); }
      .gs-cv-search-clear {
        position: absolute; right: 14px; top: 50%; transform: translateY(-50%);
        background: none; border: none; font-size: 16px; color: #999; cursor: pointer;
        padding: 0 4px; line-height: 1;
      }
      .gs-cv-search-clear:hover { color: #333; }
      .gs-cv-content { overflow-y: auto; padding: 8px; flex: 1; }
      .gs-cv-group { margin-bottom: 6px; border: 1px solid #e8e8e8; border-radius: 6px; overflow: hidden; }
      .gs-cv-group-header {
        padding: 9px 12px; background: #f0f4f8; font-weight: 600; font-size: 14px;
        cursor: pointer; user-select: none;
      }
      .gs-cv-group-header:hover { background: #e4eaf0; }
      .gs-cv-group-body { padding: 4px 8px; }
      .gs-cv-group-body.gs-cv-collapsed { display: none; }
      .gs-cv-item { margin: 4px 0; }
      .gs-cv-item-header {
        padding: 6px 10px; background: #fafafa; font-weight: 500; font-size: 14px;
        color: #555; border-left: 3px solid #4a90d9; margin-bottom: 2px;
        cursor: pointer; user-select: none;
      }
      .gs-cv-item-header:hover { background: #f0f0f0; }
      .gs-cv-item-body { }
      .gs-cv-item-body.gs-cv-collapsed { display: none; }
      .gs-cv-comment {
        padding: 8px 12px; margin: 3px 0; background: #fffef5; border-radius: 4px;
        border: 1px solid #f0eedc; font-size: 14px; line-height: 1.5;
        cursor: pointer; transition: background 0.1s; white-space: pre-wrap;
      }
      .gs-cv-comment:hover { background: #fff9d4; }
      .gs-cv-comment.gs-cv-copied { background: #e6ffe6; border-color: #b3e6b3; }
      .gs-cv-mark { background: #ffe066; color: #000; padding: 0 1px; border-radius: 2px; }
      #gs-cv-toggle {
        position: fixed; top: 60px; left: 72px; z-index: 99998;
        background: #4a90d9; color: #fff; border: none; border-radius: 6px;
        padding: 8px 14px; font-size: 14px; font-weight: 600; cursor: pointer;
        box-shadow: 0 2px 8px rgba(0,0,0,0.15); display: none;
      }
      #gs-cv-toggle:hover { background: #3a7bc8; }
      .gs-cv-resize-grip {
        position: absolute; bottom: 0; right: 0; width: 16px; height: 16px;
        cursor: nwse-resize;
        background: linear-gradient(135deg, transparent 50%, #ccc 50%, transparent 55%, #ccc 65%, transparent 65%);
      }
    `;
    document.head.appendChild(style);
    }

    const grip = document.createElement("div");
    grip.className = "gs-cv-resize-grip";
    panel.appendChild(grip);

    makeDraggable(panel, header);
    makeResizable(panel);

    document.body.appendChild(panel);
    document.body.appendChild(toggleBtn);

    return { refresh: refreshComments };
  }

  function makeCommentEl(text) {
    const el = document.createElement("div");
    el.className = "gs-cv-comment";
    el.textContent = text;
    el.title = "Click to copy";
    el.addEventListener("click", () => {
      navigator.clipboard.writeText(text).then(() => {
        el.classList.add("gs-cv-copied");
        setTimeout(() => el.classList.remove("gs-cv-copied"), 600);
      });
    });
    return el;
  }

  // --- Intercept fetch to detect comment mutations ---
  function interceptFetch(onCommentChange) {
    const origFetch = window.fetch;
    window.fetch = function (url, opts) {
      const result = origFetch.apply(this, arguments);
      const method = (opts?.method || "GET").toUpperCase();
      if (
        method !== "GET" &&
        typeof url === "string" &&
        (url.includes("/annotation") || url.includes("/comments"))
      ) {
        result.then(() => {
          // Small delay to let the server process
          setTimeout(onCommentChange, 500);
        }).catch(() => { });
      }
      return result;
    };

    // Also intercept XMLHttpRequest for older code paths
    const origOpen = XMLHttpRequest.prototype.open;
    const origSend = XMLHttpRequest.prototype.send;
    XMLHttpRequest.prototype.open = function (method, url) {
      this._gscvMethod = method;
      this._gscvUrl = url;
      return origOpen.apply(this, arguments);
    };
    XMLHttpRequest.prototype.send = function () {
      if (
        this._gscvMethod &&
        this._gscvMethod.toUpperCase() !== "GET" &&
        typeof this._gscvUrl === "string" &&
        (this._gscvUrl.includes("/annotation") ||
          this._gscvUrl.includes("/comments"))
      ) {
        this.addEventListener("load", () => {
          setTimeout(onCommentChange, 500);
        });
      }
      return origSend.apply(this, arguments);
    };
  }

  // --- Main ---
  async function init() {
    console.log("[GS-CV] Script running on:", path);
    const { rubricItems, rubricGroups } = extractPageData();
    console.log(
      "[GS-CV] Found rubric items:",
      rubricItems.length,
      "groups:",
      rubricGroups.length
    );
    if (rubricItems.length === 0) return;

    try {
      const comments = await fetchComments();
      console.log("[GS-CV] Fetched comments:", Object.keys(comments).length);
      const grouped = groupComments(comments, rubricItems, rubricGroups);
      const { refresh } = renderPanel(grouped);
      console.log("[GS-CV] Panel rendered");

      // Auto-refresh when comments are created/updated/deleted
      interceptFetch(refresh);
    } catch (err) {
      console.error("[GS-CV] Error:", err);
      const errBtn = document.createElement("button");
      errBtn.id = "gs-cv-toggle";
      errBtn.textContent = "Comments (error loading)";
      errBtn.style.display = "block";
      errBtn.style.background = "#d9534f";
      errBtn.title = err.message;
      errBtn.onclick = () => { errBtn.remove(); init(); };
      document.body.appendChild(errBtn);
    }
  }

  init();
})();