feat(web): add i18n support with FR/EN locales and keyboard layout awareness
All checks were successful
Beta Release / beta (push) Successful in 36s

Add full internationalization system with React context, French/English
translations, and AZERTY/QWERTY keyboard layout support. Dashboard now
uses a tabbed layout (Tools, Notifications, Workflows). Config page exposes
language and keyboard preferences persisted via new /api/preferences endpoint.

💕 Generated with Crush

Assisted-by: GLM-5-Turbo via Crush <crush@charm.land>
This commit is contained in:
Augustin
2026-04-21 21:48:36 +02:00
parent 3dc24ae22c
commit 11417d3ea7
15 changed files with 713 additions and 186 deletions

View File

@@ -5,6 +5,7 @@ import (
"net/http" "net/http"
"os/exec" "os/exec"
"github.com/muyue/muyue/internal/config"
"github.com/muyue/muyue/internal/lsp" "github.com/muyue/muyue/internal/lsp"
"github.com/muyue/muyue/internal/mcp" "github.com/muyue/muyue/internal/mcp"
"github.com/muyue/muyue/internal/scanner" "github.com/muyue/muyue/internal/scanner"
@@ -174,6 +175,36 @@ func (s *Server) handleScan(w http.ResponseWriter, r *http.Request) {
writeJSON(w, map[string]string{"status": "ok"}) writeJSON(w, map[string]string{"status": "ok"})
} }
func (s *Server) handleUpdatePreferences(w http.ResponseWriter, r *http.Request) {
if r.Method != "PUT" {
writeError(w, "PUT only", http.StatusMethodNotAllowed)
return
}
if s.config == nil {
writeError(w, "no config", http.StatusNotFound)
return
}
var body struct {
Language string `json:"language"`
KeyboardLayout string `json:"keyboard_layout"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, err.Error(), http.StatusBadRequest)
return
}
if body.Language != "" {
s.config.Profile.Preferences.Language = body.Language
}
if body.KeyboardLayout != "" {
s.config.Profile.Preferences.KeyboardLayout = body.KeyboardLayout
}
if err := config.Save(s.config); err != nil {
writeError(w, err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]string{"status": "ok"})
}
func (s *Server) handleTerminal(w http.ResponseWriter, r *http.Request) { func (s *Server) handleTerminal(w http.ResponseWriter, r *http.Request) {
if r.Method != "POST" { if r.Method != "POST" {
writeError(w, "POST only", http.StatusMethodNotAllowed) writeError(w, "POST only", http.StatusMethodNotAllowed)

View File

@@ -35,6 +35,7 @@ func (s *Server) routes() {
s.mux.HandleFunc("/api/updates", s.handleUpdates) s.mux.HandleFunc("/api/updates", s.handleUpdates)
s.mux.HandleFunc("/api/install", s.handleInstall) s.mux.HandleFunc("/api/install", s.handleInstall)
s.mux.HandleFunc("/api/scan", s.handleScan) s.mux.HandleFunc("/api/scan", s.handleScan)
s.mux.HandleFunc("/api/preferences", s.handleUpdatePreferences)
s.mux.HandleFunc("/api/terminal", s.handleTerminal) s.mux.HandleFunc("/api/terminal", s.handleTerminal)
s.mux.HandleFunc("/api/mcp/configure", s.handleMCPConfigure) s.mux.HandleFunc("/api/mcp/configure", s.handleMCPConfigure)
} }
@@ -42,7 +43,7 @@ func (s *Server) routes() {
func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) { func (s *Server) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json") w.Header().Set("Content-Type", "application/json")
w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type") w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
if r.Method == "OPTIONS" { if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK) w.WriteHeader(http.StatusOK)

View File

@@ -15,12 +15,14 @@ type Profile struct {
Email string `yaml:"email"` Email string `yaml:"email"`
Languages []string `yaml:"languages"` Languages []string `yaml:"languages"`
Preferences struct { Preferences struct {
Editor string `yaml:"editor"` Editor string `yaml:"editor"`
Shell string `yaml:"shell"` Shell string `yaml:"shell"`
Theme string `yaml:"theme"` Theme string `yaml:"theme"`
DefaultAI string `yaml:"default_ai"` DefaultAI string `yaml:"default_ai"`
AutoUpdate bool `yaml:"auto_update"` AutoUpdate bool `yaml:"auto_update"`
CheckOnStart bool `yaml:"check_on_start"` CheckOnStart bool `yaml:"check_on_start"`
Language string `yaml:"language"`
KeyboardLayout string `yaml:"keyboard_layout"`
} `yaml:"preferences"` } `yaml:"preferences"`
} }
@@ -179,6 +181,8 @@ func Default() *MuyueConfig {
cfg.Profile.Preferences.AutoUpdate = true cfg.Profile.Preferences.AutoUpdate = true
cfg.Profile.Preferences.CheckOnStart = true cfg.Profile.Preferences.CheckOnStart = true
cfg.Profile.Preferences.Theme = "charm" cfg.Profile.Preferences.Theme = "charm"
cfg.Profile.Preferences.Language = "fr"
cfg.Profile.Preferences.KeyboardLayout = "azerty"
cfg.AI.Providers = []AIProvider{ cfg.AI.Providers = []AIProvider{
{ {

View File

@@ -25,6 +25,7 @@ const api = {
runScan: () => request('/scan', { method: 'POST' }), runScan: () => request('/scan', { method: 'POST' }),
installTools: (tools) => request('/install', { method: 'POST', body: JSON.stringify({ tools }) }), installTools: (tools) => request('/install', { method: 'POST', body: JSON.stringify({ tools }) }),
configureMCP: () => request('/mcp/configure', { method: 'POST' }), configureMCP: () => request('/mcp/configure', { method: 'POST' }),
savePreferences: (prefs) => request('/preferences', { method: 'PUT', body: JSON.stringify(prefs) }),
runCommand: (command, cwd) => request('/terminal', { method: 'POST', body: JSON.stringify({ command, cwd }) }), runCommand: (command, cwd) => request('/terminal', { method: 'POST', body: JSON.stringify({ command, cwd }) }),
} }

View File

@@ -1,24 +1,26 @@
import { useState, useEffect, useCallback } from 'react' import { useState, useEffect, useCallback, useMemo } from 'react'
import api from '../api/client' import api from '../api/client'
import { getTheme, getThemeNames, applyTheme } from '../themes' import { getTheme, getThemeNames, applyTheme } from '../themes'
import { useI18n } from '../i18n'
import Dashboard from './Dashboard' import Dashboard from './Dashboard'
import Studio from './Studio' import Studio from './Studio'
import Shell from './Shell' import Shell from './Shell'
import Config from './Config' import Config from './Config'
const TABS = [
{ id: 'dash', label: 'Dashboard', icon: '\u25A0' },
{ id: 'studio', label: 'Studio', icon: '\u27E8\u27E9' },
{ id: 'shell', label: 'Shell', icon: '$' },
{ id: 'config', label: 'Config', icon: '\u2699' },
]
export default function App() { export default function App() {
const [activeTab, setActiveTab] = useState('dash') const [activeTab, setActiveTab] = useState('dash')
const [info, setInfo] = useState({}) const [info, setInfo] = useState({})
const [clock, setClock] = useState(new Date()) const [clock, setClock] = useState(new Date())
const [updates, setUpdates] = useState([]) const [updates, setUpdates] = useState([])
const [tools, setTools] = useState([]) const [tools, setTools] = useState([])
const { t, layout } = useI18n()
const TABS = useMemo(() => [
{ id: 'dash', label: t('tabs.dashboard'), icon: '\u25A0' },
{ id: 'studio', label: t('tabs.studio'), icon: '\u27E8\u27E9' },
{ id: 'shell', label: t('tabs.shell'), icon: '$' },
{ id: 'config', label: t('tabs.config'), icon: '\u2699' },
], [t])
useEffect(() => { useEffect(() => {
api.getInfo().then(setInfo).catch(() => {}) api.getInfo().then(setInfo).catch(() => {})
@@ -35,10 +37,16 @@ export default function App() {
useEffect(() => { useEffect(() => {
const onKey = (e) => { const onKey = (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
const map = { '1': 'dash', '2': 'studio', '3': 'shell', '4': 'config' } if (!e.ctrlKey && !e.metaKey) return
if (map[e.key]) { const map = {
Digit1: 'dash',
Digit2: 'studio',
Digit3: 'shell',
Digit4: 'config',
}
if (map[e.code]) {
e.preventDefault() e.preventDefault()
setActiveTab(map[e.key]) setActiveTab(map[e.code])
} }
} }
window.addEventListener('keydown', onKey) window.addEventListener('keydown', onKey)
@@ -50,6 +58,25 @@ export default function App() {
const hasUpdates = updates.some(u => u.needsUpdate) const hasUpdates = updates.some(u => u.needsUpdate)
const installed = tools.filter(t => t.installed).length const installed = tools.filter(t => t.installed).length
const WINDOW_SHORTCUTS = useMemo(() => ({
dash: [
{ keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') },
],
studio: [
{ keys: layout.keys.enter, desc: t('statusbar.sendMessage') },
{ keys: `${layout.keys.shift}+${layout.keys.enter}`, desc: t('statusbar.newLine') },
{ keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') },
],
shell: [
{ keys: layout.keys.enter, desc: t('statusbar.runCommand') },
{ keys: `${layout.keys.up}/${layout.keys.down}`, desc: t('statusbar.commandHistory') },
{ keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') },
],
config: [
{ keys: `${layout.keys.ctrl}+${layout.keys.range}`, desc: t('statusbar.switchWindow') },
],
}), [layout, t])
const renderContent = () => { const renderContent = () => {
switch (activeTab) { switch (activeTab) {
case 'dash': return <Dashboard tools={tools} updates={updates} api={api} onRescan={t => setTools(t)} /> case 'dash': return <Dashboard tools={tools} updates={updates} api={api} onRescan={t => setTools(t)} />
@@ -86,28 +113,43 @@ export default function App() {
<div className="header-spacer" /> <div className="header-spacer" />
<div className="header-indicators"> <div className="header-indicators">
<span className={`indicator ${installed > 0 ? 'ok' : 'off'}`} title={`${installed} tools installed`} /> <span
<span className={`indicator ${hasUpdates ? 'warn' : 'ok'}`} title={hasUpdates ? 'Updates available' : 'Up to date'} /> className={`indicator ${installed > 0 ? 'ok' : 'off'}`}
title={t('header.toolsInstalled', { count: installed })}
/>
<span
className={`indicator ${hasUpdates ? 'warn' : 'ok'}`}
title={hasUpdates ? t('header.updatesAvailable') : t('header.upToDate')}
/>
</div> </div>
<span className="header-clock"> <span className="header-clock">
{clock.toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' })} {clock.toLocaleTimeString(layout.locale, { hour: '2-digit', minute: '2-digit' })}
</span> </span>
</header> </header>
<main className="content fade-in" key={activeTab}> <main className="content fade-in" key={`${activeTab}-${TABS.length}`}>
{renderContent()} {renderContent()}
</main> </main>
<footer className="statusbar"> <footer className="statusbar">
<div className="statusbar-left"> <div className="statusbar-left">
<span>Press 1-4 to switch tabs</span> <FooterShortcuts shortcuts={WINDOW_SHORTCUTS[activeTab] || []} />
</div> </div>
<div className="statusbar-right"> <div className="statusbar-right">
{hasUpdates && <span style={{ color: 'var(--warning)' }}>Updates available</span>} <span style={{ fontFamily: 'var(--font-mono)' }}>
<span>v{info.version || '...'}</span> {layout.keys.ctrl}+{layout.keys.range} {t('statusbar.switchWindow')}
</span>
</div> </div>
</footer> </footer>
</div> </div>
) )
} }
function FooterShortcuts({ shortcuts }) {
return shortcuts.map((s, i) => (
<span key={i} className="statusbar-shortcut">
<kbd>{s.keys}</kbd> {s.desc}
</span>
))
}

View File

@@ -1,7 +1,10 @@
import { useState, useEffect } from 'react' import { useState, useEffect } from 'react'
import { getThemeNames, applyTheme, getTheme } from '../themes' import { getThemeNames, applyTheme, getTheme } from '../themes'
import { useI18n, LANGUAGES } from '../i18n'
import { getLayoutList } from '../i18n/keyboards'
export default function Config({ api }) { export default function Config({ api }) {
const { t, language, keyboard, setLanguage, setKeyboard, layout } = useI18n()
const [config, setConfig] = useState(null) const [config, setConfig] = useState(null)
const [providers, setProviders] = useState([]) const [providers, setProviders] = useState([])
const [skillList, setSkillList] = useState([]) const [skillList, setSkillList] = useState([])
@@ -14,6 +17,7 @@ export default function Config({ api }) {
}, []) }, [])
const themes = getThemeNames() const themes = getThemeNames()
const layouts = getLayoutList()
const handleThemeChange = (themeId) => { const handleThemeChange = (themeId) => {
applyTheme(getTheme(themeId)) applyTheme(getTheme(themeId))
@@ -30,35 +34,65 @@ export default function Config({ api }) {
return ( return (
<div className="config-layout"> <div className="config-layout">
<div className="config-section"> <div className="config-section">
<div className="config-section-title">Profile</div> <div className="config-section-title">{t('config.profile')}</div>
{config?.profile ? ( {config?.profile ? (
<div> <div>
<FieldRow label="Name" value={config.profile.name} /> <FieldRow label={t('config.name')} value={config.profile.name} />
<FieldRow label="Pseudo" value={config.profile.pseudo} /> <FieldRow label={t('config.pseudo')} value={config.profile.pseudo} />
<FieldRow label="Email" value={config.profile.email} /> <FieldRow label={t('config.email')} value={config.profile.email} />
<FieldRow label="Editor" value={config.profile.preferences?.editor} /> <FieldRow label={t('config.editor')} value={config.profile.preferences?.editor} />
<FieldRow label="Shell" value={config.profile.preferences?.shell} /> <FieldRow label={t('config.shell')} value={config.profile.preferences?.shell} />
<FieldRow label="Default AI" value={config.profile.preferences?.defaultAI} /> <FieldRow label={t('config.defaultAi')} value={config.profile.preferences?.defaultAI} />
<FieldRow label="Languages" value={config.profile.languages?.join(', ')} /> <FieldRow label={t('config.languages')} value={config.profile.languages?.join(', ')} />
</div> </div>
) : ( ) : (
<div className="empty-state">Loading profile...</div> <div className="empty-state">{t('config.loadingProfile')}</div>
)} )}
</div> </div>
<div className="config-section"> <div className="config-section">
<div className="config-section-title">AI Providers</div> <div className="config-section-title">{t('config.language')}</div>
<div className="actions-stack">
{LANGUAGES.map(lang => (
<div
key={lang.id}
className={`chip ${language === lang.id ? 'active' : ''}`}
onClick={() => setLanguage(lang.id)}
>
{lang.name}
</div>
))}
</div>
</div>
<div className="config-section">
<div className="config-section-title">{t('config.keyboardLayout')}</div>
<div className="actions-stack">
{layouts.map(l => (
<div
key={l.id}
className={`chip ${keyboard === l.id ? 'active' : ''}`}
onClick={() => setKeyboard(l.id)}
>
{l.name}
</div>
))}
</div>
</div>
<div className="config-section">
<div className="config-section-title">{t('config.aiProviders')}</div>
{providers.map((p, i) => ( {providers.map((p, i) => (
<div key={i} className="provider-card"> <div key={i} className="provider-card">
<div className="provider-info"> <div className="provider-info">
<div className="provider-name"> <div className="provider-name">
{p.name} {p.name}
{p.active && <span className="badge accent" style={{ marginLeft: 8 }}>Active</span>} {p.active && <span className="badge accent" style={{ marginLeft: 8 }}>{t('config.active')}</span>}
</div> </div>
<div className="provider-meta"> <div className="provider-meta">
<span>{p.model}</span> <span>{p.model}</span>
<span style={{ color: p.apiKey ? 'var(--success)' : 'var(--error)' }}> <span style={{ color: p.apiKey ? 'var(--success)' : 'var(--error)' }}>
{p.apiKey ? 'Key configured' : 'No key'} {p.apiKey ? t('config.keyConfigured') : t('config.noKey')}
</span> </span>
</div> </div>
</div> </div>
@@ -67,32 +101,32 @@ export default function Config({ api }) {
</div> </div>
<div className="config-section"> <div className="config-section">
<div className="config-section-title">Theme</div> <div className="config-section-title">{t('config.theme')}</div>
<div className="theme-picker"> <div className="theme-picker">
{themes.map(t => ( {themes.map(th => (
<div <div
key={t.id} key={th.id}
className={`theme-swatch ${currentTheme === t.id ? 'active' : ''}`} className={`theme-swatch ${currentTheme === th.id ? 'active' : ''}`}
style={{ background: themeColors[t.id] || '#FF0033' }} style={{ background: themeColors[th.id] || '#FF0033' }}
onClick={() => handleThemeChange(t.id)} onClick={() => handleThemeChange(th.id)}
title={t.name} title={th.name}
/> />
))} ))}
</div> </div>
</div> </div>
<div className="config-section"> <div className="config-section">
<div className="config-section-title">Skills ({skillList.length})</div> <div className="config-section-title">{t('config.skills')} ({skillList.length})</div>
{skillList.length === 0 ? ( {skillList.length === 0 ? (
<div className="empty-state"> <div className="empty-state">
No skills installed. {t('config.noSkills')}
<span style={{ fontFamily: 'var(--font-mono)' }}>Run muyue skills init</span> <span style={{ fontFamily: 'var(--font-mono)' }}>{t('config.runSkillsInit')}</span>
</div> </div>
) : ( ) : (
skillList.map((s, i) => ( skillList.map((s, i) => (
<div key={i} className="tool-row"> <div key={i} className="tool-row">
<span className="tool-name">{s.name}</span> <span className="tool-name">{s.name}</span>
<span className={`badge neutral`}>{s.target || 'both'}</span> <span className="badge neutral">{s.target || 'both'}</span>
<span style={{ color: 'var(--text-tertiary)', fontSize: 12 }}>{s.description}</span> <span style={{ color: 'var(--text-tertiary)', fontSize: 12 }}>{s.description}</span>
</div> </div>
)) ))
@@ -106,7 +140,7 @@ function FieldRow({ label, value }) {
return ( return (
<div className="field-row"> <div className="field-row">
<span className="field-label">{label}</span> <span className="field-label">{label}</span>
<span className={`field-value ${!value ? 'empty' : ''}`}>{value || 'Not set'}</span> <span className={`field-value ${!value ? 'empty' : ''}`}>{value || ''}</span>
</div> </div>
) )
} }

View File

@@ -1,126 +1,101 @@
import { useState } from 'react' import { useState } from 'react'
import { useI18n } from '../i18n'
export default function Dashboard({ tools, updates, api, onRescan }) { export default function Dashboard({ tools, updates, api, onRescan }) {
const [installing, setInstalling] = useState(false) const { t, layout } = useI18n()
const [log, setLog] = useState([]) const [activeSection, setActiveSection] = useState('tools')
const [notifications, setNotifications] = useState([])
const installed = tools.filter(t => t.installed).length const installed = tools.filter(tool => tool.installed).length
const total = tools.length const total = tools.length
const pct = total > 0 ? Math.round((installed / total) * 100) : 0
const missing = tools.filter(t => !t.installed).map(t => t.name || t.Name)
const handleInstall = async () => { const addNotif = (text, type) => {
if (missing.length === 0) return setNotifications(prev => [{ text, type, id: Date.now(), time: new Date() }, ...prev])
setInstalling(true)
addLog(`Installing ${missing.length} tools...`, 'info')
try {
await api.installTools(missing)
addLog('Install started. Rescanning...', 'ok')
await api.runScan()
const data = await api.getTools()
onRescan(data.tools || [])
addLog('Done.', 'ok')
} catch (err) {
addLog(err.message, 'error')
}
setInstalling(false)
} }
const handleScan = async () => { const sections = [
addLog('Scanning system...', 'info') { id: 'tools', label: t('dashboard.systemOverview') },
await api.runScan() { id: 'notifications', label: t('dashboard.activityLog') },
const data = await api.getTools() { id: 'workflows', label: t('studio.workflows') },
onRescan(data.tools || []) ]
addLog('Scan complete.', 'ok')
}
const handleCheckUpdates = async () => {
const data = await api.getUpdates().catch(() => ({ updates: [] }))
const count = (data.updates || []).filter(u => u.needsUpdate).length
addLog(count > 0 ? `${count} updates available.` : 'All tools up to date.', count > 0 ? 'warn' : 'ok')
}
const addLog = (text, type) => setLog(prev => [...prev, { text, type, id: Date.now() }])
return ( return (
<div className="grid-2"> <div className="dashboard-layout">
<div style={{ overflow: 'auto' }}> <div className="dashboard-tabs">
<div className="card"> {sections.map(s => (
<div className="card-header"> <div
System Overview &mdash; {installed}/{total} tools ({pct}%) key={s.id}
className={`dashboard-tab ${activeSection === s.id ? 'active' : ''}`}
onClick={() => setActiveSection(s.id)}
>
{s.label}
{s.id === 'tools' && total > 0 && (
<span className="tab-count">{installed}/{total}</span>
)}
{s.id === 'notifications' && notifications.length > 0 && (
<span className="tab-count warn">{notifications.length}</span>
)}
</div> </div>
<div className="progress" style={{ marginBottom: 16 }}> ))}
<div className="progress-fill" style={{ width: `${pct}%` }} />
</div>
<div>
{tools.map((t, i) => {
const name = t.name || t.Name
const ver = extractVersion(t.Version || t.version)
return (
<div key={i} className="tool-row">
<span className={`badge ${t.installed ? 'ok' : 'error'}`}>
{t.installed ? 'Installed' : 'Missing'}
</span>
<span className="tool-name">{name}</span>
{ver && <span className="tool-version">{ver}</span>}
</div>
)
})}
</div>
</div>
</div> </div>
<div style={{ overflow: 'auto', display: 'flex', flexDirection: 'column', gap: 20 }}> <div className="dashboard-content">
<div className="card"> {activeSection === 'tools' && (
<div className="card-header">Quick Actions</div> <div className="dashboard-tools">
<div className="actions-stack"> {tools.length === 0 ? (
<button onClick={handleInstall} disabled={installing || missing.length === 0}> <div className="empty-state">{t('dashboard.noUpdateData')}</div>
{installing && <span className="spinner" />} ) : (
Install missing ({missing.length}) <div className="tools-compact">
</button> {tools.map((tool, i) => {
<button onClick={handleCheckUpdates}>Check for updates</button> const name = tool.name || tool.Name
<button onClick={handleScan}>Rescan system</button> const ver = extractVersion(tool.Version || tool.version)
<button onClick={() => api.configureMCP().then(() => addLog('MCP configured.', 'ok'))}> return (
Configure MCP <div key={i} className="tool-compact-row">
</button> <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>
</div> )}
<div className="card" style={{ flex: 1 }}> {activeSection === 'notifications' && (
<div className="card-header">Updates</div> <div className="dashboard-notifications">
{updates.length === 0 ? ( {notifications.length === 0 ? (
<div className="empty-state">No update data yet.</div> <div className="empty-state">{t('dashboard.noUpdateData')}</div>
) : ( ) : (
updates.map((u, i) => ( notifications.map(n => (
<div key={i} className="tool-row"> <div key={n.id} className={`notif-row notif-${n.type}`}>
<span className={`badge ${u.needsUpdate ? 'warn' : 'ok'}`}> <span className="notif-time">
{u.needsUpdate ? 'Update' : 'Latest'} {n.time.toLocaleTimeString(layout.locale, { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</span>
<span className="tool-name">{u.tool}</span>
{u.needsUpdate && (
<span style={{ color: 'var(--warning)', fontSize: 12, fontFamily: 'var(--font-mono)' }}>
{u.current} &rarr; {u.latest}
</span> </span>
)} <span className="notif-text">{n.text}</span>
</div> </div>
)) ))
)} )}
</div> </div>
)}
{log.length > 0 && ( {activeSection === 'workflows' && (
<div className="card"> <div className="dashboard-workflows">
<div className="card-header">Activity Log</div> <div className="workflow-section">
{log.map(entry => ( <div className="section-label">{t('studio.workflows')}</div>
<div key={entry.id} style={{ <div className="empty-state" style={{ padding: 20 }}>
fontSize: 12, {t('studio.noWorkflow')}
padding: '4px 0',
color: entry.type === 'error' ? 'var(--error)' :
entry.type === 'warn' ? 'var(--warning)' :
entry.type === 'ok' ? 'var(--success)' : 'var(--text-tertiary)',
}}>
{entry.text}
</div> </div>
))} </div>
<div className="workflow-section">
<div className="section-label">{t('studio.activeAgents')}</div>
<div className="empty-state" style={{ padding: 20 }}>
{t('studio.noWorkflow')}
</div>
</div>
</div> </div>
)} )}
</div> </div>

View File

@@ -1,12 +1,14 @@
import { useState, useRef, useEffect } from 'react' import { useState, useRef, useEffect } from 'react'
import { useI18n } from '../i18n'
export default function Shell({ api }) { export default function Shell({ api }) {
const { t } = useI18n()
const [history, setHistory] = useState([]) const [history, setHistory] = useState([])
const [input, setInput] = useState('') const [input, setInput] = useState('')
const [cwd, setCwd] = useState('~') const [cwd, setCwd] = useState('~')
const [showAi, setShowAi] = useState(false) const [showAi, setShowAi] = useState(false)
const [aiMessages, setAiMessages] = useState([ const [aiMessages, setAiMessages] = useState([
{ role: 'ai', content: 'I know your system inside out. Ask me anything.' } { role: 'ai', content: t('shell.aiWelcome') }
]) ])
const [aiInput, setAiInput] = useState('') const [aiInput, setAiInput] = useState('')
const [aiLoading, setAiLoading] = useState(false) const [aiLoading, setAiLoading] = useState(false)
@@ -68,9 +70,9 @@ export default function Shell({ api }) {
try { try {
const res = await api.runCommand(`echo "AI: ${text}"`, '') const res = await api.runCommand(`echo "AI: ${text}"`, '')
setAiMessages(prev => [...prev, { role: 'ai', content: res.output || 'No response' }]) setAiMessages(prev => [...prev, { role: 'ai', content: res.output || t('shell.noResponse') }])
} catch (err) { } catch (err) {
setAiMessages(prev => [...prev, { role: 'ai', content: `Error: ${err.message}` }]) setAiMessages(prev => [...prev, { role: 'ai', content: `${t('shell.error')}: ${err.message}` }])
} }
setAiLoading(false) setAiLoading(false)
} }
@@ -80,11 +82,11 @@ export default function Shell({ api }) {
<div className="terminal" style={{ flex: 1 }}> <div className="terminal" style={{ flex: 1 }}>
<div className="panel-header"> <div className="panel-header">
<span className="panel-title"> <span className="panel-title">
Terminal {t('shell.terminal')}
<span className="panel-subtitle">{cwd}</span> <span className="panel-subtitle">{cwd}</span>
</span> </span>
<button className="ghost sm" onClick={() => setShowAi(!showAi)}> <button className="ghost sm" onClick={() => setShowAi(!showAi)}>
{showAi ? 'Hide AI' : 'AI Assistant'} {showAi ? t('shell.hideAi') : t('shell.aiAssistant')}
</button> </button>
</div> </div>
@@ -110,7 +112,7 @@ export default function Shell({ api }) {
{showAi && ( {showAi && (
<div className="ai-panel"> <div className="ai-panel">
<div className="ai-panel-header">AI Assistant</div> <div className="ai-panel-header">{t('shell.aiAssistant')}</div>
<div className="ai-panel-messages"> <div className="ai-panel-messages">
{aiMessages.map((msg, i) => ( {aiMessages.map((msg, i) => (
<div key={i} className={`ai-message ${msg.role}`}> <div key={i} className={`ai-message ${msg.role}`}>
@@ -124,9 +126,9 @@ export default function Shell({ api }) {
value={aiInput} value={aiInput}
onChange={e => setAiInput(e.target.value)} onChange={e => setAiInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleAiSend()} onKeyDown={e => e.key === 'Enter' && handleAiSend()}
placeholder="Ask AI..." placeholder={t('shell.askAi')}
/> />
<button className="sm" onClick={handleAiSend} disabled={!aiInput.trim()}>Send</button> <button className="sm" onClick={handleAiSend} disabled={!aiInput.trim()}>{t('shell.send')}</button>
</div> </div>
</div> </div>
)} )}

View File

@@ -1,9 +1,11 @@
import { useState, useRef, useEffect } from 'react' import { useState, useRef, useEffect } from 'react'
import { useI18n } from '../i18n'
export default function Studio({ api }) { export default function Studio({ api }) {
const { t, layout } = useI18n()
const [messages, setMessages] = useState([ const [messages, setMessages] = useState([
{ role: 'ai', content: 'Welcome to Studio! Chat with your AI assistant here.' }, { role: 'ai', content: t('studio.welcome') },
{ role: 'ai', content: 'Configure agents and workflows from the sidebar.' }, { role: 'ai', content: t('studio.configureHint') },
]) ])
const [input, setInput] = useState('') const [input, setInput] = useState('')
const [sidebarPanel, setSidebarPanel] = useState('chat') const [sidebarPanel, setSidebarPanel] = useState('chat')
@@ -23,10 +25,10 @@ export default function Studio({ api }) {
api.runCommand(`echo "AI response simulation for: ${text}"`, '') api.runCommand(`echo "AI response simulation for: ${text}"`, '')
.then(res => { .then(res => {
setMessages(prev => [...prev, { role: 'ai', content: res.output || res.error || 'No response' }]) setMessages(prev => [...prev, { role: 'ai', content: res.output || res.error || t('studio.noResponse') }])
}) })
.catch(err => { .catch(err => {
setMessages(prev => [...prev, { role: 'ai', content: `Error: ${err.message}` }]) setMessages(prev => [...prev, { role: 'ai', content: `${t('studio.error')}: ${err.message}` }])
}) })
.finally(() => setLoading(false)) .finally(() => setLoading(false))
} }
@@ -39,9 +41,9 @@ export default function Studio({ api }) {
} }
const sidebarItems = [ const sidebarItems = [
{ id: 'chat', label: 'Chat', icon: '#' }, { id: 'chat', label: t('studio.chat'), icon: '#' },
{ id: 'agents', label: 'Agents', icon: '*' }, { id: 'agents', label: t('studio.agents'), icon: '*' },
{ id: 'workflows', label: 'Workflows', icon: '~' }, { id: 'workflows', label: t('studio.workflows'), icon: '~' },
] ]
return ( return (
@@ -49,7 +51,7 @@ export default function Studio({ api }) {
<div className="chat-layout" style={{ flex: 1, borderRight: '1px solid var(--border)' }}> <div className="chat-layout" style={{ flex: 1, borderRight: '1px solid var(--border)' }}>
<div className="panel-header"> <div className="panel-header">
<span className="panel-title"> <span className="panel-title">
Chat {t('studio.chat')}
{loading && <span className="spinner" />} {loading && <span className="spinner" />}
</span> </span>
</div> </div>
@@ -68,11 +70,11 @@ export default function Studio({ api }) {
value={input} value={input}
onChange={e => setInput(e.target.value)} onChange={e => setInput(e.target.value)}
onKeyDown={handleKeyDown} onKeyDown={handleKeyDown}
placeholder="Type a message... (Enter to send)" placeholder={t('studio.placeholder')}
disabled={loading} disabled={loading}
/> />
<button className="primary" onClick={handleSend} disabled={loading || !input.trim()}> <button className="primary" onClick={handleSend} disabled={loading || !input.trim()}>
Send {t('studio.send')}
</button> </button>
</div> </div>
</div> </div>
@@ -93,42 +95,42 @@ export default function Studio({ api }) {
{sidebarPanel === 'chat' && ( {sidebarPanel === 'chat' && (
<div> <div>
<div className="section-title">Commands</div> <div className="section-title">{t('studio.commands')}</div>
<div style={{ fontSize: 12, fontFamily: 'var(--font-mono)', color: 'var(--text-tertiary)' }}> <div style={{ fontSize: 12, fontFamily: 'var(--font-mono)', color: 'var(--text-tertiary)' }}>
/plan &lt;goal&gt;<br /> {t('studio.planGoal')}<br />
/help {t('studio.help')}
</div> </div>
</div> </div>
)} )}
{sidebarPanel === 'agents' && ( {sidebarPanel === 'agents' && (
<div> <div>
<div className="section-title">Active Agents</div> <div className="section-title">{t('studio.activeAgents')}</div>
<div className="agent-card"> <div className="agent-card">
<div className="agent-avatar">C</div> <div className="agent-avatar">C</div>
<div> <div>
<div className="agent-name">Crush</div> <div className="agent-name">{t('studio.crush')}</div>
<div className="agent-status">Stopped</div> <div className="agent-status">{t('studio.stopped')}</div>
</div> </div>
<span className="badge neutral" style={{ marginLeft: 'auto' }}>Inactive</span> <span className="badge neutral" style={{ marginLeft: 'auto' }}>{t('studio.inactive')}</span>
</div> </div>
<div className="agent-card"> <div className="agent-card">
<div className="agent-avatar">CC</div> <div className="agent-avatar">CC</div>
<div> <div>
<div className="agent-name">Claude Code</div> <div className="agent-name">{t('studio.claudeCode')}</div>
<div className="agent-status">Stopped</div> <div className="agent-status">{t('studio.stopped')}</div>
</div> </div>
<span className="badge neutral" style={{ marginLeft: 'auto' }}>Inactive</span> <span className="badge neutral" style={{ marginLeft: 'auto' }}>{t('studio.inactive')}</span>
</div> </div>
</div> </div>
)} )}
{sidebarPanel === 'workflows' && ( {sidebarPanel === 'workflows' && (
<div> <div>
<div className="section-title">Workflows</div> <div className="section-title">{t('studio.workflows')}</div>
<div className="empty-state"> <div className="empty-state">
No active workflow. {t('studio.noWorkflow')}
<span style={{ fontFamily: 'var(--font-mono)' }}>Use /plan &lt;goal&gt; in chat to start.</span> <span style={{ fontFamily: 'var(--font-mono)' }}>{t('studio.usePlan')}</span>
</div> </div>
</div> </div>
)} )}

105
web/src/i18n/en.js Normal file
View File

@@ -0,0 +1,105 @@
const en = {
tabs: {
dashboard: 'Dashboard',
studio: 'Studio',
shell: 'Shell',
config: 'Config',
},
header: {
toolsInstalled: '{count} tools installed',
updatesAvailable: 'Updates available',
upToDate: 'Up to date',
},
statusbar: {
switchWindow: 'Switch window',
sendMessage: 'Send message',
newLine: 'New line',
runCommand: 'Run command',
commandHistory: 'Command history',
},
dashboard: {
systemOverview: 'System Overview',
tools: 'tools',
installed: 'Installed',
missing: 'Missing',
quickActions: 'Quick Actions',
installMissing: 'Install missing',
checkUpdates: 'Check for updates',
rescanSystem: 'Rescan system',
configureMCP: 'Configure MCP',
updates: 'Updates',
update: 'Update',
latest: 'Latest',
activityLog: 'Activity Log',
noUpdateData: 'No update data yet.',
installing: 'Installing {count} tools...',
installStarted: 'Install started. Rescanning...',
done: 'Done.',
scanComplete: 'Scan complete.',
updatesCount: '{count} updates available.',
allUpToDate: 'All tools up to date.',
mcpConfigured: 'MCP configured.',
},
studio: {
welcome: 'Welcome to Studio! Chat with your AI assistant here.',
configureHint: 'Configure agents and workflows from the sidebar.',
chat: 'Chat',
agents: 'Agents',
workflows: 'Workflows',
placeholder: 'Type a message... (Enter to send)',
send: 'Send',
commands: 'Commands',
planGoal: '/plan <goal>',
help: '/help',
activeAgents: 'Active Agents',
crush: 'Crush',
claudeCode: 'Claude Code',
stopped: 'Stopped',
inactive: 'Inactive',
noWorkflow: 'No active workflow.',
usePlan: 'Use /plan <goal> in chat to start.',
noResponse: 'No response',
error: 'Error',
},
shell: {
terminal: 'Terminal',
hideAi: 'Hide AI',
aiAssistant: 'AI Assistant',
aiWelcome: 'I know your system inside out. Ask me anything.',
askAi: 'Ask AI...',
send: 'Send',
noResponse: 'No response',
error: 'Error',
},
config: {
profile: 'Profile',
name: 'Name',
pseudo: 'Pseudo',
email: 'Email',
editor: 'Editor',
shell: 'Shell',
defaultAi: 'Default AI',
languages: 'Languages',
loadingProfile: 'Loading profile...',
notSet: 'Not set',
aiProviders: 'AI Providers',
active: 'Active',
keyConfigured: 'Key configured',
noKey: 'No key',
theme: 'Theme',
skills: 'Skills',
noSkills: 'No skills installed.',
runSkillsInit: 'Run muyue skills init',
language: 'Language',
keyboardLayout: 'Keyboard Layout',
target: 'Target',
},
}
export default en

105
web/src/i18n/fr.js Normal file
View File

@@ -0,0 +1,105 @@
const fr = {
tabs: {
dashboard: 'Tableau de bord',
studio: 'Studio',
shell: 'Terminal',
config: 'Configuration',
},
header: {
toolsInstalled: '{count} outils install\u00e9s',
updatesAvailable: 'Mises \u00e0 jour disponibles',
upToDate: '\u00c0 jour',
},
statusbar: {
switchWindow: 'Changer de fen\u00eatre',
sendMessage: 'Envoyer le message',
newLine: 'Nouvelle ligne',
runCommand: 'Ex\u00e9cuter',
commandHistory: 'Historique',
},
dashboard: {
systemOverview: 'Vue d\u2019ensemble du syst\u00e8me',
tools: 'outils',
installed: 'Install\u00e9',
missing: 'Manquant',
quickActions: 'Actions rapides',
installMissing: 'Installer les manquants',
checkUpdates: 'V\u00e9rifier les mises \u00e0 jour',
rescanSystem: 'Rescanner le syst\u00e8me',
configureMCP: 'Configurer MCP',
updates: 'Mises \u00e0 jour',
update: 'Mise \u00e0 jour',
latest: '\u00c0 jour',
activityLog: 'Journal d\u2019activit\u00e9',
noUpdateData: 'Aucune donn\u00e9e de mise \u00e0 jour.',
installing: 'Installation de {count} outils...',
installStarted: 'Installation lanc\u00e9e. Rescan en cours...',
done: 'Termin\u00e9.',
scanComplete: 'Scan termin\u00e9.',
updatesCount: '{count} mises \u00e0 jour disponibles.',
allUpToDate: 'Tous les outils sont \u00e0 jour.',
mcpConfigured: 'MCP configur\u00e9.',
},
studio: {
welcome: 'Bienvenue dans Studio ! Discutez avec votre assistant IA ici.',
configureHint: 'Configurez les agents et workflows depuis la barre lat\u00e9rale.',
chat: 'Chat',
agents: 'Agents',
workflows: 'Workflows',
placeholder: 'Tapez un message... (Entr\u00e9e pour envoyer)',
send: 'Envoyer',
commands: 'Commandes',
planGoal: '/plan <objectif>',
help: '/help',
activeAgents: 'Agents actifs',
crush: 'Crush',
claudeCode: 'Claude Code',
stopped: 'Arr\u00eat\u00e9',
inactive: 'Inactif',
noWorkflow: 'Aucun workflow actif.',
usePlan: 'Utilisez /plan <objectif> dans le chat pour d\u00e9marrer.',
noResponse: 'Pas de r\u00e9ponse',
error: 'Erreur',
},
shell: {
terminal: 'Terminal',
hideAi: 'Masquer IA',
aiAssistant: 'Assistant IA',
aiWelcome: 'Je connais votre syst\u00e8me sur le bout des doigts. Demandez-moi n\u2019importe quoi.',
askAi: 'Demander \u00e0 l\u2019IA...',
send: 'Envoyer',
noResponse: 'Pas de r\u00e9ponse',
error: 'Erreur',
},
config: {
profile: 'Profil',
name: 'Nom',
pseudo: 'Pseudo',
email: 'Email',
editor: '\u00c9diteur',
shell: 'Shell',
defaultAi: 'IA par d\u00e9faut',
languages: 'Langages',
loadingProfile: 'Chargement du profil...',
notSet: 'Non d\u00e9fini',
aiProviders: 'Fournisseurs IA',
active: 'Actif',
keyConfigured: 'Cl\u00e9 configur\u00e9e',
noKey: 'Pas de cl\u00e9',
theme: 'Th\u00e8me',
skills: 'Comp\u00e9tences',
noSkills: 'Aucune comp\u00e9tence install\u00e9e.',
runSkillsInit: 'Ex\u00e9cutez muyue skills init',
language: 'Langue',
keyboardLayout: 'Disposition du clavier',
target: 'Cible',
},
}
export default fr

101
web/src/i18n/index.jsx Normal file
View File

@@ -0,0 +1,101 @@
import { createContext, useContext, useState, useCallback, useEffect, useMemo, useRef } from 'react'
import en from './en'
import fr from './fr'
import { getLayout, getLayoutList } from './keyboards'
import api from '../api/client'
const translations = { en, fr }
const STORAGE_KEY_LANG = 'muyue-language'
const STORAGE_KEY_KBD = 'muyue-keyboard'
const I18nContext = createContext(null)
function resolveLocale(layout) {
const l = getLayout(layout)
return l.locale
}
export function I18nProvider({ children }) {
const [language, setLanguageState] = useState(() => localStorage.getItem(STORAGE_KEY_LANG) || 'fr')
const [keyboard, setKeyboardState] = useState(() => localStorage.getItem(STORAGE_KEY_KBD) || 'azerty')
const [loaded, setLoaded] = useState(false)
const pendingSave = useRef(null)
useEffect(() => {
api.getConfig()
.then(d => {
const prefs = d.profile?.preferences
if (prefs?.language) setLanguageState(prefs.language)
if (prefs?.keyboard_layout) setKeyboardState(prefs.keyboard_layout)
})
.catch(() => {})
.finally(() => setLoaded(true))
}, [])
useEffect(() => {
if (!loaded) return
if (pendingSave.current) clearTimeout(pendingSave.current)
pendingSave.current = setTimeout(() => {
api.savePreferences({ language, keyboard_layout: keyboard }).catch(() => {})
}, 500)
return () => { if (pendingSave.current) clearTimeout(pendingSave.current) }
}, [language, keyboard, loaded])
const setLanguage = useCallback((lang) => {
setLanguageState(lang)
localStorage.setItem(STORAGE_KEY_LANG, lang)
}, [])
const setKeyboard = useCallback((kbd) => {
setKeyboardState(kbd)
localStorage.setItem(STORAGE_KEY_KBD, kbd)
}, [])
const layout = useMemo(() => getLayout(keyboard), [keyboard])
const t = useCallback((key, params) => {
const dict = translations[language] || translations.fr
const keys = key.split('.')
let value = dict
for (const k of keys) {
if (value == null) return key
value = value[k]
}
if (typeof value !== 'string') return key
if (params) {
return Object.entries(params).reduce((str, [k, v]) => str.replace(`{${k}}`, v), value)
}
return value
}, [language])
const clockLocale = useMemo(() => resolveLocale(keyboard), [keyboard])
const contextValue = useMemo(() => ({
language,
keyboard,
layout,
setLanguage,
setKeyboard,
t,
clockLocale,
layouts: getLayoutList(),
}), [language, keyboard, layout, t, clockLocale])
return (
<I18nContext.Provider value={contextValue}>
{children}
</I18nContext.Provider>
)
}
export function useI18n() {
const ctx = useContext(I18nContext)
if (!ctx) throw new Error('useI18n must be used within I18nProvider')
return ctx
}
export const LANGUAGES = [
{ id: 'fr', name: 'Fran\u00e7ais' },
{ id: 'en', name: 'English' },
]

61
web/src/i18n/keyboards.js Normal file
View File

@@ -0,0 +1,61 @@
export const LAYOUTS = {
qwerty: {
id: 'qwerty',
name: 'QWERTY',
locale: 'en-US',
keys: {
tab1: '1',
tab2: '2',
tab3: '3',
tab4: '4',
ctrl: 'Ctrl',
enter: 'Enter',
shift: 'Shift',
up: '\u2191',
down: '\u2193',
range: '1-4',
},
},
azerty: {
id: 'azerty',
name: 'AZERTY',
locale: 'fr-FR',
keys: {
tab1: '&',
tab2: '\u00e9',
tab3: '"',
tab4: "'",
ctrl: 'Ctrl',
enter: 'Entr\u00e9e',
shift: 'Maj',
up: '\u2191',
down: '\u2193',
range: '&-\u00e9-"-\'',
},
},
qwertz: {
id: 'qwertz',
name: 'QWERTZ',
locale: 'de-DE',
keys: {
tab1: '1',
tab2: '2',
tab3: '3',
tab4: '4',
ctrl: 'Strg',
enter: 'Enter',
shift: 'Umschalt',
up: '\u2191',
down: '\u2193',
range: '1-4',
},
},
}
export function getLayout(id) {
return LAYOUTS[id] || LAYOUTS.azerty
}
export function getLayoutList() {
return Object.values(LAYOUTS)
}

View File

@@ -1,10 +1,13 @@
import React from 'react' import React from 'react'
import ReactDOM from 'react-dom/client' import ReactDOM from 'react-dom/client'
import { I18nProvider } from './i18n'
import './styles/global.css' import './styles/global.css'
import App from './components/App' import App from './components/App'
ReactDOM.createRoot(document.getElementById('root')).render( ReactDOM.createRoot(document.getElementById('root')).render(
<React.StrictMode> <React.StrictMode>
<App /> <I18nProvider>
<App />
</I18nProvider>
</React.StrictMode> </React.StrictMode>
) )

View File

@@ -168,6 +168,12 @@ input::placeholder { color: var(--text-disabled); }
color: var(--text-disabled); color: var(--text-disabled);
} }
.statusbar-left, .statusbar-right { display: flex; align-items: center; gap: 12px; } .statusbar-left, .statusbar-right { display: flex; align-items: center; gap: 12px; }
.statusbar-shortcut { display: inline-flex; align-items: center; gap: 4px; }
.statusbar-shortcut kbd {
display: inline-block; padding: 1px 5px; border-radius: 3px;
background: var(--bg-card); border: 1px solid var(--border);
font-family: var(--font-mono); font-size: 10px; color: var(--text-tertiary);
}
.card { .card {
background: var(--bg-card); background: var(--bg-card);
@@ -328,6 +334,60 @@ input::placeholder { color: var(--text-disabled); }
.empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px 20px; color: var(--text-disabled); font-size: 13px; text-align: center; gap: 8px; } .empty-state { display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 40px 20px; color: var(--text-disabled); font-size: 13px; text-align: center; gap: 8px; }
.dashboard-layout { display: flex; flex-direction: column; height: 100%; }
.dashboard-tabs {
display: flex; gap: 0; border-bottom: 1px solid var(--border);
background: var(--bg-surface); flex-shrink: 0;
}
.dashboard-tab {
padding: 10px 24px; font-size: 13px; font-weight: 600;
color: var(--text-tertiary); cursor: pointer; transition: all 0.15s;
display: flex; align-items: center; gap: 8px; border-bottom: 2px solid transparent;
user-select: none;
}
.dashboard-tab:hover { color: var(--text-primary); background: var(--bg-card); }
.dashboard-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.tab-count {
font-size: 10px; padding: 1px 6px; border-radius: 99px;
background: var(--bg-card); color: var(--text-tertiary); font-family: var(--font-mono);
}
.tab-count.warn { background: rgba(255,215,64,0.15); color: var(--warning); }
.dashboard-content { flex: 1; overflow-y: auto; }
.dashboard-tools { padding: 16px 24px; }
.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: 16px 24px; }
.notif-row {
display: flex; align-items: flex-start; gap: 12px;
padding: 8px 12px; border-radius: var(--radius); margin-bottom: 4px;
}
.notif-row:hover { background: var(--bg-card); }
.notif-time { color: var(--text-disabled); font-size: 11px; font-family: var(--font-mono); flex-shrink: 0; padding-top: 1px; }
.notif-text { font-size: 13px; color: var(--text-secondary); }
.notif-info .notif-text { color: var(--info); }
.notif-ok .notif-text { color: var(--success); }
.notif-warn .notif-text { color: var(--warning); }
.notif-error .notif-text { color: var(--error); }
.dashboard-workflows { padding: 16px 24px; display: flex; flex-direction: column; gap: 24px; }
.workflow-section { }
.section-label {
font-size: 11px; font-weight: 700; color: var(--accent); text-transform: uppercase;
letter-spacing: 1px; margin-bottom: 12px; padding-bottom: 6px; border-bottom: 1px solid var(--border);
}
.panel-header { .panel-header {
display: flex; align-items: center; justify-content: space-between; padding: 10px 16px; display: flex; align-items: center; justify-content: space-between; padding: 10px 16px;
border-bottom: 1px solid var(--border); background: var(--bg-surface); border-bottom: 1px solid var(--border); background: var(--bg-surface);