SidePanel.tsx 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607
  1. /* eslint-disable @typescript-eslint/no-explicit-any */
  2. import { useState, useEffect, useCallback, useRef } from 'react';
  3. import { RxDiscordLogo } from 'react-icons/rx';
  4. import { FiSettings } from 'react-icons/fi';
  5. import { PiPlusBold } from 'react-icons/pi';
  6. import { GrHistory } from 'react-icons/gr';
  7. import { type Message, Actors, chatHistoryStore } from '@extension/storage';
  8. import MessageList from './components/MessageList';
  9. import ChatInput from './components/ChatInput';
  10. import ChatHistoryList from './components/ChatHistoryList';
  11. import TemplateList from './components/TemplateList';
  12. import { EventType, type AgentEvent, ExecutionState } from './types/event';
  13. import { defaultTemplates } from './templates';
  14. import './SidePanel.css';
  15. const SidePanel = () => {
  16. const [messages, setMessages] = useState<Message[]>([]);
  17. const [inputEnabled, setInputEnabled] = useState(true);
  18. const [showStopButton, setShowStopButton] = useState(false);
  19. const [currentSessionId, setCurrentSessionId] = useState<string | null>(null);
  20. const [showHistory, setShowHistory] = useState(false);
  21. const [chatSessions, setChatSessions] = useState<Array<{ id: string; title: string; createdAt: number }>>([]);
  22. const [isFollowUpMode, setIsFollowUpMode] = useState(false);
  23. const [isHistoricalSession, setIsHistoricalSession] = useState(false);
  24. const [isDarkMode, setIsDarkMode] = useState(false);
  25. const sessionIdRef = useRef<string | null>(null);
  26. const portRef = useRef<chrome.runtime.Port | null>(null);
  27. const heartbeatIntervalRef = useRef<number | null>(null);
  28. const messagesEndRef = useRef<HTMLDivElement>(null);
  29. const setInputTextRef = useRef<((text: string) => void) | null>(null);
  30. // Check for dark mode preference
  31. useEffect(() => {
  32. const darkModeMediaQuery = window.matchMedia('(prefers-color-scheme: dark)');
  33. setIsDarkMode(darkModeMediaQuery.matches);
  34. const handleChange = (e: MediaQueryListEvent) => {
  35. setIsDarkMode(e.matches);
  36. };
  37. darkModeMediaQuery.addEventListener('change', handleChange);
  38. return () => darkModeMediaQuery.removeEventListener('change', handleChange);
  39. }, []);
  40. useEffect(() => {
  41. sessionIdRef.current = currentSessionId;
  42. }, [currentSessionId]);
  43. const appendMessage = useCallback((newMessage: Message, sessionId?: string | null) => {
  44. // Don't save progress messages
  45. const isProgressMessage = newMessage.content === 'Showing progress...';
  46. setMessages(prev => {
  47. const filteredMessages = prev.filter(
  48. (msg, idx) => !(msg.content === 'Showing progress...' && idx === prev.length - 1),
  49. );
  50. return [...filteredMessages, newMessage];
  51. });
  52. // Use provided sessionId if available, otherwise fall back to sessionIdRef.current
  53. const effectiveSessionId = sessionId !== undefined ? sessionId : sessionIdRef.current;
  54. console.log('sessionId', effectiveSessionId);
  55. // Save message to storage if we have a session and it's not a progress message
  56. if (effectiveSessionId && !isProgressMessage) {
  57. chatHistoryStore
  58. .addMessage(effectiveSessionId, newMessage)
  59. .catch(err => console.error('Failed to save message to history:', err));
  60. }
  61. }, []);
  62. const handleTaskState = useCallback(
  63. (event: AgentEvent) => {
  64. const { actor, state, timestamp, data } = event;
  65. const content = data?.details;
  66. let skip = true;
  67. let displayProgress = false;
  68. switch (actor) {
  69. case Actors.SYSTEM:
  70. switch (state) {
  71. case ExecutionState.TASK_START:
  72. // Reset historical session flag when a new task starts
  73. setIsHistoricalSession(false);
  74. break;
  75. case ExecutionState.TASK_OK:
  76. setIsFollowUpMode(true);
  77. setInputEnabled(true);
  78. setShowStopButton(false);
  79. break;
  80. case ExecutionState.TASK_FAIL:
  81. setIsFollowUpMode(true);
  82. setInputEnabled(true);
  83. setShowStopButton(false);
  84. skip = false;
  85. break;
  86. case ExecutionState.TASK_CANCEL:
  87. setIsFollowUpMode(false);
  88. setInputEnabled(true);
  89. setShowStopButton(false);
  90. skip = false;
  91. break;
  92. case ExecutionState.TASK_PAUSE:
  93. break;
  94. case ExecutionState.TASK_RESUME:
  95. break;
  96. default:
  97. console.error('Invalid task state', state);
  98. return;
  99. }
  100. break;
  101. case Actors.USER:
  102. break;
  103. case Actors.PLANNER:
  104. switch (state) {
  105. case ExecutionState.STEP_START:
  106. displayProgress = true;
  107. break;
  108. case ExecutionState.STEP_OK:
  109. skip = false;
  110. break;
  111. case ExecutionState.STEP_FAIL:
  112. skip = false;
  113. break;
  114. case ExecutionState.STEP_CANCEL:
  115. break;
  116. default:
  117. console.error('Invalid step state', state);
  118. return;
  119. }
  120. break;
  121. case Actors.NAVIGATOR:
  122. switch (state) {
  123. case ExecutionState.STEP_START:
  124. displayProgress = true;
  125. break;
  126. case ExecutionState.STEP_OK:
  127. displayProgress = false;
  128. break;
  129. case ExecutionState.STEP_FAIL:
  130. skip = false;
  131. displayProgress = false;
  132. break;
  133. case ExecutionState.STEP_CANCEL:
  134. displayProgress = false;
  135. break;
  136. case ExecutionState.ACT_START:
  137. if (content !== 'cache_content') {
  138. // skip to display caching content
  139. skip = false;
  140. }
  141. break;
  142. case ExecutionState.ACT_OK:
  143. skip = true;
  144. break;
  145. case ExecutionState.ACT_FAIL:
  146. skip = false;
  147. break;
  148. default:
  149. console.error('Invalid action', state);
  150. return;
  151. }
  152. break;
  153. case Actors.VALIDATOR:
  154. switch (state) {
  155. case ExecutionState.STEP_START:
  156. displayProgress = true;
  157. break;
  158. case ExecutionState.STEP_OK:
  159. skip = false;
  160. break;
  161. case ExecutionState.STEP_FAIL:
  162. skip = false;
  163. break;
  164. default:
  165. console.error('Invalid validation', state);
  166. return;
  167. }
  168. break;
  169. default:
  170. console.error('Unknown actor', actor);
  171. return;
  172. }
  173. if (!skip) {
  174. appendMessage({
  175. actor,
  176. content: content || '',
  177. timestamp: timestamp,
  178. });
  179. }
  180. if (displayProgress) {
  181. appendMessage({
  182. actor,
  183. content: 'Showing progress...',
  184. timestamp: timestamp,
  185. });
  186. }
  187. },
  188. [appendMessage],
  189. );
  190. // Stop heartbeat and close connection
  191. const stopConnection = useCallback(() => {
  192. if (heartbeatIntervalRef.current) {
  193. clearInterval(heartbeatIntervalRef.current);
  194. heartbeatIntervalRef.current = null;
  195. }
  196. if (portRef.current) {
  197. portRef.current.disconnect();
  198. portRef.current = null;
  199. }
  200. }, []);
  201. // Setup connection management
  202. const setupConnection = useCallback(() => {
  203. // Only setup if no existing connection
  204. if (portRef.current) {
  205. return;
  206. }
  207. try {
  208. portRef.current = chrome.runtime.connect({ name: 'side-panel-connection' });
  209. // biome-ignore lint/suspicious/noExplicitAny: <explanation>
  210. portRef.current.onMessage.addListener((message: any) => {
  211. // Add type checking for message
  212. if (message && message.type === EventType.EXECUTION) {
  213. handleTaskState(message);
  214. } else if (message && message.type === 'error') {
  215. // Handle error messages from service worker
  216. appendMessage({
  217. actor: Actors.SYSTEM,
  218. content: message.error || 'Unknown error occurred',
  219. timestamp: Date.now(),
  220. });
  221. setInputEnabled(true);
  222. setShowStopButton(false);
  223. } else if (message && message.type === 'heartbeat_ack') {
  224. console.log('Heartbeat acknowledged');
  225. }
  226. });
  227. portRef.current.onDisconnect.addListener(() => {
  228. const error = chrome.runtime.lastError;
  229. console.log('Connection disconnected', error ? `Error: ${error.message}` : '');
  230. portRef.current = null;
  231. if (heartbeatIntervalRef.current) {
  232. clearInterval(heartbeatIntervalRef.current);
  233. heartbeatIntervalRef.current = null;
  234. }
  235. setInputEnabled(true);
  236. setShowStopButton(false);
  237. });
  238. // Setup heartbeat interval
  239. if (heartbeatIntervalRef.current) {
  240. clearInterval(heartbeatIntervalRef.current);
  241. }
  242. heartbeatIntervalRef.current = window.setInterval(() => {
  243. if (portRef.current?.name === 'side-panel-connection') {
  244. try {
  245. portRef.current.postMessage({ type: 'heartbeat' });
  246. } catch (error) {
  247. console.error('Heartbeat failed:', error);
  248. stopConnection(); // Stop connection if heartbeat fails
  249. }
  250. } else {
  251. stopConnection(); // Stop if port is invalid
  252. }
  253. }, 25000);
  254. } catch (error) {
  255. console.error('Failed to establish connection:', error);
  256. appendMessage({
  257. actor: Actors.SYSTEM,
  258. content: 'Failed to connect to service worker',
  259. timestamp: Date.now(),
  260. });
  261. // Clear any references since connection failed
  262. portRef.current = null;
  263. }
  264. }, [handleTaskState, appendMessage, stopConnection]);
  265. // Add safety check for message sending
  266. const sendMessage = useCallback(
  267. // biome-ignore lint/suspicious/noExplicitAny: <explanation>
  268. (message: any) => {
  269. if (portRef.current?.name !== 'side-panel-connection') {
  270. throw new Error('No valid connection available');
  271. }
  272. try {
  273. portRef.current.postMessage(message);
  274. } catch (error) {
  275. console.error('Failed to send message:', error);
  276. stopConnection(); // Stop connection when message sending fails
  277. throw error;
  278. }
  279. },
  280. [stopConnection],
  281. );
  282. const handleSendMessage = async (text: string) => {
  283. console.log('handleSendMessage', text);
  284. if (!text.trim()) return;
  285. // Block sending messages in historical sessions
  286. if (isHistoricalSession) {
  287. console.log('Cannot send messages in historical sessions');
  288. return;
  289. }
  290. try {
  291. const tabs = await chrome.tabs.query({ active: true, currentWindow: true });
  292. const tabId = tabs[0]?.id;
  293. if (!tabId) {
  294. throw new Error('No active tab found');
  295. }
  296. setInputEnabled(false);
  297. setShowStopButton(true);
  298. // Create a new chat session for this task if not in follow-up mode
  299. if (!isFollowUpMode) {
  300. const newSession = await chatHistoryStore.createSession(
  301. text.substring(0, 50) + (text.length > 50 ? '...' : ''),
  302. );
  303. console.log('newSession', newSession);
  304. // Store the session ID in both state and ref
  305. const sessionId = newSession.id;
  306. setCurrentSessionId(sessionId);
  307. sessionIdRef.current = sessionId;
  308. }
  309. const userMessage = {
  310. actor: Actors.USER,
  311. content: text,
  312. timestamp: Date.now(),
  313. };
  314. // Pass the sessionId directly to appendMessage
  315. appendMessage(userMessage, sessionIdRef.current);
  316. // Setup connection if not exists
  317. if (!portRef.current) {
  318. setupConnection();
  319. }
  320. // Send message using the utility function
  321. if (isFollowUpMode) {
  322. // Send as follow-up task
  323. await sendMessage({
  324. type: 'follow_up_task',
  325. task: text,
  326. taskId: sessionIdRef.current,
  327. tabId,
  328. });
  329. console.log('follow_up_task sent', text, tabId, sessionIdRef.current);
  330. } else {
  331. // Send as new task
  332. await sendMessage({
  333. type: 'new_task',
  334. task: text,
  335. taskId: sessionIdRef.current,
  336. tabId,
  337. });
  338. console.log('new_task sent', text, tabId, sessionIdRef.current);
  339. }
  340. } catch (err) {
  341. const errorMessage = err instanceof Error ? err.message : String(err);
  342. console.error('Task error', errorMessage);
  343. appendMessage({
  344. actor: Actors.SYSTEM,
  345. content: errorMessage,
  346. timestamp: Date.now(),
  347. });
  348. setInputEnabled(true);
  349. setShowStopButton(false);
  350. stopConnection();
  351. }
  352. };
  353. const handleStopTask = async () => {
  354. try {
  355. portRef.current?.postMessage({
  356. type: 'cancel_task',
  357. });
  358. } catch (err) {
  359. const errorMessage = err instanceof Error ? err.message : String(err);
  360. console.error('cancel_task error', errorMessage);
  361. appendMessage({
  362. actor: Actors.SYSTEM,
  363. content: errorMessage,
  364. timestamp: Date.now(),
  365. });
  366. }
  367. setInputEnabled(true);
  368. setShowStopButton(false);
  369. };
  370. const handleNewChat = () => {
  371. // Clear messages and start a new chat
  372. setMessages([]);
  373. setCurrentSessionId(null);
  374. sessionIdRef.current = null;
  375. setInputEnabled(true);
  376. setShowStopButton(false);
  377. setIsFollowUpMode(false);
  378. setIsHistoricalSession(false);
  379. // Disconnect any existing connection
  380. stopConnection();
  381. };
  382. const loadChatSessions = useCallback(async () => {
  383. try {
  384. const sessions = await chatHistoryStore.getSessionsMetadata();
  385. setChatSessions(sessions.sort((a, b) => b.createdAt - a.createdAt));
  386. } catch (error) {
  387. console.error('Failed to load chat sessions:', error);
  388. }
  389. }, []);
  390. const handleLoadHistory = async () => {
  391. await loadChatSessions();
  392. setShowHistory(true);
  393. };
  394. const handleBackToChat = () => {
  395. setShowHistory(false);
  396. };
  397. const handleSessionSelect = async (sessionId: string) => {
  398. try {
  399. const fullSession = await chatHistoryStore.getSession(sessionId);
  400. if (fullSession && fullSession.messages.length > 0) {
  401. setCurrentSessionId(fullSession.id);
  402. setMessages(fullSession.messages);
  403. setIsFollowUpMode(false);
  404. setIsHistoricalSession(true); // Mark this as a historical session
  405. }
  406. setShowHistory(false);
  407. } catch (error) {
  408. console.error('Failed to load session:', error);
  409. }
  410. };
  411. const handleSessionDelete = async (sessionId: string) => {
  412. try {
  413. await chatHistoryStore.deleteSession(sessionId);
  414. await loadChatSessions();
  415. if (sessionId === currentSessionId) {
  416. setMessages([]);
  417. setCurrentSessionId(null);
  418. }
  419. } catch (error) {
  420. console.error('Failed to delete session:', error);
  421. }
  422. };
  423. const handleTemplateSelect = (content: string) => {
  424. console.log('handleTemplateSelect', content);
  425. if (setInputTextRef.current) {
  426. setInputTextRef.current(content);
  427. }
  428. };
  429. // Cleanup on unmount
  430. useEffect(() => {
  431. return () => {
  432. stopConnection();
  433. };
  434. }, [stopConnection]);
  435. // Scroll to bottom when new messages arrive
  436. // biome-ignore lint/correctness/useExhaustiveDependencies: <explanation>
  437. useEffect(() => {
  438. messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
  439. }, [messages]);
  440. return (
  441. <div>
  442. <div
  443. className={`flex h-screen flex-col ${isDarkMode ? 'bg-slate-900' : "bg-[url('/bg.jpg')] bg-cover bg-no-repeat"} overflow-hidden border ${isDarkMode ? 'border-sky-800' : 'border-[rgb(186,230,253)]'} rounded-2xl`}>
  444. <header className="header relative">
  445. <div className="header-logo">
  446. {showHistory ? (
  447. <button
  448. type="button"
  449. onClick={handleBackToChat}
  450. className={`${isDarkMode ? 'text-sky-400 hover:text-sky-300' : 'text-sky-400 hover:text-sky-500'} cursor-pointer`}
  451. aria-label="Back to chat">
  452. ← Back
  453. </button>
  454. ) : (
  455. <img src="/icon-128.png" alt="Extension Logo" className="size-6" />
  456. )}
  457. </div>
  458. <div className="header-icons">
  459. {!showHistory && (
  460. <>
  461. <button
  462. type="button"
  463. onClick={handleNewChat}
  464. onKeyDown={e => e.key === 'Enter' && handleNewChat()}
  465. className={`header-icon ${isDarkMode ? 'text-sky-400 hover:text-sky-300' : 'text-sky-400 hover:text-sky-500'} cursor-pointer`}
  466. aria-label="New Chat"
  467. tabIndex={0}>
  468. <PiPlusBold size={20} />
  469. </button>
  470. <button
  471. type="button"
  472. onClick={handleLoadHistory}
  473. onKeyDown={e => e.key === 'Enter' && handleLoadHistory()}
  474. className={`header-icon ${isDarkMode ? 'text-sky-400 hover:text-sky-300' : 'text-sky-400 hover:text-sky-500'} cursor-pointer`}
  475. aria-label="Load History"
  476. tabIndex={0}>
  477. <GrHistory size={20} />
  478. </button>
  479. </>
  480. )}
  481. <a
  482. href="https://discord.gg/NN3ABHggMK"
  483. target="_blank"
  484. rel="noopener noreferrer"
  485. className={`header-icon ${isDarkMode ? 'text-sky-400 hover:text-sky-300' : 'text-sky-400 hover:text-sky-500'}`}>
  486. <RxDiscordLogo size={20} />
  487. </a>
  488. <button
  489. type="button"
  490. onClick={() => chrome.runtime.openOptionsPage()}
  491. onKeyDown={e => e.key === 'Enter' && chrome.runtime.openOptionsPage()}
  492. className={`header-icon ${isDarkMode ? 'text-sky-400 hover:text-sky-300' : 'text-sky-400 hover:text-sky-500'} cursor-pointer`}
  493. aria-label="Settings"
  494. tabIndex={0}>
  495. <FiSettings size={20} />
  496. </button>
  497. </div>
  498. </header>
  499. {showHistory ? (
  500. <div className="flex-1 overflow-hidden">
  501. <ChatHistoryList
  502. sessions={chatSessions}
  503. onSessionSelect={handleSessionSelect}
  504. onSessionDelete={handleSessionDelete}
  505. visible={true}
  506. isDarkMode={isDarkMode}
  507. />
  508. </div>
  509. ) : (
  510. <>
  511. {messages.length === 0 && (
  512. <>
  513. <div
  514. className={`border-t ${isDarkMode ? 'border-sky-900' : 'border-sky-100'} mb-2 p-2 shadow-sm backdrop-blur-sm`}>
  515. <ChatInput
  516. onSendMessage={handleSendMessage}
  517. onStopTask={handleStopTask}
  518. disabled={!inputEnabled || isHistoricalSession}
  519. showStopButton={showStopButton}
  520. setContent={setter => {
  521. setInputTextRef.current = setter;
  522. }}
  523. isDarkMode={isDarkMode}
  524. />
  525. </div>
  526. <div>
  527. <TemplateList
  528. templates={defaultTemplates}
  529. onTemplateSelect={handleTemplateSelect}
  530. isDarkMode={isDarkMode}
  531. />
  532. </div>
  533. </>
  534. )}
  535. <div
  536. className={`scrollbar-gutter-stable flex-1 overflow-x-hidden overflow-y-scroll scroll-smooth p-2 ${isDarkMode ? 'bg-slate-900/80' : ''}`}>
  537. <MessageList messages={messages} isDarkMode={isDarkMode} />
  538. <div ref={messagesEndRef} />
  539. </div>
  540. {messages.length > 0 && (
  541. <div
  542. className={`border-t ${isDarkMode ? 'border-sky-900' : 'border-sky-100'} p-2 shadow-sm backdrop-blur-sm`}>
  543. <ChatInput
  544. onSendMessage={handleSendMessage}
  545. onStopTask={handleStopTask}
  546. disabled={!inputEnabled || isHistoricalSession}
  547. showStopButton={showStopButton}
  548. setContent={setter => {
  549. setInputTextRef.current = setter;
  550. }}
  551. isDarkMode={isDarkMode}
  552. />
  553. </div>
  554. )}
  555. </>
  556. )}
  557. </div>
  558. </div>
  559. );
  560. };
  561. export default SidePanel;