feat(rnotes): suggestions in sidebar panel with per-item accept/reject

- Suggestions now appear as cards in the right-hand comment sidebar
  with author, type (Added/Deleted), text preview, and Accept/Reject
- Clicking a suggestion card scrolls the editor to the marked text
- Removed "Accept All" and "Reject All" from the review bar — too blunt
- Review bar now directs users to the sidebar for granular review
- Sidebar auto-opens when suggestions exist
- Inline popover accept/reject still works for quick actions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-28 16:28:42 -07:00
parent 0ec5edd1ee
commit 99fa59c8df
3 changed files with 114 additions and 13 deletions

View File

@ -40,6 +40,15 @@ interface NotebookDoc {
const REACTION_EMOJIS = ['👍', '👎', '❤️', '🎉', '😂', '😮', '🔥']; const REACTION_EMOJIS = ['👍', '👎', '❤️', '🎉', '😂', '😮', '🔥'];
interface SuggestionEntry {
id: string;
type: 'insert' | 'delete';
text: string;
authorId: string;
authorName: string;
createdAt: number;
}
class NotesCommentPanel extends HTMLElement { class NotesCommentPanel extends HTMLElement {
private shadow: ShadowRoot; private shadow: ShadowRoot;
private _noteId: string | null = null; private _noteId: string | null = null;
@ -49,6 +58,7 @@ class NotesCommentPanel extends HTMLElement {
private _editor: Editor | null = null; private _editor: Editor | null = null;
private _demoThreads: Record<string, CommentThread> | null = null; private _demoThreads: Record<string, CommentThread> | null = null;
private _space = ''; private _space = '';
private _suggestions: SuggestionEntry[] = [];
constructor() { constructor() {
super(); super();
@ -65,6 +75,10 @@ class NotesCommentPanel extends HTMLElement {
this._demoThreads = v; this._demoThreads = v;
this.render(); this.render();
} }
set suggestions(v: SuggestionEntry[]) {
this._suggestions = v;
this.render();
}
private get isDemo(): boolean { private get isDemo(): boolean {
return this._space === 'demo'; return this._space === 'demo';
@ -105,7 +119,8 @@ class NotesCommentPanel extends HTMLElement {
private render() { private render() {
const threads = this.getThreads(); const threads = this.getThreads();
if (threads.length === 0 && !this._activeThreadId) { const suggestions = this._suggestions || [];
if (threads.length === 0 && suggestions.length === 0 && !this._activeThreadId) {
this.shadow.innerHTML = ''; this.shadow.innerHTML = '';
return; return;
} }
@ -241,6 +256,48 @@ class NotesCommentPanel extends HTMLElement {
.reminder-clear { padding: 1px 4px; border: none; background: none; font-size: 10px; cursor: pointer; color: var(--rs-text-muted, #999); } .reminder-clear { padding: 1px 4px; border: none; background: none; font-size: 10px; cursor: pointer; color: var(--rs-text-muted, #999); }
.reminder-date-input { padding: 2px 6px; border: 1px solid var(--rs-input-border, #ddd); border-radius: 6px; font-size: 11px; background: var(--rs-input-bg, #fff); color: var(--rs-input-text, #111); } .reminder-date-input { padding: 2px 6px; border: 1px solid var(--rs-input-border, #ddd); border-radius: 6px; font-size: 11px; background: var(--rs-input-bg, #fff); color: var(--rs-input-text, #111); }
/* ── Suggestion Cards ── */
.suggestion-section-title {
font-weight: 600; font-size: 12px; color: #b45309;
padding: 6px 0 4px; margin-bottom: 4px;
border-bottom: 1px solid color-mix(in srgb, #f59e0b 20%, var(--rs-border-subtle, #f0f0f0));
}
.suggestion-card {
margin-bottom: 8px; padding: 10px 12px;
border-radius: 8px; border: 1px solid color-mix(in srgb, #f59e0b 25%, var(--rs-border-subtle, #e8e8e8));
background: color-mix(in srgb, #f59e0b 4%, var(--rs-bg-surface, #fff));
border-left: 3px solid #f59e0b;
}
.suggestion-card .sg-header { display: flex; align-items: center; gap: 6px; margin-bottom: 6px; }
.suggestion-card .sg-avatar {
width: 22px; height: 22px; border-radius: 50%;
display: flex; align-items: center; justify-content: center;
font-size: 9px; font-weight: 600; color: #fff; flex-shrink: 0;
}
.suggestion-card .sg-author { font-weight: 600; font-size: 12px; color: var(--rs-text-primary, #111); }
.suggestion-card .sg-time { font-size: 10px; color: var(--rs-text-muted, #aaa); margin-left: auto; }
.suggestion-card .sg-type {
font-size: 10px; font-weight: 600; padding: 1px 6px; border-radius: 4px;
}
.sg-type-insert { background: rgba(22, 163, 74, 0.1); color: #137333; }
.sg-type-delete { background: rgba(220, 38, 38, 0.1); color: #c5221f; }
.suggestion-card .sg-text {
font-size: 13px; line-height: 1.5; padding: 4px 6px;
border-radius: 4px; margin-bottom: 8px;
word-break: break-word; overflow-wrap: anywhere;
}
.sg-text-insert { background: rgba(22, 163, 74, 0.08); color: var(--rs-text-primary, #111); }
.sg-text-delete { background: rgba(220, 38, 38, 0.06); color: var(--rs-text-muted, #666); text-decoration: line-through; }
.suggestion-card .sg-actions { display: flex; gap: 6px; justify-content: flex-end; }
.sg-accept, .sg-reject {
padding: 4px 12px; border-radius: 6px; font-size: 12px; font-weight: 500;
cursor: pointer; border: 1px solid; transition: all 0.15s;
}
.sg-accept { color: #137333; border-color: #137333; background: rgba(22, 163, 74, 0.06); }
.sg-accept:hover { background: rgba(22, 163, 74, 0.15); }
.sg-reject { color: #c5221f; border-color: #c5221f; background: rgba(220, 38, 38, 0.04); }
.sg-reject:hover { background: rgba(220, 38, 38, 0.12); }
/* New comment input — shown when thread has no messages */ /* New comment input — shown when thread has no messages */
.new-comment-form { margin-top: 4px; } .new-comment-form { margin-top: 4px; }
.new-comment-input { .new-comment-input {
@ -264,9 +321,26 @@ class NotesCommentPanel extends HTMLElement {
</style> </style>
<div class="panel" id="comment-panel"> <div class="panel" id="comment-panel">
<div class="panel-title" data-action="toggle-collapse"> <div class="panel-title" data-action="toggle-collapse">
<span>Comments (${threads.filter(t => !t.resolved).length})</span> <span>${suggestions.length > 0 ? `Suggestions (${suggestions.length})` : ''} ${threads.length > 0 ? `Comments (${threads.filter(t => !t.resolved).length})` : ''}</span>
<button class="collapse-btn" title="Minimize">&#9660;</button> <button class="collapse-btn" title="Minimize">&#9660;</button>
</div> </div>
${suggestions.length > 0 ? `
${suggestions.map(s => `
<div class="suggestion-card" data-suggestion-id="${s.id}">
<div class="sg-header">
<div class="sg-avatar" style="background: ${avatarColor(s.authorId)}">${initials(s.authorName)}</div>
<span class="sg-author">${esc(s.authorName)}</span>
<span class="sg-type ${s.type === 'insert' ? 'sg-type-insert' : 'sg-type-delete'}">${s.type === 'insert' ? 'Added' : 'Deleted'}</span>
<span class="sg-time">${timeAgo(s.createdAt)}</span>
</div>
<div class="sg-text ${s.type === 'insert' ? 'sg-text-insert' : 'sg-text-delete'}">${esc(s.text)}</div>
<div class="sg-actions">
<button class="sg-accept" data-accept-suggestion="${s.id}">Accept</button>
<button class="sg-reject" data-reject-suggestion="${s.id}">Reject</button>
</div>
</div>
`).join('')}
` : ''}
${threads.map(thread => { ${threads.map(thread => {
const reactions = thread.reactions || {}; const reactions = thread.reactions || {};
const reactionEntries = Object.entries(reactions).filter(([, users]) => users.length > 0); const reactionEntries = Object.entries(reactions).filter(([, users]) => users.length > 0);
@ -355,6 +429,43 @@ class NotesCommentPanel extends HTMLElement {
} }
private wireEvents() { private wireEvents() {
// Suggestion accept/reject
this.shadow.querySelectorAll('[data-accept-suggestion]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const id = (btn as HTMLElement).dataset.acceptSuggestion;
if (id) this.dispatchEvent(new CustomEvent('suggestion-accept', { detail: { suggestionId: id }, bubbles: true, composed: true }));
});
});
this.shadow.querySelectorAll('[data-reject-suggestion]').forEach(btn => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const id = (btn as HTMLElement).dataset.rejectSuggestion;
if (id) this.dispatchEvent(new CustomEvent('suggestion-reject', { detail: { suggestionId: id }, bubbles: true, composed: true }));
});
});
// Click suggestion card to scroll editor to it
this.shadow.querySelectorAll('.suggestion-card[data-suggestion-id]').forEach(el => {
el.addEventListener('click', (e) => {
if ((e.target as HTMLElement).closest('button')) return;
const id = (el as HTMLElement).dataset.suggestionId;
if (!id || !this._editor) return;
this._editor.state.doc.descendants((node, pos) => {
if (!node.isText) return;
const mark = node.marks.find(m =>
(m.type.name === 'suggestionInsert' || m.type.name === 'suggestionDelete') &&
m.attrs.suggestionId === id
);
if (mark) {
this._editor!.commands.setTextSelection(pos);
this._editor!.commands.scrollIntoView();
return false;
}
});
});
});
// Collapse/expand panel // Collapse/expand panel
const collapseBtn = this.shadow.querySelector('[data-action="toggle-collapse"]'); const collapseBtn = this.shadow.querySelector('[data-action="toggle-collapse"]');
if (collapseBtn) { if (collapseBtn) {

View File

@ -3935,16 +3935,6 @@ Gear: EUR 400 (10%)</code></pre><p><em>Maya is tracking expenses in rF
.srb-label { font-weight: 600; color: #b45309; } .srb-label { font-weight: 600; color: #b45309; }
.srb-count { margin-left: auto; color: var(--rs-text-muted, #999); } .srb-count { margin-left: auto; color: var(--rs-text-muted, #999); }
.srb-hint { color: var(--rs-text-muted, #999); font-style: italic; } .srb-hint { color: var(--rs-text-muted, #999); font-style: italic; }
.srb-btn {
padding: 3px 10px; border-radius: 4px; border: 1px solid var(--rs-border, #ddd);
font-size: 11px; cursor: pointer; font-weight: 500; background: var(--rs-bg-surface, #fff);
color: var(--rs-text-primary, #111);
}
.srb-btn:hover { background: var(--rs-bg-hover, #f5f5f5); }
.srb-accept-all { color: #137333; border-color: #137333; }
.srb-accept-all:hover { background: rgba(22, 163, 74, 0.08); }
.srb-reject-all { color: #c5221f; border-color: #c5221f; }
.srb-reject-all:hover { background: rgba(220, 38, 38, 0.08); }
/* ── Suggestion Popover (accept/reject on click) ── */ /* ── Suggestion Popover (accept/reject on click) ── */
.suggestion-popover { .suggestion-popover {

View File

@ -1622,7 +1622,7 @@ routes.get("/", (c) => {
modules: getModuleInfoList(), modules: getModuleInfoList(),
theme: "dark", theme: "dark",
body: `<folk-notes-app space="${space}"></folk-notes-app>`, body: `<folk-notes-app space="${space}"></folk-notes-app>`,
scripts: `<script type="module" src="/modules/rnotes/folk-notes-app.js?v=10"></script>`, scripts: `<script type="module" src="/modules/rnotes/folk-notes-app.js?v=11"></script>`,
styles: `<link rel="stylesheet" href="/modules/rnotes/notes.css?v=5">`, styles: `<link rel="stylesheet" href="/modules/rnotes/notes.css?v=5">`,
})); }));
}); });