@@ -1268,16 +1268,46 @@ window.switchModel = function(modelId) {
12681268} ;
12691269
12701270// ── Session Rename ──
1271- window . renameSession = async function ( sid , el ) {
1271+ // Inline edit: swaps the task label for an input; Enter commits, Esc
1272+ // cancels, blur commits. No window.prompt.
1273+ window . renameSession = function ( sid , el ) {
12721274 const item = el . closest ( '.session-item' ) ;
12731275 if ( ! item ) return ;
12741276 const taskEl = item . querySelector ( '.task' ) ;
1275- const currentName = taskEl ? taskEl . textContent : '' ;
1276- const newName = prompt ( 'Rename session:' , currentName ) ;
1277- if ( ! newName || newName === currentName ) return ;
1277+ if ( ! taskEl || taskEl . querySelector ( '.si-rename-input' ) ) return ;
1278+ const currentName = taskEl . textContent ;
1279+
1280+ const input = document . createElement ( 'input' ) ;
1281+ input . className = 'si-rename-input' ;
1282+ input . type = 'text' ;
1283+ input . value = currentName === 'untitled' ? '' : currentName ;
1284+ input . placeholder = 'session name…' ;
1285+ taskEl . textContent = '' ;
1286+ taskEl . appendChild ( input ) ;
1287+ input . focus ( ) ;
1288+ input . select ( ) ;
1289+
1290+ let done = false ;
1291+ const finish = ( commit ) => {
1292+ if ( done ) return ;
1293+ done = true ;
1294+ const newName = input . value . trim ( ) ;
1295+ taskEl . textContent = currentName ;
1296+ if ( commit && newName && newName !== currentName ) {
1297+ doRenameSession ( sid , newName ) ;
1298+ }
1299+ } ;
1300+ input . addEventListener ( 'keydown' , ( e ) => {
1301+ e . stopPropagation ( ) ;
1302+ if ( e . key === 'Enter' ) { e . preventDefault ( ) ; finish ( true ) ; }
1303+ if ( e . key === 'Escape' ) { e . preventDefault ( ) ; finish ( false ) ; }
1304+ } ) ;
1305+ input . addEventListener ( 'click' , ( e ) => e . stopPropagation ( ) ) ;
1306+ input . addEventListener ( 'blur' , ( ) => finish ( true ) ) ;
1307+ } ;
12781308
1309+ async function doRenameSession ( sid , newName ) {
12791310 const token = await ensureSessionToken ( sid ) ;
1280-
12811311 fetch ( '/api/sessions/' + encodeURIComponent ( sid ) , {
12821312 method : 'POST' ,
12831313 headers : apiHeaders ( {
@@ -1292,7 +1322,7 @@ window.renameSession = async function(sid, el) {
12921322 showToast ( 'Session renamed' ) ;
12931323 } )
12941324 . catch ( ( ) => showToast ( 'Failed to rename session' ) ) ;
1295- } ;
1325+ }
12961326
12971327// ── Skill Events ──
12981328function handleSkillEvent ( event ) {
@@ -1596,7 +1626,7 @@ function addAttachedFile(file) {
15961626 // Check total size (max 10MB total)
15971627 const totalSize = attachedFiles . reduce ( ( s , f ) => s + f . size , 0 ) + file . size ;
15981628 if ( totalSize > 10 * 1024 * 1024 ) {
1599- showToast ( 'Total attachment size exceeds 10 MB') ;
1629+ addErrorChip ( file . name , 'total attachments exceed 10 MB') ;
16001630 return ;
16011631 }
16021632 attachedFiles . push ( file ) ;
@@ -1641,6 +1671,38 @@ function renderFileChips() {
16411671 chip . append ( icon , name , size , remove ) ;
16421672 fileChips . appendChild ( chip ) ;
16431673 } ) ;
1674+
1675+ // Total-size meter against the 10 MB cap.
1676+ if ( attachedFiles . length > 0 ) {
1677+ const total = attachedFiles . reduce ( ( s , f ) => s + f . size , 0 ) ;
1678+ const meter = document . createElement ( 'span' ) ;
1679+ meter . className = 'chips-total' ;
1680+ meter . textContent = formatFileSize ( total ) + ' / 10 MB' ;
1681+ if ( total > 8 * 1024 * 1024 ) meter . classList . add ( 'warn' ) ;
1682+ fileChips . appendChild ( meter ) ;
1683+ }
1684+ }
1685+
1686+ // addErrorChip shows a transient error chip for a file that could not be
1687+ // attached (too large, unreadable). Dismisses on click or after 6s.
1688+ function addErrorChip ( name , reason ) {
1689+ const chip = document . createElement ( 'span' ) ;
1690+ chip . className = 'file-chip error' ;
1691+ chip . title = reason ;
1692+ const icon = document . createElement ( 'span' ) ;
1693+ icon . className = 'chip-icon' ;
1694+ icon . textContent = '⚠️' ;
1695+ const label = document . createElement ( 'span' ) ;
1696+ label . className = 'chip-name' ;
1697+ label . textContent = name + ' — ' + reason ;
1698+ const remove = document . createElement ( 'span' ) ;
1699+ remove . className = 'chip-remove' ;
1700+ remove . textContent = '✕' ;
1701+ chip . append ( icon , label , remove ) ;
1702+ fileChips . appendChild ( chip ) ;
1703+ const dismiss = ( ) => chip . remove ( ) ;
1704+ remove . addEventListener ( 'click' , dismiss ) ;
1705+ setTimeout ( dismiss , 6000 ) ;
16441706}
16451707
16461708function readFileAsText ( file ) {
@@ -1665,7 +1727,7 @@ function handleFiles(fileList) {
16651727 readFileAsText ( file ) . then ( content => {
16661728 addAttachedFile ( { name : file . name , size : file . size , content} ) ;
16671729 } ) . catch ( err => {
1668- showToast ( err . message ) ;
1730+ addErrorChip ( file . name , err . message || 'could not read file' ) ;
16691731 } )
16701732 ) ;
16711733 }
@@ -1705,6 +1767,26 @@ messagesEl.addEventListener('drop', (e) => {
17051767
17061768// ── Input handlers ──
17071769promptEl . addEventListener ( 'keydown' , ( e ) => {
1770+ // @-completion keyboard navigation takes precedence while visible:
1771+ // ↑/↓ move, Enter/Tab accept, Esc dismisses.
1772+ if ( completionEl . classList . contains ( 'visible' ) ) {
1773+ if ( e . key === 'ArrowDown' || e . key === 'ArrowUp' ) {
1774+ e . preventDefault ( ) ;
1775+ moveCompletionSelection ( e . key === 'ArrowDown' ? 1 : - 1 ) ;
1776+ return ;
1777+ }
1778+ if ( e . key === 'Enter' && ! e . shiftKey ) {
1779+ e . preventDefault ( ) ;
1780+ selectCompletion ( ) ;
1781+ return ;
1782+ }
1783+ if ( e . key === 'Escape' ) {
1784+ e . preventDefault ( ) ;
1785+ completionEl . classList . remove ( 'visible' ) ;
1786+ return ;
1787+ }
1788+ }
1789+
17081790 if ( e . key === 'Enter' && ! e . shiftKey ) {
17091791 e . preventDefault ( ) ;
17101792 send ( ) ;
@@ -1784,8 +1866,10 @@ completionEl.addEventListener('click', (e) => {
17841866completionEl . addEventListener ( 'mousemove' , ( e ) => {
17851867 const item = e . target . closest ( '.comp-item' ) ;
17861868 if ( ! item ) return ;
1787- completionEl . querySelectorAll ( '.comp-item' ) . forEach ( el => el . classList . remove ( 'selected' ) ) ;
1788- item . classList . add ( 'selected' ) ;
1869+ completionEl . querySelectorAll ( '.comp-item' ) . forEach ( el => {
1870+ el . classList . toggle ( 'selected' , el === item ) ;
1871+ el . setAttribute ( 'aria-selected' , el === item ) ;
1872+ } ) ;
17891873} ) ;
17901874
17911875let lastAtIdx = - 1 ;
@@ -1824,7 +1908,7 @@ async function checkCompletion() {
18241908 }
18251909
18261910 completionEl . innerHTML = results . map ( ( r , i ) =>
1827- `<div class="comp-item${ i === 0 ? ' selected' : '' } " data-id="${ escapeAttr ( r . id ) } ">
1911+ `<div class="comp-item${ i === 0 ? ' selected' : '' } " role="option" aria-selected=" ${ i === 0 } " data-id="${ escapeAttr ( r . id ) } ">
18281912 <span class="comp-type">${ escapeAttr ( r . type ) } </span>
18291913 <span class="comp-label">${ escapeHtml ( r . label ) } </span>
18301914 <span class="comp-detail">${ escapeHtml ( r . detail || '' ) } </span>
@@ -1837,6 +1921,19 @@ async function checkCompletion() {
18371921 }
18381922}
18391923
1924+ // moveCompletionSelection moves the .selected marker by delta (+1/-1),
1925+ // keeping aria-selected in sync for assistive technology.
1926+ function moveCompletionSelection ( delta ) {
1927+ const items = Array . from ( completionEl . querySelectorAll ( '.comp-item' ) ) ;
1928+ if ( items . length === 0 ) return ;
1929+ let idx = items . findIndex ( el => el . classList . contains ( 'selected' ) ) ;
1930+ idx = ( idx + delta + items . length ) % items . length ;
1931+ items . forEach ( ( el , i ) => {
1932+ el . classList . toggle ( 'selected' , i === idx ) ;
1933+ el . setAttribute ( 'aria-selected' , i === idx ) ;
1934+ } ) ;
1935+ }
1936+
18401937function selectCompletion ( ) {
18411938 const selected = completionEl . querySelector ( '.selected' ) ;
18421939 if ( ! selected ) return ;
@@ -1867,14 +1964,16 @@ function relativeTime(dateStr) {
18671964// ── Sessions ──
18681965let allSessions = [ ] ;
18691966let pendingDeleteId = null ;
1967+ let sessionsSig = '' ;
18701968
18711969sessionListEl . addEventListener ( 'click' , ( e ) => {
1970+ const item = e . target . closest ( '.session-item' ) ;
1971+ if ( ! item ) return ;
1972+ const sid = item . dataset . id ;
1973+ if ( ! sid ) return ;
1974+
18721975 // Delete button
1873- if ( e . target . classList . contains ( 'del-btn' ) ) {
1874- const item = e . target . closest ( '.session-item' ) ;
1875- if ( ! item ) return ;
1876- const sid = item . dataset . id ;
1877- if ( ! sid ) return ;
1976+ if ( e . target . closest ( '.del-btn' ) ) {
18781977 e . stopPropagation ( ) ;
18791978 pendingDeleteId = sid ;
18801979 document . getElementById ( 'confirm-msg' ) . textContent = 'Delete session ' + sid . slice ( 0 , 8 ) + '...?' ;
@@ -1883,19 +1982,73 @@ sessionListEl.addEventListener('click', (e) => {
18831982 }
18841983
18851984 // Rename button
1886- if ( e . target . classList . contains ( 'rename-btn' ) ) return ; // handled by inline onclick
1985+ if ( e . target . closest ( '.rename-btn' ) ) {
1986+ e . stopPropagation ( ) ;
1987+ renameSession ( sid , e . target ) ;
1988+ return ;
1989+ }
18871990
1888- // Load and render session
1889- const item = e . target . closest ( '.session-item' ) ;
1890- if ( ! item ) return ;
1891- const sid = item . dataset . id ;
1892- if ( ! sid || sid === sessionId ) return ;
1991+ // Load and render session (click on the item body)
1992+ if ( e . target . closest ( '.si-body' ) ) {
1993+ if ( sid === sessionId ) return ;
1994+ sessionListEl . querySelectorAll ( '.session-item' ) . forEach ( s => s . classList . remove ( 'active' ) ) ;
1995+ item . classList . add ( 'active' ) ;
1996+ loadAndRenderSession ( sid ) ;
1997+ }
1998+ } ) ;
18931999
1894- sessionListEl . querySelectorAll ( '.session-item' ) . forEach ( s => s . classList . remove ( 'active' ) ) ;
1895- item . classList . add ( 'active' ) ;
2000+ // updateActiveSessionItem syncs the .active marker with the current
2001+ // sessionId without re-rendering the list.
2002+ function updateActiveSessionItem ( ) {
2003+ sessionListEl . querySelectorAll ( '.session-item' ) . forEach ( el => {
2004+ el . classList . toggle ( 'active' , el . dataset . id === sessionId ) ;
2005+ } ) ;
2006+ }
18962007
1897- loadAndRenderSession ( sid ) ;
1898- } ) ;
2008+ async function loadSessions ( ) {
2009+ // Skeleton rows only on cold start (empty list); refreshes keep the
2010+ // existing items so scroll position and hover state survive.
2011+ if ( ! sessionListEl . querySelector ( '.session-item' ) ) {
2012+ sessionListEl . innerHTML = '<div class="session-skel"></div>' . repeat ( 3 ) ;
2013+ }
2014+ try {
2015+ const resp = await fetch ( '/api/sessions' , { headers : apiHeaders ( ) } ) ;
2016+ const sessions = await resp . json ( ) ;
2017+ if ( ! sessions || ! Array . isArray ( sessions ) ) {
2018+ sessionListEl . querySelectorAll ( '.session-skel' ) . forEach ( el => el . remove ( ) ) ;
2019+ return ;
2020+ }
2021+ allSessions = sessions ;
2022+
2023+ // Skip the full re-render when nothing changed — this runs after every
2024+ // turn, and re-rendering would steal scroll position and hover state.
2025+ const sig = sessions . map ( s => [ s . id , s . task , s . turns , s . updated_at , s . model ] . join ( '|' ) ) . join ( '\n' ) ;
2026+ if ( sig === sessionsSig ) {
2027+ updateActiveSessionItem ( ) ;
2028+ return ;
2029+ }
2030+ sessionsSig = sig ;
2031+
2032+ sessionListEl . innerHTML = sessions . map ( s =>
2033+ `<div class="session-item${ s . id === sessionId ? ' active' : '' } " data-id="${ escapeAttr ( s . id ) } ">
2034+ <button class="si-body" type="button" title="Open session">
2035+ <span class="id">${ escapeHtml ( s . id . slice ( 0 , 8 ) ) } </span>
2036+ <span class="task${ ! s . task ? ' untitled' : '' } ">${ escapeHtml ( s . task || 'untitled' ) } </span>
2037+ <span class="meta">
2038+ <span>${ s . turns || 0 } turn${ s . turns !== 1 ? 's' : '' } </span><span>${ relativeTime ( s . updated_at ) } </span>
2039+ ${ s . model ? `<span class="model-chip">${ escapeHtml ( s . model ) } </span>` : '' }
2040+ </span>
2041+ </button>
2042+ <span class="si-actions">
2043+ <button class="rename-btn" type="button" title="Rename">✎</button>
2044+ <button class="del-btn" type="button" title="Delete">✕</button>
2045+ </span>
2046+ </div>`
2047+ ) . join ( '' ) ;
2048+ } catch {
2049+ sessionListEl . querySelectorAll ( '.session-skel' ) . forEach ( el => el . remove ( ) ) ;
2050+ }
2051+ }
18992052
19002053// ── Session history rendering ──
19012054// Renders the full persisted transcript on session load: user/assistant
@@ -2092,30 +2245,6 @@ sidebarSearch.addEventListener('input', () => {
20922245 } ) ;
20932246} ) ;
20942247
2095- async function loadSessions ( ) {
2096- try {
2097- const resp = await fetch ( '/api/sessions' , { headers : apiHeaders ( ) } ) ;
2098- const sessions = await resp . json ( ) ;
2099- if ( ! sessions || ! Array . isArray ( sessions ) ) return ;
2100- allSessions = sessions ;
2101-
2102- sessionListEl . innerHTML = sessions . map ( s =>
2103- `<div class="session-item${ s . id === sessionId ? ' active' : '' } " data-id="${ escapeAttr ( s . id ) } ">
2104- <div style="display:flex;align-items:center;gap:4px">
2105- <div class="id">${ escapeHtml ( s . id . slice ( 0 , 8 ) ) } </div>
2106- <span class="rename-btn" title="Rename" onclick="event.stopPropagation();renameSession('${ escapeAttr ( s . id ) } ', this)">✎</span>
2107- <span class="del-btn" title="Delete">✕</span>
2108- </div>
2109- <div class="task${ ! s . task ? ' untitled' : '' } ">${ escapeHtml ( s . task || 'untitled' ) } </div>
2110- <div class="meta">
2111- <span>${ s . turns || 0 } turn${ s . turns !== 1 ? 's' : '' } </span><span>${ relativeTime ( s . updated_at ) } </span>
2112- ${ s . model ? `<span class="model-chip">${ escapeHtml ( s . model ) } </span>` : '' }
2113- </div>
2114- </div>`
2115- ) . join ( '' ) ;
2116- } catch { /* ignore */ }
2117- }
2118-
21192248// ── Init ──
21202249// Save references so newSession() can restore the empty state after clearing.
21212250savedEmptyStateNode = document . getElementById ( 'empty-state' ) ;
0 commit comments