tfrere HF Staff Cursor commited on
Commit
aa09c01
·
1 Parent(s): 990dc4f

feat(editor): show connected collaborators in the top bar

Browse files

The collab cursors already render inline next to each peer's
selection, but if no one's actively editing right now you have
no way to see who else is in the room. Surface that in the top
bar as an overlapping row of small avatars (24px circles, -6px
overlap, max 4 visible + "+N" badge for the tail), placed just
before the SyncIndicator.

Data source: the same Y.js awareness states that CollaborationC
ursorV3 already populates with \`{ name, color, avatarUrl }\`
for every client. We listen to awareness "change" events and
re-derive the list on each tick, excluding our own clientID so
the user doesn't show up to themselves (they already see their
own chip on the far right).

Implementation notes:
- Border color of each avatar mirrors the user's collab color
(same hue as their cursor caret in the doc) so it's obvious
at a glance which avatar maps to which inline cursor.
- Fallback to a colored initial when avatarUrl is missing
(Anonymous sessions before HF OAuth resolves).
- Sort by clientId, not by name or last-seen, so avatars don't
reshuffle on every keystroke when peers move their cursors.
- Same "wait for providerRef.current" bootstrap as the
SyncIndicator fix (since the provider is created lazily by
<Editor> after this component mounts) - a useEffect with
[providerRef] alone wouldn't re-fire.
- Hover lifts the avatar 1px + scales 8% so you can tell it's
hoverable, and the tooltip shows the user's full display name.
- "+N" overflow badge tooltips the comma-separated list of the
hidden users.

Co-authored-by: Cursor <cursoragent@cursor.com>

frontend/src/components/ConnectedUsers.tsx ADDED
@@ -0,0 +1,134 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // ---------------------------------------------------------------------------
2
+ // ConnectedUsers
3
+ //
4
+ // Tiny presence indicator for the editor top bar - shows the avatars of
5
+ // every OTHER user currently editing the document, overlapping pill
6
+ // style. The local user isn't included (they already see their own
7
+ // chip on the far right).
8
+ //
9
+ // Data source: the Hocuspocus provider's Y.js awareness, populated by
10
+ // CollaborationCursorV3 with `{ name, color, avatarUrl }` per client.
11
+ // We re-read on every awareness "change" event so joins/leaves /
12
+ // rename flips update within a tick.
13
+ //
14
+ // The provider is created lazily by <Editor> and assigned to
15
+ // providerRef AFTER this component mounts, so we use the same
16
+ // "poll until ref populates, then attach listener" pattern as
17
+ // SyncIndicator (which had the exact same latent bug). Without that,
18
+ // the effect would early-return on the first run and never re-fire.
19
+ // ---------------------------------------------------------------------------
20
+
21
+ import { useEffect, useState } from "react";
22
+ import type { HocuspocusProvider } from "@hocuspocus/provider";
23
+ import { Tooltip } from "./Tooltip";
24
+
25
+ interface AwarenessUser {
26
+ clientId: number;
27
+ name: string;
28
+ color: string;
29
+ avatarUrl?: string;
30
+ }
31
+
32
+ interface Props {
33
+ providerRef: { current: HocuspocusProvider | null };
34
+ }
35
+
36
+ /** Max avatars shown inline before collapsing the tail into a "+N" badge. */
37
+ const MAX_VISIBLE = 4;
38
+
39
+ export function ConnectedUsers({ providerRef }: Props) {
40
+ const [users, setUsers] = useState<AwarenessUser[]>([]);
41
+
42
+ useEffect(() => {
43
+ let cleanup: (() => void) | null = null;
44
+ let pollId: ReturnType<typeof setInterval> | null = null;
45
+
46
+ const attach = (provider: HocuspocusProvider) => {
47
+ const awareness = (provider as any).awareness;
48
+ if (!awareness) return () => {};
49
+
50
+ const sync = () => {
51
+ const localClientId = awareness.clientID;
52
+ const arr: AwarenessUser[] = [];
53
+ awareness.getStates().forEach((state: any, clientId: number) => {
54
+ if (clientId === localClientId) return;
55
+ const user = state?.user;
56
+ if (!user || !user.name) return;
57
+ arr.push({
58
+ clientId,
59
+ name: String(user.name),
60
+ color: String(user.color || "#888"),
61
+ avatarUrl: user.avatarUrl ? String(user.avatarUrl) : undefined,
62
+ });
63
+ });
64
+ // Stable order: by clientId so the avatars don't dance around
65
+ // every time someone moves their cursor and emits an update.
66
+ arr.sort((a, b) => a.clientId - b.clientId);
67
+ setUsers(arr);
68
+ };
69
+
70
+ sync();
71
+ awareness.on("change", sync);
72
+ return () => awareness.off("change", sync);
73
+ };
74
+
75
+ if (providerRef.current) {
76
+ cleanup = attach(providerRef.current);
77
+ } else {
78
+ pollId = setInterval(() => {
79
+ const p = providerRef.current;
80
+ if (!p) return;
81
+ if (pollId) {
82
+ clearInterval(pollId);
83
+ pollId = null;
84
+ }
85
+ cleanup = attach(p);
86
+ }, 100);
87
+ }
88
+
89
+ return () => {
90
+ if (pollId) clearInterval(pollId);
91
+ if (cleanup) cleanup();
92
+ };
93
+ }, [providerRef]);
94
+
95
+ if (users.length === 0) return null;
96
+
97
+ const visible = users.slice(0, MAX_VISIBLE);
98
+ const overflow = users.length - visible.length;
99
+ const totalLabel = `${users.length} other ${users.length === 1 ? "user" : "users"} editing`;
100
+
101
+ return (
102
+ <div className="connected-users" aria-label={totalLabel}>
103
+ {visible.map((u) => (
104
+ <Tooltip key={u.clientId} title={u.name}>
105
+ <span
106
+ className="connected-users__avatar"
107
+ style={{ borderColor: u.color }}
108
+ >
109
+ {u.avatarUrl ? (
110
+ <img src={u.avatarUrl} alt="" referrerPolicy="no-referrer" />
111
+ ) : (
112
+ <span
113
+ className="connected-users__initials"
114
+ style={{ background: u.color }}
115
+ >
116
+ {u.name.slice(0, 1).toUpperCase()}
117
+ </span>
118
+ )}
119
+ </span>
120
+ </Tooltip>
121
+ ))}
122
+ {overflow > 0 && (
123
+ <Tooltip
124
+ title={users
125
+ .slice(MAX_VISIBLE)
126
+ .map((u) => u.name)
127
+ .join(", ")}
128
+ >
129
+ <span className="connected-users__more">+{overflow}</span>
130
+ </Tooltip>
131
+ )}
132
+ </div>
133
+ );
134
+ }
frontend/src/components/TopBar.tsx CHANGED
@@ -18,6 +18,7 @@ import {
18
  } from "lucide-react";
19
  import { Tooltip } from "./Tooltip";
20
  import { SyncIndicator } from "./SyncIndicator";
 
21
  import type { CollabUser } from "../utils/user";
22
 
23
  interface TopBarProps {
@@ -258,6 +259,7 @@ export function TopBar({
258
  <Upload size={18} />
259
  </button>
260
  </Tooltip>
 
261
  <SyncIndicator editorInstance={editorInstance} providerRef={providerRef} />
262
  {loginUrl && !isAuthenticated ? (
263
  <a
 
18
  } from "lucide-react";
19
  import { Tooltip } from "./Tooltip";
20
  import { SyncIndicator } from "./SyncIndicator";
21
+ import { ConnectedUsers } from "./ConnectedUsers";
22
  import type { CollabUser } from "../utils/user";
23
 
24
  interface TopBarProps {
 
259
  <Upload size={18} />
260
  </button>
261
  </Tooltip>
262
+ <ConnectedUsers providerRef={providerRef} />
263
  <SyncIndicator editorInstance={editorInstance} providerRef={providerRef} />
264
  {loginUrl && !isAuthenticated ? (
265
  <a
frontend/src/main.tsx CHANGED
@@ -16,6 +16,7 @@ import "./styles/components/_mermaid.css";
16
  import "./styles/components/_embed.css";
17
  import "./styles/components/_embed-studio.css";
18
  import "./styles/components/_hf-user.css";
 
19
  import "./styles/components/_hero.css";
20
  import "./styles/components/_toc.css";
21
  import "./styles/components/_button.css";
 
16
  import "./styles/components/_embed.css";
17
  import "./styles/components/_embed-studio.css";
18
  import "./styles/components/_hf-user.css";
19
+ import "./styles/components/_connected-users.css";
20
  import "./styles/components/_hero.css";
21
  import "./styles/components/_toc.css";
22
  import "./styles/components/_button.css";
frontend/src/styles/components/_connected-users.css ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* ---------------------------------------------------------------------------
2
+ Connected users pill (editor top bar)
3
+
4
+ Small overlapping avatars of everyone else currently editing the
5
+ document. Each avatar's border matches the user's collab color
6
+ (same color as their cursor caret in the document), so it's easy
7
+ to tell at a glance who's behind a given selection.
8
+ --------------------------------------------------------------------------- */
9
+
10
+ .connected-users {
11
+ display: inline-flex;
12
+ align-items: center;
13
+ padding: 0 6px 0 4px;
14
+ }
15
+
16
+ .connected-users__avatar {
17
+ position: relative;
18
+ display: inline-flex;
19
+ align-items: center;
20
+ justify-content: center;
21
+ width: 24px;
22
+ height: 24px;
23
+ border-radius: 50%;
24
+ overflow: hidden;
25
+ border: 2px solid var(--border-color);
26
+ background: var(--surface-bg);
27
+ margin-left: -6px;
28
+ transition: transform 120ms ease, z-index 0s;
29
+ z-index: 1;
30
+ }
31
+ .connected-users__avatar:first-child {
32
+ margin-left: 0;
33
+ }
34
+ .connected-users__avatar:hover {
35
+ transform: translateY(-1px) scale(1.08);
36
+ z-index: 2;
37
+ }
38
+
39
+ .connected-users__avatar img {
40
+ width: 100%;
41
+ height: 100%;
42
+ object-fit: cover;
43
+ display: block;
44
+ }
45
+
46
+ .connected-users__initials {
47
+ width: 100%;
48
+ height: 100%;
49
+ display: flex;
50
+ align-items: center;
51
+ justify-content: center;
52
+ font-size: 11px;
53
+ font-weight: 700;
54
+ color: #fff;
55
+ text-transform: uppercase;
56
+ line-height: 1;
57
+ }
58
+
59
+ .connected-users__more {
60
+ display: inline-flex;
61
+ align-items: center;
62
+ justify-content: center;
63
+ width: 24px;
64
+ height: 24px;
65
+ border-radius: 50%;
66
+ background: var(--surface-bg);
67
+ color: var(--muted-color);
68
+ font-size: 11px;
69
+ font-weight: 700;
70
+ border: 2px solid var(--border-color);
71
+ margin-left: -6px;
72
+ }