@agent-native/toolkit 0.203.0-nightly-20261007064822 → 0.203.0

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 (40) hide show
  1. package/dist/agentkit.d.ts +2 -1
  2. package/dist/agentkit.d.ts.map +1 -1
  3. package/dist/agentkit.js +2 -1
  4. package/dist/agentkit.js.map +1 -1
  5. package/dist/app/agentkit/react/components.d.ts.map +1 -1
  6. package/dist/app/agentkit/react/components.js +5 -5
  7. package/dist/app/agentkit/react/components.js.map +1 -1
  8. package/dist/app/agentkit/react/context.d.ts +1 -0
  9. package/dist/app/agentkit/react/context.d.ts.map +1 -1
  10. package/dist/app/agentkit/react/context.js +1 -0
  11. package/dist/app/agentkit/react/context.js.map +1 -1
  12. package/dist/app/agentkit/react/styles.css +6 -1
  13. package/dist/app/chat/AgentKitAssistantChat.d.ts.map +1 -1
  14. package/dist/app/chat/AgentKitAssistantChat.js +94 -5
  15. package/dist/app/chat/AgentKitAssistantChat.js.map +1 -1
  16. package/dist/app/chat/MultiTabAssistantChat.d.ts.map +1 -1
  17. package/dist/app/chat/MultiTabAssistantChat.js +4 -1
  18. package/dist/app/chat/MultiTabAssistantChat.js.map +1 -1
  19. package/dist/composer/PromptComposer.d.ts +1 -0
  20. package/dist/composer/PromptComposer.d.ts.map +1 -1
  21. package/dist/composer/PromptComposer.js +13 -1
  22. package/dist/composer/PromptComposer.js.map +1 -1
  23. package/dist/composer/TiptapComposer.d.ts +2 -2
  24. package/dist/composer/TiptapComposer.d.ts.map +1 -1
  25. package/dist/composer/TiptapComposer.js +9 -6
  26. package/dist/composer/TiptapComposer.js.map +1 -1
  27. package/package.json +4 -4
  28. package/src/agentkit.ts +2 -0
  29. package/src/app/agentkit/react/components.interaction.spec.tsx +68 -0
  30. package/src/app/agentkit/react/components.tsx +11 -4
  31. package/src/app/agentkit/react/context.tsx +2 -0
  32. package/src/app/agentkit/react/styles.css +6 -1
  33. package/src/app/chat/AgentKitAssistantChat.spec.tsx +38 -1
  34. package/src/app/chat/AgentKitAssistantChat.tsx +111 -4
  35. package/src/app/chat/MultiTabAssistantChat.spec.tsx +82 -0
  36. package/src/app/chat/MultiTabAssistantChat.tsx +4 -1
  37. package/src/composer/PromptComposer.spec.ts +80 -0
  38. package/src/composer/PromptComposer.tsx +15 -0
  39. package/src/composer/TiptapComposer.spec.ts +23 -0
  40. package/src/composer/TiptapComposer.tsx +18 -7
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/toolkit",
3
- "version": "0.203.0-nightly-20261007064822",
3
+ "version": "0.203.0",
4
4
  "description": "Reusable app-building UI and helpers for Agent-Native apps.",
5
5
  "homepage": "https://github.com/BuilderIO/agent-native#readme",
6
6
  "bugs": {
@@ -673,7 +673,7 @@
673
673
  "y-protocols": "^1.0.7",
674
674
  "yjs": "^13.6.33",
675
675
  "zod": "^4.3.6",
676
- "@agent-native/agentkit": "^0.203.0-nightly-20261007064822"
676
+ "@agent-native/agentkit": "^0.203.0"
677
677
  },
678
678
  "devDependencies": {
679
679
  "@excalidraw/excalidraw": "0.18.1",
@@ -697,10 +697,10 @@
697
697
  "typescript-7": "npm:typescript@^7.0.2",
698
698
  "vite": "8.1.0",
699
699
  "vitest": "^4.1.5",
700
- "@agent-native/core": "^0.203.0-nightly-20261007064822"
700
+ "@agent-native/core": "^0.203.0"
701
701
  },
