@agent-native/toolkit 0.10.9 → 0.10.11

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.
@@ -948,6 +948,50 @@ function latestModelsOnly(models: string[]): string[] {
948
948
  });
949
949
  }
950
950
 
951
+ /**
952
+ * Coarse relative cost per model, rendered as a quiet `$`…`$$$` suffix.
953
+ *
954
+ * Tokens and their order mirror `MODEL_COST_ORDER` in `@agent-native/core`'s
955
+ * chat-model-groups, which sorts these same rows — the toolkit cannot import
956
+ * from core, so a new model family has to be added in both places. Tiers are
957
+ * each provider's own entry/mid/flagship ladder, not a cross-provider price
958
+ * claim; anything unlisted has no tier rather than a guessed one.
959
+ */
960
+ const MODEL_COST_TIERS: ReadonlyArray<readonly [string, 1 | 2 | 3]> = [
961
+ ["luna", 1],
962
+ ["terra", 2],
963
+ ["sol", 3],
964
+ ["haiku", 1],
965
+ ["sonnet", 2],
966
+ ["opus", 3],
967
+ ["fable", 3],
968
+ ["flash", 1],
969
+ ["pro", 3],
970
+ ];
971
+
972
+ const COST_TIER_LABELS = { 1: "Lower", 2: "Medium", 3: "Higher" } as const;
973
+
974
+ export function composerModelCostTier(model: string): 1 | 2 | 3 | undefined {
975
+ const normalized = model.toLowerCase();
976
+ return MODEL_COST_TIERS.find(([token]) => normalized.includes(token))?.[1];
977
+ }
978
+
979
+ function ModelCostTier({ model }: { model: string }) {
980
+ const tier = composerModelCostTier(model);
981
+ if (!tier) return null;
982
+ return (
983
+ <>
984
+ <span
985
+ aria-hidden="true"
986
+ className="shrink-0 text-[11px] tabular-nums text-muted-foreground/60"
987
+ >
988
+ {"$".repeat(tier)}
989
+ </span>
990
+ <span className="sr-only">{COST_TIER_LABELS[tier]} cost</span>
991
+ </>
992
+ );
993
+ }
994
+
951
995
  /**
952
996
  * Optional secondary model menu for apps that drive a separate generation model
953
997
  * alongside the chat LLM (e.g. the Assets app's image-generation model). When
@@ -1357,6 +1401,7 @@ function ModelSelector({
1357
1401
  <span className="flex-1 min-w-0 text-[13px] text-foreground truncate">
1358
1402
  {friendlyModelName(m)}
1359
1403
  </span>
1404
+ <ModelCostTier model={m} />
1360
1405
  {m === model && group.configured && (
1361
1406
  <IconCheck className="h-3.5 w-3.5 shrink-0 text-blue-500" />
1362
1407
  )}
@@ -4,7 +4,24 @@ import { Editor } from "@tiptap/core";
4
4
  import StarterKit from "@tiptap/starter-kit";
5
5
  import { afterEach, describe, expect, it, vi } from "vitest";
6
6
 
7
- import { DragHandle, type DragHandleOptions } from "./DragHandle.js";
7
+ import {
8
+ DragHandle,
9
+ dragPreviewTransform,
10
+ type DragHandleOptions,
11
+ } from "./DragHandle.js";
12
+
13
+ describe("dragPreviewTransform", () => {
14
+ it("keeps the grabbed point under the pointer", () => {
15
+ expect(
16
+ dragPreviewTransform({
17
+ clientX: 180,
18
+ clientY: 240,
19
+ pointerOffsetX: 32,
20
+ pointerOffsetY: 9,
21
+ }),
22
+ ).toBe("translate3d(148px, 231px, 0)");
23
+ });
24
+ });
8
25
 
9
26
  function makeRect({
10
27
  left,
@@ -146,6 +163,21 @@ function childText(editor: Editor, index: number): string {
146
163
  return editor.state.doc.child(index).textContent;
147
164
  }
148
165
 
166
+ function focusEditorWithNativeSelection(editor: Editor): void {
167
+ editor.view.dom.focus();
168
+ editor.commands.setTextSelection({ from: 1, to: 3 });
169
+ const textNode = editor.view.dom.querySelector("p")?.firstChild;
170
+ if (!(textNode instanceof Text)) {
171
+ throw new Error("Expected a paragraph text node");
172
+ }
173
+ const range = document.createRange();
174
+ range.setStart(textNode, 0);
175
+ range.setEnd(textNode, Math.min(2, textNode.length));
176
+ const selection = window.getSelection();
177
+ selection?.removeAllRanges();
178
+ selection?.addRange(range);
179
+ }
180
+
149
181
  afterEach(() => {
150
182
  document.body.innerHTML = "";
151
183
  document
@@ -160,11 +192,13 @@ describe("DragHandle menu", () => {
160
192
  try {
161
193
  clickHandle(handle);
162
194
 
163
- expect(getMenuItems().map((item) => item.textContent)).toEqual([
195
+ const items = getMenuItems();
196
+ expect(items.map((item) => item.textContent)).toEqual([
164
197
  "Duplicate",
165
198
  "Delete",
166
199
  "Insert block below",
167
200
  ]);
201
+ expect(document.activeElement).toBe(items[0]);
168
202
  } finally {
169
203
  editor.destroy();
170
204
  }
@@ -222,6 +256,9 @@ describe("DragHandle menu", () => {
222
256
  const { editor, handle } = mountEditor("<p>First</p><p>Second</p>");
223
257
 
224
258
  try {
259
+ focusEditorWithNativeSelection(editor);
260
+ expect(document.activeElement).toBe(editor.view.dom);
261
+ expect(window.getSelection()?.toString()).toBe("Fi");
225
262
  handle.dispatchEvent(
226
263
  new MouseEvent("mousedown", {
227
264
  bubbles: true,
@@ -250,6 +287,63 @@ describe("DragHandle menu", () => {
250
287
  expect(editor.state.doc.childCount).toBe(2);
251
288
  expect(childText(editor, 0)).toBe("Second");
252
289
  expect(childText(editor, 1)).toBe("First");
290
+ expect(editor.state.selection.empty).toBe(true);
291
+ expect(editor.isFocused).toBe(false);
292
+ expect(window.getSelection()?.toString()).toBe("");
293
+
294
+ expect(editor.commands.undo()).toBe(true);
295
+ expect(childText(editor, 0)).toBe("First");
296
+ expect(childText(editor, 1)).toBe("Second");
297
+ } finally {
298
+ editor.destroy();
299
+ }
300
+ });
301
+
302
+ it("preserves native selections outside the editor after a drop", () => {
303
+ const outside = document.createElement("p");
304
+ outside.textContent = "Keep this selection";
305
+ document.body.append(outside);
306
+ const { editor, handle } = mountEditor("<p>First</p><p>Second</p>");
307
+
308
+ try {
309
+ const textNode = outside.firstChild;
310
+ if (!(textNode instanceof Text)) {
311
+ throw new Error("Expected outside text");
312
+ }
313
+ const range = document.createRange();
314
+ range.setStart(textNode, 0);
315
+ range.setEnd(textNode, 4);
316
+ const selection = window.getSelection();
317
+ selection?.removeAllRanges();
318
+ selection?.addRange(range);
319
+
320
+ handle.dispatchEvent(
321
+ new MouseEvent("mousedown", {
322
+ bubbles: true,
323
+ button: 0,
324
+ clientX: 12,
325
+ clientY: 12,
326
+ }),
327
+ );
328
+ document.dispatchEvent(
329
+ new MouseEvent("mousemove", {
330
+ bubbles: true,
331
+ clientX: 12,
332
+ clientY: 56,
333
+ }),
334
+ );
335
+ document.dispatchEvent(
336
+ new MouseEvent("mouseup", {
337
+ bubbles: true,
338
+ button: 0,
339
+ clientX: 12,
340
+ clientY: 56,
341
+ }),
342
+ );
343
+
344
+ expect(childText(editor, 0)).toBe("Second");
345
+ expect(childText(editor, 1)).toBe("First");
346
+ expect(window.getSelection()?.toString()).toBe("Keep");
253
347
  } finally {
254
348
  editor.destroy();
255
349
  }
@@ -271,6 +365,7 @@ describe("DragHandle menu", () => {
271
365
  );
272
366
 
273
367
  try {
368
+ focusEditorWithNativeSelection(source.editor);
274
369
  hoverAt(32, 12);
275
370
  source.handle.dispatchEvent(
276
371
  new MouseEvent("mousedown", {
@@ -315,6 +410,11 @@ describe("DragHandle menu", () => {
315
410
  expect(childText(target.editor, 0)).toBe("Move me");
316
411
  expect(childText(target.editor, 1)).toBe("Target first");
317
412
  expect(childText(target.editor, 2)).toBe("Target second");
413
+ expect(source.editor.state.selection.empty).toBe(true);
414
+ expect(target.editor.state.selection.empty).toBe(true);
415
+ expect(source.editor.isFocused).toBe(false);
416
+ expect(target.editor.isFocused).toBe(false);
417
+ expect(window.getSelection()?.toString()).toBe("");
318
418
  } finally {
319
419
  source.editor.destroy();
320
420
  target.editor.destroy();
@@ -337,6 +437,7 @@ describe("DragHandle menu", () => {
337
437
  );
338
438
 
339
439
  try {
440
+ focusEditorWithNativeSelection(source.editor);
340
441
  hoverAt(32, 12);
341
442
  source.handle.dispatchEvent(
342
443
  new MouseEvent("mousedown", {
@@ -381,6 +482,11 @@ describe("DragHandle menu", () => {
381
482
  expect(target.editor.state.doc.childCount).toBe(2);
382
483
  expect(childText(target.editor, 0)).toBe("Target first");
383
484
  expect(childText(target.editor, 1)).toBe("Target second");
485
+ expect(source.editor.state.selection.empty).toBe(true);
486
+ expect(target.editor.state.selection.empty).toBe(true);
487
+ expect(source.editor.isFocused).toBe(false);
488
+ expect(target.editor.isFocused).toBe(false);
489
+ expect(window.getSelection()?.toString()).toBe("");
384
490
  } finally {
385
491
  source.editor.destroy();
386
492
  target.editor.destroy();
@@ -148,6 +148,8 @@ type DragSession = {
148
148
  sourceNodeSize: number;
149
149
  startX: number;
150
150
  startY: number;
151
+ pointerOffsetX: number;
152
+ pointerOffsetY: number;
151
153
  dragging: boolean;
152
154
  preview: HTMLElement | null;
153
155
  dropLine: HTMLElement | null;
@@ -194,6 +196,47 @@ let activeHoverRegistration: DragHandleRegistration | null = null;
194
196
  const clamp = (value: number, min: number, max: number) =>
195
197
  Math.min(Math.max(value, min), max);
196
198
 
199
+ export const dragPreviewTransform = ({
200
+ clientX,
201
+ clientY,
202
+ pointerOffsetX,
203
+ pointerOffsetY,
204
+ }: {
205
+ clientX: number;
206
+ clientY: number;
207
+ pointerOffsetX: number;
208
+ pointerOffsetY: number;
209
+ }) =>
210
+ `translate3d(${clientX - pointerOffsetX}px, ${clientY - pointerOffsetY}px, 0)`;
211
+
212
+ const collapseAndBlurEditorAfterDrop = (
213
+ view: EditorView,
214
+ preferredPos: number,
215
+ ) => {
216
+ const pos = clamp(preferredPos, 0, view.state.doc.content.size);
217
+ const selection = TextSelection.near(view.state.doc.resolve(pos), 1);
218
+ if (!view.state.selection.eq(selection)) {
219
+ view.dispatch(view.state.tr.setSelection(selection));
220
+ }
221
+
222
+ const nativeSelection = window.getSelection();
223
+ const selectionBelongsToEditor = [
224
+ nativeSelection?.anchorNode,
225
+ nativeSelection?.focusNode,
226
+ ].some((node) => node && (node === view.dom || view.dom.contains(node)));
227
+ if (selectionBelongsToEditor) {
228
+ nativeSelection?.removeAllRanges();
229
+ }
230
+ const activeElement = document.activeElement;
231
+ if (
232
+ activeElement instanceof HTMLElement &&
233
+ (activeElement === view.dom || view.dom.contains(activeElement))
234
+ ) {
235
+ activeElement.blur();
236
+ }
237
+ view.dom.blur();
238
+ };
239
+
197
240
  const editorArea = (registration: DragHandleRegistration) => {
198
241
  const rect = registration.view.dom.getBoundingClientRect();
199
242
  return rect.width * rect.height;
@@ -977,7 +1020,12 @@ export const DragHandle = Extension.create<DragHandleOptions>({
977
1020
  ) => {
978
1021
  if (!session.preview) return;
979
1022
 
980
- session.preview.style.transform = `translate3d(${clientX + 12}px, ${clientY + 10}px, 0)`;
1023
+ session.preview.style.transform = dragPreviewTransform({
1024
+ clientX,
1025
+ clientY,
1026
+ pointerOffsetX: session.pointerOffsetX,
1027
+ pointerOffsetY: session.pointerOffsetY,
1028
+ });
981
1029
  };
982
1030
 
983
1031
  const updateDropLine = (
@@ -1174,6 +1222,7 @@ export const DragHandle = Extension.create<DragHandleOptions>({
1174
1222
  ) {
1175
1223
  const sourceNode = session.view.state.doc.nodeAt(sourceStart);
1176
1224
  if (sourceNode) {
1225
+ let dropCommitted = false;
1177
1226
  const sourceRegistration = registrationForView(session.view);
1178
1227
  const transferData = sourceRegistration?.getDragTransferData?.({
1179
1228
  view: session.view,
@@ -1198,7 +1247,7 @@ export const DragHandle = Extension.create<DragHandleOptions>({
1198
1247
  false);
1199
1248
 
1200
1249
  if (handled) {
1201
- target.view.focus();
1250
+ dropCommitted = true;
1202
1251
  } else if (target.view === session.view) {
1203
1252
  const insertPos =
1204
1253
  dropPos > sourceStart ? dropPos - sourceNode.nodeSize : dropPos;
@@ -1206,10 +1255,8 @@ export const DragHandle = Extension.create<DragHandleOptions>({
1206
1255
  .delete(sourceStart, sourceEnd)
1207
1256
  .insert(insertPos, sourceNode);
1208
1257
 
1209
- tr.setSelection(NodeSelection.create(tr.doc, insertPos));
1210
-
1211
1258
  session.view.dispatch(tr.scrollIntoView());
1212
- session.view.focus();
1259
+ dropCommitted = true;
1213
1260
  } else {
1214
1261
  try {
1215
1262
  const targetNode = target.view.state.schema.nodeFromJSON(
@@ -1225,9 +1272,6 @@ export const DragHandle = Extension.create<DragHandleOptions>({
1225
1272
  dropPos,
1226
1273
  targetNode,
1227
1274
  );
1228
- insertTr.setSelection(
1229
- NodeSelection.create(insertTr.doc, dropPos),
1230
- );
1231
1275
  target.view.dispatch(insertTr.scrollIntoView());
1232
1276
 
1233
1277
  const deleteTr = session.view.state.tr.delete(
@@ -1235,12 +1279,19 @@ export const DragHandle = Extension.create<DragHandleOptions>({
1235
1279
  sourceEnd,
1236
1280
  );
1237
1281
  session.view.dispatch(deleteTr);
1238
- target.view.focus();
1282
+ dropCommitted = true;
1239
1283
  } catch {
1240
1284
  // If the target schema cannot accept this node, leave the
1241
1285
  // source document untouched.
1242
1286
  }
1243
1287
  }
1288
+
1289
+ if (dropCommitted) {
1290
+ collapseAndBlurEditorAfterDrop(target.view, dropPos);
1291
+ if (target.view !== session.view) {
1292
+ collapseAndBlurEditorAfterDrop(session.view, sourceStart);
1293
+ }
1294
+ }
1244
1295
  }
1245
1296
  }
1246
1297
  } else if (commit && !session.dragging && event) {
@@ -1356,6 +1407,7 @@ export const DragHandle = Extension.create<DragHandleOptions>({
1356
1407
  if (!sourceNode) return;
1357
1408
 
1358
1409
  e.preventDefault();
1410
+ const sourceRect = currentBlock.getBoundingClientRect();
1359
1411
  dragSession = {
1360
1412
  view: editorView,
1361
1413
  sourceBlock: currentBlock,
@@ -1363,6 +1415,8 @@ export const DragHandle = Extension.create<DragHandleOptions>({
1363
1415
  sourceNodeSize: sourceNode.nodeSize,
1364
1416
  startX: e.clientX,
1365
1417
  startY: e.clientY,
1418
+ pointerOffsetX: e.clientX - sourceRect.left,
1419
+ pointerOffsetY: e.clientY - sourceRect.top,
1366
1420
  dragging: false,
1367
1421
  preview: null,
1368
1422
  dropLine: null,