@aleph-alpha/chat-kit 5.4.0 → 5.6.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 (38) hide show
  1. package/dist/{CkHistory.vue_vue_type_script_setup_true_lang-D9oP6oEj.js → CkHistory.vue_vue_type_script_setup_true_lang-DHzZgEn-.js} +28 -10
  2. package/dist/adapters/responses-api.d.ts +3 -1
  3. package/dist/adapters/responses-api.d.ts.map +1 -1
  4. package/dist/adapters/responses-api.js +158 -3
  5. package/dist/components/base/CkConversationLayout/CkConversationLayout.vue.d.ts +6 -0
  6. package/dist/components/base/CkConversationLayout/CkConversationLayout.vue.d.ts.map +1 -1
  7. package/dist/components/composed/CkConversation/CkConversation.vue.d.ts +6 -0
  8. package/dist/components/composed/CkConversation/CkConversation.vue.d.ts.map +1 -1
  9. package/dist/components/index.js +1 -1
  10. package/dist/composables/index.js +1 -1
  11. package/dist/handlers/index.js +1 -1
  12. package/dist/handlers/useMessageHandler.d.ts +7 -2
  13. package/dist/handlers/useMessageHandler.d.ts.map +1 -1
  14. package/dist/helpers/conversationTree.d.ts +44 -4
  15. package/dist/helpers/conversationTree.d.ts.map +1 -1
  16. package/dist/{index-CyrVnbS9.js → index-Hcwv9Hby.js} +3 -2
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.d.ts.map +1 -1
  19. package/dist/index.js +3 -3
  20. package/dist/services/types.d.ts +7 -0
  21. package/dist/services/types.d.ts.map +1 -1
  22. package/dist/types.d.ts +38 -0
  23. package/dist/types.d.ts.map +1 -1
  24. package/dist/{useMessageHandler-N50Yj-NU.js → useMessageHandler-Tellov3w.js} +112 -30
  25. package/package.json +2 -2
  26. package/src/adapters/arena.spec.ts +86 -0
  27. package/src/adapters/responses-api.ts +276 -2
  28. package/src/components/base/CkConversationLayout/CkConversationLayout.vue +30 -4
  29. package/src/components/composed/CkConversation/CkConversation.vue +10 -0
  30. package/src/composables/useConversations.spec.ts +2 -0
  31. package/src/composables/useConversations.ts +1 -0
  32. package/src/handlers/useMessageHandler.ts +46 -6
  33. package/src/helpers/comparison.spec.ts +114 -0
  34. package/src/helpers/conversationTree.spec.ts +98 -0
  35. package/src/helpers/conversationTree.ts +211 -26
  36. package/src/index.ts +2 -0
  37. package/src/services/types.ts +7 -0
  38. package/src/types.ts +40 -0
@@ -599,6 +599,10 @@ interface TreeBuildState {
599
599
  // point for any future child response that chains onto it. Empty responses
600
600
  // fall back to their parent's attachment point so the chain stays connected.
601
601
  tailMessageByResponse: Map<string, string | null>;
602
+ // For each response, the id of its first user node (if any). Lets the arena
603
+ // collapse pass resolve a response's echoed user turn in O(1) instead of
604
+ // scanning every node; responses with no user input have no entry.
605
+ userMessageByResponse: Map<string, string>;
602
606
  rootMessageId: string | null;
603
607
  }
604
608
 
