@@ -39,6 +39,26 @@ function clearSessionToken(sid) {
3939 localStorage . removeItem ( 'odek_session_token_' + sid ) ;
4040}
4141
42+ // ensureSessionToken returns a stored token for sid, bootstrapping one from
43+ // the server (GET /api/sessions/<id>) when missing. Used by session REST
44+ // mutations (rename/delete) whose tokens may predate this browser. Returns
45+ // '' on failure — the server will answer 401 if a token was required.
46+ async function ensureSessionToken ( sid ) {
47+ let token = getSessionToken ( sid ) ;
48+ if ( token ) return token ;
49+ try {
50+ const bootstrap = await fetch ( '/api/sessions/' + encodeURIComponent ( sid ) , {
51+ headers : apiHeaders ( )
52+ } ) ;
53+ if ( bootstrap . ok ) {
54+ const bs = await bootstrap . json ( ) ;
55+ token = bootstrap . headers . get ( 'X-Session-Token' ) || bs . auth_token ;
56+ if ( token ) setSessionToken ( sid , token ) ;
57+ }
58+ } catch { /* continue — server will return 401 if token required */ }
59+ return token || '' ;
60+ }
61+
4262// ── DOM ──
4363const messagesEl = document . getElementById ( 'messages' ) ;
4464const promptEl = document . getElementById ( 'prompt' ) ;
@@ -47,7 +67,6 @@ const completionEl = document.getElementById('completion');
4767const statusEl = document . getElementById ( 'ws-status' ) ;
4868const dotEl = document . getElementById ( 'ws-dot' ) ;
4969const modelLabel = document . getElementById ( 'model-label' ) ;
50- const statsBar = document . getElementById ( 'stats-bar' ) ;
5170const sessionListEl = document . getElementById ( 'session-list' ) ;
5271const sidebarSearch = document . getElementById ( 'sidebar-search' ) ;
5372const emptyState = document . getElementById ( 'empty-state' ) ;
@@ -73,6 +92,24 @@ let inToolGroup = false;
7392// Timestamps for tool latency (name → start ms, queue-based like above).
7493const toolStartQueues = new Map ( ) ;
7594
95+ // resetTurnState clears all per-turn streaming/tool/sub-agent state. Called
96+ // before a new turn (send), on new session, and when loading a session.
97+ function resetTurnState ( ) {
98+ streamBuffer = '' ;
99+ if ( streamRAF ) {
100+ cancelAnimationFrame ( streamRAF ) ;
101+ streamRAF = null ;
102+ }
103+ streamBubbleEl = null ;
104+ streamContentEl = null ;
105+ currentToolBlock = null ;
106+ subagentGroup = null ;
107+ thinkingContentEl = null ;
108+ toolBlockQueues . clear ( ) ;
109+ toolStartQueues . clear ( ) ;
110+ inToolGroup = false ;
111+ }
112+
76113// ── Sub-agent state ──
77114let subagentGroup = null ;
78115
@@ -173,10 +210,10 @@ function formatNum(n) {
173210 return String ( n ) ;
174211}
175212
176- // ── Mesage cap ──
213+ // ── Message cap ──
177214const MAX_MESSAGES = 80 ;
178215function pruneMessages ( ) {
179- const items = messagesEl . querySelectorAll ( ':scope > .msg, :scope > .tool-block, :scope > .subagent-group, :scope > .thinking-block, :scope > .typing-indicator ' ) ;
216+ const items = messagesEl . querySelectorAll ( ':scope > .msg, :scope > .tool-block, :scope > .subagent-group, :scope > .thinking-block' ) ;
180217 if ( items . length > MAX_MESSAGES ) {
181218 for ( let i = 0 , n = items . length - MAX_MESSAGES ; i < n ; i ++ ) {
182219 items [ i ] . remove ( ) ;
@@ -190,22 +227,6 @@ promptEl.addEventListener('input', () => {
190227 promptEl . style . height = Math . min ( promptEl . scrollHeight , 200 ) + 'px' ;
191228} ) ;
192229
193- // ── Particles ──
194- function initParticles ( ) {
195- const el = document . getElementById ( 'particles' ) ;
196- if ( ! el ) return ;
197- for ( let i = 0 ; i < 15 ; i ++ ) {
198- const p = document . createElement ( 'div' ) ;
199- p . className = 'particle' ;
200- p . style . left = Math . random ( ) * 100 + '%' ;
201- p . style . animationDelay = Math . random ( ) * 6 + 's' ;
202- p . style . animationDuration = ( 4 + Math . random ( ) * 4 ) + 's' ;
203- p . style . width = p . style . height = ( 1 + Math . random ( ) * 3 ) + 'px' ;
204- el . appendChild ( p ) ;
205- }
206- }
207- initParticles ( ) ;
208-
209230// ── WebSocket ──
210231function getWsToken ( ) {
211232 const meta = document . querySelector ( 'meta[name="odek-ws-token"]' ) ;
@@ -233,7 +254,6 @@ function connect() {
233254 dotEl . className = 'dot connected' ;
234255 statusEl . textContent = 'connected' ;
235256 sendBtn . disabled = false ;
236- statsBar . textContent = '' ;
237257 // Hide loading skeleton when connected
238258 if ( skeletonEl ) skeletonEl . classList . remove ( 'visible' ) ;
239259 } ;
@@ -538,15 +558,6 @@ function addSystemMessage(content) {
538558 scrollBottom ( ) ;
539559}
540560
541- function addTypingIndicator ( ) {
542- const el = document . createElement ( 'div' ) ;
543- el . className = 'typing-indicator' ;
544- el . innerHTML = '<div class="typing-dot"></div><div class="typing-dot"></div><div class="typing-dot"></div>' ;
545- messagesEl . appendChild ( el ) ;
546- scrollBottom ( ) ;
547- return el ;
548- }
549-
550561function addDivider ( text ) {
551562 const el = document . createElement ( 'div' ) ;
552563 el . className = 'msg-divider' ;
@@ -906,8 +917,7 @@ window.showApprovalDialog = function(event) {
906917 frictionMsg = document . createElement ( 'div' ) ;
907918 frictionMsg . id = 'approval-friction-msg' ;
908919 frictionMsg . style . cssText = 'color: var(--accent); font-size: 13px; margin-top: 8px;' ;
909- overlay . querySelector ( '.approval-dialog' ) ?. appendChild ( frictionMsg )
910- || document . getElementById ( 'approval-actions' ) . parentNode . insertBefore ( frictionMsg , document . getElementById ( 'approval-actions' ) ) ;
920+ document . getElementById ( 'approval-actions' ) . parentNode . insertBefore ( frictionMsg , document . getElementById ( 'approval-actions' ) ) ;
911921 }
912922 frictionMsg . textContent =
913923 `⚠️ You have approved ${ event . friction_approvals || 0 } ${ event . risk } operations in the last minute. ` +
@@ -968,19 +978,7 @@ window.executeDeleteSession = async function() {
968978 pendingDeleteId = null ;
969979 document . getElementById ( 'confirm-overlay' ) . classList . remove ( 'active' ) ;
970980
971- let token = getSessionToken ( sid ) ;
972- if ( ! token ) {
973- try {
974- const bootstrap = await fetch ( '/api/sessions/' + encodeURIComponent ( sid ) , {
975- headers : apiHeaders ( )
976- } ) ;
977- if ( bootstrap . ok ) {
978- const bs = await bootstrap . json ( ) ;
979- token = bootstrap . headers . get ( 'X-Session-Token' ) || bs . auth_token ;
980- if ( token ) setSessionToken ( sid , token ) ;
981- }
982- } catch { /* continue — server will return 401 if token required */ }
983- }
981+ const token = await ensureSessionToken ( sid ) ;
984982
985983 fetch ( '/api/sessions/' + encodeURIComponent ( sid ) , {
986984 method : 'DELETE' ,
@@ -1114,19 +1112,7 @@ window.renameSession = async function(sid, el) {
11141112 const newName = prompt ( 'Rename session:' , currentName ) ;
11151113 if ( ! newName || newName === currentName ) return ;
11161114
1117- let token = getSessionToken ( sid ) ;
1118- if ( ! token ) {
1119- try {
1120- const bootstrap = await fetch ( '/api/sessions/' + encodeURIComponent ( sid ) , {
1121- headers : apiHeaders ( )
1122- } ) ;
1123- if ( bootstrap . ok ) {
1124- const bs = await bootstrap . json ( ) ;
1125- token = bootstrap . headers . get ( 'X-Session-Token' ) || bs . auth_token ;
1126- if ( token ) setSessionToken ( sid , token ) ;
1127- }
1128- } catch { /* continue — server will return 401 if token required */ }
1129- }
1115+ const token = await ensureSessionToken ( sid ) ;
11301116
11311117 fetch ( '/api/sessions/' + encodeURIComponent ( sid ) , {
11321118 method : 'POST' ,
@@ -1229,11 +1215,7 @@ window.newSession = function() {
12291215 sessionId = null ;
12301216
12311217 // Reset all streaming + tool state.
1232- streamBuffer = '' ;
1233- if ( streamRAF ) { cancelAnimationFrame ( streamRAF ) ; streamRAF = null ; }
1234- streamBubbleEl = null ; streamContentEl = null ;
1235- currentToolBlock = null ; subagentGroup = null ; thinkingContentEl = null ;
1236- toolBlockQueues . clear ( ) ; toolStartQueues . clear ( ) ; inToolGroup = false ;
1218+ resetTurnState ( ) ;
12371219 busy = false ;
12381220 hideLoading ( ) ; hideCancel ( ) ;
12391221 sendBtn . disabled = ! ws || ws . readyState !== WebSocket . OPEN ;
@@ -1276,7 +1258,8 @@ function escapeAttr(s) {
12761258 if ( ! s ) return '' ;
12771259 // & must be replaced first — doing it last double-escapes the entities
12781260 // introduced by the quote replacements (" → &quot;).
1279- return s . replace ( / & / g, '&' ) . replace ( / " / g, '"' ) . replace ( / ' / g, ''' ) ;
1261+ return s . replace ( / & / g, '&' ) . replace ( / " / g, '"' ) . replace ( / ' / g, ''' )
1262+ . replace ( / < / g, '<' ) . replace ( / > / g, '>' ) ;
12801263}
12811264
12821265// ── Markdown to HTML (safe, no DOMPurify needed since we control input) ──
@@ -1346,30 +1329,38 @@ function markdownToHtml(text) {
13461329 return html ;
13471330}
13481331
1349- // ── Copy code ──
1332+ // ── Copy helpers ──
1333+ // copyTextToClipboard writes text to the clipboard, falling back to the
1334+ // legacy execCommand path when the async Clipboard API is unavailable or
1335+ // denied (non-secure contexts). Returns a Promise.
1336+ function copyTextToClipboard ( text ) {
1337+ const fallback = ( ) => {
1338+ const ta = document . createElement ( 'textarea' ) ;
1339+ ta . value = text ;
1340+ document . body . appendChild ( ta ) ;
1341+ ta . select ( ) ;
1342+ document . execCommand ( 'copy' ) ;
1343+ document . body . removeChild ( ta ) ;
1344+ } ;
1345+ if ( ! navigator . clipboard || ! navigator . clipboard . writeText ) {
1346+ fallback ( ) ;
1347+ return Promise . resolve ( ) ;
1348+ }
1349+ return navigator . clipboard . writeText ( text ) . catch ( fallback ) ;
1350+ }
1351+
13501352window . copyCode = function ( el ) {
13511353 const block = el . closest ( '.code-block' ) ;
13521354 if ( ! block ) return ;
13531355 const code = block . querySelector ( 'pre code' ) ;
13541356 if ( ! code ) return ;
1355- const text = code . textContent ;
1356- navigator . clipboard . writeText ( text ) . then ( ( ) => {
1357+ copyTextToClipboard ( code . textContent ) . then ( ( ) => {
13571358 el . textContent = '✓ copied' ;
13581359 el . classList . add ( 'copied' ) ;
13591360 setTimeout ( ( ) => {
13601361 el . textContent = '📋 copy' ;
13611362 el . classList . remove ( 'copied' ) ;
13621363 } , 2000 ) ;
1363- } ) . catch ( ( ) => {
1364- // Fallback
1365- const ta = document . createElement ( 'textarea' ) ;
1366- ta . value = text ;
1367- document . body . appendChild ( ta ) ;
1368- ta . select ( ) ;
1369- document . execCommand ( 'copy' ) ;
1370- document . body . removeChild ( ta ) ;
1371- el . textContent = '✓ copied' ;
1372- setTimeout ( ( ) => { el . textContent = '📋 copy' ; } , 2000 ) ;
13731364 } ) ;
13741365} ;
13751366
@@ -1403,19 +1394,7 @@ function send() {
14031394 addMessage ( 'user' , display ) ;
14041395
14051396 // Reset streaming + tool state for the new turn.
1406- streamBuffer = '' ;
1407- if ( streamRAF ) {
1408- cancelAnimationFrame ( streamRAF ) ;
1409- streamRAF = null ;
1410- }
1411- streamBubbleEl = null ;
1412- streamContentEl = null ;
1413- currentToolBlock = null ;
1414- subagentGroup = null ;
1415- thinkingContentEl = null ;
1416- toolBlockQueues . clear ( ) ;
1417- toolStartQueues . clear ( ) ;
1418- inToolGroup = false ;
1397+ resetTurnState ( ) ;
14191398
14201399 busy = true ;
14211400 sendBtn . disabled = true ;
@@ -1769,11 +1748,7 @@ async function loadAndRenderSession(sid) {
17691748 sessionId = sid ;
17701749
17711750 // Clear current messages and reset all streaming state.
1772- streamBuffer = '' ;
1773- if ( streamRAF ) { cancelAnimationFrame ( streamRAF ) ; streamRAF = null ; }
1774- streamBubbleEl = null ; streamContentEl = null ;
1775- currentToolBlock = null ; subagentGroup = null ; thinkingContentEl = null ;
1776- toolBlockQueues . clear ( ) ; toolStartQueues . clear ( ) ; inToolGroup = false ;
1751+ resetTurnState ( ) ;
17771752 busy = false ; hideLoading ( ) ; hideCancel ( ) ;
17781753 sendBtn . disabled = ! ws || ws . readyState !== WebSocket . OPEN ;
17791754 promptEl . disabled = false ;
@@ -1972,14 +1947,14 @@ window.copyMessage = function(btn, content) {
19721947 }
19731948 }
19741949 if ( ! content ) return ;
1975- navigator . clipboard . writeText ( content ) . then ( function ( ) {
1950+ copyTextToClipboard ( content ) . then ( function ( ) {
19761951 btn . classList . add ( 'copied' ) ;
19771952 btn . innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg> Copied' ;
19781953 setTimeout ( function ( ) {
19791954 btn . classList . remove ( 'copied' ) ;
19801955 btn . innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>' ;
19811956 } , 2000 ) ;
1982- } ) . catch ( function ( ) { } ) ;
1957+ } ) ;
19831958} ;
19841959
19851960// ── Phase 1: Add copy buttons to rendered messages ──
@@ -1994,9 +1969,11 @@ function addCopyButton(bubble) {
19941969}
19951970
19961971// ── Phase 1: Collapse long messages after rendering ──
1972+ // Must match the max-height of .bubble.collapsible in style.css.
1973+ const COLLAPSE_MAX_HEIGHT_PX = 460 ;
19971974function checkCollapse ( bubble ) {
19981975 var content = bubble . querySelector ( '.content' ) ;
1999- if ( ! content || content . scrollHeight <= 500 ) return ;
1976+ if ( ! content || content . scrollHeight <= COLLAPSE_MAX_HEIGHT_PX ) return ;
20001977 bubble . classList . add ( 'collapsible' ) ;
20011978 var toggle = document . createElement ( 'div' ) ;
20021979 toggle . className = 'collapse-toggle' ;
0 commit comments