@assistant-ui/core 0.3.12 → 0.3.13

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 (107) hide show
  1. package/dist/index.d.ts +2 -2
  2. package/dist/index.js +2 -2
  3. package/dist/index.js.map +1 -1
  4. package/dist/react/client/interactable-model-context.d.ts.map +1 -1
  5. package/dist/react/client/interactable-model-context.js +5 -1
  6. package/dist/react/client/interactable-model-context.js.map +1 -1
  7. package/dist/runtime/api/thread-runtime.d.ts.map +1 -1
  8. package/dist/runtime/api/thread-runtime.js +5 -1
  9. package/dist/runtime/api/thread-runtime.js.map +1 -1
  10. package/dist/runtime/base/base-composer-runtime-core.d.ts +25 -0
  11. package/dist/runtime/base/base-composer-runtime-core.d.ts.map +1 -1
  12. package/dist/runtime/base/base-composer-runtime-core.js +64 -11
  13. package/dist/runtime/base/base-composer-runtime-core.js.map +1 -1
  14. package/dist/runtime/base/base-thread-runtime-core.d.ts +14 -0
  15. package/dist/runtime/base/base-thread-runtime-core.d.ts.map +1 -1
  16. package/dist/runtime/base/base-thread-runtime-core.js +31 -0
  17. package/dist/runtime/base/base-thread-runtime-core.js.map +1 -1
  18. package/dist/runtime/base/default-edit-composer-runtime-core.d.ts.map +1 -1
  19. package/dist/runtime/base/default-edit-composer-runtime-core.js +1 -6
  20. package/dist/runtime/base/default-edit-composer-runtime-core.js.map +1 -1
  21. package/dist/runtime/base/default-thread-composer-runtime-core.d.ts.map +1 -1
  22. package/dist/runtime/base/default-thread-composer-runtime-core.js +1 -4
  23. package/dist/runtime/base/default-thread-composer-runtime-core.js.map +1 -1
  24. package/dist/runtime/queue/external-thread-queue-adapter.d.ts +15 -0
  25. package/dist/runtime/queue/external-thread-queue-adapter.d.ts.map +1 -1
  26. package/dist/runtime/queue/message-queue.js +23 -10
  27. package/dist/runtime/queue/message-queue.js.map +1 -1
  28. package/dist/runtimes/external-store/external-store-adapter.d.ts +7 -0
  29. package/dist/runtimes/external-store/external-store-adapter.d.ts.map +1 -1
  30. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts +3 -1
  31. package/dist/runtimes/external-store/external-store-thread-runtime-core.d.ts.map +1 -1
  32. package/dist/runtimes/external-store/external-store-thread-runtime-core.js +49 -13
  33. package/dist/runtimes/external-store/external-store-thread-runtime-core.js.map +1 -1
  34. package/dist/runtimes/local/local-thread-runtime-core.d.ts.map +1 -1
  35. package/dist/runtimes/local/local-thread-runtime-core.js +2 -1
  36. package/dist/runtimes/local/local-thread-runtime-core.js.map +1 -1
  37. package/dist/store/clients/external-thread.d.ts +7 -0
  38. package/dist/store/clients/external-thread.d.ts.map +1 -1
  39. package/dist/store/clients/external-thread.js +460 -369
  40. package/dist/store/clients/external-thread.js.map +1 -1
  41. package/dist/store/clients/runtime-adapter.d.ts.map +1 -1
  42. package/dist/store/clients/runtime-adapter.js +5 -2
  43. package/dist/store/clients/runtime-adapter.js.map +1 -1
  44. package/dist/store/clients/single-thread-list.d.ts.map +1 -1
  45. package/dist/store/clients/single-thread-list.js +31 -27
  46. package/dist/store/clients/single-thread-list.js.map +1 -1
  47. package/dist/store/clients/suggestions.d.ts +3 -1
  48. package/dist/store/clients/suggestions.d.ts.map +1 -1
  49. package/dist/store/clients/suggestions.js +65 -37
  50. package/dist/store/clients/suggestions.js.map +1 -1
  51. package/dist/store/index.d.ts +2 -2
  52. package/dist/store/index.js +1 -1
  53. package/dist/store/runtime-clients/thread-list-item-runtime-client.d.ts.map +1 -1
  54. package/dist/store/runtime-clients/thread-list-item-runtime-client.js +64 -50
  55. package/dist/store/runtime-clients/thread-list-item-runtime-client.js.map +1 -1
  56. package/dist/store/runtime-clients/thread-runtime-client.d.ts.map +1 -1
  57. package/dist/store/runtime-clients/thread-runtime-client.js +98 -82
  58. package/dist/store/runtime-clients/thread-runtime-client.js.map +1 -1
  59. package/dist/store/scope-registration.d.ts +2 -2
  60. package/dist/store/scopes/suggestions.d.ts +6 -1
  61. package/dist/store/scopes/suggestions.d.ts.map +1 -1
  62. package/dist/store/scopes/thread.d.ts +17 -0
  63. package/dist/store/scopes/thread.d.ts.map +1 -1
  64. package/dist/types/error.d.ts +15 -1
  65. package/dist/types/error.d.ts.map +1 -1
  66. package/dist/types/error.js +18 -1
  67. package/dist/types/error.js.map +1 -1
  68. package/dist/types/message.d.ts +2 -0
  69. package/dist/types/message.d.ts.map +1 -1
  70. package/dist/types/message.js.map +1 -1
  71. package/package.json +5 -5
  72. package/src/index.ts +1 -0
  73. package/src/react/client/interactable-model-context.ts +6 -1
  74. package/src/react/runtimes/useExternalStoreRuntime.suggestions.test.tsx +114 -0
  75. package/src/runtime/api/thread-runtime.ts +8 -1
  76. package/src/runtime/base/base-composer-runtime-core.ts +100 -8
  77. package/src/runtime/base/base-thread-runtime-core.ts +36 -0
  78. package/src/runtime/base/default-edit-composer-runtime-core.ts +1 -15
  79. package/src/runtime/base/default-thread-composer-runtime-core.ts +1 -11
  80. package/src/runtime/queue/external-thread-queue-adapter.ts +17 -0
  81. package/src/runtime/queue/message-queue.ts +26 -9
  82. package/src/runtimes/external-store/external-store-adapter.ts +7 -0
  83. package/src/runtimes/external-store/external-store-thread-runtime-core.ts +100 -26
  84. package/src/runtimes/local/local-thread-runtime-core.ts +4 -1
  85. package/src/store/clients/external-thread.ts +119 -16
  86. package/src/store/clients/runtime-adapter.ts +5 -2
  87. package/src/store/clients/single-thread-list.ts +2 -1
  88. package/src/store/clients/suggestions.ts +39 -13
  89. package/src/store/runtime-clients/thread-list-item-runtime-client.ts +13 -28
  90. package/src/store/runtime-clients/thread-runtime-client.ts +5 -0
  91. package/src/store/scopes/suggestions.ts +6 -0
  92. package/src/store/scopes/thread.ts +17 -0
  93. package/src/tests/append-interactable-snapshots.test.ts +350 -0
  94. package/src/tests/base-composer-runtime-core-send.test.ts +126 -0
  95. package/src/tests/base-composer-runtime-core.test.ts +108 -1
  96. package/src/tests/default-edit-composer-runtime-core.test.ts +0 -68
  97. package/src/tests/external-store-thread-runtime-core-adapter.test.ts +300 -0
  98. package/src/tests/external-thread-attachments.test.tsx +214 -0
  99. package/src/tests/external-thread-parity.test.tsx +54 -0
  100. package/src/tests/external-thread-refetch.test.tsx +117 -0
  101. package/src/tests/external-thread-suggestions.test.tsx +59 -0
  102. package/src/tests/message-queue.test.ts +26 -0
  103. package/src/tests/thread-message-like.test.ts +39 -0
  104. package/src/tests/thread-switch-events.test.tsx +136 -0
  105. package/src/types/error.ts +24 -0
  106. package/src/types/message.ts +2 -0
  107. package/src/tests/default-thread-composer-runtime-core.test.ts +0 -82
