@agent-native/toolkit 0.204.0 → 0.204.1-nightly-20261008065100

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 (168) hide show
  1. package/agent-native.eject.json +2 -0
  2. package/dist/app/agent-page/AgentJobsTab.d.ts.map +1 -1
  3. package/dist/app/agent-page/AgentJobsTab.js +4 -2
  4. package/dist/app/agent-page/AgentJobsTab.js.map +1 -1
  5. package/dist/app/agent-page/AutomationDetailsDialog.d.ts +2 -1
  6. package/dist/app/agent-page/AutomationDetailsDialog.d.ts.map +1 -1
  7. package/dist/app/agent-page/AutomationDetailsDialog.js +8 -4
  8. package/dist/app/agent-page/AutomationDetailsDialog.js.map +1 -1
  9. package/dist/app/agent-page/ScheduledTriggerNotice.d.ts.map +1 -1
  10. package/dist/app/agent-page/ScheduledTriggerNotice.js +41 -19
  11. package/dist/app/agent-page/ScheduledTriggerNotice.js.map +1 -1
  12. package/dist/app/agent-page/use-jobs.d.ts +1 -1
  13. package/dist/app/agent-page/use-jobs.d.ts.map +1 -1
  14. package/dist/app/agentkit/react/components.d.ts.map +1 -1
  15. package/dist/app/agentkit/react/components.js +124 -45
  16. package/dist/app/agentkit/react/components.js.map +1 -1
  17. package/dist/app/agentkit/react/context.d.ts +22 -1
  18. package/dist/app/agentkit/react/context.d.ts.map +1 -1
  19. package/dist/app/agentkit/react/context.js +14 -1
  20. package/dist/app/agentkit/react/context.js.map +1 -1
  21. package/dist/app/agentkit/react/root.d.ts +3 -2
  22. package/dist/app/agentkit/react/root.d.ts.map +1 -1
  23. package/dist/app/agentkit/react/root.js +2 -2
  24. package/dist/app/agentkit/react/root.js.map +1 -1
  25. package/dist/app/agentkit/react/styles.css +32 -2
  26. package/dist/app/chat/AgentKitAssistantChat.d.ts.map +1 -1
  27. package/dist/app/chat/AgentKitAssistantChat.js +74 -7
  28. package/dist/app/chat/AgentKitAssistantChat.js.map +1 -1
  29. package/dist/app/chat/AgentPanel.js +1 -1
  30. package/dist/app/chat/AgentPanel.js.map +1 -1
  31. package/dist/app/chat/MultiTabAssistantChat.d.ts.map +1 -1
  32. package/dist/app/chat/MultiTabAssistantChat.js +10 -9
  33. package/dist/app/chat/MultiTabAssistantChat.js.map +1 -1
  34. package/dist/app/chat/RunStuckBanner.d.ts +8 -1
  35. package/dist/app/chat/RunStuckBanner.d.ts.map +1 -1
  36. package/dist/app/chat/RunStuckBanner.js +68 -6
  37. package/dist/app/chat/RunStuckBanner.js.map +1 -1
  38. package/dist/app/chat/chat/run-recovery.d.ts +4 -2
  39. package/dist/app/chat/chat/run-recovery.d.ts.map +1 -1
  40. package/dist/app/chat/chat/run-recovery.js +3 -3
  41. package/dist/app/chat/chat/run-recovery.js.map +1 -1
  42. package/dist/app/chat/chat/surface-types.d.ts +1 -0
  43. package/dist/app/chat/chat/surface-types.d.ts.map +1 -1
  44. package/dist/app/chat/composer/use-mention-search.d.ts +1 -0
  45. package/dist/app/chat/composer/use-mention-search.d.ts.map +1 -1
  46. package/dist/app/chat/fallback-chat-title.d.ts +2 -0
  47. package/dist/app/chat/fallback-chat-title.d.ts.map +1 -0
  48. package/dist/app/chat/fallback-chat-title.js +11 -0
  49. package/dist/app/chat/fallback-chat-title.js.map +1 -0
  50. package/dist/app/chat/feedback-report.d.ts +12 -0
  51. package/dist/app/chat/feedback-report.d.ts.map +1 -0
  52. package/dist/app/chat/feedback-report.js +21 -0
  53. package/dist/app/chat/feedback-report.js.map +1 -0
  54. package/dist/app/chat/reconcile-settled-run.d.ts +15 -0
  55. package/dist/app/chat/reconcile-settled-run.d.ts.map +1 -0
  56. package/dist/app/chat/reconcile-settled-run.js +41 -0
  57. package/dist/app/chat/reconcile-settled-run.js.map +1 -0
  58. package/dist/app/i18n/catalogs/ar-SA.d.ts.map +1 -1
  59. package/dist/app/i18n/catalogs/ar-SA.js +9 -0
  60. package/dist/app/i18n/catalogs/ar-SA.js.map +1 -1
  61. package/dist/app/i18n/catalogs/de-DE.d.ts.map +1 -1
  62. package/dist/app/i18n/catalogs/de-DE.js +9 -0
  63. package/dist/app/i18n/catalogs/de-DE.js.map +1 -1
  64. package/dist/app/i18n/catalogs/en-US.d.ts +9 -0
  65. package/dist/app/i18n/catalogs/en-US.d.ts.map +1 -1
  66. package/dist/app/i18n/catalogs/en-US.js +9 -0
  67. package/dist/app/i18n/catalogs/en-US.js.map +1 -1
  68. package/dist/app/i18n/catalogs/es-ES.d.ts.map +1 -1
  69. package/dist/app/i18n/catalogs/es-ES.js +9 -0
  70. package/dist/app/i18n/catalogs/es-ES.js.map +1 -1
  71. package/dist/app/i18n/catalogs/fr-FR.d.ts.map +1 -1
  72. package/dist/app/i18n/catalogs/fr-FR.js +9 -0
  73. package/dist/app/i18n/catalogs/fr-FR.js.map +1 -1
  74. package/dist/app/i18n/catalogs/hi-IN.d.ts.map +1 -1
  75. package/dist/app/i18n/catalogs/hi-IN.js +9 -0
  76. package/dist/app/i18n/catalogs/hi-IN.js.map +1 -1
  77. package/dist/app/i18n/catalogs/ja-JP.d.ts.map +1 -1
  78. package/dist/app/i18n/catalogs/ja-JP.js +9 -0
  79. package/dist/app/i18n/catalogs/ja-JP.js.map +1 -1
  80. package/dist/app/i18n/catalogs/ko-KR.d.ts.map +1 -1
  81. package/dist/app/i18n/catalogs/ko-KR.js +9 -0
  82. package/dist/app/i18n/catalogs/ko-KR.js.map +1 -1
  83. package/dist/app/i18n/catalogs/pt-BR.d.ts.map +1 -1
  84. package/dist/app/i18n/catalogs/pt-BR.js +9 -0
  85. package/dist/app/i18n/catalogs/pt-BR.js.map +1 -1
  86. package/dist/app/i18n/catalogs/zh-CN.d.ts.map +1 -1
  87. package/dist/app/i18n/catalogs/zh-CN.js +9 -0
  88. package/dist/app/i18n/catalogs/zh-CN.js.map +1 -1
  89. package/dist/app/i18n/catalogs/zh-TW.d.ts.map +1 -1
  90. package/dist/app/i18n/catalogs/zh-TW.js +9 -0
  91. package/dist/app/i18n/catalogs/zh-TW.js.map +1 -1
  92. package/dist/app/providers/AppProviders.d.ts.map +1 -1
  93. package/dist/app/providers/AppProviders.js +7 -1
  94. package/dist/app/providers/AppProviders.js.map +1 -1
  95. package/dist/app/settings/shell/pages/audit.d.ts.map +1 -1
  96. package/dist/app/settings/shell/pages/audit.js +11 -2
  97. package/dist/app/settings/shell/pages/audit.js.map +1 -1
  98. package/dist/app/shared/AppShellSkeleton.d.ts.map +1 -1
  99. package/dist/app/shared/AppShellSkeleton.js +4 -0
  100. package/dist/app/shared/AppShellSkeleton.js.map +1 -1
  101. package/dist/composer/ComposerContextMenu.d.ts +14 -1
  102. package/dist/composer/ComposerContextMenu.d.ts.map +1 -1
  103. package/dist/composer/ComposerContextMenu.js +65 -19
  104. package/dist/composer/ComposerContextMenu.js.map +1 -1
  105. package/dist/composer/MentionPopover.d.ts.map +1 -1
  106. package/dist/composer/MentionPopover.js +3 -3
  107. package/dist/composer/MentionPopover.js.map +1 -1
  108. package/dist/composer/PromptComposer.d.ts +1 -1
  109. package/dist/composer/PromptComposer.d.ts.map +1 -1
  110. package/dist/composer/TiptapComposer.d.ts +2 -2
  111. package/dist/composer/TiptapComposer.d.ts.map +1 -1
  112. package/dist/composer/TiptapComposer.js +92 -36
  113. package/dist/composer/TiptapComposer.js.map +1 -1
  114. package/dist/composer/use-mention-search.d.ts +1 -0
  115. package/dist/composer/use-mention-search.d.ts.map +1 -1
  116. package/dist/composer/use-mention-search.js +8 -1
  117. package/dist/composer/use-mention-search.js.map +1 -1
  118. package/package.json +4 -4
  119. package/src/app/agent-page/AgentJobsTab.blocked.spec.tsx +43 -0
  120. package/src/app/agent-page/AgentJobsTab.tsx +14 -5
  121. package/src/app/agent-page/AutomationDetailsDialog.tsx +22 -8
  122. package/src/app/agent-page/ScheduledTriggerNotice.spec.tsx +45 -3
  123. package/src/app/agent-page/ScheduledTriggerNotice.tsx +52 -31
  124. package/src/app/agent-page/use-jobs.ts +1 -1
  125. package/src/app/agentkit/react/components.interaction.spec.tsx +311 -1
  126. package/src/app/agentkit/react/components.spec.tsx +106 -2
  127. package/src/app/agentkit/react/components.tsx +217 -80
  128. package/src/app/agentkit/react/context.tsx +36 -0
  129. package/src/app/agentkit/react/root.tsx +4 -0
  130. package/src/app/agentkit/react/styles.css +32 -2
  131. package/src/app/chat/AgentKitAssistantChat.spec.tsx +140 -18
  132. package/src/app/chat/AgentKitAssistantChat.tsx +123 -7
  133. package/src/app/chat/AgentPanel.header.spec.ts +43 -7
  134. package/src/app/chat/AgentPanel.tsx +1 -1
  135. package/src/app/chat/MultiTabAssistantChat.spec.tsx +34 -0
  136. package/src/app/chat/MultiTabAssistantChat.tsx +10 -9
  137. package/src/app/chat/RunStuckBanner.spec.tsx +258 -17
  138. package/src/app/chat/RunStuckBanner.tsx +107 -5
  139. package/src/app/chat/chat/run-recovery.tsx +10 -2
  140. package/src/app/chat/chat/surface-types.ts +1 -0
  141. package/src/app/chat/fallback-chat-title.ts +10 -0
  142. package/src/app/chat/feedback-report.spec.ts +45 -0
  143. package/src/app/chat/feedback-report.ts +27 -0
  144. package/src/app/chat/reconcile-settled-run.spec.ts +112 -0
  145. package/src/app/chat/reconcile-settled-run.ts +57 -0
  146. package/src/app/i18n/catalogs/ar-SA.ts +12 -0
  147. package/src/app/i18n/catalogs/de-DE.ts +12 -0
  148. package/src/app/i18n/catalogs/en-US.ts +12 -0
  149. package/src/app/i18n/catalogs/es-ES.ts +12 -0
  150. package/src/app/i18n/catalogs/fr-FR.ts +12 -0
  151. package/src/app/i18n/catalogs/hi-IN.ts +12 -0
  152. package/src/app/i18n/catalogs/ja-JP.ts +12 -0
  153. package/src/app/i18n/catalogs/ko-KR.ts +12 -0
  154. package/src/app/i18n/catalogs/pt-BR.ts +12 -0
  155. package/src/app/i18n/catalogs/zh-CN.ts +10 -0
  156. package/src/app/i18n/catalogs/zh-TW.ts +10 -0
  157. package/src/app/providers/AppProviders.spec.tsx +29 -0
  158. package/src/app/providers/AppProviders.tsx +13 -0
  159. package/src/app/settings/shell/pages/audit.spec.tsx +30 -0
  160. package/src/app/settings/shell/pages/audit.tsx +12 -1
  161. package/src/app/shared/AppShellSkeleton.tsx +4 -0
  162. package/src/composer/ComposerContextMenu.tsx +94 -22
  163. package/src/composer/ComposerPlusMenu.parity.spec.tsx +218 -233
  164. package/src/composer/MentionPopover.tsx +12 -3
  165. package/src/composer/PromptComposer.context.spec.tsx +527 -195
  166. package/src/composer/PromptComposer.tsx +1 -1
  167. package/src/composer/TiptapComposer.tsx +105 -37
  168. package/src/composer/use-mention-search.ts +8 -1
