@agent-native/toolkit 0.10.7 → 0.10.9

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.
@@ -839,6 +839,18 @@ export function shouldShowModelSelectorSkeleton(
839
839
  return isLoading && engineCount === 0;
840
840
  }
841
841
 
842
+ /**
843
+ * With nothing connected, every family is a dead "needs API key" row, so the
844
+ * picker shows only the connect CTAs. Never hide the list unless a CTA is
845
+ * there to replace it — an empty popover reads as more broken, not less.
846
+ */
847
+ export function shouldShowOnlyConnectPath(
848
+ showBuilderCta: boolean,
849
+ groups: ReadonlyArray<{ configured: boolean }>,
850
+ ): boolean {
851
+ return showBuilderCta && groups.every((group) => !group.configured);
852
+ }
853
+
842
854
  function friendlyModelName(model: string): string {
843
855
  if (FRIENDLY_MODEL_NAMES[model]) return FRIENDLY_MODEL_NAMES[model];
844
856
  // Claude: claude-{tier}-{major}[-minor][-dateYYYYMMDD] → Tier Major[.Minor]
@@ -1081,6 +1093,10 @@ function ModelSelector({
1081
1093
  !builderFlow.configured &&
1082
1094
  !builderFlow.envManaged &&
1083
1095
  !hasConfiguredBuilderModels;
1096
+ const onlyConnectPathAvailable = shouldShowOnlyConnectPath(
1097
+ showBuilderCta,
1098
+ providerGroups,
1099
+ );
1084
1100
  const openLlmSettings = useCallback(() => {
1085
1101
  try {
1086
1102
  window.location.hash = "llm";
@@ -1161,6 +1177,14 @@ function ModelSelector({
1161
1177
  </span>
1162
1178
  </span>
1163
1179
  </button>
1180
+ {!onConnectProvider && builderFlow.error && (
1181
+ <p
1182
+ role="alert"
1183
+ className="px-3 pb-2 ps-9 text-[11px] text-destructive"
1184
+ >
1185
+ {builderFlow.error}
1186
+ </p>
1187
+ )}
1164
1188
  <button
1165
1189
  type="button"
1166
1190
  onClick={openLlmSettings}
@@ -1181,7 +1205,9 @@ function ModelSelector({
1181
1205
  </span>
1182
1206
  </span>
1183
1207
  </button>
1184
- <div className="my-1 border-t border-border" />
1208
+ {!onlyConnectPathAvailable && (
1209
+ <div className="my-1 border-t border-border" />
1210
+ )}
1185
1211
  </>
1186
1212
  )}
1187
1213
  {imageModel && imageModel.options.length > 0 && (
@@ -1231,7 +1257,7 @@ function ModelSelector({
1231
1257
  </>
1232
1258
  )}
1233
1259
  {showModelListSkeleton && <ModelSelectorSkeleton />}
1234
- {autoModelGroup && (
1260
+ {autoModelGroup && !onlyConnectPathAvailable && (
1235
1261
  <button
1236
1262
  type="button"
1237
1263
  onClick={() => {
@@ -1248,136 +1274,142 @@ function ModelSelector({
1248
1274
  )}
1249
1275
  </button>
1250
1276
  )}
1251
- {autoModelGroup && providerGroups.length > 0 && (
1252
- <div className="my-1 border-t border-border" />
1253
- )}
1254
- {providerGroups.map((group) => {
1255
- const models = latestModelsOnly(group.models);
1256
- const groupKey = `${group.engine}:${group.label}`;
1257
- const isExpanded = expandedGroups.has(groupKey);
1258
- const ChevronIcon = isExpanded ? IconChevronDown : IconChevronRight;
1259
- return (
1260
- <div key={groupKey}>
1277
+ {autoModelGroup &&
1278
+ providerGroups.length > 0 &&
1279
+ !onlyConnectPathAvailable && (
1280
+ <div className="my-1 border-t border-border" />
1281
+ )}
1282
+ {!onlyConnectPathAvailable &&
1283
+ providerGroups.map((group) => {
1284
+ const models = latestModelsOnly(group.models);
1285
+ const groupKey = `${group.engine}:${group.label}`;
1286
+ const isExpanded = expandedGroups.has(groupKey);
1287
+ const ChevronIcon = isExpanded ? IconChevronDown : IconChevronRight;
1288
+ return (
1289
+ <div key={groupKey}>
1290
+ <div className="flex items-center hover:bg-accent/30">
1291
+ <button
1292
+ type="button"
1293
+ aria-expanded={isExpanded}
1294
+ onClick={() => toggleGroup(groupKey)}
1295
+ className="flex flex-1 min-w-0 items-center gap-1.5 px-2 py-1.5 cursor-pointer text-start"
1296
+ >
1297
+ <ChevronIcon className="h-3 w-3 shrink-0 text-muted-foreground rtl:-scale-x-100" />
1298
+ <span className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide shrink-0">
1299
+ {group.label}
1300
+ </span>
1301
+ {!isExpanded && groupKey === selectedGroupKey && (
1302
+ <span className="text-[11px] text-muted-foreground/80 truncate">
1303
+ {friendlyModelName(model)}
1304
+ </span>
1305
+ )}
1306
+ </button>
1307
+ {!group.configured && (
1308
+ <button
1309
+ type="button"
1310
+ className="text-[10px] text-muted-foreground/60 hover:text-foreground cursor-pointer pe-3 py-1.5"
1311
+ onClick={() =>
1312
+ group.engine === "codex-cli" && onConnectLocalRuntime
1313
+ ? connectLocalRuntime(group.engine)
1314
+ : openLlmSettings()
1315
+ }
1316
+ >
1317
+ {group.engine === "codex-cli" && onConnectLocalRuntime
1318
+ ? "sign in"
1319
+ : "needs API key"}
1320
+ </button>
1321
+ )}
1322
+ </div>
1323
+ {isExpanded &&
1324
+ models.map((m) => (
1325
+ <button
1326
+ key={m}
1327
+ type="button"
1328
+ onClick={() => {
1329
+ if (!group.configured) {
1330
+ if (
1331
+ group.engine === "codex-cli" &&
1332
+ onConnectLocalRuntime
1333
+ ) {
1334
+ connectLocalRuntime(group.engine);
1335
+ } else {
1336
+ openLlmSettings();
1337
+ }
1338
+ return;
1339
+ }
1340
+ onChange(m, group.engine);
1341
+ const nextOptions =
1342
+ getReasoningEffortOptionsForModel(m);
1343
+ if (
1344
+ nextOptions.length > 0 &&
1345
+ !nextOptions.includes(selectedEffort)
1346
+ ) {
1347
+ onEffortChange?.(defaultEffort);
1348
+ }
1349
+ setOpen(false);
1350
+ }}
1351
+ className={`flex w-full items-center gap-3 ps-7 pe-3 py-1.5 text-start ${
1352
+ group.configured
1353
+ ? "hover:bg-accent/50"
1354
+ : "opacity-40 cursor-default"
1355
+ }`}
1356
+ >
1357
+ <span className="flex-1 min-w-0 text-[13px] text-foreground truncate">
1358
+ {friendlyModelName(m)}
1359
+ </span>
1360
+ {m === model && group.configured && (
1361
+ <IconCheck className="h-3.5 w-3.5 shrink-0 text-blue-500" />
1362
+ )}
1363
+ </button>
1364
+ ))}
1365
+ </div>
1366
+ );
1367
+ })}
1368
+ {!showModelListSkeleton &&
1369
+ !onlyConnectPathAvailable &&
1370
+ effortOptions.length > 0 && (
1371
+ <>
1372
+ <div className="my-1 border-t border-border" />
1261
1373
  <div className="flex items-center hover:bg-accent/30">
1262
1374
  <button
1263
1375
  type="button"
1264
- aria-expanded={isExpanded}
1265
- onClick={() => toggleGroup(groupKey)}
1376
+ aria-expanded={reasoningExpanded}
1377
+ onClick={() => setReasoningExpanded((prev) => !prev)}
1266
1378
  className="flex flex-1 min-w-0 items-center gap-1.5 px-2 py-1.5 cursor-pointer text-start"
1267
1379
  >
1268
- <ChevronIcon className="h-3 w-3 shrink-0 text-muted-foreground rtl:-scale-x-100" />
1380
+ {reasoningExpanded ? (
1381
+ <IconChevronDown className="h-3 w-3 shrink-0 text-muted-foreground" />
1382
+ ) : (
1383
+ <IconChevronRight className="h-3 w-3 shrink-0 text-muted-foreground rtl:-scale-x-100" />
1384
+ )}
1269
1385
  <span className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide shrink-0">
1270
- {group.label}
1386
+ Reasoning
1271
1387
  </span>
1272
- {!isExpanded && groupKey === selectedGroupKey && (
1388
+ {!reasoningExpanded && (
1273
1389
  <span className="text-[11px] text-muted-foreground/80 truncate">
1274
- {friendlyModelName(model)}
1390
+ {effortLabel(selectedEffort)}
1275
1391
  </span>
1276
1392
  )}
1277
1393
  </button>
1278
- {!group.configured && (
1279
- <button
1280
- type="button"
1281
- className="text-[10px] text-muted-foreground/60 hover:text-foreground cursor-pointer pe-3 py-1.5"
1282
- onClick={() =>
1283
- group.engine === "codex-cli" && onConnectLocalRuntime
1284
- ? connectLocalRuntime(group.engine)
1285
- : openLlmSettings()
1286
- }
1287
- >
1288
- {group.engine === "codex-cli" && onConnectLocalRuntime
1289
- ? "sign in"
1290
- : "needs API key"}
1291
- </button>
1292
- )}
1293
1394
  </div>
1294
- {isExpanded &&
1295
- models.map((m) => (
1395
+ {reasoningExpanded &&
1396
+ effortOptions.map((option) => (
1296
1397
  <button
1297
- key={m}
1398
+ key={option}
1298
1399
  type="button"
1299
- onClick={() => {
1300
- if (!group.configured) {
1301
- if (
1302
- group.engine === "codex-cli" &&
1303
- onConnectLocalRuntime
1304
- ) {
1305
- connectLocalRuntime(group.engine);
1306
- } else {
1307
- openLlmSettings();
1308
- }
1309
- return;
1310
- }
1311
- onChange(m, group.engine);
1312
- const nextOptions = getReasoningEffortOptionsForModel(m);
1313
- if (
1314
- nextOptions.length > 0 &&
1315
- !nextOptions.includes(selectedEffort)
1316
- ) {
1317
- onEffortChange?.(defaultEffort);
1318
- }
1319
- setOpen(false);
1320
- }}
1321
- className={`flex w-full items-center gap-3 ps-7 pe-3 py-1.5 text-start ${
1322
- group.configured
1323
- ? "hover:bg-accent/50"
1324
- : "opacity-40 cursor-default"
1325
- }`}
1400
+ onClick={() => onEffortChange?.(option)}
1401
+ className="flex w-full items-center gap-3 ps-7 pe-3 py-1.5 text-start hover:bg-accent/50"
1326
1402
  >
1327
1403
  <span className="flex-1 min-w-0 text-[13px] text-foreground truncate">
1328
- {friendlyModelName(m)}
1404
+ {effortLabel(option)}
1329
1405
  </span>
1330
- {m === model && group.configured && (
1406
+ {option === selectedEffort && (
1331
1407
  <IconCheck className="h-3.5 w-3.5 shrink-0 text-blue-500" />
1332
1408
  )}
1333
1409
  </button>
1334
1410
  ))}
1335
- </div>
1336
- );
1337
- })}
1338
- {!showModelListSkeleton && effortOptions.length > 0 && (
1339
- <>
1340
- <div className="my-1 border-t border-border" />
1341
- <div className="flex items-center hover:bg-accent/30">
1342
- <button
1343
- type="button"
1344
- aria-expanded={reasoningExpanded}
1345
- onClick={() => setReasoningExpanded((prev) => !prev)}
1346
- className="flex flex-1 min-w-0 items-center gap-1.5 px-2 py-1.5 cursor-pointer text-start"
1347
- >
1348
- {reasoningExpanded ? (
1349
- <IconChevronDown className="h-3 w-3 shrink-0 text-muted-foreground" />
1350
- ) : (
1351
- <IconChevronRight className="h-3 w-3 shrink-0 text-muted-foreground rtl:-scale-x-100" />
1352
- )}
1353
- <span className="text-[11px] font-medium text-muted-foreground uppercase tracking-wide shrink-0">
1354
- Reasoning
1355
- </span>
1356
- {!reasoningExpanded && (
1357
- <span className="text-[11px] text-muted-foreground/80 truncate">
1358
- {effortLabel(selectedEffort)}
1359
- </span>
1360
- )}
1361
- </button>
1362
- </div>
1363
- {reasoningExpanded &&
1364
- effortOptions.map((option) => (
1365
- <button
1366
- key={option}
1367
- type="button"
1368
- onClick={() => onEffortChange?.(option)}
1369
- className="flex w-full items-center gap-3 ps-7 pe-3 py-1.5 text-start hover:bg-accent/50"
1370
- >
1371
- <span className="flex-1 min-w-0 text-[13px] text-foreground truncate">
1372
- {effortLabel(option)}
1373
- </span>
1374
- {option === selectedEffort && (
1375
- <IconCheck className="h-3.5 w-3.5 shrink-0 text-blue-500" />
1376
- )}
1377
- </button>
1378
- ))}
1379
- </>
1380
- )}
1411
+ </>
1412
+ )}
1381
1413
  </PopoverContent>
1382
1414
  </Popover>
1383
1415
  );
@@ -43,6 +43,7 @@ export interface ComposerBuilderConnectFlow {
43
43
  envManaged: boolean;
44
44
  connecting: boolean;
45
45
  statusResolved: boolean;
46
+ error: string | null;
46
47
  start: () => void;
47
48
  }
48
49
 
@@ -196,6 +197,7 @@ const fallbackBuilderFlow = {
196
197
  envManaged: false,
197
198
  connecting: false,
198
199
  statusResolved: false,
200
+ error: null,
199
201
  start: () => {},
200
202
  };
201
203
 
@@ -183,6 +183,57 @@ function render(
183
183
  }
184
184
 
185
185
  describe("useCollabReconcile — concurrent edit / lost-update guards", () => {
186
+ it("persists the first local edit after a synced empty collaborative document", async () => {
187
+ const captured: Captured = {
188
+ editor: null,
189
+ emitted: [],
190
+ setContentCalls: 0,
191
+ };
192
+
193
+ function EmptyDocumentHarness() {
194
+ const guardsRef = React.useRef<ReturnType<
195
+ typeof useCollabReconcile
196
+ > | null>(null);
197
+ const insertedRef = React.useRef(false);
198
+ const ydoc = React.useMemo(() => new Y.Doc(), []);
199
+ const editor = useEditor({
200
+ extensions: createRichMarkdownExtensions({ dialect: "gfm", ydoc }),
201
+ onUpdate: ({ editor, transaction }) => {
202
+ const guards = guardsRef.current;
203
+ if (!guards || guards.shouldIgnoreUpdate(transaction)) return;
204
+ const markdown = getEditorMarkdown(editor);
205
+ if (!guards.registerEmitted(markdown)) return;
206
+ captured.emitted.push(markdown);
207
+ },
208
+ });
209
+ captured.editor = editor;
210
+ guardsRef.current = useCollabReconcile({
211
+ editor,
212
+ ydoc,
213
+ collabSynced: true,
214
+ value: "",
215
+ contentUpdatedAt: "2024-01-01T00:00:01.000Z",
216
+ editable: true,
217
+ getMarkdown: getEditorMarkdown,
218
+ });
219
+ React.useLayoutEffect(() => {
220
+ if (!editor || insertedRef.current) return;
221
+ insertedRef.current = true;
222
+ editor.view.dispatch(
223
+ editor.state.tr
224
+ .insertText("First persisted edit")
225
+ .setMeta("uiEvent", "input"),
226
+ );
227
+ }, [editor]);
228
+ return React.createElement("div", null);
229
+ }
230
+
231
+ act(() => root.render(React.createElement(EmptyDocumentHarness)));
232
+ await flush();
233
+
234
+ expect(captured.emitted).toContain("First persisted edit");
235
+ });
236
+
186
237
  it("does not seed until initial collab sync has completed", async () => {
187
238
  const { captured, Harness } = makeCollabSeedHarness();
188
239
 
@@ -626,7 +626,20 @@ export function useCollabReconcile({
626
626
  // app's local mirror or autosave path (Content serializes an empty paragraph
627
627
  // as `<empty-block/>`, which is non-empty text and bypasses the generic
628
628
  // registerEmitted empty-string guard).
629
- if (collab && !seededRef.current) return true;
629
+ if (collab && !seededRef.current) {
630
+ // Passive effects normally mark a synced empty document initialized
631
+ // before a person can type. Keep the event boundary correct too: if a
632
+ // genuine local edit wins that tiny race, it is itself proof that the
633
+ // synced empty document is ready. Remote Yjs transactions stay ignored.
634
+ const firstSyncedEmptyUserEdit =
635
+ collabSynced &&
636
+ !value.trim() &&
637
+ transaction.docChanged &&
638
+ Boolean(transaction.getMeta("uiEvent")) &&
639
+ !isChangeOrigin(transaction);
640
+ if (!firstSyncedEmptyUserEdit) return true;
641
+ seededRef.current = true;
642
+ }
630
643
  if (transaction.getMeta(RICH_MARKDOWN_PROGRAMMATIC_TRANSACTION)) {
631
644
  return true;
632
645
  }