diff --git a/packages/web/src/components/project/reconcile-tab/PresenceAvatars.jsx b/packages/web/src/components/project/reconcile-tab/PresenceAvatars.jsx
new file mode 100644
index 000000000..5e42c921b
--- /dev/null
+++ b/packages/web/src/components/project/reconcile-tab/PresenceAvatars.jsx
@@ -0,0 +1,108 @@
+/**
+ * PresenceAvatars - Displays stacked avatars of users viewing the reconciliation
+ *
+ * Features:
+ * - Stacked avatar display with overlap
+ * - Tooltip showing user name and current question
+ * - Click to jump to user's current question
+ * - Overflow indicator for many users
+ */
+
+import { For, Show } from 'solid-js';
+import { Avatar, AvatarImage, AvatarFallback, getInitials } from '@/components/ui/avatar';
+import {
+ Tooltip,
+ TooltipTrigger,
+ TooltipPositioner,
+ TooltipContent,
+} from '@/components/ui/tooltip';
+import { API_BASE } from '@config/api.js';
+
+/**
+ * @param {Object} props
+ * @param {Array} props.users - Array of presence users with { userId, name, image, currentPage, color }
+ * @param {Function} props.onUserClick - Callback when user avatar is clicked (receives userId, currentPage)
+ * @param {number} [props.maxVisible=4] - Maximum number of avatars to show before overflow
+ * @param {Function} [props.getPageLabel] - Function to convert page index to display label (default: "Question N")
+ */
+export default function PresenceAvatars(props) {
+ const maxVisible = () => props.maxVisible ?? 4;
+ const visibleUsers = () => props.users.slice(0, maxVisible());
+ const overflowCount = () => Math.max(0, props.users.length - maxVisible());
+
+ const getPageLabel = pageIndex => {
+ if (props.getPageLabel) {
+ return props.getPageLabel(pageIndex);
+ }
+ return `Question ${pageIndex + 1}`;
+ };
+
+ // Build avatar URL from user data
+ const getAvatarUrl = user => {
+ if (user.image) {
+ // If it's a full URL, use it directly
+ if (user.image.startsWith('http')) {
+ return user.image;
+ }
+ // Otherwise, it's a path to our API
+ return `${API_BASE}/api/users/avatar/${user.userId}`;
+ }
+ return null;
+ };
+
+ return (
+
+
+
+ {user => (
+
+
+ props.onUserClick?.(user.userId, user.currentPage)}
+ class='focus:ring-primary relative rounded-full transition-transform hover:z-10 hover:scale-110 focus:z-10 focus:ring-2 focus:ring-offset-2 focus:outline-none'
+ style={{
+ 'box-shadow': `0 0 0 2px ${user.color.hex}`,
+ }}
+ >
+
+
+
+ {getInitials(user.name)}
+
+
+
+
+
+
+ {user.name}
+
+ Viewing {getPageLabel(user.currentPage)}
+
+
+
+
+ )}
+
+
+ {/* Overflow indicator */}
+
0}>
+
+
+
+ +{overflowCount()}
+
+
+
+
+ {overflowCount()} more {overflowCount() === 1 ? 'person' : 'people'} viewing
+
+
+
+
+
+
+ );
+}
diff --git a/packages/web/src/components/project/reconcile-tab/QuestionPresenceIndicator.jsx b/packages/web/src/components/project/reconcile-tab/QuestionPresenceIndicator.jsx
new file mode 100644
index 000000000..02132cdad
--- /dev/null
+++ b/packages/web/src/components/project/reconcile-tab/QuestionPresenceIndicator.jsx
@@ -0,0 +1,63 @@
+/**
+ * QuestionPresenceIndicator - Colored rings on question pills showing who's viewing
+ *
+ * Features:
+ * - Renders colored ring(s) around the parent element
+ * - Supports multiple users with stacked rings
+ * - Pulsing animation for visibility
+ */
+
+import { For, Show } from 'solid-js';
+
+/**
+ * @param {Object} props
+ * @param {Array} props.users - Array of users viewing this question with { userId, color, name }
+ * @param {number} [props.maxRings=2] - Maximum number of rings to show
+ * @param {string} [props.size='md'] - Ring size: 'sm', 'md', 'lg'
+ */
+export default function QuestionPresenceIndicator(props) {
+ const users = () => props.users ?? [];
+ const maxRings = () => props.maxRings ?? 2;
+ const visibleUsers = () => users().slice(0, maxRings());
+
+ // Ring offsets for stacking effect
+ const getOffset = index => {
+ const offsets = {
+ sm: 2,
+ md: 3,
+ lg: 4,
+ };
+ const base = offsets[props.size || 'md'] || 3;
+ return index * base;
+ };
+
+ // Ring width - thicker for better visibility
+ const getRingWidth = () => {
+ const widths = {
+ sm: 2,
+ md: 3,
+ lg: 4,
+ };
+ return widths[props.size || 'md'] || 3;
+ };
+
+ return (
+ 0}>
+
+
+ {(user, index) => (
+
+ )}
+
+
+
+ );
+}
diff --git a/packages/web/src/components/project/reconcile-tab/ReconciliationWithPdf.jsx b/packages/web/src/components/project/reconcile-tab/ReconciliationWithPdf.jsx
index 6c5463054..4608ed58b 100644
--- a/packages/web/src/components/project/reconcile-tab/ReconciliationWithPdf.jsx
+++ b/packages/web/src/components/project/reconcile-tab/ReconciliationWithPdf.jsx
@@ -1,15 +1,22 @@
/**
* ReconciliationWithPdf - Wrapper that combines ChecklistReconciliation with a PDF viewer
* in a split-screen layout. The PDF is read-only during reconciliation.
+ *
+ * Includes presence features:
+ * - Shows avatars of other users viewing the reconciliation
+ * - Shows colored rings on question pills indicating viewer positions
+ * - Shows remote mouse cursors in real-time
*/
-import { Show, createMemo, lazy, Suspense } from 'solid-js';
+import { Show, createMemo, createSignal, lazy, Suspense } from 'solid-js';
import { createStore } from 'solid-js/store';
import { FiArrowLeft } from 'solid-icons/fi';
import ChecklistReconciliation from './amstar2-reconcile/ChecklistReconciliation.jsx';
-
import Navbar from './amstar2-reconcile/Navbar.jsx';
import SplitScreenLayout from '@/components/checklist/SplitScreenLayout.jsx';
+import { useReconciliationPresence } from '@primitives/useReconciliationPresence.js';
+import PresenceAvatars from './PresenceAvatars.jsx';
+import RemoteCursors from './RemoteCursors.jsx';
const EmbedPdfViewer = lazy(() => import('@pdf/embedpdf/EmbedPdfViewer.jsx'));
@@ -33,9 +40,16 @@ const EmbedPdfViewer = lazy(() => import('@pdf/embedpdf/EmbedPdfViewer.jsx'));
* @param {Function} props.onPdfSelect - Handler for PDF selection change
* @param {Function} props.getQuestionNote - Function to get Y.Text for a question note (questionKey => Y.Text)
* @param {Function} props.updateChecklistAnswer - Function to update a question answer (questionKey, questionData)
+ * @param {Function} props.getAwareness - Function to get Yjs awareness instance
+ * @param {Function} props.currentUser - Reactive getter for current user { id, name, image }
+ * @param {string} [props.checklistType='AMSTAR2'] - Checklist type for presence filtering
* @returns {JSX.Element}
*/
export default function ReconciliationWithPdf(props) {
+ // Container ref for mouse cursor tracking
+ let containerRef;
+ const [containerScrollY, setContainerScrollY] = createSignal(0);
+
// Navbar store for deep reactivity - ChecklistReconciliation will update this
const [navbarStore, setNavbarStore] = createStore({
questionKeys: [],
@@ -51,10 +65,24 @@ export default function ReconciliationWithPdf(props) {
onReset: null,
});
+ // Presence tracking
+ const presence = useReconciliationPresence({
+ getAwareness: () => props.getAwareness?.(),
+ getCurrentPage: () => navbarStore.currentPage,
+ checklistType: () => props.checklistType || 'AMSTAR2',
+ currentUser: () => props.currentUser?.(),
+ containerRef: () => containerRef,
+ });
+
+ // Handle scroll updates for cursor position adjustment
+ function handleScroll(event) {
+ setContainerScrollY(event.target.scrollTop);
+ }
+
// Check if we have PDF to show (reactive)
const hasPdf = createMemo(() => !!(props.pdfData || props.pdfLoading));
- // Build header content with back button, title, and navbar
+ // Build header content with back button, title, presence avatars, and navbar
const headerContent = (
<>
{/* Back button */}
@@ -76,10 +104,22 @@ export default function ReconciliationWithPdf(props) {
+ {/* Presence avatars - show other users viewing this reconciliation */}
+ 0}>
+ {
+ navbarStore.goToQuestion?.(currentPage);
+ }}
+ getPageLabel={pageIndex => `Question ${pageIndex + 1}`}
+ />
+
+
+
{/* Navbar - question navigation pills */}
0}>
-
+
>
@@ -96,22 +136,30 @@ export default function ReconciliationWithPdf(props) {
pdfUrl={props.pdfUrl}
pdfData={props.pdfData}
>
- {/* First panel: Reconciliation view */}
-
- props.updateChecklistAnswer?.(questionKey, questionData)
- }
- />
+ {/* First panel: Reconciliation view with cursor tracking */}
+
+ {/* Remote cursors overlay */}
+
+
+
+ props.updateChecklistAnswer?.(questionKey, questionData)
+ }
+ />
+
{/* Second panel: PDF Viewer (read-only) - only rendered when PDF exists */}
diff --git a/packages/web/src/components/project/reconcile-tab/ReconciliationWrapper.jsx b/packages/web/src/components/project/reconcile-tab/ReconciliationWrapper.jsx
index a1230e615..d82a46c67 100644
--- a/packages/web/src/components/project/reconcile-tab/ReconciliationWrapper.jsx
+++ b/packages/web/src/components/project/reconcile-tab/ReconciliationWrapper.jsx
@@ -7,6 +7,7 @@ import { createSignal, createMemo, createEffect, Show } from 'solid-js';
import { useParams, useNavigate } from '@solidjs/router';
import { useProjectContext } from '@/components/project/ProjectContext.jsx';
import projectStore from '@/stores/projectStore.js';
+import { useBetterAuth } from '@api/better-auth-store.js';
import projectActionsStore from '@/stores/projectActionsStore';
import { ACCESS_DENIED_ERRORS } from '@/constants/errors.js';
import { CHECKLIST_STATUS } from '@/constants/checklist-status.js';
@@ -39,6 +40,9 @@ export default function ReconciliationWrapper() {
const [error, setError] = createSignal(null);
+ // Get current user for presence
+ const { user } = useBetterAuth();
+
// Destructure Y.js operations from parent ProjectView's connection
const {
createChecklist: createProjectChecklist,
@@ -50,8 +54,20 @@ export default function ReconciliationWrapper() {
getRobinsText,
getRob2Text,
saveReconciliationProgress,
+ getAwareness,
} = projectOps || {};
+ // Current user for presence features
+ const currentUser = createMemo(() => {
+ const u = user();
+ if (!u) return null;
+ return {
+ id: u.id,
+ name: u.name || u.email || 'Unknown',
+ image: u.image,
+ };
+ });
+
// Set active project for action store
createEffect(() => {
const pid = params.projectId;
@@ -494,6 +510,9 @@ export default function ReconciliationWrapper() {
if (!id) return;
updateChecklistAnswer(params.studyId, id, questionKey, questionData);
}}
+ getAwareness={getAwareness}
+ currentUser={currentUser}
+ checklistType='AMSTAR2'
/>
}
>
@@ -527,6 +546,9 @@ export default function ReconciliationWrapper() {
questionKey,
)
}
+ getAwareness={getAwareness}
+ currentUser={currentUser}
+ checklistType='ROB2'
/>
}
@@ -561,6 +583,9 @@ export default function ReconciliationWrapper() {
questionKey,
)
}
+ getAwareness={getAwareness}
+ currentUser={currentUser}
+ checklistType='ROBINS_I'
/>
diff --git a/packages/web/src/components/project/reconcile-tab/RemoteCursors.jsx b/packages/web/src/components/project/reconcile-tab/RemoteCursors.jsx
new file mode 100644
index 000000000..6a3730fc1
--- /dev/null
+++ b/packages/web/src/components/project/reconcile-tab/RemoteCursors.jsx
@@ -0,0 +1,92 @@
+/**
+ * RemoteCursors - Floating cursor overlay showing other users' mouse positions
+ *
+ * Features:
+ * - Renders colored cursor arrows with name labels
+ * - Smooth position updates via CSS transforms
+ * - Fades out stale cursors
+ * - Handles scroll position differences
+ */
+
+import { For } from 'solid-js';
+
+/**
+ * Cursor icon SVG - Clean arrowhead pointer with rounded bottom
+ */
+function CursorIcon(props) {
+ return (
+
+ {/* Simple arrowhead */}
+
+
+ );
+}
+
+/**
+ * @param {Object} props
+ * @param {Array} props.users - Array of users with cursor data { userId, name, cursor: { x, y, scrollY, timestamp }, color }
+ * (already filtered by useReconciliationPresence for staleness)
+ * @param {number} props.containerScrollY - Current scroll position of the container
+ */
+export default function RemoteCursors(props) {
+ // Users are already filtered for staleness by useReconciliationPresence
+ const activeCursors = () => props.users || [];
+
+ // Calculate adjusted Y position based on scroll difference
+ // cursor.y is the content position (viewport Y + scrollTop when recorded)
+ // To display: convert content position to viewport position by subtracting local scroll
+ const getAdjustedY = cursor => {
+ const localScroll = props.containerScrollY ?? 0;
+ // cursor.y is content position, subtract local scroll to get viewport position
+ return cursor.y - localScroll;
+ };
+
+ return (
+
+
+ {user => {
+ const x = () => user.cursor.x;
+ const y = () => getAdjustedY(user.cursor);
+
+ return (
+
+ {/* Cursor icon */}
+
+
+ {/* Name label */}
+
+ {user.name}
+
+
+ );
+ }}
+
+
+ );
+}
diff --git a/packages/web/src/components/project/reconcile-tab/amstar2-reconcile/Navbar.jsx b/packages/web/src/components/project/reconcile-tab/amstar2-reconcile/Navbar.jsx
index 94cb2594e..083a12581 100644
--- a/packages/web/src/components/project/reconcile-tab/amstar2-reconcile/Navbar.jsx
+++ b/packages/web/src/components/project/reconcile-tab/amstar2-reconcile/Navbar.jsx
@@ -8,6 +8,7 @@ import {
TooltipContent,
} from '@/components/ui/tooltip';
import { hasQuestionAnswer, getQuestionPillStyle, getQuestionTooltip } from './navbar-utils.js';
+import QuestionPresenceIndicator from '../QuestionPresenceIndicator.jsx';
/**
* Navigation bar for checklist reconciliation
@@ -24,12 +25,20 @@ import { hasQuestionAnswer, getQuestionPillStyle, getQuestionTooltip } from './n
* - setViewMode: function to change view mode
* - goToQuestion: function to go to a specific question
* - onReset: function to reset all reconciliation answers
+ * - usersByPage: Map of page index to array of users viewing that page (for presence indicators)
*/
export default function Navbar(props) {
return (
- {(key, index) => }
+ {(key, index) => (
+
+ )}
props.store.onReset?.()} />
@@ -38,7 +47,7 @@ export default function Navbar(props) {
}
/**
- * Individual question pill button
+ * Individual question pill button with optional presence indicator
*/
function QuestionPill(props) {
const key = () => props.store.questionKeys[props.questionIndex];
@@ -54,9 +63,15 @@ function QuestionPill(props) {
getQuestionPillStyle(isCurrentPage(), hasAnswer(), isAgreement()),
);
- const tooltip = createMemo(() =>
- getQuestionTooltip(props.questionIndex + 1, hasAnswer(), isAgreement()),
- );
+ const tooltip = createMemo(() => {
+ const baseTooltip = getQuestionTooltip(props.questionIndex + 1, hasAnswer(), isAgreement());
+ const users = props.usersOnPage || [];
+ if (users.length === 0) return baseTooltip;
+
+ // Add viewing users to tooltip
+ const viewerNames = users.map(u => u.name).join(', ');
+ return `${baseTooltip} | Viewing: ${viewerNames}`;
+ });
return (
@@ -67,7 +82,13 @@ function QuestionPill(props) {
aria-label={tooltip()}
aria-current={isCurrentPage() ? 'page' : undefined}
>
+ {/* Presence indicator - colored rings for users viewing this question */}
+
+
+
+
{props.questionIndex + 1}
+
import('@pdf/embedpdf/EmbedPdfViewer.jsx'));
@@ -34,9 +41,16 @@ const EmbedPdfViewer = lazy(() => import('@pdf/embedpdf/EmbedPdfViewer.jsx'));
* @param {Function} props.onPdfSelect - Handler for PDF selection change
* @param {Function} props.updateChecklistAnswer - Function to update answer
* @param {Function} props.getRob2Text - Function to get Y.Text for comments (domainKey, questionKey) => Y.Text
+ * @param {Function} props.getAwareness - Function to get Yjs awareness instance
+ * @param {Function} props.currentUser - Reactive getter for current user { id, name, image }
+ * @param {string} [props.checklistType='ROB2'] - Checklist type for presence filtering
* @returns {JSX.Element}
*/
export default function ROB2ReconciliationWithPdf(props) {
+ // Container ref for mouse cursor tracking
+ let containerRef;
+ const [containerScrollY, setContainerScrollY] = createSignal(0);
+
// Navbar store for deep reactivity - ROB2Reconciliation will update this
const [navbarStore, setNavbarStore] = createStore({
navItems: [],
@@ -52,6 +66,20 @@ export default function ROB2ReconciliationWithPdf(props) {
onReset: null,
});
+ // Presence tracking
+ const presence = useReconciliationPresence({
+ getAwareness: () => props.getAwareness?.(),
+ getCurrentPage: () => navbarStore.currentPage,
+ checklistType: () => props.checklistType || 'ROB2',
+ currentUser: () => props.currentUser?.(),
+ containerRef: () => containerRef,
+ });
+
+ // Handle scroll updates for cursor position adjustment
+ function handleScroll(event) {
+ setContainerScrollY(event.target.scrollTop);
+ }
+
// Check if we have PDF to show (reactive)
const hasPdf = createMemo(() => !!(props.pdfData || props.pdfLoading));
@@ -77,6 +105,18 @@ export default function ROB2ReconciliationWithPdf(props) {
+ {/* Presence avatars - show other users viewing this reconciliation */}
+ 0}>
+ {
+ navbarStore.goToPage?.(currentPage);
+ }}
+ getPageLabel={pageIndex => `Item ${pageIndex + 1}`}
+ />
+
+
+
{/* Navbar - navigation pills */}
0}>
@@ -97,20 +137,28 @@ export default function ROB2ReconciliationWithPdf(props) {
pdfUrl={props.pdfUrl}
pdfData={props.pdfData}
>
- {/* First panel: Reconciliation view */}
-
+ {/* First panel: Reconciliation view with cursor tracking */}
+
+ {/* Remote cursors overlay */}
+
+
+
+
{/* Second panel: PDF Viewer (read-only) - only rendered when PDF exists */}
diff --git a/packages/web/src/components/project/reconcile-tab/robins-i-reconcile/RobinsIReconciliationWithPdf.jsx b/packages/web/src/components/project/reconcile-tab/robins-i-reconcile/RobinsIReconciliationWithPdf.jsx
index 81829996a..32347c581 100644
--- a/packages/web/src/components/project/reconcile-tab/robins-i-reconcile/RobinsIReconciliationWithPdf.jsx
+++ b/packages/web/src/components/project/reconcile-tab/robins-i-reconcile/RobinsIReconciliationWithPdf.jsx
@@ -1,14 +1,21 @@
/**
* RobinsIReconciliationWithPdf - Wrapper that combines RobinsIReconciliation with a PDF viewer
* in a split-screen layout. The PDF is read-only during reconciliation.
+ *
+ * Includes presence features:
+ * - Shows avatars of other users viewing the reconciliation
+ * - Shows remote mouse cursors in real-time
*/
-import { Show, createMemo, lazy, Suspense } from 'solid-js';
+import { Show, createMemo, createSignal, lazy, Suspense } from 'solid-js';
import { createStore } from 'solid-js/store';
import { FiArrowLeft } from 'solid-icons/fi';
import RobinsIReconciliation from './RobinsIReconciliation.jsx';
import RobinsINavbar from './RobinsINavbar.jsx';
import SplitScreenLayout from '@/components/checklist/SplitScreenLayout.jsx';
+import { useReconciliationPresence } from '@primitives/useReconciliationPresence.js';
+import PresenceAvatars from '../PresenceAvatars.jsx';
+import RemoteCursors from '../RemoteCursors.jsx';
const EmbedPdfViewer = lazy(() => import('@pdf/embedpdf/EmbedPdfViewer.jsx'));
@@ -34,9 +41,16 @@ const EmbedPdfViewer = lazy(() => import('@pdf/embedpdf/EmbedPdfViewer.jsx'));
* @param {Function} props.onPdfSelect - Handler for PDF selection change
* @param {Function} props.updateChecklistAnswer - Function to update answer
* @param {Function} props.getRobinsText - Function to get Y.Text for comments (sectionKey, fieldKey, questionKey) => Y.Text
+ * @param {Function} props.getAwareness - Function to get Yjs awareness instance
+ * @param {Function} props.currentUser - Reactive getter for current user { id, name, image }
+ * @param {string} [props.checklistType='ROBINS_I'] - Checklist type for presence filtering
* @returns {JSX.Element}
*/
export default function RobinsIReconciliationWithPdf(props) {
+ // Container ref for mouse cursor tracking
+ let containerRef;
+ const [containerScrollY, setContainerScrollY] = createSignal(0);
+
// Navbar store for deep reactivity - RobinsIReconciliation will update this
const [navbarStore, setNavbarStore] = createStore({
navItems: [],
@@ -52,6 +66,20 @@ export default function RobinsIReconciliationWithPdf(props) {
onReset: null,
});
+ // Presence tracking
+ const presence = useReconciliationPresence({
+ getAwareness: () => props.getAwareness?.(),
+ getCurrentPage: () => navbarStore.currentPage,
+ checklistType: () => props.checklistType || 'ROBINS_I',
+ currentUser: () => props.currentUser?.(),
+ containerRef: () => containerRef,
+ });
+
+ // Handle scroll updates for cursor position adjustment
+ function handleScroll(event) {
+ setContainerScrollY(event.target.scrollTop);
+ }
+
// Check if we have PDF to show (reactive)
const hasPdf = createMemo(() => !!(props.pdfData || props.pdfLoading));
@@ -77,6 +105,18 @@ export default function RobinsIReconciliationWithPdf(props) {
+ {/* Presence avatars - show other users viewing this reconciliation */}
+ 0}>
+ {
+ navbarStore.goToPage?.(currentPage);
+ }}
+ getPageLabel={pageIndex => `Item ${pageIndex + 1}`}
+ />
+
+
+
{/* Navbar - navigation pills */}
0}>
@@ -97,20 +137,28 @@ export default function RobinsIReconciliationWithPdf(props) {
pdfUrl={props.pdfUrl}
pdfData={props.pdfData}
>
- {/* First panel: Reconciliation view */}
-
+ {/* First panel: Reconciliation view with cursor tracking */}
+
+ {/* Remote cursors overlay */}
+
+
+
+
{/* Second panel: PDF Viewer (read-only) - only rendered when PDF exists */}
diff --git a/packages/web/src/lib/userColors.js b/packages/web/src/lib/userColors.js
new file mode 100644
index 000000000..e0d5cd390
--- /dev/null
+++ b/packages/web/src/lib/userColors.js
@@ -0,0 +1,80 @@
+/**
+ * User color utilities for presence indicators
+ * Generates stable, distinguishable colors for user identification
+ */
+
+// Color palette optimized for presence indicators
+// Uses colors that work well as rings, cursors, and avatars
+export const PRESENCE_COLORS = [
+ { name: 'blue', ring: 'ring-blue-400', bg: 'bg-blue-500', text: 'text-blue-500', hex: '#3b82f6' },
+ {
+ name: 'emerald',
+ ring: 'ring-emerald-400',
+ bg: 'bg-emerald-500',
+ text: 'text-emerald-500',
+ hex: '#10b981',
+ },
+ {
+ name: 'amber',
+ ring: 'ring-amber-400',
+ bg: 'bg-amber-500',
+ text: 'text-amber-500',
+ hex: '#f59e0b',
+ },
+ { name: 'pink', ring: 'ring-pink-400', bg: 'bg-pink-500', text: 'text-pink-500', hex: '#ec4899' },
+ {
+ name: 'indigo',
+ ring: 'ring-indigo-400',
+ bg: 'bg-indigo-500',
+ text: 'text-indigo-500',
+ hex: '#6366f1',
+ },
+ { name: 'cyan', ring: 'ring-cyan-400', bg: 'bg-cyan-500', text: 'text-cyan-500', hex: '#06b6d4' },
+ {
+ name: 'orange',
+ ring: 'ring-orange-400',
+ bg: 'bg-orange-500',
+ text: 'text-orange-500',
+ hex: '#f97316',
+ },
+ {
+ name: 'violet',
+ ring: 'ring-violet-400',
+ bg: 'bg-violet-500',
+ text: 'text-violet-500',
+ hex: '#8b5cf6',
+ },
+];
+
+/**
+ * Simple hash function (DJB2) for stable color assignment
+ * @param {string} str - String to hash
+ * @returns {number} Hash value
+ */
+function hashString(str) {
+ let hash = 5381;
+ for (let i = 0; i < str.length; i++) {
+ hash = (hash * 33) ^ str.charCodeAt(i);
+ }
+ return hash >>> 0; // Convert to unsigned 32-bit integer
+}
+
+/**
+ * Get a stable color for a user based on their ID
+ * @param {string} userId - User ID
+ * @returns {Object} Color object with ring, bg, text, and hex properties
+ */
+export function getUserColor(userId) {
+ if (!userId) return PRESENCE_COLORS[0];
+ const index = hashString(userId) % PRESENCE_COLORS.length;
+ return PRESENCE_COLORS[index];
+}
+
+/**
+ * Get just the hex color for a user (for inline styles)
+ * @param {string} userId - User ID
+ * @returns {string} Hex color value
+ */
+export function getUserHexColor(userId) {
+ return getUserColor(userId).hex;
+}
diff --git a/packages/web/src/primitives/useProject/index.js b/packages/web/src/primitives/useProject/index.js
index 4a9ccb776..caee77f6e 100644
--- a/packages/web/src/primitives/useProject/index.js
+++ b/packages/web/src/primitives/useProject/index.js
@@ -368,6 +368,9 @@ export function useProject(projectId) {
// Connection management
connect,
disconnect,
+
+ // Awareness (for presence features)
+ getAwareness: () => connectionEntry?.connectionManager?.getAwareness() || null,
};
}
diff --git a/packages/web/src/primitives/useReconciliationPresence.js b/packages/web/src/primitives/useReconciliationPresence.js
new file mode 100644
index 000000000..6be042bcd
--- /dev/null
+++ b/packages/web/src/primitives/useReconciliationPresence.js
@@ -0,0 +1,253 @@
+/**
+ * useReconciliationPresence - Manages presence state for reconciliation views
+ *
+ * Provides:
+ * - Local awareness state broadcasting (current page, cursor position)
+ * - Remote user presence tracking
+ * - Derived state (users by page, cursor positions)
+ *
+ * Uses Yjs awareness protocol for real-time sync.
+ */
+
+import { createSignal, createEffect, onCleanup, createMemo } from 'solid-js';
+import { throttle } from '@solid-primitives/scheduled';
+import { getUserColor } from '@lib/userColors.js';
+
+// Throttle mouse position updates to 50ms (20 updates/second)
+const MOUSE_THROTTLE_MS = 50;
+
+/**
+ * Hook to manage presence in reconciliation views
+ *
+ * @param {Object} options
+ * @param {Function} options.getAwareness - Function that returns Yjs awareness instance
+ * @param {Function} options.getCurrentPage - Reactive getter for current question page
+ * @param {Function|string} options.checklistType - Type of checklist (AMSTAR2, ROB2, ROBINS_I) - can be getter or string
+ * @param {Function} options.currentUser - Reactive getter for current user object { id, name, image }
+ * @param {Function} options.containerRef - Reactive getter for container element reference
+ * @returns {Object} Presence state and helpers
+ */
+export function useReconciliationPresence(options) {
+ const { getAwareness, getCurrentPage, currentUser, containerRef } = options;
+
+ // Handle checklistType as either a getter function or static string
+ const getChecklistType = () => {
+ const ct = options.checklistType;
+ return typeof ct === 'function' ? ct() : ct || 'AMSTAR2';
+ };
+
+ // Local state for remote users
+ const [remoteUsers, setRemoteUsers] = createSignal([]);
+
+ // Refresh tick for stale cursor detection (updates every second)
+ const [refreshTick, setRefreshTick] = createSignal(Date.now());
+
+ // Set up periodic refresh for stale cursor detection
+ if (typeof window !== 'undefined') {
+ const intervalId = setInterval(() => {
+ setRefreshTick(Date.now());
+ }, 1000);
+
+ onCleanup(() => clearInterval(intervalId));
+ }
+
+ // Track if awareness is available
+ const awareness = () => getAwareness?.();
+
+ // Update local awareness state when page changes
+ createEffect(() => {
+ const aw = awareness();
+ const user = currentUser?.();
+ const page = getCurrentPage?.();
+ const ct = getChecklistType();
+
+ if (!aw || !user?.id) return;
+
+ // Get existing local state to preserve cursor position
+ const existingState = aw.getLocalState() || {};
+
+ aw.setLocalState({
+ ...existingState,
+ user: {
+ userId: user.id,
+ name: user.name || user.email || 'Unknown',
+ image: user.image || null,
+ },
+ reconciliation: {
+ currentPage: page ?? 0,
+ checklistType: ct || 'AMSTAR2',
+ timestamp: Date.now(),
+ },
+ });
+ });
+
+ // Throttled mouse position update
+ const updateCursorPosition = throttle((x, y, scrollY) => {
+ const aw = awareness();
+ if (!aw) return;
+
+ const existingState = aw.getLocalState() || {};
+
+ aw.setLocalState({
+ ...existingState,
+ cursor: {
+ x,
+ y,
+ scrollY,
+ timestamp: Date.now(),
+ },
+ });
+ }, MOUSE_THROTTLE_MS);
+
+ // Mouse move handler
+ function handleMouseMove(event) {
+ const container = containerRef?.();
+ if (!container) return;
+
+ const rect = container.getBoundingClientRect();
+ const x = event.clientX - rect.left;
+ const y = event.clientY - rect.top + container.scrollTop;
+ const scrollY = container.scrollTop;
+
+ updateCursorPosition(x, y, scrollY);
+ }
+
+ // Mouse leave handler - clear cursor position
+ function handleMouseLeave() {
+ const aw = awareness();
+ if (!aw) return;
+
+ const existingState = aw.getLocalState() || {};
+ aw.setLocalState({
+ ...existingState,
+ cursor: null,
+ });
+ }
+
+ // Set up mouse tracking
+ createEffect(() => {
+ const container = containerRef?.();
+ if (!container) return;
+
+ container.addEventListener('mousemove', handleMouseMove);
+ container.addEventListener('mouseleave', handleMouseLeave);
+
+ onCleanup(() => {
+ container.removeEventListener('mousemove', handleMouseMove);
+ container.removeEventListener('mouseleave', handleMouseLeave);
+ });
+ });
+
+ // Listen to awareness changes and update remote users
+ createEffect(() => {
+ const aw = awareness();
+ if (!aw) return;
+
+ // Capture awareness reference for cleanup (ensures cleanup works even if awareness() changes)
+ const awarenessRef = aw;
+
+ function updateRemoteUsers() {
+ const states = [];
+ const localClientId = awarenessRef.clientID;
+ const user = currentUser?.();
+ const localChecklistType = getChecklistType();
+
+ awarenessRef.getStates().forEach((state, clientId) => {
+ // Skip local user
+ if (clientId === localClientId) return;
+
+ // Skip users without valid data
+ if (!state.user?.userId) return;
+
+ // Skip users in different checklist types (if filtering)
+ const remoteChecklistType = state.reconciliation?.checklistType;
+ if (
+ localChecklistType &&
+ remoteChecklistType &&
+ remoteChecklistType !== localChecklistType
+ ) {
+ return;
+ }
+
+ // Skip if this is actually the current user (edge case with multiple tabs)
+ if (user?.id && state.user.userId === user.id) return;
+
+ const color = getUserColor(state.user.userId);
+
+ states.push({
+ clientId,
+ userId: state.user.userId,
+ name: state.user.name || 'Unknown',
+ image: state.user.image,
+ currentPage: state.reconciliation?.currentPage ?? 0,
+ cursor: state.cursor || null,
+ color,
+ });
+ });
+
+ setRemoteUsers(states);
+ }
+
+ // Initial update
+ updateRemoteUsers();
+
+ // Subscribe to changes
+ awarenessRef.on('change', updateRemoteUsers);
+
+ onCleanup(() => {
+ awarenessRef.off('change', updateRemoteUsers);
+
+ // Clear local awareness state on unmount
+ const localState = awarenessRef.getLocalState();
+ if (localState) {
+ awarenessRef.setLocalState(null);
+ }
+ });
+ });
+
+ // Derived: users grouped by page
+ const usersByPage = createMemo(() => {
+ const users = remoteUsers();
+ const byPage = new Map();
+
+ for (const user of users) {
+ const page = user.currentPage;
+ if (!byPage.has(page)) {
+ byPage.set(page, []);
+ }
+ byPage.get(page).push(user);
+ }
+
+ return byPage;
+ });
+
+ // Derived: users with active cursors on the same page
+ const usersWithCursors = createMemo(() => {
+ // Track refreshTick to force periodic re-evaluation
+ refreshTick();
+ const currentPage = getCurrentPage?.() ?? 0;
+ // Only show cursors from users on the same page
+ return remoteUsers().filter(user => user.cursor != null && user.currentPage === currentPage);
+ });
+
+ // Helper: get users on a specific page
+ function getUsersOnPage(pageIndex) {
+ return usersByPage().get(pageIndex) || [];
+ }
+
+ return {
+ // All remote users in this reconciliation
+ remoteUsers,
+
+ // Users grouped by page index
+ usersByPage,
+
+ // Users with active (non-stale) cursor positions
+ usersWithCursors,
+
+ // Helper to get users on a specific page
+ getUsersOnPage,
+ };
+}
+
+export default useReconciliationPresence;