Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
1 change: 1 addition & 0 deletions TODO.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
207 changes: 207 additions & 0 deletions shell/chat/tandem-local-backend.js
Original file line number Diff line number Diff line change
@@ -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);
}
}
}
1 change: 1 addition & 0 deletions shell/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -393,6 +393,7 @@ <h3 id="alert-title">🤖 Wingman needs you</h3>
<script src="js/api-auth.js"></script>

<!-- Chat Router + Backends (Phase 3) + DualMode (Phase 5) -->
<script src="chat/tandem-local-backend.js"></script>
<script src="chat/openclaw-backend.js"></script>
<script src="chat/claude-activity-backend.js"></script>
<script src="chat/router.js"></script>
Expand Down
36 changes: 25 additions & 11 deletions shell/js/wingman/chat-streaming.js
Original file line number Diff line number Diff line change
Expand Up @@ -43,27 +43,41 @@ export function createStreamingRenderer({ messagesEl }) {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/\n/g, '<br>');
}

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') {
cls = 'user';
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 = `<div class="msg-from">${escapeHtml(name)}</div><div class="msg-text">${escapeHtml(text)}</div><div class="msg-time">${formatTime(timestamp)}</div>`;
// 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'; };
Expand Down Expand Up @@ -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';
}
}
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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);
}
Expand Down Expand Up @@ -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';
}

Expand All @@ -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(),
Expand Down Expand Up @@ -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);
}
Expand Down
Loading
Loading