Compare commits

...

12 Commits

Author SHA1 Message Date
Augustin
3cf701b002 fix(terminal): improve tab visibility checks and positioning
All checks were successful
Beta Release / beta (push) Successful in 48s
- Add null check for container before accessing offsetHeight
- Validate activeTabRef during initialization and fit operations
- Check for display:none as visibility indicator
- Simplify useEffect dependency array
- Use absolute positioning for terminal wrapper/instance

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 17:59:48 +02:00
Augustin
3a09e0e0c2 fix(ui): adjust global CSS styles
All checks were successful
Beta Release / beta (push) Successful in 45s
💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 17:38:21 +02:00
Augustin
47fa2e01bb fix(terminal): use display:none instead of visibility for tab hiding
All checks were successful
Beta Release / beta (push) Successful in 49s
Replace visibility-based hiding with display property for reliable tab
detection. Use offsetParent and offsetHeight checks instead of style
properties to properly detect hidden terminals.

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 17:23:54 +02:00
Augustin
401292ec5b feat(ui): refactor copy state to Set and add helper functions
All checks were successful
Beta Release / beta (push) Successful in 46s
- Change copiedIdx (number) to copiedSet (Set) for tracking multiple copied items
- Add copyCmd function to handle clipboard and timeout cleanup
- Add relativeTime function for displaying relative timestamps

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 17:04:38 +02:00
Augustin
199a7e409a feat(ui): add recentUnique to deduplicate recent commands in Dashboard
All checks were successful
Beta Release / beta (push) Successful in 47s
💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 17:01:08 +02:00
Augustin
c91931f42f feat(ui): redesign recent commands display and fix terminal visibility
All checks were successful
Beta Release / beta (push) Successful in 44s
- Dashboard: add frequency bars for top commands, click-to-copy, time display
- Shell: switch from display:none to visibility:hidden for terminal containers
- CSS: restyle command list with improved hover states and copy indicators

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 16:53:59 +02:00
Augustin
cbbb224725 fix(shell): initialize activeTabRef with activeTab and move useEffect
All checks were successful
Beta Release / beta (push) Successful in 45s
Reorder code to follow React hooks rules - initialize ref with value
instead of null, then update via useEffect.

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 16:44:02 +02:00
Augustin
8d10d2182e fix(config): remove unused import, reorder hooks, and improve variable naming
All checks were successful
Beta Release / beta (push) Successful in 42s
Reorder validateKey function and useEffect to avoid referencing before definition.
Rename loop variable from 't' to 'tool' for clarity.

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 16:33:09 +02:00
Augustin
e9696ef82b fix(studio): add tool results serialization and improve message handling
All checks were successful
Beta Release / beta (push) Successful in 43s
- Add tool_results array to AI message content with tool_call_id, result, and is_error
- Convert cleanContent to let for potential reuse
- Reset accumulated and streaming state on tool_call events

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 16:22:54 +02:00
Augustin
1edd4f053a fix(shell): improve tab reference stability and command queueing
All checks were successful
Beta Release / beta (push) Successful in 47s
Add refs to track activeTab and pending commands outside render cycle.
Flush queued commands after terminal initialization completes.
Fix sendToTerminal to use stable refs instead of stale state.
Enhance debug logging for tab operations.

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 16:10:54 +02:00
Augustin
92f943c3e6 fix(shell): add debug logging for tab tracking and WebSocket state
All checks were successful
Beta Release / beta (push) Successful in 46s
Track which tab messages belong to via _tabId field to ensure AI
responses are sent to the correct terminal tab. Add console.log in
initTerminal, sendToTerminal for troubleshooting tab lifecycle issues.

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 15:53:13 +02:00
Augustin
1704b196cf fix(terminal): refactor WebSocket cleanup, buffer management, and disposal
All checks were successful
Beta Release / beta (push) Successful in 52s
- Add proper disposal tracking to prevent memory leaks
- Move terminal buffer from localStorage to sessionStorage
- Restore buffer immediately after first WS message
- Fix clear detection logic and error handling
- Add signal parameter support for abortable fetch requests

