@mohou/panel 1.0.20

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (113) hide show
  1. package/README.md +8 -0
  2. package/lib/types/chrome/hue.d.ts +3 -0
  3. package/lib/types/chrome/hue.d.ts.map +1 -0
  4. package/lib/types/gallery/card-style.d.ts +8 -0
  5. package/lib/types/gallery/card-style.d.ts.map +1 -0
  6. package/lib/types/gallery/client.d.ts +20 -0
  7. package/lib/types/gallery/client.d.ts.map +1 -0
  8. package/lib/types/gallery/desk.d.ts +16 -0
  9. package/lib/types/gallery/desk.d.ts.map +1 -0
  10. package/lib/types/gallery/list.d.ts +37 -0
  11. package/lib/types/gallery/list.d.ts.map +1 -0
  12. package/lib/types/gallery/sort.d.ts +24 -0
  13. package/lib/types/gallery/sort.d.ts.map +1 -0
  14. package/lib/types/gallery/state.d.ts +90 -0
  15. package/lib/types/gallery/state.d.ts.map +1 -0
  16. package/lib/types/gallery/tabs.d.ts +45 -0
  17. package/lib/types/gallery/tabs.d.ts.map +1 -0
  18. package/lib/types/gallery/view.d.ts +57 -0
  19. package/lib/types/gallery/view.d.ts.map +1 -0
  20. package/lib/types/history/client.d.ts +25 -0
  21. package/lib/types/history/client.d.ts.map +1 -0
  22. package/lib/types/history/state.d.ts +39 -0
  23. package/lib/types/history/state.d.ts.map +1 -0
  24. package/lib/types/history/view.d.ts +15 -0
  25. package/lib/types/history/view.d.ts.map +1 -0
  26. package/lib/types/index.d.ts +24 -0
  27. package/lib/types/index.d.ts.map +1 -0
  28. package/lib/types/labels.d.ts +267 -0
  29. package/lib/types/labels.d.ts.map +1 -0
  30. package/lib/types/settings/author-mcp-view.d.ts +15 -0
  31. package/lib/types/settings/author-mcp-view.d.ts.map +1 -0
  32. package/lib/types/settings/author-mcp.d.ts +4 -0
  33. package/lib/types/settings/author-mcp.d.ts.map +1 -0
  34. package/lib/types/settings/client.d.ts +198 -0
  35. package/lib/types/settings/client.d.ts.map +1 -0
  36. package/lib/types/settings/credentials-view.d.ts +15 -0
  37. package/lib/types/settings/credentials-view.d.ts.map +1 -0
  38. package/lib/types/settings/form.d.ts +27 -0
  39. package/lib/types/settings/form.d.ts.map +1 -0
  40. package/lib/types/settings/install-row.d.ts +10 -0
  41. package/lib/types/settings/install-row.d.ts.map +1 -0
  42. package/lib/types/settings/mcp-view.d.ts +14 -0
  43. package/lib/types/settings/mcp-view.d.ts.map +1 -0
  44. package/lib/types/settings/skill-dirs.d.ts +6 -0
  45. package/lib/types/settings/skill-dirs.d.ts.map +1 -0
  46. package/lib/types/settings/skill-install.d.ts +13 -0
  47. package/lib/types/settings/skill-install.d.ts.map +1 -0
  48. package/lib/types/settings/state.d.ts +94 -0
  49. package/lib/types/settings/state.d.ts.map +1 -0
  50. package/lib/types/settings/update-dialog.d.ts +56 -0
  51. package/lib/types/settings/update-dialog.d.ts.map +1 -0
  52. package/lib/types/settings/view.d.ts +26 -0
  53. package/lib/types/settings/view.d.ts.map +1 -0
  54. package/lib/types/storage/client.d.ts +15 -0
  55. package/lib/types/storage/client.d.ts.map +1 -0
  56. package/lib/types/storage/state.d.ts +42 -0
  57. package/lib/types/storage/state.d.ts.map +1 -0
  58. package/lib/types/storage/view.d.ts +16 -0
  59. package/lib/types/storage/view.d.ts.map +1 -0
  60. package/lib/types/surface/state.d.ts +42 -0
  61. package/lib/types/surface/state.d.ts.map +1 -0
  62. package/lib/types/surface/view.d.ts +34 -0
  63. package/lib/types/surface/view.d.ts.map +1 -0
  64. package/lib/types/theme/client.d.ts +45 -0
  65. package/lib/types/theme/client.d.ts.map +1 -0
  66. package/lib/types/theme/menu.d.ts +25 -0
  67. package/lib/types/theme/menu.d.ts.map +1 -0
  68. package/lib/types/theme/state.d.ts +46 -0
  69. package/lib/types/theme/state.d.ts.map +1 -0
  70. package/lib/types/theme/view.d.ts +16 -0
  71. package/lib/types/theme/view.d.ts.map +1 -0
  72. package/lib/types/ui/dialog.d.ts +12 -0
  73. package/lib/types/ui/dialog.d.ts.map +1 -0
  74. package/lib/types/ui/tooltip.d.ts +12 -0
  75. package/lib/types/ui/tooltip.d.ts.map +1 -0
  76. package/package.json +38 -0
  77. package/src/chrome/hue.ts +6 -0
  78. package/src/gallery/card-style.ts +27 -0
  79. package/src/gallery/client.ts +26 -0
  80. package/src/gallery/desk.tsx +54 -0
  81. package/src/gallery/list.ts +54 -0
  82. package/src/gallery/sort.ts +72 -0
  83. package/src/gallery/state.ts +229 -0
  84. package/src/gallery/tabs.ts +85 -0
  85. package/src/gallery/view.tsx +501 -0
  86. package/src/history/client.ts +27 -0
  87. package/src/history/state.ts +90 -0
  88. package/src/history/view.tsx +131 -0
  89. package/src/index.ts +61 -0
  90. package/src/labels.ts +536 -0
  91. package/src/settings/author-mcp-view.tsx +154 -0
  92. package/src/settings/author-mcp.ts +24 -0
  93. package/src/settings/client.ts +205 -0
  94. package/src/settings/credentials-view.tsx +195 -0
  95. package/src/settings/form.ts +27 -0
  96. package/src/settings/install-row.tsx +35 -0
  97. package/src/settings/mcp-view.tsx +793 -0
  98. package/src/settings/skill-dirs.ts +41 -0
  99. package/src/settings/skill-install.tsx +134 -0
  100. package/src/settings/state.ts +225 -0
  101. package/src/settings/update-dialog.tsx +207 -0
  102. package/src/settings/view.tsx +722 -0
  103. package/src/storage/client.ts +16 -0
  104. package/src/storage/state.ts +94 -0
  105. package/src/storage/view.tsx +190 -0
  106. package/src/surface/state.ts +72 -0
  107. package/src/surface/view.tsx +255 -0
  108. package/src/theme/client.ts +34 -0
  109. package/src/theme/menu.tsx +286 -0
  110. package/src/theme/state.ts +113 -0
  111. package/src/theme/view.tsx +73 -0
  112. package/src/ui/dialog.tsx +25 -0
  113. package/src/ui/tooltip.tsx +19 -0
