diff --git a/apps/gnome-extension/jarvis@qvac.local/extension.js b/apps/gnome-extension/jarvis@qvac.local/extension.js index e0e9ea1..286b465 100644 --- a/apps/gnome-extension/jarvis@qvac.local/extension.js +++ b/apps/gnome-extension/jarvis@qvac.local/extension.js @@ -105,6 +105,8 @@ class ArcOverlay { this.entry.clutter_text.connect('activate', () => { const text = this.entry.get_text().trim(); if (text) { this.onAsk?.(text); this.entry.set_text(''); } }); this.root.add_child(this.entry); this.root.add_child(this.controls); this.reducedMotion = false; + this.streamingReply = false; + this.replyFinalized = false; } attach() { Main.layoutManager.addChrome(this.root, { affectsStruts: false, trackFullscreen: false }); this.halo = new St.Widget({ style_class: 'jarvis-halo', reactive: false }); Main.layoutManager.addChrome(this.halo, { affectsStruts: false, trackFullscreen: false }); this.halo.hide(); this.hide(); } show(force = false) { @@ -123,7 +125,7 @@ class ArcOverlay { hide() { this.onTalk?.(false); this.root.visible = false; this.minimized = false; } minimize() { this.onTalk?.(false); this.root.visible = false; this.minimized = true; } toggle() { this.root.visible ? this.hide() : this.show(true); } - clear() { this.transcript.destroy_all_children(); this.thinking.text = ''; this.thinking.visible = false; this.thinkingToggle.visible = false; } + clear() { this.transcript.destroy_all_children(); this.thinking.text = ''; this.thinking.visible = false; this.thinkingToggle.visible = false; this.streamingReply = false; this.replyFinalized = false; } _followConversation() { const adjustment = this.scroll?.get_vadjustment?.() || this.scroll?.vadjustment; if (!adjustment) return; @@ -133,22 +135,24 @@ class ArcOverlay { }); } addRow(who, text) { const body = safeText(text); const row = new St.Label({ text: `${who === 'J' ? 'J' : 'YOU'} ${body}`, style_class: `jarvis-row jarvis-row-${who === 'J' ? 'jarvis' : 'user'}`, can_focus: true }); row.accessible_name = `${who === 'J' ? 'Jarvis' : 'You'}: ${body}`; if (row.clutter_text) { row.clutter_text.line_wrap = true; row.clutter_text.line_wrap_mode = Pango.WrapMode.WORD_CHAR; row.clutter_text.ellipsize = Pango.EllipsizeMode.NONE; } this.transcript.add_child(row); if (this.transcript.get_n_children() > 100) this.transcript.get_first_child().destroy(); this.show(); this._followConversation(); return row; } - token(text) { const chunk = safeText(text); if (!chunk) return; let row = this.transcript.get_last_child?.(); if (!row || !String(row.style_class || '').includes('jarvis-row-jarvis')) { row = this.addRow('J', ''); } row.text = `${row.text}${chunk}`; row.accessible_name = `Jarvis: ${row.text}`; this.show(); this._followConversation(); } + token(text) { const chunk = safeText(text); if (!chunk) return; let row = this.transcript.get_last_child?.(); if (!this.streamingReply || !row || !String(row.style_class || '').includes('jarvis-row-jarvis')) { row = this.addRow('J', ''); this.streamingReply = true; this.replyFinalized = false; } row.text = `${row.text}${chunk}`; row.accessible_name = `Jarvis: ${row.text}`; this.show(); this._followConversation(); } updateThinking(text) { const chunk = safeText(text); if (!chunk) return; this.thinking.text = `${this.thinking.text || ''}${chunk}`; this.thinkingToggle.visible = true; this.thinking.visible = true; this.thinkingToggle.label = 'Thinking ▾'; this.show(); } toggleThinking() { this.thinking.visible = !this.thinking.visible; this.thinkingToggle.label = this.thinking.visible ? 'Thinking ▾' : 'Thinking ▸'; } finishThinking() { if (this.thinking.text) { this.thinkingToggle.visible = true; this.thinkingToggle.label = 'Thinking ▸'; this.thinking.visible = false; } } - addToolCall(json) { try { const call = JSON.parse(json); this.addRow('J', `Using ${safeText(call.name || 'tool')}…`); } catch { this.addRow('J', `Using ${safeText(json)}…`); } this.finishThinking(); } - addToolResult(json) { try { const result = JSON.parse(json); this.addRow('J', `${safeText(result.name || 'tool')} complete`); } catch { this.addRow('J', 'Tool complete'); } this.finishThinking(); } + addToolCall(json) { this.streamingReply = false; try { const call = JSON.parse(json); this.addRow('J', `Using ${safeText(call.name || 'tool')}…`); } catch { this.addRow('J', `Using ${safeText(json)}…`); } this.finishThinking(); } + addToolResult(json) { this.streamingReply = false; try { const result = JSON.parse(json); this.addRow('J', `${safeText(result.name || 'tool')} complete`); } catch { this.addRow('J', 'Tool complete'); } this.finishThinking(); } finalizeReply(text) { this.finishThinking(); const spoken = safeText(text); + if (this.replyFinalized) return; let row = this.transcript.get_last_child?.(); - if (row && String(row.style_class || '').includes('jarvis-row-jarvis')) { + if (this.streamingReply && row && String(row.style_class || '').includes('jarvis-row-jarvis')) { const body = String(row.text || '').replace(/^J\s+/, ''); if (spoken && !body.trim()) { row.text = `J ${spoken}`; row.accessible_name = `Jarvis: ${spoken}`; } - this.show(); return; + this.streamingReply = false; this.replyFinalized = true; this.show(); return; } if (spoken) this.addRow('J', spoken); + this.replyFinalized = true; } setConnectionStatus(kind) { const labels = { local: 'LOCAL', offline: 'LOCAL · daemon unavailable', 'voice-unavailable': 'LOCAL · voice unavailable' }; diff --git a/test/gnome-extension.test.js b/test/gnome-extension.test.js index 43bf891..dcf64e0 100644 --- a/test/gnome-extension.test.js +++ b/test/gnome-extension.test.js @@ -79,6 +79,16 @@ test('Reply finalizes a streaming row instead of duplicating [object Object]', ( assert.doesNotMatch(overlay.transcript.children[0].text, /object Object/); }); +test('final reply is shown after tool result rows', () => { + const { ArcOverlay } = harness(); + const overlay = new ArcOverlay(); + overlay.addToolCall('{"name":"runtime_status"}'); + overlay.addToolResult('{"name":"runtime_status"}'); + overlay.finalizeReply('Your computer is ready.'); + assert.equal(overlay.transcript.children.length, 3); + assert.match(overlay.transcript.children[2].text, /Your computer is ready/); +}); + test('addRow and token coerce objects to readable text', () => { const { ArcOverlay } = harness(); const overlay = new ArcOverlay();