canopy-ui 0.6.2 → 0.6.3

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canopy-ui",
3
- "version": "0.6.2",
3
+ "version": "0.6.3",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -12,6 +12,7 @@
12
12
  "./lib": "./src/lib/index.ts",
13
13
  "./ui": "./src/ui/index.ts",
14
14
  "./chat": "./src/chat/index.ts",
15
+ "./presence": "./src/presence/index.ts",
15
16
  "./shell": "./src/shell/index.ts",
16
17
  "./tokens": "./src/tokens/index.ts",
17
18
  "./tokens/preset.css": "./src/tokens/preset.css",
@@ -42,5 +43,7 @@
42
43
  "devDependencies": {
43
44
  "sonner": "^2.0.7"
44
45
  },
45
- "files": ["src"]
46
+ "files": [
47
+ "src"
48
+ ]
46
49
  }
@@ -0,0 +1,58 @@
1
+ import type { SessionMenu } from "./protocol";
2
+
3
+ export interface MenuPromptProps {
4
+ menu: SessionMenu;
5
+ busy?: boolean;
6
+ error?: string;
7
+ onAnswer: (option: number | null) => void;
8
+ }
9
+
10
+ /**
11
+ * The dialog a blocked agent is waiting on, answerable from here.
12
+ *
13
+ * Shows the SUBJECT, not just the question: "Do you want to proceed?" tells you
14
+ * nothing away from the keyboard — the command is the whole decision, so it is
15
+ * rendered verbatim and monospaced rather than summarised.
16
+ *
17
+ * Refusing is always offered separately from the numbered options. Every dialog
18
+ * accepts Escape, and it is the only answer that stays correct if the dialog on
19
+ * screen is not the one rendered here.
20
+ */
21
+ export function MenuPrompt({ menu, busy = false, error, onAnswer }: MenuPromptProps) {
22
+ return (
23
+ <div className="rounded-md border border-warning/30 bg-warning/10 px-3 py-2.5 text-sm">
24
+ <div className="flex items-center gap-1.5 font-medium text-warning">
25
+ <span className="inline-block h-1.5 w-1.5 animate-pulse rounded-full bg-warning" />
26
+ {menu.title || "Waiting on you"}
27
+ </div>
28
+ {menu.body ? (
29
+ <pre className="mt-1.5 max-h-32 overflow-auto whitespace-pre-wrap break-all rounded bg-muted px-2 py-1.5 font-mono text-[12px] text-foreground-secondary">
30
+ {menu.body}
31
+ </pre>
32
+ ) : null}
33
+ <div className="mt-2 text-foreground">{menu.question}</div>
34
+ <div className="mt-2 flex flex-wrap gap-1.5">
35
+ {menu.options.map((option) => (
36
+ <button
37
+ key={option.number}
38
+ type="button"
39
+ disabled={busy}
40
+ onClick={() => onAnswer(option.number)}
41
+ className="rounded border border-input bg-card px-2 py-1 text-[13px] text-foreground hover:bg-muted disabled:opacity-50"
42
+ >
43
+ {option.label}
44
+ </button>
45
+ ))}
46
+ <button
47
+ type="button"
48
+ disabled={busy}
49
+ onClick={() => onAnswer(null)}
50
+ className="rounded px-2 py-1 text-[13px] text-muted-foreground hover:text-foreground disabled:opacity-50"
51
+ >
52
+ Cancel (Esc)
53
+ </button>
54
+ </div>
55
+ {error ? <div className="mt-1.5 text-[12px] text-destructive">{error}</div> : null}
56
+ </div>
57
+ );
58
+ }
package/src/chat/index.ts CHANGED
@@ -11,6 +11,7 @@ export type {
11
11
  MessageStatus,
12
12
  Draft,
13
13
  Participant,
14
+ SessionMenu,
14
15
  SessionState,
15
16
  WsAction,
16
17
  WsEvent,
@@ -43,6 +44,7 @@ export {
43
44
 
44
45
  // Presentational components
45
46
  export { ChatPanel, type ChatPanelProps } from "./ChatPanel";
47
+ export { MenuPrompt, type MenuPromptProps } from "./MenuPrompt";
46
48
  export { MessageList } from "./MessageList";
47
49
  export { MessageItem, type RenderMarkdown } from "./MessageItem";
48
50
  export { ToolCallPair } from "./ToolCallPair";
@@ -60,6 +60,18 @@ export interface Participant {
60
60
  last_seen_at: string | null;
61
61
  }
62
62
 
63
+ /** A dialog an agent is blocked on, read off its terminal.
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
+ export interface SessionMenu {
68
+ question: string;
69
+ title?: string;
70
+ body?: string;
71
+ selected?: number | null;
72
+ options: { number: number; label: string }[];
73
+ }
74
+
63
75
  export interface SessionState {
64
76
  messages: Message[];
65
77
  /** Live agent activity, from the runner's turn-boundary hooks. Undefined when
@@ -71,6 +83,10 @@ export interface SessionState {
71
83
  * those apart — but it is the difference between "still thinking, wait" and
72
84
  * "it is waiting on YOU", which previously rendered identically. */
73
85
  activity?: "working" | "idle" | "blocked";
86
+ /** The dialog the agent is waiting on, when one could be read off its screen.
87
+ * Absent when the agent is not blocked, or when the runner has no way to look
88
+ * (no CDP) — "blocked" still arrives, it just has no buttons. */
89
+ menu?: SessionMenu;
74
90
  active_draft: Draft | null;
75
91
  participants: Participant[];
76
92
  presence_user_ids: number[];
@@ -96,7 +112,7 @@ export type WsEvent =
96
112
  | { event: "chat.stream_start"; data: { message_id: string; turn_index: number } }
97
113
  // The agent started or finished a turn. Distinct from tool events: it fires
98
114
  // while Claude is THINKING, before any content exists to show.
99
- | { event: "session.activity"; data: { state: "working" | "idle" | "blocked" } }
115
+ | { event: "session.activity"; data: { state: "working" | "idle" | "blocked"; menu?: SessionMenu } }
100
116
  // A human typed into emdash rather than into this page. No client echoed it,
101
117
  // so this is the only way it reaches the browser before a reload.
102
118
  | { event: "chat.user_message"; data: { message_id: string; turn_index: number; plaintext: string } }
@@ -584,3 +584,45 @@ 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 has no menu", () => {
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
+ });
@@ -29,7 +29,10 @@ const UNBLOCKING_FRAMES = new Set([
29
29
 
30
30
  export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState {
31
31
  if (prev.activity === "blocked" && UNBLOCKING_FRAMES.has(frame.event)) {
32
- prev = { ...prev, activity: "working" };
32
+ // Dropping the menu with the state matters as much as the state itself —
33
+ // the dialog is gone, and buttons that answer a gone dialog send a stray
34
+ // keystroke into the prompt.
35
+ prev = { ...prev, activity: "working", menu: undefined };
33
36
  }
34
37
  switch (frame.event) {
35
38
  case "session.state":
@@ -68,7 +71,10 @@ export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState
68
71
  }
69
72
 
70
73
  case "session.activity":
71
- return { ...prev, activity: frame.data.state };
74
+ // The menu is cleared unless this frame carries one: a stale dialog is
75
+ // worse than none, because its buttons would answer a prompt that is no
76
+ // longer on screen.
77
+ return { ...prev, activity: frame.data.state, menu: frame.data.menu };
72
78
 
73
79
  case "chat.user_message": {
74
80
  // Someone typed into emdash, OR into this page. Both reach here, and that
@@ -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,4 @@
1
+ export { PresenceBadge } from './PresenceBadge'
2
+ export { usePresence, type Viewer, type UsePresenceOptions } from './usePresence'
3
+ export { pageKeyFor, type PageLocation, type RouteRule } from './pageKey'
4
+ export { avatarFor } from './avatar'
@@ -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
+ }
@@ -0,0 +1,199 @@
1
+ // @vitest-environment jsdom
2
+ import { act, renderHook } from '@testing-library/react'
3
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
4
+ import { usePresence } from './usePresence'
5
+
6
+ class FakeSocket {
7
+ static last: FakeSocket | null = null
8
+ static created = 0
9
+ static OPEN = 1
10
+ readyState = 0
11
+ sent: string[] = []
12
+ onopen: (() => void) | null = null
13
+ onmessage: ((e: { data: string }) => void) | null = null
14
+ onclose: (() => void) | null = null
15
+ constructor(public url: string) {
16
+ FakeSocket.last = this
17
+ FakeSocket.created += 1
18
+ }
19
+ send(frame: string) {
20
+ this.sent.push(frame)
21
+ }
22
+ close() {
23
+ this.readyState = 3
24
+ }
25
+ open() {
26
+ this.readyState = FakeSocket.OPEN
27
+ this.onopen?.()
28
+ }
29
+ deliver(payload: unknown) {
30
+ this.onmessage?.({ data: JSON.stringify(payload) })
31
+ }
32
+ }
33
+
34
+ beforeEach(() => {
35
+ vi.stubGlobal('WebSocket', FakeSocket as unknown as typeof WebSocket)
36
+ vi.useFakeTimers()
37
+ FakeSocket.created = 0
38
+ })
39
+ afterEach(() => {
40
+ vi.useRealTimers()
41
+ vi.unstubAllGlobals()
42
+ FakeSocket.last = null
43
+ })
44
+
45
+ const LOC = { pageKey: 'ace:ws:opp:a/run-001', subLocation: 'run overview' }
46
+
47
+ describe('usePresence', () => {
48
+ it('sends presence.enter once the socket opens', () => {
49
+ renderHook(() => usePresence({ url: 'ws://x/ws/presence/', location: LOC }))
50
+ act(() => FakeSocket.last!.open())
51
+ expect(JSON.parse(FakeSocket.last!.sent[0])).toEqual({
52
+ type: 'presence.enter',
53
+ page_key: LOC.pageKey,
54
+ sub_location: LOC.subLocation,
55
+ })
56
+ })
57
+
58
+ it('exposes the roster the server broadcasts', () => {
59
+ const { result } = renderHook(() => usePresence({ url: 'ws://x/ws/presence/', location: LOC }))
60
+ act(() => FakeSocket.last!.open())
61
+ act(() =>
62
+ FakeSocket.last!.deliver({
63
+ event: 'presence.roster',
64
+ data: {
65
+ page_key: LOC.pageKey,
66
+ viewers: [{ email: 'a@x.com', name: 'A', sub_location: 'idea-to-pdd', idle: false, self: true }],
67
+ },
68
+ }),
69
+ )
70
+ expect(result.current.viewers).toEqual([
71
+ { email: 'a@x.com', name: 'A', subLocation: 'idea-to-pdd', idle: false, self: true },
72
+ ])
73
+ })
74
+
75
+ it('ignores a roster for a page key it is no longer on', () => {
76
+ const { result } = renderHook(() => usePresence({ url: 'ws://x/ws/presence/', location: LOC }))
77
+ act(() => FakeSocket.last!.open())
78
+ act(() =>
79
+ FakeSocket.last!.deliver({
80
+ event: 'presence.roster',
81
+ data: { page_key: 'ace:ws:opp:STALE/run-999', viewers: [
82
+ { email: 'z@x.com', name: 'Z', sub_location: '', idle: false, self: false }] },
83
+ }),
84
+ )
85
+ expect(result.current.viewers).toEqual([])
86
+ })
87
+
88
+ it('re-enters without reconnecting when the location changes', () => {
89
+ const { rerender } = renderHook(
90
+ ({ location }) => usePresence({ url: 'ws://x/ws/presence/', location }),
91
+ { initialProps: { location: LOC } },
92
+ )
93
+ act(() => FakeSocket.last!.open())
94
+ const socket = FakeSocket.last!
95
+ rerender({ location: { pageKey: 'ace:ws:activity', subLocation: 'Activity' } })
96
+ expect(FakeSocket.last).toBe(socket) // same socket, no reconnect
97
+ expect(JSON.parse(socket.sent[socket.sent.length - 1])).toEqual({
98
+ type: 'presence.enter',
99
+ page_key: 'ace:ws:activity',
100
+ sub_location: 'Activity',
101
+ })
102
+ })
103
+
104
+ it('heartbeats every 20 seconds', () => {
105
+ renderHook(() => usePresence({ url: 'ws://x/ws/presence/', location: LOC }))
106
+ act(() => FakeSocket.last!.open())
107
+ act(() => void vi.advanceTimersByTime(20_000))
108
+ expect(JSON.parse(FakeSocket.last!.sent[1])).toEqual({ type: 'presence.heartbeat', idle: false })
109
+ })
110
+
111
+ it('clears the roster and sends nothing when there is no location', () => {
112
+ const { result } = renderHook(() =>
113
+ usePresence({ url: 'ws://x/ws/presence/', location: null }),
114
+ )
115
+ expect(result.current.viewers).toEqual([])
116
+ expect(FakeSocket.last).toBeNull()
117
+ })
118
+
119
+ it('backs off exponentially while reconnects keep failing, up to a ceiling', () => {
120
+ // A tab left open past session expiry is closed with 4001 on every
121
+ // attempt. A flat retry hammers a handshake every 2s forever; the delay
122
+ // must grow and then cap.
123
+ renderHook(() => usePresence({ url: 'ws://x/ws/presence/', location: LOC }))
124
+ expect(FakeSocket.created).toBe(1)
125
+
126
+ const failOnce = (expectedDelay: number) => {
127
+ const before = FakeSocket.created
128
+ act(() => FakeSocket.last!.onclose!())
129
+ // Nothing reconnects a millisecond early...
130
+ act(() => void vi.advanceTimersByTime(expectedDelay - 1))
131
+ expect(FakeSocket.created).toBe(before)
132
+ // ...and exactly one reconnect lands on the deadline.
133
+ act(() => void vi.advanceTimersByTime(1))
134
+ expect(FakeSocket.created).toBe(before + 1)
135
+ }
136
+
137
+ failOnce(2_000)
138
+ failOnce(4_000)
139
+ failOnce(8_000)
140
+ failOnce(16_000)
141
+ failOnce(32_000)
142
+ failOnce(60_000) // ceiling, not 64_000
143
+ failOnce(60_000) // and it stays there
144
+ })
145
+
146
+ it('resets the backoff once a socket actually opens', () => {
147
+ renderHook(() => usePresence({ url: 'ws://x/ws/presence/', location: LOC }))
148
+ act(() => FakeSocket.last!.onclose!())
149
+ act(() => void vi.advanceTimersByTime(2_000))
150
+ act(() => FakeSocket.last!.onclose!())
151
+ act(() => void vi.advanceTimersByTime(4_000)) // now at a 4s delay
152
+
153
+ act(() => FakeSocket.last!.open()) // a good connection clears the streak
154
+
155
+ const before = FakeSocket.created
156
+ act(() => FakeSocket.last!.onclose!())
157
+ act(() => void vi.advanceTimersByTime(2_000))
158
+ expect(FakeSocket.created).toBe(before + 1)
159
+ })
160
+
161
+ it('clears the previous page\'s viewers on navigation even when the socket is not open', () => {
162
+ // Otherwise the old page's avatars linger on the new page until a roster
163
+ // for it arrives — which, on a dead socket, may be never.
164
+ const { result, rerender } = renderHook(
165
+ ({ location }) => usePresence({ url: 'ws://x/ws/presence/', location }),
166
+ { initialProps: { location: LOC } },
167
+ )
168
+ act(() => FakeSocket.last!.open())
169
+ act(() =>
170
+ FakeSocket.last!.deliver({
171
+ event: 'presence.roster',
172
+ data: {
173
+ page_key: LOC.pageKey,
174
+ viewers: [{ email: 'a@x.com', name: 'A', sub_location: '', idle: false, self: false }],
175
+ },
176
+ }),
177
+ )
178
+ expect(result.current.viewers).toHaveLength(1)
179
+
180
+ FakeSocket.last!.readyState = 3 // socket dropped, reconnect pending
181
+ rerender({ location: { pageKey: 'ace:ws:activity', subLocation: 'Activity' } })
182
+ expect(result.current.viewers).toEqual([])
183
+ })
184
+
185
+ it('swallows a synchronous WebSocket constructor throw and stays on an empty roster', () => {
186
+ class ThrowingSocket {
187
+ constructor() {
188
+ throw new Error('mixed content: refused to connect to insecure WebSocket')
189
+ }
190
+ }
191
+ vi.stubGlobal('WebSocket', ThrowingSocket as unknown as typeof WebSocket)
192
+
193
+ let hook!: ReturnType<typeof renderHook<{ viewers: unknown[] }, void>>
194
+ expect(() => {
195
+ hook = renderHook(() => usePresence({ url: 'ws://x/ws/presence/', location: LOC }))
196
+ }).not.toThrow()
197
+ expect(hook.result.current.viewers).toEqual([])
198
+ })
199
+ })
@@ -0,0 +1,188 @@
1
+ import { useEffect, useRef, useState } from 'react'
2
+ import type { PageLocation } from './pageKey'
3
+
4
+ export interface Viewer {
5
+ email: string
6
+ name: string
7
+ subLocation: string
8
+ idle: boolean
9
+ self: boolean
10
+ }
11
+
12
+ export interface UsePresenceOptions {
13
+ url: string
14
+ location: PageLocation | null
15
+ }
16
+
17
+ const HEARTBEAT_MS = 20_000
18
+ const IDLE_AFTER_MS = 120_000
19
+ // Reconnect backoff. A flat retry is wrong for the permanent-failure case:
20
+ // a tab left open past session expiry is closed with 4001 on every attempt,
21
+ // so a flat 2s retry hammers a handshake forever. Double up to a ceiling and
22
+ // reset on a socket that actually opens.
23
+ const RECONNECT_BASE_MS = 2_000
24
+ const RECONNECT_MAX_MS = 60_000
25
+
26
+ /**
27
+ * One presence socket per tab.
28
+ *
29
+ * Navigation re-keys the existing connection with a fresh `presence.enter`
30
+ * rather than reconnecting — a socket per page would churn handshakes on
31
+ * every click.
32
+ *
33
+ * Every failure path degrades to an empty roster. Presence is an
34
+ * enhancement; it must never surface an error to the user.
35
+ */
36
+ export function usePresence({ url, location }: UsePresenceOptions): { viewers: Viewer[] } {
37
+ const [viewers, setViewers] = useState<Viewer[]>([])
38
+ const wsRef = useRef<WebSocket | null>(null)
39
+ const locationRef = useRef(location)
40
+ const idleRef = useRef(false)
41
+ locationRef.current = location
42
+
43
+ // Socket lifecycle. Deliberately NOT keyed on `location` — the socket
44
+ // outlives navigation.
45
+ useEffect(() => {
46
+ if (!location) {
47
+ setViewers([])
48
+ return
49
+ }
50
+ let closedByCleanup = false
51
+ let reconnectTimer: ReturnType<typeof setTimeout> | null = null
52
+ let heartbeat: ReturnType<typeof setInterval> | null = null
53
+ let reconnectDelay = RECONNECT_BASE_MS
54
+
55
+ const scheduleReconnect = () => {
56
+ if (closedByCleanup) return
57
+ reconnectTimer = setTimeout(open, reconnectDelay)
58
+ reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX_MS)
59
+ }
60
+
61
+ const send = (frame: unknown) => {
62
+ const ws = wsRef.current
63
+ if (ws && ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify(frame))
64
+ }
65
+
66
+ const enter = () => {
67
+ const loc = locationRef.current
68
+ if (loc) send({ type: 'presence.enter', page_key: loc.pageKey, sub_location: loc.subLocation })
69
+ }
70
+
71
+ function open() {
72
+ let sock: WebSocket
73
+ try {
74
+ sock = new WebSocket(url)
75
+ } catch {
76
+ // Synchronous construction failure (malformed URL, mixed-content
77
+ // scheme mismatch, …): treat exactly like any other connection
78
+ // failure — stay on the empty roster and retry on the normal
79
+ // reconnect cadence, never propagate.
80
+ setViewers([])
81
+ scheduleReconnect()
82
+ return
83
+ }
84
+ wsRef.current = sock
85
+ sock.onopen = () => {
86
+ // A socket that actually opened means the failure that got us here
87
+ // is over; start the next backoff series from the bottom.
88
+ reconnectDelay = RECONNECT_BASE_MS
89
+ enter()
90
+ heartbeat = setInterval(
91
+ () => send({ type: 'presence.heartbeat', idle: idleRef.current }),
92
+ HEARTBEAT_MS,
93
+ )
94
+ }
95
+ sock.onmessage = (e) => {
96
+ try {
97
+ const msg = JSON.parse(e.data)
98
+ if (msg.event !== 'presence.roster') return
99
+ // Drop rosters for a page we have already navigated away from —
100
+ // an in-flight broadcast can land after the re-key.
101
+ if (msg.data?.page_key !== locationRef.current?.pageKey) return
102
+ setViewers(
103
+ (msg.data.viewers ?? []).map((v: Record<string, unknown>) => ({
104
+ email: String(v.email ?? ''),
105
+ name: String(v.name ?? ''),
106
+ subLocation: String(v.sub_location ?? ''),
107
+ idle: Boolean(v.idle),
108
+ self: Boolean(v.self),
109
+ })),
110
+ )
111
+ } catch {
112
+ // Malformed frame: ignore. Never surface.
113
+ }
114
+ }
115
+ sock.onclose = () => {
116
+ if (wsRef.current === sock) wsRef.current = null
117
+ if (heartbeat) clearInterval(heartbeat)
118
+ setViewers([])
119
+ scheduleReconnect()
120
+ }
121
+ }
122
+ open()
123
+
124
+ return () => {
125
+ closedByCleanup = true
126
+ if (reconnectTimer) clearTimeout(reconnectTimer)
127
+ if (heartbeat) clearInterval(heartbeat)
128
+ wsRef.current?.close()
129
+ wsRef.current = null
130
+ }
131
+ // eslint-disable-next-line react-hooks/exhaustive-deps
132
+ }, [url, location === null])
133
+
134
+ // Re-key on navigation. Deps are the primitive fields, not `location`
135
+ // itself — callers (e.g. pageKeyFor) may return a fresh object each
136
+ // render, and keying on identity would re-send presence.enter on every
137
+ // render instead of only on an actual location change.
138
+ useEffect(() => {
139
+ // Clear FIRST, unconditionally. If the socket is not open yet the old
140
+ // page's avatars are already wrong — leaving them up until a roster for
141
+ // the new page arrives (which, on a dead socket, may be never) shows
142
+ // strangers as viewing the page you just navigated to.
143
+ setViewers([])
144
+ const ws = wsRef.current
145
+ if (!location || !ws || ws.readyState !== WebSocket.OPEN) return
146
+ ws.send(
147
+ JSON.stringify({
148
+ type: 'presence.enter',
149
+ page_key: location.pageKey,
150
+ sub_location: location.subLocation,
151
+ }),
152
+ )
153
+ // eslint-disable-next-line react-hooks/exhaustive-deps
154
+ }, [location?.pageKey, location?.subLocation])
155
+
156
+ // Idle tracking: hidden for longer than IDLE_AFTER_MS. Reports an
157
+ // observable fact (the tab is not frontmost), never an attention claim.
158
+ useEffect(() => {
159
+ let timer: ReturnType<typeof setTimeout> | null = null
160
+ const flush = () => {
161
+ const ws = wsRef.current
162
+ if (ws && ws.readyState === WebSocket.OPEN) {
163
+ ws.send(JSON.stringify({ type: 'presence.heartbeat', idle: idleRef.current }))
164
+ }
165
+ }
166
+ const onVisibility = () => {
167
+ if (document.hidden) {
168
+ timer = setTimeout(() => {
169
+ idleRef.current = true
170
+ flush()
171
+ }, IDLE_AFTER_MS)
172
+ } else {
173
+ if (timer) clearTimeout(timer)
174
+ if (idleRef.current) {
175
+ idleRef.current = false
176
+ flush()
177
+ }
178
+ }
179
+ }
180
+ document.addEventListener('visibilitychange', onVisibility)
181
+ return () => {
182
+ if (timer) clearTimeout(timer)
183
+ document.removeEventListener('visibilitychange', onVisibility)
184
+ }
185
+ }, [])
186
+
187
+ return { viewers }
188
+ }