canopy-ui 0.6.2 → 0.7.0
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.pending.test.tsx +136 -0
- package/src/chat/ChatPanel.tsx +48 -2
- package/src/chat/MenuPrompt.test.tsx +24 -0
- package/src/chat/MenuPrompt.tsx +331 -0
- package/src/chat/MessageItem.tsx +21 -8
- package/src/chat/MessageList.tsx +32 -3
- package/src/chat/PlacementBanner.test.tsx +75 -0
- package/src/chat/PlacementBanner.tsx +52 -15
- package/src/chat/PresenceChips.tsx +113 -19
- package/src/chat/SendBox.test.tsx +151 -0
- package/src/chat/SendBox.tsx +139 -10
- package/src/chat/drafts.test.ts +120 -0
- package/src/chat/drafts.ts +134 -0
- package/src/chat/index.ts +13 -0
- package/src/chat/pairToolMessages.ts +1 -1
- package/src/chat/protocol.ts +98 -2
- package/src/chat/sessionReducer.test.ts +143 -0
- package/src/chat/sessionReducer.ts +54 -3
- 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
package/src/chat/protocol.ts
CHANGED
|
@@ -60,6 +60,66 @@ export interface Participant {
|
|
|
60
60
|
last_seen_at: string | null;
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
/** A dialog an agent is blocked on.
|
|
64
|
+
*
|
|
65
|
+
* `title` and `body` are what makes it answerable away from the keyboard:
|
|
66
|
+
* "Do you want to proceed?" tells you nothing without the command it means.
|
|
67
|
+
*
|
|
68
|
+
* Two producers, one shape, so this never grows a second reader: the session
|
|
69
|
+
* report derives it from the transcript (an `AskUserQuestion` tool call — what
|
|
70
|
+
* actually blocks a fleet running `bypass permissions`), and the runner can
|
|
71
|
+
* still read it off the rendered screen for the dialogs a transcript cannot
|
|
72
|
+
* see. `source` says which, and a client is free to ignore it. */
|
|
73
|
+
/** One question of an `AskUserQuestion`. The TUI draws these as TABS and will
|
|
74
|
+
* not submit until each has an answer — so a surface that renders only the
|
|
75
|
+
* first one cannot complete the ask no matter which button is pressed. That
|
|
76
|
+
* was the bug: a two-question closeout showed one question, and a tap toggled
|
|
77
|
+
* a checkbox on a dialog that then sat waiting for a Submit nobody could
|
|
78
|
+
* reach (eva, 2026-08-12). */
|
|
79
|
+
export interface MenuQuestion {
|
|
80
|
+
index: number;
|
|
81
|
+
question: string;
|
|
82
|
+
header?: string;
|
|
83
|
+
/** Whether this question takes ANY number of answers. The TUI renders it as
|
|
84
|
+
* checkboxes and a number key TOGGLES one instead of answering, which is why
|
|
85
|
+
* a client that cannot see this flag renders the wrong control AND the
|
|
86
|
+
* runner presses the wrong key. */
|
|
87
|
+
multi_select?: boolean;
|
|
88
|
+
options: { number: number; label: string; description?: string }[];
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface SessionMenu {
|
|
92
|
+
question: string;
|
|
93
|
+
title?: string;
|
|
94
|
+
body?: string;
|
|
95
|
+
selected?: number | null;
|
|
96
|
+
source?: string;
|
|
97
|
+
/** Every question in the ask, in declaration order. Absent on a dialog with
|
|
98
|
+
* no tool call behind it (a permission prompt, a trust gate) and on a menu
|
|
99
|
+
* from a producer older than this — in both cases the client falls back to
|
|
100
|
+
* the single-question fields above, which still describe question 1. */
|
|
101
|
+
questions?: MenuQuestion[];
|
|
102
|
+
/** `description` is present on the transcript path and is often the only
|
|
103
|
+
* thing that distinguishes two options — "Proceed to Phase 4" does not say
|
|
104
|
+
* that Phase 4 is test-gated, and its description does. */
|
|
105
|
+
options: { number: number; label: string; description?: string }[];
|
|
106
|
+
/** Set when a human's tap was RELAYED to the runner and then refused there —
|
|
107
|
+
* a stale dialog, a shell tab selected in emdash, an unreachable box. The
|
|
108
|
+
* API answers `ok:true` the moment it relays the frame, so without this a
|
|
109
|
+
* correct refusal is indistinguishable from a press that worked, and the
|
|
110
|
+
* button reads as dead. `answer_note` is the sentence to show; the code is
|
|
111
|
+
* for logs. The menu stays up alongside it, so there is something to retry. */
|
|
112
|
+
answer_error?: string;
|
|
113
|
+
answer_note?: string;
|
|
114
|
+
/** Carried across a runner restart rather than observed this process. Nothing
|
|
115
|
+
* should branch on it — a tap verifies against the real screen either way. */
|
|
116
|
+
restored?: boolean;
|
|
117
|
+
/** Epoch seconds when a producer last SAW this dialog. The dialog lives on a
|
|
118
|
+
* terminal; this object is a copy, and without an age the only way to find
|
|
119
|
+
* out the copy is stale is to tap it and be refused. */
|
|
120
|
+
observed_at?: number;
|
|
121
|
+
}
|
|
122
|
+
|
|
63
123
|
export interface SessionState {
|
|
64
124
|
messages: Message[];
|
|
65
125
|
/** Live agent activity, from the runner's turn-boundary hooks. Undefined when
|
|
@@ -71,6 +131,21 @@ export interface SessionState {
|
|
|
71
131
|
* those apart — but it is the difference between "still thinking, wait" and
|
|
72
132
|
* "it is waiting on YOU", which previously rendered identically. */
|
|
73
133
|
activity?: "working" | "idle" | "blocked";
|
|
134
|
+
/** Whether a stop the human asked for actually landed. A SEPARATE axis from
|
|
135
|
+
* `activity`, deliberately: a stop that failed leaves the agent `working`,
|
|
136
|
+
* which is true and must stay true, so the outcome of the stop cannot be a
|
|
137
|
+
* value of activity without either lying or being lost.
|
|
138
|
+
*
|
|
139
|
+
* "requested" is set the moment the server publishes to the runner — nothing
|
|
140
|
+
* has pressed Escape yet. Only the runner can say "stopped" or "failed", and
|
|
141
|
+
* it only says so after verifying the terminal (see #649: an unverified
|
|
142
|
+
* Escape reported as success is what made Stop untrustworthy). */
|
|
143
|
+
stopState?: "requested" | "stopped" | "failed";
|
|
144
|
+
/** The dialog the agent is waiting on. Carried in the CONNECT SNAPSHOT, not
|
|
145
|
+
* only in live frames: `session.activity` is view-only and reaches a client
|
|
146
|
+
* only if it was already connected when the agent blocked — which is exactly
|
|
147
|
+
* the case that fails, because you go and look BECAUSE it stopped. */
|
|
148
|
+
menu?: SessionMenu;
|
|
74
149
|
active_draft: Draft | null;
|
|
75
150
|
participants: Participant[];
|
|
76
151
|
presence_user_ids: number[];
|
|
@@ -96,7 +171,14 @@ export type WsEvent =
|
|
|
96
171
|
| { event: "chat.stream_start"; data: { message_id: string; turn_index: number } }
|
|
97
172
|
// The agent started or finished a turn. Distinct from tool events: it fires
|
|
98
173
|
// while Claude is THINKING, before any content exists to show.
|
|
99
|
-
| { event: "session.activity"; data: { state: "working" | "idle" | "blocked" } }
|
|
174
|
+
| { event: "session.activity"; data: { state: "working" | "idle" | "blocked"; menu?: SessionMenu } }
|
|
175
|
+
| { event: "session.stop"; data: { state: "requested" | "stopped" | "failed" } }
|
|
176
|
+
// The agent started, or stopped, waiting on a dialog. Its own frame rather
|
|
177
|
+
// than an overloaded `session.activity`: activity answers "is it producing",
|
|
178
|
+
// which the hook path owns on a much faster clock, and inventing a state here
|
|
179
|
+
// to carry a menu would report an agent as idle or blocked on the wrong one.
|
|
180
|
+
// `menu: null` is the retraction — somebody answered at the keyboard.
|
|
181
|
+
| { event: "session.menu"; data: { menu: SessionMenu | null } }
|
|
100
182
|
// A human typed into emdash rather than into this page. No client echoed it,
|
|
101
183
|
// so this is the only way it reaches the browser before a reload.
|
|
102
184
|
| { event: "chat.user_message"; data: { message_id: string; turn_index: number; plaintext: string } }
|
|
@@ -113,5 +195,19 @@ export type WsEvent =
|
|
|
113
195
|
| { event: "draft.lock_changed"; data: { draft_id: string; holder_user_id: number | null; expires_at: number | null } }
|
|
114
196
|
| { event: "draft.committed"; data: { draft_id: string; user_message_id: string } }
|
|
115
197
|
| { event: "draft.discarded"; data: { draft_id: string } }
|
|
116
|
-
|
|
198
|
+
// `participant` carries WHO joined, so a client can add them to its
|
|
199
|
+
// participant list. Without it a first-time joiner has an id and no name,
|
|
200
|
+
// and the presence row (which renders participants filtered by presence)
|
|
201
|
+
// cannot show them at all. Optional so an older server degrades rather than
|
|
202
|
+
// breaks. The bare `email`/`display_name` below are the vestigial shape that
|
|
203
|
+
// was declared but never sent by anything.
|
|
204
|
+
| {
|
|
205
|
+
event: "presence.joined";
|
|
206
|
+
data: {
|
|
207
|
+
user_id: number;
|
|
208
|
+
participant?: Participant;
|
|
209
|
+
email?: string;
|
|
210
|
+
display_name?: string;
|
|
211
|
+
};
|
|
212
|
+
}
|
|
117
213
|
| { event: "presence.left"; data: { user_id: number } };
|
|
@@ -584,3 +584,146 @@ describe("blocked — the agent is waiting on YOU", () => {
|
|
|
584
584
|
expect(after.activity).toBe("blocked");
|
|
585
585
|
});
|
|
586
586
|
});
|
|
587
|
+
|
|
588
|
+
describe("the dialog an agent is blocked on", () => {
|
|
589
|
+
const MENU = {
|
|
590
|
+
question: "Do you want to proceed?",
|
|
591
|
+
title: "Bash command",
|
|
592
|
+
body: "rm target.txt",
|
|
593
|
+
options: [{ number: 1, label: "Yes" }, { number: 2, label: "No" }],
|
|
594
|
+
};
|
|
595
|
+
|
|
596
|
+
it("is carried with the blocked state", () => {
|
|
597
|
+
const state = sessionReducer(makeState(), {
|
|
598
|
+
event: "session.activity",
|
|
599
|
+
data: { state: "blocked", menu: MENU },
|
|
600
|
+
});
|
|
601
|
+
expect(state.menu?.options).toHaveLength(2);
|
|
602
|
+
expect(state.menu?.body).toBe("rm target.txt");
|
|
603
|
+
});
|
|
604
|
+
|
|
605
|
+
it("is dropped when the agent starts producing again", () => {
|
|
606
|
+
// Buttons that answer a dialog no longer on screen send a stray keystroke
|
|
607
|
+
// into the prompt — worse than showing nothing.
|
|
608
|
+
const blocked = sessionReducer(makeState(), {
|
|
609
|
+
event: "session.activity",
|
|
610
|
+
data: { state: "blocked", menu: MENU },
|
|
611
|
+
});
|
|
612
|
+
const after = sessionReducer(blocked, {
|
|
613
|
+
event: "chat.tool_use",
|
|
614
|
+
data: { parent_message_id: null, tool_message_id: "m1", turn_index: 3, block: { id: "t1" } },
|
|
615
|
+
});
|
|
616
|
+
expect(after.activity).toBe("working");
|
|
617
|
+
expect(after.menu).toBeUndefined();
|
|
618
|
+
});
|
|
619
|
+
|
|
620
|
+
it("is cleared by a later activity frame that says the agent is not waiting", () => {
|
|
621
|
+
const blocked = sessionReducer(makeState(), {
|
|
622
|
+
event: "session.activity",
|
|
623
|
+
data: { state: "blocked", menu: MENU },
|
|
624
|
+
});
|
|
625
|
+
const idle = sessionReducer(blocked, { event: "session.activity", data: { state: "idle" } });
|
|
626
|
+
expect(idle.menu).toBeUndefined();
|
|
627
|
+
});
|
|
628
|
+
|
|
629
|
+
it("survives a blocked frame that carries no menu", () => {
|
|
630
|
+
// The hook path reports `blocked` WITHOUT a menu — it deliberately does not
|
|
631
|
+
// read the screen (#510: doing so stole emdash's focus). Treating that as a
|
|
632
|
+
// retraction would erase a menu the snapshot or the session report had
|
|
633
|
+
// already supplied, which is every menu we now have.
|
|
634
|
+
const blocked = sessionReducer(makeState(), {
|
|
635
|
+
event: "session.activity",
|
|
636
|
+
data: { state: "blocked", menu: MENU },
|
|
637
|
+
});
|
|
638
|
+
const again = sessionReducer(blocked, { event: "session.activity", data: { state: "blocked" } });
|
|
639
|
+
expect(again.menu?.question).toBe("Do you want to proceed?");
|
|
640
|
+
});
|
|
641
|
+
|
|
642
|
+
it("arrives in the connect snapshot, not only in a live frame", () => {
|
|
643
|
+
// THE fix for "when I click on the session I don't see the menu". Activity
|
|
644
|
+
// frames are view-only and reach a client only if it was connected when
|
|
645
|
+
// they fired; you open the session precisely because it stopped.
|
|
646
|
+
const state = sessionReducer(makeState(), {
|
|
647
|
+
event: "session.state",
|
|
648
|
+
data: makeState({ activity: "blocked", menu: MENU }),
|
|
649
|
+
});
|
|
650
|
+
expect(state.menu?.question).toBe("Do you want to proceed?");
|
|
651
|
+
});
|
|
652
|
+
|
|
653
|
+
it("appears while you are already watching, via its own frame", () => {
|
|
654
|
+
const state = sessionReducer(makeState(), {
|
|
655
|
+
event: "session.menu",
|
|
656
|
+
data: { menu: MENU },
|
|
657
|
+
});
|
|
658
|
+
expect(state.menu?.options).toHaveLength(2);
|
|
659
|
+
});
|
|
660
|
+
|
|
661
|
+
it("is retracted when somebody answers at the keyboard", () => {
|
|
662
|
+
// `menu: null` from the session report. Buttons that outlive their dialog
|
|
663
|
+
// press a number into what is now an ordinary prompt, where the agent reads
|
|
664
|
+
// a bare "1" as an instruction.
|
|
665
|
+
const blocked = sessionReducer(makeState(), {
|
|
666
|
+
event: "session.menu",
|
|
667
|
+
data: { menu: MENU },
|
|
668
|
+
});
|
|
669
|
+
const cleared = sessionReducer(blocked, { event: "session.menu", data: { menu: null } });
|
|
670
|
+
expect(cleared.menu).toBeUndefined();
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
it("keeps an option's description, which is often the whole difference", () => {
|
|
674
|
+
const state = sessionReducer(makeState(), {
|
|
675
|
+
event: "session.menu",
|
|
676
|
+
data: {
|
|
677
|
+
menu: {
|
|
678
|
+
question: "How should the run proceed?",
|
|
679
|
+
options: [
|
|
680
|
+
{ number: 1, label: "Proceed to Phase 4", description: "nothing reaches a real LLO" },
|
|
681
|
+
{ number: 2, label: "Stop the run here", description: "end at the Phase 3 boundary" },
|
|
682
|
+
],
|
|
683
|
+
},
|
|
684
|
+
},
|
|
685
|
+
});
|
|
686
|
+
expect(state.menu?.options[0].description).toContain("real LLO");
|
|
687
|
+
});
|
|
688
|
+
});
|
|
689
|
+
|
|
690
|
+
describe("session.stop — whether the stop actually landed", () => {
|
|
691
|
+
const base = (over: Partial<SessionState> = {}): SessionState => ({
|
|
692
|
+
messages: [],
|
|
693
|
+
active_draft: null,
|
|
694
|
+
participants: [],
|
|
695
|
+
presence_user_ids: [],
|
|
696
|
+
current_user_id: 1,
|
|
697
|
+
...over,
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
it("records a requested stop without claiming it worked", () => {
|
|
701
|
+
const out = sessionReducer(base(), {
|
|
702
|
+
event: "session.stop",
|
|
703
|
+
data: { state: "requested" },
|
|
704
|
+
} as WsEvent);
|
|
705
|
+
expect(out.stopState).toBe("requested");
|
|
706
|
+
});
|
|
707
|
+
|
|
708
|
+
it("keeps the agent WORKING when the stop failed", () => {
|
|
709
|
+
// The whole point of a separate axis. A failed stop leaves the agent running,
|
|
710
|
+
// and both facts have to survive together — collapsing them into `activity`
|
|
711
|
+
// loses whichever one loses the race, and it was always the stop.
|
|
712
|
+
const out = sessionReducer(base({ activity: "working" }), {
|
|
713
|
+
event: "session.stop",
|
|
714
|
+
data: { state: "failed" },
|
|
715
|
+
} as WsEvent);
|
|
716
|
+
expect(out.stopState).toBe("failed");
|
|
717
|
+
expect(out.activity).toBe("working");
|
|
718
|
+
});
|
|
719
|
+
|
|
720
|
+
it("a new turn clears a stale stop outcome", () => {
|
|
721
|
+
// "your stop did not take" pinned over fresh work is a warning about
|
|
722
|
+
// something the human has already moved on from.
|
|
723
|
+
const out = sessionReducer(base({ stopState: "failed" }), {
|
|
724
|
+
event: "chat.stream_start",
|
|
725
|
+
data: { message_id: "m1" },
|
|
726
|
+
} as WsEvent);
|
|
727
|
+
expect(out.stopState).toBeUndefined();
|
|
728
|
+
});
|
|
729
|
+
});
|
|
@@ -28,8 +28,17 @@ const UNBLOCKING_FRAMES = new Set([
|
|
|
28
28
|
]);
|
|
29
29
|
|
|
30
30
|
export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState {
|
|
31
|
+
if (frame.event === "chat.stream_start" || frame.event === "draft.committed") {
|
|
32
|
+
// A new turn is starting, so the previous turn's stop outcome is history —
|
|
33
|
+
// leaving "your stop did not take" pinned over fresh work would be a stale
|
|
34
|
+
// warning about something the human has already moved on from.
|
|
35
|
+
prev = { ...prev, stopState: undefined };
|
|
36
|
+
}
|
|
31
37
|
if (prev.activity === "blocked" && UNBLOCKING_FRAMES.has(frame.event)) {
|
|
32
|
-
|
|
38
|
+
// Dropping the menu with the state matters as much as the state itself —
|
|
39
|
+
// the dialog is gone, and buttons that answer a gone dialog send a stray
|
|
40
|
+
// keystroke into the prompt.
|
|
41
|
+
prev = { ...prev, activity: "working", menu: undefined };
|
|
33
42
|
}
|
|
34
43
|
switch (frame.event) {
|
|
35
44
|
case "session.state":
|
|
@@ -68,7 +77,33 @@ export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState
|
|
|
68
77
|
}
|
|
69
78
|
|
|
70
79
|
case "session.activity":
|
|
71
|
-
|
|
80
|
+
// A frame that says the agent is NOT waiting retracts the menu — a stale
|
|
81
|
+
// dialog is worse than none, because its buttons would answer a prompt
|
|
82
|
+
// that is no longer on screen.
|
|
83
|
+
//
|
|
84
|
+
// A `blocked` frame that carries no menu does NOT, and that asymmetry is
|
|
85
|
+
// load-bearing. The hook path reports `blocked` without one on purpose
|
|
86
|
+
// (#510 — reading the screen stole emdash's focus), so treating a bare
|
|
87
|
+
// `blocked` as a retraction would erase every menu the snapshot and the
|
|
88
|
+
// session report supply, which is now all of them.
|
|
89
|
+
if (frame.data.state !== "blocked") {
|
|
90
|
+
return { ...prev, activity: frame.data.state, menu: undefined };
|
|
91
|
+
}
|
|
92
|
+
return { ...prev, activity: "blocked", menu: frame.data.menu ?? prev.menu };
|
|
93
|
+
|
|
94
|
+
case "session.stop":
|
|
95
|
+
// Independent of `activity` on purpose. A stop that FAILED leaves the agent
|
|
96
|
+
// working, and both facts have to be sayable at once: "it is still going"
|
|
97
|
+
// AND "your stop did not take". Collapsing them loses whichever one loses
|
|
98
|
+
// the race, and it was always the second — which is how a dead Stop button
|
|
99
|
+
// stayed invisible.
|
|
100
|
+
return { ...prev, stopState: frame.data.state };
|
|
101
|
+
|
|
102
|
+
case "session.menu":
|
|
103
|
+
// The authoritative producer: the session report re-derives the dialog
|
|
104
|
+
// from the transcript every ~10s and pushes only the edges. `null` is the
|
|
105
|
+
// retraction, and has to be honoured — somebody answered at the keyboard.
|
|
106
|
+
return { ...prev, menu: frame.data.menu ?? undefined };
|
|
72
107
|
|
|
73
108
|
case "chat.user_message": {
|
|
74
109
|
// Someone typed into emdash, OR into this page. Both reach here, and that
|
|
@@ -335,7 +370,23 @@ export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState
|
|
|
335
370
|
case "presence.joined": {
|
|
336
371
|
const ids = new Set(prev.presence_user_ids);
|
|
337
372
|
ids.add(frame.data.user_id);
|
|
338
|
-
|
|
373
|
+
// Adopt the joiner into `participants` too. The presence ROW renders
|
|
374
|
+
// participants filtered by presence, so an id with no matching
|
|
375
|
+
// participant is invisible — which is what made a first-time joiner
|
|
376
|
+
// unseeable by everyone already in the room until they reloaded.
|
|
377
|
+
// `participant` is optional (an older server may not send it); without
|
|
378
|
+
// it this degrades to exactly the previous behaviour rather than
|
|
379
|
+
// inventing a nameless entry.
|
|
380
|
+
const joined = frame.data.participant;
|
|
381
|
+
const known = joined
|
|
382
|
+
? prev.participants.some((p) => p.user_id === joined.user_id)
|
|
383
|
+
: true;
|
|
384
|
+
return {
|
|
385
|
+
...prev,
|
|
386
|
+
presence_user_ids: [...ids],
|
|
387
|
+
participants:
|
|
388
|
+
joined && !known ? [...prev.participants, joined] : prev.participants,
|
|
389
|
+
};
|
|
339
390
|
}
|
|
340
391
|
|
|
341
392
|
case "presence.left":
|
|
@@ -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
|
+
}
|