From e0e9802bd7a94453efcadb58a0c9f3f69f4a09b3 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 25 Mar 2026 16:45:45 -0700 Subject: [PATCH 1/4] feat(rwallet): use CoinGecko Demo API key for batch token pricing Reads COINGECKO_API_KEY from env (injected via Infisical) and appends x_cg_demo_api_key param. Enables batch lookups + spam filtering. Co-Authored-By: Claude Opus 4.6 --- modules/rwallet/lib/price-feed.ts | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/modules/rwallet/lib/price-feed.ts b/modules/rwallet/lib/price-feed.ts index 10f0d29..0f888e5 100644 --- a/modules/rwallet/lib/price-feed.ts +++ b/modules/rwallet/lib/price-feed.ts @@ -42,15 +42,23 @@ const TTL = 5 * 60 * 1000; // 5 minutes const cache = new Map(); const inFlight = new Map>(); +const CG_API_KEY = process.env.COINGECKO_API_KEY || ""; + +function cgUrl(url: string): string { + if (!CG_API_KEY) return url; + const sep = url.includes("?") ? "&" : "?"; + return `${url}${sep}x_cg_demo_api_key=${CG_API_KEY}`; +} + async function cgFetch(url: string): Promise { - const res = await fetch(url, { + const res = await fetch(cgUrl(url), { headers: { accept: "application/json" }, signal: AbortSignal.timeout(10000), }); if (res.status === 429) { console.warn("[price-feed] CoinGecko rate limited, waiting 60s..."); await new Promise((r) => setTimeout(r, 60000)); - const retry = await fetch(url, { + const retry = await fetch(cgUrl(url), { headers: { accept: "application/json" }, signal: AbortSignal.timeout(10000), }); From 0db5addc17a387e1657960e61e385229f35a8df5 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 25 Mar 2026 16:48:11 -0700 Subject: [PATCH 2/4] fix(rdesign): switch to StreamableHTTP transport, fix KiCad Python path SSE transport crashes on concurrent connections (supergateway single-session limit). StreamableHTTP supports multiple sessions. Also set KICAD_PYTHON=/usr/bin/python3 for existsSync validation and install missing requests package. Co-Authored-By: Claude Opus 4.6 --- docker/freecad-mcp/Dockerfile | 5 +- docker/kicad-mcp/Dockerfile | 17 +- modules/rnotes/components/comment-panel.ts | 37 +- modules/rnotes/components/folk-notes-app.ts | 209 +++++++++- .../rnotes/components/suggestion-plugin.ts | 386 +++++++++++------- server/index.ts | 14 +- 6 files changed, 486 insertions(+), 182 deletions(-) diff --git a/docker/freecad-mcp/Dockerfile b/docker/freecad-mcp/Dockerfile index ccd7a19..a6e0e0a 100644 --- a/docker/freecad-mcp/Dockerfile +++ b/docker/freecad-mcp/Dockerfile @@ -16,7 +16,7 @@ WORKDIR /app # Copy MCP server source COPY freecad-mcp-server/ . -# Install Node deps + supergateway (stdio→SSE bridge) +# Install Node deps + supergateway (stdio→HTTP bridge) RUN npm install && npm install -g supergateway # Ensure generated files dir exists @@ -24,4 +24,5 @@ RUN mkdir -p /data/files/generated EXPOSE 8808 -CMD ["supergateway", "--stdio", "node build/index.js", "--port", "8808"] +# Use StreamableHttp (supports multiple concurrent connections, unlike SSE) +CMD ["supergateway", "--stdio", "node build/index.js", "--port", "8808", "--outputTransport", "streamableHttp"] diff --git a/docker/kicad-mcp/Dockerfile b/docker/kicad-mcp/Dockerfile index b803f0c..19344b7 100644 --- a/docker/kicad-mcp/Dockerfile +++ b/docker/kicad-mcp/Dockerfile @@ -1,31 +1,38 @@ FROM node:20-slim -# Install KiCad, Python, and build dependencies +# Install KiCad (includes pcbnew Python module), Python, and build dependencies RUN apt-get update && apt-get install -y --no-install-recommends \ kicad \ python3 \ python3-pip \ - python3-venv \ ca-certificates \ && rm -rf /var/lib/apt/lists/* # Use SWIG backend (headless — no KiCad GUI needed) ENV KICAD_BACKEND=swig +# Ensure pcbnew module is findable (installed by kicad package) +ENV PYTHONPATH=/usr/lib/python3/dist-packages +# Point KiCad MCP to system Python (absolute path for existsSync validation) +ENV KICAD_PYTHON=/usr/bin/python3 WORKDIR /app # Copy MCP server source COPY KiCAD-MCP-Server/ . +# Remove any venv so the server uses system Python (which has pcbnew) +RUN rm -rf .venv venv + # Install Node deps + supergateway (stdio→SSE bridge) RUN npm install && npm install -g supergateway -# Install Python requirements (Pillow, cairosvg, etc.) -RUN pip3 install --break-system-packages -r python/requirements.txt +# Install Python requirements into system Python (Pillow, cairosvg, requests, etc.) +RUN pip3 install --break-system-packages -r python/requirements.txt requests # Ensure generated files dir exists RUN mkdir -p /data/files/generated EXPOSE 8809 -CMD ["supergateway", "--stdio", "node dist/index.js", "--port", "8809"] +# Use StreamableHttp (supports multiple concurrent connections, unlike SSE) +CMD ["supergateway", "--stdio", "node dist/index.js", "--port", "8809", "--outputTransport", "streamableHttp"] diff --git a/modules/rnotes/components/comment-panel.ts b/modules/rnotes/components/comment-panel.ts index a237e02..1f34b1b 100644 --- a/modules/rnotes/components/comment-panel.ts +++ b/modules/rnotes/components/comment-panel.ts @@ -130,14 +130,25 @@ class NotesCommentPanel extends HTMLElement { this.shadow.innerHTML = ` -
-
+
+
Comments (${threads.filter(t => !t.resolved).length}) +
${threads.map(thread => { const reactions = thread.reactions || {}; @@ -334,6 +347,16 @@ class NotesCommentPanel extends HTMLElement { } private wireEvents() { + // Collapse/expand panel + const collapseBtn = this.shadow.querySelector('[data-action="toggle-collapse"]'); + if (collapseBtn) { + collapseBtn.addEventListener('click', (e) => { + if ((e.target as HTMLElement).closest('.thread, input, textarea, button:not(.collapse-btn)')) return; + const panel = this.shadow.getElementById('comment-panel'); + if (panel) panel.classList.toggle('collapsed'); + }); + } + // Click thread to scroll editor to it this.shadow.querySelectorAll('.thread[data-thread]').forEach(el => { el.addEventListener('click', (e) => { diff --git a/modules/rnotes/components/folk-notes-app.ts b/modules/rnotes/components/folk-notes-app.ts index ba5d9e8..d063d08 100644 --- a/modules/rnotes/components/folk-notes-app.ts +++ b/modules/rnotes/components/folk-notes-app.ts @@ -36,7 +36,7 @@ import { ySyncPlugin, yUndoPlugin, yCursorPlugin } from '@tiptap/y-tiptap'; import { RSpaceYjsProvider } from '../yjs-ws-provider'; import { CommentMark } from './comment-mark'; import { SuggestionInsertMark, SuggestionDeleteMark } from './suggestion-marks'; -import { createSuggestionPlugin } from './suggestion-plugin'; +import { createSuggestionPlugin, acceptSuggestion, rejectSuggestion, acceptAllSuggestions, rejectAllSuggestions } from './suggestion-plugin'; import './comment-panel'; const lowlight = createLowlight(common); @@ -1335,7 +1335,6 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF const s = this.getSessionInfo(); return { authorId: s.userId, authorName: s.username }; }, - () => this.editor?.view ?? null, ); this.editor.registerPlugin(suggestionPlugin); @@ -2303,6 +2302,118 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF if (container) { container.classList.toggle('suggesting-mode', this.suggestingMode); } + + // Show/hide suggestion review bar + this.updateSuggestionReviewBar(); + } + + /** Show a review bar when there are pending suggestions. */ + private updateSuggestionReviewBar() { + let bar = this.shadow.getElementById('suggestion-review-bar'); + if (!this.editor) { + bar?.remove(); + return; + } + + // Count suggestions + const ids = new Set(); + this.editor.state.doc.descendants((node: any) => { + if (!node.isText) return; + for (const mark of node.marks) { + if (mark.type.name === 'suggestionInsert' || mark.type.name === 'suggestionDelete') { + ids.add(mark.attrs.suggestionId); + } + } + }); + + if (ids.size === 0 && !this.suggestingMode) { + bar?.remove(); + return; + } + + if (!bar) { + bar = document.createElement('div'); + bar.id = 'suggestion-review-bar'; + bar.className = 'suggestion-review-bar'; + // Insert after toolbar + const toolbar = this.shadow.getElementById('editor-toolbar'); + if (toolbar?.parentNode) { + toolbar.parentNode.insertBefore(bar, toolbar.nextSibling); + } + } + + bar.innerHTML = ` + ${this.suggestingMode ? 'Suggesting' : 'Editing'} + ${ids.size > 0 ? ` + ${ids.size} suggestion${ids.size !== 1 ? 's' : ''} + + + ` : 'Start typing to suggest changes'} + `; + + // Wire buttons + bar.querySelector('[data-action="accept-all-suggestions"]')?.addEventListener('click', () => { + if (this.editor) { + acceptAllSuggestions(this.editor); + this.updateSuggestionReviewBar(); + } + }); + bar.querySelector('[data-action="reject-all-suggestions"]')?.addEventListener('click', () => { + if (this.editor) { + rejectAllSuggestions(this.editor); + this.updateSuggestionReviewBar(); + } + }); + } + + /** Show an accept/reject popover near a clicked suggestion mark. */ + private showSuggestionPopover(suggestionId: string, authorName: string, type: 'insert' | 'delete', rect: DOMRect) { + // Remove any existing popover + this.shadow.querySelector('.suggestion-popover')?.remove(); + + const pop = document.createElement('div'); + pop.className = 'suggestion-popover'; + + const hostRect = (this.shadow.host as HTMLElement).getBoundingClientRect(); + pop.style.left = `${rect.left - hostRect.left}px`; + pop.style.top = `${rect.bottom - hostRect.top + 4}px`; + + pop.innerHTML = ` +

+ ${this.esc(authorName)} + ${type === 'insert' ? 'Added' : 'Deleted'} +
+
+ + +
+ `; + + pop.querySelector('.sp-accept')!.addEventListener('click', () => { + if (this.editor) { + acceptSuggestion(this.editor, suggestionId); + this.updateSuggestionReviewBar(); + } + pop.remove(); + }); + pop.querySelector('.sp-reject')!.addEventListener('click', () => { + if (this.editor) { + rejectSuggestion(this.editor, suggestionId); + this.updateSuggestionReviewBar(); + } + pop.remove(); + }); + + this.shadow.appendChild(pop); + + // Close on click outside + const close = (e: Event) => { + if (!pop.contains((e as MouseEvent).target as Node)) { + pop.remove(); + this.shadow.removeEventListener('click', close); + } + }; + setTimeout(() => this.shadow.addEventListener('click', close), 0); } /** Show comment panel for a specific thread. */ @@ -2343,7 +2454,7 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF if (sidebar) sidebar.classList.remove('has-comments'); } - /** Wire click handling on comment highlights in the editor to open comment panel. */ + /** Wire click handling on comment highlights and suggestion marks in the editor. */ private wireCommentHighlightClicks() { if (!this.editor) return; @@ -2358,15 +2469,36 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF } }); - // Direct click on comment highlight in the DOM + // On any change, update the suggestion review bar + this.editor.on('update', () => { + this.updateSuggestionReviewBar(); + }); + + // Direct click on comment highlight or suggestion marks in the DOM const container = this.shadow.getElementById('tiptap-container'); if (container) { container.addEventListener('click', (e) => { const target = e.target as HTMLElement; + + // Comment highlights const highlight = target.closest?.('.comment-highlight') as HTMLElement; if (highlight) { const threadId = highlight.getAttribute('data-thread-id'); if (threadId) this.showCommentPanel(threadId); + return; + } + + // Suggestion marks — show accept/reject popover + const suggestionEl = target.closest?.('.suggestion-insert, .suggestion-delete') as HTMLElement; + if (suggestionEl) { + const suggestionId = suggestionEl.getAttribute('data-suggestion-id'); + const authorName = suggestionEl.getAttribute('data-author-name') || 'Unknown'; + const type = suggestionEl.classList.contains('suggestion-insert') ? 'insert' : 'delete'; + if (suggestionId) { + const rect = suggestionEl.getBoundingClientRect(); + this.showSuggestionPopover(suggestionId, authorName, type as 'insert' | 'delete', rect); + } + return; } }); } @@ -3060,6 +3192,7 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF } .comment-sidebar.has-comments { width: 280px; + overflow-y: auto; border-left: 1px solid var(--rs-border, #e5e7eb); } @media (max-width: 768px) { @@ -3574,16 +3707,24 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF /* ── Collaboration: Suggestions ── */ .tiptap-container .tiptap .suggestion-insert { - color: #16a34a; - background: rgba(22, 163, 74, 0.08); - border-bottom: 1px dashed #16a34a; + color: #137333; + background: rgba(22, 163, 74, 0.1); + border-bottom: 2px solid rgba(22, 163, 74, 0.4); text-decoration: none; + cursor: pointer; + } + .tiptap-container .tiptap .suggestion-insert:hover { + background: rgba(22, 163, 74, 0.18); } .tiptap-container .tiptap .suggestion-delete { - color: #dc2626; + color: #c5221f; background: rgba(220, 38, 38, 0.08); text-decoration: line-through; - text-decoration-color: #dc2626; + text-decoration-color: #c5221f; + cursor: pointer; + } + .tiptap-container .tiptap .suggestion-delete:hover { + background: rgba(220, 38, 38, 0.15); } .tiptap-container.suggesting-mode { border-left: 3px solid #f59e0b; @@ -3592,6 +3733,56 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF background: #f59e0b !important; color: #fff !important; } + + /* ── Suggestion Review Bar ── */ + .suggestion-review-bar { + display: flex; align-items: center; gap: 8px; + padding: 4px 12px; height: 32px; + background: color-mix(in srgb, #f59e0b 8%, var(--rs-bg-surface, #fff)); + border-bottom: 1px solid color-mix(in srgb, #f59e0b 20%, var(--rs-border, #e5e7eb)); + font-size: 12px; color: var(--rs-text-secondary, #666); + } + .srb-label { font-weight: 600; color: #b45309; } + .srb-count { margin-left: auto; color: var(--rs-text-muted, #999); } + .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 { + position: absolute; + z-index: 100; + background: var(--rs-bg-surface, #fff); + border: 1px solid var(--rs-border, #e5e7eb); + border-radius: 8px; + box-shadow: 0 2px 12px rgba(0,0,0,0.12); + padding: 8px 10px; + display: flex; flex-direction: column; gap: 6px; + min-width: 140px; + font-size: 12px; + } + .sp-header { display: flex; align-items: center; gap: 6px; } + .sp-author { font-weight: 600; color: var(--rs-text-primary, #111); } + .sp-type { color: var(--rs-text-muted, #999); font-size: 11px; } + .sp-actions { display: flex; gap: 6px; } + .sp-btn { + flex: 1; padding: 4px 0; border: 1px solid var(--rs-border, #ddd); + border-radius: 4px; cursor: pointer; font-size: 14px; font-weight: 600; + background: var(--rs-bg-surface, #fff); text-align: center; + } + .sp-btn:hover { background: var(--rs-bg-hover, #f5f5f5); } + .sp-accept { color: #137333; border-color: #137333; } + .sp-accept:hover { background: rgba(22, 163, 74, 0.08); } + .sp-reject { color: #c5221f; border-color: #c5221f; } + .sp-reject:hover { background: rgba(220, 38, 38, 0.08); } `; } } diff --git a/modules/rnotes/components/suggestion-plugin.ts b/modules/rnotes/components/suggestion-plugin.ts index 04078e3..649b455 100644 --- a/modules/rnotes/components/suggestion-plugin.ts +++ b/modules/rnotes/components/suggestion-plugin.ts @@ -1,227 +1,315 @@ /** - * ProseMirror plugin that intercepts transactions in "suggesting" mode - * and converts them into track-changes marks instead of direct edits. + * ProseMirror plugin that intercepts user input in "suggesting" mode + * and converts edits into track-changes marks instead of direct mutations. * * In suggesting mode: - * - Insertions → wraps inserted text with `suggestionInsert` mark - * - Deletions → converts to `suggestionDelete` mark instead of deleting + * - Typed text → inserted with `suggestionInsert` mark (green underline) + * - Backspace/Delete → text NOT deleted, marked with `suggestionDelete` (red strikethrough) + * - Select + type → old text gets `suggestionDelete`, new text gets `suggestionInsert` + * - Paste → same as select + type * - * Accept/reject operations remove marks (and optionally the text). + * Uses ProseMirror props (handleTextInput, handleKeyDown, handlePaste) rather + * than filterTransaction for reliability. */ -import { Plugin, PluginKey, type Transaction } from '@tiptap/pm/state'; +import { Plugin, PluginKey, TextSelection } from '@tiptap/pm/state'; import type { EditorView } from '@tiptap/pm/view'; +import type { Slice } from '@tiptap/pm/model'; import type { Editor } from '@tiptap/core'; const pluginKey = new PluginKey('suggestion-plugin'); -interface SuggestionPluginState { - suggesting: boolean; - authorId: string; - authorName: string; +function makeSuggestionId(): string { + return `s_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; } /** * Create the suggestion mode ProseMirror plugin. * @param getSuggesting - callback that returns current suggesting mode state * @param getAuthor - callback that returns { authorId, authorName } - * @param getView - callback that returns the EditorView (needed to dispatch replacement transactions) */ export function createSuggestionPlugin( getSuggesting: () => boolean, getAuthor: () => { authorId: string; authorName: string }, - getView?: () => EditorView | null, ): Plugin { return new Plugin({ key: pluginKey, - filterTransaction(tr: Transaction, state) { - if (!getSuggesting()) return true; - if (!tr.docChanged) return true; - if (tr.getMeta('suggestion-accept') || tr.getMeta('suggestion-reject')) return true; - if (tr.getMeta('suggestion-applied')) return true; + props: { + /** Intercept typed text — insert with suggestionInsert mark. */ + handleTextInput(view: EditorView, from: number, to: number, text: string): boolean { + if (!getSuggesting()) return false; - // Intercept the transaction and convert it to suggestion marks - const { authorId, authorName } = getAuthor(); - const suggestionId = `s_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`; + const { state } = view; + const { authorId, authorName } = getAuthor(); + const suggestionId = makeSuggestionId(); + const tr = state.tr; - // We need to rebuild the transaction with suggestion marks - const newTr = state.tr; - let blocked = false; + // If there's a selection (replacement), mark the selected text as deleted + if (from !== to) { + // Check if selected text is all suggestionInsert from the same author + // → if so, just replace it (editing your own suggestion) + const ownInsert = isOwnSuggestionInsert(state, from, to, authorId); + if (ownInsert) { + tr.replaceWith(from, to, state.schema.text(text, [ + state.schema.marks.suggestionInsert.create({ + suggestionId: ownInsert, authorId, authorName, createdAt: Date.now(), + }), + ])); + tr.setMeta('suggestion-applied', true); + view.dispatch(tr); + return true; + } - tr.steps.forEach((step, i) => { - const stepMap = step.getMap(); - let hasInsert = false; - let hasDelete = false; + const deleteMark = state.schema.marks.suggestionDelete.create({ + suggestionId, authorId, authorName, createdAt: Date.now(), + }); + tr.addMark(from, to, deleteMark); + } - stepMap.forEach((oldStart: number, oldEnd: number, newStart: number, newEnd: number) => { - if (newEnd > newStart) hasInsert = true; - if (oldEnd > oldStart) hasDelete = true; + // Insert the new text with insert mark after the (marked-for-deletion) text + const insertPos = to; + const insertMark = state.schema.marks.suggestionInsert.create({ + suggestionId, authorId, authorName, createdAt: Date.now(), + }); + tr.insert(insertPos, state.schema.text(text, [insertMark])); + tr.setMeta('suggestion-applied', true); + + // Place cursor after the inserted text + tr.setSelection(TextSelection.create(tr.doc, insertPos + text.length)); + + view.dispatch(tr); + return true; + }, + + /** Intercept Backspace/Delete — mark text as deleted instead of removing. */ + handleKeyDown(view: EditorView, event: KeyboardEvent): boolean { + if (!getSuggesting()) return false; + if (event.key !== 'Backspace' && event.key !== 'Delete') return false; + + const { state } = view; + const { from, to, empty } = state.selection; + const { authorId, authorName } = getAuthor(); + + let deleteFrom = from; + let deleteTo = to; + + if (empty) { + if (event.key === 'Backspace') { + if (from === 0) return true; + deleteFrom = from - 1; + deleteTo = from; + } else { + if (from >= state.doc.content.size) return true; + deleteFrom = from; + deleteTo = from + 1; + } + + // Don't cross block boundaries + const $from = state.doc.resolve(deleteFrom); + const $to = state.doc.resolve(deleteTo); + if ($from.parent !== $to.parent) return true; + } + + // Backspace/delete on own suggestionInsert → actually remove it + const ownInsert = isOwnSuggestionInsert(state, deleteFrom, deleteTo, authorId); + if (ownInsert) { + const tr = state.tr; + tr.delete(deleteFrom, deleteTo); + tr.setMeta('suggestion-applied', true); + view.dispatch(tr); + return true; + } + + // Already marked as suggestionDelete → skip past it + if (isAlreadySuggestionDelete(state, deleteFrom, deleteTo)) { + const tr = state.tr; + const newPos = event.key === 'Backspace' ? deleteFrom : deleteTo; + tr.setSelection(TextSelection.create(state.doc, newPos)); + view.dispatch(tr); + return true; + } + + // Mark the text as deleted + const suggestionId = makeSuggestionId(); + const tr = state.tr; + const deleteMark = state.schema.marks.suggestionDelete.create({ + suggestionId, authorId, authorName, createdAt: Date.now(), + }); + tr.addMark(deleteFrom, deleteTo, deleteMark); + tr.setMeta('suggestion-applied', true); + + if (event.key === 'Backspace') { + tr.setSelection(TextSelection.create(tr.doc, deleteFrom)); + } else { + tr.setSelection(TextSelection.create(tr.doc, deleteTo)); + } + + view.dispatch(tr); + return true; + }, + + /** Intercept paste — insert pasted text as a suggestion. */ + handlePaste(view: EditorView, _event: ClipboardEvent, slice: Slice): boolean { + if (!getSuggesting()) return false; + + const { state } = view; + const { from, to } = state.selection; + const { authorId, authorName } = getAuthor(); + const suggestionId = makeSuggestionId(); + const tr = state.tr; + + // Mark selected text as deleted + if (from !== to) { + const deleteMark = state.schema.marks.suggestionDelete.create({ + suggestionId, authorId, authorName, createdAt: Date.now(), + }); + tr.addMark(from, to, deleteMark); + } + + // Extract text from slice and insert with mark + let pastedText = ''; + slice.content.forEach((node: any) => { + if (pastedText) pastedText += '\n'; + pastedText += node.textContent; }); - if (hasInsert && !hasDelete) { - // Pure insertion — let it through but add the suggestionInsert mark - blocked = true; - const doc = tr.docs[i]; - stepMap.forEach((_oldStart: number, _oldEnd: number, newStart: number, newEnd: number) => { - if (newEnd > newStart) { - const insertedText = tr.docs[i + 1]?.textBetween(newStart, newEnd, '', '') || ''; - if (insertedText) { - const insertMark = state.schema.marks.suggestionInsert.create({ - suggestionId, - authorId, - authorName, - createdAt: Date.now(), - }); - newTr.insertText(insertedText, newStart); - newTr.addMark(newStart, newStart + insertedText.length, insertMark); - newTr.setMeta('suggestion-applied', true); - } - } + if (pastedText) { + const insertPos = to; + const insertMark = state.schema.marks.suggestionInsert.create({ + suggestionId, authorId, authorName, createdAt: Date.now(), }); - } else if (hasDelete && !hasInsert) { - // Pure deletion — convert to suggestionDelete mark instead - blocked = true; - stepMap.forEach((oldStart: number, oldEnd: number) => { - if (oldEnd > oldStart) { - const deleteMark = state.schema.marks.suggestionDelete.create({ - suggestionId, - authorId, - authorName, - createdAt: Date.now(), - }); - newTr.addMark(oldStart, oldEnd, deleteMark); - newTr.setMeta('suggestion-applied', true); - } - }); - } else if (hasInsert && hasDelete) { - // Replacement (delete + insert) — mark old text as deleted, new text as inserted - blocked = true; - stepMap.forEach((oldStart: number, oldEnd: number, newStart: number, newEnd: number) => { - if (oldEnd > oldStart) { - const deleteMark = state.schema.marks.suggestionDelete.create({ - suggestionId, - authorId, - authorName, - createdAt: Date.now(), - }); - newTr.addMark(oldStart, oldEnd, deleteMark); - } - if (newEnd > newStart) { - const insertedText = tr.docs[i + 1]?.textBetween(newStart, newEnd, '', '') || ''; - if (insertedText) { - const insertMark = state.schema.marks.suggestionInsert.create({ - suggestionId, - authorId, - authorName, - createdAt: Date.now(), - }); - // Insert after the "deleted" text - const insertPos = oldEnd; - newTr.insertText(insertedText, insertPos); - newTr.addMark(insertPos, insertPos + insertedText.length, insertMark); - } - } - }); - newTr.setMeta('suggestion-applied', true); + tr.insert(insertPos, state.schema.text(pastedText, [insertMark])); + tr.setMeta('suggestion-applied', true); + tr.setSelection(TextSelection.create(tr.doc, insertPos + pastedText.length)); } - }); - if (blocked && newTr.docChanged) { - // Dispatch our modified transaction instead on the next tick - setTimeout(() => { - const view = getView?.(); - if (view) view.dispatch(newTr); - }, 0); - return false; // Block the original transaction - } - - return !blocked; + view.dispatch(tr); + return true; + }, }, }); } +/** Check if the range is entirely covered by suggestionInsert marks from the same author. */ +function isOwnSuggestionInsert( + state: { doc: any; schema: any }, + from: number, + to: number, + authorId: string, +): string | null { + let allOwn = true; + let foundId: string | null = null; + state.doc.nodesBetween(from, to, (node: any) => { + if (!node.isText) return; + const mark = node.marks.find( + (m: any) => m.type.name === 'suggestionInsert' && m.attrs.authorId === authorId + ); + if (!mark) { + allOwn = false; + } else if (!foundId) { + foundId = mark.attrs.suggestionId; + } + }); + return allOwn && foundId ? foundId : null; +} + +/** Check if the range is already entirely covered by suggestionDelete marks. */ +function isAlreadySuggestionDelete(state: { doc: any }, from: number, to: number): boolean { + let allDeleted = true; + state.doc.nodesBetween(from, to, (node: any) => { + if (!node.isText) return; + if (!node.marks.find((m: any) => m.type.name === 'suggestionDelete')) allDeleted = false; + }); + return allDeleted; +} + + /** - * Accept a suggestion by removing the mark. - * - For insertions: remove the mark (text stays) - * - For deletions: remove the text and the mark + * Accept a suggestion: insertions stay, deletions are removed. */ export function acceptSuggestion(editor: Editor, suggestionId: string) { const { state } = editor; - const { doc, tr } = state; + const { tr } = state; - // Find all marks with this suggestionId - doc.descendants((node, pos) => { + // Collect ranges first, apply from end→start to preserve positions + const deleteRanges: [number, number][] = []; + const insertRanges: [number, number, any][] = []; + + state.doc.descendants((node: any, pos: number) => { if (!node.isText) return; - - // Check for suggestionDelete marks — accept = remove the text const deleteMark = node.marks.find( - m => m.type.name === 'suggestionDelete' && m.attrs.suggestionId === suggestionId + (m: any) => m.type.name === 'suggestionDelete' && m.attrs.suggestionId === suggestionId ); if (deleteMark) { - tr.delete(pos, pos + node.nodeSize); - tr.setMeta('suggestion-accept', true); - return false; + deleteRanges.push([pos, pos + node.nodeSize]); + return; } - - // Check for suggestionInsert marks — accept = remove the mark, keep text const insertMark = node.marks.find( - m => m.type.name === 'suggestionInsert' && m.attrs.suggestionId === suggestionId + (m: any) => m.type.name === 'suggestionInsert' && m.attrs.suggestionId === suggestionId ); if (insertMark) { - tr.removeMark(pos, pos + node.nodeSize, insertMark); - tr.setMeta('suggestion-accept', true); + insertRanges.push([pos, pos + node.nodeSize, insertMark]); } }); + for (const [from, to] of deleteRanges.sort((a, b) => b[0] - a[0])) { + tr.delete(from, to); + } + for (const [from, to, mark] of insertRanges.sort((a, b) => b[0] - a[0])) { + tr.removeMark(from, to, mark); + } + if (tr.docChanged) { + tr.setMeta('suggestion-accept', true); editor.view.dispatch(tr); } } /** - * Reject a suggestion by reverting it. - * - For insertions: remove the text and the mark - * - For deletions: remove the mark (text stays) + * Reject a suggestion: insertions are removed, deletions stay. */ export function rejectSuggestion(editor: Editor, suggestionId: string) { const { state } = editor; - const { doc, tr } = state; + const { tr } = state; - doc.descendants((node, pos) => { + const insertRanges: [number, number][] = []; + const deleteRanges: [number, number, any][] = []; + + state.doc.descendants((node: any, pos: number) => { if (!node.isText) return; - - // Check for suggestionInsert marks — reject = remove text + mark const insertMark = node.marks.find( - m => m.type.name === 'suggestionInsert' && m.attrs.suggestionId === suggestionId + (m: any) => m.type.name === 'suggestionInsert' && m.attrs.suggestionId === suggestionId ); if (insertMark) { - tr.delete(pos, pos + node.nodeSize); - tr.setMeta('suggestion-reject', true); - return false; + insertRanges.push([pos, pos + node.nodeSize]); + return; } - - // Check for suggestionDelete marks — reject = remove the mark, keep text const deleteMark = node.marks.find( - m => m.type.name === 'suggestionDelete' && m.attrs.suggestionId === suggestionId + (m: any) => m.type.name === 'suggestionDelete' && m.attrs.suggestionId === suggestionId ); if (deleteMark) { - tr.removeMark(pos, pos + node.nodeSize, deleteMark); - tr.setMeta('suggestion-reject', true); + deleteRanges.push([pos, pos + node.nodeSize, deleteMark]); } }); + for (const [from, to] of insertRanges.sort((a, b) => b[0] - a[0])) { + tr.delete(from, to); + } + for (const [from, to, mark] of deleteRanges.sort((a, b) => b[0] - a[0])) { + tr.removeMark(from, to, mark); + } + if (tr.docChanged) { + tr.setMeta('suggestion-reject', true); editor.view.dispatch(tr); } } -/** - * Accept all suggestions in the document. - */ +/** Accept all suggestions in the document. */ export function acceptAllSuggestions(editor: Editor) { const ids = new Set(); - editor.state.doc.descendants((node) => { + editor.state.doc.descendants((node: any) => { if (!node.isText) return; for (const mark of node.marks) { if (mark.type.name === 'suggestionInsert' || mark.type.name === 'suggestionDelete') { @@ -229,17 +317,13 @@ export function acceptAllSuggestions(editor: Editor) { } } }); - for (const id of ids) { - acceptSuggestion(editor, id); - } + for (const id of ids) acceptSuggestion(editor, id); } -/** - * Reject all suggestions in the document. - */ +/** Reject all suggestions in the document. */ export function rejectAllSuggestions(editor: Editor) { const ids = new Set(); - editor.state.doc.descendants((node) => { + editor.state.doc.descendants((node: any) => { if (!node.isText) return; for (const mark of node.marks) { if (mark.type.name === 'suggestionInsert' || mark.type.name === 'suggestionDelete') { @@ -247,7 +331,5 @@ export function rejectAllSuggestions(editor: Editor) { } } }); - for (const id of ids) { - rejectSuggestion(editor, id); - } + for (const id of ids) rejectSuggestion(editor, id); } diff --git a/server/index.ts b/server/index.ts index 6f7b9b1..93b02f5 100644 --- a/server/index.ts +++ b/server/index.ts @@ -1639,18 +1639,18 @@ app.post("/api/blender-gen", async (c) => { } }); -// KiCAD PCB design — MCP SSE bridge (sidecar container) +// KiCAD PCB design — MCP StreamableHTTP bridge (sidecar container) import { Client } from "@modelcontextprotocol/sdk/client/index.js"; -import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js"; +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js"; import { runCadAgentLoop, assembleKicadResult, assembleFreecadResult, KICAD_SYSTEM_PROMPT, FREECAD_SYSTEM_PROMPT } from "./cad-orchestrator"; -const KICAD_MCP_URL = process.env.KICAD_MCP_URL || "http://kicad-mcp:8809/sse"; +const KICAD_MCP_URL = process.env.KICAD_MCP_URL || "http://kicad-mcp:8809/mcp"; let kicadClient: Client | null = null; async function getKicadClient(): Promise { if (kicadClient) return kicadClient; - const transport = new SSEClientTransport(new URL(KICAD_MCP_URL)); + const transport = new StreamableHTTPClientTransport(new URL(KICAD_MCP_URL)); const client = new Client({ name: "rspace-kicad-bridge", version: "1.0.0" }); transport.onclose = () => { kicadClient = null; }; @@ -1742,14 +1742,14 @@ app.post("/api/kicad/:action", async (c) => { } }); -// FreeCAD parametric CAD — MCP SSE bridge (sidecar container) -const FREECAD_MCP_URL = process.env.FREECAD_MCP_URL || "http://freecad-mcp:8808/sse"; +// FreeCAD parametric CAD — MCP StreamableHTTP bridge (sidecar container) +const FREECAD_MCP_URL = process.env.FREECAD_MCP_URL || "http://freecad-mcp:8808/mcp"; let freecadClient: Client | null = null; async function getFreecadClient(): Promise { if (freecadClient) return freecadClient; - const transport = new SSEClientTransport(new URL(FREECAD_MCP_URL)); + const transport = new StreamableHTTPClientTransport(new URL(FREECAD_MCP_URL)); const client = new Client({ name: "rspace-freecad-bridge", version: "1.0.0" }); transport.onclose = () => { freecadClient = null; }; From d7c1501d4ff050bcfd18b3473f665d2962dce7bc Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 25 Mar 2026 16:48:16 -0700 Subject: [PATCH 3/4] feat(rnotes): Google Docs-style suggestion mode + comment panel fixes Rewrite suggestion plugin to use ProseMirror props (handleTextInput, handleKeyDown, handlePaste) instead of broken filterTransaction approach. Typed text gets suggestionInsert mark (green underline), deleted text gets suggestionDelete mark (red strikethrough). Add per-suggestion accept/reject popover and review bar with Accept All / Reject All. Fix comment panel text overflow with box-sizing: border-box, add collapse/minimize toggle button. Co-Authored-By: Claude Opus 4.6 --- modules/rnotes/mod.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/modules/rnotes/mod.ts b/modules/rnotes/mod.ts index 04c7ecb..f07b5b1 100644 --- a/modules/rnotes/mod.ts +++ b/modules/rnotes/mod.ts @@ -1615,7 +1615,7 @@ routes.get("/", (c) => { modules: getModuleInfoList(), theme: "dark", body: ``, - scripts: ``, + scripts: ``, styles: ``, })); }); From 6d20a275ffcc49481507c282262987cdc8d3bdcd Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 25 Mar 2026 16:50:20 -0700 Subject: [PATCH 4/4] fix(rwallet): show all chains with activity, fix chain filter stats - Backend: detect chains where Safe has transaction history even if current balance is zero (queries all-transactions?limit=1) - Frontend: stats (Total Value, Tokens) now update when clicking chain filter buttons instead of always showing all-chain totals Co-Authored-By: Claude Opus 4.6 --- modules/rwallet/components/folk-wallet-viewer.ts | 4 ++-- modules/rwallet/mod.ts | 15 +++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/modules/rwallet/components/folk-wallet-viewer.ts b/modules/rwallet/components/folk-wallet-viewer.ts index 38f2812..5b2ef34 100644 --- a/modules/rwallet/components/folk-wallet-viewer.ts +++ b/modules/rwallet/components/folk-wallet-viewer.ts @@ -3285,8 +3285,8 @@ class FolkWalletViewer extends HTMLElement { private renderDashboard(): string { if (!this.hasData()) return ""; - // Aggregate stats across ALL chains (ignoring filter) - const allBalances = this.getUnifiedBalances(true); + // Stats reflect current filter (all chains when no filter active) + const allBalances = this.getUnifiedBalances(); const totalUSD = allBalances.reduce((sum, b) => sum + parseFloat(b.fiatBalance || "0"), 0); const totalTokens = allBalances.filter((b) => { const fiat = parseFloat(b.fiatBalance || "0"); diff --git a/modules/rwallet/mod.ts b/modules/rwallet/mod.ts index d0b498c..d73e651 100644 --- a/modules/rwallet/mod.ts +++ b/modules/rwallet/mod.ts @@ -702,6 +702,21 @@ routes.get("/api/safe/:address/all-balances", async (c) => { if (chainBalances.length > 0) { const enriched = await enrichWithPrices(chainBalances, chainId, { filterSpam: true }); results.push({ chainId, chainName: info.name, balances: enriched }); + } else { + // Safe exists on this chain (API returned 200) but has zero balance. + // Check if there are any historical transactions (>$0 activity). + try { + const txRes = await fetch( + `${safeApiBase(info.prefix)}/safes/${address}/all-transactions/?limit=1&executed=true`, + { signal: AbortSignal.timeout(5000) }, + ); + if (txRes.ok) { + const txData = await txRes.json() as { count?: number; results?: any[] }; + if (txData.results && txData.results.length > 0) { + results.push({ chainId, chainName: info.name, balances: [] }); + } + } + } catch {} } } catch {} })