@@ -86,6 +86,7 @@ import type {
86
86
  AssistantChatHandle,
87
87
  AssistantChatSendOptions,
88
88
  } from "./chat/surface-types.js";
89
+ import { fallbackChatTitle } from "./fallback-chat-title.js";
89
90
 
90
91
  type AgentActionScope = NonNullable<AgentChatMessage["actionScope"]>;
91
92
 
@@ -2725,15 +2726,14 @@ export function MultiTabAssistantChat({
2725
2726
  selection: { engine?: string; model?: string },
2726
2727
  ) => {
2727
2728
  void generateTitle(threadId, message, selection).then((title) => {
2728
- if (title) {
2729
- // Persist the generated title to the server
2730
- void saveThreadData(threadId, {
2731
- threadData: "",
2732
- title,
2733
- preview: message.slice(0, 120),
2734
- titleSource: "generated",
2735
- });
2736
- }
2729
+ const resolvedTitle = title ?? fallbackChatTitle(message);
2730
+ if (!resolvedTitle) return;
2731
+ void saveThreadData(threadId, {
2732
+ threadData: "",
2733
+ title: resolvedTitle,
2734
+ preview: message.slice(0, 120),
2735
+ titleSource: title ? "generated" : "fallback",
2736
+ });
2737
2737
  });
2738
2738
  },
2739
2739
  [generateTitle, saveThreadData],
@@ -2747,6 +2747,7 @@ export function MultiTabAssistantChat({
2747
2747
  title: string;
2748
2748
  preview: string;
2749
2749
  messageCount: number;
2750
+ titleSource?: "fallback";
2750
2751
  },
2751
2752
  ) => {
2752
2753
  void saveThreadData(threadId, {
@@ -1,5 +1,6 @@
1
1
  // @vitest-environment happy-dom
2
2
 
3
+ import type { RunStuckState } from "@agent-native/core/client/agent-chat";
3
4
  import { act } from "react";
4
5
  import { createRoot, type Root } from "react-dom/client";
5
6
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
@@ -8,20 +9,42 @@ import { RunStuckBanner } from "./RunStuckBanner.js";
8
9
 
9
10
  const trackEventMock = vi.hoisted(() => vi.fn());
10
11
 
12
+ const STUCK_STATE: RunStuckState = {
13
+ isStuck: true,
14
+ runId: "run-1",
15
+ status: "running",
16
+ lastProgressAt: 0,
17
+ stuckSinceMs: 90_000,
18
+ lastProgressSeq: 1,
19
+ heartbeatAt: null,
20
+ heartbeatSinceMs: null,
21
+ dispatchMode: null,
22
+ hasInFlightWork: false,
23
+ serverSettled: false,
24
+ statusUnreadable: false,
25
+ };
26
+
27
+ const SETTLED_STATE: RunStuckState = {
28
+ ...STUCK_STATE,
29
+ isStuck: false,
30
+ status: "idle",
31
+ stuckSinceMs: null,
32
+ serverSettled: true,
33
+ };
34
+
35
+ const hookState = vi.hoisted(() => ({
36
+ current: null as RunStuckState | null,
37
+ awaitingResponse: undefined as boolean | undefined,
38
+ }));
39
+
40
+ const abortRunMock = vi.hoisted(() => vi.fn(async () => null));
41
+
11
42
  vi.mock("@agent-native/core/client/agent-chat", () => ({
12
- useRunStuckDetection: () => ({
13
- isStuck: true,
14
- runId: "run-1",
15
- status: "running",
16
- lastProgressAt: 0,
17
- stuckSinceMs: 90_000,
18
- lastProgressSeq: 1,
19
- heartbeatAt: null,
20
- heartbeatSinceMs: null,
21
- dispatchMode: null,
22
- hasInFlightWork: false,
23
- }),
24
- useAbortRun: () => vi.fn(),
43
+ useRunStuckDetection: (options: { awaitingResponse?: boolean }) => {
44
+ hookState.awaitingResponse = options.awaitingResponse;
45
+ return hookState.current;
46
+ },
47
+ useAbortRun: () => abortRunMock,
25
48
  }));
26
49
 
27
50
  vi.mock("@agent-native/core/client/analytics", () => ({
@@ -38,28 +61,246 @@ describe("RunStuckBanner", () => {
38
61
 
39
62
  beforeEach(() => {
40
63
  vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
64
+ vi.useFakeTimers({ toFake: ["Date"] });
65
+ vi.setSystemTime(1_000_000);
66
+ trackEventMock.mockClear();
67
+ abortRunMock.mockClear();
68
+ window.localStorage.clear();
69
+ hookState.current = STUCK_STATE;
41
70
  container = document.createElement("div");
42
71
  document.body.appendChild(container);
72
+ root = createRoot(container);
43
73
  });
44
74
 
45
75
  afterEach(() => {
76
+ act(() => root.unmount());
46
77
  container.remove();
78
+ vi.useRealTimers();
47
79
  vi.unstubAllGlobals();
48
80
  });
49
81
 
82
+ async function render(props: Parameters<typeof RunStuckBanner>[0]) {
83
+ await act(async () => {
84
+ root.render(<RunStuckBanner {...props} />);
85
+ });
86
+ }
87
+
50
88
  it("reports a stuck run once, even when the chat view remounts", async () => {
51
89
  for (let mount = 0; mount < 2; mount += 1) {
52
- root = createRoot(container);
90
+ const remounted = createRoot(document.createElement("div"));
53
91
  await act(async () => {
54
- root.render(<RunStuckBanner threadId="thread-1" />);
92
+ remounted.render(<RunStuckBanner threadId="thread-1" />);
55
93
  });
56
- await act(async () => root.unmount());
94
+ await act(async () => remounted.unmount());
57
95
  }
58
96
 
59
97
  expect(trackEventMock).toHaveBeenCalledTimes(1);
60
98
  expect(trackEventMock).toHaveBeenCalledWith(
61
99
  "agent_chat_stuck_detected",
62
- expect.objectContaining({ runId: "run-1", threadId: "thread-1" }),
100
+ expect.objectContaining({
101
+ runId: "run-1",
102
+ threadId: "thread-1",
103
+ reason: "no_progress",
104
+ dispatchMode: null,
105
+ hasInFlightWork: false,
106
+ }),
107
+ );
108
+ });
109
+
110
+ const isAwaitingResponse = () => true;
111
+
112
+ describe("when the server stops tracking a run the chat shows as running", () => {
113
+ beforeEach(() => {
114
+ hookState.current = SETTLED_STATE;
115
+ });
116
+
117
+ it("reloads the thread once and says nothing when that settles the chat", async () => {
118
+ const onServerSettled = vi.fn(async () => "settled" as const);
119
+
120
+ await render({
121
+ threadId: "thread-1",
122
+ onServerSettled,
123
+ isAwaitingResponse,
124
+ });
125
+ await render({
126
+ threadId: "thread-1",
127
+ onServerSettled,
128
+ isAwaitingResponse,
129
+ });
130
+
131
+ expect(onServerSettled).toHaveBeenCalledTimes(1);
132
+ expect(container.textContent).toBe("");
133
+ });
134
+
135
+ it("does not reconcile a host that cannot say whether the chat is waiting", async () => {
136
+ const onServerSettled = vi.fn(async () => "settled" as const);
137
+
138
+ await render({ threadId: "thread-1", onServerSettled });
139
+
140
+ expect(onServerSettled).not.toHaveBeenCalled();
141
+ });
142
+
143
+ it("does nothing for a chat that is not waiting on a response", async () => {
144
+ const onServerSettled = vi.fn(async () => "settled" as const);
145
+
146
+ await render({
147
+ threadId: "thread-1",
148
+ onServerSettled,
149
+ isAwaitingResponse: () => false,
150
+ });
151
+
152
+ expect(onServerSettled).not.toHaveBeenCalled();
153
+ expect(container.textContent).toBe("");
154
+ });
155
+
156
+ it("tells the user when the reload could not settle the chat, and retries at a bounded pace", async () => {
157
+ const onServerSettled = vi.fn(async () => "still_running" as const);
158
+
159
+ await render({
160
+ threadId: "thread-1",
161
+ onServerSettled,
162
+ isAwaitingResponse,
163
+ });
164
+ expect(container.textContent).toContain(
165
+ "agentChat.recovery.statusMismatch",
166
+ );
167
+ expect(container.textContent).toContain("agentChat.recovery.reload");
168
+ expect(container.textContent).not.toContain("recovery.stuckTitle");
169
+
170
+ // Every poll hands the banner a fresh state object.
171
+ vi.setSystemTime(1_015_000);
172
+ hookState.current = { ...SETTLED_STATE };
173
+ await render({
174
+ threadId: "thread-1",
175
+ onServerSettled,
176
+ isAwaitingResponse,
177
+ });
178
+ expect(onServerSettled).toHaveBeenCalledTimes(1);
179
+
180
+ vi.setSystemTime(1_031_000);
181
+ hookState.current = { ...SETTLED_STATE };
182
+ await render({
183
+ threadId: "thread-1",
184
+ onServerSettled,
185
+ isAwaitingResponse,
186
+ });
187
+ expect(onServerSettled).toHaveBeenCalledTimes(2);
188
+ });
189
+
190
+ it("tells the detector whether the chat is waiting, so a settle predating the wait is dropped", async () => {
191
+ await render({ threadId: "thread-1", isAwaitingResponse: () => true });
192
+ expect(hookState.awaitingResponse).toBe(true);
193
+
194
+ await render({ threadId: "thread-1", isAwaitingResponse: () => false });
195
+ expect(hookState.awaitingResponse).toBe(false);
196
+
197
+ await render({ threadId: "thread-1" });
198
+ expect(hookState.awaitingResponse).toBeUndefined();
199
+ });
200
+
201
+ it("ignores a reload that finishes after the server started a run again", async () => {
202
+ let finishReload: (outcome: "still_running") => void = () => {};
203
+ const onServerSettled = vi.fn(
204
+ () =>
205
+ new Promise<"still_running">((resolve) => {
206
+ finishReload = resolve;
207
+ }),
208
+ );
209
+
210
+ await render({
211
+ threadId: "thread-1",
212
+ onServerSettled,
213
+ isAwaitingResponse,
214
+ });
215
+ expect(onServerSettled).toHaveBeenCalledTimes(1);
216
+
217
+ hookState.current = {
218
+ ...STUCK_STATE,
219
+ isStuck: false,
220
+ serverSettled: false,
221
+ };
222
+ await render({
223
+ threadId: "thread-1",
224
+ onServerSettled,
225
+ isAwaitingResponse,
226
+ });
227
+ await act(async () => finishReload("still_running"));
228
+
229
+ expect(container.textContent).toBe("");
230
+ });
231
+
232
+ it("shows an unreadable status, not a finished chat, when the reload itself fails", async () => {
233
+ const onServerSettled = vi.fn(async () => {
234
+ throw new Error("thread unavailable");
235
+ });
236
+
237
+ await render({
238
+ threadId: "thread-1",
239
+ onServerSettled,
240
+ isAwaitingResponse,
241
+ });
242
+
243
+ expect(container.textContent).toContain(
244
+ "agentChat.recovery.statusUnreadable",
245
+ );
246
+ expect(container.textContent).not.toContain(
247
+ "agentChat.recovery.statusMismatch",
248
+ );
249
+ });
250
+ });
251
+
252
+ it("shows an unreadable status when the run's status cannot be fetched", async () => {
253
+ hookState.current = {
254
+ ...STUCK_STATE,
255
+ isStuck: false,
256
+ stuckSinceMs: null,
257
+ statusUnreadable: true,
258
+ };
259
+
260
+ await render({ threadId: "thread-1" });
261
+ expect(container.textContent).toContain(
262
+ "agentChat.recovery.statusUnreadable",
263
+ );
264
+ expect(container.textContent).not.toContain("recovery.stuckTitle");
265
+ expect(container.textContent).not.toContain("agentChat.recovery.reload");
266
+
267
+ await render({ threadId: "thread-1", isAwaitingResponse: () => false });
268
+ expect(container.textContent).toBe("");
269
+
270
+ // A host that cannot say whether the chat waits only doubts a run it saw.
271
+ hookState.current = { ...hookState.current!, status: "idle", runId: null };
272
+ await render({ threadId: "thread-1" });
273
+ expect(container.textContent).toBe("");
274
+ });
275
+
276
+ it("shows the unreadable status, not a stuck banner, when the status is unreadable", async () => {
277
+ hookState.current = { ...STUCK_STATE, statusUnreadable: true };
278
+
279
+ await render({ threadId: "thread-1" });
280
+
281
+ expect(container.textContent).toContain(
282
+ "agentChat.recovery.statusUnreadable",
63
283
  );
284
+ expect(container.textContent).not.toContain(
285
+ "agentChat.recovery.stuckTitle",
286
+ );
287
+ expect(container.querySelectorAll("button")).toHaveLength(0);
288
+ });
289
+
290
+ it("does not auto-abort a run on a stuck verdict it cannot confirm", async () => {
291
+ const props = {
292
+ threadId: "thread-auto-retry",
293
+ autoRetry: true,
294
+ autoRetryOwnerId: "owner-1",
295
+ };
296
+ hookState.current = { ...STUCK_STATE, statusUnreadable: true };
297
+
298
+ await render(props);
299
+ expect(abortRunMock).not.toHaveBeenCalled();
300
+
301
+ hookState.current = { ...STUCK_STATE };
302
+ await render(props);
303
+ await act(async () => {});
304
+ expect(abortRunMock).toHaveBeenCalledWith("run-1", "auto_stuck_retry");
64
305
  });
65
306
  });
@@ -18,11 +18,20 @@ export interface RunStuckBannerProps {
18
18
  hasInFlightWork?: () => boolean;
19
19
  isAwaitingResponse?: () => boolean;
20
20
  onStuckStateChange?: (state: RunStuckState) => void;
21
+ /**
22
+ * The server has stopped tracking a run this chat still shows as running.
23
+ * Resolves `settled` once the chat no longer shows it running; rejects when
24
+ * the thread could not be refreshed.
25
+ */
26
+ onServerSettled?: () => Promise<RunReconcileOutcome>;
21
27
  autoRetry?: boolean;
22
28
  autoRetryOwnerId?: string;
23
29
  className?: string;
24
30
  }
25
31
 
32
+ export type RunReconcileOutcome = "settled" | "still_running";
33
+
34
+ const RECONCILE_RETRY_MS = 30_000;
26
35
  const AUTO_RETRY_CLAIM_TTL_MS = 5 * 60 * 1000;
27
36
  const BACKGROUND_WORKER_FRESH_HEARTBEAT_MS = 30_000;
28
37
 
@@ -123,6 +132,7 @@ export function RunStuckBanner({
123
132
  stuckThresholdMs,
124
133
  onRetry,
125
134
  onStuckStateChange,
135
+ onServerSettled,
126
136
  autoRetry = false,
127
137
  autoRetryOwnerId,
128
138
  hasInFlightWork,
@@ -130,9 +140,11 @@ export function RunStuckBanner({
130
140
  className,
131
141
  }: RunStuckBannerProps) {
132
142
  const t = useT();
143
+ const chatAwaiting = isAwaitingResponse?.();
133
144
  const state = useRunStuckDetection({
134
145
  threadId,
135
146
  enabled,
147
+ awaitingResponse: chatAwaiting,
136
148
  stuckThresholdMs,
137
149
  apiUrl,
138
150
  });
@@ -148,9 +160,13 @@ export function RunStuckBanner({
148
160
  const backgroundWorkerStillAlive = isFreshBackgroundWorker(state);
149
161
  const inFlightWork =
150
162
  state.hasInFlightWork === true || (hasInFlightWork?.() ?? false);
151
- const awaitingResponse = isAwaitingResponse?.() ?? true;
163
+ const awaitingResponse = chatAwaiting ?? true;
164
+ // While the status is unreadable `isStuck` is the last answer carried forward
165
+ // by the clock, not a fresh one: only the unreadable notice may speak, and
166
+ // nothing may abort a run on it.
167
+ const isStuck = state.isStuck && !state.statusUnreadable;
152
168
  const showsStuckBanner =
153
- state.isStuck &&
169
+ isStuck &&
154
170
  !!state.runId &&
155
171
  !backgroundWorkerStillAlive &&
156
172
  !inFlightWork &&
@@ -159,6 +175,50 @@ export function RunStuckBanner({
159
175
  state.dispatchMode === "foreground-self-chain" ||
160
176
  state.dispatchMode?.startsWith("background") === true;
161
177
 
178
+ // Only a host that says when the chat is waiting can tell a lost ending from
179
+ // an idle chat, so reconciling needs `isAwaitingResponse`.
180
+ const chatShowsRunning = isAwaitingResponse != null && awaitingResponse;
181
+ const settledWhileRunning = chatShowsRunning && state.serverSettled;
182
+ const [reconcile, setReconcile] = useState<"idle" | "failed" | "unsettled">(
183
+ "idle",
184
+ );
185
+ const lastReconcileAtRef = useRef(0);
186
+ const reconcileEpochRef = useRef(0);
187
+ const mountedRef = useRef(true);
188
+ useEffect(() => {
189
+ mountedRef.current = true;
190
+ return () => {
191
+ mountedRef.current = false;
192
+ };
193
+ }, []);
194
+ // `state` changes on every poll, so a reconcile that did not settle the chat
195
+ // is retried at most once per RECONCILE_RETRY_MS, never in a loop.
196
+ useEffect(() => {
197
+ if (!settledWhileRunning || !onServerSettled) {
198
+ // A reload still in flight answers a chat that has since moved on.
199
+ reconcileEpochRef.current += 1;
200
+ lastReconcileAtRef.current = 0;
201
+ setReconcile("idle");
202
+ return;
203
+ }
204
+ const now = Date.now();
205
+ if (now - lastReconcileAtRef.current < RECONCILE_RETRY_MS) return;
206
+ lastReconcileAtRef.current = now;
207
+ const epoch = reconcileEpochRef.current;
208
+ const current = () =>
209
+ mountedRef.current && epoch === reconcileEpochRef.current;
210
+ onServerSettled().then(
211
+ (outcome) => {
212
+ if (current()) {
213
+ setReconcile(outcome === "settled" ? "idle" : "unsettled");
214
+ }
215
+ },
216
+ () => {
217
+ if (current()) setReconcile("failed");
218
+ },
219
+ );
220
+ }, [onServerSettled, settledWhileRunning, state]);
221
+
162
222
  const lastReportedRef = useRef<{
163
223
  isStuck: boolean;
164
224
  runId: string | null;
@@ -185,6 +245,13 @@ export function RunStuckBanner({
185
245
  ? Math.floor(state.stuckSinceMs / 1000)
186
246
  : null,
187
247
  runStatus: state.status,
248
+ reason: "no_progress",
249
+ dispatchMode: state.dispatchMode,
250
+ hasInFlightWork: state.hasInFlightWork,
251
+ heartbeatSinceSec:
252
+ state.heartbeatSinceMs != null
253
+ ? Math.floor(state.heartbeatSinceMs / 1000)
254
+ : null,
188
255
  });
189
256
  }, [showsStuckBanner, state, threadId]);
190
257
 
@@ -204,7 +271,7 @@ export function RunStuckBanner({
204
271
  backgroundWorkerStillAlive ||
205
272
  inFlightWork ||
206
273
  !awaitingResponse ||
207
- !state.isStuck ||
274
+ !isStuck ||
208
275
  !state.runId ||
209
276
  busy.type !== "none" ||
210
277
  autoRetriedRunIdsRef.current.has(state.runId)
@@ -239,9 +306,9 @@ export function RunStuckBanner({
239
306
  busy,
240
307
  inFlightWork,
241
308
  isServerContinuedDispatch,
309
+ isStuck,
242
310
  onRetry,
243
311
  ownerId,
244
- state.isStuck,
245
312
  state.runId,
246
313
  state.stuckSinceMs,
247
314
  threadId,
@@ -249,7 +316,42 @@ export function RunStuckBanner({
249
316
  ]);
250
317
 
251
318
  if (!showsStuckBanner) {
252
- return null;
319
+ const notice =
320
+ reconcile === "unsettled"
321
+ ? "agentChat.recovery.statusMismatch"
322
+ : (isAwaitingResponse
323
+ ? awaitingResponse
324
+ : state.status === "running") &&
325
+ (state.statusUnreadable || reconcile === "failed")
326
+ ? "agentChat.recovery.statusUnreadable"
327
+ : null;
328
+ if (!notice) return null;
329
+ return (
330
+ <div
331
+ role="status"
332
+ aria-live="polite"
333
+ className={cn(
334
+ "mx-3 mt-2 flex items-center gap-2.5 rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-2.5 text-xs text-foreground",
335
+ className,
336
+ )}
337
+ >
338
+ <IconAlertTriangle
339
+ size={16}
340
+ className="shrink-0 text-amber-500"
341
+ aria-hidden="true"
342
+ />
343
+ <span className="min-w-0 flex-1 leading-snug">{t(notice)}</span>
344
+ {reconcile === "unsettled" ? (
345
+ <button
346
+ type="button"
347
+ onClick={() => window.location.reload()}
348
+ className="inline-flex h-7 shrink-0 cursor-pointer items-center rounded-md bg-foreground px-2.5 text-[11px] font-medium text-background transition-colors hover:bg-foreground/90"
349
+ >
350
+ {t("agentChat.recovery.reload")}
351
+ </button>
352
+ ) : null}
353
+ </div>
354
+ );
253
355
  }
254
356
 
255
357
  const handleCancel = async () => {
@@ -523,9 +523,12 @@ export function RunErrorRecoveryCard({
523
523
  onFork,
524
524
  onDismiss,
525
525
  onProviderConnected,
526
+ continueError,
526
527
  }: {
527
528
  info: RunErrorInfo;
528
- onContinue: () => void;
529
+ /** Absent when the stopped run can no longer be continued. */
530
+ onContinue?: () => void;
531
+ continueError?: string | null;
529
532
  onRetry: () => void;
530
533
  retryHasUnavailableAttachment?: boolean;
531
534
  onFork?: () => void | boolean | Promise<void | boolean>;
@@ -801,7 +804,7 @@ export function RunErrorRecoveryCard({
801
804
  </button>
802
805
  </DeferredBuilderConnectPopover>
803
806
  )}
804
- {canRecover && (
807
+ {canRecover && onContinue && (
805
808
  <button
806
809
  type="button"
807
810
  onClick={onContinue}
@@ -902,6 +905,11 @@ export function RunErrorRecoveryCard({
902
905
  {forkError && (
903
906
  <p className="mt-2 text-xs leading-relaxed text-red-500">{forkError}</p>
904
907
  )}
908
+ {continueError && (
909
+ <p className="mt-2 text-xs leading-relaxed text-destructive">
910
+ {continueError}
911
+ </p>
912
+ )}
905
913
  </div>
906
914
  );
907
915
  }
@@ -232,6 +232,7 @@ export interface AssistantChatProps {
232
232
  title: string;
233
233
  preview: string;
234
234
  messageCount: number;
235
+ titleSource?: "fallback";
235
236
  },
236
237
  ) => void;
237
238
  /** Callback to generate a title from the first user message, on the model it was sent with */
@@ -0,0 +1,10 @@
1
+ export function fallbackChatTitle(message: string): string {
2
+ return message
3
+ .replace(/<context\b[^>]*>[\s\S]*?<\/context>\n?/gi, "")
4
+ .replace(/<context\b[^>]*>[\s\S]*$/gi, "")
5
+ .replace(/<\/context>/gi, "")
6
+ .replace(/@\[([^\]|]+)\|[^\]]*\]/g, "@$1")
7
+ .replace(/\s+/g, " ")
8
+ .trim()
9
+ .slice(0, 80);
10
+ }
@@ -0,0 +1,45 @@
1
+ // @vitest-environment happy-dom
2
+
3
+ import { beforeEach, describe, expect, it } from "vitest";
4
+
5
+ import { formatFeedbackReport } from "./feedback-report.js";
6
+
7
+ describe("formatFeedbackReport", () => {
8
+ beforeEach(() => {
9
+ sessionStorage.clear();
10
+ window.history.replaceState(null, "", "/");
11
+ });
12
+
13
+ it("names the run, thread link, message and note so a pasted report opens the exact run", () => {
14
+ const lines = formatFeedbackReport({
15
+ threadId: "thr_1",
16
+ runId: "run_1",
17
+ messageId: "msg_1",
18
+ note: "Said done,\nbut wasn't. Wrong numbers",
19
+ }).split("\n");
20
+
21
+ expect(lines).toEqual(
22
+ expect.arrayContaining([
23
+ "Agent-Native failure report",
24
+ `thread: ${window.location.origin}/?thread=thr_1`,
25
+ "run: run_1",
26
+ 'inspect: get-agent-thread-debug {"runId":"run_1"}',
27
+ "message: msg_1",
28
+ "note: Said done, but wasn't. Wrong numbers",
29
+ ]),
30
+ );
31
+ expect(lines.some((line) => line.startsWith("build: "))).toBe(true);
32
+ });
33
+
34
+ it("omits the note line and the run when there are none", () => {
35
+ const text = formatFeedbackReport({
36
+ threadId: "thr_2",
37
+ messageId: "msg_2",
38
+ note: " ",
39
+ });
40
+
41
+ expect(text).not.toContain("note:");
42
+ expect(text).not.toContain("run:");
43
+ expect(text).toContain("message: msg_2");
44
+ });
45
+ });
@@ -0,0 +1,27 @@
1
+ import { formatClientFailureReport } from "@agent-native/core/client/failure-report";
2
+
3
+ const MAX_NOTE_CHARS = 600;
4
+
5
+ /**
6
+ * "Copy details" for a thumbs-down: the same packet an error card copies, so a
7
+ * pasted report opens the exact thread and run, plus the message and the
8
+ * reader's note flattened to one line.
9
+ */
10
+ export function formatFeedbackReport(input: {
11
+ threadId: string;
12
+ runId?: string;
13
+ messageId: string;
14
+ note: string;
15
+ }): string {
16
+ const note = input.note.replace(/\s+/g, " ").trim().slice(0, MAX_NOTE_CHARS);
17
+ return [
18
+ formatClientFailureReport({
19
+ threadId: input.threadId,
20
+ ...(input.runId ? { runId: input.runId } : {}),
21
+ }),
22
+ `message: ${input.messageId}`,
23
+ note ? `note: ${note}` : "",
24
+ ]
25
+ .filter(Boolean)
26
+ .join("\n");
27
+ }