@ddtcorex/dsh-maestro-config 0.1.2 → 0.2.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.
@@ -0,0 +1,1463 @@
1
+ // @ts-nocheck
2
+ /**
3
+ * Maestro Settings — DSH-native redesign.
4
+ * Reuses DeepSeek Harness design tokens & primitive geometry maximally:
5
+ * - --dsw-alias-* color family (no hard-coded hex except QR tile #fff)
6
+ * - Button variants primary/ghost/outline (h36 capsule / h28 small) — same as @deepseek-ai/dsh-client-ui-primitives/Button
7
+ * - Input atom (h32, radius 8, bg-layer-1, focus border brand) — same as primitives/Input
8
+ * - DisclosureRow (24px row, 14px glyph, chevron hover) — same as primitives/DisclosureRow
9
+ * - Panel chroma: inner cards use bg-layer-2 / border-l2 / radius 12 / shadow lv3 where needed
10
+ */
11
+
12
+ import { createElement as h, useEffect, useRef, useState } from 'react'
13
+ import QRCode from 'qrcode'
14
+ import { MAESTRO_ENDPOINTS } from './api.js'
15
+ import { generateWebhookSecret, gitlabWebhookUrl } from './webhook-secret.js'
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // DSH tokens — single source, no custom hex (except QR quiet zone #fff)
19
+ // ---------------------------------------------------------------------------
20
+ const t = {
21
+ bgLayer1: 'var(--dsw-alias-bg-layer-1)',
22
+ bgLayer2: 'var(--dsw-alias-bg-layer-2)',
23
+ bgLayer3: 'var(--dsw-alias-bg-layer-3)',
24
+ borderL2: 'var(--dsw-alias-border-l2)',
25
+ labelPrimary: 'var(--dsw-alias-label-primary)',
26
+ labelSecondary: 'var(--dsw-alias-label-secondary)',
27
+ labelTertiary: 'var(--dsw-alias-label-tertiary)',
28
+ labelDimmed: 'var(--dsw-alias-label-dimmed)',
29
+ labelFg: 'var(--dsw-alias-label-primary-foreground)',
30
+ primaryFill: 'var(--dsw-alias-button-primary-fill)',
31
+ primaryHover: 'var(--dsw-alias-button-primary-hover)',
32
+ interactiveHover: 'var(--dsw-alias-interactive-bg-hover)',
33
+ interactiveActive: 'var(--dsw-alias-interactive-bg-active)',
34
+ stateError: 'var(--dsw-alias-state-error-primary)',
35
+ brand: 'var(--dsw-alias-brand-primary)',
36
+ shadowLv3: 'var(--dsw-shadow-lv3)',
37
+ scrollbarL2: 'var(--dsw-alias-scrollbar-bg-l2)',
38
+ }
39
+
40
+ // ---------------------------------------------------------------------------
41
+ // Lightweight DSH primitive mirrors (geometry + tokens identical to host)
42
+ // Usage is identical to @deepseek-ai/dsh-client-ui-primitives at runtime.
43
+ // ---------------------------------------------------------------------------
44
+ type ButtonVariant = 'primary' | 'ghost' | 'outline'
45
+ function Button({
46
+ variant = 'ghost',
47
+ size = 'md',
48
+ icon,
49
+ children,
50
+ style,
51
+ ...rest
52
+ }: {
53
+ variant?: ButtonVariant
54
+ size?: 'md' | 'sm'
55
+ icon?: unknown
56
+ children?: unknown
57
+ style?: Record<string, unknown>
58
+ } & Record<string, unknown>) {
59
+ const base: Record<string, unknown> = {
60
+ display: 'inline-flex',
61
+ alignItems: 'center',
62
+ justifyContent: 'center',
63
+ gap: 4,
64
+ border: 'none',
65
+ borderRadius: size === 'sm' ? 16 : 18,
66
+ cursor: 'pointer',
67
+ fontSize: size === 'sm' ? 13 : 14,
68
+ lineHeight: size === 'sm' ? '18px' : '22px',
69
+ padding: size === 'sm' ? '0 12px' : '0 14px',
70
+ height: size === 'sm' ? 32 : 36,
71
+ color: t.labelPrimary,
72
+ background: 'transparent',
73
+ fontFamily: 'inherit',
74
+ }
75
+ if (variant === 'primary') {
76
+ base.background = t.primaryFill
77
+ base.color = t.labelFg
78
+ }
79
+ if (variant === 'outline') {
80
+ base.border = `1px solid ${t.borderL2}`
81
+ base.background = 'transparent'
82
+ }
83
+ const merged = { ...base, ...(style as object) } as Record<string, string>
84
+ return h(
85
+ 'button',
86
+ {
87
+ type: 'button',
88
+ style: merged,
89
+ onMouseEnter: (e: any) => {
90
+ if ((rest as any).disabled) return
91
+ if (variant === 'primary') (e.currentTarget as HTMLElement).style.background = t.primaryHover as string
92
+ else (e.currentTarget as HTMLElement).style.background = t.interactiveHover as string
93
+ },
94
+ onMouseLeave: (e: any) => {
95
+ if (variant === 'primary') (e.currentTarget as HTMLElement).style.background = t.primaryFill as string
96
+ else (e.currentTarget as HTMLElement).style.background = variant === 'outline' ? 'transparent' : 'transparent'
97
+ },
98
+ ...(rest as any),
99
+ },
100
+ icon ? h('span', { style: { display: 'inline-flex', width: 16, height: 16, alignItems: 'center', justifyContent: 'center' } }, icon as any) : null,
101
+ children as any,
102
+ )
103
+ }
104
+
105
+ function InputWrap({
106
+ icon,
107
+ children,
108
+ style,
109
+ focused,
110
+ }: {
111
+ icon?: unknown
112
+ children: unknown
113
+ style?: Record<string, unknown>
114
+ focused?: boolean
115
+ }) {
116
+ return h(
117
+ 'span',
118
+ {
119
+ style: {
120
+ display: 'inline-flex',
121
+ alignItems: 'center',
122
+ gap: 8,
123
+ height: 36,
124
+ padding: '0 12px',
125
+ border: `1px solid ${focused ? t.brand : t.borderL2}`,
126
+ borderRadius: 10,
127
+ background: t.bgLayer1,
128
+ flex: 1,
129
+ minWidth: 0,
130
+ boxSizing: 'border-box' as const,
131
+ ...(style as object),
132
+ },
133
+ },
134
+ icon
135
+ ? h('span', { style: { display: 'inline-flex', width: 16, height: 16, color: t.labelTertiary } }, icon as any)
136
+ : null,
137
+ children as any,
138
+ )
139
+ }
140
+
141
+ function FieldInput(props: React.InputHTMLAttributes<HTMLInputElement> & { icon?: unknown }) {
142
+ const [focused, setFocused] = useState(false)
143
+ const { icon, style, ...rest } = props as any
144
+ return h(
145
+ InputWrap as any,
146
+ { icon, focused, style: { ...(style as object), flex: '1 1 auto' } },
147
+ h('input', {
148
+ ...(rest as any),
149
+ onFocus: (e: any) => {
150
+ setFocused(true)
151
+ ;(rest as any).onFocus?.(e)
152
+ },
153
+ onBlur: (e: any) => {
154
+ setFocused(false)
155
+ ;(rest as any).onBlur?.(e)
156
+ },
157
+ style: {
158
+ flex: 1,
159
+ minWidth: 0,
160
+ border: 'none',
161
+ outline: 'none',
162
+ background: 'transparent',
163
+ fontSize: 14,
164
+ lineHeight: '22px',
165
+ color: t.labelPrimary,
166
+ fontFamily: 'inherit',
167
+ },
168
+ }),
169
+ )
170
+ }
171
+
172
+ function TextareaField(props: React.TextareaHTMLAttributes<HTMLTextAreaElement>) {
173
+ const [focused, setFocused] = useState(false)
174
+ return h('textarea', {
175
+ ...(props as any),
176
+ onFocus: (e: any) => {
177
+ setFocused(true)
178
+ ;(props as any).onFocus?.(e)
179
+ },
180
+ onBlur: (e: any) => {
181
+ setFocused(false)
182
+ ;(props as any).onBlur?.(e)
183
+ },
184
+ style: {
185
+ width: '100%',
186
+ minHeight: 96,
187
+ padding: '8px 10px',
188
+ border: `1px solid ${focused ? t.brand : t.borderL2}`,
189
+ borderRadius: 8,
190
+ background: t.bgLayer1,
191
+ color: t.labelPrimary,
192
+ fontFamily: 'inherit',
193
+ fontSize: 13,
194
+ lineHeight: '18px',
195
+ resize: 'vertical' as const,
196
+ boxSizing: 'border-box' as const,
197
+ outline: 'none',
198
+ ...(props as any).style,
199
+ },
200
+ })
201
+ }
202
+
203
+ // DSH DisclosureRow — 24px row, 14px glyph, hover chevron swap, same semantics as host
204
+ function DisclosureRow({
205
+ icon,
206
+ title,
207
+ caption,
208
+ open,
209
+ expandable,
210
+ onToggle,
211
+ children,
212
+ }: {
213
+ icon: unknown
214
+ title: string
215
+ caption?: string
216
+ open: boolean
217
+ expandable: boolean
218
+ onToggle: () => void
219
+ children?: unknown
220
+ }) {
221
+ const rowExpands = expandable
222
+ return h(
223
+ 'div',
224
+ { style: { display: 'flex', flexDirection: 'column', width: '100%', minWidth: 0 } },
225
+ h(
226
+ 'div',
227
+ {
228
+ role: rowExpands ? 'button' : undefined,
229
+ tabIndex: rowExpands ? 0 : undefined,
230
+ 'aria-expanded': rowExpands ? open : undefined,
231
+ onClick: rowExpands ? onToggle : undefined,
232
+ onKeyDown: rowExpands
233
+ ? (e: any) => {
234
+ if (e.key === 'Enter' || e.key === ' ') {
235
+ e.preventDefault()
236
+ onToggle()
237
+ }
238
+ }
239
+ : undefined,
240
+ style: {
241
+ display: 'flex',
242
+ alignItems: 'center',
243
+ gap: 8,
244
+ minHeight: 40,
245
+ padding: '8px 10px',
246
+ borderRadius: 12,
247
+ cursor: rowExpands ? 'pointer' : 'default',
248
+ background: open ? 'var(--dsw-specific-sidebar-nav-item-active)' : 'transparent',
249
+ border: `1px solid ${open ? t.borderL2 : 'transparent'}`,
250
+ boxSizing: 'border-box' as const,
251
+ },
252
+ onMouseEnter: (e: any) => {
253
+ if (!open) (e.currentTarget as HTMLElement).style.background = 'var(--dsw-specific-sidebar-nav-item-hover)'
254
+ },
255
+ onMouseLeave: (e: any) => {
256
+ if (!open) (e.currentTarget as HTMLElement).style.background = 'transparent'
257
+ },
258
+ },
259
+ expandable
260
+ ? h(
261
+ 'button',
262
+ {
263
+ type: 'button',
264
+ 'aria-expanded': open,
265
+ onClick: (e: any) => {
266
+ e.stopPropagation()
267
+ onToggle()
268
+ },
269
+ style: {
270
+ flex: 'none',
271
+ width: 20,
272
+ height: 20,
273
+ display: 'inline-flex',
274
+ alignItems: 'center',
275
+ justifyContent: 'center',
276
+ border: 'none',
277
+ background: 'none',
278
+ cursor: 'pointer',
279
+ color: t.labelTertiary,
280
+ padding: 0,
281
+ },
282
+ },
283
+ open
284
+ ? h('span', { style: { fontSize: 12, lineHeight: 1 } }, '▾')
285
+ : h('span', { style: { fontSize: 12, lineHeight: 1 } }, '▸'),
286
+ )
287
+ : h('span', { style: { width: 20, display: 'inline-flex', justifyContent: 'center', color: t.labelTertiary, flex: 'none' } }, icon as any),
288
+ h('span', { style: { flex: 1, minWidth: 0 } },
289
+ h('span', { style: { display: 'block', fontSize: 14, lineHeight: '20px', fontWeight: 500, color: t.labelPrimary } }, title),
290
+ caption ? h('span', { style: { display: 'block', fontSize: 12, lineHeight: '16px', color: t.labelSecondary, marginTop: 1 } }, caption) : null,
291
+ ),
292
+ !open && expandable
293
+ ? h('span', { style: { color: t.labelTertiary, fontSize: 12 } }, '›')
294
+ : null,
295
+ ),
296
+ open ? h('div', { style: { padding: '10px 0 14px 30px', display: 'flex', flexDirection: 'column', gap: 10 } }, children as any) : null,
297
+ )
298
+ }
299
+
300
+ // ---------------------------------------------------------------------------
301
+ // Shared field helpers — DSH-native label / caption rhythm
302
+ // ---------------------------------------------------------------------------
303
+ const fieldLabelStyle: Record<string, string> = {
304
+ display: 'flex',
305
+ flexDirection: 'column',
306
+ gap: '6px',
307
+ fontSize: '12px',
308
+ lineHeight: '16px',
309
+ fontWeight: '500',
310
+ color: t.labelSecondary as string,
311
+ margin: '12px 0 0',
312
+ }
313
+ const captionStyle: Record<string, string> = {
314
+ fontSize: '12px',
315
+ lineHeight: '16px',
316
+ color: t.labelSecondary as string,
317
+ margin: '4px 0',
318
+ }
319
+ const cardInsetStyle: Record<string, string> = {
320
+ display: 'flex',
321
+ flexDirection: 'column',
322
+ gap: '8px',
323
+ padding: '12px',
324
+ borderRadius: '12px',
325
+ border: `1px solid ${t.borderL2}`,
326
+ background: t.bgLayer1 as string,
327
+ }
328
+
329
+ // ---------------------------------------------------------------------------
330
+ // DSH General row — title + desc left, control right, 16px 0, border-bottom
331
+ // ---------------------------------------------------------------------------
332
+ const rowStyle: Record<string, string> = {
333
+ display: 'flex',
334
+ alignItems: 'center',
335
+ gap: '8px',
336
+ padding: '16px 0',
337
+ borderBottom: `1px solid ${t.borderL2}`,
338
+ minWidth: '0',
339
+ }
340
+ const rowTextStyle: Record<string, string> = {
341
+ flex: '1',
342
+ minWidth: '0',
343
+ display: 'flex',
344
+ flexDirection: 'column',
345
+ gap: '4px',
346
+ paddingRight: '48px',
347
+ }
348
+ const rowTitleStyle: Record<string, string> = {
349
+ fontSize: '14px',
350
+ fontWeight: '400',
351
+ lineHeight: '22px',
352
+ color: t.labelPrimary as string,
353
+ }
354
+ const rowDescStyle: Record<string, string> = {
355
+ fontSize: '12px',
356
+ fontWeight: '400',
357
+ lineHeight: '18px',
358
+ color: t.labelTertiary as string,
359
+ }
360
+ // Pill selector — same as LanguageRow/EnterBehaviorRow: h36 r18 bg-module-platform
361
+ const pillSelectorStyle: Record<string, string> = {
362
+ display: 'inline-flex',
363
+ alignItems: 'center',
364
+ gap: '12px',
365
+ height: '36px',
366
+ padding: '0 14px',
367
+ border: 'none',
368
+ borderRadius: '18px',
369
+ background: 'var(--dsw-alias-bg-module-platform, #F5F6F7)',
370
+ font: 'inherit',
371
+ fontSize: '14px',
372
+ lineHeight: '22px',
373
+ color: t.labelPrimary as string,
374
+ cursor: 'pointer',
375
+ whiteSpace: 'nowrap',
376
+ }
377
+ function SettingRow({ title, description, control }: { title: string; description?: string; control: unknown }) {
378
+ return h(
379
+ 'div',
380
+ { 'data-maestro-row': '', style: rowStyle },
381
+ h('div', { 'data-maestro-row-text': '', style: rowTextStyle }, h('div', { style: rowTitleStyle }, title), description ? h('div', { style: rowDescStyle }, description) : null),
382
+ h('div', { 'data-maestro-control': '', style: { flex: 'none', display: 'flex', alignItems: 'center', justifyContent: 'flex-end', gap: 8, minHeight: '36px' } }, control as any),
383
+ )
384
+ }
385
+ function ToggleRow({ title, description, checked, onChange }: { title: string; description?: string; checked?: boolean; onChange: (v: boolean) => void }) {
386
+ return h(
387
+ 'label',
388
+ { 'data-maestro-row': '', style: { ...rowStyle, cursor: 'pointer', alignItems: 'flex-start' } },
389
+ h('input', { type: 'checkbox', checked: checked === true, onChange: (e: any) => onChange(e.target.checked), style: { width: 16, height: 16, accentColor: t.primaryFill as string, marginTop: 4, flex: 'none' } }),
390
+ h('div', { 'data-maestro-row-text': '', style: { ...rowTextStyle, paddingRight: '0' } }, h('div', { style: rowTitleStyle }, title), description ? h('div', { style: rowDescStyle }, description) : null),
391
+ )
392
+ }
393
+
394
+ // ---------------------------------------------------------------------------
395
+ // QR — same as before (light tile + quiet zone) — QR itself is #fff
396
+ // ---------------------------------------------------------------------------
397
+ function QrImage({ url, size = 104 }: { url: string; size?: number }) {
398
+ const [dataUrl, setDataUrl] = useState<string | null>(null)
399
+ useEffect(() => {
400
+ let live = true
401
+ QRCode.toDataURL(url, { margin: 0, width: size * 2 })
402
+ .then((d) => {
403
+ if (live) setDataUrl(d)
404
+ })
405
+ .catch(() => {})
406
+ return () => {
407
+ live = false
408
+ }
409
+ }, [url, size])
410
+ return h(
411
+ 'div',
412
+ {
413
+ style: {
414
+ background: '#ffffff',
415
+ borderRadius: 10,
416
+ boxSizing: 'border-box',
417
+ width: size + 20,
418
+ height: size + 20,
419
+ display: 'flex',
420
+ alignItems: 'center',
421
+ justifyContent: 'center',
422
+ lineHeight: 0,
423
+ flex: 'none',
424
+ },
425
+ },
426
+ dataUrl === null
427
+ ? h('div', { style: { width: size, height: size, background: 'var(--dsw-alias-bg-skeleton)', borderRadius: 4 } })
428
+ : h('img', { src: dataUrl, alt: url, width: size, height: size, style: { display: 'block' } }),
429
+ )
430
+ }
431
+
432
+ // ---------------------------------------------------------------------------
433
+ // ReviewModelSelector — styled with DSH button + menu tokens
434
+ // ---------------------------------------------------------------------------
435
+ function ReviewModelSelector({
436
+ value,
437
+ catalog,
438
+ fallbackValue,
439
+ fallbackLabel,
440
+ onChange,
441
+ label,
442
+ }: {
443
+ value: { provider: string; model: string; reasoningEffort?: string } | null
444
+ catalog: any
445
+ fallbackValue: any
446
+ fallbackLabel: string
447
+ onChange: (v: any) => void
448
+ label: string | null
449
+ }) {
450
+ const groups = catalog?.groups ?? []
451
+ const providers: string[] = groups.map((g: any) => g.provider)
452
+ const selectedProvider = value?.provider ?? ''
453
+ const providerGroup = groups.find((g: any) => g.provider === selectedProvider)
454
+ const selectedEffort = value?.reasoningEffort ?? ''
455
+ const [open, setOpen] = useState(false)
456
+ const [pane, setPane] = useState<'root' | 'model' | 'effort'>('root')
457
+ const rootRef = useRef<HTMLDivElement | null>(null)
458
+ useEffect(() => {
459
+ if (!open) return
460
+ const onDown = (e: MouseEvent) => {
461
+ if (rootRef.current && !rootRef.current.contains(e.target as Node)) {
462
+ setOpen(false)
463
+ setPane('root')
464
+ }
465
+ }
466
+ const onKey = (e: KeyboardEvent) => {
467
+ if ((e as any).key === 'Escape') {
468
+ setOpen(false)
469
+ setPane('root')
470
+ }
471
+ }
472
+ document.addEventListener('mousedown', onDown as any)
473
+ document.addEventListener('keydown', onKey as any)
474
+ return () => {
475
+ document.removeEventListener('mousedown', onDown as any)
476
+ document.removeEventListener('keydown', onKey as any)
477
+ }
478
+ }, [open])
479
+ const getModelId = (m: any) => (typeof m === 'string' ? m : m.id)
480
+ const getModelName = (m: any) => (typeof m === 'string' ? m : (m.name ?? m.id))
481
+ const selectedModelInfo = (() => {
482
+ if (!selectedProvider || !value?.model) return null
483
+ const raw = (providerGroup?.models ?? []).find((mm: any) => getModelId(mm) === value.model)
484
+ if (raw === undefined) return null
485
+ if (typeof raw === 'string') return { id: raw, supportsReasoning: false, reasoningEfforts: [] as string[] }
486
+ return raw
487
+ })()
488
+ const supportsReasoning = (() => {
489
+ if (!selectedModelInfo) return false
490
+ if (typeof (selectedModelInfo as any).supportsReasoning === 'boolean') return (selectedModelInfo as any).supportsReasoning
491
+ const efforts = (selectedModelInfo as any).reasoningEfforts ?? (selectedModelInfo as any).reasoning?.efforts?.map((e: any) => e.id) ?? []
492
+ return efforts.filter((e: string) => e !== 'off').length > 0
493
+ })()
494
+ const availableEfforts: string[] = (() => {
495
+ if (!supportsReasoning) return []
496
+ const efforts = (selectedModelInfo as any)?.reasoningEfforts ?? (selectedModelInfo as any)?.reasoning?.efforts?.map((e: any) => e.id) ?? []
497
+ const filtered = efforts.filter((e: string) => e !== 'off' && e !== '')
498
+ if (filtered.length > 0) return filtered
499
+ return ['low', 'medium', 'high']
500
+ })()
501
+ const warning =
502
+ selectedEffort !== '' && !supportsReasoning && selectedModelInfo !== null
503
+ ? `⚠️ This model does not support reasoning effort "${selectedEffort}" — reviews will fail.`
504
+ : null
505
+ const update = (field: string, newVal: string) => {
506
+ if (newVal === '' && field === 'provider') {
507
+ onChange(null)
508
+ setOpen(false)
509
+ setPane('root')
510
+ return
511
+ }
512
+ const next: any = { provider: value?.provider ?? '', model: value?.model ?? '', ...(value?.reasoningEffort ? { reasoningEffort: value.reasoningEffort } : {}) }
513
+ if (field === 'provider') {
514
+ const g = groups.find((x: any) => x.provider === newVal)
515
+ const first = g?.models[0]
516
+ next.provider = newVal
517
+ next.model = first !== undefined ? getModelId(first) : ''
518
+ } else if (field === 'model') next.model = newVal
519
+ else if (field === 'reasoningEffort') {
520
+ if (newVal === '') delete next.reasoningEffort
521
+ else next.reasoningEffort = newVal
522
+ }
523
+ if (!next.provider || !next.model) onChange(null)
524
+ else onChange(next)
525
+ }
526
+ const effectiveFallback = fallbackValue !== undefined ? fallbackValue : (catalog?.current ?? null)
527
+ const triggerLabel = value
528
+ ? `${value.provider} / ${value.model}${value.reasoningEffort ? ` · ${value.reasoningEffort}` : ''}`
529
+ : effectiveFallback
530
+ ? `${fallbackLabel} · ${effectiveFallback.provider}/${effectiveFallback.model}${effectiveFallback.reasoningEffort ? ` · ${effectiveFallback.reasoningEffort}` : ''}`
531
+ : fallbackLabel
532
+ const menuStyle: Record<string, string> = {
533
+ position: 'absolute',
534
+ top: 'calc(100% + 8px)',
535
+ left: '0',
536
+ minWidth: '300px',
537
+ maxWidth: '360px',
538
+ background: t.bgLayer2 as string,
539
+ border: `1px solid ${t.borderL2}`,
540
+ borderRadius: '12px',
541
+ boxShadow: t.shadowLv3 as string,
542
+ zIndex: '20',
543
+ padding: '6px',
544
+ }
545
+ const rowStyle: Record<string, any> = {
546
+ width: '100%',
547
+ display: 'flex',
548
+ alignItems: 'center',
549
+ justifyContent: 'space-between',
550
+ gap: 12,
551
+ padding: '9px 10px',
552
+ borderRadius: 8,
553
+ border: 'none',
554
+ background: 'transparent',
555
+ color: t.labelPrimary,
556
+ fontFamily: 'inherit',
557
+ fontSize: 13,
558
+ cursor: 'pointer',
559
+ textAlign: 'left',
560
+ }
561
+ const check = (active: boolean) =>
562
+ active
563
+ ? h('svg', { width: 16, height: 16, viewBox: '0 0 16 16', style: { flex: 'none' } }, h('path', { d: 'M3.5 8.2l2.8 2.8L12.5 4.8', fill: 'none', stroke: 'currentColor', strokeWidth: 1.6, strokeLinecap: 'round', strokeLinejoin: 'round' }))
564
+ : h('span', { style: { width: 16, flex: 'none' } })
565
+ const chevronRight = h('svg', { width: 14, height: 14, viewBox: '0 0 14 14', style: { flex: 'none', opacity: 0.6 } }, h('path', { d: 'M5 3.5L8.5 7L5 10.5', fill: 'none', stroke: 'currentColor', strokeWidth: 1.4, strokeLinecap: 'round', strokeLinejoin: 'round' }))
566
+ const effortLabel = selectedEffort === '' ? 'Default effort' : selectedEffort
567
+ const modelLabel = selectedProvider === '' ? 'Select model' : (value?.model ?? 'Select model')
568
+ return h(
569
+ 'div',
570
+ { ref: rootRef as any, 'data-maestro-trigger-wrap': '', style: { position: 'relative', display: 'inline-block', maxWidth: '100%' } },
571
+ label ? h('span', { style: fieldLabelStyle }, label) : null,
572
+ h(
573
+ 'button',
574
+ {
575
+ type: 'button',
576
+ style: {
577
+ height: 36,
578
+ padding: '0 14px 0 16px',
579
+ borderRadius: 18,
580
+ border: 'none',
581
+ background: 'var(--dsw-alias-bg-module-platform, #F5F6F7)' as string,
582
+ color: t.labelPrimary as string,
583
+ fontFamily: 'inherit',
584
+ fontSize: 13,
585
+ display: 'inline-flex',
586
+ alignItems: 'center',
587
+ gap: 8,
588
+ cursor: 'pointer',
589
+ maxWidth: 320,
590
+ whiteSpace: 'nowrap',
591
+ },
592
+ onClick: () => {
593
+ setOpen((v) => !v)
594
+ setPane('root')
595
+ },
596
+ 'aria-expanded': open,
597
+ 'aria-haspopup': 'menu',
598
+ title: triggerLabel,
599
+ },
600
+ h('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, triggerLabel),
601
+ h('svg', { width: 14, height: 14, viewBox: '0 0 14 14', style: { flex: 'none', opacity: 0.7 } }, h('path', { d: 'M3.5 5L7 8.5L10.5 5', fill: 'none', stroke: 'currentColor', strokeWidth: 1.4, strokeLinecap: 'round', strokeLinejoin: 'round' })),
602
+ ),
603
+ open
604
+ ? h(
605
+ 'div',
606
+ { 'data-maestro-menu': '', style: menuStyle as any, role: 'menu' },
607
+ pane === 'root'
608
+ ? h(
609
+ 'div',
610
+ null,
611
+ h(
612
+ 'button',
613
+ { type: 'button', style: { ...rowStyle, background: !value ? t.bgLayer1 : 'transparent' } as any, onClick: () => { onChange(null); setOpen(false) } },
614
+ h('span', null, fallbackLabel),
615
+ check(!value),
616
+ ),
617
+ h('div', { style: { height: 1, background: t.borderL2 as string, margin: '6px 2px' } }),
618
+ h(
619
+ 'button',
620
+ { type: 'button', style: rowStyle as any, onClick: () => setPane('model') },
621
+ h('span', null, 'Model'),
622
+ h('span', { style: { display: 'flex', alignItems: 'center', gap: 8, color: t.labelSecondary as string, maxWidth: 160, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' } }, h('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis' } }, modelLabel), chevronRight),
623
+ ),
624
+ h(
625
+ 'button',
626
+ { type: 'button', style: rowStyle as any, onClick: () => setPane('effort') },
627
+ h('span', null, 'Effort'),
628
+ h('span', { style: { display: 'flex', alignItems: 'center', gap: 8, color: t.labelSecondary as string } }, effortLabel, chevronRight),
629
+ ),
630
+ value ? h('p', { style: { ...captionStyle, margin: '8px 4px 2px' } }, `Selected: ${value.provider} / ${value.model}${value.reasoningEffort ? ` (${value.reasoningEffort})` : ''}`) : null,
631
+ !value && effectiveFallback ? h('p', { style: { ...captionStyle, margin: '8px 4px 2px' } }, `${fallbackLabel === 'Use Global' ? 'Using Global' : 'Using DSH default'}: ${effectiveFallback.provider} / ${effectiveFallback.model}${effectiveFallback.reasoningEffort ? ` (${effectiveFallback.reasoningEffort})` : ''}`) : null,
632
+ warning ? h('p', { style: { ...captionStyle, margin: '4px 4px 2px', color: t.stateError as string } }, warning) : null,
633
+ )
634
+ : null,
635
+ pane === 'model'
636
+ ? h(
637
+ 'div',
638
+ null,
639
+ h('button', { type: 'button', style: { ...rowStyle, color: t.labelSecondary } as any, onClick: () => setPane('root') }, h('span', null, '← Back'), h('span', { style: { fontSize: 12 } }, 'Model')),
640
+ h(
641
+ 'div',
642
+ { style: { maxHeight: 260, overflowY: 'auto', marginTop: 4 } },
643
+ providers.length === 0
644
+ ? h('p', { style: captionStyle }, 'No providers')
645
+ : providers.map((p: string) => {
646
+ const g = groups.find((x: any) => x.provider === p)
647
+ const ms: any[] = g?.models ?? []
648
+ return h(
649
+ 'div',
650
+ { key: p, style: { marginBottom: 8 } },
651
+ h('div', { style: { fontSize: 11, fontWeight: 600, color: t.labelSecondary as string, padding: '6px 10px 2px', textTransform: 'uppercase', letterSpacing: 0.4, display: 'flex', alignItems: 'center', gap: 6 } }, h('span', { style: { width: 6, height: 6, borderRadius: 3, background: t.borderL2 as string, flex: 'none' } }), (g as any)?.name ?? p),
652
+ ms.length === 0
653
+ ? h('p', { style: { ...captionStyle, padding: '2px 10px 2px 28px' } }, 'No models')
654
+ : h(
655
+ 'div',
656
+ { style: { marginLeft: 12, borderLeft: `1px solid ${t.borderL2}`, paddingLeft: 6, display: 'flex', flexDirection: 'column', gap: 2 } },
657
+ ms.map((m: any) => {
658
+ const mid = getModelId(m)
659
+ const mname = getModelName(m)
660
+ const active = value?.provider === p && value?.model === mid
661
+ return h(
662
+ 'button',
663
+ {
664
+ key: mid,
665
+ type: 'button',
666
+ style: { ...rowStyle, paddingLeft: 10, background: active ? (t.bgLayer1 as string) : 'transparent' } as any,
667
+ onClick: () => {
668
+ update('model', mid)
669
+ if (value?.provider !== p) update('provider', p)
670
+ else {
671
+ const next: any = { provider: p, model: mid, ...(selectedEffort ? { reasoningEffort: selectedEffort } : {}) }
672
+ onChange(next)
673
+ setPane('root')
674
+ }
675
+ },
676
+ },
677
+ h('span', { style: { overflow: 'hidden', textOverflow: 'ellipsis' } }, mname),
678
+ check(active),
679
+ )
680
+ }),
681
+ ),
682
+ )
683
+ }),
684
+ ),
685
+ )
686
+ : null,
687
+ pane === 'effort'
688
+ ? h(
689
+ 'div',
690
+ null,
691
+ h('button', { type: 'button', style: { ...rowStyle, color: t.labelSecondary } as any, onClick: () => setPane('root') }, h('span', null, '← Back'), h('span', { style: { fontSize: 12 } }, 'Effort')),
692
+ selectedModelInfo === null
693
+ ? h('p', { style: { ...captionStyle, margin: '8px 4px 2px' } }, 'Select a model first to configure effort.')
694
+ : !supportsReasoning
695
+ ? h(
696
+ 'div',
697
+ null,
698
+ h('p', { style: { ...captionStyle, margin: '8px 4px 6px' } }, 'This model does not support reasoning effort — using provider default'),
699
+ h(
700
+ 'div',
701
+ { style: { marginTop: 4 } },
702
+ ([{ id: '', label: 'Default effort' }] as any).map((e: any) =>
703
+ h(
704
+ 'button',
705
+ { key: e.id || 'default', type: 'button', style: { ...rowStyle, background: selectedEffort === e.id ? (t.bgLayer1 as string) : 'transparent' } as any, onClick: () => { update('reasoningEffort', e.id); setPane('root') } },
706
+ h('span', null, e.label),
707
+ check(selectedEffort === e.id),
708
+ ),
709
+ ),
710
+ ),
711
+ warning ? h('p', { style: { ...captionStyle, margin: '8px 4px 2px', color: t.stateError as string } }, warning) : null,
712
+ )
713
+ : h(
714
+ 'div',
715
+ null,
716
+ h(
717
+ 'div',
718
+ { style: { marginTop: 4 } },
719
+ ([{ id: '', label: 'Default effort' }, ...availableEfforts.map((id) => ({ id, label: id }))] as any).map((e: any) =>
720
+ h(
721
+ 'button',
722
+ { key: e.id || 'default', type: 'button', style: { ...rowStyle, background: selectedEffort === e.id ? (t.bgLayer1 as string) : 'transparent' } as any, onClick: () => { update('reasoningEffort', e.id); setPane('root') } },
723
+ h('span', null, e.label),
724
+ check(selectedEffort === e.id),
725
+ ),
726
+ ),
727
+ ),
728
+ warning ? h('p', { style: { ...captionStyle, margin: '8px 4px 2px', color: t.stateError as string } }, warning) : null,
729
+ ),
730
+ )
731
+ : null,
732
+ )
733
+ : null,
734
+ )
735
+ }
736
+
737
+ function ProjectMappingsEditor({ mappings, onChange, catalog, globalReviewModel }: { mappings: any[]; onChange: (v: any[]) => void; catalog: any; globalReviewModel: any }) {
738
+ const rows = mappings as any[]
739
+ const updateRow = (index: number, field: string, value: any) => {
740
+ const next = rows.map((row: any, i: number) => (i === index ? { ...row, [field]: value } : row))
741
+ onChange(next.filter((r) => (r.projectPath ?? '') !== '' || (r.localRepoPath ?? '') !== ''))
742
+ }
743
+ const removeRow = (index: number) => onChange(rows.filter((_, i) => i !== index))
744
+ const addRow = () => onChange([...rows, { projectPath: '', localRepoPath: '', reviewProfile: 'magento2' }])
745
+ return h(
746
+ 'div',
747
+ { 'data-maestro-projects': '', style: { display: 'flex', flexDirection: 'column', gap: 12, paddingTop: 8 } },
748
+ // Header — count + primary Add
749
+ h(
750
+ 'div',
751
+ { style: { display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 12, padding: '4px 0 4px' } },
752
+ h(
753
+ 'div',
754
+ { style: { flex: 1, minWidth: 0 } },
755
+ h('div', { style: { fontSize: 14, fontWeight: 600, color: t.labelPrimary as string, lineHeight: '20px' } }, `Projects — ${rows.length} tracked`),
756
+ h('div', { style: { fontSize: 12, color: t.labelSecondary as string, lineHeight: '16px', marginTop: 2 } }, 'GitLab path → local checkout → profile → model override'),
757
+ ),
758
+ h(Button as any, { variant: 'primary', size: 'md', onClick: addRow }, '+ Add project'),
759
+ ),
760
+ rows.length === 0
761
+ ? h(
762
+ 'div',
763
+ {
764
+ 'data-maestro-project-empty': '',
765
+ style: {
766
+ display: 'flex',
767
+ flexDirection: 'column',
768
+ alignItems: 'center',
769
+ gap: 10,
770
+ padding: '20px 16px',
771
+ borderRadius: 12,
772
+ border: `1px dashed ${t.borderL2}`,
773
+ background: 'transparent',
774
+ textAlign: 'center' as const,
775
+ },
776
+ },
777
+ h('div', { style: { fontSize: 13, color: t.labelSecondary as string, lineHeight: '18px' } }, 'No projects yet — add your first mapping'),
778
+ h(Button as any, { variant: 'outline', size: 'md', onClick: addRow }, '+ Add project'),
779
+ )
780
+ : h(
781
+ 'div',
782
+ { style: { display: 'flex', flexDirection: 'column', gap: 12 } },
783
+ ...rows.map((row: any, i: number) =>
784
+ h(
785
+ 'div',
786
+ {
787
+ key: i,
788
+ 'data-maestro-project-card': '',
789
+ style: {
790
+ display: 'flex',
791
+ flexDirection: 'column',
792
+ gap: 12,
793
+ padding: 12,
794
+ borderRadius: 12,
795
+ border: `1px solid ${t.borderL2}`,
796
+ background: t.bgLayer1 as string,
797
+ boxSizing: 'border-box' as const,
798
+ },
799
+ },
800
+ // Card header: index + path + remove
801
+ h(
802
+ 'div',
803
+ { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: 12 } },
804
+ h(
805
+ 'div',
806
+ { style: { display: 'flex', alignItems: 'center', gap: 8, minWidth: 0, flex: 1 } },
807
+ h(
808
+ 'span',
809
+ {
810
+ style: {
811
+ flex: 'none',
812
+ width: 24,
813
+ height: 24,
814
+ borderRadius: 12,
815
+ background: 'var(--dsw-alias-bg-module-platform, #F5F6F7)',
816
+ display: 'inline-flex',
817
+ alignItems: 'center',
818
+ justifyContent: 'center',
819
+ fontSize: 11,
820
+ fontWeight: 600,
821
+ color: t.labelSecondary as string,
822
+ },
823
+ },
824
+ String(i + 1),
825
+ ),
826
+ h(
827
+ 'span',
828
+ {
829
+ style: {
830
+ fontSize: 12,
831
+ fontFamily: 'ui-monospace, monospace',
832
+ color: row.projectPath ? (t.labelPrimary as string) : (t.labelTertiary as string),
833
+ overflow: 'hidden',
834
+ textOverflow: 'ellipsis',
835
+ whiteSpace: 'nowrap',
836
+ minWidth: 0,
837
+ },
838
+ },
839
+ row.projectPath || 'Untitled project',
840
+ ),
841
+ ),
842
+ h(
843
+ Button as any,
844
+ { variant: 'outline', size: 'sm', onClick: () => removeRow(i), 'aria-label': `Remove project ${i + 1}`, title: 'Remove mapping' },
845
+ '✕',
846
+ ),
847
+ ),
848
+ // Grid 2-col for paths
849
+ h(
850
+ 'div',
851
+ { 'data-maestro-project-grid': '', style: { display: 'grid', gridTemplateColumns: '1fr 1fr', gap: 12 } },
852
+ h(
853
+ 'label',
854
+ { style: fieldLabelStyle, 'data-maestro-field': '' },
855
+ 'GitLab path',
856
+ h(FieldInput as any, {
857
+ placeholder: 'group/project',
858
+ value: row.projectPath,
859
+ onChange: (e: any) => updateRow(i, 'projectPath', e.target.value),
860
+ 'aria-label': `GitLab path ${i + 1}`,
861
+ style: { width: '100%' } as any,
862
+ }),
863
+ ),
864
+ h(
865
+ 'label',
866
+ { style: fieldLabelStyle, 'data-maestro-field': '' },
867
+ 'Local checkout',
868
+ h(FieldInput as any, {
869
+ placeholder: '/path/to/local/clone',
870
+ value: row.localRepoPath,
871
+ onChange: (e: any) => updateRow(i, 'localRepoPath', e.target.value),
872
+ 'aria-label': `Local checkout ${i + 1}`,
873
+ style: { width: '100%' } as any,
874
+ }),
875
+ ),
876
+ ),
877
+ // Row for profile + model — unified label+control, gap 12, pill h36
878
+ h(
879
+ 'div',
880
+ { 'data-maestro-project-profile-row': '', style: { display: 'flex', gap: 12, flexWrap: 'wrap' as const, alignItems: 'flex-start' } },
881
+ h(
882
+ 'label',
883
+ { style: { ...fieldLabelStyle, flex: '1 1 160px', minWidth: 0 } },
884
+ 'Review profile',
885
+ h(
886
+ 'select',
887
+ {
888
+ value: row.reviewProfile ?? 'magento2',
889
+ onChange: (e: any) => updateRow(i, 'reviewProfile', e.target.value),
890
+ 'aria-label': `Review profile ${i + 1}`,
891
+ style: {
892
+ height: 36,
893
+ width: '100%',
894
+ padding: '0 14px',
895
+ border: 'none',
896
+ borderRadius: 18,
897
+ background: 'var(--dsw-alias-bg-module-platform, #F5F6F7)' as string,
898
+ color: t.labelPrimary as string,
899
+ font: 'inherit',
900
+ fontSize: 13,
901
+ },
902
+ },
903
+ h('option', { value: 'magento2' }, 'Magento 2'),
904
+ h('option', { value: 'generic' }, 'Generic'),
905
+ ),
906
+ ),
907
+ h(
908
+ 'label',
909
+ { style: { ...fieldLabelStyle, flex: '1 1 200px', minWidth: 0 } },
910
+ 'Model override',
911
+ h(ReviewModelSelector as any, {
912
+ value: row.reviewModel ?? null,
913
+ catalog,
914
+ fallbackValue: globalReviewModel ?? catalog?.current ?? null,
915
+ fallbackLabel: globalReviewModel ? 'Use Global' : 'Use DSH default',
916
+ onChange: (v: any) => updateRow(i, 'reviewModel', v),
917
+ label: null,
918
+ }),
919
+ ),
920
+ ),
921
+ ),
922
+ ),
923
+ ),
924
+ )
925
+ }
926
+
927
+ function SecretInput({ label, placeholder, hasSaved, onSave }: { label: string; placeholder: string; hasSaved?: boolean; onSave: (v: string) => void }) {
928
+ const [draft, setDraft] = useState('')
929
+ const clear = () => {
930
+ setDraft('')
931
+ onSave('')
932
+ }
933
+ return h(
934
+ 'div',
935
+ null,
936
+ h('label', { style: fieldLabelStyle }, label),
937
+ h(
938
+ 'div',
939
+ { style: { display: 'flex', gap: 8 } },
940
+ h(FieldInput as any, {
941
+ placeholder: hasSaved === true ? 'saved — leave blank to keep' : placeholder,
942
+ type: 'password',
943
+ autoComplete: 'off',
944
+ value: draft,
945
+ onChange: (e: any) => setDraft(e.target.value),
946
+ onBlur: () => {
947
+ if (draft !== '') onSave(draft)
948
+ },
949
+ style: { flex: 1 } as any,
950
+ }),
951
+ hasSaved === true ? h(Button as any, { variant: 'outline', size: 'sm', onClick: clear }, 'Clear') : null,
952
+ ),
953
+ )
954
+ }
955
+
956
+ function ToggleField({ label, caption, checked, onChange }: { label: string; caption?: string; checked?: boolean; onChange: (v: boolean) => void }) {
957
+ return h(
958
+ 'label',
959
+ { style: { display: 'flex', alignItems: 'flex-start', gap: 10, margin: '8px 0', cursor: 'pointer' } },
960
+ h('input', { type: 'checkbox', checked: checked === true, onChange: (e: any) => onChange(e.target.checked), style: { marginTop: 4, width: 16, height: 16, accentColor: t.primaryFill as string } }),
961
+ h('span', null, h('div', { style: { fontSize: 13, color: t.labelPrimary as string, lineHeight: '18px' } }, label), caption ? h('div', { style: captionStyle }, caption) : null),
962
+ )
963
+ }
964
+
965
+ function LanAccess({ proxyStatus, lanPin }: { proxyStatus: any; lanPin: any }) {
966
+ const urls: string[] = proxyStatus?.lanUrls ?? []
967
+ const [selected, setSelected] = useState(0)
968
+ const index = Math.min(selected, Math.max(urls.length - 1, 0))
969
+ if (!proxyStatus?.running) {
970
+ return h('p', { style: { color: t.stateError as string, fontSize: 12, margin: '8px 0 0' } }, proxyStatus?.errorMessage ?? 'Proxy not running')
971
+ }
972
+ return h(
973
+ 'div',
974
+ null,
975
+ h('p', { style: captionStyle }, lanPin?.enabled === true ? 'Open this DSH UI from any device on your network — visitors enter the LAN PIN below.' : 'Open this DSH UI from any device on your network — no PIN needed.'),
976
+ urls.length > 0
977
+ ? h(
978
+ 'div',
979
+ { style: { display: 'flex', gap: 6, flexWrap: 'wrap', marginBottom: 12 } },
980
+ ...urls.map((url, i) =>
981
+ h(Button as any, { key: url, variant: i === index ? 'primary' : 'outline', size: 'sm', onClick: () => setSelected(i) }, url.replace(/^http:\/\//, '')),
982
+ ),
983
+ )
984
+ : null,
985
+ urls.length > 0
986
+ ? h(
987
+ 'div',
988
+ { 'data-maestro-qr-row': '', style: { display: 'flex', gap: 14, alignItems: 'center', flexWrap: 'wrap' as const } },
989
+ h(QrImage as any, { url: urls[index], size: 116 }),
990
+ h('div', null, h('div', { style: { fontFamily: 'ui-monospace, SFMono-Regular, Menlo, monospace', fontSize: 12, color: t.labelPrimary as string, wordBreak: 'break-all' } }, urls[index]), h('p', { style: captionStyle }, 'Scan with a phone on the same network.')),
991
+ )
992
+ : null,
993
+ lanPin !== null ? h(LanPinRow as any, { lanPin }) : null,
994
+ )
995
+ }
996
+
997
+ function LanPinRow({ lanPin }: { lanPin: any }) {
998
+ return h(
999
+ 'div',
1000
+ { style: { marginTop: 12 } },
1001
+ h(
1002
+ 'label',
1003
+ { style: { display: 'flex', gap: 8, alignItems: 'center', cursor: 'pointer' } },
1004
+ h('input', { type: 'checkbox', checked: lanPin.enabled, onChange: (e: any) => lanPin.onToggle(e.target.checked), style: { width: 15, height: 15, accentColor: t.primaryFill as string } }),
1005
+ h('span', { style: { ...fieldLabelStyle, margin: 0 } }, 'Require a PIN on the LAN'),
1006
+ ),
1007
+ lanPin.enabled
1008
+ ? h(
1009
+ 'div',
1010
+ { style: { display: 'flex', gap: 8, alignItems: 'center', marginTop: 8, flexWrap: 'wrap' as const } },
1011
+ h('span', { style: { ...fieldLabelStyle, margin: 0 } }, 'LAN PIN'),
1012
+ h('code', { style: { fontFamily: 'ui-monospace, monospace', fontSize: 15, letterSpacing: 2, color: t.labelPrimary as string } }, lanPin.show ? (lanPin.pin ?? '••••••••') : '••••••••'),
1013
+ lanPin.show ? h(Button as any, { variant: 'outline', size: 'sm', onClick: lanPin.onHide }, 'Hide') : h(Button as any, { variant: 'outline', size: 'sm', onClick: lanPin.onShow }, 'Show'),
1014
+ h(Button as any, { variant: 'outline', size: 'sm', onClick: lanPin.onRotate }, 'Rotate'),
1015
+ )
1016
+ : null,
1017
+ )
1018
+ }
1019
+
1020
+ function PublicAccess({ status, pin, showPin, onRevealPin, onHidePin, onRotatePin }: { status: any; pin: string | null; showPin: boolean; onRevealPin: () => void; onHidePin: () => void; onRotatePin: () => void }) {
1021
+ return h(
1022
+ 'div',
1023
+ null,
1024
+ status?.running && status?.publicUrl
1025
+ ? h(
1026
+ 'div',
1027
+ null,
1028
+ h(
1029
+ 'div',
1030
+ { 'data-maestro-qr-row': '', style: { display: 'flex', gap: 14, alignItems: 'center', marginBottom: 12, flexWrap: 'wrap' as const } },
1031
+ h(QrImage as any, { url: status.publicUrl, size: 116 }),
1032
+ h('div', null, h('div', { style: { fontFamily: 'ui-monospace, monospace', fontSize: 12, color: t.labelPrimary as string, wordBreak: 'break-all' } }, status.publicUrl), h('p', { style: captionStyle }, 'Works from anywhere; visitors enter the PIN below.')),
1033
+ ),
1034
+ )
1035
+ : h('p', { style: captionStyle }, 'Start the tunnel to get a public address.'),
1036
+ h(
1037
+ 'div',
1038
+ { style: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' as const } },
1039
+ h('span', { style: { ...fieldLabelStyle, margin: 0 } }, 'Access PIN'),
1040
+ h('code', { style: { fontFamily: 'ui-monospace, monospace', fontSize: 15, letterSpacing: 2, color: t.labelPrimary as string } }, showPin && pin !== null ? pin : '••••••••'),
1041
+ showPin ? h(Button as any, { variant: 'outline', size: 'sm', onClick: onHidePin }, 'Hide') : h(Button as any, { variant: 'outline', size: 'sm', onClick: onRevealPin }, 'Show'),
1042
+ h(Button as any, { variant: 'outline', size: 'sm', onClick: onRotatePin }, 'Rotate'),
1043
+ ),
1044
+ h('p', { style: captionStyle }, 'Stays the same across tunnel and DSH restarts; use Rotate when you need a new PIN.'),
1045
+ )
1046
+ }
1047
+
1048
+ function NamedTunnelSetupNote() {
1049
+ return h(
1050
+ 'div',
1051
+ { style: { ...cardInsetStyle, marginTop: '12px' } },
1052
+ h('p', { style: { ...captionStyle, marginBottom: 4, fontWeight: 500, color: t.labelPrimary as string } }, 'Named tunnel — one-time manual setup (requires your own Cloudflare account):'),
1053
+ h('ol', { style: { margin: '4px 0', paddingLeft: 20, fontSize: 12, color: t.labelSecondary as string, lineHeight: '18px' } }, h('li', null, 'cloudflared tunnel login'), h('li', null, 'cloudflared tunnel create dsh-maestro-webhook'), h('li', null, 'cloudflared tunnel route dns dsh-maestro-webhook <your-hostname>'), h('li', null, 'Paste the printed Tunnel ID, credentials file path and hostname below.')),
1054
+ )
1055
+ }
1056
+
1057
+ // ---------------------------------------------------------------------------
1058
+ // Main — DSH-native grouped settings (DisclosureRow per domain)
1059
+ // ---------------------------------------------------------------------------
1060
+ export function MaestroSettingsTab({ rpcCall, configRpcCall }: { rpcCall: any; configRpcCall?: any }) {
1061
+ const [status, setStatus] = useState<any>(null)
1062
+ const [proxyStatus, setProxyStatus] = useState<any>(null)
1063
+ const [config, setConfig] = useState<any>({ tunnelMode: 'quick', projectMappings: [] })
1064
+ const [catalog, setCatalog] = useState<any>(null)
1065
+ const [busy, setBusy] = useState(false)
1066
+ const [error, setError] = useState<string | null>(null)
1067
+ const [pin, setPin] = useState<string | null>(null)
1068
+ const [showPin, setShowPin] = useState(false)
1069
+ const [lanPinEnabled, setLanPinEnabled] = useState(false)
1070
+ const [lanPin, setLanPin] = useState<string | null>(null)
1071
+ const [showLanPin, setShowLanPin] = useState(false)
1072
+ const [guard, setGuard] = useState<any>({})
1073
+ const [patternsText, setPatternsText] = useState('')
1074
+ const [placeholdersText, setPlaceholdersText] = useState('')
1075
+ const [supervisorCfg, setSupervisorCfg] = useState<any>({})
1076
+ const [notifierCfg, setNotifierCfg] = useState<any>({})
1077
+ const [activeTab, setActiveTab] = useState('tunnel')
1078
+ // Mobile: inject responsive overrides once (mirrors dsh-maestro-mobile settings-sheet pill pattern + market catsWrap)
1079
+ useEffect(() => {
1080
+ const css = `
1081
+ /* Maestro nested tabs — market-like pill bar + mobile fixes */
1082
+ [data-maestro-tabs] { display:flex; gap:6px; overflow-x:auto; overflow-y:hidden; scrollbar-width:none; -webkit-overflow-scrolling:touch; overscroll-behavior-x:contain; touch-action:pan-x; padding-bottom:6px; margin-bottom:10px; border-bottom:1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.08)); }
1083
+ [data-maestro-tabs]::-webkit-scrollbar { display:none; width:0; height:0; }
1084
+ [data-maestro-tab] { flex:none; height:32px; min-width:fit-content; padding:0 14px; border-radius:999px; border:1px solid var(--dsw-alias-border-l1, rgba(0,0,0,.12)); background:transparent; color:var(--dsw-alias-label-primary); font-size:13px; line-height:20px; white-space:nowrap; display:inline-flex; align-items:center; justify-content:center; cursor:pointer; font-family:inherit; -webkit-tap-highlight-color:transparent; }
1085
+ [data-maestro-tab][data-active="true"] { background:var(--dsw-specific-sidebar-nav-item-active, #EBEEF2); border-color:var(--dsw-specific-sidebar-nav-item-active, #EBEEF2); color:var(--dsw-alias-label-primary); }
1086
+ [data-maestro-tab]:hover { background:var(--dsw-alias-interactive-bg-hover, rgba(0,0,0,.06)); }
1087
+ [data-maestro-tab][data-active="true"]:hover { background:var(--dsw-specific-sidebar-nav-item-active, #EBEEF2); }
1088
+ [data-maestro-tab]:focus-visible { outline:2px solid var(--dsw-alias-state-business-primary, #4f6ef7); outline-offset:1px; }
1089
+ [data-maestro-panel] { width:100%; min-width:0; box-sizing:border-box; }
1090
+ /* label → input dính nhau fix: flex column gap + full width */
1091
+ [data-maestro-panel] label { gap:6px !important; }
1092
+ [data-maestro-row]:last-child { border-bottom:none !important; }
1093
+ [data-maestro-panel] label > span { width:100% !important; box-sizing:border-box !important; }
1094
+ @media (max-width: 640px) {
1095
+ [data-maestro-settings-card] { max-width:100% !important; gap:6px !important; padding:0 2px !important; }
1096
+ [data-maestro-tabs] { gap:6px !important; padding:0 0 6px !important; margin:0 -2px 10px !important; }
1097
+ [data-maestro-tab] { height:32px !important; padding:0 12px !important; font-size:13px !important; }
1098
+ [data-maestro-mapping-row] { flex-direction:column !important; align-items:stretch !important; gap:8px !important; }
1099
+ [data-maestro-mapping-row] > * { flex:1 1 100% !important; width:100% !important; max-width:100% !important; }
1100
+ [data-maestro-qr-row] { flex-direction:column !important; align-items:flex-start !important; }
1101
+ [data-maestro-trigger-wrap] { max-width:100% !important; }
1102
+ [data-maestro-menu] { min-width:0 !important; max-width:calc(100vw - 32px) !important; left:0 !important; right:auto !important; }
1103
+ [data-maestro-panel] label { gap:8px !important; }
1104
+ div[data-maestro-row] { flex-direction:column !important; align-items:stretch !important; padding:12px 0 !important; }
1105
+ [data-maestro-row-text] { padding-right:0 !important; }
1106
+ [data-maestro-control] { width:100% !important; justify-content:flex-start !important; }
1107
+ [data-maestro-control] > span { width:100% !important; }
1108
+ [data-maestro-control] select { width:100% !important; }
1109
+ [data-maestro-project-grid] { grid-template-columns:1fr !important; }
1110
+ [data-maestro-project-card] { padding:10px !important; }
1111
+ [data-maestro-project-profile-row] { flex-direction:column !important; align-items:stretch !important; }
1112
+ [data-maestro-project-profile-row] > label { flex:1 1 100% !important; width:100% !important; }
1113
+ }
1114
+ @media (max-width: 390px) {
1115
+ [data-maestro-tab] { height:30px !important; padding:0 10px !important; font-size:12px !important; }
1116
+ }
1117
+ `
1118
+ const tag = document.createElement('style')
1119
+ tag.dataset.plugin = '@ddtcorex/dsh-maestro-config'
1120
+ tag.dataset.pluginCss = 'maestro/mobile-tabs.css'
1121
+ tag.textContent = css
1122
+ document.head.appendChild(tag)
1123
+ return () => tag.remove()
1124
+ }, [])
1125
+
1126
+ const call = async (endpoint: string, payload?: unknown) => {
1127
+ const res = await rpcCall(endpoint, payload)
1128
+ if (!res?.ok) throw new Error(res?.error?.message ?? 'RPC failed')
1129
+ return res.value
1130
+ }
1131
+ const unwrap = (res: any) => {
1132
+ if (res && typeof res === 'object' && 'ok' in res) {
1133
+ if (res.ok) return res.value
1134
+ throw new Error(res.error?.message ?? 'RPC failed')
1135
+ }
1136
+ return res
1137
+ }
1138
+ const cfgGet = async (domain: string) => {
1139
+ if (!configRpcCall) throw new Error('config RPC not available')
1140
+ const res = await configRpcCall('get', { domain })
1141
+ return unwrap(res)
1142
+ }
1143
+ const cfgSet = async (domain: string, patch: object) => {
1144
+ if (!configRpcCall) throw new Error('config RPC not available')
1145
+ const res = await configRpcCall('set', { domain, patch })
1146
+ return unwrap(res)
1147
+ }
1148
+ const saveGuard = async (patch: any) => {
1149
+ setError(null)
1150
+ const next = { ...guard, ...patch }
1151
+ if (patch.gitProtection && guard.gitProtection) next.gitProtection = { ...guard.gitProtection, ...patch.gitProtection }
1152
+ setGuard(next)
1153
+ try {
1154
+ await cfgSet('guard', patch)
1155
+ } catch (e: any) {
1156
+ setError(e.message ?? String(e))
1157
+ }
1158
+ }
1159
+ const commitBlacklistPatterns = async (text: string) => {
1160
+ const patterns = text.split('\n').map((s) => s.trim()).filter(Boolean)
1161
+ setError(null)
1162
+ try {
1163
+ await cfgSet('guardBlacklist', { patterns })
1164
+ } catch (e: any) {
1165
+ setError(e.message ?? String(e))
1166
+ }
1167
+ }
1168
+ const commitPlaceholders = async () => {
1169
+ setError(null)
1170
+ let obj: any = {}
1171
+ try {
1172
+ obj = placeholdersText.trim() ? JSON.parse(placeholdersText) : {}
1173
+ if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) throw new Error('placeholders must be JSON object')
1174
+ } catch (e: any) {
1175
+ setError(`placeholders JSON invalid: ${e.message ?? String(e)}`)
1176
+ return
1177
+ }
1178
+ try {
1179
+ await cfgSet('guardBlacklist', { placeholders: obj })
1180
+ } catch (e: any) {
1181
+ setError(e.message ?? String(e))
1182
+ }
1183
+ }
1184
+ const saveSupervisorCfg = async (patch: any) => {
1185
+ setError(null)
1186
+ setSupervisorCfg((prev: any) => ({ ...prev, ...patch }))
1187
+ try {
1188
+ await cfgSet('supervisor', patch)
1189
+ } catch (e: any) {
1190
+ setError(e.message ?? String(e))
1191
+ }
1192
+ }
1193
+ const saveNotifierCfg = async (patch: any) => {
1194
+ setError(null)
1195
+ setNotifierCfg((prev: any) => {
1196
+ const next = { ...prev }
1197
+ for (const [k, v] of Object.entries(patch)) {
1198
+ if (k === 'telegram' && typeof v === 'object' && v !== null) (next as any).telegram = { ...((prev as any).telegram ?? {}), ...(v as any) }
1199
+ else (next as any)[k] = v
1200
+ }
1201
+ return next
1202
+ })
1203
+ try {
1204
+ await cfgSet('notifier', patch)
1205
+ } catch (e: any) {
1206
+ setError(e.message ?? String(e))
1207
+ }
1208
+ }
1209
+ const refresh = async () => {
1210
+ try {
1211
+ setStatus(await call(MAESTRO_ENDPOINTS.status, {}))
1212
+ } catch {}
1213
+ try {
1214
+ setProxyStatus(await call(MAESTRO_ENDPOINTS.proxyStatus, {}))
1215
+ } catch {}
1216
+ }
1217
+ useEffect(() => {
1218
+ call(MAESTRO_ENDPOINTS.getConfig, {})
1219
+ .then((saved) => setConfig((prev: any) => ({ ...prev, ...saved })))
1220
+ .catch(() => {})
1221
+ if (configRpcCall) {
1222
+ configRpcCall('get', { domain: 'supervisor' })
1223
+ .then((res: any) => {
1224
+ if (res?.ok && res.value?.model) setConfig((prev: any) => ({ ...prev, supervisorModel: res.value.model }))
1225
+ })
1226
+ .catch(() => {})
1227
+ Promise.all([cfgGet('guard').catch(() => ({})), cfgGet('guardBlacklist').catch(() => ({ patterns: [], placeholders: {} })), cfgGet('supervisor').catch(() => ({})), cfgGet('notifier').catch(() => ({}))])
1228
+ .then(([g, bl, sup, not]) => {
1229
+ setGuard(g ?? {})
1230
+ const pats = Array.isArray((bl as any)?.patterns) ? (bl as any).patterns : []
1231
+ const ph = (bl as any)?.placeholders && typeof (bl as any).placeholders === 'object' ? (bl as any).placeholders : {}
1232
+ setPatternsText(pats.join('\n'))
1233
+ setPlaceholdersText(JSON.stringify(ph, null, 2))
1234
+ setSupervisorCfg(sup ?? {})
1235
+ setNotifierCfg(not ?? {})
1236
+ })
1237
+ .catch(() => {})
1238
+ }
1239
+ call(MAESTRO_ENDPOINTS.lanPinStatus, {})
1240
+ .then((value: any) => {
1241
+ setLanPinEnabled(value.enabled)
1242
+ if (value.enabled) setLanPin(value.pin ?? null)
1243
+ })
1244
+ .catch(() => {})
1245
+ call(MAESTRO_ENDPOINTS.modelsList, {})
1246
+ .then((value) => setCatalog(value))
1247
+ .catch(() => {})
1248
+ }, [])
1249
+ useEffect(() => {
1250
+ refresh()
1251
+ const t = setInterval(refresh, 3000)
1252
+ return () => clearInterval(t)
1253
+ }, [])
1254
+ const revealPin = async () => {
1255
+ if (pin === null) {
1256
+ try {
1257
+ setPin((await call(MAESTRO_ENDPOINTS.getPin, {})).pin)
1258
+ } catch (err: any) {
1259
+ setError(err.message)
1260
+ }
1261
+ }
1262
+ setShowPin(true)
1263
+ }
1264
+ const rotatePin = async () => {
1265
+ setError(null)
1266
+ try {
1267
+ const fresh = (await call(MAESTRO_ENDPOINTS.rotatePin, {})).pin
1268
+ setPin(fresh)
1269
+ setShowPin(true)
1270
+ } catch (err: any) {
1271
+ setError(err.message)
1272
+ }
1273
+ }
1274
+ const toggleLanPin = async (enabled: boolean) => {
1275
+ setError(null)
1276
+ const previous = lanPinEnabled
1277
+ setLanPinEnabled(enabled)
1278
+ try {
1279
+ await call(MAESTRO_ENDPOINTS.lanPinSetEnabled, { enabled })
1280
+ if (enabled) {
1281
+ const value = await call(MAESTRO_ENDPOINTS.lanPinStatus, {})
1282
+ setLanPin(value.pin ?? null)
1283
+ setShowLanPin(true)
1284
+ } else {
1285
+ setLanPin(null)
1286
+ setShowLanPin(false)
1287
+ }
1288
+ } catch (err: any) {
1289
+ setLanPinEnabled(previous)
1290
+ setError(err.message)
1291
+ }
1292
+ }
1293
+ const revealLanPin = async () => {
1294
+ if (lanPin === null) {
1295
+ try {
1296
+ setLanPin((await call(MAESTRO_ENDPOINTS.lanPinStatus, {})).pin ?? null)
1297
+ } catch (err: any) {
1298
+ setError(err.message)
1299
+ }
1300
+ }
1301
+ setShowLanPin(true)
1302
+ }
1303
+ const rotateLanPin = async () => {
1304
+ setError(null)
1305
+ try {
1306
+ const fresh = (await call(MAESTRO_ENDPOINTS.lanPinRotate, {})).pin
1307
+ setLanPin(fresh)
1308
+ setShowLanPin(true)
1309
+ } catch (err: any) {
1310
+ setError(err.message)
1311
+ }
1312
+ }
1313
+ const startTunnel = async () => {
1314
+ setBusy(true)
1315
+ setError(null)
1316
+ try {
1317
+ setStatus(await call(MAESTRO_ENDPOINTS.tunnelStart, {}))
1318
+ } catch (err: any) {
1319
+ setError(err.message)
1320
+ } finally {
1321
+ setBusy(false)
1322
+ }
1323
+ }
1324
+ const stopTunnel = async () => {
1325
+ setBusy(true)
1326
+ setError(null)
1327
+ try {
1328
+ setStatus(await call(MAESTRO_ENDPOINTS.tunnelStop, {}))
1329
+ } catch (err: any) {
1330
+ setError(err.message)
1331
+ } finally {
1332
+ setBusy(false)
1333
+ }
1334
+ }
1335
+ const saveField = async (field: string, value: unknown) => {
1336
+ setError(null)
1337
+ setConfig((prev: any) => ({ ...prev, [field]: value }))
1338
+ if (field === 'supervisorModel' && configRpcCall) {
1339
+ try {
1340
+ const res = await configRpcCall('set', { domain: 'supervisor', patch: { model: value } })
1341
+ if (res?.ok) return
1342
+ } catch {}
1343
+ }
1344
+ try {
1345
+ await call(MAESTRO_ENDPOINTS.saveConfig, { [field]: value })
1346
+ } catch (err: any) {
1347
+ setError(err.message)
1348
+ }
1349
+ }
1350
+
1351
+ // Nested tabs like plugin marketplace — pill bar + single panel (market catsWrap catsRow pattern)
1352
+ const TABS = [
1353
+ { id: 'tunnel', label: 'Tunnel' },
1354
+ { id: 'notify', label: 'Notify' },
1355
+ { id: 'gitlab', label: 'GitLab' },
1356
+ { id: 'review', label: 'Review' },
1357
+ { id: 'guard', label: 'Guard' },
1358
+ { id: 'blacklist', label: 'Blacklist' },
1359
+ { id: 'supervisor', label: 'Supervisor' },
1360
+ { id: 'notifier', label: 'Notifier' },
1361
+ ] as const
1362
+
1363
+ const tabContents: Record<string, unknown> = {
1364
+ tunnel: h(
1365
+ 'div',
1366
+ { style: { display: 'flex', flexDirection: 'column' } },
1367
+ h(SettingRow as any, { title: 'Tunnel mode', description: 'Quick = ephemeral URL, Named = stable URL with Cloudflare setup.', control: h('select', { value: config.tunnelMode, onChange: (e: any) => saveField('tunnelMode', e.target.value), style: { height: 36, padding: '0 12px', border: `1px solid ${t.borderL2}`, borderRadius: 18, background: 'var(--dsw-alias-bg-module-platform, #F5F6F7)' as string, color: t.labelPrimary as string, font: 'inherit', fontSize: 13 } }, h('option', { value: 'quick' }, 'Quick'), h('option', { value: 'named' }, 'Named')) }),
1368
+ config.tunnelMode === 'named' ? h(NamedTunnelSetupNote as any, null) : null,
1369
+ config.tunnelMode === 'named'
1370
+ ? h('div', { style: { display: 'flex', flexDirection: 'column' } },
1371
+ h(SettingRow as any, { title: 'Tunnel ID', description: 'Cloudflare tunnel ID.', control: h(FieldInput as any, { placeholder: 'Tunnel ID', value: config.tunnelId ?? '', onChange: (e: any) => saveField('tunnelId', e.target.value), style: { width: 260 } as any }) }),
1372
+ h(SettingRow as any, { title: 'Credentials file', description: 'Path to tunnel credentials JSON.', control: h(FieldInput as any, { placeholder: '~/.cloudflared/<id>.json', value: config.tunnelCredentialsFile ?? '', onChange: (e: any) => saveField('tunnelCredentialsFile', e.target.value), style: { width: 260 } as any }) }),
1373
+ h(SettingRow as any, { title: 'Hostname', description: 'Public hostname for the tunnel.', control: h(FieldInput as any, { placeholder: 'dsh.example.com', value: config.tunnelHostname ?? '', onChange: (e: any) => saveField('tunnelHostname', e.target.value), style: { width: 260 } as any }) }),
1374
+ )
1375
+ : null,
1376
+ h('div', { style: { display: 'flex', gap: 8, flexWrap: 'wrap' as const, padding: '12px 0', borderBottom: `1px solid ${t.borderL2}` } }, status?.running ? h(Button as any, { variant: 'outline', size: 'md', disabled: busy, onClick: stopTunnel }, 'Stop tunnel') : h(Button as any, { variant: 'primary', size: 'md', disabled: busy, onClick: startTunnel }, 'Start tunnel')),
1377
+ h('div', { style: { ...cardInsetStyle, marginTop: '12px' } }, h('div', { style: { fontSize: 13, fontWeight: 600, color: t.labelPrimary as string } }, 'Remote access — LAN'), h(LanAccess as any, { proxyStatus, lanPin: lanPinEnabled === null ? null : { enabled: lanPinEnabled, pin: lanPin, show: showLanPin, onShow: revealLanPin, onHide: () => setShowLanPin(false), onRotate: rotateLanPin, onToggle: toggleLanPin } })),
1378
+ h('div', { style: { ...cardInsetStyle, marginTop: '12px' } }, h('div', { style: { fontSize: 13, fontWeight: 600, color: t.labelPrimary as string } }, 'Public access'), h(PublicAccess as any, { status, pin, showPin, onRevealPin: revealPin, onHidePin: () => setShowPin(false), onRotatePin: rotatePin })),
1379
+ ),
1380
+ notify: h(
1381
+ 'div',
1382
+ { style: { display: 'flex', flexDirection: 'column' } },
1383
+ h('div', { style: { padding: '12px 0', borderBottom: `1px solid ${t.borderL2}` } }, h('p', { style: captionStyle }, 'Sends one protected startup update with the current public-access PIN to a single chat. Leave either credential blank to disable.')),
1384
+ h(SettingRow as any, { title: 'Bot token', description: 'Telegram bot token from @BotFather.', control: h('div', { style: { display: 'flex', gap: 8, alignItems: 'center' } }, h(FieldInput as any, { placeholder: '123456:ABC-DEF...', type: 'password', autoComplete: 'off', value: config.hasTelegramBotToken ? '••••••••' : '', onChange: (e: any) => saveField('telegramBotToken', e.target.value), style: { width: 220 } as any }), config.hasTelegramBotToken ? h(Button as any, { variant: 'outline', size: 'md', onClick: () => saveField('telegramBotToken', '') }, 'Clear') : null) }),
1385
+ h(SettingRow as any, { title: 'Chat ID', description: 'Target chat, e.g. -1001234567890.', control: h(FieldInput as any, { placeholder: '-1001234567890', value: config.telegramChatId ?? '', onChange: (e: any) => saveField('telegramChatId', e.target.value), style: { width: 220 } as any }) }),
1386
+ h(ToggleRow as any, { title: 'Also notify about reviews', description: 'One message per review run with its outcome.', checked: config.telegramReviewNotifications === true, onChange: (v: boolean) => saveField('telegramReviewNotifications', v) }),
1387
+ ),
1388
+ gitlab: h(
1389
+ 'div',
1390
+ { style: { display: 'flex', flexDirection: 'column' } },
1391
+ h(SettingRow as any, { title: 'GitLab base URL', description: 'e.g. https://gitlab.example.com', control: h(FieldInput as any, { placeholder: 'https://gitlab.example.com', value: config.gitlabBaseUrl ?? '', onChange: (e: any) => saveField('gitlabBaseUrl', e.target.value), style: { width: 260 } as any }) }),
1392
+ h(SettingRow as any, { title: 'GitLab token', description: 'Personal access token with api scope.', control: h('div', { style: { display: 'flex', gap: 8, alignItems: 'center' } }, h(FieldInput as any, { type: 'password', autoComplete: 'off', value: config.hasGitlabToken ? '••••••••' : '', placeholder: 'GitLab token', onChange: (e: any) => saveField('gitlabToken', e.target.value), style: { width: 200 } as any }), config.hasGitlabToken ? h(Button as any, { variant: 'outline', size: 'md', onClick: () => saveField('gitlabToken', '') }, 'Clear') : null) }),
1393
+ h(SettingRow as any, { title: 'Bot username', description: 'Username of the bot that posts reviews.', control: h(FieldInput as any, { placeholder: 'maestro-bot', value: config.botUsername ?? '', onChange: (e: any) => saveField('botUsername', e.target.value), style: { width: 220 } as any }) }),
1394
+ h(SettingRow as any, { title: 'Webhook secret', description: 'Secret token for GitLab webhooks.', control: h('div', { style: { display: 'flex', gap: 8, alignItems: 'center' } }, h(FieldInput as any, { type: 'password', autoComplete: 'off', value: config.hasWebhookSecret ? '••••••••' : '', placeholder: 'Webhook secret', onChange: (e: any) => saveField('webhookSecret', e.target.value), style: { width: 200 } as any }), h(Button as any, { variant: 'outline', size: 'md', onClick: () => saveField('webhookSecret', generateWebhookSecret()) }, 'Generate')) }),
1395
+ h('div', { style: { padding: '16px 0', display: 'flex', flexDirection: 'column', gap: 6 } },
1396
+ h('p', { style: captionStyle }, 'In GitLab: Settings → Webhooks, Secret token = this value, enable Merge request events. Webhook URL:'),
1397
+ h('div', { style: { fontFamily: 'ui-monospace, monospace', fontSize: 12, color: t.labelPrimary as string, wordBreak: 'break-all', padding: '10px 12px', borderRadius: 8, background: t.bgLayer3 as string, border: `1px solid ${t.borderL2}`, overflowWrap:'anywhere' as any } }, gitlabWebhookUrl(config.tunnelHostname)),
1398
+ ),
1399
+ ),
1400
+ review: h(
1401
+ 'div',
1402
+ { style: { display: 'flex', flexDirection: 'column' } },
1403
+ h(ToggleRow as any, { title: 'Re-review on push', description: 'When new commits are pushed, trigger an automatic re-review.', checked: config.autoRereviewOnPush === true, onChange: (v: boolean) => saveField('autoRereviewOnPush', v) }),
1404
+ h(SettingRow as any, { title: 'Global review model', description: 'Model for automated reviews. Empty = DSH default.', control: h(ReviewModelSelector as any, { value: config.reviewModel ?? null, catalog, fallbackValue: catalog?.current ?? null, fallbackLabel: 'Use DSH default', onChange: (v: any) => saveField('reviewModel', v), label: null }) }),
1405
+ h(SettingRow as any, { title: 'Supervisor model', description: 'Model for auto-fixing DSH Web crashes.', control: h(ReviewModelSelector as any, { value: config.supervisorModel ?? null, catalog, fallbackValue: catalog?.current ?? null, fallbackLabel: 'Use DSH default', onChange: (v: any) => saveField('supervisorModel', v), label: null }) }),
1406
+ h(ProjectMappingsEditor as any, { mappings: config.projectMappings ?? [], onChange: (mappings: any) => saveField('projectMappings', mappings), catalog, globalReviewModel: config.reviewModel ?? null }),
1407
+ ),
1408
+ guard: h(
1409
+ 'div',
1410
+ { style: { display: 'flex', flexDirection: 'column' } },
1411
+ h(ToggleRow as any, { title: 'Block publish commands', description: 'Prevent publish-related commands when enabled.', checked: guard.publishBlocked === true, onChange: (v: boolean) => saveGuard({ publishBlocked: v }) }),
1412
+ h(ToggleRow as any, { title: 'Protect git branches', description: 'Block direct pushes to protected branches.', checked: guard.gitProtection?.enabled === true, onChange: (v: boolean) => saveGuard({ gitProtection: { enabled: v, branches: guard.gitProtection?.branches ?? ['master', 'main'] } }) }),
1413
+ h(SettingRow as any, { title: 'Protected branches', description: 'Comma-separated list, e.g. master, main.', control: h(FieldInput as any, { value: (guard.gitProtection?.branches ?? ['master', 'main']).join(', '), placeholder: 'master, main', onChange: (e: any) => saveGuard({ gitProtection: { enabled: guard.gitProtection?.enabled ?? true, branches: e.target.value.split(',').map((s: any) => s.trim()).filter(Boolean) } }), style: { width: 260 } as any }) }),
1414
+ h(ToggleRow as any, { title: 'Contain working directory', description: 'Restrict file operations to the session working directory.', checked: guard.cwdContainment === true, onChange: (v: boolean) => saveGuard({ cwdContainment: v }) }),
1415
+ h(SettingRow as any, { title: 'Credential file paths', description: 'Comma-separated paths to credential files.', control: h(FieldInput as any, { value: (guard.credentialPaths ?? []).join(', '), placeholder: '~/.config/credentials.yaml', onChange: (e: any) => saveGuard({ credentialPaths: e.target.value.split(',').map((s: any) => s.trim()).filter(Boolean) }), style: { width: 260 } as any }) }),
1416
+ ),
1417
+ blacklist: h(
1418
+ 'div',
1419
+ { style: { display: 'flex', flexDirection: 'column' } },
1420
+ h('div', { style: { ...rowStyle, flexDirection:'column', alignItems:'stretch', gap: 8 } as any },
1421
+ h('div', { style: rowTitleStyle }, 'Blacklist patterns'),
1422
+ h('div', { style: rowDescStyle }, 'One pattern per line. Matching files are blocked from commit.'),
1423
+ h(TextareaField as any, { value: patternsText, placeholder: 'example-project\nacme-shop', onChange: (e: any) => setPatternsText(e.target.value), onBlur: (e: any) => commitBlacklistPatterns(e.target.value) }),
1424
+ ),
1425
+ h('div', { style: { ...rowStyle, flexDirection:'column', alignItems:'stretch', gap: 8, borderBottom:'none' } as any },
1426
+ h('div', { style: rowTitleStyle }, 'Placeholder mappings'),
1427
+ h('div', { style: rowDescStyle }, 'JSON object mapping blocked patterns to placeholder suggestions.'),
1428
+ h(TextareaField as any, { value: placeholdersText, placeholder: '{"example-project":"my-project"}', onChange: (e: any) => setPlaceholdersText(e.target.value), onBlur: () => commitPlaceholders(), style: { minHeight: 80 } as any }),
1429
+ h('div', { style: { marginTop: 8 } }, h(Button as any, { variant: 'outline', size: 'sm', onClick: () => { commitBlacklistPatterns(patternsText); commitPlaceholders() } }, 'Save Blacklist')),
1430
+ ),
1431
+ ),
1432
+ supervisor: h(
1433
+ 'div',
1434
+ { style: { display: 'flex', flexDirection: 'column' } },
1435
+ h(SettingRow as any, { title: 'Check interval', description: 'Milliseconds between supervisor checks. Default 5000.', control: h(FieldInput as any, { type: 'number', value: supervisorCfg.intervalMs ?? '', placeholder: '5000', onChange: (e: any) => { const v = e.target.value === '' ? undefined : Number(e.target.value); saveSupervisorCfg({ intervalMs: v }) }, style: { width: 160 } as any }) }),
1436
+ h(SettingRow as any, { title: 'Down threshold', description: 'Consecutive failures before marking a session as down.', control: h(FieldInput as any, { type: 'number', value: supervisorCfg.downThreshold ?? '', placeholder: '3', onChange: (e: any) => { const v = e.target.value === '' ? undefined : Number(e.target.value); saveSupervisorCfg({ downThreshold: v }) }, style: { width: 160 } as any }) }),
1437
+ h(ToggleRow as any, { title: 'Auto-resume sessions', description: 'Automatically resume sessions marked as down.', checked: supervisorCfg.autoResumeEnabled === true, onChange: (v: boolean) => saveSupervisorCfg({ autoResumeEnabled: v }) }),
1438
+ ),
1439
+ notifier: h(
1440
+ 'div',
1441
+ { style: { display: 'flex', flexDirection: 'column' } },
1442
+ h(SettingRow as any, { title: 'Bot token', description: 'Telegram bot token from @BotFather.', control: h(FieldInput as any, { type: 'password', autoComplete: 'off', value: notifierCfg.telegram?.botToken ?? '', placeholder: '123456:ABC-DEF...', onChange: (e: any) => saveNotifierCfg({ telegram: { botToken: e.target.value } }), style: { width: 260 } as any }) }),
1443
+ h(SettingRow as any, { title: 'Chat ID', description: 'Target chat, e.g. -1001234567890.', control: h(FieldInput as any, { value: notifierCfg.telegram?.chatId ?? '', placeholder: '-1001234567890', onChange: (e: any) => saveNotifierCfg({ telegram: { chatId: e.target.value } }), style: { width: 260 } as any }) }),
1444
+ h(ToggleRow as any, { title: 'Review notifications', description: 'Also notify about finished reviews.', checked: notifierCfg.telegram?.reviewNotifications === true || notifierCfg.policy?.reviewNotifications === true, onChange: (v: boolean) => saveNotifierCfg({ telegram: { reviewNotifications: v } }) }),
1445
+ ),
1446
+ }
1447
+
1448
+ return h(
1449
+ 'div',
1450
+ { 'data-maestro-settings-card': '', style: { display: 'flex', flexDirection: 'column', gap: 8, width: '100%', maxWidth: 640, minWidth:0, boxSizing:'border-box' as any } },
1451
+ h(
1452
+ 'div',
1453
+ { style: { padding: '2px 2px 8px' } },
1454
+ h('div', { style: { fontSize: 15, fontWeight: 600, color: t.labelPrimary as string, lineHeight: '22px' } }, 'Maestro'),
1455
+ h('div', { style: { fontSize: 12, color: t.labelSecondary as string, lineHeight: '16px', marginTop: 2 } }, 'Tunnel, access, review & guard — all via the shared Maestro store. Uses the same tokens and primitives as DSH settings.'),
1456
+ ),
1457
+ h('div', { 'data-maestro-tabs': '', role:'tablist', 'aria-label':'Maestro settings sections' },
1458
+ ...TABS.map(tab => h('button', { key: tab.id, 'data-maestro-tab':'', 'data-active': String(activeTab===tab.id), role:'tab', 'aria-selected': activeTab===tab.id, onClick: () => setActiveTab(tab.id) }, tab.label))
1459
+ ),
1460
+ h('div', { 'data-maestro-panel': activeTab, style: { display:'flex', flexDirection:'column', gap:10, minWidth:0 } }, tabContents[activeTab] as any),
1461
+ error ? h('p', { style: { color: t.stateError as string, fontSize: 12, margin: '8px 0 0', padding: '8px 10px', borderRadius: 8, background: 'color-mix(in srgb, var(--dsw-alias-state-error-primary) 10%, transparent)', border: `1px solid color-mix(in srgb, var(--dsw-alias-state-error-primary) 30%, transparent)` } }, error) : null,
1462
+ )
1463
+ }