diff --git a/src/main/index.ts b/src/main/index.ts index 81a33cf..fafda8f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -481,12 +481,21 @@ function createWindow(): void { const ZOOM_OUT_KEYS = new Set(['-', '_']); mainWindow.webContents.on('before-input-event', (event, input) => { if (!mainWindow || mainWindow.isDestroyed()) return; + if (input.type !== 'keyDown') return; - // Prevent Electron's default Ctrl+R / Cmd+R page reload so the renderer - // keyboard handler can use it as "Refresh Session" (fixes #58). - // Also prevent Ctrl+Shift+R / Cmd+Shift+R (hard reload). - if ((input.control || input.meta) && input.key.toLowerCase() === 'r') { + // Intercept Ctrl+R / Cmd+R to prevent Chromium's built-in page reload, + // then notify the renderer via IPC so it can refresh the session (fixes #58, #85). + // We must preventDefault here because Chromium handles Ctrl+R at the browser + // engine level, which also blocks the keydown from reaching the renderer — + // hence the IPC bridge. + if ((input.control || input.meta) && !input.shift && input.key.toLowerCase() === 'r') { + event.preventDefault(); + mainWindow.webContents.send('session:refresh'); + return; + } + // Also block Ctrl+Shift+R (hard reload) + if ((input.control || input.meta) && input.shift && input.key.toLowerCase() === 'r') { event.preventDefault(); return; } diff --git a/src/preload/constants/ipcChannels.ts b/src/preload/constants/ipcChannels.ts index 408e904..68de22b 100644 --- a/src/preload/constants/ipcChannels.ts +++ b/src/preload/constants/ipcChannels.ts @@ -174,3 +174,6 @@ export const WINDOW_IS_MAXIMIZED = 'window:isMaximized'; /** Relaunch the application */ export const APP_RELAUNCH = 'app:relaunch'; + +/** Refresh session shortcut (main → renderer, triggered by Ctrl+R / Cmd+R) */ +export const SESSION_REFRESH = 'session:refresh'; diff --git a/src/preload/index.ts b/src/preload/index.ts index e9c32ae..e5d7064 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -10,6 +10,7 @@ import { HTTP_SERVER_GET_STATUS, HTTP_SERVER_START, HTTP_SERVER_STOP, + SESSION_REFRESH, SSH_CONNECT, SSH_DISCONNECT, SSH_GET_CONFIG_HOSTS, @@ -347,6 +348,15 @@ const electronAPI: ElectronAPI = { }; }, + // Session refresh event (Ctrl+R / Cmd+R intercepted by main process) + onSessionRefresh: (callback: () => void): (() => void) => { + const listener = (): void => callback(); + ipcRenderer.on(SESSION_REFRESH, listener); + return (): void => { + ipcRenderer.removeListener(SESSION_REFRESH, listener); + }; + }, + // Shell operations openPath: (targetPath: string, projectRoot?: string) => ipcRenderer.invoke('shell:openPath', targetPath, projectRoot), diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index 9686867..4ce15d6 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -490,6 +490,11 @@ export class HttpAPIClient implements ElectronAPI { onTodoChange = (callback: (event: FileChangeEvent) => void): (() => void) => this.addEventListener('todo-change', callback); + // No-op in browser mode — Ctrl+R refresh is Electron-only + onSessionRefresh = (_callback: () => void): (() => void) => { + return () => {}; + }; + // --------------------------------------------------------------------------- // Shell operations (browser fallbacks) // --------------------------------------------------------------------------- diff --git a/src/renderer/components/chat/ChatHistory.tsx b/src/renderer/components/chat/ChatHistory.tsx index 867df2a..c25983c 100644 --- a/src/renderer/components/chat/ChatHistory.tsx +++ b/src/renderer/components/chat/ChatHistory.tsx @@ -377,6 +377,15 @@ export const ChatHistory = ({ tabId }: ChatHistoryProps): JSX.Element => { checkScrollButton(); }, [conversation, checkScrollButton]); + // Listen for session-refresh-scroll-bottom events (from Ctrl+R / refresh button) + useEffect(() => { + const handler = (): void => { + scrollToBottom('smooth'); + }; + window.addEventListener('session-refresh-scroll-bottom', handler); + return () => window.removeEventListener('session-refresh-scroll-bottom', handler); + }, [scrollToBottom]); + // Callback to register AI group refs (combines with visibility hook) const registerAIGroupRefCombined = useCallback( (groupId: string) => { diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index 670b405..b97773b 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -32,6 +32,7 @@ import { highlightSearchInChildren, type SearchContext, } from '../searchHighlightUtils'; +import { highlightLine } from '../viewers/syntaxHighlighter'; // ============================================================================= // Types @@ -154,9 +155,18 @@ function createViewerMarkdownComponents(searchCtx: SearchContext | null): Compon const isBlock = (hasLanguage ?? false) || isMultiLine; if (isBlock) { + const lang = codeClassName?.replace('language-', '') ?? ''; + const raw = typeof children === 'string' ? children : ''; + const text = raw.replace(/\n$/, ''); + const lines = text.split('\n'); return ( - {hl(children)} + {lines.map((line, i) => ( + + {hl(highlightLine(line, lang))} + {i < lines.length - 1 ? '\n' : null} + + ))} ); } diff --git a/src/renderer/components/chat/viewers/syntaxHighlighter.ts b/src/renderer/components/chat/viewers/syntaxHighlighter.ts index 2271d08..e6e2f6b 100644 --- a/src/renderer/components/chat/viewers/syntaxHighlighter.ts +++ b/src/renderer/components/chat/viewers/syntaxHighlighter.ts @@ -208,6 +208,200 @@ const KEYWORDS: Record> = { 'true', 'false', ]), + r: new Set([ + 'if', + 'else', + 'for', + 'while', + 'repeat', + 'function', + 'return', + 'next', + 'break', + 'in', + 'library', + 'require', + 'source', + 'TRUE', + 'FALSE', + 'NULL', + 'NA', + 'Inf', + 'NaN', + 'NA_integer_', + 'NA_real_', + 'NA_complex_', + 'NA_character_', + ]), + ruby: new Set([ + 'def', + 'class', + 'module', + 'end', + 'do', + 'if', + 'elsif', + 'else', + 'unless', + 'while', + 'until', + 'for', + 'in', + 'begin', + 'rescue', + 'ensure', + 'raise', + 'return', + 'yield', + 'block_given?', + 'require', + 'require_relative', + 'include', + 'extend', + 'attr_accessor', + 'attr_reader', + 'attr_writer', + 'self', + 'super', + 'nil', + 'true', + 'false', + 'and', + 'or', + 'not', + 'then', + 'when', + 'case', + 'lambda', + 'proc', + 'puts', + 'print', + ]), + php: new Set([ + 'function', + 'class', + 'interface', + 'trait', + 'extends', + 'implements', + 'namespace', + 'use', + 'public', + 'private', + 'protected', + 'static', + 'abstract', + 'final', + 'const', + 'var', + 'new', + 'return', + 'if', + 'elseif', + 'else', + 'for', + 'foreach', + 'while', + 'do', + 'switch', + 'case', + 'break', + 'continue', + 'default', + 'try', + 'catch', + 'finally', + 'throw', + 'as', + 'echo', + 'print', + 'require', + 'require_once', + 'include', + 'include_once', + 'true', + 'false', + 'null', + 'array', + 'isset', + 'unset', + 'empty', + 'self', + 'this', + ]), + sql: new Set([ + 'SELECT', + 'FROM', + 'WHERE', + 'INSERT', + 'INTO', + 'UPDATE', + 'SET', + 'DELETE', + 'CREATE', + 'ALTER', + 'DROP', + 'TABLE', + 'INDEX', + 'VIEW', + 'DATABASE', + 'JOIN', + 'INNER', + 'LEFT', + 'RIGHT', + 'OUTER', + 'FULL', + 'CROSS', + 'ON', + 'AND', + 'OR', + 'NOT', + 'IN', + 'EXISTS', + 'BETWEEN', + 'LIKE', + 'IS', + 'NULL', + 'AS', + 'ORDER', + 'BY', + 'GROUP', + 'HAVING', + 'LIMIT', + 'OFFSET', + 'UNION', + 'ALL', + 'DISTINCT', + 'COUNT', + 'SUM', + 'AVG', + 'MIN', + 'MAX', + 'CASE', + 'WHEN', + 'THEN', + 'ELSE', + 'END', + 'BEGIN', + 'COMMIT', + 'ROLLBACK', + 'TRANSACTION', + 'PRIMARY', + 'KEY', + 'FOREIGN', + 'REFERENCES', + 'CONSTRAINT', + 'DEFAULT', + 'VALUES', + 'TRUE', + 'FALSE', + 'INTEGER', + 'VARCHAR', + 'TEXT', + 'BOOLEAN', + 'DATE', + 'TIMESTAMP', + ]), }; // Extend tsx/jsx to use typescript/javascript keywords @@ -296,8 +490,23 @@ export function highlightLine(line: string, language: string): React.ReactNode[] break; } - // Check for comment (# style for Python/Shell) - if ((language === 'python' || language === 'bash') && remaining.startsWith('#')) { + // Check for comment (# style for Python/Shell/R/Ruby/PHP) + if ( + (language === 'python' || language === 'bash' || language === 'r' || language === 'ruby' || language === 'php') && + remaining.startsWith('#') + ) { + segments.push( + React.createElement( + 'span', + { key: currentPos, style: { color: 'var(--syntax-comment)', fontStyle: 'italic' } }, + remaining + ) + ); + break; + } + + // Check for comment (-- style for SQL) + if (language === 'sql' && remaining.startsWith('--')) { segments.push( React.createElement( 'span', @@ -326,7 +535,8 @@ export function highlightLine(line: string, language: string): React.ReactNode[] const wordMatch = /^([a-zA-Z_$][a-zA-Z0-9_$]*)/.exec(remaining); if (wordMatch) { const word = wordMatch[1]; - if (keywords.has(word)) { + // SQL keywords are case-insensitive + if (keywords.has(word) || (language === 'sql' && keywords.has(word.toUpperCase()))) { segments.push( React.createElement( 'span', diff --git a/src/renderer/components/layout/TabBar.tsx b/src/renderer/components/layout/TabBar.tsx index 8ce99f6..09ae142 100644 --- a/src/renderer/components/layout/TabBar.tsx +++ b/src/renderer/components/layout/TabBar.tsx @@ -39,7 +39,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { setSelectedTabIds, clearTabSelection, openDashboard, - fetchSessionDetail, + refreshSessionInPlace, fetchSessions, unreadCount, openNotificationsTab, @@ -64,7 +64,7 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { setSelectedTabIds: s.setSelectedTabIds, clearTabSelection: s.clearTabSelection, openDashboard: s.openDashboard, - fetchSessionDetail: s.fetchSessionDetail, + refreshSessionInPlace: s.refreshSessionInPlace, fetchSessions: s.fetchSessions, unreadCount: s.unreadCount, openNotificationsTab: s.openNotificationsTab, @@ -215,9 +215,10 @@ export const TabBar = ({ paneId }: TabBarProps): React.JSX.Element => { const handleRefresh = async (): Promise => { if (activeTab?.type === 'session' && activeTab.projectId && activeTab.sessionId) { await Promise.all([ - fetchSessionDetail(activeTab.projectId, activeTab.sessionId, activeTabId ?? undefined), + refreshSessionInPlace(activeTab.projectId, activeTab.sessionId), fetchSessions(activeTab.projectId), ]); + window.dispatchEvent(new CustomEvent('session-refresh-scroll-bottom')); } }; diff --git a/src/renderer/hooks/useKeyboardShortcuts.ts b/src/renderer/hooks/useKeyboardShortcuts.ts index 3706c89..73c8999 100644 --- a/src/renderer/hooks/useKeyboardShortcuts.ts +++ b/src/renderer/hooks/useKeyboardShortcuts.ts @@ -29,7 +29,7 @@ export function useKeyboardShortcuts(): void { getActiveTab, selectedProjectId, selectedSessionId, - fetchSessionDetail, + refreshSessionInPlace, fetchSessions, openCommandPalette, openSettingsTab, @@ -56,7 +56,7 @@ export function useKeyboardShortcuts(): void { getActiveTab: s.getActiveTab, selectedProjectId: s.selectedProjectId, selectedSessionId: s.selectedSessionId, - fetchSessionDetail: s.fetchSessionDetail, + refreshSessionInPlace: s.refreshSessionInPlace, fetchSessions: s.fetchSessions, openCommandPalette: s.openCommandPalette, openSettingsTab: s.openSettingsTab, @@ -261,9 +261,11 @@ export function useKeyboardShortcuts(): void { event.preventDefault(); if (selectedProjectId && selectedSessionId) { void Promise.all([ - fetchSessionDetail(selectedProjectId, selectedSessionId), + refreshSessionInPlace(selectedProjectId, selectedSessionId), fetchSessions(selectedProjectId), - ]); + ]).then(() => { + window.dispatchEvent(new CustomEvent('session-refresh-scroll-bottom')); + }); } return; } @@ -290,7 +292,7 @@ export function useKeyboardShortcuts(): void { getActiveTab, selectedProjectId, selectedSessionId, - fetchSessionDetail, + refreshSessionInPlace, fetchSessions, openCommandPalette, openSettingsTab, diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 0111428..c302294 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -268,6 +268,26 @@ export function initializeNotificationListeners(): () => void { } } + // Listen for Ctrl+R / Cmd+R session refresh from main process (fixes #85) + if (api.onSessionRefresh) { + const cleanup = api.onSessionRefresh(() => { + const state = useStore.getState(); + const activeTabId = state.activeTabId; + const activeTab = activeTabId ? state.openTabs.find((t) => t.id === activeTabId) : null; + if (activeTab?.type === 'session' && activeTab.projectId && activeTab.sessionId) { + void Promise.all([ + state.refreshSessionInPlace(activeTab.projectId, activeTab.sessionId), + state.fetchSessions(activeTab.projectId), + ]).then(() => { + window.dispatchEvent(new CustomEvent('session-refresh-scroll-bottom')); + }); + } + }); + if (typeof cleanup === 'function') { + cleanupFns.push(cleanup); + } + } + // Listen for updater status events from main process if (api.updater?.onStatus) { const cleanup = api.updater.onStatus((_event: unknown, status: unknown) => { diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index b318daa..9822c14 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -395,6 +395,9 @@ export interface ElectronAPI { onFileChange: (callback: (event: FileChangeEvent) => void) => () => void; onTodoChange: (callback: (event: FileChangeEvent) => void) => () => void; + // Session refresh (Ctrl+R / Cmd+R intercepted by main process) + onSessionRefresh: (callback: () => void) => () => void; + // Shell operations openPath: ( targetPath: string,