Skip to content

Commit 87e9bf3

Browse files
authored
Merge pull request #138 from lcomplete/dev
feat(extension): integrate MCP tool loading and context menu image/selection support
2 parents e5243cc + aaa969d commit 87e9bf3

16 files changed

Lines changed: 941 additions & 395 deletions

File tree

app/extension/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
"@ai-sdk/deepseek": "^2.0.24",
2525
"@ai-sdk/google": "^3.0.43",
2626
"@ai-sdk/groq": "^3.0.29",
27+
"@ai-sdk/mcp": "^1.0.36",
2728
"@ai-sdk/openai": "^3.0.41",
2829
"@ai-sdk/react": "^3.0.170",
2930
"@emotion/react": "^11.10.6",

app/extension/src/background.ts

Lines changed: 235 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,27 @@ const vercelAIAbortControllers = new Map<string, AbortController>();
4242
// Badge management: cache to avoid redundant API calls
4343
// Maps tabId -> url to track which URL was last checked for each tab
4444
const badgeCache = new Map<number, string>();
45+
type PendingSelectionPageContext = Pick<
46+
PageModel,
47+
"title" | "content" | "url" | "faviconUrl" | "description" | "author" | "siteName"
48+
>;
49+
type PendingSidepanelContextCommand = {
50+
id: string;
51+
} & (
52+
| {
53+
kind: "image";
54+
source: string;
55+
}
56+
| {
57+
kind: "page-context";
58+
}
59+
| {
60+
kind: "selection";
61+
page: PendingSelectionPageContext;
62+
}
63+
);
64+
const pendingSidepanelContextCommands =
65+
new Map<number, PendingSidepanelContextCommand[]>();
4566
const SAVED_BADGE_TEXT = "✓";
4667
const SAVED_BADGE_BG = "#15803D";
4768
const AI_MAX_OUTPUT_TOKENS = 20000;
@@ -97,6 +118,19 @@ async function blobToDataUrl(blob: Blob): Promise<string> {
97118
return `data:${contentType};base64,${base64}`;
98119
}
99120

