diff --git a/extension/background.js b/extension/background.js index 7e1dd6da..4998e149 100644 --- a/extension/background.js +++ b/extension/background.js @@ -378,6 +378,22 @@ chrome.runtime.onInstalled.addListener(async () => { }, 1000); }); +// ─── Tab Switch Detection ──────────────────────────────────────── +// Notify sidepanel instantly when the user switches tabs in the browser. +// This is faster than polling — the sidebar swaps chat context immediately. + +chrome.tabs.onActivated.addListener((activeInfo) => { + chrome.tabs.get(activeInfo.tabId, (tab) => { + if (chrome.runtime.lastError || !tab) return; + chrome.runtime.sendMessage({ + type: 'browserTabActivated', + tabId: activeInfo.tabId, + url: tab.url || '', + title: tab.title || '', + }).catch(() => {}); // sidepanel may not be open + }); +}); + // ─── Startup ──────────────────────────────────────────────────── // Load auth token BEFORE first health poll (token no longer in /health response) diff --git a/extension/sidepanel.css b/extension/sidepanel.css index 55c7392a..bb53efa8 100644 --- a/extension/sidepanel.css +++ b/extension/sidepanel.css @@ -262,16 +262,27 @@ body::after { } .agent-tool { display: flex; - align-items: center; - gap: 4px; - padding: 2px 6px; - background: var(--bg-base); - border: 1px solid var(--border-subtle); - border-radius: 3px; - font-size: 10px; - font-family: var(--font-mono); - overflow: hidden; + align-items: flex-start; + gap: 6px; + padding: 4px 8px; + background: rgba(245, 158, 11, 0.06); + border-left: 2px solid var(--amber-500); + border-radius: 0 4px 4px 0; + font-size: 12px; + font-family: var(--font-system); + margin: 2px 0; } +.tool-icon { + flex-shrink: 0; + font-size: 11px; + line-height: 1.5; +} +.tool-description { + color: var(--text-body); + line-height: 1.5; + word-break: break-word; +} +/* Legacy classes kept for compat */ .tool-name { color: var(--amber-500); font-weight: 600; @@ -285,9 +296,10 @@ body::after { } .agent-text { color: var(--text-body); - font-size: 11px; - line-height: 1.4; + font-size: 12.5px; + line-height: 1.5; word-break: break-word; + padding: 2px 0; } .agent-text pre { background: var(--bg-base); @@ -637,6 +649,22 @@ body::after { opacity: 0.3; cursor: not-allowed; } +.stop-btn { + width: 26px; + height: 26px; + background: var(--error); + border: none; + border-radius: var(--radius-sm); + color: #fff; + font-size: 10px; + font-weight: 700; + cursor: pointer; + flex-shrink: 0; + line-height: 26px; + text-align: center; +} +.stop-btn:hover { background: #dc2626; } +.stop-btn:active { transform: scale(0.93); } /* ─── Footer ──────────────────────────────────────────── */ footer { @@ -686,17 +714,55 @@ footer { /* ─── Experimental Banner ─────────────────────────────── */ .experimental-banner { - background: rgba(245, 158, 11, 0.15); - border: 1px solid rgba(245, 158, 11, 0.3); - color: #F59E0B; - padding: 8px 12px; + background: rgba(59, 130, 246, 0.08); + border: 1px solid rgba(59, 130, 246, 0.15); + color: var(--zinc-400); + padding: 6px 12px; border-radius: 6px; - font-size: 12px; - margin: 8px 12px; + font-size: 11px; + margin: 6px 12px; text-align: center; flex-shrink: 0; } +/* ─── Browser Tab Bar ─────────────────────────────────── */ +.browser-tabs { + display: flex; + gap: 1px; + padding: 4px 8px; + background: var(--bg-base); + border-bottom: 1px solid var(--border); + overflow-x: auto; + flex-shrink: 0; + scrollbar-width: none; +} +.browser-tabs::-webkit-scrollbar { display: none; } +.browser-tab { + padding: 4px 10px; + font-size: 11px; + font-family: var(--font-system); + color: var(--text-meta); + background: transparent; + border: 1px solid transparent; + border-radius: var(--radius-sm); + cursor: pointer; + white-space: nowrap; + max-width: 140px; + overflow: hidden; + text-overflow: ellipsis; + flex-shrink: 0; + transition: background 100ms, color 100ms; +} +.browser-tab:hover { + background: var(--bg-hover); + color: var(--text-label); +} +.browser-tab.active { + background: var(--bg-surface); + color: var(--text-body); + border-color: var(--border); +} + /* ─── Inspector Tab ──────────────────────────────────── */ .inspector-toolbar { diff --git a/extension/sidepanel.html b/extension/sidepanel.html index 8e5b8fd4..5fe73070 100644 --- a/extension/sidepanel.html +++ b/extension/sidepanel.html @@ -14,6 +14,9 @@ + + +
@@ -127,12 +130,13 @@
- + +
diff --git a/extension/sidepanel.js b/extension/sidepanel.js index 546e2fec..1ff287f7 100644 --- a/extension/sidepanel.js +++ b/extension/sidepanel.js @@ -17,6 +17,10 @@ let serverToken = null; let chatLineCount = 0; let chatPollInterval = null; let connState = 'disconnected'; // disconnected | connected | reconnecting | dead +let lastOptimisticMsg = null; // track optimistically rendered user msg to avoid dupes +let sidebarActiveTabId = null; // which browser tab's chat we're showing +const chatLineCountByTab = {}; // tabId -> last seen chatLineCount +const chatDomByTab = {}; // tabId -> saved innerHTML let reconnectAttempts = 0; let reconnectTimer = null; const MAX_RECONNECT_ATTEMPTS = 30; // 30 * 2s = 60s before showing "dead" @@ -103,8 +107,12 @@ function addChatEntry(entry) { const welcome = chatMessages.querySelector('.chat-welcome'); if (welcome) welcome.remove(); - // User messages → chat bubble + // User messages → chat bubble (skip if we already rendered it optimistically) if (entry.role === 'user') { + if (lastOptimisticMsg === entry.message) { + lastOptimisticMsg = null; // consumed — don't skip next identical msg + return; + } const bubble = document.createElement('div'); bubble.className = 'chat-bubble user'; bubble.innerHTML = `${escapeHtml(entry.message)}${formatChatTime(entry.ts)}`; @@ -136,6 +144,13 @@ function addChatEntry(entry) { function handleAgentEvent(entry) { if (entry.type === 'agent_start') { + // If we already showed thinking dots optimistically in sendMessage(), + // don't duplicate. Just ensure fast polling is on. + if (agentContainer && document.getElementById('agent-thinking')) { + startFastPoll(); + updateStopButton(true); + return; + } // Create a new agent response container agentText = ''; agentContainer = document.createElement('div'); @@ -150,6 +165,8 @@ function handleAgentEvent(entry) { thinking.innerHTML = ''; agentContainer.appendChild(thinking); agentContainer.scrollIntoView({ behavior: 'smooth', block: 'end' }); + startFastPoll(); + updateStopButton(true); return; } @@ -157,6 +174,8 @@ function handleAgentEvent(entry) { // Remove thinking indicator const thinking = document.getElementById('agent-thinking'); if (thinking) thinking.remove(); + updateStopButton(false); + stopFastPoll(); // Add timestamp if (agentContainer) { const ts = document.createElement('span'); @@ -172,6 +191,8 @@ function handleAgentEvent(entry) { if (entry.type === 'agent_error') { const thinking = document.getElementById('agent-thinking'); if (thinking) thinking.remove(); + updateStopButton(false); + stopFastPoll(); if (!agentContainer) { agentContainer = document.createElement('div'); agentContainer.className = 'agent-response'; @@ -200,7 +221,11 @@ function handleAgentEvent(entry) { toolEl.className = 'agent-tool'; const toolName = entry.tool || 'Tool'; const toolInput = entry.input || ''; - toolEl.innerHTML = `${escapeHtml(toolName)} ${escapeHtml(toolInput)}`; + + // Use the verbose description as the primary text + // The tool name becomes a subtle badge + const toolIcon = toolName === 'Bash' ? '▸' : toolName === 'Read' ? '📄' : toolName === 'Grep' ? '🔍' : toolName === 'Glob' ? '📁' : '⚡'; + toolEl.innerHTML = `${toolIcon} ${escapeHtml(toolInput)}`; agentContainer.appendChild(toolEl); agentContainer.scrollIntoView({ behavior: 'smooth', block: 'end' }); return; @@ -251,8 +276,34 @@ async function sendMessage() { commandInput.disabled = true; sendBtn.disabled = true; + // Show user bubble + thinking dots IMMEDIATELY — don't wait for poll. + // This eliminates up to 1000ms of perceived latency. + lastOptimisticMsg = msg; + const welcome = chatMessages.querySelector('.chat-welcome'); + if (welcome) welcome.remove(); + const userBubble = document.createElement('div'); + userBubble.className = 'chat-bubble user'; + userBubble.innerHTML = `${escapeHtml(msg)}${formatChatTime(new Date().toISOString())}`; + chatMessages.appendChild(userBubble); + + agentText = ''; + agentContainer = document.createElement('div'); + agentContainer.className = 'agent-response'; + agentTextEl = null; + chatMessages.appendChild(agentContainer); + const thinking = document.createElement('div'); + thinking.className = 'agent-thinking'; + thinking.id = 'agent-thinking'; + thinking.innerHTML = ''; + agentContainer.appendChild(thinking); + agentContainer.scrollIntoView({ behavior: 'smooth', block: 'end' }); + updateStopButton(true); + + // Speed up polling while agent is working + startFastPoll(); + const result = await new Promise((resolve) => { - chrome.runtime.sendMessage({ type: 'sidebar-command', message: msg }, resolve); + chrome.runtime.sendMessage({ type: 'sidebar-command', message: msg, tabId: sidebarActiveTabId }, resolve); }); commandInput.disabled = false; @@ -260,7 +311,7 @@ async function sendMessage() { commandInput.focus(); if (result?.ok) { - // Immediately poll to show the user's own message + // Poll immediately to sync server state pollChat(); } else { commandInput.classList.add('error'); @@ -286,6 +337,7 @@ commandInput.addEventListener('keydown', (e) => { }); sendBtn.addEventListener('click', sendMessage); +document.getElementById('stop-agent-btn').addEventListener('click', stopAgent); // Poll for new chat messages let initialLoadDone = false; @@ -293,16 +345,25 @@ let initialLoadDone = false; async function pollChat() { if (!serverUrl || !serverToken) return; try { - const resp = await fetch(`${serverUrl}/sidebar-chat?after=${chatLineCount}`, { + // Request chat for the currently displayed tab + const tabParam = sidebarActiveTabId !== null ? `&tabId=${sidebarActiveTabId}` : ''; + const resp = await fetch(`${serverUrl}/sidebar-chat?after=${chatLineCount}${tabParam}`, { headers: authHeaders(), signal: AbortSignal.timeout(3000), }); if (!resp.ok) return; const data = await resp.json(); + // Detect tab switch from server — swap chat context + if (data.activeTabId !== undefined && data.activeTabId !== sidebarActiveTabId) { + switchChatTab(data.activeTabId); + return; // switchChatTab triggers a fresh poll + } + // First successful poll — hide loading spinner if (!initialLoadDone) { initialLoadDone = true; + sidebarActiveTabId = data.activeTabId ?? null; const loading = document.getElementById('chat-loading'); const welcome = document.getElementById('chat-welcome'); if (loading) loading.style.display = 'none'; @@ -319,6 +380,181 @@ async function pollChat() { } chatLineCount = data.total; } + + // Clean up orphaned thinking indicators after replay. + const thinking = document.getElementById('agent-thinking'); + if (thinking && data.agentStatus !== 'processing') { + thinking.remove(); + if (agentContainer) { + const notice = document.createElement('div'); + notice.className = 'agent-text'; + notice.style.color = 'var(--text-meta)'; + notice.style.fontStyle = 'italic'; + notice.textContent = '(session ended)'; + agentContainer.appendChild(notice); + agentContainer = null; + agentTextEl = null; + } + } + + // Show/hide stop button based on agent status + updateStopButton(data.agentStatus === 'processing'); + } catch {} +} + +/** Switch the sidebar to show a different tab's chat context */ +function switchChatTab(newTabId) { + if (newTabId === sidebarActiveTabId) return; + + // Save current tab's chat DOM + scroll position + if (sidebarActiveTabId !== null) { + chatDomByTab[sidebarActiveTabId] = chatMessages.innerHTML; + chatLineCountByTab[sidebarActiveTabId] = chatLineCount; + } + + sidebarActiveTabId = newTabId; + + // Restore saved chat for new tab, or show welcome + if (chatDomByTab[newTabId]) { + chatMessages.innerHTML = chatDomByTab[newTabId]; + chatLineCount = chatLineCountByTab[newTabId] || 0; + } else { + chatMessages.innerHTML = ` +
+
G
+

