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
|
@@ -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
|
+
}
|