@stigmer/react 3.0.8-dev.20260613051837 → 3.0.8-dev.20260613072749

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 (59) hide show
  1. package/composer/ComposerToolbar.d.ts +9 -1
  2. package/composer/ComposerToolbar.d.ts.map +1 -1
  3. package/composer/ComposerToolbar.js +7 -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 +7 -0
  10. package/composer/icons.d.ts.map +1 -1
  11. package/composer/icons.js +9 -0
  12. package/composer/icons.js.map +1 -1
  13. package/execution/MessageEntry.d.ts +7 -0
  14. package/execution/MessageEntry.d.ts.map +1 -1
  15. package/execution/MessageEntry.js +7 -4
  16. package/execution/MessageEntry.js.map +1 -1
  17. package/execution/MessageThread.d.ts +16 -3
  18. package/execution/MessageThread.d.ts.map +1 -1
  19. package/execution/MessageThread.js +15 -9
  20. package/execution/MessageThread.js.map +1 -1
  21. package/execution/index.d.ts +2 -0
  22. package/execution/index.d.ts.map +1 -1
  23. package/execution/index.js +1 -0
  24. package/execution/index.js.map +1 -1
  25. package/execution/useAgentExecutionActions.d.ts +67 -0
  26. package/execution/useAgentExecutionActions.d.ts.map +1 -0
  27. package/execution/useAgentExecutionActions.js +105 -0
  28. package/execution/useAgentExecutionActions.js.map +1 -0
  29. package/index.d.ts +2 -2
  30. package/index.d.ts.map +1 -1
  31. package/index.js +1 -1
  32. package/index.js.map +1 -1
  33. package/internal/VirtualizedThread.d.ts +2 -1
  34. package/internal/VirtualizedThread.d.ts.map +1 -1
  35. package/internal/VirtualizedThread.js +3 -2
  36. package/internal/VirtualizedThread.js.map +1 -1
  37. package/package.json +4 -4
  38. package/session/SessionViewer.js +17 -2
  39. package/session/SessionViewer.js.map +1 -1
  40. package/session/useSessionConversation.d.ts +21 -0
  41. package/session/useSessionConversation.d.ts.map +1 -1
  42. package/session/useSessionConversation.js +22 -0
  43. package/session/useSessionConversation.js.map +1 -1
  44. package/src/composer/ComposerToolbar.tsx +35 -9
  45. package/src/composer/SessionComposer.tsx +22 -1
  46. package/src/composer/__tests__/SessionComposer-stop.test.tsx +98 -0
  47. package/src/composer/icons.tsx +20 -0
  48. package/src/execution/MessageEntry.tsx +57 -2
  49. package/src/execution/MessageThread.tsx +32 -3
  50. package/src/execution/__tests__/MessageThread.test.tsx +66 -0
  51. package/src/execution/__tests__/useAgentExecutionActions.test.tsx +299 -0
  52. package/src/execution/index.ts +6 -0
  53. package/src/execution/useAgentExecutionActions.ts +205 -0
  54. package/src/index.ts +3 -0
  55. package/src/internal/VirtualizedThread.tsx +4 -1
  56. package/src/session/SessionViewer.tsx +25 -1
  57. package/src/session/__tests__/useSessionConversation.test.tsx +92 -0
  58. package/src/session/useSessionConversation.ts +50 -0
  59. package/styles.css +1 -1
