diff --git a/docs/plans/tab-emojis-design.md b/docs/plans/tab-emojis-design.md deleted file mode 100644 index 332bd1f..0000000 --- a/docs/plans/tab-emojis-design.md +++ /dev/null @@ -1,133 +0,0 @@ -# Design: Tab Emojis - -> **Date:** 2026-02-28 -> **Status:** Planned -> **Effort:** Easy (1-2d) -> **Author:** Kees - ---- - -## Problem / Motivation - -Tabs in Tandem are functional but visually monotonous. When Robin has 15+ tabs open, favicon + title are sometimes not enough to quickly find the right tab โ€” especially with multiple tabs from the same site. - -**Opera has:** Tab Emojis โ€” hover over a tab shows an emoji selector. Click "+" to assign an emoji as a badge on the tab. Persistent across sessions. - -**Tandem currently has:** Nothing. Tabs show only a favicon, title, source indicator (๐Ÿ‘ค), and a close button. No personalization option. - -**Gap:** Completely missing. No emoji assignment, no storage, no UI. - ---- - -## User Experience โ€” How It Works - -> Robin has 12 tabs open. Three of them are GitHub repositories โ€” all with the same favicon. -> -> He hovers over the first GitHub tab. Next to the title a small "+" icon appears. He clicks it โ†’ a compact emoji picker popup appears (default browser emojis or a grid of popular emojis). -> -> He chooses ๐Ÿ”ฅ for the main project, ๐Ÿงช for the test repo, and ๐Ÿ“š for the docs repo. -> -> Now each tab shows its emoji as a badge before the title. Robin can tell at a glance which tab serves which purpose. -> -> The next day Robin opens Tandem โ€” the emojis are still there. They are stored per URL domain+path. - ---- - -## Technical Approach - -### Architecture - -``` - โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” - โ”‚ Shell UI โ”‚ - โ”‚ emoji picker popup โ”‚ - โ”‚ badge on tab โ”‚ - โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - โ”‚ fetch() - โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” - โ”‚ REST API โ”‚ - โ”‚ POST /tabs/:id/emojiโ”‚ - โ”‚ routes/tabs.ts โ”‚ - โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - โ”‚ - โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” - โ”‚ TabManager โ”‚ - โ”‚ tab.emoji field โ”‚ - โ”‚ persist to JSON โ”‚ - โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”ฌโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ - โ”‚ - โ”Œโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ–ผโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ” - โ”‚ ~/.tandem/ โ”‚ - โ”‚ tab-emojis.json โ”‚ - โ”‚ { url: emoji } โ”‚ - โ””โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”˜ -``` - -### New Files - -| File | Responsibility | -|---------|---------------------| -| โ€” | None โ€” everything fits in existing modules | - -### Modify Existing Files - -| File | Change | Function | -|---------|-----------|---------| -| `src/tabs/manager.ts` | `emoji` field on `Tab` interface + `setEmoji()` / `getEmoji()` + persistence load/save | `class TabManager` | -| `src/api/routes/tabs.ts` | Emoji set/delete endpoints | `function registerTabRoutes()` | -| `shell/index.html` | Emoji badge in tab element + emoji picker popup on hover | Tab creation in JS | -| `shell/css/main.css` | `.tab-emoji` badge styling | Tab styling section | - -### New API Endpoints - -| Method | Endpoint | Description | -|---------|---------|--------------| -| POST | `/tabs/:id/emoji` | Set emoji for tab (body: `{ emoji: "๐Ÿ”ฅ" }`) | -| DELETE | `/tabs/:id/emoji` | Remove emoji from tab | - -### Persistence - -Storage in `~/.tandem/tab-emojis.json`: -```json -{ - "github.com/hydro13/tandem-browser": "๐Ÿ”ฅ", - "github.com/hydro13/tandem-cli": "๐Ÿงช", - "docs.google.com/document/d/abc123": "๐Ÿ“š" -} -``` - -Key = URL hostname + pathname (without query/hash). When opening a tab, it checks whether there is a stored emoji for that URL. - -### No new npm packages needed? โœ… - ---- - -## Phase Breakdown - -| Phase | Scope | Sessions | Depends on | -|------|--------|---------|----------------| -| 1 | Full implementation: extend Tab interface, API endpoints, persistence, shell emoji picker + badge | 1 | โ€” | - ---- - -## Risks / Pitfalls - -- **Emoji rendering:** Not all emojis render equally well on all OSes. Mitigation: use native OS emoji rendering (no custom font). Tandem runs on macOS/Linux anyway. -- **URL matching too strict:** If the emoji is tied to an exact path, `github.com/hydro13/tandem-browser` won't match `github.com/hydro13/tandem-browser/issues`. Mitigation: match on longest prefix, or let Robin choose: per-page or per-domain. -- **tab-emojis.json grows:** With many sites the file can become large. Mitigation: LRU limit of 500 entries, oldest are removed. - ---- - -## Anti-detect Considerations - -- โœ… Everything via shell + main process โ€” no injection into the webview -- โœ… Emoji picker is a shell overlay, not visible to the website -- โœ… Storage is purely local filesystem - ---- - -## Open Questions - -- [ ] Emoji picker: simple grid of ~50 popular emojis, or full OS emoji picker? -- [ ] Persistence scope: per exact URL, per domain+path, or per domain? -- [ ] Should the emoji remain visible when a tab is very narrow (where it would overlap with the favicon)? diff --git a/shell/chat/claude-activity-backend.js b/shell/chat/claude-activity-backend.js index 3feaf20..49156d8 100644 --- a/shell/chat/claude-activity-backend.js +++ b/shell/chat/claude-activity-backend.js @@ -2,9 +2,9 @@ * ClaudeActivityBackend โ€” Polls GET /chat for Claude MCP activity * Implements ChatBackend interface (see src/chat/interfaces.ts) * - * Creates a chat loop: Robin (browser) <-> Claude (Cowork) via MCP. + * Creates a chat loop: User (browser) <-> Claude (Cowork) via MCP. * Claude writes via tandem_send_message MCP tool -> POST /chat from:"claude" - * Robin writes via this backend -> POST /chat from:"robin" + * User writes via this backend -> POST /chat from:"user" * Claude reads via tandem_get_chat_history MCP tool -> GET /chat */ class ClaudeActivityBackend { @@ -57,7 +57,7 @@ class ClaudeActivityBackend { const res = await fetch(`${this._apiBase}/chat`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ text, from: 'robin' }) + body: JSON.stringify({ text, from: 'user' }) }); if (res.ok) { const data = await res.json(); @@ -66,7 +66,7 @@ class ClaudeActivityBackend { id: data.message?.id?.toString() || crypto.randomUUID(), role: 'user', text, - source: 'robin', + source: 'user', timestamp: Date.now() }); } @@ -111,7 +111,7 @@ class ClaudeActivityBackend { for (const m of messages) { if (m.id > this._lastSeenId) { this._lastSeenId = m.id; - // Only emit Claude messages (not our own robin messages) during polling + // Only emit Claude messages (not our own user messages) during polling if (m.from === 'claude' || m.from === 'wingman') { this._emit('message', { id: m.id.toString(), @@ -140,9 +140,9 @@ class ClaudeActivityBackend { if (m.id > this._lastSeenId) this._lastSeenId = m.id; parsed.push({ id: m.id.toString(), - role: m.from === 'robin' ? 'user' : 'assistant', + role: m.from === 'user' ? 'user' : 'assistant', text: m.text, - source: m.from === 'robin' ? 'robin' : 'claude', + source: m.from === 'user' ? 'user' : 'claude', timestamp: m.timestamp || Date.now() }); } diff --git a/shell/chat/openclaw-backend.js b/shell/chat/openclaw-backend.js index 672efca..8d22c05 100644 --- a/shell/chat/openclaw-backend.js +++ b/shell/chat/openclaw-backend.js @@ -89,7 +89,7 @@ class OpenClawBackend { id: m.id || crypto.randomUUID(), role: m.role, text, - source: m.role === 'user' ? 'robin' : 'openclaw', + source: m.role === 'user' ? 'user' : 'openclaw', timestamp: m.timestamp || m.createdAt || Date.now() }); } diff --git a/shell/css/wingman.css b/shell/css/wingman.css index d93c8d3..0118cde 100644 --- a/shell/css/wingman.css +++ b/shell/css/wingman.css @@ -201,7 +201,7 @@ font-size: 10px; } - .activity-item .a-source.robin { + .activity-item .a-source.user { color: var(--success); } @@ -259,7 +259,7 @@ min-width: 0; } - .chat-msg.robin { + .chat-msg.user { background: rgba(78, 204, 163, 0.2); border: 1px solid rgba(78, 204, 163, 0.3); align-self: flex-end; diff --git a/shell/js/wingman.js b/shell/js/wingman.js index faf9955..20489cb 100644 --- a/shell/js/wingman.js +++ b/shell/js/wingman.js @@ -189,8 +189,8 @@ else if (event.data.selector) text = `${event.type}: ${event.data.selector}`; else if (event.data.title) text = `${event.type}: ${event.data.title}`; - const rawSource = event.data.source || 'robin'; - const source = ['wingman', 'robin'].includes(rawSource) ? rawSource : 'robin'; + const rawSource = event.data.source || 'user'; + const source = ['wingman', 'user'].includes(rawSource) ? rawSource : 'user'; const sourceEmoji = source === 'wingman' ? '๐Ÿค–' : '๐Ÿ‘ค'; const item = document.createElement('div'); item.className = 'activity-item'; @@ -227,7 +227,7 @@ } }); - // Robin claims an AI tab by focusing it (click on tab header) + // User claims an AI tab by focusing it (click on tab header) // The click handler already calls focusTab, we hook into it to also claim const origTabClickHandler = (tabId) => { // Check if this is an AI tab @@ -235,11 +235,11 @@ if (entry) { const sourceEl = entry.tabEl.querySelector('.tab-source'); if (sourceEl && sourceEl.textContent === '๐Ÿค–') { - // Claim the tab for Robin + // Claim the tab for User fetch('http://localhost:8765/tabs/source', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ tabId, source: 'robin' }) + body: JSON.stringify({ tabId, source: 'user' }) }).catch(() => { }); } } @@ -329,7 +329,7 @@ const sourceClass = source || 'openclaw'; let cls, name; if (role === 'user') { - cls = 'robin'; + cls = 'user'; name = 'You'; } else if (sourceClass === 'claude') { cls = 'claude'; @@ -442,15 +442,15 @@ } function switchBackend(id) { - // Store any locally typed Robin messages before clearing + // Store any locally typed user messages before clearing const localRobinMessages = []; for (const child of messagesEl.children) { - if (child.classList.contains('robin') && child.dataset.localMessage === 'true') { + if (child.classList.contains('user') && child.dataset.localMessage === 'true') { localRobinMessages.push({ role: 'user', text: child.querySelector('.msg-text').textContent, timestamp: child.querySelector('.msg-time').textContent, - source: 'robin' + source: 'user' }); } } @@ -470,7 +470,7 @@ const el = appendMessage(m.role, m.text, m.timestamp, m.source, m.image); el.dataset.fromHistory = 'true'; } - // Re-add local Robin messages + // Re-add local user messages for (const localMsg of localRobinMessages) { const el = appendMessage(localMsg.role, localMsg.text, localMsg.timestamp, localMsg.source, localMsg.image); el.dataset.localMessage = 'true'; @@ -485,7 +485,7 @@ const el = appendMessage(m.role, m.text, m.timestamp, m.source, m.image); el.dataset.fromHistory = 'true'; } - // Re-add local Robin messages + // Re-add local user messages for (const localMsg of localRobinMessages) { const el = appendMessage(localMsg.role, localMsg.text, localMsg.timestamp, localMsg.source, localMsg.image); el.dataset.localMessage = 'true'; @@ -538,15 +538,15 @@ if (currentMode === 'both') return; // handled by dualMode if (type === 'historyReload') { - // Store any locally typed Robin messages before processing history + // Store any locally typed user messages before processing history const localRobinMessages = []; for (const child of messagesEl.children) { - if (child.classList.contains('robin') && child.dataset.localMessage === 'true') { + if (child.classList.contains('user') && child.dataset.localMessage === 'true') { localRobinMessages.push({ role: 'user', text: child.querySelector('.msg-text').textContent, timestamp: child.querySelector('.msg-time').textContent, - source: 'robin' + source: 'user' }); } } @@ -571,7 +571,7 @@ for (const localMsg of localRobinMessages) { let alreadyExists = false; for (const child of messagesEl.children) { - if (child.classList.contains('robin') && + if (child.classList.contains('user') && child.querySelector('.msg-text').textContent === localMsg.text && child.dataset.fromHistory === 'true') { alreadyExists = true; @@ -608,7 +608,7 @@ // Update existing streaming element content streamData.element.querySelector('.msg-text').innerHTML = escapeHtml(msg.text); - // Ensure streaming element stays at the end (after any Robin messages sent during streaming) + // Ensure streaming element stays at the end (after any user messages sent during streaming) const currentIndex = Array.from(messagesEl.children).indexOf(streamData.element); const lastIndex = messagesEl.children.length - 1; if (currentIndex !== lastIndex) { @@ -650,15 +650,15 @@ let dualStreamingConversations = {}; // per-backend conversation tracking dualMode.onMessage((msg, type, backendId) => { if (type === 'historyReload') { - // Store any locally typed Robin messages before clearing + // Store any locally typed user messages before clearing const localRobinMessages = []; for (const child of messagesEl.children) { - if (child.classList.contains('robin') && child.dataset.localMessage === 'true') { + if (child.classList.contains('user') && child.dataset.localMessage === 'true') { localRobinMessages.push({ role: 'user', text: child.querySelector('.msg-text').textContent, timestamp: child.querySelector('.msg-time').textContent, - source: 'robin' + source: 'user' }); } } @@ -674,7 +674,7 @@ el.dataset.fromHistory = 'true'; } - // Re-add local Robin messages that aren't in history + // Re-add local user messages that aren't in history for (const localMsg of localRobinMessages) { const el = appendMessage(localMsg.role, localMsg.text, localMsg.timestamp, localMsg.source, localMsg.image); el.dataset.localMessage = 'true'; @@ -838,7 +838,7 @@ inputEl.style.height = ''; // Show local preview immediately - const robinMsg = appendMessage('user', text || '', Date.now(), 'robin'); + const robinMsg = appendMessage('user', text || '', Date.now(), 'user'); robinMsg.dataset.localMessage = 'true'; const msgText = robinMsg.querySelector('.msg-text'); const img = document.createElement('img'); @@ -870,7 +870,7 @@ if (target === 'both' && !openclawBackend.isConnected() && !claudeBackend.isConnected()) return; // Show user message (display original text with @-mention for clarity) - const robinMsg = appendMessage('user', text, Date.now(), 'robin'); + const robinMsg = appendMessage('user', text, Date.now(), 'user'); robinMsg.dataset.localMessage = 'true'; dualMode.sendMessage(text); @@ -881,11 +881,11 @@ // OpenClaw: send through the official gateway chat path. if (activeId === 'openclaw') { - const robinMsg = appendMessage('user', text, Date.now(), 'robin'); + const robinMsg = appendMessage('user', text, Date.now(), 'user'); robinMsg.dataset.localMessage = 'true'; const sentViaGateway = await router.sendMessage(text); if (sentViaGateway) { - void persistChatMessage('robin', text); + void persistChatMessage('user', text); } else { appendMessage('assistant', 'โš ๏ธ Wingman could not reach OpenClaw.', Date.now(), 'wingman'); } @@ -1017,8 +1017,8 @@ if (window.tandem && window.tandem.onChatMessage) { window.tandem.onChatMessage((msg) => { // msg: {id, from, text, timestamp, image} - // Skip robin messages โ€” already shown optimistically in the UI - if (msg.from === 'robin') return; + // Skip user messages โ€” already shown optimistically in the UI + if (msg.from === 'user') return; const source = msg.from; // 'wingman' or 'claude' appendMessage('assistant', msg.text, msg.timestamp, source, msg.image); if (messagesEl) messagesEl.scrollTop = messagesEl.scrollHeight; diff --git a/src/activity/tracker.ts b/src/activity/tracker.ts index 4db94ca..f3ec32e 100644 --- a/src/activity/tracker.ts +++ b/src/activity/tracker.ts @@ -154,8 +154,8 @@ export class ActivityTracker { break; case 'tab-open': - // Only stream user-initiated opens (source: 'robin'), not agent opens - if (data.source === 'robin') { + // Only stream user-initiated opens (source: 'user'), not agent opens + if (data.source === 'user') { void this.wingmanStream.emit({ type: 'tab-opened', tabId, diff --git a/src/agents/tab-lock-manager.ts b/src/agents/tab-lock-manager.ts index 36bbb29..f8e607b 100644 --- a/src/agents/tab-lock-manager.ts +++ b/src/agents/tab-lock-manager.ts @@ -40,15 +40,15 @@ export class TabLockManager extends EventEmitter { acquire(tabId: string, agentId: string): { acquired: boolean; owner?: string } { this.cleanExpired(); - // Robin always has priority - if (agentId === 'robin') { + // User always has priority + if (agentId === 'user') { const existing = this.locks.get(tabId); - if (existing && existing.agentId !== 'robin') { - this.emit('lock-overridden', { tabId, previousOwner: existing.agentId, newOwner: 'robin' }); + if (existing && existing.agentId !== 'user') { + this.emit('lock-overridden', { tabId, previousOwner: existing.agentId, newOwner: 'user' }); } this.locks.set(tabId, { tabId, - agentId: 'robin', + agentId: 'user', acquiredAt: Date.now(), expiresAt: Date.now() + this.lockTimeoutMs, }); @@ -73,13 +73,13 @@ export class TabLockManager extends EventEmitter { } /** - * Release a lock. Only the owner (or robin) can release. + * Release a lock. Only the owner (or user) can release. */ release(tabId: string, agentId: string): boolean { const existing = this.locks.get(tabId); if (!existing) return true; - if (existing.agentId !== agentId && agentId !== 'robin') { + if (existing.agentId !== agentId && agentId !== 'user') { return false; } diff --git a/src/agents/task-manager.ts b/src/agents/task-manager.ts index 6051daa..f5f13bb 100644 --- a/src/agents/task-manager.ts +++ b/src/agents/task-manager.ts @@ -37,7 +37,7 @@ export interface TaskStep { export interface AITask { id: string; description: string; - createdBy: 'robin' | 'claude' | 'openclaw'; + createdBy: 'user' | 'claude' | 'openclaw'; assignedTo: 'claude' | 'openclaw'; status: TaskStatus; steps: TaskStep[]; @@ -56,7 +56,7 @@ export interface TaskActivityEntry { target?: string; riskLevel?: RiskLevel; approved?: boolean; - approvedBy?: 'robin' | 'auto'; + approvedBy?: 'user' | 'auto'; } export interface AutonomySettings { @@ -330,7 +330,7 @@ export class TaskManager extends EventEmitter { target: step.description, riskLevel: step.riskLevel, approved, - approvedBy: 'robin', + approvedBy: 'user', }); } @@ -428,7 +428,7 @@ export class TaskManager extends EventEmitter { agent: 'system', action: 'emergency-stop', target: `${stopped} tasks paused`, - approvedBy: 'robin', + approvedBy: 'user', }); // Auto-reset after a brief moment so new tasks can be created diff --git a/src/agents/tests/tab-lock-manager.test.ts b/src/agents/tests/tab-lock-manager.test.ts new file mode 100644 index 0000000..b632350 --- /dev/null +++ b/src/agents/tests/tab-lock-manager.test.ts @@ -0,0 +1,72 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { TabLockManager } from '../tab-lock-manager'; + +describe('TabLockManager', () => { + let lm: TabLockManager; + + beforeEach(() => { + lm = new TabLockManager(); + }); + + describe('acquire()', () => { + it('user always acquires, even when locked by agent', () => { + lm.acquire('tab-1', 'claude'); + const result = lm.acquire('tab-1', 'user'); + expect(result.acquired).toBe(true); + expect(lm.getOwner('tab-1')).toBe('user'); + }); + + it('emits lock-overridden when user overrides agent lock', () => { + const handler = vi.fn(); + lm.on('lock-overridden', handler); + + lm.acquire('tab-1', 'claude'); + lm.acquire('tab-1', 'user'); + + expect(handler).toHaveBeenCalledWith({ + tabId: 'tab-1', + previousOwner: 'claude', + newOwner: 'user', + }); + }); + + it('does not emit lock-overridden when user acquires unlocked tab', () => { + const handler = vi.fn(); + lm.on('lock-overridden', handler); + + lm.acquire('tab-1', 'user'); + expect(handler).not.toHaveBeenCalled(); + }); + + it('agent cannot acquire tab locked by another agent', () => { + lm.acquire('tab-1', 'claude'); + const result = lm.acquire('tab-1', 'other-agent'); + expect(result.acquired).toBe(false); + expect(result.owner).toBe('claude'); + }); + + it('agent can renew own lock', () => { + lm.acquire('tab-1', 'claude'); + const result = lm.acquire('tab-1', 'claude'); + expect(result.acquired).toBe(true); + }); + }); + + describe('release()', () => { + it('user can release any lock', () => { + lm.acquire('tab-1', 'claude'); + expect(lm.release('tab-1', 'user')).toBe(true); + expect(lm.isLocked('tab-1')).toBe(false); + }); + + it('agent cannot release another agent lock', () => { + lm.acquire('tab-1', 'claude'); + expect(lm.release('tab-1', 'other-agent')).toBe(false); + expect(lm.isLocked('tab-1')).toBe(true); + }); + + it('returns true for unlocked tab', () => { + expect(lm.release('tab-1', 'claude')).toBe(true); + }); + }); +}); diff --git a/src/agents/tests/task-manager.test.ts b/src/agents/tests/task-manager.test.ts index 23736e2..016a783 100644 --- a/src/agents/tests/task-manager.test.ts +++ b/src/agents/tests/task-manager.test.ts @@ -69,32 +69,32 @@ describe('TaskManager', () => { describe('createTask()', () => { it('creates a task with pending status', () => { - const task = tm.createTask('Test task', 'robin', 'claude', [ + const task = tm.createTask('Test task', 'user', 'claude', [ { description: 'Step 1', action: { type: 'navigate', params: { url: 'https://test.com' } }, riskLevel: 'low', requiresApproval: false }, ]); expect(task.description).toBe('Test task'); expect(task.status).toBe('pending'); - expect(task.createdBy).toBe('robin'); + expect(task.createdBy).toBe('user'); expect(task.assignedTo).toBe('claude'); expect(task.steps).toHaveLength(1); expect(task.steps[0].status).toBe('pending'); }); it('generates unique task IDs', () => { - const t1 = tm.createTask('Task 1', 'robin', 'claude', []); - const t2 = tm.createTask('Task 2', 'robin', 'claude', []); + const t1 = tm.createTask('Task 1', 'user', 'claude', []); + const t2 = tm.createTask('Task 2', 'user', 'claude', []); expect(t1.id).not.toBe(t2.id); }); it('emits task-created event', () => { const handler = vi.fn(); tm.on('task-created', handler); - const task = tm.createTask('Task', 'robin', 'claude', []); + const task = tm.createTask('Task', 'user', 'claude', []); expect(handler).toHaveBeenCalledWith(task); }); it('persists task to filesystem', () => { - const task = tm.createTask('Task', 'robin', 'claude', []); + const task = tm.createTask('Task', 'user', 'claude', []); expect(fs.writeFileSync).toHaveBeenCalledWith( expect.stringContaining(task.id), expect.any(String) @@ -164,7 +164,7 @@ describe('TaskManager', () => { describe('emergencyStop()', () => { it('pauses running tasks', () => { // Create a task and simulate it running - const task = tm.createTask('Task', 'robin', 'claude', [ + const task = tm.createTask('Task', 'user', 'claude', [ { description: 'Step', action: { type: 'click', params: {} }, riskLevel: 'medium', requiresApproval: true }, ]); @@ -216,7 +216,7 @@ describe('TaskManager', () => { describe('approval flow', () => { it('respondToApproval emits approval-response', () => { - const task = tm.createTask('Task', 'robin', 'claude', [ + const task = tm.createTask('Task', 'user', 'claude', [ { description: 'Step', action: { type: 'click', params: {} }, riskLevel: 'medium', requiresApproval: true }, ]); @@ -240,7 +240,7 @@ describe('TaskManager', () => { let task: AITask; beforeEach(() => { - task = tm.createTask('Lifecycle test', 'robin', 'claude', [ + task = tm.createTask('Lifecycle test', 'user', 'claude', [ { description: 'Step 1', action: { type: 'read_page', params: {} }, riskLevel: 'none', requiresApproval: false }, { description: 'Step 2', action: { type: 'click', params: {} }, riskLevel: 'medium', requiresApproval: true }, ]); diff --git a/src/api/routes/media.ts b/src/api/routes/media.ts index 16594b7..eea3deb 100644 --- a/src/api/routes/media.ts +++ b/src/api/routes/media.ts @@ -80,7 +80,7 @@ export function registerMediaRoutes(router: Router, ctx: RouteContext): void { router.post('/chat', (req: Request, res: Response) => { const { text, from, image } = req.body; if (!text && !image) { res.status(400).json({ error: 'text or image required' }); return; } - const sender: 'robin' | 'wingman' | 'claude' = (from === 'robin') ? 'robin' : (from === 'claude') ? 'claude' : 'wingman'; + const sender: 'user' | 'wingman' | 'claude' = (from === 'user') ? 'user' : (from === 'claude') ? 'claude' : 'wingman'; try { let savedImage: string | undefined; if (image) { diff --git a/src/api/routes/tabs.ts b/src/api/routes/tabs.ts index ad7c8f3..6c2dad8 100644 --- a/src/api/routes/tabs.ts +++ b/src/api/routes/tabs.ts @@ -13,7 +13,7 @@ export function registerTabRoutes(router: Router, ctx: RouteContext): void { const { url = 'about:blank', groupId, - source = 'robin', + source = 'user', focus = true, inheritSessionFrom, workspaceId, @@ -31,7 +31,7 @@ export function registerTabRoutes(router: Router, ctx: RouteContext): void { return; } try { - const tabSource = source === 'wingman' ? 'wingman' as const : 'robin' as const; + const tabSource = source === 'wingman' ? 'wingman' as const : 'user' as const; const tab = await ctx.tabManager.openTab( url, groupId, diff --git a/src/api/tests/helpers.ts b/src/api/tests/helpers.ts index 8e8be77..2d784bd 100644 --- a/src/api/tests/helpers.ts +++ b/src/api/tests/helpers.ts @@ -57,7 +57,7 @@ export function createMockContext(): RouteContext { url: 'about:blank', title: '', active: true, - source: 'robin', + source: 'user', partition: 'persist:tandem', }), closeTab: vi.fn().mockResolvedValue(true), @@ -74,7 +74,7 @@ export function createMockContext(): RouteContext { url: 'https://example.com', title: 'Example', active: true, - source: 'robin', + source: 'user', partition: 'persist:tandem', }), setEmoji: vi.fn().mockReturnValue(true), diff --git a/src/api/tests/routes/media.test.ts b/src/api/tests/routes/media.test.ts index 03e43b7..755816c 100644 --- a/src/api/tests/routes/media.test.ts +++ b/src/api/tests/routes/media.test.ts @@ -107,7 +107,7 @@ describe('Media Routes', () => { }); it('supports ?since_id= for polling', async () => { - const newMessages = [{ id: 3, from: 'robin', text: 'new', ts: 2000 }]; + const newMessages = [{ id: 3, from: 'user', text: 'new', ts: 2000 }]; vi.mocked(ctx.panelManager.getChatMessagesSince).mockReturnValue(newMessages as any); const res = await request(app).get('/chat?since_id=2'); @@ -153,12 +153,12 @@ describe('Media Routes', () => { expect(ctx.panelManager.addChatMessage).toHaveBeenCalledWith('wingman', 'hello', undefined); }); - it('maps from=robin to sender robin', async () => { + it('maps from=user to sender user', async () => { await request(app) .post('/chat') - .send({ text: 'hi', from: 'robin' }); + .send({ text: 'hi', from: 'user' }); - expect(ctx.panelManager.addChatMessage).toHaveBeenCalledWith('robin', 'hi', undefined); + expect(ctx.panelManager.addChatMessage).toHaveBeenCalledWith('user', 'hi', undefined); }); it('maps from=claude to sender claude', async () => { diff --git a/src/api/tests/routes/tabs.test.ts b/src/api/tests/routes/tabs.test.ts index 11765d5..0676ca4 100644 --- a/src/api/tests/routes/tabs.test.ts +++ b/src/api/tests/routes/tabs.test.ts @@ -38,28 +38,28 @@ describe('Tab Routes', () => { expect(ctx.tabManager.openTab).toHaveBeenCalledWith( 'about:blank', undefined, - 'robin', + 'user', 'persist:tandem', true, undefined, ); expect(ctx.panelManager.logActivity).toHaveBeenCalledWith( 'tab-open', - { url: 'about:blank', source: 'robin', inheritSessionFrom: null, workspaceId: null }, + { url: 'about:blank', source: 'user', inheritSessionFrom: null, workspaceId: null }, ); }); it('opens a tab with explicit url and groupId', async () => { const res = await request(app) .post('/tabs/open') - .send({ url: 'https://example.com', groupId: 'g1', source: 'robin', focus: false }); + .send({ url: 'https://example.com', groupId: 'g1', source: 'user', focus: false }); expect(res.status).toBe(200); expect(res.body.ok).toBe(true); expect(ctx.tabManager.openTab).toHaveBeenCalledWith( 'https://example.com', 'g1', - 'robin', + 'user', 'persist:tandem', false, undefined, @@ -93,7 +93,7 @@ describe('Tab Routes', () => { expect(ctx.tabManager.openTab).toHaveBeenCalledWith( 'about:blank', undefined, - 'robin', + 'user', 'persist:tandem', true, undefined, @@ -109,7 +109,7 @@ describe('Tab Routes', () => { expect(ctx.tabManager.openTab).toHaveBeenCalledWith( 'https://discord.com/channels/@me', undefined, - 'robin', + 'user', 'persist:tandem', true, { inheritSessionFrom: 'tab-9' }, @@ -118,7 +118,7 @@ describe('Tab Routes', () => { 'tab-open', { url: 'https://discord.com/channels/@me', - source: 'robin', + source: 'user', inheritSessionFrom: 'tab-9', workspaceId: null, }, @@ -156,7 +156,7 @@ describe('Tab Routes', () => { 'tab-open', { url: 'https://example.com', - source: 'robin', + source: 'user', inheritSessionFrom: null, workspaceId: 'ws-ai', }, diff --git a/src/bootstrap/tab-session.ts b/src/bootstrap/tab-session.ts index b26bd5a..9619bfb 100644 --- a/src/bootstrap/tab-session.ts +++ b/src/bootstrap/tab-session.ts @@ -27,7 +27,7 @@ async function restoreSessionTabs(runtime: RuntimeManagers, initialTabId: string let firstRestoredTabId: string | null = null; for (const savedTab of saved.tabs) { try { - const tab = await runtime.tabManager.openTab(savedTab.url, savedTab.groupId ?? undefined, 'robin', 'persist:tandem', false); + const tab = await runtime.tabManager.openTab(savedTab.url, savedTab.groupId ?? undefined, 'user', 'persist:tandem', false); const targetWorkspaceId = savedTab.workspaceId && runtime.workspaceManager.get(savedTab.workspaceId) ? savedTab.workspaceId : defaultWorkspaceId; diff --git a/src/context-menu/menu-builder.ts b/src/context-menu/menu-builder.ts index 3bf61e9..85b97ec 100644 --- a/src/context-menu/menu-builder.ts +++ b/src/context-menu/menu-builder.ts @@ -623,7 +623,7 @@ export class ContextMenuBuilder { menu.append(new MenuItem({ label: currentSource === 'wingman' ? 'Take back from Wingman' : 'Let Wingman handle this tab', click: () => { - const newSource = this.deps.tabManager.getTabSource(tabId) === 'wingman' ? 'robin' : 'wingman'; + const newSource = this.deps.tabManager.getTabSource(tabId) === 'wingman' ? 'user' : 'wingman'; this.deps.tabManager.setTabSource(tabId, newSource); }, })); diff --git a/src/context-menu/types.ts b/src/context-menu/types.ts index fa87957..5399bb1 100644 --- a/src/context-menu/types.ts +++ b/src/context-menu/types.ts @@ -34,7 +34,7 @@ export interface ContextMenuParams { }; // Tandem-specific tabId?: string; - tabSource?: 'robin' | 'wingman'; + tabSource?: 'user' | 'wingman'; } /** diff --git a/src/ipc/handlers.ts b/src/ipc/handlers.ts index 264359a..9eaa914 100644 --- a/src/ipc/handlers.ts +++ b/src/ipc/handlers.ts @@ -131,29 +131,29 @@ export function registerIpcHandlers(deps: IpcDeps): void { syncTabsToContext(tabManager, contextBridge); }); - // โ•โ•โ• Chat IPC โ€” Robin sends messages from renderer โ•โ•โ• + // โ•โ•โ• Chat IPC โ€” User sends messages from renderer โ•โ•โ• ipcMain.on(IpcChannels.CHAT_SEND, (_event, text: string) => { if (text) { - panelManager.addChatMessage('robin', text); + panelManager.addChatMessage('user',text); } }); // Legacy webhook-based path kept as a fallback during OpenClaw chat migration. ipcMain.on(IpcChannels.CHAT_SEND_LEGACY, (_event, text: string) => { if (text) { - panelManager.addChatMessage('robin', text); + panelManager.addChatMessage('user',text); } }); - // โ•โ•โ• Chat Image IPC โ€” Robin pastes image from clipboard โ•โ•โ• + // โ•โ•โ• Chat Image IPC โ€” User pastes image from clipboard โ•โ•โ• ipcMain.handle(IpcChannels.CHAT_SEND_IMAGE, async (_event, data: { text: string; image: string }) => { const filename = panelManager.saveImage(data.image); - const msg = panelManager.addChatMessage('robin', data.text || '', filename); + const msg = panelManager.addChatMessage('user',data.text || '', filename); return { ok: true, message: msg }; }); ipcMain.handle(IpcChannels.CHAT_PERSIST_MESSAGE, async (_event, data: { - from: 'robin' | 'wingman' | 'claude'; + from: 'user' | 'wingman' | 'claude'; text?: string; image?: string; notifyWebhook?: boolean; @@ -499,7 +499,7 @@ export function registerIpcHandlers(deps: IpcDeps): void { const tab = await tabManager.openTab(targetUrl); if (tab) { eventStream.handleTabEvent('tab-opened', { tabId: tab.id, url: targetUrl }); - activityTracker.onWebviewEvent({ type: 'tab-open', tabId: tab.id, url: targetUrl, source: 'robin' }); + activityTracker.onWebviewEvent({ type: 'tab-open', tabId: tab.id, url: targetUrl, source: 'user' }); } syncTabsToContext(tabManager, contextBridge); return tab; diff --git a/src/mcp/tests/chat.test.ts b/src/mcp/tests/chat.test.ts index e4dd7a2..7bfdc65 100644 --- a/src/mcp/tests/chat.test.ts +++ b/src/mcp/tests/chat.test.ts @@ -37,11 +37,11 @@ describe('MCP chat tools', () => { it('returns formatted chat history', async () => { mockApiCall.mockResolvedValueOnce({ - messages: [{ from: 'robin', text: 'hi', timestamp: 1700000000000 }], + messages: [{ from: 'user', text: 'hi', timestamp: 1700000000000 }], }); const result = await handler({ limit: 20 }); const text = expectTextContent(result, 'Chat history (1 messages)'); - expect(text).toContain('robin: hi'); + expect(text).toContain('user: hi'); }); it('handles empty chat', async () => { diff --git a/src/panel/manager.ts b/src/panel/manager.ts index 7279843..c385a5e 100644 --- a/src/panel/manager.ts +++ b/src/panel/manager.ts @@ -20,7 +20,7 @@ export interface ActivityEvent { export interface ChatMessage { id: number; - from: 'robin' | 'wingman' | 'claude'; + from: 'user' | 'wingman' | 'claude'; text: string; timestamp: number; image?: string; // relative filename in ~/.tandem/chat-images/ @@ -114,7 +114,7 @@ export class PanelManager { /** Add a chat message */ addChatMessage( - from: 'robin' | 'wingman' | 'claude', + from: 'user' | 'wingman' | 'claude', text: string, image?: string, opts: AddChatMessageOptions = {}, @@ -138,7 +138,7 @@ export class PanelManager { this.maybeNotifyForIncomingReply(msg); - // Fire webhook for robin messages (async, non-blocking) + // Fire webhook for user messages (async, non-blocking) if (opts.notifyWebhook !== false) { this.fireWebhook(msg).catch(e => log.warn('fireWebhook failed:', e instanceof Error ? e.message : e)); } @@ -240,8 +240,8 @@ export class PanelManager { if (!this.configManager) return; const config = this.configManager.getConfig(); if (!config.webhook?.enabled || !config.webhook?.url) return; - // Only notify for robin messages (wingman messages come FROM OpenClaw, no need to echo back) - if (msg.from !== 'robin') return; + // Only notify for user messages (wingman messages come FROM OpenClaw, no need to echo back) + if (msg.from !== 'user') return; if (!config.webhook.notifyOnRobinChat) return; const url = config.webhook.url.replace(/\/$/, ''); @@ -254,7 +254,7 @@ export class PanelManager { ...(config.webhook.secret ? { 'Authorization': `Bearer ${config.webhook.secret}` } : {}), }, body: JSON.stringify({ - text: `[Tandem Chat] Robin: ${msg.text}${msg.image ? ' [image attached]' : ''}`, + text: `[Tandem Chat] User: ${msg.text}${msg.image ? ' [image attached]' : ''}`, mode: 'now', }), signal: AbortSignal.timeout(5000), @@ -273,7 +273,7 @@ export class PanelManager { private maybeNotifyForIncomingReply(msg: ChatMessage): void { if (this.panelOpen) return; - if (msg.from === 'robin') return; + if (msg.from === 'user') return; const sender = this.getReplySenderLabel(msg.from); const body = this.buildReplyNotificationBody(msg); diff --git a/src/panel/tests/manager.test.ts b/src/panel/tests/manager.test.ts index 22d169a..20903f3 100644 --- a/src/panel/tests/manager.test.ts +++ b/src/panel/tests/manager.test.ts @@ -57,11 +57,11 @@ describe('PanelManager reply notifications', () => { expect(wingmanAlert).not.toHaveBeenCalled(); }); - it('does not notify for Robin messages', () => { + it('does not notify for user messages', () => { const win = createWindowStub(); const manager = new PanelManager(win as never); - manager.addChatMessage('robin', 'This is my own message.'); + manager.addChatMessage('user', 'This is my own message.'); expect(wingmanAlert).not.toHaveBeenCalled(); }); @@ -89,6 +89,56 @@ describe('PanelManager reply notifications', () => { expect(lastTyping[1]).toEqual({ typing: false }); }); + it('does not fire webhook for wingman messages', async () => { + const win = createWindowStub(); + const manager = new PanelManager(win as never); + + const mockConfigManager = { + getConfig: vi.fn().mockReturnValue({ + webhook: { enabled: true, url: 'http://localhost:9999', notifyOnRobinChat: true }, + }), + }; + (manager as any).configManager = mockConfigManager; + + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true } as Response); + + manager.addChatMessage('wingman', 'AI response'); + + // Give the async fireWebhook a tick to run + await new Promise(r => setTimeout(r, 10)); + + expect(fetchSpy).not.toHaveBeenCalled(); + fetchSpy.mockRestore(); + }); + + it('fires webhook for user messages when configured', async () => { + const win = createWindowStub(); + const manager = new PanelManager(win as never); + + const mockConfigManager = { + getConfig: vi.fn().mockReturnValue({ + webhook: { enabled: true, url: 'http://localhost:9999', notifyOnRobinChat: true }, + }), + }; + (manager as any).configManager = mockConfigManager; + + const fetchSpy = vi.spyOn(globalThis, 'fetch').mockResolvedValue({ ok: true } as Response); + + manager.addChatMessage('user', 'Hello from user'); + + await new Promise(r => setTimeout(r, 10)); + + expect(fetchSpy).toHaveBeenCalledTimes(1); + expect(fetchSpy).toHaveBeenCalledWith( + 'http://localhost:9999/hooks/wake', + expect.objectContaining({ + method: 'POST', + body: expect.stringContaining('Hello from user'), + }), + ); + fetchSpy.mockRestore(); + }); + it('falls back to an image message when there is no text', () => { const win = createWindowStub(); const manager = new PanelManager(win as never); diff --git a/src/preload/panel.ts b/src/preload/panel.ts index 654ebf5..a82aeb0 100644 --- a/src/preload/panel.ts +++ b/src/preload/panel.ts @@ -28,7 +28,7 @@ export function createPanelApi() { }, sendChatImage: (text: string, image: string) => ipcRenderer.invoke(IpcChannels.CHAT_SEND_IMAGE, { text, image }), persistChatMessage: (data: { - from: 'robin' | 'wingman' | 'claude'; + from: 'user' | 'wingman' | 'claude'; text?: string; image?: string; notifyWebhook?: boolean; diff --git a/src/tabs/manager.ts b/src/tabs/manager.ts index 3a4ba67..0591e79 100644 --- a/src/tabs/manager.ts +++ b/src/tabs/manager.ts @@ -9,7 +9,7 @@ const log = createLogger('TabManager'); // โ”€โ”€โ”€ Types โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ -export type TabSource = 'robin' | 'wingman'; +export type TabSource = 'user' | 'wingman'; export interface Tab { id: string; @@ -146,7 +146,7 @@ export class TabManager { async openTab( url: string = 'about:blank', groupId?: string, - source: TabSource = 'robin', + source: TabSource = 'user', partition: string = 'persist:tandem', focus: boolean = true, options?: OpenTabOptions, @@ -447,7 +447,7 @@ export class TabManager { groupId: null, active: true, createdAt: Date.now(), - source: 'robin', + source: 'user', pinned: false, partition: 'persist:tandem', emoji: null, diff --git a/src/tabs/tests/tabs.test.ts b/src/tabs/tests/tabs.test.ts index 8b214db..4b4c32e 100644 --- a/src/tabs/tests/tabs.test.ts +++ b/src/tabs/tests/tabs.test.ts @@ -98,7 +98,7 @@ describe('TabManager', () => { it('creates a new tab with default values', async () => { const tab = await tm.openTab('https://test.com'); expect(tab.url).toBe('https://test.com'); - expect(tab.source).toBe('robin'); + expect(tab.source).toBe('user'); expect(tab.pinned).toBe(false); expect(tab.partition).toBe('persist:tandem'); expect(tm.count).toBe(1); @@ -120,7 +120,7 @@ describe('TabManager', () => { it('does not focus when focus=false', async () => { const t1 = await tm.openTab('https://one.com'); - await tm.openTab('https://two.com', undefined, 'robin', 'persist:tandem', false); + await tm.openTab('https://two.com', undefined, 'user', 'persist:tandem', false); expect(tm.getActiveTab()?.id).toBe(t1.id); }); @@ -141,7 +141,7 @@ describe('TabManager', () => { const inheritedTab = await tm.openTab( 'https://discord.com/channels/123', undefined, - 'robin', + 'user', 'persist:tandem', true, { inheritSessionFrom: sourceTab.id }, @@ -157,7 +157,7 @@ describe('TabManager', () => { const tab = await tm.openTab( 'https://discord.com/channels/@me', undefined, - 'robin', + 'user', 'persist:tandem', true, { inheritSessionFrom: 'tab-999' }, @@ -228,7 +228,7 @@ describe('TabManager', () => { describe('focusTab()', () => { it('activates the target tab and deactivates the previous', async () => { const t1 = await tm.openTab('https://one.com'); - const t2 = await tm.openTab('https://two.com', undefined, 'robin', 'persist:tandem', false); + const t2 = await tm.openTab('https://two.com', undefined, 'user', 'persist:tandem', false); await tm.focusTab(t2.id); expect(tm.getActiveTab()?.id).toBe(t2.id); expect(tm.getTab(t1.id)?.active).toBe(false); diff --git a/src/voice/recognition.ts b/src/voice/recognition.ts index fd68e5b..db1b9c7 100644 --- a/src/voice/recognition.ts +++ b/src/voice/recognition.ts @@ -65,8 +65,8 @@ export class VoiceManager { /** Handle transcript from renderer */ handleTranscript(text: string, isFinal: boolean): void { if (isFinal && text.trim()) { - // Send as Robin's chat message - this.panelManager.addChatMessage('robin', `๐ŸŽ™๏ธ ${text.trim()}`); + // Send as user's chat message + this.panelManager.addChatMessage('user', `๐ŸŽ™๏ธ ${text.trim()}`); } // Send live transcript to renderer for display if (this.canSendToRenderer()) {