@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.
Files changed (92) hide show
  1. package/composer/ComposerToolbar.d.ts +9 -1
  2. package/composer/ComposerToolbar.d.ts.map +1 -1
  3. package/composer/ComposerToolbar.js +3 -3
  4. package/composer/ComposerToolbar.js.map +1 -1
  5. package/composer/SessionComposer.d.ts +12 -0
  6. package/composer/SessionComposer.d.ts.map +1 -1
  7. package/composer/SessionComposer.js +6 -3
  8. package/composer/SessionComposer.js.map +1 -1
  9. package/composer/icons.d.ts +2 -0
  10. package/composer/icons.d.ts.map +1 -1
  11. package/composer/icons.js +4 -0
  12. package/composer/icons.js.map +1 -1
  13. package/execution/ExecutionProgress.d.ts.map +1 -1
  14. package/execution/ExecutionProgress.js +5 -1
  15. package/execution/ExecutionProgress.js.map +1 -1
  16. package/execution/MessageEntry.d.ts +7 -0
  17. package/execution/MessageEntry.d.ts.map +1 -1
  18. package/execution/MessageEntry.js +7 -4
  19. package/execution/MessageEntry.js.map +1 -1
  20. package/execution/MessageThread.d.ts +45 -3
  21. package/execution/MessageThread.d.ts.map +1 -1
  22. package/execution/MessageThread.js +66 -11
  23. package/execution/MessageThread.js.map +1 -1
  24. package/execution/index.d.ts +2 -0
  25. package/execution/index.d.ts.map +1 -1
  26. package/execution/index.js +1 -0
  27. package/execution/index.js.map +1 -1
  28. package/execution/useAgentExecutionActions.d.ts +67 -0
  29. package/execution/useAgentExecutionActions.d.ts.map +1 -0
  30. package/execution/useAgentExecutionActions.js +105 -0
  31. package/execution/useAgentExecutionActions.js.map +1 -0
  32. package/execution/useExecutionStream.d.ts +27 -0
  33. package/execution/useExecutionStream.d.ts.map +1 -1
  34. package/execution/useExecutionStream.js +48 -5
  35. package/execution/useExecutionStream.js.map +1 -1
  36. package/index.d.ts +2 -2
  37. package/index.d.ts.map +1 -1
  38. package/index.js +1 -1
  39. package/index.js.map +1 -1
  40. package/internal/VirtualizedThread.d.ts +4 -1
  41. package/internal/VirtualizedThread.d.ts.map +1 -1
  42. package/internal/VirtualizedThread.js +5 -2
  43. package/internal/VirtualizedThread.js.map +1 -1
  44. package/internal/store/conversation-store.d.ts +22 -0
  45. package/internal/store/conversation-store.d.ts.map +1 -1
  46. package/internal/store/conversation-store.js +43 -2
  47. package/internal/store/conversation-store.js.map +1 -1
  48. package/internal/stream-controller.d.ts +46 -2
  49. package/internal/stream-controller.d.ts.map +1 -1
  50. package/internal/stream-controller.js +95 -4
  51. package/internal/stream-controller.js.map +1 -1
  52. package/internal/useFetch.d.ts +7 -0
  53. package/internal/useFetch.d.ts.map +1 -1
  54. package/internal/useFetch.js +21 -0
  55. package/internal/useFetch.js.map +1 -1
  56. package/package.json +4 -4
  57. package/session/SessionViewer.js +39 -2
  58. package/session/SessionViewer.js.map +1 -1
  59. package/session/useSessionConversation.d.ts +55 -3
  60. package/session/useSessionConversation.d.ts.map +1 -1
  61. package/session/useSessionConversation.js +95 -10
  62. package/session/useSessionConversation.js.map +1 -1
  63. package/session/useSessionExecutions.d.ts +17 -1
  64. package/session/useSessionExecutions.d.ts.map +1 -1
  65. package/session/useSessionExecutions.js +6 -2
  66. package/session/useSessionExecutions.js.map +1 -1
  67. package/src/composer/ComposerToolbar.tsx +32 -9
  68. package/src/composer/SessionComposer.tsx +22 -1
  69. package/src/composer/__tests__/SessionComposer-stop.test.tsx +98 -0
  70. package/src/composer/icons.tsx +15 -0
  71. package/src/execution/ExecutionProgress.tsx +12 -0
  72. package/src/execution/MessageEntry.tsx +57 -2
  73. package/src/execution/MessageThread.tsx +203 -5
  74. package/src/execution/__tests__/MessageThread.test.tsx +130 -0
  75. package/src/execution/__tests__/useAgentExecutionActions.test.tsx +299 -0
  76. package/src/execution/__tests__/useExecutionStream.test.tsx +95 -0
  77. package/src/execution/index.ts +6 -0
  78. package/src/execution/useAgentExecutionActions.ts +205 -0
  79. package/src/execution/useExecutionStream.ts +80 -4
  80. package/src/index.ts +3 -0
  81. package/src/internal/VirtualizedThread.tsx +10 -1
  82. package/src/internal/__tests__/stream-controller.test.ts +165 -10
  83. package/src/internal/__tests__/useFetch.test.tsx +59 -0
  84. package/src/internal/store/__tests__/conversation-store.test.ts +61 -0
  85. package/src/internal/store/conversation-store.ts +46 -3
  86. package/src/internal/stream-controller.ts +123 -3
  87. package/src/internal/useFetch.ts +26 -0
  88. package/src/session/SessionViewer.tsx +87 -1
  89. package/src/session/__tests__/useSessionConversation.test.tsx +145 -0
  90. package/src/session/useSessionConversation.ts +163 -14
  91. package/src/session/useSessionExecutions.ts +23 -1
  92. package/styles.css +1 -1
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { useCallback, useEffect, useMemo, useState } from "react";
3
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
4
4
  import type { AgentExecution } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
