canopy-ui 0.3.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.
- package/package.json +2 -1
- package/src/chat/ChatPanel.tsx +140 -0
- package/src/chat/ConnectionStatus.tsx +30 -0
- package/src/chat/MessageItem.tsx +198 -0
- package/src/chat/MessageList.tsx +95 -0
- package/src/chat/PlacementBanner.test.tsx +112 -0
- package/src/chat/PlacementBanner.tsx +92 -0
- package/src/chat/PresenceChips.tsx +52 -0
- package/src/chat/SendBox.tsx +144 -0
- package/src/chat/ToolCallPair.tsx +86 -0
- package/src/chat/drafts.test.ts +66 -0
- package/src/chat/drafts.ts +19 -0
- package/src/chat/history.test.ts +35 -0
- package/src/chat/history.ts +16 -0
- package/src/chat/index.ts +56 -0
- package/src/chat/pairToolMessages.test.ts +208 -0
- package/src/chat/pairToolMessages.ts +162 -0
- package/src/chat/protocol.ts +99 -0
- package/src/chat/sessionReducer.test.ts +284 -0
- package/src/chat/sessionReducer.ts +245 -0
- package/src/chat/useSessionSocket.ts +269 -0
- package/src/chat/useStickyBottom.ts +77 -0
|
@@ -0,0 +1,245 @@
|
|
|
1
|
+
import type { Draft, Message, SessionState, WsEvent } from "./protocol";
|
|
2
|
+
|
|
3
|
+
// Pure reducer for SessionState — extracted from useSessionSocket so it
|
|
4
|
+
// can be unit-tested without WebSocket plumbing. Side-effect events
|
|
5
|
+
// (session.title_updated → optional injected callback; session.error →
|
|
6
|
+
// setLastError + clear draft debounce) stay in the hook itself.
|
|
7
|
+
//
|
|
8
|
+
// Keep this file dependency-free (no React) so a vitest run doesn't pull
|
|
9
|
+
// jsdom or RTL.
|
|
10
|
+
//
|
|
11
|
+
// canopy adaptation vs ace: message/draft ids are STRINGS, and
|
|
12
|
+
// `chat.stream_start` UPSERTS the assistant message — canopy's
|
|
13
|
+
// `draft.committed` carries only `user_message_id` (no assistant id to
|
|
14
|
+
// pre-insert), so the assistant row is created lazily when its first stream
|
|
15
|
+
// frame arrives.
|
|
16
|
+
export function sessionReducer(prev: SessionState, frame: WsEvent): SessionState {
|
|
17
|
+
switch (frame.event) {
|
|
18
|
+
case "session.state":
|
|
19
|
+
return frame.data;
|
|
20
|
+
|
|
21
|
+
case "chat.stream_start": {
|
|
22
|
+
// Upsert: if the assistant message already exists (rare — a runner that
|
|
23
|
+
// pre-inserts it), flip it to streaming; otherwise create it. canopy's
|
|
24
|
+
// draft.committed cannot pre-send the assistant id, so this is the
|
|
25
|
+
// normal path for making the streamed reply visible.
|
|
26
|
+
const exists = prev.messages.some((m) => m.id === frame.data.message_id);
|
|
27
|
+
if (exists) {
|
|
28
|
+
return {
|
|
29
|
+
...prev,
|
|
30
|
+
messages: prev.messages.map((m) =>
|
|
31
|
+
m.id === frame.data.message_id
|
|
32
|
+
? { ...m, status: "streaming" as const }
|
|
33
|
+
: m,
|
|
34
|
+
),
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
const nowIso = new Date().toISOString();
|
|
38
|
+
const assistant: Message = {
|
|
39
|
+
id: frame.data.message_id,
|
|
40
|
+
turn_index: frame.data.turn_index,
|
|
41
|
+
role: "assistant",
|
|
42
|
+
content: {},
|
|
43
|
+
plaintext: "",
|
|
44
|
+
status: "streaming",
|
|
45
|
+
error_detail: null,
|
|
46
|
+
started_at: nowIso,
|
|
47
|
+
completed_at: null,
|
|
48
|
+
created_at: nowIso,
|
|
49
|
+
};
|
|
50
|
+
return { ...prev, messages: [...prev.messages, assistant] };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
case "chat.delta":
|
|
54
|
+
return {
|
|
55
|
+
...prev,
|
|
56
|
+
messages: prev.messages.map((m) =>
|
|
57
|
+
m.id === frame.data.message_id
|
|
58
|
+
? { ...m, plaintext: m.plaintext + frame.data.text }
|
|
59
|
+
: m,
|
|
60
|
+
),
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
case "chat.stream_complete":
|
|
64
|
+
return {
|
|
65
|
+
...prev,
|
|
66
|
+
messages: prev.messages.map((m) =>
|
|
67
|
+
m.id === frame.data.message_id
|
|
68
|
+
? {
|
|
69
|
+
...m,
|
|
70
|
+
plaintext: frame.data.plaintext,
|
|
71
|
+
status: "complete" as const,
|
|
72
|
+
}
|
|
73
|
+
: m,
|
|
74
|
+
),
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
case "chat.stream_error":
|
|
78
|
+
// NOTE: backend emits chat.stream_error with detail="cancelled"
|
|
79
|
+
// for stop-driven cancellation; there's no separate
|
|
80
|
+
// chat.stream_cancelled event in practice. Distinguished by detail.
|
|
81
|
+
return {
|
|
82
|
+
...prev,
|
|
83
|
+
messages: prev.messages.map((m) =>
|
|
84
|
+
m.id === frame.data.message_id
|
|
85
|
+
? {
|
|
86
|
+
...m,
|
|
87
|
+
status: "error" as const,
|
|
88
|
+
error_detail: frame.data.detail,
|
|
89
|
+
}
|
|
90
|
+
: m,
|
|
91
|
+
),
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
case "chat.stream_cancelled":
|
|
95
|
+
return {
|
|
96
|
+
...prev,
|
|
97
|
+
messages: prev.messages.map((m) =>
|
|
98
|
+
m.id === frame.data.message_id
|
|
99
|
+
? {
|
|
100
|
+
...m,
|
|
101
|
+
status: "error" as const,
|
|
102
|
+
error_detail: `cancelled (partial: ${frame.data.partial_len} chars)`,
|
|
103
|
+
}
|
|
104
|
+
: m,
|
|
105
|
+
),
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
case "chat.tool_use":
|
|
109
|
+
case "chat.tool_result":
|
|
110
|
+
// Tool rows are their own Message rows on the server. A full
|
|
111
|
+
// refresh picks them up; for now, don't duplicate bookkeeping here.
|
|
112
|
+
return prev;
|
|
113
|
+
|
|
114
|
+
case "draft.updated": {
|
|
115
|
+
const incoming = frame.data as Draft;
|
|
116
|
+
// If we're the current editor, keep our local body — the server
|
|
117
|
+
// echo is stale relative to keystrokes that happened since the
|
|
118
|
+
// debounced send. Only accept metadata (version, last_editor, etc).
|
|
119
|
+
if (
|
|
120
|
+
prev.active_draft &&
|
|
121
|
+
incoming.last_editor === prev.current_user_id
|
|
122
|
+
) {
|
|
123
|
+
return {
|
|
124
|
+
...prev,
|
|
125
|
+
active_draft: {
|
|
126
|
+
...prev.active_draft,
|
|
127
|
+
version: incoming.version,
|
|
128
|
+
last_editor: incoming.last_editor,
|
|
129
|
+
last_edit_at: incoming.last_edit_at,
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
return { ...prev, active_draft: incoming };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
case "draft.lock_changed":
|
|
137
|
+
if (prev.active_draft && prev.active_draft.id === frame.data.draft_id) {
|
|
138
|
+
return {
|
|
139
|
+
...prev,
|
|
140
|
+
active_draft: {
|
|
141
|
+
...prev.active_draft,
|
|
142
|
+
last_editor: frame.data.holder_user_id ?? prev.active_draft.last_editor,
|
|
143
|
+
},
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
return prev;
|
|
147
|
+
|
|
148
|
+
case "draft.committed": {
|
|
149
|
+
// Insert the optimistic USER message from the draft body that's about
|
|
150
|
+
// to be cleared. The assistant reply is NOT inserted here — canopy's
|
|
151
|
+
// draft.committed carries no assistant id; `chat.stream_start` upserts
|
|
152
|
+
// that row when the reply begins.
|
|
153
|
+
//
|
|
154
|
+
// Also clear active_draft.body here. The server creates a new empty
|
|
155
|
+
// draft with last_editor=sender, so the follow-up draft.updated hits
|
|
156
|
+
// the "keep local body" branch above and would otherwise leave the
|
|
157
|
+
// just-sent text in the textarea — which lets Enter re-send the same
|
|
158
|
+
// turn repeatedly.
|
|
159
|
+
const prevDraftBody = prev.active_draft?.body ?? "";
|
|
160
|
+
const maxTurnIndex = prev.messages.reduce(
|
|
161
|
+
(acc, msg) => Math.max(acc, msg.turn_index),
|
|
162
|
+
0,
|
|
163
|
+
);
|
|
164
|
+
const nowIso = new Date().toISOString();
|
|
165
|
+
const userMessage: Message = {
|
|
166
|
+
id: frame.data.user_message_id,
|
|
167
|
+
turn_index: maxTurnIndex + 1,
|
|
168
|
+
role: "user",
|
|
169
|
+
content: { text: prevDraftBody },
|
|
170
|
+
plaintext: prevDraftBody,
|
|
171
|
+
status: "complete",
|
|
172
|
+
error_detail: null,
|
|
173
|
+
started_at: null,
|
|
174
|
+
completed_at: nowIso,
|
|
175
|
+
created_at: nowIso,
|
|
176
|
+
};
|
|
177
|
+
return {
|
|
178
|
+
...prev,
|
|
179
|
+
active_draft: prev.active_draft
|
|
180
|
+
? { ...prev.active_draft, body: "" }
|
|
181
|
+
: prev.active_draft,
|
|
182
|
+
messages: [...prev.messages, userMessage],
|
|
183
|
+
};
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
case "draft.discarded":
|
|
187
|
+
if (prev.active_draft && prev.active_draft.id === frame.data.draft_id) {
|
|
188
|
+
return {
|
|
189
|
+
...prev,
|
|
190
|
+
active_draft: { ...prev.active_draft, body: "" },
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
return prev;
|
|
194
|
+
|
|
195
|
+
case "presence.joined": {
|
|
196
|
+
const ids = new Set(prev.presence_user_ids);
|
|
197
|
+
ids.add(frame.data.user_id);
|
|
198
|
+
return { ...prev, presence_user_ids: [...ids] };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
case "presence.left":
|
|
202
|
+
return {
|
|
203
|
+
...prev,
|
|
204
|
+
presence_user_ids: prev.presence_user_ids.filter(
|
|
205
|
+
(id) => id !== frame.data.user_id,
|
|
206
|
+
),
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
case "session.error": {
|
|
210
|
+
// Side effects (setLastError, clear draft debounce) are handled
|
|
211
|
+
// by the hook; the reducer only knows about the version-mismatch
|
|
212
|
+
// recovery, which mutates active_draft.
|
|
213
|
+
if (
|
|
214
|
+
frame.data.code === "draft_version_mismatch" &&
|
|
215
|
+
frame.data.detail &&
|
|
216
|
+
typeof frame.data.detail === "object"
|
|
217
|
+
) {
|
|
218
|
+
const detail = frame.data.detail as {
|
|
219
|
+
current_version: number;
|
|
220
|
+
current_body: string;
|
|
221
|
+
};
|
|
222
|
+
return prev.active_draft
|
|
223
|
+
? {
|
|
224
|
+
...prev,
|
|
225
|
+
active_draft: {
|
|
226
|
+
...prev.active_draft,
|
|
227
|
+
version: detail.current_version,
|
|
228
|
+
body: detail.current_body,
|
|
229
|
+
},
|
|
230
|
+
}
|
|
231
|
+
: prev;
|
|
232
|
+
}
|
|
233
|
+
return prev;
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
case "session.title_updated":
|
|
237
|
+
// Pure reducer leaves this alone — the hook calls its optional
|
|
238
|
+
// onTitleUpdated callback on receipt and short-circuits. Included
|
|
239
|
+
// here so an exhaustive switch type-checks.
|
|
240
|
+
return prev;
|
|
241
|
+
|
|
242
|
+
default:
|
|
243
|
+
return prev;
|
|
244
|
+
}
|
|
245
|
+
}
|
|
@@ -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
|
+
}
|