From 0db5addc17a387e1657960e61e385229f35a8df5 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 25 Mar 2026 16:48:11 -0700 Subject: [PATCH] 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; };