@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
@@ -14,6 +14,13 @@ export interface MessageEntryProps {
14
14
  readonly message: AgentMessage;
15
15
  /** Additional CSS class names for the root container. */
16
16
  readonly className?: string;
17
+ /**
18
+ * When provided on a `MESSAGE_HUMAN` entry, a hover-revealed "Edit" button
19
+ * appears on the bubble. Clicking it invokes this callback — the session
20
+ * chat uses it to stop the in-flight turn and pre-fill the composer with
21
+ * this message for editing. Ignored for non-human messages.
22
+ */
23
+ readonly onEdit?: () => void;
17
24
  }
18
25
 
19
26
  /**
@@ -43,6 +50,7 @@ export interface MessageEntryProps {
43
50
  export const MessageEntry = memo(function MessageEntry({
44
51
  message,
45
52
  className,
53
+ onEdit,
46
54
  }: MessageEntryProps) {
47
55
  useRenderTracer("MessageEntry", {
48
56
  messageType: message.type,
@@ -52,7 +60,13 @@ export const MessageEntry = memo(function MessageEntry({
52
60
 
53
61
  switch (message.type) {
54
62
  case MessageType.MESSAGE_HUMAN:
55
- return <HumanMessage content={message.content} className={className} />;
63
+ return (
64
+ <HumanMessage
65
+ content={message.content}
66
+ className={className}
67
+ onEdit={onEdit}
68
+ />
69
+ );
56
70
  case MessageType.MESSAGE_AI:
57
71
  return (
58
72
  <AiMessage
@@ -79,21 +93,62 @@ export const MessageEntry = memo(function MessageEntry({
79
93
  function HumanMessage({
80
94
  content,
81
95
  className,
96
+ onEdit,
82
97
  }: {
83
98
  content: string;
84
99
  className?: string;
100
+ onEdit?: () => void;
85
101
  }) {
86
102
  return (
87
103
  <div
88
104
  role="article"
89
105
  aria-label="User message"
90
- className={cn("ms-[20%] rounded-lg bg-muted-subtle px-4 py-3", className)}
106
+ className={cn(
107
+ "group relative ms-[20%] rounded-lg bg-muted-subtle px-4 py-3",
108
+ className,
109
+ )}
91
110
  >
92
111
  <p className="text-sm text-foreground whitespace-pre-wrap">{content}</p>
112
+ {onEdit && (
113
+ <button
114
+ type="button"
115
+ onClick={onEdit}
116
+ aria-label="Edit message"
117
+ title="Edit"
118
+ className={cn(
119
+ "absolute -top-2.5 -right-2.5 inline-flex h-7 w-7 items-center justify-center rounded-full",
120
+ "border border-border bg-card text-muted-foreground shadow-sm transition",
121
+ "hover:text-foreground hover:bg-accent-hover",
122
+ "opacity-0 group-hover:opacity-100 focus-visible:opacity-100",
123
+ "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
124
+ )}
125
+ >
126
+ <EditIcon />
127
+ </button>
128
+ )}
93
129
  </div>
94
130
  );
95
131
  }
96
132
 
133
+ function EditIcon() {
134
+ return (
135
+ <svg
136
+ width="13"
137
+ height="13"
138
+ viewBox="0 0 24 24"
139
+ fill="none"
140
+ stroke="currentColor"
141
+ strokeWidth="2"
142
+ strokeLinecap="round"
143
+ strokeLinejoin="round"
144
+ aria-hidden="true"
145
+ >
146
+ <path d="M12 20h9" />
147
+ <path d="M16.5 3.5a2.121 2.121 0 0 1 3 3L7 19l-4 1 1-4Z" />
148
+ </svg>
149
+ );
150
+ }
151
+
97
152
  function AiMessage({
98
153
  content,
99
154
  isStreaming,
@@ -1,6 +1,6 @@
1
1
  "use client";
2
2
 
3
- import { lazy, memo, Suspense, useCallback, useMemo } from "react";
3
+ import { lazy, memo, Suspense, useCallback, useMemo, useState } from "react";
4
4
  import { create } from "@bufbuild/protobuf";
5
5
  import type { AgentExecution } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/api_pb";
6
6
  import type { AgentMessage, ToolCall } from "@stigmer/protos/ai/stigmer/agentic/agentexecution/v1/message_pb";
@@ -60,6 +60,38 @@ export interface MessageThreadProps {
60
60
  * first snapshot.
61
61
  */
