From 04a9306594f32ce24fbdbaab350501e36c784ab4 Mon Sep 17 00:00:00 2001 From: Raven Scott Date: Sat, 12 Sep 2026 16:09:52 -0400 Subject: [PATCH] Fix Hud --- .../jarvis@qvac.local/extension.js | 5 +- apps/gnome-extension/jarvis@qvac.local/ui.js | 101 +++++++++++++++--- test/gnome-extension.test.js | 15 +++ 3 files changed, 108 insertions(+), 13 deletions(-) diff --git a/apps/gnome-extension/jarvis@qvac.local/extension.js b/apps/gnome-extension/jarvis@qvac.local/extension.js index 1feaee9..1f8dc86 100644 --- a/apps/gnome-extension/jarvis@qvac.local/extension.js +++ b/apps/gnome-extension/jarvis@qvac.local/extension.js @@ -137,7 +137,10 @@ export default class JarvisExtension extends Extension { settingsItem.connect('activate', () => this._openSettings()); menu.addMenuItem(settingsItem); } catch {} - this._menuState = menu.connect('open-state-changed', (_menu, open) => { if (!open) this.popup.endTalk(); }); + this._menuState = menu.connect('open-state-changed', (_menu, open) => { + if (!open) this.popup.endTalk(); + else this.popup.followActive(); + }); } _openPopup() { try { this._indicator.menu.open(); } catch {} } _openSettings() { diff --git a/apps/gnome-extension/jarvis@qvac.local/ui.js b/apps/gnome-extension/jarvis@qvac.local/ui.js index 1346e58..6ec106a 100644 --- a/apps/gnome-extension/jarvis@qvac.local/ui.js +++ b/apps/gnome-extension/jarvis@qvac.local/ui.js @@ -177,6 +177,10 @@ export class ConversationView { this._userPickedTab = false; this._activity = ''; this.reducedMotion = false; + this._chatLayoutTimers = []; + this._thinkLayoutTimers = []; + this._bindFollow(this.scroll); + this._bindFollow(this.thinkingScroll); } _bindChip(button, action) { button.connect('clicked', () => action?.()); @@ -204,16 +208,71 @@ export class ConversationView { this.showTab('chat'); } setNotice(text) { const body = shortError(text); this.notice.text = body; this.notice.visible = Boolean(body); } - _followConversation() { this._followScroll(this.scroll); } - _followThinking() { this._followScroll(this.thinkingScroll); } - _followScroll(scroll) { + _bindFollow(scroll) { + const adjustment = scroll?.get_vadjustment?.() || scroll?.vadjustment; + if (!adjustment || typeof adjustment.connect !== 'function') return; + scroll._jarvisFollow = true; + adjustment.connect('notify::value', () => { + if (this._pinning) return; + scroll._jarvisFollow = this._nearBottom(adjustment); + }); + adjustment.connect('notify::upper', () => { + if (scroll.visible === false) return; + if (scroll._jarvisFollow !== false) this._pinScroll(scroll, { force: true }); + }); + } + _nearBottom(adjustment) { + const upper = Number(adjustment?.upper) || 0; + const page = Number(adjustment?.page_size) || 0; + const value = Number(adjustment?.value) || 0; + return value >= Math.max(0, upper - page) - 32; + } + _clearLayoutTimers(slot) { + for (const id of this[slot] || []) { + try { GLib.Source.remove(id); } catch {} + } + this[slot] = []; + } + _relayoutPane(scroll) { + try { scroll?.queue_relayout?.(); } catch {} + const child = scroll?.get_child?.() || scroll?.get_first_child?.(); + try { child?.queue_relayout?.(); } catch {} + try { child?.get_first_child?.()?.queue_relayout?.(); } catch {} + } + _pinScroll(scroll, { force = false } = {}) { const adjustment = scroll?.get_vadjustment?.() || scroll?.vadjustment; if (!adjustment) return; + if (force) scroll._jarvisFollow = true; + if (!force && scroll._jarvisFollow === false) return; + const upper = Number(adjustment.upper) || 0; + const page = Number(adjustment.page_size) || 0; + this._pinning = true; + try { adjustment.value = Math.max(0, upper - page); } catch {} + this._pinning = false; + } + _followScroll(scroll, { force = false } = {}) { + this._pinScroll(scroll, { force }); GLib.idle_add(GLib.PRIORITY_DEFAULT_IDLE || GLib.PRIORITY_DEFAULT, () => { - try { adjustment.value = Math.max(0, adjustment.upper - adjustment.page_size); } catch {} + this._pinScroll(scroll, { force }); return GLib.SOURCE_REMOVE; }); } + _followAfterLayout(scroll, { force = true } = {}) { + const slot = scroll === this.thinkingScroll ? '_thinkLayoutTimers' : '_chatLayoutTimers'; + this._relayoutPane(scroll); + this._followScroll(scroll, { force }); + this._clearLayoutTimers(slot); + this[slot] = [0, 50].map((delay) => GLib.timeout_add(GLib.PRIORITY_DEFAULT, delay, () => { + this._pinScroll(scroll, { force }); + return GLib.SOURCE_REMOVE; + })); + } + _followConversation() { this._followScroll(this.scroll); } + _followThinking() { this._followScroll(this.thinkingScroll); } + followActive() { + const scroll = this._tab === 'thinking' ? this.thinkingScroll : this.scroll; + this._followAfterLayout(scroll, { force: true }); + } showTab(name, { user = false } = {}) { if (user) this._userPickedTab = true; this._tab = name === 'thinking' ? 'thinking' : 'chat'; @@ -228,6 +287,7 @@ export class ConversationView { this.chipScroll.visible = !thinking && this.chips.get_n_children() > 0; this.entry.visible = !thinking; this.talk.visible = !thinking; + this.followActive(); } addRow(who, text) { const body = safeText(text); @@ -240,7 +300,8 @@ export class ConversationView { if (typeof this.transcript.remove_child === 'function') this.transcript.remove_child(first); else first.destroy(); } - this._followConversation(); + if (this._tab === 'chat') this._followAfterLayout(this.scroll, { force: false }); + else this._followConversation(); return row; } token(text) { @@ -255,14 +316,16 @@ export class ConversationView { } row.text = `${row.text}${chunk}`; row.accessible_name = `Jarvis: ${row.text}`; - this._followConversation(); + if (this._tab === 'chat') this._followAfterLayout(this.scroll, { force: false }); + else this._followConversation(); } updateThinking(text) { const chunk = safeText(text); if (!chunk) return; this.thinking.text = `${this.thinking.text || ''}${chunk}`; if (this._state === 'THINKING' && !this._userPickedTab) this.showTab('thinking'); - this._followThinking(); + if (this._tab === 'thinking') this._followAfterLayout(this.thinkingScroll, { force: false }); + else this._followThinking(); } toggleThinking() { this.showTab(this._tab === 'thinking' ? 'chat' : 'thinking', { user: true }); } finishThinking() { if (this._state !== 'THINKING') this.showTab('chat'); } @@ -318,7 +381,8 @@ export class ConversationView { row.text = body; } row.accessible_name = `Activity: ${body}`; - this._followConversation(); + if (this._tab === 'chat') this._followAfterLayout(this.scroll, { force: false }); + else this._followConversation(); return row; } _addToolRow(text) { return this._setToolActivity(text); } @@ -341,15 +405,22 @@ export class ConversationView { finalizeReply(text) { this.finishThinking(); const spoken = safeText(text); - if (this.replyFinalized) return; + if (this.replyFinalized) { + this.followActive(); + return; + } let row = this.transcript.get_last_child?.(); if (this.streamingReply && row && String(row.style_class || '').includes('jarvis-row-jarvis') && !String(row.style_class || '').includes('jarvis-row-tool')) { const body = String(row.text || '').replace(/^J\s+/, ''); if (spoken && !body.trim()) { row.text = `J ${spoken}`; row.accessible_name = `Jarvis: ${spoken}`; } - this.streamingReply = false; this.replyFinalized = true; return; + this.streamingReply = false; + this.replyFinalized = true; + this.followActive(); + return; } if (spoken) this.addRow('J', spoken); this.replyFinalized = true; + this.followActive(); } setConnectionStatus(kind) { this._connection = kind; @@ -382,7 +453,8 @@ export class ConversationView { this.showTab('chat'); } this._refreshStatusLine(); - this.title.text = value === 'SLEEPING' ? 'Jarvis · privacy' : 'Jarvis'; + const name = this._assistantName || 'Jarvis'; + this.title.text = value === 'SLEEPING' ? `${name} · privacy` : name; } _refreshStatusLine() { const value = this._state; @@ -416,7 +488,11 @@ export class ConversationView { } catch { this.addRow('J', json); } } endTalk() { this.onTalk?.(false); } - destroy() { this.root.destroy(); } + destroy() { + this._clearLayoutTimers('_chatLayoutTimers'); + this._clearLayoutTimers('_thinkLayoutTimers'); + this.root.destroy(); + } } const PANEL_STATES = { @@ -538,6 +614,7 @@ export class SessionPanel { this.view.thinkingScroll.set_height(Math.max(120, Math.min(240, monitor.height - 400))); } this.root.visible = true; + this.view.followActive(); } hide() { this.view.endTalk(); this.root.visible = false; this.minimized = true; } toggle() { this.root.visible ? this.hide() : this.show(true); } diff --git a/test/gnome-extension.test.js b/test/gnome-extension.test.js index ac64fc4..95ef58b 100644 --- a/test/gnome-extension.test.js +++ b/test/gnome-extension.test.js @@ -271,6 +271,9 @@ test('session panel stays closed while Jarvis speaks unless expanded', () => { session.show(true); assert.equal(session.root.visible, true); assert.match(session.view.transcript.children[0].text, /still talking/); + session.view.scroll.vadjustment.value = 0; + session.show(true); + assert.equal(session.view.scroll.vadjustment.value, 160); }); test('lazy ASR still shows the microphone as available when capture is up', () => { @@ -480,11 +483,23 @@ test('Chat and Thinking tabs exist and thinking auto-follows', () => { assert.match(view.thinking.text, /Considering the lookup/); assert.equal(view.thinking.clutter_text.ellipsize, 0); assert.equal(view.thinkingScroll.vadjustment.value, 160); + view.token('Latest reply token'); + view.scroll.vadjustment.value = 0; + view.setState('SPEAKING'); + assert.equal(view._tab, 'chat'); + assert.equal(view.scroll.visible, true); + assert.equal(view.thinkingScroll.visible, false); + assert.equal(view.scroll.vadjustment.value, 160); view.showTab('chat', { user: true }); view.updateThinking('still thinking'); assert.equal(view._tab, 'chat'); assert.equal(view.thinkingScroll.visible, false); assert.match(view.thinking.text, /still thinking/); + view.thinkingScroll.vadjustment.value = 0; + view.showTab('thinking', { user: true }); + assert.equal(view._tab, 'thinking'); + assert.equal(view.thinkingScroll.visible, true); + assert.equal(view.thinkingScroll.vadjustment.value, 160); } });