Send a message about this page.

+

Each tab has its own conversation.

+
`; + chatLineCount = 0; + } + + // Reset agent state for this tab + agentContainer = null; + agentTextEl = null; + agentText = ''; + + // Immediately poll the new tab's chat + pollChat(); +} + +function updateStopButton(agentRunning) { + const stopBtn = document.getElementById('stop-agent-btn'); + if (!stopBtn) return; + stopBtn.style.display = agentRunning ? '' : 'none'; +} + +async function stopAgent() { + if (!serverUrl) return; + try { + await fetch(`${serverUrl}/sidebar-agent/stop`, { method: 'POST', headers: authHeaders() }); + } catch {} + // Immediately clean up UI + const thinking = document.getElementById('agent-thinking'); + if (thinking) thinking.remove(); + if (agentContainer) { + const notice = document.createElement('div'); + notice.className = 'agent-text'; + notice.style.color = 'var(--text-meta)'; + notice.style.fontStyle = 'italic'; + notice.textContent = 'Stopped'; + agentContainer.appendChild(notice); + agentContainer = null; + agentTextEl = null; + } + updateStopButton(false); + stopFastPoll(); +} + +// ─── Adaptive poll speed ───────────────────────────────────────── +// 300ms while agent is working (fast first-token), 1000ms when idle. +const FAST_POLL_MS = 300; +const SLOW_POLL_MS = 1000; + +function startFastPoll() { + if (chatPollInterval) clearInterval(chatPollInterval); + chatPollInterval = setInterval(pollChat, FAST_POLL_MS); +} + +function stopFastPoll() { + if (chatPollInterval) clearInterval(chatPollInterval); + chatPollInterval = setInterval(pollChat, SLOW_POLL_MS); +} + +// ─── Browser Tab Bar ───────────────────────────────────────────── +let tabPollInterval = null; +let lastTabJson = ''; + +async function pollTabs() { + if (!serverUrl || !serverToken) return; + try { + // Tell the server which Chrome tab the user is actually looking at. + // This syncs manual tab switches in the browser → server activeTabId. + let activeTabUrl = null; + try { + const chromeTabs = await chrome.tabs.query({ active: true, currentWindow: true }); + activeTabUrl = chromeTabs?.[0]?.url || null; + } catch {} + + const resp = await fetch(`${serverUrl}/sidebar-tabs${activeTabUrl ? '?activeUrl=' + encodeURIComponent(activeTabUrl) : ''}`, { + headers: authHeaders(), + signal: AbortSignal.timeout(2000), + }); + if (!resp.ok) return; + const data = await resp.json(); + if (!data.tabs) return; + + // Only re-render if tabs changed + const json = JSON.stringify(data.tabs); + if (json === lastTabJson) return; + lastTabJson = json; + + renderTabBar(data.tabs); + } catch {} +} + +function renderTabBar(tabs) { + const bar = document.getElementById('browser-tabs'); + if (!bar) return; + + if (!tabs || tabs.length <= 1) { + bar.style.display = 'none'; + return; + } + + bar.style.display = ''; + bar.innerHTML = ''; + + for (const tab of tabs) { + const el = document.createElement('div'); + el.className = 'browser-tab' + (tab.active ? ' active' : ''); + el.title = tab.url || ''; + + // Show favicon-style domain + title + let label = tab.title || ''; + if (!label && tab.url) { + try { label = new URL(tab.url).hostname; } catch { label = tab.url; } + } + if (label.length > 20) label = label.slice(0, 20) + '…'; + + el.textContent = label || `Tab ${tab.id}`; + el.dataset.tabId = tab.id; + + el.addEventListener('click', () => switchBrowserTab(tab.id)); + bar.appendChild(el); + } +} + +async function switchBrowserTab(tabId) { + if (!serverUrl) return; + try { + await fetch(`${serverUrl}/sidebar-tabs/switch`, { + method: 'POST', + headers: authHeaders(), + body: JSON.stringify({ id: tabId }), + }); + // Switch chat context + re-poll tabs + switchChatTab(tabId); + pollTabs(); } catch {} } @@ -960,12 +1196,17 @@ function updateConnection(url, token) { connectSSE(); connectInspectorSSE(); if (chatPollInterval) clearInterval(chatPollInterval); - chatPollInterval = setInterval(pollChat, 1000); + chatPollInterval = setInterval(pollChat, SLOW_POLL_MS); pollChat(); + // Poll browser tabs every 2s (lightweight, just tab list) + if (tabPollInterval) clearInterval(tabPollInterval); + tabPollInterval = setInterval(pollTabs, 2000); + pollTabs(); } else { document.getElementById('footer-dot').className = 'dot'; document.getElementById('footer-port').textContent = ''; if (chatPollInterval) { clearInterval(chatPollInterval); chatPollInterval = null; } + if (tabPollInterval) { clearInterval(tabPollInterval); tabPollInterval = null; } if (wasConnected) { startReconnect(); } @@ -1060,6 +1301,25 @@ chrome.runtime.onMessage.addListener((msg) => { inspectorPickerActive = false; inspectorPickBtn.classList.remove('active'); } + // Instant tab switch — background.js fires this on chrome.tabs.onActivated + if (msg.type === 'browserTabActivated') { + // Tell the server which tab is now active, then switch chat context + if (serverUrl && serverToken) { + fetch(`${serverUrl}/sidebar-tabs?activeUrl=${encodeURIComponent(msg.url || '')}`, { + headers: authHeaders(), + signal: AbortSignal.timeout(2000), + }).then(r => r.json()).then(data => { + if (data.tabs) { + renderTabBar(data.tabs); + // Find the server-side tab ID for this Chrome tab + const activeTab = data.tabs.find(t => t.active); + if (activeTab && activeTab.id !== sidebarActiveTabId) { + switchChatTab(activeTab.id); + } + } + }).catch(() => {}); + } + } }); // ─── Chat Gate ──────────────────────────────────────────────────