@@ -0,0 +1,205 @@
1
+ "use client";
2
+
3
+ import { useCallback, useRef, useState } from "react";
4
+ import type { AgentExecution } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
5
+ import { create } from "@bufbuild/protobuf";
6
+ import {
7
+ CancelAgentExecutionInputSchema,
8
+ TerminateAgentExecutionInputSchema,
9
+ PauseAgentExecutionInputSchema,
10
+ ResumeAgentExecutionInputSchema,
11
+ } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/io_pb";
12
+ import { useStigmer } from "../hooks";
13
+ import { toError } from "../internal/toError";
14
+
15
+ /** Options for {@link useAgentExecutionActions}. */
16
+ export interface UseAgentExecutionActionsOptions {
17
+ /**
18
+ * Called after any lifecycle action (cancel, terminate, pause, resume)
19
+ * succeeds. Receives the updated execution returned by the server.
20
+ * Useful for triggering a refetch so the UI reflects the new phase.
21
+ */
22
+ readonly onSuccess?: (execution: AgentExecution) => void;
23
+ }
24
+
25
+ /** Return value of {@link useAgentExecutionActions}. */
26
+ export interface UseAgentExecutionActionsReturn {
27
+ /** Cancel a running execution gracefully (PENDING or IN_PROGRESS only). */
28
+ readonly cancel: (reason?: string) => Promise<AgentExecution | null>;
29
+ /** Terminate a running execution immediately (PENDING or IN_PROGRESS only). */
30
+ readonly terminate: (reason?: string) => Promise<AgentExecution | null>;
31
+ /** Pause a running execution (PENDING or IN_PROGRESS only). */
32
+ readonly pause: (reason?: string) => Promise<AgentExecution | null>;
33
+ /** Resume a paused execution. */
34
+ readonly resume: () => Promise<AgentExecution | null>;
35
+ /**
36
+ * Stop a running execution with progressive escalation.
37
+ *
38
+ * The first call gracefully {@link cancel}s — the agent gets a chance to
39
+ * checkpoint and clean up. If the user presses Stop again because the run
40
+ * is still winding down, this escalates to a forceful {@link terminate}.
41
+ * The escalation state is keyed to the execution id, so a fresh execution
42
+ * always starts from a graceful cancel.
43
+ *
44
+ * @param reason - Optional audit message recorded with the cancel/terminate.
45
+ */
46
+ readonly stop: (reason?: string) => Promise<AgentExecution | null>;
47
+ /** `true` while any action is in flight. */
48
+ readonly isSubmitting: boolean;
49
+ /** Error from the last failed action, or `null`. */
50
+ readonly error: Error | null;
51
+ /** Reset `error` to `null`. */
52
+ readonly clearError: () => void;
53
+ }
54
+
55
+ /**
56
+ * Behavior hook that encapsulates agent execution lifecycle actions.
57
+ *
58
+ * The agent-execution analog of {@link useWorkflowExecutionActions}: each
59
+ * action calls the corresponding RPC and returns the updated execution, or
60
+ * `null` on failure (with `error` populated).
61
+ *
62
+ * Pass `null` for `executionId` to disable all actions (they become no-ops
63
+ * that return `null`).
64
+ *
65
+ * Headless by design — embedders can wire a custom Stop/Cancel control
66
+ * directly to this hook. The session chat uses it via
67
+ * {@link useSessionConversation}'s `stop` / `isStoppable`.
68
+ *
69
+ * @example
70
+ * ```tsx
71
+ * const actions = useAgentExecutionActions(executionId, {
72
+ * onSuccess: () => refetch(),
73
+ * });
74
+ *
75
+ * // A single Stop button that escalates on repeat press.
76
+ * <button onClick={() => actions.stop()} disabled={actions.isSubmitting}>
77
+ * Stop
78
+ * </button>
79
+ * ```
80
+ */
81
+ export function useAgentExecutionActions(
82
+ executionId: string | null,
83
+ options?: UseAgentExecutionActionsOptions,
84
+ ): UseAgentExecutionActionsReturn {
85
+ const stigmer = useStigmer();
86
+ const [isSubmitting, setIsSubmitting] = useState(false);
87
+ const [error, setError] = useState<Error | null>(null);
88
+
89
+ const executionIdRef = useRef(executionId);
90
+ executionIdRef.current = executionId;
91
+ const stigmerRef = useRef(stigmer);
92
+ stigmerRef.current = stigmer;
93
+ const onSuccessRef = useRef(options?.onSuccess);
94
+ onSuccessRef.current = options?.onSuccess;
95
+
96
+ const clearError = useCallback(() => setError(null), []);
97
+
98
+ const wrap = useCallback(
99
+ async (
100
+ fn: () => Promise<AgentExecution>,
101
+ ): Promise<AgentExecution | null> => {
102
+ if (!executionIdRef.current) return null;
103
+ setIsSubmitting(true);
104
+ setError(null);
105
+ try {
106
+ const result = await fn();
107
+ onSuccessRef.current?.(result);
108
+ return result;
109
+ } catch (err) {
110
+ setError(toError(err));
111
+ return null;
112
+ } finally {
113
+ setIsSubmitting(false);
114
+ }
115
+ },
116
+ [],
117
+ );
118
+
119
+ const cancel = useCallback(
120
+ (reason?: string) =>
121
+ wrap(() =>
122
+ stigmerRef.current.agentExecution.cancel(
123
+ create(CancelAgentExecutionInputSchema, {
124
+ id: executionIdRef.current!,
125
+ reason: reason ?? "",
126
+ }),
127
+ ),
128
+ ),
129
+ [wrap],
130
+ );
131
+
132
+ const terminate = useCallback(
133
+ (reason?: string) =>
134
+ wrap(() =>
135
+ stigmerRef.current.agentExecution.terminate(
136
+ create(TerminateAgentExecutionInputSchema, {
137
+ id: executionIdRef.current!,
138
+ reason: reason ?? "",
139
+ }),
140
+ ),
141
+ ),
142
+ [wrap],
143
+ );
144
+
145
+ const pause = useCallback(
146
+ (reason?: string) =>
147
+ wrap(() =>
148
+ stigmerRef.current.agentExecution.pause(
149
+ create(PauseAgentExecutionInputSchema, {
150
+ id: executionIdRef.current!,
151
+ reason: reason ?? "",
152
+ }),
153
+ ),
154
+ ),
155
+ [wrap],
156
+ );
157
+
158
+ const resume = useCallback(
159
+ () =>
160
+ wrap(() =>
161
+ stigmerRef.current.agentExecution.resume(
162
+ create(ResumeAgentExecutionInputSchema, {
163
+ id: executionIdRef.current!,
164
+ }),
165
+ ),
166
+ ),
167
+ [wrap],
168
+ );
169
+
170
+ // Escalation state: remembers whether a graceful cancel has already been
171
+ // issued for the current execution id. Keyed by id so a new execution
172
+ // always begins with cancel rather than inheriting a stale "escalate" flag.
173
+ const stopStateRef = useRef<{ id: string; cancelIssued: boolean } | null>(
174
+ null,
175
+ );
176
+
177
+ const stop = useCallback(
178
+ (reason?: string): Promise<AgentExecution | null> => {
179
+ const id = executionIdRef.current;
180
+ if (!id) return Promise.resolve(null);
181
+
182
+ if (stopStateRef.current?.id !== id) {
183
+ stopStateRef.current = { id, cancelIssued: false };
184
+ }
185
+
186
+ if (!stopStateRef.current.cancelIssued) {
187
+ stopStateRef.current.cancelIssued = true;
188
+ return cancel(reason);
189
+ }
190
+ return terminate(reason);
191
+ },
192
+ [cancel, terminate],
193
+ );
194
+
195
+ return {
196
+ cancel,
197
+ terminate,
198
+ pause,
199
+ resume,
200
+ stop,
201
+ isSubmitting,
202
+ error,
203
+ clearError,
204
+ };
205
+ }
package/src/index.ts CHANGED
@@ -180,6 +180,7 @@ export {
180
180
  isTerminalPhase,
181
181
  useCreateAgentExecution,
182
182
  useExecutionStream,
183
+ useAgentExecutionActions,
183
184
  useSubmitApproval,
184
185
  ExecutionPhaseBadge,
185
186
  InteractionModeBadge,
@@ -251,6 +252,8 @@ export type {
251
252
  CreateAgentExecutionResult,
252
253
  UseCreateAgentExecutionReturn,
253
254
  UseExecutionStreamReturn,
255
+ UseAgentExecutionActionsOptions,
256
+ UseAgentExecutionActionsReturn,
254
257
  UseSubmitApprovalReturn,
255
258
  ExecutionPhaseBadgeProps,
256
259
  InteractionModeBadgeProps,
@@ -43,6 +43,7 @@ export interface VirtualizedThreadProps {
43
43
  readonly centerContent?: boolean;
44
44
  readonly onRetrySend?: () => void;
45
45
  readonly onRetryExecution?: (message: string) => void;
46
+ readonly onEditMessage?: (text: string) => void;
46
47
  }
47
48
 
48
49
  // ---------------------------------------------------------------------------
@@ -105,6 +106,7 @@ export function VirtualizedThread({
105
106
  centerContent,
106
107
  onRetrySend,
107
108
  onRetryExecution,
109
+ onEditMessage,
108
110
  }: VirtualizedThreadProps) {
109
111
  const virtuosoRef = useRef<VirtuosoHandle>(null);
110
112
  const scrollerRef = useRef<HTMLDivElement | null>(null);
@@ -138,8 +140,9 @@ export function VirtualizedThread({
138
140
  planActionsDisabled,
139
141
  onRetrySend,
140
142
  onRetryExecution,
143
+ onEditMessage,
141
144
  }),
142
- [formatToolCallSummary, onApprovalSubmit, submittingApprovalIds, onBuildFromPlan, org, planActionsDisabled, onRetrySend, onRetryExecution],
145
+ [formatToolCallSummary, onApprovalSubmit, submittingApprovalIds, onBuildFromPlan, org, planActionsDisabled, onRetrySend, onRetryExecution, onEditMessage],
143
146
  );
144
147
 
145
148
  return (
@@ -288,7 +288,8 @@ function ConversationColumn({
288
288
  isEndUser,
289
289
  }: ConversationColumnProps) {
290
290
  const { conv } = flow;
291
- const sendError = flow.submitError ?? conv.sendError ?? conv.approvalError;
291
+ const sendError =
292
+ flow.submitError ?? conv.sendError ?? conv.approvalError ?? conv.stopError;
292
293
 
293
294
  // Retry a terminal-failed execution by resending its originating message
294
295
  // through the full submit pipeline (agent override, runtime-env, workspace).
@@ -299,6 +300,26 @@ function ConversationColumn({
299
300
  [flow.handleSubmit],
300
301
  );
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
+ );
322
+
302
323
  return (
303
324
  <div className="flex h-full min-w-0 flex-col">
304
325
  <MessageThread
@@ -310,6 +331,7 @@ function ConversationColumn({
310
331
  onRetryExecution={onRetryExecution}
311
332
  onApprovalSubmit={flow.submitApproval}
312
333
  submittingApprovalIds={conv.submittingApprovalIds}
334
+ onEditMessage={conv.isStoppable ? handleEditMessage : undefined}
313
335
  workspaceEntries={conv.workspaceEntries}
314
336
  sandboxWorkspaceRoot={flow.sandboxWorkspaceRoot}
315
337
  onBuildFromPlan={onBuildFromPlan}
@@ -339,6 +361,8 @@ function ConversationColumn({
339
361
  onSubmit={flow.handleSubmit}
340
362
  isSubmitting={conv.isSending}
341
363
  disabled={!conv.canSendFollowUp}
364
+ onStop={conv.isStoppable ? handleStop : undefined}
365
+ isStopping={conv.isStopping}
342
366
  org={org}
343
367
  harness={flow.harness}
344
368
  defaultModelId={modelId}
@@ -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
  });
@@ -411,6 +417,92 @@ describe("useSessionConversation", () => {
411
417
  }),
412
418
  );
413
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
+ });
414
506
  });
415
507
 
416
508
  describe("useSessionConversation — local runner worker lifecycle", () => {
@@ -21,6 +21,7 @@ import { toError } from "../internal/toError";
21
21
  import { useConversationStoreRef } from "../internal/store";
22
22
  import { useCreateAgentExecution } from "../execution/useCreateAgentExecution";
23
23
  import { useExecutionStream } from "../execution/useExecutionStream";
24
+ import { useAgentExecutionActions } from "../execution/useAgentExecutionActions";
24
25
  import { useSubmitApproval } from "../execution/useSubmitApproval";
25
26
  import { useSession } from "./useSession";
26
27
  import { useSessionExecutions } from "./useSessionExecutions";
@@ -200,6 +201,28 @@ export interface UseSessionConversationReturn {
200
201
  /** Reset `approvalError` to `null`. */
201
202
  readonly clearApprovalError: () => void;
202
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
+
203
226
  /** `true` while the session or execution list is loading. */
204
227
  readonly isLoading: boolean;
205
228
  /** Error from session or execution list loading, or `null` when healthy. */
@@ -431,6 +454,28 @@ export function useSessionConversation(
431
454
  return stream.phase;
432
455
  }, [activeExecutionId, stream.phase]);
433
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
+
434
479
  const canSendFollowUp = !isCreating && activeExecutionId === null;
435
480
 
436
481
  const workspaceEntries = useMemo<readonly ProtoWorkspaceEntry[]>(
@@ -563,6 +608,11 @@ export function useSessionConversation(
563
608
  approvalError,
564
609
  clearApprovalError,
565
610
 
611
+ isStoppable,
612
+ stop,
613
+ isStopping: stopActions.isSubmitting,
614
+ stopError: stopActions.error,
615
+
566
616
  isLoading,
567
617
  loadError,
568
618