canopy-ui 0.2.0 → 0.4.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.
@@ -0,0 +1,269 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+
3
+ import type { Message, SessionState, WsEvent } from "./protocol";
4
+ import { prependHistory } from "./history";
5
+ import { sessionReducer } from "./sessionReducer";
6
+
7
+ const HEARTBEAT_INTERVAL_MS = 20_000;
8
+ const RECONNECT_DELAYS_MS = [1_000, 2_000, 5_000, 10_000];
9
+ const DRAFT_UPDATE_DEBOUNCE_MS = 150;
10
+
11
+ const INITIAL_STATE: SessionState = {
12
+ messages: [],
13
+ active_draft: null,
14
+ participants: [],
15
+ presence_user_ids: [],
16
+ current_user_id: 0,
17
+ };
18
+
19
+ export interface UseSessionSocketOptions {
20
+ /** The chat session id (UUID string). */
21
+ sessionId: string;
22
+ /**
23
+ * App-injected WebSocket URL builder. The kit never imports app routing/base
24
+ * helpers; the container passes one (e.g. canopy's `wsUrl`). Called with the
25
+ * relative path `ws/canopy-sessions/${sessionId}/`.
26
+ */
27
+ wsUrl: (path: string) => string;
28
+ /**
29
+ * Optional side-effect callback fired when the server broadcasts a
30
+ * `session.title_updated` (replaces ace's `notifySessionsUpdated`). The kit
31
+ * has no opinion on what to do with it.
32
+ */
33
+ onTitleUpdated?: () => void;
34
+ }
35
+
36
+ export interface UseSessionSocketResult {
37
+ state: SessionState;
38
+ connected: boolean;
39
+ sendChat: () => void;
40
+ stopChat: (messageId: string) => void;
41
+ updateDraft: (body: string) => void;
42
+ takeOverDraft: () => void;
43
+ discardDraft: () => void;
44
+ prependMessages: (older: Message[]) => void;
45
+ lastError: string | null;
46
+ }
47
+
48
+ export function useSessionSocket({
49
+ sessionId,
50
+ wsUrl,
51
+ onTitleUpdated,
52
+ }: UseSessionSocketOptions): UseSessionSocketResult {
53
+ const [state, setState] = useState<SessionState>(INITIAL_STATE);
54
+ const [connected, setConnected] = useState(false);
55
+ const [lastError, setLastError] = useState<string | null>(null);
56
+
57
+ const socketRef = useRef<WebSocket | null>(null);
58
+ const stateRef = useRef<SessionState>(INITIAL_STATE);
59
+ const reconnectAttemptRef = useRef(0);
60
+ const heartbeatTimerRef = useRef<number | null>(null);
61
+ const draftDebounceRef = useRef<number | null>(null);
62
+ const pendingDraftBodyRef = useRef<string | null>(null);
63
+ const closedByUserRef = useRef(false);
64
+ const onTitleUpdatedRef = useRef(onTitleUpdated);
65
+ // Control frames that must not be lost across a reconnect (currently
66
+ // only chat.stop). The WS-world analogue of an abortable chat transport.
67
+ const pendingFramesRef = useRef<{ action: string; data: unknown }[]>([]);
68
+
69
+ useEffect(() => {
70
+ stateRef.current = state;
71
+ }, [state]);
72
+
73
+ useEffect(() => {
74
+ onTitleUpdatedRef.current = onTitleUpdated;
75
+ }, [onTitleUpdated]);
76
+
77
+ const send = useCallback((frame: { action: string; data: unknown }) => {
78
+ const ws = socketRef.current;
79
+ if (ws && ws.readyState === WebSocket.OPEN) {
80
+ ws.send(JSON.stringify(frame));
81
+ return;
82
+ }
83
+ // Queue chat.stop so a stop clicked while the socket is reconnecting
84
+ // is delivered on next OPEN instead of silently dropped. Draft updates
85
+ // are intentionally NOT queued — they have a version guard and the
86
+ // user's next keystroke will refresh the body anyway.
87
+ if (frame.action === "chat.stop") {
88
+ pendingFramesRef.current.push(frame);
89
+ }
90
+ }, []);
91
+
92
+ const applyEvent = useCallback((frame: WsEvent) => {
93
+ // Side-effect events: handle BEFORE setState so React strict-mode's
94
+ // double-invocation of the updater doesn't double-fire the effect.
95
+ if (frame.event === "session.title_updated") {
96
+ onTitleUpdatedRef.current?.();
97
+ return;
98
+ }
99
+ if (frame.event === "session.error") {
100
+ setLastError(frame.data.message);
101
+ if (
102
+ frame.data.code === "draft_version_mismatch" &&
103
+ frame.data.detail &&
104
+ typeof frame.data.detail === "object"
105
+ ) {
106
+ // Clear any pending optimistic body so the user's stale local
107
+ // text doesn't auto-re-send with the new version.
108
+ pendingDraftBodyRef.current = null;
109
+ if (draftDebounceRef.current != null) {
110
+ window.clearTimeout(draftDebounceRef.current);
111
+ draftDebounceRef.current = null;
112
+ }
113
+ }
114
+ }
115
+ setState((prev) => sessionReducer(prev, frame));
116
+ }, []);
117
+
118
+ const connect = useCallback(() => {
119
+ if (closedByUserRef.current) return;
120
+ const ws = new WebSocket(wsUrl(`ws/canopy-sessions/${sessionId}/`));
121
+ socketRef.current = ws;
122
+
123
+ ws.onopen = () => {
124
+ setConnected(true);
125
+ reconnectAttemptRef.current = 0;
126
+ // Flush any control frames that were queued while the socket was
127
+ // closed. See `send` above.
128
+ const queued = pendingFramesRef.current;
129
+ pendingFramesRef.current = [];
130
+ for (const frame of queued) {
131
+ ws.send(JSON.stringify(frame));
132
+ }
133
+ if (heartbeatTimerRef.current != null) {
134
+ window.clearInterval(heartbeatTimerRef.current);
135
+ }
136
+ heartbeatTimerRef.current = window.setInterval(() => {
137
+ send({ action: "presence.heartbeat", data: {} });
138
+ }, HEARTBEAT_INTERVAL_MS);
139
+ };
140
+
141
+ ws.onmessage = (e) => {
142
+ try {
143
+ const frame = JSON.parse(e.data) as WsEvent;
144
+ applyEvent(frame);
145
+ } catch {
146
+ // ignore malformed frames
147
+ }
148
+ };
149
+
150
+ ws.onclose = () => {
151
+ setConnected(false);
152
+ if (heartbeatTimerRef.current != null) {
153
+ window.clearInterval(heartbeatTimerRef.current);
154
+ heartbeatTimerRef.current = null;
155
+ }
156
+ if (closedByUserRef.current) return;
157
+ const attempt = reconnectAttemptRef.current;
158
+ const delay =
159
+ RECONNECT_DELAYS_MS[Math.min(attempt, RECONNECT_DELAYS_MS.length - 1)];
160
+ reconnectAttemptRef.current = attempt + 1;
161
+ window.setTimeout(connect, delay);
162
+ };
163
+
164
+ ws.onerror = () => {
165
+ // onclose will fire next; nothing to do here.
166
+ };
167
+ }, [applyEvent, send, sessionId, wsUrl]);
168
+
169
+ useEffect(() => {
170
+ closedByUserRef.current = false;
171
+ reconnectAttemptRef.current = 0;
172
+ connect();
173
+ return () => {
174
+ closedByUserRef.current = true;
175
+ if (heartbeatTimerRef.current != null) {
176
+ window.clearInterval(heartbeatTimerRef.current);
177
+ }
178
+ if (socketRef.current) {
179
+ socketRef.current.close();
180
+ socketRef.current = null;
181
+ }
182
+ };
183
+ }, [connect]);
184
+
185
+ const sendChat = useCallback(() => {
186
+ // Flush any pending debounced update first so the committed draft
187
+ // carries the latest local body.
188
+ if (draftDebounceRef.current != null) {
189
+ window.clearTimeout(draftDebounceRef.current);
190
+ draftDebounceRef.current = null;
191
+ if (pendingDraftBodyRef.current != null && stateRef.current.active_draft) {
192
+ send({
193
+ action: "draft.update",
194
+ data: {
195
+ version: stateRef.current.active_draft.version,
196
+ body: pendingDraftBodyRef.current,
197
+ },
198
+ });
199
+ }
200
+ }
201
+ pendingDraftBodyRef.current = null;
202
+ send({ action: "chat.send", data: {} });
203
+ }, [send]);
204
+
205
+ const stopChat = useCallback(
206
+ (messageId: string) => {
207
+ send({ action: "chat.stop", data: { message_id: messageId } });
208
+ },
209
+ [send],
210
+ );
211
+
212
+ const updateDraft = useCallback(
213
+ (body: string) => {
214
+ // Optimistic local update so the textarea feels snappy.
215
+ setState((prev) =>
216
+ prev.active_draft
217
+ ? { ...prev, active_draft: { ...prev.active_draft, body } }
218
+ : prev,
219
+ );
220
+ pendingDraftBodyRef.current = body;
221
+ if (draftDebounceRef.current != null) {
222
+ window.clearTimeout(draftDebounceRef.current);
223
+ }
224
+ draftDebounceRef.current = window.setTimeout(() => {
225
+ draftDebounceRef.current = null;
226
+ const current = stateRef.current.active_draft;
227
+ const pending = pendingDraftBodyRef.current;
228
+ pendingDraftBodyRef.current = null;
229
+ if (current != null && pending != null) {
230
+ send({
231
+ action: "draft.update",
232
+ data: { version: current.version, body: pending },
233
+ });
234
+ }
235
+ }, DRAFT_UPDATE_DEBOUNCE_MS);
236
+ },
237
+ [send],
238
+ );
239
+
240
+ const takeOverDraft = useCallback(() => {
241
+ send({ action: "draft.take_over", data: {} });
242
+ }, [send]);
243
+
244
+ const discardDraft = useCallback(() => {
245
+ send({ action: "draft.discard", data: {} });
246
+ }, [send]);
247
+
248
+ const prependMessages = useCallback((older: Message[]) => {
249
+ // Apply a REST "Load earlier" page into the live socket state. A later
250
+ // session.state snapshot (e.g. reconnect) resets to the tail — acceptable;
251
+ // the user re-loads earlier if needed.
252
+ setState((prev) => {
253
+ const merged = prependHistory(prev.messages, older);
254
+ return merged === prev.messages ? prev : { ...prev, messages: merged };
255
+ });
256
+ }, []);
257
+
258
+ return {
259
+ state,
260
+ connected,
261
+ sendChat,
262
+ stopChat,
263
+ updateDraft,
264
+ takeOverDraft,
265
+ discardDraft,
266
+ prependMessages,
267
+ lastError,
268
+ };
269
+ }
@@ -0,0 +1,77 @@
1
+ import { useCallback, useEffect, useRef, type RefObject } from "react";
2
+
3
+ /**
4
+ * Sticky-bottom auto-scroll for a streaming message list.
5
+ *
6
+ * Behaviour:
7
+ * - When the user is at (or within `thresholdPx` of) the bottom of the
8
+ * scroll container, growth of `dep` (e.g. messages array, streaming
9
+ * text length) snaps the view to the new bottom.
10
+ * - When the user has scrolled up to read history, growth does NOT
11
+ * yank them back. Auto-follow resumes once they scroll back near
12
+ * the bottom themselves.
13
+ *
14
+ * The "near bottom" predicate is updated in two places:
15
+ * - on the user's `scroll` event (so manual scroll-up disables follow)
16
+ * - immediately after an auto-scroll write (so we stay sticky even
17
+ * though the scroll event for our own write would temporarily make
18
+ * `scrollHeight - scrollTop - clientHeight` larger by a few pixels)
19
+ *
20
+ * Use "instant" scroll behavior for streaming chunks — smooth scroll
21
+ * cannot keep up with high-frequency updates and the view drifts.
22
+ *
23
+ * Returns:
24
+ * - `containerRef`: attach to the scrollable element.
25
+ * - `onScroll`: attach to `onScroll` on the same element.
26
+ * - `scrollToBottom`: force a snap (e.g. on send).
27
+ */
28
+ export function useStickyBottom<T>(
29
+ dep: T,
30
+ options: { thresholdPx?: number; enabled?: boolean } = {},
31
+ ): {
32
+ containerRef: RefObject<HTMLDivElement | null>;
33
+ onScroll: () => void;
34
+ scrollToBottom: () => void;
35
+ } {
36
+ const { thresholdPx = 100, enabled = true } = options;
37
+ const containerRef = useRef<HTMLDivElement>(null);
38
+ // Default true: when the container first mounts there's no history
39
+ // to read, so the user is "at the bottom" by definition.
40
+ const wasNearBottomRef = useRef(true);
41
+
42
+ const isNearBottom = useCallback(
43
+ (el: HTMLElement): boolean =>
44
+ el.scrollHeight - el.scrollTop - el.clientHeight < thresholdPx,
45
+ [thresholdPx],
46
+ );
47
+
48
+ const onScroll = useCallback(() => {
49
+ const el = containerRef.current;
50
+ if (!el) return;
51
+ wasNearBottomRef.current = isNearBottom(el);
52
+ }, [isNearBottom]);
53
+
54
+ const scrollToBottom = useCallback(() => {
55
+ const el = containerRef.current;
56
+ if (!el) return;
57
+ el.scrollTop = el.scrollHeight;
58
+ wasNearBottomRef.current = true;
59
+ }, []);
60
+
61
+ useEffect(() => {
62
+ if (!enabled) return;
63
+ const el = containerRef.current;
64
+ if (!el) return;
65
+ if (wasNearBottomRef.current) {
66
+ // "instant" by direct scrollTop write — smooth scroll falls
67
+ // behind during streaming and the view drifts.
68
+ el.scrollTop = el.scrollHeight;
69
+ }
70
+ // dep is intentionally the only signal that triggers a follow;
71
+ // it should change whenever the message list grows (length or
72
+ // the last message's content length).
73
+ // eslint-disable-next-line react-hooks/exhaustive-deps
74
+ }, [dep, enabled]);
75
+
76
+ return { containerRef, onScroll, scrollToBottom };
77
+ }
@@ -0,0 +1,61 @@
1
+ import * as React from "react";
2
+ import { cn } from "../lib/cn";
3
+
4
+ /**
5
+ * Textarea that auto-sizes to fit its content ONCE — when the content first
6
+ * loads — then behaves as a normal drag-resizable textarea.
7
+ *
8
+ * So the editor opens with every box sized to show all its text (no internal
9
+ * scrollbars), and after that the user owns each box's size by dragging the
10
+ * `resize-y` handle. Typing after the initial fit does NOT re-grow/shrink the
11
+ * box (it scrolls like a normal textarea) — per the desired behavior:
12
+ * auto-size on open, manual thereafter.
13
+ *
14
+ * The fit runs once, keyed on the arrival of content (the value is empty on the
15
+ * first paint while the template loads, then populated). It re-fits on remount,
16
+ * so keying the editor by template id sizes a freshly-opened template's boxes.
17
+ * Tailwind sets `box-sizing: border-box`, so `scrollHeight` includes padding.
18
+ */
19
+ export const AutoResizeTextarea = React.forwardRef<
20
+ HTMLTextAreaElement,
21
+ React.ComponentPropsWithoutRef<"textarea">
22
+ >(({ className, rows = 2, ...props }, forwardedRef) => {
23
+ const innerRef = React.useRef<HTMLTextAreaElement | null>(null);
24
+ const fitted = React.useRef(false);
25
+
26
+ // Merge forwardedRef + innerRef so callers can still access the element.
27
+ const ref = React.useCallback(
28
+ (el: HTMLTextAreaElement | null) => {
29
+ innerRef.current = el;
30
+ if (typeof forwardedRef === "function") {
31
+ forwardedRef(el);
32
+ } else if (forwardedRef) {
33
+ (forwardedRef as React.MutableRefObject<HTMLTextAreaElement | null>).current = el;
34
+ }
35
+ },
36
+ [forwardedRef],
37
+ );
38
+
39
+ // Fit exactly once, when content first arrives. `height: auto` collapses the
40
+ // box so scrollHeight reflects content, then we pin that height. After this
41
+ // the box is purely manual (resize-y); we never auto-resize again.
42
+ React.useLayoutEffect(() => {
43
+ const el = innerRef.current;
44
+ if (!el || fitted.current) return;
45
+ if (!el.value) return; // wait until the loaded content populates the value
46
+ el.style.height = "auto";
47
+ el.style.height = `${el.scrollHeight}px`;
48
+ fitted.current = true;
49
+ }, [props.value]);
50
+
51
+ return (
52
+ <textarea
53
+ ref={ref}
54
+ rows={rows}
55
+ className={cn("resize-y", className?.replace(/\bresize-none\b/g, ""))}
56
+ {...props}
57
+ />
58
+ );
59
+ });
60
+
61
+ AutoResizeTextarea.displayName = "AutoResizeTextarea";
@@ -0,0 +1,131 @@
1
+ import * as React from "react"
2
+ import { Dialog as DialogPrimitive } from "@base-ui/react/dialog"
3
+ import { X } from "lucide-react"
4
+
5
+ import { cn } from "../lib/cn"
6
+
7
+ const Dialog = DialogPrimitive.Root
8
+
9
+ const DialogTrigger = DialogPrimitive.Trigger
10
+
11
+ const DialogClose = DialogPrimitive.Close
12
+
13
+ const DialogPortal = ({ children }: { children: React.ReactNode }) => (
14
+ <DialogPrimitive.Portal>{children}</DialogPrimitive.Portal>
15
+ )
16
+
17
+ function DialogOverlay({
18
+ className,
19
+ ...props
20
+ }: React.ComponentProps<typeof DialogPrimitive.Backdrop>) {
21
+ return (
22
+ <DialogPrimitive.Backdrop
23
+ data-slot="dialog-overlay"
24
+ className={cn(
25
+ "fixed inset-0 z-50 bg-black/80 transition-all data-[ending-style]:opacity-0 data-[starting-style]:opacity-0",
26
+ className
27
+ )}
28
+ {...props}
29
+ />
30
+ )
31
+ }
32
+
33
+ function DialogContent({
34
+ className,
35
+ children,
36
+ ...props
37
+ }: React.ComponentProps<typeof DialogPrimitive.Popup>) {
38
+ return (
39
+ <DialogPrimitive.Portal>
40
+ <DialogOverlay />
41
+ <DialogPrimitive.Popup
42
+ data-slot="dialog-content"
43
+ className={cn(
44
+ "fixed top-[50%] left-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border bg-background p-6 shadow-lg transition-all data-[ending-style]:scale-95 data-[ending-style]:opacity-0 data-[starting-style]:scale-95 data-[starting-style]:opacity-0",
45
+ className
46
+ )}
47
+ {...props}
48
+ >
49
+ {children}
50
+ <DialogPrimitive.Close className="absolute top-4 right-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-none disabled:pointer-events-none">
51
+ <X className="h-4 w-4" />
52
+ <span className="sr-only">Close</span>
53
+ </DialogPrimitive.Close>
54
+ </DialogPrimitive.Popup>
55
+ </DialogPrimitive.Portal>
56
+ )
57
+ }
58
+
59
+ function DialogHeader({
60
+ className,
61
+ ...props
62
+ }: React.HTMLAttributes<HTMLDivElement>) {
63
+ return (
64
+ <div
65
+ data-slot="dialog-header"
66
+ className={cn(
67
+ "flex flex-col gap-1.5 text-center sm:text-left",
68
+ className
69
+ )}
70
+ {...props}
71
+ />
72
+ )
73
+ }
74
+
75
+ function DialogFooter({
76
+ className,
77
+ ...props
78
+ }: React.HTMLAttributes<HTMLDivElement>) {
79
+ return (
80
+ <div
81
+ data-slot="dialog-footer"
82
+ className={cn(
83
+ "flex flex-col-reverse sm:flex-row sm:justify-end sm:gap-2",
84
+ className
85
+ )}
86
+ {...props}
87
+ />
88
+ )
89
+ }
90
+
91
+ function DialogTitle({
92
+ className,
93
+ ...props
94
+ }: React.ComponentProps<typeof DialogPrimitive.Title>) {
95
+ return (
96
+ <DialogPrimitive.Title
97
+ data-slot="dialog-title"
98
+ className={cn(
99
+ "text-lg font-semibold leading-none tracking-tight",
100
+ className
101
+ )}
102
+ {...props}
103
+ />
104
+ )
105
+ }
106
+
107
+ function DialogDescription({
108
+ className,
109
+ ...props
110
+ }: React.ComponentProps<typeof DialogPrimitive.Description>) {
111
+ return (
112
+ <DialogPrimitive.Description
113
+ data-slot="dialog-description"
114
+ className={cn("text-sm text-muted-foreground", className)}
115
+ {...props}
116
+ />
117
+ )
118
+ }
119
+
120
+ export {
121
+ Dialog,
122
+ DialogPortal,
123
+ DialogOverlay,
124
+ DialogTrigger,
125
+ DialogClose,
126
+ DialogContent,
127
+ DialogHeader,
128
+ DialogFooter,
129
+ DialogTitle,
130
+ DialogDescription,
131
+ }