@assistant-ui/react 0.15.1 → 0.15.3

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 (46) hide show
  1. package/dist/client/ExternalThread.d.ts.map +1 -1
  2. package/dist/client/ExternalThread.js +43 -47
  3. package/dist/client/ExternalThread.js.map +1 -1
  4. package/dist/client/InMemoryThreadList.d.ts.map +1 -1
  5. package/dist/client/InMemoryThreadList.js +199 -176
  6. package/dist/client/InMemoryThreadList.js.map +1 -1
  7. package/dist/client/SingleThreadList.d.ts.map +1 -1
  8. package/dist/client/SingleThreadList.js +56 -48
  9. package/dist/client/SingleThreadList.js.map +1 -1
  10. package/dist/index.d.ts +2 -2
  11. package/dist/legacy-runtime/cloud/auiV0.d.ts +26 -2
  12. package/dist/legacy-runtime/cloud/auiV0.d.ts.map +1 -1
  13. package/dist/legacy-runtime/cloud/auiV0.js +29 -8
  14. package/dist/legacy-runtime/cloud/auiV0.js.map +1 -1
  15. package/dist/mcp-apps/McpAppRenderer.d.ts.map +1 -1
  16. package/dist/mcp-apps/McpAppRenderer.js +25 -14
  17. package/dist/mcp-apps/McpAppRenderer.js.map +1 -1
  18. package/dist/mcp-apps/McpAppsRemoteHost.d.ts.map +1 -1
  19. package/dist/mcp-apps/McpAppsRemoteHost.js +17 -6
  20. package/dist/mcp-apps/McpAppsRemoteHost.js.map +1 -1
  21. package/dist/primitives/message/MessagePartsGrouped.d.ts +6 -1
  22. package/dist/primitives/message/MessagePartsGrouped.d.ts.map +1 -1
  23. package/dist/primitives/message/MessagePartsGrouped.js.map +1 -1
  24. package/dist/primitives/suggestion/SuggestionTrigger.d.ts +4 -2
  25. package/dist/primitives/suggestion/SuggestionTrigger.d.ts.map +1 -1
  26. package/dist/primitives/suggestion/SuggestionTrigger.js +21 -42
  27. package/dist/primitives/suggestion/SuggestionTrigger.js.map +1 -1
  28. package/dist/primitives/thread/ThreadSuggestion.d.ts +4 -2
  29. package/dist/primitives/thread/ThreadSuggestion.d.ts.map +1 -1
  30. package/dist/primitives/thread/ThreadSuggestion.js.map +1 -1
  31. package/dist/utils/useToolArgsFieldStatus.d.ts +2 -2
  32. package/package.json +6 -6
  33. package/src/client/ExternalThread.ts +5 -16
  34. package/src/client/InMemoryThreadList.ts +27 -7
  35. package/src/client/SingleThreadList.ts +10 -2
  36. package/src/index.ts +1 -0
  37. package/src/legacy-runtime/cloud/auiV0.ts +59 -14
  38. package/src/mcp-apps/McpAppRenderer.test.tsx +262 -3
  39. package/src/mcp-apps/McpAppRenderer.tsx +27 -15
  40. package/src/mcp-apps/McpAppsRemoteHost.ts +18 -9
  41. package/src/primitives/message/MessagePartsGrouped.tsx +6 -1
  42. package/src/primitives/suggestion/SuggestionTrigger.ts +11 -32
  43. package/src/primitives/thread/ThreadSuggestion.ts +2 -1
  44. package/src/tests/RemoteThreadListRuntime.reloadMainThread.test.tsx +115 -0
  45. package/src/tests/external-thread-parity.test.tsx +108 -16
  46. package/src/tests/threadListItemIsRunning.test.tsx +198 -0
