diff --git a/modules/rdocs/components/comment-panel.ts b/modules/rdocs/components/comment-panel.ts index cd0f6763..e7f331ac 100644 --- a/modules/rdocs/components/comment-panel.ts +++ b/modules/rdocs/components/comment-panel.ts @@ -34,6 +34,7 @@ interface CommentThread { interface NotebookDoc { items: Record; + suggestions?: Record; [key: string]: any; }>; [key: string]: any; diff --git a/modules/rdocs/components/folk-docs-app.ts b/modules/rdocs/components/folk-docs-app.ts index 0e8f6cbb..36f817dd 100644 --- a/modules/rdocs/components/folk-docs-app.ts +++ b/modules/rdocs/components/folk-docs-app.ts @@ -12,6 +12,7 @@ import * as Automerge from '@automerge/automerge'; import { makeDraggableAll } from '../../../shared/draggable'; import { notebookSchema } from '../schemas'; +import type { SuggestionRecord } from '../schemas'; import type { DocumentId } from '../../../shared/local-first/document'; import { getAccessToken } from '../../../shared/components/rstack-identity'; import { broadcastPresence as sharedBroadcastPresence, startPresenceHeartbeat } from '../../../shared/collab-presence'; @@ -207,6 +208,8 @@ class FolkDocsApp extends HTMLElement { // ── Demo data ── private demoNotebooks: (Notebook & { notes: Note[] })[] = []; private _demoThreads = new Map>(); + private _demoSuggestions = new Map>(); + private _lastSuggestionAction: { id: string; action: 'accepted' | 'rejected' } | null = null; constructor() { super(); @@ -2836,6 +2839,7 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF pop.querySelector('.sp-accept')!.addEventListener('click', () => { if (this.editor) { + this._lastSuggestionAction = { id: suggestionId, action: 'accepted' }; acceptSuggestion(this.editor, suggestionId); this.updateSuggestionReviewBar(); this.syncSuggestionsToPanel(true); @@ -2844,6 +2848,7 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF }); pop.querySelector('.sp-reject')!.addEventListener('click', () => { if (this.editor) { + this._lastSuggestionAction = { id: suggestionId, action: 'rejected' }; rejectSuggestion(this.editor, suggestionId); this.updateSuggestionReviewBar(); this.syncSuggestionsToPanel(true); @@ -2899,6 +2904,7 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF if (!panel) return; const suggestions = this.collectSuggestions(); panel.suggestions = suggestions; + this.persistSuggestionsToAutomerge(suggestions); const sidebar = this.shadow.getElementById('comment-sidebar'); if (sidebar && suggestions.length > 0) { sidebar.classList.add('has-comments'); @@ -2911,6 +2917,79 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF } } + /** Persist suggestion metadata to Automerge so they survive across sessions. */ + private persistSuggestionsToAutomerge(editorSuggestions: { id: string; type: 'insert' | 'delete'; text: string; authorId: string; authorName: string; createdAt: number }[]) { + const noteId = this.editorNoteId; + if (!noteId) return; + + const editorIds = new Set(editorSuggestions.map(s => s.id)); + + // ── Demo mode ── + if (this.space === 'demo') { + if (!this._demoSuggestions.has(noteId)) { + this._demoSuggestions.set(noteId, {}); + } + const store = this._demoSuggestions.get(noteId)!; + for (const s of editorSuggestions) { + if (!store[s.id]) { + store[s.id] = { ...s, status: 'pending' }; + } + } + // Clean up suggestions no longer in the editor (accepted/rejected) + for (const id of Object.keys(store)) { + if (store[id].status === 'pending' && !editorIds.has(id)) { + const action = this._lastSuggestionAction; + store[id].status = (action?.id === id) ? action.action : 'accepted'; + } + } + this._lastSuggestionAction = null; + return; + } + + // ── Automerge mode ── + if (!this.doc?.items?.[noteId] || !this.subscribedDocId) return; + const runtime = (window as any).__rspaceOfflineRuntime; + if (!runtime?.isInitialized) return; + + const existing: Record = (this.doc.items[noteId] as any).suggestions || {}; + + // Check if anything actually changed to avoid no-op writes + const hasNew = editorSuggestions.some(s => !existing[s.id]); + const hasRemoved = Object.entries(existing).some(([id, rec]) => + rec.status === 'pending' && !editorIds.has(id)); + if (!hasNew && !hasRemoved) return; + + const lastAction = this._lastSuggestionAction; + this._lastSuggestionAction = null; + + runtime.change(this.subscribedDocId as DocumentId, 'Sync suggestions', (d: any) => { + const item = d.items[noteId]; + if (!item) return; + if (!item.suggestions) item.suggestions = {}; + // Add new suggestions + for (const s of editorSuggestions) { + if (!item.suggestions[s.id]) { + item.suggestions[s.id] = { + id: s.id, + type: s.type, + text: s.text, + authorId: s.authorId, + authorName: s.authorName, + createdAt: s.createdAt, + status: 'pending', + }; + } + } + // Mark removed suggestions with final status + for (const id of Object.keys(item.suggestions)) { + if (item.suggestions[id].status === 'pending' && !editorIds.has(id)) { + item.suggestions[id].status = (lastAction?.id === id) ? lastAction.action : 'accepted'; + } + } + }); + this.doc = runtime.get(this.subscribedDocId as DocumentId); + } + /** Show comment panel for a specific thread. */ private showCommentPanel(threadId?: string) { const sidebar = this.shadow.getElementById('comment-sidebar'); @@ -2928,6 +3007,7 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF // Listen for suggestion accept/reject from comment panel panel.addEventListener('suggestion-accept', (e: CustomEvent) => { if (this.editor && e.detail?.suggestionId) { + this._lastSuggestionAction = { id: e.detail.suggestionId, action: 'accepted' }; acceptSuggestion(this.editor, e.detail.suggestionId); this.updateSuggestionReviewBar(); this.syncSuggestionsToPanel(true); @@ -2935,6 +3015,7 @@ Gear: EUR 400 (10%)

Maya is tracking expenses in rF }); panel.addEventListener('suggestion-reject', (e: CustomEvent) => { if (this.editor && e.detail?.suggestionId) { + this._lastSuggestionAction = { id: e.detail.suggestionId, action: 'rejected' }; rejectSuggestion(this.editor, e.detail.suggestionId); this.updateSuggestionReviewBar(); this.syncSuggestionsToPanel(true); diff --git a/modules/rdocs/schemas.ts b/modules/rdocs/schemas.ts index 0f08c5b1..401490ca 100644 --- a/modules/rdocs/schemas.ts +++ b/modules/rdocs/schemas.ts @@ -37,6 +37,16 @@ export interface CommentThread { createdAt: number; } +export interface SuggestionRecord { + id: string; + type: 'insert' | 'delete'; + text: string; + authorId: string; + authorName: string; + createdAt: number; + status: 'pending' | 'accepted' | 'rejected'; +} + export interface NoteItem { id: string; notebookId: string; @@ -62,6 +72,7 @@ export interface NoteItem { conflictContent?: string; collabEnabled?: boolean; comments?: Record; + suggestions?: Record; createdAt: number; updatedAt: number; visibility?: import('../../shared/membrane').ObjectVisibility; diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts index d735bcbc..55839047 100644 --- a/src/encryptid/server.ts +++ b/src/encryptid/server.ts @@ -6215,6 +6215,18 @@ function joinPage(token: string): string { font-style: italic; display: none; } + /* Tabs */ + .tabs { display: flex; gap: 0; margin-bottom: 1.5rem; border-radius: 0.5rem; overflow: hidden; border: 1px solid rgba(255,255,255,0.15); } + .tab { + flex: 1; padding: 0.6rem; background: transparent; border: none; + color: #94a3b8; font-size: 0.85rem; font-weight: 500; cursor: pointer; + transition: all 0.2s; + } + .tab.active { background: rgba(124,58,237,0.2); color: #fff; } + .tab:hover:not(.active) { background: rgba(255,255,255,0.05); } + .panel { display: none; } + .panel.active { display: block; } + .form-group { margin-bottom: 1rem; text-align: left; } .form-group label { display: block; font-size: 0.8rem; color: #94a3b8; margin-bottom: 0.4rem; font-weight: 500; } .form-group input { @@ -6244,15 +6256,6 @@ function joinPage(token: string): string { color: #86efac; display: none; } .status { color: #94a3b8; font-size: 0.85rem; margin-top: 1rem; display: none; } - .step-indicator { - display: flex; justify-content: center; gap: 0.5rem; margin-bottom: 1.5rem; - } - .step { - width: 8px; height: 8px; border-radius: 50%; - background: rgba(255,255,255,0.15); transition: background 0.3s; - } - .step.active { background: #7c3aed; } - .step.done { background: #22c55e; } .features { margin-top: 1.5rem; text-align: left; font-size: 0.8rem; color: #94a3b8; } @@ -6263,31 +6266,41 @@ function joinPage(token: string): string {

-

Claim your rSpace

-

Create your passkey-protected identity

- -
-
-
-
-
+

Join rSpace

+

Loading invite details...

-
-
- - +