121+
function sendMessageToTab<T = any>(tabId: number, message: unknown): Promise<T> {
122+
return new Promise((resolve, reject) => {
123+
chrome.tabs.sendMessage(tabId, message, (response) => {
124+
const error = chrome.runtime.lastError;
125+
if (error) {
126+
reject(new Error(error.message));
127+
return;
128+
}
129+
resolve(response as T);
130+
});
131+
});
132+
}
133+
100134
/**
101135
* Update the badge for a tab based on whether the page is saved in Huntly
102136
* @param tabId The tab ID to update
@@ -168,6 +202,23 @@ function cancelVercelAITask(taskId: string): boolean {
168202
return false;
169203
}
170204

205+
function enqueuePendingSidepanelContextCommand(
206+
windowId: number,
207+
command: PendingSidepanelContextCommand
208+
): void {
209+
const queue = pendingSidepanelContextCommands.get(windowId) || [];
210+
queue.push(command);
211+
pendingSidepanelContextCommands.set(windowId, queue);
212+
}
213+
214+
function consumePendingSidepanelContextCommands(
215+
windowId: number
216+
): PendingSidepanelContextCommand[] {
217+
const queued = pendingSidepanelContextCommands.get(windowId) || [];
218+
pendingSidepanelContextCommands.delete(windowId);
219+
return queued;
220+
}
221+
171222
function startProcessingWithShortcuts(
172223
task: any,
173224
shortcuts: any[],
@@ -581,7 +632,22 @@ export function initBackground(): void {
581632
chrome.tabs.create({ url });
582633
}
583634
} else if ((msg as any).type === "open_side_panel") {
584-
openSidePanelForContextMenuClick(sender.tab);
635+
void openSidePanelForContextMenuClick(sender.tab);
636+
} else if ((msg as any).type === "consume_pending_sidepanel_context_commands") {
637+
const windowId = msg.payload?.windowId;
638+
if (typeof windowId !== "number") {
639+
sendResponse({
640+
success: false,
641+
error: "Invalid window id for pending sidepanel context commands.",
642+
});
643+
return;
644+
}
645+
646+
sendResponse({
647+
success: true,
648+
commands: consumePendingSidepanelContextCommands(windowId),
649+
});
650+
return;
585651
} else if ((msg as any).type === "fetch_image") {
586652
const imageUrl = msg.payload?.url;
587653
if (!imageUrl) {
@@ -830,14 +896,28 @@ function refreshBadgeForActiveTab() {
830896
const CONTEXT_MENU_HUNTLY_ROOT = "huntly_root";
831897
const CONTEXT_MENU_READING_MODE_PAGE = "huntly_reading_mode_page";
832898
const CONTEXT_MENU_SIDE_PANEL_PAGE = "huntly_side_panel_page";
899+
const CONTEXT_MENU_SIDE_PANEL_IMAGE = "huntly_side_panel_image";
833900
const CONTEXT_MENU_READING_MODE_ACTION = "huntly_reading_mode_action";
834901
const CONTEXT_MENU_SIDE_PANEL_ACTION = "huntly_side_panel_action";
835-
const CONTEXT_MENU_PAGE_CONTEXTS = ["page", "selection"];
836-
const CONTEXT_MENU_ACTION_CONTEXTS = ["action"];
902+
const CONTEXT_MENU_PAGE_CONTEXTS: chrome.contextMenus.ContextType[] = [
903+
"page",
904+
"selection",
905+
];
906+
const CONTEXT_MENU_IMAGE_CONTEXTS: chrome.contextMenus.ContextType[] = [
907+
"image",
908+
];
909+
const CONTEXT_MENU_ACTION_CONTEXTS: chrome.contextMenus.ContextType[] = [
910+
"action",
911+
];
912+
const CONTEXT_MENU_PAGE_AND_IMAGE_CONTEXTS: chrome.contextMenus.ContextType[] = [
913+
...CONTEXT_MENU_PAGE_CONTEXTS,
914+
...CONTEXT_MENU_IMAGE_CONTEXTS,
915+
];
837916

838917
const HUNTLY_MENU_TITLE = isDebugging ? "Huntly [DEV]" : "Huntly";
839918
const READING_MODE_TITLE = "Reading Mode";
840919
const SIDE_PANEL_TITLE = "Chat";
920+
const SIDE_PANEL_SEND_TITLE = "Send to Chat";
841921

842922
function createContextMenuItem(
843923
properties: chrome.contextMenus.CreateProperties
@@ -858,21 +938,28 @@ function setupContextMenus() {
858938
createContextMenuItem({
859939
id: CONTEXT_MENU_HUNTLY_ROOT,
860940
title: HUNTLY_MENU_TITLE,
861-
contexts: CONTEXT_MENU_PAGE_CONTEXTS,
941+
contexts: CONTEXT_MENU_PAGE_AND_IMAGE_CONTEXTS,
862942
});
863943

864944
createContextMenuItem({
865945
id: CONTEXT_MENU_READING_MODE_PAGE,
866946
parentId: CONTEXT_MENU_HUNTLY_ROOT,
867947
title: READING_MODE_TITLE,
868-
contexts: CONTEXT_MENU_PAGE_CONTEXTS,
948+
contexts: CONTEXT_MENU_PAGE_AND_IMAGE_CONTEXTS,
869949
});
870950

871951
createContextMenuItem({
872952
id: CONTEXT_MENU_SIDE_PANEL_PAGE,
873953
parentId: CONTEXT_MENU_HUNTLY_ROOT,
874954
title: SIDE_PANEL_TITLE,
875-
contexts: CONTEXT_MENU_PAGE_CONTEXTS,
955+
contexts: CONTEXT_MENU_PAGE_AND_IMAGE_CONTEXTS,
956+
});
957+
958+
createContextMenuItem({
959+
id: CONTEXT_MENU_SIDE_PANEL_IMAGE,
960+
parentId: CONTEXT_MENU_HUNTLY_ROOT,
961+
title: SIDE_PANEL_SEND_TITLE,
962+
contexts: CONTEXT_MENU_IMAGE_CONTEXTS,
876963
});
877964

878965
createContextMenuItem({
@@ -904,20 +991,33 @@ async function resolveContextMenuTab(
904991
return getCurrentActiveTab();
905992
}
906993

907-
function openSidePanelForContextMenuClick(tab?: chrome.tabs.Tab): void {
994+
function openSidePanelForContextMenuClick(tab?: chrome.tabs.Tab): boolean {
908995
const sidePanelApi = (chrome as any).sidePanel;
909996
if (!sidePanelApi?.open) {
910-
return;
997+
return false;
911998
}
912999

9131000
if (typeof tab?.windowId !== "number") {
914-
log("Failed to open side panel: missing windowId from context menu tab");
915-
return;
1001+
log("Failed to open side panel: missing window id");
1002+
return false;
1003+
}
1004+
1005+
if (typeof tab.id === "number" && typeof sidePanelApi.setOptions === "function") {
1006+
void sidePanelApi
1007+
.setOptions({
1008+
tabId: tab.id,
1009+
path: "sidepanel.html",
1010+
enabled: true,
1011+
})
1012+
.catch((error: unknown) => {
1013+
log("Failed to configure side panel:", error);
1014+
});
9161015
}
9171016

9181017
void sidePanelApi.open({ windowId: tab.windowId }).catch((error: unknown) => {
9191018
log("Failed to open side panel:", error);
9201019
});
1020+
return true;
9211021
}
9221022

9231023
function isReadingModeMenuItem(menuItemId: string | number): boolean {
@@ -934,6 +1034,121 @@ function isSidePanelMenuItem(menuItemId: string | number): boolean {
9341034
);
9351035
}
9361036

1037+
async function handleImageSidePanelContextMenuClick(
1038+
info: chrome.contextMenus.OnClickData,
1039+
tab?: chrome.tabs.Tab
1040+
): Promise<void> {
1041+
if (!info.srcUrl) return;
1042+
1043+
const openedFromContextMenu = openSidePanelForContextMenuClick(tab);
1044+
1045+
const targetTab = tab?.id ? tab : await resolveContextMenuTab(tab);
1046+
if (!targetTab || typeof targetTab.windowId !== "number") return;
1047+
1048+
const command: PendingSidepanelContextCommand = {
1049+
id: crypto.randomUUID(),
1050+
kind: "image",
1051+
source: info.srcUrl,
1052+
};
1053+
1054+
if (!openedFromContextMenu && !openSidePanelForContextMenuClick(targetTab)) {
1055+
enqueuePendingSidepanelContextCommand(targetTab.windowId, command);
1056+
return;
1057+
}
1058+
1059+
try {
1060+
const response = await chrome.runtime.sendMessage({
1061+
type: "sidepanel_context_menu_command",
1062+
payload: {
1063+
command,
1064+
windowId: targetTab.windowId,
1065+
},
1066+
});
1067+
1068+
if (response?.success && response.commandId === command.id) {
1069+
return;
1070+
}
1071+
} catch (error) {
1072+
log("Deferred sidepanel image attachment until panel is ready", error);
1073+
}
1074+
1075+
enqueuePendingSidepanelContextCommand(targetTab.windowId, command);
1076+
}
1077+
1078+
async function handlePageSidePanelContextMenuClick(
1079+
info: chrome.contextMenus.OnClickData,
1080+
tab?: chrome.tabs.Tab
1081+
): Promise<void> {
1082+
const targetTab = await resolveContextMenuTab(tab);
1083+
if (
1084+
!targetTab?.id ||
1085+
typeof targetTab.id !== "number" ||
1086+
typeof targetTab.windowId !== "number"
1087+
) {
1088+
return;
1089+
}
1090+
1091+
let command: PendingSidepanelContextCommand;
1092+
if (info.selectionText?.trim()) {
1093+
try {
1094+
const response = await sendMessageToTab<{ page?: PageModel | null }>(
1095+
targetTab.id,
1096+
{ type: "get_selection" }
1097+
);
1098+
const page = response?.page;
1099+
if (!page?.content) {
1100+
return;
1101+
}
1102+
1103+
command = {
1104+
id: crypto.randomUUID(),
1105+
kind: "selection",
1106+
page: {
1107+
title: page.title,
1108+
content: page.content,
1109+
url: page.url,
1110+
faviconUrl: page.faviconUrl,
1111+
description: page.description,
1112+
author: page.author,
1113+
siteName: page.siteName,
1114+
},
1115+
};
1116+
} catch (error) {
1117+
log("Failed to capture page selection for chat", error);
1118+
return;
1119+
}
1120+
} else {
1121+
command = {
1122+
id: crypto.randomUUID(),
1123+
kind: "page-context",
1124+
};
1125+
}
1126+
1127+
const opened = await openSidePanelForContextMenuClick(targetTab);
1128+
if (!opened) {
1129+
enqueuePendingSidepanelContextCommand(targetTab.windowId, command);
1130+
return;
1131+
}
1132+
1133+
try {
1134+
const response = await chrome.runtime.sendMessage({
1135+
type: "sidepanel_context_menu_command",
1136+
payload: {
1137+
command,
1138+
windowId: targetTab.windowId,
1139+
},
1140+
});
1141+
1142+
if (response?.success && response.commandId === command.id) {
1143+
return;
1144+
}
1145+
} catch (error) {
1146+
log("Deferred sidepanel page context command until panel is ready", error);
1147+
}
1148+
1149+
enqueuePendingSidepanelContextCommand(targetTab.windowId, command);
1150+
}
1151+
9371152
async function handleReadingModeContextMenuClick(
9381153
info: chrome.contextMenus.OnClickData,
9391154
tab?: chrome.tabs.Tab
@@ -1014,12 +1229,21 @@ function registerBackgroundUiListeners(): void {
10141229
badgeCache.delete(tabId);
10151230
});
10161231

1232+
chrome.windows.onRemoved.addListener((windowId) => {
1233+
pendingSidepanelContextCommands.delete(windowId);
1234+
});
1235+
10171236
chrome.runtime.onInstalled.addListener(setupContextMenus);
10181237
chrome.runtime.onStartup.addListener(setupContextMenus);
10191238

10201239
chrome.contextMenus.onClicked.addListener((info, tab) => {
1240+
if (info.menuItemId === CONTEXT_MENU_SIDE_PANEL_IMAGE) {
1241+
void handleImageSidePanelContextMenuClick(info, tab);
1242+
return;
1243+
}
1244+
10211245
if (isSidePanelMenuItem(info.menuItemId)) {
1022-
openSidePanelForContextMenuClick(tab);
1246+
void openSidePanelForContextMenuClick(tab);
10231247
return;
10241248
}
10251249

app/extension/src/model.d.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@ interface ShortcutPayload {
2626
}
2727

2828
interface Message {
29-
type: "auto_save_clipper" | "save_clipper" | 'tab_complete' | 'auto_save_tweets' | 'read_tweet' | 'parse_doc' | 'save_clipper_success' | 'shortcuts_preview' | 'shortcuts_execute' | 'shortcuts_process' | 'shortcuts_cancel' | 'shortcuts_processing_start' | 'shortcuts_process_result' | 'shortcuts_process_data' | 'shortcuts_process_error' | 'get_selection' | 'detect_rss_feed' | 'get_huntly_shortcuts' | 'get_ai_toolbar_data' | 'open_tab' | 'open_side_panel' | 'save_detail_init' | 'http_proxy' | 'badge_refresh' | 'fetch_image' | 'get_dragged_image' | 'clear_dragged_image',
29+
type: "auto_save_clipper" | "save_clipper" | 'tab_complete' | 'auto_save_tweets' | 'read_tweet' | 'parse_doc' | 'save_clipper_success' | 'shortcuts_preview' | 'shortcuts_execute' | 'shortcuts_process' | 'shortcuts_cancel' | 'shortcuts_processing_start' | 'shortcuts_process_result' | 'shortcuts_process_data' | 'shortcuts_process_error' | 'get_selection' | 'detect_rss_feed' | 'get_huntly_shortcuts' | 'get_ai_toolbar_data' | 'open_tab' | 'open_side_panel' | 'save_detail_init' | 'http_proxy' | 'badge_refresh' | 'fetch_image' | 'get_dragged_image' | 'clear_dragged_image' | 'sidepanel_context_menu_command' | 'consume_pending_sidepanel_context_commands',
3030
payload?: any,
3131
url?: string
3232
}

0 commit comments

Comments
 (0)