OAuth enabled but no valid token found. Starting authentication...

Found expired OAuth token, attempting refresh...
Token refresh successful
## CHANGES

- 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 🧷
This commit is contained in:
Pedram Amini
2025-12-24 12:17:42 -06:00
parent e26ffa849e
commit 93268d3528
20 changed files with 440 additions and 138 deletions

1
.gitignore vendored
View File

@@ -19,6 +19,7 @@ node_modules/
# Build outputs # Build outputs
dist/ dist/
release/ release/
src/generated/
*.log *.log
tmp/ tmp/
scratch/ scratch/

View File

@@ -18,10 +18,11 @@
"scripts": { "scripts": {
"dev": "concurrently \"npm run dev:main\" \"npm run dev:renderer\"", "dev": "concurrently \"npm run dev:main\" \"npm run dev:renderer\"",
"dev:demo": "MAESTRO_DEMO_DIR=/tmp/maestro-demo npm run dev", "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:renderer": "vite",
"dev:web": "vite --config vite.config.web.mts", "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:main": "tsc -p tsconfig.main.json",
"build:cli": "node scripts/build-cli.mjs", "build:cli": "node scripts/build-cli.mjs",
"build:renderer": "vite build", "build:renderer": "vite build",
@@ -97,10 +98,6 @@
{ {
"from": "src/prompts/speckit", "from": "src/prompts/speckit",
"to": "prompts/speckit" "to": "prompts/speckit"
},
{
"from": "src/prompts/group-chat-*.md",
"to": "prompts"
} }
] ]
}, },
@@ -129,10 +126,6 @@
{ {
"from": "src/prompts/speckit", "from": "src/prompts/speckit",
"to": "prompts/speckit" "to": "prompts/speckit"
},
{
"from": "src/prompts/group-chat-*.md",
"to": "prompts"
} }
] ]
}, },
@@ -170,10 +163,6 @@
{ {
"from": "src/prompts/speckit", "from": "src/prompts/speckit",
"to": "prompts/speckit" "to": "prompts/speckit"
},
{
"from": "src/prompts/group-chat-*.md",
"to": "prompts"
} }
] ]
}, },

View File

@@ -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);
});

View File

