diff --git a/.gitignore b/.gitignore index 2b5cb8f..6bc6aca 100644 --- a/.gitignore +++ b/.gitignore @@ -18,3 +18,6 @@ native/speech/tandem-speech # Cowork workspace (local only, not for the repo) .cowork/ + +# Implementation plans (private, local only) +docs/superpowers/ diff --git a/AGENTS.md b/AGENTS.md index 9c61258..700d508 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -16,7 +16,7 @@ how it works, and why it exists. - **Repo:** `hydro13/tandem-browser` (GitHub: hydro13) - **Stack:** Electron 40 + TypeScript + Express.js API (`localhost:8765`) + - MCP server (236 tools) + MCP server (239 tools) - **Goal:** An agent-first browser where any AI (via MCP, HTTP API, or WebSocket) and a human browse together - **Philosophy:** Local-first, privacy-first, no cloud dependencies in the @@ -39,7 +39,7 @@ tandem-browser/ │ ├── snapshot/ # Accessibility tree with @refs │ ├── network/ # Inspector + mocking │ ├── sessions/ # Multi-session isolation -│ ├── mcp/ # MCP server (236 tools, full API parity) +│ ├── mcp/ # MCP server (239 tools, full API parity) │ │ ├── server.ts # MCP server entry point │ │ └── tools/ # Tool definitions (one file per domain) │ ├── agents/ # TaskManager, X-Scout, TabLockManager diff --git a/CHANGELOG.md b/CHANGELOG.md index e41f6d3..18b4ee6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ All notable changes to Tandem Browser will be documented in this file. +## [v0.71.0] - 2026-04-11 + +- feat: tab emoji badges — assign emoji to tabs for quick visual identification + +New endpoints: +- POST /tabs/:id/emoji — set or flash an emoji badge on a tab +- DELETE /tabs/:id/emoji — remove emoji badge from a tab + +New MCP tools: +- tandem_tab_emoji_set — set emoji badge on a tab +- tandem_tab_emoji_remove — remove emoji badge from a tab +- tandem_tab_emoji_flash — flash a pulsing emoji to attract user attention + +Emoji badges are per tab session. The AI can flash emojis to signal the user +(e.g. "this tab is ready for review"). Users assign emojis via right-click +context menu. Everything runs in the shell — no injection into webviews. + ## [v0.70.0] - 2026-04-10 - feat: add awareness tools — digest and focus for shared human-AI context diff --git a/PROJECT.md b/PROJECT.md index 19f6589..bc2b954 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -10,7 +10,7 @@ bicycle: two riders, one machine, each contributing what the other can't do alone. The browser runs two things in parallel. The human uses it like any other browser -while AI agents operate through a built-in **MCP server** (236 tools) or a full +while AI agents operate through a built-in **MCP server** (239 tools) or a full local **HTTP API** on `127.0.0.1:8765` with 300+ endpoints for navigation, interaction, data extraction, automation, sessions, sync, extensions, and developer tooling. Websites see a normal Chrome browser on macOS. They don't see @@ -192,15 +192,17 @@ New Tab ───────────────── Reload Duplicate Tab -Copy Page Address -───────────────── -Move to Workspace ▶ [workspace icon + name per workspace] -───────────────── +Add to / Remove from Quick Links +Pin Tab / Unpin Tab Mute Tab / Unmute Tab +Let Wingman handle this tab / Take back from Wingman +Set Emoji... ▶ [50 popular emojis grid, + Remove Emoji if set] ───────────────── Close Tab Close Other Tabs Close Tabs to the Right +───────────────── +Reopen Closed Tab ``` --- @@ -211,7 +213,7 @@ Most endpoints require the `Authorization: Bearer ` header. The token is Current route modules: - `browser.ts` — navigation, screenshots, page actions -- `tabs.ts` — tab management, groups, focus +- `tabs.ts` — tab management, groups, focus, emoji badges - `snapshots.ts` — accessibility tree and `@ref` interaction surfaces - `devtools.ts` — CDP bridge (console, network, DOM, storage) - `extensions.ts` — extension management and helper routes diff --git a/README.md b/README.md index 236850c..997a54c 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ [![Coverage](https://codecov.io/gh/hydro13/tandem-browser/branch/main/graph/badge.svg)](https://codecov.io/gh/hydro13/tandem-browser) [![Ask a question](https://img.shields.io/badge/discussions-Q%26A-blue)](https://github.com/hydro13/tandem-browser/discussions/categories/q-a) -**236 MCP tools. Plug in any AI. No scraping. No API wrangling.** +**239 MCP tools. Plug in any AI. No scraping. No API wrangling.** Tandem is a local-first Electron browser where a human and an AI agent browse together. The agent sees what you see, navigates your tabs, reads your pages, @@ -25,7 +25,7 @@ either protocol. | Category | Tools | Examples | |----------|-------|---------| | **Navigation & Input** | 10 | Navigate, click, type, scroll, press keys, wait for load | -| **Tabs & Workspaces** | 10 | Open/close/focus tabs, create workspaces, move tabs between them | +| **Tabs & Workspaces** | 13 | Open/close/focus tabs, emoji badges, create workspaces, move tabs between them | | **Page Content** | 8 | Read page, get HTML, extract content, get links, forms, screenshots | | **Accessibility Snapshots** | 7 | Accessibility tree with `@ref` IDs, click/fill by ref, semantic find | | **DevTools** | 12 | Console logs, network requests, DOM queries, XPath, performance, storage | @@ -42,7 +42,7 @@ either protocol. | **System** | 6 | Browser status, headless mode, Google Photos, security overrides | | **Awareness** | 2 | Activity digest, real-time focus detection — the AI knows what you're doing | -**236 tools total** — full parity with the HTTP API. +**239 tools total** — full parity with the HTTP API. ## Why Not Just Use Playwright? @@ -102,7 +102,7 @@ Add to your MCP configuration: } ``` -Start Tandem, and 236 tools are available immediately. +Start Tandem, and 239 tools are available immediately. ### Cursor / Windsurf / Other MCP Clients diff --git a/TODO.md b/TODO.md index 8d30414..545ab7a 100644 --- a/TODO.md +++ b/TODO.md @@ -15,7 +15,7 @@ Last updated: April 9, 2026 ## Current Snapshot - Current app version: `0.70.0` -- MCP server: 236 tools (full API parity + awareness) +- MCP server: 239 tools (full API parity + awareness) - The codebase scope is larger than this backlog summary and includes major subsystems such as `sidebar`, `workspaces`, `pinboards`, `sync`, `headless`, and `sessions`. - Scheduled browsing already exists in baseline form via `WatchManager` and the `/watch/*` API routes. - Session isolation already exists in baseline form via `SessionManager` and the `/sessions/*` API routes. diff --git a/docs/api.html b/docs/api.html index 0d1dabe..9443efe 100644 --- a/docs/api.html +++ b/docs/api.html @@ -117,7 +117,7 @@

