1- import { useEffect , useState } from 'react'
1+ import { useEffect , useState , useCallback } from 'react'
22import { useParams , Link } from 'react-router-dom'
3- import { ArrowLeft , ChevronDown , ChevronRight , PanelLeft , X , Lock } from 'lucide-react'
3+ import { ArrowLeft , ChevronDown , ChevronRight , PanelLeft , X , Lock , Plus , Terminal as TerminalIcon } from 'lucide-react'
44import { api } from '../lib/api'
55import Markdown from '../components/Markdown'
66import AgentTerminal from '../components/AgentTerminal'
77import { getAgentMeta } from '../lib/agent-meta'
8+ import { trackAgentVisit } from './Agents'
89import { AgentAvatar } from '../components/AgentAvatar'
910import { useAuth } from '../context/AuthContext'
1011
@@ -16,6 +17,18 @@ interface MemoryFile {
1617
1718type Tab = 'profile' | 'memory'
1819
20+ // Terminal-server URL (same logic as AgentTerminal)
21+ const isLocal = import . meta. env . DEV || / ^ ( l o c a l h o s t | 1 2 7 \. 0 \. 0 \. 1 ) $ / i. test ( window . location . hostname )
22+ const TS_HTTP = isLocal
23+ ? `http://${ window . location . hostname } :32352`
24+ : `${ window . location . origin } /terminal`
25+
26+ interface TerminalTab {
27+ id : string // sessionId
28+ name : string // display name
29+ active : boolean // is claude running
30+ }
31+
1932function formatSize ( bytes : number ) : string {
2033 if ( bytes < 1024 ) return `${ bytes } b`
2134 if ( bytes < 1024 * 1024 ) return `${ ( bytes / 1024 ) . toFixed ( 1 ) } kb`
@@ -40,6 +53,93 @@ export default function AgentDetail() {
4053 const [ tab , setTab ] = useState < Tab > ( 'profile' )
4154 const [ railOpen , setRailOpen ] = useState ( false ) // mobile drawer
4255
56+ // Multi-terminal tabs
57+ const [ termTabs , setTermTabs ] = useState < TerminalTab [ ] > ( [ ] )
58+ const [ activeTermTab , setActiveTermTab ] = useState < string | null > ( null )
59+ const [ , setTermTabsLoading ] = useState ( true )
60+
61+ // Track agent visit for "Recent" section
62+ useEffect ( ( ) => {
63+ if ( name ) trackAgentVisit ( name )
64+ } , [ name ] )
65+
66+ // Load existing terminal sessions for this agent
67+ useEffect ( ( ) => {
68+ if ( ! name ) return
69+ setTermTabsLoading ( true )
70+ fetch ( `${ TS_HTTP } /api/sessions/by-agent/${ name } ` )
71+ . then ( r => r . ok ? r . json ( ) : { sessions : [ ] } )
72+ . then ( data => {
73+ const sessions : TerminalTab [ ] = ( data . sessions || [ ] ) . map ( ( s : any ) => ( {
74+ id : s . id ,
75+ name : s . name || name ,
76+ active : s . active ,
77+ } ) )
78+ if ( sessions . length === 0 ) {
79+ // No existing sessions — will use default find-or-create (no tab needed yet)
80+ setTermTabs ( [ ] )
81+ setActiveTermTab ( null )
82+ } else {
83+ setTermTabs ( sessions )
84+ setActiveTermTab ( sessions [ 0 ] . id )
85+ }
86+ } )
87+ . catch ( ( ) => {
88+ setTermTabs ( [ ] )
89+ setActiveTermTab ( null )
90+ } )
91+ . finally ( ( ) => setTermTabsLoading ( false ) )
92+ } , [ name ] )
93+
94+ const createNewTerminal = useCallback ( async ( ) => {
95+ if ( ! name ) return
96+ try {
97+ const res = await fetch ( `${ TS_HTTP } /api/sessions/create` , {
98+ method : 'POST' ,
99+ headers : { 'Content-Type' : 'application/json' } ,
100+ body : JSON . stringify ( { agentName : name } ) ,
101+ } )
102+ if ( ! res . ok ) return
103+ const data = await res . json ( )
104+ const newTab : TerminalTab = {
105+ id : data . sessionId ,
106+ name : data . session ?. name || `${ name } #${ termTabs . length + 1 } ` ,
107+ active : false ,
108+ }
109+ // If this is the first extra tab, we also need to load the existing default session
110+ if ( termTabs . length === 0 && activeTermTab === null ) {
111+ // Fetch existing sessions first to get the default one
112+ const existing = await fetch ( `${ TS_HTTP } /api/sessions/by-agent/${ name } ` )
113+ if ( existing . ok ) {
114+ const existingData = await existing . json ( )
115+ const allSessions : TerminalTab [ ] = ( existingData . sessions || [ ] )
116+ . filter ( ( s : any ) => s . id !== data . sessionId )
117+ . map ( ( s : any ) => ( { id : s . id , name : s . name || name , active : s . active } ) )
118+ setTermTabs ( [ ...allSessions , newTab ] )
119+ } else {
120+ setTermTabs ( [ newTab ] )
121+ }
122+ } else {
123+ setTermTabs ( prev => [ ...prev , newTab ] )
124+ }
125+ setActiveTermTab ( data . sessionId )
126+ } catch { }
127+ } , [ name , termTabs , activeTermTab ] )
128+
129+ const closeTerminalTab = useCallback ( async ( sessionId : string ) => {
130+ // Stop and delete session
131+ try {
132+ await fetch ( `${ TS_HTTP } /api/sessions/${ sessionId } ` , { method : 'DELETE' } )
133+ } catch { }
134+ setTermTabs ( prev => {
135+ const next = prev . filter ( t => t . id !== sessionId )
136+ if ( activeTermTab === sessionId ) {
137+ setActiveTermTab ( next . length > 0 ? next [ 0 ] . id : null )
138+ }
139+ return next
140+ } )
141+ } , [ activeTermTab ] )
142+
43143 useEffect ( ( ) => {
44144 if ( ! name ) return
45145 setLoading ( true )
@@ -226,7 +326,7 @@ export default function AgentDetail() {
226326 ) }
227327
228328 { /* Terminal stage */ }
229- < section className = "flex-1 min-w-0 relative bg-[#0C111D] overflow-hidden" >
329+ < section className = "flex-1 min-w-0 relative bg-[#0C111D] overflow-hidden flex flex-col " >
230330 { /* Ambient glow */ }
231331 < div
232332 className = "pointer-events-none absolute top-0 right-0 h-[400px] w-[400px] blur-3xl"
@@ -235,8 +335,70 @@ export default function AgentDetail() {
235335 opacity : 0.06 ,
236336 } }
237337 />
238- < div className = "relative z-10 h-full" >
239- < AgentTerminal agent = { name } accentColor = { agentColor } />
338+
339+ { /* Terminal tabs bar — only show when there are multiple tabs */ }
340+ { termTabs . length > 1 && (
341+ < div className = "relative z-10 flex items-center flex-shrink-0 h-9 border-b border-[#21262d] bg-[#0d1117] overflow-x-auto" >
342+ { termTabs . map ( ( tt ) => (
343+ < div
344+ key = { tt . id }
345+ className = { `group flex items-center gap-2 px-3 h-full text-[11px] cursor-pointer border-r border-[#21262d] transition-colors ${
346+ activeTermTab === tt . id
347+ ? 'bg-[#0C111D] text-[#e6edf3]'
348+ : 'text-[#8b949e] hover:text-[#e6edf3] hover:bg-[#161b22]'
349+ } `}
350+ onClick = { ( ) => setActiveTermTab ( tt . id ) }
351+ >
352+ < TerminalIcon size = { 11 } style = { { color : activeTermTab === tt . id ? agentColor : undefined } } />
353+ < span className = "truncate max-w-[120px]" > { tt . name } </ span >
354+ { tt . active && (
355+ < span
356+ className = "inline-block h-1.5 w-1.5 rounded-full flex-shrink-0"
357+ style = { { backgroundColor : agentColor , boxShadow : `0 0 4px ${ agentColor } 88` } }
358+ />
359+ ) }
360+ { termTabs . length > 1 && (
361+ < button
362+ onClick = { ( e ) => { e . stopPropagation ( ) ; closeTerminalTab ( tt . id ) } }
363+ className = "opacity-0 group-hover:opacity-100 text-[#667085] hover:text-[#ef4444] transition-opacity"
364+ >
365+ < X size = { 11 } />
366+ </ button >
367+ ) }
368+ </ div >
369+ ) ) }
370+ { /* New tab button */ }
371+ < button
372+ onClick = { createNewTerminal }
373+ className = "flex items-center justify-center h-full px-2.5 text-[#667085] hover:text-[#e6edf3] hover:bg-[#161b22] transition-colors"
374+ title = "New terminal"
375+ >
376+ < Plus size = { 13 } />
377+ </ button >
378+ </ div >
379+ ) }
380+
381+ { /* Single "+" button when only one tab (or none) — show as floating button */ }
382+ { termTabs . length <= 1 && (
383+ < button
384+ onClick = { createNewTerminal }
385+ className = "absolute top-1.5 right-3 z-20 flex items-center gap-1 px-2 py-1 rounded text-[10px] text-[#667085] hover:text-[#e6edf3] bg-[#0d1117]/80 hover:bg-[#161b22] border border-[#21262d] transition-colors"
386+ title = "New terminal"
387+ >
388+ < Plus size = { 11 } />
389+ < span className = "hidden sm:inline" > New</ span >
390+ </ button >
391+ ) }
392+
393+ { /* Terminal content */ }
394+ < div className = "relative z-10 flex-1 min-h-0" >
395+ { termTabs . length === 0 || activeTermTab === null ? (
396+ // Default mode — single terminal, no explicit sessionId
397+ < AgentTerminal key = { `default-${ name } ` } agent = { name } accentColor = { agentColor } />
398+ ) : (
399+ // Multi-tab mode — render the active session
400+ < AgentTerminal key = { activeTermTab } agent = { name } sessionId = { activeTermTab } accentColor = { agentColor } />
401+ ) }
240402 </ div >
241403 </ section >
242404 </ div >
0 commit comments