refactor: redesign Config as settings window with sidebar panels, remove system overview from Dashboard
All checks were successful
Beta Release / beta (push) Successful in 38s

- Config: sidebar navigation with 5 panels (Profile, AI Providers, Updates, Locale, Skills)
- Dashboard: remove duplicated system overview section, keep workflows and activity log
- New CSS for config window layout, cards, provider cards, update rows
- Add i18n panel keys (FR/EN)

💾 Generated with Crush

Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
This commit is contained in:
Augustin
2026-04-21 22:41:25 +02:00
parent 3cdcb22068
commit f3cb306053
10 changed files with 731 additions and 499 deletions

View File

@@ -0,0 +1,157 @@
package api
import (
"encoding/json"
"os"
"path/filepath"
"sync"
"time"
"unicode/utf8"
"github.com/muyue/muyue/internal/config"
)
const maxTokensApprox = 100000
const summarizeThreshold = 80000
const charsPerToken = 4
type FeedMessage struct {
ID string `json:"id"`
Role string `json:"role"`
Content string `json:"content"`
Time string `json:"time"`
}
type Conversation struct {
Messages []FeedMessage `json:"messages"`
Summary string `json:"summary,omitempty"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
}
type ConversationStore struct {
mu sync.RWMutex
path string
conv *Conversation
}
func NewConversationStore() *ConversationStore {
dir, err := config.ConfigDir()
if err != nil {
dir = "/tmp/muyue"
}
path := filepath.Join(dir, "conversation.json")
cs := &ConversationStore{path: path}
cs.load()
return cs
}
func (cs *ConversationStore) load() {
data, err := os.ReadFile(cs.path)
if err != nil {
cs.conv = &Conversation{
Messages: []FeedMessage{},
CreatedAt: time.Now().Format(time.RFC3339),
UpdatedAt: time.Now().Format(time.RFC3339),
}
return
}
var conv Conversation
if err := json.Unmarshal(data, &conv); err != nil {
cs.conv = &Conversation{
Messages: []FeedMessage{},
CreatedAt: time.Now().Format(time.RFC3339),
UpdatedAt: time.Now().Format(time.RFC3339),
}
return
}
if conv.Messages == nil {
conv.Messages = []FeedMessage{}
}
cs.conv = &conv
}
func (cs *ConversationStore) save() error {
cs.conv.UpdatedAt = time.Now().Format(time.RFC3339)
data, err := json.MarshalIndent(cs.conv, "", " ")
if err != nil {
return err
}
dir := filepath.Dir(cs.path)
os.MkdirAll(dir, 0755)
return os.WriteFile(cs.path, data, 0600)
}
func (cs *ConversationStore) Get() []FeedMessage {
cs.mu.RLock()
defer cs.mu.RUnlock()
out := make([]FeedMessage, len(cs.conv.Messages))
copy(out, cs.conv.Messages)
return out
}
func (cs *ConversationStore) GetSummary() string {
cs.mu.RLock()
defer cs.mu.RUnlock()
return cs.conv.Summary
}
func (cs *ConversationStore) Add(role, content string) FeedMessage {
cs.mu.Lock()
defer cs.mu.Unlock()
msg := FeedMessage{
ID: generateMsgID(),
Role: role,
Content: content,
Time: time.Now().Format(time.RFC3339),
}
cs.conv.Messages = append(cs.conv.Messages, msg)
cs.save()
return msg
}
func (cs *ConversationStore) Clear() {
cs.mu.Lock()
defer cs.mu.Unlock()
cs.conv.Messages = []FeedMessage{}
cs.conv.Summary = ""
cs.conv.CreatedAt = time.Now().Format(time.RFC3339)
cs.conv.UpdatedAt = time.Now().Format(time.RFC3339)
cs.save()
}
func (cs *ConversationStore) SetSummary(summary string) {
cs.mu.Lock()
defer cs.mu.Unlock()
cs.conv.Summary = summary
cs.save()
}
func (cs *ConversationStore) TrimOld(keepCount int) {
cs.mu.Lock()
defer cs.mu.Unlock()
if len(cs.conv.Messages) <= keepCount {
return
}
cs.conv.Messages = cs.conv.Messages[len(cs.conv.Messages)-keepCount:]
cs.save()
}
func (cs *ConversationStore) ApproxTokenCount() int {
cs.mu.RLock()
defer cs.mu.RUnlock()
total := utf8.RuneCountInString(cs.conv.Summary)
for _, m := range cs.conv.Messages {
total += utf8.RuneCountInString(m.Content)
}
return total / charsPerToken
}
func (cs *ConversationStore) NeedsSummarization() bool {
return cs.ApproxTokenCount() > summarizeThreshold
}
func generateMsgID() string {
return time.Now().Format("20060102150405.000")
}

View File

@@ -15,6 +15,8 @@ import (
"github.com/muyue/muyue/internal/version"
)
const summarizePrompt = `Résume la conversation suivante de manière concise et structurée. Garde les points clés, les décisions prises, le contexte technique important. Le résumé doit permettre de continuer la conversation sans perte de contexte. Réponds uniquement avec le résumé, sans meta-commentaire.`
func writeJSON(w http.ResponseWriter, data interface{}) {
json.NewEncoder(w).Encode(data)
}
@@ -264,18 +266,24 @@ func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) {
return
}
s.convStore.Add("user", body.Message)
if s.convStore.NeedsSummarization() {
s.autoSummarize()
}
orb, err := orchestrator.New(s.config)
if err != nil {
writeError(w, err.Error(), http.StatusServiceUnavailable)
return
}
orb.SetSystemPrompt(`You are Muyue Studio's AI orchestrator. You help the user with software development tasks. You can:
- Create and manage development plans with step-by-step workflows
- Propose agents (tools like Crush, Claude Code, etc.) to execute specific tasks
- Track progress across multi-step tasks
- Suggest file modifications, code reviews, and architecture decisions
orb.SetSystemPrompt(`Tu es l'orchestrateur IA de Muyue Studio. Tu aides l'utilisateur dans ses tâches de développement logiciel. Tu peux :
- Créer et gérer des plans de développement étape par étape
- Proposer des agents (outils comme Crush, Claude Code, etc.) pour exécuter des tâches spécifiques
- Suivre la progression de tâches multi-étapes
- Suggérer des modifications de fichiers, des revues de code, et des décisions d'architecture
Be concise, actionable, and structured. When proposing a plan, use clear numbered steps. When referencing files, use relative paths. You are embedded in the Muyue desktop app.`)
Sois concis, actionnable, et structuré. Quand tu proposes un plan, utilise des étapes numérotées claires. Quand tu références des fichiers, utilise des chemins relatifs. Tu es intégré dans l'application desktop Muyue.`)
if body.Stream {
w.Header().Set("Content-Type", "text/event-stream")
@@ -285,7 +293,6 @@ Be concise, actionable, and structured. When proposing a plan, use clear numbere
w.WriteHeader(http.StatusOK)
flusher, canFlush := w.(http.Flusher)
chunkSize := 8
result, err := orb.Send(body.Message)
if err != nil {
data, _ := json.Marshal(map[string]string{"error": err.Error()})
@@ -296,6 +303,9 @@ Be concise, actionable, and structured. When proposing a plan, use clear numbere
return
}
s.convStore.Add("assistant", result)
chunkSize := 8
runes := []rune(result)
for i := 0; i < len(runes); i += chunkSize {
end := i + chunkSize
@@ -323,9 +333,64 @@ Be concise, actionable, and structured. When proposing a plan, use clear numbere
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
s.convStore.Add("assistant", result)
writeJSON(w, map[string]string{"content": result})
}
func (s *Server) autoSummarize() {
messages := s.convStore.Get()
if len(messages) < 10 {
return
}
half := len(messages) / 2
var oldText string
for _, m := range messages[:half] {
oldText += m.Role + ": " + m.Content + "\n\n"
}
summary := s.convStore.GetSummary()
if summary != "" {
oldText = "Résumé précédent:\n" + summary + "\n\nNouveaux échanges:\n" + oldText
}
orb, err := orchestrator.New(s.config)
if err != nil {
return
}
orb.SetSystemPrompt(summarizePrompt)
result, err := orb.Send(oldText)
if err != nil {
return
}
s.convStore.SetSummary(result)
s.convStore.TrimOld(len(messages) - half)
s.convStore.Add("system", "[Conversation résumée automatiquement]")
}
func (s *Server) handleChatHistory(w http.ResponseWriter, r *http.Request) {
if r.Method != "GET" {
writeError(w, "GET only", http.StatusMethodNotAllowed)
return
}
messages := s.convStore.Get()
writeJSON(w, map[string]interface{}{
"messages": messages,
"tokens": s.convStore.ApproxTokenCount(),
})
}
func (s *Server) handleChatClear(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed)
return
}
s.convStore.Clear()
writeJSON(w, map[string]string{"status": "ok"})
}
func (s *Server) handleSaveProfile(w http.ResponseWriter, r *http.Request) {
if r.Method != "PUT" {
writeError(w, "PUT only", http.StatusMethodNotAllowed)

View File

@@ -12,6 +12,7 @@ type Server struct {
config *config.MuyueConfig
scanResult *scanner.ScanResult
mux *http.ServeMux
convStore *ConversationStore
}
func NewServer(cfg *config.MuyueConfig) *Server {
@@ -20,6 +21,7 @@ func NewServer(cfg *config.MuyueConfig) *Server {
mux: http.NewServeMux(),
}
s.scanResult = scanner.ScanSystem()
s.convStore = NewConversationStore()
s.routes()
return s
}
@@ -45,6 +47,9 @@ func (s *Server) routes() {
s.mux.HandleFunc("/api/config/profile", s.handleSaveProfile)
s.mux.HandleFunc("/api/config/provider", s.handleSaveProvider)
s.mux.HandleFunc("/api/update/run", s.handleRunUpdate)
s.mux.HandleFunc("/api/chat", s.handleChat)
s.mux.HandleFunc("/api/chat/history", s.handleChatHistory)
s.mux.HandleFunc("/api/chat/clear", s.handleChatClear)
}
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {

View File

@@ -33,6 +33,8 @@ const api = {
getTerminalSessions: () => request('/terminal/sessions'),
addSSHConnection: (conn) => request('/terminal/sessions', { method: 'POST', body: JSON.stringify(conn) }),
deleteSSHConnection: (name) => request(`/terminal/sessions/${encodeURIComponent(name)}`, { method: 'DELETE' }),
getChatHistory: () => request('/chat/history'),
clearChat: () => request('/chat/clear', { method: 'POST' }),
sendChat: (message, stream = true) => {
if (!stream) {
return request('/chat', { method: 'POST', body: JSON.stringify({ message, stream: false }) })

View File

@@ -1,9 +1,19 @@
import { useState, useEffect, useCallback } from 'react'
import { User, Brain, RefreshCw, Globe, Wrench } from 'lucide-react'
import { useI18n, LANGUAGES } from '../i18n'
import { getLayoutList } from '../i18n/keyboards'
const PANELS = [
{ id: 'profile', icon: User },
{ id: 'providers', icon: Brain },
{ id: 'updates', icon: RefreshCw },
{ id: 'locale', icon: Globe },
{ id: 'skills', icon: Wrench },
]
export default function Config({ api }) {
const { t, language, keyboard, setLanguage, setKeyboard } = useI18n()
const [activePanel, setActivePanel] = useState('profile')
const [config, setConfig] = useState(null)
const [providers, setProviders] = useState([])
const [skillList, setSkillList] = useState([])
@@ -119,14 +129,188 @@ export default function Config({ api }) {
const missingCount = tools.filter(t => !t.installed).length
return (
<div className="config-layout">
<div className="config-window">
{toast && <div className="config-toast">{toast}</div>}
<div className="config-section">
<div className="config-section-title">{t('config.systemUpdates')}</div>
<div className="config-actions-row">
<div className="config-sidebar">
{PANELS.map(p => {
const Icon = p.icon
return (
<div
key={p.id}
className={`config-sidebar-item ${activePanel === p.id ? 'active' : ''}`}
onClick={() => setActivePanel(p.id)}
>
<Icon size={16} />
<span>{t(`config.panels.${p.id}`)}</span>
</div>
)
})}
</div>
<div className="config-panel-area">
<div className="config-panel-header">
<h2 className="config-panel-title">{t(`config.panels.${activePanel}`)}</h2>
</div>
<div className="config-panel-body">
{activePanel === 'profile' && (
<PanelProfile
config={config} editProfile={editProfile}
profileForm={profileForm} setProfileForm={setProfileForm}
setEditProfile={setEditProfile} handleSaveProfile={handleSaveProfile}
t={t}
/>
)}
{activePanel === 'providers' && (
<PanelProviders
providers={providers} editProvider={editProvider}
providerForm={providerForm} setProviderForm={setProviderForm}
setEditProvider={setEditProvider} openProviderEdit={openProviderEdit}
handleSaveProvider={handleSaveProvider} api={api} loadData={loadData}
t={t}
/>
)}
{activePanel === 'updates' && (
<PanelUpdates
updates={updates} tools={tools}
checking={checking} updating={updating}
needsUpdateCount={needsUpdateCount}
installedCount={installedCount} missingCount={missingCount}
handleCheckUpdates={handleCheckUpdates}
handleUpdateTool={handleUpdateTool}
handleUpdateAll={handleUpdateAll}
t={t}
/>
)}
{activePanel === 'locale' && (
<PanelLocale
language={keyboard} layouts={layouts}
setLanguage={setLanguage} setKeyboard={setKeyboard}
t={t}
/>
)}
{activePanel === 'skills' && (
<PanelSkills skillList={skillList} t={t} />
)}
</div>
</div>
</div>
)
}
function PanelProfile({ config, editProfile, profileForm, setProfileForm, setEditProfile, handleSaveProfile, t }) {
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">
<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>
)}
</div>
)
}
function PanelProviders({ providers, editProvider, providerForm, setProviderForm, setEditProvider, openProviderEdit, handleSaveProvider, api, loadData, t }) {
return (
<div className="config-providers-list">
{providers.map((p, i) => (
<div key={i} className="config-card provider-card-v2">
<div className="provider-card-top">
<div className="provider-card-identity">
<span className="provider-card-name">{p.name}</span>
{p.active && <span className="badge accent">{t('config.active')}</span>}
</div>
<div className="provider-card-actions">
{editProvider !== p.name && (
<button className="ghost sm" onClick={() => openProviderEdit(p)}>{t('config.editProvider')}</button>
)}
{!p.active && editProvider !== p.name && (
<button className="sm" onClick={async () => {
await api.saveProvider({ name: p.name, active: true })
loadData()
}}>{t('config.activate')}</button>
)}
</div>
</div>
{editProvider !== p.name ? (
<div className="provider-card-meta">
<span className="mono">{p.model || '—'}</span>
<span style={{ color: p.apiKey ? 'var(--success)' : 'var(--error)' }}>
{p.apiKey ? t('config.keyConfigured') : t('config.noKey')}
</span>
</div>
) : (
<div className="provider-card-form">
<FormInput label={t('config.apiKey')} value={providerForm.api_key} onChange={v => setProviderForm(f => ({ ...f, api_key: v }))} type="password" />
<FormInput label={t('config.model')} value={providerForm.model} onChange={v => setProviderForm(f => ({ ...f, model: v }))} />
<FormInput label={t('config.baseUrl')} value={providerForm.base_url} onChange={v => setProviderForm(f => ({ ...f, base_url: v }))} />
<div className="config-card-actions">
<button className="primary sm" onClick={handleSaveProvider}>{t('config.save')}</button>
<button className="ghost sm" onClick={() => setEditProvider(null)}>{t('config.cancel')}</button>
</div>
</div>
)}
</div>
))}
</div>
)
}
function PanelUpdates({ updates, checking, updating, needsUpdateCount, installedCount, missingCount, handleCheckUpdates, handleUpdateTool, handleUpdateAll, t }) {
return (
<>
<div className="config-card">
<div className="config-update-controls">
<div className="config-update-stats">
<span className="badge ok">{installedCount} {t('config.installed')}</span>
{missingCount > 0 && <span className="badge error">{missingCount} {t('config.missing')}</span>}
{needsUpdateCount > 0 && <span className="badge warn">{needsUpdateCount} {t('config.needsUpdate')}</span>}
</div>
<div className="config-update-buttons">
<button className="sm" onClick={handleCheckUpdates} disabled={checking}>
{checking ? t('config.checking') : t('config.checkUpdates')}
{checking ? <><RefreshCw size={12} className="spin-icon" /> {t('config.checking')}</> : t('config.checkUpdates')}
</button>
{needsUpdateCount > 0 && (
<button className="sm primary" onClick={handleUpdateAll} disabled={updating === '__all__'}>
@@ -134,14 +318,13 @@ export default function Config({ api }) {
</button>
)}
</div>
<div className="config-stats">
<span className="badge ok">{installedCount} {t('config.installed')}</span>
{missingCount > 0 && <span className="badge error">{missingCount} {t('config.missing')}</span>}
{needsUpdateCount > 0 && <span className="badge warn">{needsUpdateCount} {t('config.needsUpdate')}</span>}
</div>
</div>
{updates.length === 0 ? (
<div className="config-card">
<div className="empty-state">{t('config.noUpdates')}</div>
</div>
) : (
<div className="config-update-list">
{updates.map((u, i) => (
@@ -169,82 +352,15 @@ export default function Config({ api }) {
))}
</div>
)}
</div>
</>
)
}
<div className="config-section">
<div className="config-section-title">
{t('config.profile')}
<button className="ghost sm" onClick={() => setEditProfile(!editProfile)}>
{editProfile ? t('config.cancel') : t('config.editProfile')}
</button>
</div>
{config?.profile && !editProfile ? (
<div>
<FieldRow label={t('config.name')} value={config.profile.name} />
<FieldRow label={t('config.pseudo')} value={config.profile.pseudo} />
<FieldRow label={t('config.email')} value={config.profile.email} />
<FieldRow label={t('config.editor')} value={config.profile.preferences?.editor} />
<FieldRow label={t('config.shell')} value={config.profile.preferences?.shell} />
<FieldRow label={t('config.languages')} value={config.profile.languages?.join(', ')} />
</div>
) : editProfile ? (
<div className="config-form">
<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 }))} />
<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-form-actions">
<button className="primary sm" onClick={handleSaveProfile}>{t('config.save')}</button>
<button className="ghost sm" onClick={() => setEditProfile(false)}>{t('config.cancel')}</button>
</div>
</div>
) : (
<div className="empty-state">{t('config.loadingProfile')}</div>
)}
</div>
<div className="config-section">
<div className="config-section-title">{t('config.aiProviders')}</div>
{providers.map((p, i) => (
<div key={i} className="provider-card">
<div className="provider-info">
<div className="provider-name">
{p.name}
{p.active && <span className="badge accent" style={{ marginLeft: 8 }}>{t('config.active')}</span>}
</div>
{editProvider !== p.name ? (
<div className="provider-meta">
<span>{p.model}</span>
<span style={{ color: p.apiKey ? 'var(--success)' : 'var(--error)' }}>
{p.apiKey ? t('config.keyConfigured') : t('config.noKey')}
</span>
<button className="ghost sm" onClick={() => openProviderEdit(p)}>{t('config.editProvider')}</button>
{!p.active && (
<button className="sm" onClick={async () => {
await api.saveProvider({ name: p.name, active: true })
loadData()
}}>{t('config.activate')}</button>
)}
</div>
) : (
<div className="config-form" style={{ marginTop: 8 }}>
<FormInput label={t('config.apiKey')} value={providerForm.api_key} onChange={v => setProviderForm(f => ({ ...f, api_key: v }))} type="password" />
<FormInput label={t('config.model')} value={providerForm.model} onChange={v => setProviderForm(f => ({ ...f, model: v }))} />
<FormInput label={t('config.baseUrl')} value={providerForm.base_url} onChange={v => setProviderForm(f => ({ ...f, base_url: v }))} />
<div className="config-form-actions">
<button className="primary sm" onClick={handleSaveProvider}>{t('config.save')}</button>
<button className="ghost sm" onClick={() => setEditProvider(null)}>{t('config.cancel')}</button>
</div>
</div>
)}
</div>
</div>
))}
</div>
<div className="config-section">
<div className="config-section-title">{t('config.language')}</div>
function PanelLocale({ language, keyboard, layouts, setLanguage, setKeyboard, t }) {
return (
<div className="config-card">
<div className="config-card-group">
<span className="config-card-group-label">{t('config.language')}</span>
<div className="chip-row">
{LANGUAGES.map(lang => (
<div
@@ -257,9 +373,8 @@ export default function Config({ api }) {
))}
</div>
</div>
<div className="config-section">
<div className="config-section-title">{t('config.keyboardLayout')}</div>
<div className="config-card-group">
<span className="config-card-group-label">{t('config.keyboardLayout')}</span>
<div className="chip-row">
{layouts.map(l => (
<div
@@ -272,9 +387,13 @@ export default function Config({ api }) {
))}
</div>
</div>
</div>
)
}
<div className="config-section">
<div className="config-section-title">{t('config.skills')} ({skillList.length})</div>
function PanelSkills({ skillList, t }) {
return (
<div className="config-card">
{skillList.length === 0 ? (
<div className="empty-state">
{t('config.noSkills')}
@@ -290,25 +409,15 @@ export default function Config({ api }) {
))
)}
</div>
</div>
)
}
function FieldRow({ label, value }) {
return (
<div className="field-row">
<span className="field-label">{label}</span>
<span className={`field-value ${!value ? 'empty' : ''}`}>{value || '—'}</span>
</div>
)
}
function FormInput({ label, value, onChange, type = 'text' }) {
return (
<div className="field-row">
<span className="field-label">{label}</span>
<div className="config-form-field">
<label className="config-form-label">{label}</label>
<input
className="config-input"
className="config-form-input"
type={type}
value={value}
onChange={e => onChange(e.target.value)}

View File

@@ -1,13 +1,10 @@
import { useState } from 'react'
import { useI18n } from '../i18n'
export default function Dashboard({ tools, updates, api, onRescan }) {
export default function Dashboard({ api, onRescan }) {
const { t, layout } = useI18n()
const [notifications, setNotifications] = useState([])
const installed = tools.filter(tool => tool.installed).length
const total = tools.length
const addNotif = (text, type) => {
setNotifications(prev => [{ text, type, id: Date.now(), time: new Date() }, ...prev])
}
@@ -16,35 +13,6 @@ export default function Dashboard({ tools, updates, api, onRescan }) {
<div className="dashboard-layout">
<div className="dashboard-content">
<div className="dashboard-grid">
<div className="dashboard-section">
<div className="dashboard-section-header">
<div className="dashboard-section-title">{t('dashboard.systemOverview')}</div>
{total > 0 && (
<span className="badge info">{installed}/{total}</span>
)}
</div>
{tools.length === 0 ? (
<div className="empty-state">{t('dashboard.noUpdateData')}</div>
) : (
<div className="tools-compact">
{tools.map((tool, i) => {
const name = tool.name || tool.Name
const ver = extractVersion(tool.Version || tool.version)
return (
<div key={i} className="tool-compact-row">
<span className={`badge sm ${tool.installed ? 'ok' : 'error'}`}>
{tool.installed ? '\u2713' : '\u2717'}
</span>
<span className="tool-compact-name">{name}</span>
{ver && <span className="tool-compact-ver">{ver}</span>}
{tool.installed && <span className="tool-compact-installed">{t('dashboard.installed')}</span>}
</div>
)
})}
</div>
)}
</div>
<div className="dashboard-section">
<div className="dashboard-section-header">
<div className="dashboard-section-title">{t('studio.workflows')}</div>
@@ -92,9 +60,3 @@ export default function Dashboard({ tools, updates, api, onRescan }) {
</div>
)
}
function extractVersion(s) {
if (!s) return ''
const m = s.match(/\d+\.\d+\.\d+/)
return m ? m[0] : s.slice(0, 12)
}

View File

@@ -1,54 +1,8 @@
import { useState, useRef, useEffect, useCallback } from 'react'
import { useI18n } from '../i18n'
const MSG_ID = () => Math.random().toString(36).slice(2, 10)
function parsePlanBlocks(text) {
const plans = []
const regex = /(?:^|\n)(?:###?\s+|(?:\d+\.\s+)?)\[?PLAN[^\]]*\]?:?\s*(.*?)(?=\n(?:###?\s+|(?:\d+\.\s+)?)\[?PLAN|\n## |\n### |\n$)/gis
const matches = text.matchAll(regex)
for (const m of matches) {
plans.push({ id: MSG_ID(), title: m[1].trim(), content: m[0].trim() })
}
if (plans.length === 0 && /plan|workflow/i.test(text)) {
const lines = text.split('\n').filter(l => /^\s*[-*]\s|^\s*\d+\.\s/.test(l))
if (lines.length > 0) {
plans.push({ id: MSG_ID(), title: text.split('\n')[0].slice(0, 80), content: text.trim() })
}
}
return plans
}
function parseAgentMentions(text) {
const agents = new Set()
const names = ['crush', 'claude', 'claude code', 'ollama', 'copilot', 'cursor', 'agent']
for (const name of names) {
if (new RegExp('\\b' + name + '\\b', 'i').test(text)) {
agents.add(name)
}
}
return [...agents]
}
function parseSteps(text) {
const steps = []
const lines = text.split('\n')
for (const line of lines) {
const match = line.match(/^\s*(\d+)[.)]\s+(.+)/)
if (match) {
steps.push({ num: match[1], text: match[2].trim() })
}
const bulletMatch = line.match(/^\s*[-*]\s+(.+)/)
if (bulletMatch) {
steps.push({ num: String(steps.length + 1), text: bulletMatch[1].trim() })
}
}
return steps
}
function renderContent(text) {
const parts = []
let i = 0
const codeBlockRegex = /(```[\s\S]*?```)/g
let match
let lastIndex = 0
@@ -70,7 +24,7 @@ function renderContent(text) {
}
function formatText(text) {
let html = text
return text
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
@@ -78,25 +32,41 @@ function formatText(text) {
.replace(/^## (.+)$/gm, '<h3 class="msg-h3">$1</h3>')
.replace(/^\s*[-*] (.+)$/gm, '<span class="msg-bullet">$1</span>')
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<span class="msg-step"><span class="msg-step-num">$1</span>$2</span>')
return html
}
function MessageBubble({ msg }) {
const { t } = useI18n()
const [expanded, setExpanded] = useState(null)
const plans = msg.role === 'ai' ? parsePlanBlocks(msg.content) : []
const steps = msg.role === 'ai' ? parseSteps(msg.content) : []
const agents = msg.role === 'ai' ? parseAgentMentions(msg.content) : []
function FeedItem({ msg }) {
const isUser = msg.role === 'user'
const isSystem = msg.role === 'system'
const roleLabel = isUser ? null : isSystem ? null : (
<div className="feed-avatar">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>
</svg>
</div>
)
const timeStr = msg.time ? new Date(msg.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) : ''
if (isSystem) {
return (
<div className="feed-item system">
<div className="feed-system-badge" />
<div className="feed-system-text">{msg.content}</div>
{timeStr && <span className="feed-time">{timeStr}</span>}
</div>
)
}
return (
<div className={`studio-msg ${msg.role}`}>
{msg.role === 'ai' && (
<div className="studio-msg-avatar">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
<div className={`feed-item ${msg.role}`}>
{roleLabel}
<div className="feed-body">
<div className="feed-header">
<span className="feed-role">{isUser ? 'Vous' : 'IA'}</span>
{timeStr && <span className="feed-time">{timeStr}</span>}
</div>
)}
<div className="studio-msg-body">
<div className="studio-msg-content">
<div className="feed-content">
{renderContent(msg.content).map((part, i) =>
part.type === 'code' ? (
<div key={i} className="studio-code-block">
@@ -108,53 +78,24 @@ function MessageBubble({ msg }) {
)
)}
</div>
{msg.role === 'ai' && (plans.length > 0 || agents.length > 0) && (
<div className="studio-msg-meta">
{plans.map(plan => (
<div key={plan.id} className="studio-plan-chip" onClick={() => setExpanded(expanded === plan.id ? null : plan.id)}>
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
{plan.title.slice(0, 60)}
<span className="studio-expand-icon">{expanded === plan.id ? '\u25B2' : '\u25BC'}</span>
</div>
))}
{agents.map(agent => (
<span key={agent} className="studio-agent-tag">
{agent}
</span>
))}
</div>
)}
{expanded && plans.find(p => p.id === expanded) && (
<div className="studio-plan-detail">
<div className="studio-plan-detail-header">{t('studio.planDetail')}</div>
{steps.length > 0 && (
<div className="studio-steps">
{steps.map(step => (
<div key={step.num} className="studio-step">
<span className="studio-step-num">{step.num}</span>
<span className="studio-step-text">{step.text}</span>
</div>
))}
</div>
)}
<div className="studio-plan-raw">
<pre>{plans.find(p => p.id === expanded).content}</pre>
</div>
</div>
)}
</div>
</div>
)
}
function StreamingMessage({ content }) {
function StreamingItem({ content }) {
return (
<div className="studio-msg ai">
<div className="studio-msg-avatar">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
<div className="feed-item assistant">
<div className="feed-avatar">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>
</svg>
</div>
<div className="studio-msg-body">
<div className="studio-msg-content">
<div className="feed-body">
<div className="feed-header">
<span className="feed-role">IA</span>
</div>
<div className="feed-content">
{renderContent(content).map((part, i) =>
part.type === 'code' ? (
<div key={i} className="studio-code-block">
@@ -165,121 +106,41 @@ function StreamingMessage({ content }) {
<span key={i} dangerouslySetInnerHTML={{ __html: formatText(part.content) }} />
)
)}
</div>
<span className="studio-cursor" />
</div>
</div>
)
}
function ContextPanel({ messages, selectedPlan, onSelectPlan }) {
const { t } = useI18n()
const [tab, setTab] = useState('plans')
const allPlans = []
const allAgents = new Set()
const activities = []
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i]
if (msg.role === 'ai') {
const plans = parsePlanBlocks(msg.content)
for (const plan of plans) {
if (!allPlans.find(p => p.title === plan.title)) {
allPlans.push({ ...plan, msgIndex: i })
}
}
parseAgentMentions(msg.content).forEach(a => allAgents.add(a))
}
activities.push({ role: msg.role, content: msg.content.slice(0, 100), time: msg.time })
}
const tabs = [
{ id: 'plans', label: t('studio.plans'), count: allPlans.length },
{ id: 'agents', label: t('studio.agents'), count: allAgents.size },
{ id: 'activity', label: t('studio.activity'), count: activities.length },
]
return (
<div className="studio-context">
<div className="studio-context-tabs">
{tabs.map(t2 => (
<div
key={t2.id}
className={`studio-context-tab ${tab === t2.id ? 'active' : ''}`}
onClick={() => setTab(t2.id)}
>
{t2.label}
{t2.count > 0 && <span className="studio-tab-count">{t2.count}</span>}
</div>
))}
</div>
<div className="studio-context-body">
{tab === 'plans' && (
allPlans.length > 0 ? (
<div className="studio-plan-list">
{allPlans.map(plan => (
<div
key={plan.id}
className={`studio-plan-item ${selectedPlan === plan.id ? 'active' : ''}`}
onClick={() => onSelectPlan(selectedPlan === plan.id ? null : plan.id)}
>
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"/><polyline points="14 2 14 8 20 8"/></svg>
<div className="studio-plan-item-text">{plan.title}</div>
<span className="studio-plan-item-badge">{parseSteps(plan.content).length} {t('studio.steps')}</span>
</div>
))}
</div>
) : (
<div className="studio-empty">{t('studio.noPlansYet')}</div>
)
)}
{tab === 'agents' && (
allAgents.size > 0 ? (
<div className="studio-agent-list">
{[...allAgents].map(agent => (
<div key={agent} className="studio-agent-item">
<div className="studio-agent-dot" />
<span className="studio-agent-name">{agent}</span>
<span className="badge info">{t('studio.mentioned')}</span>
</div>
))}
</div>
) : (
<div className="studio-empty">{t('studio.noAgentsYet')}</div>
)
)}
{tab === 'activity' && (
<div className="studio-activity-list">
{activities.map((act, i) => (
<div key={i} className="studio-activity-item">
<div className={`studio-activity-dot ${act.role}`} />
<div className="studio-activity-text">
{act.role === 'user' ? t('studio.you') + ': ' : 'AI: '}
{act.content}{act.content.length >= 100 ? '...' : ''}
</div>
</div>
))}
</div>
)}
</div>
</div>
)
}
export default function Studio({ api }) {
const { t } = useI18n()
const [messages, setMessages] = useState([
{ id: MSG_ID(), role: 'ai', content: t('studio.welcomeNew'), time: new Date() },
])
const [messages, setMessages] = useState([])
const [input, setInput] = useState('')
const [loading, setLoading] = useState(false)
const [streaming, setStreaming] = useState('')
const [selectedPlan, setSelectedPlan] = useState(null)
const [showContext, setShowContext] = useState(true)
const [loaded, setLoaded] = useState(false)
const messagesEnd = useRef(null)
const textareaRef = useRef(null)
useEffect(() => {
api.getChatHistory().then(data => {
if (data.messages && data.messages.length > 0) {
setMessages(data.messages)
} else {
setMessages([
{ id: 'welcome', role: 'system', content: t('studio.welcomeNew'), time: new Date().toISOString() },
])
}
setLoaded(true)
}).catch(() => {
setMessages([
{ id: 'welcome', role: 'system', content: t('studio.welcomeNew'), time: new Date().toISOString() },
])
setLoaded(true)
})
}, [])
useEffect(() => {
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages, streaming])
@@ -291,11 +152,26 @@ export default function Studio({ api }) {
}
}, [input])
const handleClear = useCallback(async () => {
try {
await api.clearChat()
setMessages([
{ id: 'clear-' + Date.now(), role: 'system', content: t('studio.cleared'), time: new Date().toISOString() },
])
} catch {}
}, [api, t])
const handleSend = useCallback(async () => {
if (!input.trim() || loading) return
const text = input.trim()
setInput('')
const userMsg = { id: MSG_ID(), role: 'user', content: text, time: new Date() }
if (text === '/clear') {
handleClear()
return
}
const userMsg = { id: Date.now().toString(), role: 'user', content: text, time: new Date().toISOString() }
setMessages(prev => [...prev, userMsg])
setLoading(true)
setStreaming('')
@@ -307,14 +183,24 @@ export default function Studio({ api }) {
}).catch(() => {})
const finalContent = accumulated || t('studio.noResponse')
setMessages(prev => [...prev, { id: MSG_ID(), role: 'ai', content: finalContent, time: new Date() }])
setMessages(prev => [...prev, {
id: (Date.now() + 1).toString(),
role: 'assistant',
content: finalContent,
time: new Date().toISOString(),
}])
} catch (err) {
setMessages(prev => [...prev, { id: MSG_ID(), role: 'ai', content: `${t('studio.error')}: ${err.message}`, time: new Date() }])
setMessages(prev => [...prev, {
id: (Date.now() + 1).toString(),
role: 'system',
content: `${t('studio.error')}: ${err.message}`,
time: new Date().toISOString(),
}])
} finally {
setLoading(false)
setStreaming('')
}
}, [input, loading, api, t])
}, [input, loading, api, t, handleClear])
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
@@ -323,22 +209,35 @@ export default function Studio({ api }) {
}
}
if (!loaded) {
return (
<div className="studio-layout">
<div className="studio-chat-area">
<div className="studio-messages">
{messages.map(msg => (
<MessageBubble key={msg.id} msg={msg} />
))}
{streaming && <StreamingMessage content={streaming} />}
{loading && !streaming && (
<div className="studio-msg ai">
<div className="studio-msg-avatar">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/></svg>
<div className="studio-feed-layout">
<div className="studio-feed">
<div className="feed-loading">
<div className="studio-thinking"><span /><span /><span /></div>
</div>
<div className="studio-msg-body">
<div className="studio-thinking">
<span /><span /><span />
</div>
</div>
)
}
return (
<div className="studio-feed-layout">
<div className="studio-feed">
{messages.map(msg => (
<FeedItem key={msg.id} msg={msg} />
))}
{streaming && <StreamingItem content={streaming} />}
{loading && !streaming && (
<div className="feed-item assistant">
<div className="feed-avatar">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
<path d="M12 2L2 7l10 5 10-5-10-5z"/><path d="M2 17l10 5 10-5"/><path d="M2 12l10 5 10-5"/>
</svg>
</div>
<div className="feed-body">
<div className="feed-content">
<div className="studio-thinking"><span /><span /><span /></div>
</div>
</div>
</div>
@@ -368,26 +267,9 @@ export default function Studio({ api }) {
</button>
</div>
<div className="studio-input-hint">
{t('studio.inputHint')}
{t('studio.inputHint')} &middot; /clear
</div>
</div>
</div>
<div className={`studio-sidebar ${showContext ? 'open' : ''}`}>
<div className="studio-sidebar-header">
<span>{t('studio.context')}</span>
<button className="ghost sm studio-sidebar-toggle" onClick={() => setShowContext(!showContext)}>
{showContext ? '\u203A' : '\u2039'}
</button>
</div>
{showContext && (
<ContextPanel
messages={messages}
selectedPlan={selectedPlan}
onSelectPlan={setSelectedPlan}
/>
)}
</div>
</div>
)
}

View File

@@ -113,6 +113,13 @@ const en = {
},
config: {
panels: {
profile: 'Profile',
providers: 'AI Providers',
updates: 'Updates',
locale: 'Language & Keyboard',
skills: 'Skills',
},
profile: 'Profile',
name: 'Name',
pseudo: 'Pseudo',
@@ -155,7 +162,7 @@ const en = {
version: 'Version',
installed: 'Installed',
missing: 'Missing',
editProfile: 'Edit profile',
editProfile: 'Edit',
cancel: 'Cancel',
editProvider: 'Configure',
},

View File

@@ -113,6 +113,13 @@ const fr = {
},
config: {
panels: {
profile: 'Profil',
providers: 'Fournisseurs IA',
updates: 'Mises \u00e0 jour',
locale: 'Langue & Clavier',
skills: 'Comp\u00e9tences',
},
profile: 'Profil',
name: 'Nom',
pseudo: 'Pseudo',
@@ -155,7 +162,7 @@ const fr = {
version: 'Version',
installed: 'Install\u00e9',
missing: 'Manquant',
editProfile: 'Modifier le profil',
editProfile: 'Modifier',
editProvider: 'Configurer',
cancel: 'Annuler',
},

View File

@@ -407,34 +407,93 @@ input::placeholder { color: var(--text-disabled); }
display: flex; justify-content: flex-end; gap: 8px;
}
.config-layout { max-width: 840px; margin: 0 auto; padding: 24px; overflow-y: auto; height: 100%; position: relative; }
.config-section { margin-bottom: 28px; }
.config-section-title {
font-size: 12px; font-weight: 700; color: var(--accent); text-transform: uppercase;
letter-spacing: 1px; margin-bottom: 16px; padding-bottom: 8px; border-bottom: 1px solid var(--border);
display: flex; align-items: center; justify-content: space-between;
.config-window { display: flex; height: 100%; overflow: hidden; }
.config-sidebar {
width: 180px; background: var(--bg-surface); border-right: 1px solid var(--border);
display: flex; flex-direction: column; padding: 12px 8px; gap: 2px; flex-shrink: 0;
overflow-y: auto;
}
.field-row { display: flex; align-items: center; padding: 10px 0; border-bottom: 1px solid var(--border); gap: 12px; }
.field-row:last-child { border-bottom: none; }
.field-label { width: 140px; flex-shrink: 0; color: var(--text-tertiary); font-size: 13px; }
.field-value { color: var(--text-primary); font-size: 14px; flex: 1; }
.field-value.empty { color: var(--text-disabled); font-style: italic; }
.config-input { flex: 1; background: var(--bg-input); border: 1px solid var(--border); border-radius: var(--radius); padding: 6px 10px; color: var(--text-primary); font-size: 13px; outline: none; font-family: var(--font-mono); }
.config-input:focus { border-color: var(--accent); box-shadow: 0 0 0 2px var(--border-accent); }
.config-form-actions { display: flex; gap: 8px; padding: 12px 0 0 152px; }
.config-actions-row { display: flex; gap: 8px; margin-bottom: 12px; }
.config-stats { display: flex; gap: 8px; margin-bottom: 12px; }
.config-sidebar-item {
display: flex; align-items: center; gap: 10px; padding: 9px 12px;
border-radius: var(--radius); font-size: 13px; font-weight: 500;
color: var(--text-tertiary); cursor: pointer; transition: all 0.15s;
user-select: none;
}
.config-sidebar-item:hover { background: var(--bg-card); color: var(--text-primary); }
.config-sidebar-item.active { background: var(--accent-bg); color: var(--accent); font-weight: 600; }
.config-sidebar-item svg { flex-shrink: 0; }
.config-panel-area { flex: 1; display: flex; flex-direction: column; overflow: hidden; }
.config-panel-header {
padding: 20px 28px 0; flex-shrink: 0;
}
.config-panel-title {
font-size: 16px; font-weight: 700; color: var(--text-primary); margin: 0;
}
.config-panel-body { flex: 1; overflow-y: auto; padding: 16px 28px 28px; }
.config-card {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 20px 24px; margin-bottom: 16px;
}
.config-card-row {
display: flex; align-items: center; padding: 10px 0;
border-bottom: 1px solid var(--border); gap: 16px;
}
.config-card-row:last-of-type { border-bottom: none; }
.config-card-label { width: 130px; flex-shrink: 0; color: var(--text-tertiary); font-size: 13px; }
.config-card-value { color: var(--text-primary); font-size: 14px; flex: 1; }
.config-card-value.mono { font-family: var(--font-mono); }
.config-card-value:not(.mono)[style*="—"] { color: var(--text-disabled); font-style: italic; }
.config-card-actions { display: flex; gap: 8px; padding-top: 16px; }
.config-form-field { margin-bottom: 14px; }
.config-form-label { display: block; font-size: 11px; font-weight: 600; color: var(--text-tertiary); margin-bottom: 5px; text-transform: uppercase; letter-spacing: 0.3px; }
.config-form-input {
width: 100%; background: var(--bg-input); border: 1px solid var(--border);
border-radius: var(--radius); padding: 8px 12px; color: var(--text-primary);
font-size: 13px; font-family: var(--font-mono); outline: none;
transition: border-color 0.2s, box-shadow 0.2s;
}
.config-form-input:focus { border-color: var(--accent); box-shadow: 0 0 0 2px var(--border-accent); }
.config-card-group { margin-bottom: 20px; }
.config-card-group:last-child { margin-bottom: 0; }
.config-card-group-label { display: block; font-size: 11px; font-weight: 700; color: var(--text-tertiary); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 10px; }
.config-providers-list { display: flex; flex-direction: column; gap: 12px; }
.provider-card-v2 {
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 16px 20px; transition: border-color 0.2s;
}
.provider-card-v2:hover { border-color: var(--accent-dim); }
.provider-card-top { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.provider-card-identity { display: flex; align-items: center; gap: 10px; }
.provider-card-name { font-weight: 600; color: var(--text-primary); font-size: 14px; }
.provider-card-actions { display: flex; gap: 8px; flex-shrink: 0; }
.provider-card-meta { display: flex; gap: 16px; font-size: 12px; color: var(--text-tertiary); font-family: var(--font-mono); margin-top: 8px; }
.provider-card-form { margin-top: 12px; padding-top: 12px; border-top: 1px solid var(--border); }
.config-update-controls {
display: flex; align-items: center; justify-content: space-between; gap: 16px; flex-wrap: wrap;
}
.config-update-stats { display: flex; gap: 8px; }
.config-update-buttons { display: flex; gap: 8px; }
.config-update-list { display: flex; flex-direction: column; gap: 2px; }
.config-update-row { display: flex; align-items: center; justify-content: space-between; padding: 8px 12px; border-radius: var(--radius); }
.config-update-row:hover { background: var(--bg-card); }
.config-update-row { display: flex; align-items: center; justify-content: space-between; padding: 10px 16px; border-radius: var(--radius); background: var(--bg-card); border: 1px solid var(--border); margin-bottom: 6px; }
.config-update-row:hover { border-color: var(--accent-dim); }
.config-update-info { display: flex; align-items: center; gap: 16px; flex: 1; }
.config-update-name { color: var(--text-primary); font-weight: 600; font-size: 13px; min-width: 100px; }
.config-update-versions { color: var(--text-tertiary); font-size: 12px; font-family: var(--font-mono); }
.config-skill-row { display: flex; align-items: center; gap: 10px; padding: 6px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
.config-skill-row { display: flex; align-items: center; gap: 10px; padding: 8px 0; border-bottom: 1px solid var(--border); font-size: 13px; }
.config-skill-row:last-child { border-bottom: none; }
.config-skill-name { color: var(--text-primary); font-weight: 600; min-width: 120px; }
.config-skill-desc { color: var(--text-tertiary); flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.chip-row { display: flex; gap: 8px; flex-wrap: wrap; }
.config-toast {
position: fixed; bottom: 40px; left: 50%; transform: translateX(-50%);
background: var(--accent); color: #fff; padding: 10px 24px; border-radius: var(--radius-lg);
@@ -442,15 +501,8 @@ input::placeholder { color: var(--text-disabled); }
box-shadow: 0 4px 24px rgba(255, 0, 51, 0.3);
}
.provider-card {
background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--radius);
padding: 14px 16px; margin-bottom: 8px; display: flex; align-items: center; justify-content: space-between;
transition: border-color 0.2s;
}
.provider-card:hover { border-color: var(--accent-dim); }
.provider-info { display: flex; flex-direction: column; gap: 4px; }
.provider-name { font-weight: 600; color: var(--text-primary); font-size: 14px; }
.provider-meta { display: flex; gap: 12px; font-size: 12px; color: var(--text-tertiary); font-family: var(--font-mono); }
.spin-icon { animation: spin 0.8s linear infinite; display: inline-block; vertical-align: middle; }
.mono { font-family: var(--font-mono); }
@@ -480,11 +532,8 @@ input::placeholder { color: var(--text-disabled); }
.dashboard-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; padding: 24px; }
.dashboard-section {
background: var(--bg-card);
border: 1px solid var(--border);
border-radius: var(--radius-lg);
padding: 20px;
transition: border-color 0.2s;
background: var(--bg-card); border: 1px solid var(--border);
border-radius: var(--radius-lg); padding: 20px; transition: border-color 0.2s;
}
.dashboard-section:hover { border-color: var(--accent-dim); }
.dashboard-section.full-width { grid-column: 1 / -1; }
@@ -498,19 +547,6 @@ input::placeholder { color: var(--text-disabled); }
.dashboard-notifications-inline { display: flex; flex-direction: column; gap: 2px; }
.dashboard-tools { padding: 0; }
.tools-compact { display: flex; flex-direction: column; gap: 2px; }
.tool-compact-row {
display: flex; align-items: center; gap: 10px;
padding: 6px 12px; border-radius: var(--radius);
font-size: 13px; transition: background 0.1s;
}
.tool-compact-row:hover { background: var(--bg-card); }
.badge.sm { padding: 1px 5px; font-size: 10px; }
.tool-compact-name { color: var(--text-primary); font-weight: 500; flex: 1; }
.tool-compact-ver { color: var(--text-tertiary); font-size: 11px; font-family: var(--font-mono); }
.tool-compact-installed { color: var(--success); font-size: 11px; font-family: var(--font-mono); opacity: 0.7; }
.dashboard-notifications { padding: 0; }
.notif-row {
display: flex; align-items: flex-start; gap: 12px;