@agent-native/core 0.68.0 → 0.68.1

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 (44) hide show
  1. package/corpus/core/CHANGELOG.md +8 -0
  2. package/corpus/core/package.json +1 -1
  3. package/corpus/core/src/agent/engine/builder-engine.ts +22 -3
  4. package/corpus/core/src/agent/engine/types.ts +1 -0
  5. package/corpus/core/src/agent/production-agent.ts +2 -0
  6. package/corpus/core/src/agent/types.ts +1 -0
  7. package/corpus/core/src/client/AssistantChat.tsx +45 -6
  8. package/corpus/core/src/client/active-run-state.ts +16 -0
  9. package/corpus/core/src/client/guided-questions.tsx +73 -17
  10. package/corpus/core/src/client/sse-event-processor.ts +9 -0
  11. package/corpus/core/src/templates/workspace-root/.env.example +4 -0
  12. package/corpus/templates/design/actions/show-design-questions.ts +15 -1
  13. package/corpus/templates/design/app/hooks/use-agent-generating.ts +47 -8
  14. package/corpus/templates/design/app/pages/DesignEditor.tsx +6 -0
  15. package/dist/agent/engine/builder-engine.d.ts.map +1 -1
  16. package/dist/agent/engine/builder-engine.js +21 -3
  17. package/dist/agent/engine/builder-engine.js.map +1 -1
  18. package/dist/agent/engine/types.d.ts +2 -0
  19. package/dist/agent/engine/types.d.ts.map +1 -1
  20. package/dist/agent/engine/types.js.map +1 -1
  21. package/dist/agent/production-agent.d.ts.map +1 -1
  22. package/dist/agent/production-agent.js +3 -0
  23. package/dist/agent/production-agent.js.map +1 -1
  24. package/dist/agent/types.d.ts +2 -0
  25. package/dist/agent/types.d.ts.map +1 -1
  26. package/dist/agent/types.js.map +1 -1
  27. package/dist/client/AssistantChat.d.ts.map +1 -1
  28. package/dist/client/AssistantChat.js +42 -7
  29. package/dist/client/AssistantChat.js.map +1 -1
  30. package/dist/client/active-run-state.d.ts +2 -0
  31. package/dist/client/active-run-state.d.ts.map +1 -1
  32. package/dist/client/active-run-state.js +10 -0
  33. package/dist/client/active-run-state.js.map +1 -1
  34. package/dist/client/guided-questions.d.ts +2 -0
  35. package/dist/client/guided-questions.d.ts.map +1 -1
  36. package/dist/client/guided-questions.js +52 -7
  37. package/dist/client/guided-questions.js.map +1 -1
  38. package/dist/client/sse-event-processor.d.ts +1 -1
  39. package/dist/client/sse-event-processor.d.ts.map +1 -1
  40. package/dist/client/sse-event-processor.js +7 -1
  41. package/dist/client/sse-event-processor.js.map +1 -1
  42. package/dist/templates/workspace-root/.env.example +4 -0
  43. package/package.json +1 -1
  44. package/src/templates/workspace-root/.env.example +4 -0
@@ -1,5 +1,13 @@
1
1
  # @agent-native/core
2
2
 
3
+ ## 0.68.1
4
+
5
+ ### Patch Changes
6
+
7
+ - 48356d7: Forward Builder gateway heartbeat JSONL frames through the engine and agent SSE stream so long upstream silences (adaptive thinking, TTFT) do not trip the client no-progress timeout.
8
+ - 48356d7: Fix guided question selection UX: preserve answers across poll refreshes, pause polling while a form is open, show clearer selected-state affordances (including `aria-pressed` on option buttons), and stop duplicate Explore/Decide injection in Design question flows.
9
+ - 48356d7: Resume page-load chat reconnect from the last seen run event seq instead of replaying the full SSE history, preventing duplicated assistant turns after refresh.
10
+
3
11
  ## 0.68.0
4
12
 
5
13
  ### Minor Changes
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-native/core",
3
- "version": "0.68.0",
3
+ "version": "0.68.1",
4
4
  "type": "module",
