@agent-native/core 0.80.10 → 0.80.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.
Files changed (66) hide show
  1. package/corpus/README.md +1 -1
  2. package/corpus/core/CHANGELOG.md +6 -0
  3. package/corpus/core/package.json +1 -1
  4. package/corpus/core/src/agent/production-agent.ts +108 -15
  5. package/corpus/core/src/client/AssistantChat.tsx +123 -26
  6. package/corpus/core/src/client/sse-event-processor.ts +9 -0
  7. package/corpus/templates/assets/.agents/skills/image-generation/SKILL.md +9 -2
  8. package/corpus/templates/assets/AGENTS.md +4 -0
  9. package/corpus/templates/assets/actions/_tool-activity.ts +46 -0
  10. package/corpus/templates/assets/actions/generate-image-batch.ts +26 -5
  11. package/corpus/templates/assets/actions/generate-image.ts +118 -67
  12. package/corpus/templates/assets/actions/list-draft-assets.ts +50 -0
  13. package/corpus/templates/assets/actions/rerun-generation-run.ts +52 -31
  14. package/corpus/templates/assets/app/components/create/RecentDraftsSection.tsx +100 -0
  15. package/corpus/templates/assets/app/i18n/zh-TW.ts +7 -0
  16. package/corpus/templates/assets/app/i18n-data.ts +61 -0
  17. package/corpus/templates/assets/app/routes/_index.tsx +4 -0
  18. package/corpus/templates/assets/app/routes/library.tsx +145 -38
  19. package/corpus/templates/assets/changelog/2026-06-29-image-generation-can-now-render-requested-text-and-respect-b.md +6 -0
  20. package/corpus/templates/assets/changelog/2026-06-30-image-generation-no-longer-looks-stuck-while-the-provider-is-still-working.md +6 -0
  21. package/corpus/templates/assets/changelog/2026-06-30-tagged-presets-now-control-the-image-aspect-ratio.md +6 -0
  22. package/corpus/templates/assets/server/lib/generation.ts +135 -11
  23. package/corpus/templates/assets/shared/api.ts +4 -0
  24. package/corpus/templates/clips/actions/list-ai-requests.ts +60 -0
  25. package/corpus/templates/clips/app/hooks/use-auto-title.ts +24 -25
  26. package/corpus/templates/design/actions/apply-component-prop-edit.ts +13 -5
  27. package/corpus/templates/design/actions/apply-design-state.ts +17 -4
  28. package/corpus/templates/design/actions/apply-motion-edit.ts +9 -46
  29. package/corpus/templates/design/actions/delete-design.ts +20 -0
  30. package/corpus/templates/design/actions/get-component-details.ts +16 -7
  31. package/corpus/templates/design/actions/index-design-tokens.ts +8 -4
  32. package/corpus/templates/design/actions/list-design-states.ts +19 -1
  33. package/corpus/templates/design/actions/open-component-source.ts +7 -2
  34. package/corpus/templates/design/actions/preview-component-prop-edit.ts +32 -26
  35. package/corpus/templates/design/actions/preview-shader-fill.ts +9 -11
  36. package/corpus/templates/design/actions/run-design-audit.ts +21 -6
  37. package/corpus/templates/design/app/components/design/DesignCanvas.tsx +139 -14
  38. package/corpus/templates/design/app/components/design/DesignExtensionsPanel.tsx +20 -3
  39. package/corpus/templates/design/app/components/design/EditPanel.tsx +428 -31
  40. package/corpus/templates/design/app/components/design/LayersPanel.tsx +110 -30
  41. package/corpus/templates/design/app/components/design/MotionDock.tsx +9 -0
  42. package/corpus/templates/design/app/components/design/MultiScreenCanvas.tsx +1 -1
  43. package/corpus/templates/design/app/components/design/ReviewPanel.tsx +12 -5
  44. package/corpus/templates/design/app/components/design/StatesPanel.tsx +4 -2
  45. package/corpus/templates/design/app/components/design/TokensPanel.tsx +13 -8
  46. package/corpus/templates/design/app/i18n/zh-TW.ts +1 -0
  47. package/corpus/templates/design/app/i18n-data.ts +11 -0
  48. package/corpus/templates/design/app/pages/DesignEditor.tsx +684 -179
  49. package/corpus/templates/design/changelog/2026-06-30-design-token-edits-now-stay-visible-in-previews-and-token-li.md +6 -0
  50. package/corpus/templates/design/e2e/global-setup.ts +94 -2
  51. package/corpus/templates/design/shared/component-model.ts +35 -0
  52. package/corpus/templates/design/shared/motion-compiler.ts +79 -8
  53. package/corpus/templates/design/shared/resolve-tweaks.ts +32 -9
  54. package/dist/agent/production-agent.d.ts.map +1 -1
  55. package/dist/agent/production-agent.js +94 -10
  56. package/dist/agent/production-agent.js.map +1 -1
  57. package/dist/client/AssistantChat.d.ts +14 -0
  58. package/dist/client/AssistantChat.d.ts.map +1 -1
  59. package/dist/client/AssistantChat.js +115 -22
  60. package/dist/client/AssistantChat.js.map +1 -1
  61. package/dist/client/sse-event-processor.d.ts.map +1 -1
  62. package/dist/client/sse-event-processor.js +10 -0
  63. package/dist/client/sse-event-processor.js.map +1 -1
  64. package/dist/observability/routes.d.ts +3 -3
  65. package/dist/server/agent-engine-api-key-route.d.ts +1 -1
  66. package/package.json +1 -1