702
702
  "peerDependencies": {
703
- "@agent-native/core": "0.203.0-nightly-20261007064822",
703
+ "@agent-native/core": ">=0.196.0",
704
704
  "@excalidraw/excalidraw": ">=0.18",
705
705
  "@excalidraw/mermaid-to-excalidraw": ">=2",
706
706
  "@tanstack/react-query": "^5.99.2",
package/src/agentkit.ts CHANGED
@@ -3,6 +3,7 @@ export {
3
3
  AgentSuggestionBar,
4
4
  agentSuggestionPrompt,
5
5
  } from "./composer/AgentSuggestionBar.js";
6
+ export { formatAttachmentError } from "./composer/attachment-accept.js";
6
7
  export { MessageQueueDrawer } from "./composer/MessageQueueDrawer.js";
7
8
  export {
8
9
  ComposerContextMenu,
@@ -21,6 +22,7 @@ export {
21
22
  type ComposerContextPickerFooterAction,
22
23
  } from "./composer/ComposerContextMenu.js";
23
24
  export {
25
+ createChatAttachmentAdapter,
24
26
  PromptComposer,
25
27
  type PromptComposerFile,
26
28
  type PromptComposerProps,
@@ -870,6 +870,74 @@ describe("AgentKitChat interactions", () => {
870
870
  }
871
871
  });
872
872
 
873
+ it("attaches dropped SVGs and names rejected drops without the accept list", async () => {
874
+ const transport: AgentTransport = {
875
+ capabilities: { uploads: true },
876
+ async startRun() {
877
+ return { runId: "run-svg" };
878
+ },
879
+ async *subscribeToRun() {},
880
+ async cancelRun() {},
881
+ };
882
+ const client = new AgentKitClient({ transport });
883
+ const onAttachmentError = vi.fn();
884
+ const container = document.createElement("div");
885
+ document.body.appendChild(container);
886
+ const root = createRoot(container);
887
+ const actEnvironment = globalThis as typeof globalThis & {
888
+ IS_REACT_ACT_ENVIRONMENT?: boolean;
889
+ };
890
+ const previousActEnvironment = actEnvironment.IS_REACT_ACT_ENVIRONMENT;
891
+ actEnvironment.IS_REACT_ACT_ENVIRONMENT = true;
892
+ const dropOnChat = async (file: File) => {
893
+ const drop = new Event("drop", { bubbles: true, cancelable: true });
894
+ Object.defineProperty(drop, "dataTransfer", {
895
+ value: { types: ["Files"], files: [file], dropEffect: "none" },
896
+ });
897
+ await act(async () => {
898
+ container.querySelector(".agentkit-chat")?.dispatchEvent(drop);
899
+ await new Promise((resolve) => setTimeout(resolve, 0));
900
+ });
901
+ };
902
+
903
+ try {
904
+ await act(async () => {
905
+ root.render(
906
+ <AgentKitProvider controller={client} threadId="thread-svg">
907
+ <AgentKitChat composerProps={{ onAttachmentError }} />
908
+ </AgentKitProvider>,
909
+ );
910
+ await Promise.resolve();
911
+ });
912
+
913
+ await dropOnChat(
914
+ new File(['<svg xmlns="http://www.w3.org/2000/svg"/>'], "logo.svg", {
915
+ type: "image/svg+xml",
916
+ }),
917
+ );
918
+ expect(container.textContent).toContain("logo.svg");
919
+ expect(onAttachmentError).not.toHaveBeenCalled();
920
+
921
+ await dropOnChat(
922
+ new File(["zip"], "archive.zip", { type: "application/zip" }),
923
+ );
924
+ expect(onAttachmentError).toHaveBeenCalledWith(
925
+ "archive.zip: Could not add the dropped file. Try a different format.",
926
+ );
927
+ } finally {
928
+ await act(async () => {
929
+ root.unmount();
930
+ await Promise.resolve();
931
+ });
932
+ container.remove();
933
+ if (previousActEnvironment === undefined) {
934
+ delete actEnvironment.IS_REACT_ACT_ENVIRONMENT;
935
+ } else {
936
+ actEnvironment.IS_REACT_ACT_ENVIRONMENT = previousActEnvironment;
937
+ }
938
+ }
939
+ });
940
+
873
941
  it("disables transcript file drops when the host disables uploads", async () => {
874
942
  const transport: AgentTransport = {
875
943
  capabilities: { uploads: true },
@@ -68,6 +68,8 @@ import {
68
68
  Surface,
69
69
  TextField,
70
70
  agentSuggestionPrompt,
71
+ createChatAttachmentAdapter,
72
+ formatAttachmentError,
71
73
  type PromptComposerFile,
72
74
  type PromptComposerProps,
73
75
  type TiptapComposerHandle,
@@ -4065,6 +4067,10 @@ export function AgentKitComposer({
4065
4067
  }, []);
4066
4068
  const [uncontrolledMode, setUncontrolledMode] = useState(defaultMode);
4067
4069
  const [attachmentError, setAttachmentError] = useState<string | null>(null);
4070
+ const composerAttachmentAdapter = useMemo(
4071
+ () => attachmentAdapter ?? createChatAttachmentAdapter(),
4072
+ [attachmentAdapter],
4073
+ );
4068
4074
  const executionMode = mode ?? uncontrolledMode;
4069
4075
  const active = hasActiveRuns(thread);
4070
4076
  const composerInitialText = editingMessage
@@ -4095,12 +4101,13 @@ export function AgentKitComposer({
4095
4101
  await composer.addAttachment(file);
4096
4102
  setAttachmentError(null);
4097
4103
  } catch (error) {
4098
- const message = error instanceof Error ? error.message : labels.error;
4099
- reportAttachmentError(message);
4104
+ reportAttachmentError(
4105
+ `${file.name}: ${formatAttachmentError(error, labels.dropFileFailed)}`,
4106
+ );
4100
4107
  throw error;
4101
4108
  }
4102
4109
  },
4103
- [labels.error, reportAttachmentError],
4110
+ [labels.dropFileFailed, labels.error, reportAttachmentError],
4104
4111
  );
4105
4112
  useEffect(() => {
4106
4113
  const receiveAttachments = (event: Event) => {
@@ -4494,7 +4501,7 @@ export function AgentKitComposer({
4494
4501
  onInspectContextItem={onInspectContextItem}
4495
4502
  onRetryContextItem={onRetryContextItem}
4496
4503
  contextMenuItems={contextMenuItems}
4497
- attachmentAdapter={attachmentAdapter}
4504
+ attachmentAdapter={composerAttachmentAdapter}
4498
4505
  inlineTextAttachments={inlineTextAttachments}
4499
4506
  rootClassName="agentkit-composer"
4500
4507
  className={
@@ -274,6 +274,7 @@ export interface AgentKitLabels {
274
274
  imagePreview: string;
275
275
  closePreview: string;
276
276
  dropFilesToAttach: string;
277
+ dropFileFailed: string;
277
278
  scrollToBottom: string;
278
279
  formatTimestamp?: (createdAt: string) => string;
279
280
  error: string;
@@ -384,6 +385,7 @@ export const defaultAgentKitLabels: AgentKitLabels = {
384
385
  imagePreview: "Image preview",
385
386
  closePreview: "Close preview",
386
387
  dropFilesToAttach: "Drop files to attach",
388
+ dropFileFailed: "Could not add the dropped file. Try a different format.",
387
389
  scrollToBottom: "Scroll to bottom",
388
390
  error: "Something went wrong",
389
391
  renderError: "This content couldn’t be displayed.",
@@ -1715,11 +1715,16 @@ button.agentkit-object {
1715
1715
 
1716
1716
  .agentkit-composer-error {
1717
1717
  display: flex;
1718
- align-items: center;
1718
+ align-items: flex-start;
1719
1719
  gap: 0.375rem;
1720
1720
  padding: 0.375rem 0.625rem 0;
1721
1721
  }
1722
1722
 
1723
+ .agentkit-composer-error > span {
1724
+ min-inline-size: 0;
1725
+ overflow-wrap: anywhere;
1726
+ }
1727
+
1723
1728
  .agentkit-file {
1724
1729
  display: inline-flex;
1725
1730
  align-items: center;
@@ -52,6 +52,7 @@ const chatMocks = vi.hoisted(() => ({
52
52
  reasoningProps: null as any,
53
53
  thinkingDisplay: null as any,
54
54
  requestComposerFocus: vi.fn(),
55
+ persistThreadSnapshot: vi.fn(async () => undefined),
55
56
  readiness: { canChat: true, missing: false, state: "configured" },
56
57
  fetchProviderState: vi.fn(async () => chatMocks.readiness.state),
57
58
  fileUploadStatus: {
@@ -239,7 +240,10 @@ vi.mock("../agentkit/react/index.js", async () => {
239
240
  : {
240
241
  threadId: chatMocks.threadId,
241
242
  requestComposerFocus: chatMocks.requestComposerFocus,
242
- controller: { getThread: () => chatMocks.readThread() },
243
+ controller: {
244
+ getThread: () => chatMocks.readThread(),
245
+ persistThreadSnapshot: chatMocks.persistThreadSnapshot,
246
+ },
243
247
  },
244
248
  useAgentKitControl: () =>
245
249
  chatMocks.useRealChat ? useAgentKitControl() : chatMocks.control,
@@ -755,6 +759,7 @@ beforeEach(() => {
755
759
  chatMocks.reasoningProps = null;
756
760
  chatMocks.thinkingDisplay = null;
757
761
  chatMocks.requestComposerFocus.mockReset();
762
+ chatMocks.persistThreadSnapshot.mockReset().mockResolvedValue(undefined);
758
763
  chatMocks.readiness = {
759
764
  canChat: true,
760
765
  missing: false,
@@ -4862,6 +4867,38 @@ describe("AgentKitAssistantChat host behavior", () => {
4862
4867
  window.removeEventListener("agentNative.chatRunning", onRunning);
4863
4868
  });
4864
4869
 
4870
+ it("persists changed custom-transport snapshots through the protocol save", async () => {
4871
+ const createTransport = () => chatMocks.transport;
4872
+ const onSaveThread = vi.fn();
4873
+ const message = {
4874
+ id: "custom-user-message",
4875
+ role: "user",
4876
+ status: "complete",
4877
+ createdAt: "2026-10-07T12:00:00.000Z",
4878
+ parts: [{ type: "text", text: "Save this message" }],
4879
+ };
4880
+ await mount(baseProps({ createTransport, onSaveThread }));
4881
+
4882
+ chatMocks.thread = {
4883
+ ...chatMocks.thread,
4884
+ thread: null,
4885
+ messages: [message],
4886
+ };
4887
+ await act(async () => {
4888
+ root.render(
4889
+ <AgentKitAssistantChat
4890
+ {...baseProps({ createTransport, onSaveThread })}
4891
+ />,
4892
+ );
4893
+ });
4894
+ await flush();
4895
+
4896
+ expect(chatMocks.persistThreadSnapshot).toHaveBeenCalledWith("thread-1", [
4897
+ expect.objectContaining({ id: "custom-user-message" }),
4898
+ ]);
4899
+ expect(onSaveThread).toHaveBeenCalledOnce();
4900
+ });
4901
+
4865
4902
  it("shows an expired-session card and emits the session-expired event", async () => {
4866
4903
  chatMocks.failureError = { code: "unauthorized", message: "HTTP 401" };
4867
4904
  vi.stubGlobal(
@@ -12,6 +12,7 @@ import type {
12
12
  AgentEvent,
13
13
  AgentMessage,
14
14
  AgentStreamIntegrityReport,
15
+ AgentTransport,
15
16
  AgentThreadSnapshot,
16
17
  AgentToolCall,
17
18
  AgentUploadTarget,
@@ -211,6 +212,65 @@ const deferredProviderSubmissionOperations = new Map<
211
212
  const RECOVERY_CONTINUE_PROMPT =
212
213
  "Continue from where you left off and finish my last request. Do not repeat completed work.";
213
214
 
215
+ function withCoreSnapshotPersistence(
216
+ customTransport: AgentTransport,
217
+ coreTransport: AgentTransport,
218
+ ): AgentTransport {
219
+ if (customTransport === coreTransport) return customTransport;
220
+ const corePersist = coreTransport.persistThreadSnapshot;
221
+ if (!corePersist) {
222
+ throw new TypeError("Core transport must persist thread snapshots.");
223
+ }
224
+ const boundMethods = new Map<PropertyKey, Function>();
225
+ let disposed = false;
226
+ const persistThreadSnapshot: NonNullable<
227
+ AgentTransport["persistThreadSnapshot"]
228
+ > = async (...args) => {
229
+ const writes = [
230
+ ...(customTransport.persistThreadSnapshot
231
+ ? [customTransport.persistThreadSnapshot.call(customTransport, ...args)]
232
+ : []),
233
+ corePersist.call(coreTransport, ...args),
234
+ ];
235
+ const results = await Promise.allSettled(writes);
236
+ const errors = results.flatMap((result) =>
237
+ result.status === "rejected" ? [result.reason] : [],
238
+ );
239
+ if (errors.length === 1) throw errors[0];
240
+ if (errors.length > 1) {
241
+ throw new AggregateError(errors, "Thread snapshot persistence failed.");
242
+ }
243
+ };
244
+ const dispose = async () => {
245
+ if (disposed) return;
246
+ disposed = true;
247
+ const results = await Promise.allSettled([
248
+ Promise.resolve(customTransport.dispose?.()),
249
+ Promise.resolve(coreTransport.dispose?.()),
250
+ ]);
251
+ const errors = results.flatMap((result) =>
252
+ result.status === "rejected" ? [result.reason] : [],
253
+ );
254
+ if (errors.length === 1) throw errors[0];
255
+ if (errors.length > 1) {
256
+ throw new AggregateError(errors, "Chat transport disposal failed.");
257
+ }
258
+ };
259
+ return new Proxy(customTransport, {
260
+ get(target, property) {
261
+ if (property === "persistThreadSnapshot") return persistThreadSnapshot;
262
+ if (property === "dispose") return dispose;
263
+ const value = Reflect.get(target, property, target);
264
+ if (typeof value !== "function") return value;
265
+ const cached = boundMethods.get(property);
266
+ if (cached) return cached;
267
+ const bound = value.bind(target);
268
+ boundMethods.set(property, bound);
269
+ return bound;
270
+ },
271
+ });
272
+ }
273
+
214
274
  type ThreadRestoreState =
215
275
  | { status: "ready" | "loading" }
216
276
  | { status: "error"; notFound: boolean };
@@ -827,6 +887,7 @@ export const AgentKitAssistantChat = forwardRef<
827
887
  imagePreview: t("agentChat.composer.imagePreview"),
828
888
  closePreview: t("agentChat.composer.closePreview"),
829
889
  dropFilesToAttach: t("agentChat.composer.dropToAttach"),
890
+ dropFileFailed: t("agentChat.composer.droppedFileError"),
830
891
  scrollToBottom: t("agentChat.composer.scrollToBottom"),
831
892
  formatTimestamp: (createdAt: string) => {
832
893
  const date = new Date(createdAt);
@@ -982,8 +1043,9 @@ export const AgentKitAssistantChat = forwardRef<
982
1043
  : props.agentChatSurface === "desktop"
983
1044
  ? "desktop"
984
1045
  : "app";
1046
+ const apiUrl = props.apiUrl ?? agentNativePath("/_agent-native/agent-chat");
985
1047
  const adapterContext: AssistantChatAdapterContext = {
986
- apiUrl: props.apiUrl ?? agentNativePath("/_agent-native/agent-chat"),
1048
+ apiUrl,
987
1049
  streamingUrl: props.streamingUrl,
988
1050
  tabId: props.tabId,
989
1051
  threadId,
@@ -998,7 +1060,27 @@ export const AgentKitAssistantChat = forwardRef<
998
1060
  surface,
999
1061
  };
1000
1062
  const customTransport = createTransportRef.current;
1001
- if (customTransport) return customTransport(adapterContext);
1063
+ if (customTransport) {
1064
+ const persistenceTransport = createAgentNativeAgentKitTransport({
1065
+ apiUrl,
1066
+ browserTabId: props.browserTabId,
1067
+ get threadId() {
1068
+ return transportThreadIdRef.current;
1069
+ },
1070
+ surface,
1071
+ get scope() {
1072
+ return scopeRef.current;
1073
+ },
1074
+ get isolateHistoryByScope() {
1075
+ return isolateHistoryByScopeRef.current;
1076
+ },
1077
+ adapter: { textFormat: "markdown" },
1078
+ });
1079
+ return withCoreSnapshotPersistence(
1080
+ customTransport(adapterContext),
1081
+ persistenceTransport,
1082
+ );
1083
+ }
1002
1084
  const runtimeOptions: CreateAgentNativeChatRuntimeOptions = {
1003
1085
  apiUrl: props.apiUrl ?? agentNativePath("/_agent-native/agent-chat"),
1004
1086
  streamingUrl: props.streamingUrl,
@@ -1890,6 +1972,12 @@ const AgentKitAssistantChatBody = forwardRef<
1890
1972
  if (!props.onSaveThread) return;
1891
1973
  if (snapshot.threadData === lastSavedThreadDataRef.current) return;
1892
1974
  lastSavedThreadDataRef.current = snapshot.threadData;
1975
+ if (props.createTransport) {
1976
+ void controller.persistThreadSnapshot(
1977
+ threadId,
1978
+ agentKitMessagesFromThreadSnapshot(snapshot),
1979
+ );
1980
+ }
1893
1981
  props.onSaveThread(threadId, snapshot);
1894
1982
  };
1895
1983
 
@@ -1930,11 +2018,17 @@ const AgentKitAssistantChatBody = forwardRef<
1930
2018
  );
1931
2019
  if (props.onSaveThread) {
1932
2020
  lastSavedThreadDataRef.current = snapshot.threadData;
2021
+ if (props.createTransport) {
2022
+ void controller.persistThreadSnapshot(
2023
+ threadId,
2024
+ agentKitMessagesFromThreadSnapshot(snapshot),
2025
+ );
2026
+ }
1933
2027
  props.onSaveThread(threadId, snapshot);
1934
2028
  }
1935
2029
  return true;
1936
2030
  },
1937
- [isRestoring, isRunning, props, thread, threadId],
2031
+ [controller, isRestoring, isRunning, props, thread, threadId],
1938
2032
  );
1939
2033
 
1940
2034
  useEffect(() => {
@@ -4146,7 +4240,9 @@ function AgentKitComposerSurface({
4146
4240
  className="mx-3 mb-1.5 flex shrink-0 items-start gap-2 rounded-md border border-border bg-muted/70 px-3 py-2 text-xs text-foreground shadow-sm"
4147
4241
  >
4148
4242
  <IconAlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-muted-foreground" />
4149
- <span className="flex-1 leading-snug">{composerError}</span>
4243
+ <span className="min-w-0 flex-1 break-words leading-snug">
4244
+ {composerError}
4245
+ </span>
4150
4246
  <button
4151
4247
  type="button"
4152
4248
  aria-label={t("agentChat.common.dismissError")}
@@ -5265,6 +5361,17 @@ function createAgentKitThreadSnapshot(thread: AgentThreadState) {
5265
5361
  };
5266
5362
  }
5267
5363
 
5364
+ function agentKitMessagesFromThreadSnapshot(
5365
+ snapshot: ReturnType<typeof createAgentKitThreadSnapshot>,
5366
+ ): AgentMessage[] {
5367
+ const repository = asRecord(JSON.parse(snapshot.threadData));
5368
+ const agentKit = asRecord(repository?.agentKit);
5369
+ if (!Array.isArray(agentKit?.messages)) {
5370
+ throw new TypeError("AgentKit thread snapshot is missing its messages.");
5371
+ }
5372
+ return agentKit.messages as AgentMessage[];
5373
+ }
5374
+
5268
5375
  function createAgentKitThreadHandoffKey(
5269
5376
  props: Pick<AssistantChatProps, "apiUrl" | "browserTabId" | "contextScope">,
5270
5377
  threadId: string,
@@ -32,6 +32,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
32
32
  import {
33
33
  MultiTabAssistantChat,
34
34
  type MultiTabAssistantChatHeaderProps,
35
+ type MultiTabAssistantChatProps,
35
36
  } from "./MultiTabAssistantChat.js";
36
37
 
37
38
  afterEach(() => {
@@ -84,6 +85,17 @@ const assistantChatMockState = vi.hoisted(() => ({
84
85
  onRetryModelList: undefined as (() => void) | undefined,
85
86
  onSlashCommand: undefined as ((command: string) => void) | undefined,
86
87
  onForkedThread: undefined as ((threadId: string) => void) | undefined,
88
+ onSaveThread: undefined as
89
+ | ((
90
+ threadId: string,
91
+ data: {
92
+ threadData: string;
93
+ title: string;
94
+ preview: string;
95
+ messageCount: number;
96
+ },
97
+ ) => void)
98
+ | undefined,
87
99
  branchNavigation: undefined as
88
100
  | {
89
101
  index: number;
@@ -339,6 +351,7 @@ vi.mock("./AgentKitAssistantChat.js", async () => {
339
351
  onRetryModelList?: () => void;
340
352
  onSlashCommand?: (command: string) => void;
341
353
  onForkedThread?: (threadId: string) => void;
354
+ onSaveThread?: typeof assistantChatMockState.onSaveThread;
342
355
  branchNavigation?: typeof assistantChatMockState.branchNavigation;
343
356
  };
344
357
  assistantChatMockState.onThreadRestoreNotFound =
@@ -346,6 +359,7 @@ vi.mock("./AgentKitAssistantChat.js", async () => {
346
359
  assistantChatMockState.onRetryModelList = props.onRetryModelList;
347
360
  assistantChatMockState.onSlashCommand = props.onSlashCommand;
348
361
  assistantChatMockState.onForkedThread = props.onForkedThread;
362
+ assistantChatMockState.onSaveThread = props.onSaveThread;
349
363
  assistantChatMockState.branchNavigation = props.branchNavigation;
350
364
  React.useImperativeHandle(ref, () => ({
351
365
  sendMessage: chatHandleMocks.sendMessage,
@@ -399,6 +413,7 @@ function resetThreadMocks() {
399
413
  assistantChatMockState.onRetryModelList = undefined;
400
414
  assistantChatMockState.onSlashCommand = undefined;
401
415
  assistantChatMockState.onForkedThread = undefined;
416
+ assistantChatMockState.onSaveThread = undefined;
402
417
  assistantChatMockState.branchNavigation = undefined;
403
418
  threadMocks.activeThreadId = "thread-1";
404
419
  threadMocks.evictedThreadIds = [];
@@ -501,6 +516,73 @@ describe("MultiTabAssistantChat postMessage bridge", () => {
501
516
  vi.clearAllMocks();
502
517
  });
503
518
 
519
+ it("keeps thread saves metadata-only for built-in, runtime, and custom transports", async () => {
520
+ const snapshot = {
521
+ threadData: JSON.stringify({ messages: [{ id: "message-1" }] }),
522
+ title: "Saved chat",
523
+ preview: "Latest request",
524
+ messageCount: 1,
525
+ };
526
+ window.history.replaceState(null, "", "/");
527
+
528
+ await act(async () => {
529
+ root.render(
530
+ <MultiTabAssistantChat storageKey="bridge-test" threadUrlSync />,
531
+ );
532
+ await Promise.resolve();
533
+ });
534
+
535
+ act(() => assistantChatMockState.onSaveThread?.("thread-1", snapshot));
536
+
537
+ expect(threadMocks.saveThreadData).toHaveBeenLastCalledWith("thread-1", {
538
+ ...snapshot,
539
+ threadData: "",
540
+ });
541
+ expect(window.location.search).toBe("?thread=thread-1");
542
+
543
+ const createTransport = (() => ({}) as never) as NonNullable<
544
+ MultiTabAssistantChatProps["createTransport"]
545
+ >;
546
+ await act(async () => {
547
+ root.render(
548
+ <MultiTabAssistantChat
549
+ storageKey="bridge-test"
550
+ threadUrlSync
551
+ createTransport={createTransport}
552
+ />,
553
+ );
554
+ await Promise.resolve();
555
+ });
556
+
557
+ act(() => assistantChatMockState.onSaveThread?.("thread-1", snapshot));
558
+
559
+ expect(threadMocks.saveThreadData).toHaveBeenLastCalledWith("thread-1", {
560
+ ...snapshot,
561
+ threadData: "",
562
+ });
563
+ expect(window.location.search).toBe("?thread=thread-1");
564
+
565
+ const runtime = {} as NonNullable<MultiTabAssistantChatProps["runtime"]>;
566
+ await act(async () => {
567
+ root.render(
568
+ <MultiTabAssistantChat
569
+ storageKey="bridge-test"
570
+ threadUrlSync
571
+ runtime={runtime}
572
+ />,
573
+ );
574
+ await Promise.resolve();
575
+ });
576
+
577
+ act(() => assistantChatMockState.onSaveThread?.("thread-1", snapshot));
578
+
579
+ expect(threadMocks.saveThreadData).toHaveBeenLastCalledWith("thread-1", {
580
+ ...snapshot,
581
+ threadData: "",
582
+ });
583
+ expect(window.location.search).toBe("?thread=thread-1");
584
+ });
585
+
504
586
  it("prefills the active composer without submitting when submit is false", () => {
505
587
  act(() => {
506
588
  dispatchSubmitChat({
@@ -2749,7 +2749,10 @@ export function MultiTabAssistantChat({
2749
2749
  messageCount: number;
2750
2750
  },
2751
2751
  ) => {
2752
- void saveThreadData(threadId, data);
2752
+ void saveThreadData(threadId, {
2753
+ ...data,
2754
+ threadData: "",
2755
+ });
2753
2756
  if (
2754
2757
  data.messageCount > 0 &&
2755
2758
  threadId === activeThreadIdRef.current &&
@@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
6
 
7
7
  import {
8
8
  buildPromptComposerSubmission,
9
+ createChatAttachmentAdapter,
9
10
  PromptComposer,
10
11
  resolveComposerModelStatusChecksEnabled,
11
12
  shouldGateComposerForEngine,
@@ -234,3 +235,82 @@ describe("PromptComposer scoped runtime", () => {
234
235
  expect(attachedFiles).toHaveLength(0);
235
236
  });
236
237
  });
238
+
239
+ describe("PromptComposer attachment policy", () => {
240
+ it("keeps SVGs out of standalone prompts while chat stages them as documents", async () => {
241
+ await act(async () => {
242
+ root.render(
243
+ React.createElement(PromptComposer, {
244
+ attachmentsEnabled: true,
245
+ includeDefaultSlashSkills: false,
246
+ onSubmit: () => {},
247
+ plusMenuMode: "upload-only",
248
+ showModelSelector: false,
249
+ voiceEnabled: false,
250
+ }),
251
+ );
252
+ await new Promise((resolve) => setTimeout(resolve, 0));
253
+ });
254
+ const standaloneAccept = container
255
+ .querySelector<HTMLInputElement>('input[type="file"]')
256
+ ?.getAttribute("accept")
257
+ ?.split(",");
258
+ expect(standaloneAccept).toContain(".pdf");
259
+ expect(standaloneAccept).not.toContain(".svg");
260
+
261
+ const chatAdapter = createChatAttachmentAdapter();
262
+ expect(chatAdapter.accept.split(",")).toEqual(
263
+ expect.arrayContaining(["image/svg+xml", ".svg", ".png", ".pdf"]),
264
+ );
265
+ await expect(
266
+ chatAdapter.add({
267
+ file: new File(["<svg/>"], "logo.svg", { type: "image/svg+xml" }),
268
+ }),
269
+ ).resolves.toMatchObject({
270
+ type: "document",
271
+ name: "logo.svg",
272
+ contentType: "image/svg+xml",
273
+ });
274
+ await expect(
275
+ chatAdapter.add({
276
+ file: new File(["png"], "logo.png", { type: "image/png" }),
277
+ }),
278
+ ).resolves.toMatchObject({ type: "image", name: "logo.png" });
279
+ });
280
+
281
+ it("shows why a file was rejected when the host passes no error handler", async () => {
282
+ await act(async () => {
283
+ root.render(
284
+ React.createElement(PromptComposer, {
285
+ attachmentsEnabled: true,
286
+ includeDefaultSlashSkills: false,
287
+ onSubmit: () => {},
288
+ plusMenuMode: "upload-only",
289
+ showModelSelector: false,
290
+ voiceEnabled: false,
291
+ }),
292
+ );
293
+ await new Promise((resolve) => setTimeout(resolve, 0));
294
+ });
295
+ const editor = container.querySelector<HTMLElement>(
296
+ ".agent-composer-prosemirror",
297
+ );
298
+ expect(editor).not.toBeNull();
299
+ const pasteEvent = new Event("paste", { bubbles: true, cancelable: true });
300
+ Object.defineProperty(pasteEvent, "clipboardData", {
301
+ value: {
302
+ files: [new File(["<svg/>"], "logo.svg", { type: "image/svg+xml" })],
303
+ getData: () => "",
304
+ },
305
+ });
306
+
307
+ await act(async () => {
308
+ editor?.dispatchEvent(pasteEvent);
309
+ await new Promise((resolve) => setTimeout(resolve, 20));
310
+ });
311
+
312
+ expect(container.querySelector('[role="alert"]')?.textContent).toBe(
313
+ "Could not attach the pasted image. Try a different format.",
314
+ );
315
+ });
316
+ });