@@ -27,6 +27,7 @@ import type {
27
27
  RespondToToolApprovalOptions,
28
28
  ResumeToolCallOptions,
29
29
  SpeechState,
30
+ ThreadSuggestion,
30
31
  } from "../../runtime/interfaces/thread-runtime-core";
31
32
  import type {
32
33
  ExternalThreadQueueAdapter,
@@ -45,13 +46,14 @@ import { getThreadMessageText } from "../../utils/text";
45
46
  import { resolveToolApprovalResponse } from "../../runtime/utils/resolveToolApprovalResponse";
46
47
  import { toMessagePartStatus } from "../../utils/normalizePartStatus";
47
48
  import { ModelContext } from "./model-context-client";
48
- import { Suggestions } from "./suggestions";
49
+ import { ThreadSuggestions } from "./suggestions";
49
50
  import { Tools } from "../../react/client/Tools";
50
51
  import { DataRenderers } from "../../react/client/DataRenderers";
51
52
  import { SingleThreadList } from "./single-thread-list";
52
53
 
53
54
  const EMPTY_QUEUE_ITEMS: readonly QueueItemState[] = [];
54
55
  const EMPTY_BRANCH_IDS: readonly string[] = [];
56
+ const EMPTY_SUGGESTIONS: readonly ThreadSuggestion[] = [];
55
57
 
56
58
  export type ExternalThreadMessage = ThreadMessage & {
57
59
  id: string;
@@ -93,6 +95,13 @@ export type ExternalThreadProps = {
93
95
  onStartRun?: () => void;
94
96
  onCancel?: () => void;
95
97
  onResume?: (() => void) | undefined;
98
+ /**
99
+ * Handler for re-fetching this thread's state in place, driving
100
+ * `threads.reloadMainThread()`. Unrelated to `onReload`, which re-generates
101
+ * an assistant message. Presence enables the `refetchThread` capability;
102
+ * rejections propagate to the caller.
103
+ */
104
+ onRefetchThread?: (() => Promise<void>) | undefined;
96
105
  onAddToolResult?: ((options: AddToolResultOptions) => void) | undefined;
97
106
  /** Callback for resuming a tool call that is waiting for human input. */
98
107
  onResumeToolCall?: ((options: ResumeToolCallOptions) => void) | undefined;
@@ -401,6 +410,7 @@ const AttachmentResource = resource(useAttachmentResource);
401
410
  type ComposerClientResourceProps = {
402
411
  type: "thread" | "edit";
403
412
  canCancel: boolean;
413
+ isRunning?: boolean;
404
414
  isSendDisabled?: boolean;
405
415
  onCancel?: () => void;
406
416
  onBeginEdit?: () => void;
@@ -410,6 +420,53 @@ type ComposerClientResourceProps = {
410
420
  attachmentAdapter?: AttachmentAdapter | undefined;
411
421
  };
412
422
 
423
+ type AttachmentAddOperation = {
424
+ cancelled: boolean;
425
+ attachmentIds: Set<string>;
426
+ };
427
+
428
+ class AttachmentAddOperations {
429
+ private readonly operations = new Set<AttachmentAddOperation>();
430
+
431
+ start() {
432
+ const operation: AttachmentAddOperation = {
433
+ cancelled: false,
434
+ attachmentIds: new Set(),
435
+ };
436
+ this.operations.add(operation);
437
+ return operation;
438
+ }
439
+
440
+ accept(operation: AttachmentAddOperation, attachmentId: string) {
441
+ if (operation.cancelled) return false;
442
+ operation.attachmentIds.add(attachmentId);
443
+ return true;
444
+ }
445
+
446
+ finish(operation: AttachmentAddOperation) {
447
+ this.operations.delete(operation);
448
+ }
449
+
450
+ isCancelled(operation: AttachmentAddOperation) {
451
+ return operation.cancelled;
452
+ }
453
+
454
+ cancel(attachmentId: string) {
455
+ for (const operation of [...this.operations]) {
456
+ if (!operation.attachmentIds.has(attachmentId)) continue;
457
+ operation.cancelled = true;
458
+ this.operations.delete(operation);
459
+ }
460
+ }
461
+
462
+ cancelAll() {
463
+ for (const operation of this.operations) {
464
+ operation.cancelled = true;
465
+ }
466
+ this.operations.clear();
467
+ }
468
+ }
469
+
413
470
  const useQueueItemClient = ({
414
471
  item,
415
472
  onMove,
@@ -431,11 +488,11 @@ const QueueItemClient = resource(useQueueItemClient);
431
488
 
432
489
  const drainAdapterAdd = async (
433
490
  result: ReturnType<AttachmentAdapter["add"]>,
434
- upsert: (attachment: Attachment) => void,
491
+ upsert: (attachment: Attachment) => boolean,
435
492
  ) => {
436
493
  if (Symbol.asyncIterator in result) {
437
494
  for await (const attachment of result) {
438
- upsert(attachment);
495
+ if (!upsert(attachment)) break;
439
496
  }
440
497
  } else {
441
498
  upsert(await result);
@@ -460,6 +517,7 @@ const useLiveState = <T>(initial: T) => {
460
517
  const useComposerClientResource = ({
461
518
  type,
462
519
  canCancel,
520
+ isRunning = false,
463
521
  isSendDisabled = false,
464
522
  onCancel,
465
523
  onBeginEdit,
@@ -484,6 +542,10 @@ const useComposerClientResource = ({
484
542
  const [quote, setQuote, quoteRef] = useLiveState<
485
543
  { readonly text: string; readonly messageId: string } | undefined
486
544
  >(undefined);
545
+ const attachmentAddOperations = useMemo(
546
+ () => new AttachmentAddOperations(),
547
+ [],
548
+ );
487
549
 
488
550
  const updateFromMessage = () => {
489
551
  if (!message) return;
@@ -503,6 +565,7 @@ const useComposerClientResource = ({
503
565
  AttachmentResource({
504
566
  attachment,
505
567
  onRemove: async () => {
568
+ attachmentAddOperations.cancel(attachment.id);
506
569
  await attachmentAdapter?.remove(attachment);
507
570
  setAttachments((prev) =>
508
571
  prev.filter((a) => a.id !== attachment.id),
@@ -607,10 +670,22 @@ const useComposerClientResource = ({
607
670
  );
608
671
  }
609
672
  if (!isCreateAttachment(fileOrAttachment) && attachmentAdapter) {
610
- await drainAdapterAdd(
611
- attachmentAdapter.add({ file: fileOrAttachment }),
612
- upsertAttachment,
613
- );
673
+ const operation = attachmentAddOperations.start();
674
+ try {
675
+ await drainAdapterAdd(
676
+ attachmentAdapter.add({ file: fileOrAttachment }),
677
+ (attachment) => {
678
+ if (!attachmentAddOperations.accept(operation, attachment.id))
679
+ return false;
680
+ upsertAttachment(attachment);
681
+ return true;
682
+ },
683
+ );
684
+ attachmentAddOperations.finish(operation);
685
+ } catch (error) {
686
+ attachmentAddOperations.finish(operation);
687
+ if (!attachmentAddOperations.isCancelled(operation)) throw error;
688
+ }
614
689
  } else if (!isCreateAttachment(fileOrAttachment)) {
615
690
  const newAttachment: Attachment = {
616
691
  id: Math.random().toString(36).substring(7),
@@ -635,6 +710,7 @@ const useComposerClientResource = ({
635
710
  }
636
711
  },
637
712
  clearAttachments: async () => {
713
+ attachmentAddOperations.cancelAll();
638
714
  const removed = attachmentsRef.current;
639
715
  setAttachments([]);
640
716
  await removePendingAttachments(removed);
@@ -646,6 +722,7 @@ const useComposerClientResource = ({
646
722
  return attachmentClients.get(selector);
647
723
  },
648
724
  reset: async () => {
725
+ attachmentAddOperations.cancelAll();
649
726
  const removed = attachmentsRef.current;
650
727
  setText("");
651
728
  setRole("user");
@@ -657,18 +734,21 @@ const useComposerClientResource = ({
657
734
  send: (opts?: ComposerSendOptions) => {
658
735
  const currentQuote = quoteRef.current;
659
736
  const currentText = textRef.current;
737
+ const currentRole = roleRef.current;
738
+ const currentRunConfig = runConfigRef.current;
660
739
  const currentAttachments = attachmentsRef.current;
661
740
  const isEmpty = !currentText.trim() && !currentAttachments.length;
662
741
  if (!isEditingRef.current) throw new Error("Composer is not available");
663
742
  if (isEmpty || isSendDisabled) return;
664
743
 
744
+ attachmentAddOperations.cancelAll();
665
745
  setText("");
666
746
  setAttachments([]);
667
747
  setQuote(undefined);
668
748
 
669
749
  const dispatch = (sendAttachments: readonly Attachment[]) => {
670
750
  const composedMessage: AppendMessage = {
671
- role: roleRef.current,
751
+ role: currentRole,
672
752
  content: currentText
673
753
  ? [{ type: "text" as const, text: currentText }]
674
754
  : [],
@@ -676,7 +756,7 @@ const useComposerClientResource = ({
676
756
  createdAt: new Date(),
677
757
  parentId: null,
678
758
  sourceId: null,
679
- runConfig: runConfigRef.current,
759
+ runConfig: currentRunConfig,
680
760
  startRun: opts?.startRun,
681
761
  metadata: {
682
762
  custom: { ...(currentQuote ? { quote: currentQuote } : {}) },
@@ -684,7 +764,7 @@ const useComposerClientResource = ({
684
764
  };
685
765
  // edit sends carry a sourceId contract; only thread sends queue
686
766
  if (queue && type === "thread") {
687
- if (opts?.steer ?? canCancel) queue.steer(composedMessage);
767
+ if (opts?.steer ?? isRunning) queue.steer(composedMessage);
688
768
  else queue.enqueue(composedMessage);
689
769
  } else {
690
770
  onSend?.(composedMessage);
@@ -829,6 +909,7 @@ const useExternalThread = ({
829
909
  onStartRun,
830
910
  onCancel,
831
911
  onResume,
912
+ onRefetchThread,
832
913
  onAddToolResult,
833
914
  onResumeToolCall,
834
915
  onLoadExternalState,
@@ -942,7 +1023,15 @@ const useExternalThread = ({
942
1023
  );
943
1024
 
944
1025
  const handleCancelRun = () => {
945
- onCancel?.();
1026
+ // Nothing is aborted without a handler, so pausing the queue would hold
1027
+ // the pending items against a run that keeps going.
1028
+ if (!onCancel) return;
1029
+
1030
+ // Before the run is aborted, so the settle it produces keeps the pending
1031
+ // items instead of dispatching the next one at the moment the user
1032
+ // stopped.
1033
+ queue?.__internal_notifyCancelled?.();
1034
+ onCancel();
946
1035
  };
947
1036
 
948
1037
  const handleSendNew = (message: AppendMessage) => {
@@ -952,6 +1041,8 @@ const useExternalThread = ({
952
1041
  };
953
1042
 
954
1043
  const headId = messages.at(-1)?.id ?? null;
1044
+ const hasCancel = !!onCancel;
1045
+ const hasRefetchThread = !!onRefetchThread;
955
1046
  const composerQueue = useMemo(
956
1047
  (): ExternalThreadQueueAdapter | undefined =>
957
1048
  queue && {
@@ -967,7 +1058,8 @@ const useExternalThread = ({
967
1058
  const composerClient = useClientResource(
968
1059
  ComposerClientResource({
969
1060
  type: "thread",
970
- canCancel: isRunning,
1061
+ canCancel: isRunning && hasCancel,
1062
+ isRunning,
971
1063
  isSendDisabled,
972
1064
  onCancel: handleCancelRun,
973
1065
  onSend: handleSendNew,
@@ -975,6 +1067,9 @@ const useExternalThread = ({
975
1067
  attachmentAdapter,
976
1068
  }),
977
1069
  );
1070
+ const suggestionsClient = useClientResource(
1071
+ ThreadSuggestions(EMPTY_SUGGESTIONS),
1072
+ );
978
1073
 
979
1074
  const hasQueue = !!queue;
980
1075
  const hasBranches = !!branches;
@@ -998,8 +1093,8 @@ const useExternalThread = ({
998
1093
  edit: hasEdit,
999
1094
  delete: false,
1000
1095
  reload: hasReload,
1001
- refetchThread: false,
1002
- cancel: isRunning,
1096
+ refetchThread: hasRefetchThread,
1097
+ cancel: hasCancel,
1003
1098
  speech: hasSpeech,
1004
1099
  attachments: hasAttachments,
1005
1100
  feedback: hasFeedback,
@@ -1012,7 +1107,7 @@ const useExternalThread = ({
1012
1107
  },
1013
1108
  messages: messageStates,
1014
1109
  state: threadState ?? {},
1015
- suggestions: [],
1110
+ suggestions: EMPTY_SUGGESTIONS,
1016
1111
  extras,
1017
1112
  speech,
1018
1113
  voice: undefined,
@@ -1028,6 +1123,8 @@ const useExternalThread = ({
1028
1123
  hasBranches,
1029
1124
  hasEdit,
1030
1125
  hasReload,
1126
+ hasCancel,
1127
+ hasRefetchThread,
1031
1128
  hasAttachments,
1032
1129
  hasFeedback,
1033
1130
  hasSpeech,
@@ -1039,6 +1136,7 @@ const useExternalThread = ({
1039
1136
  return {
1040
1137
  getState: () => state,
1041
1138
  composer: () => composerClient.methods,
1139
+ suggestions: () => suggestionsClient.methods,
1042
1140
  append: (message) => {
1043
1141
  const appendMessage: AppendMessage =
1044
1142
  typeof message === "string"
@@ -1081,6 +1179,7 @@ const useExternalThread = ({
1081
1179
  onResume();
1082
1180
  },
1083
1181
  cancelRun: handleCancelRun,
1182
+ ...(onRefetchThread && { unstable_refetchThread: onRefetchThread }),
1084
1183
  importExternalState: (state: unknown) => {
1085
1184
  if (!onLoadExternalState)
1086
1185
  throw new Error(
@@ -1147,6 +1246,10 @@ attachTransformScopes(useExternalThread, (scopes, parent) => {
1147
1246
  scopes.dataRenderers = DataRenderers();
1148
1247
  }
1149
1248
  if (!scopes.suggestions && parent.suggestions.source === null) {
1150
- scopes.suggestions = Suggestions();
1249
+ scopes.suggestions = Derived({
1250
+ source: "thread",
1251
+ query: {},
1252
+ get: (aui) => aui.thread.suggestions(),
1253
+ });
1151
1254
  }
1152
1255
  });
@@ -1,7 +1,6 @@
1
1
  import type { AssistantClient, ScopesConfig } from "@assistant-ui/store";
2
2
  import { Derived } from "@assistant-ui/store/client";
3
3
  import { ModelContext } from "./model-context-client";
4
- import { Suggestions } from "./suggestions";
5
4
 
6
5
  export const baseRuntimeAdapterTransformScopes = (
7
6
  scopes: ScopesConfig,
@@ -27,6 +26,10 @@ export const baseRuntimeAdapterTransformScopes = (
27
26
  scopes.modelContext = ModelContext();
28
27
  }
29
28
  if (!scopes.suggestions && parent.suggestions.source === null) {
30
- scopes.suggestions = Suggestions();
29
+ scopes.suggestions = Derived({
30
+ source: "thread",
31
+ query: {},
32
+ get: (aui) => aui.thread.suggestions(),
33
+ });
31
34
  }
32
35
  };
@@ -79,7 +79,8 @@ const useSingleThreadList = ({
79
79
  },
80
80
  getLoadThreadsPromise: () => RESOLVED_PROMISE,
81
81
  reload: () => RESOLVED_PROMISE,
82
- reloadMainThread: () => RESOLVED_PROMISE,
82
+ reloadMainThread: () =>
83
+ threadClient.methods.unstable_refetchThread?.() ?? RESOLVED_PROMISE,
83
84
  loadMore: () => RESOLVED_PROMISE,
84
85
  item: (selector) => {
85
86
  if (
@@ -1,9 +1,10 @@
1
- import { useState } from "react";
1
+ import { useMemo, useState } from "react";
2
2
  import { resource, withKey } from "@assistant-ui/tap";
3
3
  import type { ClientOutput } from "@assistant-ui/store";
4
4
  import { useClientLookup } from "@assistant-ui/store/client";
5
5
  import type { SuggestionsState } from "../scopes/suggestions";
6
6
  import type { SuggestionState } from "../scopes/suggestion";
7
+ import type { ThreadSuggestion } from "../../runtime/interfaces/thread-runtime-core";
7
8
 
8
9
  export type SuggestionConfig =
9
10
  | string
@@ -19,7 +20,24 @@ const useSuggestionClient = (
19
20
 
20
21
  const SuggestionClient = resource(useSuggestionClient);
21
22
 
22
- const useSuggestionsResource = (
23
+ const useSuggestionsClient = (
24
+ state: SuggestionsState,
25
+ ): ClientOutput<"suggestions"> => {
26
+ const suggestionClients = useClientLookup(
27
+ state.suggestions.map((suggestion, index) =>
28
+ withKey(index, SuggestionClient(suggestion), [suggestion]),
29
+ ),
30
+ );
31
+
32
+ return {
33
+ getState: () => state,
34
+ suggestion: ({ index }: { index: number }) => {
35
+ return suggestionClients.get({ index });
36
+ },
37
+ };
38
+ };
39
+
40
+ const useStaticSuggestions = (
23
41
  suggestions?: SuggestionConfig[],
24
42
  ): ClientOutput<"suggestions"> => {
25
43
  const [state] = useState<SuggestionsState>(() => {
@@ -43,18 +61,26 @@ const useSuggestionsResource = (
43
61
  };
44
62
  });
45
63
 
46
- const suggestionClients = useClientLookup(
47
- state.suggestions.map((suggestion, index) =>
48
- withKey(index, SuggestionClient(suggestion), [suggestion]),
49
- ),
64
+ return useSuggestionsClient(state);
65
+ };
66
+
67
+ export const Suggestions = resource(useStaticSuggestions);
68
+
69
+ const useThreadSuggestions = (
70
+ suggestions: readonly ThreadSuggestion[],
71
+ ): ClientOutput<"suggestions"> => {
72
+ const state = useMemo<SuggestionsState>(
73
+ () => ({
74
+ suggestions: suggestions.map((s) => ({
75
+ title: s.prompt,
76
+ label: "",
77
+ prompt: s.prompt,
78
+ })),
79
+ }),
80
+ [suggestions],
50
81
  );
51
82
 
52
- return {
53
- getState: () => state,
54
- suggestion: ({ index }: { index: number }) => {
55
- return suggestionClients.get({ index });
56
- },
57
- };
83
+ return useSuggestionsClient(state);
58
84
  };
59
85
 
60
- export const Suggestions = resource(useSuggestionsResource);
86
+ export const ThreadSuggestions = resource(useThreadSuggestions);
@@ -1,12 +1,8 @@
1
- import type { Unsubscribe } from "../../types/unsubscribe";
2
- import { useEffect, useMemo } from "react";
1
+ import { useEffect, useMemo, useRef } from "react";
3
2
  import { resource } from "@assistant-ui/tap";
4
3
  import type { ClientOutput } from "@assistant-ui/store";
5
4
  import { useAssistantEmit } from "@assistant-ui/store/client";
6
- import type {
7
- ThreadListItemEventType,
8
- ThreadListItemRuntime,
9
- } from "../../runtime/api/thread-list-item-runtime";
5
+ import type { ThreadListItemRuntime } from "../../runtime/api/thread-list-item-runtime";
10
6
  import { useSubscribable } from "./useSubscribable";
11
7
 
12
8
  const useThreadListItemClient = ({
@@ -28,29 +24,18 @@ const useThreadListItemClient = ({
28
24
  }, [runtimeState, mainThreadIsRunning]);
29
25
  const emit = useAssistantEmit();
30
26
 
31
- // Bind thread list item events to event manager
27
+ // Emitted after the flush that rebinds the derived scopes; the runtime's own
28
+ // synchronous notification would be delivered against the pre-switch binding.
29
+ const { isMain, id: threadId } = runtimeState;
30
+ const selectionRef = useRef({ isMain, threadId });
32
31
  useEffect(() => {
33
- const unsubscribers: Unsubscribe[] = [];
34
-
35
- // Subscribe to thread list item events
36
- const threadListItemEvents: ThreadListItemEventType[] = [
37
- "switchedTo",
38
- "switchedAway",
39
- ];
40
-
41
- for (const event of threadListItemEvents) {
42
- const unsubscribe = runtime.unstable_on(event, () => {
43
- emit(`threadListItem.${event}`, {
44
- threadId: runtime.getState()!.id,
45
- });
46
- });
47
- unsubscribers.push(unsubscribe);
48
- }
49
-
50
- return () => {
51
- for (const unsub of unsubscribers) unsub();
52
- };
53
- }, [runtime, emit]);
32
+ const previous = selectionRef.current;
33
+ if (previous.isMain === isMain && previous.threadId === threadId) return;
34
+ selectionRef.current = { isMain, threadId };
35
+ emit(isMain ? "threadListItem.switchedTo" : "threadListItem.switchedAway", {
36
+ threadId,
37
+ });
38
+ }, [isMain, threadId, emit]);
54
39
 
55
40
  return {
56
41
  getState: () => state,
@@ -12,6 +12,7 @@ import {
12
12
  } from "@assistant-ui/store/client";
13
13
  import { ComposerClient } from "./composer-runtime-client";
14
14
  import { MessageClient } from "./message-runtime-client";
15
+ import { ThreadSuggestions } from "../clients/suggestions";
15
16
  import { useSubscribable } from "./useSubscribable";
16
17
  import type { ThreadState } from "../scopes/thread";
17
18
 
@@ -78,6 +79,9 @@ const useThreadClient = ({
78
79
  threadIdRef,
79
80
  }),
80
81
  );
82
+ const suggestions = useClientResource(
83
+ ThreadSuggestions(runtimeState.suggestions),
84
+ );
81
85
  const messages = useClientLookup(
82
86
  runtimeState.messages.map((m) =>
83
87
  withKey(m.id, MessageClientById({ runtime, id: m.id, threadIdRef }), [
@@ -109,6 +113,7 @@ const useThreadClient = ({
109
113
  return {
110
114
  getState: () => state,
111
115
  composer: () => composer.methods,
116
+ suggestions: () => suggestions.methods,
112
117
  append: runtime.append,
113
118
  deleteMessage: runtime.deleteMessage,
114
119
  startRun: runtime.startRun,
@@ -15,6 +15,12 @@ export type SuggestionsMethods = {
15
15
  suggestion(query: { index: number }): SuggestionMethods;
16
16
  };
17
17
 
18
+ export type SuggestionsMeta = {
19
+ source: "thread";
20
+ query: Record<string, never>;
21
+ };
22
+
18
23
  export type SuggestionsClientSchema = {
19
24
  methods: SuggestionsMethods;
25
+ meta: SuggestionsMeta;
20
26
  };
@@ -17,6 +17,7 @@ import type {
17
17
  import type { ModelContext } from "../../model-context/types";
18
18
  import type { MessageMethods, MessageState } from "./message";
19
19
  import type { ComposerMethods, ComposerState } from "./composer";
20
+ import type { SuggestionsMethods } from "./suggestions";
20
21
 
21
22
  export type ThreadState = {
22
23
  /**
@@ -71,6 +72,10 @@ export type ThreadMethods = {
71
72
  * The thread composer runtime.
72
73
  */
73
74
  composer(): ComposerMethods;
75
+ /**
76
+ * The suggestions shown for this thread.
77
+ */
78
+ suggestions(): SuggestionsMethods;
74
79
  /**
75
80
  * Append a new message to the thread.
76
81
  *
@@ -100,6 +105,18 @@ export type ThreadMethods = {
100
105
  */
101
106
  resumeRun(config: CreateResumeRunConfig): void;
102
107
  cancelRun(): void;
108
+ /**
109
+ * Re-fetch this thread's state from its backing store, in place: the tap
110
+ * thread's refetch hook, which `threads.reloadMainThread()` prefers and
111
+ * whose rejection it propagates. `capabilities.refetchThread` is the
112
+ * portable feature-detection signal; a legacy-bridged thread reports it
113
+ * there while routing the refetch through its runtime, not this method.
114
+ * The method-shorthand optionality is load-bearing: an explicit
115
+ * `| undefined` stops `ThreadMethods` satisfying `ClientMethods` and
116
+ * collapses the client schema, which only a workspace-level app typecheck
117
+ * surfaces.
118
+ */
119
+ unstable_refetchThread?(): Promise<void>;
103
120
  getModelContext(): ModelContext;
104
121
  export(): ExportedMessageRepository;
105
122
  import(repository: ExportedMessageRepository): void;