authorgravatar for maria@kuuro.netmaria <maria@kuuro.net> 2026-09-08 00:17:42-03:00
committergravatar for noreply@github.comGitHub <noreply@github.com> 2026-09-08 00:17:42-03:00
log15193df9fb915837f7215909a7d741789db0a273
tree9e7c3e1d5d0e8e1fe0a57cf2a8ae341578e927d0
parentb7c002f918b94b8ce13c44bddcd29b8443e4af41
signature Signed by PGP key B5690EEEBB952194

fix(web): update machines together in auto balance (#10596)


4 files changed, 471 insertions(+), 74 deletions(-)

apps/web/src/components/ChatView.tsx+34-18
......@@ -440,6 +440,7 @@ import {
440440} from "./ui/alert-dialog";
441441import { Tooltip, TooltipPopup, TooltipTrigger } from "./ui/tooltip";
442442import { ServerUpdateAction } from "./ServerUpdateAction";
443import { useAutoBalanceUpdateBanner } from "./chat/useAutoBalanceUpdateBanner";
443444import {
444445 ComposerServerUpdateIcon,
445446 ComposerServerUpdateStatus,
......@@ -2317,6 +2318,35 @@ export default function ChatView(props: ChatViewProps) {
23172318 advertisedFileAttachmentBytes === null
23182319 ? null
23192320 : clampFileAttachmentUploadBytes(advertisedFileAttachmentBytes);
2321 const envLocked = Boolean(
2322 activeThread &&
2323 (activeThread.messages.length > 0 ||
2324 (activeThread.session !== null && activeThread.session.status !== "stopped")),
2325 );
2326
2327 const loadBalancingSettings = useClientSettings();
2328 const automaticEnvironment = Boolean(
2329 clientSettingsHydrated &&
2330 draftId &&
2331 !envLocked &&
2332 hasMultipleEnvironments &&
2333 loadBalancingSettings.loadBalancingEnabled &&
2334 draftThread?.environmentSelection !== "manual" &&
2335 (!composerHasAttachments || Boolean(draftThread?.loadBalancedEnvironmentId)) &&
2336 (!draftThread?.branch || draftThread.environmentSelection === "auto") &&
2337 !draftThread?.worktreePath,
2338 );
2339 const autoUpdateEnvironments = useMemo(
2340 () =>
2341 automaticEnvironment
2342 ? logicalProjectEnvironments.flatMap(({ environmentId }) => {
2343 const environment = environmentById.get(environmentId);
2344 return environment ? [environment] : [];
2345 })
2346 : [],
2347 [automaticEnvironment, logicalProjectEnvironments, environmentById],
2348 );
2349 const autoBalanceUpdateBanner = useAutoBalanceUpdateBanner(autoUpdateEnvironments);
23202350 const versionMismatch = resolveServerConfigVersionMismatch(serverConfig);
23212351 const versionMismatchDismissKey =
23222352 versionMismatch && activeThread
......@@ -2420,6 +2450,7 @@ export default function ChatView(props: ChatViewProps) {
24202450 }
24212451 }
24222452 if (
2453 !automaticEnvironment &&
24232454 serverUpdateEnvironmentId &&
24242455 !reconnectingThroughVersionSkew &&
24252456 (serverUpdateState.status === "idle"
......@@ -2498,8 +2529,11 @@ export default function ChatView(props: ChatViewProps) {
24982529 }),
24992530 });
25002531 }
2532 if (autoBalanceUpdateBanner) items.push(autoBalanceUpdateBanner);
25012533 return items;
25022534 }, [
2535 automaticEnvironment,
2536 autoBalanceUpdateBanner,
25032537 activeEnvironmentUnavailableState,
25042538 reconnectWarningGraceElapsed,
25052539 handleReconnectActiveEnvironment,
......@@ -3233,24 +3267,6 @@ export default function ChatView(props: ChatViewProps) {
32333267 }
32343268 }, [activeThreadRef, diffOpen, isServerThread, onDiffPanelOpen]);
32353269
3236 const envLocked = Boolean(
3237 activeThread &&
3238 (activeThread.messages.length > 0 ||
3239 (activeThread.session !== null && activeThread.session.status !== "stopped")),
3240 );
3241
3242 const loadBalancingSettings = useClientSettings();
3243 const automaticEnvironment = Boolean(
3244 clientSettingsHydrated &&
3245 draftId &&
3246 !envLocked &&
3247 hasMultipleEnvironments &&
3248 loadBalancingSettings.loadBalancingEnabled &&
3249 draftThread?.environmentSelection !== "manual" &&
3250 (!composerHasAttachments || Boolean(draftThread?.loadBalancedEnvironmentId)) &&
3251 (!draftThread?.branch || draftThread.environmentSelection === "auto") &&
3252 !draftThread?.worktreePath,
3253 );
32543270 const needsLoadBalancing = automaticEnvironment && !draftThread?.loadBalancedEnvironmentId;
32553271 const loadBalancingCandidates = useMemo(
32563272 () =>
apps/web/src/components/ServerUpdateAction.test.tsx+158-3
......@@ -1,9 +1,10 @@
1import type { ReactElement } from "react";
1import { act, type ReactElement } from "react";
2import { create, type ReactTestRenderer } from "react-test-renderer";
23import { renderToStaticMarkup } from "react-dom/server";
34import type { EnvironmentId } from "@t3tools/contracts";
45import * as Cause from "effect/Cause";
56import { AsyncResult } from "effect/unstable/reactivity";
6import { beforeEach, describe, expect, it, vi } from "vite-plus/test";
7import { afterEach, beforeEach, describe, expect, it, vi } from "vite-plus/test";
78
89const testState = vi.hoisted(() => ({
910 updateServer: vi.fn(),
......@@ -30,7 +31,18 @@ vi.mock("./ui/toast", () => ({
3031 toastManager: { add: testState.toast },
3132}));
3233
33import { ServerUpdateAction, ServerUpdateProgress } from "./ServerUpdateAction";
34import {
35 readConfirmDialogState,
36 registerConfirmDialogHost,
37 resetConfirmDialogForTests,
38 respondToConfirmDialog,
39} from "~/confirmDialog";
40import {
41 ServerUpdateAction,
42 ServerUpdateProgress,
43 ServerUpdatesAction,
44 type ServerUpdateTarget,
45} from "./ServerUpdateAction";
3446
3547type ActionElement = ReactElement<{
3648 readonly onClick?: () => void;
......@@ -194,6 +206,149 @@ describe("ServerUpdateAction", () => {
194206 });
195207});
196208
209describe("ServerUpdatesAction", () => {
210 let renderer: ReactTestRenderer | undefined;
211 const targets: ReadonlyArray<ServerUpdateTarget> = [
212 {
213 environmentId: "batch-a" as EnvironmentId,
214 serverLabel: "Laptop",
215 selfUpdate: "boot-service",
216 targetVersion: "0.0.31",
217 threadContinuation: true,
218 continueThreadsAfterServerUpdate: true,
219 },
220 {
221 environmentId: "batch-b" as EnvironmentId,
222 serverLabel: "Office",
223 selfUpdate: "respawn",
224 targetVersion: "0.0.31",
225 threadContinuation: true,
226 continueThreadsAfterServerUpdate: false,
227 },
228 {
229 environmentId: "batch-c" as EnvironmentId,
230 serverLabel: "Manual",
231 selfUpdate: null,
232 targetVersion: "0.0.31",
233 },
234 ];
235 const success = AsyncResult.success({ targetVersion: "0.0.31", method: "boot-service" as const });
236
237 async function mount(batch = targets) {
238 await act(async () => {
239 renderer = create(<ServerUpdatesAction targets={batch} />);
240 });
241 return renderer!.root.findByType("button");
242 }
243
244 beforeEach(() => {
245 vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
246 testState.updateServer.mockReset();
247 testState.toast.mockReset();
248 resetConfirmDialogForTests();
249 });
250 afterEach(async () => {
251 await act(async () => {
252 renderer?.unmount();
253 });
254 renderer = undefined;
255 resetConfirmDialogForTests();
256 vi.unstubAllGlobals();
257 });
258
259 it("updates both supported machines with their own continuation preference and skips the manual machine", async () => {
260 testState.updateServer.mockResolvedValue(success);
261 const button = await mount();
262 await act(async () => {
263 button.props.onClick();
264 });
265
266 expect(testState.updateServer.mock.calls.map(([target]) => target)).toEqual([
267 {
268 environmentId: "batch-a",
269 input: { targetVersion: "0.0.31", continueRunningThreads: true },
270 },
271 { environmentId: "batch-b", input: { targetVersion: "0.0.31" } },
272 ]);
273 expect(testState.toast.mock.calls.map(([toast]) => toast.title)).toEqual([
274 "Laptop updated",
275 "Office updated",
276 ]);
277 });
278
279 it("names a failed machine while letting the other machine complete", async () => {
280 testState.updateServer
281 .mockResolvedValueOnce(AsyncResult.failure(Cause.fail(new Error("Download failed"))))
282 .mockResolvedValueOnce(success);
283 const button = await mount();
284 await act(async () => {
285 button.props.onClick();
286 });
287
288 expect(testState.updateServer).toHaveBeenCalledTimes(2);
289 expect(testState.toast).toHaveBeenCalledWith({
290 type: "error",
291 title: "Laptop update failed",
292 description: "Download failed",
293 });
294 expect(testState.toast).toHaveBeenCalledWith(
295 expect.objectContaining({ type: "success", title: "Office updated" }),
296 );
297 expect(button.props.disabled).toBe(false);
298 });
299
300 it("starts each machine once when double-clicked and disables the action until both finish", async () => {
301 const completions: Array<() => void> = [];
302 testState.updateServer.mockImplementation(
303 () =>
304 new Promise((resolve) => {
305 completions.push(() => resolve(success));
306 }),
307 );
308 const button = await mount();
309 await act(async () => {
310 button.props.onClick();
311 button.props.onClick();
312 });
313 expect(testState.updateServer).toHaveBeenCalledTimes(2);
314 expect(button.props.disabled).toBe(true);
315 await act(async () => {
316 completions[0]!();
317 });
318 expect(button.props.disabled).toBe(true);
319 await act(async () => {
320 completions[1]!();
321 });
322 expect(button.props.disabled).toBe(false);
323 expect(testState.toast).toHaveBeenCalledTimes(2);
324 });
325
326 it("asks once for desktop machines and cancels the entire batch", async () => {
327 registerConfirmDialogHost();
328 const button = await mount(
329 targets.map((target, index) =>
330 index < 2 ? { ...target, selfUpdate: "desktop-managed", desktopAppUpdate: true } : target,
331 ),
332 );
333 await act(async () => {
334 button.props.onClick();
335 });
336 const confirmation = readConfirmDialogState();
337 expect(confirmation).toEqual(
338 expect.objectContaining({
339 status: "confirming",
340 message: expect.stringContaining("Laptop, Office"),
341 }),
342 );
343 expect(testState.updateServer).not.toHaveBeenCalled();
344 await act(async () => {
345 respondToConfirmDialog(false);
346 });
347 expect(testState.updateServer).not.toHaveBeenCalled();
348 expect(button.props.disabled).toBe(false);
349 });
350});
351
197352describe("ServerUpdateProgress", () => {
198353 it("shows one calm status row for the restart wait", () => {
199354 const markup = renderToStaticMarkup(
apps/web/src/components/ServerUpdateAction.tsx+119-53
......@@ -4,7 +4,7 @@ import {
44 isAtomCommandInterrupted,
55 squashAtomCommandFailure,
66} from "@t3tools/client-runtime/state/runtime";
7import type { ComponentProps } from "react";
7import { type ComponentProps, useRef, useState } from "react";
88
99import { requestConfirmDialog } from "~/confirmDialog";
1010import { useCopyToClipboard } from "~/hooks/useCopyToClipboard";
......@@ -34,6 +34,113 @@ function updateFailureMessage(error: unknown): string {
3434 return error instanceof Error ? error.message : "Server update failed.";
3535}
3636
37export interface ServerUpdateTarget {
38 readonly environmentId: EnvironmentId;
39 readonly serverLabel: string;
40 readonly selfUpdate: ServerSelfUpdateCapability | null;
41 readonly desktopAppUpdate?: boolean;
42 readonly threadContinuation?: boolean;
43 readonly targetVersion: string;
44 readonly continueThreadsAfterServerUpdate?: boolean;
45}
46
47type UpdateButtonProps = Pick<ComponentProps<typeof Button>, "variant" | "size"> & {
48 readonly label?: string;
49};
50
51function useServerUpdate() {
52 const updateServer = useAtomCommand(serverEnvironment.updateServer, { reportFailure: false });
53 return async (target: ServerUpdateTarget, failureTitle = "Server update failed") => {
54 const { environmentId, serverLabel, selfUpdate, targetVersion } = target;
55 if (pendingUpdateEnvironmentIds.has(environmentId)) return;
56 pendingUpdateEnvironmentIds.add(environmentId);
57 try {
58 const result = await updateServer({
59 environmentId,
60 input: {
61 targetVersion,
62 ...(target.threadContinuation && target.continueThreadsAfterServerUpdate
63 ? { continueRunningThreads: true }
64 : {}),
65 },
66 });
67 if (result._tag === "Failure") {
68 if (isAtomCommandInterrupted(result)) return;
69 throw squashAtomCommandFailure(result);
70 }
71 toastManager.add({
72 type: "success",
73 title: `${serverLabel} updated`,
74 description:
75 selfUpdate === "desktop-managed"
76 ? `Desktop app relaunched on ${result.value.targetVersion}.`
77 : `Reconnected on t3@${result.value.targetVersion}.`,
78 });
79 } catch (error) {
80 toastManager.add({
81 type: "error",
82 title: failureTitle,
83 description: updateFailureMessage(error),
84 });
85 } finally {
86 pendingUpdateEnvironmentIds.delete(environmentId);
87 }
88 };
89}
90
91/** Updates eligible machines independently; manual paths remain in the machine list. */
92export function ServerUpdatesAction({
93 targets,
94 label = "Update all",
95 variant = "outline",
96 size = "xs",
97}: UpdateButtonProps & {
98 readonly targets: ReadonlyArray<ServerUpdateTarget>;
99}) {
100 const update = useServerUpdate();
101 const pending = useRef(false);
102 const [isPending, setIsPending] = useState(false);
103 const eligible = targets.filter(
104 (target) =>
105 target.selfUpdate !== null &&
106 (target.selfUpdate !== "desktop-managed" || target.desktopAppUpdate),
107 );
108 const handleUpdate = async () => {
109 if (pending.current) return;
110 pending.current = true;
111 setIsPending(true);
112 try {
113 const available = eligible.filter(
114 (target) => !pendingUpdateEnvironmentIds.has(target.environmentId),
115 );
116 const desktopTargets = available.filter((target) => target.selfUpdate === "desktop-managed");
117 if (desktopTargets.length > 0) {
118 const confirmed =
119 (await requestConfirmDialog(
120 `Update the T3 Code desktop apps on ${desktopTargets.map((target) => target.serverLabel).join(", ")}? They will close and relaunch on those machines.`,
121 )) ?? true;
122 if (!confirmed) return;
123 }
124 await Promise.all(
125 available.map((target) => update(target, `${target.serverLabel} update failed`)),
126 );
127 } finally {
128 pending.current = false;
129 setIsPending(false);
130 }
131 };
132 return (
133 <Button
134 size={size}
135 variant={variant}
136 disabled={isPending || eligible.length === 0}
137 onClick={() => void handleUpdate()}
138 >
139 {label}
140 </Button>
141 );
142}
143
37144/**
38145 * One-row status for an in-flight server update: "Downloading…" then
39146 * "Restarting…". The update is a wait, not a warning: a single pulsing dot
......@@ -84,28 +191,13 @@ export function ServerUpdateAction({
84191 label = "Update",
85192 variant = "outline",
86193 size = "xs",
87}: {
88 readonly environmentId: EnvironmentId;
89 readonly serverLabel: string;
90 readonly selfUpdate: ServerSelfUpdateCapability | null;
91 /** The desktop app supervising this server accepts remote update
92 requests (capabilities.desktopAppUpdate). */
93 readonly desktopAppUpdate?: boolean;
94 /** The server can durably continue running provider turns after updating. */
95 readonly threadContinuation?: boolean;
96 readonly targetVersion: string;
97 readonly label?: string;
98 readonly variant?: ComponentProps<typeof Button>["variant"];
99 readonly size?: ComponentProps<typeof Button>["size"];
100}) {
194}: Omit<ServerUpdateTarget, "continueThreadsAfterServerUpdate"> & UpdateButtonProps) {
101195 const isDesktopAppUpdate = selfUpdate === "desktop-managed";
102196 const continueThreadsAfterServerUpdate = useEnvironmentSettings(
103197 environmentId,
104198 (settings) => settings.continueThreadsAfterServerUpdate,
105199 );
106 const updateServer = useAtomCommand(serverEnvironment.updateServer, {
107 reportFailure: false,
108 });
200 const update = useServerUpdate();
109201 const { copyToClipboard } = useCopyToClipboard<{ command: string }>({
110202 target: "update command",
111203 onCopy: ({ command }) => {
......@@ -140,41 +232,15 @@ export function ServerUpdateAction({
140232 return;
141233 }
142234 }
143 if (pendingUpdateEnvironmentIds.has(environmentId)) {
144 return;
145 }
146 pendingUpdateEnvironmentIds.add(environmentId);
147 try {
148 const result = await updateServer({
149 environmentId,
150 input: {
151 targetVersion,
152 ...(threadContinuation && continueThreadsAfterServerUpdate
153 ? { continueRunningThreads: true }
154 : {}),
155 },
156 });
157 if (result._tag === "Failure") {
158 if (isAtomCommandInterrupted(result)) {
159 return;
160 }
161 toastManager.add({
162 type: "error",
163 title: "Server update failed",
164 description: updateFailureMessage(squashAtomCommandFailure(result)),
165 });
166 return;
167 }
168 toastManager.add({
169 type: "success",
170 title: `${serverLabel} updated`,
171 description: isDesktopAppUpdate
172 ? `Desktop app relaunched on ${result.value.targetVersion}.`
173 : `Reconnected on t3@${result.value.targetVersion}.`,
174 });
175 } finally {
176 pendingUpdateEnvironmentIds.delete(environmentId);
177 }
235 await update({
236 environmentId,
237 serverLabel,
238 selfUpdate,
239 desktopAppUpdate,
240 threadContinuation,
241 targetVersion,
242 continueThreadsAfterServerUpdate,
243 });
178244 };
179245
180246 if (selfUpdate === "desktop-managed" && !desktopAppUpdate) {
apps/web/src/components/chat/useAutoBalanceUpdateBanner.tsx created+160
......@@ -0,0 +1,160 @@
1import { useAtomValue } from "@effect/atom-react";
2import type { ServerUpdateState } from "@t3tools/client-runtime/state/server";
3import { Atom } from "effect/unstable/reactivity";
4import { useMemo, useState } from "react";
5
6import type { EnvironmentPresentation } from "~/state/environments";
7import { serverEnvironment } from "~/state/server";
8import {
9 buildVersionMismatchDismissalKey,
10 dismissServerUpdateFailure,
11 dismissVersionMismatch,
12 isServerUpdateFailureDismissed,
13 isVersionMismatchDismissed,
14 resolveServerConfigVersionMismatch,
15 resolveServerSelfUpdateCapability,
16 supportsDesktopAppUpdate,
17 supportsServerUpdateThreadContinuation,
18} from "~/versionSkew";
19import {
20 ServerUpdateAction,
21 ServerUpdateProgress,
22 ServerUpdatesAction,
23} from "../ServerUpdateAction";
24import { Popover, PopoverPopup, PopoverTrigger } from "../ui/popover";
25import type { ComposerBannerStackItem } from "./ComposerBannerStack";
26import { ComposerServerUpdateIcon } from "./ComposerServerUpdateStatus";
27
28/** Keep every machine's update visible while auto balance has no single update target. */
29export function useAutoBalanceUpdateBanner(
30 environments: readonly EnvironmentPresentation[],
31): ComposerBannerStackItem | null {
32 const statesAtom = useMemo(
33 () =>
34 Atom.make((get) =>
35 environments.map((environment) => ({
36 environment,
37 state: get(serverEnvironment.updateStateAtom(environment.environmentId)),
38 })),
39 ),
40 [environments],
41 );
42 const states = useAtomValue(statesAtom);
43 const [dismissedNotices, setDismissedNotices] = useState<ReadonlySet<string | ServerUpdateState>>(
44 () => new Set(),
45 );
46 const machines = states.flatMap(({ environment, state }) => {
47 const mismatch = resolveServerConfigVersionMismatch(environment.serverConfig);
48 const dismissKey = mismatch
49 ? buildVersionMismatchDismissalKey(environment.environmentId, mismatch)
50 : null;
51 if (
52 state.status === "idle"
53 ? !mismatch ||
54 (dismissKey !== null && dismissedNotices.has(dismissKey)) ||
55 isVersionMismatchDismissed(dismissKey)
56 : dismissedNotices.has(state) || isServerUpdateFailureDismissed(state)
57 )
58 return [];
59 const selfUpdate = resolveServerSelfUpdateCapability(environment.serverConfig);
60 const desktopAppUpdate = supportsDesktopAppUpdate(environment.serverConfig);
61 return [
62 {
63 environmentId: environment.environmentId,
64 serverLabel: environment.label,
65 selfUpdate,
66 desktopAppUpdate,
67 threadContinuation: supportsServerUpdateThreadContinuation(environment.serverConfig),
68 continueThreadsAfterServerUpdate:
69 environment.serverConfig?.settings.continueThreadsAfterServerUpdate ?? false,
70 targetVersion: state.status === "idle" ? mismatch!.clientVersion : state.targetVersion,
71 connected: environment.connection.phase === "connected",
72 remoteUpdate: selfUpdate !== null && (selfUpdate !== "desktop-managed" || desktopAppUpdate),
73 state,
74 dismissKey,
75 },
76 ];
77 });
78 if (machines.length === 0) return null;
79
80 const running = machines.filter((machine) => machine.state.status === "running").length;
81 const failed = machines.filter((machine) => machine.state.status === "failed").length;
82 const manual = machines.filter((machine) => !machine.remoteUpdate).length;
83 const targets = machines.filter(
84 (machine) => machine.connected && machine.remoteUpdate && machine.state.status !== "running",
85 );
86 const count = running || failed || machines.length;
87 const status = running ? "running" : failed ? "failed" : "idle";
88 const prefix = running ? "Updating" : failed ? "Could not update" : "Update available for";
89 const title = `${prefix} ${count} ${count === 1 ? "machine" : "machines"}`;
90 return {
91 id: `auto-balance-server-updates-${dismissedNotices.size}`,
92 variant: failed ? "error" : "default",
93 priority: running ? "urgent" : "notice",
94 icon: <ComposerServerUpdateIcon status={status} />,
95 title: (
96 <Popover>
97 <PopoverTrigger
98 className="block max-w-full truncate rounded-sm text-left outline-none focus-visible:ring-2 focus-visible:ring-ring"
99 aria-label={`${title}. View machines`}
100 >
101 {title}
102 </PopoverTrigger>
103 <PopoverPopup side="top" align="start" className="w-80 max-w-[calc(100vw-2rem)]">
104 <div className="space-y-3 text-xs">
105 {machines.map((machine) => (
106 <div key={machine.environmentId} className="space-y-1">
107 <div className="font-medium">{machine.serverLabel}</div>
108 {machine.state.status !== "idle" ? (
109 <ServerUpdateProgress state={machine.state} />
110 ) : !machine.remoteUpdate ? (
111 <>
112 <div className="text-muted-foreground">Manual update required</div>
113 <ServerUpdateAction {...machine} />
114 </>
115 ) : (
116 <div className="text-muted-foreground">
117 {machine.connected
118 ? `Ready to update to ${machine.targetVersion}`
119 : "Reconnect this machine to update"}
120 </div>
121 )}
122 </div>
123 ))}
124 </div>
125 </PopoverPopup>
126 </Popover>
127 ),
128 description:
129 manual > 0 ? `${manual} ${manual === 1 ? "needs" : "need"} a manual update` : undefined,
130 actions:
131 running === 0 && targets.length > 0 ? (
132 <ServerUpdatesAction
133 targets={targets}
134 variant="ghost"
135 label={
136 failed > 0
137 ? "Retry"
138 : targets.length === machines.length
139 ? "Update all"
140 : `Update ${targets.length} ${targets.length === 1 ? "machine" : "machines"}`
141 }
142 />
143 ) : undefined,
144 dismissLabel: "Dismiss update notice",
145 ...(running
146 ? {}
147 : {
148 onDismiss: () => {
149 const next = new Set(dismissedNotices);
150 for (const { state, dismissKey } of machines) {
151 dismissServerUpdateFailure(state);
152 dismissVersionMismatch(dismissKey);
153 if (dismissKey) next.add(dismissKey);
154 if (state.status === "failed") next.add(state);
155 }
156 setDismissedNotices(next);
157 },
158 }),
159 };
160}