@aleph-alpha/chat-kit 5.5.0 → 5.7.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 (41) hide show
  1. package/dist/{CkHistory.vue_vue_type_script_setup_true_lang-C1busuyf.js → CkHistory.vue_vue_type_script_setup_true_lang-YHwE2DYx.js} +27 -9
  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/chat-kit.css +1 -0
  6. package/dist/components/base/CkConversationLayout/CkConversationLayout.vue.d.ts +6 -0
  7. package/dist/components/base/CkConversationLayout/CkConversationLayout.vue.d.ts.map +1 -1
  8. package/dist/components/composed/CkConversation/CkConversation.vue.d.ts +6 -0
  9. package/dist/components/composed/CkConversation/CkConversation.vue.d.ts.map +1 -1
  10. package/dist/components/index.js +1 -1
  11. package/dist/composables/index.js +4 -4
  12. package/dist/handlers/index.js +4 -4
  13. package/dist/handlers/useMessageHandler.d.ts +7 -2
  14. package/dist/handlers/useMessageHandler.d.ts.map +1 -1
  15. package/dist/helpers/conversationTree.d.ts +44 -4
  16. package/dist/helpers/conversationTree.d.ts.map +1 -1
  17. package/dist/{index-CyrVnbS9.js → index-Dn4ctxk8.js} +7 -6
  18. package/dist/index.d.ts +2 -1
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +11 -11
  21. package/dist/services/types.d.ts +7 -0
  22. package/dist/services/types.d.ts.map +1 -1
  23. package/dist/types.d.ts +38 -0
  24. package/dist/types.d.ts.map +1 -1
  25. package/dist/{useChatKitLabels-DLy0BFx8.js → useChatKitLabels-CMOr3Kjq.js} +2 -2
  26. package/dist/{useMessageHandler-N50Yj-NU.js → useMessageHandler-Tellov3w.js} +112 -30
  27. package/package.json +4 -2
  28. package/src/adapters/arena.spec.ts +86 -0
  29. package/src/adapters/responses-api.ts +276 -2
  30. package/src/components/base/CkConversationLayout/CkConversationLayout.vue +28 -2
  31. package/src/components/composed/CkConversation/CkConversation.vue +10 -0
  32. package/src/composables/useConversations.spec.ts +2 -0
  33. package/src/composables/useConversations.ts +1 -0
  34. package/src/handlers/useMessageHandler.ts +46 -6
  35. package/src/helpers/comparison.spec.ts +114 -0
  36. package/src/helpers/conversationTree.spec.ts +98 -0
  37. package/src/helpers/conversationTree.ts +211 -26
  38. package/src/index.ts +10 -0
  39. package/src/services/types.ts +7 -0
  40. package/src/types.ts +40 -0
  41. package/src/uno-virtual.d.ts +2 -0
@@ -3,47 +3,98 @@ import { reactive, ref, computed, readonly } from "vue";
3
3
  function createEmptyTree() {
4
4
  return { nodes: {}, rootId: null };
5
5
  }
