@tea-agent/loop-agent 0.32.0 → 0.33.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.
Files changed (56) hide show
  1. package/CHANGELOG.md +60 -0
  2. package/dist/executors/model-routing.js +14 -4
  3. package/dist/governance/manifest-types.js +34 -7
  4. package/dist/worker/console/chat/model-resolver.js +114 -34
  5. package/dist/worker/console/chat/workspace-landing.js +58 -22
  6. package/dist/worker/console/doctor.js +1 -0
  7. package/dist/worker/console/night-aux-ticker.js +5 -0
  8. package/dist/worker/console/pi-readiness.js +26 -17
  9. package/dist/worker/console/server.js +2 -0
  10. package/dist/worker/console/static/assets/index-3R-GT3a_.js +29 -0
  11. package/dist/worker/console/static/assets/index-B0EQt_yq.css +1 -0
  12. package/dist/worker/console/static/index.html +2 -2
  13. package/dist/worker/console/static-src/app/console-types.js +140 -0
  14. package/dist/worker/console/static-src/app/useConsoleShell.js +101 -0
  15. package/dist/worker/console/static-src/app/useOperatorActions.js +304 -0
  16. package/dist/worker/console/static-src/app/usePrdImport.js +171 -0
  17. package/dist/worker/console/static-src/app/useRecoveryActions.js +257 -0
  18. package/dist/worker/console/static-src/app/useRecoveryConsole.js +334 -0
  19. package/dist/worker/console/static-src/app/useTaskWizard.js +229 -0
  20. package/dist/worker/console/static-src/chat-view-types.js +2 -0
  21. package/dist/worker/console/static-src/night/night-types.js +24 -0
  22. package/dist/worker/console/static-src/night/useNightBoard.js +94 -0
  23. package/dist/worker/console/static-src/night/useNightWizard.js +171 -0
  24. package/dist/worker/console/static-src/operator-chat/chat-sse-events.js +253 -0
  25. package/dist/worker/console/static-src/operator-chat/format.js +71 -0
  26. package/dist/worker/console/static-src/operator-chat/refs.js +47 -0
  27. package/dist/worker/console/static-src/operator-chat/tools-catalog.js +77 -0
  28. package/dist/worker/console/static-src/operator-chat/types.js +1 -0
  29. package/dist/worker/console/static-src/operator-chat/useChatSessions.js +320 -0
  30. package/dist/worker/console/static-src/operator-chat/useChatStream.js +209 -0
  31. package/dist/worker/console/static-src/operator-chat/useChatThread.js +218 -0
  32. package/dist/worker/console/static-src/operator-chat/useComposer.js +125 -0
  33. package/dist/worker/console/static-src/operator-chat/useInterview.js +108 -0
  34. package/dist/worker/console/static-src/operator-chat/useRepoBrowser.js +123 -0
  35. package/dist/worker/console/static-src/operator-chat/useRuntimeControls.js +207 -0
  36. package/dist/worker/observability/read-model.js +106 -0
  37. package/dist/worker/observe/routes.js +19 -1
  38. package/dist/worker/observe/static/api.js +42 -3
  39. package/dist/worker/observe/static/app.js +4 -0
  40. package/dist/worker/observe/static/constants.js +10 -0
  41. package/dist/worker/observe/static/custom-select.js +567 -0
  42. package/dist/worker/observe/static/index.html +47 -6
  43. package/dist/worker/observe/static/router.js +54 -1
  44. package/dist/worker/observe/static/state.js +20 -0
  45. package/dist/worker/observe/static/styles.css +618 -30
  46. package/dist/worker/observe/static/views/dag-inspector.js +20 -17
  47. package/dist/worker/observe/static/views/dag.js +136 -59
  48. package/dist/worker/observe/static/views/dags.js +877 -0
  49. package/dist/worker/observe/static/views/dashboard.js +15 -4
  50. package/dist/workflows/dag/init-hybrid.js +58 -29
  51. package/docs/templates/agent-worker-production-readiness-checklist.md +26 -24
  52. package/docs/templates/harness.schema.json +5 -0
  53. package/harness.json +4 -4
  54. package/package.json +5 -4
  55. package/dist/worker/console/static/assets/index-D9gnJn_l.js +0 -29
  56. package/dist/worker/console/static/assets/index-rajoXwkM.css +0 -1
