diff --git a/CHANGELOG.md b/CHANGELOG.md index 44eeb8f0..95bd95dc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,22 @@ All notable changes to Tandem Browser will be documented in this file. ## Unreleased -No unreleased changes yet. +### Added + +- **OpenClaw-free Wingman chat path** (`shell/chat/tandem-local-backend.js`, + `src/api/routes/media.ts`, `src/mcp/tools/chat.ts`) - adds a built-in + Tandem local chat backend so MCP/API agents can read Robin's Wingman chat + messages and reply into the panel without requiring OpenClaw on the host. + OpenClaw remains available as a separate backend and the shell falls back to + Tandem local chat when the gateway is not reachable. + +### Changed + +- **Wingman chat channel selector** (`shell/js/wingman/chat.js`, + `shell/chat/tandem-local-backend.js`) - now shows only chat channels for + agents configured in Connected Agents, so a lone Codex binding no longer + exposes stale OpenClaw/Claude tabs or lets the legacy Claude polling backend + mirror the Codex conversation. ## [v1.10.0] - 2026-05-05 diff --git a/TODO.md b/TODO.md index 3b970d8c..330ded55 100644 --- a/TODO.md +++ b/TODO.md @@ -52,6 +52,7 @@ Last updated: May 5, 2026 - [ ] Finish Cloudflare human mode phases 4-5 so challenge-sensitive tabs pause cleanly for the human and resume conservatively after `cf_clearance`; phase 3 now gates ScriptGuard and resource monitoring on Cloudflare tabs - [x] Make Wingman `openclaw` mode gateway-first for sends, sign a real OpenClaw device identity for the WebSocket handshake, and persist gateway replies into Tandem chat history so stock Tandem no longer depends on a local OpenClaw tandem-chat skill +- [x] Add a built-in Tandem local Wingman chat backend so MCP/API agents can read and reply in the panel without requiring OpenClaw on the host, while keeping the OpenClaw gateway backend available when installed - [x] Split `src/main.ts` bootstrap and teardown wiring into dedicated `src/bootstrap/` modules so manager composition stops growing in one file - [x] Extract the largest shell surfaces out of `shell/index.html` and `shell/css/main.css` so sidebar logic, modal helpers, and stylesheet sections stop living in single inline or monolithic files - [x] Split the Wingman and ClaroNote renderer surfaces out of `shell/js/main.js` into dedicated shell modules with explicit shared state instead of file-scope coupling diff --git a/shell/chat/tandem-local-backend.js b/shell/chat/tandem-local-backend.js new file mode 100644 index 00000000..1f25ac30 --- /dev/null +++ b/shell/chat/tandem-local-backend.js @@ -0,0 +1,207 @@ +/** + * TandemLocalBackend - Wingman chat over Tandem's own HTTP API. + * + * This keeps the Wingman panel useful without a local OpenClaw install. Agents + * can read Robin's messages through MCP/HTTP and reply back into the same + * local chat history. + */ +class TandemLocalBackend { + constructor() { + this.id = 'tandem'; + this.name = 'Tandem Local'; + this.icon = 'T'; + + this._connected = false; + this._pollTimer = null; + this._pollInterval = 1500; + this._lastSeenId = 0; + this._apiBase = window.tandemApi?.baseUrl() || window.__TANDEM_API_BASE__ || 'http://127.0.0.1:8765'; + this._primaryAgent = null; + this._connectedAgents = []; + + this._messageCallbacks = []; + this._typingCallbacks = []; + this._connectionCallbacks = []; + } + + async connect() { + try { + const res = await fetch(`${this._apiBase}/chat/status`); + if (!res.ok) { + this._setConnected(false); + return; + } + const status = await res.json(); + this._applyStatus(status); + this._setConnected(true); + await this._loadHistory(); + this._startPolling(); + } catch (e) { + console.warn('[TandemLocalBackend] API not reachable:', e.message); + this._setConnected(false); + } + } + + async disconnect() { + this._stopPolling(); + this._setConnected(false); + } + + isConnected() { + return this._connected; + } + + async sendMessage(text) { + if (!text) return false; + try { + const res = await fetch(`${this._apiBase}/chat/messages`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text, from: 'user' }) + }); + if (!res.ok) return false; + const data = await res.json(); + const msg = data.message || {}; + this._trackSeen(msg); + this._emit('message', this._toUiMessage(msg)); + return true; + } catch (e) { + console.warn('[TandemLocalBackend] Send failed:', e.message); + this._setConnected(false); + return false; + } + } + + onMessage(cb) { this._messageCallbacks.push(cb); } + onTyping(cb) { this._typingCallbacks.push(cb); } + onConnectionChange(cb) { this._connectionCallbacks.push(cb); } + + async loadHistory(onMessages) { + const messages = await this._fetchHistory(50); + if (typeof onMessages === 'function') { + onMessages(messages); + } + } + + getPrimaryAgent() { + return this._primaryAgent; + } + + getConnectedAgents() { + return [...this._connectedAgents]; + } + + _startPolling() { + this._stopPolling(); + this._pollTimer = setInterval(() => this._poll(), this._pollInterval); + } + + _stopPolling() { + if (this._pollTimer) { + clearInterval(this._pollTimer); + this._pollTimer = null; + } + } + + async _poll() { + try { + const res = await fetch(`${this._apiBase}/chat/messages?since_id=${this._lastSeenId}`); + if (!res.ok) { + this._setConnected(false); + return; + } + if (!this._connected) this._setConnected(true); + + const data = await res.json(); + const messages = data.messages || []; + for (const msg of messages) { + this._trackSeen(msg); + if (msg.from !== 'user') { + this._emit('message', this._toUiMessage(msg)); + } + } + } catch { + this._setConnected(false); + } + } + + async _loadHistory() { + const messages = await this._fetchHistory(50); + if (messages.length > 0) { + this._emit('historyReload', messages); + } + } + + async _fetchHistory(limit) { + try { + const res = await fetch(`${this._apiBase}/chat/messages?limit=${limit}`); + if (!res.ok) return []; + const data = await res.json(); + const messages = data.messages || []; + const parsed = []; + for (const msg of messages) { + this._trackSeen(msg); + parsed.push(this._toUiMessage(msg)); + } + return parsed; + } catch (e) { + console.warn('[TandemLocalBackend] History load failed:', e.message); + return []; + } + } + + _toUiMessage(msg) { + const from = msg?.from || 'wingman'; + const fallbackLabel = from === 'wingman' ? this._primaryAgent?.label : undefined; + const source = from === 'user' ? 'user' : (from === 'wingman' && this._primaryAgent?.type ? this._primaryAgent.type : from); + return { + id: msg?.id?.toString?.() || crypto.randomUUID(), + role: from === 'user' ? 'user' : 'assistant', + text: msg?.text || '', + source, + actorLabel: msg?.actorLabel || fallbackLabel, + timestamp: msg?.timestamp || Date.now(), + image: msg?.image + }; + } + + _applyStatus(status) { + this._connectedAgents = Array.isArray(status?.connectedAgents) + ? status.connectedAgents.filter((agent) => agent && typeof agent.type === 'string') + : []; + + const primary = status?.primaryAgent; + if (primary && typeof primary.label === 'string' && primary.label.trim()) { + this._primaryAgent = { + id: primary.id || null, + label: primary.label.trim(), + type: primary.type || 'agent' + }; + this.name = this._primaryAgent.label; + } else { + this._primaryAgent = null; + this.name = 'Tandem Local'; + } + } + + _trackSeen(msg) { + if (typeof msg?.id === 'number' && msg.id > this._lastSeenId) { + this._lastSeenId = msg.id; + } + } + + _setConnected(connected) { + if (this._connected !== connected) { + this._connected = connected; + for (const cb of this._connectionCallbacks) cb(connected); + } + } + + _emit(type, data) { + if (type === 'message' || type === 'historyReload') { + for (const cb of this._messageCallbacks) cb(data, type); + } else if (type === 'typing') { + for (const cb of this._typingCallbacks) cb(data); + } + } +} diff --git a/shell/index.html b/shell/index.html index 8fd8a199..f3e6b92e 100644 --- a/shell/index.html +++ b/shell/index.html @@ -393,6 +393,7 @@

