Compare commits

...

2 Commits

Author SHA1 Message Date
Augustin
8005e978f0 feat(config): dynamic profile panel, generic save, tabs margin fix
All checks were successful
Beta Release / beta (push) Successful in 44s
- 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 <crush@charm.land>
2026-04-23 21:14:47 +02:00
Augustin
6e76e7dca6 fix(dashboard): remove bg graphs, add scrollable lists, show used/total quota
All checks were successful
Beta Release / beta (push) Successful in 40s
Remove BgGraph background SVGs that were misaligned with foreground graphs.
Add max-height: 270px with overflow-y scroll to quota/processes/commands lists.
Change API quota display from remaining/total to used/total.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 21:02:53 +02:00
4 changed files with 236 additions and 188 deletions

View File

@@ -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, &currentMap)
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)

View File

@@ -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 (
<div className="config-card">
<div className="empty-state">{t('config.loadingProfile')}</div>
</div>
)
}
return (
<div className="config-card">
{config?.profile && !editProfile ? (
<>
<div className="config-card-row">
<span className="config-card-label">{t('config.name')}</span>
<span className="config-card-value">{config.profile.name || '—'}</span>
</div>
<div className="config-card-row">
<span className="config-card-label">{t('config.pseudo')}</span>
<span className="config-card-value">{config.profile.pseudo || '—'}</span>
</div>
<div className="config-card-row">
<span className="config-card-label">{t('config.email')}</span>
<span className="config-card-value">{config.profile.email || '—'}</span>
</div>
<div className="config-card-row">
<span className="config-card-label">{t('config.editor')}</span>
<span className="config-card-value mono">{config.profile.preferences?.editor || '—'}</span>
</div>
<div className="config-card-row">
<span className="config-card-label">{t('config.shell')}</span>
<span className="config-card-value mono">{config.profile.preferences?.shell || '—'}</span>
</div>
<div className="config-card-row">
<span className="config-card-label">{t('config.languages')}</span>
<span className="config-card-value">{config.profile.languages?.join(', ') || '—'}</span>
</div>
<div className="config-card-actions">
<button className="primary sm" onClick={() => setEditProfile(true)}>{t('config.editProfile')}</button>
</div>
</>
) : editProfile ? (
<>
<FormInput label={t('config.name')} value={profileForm.name} onChange={v => setProfileForm(p => ({ ...p, name: v }))} />
<FormInput label={t('config.pseudo')} value={profileForm.pseudo} onChange={v => setProfileForm(p => ({ ...p, pseudo: v }))} />
<FormInput label={t('config.email')} value={profileForm.email} onChange={v => setProfileForm(p => ({ ...p, email: v }))} type="email" />
<FormInput label={t('config.editor')} value={profileForm.editor} onChange={v => setProfileForm(p => ({ ...p, editor: v }))} />
<FormInput label={t('config.shell')} value={profileForm.shell} onChange={v => setProfileForm(p => ({ ...p, shell: v }))} />
<div className="config-card-actions">
<RenderFields obj={profile} path="" editing={editProfile} onChange={updateField} t={t} />
<div className="config-card-actions">
{editProfile ? (
<>
<button className="primary sm" onClick={handleSaveProfile}>{t('config.save')}</button>
<button className="ghost sm" onClick={() => setEditProfile(false)}>{t('config.cancel')}</button>
</div>
</>
) : (
<div className="empty-state">{t('config.loadingProfile')}</div>
)}
</>
) : (
<button className="primary sm" onClick={() => {
setProfileForm(config.profile ? JSON.parse(JSON.stringify(config.profile)) : {})
setEditProfile(true)
}}>{t('config.editProfile')}</button>
)}
</div>
</div>
)
}
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 (
<div key={key} className="config-card-group">
<span className="config-card-group-label">{label}</span>
<RenderFields obj={value} path={fieldPath} editing={editing} onChange={onChange} t={t} />
</div>
)
}
if (editing) {
if (typeof value === 'boolean') {
return (
<div key={key} className="config-card-row">
<span className="config-card-label">{label}</span>
<label style={{ display: 'flex', alignItems: 'center', gap: 8, cursor: 'pointer' }}>
<input type="checkbox" checked={value} onChange={e => onChange(fieldPath, e.target.checked)} />
<span style={{ fontSize: 12, color: 'var(--text-tertiary)' }}>{value ? 'On' : 'Off'}</span>
</label>
</div>
)
}
if (Array.isArray(value)) {
return (
<div key={key} className="config-form-field">
<label className="config-form-label">{label}</label>
<input className="config-form-input" value={value.join(', ')} onChange={e => onChange(fieldPath, e.target.value.split(',').map(s => s.trim()).filter(Boolean))} />
</div>
)
}
return (
<div key={key} className="config-form-field">
<label className="config-form-label">{label}</label>
<input className="config-form-input" type={typeof value === 'number' ? 'number' : 'text'} value={value ?? ''} onChange={e => onChange(fieldPath, typeof value === 'number' ? Number(e.target.value) : e.target.value)} />
</div>
)
}
if (typeof value === 'boolean') {
return (
<div key={key} className="config-card-row">
<span className="config-card-label">{label}</span>
<span className="config-card-value">{value ? 'On' : 'Off'}</span>
</div>
)
}
if (Array.isArray(value)) {
return (
<div key={key} className="config-card-row">
<span className="config-card-label">{label}</span>
<span className="config-card-value">{value.length > 0 ? value.join(', ') : '—'}</span>
</div>
)
}
return (
<div key={key} className="config-card-row">
<span className="config-card-label">{label}</span>
<span className="config-card-value">{value != null && value !== '' ? String(value) : '—'}</span>
</div>
)
})
}
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)

