canopy-ui 0.10.0 → 0.11.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
CHANGED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { act, renderHook } from '@testing-library/react'
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import { useSessionSocket } from './useSessionSocket'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* A read-only principal — a canopy CONTACT — chats through the same hook: the
|
|
9
|
+
* message goes out over HTTP, the draft stays local, and nothing is written to
|
|
10
|
+
* the socket (which would refuse it).
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
class FakeSocket {
|
|
14
|
+
static OPEN = 1
|
|
15
|
+
static sent: string[] = []
|
|
16
|
+
onopen: (() => void) | null = null
|
|
17
|
+
onmessage: ((e: { data: string }) => void) | null = null
|
|
18
|
+
onclose: (() => void) | null = null
|
|
19
|
+
onerror: (() => void) | null = null
|
|
20
|
+
readyState = 1
|
|
21
|
+
constructor(public url: string) {}
|
|
22
|
+
send(data: string) {
|
|
23
|
+
FakeSocket.sent.push(data)
|
|
24
|
+
}
|
|
25
|
+
close() {}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
FakeSocket.sent = []
|
|
30
|
+
vi.stubGlobal('WebSocket', FakeSocket)
|
|
31
|
+
})
|
|
32
|
+
afterEach(() => vi.unstubAllGlobals())
|
|
33
|
+
|
|
34
|
+
function contact(sendOverHttp = vi.fn().mockResolvedValue({})) {
|
|
35
|
+
const hook = renderHook(() =>
|
|
36
|
+
useSessionSocket({ sessionId: 's1', wsUrl: () => 'wss://h/x', sendOverHttp }),
|
|
37
|
+
)
|
|
38
|
+
return { hook, sendOverHttp }
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
describe('sending over HTTP', () => {
|
|
42
|
+
it('types into a local draft and sends it over HTTP, never the socket', async () => {
|
|
43
|
+
const { hook, sendOverHttp } = contact()
|
|
44
|
+
act(() => hook.result.current.updateDraft('hello ace'))
|
|
45
|
+
expect(hook.result.current.state.active_draft?.body).toBe('hello ace')
|
|
46
|
+
await act(async () => hook.result.current.sendChat())
|
|
47
|
+
expect(sendOverHttp).toHaveBeenCalledWith('hello ace')
|
|
48
|
+
expect(FakeSocket.sent.filter((f) => f.includes('chat.send') || f.includes('draft.update'))).toEqual([])
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('shows the line and waits for the reply, as a user send does', async () => {
|
|
52
|
+
const { hook } = contact()
|
|
53
|
+
act(() => hook.result.current.updateDraft('are we on track?'))
|
|
54
|
+
await act(async () => hook.result.current.sendChat())
|
|
55
|
+
expect(hook.result.current.awaitingReply).toBe(true)
|
|
56
|
+
expect(hook.result.current.state.messages.at(-1)?.plaintext).toBe('are we on track?')
|
|
57
|
+
expect(hook.result.current.state.active_draft).toBeNull()
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('a failed send puts the words back and says why', async () => {
|
|
61
|
+
const { hook } = contact(vi.fn().mockRejectedValue(new Error('canopy said 429')))
|
|
62
|
+
act(() => hook.result.current.updateDraft('keep me'))
|
|
63
|
+
await act(async () => {
|
|
64
|
+
hook.result.current.sendChat()
|
|
65
|
+
await Promise.resolve()
|
|
66
|
+
})
|
|
67
|
+
expect(hook.result.current.state.active_draft?.body).toBe('keep me')
|
|
68
|
+
expect(hook.result.current.lastError).toBe('canopy said 429')
|
|
69
|
+
expect(hook.result.current.awaitingReply).toBe(false)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('an empty draft sends nothing', async () => {
|
|
73
|
+
const { hook, sendOverHttp } = contact()
|
|
74
|
+
act(() => hook.result.current.updateDraft(' '))
|
|
75
|
+
await act(async () => hook.result.current.sendChat())
|
|
76
|
+
expect(sendOverHttp).not.toHaveBeenCalled()
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('without it, a user sends over the socket exactly as before', async () => {
|
|
80
|
+
const hook = renderHook(() => useSessionSocket({ sessionId: 's1', wsUrl: () => 'wss://h/x' }))
|
|
81
|
+
await act(async () => hook.result.current.sendChat())
|
|
82
|
+
expect(FakeSocket.sent.some((f) => f.includes('chat.send'))).toBe(true)
|
|
83
|
+
})
|
|
84
|
+
})
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
-
import { useCallback, useEffect, useRef, useState } from "react";
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
2
|
|
|
3
3
|
import { fromAgui, resetAguiState } from "./agui";
|
|
4
|
-
import type { Message, SessionState, WsEvent } from "./protocol";
|
|
4
|
+
import type { Draft, Message, SessionState, WsEvent } from "./protocol";
|
|
5
5
|
import { shouldSyncDraftLive } from "./drafts";
|
|
6
6
|
import { prependHistory } from "./history";
|
|
7
7
|
import { sessionReducer } from "./sessionReducer";
|
|
@@ -56,6 +56,18 @@ export interface UseSessionSocketOptions {
|
|
|
56
56
|
* that against a fixture the server generates).
|
|
57
57
|
*/
|
|
58
58
|
protocol?: "canopy" | "ag-ui";
|
|
59
|
+
/**
|
|
60
|
+
* For a principal whose socket is READ-ONLY — a canopy CONTACT, someone a
|
|
61
|
+
* site vouched for who has no canopy account. They can watch the conversation
|
|
62
|
+
* over the socket but not write to it, so their message goes out over HTTP
|
|
63
|
+
* (canopy-client's `rest.send`, which routes to `/api/contact/…`) and their
|
|
64
|
+
* draft stays local: there is no shared draft to co-edit with nobody else.
|
|
65
|
+
*
|
|
66
|
+
* Everything else is unchanged — the same `state`, the same pending row, the
|
|
67
|
+
* same "waiting for a reply" — so one chat UI serves a user and a contact
|
|
68
|
+
* without a second send path in every host. Omit for a user (the default).
|
|
69
|
+
*/
|
|
70
|
+
sendOverHttp?: (text: string) => Promise<unknown>;
|
|
59
71
|
}
|
|
60
72
|
|
|
61
73
|
/**
|
|
@@ -132,7 +144,10 @@ export function useSessionSocket({
|
|
|
132
144
|
onTitleUpdated,
|
|
133
145
|
onUnknownEvent,
|
|
134
146
|
protocol = "canopy",
|
|
147
|
+
sendOverHttp,
|
|
135
148
|
}: UseSessionSocketOptions): UseSessionSocketResult {
|
|
149
|
+
// The local draft a read-only principal types into (see `sendOverHttp`).
|
|
150
|
+
const [localDraft, setLocalDraft] = useState<Draft | null>(null);
|
|
136
151
|
const [state, setState] = useState<SessionState>(INITIAL_STATE);
|
|
137
152
|
const [connected, setConnected] = useState(false);
|
|
138
153
|
const [lastError, setLastError] = useState<string | null>(null);
|
|
@@ -442,6 +457,15 @@ export function useSessionSocket({
|
|
|
442
457
|
});
|
|
443
458
|
}, []);
|
|
444
459
|
|
|
460
|
+
// `sendOverHttp`'s half of the hook: typing is local, sending is HTTP. Defined
|
|
461
|
+
// here, after noteLocalSend, which it reuses for the pending row.
|
|
462
|
+
const updateLocalDraft = useCallback((body: string) => {
|
|
463
|
+
setLocalDraft({
|
|
464
|
+
id: "local", slot: "next", status: "open", body, version: 0, last_editor: 0,
|
|
465
|
+
last_edit_at: new Date().toISOString(),
|
|
466
|
+
});
|
|
467
|
+
}, []);
|
|
468
|
+
|
|
445
469
|
const noteLocalSend = useCallback((text: string) => {
|
|
446
470
|
setAwaitingReply(true);
|
|
447
471
|
const body = text.trim();
|
|
@@ -465,13 +489,31 @@ export function useSessionSocket({
|
|
|
465
489
|
);
|
|
466
490
|
}, []);
|
|
467
491
|
|
|
492
|
+
const sendChatOverHttp = useCallback(() => {
|
|
493
|
+
const body = (localDraft?.body ?? "").trim();
|
|
494
|
+
if (!body || !sendOverHttp) return;
|
|
495
|
+
noteLocalSend(body);
|
|
496
|
+
setLocalDraft(null);
|
|
497
|
+
sendOverHttp(body).catch((err: unknown) => {
|
|
498
|
+
setAwaitingReply(false);
|
|
499
|
+
setLastError(err instanceof Error ? err.message : "the message could not be sent");
|
|
500
|
+
// Put the words back, so a failed send never costs what was typed.
|
|
501
|
+
updateLocalDraft(body);
|
|
502
|
+
});
|
|
503
|
+
}, [localDraft, sendOverHttp, noteLocalSend, updateLocalDraft]);
|
|
504
|
+
|
|
505
|
+
const exposedState = useMemo(
|
|
506
|
+
() => (sendOverHttp ? { ...state, active_draft: localDraft } : state),
|
|
507
|
+
[sendOverHttp, state, localDraft],
|
|
508
|
+
);
|
|
509
|
+
|
|
468
510
|
return {
|
|
469
|
-
state,
|
|
511
|
+
state: exposedState,
|
|
470
512
|
connected,
|
|
471
513
|
awaitingReply,
|
|
472
|
-
sendChat,
|
|
514
|
+
sendChat: sendOverHttp ? sendChatOverHttp : sendChat,
|
|
473
515
|
stopChat,
|
|
474
|
-
updateDraft,
|
|
516
|
+
updateDraft: sendOverHttp ? updateLocalDraft : updateDraft,
|
|
475
517
|
takeOverDraft,
|
|
476
518
|
discardDraft,
|
|
477
519
|
prependMessages,
|