🤖 Wingman needs you

+ diff --git a/shell/js/wingman/chat-streaming.js b/shell/js/wingman/chat-streaming.js index c098509c..90acc0ef 100644 --- a/shell/js/wingman/chat-streaming.js +++ b/shell/js/wingman/chat-streaming.js @@ -43,7 +43,20 @@ export function createStreamingRenderer({ messagesEl }) { return s.replace(/&/g, '&').replace(//g, '>').replace(/\n/g, '
'); } - function appendMessage(role, text, timestamp, source, image) { + function labelForSource(sourceClass, actorLabel) { + if (actorLabel && typeof actorLabel === 'string') return actorLabel; + if (sourceClass === 'claude') return 'Claude'; + if (sourceClass === 'codex') return 'Codex'; + if (sourceClass === 'openclaw') return 'OpenClaw'; + if (sourceClass === 'tandem') return 'Tandem'; + return 'Wingman'; + } + + function classForSource(sourceClass) { + return String(sourceClass || 'openclaw').toLowerCase().replace(/[^a-z0-9_-]/g, '-'); + } + + function appendMessage(role, text, timestamp, source, image, actorLabel) { const sourceClass = source || 'openclaw'; let cls, name; if (role === 'user') { @@ -51,19 +64,20 @@ export function createStreamingRenderer({ messagesEl }) { name = 'You'; } else if (sourceClass === 'claude') { cls = 'claude'; - name = 'Claude'; + name = labelForSource(sourceClass, actorLabel); } else { cls = 'wingman'; - name = 'Wingman'; + name = labelForSource(sourceClass, actorLabel); } const el = document.createElement('div'); - el.className = `chat-msg ${cls} source-${source || sourceClass}`; + el.className = `chat-msg ${cls} source-${classForSource(source || sourceClass)}`; el.innerHTML = `
${escapeHtml(name)}
${escapeHtml(text)}
${formatTime(timestamp)}
`; // Add image if present if (image) { const msgText = el.querySelector('.msg-text'); const img = document.createElement('img'); - img.src = `http://localhost:8765/chat/image/${image}`; + const apiBase = window.tandemApi?.baseUrl() || window.__TANDEM_API_BASE__ || 'http://127.0.0.1:8765'; + img.src = `${apiBase}/chat/image/${image}`; img.className = 'chat-msg-image'; img.addEventListener('click', () => window.open(img.src, '_blank')); img.onerror = () => { img.style.display = 'none'; }; @@ -104,7 +118,7 @@ export function createStreamingRenderer({ messagesEl }) { if (Array.isArray(msg)) { for (const historyMsg of msg) { - const el = appendMessage(historyMsg.role, historyMsg.text, historyMsg.timestamp, historyMsg.source, historyMsg.image); + const el = appendMessage(historyMsg.role, historyMsg.text, historyMsg.timestamp, historyMsg.source, historyMsg.image, historyMsg.actorLabel); el.dataset.fromHistory = 'true'; } } @@ -139,7 +153,7 @@ export function createStreamingRenderer({ messagesEl }) { let streamData = streamingMessages.get(currentConversationId); if (!streamData) { // Create new streaming message element - always insert at the very end - const element = appendMessage(msg.role, msg.text, msg.timestamp, msg.source, msg.image); + const element = appendMessage(msg.role, msg.text, msg.timestamp, msg.source, msg.image, msg.actorLabel); streamData = { element, startTime: Date.now(), @@ -180,7 +194,7 @@ export function createStreamingRenderer({ messagesEl }) { } // Only append a new element if this is NOT a final event (final reuses the streaming element) if (!msg._final) { - appendMessage(msg.role, msg.text, msg.timestamp, msg.source, msg.image); + appendMessage(msg.role, msg.text, msg.timestamp, msg.source, msg.image, msg.actorLabel); if (backendId === 'openclaw' && msg.text) { void persistChatMessage('wingman', msg.text, msg.image); } @@ -213,7 +227,7 @@ export function createStreamingRenderer({ messagesEl }) { if (Array.isArray(msg)) { for (const m of msg) { - const el = appendMessage(m.role, m.text, m.timestamp, m.source, m.image); + const el = appendMessage(m.role, m.text, m.timestamp, m.source, m.image, m.actorLabel); el.dataset.fromHistory = 'true'; } @@ -240,7 +254,7 @@ export function createStreamingRenderer({ messagesEl }) { if (!streamData) { // Create new streaming message element - const element = appendMessage(msg.role, msg.text, msg.timestamp, msg.source, msg.image); + const element = appendMessage(msg.role, msg.text, msg.timestamp, msg.source, msg.image, msg.actorLabel); streamData = { element, startTime: Date.now(), @@ -279,7 +293,7 @@ export function createStreamingRenderer({ messagesEl }) { void persistChatMessage('wingman', msg.text); } if (!msg._final) { - appendMessage(msg.role, msg.text, msg.timestamp, msg.source, msg.image); + appendMessage(msg.role, msg.text, msg.timestamp, msg.source, msg.image, msg.actorLabel); if (backendId === 'openclaw' && msg.text) { void persistChatMessage('wingman', msg.text, msg.image); } diff --git a/shell/js/wingman/chat.js b/shell/js/wingman/chat.js index a01249bc..62fe3c47 100644 --- a/shell/js/wingman/chat.js +++ b/shell/js/wingman/chat.js @@ -7,8 +7,8 @@ * (Kept as window binding for classic scripts and main-process IPC.) * * External globals used (loaded via classic