View File

@@ -3,32 +3,6 @@ import { useI18n } from '../i18n'
const MAX_POINTS = 30
function BgGraph({ data, max, color }) {
if (!data || data.length < 2) return null
const m = max || Math.max(...data, 1)
const w = 120
const h = 60
const points = data.map((v, i) => {
const x = (i / (data.length - 1)) * w
const y = h - (v / m) * h
return `${x},${y}`
})
const area = `${points.join(' ')} ${w},${h} 0,${h}`
const line = points.join(' ')
return (
<svg viewBox={`0 0 ${w} ${h}`} className="dash-bg-graph" preserveAspectRatio="none">
<defs>
<linearGradient id={`g-${color.replace('#','')}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity="0.25" />
<stop offset="100%" stopColor={color} stopOpacity="0" />
</linearGradient>
</defs>
<polygon fill={`url(#g-${color.replace('#','')})`} points={area} />
<polyline fill="none" stroke={color} strokeWidth="1.5" points={line} vectorEffect="non-scaling-stroke" opacity="0.6" />
</svg>
)
}
function MiniGraph({ data, max, color, label, unit }) {
if (!data || data.length < 2) return <div className="dash-graph-empty">collecting...</div>
const m = max || Math.max(...data, 1)
@@ -70,7 +44,6 @@ export default function Dashboard({ api, refreshRef }) {
const memRef = useRef([])
const netRxRef = useRef([])
const netTxRef = useRef([])
const procCountRef = useRef([])
const loadData = useCallback(async () => {
try {
@@ -90,7 +63,6 @@ export default function Dashboard({ api, refreshRef }) {
netRxRef.current = [...netRxRef.current, metricsData.net_rx_kbs].slice(-MAX_POINTS)
netTxRef.current = [...netTxRef.current, metricsData.net_tx_kbs].slice(-MAX_POINTS)
}
procCountRef.current = [...procCountRef.current, procData.processes?.length || 0].slice(-MAX_POINTS)
} catch (err) {
console.error('Dashboard load error:', err)
}
@@ -105,99 +77,82 @@ export default function Dashboard({ api, refreshRef }) {
const minimax = (quota || []).find(p => p.name === 'minimax')
const zai = (quota || []).find(p => p.name === 'zai')
const totalQuotaUsed = minimax?.data?.models?.reduce((s, m) => s + (m.used || 0), 0) || 0
const totalQuotaMax = minimax?.data?.models?.reduce((s, m) => s + (m.total || 0), 0) || 1
return (
<div className="dash-grid">
{/* CPU */}
<div className="dash-card dash-card-graph">
<BgGraph data={cpuRef.current} max={100} color="#06b6d4" />
<div className="dash-card-content">
<div className="dash-card-head">
<span className="dash-label">CPU</span>
<span className="dash-count">{metrics ? metrics.cpu_percent.toFixed(0) : '—'}%</span>
</div>
<MiniGraph data={cpuRef.current} max={100} color="var(--accent)" label="CPU" unit="%" />
<div className="dash-card">
<div className="dash-card-head">
<span className="dash-label">CPU</span>
<span className="dash-count">{metrics ? metrics.cpu_percent.toFixed(0) : '—'}%</span>
</div>
<MiniGraph data={cpuRef.current} max={100} color="var(--accent)" label="CPU" unit="%" />
</div>
{/* RAM */}
<div className="dash-card dash-card-graph">
<BgGraph data={memRef.current} max={100} color="#a78bfa" />
<div className="dash-card-content">
<div className="dash-card-head">
<span className="dash-label">RAM</span>
<span className="dash-count">{metrics ? `${metrics.mem_used_mb.toFixed(0)}/${metrics.mem_total_mb.toFixed(0)}` : '—'}</span>
</div>
<MiniGraph data={memRef.current} max={100} color="#a78bfa" label="RAM" unit="%" />
<div className="dash-card">
<div className="dash-card-head">
<span className="dash-label">RAM</span>
<span className="dash-count">{metrics ? `${metrics.mem_used_mb.toFixed(0)}/${metrics.mem_total_mb.toFixed(0)}` : '—'}</span>
</div>
<MiniGraph data={memRef.current} max={100} color="#a78bfa" label="RAM" unit="%" />
</div>
{/* Network */}
<div className="dash-card dash-card-graph">
<BgGraph data={netRxRef.current} max={null} color="#34d399" />
<div className="dash-card-content">
<div className="dash-card-head">
<span className="dash-label">Network</span>
<span className="dash-count">{metrics ? `${metrics.net_rx_kbs.toFixed(0)}${metrics.net_tx_kbs.toFixed(0)}` : '—'}</span>
</div>
<MiniGraph data={netRxRef.current} max={null} color="#34d399" label="RX" unit=" KB/s" />
<MiniGraph data={netTxRef.current} max={null} color="#f59e0b" label="TX" unit=" KB/s" />
<div className="dash-card">
<div className="dash-card-head">
<span className="dash-label">Network</span>
<span className="dash-count">{metrics ? `${metrics.net_rx_kbs.toFixed(0)}${metrics.net_tx_kbs.toFixed(0)}` : '—'}</span>
</div>
<MiniGraph data={netRxRef.current} max={null} color="#34d399" label="RX" unit=" KB/s" />
<MiniGraph data={netTxRef.current} max={null} color="#f59e0b" label="TX" unit=" KB/s" />
</div>
{/* API Quota */}
<div className="dash-card dash-card-graph">
<BgGraph data={totalQuotaMax > 0 ? [totalQuotaUsed / totalQuotaMax * 100, ...(cpuRef.current.length > 0 ? [] : [0])] : []} max={100} color="#f472b6" />
<div className="dash-card-content">
<div className="dash-card-head">
<span className="dash-label">API Quota</span>
</div>
<div className="dash-quota-list">
{minimax && minimax.data?.models?.map((m, i) => (
<div key={i} className="dash-quota-row">
<span className="dash-quota-name">{String(m.model).replace('MiniMax-', '')}</span>
<div className="dash-bar">
<div className="dash-bar-fill" style={{ width: `${Math.min(100, (m.used / m.total) * 100)}%` }} />
</div>
<span className="dash-quota-val">{m.remaining}/{m.total}</span>
<div className="dash-card">
<div className="dash-card-head">
<span className="dash-label">API Quota</span>
</div>
<div className="dash-quota-list">
{minimax && minimax.data?.models?.map((m, i) => (
<div key={i} className="dash-quota-row">
<span className="dash-quota-name">{String(m.model).replace('MiniMax-', '')}</span>
<div className="dash-bar">
<div className="dash-bar-fill" style={{ width: `${Math.min(100, (m.used / m.total) * 100)}%` }} />
</div>
))}
{minimax && minimax.data?.models?.length === 0 && (
<div className="dash-quota-row">
<span className="dash-quota-name">MiniMax</span>
<span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>{minimax.error || 'no data'}</span>
</div>
)}
{zai && (
<div className="dash-quota-row">
<span className="dash-quota-name">Z.AI</span>
<span className="dash-quota-val">{zai.healthy ? '✓ active' : zai.error || '—'}</span>
</div>
)}
{!minimax && !zai && <span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>No providers</span>}
</div>
<span className="dash-quota-val">{m.used}/{m.total}</span>
</div>
))}
{minimax && minimax.data?.models?.length === 0 && (
<div className="dash-quota-row">
<span className="dash-quota-name">MiniMax</span>
<span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>{minimax.error || 'no data'}</span>
</div>
)}
{zai && (
<div className="dash-quota-row">
<span className="dash-quota-name">Z.AI</span>
<span className="dash-quota-val">{zai.healthy ? '✓ active' : zai.error || '—'}</span>
</div>
)}
{!minimax && !zai && <span className="dash-quota-val" style={{ color: 'var(--text-tertiary)' }}>No providers</span>}
</div>
</div>
{/* Running Processes */}
<div className="dash-card dash-card-graph">
<BgGraph data={procCountRef.current} max={null} color="#fb923c" />
<div className="dash-card-content">
<div className="dash-card-head">
<span className="dash-label">Processes</span>
<span className="dash-count">{processes.length}</span>
</div>
<div className="dash-proc-list">
{processes.length === 0 && <span className="dash-empty">No relevant processes</span>}
{processes.slice(0, 6).map((p, i) => (
<div key={i} className="dash-proc-row">
<span className="dash-proc-name">{p.name}</span>
<span className="dash-proc-res">cpu {p.cpu}% · mem {p.mem}%</span>
</div>
))}
</div>
<div className="dash-card">
<div className="dash-card-head">
<span className="dash-label">Processes</span>
<span className="dash-count">{processes.length}</span>
</div>
<div className="dash-proc-list">
{processes.length === 0 && <span className="dash-empty">No relevant processes</span>}
{processes.map((p, i) => (
<div key={i} className="dash-proc-row">
<span className="dash-proc-name">{p.name}</span>
<span className="dash-proc-res">cpu {p.cpu}% · mem {p.mem}%</span>
</div>
))}
</div>
</div>
@@ -208,7 +163,7 @@ export default function Dashboard({ api, refreshRef }) {
</div>
<div className="dash-cmd-list">
{recentCmds.length === 0 && <span className="dash-empty">No history</span>}
{recentCmds.slice(0, 8).map((c, i) => (
{recentCmds.map((c, i) => (
<div key={i} className="dash-cmd-row" title={c.cmd}>
<span className="dash-cmd-shell">{c.shell}</span>
<span className="dash-cmd-text">{c.cmd.length > 45 ? c.cmd.slice(0, 42) + '...' : c.cmd}</span>

View File

@@ -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;
}
@@ -547,16 +547,7 @@ input::placeholder { color: var(--text-disabled); }
display: flex; flex-direction: column; gap: 8px;
overflow: hidden;
}
.dash-card-graph { padding: 0; }
.dash-bg-graph {
position: absolute; inset: 0; width: 100%; height: 100%;
opacity: 0.35; pointer-events: none;
}
.dash-card-content {
position: relative; z-index: 1;
padding: 14px 16px;
display: flex; flex-direction: column; gap: 8px;
}
.dash-span-2 { grid-column: span 2; }
.dash-card-head {
display: flex; align-items: center; justify-content: space-between;
@@ -585,7 +576,7 @@ input::placeholder { color: var(--text-disabled); }
.dash-tool-tag.missing { color: var(--error); }
/* Quota */
.dash-quota-list { display: flex; flex-direction: column; gap: 6px; }
.dash-quota-list { display: flex; flex-direction: column; gap: 6px; max-height: 270px; overflow-y: auto; }
.dash-quota-row { display: flex; align-items: center; gap: 8px; }
.dash-quota-name {
font-size: 11px; font-weight: 600; color: var(--text-primary);
@@ -604,7 +595,7 @@ input::placeholder { color: var(--text-disabled); }
}
/* Processes */
.dash-proc-list { display: flex; flex-direction: column; gap: 4px; }
.dash-proc-list { display: flex; flex-direction: column; gap: 4px; max-height: 270px; overflow-y: auto; }
.dash-proc-row {
display: flex; justify-content: space-between; align-items: center;
padding: 4px 0;
@@ -618,7 +609,7 @@ input::placeholder { color: var(--text-disabled); }
}
/* Commands */
.dash-cmd-list { display: flex; flex-direction: column; gap: 3px; }
.dash-cmd-list { display: flex; flex-direction: column; gap: 3px; max-height: 270px; overflow-y: auto; }
.dash-cmd-row {
display: flex; align-items: center; gap: 6px;
padding: 3px 0; overflow: hidden;
@@ -737,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);
@@ -800,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;
@@ -825,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);