62
62
  readonly pendingUserMessage?: string | null;
63
+ /**
64
+ * Marks the pending user message as failed-to-send. The optimistic
65
+ * bubble renders an inline "Couldn't send — Retry" affordance instead
66
+ * of the sending indicator, so a failed follow-up never silently
67
+ * vanishes. Pair with {@link onRetrySend}.
68
+ *
69
+ * @default false
70
+ */
71
+ readonly pendingMessageFailed?: boolean;
72
+ /**
73
+ * Retry handler for a failed pending message. Wired to the inline
74
+ * "Retry" control when {@link pendingMessageFailed} is `true`.
75
+ */
76
+ readonly onRetrySend?: () => void;
77
+ /**
78
+ * When provided, the in-flight human turn (the active execution's prompt)
79
+ * shows a hover "Edit" affordance. Clicking it invokes this callback with
80
+ * the message text — the session chat stops the running execution and
81
+ * pre-fills the composer for an edit-and-resubmit.
82
+ *
83
+ * Provide this only while the active execution is stoppable; the SDK marks
84
+ * exactly the active-stream human turn editable. When omitted, no edit
85
+ * control is shown (backward compatible).
86
+ */
87
+ readonly onEditMessage?: (text: string) => void;
88
+ /**
89
+ * Retry handler for a terminal-failed execution that exposed a server
90
+ * error reason. Receives the originating message; the consumer typically
91
+ * resends it as a new execution. When omitted, no Retry control is shown
92
+ * beside the surfaced failure reason.
93
+ */
94
+ readonly onRetryExecution?: (message: string) => void;
63
95
  /** Additional CSS class names for the root container. */
64
96
  readonly className?: string;
65
97
  /**
@@ -178,10 +210,11 @@ export interface MessageThreadProps {
178
210
  * part of the public API.
179
211
  */
180
212
  export type ThreadItem =
181
- | { readonly kind: "message"; readonly message: AgentMessage; readonly key: string; readonly isPending?: boolean }
213
+ | { readonly kind: "message"; readonly message: AgentMessage; readonly key: string; readonly isPending?: boolean; readonly isFailed?: boolean; readonly isEditable?: boolean }
182
214
  | { readonly kind: "tool-group"; readonly toolCalls: readonly ToolCall[]; readonly subAgentExecutions: readonly SubAgentExecution[]; readonly key: string }
183
215
  | { readonly kind: "sub-agent"; readonly subAgentExecution: SubAgentExecution; readonly key: string }
184
216
  | { readonly kind: "phase-badge"; readonly phase: ExecutionPhase; readonly key: string }
217
+ | { readonly kind: "execution-error"; readonly error: string; readonly retryMessage?: string; readonly key: string }
185
218
  | { readonly kind: "approval-request"; readonly pendingApproval: PendingApproval; readonly key: string }
186
219
  | { readonly kind: "setup-progress"; readonly workspaceEntries: readonly WorkspaceEntry[]; readonly serverPhase?: string; readonly isAwaitingResponse?: boolean; readonly key: string }
187
220
  | { readonly kind: "context-compacted"; readonly event: SummarizationEventView; readonly key: string }
@@ -216,6 +249,8 @@ export function buildThreadItems(
216
249
  includeApprovals: boolean,
217
250
  workspaceEntries: readonly WorkspaceEntry[] | undefined,
218
251
  summarizationEvents?: readonly SummarizationEventView[],
252
+ pendingMessageFailed = false,
253
+ editableActiveTurn = false,
219
254
  ): ThreadItem[] {
220
255
  const items: ThreadItem[] = [];
221
256
  const allExecutions = activeStreamExecution
@@ -274,6 +309,9 @@ export function buildThreadItems(
274
309
  kind: "message",
275
310
  message: syntheticHumanMsg,
276
311
  key: bridgePending ? "pending-user-turn" : `${execId}-spec`,
312
+ // The active execution's prompt is the one a user can edit-and-resubmit
313
+ // (stop + rephrase). Only mark it when the consumer enabled editing.
314
+ isEditable: isActiveStreamExec && editableActiveTurn,
277
315
  });