6
- function getLinearPath(tree) {
7
- const { nodes, rootId } = tree;
8
- if (!rootId || !nodes[rootId]) return [];
6
+ function toBaseMessage(nodes, node) {
7
+ const base = {
8
+ id: node.id,
9
+ role: node.role,
10
+ content: node.content,
11
+ createdAt: node.createdAt,
12
+ status: node.status,
13
+ ...node.metadata !== void 0 ? { metadata: node.metadata } : {}
14
+ };
15
+ if (node.parentId !== null) {
16
+ const parent = nodes[node.parentId];
17
+ if (parent && parent.childrenIds.length > 1) {
18
+ base.siblingIds = [...parent.childrenIds];
19
+ base.siblingIndex = parent.childrenIds.indexOf(node.id);
20
+ }
21
+ }
22
+ return base;
23
+ }
24
+ function isActiveComparison(nodes, node) {
25
+ if (!node.comparing || node.childrenIds.length < 2) return false;
26
+ const winnerId = node.comparisonWinnerId;
27
+ if (winnerId == null) return true;
28
+ const winner = nodes[winnerId];
29
+ return !winner || winner.childrenIds.length === 0;
30
+ }
31
+ function laneWalk(nodes, headId) {
9
32
  const path = [];
33
+ let current = headId;
34
+ while (current !== null) {
35
+ const node = nodes[current];
36
+ if (!node) break;
37
+ path.push(toBaseMessage(nodes, node));
38
+ if (node.childrenIds.length === 0) break;
39
+ current = node.childrenIds[node.activeChildIndex];
40
+ }
41
+ return path;
42
+ }
43
+ function projectActivePath(tree) {
44
+ const { nodes, rootId } = tree;
45
+ const trunk = [];
46
+ if (!rootId || !nodes[rootId]) return { trunk, activeComparison: null };
10
47
  let current = rootId;
11
48
  while (current !== null) {
12
49
  const node = nodes[current];
13
50
  if (!node) break;
14
- const base = {
15
- id: node.id,
16
- role: node.role,
17
- content: node.content,
18
- createdAt: node.createdAt,
19
- status: node.status,
20
- ...node.metadata !== void 0 ? { metadata: node.metadata } : {}
21
- };
22
- if (node.parentId !== null) {
23
- const parent = nodes[node.parentId];
24
- if (parent && parent.childrenIds.length > 1) {
25
- base.siblingIds = [...parent.childrenIds];
26
- base.siblingIndex = parent.childrenIds.indexOf(node.id);
27
- }
51
+ trunk.push(toBaseMessage(nodes, node));
52
+ if (isActiveComparison(nodes, node)) {
53
+ const lanes = node.childrenIds.map((branchId) => ({
54
+ branchId,
55
+ path: laneWalk(nodes, branchId)
56
+ }));
57
+ const winnerBranchId = node.comparisonWinnerId ?? null;
58
+ return {
59
+ trunk,
60
+ activeComparison: {
61
+ forkParentId: node.id,
62
+ resolved: winnerBranchId !== null,
63
+ winnerBranchId,
64
+ lanes
65
+ }
66
+ };
28
67
  }
29
- path.push(base);
30
68
  if (node.childrenIds.length === 0) break;
31
69
  current = node.childrenIds[node.activeChildIndex];
32
70
  }
33
- return path;
71
+ return { trunk, activeComparison: null };
34
72
  }
35
- function addNode(tree, node) {
73
+ function addNode(tree, node, opts = { activate: true }) {
36
74
  tree.nodes[node.id] = node;
37
75
  if (node.parentId !== null) {
38
76
  const parent = tree.nodes[node.parentId];
39
77
  if (parent && !parent.childrenIds.includes(node.id)) {
40
78
  parent.childrenIds.push(node.id);
41
- parent.activeChildIndex = parent.childrenIds.length - 1;
79
+ if (opts.activate)
80
+ parent.activeChildIndex = parent.childrenIds.length - 1;
42
81
  }
43
82
  } else if (tree.rootId === null) {
44
83
  tree.rootId = node.id;
45
84
  }
46
85
  }
86
+ function markComparison(tree, parentId) {
87
+ const parent = tree.nodes[parentId];
88
+ if (parent) parent.comparing = true;
89
+ }
90
+ function voteComparison(tree, parentId, winnerBranchId) {
91
+ const parent = tree.nodes[parentId];
92
+ if (!parent) return;
93
+ const idx = parent.childrenIds.indexOf(winnerBranchId);
94
+ if (idx === -1) return;
95
+ parent.comparisonWinnerId = winnerBranchId;
96
+ parent.activeChildIndex = idx;
97
+ }
47
98
  function updateNode(tree, id, updates) {
48
99
  const node = tree.nodes[id];
49
100
  if (!node) return;
@@ -81,12 +132,18 @@ function deleteSubtrees(tree, ids) {
81
132
  delete tree.nodes[currentId];
82
133
  }
83
134
  }
84
- function cutBranchAfter(tree, id) {
135
+ function cutBranchAfter(tree, id, keepChildId) {
85
136
  const node = tree.nodes[id];
86
- if (!node) return;
87
- deleteSubtrees(tree, node.childrenIds);
88
- node.childrenIds = [];
137
+ if (!node || !Object.prototype.hasOwnProperty.call(tree.nodes, id)) return;
138
+ const kept = keepChildId !== void 0 && node.childrenIds.includes(keepChildId) ? keepChildId : null;
139
+ deleteSubtrees(
140
+ tree,
141
+ kept === null ? node.childrenIds : node.childrenIds.filter((childId) => childId !== kept)
142
+ );
143
+ node.childrenIds = kept === null ? [] : [kept];
89
144
  node.activeChildIndex = 0;
145
+ delete node.comparing;
146
+ delete node.comparisonWinnerId;
90
147
  }
91
148
  function cutBranchAt(tree, id) {
92
149
  const node = tree.nodes[id];
@@ -106,6 +163,14 @@ function cutBranchAt(tree, id) {
106
163
  if (parent.activeChildIndex >= parent.childrenIds.length) {
107
164
  parent.activeChildIndex = Math.max(0, parent.childrenIds.length - 1);
108
165
  }
166
+ if (parent.comparing) {
167
+ if (parent.childrenIds.length < 2) {
168
+ delete parent.comparing;
169
+ delete parent.comparisonWinnerId;
170
+ } else if (parent.comparisonWinnerId === id) {
171
+ delete parent.comparisonWinnerId;
172
+ }
173
+ }
109
174
  }
110
175
  function navigate(tree, targetId) {
111
176
  const node = tree.nodes[targetId];
@@ -119,9 +184,14 @@ function navigate(tree, targetId) {
119
184
  const useMessageHandler = () => {
120
185
  const tree = reactive(createEmptyTree());
121
186
  const currentUserMessage = ref("");
122
- const messages = computed(() => getLinearPath(tree));
187
+ const projection = computed(() => projectActivePath(tree));
188
+ const messages = computed(() => projection.value.trunk);
189
+ const activeComparison = computed(
190
+ () => projection.value.activeComparison
191
+ );
123
192
  const currentLeafId = computed(() => {
124
- const list = messages.value;
193
+ if (projection.value.activeComparison) return null;
194
+ const list = projection.value.trunk;
125
195
  return list.length > 0 ? list[list.length - 1].id : null;
126
196
  });
127
197
  function resolveParentId(parentId) {
@@ -171,6 +241,9 @@ const useMessageHandler = () => {
171
241
  function updateMessage(message) {
172
242
  const existing = tree.nodes[message.id];
173
243
  if (existing) {
244
+ if (message.comparing && existing.parentId) {
245
+ markComparison(tree, existing.parentId);
246
+ }
174
247
  updateNode(tree, message.id, {
175
248
  content: message.content,
176
249
  role: message.role,
@@ -178,9 +251,13 @@ const useMessageHandler = () => {
178
251
  ...message.metadata !== void 0 ? { metadata: message.metadata } : {}
179
252
  });
180
253
  } else {
254
+ const parentId = resolveParentId(message.parentId);
255
+ if (message.comparing && parentId) {
256
+ markComparison(tree, parentId);
257
+ }
181
258
  const node = {
182
259
  id: message.id,
183
- parentId: resolveParentId(message.parentId),
260
+ parentId,
184
261
  childrenIds: [],
185
262
  activeChildIndex: 0,
186
263
  role: message.role,
@@ -218,8 +295,8 @@ const useMessageHandler = () => {
218
295
  function navigate$1(targetId) {
219
296
  navigate(tree, targetId);
220
297
  }
221
- function cutBranchAfter$1(messageId) {
222
- cutBranchAfter(tree, messageId);
298
+ function cutBranchAfter$1(messageId, keepChildId) {
299
+ cutBranchAfter(tree, messageId, keepChildId);
223
300
  }
224
301
  function cutBranchAt$1(messageId) {
225
302
  cutBranchAt(tree, messageId);
@@ -227,8 +304,12 @@ const useMessageHandler = () => {
227
304
  function getTree() {
228
305
  return readonly(tree);
229
306
  }
307
+ function voteComparison$1(parentId, winnerBranchId) {
308
+ voteComparison(tree, parentId, winnerBranchId);
309
+ }
230
310
  return {
231
311
  messages,
312
+ activeComparison,
232
313
  currentUserMessage: readonly(currentUserMessage),
233
314
  currentLeafId,
234
315
  updateMessage,
@@ -242,6 +323,7 @@ const useMessageHandler = () => {
242
323
  navigate: navigate$1,
243
324
  cutBranchAfter: cutBranchAfter$1,
244
325
  cutBranchAt: cutBranchAt$1,
326
+ voteComparison: voteComparison$1,
245
327
  getTree
246
328
  };
247
329
  };
package/package.json CHANGED
@@ -1,16 +1,18 @@
1
1
  {
2
2
  "name": "@aleph-alpha/chat-kit",
3
- "version": "5.5.0",
3
+ "version": "5.7.0",
4
4
  "description": "Chat kit package for building chat interfaces with Vue",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
7
7
  "module": "./dist/index.js",
8
8
  "types": "./dist/index.d.ts",
9
+ "style": "./dist/chat-kit.css",
9
10
  "exports": {
10
11
  ".": {
11
12
  "import": "./dist/index.js",
12
13
  "types": "./dist/index.d.ts"
13
14
  },
15
+ "./style.css": "./dist/chat-kit.css",
14
16
  "./services": {
15
17
  "import": "./dist/services/index.js",
16
18
  "types": "./dist/services/index.d.ts"
@@ -107,7 +109,7 @@
107
109
  "wait-on": "9.0.3",
108
110
  "@aleph-alpha/eslint-config-frontend": "0.7.0",
109
111
  "@aleph-alpha/prettier-config-frontend": "2.1.0",
110
- "@aleph-alpha/tsconfig-frontend": "0.7.0"
112
+ "@aleph-alpha/tsconfig-frontend": "0.7.1"
111
113
  },
112
114
  "scripts": {
113
115
  "build": "vite build && vue-tsc --project tsconfig.build.json --declaration --emitDeclarationOnly --outDir dist",
@@ -0,0 +1,86 @@
1
+ import { describe, expect, it } from 'vitest';
2
+
3
+ import { responsesToTree, type StoredResponse } from './responses-api';
4
+
5
+ function arenaResponse(
6
+ id: string,
7
+ matchupId: string,
8
+ slot: 'a' | 'b',
9
+ modelId: string,
10
+ answer: string,
11
+ ): StoredResponse {
12
+ return {
13
+ id,
14
+ object: 'response',
15
+ created_at: 1,
16
+ completed_at: 2,
17
+ status: 'completed',
18
+ model: modelId,
19
+ previous_response_id: null,
20
+ request_input: [{ role: 'user', content: 'prompt' }],
21
+ output: [
22
+ {
23
+ type: 'message',
24
+ id: `msg-${id}`,
25
+ status: 'completed',
26
+ role: 'assistant',
27
+ content: [{ type: 'output_text', text: answer }],
28
+ },
29
+ ],
30
+ metadata: {
31
+ arena: JSON.stringify({
32
+ matchup_id: matchupId,
33
+ slot,
34
+ model_id: modelId,
35
+ variant: 'king-of-the-hill',
36
+ }),
37
+ },
38
+ } as StoredResponse;
39
+ }
40
+
41
+ describe('responsesToTree arena collapse', () => {
42
+ it('folds an unvoted pair into one comparison node with two lanes', () => {
43
+ const tree = responsesToTree([
44
+ arenaResponse('respA', 'm1', 'a', 'phoenix-latest', 'A'),
45
+ arenaResponse('respB', 'm1', 'b', 'kimi-k2.6', 'B'),
46
+ ]);
47
+
48
+ // Single root user node, marked comparing, with two assistant children.
49
+ const root = tree.nodes[tree.rootId!]!;
50
+ expect(root.role).toBe('user');
51
+ expect(root.comparing).toBe(true);
52
+ expect(root.comparisonWinnerId).toBeUndefined();
53
+ expect(root.childrenIds).toHaveLength(2);
54
+
55
+ const children = root.childrenIds.map((id) => tree.nodes[id]!);
56
+ expect(children.every((c) => c.role === 'assistant')).toBe(true);
57
+ // Model id stamped for lane labels; ordered slot a then b.
58
+ const slots = children.map(
59
+ (c) => (c.metadata.arena as { slot?: string }).slot,
60
+ );
61
+ expect(slots).toEqual(['a', 'b']);
62
+ });
63
+
64
+ it('marks the recorded winner when arenaWinners is supplied', () => {
65
+ const tree = responsesToTree(
66
+ [
67
+ arenaResponse('respA', 'm1', 'a', 'phoenix-latest', 'A'),
68
+ arenaResponse('respB', 'm1', 'b', 'kimi-k2.6', 'B'),
69
+ ],
70
+ { arenaWinners: { m1: 'respB' } },
71
+ );
72
+ const root = tree.nodes[tree.rootId!]!;
73
+ // Winner is respB's assistant node (its wire message id).
74
+ expect(root.comparisonWinnerId).toBe('msg-respB');
75
+ expect(root.childrenIds[root.activeChildIndex]).toBe('msg-respB');
76
+ });
77
+
78
+ it('leaves a single surviving response as an ordinary linear turn', () => {
79
+ // 7-Discard: after continue, the BE dropped the loser — one response left.
80
+ const tree = responsesToTree([
81
+ arenaResponse('respA', 'm1', 'a', 'phoenix-latest', 'A'),
82
+ ]);
83
+ const root = tree.nodes[tree.rootId!]!;
84
+ expect(root.comparing).toBeUndefined();
85
+ });
86
+ });
@@ -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
  }