From aa1df1e64a00865129182d06e72a7f8929ad3294 Mon Sep 17 00:00:00 2001 From: Psypeal Gwai Date: Wed, 25 Feb 2026 02:15:06 -0800 Subject: [PATCH 1/4] =?UTF-8?q?fix(#80):=20unify=20cost=20computation=20?= =?UTF-8?q?=E2=80=94=20single=20source=20of=20truth?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit sessionAnalyzer now reads pre-computed costUsd from calculateMetrics() instead of re-computing costs independently. Parent cost uses detail.metrics.costUsd, subagent cost uses proc.metrics.costUsd. This ensures the cost analysis panel and chat header always agree. Co-Authored-By: Claude Opus 4.6 --- src/renderer/utils/sessionAnalyzer.ts | 14 +--- test/renderer/utils/sessionAnalyzer.test.ts | 87 ++++++++++++++++++++- 2 files changed, 87 insertions(+), 14 deletions(-) diff --git a/src/renderer/utils/sessionAnalyzer.ts b/src/renderer/utils/sessionAnalyzer.ts index 0a4217bd..33f6bcab 100644 --- a/src/renderer/utils/sessionAnalyzer.ts +++ b/src/renderer/utils/sessionAnalyzer.ts @@ -298,8 +298,8 @@ export function analyzeSession(detail: SessionDetail): SessionReport { // Test progression const testSnapshots: TestSnapshot[] = []; - // Cost tracking - let parentCost = 0; + // Cost tracking — use the pre-computed cost from calculateMetrics() as single source of truth + const parentCost = detail.metrics.costUsd ?? 0; // Git activity const gitCommits: GitCommit[] = []; @@ -402,7 +402,6 @@ export function analyzeSession(detail: SessionDetail): SessionReport { const callCost = calculateMessageCost(model, inpTok, outTok, cr, cc); stats.costUsd += callCost; - parentCost += callCost; totalCacheCreation += cc; totalCacheRead += cr; @@ -823,14 +822,7 @@ export function analyzeSession(detail: SessionDetail): SessionReport { const subagentModel = proc.messages.find((m: ParsedMessage) => m.type === 'assistant' && m.model)?.model ?? 'default (inherits parent)'; - // Compute cost from subagent token breakdown (proc.metrics.costUsd is not populated upstream) - const computedCost = calculateMessageCost( - subagentModel, - proc.metrics.inputTokens, - proc.metrics.outputTokens, - proc.metrics.cacheReadTokens, - proc.metrics.cacheCreationTokens - ); + const computedCost = proc.metrics.costUsd ?? 0; return { description: desc, subagentType: proc.subagentType ?? 'unknown', diff --git a/test/renderer/utils/sessionAnalyzer.test.ts b/test/renderer/utils/sessionAnalyzer.test.ts index 06064df4..a7cf90e6 100644 --- a/test/renderer/utils/sessionAnalyzer.test.ts +++ b/test/renderer/utils/sessionAnalyzer.test.ts @@ -173,6 +173,7 @@ describe('analyzeSession', () => { createMockDetail({ messages, session: createMockSession({ messageCount: 4 }), + metrics: createMockMetrics({ costUsd: 0.025 }), }) ); @@ -188,9 +189,9 @@ describe('analyzeSession', () => { expect(report.tokenUsage.totals.cacheCreation).toBe(100); expect(report.tokenUsage.totals.grandTotal).toBe(4000); - // Cost should be positive (sonnet-4 pricing) - expect(report.costAnalysis.parentCostUsd).toBeGreaterThan(0); - expect(report.costAnalysis.totalSessionCostUsd).toBeGreaterThan(0); + // Cost reads from detail.metrics.costUsd (single source of truth) + expect(report.costAnalysis.parentCostUsd).toBe(0.025); + expect(report.costAnalysis.totalSessionCostUsd).toBe(0.025); // Message types expect(report.messageTypes.user).toBe(2); @@ -1506,4 +1507,84 @@ describe('analyzeSession', () => { expect(report.subagentMetrics.byAgent[0].modelMismatch).toBeNull(); }); }); + + // ------------------------------------------------------------------------- + // Unified cost — single source of truth + // ------------------------------------------------------------------------- + describe('unified cost computation', () => { + it('parentCostUsd reads from detail.metrics.costUsd', () => { + const report = analyzeSession( + createMockDetail({ + metrics: createMockMetrics({ costUsd: 1.2345 }), + }) + ); + expect(report.costAnalysis.parentCostUsd).toBe(1.2345); + }); + + it('parentCostUsd defaults to 0 when detail.metrics.costUsd is undefined', () => { + const report = analyzeSession(createMockDetail()); + expect(report.costAnalysis.parentCostUsd).toBe(0); + }); + + it('subagent cost reads from proc.metrics.costUsd', () => { + const processes: Process[] = [ + { + id: 'agent-1', + filePath: '/path/to/agent-1.jsonl', + messages: [], + startTime: new Date('2024-01-01T10:00:00Z'), + endTime: new Date('2024-01-01T10:01:00Z'), + durationMs: 60000, + metrics: createMockMetrics({ totalTokens: 5000, costUsd: 0.15 }), + description: 'research task', + subagentType: 'explore', + isParallel: false, + }, + { + id: 'agent-2', + filePath: '/path/to/agent-2.jsonl', + messages: [], + startTime: new Date('2024-01-01T10:01:00Z'), + endTime: new Date('2024-01-01T10:02:00Z'), + durationMs: 60000, + metrics: createMockMetrics({ totalTokens: 3000, costUsd: 0.10 }), + description: 'test runner', + subagentType: 'code', + isParallel: false, + }, + ]; + + const report = analyzeSession(createMockDetail({ processes })); + expect(report.subagentMetrics.byAgent[0].costUsd).toBe(0.15); + expect(report.subagentMetrics.byAgent[1].costUsd).toBe(0.10); + expect(report.costAnalysis.subagentCostUsd).toBe(0.25); + }); + + it('totalSessionCostUsd equals parent + subagent costs', () => { + const processes: Process[] = [ + { + id: 'agent-1', + filePath: '/path/to/agent-1.jsonl', + messages: [], + startTime: new Date('2024-01-01T10:00:00Z'), + endTime: new Date('2024-01-01T10:01:00Z'), + durationMs: 60000, + metrics: createMockMetrics({ totalTokens: 5000, costUsd: 0.30 }), + description: 'task', + subagentType: 'code', + isParallel: false, + }, + ]; + + const report = analyzeSession( + createMockDetail({ + metrics: createMockMetrics({ costUsd: 0.50 }), + processes, + }) + ); + expect(report.costAnalysis.parentCostUsd).toBe(0.50); + expect(report.costAnalysis.subagentCostUsd).toBe(0.30); + expect(report.costAnalysis.totalSessionCostUsd).toBe(0.80); + }); + }); }); From 09fb97f07423574dff8bc7f15a55769f96b11aad Mon Sep 17 00:00:00 2001 From: Psypeal Gwai Date: Wed, 25 Feb 2026 20:15:45 -0800 Subject: [PATCH 2/4] fix(#85): Ctrl+R session refresh via IPC with scroll-to-bottom Electron's before-input-event preventDefault blocks both Chromium's built-in reload AND keydown propagation to the renderer. Route Ctrl+R through IPC instead: main process intercepts and sends session:refresh, preload bridges to renderer, store listener refreshes in place. Switch all refresh paths (IPC, refresh button, keyboard shortcut) to refreshSessionInPlace to avoid unmounting the scroll container, then dispatch a custom event to smoothly scroll to the bottom. Fixes #85 --- src/main/index.ts | 17 +++++++++++++---- src/preload/constants/ipcChannels.ts | 3 +++ src/preload/index.ts | 10 ++++++++++ src/renderer/api/httpClient.ts | 5 +++++ src/renderer/components/chat/ChatHistory.tsx | 9 +++++++++ src/renderer/components/layout/TabBar.tsx | 7 ++++--- src/renderer/hooks/useKeyboardShortcuts.ts | 12 +++++++----- src/renderer/store/index.ts | 20 ++++++++++++++++++++ src/shared/types/api.ts | 3 +++ 9 files changed, 74 insertions(+), 12 deletions(-) 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 57ffdf99..35c2c033 100644 --- a/src/renderer/components/chat/ChatHistory.tsx +++ b/src/renderer/components/chat/ChatHistory.tsx @@ -387,6 +387,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/layout/TabBar.tsx b/src/renderer/components/layout/TabBar.tsx index 52e0b099..0a7db148 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/shared/types/api.ts b/src/shared/types/api.ts index 81960cea..26387a8a 100644 --- a/src/shared/types/api.ts +++ b/src/shared/types/api.ts @@ -419,6 +419,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, From c322cc46d85cb87b7e60cde8726417d3bce045c7 Mon Sep 17 00:00:00 2001 From: Psypeal Gwai Date: Fri, 27 Feb 2026 04:12:46 -0800 Subject: [PATCH 3/4] feat: add BashToolViewer, collapsible output, and expanded syntax highlighting - Add BashToolViewer with syntax-highlighted command input via CodeBlockViewer - Add CollapsibleOutputSection (collapsed by default) for tool output - Apply collapsible output to DefaultToolViewer and BashToolViewer - Add bash/zsh/fish keyword sets for syntax highlighting - Add keyword sets for c, cpp, java, kotlin, swift, lua, html, yaml - Fix markdown/html false type-coloring on capitalized words - Add markdown preview toggle to ReadToolViewer (defaults to preview) - Default WriteToolViewer to preview mode for markdown files - Extend comment detection for zsh, fish, yaml (#) and lua (--) --- .../components/chat/items/LinkedToolItem.tsx | 14 +- .../chat/items/linkedTool/BashToolViewer.tsx | 52 +++ .../linkedTool/CollapsibleOutputSection.tsx | 57 +++ .../items/linkedTool/DefaultToolViewer.tsx | 30 +- .../chat/items/linkedTool/ReadToolViewer.tsx | 51 +- .../chat/items/linkedTool/WriteToolViewer.tsx | 2 +- .../components/chat/items/linkedTool/index.ts | 2 + .../chat/viewers/syntaxHighlighter.ts | 442 +++++++++++++++++- src/renderer/utils/toolRendering/index.ts | 1 + .../utils/toolRendering/toolContentChecks.ts | 7 + 10 files changed, 620 insertions(+), 38 deletions(-) create mode 100644 src/renderer/components/chat/items/linkedTool/BashToolViewer.tsx create mode 100644 src/renderer/components/chat/items/linkedTool/CollapsibleOutputSection.tsx 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/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; +} From fd8ea3bfc31aa64f56c36fbfd90ea9262a6319c7 Mon Sep 17 00:00:00 2001 From: Psypeal Gwai Date: Sun, 5 Apr 2026 03:10:26 -0700 Subject: [PATCH 4/4] fix: always swap conversation data on tab switch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove the sessionChanged guard from setActiveTab — the global conversation may belong to a different tab even when selectedSessionId matches (multi-tab scenario). Always swap from per-tab cache or trigger a fetch to ensure the correct conversation is displayed. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/renderer/store/slices/tabSlice.ts | 73 +++++++++++++-------------- 1 file changed, 36 insertions(+), 37 deletions(-) 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; }