5
5
  import type { PendingApproval } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/approval_pb";
6
6
  import { ApprovalAction, ExecutionPhase } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/enum_pb";
@@ -17,9 +17,11 @@ import type {
17
17
  } from "@stigmer/sdk";
18
18
  import { isTerminalPhase } from "../execution/execution-phases";
19
19
  import { useStigmer } from "../hooks";
20
+ import { toError } from "../internal/toError";
20
21
  import { useConversationStoreRef } from "../internal/store";
21
22
  import { useCreateAgentExecution } from "../execution/useCreateAgentExecution";
22
23
  import { useExecutionStream } from "../execution/useExecutionStream";
24
+ import { useAgentExecutionActions } from "../execution/useAgentExecutionActions";
23
25
  import { useSubmitApproval } from "../execution/useSubmitApproval";
24
26
  import { useSession } from "./useSession";
25
27
  import { useSessionExecutions } from "./useSessionExecutions";
@@ -31,6 +33,14 @@ import {
31
33
  specSkillRefsToInput,
32
34
  } from "./session-spec-converters";
33
35
 
36
+ /**
37
+ * Cadence for re-discovering the session's executions while the live stream
38
+ * cannot be relied on (a created-but-not-yet-listed execution, a silent
39
+ * connect-timeout, or an exhausted stream error). Disabled the instant the
40
+ * stream is healthy or terminal, so this never competes with the live feed.
41
+ */
42
+ const REDISCOVERY_POLL_INTERVAL_MS = 5_000;
43
+
34
44
  /**
35
45
  * Options for {@link UseSessionConversationReturn.sendFollowUp}.
36
46
  *
@@ -144,12 +154,29 @@ export interface UseSessionConversationReturn {
144
154
  readonly canSendFollowUp: boolean;
145
155
  /** True during the create RPC call (between submit and execution ID). */
146
156
  readonly isSending: boolean;
147
- /** Error from the last sendFollowUp attempt, or null. */
157
+ /**
158
+ * Error from the last `sendFollowUp` attempt, or `null`.
159
+ *
160
+ * Covers **both** failing paths — the optional `session.update()` and the
161
+ * `create()` RPC — so a follow-up never fails silently. When set, the user's
162
+ * message is preserved (see {@link pendingUserMessage}) and can be re-sent
163
+ * via {@link retryLastSend}.
164
+ */
148
165
  readonly sendError: Error | null;
149
- /** Reset `sendError` to `null`. */
166
+ /** Reset `sendError` to `null` (keeps the preserved pending message). */
150
167
  readonly clearSendError: () => void;
168
+ /**
169
+ * Re-send the most recent `sendFollowUp` (same message and options). No-op
170
+ * when nothing has been sent yet. Use as the "Retry" affordance on a failed
171
+ * turn; clears {@link sendError} for the new attempt.
172
+ */
173
+ readonly retryLastSend: () => void;
151
174
 
152
- /** The user's message text, shown in the thread before the stream delivers it. */
175
+ /**
176
+ * The user's message text, shown in the thread before the stream delivers it.
177
+ * Retained when a send fails so the typed message is never lost — pair with
178
+ * {@link sendError} to render the turn as failed with a retry control.
179
+ */
153
180
  readonly pendingUserMessage: string | null;
154
181
 
155
182
  /** Current workspace entries from the session spec. Empty array when session is not loaded. */
