Skip to content

feat(viewer): mark comments as fixed/solved/ignored and hide them - #1101

Open
nitishagar wants to merge 1 commit into
alibaba:mainfrom
nitishagar:feat/825-viewer-comment-marks
Open

feat(viewer): mark comments as fixed/solved/ignored and hide them#1101
nitishagar wants to merge 1 commit into
alibaba:mainfrom
nitishagar:feat/825-viewer-comment-marks

Conversation

@nitishagar

Copy link
Copy Markdown
Contributor

What

Implements #825: in the ocr viewer session page, each review comment card gains Fixed / Solved / Ignored buttons (mutually exclusive; setting one replaces another, Clear undoes) and the toolbar gains a Hide marked checkbox (default on, persisted per browser) plus Clear all marks. This is the workflow the issue asks for: walk the findings, mark as you fix, hide what is done, toggle back anytime to see everything.

The session data stays untouched — marks are viewer state stored outside the read-only session store, per the design framing in the issue thread.

How

  • Identity: each comment gets a stable mark ID derived from its owning session record's uuid + its index within that record. Session files are immutable, so the identity is reload-stable and collision-free; legacy uuid-less records fall back to a full-field hash with an occurrence counter (exact duplicates stay distinct).
  • Storage: one sidecar per session, <sessionID>.marks.json beside the jsonl (different suffix — invisible to the session listing). Written atomically (temp + rename, 0600); corrupt/missing file degrades to "no marks"; deleting it restores the pristine view. The viewer never opens a .jsonl for write (asserted by a test).
  • API: POST /r/{repo}/{sessionID}/marks ({mark_id, state}, empty state clears; response echoes the authoritative state so divergent tabs converge) and DELETE/marks (clear all). Same path validation as existing routes, 64 KiB body cap, strict JSON decoding, JSON content-type required, wrong verbs get 405 + Allow.
  • UI: server-rendered state chip per card; session.js wires the buttons via fetch and applies the echoed state; hide-marked composes with the existing severity/category filters through the same group-visibility/count path (per-file counts and the empty state reflect hidden cards).

Semantics chosen (answering the open question from the thread)

  • One state per comment, mutually exclusive — fixed/solved/ignored are alternatives, replace-on-set.
  • Marks are session-scoped: comments have no identity across review runs, so a mark does not carry to a later re-review of the same PR. If cross-run continuity is wanted, that needs a stable cross-run comment identity first — happy to take that as a follow-up.
  • Hide-marked defaults on (hide-as-you-fix); it is presentation state, so it lives in localStorage rather than the server.

Screenshot-friendly summary of placement: the toggle and Clear-all sit on the existing filter bar above the comment groups; the mark buttons sit at the bottom of each comment card; a marked card shows a colored state chip next to its category/severity badges.

Tests

Store (identity stability/duplicates/unicode fallback), marks file (round trip, corrupt ⇒ empty, 0600, atomicity, failed write keeps previous state), API (set/clear/echo, 400/404/405+Allow cases, traversal, concurrent posts, write failure 500, content-type enforcement), rendering (marked chip + data-mark, unmarked identical to today, zero-comment session, session jsonl byte-immutability). Full suite green under -race.

Closes #825

@github-actions

github-actions Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

🔍 OpenCodeReview found 7 issue(s) in this PR.

  • ✅ Successfully posted inline: 7 comment(s)

