canopy-ui 0.4.0 → 0.6.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +5 -2
- package/src/chat/ChatPanel.tsx +18 -3
- package/src/chat/MessageList.tsx +54 -4
- package/src/chat/SendBox.test.tsx +359 -0
- package/src/chat/SendBox.tsx +183 -21
- package/src/chat/drafts.test.ts +25 -1
- package/src/chat/drafts.ts +18 -0
- package/src/chat/groupToolRuns.test.ts +81 -0
- package/src/chat/groupToolRuns.ts +82 -0
- package/src/chat/index.ts +1 -1
- package/src/chat/pairToolMessages.test.ts +152 -0
- package/src/chat/pairToolMessages.ts +90 -14
- package/src/chat/protocol.ts +15 -2
- package/src/chat/sessionReducer.test.ts +259 -2
- package/src/chat/sessionReducer.ts +127 -4
- package/src/chat/useSessionSocket.ts +77 -14
- package/src/presence/PresenceBadge.test.tsx +84 -0
- package/src/presence/PresenceBadge.tsx +111 -0
- package/src/presence/avatar.test.ts +42 -0
- package/src/presence/avatar.ts +36 -0
- package/src/presence/index.ts +4 -0
- package/src/presence/pageKey.test.ts +53 -0
- package/src/presence/pageKey.ts +37 -0
- package/src/presence/usePresence.test.ts +199 -0
- package/src/presence/usePresence.ts +188 -0
|
@@ -50,6 +50,66 @@ export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState
|
|
|
50
50
|
return { ...prev, messages: [...prev.messages, assistant] };
|
|
51
51
|
}
|
|
52
52
|
|
|
53
|
+
case "session.activity":
|
|
54
|
+
return { ...prev, activity: frame.data.state };
|
|
55
|
+
|
|
56
|
+
case "chat.user_message": {
|
|
57
|
+
// Someone typed into emdash, OR into this page. Both reach here, and that
|
|
58
|
+
// is why matching on turn_index alone is not enough: a web send writes its
|
|
59
|
+
// row at a DENSE index (services._next_index), then the agent reads the
|
|
60
|
+
// message and the transcript re-ships the same text at a COMPOSITE ordinal
|
|
61
|
+
// (record * BLOCK_STRIDE + block). Same words, two different indices, two
|
|
62
|
+
// different ids — so the upsert missed and the message rendered twice
|
|
63
|
+
// live, while a reload showed it once (get_or_create dedupes server-side).
|
|
64
|
+
//
|
|
65
|
+
// Falling back to matching identical text on a recent user row closes it.
|
|
66
|
+
// Deliberately narrow: same role, same text, and only against the tail, so
|
|
67
|
+
// a genuine repeat of a short message ("yes") sent much later still lands
|
|
68
|
+
// as its own row.
|
|
69
|
+
const RECENT_USER_ROWS = 6;
|
|
70
|
+
const sameText = (m: Message) =>
|
|
71
|
+
m.role === "user" &&
|
|
72
|
+
m.plaintext.trim() !== "" &&
|
|
73
|
+
m.plaintext.trim() === frame.data.plaintext.trim();
|
|
74
|
+
const recentUsers = prev.messages.filter((m) => m.role === "user").slice(-RECENT_USER_ROWS);
|
|
75
|
+
const existing =
|
|
76
|
+
prev.messages.find(
|
|
77
|
+
(m) => m.id === frame.data.message_id ||
|
|
78
|
+
(m.role === "user" && m.turn_index === frame.data.turn_index),
|
|
79
|
+
) ?? recentUsers.find(sameText);
|
|
80
|
+
if (existing) {
|
|
81
|
+
return {
|
|
82
|
+
...prev,
|
|
83
|
+
messages: prev.messages.map((m) =>
|
|
84
|
+
m === existing
|
|
85
|
+
? {
|
|
86
|
+
...m,
|
|
87
|
+
id: frame.data.message_id,
|
|
88
|
+
// Take the incoming ordinal: the transcript's composite index
|
|
89
|
+
// is the durable one, so the row sorts where a reload puts it.
|
|
90
|
+
turn_index: frame.data.turn_index,
|
|
91
|
+
plaintext: frame.data.plaintext,
|
|
92
|
+
}
|
|
93
|
+
: m,
|
|
94
|
+
),
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
const nowIso = new Date().toISOString();
|
|
98
|
+
const user: Message = {
|
|
99
|
+
id: frame.data.message_id,
|
|
100
|
+
turn_index: frame.data.turn_index,
|
|
101
|
+
role: "user",
|
|
102
|
+
content: { text: frame.data.plaintext },
|
|
103
|
+
plaintext: frame.data.plaintext,
|
|
104
|
+
status: "complete",
|
|
105
|
+
error_detail: null,
|
|
106
|
+
started_at: null,
|
|
107
|
+
completed_at: null,
|
|
108
|
+
created_at: nowIso,
|
|
109
|
+
};
|
|
110
|
+
return { ...prev, messages: [...prev.messages, user] };
|
|
111
|
+
}
|
|
112
|
+
|
|
53
113
|
case "chat.delta":
|
|
54
114
|
return {
|
|
55
115
|
...prev,
|
|
@@ -106,10 +166,73 @@ export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState
|
|
|
106
166
|
};
|
|
107
167
|
|
|
108
168
|
case "chat.tool_use":
|
|
109
|
-
case "chat.tool_result":
|
|
110
|
-
// Tool rows are
|
|
111
|
-
//
|
|
112
|
-
|
|
169
|
+
case "chat.tool_result": {
|
|
170
|
+
// Tool rows are real Message rows on the server; these frames are the
|
|
171
|
+
// same row arriving live. Upsert by id so a re-delivery (reconnect
|
|
172
|
+
// catch-up, a retried post) updates in place instead of doubling the
|
|
173
|
+
// row — and so a tool_result that lands twice can't orphan its pair.
|
|
174
|
+
const role = frame.event === "chat.tool_use" ? "tool_use" : "tool_result";
|
|
175
|
+
const block = frame.data.block ?? {};
|
|
176
|
+
const plaintext = typeof block.text === "string" ? block.text : "";
|
|
177
|
+
const id = frame.data.tool_message_id;
|
|
178
|
+
// Reconcile on the CORRELATION key, not the message id. The same tool call
|
|
179
|
+
// arrives twice by design: once live from a hook (no ordinal, id `seq:-1`)
|
|
180
|
+
// and once durably from the transcript (ordinal-keyed, a real id). They
|
|
181
|
+
// share only `tool_use_id` / `id` in content, so matching on that is what
|
|
182
|
+
// makes the live row a placeholder the durable row REPLACES rather than a
|
|
183
|
+
// duplicate that sits beside it forever.
|
|
184
|
+
const correlation =
|
|
185
|
+
role === "tool_use"
|
|
186
|
+
? (block.id as string | undefined)
|
|
187
|
+
: (block.tool_use_id as string | undefined);
|
|
188
|
+
const existing = prev.messages.find(
|
|
189
|
+
(m) =>
|
|
190
|
+
m.id === id ||
|
|
191
|
+
(m.role === role &&
|
|
192
|
+
correlation !== undefined &&
|
|
193
|
+
correlation !== "" &&
|
|
194
|
+
(m.content as Record<string, unknown>)?.[
|
|
195
|
+
role === "tool_use" ? "id" : "tool_use_id"
|
|
196
|
+
] === correlation),
|
|
197
|
+
);
|
|
198
|
+
if (existing) {
|
|
199
|
+
// Adopt the incoming id and ordinal: a durable row superseding a live
|
|
200
|
+
// placeholder must take over its identity, or the next update keys on a
|
|
201
|
+
// `seq:-1` that no longer means anything.
|
|
202
|
+
return {
|
|
203
|
+
...prev,
|
|
204
|
+
messages: prev.messages.map((m) =>
|
|
205
|
+
m === existing
|
|
206
|
+
? {
|
|
207
|
+
...m,
|
|
208
|
+
id,
|
|
209
|
+
turn_index: frame.data.turn_index ?? m.turn_index,
|
|
210
|
+
content: block,
|
|
211
|
+
plaintext,
|
|
212
|
+
}
|
|
213
|
+
: m,
|
|
214
|
+
),
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
const nowIso = new Date().toISOString();
|
|
218
|
+
const message: Message = {
|
|
219
|
+
id,
|
|
220
|
+
// Fall back to appending after the newest row when the server didn't
|
|
221
|
+
// send an ordinal — order still holds, since frames arrive in order.
|
|
222
|
+
turn_index:
|
|
223
|
+
frame.data.turn_index ??
|
|
224
|
+
(prev.messages[prev.messages.length - 1]?.turn_index ?? 0) + 1,
|
|
225
|
+
role,
|
|
226
|
+
content: block,
|
|
227
|
+
plaintext,
|
|
228
|
+
status: block.is_error === true ? "error" : "complete",
|
|
229
|
+
error_detail: null,
|
|
230
|
+
started_at: nowIso,
|
|
231
|
+
completed_at: nowIso,
|
|
232
|
+
created_at: nowIso,
|
|
233
|
+
};
|
|
234
|
+
return { ...prev, messages: [...prev.messages, message] };
|
|
235
|
+
}
|
|
113
236
|
|
|
114
237
|
case "draft.updated": {
|
|
115
238
|
const incoming = frame.data as Draft;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { useCallback, useEffect, useRef, useState } from "react";
|
|
2
2
|
|
|
3
3
|
import type { Message, SessionState, WsEvent } from "./protocol";
|
|
4
|
+
import { shouldSyncDraftLive } from "./drafts";
|
|
4
5
|
import { prependHistory } from "./history";
|
|
5
6
|
import { sessionReducer } from "./sessionReducer";
|
|
6
7
|
|
|
@@ -36,8 +37,13 @@ export interface UseSessionSocketOptions {
|
|
|
36
37
|
export interface UseSessionSocketResult {
|
|
37
38
|
state: SessionState;
|
|
38
39
|
connected: boolean;
|
|
40
|
+
/** A send is outstanding with no reply yet — the turn is QUEUED, waiting for
|
|
41
|
+
* a runner. Nothing in `state` can express this (there is no assistant
|
|
42
|
+
* message until the first token), and it is what keeps Stop reachable while
|
|
43
|
+
* a turn is stuck. */
|
|
44
|
+
awaitingReply: boolean;
|
|
39
45
|
sendChat: () => void;
|
|
40
|
-
stopChat: (messageId: string) => void;
|
|
46
|
+
stopChat: (messageId: string | null) => void;
|
|
41
47
|
updateDraft: (body: string) => void;
|
|
42
48
|
takeOverDraft: () => void;
|
|
43
49
|
discardDraft: () => void;
|
|
@@ -53,6 +59,11 @@ export function useSessionSocket({
|
|
|
53
59
|
const [state, setState] = useState<SessionState>(INITIAL_STATE);
|
|
54
60
|
const [connected, setConnected] = useState(false);
|
|
55
61
|
const [lastError, setLastError] = useState<string | null>(null);
|
|
62
|
+
// A send has gone out but no reply has begun — i.e. the turn is QUEUED,
|
|
63
|
+
// waiting for a runner to claim it. There is no assistant message during
|
|
64
|
+
// this window, so nothing else in the state can express it, and without it
|
|
65
|
+
// the Stop control is unreachable exactly when the turn is stuck.
|
|
66
|
+
const [awaitingReply, setAwaitingReply] = useState(false);
|
|
56
67
|
|
|
57
68
|
const socketRef = useRef<WebSocket | null>(null);
|
|
58
69
|
const stateRef = useRef<SessionState>(INITIAL_STATE);
|
|
@@ -90,6 +101,17 @@ export function useSessionSocket({
|
|
|
90
101
|
}, []);
|
|
91
102
|
|
|
92
103
|
const applyEvent = useCallback((frame: WsEvent) => {
|
|
104
|
+
// Any of these means the queued window is over: the reply began, ended,
|
|
105
|
+
// was cancelled, or the send failed outright.
|
|
106
|
+
if (
|
|
107
|
+
frame.event === "chat.stream_start" ||
|
|
108
|
+
frame.event === "chat.stream_complete" ||
|
|
109
|
+
frame.event === "chat.stream_error" ||
|
|
110
|
+
frame.event === "chat.stream_cancelled" ||
|
|
111
|
+
frame.event === "session.error"
|
|
112
|
+
) {
|
|
113
|
+
setAwaitingReply(false);
|
|
114
|
+
}
|
|
93
115
|
// Side-effect events: handle BEFORE setState so React strict-mode's
|
|
94
116
|
// double-invocation of the updater doesn't double-fire the effect.
|
|
95
117
|
if (frame.event === "session.title_updated") {
|
|
@@ -183,27 +205,36 @@ export function useSessionSocket({
|
|
|
183
205
|
}, [connect]);
|
|
184
206
|
|
|
185
207
|
const sendChat = useCallback(() => {
|
|
186
|
-
// Flush
|
|
187
|
-
//
|
|
208
|
+
// Flush the local body BEFORE committing. `chat.send` commits the SERVER's
|
|
209
|
+
// draft, so this is the moment the body has to exist there — and when
|
|
210
|
+
// live sync is off (single-player) it is the ONLY time it is sent.
|
|
211
|
+
//
|
|
212
|
+
// Unconditional on purpose: keying this off a pending debounce timer meant
|
|
213
|
+
// nothing was flushed when there was no timer, which is now the normal case.
|
|
188
214
|
if (draftDebounceRef.current != null) {
|
|
189
215
|
window.clearTimeout(draftDebounceRef.current);
|
|
190
216
|
draftDebounceRef.current = null;
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
}
|
|
199
|
-
}
|
|
217
|
+
}
|
|
218
|
+
if (pendingDraftBodyRef.current != null && stateRef.current.active_draft) {
|
|
219
|
+
send({
|
|
220
|
+
action: "draft.update",
|
|
221
|
+
data: {
|
|
222
|
+
version: stateRef.current.active_draft.version,
|
|
223
|
+
body: pendingDraftBodyRef.current,
|
|
224
|
+
},
|
|
225
|
+
});
|
|
200
226
|
}
|
|
201
227
|
pendingDraftBodyRef.current = null;
|
|
228
|
+
setAwaitingReply(true);
|
|
202
229
|
send({ action: "chat.send", data: {} });
|
|
203
230
|
}, [send]);
|
|
204
231
|
|
|
205
232
|
const stopChat = useCallback(
|
|
206
|
-
(messageId: string) => {
|
|
233
|
+
(messageId: string | null) => {
|
|
234
|
+
// messageId is null when the turn is still queued. The server's
|
|
235
|
+
// chat.stop cancels every non-terminal turn on the session and only
|
|
236
|
+
// echoes the id back, so a null one cancels just as effectively.
|
|
237
|
+
setAwaitingReply(false);
|
|
207
238
|
send({ action: "chat.stop", data: { message_id: messageId } });
|
|
208
239
|
},
|
|
209
240
|
[send],
|
|
@@ -218,6 +249,17 @@ export function useSessionSocket({
|
|
|
218
249
|
: prev,
|
|
219
250
|
);
|
|
220
251
|
pendingDraftBodyRef.current = body;
|
|
252
|
+
// Alone in the session? Don't mirror keystrokes at all. The body is
|
|
253
|
+
// flushed once by sendChat, which is the only moment the server actually
|
|
254
|
+
// needs it. This is what makes single-player typing purely local — no
|
|
255
|
+
// round trip, no echo, no version to disagree about.
|
|
256
|
+
if (!shouldSyncDraftLive(stateRef.current.presence_user_ids)) {
|
|
257
|
+
if (draftDebounceRef.current != null) {
|
|
258
|
+
window.clearTimeout(draftDebounceRef.current);
|
|
259
|
+
draftDebounceRef.current = null;
|
|
260
|
+
}
|
|
261
|
+
return;
|
|
262
|
+
}
|
|
221
263
|
if (draftDebounceRef.current != null) {
|
|
222
264
|
window.clearTimeout(draftDebounceRef.current);
|
|
223
265
|
}
|
|
@@ -225,8 +267,11 @@ export function useSessionSocket({
|
|
|
225
267
|
draftDebounceRef.current = null;
|
|
226
268
|
const current = stateRef.current.active_draft;
|
|
227
269
|
const pending = pendingDraftBodyRef.current;
|
|
228
|
-
pendingDraftBodyRef.current = null;
|
|
229
270
|
if (current != null && pending != null) {
|
|
271
|
+
// Only consumed once it has actually gone out. Clearing it
|
|
272
|
+
// unconditionally dropped anything typed before session.state
|
|
273
|
+
// arrived (no draft yet ⇒ nothing sent, body forgotten).
|
|
274
|
+
pendingDraftBodyRef.current = null;
|
|
230
275
|
send({
|
|
231
276
|
action: "draft.update",
|
|
232
277
|
data: { version: current.version, body: pending },
|
|
@@ -245,6 +290,23 @@ export function useSessionSocket({
|
|
|
245
290
|
send({ action: "draft.discard", data: {} });
|
|
246
291
|
}, [send]);
|
|
247
292
|
|
|
293
|
+
// Someone joining mid-compose must see what is ALREADY typed. Nothing was
|
|
294
|
+
// mirrored while we were alone, so without this one catch-up flush their view
|
|
295
|
+
// would sit empty until the next keystroke. Closes the only gap that skipping
|
|
296
|
+
// live sync opens up.
|
|
297
|
+
const liveSync = shouldSyncDraftLive(state.presence_user_ids);
|
|
298
|
+
useEffect(() => {
|
|
299
|
+
if (!liveSync) return;
|
|
300
|
+
const pending = pendingDraftBodyRef.current;
|
|
301
|
+
const current = stateRef.current.active_draft;
|
|
302
|
+
if (pending == null || current == null) return;
|
|
303
|
+
pendingDraftBodyRef.current = null;
|
|
304
|
+
send({
|
|
305
|
+
action: "draft.update",
|
|
306
|
+
data: { version: current.version, body: pending },
|
|
307
|
+
});
|
|
308
|
+
}, [liveSync, send]);
|
|
309
|
+
|
|
248
310
|
const prependMessages = useCallback((older: Message[]) => {
|
|
249
311
|
// Apply a REST "Load earlier" page into the live socket state. A later
|
|
250
312
|
// session.state snapshot (e.g. reconnect) resets to the tail — acceptable;
|
|
@@ -258,6 +320,7 @@ export function useSessionSocket({
|
|
|
258
320
|
return {
|
|
259
321
|
state,
|
|
260
322
|
connected,
|
|
323
|
+
awaitingReply,
|
|
261
324
|
sendChat,
|
|
262
325
|
stopChat,
|
|
263
326
|
updateDraft,
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import { PresenceBadge } from './PresenceBadge'
|
|
5
|
+
import type { Viewer } from './usePresence'
|
|
6
|
+
|
|
7
|
+
// NOTE ON CONVENTION: canopy-web has no @testing-library/jest-dom and no
|
|
8
|
+
// user-event package. Assertions use toBeTruthy(), interactions use
|
|
9
|
+
// fireEvent, and every DOM test carries the `@vitest-environment jsdom`
|
|
10
|
+
// docblock above — the vitest config sets no global environment. Do not
|
|
11
|
+
// introduce toBeInTheDocument() here; it will not exist.
|
|
12
|
+
|
|
13
|
+
const viewer = (n: number, over: Partial<Viewer> = {}): Viewer => ({
|
|
14
|
+
email: `u${n}@x.com`,
|
|
15
|
+
name: `User ${n}`,
|
|
16
|
+
subLocation: 'run overview',
|
|
17
|
+
idle: false,
|
|
18
|
+
self: false,
|
|
19
|
+
...over,
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
describe('PresenceBadge', () => {
|
|
23
|
+
afterEach(cleanup)
|
|
24
|
+
|
|
25
|
+
it('renders nothing when you are the only viewer', () => {
|
|
26
|
+
const { container } = render(<PresenceBadge viewers={[viewer(1, { self: true })]} />)
|
|
27
|
+
expect(container.innerHTML).toBe('')
|
|
28
|
+
})
|
|
29
|
+
|
|
30
|
+
it('renders nothing when the roster is empty', () => {
|
|
31
|
+
const { container } = render(<PresenceBadge viewers={[]} />)
|
|
32
|
+
expect(container.innerHTML).toBe('')
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('shows at most three avatars and collapses the rest into +N', () => {
|
|
36
|
+
render(<PresenceBadge viewers={[viewer(1), viewer(2), viewer(3), viewer(4), viewer(5)]} />)
|
|
37
|
+
expect(screen.getByText('+2')).toBeTruthy()
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
it('labels the control with the viewer count for screen readers', () => {
|
|
41
|
+
render(<PresenceBadge viewers={[viewer(1), viewer(2)]} />)
|
|
42
|
+
expect(screen.getByRole('button', { name: /2 people viewing this page/i })).toBeTruthy()
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('expands to a named list on click, listing you first and marked', () => {
|
|
46
|
+
render(<PresenceBadge viewers={[viewer(1), viewer(2, { self: true, name: 'Me' })]} />)
|
|
47
|
+
fireEvent.click(screen.getByRole('button'))
|
|
48
|
+
expect(screen.getByText(/Me/)).toBeTruthy()
|
|
49
|
+
expect(screen.getByText('(you)')).toBeTruthy()
|
|
50
|
+
expect(screen.getByText('User 1')).toBeTruthy()
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
it('renders two viewers with no email without colliding their React keys', () => {
|
|
54
|
+
// Email is not guaranteed non-empty (the server sends "" for a user with
|
|
55
|
+
// none), so it cannot be the row key. A collision here shows up as a
|
|
56
|
+
// React duplicate-key error, not as a visibly missing row.
|
|
57
|
+
const errors: unknown[][] = []
|
|
58
|
+
const spy = vi.spyOn(console, 'error').mockImplementation((...args) => {
|
|
59
|
+
errors.push(args)
|
|
60
|
+
})
|
|
61
|
+
try {
|
|
62
|
+
render(
|
|
63
|
+
<PresenceBadge
|
|
64
|
+
viewers={[
|
|
65
|
+
viewer(1, { email: '', name: 'Ana' }),
|
|
66
|
+
viewer(2, { email: '', name: 'Bo' }),
|
|
67
|
+
]}
|
|
68
|
+
/>,
|
|
69
|
+
)
|
|
70
|
+
fireEvent.click(screen.getByRole('button'))
|
|
71
|
+
expect(screen.getByText('Ana')).toBeTruthy()
|
|
72
|
+
expect(screen.getByText('Bo')).toBeTruthy()
|
|
73
|
+
} finally {
|
|
74
|
+
spy.mockRestore()
|
|
75
|
+
}
|
|
76
|
+
expect(errors.flat().join(' ')).not.toMatch(/same key/i)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('marks idle viewers in the expanded list', () => {
|
|
80
|
+
render(<PresenceBadge viewers={[viewer(1, { idle: true }), viewer(2)]} />)
|
|
81
|
+
fireEvent.click(screen.getByRole('button'))
|
|
82
|
+
expect(screen.getByText('idle')).toBeTruthy()
|
|
83
|
+
})
|
|
84
|
+
})
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react'
|
|
2
|
+
import { avatarFor } from './avatar'
|
|
3
|
+
import type { Viewer } from './usePresence'
|
|
4
|
+
|
|
5
|
+
const MAX_AVATARS = 3
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Collapsed viewer cluster that expands into a named list.
|
|
9
|
+
*
|
|
10
|
+
* Renders nothing when you are alone — a badge that permanently reads "1"
|
|
11
|
+
* is noise, and the account menu already tells you who you are.
|
|
12
|
+
*/
|
|
13
|
+
export function PresenceBadge({ viewers }: { viewers: Viewer[] }) {
|
|
14
|
+
const [open, setOpen] = useState(false)
|
|
15
|
+
const rootRef = useRef<HTMLDivElement>(null)
|
|
16
|
+
|
|
17
|
+
useEffect(() => {
|
|
18
|
+
if (!open) return
|
|
19
|
+
const onDown = (e: MouseEvent) => {
|
|
20
|
+
if (!rootRef.current?.contains(e.target as Node)) setOpen(false)
|
|
21
|
+
}
|
|
22
|
+
const onKey = (e: KeyboardEvent) => {
|
|
23
|
+
if (e.key === 'Escape') setOpen(false)
|
|
24
|
+
}
|
|
25
|
+
document.addEventListener('mousedown', onDown)
|
|
26
|
+
document.addEventListener('keydown', onKey)
|
|
27
|
+
return () => {
|
|
28
|
+
document.removeEventListener('mousedown', onDown)
|
|
29
|
+
document.removeEventListener('keydown', onKey)
|
|
30
|
+
}
|
|
31
|
+
}, [open])
|
|
32
|
+
|
|
33
|
+
if (viewers.length < 2) return null
|
|
34
|
+
|
|
35
|
+
// You first, then everyone else in roster order.
|
|
36
|
+
//
|
|
37
|
+
// Rows are keyed by POSITION, not by email: email is the only plausible
|
|
38
|
+
// identity field on a Viewer and it is not guaranteed non-empty (the
|
|
39
|
+
// server sends "" for a user with no email), so two such viewers would
|
|
40
|
+
// collide on one key. The list is short, server-ordered, and re-rendered
|
|
41
|
+
// wholesale on every roster broadcast, so index keys cost nothing here.
|
|
42
|
+
const ordered = [...viewers].sort((a, b) => Number(b.self) - Number(a.self))
|
|
43
|
+
const shown = ordered.slice(0, MAX_AVATARS)
|
|
44
|
+
const overflow = ordered.length - shown.length
|
|
45
|
+
|
|
46
|
+
return (
|
|
47
|
+
<div className="relative" ref={rootRef}>
|
|
48
|
+
<button
|
|
49
|
+
type="button"
|
|
50
|
+
onClick={() => setOpen((v) => !v)}
|
|
51
|
+
aria-expanded={open}
|
|
52
|
+
aria-label={`${viewers.length} people viewing this page`}
|
|
53
|
+
className="flex items-center -space-x-2 rounded-full p-0.5 hover:opacity-90"
|
|
54
|
+
>
|
|
55
|
+
{shown.map((v, i) => {
|
|
56
|
+
const { initials, colorClass } = avatarFor(v.email, v.name)
|
|
57
|
+
return (
|
|
58
|
+
<span
|
|
59
|
+
key={i}
|
|
60
|
+
className={`inline-flex h-6 w-6 items-center justify-center rounded-full
|
|
61
|
+
ring-2 ring-card text-[10px] font-semibold text-white ${colorClass}
|
|
62
|
+
${v.idle ? 'opacity-45' : ''}`}
|
|
63
|
+
>
|
|
64
|
+
{initials}
|
|
65
|
+
</span>
|
|
66
|
+
)
|
|
67
|
+
})}
|
|
68
|
+
{overflow > 0 && (
|
|
69
|
+
<span
|
|
70
|
+
className="inline-flex h-6 w-6 items-center justify-center rounded-full
|
|
71
|
+
bg-muted ring-2 ring-card text-[10px] font-semibold text-muted-foreground"
|
|
72
|
+
>
|
|
73
|
+
+{overflow}
|
|
74
|
+
</span>
|
|
75
|
+
)}
|
|
76
|
+
</button>
|
|
77
|
+
|
|
78
|
+
{open && (
|
|
79
|
+
<div
|
|
80
|
+
className="absolute right-0 z-50 mt-2 w-64 rounded-md border border-border
|
|
81
|
+
bg-card p-1 shadow-md"
|
|
82
|
+
>
|
|
83
|
+
{ordered.map((v, i) => {
|
|
84
|
+
const { initials, colorClass } = avatarFor(v.email, v.name)
|
|
85
|
+
return (
|
|
86
|
+
<div key={i} className="flex items-center gap-2 rounded px-2 py-1.5">
|
|
87
|
+
<span
|
|
88
|
+
className={`inline-flex h-6 w-6 shrink-0 items-center justify-center
|
|
89
|
+
rounded-full text-[10px] font-semibold text-white ${colorClass}
|
|
90
|
+
${v.idle ? 'opacity-45' : ''}`}
|
|
91
|
+
>
|
|
92
|
+
{initials}
|
|
93
|
+
</span>
|
|
94
|
+
<span className="min-w-0 flex-1">
|
|
95
|
+
<span className="block truncate text-sm text-foreground">
|
|
96
|
+
{v.name || v.email}
|
|
97
|
+
{v.self && <span className="ml-1 text-muted-foreground">(you)</span>}
|
|
98
|
+
</span>
|
|
99
|
+
<span className="block truncate text-xs text-muted-foreground">
|
|
100
|
+
{v.subLocation}
|
|
101
|
+
</span>
|
|
102
|
+
</span>
|
|
103
|
+
{v.idle && <span className="text-[10px] text-muted-foreground">idle</span>}
|
|
104
|
+
</div>
|
|
105
|
+
)
|
|
106
|
+
})}
|
|
107
|
+
</div>
|
|
108
|
+
)}
|
|
109
|
+
</div>
|
|
110
|
+
)
|
|
111
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { avatarFor } from './avatar'
|
|
3
|
+
|
|
4
|
+
describe('avatarFor', () => {
|
|
5
|
+
it('takes initials from a two-part display name', () => {
|
|
6
|
+
expect(avatarFor('alice@x.com', 'Alice Chen').initials).toBe('AC')
|
|
7
|
+
})
|
|
8
|
+
|
|
9
|
+
it('falls back to the email local-part when there is no name', () => {
|
|
10
|
+
expect(avatarFor('bob.ali@x.com', '').initials).toBe('BA')
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('produces a single initial for a one-word identity', () => {
|
|
14
|
+
expect(avatarFor('ace@x.com', 'ACE').initials).toBe('A')
|
|
15
|
+
})
|
|
16
|
+
|
|
17
|
+
it('is deterministic: the same email is always the same color', () => {
|
|
18
|
+
expect(avatarFor('alice@x.com', 'Alice Chen').colorClass)
|
|
19
|
+
.toBe(avatarFor('alice@x.com', 'Different Name').colorClass)
|
|
20
|
+
})
|
|
21
|
+
|
|
22
|
+
it('color is a deterministic function of email via djb2', () => {
|
|
23
|
+
const email = 'alice@example.com'
|
|
24
|
+
const COLORS = [
|
|
25
|
+
'bg-sky-600',
|
|
26
|
+
'bg-emerald-600',
|
|
27
|
+
'bg-violet-600',
|
|
28
|
+
'bg-amber-600',
|
|
29
|
+
'bg-rose-600',
|
|
30
|
+
'bg-teal-600',
|
|
31
|
+
'bg-indigo-600',
|
|
32
|
+
'bg-fuchsia-600',
|
|
33
|
+
]
|
|
34
|
+
|
|
35
|
+
// Compute expected color using djb2 formula
|
|
36
|
+
let h = 5381
|
|
37
|
+
for (let i = 0; i < email.length; i++) h = ((h << 5) + h + email.charCodeAt(i)) | 0
|
|
38
|
+
const expectedColorClass = COLORS[Math.abs(h) % COLORS.length]
|
|
39
|
+
|
|
40
|
+
expect(avatarFor(email, 'Any Name').colorClass).toBe(expectedColorClass)
|
|
41
|
+
})
|
|
42
|
+
})
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Fixed palette. Chosen for legibility against white text in both themes;
|
|
2
|
+
// index is picked by a stable hash of the email so a person keeps one color
|
|
3
|
+
// everywhere, in every app, across sessions.
|
|
4
|
+
const COLORS = [
|
|
5
|
+
'bg-sky-600',
|
|
6
|
+
'bg-emerald-600',
|
|
7
|
+
'bg-violet-600',
|
|
8
|
+
'bg-amber-600',
|
|
9
|
+
'bg-rose-600',
|
|
10
|
+
'bg-teal-600',
|
|
11
|
+
'bg-indigo-600',
|
|
12
|
+
'bg-fuchsia-600',
|
|
13
|
+
]
|
|
14
|
+
|
|
15
|
+
function hash(value: string): number {
|
|
16
|
+
// djb2. Not cryptographic — we only need stable bucketing.
|
|
17
|
+
let h = 5381
|
|
18
|
+
for (let i = 0; i < value.length; i++) h = ((h << 5) + h + value.charCodeAt(i)) | 0
|
|
19
|
+
return Math.abs(h)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Initials + a stable color class for one person.
|
|
24
|
+
*
|
|
25
|
+
* Color keys on email rather than display name so changing your name does
|
|
26
|
+
* not change your color out from under people who have learned it.
|
|
27
|
+
*/
|
|
28
|
+
export function avatarFor(email: string, name: string): { initials: string; colorClass: string } {
|
|
29
|
+
const source = (name || email.split('@')[0] || '?').trim()
|
|
30
|
+
const words = source.split(/[\s._-]+/).filter(Boolean)
|
|
31
|
+
const initials =
|
|
32
|
+
words.length >= 2
|
|
33
|
+
? (words[0][0] + words[1][0]).toUpperCase()
|
|
34
|
+
: (words[0]?.[0] ?? '?').toUpperCase()
|
|
35
|
+
return { initials, colorClass: COLORS[hash(email) % COLORS.length] }
|
|
36
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest'
|
|
2
|
+
import { pageKeyFor, type RouteRule } from './pageKey'
|
|
3
|
+
|
|
4
|
+
const ACE_RULES: RouteRule[] = [
|
|
5
|
+
{
|
|
6
|
+
pattern: /^\/w\/([^/]+)\/opps\/([^/]+)\/runs\/([^/]+)\/steps\/([^/]+)/,
|
|
7
|
+
build: (m) => ({ workspace: m[1], resource: `opp:${m[2]}/${m[3]}`, subLocation: m[4] }),
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
pattern: /^\/w\/([^/]+)\/opps\/([^/]+)\/runs\/([^/]+)/,
|
|
11
|
+
build: (m) => ({ workspace: m[1], resource: `opp:${m[2]}/${m[3]}`, subLocation: 'run overview' }),
|
|
12
|
+
},
|
|
13
|
+
{
|
|
14
|
+
pattern: /^\/w\/([^/]+)\/activity/,
|
|
15
|
+
build: (m) => ({ workspace: m[1], resource: 'activity', subLocation: 'Activity' }),
|
|
16
|
+
},
|
|
17
|
+
]
|
|
18
|
+
|
|
19
|
+
describe('pageKeyFor', () => {
|
|
20
|
+
it('collapses every step of a run onto one key, keeping the step as sub-location', () => {
|
|
21
|
+
const a = pageKeyFor('ace', '/w/dimagi-team/opps/bednet/runs/run-001/steps/idea-to-pdd', ACE_RULES)
|
|
22
|
+
const b = pageKeyFor('ace', '/w/dimagi-team/opps/bednet/runs/run-001', ACE_RULES)
|
|
23
|
+
expect(a?.pageKey).toBe('ace:dimagi-team:opp:bednet/run-001')
|
|
24
|
+
expect(b?.pageKey).toBe(a?.pageKey)
|
|
25
|
+
expect(a?.subLocation).toBe('idea-to-pdd')
|
|
26
|
+
expect(b?.subLocation).toBe('run overview')
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('keeps different runs of the same opp on different keys', () => {
|
|
30
|
+
const a = pageKeyFor('ace', '/w/dimagi-team/opps/bednet/runs/run-001', ACE_RULES)
|
|
31
|
+
const b = pageKeyFor('ace', '/w/dimagi-team/opps/bednet/runs/run-002', ACE_RULES)
|
|
32
|
+
expect(a?.pageKey).not.toBe(b?.pageKey)
|
|
33
|
+
})
|
|
34
|
+
|
|
35
|
+
it('namespaces by app so two apps never collide', () => {
|
|
36
|
+
const ace = pageKeyFor('ace', '/w/dimagi-team/activity', ACE_RULES)
|
|
37
|
+
const canopy = pageKeyFor('canopy', '/w/dimagi-team/activity', ACE_RULES)
|
|
38
|
+
expect(ace?.pageKey).toBe('ace:dimagi-team:activity')
|
|
39
|
+
expect(canopy?.pageKey).toBe('canopy:dimagi-team:activity')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it('returns null for unmatched routes rather than a catch-all key', () => {
|
|
43
|
+
expect(pageKeyFor('ace', '/totally/unknown', ACE_RULES)).toBeNull()
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
it('is order-sensitive: the first matching rule wins', () => {
|
|
47
|
+
const loose: RouteRule[] = [
|
|
48
|
+
{ pattern: /^\/w\/([^/]+)\/opps/, build: (m) => ({ workspace: m[1], resource: 'opps', subLocation: 'Opps' }) },
|
|
49
|
+
...ACE_RULES,
|
|
50
|
+
]
|
|
51
|
+
expect(pageKeyFor('ace', '/w/x/opps/bednet/runs/run-001', loose)?.pageKey).toBe('ace:x:opps')
|
|
52
|
+
})
|
|
53
|
+
})
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/** A resolved presence location: which roster to join, and where in it you are. */
|
|
2
|
+
export interface PageLocation {
|
|
3
|
+
/** `<app>:<workspace|global>:<resource>` — the roster identity. */
|
|
4
|
+
pageKey: string
|
|
5
|
+
/** Human-readable position within the resource, for the expanded panel. */
|
|
6
|
+
subLocation: string
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface RouteRule {
|
|
10
|
+
pattern: RegExp
|
|
11
|
+
build: (m: RegExpMatchArray) => { workspace: string; resource: string; subLocation: string }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Resolve a pathname to a presence location. Pure — the single place
|
|
16
|
+
* grouping correctness lives.
|
|
17
|
+
*
|
|
18
|
+
* Rules are evaluated in order and the first match wins, so more specific
|
|
19
|
+
* patterns must be listed before looser ones.
|
|
20
|
+
*
|
|
21
|
+
* Returns null when nothing matches. Callers render no badge in that case;
|
|
22
|
+
* grouping every unrecognised route under one catch-all key would put
|
|
23
|
+
* unrelated strangers in the same roster.
|
|
24
|
+
*/
|
|
25
|
+
export function pageKeyFor(
|
|
26
|
+
app: string,
|
|
27
|
+
pathname: string,
|
|
28
|
+
rules: RouteRule[],
|
|
29
|
+
): PageLocation | null {
|
|
30
|
+
for (const rule of rules) {
|
|
31
|
+
const m = pathname.match(rule.pattern)
|
|
32
|
+
if (!m) continue
|
|
33
|
+
const { workspace, resource, subLocation } = rule.build(m)
|
|
34
|
+
return { pageKey: `${app}:${workspace}:${resource}`, subLocation }
|
|
35
|
+
}
|
|
36
|
+
return null
|
|
37
|
+
}
|