Full programmatic control over a real browser

280+Endpoints
19Domains
-
236MCP tools
+
239MCP tools
@@ -126,7 +126,7 @@

Full programmatic control over a real browser

Browser 16 -Tabs 8 +Tabs 10 Snapshots 8 Content 17 DevTools 16 @@ -188,6 +188,8 @@

Full programmatic control over a real browser

POST/tabs/source Set tab source (agent owner)
POST/tabs/reconcile Reconcile tabs with renderer
POST/tabs/cleanup Cleanup zombie tabs
+
POST/tabs/:id/emoji Set or flash emoji badge on tab
+
DELETE/tabs/:id/emoji Remove emoji badge from tab
diff --git a/docs/index.html b/docs/index.html index 7c216c4..f8109a0 100644 --- a/docs/index.html +++ b/docs/index.html @@ -4,9 +4,9 @@ Tandem Browser — The AI browser where human and AI work as one - + - + @@ -133,7 +133,7 @@

The browser where human and AI work as one

Tandem Browser gives AI agents secure access to a real human browser. No API wrappers, no scraping, no bot detection. The accessibility tree of every website is the universal API. The AI rides alongside you — same tabs, same session, same screen.

-
236MCP tools
+
239MCP tools
300+HTTP endpoints
8Security layers
0Telemetry
diff --git a/docs/public-launch.md b/docs/public-launch.md index c95c6bd..93f5c2f 100644 --- a/docs/public-launch.md +++ b/docs/public-launch.md @@ -5,7 +5,7 @@ repository to the public. ## GitHub Repository Description -Agent-first browser for human-AI collaboration — 236 MCP tools, 300+ HTTP endpoints, built-in security. +Agent-first browser for human-AI collaboration — 239 MCP tools, 300+ HTTP endpoints, built-in security. ## Short Tagline @@ -13,14 +13,14 @@ An agent-first browser for human-AI collaboration. ## Social / Announcement One-Liner -Tandem Browser is now public: an agent-first browser for human-AI collaboration with 236 MCP tools and 300+ HTTP endpoints, released as a developer preview. +Tandem Browser is now public: an agent-first browser for human-AI collaboration with 239 MCP tools and 300+ HTTP endpoints, released as a developer preview. ## Launch Post Tandem Browser is now public. Tandem is an agent-first browser built for human-AI collaboration on the local -machine. The human browses normally. Any AI agent that speaks MCP (236 tools) or +machine. The human browses normally. Any AI agent that speaks MCP (239 tools) or HTTP (300+ endpoints) gets full browser control for navigation, extraction, automation, screenshots, session work, and observability, while websites continue to see a normal Chromium browser instead of an "AI browser" fingerprint. diff --git a/package.json b/package.json index 1a9e9bd..e55ac9f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "tandem-browser", "version": "0.70.0", - "description": "Local-first Electron browser for human-AI collaboration with 236-tool MCP server, 300+ endpoint HTTP API, and built-in security controls", + "description": "Local-first Electron browser for human-AI collaboration with 239-tool MCP server, 300+ endpoint HTTP API, and built-in security controls", "main": "dist/main.js", "author": "Tandem Browser contributors", "license": "MIT", diff --git a/shell/css/browser-shell.css b/shell/css/browser-shell.css index 93ecfe4..8a5b3b4 100644 --- a/shell/css/browser-shell.css +++ b/shell/css/browser-shell.css @@ -332,6 +332,21 @@ flex-shrink: 0; } + .tab .tab-emoji { + font-size: 12px; + flex-shrink: 0; + line-height: 1; + } + + .tab .tab-emoji.flash { + animation: emojiFlash 1s ease-in-out infinite; + } + + @keyframes emojiFlash { + 0%, 100% { opacity: 1; transform: scale(1); } + 50% { opacity: 0.4; transform: scale(1.3); } + } + .tab .tab-favicon { width: 14px; height: 14px; diff --git a/shell/css/sidebar-panels.css b/shell/css/sidebar-panels.css index 5cd7e50..eb6bbf3 100644 --- a/shell/css/sidebar-panels.css +++ b/shell/css/sidebar-panels.css @@ -476,6 +476,32 @@ right: 100%; } +/* Emoji picker grid inside context menu submenu */ +.tandem-emoji-grid { + min-width: 240px; + max-width: 260px; +} +.tandem-emoji-picker { + display: grid; + grid-template-columns: repeat(10, 1fr); + gap: 2px; + padding: 6px 8px; +} +.tandem-emoji-btn { + font-size: 16px; + width: 22px; + height: 22px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + border-radius: 4px; + transition: background 0.1s; +} +.tandem-emoji-btn:hover { + background: rgba(255,255,255,0.15); +} + /* ═══════════════════════════════════════════════ */ /* PINBOARDS — Sidebar Panel + Card Grid */ /* ═══════════════════════════════════════════════ */ diff --git a/shell/js/sidebar.js b/shell/js/sidebar.js index d650321..e47f600 100644 --- a/shell/js/sidebar.js +++ b/shell/js/sidebar.js @@ -2124,6 +2124,70 @@ if (wv) wv.audioMuted = !isMuted; }); + // — Set Emoji (submenu) + { + const emojiItem = document.createElement('div'); + emojiItem.className = 'tandem-ctx-menu-item'; + const tabEl = document.querySelector('.tab[data-tab-id="' + domTabId + '"]'); + const tabEmojiSpan = tabEl ? tabEl.querySelector('.tab-emoji') : null; + const currentEmoji = (tabEmojiSpan && tabEmojiSpan.style.display !== 'none') ? tabEmojiSpan.textContent : ''; + const emojiLabel = document.createElement('span'); + emojiLabel.textContent = currentEmoji ? ('Emoji: ' + currentEmoji) : 'Set Emoji...'; + const emojiArrow = document.createElement('span'); + emojiArrow.className = 'ctx-arrow'; + emojiArrow.textContent = '▶'; + emojiItem.appendChild(emojiLabel); + emojiItem.appendChild(emojiArrow); + + const emojiSub = document.createElement('div'); + emojiSub.className = 'tandem-ctx-submenu tandem-emoji-grid'; + + if (currentEmoji) { + const removeItem = document.createElement('div'); + removeItem.className = 'tandem-ctx-submenu-item'; + removeItem.textContent = 'Remove Emoji'; + removeItem.addEventListener('click', async () => { + closeCtxMenu(); + await fetch('http://localhost:8765/tabs/' + encodeURIComponent(domTabId) + '/emoji', { + method: 'DELETE', + headers: { Authorization: 'Bearer ' + TOKEN } + }); + }); + emojiSub.appendChild(removeItem); + const sep = document.createElement('div'); + sep.className = 'tandem-ctx-separator'; + emojiSub.appendChild(sep); + } + + const emojis = [ + '🔥','⭐','💡','🚀','✅','❌','⚠️','🎯','💬','📌', + '📚','🧪','🔧','🎨','📊','🔒','👀','💰','🎵','❤️', + '🏠','📧','🛒','📝','🗂️','🌍','☁️','📸','🎮','🤖', + '🧠','🔍','📅','🎁','🏷️','⏰','🔔','💻','📱','🎬', + '🍕','☕','🌟','💎','🦊','🐛','🏗️','📦','🔗','🏆', + ]; + const grid = document.createElement('div'); + grid.className = 'tandem-emoji-picker'; + emojis.forEach(emoji => { + const btn = document.createElement('span'); + btn.className = 'tandem-emoji-btn'; + btn.textContent = emoji; + btn.addEventListener('click', async () => { + closeCtxMenu(); + await fetch('http://localhost:8765/tabs/' + encodeURIComponent(domTabId) + '/emoji', { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: 'Bearer ' + TOKEN }, + body: JSON.stringify({ emoji: emoji }) + }); + }); + grid.appendChild(btn); + }); + emojiSub.appendChild(grid); + + emojiItem.appendChild(emojiSub); + menu.appendChild(emojiItem); + } + addSep(); // — Close Tab diff --git a/shell/js/tabs.js b/shell/js/tabs.js index 6e5669d..d050105 100644 --- a/shell/js/tabs.js +++ b/shell/js/tabs.js @@ -87,8 +87,9 @@ tabEl.dataset.tabId = tabId; tabEl.draggable = true; tabEl.innerHTML = ` - 👤 + + New Tab @@ -345,6 +346,22 @@ focusTab(tabId) { focusRendererTab(tabId); }, + + setEmoji(tabId, emoji, flash) { + const entry = tabs.get(tabId); + if (!entry) return; + const emojiEl = entry.tabEl.querySelector('.tab-emoji'); + if (!emojiEl) return; + if (emoji) { + emojiEl.textContent = emoji; + emojiEl.style.display = ''; + emojiEl.classList.toggle('flash', !!flash); + } else { + emojiEl.textContent = ''; + emojiEl.style.display = 'none'; + emojiEl.classList.remove('flash'); + } + }, }; window.__tandemRenderer = { @@ -445,5 +462,11 @@ } }); } + + if (window.tandem && window.tandem.onTabEmojiChanged) { + window.tandem.onTabEmojiChanged((data) => { + window.__tandemTabs.setEmoji(data.tabId, data.emoji, data.flash); + }); + } })(); })(); diff --git a/shell/js/wingman.js b/shell/js/wingman.js index e0c11f3..1d4c48c 100644 --- a/shell/js/wingman.js +++ b/shell/js/wingman.js @@ -207,8 +207,15 @@ if (id === data.tabId) { const sourceEl = entry.tabEl.querySelector('.tab-source'); if (sourceEl) { - sourceEl.textContent = data.source === 'kees' ? '🤖' : '👤'; - sourceEl.title = data.source === 'kees' ? 'AI controlled — click to take over' : 'You controlled'; + if (data.source === 'kees') { + sourceEl.textContent = '🤖'; + sourceEl.title = 'AI controlled — click to take over'; + sourceEl.style.display = ''; + } else { + sourceEl.textContent = ''; + sourceEl.title = ''; + sourceEl.style.display = 'none'; + } } // Visual indicator: purple bottom border for AI tabs if (data.source === 'kees') { diff --git a/skill/SKILL.md b/skill/SKILL.md index adfdfeb..b5f6f9c 100644 --- a/skill/SKILL.md +++ b/skill/SKILL.md @@ -22,7 +22,7 @@ instead of a sandbox browser, especially for: ### Option 1: MCP Server (recommended) -The MCP server exposes 236 tools with full API parity. Add to your MCP client +The MCP server exposes 239 tools with full API parity. Add to your MCP client configuration (e.g. `~/.claude/settings.json` for Claude Code): ```json @@ -36,7 +36,7 @@ configuration (e.g. `~/.claude/settings.json` for Claude Code): } ``` -Start Tandem (`npm start`), and the agent has 236 tools available immediately. +Start Tandem (`npm start`), and the agent has 239 tools available immediately. All MCP tools mirror the HTTP API below, so the same capabilities are available through either connection method. diff --git a/src/api/routes/tabs.ts b/src/api/routes/tabs.ts index faedcb4..465b29e 100644 --- a/src/api/routes/tabs.ts +++ b/src/api/routes/tabs.ts @@ -126,6 +126,43 @@ export function registerTabRoutes(router: Router, ctx: RouteContext): void { } }); + // Set or flash emoji on a tab + router.post('/tabs/:id/emoji', (req: Request, res: Response) => { + try { + const id = req.params.id as string; + const { emoji, flash } = req.body; + if (!emoji) { + res.status(400).json({ error: 'emoji required' }); + return; + } + const ok = flash + ? ctx.tabManager.flashEmoji(id, emoji) + : ctx.tabManager.setEmoji(id, emoji); + if (!ok) { + res.status(404).json({ error: 'Tab not found' }); + return; + } + res.json({ ok: true }); + } catch (e) { + handleRouteError(res, e); + } + }); + + // Remove emoji from a tab + router.delete('/tabs/:id/emoji', (req: Request, res: Response) => { + try { + const id = req.params.id as string; + const ok = ctx.tabManager.clearEmoji(id); + if (!ok) { + res.status(404).json({ error: 'Tab not found' }); + return; + } + res.json({ ok: true }); + } catch (e) { + handleRouteError(res, e); + } + }); + // Cleanup zombie tabs (unmanaged webContents) router.post('/tabs/cleanup', (_req: Request, res: Response) => { try { diff --git a/src/api/tests/helpers.ts b/src/api/tests/helpers.ts index 35f9413..8e8be77 100644 --- a/src/api/tests/helpers.ts +++ b/src/api/tests/helpers.ts @@ -77,6 +77,10 @@ export function createMockContext(): RouteContext { source: 'robin', partition: 'persist:tandem', }), + setEmoji: vi.fn().mockReturnValue(true), + clearEmoji: vi.fn().mockReturnValue(true), + flashEmoji: vi.fn().mockReturnValue(true), + getEmoji: vi.fn().mockReturnValue(null), count: 1, } as any, diff --git a/src/api/tests/routes/tabs.test.ts b/src/api/tests/routes/tabs.test.ts index d1f1923..90e5cd4 100644 --- a/src/api/tests/routes/tabs.test.ts +++ b/src/api/tests/routes/tabs.test.ts @@ -391,4 +391,72 @@ describe('Tab Routes', () => { expect(chromeWc.close).not.toHaveBeenCalled(); }); }); + + // ─── POST /tabs/:id/emoji ──────────────────────── + + describe('POST /tabs/:id/emoji', () => { + it('sets emoji on a tab', async () => { + const res = await request(app) + .post('/tabs/tab-1/emoji') + .send({ emoji: '🔥' }); + + expect(res.status).toBe(200); + expect(res.body.ok).toBe(true); + expect(ctx.tabManager.setEmoji).toHaveBeenCalledWith('tab-1', '🔥'); + }); + + it('returns 400 when emoji is missing', async () => { + const res = await request(app) + .post('/tabs/tab-1/emoji') + .send({}); + + expect(res.status).toBe(400); + expect(res.body.error).toBe('emoji required'); + expect(ctx.tabManager.setEmoji).not.toHaveBeenCalled(); + }); + + it('returns 404 when tab not found', async () => { + vi.mocked(ctx.tabManager.setEmoji).mockReturnValueOnce(false); + + const res = await request(app) + .post('/tabs/bad-id/emoji') + .send({ emoji: '🔥' }); + + expect(res.status).toBe(404); + expect(res.body.error).toBe('Tab not found'); + }); + + it('flashes emoji when flash=true', async () => { + const res = await request(app) + .post('/tabs/tab-1/emoji') + .send({ emoji: '🔥', flash: true }); + + expect(res.status).toBe(200); + expect(res.body.ok).toBe(true); + expect(ctx.tabManager.flashEmoji).toHaveBeenCalledWith('tab-1', '🔥'); + }); + }); + + // ─── DELETE /tabs/:id/emoji ────────────────────── + + describe('DELETE /tabs/:id/emoji', () => { + it('removes emoji from a tab', async () => { + const res = await request(app) + .delete('/tabs/tab-1/emoji'); + + expect(res.status).toBe(200); + expect(res.body.ok).toBe(true); + expect(ctx.tabManager.clearEmoji).toHaveBeenCalledWith('tab-1'); + }); + + it('returns 404 when tab not found', async () => { + vi.mocked(ctx.tabManager.clearEmoji).mockReturnValueOnce(false); + + const res = await request(app) + .delete('/tabs/bad-id/emoji'); + + expect(res.status).toBe(404); + expect(res.body.error).toBe('Tab not found'); + }); + }); }); diff --git a/src/context-menu/menu-builder.ts b/src/context-menu/menu-builder.ts index b7142f9..8089587 100644 --- a/src/context-menu/menu-builder.ts +++ b/src/context-menu/menu-builder.ts @@ -628,6 +628,36 @@ export class ContextMenuBuilder { }, })); + // Emoji badge + const currentEmoji = this.deps.tabManager.getEmoji(tabId); + const popularEmojis = [ + '🔥', '⭐', '💡', '🚀', '✅', '❌', '⚠️', '🎯', '💬', '📌', + '📚', '🧪', '🔧', '🎨', '📊', '🔒', '👀', '💰', '🎵', '❤️', + '🏠', '📧', '🛒', '📝', '🗂️', '🌍', '☁️', '📸', '🎮', '🤖', + '🧠', '🔍', '📅', '🎁', '🏷️', '⏰', '🔔', '💻', '📱', '🎬', + '🍕', '☕', '🌟', '💎', '🦊', '🐛', '🏗️', '📦', '🔗', '🏆', + ]; + + const emojiSubmenu = Menu.buildFromTemplate( + popularEmojis.map(emoji => ({ + label: emoji, + click: () => this.deps.tabManager.setEmoji(tabId, emoji), + })) + ); + + if (currentEmoji) { + emojiSubmenu.insert(0, new MenuItem({ type: 'separator' })); + emojiSubmenu.insert(0, new MenuItem({ + label: 'Remove Emoji', + click: () => this.deps.tabManager.clearEmoji(tabId), + })); + } + + menu.append(new MenuItem({ + label: currentEmoji ? `Emoji: ${currentEmoji}` : 'Set Emoji...', + submenu: emojiSubmenu, + })); + this.addSeparator(menu); menu.append(new MenuItem({ diff --git a/src/mcp/tests/tabs.test.ts b/src/mcp/tests/tabs.test.ts index b64306a..c28e3fe 100644 --- a/src/mcp/tests/tabs.test.ts +++ b/src/mcp/tests/tabs.test.ts @@ -58,6 +58,20 @@ describe('MCP tab tools', () => { mockApiCall.mockRejectedValueOnce(new Error('connection refused')); await expect(handler({})).rejects.toThrow('connection refused'); }); + + it('includes emoji in tab listing', async () => { + mockApiCall.mockResolvedValueOnce({ + tabs: [ + { id: 't1', title: 'Project', url: 'https://github.com', active: true, emoji: '🔥' }, + { id: 't2', title: 'Docs', url: 'https://docs.com', active: false, emoji: null }, + ], + }); + + const result = await handler({}); + const text = expectTextContent(result, 'Open tabs (2)'); + expect(text).toContain('🔥 Project'); + expect(text).not.toContain('null'); + }); }); // ── tandem_open_tab ─────────────────────────────────────────────── @@ -131,4 +145,49 @@ describe('MCP tab tools', () => { await expect(handler({ tabId: 'bad' })).rejects.toThrow('tab not found'); }); }); + + // ── tandem_tab_emoji_set ───────────────────────────────────────── + describe('tandem_tab_emoji_set', () => { + const handler = getHandler(tools, 'tandem_tab_emoji_set'); + + it('sets emoji on a tab', async () => { + mockApiCall.mockResolvedValueOnce({ ok: true }); + mockLogActivity.mockResolvedValueOnce(undefined); + + const result = await handler({ tabId: 't1', emoji: '🔥' }); + expectTextContent(result, 'Set emoji'); + expect(mockApiCall).toHaveBeenCalledWith('POST', '/tabs/t1/emoji', { emoji: '🔥' }); + expect(mockLogActivity).toHaveBeenCalledWith('tab_emoji_set', 't1: 🔥'); + }); + }); + + // ── tandem_tab_emoji_remove ────────────────────────────────────── + describe('tandem_tab_emoji_remove', () => { + const handler = getHandler(tools, 'tandem_tab_emoji_remove'); + + it('removes emoji from a tab', async () => { + mockApiCall.mockResolvedValueOnce({ ok: true }); + mockLogActivity.mockResolvedValueOnce(undefined); + + const result = await handler({ tabId: 't1' }); + expectTextContent(result, 'Removed emoji'); + expect(mockApiCall).toHaveBeenCalledWith('DELETE', '/tabs/t1/emoji'); + expect(mockLogActivity).toHaveBeenCalledWith('tab_emoji_remove', 't1'); + }); + }); + + // ── tandem_tab_emoji_flash ─────────────────────────────────────── + describe('tandem_tab_emoji_flash', () => { + const handler = getHandler(tools, 'tandem_tab_emoji_flash'); + + it('flashes emoji on a tab', async () => { + mockApiCall.mockResolvedValueOnce({ ok: true }); + mockLogActivity.mockResolvedValueOnce(undefined); + + const result = await handler({ tabId: 't1', emoji: '🔥' }); + expectTextContent(result, 'Flashing emoji'); + expect(mockApiCall).toHaveBeenCalledWith('POST', '/tabs/t1/emoji', { emoji: '🔥', flash: true }); + expect(mockLogActivity).toHaveBeenCalledWith('tab_emoji_flash', 't1: 🔥'); + }); + }); }); diff --git a/src/mcp/tools/tabs.ts b/src/mcp/tools/tabs.ts index 260d3e3..344d6c6 100644 --- a/src/mcp/tools/tabs.ts +++ b/src/mcp/tools/tabs.ts @@ -8,12 +8,13 @@ export function registerTabTools(server: McpServer): void { 'List all open browser tabs with their titles, URLs, and IDs', async () => { const data = await apiCall('GET', '/tabs/list'); - const tabs: Array<{ id: string; title: string; url: string; active: boolean }> = data.tabs || []; + const tabs: Array<{ id: string; title: string; url: string; active: boolean; emoji?: string | null }> = data.tabs || []; let text = `Open tabs (${tabs.length}):\n\n`; for (const tab of tabs) { const marker = tab.active ? '→ ' : ' '; - text += `${marker}[${tab.id}] ${tab.title || '(untitled)'}\n ${tab.url}\n`; + const emojiPrefix = tab.emoji ? `${tab.emoji} ` : ''; + text += `${marker}[${tab.id}] ${emojiPrefix}${tab.title || '(untitled)'}\n ${tab.url}\n`; } return { content: [{ type: 'text', text }] }; @@ -61,4 +62,45 @@ export function registerTabTools(server: McpServer): void { return { content: [{ type: 'text', text: `Focused tab: ${tabId}` }] }; } ); + + server.tool( + 'tandem_tab_emoji_set', + 'Set an emoji badge on a browser tab for visual identification', + { + tabId: z.string().describe('The tab ID to set the emoji on'), + emoji: z.string().describe('The emoji to display (e.g. "🔥", "📚", "🧪")'), + }, + async ({ tabId, emoji }) => { + await apiCall('POST', `/tabs/${encodeURIComponent(tabId)}/emoji`, { emoji }); + await logActivity('tab_emoji_set', `${tabId}: ${emoji}`); + return { content: [{ type: 'text', text: `Set emoji ${emoji} on tab ${tabId}` }] }; + } + ); + + server.tool( + 'tandem_tab_emoji_remove', + 'Remove the emoji badge from a browser tab', + { + tabId: z.string().describe('The tab ID to remove the emoji from'), + }, + async ({ tabId }) => { + await apiCall('DELETE', `/tabs/${encodeURIComponent(tabId)}/emoji`); + await logActivity('tab_emoji_remove', tabId); + return { content: [{ type: 'text', text: `Removed emoji from tab ${tabId}` }] }; + } + ); + + server.tool( + 'tandem_tab_emoji_flash', + 'Flash a pulsing emoji on a tab to attract the user\'s attention (e.g. signal that a page is ready for review)', + { + tabId: z.string().describe('The tab ID to flash the emoji on'), + emoji: z.string().describe('The emoji to flash (e.g. "🔥", "✅", "⚠️")'), + }, + async ({ tabId, emoji }) => { + await apiCall('POST', `/tabs/${encodeURIComponent(tabId)}/emoji`, { emoji, flash: true }); + await logActivity('tab_emoji_flash', `${tabId}: ${emoji}`); + return { content: [{ type: 'text', text: `Flashing emoji ${emoji} on tab ${tabId}` }] }; + } + ); } diff --git a/src/preload/tabs.ts b/src/preload/tabs.ts index 5d831eb..6011f08 100644 --- a/src/preload/tabs.ts +++ b/src/preload/tabs.ts @@ -25,6 +25,11 @@ export function createTabsApi() { ipcRenderer.on(IpcChannels.TAB_SOURCE_CHANGED, handler); return () => ipcRenderer.removeListener(IpcChannels.TAB_SOURCE_CHANGED, handler); }, + onTabEmojiChanged: (callback: (data: { tabId: string; emoji: string | null; flash: boolean }) => void) => { + const handler = (_event: Electron.IpcRendererEvent, data: { tabId: string; emoji: string | null; flash: boolean }) => callback(data); + ipcRenderer.on(IpcChannels.TAB_EMOJI_CHANGED, handler); + return () => ipcRenderer.removeListener(IpcChannels.TAB_EMOJI_CHANGED, handler); + }, onOpenUrlInNewTab: (callback: (url: string) => void) => { const handler = (_event: Electron.IpcRendererEvent, url: string) => callback(url); ipcRenderer.on(IpcChannels.OPEN_URL_IN_NEW_TAB, handler); diff --git a/src/shared/ipc-channels.ts b/src/shared/ipc-channels.ts index e94681d..a1180e2 100644 --- a/src/shared/ipc-channels.ts +++ b/src/shared/ipc-channels.ts @@ -30,6 +30,7 @@ export const IpcChannels = { TAB_REGISTERED: 'tab-registered', TAB_SOURCE_CHANGED: 'tab-source-changed', TAB_PIN_CHANGED: 'tab-pin-changed', + TAB_EMOJI_CHANGED: 'tab-emoji-changed', SHOW_TAB_CONTEXT_MENU: 'show-tab-context-menu', // Panel / Chat diff --git a/src/tabs/manager.ts b/src/tabs/manager.ts index d5976ef..947dac3 100644 --- a/src/tabs/manager.ts +++ b/src/tabs/manager.ts @@ -23,6 +23,8 @@ export interface Tab { source: TabSource; pinned: boolean; partition: string; + emoji: string | null; + emojiFlash: boolean; } export interface TabGroup { @@ -190,6 +192,8 @@ export class TabManager { source, pinned: false, partition: resolvedPartition, + emoji: null, + emojiFlash: false, }; this.tabs.set(id, tab); @@ -317,6 +321,45 @@ export class TabManager { return tab ? tab.source : null; } + /** Set an emoji badge on a tab */ + setEmoji(tabId: string, emoji: string): boolean { + const tab = this.tabs.get(tabId); + if (!tab) return false; + tab.emoji = emoji; + tab.emojiFlash = false; + this.win.webContents.send(IpcChannels.TAB_EMOJI_CHANGED, { tabId, emoji, flash: false }); + this.onTabsChanged(); + return true; + } + + /** Remove emoji badge from a tab */ + clearEmoji(tabId: string): boolean { + const tab = this.tabs.get(tabId); + if (!tab) return false; + tab.emoji = null; + tab.emojiFlash = false; + this.win.webContents.send(IpcChannels.TAB_EMOJI_CHANGED, { tabId, emoji: null, flash: false }); + this.onTabsChanged(); + return true; + } + + /** Flash an emoji on a tab (AI signals user to look at this tab) */ + flashEmoji(tabId: string, emoji: string): boolean { + const tab = this.tabs.get(tabId); + if (!tab) return false; + tab.emoji = emoji; + tab.emojiFlash = true; + this.win.webContents.send(IpcChannels.TAB_EMOJI_CHANGED, { tabId, emoji, flash: true }); + this.onTabsChanged(); + return true; + } + + /** Get a tab's emoji */ + getEmoji(tabId: string): string | null { + const tab = this.tabs.get(tabId); + return tab ? tab.emoji : null; + } + /** Update tab metadata (called from renderer events) */ updateTab(tabId: string, updates: Partial>): void { const tab = this.tabs.get(tabId); @@ -407,6 +450,8 @@ export class TabManager { source: 'robin', pinned: false, partition: 'persist:tandem', + emoji: null, + emojiFlash: false, }; this.tabs.set(id, tab); this.activeTabId = id; diff --git a/src/tabs/tests/tabs.test.ts b/src/tabs/tests/tabs.test.ts index e87f5bc..9605cdb 100644 --- a/src/tabs/tests/tabs.test.ts +++ b/src/tabs/tests/tabs.test.ts @@ -452,4 +452,82 @@ describe('TabManager', () => { }); }); }); + + // ─── Emoji Methods ────────────────────────────────── + + describe('setEmoji()', () => { + it('sets emoji on a tab', async () => { + const tab = await tm.openTab('https://test.com'); + const result = tm.setEmoji(tab.id, '🔥'); + expect(result).toBe(true); + expect(tab.emoji).toBe('🔥'); + expect(tab.emojiFlash).toBe(false); + expect(win.webContents.send).toHaveBeenCalledWith( + IpcChannels.TAB_EMOJI_CHANGED, + { tabId: tab.id, emoji: '🔥', flash: false }, + ); + }); + + it('returns false for unknown tab', () => { + expect(tm.setEmoji('nonexistent', '🔥')).toBe(false); + }); + + it('clears flash when setting emoji', async () => { + const tab = await tm.openTab('https://test.com'); + tm.flashEmoji(tab.id, '⚡'); + expect(tab.emojiFlash).toBe(true); + tm.setEmoji(tab.id, '🔥'); + expect(tab.emojiFlash).toBe(false); + }); + }); + + describe('clearEmoji()', () => { + it('clears emoji from a tab', async () => { + const tab = await tm.openTab('https://test.com'); + tm.setEmoji(tab.id, '🔥'); + const result = tm.clearEmoji(tab.id); + expect(result).toBe(true); + expect(tab.emoji).toBeNull(); + expect(tab.emojiFlash).toBe(false); + expect(win.webContents.send).toHaveBeenCalledWith( + IpcChannels.TAB_EMOJI_CHANGED, + { tabId: tab.id, emoji: null, flash: false }, + ); + }); + + it('returns false for unknown tab', () => { + expect(tm.clearEmoji('nonexistent')).toBe(false); + }); + }); + + describe('flashEmoji()', () => { + it('sets emoji with flash on a tab', async () => { + const tab = await tm.openTab('https://test.com'); + const result = tm.flashEmoji(tab.id, '⚡'); + expect(result).toBe(true); + expect(tab.emoji).toBe('⚡'); + expect(tab.emojiFlash).toBe(true); + expect(win.webContents.send).toHaveBeenCalledWith( + IpcChannels.TAB_EMOJI_CHANGED, + { tabId: tab.id, emoji: '⚡', flash: true }, + ); + }); + + it('returns false for unknown tab', () => { + expect(tm.flashEmoji('nonexistent', '⚡')).toBe(false); + }); + }); + + describe('getEmoji()', () => { + it('returns emoji for a tab', async () => { + const tab = await tm.openTab('https://test.com'); + expect(tm.getEmoji(tab.id)).toBeNull(); + tm.setEmoji(tab.id, '🔥'); + expect(tm.getEmoji(tab.id)).toBe('🔥'); + }); + + it('returns null for unknown tab', () => { + expect(tm.getEmoji('nonexistent')).toBeNull(); + }); + }); });