package/corpus/README.md CHANGED
@@ -28,4 +28,4 @@ rg -n "defineAction|useActionQuery" node_modules/@agent-native/core/corpus
28
28
  ## Generated Counts
29
29
 
30
30
  - core files: 2043
31
- - template files: 4719
31
+ - template files: 4729
@@ -1,5 +1,11 @@
1
1
  # @agent-native/core
2
2
 
3
+ ## 0.80.11
4
+
5
+ ### Patch Changes
6
+
7
+ - abf0681: Stop chat reconnect from falsely reporting "stopped before finishing" on long but healthy runs. The active-run reconnect now treats the stuck threshold as an idle deadline that resets on every streamed event, instead of a one-shot cap on total reconnect duration — so a long-running tool (e.g. image generation) that keeps emitting activity heartbeats is never aborted with a no-progress error just for running longer than the threshold. Also surfaces active tool progress while reconnecting, and waits for interrupted write-tool results before re-running them so long-running tools do not appear stopped or duplicate work.
8
+
3
9
  ## 0.80.10
4
10
 
5
11
  ### Patch Changes
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.80.10",
3
+ "version": "0.80.11",
4
4
  "description": "Framework for agent-native application development — where AI agents and UI share SQL state, actions, and context",
5
5
  "homepage": "https://github.com/BuilderIO/agent-native#readme",
