@agent-native/toolkit 0.204.0 → 0.204.1-nightly-20261008034100
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/app/agent-page/AgentJobsTab.d.ts.map +1 -1
- package/dist/app/agent-page/AgentJobsTab.js +4 -2
- package/dist/app/agent-page/AgentJobsTab.js.map +1 -1
- package/dist/app/agent-page/AutomationDetailsDialog.d.ts +2 -1
- package/dist/app/agent-page/AutomationDetailsDialog.d.ts.map +1 -1
- package/dist/app/agent-page/AutomationDetailsDialog.js +8 -4
- package/dist/app/agent-page/AutomationDetailsDialog.js.map +1 -1
- package/dist/app/agent-page/ScheduledTriggerNotice.d.ts.map +1 -1
- package/dist/app/agent-page/ScheduledTriggerNotice.js +41 -19
- package/dist/app/agent-page/ScheduledTriggerNotice.js.map +1 -1
- package/dist/app/agent-page/use-jobs.d.ts +1 -1
- package/dist/app/agent-page/use-jobs.d.ts.map +1 -1
- package/dist/app/agentkit/react/components.d.ts.map +1 -1
- package/dist/app/agentkit/react/components.js +124 -45
- package/dist/app/agentkit/react/components.js.map +1 -1
- package/dist/app/agentkit/react/context.d.ts +22 -1
- package/dist/app/agentkit/react/context.d.ts.map +1 -1
- package/dist/app/agentkit/react/context.js +14 -1
- package/dist/app/agentkit/react/context.js.map +1 -1
- package/dist/app/agentkit/react/root.d.ts +3 -2
- package/dist/app/agentkit/react/root.d.ts.map +1 -1
- package/dist/app/agentkit/react/root.js +2 -2
- package/dist/app/agentkit/react/root.js.map +1 -1
- package/dist/app/agentkit/react/styles.css +32 -2
- package/dist/app/chat/AgentKitAssistantChat.d.ts.map +1 -1
- package/dist/app/chat/AgentKitAssistantChat.js +28 -3
- package/dist/app/chat/AgentKitAssistantChat.js.map +1 -1
- package/dist/app/chat/AgentPanel.js +1 -1
- package/dist/app/chat/AgentPanel.js.map +1 -1
- package/dist/app/chat/RunStuckBanner.d.ts +8 -1
- package/dist/app/chat/RunStuckBanner.d.ts.map +1 -1
- package/dist/app/chat/RunStuckBanner.js +68 -6
- package/dist/app/chat/RunStuckBanner.js.map +1 -1
- package/dist/app/chat/chat/run-recovery.d.ts +4 -2
- package/dist/app/chat/chat/run-recovery.d.ts.map +1 -1
- package/dist/app/chat/chat/run-recovery.js +3 -3
- package/dist/app/chat/chat/run-recovery.js.map +1 -1
- package/dist/app/chat/composer/use-mention-search.d.ts +1 -0
- package/dist/app/chat/composer/use-mention-search.d.ts.map +1 -1
- package/dist/app/chat/feedback-report.d.ts +12 -0
- package/dist/app/chat/feedback-report.d.ts.map +1 -0
- package/dist/app/chat/feedback-report.js +21 -0
- package/dist/app/chat/feedback-report.js.map +1 -0
- package/dist/app/chat/reconcile-settled-run.d.ts +15 -0
- package/dist/app/chat/reconcile-settled-run.d.ts.map +1 -0
- package/dist/app/chat/reconcile-settled-run.js +41 -0
- package/dist/app/chat/reconcile-settled-run.js.map +1 -0
- package/dist/app/i18n/catalogs/ar-SA.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/ar-SA.js +9 -0
- package/dist/app/i18n/catalogs/ar-SA.js.map +1 -1
- package/dist/app/i18n/catalogs/de-DE.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/de-DE.js +9 -0
- package/dist/app/i18n/catalogs/de-DE.js.map +1 -1
- package/dist/app/i18n/catalogs/en-US.d.ts +9 -0
- package/dist/app/i18n/catalogs/en-US.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/en-US.js +9 -0
- package/dist/app/i18n/catalogs/en-US.js.map +1 -1
- package/dist/app/i18n/catalogs/es-ES.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/es-ES.js +9 -0
- package/dist/app/i18n/catalogs/es-ES.js.map +1 -1
- package/dist/app/i18n/catalogs/fr-FR.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/fr-FR.js +9 -0
- package/dist/app/i18n/catalogs/fr-FR.js.map +1 -1
- package/dist/app/i18n/catalogs/hi-IN.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/hi-IN.js +9 -0
- package/dist/app/i18n/catalogs/hi-IN.js.map +1 -1
- package/dist/app/i18n/catalogs/ja-JP.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/ja-JP.js +9 -0
- package/dist/app/i18n/catalogs/ja-JP.js.map +1 -1
- package/dist/app/i18n/catalogs/ko-KR.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/ko-KR.js +9 -0
- package/dist/app/i18n/catalogs/ko-KR.js.map +1 -1
- package/dist/app/i18n/catalogs/pt-BR.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/pt-BR.js +9 -0
- package/dist/app/i18n/catalogs/pt-BR.js.map +1 -1
- package/dist/app/i18n/catalogs/zh-CN.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/zh-CN.js +9 -0
- package/dist/app/i18n/catalogs/zh-CN.js.map +1 -1
- package/dist/app/i18n/catalogs/zh-TW.d.ts.map +1 -1
- package/dist/app/i18n/catalogs/zh-TW.js +9 -0
- package/dist/app/i18n/catalogs/zh-TW.js.map +1 -1
- package/dist/app/settings/shell/pages/audit.d.ts.map +1 -1
- package/dist/app/settings/shell/pages/audit.js +11 -2
- package/dist/app/settings/shell/pages/audit.js.map +1 -1
- package/dist/composer/ComposerContextMenu.d.ts +14 -1
- package/dist/composer/ComposerContextMenu.d.ts.map +1 -1
- package/dist/composer/ComposerContextMenu.js +65 -19
- package/dist/composer/ComposerContextMenu.js.map +1 -1
- package/dist/composer/MentionPopover.d.ts.map +1 -1
- package/dist/composer/MentionPopover.js +3 -3
- package/dist/composer/MentionPopover.js.map +1 -1
- package/dist/composer/PromptComposer.d.ts +1 -1
- package/dist/composer/PromptComposer.d.ts.map +1 -1
- package/dist/composer/TiptapComposer.d.ts +2 -2
- package/dist/composer/TiptapComposer.d.ts.map +1 -1
- package/dist/composer/TiptapComposer.js +92 -36
- package/dist/composer/TiptapComposer.js.map +1 -1
- package/dist/composer/use-mention-search.d.ts +1 -0
- package/dist/composer/use-mention-search.d.ts.map +1 -1
- package/dist/composer/use-mention-search.js +8 -1
- package/dist/composer/use-mention-search.js.map +1 -1
- package/package.json +4 -4
- package/src/app/agent-page/AgentJobsTab.blocked.spec.tsx +43 -0
- package/src/app/agent-page/AgentJobsTab.tsx +14 -5
- package/src/app/agent-page/AutomationDetailsDialog.tsx +22 -8
- package/src/app/agent-page/ScheduledTriggerNotice.spec.tsx +45 -3
- package/src/app/agent-page/ScheduledTriggerNotice.tsx +52 -31
- package/src/app/agent-page/use-jobs.ts +1 -1
- package/src/app/agentkit/react/components.interaction.spec.tsx +311 -1
- package/src/app/agentkit/react/components.spec.tsx +106 -2
- package/src/app/agentkit/react/components.tsx +217 -80
- package/src/app/agentkit/react/context.tsx +36 -0
- package/src/app/agentkit/react/root.tsx +4 -0
- package/src/app/agentkit/react/styles.css +32 -2
- package/src/app/chat/AgentKitAssistantChat.tsx +35 -2
- package/src/app/chat/AgentPanel.header.spec.ts +43 -7
- package/src/app/chat/AgentPanel.tsx +1 -1
- package/src/app/chat/RunStuckBanner.spec.tsx +258 -17
- package/src/app/chat/RunStuckBanner.tsx +107 -5
- package/src/app/chat/chat/run-recovery.tsx +10 -2
- package/src/app/chat/feedback-report.spec.ts +45 -0
- package/src/app/chat/feedback-report.ts +27 -0
- package/src/app/chat/reconcile-settled-run.spec.ts +112 -0
- package/src/app/chat/reconcile-settled-run.ts +57 -0
- package/src/app/i18n/catalogs/ar-SA.ts +12 -0
- package/src/app/i18n/catalogs/de-DE.ts +12 -0
- package/src/app/i18n/catalogs/en-US.ts +12 -0
- package/src/app/i18n/catalogs/es-ES.ts +12 -0
- package/src/app/i18n/catalogs/fr-FR.ts +12 -0
- package/src/app/i18n/catalogs/hi-IN.ts +12 -0
- package/src/app/i18n/catalogs/ja-JP.ts +12 -0
- package/src/app/i18n/catalogs/ko-KR.ts +12 -0
- package/src/app/i18n/catalogs/pt-BR.ts +12 -0
- package/src/app/i18n/catalogs/zh-CN.ts +10 -0
- package/src/app/i18n/catalogs/zh-TW.ts +10 -0
- package/src/app/settings/shell/pages/audit.spec.tsx +30 -0
- package/src/app/settings/shell/pages/audit.tsx +12 -1
- package/src/composer/ComposerContextMenu.tsx +94 -22
- package/src/composer/ComposerPlusMenu.parity.spec.tsx +218 -233
- package/src/composer/MentionPopover.tsx +12 -3
- package/src/composer/PromptComposer.context.spec.tsx +526 -195
- package/src/composer/PromptComposer.tsx +1 -1
- package/src/composer/TiptapComposer.tsx +105 -37
- package/src/composer/use-mention-search.ts +8 -1
|
@@ -169,7 +169,9 @@ import {
|
|
|
169
169
|
} from "./chat/tool-call-display.js";
|
|
170
170
|
import { resolveAgentKitToolSource } from "./chat/tool-integration.js";
|
|
171
171
|
import { ExternalAgentNudge } from "./external-agent-host.js";
|
|
172
|
+
import { formatFeedbackReport } from "./feedback-report.js";
|
|
172
173
|
import { FileStorageSetupPopover } from "./FileStorageSetupPopover.js";
|
|
174
|
+
import { reconcileSettledRun } from "./reconcile-settled-run.js";
|
|
173
175
|
import { RunStuckBanner } from "./RunStuckBanner.js";
|
|
174
176
|
import { ThinkingDisplayProvider } from "./thinking-display.js";
|
|
175
177
|
|
|
@@ -856,6 +858,11 @@ export const AgentKitAssistantChat = forwardRef<
|
|
|
856
858
|
shortcut: "{{shortcut}}",
|
|
857
859
|
}),
|
|
858
860
|
feedbackSubmit: t("agentChat.feedback.submit"),
|
|
861
|
+
feedbackReasonMisread: t("agentChat.feedback.reasonMisread"),
|
|
862
|
+
feedbackReasonNotDone: t("agentChat.feedback.reasonNotDone"),
|
|
863
|
+
feedbackReasonWrongNumbers: t("agentChat.feedback.reasonWrongNumbers"),
|
|
864
|
+
feedbackReasonTooSlow: t("agentChat.feedback.tooSlow"),
|
|
865
|
+
feedbackCopyDetails: t("agentChat.feedback.copyDetails"),
|
|
859
866
|
fork: t("agentChat.message.forkChat"),
|
|
860
867
|
previousBranch: t("agentChat.message.previousBranch"),
|
|
861
868
|
nextBranch: t("agentChat.message.nextBranch"),
|
|
@@ -863,6 +870,8 @@ export const AgentKitAssistantChat = forwardRef<
|
|
|
863
870
|
error: t("agentChat.error.failed"),
|
|
864
871
|
renderError: t("agentChat.error.render"),
|
|
865
872
|
runFailed: t("agentChat.error.failed"),
|
|
873
|
+
continueRun: t("agentChat.common.continue"),
|
|
874
|
+
continueRunUnavailable: t("agentChat.recovery.continueUnavailable"),
|
|
866
875
|
reconnect: t("agentChat.agentPanel.chatgptSubscriptionReconnect"),
|
|
867
876
|
reasoning: t("agentChat.status.thinking"),
|
|
868
877
|
expandActivity: t("agentChat.common.expand"),
|
|
@@ -1202,6 +1211,7 @@ export const AgentKitAssistantChat = forwardRef<
|
|
|
1202
1211
|
labels={labels}
|
|
1203
1212
|
branchNavigation={props.branchNavigation}
|
|
1204
1213
|
loadRunUsage={loadRunUsage}
|
|
1214
|
+
buildFeedbackReport={formatFeedbackReport}
|
|
1205
1215
|
onThreadForked={(thread) => props.onForkedThread?.(thread.id)}
|
|
1206
1216
|
onCopyMessage={({ text }) => {
|
|
1207
1217
|
const html = renderMarkdownToClipboardHtml(text);
|
|
@@ -1522,6 +1532,15 @@ const AgentKitAssistantChatBody = forwardRef<
|
|
|
1522
1532
|
() => hasActiveAgentRuns(controller.getThread(threadId)),
|
|
1523
1533
|
[controller, threadId],
|
|
1524
1534
|
);
|
|
1535
|
+
const reconcileServerSettled = useCallback(
|
|
1536
|
+
() =>
|
|
1537
|
+
reconcileSettledRun({
|
|
1538
|
+
load: control.load,
|
|
1539
|
+
getThread: () => controller.getThread(threadId),
|
|
1540
|
+
tabId: props.tabId ?? threadId,
|
|
1541
|
+
}),
|
|
1542
|
+
[control.load, controller, props.tabId, threadId],
|
|
1543
|
+
);
|
|
1525
1544
|
|
|
1526
1545
|
useEffect(() => {
|
|
1527
1546
|
if (previousPrefillRevisionRef.current === prefillRevision) return;
|
|
@@ -3141,6 +3160,7 @@ const AgentKitAssistantChatBody = forwardRef<
|
|
|
3141
3160
|
)
|
|
3142
3161
|
}
|
|
3143
3162
|
isAwaitingResponse={() => isRunning}
|
|
3163
|
+
onServerSettled={reconcileServerSettled}
|
|
3144
3164
|
onRetry={() =>
|
|
3145
3165
|
void sendRecoveryMessage(RECOVERY_CONTINUE_PROMPT, "continue")
|
|
3146
3166
|
}
|
|
@@ -4607,6 +4627,7 @@ function AgentKitRunFailure({
|
|
|
4607
4627
|
const surface = useAgentKitSurface();
|
|
4608
4628
|
const t = useT();
|
|
4609
4629
|
const [dismissed, setDismissed] = useState<string | null>(null);
|
|
4630
|
+
const [continueFailed, setContinueFailed] = useState(false);
|
|
4610
4631
|
const authErrorReason =
|
|
4611
4632
|
error.code === "unauthorized" || error.code === "http_401"
|
|
4612
4633
|
? "session-expired"
|
|
@@ -4690,6 +4711,17 @@ function AgentKitRunFailure({
|
|
|
4690
4711
|
);
|
|
4691
4712
|
}
|
|
4692
4713
|
if (wasRetried(thread.messages, runId)) return null;
|
|
4714
|
+
// Continuing resumes the stopped run's own turn, so finished steps are not
|
|
4715
|
+
// run again; that needs it to still be the turn's newest run.
|
|
4716
|
+
const continueStoppedRun = control.canContinueRun
|
|
4717
|
+
? superseded
|
|
4718
|
+
? undefined
|
|
4719
|
+
: () => {
|
|
4720
|
+
setContinueFailed(false);
|
|
4721
|
+
control.continueRun(runId).catch(() => setContinueFailed(true));
|
|
4722
|
+
}
|
|
4723
|
+
: () =>
|
|
4724
|
+
void surface.sendRecoveryMessage(RECOVERY_CONTINUE_PROMPT, "continue");
|
|
4693
4725
|
const info: RunErrorInfo = {
|
|
4694
4726
|
message: formatAgentKitErrorText(error, t),
|
|
4695
4727
|
errorCode: error.code,
|
|
@@ -4700,8 +4732,9 @@ function AgentKitRunFailure({
|
|
|
4700
4732
|
return (
|
|
4701
4733
|
<RunErrorRecoveryCard
|
|
4702
4734
|
info={info}
|
|
4703
|
-
onContinue={
|
|
4704
|
-
|
|
4735
|
+
onContinue={continueStoppedRun}
|
|
4736
|
+
continueError={
|
|
4737
|
+
continueFailed ? t("agentChat.recovery.continueUnavailable") : null
|
|
4705
4738
|
}
|
|
4706
4739
|
onRetry={() => void retryFailedTurn()}
|
|
4707
4740
|
retryHasUnavailableAttachment={retryRequest.hasUnavailableAttachment}
|
|
@@ -9,7 +9,7 @@ import {
|
|
|
9
9
|
} from "@agent-native/toolkit/ui/dropdown-menu";
|
|
10
10
|
import React, { act } from "react";
|
|
11
11
|
import { createRoot, type Root } from "react-dom/client";
|
|
12
|
-
import { MemoryRouter, useLocation } from "react-router";
|
|
12
|
+
import { MemoryRouter, useHref, useLocation } from "react-router";
|
|
13
13
|
import { describe, expect, it, vi } from "vitest";
|
|
14
14
|
|
|
15
15
|
import {
|
|
@@ -445,6 +445,45 @@ describe("AgentPanel settings navigation", () => {
|
|
|
445
445
|
}
|
|
446
446
|
});
|
|
447
447
|
|
|
448
|
+
it("keeps a settings request inside the mounted app path", () => {
|
|
449
|
+
const container = document.createElement("div");
|
|
450
|
+
document.body.appendChild(container);
|
|
451
|
+
const root: Root = createRoot(container);
|
|
452
|
+
let href = "";
|
|
453
|
+
|
|
454
|
+
function LocationProbe() {
|
|
455
|
+
const location = useLocation();
|
|
456
|
+
href = useHref(`${location.pathname}${location.hash}`);
|
|
457
|
+
return null;
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
try {
|
|
461
|
+
vi.stubEnv("VITE_APP_BASE_PATH", "/dispatch");
|
|
462
|
+
act(() => {
|
|
463
|
+
window.history.replaceState(null, "", "/dispatch/chat#llm");
|
|
464
|
+
root.render(
|
|
465
|
+
React.createElement(
|
|
466
|
+
MemoryRouter,
|
|
467
|
+
{ basename: "/dispatch", initialEntries: ["/dispatch/chat#llm"] },
|
|
468
|
+
React.createElement(AgentPanelSettingsNavigation),
|
|
469
|
+
React.createElement(LocationProbe),
|
|
470
|
+
),
|
|
471
|
+
);
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
act(() => {
|
|
475
|
+
window.dispatchEvent(new CustomEvent("agent-panel:open-settings"));
|
|
476
|
+
});
|
|
477
|
+
|
|
478
|
+
expect(href).toBe("/dispatch/settings#llm");
|
|
479
|
+
} finally {
|
|
480
|
+
act(() => root.unmount());
|
|
481
|
+
container.remove();
|
|
482
|
+
window.history.replaceState(null, "", "/");
|
|
483
|
+
vi.unstubAllEnvs();
|
|
484
|
+
}
|
|
485
|
+
});
|
|
486
|
+
|
|
448
487
|
it("routes a mounted settings request to an existing secret-specific hash", () => {
|
|
449
488
|
const container = document.createElement("div");
|
|
450
489
|
document.body.appendChild(container);
|
|
@@ -532,7 +571,7 @@ describe("AgentPanel settings navigation", () => {
|
|
|
532
571
|
}
|
|
533
572
|
});
|
|
534
573
|
|
|
535
|
-
it("
|
|
574
|
+
it("navigates with a router-local settings path", () => {
|
|
536
575
|
const container = document.createElement("div");
|
|
537
576
|
document.body.appendChild(container);
|
|
538
577
|
const root: Root = createRoot(container);
|
|
@@ -545,11 +584,10 @@ describe("AgentPanel settings navigation", () => {
|
|
|
545
584
|
|
|
546
585
|
try {
|
|
547
586
|
act(() => {
|
|
548
|
-
window.history.replaceState(null, "", "/dispatch/_agent-native/poll");
|
|
549
587
|
root.render(
|
|
550
588
|
React.createElement(
|
|
551
589
|
MemoryRouter,
|
|
552
|
-
{ initialEntries: ["/"] },
|
|
590
|
+
{ initialEntries: ["/chat"] },
|
|
553
591
|
React.createElement(AgentPanelSettingsNavigation),
|
|
554
592
|
React.createElement(LocationProbe),
|
|
555
593
|
),
|
|
@@ -564,11 +602,10 @@ describe("AgentPanel settings navigation", () => {
|
|
|
564
602
|
);
|
|
565
603
|
});
|
|
566
604
|
|
|
567
|
-
expect(pathname).toBe("/
|
|
605
|
+
expect(pathname).toBe("/settings");
|
|
568
606
|
} finally {
|
|
569
607
|
act(() => root.unmount());
|
|
570
608
|
container.remove();
|
|
571
|
-
window.history.replaceState(null, "", "/");
|
|
572
609
|
}
|
|
573
610
|
});
|
|
574
611
|
|
|
@@ -1134,7 +1171,6 @@ describe("AgentChatSurface chrome defaults", () => {
|
|
|
1134
1171
|
expect(source).not.toContain("SettingsPanel");
|
|
1135
1172
|
expect(source).not.toContain("allowSettingsMode");
|
|
1136
1173
|
expect(source).not.toContain('mode === "settings"');
|
|
1137
|
-
expect(source).toContain('pathname: appPath("/settings")');
|
|
1138
1174
|
});
|
|
1139
1175
|
|
|
1140
1176
|
it("mounts URL command sync for a full-page chat surface", () => {
|
|
@@ -199,7 +199,7 @@ export function AgentPanelSettingsNavigation({
|
|
|
199
199
|
const requested = requestedSettingsSection(section, window.location.hash);
|
|
200
200
|
const navigation = navigate(
|
|
201
201
|
{
|
|
202
|
-
pathname:
|
|
202
|
+
pathname: "/settings",
|
|
203
203
|
hash: settingsRouteHashForSection(section, window.location.hash),
|
|
204
204
|
},
|
|
205
205
|
// The hash can't tell API keys from Integrations; the redesigned
|
|
@@ -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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
-
|
|
90
|
+
const remounted = createRoot(document.createElement("div"));
|
|
53
91
|
await act(async () => {
|
|
54
|
-
|
|
92
|
+
remounted.render(<RunStuckBanner threadId="thread-1" />);
|
|
55
93
|
});
|
|
56
|
-
await act(async () =>
|
|
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({
|
|
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 =
|
|
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
|
-
|
|
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
|
-
!
|
|
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
|
-
|
|
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 () => {
|