@@ -50,19 +50,22 @@ const hubCount = document.getElementById('hubCount');
5050const hubEl = document . getElementById ( 'hub' ) ;
5151const hubOverlay = document . getElementById ( 'hub-overlay' ) ;
5252const hubList = document . getElementById ( 'hubList' ) ;
53+ const jumpLatestBtn = document . getElementById ( 'jumpLatest' ) ;
5354
5455// ── 2. State ─────────────────────────────────────────────────────────
5556
5657let ws = null ;
5758let streaming = false ;
58- let activeSessionId = null ;
59+ let activeSessionId = localStorage . getItem ( 'chat-active-session' ) ;
5960let reconnectTimer = null ;
6061let promptWatchdog = null ;
6162let lastState = null ; // last state_update (model, thinkingLevel, …)
6263let modelCache = null ; // last model_list payload
6364let caps = { models : false , thinking : false , subagents : false , transcripts : false , cost : false , commands : false , abort : true } ;
6465let COMMANDS = [ ] ; // adapter-provided slash commands (via capabilities)
6566let hubAgents = [ ] ; // last subagent_update payload
67+ let followTail = true ; // false while the user is reading older output
68+ let restoringHistory = false ;
6669
6770// In-flight assistant message being streamed.
6871let agentEl = null ; // bubble element
@@ -86,7 +89,80 @@ function resetAgent() {
8689 agentEl = null ; textEl = null ; thinkEl = null ; pendingMd = '' ;
8790}
8891
89- function scrollBottom ( ) { chat . scrollTop = chat . scrollHeight ; }
92+ function isNearBottom ( ) {
93+ return chat . scrollHeight - chat . scrollTop - chat . clientHeight < 72 ;
94+ }
95+
96+ function updateFollowTail ( ) {
97+ if ( restoringHistory ) return ;
98+ followTail = isNearBottom ( ) ;
99+ jumpLatestBtn . classList . toggle ( 'visible' , ! followTail ) ;
100+ }
101+
102+ function scrollBottom ( force = false ) {
103+ if ( ! force && ! followTail ) {
104+ jumpLatestBtn . classList . add ( 'visible' ) ;
105+ return ;
106+ }
107+ chat . scrollTop = chat . scrollHeight ;
108+ followTail = true ;
109+ jumpLatestBtn . classList . remove ( 'visible' ) ;
110+ }
111+
112+ function jumpToLatest ( ) {
113+ scrollBottom ( true ) ;
114+ }
115+
116+ function draftKey ( sessionId = activeSessionId ) {
117+ return sessionId ? `chat-draft:${ sessionId } ` : null ;
118+ }
119+
120+ function persistDraft ( ) {
121+ const key = draftKey ( ) ;
122+ if ( ! key ) return ;
123+ if ( input . value ) localStorage . setItem ( key , input . value ) ;
124+ else localStorage . removeItem ( key ) ;
125+ }
126+
127+ function restoreDraft ( ) {
128+ input . value = localStorage . getItem ( draftKey ( ) ) || '' ;
129+ input . style . height = 'auto' ;
130+ input . style . height = Math . min ( input . scrollHeight , 120 ) + 'px' ;
131+ }
132+
133+ async function copyText ( text , button ) {
134+ if ( ! text ) return ;
135+ try {
136+ await navigator . clipboard . writeText ( text ) ;
137+ } catch {
138+ const helper = document . createElement ( 'textarea' ) ;
139+ helper . value = text ;
140+ helper . style . position = 'fixed' ;
141+ helper . style . opacity = '0' ;
142+ document . body . appendChild ( helper ) ;
143+ helper . select ( ) ;
144+ document . execCommand ( 'copy' ) ;
145+ helper . remove ( ) ;
146+ }
147+ const old = button . textContent ;
148+ button . textContent = 'Copied' ;
149+ button . classList . add ( 'copied' ) ;
150+ setTimeout ( ( ) => { button . textContent = old ; button . classList . remove ( 'copied' ) ; } , 1200 ) ;
151+ }
152+
153+ function addMessageActions ( el , text ) {
154+ if ( ! text ) return ;
155+ const actions = document . createElement ( 'div' ) ;
156+ actions . className = 'message-actions' ;
157+ const button = document . createElement ( 'button' ) ;
158+ button . className = 'copy-btn' ;
159+ button . type = 'button' ;
160+ button . textContent = 'Copy' ;
161+ button . setAttribute ( 'aria-label' , 'Copy message' ) ;
162+ button . onclick = ( ) => copyText ( text , button ) ;
163+ actions . appendChild ( button ) ;
164+ el . appendChild ( actions ) ;
165+ }
90166
91167function saveNick ( v ) {
92168 nick = ( v || '' ) . trim ( ) . slice ( 0 , 20 ) || nick ;
@@ -205,6 +281,7 @@ function userMsg(text, who) {
205281 el . className = 'msg theirs' ;
206282 el . innerHTML = `<span class="nick">${ esc ( who ) } </span>${ esc ( text ) } ` ;
207283 }
284+ addMessageActions ( el , text ) ;
208285 chat . appendChild ( el ) ;
209286 scrollBottom ( ) ;
210287}
@@ -224,6 +301,15 @@ function renderText(delta) {
224301 agentBubble ( ) ;
225302 pendingMd += delta ;
226303 textEl . innerHTML = md ( pendingMd ) ;
304+ let actions = agentEl . querySelector ( '.message-actions' ) ;
305+ if ( ! actions ) {
306+ actions = document . createElement ( 'div' ) ;
307+ actions . className = 'message-actions' ;
308+ actions . innerHTML = '<button class="copy-btn" type="button" aria-label="Copy message">Copy</button>' ;
309+ agentEl . appendChild ( actions ) ;
310+ }
311+ const messageText = pendingMd ;
312+ actions . querySelector ( 'button' ) . onclick = ( e ) => copyText ( messageText , e . currentTarget ) ;
227313 scrollBottom ( ) ;
228314}
229315
@@ -247,7 +333,7 @@ function renderToolCard(name, params) {
247333 <div class="hd"><span class="icon">⚙</span><span class="name">${ esc ( name ) } </span><span class="st">running…</span></div>
248334 <div class="bd">
249335 ${ paramsStr ? `<div class="tool-params"><span class="lbl">params</span><pre>${ esc ( paramsStr ) } </pre></div>` : '' }
250- <div class="tool-result"><span class="lbl">result</span><pre>waiting…</pre></div>
336+ <div class="tool-result"><div class="tool-label-row">< span class="lbl">result</span><button class="copy-btn" type="button" aria-label="Copy tool result" disabled>Copy</button></div ><pre>waiting…</pre></div>
251337 </div>` ;
252338 card . querySelector ( '.hd' ) . onclick = ( ) => card . classList . toggle ( 'open' ) ;
253339 chat . appendChild ( card ) ;
@@ -262,6 +348,10 @@ function finishToolCard(result) {
262348 last . querySelector ( '.st' ) . classList . add ( 'done' ) ;
263349 const out = last . querySelector ( '.tool-result pre' ) ;
264350 if ( ! out ) return ;
351+ const copyButton = last . querySelector ( '.tool-result .copy-btn' ) ;
352+ const resultText = result || '(no output)' ;
353+ copyButton . disabled = false ;
354+ copyButton . onclick = ( ) => copyText ( resultText , copyButton ) ;
265355 if ( result ) {
266356 // Markdown-render text-like results, truncate long ones
267357 if ( result . length < 2000 && ( result . includes ( '\n' ) || result . includes ( '`' ) || result . includes ( '**' ) ) ) {
@@ -318,6 +408,7 @@ function clearChat() {
318408}
319409
320410function loadHistory ( messages ) {
411+ restoringHistory = true ;
321412 clearChat ( ) ;
322413 for ( const m of messages ) {
323414 if ( m . role === 'user' ) {
@@ -326,10 +417,12 @@ function loadHistory(messages) {
326417 agentBubble ( ) ;
327418 pendingMd = m . content ;
328419 textEl . innerHTML = md ( pendingMd ) ;
420+ addMessageActions ( agentEl , m . content ) ;
329421 resetAgent ( ) ;
330422 }
331423 }
332- chat . scrollTop = chat . scrollHeight ;
424+ restoringHistory = false ;
425+ scrollBottom ( true ) ;
333426}
334427
335428// ── 7. Agent Hub (R14 + transcripts) ────────────────────────────────
@@ -678,8 +771,11 @@ function handleEvent(msg) {
678771 renderSessionList ( lastSessions ) ;
679772 break ;
680773 case 'session_switched' :
774+ persistDraft ( ) ;
681775 activeSessionId = msg . sessionId ;
776+ localStorage . setItem ( 'chat-active-session' , activeSessionId ) ;
682777 clearChat ( ) ;
778+ restoreDraft ( ) ;
683779 // Per-session state from the previous session must not leak in
684780 hubAgents = [ ] ;
685781 hubView = { mode : 'list' , agentId : null } ;
@@ -871,6 +967,7 @@ function send() {
871967 return ;
872968 }
873969 input . value = '' ;
970+ persistDraft ( ) ;
874971 input . style . height = 'auto' ;
875972 userMsg ( t , nick ) ;
876973 ws . send ( JSON . stringify ( {
@@ -893,7 +990,7 @@ function stop() {
893990input . addEventListener ( 'keydown' , ( e ) => {
894991 const pickerOpen = pickerEl . classList . contains ( 'visible' ) ;
895992 const cmdOpen = cmdList . classList . contains ( 'visible' ) ;
896- if ( e . key === 'Enter' && ! e . shiftKey ) {
993+ if ( e . key === 'Enter' && ! e . shiftKey && ! e . isComposing && e . keyCode !== 229 ) {
897994 e . preventDefault ( ) ;
898995 if ( pickerOpen ) pickerAccept ( ) ;
899996 else if ( cmdOpen ) cmdAccept ( ) ;
@@ -917,13 +1014,25 @@ input.addEventListener('keydown', (e) => {
9171014} ) ;
9181015
9191016input . addEventListener ( 'input' , ( ) => {
1017+ persistDraft ( ) ;
9201018 input . style . height = 'auto' ;
9211019 input . style . height = Math . min ( input . scrollHeight , 120 ) + 'px' ;
9221020 if ( maybeShowPicker ( input . value ) ) return ;
9231021 hidePicker ( ) ;
9241022 showAutocomplete ( input . value ) ;
9251023} ) ;
9261024
1025+ chat . addEventListener ( 'scroll' , updateFollowTail , { passive : true } ) ;
1026+ chat . addEventListener ( 'click' , ( e ) => {
1027+ const button = e . target . closest ( '[data-copy="code"]' ) ;
1028+ if ( ! button ) return ;
1029+ const code = button . closest ( '.code-block' ) ?. querySelector ( 'code' ) ?. textContent || '' ;
1030+ copyText ( code , button ) ;
1031+ } ) ;
1032+ document . addEventListener ( 'visibilitychange' , ( ) => {
1033+ if ( document . hidden ) persistDraft ( ) ;
1034+ } ) ;
1035+
9271036// ── init ─────────────────────────────────────────────────────────────
9281037
9291038connect ( ) ;
0 commit comments