@stigmer/react 3.0.8-dev.20260613041848 → 3.0.8-dev.20260613071809
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/composer/ComposerToolbar.d.ts +9 -1
- package/composer/ComposerToolbar.d.ts.map +1 -1
- package/composer/ComposerToolbar.js +3 -3
- package/composer/ComposerToolbar.js.map +1 -1
- package/composer/SessionComposer.d.ts +12 -0
- package/composer/SessionComposer.d.ts.map +1 -1
- package/composer/SessionComposer.js +6 -3
- package/composer/SessionComposer.js.map +1 -1
- package/composer/icons.d.ts +2 -0
- package/composer/icons.d.ts.map +1 -1
- package/composer/icons.js +4 -0
- package/composer/icons.js.map +1 -1
- package/execution/ExecutionProgress.d.ts.map +1 -1
- package/execution/ExecutionProgress.js +5 -1
- package/execution/ExecutionProgress.js.map +1 -1
- package/execution/MessageEntry.d.ts +7 -0
- package/execution/MessageEntry.d.ts.map +1 -1
- package/execution/MessageEntry.js +7 -4
- package/execution/MessageEntry.js.map +1 -1
- package/execution/MessageThread.d.ts +45 -3
- package/execution/MessageThread.d.ts.map +1 -1
- package/execution/MessageThread.js +66 -11
- package/execution/MessageThread.js.map +1 -1
- package/execution/index.d.ts +2 -0
- package/execution/index.d.ts.map +1 -1
- package/execution/index.js +1 -0
- package/execution/index.js.map +1 -1
- package/execution/useAgentExecutionActions.d.ts +67 -0
- package/execution/useAgentExecutionActions.d.ts.map +1 -0
- package/execution/useAgentExecutionActions.js +105 -0
- package/execution/useAgentExecutionActions.js.map +1 -0
- package/execution/useExecutionStream.d.ts +27 -0
- package/execution/useExecutionStream.d.ts.map +1 -1
- package/execution/useExecutionStream.js +48 -5
- package/execution/useExecutionStream.js.map +1 -1
- package/index.d.ts +2 -2
- package/index.d.ts.map +1 -1
- package/index.js +1 -1
- package/index.js.map +1 -1
- package/internal/VirtualizedThread.d.ts +4 -1
- package/internal/VirtualizedThread.d.ts.map +1 -1
- package/internal/VirtualizedThread.js +5 -2
- package/internal/VirtualizedThread.js.map +1 -1
- package/internal/store/conversation-store.d.ts +22 -0
- package/internal/store/conversation-store.d.ts.map +1 -1
- package/internal/store/conversation-store.js +43 -2
- package/internal/store/conversation-store.js.map +1 -1
- package/internal/stream-controller.d.ts +46 -2
- package/internal/stream-controller.d.ts.map +1 -1
- package/internal/stream-controller.js +95 -4
- 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 +39 -2
- package/session/SessionViewer.js.map +1 -1
- package/session/useSessionConversation.d.ts +55 -3
- package/session/useSessionConversation.d.ts.map +1 -1
- package/session/useSessionConversation.js +95 -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/composer/ComposerToolbar.tsx +32 -9
- package/src/composer/SessionComposer.tsx +22 -1
- package/src/composer/__tests__/SessionComposer-stop.test.tsx +98 -0
- package/src/composer/icons.tsx +15 -0
- package/src/execution/ExecutionProgress.tsx +12 -0
- package/src/execution/MessageEntry.tsx +57 -2
- package/src/execution/MessageThread.tsx +203 -5
- package/src/execution/__tests__/MessageThread.test.tsx +130 -0
- package/src/execution/__tests__/useAgentExecutionActions.test.tsx +299 -0
- package/src/execution/__tests__/useExecutionStream.test.tsx +95 -0
- package/src/execution/index.ts +6 -0
- package/src/execution/useAgentExecutionActions.ts +205 -0
- package/src/execution/useExecutionStream.ts +80 -4
- package/src/index.ts +3 -0
- package/src/internal/VirtualizedThread.tsx +10 -1
- package/src/internal/__tests__/stream-controller.test.ts +165 -10
- package/src/internal/__tests__/useFetch.test.tsx +59 -0
- package/src/internal/store/__tests__/conversation-store.test.ts +61 -0
- package/src/internal/store/conversation-store.ts +46 -3
- package/src/internal/stream-controller.ts +123 -3
- package/src/internal/useFetch.ts +26 -0
- package/src/session/SessionViewer.tsx +87 -1
- package/src/session/__tests__/useSessionConversation.test.tsx +145 -0
- package/src/session/useSessionConversation.ts +163 -14
- package/src/session/useSessionExecutions.ts +23 -1
- package/styles.css +1 -1
|
@@ -14,6 +14,20 @@ import { ExecutionPhase } from "@stigmer/protos/ai/stigmer/agentic/agentexecutio
|
|
|
14
14
|
|
|
15
15
|
const IDLE: StreamState = { stage: "idle" };
|
|
16
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;
|
|
30
|
+
|
|
17
31
|
/**
|
|
18
32
|
* Callback interface for the stream controller to communicate with
|
|
19
33
|
* its host (the React hook). All mutations to external state go
|
|
@@ -24,6 +38,27 @@ export interface StreamControllerSink {
|
|
|
24
38
|
ingestSnapshot(snapshot: AgentExecution): void;
|
|
25
39
|
/** Transition the store's stream lifecycle state. */
|
|
26
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;
|
|
27
62
|
}
|
|
28
63
|
|
|
29
64
|
// ---------------------------------------------------------------------------
|
|
@@ -52,6 +87,14 @@ export class StreamController {
|
|
|
52
87
|
private _scheduleFlush: (cb: () => void) => number;
|
|
53
88
|
private _cancelFlush: (id: number) => void;
|
|
54
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
|
+
|
|
55
98
|
constructor(
|
|
56
99
|
sink: StreamControllerSink,
|
|
57
100
|
scheduleFlush: (cb: () => void) => number = typeof requestAnimationFrame !== "undefined"
|
|
@@ -60,10 +103,19 @@ export class StreamController {
|
|
|
60
103
|
cancelFlush: (id: number) => void = typeof cancelAnimationFrame !== "undefined"
|
|
61
104
|
? (id: number) => cancelAnimationFrame(id)
|
|
62
105
|
: (id: number) => clearTimeout(id),
|
|
106
|
+
watchdog?: StreamControllerWatchdog,
|
|
63
107
|
) {
|
|
64
108
|
this._sink = sink;
|
|
65
109
|
this._scheduleFlush = scheduleFlush;
|
|
66
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;
|
|
67
119
|
}
|
|
68
120
|
|
|
69
121
|
/** Current FSM state (read-only). */
|
|
@@ -78,6 +130,11 @@ export class StreamController {
|
|
|
78
130
|
start(executionId: string): void {
|
|
79
131
|
this._cancelPendingFlush();
|
|
80
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();
|
|
81
138
|
this._transition({ stage: "connecting", executionId });
|
|
82
139
|
}
|
|
83
140
|
|
|
@@ -95,13 +152,19 @@ export class StreamController {
|
|
|
95
152
|
const terminal = isTerminalPhase(phase);
|
|
96
153
|
|
|
97
154
|
if (terminal) {
|
|
155
|
+
this._cancelWatchdogs();
|
|
156
|
+
this._sink.setSlow(false);
|
|
98
157
|
this._cancelPendingFlush();
|
|
99
158
|
this._bufferedSnapshot = null;
|
|
100
159
|
this._sink.ingestSnapshot(snapshot);
|
|
101
160
|
this._transition({ stage: "complete", executionId });
|
|
102
161
|
} else {
|
|
103
|
-
// A snapshot proves the (re)connection is healthy:
|
|
104
|
-
//
|
|
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`.
|
|
105
168
|
if (
|
|
106
169
|
this._state.stage === "connecting" ||
|
|
107
170
|
this._state.stage === "reconnecting"
|
|
@@ -123,6 +186,12 @@ export class StreamController {
|
|
|
123
186
|
handleReconnecting(attempt: number, error: Error): void {
|
|
124
187
|
const executionId = this._activeExecutionId();
|
|
125
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);
|
|
126
195
|
this._transition({ stage: "reconnecting", executionId, attempt, error });
|
|
127
196
|
}
|
|
128
197
|
|
|
@@ -134,6 +203,8 @@ export class StreamController {
|
|
|
134
203
|
const executionId = this._activeExecutionId();
|
|
135
204
|
if (!executionId) return;
|
|
136
205
|
|
|
206
|
+
this._cancelWatchdogs();
|
|
207
|
+
this._sink.setSlow(false);
|
|
137
208
|
this._flushBuffer();
|
|
138
209
|
if (this._state.stage !== "complete") {
|
|
139
210
|
this._transition({ stage: "complete", executionId });
|
|
@@ -148,13 +219,17 @@ export class StreamController {
|
|
|
148
219
|
const executionId = this._activeExecutionId();
|
|
149
220
|
if (!executionId) return;
|
|
150
221
|
|
|
222
|
+
this._cancelWatchdogs();
|
|
223
|
+
this._sink.setSlow(false);
|
|
151
224
|
this._cancelPendingFlush();
|
|
152
225
|
this._flushBuffer();
|
|
153
226
|
this._transition({ stage: "error", executionId, error });
|
|
154
227
|
}
|
|
155
228
|
|
|
156
|
-
/** Reset to idle. Cancels any pending flush. */
|
|
229
|
+
/** Reset to idle. Cancels any pending flush and watchdog timers. */
|
|
157
230
|
reset(): void {
|
|
231
|
+
this._cancelWatchdogs();
|
|
232
|
+
this._sink.setSlow(false);
|
|
158
233
|
this._cancelPendingFlush();
|
|
159
234
|
this._bufferedSnapshot = null;
|
|
160
235
|
if (this._state.stage !== "idle") {
|
|
@@ -203,4 +278,49 @@ export class StreamController {
|
|
|
203
278
|
this._rafId = null;
|
|
204
279
|
}
|
|
205
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
|
+
}
|
|
206
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
|
|
|
@@ -288,7 +288,37 @@ function ConversationColumn({
|
|
|
288
288
|
isEndUser,
|
|
289
289
|
}: ConversationColumnProps) {
|
|
290
290
|
const { conv } = flow;
|
|
291
|
-
const sendError =
|
|
291
|
+
const sendError =
|
|
292
|
+
flow.submitError ?? conv.sendError ?? conv.approvalError ?? conv.stopError;
|
|
293
|
+
|
|
294
|
+
// Retry a terminal-failed execution by resending its originating message
|
|
295
|
+
// through the full submit pipeline (agent override, runtime-env, workspace).
|
|
296
|
+
const onRetryExecution = useCallback(
|
|
297
|
+
(message: string) => {
|
|
298
|
+
void flow.handleSubmit(message);
|
|
299
|
+
},
|
|
300
|
+
[flow.handleSubmit],
|
|
301
|
+
);
|
|
302
|
+
|
|
303
|
+
// Stop the in-flight turn (graceful cancel, escalating to terminate on a
|
|
304
|
+
// repeat press). Only wired while the active execution is stoppable.
|
|
305
|
+
const handleStop = useCallback(() => {
|
|
306
|
+
void conv.stop();
|
|
307
|
+
}, [conv.stop]);
|
|
308
|
+
|
|
309
|
+
// Edit-and-resubmit: stop the in-flight turn and pre-fill the composer with
|
|
310
|
+
// the original text. The append-only execution log can't be rewritten, so
|
|
311
|
+
// the user reviews the prefilled message and resubmits it as a NEW execution
|
|
312
|
+
// through the normal Send pipeline. The cancelled turn stays in history with
|
|
313
|
+
// its phase badge — an honest record rather than a silent edit.
|
|
314
|
+
const handleEditMessage = useCallback(
|
|
315
|
+
(text: string) => {
|
|
316
|
+
void conv.stop();
|
|
317
|
+
composerRef.current?.setMessage(text);
|
|
318
|
+
composerRef.current?.focus();
|
|
319
|
+
},
|
|
320
|
+
[conv.stop, composerRef],
|
|
321
|
+
);
|
|
292
322
|
|
|
293
323
|
return (
|
|
294
324
|
<div className="flex h-full min-w-0 flex-col">
|
|
@@ -296,8 +326,12 @@ function ConversationColumn({
|
|
|
296
326
|
executions={conv.completedExecutions}
|
|
297
327
|
activeStreamExecution={conv.activeStreamExecution}
|
|
298
328
|
pendingUserMessage={conv.pendingUserMessage}
|
|
329
|
+
pendingMessageFailed={!!conv.sendError && !!conv.pendingUserMessage}
|
|
330
|
+
onRetrySend={conv.retryLastSend}
|
|
331
|
+
onRetryExecution={onRetryExecution}
|
|
299
332
|
onApprovalSubmit={flow.submitApproval}
|
|
300
333
|
submittingApprovalIds={conv.submittingApprovalIds}
|
|
334
|
+
onEditMessage={conv.isStoppable ? handleEditMessage : undefined}
|
|
301
335
|
workspaceEntries={conv.workspaceEntries}
|
|
302
336
|
sandboxWorkspaceRoot={flow.sandboxWorkspaceRoot}
|
|
303
337
|
onBuildFromPlan={onBuildFromPlan}
|
|
@@ -308,6 +342,10 @@ function ConversationColumn({
|
|
|
308
342
|
/>
|
|
309
343
|
<div className="mx-auto w-full max-w-3xl">
|
|
310
344
|
{conv.isReconnecting && <ReconnectingIndicator />}
|
|
345
|
+
{conv.connectTimedOut && (
|
|
346
|
+
<ConnectTimedOutBanner onRetry={conv.reconnectStream} />
|
|
347
|
+
)}
|
|
348
|
+
{conv.isSlow && !conv.connectTimedOut && <SlowIndicator />}
|
|
311
349
|
{conv.streamError && (
|
|
312
350
|
<StreamErrorBanner
|
|
313
351
|
error={conv.streamError}
|
|
@@ -323,6 +361,8 @@ function ConversationColumn({
|
|
|
323
361
|
onSubmit={flow.handleSubmit}
|
|
324
362
|
isSubmitting={conv.isSending}
|
|
325
363
|
disabled={!conv.canSendFollowUp}
|
|
364
|
+
onStop={conv.isStoppable ? handleStop : undefined}
|
|
365
|
+
isStopping={conv.isStopping}
|
|
326
366
|
org={org}
|
|
327
367
|
harness={flow.harness}
|
|
328
368
|
defaultModelId={modelId}
|
|
@@ -573,6 +613,52 @@ function ReconnectingIndicator() {
|
|
|
573
613
|
);
|
|
574
614
|
}
|
|
575
615
|
|
|
616
|
+
/**
|
|
617
|
+
* Actionable banner for the connect-timeout watchdog: the stream opened but
|
|
618
|
+
* never delivered a first snapshot — the agent hasn't started. Mirrors
|
|
619
|
+
* {@link StreamErrorBanner}'s shape (message + Retry) since both resolve via
|
|
620
|
+
* the same `reconnect()` path, but its copy names the specific failure.
|
|
621
|
+
*/
|
|
622
|
+
function ConnectTimedOutBanner({ onRetry }: { onRetry: () => void }) {
|
|
623
|
+
return (
|
|
624
|
+
<div role="alert" className="flex items-center gap-3 border-t border-border bg-muted px-4 py-2.5">
|
|
625
|
+
<p className="min-w-0 flex-1 truncate text-sm text-muted-foreground">
|
|
626
|
+
The agent hasn’t started yet.
|
|
627
|
+
</p>
|
|
628
|
+
<button
|
|
629
|
+
type="button"
|
|
630
|
+
onClick={onRetry}
|
|
631
|
+
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"
|
|
632
|
+
>
|
|
633
|
+
Retry
|
|
634
|
+
</button>
|
|
635
|
+
</div>
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
/**
|
|
640
|
+
* Low-weight informational hint for the slow-stall watchdog: a live stream has
|
|
641
|
+
* gone quiet longer than usual. Never an error and offers no action — the
|
|
642
|
+
* stream is still expected to resume on its own.
|
|
643
|
+
*/
|
|
644
|
+
function SlowIndicator() {
|
|
645
|
+
return (
|
|
646
|
+
<div
|
|
647
|
+
role="status"
|
|
648
|
+
aria-live="polite"
|
|
649
|
+
className="flex items-center gap-2 border-t border-border-muted px-4 py-1.5 text-xs text-muted-foreground"
|
|
650
|
+
>
|
|
651
|
+
<span className="relative flex h-2 w-2 shrink-0" aria-hidden="true">
|
|
652
|
+
<span className="absolute inline-flex h-full w-full animate-ping rounded-full bg-muted-foreground opacity-75 motion-reduce:animate-none" />
|
|
653
|
+
<span className="relative inline-flex h-2 w-2 rounded-full bg-muted-foreground" />
|
|
654
|
+
</span>
|
|
655
|
+
<span className="min-w-0 flex-1 truncate">
|
|
656
|
+
Still working — this is taking longer than usual.
|
|
657
|
+
</span>
|
|
658
|
+
</div>
|
|
659
|
+
);
|
|
660
|
+
}
|
|
661
|
+
|
|
576
662
|
function StreamErrorBanner({
|
|
577
663
|
error,
|
|
578
664
|
onReconnect,
|
|
@@ -81,6 +81,8 @@ interface MockMethods {
|
|
|
81
81
|
executionCreate: ReturnType<typeof vi.fn>;
|
|
82
82
|
subscribe: ReturnType<typeof vi.fn>;
|
|
83
83
|
submitApproval: ReturnType<typeof vi.fn>;
|
|
84
|
+
cancel?: ReturnType<typeof vi.fn>;
|
|
85
|
+
terminate?: ReturnType<typeof vi.fn>;
|
|
84
86
|
}
|
|
85
87
|
|
|
86
88
|
function createMockStigmer(methods: MockMethods): Stigmer {
|
|
@@ -93,6 +95,8 @@ function createMockStigmer(methods: MockMethods): Stigmer {
|
|
|
93
95
|
create: methods.executionCreate,
|
|
94
96
|
subscribe: methods.subscribe,
|
|
95
97
|
submitApproval: methods.submitApproval,
|
|
98
|
+
cancel: methods.cancel,
|
|
99
|
+
terminate: methods.terminate,
|
|
96
100
|
},
|
|
97
101
|
} as unknown as Stigmer;
|
|
98
102
|
}
|
|
@@ -118,6 +122,8 @@ describe("useSessionConversation", () => {
|
|
|
118
122
|
executionCreate: vi.fn(),
|
|
119
123
|
subscribe: vi.fn().mockReturnValue(stream.generator),
|
|
120
124
|
submitApproval: vi.fn().mockResolvedValue({}),
|
|
125
|
+
cancel: vi.fn().mockResolvedValue(makeExecution("e1", ExecutionPhase.EXECUTION_CANCELLED)),
|
|
126
|
+
terminate: vi.fn().mockResolvedValue(makeExecution("e1", ExecutionPhase.EXECUTION_TERMINATED)),
|
|
121
127
|
};
|
|
122
128
|
mockStigmer = createMockStigmer(methods);
|
|
123
129
|
});
|
|
@@ -306,6 +312,59 @@ describe("useSessionConversation", () => {
|
|
|
306
312
|
});
|
|
307
313
|
});
|
|
308
314
|
|
|
315
|
+
it("preserves the message and surfaces sendError when the send fails", async () => {
|
|
316
|
+
methods.listBySession.mockResolvedValue({ entries: [] });
|
|
317
|
+
methods.executionCreate.mockRejectedValue(new Error("create boom"));
|
|
318
|
+
|
|
319
|
+
const { result } = renderHook(
|
|
320
|
+
() => useSessionConversation("session-1", "org"),
|
|
321
|
+
{ wrapper: createWrapper(mockStigmer) },
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
325
|
+
|
|
326
|
+
await act(async () => {
|
|
327
|
+
await result.current.sendFollowUp("keep me");
|
|
328
|
+
});
|
|
329
|
+
|
|
330
|
+
// The failure is surfaced and the user's text is NOT lost.
|
|
331
|
+
expect(result.current.sendError?.message).toBe("create boom");
|
|
332
|
+
expect(result.current.pendingUserMessage).toBe("keep me");
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it("retryLastSend resubmits the same message after a failure", async () => {
|
|
336
|
+
methods.listBySession.mockResolvedValue({ entries: [] });
|
|
337
|
+
methods.executionCreate.mockRejectedValueOnce(new Error("boom"));
|
|
338
|
+
const retried = makeExecution("e-retry", ExecutionPhase.EXECUTION_PENDING);
|
|
339
|
+
retried.metadata!.id = "e-retry";
|
|
340
|
+
methods.executionCreate.mockResolvedValue(retried);
|
|
341
|
+
|
|
342
|
+
const { result } = renderHook(
|
|
343
|
+
() => useSessionConversation("session-1", "org"),
|
|
344
|
+
{ wrapper: createWrapper(mockStigmer) },
|
|
345
|
+
);
|
|
346
|
+
|
|
347
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
348
|
+
|
|
349
|
+
await act(async () => {
|
|
350
|
+
await result.current.sendFollowUp("retry me");
|
|
351
|
+
});
|
|
352
|
+
expect(result.current.sendError).not.toBeNull();
|
|
353
|
+
|
|
354
|
+
await act(async () => {
|
|
355
|
+
result.current.retryLastSend();
|
|
356
|
+
});
|
|
357
|
+
|
|
358
|
+
await waitFor(() =>
|
|
359
|
+
expect(methods.executionCreate).toHaveBeenCalledTimes(2),
|
|
360
|
+
);
|
|
361
|
+
expect(methods.executionCreate).toHaveBeenLastCalledWith(
|
|
362
|
+
expect.objectContaining({ message: "retry me" }),
|
|
363
|
+
);
|
|
364
|
+
// The retry cleared the prior failure for the new attempt.
|
|
365
|
+
expect(result.current.sendError).toBeNull();
|
|
366
|
+
});
|
|
367
|
+
|
|
309
368
|
it("full follow-up lifecycle: active execution completes → canSendFollowUp → sendFollowUp succeeds", async () => {
|
|
310
369
|
// Start with one IN_PROGRESS execution (simulates a Cursor execution running)
|
|
311
370
|
const activeExec = makeExecution("exec-1", ExecutionPhase.EXECUTION_IN_PROGRESS);
|
|
@@ -358,6 +417,92 @@ describe("useSessionConversation", () => {
|
|
|
358
417
|
}),
|
|
359
418
|
);
|
|
360
419
|
});
|
|
420
|
+
|
|
421
|
+
it("isStoppable is false when no execution is active", async () => {
|
|
422
|
+
const completed = makeExecution("e1", ExecutionPhase.EXECUTION_COMPLETED);
|
|
423
|
+
methods.listBySession.mockResolvedValue({ entries: [completed] });
|
|
424
|
+
|
|
425
|
+
const { result } = renderHook(
|
|
426
|
+
() => useSessionConversation("session-1", "org"),
|
|
427
|
+
{ wrapper: createWrapper(mockStigmer) },
|
|
428
|
+
);
|
|
429
|
+
|
|
430
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
431
|
+
expect(result.current.isStoppable).toBe(false);
|
|
432
|
+
});
|
|
433
|
+
|
|
434
|
+
it("isStoppable becomes true once the active execution streams IN_PROGRESS", async () => {
|
|
435
|
+
const active = makeExecution("e1", ExecutionPhase.EXECUTION_IN_PROGRESS);
|
|
436
|
+
methods.listBySession.mockResolvedValue({ entries: [active] });
|
|
437
|
+
|
|
438
|
+
const execStream = createControllableStream<AgentExecution>();
|
|
439
|
+
methods.subscribe.mockReturnValue(execStream.generator);
|
|
440
|
+
|
|
441
|
+
const { result } = renderHook(
|
|
442
|
+
() => useSessionConversation("session-1", "org"),
|
|
443
|
+
{ wrapper: createWrapper(mockStigmer) },
|
|
444
|
+
);
|
|
445
|
+
|
|
446
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
447
|
+
|
|
448
|
+
act(() => {
|
|
449
|
+
execStream.push(makeExecution("e1", ExecutionPhase.EXECUTION_IN_PROGRESS));
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
await waitFor(() => expect(result.current.isStoppable).toBe(true));
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
it("stop() cancels the active execution, then escalates to terminate on repeat", async () => {
|
|
456
|
+
const active = makeExecution("e1", ExecutionPhase.EXECUTION_IN_PROGRESS);
|
|
457
|
+
methods.listBySession.mockResolvedValue({ entries: [active] });
|
|
458
|
+
|
|
459
|
+
const execStream = createControllableStream<AgentExecution>();
|
|
460
|
+
methods.subscribe.mockReturnValue(execStream.generator);
|
|
461
|
+
|
|
462
|
+
const { result } = renderHook(
|
|
463
|
+
() => useSessionConversation("session-1", "org"),
|
|
464
|
+
{ wrapper: createWrapper(mockStigmer) },
|
|
465
|
+
);
|
|
466
|
+
|
|
467
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
468
|
+
|
|
469
|
+
act(() => {
|
|
470
|
+
execStream.push(makeExecution("e1", ExecutionPhase.EXECUTION_IN_PROGRESS));
|
|
471
|
+
});
|
|
472
|
+
await waitFor(() => expect(result.current.isStoppable).toBe(true));
|
|
473
|
+
|
|
474
|
+
await act(async () => {
|
|
475
|
+
await result.current.stop("Stop from chat");
|
|
476
|
+
});
|
|
477
|
+
expect(methods.cancel).toHaveBeenCalledTimes(1);
|
|
478
|
+
expect(methods.cancel!.mock.calls[0][0]).toMatchObject({ id: "e1" });
|
|
479
|
+
expect(methods.terminate).not.toHaveBeenCalled();
|
|
480
|
+
|
|
481
|
+
await act(async () => {
|
|
482
|
+
await result.current.stop("Stop from chat");
|
|
483
|
+
});
|
|
484
|
+
expect(methods.cancel).toHaveBeenCalledTimes(1);
|
|
485
|
+
expect(methods.terminate).toHaveBeenCalledTimes(1);
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
it("stop() is a no-op when nothing is stoppable", async () => {
|
|
489
|
+
const completed = makeExecution("e1", ExecutionPhase.EXECUTION_COMPLETED);
|
|
490
|
+
methods.listBySession.mockResolvedValue({ entries: [completed] });
|
|
491
|
+
|
|
492
|
+
const { result } = renderHook(
|
|
493
|
+
() => useSessionConversation("session-1", "org"),
|
|
494
|
+
{ wrapper: createWrapper(mockStigmer) },
|
|
495
|
+
);
|
|
496
|
+
|
|
497
|
+
await waitFor(() => expect(result.current.isLoading).toBe(false));
|
|
498
|
+
|
|
499
|
+
await act(async () => {
|
|
500
|
+
await result.current.stop();
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
expect(methods.cancel).not.toHaveBeenCalled();
|
|
504
|
+
expect(methods.terminate).not.toHaveBeenCalled();
|
|
505
|
+
});
|
|
361
506
|
});
|
|
362
507
|
|
|
363
508
|
describe("useSessionConversation — local runner worker lifecycle", () => {
|