Skip to content

Commit 1e45894

Browse files
Fix extension losing workflow state when interacting with webpage (#86)
1 parent 818c982 commit 1e45894

8 files changed

Lines changed: 226 additions & 52 deletions

File tree

src/background/handlers/misc-handlers.ts

Lines changed: 42 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import { successResponse, errorResponse } from '../../shared/types/common';
1212
import type { MessageHandler } from './types';
1313
import { loggers } from '../../shared/utils/logger';
1414
import { generateNativePDF, isNativePDFAvailable } from '../../shared/extraction/native-pdf';
15+
import { savePanelWorkflowState, loadPanelWorkflowState, clearPanelWorkflowState, type PanelWorkflowState } from '../../shared/utils/storage';
1516

1617
const log = loggers.background;
1718

@@ -182,12 +183,47 @@ export const handleInjectAllTabs: MessageHandler = async (_payload, sendResponse
182183
};
183184

184185
/**
185-
* Get panel state
186+
* Get panel state - returns any saved workflow state so the panel can restore it
186187
*/
187188
export const handleGetPanelState: MessageHandler = async (_payload, sendResponse) => {
188-
// Panel state is managed by the content script
189-
// This just confirms the panel is ready
190-
sendResponse(successResponse({ ready: true }));
189+
try {
190+
const workflowState = await loadPanelWorkflowState();
191+
sendResponse(successResponse({ ready: true, workflowState }));
192+
} catch (error) {
193+
log.warn('Failed to load panel workflow state:', error);
194+
sendResponse(successResponse({ ready: true, workflowState: null }));
195+
}
196+
};
197+
198+
/**
199+
* Save panel workflow state for persistence across close/reopen
200+
*/
201+
export const handleSavePanelState: MessageHandler = async (payload, sendResponse) => {
202+
const state = payload as PanelWorkflowState | undefined;
203+
if (!state || typeof state !== 'object' || !('panelMode' in state) || !state.panelMode) {
204+
sendResponse(errorResponse('Invalid panel state'));
205+
return;
206+
}
207+
try {
208+
await savePanelWorkflowState({ ...state, timestamp: Date.now() });
209+
sendResponse(successResponse({ saved: true }));
210+
} catch (error) {
211+
log.warn('Failed to save panel workflow state:', error);
212+
sendResponse(errorResponse('Failed to save state'));
213+
}
214+
};
215+
216+
/**
217+
* Clear panel workflow state (e.g. after completing a workflow)
218+
*/
219+
export const handleClearPanelState: MessageHandler = async (_payload, sendResponse) => {
220+
try {
221+
await clearPanelWorkflowState();
222+
sendResponse(successResponse({ cleared: true }));
223+
} catch (error) {
224+
log.warn('Failed to clear panel workflow state:', error);
225+
sendResponse(errorResponse('Failed to clear state'));
226+
}
191227
};
192228

193229
/**
@@ -293,6 +329,8 @@ export const miscHandlers: Record<string, MessageHandler> = {
293329
INJECT_CONTENT_SCRIPT: handleInjectContentScript,
294330
INJECT_ALL_TABS: handleInjectAllTabs,
295331
GET_PANEL_STATE: handleGetPanelState,
332+
SAVE_PANEL_STATE: handleSavePanelState,
333+
CLEAR_PANEL_STATE: handleClearPanelState,
296334
GENERATE_NATIVE_PDF: handleGenerateNativePDF,
297335
FETCH_IMAGE_AS_DATA_URL: handleFetchImageAsDataURL,
298336
};

src/content/panel.ts

Lines changed: 0 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,6 @@
1414
*/
1515

1616
import { loggers } from '../shared/utils/logger';
17-
import { PANEL_WIDTH_PX } from '../shared/constants';
1817
import type { DetectedObservable } from '../shared/types/observables';
1918
import type { DetectedOCTIEntity } from '../shared/types/opencti';
2019
import { extractArticleContent, extractFirstParagraph } from './extraction';
@@ -29,7 +28,6 @@ let panelFrame: HTMLIFrameElement | null = null;
2928
let panelOverlay: HTMLDivElement | null = null;
3029
let isPanelReady = false;
3130
const panelMessageQueue: Array<{ type: string; payload?: unknown }> = [];
32-
let documentClickHandlerInstalled = false;
3331
let highlightClickInProgress = false;
3432
// Split screen mode - uses browser's native side panel instead of floating iframe
3533
let splitScreenMode = false;
@@ -431,11 +429,6 @@ export function ensurePanelElements(): void {
431429
}, 100);
432430
}
433431

