Skip to content

Commit 024c9ba

Browse files
authored
Merge pull request #43 from janhq/feat/tab_focus
feat: Tab focus
2 parents 7840404 + 6dbceab commit 024c9ba

File tree

6 files changed

+849
-13
lines changed

6 files changed

+849
-13
lines changed

src/background.js

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,8 @@ import { loadConfig, getSettings, testSettings, pingModels, chatCompletions } fr
3232

3333
import { buildInlineAssistMessages, handleSummarize } from './prompts.js';
3434

35+
import { clearMcpRegisteredTab, getMcpRegisteredTab, setMcpRegisteredTab } from './lib/tab-manager.js';
36+
3537
// ============================================================================
3638
// Browser API Shim (cross-browser compatibility)
3739
// ============================================================================
@@ -144,6 +146,19 @@ chrome.runtime.onStartup.addListener(async () => {
144146
}
145147
});
146148

149+
// ============================================================================
150+
// Tab Lifecycle - MCP Registered Tab Cleanup
151+
// ============================================================================
152+
153+
// Clean up registered tab when it's closed
154+
chrome.tabs.onRemoved.addListener((tabId) => {
155+
const registeredTabId = getMcpRegisteredTab();
156+
if (registeredTabId === tabId) {
157+
clearMcpRegisteredTab();
158+
console.log('[BG] MCP registered tab closed, cleared registration:', tabId);
159+
}
160+
});
161+
147162
// ============================================================================
148163
// Action Click Handler
149164
// ============================================================================
@@ -534,5 +549,38 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
534549
return true;
535550
}
536551

552+
// Handle MCP_REGISTER_TAB (register a tab for MCP operations)
553+
if (message?.type === 'MCP_REGISTER_TAB') {
554+
try {
555+
const { tabId } = message.payload || {};
556+
if (tabId) {
557+
setMcpRegisteredTab(tabId);
558+
console.log('[BG] MCP tab registered:', tabId);
559+
sendResponse({ ok: true, tabId });
560+
} else if (tabId === null) {
561+
// Explicitly disconnect/clear the registered tab
562+
setMcpRegisteredTab(null);
563+
console.log('[BG] MCP tab disconnected');
564+
sendResponse({ ok: true, tabId: null });
565+
} else {
566+
sendResponse({ ok: false, error: 'Missing tabId' });
567+
}
568+
} catch (e) {
569+
sendResponse({ ok: false, error: String(e?.message || e) });
570+
}
571+
return true;
572+
}
573+
574+
// Handle MCP_GET_REGISTERED_TAB (get the currently registered MCP tab)
575+
if (message?.type === 'MCP_GET_REGISTERED_TAB') {
576+
try {
577+
const tabId = getMcpRegisteredTab();
578+
sendResponse({ ok: true, tabId });
579+
} catch (e) {
580+
sendResponse({ ok: false, error: String(e?.message || e) });
581+
}
582+
return true;
583+
}
584+
537585
return false;
538586
});

src/mcp-tools/navigation.js

Lines changed: 41 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,14 @@
11
// navigation.js
22
// MCP Bridge navigation tools: visit, go_back, go_forward, scroll
33

4-
import { selectTab, setMcpRegisteredTab } from '../lib/tab-manager.js';
4+
import { selectTab, setMcpRegisteredTab, getMcpRegisteredTab } from '../lib/tab-manager.js';
55
import { sendMessageWithRetry } from '../lib/fetch-utils.js';
66
import { CONTENT_LOAD_TIMEOUT, TAB_REGISTRATION_DELAY, VisitOutputModes } from '../constants.js';
77