Comment on lines +113 to +117
// Marks exist only for sessions that exist.
if _, err := os.Stat(filepath.Join(root, repo, sessionID+".jsonl")); err != nil {
http.Error(w, "session not found", http.StatusNotFound)
return
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

security · medium
For POST requests, the session existence check (os.Stat) runs before the Content-Type validation below (line 132). This creates an information oracle: an attacker can enumerate valid session IDs by sending POST requests with any Content-Type (e.g., text/plain) and distinguishing 404 (session not found) from 415 (wrong content type). Since the Content-Type check is explicitly documented as a CSRF defense, the existence check should be moved after it for POST requests so that unauthorized requests are rejected before touching the filesystem.

Consider restructuring so that for POST, the Content-Type check happens first, then the session existence check. DELETE does not need this reordering since it has no body-based CSRF vector.

Comment on lines +164 to +172
func writeMarksResponse(w http.ResponseWriter, markID, state string) {
if err := json.NewEncoder(w).Encode(map[string]any{
"ok": true,
"mark_id": markID,
"state": state,
}); err != nil {
http.Error(w, fmt.Sprintf("encode response: %v", err), http.StatusInternalServerError)
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
If json.NewEncoder(w).Encode() fails after partially writing the response body, the HTTP headers (200 OK, Content-Type: application/json) have already been flushed. The subsequent http.Error call will attempt to write a 500 status and text/plain content type, but these will be silently ignored since headers were already sent. The client would receive a truncated JSON body with a 200 status code.

To fix this, either marshal to a buffer first and write in one shot, or simply log the error (the client will see a broken response regardless):

func writeMarksResponse(w http.ResponseWriter, markID, state string) {
    resp := map[string]any{"ok": true, "mark_id": markID, "state": state}
    data, err := json.Marshal(resp)
    if err != nil {
        http.Error(w, fmt.Sprintf("encode response: %v", err), http.StatusInternalServerError)
        return
    }
    w.Write(data)
}

Comment thread internal/viewer/static/session.js Outdated
let activeCategory = 'all';
// Hide-marked is presentation, not data: the default (on) matches the
// mark-and-hide workflow, and the choice persists per browser only.
let hideMarked = hideMarkedToggle ? (localStorage.getItem('ocr-viewer-hide-marked') || '1') === '1' : false;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
localStorage safety: Both localStorage.getItem and localStorage.setItem can throw in certain browser environments (e.g., private browsing with quota exceeded, or storage disabled by policy). An unhandled exception here would prevent the entire filter/marks feature from initializing. Wrap localStorage access in try-catch to degrade gracefully.

Suggestion:

Suggested change
let hideMarked = hideMarkedToggle ? (localStorage.getItem('ocr-viewer-hide-marked') || '1') === '1' : false;
let hideMarked = false;
if (hideMarkedToggle) {
try {
hideMarked = (localStorage.getItem('ocr-viewer-hide-marked') || '1') === '1';
} catch (_) {
hideMarked = true; // default when storage is unavailable
}
}

Comment thread internal/viewer/static/session.js Outdated
Comment on lines +120 to +121
card.dataset.mark = state;
card.setAttribute('data-mark', state);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · low
Redundant DOM manipulation: Setting both card.dataset.mark and card.setAttribute('data-mark', state) is redundant — they are two interfaces to the same underlying attribute. Either one suffices. Since the rest of this file reads marks via card.dataset.mark, using only card.dataset.mark = state would be consistent and sufficient.

Additionally, the state value is interpolated directly into a CSS class name ('mark-' + state) without validation on the client side. While the server validates against validMarkStates, if the response were ever tampered with or a bug introduced server-side, an unexpected value could inject arbitrary CSS classes. Consider validating state against an allowlist before constructing the class name.

Suggestion:

Suggested change
card.dataset.mark = state;
card.setAttribute('data-mark', state);
card.dataset.mark = state;

Comment on lines +127 to +133
} else {
delete card.dataset.mark;
card.removeAttribute('data-mark');
if (chip) {
chip.hidden = true;
}
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · low
Stale chip text/class on clear: When clearing a mark (state is falsy), the chip is hidden but its textContent and className are not reset. If the chip becomes visible again through any path other than applyMarkState with a truthy state (e.g., a future code change, or a DOM inspector toggle), it would display the stale previous mark text and color class. Reset them defensively:

Suggestion:

Suggested change
} else {
delete card.dataset.mark;
card.removeAttribute('data-mark');
if (chip) {
chip.hidden = true;
}
}
} else {
delete card.dataset.mark;
card.removeAttribute('data-mark');
if (chip) {
chip.textContent = '';
chip.className = 'comment-badge mark-chip';
chip.hidden = true;
}
}

Comment thread internal/viewer/static/session.js Outdated
Comment on lines +162 to +164
.catch(function(err) {
console.error('[ocr viewer]', err);
});

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

maintainability · medium
Async Error Handling: When a mark POST or clear-all DELETE request fails, the error is only logged to console.error with no user-facing feedback. Users will have no indication that their action failed to persist, and the UI may show stale state (e.g., a mark appears applied locally but was never saved). Consider adding a lightweight user-visible notification (e.g., a toast, or temporarily reverting the UI change) so failures are not silent.

Comment thread internal/viewer/static/session.js Outdated
hideMarkedToggle.checked = hideMarked;
hideMarkedToggle.addEventListener('change', function() {
hideMarked = hideMarkedToggle.checked;
localStorage.setItem('ocr-viewer-hide-marked', hideMarked ? '1' : '0');

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

bug · medium
Same localStorage safety issue: This setItem call can also throw if storage is full or disabled. Wrap in try-catch to avoid breaking the toggle handler.

Suggestion:

Suggested change
localStorage.setItem('ocr-viewer-hide-marked', hideMarked ? '1' : '0');
try { localStorage.setItem('ocr-viewer-hide-marked', hideMarked ? '1' : '0'); } catch (_) {}

@nitishagar
nitishagar force-pushed the feat/825-viewer-comment-marks branch 2 times, most recently from 5028957 to b518b4e Compare August 28, 2026 13:45

@wu21-web wu21-web left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  1. Concurrent UI mutations can leave the page inconsistent with disk
  2. Documentation changes incomplete
  3. Too many code comments
  4. Strict JSON parsing accepts trailing JSON values

Comment thread internal/viewer/store.go Outdated
Comment on lines +243 to +247
// MarkID is the stable identity used to bind viewer marks (fixed /
// solved / ignored) to this comment. Session files are immutable, so the
// identity is reload-stable: record uuid + comment index for records that
// carry a uuid, a full-field hash plus an occurrence counter otherwise.
MarkID string `json:"-"`

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you please clean up those code comments?

Suggested change
// MarkID is the stable identity used to bind viewer marks (fixed /
// solved / ignored) to this comment. Session files are immutable, so the
// identity is reload-stable: record uuid + comment index for records that
// carry a uuid, a full-field hash plus an occurrence counter otherwise.
MarkID string `json:"-"`
MarkID string `json:"-"`

Comment thread README.md

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The documentation changes are not complete. At the very least, add the details about this feature to the corresponding site documentation section. Currently deployed at https://open-codereview.ai/docs/viewer

Comment on lines +145 to +147
if err := dec.Decode(&req); err != nil {
http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest)
return

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

EOF after the first object

Suggested change
if err := dec.Decode(&req); err != nil {
http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest)
return
if err := dec.Decode(&req); err != nil {
http.Error(w, fmt.Sprintf("invalid request body: %v", err), http.StatusBadRequest)
return
}
if err := dec.Decode(&struct{}{}); err != io.EOF {
http.Error(w, "request body must contain exactly one JSON object", http.StatusBadRequest)
return
}

Comment on lines +137 to +140
// The server whitelists states; this mirrors the same set so a tampered
// or buggy response can never grow an arbitrary class name on the chip.
const knownMarkStates = { fixed: true, solved: true, ignored: true };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// The server whitelists states; this mirrors the same set so a tampered
// or buggy response can never grow an arbitrary class name on the chip.
const knownMarkStates = { fixed: true, solved: true, ignored: true };
// The server whitelists states; this mirrors the same set so a tampered
// or buggy response can never grow an arbitrary class name on the chip.
const knownMarkStates = { fixed: true, solved: true, ignored: true };
let marksMutationQueue = Promise.resolve();
function enqueueMarksMutation(operation) {
const result = marksMutationQueue.then(operation);
marksMutationQueue = result.catch(function() {
});
return result;
}

Comment on lines 6 to 12
import (
"encoding/json"
"fmt"
"mime"
"net/http"
"os"
"path/filepath"

@wu21-web wu21-web Aug 28, 2026

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
import (
"encoding/json"
"fmt"
"mime"
"net/http"
"os"
"path/filepath"
import (
"encoding/json"
"fmt"
"io"
"mime"
"net/http"
"os"
"path/filepath"

Walking through a long comment list in the session viewer had no way
to track progress: every finding stayed on screen no matter what was
already addressed. This adds per-comment marks (fixed / solved /
ignored, mutually exclusive; replace-on-set, clear to undo) with a
Hide marked toggle (default on, persisted per browser) and a Clear
all marks control.

Marks are viewer state, not session data: they live in a per-session
sidecar file <sessionID>.marks.json next to the immutable session
jsonl (a different suffix, so session listings never see it), written
atomically via temp+rename with 0600 permissions. The session store
itself is never opened for write; deleting the sidecar restores the
unmarked view exactly.

Each comment is identified by a stable mark ID derived from its owning
record's uuid plus its index within that record (legacy uuid-less
records fall back to a full-field hash with an occurrence counter, so
exact duplicates stay distinct). Mutation goes through two new
endpoints, POST and DELETE /r/{repo}/{sessionID}/marks, with the same
path validation as the existing routes, a 64 KiB body cap, strict
single-object JSON decoding (trailing data after the object is a
400), and a required JSON content type; responses echo the
authoritative state so concurrent tabs converge on their next
interaction. A corrupt or unreadable marks file degrades to no marks
rather than breaking the page.

On the client, mark mutations run through a serial promise queue, so
rapid clicks — or a set racing Clear all — apply responses in order
and a stale echo can never resurrect a replaced or cleared mark.

Docs: the Session Viewer page on the docs site (en/ja/ko/ru/zh)
gains a Review comments section covering the cards, filters, marks,
the hide-marked toggle, and the marks sidecar in the on-disk layout.

Closes alibaba#825
@nitishagar
nitishagar force-pushed the feat/825-viewer-comment-marks branch from b518b4e to e806fe5 Compare August 31, 2026 15:21
@lizhengfeng101

Copy link
Copy Markdown
Contributor

Thanks @nitishagar — the MarkID design is genuinely good (record uuid + index is exactly the right identity, and the legacy sha256 fallback is a thoughtful touch), and the security work is above the usual bar.

But I have to hold the line on the architecture, and I owe you a better explanation than the issue thread gave. ocr viewer is deliberately read-only: it holds no state of its own and is a pure function of the JSONL on disk. That's what keeps it dependency-free, lets us promise session data is never mutated by viewing it, and keeps the browser-facing surface of a tool that exposes users' source code down to GETs. This PR crosses that line, and it doesn't uncross — after merge we own a write API, a CSRF surface, a versioned sidecar format, a global write lock, orphanable temp files, and .marks.json accumulating in ~/.opencodereview/sessions/ with no reclaim path (we have no session cleanup command today).

Keep the feature, drop the server. Re-reading @iredmail's scenario — "i may leave and come back to continue" — the persistence he needs is "close the tab, come back tomorrow." localStorage keyed per session gives that, plus exactly the session-scoped semantics you already argued for correctly. So: keep MarkID in store.go and data-mark-id in the template (server-side identity is the right call), keep the buttons, chip, toggle, and filter composition. Drop marks.go, handleMarks, the route, and the tests that only exist for them — roughly 150 lines instead of 1200, and "read-only" becomes true rather than argued. Your own split already points here: hide-marked is in localStorage as presentation state, and the marks are too.

Two smaller things for the reduced version:

  • Collapse fixed/solved into one state — they're synonyms for a review comment, and the issue's fixed/solved/ignore/… is examples, not a spec. Free to change now, not after values are on disk.
  • The chip sets border-color on .comment-badge, which has no border declaration, so no border renders; and there's no dark-mode override the way every .cat-* badge has. Match the filled-badge pattern at style.css:1045.

The thread stalled with us asking for a screenshot and going quiet — not a fair signal to build 1200 lines against, and that's on us. This isn't a no. I want the feature and I'd like you to land it; if you're up for the reduced form I'll review promptly. If you'd rather not, say so and I'll take the MarkID design from here with credit to you.

Also please update pages/src/content/docs/{en,zh,ja,ko,ru}/viewer.md — the READMEs are synced but the docs pages aren't.

@iredmail

iredmail commented Sep 1, 2026

Copy link
Copy Markdown

Thanks for making it happen guys. :)

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Feature request: viewer: mark comment as Fixed/Solved/Ignore and hide

4 participants