434-
// Install document click handler
435-
if (!documentClickHandlerInstalled) {
436-
document.addEventListener('click', handleDocumentClickForPanel, true);
437-
documentClickHandlerInstalled = true;
438-
}
439432
}
440433

441434
/**
@@ -482,44 +475,6 @@ export function isPanelHidden(): boolean {
482475
return panelFrame?.classList.contains('hidden') ?? true;
483476
}
484477

485-
// ============================================================================
486-
// Click Handling
487-
// ============================================================================
488-
489-
/**
490-
* Document click handler for closing panel when clicking outside
491-
*/
492-
function handleDocumentClickForPanel(e: MouseEvent): void {
493-
if (panelFrame?.classList.contains('hidden')) {
494-
return;
495-
}
496-
497-
if (highlightClickInProgress) {
498-
return;
499-
}
500-
501-
const target = e.target as HTMLElement;
502-
503-
if (panelFrame && (target === panelFrame || panelFrame.contains(target))) {
504-
return;
505-
}
506-
507-
if (target.closest('.xtm-highlight')) {
508-
return;
509-
}
510-
511-
if (target.closest('[class*="xtm-"]')) {
512-
return;
513-
}
514-
515-
const panelAreaStart = window.innerWidth - PANEL_WIDTH_PX;
516-
if (e.clientX >= panelAreaStart) {
517-
return;
518-
}
519-
520-
hidePanel();
521-
}
522-
523478
// ============================================================================
524479
// Theme Helper
525480
// ============================================================================

src/panel/App.tsx

Lines changed: 122 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import React, { useEffect, useState, useMemo, useCallback } from 'react';
1+
import React, { useEffect, useRef, useState, useMemo, useCallback } from 'react';
22
import {
33
ThemeProvider,
44
createTheme,
@@ -357,6 +357,75 @@ const App: React.FC = () => {
357357
showToast,
358358
});
359359