@@ -20,7 +20,7 @@ import { appendToLog } from './group-chat-log';
import { IProcessManager, isModeratorActive } from './group-chat-moderator'; import { IProcessManager, isModeratorActive } from './group-chat-moderator';
import type { AgentDetector } from '../agent-detector'; import type { AgentDetector } from '../agent-detector';
import { buildAgentArgs, applyAgentConfigOverrides, getContextWindowValue } from '../utils/agent-args'; import { buildAgentArgs, applyAgentConfigOverrides, getContextWindowValue } from '../utils/agent-args';
import { groupChatParticipantPrompt } from '../prompts'; import { groupChatParticipantPrompt } from '../../prompts';
/** /**
* In-memory store for active participant sessions. * In-memory store for active participant sessions.

View File

@@ -17,7 +17,7 @@ import { appendToLog, readLog } from './group-chat-log';
import { import {
groupChatModeratorSystemPrompt, groupChatModeratorSystemPrompt,
groupChatModeratorSynthesisPrompt, groupChatModeratorSynthesisPrompt,
} from '../prompts'; } from '../../prompts';
/** /**
* Interface for the process manager dependency. * 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. * This is combined with participant info and chat history in routeUserMessage.
* Loaded from src/prompts/group-chat-moderator-system.md * 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. * The moderator decides whether to continue with agents or return to the user.
* Loaded from src/prompts/group-chat-moderator-synthesis.md * 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. * Spawns a moderator agent for a group chat.

View File

@@ -15,15 +15,15 @@ import {
IProcessManager, IProcessManager,
getModeratorSessionId, getModeratorSessionId,
isModeratorActive, isModeratorActive,
MODERATOR_SYSTEM_PROMPT, getModeratorSystemPrompt,
MODERATOR_SYNTHESIS_PROMPT, getModeratorSynthesisPrompt,
} from './group-chat-moderator'; } from './group-chat-moderator';
import { import {
addParticipant, addParticipant,
} from './group-chat-agent'; } from './group-chat-agent';
import { AgentDetector } from '../agent-detector'; import { AgentDetector } from '../agent-detector';
import { buildAgentArgs, applyAgentConfigOverrides, getContextWindowValue } from '../utils/agent-args'; 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 emitters from IPC handlers (will be populated after handlers are registered)
import { groupChatEmitters } from '../ipc/handlers/groupChat'; import { groupChatEmitters } from '../ipc/handlers/groupChat';
@@ -371,7 +371,7 @@ export async function routeUserMessage(
`[${m.from}]: ${m.content}` `[${m.from}]: ${m.content}`
).join('\n'); ).join('\n');
const fullPrompt = `${MODERATOR_SYSTEM_PROMPT} const fullPrompt = `${getModeratorSystemPrompt()}
## Current Participants: ## Current Participants:
${participantContext}${availableSessionsContext} ${participantContext}${availableSessionsContext}
@@ -913,9 +913,9 @@ export async function spawnModeratorSynthesis(
? chat.participants.map(p => `- @${p.name} (${p.agentId} session)`).join('\n') ? chat.participants.map(p => `- @${p.name} (${p.agentId} session)`).join('\n')
: '(No agents currently in this group chat)'; : '(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): ## Current Participants (you can @mention these for follow-up):
${participantContext} ${participantContext}

View File

@@ -20,6 +20,7 @@ import {
listGroupChats, listGroupChats,
deleteGroupChat, deleteGroupChat,
updateGroupChat, updateGroupChat,
updateParticipant,
GroupChat, GroupChat,
GroupChatParticipant, GroupChatParticipant,
addGroupChatHistoryEntry, addGroupChatHistoryEntry,
@@ -27,6 +28,7 @@ import {
deleteGroupChatHistoryEntry, deleteGroupChatHistoryEntry,
clearGroupChatHistory, clearGroupChatHistory,
getGroupChatHistoryFilePath, getGroupChatHistoryFilePath,
getGroupChatDir,
} from '../../group-chat/group-chat-storage'; } from '../../group-chat/group-chat-storage';
// Group chat history type // Group chat history type
@@ -66,6 +68,8 @@ import { routeUserMessage } from '../../group-chat/group-chat-router';
// Agent detector import // Agent detector import
import { AgentDetector } from '../../agent-detector'; import { AgentDetector } from '../../agent-detector';
import { buildAgentArgs, applyAgentConfigOverrides, getContextWindowValue } from '../../utils/agent-args';
import { v4 as uuidv4 } from 'uuid';
const LOG_CONTEXT = '[GroupChat]'; 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<string>((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 ========== // ========== History Handlers ==========
// Get all history entries for a group chat // Get all history entries for a group chat

View File

@@ -1116,6 +1116,8 @@ contextBridge.exposeInMainWorld('maestro', {
ipcRenderer.invoke('groupChat:sendToParticipant', id, name, message, images), ipcRenderer.invoke('groupChat:sendToParticipant', id, name, message, images),
removeParticipant: (id: string, name: string) => removeParticipant: (id: string, name: string) =>
ipcRenderer.invoke('groupChat:removeParticipant', id, name), 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 // History
getHistory: (id: string) => getHistory: (id: string) =>
@@ -2077,6 +2079,7 @@ export interface MaestroAPI {
}>; }>;
sendToParticipant: (id: string, name: string, message: string, images?: string[]) => Promise<void>; sendToParticipant: (id: string, name: string, message: string, images?: string[]) => Promise<void>;
removeParticipant: (id: string, name: string) => Promise<void>; removeParticipant: (id: string, name: string) => Promise<void>;
resetParticipantContext: (id: string, name: string, cwd?: string) => Promise<{ newAgentSessionId: string }>;
// History // History
getHistory: (id: string) => Promise<Array<{ getHistory: (id: string) => Promise<Array<{

View File

@@ -1,59 +0,0 @@
/**
* Prompt loader for the main process.
*
* This module reads prompt markdown files from src/prompts/ at runtime.
* Unlike the renderer (which uses Vite's ?raw imports), the main process
* uses Node.js fs to read files synchronously at module load time.
*
* This ensures all prompts live in src/prompts/ as markdown files for
* easy discovery and editing, while still being usable in the main process.
*/
import * as fs from 'fs';
import * as path from 'path';
import { app } from 'electron';
/**
* Resolves the path to a prompt file.
* In development, reads from src/prompts/
* In production, reads from the app's resources
*/
function getPromptsDir(): string {
// In development, __dirname is dist/main, so go up to project root
// In production, we need to handle the packaged app structure
const isDev = !app.isPackaged;
if (isDev) {
// Development: src/prompts is relative to project root
return path.resolve(__dirname, '../../src/prompts');
} else {
// Production: prompts are copied to resources
return path.join(process.resourcesPath, 'prompts');
}
}
/**
* Reads a prompt file and returns its contents.
* Throws an error if the file doesn't exist.
*/
function loadPrompt(filename: string): string {
const promptsDir = getPromptsDir();
const filePath = path.join(promptsDir, filename);
try {
return fs.readFileSync(filePath, 'utf8');
} catch (error) {
// In production, if the file is missing, provide a helpful error
throw new Error(
`Failed to load prompt file: ${filename}. ` +
`Expected at: ${filePath}. ` +
`Error: ${error instanceof Error ? error.message : String(error)}`
);
}
}
// Group Chat Prompts
export const groupChatModeratorSystemPrompt = loadPrompt('group-chat-moderator-system.md');
export const groupChatModeratorSynthesisPrompt = loadPrompt('group-chat-moderator-synthesis.md');
export const groupChatParticipantPrompt = loadPrompt('group-chat-participant.md');
export const groupChatParticipantRequestPrompt = loadPrompt('group-chat-participant-request.md');

