Add peer cursor name labels for collaborative editing#71
Conversation
Defines transient username tags on collaborative cursors: auto-show for 4 seconds on peer cell change, hover to show on demand. Canvas- rendered on the overlay layer, with Store interface widening to pass username through presences. Co-Authored-By: Claude Opus 4.6 <[email protected]>
4-task plan: widen Store type, add Overlay label rendering with exported drawPeerLabel function, add Worksheet visibility state and hover detection, integration testing. Co-Authored-By: Claude Opus 4.6 <[email protected]>
The presence object now includes an optional username field to support displaying peer cursor name labels in collaborative editing.
Exported drawPeerLabel function draws username tags above peer cells. 11px white text on peer-color background with truncation at 120px, edge clamping, and stable stacking for multiple peers on same cell.
Worksheet tracks peer cell changes with 4-second timers and detects mouse hover over peer cursor cells. Derives visiblePeerLabels set and passes it to Overlay for rendering. Co-Authored-By: Claude Opus 4.6 <[email protected]>
Include hoveredPeerClientID in the changed condition so the overlay re-renders when the mouse leaves the scroll container while hovering a peer cursor. Co-Authored-By: Claude Opus 4.6 <[email protected]>
Combine peer hover change with the existing changed flag to avoid redundant overlay renders in the same frame. Co-Authored-By: Claude Opus 4.6 <[email protected]>
Use dark text on light peer cursor colors (e.g. yellow, gold) and white text on dark colors for better readability. Co-Authored-By: Claude Opus 4.6 <[email protected]>
Avatar borders match the peer's cursor color on the canvas, so users can visually connect an avatar with its cursor. Uses getPeerCursorColor from the sheets package, now exported via the public API. Co-Authored-By: Claude Opus 4.6 <[email protected]>
Avatars are stacked with negative spacing, so a transparent background causes border rings to overlap visually. Co-Authored-By: Claude Opus 4.6 <[email protected]>
|
Warning Rate limit exceeded
⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the We recommend that you space out your commits to avoid hitting the rate limit. 🚦 How do rate limits work?CodeRabbit enforces hourly rate limits for each developer per organization. Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout. Please see our FAQ for further information. ℹ️ Review info⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Run ID: 📒 Files selected for processing (1)
📝 WalkthroughWalkthroughAdds transient peer cursor name labels: a design doc and implementation that widens presence typing to include optional usernames, renders stacked, truncated labels on the overlay with viewport-clamping, and manages visibility via hover and per-peer 4s timers in the worksheet. Changes
Sequence DiagramsequenceDiagram
actor User
participant Worksheet
participant Store
participant Timers as "WS Timers"
participant Overlay
participant Canvas
User->>Store: presence.activeCell (+ optional username) changes
Store-->>Worksheet: getPresences() -> presences (clientID, activeCell, username?)
Worksheet->>Timers: schedule/refresh 4s timer for clientID
User->>Worksheet: mousemove -> determine hovered cell
Worksheet->>Worksheet: set hoveredPeerClientID if matches presence
Worksheet->>Overlay: render(presences, visiblePeerLabels)
Overlay->>Overlay: group peers by cell, sort, compute stackIndex
Overlay->>Canvas: drawPeerLabel(username, color, cellRect, port, stackIndex)
Canvas-->>User: label appears (positioned, truncated, clamped)
Estimated code review effort🎯 4 (Complex) | ⏱️ ~60 minutes Possibly related PRs
Poem
🚥 Pre-merge checks | ✅ 2 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (2 passed)
✏️ Tip: You can configure your own custom pre-merge checks in the settings. ✨ Finishing Touches🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Verification: verify:selfResult: ✅ PASS in 103.8s
Verification: verify:integrationResult: ✅ PASS |
There was a problem hiding this comment.
Actionable comments posted: 3
Caution
Some comments are outside the diff and can’t be posted inline due to platform limitations.
⚠️ Outside diff range comments (1)
packages/sheets/src/view/worksheet.ts (1)
2267-2270:⚠️ Potential issue | 🟡 MinorTimer callback should guard against destroyed instance.
The timeout callback calls
this.renderOverlay()without checking if the worksheet is still valid. Ifcleanup()is called before the timer fires (and timers aren't cleared per the previous comment),renderOverlay()will accessthis.sheet!which isundefinedafter cleanup.🛡️ Proposed fix
const timerId = window.setTimeout(() => { this.peerLabelTimers.delete(clientID); - this.renderOverlay(); + if (this.sheet) { + this.renderOverlay(); + } }, 4000);🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/sheets/src/view/worksheet.ts` around lines 2267 - 2270, The timer callback must verify the worksheet instance is still valid before calling renderOverlay; update the timeout handler that currently invokes this.renderOverlay() (which follows getAutofillSelectionRect/const range = this.sheet.getRangeOrActiveCell()) to early-return if the instance was cleaned up (e.g., if this.sheet is null/undefined or a boolean flag like this.isDestroyed is true), so renderOverlay() won't run after cleanup() has cleared this.sheet.
🧹 Nitpick comments (5)
packages/frontend/src/components/user-presence.tsx (1)
85-92: Unify avatar border styling to prevent future drift.The peer border color logic is duplicated, and
bg-backgroundis present on Line 86 but missing on Line 146. Extracting shared style logic keeps both avatar variants consistent.♻️ Suggested refactor
+ const getPeerBorderColor = (user: (typeof users)[number]) => + user.isCurrentUser + ? undefined + : getPeerCursorColor(resolvedTheme, user.clientID); + const renderAvatar = (user: (typeof users)[number]) => { @@ <Avatar className="h-8 w-8 border-2 bg-background" style={{ - borderColor: user.isCurrentUser - ? undefined - : getPeerCursorColor(resolvedTheme, user.clientID), + borderColor: getPeerBorderColor(user), }} > @@ <Avatar - className="h-6 w-6 border-2" + className="h-6 w-6 border-2 bg-background" style={{ - borderColor: user.isCurrentUser - ? undefined - : getPeerCursorColor(resolvedTheme, user.clientID), + borderColor: getPeerBorderColor(user), }} >Also applies to: 145-152
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/frontend/src/components/user-presence.tsx` around lines 85 - 92, The Avatar components duplicate border/background styling; extract a shared computed style object (e.g., avatarBorderStyle) that sets borderColor to user.isCurrentUser ? undefined : getPeerCursorColor(resolvedTheme, user.clientID) and includes bg-background, then apply that object to both Avatar usages (the one around Line ~85 and the one around Line ~146) to ensure consistent border and background across variants; update any inline style references to use the new shared variable so future changes stay unified.packages/sheets/src/store/store.ts (1)
76-76: Extract a sharedStorePresencetype to avoid signature drift.Line 76 duplicates a complex inline shape now repeated across
store.ts,memory.ts,readonly.ts, andsheet.ts. A shared exported type will reduce future mismatch risk.♻️ Proposed refactor
export interface Store { + // Presence payload returned by collaboration-capable stores. + // `username` is optional because some clients may not publish it. + // `activeCell` remains required for cursor rendering. +} + +export type StorePresence = { + clientID: string; + presence: { activeCell: string; username?: string }; +}; + +export interface Store { @@ - getPresences(): Array<{ clientID: string; presence: { activeCell: string; username?: string } }>; + getPresences(): StorePresence[];🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/sheets/src/store/store.ts` at line 76, Extract and export a shared type named StorePresence that represents { clientID: string; presence: { activeCell: string; username?: string } }, then replace the inline signature in the getPresences declaration in store.ts with Array<StorePresence> and update all other occurrences (memory.ts, readonly.ts, sheet.ts) to use StorePresence so the shape is defined in one place and exported for reuse; ensure the new type is exported from the module that previously declared the inline shape.docs/tasks/active/20260323-peer-cursor-labels-todo.md (1)
15-15: Heading levels skip h2.The document jumps from
# Peer Cursor Name Labels Implementation Plan(h1) directly to### Task 1(h3), skipping h2. This violates markdown best practices and accessibility guidelines for document structure.📝 Suggested fix
Either change task headings to h2:
-### Task 1: Widen Store presence type to include `username` +## Task 1: Widen Store presence type to include `username`Or add an h2 section wrapper:
--- +## Implementation Tasks + ### Task 1: Widen Store presence type to include `username`🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@docs/tasks/active/20260323-peer-cursor-labels-todo.md` at line 15, The document skips from the H1 "Peer Cursor Name Labels Implementation Plan" to "### Task 1: Widen Store presence type to include `username`" (H3); update the heading structure by changing each task heading like "### Task 1: Widen Store presence type to include `username`" to an H2 ("## Task 1: ...") or alternatively insert a new H2 wrapper (e.g., "## Tasks" immediately after the H1) and keep the existing task headings as H3, ensuring the heading hierarchy is H1 → H2 → H3 for proper accessibility.docs/design/peer-cursor-labels.md (1)
42-42: Documentation doesn't match implementation: text color is now luminance-based.The design spec states text color is always
#FFFFFF(white), but the implementation inoverlay.tsusesgetLabelTextColor()which selects black (#000000) or white (#FFFFFF) based on background luminance. This is a better approach for accessibility, but the documentation should be updated to reflect the actual behavior.📝 Suggested doc update
-| Text color | White (`#FFFFFF`) | +| Text color | Auto-contrast (white on dark, black on light) |🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@docs/design/peer-cursor-labels.md` at line 42, The docs state label text color is always `#FFFFFF`, but the implementation in overlay.ts uses getLabelTextColor() to pick `#000000` or `#FFFFFF` based on background luminance; update docs/design/peer-cursor-labels.md to describe this adaptive behavior, mentioning getLabelTextColor() and that text color is chosen (black or white) depending on background luminance for accessibility rather than always white.packages/sheets/src/view/overlay.ts (1)
37-44:getLabelTextColorassumes well-formed 6-digit hex color.The function expects a hex color like
#FF6B6BorFF6B6B. If a non-hex format is passed (CSS color name,rgb(), 3-digit hex), the substring parsing will produce incorrect results. Currently safe sincegetPeerCursorColoralways returns hex, but consider adding validation or a comment noting this assumption.🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed. In `@packages/sheets/src/view/overlay.ts` around lines 37 - 44, getLabelTextColor currently assumes a 6-digit hex string and will misparse other color formats; update getLabelTextColor to validate and normalize input (accept leading '#', expand 3-digit hex to 6-digit, and detect non-hex formats) and fall back to a safe default color if validation fails, or add a concise comment documenting the assumption and linking its use by getPeerCursorColor so future callers know the hex-only contract; reference the getLabelTextColor function and its caller getPeerCursorColor when making the change.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/sheets/src/view/__tests__/overlay-peer-labels.test.ts`:
- Around line 20-87: Add two unit tests for drawPeerLabel: one that verifies
vertical stacking by passing different stackIndex values to drawPeerLabel and
asserting the produced fillText Y coordinates are offset by the expected stack
spacing relative to cellRect.top/height (use createMockCtx() and read
ctx.fillText mock.calls to compare Y positions for stackIndex 0 vs 1+), and
another that verifies horizontal clamping by placing cellRect near the left and
right edges of the port and asserting the fillText X coordinate is clamped
inside the port bounds (use port.left/width and inspect ctx.fillText
mock.calls[0][1]; if needed mock ctx.measureText to ensure width calculations).
Ensure tests reference drawPeerLabel, stackIndex, cellRect, port and reuse
createMockCtx() for consistency.
In `@packages/sheets/src/view/overlay.ts`:
- Around line 259-261: The freeze-pane branch is always calling drawPeerLabel
with stackIndex 0, causing overlapping labels when multiple peers share the same
frozen cell; update the logic in the freeze-pane path (the block that checks
visiblePeerLabels, presence.username, peerColor, rect, port) to group peer
presences by the same rect/port (same cell) and call drawPeerLabel for each peer
with an incrementing stackIndex (or extract the existing grouping/stacking
helper used in the non-freeze path and reuse it) so labels are stacked the same
way as in the non-freeze rendering.
In `@packages/sheets/src/view/worksheet.ts`:
- Around line 179-181: The peer label timeouts stored in peerLabelTimers are not
cleared in the Worksheet.cleanup() routine, allowing setTimeout callbacks (which
call renderOverlay()) to run after destruction; update the cleanup() method to
iterate over this.peerLabelTimers and call clearTimeout for each timer id, then
clear the map and reset hoveredPeerClientID (if needed) so no stale timers or
state remain, ensuring any callbacks won't execute against a destroyed Worksheet
instance.
---
Outside diff comments:
In `@packages/sheets/src/view/worksheet.ts`:
- Around line 2267-2270: The timer callback must verify the worksheet instance
is still valid before calling renderOverlay; update the timeout handler that
currently invokes this.renderOverlay() (which follows
getAutofillSelectionRect/const range = this.sheet.getRangeOrActiveCell()) to
early-return if the instance was cleaned up (e.g., if this.sheet is
null/undefined or a boolean flag like this.isDestroyed is true), so
renderOverlay() won't run after cleanup() has cleared this.sheet.
---
Nitpick comments:
In `@docs/design/peer-cursor-labels.md`:
- Line 42: The docs state label text color is always `#FFFFFF`, but the
implementation in overlay.ts uses getLabelTextColor() to pick `#000000` or `#FFFFFF`
based on background luminance; update docs/design/peer-cursor-labels.md to
describe this adaptive behavior, mentioning getLabelTextColor() and that text
color is chosen (black or white) depending on background luminance for
accessibility rather than always white.
In `@docs/tasks/active/20260323-peer-cursor-labels-todo.md`:
- Line 15: The document skips from the H1 "Peer Cursor Name Labels
Implementation Plan" to "### Task 1: Widen Store presence type to include
`username`" (H3); update the heading structure by changing each task heading
like "### Task 1: Widen Store presence type to include `username`" to an H2 ("##
Task 1: ...") or alternatively insert a new H2 wrapper (e.g., "## Tasks"
immediately after the H1) and keep the existing task headings as H3, ensuring
the heading hierarchy is H1 → H2 → H3 for proper accessibility.
In `@packages/frontend/src/components/user-presence.tsx`:
- Around line 85-92: The Avatar components duplicate border/background styling;
extract a shared computed style object (e.g., avatarBorderStyle) that sets
borderColor to user.isCurrentUser ? undefined :
getPeerCursorColor(resolvedTheme, user.clientID) and includes bg-background,
then apply that object to both Avatar usages (the one around Line ~85 and the
one around Line ~146) to ensure consistent border and background across
variants; update any inline style references to use the new shared variable so
future changes stay unified.
In `@packages/sheets/src/store/store.ts`:
- Line 76: Extract and export a shared type named StorePresence that represents
{ clientID: string; presence: { activeCell: string; username?: string } }, then
replace the inline signature in the getPresences declaration in store.ts with
Array<StorePresence> and update all other occurrences (memory.ts, readonly.ts,
sheet.ts) to use StorePresence so the shape is defined in one place and exported
for reuse; ensure the new type is exported from the module that previously
declared the inline shape.
In `@packages/sheets/src/view/overlay.ts`:
- Around line 37-44: getLabelTextColor currently assumes a 6-digit hex string
and will misparse other color formats; update getLabelTextColor to validate and
normalize input (accept leading '#', expand 3-digit hex to 6-digit, and detect
non-hex formats) and fall back to a safe default color if validation fails, or
add a concise comment documenting the assumption and linking its use by
getPeerCursorColor so future callers know the hex-only contract; reference the
getLabelTextColor function and its caller getPeerCursorColor when making the
change.
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: b6e3acf1-cf61-46bb-b37c-6d9621d982c2
📒 Files selected for processing (12)
docs/design/README.mddocs/design/peer-cursor-labels.mddocs/tasks/active/20260323-peer-cursor-labels-todo.mdpackages/frontend/src/components/user-presence.tsxpackages/sheets/src/index.tspackages/sheets/src/model/worksheet/sheet.tspackages/sheets/src/store/memory.tspackages/sheets/src/store/readonly.tspackages/sheets/src/store/store.tspackages/sheets/src/view/__tests__/overlay-peer-labels.test.tspackages/sheets/src/view/overlay.tspackages/sheets/src/view/worksheet.ts
| if (visiblePeerLabels?.has(clientID) && presence.username) { | ||
| drawPeerLabel(ctx, presence.username, peerColor, rect, port, 0); | ||
| } |
There was a problem hiding this comment.
Freeze-pane path doesn't support label stacking for multiple peers on same cell.
The freeze-pane rendering always passes stackIndex: 0 to drawPeerLabel, so if multiple peers are on the same cell in frozen panes, their labels will overlap. The non-freeze path at lines 641-648 correctly groups peers by cell and uses incrementing stack indices.
This is an edge case (multiple peers on same frozen cell is rare), but for consistency, consider extracting the stacking logic to a shared helper or applying the same grouping approach here.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.
In `@packages/sheets/src/view/overlay.ts` around lines 259 - 261, The freeze-pane
branch is always calling drawPeerLabel with stackIndex 0, causing overlapping
labels when multiple peers share the same frozen cell; update the logic in the
freeze-pane path (the block that checks visiblePeerLabels, presence.username,
peerColor, rect, port) to group peer presences by the same rect/port (same cell)
and call drawPeerLabel for each peer with an incrementing stackIndex (or extract
the existing grouping/stacking helper used in the non-freeze path and reuse it)
so labels are stacked the same way as in the non-freeze rendering.
Prevent stale timer callbacks after Worksheet destruction. Add tests for label stacking with stackIndex and right-edge viewport clamping. Co-Authored-By: Claude Opus 4.6 <[email protected]>
There was a problem hiding this comment.
Actionable comments posted: 1
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.
Inline comments:
In `@packages/sheets/src/view/worksheet.ts`:
- Around line 2477-2489: The loop that checks peer presences calls
parseRef(presence.activeCell) without validating the value, which can throw for
malformed or empty strings; update the presence iteration in the mouse-move
handling code to first check presence.activeCell is a non-empty string (or
matches expected ref pattern) and wrap parseRef in a try-catch (or conditional
validation) so a bad remote value won’t throw and break the hover logic;
reference symbols: getPresences(), presence.activeCell, parseRef(),
toRowFromMouse(), toColFromMouse().
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro
Run ID: 10bdc02a-212a-4f8a-bb32-fc6433db0909
📒 Files selected for processing (2)
packages/sheets/src/view/__tests__/overlay-peer-labels.test.tspackages/sheets/src/view/worksheet.ts
🚧 Files skipped from review as they are similar to previous changes (1)
- packages/sheets/src/view/tests/overlay-peer-labels.test.ts
Wrap parseRef in try-catch in the hover detection loop to prevent a malformed remote presence value from breaking the mouse handler. Co-Authored-By: Claude Opus 4.6 <[email protected]>
Summary
Changes
getPresences()to include optionalusernamedrawPeerLabelfunction with truncation, edge clamping, and stackingvisiblePeerLabelsto OverlayTest plan
drawPeerLabel(position, boundary flip, truncation, luminance-based text color)🤖 Generated with Claude Code
Summary by CodeRabbit
Documentation
New Features
Tests