@asteby/metacore-runtime-react 37.6.0 → 38.1.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.
package/dist/index.js CHANGED
@@ -85,3 +85,4 @@ export { StatWidget, BarWidget, LineWidget, AreaWidget, PieWidget, DonutWidget,
85
85
  export { WidgetRenderer, WidgetSkeleton, SIZE_SPAN, SIZE_CLASS, } from './widgets/widget-renderer';
86
86
  export { WidgetCard, DeltaChip, WidgetEmpty, WidgetError, } from './widgets/widget-card';
87
87
  export { formatWidgetValue, formatAxisTick, formatDelta, accentClasses, paletteColor, CHART_PALETTE, } from './widgets/widget-format';
88
+ export { AssistInterview, AssistCardView } from './assist-interview';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@asteby/metacore-runtime-react",
3
- "version": "37.6.0",
3
+ "version": "38.1.0",
4
4
  "description": "React runtime for metacore hosts — renders addon contributions dynamically",
5
5
  "repository": {
6
6
  "type": "git",
@@ -38,7 +38,7 @@
38
38
  "sonner": ">=1.7",
39
39
  "zustand": ">=5",
40
40
  "@asteby/metacore-sdk": "^3.7.0",
41
- "@asteby/metacore-ui": "^2.17.3"
41
+ "@asteby/metacore-ui": "^2.18.0"
42
42
  },
43
43
  "peerDependenciesMeta": {
44
44
  "@tanstack/react-router": {
@@ -68,7 +68,7 @@
68
68
  "vitest": "^4.0.0",
69
69
  "zustand": "^5.0.0",
70
70
  "@asteby/metacore-sdk": "3.7.0",
71
- "@asteby/metacore-ui": "2.17.3"
71
+ "@asteby/metacore-ui": "2.18.0"
72
72
  },
73
73
  "scripts": {
74
74
  "build": "tsc -p tsconfig.json",
@@ -38,6 +38,7 @@ import {
38
38
  Switch,
39
39
  } from '@asteby/metacore-ui/primitives'
40
40
  import { Loader2 } from 'lucide-react'
41
+ import { ProcessStepper } from '@asteby/metacore-ui/wizard'
41
42
  import { toast } from 'sonner'
42
43
  import { toastServerError, toastServerSuccess, extractFieldErrors, localizeFieldErrorMap } from './server-error'
43
44
  import { useBranchCreateGate } from './branch-create-gate'
@@ -994,26 +995,16 @@ function WizardActionModal({ open, onOpenChange, action, model, record, endpoint
994
995
  <DynamicIcon name={action.icon} className="h-5 w-5" />
995
996
  {tl(action.label)}
996
997
  </DialogTitle>
997
- {/* Progress/step bar: one segment per step. The current and
998
- completed segments are filled; a numbered marker + the
999
- current step's title tell the user where they are. Inline
1000
- styles for the fill color guarantee it shows even if the
1001
- host's Tailwind scan drops an arbitrary class. */}
1002
- <div className="pt-2">
1003
- <div className="flex items-center gap-1.5" role="list" aria-label="progress">
1004
- {steps.map((s, i) => (
1005
- <div
1006
- key={i}
1007
- role="listitem"
1008
- aria-current={i === stepIndex ? 'step' : undefined}
1009
- className="h-1.5 flex-1 rounded-full"
1010
- style={{
1011
- backgroundColor: i <= stepIndex ? (action.color || 'hsl(var(--primary))') : 'hsl(var(--muted))',
1012
- }}
1013
- />
1014
- ))}
1015
- </div>
1016
- <DialogDescription className="pt-2">
998
+ {/* Step indicator: the platform ProcessStepper (same as the
999
+ declarative form_layout wizards and the process modals) +
1000
+ "Paso i/n · título" and the step's description. */}
1001
+ <div className="pt-1">
1002
+ <ProcessStepper
1003
+ steps={steps.map((s, i) => ({ key: String(i), label: s.title ? tl(s.title) : `${t('common.step', { defaultValue: 'Paso' })} ${i + 1}` }))}
1004
+ activeIndex={stepIndex}
1005
+ onStepClick={i => setStepIndex(i)}
1006
+ />
1007
+ <DialogDescription className="pt-3">
1017
1008
  {t('common.step', { defaultValue: 'Paso' })} {stepIndex + 1}/{steps.length}
1018
1009
  {step?.title ? ` · ${tl(step.title)}` : ''}
1019
1010
  </DialogDescription>
@@ -0,0 +1,411 @@
1
+ // AssistInterview — the conversational AI step of a declarative wizard
2
+ // (`form_layout.sections[].assist`, kernel v0.141.0).
3
+ //
4
+ // Link-onboarding style: ONE question at a time as a big typed headline,
5
+ // progress beats on top, a single answer input (or quick-reply chips) at the
6
+ // bottom, and the assistant's work (fetching a website, thinking…) shown as
7
+ // live status. No form inputs: the provider asks for whatever it needs, does
8
+ // the work, previews what it found and returns the fields the form takes.
9
+ // The panel is generic — it renders the turns the provider sends and knows
10
+ // nothing about brands. Protocol (host `/assist/:provider/…`):
11
+ //
12
+ // POST /assist/:provider/sessions { input } → Session
13
+ // GET /assist/:provider/sessions/:id → Session (poll while working)
14
+ // POST /assist/:provider/sessions/:id/reply { key, value } → Session
15
+ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
16
+ import { Button, Input } from '@asteby/metacore-ui/primitives'
17
+ import { ArrowRight, Check, CheckCircle2, Loader2, RotateCcw, Sparkles, XCircle } from 'lucide-react'
18
+ import { useApi } from './api-context'
19
+ import type { FormAssist } from './form-layout'
20
+
21
+ export interface AssistProgressStep {
22
+ label: string
23
+ state: 'pending' | 'running' | 'done' | 'error'
24
+ detail?: string
25
+ }
26
+
27
+ export interface AssistQuestion {
28
+ key: string
29
+ prompt: string
30
+ kind?: 'text' | 'choice' | 'confirm'
31
+ options?: { value: string; label: string }[]
32
+ placeholder?: string
33
+ /** Prefilled suggestion the user can accept with Enter. */
34
+ suggestion?: string
35
+ }
36
+
37
+ export interface AssistCard {
38
+ kind: string
39
+ title?: string
40
+ logo?: string
41
+ logo_light?: string
42
+ logo_dark?: string
43
+ colors?: Record<string, string>
44
+ lines?: { label: string; value: string }[]
45
+ }
46
+
47
+ export interface AssistTurn {
48
+ id: string
49
+ type: 'message' | 'progress' | 'card' | 'question' | 'answer' | 'result'
50
+ text?: string
51
+ steps?: AssistProgressStep[]
52
+ card?: AssistCard
53
+ question?: AssistQuestion
54
+ fields?: Record<string, any>
55
+ }
56
+
57
+ export interface AssistSession {
58
+ id: string
59
+ working: boolean
60
+ done: boolean
61
+ turns: AssistTurn[]
62
+ result?: Record<string, any>
63
+ error?: string
64
+ /** Optional: how many beats the provider expects (progress dots). */
65
+ beats?: number
66
+ }
67
+
68
+ export interface AssistInterviewProps {
69
+ assist: FormAssist
70
+ /** Live form values; the declared `input` fields are sent on start. */
71
+ values: Record<string, any>
72
+ /** Called with the provider's result fields (limited to `output`). */
73
+ onApply: (fields: Record<string, any>) => void
74
+ /** Start immediately (the step is the chat itself). Default true. */
75
+ autoStart?: boolean
76
+ /** Small uppercase label above the headline (e.g. the step title). */
77
+ eyebrow?: string
78
+ className?: string
79
+ }
80
+
81
+ const POLL_MS = 800
82
+ const THINKING_HINTS = ['Un momento…', 'Sigo trabajando…', 'Casi listo…']
83
+
84
+ function unwrap(res: any): AssistSession {
85
+ const d = res?.data?.data ?? res?.data ?? res
86
+ return d as AssistSession
87
+ }
88
+
89
+ function useTypewriter(text: string) {
90
+ const [n, setN] = useState(0)
91
+ const reduce = typeof window !== 'undefined' && !!window.matchMedia?.('(prefers-reduced-motion: reduce)').matches
92
+ useEffect(() => {
93
+ setN(reduce ? text.length : 0)
94
+ }, [text, reduce])
95
+ useEffect(() => {
96
+ if (n >= text.length) return
97
+ const t = setTimeout(() => setN(v => Math.min(text.length, v + 3)), 22)
98
+ return () => clearTimeout(t)
99
+ }, [n, text])
100
+ return { shown: text.slice(0, n), typing: n < text.length }
101
+ }
102
+
103
+ export function AssistInterview({ assist, values, onApply, autoStart = true, eyebrow, className }: AssistInterviewProps) {
104
+ const api = useApi()
105
+ const [session, setSession] = useState<AssistSession | null>(null)
106
+ const [starting, setStarting] = useState(false)
107
+ const [answer, setAnswer] = useState('')
108
+ const [applied, setApplied] = useState(false)
109
+ const [error, setError] = useState<string | null>(null)
110
+ const [hintIdx, setHintIdx] = useState(0)
111
+ const inputRef = useRef<HTMLInputElement | null>(null)
112
+ // Answers given so far: if the host loses the session (restart, TTL) we
113
+ // start a new one and replay them, so the user never types twice.
114
+ const answersRef = useRef<{ key: string; value: any }[]>([])
115
+ const base = `/assist/${encodeURIComponent(assist.provider)}/sessions`
116
+
117
+ const inputPayload = useMemo(() => {
118
+ const out: Record<string, any> = {}
119
+ for (const k of assist.input ?? []) out[k] = values?.[k]
120
+ return out
121
+ }, [assist.input, values])
122
+
123
+ const start = useCallback(async () => {
124
+ if (starting) return
125
+ setStarting(true)
126
+ setError(null)
127
+ setApplied(false)
128
+ answersRef.current = []
129
+ try {
130
+ const res = await api.post(base, { input: inputPayload })
131
+ setSession(unwrap(res))
132
+ } catch (e: any) {
133
+ setError(e?.response?.data?.message || e?.message || 'No se pudo iniciar el asistente')
134
+ } finally {
135
+ setStarting(false)
136
+ }
137
+ }, [api, base, inputPayload, starting])
138
+
139
+ const autoFired = useRef(false)
140
+ useEffect(() => {
141
+ if (!autoStart || autoFired.current || session) return
142
+ autoFired.current = true
143
+ void start()
144
+ }, [autoStart, session, start])
145
+
146
+ // Poll while the provider works.
147
+ useEffect(() => {
148
+ if (!session || !session.working || session.done) return
149
+ const t = setInterval(async () => {
150
+ try {
151
+ const res = await api.get(`${base}/${session.id}`)
152
+ setSession(unwrap(res))
153
+ } catch (e: any) {
154
+ if (isGone(e)) {
155
+ try {
156
+ setSession(await recover())
157
+ } catch {
158
+ /* next tick retries */
159
+ }
160
+ }
161
+ }
162
+ }, POLL_MS)
163
+ return () => clearInterval(t)
164
+ }, [api, base, session])
165
+
166
+ // Rotate the thinking hint while working.
167
+ useEffect(() => {
168
+ if (!session?.working) return
169
+ setHintIdx(0)
170
+ const t = setInterval(() => setHintIdx(i => Math.min(i + 1, THINKING_HINTS.length - 1)), 2600)
171
+ return () => clearInterval(t)
172
+ }, [session?.working])
173
+
174
+ // Apply the result once.
175
+ useEffect(() => {
176
+ if (!session?.done || !session.result || applied) return
177
+ const allowed = assist.output?.length ? new Set(assist.output) : null
178
+ const out: Record<string, any> = {}
179
+ for (const [k, v] of Object.entries(session.result)) {
180
+ if (allowed && !allowed.has(k)) continue
181
+ if (v === undefined || v === null || v === '') continue
182
+ out[k] = v
183
+ }
184
+ onApply(out)
185
+ setApplied(true)
186
+ }, [session, applied, assist.output, onApply])
187
+
188
+ const isGone = (e: any) => e?.response?.status === 404 || /sesi[oó]n no encontrada/i.test(String(e?.response?.data?.message || e?.message || ''))
189
+
190
+ // Recreate the session and replay every answer (host lost it).
191
+ const recover = useCallback(async () => {
192
+ const res = await api.post(base, { input: inputPayload })
193
+ let s = unwrap(res)
194
+ for (const a of answersRef.current) {
195
+ // wait until the provider is ready for the next answer
196
+ for (let i = 0; i < 120 && s.working && !s.done; i++) {
197
+ await new Promise(r => setTimeout(r, POLL_MS))
198
+ s = unwrap(await api.get(`${base}/${s.id}`))
199
+ }
200
+ if (s.done) break
201
+ s = unwrap(await api.post(`${base}/${s.id}/reply`, { key: a.key, value: a.value }))
202
+ }
203
+ return s
204
+ }, [api, base, inputPayload])
205
+
206
+ const reply = useCallback(
207
+ async (key: string, value: any) => {
208
+ if (!session) return
209
+ setAnswer('')
210
+ setError(null)
211
+ setSession(s => (s ? { ...s, working: true } : s))
212
+ try {
213
+ const res = await api.post(`${base}/${session.id}/reply`, { key, value })
214
+ answersRef.current = [...answersRef.current, { key, value }]
215
+ setSession(unwrap(res))
216
+ } catch (e: any) {
217
+ if (isGone(e)) {
218
+ try {
219
+ answersRef.current = [...answersRef.current, { key, value }]
220
+ setSession(await recover())
221
+ return
222
+ } catch (e2: any) {
223
+ setError(e2?.response?.data?.message || e2?.message || 'No se pudo retomar la conversación')
224
+ }
225
+ } else {
226
+ setError(e?.response?.data?.message || e?.message || 'No se pudo enviar la respuesta')
227
+ }
228
+ setSession(s => (s ? { ...s, working: false } : s))
229
+ }
230
+ },
231
+ [api, base, session, recover],
232
+ )
233
+
234
+ const turns = session?.turns ?? []
235
+ const last = turns[turns.length - 1]
236
+ const pendingQuestion = !session?.working && !session?.done && last?.type === 'question' ? last.question : null
237
+ const lastSpoken = [...turns].reverse().find(t => t.type === 'message' || t.type === 'question')
238
+ const headline = lastSpoken?.type === 'question' ? lastSpoken.question?.prompt ?? '' : lastSpoken?.text ?? ''
239
+ const { shown, typing } = useTypewriter(headline || (starting ? 'Un segundo…' : ''))
240
+ // The latest progress block stays visible as compact chips (done / error
241
+ // included) until the next block replaces it; the card that followed it
242
+ // shows whenever the assistant is waiting on the user or finished.
243
+ const activeProgress = [...turns].reverse().find(t => t.type === 'progress')
244
+ const lastCard = [...turns].reverse().find(t => t.type === 'card')?.card
245
+ const answered = turns.filter(t => t.type === 'answer').length
246
+ const beats = Math.max(session?.beats ?? 5, 2)
247
+ const progress = session?.done ? beats : Math.min(answered + 1, beats)
248
+ const running = activeProgress?.steps?.find(s => s.state === 'running')
249
+
250
+ useEffect(() => {
251
+ if (pendingQuestion && !typing) inputRef.current?.focus()
252
+ }, [pendingQuestion, typing])
253
+
254
+ const submitText = () => {
255
+ if (!pendingQuestion) return
256
+ const v = answer.trim() || pendingQuestion.suggestion || ''
257
+ if (v) void reply(pendingQuestion.key, v)
258
+ }
259
+
260
+ return (
261
+ <div className={'relative overflow-hidden rounded-2xl border bg-gradient-to-b from-primary/[0.06] via-background to-background px-5 py-6 sm:px-8 sm:py-8 ' + (className ?? '')}>
262
+ {/* top: eyebrow + beats */}
263
+ <div className="flex items-center justify-between gap-4">
264
+ <p className="text-xs font-semibold uppercase tracking-wider text-primary">{eyebrow || assist.label || 'Asistente'}</p>
265
+ <div className="flex items-center gap-1.5" aria-label="progreso">
266
+ {Array.from({ length: beats }).map((_, i) => (
267
+ <span key={i} className={'h-1.5 rounded-full transition-all duration-500 ' + (i < progress ? 'w-6 bg-primary' : 'w-3 bg-muted-foreground/25')} />
268
+ ))}
269
+ </div>
270
+ </div>
271
+
272
+ {/* the question — one at a time, typed out */}
273
+ <div className="mt-5 flex items-start gap-3 sm:gap-4">
274
+ <span className="mt-1 flex size-9 shrink-0 items-center justify-center rounded-xl bg-primary text-primary-foreground shadow-lg shadow-primary/25 sm:size-10">
275
+ <Sparkles className={'size-4 sm:size-5 ' + (session?.working || starting ? 'animate-pulse' : '')} />
276
+ </span>
277
+ <h3 className="min-h-[3.5rem] text-lg font-semibold leading-snug tracking-tight sm:min-h-[4rem] sm:text-2xl">
278
+ {shown}
279
+ {typing && <span className="ml-0.5 inline-block h-5 w-[2px] animate-pulse bg-primary align-middle" />}
280
+ </h3>
281
+ </div>
282
+
283
+ {/* status line */}
284
+ <div className="mt-3 min-h-6 pl-12 sm:pl-14">
285
+ {(session?.working || starting) && !running && (
286
+ <span className="inline-flex items-center gap-2 text-sm text-muted-foreground">
287
+ <Loader2 className="size-3.5 animate-spin" />
288
+ {THINKING_HINTS[hintIdx]}
289
+ </span>
290
+ )}
291
+ {activeProgress && (
292
+ <ul className="mt-2 flex flex-wrap gap-x-4 gap-y-1" aria-label="pasos">
293
+ {(activeProgress.steps ?? []).map((s, i) => (
294
+ <li key={i} className={'inline-flex items-center gap-1.5 text-xs ' + (s.state === 'pending' ? 'text-muted-foreground/70' : s.state === 'error' ? 'text-destructive' : 'text-foreground/80')}>
295
+ {s.state === 'done' ? <CheckCircle2 className="size-3.5 text-emerald-500" /> : s.state === 'running' ? <Loader2 className="size-3.5 animate-spin text-primary" /> : s.state === 'error' ? <XCircle className="size-3.5" /> : <span className="size-1.5 rounded-full bg-muted-foreground/40" />}
296
+ {s.label}
297
+ {s.state === 'running' && s.detail && <span className="text-muted-foreground">· {s.detail}</span>}
298
+ </li>
299
+ ))}
300
+ </ul>
301
+ )}
302
+ {lastCard && !session?.working && <AssistCardView card={lastCard} />}
303
+ {session?.done && (
304
+ <span className="inline-flex items-center gap-2 text-sm font-medium text-emerald-600 dark:text-emerald-400">
305
+ <Check className="size-4" />
306
+ {applied ? 'Listo. Pulsa Siguiente para revisar lo que armé.' : 'Listo.'}
307
+ </span>
308
+ )}
309
+ {(error || session?.error) && <p className="text-sm text-destructive">{error || session?.error}</p>}
310
+ </div>
311
+
312
+ {/* the answer */}
313
+ {pendingQuestion && (
314
+ <div className="mt-5 pl-0 sm:pl-14">
315
+ {pendingQuestion.kind === 'choice' || pendingQuestion.kind === 'confirm' ? (
316
+ <div className="flex flex-wrap gap-2">
317
+ {(pendingQuestion.options ?? [{ value: 'yes', label: 'Sí' }, { value: 'no', label: 'No' }]).map(o => (
318
+ <Button key={o.value} type="button" variant="outline" className="h-10 rounded-full px-4" onClick={() => void reply(pendingQuestion.key, o.value)}>
319
+ {o.label}
320
+ </Button>
321
+ ))}
322
+ </div>
323
+ ) : (
324
+ <div className="relative">
325
+ <Input
326
+ ref={inputRef}
327
+ value={answer}
328
+ onChange={e => setAnswer(e.target.value)}
329
+ onKeyDown={e => {
330
+ if (e.key === 'Enter') {
331
+ e.preventDefault()
332
+ submitText()
333
+ }
334
+ }}
335
+ placeholder={pendingQuestion.suggestion || pendingQuestion.placeholder || 'Escribe tu respuesta…'}
336
+ className="h-12 pr-12 text-base"
337
+ autoFocus
338
+ />
339
+ <Button type="button" size="icon" className="absolute right-1.5 top-1.5 size-9" onClick={submitText} disabled={!answer.trim() && !pendingQuestion.suggestion}>
340
+ <ArrowRight className="size-4" />
341
+ </Button>
342
+ </div>
343
+ )}
344
+ {pendingQuestion.suggestion && pendingQuestion.kind !== 'choice' && (
345
+ <p className="pt-1.5 text-xs text-muted-foreground">Enter acepta la sugerencia.</p>
346
+ )}
347
+ </div>
348
+ )}
349
+
350
+ {/* idle / retry */}
351
+ {!session && !starting && (
352
+ <div className="mt-5 pl-0 sm:pl-14">
353
+ <Button type="button" onClick={() => void start()}>
354
+ <Sparkles className="mr-2 size-4" />
355
+ {assist.label || 'Empezar'}
356
+ </Button>
357
+ </div>
358
+ )}
359
+ {session?.done && (
360
+ <div className="mt-4 pl-0 sm:pl-14">
361
+ <button type="button" onClick={() => { setSession(null); setApplied(false); autoFired.current = false }} className="inline-flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground">
362
+ <RotateCcw className="size-3.5" /> Empezar de nuevo
363
+ </button>
364
+ </div>
365
+ )}
366
+ </div>
367
+ )
368
+ }
369
+
370
+ /** Preview card: logo on dark and light, colour swatches, key/value lines. */
371
+ export function AssistCardView({ card }: { card: AssistCard }) {
372
+ const colors = Object.entries(card.colors ?? {})
373
+ const logoDark = card.logo_light || card.logo
374
+ const logoLight = card.logo_dark || card.logo
375
+ return (
376
+ <div className="mt-3 rounded-xl border bg-background p-3">
377
+ {card.title && <p className="pb-2 text-xs font-semibold uppercase tracking-wide text-muted-foreground">{card.title}</p>}
378
+ {(card.logo || card.logo_light || card.logo_dark) && (
379
+ <div className="grid grid-cols-2 gap-2">
380
+ <div className="flex h-20 items-center justify-center rounded-lg bg-neutral-950 p-3">
381
+ {logoDark && <img src={logoDark} alt="" className="max-h-full max-w-full object-contain" />}
382
+ </div>
383
+ <div className="flex h-20 items-center justify-center rounded-lg border bg-white p-3">
384
+ {logoLight && <img src={logoLight} alt="" className="max-h-full max-w-full object-contain" />}
385
+ </div>
386
+ </div>
387
+ )}
388
+ {colors.length > 0 && (
389
+ <div className="flex flex-wrap gap-2 pt-3">
390
+ {colors.map(([k, v]) => (
391
+ <span key={k} className="inline-flex items-center gap-1.5 rounded-full border px-2 py-1 text-xs">
392
+ <span className="size-4 rounded-full border" style={{ backgroundColor: v }} />
393
+ <span className="text-muted-foreground">{k}</span>
394
+ <span className="font-mono">{v}</span>
395
+ </span>
396
+ ))}
397
+ </div>
398
+ )}
399
+ {(card.lines ?? []).length > 0 && (
400
+ <dl className="grid gap-1 pt-3 text-sm">
401
+ {card.lines!.map(l => (
402
+ <div key={l.label} className="grid grid-cols-[6rem_1fr] gap-2">
403
+ <dt className="text-muted-foreground">{l.label}</dt>
404
+ <dd className="min-w-0 break-words">{l.value}</dd>
405
+ </div>
406
+ ))}
407
+ </dl>
408
+ )}
409
+ </div>
410
+ )
411
+ }
@@ -63,6 +63,7 @@ import { getFieldRef, getVisibleWhen, evaluateVisibleWhen } from '../dynamic-for
63
63
  import type { VisibleWhen } from '../types'
64
64
  import { groupFieldsBySection, type FormLayout } from '../form-layout'
65
65
  import { FieldSection, WizardProgress } from '../form-layout-ui'
66
+ import { AssistInterview } from '../assist-interview'
66
67
  import { FieldCell } from '../field-grid'
67
68
  import { isNilUuid, normalizeNilUuid } from '../nil-uuid'
68
69
  import { normalizeRefFieldsForSubmit } from './normalize-submit'
@@ -1080,15 +1081,37 @@ export function DynamicRecordDialog({
1080
1081
  className="grid gap-y-4"
1081
1082
  >
1082
1083
  {isSteps && (
1083
- <WizardProgress groups={groups} stepIndex={clampedStep} />
1084
+ <WizardProgress groups={groups} stepIndex={clampedStep} onStepClick={i => setStepIndex(i)} />
1085
+ )}
1086
+ {(isSteps ? [groups[clampedStep]] : groups).map(group =>
1087
+ // An assisted step IS the interview: no form inputs, the
1088
+ // provider asks what it needs and fills the fields.
1089
+ isSteps && isEditable && group.assist ? (
1090
+ <AssistInterview
1091
+ key={group.key}
1092
+ assist={group.assist}
1093
+ values={formValues}
1094
+ eyebrow={group.title}
1095
+ autoStart={group.assist.trigger !== 'button'}
1096
+ onApply={fields => setFormValues(prev => ({ ...prev, ...fields }))}
1097
+ />
1098
+ ) : (
1099
+ <FieldSection key={group.key} group={group}>
1100
+ <div className="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2">
1101
+ {renderFields(group.fields)}
1102
+ </div>
1103
+ {isEditable && group.assist && (
1104
+ <AssistInterview
1105
+ assist={group.assist}
1106
+ values={formValues}
1107
+ eyebrow={group.title}
1108
+ autoStart={group.assist.trigger === 'auto'}
1109
+ onApply={fields => setFormValues(prev => ({ ...prev, ...fields }))}
1110
+ />
1111
+ )}
1112
+ </FieldSection>
1113
+ ),
1084
1114
  )}
1085
- {(isSteps ? [groups[clampedStep]] : groups).map(group => (
1086
- <FieldSection key={group.key} group={group}>
1087
- <div className="grid grid-cols-1 gap-x-6 gap-y-4 sm:grid-cols-2">
1088
- {renderFields(group.fields)}
1089
- </div>
1090
- </FieldSection>
1091
- ))}
1092
1115
 
1093
1116
  {record?.external_url && (
1094
1117
  <div className="grid grid-cols-1 sm:grid-cols-2">
@@ -4,6 +4,7 @@
4
4
  import { useEffect, useMemo, useState } from 'react'
5
5
  import { groupFieldsBySection, type FormLayout } from './form-layout'
6
6
  import { FieldSection, WizardProgress } from './form-layout-ui'
7
+ import { AssistInterview } from './assist-interview'
7
8
  import {
8
9
  Input,
9
10
  Textarea,
@@ -211,8 +212,18 @@ export function DynamicForm({
211
212
 
212
213
  return (
213
214
  <form onSubmit={handleSubmit} className="grid gap-4">
214
- <WizardProgress groups={groups} stepIndex={stepIndex} />
215
- {renderGrid(step.fields)}
215
+ <WizardProgress groups={groups} stepIndex={stepIndex} onStepClick={i => setStepIndex(i)} />
216
+ {step.assist ? (
217
+ <AssistInterview
218
+ assist={step.assist}
219
+ values={values}
220
+ eyebrow={step.title}
221
+ autoStart={step.assist.trigger !== 'button'}
222
+ onApply={fields => setValues(prev => ({ ...prev, ...fields }))}
223
+ />
224
+ ) : (
225
+ renderGrid(step.fields)
226
+ )}
216
227
  <div className="flex justify-between gap-2 pt-2">
217
228
  {stepIndex > 0 ? (
218
229
  <Button type="button" variant="outline" onClick={goBack} disabled={submitting || disabled}>
@@ -10,6 +10,7 @@ import {
10
10
  Button,
11
11
  } from '@asteby/metacore-ui/primitives'
12
12
  import { ChevronDown } from 'lucide-react'
13
+ import { ProcessStepper } from '@asteby/metacore-ui/wizard'
13
14
  import type { FieldGroup } from './form-layout'
14
15
 
15
16
  /**
@@ -75,43 +76,33 @@ export function FieldSection({
75
76
  }
76
77
 
77
78
  /**
78
- * Progress bar for `mode: "steps"`: one filled segment per completed/current
79
- * step plus a "Paso i/n · <title>" caption. Mirrors the WizardActionModal look
80
- * so a model wizard and an action wizard read the same.
79
+ * Step indicator for `mode: "steps"`: the platform's ProcessStepper (numbered
80
+ * circles with a check when done, label underneath, connectors) plus a
81
+ * "Paso i/n · <title>" caption and the step's description. Same component the
82
+ * process modals (workshop, warehouse) use, so a model wizard, an action wizard
83
+ * and a process modal read the same.
81
84
  */
82
85
  export function WizardProgress({
83
86
  groups,
84
87
  stepIndex,
85
88
  stepLabel = 'Paso',
89
+ onStepClick,
86
90
  }: {
87
91
  groups: FieldGroup<unknown>[]
88
92
  stepIndex: number
89
93
  stepLabel?: string
94
+ /** Jump back to an already completed step (forward jumps are never offered). */
95
+ onStepClick?: (index: number) => void
90
96
  }) {
91
97
  const current = groups[stepIndex]
98
+ const steps = groups.map((g, i) => ({ key: g.key, label: g.title ?? `${stepLabel} ${i + 1}` }))
92
99
  return (
93
- <div className="pt-2">
94
- <div className="flex items-center gap-1.5" role="list" aria-label="progress">
95
- {groups.map((g, i) => (
96
- <div
97
- key={g.key}
98
- role="listitem"
99
- aria-current={i === stepIndex ? 'step' : undefined}
100
- className="h-1.5 flex-1 rounded-full"
101
- style={{
102
- backgroundColor:
103
- i <= stepIndex ? 'hsl(var(--primary))' : 'hsl(var(--muted))',
104
- }}
105
- />
106
- ))}
107
- </div>
108
- <p className="pt-2 text-sm text-muted-foreground">
100
+ <div className="pt-1">
101
+ <ProcessStepper steps={steps} activeIndex={stepIndex} onStepClick={onStepClick} />
102
+ <p className="pt-3 text-sm text-muted-foreground">
109
103
  {stepLabel} {stepIndex + 1}/{groups.length}
110
104
  {current?.title ? ` · ${current.title}` : ''}
111
105
  </p>
112
- {current?.description && (
113
- <p className="pt-1 text-sm text-muted-foreground">{current.description}</p>
114
- )}
115
106
  </div>
116
107
  )
117
108
  }