View File

@@ -1,34 +1,12 @@
/** /**
* Centralized prompts module * Centralized prompts module
* *
* All built-in prompts are stored as .md files in this directory * All prompts are stored as .md files in this directory and compiled
* and imported at build time using Vite's ?raw suffix. * to TypeScript at build time by scripts/generate-prompts.mjs.
*
* The generated file is at src/generated/prompts.ts
*/ */
// Wizard prompts
import wizardSystemPrompt from './wizard-system.md?raw';
import wizardSystemContinuationPrompt from './wizard-system-continuation.md?raw';
import wizardDocumentGenerationPrompt from './wizard-document-generation.md?raw';
// AutoRun prompts
import autorunDefaultPrompt from './autorun-default.md?raw';
import autorunSynopsisPrompt from './autorun-synopsis.md?raw';
// Input processing prompts
import imageOnlyDefaultPrompt from './image-only-default.md?raw';
// Built-in command prompts
import commitCommandPrompt from './commit-command.md?raw';
// Maestro system prompt (injected at agent startup)
import maestroSystemPrompt from './maestro-system-prompt.md?raw';
// Group chat prompts (used by main process via src/main/prompts.ts)
import groupChatModeratorSystemPrompt from './group-chat-moderator-system.md?raw';
import groupChatModeratorSynthesisPrompt from './group-chat-moderator-synthesis.md?raw';
import groupChatParticipantPrompt from './group-chat-participant.md?raw';
import groupChatParticipantRequestPrompt from './group-chat-participant-request.md?raw';
export { export {
// Wizard // Wizard
wizardSystemPrompt, wizardSystemPrompt,
@@ -53,4 +31,4 @@ export {
groupChatModeratorSynthesisPrompt, groupChatModeratorSynthesisPrompt,
groupChatParticipantPrompt, groupChatParticipantPrompt,
groupChatParticipantRequestPrompt, groupChatParticipantRequestPrompt,
}; } from '../generated/prompts';

View File

@@ -7785,11 +7785,6 @@ export default function MaestroConsole() {
onTabChange={handleGroupChatRightTabChange} onTabChange={handleGroupChatRightTabChange}
onJumpToMessage={handleJumpToGroupChatMessage} onJumpToMessage={handleJumpToGroupChatMessage}
onColorsComputed={setGroupChatParticipantColors} onColorsComputed={setGroupChatParticipantColors}
onJumpToSession={(sessionId) => {
// Dismiss group chat and switch to the participant's session
setActiveGroupChatId(null);
setActiveSessionId(sessionId);
}}
/> />
</> </>
)} )}
@@ -8392,6 +8387,42 @@ export default function MaestroConsole() {
onResumeSession={handleResumeSession} onResumeSession={handleResumeSession}
onOpenSessionAsTab={handleResumeSession} onOpenSessionAsTab={handleResumeSession}
onOpenAboutModal={() => setAboutModalOpen(true)} 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);
}
}}
/> />
</ErrorBoundary> </ErrorBoundary>
)} )}

View File

