diff --git a/chrome-extension/public/manifest.json b/chrome-extension/public/manifest.json index 5ab7ca3..34fde9a 100644 --- a/chrome-extension/public/manifest.json +++ b/chrome-extension/public/manifest.json @@ -15,7 +15,8 @@ "storage", "sidePanel", "nativeMessaging", - "tabs" + "tabs", + "scripting" ], "host_permissions": [ "" @@ -35,5 +36,12 @@ }, "default_title": "reverse-api-engineer" }, + "content_scripts": [ + { + "matches": [""], + "js": ["content/codegen-recorder.js"], + "run_at": "document_start" + } + ], "default_locale": "en" } diff --git a/chrome-extension/src/background/service-worker.ts b/chrome-extension/src/background/service-worker.ts index d948b1d..febda25 100644 --- a/chrome-extension/src/background/service-worker.ts +++ b/chrome-extension/src/background/service-worker.ts @@ -8,24 +8,46 @@ import { getSettings, saveSettings, getCurrentSession, - saveCurrentSession, clearCapturedRequests, - addCapturedRequest + addCapturedRequest, + getAllSessions, + getSession, + saveSession, + deleteSession as deleteSessionFromStorage, + getActiveSessionId, + setActiveSessionId, + getAppMode, + setAppMode } from '../shared/storage' +import type { Session, AppMode } from '../shared/types' let currentRunId: string | null = null +let activeSessionId: string | null = null let nativeHostConnected = false +let currentMode: AppMode = 'capture' + +// Codegen state +let codegenActive = false +let codegenScript = '' +let codegenTabId: number | null = null async function initialize(): Promise { console.log('Reverse API Engineer: Initializing...') chrome.sidePanel.setPanelBehavior({ openPanelOnActionClick: true }) - const session = await getCurrentSession() - if (session) { - currentRunId = session.runId - console.log('Restored session:', currentRunId) + // Restore active session + activeSessionId = await getActiveSessionId() + if (activeSessionId) { + const session = await getSession(activeSessionId) + if (session) { + currentRunId = session.runId + console.log('Restored session:', currentRunId) + } } + // Restore mode + currentMode = await getAppMode() + await checkNativeHost() captureManager.addListener(handleCaptureEvent) console.log('Reverse API Engineer: Ready') @@ -45,7 +67,7 @@ async function checkNativeHost(): Promise { function handleCaptureEvent(event: { type: string; request?: unknown }): void { broadcastMessage({ type: 'captureEvent', event }) if (event.type === 'complete' || event.type === 'failed') { - addCapturedRequest(event.request) + addCapturedRequest(event.request, activeSessionId || undefined) } } @@ -83,6 +105,27 @@ async function handleMessage(message: { type: string; [key: string]: unknown }): return nativeHost.getStatus() case 'chat': return handleChat(message.message as string, message.model as string | undefined) + // Session management + case 'getSessions': + return getAllSessions() + case 'createSession': + return createSession(message.name as string | undefined) + case 'switchSession': + return switchSession(message.sessionId as string) + case 'deleteSession': + return deleteSession(message.sessionId as string) + case 'renameSession': + return renameSession(message.sessionId as string, message.name as string) + // Mode management + case 'setMode': + return setMode(message.mode as AppMode) + case 'startCodegen': + return startCodegen() + case 'stopCodegen': + return stopCodegen() + // Codegen state for content script + case 'getCodegenState': + return { codegenActive, codegenTabId } default: throw new Error(`Unknown message type: ${message.type}`) } @@ -90,6 +133,7 @@ async function handleMessage(message: { type: string; [key: string]: unknown }): async function getState(): Promise> { const session = await getCurrentSession() + const sessions = await getAllSessions() const settings = await getSettings() const stats = captureManager.getStats() @@ -97,12 +141,215 @@ async function getState(): Promise> { capturing: captureManager.isCapturing(), runId: currentRunId, session, + sessions, settings, stats, - nativeHostConnected + nativeHostConnected, + activeSessionId, + mode: currentMode, + codegenActive, + codegenScript + } +} + +// Session management functions +async function createSession(name?: string): Promise<{ success: boolean; session: Session }> { + const runId = generateRunId() + const sessionId = `session_${Date.now()}` + const sessionName = name || `Session ${new Date().toLocaleString()}` + + const newSession: Session = { + id: sessionId, + runId, + name: sessionName, + tabId: 0, + startTime: new Date().toISOString(), + requestCount: 0, + isActive: true, + messages: [] + } + + await saveSession(newSession) + + // Don't switch if currently capturing - just create the session + if (!captureManager.isCapturing()) { + await setActiveSessionId(sessionId) + activeSessionId = sessionId + currentRunId = runId + } + + broadcastMessage({ type: 'sessionCreated', session: newSession }) + + return { success: true, session: newSession } +} + +async function switchSession(sessionId: string): Promise<{ success: boolean; session: Session | null }> { + // Don't allow switching if capturing on current session + if (captureManager.isCapturing()) { + throw new Error('Cannot switch sessions while capturing. Stop capture first.') + } + + const session = await getSession(sessionId) + if (!session) { + throw new Error('Session not found') + } + + await setActiveSessionId(sessionId) + activeSessionId = sessionId + currentRunId = session.runId + + broadcastMessage({ type: 'sessionSwitched', session }) + + return { success: true, session } +} + +async function deleteSession(sessionId: string): Promise<{ success: boolean }> { + // Don't allow deleting active session while capturing + if (sessionId === activeSessionId && captureManager.isCapturing()) { + throw new Error('Cannot delete active session while capturing.') + } + + await deleteSessionFromStorage(sessionId) + + // If we deleted the active session, clear the active state + if (sessionId === activeSessionId) { + await setActiveSessionId(null) + activeSessionId = null + currentRunId = null + } + + broadcastMessage({ type: 'sessionDeleted', sessionId }) + + return { success: true } +} + +async function renameSession(sessionId: string, name: string): Promise<{ success: boolean; session: Session | null }> { + const session = await getSession(sessionId) + if (!session) { + throw new Error('Session not found') } + + session.name = name + await saveSession(session) + + broadcastMessage({ type: 'sessionRenamed', session }) + + return { success: true, session } +} + +// Mode management +async function setMode(mode: AppMode): Promise<{ success: boolean; mode: AppMode }> { + currentMode = mode + await setAppMode(mode) + broadcastMessage({ type: 'modeChanged', mode }) + return { success: true, mode } } +// Codegen functions +async function startCodegen(): Promise<{ success: boolean }> { + if (codegenActive) { + throw new Error('Codegen already active') + } + + const [tab] = await chrome.tabs.query({ active: true, currentWindow: true }) + if (!tab?.id) throw new Error('No active tab') + codegenTabId = tab.id + + codegenActive = true + codegenScript = `from playwright.sync_api import sync_playwright + +def run(): + with sync_playwright() as p: + browser = p.chromium.launch(headless=False) + page = browser.new_page() + page.goto("${tab.url || 'about:blank'}") + +` + + // Send message to content script to start recording + try { + await chrome.tabs.sendMessage(codegenTabId, { type: 'startCodegenRecording' }) + } catch (error) { + console.error('Failed to start content script recording:', error) + } + + broadcastMessage({ type: 'codegenStarted', script: codegenScript }) + + return { success: true } +} + +async function stopCodegen(): Promise<{ success: boolean; script: string }> { + if (!codegenActive) { + throw new Error('Codegen not active') + } + + // Close the script + codegenScript += ` browser.close() + +if __name__ == "__main__": + run() +` + + // Send message to content script to stop recording + if (codegenTabId) { + try { + await chrome.tabs.sendMessage(codegenTabId, { type: 'stopCodegenRecording' }) + } catch (error) { + console.error('Failed to stop content script recording:', error) + } + } + + codegenActive = false + const finalScript = codegenScript + + // Save to active session if exists + if (activeSessionId) { + const session = await getSession(activeSessionId) + if (session) { + session.codegenScript = finalScript + await saveSession(session) + } + } + + broadcastMessage({ type: 'codegenStopped', script: finalScript }) + + codegenTabId = null + + return { success: true, script: finalScript } +} + +// Listen for codegen events from content script +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (message.type === 'codegenAction' && codegenActive && sender.tab?.id === codegenTabId) { + const { action, selector, value, url } = message + let code = '' + + switch (action) { + case 'click': + code = ` page.click("${selector}")\n` + break + case 'fill': + code = ` page.fill("${selector}", "${value}")\n` + break + case 'navigate': + code = ` page.goto("${url}")\n` + break + case 'select': + code = ` page.select_option("${selector}", "${value}")\n` + break + } + + if (code) { + codegenScript += code + broadcastMessage({ type: 'codegenUpdate', script: codegenScript, newCode: code }) + } + + sendResponse({ success: true }) + return true + } + return false +}) + async function startCapture(tabId?: number): Promise<{ success: boolean; runId: string; tabId: number }> { if (captureManager.isCapturing()) { throw new Error('Already capturing') @@ -114,18 +361,29 @@ async function startCapture(tabId?: number): Promise<{ success: boolean; runId: tabId = tab.id } + // Create a new session if none exists + if (!activeSessionId) { + const { session } = await createSession() + activeSessionId = session.id + currentRunId = session.runId + } + currentRunId = generateRunId() const settings = await getSettings() - await clearCapturedRequests() + await clearCapturedRequests(activeSessionId || undefined) await captureManager.start(tabId, { captureTypes: settings.captureTypes }) - await saveCurrentSession({ - runId: currentRunId, - tabId, - startTime: new Date().toISOString(), - requestCount: 0 - }) + // Update the active session + const session = await getSession(activeSessionId) + if (session) { + session.runId = currentRunId + session.tabId = tabId + session.startTime = new Date().toISOString() + session.requestCount = 0 + session.isActive = true + await saveSession(session) + } chrome.action.setBadgeText({ text: 'REC' }) chrome.action.setBadgeBackgroundColor({ color: '#ff0000' }) @@ -145,7 +403,8 @@ async function stopCapture(): Promise<{ success: boolean; runId: string | null; if (session && har) { session.endTime = new Date().toISOString() session.requestCount = har.log.entries.length - await saveCurrentSession(session) + session.isActive = false + await saveSession(session) } let harPath: string | null = null diff --git a/chrome-extension/src/components/code-display.tsx b/chrome-extension/src/components/code-display.tsx new file mode 100644 index 0000000..b064bf3 --- /dev/null +++ b/chrome-extension/src/components/code-display.tsx @@ -0,0 +1,179 @@ +import { useRef, useEffect, useState } from 'react' +import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter' +import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism' +import { Button } from '@base-ui/react/button' + +interface CodeDisplayProps { + code: string + language?: string + title?: string + isLive?: boolean + onCopy?: () => void +} + +export function CodeDisplay({ + code, + language = 'python', + title = 'Generated Script', + isLive = false, + onCopy +}: CodeDisplayProps) { + const containerRef = useRef(null) + const [copied, setCopied] = useState(false) + const [autoScroll, setAutoScroll] = useState(true) + + // Auto-scroll to bottom when new code is added + useEffect(() => { + if (autoScroll && containerRef.current) { + containerRef.current.scrollTop = containerRef.current.scrollHeight + } + }, [code, autoScroll]) + + const handleCopy = async () => { + try { + await navigator.clipboard.writeText(code) + setCopied(true) + setTimeout(() => setCopied(false), 2000) + onCopy?.() + } catch (err) { + console.error('Failed to copy:', err) + } + } + + const handleScroll = () => { + if (containerRef.current) { + const { scrollTop, scrollHeight, clientHeight } = containerRef.current + // If user scrolls up, disable auto-scroll + // If they scroll to bottom, re-enable it + const isAtBottom = scrollHeight - scrollTop - clientHeight < 50 + setAutoScroll(isAtBottom) + } + } + + const lineCount = code.split('\n').length + + return ( +
+ {/* Header */} +
+
+ + {title} + {isLive && ( + + + Live + + )} +
+
+ {lineCount} lines + +
+
+ + {/* Code content */} +
+ {code ? ( + + {code} + + ) : ( +
+ +

+ {isLive ? 'Waiting for actions...' : 'No code generated yet'} +

+ {isLive && ( +

+ Interact with the page to record actions +

+ )} +
+ )} +
+ + {/* Auto-scroll indicator */} + {!autoScroll && code && ( +
+ +
+ )} +
+ ) +} + +function CodeIcon() { + return ( + + + + ) +} + +function CopyIcon() { + return ( + + + + ) +} + +function CheckIcon() { + return ( + + + + ) +} + +function EmptyCodeIcon() { + return ( + + + + + + + + ) +} diff --git a/chrome-extension/src/components/mode-selector.tsx b/chrome-extension/src/components/mode-selector.tsx new file mode 100644 index 0000000..b70f2d0 --- /dev/null +++ b/chrome-extension/src/components/mode-selector.tsx @@ -0,0 +1,36 @@ +import type { AppMode } from '../shared/types' + +interface ModeSelectorProps { + mode: AppMode + onModeChange: (mode: AppMode) => void + disabled?: boolean +} + +export function ModeSelector({ mode, onModeChange, disabled }: ModeSelectorProps) { + return ( +
+ + +
+ ) +} diff --git a/chrome-extension/src/components/session-selector.tsx b/chrome-extension/src/components/session-selector.tsx new file mode 100644 index 0000000..e5620f3 --- /dev/null +++ b/chrome-extension/src/components/session-selector.tsx @@ -0,0 +1,265 @@ +import { useState, useRef, useEffect } from 'react' +import { Button } from '@base-ui/react/button' +import type { Session } from '../shared/types' + +interface SessionSelectorProps { + sessions: Session[] + activeSessionId: string | null + isCapturing: boolean + onCreateSession: (name?: string) => void + onSwitchSession: (sessionId: string) => void + onDeleteSession: (sessionId: string) => void + onRenameSession: (sessionId: string, name: string) => void +} + +export function SessionSelector({ + sessions, + activeSessionId, + isCapturing, + onCreateSession, + onSwitchSession, + onDeleteSession, + onRenameSession +}: SessionSelectorProps) { + const [isOpen, setIsOpen] = useState(false) + const [editingId, setEditingId] = useState(null) + const [editName, setEditName] = useState('') + const [newSessionName, setNewSessionName] = useState('') + const [showNewInput, setShowNewInput] = useState(false) + const dropdownRef = useRef(null) + const inputRef = useRef(null) + + const activeSession = sessions.find(s => s.id === activeSessionId) + + // Close dropdown when clicking outside + useEffect(() => { + const handleClickOutside = (event: MouseEvent) => { + if (dropdownRef.current && !dropdownRef.current.contains(event.target as Node)) { + setIsOpen(false) + setEditingId(null) + setShowNewInput(false) + } + } + + document.addEventListener('mousedown', handleClickOutside) + return () => document.removeEventListener('mousedown', handleClickOutside) + }, []) + + // Focus input when editing + useEffect(() => { + if ((editingId || showNewInput) && inputRef.current) { + inputRef.current.focus() + } + }, [editingId, showNewInput]) + + const handleCreateSession = () => { + if (showNewInput && newSessionName.trim()) { + onCreateSession(newSessionName.trim()) + setNewSessionName('') + setShowNewInput(false) + } else { + setShowNewInput(true) + } + } + + const handleNewSessionKeyDown = (e: React.KeyboardEvent) => { + if (e.key === 'Enter' && newSessionName.trim()) { + onCreateSession(newSessionName.trim()) + setNewSessionName('') + setShowNewInput(false) + } else if (e.key === 'Escape') { + setShowNewInput(false) + setNewSessionName('') + } + } + + const handleRenameKeyDown = (e: React.KeyboardEvent, sessionId: string) => { + if (e.key === 'Enter' && editName.trim()) { + onRenameSession(sessionId, editName.trim()) + setEditingId(null) + } else if (e.key === 'Escape') { + setEditingId(null) + } + } + + const startEditing = (session: Session) => { + setEditingId(session.id) + setEditName(session.name) + } + + const formatDate = (dateString: string) => { + const date = new Date(dateString) + return date.toLocaleString(undefined, { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }) + } + + return ( +
+ + + {isOpen && ( +
+ {/* Header */} +
+
+ Sessions + +
+
+ + {/* New session input */} + {showNewInput && ( +
+ setNewSessionName(e.target.value)} + onKeyDown={handleNewSessionKeyDown} + placeholder="Session name..." + className="w-full bg-transparent text-xs text-white placeholder:text-white/30 border-none outline-none" + /> +
+ )} + + {/* Session list */} +
+ {sessions.length === 0 ? ( +
+ No sessions yet +
+ ) : ( + sessions.map(session => ( +
{ + if (!editingId && session.id !== activeSessionId) { + onSwitchSession(session.id) + setIsOpen(false) + } + }} + > + {editingId === session.id ? ( + setEditName(e.target.value)} + onKeyDown={(e) => handleRenameKeyDown(e, session.id)} + onClick={(e) => e.stopPropagation()} + className="flex-1 bg-transparent text-xs text-white border border-primary/50 rounded px-1 py-0.5 outline-none" + /> + ) : ( + <> +
+
+ {session.name} + {session.isActive && ( + + )} +
+
+ {formatDate(session.startTime)} + + {session.requestCount} requests +
+
+ + {/* Actions */} +
+ + {session.id !== activeSessionId && !isCapturing && ( + + )} +
+ + )} +
+ )) + )} +
+ + {/* Footer with warning if capturing */} + {isCapturing && ( +
+ + Stop capture to switch sessions + +
+ )} +
+ )} +
+ ) +} + +function FolderIcon() { + return ( + + + + ) +} + +function ChevronIcon({ isOpen }: { isOpen: boolean }) { + return ( + + + + ) +} + +function EditIcon() { + return ( + + + + ) +} + +function TrashIcon() { + return ( + + + + ) +} diff --git a/chrome-extension/src/content/codegen-recorder.ts b/chrome-extension/src/content/codegen-recorder.ts new file mode 100644 index 0000000..0923a5c --- /dev/null +++ b/chrome-extension/src/content/codegen-recorder.ts @@ -0,0 +1,247 @@ +/** + * Content script for recording user interactions for Playwright codegen + */ + +let isRecording = false + +// Generate a robust CSS selector for an element +function getSelector(element: Element): string { + // Try data-testid first + if (element.hasAttribute('data-testid')) { + return `[data-testid="${element.getAttribute('data-testid')}"]` + } + + // Try id + if (element.id) { + return `#${CSS.escape(element.id)}` + } + + // Try unique class combination + if (element.classList.length > 0) { + const classes = Array.from(element.classList) + .filter(c => !c.match(/^(active|hover|focus|selected|open|closed|hidden|visible)/i)) + .slice(0, 3) + if (classes.length > 0) { + const selector = classes.map(c => `.${CSS.escape(c)}`).join('') + const matches = document.querySelectorAll(selector) + if (matches.length === 1) { + return selector + } + } + } + + // Try name attribute for form elements + if (element.hasAttribute('name')) { + const name = element.getAttribute('name') + const tag = element.tagName.toLowerCase() + const selector = `${tag}[name="${name}"]` + const matches = document.querySelectorAll(selector) + if (matches.length === 1) { + return selector + } + } + + // Try placeholder for inputs + if (element.hasAttribute('placeholder')) { + const placeholder = element.getAttribute('placeholder') + return `[placeholder="${placeholder}"]` + } + + // Try aria-label + if (element.hasAttribute('aria-label')) { + return `[aria-label="${element.getAttribute('aria-label')}"]` + } + + // Try text content for buttons and links + if (element.tagName === 'BUTTON' || element.tagName === 'A') { + const text = element.textContent?.trim().slice(0, 30) + if (text) { + return `text="${text}"` + } + } + + // Fall back to tag + nth-child + const parent = element.parentElement + if (parent) { + const siblings = Array.from(parent.children) + const index = siblings.indexOf(element) + 1 + const tag = element.tagName.toLowerCase() + const parentSelector = getSelector(parent) + if (parentSelector !== 'body') { + return `${parentSelector} > ${tag}:nth-child(${index})` + } + } + + // Last resort: just the tag + return element.tagName.toLowerCase() +} + +// Escape string for Python +function escapeString(str: string): string { + return str + .replace(/\\/g, '\\\\') + .replace(/"/g, '\\"') + .replace(/\n/g, '\\n') + .replace(/\r/g, '\\r') + .replace(/\t/g, '\\t') +} + +// Send action to background script +function sendAction(action: string, data: Record) { + chrome.runtime.sendMessage({ + type: 'codegenAction', + action, + ...data + }).catch(() => { + // Extension might not be listening + }) +} + +// Track the last input element to capture its final value +let lastInputElement: HTMLInputElement | HTMLTextAreaElement | null = null +let inputTimeout: ReturnType | null = null + +function handleClick(event: MouseEvent) { + if (!isRecording) return + + const target = event.target as Element + if (!target) return + + // Skip clicks on the extension's own elements + if (target.closest('[data-codegen-ignore]')) return + + // Commit any pending input + commitPendingInput() + + const selector = getSelector(target) + + // Check if it's a select element + if (target.tagName === 'SELECT') { + // Will be handled by change event + return + } + + sendAction('click', { selector }) +} + +function handleInput(event: Event) { + if (!isRecording) return + + const target = event.target as HTMLInputElement | HTMLTextAreaElement + if (!target) return + + // Track the input element + lastInputElement = target + + // Debounce the input to get the final value + if (inputTimeout) { + clearTimeout(inputTimeout) + } + + inputTimeout = setTimeout(() => { + commitPendingInput() + }, 500) +} + +function commitPendingInput() { + if (!lastInputElement) return + + const selector = getSelector(lastInputElement) + const value = escapeString(lastInputElement.value) + + if (value) { + sendAction('fill', { selector, value }) + } + + lastInputElement = null + if (inputTimeout) { + clearTimeout(inputTimeout) + inputTimeout = null + } +} + +function handleChange(event: Event) { + if (!isRecording) return + + const target = event.target as HTMLSelectElement + if (!target || target.tagName !== 'SELECT') return + + const selector = getSelector(target) + const value = escapeString(target.value) + + sendAction('select', { selector, value }) +} + +function handleKeyDown(event: KeyboardEvent) { + if (!isRecording) return + + // Commit input on Enter + if (event.key === 'Enter') { + commitPendingInput() + } +} + +// Handle navigation +let lastUrl = window.location.href +function checkNavigation() { + if (window.location.href !== lastUrl) { + lastUrl = window.location.href + if (isRecording) { + sendAction('navigate', { url: lastUrl }) + } + } +} + +// Start recording +function startRecording() { + if (isRecording) return + + isRecording = true + lastUrl = window.location.href + + document.addEventListener('click', handleClick, true) + document.addEventListener('input', handleInput, true) + document.addEventListener('change', handleChange, true) + document.addEventListener('keydown', handleKeyDown, true) + + // Check for navigation periodically + setInterval(checkNavigation, 100) + + console.log('[Codegen] Recording started') +} + +// Stop recording +function stopRecording() { + if (!isRecording) return + + commitPendingInput() + isRecording = false + + document.removeEventListener('click', handleClick, true) + document.removeEventListener('input', handleInput, true) + document.removeEventListener('change', handleChange, true) + document.removeEventListener('keydown', handleKeyDown, true) + + console.log('[Codegen] Recording stopped') +} + +// Listen for messages from background script +chrome.runtime.onMessage.addListener((message, _sender, sendResponse) => { + if (message.type === 'startCodegenRecording') { + startRecording() + sendResponse({ success: true }) + } else if (message.type === 'stopCodegenRecording') { + stopRecording() + sendResponse({ success: true }) + } + return true +}) + +// Check if we should be recording on load +chrome.runtime.sendMessage({ type: 'getCodegenState' }).then(response => { + if (response?.codegenActive) { + startRecording() + } +}).catch(() => { + // Extension not ready yet +}) diff --git a/chrome-extension/src/shared/storage.ts b/chrome-extension/src/shared/storage.ts index c10332e..1f981ca 100644 --- a/chrome-extension/src/shared/storage.ts +++ b/chrome-extension/src/shared/storage.ts @@ -2,7 +2,7 @@ * Storage management for the extension */ -import type { Settings } from './types' +import type { Settings, Session, ChatMessage, AppMode } from './types' const DEFAULT_SETTINGS: Settings = { captureTypes: ['xhr', 'fetch', 'websocket'], @@ -21,46 +21,122 @@ export async function saveSettings(settings: Partial): Promise { }) } -interface Session { - runId: string - tabId: number - startTime: string - endTime?: string - requestCount: number +// Multi-session storage functions + +export async function getAllSessions(): Promise { + const result = await chrome.storage.local.get('sessions') + return result.sessions || [] +} + +export async function saveAllSessions(sessions: Session[]): Promise { + await chrome.storage.local.set({ sessions }) +} + +export async function getSession(sessionId: string): Promise { + const sessions = await getAllSessions() + return sessions.find(s => s.id === sessionId) || null +} + +export async function saveSession(session: Session): Promise { + const sessions = await getAllSessions() + const index = sessions.findIndex(s => s.id === session.id) + if (index >= 0) { + sessions[index] = session + } else { + sessions.push(session) + } + await saveAllSessions(sessions) +} + +export async function deleteSession(sessionId: string): Promise { + const sessions = await getAllSessions() + const filtered = sessions.filter(s => s.id !== sessionId) + await saveAllSessions(filtered) + // Also clean up captured requests for this session + await chrome.storage.local.remove(`capturedRequests_${sessionId}`) +} + +export async function getActiveSessionId(): Promise { + const result = await chrome.storage.local.get('activeSessionId') + return result.activeSessionId || null +} + +export async function setActiveSessionId(sessionId: string | null): Promise { + if (sessionId) { + await chrome.storage.local.set({ activeSessionId: sessionId }) + } else { + await chrome.storage.local.remove('activeSessionId') + } } +export async function getActiveSession(): Promise { + const activeId = await getActiveSessionId() + if (!activeId) return null + return getSession(activeId) +} + +export async function updateSessionMessages(sessionId: string, messages: ChatMessage[]): Promise { + const session = await getSession(sessionId) + if (session) { + session.messages = messages + await saveSession(session) + } +} + +export async function updateSessionCodegenScript(sessionId: string, script: string): Promise { + const session = await getSession(sessionId) + if (session) { + session.codegenScript = script + await saveSession(session) + } +} + +// Mode storage +export async function getAppMode(): Promise { + const result = await chrome.storage.local.get('appMode') + return result.appMode || 'capture' +} + +export async function setAppMode(mode: AppMode): Promise { + await chrome.storage.local.set({ appMode: mode }) +} + +// Legacy functions for backward compatibility export async function getCurrentSession(): Promise { - const result = await chrome.storage.local.get('currentSession') - return result.currentSession || null + return getActiveSession() } -export async function saveCurrentSession(session: Session): Promise { - await chrome.storage.local.set({ currentSession: session }) +export async function saveCurrentSession(session: Partial & { runId: string; tabId: number; startTime: string; requestCount: number }): Promise { + const existingSession = await getActiveSession() + if (existingSession) { + const updatedSession: Session = { + ...existingSession, + ...session, + } + await saveSession(updatedSession) + } } export async function clearCurrentSession(): Promise { - await chrome.storage.local.remove('currentSession') + await setActiveSessionId(null) } -export async function getCapturedRequests(): Promise { - const result = await chrome.storage.local.get('capturedRequests') - return result.capturedRequests || [] +export async function getCapturedRequests(sessionId?: string): Promise { + const key = sessionId ? `capturedRequests_${sessionId}` : 'capturedRequests' + const result = await chrome.storage.local.get(key) + return result[key] || [] } -export async function addCapturedRequest(request: unknown): Promise { - const requests = await getCapturedRequests() +export async function addCapturedRequest(request: unknown, sessionId?: string): Promise { + const key = sessionId ? `capturedRequests_${sessionId}` : 'capturedRequests' + const requests = await getCapturedRequests(sessionId) requests.push(request) - await chrome.storage.local.set({ capturedRequests: requests }) -} - -export async function clearCapturedRequests(): Promise { - await chrome.storage.local.set({ capturedRequests: [] }) + await chrome.storage.local.set({ [key]: requests }) } -interface ChatMessage { - role: 'user' | 'assistant' - content: string - timestamp: string +export async function clearCapturedRequests(sessionId?: string): Promise { + const key = sessionId ? `capturedRequests_${sessionId}` : 'capturedRequests' + await chrome.storage.local.set({ [key]: [] }) } export async function getChatHistory(runId: string): Promise { diff --git a/chrome-extension/src/shared/types.ts b/chrome-extension/src/shared/types.ts index 61ea856..0d348bc 100644 --- a/chrome-extension/src/shared/types.ts +++ b/chrome-extension/src/shared/types.ts @@ -1,3 +1,26 @@ +export interface Session { + id: string + runId: string + name: string + tabId: number + startTime: string + endTime?: string + requestCount: number + isActive: boolean + messages: ChatMessage[] + codegenScript?: string +} + +export interface ChatMessage { + id: string + role: 'user' | 'assistant' + content?: string + events?: AgentEvent[] + timestamp: string +} + +export type AppMode = 'capture' | 'codegen' + export interface AppState { capturing: boolean runId: string | null @@ -7,6 +30,8 @@ export interface AppState { total: number } current_task?: string | null + activeSessionId: string | null + mode: AppMode } export interface AgentEvent { @@ -34,6 +59,14 @@ export type MessageType = | { type: 'chat'; message: string; model?: string } | { type: 'getSettings' } | { type: 'saveSettings'; settings: Settings } + | { type: 'getSessions' } + | { type: 'createSession'; name?: string } + | { type: 'switchSession'; sessionId: string } + | { type: 'deleteSession'; sessionId: string } + | { type: 'renameSession'; sessionId: string; name: string } + | { type: 'setMode'; mode: AppMode } + | { type: 'startCodegen' } + | { type: 'stopCodegen' } export interface CaptureEvent { type: 'complete' | 'failed' | 'started' diff --git a/chrome-extension/src/sidepanel/side-panel.tsx b/chrome-extension/src/sidepanel/side-panel.tsx index 2a79502..190acb4 100644 --- a/chrome-extension/src/sidepanel/side-panel.tsx +++ b/chrome-extension/src/sidepanel/side-panel.tsx @@ -3,7 +3,10 @@ import { Button } from '@base-ui/react/button' import { Tooltip } from '@base-ui/react/tooltip' import { AgentAction } from '../components/agent-action' import { ChatInput } from '../components/chat-input' -import type { AppState, AgentEvent, Settings } from '../shared/types' +import { SessionSelector } from '../components/session-selector' +import { ModeSelector } from '../components/mode-selector' +import { CodeDisplay } from '../components/code-display' +import type { AppState, AgentEvent, Settings, Session, AppMode } from '../shared/types' interface ChatMessage { id: string @@ -12,13 +15,24 @@ interface ChatMessage { events?: AgentEvent[] } -const DEFAULT_STATE: AppState = { +interface ExtendedAppState extends AppState { + sessions?: Session[] + codegenActive?: boolean + codegenScript?: string +} + +const DEFAULT_STATE: ExtendedAppState = { capturing: false, runId: null, nativeHostConnected: false, isStreaming: false, stats: { total: 0 }, current_task: null, + activeSessionId: null, + mode: 'capture', + sessions: [], + codegenActive: false, + codegenScript: '' } const DEFAULT_SETTINGS: Settings = { @@ -27,7 +41,7 @@ const DEFAULT_SETTINGS: Settings = { } export function SidePanel() { - const [state, setState] = useState(DEFAULT_STATE) + const [state, setState] = useState(DEFAULT_STATE) const [settings, setSettings] = useState(DEFAULT_SETTINGS) const [messages, setMessages] = useState([]) const [inputValue, setInputValue] = useState('') @@ -35,7 +49,7 @@ export function SidePanel() { const messagesEndRef = useRef(null) const currentResponseIdRef = useRef(null) - const warningTimeoutRef = useRef(null) + const warningTimeoutRef = useRef | null>(null) const scrollToBottom = useCallback(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }) @@ -67,7 +81,7 @@ export function SidePanel() { // Listen for messages from background useEffect(() => { - const handleMessage = (message: { type: string; event?: AgentEvent | { type: string } }) => { + const handleMessage = (message: { type: string; event?: AgentEvent | { type: string }; script?: string; session?: Session; mode?: AppMode; newCode?: string }) => { switch (message.type) { case 'captureEvent': // Refresh state to get updated counts @@ -81,6 +95,29 @@ export function SidePanel() { case 'nativeHostDisconnected': setState(prev => ({ ...prev, nativeHostConnected: false })) break + case 'sessionCreated': + case 'sessionSwitched': + case 'sessionDeleted': + case 'sessionRenamed': + // Refresh sessions list + chrome.runtime.sendMessage({ type: 'getState' }).then(res => { + if (res) setState(prev => ({ ...prev, ...res })) + }) + break + case 'modeChanged': + if (message.mode) { + setState(prev => ({ ...prev, mode: message.mode! })) + } + break + case 'codegenStarted': + setState(prev => ({ ...prev, codegenActive: true, codegenScript: message.script || '' })) + break + case 'codegenUpdate': + setState(prev => ({ ...prev, codegenScript: message.script || '' })) + break + case 'codegenStopped': + setState(prev => ({ ...prev, codegenActive: false, codegenScript: message.script || '' })) + break } } @@ -213,6 +250,81 @@ export function SidePanel() { } } + // Session management handlers + const handleCreateSession = async (name?: string) => { + try { + await chrome.runtime.sendMessage({ type: 'createSession', name }) + const res = await chrome.runtime.sendMessage({ type: 'getState' }) + if (res) setState(prev => ({ ...prev, ...res })) + // Clear messages for new session + setMessages([]) + } catch (err) { + console.error('Create session error:', err) + showWarning('Failed to create session') + } + } + + const handleSwitchSession = async (sessionId: string) => { + try { + await chrome.runtime.sendMessage({ type: 'switchSession', sessionId }) + const res = await chrome.runtime.sendMessage({ type: 'getState' }) + if (res) setState(prev => ({ ...prev, ...res })) + // Load messages for the switched session (TODO: implement message persistence per session) + setMessages([]) + } catch (err) { + console.error('Switch session error:', err) + showWarning((err as Error).message || 'Failed to switch session') + } + } + + const handleDeleteSession = async (sessionId: string) => { + try { + await chrome.runtime.sendMessage({ type: 'deleteSession', sessionId }) + const res = await chrome.runtime.sendMessage({ type: 'getState' }) + if (res) setState(prev => ({ ...prev, ...res })) + } catch (err) { + console.error('Delete session error:', err) + showWarning((err as Error).message || 'Failed to delete session') + } + } + + const handleRenameSession = async (sessionId: string, name: string) => { + try { + await chrome.runtime.sendMessage({ type: 'renameSession', sessionId, name }) + const res = await chrome.runtime.sendMessage({ type: 'getState' }) + if (res) setState(prev => ({ ...prev, ...res })) + } catch (err) { + console.error('Rename session error:', err) + showWarning('Failed to rename session') + } + } + + // Mode management handlers + const handleModeChange = async (mode: AppMode) => { + try { + await chrome.runtime.sendMessage({ type: 'setMode', mode }) + setState(prev => ({ ...prev, mode })) + } catch (err) { + console.error('Mode change error:', err) + } + } + + // Codegen handlers + const toggleCodegen = async () => { + try { + if (state.codegenActive) { + await chrome.runtime.sendMessage({ type: 'stopCodegen' }) + } else { + await chrome.runtime.sendMessage({ type: 'startCodegen' }) + } + const res = await chrome.runtime.sendMessage({ type: 'getState' }) + if (res) setState(prev => ({ ...prev, ...res })) + } catch (err) { + console.error('Codegen error:', err) + showWarning((err as Error).message || 'Codegen error') + } + } + return (
{/* Header */} @@ -229,16 +341,38 @@ export function SidePanel() {
- {/* Capture Toggle Button in Header */} + {/* Mode Toggle */} + +
+ + + {/* Sub-header with session selector and action button */} +
+ + + {/* Action Button based on mode */} + {state.mode === 'capture' ? ( {state.capturing ? 'Stop Capture' : 'Start Capture'} @@ -247,17 +381,27 @@ export function SidePanel() { - {state.capturing ? 'Stop process' : 'Start recording'} + {state.capturing ? 'Stop recording' : 'Start recording'} {state.stats.total > 0 && ` (${state.stats.total} requests)`} -
- + ) : ( + + )} + {/* Native host warning */} - {!state.nativeHostConnected && ( + {!state.nativeHostConnected && state.mode === 'capture' && (
@@ -278,8 +422,8 @@ export function SidePanel() {
)} - {/* Traffic Count indicator (when capturing but outside header) */} - {state.stats.total > 0 && ( + {/* Traffic Count indicator (when capturing) */} + {state.mode === 'capture' && state.stats.total > 0 && (
Traffic captured: {state.stats.total} @@ -287,75 +431,90 @@ export function SidePanel() {
)} - {/* Chat area */} -
- {messages.length === 0 ? ( -
-
- - - - - - - -
-
- ) : ( -
- {messages.map((msg, msgIdx) => ( -
- {msg.role === 'user' ? ( - <> - {msgIdx > 0 && ( -
- )} -
- {'>'} -
- {msg.content} + {/* Main content area - conditional based on mode */} + {state.mode === 'codegen' ? ( + /* Codegen Mode - Show code display */ +
+ +
+ ) : ( + /* Capture Mode - Show chat area */ + <> +
+ {messages.length === 0 ? ( +
+
+ + + + + + + +
+
+ ) : ( +
+ {messages.map((msg, msgIdx) => ( +
+ {msg.role === 'user' ? ( + <> + {msgIdx > 0 && ( +
+ )} +
+ {'>'} +
+ {msg.content} +
+
+ + ) : ( +
+ {msg.events?.map((event, idx) => { + const previousEvent = idx > 0 ? msg.events?.[idx - 1] : undefined + return + })}
-
- - ) : ( -
- {msg.events?.map((event, idx) => { - const previousEvent = idx > 0 ? msg.events?.[idx - 1] : undefined - return - })} + )}
- )} + ))} +
- ))} -
+ )}
- )} -
- {/* Chat input */} -
- {warningMessage && ( -
-
-
- {warningMessage} -
+ {/* Chat input */} +
+ {warningMessage && ( +
+
+
+ {warningMessage} +
+
+ )} +
- )} - -
+ + )}
) } diff --git a/chrome-extension/vite.config.ts b/chrome-extension/vite.config.ts index a51ac71..164f272 100644 --- a/chrome-extension/vite.config.ts +++ b/chrome-extension/vite.config.ts @@ -55,12 +55,16 @@ export default defineConfig({ input: { sidepanel: resolve(__dirname, 'src/sidepanel/index.html'), 'service-worker': resolve(__dirname, 'src/background/service-worker.ts'), + 'codegen-recorder': resolve(__dirname, 'src/content/codegen-recorder.ts'), }, output: { entryFileNames: (chunkInfo) => { if (chunkInfo.name === 'service-worker') { return 'background/service-worker.js' } + if (chunkInfo.name === 'codegen-recorder') { + return 'content/codegen-recorder.js' + } return 'sidepanel/[name].js' }, chunkFileNames: 'assets/[name]-[hash].js', @@ -70,7 +74,7 @@ export default defineConfig({ } return 'assets/[name]-[hash][extname]' }, - // Prevent code splitting for service worker + // Prevent code splitting for service worker and content script manualChunks: undefined, }, },