diff --git a/src/main/index.ts b/src/main/index.ts index 81a33cfd..fafda8fe 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 408e9041..68de22b3 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 e9c32ae6..e5d70646 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 96868677..4ce15d64 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 867df2aa..c25983c6 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/items/LinkedToolItem.tsx b/src/renderer/components/chat/items/LinkedToolItem.tsx index c11798d1..5ad9dae9 100644 --- a/src/renderer/components/chat/items/LinkedToolItem.tsx +++ b/src/renderer/components/chat/items/LinkedToolItem.tsx @@ -14,6 +14,7 @@ import { getToolContextTokens, getToolStatus, getToolSummary, + hasBashContent, hasEditContent, hasReadContent, hasSkillInstructions, @@ -31,6 +32,7 @@ import { Wrench } from 'lucide-react'; import { BaseItem, StatusDot } from './BaseItem'; import { formatDuration } from './baseItemHelpers'; import { + BashToolViewer, DefaultToolViewer, EditToolViewer, ReadToolViewer, @@ -39,6 +41,7 @@ import { WriteToolViewer, } from './linkedTool'; +import type { StepVariant } from '@renderer/constants/stepVariants'; import type { LinkedToolItem as LinkedToolItemType } from '@renderer/types/groups'; interface LinkedToolItemProps { @@ -139,7 +142,12 @@ export const LinkedToolItem: React.FC = ({ const useWriteViewer = linkedTool.name === 'Write' && hasWriteContent(linkedTool) && !linkedTool.result?.isError; const useSkillViewer = linkedTool.name === 'Skill' && hasSkillInstructions(linkedTool); - const useDefaultViewer = !useReadViewer && !useEditViewer && !useWriteViewer && !useSkillViewer; + const useBashViewer = linkedTool.name === 'Bash' && hasBashContent(linkedTool); + const useDefaultViewer = + !useReadViewer && !useEditViewer && !useWriteViewer && !useSkillViewer && !useBashViewer; + + // Determine step variant for colored borders/icons + const toolVariant: StepVariant = status === 'error' ? 'tool-error' : 'tool'; // Check if we should show error display for Read/Write tools const showReadError = linkedTool.name === 'Read' && linkedTool.result?.isError; @@ -164,6 +172,7 @@ export const LinkedToolItem: React.FC = ({ highlightClasses={highlightClasses} highlightStyle={highlightStyle} notificationDotColor={notificationDotColor} + variant={toolVariant} > {/* Read tool with CodeBlockViewer */} {useReadViewer && } @@ -177,6 +186,9 @@ export const LinkedToolItem: React.FC = ({ {/* Skill tool with instructions */} {useSkillViewer && } + {/* Bash tool with syntax-highlighted command */} + {useBashViewer && } + {/* Default rendering for other tools */} {useDefaultViewer && } diff --git a/src/renderer/components/chat/items/linkedTool/BashToolViewer.tsx b/src/renderer/components/chat/items/linkedTool/BashToolViewer.tsx new file mode 100644 index 00000000..dd8f65cb --- /dev/null +++ b/src/renderer/components/chat/items/linkedTool/BashToolViewer.tsx @@ -0,0 +1,52 @@ +/** + * BashToolViewer + * + * Renders Bash tool calls with syntax-highlighted command input + * via CodeBlockViewer and collapsible output section. + */ + +import React from 'react'; + +import { CodeBlockViewer } from '@renderer/components/chat/viewers'; + +import { type ItemStatus } from '../BaseItem'; + +import { CollapsibleOutputSection } from './CollapsibleOutputSection'; +import { renderOutput } from './renderHelpers'; + +import type { LinkedToolItem } from '@renderer/types/groups'; + +interface BashToolViewerProps { + linkedTool: LinkedToolItem; + status: ItemStatus; +} + +export const BashToolViewer: React.FC = ({ linkedTool, status }) => { + const command = linkedTool.input.command as string; + const description = linkedTool.input.description as string | undefined; + + // Use the description (truncated) as the file name label, or fallback to "bash" + const fileName = description + ? description.length > 60 + ? description.slice(0, 57) + '...' + : description + : 'bash'; + + return ( + <> + {/* Input Section — Syntax-highlighted command */} + + + {/* Output Section — Collapsible */} + {!linkedTool.isOrphaned && linkedTool.result && ( + + {renderOutput(linkedTool.result.content)} + + )} + + ); +}; diff --git a/src/renderer/components/chat/items/linkedTool/CollapsibleOutputSection.tsx b/src/renderer/components/chat/items/linkedTool/CollapsibleOutputSection.tsx new file mode 100644 index 00000000..de6fb699 --- /dev/null +++ b/src/renderer/components/chat/items/linkedTool/CollapsibleOutputSection.tsx @@ -0,0 +1,57 @@ +/** + * CollapsibleOutputSection + * + * Reusable component that wraps tool output in a collapsed-by-default section. + * Shows a clickable header with label, StatusDot, and chevron toggle. + */ + +import React, { useState } from 'react'; + +import { ChevronDown, ChevronRight } from 'lucide-react'; + +import { type ItemStatus, StatusDot } from '../BaseItem'; + +interface CollapsibleOutputSectionProps { + status: ItemStatus; + children: React.ReactNode; + /** Label shown in the header (default: "Output") */ + label?: string; +} + +export const CollapsibleOutputSection: React.FC = ({ + status, + children, + label = 'Output', +}) => { + const [isExpanded, setIsExpanded] = useState(false); + + return ( +
+ + {isExpanded && ( +
+ {children} +
+ )} +
+ ); +}; diff --git a/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx b/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx index 1be3f906..c0a06fcf 100644 --- a/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx +++ b/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx @@ -6,8 +6,9 @@ import React from 'react'; -import { type ItemStatus, StatusDot } from '../BaseItem'; +import { type ItemStatus } from '../BaseItem'; +import { CollapsibleOutputSection } from './CollapsibleOutputSection'; import { renderInput, renderOutput } from './renderHelpers'; import type { LinkedToolItem } from '@renderer/types/groups'; @@ -37,30 +38,11 @@ export const DefaultToolViewer: React.FC = ({ linkedTool - {/* Output Section */} + {/* Output Section — Collapsed by default */} {!linkedTool.isOrphaned && linkedTool.result && ( -
-
- Output - -
-
- {renderOutput(linkedTool.result.content)} -
-
+ + {renderOutput(linkedTool.result.content)} + )} ); diff --git a/src/renderer/components/chat/items/linkedTool/ReadToolViewer.tsx b/src/renderer/components/chat/items/linkedTool/ReadToolViewer.tsx index f0eeb688..4edd8c93 100644 --- a/src/renderer/components/chat/items/linkedTool/ReadToolViewer.tsx +++ b/src/renderer/components/chat/items/linkedTool/ReadToolViewer.tsx @@ -6,7 +6,7 @@ import React from 'react'; -import { CodeBlockViewer } from '@renderer/components/chat/viewers'; +import { CodeBlockViewer, MarkdownViewer } from '@renderer/components/chat/viewers'; import type { LinkedToolItem } from '@renderer/types/groups'; @@ -54,12 +54,49 @@ export const ReadToolViewer: React.FC = ({ linkedTool }) => ? startLine + limit - 1 : undefined; + const isMarkdownFile = /\.mdx?$/i.test(filePath); + const [viewMode, setViewMode] = React.useState<'code' | 'preview'>(isMarkdownFile ? 'preview' : 'code'); + return ( - +
+ {isMarkdownFile && ( +
+ + +
+ )} + {isMarkdownFile && viewMode === 'preview' ? ( + + ) : ( + + )} +
); }; diff --git a/src/renderer/components/chat/items/linkedTool/WriteToolViewer.tsx b/src/renderer/components/chat/items/linkedTool/WriteToolViewer.tsx index d08d7005..14fba8aa 100644 --- a/src/renderer/components/chat/items/linkedTool/WriteToolViewer.tsx +++ b/src/renderer/components/chat/items/linkedTool/WriteToolViewer.tsx @@ -21,7 +21,7 @@ export const WriteToolViewer: React.FC = ({ linkedTool }) const content = (toolUseResult?.content as string) || (linkedTool.input.content as string) || ''; const isCreate = toolUseResult?.type === 'create'; const isMarkdownFile = /\.mdx?$/i.test(filePath); - const [viewMode, setViewMode] = React.useState<'code' | 'preview'>('code'); + const [viewMode, setViewMode] = React.useState<'code' | 'preview'>(isMarkdownFile ? 'preview' : 'code'); return (
diff --git a/src/renderer/components/chat/items/linkedTool/index.ts b/src/renderer/components/chat/items/linkedTool/index.ts index 5c415dac..92ff5fe5 100644 --- a/src/renderer/components/chat/items/linkedTool/index.ts +++ b/src/renderer/components/chat/items/linkedTool/index.ts @@ -4,6 +4,8 @@ * Exports all specialized tool viewer components. */ +export { BashToolViewer } from './BashToolViewer'; +export { CollapsibleOutputSection } from './CollapsibleOutputSection'; export { DefaultToolViewer } from './DefaultToolViewer'; export { EditToolViewer } from './EditToolViewer'; export { ReadToolViewer } from './ReadToolViewer'; diff --git a/src/renderer/components/chat/viewers/syntaxHighlighter.ts b/src/renderer/components/chat/viewers/syntaxHighlighter.ts index e6e2f6b3..101307fc 100644 --- a/src/renderer/components/chat/viewers/syntaxHighlighter.ts +++ b/src/renderer/components/chat/viewers/syntaxHighlighter.ts @@ -402,12 +402,443 @@ const KEYWORDS: Record> = { 'DATE', 'TIMESTAMP', ]), + bash: new Set([ + 'if', + 'then', + 'else', + 'elif', + 'fi', + 'for', + 'while', + 'do', + 'done', + 'case', + 'esac', + 'in', + 'function', + 'return', + 'local', + 'export', + 'readonly', + 'declare', + 'typeset', + 'unset', + 'shift', + 'source', + 'eval', + 'exec', + 'exit', + 'trap', + 'break', + 'continue', + 'echo', + 'printf', + 'read', + 'test', + 'true', + 'false', + 'cd', + 'pwd', + 'mkdir', + 'rm', + 'cp', + 'mv', + 'ls', + 'cat', + 'grep', + 'sed', + 'awk', + 'find', + 'sort', + 'uniq', + 'wc', + 'head', + 'tail', + 'chmod', + 'chown', + 'sudo', + 'apt', + 'pip', + 'npm', + 'pnpm', + 'yarn', + 'git', + 'docker', + 'curl', + 'wget', + ]), + c: new Set([ + 'auto', + 'break', + 'case', + 'char', + 'const', + 'continue', + 'default', + 'do', + 'double', + 'else', + 'enum', + 'extern', + 'float', + 'for', + 'goto', + 'if', + 'inline', + 'int', + 'long', + 'register', + 'return', + 'short', + 'signed', + 'sizeof', + 'static', + 'struct', + 'switch', + 'typedef', + 'union', + 'unsigned', + 'void', + 'volatile', + 'while', + 'NULL', + 'true', + 'false', + 'include', + 'define', + 'ifdef', + 'ifndef', + 'endif', + 'pragma', + ]), + java: new Set([ + 'abstract', + 'assert', + 'boolean', + 'break', + 'byte', + 'case', + 'catch', + 'char', + 'class', + 'const', + 'continue', + 'default', + 'do', + 'double', + 'else', + 'enum', + 'extends', + 'final', + 'finally', + 'float', + 'for', + 'if', + 'implements', + 'import', + 'instanceof', + 'int', + 'interface', + 'long', + 'native', + 'new', + 'package', + 'private', + 'protected', + 'public', + 'return', + 'short', + 'static', + 'strictfp', + 'super', + 'switch', + 'synchronized', + 'this', + 'throw', + 'throws', + 'transient', + 'try', + 'void', + 'volatile', + 'while', + 'true', + 'false', + 'null', + 'var', + 'yield', + 'record', + 'sealed', + 'permits', + ]), + kotlin: new Set([ + 'abstract', + 'annotation', + 'as', + 'break', + 'by', + 'catch', + 'class', + 'companion', + 'const', + 'constructor', + 'continue', + 'crossinline', + 'data', + 'do', + 'else', + 'enum', + 'external', + 'false', + 'final', + 'finally', + 'for', + 'fun', + 'if', + 'import', + 'in', + 'infix', + 'init', + 'inline', + 'inner', + 'interface', + 'internal', + 'is', + 'lateinit', + 'noinline', + 'null', + 'object', + 'open', + 'operator', + 'out', + 'override', + 'package', + 'private', + 'protected', + 'public', + 'reified', + 'return', + 'sealed', + 'super', + 'suspend', + 'this', + 'throw', + 'true', + 'try', + 'typealias', + 'val', + 'var', + 'vararg', + 'when', + 'where', + 'while', + ]), + swift: new Set([ + 'associatedtype', + 'break', + 'case', + 'catch', + 'class', + 'continue', + 'default', + 'defer', + 'deinit', + 'do', + 'else', + 'enum', + 'extension', + 'fallthrough', + 'false', + 'fileprivate', + 'for', + 'func', + 'guard', + 'if', + 'import', + 'in', + 'init', + 'inout', + 'internal', + 'is', + 'let', + 'nil', + 'open', + 'operator', + 'override', + 'private', + 'protocol', + 'public', + 'repeat', + 'rethrows', + 'return', + 'self', + 'static', + 'struct', + 'subscript', + 'super', + 'switch', + 'throw', + 'throws', + 'true', + 'try', + 'typealias', + 'var', + 'where', + 'while', + 'async', + 'await', + ]), + lua: new Set([ + 'and', + 'break', + 'do', + 'else', + 'elseif', + 'end', + 'false', + 'for', + 'function', + 'goto', + 'if', + 'in', + 'local', + 'nil', + 'not', + 'or', + 'repeat', + 'return', + 'then', + 'true', + 'until', + 'while', + 'self', + 'require', + 'print', + 'type', + 'tostring', + 'tonumber', + 'pairs', + 'ipairs', + 'error', + 'pcall', + 'xpcall', + 'setmetatable', + 'getmetatable', + ]), + html: new Set([ + 'div', + 'span', + 'html', + 'head', + 'body', + 'title', + 'meta', + 'link', + 'script', + 'style', + 'section', + 'article', + 'header', + 'footer', + 'nav', + 'main', + 'aside', + 'form', + 'input', + 'button', + 'select', + 'option', + 'textarea', + 'label', + 'table', + 'thead', + 'tbody', + 'tr', + 'th', + 'td', + 'ul', + 'ol', + 'li', + 'a', + 'p', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'img', + 'video', + 'audio', + 'canvas', + 'svg', + 'class', + 'id', + 'src', + 'href', + 'type', + 'name', + 'value', + 'placeholder', + 'alt', + 'width', + 'height', + 'true', + 'false', + ]), + yaml: new Set([ + 'true', + 'false', + 'null', + 'yes', + 'no', + 'on', + 'off', + ]), }; // Extend tsx/jsx to use typescript/javascript keywords KEYWORDS.tsx = KEYWORDS.typescript; KEYWORDS.jsx = KEYWORDS.javascript; +// Extend zsh/fish to use bash keywords +KEYWORDS.zsh = KEYWORDS.bash; +KEYWORDS.fish = KEYWORDS.bash; + +// Extend cpp/hpp to use c keywords (superset) +KEYWORDS.cpp = new Set([...KEYWORDS.c, ...[ + 'class', + 'namespace', + 'template', + 'typename', + 'public', + 'private', + 'protected', + 'virtual', + 'override', + 'final', + 'new', + 'delete', + 'try', + 'catch', + 'throw', + 'noexcept', + 'constexpr', + 'decltype', + 'nullptr', + 'this', + 'using', + 'friend', + 'operator', + 'dynamic_cast', + 'static_cast', + 'reinterpret_cast', + 'const_cast', + 'bool', + 'wchar_t', + 'auto', +]]); +KEYWORDS.hpp = KEYWORDS.cpp; + /** * Very basic tokenization for syntax highlighting. * This is a simple approach without a full parser. @@ -416,7 +847,7 @@ export function highlightLine(line: string, language: string): React.ReactNode[] const keywords = KEYWORDS[language] || new Set(); // If no highlighting support, return plain text as single-element array - if (keywords.size === 0 && !['json', 'css', 'html', 'bash', 'markdown'].includes(language)) { + if (keywords.size === 0 && !['json', 'css', 'bash'].includes(language)) { return [line]; } @@ -490,9 +921,10 @@ export function highlightLine(line: string, language: string): React.ReactNode[] break; } - // Check for comment (# style for Python/Shell/R/Ruby/PHP) + // Check for comment (# style for Python/Shell/R/Ruby/PHP/YAML) if ( - (language === 'python' || language === 'bash' || language === 'r' || language === 'ruby' || language === 'php') && + (language === 'python' || language === 'bash' || language === 'zsh' || language === 'fish' || + language === 'r' || language === 'ruby' || language === 'php' || language === 'yaml') && remaining.startsWith('#') ) { segments.push( @@ -505,8 +937,8 @@ export function highlightLine(line: string, language: string): React.ReactNode[] break; } - // Check for comment (-- style for SQL) - if (language === 'sql' && remaining.startsWith('--')) { + // Check for comment (-- style for SQL/Lua) + if ((language === 'sql' || language === 'lua') && remaining.startsWith('--')) { segments.push( React.createElement( 'span', diff --git a/src/renderer/components/layout/TabBar.tsx b/src/renderer/components/layout/TabBar.tsx index 8ce99f6f..09ae1420 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 3706c897..73c89993 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 01114285..c3022949 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/renderer/store/slices/tabSlice.ts b/src/renderer/store/slices/tabSlice.ts index f0d4f3af..1a86cfba 100644 --- a/src/renderer/store/slices/tabSlice.ts +++ b/src/renderer/store/slices/tabSlice.ts @@ -264,7 +264,6 @@ export const createTabSlice: StateCreator = (set, ge if (tab.type === 'session' && tab.sessionId && tab.projectId) { const sessionId = tab.sessionId; const projectId = tab.projectId; - const sessionChanged = state.selectedSessionId !== sessionId; // Check if per-tab data is already cached const cachedTabData = state.tabSessionData[tabId]; @@ -297,24 +296,24 @@ export const createTabSlice: StateCreator = (set, ge if (worktreeChanged) { void get().fetchSessionsInitial(foundWorktree); } - if (sessionChanged) { - if (hasCachedData) { - // Swap global state from per-tab cache (no re-fetch) - set({ - sessionDetail: cachedTabData.sessionDetail, - conversation: cachedTabData.conversation, - conversationLoading: false, - sessionDetailLoading: false, - sessionDetailError: null, - sessionClaudeMdStats: cachedTabData.sessionClaudeMdStats, - sessionContextStats: cachedTabData.sessionContextStats, - sessionPhaseInfo: cachedTabData.sessionPhaseInfo, - visibleAIGroupId: cachedTabData.visibleAIGroupId, - selectedAIGroup: cachedTabData.selectedAIGroup, - }); - } else { - void get().fetchSessionDetail(foundWorktree, sessionId, tabId); - } + // Always swap conversation to match this tab — the global conversation + // may belong to a different tab even when selectedSessionId matches. + if (hasCachedData) { + // Swap global state from per-tab cache (no re-fetch) + set({ + sessionDetail: cachedTabData.sessionDetail, + conversation: cachedTabData.conversation, + conversationLoading: false, + sessionDetailLoading: false, + sessionDetailError: null, + sessionClaudeMdStats: cachedTabData.sessionClaudeMdStats, + sessionContextStats: cachedTabData.sessionContextStats, + sessionPhaseInfo: cachedTabData.sessionPhaseInfo, + visibleAIGroupId: cachedTabData.visibleAIGroupId, + selectedAIGroup: cachedTabData.selectedAIGroup, + }); + } else { + void get().fetchSessionDetail(foundWorktree, sessionId, tabId); } return; } @@ -333,24 +332,24 @@ export const createTabSlice: StateCreator = (set, ge if (projectChanged) { void get().fetchSessionsInitial(project.id); } - if (sessionChanged) { - if (hasCachedData) { - // Swap global state from per-tab cache (no re-fetch) - set({ - sessionDetail: cachedTabData.sessionDetail, - conversation: cachedTabData.conversation, - conversationLoading: false, - sessionDetailLoading: false, - sessionDetailError: null, - sessionClaudeMdStats: cachedTabData.sessionClaudeMdStats, - sessionContextStats: cachedTabData.sessionContextStats, - sessionPhaseInfo: cachedTabData.sessionPhaseInfo, - visibleAIGroupId: cachedTabData.visibleAIGroupId, - selectedAIGroup: cachedTabData.selectedAIGroup, - }); - } else { - void get().fetchSessionDetail(project.id, sessionId, tabId); - } + // Always swap conversation to match this tab — the global conversation + // may belong to a different tab even when selectedSessionId matches. + if (hasCachedData) { + // Swap global state from per-tab cache (no re-fetch) + set({ + sessionDetail: cachedTabData.sessionDetail, + conversation: cachedTabData.conversation, + conversationLoading: false, + sessionDetailLoading: false, + sessionDetailError: null, + sessionClaudeMdStats: cachedTabData.sessionClaudeMdStats, + sessionContextStats: cachedTabData.sessionContextStats, + sessionPhaseInfo: cachedTabData.sessionPhaseInfo, + visibleAIGroupId: cachedTabData.visibleAIGroupId, + selectedAIGroup: cachedTabData.selectedAIGroup, + }); + } else { + void get().fetchSessionDetail(project.id, sessionId, tabId); } return; } diff --git a/src/renderer/utils/toolRendering/index.ts b/src/renderer/utils/toolRendering/index.ts index b2936950..18eb6bc7 100644 --- a/src/renderer/utils/toolRendering/index.ts +++ b/src/renderer/utils/toolRendering/index.ts @@ -5,6 +5,7 @@ */ export { + hasBashContent, hasEditContent, hasReadContent, hasSkillInstructions, diff --git a/src/renderer/utils/toolRendering/toolContentChecks.ts b/src/renderer/utils/toolRendering/toolContentChecks.ts index 090cf546..e101159f 100644 --- a/src/renderer/utils/toolRendering/toolContentChecks.ts +++ b/src/renderer/utils/toolRendering/toolContentChecks.ts @@ -56,3 +56,10 @@ export function hasWriteContent(linkedTool: LinkedToolItem): boolean { return false; } + +/** + * Checks if a Bash tool has a command to display. + */ +export function hasBashContent(linkedTool: LinkedToolItem): boolean { + return !!linkedTool.input?.command; +} diff --git a/src/shared/types/api.ts b/src/shared/types/api.ts index b318daaf..9822c14f 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,