💘 Generated with Crush

Assisted-by: MiniMax-M2.7 via Crush <crush@charm.land>
2026-04-24 15:41:01 +02:00
7 changed files with 228 additions and 128 deletions

View File

@@ -165,8 +165,6 @@ func (s *Server) handleTerminalWS(w http.ResponseWriter, r *http.Request) {
n, err := ptmx.Read(buf)
if err != nil {
cleanup()
conn.WriteMessage(websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
return
}
if err := conn.WriteJSON(wsMessage{

View File

@@ -105,7 +105,7 @@ const api = {
}).catch(reject)
})
},
sendShellChat: (message, context = {}, stream = true, onChunk) => {
sendShellChat: (message, context = {}, stream = true, onChunk, signal) => {
const payload = {
message,
cwd: context.cwd || '',
@@ -120,6 +120,7 @@ const api = {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
signal,
}).then(async (res) => {
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }))

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react'
import { User, Brain, RefreshCw, Wrench, Monitor, AlertTriangle, X } from 'lucide-react'
import { User, Brain, RefreshCw, Wrench, Monitor, AlertTriangle } from 'lucide-react'
import { useI18n } from '../i18n'
const PANELS = [
@@ -311,16 +311,6 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
const [validating, setValidating] = useState(null)
const [keyStatus, setKeyStatus] = useState({})
useEffect(() => {
providers.forEach(p => {
if (p.apiKey && !keyStatus[p.name]) {
validateKey(p)
} else if (!p.apiKey) {
setKeyStatus(prev => ({ ...prev, [p.name]: { valid: false, checked: true, error: 'Aucune clé' } }))
}
})
}, [providers])
const validateKey = async (p) => {
setValidating(p.name)
try {
@@ -332,6 +322,16 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
setValidating(null)
}
useEffect(() => {
providers.forEach(p => {
if (p.apiKey && !keyStatus[p.name]) {
validateKey(p)
} else if (!p.apiKey) {
setKeyStatus(prev => ({ ...prev, [p.name]: { valid: false, checked: true, error: 'Aucune clé' } }))
}
})
}, [providers])
const handleValidate = async (name, apiKey, model, baseUrl) => {
setValidating(name)
try {
@@ -412,7 +412,7 @@ function PanelUpdates({ updates, tools, checking, updating, needsUpdateCount, in
window.dispatchEvent(new CustomEvent('ask-ai-terminal', { detail: { message: `Installe l'outil ${tool} sur mon système. Vérifie d'abord s'il est déjà installé, puis installe-le si nécessaire avec les commandes appropriées.` } }))
}
const missingTools = tools.filter(t => !t.installed)
const missingTools = tools.filter(tool => !tool.installed)
return (
<>

View File

@@ -43,7 +43,7 @@ export default function Dashboard({ api, refreshRef }) {
const [recentCmds, setRecentCmds] = useState([])
const [processes, setProcesses] = useState([])
const [metrics, setMetrics] = useState(null)
const [copiedIdx, setCopiedIdx] = useState(-1)
const [copiedSet, setCopiedSet] = useState(new Set())
const cpuRef = useRef([])
const memRef = useRef([])
const netRxRef = useRef([])
@@ -109,6 +109,32 @@ export default function Dashboard({ api, refreshRef }) {
.map(([cmd, count]) => ({ cmd, count }))
})()
const maxCount = topCmds.length > 0 ? topCmds[0].count : 1
const copyCmd = (cmd, key) => {
navigator.clipboard.writeText(cmd)
setCopiedSet(prev => new Set(prev).add(key))
setTimeout(() => setCopiedSet(prev => { const next = new Set(prev); next.delete(key); return next }), 1500)
}
const relativeTime = (ts) => {
if (!ts) return ''
const diff = Math.floor((Date.now() - new Date(ts).getTime()) / 1000)
if (diff < 60) return `${diff}s`
if (diff < 3600) return `${Math.floor(diff / 60)}m`
if (diff < 86400) return `${Math.floor(diff / 3600)}h`
return `${Math.floor(diff / 86400)}d`
}
const recentUnique = (() => {
const seen = new Set()
return recentCmds.filter(c => {
if (seen.has(c.cmd)) return false
seen.add(c.cmd)
return true
})
})()
return (
<div className="dash-grid">
{/* CPU */}
@@ -197,26 +223,34 @@ export default function Dashboard({ api, refreshRef }) {
</div>
{/* Recent Commands */}
<div className="dash-card">
<div className="dash-card dash-cmd-card">
<div className="dash-card-head">
<span className="dash-label">Recent Commands</span>
<span className="dash-count">{recentUnique.length}</span>
</div>
{topCmds.length > 0 && (
<div className="dash-cmd-top">
<div className="dash-cmd-freq">
<span className="dash-cmd-freq-title">Most used</span>
{topCmds.map((c, i) => (
<div key={i} className={'dash-cmd-chip' + (copiedIdx === i ? ' dash-cmd-chip-copied' : '')} onClick={() => { navigator.clipboard.writeText(c.cmd); setCopiedIdx(i); setTimeout(() => setCopiedIdx(-1), 1200); }}>
<span className="dash-cmd-chip-name">{copiedIdx === i ? '✓ Copié' : c.cmd}</span>
<span className="dash-cmd-chip-count">{c.count}×</span>
<div key={i} className="dash-cmd-freq-row" onClick={() => copyCmd(c.cmd, `top-${i}`)} title={c.cmd}>
<span className="dash-cmd-freq-name">{copiedSet.has(`top-${i}`) ? '✓ Copié' : c.cmd}</span>
<div className="dash-cmd-freq-bar-wrap">
<div className="dash-cmd-freq-bar" style={{ width: `${(c.count / maxCount) * 100}%` }} />
</div>
<span className="dash-cmd-freq-count">{c.count}×</span>
</div>
))}
</div>
)}
<div className="dash-cmd-list">
{recentCmds.length === 0 && <span className="dash-empty">No history</span>}
{recentCmds.map((c, i) => (
<div key={i} className="dash-cmd-row" title={c.cmd}>
<span className="dash-cmd-shell">{c.shell}</span>
<span className="dash-cmd-text">{c.cmd.length > 45 ? c.cmd.slice(0, 42) + '...' : c.cmd}</span>
{recentUnique.length === 0 && <span className="dash-empty">No history</span>}
{recentUnique.map((c, i) => (
<div key={i} className="dash-cmd-row" onClick={() => copyCmd(c.cmd, `list-${i}`)} title={c.cmd + ' · click to copy'}>
<div className="dash-cmd-left">
<span className="dash-cmd-text">{c.cmd.length > 38 ? c.cmd.slice(0, 35) + '...' : c.cmd}</span>
<span className="dash-cmd-time">{relativeTime(c.ts)}</span>
</div>
<span className="dash-cmd-copy">{copiedSet.has(`list-${i}`) ? '✓' : '⎘'}</span>
</div>
))}
</div>

View File

@@ -149,7 +149,7 @@ function createTerminal(container, settings = {}) {
return { term, fitAddon }
}
function connectWebSocket(term, fitAddon, initPayload, onStateChange) {
function connectWebSocket(term, fitAddon, initPayload, onStateChange, onFirstMessage) {
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const ws = new WebSocket(`${proto}//${window.location.host}/api/ws/terminal`)
@@ -162,7 +162,12 @@ function connectWebSocket(term, fitAddon, initPayload, onStateChange) {
if (onStateChange) onStateChange(true)
})
let firstMessage = true
ws.addEventListener('message', (event) => {
if (firstMessage) {
firstMessage = false
if (onFirstMessage) onFirstMessage()
}
try {
const msg = JSON.parse(event.data)
if (msg.type === 'output') {
@@ -185,12 +190,6 @@ function connectWebSocket(term, fitAddon, initPayload, onStateChange) {
if (onStateChange) onStateChange(false)
})
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'input', data }))
}
})
term.onResize(({ rows, cols }) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'resize', rows, cols }))
@@ -205,6 +204,7 @@ export default function Shell({ api }) {
const tabsRef = useRef({})
const nextIdRef = useRef(1)
const settingsRef = useRef({ fontSize: 12, fontFamily: "'JetBrains Mono', 'Fira Code', monospace", theme: 'default' })
const pendingCommandsRef = useRef({})
const savedTabs = (() => {
try {
@@ -228,6 +228,8 @@ export default function Shell({ api }) {
}
return 1
})
const activeTabRef = useRef(activeTab)
useEffect(() => { activeTabRef.current = activeTab }, [activeTab])
const [sshConnections, setSshConnections] = useState([])
const [systemTerminals, setSystemTerminals] = useState([])
const [showMenu, setShowMenu] = useState(false)
@@ -256,6 +258,7 @@ export default function Shell({ api }) {
const [analysisContent, setAnalysisContent] = useState('')
const aiMessagesRef = useRef(null)
const aiLoadedRef = useRef(false)
const aiLoadingRef = useRef(false)
useEffect(() => {
aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight)
@@ -338,23 +341,7 @@ export default function Shell({ api }) {
}
}
const onWsState = (connected) => {
if (!connected) saveBuffer()
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected } : t))
}
const ws = connectWebSocket(term, fitAddon, initPayload, onWsState)
// Restore saved terminal buffer after first output settles
const restoreBuffer = () => {
try {
const savedBuffers = JSON.parse(localStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
if (savedBuffers[tabId]) {
term.write('\x1b[90m— session restaurée —\x1b[0m\r\n')
term.write(savedBuffers[tabId])
}
} catch {}
}
setTimeout(restoreBuffer, 300)
let disposed = false
const saveBuffer = () => {
try {
@@ -364,39 +351,56 @@ export default function Shell({ api }) {
const line = buf.getLine(i)
if (line) lines.push(line.translateToString(true))
}
const savedBuffers = JSON.parse(localStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
const savedBuffers = JSON.parse(sessionStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
savedBuffers[tabId] = lines.join('\n')
localStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers))
} catch {}
sessionStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers))
} catch (e) { console.warn('[Shell] Buffer save failed:', e) }
}
const bufferSaveInterval = setInterval(saveBuffer, 5000)
const onWsState = (connected) => {
if (disposed) return
if (!connected) saveBuffer()
setTabs(prev => prev.map(t => t.id === tabId ? { ...t, connected } : t))
}
const restoreBuffer = () => {
try {
const savedBuffers = JSON.parse(sessionStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
if (savedBuffers[tabId]) {
term.write('\x1b[90m— session restaurée —\x1b[0m\r\n')
term.write(savedBuffers[tabId])
}
} catch (e) { console.warn('[Shell] Buffer restore failed:', e) }
}
const ws = connectWebSocket(term, fitAddon, initPayload, onWsState, restoreBuffer)
const clearBufferOnClear = () => {
try {
const buf = term.buffer.active
const lineY = buf.viewportY + buf.cursorY
const lineY = buf.length - 1
const line = buf.getLine(lineY)
if (line) {
const text = line.translateToString(true).trim().toLowerCase()
if (text === 'clear' || text === '$ clear' || text.endsWith(' clear')) {
const savedBuffers = JSON.parse(localStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
const savedBuffers = JSON.parse(sessionStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
delete savedBuffers[tabId]
localStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers))
sessionStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers))
}
}
} catch {}
} catch (e) { console.warn('[Shell] Clear detection failed:', e) }
}
term.onData((data) => {
if (data === '\r') {
clearBufferOnClear()
if (data === '\r') clearBufferOnClear()
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'input', data }))
}
})
const onResize = () => {
const el = document.getElementById(`terminal-${tabId}`)
if (el && el.offsetParent !== null) {
if (el && el.style.display !== 'none') {
fitAddon.fit()
}
}
@@ -405,36 +409,66 @@ export default function Shell({ api }) {
resizeObserver.observe(container)
window.addEventListener('resize', onResize)
tabsRef.current[tabId] = { term, fitAddon, ws, resizeObserver, onResize, bufferSaveInterval, saveBuffer }
const bufferSaveInterval = setInterval(() => { if (!disposed) saveBuffer() }, 5000)
console.log(`[Shell] initTerminal tab=${tabId} type=${tab.type} name="${tab.name}" shell="${tab.shell || '(default)'}"`)
tabsRef.current[tabId] = { term, fitAddon, ws, resizeObserver, onResize, bufferSaveInterval, saveBuffer, disposed: () => disposed }
tabsRef.current[tabId]._markDisposed = () => { disposed = true }
console.log(`[Shell] initTerminal tab=${tabId} done, tabsRef keys:`, Object.keys(tabsRef.current))
const pending = pendingCommandsRef.current[tabId]
if (pending && pending.length > 0) {
console.log(`[Shell] Flushing ${pending.length} pending commands for tab ${tabId}`)
for (const cmd of pending) {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'input', data: cmd + '\r' }))
}
}
delete pendingCommandsRef.current[tabId]
}
}, [])
useEffect(() => {
const tab = tabs.find(t => t.id === activeTab)
if (!tab) return
let cancelled = false
const pending = []
const tryInit = (attempt) => {
if (attempt > 20) return
if (cancelled || attempt > 20) return
const shellCol = document.querySelector('.shell-terminal-col')
if (!shellCol || shellCol.offsetParent === null) {
setTimeout(() => tryInit(attempt + 1), 150)
pending.push(setTimeout(() => tryInit(attempt + 1), 150))
return
}
const container = document.getElementById(`terminal-${tab.id}`)
if (!container || container.offsetHeight === 0) {
setTimeout(() => tryInit(attempt + 1), 100)
if (!container) {
pending.push(setTimeout(() => tryInit(attempt + 1), 100))
return
}
if (activeTabRef.current !== tab.id) return
if (container.offsetHeight === 0 || container.style.display === 'none') {
pending.push(setTimeout(() => tryInit(attempt + 1), 100))
return
}
if (!tabsRef.current[tab.id]) {
initTerminal(tab.id, tab)
}
requestAnimationFrame(() => {
if (cancelled) return
if (activeTabRef.current !== tab.id) return
const entry = tabsRef.current[tab.id]
if (entry) entry.fitAddon.fit()
})
}
tryInit(0)
}, [activeTab, tabs, initTerminal])
return () => {
cancelled = true
pending.forEach(clearTimeout)
}
}, [activeTab, initTerminal])
useEffect(() => {
const iv = setInterval(() => {
@@ -442,7 +476,7 @@ export default function Shell({ api }) {
const entry = tabsRef.current[tab.id]
if (entry) {
const el = document.getElementById(`terminal-${tab.id}`)
if (el && el.offsetParent !== null) {
if (el && el.style.display !== 'none') {
entry.fitAddon.fit()
}
}
@@ -451,6 +485,20 @@ export default function Shell({ api }) {
return () => clearInterval(iv)
}, [tabs])
useEffect(() => {
return () => {
for (const [tabId, entry] of Object.entries(tabsRef.current)) {
entry._markDisposed?.()
if (entry.bufferSaveInterval) clearInterval(entry.bufferSaveInterval)
window.removeEventListener('resize', entry.onResize)
entry.resizeObserver?.disconnect()
entry.ws?.close()
entry.term?.dispose()
}
tabsRef.current = {}
}
}, [])
useEffect(() => {
const onKey = (e) => {
if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return
@@ -506,29 +554,26 @@ export default function Shell({ api }) {
const closeTab = (tabId, e) => {
if (e) e.stopPropagation()
const entry = tabsRef.current[tabId]
if (entry) {
entry._markDisposed?.()
entry.saveBuffer?.()
if (entry.bufferSaveInterval) clearInterval(entry.bufferSaveInterval)
window.removeEventListener('resize', entry.onResize)
entry.resizeObserver.disconnect()
entry.ws.close()
entry.term.dispose()
delete tabsRef.current[tabId]
}
try {
const savedBuffers = JSON.parse(sessionStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
delete savedBuffers[tabId]
sessionStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers))
} catch (e) { console.warn('[Shell] Buffer cleanup failed:', e) }
setTabs(prev => {
if (prev.length <= 1) return prev
const tab = prev.find(t => t.id === tabId)
if (!tab) return prev
const entry = tabsRef.current[tabId]
if (entry) {
entry.saveBuffer?.()
if (entry.bufferSaveInterval) clearInterval(entry.bufferSaveInterval)
window.removeEventListener('resize', entry.onResize)
entry.resizeObserver.disconnect()
entry.ws.close()
entry.term.dispose()
delete tabsRef.current[tabId]
}
// Clean buffer from localStorage
try {
const savedBuffers = JSON.parse(localStorage.getItem(TERMINAL_BUFFER_KEY) || '{}')
delete savedBuffers[tabId]
localStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers))
} catch {}
const next = prev.filter(t => t.id !== tabId)
if (activeTab === tabId && next.length > 0) {
setActiveTab(next[next.length - 1].id)
@@ -574,27 +619,33 @@ export default function Shell({ api }) {
}
const sendToTerminal = useCallback((code, tabId) => {
const targetId = tabId || activeTab
const targetId = tabId || activeTabRef.current
const entry = tabsRef.current[targetId]
if (!entry) {
console.warn('sendToTerminal: no terminal initialized for tab', targetId)
console.warn(`[Shell] sendToTerminal: tab ${targetId} not ready. Queueing. tabsRef:`, Object.keys(tabsRef.current), 'activeTab:', activeTabRef.current, 'requested:', tabId)
if (!pendingCommandsRef.current[targetId]) pendingCommandsRef.current[targetId] = []
pendingCommandsRef.current[targetId].push(code)
return
}
if (!entry.ws || entry.ws.readyState !== WebSocket.OPEN) {
console.warn('sendToTerminal: WebSocket not ready for tab', targetId)
console.warn(`[Shell] sendToTerminal: WS not open for tab ${targetId} (state=${entry.ws?.readyState}). Queueing.`)
if (!pendingCommandsRef.current[targetId]) pendingCommandsRef.current[targetId] = []
pendingCommandsRef.current[targetId].push(code)
return
}
console.log(`[Shell] sendToTerminal: tab ${targetId}${code.length} chars`)
entry.ws.send(JSON.stringify({ type: 'input', data: code + '\r' }))
}, [activeTab])
}, [])
const focusAiTerminal = useCallback(() => {
const entry = tabsRef.current[activeTab]
const entry = tabsRef.current[activeTabRef.current]
if (entry) entry.term.focus()
}, [activeTab])
}, [])
const _sendAiMessage = useCallback(async (text, fromEvent = false) => {
if (!text || !text.trim() || aiLoading || aiAtLimit) return
if (!text || !text.trim() || aiLoadingRef.current || aiAtLimit) return
const trimmed = text.trim()
aiLoadingRef.current = true
if (!fromEvent) {
setAiInput('')
@@ -608,6 +659,7 @@ export default function Shell({ api }) {
setAiTokens(0)
setAiAtLimit(false)
} catch {}
aiLoadingRef.current = false
return
}
@@ -616,10 +668,13 @@ export default function Shell({ api }) {
{ role: 'user', content: trimmed },
{ role: 'assistant', content: 'Commandes disponibles:\n• /clear — Effacer la conversation\n• /help — Afficher l\'aide\n\nJe ne peux pas exécuter de code. Les blocs de code proposés peuvent être copiés ou envoyés directement au terminal actif.' }
])
aiLoadingRef.current = false
return
}
setAiMessages(prev => [...prev, { role: 'user', content: trimmed }])
const currentTab = activeTabRef.current
console.log(`[Shell] _sendAiMessage: activeTab=${currentTab}, fromEvent=${fromEvent}, text="${trimmed.slice(0, 50)}"`)
setAiMessages(prev => [...prev, { role: 'user', content: trimmed, _tabId: currentTab }])
setAiLoading(true)
try {
@@ -628,26 +683,27 @@ export default function Shell({ api }) {
accumulated = partial
setAiMessages(prev => {
const filtered = prev.filter(m => !m._streaming)
return [...filtered, { role: 'assistant', content: partial, _streaming: true }]
return [...filtered, { role: 'assistant', content: partial, _streaming: true, _tabId: currentTab }]
})
})
setAiMessages(prev => {
const filtered = prev.filter(m => !m._streaming)
return [...filtered, { role: 'assistant', content: accumulated }]
return [...filtered, { role: 'assistant', content: accumulated, _tabId: currentTab }]
})
api.getShellChatHistory().then(d => {
setAiTokens(d.tokens || 0)
setAiAtLimit(d.at_limit || false)
}).catch(() => {})
} catch (err) {
if (err.message.includes('context limit')) {
if (err.message?.includes('context limit')) {
setAiAtLimit(true)
}
setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'assistant', content: `Erreur: ${err.message}` }])
}
setAiLoading(false)
}, [api, t, aiLoading, aiAtLimit, focusAiTerminal])
aiLoadingRef.current = false
}, [api, t, aiAtLimit, focusAiTerminal])
const handleAiSend = () => _sendAiMessage(aiInput, false)
@@ -830,7 +886,7 @@ export default function Shell({ api }) {
</div>
<div className="ai-panel-messages" ref={aiMessagesRef}>
{aiMessages.map((msg, i) => (
<ShellAIMessage key={i} msg={msg} sendToTerminal={sendToTerminal} terminalTabId={activeTab} />
<ShellAIMessage key={i} msg={msg} sendToTerminal={sendToTerminal} terminalTabId={msg._tabId || activeTab} />
))}
{aiLoading && <div style={{ textAlign: 'center', padding: 8 }}><span className="spinner" /></div>}
</div>

View File

@@ -197,7 +197,7 @@ function FeedItem({ msg }) {
)
}
const cleanContent = displayContent.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
let cleanContent = displayContent.replace(/<think[^>]*>[\s\S]*?<\/think>/gi, '')
return (
<div className={`feed-item ${msg.role}`}>
@@ -532,6 +532,8 @@ export default function Studio({ api }) {
if (event && event.tool_call) {
toolCalls = [...toolCalls, { call: event.tool_call, result: null }]
setStreamToolCalls([...toolCalls])
accumulated = ''
setStreaming('')
return
}
if (event && event.tool_result) {
@@ -558,6 +560,11 @@ export default function Studio({ api }) {
aiMsg.content = JSON.stringify({
content: finalContent,
tool_calls: toolCalls.map(tc => tc.call),
tool_results: toolCalls.map(tc => ({
tool_call_id: tc.call?.tool_call_id,
result: tc.result?.content || '',
is_error: tc.result?.is_error || false,
})),
})
}
setMessages(prev => [...prev, aiMsg])

View File

@@ -382,12 +382,12 @@ input::placeholder { color: var(--text-disabled); }
}
.shell-menu-divider { height: 1px; background: var(--border); margin: 4px 6px; }
.shell-xterm-wrapper { flex: 1; background: var(--bg); overflow: hidden; position: relative; }
.shell-xterm-wrapper { flex: 1; height: 100%; background: var(--bg); overflow: hidden; position: relative; }
.shell-xterm-instance {
position: absolute; inset: 0; padding: 4px;
display: block !important;
position: absolute;
inset: 0;
}
.shell-xterm-instance .xterm { height: 100%; padding: 4px; }
.shell-xterm-instance .xterm { height: 100%; }
.connection-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; }
.connection-dot.on { background: var(--success); box-shadow: 0 0 6px var(--success); }
@@ -691,34 +691,38 @@ input::placeholder { color: var(--text-disabled); }
}
/* Commands */
.dash-cmd-list { display: flex; flex-direction: column; gap: 3px; max-height: 270px; overflow-y: auto; }
.dash-cmd-card .dash-cmd-list { max-height: 220px; }
.dash-cmd-list { display: flex; flex-direction: column; gap: 2px; overflow-y: auto; }
.dash-cmd-row {
display: flex; align-items: center; gap: 6px;
padding: 3px 0; overflow: hidden;
}
.dash-cmd-shell {
font-size: 9px; font-family: var(--font-mono); color: var(--text-disabled);
background: var(--bg-input); padding: 1px 4px; border-radius: 3px;
text-transform: uppercase; flex-shrink: 0;
display: flex; align-items: center; justify-content: space-between; gap: 8px;
padding: 5px 8px; border-radius: var(--radius-sm);
background: var(--bg-surface); cursor: pointer;
transition: background 0.12s;
}
.dash-cmd-row:hover { background: var(--accent-bg); }
.dash-cmd-left { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
.dash-cmd-text {
font-size: 11px; font-family: var(--font-mono); color: var(--text-secondary);
font-size: 11px; font-family: var(--font-mono); color: var(--text-primary);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
flex: 1; min-width: 0;
}
.dash-cmd-time { font-size: 9px; color: var(--text-disabled); }
.dash-cmd-copy { font-size: 13px; color: var(--text-disabled); flex-shrink: 0; }
.dash-cmd-row:hover .dash-cmd-copy { color: var(--accent); }
.dash-cmd-freq { display: flex; flex-direction: column; gap: 6px; margin-bottom: 10px; padding-bottom: 10px; border-bottom: 1px solid var(--border); }
.dash-cmd-freq-title { font-size: 10px; font-weight: 700; text-transform: uppercase; color: var(--text-disabled); letter-spacing: 0.05em; margin-bottom: 2px; }
.dash-cmd-freq-row {
display: flex; align-items: center; gap: 8px; cursor: pointer;
padding: 3px 4px; border-radius: var(--radius-sm);
transition: background 0.12s;
}
.dash-cmd-freq-row:hover { background: var(--accent-bg); }
.dash-cmd-freq-name { font-size: 12px; font-weight: 600; font-family: var(--font-mono); color: var(--text-primary); width: 100px; flex-shrink: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.dash-cmd-freq-bar-wrap { flex: 1; height: 6px; background: var(--bg-input); border-radius: 3px; overflow: hidden; }
.dash-cmd-freq-bar { height: 100%; background: var(--accent); border-radius: 3px; transition: width 0.3s ease; }
.dash-cmd-freq-count { font-size: 10px; font-family: var(--font-mono); color: var(--accent); width: 28px; text-align: right; flex-shrink: 0; }
.dash-cmd-top { display: flex; flex-wrap: wrap; gap: 6px; margin-bottom: 8px; }
.dash-cmd-chip {
display: flex; align-items: center; gap: 6px;
padding: 6px 12px; border-radius: var(--radius);
background: var(--bg-surface); border: 1px solid var(--border);
cursor: pointer; transition: all 0.15s;
}
.dash-cmd-chip:hover { border-color: var(--accent-dim); background: var(--accent-bg); }
.dash-cmd-chip-copied { border-color: var(--accent) !important; background: var(--accent-bg) !important; }
.dash-cmd-chip-copied .dash-cmd-chip-name { color: var(--accent); }
.dash-cmd-chip-name { font-size: 13px; font-weight: 700; font-family: var(--font-mono); color: var(--text-primary); }
.dash-cmd-chip-count { font-size: 10px; font-family: var(--font-mono); color: var(--accent); }
/* Services */
.dash-services { display: flex; flex-direction: column; gap: 6px; }