@@ -606,7 +610,7 @@ interface TreeBuildState {
606
610
  // continuations fold onto the turn they extend; everything else appends under
607
611
  // the chain parent resolved from `previous_response_id`.
608
612
  function linkResponse(state: TreeBuildState, resp: StoredResponse): void {
609
- const { treeNodes, tailMessageByResponse } = state;
613
+ const { treeNodes, tailMessageByResponse, userMessageByResponse } = state;
610
614
  const parentResponseId =
611
615
  resp.previous_response_id &&
612
616
  tailMessageByResponse.has(resp.previous_response_id)
@@ -636,6 +640,11 @@ function linkResponse(state: TreeBuildState, resp: StoredResponse): void {
636
640
  for (const node of messageNodes) {
637
641
  appendNode(treeNodes, node, chainParent);
638
642
  if (firstId === null) firstId = node.id;
643
+ // Record the first user node so the arena collapse pass can resolve this
644
+ // response's echoed user turn without scanning the node map.
645
+ if (node.role === 'user' && !userMessageByResponse.has(resp.id)) {
646
+ userMessageByResponse.set(resp.id, node.id);
647
+ }
639
648
  chainParent = node.id;
640
649
  }
641
650
 
@@ -667,6 +676,7 @@ function linkResponse(state: TreeBuildState, resp: StoredResponse): void {
667
676
  */
668
677
  export function responsesToTree(
669
678
  responses: StoredResponse[],
679
+ opts?: { arenaWinners?: Record<string, string> },
670
680
  ): ResponseConversationTree {
671
681
  if (responses.length === 0) {
672
682
  return { nodes: {}, rootId: null };
@@ -675,6 +685,7 @@ export function responsesToTree(
675
685
  const state: TreeBuildState = {
676
686
  treeNodes: {},
677
687
  tailMessageByResponse: new Map<string, string | null>(),
688
+ userMessageByResponse: new Map<string, string>(),
678
689
  rootMessageId: null,
679
690
  };
680
691
 
@@ -682,5 +693,268 @@ export function responsesToTree(
682
693
  linkResponse(state, resp);
683
694
  }
684
695
 
685
- return { nodes: state.treeNodes, rootId: state.rootMessageId };
696
+ const rootId = collapseArenaComparisons(
697
+ state.treeNodes,
698
+ responses,
699
+ opts?.arenaWinners ?? {},
700
+ state.rootMessageId,
701
+ state.userMessageByResponse,
702
+ );
703
+
704
+ return { nodes: state.treeNodes, rootId };
705
+ }
706
+
707
+ interface ParsedArena {
708
+ matchupId: string;
709
+ modelId?: string;
710
+ slot?: string;
711
+ slotSeed?: string;
712
+ variant?: string;
713
+ }
714
+
715
+ /** Parse a stored response's `metadata.arena` (JSON string or dict). */
716
+ function parseArenaMeta(resp: StoredResponse): ParsedArena | null {
717
+ const raw = resp.metadata?.arena;
718
+ let arena: unknown = raw;
719
+ if (typeof raw === 'string') {
720
+ try {
721
+ arena = JSON.parse(raw);
722
+ } catch {
723
+ return null;
724
+ }
725
+ }
726
+ if (!arena || typeof arena !== 'object') return null;
727
+ const a = arena as {
728
+ matchup_id?: unknown;
729
+ model_id?: unknown;
730
+ slot?: unknown;
731
+ slot_seed?: unknown;
732
+ variant?: unknown;
733
+ };
734
+ if (typeof a.matchup_id !== 'string') return null;
735
+ return {
736
+ matchupId: a.matchup_id,
737
+ modelId: typeof a.model_id === 'string' ? a.model_id : undefined,
738
+ slot: typeof a.slot === 'string' ? a.slot : undefined,
739
+ slotSeed: typeof a.slot_seed === 'string' ? a.slot_seed : undefined,
740
+ variant: typeof a.variant === 'string' ? a.variant : undefined,
741
+ };
742
+ }
743
+
744
+ /**
745
+ * Fold arena sibling responses (same `metadata.arena.matchup_id`) into one
746
+ * comparison node: a single user turn with the pair's assistant answers as
747
+ * children, marked `comparing`. Each stored arena response carries its own
748
+ * echoed user input, so the naive tree has one user node per response — this
749
+ * collapses those duplicates so reload matches the live shape (one prompt, N
750
+ * lanes) that {@link projectActivePath} renders.
751
+ *
752
+ * Only pairs still present as ≥2 responses are folded: once a comparison is
753
+ * voted-and-continued the backend drops the loser (Decision 7-Discard), so a
754
+ * single surviving response is left as an ordinary linear turn. `arenaWinners`
755
+ * (matchup id → winning response id) marks the resolved winner so the reloaded
756
+ * comparison renders revealed rather than blind.
757
+ *
758
+ * Returns the (possibly updated) root id. Best-effort per group.
759
+ */
760
+ function collapseArenaComparisons(
761
+ nodes: Record<string, ResponseTreeMessage>,
762
+ responses: StoredResponse[],
763
+ arenaWinners: Record<string, string>,
764
+ rootMessageId: string | null,
765
+ userMessageByResponse: Map<string, string>,
766
+ ): string | null {
767
+ const userNodeFor = (responseId: string): ResponseTreeMessage | undefined => {
768
+ const id = userMessageByResponse.get(responseId);
769
+ return id ? nodes[id] : undefined;
770
+ };
771
+
772
+ let root = rootMessageId;
773
+ for (const [matchupId, group] of groupResponsesByMatchup(responses)) {
774
+ // Fewer than two survivors is no comparison: a lone response left after a
775
+ // vote-and-continue (7-Discard) stays an ordinary linear turn.
776
+ if (group.length < 2) continue;
777
+ try {
778
+ root = foldArenaGroup(
779
+ nodes,
780
+ group,
781
+ arenaWinners[matchupId],
782
+ userNodeFor,
783
+ root,
784
+ );
785
+ } catch {
786
+ // Leave this group as-is on any structural surprise.
787
+ }
788
+ }
789
+ return root;
790
+ }
791
+
792
+ /** Bucket the responses that carry arena metadata by their `matchup_id`. */
793
+ function groupResponsesByMatchup(
794
+ responses: StoredResponse[],
795
+ ): Map<string, StoredResponse[]> {
796
+ const groups = new Map<string, StoredResponse[]>();
797
+ for (const resp of responses) {
798
+ const meta = parseArenaMeta(resp);
799
+ if (!meta) continue;
800
+ const group = groups.get(meta.matchupId) ?? [];
801
+ group.push(resp);
802
+ groups.set(meta.matchupId, group);
803
+ }
804
+ return groups;
805
+ }
806
+
807
+ /** The lane slot ('a' / 'b') stamped on an assistant node, or '' when absent. */
808
+ function laneSlot(node: ResponseTreeMessage | undefined): string {
809
+ return (node?.metadata.arena as { slot?: string } | undefined)?.slot ?? '';
810
+ }
811
+
812
+ // Order comparison lanes by slot ('a' before 'b') for stable column placement.
813
+ function compareLaneSlots(
814
+ nodes: Record<string, ResponseTreeMessage>,
815
+ x: string,
816
+ y: string,
817
+ ): number {
818
+ const sx = laneSlot(nodes[x]);
819
+ const sy = laneSlot(nodes[y]);
820
+ if (sx < sy) return -1;
821
+ if (sx > sy) return 1;
822
+ return 0;
823
+ }
824
+
825
+ // Stamp a lane's model/slot onto its assistant node so the FE can resolve a
826
+ // column label. No-op when the assistant node or arena meta is missing.
827
+ function stampLaneAssistant(
828
+ nodes: Record<string, ResponseTreeMessage>,
829
+ resp: StoredResponse,
830
+ ): void {
831
+ const meta = parseArenaMeta(resp);
832
+ const assistant = nodes[getAssistantNodeId(resp)];
833
+ if (assistant && meta) {
834
+ assistant.metadata.arena = { modelId: meta.modelId, slot: meta.slot };
835
+ }
836
+ }
837
+
838
+ // Re-parent a lane's assistant under the shared fork parent, without a duplicate
839
+ // child link.
840
+ function attachLane(
841
+ canonicalUser: ResponseTreeMessage,
842
+ assistant: ResponseTreeMessage,
843
+ ): void {
844
+ assistant.parentId = canonicalUser.id;
845
+ if (!canonicalUser.childrenIds.includes(assistant.id)) {
846
+ canonicalUser.childrenIds.push(assistant.id);
847
+ }
848
+ }
849
+
850
+ // Drop a lane's duplicate echoed-user node, splicing it out of its original
851
+ // parent and clamping that parent's active index. Returns the root id, redirected
852
+ // onto the canonical user when the removed duplicate was itself the root.
853
+ function removeDuplicateUser(
854
+ nodes: Record<string, ResponseTreeMessage>,
855
+ dupUser: ResponseTreeMessage,
856
+ sharedParentId: string | null,
857
+ canonicalUserId: string,
858
+ root: string | null,
859
+ ): string | null {
860
+ const parent = sharedParentId !== null ? nodes[sharedParentId] : undefined;
861
+ if (parent) {
862
+ parent.childrenIds = parent.childrenIds.filter((c) => c !== dupUser.id);
863
+ if (parent.activeChildIndex >= parent.childrenIds.length) {
864
+ parent.activeChildIndex = Math.max(0, parent.childrenIds.length - 1);
865
+ }
866
+ }
867
+ delete nodes[dupUser.id];
868
+ return root === dupUser.id ? canonicalUserId : root;
869
+ }
870
+
871
+ // Put the collapsed comparison node on the active path: at the root it becomes
872
+ // the new root, otherwise its parent's active child points at it. Returns the
873
+ // (possibly updated) root id.
874
+ function activateForkNode(
875
+ nodes: Record<string, ResponseTreeMessage>,
876
+ canonicalUser: ResponseTreeMessage,
877
+ sharedParentId: string | null,
878
+ root: string | null,
879
+ ): string | null {
880
+ if (sharedParentId === null) return canonicalUser.id;
881
+ const parent = nodes[sharedParentId];
882
+ if (parent) {
883
+ const idx = parent.childrenIds.indexOf(canonicalUser.id);
884
+ if (idx !== -1) parent.activeChildIndex = idx;
885
+ }
886
+ return root;
887
+ }
888
+
889
+ // Reveal the recorded winner lane: mark it and make it the active child. No-op
890
+ // when no winner is recorded or its assistant is not among the lanes.
891
+ function applyArenaWinner(
892
+ canonicalUser: ResponseTreeMessage,
893
+ group: StoredResponse[],
894
+ winnerRespId: string | undefined,
895
+ ): void {
896
+ if (!winnerRespId) return;
897
+ const winnerResp = group.find((r) => r.id === winnerRespId);
898
+ if (!winnerResp) return;
899
+ const winnerAssistantId = getAssistantNodeId(winnerResp);
900
+ const idx = canonicalUser.childrenIds.indexOf(winnerAssistantId);
901
+ if (idx === -1) return;
902
+ canonicalUser.comparisonWinnerId = winnerAssistantId;
903
+ canonicalUser.activeChildIndex = idx;
904
+ }
905
+
906
+ // Fold one matchup's sibling responses onto a single comparison node: the first
907
+ // lane's user turn becomes the shared fork parent, every lane's assistant becomes
908
+ // a child, and the duplicate echoed-user turns are dropped. The first lane's
909
+ // assistant already sits under the canonical user, so only later lanes are
910
+ // re-parented. Returns the (possibly updated) root id.
911
+ function foldArenaGroup(
912
+ nodes: Record<string, ResponseTreeMessage>,
913
+ group: StoredResponse[],
914
+ winnerRespId: string | undefined,
915
+ userNodeFor: (responseId: string) => ResponseTreeMessage | undefined,
916
+ root: string | null,
917
+ ): string | null {
918
+ const first = group[0];
919
+ if (!first) return root;
920
+ const canonicalUser = userNodeFor(first.id);
921
+ if (!canonicalUser) return root;
922
+ const sharedParentId = canonicalUser.parentId;
923
+
924
+ // Stamp matchup context on the fork parent so a reloaded comparison can be
925
+ // voted on (the host reconstructs the preference payload from here).
926
+ const forkMeta = parseArenaMeta(first);
927
+ if (forkMeta) {
928
+ canonicalUser.metadata.arena = {
929
+ matchupId: forkMeta.matchupId,
930
+ slotSeed: forkMeta.slotSeed,
931
+ variant: forkMeta.variant,
932
+ };
933
+ }
934
+
935
+ let newRoot = root;
936
+ for (const [i, resp] of group.entries()) {
937
+ stampLaneAssistant(nodes, resp);
938
+ if (i === 0) continue;
939
+ const assistant = nodes[getAssistantNodeId(resp)];
940
+ if (!assistant) continue;
941
+ attachLane(canonicalUser, assistant);
942
+ const dupUser = userNodeFor(resp.id);
943
+ if (dupUser && dupUser.id !== canonicalUser.id) {
944
+ newRoot = removeDuplicateUser(
945
+ nodes,
946
+ dupUser,
947
+ sharedParentId,
948
+ canonicalUser.id,
949
+ newRoot,
950
+ );
951
+ }
952
+ }
953
+
954
+ canonicalUser.childrenIds.sort((x, y) => compareLaneSlots(nodes, x, y));
955
+
956
+ canonicalUser.comparing = true;
957
+ newRoot = activateForkNode(nodes, canonicalUser, sharedParentId, newRoot);
958
+ applyArenaWinner(canonicalUser, group, winnerRespId);
959
+ return newRoot;
686
960
  }
@@ -7,9 +7,15 @@ const props = defineProps<{
7
7
  messages: T[];
8
8
  }>();
9
9
 
10
- defineSlots<{
10
+ const slots = defineSlots<{
11
11
  'user-message': (props: { message: T }) => void;
12
12
  'agent-message': (props: { message: T }) => void;
13
+ /**
14
+ * Extra content appended after the last message, inside the scroll flow and
15
+ * aligned like an agent turn. Use for a trailing element that continues the
16
+ * thread but isn't a tree message (e.g. an in-progress preference comparison).
17
+ */
18
+ 'thread-footer': () => void;
13
19
  }>();
14
20
 
15
21
  const scrollContainerRef = ref<HTMLElement | null>(null);
@@ -188,12 +194,23 @@ watch(
188
194
 
189
195
  // Follow agent content: handles both new agent messages and streaming updates.
190
196
  // First shrinks the spacer to match the growing content, then scrolls when it overflows.
197
+ //
198
+ // The trailing content can also be the `thread-footer` slot (e.g. streaming
199
+ // preference-comparison lanes), which continues the thread after a trunk whose
200
+ // last message is the shared *user* prompt. That case must run the same
201
+ // spacer-shrink/follow pass — otherwise the full-viewport spacer set by
202
+ // `scrollUserMessageToTop` is never reduced, leaving a viewport-sized gap that
203
+ // pushes the prompt and answers out of view. `updateSpacerHeight` already
204
+ // measures the footer (it carries `data-message-row="agent"`, so it is the
205
+ // `lastRow`); it just needs to be invoked here. The watch fires on every tree
206
+ // mutation because the messages array is recreated per projection.
191
207
  watch(
192
208
  () => props.messages,
193
209
  () => {
194
210
  if (props.messages.length === 0) return;
195
211
  const lastMessage = props.messages[props.messages.length - 1];
196
- if (lastMessage && lastMessage.role !== 'user') {
212
+ const hasFooter = Boolean(slots['thread-footer']);
213
+ if ((lastMessage && lastMessage.role !== 'user') || hasFooter) {
197
214
  nextTick(() => {
198
215
  updateSpacerHeight();
199
216
  if (isAutoScrollEnabled.value) {
@@ -212,7 +229,7 @@ watch(
212
229
  <!-- Top fade: hints that there is more content above the viewport -->
213
230
  <div
214
231
  aria-hidden="true"
215
- class="pointer-events-none absolute inset-x-0 top-0 z-10 h-8 bg-gradient-to-b from-background-surface-primary to-transparent transition-opacity duration-200"
232
+ class="pointer-events-none absolute inset-x-0 top-0 z-10 h-8 bg-gradient-to-b from-background-surface-secondary to-transparent transition-opacity duration-200"
216
233
  :class="showTopFade ? 'opacity-100' : 'opacity-0'"
217
234
  />
218
235
  <div
@@ -233,6 +250,15 @@ watch(
233
250
  <slot name="agent-message" :message="message" />
234
251
  </div>
235
252
  </div>
253
+ <!-- Trailing non-message content (e.g. an open comparison), rendered as
254
+ the continuation of the thread inside the scroll flow. -->
255
+ <div
256
+ v-if="$slots['thread-footer']"
257
+ data-message-row="agent"
258
+ class="flex min-w-0 justify-start"
259
+ >
260
+ <slot name="thread-footer" />
261
+ </div>
236
262
  <!-- Bottom spacer: shrinks as agent content grows to keep viewport stable -->
237
263
  <div
238
264
  v-if="hasBottomSpacer"
@@ -243,7 +269,7 @@ watch(
243
269
  <!-- Bottom fade: hints that there is more content below the viewport -->
244
270
  <div
245
271
  aria-hidden="true"
246
- class="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-8 bg-gradient-to-t from-background-surface-primary to-transparent transition-opacity duration-200"
272
+ class="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-8 bg-gradient-to-t from-background-surface-secondary to-transparent transition-opacity duration-200"
247
273
  :class="showBottomFade ? 'opacity-100' : 'opacity-0'"
248
274
  />
249
275
  </div>
@@ -58,6 +58,12 @@ const slots = defineSlots<{
58
58
  code: string;
59
59
  language: string;
60
60
  }) => void;
61
+ /**
62
+ * Trailing content appended after the last message, inside the scroll flow
63
+ * and aligned like an agent turn — e.g. an in-progress preference comparison
64
+ * that continues the thread but isn't a tree message.
65
+ */
66
+ 'thread-footer': () => void;
61
67
  }>();
62
68
 
63
69
  const labels = useChatKitLabels();
@@ -229,5 +235,9 @@ function hasHeaderActivity(message: BaseMessage): boolean {
229
235
  </CkAgentMessage>
230
236
  </slot>
231
237
  </template>
238
+
239
+ <template v-if="$slots['thread-footer']" #thread-footer>
240
+ <slot name="thread-footer" />
241
+ </template>
232
242
  </CkConversationLayout>
233
243
  </template>
@@ -80,6 +80,7 @@ describe('useConversations', () => {
80
80
  title: 'hey again',
81
81
  createdAt: new Date(1_777_456_746 * 1000),
82
82
  updatedAt: new Date(1_777_456_750 * 1000),
83
+ metadata: { title: 'hey again' },
83
84
  });
84
85
  });
85
86
 
@@ -290,6 +291,7 @@ describe('useConversations', () => {
290
291
  title: 'far away',
291
292
  createdAt: new Date(1_777_456_700 * 1000),
292
293
  updatedAt: new Date(1_777_456_701 * 1000),
294
+ metadata: { title: 'far away' },
293
295
  });
294
296
  });
295
297
 
@@ -332,6 +332,7 @@ function mapConversation(raw: RawConversation): ConversationSummary {
332
332
  title: raw.metadata?.title ?? '',
333
333
  createdAt: secondsToDate(raw.created_at),
334
334
  updatedAt: secondsToDate(raw.updated_at),
335
+ metadata: raw.metadata ?? null,
335
336
  };
336
337
  }
337
338
 
@@ -4,12 +4,15 @@ import {
4
4
  createEmptyTree,
5
5
  cutBranchAfter as treeCutBranchAfter,
6
6
  cutBranchAt as treeCutBranchAt,
7
- getLinearPath,
7
+ markComparison as treeMarkComparison,
8
8
  navigate as treeNavigate,
9
+ projectActivePath,
9
10
  updateNode,
10
11
  updateNodeId,
12
+ voteComparison as treeVoteComparison,
11
13
  } from '../helpers';
12
14
  import type {
15
+ ActiveComparison,
13
16
  BaseMessage,
14
17
  ConversationTree,
15
18
  MessageContent,
@@ -24,10 +27,20 @@ export const useMessageHandler = () => {
24
27
  const tree = reactive<ConversationTree>(createEmptyTree());
25
28
  const currentUserMessage = ref('');
26
29
 
27
- const messages = computed<BaseMessage[]>(() => getLinearPath(tree));
30
+ // One walk produces the linear trunk and (at most) one active comparison, so
31
+ // the two projections can never drift.
32
+ const projection = computed(() => projectActivePath(tree));
33
+ const messages = computed<BaseMessage[]>(() => projection.value.trunk);
34
+ const activeComparison = computed<ActiveComparison | null>(
35
+ () => projection.value.activeComparison,
36
+ );
28
37
 
29
38
  const currentLeafId = computed<string | null>(() => {
30
- const list = messages.value;
39
+ // No single leaf while a comparison is open at the tail — sending is gated
40
+ // on `activeComparison` and "continue with winner" passes an explicit
41
+ // parentId (the winner branch).
42
+ if (projection.value.activeComparison) return null;
43
+ const list = projection.value.trunk;
31
44
  return list.length > 0 ? list[list.length - 1].id : null;
32
45
  });
33
46
 
@@ -114,10 +127,18 @@ export const useMessageHandler = () => {
114
127
  status: MessageStatus;
115
128
  metadata?: MessageMetadata;
116
129
  parentId?: string;
130
+ comparing?: boolean;
117
131
  }) {
118
132
  const existing = tree.nodes[message.id];
119
133
 
120
134
  if (existing) {
135
+ // `parentId` is ignored for an already-present node (it is never
136
+ // re-linked), so resolve it only to mark a comparison — and use the node's
137
+ // *actual* parent, so a stale/unknown `parentId` on the patch is a safe
138
+ // no-op rather than an `Unknown parent message id` throw.
139
+ if (message.comparing && existing.parentId) {
140
+ treeMarkComparison(tree, existing.parentId);
141
+ }
121
142
  updateNode(tree, message.id, {
122
143
  content: message.content,
123
144
  role: message.role,
@@ -127,9 +148,13 @@ export const useMessageHandler = () => {
127
148
  : {}),
128
149
  });
129
150
  } else {
151
+ const parentId = resolveParentId(message.parentId);
152
+ if (message.comparing && parentId) {
153
+ treeMarkComparison(tree, parentId);
154
+ }
130
155
  const node: TreeMessage = {
131
156
  id: message.id,
132
- parentId: resolveParentId(message.parentId),
157
+ parentId,
133
158
  childrenIds: [],
134
159
  activeChildIndex: 0,
135
160
  role: message.role,
@@ -187,9 +212,13 @@ export const useMessageHandler = () => {
187
212
  /**
188
213
  * Drop every message that comes after `messageId` on every branch beneath
189
214
  * it. The target message is kept and becomes the new leaf of its branch.
215
+ *
216
+ * Pass `keepChildId` (a direct child of `messageId`) to spare one branch: it
217
+ * survives as the target's sole, active child while its siblings are dropped
218
+ * — e.g. keeping a chosen comparison winner and discarding the rest.
190
219
  */
191
- function cutBranchAfter(messageId: string) {
192
- treeCutBranchAfter(tree, messageId);
220
+ function cutBranchAfter(messageId: string, keepChildId?: string) {
221
+ treeCutBranchAfter(tree, messageId, keepChildId);
193
222
  }
194
223
 
195
224
  /**
@@ -205,8 +234,18 @@ export const useMessageHandler = () => {
205
234
  return readonly(tree);
206
235
  }
207
236
 
237
+ /**
238
+ * Record the winner of the comparison rooted at `parentId` and reveal it.
239
+ * Does not collapse the comparison — it stays a labelled, revealed view
240
+ * until the user continues past the winner (appends a turn under it).
241
+ */
242
+ function voteComparison(parentId: string, winnerBranchId: string) {
243
+ treeVoteComparison(tree, parentId, winnerBranchId);
244
+ }
245
+
208
246
  return {
209
247
  messages: messages as Ref<BaseMessage[]>,
248
+ activeComparison,
210
249
  currentUserMessage: readonly(currentUserMessage),
211
250
  currentLeafId,
212
251
  updateMessage,
@@ -220,6 +259,7 @@ export const useMessageHandler = () => {
220
259
  navigate,
221
260
  cutBranchAfter,
222
261
  cutBranchAt,
262
+ voteComparison,
223
263
  getTree,
224
264
  };
225
265
  };
@@ -0,0 +1,114 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import {
4
+ addNode,
5
+ createEmptyTree,
6
+ markComparison,
7
+ projectActivePath,
8
+ voteComparison,
9
+ } from './conversationTree';
10
+ import type { ConversationTree, MessageContent, TreeMessage } from '../types';
11
+
12
+ function node(
13
+ id: string,
14
+ parentId: string | null,
15
+ role: TreeMessage['role'],
16
+ text = id,
17
+ ): TreeMessage {
18
+ const content: MessageContent[] = [
19
+ { type: 'text', id: `${id}-t`, text, status: 'completed' },
20
+ ];
21
+ return {
22
+ id,
23
+ parentId,
24
+ childrenIds: [],
25
+ activeChildIndex: 0,
26
+ role,
27
+ content,
28
+ status: 'completed',
29
+ createdAt: new Date(0),
30
+ };
31
+ }
32
+
33
+ /** user U with two assistant children A/B, marked as a comparison. */
34
+ function comparisonTree(): ConversationTree {
35
+ const tree = createEmptyTree();
36
+ addNode(tree, node('U', null, 'user', 'prompt'));
37
+ addNode(tree, node('A', 'U', 'assistant', 'answer A'), { activate: false });
38
+ addNode(tree, node('B', 'U', 'assistant', 'answer B'), { activate: false });
39
+ markComparison(tree, 'U');
40
+ return tree;
41
+ }
42
+
43
+ describe('projectActivePath', () => {
44
+ it('surfaces an unresolved comparison as blind lanes at the trunk tail', () => {
45
+ const { trunk, activeComparison } = projectActivePath(comparisonTree());
46
+
47
+ expect(trunk.map((m) => m.id)).toEqual(['U']);
48
+ expect(activeComparison).not.toBeNull();
49
+ expect(activeComparison!.resolved).toBe(false);
50
+ expect(activeComparison!.winnerBranchId).toBeNull();
51
+ expect(activeComparison!.lanes.map((l) => l.branchId)).toEqual(['A', 'B']);
52
+ expect(activeComparison!.lanes[0]!.path.map((m) => m.id)).toEqual(['A']);
53
+ });
54
+
55
+ it('keeps the comparison active + revealed after a vote (not yet continued)', () => {
56
+ const tree = comparisonTree();
57
+ voteComparison(tree, 'U', 'A');
58
+
59
+ const { trunk, activeComparison } = projectActivePath(tree);
60
+ expect(trunk.map((m) => m.id)).toEqual(['U']);
61
+ expect(activeComparison).not.toBeNull();
62
+ expect(activeComparison!.resolved).toBe(true);
63
+ expect(activeComparison!.winnerBranchId).toBe('A');
64
+ });
65
+
66
+ it('collapses to a linear winner chain once the winner is continued', () => {
67
+ const tree = comparisonTree();
68
+ voteComparison(tree, 'U', 'A');
69
+ // Continue past the winner: a follow-up turn under A.
70
+ addNode(tree, node('U2', 'A', 'user', 'follow up'));
71
+
72
+ const { trunk, activeComparison } = projectActivePath(tree);
73
+ expect(activeComparison).toBeNull();
74
+ // History: the fork is transparent, trunk flows through the winner.
75
+ expect(trunk.map((m) => m.id)).toEqual(['U', 'A', 'U2']);
76
+ });
77
+
78
+ it('does not treat ordinary regenerate siblings as a comparison', () => {
79
+ const tree = createEmptyTree();
80
+ addNode(tree, node('U', null, 'user'));
81
+ addNode(tree, node('A1', 'U', 'assistant'));
82
+ addNode(tree, node('A2', 'U', 'assistant')); // regenerate: latest active
83
+ const { trunk, activeComparison } = projectActivePath(tree);
84
+ expect(activeComparison).toBeNull();
85
+ expect(trunk.map((m) => m.id)).toEqual(['U', 'A2']);
86
+ });
87
+ });
88
+
89
+ describe('addNode activate flag', () => {
90
+ it('does not move activeChildIndex when activate:false', () => {
91
+ const tree = createEmptyTree();
92
+ addNode(tree, node('U', null, 'user'));
93
+ addNode(tree, node('A', 'U', 'assistant'), { activate: false });
94
+ addNode(tree, node('B', 'U', 'assistant'), { activate: false });
95
+ expect(tree.nodes['U']!.activeChildIndex).toBe(0);
96
+ expect(tree.nodes['U']!.childrenIds).toEqual(['A', 'B']);
97
+ });
98
+ });
99
+
100
+ describe('voteComparison', () => {
101
+ it('records the winner and points activeChildIndex at it', () => {
102
+ const tree = comparisonTree();
103
+ voteComparison(tree, 'U', 'B');
104
+ expect(tree.nodes['U']!.comparisonWinnerId).toBe('B');
105
+ expect(tree.nodes['U']!.activeChildIndex).toBe(1);
106
+ expect(tree.nodes['U']!.comparing).toBe(true); // stays a comparison
107
+ });
108
+
109
+ it('is a no-op for an unknown winner id', () => {
110
+ const tree = comparisonTree();
111
+ voteComparison(tree, 'U', 'nope');
112
+ expect(tree.nodes['U']!.comparisonWinnerId).toBeUndefined();
113
+ });
114
+ });