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);