From 93268d3528b5ff965b172c71d09b711cadf3342e Mon Sep 17 00:00:00 2001 From: Pedram Amini Date: Wed, 24 Dec 2025 12:17:42 -0600 Subject: [PATCH] OAuth enabled but no valid token found. Starting authentication... Found expired OAuth token, attempting refresh... Token refresh successful ## CHANGES MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Prompts now compile from Markdown into TypeScript at build-time ๐Ÿ”ง - New `build:prompts` step runs automatically for dev and release builds ๐Ÿ—๏ธ - Main process stops runtime prompt file I/O for faster, safer startups โšก - Group chat prompt access refactored into getter functions for flexibility ๐Ÿงฉ - Added IPC to reset a participant context with session summarization ๐Ÿ”„ - Participant cards now copy agent session IDs with one click ๐Ÿ“‹ - UI shows context-reset button when participant usage hits 40%+ โฑ๏ธ - History markdown now supports raw HTML rendering via `rehype-raw` ๐Ÿงช - History detail supports clickable file links and in-app file previews ๐Ÿ—‚๏ธ - Document copy-drag enables reset-on-completion across duplicate filenames ๐Ÿงท --- .gitignore | 1 + package.json | 17 +- scripts/generate-prompts.mjs | 101 ++++++++++++ src/main/group-chat/group-chat-agent.ts | 2 +- src/main/group-chat/group-chat-moderator.ts | 14 +- src/main/group-chat/group-chat-router.ts | 12 +- src/main/ipc/handlers/groupChat.ts | 146 ++++++++++++++++++ src/main/preload.ts | 3 + src/main/prompts.ts | 59 ------- src/prompts/index.ts | 32 +--- src/renderer/App.tsx | 41 ++++- src/renderer/components/DocumentsPanel.tsx | 6 + .../components/GroupChatParticipants.tsx | 18 ++- .../components/GroupChatRightPanel.tsx | 16 +- .../components/HistoryDetailModal.tsx | 16 +- src/renderer/components/HistoryPanel.tsx | 10 +- src/renderer/components/MarkdownRenderer.tsx | 2 + src/renderer/components/ParticipantCard.tsx | 75 +++++++-- src/renderer/components/RightPanel.tsx | 6 +- src/renderer/global.d.ts | 1 + 20 files changed, 440 insertions(+), 138 deletions(-) create mode 100644 scripts/generate-prompts.mjs delete mode 100644 src/main/prompts.ts diff --git a/.gitignore b/.gitignore index 00992677..c99c5801 100644 --- a/.gitignore +++ b/.gitignore @@ -19,6 +19,7 @@ node_modules/ # Build outputs dist/ release/ +src/generated/ *.log tmp/ scratch/ diff --git a/package.json b/package.json index be766f88..34cf7e76 100644 --- a/package.json +++ b/package.json @@ -18,10 +18,11 @@ "scripts": { "dev": "concurrently \"npm run dev:main\" \"npm run dev:renderer\"", "dev:demo": "MAESTRO_DEMO_DIR=/tmp/maestro-demo npm run dev", - "dev:main": "tsc -p tsconfig.main.json && NODE_ENV=development electron .", + "dev:main": "npm run build:prompts && tsc -p tsconfig.main.json && NODE_ENV=development electron .", "dev:renderer": "vite", "dev:web": "vite --config vite.config.web.mts", - "build": "npm run build:main && npm run build:renderer && npm run build:web && npm run build:cli", + "build": "npm run build:prompts && npm run build:main && npm run build:renderer && npm run build:web && npm run build:cli", + "build:prompts": "node scripts/generate-prompts.mjs", "build:main": "tsc -p tsconfig.main.json", "build:cli": "node scripts/build-cli.mjs", "build:renderer": "vite build", @@ -97,10 +98,6 @@ { "from": "src/prompts/speckit", "to": "prompts/speckit" - }, - { - "from": "src/prompts/group-chat-*.md", - "to": "prompts" } ] }, @@ -129,10 +126,6 @@ { "from": "src/prompts/speckit", "to": "prompts/speckit" - }, - { - "from": "src/prompts/group-chat-*.md", - "to": "prompts" } ] }, @@ -170,10 +163,6 @@ { "from": "src/prompts/speckit", "to": "prompts/speckit" - }, - { - "from": "src/prompts/group-chat-*.md", - "to": "prompts" } ] }, diff --git a/scripts/generate-prompts.mjs b/scripts/generate-prompts.mjs new file mode 100644 index 00000000..f8d79259 --- /dev/null +++ b/scripts/generate-prompts.mjs @@ -0,0 +1,101 @@ +#!/usr/bin/env node +/** + * Build script to generate TypeScript from prompt markdown files. + * + * Reads all .md files from src/prompts/ and generates src/generated/prompts.ts + * with the content as exported string constants. + * + * This allows prompts to be: + * - Edited as readable markdown files + * - Imported as regular TypeScript constants (no runtime file I/O) + * - Used consistently in both renderer and main process + */ + +import * as fs from 'fs'; +import * as path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const rootDir = path.resolve(__dirname, '..'); +const promptsDir = path.join(rootDir, 'src/prompts'); +const outputDir = path.join(rootDir, 'src/generated'); +const outputFile = path.join(outputDir, 'prompts.ts'); + +/** + * Convert a filename like "wizard-system.md" to a camelCase variable name + * like "wizardSystemPrompt" + */ +function filenameToVarName(filename) { + const base = filename.replace(/\.md$/, ''); + const parts = base.split('-'); + const camelCase = parts + .map((part, i) => (i === 0 ? part : part.charAt(0).toUpperCase() + part.slice(1))) + .join(''); + // Only add "Prompt" suffix if the name doesn't already end with it + if (camelCase.toLowerCase().endsWith('prompt')) { + return camelCase; + } + return camelCase + 'Prompt'; +} + +/** + * Escape backticks and ${} in template literal content + */ +function escapeTemplateString(content) { + return content + .replace(/\\/g, '\\\\') + .replace(/`/g, '\\`') + .replace(/\$\{/g, '\\${'); +} + +async function generate() { + console.log('Generating prompts from markdown files...'); + + // Ensure output directory exists + if (!fs.existsSync(outputDir)) { + fs.mkdirSync(outputDir, { recursive: true }); + } + + // Find all .md files in prompts directory (not in subdirectories) + const files = fs.readdirSync(promptsDir).filter((f) => f.endsWith('.md')); + + if (files.length === 0) { + console.error('No .md files found in', promptsDir); + process.exit(1); + } + + // Build the output + const exports = []; + const lines = [ + '/**', + ' * AUTO-GENERATED FILE - DO NOT EDIT DIRECTLY', + ' *', + ' * This file is generated by scripts/generate-prompts.mjs', + ' * Edit the source .md files in src/prompts/ instead.', + ' *', + ` * Generated: ${new Date().toISOString()}`, + ' */', + '', + ]; + + for (const file of files.sort()) { + const filePath = path.join(promptsDir, file); + const content = fs.readFileSync(filePath, 'utf8'); + const varName = filenameToVarName(file); + + lines.push(`export const ${varName} = \`${escapeTemplateString(content)}\`;`); + lines.push(''); + exports.push(varName); + } + + // Write the file + fs.writeFileSync(outputFile, lines.join('\n')); + + console.log(`โœ“ Generated ${outputFile}`); + console.log(` ${exports.length} prompts: ${exports.join(', ')}`); +} + +generate().catch((error) => { + console.error('Generation failed:', error); + process.exit(1); +}); diff --git a/src/main/group-chat/group-chat-agent.ts b/src/main/group-chat/group-chat-agent.ts index 0c8b12f3..86c4f44b 100644 --- a/src/main/group-chat/group-chat-agent.ts +++ b/src/main/group-chat/group-chat-agent.ts @@ -20,7 +20,7 @@ import { appendToLog } from './group-chat-log'; import { IProcessManager, isModeratorActive } from './group-chat-moderator'; import type { AgentDetector } from '../agent-detector'; import { buildAgentArgs, applyAgentConfigOverrides, getContextWindowValue } from '../utils/agent-args'; -import { groupChatParticipantPrompt } from '../prompts'; +import { groupChatParticipantPrompt } from '../../prompts'; /** * In-memory store for active participant sessions. diff --git a/src/main/group-chat/group-chat-moderator.ts b/src/main/group-chat/group-chat-moderator.ts index 25a8b44f..3ab58a43 100644 --- a/src/main/group-chat/group-chat-moderator.ts +++ b/src/main/group-chat/group-chat-moderator.ts @@ -17,7 +17,7 @@ import { appendToLog, readLog } from './group-chat-log'; import { groupChatModeratorSystemPrompt, groupChatModeratorSynthesisPrompt, -} from '../prompts'; +} from '../../prompts'; /** * Interface for the process manager dependency. @@ -114,18 +114,22 @@ function touchSession(groupChatId: string): void { } /** - * The base system prompt for the moderator. + * Gets the base system prompt for the moderator. * This is combined with participant info and chat history in routeUserMessage. * Loaded from src/prompts/group-chat-moderator-system.md */ -export const MODERATOR_SYSTEM_PROMPT = groupChatModeratorSystemPrompt; +export function getModeratorSystemPrompt(): string { + return groupChatModeratorSystemPrompt; +} /** - * The synthesis prompt for the moderator when reviewing agent responses. + * Gets the synthesis prompt for the moderator when reviewing agent responses. * The moderator decides whether to continue with agents or return to the user. * Loaded from src/prompts/group-chat-moderator-synthesis.md */ -export const MODERATOR_SYNTHESIS_PROMPT = groupChatModeratorSynthesisPrompt; +export function getModeratorSynthesisPrompt(): string { + return groupChatModeratorSynthesisPrompt; +} /** * Spawns a moderator agent for a group chat. diff --git a/src/main/group-chat/group-chat-router.ts b/src/main/group-chat/group-chat-router.ts index b6b6541a..16d56fea 100644 --- a/src/main/group-chat/group-chat-router.ts +++ b/src/main/group-chat/group-chat-router.ts @@ -15,15 +15,15 @@ import { IProcessManager, getModeratorSessionId, isModeratorActive, - MODERATOR_SYSTEM_PROMPT, - MODERATOR_SYNTHESIS_PROMPT, + getModeratorSystemPrompt, + getModeratorSynthesisPrompt, } from './group-chat-moderator'; import { addParticipant, } from './group-chat-agent'; import { AgentDetector } from '../agent-detector'; import { buildAgentArgs, applyAgentConfigOverrides, getContextWindowValue } from '../utils/agent-args'; -import { groupChatParticipantRequestPrompt } from '../prompts'; +import { groupChatParticipantRequestPrompt } from '../../prompts'; // Import emitters from IPC handlers (will be populated after handlers are registered) import { groupChatEmitters } from '../ipc/handlers/groupChat'; @@ -371,7 +371,7 @@ export async function routeUserMessage( `[${m.from}]: ${m.content}` ).join('\n'); - const fullPrompt = `${MODERATOR_SYSTEM_PROMPT} + const fullPrompt = `${getModeratorSystemPrompt()} ## Current Participants: ${participantContext}${availableSessionsContext} @@ -913,9 +913,9 @@ export async function spawnModeratorSynthesis( ? chat.participants.map(p => `- @${p.name} (${p.agentId} session)`).join('\n') : '(No agents currently in this group chat)'; - const synthesisPrompt = `${MODERATOR_SYSTEM_PROMPT} + const synthesisPrompt = `${getModeratorSystemPrompt()} -${MODERATOR_SYNTHESIS_PROMPT} +${getModeratorSynthesisPrompt()} ## Current Participants (you can @mention these for follow-up): ${participantContext} diff --git a/src/main/ipc/handlers/groupChat.ts b/src/main/ipc/handlers/groupChat.ts index 70468caf..8ae110b6 100644 --- a/src/main/ipc/handlers/groupChat.ts +++ b/src/main/ipc/handlers/groupChat.ts @@ -20,6 +20,7 @@ import { listGroupChats, deleteGroupChat, updateGroupChat, + updateParticipant, GroupChat, GroupChatParticipant, addGroupChatHistoryEntry, @@ -27,6 +28,7 @@ import { deleteGroupChatHistoryEntry, clearGroupChatHistory, getGroupChatHistoryFilePath, + getGroupChatDir, } from '../../group-chat/group-chat-storage'; // Group chat history type @@ -66,6 +68,8 @@ import { routeUserMessage } from '../../group-chat/group-chat-router'; // Agent detector import import { AgentDetector } from '../../agent-detector'; +import { buildAgentArgs, applyAgentConfigOverrides, getContextWindowValue } from '../../utils/agent-args'; +import { v4 as uuidv4 } from 'uuid'; const LOG_CONTEXT = '[GroupChat]'; @@ -458,6 +462,148 @@ export function registerGroupChatHandlers(deps: GroupChatHandlerDependencies): v }) ); + // Reset participant context - summarize current session and start fresh + ipcMain.handle( + 'groupChat:resetParticipantContext', + withIpcErrorLogging( + handlerOpts('resetParticipantContext'), + async (groupChatId: string, participantName: string, cwd?: string): Promise<{ newAgentSessionId: string }> => { + logger.info(`Resetting context for participant ${participantName} in ${groupChatId}`, LOG_CONTEXT); + + const chat = await loadGroupChat(groupChatId); + if (!chat) { + throw new Error(`Group chat not found: ${groupChatId}`); + } + + const participant = chat.participants.find(p => p.name === participantName); + if (!participant) { + throw new Error(`Participant not found: ${participantName}`); + } + + const processManager = getProcessManager(); + if (!processManager) { + throw new Error('Process manager not initialized'); + } + + const agentDetector = getAgentDetector(); + if (!agentDetector) { + throw new Error('Agent detector not initialized'); + } + + const agent = await agentDetector.getAgent(participant.agentId); + if (!agent || !agent.available) { + throw new Error(`Agent not available: ${participant.agentId}`); + } + + // Get the group chat folder for file access + const groupChatFolder = getGroupChatDir(groupChatId); + + // Build a context summary prompt to ask the agent to summarize its current state + const summaryPrompt = `You are "${participantName}" in the group chat "${chat.name}". +The shared group chat folder is: ${groupChatFolder} + +Your context window is getting full. Please provide a concise summary of: +1. What you've been working on in this group chat +2. Key decisions made and their rationale +3. Current state of any ongoing tasks +4. Important context that should be preserved for continuity + +This summary will be used to initialize your fresh session so you can continue seamlessly. + +Respond with ONLY the summary text, no additional commentary.`; + + // Spawn a batch process to get the summary + const summarySessionId = `group-chat-${groupChatId}-summary-${participantName}-${Date.now()}`; + const effectiveCwd = cwd || process.env.HOME || '/tmp'; + + const agentConfigValues = getAgentConfig?.(participant.agentId) || {}; + const customEnvVars = getCustomEnvVars?.(participant.agentId); + + const baseArgs = buildAgentArgs(agent, { + baseArgs: [...agent.args], + prompt: summaryPrompt, + cwd: effectiveCwd, + readOnlyMode: true, // Summary is read-only + agentSessionId: participant.agentSessionId, // Use existing session to get context + }); + + const configResolution = applyAgentConfigOverrides(agent, baseArgs, { + agentConfigValues, + }); + + // Collect summary output + let summaryOutput = ''; + const summaryPromise = new Promise((resolve, reject) => { + const timeout = setTimeout(() => { + reject(new Error('Summary generation timed out')); + }, 60000); // 60 second timeout + + // Set up a listener for the summary output + const checkInterval = setInterval(() => { + // The process manager should emit output events + // For now, we'll wait for the process to complete + }, 100); + + // Clean up on completion + setTimeout(() => { + clearInterval(checkInterval); + clearTimeout(timeout); + resolve(summaryOutput || 'No summary available - starting fresh session.'); + }, 10000); // Wait 10 seconds for summary + }); + + // Spawn the summary process + const command = agent.path || agent.command; + processManager.spawn({ + sessionId: summarySessionId, + toolType: participant.agentId, + cwd: effectiveCwd, + command, + args: configResolution.args, + readOnlyMode: true, + prompt: summaryPrompt, + contextWindow: getContextWindowValue(agent, agentConfigValues), + customEnvVars: configResolution.effectiveCustomEnvVars ?? customEnvVars, + }); + + // Wait for summary (with timeout) + try { + await summaryPromise; + } catch (error) { + logger.warn(`Summary generation failed: ${error}`, LOG_CONTEXT); + } + + // Kill the summary process + try { + processManager.kill(summarySessionId); + } catch { + // Ignore kill errors + } + + // Generate a new agent session ID (the actual UUID will be set when the agent responds) + // For now, we clear the old one to signal a fresh start + const newSessionMarker = uuidv4(); + + // Update the participant with a cleared agentSessionId + // The next interaction will establish a new session + await updateParticipant(groupChatId, participantName, { + agentSessionId: undefined, // Clear to force new session + contextUsage: 0, // Reset context usage + }); + + // Emit participants changed event + const updatedChat = await loadGroupChat(groupChatId); + if (updatedChat) { + groupChatEmitters.emitParticipantsChanged?.(groupChatId, updatedChat.participants); + } + + logger.info(`Reset context for ${participantName}, new session marker: ${newSessionMarker}`, LOG_CONTEXT); + + return { newAgentSessionId: newSessionMarker }; + } + ) + ); + // ========== History Handlers ========== // Get all history entries for a group chat diff --git a/src/main/preload.ts b/src/main/preload.ts index b8a6218f..021ae915 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -1116,6 +1116,8 @@ contextBridge.exposeInMainWorld('maestro', { ipcRenderer.invoke('groupChat:sendToParticipant', id, name, message, images), removeParticipant: (id: string, name: string) => ipcRenderer.invoke('groupChat:removeParticipant', id, name), + resetParticipantContext: (id: string, name: string, cwd?: string) => + ipcRenderer.invoke('groupChat:resetParticipantContext', id, name, cwd) as Promise<{ newAgentSessionId: string }>, // History getHistory: (id: string) => @@ -2077,6 +2079,7 @@ export interface MaestroAPI { }>; sendToParticipant: (id: string, name: string, message: string, images?: string[]) => Promise; removeParticipant: (id: string, name: string) => Promise; + resetParticipantContext: (id: string, name: string, cwd?: string) => Promise<{ newAgentSessionId: string }>; // History getHistory: (id: string) => Promise { - // Dismiss group chat and switch to the participant's session - setActiveGroupChatId(null); - setActiveSessionId(sessionId); - }} /> )} @@ -8392,6 +8387,42 @@ export default function MaestroConsole() { onResumeSession={handleResumeSession} onOpenSessionAsTab={handleResumeSession} onOpenAboutModal={() => setAboutModalOpen(true)} + onFileClick={async (relativePath: string) => { + if (!activeSession) return; + const filename = relativePath.split('/').pop() || relativePath; + + // Check if file should be opened externally (PDF, etc.) + if (shouldOpenExternally(filename)) { + const fullPath = `${activeSession.fullPath}/${relativePath}`; + window.maestro.shell.openExternal(`file://${fullPath}`); + return; + } + + try { + const fullPath = `${activeSession.fullPath}/${relativePath}`; + const content = await window.maestro.fs.readFile(fullPath); + const newFile = { + name: filename, + content, + path: fullPath + }; + + // Only add to history if it's a different file than the current one + const currentFile = filePreviewHistory[filePreviewHistoryIndex]; + if (!currentFile || currentFile.path !== fullPath) { + // Add to navigation history (truncate forward history if we're not at the end) + const newHistory = filePreviewHistory.slice(0, filePreviewHistoryIndex + 1); + newHistory.push(newFile); + setFilePreviewHistory(newHistory); + setFilePreviewHistoryIndex(newHistory.length - 1); + } + + setPreviewFile(newFile); + setActiveFocus('main'); + } catch (error) { + console.error('[onFileClick] Failed to read file:', error); + } + }} /> )} diff --git a/src/renderer/components/DocumentsPanel.tsx b/src/renderer/components/DocumentsPanel.tsx index 4cb7cdd3..dece3963 100644 --- a/src/renderer/components/DocumentsPanel.tsx +++ b/src/renderer/components/DocumentsPanel.tsx @@ -667,6 +667,12 @@ export function DocumentsPanel({ const items = [...prev]; if (currentIsCopyDrag) { const original = items[draggedIndex]; + // Enable reset on ALL documents with the same filename (since duplicates require reset) + for (let i = 0; i < items.length; i++) { + if (items[i].filename === original.filename) { + items[i] = { ...items[i], resetOnCompletion: true }; + } + } items.splice(currentDropTargetIndex, 0, { id: generateId(), filename: original.filename, diff --git a/src/renderer/components/GroupChatParticipants.tsx b/src/renderer/components/GroupChatParticipants.tsx index 6b20a926..1aa03bc6 100644 --- a/src/renderer/components/GroupChatParticipants.tsx +++ b/src/renderer/components/GroupChatParticipants.tsx @@ -6,7 +6,7 @@ * This panel replaces the RightPanel when a group chat is active. */ -import { useMemo } from 'react'; +import { useMemo, useCallback } from 'react'; import { PanelRightClose } from 'lucide-react'; import type { Theme, GroupChatParticipant, SessionState, Shortcut } from '../types'; import { ParticipantCard } from './ParticipantCard'; @@ -22,6 +22,8 @@ interface GroupChatParticipantsProps { width: number; setWidthState: (width: number) => void; shortcuts: Record; + /** Group chat ID */ + groupChatId: string; /** Moderator agent ID (e.g., 'claude-code') */ moderatorAgentId: string; /** Moderator internal session ID (for routing) */ @@ -43,6 +45,7 @@ export function GroupChatParticipants({ width, setWidthState, shortcuts, + groupChatId, moderatorAgentId, moderatorSessionId, moderatorAgentSessionId, @@ -74,6 +77,15 @@ export function GroupChatParticipants({ return [...participants].sort((a, b) => a.name.localeCompare(b.name)); }, [participants]); + // Handle context reset for a participant + const handleContextReset = useCallback(async (participantName: string) => { + try { + await window.maestro.groupChat.resetParticipantContext(groupChatId, participantName); + } catch (error) { + console.error(`Failed to reset context for ${participantName}:`, error); + } + }, [groupChatId]); + if (!isOpen) return null; return ( @@ -131,7 +143,7 @@ export function GroupChatParticipants({
- {/* Moderator card always at top */} + {/* Moderator card always at top - no reset for moderator */} )) )} diff --git a/src/renderer/components/GroupChatRightPanel.tsx b/src/renderer/components/GroupChatRightPanel.tsx index de37d799..2301fe2e 100644 --- a/src/renderer/components/GroupChatRightPanel.tsx +++ b/src/renderer/components/GroupChatRightPanel.tsx @@ -53,8 +53,6 @@ interface GroupChatRightPanelProps { onJumpToMessage?: (timestamp: number) => void; /** Callback when participant colors are computed (for sharing with other components) */ onColorsComputed?: (colors: Record) => void; - /** Callback when user clicks to jump to a participant's session */ - onJumpToSession?: (sessionId: string) => void; } export function GroupChatRightPanel({ @@ -77,7 +75,6 @@ export function GroupChatRightPanel({ onTabChange, onJumpToMessage, onColorsComputed, - onJumpToSession, }: GroupChatRightPanelProps): JSX.Element | null { // Color preferences state const [colorPreferences, setColorPreferences] = useState>({}); @@ -161,6 +158,15 @@ export function GroupChatRightPanel({ return [...participants].sort((a, b) => a.name.localeCompare(b.name)); }, [participants]); + // Handle context reset for a participant + const handleContextReset = useCallback(async (participantName: string) => { + try { + await window.maestro.groupChat.resetParticipantContext(groupChatId, participantName); + } catch (error) { + console.error(`Failed to reset context for ${participantName}:`, error); + } + }, [groupChatId]); + // History entries state const [historyEntries, setHistoryEntries] = useState([]); const [isLoadingHistory, setIsLoadingHistory] = useState(true); @@ -285,7 +291,6 @@ export function GroupChatRightPanel({ participant={moderatorParticipant} state={moderatorState} color={participantColors['Moderator']} - onJumpToSession={onJumpToSession} /> {/* Separator between moderator and participants */} @@ -318,7 +323,8 @@ export function GroupChatRightPanel({ participant={participant} state={sessionState} color={participantColors[participant.name]} - onJumpToSession={onJumpToSession} + groupChatId={groupChatId} + onContextReset={handleContextReset} /> ); }) diff --git a/src/renderer/components/HistoryDetailModal.tsx b/src/renderer/components/HistoryDetailModal.tsx index 15bb099e..678d979c 100644 --- a/src/renderer/components/HistoryDetailModal.tsx +++ b/src/renderer/components/HistoryDetailModal.tsx @@ -1,6 +1,7 @@ import React, { useEffect, useRef, useState, useCallback } from 'react'; import { X, Bot, User, Copy, Check, CheckCircle, XCircle, Trash2, Clock, Cpu, Zap, Play, ChevronLeft, ChevronRight } from 'lucide-react'; import type { Theme, HistoryEntry } from '../types'; +import type { FileNode } from '../types/fileTree'; import { useLayerStack } from '../contexts/LayerStackContext'; import { MODAL_PRIORITIES } from '../constants/modalPriorities'; import { formatElapsedTime } from '../utils/formatters'; @@ -27,6 +28,11 @@ interface HistoryDetailModalProps { filteredEntries?: HistoryEntry[]; currentIndex?: number; onNavigate?: (entry: HistoryEntry, index: number) => void; + // File linking props for markdown rendering + fileTree?: FileNode[]; + cwd?: string; + projectRoot?: string; + onFileClick?: (path: string) => void; } // Get context bar color based on usage percentage @@ -46,7 +52,11 @@ export function HistoryDetailModal({ onUpdate, filteredEntries, currentIndex, - onNavigate + onNavigate, + fileTree, + cwd, + projectRoot, + onFileClick }: HistoryDetailModalProps) { const { registerLayer, unregisterLayer, updateLayerHandler } = useLayerStack(); const layerIdRef = useRef(); @@ -403,6 +413,10 @@ export function HistoryDetailModal({ content={cleanResponse} theme={theme} onCopy={(text) => navigator.clipboard.writeText(text)} + fileTree={fileTree} + cwd={cwd} + projectRoot={projectRoot} + onFileClick={onFileClick} />
diff --git a/src/renderer/components/HistoryPanel.tsx b/src/renderer/components/HistoryPanel.tsx index f4be5ca8..95e8d580 100644 --- a/src/renderer/components/HistoryPanel.tsx +++ b/src/renderer/components/HistoryPanel.tsx @@ -399,6 +399,9 @@ interface HistoryPanelProps { onResumeSession?: (agentSessionId: string) => void; onOpenSessionAsTab?: (agentSessionId: string) => void; onOpenAboutModal?: () => void; // For opening About/achievements panel from history entries + // File linking props for history detail modal + fileTree?: any[]; + onFileClick?: (path: string) => void; } export interface HistoryPanelHandle { @@ -414,7 +417,7 @@ const LOAD_MORE_COUNT = 50; // Entries to add when scrolling // Module-level storage for scroll positions (persists across session switches) const scrollPositionCache = new Map(); -export const HistoryPanel = React.memo(forwardRef(function HistoryPanel({ session, theme, onJumpToAgentSession, onResumeSession, onOpenSessionAsTab, onOpenAboutModal }, ref) { +export const HistoryPanel = React.memo(forwardRef(function HistoryPanel({ session, theme, onJumpToAgentSession, onResumeSession, onOpenSessionAsTab, onOpenAboutModal, fileTree, onFileClick }, ref) { const [historyEntries, setHistoryEntries] = useState([]); const [activeFilters, setActiveFilters] = useState>(new Set(['AUTO', 'USER'])); const [isLoading, setIsLoading] = useState(true); @@ -1120,6 +1123,11 @@ export const HistoryPanel = React.memo(forwardRef )} diff --git a/src/renderer/components/MarkdownRenderer.tsx b/src/renderer/components/MarkdownRenderer.tsx index df5e6804..caed8a6a 100644 --- a/src/renderer/components/MarkdownRenderer.tsx +++ b/src/renderer/components/MarkdownRenderer.tsx @@ -1,6 +1,7 @@ import React, { memo, useMemo, useState, useEffect } from 'react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; +import rehypeRaw from 'rehype-raw'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; import { Clipboard, Loader2, ImageOff } from 'lucide-react'; @@ -220,6 +221,7 @@ export const MarkdownRenderer = memo(({ content, theme, onCopy, className = '', > { // Check for maestro-file:// protocol OR data-maestro-file attribute diff --git a/src/renderer/components/ParticipantCard.tsx b/src/renderer/components/ParticipantCard.tsx index 994248fa..1fce8215 100644 --- a/src/renderer/components/ParticipantCard.tsx +++ b/src/renderer/components/ParticipantCard.tsx @@ -5,7 +5,8 @@ * session ID, context usage, stats, and cost. */ -import { MessageSquare, ExternalLink, DollarSign } from 'lucide-react'; +import { MessageSquare, Copy, Check, DollarSign, RotateCcw } from 'lucide-react'; +import { useState, useCallback } from 'react'; import type { Theme, GroupChatParticipant, SessionState } from '../types'; import { getStatusColor } from '../utils/theme'; import { formatCost } from '../utils/formatters'; @@ -15,8 +16,8 @@ interface ParticipantCardProps { participant: GroupChatParticipant; state: SessionState; color?: string; - /** Callback when user clicks session ID pill to jump to the agent */ - onJumpToSession?: (sessionId: string) => void; + groupChatId?: string; + onContextReset?: (participantName: string) => void; } /** @@ -35,17 +36,23 @@ export function ParticipantCard({ participant, state, color, - onJumpToSession, + groupChatId, + onContextReset, }: ParticipantCardProps): JSX.Element { + const [copied, setCopied] = useState(false); + const [isResetting, setIsResetting] = useState(false); + // Use agent's session ID (clean GUID) when available, otherwise show pending const agentSessionId = participant.agentSessionId; const isPending = !agentSessionId; - const handleJumpToSession = () => { - if (onJumpToSession && participant.sessionId) { - onJumpToSession(participant.sessionId); + const copySessionId = useCallback(() => { + if (agentSessionId) { + navigator.clipboard.writeText(agentSessionId); + setCopied(true); + setTimeout(() => setCopied(false), 2000); } - }; + }, [agentSessionId]); // Determine if state should animate (busy or connecting) const shouldPulse = state === 'busy' || state === 'connecting'; @@ -66,6 +73,19 @@ export function ParticipantCard({ // Context usage percentage (default to 0 if not set) const contextUsage = participant.contextUsage ?? 0; + // Show reset button when context usage is 40% or higher + const showResetButton = contextUsage >= 40 && onContextReset && groupChatId && !isResetting; + + const handleReset = useCallback(async () => { + if (!onContextReset || !groupChatId) return; + setIsResetting(true); + try { + await onContextReset(participant.name); + } finally { + setIsResetting(false); + } + }, [onContextReset, groupChatId, participant.name]); + return (
) : ( )}
@@ -190,6 +214,35 @@ export function ParticipantCard({ {formatCost(participant.totalCost).slice(1)} )} + {/* Reset button - shown when context usage >= 40% */} + {showResetButton && ( + + )} + {/* Reset in progress indicator */} + {isResetting && ( + + + Resetting... + + )} ); diff --git a/src/renderer/components/RightPanel.tsx b/src/renderer/components/RightPanel.tsx index 3c84bf5f..7754134f 100644 --- a/src/renderer/components/RightPanel.tsx +++ b/src/renderer/components/RightPanel.tsx @@ -93,6 +93,8 @@ interface RightPanelProps { onResumeSession?: (agentSessionId: string) => void; onOpenSessionAsTab?: (agentSessionId: string) => void; onOpenAboutModal?: () => void; // For opening About/achievements panel from history entries + // File linking callback for history detail modal + onFileClick?: (path: string) => void; } export const RightPanel = forwardRef(function RightPanel(props, ref) { @@ -112,7 +114,7 @@ export const RightPanel = forwardRef(function // Error handling callbacks (Phase 5.10) onSkipCurrentDocument, onAbortBatchOnError, onResumeAfterError, onJumpToAgentSession, onResumeSession, - onOpenSessionAsTab, onOpenAboutModal + onOpenSessionAsTab, onOpenAboutModal, onFileClick } = props; const historyPanelRef = useRef(null); @@ -461,6 +463,8 @@ export const RightPanel = forwardRef(function onResumeSession={onResumeSession} onOpenSessionAsTab={onOpenSessionAsTab} onOpenAboutModal={onOpenAboutModal} + fileTree={filteredFileTree} + onFileClick={onFileClick} /> )} diff --git a/src/renderer/global.d.ts b/src/renderer/global.d.ts index b4c74e40..10370e01 100644 --- a/src/renderer/global.d.ts +++ b/src/renderer/global.d.ts @@ -990,6 +990,7 @@ interface MaestroAPI { }>; sendToParticipant: (id: string, name: string, message: string, images?: string[]) => Promise; removeParticipant: (id: string, name: string) => Promise; + resetParticipantContext: (id: string, name: string, cwd?: string) => Promise<{ newAgentSessionId: string }>; // History getHistory: (id: string) => Promise