@@ -29,14 +29,24 @@ type ThreadData = {
29
29
  // ThreadListItem Client
30
30
  const useThreadListItemClient = (props: {
31
31
  data: ThreadData;
32
+ isRunning: boolean;
32
33
  onSwitchTo: () => void;
34
+ onRename: (title: string) => void;
33
35
  onUpdateCustom: (custom: Record<string, unknown> | undefined) => void;
34
36
  onArchive: () => void;
35
37
  onUnarchive: () => void;
36
38
  onDelete: () => void;
37
39
  }): ClientOutput<"threadListItem"> => {
38
- const { data, onSwitchTo, onUpdateCustom, onArchive, onUnarchive, onDelete } =
39
- props;
40
+ const {
41
+ data,
42
+ isRunning,
43
+ onSwitchTo,
44
+ onRename,
45
+ onUpdateCustom,
46
+ onArchive,
47
+ onUnarchive,
48
+ onDelete,
49
+ } = props;
40
50
  const state = useMemo(
41
51
  () => ({
42
52
  id: data.id,
@@ -45,14 +55,15 @@ const useThreadListItemClient = (props: {
45
55
  title: data.title,
46
56
  status: data.status,
47
57
  custom: data.custom,
58
+ isRunning,
48
59
  }),
49
- [data.id, data.title, data.status, data.custom],
60
+ [data.id, data.title, data.status, data.custom, isRunning],
50
61
  );
51
62
 
52
63
  return {
53
64
  getState: () => state,
54
65
  switchTo: onSwitchTo,
55
- rename: () => {},
66
+ rename: onRename,
56
67
  updateCustom: onUpdateCustom,
57
68
  archive: onArchive,
58
69
  unarchive: onUnarchive,
@@ -85,6 +96,12 @@ const useInMemoryThreadList = (
85
96
  onSwitchToThread?.(threadId);
86
97
  };
87
98
 
99
+ const handleRename = (threadId: string, title: string) => {
100
+ setThreads((prev) =>
101
+ prev.map((t) => (t.id === threadId ? { ...t, title } : t)),
102
+ );
103
+ };
104
+
88
105
  const handleArchive = (threadId: string) => {
89
106
  setThreads((prev) =>
90
107
  prev.map((t) =>
@@ -128,13 +145,18 @@ const useInMemoryThreadList = (
128
145
  onSwitchToNewThread?.();
129
146
  };
130
147
 
148
+ // Only the main thread is mounted, so it is the only thread that can run.
149
+ const mainThreadClient = useClientResource(threadFactory(mainThreadId));
150
+
131
151
  const threadListItems = useClientLookup(
132
152
  threads.map((t) =>
133
153
  withKey(
134
154
  t.id,
135
155
  ThreadListItemClient({
136
156
  data: t,
157
+ isRunning: t.id === mainThreadId && mainThreadClient.state.isRunning,
137
158
  onSwitchTo: () => handleSwitchToThread(t.id),
159
+ onRename: (title) => handleRename(t.id, title),
138
160
  onUpdateCustom: (custom) => handleUpdateCustom(t.id, custom),
139
161
  onArchive: () => handleArchive(t.id),
140
162
  onUnarchive: () => handleUnarchive(t.id),
@@ -144,9 +166,6 @@ const useInMemoryThreadList = (
144
166
  ),
145
167
  );
146
168
 
147
- // Create the main thread
148
- const mainThreadClient = useClientResource(threadFactory(mainThreadId));
149
-
150
169
  const state = useMemo(() => {
151
170
  const regularThreads = threads.filter((t) => t.status === "regular");
152
171
  const archivedThreads = threads.filter((t) => t.status === "archived");
@@ -170,6 +189,7 @@ const useInMemoryThreadList = (
170
189
  switchToNewThread: handleSwitchToNewThread,
171
190
  getLoadThreadsPromise: () => RESOLVED_PROMISE,
172
191
  reload: () => RESOLVED_PROMISE,
192
+ reloadMainThread: () => RESOLVED_PROMISE,
173
193
  loadMore: () => RESOLVED_PROMISE,
174
194
  item: (selector) => {
175
195
  if (selector === "main") {
@@ -9,7 +9,11 @@ import {
9
9
  const RESOLVED_PROMISE = Promise.resolve();
10
10
  const THREAD_ID = "default";
11
11
 
12
- const useSingleThreadListItem = (): ClientOutput<"threadListItem"> => {
12
+ const useSingleThreadListItem = ({
13
+ isRunning,
14
+ }: {
15
+ isRunning: boolean;
16
+ }): ClientOutput<"threadListItem"> => {
13
17
  const [custom, setCustom] = useState<Record<string, unknown> | undefined>();
14
18
 
15
19
  return {
@@ -20,6 +24,7 @@ const useSingleThreadListItem = (): ClientOutput<"threadListItem"> => {
20
24
  title: undefined,
21
25
  status: "regular",
22
26
  custom,
27
+ isRunning,
23
28
  }),
24
29
  switchTo: () => {},
25
30
  rename: () => {},
@@ -47,8 +52,10 @@ type SingleThreadListProps = {
47
52
  const useSingleThreadList = ({
48
53
  thread,
49
54
  }: SingleThreadListProps): ClientOutput<"threads"> => {
50
- const itemClient = useClientResource(SingleThreadListItem());
51
55
  const threadClient = useClientResource(thread);
56
+ const itemClient = useClientResource(
57
+ SingleThreadListItem({ isRunning: threadClient.state.isRunning }),
58
+ );
52
59
 
53
60
  const state = useMemo(
54
61
  () => ({
@@ -75,6 +82,7 @@ const useSingleThreadList = ({
75
82
  },
76
83
  getLoadThreadsPromise: () => RESOLVED_PROMISE,
77
84
  reload: () => RESOLVED_PROMISE,
85
+ reloadMainThread: () => RESOLVED_PROMISE,
78
86
  loadMore: () => RESOLVED_PROMISE,
79
87
  item: (selector) => {
80
88
  if (
package/src/index.ts CHANGED
@@ -358,6 +358,7 @@ export type {
358
358
  ToolModelContentPart,
359
359
  MessageStatus,
360
360
  MessagePartStatus,
361
+ MessagePartStreamStatus,
361
362
  ToolCallMessagePartStatus,
362
363
  MessageTiming,
363
364
  ThreadUserMessagePart,
@@ -1,14 +1,9 @@
1
1
  import type {
2
2
  CompleteAttachment,
3
- DataMessagePart,
4
- FileMessagePart,
5
- ImageMessagePart,
6
3
  MessageStatus,
7
4
  SourceProviderMetadata,
8
5
  ThreadMessage,
9
- TextMessagePart,
10
6
  ToolApprovalOption,
11
- Unstable_AudioMessagePart,
12
7
  } from "@assistant-ui/core";
13
8
  import { fromThreadMessageLike } from "../runtime-cores/external-store/ThreadMessageLike";
14
9
  import type { CloudMessage } from "assistant-cloud";
@@ -82,14 +77,38 @@ type AuiV0MessagePart =
82
77
  readonly data: string;
83
78
  readonly mimeType: string;
84
79
  readonly filename?: string;
80
+ readonly sourceType?: "url" | "id";
85
81
  };
86
82
 
87
83
  type AuiV0AttachmentPart =
88
- | TextMessagePart
89
- | ImageMessagePart
90
- | FileMessagePart
91
- | Unstable_AudioMessagePart
92
- | DataMessagePart<ReadonlyJSONValue>;
84
+ | {
85
+ readonly type: "text";
86
+ readonly text: string;
87
+ }
88
+ | {
89
+ readonly type: "image";
90
+ readonly image: string;
91
+ readonly filename?: string;
92
+ }
93
+ | {
94
+ readonly type: "file";
95
+ readonly data: string;
96
+ readonly mimeType: string;
97
+ readonly filename?: string;
98
+ readonly sourceType?: "url" | "id";
99
+ }
100
+ | {
101
+ readonly type: "audio";
102
+ readonly audio: {
103
+ readonly data: string;
104
+ readonly format: "mp3" | "wav";
105
+ };
106
+ }
107
+ | {
108
+ readonly type: "data";
109
+ readonly name: string;
110
+ readonly data: ReadonlyJSONValue;
111
+ };
93
112
 
94
113
  type AuiV0Attachment = {
95
114
  readonly id: string;
@@ -125,16 +144,41 @@ const encodeAttachmentPart = (
125
144
  const type = part.type;
126
145
  switch (type) {
127
146
  case "text":
147
+ return { type: "text", text: part.text };
148
+
128
149
  case "image":
150
+ return {
151
+ type: "image",
152
+ image: part.image,
153
+ ...(part.filename != null ? { filename: part.filename } : undefined),
154
+ };
155
+
129
156
  case "file":
157
+ return {
158
+ type: "file",
159
+ data: part.data,
160
+ mimeType: part.mimeType,
161
+ ...(part.filename != null ? { filename: part.filename } : undefined),
162
+ ...(part.sourceType != null
163
+ ? { sourceType: part.sourceType }
164
+ : undefined),
165
+ };
166
+
130
167
  case "audio":
131
- return part;
168
+ return {
169
+ type: "audio",
170
+ audio: { data: part.audio.data, format: part.audio.format },
171
+ };
132
172
 
133
173
  case "data": {
134
174
  if (!isJSONValue(part.data)) {
135
175
  console.warn(`attachment data is not JSON! ${JSON.stringify(part)}`);
136
176
  }
137
- return { ...part, data: part.data as ReadonlyJSONValue };
177
+ return {
178
+ type: "data",
179
+ name: part.name,
180
+ data: part.data as ReadonlyJSONValue,
181
+ };
138
182
  }
139
183
 
140
184
  default: {
@@ -213,7 +257,7 @@ export function auiV0Encode(message: ThreadMessage): AuiV0Message {
213
257
  };
214
258
 
215
259
  case "tool-call": {
216
- if (!isJSONValue(part.result)) {
260
+ if (part.result !== undefined && !isJSONValue(part.result)) {
217
261
  console.warn(
218
262
  `tool-call result is not JSON! ${JSON.stringify(part)}`,
219
263
  );
@@ -225,7 +269,7 @@ export function auiV0Encode(message: ThreadMessage): AuiV0Message {
225
269
  ...(JSON.stringify(part.args) === part.argsText
226
270
  ? { args: part.args }
227
271
  : { argsText: part.argsText }),
228
- ...(part.result
272
+ ...(part.result !== undefined
229
273
  ? { result: part.result as ReadonlyJSONValue }
230
274
  : undefined),
231
275
  ...(part.isError ? { isError: true } : undefined),
@@ -242,6 +286,7 @@ export function auiV0Encode(message: ThreadMessage): AuiV0Message {
242
286
  data: part.data,
243
287
  mimeType: part.mimeType,
244
288
  ...(part.filename ? { filename: part.filename } : undefined),
289
+ ...(part.sourceType ? { sourceType: part.sourceType } : undefined),
245
290
  };
246
291
 
247
292
  default: {
@@ -1,9 +1,17 @@
1
1
  // @vitest-environment jsdom
2
2
  import { render, waitFor } from "@testing-library/react";
3
- import { resource, useResource } from "@assistant-ui/tap";
4
- import type { ToolCallMessagePartProps } from "@assistant-ui/core/react";
3
+ import { resource, useResource, withKey } from "@assistant-ui/tap";
4
+ import { memo } from "react";
5
+ import type {
6
+ ToolCallMessagePartComponent,
7
+ ToolCallMessagePartProps,
8
+ } from "@assistant-ui/core/react";
5
9
  import { beforeEach, describe, expect, it, vi } from "vitest";
6
- import type { McpAppBridgeHandlers, McpAppsHost } from "./types";
10
+ import type {
11
+ McpAppBridgeHandlers,
12
+ McpAppsHost,
13
+ McpAppsRemoteHostOptions,
14
+ } from "./types";
7
15
 
8
16
  const { framePropsMock } = vi.hoisted(() => ({ framePropsMock: vi.fn() }));
9
17
 
@@ -22,10 +30,19 @@ vi.mock("./app-frame", () => ({
22
30
  }));
23
31
 
24
32
  import { McpAppRenderer } from "./McpAppRenderer";
33
+ import { McpAppsRemoteHost } from "./McpAppsRemoteHost";
25
34
 
26
35
  const useHost = ({ host }: { host: McpAppsHost }) => host;
27
36
  const Host = resource(useHost);
28
37
 
38
+ const createDeferred = <T,>() => {
39
+ let resolve!: (value: T) => void;
40
+ const promise = new Promise<T>((res) => {
41
+ resolve = res;
42
+ });
43
+ return { promise, resolve };
44
+ };
45
+
29
46
  const createPart = (serverId?: string): ToolCallMessagePartProps => ({
30
47
  type: "tool-call",
31
48
  toolCallId: "call-1",
@@ -54,6 +71,43 @@ function Harness({ host, serverId }: { host: McpAppsHost; serverId?: string }) {
54
71
  return <Renderer {...createPart(serverId)} />;
55
72
  }
56
73
 
74
+ const MemoizedPart = memo(function MemoizedPart({
75
+ Renderer,
76
+ }: {
77
+ Renderer: ToolCallMessagePartComponent;
78
+ }) {
79
+ return <Renderer {...createPart()} />;
80
+ });
81
+
82
+ function MemoizedHarness({ host }: { host: McpAppsHost }) {
83
+ const renderer = useResource(
84
+ McpAppRenderer({
85
+ host: Host({ host }),
86
+ }),
87
+ );
88
+ return <MemoizedPart Renderer={renderer.render} />;
89
+ }
90
+
91
+ function RemoteHarness({
92
+ url,
93
+ headers,
94
+ fetch,
95
+ resourceKey,
96
+ }: {
97
+ url: string;
98
+ headers: NonNullable<McpAppsRemoteHostOptions["headers"]>;
99
+ fetch: typeof globalThis.fetch;
100
+ resourceKey?: string | number;
101
+ }) {
102
+ const host = McpAppsRemoteHost({ url, headers, fetch });
103
+ const renderer = useResource(
104
+ McpAppRenderer({
105
+ host: resourceKey === undefined ? host : withKey(resourceKey, host),
106
+ }),
107
+ );
108
+ return <MemoizedPart Renderer={renderer.render} />;
109
+ }
110
+
57
111
  describe("McpAppRenderer", () => {
58
112
  beforeEach(() => {
59
113
  framePropsMock.mockReset();
@@ -88,6 +142,211 @@ describe("McpAppRenderer", () => {
88
142
  });
89
143
  });
90
144
 
145
+ it("reloads the resource and hides stale HTML when the host changes", async () => {
146
+ const nextResource = createDeferred<{
147
+ uri: string;
148
+ mimeType: "text/html;profile=mcp-app";
149
+ html: string;
150
+ }>();
151
+ const firstHost: McpAppsHost = {
152
+ loadResource: vi.fn(async ({ uri }) => ({
153
+ uri,
154
+ mimeType: "text/html;profile=mcp-app" as const,
155
+ html: "first host",
156
+ })),
157
+ callTool: vi.fn(),
158
+ readResource: vi.fn(),
159
+ listResources: vi.fn(),
160
+ };
161
+ const nextHost: McpAppsHost = {
162
+ loadResource: vi.fn(() => nextResource.promise),
163
+ callTool: vi.fn(),
164
+ readResource: vi.fn(),
165
+ listResources: vi.fn(),
166
+ };
167
+
168
+ const view = render(<MemoizedHarness host={firstHost} />);
169
+ await waitFor(() =>
170
+ expect(framePropsMock.mock.lastCall?.[0].resource.html).toBe(
171
+ "first host",
172
+ ),
173
+ );
174
+
175
+ framePropsMock.mockClear();
176
+ view.rerender(<MemoizedHarness host={nextHost} />);
177
+
178
+ expect(nextHost.loadResource).toHaveBeenCalledTimes(1);
179
+ expect(framePropsMock).not.toHaveBeenCalled();
180
+
181
+ nextResource.resolve({
182
+ uri: "ui://example/search",
183
+ mimeType: "text/html;profile=mcp-app",
184
+ html: "next host",
185
+ });
186
+ await waitFor(() =>
187
+ expect(framePropsMock.mock.lastCall?.[0].resource.html).toBe("next host"),
188
+ );
189
+ });
190
+
191
+ it("reloads remote resources when the URL changes and keeps headers current", async () => {
192
+ const fetch = vi.fn(
193
+ async (url: string | URL | Request, init?: RequestInit) =>
194
+ Response.json({
195
+ uri: "ui://example/search",
196
+ mimeType: "text/html;profile=mcp-app",
197
+ html: `${String(url)}:${new Headers(init?.headers).get("authorization")}`,
198
+ }),
199
+ ) as unknown as typeof globalThis.fetch;
200
+
201
+ const view = render(
202
+ <RemoteHarness
203
+ url="/host-a"
204
+ headers={{ authorization: "Bearer a" }}
205
+ fetch={fetch}
206
+ />,
207
+ );
208
+ await waitFor(() =>
209
+ expect(framePropsMock.mock.lastCall?.[0].resource.html).toBe(
210
+ "/host-a:Bearer a",
211
+ ),
212
+ );
213
+
214
+ view.rerender(
215
+ <RemoteHarness
216
+ url="/host-b"
217
+ headers={{ authorization: "Bearer a" }}
218
+ fetch={fetch}
219
+ />,
220
+ );
221
+ await waitFor(() =>
222
+ expect(framePropsMock.mock.lastCall?.[0].resource.html).toBe(
223
+ "/host-b:Bearer a",
224
+ ),
225
+ );
226
+
227
+ view.rerender(
228
+ <RemoteHarness
229
+ url="/host-b"
230
+ headers={{ authorization: "Bearer b" }}
231
+ fetch={fetch}
232
+ />,
233
+ );
234
+ expect(fetch).toHaveBeenCalledTimes(2);
235
+ await framePropsMock.mock.lastCall?.[0].handlers.callTool({
236
+ name: "search",
237
+ });
238
+ expect(fetch).toHaveBeenNthCalledWith(
239
+ 3,
240
+ "/host-b",
241
+ expect.objectContaining({
242
+ headers: {
243
+ "content-type": "application/json",
244
+ authorization: "Bearer b",
245
+ },
246
+ }),
247
+ );
248
+
249
+ view.rerender(
250
+ <RemoteHarness
251
+ url="/host-b"
252
+ headers={{ authorization: "Bearer b" }}
253
+ fetch={fetch}
254
+ />,
255
+ );
256
+ expect(fetch).toHaveBeenCalledTimes(3);
257
+ });
258
+
259
+ it("keeps mutated static headers current without reloading the resource", async () => {
260
+ const fetch = vi.fn(
261
+ async (url: string | URL | Request, init?: RequestInit) =>
262
+ Response.json({
263
+ uri: "ui://example/search",
264
+ mimeType: "text/html;profile=mcp-app",
265
+ html: `${String(url)}:${new Headers(init?.headers).get("authorization")}`,
266
+ }),
267
+ ) as unknown as typeof globalThis.fetch;
268
+ const headers = { authorization: "Bearer a" };
269
+
270
+ const view = render(
271
+ <RemoteHarness url="/host" headers={headers} fetch={fetch} />,
272
+ );
273
+ await waitFor(() =>
274
+ expect(framePropsMock.mock.lastCall?.[0].resource.html).toBe(
275
+ "/host:Bearer a",
276
+ ),
277
+ );
278
+
279
+ headers.authorization = "Bearer b";
280
+ view.rerender(
281
+ <RemoteHarness url="/host" headers={headers} fetch={fetch} />,
282
+ );
283
+ expect(fetch).toHaveBeenCalledTimes(1);
284
+ await framePropsMock.mock.lastCall?.[0].handlers.callTool({
285
+ name: "search",
286
+ });
287
+ expect(fetch).toHaveBeenNthCalledWith(
288
+ 2,
289
+ "/host",
290
+ expect.objectContaining({
291
+ headers: {
292
+ "content-type": "application/json",
293
+ authorization: "Bearer b",
294
+ },
295
+ }),
296
+ );
297
+ expect(fetch).toHaveBeenCalledTimes(2);
298
+ });
299
+
300
+ it("uses resource keys to reload dynamic headers without callback identity churn", async () => {
301
+ const fetch = vi.fn(
302
+ async (url: string | URL | Request, init?: RequestInit) =>
303
+ Response.json({
304
+ uri: "ui://example/search",
305
+ mimeType: "text/html;profile=mcp-app",
306
+ html: `${String(url)}:${new Headers(init?.headers).get("authorization")}`,
307
+ }),
308
+ ) as unknown as typeof globalThis.fetch;
309
+
310
+ const view = render(
311
+ <RemoteHarness
312
+ url="/host"
313
+ headers={() => ({ authorization: "Bearer a" })}
314
+ resourceKey="workspace-a"
315
+ fetch={fetch}
316
+ />,
317
+ );
318
+ await waitFor(() =>
319
+ expect(framePropsMock.mock.lastCall?.[0].resource.html).toBe(
320
+ "/host:Bearer a",
321
+ ),
322
+ );
323
+
324
+ view.rerender(
325
+ <RemoteHarness
326
+ url="/host"
327
+ headers={() => ({ authorization: "Bearer a" })}
328
+ resourceKey="workspace-a"
329
+ fetch={fetch}
330
+ />,
331
+ );
332
+ expect(fetch).toHaveBeenCalledTimes(1);
333
+
334
+ view.rerender(
335
+ <RemoteHarness
336
+ url="/host"
337
+ headers={() => ({ authorization: "Bearer b" })}
338
+ resourceKey="workspace-b"
339
+ fetch={fetch}
340
+ />,
341
+ );
342
+ await waitFor(() =>
343
+ expect(framePropsMock.mock.lastCall?.[0].resource.html).toBe(
344
+ "/host:Bearer b",
345
+ ),
346
+ );
347
+ expect(fetch).toHaveBeenCalledTimes(2);
348
+ });
349
+
91
350
  it("gives the renderer serverId precedence in listResources", async () => {
92
351
  const listResources = vi.fn();
93
352
  const host: McpAppsHost = {