278
316
  }
279
317
 
@@ -393,6 +431,22 @@ export function buildThreadItems(
393
431
  phase: lastPhase,
394
432
  key: `phase-${lastPhase}`,
395
433
  });
434
+
435
+ // The server populates `status.error` only on EXECUTION_FAILED. Surface it
436
+ // beside the badge so a failure that produced no messages still explains
437
+ // itself (the CLI shows this reason; the chat previously showed nothing).
438
+ // Kept as its own item so the badge component stays presentational.
439
+ const reason = lastExec?.status?.error;
440
+ if (reason) {
441
+ const specMessage = lastExec?.spec?.message;
442
+ items.push({
443
+ kind: "execution-error",
444
+ error: reason,
445
+ retryMessage:
446
+ specMessage && specMessage !== "execute" ? specMessage : undefined,
447
+ key: `execution-error-${lastExec?.metadata?.id ?? lastPhase}`,
448
+ });
449
+ }
396
450
  }
397
451
 
398
452
  if (
@@ -430,7 +484,10 @@ export function buildThreadItems(
430
484
  kind: "message",
431
485
  message: syntheticPending,
432
486
  key: "pending-user-turn",
433
- isPending: true,
487
+ // A failed turn shows the inline error instead of the dimmed
488
+ // sending state — the two are mutually exclusive.
489
+ isPending: !pendingMessageFailed,
490
+ isFailed: pendingMessageFailed,
434
491
  });
435
492
  }
436
493
  }
