@@ -53,6 +53,7 @@ const UI_CSS = `
5353 color: var(--dft-muted); font-size: 12px; cursor: pointer; }
5454.dft-tab:hover { color: var(--dft-fg); }
5555.dft-tab.active { background: var(--dft-surface-active); color: var(--dft-fg); border-color: var(--dft-border); }
56+ .dft-newtab { min-width: 28px; justify-content: center; font-weight: 600; font-size: 14px; flex: none; }
5657.dft-dot { width: 7px; height: 7px; border-radius: 50%; background: #3fb950; flex: none; }
5758.dft-dot.exited { background: #6e7681; }
5859.dft-dot.error { background: #f85149; }
@@ -155,15 +156,18 @@ export async function mountTerminals(
155156 const tabs = el ( 'div' , 'dft-tabs' )
156157 const actions = el ( 'div' , 'dft-actions' )
157158 const presetSelect = el ( 'select' , 'dft-select' )
158- const newShellBtn = el ( 'button' , 'dft-btn' )
159- newShellBtn . textContent = '+ Shell'
160- actions . append ( presetSelect , newShellBtn )
159+ actions . append ( presetSelect )
161160 header . append ( tabs , actions )
162161
162+ // The "new terminal" affordance sits at the end of the tab strip.
163+ const newTabBtn = el ( 'button' , 'dft-tab dft-newtab' )
164+ newTabBtn . textContent = '+'
165+ newTabBtn . title = 'New terminal'
166+
163167 const toolbar = el ( 'div' , 'dft-toolbar' )
164168 const body = el ( 'div' , 'dft-body' )
165169 const empty = el ( 'div' , 'dft-empty' )
166- empty . textContent = 'No terminal sessions — start one above .'
170+ empty . textContent = 'No terminal sessions — click + to start one.'
167171 body . append ( empty )
168172
169173 root . append ( header , toolbar , body )
@@ -174,6 +178,9 @@ export async function mountTerminals(
174178 let presets : TerminalPreset [ ] = [ ]
175179 let disposed = false
176180 let renamingId : string | null = null
181+ // Session to select once it shows up in the shared-state list (set when
182+ // we spawn one and want to focus it on arrival).
183+ let pendingSelectId : string | null = null
177184
178185 // Follow the system color mode and react to changes at runtime, switching
179186 // both the UI chrome (via CSS classes) and every xterm instance's theme.
@@ -205,17 +212,54 @@ export async function mountTerminals(
205212 return info . customTitle || info . processName || info . title
206213 }
207214
208- function spawn ( req : Parameters < DevframeRpcClient [ 'call' ] > [ 1 ] ) : void {
209- rpc . call ( 'devframes-plugin-terminals:spawn' , req as any ) . catch ( ( ) => { } )
215+ // Selection is mirrored to the URL hash (e.g. `#id=<sessionId>`) so it
216+ // survives links and reacts to back/forward + manual edits.
217+ function readHashId ( ) : string | null {
218+ if ( typeof location === 'undefined' )
219+ return null
220+ return new URLSearchParams ( location . hash . replace ( / ^ # / , '' ) ) . get ( 'id' )
210221 }
211222
212- newShellBtn . onclick = ( ) => spawn ( { mode : 'interactive' } )
223+ function writeHashId ( id : string ) : void {
224+ if ( typeof location === 'undefined' || typeof history === 'undefined' )
225+ return
226+ const target = `#id=${ id } `
227+ if ( location . hash !== target )
228+ history . replaceState ( history . state , '' , target )
229+ }
230+
231+ const onHashChange = ( ) : void => {
232+ const id = readHashId ( )
233+ if ( id && views . has ( id ) && id !== activeId )
234+ setActive ( id , { updateHash : false } )
235+ }
236+ if ( typeof window !== 'undefined' )
237+ window . addEventListener ( 'hashchange' , onHashChange )
238+
239+ /** Spawn a session and select it as soon as it appears in the list. */
240+ async function spawnAndSelect ( req : Parameters < DevframeRpcClient [ 'call' ] > [ 1 ] ) : Promise < void > {
241+ try {
242+ const info = await rpc . call ( 'devframes-plugin-terminals:spawn' , req as any ) as { id ?: string }
243+ if ( info ?. id ) {
244+ pendingSelectId = info . id
245+ if ( views . has ( info . id ) ) {
246+ pendingSelectId = null
247+ setActive ( info . id )
248+ }
249+ }
250+ }
251+ catch {
252+ // Spawn failures surface via server-side diagnostics.
253+ }
254+ }
255+
256+ newTabBtn . onclick = ( ) => void spawnAndSelect ( { mode : 'interactive' } )
213257
214258 presetSelect . onchange = ( ) => {
215259 const id = presetSelect . value
216260 presetSelect . value = ''
217261 if ( id )
218- spawn ( { presetId : id } )
262+ void spawnAndSelect ( { presetId : id } )
219263 }
220264
221265 function renderPresets ( ) : void {
@@ -247,17 +291,28 @@ export async function mountTerminals(
247291 }
248292 }
249293
250- function setActive ( id : string | null ) : void {
294+ function setActive ( id : string | null , opts : { updateHash ?: boolean } = { } ) : void {
295+ const changed = activeId !== id
251296 activeId = id
252297 for ( const [ vid , view ] of views ) {
253298 const active = vid === id
254299 view . el . classList . toggle ( 'active' , active )
255300 view . tab . classList . toggle ( 'active' , active )
256- if ( active ) {
257- requestAnimationFrame ( ( ) => {
258- fitActive ( )
259- view . term . focus ( )
260- } )
301+ }
302+ if ( id ) {
303+ if ( opts . updateHash !== false )
304+ writeHashId ( id )
305+ // Only fit + steal focus when the active session actually changed,
306+ // so background shared-state updates (e.g. process-name polling)
307+ // don't refocus the terminal every tick.
308+ if ( changed ) {
309+ const view = views . get ( id )
310+ if ( view ) {
311+ requestAnimationFrame ( ( ) => {
312+ fitActive ( )
313+ view . term . focus ( )
314+ } )
315+ }
261316 }
262317 }
263318 renderToolbar ( )
@@ -377,6 +432,8 @@ export async function mountTerminals(
377432 view . tab . title = 'Double-click to rename'
378433 view . tab . append ( dot , label )
379434 }
435+ // Keep the "+" affordance pinned to the end of the strip.
436+ tabs . append ( newTabBtn )
380437 }
381438
382439 /** Inline-edit a tab name; commits via the rename RPC on Enter/blur. */
@@ -440,8 +497,19 @@ export async function mountTerminals(
440497
441498 if ( activeId && ! views . has ( activeId ) )
442499 activeId = null
443- if ( ! activeId && views . size )
444- activeId = sessions [ sessions . length - 1 ] ?. id ?? views . keys ( ) . next ( ) . value ?? null
500+
501+ if ( pendingSelectId && views . has ( pendingSelectId ) ) {
502+ // A freshly spawned session has arrived — select it.
503+ activeId = pendingSelectId
504+ pendingSelectId = null
505+ }
506+ else if ( ! activeId && views . size ) {
507+ // Otherwise honour the URL hash, else fall back to the newest session.
508+ const hashId = readHashId ( )
509+ activeId = ( hashId && views . has ( hashId ) )
510+ ? hashId
511+ : sessions [ sessions . length - 1 ] ?. id ?? views . keys ( ) . next ( ) . value ?? null
512+ }
445513
446514 renderTabs ( )
447515 setActive ( activeId )
@@ -468,9 +536,9 @@ export async function mountTerminals(
468536 syncSessions ( full . sessions ?? [ ] )
469537 } )
470538
471- // Auto-create an interactive shell when nothing is running yet .
472- if ( options . autostart !== false && views . size === 0 )
473- spawn ( { mode : 'interactive' } )
539+ // Each page load spawns a fresh interactive session and selects it .
540+ if ( options . autostart !== false )
541+ void spawnAndSelect ( { mode : 'interactive' } )
474542
475543 const resizeObserver = typeof ResizeObserver !== 'undefined'
476544 ? new ResizeObserver ( ( ) => fitActive ( ) )
@@ -484,6 +552,8 @@ export async function mountTerminals(
484552 offSessions ?.( )
485553 offPresets ?.( )
486554 colorScheme ?. removeEventListener ( 'change' , onColorScheme )
555+ if ( typeof window !== 'undefined' )
556+ window . removeEventListener ( 'hashchange' , onHashChange )
487557 resizeObserver ?. disconnect ( )
488558 for ( const view of views . values ( ) )
489559 disposeView ( view )
0 commit comments