88
/**
99
* Visits a URL and extracts page content
10+
* If no tab is registered, creates and registers a new tab
11+
* If a tab is already registered, navigates that tab to the new URL
1012
*/
1113
export async function handleVisit(params) {
1214
const url = String(params?.url || '').trim();
@@ -28,9 +30,35 @@ export async function handleVisit(params) {
2830
console.log('[MCP Tools] visit', { url, mode, maxContentLength, closeTab });
2931

3032
try {
31-
// Create a new tab to visit the URL
32-
const tab = await chrome.tabs.create({ url, active: false });
33-
const tabId = tab.id;
33+
let tabId;
34+
let isNewTab = false;
35+
36+
// Check if we have a registered tab
37+
const registeredTabId = getMcpRegisteredTab();
38+
39+
if (registeredTabId) {
40+
// Try to use the existing registered tab
41+
try {
42+
await chrome.tabs.get(registeredTabId);
43+
console.log('[MCP Tools] Using existing registered tab:', registeredTabId);
44+
45+
// Navigate the existing tab to the new URL
46+
await chrome.tabs.update(registeredTabId, { url, active: false });
47+
tabId = registeredTabId;
48+
} catch (e) {
49+
// Registered tab no longer exists, create a new one
50+
console.log('[MCP Tools] Registered tab no longer exists, creating new tab');
51+
const tab = await chrome.tabs.create({ url, active: false });
52+
tabId = tab.id;
53+
isNewTab = true;
54+
}
55+
} else {
56+
// No registered tab, create a new one
57+
console.log('[MCP Tools] No registered tab, creating new tab');
58+
const tab = await chrome.tabs.create({ url, active: false });
59+
tabId = tab.id;
60+
isNewTab = true;
61+
}
3462

3563
// Wait for the page to load
3664
await new Promise((resolve, reject) => {
@@ -89,13 +117,19 @@ export async function handleVisit(params) {
89117
await chrome.tabs.remove(tabId);
90118
console.log('[MCP Tools] Tab closed as requested');
91119
} else {
92-
// Register tab for agentic workflows and make it active (visible)
93-
setMcpRegisteredTab(tabId);
120+
// Register tab if it's a new tab (only register once, not on every visit)
121+
if (isNewTab) {
122+
setMcpRegisteredTab(tabId);
123+
console.log('[MCP Tools] Registered new tab:', tabId);
124+
} else {
125+
console.log('[MCP Tools] Navigated existing registered tab:', tabId);
126+
}
127+
94128
// Focus the tab's window first, then activate the tab
95129
const currentTab = await chrome.tabs.get(tabId);
96130
await chrome.windows.update(currentTab.windowId, { focused: true });
97131
await chrome.tabs.update(tabId, { active: true });
98-
console.log('[MCP Tools] Registered tab:', tabId, '(now active and visible in focused window)');
132+
console.log('[MCP Tools] Tab is now active and visible in focused window');
99133
}
100134

101135
console.log('[MCP Tools] visit result', {

tests/navbar.spec.tsx

Lines changed: 239 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,239 @@
1+
import * as React from 'react'
2+
import { screen, waitFor, fireEvent } from '@testing-library/react'
3+
import { describe, it, expect, vi } from 'vitest'
4+
import { renderWithProviders } from './utils'
5+
import { Navbar } from '../ui/sidepanel/components/navbar/Navbar.jsx'
6+
7+
describe('Navbar', () => {
8+
const mockProps = {
9+
sidebarOpen: false,
10+
setSidebarOpen: vi.fn(),
11+
createNewChat: vi.fn(),
12+
SettingsTrigger: () => <button>Settings</button>,
13+
connectedTabId: null,
14+
currentTabId: null,
15+
onConnectTab: vi.fn(),
16+
onFocusToConnectedTab: vi.fn(),
17+
onDisconnectTab: vi.fn(),
18+
}
19+
20+
it('renders navbar with Jan branding', () => {
21+
renderWithProviders(<Navbar {...mockProps} />)
22+
expect(screen.getByText('Jan')).toBeTruthy()
23+
})
24+
25+
it('shows menu trigger when sidebar is closed', () => {
26+
renderWithProviders(<Navbar {...mockProps} sidebarOpen={false} />)
27+
const menuButton = screen.getByLabelText('Open sidebar')
28+
expect(menuButton).toBeTruthy()
29+
})
30+
31+
it('hides menu trigger when sidebar is open', () => {
32+
renderWithProviders(<Navbar {...mockProps} sidebarOpen={true} />)
33+
const menuButton = screen.queryByLabelText('Open sidebar')
34+
expect(menuButton).toBeFalsy()
35+
})
36+
37+
it('shows connect button with default state when no tab is connected', () => {
38+
renderWithProviders(<Navbar {...mockProps} />)
39+
const connectButton = screen.getByTitle(/Not connected - Click to connect/)
40+
expect(connectButton).toBeTruthy()
41+
})
42+
43+
it('shows connect button with connected state when tab is connected', () => {
44+
renderWithProviders(
45+
<Navbar {...mockProps} connectedTabId={123} currentTabId={456} />
46+
)
47+
const connectButton = screen.getByTitle(/Connected \(different tab\)/)
48+
expect(connectButton).toBeTruthy()
49+
})
50+
51+
it('shows connect button with current tab connected state', () => {
52+
renderWithProviders(
53+
<Navbar {...mockProps} connectedTabId={123} currentTabId={123} />
54+
)
55+
const connectButton = screen.getByTitle(/Connected \(this tab\)/)
56+
expect(connectButton).toBeTruthy()
57+
})
58+
59+
it('opens dropdown when connect button is clicked', async () => {
60+
renderWithProviders(<Navbar {...mockProps} currentTabId={789} />)
61+
const connectButton = screen.getByTitle(/Not connected/)
62+
63+
fireEvent.click(connectButton)
64+
65+
await waitFor(() => {
66+
const connectOption = screen.getByText(/Connect to this tab/)
67+
expect(connectOption).toBeTruthy()
68+
})
69+
})
70+
71+
it('shows "Focus to connected tab" option when different tab is connected', async () => {
72+
renderWithProviders(
73+
<Navbar {...mockProps} connectedTabId={123} currentTabId={456} />
74+
)
75+
const connectButton = screen.getByTitle(/Connected \(different tab\)/)
76+
77+
fireEvent.click(connectButton)
78+
79+
await waitFor(() => {
80+
const focusOption = screen.getByText('Focus to connected tab')
81+
expect(focusOption).toBeTruthy()
82+
})
83+
})
84+
85+
it('shows "Disconnect" option when current tab is connected', async () => {
86+
renderWithProviders(
87+
<Navbar {...mockProps} connectedTabId={123} currentTabId={123} />
88+
)
89+
const connectButton = screen.getByTitle(/Connected \(this tab\)/)
90+
91+
fireEvent.click(connectButton)
92+
93+
await waitFor(() => {
94+
const disconnectOption = screen.getByText(/Disconnect/)
95+
expect(disconnectOption).toBeTruthy()
96+
})
97+
})
98+
99+
it('calls onConnectTab when "Connect to this tab" is clicked', async () => {
100+
const onConnectTab = vi.fn()
101+
renderWithProviders(
102+
<Navbar {...mockProps} currentTabId={789} onConnectTab={onConnectTab} />
103+
)
104+
105+
const connectButton = screen.getByTitle(/Not connected/)
106+
fireEvent.click(connectButton)
107+
108+
await waitFor(() => {
109+
const connectOption = screen.getByText(/Connect to this tab/)
110+
fireEvent.click(connectOption)
111+
})
112+
113+
expect(onConnectTab).toHaveBeenCalledWith(789)
114+
})
115+
116+
it('calls onDisconnectTab when "Disconnect" is clicked', async () => {
117+
const onDisconnectTab = vi.fn()
118+
renderWithProviders(
119+
<Navbar
120+
{...mockProps}
121+
connectedTabId={123}
122+
currentTabId={123}
123+
onDisconnectTab={onDisconnectTab}
124+
/>
125+
)
126+
127+
const connectButton = screen.getByTitle(/Connected \(this tab\)/)
128+
fireEvent.click(connectButton)
129+
130+
await waitFor(() => {
131+
const disconnectOption = screen.getByText(/Disconnect/)
132+
fireEvent.click(disconnectOption)
133+
})
134+
135+
expect(onDisconnectTab).toHaveBeenCalled()
136+
})
137+
138+
it('calls onFocusToConnectedTab when "Focus to connected tab" is clicked', async () => {
139+
const onFocusToConnectedTab = vi.fn()
140+
renderWithProviders(
141+
<Navbar
142+
{...mockProps}
143+
connectedTabId={123}
144+
currentTabId={456}
145+
onFocusToConnectedTab={onFocusToConnectedTab}
146+
/>
147+
)
148+
149+
const connectButton = screen.getByTitle(/Connected \(different tab\)/)
150+
fireEvent.click(connectButton)
151+
152+
await waitFor(() => {
153+
const focusOption = screen.getByText('Focus to connected tab')
154+
fireEvent.click(focusOption)
155+
})
156+
157+
expect(onFocusToConnectedTab).toHaveBeenCalledWith(123)
158+
})
159+
160+
it('applies pressed styling when mousedown on connect button', async () => {
161+
renderWithProviders(<Navbar {...mockProps} />)
162+
const connectButton = screen.getByTitle(/Not connected/)
163+
164+
fireEvent.mouseDown(connectButton)
165+
166+
await waitFor(() => {
167+
expect(connectButton.className).toContain('scale-95')
168+
})
169+
})
170+
171+
it('removes pressed styling when mouseup on connect button', async () => {
172+
renderWithProviders(<Navbar {...mockProps} />)
173+
const connectButton = screen.getByTitle(/Not connected/)
174+
175+
fireEvent.mouseDown(connectButton)
176+
await waitFor(() => {
177+
expect(connectButton.className).toContain('scale-95')
178+
})
179+
180+
fireEvent.mouseUp(connectButton)
181+
await waitFor(() => {
182+
expect(connectButton.className).not.toContain('scale-95')
183+
})
184+
})
185+
186+
it('shows darker background when current tab is connected', () => {
187+
renderWithProviders(
188+
<Navbar {...mockProps} connectedTabId={123} currentTabId={123} />
189+
)
190+
const connectButton = screen.getByTitle(/Connected \(this tab\)/)
191+
expect(connectButton.className).toContain('bg-green-200/70')
192+
})
193+
194+
it('shows lighter background when different tab is connected', () => {
195+
renderWithProviders(
196+
<Navbar {...mockProps} connectedTabId={123} currentTabId={456} />
197+
)
198+
const connectButton = screen.getByTitle(/Connected \(different tab\)/)
199+
expect(connectButton.className).toContain('bg-green-100/30')
200+
})
201+
202+
it('calls createNewChat when new chat button is clicked', () => {
203+
const createNewChat = vi.fn()
204+
renderWithProviders(<Navbar {...mockProps} createNewChat={createNewChat} />)
205+
206+
const newChatButton = screen.getByTitle('New chat')
207+
fireEvent.click(newChatButton)
208+
209+
expect(createNewChat).toHaveBeenCalled()
210+
})
211+
212+
it('shows explanatory text when connected to current tab', async () => {
213+
renderWithProviders(
214+
<Navbar {...mockProps} connectedTabId={123} currentTabId={123} />
215+
)
216+
217+
const connectButton = screen.getByTitle(/Connected \(this tab\)/)
218+
fireEvent.click(connectButton)
219+
220+
await waitFor(() => {
221+
const helpText = screen.getByText('MCP tools will operate on this tab')
222+
expect(helpText).toBeTruthy()
223+
})
224+
})
225+
226+
it('shows explanatory text when connected to different tab', async () => {
227+
renderWithProviders(
228+
<Navbar {...mockProps} connectedTabId={123} currentTabId={456} />
229+
)
230+
231+
const connectButton = screen.getByTitle(/Connected \(different tab\)/)
232+
fireEvent.click(connectButton)
233+
234+
await waitFor(() => {
235+
const helpText = screen.getByText('MCP tools are using another tab')
236+
expect(helpText).toBeTruthy()
237+
})
238+
})
239+
})

0 commit comments

Comments
 (0)