@@ -0,0 +1,320 @@
1
+ import { useCallback, useEffect, useRef, useState } from "react";
2
+ import { runtimeSelectionFromRecord } from "../../chat/runtime-selection.js";
3
+ import { hydrateSessionThread } from "../../chat/turn-process.js";
4
+ import { ACTIVE_CHAT_SESSION_STORAGE_KEY } from "../../chat/workspace-landing.js";
5
+ import { CHAT_SESSION_LIST_LIMIT } from "../chat-view-types.js";
6
+ import { confirmationToken } from "./format.js";
7
+ /** Session lifecycle: list / activate / create / delete / rename-archive /
8
+ * compact, plus browser-refresh recovery from the durable session store. */
9
+ export function useChatSessions(params) {
10
+ const { origin, refs, setSession, thread, composer, browser, setError, setProcessOpen, setAutoScroll, } = params;
11
+ const { applyActivatedSession, resetForNewSession, setMessages } = thread;
12
+ const { setInput, setPendingImages } = composer;
13
+ const { setPreviews, setActivePreviewPath } = browser;
14
+ const [sessionList, setSessionList] = useState([]);
15
+ // Only the latest activate/create/restore intent may commit active-session
16
+ // state. This prevents slow responses from an older selection overwriting a
17
+ // newer user choice.
18
+ const sessionSelectionVersionRef = useRef(0);
19
+ const resetActiveProjection = useCallback(() => {
20
+ refs.streamingRef.current = false;
21
+ refs.abortRef.current = null;
22
+ refs.assistantIdRef.current = null;
23
+ refs.lastEventIdRef.current = null;
24
+ refs.seenEventIdsRef.current = new Set();
25
+ refs.turnAssistantIdsRef.current = new Map();
26
+ resetForNewSession();
27
+ setInput("");
28
+ setPendingImages([]);
29
+ setPreviews([]);
30
+ setActivePreviewPath(null);
31
+ setProcessOpen(false);
32
+ setAutoScroll(true);
33
+ }, [
34
+ refs,
35
+ resetForNewSession,
36
+ setInput,
37
+ setPendingImages,
38
+ setPreviews,
39
+ setActivePreviewPath,
40
+ setProcessOpen,
41
+ setAutoScroll,
42
+ ]);
43
+ const refreshSessionList = useCallback(async () => {
44
+ try {
45
+ const res = await fetch(`${origin}/api/operator/v1/chat/sessions`, {
46
+ credentials: "include",
47
+ });
48
+ if (!res.ok)
49
+ return;
50
+ const sessions = (await res.json()).sessions ??
51
+ [];
52
+ // API returns newest-first; keep only the recent window in the sidebar.
53
+ const keep = sessions.slice(0, CHAT_SESSION_LIST_LIMIT);
54
+ const excess = sessions.slice(CHAT_SESSION_LIST_LIMIT);
55
+ setSessionList(keep);
56
+ if (excess.length === 0)
57
+ return;
58
+ await Promise.all(excess.map((item) => fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(item.sessionId)}`, {
59
+ method: "DELETE",
60
+ credentials: "include",
61
+ headers: {
62
+ "x-loop-console-confirmation": confirmationToken(),
63
+ },
64
+ }).catch(() => undefined)));
65
+ const activeId = refs.sessionRef.current?.sessionId;
66
+ if (activeId && excess.some((item) => item.sessionId === activeId)) {
67
+ sessionSelectionVersionRef.current += 1;
68
+ refs.sessionRef.current = null;
69
+ resetActiveProjection();
70
+ setSession(null);
71
+ window.localStorage.removeItem(ACTIVE_CHAT_SESSION_STORAGE_KEY);
72
+ }
73
+ }
74
+ catch {
75
+ /* Chat remains usable without the sidebar index. */
76
+ }
77
+ }, [origin, refs, resetActiveProjection, setSession]);
78
+ const deleteSession = useCallback(async (sessionId) => {
79
+ if (!window.confirm("确定删除该会话?删除后不可恢复。"))
80
+ return;
81
+ try {
82
+ const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(sessionId)}`, {
83
+ method: "DELETE",
84
+ credentials: "include",
85
+ headers: {
86
+ "x-loop-console-confirmation": confirmationToken(),
87
+ },
88
+ });
89
+ if (!res.ok) {
90
+ setError("删除会话失败");
91
+ return;
92
+ }
93
+ if (refs.sessionRef.current?.sessionId === sessionId) {
94
+ sessionSelectionVersionRef.current += 1;
95
+ refs.sessionRef.current = null;
96
+ resetActiveProjection();
97
+ setSession(null);
98
+ window.localStorage.removeItem(ACTIVE_CHAT_SESSION_STORAGE_KEY);
99
+ }
100
+ await refreshSessionList();
101
+ }
102
+ catch (error) {
103
+ setError(error instanceof Error ? error.message : String(error));
104
+ }
105
+ }, [
106
+ origin,
107
+ refs,
108
+ setSession,
109
+ resetActiveProjection,
110
+ refreshSessionList,
111
+ setError,
112
+ ]);
113
+ const activateSession = useCallback(async (sessionId) => {
114
+ const selectionVersion = ++sessionSelectionVersionRef.current;
115
+ try {
116
+ const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(sessionId)}`, { credentials: "include" });
117
+ const body = (await res.json());
118
+ if (!res.ok || !body.session)
119
+ throw new Error(body.error?.message ?? "会话不可用");
120
+ if (selectionVersion !== sessionSelectionVersionRef.current)
121
+ return;
122
+ const next = {
123
+ sessionId: body.session.sessionId,
124
+ ...runtimeSelectionFromRecord(body.session),
125
+ };
126
+ refs.sessionRef.current = next;
127
+ refs.streamingRef.current = Boolean(body.session.activeTurnId);
128
+ refs.seenEventIdsRef.current = new Set();
129
+ refs.turnAssistantIdsRef.current = new Map();
130
+ refs.lastEventIdRef.current = null;
131
+ setSession(next);
132
+ refs.assistantIdRef.current = null;
133
+ refs.abortRef.current =
134
+ refs.sessionAbortControllersRef.current.get(sessionId) ?? null;
135
+ setPendingImages([]);
136
+ setPreviews([]);
137
+ setInput(body.session.composerDraft ?? "");
138
+ const hydrated = hydrateSessionThread(body.session.messages ?? []);
139
+ applyActivatedSession({
140
+ messages: hydrated.messages,
141
+ toolCalls: hydrated.toolCalls,
142
+ streaming: Boolean(body.session.activeTurnId),
143
+ });
144
+ window.localStorage.setItem(ACTIVE_CHAT_SESSION_STORAGE_KEY, sessionId);
145
+ setActivePreviewPath(null);
146
+ }
147
+ catch (error) {
148
+ if (selectionVersion !== sessionSelectionVersionRef.current)
149
+ return;
150
+ setError(error instanceof Error ? error.message : String(error));
151
+ }
152
+ }, [
153
+ origin,
154
+ refs,
155
+ setSession,
156
+ setPendingImages,
157
+ setPreviews,
158
+ setInput,
159
+ applyActivatedSession,
160
+ setActivePreviewPath,
161
+ setError,
162
+ ]);
163
+ useEffect(() => {
164
+ void refreshSessionList();
165
+ }, [refreshSessionList]);
166
+ // Browser refresh recovery: retain only the durable session id locally, then
167
+ // re-read messages and operation refs/facts from the server-owned stores.
168
+ useEffect(() => {
169
+ let cancelled = false;
170
+ const savedSessionId = window.localStorage.getItem(ACTIVE_CHAT_SESSION_STORAGE_KEY);
171
+ if (!savedSessionId)
172
+ return;
173
+ const selectionVersion = ++sessionSelectionVersionRef.current;
174
+ void (async () => {
175
+ try {
176
+ const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(savedSessionId)}`, { credentials: "include" });
177
+ if (!res.ok) {
178
+ window.localStorage.removeItem(ACTIVE_CHAT_SESSION_STORAGE_KEY);
179
+ return;
180
+ }
181
+ const body = (await res.json());
182
+ if (cancelled ||
183
+ selectionVersion !== sessionSelectionVersionRef.current ||
184
+ !body.session)
185
+ return;
186
+ const restored = {
187
+ sessionId: body.session.sessionId,
188
+ ...runtimeSelectionFromRecord(body.session),
189
+ };
190
+ refs.sessionRef.current = restored;
191
+ // Do not seed Last-Event-ID here: session messages are restored
192
+ // from the durable store, but tool/gate/operation projections live
193
+ // in the event ring and must replay once. applySseBlock dedups by
194
+ // eventId and marks replay so usage/unread/sound do not double-count.
195
+ refs.seenEventIdsRef.current = new Set();
196
+ refs.turnAssistantIdsRef.current = new Map();
197
+ refs.lastEventIdRef.current = null;
198
+ setSession(restored);
199
+ setInput(body.session.composerDraft ?? "");
200
+ setMessages((body.session.messages ?? [])
201
+ .filter((message) => message.role === "user" || message.role === "assistant")
202
+ .map((message) => ({
203
+ id: message.id,
204
+ role: message.role,
205
+ text: message.text,
206
+ createdAt: Date.parse(message.at) || Date.now(),
207
+ })));
208
+ }
209
+ catch {
210
+ // Recovery is best-effort; a new session remains available.
211
+ }
212
+ })();
213
+ return () => {
214
+ cancelled = true;
215
+ };
216
+ }, [origin, refs, setSession, setInput, setMessages]);
217
+ const createSession = useCallback(async (options) => {
218
+ const selectionVersion = ++sessionSelectionVersionRef.current;
219
+ setError(null);
220
+ try {
221
+ const res = await fetch(`${origin}/api/operator/v1/chat/sessions`, {
222
+ method: "POST",
223
+ headers: {
224
+ "Content-Type": "application/json",
225
+ "x-loop-console-confirmation": confirmationToken(),
226
+ },
227
+ credentials: "include",
228
+ body: JSON.stringify(options ?? {}),
229
+ });
230
+ if (!res.ok) {
231
+ const body = (await res.json().catch(() => ({})));
232
+ throw new Error(body.error?.message ?? `HTTP ${res.status}`);
233
+ }
234
+ const body = (await res.json());
235
+ const created = {
236
+ sessionId: body.sessionId,
237
+ ...(body.model ? { model: body.model } : {}),
238
+ };
239
+ void refreshSessionList();
240
+ if (selectionVersion !== sessionSelectionVersionRef.current)
241
+ return null;
242
+ resetActiveProjection();
243
+ // Set ref synchronously BEFORE setState so a chained
244
+ // createSession().then(sendPrompt) reads the new session even
245
+ // though sendPrompt's closure captured the stale (null) value.
246
+ refs.sessionRef.current = created;
247
+ window.localStorage.setItem(ACTIVE_CHAT_SESSION_STORAGE_KEY, created.sessionId);
248
+ setSession(created);
249
+ return created;
250
+ }
251
+ catch (e) {
252
+ if (selectionVersion !== sessionSelectionVersionRef.current)
253
+ return null;
254
+ setError(e instanceof Error ? e.message : String(e));
255
+ return null;
256
+ }
257
+ }, [
258
+ origin,
259
+ refs,
260
+ setSession,
261
+ setError,
262
+ resetActiveProjection,
263
+ refreshSessionList,
264
+ ]);
265
+ // Phase-1: fork / in-session branch / mainline switch UI deferred; API routes remain.
266
+ const compactSession = useCallback(async () => {
267
+ const current = refs.sessionRef.current;
268
+ if (!current || refs.streamingRef.current)
269
+ return;
270
+ try {
271
+ const response = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(current.sessionId)}/compact`, {
272
+ method: "POST",
273
+ credentials: "include",
274
+ headers: { "x-loop-console-confirmation": confirmationToken() },
275
+ });
276
+ const body = (await response.json().catch(() => ({})));
277
+ if (!response.ok) {
278
+ setError(body.error?.message ?? "上下文压缩失败");
279
+ return;
280
+ }
281
+ setError(null);
282
+ }
283
+ catch (error) {
284
+ setError(error instanceof Error ? error.message : "上下文压缩失败");
285
+ }
286
+ }, [origin, refs, setError]);
287
+ const updateSession = async (sessionId, patch) => {
288
+ const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${encodeURIComponent(sessionId)}`, {
289
+ method: "PATCH",
290
+ credentials: "include",
291
+ headers: {
292
+ "content-type": "application/json",
293
+ "x-loop-console-confirmation": confirmationToken(),
294
+ },
295
+ body: JSON.stringify(patch),
296
+ });
297
+ if (!res.ok) {
298
+ setError("会话更新失败");
299
+ return;
300
+ }
301
+ await refreshSessionList();
302
+ if (patch.state === "archived" &&
303
+ refs.sessionRef.current?.sessionId === sessionId) {
304
+ sessionSelectionVersionRef.current += 1;
305
+ refs.sessionRef.current = null;
306
+ resetActiveProjection();
307
+ setSession(null);
308
+ window.localStorage.removeItem(ACTIVE_CHAT_SESSION_STORAGE_KEY);
309
+ }
310
+ };
311
+ return {
312
+ sessionList,
313
+ activateSession,
314
+ refreshSessionList,
315
+ deleteSession,
316
+ createSession,
317
+ updateSession,
318
+ compactSession,
319
+ };
320
+ }
@@ -0,0 +1,209 @@
1
+ import { useCallback, useEffect } from "react";
2
+ import { formatOperatorUserError } from "../../operator-user-error.js";
3
+ import { confirmationToken, requestId, sleep } from "./format.js";
4
+ /** Inline prompt SSE + background reconnect GET /events stream (M1-T04/T05). */
5
+ export function useChatStream(params) {
6
+ const { origin, refs, session, thread, composer, setError, setAutoScroll } = params;
7
+ const { applySseBlock, setMessages, setStreaming, setStreamingAssistantId, streaming, } = thread;
8
+ const { setInput, setPendingImages } = composer;
9
+ const sendPrompt = useCallback(async (text, images) => {
10
+ // Read from refs (the source of truth) so callers that captured a
11
+ // stale closure — notably suggestion chips chaining
12
+ // createSession().then(sendPrompt) — still see the freshly created
13
+ // session rather than the null from an earlier render.
14
+ const session = refs.sessionRef.current;
15
+ if (!session ||
16
+ (!text.trim() && images.length === 0) ||
17
+ refs.streamingRef.current)
18
+ return;
19
+ const userMsg = {
20
+ id: requestId("u"),
21
+ role: "user",
22
+ text: text.trim(),
23
+ createdAt: Date.now(),
24
+ };
25
+ setMessages((m) => [...m, userMsg]);
26
+ setInput("");
27
+ // Stop the background replay stream before opening the inline SSE
28
+ // stream. Otherwise both connections can consume the same persisted
29
+ // event under different assistant ids and render duplicate bubbles.
30
+ refs.reconnectAbortRef.current?.abort();
31
+ refs.reconnectAbortRef.current = null;
32
+ refs.streamingRef.current = true;
33
+ setStreaming(true);
34
+ setAutoScroll(true);
35
+ setError(null);
36
+ const assistantId = requestId("a");
37
+ refs.assistantIdRef.current = assistantId;
38
+ setStreamingAssistantId(assistantId);
39
+ setMessages((m) => [
40
+ ...m,
41
+ { id: assistantId, role: "assistant", text: "", createdAt: Date.now() },
42
+ ]);
43
+ const ac = new AbortController();
44
+ refs.abortRef.current = ac;
45
+ refs.sessionAbortControllersRef.current.set(session.sessionId, ac);
46
+ refs.inlineStreamSessionIdsRef.current.add(session.sessionId);
47
+ try {
48
+ const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${session.sessionId}/prompt`, {
49
+ method: "POST",
50
+ headers: {
51
+ "Content-Type": "application/json",
52
+ "x-loop-console-confirmation": confirmationToken(),
53
+ Accept: "text/event-stream",
54
+ },
55
+ credentials: "include",
56
+ body: JSON.stringify({
57
+ text: text.trim() || "请查看附件图片。",
58
+ images: images.map(({ type, mimeType, data }) => ({
59
+ type,
60
+ mimeType,
61
+ data,
62
+ })),
63
+ }),
64
+ signal: ac.signal,
65
+ });
66
+ if (!res.ok || !res.body) {
67
+ const body = (await res.json().catch(() => ({})));
68
+ throw new Error(body.error?.message ?? `HTTP ${res.status}`);
69
+ }
70
+ const reader = res.body.getReader();
71
+ const decoder = new TextDecoder();
72
+ let buffer = "";
73
+ for (;;) {
74
+ const { done, value } = await reader.read();
75
+ if (done)
76
+ break;
77
+ buffer += decoder.decode(value, { stream: true });
78
+ const events = buffer.split("\n\n");
79
+ buffer = events.pop() ?? "";
80
+ for (const block of events) {
81
+ const eid = applySseBlock(block, assistantId, {
82
+ targetSessionId: session.sessionId,
83
+ });
84
+ if (eid)
85
+ refs.lastEventIdRef.current = eid;
86
+ }
87
+ }
88
+ }
89
+ catch (e) {
90
+ if (e?.name === "AbortError") {
91
+ // user stopped; keep partial assistant text
92
+ }
93
+ else if (refs.sessionRef.current?.sessionId === session.sessionId) {
94
+ setError(formatOperatorUserError(e instanceof Error ? e.message : String(e)));
95
+ }
96
+ }
97
+ finally {
98
+ if (refs.sessionRef.current?.sessionId === session.sessionId) {
99
+ refs.streamingRef.current = false;
100
+ setStreaming(false);
101
+ setStreamingAssistantId(null);
102
+ refs.abortRef.current = null;
103
+ refs.assistantIdRef.current = null;
104
+ setPendingImages([]);
105
+ }
106
+ refs.inlineStreamSessionIdsRef.current.delete(session.sessionId);
107
+ refs.sessionAbortControllersRef.current.delete(session.sessionId);
108
+ }
109
+ }, [
110
+ origin,
111
+ refs,
112
+ applySseBlock,
113
+ setMessages,
114
+ setStreaming,
115
+ setStreamingAssistantId,
116
+ setInput,
117
+ setPendingImages,
118
+ setAutoScroll,
119
+ setError,
120
+ ]);
121
+ const handleStop = useCallback(() => {
122
+ refs.abortRef.current?.abort();
123
+ refs.streamingRef.current = false;
124
+ setStreaming(false);
125
+ setStreamingAssistantId(null);
126
+ }, [refs, setStreaming, setStreamingAssistantId]);
127
+ /** Open the reconnectable GET /events stream for a session and apply events
128
+ * until it closes, then reopen with the latest Last-Event-ID (M1-T04/T05).
129
+ *
130
+ * The stream is strictly a recovery path: it is stopped before an inline
131
+ * prompt SSE starts, then reopened after that prompt settles. This preserves
132
+ * one owner for an in-flight assistant bubble while still replaying the
133
+ * durable tail after refreshes and network blips. */
134
+ const openEventsStream = useCallback(async (sessionId, signal) => {
135
+ while (!signal.aborted) {
136
+ try {
137
+ const headers = {
138
+ Accept: "text/event-stream",
139
+ };
140
+ const last = refs.lastEventIdRef.current;
141
+ if (last)
142
+ headers["Last-Event-ID"] = last;
143
+ const res = await fetch(`${origin}/api/operator/v1/chat/sessions/${sessionId}/events`, { headers, credentials: "include", signal });
144
+ if (!res.ok || !res.body) {
145
+ // 404 = session gone; stop the loop quietly.
146
+ if (res.status === 404)
147
+ return;
148
+ // transient error: back off before retrying.
149
+ await sleep(2000, signal);
150
+ continue;
151
+ }
152
+ const reader = res.body.getReader();
153
+ const decoder = new TextDecoder();
154
+ let buffer = "";
155
+ // Reconnect assistant id: events arriving here belong to whichever
156
+ // turn is/was active; reuse the in-flight one or allocate a stable
157
+ // placeholder so streaming text patches a single bubble.
158
+ let rcAssistantId = refs.assistantIdRef.current;
159
+ if (!rcAssistantId) {
160
+ rcAssistantId = requestId("a");
161
+ refs.assistantIdRef.current = rcAssistantId;
162
+ }
163
+ for (;;) {
164
+ const { done, value } = await reader.read();
165
+ if (done)
166
+ break;
167
+ buffer += decoder.decode(value, { stream: true });
168
+ const blocks = buffer.split("\n\n");
169
+ buffer = blocks.pop() ?? "";
170
+ for (const block of blocks) {
171
+ const eid = applySseBlock(block, rcAssistantId, {
172
+ replay: true,
173
+ targetSessionId: sessionId,
174
+ });
175
+ if (eid)
176
+ refs.lastEventIdRef.current = eid;
177
+ }
178
+ }
179
+ // Server closed the follow window (idle). Reopen from the last id
180
+ // after a short pause unless we were aborted.
181
+ await sleep(1000, signal);
182
+ }
183
+ catch (e) {
184
+ if (signal.aborted)
185
+ return;
186
+ // Network blip: back off and retry (keeps reconnect resilient).
187
+ await sleep(2000, signal);
188
+ }
189
+ }
190
+ }, [origin, refs, applySseBlock]);
191
+ // Reconnect effect: open/refresh the events stream whenever the active
192
+ // session or streaming state changes. Aborts the previous stream on cleanup
193
+ // so switching sessions, starting an inline prompt, or unmounting does not
194
+ // leak a dangling connection. It resumes after settle/stop to catch the tail.
195
+ useEffect(() => {
196
+ if (!session ||
197
+ (streaming && refs.inlineStreamSessionIdsRef.current.has(session.sessionId)))
198
+ return;
199
+ const ac = new AbortController();
200
+ refs.reconnectAbortRef.current = ac;
201
+ void openEventsStream(session.sessionId, ac.signal);
202
+ return () => {
203
+ ac.abort();
204
+ if (refs.reconnectAbortRef.current === ac)
205
+ refs.reconnectAbortRef.current = null;
206
+ };
207
+ }, [session, streaming, openEventsStream, refs]);
208
+ return { sendPrompt, handleStop, openEventsStream };
209
+ }