@@ -174,6 +201,28 @@ export interface UseSessionConversationReturn {
174
201
  /** Reset `approvalError` to `null`. */
175
202
  readonly clearApprovalError: () => void;
176
203
 
204
+ /**
205
+ * `true` when the active execution can be stopped — i.e. it is in a phase
206
+ * the backend accepts a cancel/terminate from (`PENDING` or `IN_PROGRESS`).
207
+ *
208
+ * Distinct from "is something active": an execution paused at an approval
209
+ * gate (`WAITING_FOR_APPROVAL`) is active but **not** stoppable — the
210
+ * approval card (approve / skip / reject) is its control surface. Drive the
211
+ * composer's Stop affordance off this so it only appears when {@link stop}
212
+ * will actually succeed.
213
+ */
214
+ readonly isStoppable: boolean;
215
+ /**
216
+ * Stop the active execution, with progressive escalation: the first call
217
+ * gracefully cancels; a repeat call (because the run is still winding down)
218
+ * forcefully terminates. No-op when nothing is {@link isStoppable}.
219
+ */
220
+ readonly stop: (reason?: string) => Promise<void>;
221
+ /** `true` while a stop (cancel/terminate) request is in flight. */
222
+ readonly isStopping: boolean;
223
+ /** Error from the last stop attempt, or `null` when healthy. */
224
+ readonly stopError: Error | null;
225
+
177
226
  /** `true` while the session or execution list is loading. */
178
227
  readonly isLoading: boolean;
179
228
  /** Error from session or execution list loading, or `null` when healthy. */
@@ -185,6 +234,20 @@ export interface UseSessionConversationReturn {
185
234
  * surface a subtle "Reconnecting…" hint rather than an error banner.
186
235
  */
187
236
  readonly isReconnecting: boolean;
237
+ /**
238
+ * `true` when the stream opened but never delivered a first snapshot within
239
+ * the watchdog window (even after a silent retry) — the agent hasn't started.
240
+ * Distinct from `streamError`: nothing threw, the stream is simply silent.
241
+ * Surface an actionable "the agent hasn't started — Retry" banner wired to
242
+ * {@link reconnectStream}.
243
+ */
244
+ readonly connectTimedOut: boolean;
245
+ /**
246
+ * `true` when a live, non-terminal stream has been silent past the slow
247
+ * threshold. Purely informational ("still working — taking longer than
248
+ * usual"); cleared by the next update. Never an error.
249
+ */
250
+ readonly isSlow: boolean;
188
251
  /** Error from the execution stream, or `null` when healthy or reconnecting. */
189
252
  readonly streamError: Error | null;
190
253
  /** Reset the stream error and re-establish the execution stream subscription. */
@@ -250,16 +313,24 @@ export function useSessionConversation(
250
313
  error: sessionError,
251
314
  refetch: refetchSession,
252
315
  } = useSession(sessionId);
316
+ // Bounded re-discovery (see REDISCOVERY_POLL_INTERVAL_MS). The gate depends on
317
+ // the stream below, so the decision is synced into state via an effect and fed
318
+ // back here on the next render — a one-frame lag that is immaterial at 5s.
319
+ const [rediscoveryActive, setRediscoveryActive] = useState(false);
253
320
  const {
254
321
  executions,
255
322
  isLoading: executionsLoading,
256
323
  error: executionsError,
257
324
  refetch,
258
- } = useSessionExecutions(sessionId);
325
+ } = useSessionExecutions(sessionId, {
326
+ refetchInterval: rediscoveryActive ? REDISCOVERY_POLL_INTERVAL_MS : false,
327
+ // Re-list on app-relaunch / tab refocus so an execution that appeared while
328
+ // backgrounded is picked up without the user having to act.
329
+ refetchOnWindowFocus: true,
330
+ });
259
331
  const {
260
332
  create,
261
333
  isCreating,
262
- error: createError,
263
334
  clearError: clearCreateError,
264
335
  } = useCreateAgentExecution();
265
336
  const { update: updateSession } = useUpdateSession();
@@ -280,6 +351,14 @@ export function useSessionConversation(
280
351
  const [pendingUserMessage, setPendingUserMessage] = useState<string | null>(
281
352
  null,
282
353
  );
354
+ // Dedicated send-failure state, distinct from the create hook's internal
355
+ // error so it can also cover the session.update() path. The last send's
356
+ // arguments are captured for an exact retry.
357
+ const [sendError, setSendError] = useState<Error | null>(null);
358
+ const lastSendRef = useRef<{
359
+ message: string;
360
+ options?: SendFollowUpOptions;
361
+ } | null>(null);
283
362
 
284
363
  const listActiveId = useMemo(() => {
285
364
  for (let i = executions.length - 1; i >= 0; i--) {
@@ -302,6 +381,25 @@ export function useSessionConversation(
302
381
  store: conversationStore,
303
382
  });
304
383
 
384
+ // Re-discovery gate. Poll only while the live stream cannot carry us:
385
+ // • a fresh session whose first execution is created but not yet listed
386
+ // (`executions.length === 0`) — the race this fix targets,
387
+ // • a silent connect-timeout, or an exhausted stream error.
388
+ // Never while the stream is healthy (`isStreaming`) or the active execution
389
+ // has reached a terminal phase — the live feed is then the source of truth.
390
+ const streamTerminal =
391
+ activeExecutionId !== null && isTerminalPhase(stream.phase);
392
+ const needsRediscovery =
393
+ !stream.isStreaming &&
394
+ !streamTerminal &&
395
+ ((activeExecutionId === null && executions.length === 0) ||
396
+ stream.connectTimedOut ||
397
+ stream.error !== null);
398
+
399
+ useEffect(() => {
400
+ setRediscoveryActive(needsRediscovery);
401
+ }, [needsRediscovery]);
402
+
305
403
  // Clear pendingExecutionId once the execution appears in the fetched list
306
404
  useEffect(() => {
307
405
  if (
@@ -312,12 +410,17 @@ export function useSessionConversation(
312
410
  }
313
411
  }, [pendingExecutionId, executions]);
314
412
 
315
- // Clear optimistic message once the stream delivers its first snapshot
413
+ // Clear the optimistic message and any stale send error — once the stream
414
+ // delivers a real snapshot. This also handles recovery: if a failed send's
415
+ // execution is later re-discovered and streams, the failed turn resolves into
416
+ // the live one instead of lingering. (At send time the composer is only
417
+ // enabled when no execution is active, so a *fresh* failure cannot be cleared
418
+ // here prematurely — `stream.execution` is null then.)
316
419
  useEffect(() => {
317
- if (pendingUserMessage && stream.execution) {
318
- setPendingUserMessage(null);
319
- }
320
- }, [pendingUserMessage, stream.execution]);
420
+ if (!stream.execution) return;
421
+ if (pendingUserMessage) setPendingUserMessage(null);
422
+ if (sendError) setSendError(null);
423
+ }, [pendingUserMessage, sendError, stream.execution]);
321
424
 
322
425
  // Refetch executions when stream reaches a terminal phase so the
323
426
  // fetched list reflects the completed status and listActiveId clears.
@@ -351,6 +454,28 @@ export function useSessionConversation(
351
454
  return stream.phase;
352
455
  }, [activeExecutionId, stream.phase]);
353
456
 
457
+ // Stop is only valid in phases the backend cancels/terminates from
458
+ // (PENDING / IN_PROGRESS). Other non-terminal phases (e.g.
459
+ // WAITING_FOR_APPROVAL) are handled by their own control surface.
460
+ const isStoppable =
461
+ activePhase === ExecutionPhase.EXECUTION_PENDING ||
462
+ activePhase === ExecutionPhase.EXECUTION_IN_PROGRESS;
463
+
464
+ const stopActions = useAgentExecutionActions(activeExecutionId, {
465
+ // The cancel/terminate also broadcasts the new phase over the stream, but
466
+ // refetch is the belt-and-suspenders that clears the active id even if the
467
+ // stream has already ended — mirrors the workflow viewer's onSuccess.
468
+ onSuccess: refetch,
469
+ });
470
+
471
+ const stop = useCallback(
472
+ async (reason?: string): Promise<void> => {
473
+ if (!isStoppable) return;
474
+ await stopActions.stop(reason);
475
+ },
476
+ [isStoppable, stopActions.stop],
477
+ );
478
+
354
479
  const canSendFollowUp = !isCreating && activeExecutionId === null;
355
480
 
356
481
  const workspaceEntries = useMemo<readonly ProtoWorkspaceEntry[]>(
@@ -372,6 +497,9 @@ export function useSessionConversation(
372
497
  async (message: string, options?: SendFollowUpOptions): Promise<void> => {
373
498
  if (!sessionId || !session) return;
374
499
 
500
+ // Capture for retry and clear any prior failure before the new attempt.
501
+ lastSendRef.current = { message, options };
502
+ setSendError(null);
375
503
  setPendingUserMessage(message);
376
504
 
377
505
  try {
@@ -411,7 +539,10 @@ export function useSessionConversation(
411
539
  setPendingExecutionId(result.executionId);
412
540
  refetch();
413
541
  } catch (err) {
414
- setPendingUserMessage(null);
542
+ // Surface the failure and KEEP the user's message visible (do not clear
543
+ // pendingUserMessage) so the turn renders as failed-with-retry instead
544
+ // of vanishing. Covers both the update() and create() paths.
545
+ setSendError(toError(err));
415
546
  if (process.env.NODE_ENV !== "production") {
416
547
  console.error("[useSessionConversation] sendFollowUp failed:", err);
417
548
  }
@@ -420,6 +551,16 @@ export function useSessionConversation(
420
551
  [sessionId, session, org, stigmer, create, updateSession, refetch, refetchSession],
421
552
  );
422
553
 
554
+ const retryLastSend = useCallback(() => {
555
+ const last = lastSendRef.current;
556
+ if (last) void sendFollowUp(last.message, last.options);
557
+ }, [sendFollowUp]);
558
+
559
+ const clearSendError = useCallback(() => {
560
+ setSendError(null);
561
+ clearCreateError();
562
+ }, [clearCreateError]);
563
+
423
564
  const pendingApprovals = useMemo<readonly PendingApproval[]>(
424
565
  () => activeStreamExecution?.status?.pendingApprovals ?? [],
425
566
  [activeStreamExecution],
@@ -451,8 +592,9 @@ export function useSessionConversation(
451
592
  sendFollowUp,
452
593
  canSendFollowUp,
453
594
  isSending: isCreating,
454
- sendError: createError,
455
- clearSendError: clearCreateError,
595
+ sendError,
596
+ clearSendError,
597
+ retryLastSend,
456
598
 
457
599
  pendingUserMessage,
458
600
 
@@ -466,10 +608,17 @@ export function useSessionConversation(
466
608
  approvalError,
467
609
  clearApprovalError,
468
610
 
611
+ isStoppable,
612
+ stop,
613
+ isStopping: stopActions.isSubmitting,
614
+ stopError: stopActions.error,
615
+
469
616
  isLoading,
470
617
  loadError,
471
618
 
472
619
  isReconnecting: stream.isReconnecting,
620
+ connectTimedOut: stream.connectTimedOut,
621
+ isSlow: stream.isSlow,
473
622
  streamError: stream.error,
474
623
  reconnectStream: stream.reconnect,
475
624
  };
@@ -6,6 +6,23 @@ import { ListAgentExecutionsBySessionRequestSchema } from "@stigmer/protos/ai/st
6
6
  import { useStigmer } from "../hooks";
7
7
  import { useFetch } from "../internal/useFetch";
8
8
 
9
+ /** Options for {@link useSessionExecutions}. */
10
+ export interface UseSessionExecutionsOptions {
11
+ /**
12
+ * Poll interval in milliseconds for re-listing the session's executions.
13
+ * Used by the conversation loop to re-discover a created-but-not-yet-listed
14
+ * execution. Pass `false` (the default) to disable polling and rely on the
15
+ * live stream plus imperative {@link UseSessionExecutionsReturn.refetch}.
16
+ */
17
+ readonly refetchInterval?: number | false;
18
+ /**
19
+ * Re-list when the window regains focus / the tab becomes visible — covers
20
+ * the app-relaunch case where an execution may have appeared while
21
+ * backgrounded. Defaults to `false`.
22
+ */
23
+ readonly refetchOnWindowFocus?: boolean;
24
+ }
25
+
9
26
  /** Return value of {@link useSessionExecutions}. */
10
27
  export interface UseSessionExecutionsReturn {
11
28
  /** All executions for the session, empty while loading or on error. */
@@ -56,6 +73,7 @@ export interface UseSessionExecutionsReturn {
56
73
  */
57
74
  export function useSessionExecutions(
58
75
  sessionId: string | null,
76
+ options?: UseSessionExecutionsOptions,
59
77
  ): UseSessionExecutionsReturn {
60
78
  const stigmer = useStigmer();
61
79
 
@@ -73,7 +91,11 @@ export function useSessionExecutions(
73
91
  : null,
74
92
  [sessionId, stigmer],
75
93
  [] as AgentExecution[],
76
- { cacheKey: sessionId ? `session-executions:${sessionId}` : undefined },
94
+ {
95
+ cacheKey: sessionId ? `session-executions:${sessionId}` : undefined,
96
+ refetchInterval: options?.refetchInterval,
97
+ refetchOnWindowFocus: options?.refetchOnWindowFocus,
98
+ },
77
99
  );
78
100
 
79
101
  return { executions, isLoading, isRefetching, error, refetch };