@stigmer/react 3.0.8-dev.20260612122433 → 3.0.8-dev.20260613051837
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/execution/ExecutionProgress.d.ts.map +1 -1
- package/execution/ExecutionProgress.js +5 -1
- package/execution/ExecutionProgress.js.map +1 -1
- package/execution/MessageThread.d.ts +32 -3
- package/execution/MessageThread.d.ts.map +1 -1
- package/execution/MessageThread.js +59 -10
- package/execution/MessageThread.js.map +1 -1
- package/execution/useExecutionStream.d.ts +76 -5
- package/execution/useExecutionStream.d.ts.map +1 -1
- package/execution/useExecutionStream.js +166 -23
- package/execution/useExecutionStream.js.map +1 -1
- package/internal/VirtualizedThread.d.ts +3 -1
- package/internal/VirtualizedThread.d.ts.map +1 -1
- package/internal/VirtualizedThread.js +4 -2
- package/internal/VirtualizedThread.js.map +1 -1
- package/internal/backoff.d.ts +61 -0
- package/internal/backoff.d.ts.map +1 -0
- package/internal/backoff.js +79 -0
- package/internal/backoff.js.map +1 -0
- package/internal/store/conversation-store.d.ts +34 -0
- package/internal/store/conversation-store.d.ts.map +1 -1
- package/internal/store/conversation-store.js +50 -2
- package/internal/store/conversation-store.js.map +1 -1
- package/internal/store/workflow-execution-event-store.d.ts +12 -0
- package/internal/store/workflow-execution-event-store.d.ts.map +1 -1
- package/internal/store/workflow-execution-event-store.js +7 -0
- package/internal/store/workflow-execution-event-store.js.map +1 -1
- package/internal/stream-controller.d.ts +57 -21
- package/internal/stream-controller.d.ts.map +1 -1
- package/internal/stream-controller.js +117 -3
- package/internal/stream-controller.js.map +1 -1
- package/internal/useFetch.d.ts +7 -0
- package/internal/useFetch.d.ts.map +1 -1
- package/internal/useFetch.js +21 -0
- package/internal/useFetch.js.map +1 -1
- package/package.json +4 -4
- package/session/SessionViewer.js +26 -1
- package/session/SessionViewer.js.map +1 -1
- package/session/useSessionConversation.d.ts +41 -4
- package/session/useSessionConversation.d.ts.map +1 -1
- package/session/useSessionConversation.js +74 -10
- package/session/useSessionConversation.js.map +1 -1
- package/session/useSessionExecutions.d.ts +17 -1
- package/session/useSessionExecutions.d.ts.map +1 -1
- package/session/useSessionExecutions.js +6 -2
- package/session/useSessionExecutions.js.map +1 -1
- package/src/execution/ExecutionProgress.tsx +12 -0
- package/src/execution/MessageThread.tsx +174 -5
- package/src/execution/__tests__/MessageThread.test.tsx +64 -0
- package/src/execution/__tests__/useExecutionStream.test.tsx +279 -0
- package/src/execution/useExecutionStream.ts +254 -34
- package/src/internal/VirtualizedThread.tsx +7 -1
- package/src/internal/__tests__/backoff.test.ts +99 -0
- package/src/internal/__tests__/stream-controller.test.ts +165 -10
- package/src/internal/__tests__/useFetch.test.tsx +59 -0
- package/src/internal/backoff.ts +100 -0
- package/src/internal/store/__tests__/conversation-store.test.ts +61 -0
- package/src/internal/store/conversation-store.ts +68 -3
- package/src/internal/store/workflow-execution-event-store.ts +22 -0
- package/src/internal/stream-controller.ts +151 -26
- package/src/internal/useFetch.ts +26 -0
- package/src/session/SessionViewer.tsx +89 -0
- package/src/session/__tests__/useSessionConversation.test.tsx +53 -0
- package/src/session/useSessionConversation.ts +121 -15
- package/src/session/useSessionExecutions.ts +23 -1
- package/src/workflow/WorkflowExecutionHeader.tsx +4 -1
- package/src/workflow/WorkflowExecutionTimeline.tsx +2 -1
- package/src/workflow/__tests__/useWorkflowExecutionEventStream.test.tsx +117 -1
- package/src/workflow/execution/useWaterfallEntries.ts +2 -1
- package/src/workflow/useWorkflowExecutionEventStream.ts +122 -41
- package/src/workflow/waterfall/WaterfallTimeline.tsx +2 -1
- package/styles.css +1 -1
- package/workflow/WorkflowExecutionHeader.d.ts.map +1 -1
- package/workflow/WorkflowExecutionHeader.js +3 -1
- package/workflow/WorkflowExecutionHeader.js.map +1 -1
- package/workflow/WorkflowExecutionTimeline.d.ts.map +1 -1
- package/workflow/WorkflowExecutionTimeline.js +1 -1
- package/workflow/WorkflowExecutionTimeline.js.map +1 -1
- package/workflow/execution/useWaterfallEntries.d.ts.map +1 -1
- package/workflow/execution/useWaterfallEntries.js +1 -1
- package/workflow/execution/useWaterfallEntries.js.map +1 -1
- package/workflow/useWorkflowExecutionEventStream.d.ts +32 -4
- package/workflow/useWorkflowExecutionEventStream.d.ts.map +1 -1
- package/workflow/useWorkflowExecutionEventStream.js +75 -32
- package/workflow/useWorkflowExecutionEventStream.js.map +1 -1
- package/workflow/waterfall/WaterfallTimeline.d.ts.map +1 -1
- package/workflow/waterfall/WaterfallTimeline.js +1 -1
- package/workflow/waterfall/WaterfallTimeline.js.map +1 -1
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { AgentExecution } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
|
|
2
|
-
import type {
|
|
2
|
+
import type { StreamState } from "./store/conversation-store";
|
|
3
3
|
import { isTerminalPhase } from "../execution/execution-phases";
|
|
4
4
|
import { ExecutionPhase } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
|
|
5
5
|
|
|
@@ -7,25 +7,26 @@ import { ExecutionPhase } from "@stigmer/protos/ai/stigmer/agentic/agentexecutio
|
|
|
7
7
|
// Types
|
|
8
8
|
// ---------------------------------------------------------------------------
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
10
|
+
// The controller's FSM state is exactly the store's `StreamState` — they
|
|
11
|
+
// were once duplicated unions kept in lock-step by hand. The controller
|
|
12
|
+
// reuses the store's type so the lifecycle (including the `reconnecting`
|
|
13
|
+
// stage) is defined in one place and can never drift.
|
|
14
|
+
|
|
15
|
+
const IDLE: StreamState = { stage: "idle" };
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Hard connect-timeout: time the stream may stay `connecting` (no first
|
|
19
|
+
* snapshot) before the watchdog acts. Sized well above a healthy connect
|
|
20
|
+
* (the server sends the initial snapshot in ~1 RTT) yet short enough that a
|
|
21
|
+
* silent hang surfaces an affordance within a few seconds, not minutes.
|
|
22
|
+
*/
|
|
23
|
+
export const DEFAULT_CONNECT_TIMEOUT_MS = 10_000;
|
|
24
|
+
/**
|
|
25
|
+
* Soft slow-stall threshold: a non-terminal stream that produces no new
|
|
26
|
+
* snapshot for this long flips an informational "still working" hint. Long
|
|
27
|
+
* enough that ordinary model thinking-time never trips it.
|
|
28
|
+
*/
|
|
29
|
+
export const DEFAULT_SLOW_THRESHOLD_MS = 60_000;
|
|
29
30
|
|
|
30
31
|
/**
|
|
31
32
|
* Callback interface for the stream controller to communicate with
|
|
@@ -36,7 +37,28 @@ export interface StreamControllerSink {
|
|
|
36
37
|
/** Ingest a snapshot into the store (applies structural sharing). */
|
|
37
38
|
ingestSnapshot(snapshot: AgentExecution): void;
|
|
38
39
|
/** Transition the store's stream lifecycle state. */
|
|
39
|
-
setStreamState(state:
|
|
40
|
+
setStreamState(state: StreamState): void;
|
|
41
|
+
/**
|
|
42
|
+
* The hard connect-timeout elapsed while still `connecting`. The host owns
|
|
43
|
+
* the self-heal-once-then-surface policy (it holds the `connectKey` reconnect
|
|
44
|
+
* counter and the per-subscription guard that survives a reconnect, which
|
|
45
|
+
* the controller — reset on every teardown — cannot).
|
|
46
|
+
*/
|
|
47
|
+
onConnectTimeout(): void;
|
|
48
|
+
/** Set or clear the soft slow-stall hint signal. */
|
|
49
|
+
setSlow(value: boolean): void;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Injectable watchdog configuration. Timers default to `setTimeout`/
|
|
54
|
+
* `clearTimeout`; tests pass fakes so the watchdog is exercised without real
|
|
55
|
+
* time. Mirrors the existing `scheduleFlush`/`cancelFlush` injection idiom.
|
|
56
|
+
*/
|
|
57
|
+
export interface StreamControllerWatchdog {
|
|
58
|
+
readonly setTimer?: (cb: () => void, ms: number) => number;
|
|
59
|
+
readonly clearTimer?: (id: number) => void;
|
|
60
|
+
readonly connectTimeoutMs?: number;
|
|
61
|
+
readonly slowThresholdMs?: number;
|
|
40
62
|
}
|
|
41
63
|
|
|
42
64
|
// ---------------------------------------------------------------------------
|
|
@@ -58,13 +80,21 @@ export interface StreamControllerSink {
|
|
|
58
80
|
* (typically `requestAnimationFrame`).
|
|
59
81
|
*/
|
|
60
82
|
export class StreamController {
|
|
61
|
-
private _state:
|
|
83
|
+
private _state: StreamState = IDLE;
|
|
62
84
|
private _bufferedSnapshot: AgentExecution | null = null;
|
|
63
85
|
private _rafId: number | null = null;
|
|
64
86
|
private _sink: StreamControllerSink;
|
|
65
87
|
private _scheduleFlush: (cb: () => void) => number;
|
|
66
88
|
private _cancelFlush: (id: number) => void;
|
|
67
89
|
|
|
90
|
+
// -- Watchdog ------------------------------------------------------------
|
|
91
|
+
private _setTimer: (cb: () => void, ms: number) => number;
|
|
92
|
+
private _clearTimer: (id: number) => void;
|
|
93
|
+
private _connectTimeoutMs: number;
|
|
94
|
+
private _slowThresholdMs: number;
|
|
95
|
+
private _connectTimerId: number | null = null;
|
|
96
|
+
private _slowTimerId: number | null = null;
|
|
97
|
+
|
|
68
98
|
constructor(
|
|
69
99
|
sink: StreamControllerSink,
|
|
70
100
|
scheduleFlush: (cb: () => void) => number = typeof requestAnimationFrame !== "undefined"
|
|
@@ -73,14 +103,23 @@ export class StreamController {
|
|
|
73
103
|
cancelFlush: (id: number) => void = typeof cancelAnimationFrame !== "undefined"
|
|
74
104
|
? (id: number) => cancelAnimationFrame(id)
|
|
75
105
|
: (id: number) => clearTimeout(id),
|
|
106
|
+
watchdog?: StreamControllerWatchdog,
|
|
76
107
|
) {
|
|
77
108
|
this._sink = sink;
|
|
78
109
|
this._scheduleFlush = scheduleFlush;
|
|
79
110
|
this._cancelFlush = cancelFlush;
|
|
111
|
+
this._setTimer =
|
|
112
|
+
watchdog?.setTimer ??
|
|
113
|
+
((cb, ms) => setTimeout(cb, ms) as unknown as number);
|
|
114
|
+
this._clearTimer = watchdog?.clearTimer ?? ((id) => clearTimeout(id));
|
|
115
|
+
this._connectTimeoutMs =
|
|
116
|
+
watchdog?.connectTimeoutMs ?? DEFAULT_CONNECT_TIMEOUT_MS;
|
|
117
|
+
this._slowThresholdMs =
|
|
118
|
+
watchdog?.slowThresholdMs ?? DEFAULT_SLOW_THRESHOLD_MS;
|
|
80
119
|
}
|
|
81
120
|
|
|
82
121
|
/** Current FSM state (read-only). */
|
|
83
|
-
get state():
|
|
122
|
+
get state(): StreamState {
|
|
84
123
|
return this._state;
|
|
85
124
|
}
|
|
86
125
|
|
|
@@ -91,6 +130,11 @@ export class StreamController {
|
|
|
91
130
|
start(executionId: string): void {
|
|
92
131
|
this._cancelPendingFlush();
|
|
93
132
|
this._bufferedSnapshot = null;
|
|
133
|
+
// Arm both watchdogs the moment we begin connecting. The connect-timeout
|
|
134
|
+
// guards the first snapshot; the slow-stall hint guards every quiet stretch
|
|
135
|
+
// thereafter. Both are reset by the next snapshot and cleared on any exit.
|
|
136
|
+
this._armConnectTimer();
|
|
137
|
+
this._armSlowTimer();
|
|
94
138
|
this._transition({ stage: "connecting", executionId });
|
|
95
139
|
}
|
|
96
140
|
|
|
@@ -108,12 +152,23 @@ export class StreamController {
|
|
|
108
152
|
const terminal = isTerminalPhase(phase);
|
|
109
153
|
|
|
110
154
|
if (terminal) {
|
|
155
|
+
this._cancelWatchdogs();
|
|
156
|
+
this._sink.setSlow(false);
|
|
111
157
|
this._cancelPendingFlush();
|
|
112
158
|
this._bufferedSnapshot = null;
|
|
113
159
|
this._sink.ingestSnapshot(snapshot);
|
|
114
160
|
this._transition({ stage: "complete", executionId });
|
|
115
161
|
} else {
|
|
116
|
-
|
|
162
|
+
// A snapshot proves the (re)connection is healthy: the connect-timeout no
|
|
163
|
+
// longer applies, and the slow-stall window restarts from now.
|
|
164
|
+
this._cancelConnectTimer();
|
|
165
|
+
this._armSlowTimer();
|
|
166
|
+
// Advance from either the initial `connecting` or a `reconnecting` retry
|
|
167
|
+
// into `streaming`.
|
|
168
|
+
if (
|
|
169
|
+
this._state.stage === "connecting" ||
|
|
170
|
+
this._state.stage === "reconnecting"
|
|
171
|
+
) {
|
|
117
172
|
this._transition({ stage: "streaming", executionId });
|
|
118
173
|
}
|
|
119
174
|
this._bufferedSnapshot = snapshot;
|
|
@@ -121,6 +176,25 @@ export class StreamController {
|
|
|
121
176
|
}
|
|
122
177
|
}
|
|
123
178
|
|
|
179
|
+
/**
|
|
180
|
+
* Enter the `reconnecting` stage after a transient drop. Unlike
|
|
181
|
+
* {@link start}, this preserves the buffered snapshot and never resets the
|
|
182
|
+
* store, so the last-known-good conversation stays on screen while the
|
|
183
|
+
* background retry is in flight. No-op once idle (the subscription is
|
|
184
|
+
* already torn down).
|
|
185
|
+
*/
|
|
186
|
+
handleReconnecting(attempt: number, error: Error): void {
|
|
187
|
+
const executionId = this._activeExecutionId();
|
|
188
|
+
if (!executionId) return;
|
|
189
|
+
// Auto-reconnect (#174) has taken over with its own visible affordance and
|
|
190
|
+
// bounded attempt budget, so the silence watchdogs stand down — they exist
|
|
191
|
+
// for the case where the stream stalls *without* a drop. The slow hint is
|
|
192
|
+
// cleared so "reconnecting" and "slow" never show at once.
|
|
193
|
+
this._cancelWatchdogs();
|
|
194
|
+
this._sink.setSlow(false);
|
|
195
|
+
this._transition({ stage: "reconnecting", executionId, attempt, error });
|
|
196
|
+
}
|
|
197
|
+
|
|
124
198
|
/**
|
|
125
199
|
* Handle stream completion (iterator exhausted without error).
|
|
126
200
|
* If we still have a buffered snapshot, flush it first.
|
|
@@ -129,6 +203,8 @@ export class StreamController {
|
|
|
129
203
|
const executionId = this._activeExecutionId();
|
|
130
204
|
if (!executionId) return;
|
|
131
205
|
|
|
206
|
+
this._cancelWatchdogs();
|
|
207
|
+
this._sink.setSlow(false);
|
|
132
208
|
this._flushBuffer();
|
|
133
209
|
if (this._state.stage !== "complete") {
|
|
134
210
|
this._transition({ stage: "complete", executionId });
|
|
@@ -143,13 +219,17 @@ export class StreamController {
|
|
|
143
219
|
const executionId = this._activeExecutionId();
|
|
144
220
|
if (!executionId) return;
|
|
145
221
|
|
|
222
|
+
this._cancelWatchdogs();
|
|
223
|
+
this._sink.setSlow(false);
|
|
146
224
|
this._cancelPendingFlush();
|
|
147
225
|
this._flushBuffer();
|
|
148
226
|
this._transition({ stage: "error", executionId, error });
|
|
149
227
|
}
|
|
150
228
|
|
|
151
|
-
/** Reset to idle. Cancels any pending flush. */
|
|
229
|
+
/** Reset to idle. Cancels any pending flush and watchdog timers. */
|
|
152
230
|
reset(): void {
|
|
231
|
+
this._cancelWatchdogs();
|
|
232
|
+
this._sink.setSlow(false);
|
|
153
233
|
this._cancelPendingFlush();
|
|
154
234
|
this._bufferedSnapshot = null;
|
|
155
235
|
if (this._state.stage !== "idle") {
|
|
@@ -171,7 +251,7 @@ export class StreamController {
|
|
|
171
251
|
return this._state.executionId;
|
|
172
252
|
}
|
|
173
253
|
|
|
174
|
-
private _transition(next:
|
|
254
|
+
private _transition(next: StreamState): void {
|
|
175
255
|
this._state = next;
|
|
176
256
|
this._sink.setStreamState(next);
|
|
177
257
|
}
|
|
@@ -198,4 +278,49 @@ export class StreamController {
|
|
|
198
278
|
this._rafId = null;
|
|
199
279
|
}
|
|
200
280
|
}
|
|
281
|
+
|
|
282
|
+
// -- Watchdog timers ------------------------------------------------------
|
|
283
|
+
|
|
284
|
+
private _armConnectTimer(): void {
|
|
285
|
+
this._cancelConnectTimer();
|
|
286
|
+
this._connectTimerId = this._setTimer(() => {
|
|
287
|
+
this._connectTimerId = null;
|
|
288
|
+
// Only meaningful while still awaiting the first snapshot; any later
|
|
289
|
+
// stage has its own handling and has already cleared this timer.
|
|
290
|
+
if (this._state.stage !== "connecting") return;
|
|
291
|
+
this._sink.onConnectTimeout();
|
|
292
|
+
}, this._connectTimeoutMs);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private _armSlowTimer(): void {
|
|
296
|
+
this._cancelSlowTimer();
|
|
297
|
+
// Re-arming means activity just resumed (or is starting), so a prior slow
|
|
298
|
+
// hint no longer holds — clear it before counting the next quiet stretch.
|
|
299
|
+
this._sink.setSlow(false);
|
|
300
|
+
this._slowTimerId = this._setTimer(() => {
|
|
301
|
+
this._slowTimerId = null;
|
|
302
|
+
if (this._state.stage !== "connecting" && this._state.stage !== "streaming")
|
|
303
|
+
return;
|
|
304
|
+
this._sink.setSlow(true);
|
|
305
|
+
}, this._slowThresholdMs);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
private _cancelConnectTimer(): void {
|
|
309
|
+
if (this._connectTimerId !== null) {
|
|
310
|
+
this._clearTimer(this._connectTimerId);
|
|
311
|
+
this._connectTimerId = null;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
private _cancelSlowTimer(): void {
|
|
316
|
+
if (this._slowTimerId !== null) {
|
|
317
|
+
this._clearTimer(this._slowTimerId);
|
|
318
|
+
this._slowTimerId = null;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
private _cancelWatchdogs(): void {
|
|
323
|
+
this._cancelConnectTimer();
|
|
324
|
+
this._cancelSlowTimer();
|
|
325
|
+
}
|
|
201
326
|
}
|
package/src/internal/useFetch.ts
CHANGED
|
@@ -15,6 +15,14 @@ export interface UseFetchOptions {
|
|
|
15
15
|
*/
|
|
16
16
|
readonly refetchInterval?: number | false;
|
|
17
17
|
|
|
18
|
+
/**
|
|
19
|
+
* Re-fetch when the window regains focus or the tab becomes visible
|
|
20
|
+
* again. Covers the app-relaunch / tab-switch case where data may have
|
|
21
|
+
* gone stale while the app was backgrounded. A focus refetch is skipped
|
|
22
|
+
* while a fetch is already in flight. Defaults to `false`.
|
|
23
|
+
*/
|
|
24
|
+
readonly refetchOnWindowFocus?: boolean;
|
|
25
|
+
|
|
18
26
|
/**
|
|
19
27
|
* Stable string key for cross-mount caching.
|
|
20
28
|
*
|
|
@@ -183,6 +191,24 @@ export function useFetch<T>(
|
|
|
183
191
|
return () => clearInterval(id);
|
|
184
192
|
}, [refetchInterval, fetchFn, refetch]);
|
|
185
193
|
|
|
194
|
+
const refetchOnWindowFocus = options?.refetchOnWindowFocus;
|
|
195
|
+
useEffect(() => {
|
|
196
|
+
if (!refetchOnWindowFocus || !fetchFn) return;
|
|
197
|
+
if (typeof window === "undefined") return;
|
|
198
|
+
const onActive = () => {
|
|
199
|
+
if (!isFetchingRef.current) refetch();
|
|
200
|
+
};
|
|
201
|
+
const onVisible = () => {
|
|
202
|
+
if (document.visibilityState === "visible") onActive();
|
|
203
|
+
};
|
|
204
|
+
window.addEventListener("focus", onActive);
|
|
205
|
+
document.addEventListener("visibilitychange", onVisible);
|
|
206
|
+
return () => {
|
|
207
|
+
window.removeEventListener("focus", onActive);
|
|
208
|
+
document.removeEventListener("visibilitychange", onVisible);
|
|
209
|
+
};
|
|
210
|
+
}, [refetchOnWindowFocus, fetchFn, refetch]);
|
|
211
|
+
|
|
186
212
|
const isLoading = isFetching && !hasDataRef.current;
|
|
187
213
|
const isRefetching = isFetching && hasDataRef.current;
|
|
188
214
|
|
|
@@ -290,12 +290,24 @@ function ConversationColumn({
|
|
|
290
290
|
const { conv } = flow;
|
|
291
291
|
const sendError = flow.submitError ?? conv.sendError ?? conv.approvalError;
|
|
292
292
|
|
|
293
|
+
// Retry a terminal-failed execution by resending its originating message
|
|
294
|
+
// through the full submit pipeline (agent override, runtime-env, workspace).
|
|
295
|
+
const onRetryExecution = useCallback(
|
|
296
|
+
(message: string) => {
|
|
297
|
+
void flow.handleSubmit(message);
|
|
298
|
+
},
|
|
299
|
+
[flow.handleSubmit],
|
|
300
|
+
);
|
|
301
|
+
|
|
293
302
|
return (
|
|
294
303
|
<div className="flex h-full min-w-0 flex-col">
|
|
295
304
|
<MessageThread
|
|
296
305
|
executions={conv.completedExecutions}
|
|
297
306
|
activeStreamExecution={conv.activeStreamExecution}
|
|
298
307
|
pendingUserMessage={conv.pendingUserMessage}
|
|
308
|
+
pendingMessageFailed={!!conv.sendError && !!conv.pendingUserMessage}
|
|
309
|
+
onRetrySend={conv.retryLastSend}
|
|
310
|
+
onRetryExecution={onRetryExecution}
|
|
299
311
|
onApprovalSubmit={flow.submitApproval}
|
|
300
312
|
submittingApprovalIds={conv.submittingApprovalIds}
|
|
301
313
|
workspaceEntries={conv.workspaceEntries}
|
|
@@ -307,6 +319,11 @@ function ConversationColumn({
|
|
|
307
319
|
className="flex-1"
|
|
308
320
|
/>
|
|
309
321
|
<div className="mx-auto w-full max-w-3xl">
|
|
322
|
+
{conv.isReconnecting && <ReconnectingIndicator />}
|
|
323
|
+
{conv.connectTimedOut && (
|
|
324
|
+
<ConnectTimedOutBanner onRetry={conv.reconnectStream} />
|
|
325
|
+
)}
|
|
326
|
+
{conv.isSlow && !conv.connectTimedOut && <SlowIndicator />}
|
|
310
327
|
{conv.streamError && (
|
|
311
328
|
<StreamErrorBanner
|
|
312
329
|
error={conv.streamError}
|
|
@@ -546,6 +563,78 @@ function SendErrorBanner({ error }: { error: Error }) {
|
|
|
546
563
|
);
|
|
547
564
|
}
|
|
548
565
|
|
|
566
|
+
function ReconnectingIndicator() {
|
|
567
|
+
return (
|
|
568
|
+
<div
|
|
569
|
+
role="status"
|
|
570
|
+
aria-live="polite"
|
|
571
|
+
className="flex items-center gap-2 border-t border-border bg-muted px-4 py-2 text-sm text-muted-foreground"
|
|
572
|
+
>
|
|
573
|
+
<svg
|
|
574
|
+
width="14"
|
|
575
|
+
height="14"
|
|
576
|
+
viewBox="0 0 24 24"
|
|
577
|
+
fill="none"
|
|
578
|
+
stroke="currentColor"
|
|
579
|
+
strokeWidth="2"
|
|
580
|
+
strokeLinecap="round"
|
|
581
|
+
strokeLinejoin="round"
|
|
582
|
+
className="shrink-0 animate-spin motion-reduce:animate-none"
|
|
583
|
+
aria-hidden="true"
|
|
584
|
+
>
|
|
585
|
+
<path d="M21 12a9 9 0 1 1-6.219-8.56" />
|
|
586
|
+
</svg>
|
|
587
|
+
<span className="truncate">Reconnecting…</span>
|
|
588
|
+
</div>
|
|
589
|
+
);
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
/**
|
|
593
|
+
* Actionable banner for the connect-timeout watchdog: the stream opened but
|
|
594
|
+
* never delivered a first snapshot — the agent hasn't started. Mirrors
|
|
595
|
+
* {@link StreamErrorBanner}'s shape (message + Retry) since both resolve via
|
|
596
|
+
* the same `reconnect()` path, but its copy names the specific failure.
|
|
597
|
+
*/
|
|
598
|
+
function ConnectTimedOutBanner({ onRetry }: { onRetry: () => void }) {
|
|
599
|
+
return (
|
|
600
|
+
<div role="alert" className="flex items-center gap-3 border-t border-border bg-muted px-4 py-2.5">
|
|
601
|
+
<p className="min-w-0 flex-1 truncate text-sm text-muted-foreground">
|
|
602
|
+
The agent hasn’t started yet.
|
|
603
|
+
</p>
|
|
604
|
+
<button
|
|
605
|
+
type="button"
|
|
606
|
+
onClick={onRetry}
|
|
607
|
+
className="inline-flex shrink-0 items-center gap-1.5 rounded-md border border-border px-2 py-1 text-xs font-medium hover:bg-card"
|
|
608
|
+
>
|
|
609
|
+
Retry
|
|
610
|
+
</button>
|
|
611
|
+
</div>
|
|
612
|
+
);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Low-weight informational hint for the slow-stall watchdog: a live stream has
|
|
617
|
+
* gone quiet longer than usual. Never an error and offers no action — the
|
|
618
|
+
* stream is still expected to resume on its own.
|
|
619
|
+
*/
|
|
620
|
+
function SlowIndicator() {
|
|
621
|
+
return (
|
|
622
|
+
<div
|
|
623
|
+
role="status"
|
|
624
|
+
aria-live="polite"
|
|
625
|
+
className="flex items-center gap-2 border-t border-border-muted px-4 py-1.5 text-xs text-muted-foreground"
|
|
626
|
+
>
|
|
627
|
+
<span className="relative flex h-2 w-2 shrink-0" aria-hidden="true">
|
|
628
|
+
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-muted-foreground opacity-75 motion-reduce:animate-none" />
|
|
629
|
+
<span className="relative inline-flex h-2 w-2 rounded-full bg-muted-foreground" />
|
|
630
|
+
</span>
|
|
631
|
+
<span className="min-w-0 flex-1 truncate">
|
|
632
|
+
Still working — this is taking longer than usual.
|
|
633
|
+
</span>
|
|
634
|
+
</div>
|
|
635
|
+
);
|
|
636
|
+
}
|
|
637
|
+
|
|
549
638
|
function StreamErrorBanner({
|
|
550
639
|
error,
|
|
551
640
|
onReconnect,
|
|
@@ -306,6 +306,59 @@ describe("useSessionConversation", () => {
|
|
|
306
306
|
});
|
|
307
307
|
});
|
|
308
308
|
|
|
309
|
+
it("preserves the message and surfaces sendError when the send fails", async () => {
|
|
310
|
+
methods.listBySession.mockResolvedValue({ entries: [] });
|
|
311
|
+
methods.executionCreate.mockRejectedValue(new Error("create boom"));
|
|
312
|
+
|
|
313
|
+
const { result } = renderHook(
|
|
314
|
+
() => useSessionConversation("session-1", "org"),
|
|
315
|
+
{ wrapper: createWrapper(mockStigmer) },
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
319
|
+
|
|
320
|
+
await act(async () => {
|
|
321
|
+
await result.current.sendFollowUp("keep me");
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
// The failure is surfaced and the user's text is NOT lost.
|
|
325
|
+
expect(result.current.sendError?.message).toBe("create boom");
|
|
326
|
+
expect(result.current.pendingUserMessage).toBe("keep me");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
it("retryLastSend resubmits the same message after a failure", async () => {
|
|
330
|
+
methods.listBySession.mockResolvedValue({ entries: [] });
|
|
331
|
+
methods.executionCreate.mockRejectedValueOnce(new Error("boom"));
|
|
332
|
+
const retried = makeExecution("e-retry", ExecutionPhase.EXECUTION_PENDING);
|
|
333
|
+
retried.metadata!.id = "e-retry";
|
|
334
|
+
methods.executionCreate.mockResolvedValue(retried);
|
|
335
|
+
|
|
336
|
+
const { result } = renderHook(
|
|
337
|
+
() => useSessionConversation("session-1", "org"),
|
|
338
|
+
{ wrapper: createWrapper(mockStigmer) },
|
|
339
|
+
);
|
|
340
|
+
|
|
341
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
342
|
+
|
|
343
|
+
await act(async () => {
|
|
344
|
+
await result.current.sendFollowUp("retry me");
|
|
345
|
+
});
|
|
346
|
+
expect(result.current.sendError).not.toBeNull();
|
|
347
|
+
|
|
348
|
+
await act(async () => {
|
|
349
|
+
result.current.retryLastSend();
|
|
350
|
+
});
|
|
351
|
+
|
|
352
|
+
await waitFor(() =>
|
|
353
|
+
expect(methods.executionCreate).toHaveBeenCalledTimes(2),
|
|
354
|
+
);
|
|
355
|
+
expect(methods.executionCreate).toHaveBeenLastCalledWith(
|
|
356
|
+
expect.objectContaining({ message: "retry me" }),
|
|
357
|
+
);
|
|
358
|
+
// The retry cleared the prior failure for the new attempt.
|
|
359
|
+
expect(result.current.sendError).toBeNull();
|
|
360
|
+
});
|
|
361
|
+
|
|
309
362
|
it("full follow-up lifecycle: active execution completes → canSendFollowUp → sendFollowUp succeeds", async () => {
|
|
310
363
|
// Start with one IN_PROGRESS execution (simulates a Cursor execution running)
|
|
311
364
|
const activeExec = makeExecution("exec-1", ExecutionPhase.EXECUTION_IN_PROGRESS);
|