5
5
  "engines": {
6
6
  "node": ">=22"
@@ -58,6 +58,8 @@ export const BUILDER_SUPPORTED_MODELS = BUILDER_MODEL_CONFIG.supportedModels;
58
58
  // (Netlify synchronous Functions are 60s) hard-kill the invocation.
59
59
  const DEFAULT_BUILDER_GATEWAY_TIMEOUT_MS = 45_000;
60
60
  const MAX_BUILDER_GATEWAY_TIMEOUT_MS = 45_000;
61
+ /** Local ai-services has no serverless wall; allow longer streams in dev. */
62
+ const MAX_LOCAL_BUILDER_GATEWAY_TIMEOUT_MS = 180_000;
61
63
  const BUILDER_GATEWAY_NETWORK_ERROR_CODE = "builder_gateway_network_error";
62
64
 
63
65
  export const BUILDER_DEFAULT_MODEL = BUILDER_MODEL_CONFIG.defaultModel;
@@ -533,6 +535,10 @@ async function* parseJsonlStream(
533
535
  };
534
536
  break;
535
537
 
538
+ case "heartbeat":
539
+ yield { type: "gateway-heartbeat" };
540
+ break;
541
+
536
542
  case "tool-call": {
537
543
  flushPending();
538
544
  parts.push({
@@ -749,14 +755,27 @@ export function createBuilderEngine(
749
755
  return new BuilderEngine();
750
756
  }
751
757
 
758
+ function resolveMaxBuilderGatewayTimeoutMs(): number {
759
+ try {
760
+ const base = getBuilderGatewayBaseUrl();
761
+ if (/^https?:\/\/(localhost|127\.0\.0\.1)([:/]|$)/i.test(base)) {
762
+ return MAX_LOCAL_BUILDER_GATEWAY_TIMEOUT_MS;
763
+ }
764
+ } catch {
765
+ // ignore malformed override
766
+ }
767
+ return MAX_BUILDER_GATEWAY_TIMEOUT_MS;
768
+ }
769
+
752
770
  function getBuilderGatewayTimeoutMs(): number {
753
771
  const raw = process.env.AGENT_NATIVE_BUILDER_GATEWAY_TIMEOUT_MS;
754
- if (!raw) return DEFAULT_BUILDER_GATEWAY_TIMEOUT_MS;
772
+ const maxMs = resolveMaxBuilderGatewayTimeoutMs();
773
+ if (!raw) return maxMs;
755
774
  const parsed = Number(raw);
756
775
  if (!Number.isFinite(parsed) || parsed <= 0) {
757
- return DEFAULT_BUILDER_GATEWAY_TIMEOUT_MS;
776
+ return maxMs;
758
777
  }
759
- return Math.min(parsed, MAX_BUILDER_GATEWAY_TIMEOUT_MS);
778
+ return Math.min(parsed, maxMs);
760
779
  }
761
780
 
762
781
  function createGatewayAbortSignal(
@@ -141,6 +141,7 @@ export type EngineEvent =
141
141
  | { type: "thinking-delta"; text: string; signature?: string }
142
142
  | { type: "tool-input-start"; id?: string; name?: string }
143
143
  | { type: "tool-input-delta"; id?: string; name?: string; text?: string }
144
+ | { type: "gateway-heartbeat" }
144
145
  | { type: "tool-call"; id: string; name: string; input: unknown }
145
146
  | {
146
147
  type: "tool-call-error";
@@ -2573,6 +2573,8 @@ export async function runAgentLoop(opts: {
2573
2573
  event.name ??
2574
2574
  (event.id ? toolInputNames.get(event.id) : undefined);
2575
2575
  sendToolInputActivity(toolName);
2576
+ } else if (event.type === "gateway-heartbeat") {
2577
+ send({ type: "stream_keepalive" });
2576
2578
  } else if (event.type === "tool-call") {
2577
2579
  // The authoritative tool-call blocks arrive in assistant-content.
2578
2580
  } else if (event.type === "tool-call-error") {
@@ -169,6 +169,7 @@ export type AgentChatEvent =
169
169
  | { type: "text"; text: string }
170
170
  | { type: "thinking"; text: string }
171
171
  | { type: "activity"; label: string; tool?: string }
172
+ | { type: "stream_keepalive" }
172
173
  | { type: "tool_start"; tool: string; input: Record<string, string> }
173
174
  | {
174
175
  type: "tool_done";
@@ -50,7 +50,12 @@ import type {
50
50
  ChatThreadSnapshot,
51
51
  } from "./use-chat-threads.js";
52
52
  import { useAgentEngineConfigured } from "./use-agent-engine-configured.js";
53
- import { getActiveRun } from "./active-run-state.js";
53
+ import {
54
+ getActiveRun,
55
+ resolveReconnectAfterSeq,
56
+ setActiveRun,
57
+ updateActiveRunSeq,
58
+ } from "./active-run-state.js";
54
59
  import {
55
60
  AgentAutoContinueSignal,
56
61
  type ContentPart,
@@ -1247,6 +1252,7 @@ const AssistantChatInner = forwardRef<
1247
1252
  // When stop is clicked during reconnect, keep content visible (don't wipe it)
1248
1253
  const [reconnectFrozen, setReconnectFrozen] = useState(false);
1249
1254
  const reconnectRunIdRef = useRef<string | null>(null);
1255
+ const [reconnectAfterSeq, setReconnectAfterSeq] = useState(0);
1250
1256
  const reconnectAbortRef = useRef<AbortController | null>(null);
1251
1257
  // Nuclear stop: user clicked stop. Clears the stop button/indicator AND
1252
1258
  // lets new submissions go through immediately — prevents the "stuck
@@ -1456,6 +1462,13 @@ const AssistantChatInner = forwardRef<
1456
1462
  if (reconnectRunIdRef.current === runId) return true;
1457
1463
 
1458
1464
  reconnectRunIdRef.current = runId;
1465
+ const afterSeq = resolveReconnectAfterSeq(threadId, runId);
1466
+ setReconnectAfterSeq(afterSeq);
1467
+ setActiveRun({
1468
+ threadId,
1469
+ runId,
1470
+ lastSeq: afterSeq > 0 ? afterSeq - 1 : -1,
1471
+ });
1459
1472
  setIsReconnecting(true);
1460
1473
  setReconnectFrozen(false);
1461
1474
  setReconnectContent([]);
@@ -1498,9 +1511,16 @@ const AssistantChatInner = forwardRef<
1498
1511
  const streamReconnect = async () => {
1499
1512
  let noProgressDuringReconnect = false;
1500
1513
  let latestContent: ContentPart[] = [];
1514
+ const threadPollInterval =
1515
+ afterSeq > 0
1516
+ ? window.setInterval(() => {
1517
+ if (reconnectRunIdRef.current !== runId) return;
1518
+ void refreshThreadFromServer();
1519
+ }, 2000)
1520
+ : undefined;
1501
1521
  try {
1502
1522
  const sseRes = await fetch(
1503
- `${apiUrl}/runs/${encodeURIComponent(runId)}/events?after=0`,
1523
+ `${apiUrl}/runs/${encodeURIComponent(runId)}/events?after=${afterSeq}`,
1504
1524
  { signal: abortCtrl.signal },
1505
1525
  );
1506
1526
  if (sseRes.ok && sseRes.body) {
@@ -1511,6 +1531,7 @@ const AssistantChatInner = forwardRef<
1511
1531
  let rafPending = false;
1512
1532
  let latestSnapshot: ContentPart[] = [];
1513
1533
  const scheduleUpdate = (snapshot: ContentPart[]) => {
1534
+ if (afterSeq > 0) return;
1514
1535
  latestSnapshot = snapshot;
1515
1536
  if (rafPending) return;
1516
1537
  rafPending = true;
@@ -1526,8 +1547,11 @@ const AssistantChatInner = forwardRef<
1526
1547
  toolCallCounter,
1527
1548
  tabId,
1528
1549
  scheduleUpdate,
1550
+ (seq) => updateActiveRunSeq(seq),
1529
1551
  );
1530
- setReconnectContent([...content]);
1552
+ if (afterSeq === 0) {
1553
+ setReconnectContent([...content]);
1554
+ }
1531
1555
  }
1532
1556
  } catch (err) {
1533
1557
  if (
@@ -1543,6 +1567,9 @@ const AssistantChatInner = forwardRef<
1543
1567
  noProgressDuringReconnect = true;
1544
1568
  }
1545
1569
  } finally {
1570
+ if (threadPollInterval !== undefined) {
1571
+ window.clearInterval(threadPollInterval);
1572
+ }
1546
1573
  clearInterval(watchdog);
1547
1574
  clearTimeout(maxReconnectTimer);
1548
1575
  }
@@ -1570,9 +1597,17 @@ const AssistantChatInner = forwardRef<
1570
1597
  } catch {
1571
1598
  // Best effort — the important part is unwinding the UI.
1572
1599
  }
1573
- settleInterruptedToolCalls(latestContent);
1574
- setReconnectContent([...latestContent]);
1575
- setReconnectFrozen(latestContent.length > 0);
1600
+ if (afterSeq > 0) {
1601
+ // Tail-resume only replays new events; never freeze that slice as a
1602
+ // complete assistant turn — the server thread is authoritative.
1603
+ await refreshThreadFromServer();
1604
+ setReconnectContent([]);
1605
+ setReconnectFrozen(false);
1606
+ } else {
1607
+ settleInterruptedToolCalls(latestContent);
1608
+ setReconnectContent([...latestContent]);
1609
+ setReconnectFrozen(latestContent.length > 0);
1610
+ }
1576
1611
  setRunErrorInfo({
1577
1612
  message:
1578
1613
  "The previous agent run stopped producing visible progress while reconnecting, so it was stopped before it could keep looping.",
@@ -1584,6 +1619,7 @@ const AssistantChatInner = forwardRef<
1584
1619
  reconnectAbortRef.current = null;
1585
1620
  setIsReconnecting(false);
1586
1621
  reconnectRunIdRef.current = null;
1622
+ setReconnectAfterSeq(0);
1587
1623
  window.dispatchEvent(
1588
1624
  new CustomEvent("agentNative.chatRunning", {
1589
1625
  detail: { isRunning: false, tabId: tabId || threadId },
@@ -1610,6 +1646,7 @@ const AssistantChatInner = forwardRef<
1610
1646
  reconnectAbortRef.current = null;
1611
1647
  setIsReconnecting(false);
1612
1648
  reconnectRunIdRef.current = null;
1649
+ setReconnectAfterSeq(0);
1613
1650
  window.dispatchEvent(
1614
1651
  new CustomEvent("agentNative.chatRunning", {
1615
1652
  detail: { isRunning: false, tabId: tabId || threadId },
@@ -2360,6 +2397,7 @@ const AssistantChatInner = forwardRef<
2360
2397
  reconnectAbortRef.current?.abort();
2361
2398
  reconnectAbortRef.current = null;
2362
2399
  reconnectRunIdRef.current = null;
2400
+ setReconnectAfterSeq(0);
2363
2401
  setIsReconnecting(false);
2364
2402
  setReconnectFrozen(reconnectContent.length > 0);
2365
2403
  }
@@ -3159,6 +3197,7 @@ const AssistantChatInner = forwardRef<
3159
3197
  />
3160
3198
  )}
3161
3199
  {(isReconnecting || reconnectFrozen) &&
3200
+ reconnectAfterSeq === 0 &&
3162
3201
  reconnectContent.length > 0 && (
3163
3202
  <ReconnectStreamMessage content={reconnectContent} />
3164
3203
  )}
@@ -35,3 +35,19 @@ export function clearActiveRun(): void {
35
35
  sessionStorage.removeItem(STORAGE_KEY);
36
36
  } catch {}
37
37
  }
38
+
39
+ /** Resume reconnect SSE after the last seen event (0 = replay from the start). */
40
+ export function resolveReconnectAfterSeq(
41
+ threadId: string,
42
+ runId: string,
43
+ ): number {
44
+ const stored = getActiveRun();
45
+ if (
46
+ stored?.threadId === threadId &&
47
+ stored?.runId === runId &&
48
+ Number.isFinite(stored.lastSeq)
49
+ ) {
50
+ return stored.lastSeq + 1;
51
+ }
52
+ return 0;
53
+ }
@@ -357,6 +357,37 @@ function optionKey(option: GuidedQuestionOption): string {
357
357
  return `${option.value.toLowerCase()}::${option.label.toLowerCase()}`;
358
358
  }
359
359
 
360
+ /** Stable content hash so poll refreshes do not reset in-progress answers. */
361
+ export function guidedQuestionsFingerprint(
362
+ questions: GuidedQuestion[],
363
+ ): string {
364
+ return JSON.stringify(
365
+ questions.map((question) => ({
366
+ id: question.id,
367
+ type: question.type,
368
+ header: question.header ?? null,
369
+ question: question.question,
370
+ description: question.description ?? null,
371
+ multiSelect: question.multiSelect ?? false,
372
+ required: question.required ?? false,
373
+ allowOther: question.allowOther ?? null,
374
+ includeExplore: question.includeExplore ?? null,
375
+ includeDecide: question.includeDecide ?? null,
376
+ min: question.min ?? null,
377
+ max: question.max ?? null,
378
+ step: question.step ?? null,
379
+ placeholder: question.placeholder ?? null,
380
+ options: (question.options ?? question.choices ?? []).map((option) => ({
381
+ label: option.label,
382
+ value: option.value,
383
+ color: option.color ?? null,
384
+ description: option.description ?? null,
385
+ recommended: option.recommended ?? false,
386
+ })),
387
+ })),
388
+ );
389
+ }
390
+
360
391
  function withDefaultOptions(question: GuidedQuestion): GuidedQuestionOption[] {
361
392
  const base = question.options ?? question.choices ?? [];
362
393
  const seen = new Set(base.map(optionKey));
@@ -403,10 +434,14 @@ export function GuidedQuestionFlow({
403
434
  className,
404
435
  }: GuidedQuestionFlowProps) {
405
436
  const [answers, setAnswers] = useState<GuidedQuestionAnswers>({});
437
+ const questionsFingerprint = useMemo(
438
+ () => guidedQuestionsFingerprint(questions),
439
+ [questions],
440
+ );
406
441
 
407
442
  useEffect(() => {
408
443
  setAnswers({});
409
- }, [questions]);
444
+ }, [questionsFingerprint]);
410
445
 
411
446
  const setAnswer = useCallback((id: string, value: unknown) => {
412
447
  setAnswers((prev) => ({ ...prev, [id]: value }));
@@ -663,23 +698,26 @@ function OptionButton({
663
698
  <button
664
699
  type="button"
665
700
  onClick={onClick}
701
+ aria-pressed={selected}
666
702
  className={cn(
667
703
  "group flex min-h-[56px] cursor-pointer items-start gap-2 rounded-md border px-3 py-2 text-left transition-colors",
668
704
  selected
669
- ? "border-primary bg-primary/10 text-primary"
705
+ ? "border-primary bg-primary/10 text-foreground ring-2 ring-primary/25"
670
706
  : "border-border bg-muted/30 text-muted-foreground hover:border-muted-foreground/50 hover:bg-muted/45 hover:text-foreground",
671
707
  )}
672
708
  >
673
- {multiSelect && (
674
- <span
675
- className={cn(
676
- "mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded border",
677
- selected ? "border-primary bg-primary text-primary-foreground" : "",
678
- )}
679
- >
680
- {selected && <IconCheck className="h-3 w-3" />}
681
- </span>
682
- )}
709
+ <span
710
+ className={cn(
711
+ "mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center border",
712
+ multiSelect ? "rounded-sm" : "rounded-full",
713
+ selected
714
+ ? "border-primary bg-primary text-primary-foreground"
715
+ : "border-border bg-background",
716
+ )}
717
+ aria-hidden
718
+ >
719
+ {selected && <IconCheck className="h-3 w-3" />}
720
+ </span>
683
721
  <span className="min-w-0 flex-1">
684
722
  <span className="flex flex-wrap items-center gap-1.5 text-sm font-medium leading-5">
685
723
  {option.label}
@@ -940,6 +978,17 @@ export function useGuidedQuestionFlow({
940
978
  [queryKey, normalizedBrowserTabId],
941
979
  );
942
980
 
981
+ const resolvedRefetchInterval =
982
+ refetchInterval === false
983
+ ? false
984
+ : (query: { state: { data?: GuidedQuestionPayload | null } }) => {
985
+ const activeQuestions = query.state.data?.questions;
986
+ if (Array.isArray(activeQuestions) && activeQuestions.length > 0) {
987
+ return false;
988
+ }
989
+ return refetchInterval;
990
+ };
991
+
943
992
  const { data } = useQuery({
944
993
  queryKey: resolvedQueryKey,
945
994
  queryFn: async () => {
@@ -951,9 +1000,7 @@ export function useGuidedQuestionFlow({
951
1000
  try {
952
1001
  const parsed = JSON.parse(text);
953
1002
  if (Array.isArray(parsed?.questions) && parsed.questions.length > 0) {
954
- return { ...parsed, _ts: Date.now() } as GuidedQuestionPayload & {
955
- _ts: number;
956
- };
1003
+ return parsed as GuidedQuestionPayload;
957
1004
  }
958
1005
  } catch {
959
1006
  return null;
@@ -967,13 +1014,22 @@ export function useGuidedQuestionFlow({
967
1014
  (await read(stateKey))
968
1015
  );
969
1016
  },
970
- refetchInterval,
1017
+ refetchInterval: resolvedRefetchInterval,
971
1018
  structuralSharing: false,
972
1019
  });
973
1020
 
974
1021
  useEffect(() => {
975
1022
  if (Array.isArray(data?.questions) && data.questions.length > 0) {
976
- setPayload(data);
1023
+ setPayload((prev) => {
1024
+ if (
1025
+ prev &&
1026
+ guidedQuestionsFingerprint(prev.questions) ===
1027
+ guidedQuestionsFingerprint(data.questions)
1028
+ ) {
1029
+ return prev;
1030
+ }
1031
+ return data;
1032
+ });
977
1033
  } else {
978
1034
  setPayload(null);
979
1035
  }
@@ -357,6 +357,10 @@ export function processEvent(
357
357
  };
358
358
  }
359
359
 
360
+ if (ev.type === "stream_keepalive") {
361
+ return { action: "continue" };
362
+ }
363
+
360
364
  if (ev.type === "activity") {
361
365
  const tool = ev.tool?.trim() || undefined;
362
366
  const label = humanizeToolLabelText(ev.label ?? "Working", tool);
@@ -868,6 +872,7 @@ export async function readSSEStreamRaw(
868
872
  toolCallCounter: { value: number },
869
873
  tabId: string | undefined,
870
874
  onUpdate: (content: ContentPart[]) => void,
875
+ onSeq?: (seq: number) => void,
871
876
  ): Promise<void> {
872
877
  const reader = body.getReader();
873
878
  const decoder = new TextDecoder();
@@ -907,6 +912,10 @@ export async function readSSEStreamRaw(
907
912
  sawDataEvent = true;
908
913
  lastMeaningfulEventAt = Date.now();
909
914
 
915
+ if (ev.seq !== undefined && onSeq) {
916
+ onSeq(ev.seq);
917
+ }
918
+
910
919
  const { action, autoContinue } = processEvent(
911
920
  ev,
912
921
  content,
@@ -36,6 +36,10 @@ BUILDER_PRIVATE_KEY=
36
36
  BUILDER_PUBLIC_KEY=
37
37
  BUILDER_USER_ID=
38
38
 
39
+ # Local ai-services LLM gateway. Leave unset to use the hosted Builder gateway.
40
+ # ai-services listens on API_PORT (default 8080): /agent-native/gateway/v1/messages
41
+ BUILDER_GATEWAY_BASE_URL=
42
+
39
43
  # Builder app creation / branching. In local dev these can live in .env.
40
44
  # In production, configure deploy env vars instead; production app creation
41
45
  # never writes credentials or project IDs to the filesystem.
@@ -47,6 +47,18 @@ const questionSchema = z.object({
47
47
  includeDecide: z.boolean().optional(),
48
48
  });
49
49
 
50
+ function normalizeDesignQuestions(
51
+ questions: z.infer<typeof questionSchema>[],
52
+ ): z.infer<typeof questionSchema>[] {
53
+ return questions.map((question) => ({
54
+ ...question,
55
+ // The agent supplies Explore/Decide choices explicitly when needed.
56
+ // Default injection duplicates cards on every question in the form.
57
+ includeExplore: question.includeExplore ?? false,
58
+ includeDecide: question.includeDecide ?? false,
59
+ }));
60
+ }
61
+
50
62
  export default defineAction({
51
63
  description:
52
64
  "Show a Claude Design-style question form in the Design editor before " +
@@ -95,6 +107,8 @@ export default defineAction({
95
107
  }) => {
96
108
  await assertAccess("design", designId, "editor");
97
109
 
110
+ const normalizedQuestions = normalizeDesignQuestions(questions);
111
+
98
112
  await writeAppState(designQuestionsStateKey(designId), {
99
113
  designId,
100
114
  title: title ?? "Quick questions before I design",
@@ -103,7 +117,7 @@ export default defineAction({
103
117
  "Pick what matters. Use Other for specifics, or let the agent decide.",
104
118
  skipLabel: skipLabel ?? "Decide for me",
105
119
  submitLabel: submitLabel ?? "Continue",
106
- questions,
120
+ questions: normalizedQuestions,
107
121
  });
108
122
 
109
123
  return {
@@ -8,10 +8,16 @@ import {
8
8
  // legitimately take several minutes, so avoid treating normal latency as
9
9
  // failure.
10
10
  const GENERATION_ORPHAN_TIMEOUT_MS = 30 * 60_000;
11
+ // Auto-continue briefly sets isRunning=false between gateway continuations.
12
+ // Debounce stop handling so we do not flash "generation complete" mid-turn.
13
+ const CHAT_STOP_DEBOUNCE_MS = 4_000;
11
14
 
12
15
  interface UseAgentGeneratingOptions {
13
16
  onComplete?: (tabId: string | null) => void;
14
17
  onStale?: (tabId: string | null) => void;
18
+ /** When chat starts on a tab we did not open, adopt it if this returns true. */
19
+ shouldAdoptRunningTab?: () => boolean;
20
+ onAdoptRunningTab?: (tabId: string) => void;
15
21
  }
16
22
 
17
23
  /**
@@ -23,9 +29,17 @@ export function useAgentGenerating(options: UseAgentGeneratingOptions = {}) {
23
29
  const [generating, setGenerating] = useState(false);
24
30
  const activeTabIdRef = useRef<string | null>(null);
25
31
  const timeoutRef = useRef<number | null>(null);
32
+ const stopDebounceRef = useRef<number | null>(null);
26
33
  const callbacksRef = useRef(options);
27
34
  callbacksRef.current = options;
28
35
 
36
+ const clearStopDebounce = useCallback(() => {
37
+ if (stopDebounceRef.current) {
38
+ window.clearTimeout(stopDebounceRef.current);
39
+ stopDebounceRef.current = null;
40
+ }
41
+ }, []);
42
+
29
43
  const clearGenerationTimeout = useCallback(() => {
30
44
  if (timeoutRef.current) {
31
45
  window.clearTimeout(timeoutRef.current);
@@ -35,9 +49,10 @@ export function useAgentGenerating(options: UseAgentGeneratingOptions = {}) {
35
49
 
36
50
  const reset = useCallback(() => {
37
51
  clearGenerationTimeout();
52
+ clearStopDebounce();
38
53
  activeTabIdRef.current = null;
39
54
  setGenerating(false);
40
- }, [clearGenerationTimeout]);
55
+ }, [clearGenerationTimeout, clearStopDebounce]);
41
56
 
42
57
  const startGenerationTimeout = useCallback(
43
58
  (tabId: string | null) => {
@@ -68,25 +83,49 @@ export function useAgentGenerating(options: UseAgentGeneratingOptions = {}) {
68
83
  const eventTabId =
69
84
  typeof detail.tabId === "string" ? detail.tabId : null;
70
85
 
71
- if (!activeTabIdRef.current && detail.isRunning) return;
72
- if (eventTabId && eventTabId !== activeTabIdRef.current) return;
86
+ if (!activeTabIdRef.current && detail.isRunning) {
87
+ if (eventTabId && callbacksRef.current.shouldAdoptRunningTab?.()) {
88
+ activeTabIdRef.current = eventTabId;
89
+ callbacksRef.current.onAdoptRunningTab?.(eventTabId);
90
+ setGenerating(true);
91
+ startGenerationTimeout(eventTabId);
92
+ return;
93
+ }
94
+ return;
95
+ }
96
+ if (eventTabId && eventTabId !== activeTabIdRef.current) {
97
+ if (!detail.isRunning && !activeTabIdRef.current) {
98
+ return;
99
+ }
100
+ return;
101
+ }
73
102
 
74
103
  if (!detail.isRunning) {
75
- callbacksRef.current.onComplete?.(activeTabIdRef.current);
76
- reset();
104
+ clearStopDebounce();
105
+ const tabId = activeTabIdRef.current;
106
+ stopDebounceRef.current = window.setTimeout(() => {
107
+ stopDebounceRef.current = null;
108
+ if (activeTabIdRef.current !== tabId) return;
109
+ callbacksRef.current.onComplete?.(tabId);
110
+ reset();
111
+ }, CHAT_STOP_DEBOUNCE_MS);
77
112
  return;
78
113
  }
114
+ clearStopDebounce();
79
115
  setGenerating(true);
80
116
  startGenerationTimeout(activeTabIdRef.current);
81
117
  }
82
118
  };
83
119
  window.addEventListener("agentNative.chatRunning", handler);
84
120
  return () => window.removeEventListener("agentNative.chatRunning", handler);
85
- }, [reset, startGenerationTimeout]);
121
+ }, [clearStopDebounce, reset, startGenerationTimeout]);
86
122
 
87
123
  useEffect(() => {
88
- return () => clearGenerationTimeout();
89
- }, [clearGenerationTimeout]);
124
+ return () => {
125
+ clearGenerationTimeout();
126
+ clearStopDebounce();
127
+ };
128
+ }, [clearGenerationTimeout, clearStopDebounce]);
90
129
 
91
130
  const submit = useCallback(
92
131
  (
@@ -428,6 +428,12 @@ export default function DesignEditor() {
428
428
  } = useAgentGenerating({
429
429
  onComplete: handleGenerationComplete,
430
430
  onStale: markGenerationStale,
431
+ shouldAdoptRunningTab: () =>
432
+ Boolean(id) && !generationOutputReadyRef.current,
433
+ onAdoptRunningTab: (tabId) => {
434
+ setGenerationChatTabId(tabId);
435
+ setHasPendingGeneration(true);
436
+ },
431
437
  });
432
438
  const handleQuestionFlowContinue = useCallback(
433
439
  (runTabId: string) => {
@@ -1 +1 @@
1
- {"version":3,"file":"builder-engine.d.ts","sourceRoot":"","sources":["../../../src/agent/engine/builder-engine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EACV,WAAW,EACX,kBAAkB,EAInB,MAAM,YAAY,CAAC;AAwBpB,eAAO,MAAM,oBAAoB,EAAE,kBAMlC,CAAC;AAEF,eAAO,MAAM,wBAAwB,wOAAuC,CAAC;AAS7E,eAAO,MAAM,qBAAqB,qBAAoC,CAAC;AA2qBvE,wBAAgB,mBAAmB,CACjC,OAAO,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,GACpC,WAAW,CAEb"}
1
+ {"version":3,"file":"builder-engine.d.ts","sourceRoot":"","sources":["../../../src/agent/engine/builder-engine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;GAaG;AAEH,OAAO,KAAK,EACV,WAAW,EACX,kBAAkB,EAInB,MAAM,YAAY,CAAC;AAwBpB,eAAO,MAAM,oBAAoB,EAAE,kBAMlC,CAAC;AAEF,eAAO,MAAM,wBAAwB,wOAAuC,CAAC;AAW7E,eAAO,MAAM,qBAAqB,qBAAoC,CAAC;AA+qBvE,wBAAgB,mBAAmB,CACjC,OAAO,GAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAM,GACpC,WAAW,CAEb"}
@@ -33,6 +33,8 @@ export const BUILDER_SUPPORTED_MODELS = BUILDER_MODEL_CONFIG.supportedModels;
33
33
  // (Netlify synchronous Functions are 60s) hard-kill the invocation.
34
34
  const DEFAULT_BUILDER_GATEWAY_TIMEOUT_MS = 45_000;
35
35
  const MAX_BUILDER_GATEWAY_TIMEOUT_MS = 45_000;
36
+ /** Local ai-services has no serverless wall; allow longer streams in dev. */
37
+ const MAX_LOCAL_BUILDER_GATEWAY_TIMEOUT_MS = 180_000;
36
38
  const BUILDER_GATEWAY_NETWORK_ERROR_CODE = "builder_gateway_network_error";
37
39
  export const BUILDER_DEFAULT_MODEL = BUILDER_MODEL_CONFIG.defaultModel;
38
40
  /**
@@ -442,6 +444,9 @@ async function* parseJsonlStream(reader, model, captureContext = {}) {
442
444
  : "",
443
445
  };
444
446
  break;
447
+ case "heartbeat":
448
+ yield { type: "gateway-heartbeat" };
449
+ break;
445
450
  case "tool-call": {
446
451
  flushPending();
447
452
  parts.push({
@@ -640,15 +645,28 @@ function htmlToText(html) {
640
645
  export function createBuilderEngine(_config = {}) {
641
646
  return new BuilderEngine();
642
647
  }
648
+ function resolveMaxBuilderGatewayTimeoutMs() {
649
+ try {
650
+ const base = getBuilderGatewayBaseUrl();
651
+ if (/^https?:\/\/(localhost|127\.0\.0\.1)([:/]|$)/i.test(base)) {
652
+ return MAX_LOCAL_BUILDER_GATEWAY_TIMEOUT_MS;
653
+ }
654
+ }
655
+ catch {
656
+ // ignore malformed override
657
+ }
658
+ return MAX_BUILDER_GATEWAY_TIMEOUT_MS;
659
+ }
643
660
  function getBuilderGatewayTimeoutMs() {
644
661
  const raw = process.env.AGENT_NATIVE_BUILDER_GATEWAY_TIMEOUT_MS;
662
+ const maxMs = resolveMaxBuilderGatewayTimeoutMs();
645
663
  if (!raw)
646
- return DEFAULT_BUILDER_GATEWAY_TIMEOUT_MS;
664
+ return maxMs;
647
665
  const parsed = Number(raw);
648
666
  if (!Number.isFinite(parsed) || parsed <= 0) {
649
- return DEFAULT_BUILDER_GATEWAY_TIMEOUT_MS;
667
+ return maxMs;
650
668
  }
651
- return Math.min(parsed, MAX_BUILDER_GATEWAY_TIMEOUT_MS);
669
+ return Math.min(parsed, maxMs);
652
670
  }
653
671
  function createGatewayAbortSignal(parentSignal, timeoutMs) {
654
672
  const controller = new AbortController();