6
6
  "bugs": {
@@ -2171,6 +2171,63 @@ function toolCallCacheKey(toolName: string, input: unknown): string {
2171
2171
  return `${toolName}:${stableStringify(normalizeToolCallInputForHistory(input))}`;
2172
2172
  }
2173
2173
 
2174
+ const INTERRUPTED_TOOL_LEDGER_POLL_MS =
2175
+ process.env.NODE_ENV === "test" ? 0 : 2_000;
2176
+
2177
+ async function waitForInterruptedToolLedgerEntry(opts: {
2178
+ threadId: string;
2179
+ toolKey: string;
2180
+ toolName: string;
2181
+ timeoutMs: number;
2182
+ signal: AbortSignal;
2183
+ send: (event: AgentChatEvent) => void;
2184
+ }): Promise<string | null> {
2185
+ const pollMs = INTERRUPTED_TOOL_LEDGER_POLL_MS;
2186
+ // Wait up to the tool's OWN declared timeout — the abandoned zombie can keep
2187
+ // running that long (e.g. a 12-minute image generation, whose provider keeps
2188
+ // generating after the run aborts), and giving up early re-runs the same
2189
+ // write tool while the original is still in flight, duplicating work and
2190
+ // double-charging. A flat sub-tool-timeout cap (previously 5 min) silently
2191
+ // truncated long tools. The run's AbortSignal still bounds this to the run's
2192
+ // remaining budget: when the run is cut off, the poll returns null and the
2193
+ // re-dispatch hits the already-aborted signal instead of launching a real
2194
+ // second call, so the wait never outlives the run.
2195
+ const maxWaitMs =
2196
+ process.env.NODE_ENV === "test" ? 1 : Math.max(0, opts.timeoutMs);
2197
+ const maxPolls =
2198
+ process.env.NODE_ENV === "test"
2199
+ ? 3
2200
+ : Math.max(1, Math.ceil(maxWaitMs / Math.max(1, pollMs)) + 1);
2201
+
2202
+ for (let attempt = 0; attempt < maxPolls; attempt++) {
2203
+ if (opts.signal.aborted) return null;
2204
+ const ledgerResult = await readLedgerEntry(opts.threadId, opts.toolKey);
2205
+ if (ledgerResult !== null) return ledgerResult;
2206
+
2207
+ if (attempt >= maxPolls - 1) break;
2208
+ opts.send({
2209
+ type: "activity",
2210
+ tool: opts.toolName,
2211
+ label: `Waiting for previous ${opts.toolName} result.`,
2212
+ });
2213
+ if (pollMs <= 0) continue;
2214
+ await new Promise<void>((resolve) => {
2215
+ let settled = false;
2216
+ const done = () => {
2217
+ if (settled) return;
2218
+ settled = true;
2219
+ clearTimeout(timer);
2220
+ opts.signal.removeEventListener("abort", done);
2221
+ resolve();
2222
+ };
2223
+ const timer = setTimeout(done, pollMs);
2224
+ opts.signal.addEventListener("abort", done, { once: true });
2225
+ });
2226
+ }
2227
+
2228
+ return null;
2229
+ }
2230
+
2174
2231
  function normalizeToolErrorForBreaker(error: string): string {
2175
2232
  return error.replace(/\s+/g, " ").trim();
2176
2233
  }
@@ -3241,6 +3298,17 @@ export async function runAgentLoop(opts: {
3241
3298
  };
3242
3299
  }
3243
3300
 
3301
+ const DEFAULT_TOOL_RESULT_CHARS = 50_000;
3302
+ const DEFAULT_TOOL_TIMEOUT_MS = 60_000;
3303
+ const toolTimeoutMs =
3304
+ actionEntry.timeoutMs ??
3305
+ opts.toolLimits?.timeoutMs ??
3306
+ DEFAULT_TOOL_TIMEOUT_MS;
3307
+ const toolMaxResultChars =
3308
+ actionEntry.maxResultChars ??
3309
+ opts.toolLimits?.maxResultChars ??
3310
+ DEFAULT_TOOL_RESULT_CHARS;
3311
+
3244
3312
  // TOOL-CALL JOURNAL HARD-BLOCK (resume safety, tool-layer enforcement).
3245
3313
  // The prompt-level resume journal already TELLS a resuming model not to
3246
3314
  // re-run completed tool calls; this enforces it at the tool layer so a
@@ -3291,17 +3359,23 @@ export async function runAgentLoop(opts: {
3291
3359
  // previous invocation's zombie actually completed and wrote its result to
3292
3360
  // the durable ledger. If so, return the ledger result without re-executing
3293
3361
  // (prevents the duplicate side effect) and skip counting it toward the
3294
- // interruption budget.
3362
+ // interruption budget. A just-abandoned long tool may need a short grace
3363
+ // period before its detached promise writes the ledger, so wait while the
3364
+ // current run still has budget instead of immediately re-running it.
3295
3365
  if (!actionEntry.readOnly) {
3296
3366
  const writeCacheKey = toolCallCacheKey(toolCall.name, toolCall.input);
3297
3367
  const priorInterruptions =
3298
3368
  writeToolInterruptions.get(writeCacheKey) ?? 0;
3299
3369
 
3300
3370
  if (priorInterruptions > 0 && opts.threadId) {
3301
- const ledgerResult = await readLedgerEntry(
3302
- opts.threadId,
3303
- writeCacheKey,
3304
- );
3371
+ const ledgerResult = await waitForInterruptedToolLedgerEntry({
3372
+ threadId: opts.threadId,
3373
+ toolKey: writeCacheKey,
3374
+ toolName: toolCall.name,
3375
+ timeoutMs: toolTimeoutMs,
3376
+ signal,
3377
+ send,
3378
+ });
3305
3379
  if (ledgerResult !== null) {
3306
3380
  // Zombie completed — recover the real result without re-executing.
3307
3381
  const result =
@@ -3359,6 +3433,25 @@ export async function runAgentLoop(opts: {
3359
3433
  }
3360
3434
  }
3361
3435
 
3436
+ // Stop BEFORE emitting tool_start if the run was already aborted —
3437
+ // typically because the ledger wait above polled for minutes and the soft
3438
+ // timeout fired meanwhile. Emitting tool_start/tool_done here would leave a
3439
+ // bogus interrupted pair in the transcript for a tool that never re-ran,
3440
+ // and re-invoking would spawn a duplicate zombie. Return the interrupted
3441
+ // marker (no events) so the next continuation recovers via the ledger.
3442
+ // (A second guard inside the try below still covers an abort that lands in
3443
+ // the tiny sync window between here and the action invocation.)
3444
+ if (signal.aborted) {
3445
+ recordToolResult(INTERRUPTED_TOOL_RESULT_MARKER, false);
3446
+ return {
3447
+ type: "tool-result" as const,
3448
+ toolCallId: toolCall.id,
3449
+ toolName: toolCall.name,
3450
+ toolInput: wireToolInput,
3451
+ content: INTERRUPTED_TOOL_RESULT_MARKER,
3452
+ };
3453
+ }
3454
+
3362
3455
  send({
3363
3456
  type: "tool_start",
3364
3457
  tool: toolCall.name,
@@ -3427,22 +3520,22 @@ export async function runAgentLoop(opts: {
3427
3520
  };
3428
3521
  }
3429
3522
 
3430
- const DEFAULT_TOOL_RESULT_CHARS = 50_000;
3431
- const DEFAULT_TOOL_TIMEOUT_MS = 60_000;
3432
- const toolTimeoutMs =
3433
- actionEntry.timeoutMs ??
3434
- opts.toolLimits?.timeoutMs ??
3435
- DEFAULT_TOOL_TIMEOUT_MS;
3436
- const toolMaxResultChars =
3437
- actionEntry.maxResultChars ??
3438
- opts.toolLimits?.maxResultChars ??
3439
- DEFAULT_TOOL_RESULT_CHARS;
3440
3523
  let result: string;
3441
3524
  let isError = false;
3442
3525
  let mcpApp:
3443
3526
  | import("../mcp-client/app-result.js").AgentMcpAppPayload
3444
3527
  | undefined;
3445
3528
  try {
3529
+ // The run may have been aborted while we waited above for an
3530
+ // interrupted tool's ledger result (the wait can poll for minutes).
3531
+ // Re-check before invoking the action: starting it now would spawn a
3532
+ // fresh zombie execution — a duplicate side effect / double charge —
3533
+ // which the ledger-recovery path exists to prevent. The Promise.race
3534
+ // "Run aborted" leg below only rejects AFTER the action is invoked, so
3535
+ // it cannot guard this. Throw here instead, handled like any abort.
3536
+ if (signal.aborted) {
3537
+ throw new Error("Run aborted");
3538
+ }
3446
3539
  const timeoutSignal = AbortSignal.timeout(toolTimeoutMs);
3447
3540
  const actionUserEmail = opts.ownerEmail ?? getRequestUserEmail();
3448
3541
  const actionOrgId = opts.orgId ?? getRequestOrgId() ?? null;
@@ -230,6 +230,12 @@ const PENDING_SELECTION_KEY = "pending-selection-context";
230
230
  const ACTIVE_RUN_CLEAR_TIMEOUT_MS = 5_000;
231
231
  const ACTIVE_RUN_STUCK_THRESHOLD_MS = 90_000;
232
232
  const ACTIVE_RUN_POLL_INTERVAL_MS = 150;
233
+ // How long a single activity (model call, tool prep, long tool) must stay
234
+ // in-flight before its label is surfaced in the running indicator. Below this
235
+ // the indicator stays a steady "Thinking" so normal fast turns don't flicker
236
+ // through transient labels ("Contacting model", "Preparing X action"); past it
237
+ // the live label appears so a genuinely slow step reads as working, not hung.
238
+ const ACTIVITY_LABEL_REVEAL_DELAY_MS = 6_000;
233
239
  const SUBMIT_ENGINE_STATUS_TIMEOUT_MS = 1000;
234
240
 
235
241
  type ActiveRunLookup = {
@@ -254,6 +260,24 @@ function activeRunLooksStale(runInfo: ActiveRunLookup): boolean {
254
260
  );
255
261
  }
256
262
 
263
+ /**
264
+ * Decide whether a reconnect should give up with "no progress". The signal is
265
+ * *time since the last streamed event*, NOT total reconnect duration: a healthy
266
+ * long-running tool (e.g. image generation, which emits `activity` heartbeats
267
+ * every few seconds) makes continuous progress and must never be aborted just
268
+ * for running longer than the threshold. Only genuine silence for the full
269
+ * stuck threshold counts as stuck. Pure + exported so the decision is unit
270
+ * testable without driving the whole reconnect lifecycle.
271
+ */
272
+ export function reconnectProgressTimedOut(args: {
273
+ lastProgressAt: number;
274
+ now: number;
275
+ thresholdMs?: number;
276
+ }): boolean {
277
+ const threshold = args.thresholdMs ?? ACTIVE_RUN_STUCK_THRESHOLD_MS;
278
+ return args.now - args.lastProgressAt >= threshold;
279
+ }
280
+
257
281
  function isAssistantUiDuplicateMessageIdError(error: unknown): boolean {
258
282
  const message = error instanceof Error ? error.message : String(error ?? "");
259
283
  return (
@@ -1323,6 +1347,24 @@ const AssistantChatInner = forwardRef<
1323
1347
  // True during the 250ms continuation window and startup of the next chunk
1324
1348
  // (adapter's auto-continue delay before POSTing the next chunk).
1325
1349
  const [isAutoResuming, setIsAutoResuming] = useState(false);
1350
+ const [runningActivityLabel, setRunningActivityLabel] = useState<
1351
+ string | null
1352
+ >(null);
1353
+ // Delayed-reveal state for the activity label (see ACTIVITY_LABEL_REVEAL_DELAY_MS).
1354
+ // `latest` holds the most recent activity label; `surfaced` flips true once the
1355
+ // reveal timer fires; `timer` is the pending one-shot reveal.
1356
+ const activityLabelTimerRef = useRef<number | null>(null);
1357
+ const latestActivityLabelRef = useRef<string | null>(null);
1358
+ const activityLabelSurfacedRef = useRef(false);
1359
+ const resetRunningActivity = useCallback(() => {
1360
+ if (activityLabelTimerRef.current !== null) {
1361
+ window.clearTimeout(activityLabelTimerRef.current);
1362
+ activityLabelTimerRef.current = null;
1363
+ }
1364
+ latestActivityLabelRef.current = null;
1365
+ activityLabelSurfacedRef.current = false;
1366
+ setRunningActivityLabel(null);
1367
+ }, []);
1326
1368
  const [reconnectContent, setReconnectContent] = useState<ContentPart[]>([]);
1327
1369
  // When stop is clicked during reconnect, keep content visible (don't wipe it)
1328
1370
  const [reconnectFrozen, setReconnectFrozen] = useState(false);
@@ -1341,6 +1383,19 @@ const AssistantChatInner = forwardRef<
1341
1383
  const textStreaming = isRunning || externalStreaming;
1342
1384
  // UI-only running state — drives the stop button and thinking indicator.
1343
1385
  const showRunningInUI = isRunning;
1386
+ // A revealed activity label wins; otherwise stay a steady "Thinking" by
1387
+ // default. We only surface "Reconnecting" while we are actively replaying
1388
+ // recovered content (reconnectContent populated). A bare reconnect with no
1389
+ // replayed content — e.g. a tail-resume after the serverless wall, where
1390
+ // `scheduleUpdate` intentionally leaves reconnectContent empty — is normal
1391
+ // ongoing work, so it must read as "Thinking", never a perpetual "Working".
1392
+ const runningStatusLabel = runningActivityLabel
1393
+ ? runningActivityLabel
1394
+ : isAutoResuming
1395
+ ? "Resuming"
1396
+ : isReconnecting && reconnectContent.length > 0
1397
+ ? "Reconnecting"
1398
+ : "Thinking";
1344
1399
  const lastBroadcastRunningRef = useRef(isRunning);
1345
1400
  const tiptapRef = useRef<TiptapComposerHandle>(null);
1346
1401
  // Stable ref to the "stop active run" action so addToQueue can abort
@@ -1608,11 +1663,32 @@ const AssistantChatInner = forwardRef<
1608
1663
  }, 1000);
1609
1664
 
1610
1665
  let reconnectTimedOut = false;
1611
- const maxReconnectTimer = setTimeout(() => {
1666
+ // Idle deadline, NOT a total-duration cap. A long-but-healthy run (image
1667
+ // generation emits `activity` heartbeats every few seconds) keeps
1668
+ // advancing `lastReconnectProgressAt`, so it is never aborted for simply
1669
+ // taking longer than the threshold. Only true silence for the full stuck
1670
+ // threshold trips it — the same semantics as `activeRunLooksStale` and the
1671
+ // SSE-level no-progress timeout. `markReconnectProgress` resets it on every
1672
+ // streamed event (see the `onSeq` callback below, which fires for every
1673
+ // event including activity, for both fresh and tail-resume reconnects).
1674
+ let lastReconnectProgressAt = Date.now();
1675
+ const markReconnectProgress = () => {
1676
+ lastReconnectProgressAt = Date.now();
1677
+ };
1678
+ const idleCheck = setInterval(() => {
1679
+ if (
1680
+ !reconnectProgressTimedOut({
1681
+ lastProgressAt: lastReconnectProgressAt,
1682
+ now: Date.now(),
1683
+ })
1684
+ ) {
1685
+ return;
1686
+ }
1612
1687
  reconnectTimedOut = true;
1613
1688
  abortCtrl.abort();
1614
1689
  clearInterval(watchdog);
1615
- }, 20_000);
1690
+ clearInterval(idleCheck);
1691
+ }, 1000);
1616
1692
 
1617
1693
  const streamReconnect = async () => {
1618
1694
  let noProgressDuringReconnect = false;
@@ -1653,7 +1729,10 @@ const AssistantChatInner = forwardRef<
1653
1729
  toolCallCounter,
1654
1730
  tabId,
1655
1731
  scheduleUpdate,
1656
- (seq) => updateActiveRunSeq(seq),
1732
+ (seq) => {
1733
+ markReconnectProgress();
1734
+ updateActiveRunSeq(seq);
1735
+ },
1657
1736
  );
1658
1737
  if (afterSeq === 0) {
1659
1738
  setReconnectContent([...content]);
@@ -1677,7 +1756,7 @@ const AssistantChatInner = forwardRef<
1677
1756
  window.clearInterval(threadPollInterval);
1678
1757
  }
1679
1758
  clearInterval(watchdog);
1680
- clearTimeout(maxReconnectTimer);
1759
+ clearInterval(idleCheck);
1681
1760
  }
1682
1761
 
1683
1762
  if (noProgressDuringReconnect && reconnectRunIdRef.current === runId) {
@@ -2235,11 +2314,9 @@ const AssistantChatInner = forwardRef<
2235
2314
  return () => window.removeEventListener("agent-chat:run-error", handler);
2236
2315
  }, [tabId]);
2237
2316
 
2238
- // Real activity means the next chunk has started leave the auto-resume
2239
- // ("Resuming") state so the indicator settles back to "Thinking". The
2240
- // activity label itself is intentionally not surfaced: the running
2241
- // indicator stays a steady "Thinking" rather than flipping through
2242
- // transient step labels.
2317
+ // Real activity means the next chunk has started. Surface longer-lived
2318
+ // activity such as "Still generating image" so active-run reconnects do not
2319
+ // look like failures while the server is still making progress.
2243
2320
  useEffect(() => {
2244
2321
  const handler = (e: Event) => {
2245
2322
  const detail = (e as CustomEvent).detail as {
@@ -2248,13 +2325,43 @@ const AssistantChatInner = forwardRef<
2248
2325
  tabId?: string;
2249
2326
  };
2250
2327
  if (tabId && detail?.tabId && detail.tabId !== tabId) return;
2251
- if (typeof detail?.label === "string" && detail.label.trim()) {
2252
- setIsAutoResuming(false);
2328
+ const label =
2329
+ typeof detail?.label === "string" ? detail.label.trim() : "";
2330
+ if (!label) return;
2331
+ setIsAutoResuming(false);
2332
+ latestActivityLabelRef.current = label;
2333
+ // Already past the delay → keep the visible label current.
2334
+ if (activityLabelSurfacedRef.current) {
2335
+ setRunningActivityLabel(label);
2336
+ return;
2337
+ }
2338
+ // Not yet surfaced → arm a single reveal timer on the FIRST activity of
2339
+ // this in-flight period. A burst of quick steps that all finish (clear)
2340
+ // before the timer fires never surfaces anything, so normal fast turns
2341
+ // stay a steady "Thinking". A step still in-flight at the deadline reveals
2342
+ // the latest label so a slow operation reads as working, not hung.
2343
+ if (activityLabelTimerRef.current === null) {
2344
+ activityLabelTimerRef.current = window.setTimeout(() => {
2345
+ activityLabelTimerRef.current = null;
2346
+ activityLabelSurfacedRef.current = true;
2347
+ if (latestActivityLabelRef.current) {
2348
+ setRunningActivityLabel(latestActivityLabelRef.current);
2349
+ }
2350
+ }, ACTIVITY_LABEL_REVEAL_DELAY_MS);
2253
2351
  }
2254
2352
  };
2353
+ const clear = (e: Event) => {
2354
+ const detail = (e as CustomEvent).detail as { tabId?: string };
2355
+ if (tabId && detail?.tabId && detail.tabId !== tabId) return;
2356
+ resetRunningActivity();
2357
+ };
2255
2358
  window.addEventListener("agent-chat:activity", handler);
2256
- return () => window.removeEventListener("agent-chat:activity", handler);
2257
- }, [tabId]);
2359
+ window.addEventListener("agent-chat:activity-clear", clear);
2360
+ return () => {
2361
+ window.removeEventListener("agent-chat:activity", handler);
2362
+ window.removeEventListener("agent-chat:activity-clear", clear);
2363
+ };
2364
+ }, [resetRunningActivity, tabId]);
2258
2365
 
2259
2366
  // Show "Resuming…" during the adapter's auto-continuation window (the
2260
2367
  // ~250ms gap between the end of one serverless chunk and the POST for the
@@ -2274,8 +2381,9 @@ const AssistantChatInner = forwardRef<
2274
2381
  useEffect(() => {
2275
2382
  if (!isRunning) {
2276
2383
  setIsAutoResuming(false);
2384
+ resetRunningActivity();
2277
2385
  }
2278
- }, [isRunning]);
2386
+ }, [isRunning, resetRunningActivity]);
2279
2387
 
2280
2388
  // Auto-dequeue: when the agent is idle, send the next queued message. This
2281
2389
  // intentionally does not depend on observing the running -> idle transition:
@@ -3336,18 +3444,7 @@ const AssistantChatInner = forwardRef<
3336
3444
  <ReconnectStreamMessage content={reconnectContent} />
3337
3445
  )}
3338
3446
  {showRunningInUI && (
3339
- <RunningActivityStatus
3340
- label={
3341
- isReconnecting
3342
- ? "Reconnecting"
3343
- : isAutoResuming
3344
- ? "Resuming"
3345
- : // Keep a steady "Thinking" while the model works —
3346
- // never flip through transient activity labels
3347
- // (e.g. "Contacting model", "Preparing X action").
3348
- "Thinking"
3349
- }
3350
- />
3447
+ <RunningActivityStatus label={runningStatusLabel} />
3351
3448
  )}
3352
3449
  {queuedMessages.map((msg) => {
3353
3450
  const displayText = msg.text
@@ -352,6 +352,11 @@ export function processEvent(
352
352
  }
353
353
 
354
354
  if (ev.type === "text") {
355
+ // Visible output means the run is plainly not hanging — drop any running
356
+ // activity label so a transient "Contacting model" / "Still generating
357
+ // image" doesn't linger beside streamed text. Idempotent (clears once, then
358
+ // no-ops) so per-token text deltas stay cheap.
359
+ if (ev.text) dispatchActivityClear(tabId);
355
360
  const lastPart = content[content.length - 1];
356
361
  if (lastPart && lastPart.type === "text") {
357
362
  lastPart.text += ev.text ?? "";
@@ -492,6 +497,10 @@ export function processEvent(
492
497
  }),
493
498
  );
494
499
  }
500
+ // Clear any sticky running-activity label (e.g. "Still generating image"):
501
+ // the tool that was emitting activity heartbeats has finished, so the label
502
+ // must not linger while the model streams its follow-up text or reasoning.
503
+ dispatchActivityClear(tabId);
495
504
  // Use id-based lookup when available so parallel same-name tool calls
496
505
  // get their results correctly assigned; fall back to name-matching.
497
506
  const doneIdx = findPendingToolCallIndex(content, doneTool, ev.id);
@@ -15,8 +15,15 @@ Use this skill before calling `generate-image`, `generate-image-batch`, or
15
15
  `presetId`, and `media-type` references choose image generation versus video
16
16
  generation. Call `view-screen` when the user says "this library" or "this
17
17
  image" and you need fresh IDs. The image model may default from the composer
18
- image-model picker; all other generation settings should be explicit action
19
- args, preset values, or action schema defaults.
18
+ image-model picker.
19
+ - A tagged `@preset` (presetId) owns `aspectRatio`, `imageSize`, `model`,
20
+ `tier`, and `category`. When a preset is set, do NOT pass those args yourself —
21
+ leave them out so the preset's saved values are used. You cannot see the
22
+ preset's settings from the presetId, so passing your own guess silently
23
+ overrides the preset (this is the usual cause of a preset's aspect ratio being
24
+ ignored). Pass one of these args alongside a preset ONLY when the user
25
+ explicitly asks for a value that differs from the preset. When there is no
26
+ preset, set them explicitly or rely on the action schema defaults.
20
27
  - Use category-tagged references. Blog heroes should prefer `hero`; diagrams
21
28
  should prefer `diagram`; product imagery should include `product` and `logo`
22
29
  references.
@@ -47,6 +47,10 @@ prompt, aspectRatio }`.
47
47
  composer is empty. The image model is the only remaining composer-side
48
48
  default; the image-model picker writes `imageGenerationModel`, which image
49
49
  generation actions may use when `model` is omitted.
50
+ - For exact visible copy inside a generated image, pass `embeddedText` and
51
+ optional `textPlacement` to `generate-image` or each `generate-image-batch`
52
+ slot. Keep the general `prompt` for creative direction; the structured text
53
+ fields are what allow the pipeline to render copy instead of suppressing it.
50
54
  - `asset-variants` is the shared live generation tray state. New image
51
55
  candidates should appear there through `generate-image` or
52
56
  `generate-image-batch`; do not invent page-local progress surfaces.
@@ -0,0 +1,46 @@
1
+ import type { ActionRunContext } from "@agent-native/core/action";
2
+
3
+ const DEFAULT_TOOL_ACTIVITY_INTERVAL_MS = 8_000;
4
+
5
+ type ToolActivityOptions = {
6
+ label: string;
7
+ ongoingLabel?: string;
8
+ intervalMs?: number;
9
+ tool?: string;
10
+ };
11
+
12
+ function canEmitToolActivity(
13
+ context: ActionRunContext | undefined,
14
+ ): context is ActionRunContext & Required<Pick<ActionRunContext, "send">> {
15
+ return context?.caller === "tool" && typeof context.send === "function";
16
+ }
17
+
18
+ export async function withToolActivity<T>(
19
+ context: ActionRunContext | undefined,
20
+ options: ToolActivityOptions,
21
+ work: () => Promise<T>,
22
+ ): Promise<T> {
23
+ if (!canEmitToolActivity(context)) return work();
24
+
25
+ const tool = options.tool ?? context.actionName;
26
+ const sendActivity = (label: string) => {
27
+ context.send({
28
+ type: "activity",
29
+ label,
30
+ ...(tool ? { tool } : {}),
31
+ });
32
+ };
33
+
34
+ sendActivity(options.label);
35
+ const interval = setInterval(
36
+ () => sendActivity(options.ongoingLabel ?? options.label),
37
+ options.intervalMs ?? DEFAULT_TOOL_ACTIVITY_INTERVAL_MS,
38
+ );
39
+ interval.unref?.();
40
+
41
+ try {
42
+ return await work();
43
+ } finally {
44
+ clearInterval(interval);
45
+ }
46
+ }
@@ -18,7 +18,6 @@ import {
18
18
  STYLE_STRENGTHS,
19
19
  } from "../shared/api.js";
20
20
  import { requireGenerationSessionInLibrary } from "./_helpers.js";
21
- import { readImageModelDefault } from "./_image-model-default.js";
22
21
  import generateImage from "./generate-image.js";
23
22
  import { upsertVariantSlot } from "./variant-slots.js";
24
23
 
@@ -35,14 +34,36 @@ export default defineAction({
35
34
  "Brand kit/library ID. Pass the refId from a brand-kit @mention, or choose a kit from view-screen/list-libraries.",
36
35
  ),
37
36
  collectionId: z.string().optional(),
38
- presetId: z.string().optional(),
37
+ presetId: z
38
+ .string()
39
+ .optional()
40
+ .describe(
41
+ "Generation preset ID (from a @preset mention or list-generation-presets). The preset already defines aspectRatio, imageSize, model, tier, and category. When you set presetId, OMIT each slot's aspectRatio/imageSize and the top-level model/tier so the preset's values are used; only pass one when the user explicitly asks for a value that differs from the preset.",
42
+ ),
39
43
  sessionId: z.string().optional(),
40
44
  slots: z
41
45
  .array(
42
46
  z.object({
43
47
  slotId: z.string(),
44
48
  prompt: z.string().min(1),
45
- aspectRatio: z.enum(ASPECT_RATIOS).optional(),
49
+ embeddedText: z
50
+ .string()
51
+ .optional()
52
+ .describe(
53
+ "Exact words to render inside the image, spelled exactly. When set, the image is allowed to contain this text; when omitted, the image avoids embedded text.",
54
+ ),
55
+ textPlacement: z
56
+ .string()
57
+ .optional()
58
+ .describe(
59
+ "Where/how the embedded text should appear, e.g. 'centered headline', 'lower-left label'.",
60
+ ),
61
+ aspectRatio: z
62
+ .enum(ASPECT_RATIOS)
63
+ .optional()
64
+ .describe(
65
+ "Slot aspect ratio. When a presetId is set, omit this — the preset's aspect ratio is used unless the user explicitly asks for a different ratio for this slot.",
66
+ ),
46
67
  imageSize: z.enum(IMAGE_SIZES).optional(),
47
68
  categories: z.array(z.enum(IMAGE_CATEGORIES)).optional(),
48
69
  referenceAssetIds: z.array(z.string()).optional(),
@@ -78,7 +99,6 @@ export default defineAction({
78
99
  parallelSafe: true,
79
100
  timeoutMs: IMAGE_GENERATION_TOOL_TIMEOUT_MS,
80
101
  run: async ({ slots, ...inputBase }, context?: ActionRunContext) => {
81
- const imageModelDefault = await readImageModelDefault();
82
102
  const libraryId = inputBase.libraryId;
83
103
  if (!libraryId) {
84
104
  throw new Error(
@@ -88,7 +108,6 @@ export default defineAction({
88
108
  const base = {
89
109
  ...inputBase,
90
110
  libraryId,
91
- model: inputBase.model ?? imageModelDefault,
92
111
  };
93
112
  await assertAccess("asset-library", base.libraryId, "editor");
94
113
  if (base.sessionId) {
@@ -123,6 +142,8 @@ export default defineAction({
123
142
  presetId: base.presetId,
124
143
  sessionId: base.sessionId,
125
144
  prompt: slot.prompt,
145
+ embeddedText: slot.embeddedText,
146
+ textPlacement: slot.textPlacement,
126
147
  aspectRatio: slot.aspectRatio,
127
148
  imageSize: slot.imageSize,
128
149
  model: base.model,