@@ -465,6 +522,10 @@ export function MessageThread({
465
522
  executions,
466
523
  activeStreamExecution,
467
524
  pendingUserMessage,
525
+ pendingMessageFailed = false,
526
+ onRetrySend,
527
+ onRetryExecution,
528
+ onEditMessage,
468
529
  className,
469
530
  formatToolCallSummary,
470
531
  onApprovalSubmit,
@@ -482,9 +543,10 @@ export function MessageThread({
482
543
  useRenderTracer("MessageThread", { executions, activeStreamExecution });
483
544
 
484
545
  const includeApprovals = onApprovalSubmit != null;
546
+ const editableActiveTurn = onEditMessage != null;
485
547
  const items = useMemo(
486
- () => buildThreadItems(executions, activeStreamExecution, pendingUserMessage, includeApprovals, workspaceEntries, summarizationEvents),
487
- [executions, activeStreamExecution, pendingUserMessage, includeApprovals, workspaceEntries, summarizationEvents],
548
+ () => buildThreadItems(executions, activeStreamExecution, pendingUserMessage, includeApprovals, workspaceEntries, summarizationEvents, pendingMessageFailed, editableActiveTurn),
549
+ [executions, activeStreamExecution, pendingUserMessage, includeApprovals, workspaceEntries, summarizationEvents, pendingMessageFailed, editableActiveTurn],
488
550
  );
489
551
 
490
552
  useKeyStability(items);
@@ -517,6 +579,9 @@ export function MessageThread({
517
579
  org={org}
518
580
  planActionsDisabled={planActionsDisabled}
519
581
  centerContent={centerContent}
582
+ onRetrySend={onRetrySend}
583
+ onRetryExecution={onRetryExecution}
584
+ onEditMessage={onEditMessage}
520
585
  />
521
586
  </Suspense>
522
587
  </div>
@@ -536,6 +601,9 @@ export function MessageThread({
536
601
  onBuildFromPlan={onBuildFromPlan}
537
602
  org={org}
538
603
  planActionsDisabled={planActionsDisabled}
604
+ onRetrySend={onRetrySend}
605
+ onRetryExecution={onRetryExecution}
606
+ onEditMessage={onEditMessage}
539
607
  />
540
608
  );
541
609
  }
@@ -560,6 +628,9 @@ interface NonVirtualizedThreadProps {
560
628
  readonly onBuildFromPlan?: () => void;
561
629
  readonly org?: string;
562
630
  readonly planActionsDisabled?: boolean;
631
+ readonly onRetrySend?: () => void;
632
+ readonly onRetryExecution?: (message: string) => void;
633
+ readonly onEditMessage?: (text: string) => void;
563
634
  }
564
635
 
565
636
  function NonVirtualizedThread({
@@ -574,6 +645,9 @@ function NonVirtualizedThread({
574
645
  onBuildFromPlan,
575
646
  org,
576
647
  planActionsDisabled,
648
+ onRetrySend,
649
+ onRetryExecution,
650
+ onEditMessage,
577
651
  }: NonVirtualizedThreadProps) {
578
652
  const { scrollRef, sentinelRef, contentRef, isFollowing, jumpToLatest } =
579
653
  useAutoScroll();
@@ -609,6 +683,9 @@ function NonVirtualizedThread({
609
683
  onBuildFromPlan={onBuildFromPlan}
610
684
  org={org}
611
685
  planActionsDisabled={planActionsDisabled}
686
+ onRetrySend={onRetrySend}
687
+ onRetryExecution={onRetryExecution}
688
+ onEditMessage={onEditMessage}
612
689
  />
613
690
  </ThreadItemWrapper>
614
691
  ))}
@@ -645,6 +722,9 @@ export interface ThreadItemRendererProps {
645
722
  readonly onBuildFromPlan?: () => void;
646
723
  readonly org?: string;
647
724
  readonly planActionsDisabled?: boolean;
725
+ readonly onRetrySend?: () => void;
726
+ readonly onRetryExecution?: (message: string) => void;
727
+ readonly onEditMessage?: (text: string) => void;
648
728
  }
649
729
 
650
730
  /**
@@ -666,13 +746,26 @@ export function ThreadItemRenderer({
666
746
  onBuildFromPlan,
667
747
  org,
668
748
  planActionsDisabled,
749
+ onRetrySend,
750
+ onRetryExecution,
751
+ onEditMessage,
669
752
  }: ThreadItemRendererProps) {
670
753
  switch (item.kind) {
671
754
  case "message":
755
+ if (item.isFailed) {
756
+ return (
757
+ <FailedUserMessage message={item.message} onRetry={onRetrySend} />
758
+ );
759
+ }
672
760
  return (
673
761
  <MessageEntry
674
762
  message={item.message}
675
763
  className={item.isPending ? "opacity-70" : undefined}
764
+ onEdit={
765
+ item.isEditable && onEditMessage
766
+ ? () => onEditMessage(item.message.content)
767
+ : undefined
768
+ }
676
769
  />
677
770
  );
678
771
  case "tool-group":
@@ -697,6 +790,14 @@ export function ThreadItemRenderer({
697
790
  <ExecutionPhaseBadge phase={item.phase} />
698
791
  </div>
699
792
  );
793
+ case "execution-error":
794
+ return (
795
+ <ExecutionErrorNotice
796
+ error={item.error}
797
+ retryMessage={item.retryMessage}
798
+ onRetry={onRetryExecution}
799
+ />
800
+ );
700
801
  case "approval-request":
701
802
  return (
702
803
  <ApprovalCardRow
@@ -736,6 +837,103 @@ export function ThreadItemRenderer({
736
837
  }
737
838
  }
738
839
 
840
+ // ---------------------------------------------------------------------------
841
+ // FailedUserMessage — optimistic turn whose send failed
842
+ // ---------------------------------------------------------------------------
843
+
844
+ /**
845
+ * Renders a user message whose send failed: the message itself stays visible
846
+ * (so the typed text is never lost) with an inline, actionable error beneath
847
+ * it. The error copy is intentionally short — the full reason is surfaced by
848
+ * the consumer's send-error banner; this is the in-thread "Retry" affordance.
849
+ */
850
+ function FailedUserMessage({
851
+ message,
852
+ onRetry,
853
+ }: {
854
+ message: AgentMessage;
855
+ onRetry?: () => void;
856
+ }) {
857
+ return (
858
+ <div className="flex flex-col gap-1">
859
+ <MessageEntry message={message} />
860
+ <div
861
+ role="alert"
862
+ className="mx-4 flex items-center gap-2 text-xs text-destructive"
863
+ >
864
+ <span className="min-w-0 flex-1 truncate">Couldn&rsquo;t send.</span>
865
+ {onRetry && (
866
+ <button
867
+ type="button"
868
+ onClick={onRetry}
869
+ className="shrink-0 rounded font-medium underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
870
+ >
871
+ Retry
872
+ </button>
873
+ )}
874
+ </div>
875
+ </div>
876
+ );
877
+ }
878
+
879
+ // ---------------------------------------------------------------------------
880
+ // ExecutionErrorNotice — server failure reason for a terminal-failed execution
881
+ // ---------------------------------------------------------------------------
882
+
883
+ /**
884
+ * Renders the server-reported failure reason (`AgentExecutionStatus.error`)
885
+ * for an execution that died — typically before producing any messages. The
886
+ * reason can be a long Temporal error, so it is clamped by default with a
887
+ * Show more / Show less toggle. An optional Retry resends the originating
888
+ * message as a fresh execution.
889
+ */
890
+ function ExecutionErrorNotice({
891
+ error,
892
+ retryMessage,
893
+ onRetry,
894
+ }: {
895
+ error: string;
896
+ retryMessage?: string;
897
+ onRetry?: (message: string) => void;
898
+ }) {
899
+ const [expanded, setExpanded] = useState(false);
900
+ const canRetry = !!onRetry && !!retryMessage;
901
+
902
+ return (
903
+ <div
904
+ role="alert"
905
+ className="mx-4 flex flex-col gap-1.5 rounded-md bg-destructive-subtle px-3 py-2"
906
+ >
907
+ <p
908
+ className={cn(
909
+ "text-xs whitespace-pre-wrap break-words text-destructive",
910
+ !expanded && "line-clamp-3",
911
+ )}
912
+ >
913
+ {error}
914
+ </p>
915
+ <div className="flex items-center gap-3 text-xs">
916
+ <button
917
+ type="button"
918
+ onClick={() => setExpanded((v) => !v)}
919
+ className="font-medium text-muted-foreground underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
920
+ >
921
+ {expanded ? "Show less" : "Show more"}
922
+ </button>
923
+ {canRetry && (
924
+ <button
925
+ type="button"
926
+ onClick={() => onRetry!(retryMessage!)}
927
+ className="font-medium text-foreground underline-offset-2 hover:underline focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
928
+ >
929
+ Retry
930
+ </button>
931
+ )}
932
+ </div>
933
+ </div>
934
+ );
935
+ }
936
+
739
937
  // ---------------------------------------------------------------------------
740
938
  // ApprovalCardRow — stabilizes the onSubmit callback for React.memo
741
939
  // ---------------------------------------------------------------------------
@@ -73,6 +73,7 @@ function makeExecution(opts: {
73
73
  phase?: ExecutionPhase;
74
74
  interactionMode?: InteractionMode;
75
75
  aiContent?: string;
76
+ error?: string;
76
77
  }): AgentExecution {
77
78
  const exec = create(AgentExecutionSchema);
78
79
 
@@ -91,6 +92,9 @@ function makeExecution(opts: {
91
92
 
92
93
  const status = create(AgentExecutionStatusSchema);
93
94
  status.phase = opts.phase ?? ExecutionPhase.EXECUTION_COMPLETED;
95
+ if (opts.error !== undefined) {
96
+ status.error = opts.error;
97
+ }
94
98
 
95
99
  const humanMsg = create(AgentMessageSchema);
96
100
  humanMsg.type = MessageType.MESSAGE_HUMAN;
@@ -246,6 +250,132 @@ describe("MessageThread", () => {
246
250
  expect(screen.getByText(/failed/i)).toBeTruthy();
247
251
  });
248
252
 
253
+ it("surfaces the server failure reason for a FAILED execution", () => {
254
+ const exec = makeExecution({
255
+ id: "exec-fail-reason",
256
+ phase: ExecutionPhase.EXECUTION_FAILED,
257
+ error: "Activity task timed out (RETRY_STATE_MAXIMUM_ATTEMPTS_REACHED)",
258
+ });
259
+
260
+ render(<MessageThread executions={[exec]} />);
261
+
262
+ expect(
263
+ screen.getByText(/Activity task timed out/i),
264
+ ).toBeTruthy();
265
+ });
266
+
267
+ it("does NOT surface a failure reason for a COMPLETED execution", () => {
268
+ const exec = makeExecution({
269
+ id: "exec-ok",
270
+ phase: ExecutionPhase.EXECUTION_COMPLETED,
271
+ error: "stale error that should be ignored",
272
+ });
273
+
274
+ render(<MessageThread executions={[exec]} />);
275
+
276
+ expect(screen.queryByText(/stale error/i)).toBeNull();
277
+ });
278
+
279
+ it("offers a Retry that resends the originating message on failure", () => {
280
+ const exec = makeExecution({
281
+ id: "exec-retry",
282
+ specMessage: "do the thing",
283
+ phase: ExecutionPhase.EXECUTION_FAILED,
284
+ error: "boom",
285
+ });
286
+ const onRetryExecution = vi.fn();
287
+
288
+ render(
289
+ <MessageThread executions={[exec]} onRetryExecution={onRetryExecution} />,
290
+ );
291
+
292
+ fireEvent.click(screen.getByRole("button", { name: /retry/i }));
293
+ expect(onRetryExecution).toHaveBeenCalledWith("do the thing");
294
+ });
295
+
296
+ it("renders a failed pending message with an inline Retry", () => {
297
+ const onRetrySend = vi.fn();
298
+
299
+ render(
300
+ <MessageThread
301
+ executions={[]}
302
+ pendingUserMessage="unsent message"
303
+ pendingMessageFailed
304
+ onRetrySend={onRetrySend}
305
+ />,
306
+ );
307
+
308
+ expect(screen.getByText("unsent message")).toBeTruthy();
309
+ fireEvent.click(screen.getByRole("button", { name: /retry/i }));
310
+ expect(onRetrySend).toHaveBeenCalledOnce();
311
+ });
312
+
313
+ it("shows an Edit affordance on the in-flight human turn and routes onEditMessage", () => {
314
+ const active = makeExecution({
315
+ id: "exec-active",
316
+ specMessage: "fix the bug",
317
+ phase: ExecutionPhase.EXECUTION_IN_PROGRESS,
318
+ aiContent: "working on it",
319
+ });
320
+ const onEditMessage = vi.fn();
321
+
322
+ render(
323
+ <MessageThread
324
+ executions={[]}
325
+ activeStreamExecution={active}
326
+ onEditMessage={onEditMessage}
327
+ />,
328
+ );
329
+
330
+ const editBtn = screen.getByRole("button", { name: "Edit message" });
331
+ fireEvent.click(editBtn);
332
+ expect(onEditMessage).toHaveBeenCalledWith("fix the bug");
333
+ });
334
+
335
+ it("shows no Edit affordance when onEditMessage is omitted", () => {
336
+ const active = makeExecution({
337
+ id: "exec-active",
338
+ specMessage: "fix the bug",
339
+ phase: ExecutionPhase.EXECUTION_IN_PROGRESS,
340
+ });
341
+
342
+ render(
343
+ <MessageThread executions={[]} activeStreamExecution={active} />,
344
+ );
345
+
346
+ expect(
347
+ screen.queryByRole("button", { name: "Edit message" }),
348
+ ).toBeNull();
349
+ });
350
+
351
+ it("marks only the active-stream human turn editable, not completed turns", () => {
352
+ const completed = makeExecution({
353
+ id: "exec-done",
354
+ specMessage: "old turn",
355
+ phase: ExecutionPhase.EXECUTION_COMPLETED,
356
+ });
357
+ const active = makeExecution({
358
+ id: "exec-active",
359
+ specMessage: "new turn",
360
+ phase: ExecutionPhase.EXECUTION_IN_PROGRESS,
361
+ });
362
+ const onEditMessage = vi.fn();
363
+
364
+ render(
365
+ <MessageThread
366
+ executions={[completed]}
367
+ activeStreamExecution={active}
368
+ onEditMessage={onEditMessage}
369
+ />,
370
+ );
371
+
372
+ const editBtns = screen.getAllByRole("button", { name: "Edit message" });
373
+ expect(editBtns).toHaveLength(1);
374
+
375
+ fireEvent.click(editBtns[0]);
376
+ expect(onEditMessage).toHaveBeenCalledWith("new turn");
377
+ });
378
+
249
379
  it("renders plan-completion card when last execution is completed Plan mode", () => {
250
380
  const exec = makeExecution({
251
381
  id: "exec-plan",