mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
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:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -19,6 +19,7 @@ node_modules/
|
||||
# Build outputs
|
||||
dist/
|
||||
release/
|
||||
src/generated/
|
||||
*.log
|
||||
tmp/
|
||||
scratch/
|
||||
|
||||
17
package.json
17
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"
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
101
scripts/generate-prompts.mjs
Normal file
101
scripts/generate-prompts.mjs
Normal 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);
|
||||
});
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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<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 ==========
|
||||
|
||||
// Get all history entries for a group chat
|
||||
|
||||
@@ -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<void>;
|
||||
removeParticipant: (id: string, name: string) => Promise<void>;
|
||||
resetParticipantContext: (id: string, name: string, cwd?: string) => Promise<{ newAgentSessionId: string }>;
|
||||
|
||||
// History
|
||||
getHistory: (id: string) => Promise<Array<{
|
||||
|
||||
@@ -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');
|
||||
@@ -1,34 +1,12 @@
|
||||
/**
|
||||
* Centralized prompts module
|
||||
*
|
||||
* All built-in prompts are stored as .md files in this directory
|
||||
* and imported at build time using Vite's ?raw suffix.
|
||||
* All prompts are stored as .md files in this directory and compiled
|
||||
* 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 {
|
||||
// Wizard
|
||||
wizardSystemPrompt,
|
||||
@@ -53,4 +31,4 @@ export {
|
||||
groupChatModeratorSynthesisPrompt,
|
||||
groupChatParticipantPrompt,
|
||||
groupChatParticipantRequestPrompt,
|
||||
};
|
||||
} from '../generated/prompts';
|
||||
|
||||
@@ -7785,11 +7785,6 @@ export default function MaestroConsole() {
|
||||
onTabChange={handleGroupChatRightTabChange}
|
||||
onJumpToMessage={handleJumpToGroupChatMessage}
|
||||
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}
|
||||
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);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
)}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string, Shortcut>;
|
||||
/** 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({
|
||||
</div>
|
||||
|
||||
<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
|
||||
key="moderator"
|
||||
theme={theme}
|
||||
@@ -166,6 +178,8 @@ export function GroupChatParticipants({
|
||||
participant={participant}
|
||||
state={participantStates.get(participant.sessionId) || 'idle'}
|
||||
color={participantColors[participant.name]}
|
||||
groupChatId={groupChatId}
|
||||
onContextReset={handleContextReset}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
|
||||
@@ -53,8 +53,6 @@ interface GroupChatRightPanelProps {
|
||||
onJumpToMessage?: (timestamp: number) => void;
|
||||
/** Callback when participant colors are computed (for sharing with other components) */
|
||||
onColorsComputed?: (colors: Record<string, string>) => 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<Record<string, number>>({});
|
||||
@@ -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<GroupChatHistoryEntry[]>([]);
|
||||
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}
|
||||
/>
|
||||
);
|
||||
})
|
||||
|
||||
@@ -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<string>();
|
||||
@@ -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}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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<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 [activeFilters, setActiveFilters] = useState<Set<HistoryEntryType>>(new Set(['AUTO', 'USER']));
|
||||
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));
|
||||
}
|
||||
}}
|
||||
// File linking props for markdown rendering
|
||||
fileTree={fileTree}
|
||||
cwd={session.cwd}
|
||||
projectRoot={session.projectRoot}
|
||||
onFileClick={onFileClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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 = '',
|
||||
>
|
||||
<ReactMarkdown
|
||||
remarkPlugins={remarkPlugins}
|
||||
rehypePlugins={[rehypeRaw]}
|
||||
components={{
|
||||
a: ({ node, href, children, ...props }) => {
|
||||
// Check for maestro-file:// protocol OR data-maestro-file attribute
|
||||
|
||||
@@ -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 (
|
||||
<div
|
||||
className="rounded-lg border p-3"
|
||||
@@ -105,19 +125,23 @@ export function ParticipantCard({
|
||||
</span>
|
||||
) : (
|
||||
<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"
|
||||
style={{
|
||||
backgroundColor: `${theme.colors.accent}20`,
|
||||
color: theme.colors.accent,
|
||||
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">
|
||||
{agentSessionId.slice(0, 8).toUpperCase()}
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
@@ -190,6 +214,35 @@ export function ParticipantCard({
|
||||
{formatCost(participant.totalCost).slice(1)}
|
||||
</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>
|
||||
);
|
||||
|
||||
@@ -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<RightPanelHandle, RightPanelProps>(function RightPanel(props, ref) {
|
||||
@@ -112,7 +114,7 @@ export const RightPanel = forwardRef<RightPanelHandle, RightPanelProps>(function
|
||||
// Error handling callbacks (Phase 5.10)
|
||||
onSkipCurrentDocument, onAbortBatchOnError, onResumeAfterError,
|
||||
onJumpToAgentSession, onResumeSession,
|
||||
onOpenSessionAsTab, onOpenAboutModal
|
||||
onOpenSessionAsTab, onOpenAboutModal, onFileClick
|
||||
} = props;
|
||||
|
||||
const historyPanelRef = useRef<HistoryPanelHandle>(null);
|
||||
@@ -461,6 +463,8 @@ export const RightPanel = forwardRef<RightPanelHandle, RightPanelProps>(function
|
||||
onResumeSession={onResumeSession}
|
||||
onOpenSessionAsTab={onOpenSessionAsTab}
|
||||
onOpenAboutModal={onOpenAboutModal}
|
||||
fileTree={filteredFileTree}
|
||||
onFileClick={onFileClick}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
1
src/renderer/global.d.ts
vendored
1
src/renderer/global.d.ts
vendored
@@ -990,6 +990,7 @@ interface MaestroAPI {
|
||||
}>;
|
||||
sendToParticipant: (id: string, name: string, message: string, images?: string[]) => Promise<void>;
|
||||
removeParticipant: (id: string, name: string) => Promise<void>;
|
||||
resetParticipantContext: (id: string, name: string, cwd?: string) => Promise<{ newAgentSessionId: string }>;
|
||||
// History
|
||||
getHistory: (id: string) => Promise<Array<{
|
||||
id: string;
|
||||
|
||||
Reference in New Issue
Block a user