@@ -667,6 +667,12 @@ export function DocumentsPanel({
const items = [...prev]; const items = [...prev];
if (currentIsCopyDrag) { if (currentIsCopyDrag) {
const original = items[draggedIndex]; 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, { items.splice(currentDropTargetIndex, 0, {
id: generateId(), id: generateId(),
filename: original.filename, filename: original.filename,

View File

@@ -6,7 +6,7 @@
* This panel replaces the RightPanel when a group chat is active. * 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 { PanelRightClose } from 'lucide-react';
import type { Theme, GroupChatParticipant, SessionState, Shortcut } from '../types'; import type { Theme, GroupChatParticipant, SessionState, Shortcut } from '../types';
import { ParticipantCard } from './ParticipantCard'; import { ParticipantCard } from './ParticipantCard';
@@ -22,6 +22,8 @@ interface GroupChatParticipantsProps {
width: number; width: number;
setWidthState: (width: number) => void; setWidthState: (width: number) => void;
shortcuts: Record<string, Shortcut>; shortcuts: Record<string, Shortcut>;
/** Group chat ID */
groupChatId: string;
/** Moderator agent ID (e.g., 'claude-code') */ /** Moderator agent ID (e.g., 'claude-code') */
moderatorAgentId: string; moderatorAgentId: string;
/** Moderator internal session ID (for routing) */ /** Moderator internal session ID (for routing) */
@@ -43,6 +45,7 @@ export function GroupChatParticipants({
width, width,
setWidthState, setWidthState,
shortcuts, shortcuts,
groupChatId,
moderatorAgentId, moderatorAgentId,
moderatorSessionId, moderatorSessionId,
moderatorAgentSessionId, moderatorAgentSessionId,
@@ -74,6 +77,15 @@ export function GroupChatParticipants({
return [...participants].sort((a, b) => a.name.localeCompare(b.name)); return [...participants].sort((a, b) => a.name.localeCompare(b.name));
}, [participants]); }, [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; if (!isOpen) return null;
return ( return (
@@ -131,7 +143,7 @@ export function GroupChatParticipants({
</div> </div>
<div className="flex-1 overflow-y-auto p-3 space-y-3"> <div className="flex-1 overflow-y-auto p-3 space-y-3">
{/* Moderator card always at top */} {/* Moderator card always at top - no reset for moderator */}
<ParticipantCard <ParticipantCard
key="moderator" key="moderator"
theme={theme} theme={theme}
@@ -166,6 +178,8 @@ export function GroupChatParticipants({
participant={participant} participant={participant}
state={participantStates.get(participant.sessionId) || 'idle'} state={participantStates.get(participant.sessionId) || 'idle'}
color={participantColors[participant.name]} color={participantColors[participant.name]}
groupChatId={groupChatId}
onContextReset={handleContextReset}
/> />
)) ))
)} )}

View File

@@ -53,8 +53,6 @@ interface GroupChatRightPanelProps {
onJumpToMessage?: (timestamp: number) => void; onJumpToMessage?: (timestamp: number) => void;
/** Callback when participant colors are computed (for sharing with other components) */ /** Callback when participant colors are computed (for sharing with other components) */
onColorsComputed?: (colors: Record<string, string>) => void; onColorsComputed?: (colors: Record<string, string>) => void;
/** Callback when user clicks to jump to a participant's session */
onJumpToSession?: (sessionId: string) => void;
} }
export function GroupChatRightPanel({ export function GroupChatRightPanel({
@@ -77,7 +75,6 @@ export function GroupChatRightPanel({
onTabChange, onTabChange,
onJumpToMessage, onJumpToMessage,
onColorsComputed, onColorsComputed,
onJumpToSession,
}: GroupChatRightPanelProps): JSX.Element | null { }: GroupChatRightPanelProps): JSX.Element | null {
// Color preferences state // Color preferences state
const [colorPreferences, setColorPreferences] = useState<Record<string, number>>({}); const [colorPreferences, setColorPreferences] = useState<Record<string, number>>({});
@@ -161,6 +158,15 @@ export function GroupChatRightPanel({
return [...participants].sort((a, b) => a.name.localeCompare(b.name)); return [...participants].sort((a, b) => a.name.localeCompare(b.name));
}, [participants]); }, [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 // History entries state
const [historyEntries, setHistoryEntries] = useState<GroupChatHistoryEntry[]>([]); const [historyEntries, setHistoryEntries] = useState<GroupChatHistoryEntry[]>([]);
const [isLoadingHistory, setIsLoadingHistory] = useState(true); const [isLoadingHistory, setIsLoadingHistory] = useState(true);
@@ -285,7 +291,6 @@ export function GroupChatRightPanel({
participant={moderatorParticipant} participant={moderatorParticipant}
state={moderatorState} state={moderatorState}
color={participantColors['Moderator']} color={participantColors['Moderator']}
onJumpToSession={onJumpToSession}
/> />
{/* Separator between moderator and participants */} {/* Separator between moderator and participants */}
@@ -318,7 +323,8 @@ export function GroupChatRightPanel({
participant={participant} participant={participant}
state={sessionState} state={sessionState}
color={participantColors[participant.name]} color={participantColors[participant.name]}
onJumpToSession={onJumpToSession} groupChatId={groupChatId}
onContextReset={handleContextReset}
/> />
); );
}) })

View File

@@ -1,6 +1,7 @@
import React, { useEffect, useRef, useState, useCallback } from 'react'; 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 { 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 { Theme, HistoryEntry } from '../types';
import type { FileNode } from '../types/fileTree';
import { useLayerStack } from '../contexts/LayerStackContext'; import { useLayerStack } from '../contexts/LayerStackContext';
import { MODAL_PRIORITIES } from '../constants/modalPriorities'; import { MODAL_PRIORITIES } from '../constants/modalPriorities';
import { formatElapsedTime } from '../utils/formatters'; import { formatElapsedTime } from '../utils/formatters';
@@ -27,6 +28,11 @@ interface HistoryDetailModalProps {
filteredEntries?: HistoryEntry[]; filteredEntries?: HistoryEntry[];
currentIndex?: number; currentIndex?: number;
onNavigate?: (entry: HistoryEntry, index: number) => void; 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 // Get context bar color based on usage percentage
@@ -46,7 +52,11 @@ export function HistoryDetailModal({
onUpdate, onUpdate,
filteredEntries, filteredEntries,
currentIndex, currentIndex,
onNavigate onNavigate,
fileTree,
cwd,
projectRoot,
onFileClick
}: HistoryDetailModalProps) { }: HistoryDetailModalProps) {
const { registerLayer, unregisterLayer, updateLayerHandler } = useLayerStack(); const { registerLayer, unregisterLayer, updateLayerHandler } = useLayerStack();
const layerIdRef = useRef<string>(); const layerIdRef = useRef<string>();
@@ -403,6 +413,10 @@ export function HistoryDetailModal({
content={cleanResponse} content={cleanResponse}
theme={theme} theme={theme}
onCopy={(text) => navigator.clipboard.writeText(text)} onCopy={(text) => navigator.clipboard.writeText(text)}
fileTree={fileTree}
cwd={cwd}
projectRoot={projectRoot}
onFileClick={onFileClick}
/> />
</div> </div>

View File

@@ -399,6 +399,9 @@ interface HistoryPanelProps {
onResumeSession?: (agentSessionId: string) => void; onResumeSession?: (agentSessionId: string) => void;
onOpenSessionAsTab?: (agentSessionId: string) => void; onOpenSessionAsTab?: (agentSessionId: string) => void;
onOpenAboutModal?: () => void; // For opening About/achievements panel from history entries 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 { 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) // Module-level storage for scroll positions (persists across session switches)
const scrollPositionCache = new Map<string, number>(); const scrollPositionCache = new Map<string, number>();
export const HistoryPanel = React.memo(forwardRef<HistoryPanelHandle, HistoryPanelProps>(function HistoryPanel({ session, theme, onJumpToAgentSession, onResumeSession, onOpenSessionAsTab, onOpenAboutModal }, ref) { export const HistoryPanel = React.memo(forwardRef<HistoryPanelHandle, HistoryPanelProps>(function HistoryPanel({ session, theme, onJumpToAgentSession, onResumeSession, onOpenSessionAsTab, onOpenAboutModal, fileTree, onFileClick }, ref) {
const [historyEntries, setHistoryEntries] = useState<HistoryEntry[]>([]); const [historyEntries, setHistoryEntries] = useState<HistoryEntry[]>([]);
const [activeFilters, setActiveFilters] = useState<Set<HistoryEntryType>>(new Set(['AUTO', 'USER'])); const [activeFilters, setActiveFilters] = useState<Set<HistoryEntryType>>(new Set(['AUTO', 'USER']));
const [isLoading, setIsLoading] = useState(true); const [isLoading, setIsLoading] = useState(true);
@@ -1120,6 +1123,11 @@ export const HistoryPanel = React.memo(forwardRef<HistoryPanelHandle, HistoryPan
setDisplayCount(Math.min(index + LOAD_MORE_COUNT, allFilteredEntries.length)); setDisplayCount(Math.min(index + LOAD_MORE_COUNT, allFilteredEntries.length));
} }
}} }}
// File linking props for markdown rendering
fileTree={fileTree}
cwd={session.cwd}
projectRoot={session.projectRoot}
onFileClick={onFileClick}
/> />
)} )}

View File

@@ -1,6 +1,7 @@
import React, { memo, useMemo, useState, useEffect } from 'react'; import React, { memo, useMemo, useState, useEffect } from 'react';
import ReactMarkdown from 'react-markdown'; import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm'; import remarkGfm from 'remark-gfm';
import rehypeRaw from 'rehype-raw';
import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter'; import { Prism as SyntaxHighlighter } from 'react-syntax-highlighter';
import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism'; import { vscDarkPlus } from 'react-syntax-highlighter/dist/esm/styles/prism';
import { Clipboard, Loader2, ImageOff } from 'lucide-react'; import { Clipboard, Loader2, ImageOff } from 'lucide-react';
@@ -220,6 +221,7 @@ export const MarkdownRenderer = memo(({ content, theme, onCopy, className = '',
> >
<ReactMarkdown <ReactMarkdown
remarkPlugins={remarkPlugins} remarkPlugins={remarkPlugins}
rehypePlugins={[rehypeRaw]}
components={{ components={{
a: ({ node, href, children, ...props }) => { a: ({ node, href, children, ...props }) => {
// Check for maestro-file:// protocol OR data-maestro-file attribute // Check for maestro-file:// protocol OR data-maestro-file attribute

View File

@@ -5,7 +5,8 @@
* session ID, context usage, stats, and cost. * 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 type { Theme, GroupChatParticipant, SessionState } from '../types';
import { getStatusColor } from '../utils/theme'; import { getStatusColor } from '../utils/theme';
import { formatCost } from '../utils/formatters'; import { formatCost } from '../utils/formatters';
@@ -15,8 +16,8 @@ interface ParticipantCardProps {
participant: GroupChatParticipant; participant: GroupChatParticipant;
state: SessionState; state: SessionState;
color?: string; color?: string;
/** Callback when user clicks session ID pill to jump to the agent */ groupChatId?: string;
onJumpToSession?: (sessionId: string) => void; onContextReset?: (participantName: string) => void;
} }
/** /**
@@ -35,17 +36,23 @@ export function ParticipantCard({
participant, participant,
state, state,
color, color,
onJumpToSession, groupChatId,
onContextReset,
}: ParticipantCardProps): JSX.Element { }: 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 // Use agent's session ID (clean GUID) when available, otherwise show pending
const agentSessionId = participant.agentSessionId; const agentSessionId = participant.agentSessionId;
const isPending = !agentSessionId; const isPending = !agentSessionId;
const handleJumpToSession = () => { const copySessionId = useCallback(() => {
if (onJumpToSession && participant.sessionId) { if (agentSessionId) {
onJumpToSession(participant.sessionId); navigator.clipboard.writeText(agentSessionId);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} }
}; }, [agentSessionId]);
// Determine if state should animate (busy or connecting) // Determine if state should animate (busy or connecting)
const shouldPulse = state === 'busy' || state === 'connecting'; const shouldPulse = state === 'busy' || state === 'connecting';
@@ -66,6 +73,19 @@ export function ParticipantCard({
// Context usage percentage (default to 0 if not set) // Context usage percentage (default to 0 if not set)
const contextUsage = participant.contextUsage ?? 0; 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 ( return (
<div <div
className="rounded-lg border p-3" className="rounded-lg border p-3"
@@ -105,19 +125,23 @@ export function ParticipantCard({
</span> </span>
) : ( ) : (
<button <button
onClick={handleJumpToSession} onClick={copySessionId}
className="flex items-center gap-1 text-[10px] px-2 py-0.5 rounded-full hover:opacity-80 transition-opacity cursor-pointer shrink-0" className="flex items-center gap-1 text-[10px] px-2 py-0.5 rounded-full hover:opacity-80 transition-opacity cursor-pointer shrink-0"
style={{ style={{
backgroundColor: `${theme.colors.accent}20`, backgroundColor: `${theme.colors.accent}20`,
color: theme.colors.accent, color: theme.colors.accent,
border: `1px solid ${theme.colors.accent}40`, border: `1px solid ${theme.colors.accent}40`,
}} }}
title={`Session: ${agentSessionId}\nClick to jump to agent`} title={`Session: ${agentSessionId}\nClick to copy`}
> >
<span className="font-mono"> <span className="font-mono">
{agentSessionId.slice(0, 8).toUpperCase()} {agentSessionId.slice(0, 8).toUpperCase()}
</span> </span>
<ExternalLink className="w-2.5 h-2.5" /> {copied ? (
<Check className="w-2.5 h-2.5" />
) : (
<Copy className="w-2.5 h-2.5" />
)}
</button> </button>
)} )}
</div> </div>
@@ -190,6 +214,35 @@ export function ParticipantCard({
{formatCost(participant.totalCost).slice(1)} {formatCost(participant.totalCost).slice(1)}
</span> </span>
)} )}
{/* Reset button - shown when context usage >= 40% */}
{showResetButton && (
<button
onClick={handleReset}
className="flex items-center gap-0.5 text-[10px] px-1.5 py-0.5 rounded shrink-0 hover:opacity-80 transition-opacity cursor-pointer"
style={{
backgroundColor: `${theme.colors.warning}20`,
color: theme.colors.warning,
border: `1px solid ${theme.colors.warning}40`,
}}
title="Reset context: Summarize current session and start fresh"
>
<RotateCcw className="w-3 h-3" />
Reset
</button>
)}
{/* Reset in progress indicator */}
{isResetting && (
<span
className="flex items-center gap-0.5 text-[10px] px-1.5 py-0.5 rounded shrink-0 animate-pulse"
style={{
backgroundColor: `${theme.colors.warning}20`,
color: theme.colors.warning,
}}
>
<RotateCcw className="w-3 h-3 animate-spin" />
Resetting...
</span>
)}
</div> </div>
</div> </div>
); );

View File

@@ -93,6 +93,8 @@ interface RightPanelProps {
onResumeSession?: (agentSessionId: string) => void; onResumeSession?: (agentSessionId: string) => void;
onOpenSessionAsTab?: (agentSessionId: string) => void; onOpenSessionAsTab?: (agentSessionId: string) => void;
onOpenAboutModal?: () => void; // For opening About/achievements panel from history entries 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<RightPanelHandle, RightPanelProps>(function RightPanel(props, ref) { export const RightPanel = forwardRef<RightPanelHandle, RightPanelProps>(function RightPanel(props, ref) {
@@ -112,7 +114,7 @@ export const RightPanel = forwardRef<RightPanelHandle, RightPanelProps>(function
// Error handling callbacks (Phase 5.10) // Error handling callbacks (Phase 5.10)
onSkipCurrentDocument, onAbortBatchOnError, onResumeAfterError, onSkipCurrentDocument, onAbortBatchOnError, onResumeAfterError,
onJumpToAgentSession, onResumeSession, onJumpToAgentSession, onResumeSession,
onOpenSessionAsTab, onOpenAboutModal onOpenSessionAsTab, onOpenAboutModal, onFileClick
} = props; } = props;
const historyPanelRef = useRef<HistoryPanelHandle>(null); const historyPanelRef = useRef<HistoryPanelHandle>(null);
@@ -461,6 +463,8 @@ export const RightPanel = forwardRef<RightPanelHandle, RightPanelProps>(function
onResumeSession={onResumeSession} onResumeSession={onResumeSession}
onOpenSessionAsTab={onOpenSessionAsTab} onOpenSessionAsTab={onOpenSessionAsTab}
onOpenAboutModal={onOpenAboutModal} onOpenAboutModal={onOpenAboutModal}
fileTree={filteredFileTree}
onFileClick={onFileClick}
/> />
</div> </div>
)} )}

View File

@@ -990,6 +990,7 @@ interface MaestroAPI {
}>; }>;
sendToParticipant: (id: string, name: string, message: string, images?: string[]) => Promise<void>; sendToParticipant: (id: string, name: string, message: string, images?: string[]) => Promise<void>;
removeParticipant: (id: string, name: string) => Promise<void>; removeParticipant: (id: string, name: string) => Promise<void>;
resetParticipantContext: (id: string, name: string, cwd?: string) => Promise<{ newAgentSessionId: string }>;
// History // History
getHistory: (id: string) => Promise<Array<{ getHistory: (id: string) => Promise<Array<{
id: string; id: string;