@@ -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
4444const 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 [ ] > ( ) ;
4566const SAVED_BADGE_TEXT = "✓" ;
4667const SAVED_BADGE_BG = "#15803D" ;
4768const 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+
171222function 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() {
830896const CONTEXT_MENU_HUNTLY_ROOT = "huntly_root" ;
831897const CONTEXT_MENU_READING_MODE_PAGE = "huntly_reading_mode_page" ;
832898const CONTEXT_MENU_SIDE_PANEL_PAGE = "huntly_side_panel_page" ;
899+ const CONTEXT_MENU_SIDE_PANEL_IMAGE = "huntly_side_panel_image" ;
833900const CONTEXT_MENU_READING_MODE_ACTION = "huntly_reading_mode_action" ;
834901const 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
838917const HUNTLY_MENU_TITLE = isDebugging ? "Huntly [DEV]" : "Huntly" ;
839918const READING_MODE_TITLE = "Reading Mode" ;
840919const SIDE_PANEL_TITLE = "Chat" ;
920+ const SIDE_PANEL_SEND_TITLE = "Send to Chat" ;
841921
842922function 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
9231023function 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+
9371152async 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
0 commit comments