@@ -0,0 +1,722 @@
1
+ import { Check, ExternalLink, Loader2, Pencil, RefreshCw } from 'lucide-react'
2
+ import { useEffect, useReducer, useState, type ReactNode } from 'react'
3
+
4
+ import { galleryCardStyles, type GalleryCardStyle } from '../gallery/list.ts'
5
+ import { isPanelLocale, panelText, type PanelLabelMode, type PanelLocale } from '../labels.ts'
6
+ import { Dialog } from '../ui/dialog.tsx'
7
+ import type { PaletteChip } from '../theme/client.ts'
8
+ import { applyDocumentMode, paintHostStyle } from '../theme/menu.tsx'
9
+ import { AuthorMcpInstall } from './author-mcp-view.tsx'
10
+ import type { PanelAbout, PanelAuthorMcpStatus, PanelPolicy, PanelRuntime, PanelSettingsClient, PanelSkillStatus, PanelUpdateCheck } from './client.ts'
11
+ import { CredentialSettings } from './credentials-view.tsx'
12
+ import { McpSettings } from './mcp-view.tsx'
13
+ import { SkillInstall } from './skill-install.tsx'
14
+ import { loadSettings, reduceSettings, settingsDraft, settingsState } from './state.ts'
15
+
16
+ /** The rail. Credentials appear only when Host exposes the read call, so a store-less host shows no entry. */
17
+ function sections(props: { readonly client?: PanelSettingsClient }): readonly SettingsSection[] {
18
+ return props.client?.readCredentials === undefined
19
+ ? ['appearance', 'network', 'agent', 'mcp', 'about']
20
+ : ['appearance', 'network', 'agent', 'mcp', 'credentials', 'about']
21
+ }
22
+
23
+ type SettingsSection = 'appearance' | 'network' | 'agent' | 'mcp' | 'credentials' | 'about'
24
+
25
+ /**
26
+ * Settings form. Hidden when Host exposes no config client. No route string lives here.
27
+ * @param props - client, chrome locale, and the palette list for the appearance cards
28
+ */
29
+ export function PanelSettings(props: {
30
+ readonly client?: PanelSettingsClient
31
+ readonly locale: PanelLocale
32
+ readonly mode: PanelLabelMode
33
+ readonly versions?: string
34
+ readonly palettes?: readonly PaletteChip[]
35
+ readonly cardStyle?: GalleryCardStyle
36
+ readonly onCardStyle?: (style: GalleryCardStyle) => void
37
+ readonly onClose?: () => void
38
+ readonly onPolicy?: (policy: PanelPolicy) => void
39
+ /** Live host policy from the theme menu (or other writers). */
40
+ readonly hostPolicy?: PanelPolicy
41
+ /** Preview chrome locale while the form is open (discard restores the saved locale). */
42
+ readonly onPreviewLocale?: (locale: PanelLocale) => void
43
+ readonly onUpdateOffer?: (update: PanelUpdateCheck) => void
44
+ }): ReactNode {
45
+ const [state, dispatch] = useReducer(reduceSettings, undefined, settingsState)
46
+ const [saveFeedback, setSaveFeedback] = useState<'idle' | 'saving' | 'saved'>('idle')
47
+ const [section, setSection] = useState<SettingsSection>('appearance')
48
+ const [runtimes, setRuntimes] = useState<readonly PanelRuntime[]>([])
49
+ const [runtimeBusy, setRuntimeBusy] = useState(false)
50
+ const [update, setUpdate] = useState<PanelUpdateCheck | 'checking' | 'failed' | undefined>(undefined)
51
+ const [restarting, setRestarting] = useState(false)
52
+ const [portUnlocked, setPortUnlocked] = useState(false)
53
+ useEffect(() => {
54
+ if (section !== 'network') setPortUnlocked(false)
55
+ }, [section])
56
+ useEffect(() => {
57
+ if (props.client === undefined) return
58
+ void loadSettings(props.client, dispatch)
59
+ }, [props.client])
60
+ useEffect(() => {
61
+ if (props.hostPolicy === undefined) return
62
+ dispatch({ type: 'host-policy', policy: props.hostPolicy })
63
+ }, [props.hostPolicy])
64
+ useEffect(() => {
65
+ if (state.closed) props.onClose?.()
66
+ }, [state.closed])
67
+ useEffect(() => {
68
+ const list = props.client?.listRuntimes
69
+ if (list === undefined) return
70
+ void list().then(setRuntimes, () => setRuntimes([]))
71
+ }, [props.client])
72
+ // Live preview: theme / palette / locale. Discard paths restore via fromPolicy then this effect.
73
+ useEffect(() => {
74
+ if (state.saved === undefined) return
75
+ if (state.theme === 'light' || state.theme === 'dark' || state.theme === 'system') {
76
+ applyDocumentMode(state.theme)
77
+ }
78
+ const chip = (props.palettes ?? []).find(item => item.id === state.palette)
79
+ if (chip?.style !== undefined && chip.style.length > 0) paintHostStyle(chip.style)
80
+ if (isPanelLocale(state.locale)) props.onPreviewLocale?.(state.locale)
81
+ }, [state.theme, state.palette, state.locale, state.saved, props.palettes, props.onPreviewLocale])
82
+ if (props.client === undefined) return null
83
+ const labelLocale = isPanelLocale(state.locale) ? state.locale : props.locale
84
+ const label = (key: string) => panelText(labelLocale, key, props.mode)
85
+ const client = props.client
86
+ const onSave = async () => {
87
+ if (saveFeedback === 'saving') return
88
+ setSaveFeedback('saving')
89
+ const started = Date.now()
90
+ const written = await saveSettings(client, state, dispatch)
91
+ // Keep the in-progress spinner visible long enough to read (fast saves otherwise flash).
92
+ const elapsed = Date.now() - started
93
+ if (elapsed < 500) await new Promise(resolve => window.setTimeout(resolve, 500 - elapsed))
94
+ if (written === undefined) {
95
+ setSaveFeedback('idle')
96
+ return
97
+ }
98
+ props.onPolicy?.(written)
99
+ setSaveFeedback('saved')
100
+ window.setTimeout(() => setSaveFeedback('idle'), 2000)
101
+ }
102
+ return (
103
+ <form
104
+ className="flex h-full flex-col bg-background text-foreground"
105
+ data-dirty={state.dirty}
106
+ data-closed={state.closed}
107
+ onKeyDown={(event) => {
108
+ if (event.key === 'Escape') dispatch({ type: 'escape' })
109
+ }}
110
+ >
111
+ <header className="flex items-center justify-between border-b bg-card px-4 py-3">
112
+ <div className="min-w-0">
113
+ <h3>{label('settings')}</h3>
114
+ </div>
115
+ <div className="flex items-center gap-2">
116
+ <button type="button" className="inline-flex h-8 items-center rounded-lg px-3 text-sm hover:bg-muted" onClick={() => dispatch({ type: 'escape' })}>{label('settings-close')}</button>
117
+ </div>
118
+ </header>
119
+ <div className="flex min-h-0 flex-1">
120
+ <nav className="flex w-36 shrink-0 flex-col gap-1 border-r bg-card p-3">
121
+ {sections(props).map(id => (
122
+ <button key={id} type="button" className="rounded-lg px-2 py-1.5 text-left text-sm data-[on=1]:bg-muted data-[on=1]:font-medium" data-nav={id} data-on={section === id ? '1' : '0'} onClick={() => setSection(id)}>{label(`section-${id}`)}</button>
123
+ ))}
124
+ </nav>
125
+ <div className="min-h-0 flex-1 overflow-auto px-6 py-5">
126
+ {section === 'mcp' ? <McpSettings client={client} locale={labelLocale} mode={props.mode} /> : null}
127
+ {section === 'credentials' ? <CredentialSettings client={client} locale={labelLocale} mode={props.mode} /> : null}
128
+ {section === 'appearance' ? (<>
129
+ <section className="mb-6">
130
+ <h4>{label('section-appearance')}</h4>
131
+ <div className="mb-4 flex flex-col gap-2">
132
+ <span className="text-sm text-muted-foreground">{label('language')}</span>
133
+ <div className="flex max-w-xs gap-1 rounded-lg bg-muted p-1">
134
+ <button type="button" className="h-7 flex-1 rounded-md text-xs data-[on=1]:bg-card data-[on=1]:font-semibold data-[on=1]:shadow-sm" data-on={state.locale === 'zh-CN' ? '1' : '0'} onClick={() => dispatch({ type: 'edit-locale', locale: 'zh-CN' })}>{label('lang-zh')}</button>
135
+ <button type="button" className="h-7 flex-1 rounded-md text-xs data-[on=1]:bg-card data-[on=1]:font-semibold data-[on=1]:shadow-sm" data-on={state.locale === 'en' ? '1' : '0'} onClick={() => dispatch({ type: 'edit-locale', locale: 'en' })}>{label('lang-en')}</button>
136
+ </div>
137
+ </div>
138
+ <div className="mb-4 flex flex-col gap-2">
139
+ <span className="text-sm text-muted-foreground">{label('appearance')}</span>
140
+ <div className="grid grid-cols-3 gap-2">
141
+ {(['system', 'light', 'dark'] as const).map(theme => (
142
+ <button key={theme} type="button" className={choiceCardClass} data-on={state.theme === theme ? '1' : '0'} onClick={() => dispatch({ type: 'edit-theme', theme })}>
143
+ <span className={theme === 'dark' ? 'block h-10 rounded-lg bg-foreground/80' : theme === 'light' ? 'block h-10 rounded-lg border bg-background' : 'block h-10 rounded-lg bg-gradient-to-r from-background to-foreground/80'} />
144
+ <span className="text-xs data-[on=1]:font-semibold">{label(themeLabel(theme))}</span>
145
+ </button>
146
+ ))}
147
+ </div>
148
+ </div>
149
+ <div className="mb-4 flex flex-col gap-2">
150
+ <span className="text-sm text-muted-foreground">{label('palette')}</span>
151
+ <input className="sr-only" value={state.palette} onChange={event => dispatch({ type: 'edit-palette', palette: event.target.value })} />
152
+ <div className="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-4">
153
+ {(props.palettes ?? []).map((palette) => {
154
+ const chip = palettePreview(palette)
155
+ return (
156
+ <button key={palette.id} type="button" className={choiceCardClass} data-on={state.palette === palette.id ? '1' : '0'} onClick={() => dispatch({ type: 'edit-palette', palette: palette.id })}>
157
+ <span className="flex h-10 items-end gap-1 rounded-lg border border-border/60 px-2 pb-2" style={{ background: chip.surface }}>
158
+ <span className="h-5 flex-1 rounded-sm" style={{ background: chip.primary }} />
159
+ <span className="h-3 flex-1 rounded-sm" style={{ background: chip.muted }} />
160
+ <span className="h-4 w-3 rounded-sm border border-border/50" style={{ background: chip.card }} />
161
+ </span>
162
+ <span className="text-xs">{paletteLabel(labelLocale, palette)}</span>
163
+ </button>
164
+ )
165
+ })}
166
+ </div>
167
+ </div>
168
+ {props.onCardStyle === undefined || props.cardStyle === undefined ? null : (
169
+ <div className="mb-4 flex flex-col gap-2">
170
+ <span className="text-sm text-muted-foreground">{label('card-style')}</span>
171
+ <div className="mt-2 grid grid-cols-2 gap-2 sm:grid-cols-4">
172
+ {galleryCardStyles.map(style => (
173
+ <button key={style} type="button" className={choiceCardClass} aria-pressed={props.cardStyle === style} data-on={props.cardStyle === style ? '1' : '0'} onClick={() => props.onCardStyle?.(style)}>
174
+ <CardStylePreview style={style} />
175
+ <span className="text-xs">{label(cardKey(style))}</span>
176
+ </button>
177
+ ))}
178
+ </div>
179
+ </div>
180
+ )}
181
+ </section>
182
+ </>) : null}
183
+ {section === 'network' ? (
184
+ <section className="mb-6">
185
+ <h4>{label('section-network')}</h4>
186
+ <div className={state.portError ? 'mb-4 flex flex-col gap-2 text-destructive' : 'mb-4 flex flex-col gap-2'}>
187
+ <div className="flex items-center gap-2">
188
+ <label className="text-sm text-muted-foreground" htmlFor="settings-host-port">{label('port')}</label>
189
+ <button
190
+ type="button"
191
+ className="inline-flex size-7 items-center justify-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground"
192
+ aria-label={portUnlocked ? label('port-lock') : label('port-edit')}
193
+ aria-pressed={portUnlocked}
194
+ onClick={() => { setPortUnlocked(open => !open) }}
195
+ >
196
+ <Pencil size={14} className={portUnlocked ? 'text-primary' : 'opacity-60'} />
197
+ </button>
198
+ </div>
199
+ <p className="m-0 max-w-xl text-xs leading-5 text-muted-foreground">{label('port-help')}</p>
200
+ <div className="flex flex-wrap items-center gap-2">
201
+ <div className="flex items-center gap-0">
202
+ <span className="flex h-9 items-center rounded-l-lg border bg-muted px-2 font-mono text-sm text-muted-foreground">127.0.0.1</span>
203
+ <span className="flex h-9 items-center border border-l-0 bg-muted px-1 font-mono text-sm text-muted-foreground">:</span>
204
+ <input
205
+ id="settings-host-port"
206
+ data-field="port"
207
+ readOnly={!portUnlocked}
208
+ className={portUnlocked
209
+ ? 'h-9 w-28 rounded-r-lg border border-l-0 bg-card px-3 font-mono text-sm outline-none'
210
+ : 'h-9 w-28 cursor-default rounded-r-lg border border-l-0 bg-muted px-3 font-mono text-sm text-muted-foreground outline-none'}
211
+ value={state.port}
212
+ onChange={(event) => {
213
+ if (!portUnlocked) return
214
+ dispatch({ type: 'edit-port', port: event.target.value })
215
+ }}
216
+ />
217
+ </div>
218
+ {hostOrigin(state.port) === undefined ? null : (
219
+ <a
220
+ className="inline-flex h-9 items-center gap-1.5 rounded-lg border px-3 text-sm text-primary hover:bg-muted"
221
+ href={hostOrigin(state.port)}
222
+ target="_blank"
223
+ rel="noreferrer"
224
+ >
225
+ <ExternalLink size={14} />
226
+ {label('port-open-panel')}
227
+ </a>
228
+ )}
229
+ </div>
230
+ {hostOrigin(state.port) === undefined ? null : (
231
+ <div className="mt-1 grid max-w-xl gap-1 text-xs text-muted-foreground">
232
+ <p className="m-0 flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
233
+ <span>{label('port-panel-url')}</span>
234
+ <a className="font-mono text-foreground underline-offset-2 hover:underline" href={hostOrigin(state.port)} target="_blank" rel="noreferrer">{hostOrigin(state.port)}</a>
235
+ </p>
236
+ <p className="m-0 flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
237
+ <span>{label('port-mcp-url')}</span>
238
+ <code className="rounded bg-muted px-1.5 py-0.5 font-mono text-[11px] text-foreground">{`${hostOrigin(state.port)}/mcp`}</code>
239
+ </p>
240
+ </div>
241
+ )}
242
+ {state.portError ? <p className="text-xs text-muted-foreground">{label('port-invalid')}</p> : null}
243
+ </div>
244
+ </section>
245
+ ) : null}
246
+ {section === 'agent' ? (
247
+ <AgentSection
248
+ state={state}
249
+ runtimes={runtimes}
250
+ busy={runtimeBusy}
251
+ restarting={restarting}
252
+ label={label}
253
+ onRuntime={(providerId) => {
254
+ dispatch({ type: 'edit-provider', providerId })
255
+ const first = runtimes.find(item => item.id === providerId)?.models[0]
256
+ dispatch({ type: 'edit-model', provider: first?.provider ?? '', model: first?.models[0] ?? '' })
257
+ }}
258
+ onModel={(provider, model) => dispatch({ type: 'edit-model', provider, model })}
259
+ onProbe={() => { void probeSettings(client, state.providerId, dispatch) }}
260
+ onRefresh={() => {
261
+ const list = client.listRuntimes
262
+ if (list === undefined) return
263
+ setRuntimeBusy(true)
264
+ void list().then(setRuntimes, () => setRuntimes([])).finally(() => setRuntimeBusy(false))
265
+ }}
266
+ {...client.restartHost === undefined ? {} : {
267
+ onRestart: () => {
268
+ void rebootHost(client, setRestarting, label, dispatch, hostOrigin(String(state.saved?.hostPort ?? state.port)))
269
+ },
270
+ }}
271
+ {...client.readAbout === undefined ? {} : { readAbout: client.readAbout }}
272
+ {...client.readSkill === undefined ? {} : { readSkill: client.readSkill }}
273
+ {...client.installSkill === undefined ? {} : { installSkill: client.installSkill }}
274
+ {...client.revealSkill === undefined ? {} : { revealSkill: client.revealSkill }}
275
+ {...client.readAuthorMcp === undefined ? {} : { readAuthorMcp: client.readAuthorMcp }}
276
+ {...client.installAuthorMcp === undefined ? {} : { installAuthorMcp: client.installAuthorMcp }}
277
+ {...client.revealAuthorMcp === undefined ? {} : { revealAuthorMcp: client.revealAuthorMcp }}
278
+ />
279
+ ) : null}
280
+ {section === 'about' ? (
281
+ <AboutSection
282
+ update={update}
283
+ label={label}
284
+ {...props.versions === undefined ? {} : { versions: props.versions }}
285
+ {...client.checkUpdate === undefined ? {} : { onCheck: () => { void readUpdate(client, setUpdate, props.onUpdateOffer) } }}
286
+ />
287
+ ) : null}
288
+ {section === 'mcp' || section === 'about' ? null : state.saveError ? <p className="text-sm text-destructive">{state.saveMessage !== undefined && state.saveMessage.length > 0 ? state.saveMessage : label('save-failed')}</p> : null}
289
+ {section === 'mcp' || section === 'about' || section === 'agent' || !state.restartRequired ? null : (
290
+ <div className="mt-3 space-y-2">
291
+ <div className="flex flex-wrap items-center gap-2">
292
+ <p className="m-0 text-sm text-primary">{label('restart-required')}</p>
293
+ {client.restartHost === undefined ? null : (
294
+ <button type="button" className="h-8 rounded-lg bg-primary px-3 text-sm font-semibold text-primary-foreground disabled:opacity-60" disabled={restarting} onClick={() => { void rebootHost(client, setRestarting, label, dispatch, hostOrigin(String(state.saved?.hostPort ?? state.port))) }}>{restarting ? label('restart-wait') : label('restart-host')}</button>
295
+ )}
296
+ </div>
297
+ {state.restartPort ? <p className="m-0 text-sm text-muted-foreground">{label('restart-mcp-hint')}</p> : null}
298
+ {state.restartRuntime ? <p className="m-0 text-sm text-muted-foreground">{label('restart-runtime-hint')}</p> : null}
299
+ </div>
300
+ )}
301
+ {state.pending === undefined ? null : (
302
+ <Dialog width="sm" onClose={() => dispatch({ type: 'escape' })}>
303
+ <p>{label('unsaved-confirm')}</p>
304
+ <div className="mt-4 flex justify-end gap-2">
305
+ <button type="button" onClick={() => dispatch({ type: 'escape' })}>{label('cancel')}</button>
306
+ <button type="button" className="go" onClick={() => dispatch({ type: 'confirm' })}>{label('confirm')}</button>
307
+ </div>
308
+ </Dialog>
309
+ )}
310
+ </div>
311
+ </div>
312
+ {section === 'mcp' || section === 'about' ? null : <footer className="flex items-center justify-between border-t bg-card px-4 py-3">
313
+ <div className="flex gap-2">
314
+ <button type="button" className="h-8 rounded-lg border px-3 text-sm hover:bg-muted" onClick={() => dispatch({ type: 'restore' })}>{label('restore')}</button>
315
+ </div>
316
+ <div className="flex items-center gap-2">
317
+ <span
318
+ className={[
319
+ 'inline-flex min-w-[4.5rem] items-center justify-end gap-1.5 text-xs font-medium transition-all duration-300 ease-out',
320
+ saveFeedback === 'idle' ? 'pointer-events-none translate-x-1 opacity-0' : 'translate-x-0 opacity-100',
321
+ saveFeedback === 'saving' ? 'text-muted-foreground' : '',
322
+ saveFeedback === 'saved' ? 'text-emerald-600 dark:text-emerald-400' : '',
323
+ ].filter(Boolean).join(' ')}
324
+ aria-live="polite"
325
+ >
326
+ {saveFeedback === 'saving' ? <Loader2 size={14} className="shrink-0 animate-spin" aria-hidden /> : null}
327
+ {saveFeedback === 'saved' ? <Check size={14} className="shrink-0 animate-in zoom-in-95 fade-in-0 duration-300" aria-hidden /> : null}
328
+ {saveFeedback === 'saving' ? label('saving') : null}
329
+ {saveFeedback === 'saved' ? label('saved') : null}
330
+ </span>
331
+ <button
332
+ type="button"
333
+ className="inline-flex h-9 items-center gap-2 rounded-lg bg-primary px-4 text-sm font-semibold text-primary-foreground disabled:opacity-60"
334
+ disabled={saveFeedback === 'saving'}
335
+ onClick={() => { void onSave() }}
336
+ >
337
+ {saveFeedback === 'saving' ? <Loader2 size={14} className="animate-spin" aria-hidden /> : null}
338
+ {saveFeedback === 'saving' ? label('saving') : label('save')}
339
+ </button>
340
+ </div>
341
+ </footer>}
342
+ </form>
343
+ )
344
+ }
345
+
346
+ function AgentSection(props: {
347
+ readonly state: ReturnType<typeof settingsState>
348
+ readonly runtimes: readonly PanelRuntime[]
349
+ readonly busy: boolean
350
+ readonly restarting: boolean
351
+ readonly label: (key: string) => string
352
+ readonly onRuntime: (id: string) => void
353
+ readonly onModel: (provider: string, model: string) => void
354
+ readonly onProbe: () => void
355
+ readonly onRefresh: () => void
356
+ readonly onRestart?: () => void
357
+ readonly readAbout?: () => Promise<PanelAbout>
358
+ readonly readSkill?: (customDirs?: readonly string[]) => Promise<PanelSkillStatus>
359
+ readonly installSkill?: (agentIds: readonly string[], customDirs: readonly string[]) => Promise<PanelSkillStatus>
360
+ readonly revealSkill?: (dest: string) => Promise<void>
361
+ readonly readAuthorMcp?: () => Promise<PanelAuthorMcpStatus>
362
+ readonly installAuthorMcp?: (agentIds: readonly string[], description: string) => Promise<PanelAuthorMcpStatus>
363
+ readonly revealAuthorMcp?: (dest: string) => Promise<void>
364
+ }): ReactNode {
365
+ const runtimes = props.runtimes.some(item => item.id === props.state.providerId) || props.state.providerId.length === 0
366
+ ? props.runtimes
367
+ : [...props.runtimes, { id: props.state.providerId, models: [] }]
368
+ const runtime = runtimes.find(item => item.id === props.state.providerId) ?? runtimes[0]
369
+ const vendors = runtime?.models ?? []
370
+ const vendor = vendors.find(item => item.provider === props.state.modelProvider) ?? vendors[0]
371
+ const models = vendor?.models ?? []
372
+ const vendorId = vendor?.provider ?? ''
373
+ const modelId = models.includes(props.state.model) ? props.state.model : (models[0] ?? '')
374
+ useEffect(() => {
375
+ if (vendorId.length === 0 || modelId.length === 0) return
376
+ if (vendorId === props.state.modelProvider && modelId === props.state.model) return
377
+ props.onModel(vendorId, modelId)
378
+ }, [vendorId, modelId, props.state.modelProvider, props.state.model, props.onModel])
379
+ return (
380
+ <div className="flex flex-col gap-8">
381
+ <section>
382
+ <h4 className="m-0">{props.label('section-model')}</h4>
383
+ <p className="mt-1 mb-4 text-sm text-muted-foreground">{props.label('agent-apps-help')}</p>
384
+ {runtimes.length === 0 ? <p className="text-sm text-muted-foreground">{props.label('runtime-missing')}</p> : (
385
+ <>
386
+ <div className="mb-4 flex items-center gap-3">
387
+ <div className="flex w-full max-w-md shrink-0 items-center gap-2">
388
+ <select className="h-9 min-w-0 flex-1 rounded-lg border bg-card px-3 text-sm outline-none" value={props.state.providerId} onChange={event => props.onRuntime(event.target.value)}>
389
+ {runtimes.map(item => <option key={item.id} value={item.id}>{item.label ?? item.id}</option>)}
390
+ </select>
391
+ <button type="button" className="inline-flex size-9 items-center justify-center rounded-lg border hover:bg-muted" aria-label={props.label('refresh')} onClick={props.onRefresh}>
392
+ <RefreshCw size={14} className={props.busy ? 'animate-spin' : ''} />
393
+ </button>
394
+ <button type="button" className="h-9 shrink-0 rounded-lg border px-3 text-sm hover:bg-muted" onClick={props.onProbe}>{props.label('probe')}</button>
395
+ </div>
396
+ {props.state.probe === undefined ? null : <span className="min-w-0 text-sm leading-5 text-muted-foreground">{probeText(props.state.probe, props.label)}</span>}
397
+ </div>
398
+ {runtimeRestartNote(props)}
399
+ </>
400
+ )}
401
+ {vendors.length === 0 ? <p className="text-sm text-muted-foreground">{props.label('model-empty')}</p> : (
402
+ <div className="grid max-w-md gap-3 min-[720px]:grid-cols-2">
403
+ <label className="flex flex-col gap-2">
404
+ <span className="text-sm text-muted-foreground">{props.label('provider')}</span>
405
+ <select className="h-9 rounded-lg border bg-card px-3 text-sm outline-none" value={vendorId} onChange={(event) => {
406
+ const provider = event.target.value
407
+ const next = vendors.find(item => item.provider === provider)?.models ?? []
408
+ props.onModel(provider, next.includes(props.state.model) ? props.state.model : (next[0] ?? ''))
409
+ }}>
410
+ {vendors.map(item => <option key={item.provider} value={item.provider}>{item.provider}</option>)}
411
+ </select>
412
+ </label>
413
+ <label className="flex flex-col gap-2">
414
+ <span className="text-sm text-muted-foreground">{props.label('model')}</span>
415
+ <select className="h-9 rounded-lg border bg-card px-3 text-sm outline-none" value={modelId} onChange={event => props.onModel(vendorId, event.target.value)}>
416
+ {models.map(name => <option key={name} value={name}>{name}</option>)}
417
+ </select>
418
+ </label>
419
+ </div>
420
+ )}
421
+ </section>
422
+ <section>
423
+ <h4 className="m-0">{props.label('agent-others')}</h4>
424
+ <p className="mt-1 mb-4 text-sm text-muted-foreground">{props.label('agent-others-help')}</p>
425
+ <div className="flex flex-col gap-3">
426
+ <AuthorMcpInstall
427
+ label={props.label}
428
+ {...props.readAbout === undefined ? {} : { readAbout: props.readAbout }}
429
+ {...props.readAuthorMcp === undefined ? {} : { readAuthorMcp: props.readAuthorMcp }}
430
+ {...props.installAuthorMcp === undefined ? {} : { installAuthorMcp: props.installAuthorMcp }}
431
+ {...props.revealAuthorMcp === undefined ? {} : { revealAuthorMcp: props.revealAuthorMcp }}
432
+ />
433
+ <SkillInstall
434
+ label={props.label}
435
+ {...props.readSkill === undefined ? {} : { readSkill: props.readSkill }}
436
+ {...props.installSkill === undefined ? {} : { installSkill: props.installSkill }}
437
+ {...props.revealSkill === undefined ? {} : { revealSkill: props.revealSkill }}
438
+ />
439
+ </div>
440
+ </section>
441
+ </div>
442
+ )
443
+ }
444
+
445
+ function AboutSection(props: {
446
+ readonly versions?: string
447
+ readonly update: PanelUpdateCheck | 'checking' | 'failed' | undefined
448
+ readonly label: (key: string) => string
449
+ readonly onCheck?: () => void
450
+ }): ReactNode {
451
+ const lines = (props.versions ?? '').split('\n').filter(line => line.length > 0)
452
+ return (
453
+ <section className="mb-6">
454
+ <h4>{props.label('section-about')}</h4>
455
+ <h3 className="mt-2 text-base font-semibold">{props.label('product-name')}</h3>
456
+ <div className="mt-2 mb-4 max-w-2xl space-y-2 text-sm leading-6 text-muted-foreground">
457
+ {props.label('product-meaning').split('\n').filter(line => line.length > 0).map(line => (
458
+ <p key={line.slice(0, 24)} className="m-0">{line}</p>
459
+ ))}
460
+ </div>
461
+ {lines.length === 0 ? null : (
462
+ <ul className="mb-4 font-mono text-sm text-muted-foreground">
463
+ {lines.map(line => <li key={line}>{line}</li>)}
464
+ </ul>
465
+ )}
466
+ {props.onCheck === undefined ? null : (
467
+ <button type="button" className="h-8 rounded-lg border px-3 text-sm hover:bg-muted" onClick={props.onCheck}>{props.label('update')}</button>
468
+ )}
469
+ {props.update === undefined ? null : <p className="mt-3 text-sm">{updateText(props.update, props.label)}</p>}
470
+ </section>
471
+ )
472
+ }
473
+
474
+ function themeLabel(theme: 'system' | 'light' | 'dark'): 'theme-system' | 'theme-light' | 'theme-dark' {
475
+ if (theme === 'light') return 'theme-light'
476
+ if (theme === 'dark') return 'theme-dark'
477
+ return 'theme-system'
478
+ }
479
+
480
+ function CardStylePreview(props: { readonly style: GalleryCardStyle }): ReactNode {
481
+ if (props.style === 'stamp') {
482
+ return (
483
+ <span className="relative block h-10 overflow-hidden rounded-lg border bg-card">
484
+ <span className="absolute top-1.5 right-1.5 flex size-5 items-center justify-center rounded border text-[8px] font-extrabold">Ab</span>
485
+ <span className="absolute bottom-2 left-2 h-1 w-8 rounded-full bg-foreground/70" />
486
+ </span>
487
+ )
488
+ }
489
+ if (props.style === 'etch') {
490
+ return (
491
+ <span className="flex h-10 items-end rounded-lg border bg-card px-2 pb-1.5">
492
+ <svg viewBox="0 0 52 28" className="h-7 w-12" aria-hidden><text x="1" y="22" fill="var(--card)" stroke="currentColor" strokeWidth="3.2" paintOrder="stroke fill" fontSize="20" fontWeight="800" fontFamily="ui-sans-serif, system-ui, sans-serif">Ab</text></svg>
493
+ </span>
494
+ )
495
+ }
496
+ if (props.style === 'pulse') {
497
+ return (
498
+ <span className="relative flex h-10 items-center rounded-[14px] border bg-card px-2 shadow-[inset_1px_1px_2px_#fff,0_6px_8px_#00000014]">
499
+ <span className="text-sm font-extrabold text-primary">Ab</span>
500
+ <span className="absolute top-2 right-2 size-2 rounded-full bg-primary shadow-[0_0_8px_var(--primary)]" />
501
+ </span>
502
+ )
503
+ }
504
+ if (props.style === 'list') {
505
+ return (
506
+ <span className="relative block h-10 overflow-hidden rounded-lg border bg-card">
507
+ <span className="absolute inset-1 rounded-md rounded-tr-[18px] bg-foreground/10" />
508
+ <span className="absolute inset-y-1.5 left-1.5 w-7 rounded-md bg-card/70" />
509
+ </span>
510
+ )
511
+ }
512
+ if (props.style === 'glass') {
513
+ return (
514
+ <span className="relative block h-10 overflow-hidden rounded-lg border bg-card/60">
515
+ <span className="absolute top-1.5 left-2 text-sm font-extrabold tracking-tight text-foreground/80">Ab</span>
516
+ <span className="absolute right-2 bottom-1.5 h-1 w-8 rounded-full bg-foreground/15" />
517
+ </span>
518
+ )
519
+ }
520
+ return (
521
+ <span className="relative block h-10 overflow-hidden rounded-lg border bg-card">
522
+ <span className="absolute -top-3 -right-2 size-10 rounded-full bg-primary/25" />
523
+ <span className="absolute bottom-1.5 left-2 text-sm leading-none font-extrabold text-primary">Ab</span>
524
+ </span>
525
+ )
526
+ }
527
+
528
+ function cardKey(style: GalleryCardStyle): 'card-hero' | 'card-stamp' | 'card-etch' | 'card-pulse' | 'card-list' | 'card-glass' {
529
+ if (style === 'stamp') return 'card-stamp'
530
+ if (style === 'etch') return 'card-etch'
531
+ if (style === 'pulse') return 'card-pulse'
532
+ if (style === 'list') return 'card-list'
533
+ if (style === 'glass') return 'card-glass'
534
+ return 'card-hero'
535
+ }
536
+
537
+ async function saveSettings(
538
+ client: PanelSettingsClient,
539
+ state: ReturnType<typeof settingsState>,
540
+ dispatch: (action: Parameters<typeof reduceSettings>[1]) => void,
541
+ ): Promise<PanelPolicy | undefined> {
542
+ const draft = settingsDraft(state)
543
+ if (!draft.ok) {
544
+ dispatch({ type: 'port-invalid' })
545
+ return undefined
546
+ }
547
+ try {
548
+ const result = await client.writePolicy(draft.policy)
549
+ dispatch({ type: 'saved', result })
550
+ return result.policy
551
+ } catch (error) {
552
+ const message = error instanceof Error ? error.message : ''
553
+ dispatch({ type: 'save-failed', ...message.length > 0 ? { message } : {} })
554
+ return undefined
555
+ }
556
+ }
557
+
558
+ function runtimeRestartNote(props: {
559
+ readonly state: ReturnType<typeof settingsState>
560
+ readonly restarting: boolean
561
+ readonly label: (key: string) => string
562
+ readonly onRestart?: () => void
563
+ }): ReactNode {
564
+ const savedId = props.state.saved?.runtimeProvider.id
565
+ const dirtyRuntime = props.state.dirty
566
+ && savedId !== undefined
567
+ && savedId !== props.state.providerId
568
+ if (props.state.restartRequired && props.state.restartRuntime) {
569
+ return (
570
+ <div className="flex flex-wrap items-center gap-2 rounded-lg border border-primary/30 bg-primary/5 px-3 py-2">
571
+ <p className="m-0 min-w-0 flex-1 text-sm text-primary">{props.label('restart-runtime-hint')}</p>
572
+ {props.onRestart === undefined ? null : (
573
+ <button
574
+ type="button"
575
+ className="h-8 shrink-0 rounded-lg bg-primary px-3 text-sm font-semibold text-primary-foreground disabled:opacity-60"
576
+ disabled={props.restarting}
577
+ onClick={props.onRestart}
578
+ >
579
+ {props.restarting ? props.label('restart-wait') : props.label('restart-host')}
580
+ </button>
581
+ )}
582
+ </div>
583
+ )
584
+ }
585
+ if (dirtyRuntime) {
586
+ return <p className="m-0 text-sm text-muted-foreground">{props.label('restart-runtime-pending')}</p>
587
+ }
588
+ return null
589
+ }
590
+
591
+ async function rebootHost(
592
+ client: PanelSettingsClient,
593
+ setRestarting: (value: boolean) => void,
594
+ label: (key: string) => string,
595
+ dispatch: (action: Parameters<typeof reduceSettings>[1]) => void,
596
+ nextOrigin?: string,
597
+ ): Promise<void> {
598
+ if (client.restartHost === undefined) return
599
+ setRestarting(true)
600
+ const target = nextOrigin !== undefined && nextOrigin.length > 0 ? nextOrigin : window.location.origin
601
+ // Kick restart; the socket may die before the JSON body arrives — that is OK.
602
+ void client.restartHost().catch(() => undefined)
603
+ const ready = await waitForHostOrigin(target, 20_000)
604
+ if (ready) {
605
+ if (target !== window.location.origin) window.location.assign(`${target}/`)
606
+ else window.location.reload()
607
+ return
608
+ }
609
+ setRestarting(false)
610
+ dispatch({ type: 'save-failed', message: label('restart-failed') })
611
+ }
612
+
613
+ /**
614
+ * Poll until the host answers again after dispose+start.
615
+ * Prefer seeing a downtime gap so a probe before dispose does not look "ready".
616
+ */
617
+ async function waitForHostOrigin(origin: string, budgetMs: number): Promise<boolean> {
618
+ const started = Date.now()
619
+ let sawDown = false
620
+ // Outlast the server's delayed dispose (400ms) before trusting a 200.
621
+ await new Promise(resolve => setTimeout(resolve, 700))
622
+ while (Date.now() - started < budgetMs) {
623
+ try {
624
+ const response = await fetch(`${origin}/api/about`, {
625
+ method: 'GET',
626
+ cache: 'no-store',
627
+ signal: AbortSignal.timeout(1500),
628
+ })
629
+ if (response.ok) {
630
+ if (sawDown || Date.now() - started > 1_500) return true
631
+ } else {
632
+ sawDown = true
633
+ }
634
+ } catch {
635
+ sawDown = true
636
+ }
637
+ await new Promise(resolve => setTimeout(resolve, 350))
638
+ }
639
+ return false
640
+ }
641
+
642
+ async function readUpdate(
643
+ client: PanelSettingsClient,
644
+ setUpdate: (value: PanelUpdateCheck | 'checking' | 'failed') => void,
645
+ onOffer?: (update: PanelUpdateCheck) => void,
646
+ ): Promise<void> {
647
+ if (client.checkUpdate === undefined) return
648
+ setUpdate('checking')
649
+ try {
650
+ const result = await client.checkUpdate()
651
+ setUpdate(result)
652
+ if (result.updateAvailable && result.installable === true && result.latest !== null) onOffer?.(result)
653
+ } catch {
654
+ setUpdate('failed')
655
+ }
656
+ }
657
+
658
+ function updateText(update: PanelUpdateCheck | 'checking' | 'failed', label: (key: string) => string): string {
659
+ if (update === 'checking') return label('update-checking')
660
+ if (update === 'failed') return label('update-failed')
661
+ if (update.error !== undefined && update.error.length > 0) return update.error
662
+ const current = label('update-current').replace('{n}', update.current)
663
+ if (update.updateAvailable && update.latest !== null) {
664
+ return `${label('update-available')} ${label('update-newest').replace('{n}', update.latest)} · ${current}`
665
+ }
666
+ return `${label('update-ok')} ${current}`
667
+ }
668
+
669
+ async function probeSettings(
670
+ client: PanelSettingsClient,
671
+ id: string,
672
+ dispatch: (action: Parameters<typeof reduceSettings>[1]) => void,
673
+ ): Promise<void> {
674
+ const result = await client.probe(id)
675
+ dispatch({ type: 'probed', message: result.message ?? (result.healthy ? 'probe-ok' : 'probe-failed') })
676
+ }
677
+
678
+ function probeText(probe: string, label: (key: string) => string): string {
679
+ if (probe === 'update-failed' || probe === 'probe-ok' || probe === 'probe-failed') return label(probe)
680
+ return probe
681
+ }
682
+
683
+ /** Loopback panel origin for the draft or saved host port. */
684
+ /** Bars from the theme file — not a hash of the palette id. */
685
+ function palettePreview(palette: PaletteChip): { primary: string; muted: string; card: string; surface: string } {
686
+ const css = palette.style ?? ''
687
+ const primary = tokenFromCss(css, 'primary') ?? palette.swatch ?? '#888888'
688
+ const muted = tokenFromCss(css, 'muted') ?? soften(primary, 0.55)
689
+ const card = tokenFromCss(css, 'card') ?? '#ffffff'
690
+ const surface = tokenFromCss(css, 'background') ?? soften(primary, 0.88)
691
+ return { primary, muted, card, surface }
692
+ }
693
+
694
+ function tokenFromCss(css: string, name: string): string | undefined {
695
+ const light = css.split(/\[data-mode=["']dark["']\]/)[0] ?? css
696
+ const match = new RegExp(`--${name}:\\s*([^;}]+)`).exec(light)
697
+ const value = match?.[1]?.trim()
698
+ return value !== undefined && value.length > 0 ? value : undefined
699
+ }
700
+
701
+ function soften(color: string, mix: number): string {
702
+ return `color-mix(in oklab, ${color} ${Math.round(mix * 100)}%, white)`
703
+ }
704
+
705
+ function paletteLabel(locale: PanelLocale, palette: PaletteChip): string {
706
+ if (locale === 'zh-CN' && palette.nameZh !== undefined && palette.nameZh.length > 0) return palette.nameZh
707
+ return palette.name
708
+ }
709
+
710
+ function hostOrigin(port: string): string | undefined {
711
+ const n = Number(port)
712
+ if (!Number.isInteger(n) || n < 1024 || n > 65535) return undefined
713
+ return `http://127.0.0.1:${n}`
714
+ }
715
+
716
+ /** Appearance / palette / card-style choice tile. Selected state must read at a glance. */
717
+ const choiceCardClass = [
718
+ 'flex flex-col gap-2 rounded-xl border border-border/80 bg-card p-2 text-left outline-none transition-colors',
719
+ 'hover:border-foreground/25',
720
+ 'data-[on=1]:border-primary data-[on=1]:bg-primary/10 data-[on=1]:ring-2 data-[on=1]:ring-primary data-[on=1]:ring-offset-2 data-[on=1]:ring-offset-background',
721
+ 'data-[on=1]:[&>span:last-child]:font-semibold',
722
+ ].join(' ')