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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "canopy-ui",
3
- "version": "0.10.0",
3
+ "version": "0.11.0",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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,