Compare commits

...

3 Commits

Author SHA1 Message Date
Augustin
1074b019d3 feat(studio): Tab focuses textarea, autocomplete commands
All checks were successful
Beta Release / beta (push) Successful in 41s
- Tab outside textarea focuses it
- Tab inside textarea autocompletes / commands (/clear, /summarize, etc.)

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 22:13:02 +02:00
Augustin
2da0cf9421 fix(studio): convert newlines to <br/> in AI message rendering
All checks were successful
Beta Release / beta (push) Successful in 39s
formatText now replaces \n with <br/> so AI responses display
with proper line breaks instead of a single unbroken block.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 22:10:54 +02:00
Augustin
9987a586e2 fix(config): replace hardcoded model list with free text input
All checks were successful
Beta Release / beta (push) Successful in 41s
Removed PROVIDER_MODELS hardcoded map. Model is now a simple text
input pre-filled with the current model value.

💘 Generated with Crush

Assisted-by: GLM-5.1 via Crush <crush@charm.land>
2026-04-23 22:08:41 +02:00
2 changed files with 61 additions and 36 deletions

View File

@@ -331,11 +331,6 @@ function getFieldLabel(key, t) {
return key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())
}
const PROVIDER_MODELS = {
minimax: ['MiniMax-M2.7', 'MiniMax-M1', 'abab6.5s-chat', 'abab6.5-chat', 'abab5.5-chat'],
zai: ['glm', 'glm-4', 'glm-4-plus', 'glm-4-flash', 'glm-3-turbo'],
}
function PanelProviders({ providers, editProvider, providerForm, setProviderForm, setEditProvider, openProviderEdit, handleSaveProvider, api, loadData, t }) {
const [validating, setValidating] = useState(null)
const [validationStatus, setValidationStatus] = useState(null)
@@ -357,14 +352,6 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
setValidating(null)
}
const handleSelectModel = (providerName, model) => {
setProviderForm(prev => ({
...prev,
[providerName]: { ...(prev[providerName] || {}), model },
}))
setEditProvider(providerName)
}
const displayed = providers.filter(p => p.name === 'minimax' || p.name === 'zai')
return (
@@ -372,8 +359,7 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
{displayed.map((p, i) => {
const isEditing = editProvider === p.name
const isValidationTarget = validationStatus?.provider === p.name
const models = PROVIDER_MODELS[p.name] || []
const selectedModel = providerForm[p.name]?.model || p.model
const currentModel = providerForm[p.name]?.model || p.model
return (
<div key={i} className="config-card provider-card-v2">
@@ -408,7 +394,7 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
<button
className="sm primary"
disabled={validating === p.name || !providerForm[p.name]?.api_key}
onClick={() => handleValidate(p.name, providerForm[p.name]?.api_key, selectedModel, providerForm[p.name]?.base_url)}
onClick={() => handleValidate(p.name, providerForm[p.name]?.api_key, currentModel, providerForm[p.name]?.base_url)}
>
{validating === p.name ? t('config.validating') : t('config.validateKey')}
</button>
@@ -417,22 +403,21 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
)}
</div>
</div>
{models.length > 0 && (
<div style={{ marginTop: 10 }}>
<span className="config-form-label">Modèle</span>
<div className="chip-row" style={{ marginTop: 4 }}>
{models.map(m => (
<div
key={m}
className={`chip ${selectedModel === m ? 'active' : ''}`}
onClick={() => handleSelectModel(p.name, m)}
>
{m}
</div>
))}
</div>
</div>
)}
<div className="provider-card-meta" style={{ marginTop: 8 }}>
<span className="config-form-label">{t('config.model')}</span>
<input
className="config-form-input"
value={currentModel || ''}
onChange={e => {
setProviderForm(prev => ({
...prev,
[p.name]: { ...(prev[p.name] || {}), model: e.target.value },
}))
setEditProvider(p.name)
}}
placeholder="model-name"
/>
</div>
</div>
</div>
)

View File

@@ -53,11 +53,9 @@ function renderContent(text) {
}
function formatText(text) {
// First escape HTML entities
let html = text
.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
// Apply markdown transformations (now with escaped brackets)
html = html
.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>')
.replace(/`([^`]+)`/g, '<code class="inline-code">$1</code>')
@@ -66,10 +64,10 @@ function formatText(text) {
.replace(/^# (.+)$/gm, '<h2 class="msg-h2">$1</h2>')
.replace(/^\s*[-*] (.+)$/gm, '<div class="msg-bullet">• $1</div>')
.replace(/^\s*(\d+)[.)] (.+)$/gm, '<div class="msg-step"><span class="msg-step-num">$1</span> $2</div>')
.replace(/\n/g, '<br/>')
// Sanitize: remove event handlers and dangerous protocols
html = html
.replace(/\s+on\w+=["'][^"']*["']/gi, '') // Remove on* event handlers
.replace(/\s+on\w+=["'][^"']*["']/gi, '')
.replace(/javascript:/gi, '')
.replace(/data:/gi, '')
@@ -331,6 +329,20 @@ export default function Studio({ api }) {
messagesEnd.current?.scrollIntoView({ behavior: 'smooth' })
}, [messages, streaming, streamThinking, streamToolCalls])
useEffect(() => {
const onTab = (e) => {
if (e.key !== 'Tab') return
if (document.activeElement?.tagName === 'INPUT' || document.activeElement?.tagName === 'TEXTAREA') return
const feed = document.querySelector('.studio-feed-layout')
if (!feed?.closest('.tab-hidden')) {
e.preventDefault()
textareaRef.current?.focus()
}
}
window.addEventListener('keydown', onTab)
return () => window.removeEventListener('keydown', onTab)
}, [])
useEffect(() => {
if (textareaRef.current) {
textareaRef.current.style.height = 'auto'
@@ -539,10 +551,38 @@ export default function Studio({ api }) {
}
}, [])
const COMMANDS = ['/clear', '/summarize', '/help', '/plan', '/export', '/model']
const handleKeyDown = (e) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault()
handleSend()
return
}
if (e.key === 'Tab') {
e.preventDefault()
const ta = textareaRef.current
if (!ta) return
if (document.activeElement !== ta) {
ta.focus()
return
}
const val = ta.value
const pos = ta.selectionStart
const before = val.slice(0, pos)
const afterSlash = before.match(/\/(\w*)$/)
if (afterSlash) {
const partial = afterSlash[0]
const matches = COMMANDS.filter(c => c.startsWith(partial) && c !== partial)
if (matches.length === 1) {
const completed = matches[0] + ' '
const newText = val.slice(0, pos - afterSlash[0].length) + completed + val.slice(pos)
setInput(newText)
requestAnimationFrame(() => {
ta.selectionStart = ta.selectionEnd = pos - afterSlash[0].length + completed.length
})
}
}
}
}