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) n, err := ptmx.Read(buf)
if err != nil { if err != nil {
cleanup() cleanup()
conn.WriteMessage(websocket.CloseMessage,
websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
return return
} }
if err := conn.WriteJSON(wsMessage{ if err := conn.WriteJSON(wsMessage{

View File

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

View File

@@ -1,5 +1,5 @@
import { useState, useEffect, useCallback } from 'react' 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' import { useI18n } from '../i18n'
const PANELS = [ const PANELS = [
@@ -311,16 +311,6 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
const [validating, setValidating] = useState(null) const [validating, setValidating] = useState(null)
const [keyStatus, setKeyStatus] = useState({}) 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) => { const validateKey = async (p) => {
setValidating(p.name) setValidating(p.name)
try { try {
@@ -332,6 +322,16 @@ function PanelProviders({ providers, editProvider, providerForm, setProviderForm
setValidating(null) 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) => { const handleValidate = async (name, apiKey, model, baseUrl) => {
setValidating(name) setValidating(name)
try { 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.` } })) 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 ( return (
<> <>

View File

@@ -43,7 +43,7 @@ export default function Dashboard({ api, refreshRef }) {
const [recentCmds, setRecentCmds] = useState([]) const [recentCmds, setRecentCmds] = useState([])
const [processes, setProcesses] = useState([]) const [processes, setProcesses] = useState([])
const [metrics, setMetrics] = useState(null) const [metrics, setMetrics] = useState(null)
const [copiedIdx, setCopiedIdx] = useState(-1) const [copiedSet, setCopiedSet] = useState(new Set())
const cpuRef = useRef([]) const cpuRef = useRef([])
const memRef = useRef([]) const memRef = useRef([])
const netRxRef = useRef([]) const netRxRef = useRef([])
@@ -109,6 +109,32 @@ export default function Dashboard({ api, refreshRef }) {
.map(([cmd, count]) => ({ cmd, count })) .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 ( return (
<div className="dash-grid"> <div className="dash-grid">
{/* CPU */} {/* CPU */}
@@ -197,26 +223,34 @@ export default function Dashboard({ api, refreshRef }) {
</div> </div>
{/* Recent Commands */} {/* Recent Commands */}
<div className="dash-card"> <div className="dash-card dash-cmd-card">
<div className="dash-card-head"> <div className="dash-card-head">
<span className="dash-label">Recent Commands</span> <span className="dash-label">Recent Commands</span>
<span className="dash-count">{recentUnique.length}</span>
</div> </div>
{topCmds.length > 0 && ( {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) => ( {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); }}> <div key={i} className="dash-cmd-freq-row" onClick={() => copyCmd(c.cmd, `top-${i}`)} title={c.cmd}>
<span className="dash-cmd-chip-name">{copiedIdx === i ? '✓ Copié' : c.cmd}</span> <span className="dash-cmd-freq-name">{copiedSet.has(`top-${i}`) ? '✓ Copié' : c.cmd}</span>
<span className="dash-cmd-chip-count">{c.count}×</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> </div>
)} )}
<div className="dash-cmd-list"> <div className="dash-cmd-list">
{recentCmds.length === 0 && <span className="dash-empty">No history</span>} {recentUnique.length === 0 && <span className="dash-empty">No history</span>}
{recentCmds.map((c, i) => ( {recentUnique.map((c, i) => (
<div key={i} className="dash-cmd-row" title={c.cmd}> <div key={i} className="dash-cmd-row" onClick={() => copyCmd(c.cmd, `list-${i}`)} title={c.cmd + ' · click to copy'}>
<span className="dash-cmd-shell">{c.shell}</span> <div className="dash-cmd-left">
<span className="dash-cmd-text">{c.cmd.length > 45 ? c.cmd.slice(0, 42) + '...' : c.cmd}</span> <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>
))} ))}
</div> </div>

View File

@@ -149,7 +149,7 @@ function createTerminal(container, settings = {}) {
return { term, fitAddon } 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 proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
const ws = new WebSocket(`${proto}//${window.location.host}/api/ws/terminal`) 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) if (onStateChange) onStateChange(true)
}) })
let firstMessage = true
ws.addEventListener('message', (event) => { ws.addEventListener('message', (event) => {
if (firstMessage) {
firstMessage = false
if (onFirstMessage) onFirstMessage()
}
try { try {
const msg = JSON.parse(event.data) const msg = JSON.parse(event.data)
if (msg.type === 'output') { if (msg.type === 'output') {
@@ -185,12 +190,6 @@ function connectWebSocket(term, fitAddon, initPayload, onStateChange) {
if (onStateChange) onStateChange(false) if (onStateChange) onStateChange(false)
}) })
term.onData((data) => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'input', data }))
}
})
term.onResize(({ rows, cols }) => { term.onResize(({ rows, cols }) => {
if (ws.readyState === WebSocket.OPEN) { if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'resize', rows, cols })) ws.send(JSON.stringify({ type: 'resize', rows, cols }))
@@ -205,6 +204,7 @@ export default function Shell({ api }) {
const tabsRef = useRef({}) const tabsRef = useRef({})
const nextIdRef = useRef(1) const nextIdRef = useRef(1)
const settingsRef = useRef({ fontSize: 12, fontFamily: "'JetBrains Mono', 'Fira Code', monospace", theme: 'default' }) const settingsRef = useRef({ fontSize: 12, fontFamily: "'JetBrains Mono', 'Fira Code', monospace", theme: 'default' })
const pendingCommandsRef = useRef({})
const savedTabs = (() => { const savedTabs = (() => {
try { try {
@@ -228,6 +228,8 @@ export default function Shell({ api }) {
} }
return 1 return 1
}) })
const activeTabRef = useRef(activeTab)
useEffect(() => { activeTabRef.current = activeTab }, [activeTab])
const [sshConnections, setSshConnections] = useState([]) const [sshConnections, setSshConnections] = useState([])
const [systemTerminals, setSystemTerminals] = useState([]) const [systemTerminals, setSystemTerminals] = useState([])
const [showMenu, setShowMenu] = useState(false) const [showMenu, setShowMenu] = useState(false)
@@ -256,6 +258,7 @@ export default function Shell({ api }) {
const [analysisContent, setAnalysisContent] = useState('') const [analysisContent, setAnalysisContent] = useState('')
const aiMessagesRef = useRef(null) const aiMessagesRef = useRef(null)
const aiLoadedRef = useRef(false) const aiLoadedRef = useRef(false)
const aiLoadingRef = useRef(false)
useEffect(() => { useEffect(() => {
aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight) aiMessagesRef.current?.scrollTo(0, aiMessagesRef.current.scrollHeight)
@@ -338,23 +341,7 @@ export default function Shell({ api }) {
} }
} }
const onWsState = (connected) => { let disposed = false
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)
const saveBuffer = () => { const saveBuffer = () => {
try { try {
@@ -364,39 +351,56 @@ export default function Shell({ api }) {
const line = buf.getLine(i) const line = buf.getLine(i)
if (line) lines.push(line.translateToString(true)) 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') savedBuffers[tabId] = lines.join('\n')
localStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers)) sessionStorage.setItem(TERMINAL_BUFFER_KEY, JSON.stringify(savedBuffers))
} catch {} } 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 = () => { const clearBufferOnClear = () => {
try { try {
const buf = term.buffer.active const buf = term.buffer.active
const lineY = buf.viewportY + buf.cursorY const lineY = buf.length - 1
const line = buf.getLine(lineY) const line = buf.getLine(lineY)
if (line) { if (line) {
const text = line.translateToString(true).trim().toLowerCase() const text = line.translateToString(true).trim().toLowerCase()
if (text === 'clear' || text === '$ clear' || text.endsWith(' clear')) { 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] 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) => { term.onData((data) => {
if (data === '\r') { if (data === '\r') clearBufferOnClear()
clearBufferOnClear() if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'input', data }))
} }
}) })
const onResize = () => { const onResize = () => {
const el = document.getElementById(`terminal-${tabId}`) const el = document.getElementById(`terminal-${tabId}`)
if (el && el.offsetParent !== null) { if (el && el.style.display !== 'none') {
fitAddon.fit() fitAddon.fit()
} }
} }
@@ -405,36 +409,66 @@ export default function Shell({ api }) {
resizeObserver.observe(container) resizeObserver.observe(container)
window.addEventListener('resize', onResize) 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(() => { useEffect(() => {
const tab = tabs.find(t => t.id === activeTab) const tab = tabs.find(t => t.id === activeTab)
if (!tab) return if (!tab) return
let cancelled = false
const pending = []
const tryInit = (attempt) => { const tryInit = (attempt) => {
if (attempt > 20) return if (cancelled || attempt > 20) return
const shellCol = document.querySelector('.shell-terminal-col') const shellCol = document.querySelector('.shell-terminal-col')
if (!shellCol || shellCol.offsetParent === null) { if (!shellCol || shellCol.offsetParent === null) {
setTimeout(() => tryInit(attempt + 1), 150) pending.push(setTimeout(() => tryInit(attempt + 1), 150))
return return
} }
const container = document.getElementById(`terminal-${tab.id}`) const container = document.getElementById(`terminal-${tab.id}`)
if (!container || container.offsetHeight === 0) { if (!container) {
setTimeout(() => tryInit(attempt + 1), 100) 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 return
} }
if (!tabsRef.current[tab.id]) { if (!tabsRef.current[tab.id]) {
initTerminal(tab.id, tab) initTerminal(tab.id, tab)
} }
requestAnimationFrame(() => { requestAnimationFrame(() => {
if (cancelled) return
if (activeTabRef.current !== tab.id) return
const entry = tabsRef.current[tab.id] const entry = tabsRef.current[tab.id]
if (entry) entry.fitAddon.fit() if (entry) entry.fitAddon.fit()
}) })
} }
tryInit(0) tryInit(0)
}, [activeTab, tabs, initTerminal]) return () => {
cancelled = true
pending.forEach(clearTimeout)
}
}, [activeTab, initTerminal])
useEffect(() => { useEffect(() => {
const iv = setInterval(() => { const iv = setInterval(() => {
@@ -442,7 +476,7 @@ export default function Shell({ api }) {
const entry = tabsRef.current[tab.id] const entry = tabsRef.current[tab.id]
if (entry) { if (entry) {
const el = document.getElementById(`terminal-${tab.id}`) const el = document.getElementById(`terminal-${tab.id}`)
if (el && el.offsetParent !== null) { if (el && el.style.display !== 'none') {
entry.fitAddon.fit() entry.fitAddon.fit()
} }
} }
@@ -451,6 +485,20 @@ export default function Shell({ api }) {
return () => clearInterval(iv) return () => clearInterval(iv)
}, [tabs]) }, [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(() => { 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
@@ -506,29 +554,26 @@ export default function Shell({ api }) {
const closeTab = (tabId, e) => { const closeTab = (tabId, e) => {
if (e) e.stopPropagation() 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 => { setTabs(prev => {
if (prev.length <= 1) return 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) const next = prev.filter(t => t.id !== tabId)
if (activeTab === tabId && next.length > 0) { if (activeTab === tabId && next.length > 0) {
setActiveTab(next[next.length - 1].id) setActiveTab(next[next.length - 1].id)
@@ -574,27 +619,33 @@ export default function Shell({ api }) {
} }
const sendToTerminal = useCallback((code, tabId) => { const sendToTerminal = useCallback((code, tabId) => {
const targetId = tabId || activeTab const targetId = tabId || activeTabRef.current
const entry = tabsRef.current[targetId] const entry = tabsRef.current[targetId]
if (!entry) { 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 return
} }
if (!entry.ws || entry.ws.readyState !== WebSocket.OPEN) { 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 return
} }
console.log(`[Shell] sendToTerminal: tab ${targetId}${code.length} chars`)
entry.ws.send(JSON.stringify({ type: 'input', data: code + '\r' })) entry.ws.send(JSON.stringify({ type: 'input', data: code + '\r' }))
}, [activeTab]) }, [])
const focusAiTerminal = useCallback(() => { const focusAiTerminal = useCallback(() => {
const entry = tabsRef.current[activeTab] const entry = tabsRef.current[activeTabRef.current]
if (entry) entry.term.focus() if (entry) entry.term.focus()
}, [activeTab]) }, [])
const _sendAiMessage = useCallback(async (text, fromEvent = false) => { 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() const trimmed = text.trim()
aiLoadingRef.current = true
if (!fromEvent) { if (!fromEvent) {
setAiInput('') setAiInput('')
@@ -608,6 +659,7 @@ export default function Shell({ api }) {
setAiTokens(0) setAiTokens(0)
setAiAtLimit(false) setAiAtLimit(false)
} catch {} } catch {}
aiLoadingRef.current = false
return return
} }
@@ -616,10 +668,13 @@ export default function Shell({ api }) {
{ role: 'user', content: trimmed }, { 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.' } { 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 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) setAiLoading(true)
try { try {
@@ -628,26 +683,27 @@ export default function Shell({ api }) {
accumulated = partial accumulated = partial
setAiMessages(prev => { setAiMessages(prev => {
const filtered = prev.filter(m => !m._streaming) 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 => { setAiMessages(prev => {
const filtered = prev.filter(m => !m._streaming) 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 => { api.getShellChatHistory().then(d => {
setAiTokens(d.tokens || 0) setAiTokens(d.tokens || 0)
setAiAtLimit(d.at_limit || false) setAiAtLimit(d.at_limit || false)
}).catch(() => {}) }).catch(() => {})
} catch (err) { } catch (err) {
if (err.message.includes('context limit')) { if (err.message?.includes('context limit')) {
setAiAtLimit(true) setAiAtLimit(true)
} }
setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'assistant', content: `Erreur: ${err.message}` }]) setAiMessages(prev => [...prev.filter(m => !m._streaming), { role: 'assistant', content: `Erreur: ${err.message}` }])
} }
setAiLoading(false) setAiLoading(false)
}, [api, t, aiLoading, aiAtLimit, focusAiTerminal]) aiLoadingRef.current = false
}, [api, t, aiAtLimit, focusAiTerminal])
const handleAiSend = () => _sendAiMessage(aiInput, false) const handleAiSend = () => _sendAiMessage(aiInput, false)
@@ -830,7 +886,7 @@ export default function Shell({ api }) {
</div> </div>
<div className="ai-panel-messages" ref={aiMessagesRef}> <div className="ai-panel-messages" ref={aiMessagesRef}>
{aiMessages.map((msg, i) => ( {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>} {aiLoading && <div style={{ textAlign: 'center', padding: 8 }}><span className="spinner" /></div>}
</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 ( return (
<div className={`feed-item ${msg.role}`}> <div className={`feed-item ${msg.role}`}>
@@ -532,6 +532,8 @@ export default function Studio({ api }) {
if (event && event.tool_call) { if (event && event.tool_call) {
toolCalls = [...toolCalls, { call: event.tool_call, result: null }] toolCalls = [...toolCalls, { call: event.tool_call, result: null }]
setStreamToolCalls([...toolCalls]) setStreamToolCalls([...toolCalls])
accumulated = ''
setStreaming('')
return return
} }
if (event && event.tool_result) { if (event && event.tool_result) {
@@ -558,6 +560,11 @@ export default function Studio({ api }) {
aiMsg.content = JSON.stringify({ aiMsg.content = JSON.stringify({
content: finalContent, content: finalContent,
tool_calls: toolCalls.map(tc => tc.call), 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]) 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-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 { .shell-xterm-instance {
position: absolute; inset: 0; padding: 4px; position: absolute;
display: block !important; 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 { 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); } .connection-dot.on { background: var(--success); box-shadow: 0 0 6px var(--success); }
@@ -691,34 +691,38 @@ input::placeholder { color: var(--text-disabled); }
} }
/* Commands */ /* 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 { .dash-cmd-row {
display: flex; align-items: center; gap: 6px; display: flex; align-items: center; justify-content: space-between; gap: 8px;
padding: 3px 0; overflow: hidden; padding: 5px 8px; border-radius: var(--radius-sm);
} background: var(--bg-surface); cursor: pointer;
.dash-cmd-shell { transition: background 0.12s;
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;
} }
.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 { .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; 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-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 */ /* Services */
.dash-services { display: flex; flex-direction: column; gap: 6px; } .dash-services { display: flex; flex-direction: column; gap: 6px; }