From ff1de16a0880df975c08014b87793939879a28f9 Mon Sep 17 00:00:00 2001 From: Robin Waslander Date: Sat, 11 Apr 2026 23:26:57 +0200 Subject: [PATCH 01/13] feat(tabs): add emoji and emojiFlash fields to Tab interface with manager methods --- src/shared/ipc-channels.ts | 1 + src/tabs/manager.ts | 45 ++++++++++++++++++++++++++++++++++++++ 2 files changed, 46 insertions(+) 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; From 46c1e8e0ffadc03d4dbb4081ec71f27d7d16b812 Mon Sep 17 00:00:00 2001 From: Robin Waslander Date: Sat, 11 Apr 2026 23:28:43 +0200 Subject: [PATCH 02/13] feat(api): add emoji set/flash/delete endpoints for tabs --- src/api/routes/tabs.ts | 37 +++++++++++++++++ src/api/tests/helpers.ts | 4 ++ src/api/tests/routes/tabs.test.ts | 68 +++++++++++++++++++++++++++++++ 3 files changed, 109 insertions(+) diff --git a/src/api/routes/tabs.ts b/src/api/routes/tabs.ts index faedcb4..37a5e7c 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; + 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; + 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'); + }); + }); }); From f3666dea75010d988cd7e2d8ce35514876c670ae Mon Sep 17 00:00:00 2001 From: Robin Waslander Date: Sat, 11 Apr 2026 23:30:09 +0200 Subject: [PATCH 03/13] feat(mcp): add tab emoji set, remove, and flash tools --- src/mcp/tests/tabs.test.ts | 45 ++++++++++++++++++++++++++++++++++++++ src/mcp/tools/tabs.ts | 41 ++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) diff --git a/src/mcp/tests/tabs.test.ts b/src/mcp/tests/tabs.test.ts index b64306a..d718c3a 100644 --- a/src/mcp/tests/tabs.test.ts +++ b/src/mcp/tests/tabs.test.ts @@ -131,4 +131,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..2dfdcd2 100644 --- a/src/mcp/tools/tabs.ts +++ b/src/mcp/tools/tabs.ts @@ -61,4 +61,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}` }] }; + } + ); } From a1219f4133ac53dfcd5ba1c12d7ef184cb9d946d Mon Sep 17 00:00:00 2001 From: Robin Waslander Date: Sat, 11 Apr 2026 23:32:16 +0200 Subject: [PATCH 04/13] feat(shell): render emoji badges on tabs with flash animation and preload bridge --- shell/css/browser-shell.css | 15 +++++++++++++++ shell/js/tabs.js | 23 +++++++++++++++++++++++ src/preload/tabs.ts | 5 +++++ 3 files changed, 43 insertions(+) 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/js/tabs.js b/shell/js/tabs.js index 6e5669d..8adfdef 100644 --- a/shell/js/tabs.js +++ b/shell/js/tabs.js @@ -89,6 +89,7 @@ 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/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); From 6cc800e630d5aa2752364b60d6ff482e8b8d1fed Mon Sep 17 00:00:00 2001 From: Robin Waslander Date: Sat, 11 Apr 2026 23:33:20 +0200 Subject: [PATCH 05/13] feat(context-menu): add emoji picker submenu for tabs --- src/context-menu/menu-builder.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) 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({ From 97115da5ab4d9e8fdafab260b82d4262bf7a1960 Mon Sep 17 00:00:00 2001 From: Robin Waslander Date: Sat, 11 Apr 2026 23:34:12 +0200 Subject: [PATCH 06/13] feat(mcp): show emoji badges in tandem_list_tabs output --- src/mcp/tests/tabs.test.ts | 14 ++++++++++++++ src/mcp/tools/tabs.ts | 5 +++-- 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/src/mcp/tests/tabs.test.ts b/src/mcp/tests/tabs.test.ts index d718c3a..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 ─────────────────────────────────────────────── diff --git a/src/mcp/tools/tabs.ts b/src/mcp/tools/tabs.ts index 2dfdcd2..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 }] }; From 933f2ea95fc6174a77672f9a3754326d09a431f4 Mon Sep 17 00:00:00 2001 From: Robin Waslander Date: Sat, 11 Apr 2026 23:35:40 +0200 Subject: [PATCH 07/13] fix(api): resolve TypeScript param typing for emoji endpoints --- src/api/routes/tabs.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/api/routes/tabs.ts b/src/api/routes/tabs.ts index 37a5e7c..465b29e 100644 --- a/src/api/routes/tabs.ts +++ b/src/api/routes/tabs.ts @@ -129,7 +129,7 @@ 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; + const id = req.params.id as string; const { emoji, flash } = req.body; if (!emoji) { res.status(400).json({ error: 'emoji required' }); @@ -151,7 +151,7 @@ export function registerTabRoutes(router: Router, ctx: RouteContext): void { // Remove emoji from a tab router.delete('/tabs/:id/emoji', (req: Request, res: Response) => { try { - const { id } = req.params; + const id = req.params.id as string; const ok = ctx.tabManager.clearEmoji(id); if (!ok) { res.status(404).json({ error: 'Tab not found' }); From 68ed7ff988450a28ef0a301fe06301e45cdc9fbe Mon Sep 17 00:00:00 2001 From: Robin Waslander Date: Sat, 11 Apr 2026 23:48:21 +0200 Subject: [PATCH 08/13] docs: update documentation for tab emoji feature MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add POST /tabs/:id/emoji and DELETE /tabs/:id/emoji to docs/api.html - Add v0.71.0 changelog entry with new endpoints and MCP tools - Update tab context menu in PROJECT.md - Update tool counts 236 → 239 across all docs (via check-consistency) - Update Tabs & Workspaces category count in README feature table --- AGENTS.md | 4 ++-- CHANGELOG.md | 17 +++++++++++++++++ PROJECT.md | 14 ++++++++------ README.md | 8 ++++---- TODO.md | 2 +- docs/api.html | 6 ++++-- docs/index.html | 6 +++--- docs/public-launch.md | 6 +++--- package.json | 2 +- skill/SKILL.md | 4 ++-- 10 files changed, 45 insertions(+), 24 deletions(-) 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/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. From e32de2c0903d94a3abc7d093adc0450c00fed720 Mon Sep 17 00:00:00 2001 From: Robin Waslander Date: Sat, 11 Apr 2026 23:55:14 +0200 Subject: [PATCH 09/13] chore: gitignore docs/superpowers/ (private implementation plans) --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) 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/ From 09490b05a99f8823740ffee4de2847912ac9a572 Mon Sep 17 00:00:00 2001 From: Robin Waslander Date: Sun, 12 Apr 2026 00:09:46 +0200 Subject: [PATCH 10/13] feat(shell): add emoji picker to tab context menu in sidebar The actual tab context menu is the custom HTML menu in sidebar.js (not the Electron native menu in menu-builder.ts). Add emoji grid submenu with 50 popular emojis, Remove Emoji option, and CSS styling. --- shell/css/sidebar-panels.css | 26 ++++++++++++++++ shell/js/sidebar.js | 60 ++++++++++++++++++++++++++++++++++++ 2 files changed, 86 insertions(+) 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..0f7d788 100644 --- a/shell/js/sidebar.js +++ b/shell/js/sidebar.js @@ -2124,6 +2124,66 @@ 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 : ''; + emojiItem.innerHTML = currentEmoji + ? 'Emoji: ' + currentEmoji + '' + : 'Set Emoji...'; + + 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 From 72256086f72fbeca4db5e6d476b6fcdbfcd7f57d Mon Sep 17 00:00:00 2001 From: Robin Waslander Date: Sun, 12 Apr 2026 00:24:52 +0200 Subject: [PATCH 11/13] fix(shell): hide tab source indicator for user tabs, only show for AI tabs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 👤 icon on every tab was visual noise. Now the source indicator is hidden by default and only shown (🤖) when a tab is AI-controlled. --- shell/js/tabs.js | 2 +- shell/js/wingman.js | 11 +++++++++-- 2 files changed, 10 insertions(+), 3 deletions(-) diff --git a/shell/js/tabs.js b/shell/js/tabs.js index 8adfdef..d050105 100644 --- a/shell/js/tabs.js +++ b/shell/js/tabs.js @@ -87,7 +87,7 @@ tabEl.dataset.tabId = tabId; tabEl.draggable = true; tabEl.innerHTML = ` - 👤 + 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') { From c8650384ac57474b2f2a3800c42602c45000b1fd Mon Sep 17 00:00:00 2001 From: Robin Waslander Date: Sun, 12 Apr 2026 00:28:57 +0200 Subject: [PATCH 12/13] fix(security): use textContent instead of innerHTML for emoji menu label MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Resolves CodeQL DOM text reinterpreted as HTML warning. The emoji value comes from DOM textContent which could theoretically contain HTML metacharacters — use createElement + textContent instead. --- shell/js/sidebar.js | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/shell/js/sidebar.js b/shell/js/sidebar.js index 0f7d788..e47f600 100644 --- a/shell/js/sidebar.js +++ b/shell/js/sidebar.js @@ -2131,9 +2131,13 @@ 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 : ''; - emojiItem.innerHTML = currentEmoji - ? 'Emoji: ' + currentEmoji + '' - : 'Set Emoji...'; + 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'; From 95d2fb8723522569dc11a80d8d3ad1b6d4a94b68 Mon Sep 17 00:00:00 2001 From: Robin Waslander Date: Sun, 12 Apr 2026 00:32:39 +0200 Subject: [PATCH 13/13] test(tabs): add unit tests for setEmoji, clearEmoji, flashEmoji, getEmoji --- src/tabs/tests/tabs.test.ts | 78 +++++++++++++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) 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(); + }); + }); });