360+
// Modes that represent an active workflow worth persisting
361+
const PERSISTABLE_MODES = new Set([
362+
'scan-results', 'preview', 'platform-select', 'container-type',
363+
'container-form', 'existing-containers', 'entity', 'not-found',
364+
'investigation', 'scenario-overview', 'scenario-form',
365+
'atomic-testing', 'unified-search', 'add', 'add-selection',
366+
'add-to-scan-results',
367+
]);
368+
369+
// Track whether we've restored state to avoid save/restore loops
370+
const hasRestoredState = useRef(false);
371+
// Platform ID that needs URL resolution once platforms finish loading
372+
const pendingPlatformIdRef = useRef<string | null>(null);
373+
374+
// Debounced persistence of panel workflow state.
375+
// Reads scan data from refs to always capture the latest values without
376+
// requiring them in the dependency array (which would trigger too-frequent saves).
377+
useEffect(() => {
378+
if (!hasRestoredState.current) return;
379+
if (!PERSISTABLE_MODES.has(panelMode)) return;
380+
if (typeof chrome === 'undefined' || !chrome.runtime?.sendMessage) return;
381+
382+
const timer = setTimeout(() => {
383+
const currentScanEntities = scanResultsEntitiesRef.current;
384+
const stateToSave = {
385+
panelMode,
386+
containerType,
387+
containerForm,
388+
containerWorkflowOrigin,
389+
selectedPlatformId,
390+
currentPageUrl,
391+
currentPageTitle,
392+
currentPdfFileName,
393+
isPdfSource,
394+
scanResultsEntities: currentScanEntities.length > 0 ? currentScanEntities : undefined,
395+
selectedScanItems: selectedScanItems.size > 0 ? Array.from(selectedScanItems) : undefined,
396+
scanPageContent: scanPageContent || undefined,
397+
timestamp: Date.now(),
398+
};
399+
chrome.runtime.sendMessage({ type: 'SAVE_PANEL_STATE', payload: stateToSave });
400+
}, 500);
401+
402+
return () => clearTimeout(timer);
403+
// eslint-disable-next-line react-hooks/exhaustive-deps
404+
}, [panelMode, containerType, containerForm, containerWorkflowOrigin,
405+
selectedPlatformId, currentPageUrl, currentPageTitle,
406+
selectedScanItems, scanPageContent]);
407+
408+
// Resolve deferred platform URL once platforms finish loading
409+
useEffect(() => {
410+
const pid = pendingPlatformIdRef.current;
411+
if (pid && availablePlatforms.length > 0) {
412+
const platform = availablePlatforms.find(p => p.id === pid);
413+
if (platform) {
414+
setPlatformUrl(platform.url);
415+
pendingPlatformIdRef.current = null;
416+
}
417+
}
418+
}, [availablePlatforms, setPlatformUrl]);
419+
420+
// Clear persisted state when a workflow completes or user returns to empty
421+
useEffect(() => {
422+
if (panelMode === 'empty' || panelMode === 'import-results') {
423+
if (typeof chrome !== 'undefined' && chrome.runtime?.sendMessage) {
424+
chrome.runtime.sendMessage({ type: 'CLEAR_PANEL_STATE' });
425+
}
426+
}
427+
}, [panelMode]);
428+
360429
// Sync entitiesToAdd with selectedScanItems when in preview mode
361430
React.useEffect(() => {
362431
if (panelMode === 'preview' && scanResultsEntitiesRef.current.length > 0) {
@@ -645,7 +714,15 @@ const App: React.FC = () => {
645714
setTimeout(() => {
646715
chrome.runtime.sendMessage({ type: 'GET_PANEL_STATE' }, (response) => {
647716
if (chrome.runtime.lastError) return;
648-
if (response?.success && response.data) handlePanelState(response.data);
717+
if (response?.success && response.data) {
718+
handlePanelState(response.data);
719+
// Restore persisted workflow state if available
720+
const ws = response.data.workflowState;
721+
if (ws && ws.panelMode && ws.panelMode !== 'empty' && ws.panelMode !== 'loading') {
722+
restoreWorkflowState(ws);
723+
}
724+
}
725+
hasRestoredState.current = true;
649726
});
650727
}, 50);
651728

@@ -845,6 +922,49 @@ const App: React.FC = () => {
845922
// eslint-disable-next-line react-hooks/exhaustive-deps
846923
}, [panelMode, selectedPlatformId]);
847924

925+
// Restore workflow state from persisted data (called on panel mount)
926+
const restoreWorkflowState = (ws: {
927+
panelMode?: string;
928+
containerType?: string;
929+
containerForm?: { name: string; description: string; content?: string };
930+
containerWorkflowOrigin?: string;
931+
selectedPlatformId?: string;
932+
currentPageUrl?: string;
933+
currentPageTitle?: string;
934+
currentPdfFileName?: string;
935+
isPdfSource?: boolean;
936+
scanResultsEntities?: unknown[];
937+
selectedScanItems?: string[];
938+
scanPageContent?: string;
939+
}) => {
940+
log.debug('[PANEL] Restoring workflow state, mode:', ws.panelMode);
941+
if (ws.currentPageUrl) setCurrentPageUrl(ws.currentPageUrl);
942+
if (ws.currentPageTitle) setCurrentPageTitle(ws.currentPageTitle);
943+
if (ws.currentPdfFileName) setCurrentPdfFileName(ws.currentPdfFileName);
944+
if (ws.isPdfSource !== undefined) setIsPdfSource(ws.isPdfSource);
945+
if (ws.selectedPlatformId) {
946+
setSelectedPlatformId(ws.selectedPlatformId);
947+
const platform = availablePlatformsRef.current.find(p => p.id === ws.selectedPlatformId);
948+
if (platform) {
949+
setPlatformUrl(platform.url);
950+
} else {
951+
pendingPlatformIdRef.current = ws.selectedPlatformId;
952+
}
953+
}
954+
if (ws.scanResultsEntities && Array.isArray(ws.scanResultsEntities) && ws.scanResultsEntities.length > 0) {
955+
setScanResultsEntities(ws.scanResultsEntities as typeof scanResultsEntities);
956+
scanResultsEntitiesRef.current = ws.scanResultsEntities as typeof scanResultsEntities;
957+
}
958+
if (ws.selectedScanItems && Array.isArray(ws.selectedScanItems)) {
959+
setSelectedScanItems(new Set(ws.selectedScanItems));
960+
}
961+
if (ws.scanPageContent) setScanPageContent(ws.scanPageContent);
962+
if (ws.containerType) setOCTIContainerType(ws.containerType as Parameters<typeof setOCTIContainerType>[0]);
963+
if (ws.containerForm) setContainerForm(ws.containerForm as Parameters<typeof setContainerForm>[0]);
964+
if (ws.containerWorkflowOrigin) setContainerWorkflowOrigin(ws.containerWorkflowOrigin as 'preview' | 'direct' | 'import');
965+
if (ws.panelMode) setPanelMode(ws.panelMode as PanelMode);
966+
};
967+
848968
const handleMessage = (event: MessageEvent) => {
849969
const { type, payload } = event.data;
850970
handlePanelState({ type, payload });

src/panel/views/CommonScanResultsView.tsx

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -915,6 +915,10 @@ export const CommonScanResultsView: React.FC<ExtendedScanResultsViewProps> = ({
915915
// Reset filters
916916
setScanResultsTypeFilter('all');
917917
setScanResultsFoundFilter('all');
918+
// Clear persisted workflow state so stale results don't restore on reopen
919+
if (typeof chrome !== 'undefined' && chrome.runtime?.sendMessage) {
920+
chrome.runtime.sendMessage({ type: 'CLEAR_PANEL_STATE' });
921+
}
918922
}}
919923
sx={{
920924
textTransform: 'none',

src/shared/types/messages.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@ export type MessageType =
3636
| 'GET_CACHE_STATS'
3737
| 'SELECTION_CHANGED'
3838
| 'GET_PANEL_STATE'
39+
| 'SAVE_PANEL_STATE'
40+
| 'CLEAR_PANEL_STATE'
3941
| 'OPEN_SIDE_PANEL'
4042
| 'OPEN_SIDE_PANEL_IMMEDIATE'
4143
| 'FORWARD_TO_PANEL'

src/shared/utils/storage.ts

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,59 @@ export const sessionStorage = {
8282
},
8383
};
8484

85+
// ============================================================================
86+
// Panel Workflow State Persistence
87+
// ============================================================================
88+
89+
const PANEL_WORKFLOW_STATE_KEY = 'panelWorkflowState';
90+
const PANEL_WORKFLOW_STATE_TTL = 30 * 60 * 1000; // 30 minutes
91+
92+
export interface PanelWorkflowState {
93+
panelMode: string;
94+
containerType?: string;
95+
containerForm?: { name: string; description: string; content?: string };
96+
containerWorkflowOrigin?: string;
97+
selectedPlatformId?: string;
98+
currentPageUrl?: string;
99+
currentPageTitle?: string;
100+
currentPdfFileName?: string;
101+
isPdfSource?: boolean;
102+
scanResultsEntities?: unknown[];
103+
selectedScanItems?: string[];
104+
scanPageContent?: string;
105+
timestamp: number;
106+
}
107+
108+
/**
109+
* Save panel workflow state to session storage.
110+
* This preserves the user's place in a workflow (e.g. container creation)
111+
* across panel close/reopen cycles.
112+
*/
113+
export async function savePanelWorkflowState(state: PanelWorkflowState): Promise<void> {
114+
await sessionStorage.set(PANEL_WORKFLOW_STATE_KEY, state);
115+
}
116+
117+
/**
118+
* Load panel workflow state from session storage.
119+
* Returns null if no state exists or the state has expired.
120+
*/
121+
export async function loadPanelWorkflowState(): Promise<PanelWorkflowState | null> {
122+
const state = await sessionStorage.get<PanelWorkflowState>(PANEL_WORKFLOW_STATE_KEY);
123+
if (!state) return null;
124+
if (Date.now() - state.timestamp > PANEL_WORKFLOW_STATE_TTL) {
125+
await sessionStorage.remove(PANEL_WORKFLOW_STATE_KEY);
126+
return null;
127+
}
128+
return state;
129+
}
130+
131+
/**
132+
* Clear panel workflow state (e.g. after completing a workflow).
133+
*/
134+
export async function clearPanelWorkflowState(): Promise<void> {
135+
await sessionStorage.remove(PANEL_WORKFLOW_STATE_KEY);
136+
}
137+
85138
// ============================================================================
86139
// Settings Storage
87140
// ============================================================================

tests/unit/messages.test.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,6 +454,8 @@ describe('Handler Registries', () => {
454454
'INJECT_CONTENT_SCRIPT',
455455
'INJECT_ALL_TABS',
456456
'GET_PANEL_STATE',
457+
'SAVE_PANEL_STATE',
458+
'CLEAR_PANEL_STATE',
457459
'GENERATE_NATIVE_PDF',
458460
'FETCH_IMAGE_AS_DATA_URL',
459461
];

tests/unit/misc-handlers.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -299,7 +299,7 @@ describe('handleGetPanelState', () => {
299299
expect(mockSendResponse).toHaveBeenCalledWith(
300300
expect.objectContaining({
301301
success: true,
302-
data: { ready: true },
302+
data: { ready: true, workflowState: null },
303303
})
304304
);
305305
});

0 commit comments

Comments
 (0)