From 8005e978f097b7bb22795766a07d6392e1c16068 Mon Sep 17 00:00:00 2001 From: Augustin Date: Thu, 23 Apr 2026 21:14:47 +0200 Subject: [PATCH] feat(config): dynamic profile panel, generic save, tabs margin fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Config tabs now have bottom padding for visual spacing - Profile panel dynamically renders all config fields (strings, bools, arrays, nested objects) — new struct fields appear automatically - handleSaveProfile uses generic JSON merge via deepMerge, so any new Profile field works without handler changes - RenderFields recursively renders config sections with edit/view modes 💘 Generated with Crush Assisted-by: GLM-5.1 via Crush --- internal/api/handlers_config.go | 53 ++++++----- web/src/components/Config.jsx | 163 ++++++++++++++++++++++---------- web/src/styles/global.css | 34 ++++++- 3 files changed, 176 insertions(+), 74 deletions(-) diff --git a/internal/api/handlers_config.go b/internal/api/handlers_config.go index 0d7aafd..c0c977b 100644 --- a/internal/api/handlers_config.go +++ b/internal/api/handlers_config.go @@ -53,32 +53,27 @@ func (s *Server) handleSaveProfile(w http.ResponseWriter, r *http.Request) { writeError(w, "no config", http.StatusNotFound) return } - var body struct { - Name string `json:"name"` - Pseudo string `json:"pseudo"` - Email string `json:"email"` - Editor string `json:"editor"` - Shell string `json:"shell"` + + currentJSON, err := json.Marshal(s.config.Profile) + if err != nil { + writeError(w, err.Error(), http.StatusInternalServerError) + return } - if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + var currentMap map[string]interface{} + json.Unmarshal(currentJSON, ¤tMap) + + var updates map[string]interface{} + body, _ := io.ReadAll(r.Body) + if err := json.Unmarshal(body, &updates); err != nil { writeError(w, err.Error(), http.StatusBadRequest) return } - if body.Name != "" { - s.config.Profile.Name = body.Name - } - if body.Pseudo != "" { - s.config.Profile.Pseudo = body.Pseudo - } - if body.Email != "" { - s.config.Profile.Email = body.Email - } - if body.Editor != "" { - s.config.Profile.Preferences.Editor = body.Editor - } - if body.Shell != "" { - s.config.Profile.Preferences.Shell = body.Shell - } + + deepMerge(currentMap, updates) + + mergedJSON, _ := json.Marshal(currentMap) + json.Unmarshal(mergedJSON, &s.config.Profile) + if err := config.Save(s.config); err != nil { writeError(w, err.Error(), http.StatusInternalServerError) return @@ -86,6 +81,20 @@ func (s *Server) handleSaveProfile(w http.ResponseWriter, r *http.Request) { writeJSON(w, map[string]string{"status": "ok"}) } +func deepMerge(dst, src map[string]interface{}) { + for k, sv := range src { + if dv, ok := dst[k]; ok { + dstMap, dOk := dv.(map[string]interface{}) + srcMap, sOk := sv.(map[string]interface{}) + if dOk && sOk { + deepMerge(dstMap, srcMap) + continue + } + } + dst[k] = sv + } +} + func (s *Server) handleSaveProvider(w http.ResponseWriter, r *http.Request) { if r.Method != "PUT" { writeError(w, "PUT only", http.StatusMethodNotAllowed) diff --git a/web/src/components/Config.jsx b/web/src/components/Config.jsx index 11d6b4d..e5e56c2 100644 --- a/web/src/components/Config.jsx +++ b/web/src/components/Config.jsx @@ -34,14 +34,7 @@ export default function Config({ api }) { const loadData = useCallback(() => { api.getConfig().then(d => { setConfig(d) - setProfileForm({ - name: d.profile?.name || '', - pseudo: d.profile?.pseudo || '', - email: d.profile?.email || '', - editor: d.profile?.preferences?.editor || '', - shell: d.profile?.preferences?.shell || '', - }) - + setProfileForm(d.profile ? JSON.parse(JSON.stringify(d.profile)) : {}) }).catch(() => {}) api.getProviders().then(d => setProviders(d.providers || [])).catch(() => {}) api.getSkills().then(d => setSkillList(d.skills || [])).catch(() => {}) @@ -209,57 +202,125 @@ export default function Config({ api }) { } function PanelProfile({ config, editProfile, profileForm, setProfileForm, setEditProfile, handleSaveProfile, t }) { + const updateField = (path, value) => { + setProfileForm(prev => { + const next = JSON.parse(JSON.stringify(prev)) + const keys = path.split('.') + let target = next + for (let i = 0; i < keys.length - 1; i++) { + if (target[keys[i]] == null) target[keys[i]] = {} + target = target[keys[i]] + } + target[keys[keys.length - 1]] = value + return next + }) + } + + const profile = editProfile ? profileForm : config?.profile + + if (!profile) { + return ( +
+
{t('config.loadingProfile')}
+
+ ) + } + return (
- {config?.profile && !editProfile ? ( - <> -
- {t('config.name')} - {config.profile.name || '—'} -
-
- {t('config.pseudo')} - {config.profile.pseudo || '—'} -
-
- {t('config.email')} - {config.profile.email || '—'} -
-
- {t('config.editor')} - {config.profile.preferences?.editor || '—'} -
-
- {t('config.shell')} - {config.profile.preferences?.shell || '—'} -
-
- {t('config.languages')} - {config.profile.languages?.join(', ') || '—'} -
-
- -
- - ) : editProfile ? ( - <> - setProfileForm(p => ({ ...p, name: v }))} /> - setProfileForm(p => ({ ...p, pseudo: v }))} /> - setProfileForm(p => ({ ...p, email: v }))} type="email" /> - setProfileForm(p => ({ ...p, editor: v }))} /> - setProfileForm(p => ({ ...p, shell: v }))} /> -
+ +
+ {editProfile ? ( + <> -
- - ) : ( -
{t('config.loadingProfile')}
- )} + + ) : ( + + )} +
) } +function RenderFields({ obj, path, editing, onChange, t }) { + if (!obj || typeof obj !== 'object') return null + + return Object.entries(obj).map(([key, value]) => { + const fieldPath = path ? `${path}.${key}` : key + const label = getFieldLabel(key, t) + + if (value !== null && typeof value === 'object' && !Array.isArray(value)) { + return ( +
+ {label} + +
+ ) + } + + if (editing) { + if (typeof value === 'boolean') { + return ( +
+ {label} + +
+ ) + } + if (Array.isArray(value)) { + return ( +
+ + onChange(fieldPath, e.target.value.split(',').map(s => s.trim()).filter(Boolean))} /> +
+ ) + } + return ( +
+ + onChange(fieldPath, typeof value === 'number' ? Number(e.target.value) : e.target.value)} /> +
+ ) + } + + if (typeof value === 'boolean') { + return ( +
+ {label} + {value ? 'On' : 'Off'} +
+ ) + } + if (Array.isArray(value)) { + return ( +
+ {label} + {value.length > 0 ? value.join(', ') : '—'} +
+ ) + } + return ( +
+ {label} + {value != null && value !== '' ? String(value) : '—'} +
+ ) + }) +} + +function getFieldLabel(key, t) { + const translated = t(`config.${key}`) + if (translated !== `config.${key}`) return translated + return key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase()) +} + function PanelProviders({ providers, editProvider, providerForm, setProviderForm, setEditProvider, openProviderEdit, handleSaveProvider, api, loadData, t }) { const [validating, setValidating] = useState(null) const [validationStatus, setValidationStatus] = useState(null) diff --git a/web/src/styles/global.css b/web/src/styles/global.css index 41dcd5a..465b90e 100644 --- a/web/src/styles/global.css +++ b/web/src/styles/global.css @@ -429,7 +429,7 @@ input::placeholder { color: var(--text-disabled); } .config-window { display: flex; flex-direction: column; height: 100%; overflow: hidden; } .config-tabs-bar { - display: flex; gap: 4px; padding: 12px 20px 0; background: var(--bg-surface); + display: flex; gap: 4px; padding: 12px 20px; background: var(--bg-surface); border-bottom: 1px solid var(--border); flex-shrink: 0; } @@ -728,6 +728,18 @@ input::placeholder { color: var(--text-disabled); } .feed-content { font-size: 14px; line-height: 1.7; color: var(--text-primary); word-break: break-word; } .feed-system-badge { width: 6px; height: 6px; border-radius: 50%; background: var(--accent-dim); flex-shrink: 0; } .feed-system-text { font-size: 12px; color: var(--text-tertiary); font-style: italic; flex: 1; } +.feed-system-text.compressed { color: var(--accent); font-style: normal; } +.feed-compressed-indicator { + display: flex; align-items: center; gap: 8px; + padding: 10px 12px; margin: 4px 0; + background: var(--bg-card); border: 1px solid var(--border); + border-radius: var(--radius); cursor: pointer; + transition: all 0.2s ease; +} +.feed-compressed-indicator:hover { background: var(--bg-hover); border-color: var(--accent-dim); } +.feed-compressed-indicator svg { color: var(--accent); flex-shrink: 0; } +.feed-compressed-text { font-size: 12px; color: var(--text-tertiary); flex: 1; } +.feed-compressed-count { font-size: 11px; color: var(--text-disabled); font-family: var(--font-mono); } .feed-thinking-block { background: var(--bg-surface); border: 1px solid var(--border); border-left: 2px solid var(--accent-dim); @@ -791,7 +803,12 @@ input::placeholder { color: var(--text-disabled); } .studio-token-track { flex: 1; height: 3px; background: var(--bg-input); border-radius: 2px; overflow: hidden; } .studio-token-fill { height: 100%; background: var(--accent); border-radius: 2px; transition: width 0.4s, background 0.3s; } .studio-token-fill.warn { background: var(--warning); } +.studio-token-fill.compressed { height: 2px; } .studio-token-text { font-size: 10px; font-family: var(--font-mono); color: var(--text-tertiary); white-space: nowrap; } +.studio-token-text.compressed { font-size: 9px; } +.studio-token-track.compressed { height: 2px; } +.studio-token-bar.compressed { margin-bottom: 4px; } + .studio-input-row { display: flex; gap: 8px; align-items: flex-end; } .studio-input-row textarea { flex: 1; resize: none; min-height: 42px; max-height: 200px; padding: 10px 14px; @@ -816,6 +833,21 @@ input::placeholder { color: var(--text-disabled); } .studio-stop-btn:hover { opacity: 0.8; } .studio-input-hint { font-size: 11px; color: var(--text-disabled); text-align: center; margin-top: 6px; } +/* ── Collapsed Messages ── */ +.feed-collapsed-messages { + display: flex; align-items: center; gap: 10px; + padding: 8px 16px; margin: 4px 0; + background: linear-gradient(135deg, var(--bg-surface), var(--bg-elevated)); + border: 1px dashed var(--border-accent); + border-radius: var(--radius); cursor: pointer; + transition: all 0.2s ease; +} +.feed-collapsed-messages:hover { background: var(--bg-hover); border-color: var(--accent); } +.feed-collapsed-messages svg { color: var(--accent); flex-shrink: 0; } +.feed-collapsed-text { font-size: 11px; color: var(--text-tertiary); flex: 1; } +.feed-collapsed-count { font-size: 10px; color: var(--text-disabled); font-family: var(--font-mono); } +.feed-expanded-messages { animation: fadeIn 0.2s ease-out; } + /* ── Studio Tool Blocks ── */ .studio-tool-block { background: var(--bg-surface);