diff --git a/electron.vite.config.ts b/electron.vite.config.ts index ad7b62be..649b47eb 100644 --- a/electron.vite.config.ts +++ b/electron.vite.config.ts @@ -17,7 +17,10 @@ function nativeModuleStub(): Plugin { const STUB_ID = '\0native-stub' return { name: 'native-module-stub', + enforce: 'pre', resolveId(source) { + // Don't stub our native JSONL parser — it's loaded dynamically at runtime + if (source.includes('claude-devtools-native')) return null if (source.endsWith('.node')) return STUB_ID return null }, @@ -47,7 +50,8 @@ export default defineConfig({ outDir: 'dist-electron/main', rollupOptions: { input: { - index: resolve(__dirname, 'src/main/index.ts') + index: resolve(__dirname, 'src/main/index.ts'), + sessionParseWorker: resolve(__dirname, 'src/main/workers/sessionParseWorker.ts') }, output: { // CJS format so bundled deps can use __dirname/require. diff --git a/src/main/index.ts b/src/main/index.ts index 81a33cfd..7562bae9 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -23,6 +23,7 @@ import { join } from 'path'; import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers'; import { getProjectsBasePath, getTodosBasePath } from './utils/pathDecoder'; +import { sessionParserPool } from './workers/SessionParserPool'; // Window icon path for non-mac platforms. const getWindowIconPath = (): string | undefined => { @@ -400,6 +401,9 @@ function shutdownServices(): void { sshConnectionManager.dispose(); } + // Terminate worker pool + sessionParserPool.terminate(); + // Remove IPC handlers removeIpcHandlers(); @@ -481,12 +485,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/main/ipc/sessions.ts b/src/main/ipc/sessions.ts index e99caa7c..27648a7c 100644 --- a/src/main/ipc/sessions.ts +++ b/src/main/ipc/sessions.ts @@ -22,6 +22,7 @@ import { type SessionsByIdsOptions, type SessionsPaginationOptions, } from '../types'; +import { sessionParserPool } from '../workers/SessionParserPool'; import { coercePageLimit, validateProjectId, validateSessionId } from './guards'; @@ -33,6 +34,9 @@ const logger = createLogger('IPC:sessions'); // Service registry - set via initialize let registry: ServiceContextRegistry; +// Sessions where native pipeline produced invalid chunks — permanently use JS fallback +const nativeDisabledSessions = new Set(); + /** * Initializes session handlers with service registry. */ @@ -67,6 +71,9 @@ export function removeSessionHandlers(ipcMain: IpcMain): void { ipcMain.removeHandler('get-session-metrics'); ipcMain.removeHandler('get-waterfall-data'); + // Release accumulated per-session state + nativeDisabledSessions.clear(); + logger.info('Session handlers removed'); } @@ -219,40 +226,120 @@ async function handleGetSessionDetail( // Check cache first let sessionDetail = dataCache.get(cacheKey); + let usedNative = false; + + if (!sessionDetail) { + const fsType = projectScanner.getFileSystemProvider().type; + // In SSH mode, avoid an extra deep metadata scan before full parse. + const session = await projectScanner.getSessionWithOptions(safeProjectId, safeSessionId, { + metadataLevel: fsType === 'ssh' ? 'light' : 'deep', + }); + if (!session) { + logger.error(`Session not found: ${sessionId}`); + return null; + } + + // Try native Rust pipeline (local filesystem only). + // Rust handles: JSONL read -> classify -> chunk -> tool executions -> semantic steps. + // Returns serde_json::Value with exact TS field names. JS only converts timestamps. + // JS still handles: subagent resolution (requires filesystem provider). + // Use native Rust pipeline for local sessions WITHOUT subagents. + // Sessions with subagents need ProcessLinker + sidechain context which + // only the JS pipeline provides (Review finding #1). + const hasSubagentFiles = await projectScanner.hasSubagents( + safeProjectId, + safeSessionId + ); + if (fsType === 'local' && !hasSubagentFiles && !nativeDisabledSessions.has(cacheKey)) { + try { + const { buildSessionChunksNative } = await import('../utils/nativeJsonl'); + const sessionPath = projectScanner.getSessionPath(safeProjectId, safeSessionId); + const nativeResult = buildSessionChunksNative(sessionPath); + // Validate ALL chunks — not just the first. If any chunk has wrong + // shape, fall back to JS pipeline instead of sending bad data to renderer. + const isValidNative = + nativeResult && + nativeResult.chunks.length > 0 && + (nativeResult.chunks as Record[]).every( + (c) => + c != null && + typeof c.chunkType === 'string' && + 'rawMessages' in c && + 'startTime' in c && + 'metrics' in c + ); + + if (isValidNative) { + sessionDetail = { + session, + messages: [], + chunks: nativeResult.chunks as SessionDetail['chunks'], + processes: [], + metrics: nativeResult.metrics as SessionDetail['metrics'], + }; + usedNative = true; + } else if (nativeResult) { + // Native produced chunks but they failed validation — permanently + // disable native for this session to avoid repeated failures. + logger.warn(`Native validation failed for ${cacheKey}, disabling native for this session`); + nativeDisabledSessions.add(cacheKey); + } + } catch { + // Native not available — fall through to JS + } + } - if (sessionDetail) { - return sessionDetail; + // JS fallback pipeline — dispatch to Worker Thread to avoid blocking main process + if (!usedNative) { + try { + sessionDetail = await sessionParserPool.parse({ + projectsDir: projectScanner.getProjectsDir(), + sessionPath: projectScanner.getSessionPath(safeProjectId, safeSessionId), + projectId: safeProjectId, + sessionId: safeSessionId, + fsType, + session, + }); + } catch (workerError) { + // Worker failed (timeout, crash, etc.) — fall back to inline blocking parse + logger.warn('Worker parse failed, falling back to inline:', workerError); + const parsedSession = await sessionParser.parseSession(safeProjectId, safeSessionId); + const subagents = await subagentResolver.resolveSubagents( + safeProjectId, + safeSessionId, + parsedSession.taskCalls, + parsedSession.messages + ); + session.hasSubagents = subagents.length > 0; + sessionDetail = chunkBuilder.buildSessionDetail( + session, + parsedSession.messages, + subagents + ); + } + } + + // Cache JS pipeline results only — native results skip cache so any + // rendering failures on the next request will fall back to JS pipeline. + if (sessionDetail && !usedNative) { + dataCache.set(cacheKey, sessionDetail); + } } - const fsType = projectScanner.getFileSystemProvider().type; - // In SSH mode, avoid an extra deep metadata scan before full parse. - const session = await projectScanner.getSessionWithOptions(safeProjectId, safeSessionId, { - metadataLevel: fsType === 'ssh' ? 'light' : 'deep', - }); - if (!session) { - logger.error(`Session not found: ${sessionId}`); + if (!sessionDetail) { return null; } - // Parse session messages - const parsedSession = await sessionParser.parseSession(safeProjectId, safeSessionId); - - // Resolve subagents - const subagents = await subagentResolver.resolveSubagents( - safeProjectId, - safeSessionId, - parsedSession.taskCalls, - parsedSession.messages - ); - session.hasSubagents = subagents.length > 0; - - // Build session detail with chunks - sessionDetail = chunkBuilder.buildSessionDetail(session, parsedSession.messages, subagents); - - // Cache the result - dataCache.set(cacheKey, sessionDetail); - - return sessionDetail; + // Strip raw messages before IPC transfer — the renderer never uses them. + // Only chunks (with semantic steps) and process summaries cross the boundary. + // This cuts IPC serialization + renderer heap by ~50-60%. + return { + ...sessionDetail, + messages: [], + processes: sessionDetail.processes.map((p) => ({ ...p, messages: [] })), + // Only report native pipeline when Rust actually handled full chunking. + _nativePipeline: usedNative ? Date.now() : false, + }; } catch (error) { logger.error(`Error in get-session-detail for ${projectId}/${sessionId}:`, error); return null; diff --git a/src/main/types/chunks.ts b/src/main/types/chunks.ts index dd1b94ad..48d2419a 100644 --- a/src/main/types/chunks.ts +++ b/src/main/types/chunks.ts @@ -401,6 +401,8 @@ export interface SessionDetail { processes: Process[]; /** Aggregated metrics for the entire session */ metrics: SessionMetrics; + /** Timestamp (ms) when Rust native pipeline was used, or false if JS fallback */ + _nativePipeline?: number | false; } /** diff --git a/src/main/utils/nativeJsonl.ts b/src/main/utils/nativeJsonl.ts new file mode 100644 index 00000000..1466a664 --- /dev/null +++ b/src/main/utils/nativeJsonl.ts @@ -0,0 +1,254 @@ +/** + * Native JSONL reader wrapper with graceful fallback. + * + * Uses the Rust napi-rs module for fast I/O (memory-mapped file read + line + * splitting), then pipes raw JSON strings through the existing JS + * `parseJsonlLine()` for field mapping. This keeps the semantic parsing + * logic in one place while getting ~5-10x faster file I/O. + */ + +import { createLogger } from '@shared/utils/logger'; +import { app } from 'electron'; +import { existsSync } from 'fs'; +import { join } from 'path'; + +import { parseJsonlLine } from './jsonl'; + +import type { ParsedMessage } from '../types'; + +const logger = createLogger('Util:nativeJsonl'); + +// --------------------------------------------------------------------------- +// Dynamic native module loading +// --------------------------------------------------------------------------- + +interface NativeSessionChunksResult { + chunks: Record[]; + metrics: Record; + messageCount: number; +} + +interface NativeModule { + readJsonlLines: (path: string) => string[]; + readJsonlLinesIncremental: ( + path: string, + byteOffset: number + ) => { lines: string[]; newOffset: number }; + buildSessionChunks: (path: string) => NativeSessionChunksResult; +} + +let nativeModule: NativeModule | null = null; +let loadAttempted = false; + +function tryLoadNativeModule(): NativeModule | null { + if (loadAttempted) return nativeModule; + loadAttempted = true; + + const platform = process.platform; + const arch = process.arch; + const abi = platform === 'linux' ? 'gnu' : ''; + const nodeName = `index.${platform}-${arch}${abi ? '-' + abi : ''}.node`; + + const candidates: string[] = []; + if (app.isPackaged) { + const basePath = app.getAppPath(); + candidates.push( + join(basePath, '..', 'app.asar.unpacked', 'dist-electron', nodeName), + join(basePath, 'dist-electron', nodeName) + ); + } else { + candidates.push( + join(__dirname, '..', nodeName), + join(process.cwd(), 'dist-electron', nodeName) + ); + } + + for (const candidate of candidates) { + if (existsSync(candidate)) { + try { + // eslint-disable-next-line @typescript-eslint/no-require-imports, security/detect-non-literal-require -- native .node modules must use dynamic require + const mod = require(candidate) as NativeModule; + logger.info(`Native JSONL reader loaded from ${candidate}`); + nativeModule = mod; + return mod; + } catch (err) { + logger.warn(`Failed to load native module from ${candidate}:`, err); + } + } + } + + logger.info('Native JSONL reader not available — using JS fallback'); + return null; +} + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +/** Whether the native reader is available on this platform. */ +export function isNativeAvailable(): boolean { + return tryLoadNativeModule() !== null; +} + +// --------------------------------------------------------------------------- +// Post-processing: convert Rust output to match JS type expectations +// --------------------------------------------------------------------------- + +/** Convert ISO-8601 timestamp string to Date object. */ +function toDate(value: unknown): Date { + if (value instanceof Date) return value; + if (typeof value === 'string') return new Date(value); + return new Date(); +} + +/** Return a copy of a ParsedMessage-shaped object with timestamp as Date. */ +function withFixedTimestamp(msg: Record): Record { + if (msg && typeof msg === 'object' && 'timestamp' in msg) { + return { ...msg, timestamp: toDate(msg.timestamp) }; + } + return msg; +} + +/** Convert a Rust Chunk to match the JS EnhancedChunk interface. */ +function convertChunk(raw: Record): Record { + const chunk: Record = { + ...raw, + startTime: toDate(raw.startTime), + endTime: toDate(raw.endTime), + }; + + // Convert nested ParsedMessage timestamps + if (chunk.userMessage && typeof chunk.userMessage === 'object') { + chunk.userMessage = withFixedTimestamp(chunk.userMessage as Record); + } + if (chunk.message && typeof chunk.message === 'object') { + chunk.message = withFixedTimestamp(chunk.message as Record); + } + if (Array.isArray(chunk.responses)) { + chunk.responses = (chunk.responses as Record[]).map((r) => + r && typeof r === 'object' ? withFixedTimestamp(r) : r + ); + } + if (Array.isArray(chunk.sidechainMessages)) { + chunk.sidechainMessages = (chunk.sidechainMessages as Record[]).map((m) => + m && typeof m === 'object' ? withFixedTimestamp(m) : m + ); + } + + // Convert semantic step timestamps (field names already correct from serde) + if (Array.isArray(chunk.semanticSteps)) { + chunk.semanticSteps = (chunk.semanticSteps as Record[]).map((step) => ({ + ...step, + ...(step.startTime ? { startTime: toDate(step.startTime) } : {}), + ...(step.endTime ? { endTime: toDate(step.endTime) } : {}), + })); + } + + // Convert tool execution timestamps + if (Array.isArray(chunk.toolExecutions)) { + chunk.toolExecutions = (chunk.toolExecutions as Record[]).map((te) => ({ + ...te, + ...(te.startTime ? { startTime: toDate(te.startTime) } : {}), + ...(te.endTime ? { endTime: toDate(te.endTime) } : {}), + })); + } + + return chunk; +} + +// Metrics field names are now correct from serde — no conversion needed. + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- + +interface ConvertedSessionChunks { + chunks: unknown[]; + metrics: unknown; + messageCount: number; +} + +/** + * Build session chunks entirely in Rust (Phase 2) with JS post-processing. + * + * Rust handles: JSONL read → classify → chunk → tool executions → semantic steps. + * JS post-processing converts: string timestamps → Date objects, field name fixes. + * + * Returns null if the native module is unavailable or the call fails. + */ +export function buildSessionChunksNative(filePath: string): ConvertedSessionChunks | null { + const mod = tryLoadNativeModule(); + if (!mod) return null; + + try { + const raw = mod.buildSessionChunks(filePath); + if (!raw || !Array.isArray(raw.chunks)) return null; + + // Post-process: convert timestamp strings → Date objects + // (field names are already correct from serde — no renaming needed) + const chunks = raw.chunks.map(convertChunk); + + return { chunks, metrics: raw.metrics, messageCount: raw.messageCount }; + } catch (err) { + logger.warn('Native buildSessionChunks failed, falling back to JS:', err); + return null; + } +} + +/** + * Parse a JSONL file using native I/O + JS field mapping. + * + * Rust reads the file via mmap and splits lines (~5-10x faster than readline). + * Each raw JSON line is then parsed by the existing `parseJsonlLine()` which + * handles all the ChatHistoryEntry → ParsedMessage conversion. + * + * Returns null if the native module is unavailable. + */ +export function parseJsonlFileNative(filePath: string): ParsedMessage[] | null { + const mod = tryLoadNativeModule(); + if (!mod) return null; + + try { + const rawLines = mod.readJsonlLines(filePath); + const messages: ParsedMessage[] = []; + + for (const line of rawLines) { + try { + const parsed = parseJsonlLine(line); + if (parsed) { + messages.push(parsed); + } + } catch { + // Skip malformed lines (same as JS fallback) + } + } + + return messages; + } catch (err) { + logger.warn('Native read failed, returning null for JS fallback:', err); + return null; + } +} + +/** + * Read JSONL lines incrementally from a byte offset using native I/O. + * + * Returns the raw JSON strings and the new byte offset, or null if the + * native module is unavailable or there are no new lines. + */ +export function readJsonlLinesIncremental( + filePath: string, + byteOffset: number +): { lines: string[]; newOffset: number } | null { + const mod = tryLoadNativeModule(); + if (!mod) return null; + + try { + const result = mod.readJsonlLinesIncremental(filePath, byteOffset); + if (!result || result.lines.length === 0) return null; + return result; + } catch (err) { + logger.warn('Native incremental read failed:', err); + return null; + } +} diff --git a/src/main/workers/SessionParserPool.ts b/src/main/workers/SessionParserPool.ts new file mode 100644 index 00000000..46a3a7b1 --- /dev/null +++ b/src/main/workers/SessionParserPool.ts @@ -0,0 +1,118 @@ +/** + * SessionParserPool - Single worker manager with request queuing and timeout. + * + * Dispatches session-parsing work to a Worker Thread so the main Electron + * process stays responsive during large JSONL file processing. + */ + +import { createLogger } from '@shared/utils/logger'; +import { join } from 'path'; +import { Worker } from 'worker_threads'; + +import type { WorkerRequest } from './sessionParseWorker'; +import type { SessionDetail } from '@main/types'; + +const logger = createLogger('Workers:SessionParserPool'); + +interface PendingRequest { + resolve: (value: SessionDetail) => void; + reject: (reason: Error) => void; + timer: NodeJS.Timeout; +} + +export class SessionParserPool { + private worker: Worker | null = null; + private pending = new Map(); + private requestCounter = 0; + private readonly timeoutMs = 30_000; + + /** + * Parse a session in the worker thread. + * Returns the fully assembled SessionDetail. + */ + async parse(request: Omit): Promise { + const worker = this.ensureWorker(); + const id = String(++this.requestCounter); + + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error('Worker timeout after ' + this.timeoutMs + 'ms')); + this.restartWorker(); + }, this.timeoutMs); + + this.pending.set(id, { resolve, reject, timer }); + worker.postMessage({ ...request, id }); + }); + } + + private ensureWorker(): Worker { + if (!this.worker) { + // Resolve worker path relative to this file's compiled location. + // In dev (electron-vite): both files compile to dist-electron/main/ + // In production (asar): same directory via electron-builder. + const workerPath = join(__dirname, 'sessionParseWorker.cjs'); + this.worker = new Worker(workerPath); + + this.worker.on('message', (msg: { id: string; result?: SessionDetail; error?: string }) => { + const entry = this.pending.get(msg.id); + if (!entry) return; + + clearTimeout(entry.timer); + this.pending.delete(msg.id); + + if (msg.error) { + entry.reject(new Error(msg.error)); + } else if (msg.result) { + entry.resolve(msg.result); + } else { + entry.reject(new Error('Worker returned empty response')); + } + }); + + this.worker.on('error', (err: Error) => { + logger.error('Worker error:', err); + this.rejectAllPending(new Error('Worker error: ' + err.message)); + this.worker = null; + }); + + this.worker.on('exit', (code) => { + if (code !== 0) { + logger.warn(`Worker exited with code ${code}`); + this.rejectAllPending(new Error(`Worker exited with code ${code}`)); + } + this.worker = null; + }); + + logger.info('Session parser worker started'); + } + return this.worker; + } + + private restartWorker(): void { + logger.warn('Restarting worker due to timeout'); + void this.worker?.terminate(); + this.worker = null; + } + + private rejectAllPending(error: Error): void { + for (const [, entry] of this.pending) { + clearTimeout(entry.timer); + entry.reject(error); + } + this.pending.clear(); + } + + /** + * Terminate the worker and reject all pending requests. + * Called during app shutdown. + */ + terminate(): void { + void this.worker?.terminate(); + this.worker = null; + this.rejectAllPending(new Error('Pool terminated')); + } +} + +/** Singleton instance used by session IPC handlers. */ +export const sessionParserPool = new SessionParserPool(); diff --git a/src/main/workers/sessionParseWorker.ts b/src/main/workers/sessionParseWorker.ts new file mode 100644 index 00000000..79aedb35 --- /dev/null +++ b/src/main/workers/sessionParseWorker.ts @@ -0,0 +1,455 @@ +/** + * Worker Thread entry point for session parsing. + * + * Runs the JS parsing pipeline (parseJsonlFile -> processMessages -> buildChunks -> resolveSubagents) + * off the main Electron thread so IPC, file watchers, and the renderer stay responsive. + * + * All imports must be pure Node.js / pure logic -- no Electron APIs. + */ + +import { ChunkBuilder } from '@main/services/analysis/ChunkBuilder'; +import { SubagentLocator } from '@main/services/discovery/SubagentLocator'; +import { LocalFileSystemProvider } from '@main/services/infrastructure/LocalFileSystemProvider'; +import { + isParsedInternalUserMessage, + isParsedRealUserMessage, + type ParsedMessage, + type Process, + type Session, + type SessionDetail, + type ToolCall, +} from '@main/types'; +import { calculateMetrics, getTaskCalls, parseJsonlFile } from '@main/utils/jsonl'; +import { checkMessagesOngoing } from '@main/utils/sessionStateDetection'; +import * as path from 'path'; +import { parentPort } from 'worker_threads'; + +import type { ParsedSession } from '@main/services/parsing/SessionParser'; + +// --------------------------------------------------------------------------- +// Worker request / response types +// --------------------------------------------------------------------------- + +export interface WorkerRequest { + /** Unique request ID for matching responses */ + id: string; + /** Base ~/.claude/projects/ path */ + projectsDir: string; + /** Full path to session.jsonl */ + sessionPath: string; + projectId: string; + sessionId: string; + fsType: 'local' | 'ssh'; + /** Session metadata object (serializable POJO, transferred via structured clone) */ + session: Session; +} + +interface WorkerResponse { + id: string; + result?: SessionDetail; + error?: string; +} + +// --------------------------------------------------------------------------- +// Pure-function equivalents of SessionParser.processMessages +// --------------------------------------------------------------------------- + +function processMessages(messages: ParsedMessage[]): ParsedSession { + const byType = { + user: [] as ParsedMessage[], + realUser: [] as ParsedMessage[], + internalUser: [] as ParsedMessage[], + assistant: [] as ParsedMessage[], + system: [] as ParsedMessage[], + other: [] as ParsedMessage[], + }; + const sidechainMessages: ParsedMessage[] = []; + const mainMessages: ParsedMessage[] = []; + + for (const m of messages) { + switch (m.type) { + case 'user': + byType.user.push(m); + if (isParsedRealUserMessage(m)) { + byType.realUser.push(m); + } else if (isParsedInternalUserMessage(m)) { + byType.internalUser.push(m); + } + break; + case 'assistant': + byType.assistant.push(m); + break; + case 'system': + byType.system.push(m); + break; + default: + byType.other.push(m); + break; + } + + if (m.isSidechain) { + sidechainMessages.push(m); + } else { + mainMessages.push(m); + } + } + + const metrics = calculateMetrics(messages); + const taskCalls = getTaskCalls(messages); + + return { messages, metrics, taskCalls, byType, sidechainMessages, mainMessages }; +} + +// --------------------------------------------------------------------------- +// Lightweight subagent resolution (mirrors SubagentResolver but uses paths directly) +// --------------------------------------------------------------------------- + +const PARALLEL_WINDOW_MS = 100; + +async function resolveSubagentsFromPaths( + projectsDir: string, + projectId: string, + sessionId: string, + taskCalls: ToolCall[], + messages: ParsedMessage[], + fsProvider: LocalFileSystemProvider +): Promise { + const locator = new SubagentLocator(projectsDir, fsProvider); + const subagentFiles = await locator.listSubagentFiles(projectId, sessionId); + + if (subagentFiles.length === 0) { + return []; + } + + // Parse subagent files with bounded concurrency + const concurrency = 24; + const subagents: Process[] = []; + + for (let i = 0; i < subagentFiles.length; i += concurrency) { + const batch = subagentFiles.slice(i, i + concurrency); + const settled = await Promise.allSettled( + batch.map((filePath) => parseSubagentFile(filePath, fsProvider)) + ); + for (const result of settled) { + if (result.status === 'fulfilled' && result.value !== null) { + subagents.push(result.value); + } + } + } + + // Link to Task calls + linkToTaskCalls(subagents, taskCalls, messages); + + // Propagate team metadata + propagateTeamMetadata(subagents); + + // Detect parallel execution + detectParallelExecution(subagents); + + // Enrich team colors + enrichTeamColors(subagents, messages); + + // Sort by start time + subagents.sort((a, b) => a.startTime.getTime() - b.startTime.getTime()); + + return subagents; +} + +async function parseSubagentFile( + filePath: string, + fsProvider: LocalFileSystemProvider +): Promise { + try { + const messages = await parseJsonlFile(filePath, fsProvider); + + if (messages.length === 0) return null; + + // Filter warmup subagents + const firstUser = messages.find((m) => m.type === 'user'); + if (firstUser?.content === 'Warmup') return null; + + const filename = path.basename(filePath); + const agentId = filename.replace(/^agent-/, '').replace(/\.jsonl$/, ''); + + // Filter compact files + if (agentId.startsWith('acompact')) return null; + + // Calculate timing + const timestamps = messages.map((m) => m.timestamp.getTime()).filter((t) => !isNaN(t)); + let startTime: Date; + let endTime: Date; + let durationMs: number; + + if (timestamps.length === 0) { + const now = new Date(); + startTime = now; + endTime = now; + durationMs = 0; + } else { + let minTime = timestamps[0]; + let maxTime = timestamps[0]; + for (let i = 1; i < timestamps.length; i++) { + if (timestamps[i] < minTime) minTime = timestamps[i]; + if (timestamps[i] > maxTime) maxTime = timestamps[i]; + } + startTime = new Date(minTime); + endTime = new Date(maxTime); + durationMs = maxTime - minTime; + } + + const metrics = calculateMetrics(messages); + const isOngoing = checkMessagesOngoing(messages); + + return { + id: agentId, + filePath, + messages, + startTime, + endTime, + durationMs, + metrics, + isParallel: false, + isOngoing, + }; + } catch { + return null; + } +} + +// --------------------------------------------------------------------------- +// Task call linking (mirrors SubagentResolver) +// --------------------------------------------------------------------------- + +function extractTeamMessageSummary(messages: ParsedMessage[]): string | undefined { + const firstUser = messages.find((m) => m.type === 'user'); + if (!firstUser) return undefined; + const text = typeof firstUser.content === 'string' ? firstUser.content : ''; + const match = /]*\bsummary="([^"]+)"/.exec(text); + return match?.[1]; +} + +function enrichSubagentFromTask(subagent: Process, taskCall: ToolCall): void { + /* eslint-disable no-param-reassign -- Mutation is intentional; mirrors SubagentResolver */ + subagent.parentTaskId = taskCall.id; + subagent.description = taskCall.taskDescription; + subagent.subagentType = taskCall.taskSubagentType; + + const teamName = taskCall.input?.team_name as string | undefined; + const memberName = taskCall.input?.name as string | undefined; + if (teamName && memberName) { + subagent.team = { teamName, memberName, memberColor: '' }; + } + /* eslint-enable no-param-reassign -- End of intentional mutation block */ +} + +function linkToTaskCalls( + subagents: Process[], + taskCalls: ToolCall[], + messages: ParsedMessage[] +): void { + const taskCallsOnly = taskCalls.filter((tc) => tc.isTask); + if (taskCallsOnly.length === 0 || subagents.length === 0) return; + + // Build agentId -> taskCallId map from tool result messages + const agentIdToTaskId = new Map(); + for (const msg of messages) { + if (!msg.toolUseResult) continue; + const result = msg.toolUseResult; + const agentId = (result.agentId ?? result.agent_id) as string | undefined; + if (!agentId) continue; + const taskCallId = msg.sourceToolUseID ?? msg.toolResults[0]?.toolUseId; + if (taskCallId) { + agentIdToTaskId.set(agentId, taskCallId); + } + } + + const taskCallById = new Map(taskCallsOnly.map((tc) => [tc.id, tc])); + const matchedSubagentIds = new Set(); + const matchedTaskIds = new Set(); + + // Phase 1: Result-based matching + for (const subagent of subagents) { + const taskCallId = agentIdToTaskId.get(subagent.id); + if (!taskCallId) continue; + const taskCall = taskCallById.get(taskCallId); + if (!taskCall) continue; + enrichSubagentFromTask(subagent, taskCall); + matchedSubagentIds.add(subagent.id); + matchedTaskIds.add(taskCallId); + } + + // Phase 2: Description-based matching for team members + const teamTaskCalls = taskCallsOnly.filter( + (tc) => !matchedTaskIds.has(tc.id) && tc.input?.team_name && tc.input?.name + ); + + if (teamTaskCalls.length > 0) { + const subagentSummaries = new Map(); + for (const subagent of subagents) { + if (matchedSubagentIds.has(subagent.id)) continue; + const summary = extractTeamMessageSummary(subagent.messages); + if (summary) subagentSummaries.set(subagent.id, summary); + } + + for (const taskCall of teamTaskCalls) { + const description = taskCall.taskDescription; + if (!description) continue; + let bestMatch: Process | undefined; + for (const subagent of subagents) { + if (matchedSubagentIds.has(subagent.id)) continue; + if (subagentSummaries.get(subagent.id) !== description) continue; + if (!bestMatch || subagent.startTime < bestMatch.startTime) { + bestMatch = subagent; + } + } + if (bestMatch) { + enrichSubagentFromTask(bestMatch, taskCall); + matchedSubagentIds.add(bestMatch.id); + matchedTaskIds.add(taskCall.id); + } + } + } + + // Phase 3: Positional fallback + const unmatchedSubagents = [...subagents] + .filter((s) => !matchedSubagentIds.has(s.id)) + .sort((a, b) => a.startTime.getTime() - b.startTime.getTime()); + const unmatchedTasks = taskCallsOnly.filter( + (tc) => !matchedTaskIds.has(tc.id) && !(tc.input?.team_name && tc.input?.name) + ); + for (let i = 0; i < unmatchedSubagents.length && i < unmatchedTasks.length; i++) { + enrichSubagentFromTask(unmatchedSubagents[i], unmatchedTasks[i]); + } +} + +function propagateTeamMetadata(subagents: Process[]): void { + const lastUuidToSubagent = new Map(); + for (const subagent of subagents) { + if (subagent.messages.length === 0) continue; + const lastMsg = subagent.messages[subagent.messages.length - 1]; + if (lastMsg.uuid) lastUuidToSubagent.set(lastMsg.uuid, subagent); + } + + const maxDepth = 10; + for (const subagent of subagents) { + if (subagent.team) continue; + if (subagent.messages.length === 0) continue; + const firstMsg = subagent.messages[0]; + if (!firstMsg.parentUuid) continue; + + let ancestor: Process | undefined = lastUuidToSubagent.get(firstMsg.parentUuid); + let depth = 0; + while (ancestor && !ancestor.team && depth < maxDepth) { + if (ancestor.messages.length === 0) break; + const parentUuid = ancestor.messages[0].parentUuid; + if (!parentUuid) break; + ancestor = lastUuidToSubagent.get(parentUuid); + depth++; + } + + if (ancestor?.team) { + subagent.team = { ...ancestor.team }; + subagent.parentTaskId = subagent.parentTaskId ?? ancestor.parentTaskId; + subagent.description = subagent.description ?? ancestor.description; + subagent.subagentType = subagent.subagentType ?? ancestor.subagentType; + } + } +} + +function detectParallelExecution(subagents: Process[]): void { + if (subagents.length < 2) return; + const sorted = [...subagents].sort((a, b) => a.startTime.getTime() - b.startTime.getTime()); + + const groups: Process[][] = []; + let currentGroup: Process[] = []; + let groupStartTime = 0; + + for (const agent of sorted) { + const startMs = agent.startTime.getTime(); + if (currentGroup.length === 0) { + currentGroup.push(agent); + groupStartTime = startMs; + } else if (startMs - groupStartTime <= PARALLEL_WINDOW_MS) { + currentGroup.push(agent); + } else { + if (currentGroup.length > 0) groups.push(currentGroup); + currentGroup = [agent]; + groupStartTime = startMs; + } + } + if (currentGroup.length > 0) groups.push(currentGroup); + + for (const group of groups) { + if (group.length > 1) { + for (const agent of group) { + agent.isParallel = true; + } + } + } +} + +function enrichTeamColors(subagents: Process[], messages: ParsedMessage[]): void { + for (const msg of messages) { + if (!msg.toolUseResult) continue; + const sourceId = msg.sourceToolUseID ?? msg.toolResults[0]?.toolUseId; + if (!sourceId) continue; + const result = msg.toolUseResult; + if (result.status === 'teammate_spawned' && result.color) { + for (const subagent of subagents) { + if (subagent.parentTaskId === sourceId && subagent.team) { + subagent.team.memberColor = result.color as string; + } + } + } + } +} + +// --------------------------------------------------------------------------- +// Message handler +// --------------------------------------------------------------------------- + +parentPort?.on('message', async (request: WorkerRequest) => { + const response: WorkerResponse = { id: request.id }; + + try { + const fsProvider = new LocalFileSystemProvider(); + + // 1. Parse JSONL + const messages = await parseJsonlFile(request.sessionPath, fsProvider); + + // 2. Process messages (classify, extract metrics, task calls) + const parsedSession = processMessages(messages); + + // 3. Resolve subagents + const subagents = await resolveSubagentsFromPaths( + request.projectsDir, + request.projectId, + request.sessionId, + parsedSession.taskCalls, + parsedSession.messages, + fsProvider + ); + + // 4. Build chunks and assemble SessionDetail + const chunkBuilder = new ChunkBuilder(); + const session = request.session; + session.hasSubagents = subagents.length > 0; + + const chunks = chunkBuilder.buildChunks(parsedSession.messages, subagents); + const metrics = calculateMetrics(parsedSession.messages); + + const sessionDetail: SessionDetail = { + session, + messages: parsedSession.messages, + chunks, + processes: subagents, + metrics, + }; + + response.result = sessionDetail; + } catch (error) { + response.error = error instanceof Error ? error.message : String(error); + } + + parentPort?.postMessage(response); +}); 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/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,