mirror of
https://github.com/jlengrand/Maestro.git
synced 2026-03-10 08:31:19 +00:00
I'd be happy to help you create a clean update summary for your GitHub project! However, I notice that you haven't provided the actual input content after "INPUT:" at the end of your message.
Could you please share: - The commit history, pull requests, or changelog since the last release - Any release notes or development updates - Or any other information about what has changed in your project Once you provide that information, I'll create an exciting CHANGES section with clean 10-word bullets and relevant emojis, formatted in Markdown with HTML links as requested.
This commit is contained in:
78
src/cli/commands/show-playbook.ts
Normal file
78
src/cli/commands/show-playbook.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
// Show playbook command
|
||||
// Displays detailed information about a specific playbook
|
||||
|
||||
import { findPlaybookById } from '../services/playbooks';
|
||||
import { getSessionById } from '../services/storage';
|
||||
import { readDocAndGetTasks } from '../services/agent-spawner';
|
||||
import { formatPlaybookDetail, formatError } from '../output/formatter';
|
||||
|
||||
interface ShowPlaybookOptions {
|
||||
json?: boolean;
|
||||
}
|
||||
|
||||
export function showPlaybook(playbookId: string, options: ShowPlaybookOptions): void {
|
||||
try {
|
||||
// Find playbook across all agents
|
||||
const { playbook, agentId } = findPlaybookById(playbookId);
|
||||
const agent = getSessionById(agentId);
|
||||
|
||||
if (!agent) {
|
||||
throw new Error(`Agent not found: ${agentId}`);
|
||||
}
|
||||
|
||||
const folderPath = agent.autoRunFolderPath;
|
||||
|
||||
// Get task counts for each document
|
||||
const documentDetails = playbook.documents.map((doc) => {
|
||||
let tasks: string[] = [];
|
||||
if (folderPath) {
|
||||
const result = readDocAndGetTasks(folderPath, doc.filename);
|
||||
tasks = result.tasks;
|
||||
}
|
||||
return {
|
||||
filename: doc.filename.endsWith('.md') ? doc.filename : `${doc.filename}.md`,
|
||||
resetOnCompletion: doc.resetOnCompletion,
|
||||
taskCount: tasks.length,
|
||||
tasks,
|
||||
};
|
||||
});
|
||||
|
||||
if (options.json) {
|
||||
const output = {
|
||||
id: playbook.id,
|
||||
name: playbook.name,
|
||||
agentId,
|
||||
agentName: agent.name,
|
||||
folderPath,
|
||||
loopEnabled: playbook.loopEnabled,
|
||||
maxLoops: playbook.maxLoops,
|
||||
prompt: playbook.prompt,
|
||||
documents: documentDetails,
|
||||
totalTasks: documentDetails.reduce((sum, d) => sum + d.taskCount, 0),
|
||||
};
|
||||
console.log(JSON.stringify(output, null, 2));
|
||||
} else {
|
||||
console.log(
|
||||
formatPlaybookDetail({
|
||||
id: playbook.id,
|
||||
name: playbook.name,
|
||||
agentId,
|
||||
agentName: agent.name,
|
||||
folderPath,
|
||||
loopEnabled: playbook.loopEnabled,
|
||||
maxLoops: playbook.maxLoops,
|
||||
prompt: playbook.prompt,
|
||||
documents: documentDetails,
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||
if (options.json) {
|
||||
console.error(JSON.stringify({ error: message }));
|
||||
} else {
|
||||
console.error(formatError(message));
|
||||
}
|
||||
process.exit(1);
|
||||
}
|
||||
}
|
||||
@@ -6,6 +6,7 @@ import { Command } from 'commander';
|
||||
import { listGroups } from './commands/list-groups';
|
||||
import { listAgents } from './commands/list-agents';
|
||||
import { listPlaybooks } from './commands/list-playbooks';
|
||||
import { showPlaybook } from './commands/show-playbook';
|
||||
import { runPlaybook } from './commands/run-playbook';
|
||||
|
||||
const program = new Command();
|
||||
@@ -38,6 +39,15 @@ list
|
||||
.option('--json', 'Output as JSON lines (for scripting)')
|
||||
.action(listPlaybooks);
|
||||
|
||||
// Show command
|
||||
const show = program.command('show').description('Show details of a resource');
|
||||
|
||||
show
|
||||
.command('playbook <id>')
|
||||
.description('Show detailed information about a playbook')
|
||||
.option('--json', 'Output as JSON (for scripting)')
|
||||
.action(showPlaybook);
|
||||
|
||||
// Run command
|
||||
program
|
||||
.command('run')
|
||||
|
||||
@@ -179,6 +179,83 @@ export function formatPlaybooks(
|
||||
return lines.join('\n').trimEnd();
|
||||
}
|
||||
|
||||
// Playbook detail formatting
|
||||
export interface PlaybookDetailDisplay {
|
||||
id: string;
|
||||
name: string;
|
||||
agentId: string;
|
||||
agentName: string;
|
||||
folderPath?: string;
|
||||
loopEnabled?: boolean;
|
||||
maxLoops?: number | null;
|
||||
prompt: string;
|
||||
documents: {
|
||||
filename: string;
|
||||
resetOnCompletion: boolean;
|
||||
taskCount: number;
|
||||
tasks: string[];
|
||||
}[];
|
||||
}
|
||||
|
||||
export function formatPlaybookDetail(playbook: PlaybookDetailDisplay): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
// Header
|
||||
lines.push(bold(c('cyan', 'PLAYBOOK')));
|
||||
lines.push('');
|
||||
|
||||
// Basic info
|
||||
lines.push(` ${c('white', 'Name:')} ${playbook.name}`);
|
||||
lines.push(` ${c('white', 'ID:')} ${playbook.id}`);
|
||||
lines.push(` ${c('white', 'Agent:')} ${playbook.agentName} ${dim(`(${playbook.agentId.slice(0, 8)})`)}`);
|
||||
|
||||
if (playbook.folderPath) {
|
||||
lines.push(` ${c('white', 'Folder:')} ${dim(playbook.folderPath)}`);
|
||||
}
|
||||
|
||||
// Loop configuration
|
||||
if (playbook.loopEnabled) {
|
||||
const loopInfo = playbook.maxLoops ? `max ${playbook.maxLoops}` : '∞';
|
||||
lines.push(` ${c('white', 'Loop:')} ${c('yellow', `enabled (${loopInfo})`)}`);
|
||||
} else {
|
||||
lines.push(` ${c('white', 'Loop:')} ${dim('disabled')}`);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
|
||||
// Prompt
|
||||
lines.push(` ${c('white', 'Prompt:')}`);
|
||||
const promptLines = playbook.prompt.split('\n');
|
||||
for (const line of promptLines) {
|
||||
lines.push(` ${dim(line)}`);
|
||||
}
|
||||
|
||||
lines.push('');
|
||||
|
||||
// Documents
|
||||
const totalTasks = playbook.documents.reduce((sum, d) => sum + d.taskCount, 0);
|
||||
lines.push(` ${c('white', 'Documents:')} ${dim(`(${playbook.documents.length} files, ${totalTasks} pending tasks)`)}`);
|
||||
lines.push('');
|
||||
|
||||
for (const doc of playbook.documents) {
|
||||
const reset = doc.resetOnCompletion ? c('magenta', ' ↺ reset') : '';
|
||||
const taskInfo = doc.taskCount > 0 ? c('green', ` (${doc.taskCount} tasks)`) : dim(' (0 tasks)');
|
||||
lines.push(` ${c('blue', '📄')} ${doc.filename}${taskInfo}${reset}`);
|
||||
|
||||
// Show tasks (up to 5)
|
||||
const tasksToShow = doc.tasks.slice(0, 5);
|
||||
for (let i = 0; i < tasksToShow.length; i++) {
|
||||
const task = truncate(tasksToShow[i], 60);
|
||||
lines.push(` ${dim(`${i + 1}.`)} ${task}`);
|
||||
}
|
||||
if (doc.tasks.length > 5) {
|
||||
lines.push(` ${dim(`... and ${doc.tasks.length - 5} more`)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
export function formatPlaybooksByAgent(groups: PlaybooksByAgent[]): string {
|
||||
// Filter to only agents with playbooks
|
||||
const agentsWithPlaybooks = groups.filter((g) => g.playbooks.length > 0);
|
||||
|
||||
@@ -481,7 +481,7 @@ export async function* runPlaybook(
|
||||
const loopSummary = `Loop ${loopIteration + 1} completed: ${loopTasksCompleted} tasks accomplished`;
|
||||
const historyEntry: HistoryEntry = {
|
||||
id: generateUUID(),
|
||||
type: 'LOOP_SUMMARY',
|
||||
type: 'LOOP',
|
||||
timestamp: Date.now(),
|
||||
summary: loopSummary,
|
||||
projectPath: session.cwd,
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { execFileNoThrow } from './utils/execFile';
|
||||
import { logger } from './utils/logger';
|
||||
import * as os from 'os';
|
||||
import * as fs from 'fs';
|
||||
|
||||
// Configuration option types for agent-specific settings
|
||||
export interface AgentConfigOption {
|
||||
@@ -21,6 +22,7 @@ export interface AgentConfig {
|
||||
args: string[]; // Base args always included
|
||||
available: boolean;
|
||||
path?: string;
|
||||
customPath?: string; // User-specified custom path (shown in UI even if not available)
|
||||
requiresPty?: boolean; // Whether this agent needs a pseudo-terminal
|
||||
configOptions?: AgentConfigOption[]; // Agent-specific configuration
|
||||
hidden?: boolean; // If true, agent is hidden from UI (internal use only)
|
||||
@@ -70,6 +72,23 @@ const AGENT_DEFINITIONS: Omit<AgentConfig, 'available' | 'path'>[] = [
|
||||
export class AgentDetector {
|
||||
private cachedAgents: AgentConfig[] | null = null;
|
||||
private detectionInProgress: Promise<AgentConfig[]> | null = null;
|
||||
private customPaths: Record<string, string> = {};
|
||||
|
||||
/**
|
||||
* Set custom paths for agents (from user configuration)
|
||||
*/
|
||||
setCustomPaths(paths: Record<string, string>): void {
|
||||
this.customPaths = paths;
|
||||
// Clear cache when custom paths change
|
||||
this.cachedAgents = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current custom paths
|
||||
*/
|
||||
getCustomPaths(): Record<string, string> {
|
||||
return { ...this.customPaths };
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect which agents are available on the system
|
||||
@@ -104,23 +123,45 @@ export class AgentDetector {
|
||||
logger.info(`Agent detection starting. PATH: ${expandedEnv.PATH}`, 'AgentDetector');
|
||||
|
||||
for (const agentDef of AGENT_DEFINITIONS) {
|
||||
const detection = await this.checkBinaryExists(agentDef.binaryName);
|
||||
const customPath = this.customPaths[agentDef.id];
|
||||
let detection: { exists: boolean; path?: string };
|
||||
|
||||
if (detection.exists) {
|
||||
logger.info(`Agent "${agentDef.name}" found at: ${detection.path}`, 'AgentDetector');
|
||||
} else if (agentDef.binaryName !== 'bash') {
|
||||
// Don't log bash as missing since it's always present, log others as warnings
|
||||
logger.warn(
|
||||
`Agent "${agentDef.name}" (binary: ${agentDef.binaryName}) not found. ` +
|
||||
`Searched in PATH: ${expandedEnv.PATH}`,
|
||||
'AgentDetector'
|
||||
);
|
||||
// If user has specified a custom path, check that first
|
||||
if (customPath) {
|
||||
detection = await this.checkCustomPath(customPath);
|
||||
if (detection.exists) {
|
||||
logger.info(`Agent "${agentDef.name}" found at custom path: ${detection.path}`, 'AgentDetector');
|
||||
} else {
|
||||
logger.warn(
|
||||
`Agent "${agentDef.name}" custom path not valid: ${customPath}`,
|
||||
'AgentDetector'
|
||||
);
|
||||
// Fall back to PATH detection
|
||||
detection = await this.checkBinaryExists(agentDef.binaryName);
|
||||
if (detection.exists) {
|
||||
logger.info(`Agent "${agentDef.name}" found in PATH at: ${detection.path}`, 'AgentDetector');
|
||||
}
|
||||
}
|
||||
} else {
|
||||
detection = await this.checkBinaryExists(agentDef.binaryName);
|
||||
|
||||
if (detection.exists) {
|
||||
logger.info(`Agent "${agentDef.name}" found at: ${detection.path}`, 'AgentDetector');
|
||||
} else if (agentDef.binaryName !== 'bash') {
|
||||
// Don't log bash as missing since it's always present, log others as warnings
|
||||
logger.warn(
|
||||
`Agent "${agentDef.name}" (binary: ${agentDef.binaryName}) not found. ` +
|
||||
`Searched in PATH: ${expandedEnv.PATH}`,
|
||||
'AgentDetector'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
agents.push({
|
||||
...agentDef,
|
||||
available: detection.exists,
|
||||
path: detection.path,
|
||||
customPath: customPath || undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -131,6 +172,34 @@ export class AgentDetector {
|
||||
return agents;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a custom path points to a valid executable
|
||||
*/
|
||||
private async checkCustomPath(customPath: string): Promise<{ exists: boolean; path?: string }> {
|
||||
try {
|
||||
// Check if file exists
|
||||
const stats = await fs.promises.stat(customPath);
|
||||
if (!stats.isFile()) {
|
||||
return { exists: false };
|
||||
}
|
||||
|
||||
// Check if file is executable (on Unix systems)
|
||||
if (process.platform !== 'win32') {
|
||||
try {
|
||||
await fs.promises.access(customPath, fs.constants.X_OK);
|
||||
} catch {
|
||||
// File exists but is not executable
|
||||
logger.warn(`Custom path exists but is not executable: ${customPath}`, 'AgentDetector');
|
||||
return { exists: false };
|
||||
}
|
||||
}
|
||||
|
||||
return { exists: true, path: customPath };
|
||||
} catch {
|
||||
return { exists: false };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an expanded PATH that includes common binary installation locations.
|
||||
* This is necessary because packaged Electron apps don't inherit shell environment.
|
||||
|
||||
@@ -153,14 +153,15 @@ const windowStateStore = new Store<WindowState>({
|
||||
},
|
||||
});
|
||||
|
||||
// History entries store (per-project history for AUTO and USER entries)
|
||||
// History entries store (per-project history for AUTO, USER, and LOOP entries)
|
||||
interface HistoryEntry {
|
||||
id: string;
|
||||
type: 'AUTO' | 'USER';
|
||||
type: 'AUTO' | 'USER' | 'LOOP';
|
||||
timestamp: number;
|
||||
summary: string;
|
||||
fullResponse?: string;
|
||||
claudeSessionId?: string;
|
||||
sessionName?: string; // User-defined session name
|
||||
projectPath: string;
|
||||
sessionId?: string; // Maestro session ID for isolation
|
||||
contextUsage?: number; // Context window usage percentage at time of entry
|
||||
@@ -619,6 +620,19 @@ app.whenReady().then(() => {
|
||||
// Note: webServer is created on-demand when user enables web interface (see setupWebServerCallbacks)
|
||||
agentDetector = new AgentDetector();
|
||||
|
||||
// Load custom agent paths from settings
|
||||
const allAgentConfigs = agentConfigsStore.get('configs', {});
|
||||
const customPaths: Record<string, string> = {};
|
||||
for (const [agentId, config] of Object.entries(allAgentConfigs)) {
|
||||
if (config && typeof config === 'object' && 'customPath' in config && config.customPath) {
|
||||
customPaths[agentId] = config.customPath as string;
|
||||
}
|
||||
}
|
||||
if (Object.keys(customPaths).length > 0) {
|
||||
agentDetector.setCustomPaths(customPaths);
|
||||
logger.info(`Loaded custom agent paths: ${JSON.stringify(customPaths)}`, 'Startup');
|
||||
}
|
||||
|
||||
logger.info('Core services initialized', 'Startup');
|
||||
|
||||
// Set up IPC handlers
|
||||
@@ -1738,6 +1752,55 @@ function setupIpcHandlers() {
|
||||
return true;
|
||||
});
|
||||
|
||||
// Set custom path for an agent - used when agent is not in standard PATH locations
|
||||
ipcMain.handle('agents:setCustomPath', async (_event, agentId: string, customPath: string | null) => {
|
||||
if (!agentDetector) throw new Error('Agent detector not initialized');
|
||||
|
||||
const allConfigs = agentConfigsStore.get('configs', {});
|
||||
if (!allConfigs[agentId]) {
|
||||
allConfigs[agentId] = {};
|
||||
}
|
||||
|
||||
if (customPath) {
|
||||
allConfigs[agentId].customPath = customPath;
|
||||
logger.info(`Set custom path for agent ${agentId}: ${customPath}`, 'AgentConfig');
|
||||
} else {
|
||||
delete allConfigs[agentId].customPath;
|
||||
logger.info(`Cleared custom path for agent ${agentId}`, 'AgentConfig');
|
||||
}
|
||||
|
||||
agentConfigsStore.set('configs', allConfigs);
|
||||
|
||||
// Update agent detector with all custom paths
|
||||
const allCustomPaths: Record<string, string> = {};
|
||||
for (const [id, config] of Object.entries(allConfigs)) {
|
||||
if (config && typeof config === 'object' && 'customPath' in config && config.customPath) {
|
||||
allCustomPaths[id] = config.customPath as string;
|
||||
}
|
||||
}
|
||||
agentDetector.setCustomPaths(allCustomPaths);
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
// Get custom path for an agent
|
||||
ipcMain.handle('agents:getCustomPath', async (_event, agentId: string) => {
|
||||
const allConfigs = agentConfigsStore.get('configs', {});
|
||||
return allConfigs[agentId]?.customPath || null;
|
||||
});
|
||||
|
||||
// Get all custom paths for agents
|
||||
ipcMain.handle('agents:getAllCustomPaths', async () => {
|
||||
const allConfigs = agentConfigsStore.get('configs', {});
|
||||
const customPaths: Record<string, string> = {};
|
||||
for (const [agentId, config] of Object.entries(allConfigs)) {
|
||||
if (config && typeof config === 'object' && 'customPath' in config && config.customPath) {
|
||||
customPaths[agentId] = config.customPath as string;
|
||||
}
|
||||
}
|
||||
return customPaths;
|
||||
});
|
||||
|
||||
// Folder selection dialog
|
||||
ipcMain.handle('dialog:selectFolder', async () => {
|
||||
if (!mainWindow) return null;
|
||||
@@ -4268,6 +4331,11 @@ function setupProcessListeners() {
|
||||
mainWindow?.webContents.send('process:session-id', sessionId, claudeSessionId);
|
||||
});
|
||||
|
||||
// Handle slash commands from Claude Code init message
|
||||
processManager.on('slash-commands', (sessionId: string, slashCommands: string[]) => {
|
||||
mainWindow?.webContents.send('process:slash-commands', sessionId, slashCommands);
|
||||
});
|
||||
|
||||
// Handle stderr separately from runCommand (for clean command execution)
|
||||
processManager.on('stderr', (sessionId: string, data: string) => {
|
||||
mainWindow?.webContents.send('process:stderr', sessionId, data);
|
||||
|
||||
@@ -86,6 +86,11 @@ contextBridge.exposeInMainWorld('maestro', {
|
||||
ipcRenderer.on('process:session-id', handler);
|
||||
return () => ipcRenderer.removeListener('process:session-id', handler);
|
||||
},
|
||||
onSlashCommands: (callback: (sessionId: string, slashCommands: string[]) => void) => {
|
||||
const handler = (_: any, sessionId: string, slashCommands: string[]) => callback(sessionId, slashCommands);
|
||||
ipcRenderer.on('process:slash-commands', handler);
|
||||
return () => ipcRenderer.removeListener('process:slash-commands', handler);
|
||||
},
|
||||
// Remote command execution from web interface
|
||||
// This allows web commands to go through the same code path as desktop commands
|
||||
// inputMode is optional - if provided, renderer should use it instead of session state
|
||||
@@ -309,6 +314,11 @@ contextBridge.exposeInMainWorld('maestro', {
|
||||
ipcRenderer.invoke('agents:getConfigValue', agentId, key),
|
||||
setConfigValue: (agentId: string, key: string, value: any) =>
|
||||
ipcRenderer.invoke('agents:setConfigValue', agentId, key, value),
|
||||
setCustomPath: (agentId: string, customPath: string | null) =>
|
||||
ipcRenderer.invoke('agents:setCustomPath', agentId, customPath),
|
||||
getCustomPath: (agentId: string) =>
|
||||
ipcRenderer.invoke('agents:getCustomPath', agentId),
|
||||
getAllCustomPaths: () => ipcRenderer.invoke('agents:getAllCustomPaths'),
|
||||
},
|
||||
|
||||
// Dialog API
|
||||
@@ -444,7 +454,29 @@ contextBridge.exposeInMainWorld('maestro', {
|
||||
history: {
|
||||
getAll: (projectPath?: string, sessionId?: string) =>
|
||||
ipcRenderer.invoke('history:getAll', projectPath, sessionId),
|
||||
add: (entry: { id: string; type: 'AUTO' | 'USER'; timestamp: number; summary: string; claudeSessionId?: string; projectPath: string; sessionId?: string }) =>
|
||||
add: (entry: {
|
||||
id: string;
|
||||
type: 'AUTO' | 'USER' | 'LOOP';
|
||||
timestamp: number;
|
||||
summary: string;
|
||||
fullResponse?: string;
|
||||
claudeSessionId?: string;
|
||||
projectPath: string;
|
||||
sessionId?: string;
|
||||
sessionName?: string;
|
||||
contextUsage?: number;
|
||||
usageStats?: {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadInputTokens: number;
|
||||
cacheCreationInputTokens: number;
|
||||
totalCostUsd: number;
|
||||
contextWindow: number;
|
||||
};
|
||||
success?: boolean;
|
||||
elapsedTimeMs?: number;
|
||||
validated?: boolean;
|
||||
}) =>
|
||||
ipcRenderer.invoke('history:add', entry),
|
||||
clear: (projectPath?: string) =>
|
||||
ipcRenderer.invoke('history:clear', projectPath),
|
||||
@@ -583,6 +615,7 @@ export interface MaestroAPI {
|
||||
onData: (callback: (sessionId: string, data: string) => void) => () => void;
|
||||
onExit: (callback: (sessionId: string, code: number) => void) => () => void;
|
||||
onSessionId: (callback: (sessionId: string, claudeSessionId: string) => void) => () => void;
|
||||
onSlashCommands: (callback: (sessionId: string, slashCommands: string[]) => void) => () => void;
|
||||
onRemoteCommand: (callback: (sessionId: string, command: string) => void) => () => void;
|
||||
onRemoteSwitchMode: (callback: (sessionId: string, mode: 'ai' | 'terminal') => void) => () => void;
|
||||
onRemoteInterrupt: (callback: (sessionId: string) => void) => () => void;
|
||||
@@ -690,6 +723,8 @@ export interface MaestroAPI {
|
||||
getLiveSessions: () => Promise<Array<{ sessionId: string; claudeSessionId?: string; enabledAt: number }>>;
|
||||
broadcastActiveSession: (sessionId: string) => Promise<void>;
|
||||
disableAll: () => Promise<{ success: boolean; count: number }>;
|
||||
startServer: () => Promise<{ success: boolean; url?: string; error?: string }>;
|
||||
stopServer: () => Promise<{ success: boolean }>;
|
||||
};
|
||||
agents: {
|
||||
detect: () => Promise<AgentConfig[]>;
|
||||
@@ -698,6 +733,9 @@ export interface MaestroAPI {
|
||||
setConfig: (agentId: string, config: Record<string, any>) => Promise<boolean>;
|
||||
getConfigValue: (agentId: string, key: string) => Promise<any>;
|
||||
setConfigValue: (agentId: string, key: string, value: any) => Promise<boolean>;
|
||||
setCustomPath: (agentId: string, customPath: string | null) => Promise<boolean>;
|
||||
getCustomPath: (agentId: string) => Promise<string | null>;
|
||||
getAllCustomPaths: () => Promise<Record<string, string>>;
|
||||
};
|
||||
dialog: {
|
||||
selectFolder: () => Promise<string | null>;
|
||||
@@ -843,24 +881,53 @@ export interface MaestroAPI {
|
||||
history: {
|
||||
getAll: (projectPath?: string, sessionId?: string) => Promise<Array<{
|
||||
id: string;
|
||||
type: 'AUTO' | 'USER';
|
||||
type: 'AUTO' | 'USER' | 'LOOP';
|
||||
timestamp: number;
|
||||
summary: string;
|
||||
fullResponse?: string;
|
||||
claudeSessionId?: string;
|
||||
projectPath: string;
|
||||
sessionId?: string;
|
||||
sessionName?: string;
|
||||
contextUsage?: number;
|
||||
usageStats?: {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadInputTokens: number;
|
||||
cacheCreationInputTokens: number;
|
||||
totalCostUsd: number;
|
||||
contextWindow: number;
|
||||
};
|
||||
success?: boolean;
|
||||
elapsedTimeMs?: number;
|
||||
validated?: boolean;
|
||||
}>>;
|
||||
add: (entry: {
|
||||
id: string;
|
||||
type: 'AUTO' | 'USER';
|
||||
type: 'AUTO' | 'USER' | 'LOOP';
|
||||
timestamp: number;
|
||||
summary: string;
|
||||
fullResponse?: string;
|
||||
claudeSessionId?: string;
|
||||
projectPath: string;
|
||||
sessionId?: string;
|
||||
sessionName?: string;
|
||||
contextUsage?: number;
|
||||
usageStats?: {
|
||||
inputTokens: number;
|
||||
outputTokens: number;
|
||||
cacheReadInputTokens: number;
|
||||
cacheCreationInputTokens: number;
|
||||
totalCostUsd: number;
|
||||
contextWindow: number;
|
||||
};
|
||||
success?: boolean;
|
||||
elapsedTimeMs?: number;
|
||||
validated?: boolean;
|
||||
}) => Promise<boolean>;
|
||||
clear: (projectPath?: string) => Promise<boolean>;
|
||||
delete: (entryId: string) => Promise<boolean>;
|
||||
update: (entryId: string, updates: { validated?: boolean }) => Promise<boolean>;
|
||||
};
|
||||
notification: {
|
||||
show: (title: string, body: string) => Promise<{ success: boolean; error?: string }>;
|
||||
|
||||
@@ -321,6 +321,7 @@ export class ProcessManager extends EventEmitter {
|
||||
// Only emit once per process to prevent duplicates
|
||||
if (msg.type === 'result' && msg.result && !managedProcess.resultEmitted) {
|
||||
managedProcess.resultEmitted = true;
|
||||
console.log(`[ProcessManager] Emitting result data for session ${sessionId}, length: ${msg.result.length}`);
|
||||
this.emit('data', sessionId, msg.result);
|
||||
}
|
||||
// Skip 'assistant' type - we prefer the complete 'result' over streaming chunks
|
||||
@@ -330,6 +331,12 @@ export class ProcessManager extends EventEmitter {
|
||||
managedProcess.sessionIdEmitted = true;
|
||||
this.emit('session-id', sessionId, msg.session_id);
|
||||
}
|
||||
// Extract slash commands from init message
|
||||
// Claude Code emits available slash commands (built-in + user-defined) in the init message
|
||||
if (msg.type === 'system' && msg.subtype === 'init' && msg.slash_commands) {
|
||||
console.log(`[ProcessManager] Received ${msg.slash_commands.length} slash commands from Claude Code init:`, msg.slash_commands.slice(0, 5), '...');
|
||||
this.emit('slash-commands', sessionId, msg.slash_commands);
|
||||
}
|
||||
// Extract usage statistics from stream-json messages (typically in 'result' type)
|
||||
// Note: We need to aggregate token counts from modelUsage for accurate context window tracking
|
||||
if (msg.modelUsage || msg.usage || msg.total_cost_usd !== undefined) {
|
||||
|
||||
@@ -200,11 +200,12 @@ export type GetCustomCommandsCallback = () => CustomAICommand[];
|
||||
// History entry type for the history API
|
||||
export interface HistoryEntryData {
|
||||
id: string;
|
||||
type: 'AUTO' | 'USER';
|
||||
type: 'AUTO' | 'USER' | 'LOOP';
|
||||
timestamp: number;
|
||||
summary: string;
|
||||
fullResponse?: string;
|
||||
claudeSessionId?: string;
|
||||
sessionName?: string;
|
||||
projectPath: string;
|
||||
sessionId?: string;
|
||||
contextUsage?: number;
|
||||
@@ -218,6 +219,7 @@ export interface HistoryEntryData {
|
||||
};
|
||||
success?: boolean;
|
||||
elapsedTimeMs?: number;
|
||||
validated?: boolean;
|
||||
}
|
||||
|
||||
// Callback type for fetching history entries
|
||||
|
||||
@@ -70,6 +70,40 @@ const compareNamesIgnoringEmojis = (a: string, b: string): number => {
|
||||
return aStripped.localeCompare(bStripped);
|
||||
};
|
||||
|
||||
// Get description for Claude Code slash commands
|
||||
// Built-in commands have known descriptions, custom ones use a generic description
|
||||
const CLAUDE_BUILTIN_COMMANDS: Record<string, string> = {
|
||||
'compact': 'Summarize conversation to reduce context usage',
|
||||
'context': 'Show current context window usage',
|
||||
'cost': 'Show session cost and token usage',
|
||||
'init': 'Initialize CLAUDE.md with codebase info',
|
||||
'pr-comments': 'Address PR review comments',
|
||||
'release-notes': 'Generate release notes from changes',
|
||||
'todos': 'Find and list TODO comments in codebase',
|
||||
'review': 'Review code changes',
|
||||
'security-review': 'Review code for security issues',
|
||||
'plan': 'Create an implementation plan',
|
||||
};
|
||||
|
||||
const getSlashCommandDescription = (cmd: string): string => {
|
||||
// Remove leading slash if present
|
||||
const cmdName = cmd.startsWith('/') ? cmd.slice(1) : cmd;
|
||||
|
||||
// Check for built-in command
|
||||
if (CLAUDE_BUILTIN_COMMANDS[cmdName]) {
|
||||
return CLAUDE_BUILTIN_COMMANDS[cmdName];
|
||||
}
|
||||
|
||||
// For plugin commands (e.g., "plugin-name:command"), use the full name as description hint
|
||||
if (cmdName.includes(':')) {
|
||||
const [plugin, command] = cmdName.split(':');
|
||||
return `${command} (${plugin})`;
|
||||
}
|
||||
|
||||
// Generic description for unknown commands
|
||||
return 'Claude Code command';
|
||||
};
|
||||
|
||||
export default function MaestroConsole() {
|
||||
// --- LAYER STACK (for blocking shortcuts when modals are open) ---
|
||||
const { hasOpenLayers, hasOpenModal } = useLayerStack();
|
||||
@@ -404,11 +438,12 @@ export default function MaestroConsole() {
|
||||
// Include active tab ID in session ID to match batch mode format
|
||||
const activeTabId = correctedSession.activeTabId || correctedSession.aiTabs?.[0]?.id || 'default';
|
||||
// Use agent.path (full path) if available for better cross-environment compatibility
|
||||
const agentCommand = agent.path || agent.command || agent.id;
|
||||
aiSpawnResult = await window.maestro.process.spawn({
|
||||
sessionId: `${correctedSession.id}-ai-${activeTabId}`,
|
||||
toolType: aiAgentType,
|
||||
cwd: correctedSession.cwd,
|
||||
command: agent.path || agent.command,
|
||||
command: agentCommand,
|
||||
args: agent.args || []
|
||||
});
|
||||
}
|
||||
@@ -1120,26 +1155,12 @@ export default function MaestroConsole() {
|
||||
actualSessionId = sessionId;
|
||||
}
|
||||
|
||||
// Store Claude session ID in session state and fetch commands if not already cached
|
||||
// Store Claude session ID in session state
|
||||
// Note: slash commands are now received via onSlashCommands from Claude Code's init message
|
||||
setSessions(prev => {
|
||||
const session = prev.find(s => s.id === actualSessionId);
|
||||
if (!session) return prev;
|
||||
|
||||
// Check if we need to fetch commands (only on first session establishment)
|
||||
const needsCommandFetch = !session.claudeCommands && session.toolType === 'claude-code';
|
||||
|
||||
if (needsCommandFetch) {
|
||||
// Fetch commands asynchronously and update session
|
||||
window.maestro.claude.getCommands(session.cwd).then(commands => {
|
||||
setSessions(prevSessions => prevSessions.map(s => {
|
||||
if (s.id !== actualSessionId) return s;
|
||||
return { ...s, claudeCommands: commands };
|
||||
}));
|
||||
}).catch(err => {
|
||||
console.error('[onSessionId] Failed to fetch Claude commands:', err);
|
||||
});
|
||||
}
|
||||
|
||||
// Register this as a user-initiated Maestro session (batch sessions are filtered above)
|
||||
// Do NOT pass session name - names should only be set when user explicitly renames
|
||||
window.maestro.claude.registerSessionOrigin(session.cwd, claudeSessionId, 'user')
|
||||
@@ -1189,6 +1210,28 @@ export default function MaestroConsole() {
|
||||
});
|
||||
});
|
||||
|
||||
// Handle slash commands from Claude Code init message
|
||||
// These are the authoritative source of available commands (built-in + user + plugin)
|
||||
const unsubscribeSlashCommands = window.maestro.process.onSlashCommands((sessionId: string, slashCommands: string[]) => {
|
||||
// Parse sessionId to get actual session ID (ignore tab ID suffix)
|
||||
const aiTabMatch = sessionId.match(/^(.+)-ai-(.+)$/);
|
||||
const actualSessionId = aiTabMatch ? aiTabMatch[1] : sessionId;
|
||||
|
||||
// Convert string array to command objects with descriptions
|
||||
// Claude Code returns just command names, we'll need to derive descriptions
|
||||
const commands = slashCommands.map(cmd => ({
|
||||
command: cmd.startsWith('/') ? cmd : `/${cmd}`,
|
||||
description: getSlashCommandDescription(cmd),
|
||||
}));
|
||||
|
||||
console.log(`[App] Received ${commands.length} slash commands for session ${actualSessionId}:`, commands.slice(0, 5));
|
||||
|
||||
setSessions(prev => prev.map(s => {
|
||||
if (s.id !== actualSessionId) return s;
|
||||
return { ...s, claudeCommands: commands };
|
||||
}));
|
||||
});
|
||||
|
||||
// Handle stderr from runCommand (separate from stdout)
|
||||
const unsubscribeStderr = window.maestro.process.onStderr((sessionId: string, data: string) => {
|
||||
// runCommand uses plain session ID (no suffix)
|
||||
@@ -1355,6 +1398,7 @@ export default function MaestroConsole() {
|
||||
unsubscribeData();
|
||||
unsubscribeExit();
|
||||
unsubscribeSessionId();
|
||||
unsubscribeSlashCommands();
|
||||
unsubscribeStderr();
|
||||
unsubscribeCommandExit();
|
||||
unsubscribeUsage();
|
||||
@@ -1552,7 +1596,9 @@ export default function MaestroConsole() {
|
||||
if (s.id !== session.id) return s;
|
||||
return {
|
||||
...s,
|
||||
state: 'idle'
|
||||
state: 'idle',
|
||||
busySource: undefined,
|
||||
thinkingStartTime: undefined
|
||||
};
|
||||
}));
|
||||
|
||||
@@ -2115,7 +2161,7 @@ export default function MaestroConsole() {
|
||||
// Set ALL busy tabs to 'idle' for write-mode tracking
|
||||
const updatedAiTabs = s.aiTabs?.length > 0
|
||||
? s.aiTabs.map(tab =>
|
||||
tab.state === 'busy' ? { ...tab, state: 'idle' as const } : tab
|
||||
tab.state === 'busy' ? { ...tab, state: 'idle' as const, thinkingStartTime: undefined } : tab
|
||||
)
|
||||
: s.aiTabs;
|
||||
|
||||
@@ -2265,6 +2311,12 @@ export default function MaestroConsole() {
|
||||
setTimeout(() => setFlashNotification(null), 2000);
|
||||
}, []);
|
||||
|
||||
// Helper to show success flash notification (center screen, auto-dismisses after 2 seconds)
|
||||
const showSuccessFlash = useCallback((message: string) => {
|
||||
setSuccessFlashNotification(message);
|
||||
setTimeout(() => setSuccessFlashNotification(null), 2000);
|
||||
}, []);
|
||||
|
||||
// Helper to add history entry
|
||||
const addHistoryEntry = useCallback(async (entry: { type: 'AUTO' | 'USER'; summary: string; fullResponse?: string; claudeSessionId?: string }) => {
|
||||
if (!activeSession) return;
|
||||
@@ -2311,10 +2363,10 @@ export default function MaestroConsole() {
|
||||
// Reset active tab's state to 'idle' for write-mode tracking
|
||||
const updatedAiTabs = s.aiTabs?.length > 0
|
||||
? s.aiTabs.map(tab =>
|
||||
tab.id === s.activeTabId ? { ...tab, state: 'idle' as const } : tab
|
||||
tab.id === s.activeTabId ? { ...tab, state: 'idle' as const, thinkingStartTime: undefined } : tab
|
||||
)
|
||||
: s.aiTabs;
|
||||
return { ...s, claudeSessionId: undefined, aiLogs: [], state: 'idle' as SessionState, aiTabs: updatedAiTabs };
|
||||
return { ...s, claudeSessionId: undefined, aiLogs: [], state: 'idle' as SessionState, busySource: undefined, thinkingStartTime: undefined, aiTabs: updatedAiTabs };
|
||||
}));
|
||||
setActiveClaudeSessionId(null);
|
||||
}, [activeSession]);
|
||||
@@ -2468,7 +2520,7 @@ export default function MaestroConsole() {
|
||||
setAutoRunDocumentList(result.files || []);
|
||||
setAutoRunDocumentTree((result.tree as Array<{ name: string; type: 'file' | 'folder'; path: string; children?: unknown[] }>) || []);
|
||||
// Auto-select first document if available
|
||||
const firstFile = result.files?.[0] || null;
|
||||
const firstFile = result.files?.[0];
|
||||
setSessions(prev => prev.map(s =>
|
||||
s.id === activeSession.id
|
||||
? {
|
||||
@@ -4530,13 +4582,15 @@ export default function MaestroConsole() {
|
||||
const updatedAiTabs = s.aiTabs?.length > 0
|
||||
? s.aiTabs.map(tab =>
|
||||
tab.id === s.activeTabId
|
||||
? { ...tab, state: 'idle' as const, logs: [...tab.logs, errorLog] }
|
||||
? { ...tab, state: 'idle' as const, thinkingStartTime: undefined, logs: [...tab.logs, errorLog] }
|
||||
: tab
|
||||
)
|
||||
: s.aiTabs;
|
||||
return {
|
||||
...s,
|
||||
state: 'idle',
|
||||
busySource: undefined,
|
||||
thinkingStartTime: undefined,
|
||||
aiTabs: updatedAiTabs
|
||||
};
|
||||
}));
|
||||
@@ -4556,6 +4610,8 @@ export default function MaestroConsole() {
|
||||
return {
|
||||
...s,
|
||||
state: 'idle',
|
||||
busySource: undefined,
|
||||
thinkingStartTime: undefined,
|
||||
shellLogs: [...s.shellLogs, {
|
||||
id: generateId(),
|
||||
timestamp: Date.now(),
|
||||
@@ -4581,13 +4637,15 @@ export default function MaestroConsole() {
|
||||
const updatedAiTabs = s.aiTabs?.length > 0
|
||||
? s.aiTabs.map(tab =>
|
||||
tab.id === s.activeTabId
|
||||
? { ...tab, state: 'idle' as const, logs: [...tab.logs, errorLog] }
|
||||
? { ...tab, state: 'idle' as const, thinkingStartTime: undefined, logs: [...tab.logs, errorLog] }
|
||||
: tab
|
||||
)
|
||||
: s.aiTabs;
|
||||
return {
|
||||
...s,
|
||||
state: 'idle',
|
||||
busySource: undefined,
|
||||
thinkingStartTime: undefined,
|
||||
aiTabs: updatedAiTabs
|
||||
};
|
||||
}));
|
||||
@@ -4661,6 +4719,7 @@ export default function MaestroConsole() {
|
||||
...s,
|
||||
state: 'idle' as SessionState,
|
||||
busySource: undefined,
|
||||
thinkingStartTime: undefined,
|
||||
shellLogs: [...s.shellLogs, {
|
||||
id: generateId(),
|
||||
timestamp: Date.now(),
|
||||
@@ -4998,7 +5057,7 @@ export default function MaestroConsole() {
|
||||
const updatedAiTabs = s.aiTabs?.length > 0
|
||||
? s.aiTabs.map(tab =>
|
||||
tab.id === s.activeTabId
|
||||
? { ...tab, state: 'idle' as const, logs: [...tab.logs, errorLogEntry] }
|
||||
? { ...tab, state: 'idle' as const, thinkingStartTime: undefined, logs: [...tab.logs, errorLogEntry] }
|
||||
: tab
|
||||
)
|
||||
: s.aiTabs;
|
||||
@@ -5036,12 +5095,14 @@ export default function MaestroConsole() {
|
||||
// Send interrupt signal (Ctrl+C)
|
||||
await window.maestro.process.interrupt(targetSessionId);
|
||||
|
||||
// Just set state to idle, no log entry needed
|
||||
// Set state to idle with full cleanup
|
||||
setSessions(prev => prev.map(s => {
|
||||
if (s.id !== activeSession.id) return s;
|
||||
return {
|
||||
...s,
|
||||
state: 'idle'
|
||||
state: 'idle',
|
||||
busySource: undefined,
|
||||
thinkingStartTime: undefined
|
||||
};
|
||||
}));
|
||||
} catch (error) {
|
||||
@@ -5067,16 +5128,18 @@ export default function MaestroConsole() {
|
||||
if (s.id !== activeSession.id) return s;
|
||||
if (currentMode === 'ai') {
|
||||
const tab = getActiveTab(s);
|
||||
if (!tab) return { ...s, state: 'idle' };
|
||||
if (!tab) return { ...s, state: 'idle', busySource: undefined, thinkingStartTime: undefined };
|
||||
return {
|
||||
...s,
|
||||
state: 'idle',
|
||||
busySource: undefined,
|
||||
thinkingStartTime: undefined,
|
||||
aiTabs: s.aiTabs.map(t =>
|
||||
t.id === tab.id ? { ...t, logs: [...t.logs, killLog] } : t
|
||||
t.id === tab.id ? { ...t, state: 'idle' as const, thinkingStartTime: undefined, logs: [...t.logs, killLog] } : t
|
||||
)
|
||||
};
|
||||
}
|
||||
return { ...s, shellLogs: [...s.shellLogs, killLog], state: 'idle' };
|
||||
return { ...s, shellLogs: [...s.shellLogs, killLog], state: 'idle', busySource: undefined, thinkingStartTime: undefined };
|
||||
}));
|
||||
} catch (killError) {
|
||||
console.error('Failed to kill process:', killError);
|
||||
@@ -5090,16 +5153,18 @@ export default function MaestroConsole() {
|
||||
if (s.id !== activeSession.id) return s;
|
||||
if (currentMode === 'ai') {
|
||||
const tab = getActiveTab(s);
|
||||
if (!tab) return { ...s, state: 'idle' };
|
||||
if (!tab) return { ...s, state: 'idle', busySource: undefined, thinkingStartTime: undefined };
|
||||
return {
|
||||
...s,
|
||||
state: 'idle',
|
||||
busySource: undefined,
|
||||
thinkingStartTime: undefined,
|
||||
aiTabs: s.aiTabs.map(t =>
|
||||
t.id === tab.id ? { ...t, logs: [...t.logs, errorLog] } : t
|
||||
t.id === tab.id ? { ...t, state: 'idle' as const, thinkingStartTime: undefined, logs: [...t.logs, errorLog] } : t
|
||||
)
|
||||
};
|
||||
}
|
||||
return { ...s, shellLogs: [...s.shellLogs, errorLog], state: 'idle' };
|
||||
return { ...s, shellLogs: [...s.shellLogs, errorLog], state: 'idle', busySource: undefined, thinkingStartTime: undefined };
|
||||
}));
|
||||
}
|
||||
}
|
||||
@@ -5780,10 +5845,10 @@ export default function MaestroConsole() {
|
||||
// Reset active tab's state to 'idle' for write-mode tracking
|
||||
const updatedAiTabs = s.aiTabs?.length > 0
|
||||
? s.aiTabs.map(tab =>
|
||||
tab.id === s.activeTabId ? { ...tab, state: 'idle' as const } : tab
|
||||
tab.id === s.activeTabId ? { ...tab, state: 'idle' as const, thinkingStartTime: undefined } : tab
|
||||
)
|
||||
: s.aiTabs;
|
||||
return { ...s, claudeSessionId: undefined, aiLogs: [], state: 'idle', aiTabs: updatedAiTabs };
|
||||
return { ...s, claudeSessionId: undefined, aiLogs: [], state: 'idle', busySource: undefined, thinkingStartTime: undefined, aiTabs: updatedAiTabs };
|
||||
}));
|
||||
setActiveClaudeSessionId(null);
|
||||
}
|
||||
@@ -6449,6 +6514,7 @@ export default function MaestroConsole() {
|
||||
}}
|
||||
showUnreadOnly={showUnreadOnly}
|
||||
onToggleUnreadFilter={toggleUnreadFilter}
|
||||
onOpenTabSearch={() => setTabSwitcherOpen(true)}
|
||||
onToggleTabSaveToHistory={() => {
|
||||
if (!activeSession) return;
|
||||
const activeTab = getActiveTab(activeSession);
|
||||
@@ -6531,6 +6597,7 @@ export default function MaestroConsole() {
|
||||
refreshFileTree={refreshFileTree}
|
||||
setSessions={setSessions}
|
||||
onAutoRefreshChange={handleAutoRefreshChange}
|
||||
onShowFlash={showSuccessFlash}
|
||||
autoRunDocumentList={autoRunDocumentList}
|
||||
autoRunDocumentTree={autoRunDocumentTree}
|
||||
autoRunContent={autoRunContent}
|
||||
@@ -6582,6 +6649,7 @@ export default function MaestroConsole() {
|
||||
currentDocument={activeSession.autoRunSelectedFile || ''}
|
||||
allDocuments={autoRunDocumentList}
|
||||
getDocumentTaskCount={getDocumentTaskCount}
|
||||
onRefreshDocuments={handleAutoRunRefresh}
|
||||
sessionId={activeSession.id}
|
||||
sessionCwd={activeSession.cwd}
|
||||
/>
|
||||
|
||||
@@ -33,6 +33,10 @@ export function AboutModal({ theme, sessions, autoRunStats, onClose }: AboutModa
|
||||
const [isStatsComplete, setIsStatsComplete] = useState(false);
|
||||
const badgeEscapeHandlerRef = useRef<(() => boolean) | null>(null);
|
||||
|
||||
// Use ref to avoid re-registering layer when onClose changes
|
||||
const onCloseRef = useRef(onClose);
|
||||
onCloseRef.current = onClose;
|
||||
|
||||
// Load global stats from all Claude projects on mount with streaming updates
|
||||
useEffect(() => {
|
||||
// Subscribe to streaming updates
|
||||
@@ -87,6 +91,7 @@ export function AboutModal({ theme, sessions, autoRunStats, onClose }: AboutModa
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Custom escape handler that checks for badge overlay first
|
||||
// Uses refs to avoid dependency changes that would cause infinite loops
|
||||
const handleEscape = useCallback(() => {
|
||||
// If badge overlay is open, close it first
|
||||
if (badgeEscapeHandlerRef.current) {
|
||||
@@ -94,10 +99,10 @@ export function AboutModal({ theme, sessions, autoRunStats, onClose }: AboutModa
|
||||
return;
|
||||
}
|
||||
// Otherwise close the modal
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
onCloseRef.current();
|
||||
}, []); // No dependencies - uses refs
|
||||
|
||||
// Register layer on mount
|
||||
// Register layer on mount only
|
||||
useEffect(() => {
|
||||
const id = registerLayer({
|
||||
type: 'modal',
|
||||
@@ -120,13 +125,6 @@ export function AboutModal({ theme, sessions, autoRunStats, onClose }: AboutModa
|
||||
};
|
||||
}, [registerLayer, unregisterLayer, handleEscape]);
|
||||
|
||||
// Update handler when dependencies change
|
||||
useEffect(() => {
|
||||
if (layerIdRef.current) {
|
||||
updateLayerHandler(layerIdRef.current, handleEscape);
|
||||
}
|
||||
}, [handleEscape, updateLayerHandler]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
|
||||
@@ -1696,7 +1696,7 @@ function AutoRunInner({
|
||||
)}
|
||||
|
||||
{/* Content Area */}
|
||||
<div className="flex-1 min-h-0">
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
{/* Empty folder state - show when folder is configured but has no documents */}
|
||||
{folderPath && documentList.length === 0 && !isLoadingDocuments ? (
|
||||
<div
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||
import { X, RotateCcw, Play, Variable, ChevronDown, ChevronRight, Save, GripVertical, Plus, Repeat, FolderOpen, Bookmark, GitBranch, AlertTriangle, Loader2, Maximize2, Download, Upload } from 'lucide-react';
|
||||
import { X, RotateCcw, Play, Variable, ChevronDown, ChevronRight, Save, GripVertical, Plus, Repeat, FolderOpen, Bookmark, GitBranch, AlertTriangle, Loader2, Maximize2, Download, Upload, RefreshCw } from 'lucide-react';
|
||||
import type { Theme, BatchDocumentEntry, BatchRunConfig, Playbook, PlaybookDocumentEntry, WorktreeConfig } from '../types';
|
||||
import { useLayerStack } from '../contexts/LayerStackContext';
|
||||
import { MODAL_PRIORITIES } from '../constants/modalPriorities';
|
||||
@@ -72,6 +72,7 @@ interface BatchRunnerModalProps {
|
||||
currentDocument: string;
|
||||
allDocuments: string[]; // All available docs in folder (without .md)
|
||||
getDocumentTaskCount: (filename: string) => Promise<number>; // Get task count for a document
|
||||
onRefreshDocuments: () => Promise<void>; // Refresh document list from folder
|
||||
// Session ID for playbook storage
|
||||
sessionId: string;
|
||||
// Session cwd for git worktree support
|
||||
@@ -116,6 +117,7 @@ export function BatchRunnerModal(props: BatchRunnerModalProps) {
|
||||
currentDocument,
|
||||
allDocuments,
|
||||
getDocumentTaskCount,
|
||||
onRefreshDocuments,
|
||||
sessionId,
|
||||
sessionCwd
|
||||
} = props;
|
||||
@@ -141,6 +143,9 @@ export function BatchRunnerModal(props: BatchRunnerModalProps) {
|
||||
// Document selector modal state
|
||||
const [showDocSelector, setShowDocSelector] = useState(false);
|
||||
const [selectedDocsInSelector, setSelectedDocsInSelector] = useState<Set<string>>(new Set());
|
||||
const [docSelectorRefreshing, setDocSelectorRefreshing] = useState(false);
|
||||
const [docSelectorRefreshMessage, setDocSelectorRefreshMessage] = useState<string | null>(null);
|
||||
const [prevDocCount, setPrevDocCount] = useState(allDocuments.length);
|
||||
|
||||
// Loop mode state
|
||||
const [loopEnabled, setLoopEnabled] = useState(false);
|
||||
@@ -649,6 +654,42 @@ export function BatchRunnerModal(props: BatchRunnerModalProps) {
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Handle refresh in the document selector modal
|
||||
const handleDocSelectorRefresh = useCallback(async () => {
|
||||
const countBefore = allDocuments.length;
|
||||
setDocSelectorRefreshing(true);
|
||||
setDocSelectorRefreshMessage(null);
|
||||
|
||||
await onRefreshDocuments();
|
||||
|
||||
// The parent will update allDocuments - we need to calculate the diff
|
||||
// after the refresh completes. Use a small timeout to let the prop update.
|
||||
setTimeout(() => {
|
||||
setDocSelectorRefreshing(false);
|
||||
}, 500);
|
||||
}, [onRefreshDocuments, allDocuments.length]);
|
||||
|
||||
// Track document count changes for refresh notification
|
||||
useEffect(() => {
|
||||
if (docSelectorRefreshing === false && prevDocCount !== allDocuments.length) {
|
||||
const diff = allDocuments.length - prevDocCount;
|
||||
let message: string;
|
||||
if (diff > 0) {
|
||||
message = `Found ${diff} new document${diff === 1 ? '' : 's'}`;
|
||||
} else if (diff < 0) {
|
||||
message = `${Math.abs(diff)} document${Math.abs(diff) === 1 ? '' : 's'} removed`;
|
||||
} else {
|
||||
message = 'No changes';
|
||||
}
|
||||
setDocSelectorRefreshMessage(message);
|
||||
setPrevDocCount(allDocuments.length);
|
||||
|
||||
// Clear message after 3 seconds
|
||||
const timer = setTimeout(() => setDocSelectorRefreshMessage(null), 3000);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
}, [allDocuments.length, prevDocCount, docSelectorRefreshing]);
|
||||
|
||||
// Handle loading a playbook
|
||||
const handleLoadPlaybook = useCallback((playbook: Playbook) => {
|
||||
// Convert stored entries to BatchDocumentEntry with IDs
|
||||
@@ -1869,12 +1910,36 @@ export function BatchRunnerModal(props: BatchRunnerModalProps) {
|
||||
>
|
||||
{/* Selector Header */}
|
||||
<div className="p-4 border-b flex items-center justify-between shrink-0" style={{ borderColor: theme.colors.border }}>
|
||||
<h3 className="text-sm font-bold" style={{ color: theme.colors.textMain }}>
|
||||
Select Documents
|
||||
</h3>
|
||||
<button onClick={() => setShowDocSelector(false)} style={{ color: theme.colors.textDim }}>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
<h3 className="text-sm font-bold" style={{ color: theme.colors.textMain }}>
|
||||
Select Documents
|
||||
</h3>
|
||||
{docSelectorRefreshMessage && (
|
||||
<span
|
||||
className="text-xs px-2 py-0.5 rounded animate-in fade-in"
|
||||
style={{
|
||||
backgroundColor: theme.colors.success + '20',
|
||||
color: theme.colors.success
|
||||
}}
|
||||
>
|
||||
{docSelectorRefreshMessage}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={handleDocSelectorRefresh}
|
||||
disabled={docSelectorRefreshing}
|
||||
className="p-1 rounded hover:bg-white/10 transition-colors disabled:opacity-50"
|
||||
style={{ color: theme.colors.textDim }}
|
||||
title="Refresh document list"
|
||||
>
|
||||
<RefreshCw className={`w-4 h-4 ${docSelectorRefreshing ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
<button onClick={() => setShowDocSelector(false)} className="p-1 rounded hover:bg-white/10 transition-colors" style={{ color: theme.colors.textDim }}>
|
||||
<X className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Document Checkboxes */}
|
||||
|
||||
@@ -45,6 +45,7 @@ interface FileExplorerPanelProps {
|
||||
refreshFileTree: (sessionId: string) => Promise<FileTreeChanges | undefined>;
|
||||
setSessions: React.Dispatch<React.SetStateAction<Session[]>>;
|
||||
onAutoRefreshChange?: (interval: number) => void;
|
||||
onShowFlash?: (message: string) => void;
|
||||
}
|
||||
|
||||
export function FileExplorerPanel(props: FileExplorerPanelProps) {
|
||||
@@ -52,14 +53,12 @@ export function FileExplorerPanel(props: FileExplorerPanelProps) {
|
||||
session, theme, fileTreeFilter, setFileTreeFilter, fileTreeFilterOpen, setFileTreeFilterOpen,
|
||||
filteredFileTree, selectedFileIndex, setSelectedFileIndex, activeFocus, activeRightTab,
|
||||
previewFile, setActiveFocus, fileTreeContainerRef, fileTreeFilterInputRef, toggleFolder, handleFileClick, expandAllFolders,
|
||||
collapseAllFolders, updateSessionWorkingDirectory, refreshFileTree, setSessions, onAutoRefreshChange
|
||||
collapseAllFolders, updateSessionWorkingDirectory, refreshFileTree, setSessions, onAutoRefreshChange, onShowFlash
|
||||
} = props;
|
||||
|
||||
const { registerLayer, unregisterLayer, updateLayerHandler } = useLayerStack();
|
||||
const layerIdRef = useRef<string>();
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [flashMessage, setFlashMessage] = useState<string | null>(null);
|
||||
const flashTimeoutRef = useRef<NodeJS.Timeout | null>(null);
|
||||
|
||||
// Refresh overlay state
|
||||
const [overlayOpen, setOverlayOpen] = useState(false);
|
||||
@@ -75,34 +74,22 @@ export function FileExplorerPanel(props: FileExplorerPanelProps) {
|
||||
// Handle refresh with animation and flash notification
|
||||
const handleRefresh = useCallback(async () => {
|
||||
setIsRefreshing(true);
|
||||
// Clear any existing flash message
|
||||
if (flashTimeoutRef.current) {
|
||||
clearTimeout(flashTimeoutRef.current);
|
||||
flashTimeoutRef.current = null;
|
||||
}
|
||||
setFlashMessage(null);
|
||||
|
||||
try {
|
||||
const changes = await refreshFileTree(session.id);
|
||||
|
||||
// Show flash notification with change count
|
||||
if (changes) {
|
||||
// Show center screen flash notification with change count
|
||||
if (changes && onShowFlash) {
|
||||
const message = changes.totalChanges === 0
|
||||
? 'No changes detected'
|
||||
: `Detected ${changes.totalChanges} change${changes.totalChanges === 1 ? '' : 's'}`;
|
||||
setFlashMessage(message);
|
||||
|
||||
// Auto-hide after 2 seconds
|
||||
flashTimeoutRef.current = setTimeout(() => {
|
||||
setFlashMessage(null);
|
||||
flashTimeoutRef.current = null;
|
||||
}, 2000);
|
||||
onShowFlash(message);
|
||||
}
|
||||
} finally {
|
||||
// Keep spinner visible for at least 500ms for visual feedback
|
||||
setTimeout(() => setIsRefreshing(false), 500);
|
||||
}
|
||||
}, [refreshFileTree, session.id]);
|
||||
}, [refreshFileTree, session.id, onShowFlash]);
|
||||
|
||||
// Silent refresh for auto-refresh (no flash notification)
|
||||
const silentRefresh = useCallback(async () => {
|
||||
@@ -133,15 +120,6 @@ export function FileExplorerPanel(props: FileExplorerPanelProps) {
|
||||
};
|
||||
}, [autoRefreshInterval, silentRefresh]);
|
||||
|
||||
// Cleanup flash timeout on unmount
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (flashTimeoutRef.current) {
|
||||
clearTimeout(flashTimeoutRef.current);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Hover handlers for refresh button overlay
|
||||
const handleRefreshMouseEnter = useCallback(() => {
|
||||
hoverTimeoutRef.current = setTimeout(() => {
|
||||
@@ -277,37 +255,6 @@ export function FileExplorerPanel(props: FileExplorerPanelProps) {
|
||||
|
||||
return (
|
||||
<div className="space-y-2 relative">
|
||||
{/* Flash notification for refresh results */}
|
||||
{flashMessage && (
|
||||
<div
|
||||
className="absolute inset-x-0 top-16 z-20 flex justify-center pointer-events-none"
|
||||
style={{
|
||||
animation: 'fadeInOut 2s ease-in-out forwards',
|
||||
}}
|
||||
>
|
||||
<style>
|
||||
{`
|
||||
@keyframes fadeInOut {
|
||||
0% { opacity: 0; transform: translateY(-8px); }
|
||||
15% { opacity: 1; transform: translateY(0); }
|
||||
85% { opacity: 1; transform: translateY(0); }
|
||||
100% { opacity: 0; transform: translateY(-8px); }
|
||||
}
|
||||
`}
|
||||
</style>
|
||||
<div
|
||||
className="px-4 py-2 rounded-lg shadow-lg text-xs font-medium"
|
||||
style={{
|
||||
backgroundColor: theme.colors.bgActivity,
|
||||
color: theme.colors.textMain,
|
||||
border: `1px solid ${theme.colors.border}`,
|
||||
}}
|
||||
>
|
||||
{flashMessage}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* File Tree Filter */}
|
||||
{fileTreeFilterOpen && (
|
||||
<div className="mb-3 pt-4">
|
||||
|
||||
@@ -282,7 +282,7 @@ const LOAD_MORE_COUNT = 50; // Entries to add when scrolling
|
||||
|
||||
export const HistoryPanel = React.memo(forwardRef<HistoryPanelHandle, HistoryPanelProps>(function HistoryPanel({ session, theme, onJumpToClaudeSession, onResumeSession, onOpenSessionAsTab }, ref) {
|
||||
const [historyEntries, setHistoryEntries] = useState<HistoryEntry[]>([]);
|
||||
const [activeFilters, setActiveFilters] = useState<Set<HistoryEntryType>>(new Set(['AUTO', 'USER', 'LOOP_SUMMARY']));
|
||||
const [activeFilters, setActiveFilters] = useState<Set<HistoryEntryType>>(new Set(['AUTO', 'USER', 'LOOP']));
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [selectedIndex, setSelectedIndex] = useState<number>(-1);
|
||||
const [detailModalEntry, setDetailModalEntry] = useState<HistoryEntry | null>(null);
|
||||
@@ -572,7 +572,7 @@ export const HistoryPanel = React.memo(forwardRef<HistoryPanelHandle, HistoryPan
|
||||
return { bg: theme.colors.warning + '20', text: theme.colors.warning, border: theme.colors.warning + '40' };
|
||||
case 'USER':
|
||||
return { bg: theme.colors.accent + '20', text: theme.colors.accent, border: theme.colors.accent + '40' };
|
||||
case 'LOOP_SUMMARY':
|
||||
case 'LOOP':
|
||||
return { bg: theme.colors.success + '20', text: theme.colors.success, border: theme.colors.success + '40' };
|
||||
default:
|
||||
return { bg: theme.colors.bgActivity, text: theme.colors.textDim, border: theme.colors.border };
|
||||
@@ -586,7 +586,7 @@ export const HistoryPanel = React.memo(forwardRef<HistoryPanelHandle, HistoryPan
|
||||
return Bot;
|
||||
case 'USER':
|
||||
return User;
|
||||
case 'LOOP_SUMMARY':
|
||||
case 'LOOP':
|
||||
return RefreshCw;
|
||||
default:
|
||||
return Bot;
|
||||
@@ -599,31 +599,32 @@ export const HistoryPanel = React.memo(forwardRef<HistoryPanelHandle, HistoryPan
|
||||
<div className="flex items-start gap-3 mb-4 pt-2">
|
||||
{/* Left-justified filter pills */}
|
||||
<div className="flex gap-2 flex-shrink-0">
|
||||
{(['AUTO', 'USER', 'LOOP_SUMMARY'] as HistoryEntryType[]).map(type => {
|
||||
const isActive = activeFilters.has(type);
|
||||
const colors = getPillColor(type);
|
||||
const Icon = getEntryIcon(type);
|
||||
// Use shorter label for LOOP_SUMMARY
|
||||
const displayLabel = type === 'LOOP_SUMMARY' ? 'LOOP' : type;
|
||||
{/* Only show LOOP pill if there are LOOP entries */}
|
||||
{(['AUTO', 'USER', 'LOOP'] as HistoryEntryType[])
|
||||
.filter(type => type !== 'LOOP' || historyEntries.some(e => e.type === 'LOOP'))
|
||||
.map(type => {
|
||||
const isActive = activeFilters.has(type);
|
||||
const colors = getPillColor(type);
|
||||
const Icon = getEntryIcon(type);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => toggleFilter(type)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-bold uppercase transition-all ${
|
||||
isActive ? 'opacity-100' : 'opacity-40'
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: isActive ? colors.bg : 'transparent',
|
||||
color: isActive ? colors.text : theme.colors.textDim,
|
||||
border: `1px solid ${isActive ? colors.border : theme.colors.border}`
|
||||
}}
|
||||
>
|
||||
<Icon className="w-3 h-3" />
|
||||
{displayLabel}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
return (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => toggleFilter(type)}
|
||||
className={`flex items-center gap-1.5 px-3 py-1.5 rounded-full text-xs font-bold uppercase transition-all ${
|
||||
isActive ? 'opacity-100' : 'opacity-40'
|
||||
}`}
|
||||
style={{
|
||||
backgroundColor: isActive ? colors.bg : 'transparent',
|
||||
color: isActive ? colors.text : theme.colors.textDim,
|
||||
border: `1px solid ${isActive ? colors.border : theme.colors.border}`
|
||||
}}
|
||||
>
|
||||
<Icon className="w-3 h-3" />
|
||||
{type}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* 24-hour activity bar graph */}
|
||||
@@ -757,7 +758,7 @@ export const HistoryPanel = React.memo(forwardRef<HistoryPanelHandle, HistoryPan
|
||||
}}
|
||||
>
|
||||
<Icon className="w-2.5 h-2.5" />
|
||||
{entry.type === 'LOOP_SUMMARY' ? 'LOOP' : entry.type}
|
||||
{entry.type}
|
||||
</span>
|
||||
|
||||
{/* Session Name or ID Octet (clickable) - opens session as new tab */}
|
||||
|
||||
@@ -140,6 +140,7 @@ interface MainPanelProps {
|
||||
onToggleTabSaveToHistory?: () => void;
|
||||
showUnreadOnly?: boolean;
|
||||
onToggleUnreadFilter?: () => void;
|
||||
onOpenTabSearch?: () => void;
|
||||
// Scroll position persistence
|
||||
onScrollPositionChange?: (scrollTop: number) => void;
|
||||
// Input blur handler for persisting AI input state
|
||||
@@ -184,7 +185,7 @@ export function MainPanel(props: MainPanelProps) {
|
||||
const headerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Extract tab handlers from props
|
||||
const { onTabSelect, onTabClose, onNewTab, onTabRename, onRequestTabRename, onTabReorder, onCloseOtherTabs, onTabStar, showUnreadOnly, onToggleUnreadFilter } = props;
|
||||
const { onTabSelect, onTabClose, onNewTab, onTabRename, onRequestTabRename, onTabReorder, onCloseOtherTabs, onTabStar, showUnreadOnly, onToggleUnreadFilter, onOpenTabSearch } = props;
|
||||
|
||||
// Get the active tab for header display
|
||||
// The header should show the active tab's data (UUID, name, cost, context), not session-level data
|
||||
@@ -771,6 +772,7 @@ export function MainPanel(props: MainPanelProps) {
|
||||
onTabStar={onTabStar}
|
||||
showUnreadOnly={showUnreadOnly}
|
||||
onToggleUnreadFilter={onToggleUnreadFilter}
|
||||
onOpenTabSearch={onOpenTabSearch}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ interface ProcessNode {
|
||||
claudeSessionId?: string; // UUID octet from the Claude session (for AI processes)
|
||||
tabId?: string; // Tab ID for navigation to specific AI tab
|
||||
startTime?: number; // Process start timestamp for runtime calculation
|
||||
isAutoRun?: boolean; // True for batch processes from Auto Run
|
||||
}
|
||||
|
||||
// Format runtime in human readable format (e.g., "2m 30s", "1h 5m", "3d 2h")
|
||||
@@ -289,10 +290,12 @@ export function ProcessMonitor(props: ProcessMonitorProps) {
|
||||
sessionProcesses.forEach(proc => {
|
||||
const processType = getProcessType(proc.sessionId);
|
||||
let label: string;
|
||||
let isAutoRun = false;
|
||||
if (processType === 'terminal') {
|
||||
label = 'Terminal Shell';
|
||||
} else if (processType === 'batch') {
|
||||
label = `AI Agent (${proc.toolType}) - Batch`;
|
||||
label = `AI Agent (${proc.toolType})`;
|
||||
isAutoRun = true;
|
||||
} else if (processType === 'synopsis') {
|
||||
label = `AI Agent (${proc.toolType}) - Synopsis`;
|
||||
} else {
|
||||
@@ -336,7 +339,8 @@ export function ProcessMonitor(props: ProcessMonitorProps) {
|
||||
cwd: proc.cwd,
|
||||
claudeSessionId,
|
||||
tabId,
|
||||
startTime: proc.startTime
|
||||
startTime: proc.startTime,
|
||||
isAutoRun
|
||||
});
|
||||
});
|
||||
|
||||
@@ -631,6 +635,17 @@ export function ProcessMonitor(props: ProcessMonitorProps) {
|
||||
style={{ backgroundColor: theme.colors.success }}
|
||||
/>
|
||||
<span className="text-sm flex-1 truncate">{node.label}</span>
|
||||
{node.isAutoRun && (
|
||||
<span
|
||||
className="text-xs font-semibold px-1.5 py-0.5 rounded flex-shrink-0"
|
||||
style={{
|
||||
backgroundColor: theme.colors.accent + '20',
|
||||
color: theme.colors.accent
|
||||
}}
|
||||
>
|
||||
AUTO
|
||||
</span>
|
||||
)}
|
||||
{node.claudeSessionId && node.sessionId && onNavigateToSession && (
|
||||
<button
|
||||
className="text-xs font-mono flex-shrink-0 hover:underline cursor-pointer"
|
||||
|
||||
@@ -52,6 +52,7 @@ interface RightPanelProps {
|
||||
refreshFileTree: (sessionId: string) => Promise<FileTreeChanges | undefined>;
|
||||
setSessions: React.Dispatch<React.SetStateAction<Session[]>>;
|
||||
onAutoRefreshChange?: (interval: number) => void;
|
||||
onShowFlash?: (message: string) => void;
|
||||
|
||||
// Auto Run handlers
|
||||
autoRunDocumentList: string[]; // List of document filenames (without .md)
|
||||
@@ -87,7 +88,7 @@ export const RightPanel = forwardRef<RightPanelHandle, RightPanelProps>(function
|
||||
fileTreeFilter, setFileTreeFilter, fileTreeFilterOpen, setFileTreeFilterOpen,
|
||||
filteredFileTree, selectedFileIndex, setSelectedFileIndex, previewFile, fileTreeContainerRef,
|
||||
fileTreeFilterInputRef, toggleFolder, handleFileClick, expandAllFolders, collapseAllFolders,
|
||||
updateSessionWorkingDirectory, refreshFileTree, setSessions, onAutoRefreshChange,
|
||||
updateSessionWorkingDirectory, refreshFileTree, setSessions, onAutoRefreshChange, onShowFlash,
|
||||
autoRunDocumentList, autoRunDocumentTree, autoRunContent, autoRunIsLoadingDocuments,
|
||||
onAutoRunContentChange, onAutoRunModeChange, onAutoRunStateChange,
|
||||
onAutoRunSelectDocument, onAutoRunCreateDocument, onAutoRunRefresh, onAutoRunOpenSetup,
|
||||
@@ -229,6 +230,7 @@ export const RightPanel = forwardRef<RightPanelHandle, RightPanelProps>(function
|
||||
refreshFileTree={refreshFileTree}
|
||||
setSessions={setSessions}
|
||||
onAutoRefreshChange={onAutoRefreshChange}
|
||||
onShowFlash={onShowFlash}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -77,6 +77,7 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
const [shells, setShells] = useState<ShellInfo[]>([]);
|
||||
const [shellsLoading, setShellsLoading] = useState(false);
|
||||
const [shellsLoaded, setShellsLoaded] = useState(false);
|
||||
const [customAgentPaths, setCustomAgentPaths] = useState<Record<string, string>>({});
|
||||
|
||||
// TTS test state
|
||||
const [testTtsId, setTestTtsId] = useState<number | null>(null);
|
||||
@@ -198,6 +199,10 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
configs[agent.id] = config;
|
||||
}
|
||||
setAgentConfigs(configs);
|
||||
|
||||
// Load custom paths for agents
|
||||
const paths = await window.maestro.agents.getAllCustomPaths();
|
||||
setCustomAgentPaths(paths);
|
||||
} catch (error) {
|
||||
console.error('Failed to load agents:', error);
|
||||
} finally {
|
||||
@@ -562,45 +567,92 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
<div className="text-sm opacity-50">Loading agents...</div>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{agents.map((agent) => (
|
||||
<button
|
||||
{agents.filter((agent) => !agent.hidden).map((agent) => (
|
||||
<div
|
||||
key={agent.id}
|
||||
disabled={agent.id !== 'claude-code' || !agent.available}
|
||||
onClick={() => props.setDefaultAgent(agent.id)}
|
||||
className={`w-full text-left p-3 rounded border transition-all ${
|
||||
className={`rounded border transition-all ${
|
||||
props.defaultAgent === agent.id ? 'ring-2' : ''
|
||||
} ${(agent.id !== 'claude-code' || !agent.available) ? 'opacity-40 cursor-not-allowed' : 'hover:bg-opacity-10'}`}
|
||||
}`}
|
||||
style={{
|
||||
borderColor: theme.colors.border,
|
||||
backgroundColor: props.defaultAgent === agent.id ? theme.colors.accentDim : theme.colors.bgMain,
|
||||
ringColor: theme.colors.accent,
|
||||
color: theme.colors.textMain,
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium">{agent.name}</div>
|
||||
{agent.path && (
|
||||
<div className="text-xs opacity-50 font-mono mt-1">{agent.path}</div>
|
||||
<button
|
||||
disabled={agent.id !== 'claude-code' || !agent.available}
|
||||
onClick={() => props.setDefaultAgent(agent.id)}
|
||||
className={`w-full text-left p-3 ${(agent.id !== 'claude-code' || !agent.available) ? 'opacity-40 cursor-not-allowed' : 'hover:bg-opacity-10'}`}
|
||||
style={{ color: theme.colors.textMain }}
|
||||
>
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<div className="font-medium">{agent.name}</div>
|
||||
{agent.path && (
|
||||
<div className="text-xs opacity-50 font-mono mt-1">{agent.path}</div>
|
||||
)}
|
||||
</div>
|
||||
{agent.id === 'claude-code' ? (
|
||||
agent.available ? (
|
||||
<span className="text-xs px-2 py-0.5 rounded" style={{ backgroundColor: theme.colors.success + '20', color: theme.colors.success }}>
|
||||
Available
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs px-2 py-0.5 rounded" style={{ backgroundColor: theme.colors.error + '20', color: theme.colors.error }}>
|
||||
Not Found
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<span className="text-xs px-2 py-0.5 rounded" style={{ backgroundColor: theme.colors.warning + '20', color: theme.colors.warning }}>
|
||||
Coming Soon
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{agent.id === 'claude-code' ? (
|
||||
agent.available ? (
|
||||
<span className="text-xs px-2 py-0.5 rounded" style={{ backgroundColor: theme.colors.success + '20', color: theme.colors.success }}>
|
||||
Available
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs px-2 py-0.5 rounded" style={{ backgroundColor: theme.colors.error + '20', color: theme.colors.error }}>
|
||||
Not Found
|
||||
</span>
|
||||
)
|
||||
) : (
|
||||
<span className="text-xs px-2 py-0.5 rounded" style={{ backgroundColor: theme.colors.warning + '20', color: theme.colors.warning }}>
|
||||
Coming Soon
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
</button>
|
||||
{/* Custom path input for Claude Code */}
|
||||
{agent.id === 'claude-code' && (
|
||||
<div className="px-3 pb-3 pt-1 border-t" style={{ borderColor: theme.colors.border }}>
|
||||
<label className="block text-xs opacity-60 mb-1">Custom Path (optional)</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={customAgentPaths[agent.id] || ''}
|
||||
onChange={(e) => {
|
||||
const newPaths = { ...customAgentPaths, [agent.id]: e.target.value };
|
||||
setCustomAgentPaths(newPaths);
|
||||
}}
|
||||
onBlur={async () => {
|
||||
const path = customAgentPaths[agent.id]?.trim() || null;
|
||||
await window.maestro.agents.setCustomPath(agent.id, path);
|
||||
// Refresh agents to pick up the new path
|
||||
loadAgents();
|
||||
}}
|
||||
placeholder="/path/to/claude"
|
||||
className="flex-1 p-1.5 rounded border bg-transparent outline-none text-xs font-mono"
|
||||
style={{ borderColor: theme.colors.border, color: theme.colors.textMain }}
|
||||
/>
|
||||
{customAgentPaths[agent.id] && (
|
||||
<button
|
||||
onClick={async () => {
|
||||
const newPaths = { ...customAgentPaths };
|
||||
delete newPaths[agent.id];
|
||||
setCustomAgentPaths(newPaths);
|
||||
await window.maestro.agents.setCustomPath(agent.id, null);
|
||||
loadAgents();
|
||||
}}
|
||||
className="px-2 py-1 rounded text-xs"
|
||||
style={{ backgroundColor: theme.colors.bgActivity, color: theme.colors.textDim }}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs opacity-40 mt-1">
|
||||
Specify a custom path if the agent is not in your PATH
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
@@ -1274,7 +1326,7 @@ export function SettingsModal(props: SettingsModalProps) {
|
||||
</span>
|
||||
</div>
|
||||
<p className="text-xs opacity-50 mb-3" style={{ color: theme.colors.textDim }}>
|
||||
Not all shortcuts can be modified. Press <kbd className="px-1.5 py-0.5 rounded font-mono" style={{ backgroundColor: theme.colors.bgActivity }}>⌘/</kbd> to view the full list of keyboard shortcuts.
|
||||
Not all shortcuts can be modified. Press <kbd className="px-1.5 py-0.5 rounded font-mono" style={{ backgroundColor: theme.colors.bgActivity }}>⌘/</kbd> from the main interface to view the full list of keyboard shortcuts.
|
||||
</p>
|
||||
<div className="space-y-2 flex-1 overflow-y-auto pr-2 scrollbar-thin">
|
||||
{filteredShortcuts.map((sc: Shortcut) => (
|
||||
|
||||
@@ -114,6 +114,11 @@ export function ShortcutsHelpModal({ theme, shortcuts, onClose }: ShortcutsHelpM
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="px-4 py-3 border-t" style={{ borderColor: theme.colors.border }}>
|
||||
<p className="text-xs" style={{ color: theme.colors.textDim }}>
|
||||
Many shortcuts can be customized from Settings → Shortcuts.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { X, Plus, Star, Copy, Edit2, Mail, Pencil } from 'lucide-react';
|
||||
import { X, Plus, Star, Copy, Edit2, Mail, Pencil, Search } from 'lucide-react';
|
||||
import type { AITab, Theme } from '../types';
|
||||
|
||||
interface TabBarProps {
|
||||
@@ -17,6 +17,7 @@ interface TabBarProps {
|
||||
onTabStar?: (tabId: string, starred: boolean) => void;
|
||||
showUnreadOnly?: boolean;
|
||||
onToggleUnreadFilter?: () => void;
|
||||
onOpenTabSearch?: () => void;
|
||||
}
|
||||
|
||||
interface TabProps {
|
||||
@@ -492,7 +493,8 @@ export function TabBar({
|
||||
onCloseOthers,
|
||||
onTabStar,
|
||||
showUnreadOnly: showUnreadOnlyProp,
|
||||
onToggleUnreadFilter
|
||||
onToggleUnreadFilter,
|
||||
onOpenTabSearch
|
||||
}: TabBarProps) {
|
||||
const [contextMenu, setContextMenu] = useState<{
|
||||
tabId: string;
|
||||
@@ -621,11 +623,23 @@ export function TabBar({
|
||||
borderColor: theme.colors.border
|
||||
}}
|
||||
>
|
||||
{/* Unread filter toggle - sticky at the beginning with full-height opaque background */}
|
||||
{/* Tab search and unread filter - sticky at the beginning with full-height opaque background */}
|
||||
<div
|
||||
className="sticky left-0 flex items-center shrink-0 pl-2 pr-2 self-stretch"
|
||||
className="sticky left-0 flex items-center shrink-0 pl-2 pr-1 gap-1 self-stretch"
|
||||
style={{ backgroundColor: theme.colors.bgSidebar, zIndex: 5 }}
|
||||
>
|
||||
{/* Tab search button */}
|
||||
{onOpenTabSearch && (
|
||||
<button
|
||||
onClick={onOpenTabSearch}
|
||||
className="flex items-center justify-center w-6 h-6 rounded hover:bg-white/10 transition-colors"
|
||||
style={{ color: theme.colors.textDim }}
|
||||
title="Search tabs (Cmd+Shift+O)"
|
||||
>
|
||||
<Search className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
{/* Unread filter toggle */}
|
||||
<button
|
||||
onClick={toggleUnreadFilter}
|
||||
className="relative flex items-center justify-center w-6 h-6 rounded transition-colors"
|
||||
|
||||
28
src/renderer/global.d.ts
vendored
28
src/renderer/global.d.ts
vendored
@@ -46,6 +46,8 @@ interface UsageStats {
|
||||
contextWindow: number;
|
||||
}
|
||||
|
||||
type HistoryEntryType = 'AUTO' | 'USER' | 'LOOP';
|
||||
|
||||
interface MaestroAPI {
|
||||
settings: {
|
||||
get: (key: string) => Promise<unknown>;
|
||||
@@ -78,6 +80,7 @@ interface MaestroAPI {
|
||||
onData: (callback: (sessionId: string, data: string) => void) => () => void;
|
||||
onExit: (callback: (sessionId: string, code: number) => void) => () => void;
|
||||
onSessionId: (callback: (sessionId: string, claudeSessionId: string) => void) => () => void;
|
||||
onSlashCommands: (callback: (sessionId: string, slashCommands: string[]) => void) => () => void;
|
||||
onRemoteCommand: (callback: (sessionId: string, command: string, inputMode?: 'ai' | 'terminal') => void) => () => void;
|
||||
onRemoteSwitchMode: (callback: (sessionId: string, mode: 'ai' | 'terminal') => void) => () => void;
|
||||
onRemoteInterrupt: (callback: (sessionId: string) => void) => () => void;
|
||||
@@ -112,8 +115,8 @@ interface MaestroAPI {
|
||||
}>, activeTabId: string) => Promise<void>;
|
||||
};
|
||||
git: {
|
||||
status: (cwd: string) => Promise<{ staged: string[]; unstaged: string[]; untracked: string[]; branch?: string }>;
|
||||
diff: (cwd: string, file?: string) => Promise<string>;
|
||||
status: (cwd: string) => Promise<{ stdout: string; stderr: string }>;
|
||||
diff: (cwd: string, file?: string) => Promise<{ stdout: string; stderr: string }>;
|
||||
isRepo: (cwd: string) => Promise<boolean>;
|
||||
numstat: (cwd: string) => Promise<{ stdout: string; stderr: string }>;
|
||||
branch: (cwd: string) => Promise<{ stdout: string; stderr: string }>;
|
||||
@@ -215,6 +218,12 @@ interface MaestroAPI {
|
||||
shell: {
|
||||
openExternal: (url: string) => Promise<void>;
|
||||
};
|
||||
tunnel: {
|
||||
isCloudflaredInstalled: () => Promise<boolean>;
|
||||
start: () => Promise<{ success: boolean; url?: string; error?: string }>;
|
||||
stop: () => Promise<{ success: boolean }>;
|
||||
getStatus: () => Promise<{ isRunning: boolean; url: string | null; error: string | null }>;
|
||||
};
|
||||
devtools: {
|
||||
open: () => Promise<void>;
|
||||
close: () => Promise<void>;
|
||||
@@ -320,28 +329,39 @@ interface MaestroAPI {
|
||||
history: {
|
||||
getAll: (projectPath?: string, sessionId?: string) => Promise<Array<{
|
||||
id: string;
|
||||
type: 'AUTO' | 'USER';
|
||||
type: HistoryEntryType;
|
||||
timestamp: number;
|
||||
summary: string;
|
||||
fullResponse?: string;
|
||||
claudeSessionId?: string;
|
||||
projectPath: string;
|
||||
sessionId?: string;
|
||||
sessionName?: string;
|
||||
contextUsage?: number;
|
||||
usageStats?: UsageStats;
|
||||
success?: boolean;
|
||||
elapsedTimeMs?: number;
|
||||
validated?: boolean;
|
||||
}>>;
|
||||
add: (entry: {
|
||||
id: string;
|
||||
type: 'AUTO' | 'USER';
|
||||
type: HistoryEntryType;
|
||||
timestamp: number;
|
||||
summary: string;
|
||||
fullResponse?: string;
|
||||
claudeSessionId?: string;
|
||||
projectPath: string;
|
||||
sessionId?: string;
|
||||
sessionName?: string;
|
||||
contextUsage?: number;
|
||||
usageStats?: UsageStats;
|
||||
success?: boolean;
|
||||
elapsedTimeMs?: number;
|
||||
validated?: boolean;
|
||||
}) => Promise<boolean>;
|
||||
clear: (projectPath?: string) => Promise<boolean>;
|
||||
delete: (entryId: string) => Promise<boolean>;
|
||||
update: (entryId: string, updates: { validated?: boolean }) => Promise<boolean>;
|
||||
};
|
||||
notification: {
|
||||
show: (title: string, body: string) => Promise<{ success: boolean; error?: string }>;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import type { BatchRunState, BatchRunConfig, BatchDocumentEntry, Session, HistoryEntry, UsageStats } from '../types';
|
||||
import type { BatchRunState, BatchRunConfig, BatchDocumentEntry, Session, HistoryEntry, UsageStats, Group } from '../types';
|
||||
import { substituteTemplateVariables, TemplateContext } from '../utils/templateVariables';
|
||||
|
||||
// Regex to count unchecked markdown checkboxes: - [ ] task
|
||||
const UNCHECKED_TASK_REGEX = /^[\s]*-\s*\[\s*\]\s*.+$/gm;
|
||||
@@ -55,6 +56,7 @@ interface PRResultInfo {
|
||||
|
||||
interface UseBatchProcessorProps {
|
||||
sessions: Session[];
|
||||
groups: Group[];
|
||||
onUpdateSession: (sessionId: string, updates: Partial<Session>) => void;
|
||||
onSpawnAgent: (sessionId: string, prompt: string, cwdOverride?: string) => Promise<{ success: boolean; response?: string; claudeSessionId?: string; usageStats?: UsageStats }>;
|
||||
onSpawnSynopsis: (sessionId: string, cwd: string, claudeSessionId: string, prompt: string) => Promise<{ success: boolean; response?: string }>;
|
||||
@@ -100,6 +102,73 @@ function formatLoopDuration(ms: number): string {
|
||||
return `${hours}h ${remainingMinutes}m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a loop summary history entry
|
||||
*/
|
||||
interface LoopSummaryParams {
|
||||
loopIteration: number;
|
||||
loopTasksCompleted: number;
|
||||
loopStartTime: number;
|
||||
loopTotalInputTokens: number;
|
||||
loopTotalOutputTokens: number;
|
||||
loopTotalCost: number;
|
||||
sessionCwd: string;
|
||||
sessionId: string;
|
||||
isFinal: boolean;
|
||||
exitReason?: string;
|
||||
}
|
||||
|
||||
function createLoopSummaryEntry(params: LoopSummaryParams): Omit<HistoryEntry, 'id'> {
|
||||
const {
|
||||
loopIteration,
|
||||
loopTasksCompleted,
|
||||
loopStartTime,
|
||||
loopTotalInputTokens,
|
||||
loopTotalOutputTokens,
|
||||
loopTotalCost,
|
||||
sessionCwd,
|
||||
sessionId,
|
||||
isFinal,
|
||||
exitReason
|
||||
} = params;
|
||||
|
||||
const loopElapsedMs = Date.now() - loopStartTime;
|
||||
const loopNumber = loopIteration + 1;
|
||||
const summaryPrefix = isFinal ? `Loop ${loopNumber} (final)` : `Loop ${loopNumber}`;
|
||||
const loopSummary = `${summaryPrefix} completed: ${loopTasksCompleted} task${loopTasksCompleted !== 1 ? 's' : ''} accomplished`;
|
||||
|
||||
const loopDetails = [
|
||||
`**${summaryPrefix} Summary**`,
|
||||
'',
|
||||
`- **Tasks Accomplished:** ${loopTasksCompleted}`,
|
||||
`- **Duration:** ${formatLoopDuration(loopElapsedMs)}`,
|
||||
loopTotalInputTokens > 0 || loopTotalOutputTokens > 0
|
||||
? `- **Tokens:** ${(loopTotalInputTokens + loopTotalOutputTokens).toLocaleString()} (${loopTotalInputTokens.toLocaleString()} in / ${loopTotalOutputTokens.toLocaleString()} out)`
|
||||
: '',
|
||||
loopTotalCost > 0 ? `- **Cost:** $${loopTotalCost.toFixed(4)}` : '',
|
||||
exitReason ? `- **Exit Reason:** ${exitReason}` : '',
|
||||
].filter(line => line !== '').join('\n');
|
||||
|
||||
return {
|
||||
type: 'LOOP',
|
||||
timestamp: Date.now(),
|
||||
summary: loopSummary,
|
||||
fullResponse: loopDetails,
|
||||
projectPath: sessionCwd,
|
||||
sessionId: sessionId,
|
||||
success: true,
|
||||
elapsedTimeMs: loopElapsedMs,
|
||||
usageStats: loopTotalInputTokens > 0 || loopTotalOutputTokens > 0 ? {
|
||||
inputTokens: loopTotalInputTokens,
|
||||
outputTokens: loopTotalOutputTokens,
|
||||
cacheReadInputTokens: 0,
|
||||
cacheCreationInputTokens: 0,
|
||||
totalCostUsd: loopTotalCost,
|
||||
contextWindow: 0
|
||||
} : undefined
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Count unchecked tasks in markdown content
|
||||
* Matches lines like: - [ ] task description
|
||||
@@ -161,6 +230,7 @@ function parseSynopsis(response: string): { shortSummary: string; fullSynopsis:
|
||||
|
||||
export function useBatchProcessor({
|
||||
sessions,
|
||||
groups,
|
||||
onUpdateSession,
|
||||
onSpawnAgent,
|
||||
onSpawnSynopsis,
|
||||
@@ -395,11 +465,30 @@ ${docList}
|
||||
let loopTotalOutputTokens = 0;
|
||||
let loopTotalCost = 0;
|
||||
|
||||
// Helper to add final loop summary (defined here so it has access to tracking vars)
|
||||
const addFinalLoopSummary = (exitReason: string) => {
|
||||
if (loopEnabled && (loopTasksCompleted > 0 || loopIteration > 0)) {
|
||||
onAddHistoryEntry(createLoopSummaryEntry({
|
||||
loopIteration,
|
||||
loopTasksCompleted,
|
||||
loopStartTime,
|
||||
loopTotalInputTokens,
|
||||
loopTotalOutputTokens,
|
||||
loopTotalCost,
|
||||
sessionCwd: session.cwd,
|
||||
sessionId,
|
||||
isFinal: true,
|
||||
exitReason
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
// Main processing loop (handles loop mode)
|
||||
while (true) {
|
||||
// Check for stop request
|
||||
if (stopRequestedRefs.current[sessionId]) {
|
||||
console.log('[BatchProcessor] Batch run stopped by user for session:', sessionId);
|
||||
addFinalLoopSummary('Stopped by user');
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -495,7 +584,8 @@ ${docList}
|
||||
|
||||
// Re-read document to get updated task count
|
||||
const { taskCount: newRemainingTasks } = await readDocAndCountTasks(folderPath, docEntry.filename);
|
||||
const tasksCompletedThisRun = remainingTasks - newRemainingTasks;
|
||||
// Calculate tasks completed - ensure it's never negative (Claude may have added tasks)
|
||||
const tasksCompletedThisRun = Math.max(0, remainingTasks - newRemainingTasks);
|
||||
|
||||
// Update counters
|
||||
docTasksCompleted += tasksCompletedThisRun;
|
||||
@@ -643,17 +733,20 @@ ${docList}
|
||||
// Check if we've hit the max loop limit
|
||||
if (maxLoops !== null && maxLoops !== undefined && loopIteration + 1 >= maxLoops) {
|
||||
console.log(`[BatchProcessor] Reached max loop limit (${maxLoops}), exiting loop`);
|
||||
addFinalLoopSummary(`Reached max loop limit (${maxLoops})`);
|
||||
break;
|
||||
}
|
||||
|
||||
// Check for stop request after full pass
|
||||
if (stopRequestedRefs.current[sessionId]) {
|
||||
addFinalLoopSummary('Stopped by user');
|
||||
break;
|
||||
}
|
||||
|
||||
// Safety check: if we didn't process ANY tasks this iteration, exit to avoid infinite loop
|
||||
if (!anyTasksProcessedThisIteration) {
|
||||
console.warn('[BatchProcessor] No tasks processed this iteration - exiting to avoid infinite loop');
|
||||
addFinalLoopSummary('No tasks processed this iteration');
|
||||
break;
|
||||
}
|
||||
|
||||
@@ -676,6 +769,7 @@ ${docList}
|
||||
|
||||
if (!anyNonResetDocsHaveTasks) {
|
||||
console.log('[BatchProcessor] All non-reset documents completed, exiting loop');
|
||||
addFinalLoopSummary('All tasks completed');
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -706,7 +800,7 @@ ${docList}
|
||||
].filter(line => line !== '').join('\n');
|
||||
|
||||
onAddHistoryEntry({
|
||||
type: 'LOOP_SUMMARY',
|
||||
type: 'LOOP',
|
||||
timestamp: Date.now(),
|
||||
summary: loopSummary,
|
||||
fullResponse: loopDetails,
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface GitStatus {
|
||||
path: string;
|
||||
status: string;
|
||||
}>;
|
||||
branch?: string;
|
||||
}
|
||||
|
||||
export interface GitDiff {
|
||||
@@ -74,17 +75,20 @@ export const gitService = {
|
||||
},
|
||||
|
||||
/**
|
||||
* Get git status (porcelain format)
|
||||
* Get git status (porcelain format) and current branch
|
||||
*/
|
||||
async getStatus(cwd: string): Promise<GitStatus> {
|
||||
try {
|
||||
const result = await window.maestro.git.status(cwd);
|
||||
const [statusResult, branchResult] = await Promise.all([
|
||||
window.maestro.git.status(cwd),
|
||||
window.maestro.git.branch(cwd)
|
||||
]);
|
||||
|
||||
// Parse porcelain format output
|
||||
const files: Array<{ path: string; status: string }> = [];
|
||||
|
||||
if (result.stdout) {
|
||||
const lines = result.stdout.trim().split('\n').filter(line => line.length > 0);
|
||||
if (statusResult.stdout) {
|
||||
const lines = statusResult.stdout.trim().split('\n').filter(line => line.length > 0);
|
||||
|
||||
for (const line of lines) {
|
||||
// Porcelain format: XY PATH or XY PATH -> NEWPATH (for renames)
|
||||
@@ -95,7 +99,10 @@ export const gitService = {
|
||||
}
|
||||
}
|
||||
|
||||
return { files };
|
||||
// Extract branch name
|
||||
const branch = branchResult.stdout?.trim() || undefined;
|
||||
|
||||
return { files, branch };
|
||||
} catch (error) {
|
||||
console.error('Git status error:', error);
|
||||
return { files: [] };
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// Type definitions for Maestro renderer
|
||||
|
||||
// Re-export theme types from shared location
|
||||
export { Theme, ThemeId, ThemeMode, ThemeColors, isValidThemeId } from '../../shared/theme-types';
|
||||
export type { Theme, ThemeId, ThemeMode, ThemeColors } from '../../shared/theme-types';
|
||||
export { isValidThemeId } from '../../shared/theme-types';
|
||||
|
||||
export type ToolType = 'claude' | 'claude-code' | 'aider' | 'opencode' | 'terminal';
|
||||
export type SessionState = 'idle' | 'busy' | 'waiting_input' | 'connecting' | 'error';
|
||||
@@ -73,7 +74,8 @@ export interface WorkLogItem {
|
||||
}
|
||||
|
||||
// History entry types for the History panel
|
||||
export type HistoryEntryType = 'AUTO' | 'USER' | 'LOOP_SUMMARY';
|
||||
// AUTO = task completed by Auto Run, USER = manual user prompt, LOOP = loop iteration summary
|
||||
export type HistoryEntryType = 'AUTO' | 'USER' | 'LOOP';
|
||||
|
||||
export interface HistoryEntry {
|
||||
id: string;
|
||||
@@ -368,6 +370,7 @@ export interface AgentConfig {
|
||||
name: string;
|
||||
available: boolean;
|
||||
path?: string;
|
||||
customPath?: string; // User-specified custom path (shown in UI even if not available)
|
||||
command?: string;
|
||||
args?: string[];
|
||||
hidden?: boolean; // If true, agent is hidden from UI (internal use only)
|
||||
|
||||
@@ -33,7 +33,7 @@ export interface UsageStats {
|
||||
}
|
||||
|
||||
// History entry types for the History panel
|
||||
export type HistoryEntryType = 'AUTO' | 'USER' | 'LOOP_SUMMARY';
|
||||
export type HistoryEntryType = 'AUTO' | 'USER' | 'LOOP';
|
||||
|
||||
export interface HistoryEntry {
|
||||
id: string;
|
||||
|
||||
@@ -75,6 +75,7 @@ export interface SessionData {
|
||||
thinkingStartTime?: number | null; // Timestamp when AI started thinking (for elapsed time display)
|
||||
aiTabs?: AITabData[];
|
||||
activeTabId?: string;
|
||||
bookmarked?: boolean; // Whether session is bookmarked (shows in Bookmarks group)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -476,10 +477,9 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
|
||||
// Ref for handleMessage to avoid stale closure issues
|
||||
const handleMessageRef = useRef<((event: MessageEvent) => void) | null>(null);
|
||||
|
||||
// Keep handlers ref up to date
|
||||
useEffect(() => {
|
||||
handlersRef.current = handlers;
|
||||
}, [handlers]);
|
||||
// Keep handlers ref up to date SYNCHRONOUSLY to avoid race conditions
|
||||
// This must happen before any WebSocket messages are processed
|
||||
handlersRef.current = handlers;
|
||||
|
||||
/**
|
||||
* Clear all timers
|
||||
@@ -677,11 +677,10 @@ export function useWebSocket(options: UseWebSocketOptions = {}): UseWebSocketRet
|
||||
}
|
||||
}, [startPingInterval]);
|
||||
|
||||
// Keep handleMessageRef up to date to avoid stale closure issues
|
||||
// The WebSocket uses a wrapper that always calls the latest handleMessage
|
||||
useEffect(() => {
|
||||
handleMessageRef.current = handleMessage;
|
||||
}, [handleMessage]);
|
||||
// Keep handleMessageRef up to date SYNCHRONOUSLY to avoid race conditions
|
||||
// This must happen before any WebSocket messages are received
|
||||
// Using useEffect would cause a race condition where messages arrive before the ref is set
|
||||
handleMessageRef.current = handleMessage;
|
||||
|
||||
/**
|
||||
* Attempt to reconnect to the server
|
||||
|
||||
@@ -341,7 +341,7 @@ export function AllSessionsView({
|
||||
searchQuery = '',
|
||||
}: AllSessionsViewProps) {
|
||||
const colors = useThemeColors();
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string> | null>(null);
|
||||
const [localSearchQuery, setLocalSearchQuery] = useState(searchQuery);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
@@ -357,10 +357,22 @@ export function AllSessionsView({
|
||||
);
|
||||
}, [sessions, localSearchQuery]);
|
||||
|
||||
// Organize sessions by group
|
||||
// Organize sessions by group, including a special "bookmarks" group
|
||||
const sessionsByGroup = useMemo((): Record<string, GroupInfo> => {
|
||||
const groups: Record<string, GroupInfo> = {};
|
||||
|
||||
// Add bookmarked sessions to a special "bookmarks" group
|
||||
const bookmarkedSessions = filteredSessions.filter(s => s.bookmarked);
|
||||
if (bookmarkedSessions.length > 0) {
|
||||
groups['bookmarks'] = {
|
||||
id: 'bookmarks',
|
||||
name: 'Bookmarks',
|
||||
emoji: '★',
|
||||
sessions: bookmarkedSessions,
|
||||
};
|
||||
}
|
||||
|
||||
// Organize remaining sessions by their actual groups
|
||||
for (const session of filteredSessions) {
|
||||
const groupKey = session.groupId || 'ungrouped';
|
||||
|
||||
@@ -378,20 +390,31 @@ export function AllSessionsView({
|
||||
return groups;
|
||||
}, [filteredSessions]);
|
||||
|
||||
// Get sorted group keys (ungrouped last)
|
||||
// Get sorted group keys (bookmarks first, ungrouped last)
|
||||
const sortedGroupKeys = useMemo(() => {
|
||||
const keys = Object.keys(sessionsByGroup);
|
||||
return keys.sort((a, b) => {
|
||||
// Put 'bookmarks' at the start
|
||||
if (a === 'bookmarks') return -1;
|
||||
if (b === 'bookmarks') return 1;
|
||||
// Put 'ungrouped' at the end
|
||||
if (a === 'ungrouped') return 1;
|
||||
if (b === 'ungrouped') return -1;
|
||||
return sessionsByGroup[a].name.localeCompare(sessionsByGroup[b].name);
|
||||
});
|
||||
}, [sessionsByGroup]);
|
||||
|
||||
// Initialize collapsed groups with all groups collapsed by default
|
||||
useEffect(() => {
|
||||
if (collapsedGroups === null && sortedGroupKeys.length > 0) {
|
||||
setCollapsedGroups(new Set(sortedGroupKeys));
|
||||
}
|
||||
}, [sortedGroupKeys, collapsedGroups]);
|
||||
|
||||
// Toggle group collapse
|
||||
const handleToggleCollapse = useCallback((groupId: string) => {
|
||||
setCollapsedGroups((prev) => {
|
||||
const next = new Set(prev);
|
||||
const next = new Set(prev || []);
|
||||
if (next.has(groupId)) {
|
||||
next.delete(groupId);
|
||||
} else {
|
||||
@@ -615,7 +638,7 @@ export function AllSessionsView({
|
||||
sessions={group.sessions}
|
||||
activeSessionId={activeSessionId}
|
||||
onSelectSession={handleSelectSession}
|
||||
isCollapsed={collapsedGroups.has(groupKey)}
|
||||
isCollapsed={collapsedGroups?.has(groupKey) ?? true}
|
||||
onToggleCollapse={handleToggleCollapse}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -25,10 +25,10 @@ import { DEFAULT_SLASH_COMMANDS, type SlashCommand } from './SlashCommandAutocom
|
||||
// CommandHistoryDrawer and RecentCommandChips removed for simpler mobile UI
|
||||
import { ResponseViewer, type ResponseItem } from './ResponseViewer';
|
||||
import { OfflineQueueBanner } from './OfflineQueueBanner';
|
||||
import { ConnectionStatusIndicator } from './ConnectionStatusIndicator';
|
||||
import { MessageHistory, type LogEntry } from './MessageHistory';
|
||||
import { AutoRunIndicator } from './AutoRunIndicator';
|
||||
import { TabBar } from './TabBar';
|
||||
import { TabSearchModal } from './TabSearchModal';
|
||||
import type { Session, LastResponsePreview } from '../hooks/useSessions';
|
||||
|
||||
|
||||
@@ -282,6 +282,7 @@ export default function MobileApp() {
|
||||
const [activeTabId, setActiveTabId] = useState<string | null>(null);
|
||||
const [showAllSessions, setShowAllSessions] = useState(false);
|
||||
const [showHistoryPanel, setShowHistoryPanel] = useState(false);
|
||||
const [showTabSearch, setShowTabSearch] = useState(false);
|
||||
const [commandInput, setCommandInput] = useState('');
|
||||
const [showResponseViewer, setShowResponseViewer] = useState(false);
|
||||
const [selectedResponse, setSelectedResponse] = useState<LastResponsePreview | null>(null);
|
||||
@@ -796,6 +797,17 @@ export default function MobileApp() {
|
||||
setShowHistoryPanel(false);
|
||||
}, []);
|
||||
|
||||
// Handle opening Tab Search modal
|
||||
const handleOpenTabSearch = useCallback(() => {
|
||||
setShowTabSearch(true);
|
||||
triggerHaptic(HAPTIC_PATTERNS.tap);
|
||||
}, []);
|
||||
|
||||
// Handle closing Tab Search modal
|
||||
const handleCloseTabSearch = useCallback(() => {
|
||||
setShowTabSearch(false);
|
||||
}, []);
|
||||
|
||||
// Handle command submission
|
||||
const handleCommandSubmit = useCallback((command: string) => {
|
||||
if (!activeSessionId) return;
|
||||
@@ -1197,17 +1209,7 @@ export default function MobileApp() {
|
||||
activeSession={activeSession}
|
||||
/>
|
||||
|
||||
{/* Connection status indicator with retry button - shows when disconnected or reconnecting */}
|
||||
<ConnectionStatusIndicator
|
||||
connectionState={connectionState}
|
||||
isOffline={isOffline}
|
||||
reconnectAttempts={reconnectAttempts}
|
||||
maxReconnectAttempts={10}
|
||||
error={error}
|
||||
onRetry={handleRetry}
|
||||
/>
|
||||
|
||||
{/* Session pill bar - shown when connected and sessions available */}
|
||||
{/* Session pill bar - Row 1: Groups/Sessions with search button */}
|
||||
{showSessionPillBar && (
|
||||
<SessionPillBar
|
||||
sessions={sessions}
|
||||
@@ -1218,7 +1220,7 @@ export default function MobileApp() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tab bar - shown when active session has multiple tabs and in AI mode */}
|
||||
{/* Tab bar - Row 2: Tabs for active session with search button */}
|
||||
{activeSession?.inputMode === 'ai' && activeSession?.aiTabs && activeSession.aiTabs.length > 1 && activeSession.activeTabId && (
|
||||
<TabBar
|
||||
tabs={activeSession.aiTabs}
|
||||
@@ -1226,6 +1228,7 @@ export default function MobileApp() {
|
||||
onSelectTab={handleSelectTab}
|
||||
onNewTab={handleNewTab}
|
||||
onCloseTab={handleCloseTab}
|
||||
onOpenTabSearch={handleOpenTabSearch}
|
||||
/>
|
||||
)}
|
||||
|
||||
@@ -1269,6 +1272,16 @@ export default function MobileApp() {
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Tab search modal - full-screen modal for searching tabs */}
|
||||
{showTabSearch && activeSession?.aiTabs && activeSession.activeTabId && (
|
||||
<TabSearchModal
|
||||
tabs={activeSession.aiTabs}
|
||||
activeTabId={activeSession.activeTabId}
|
||||
onSelectTab={handleSelectTab}
|
||||
onClose={handleCloseTabSearch}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Main content area */}
|
||||
<main
|
||||
style={{
|
||||
|
||||
@@ -133,6 +133,34 @@ const TEXTAREA_VERTICAL_PADDING = 28; // 14px top + 14px bottom
|
||||
/** Maximum height for textarea based on max lines */
|
||||
const MAX_TEXTAREA_HEIGHT = LINE_HEIGHT * MAX_LINES + TEXTAREA_VERTICAL_PADDING;
|
||||
|
||||
/** Mobile breakpoint - phones only, not tablets */
|
||||
const MOBILE_MAX_WIDTH = 480;
|
||||
|
||||
/** Height of expanded input on mobile (50% of viewport) */
|
||||
const MOBILE_EXPANDED_HEIGHT_VH = 50;
|
||||
|
||||
/**
|
||||
* Detect if the device is a mobile phone (not tablet/desktop)
|
||||
* Based on screen width and touch capability
|
||||
*/
|
||||
function useIsMobilePhone(): boolean {
|
||||
const [isMobile, setIsMobile] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const checkMobile = () => {
|
||||
const isTouchDevice = 'ontouchstart' in window || navigator.maxTouchPoints > 0;
|
||||
const isSmallScreen = window.innerWidth <= MOBILE_MAX_WIDTH;
|
||||
setIsMobile(isTouchDevice && isSmallScreen);
|
||||
};
|
||||
|
||||
checkMobile();
|
||||
window.addEventListener('resize', checkMobile);
|
||||
return () => window.removeEventListener('resize', checkMobile);
|
||||
}, []);
|
||||
|
||||
return isMobile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Trigger haptic feedback using the Vibration API
|
||||
* Uses short vibrations for tactile confirmation on mobile devices
|
||||
@@ -244,6 +272,12 @@ export function CommandInputBar({
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Mobile phone detection
|
||||
const isMobilePhone = useIsMobilePhone();
|
||||
|
||||
// Mobile expanded input state (AI mode only)
|
||||
const [isExpanded, setIsExpanded] = useState(false);
|
||||
|
||||
// Swipe up gesture detection for opening history drawer
|
||||
const { handlers: swipeUpHandlers } = useSwipeUp({
|
||||
onSwipeUp: () => onHistoryOpen?.(),
|
||||
@@ -277,18 +311,21 @@ export function CommandInputBar({
|
||||
const sendButtonRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
// Determine if input should be disabled
|
||||
// Disable when: externally disabled, offline, or not connected
|
||||
// For AI mode: also disable when session is busy (AI is thinking)
|
||||
// In AI mode: NEVER disable the input - user can always prep next message
|
||||
// The send button will show X (interrupt) when AI is busy
|
||||
// For terminal mode: do NOT disable when session is busy - terminal commands use a different pathway
|
||||
const isDisabled = externalDisabled || isOffline || !isConnected || (inputMode === 'ai' && isSessionBusy);
|
||||
const isDisabled = externalDisabled || isOffline || !isConnected;
|
||||
|
||||
// Separate flag for whether send is blocked (AI thinking)
|
||||
// When true, shows X button instead of send button
|
||||
const isSendBlocked = inputMode === 'ai' && isSessionBusy;
|
||||
|
||||
// Get placeholder text based on state
|
||||
const getPlaceholder = () => {
|
||||
if (isOffline) return 'Offline...';
|
||||
if (!isConnected) return 'Connecting...';
|
||||
// Only show "Waiting..." in AI mode when session is busy
|
||||
// Terminal mode is always available since it uses a different pathway
|
||||
if (inputMode === 'ai' && isSessionBusy) return 'AI thinking...';
|
||||
// In AI mode when busy, show helpful hint that user can still type
|
||||
if (inputMode === 'ai' && isSessionBusy) return 'AI thinking... (type your next message)';
|
||||
// In terminal mode, show shortened cwd as placeholder hint
|
||||
if (inputMode === 'terminal' && cwd) {
|
||||
const shortCwd = cwd.replace(/^\/Users\/[^/]+/, '~');
|
||||
@@ -461,17 +498,23 @@ export function CommandInputBar({
|
||||
|
||||
/**
|
||||
* Handle key press events
|
||||
* Enter submits, Shift+Enter adds a newline
|
||||
* AI mode: Enter adds newline (button to send)
|
||||
* Terminal mode: Enter submits (Shift+Enter adds newline)
|
||||
*/
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
// Submit on Enter (Shift+Enter adds newline for multi-line input)
|
||||
if (inputMode === 'ai') {
|
||||
// AI mode: Enter always adds newline, use button to send
|
||||
// No special handling needed - default behavior adds newline
|
||||
return;
|
||||
}
|
||||
// Terminal mode: Submit on Enter (Shift+Enter adds newline)
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit(e);
|
||||
}
|
||||
},
|
||||
[handleSubmit]
|
||||
[handleSubmit, inputMode]
|
||||
);
|
||||
|
||||
/**
|
||||
@@ -718,6 +761,72 @@ export function CommandInputBar({
|
||||
};
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Handle click outside to collapse expanded input on mobile
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!isExpanded || !isMobilePhone || inputMode !== 'ai') return;
|
||||
|
||||
const handleClickOutside = (e: MouseEvent | TouchEvent) => {
|
||||
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
|
||||
setIsExpanded(false);
|
||||
textareaRef.current?.blur();
|
||||
}
|
||||
};
|
||||
|
||||
// Use touchstart for immediate response on mobile
|
||||
document.addEventListener('touchstart', handleClickOutside);
|
||||
document.addEventListener('mousedown', handleClickOutside);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('touchstart', handleClickOutside);
|
||||
document.removeEventListener('mousedown', handleClickOutside);
|
||||
};
|
||||
}, [isExpanded, isMobilePhone, inputMode]);
|
||||
|
||||
/**
|
||||
* Handle focus to expand input on mobile in AI mode
|
||||
*/
|
||||
const handleMobileAIFocus = useCallback(() => {
|
||||
if (isMobilePhone && inputMode === 'ai') {
|
||||
setIsExpanded(true);
|
||||
}
|
||||
onInputFocus?.();
|
||||
}, [isMobilePhone, inputMode, onInputFocus]);
|
||||
|
||||
/**
|
||||
* Collapse input when submitting on mobile
|
||||
*/
|
||||
const handleMobileSubmit = useCallback((e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!value.trim() || isDisabled || isSendBlocked) return;
|
||||
|
||||
// Trigger haptic feedback on successful send
|
||||
triggerHapticFeedback('medium');
|
||||
|
||||
onSubmit?.(value.trim());
|
||||
|
||||
// Clear input after submit (for uncontrolled mode)
|
||||
if (controlledValue === undefined) {
|
||||
setInternalValue('');
|
||||
}
|
||||
|
||||
// Collapse on mobile after submit
|
||||
if (isMobilePhone && inputMode === 'ai') {
|
||||
setIsExpanded(false);
|
||||
}
|
||||
|
||||
// Keep focus on textarea after submit (unless mobile where we collapse)
|
||||
if (!isMobilePhone) {
|
||||
textareaRef.current?.focus();
|
||||
}
|
||||
}, [value, isDisabled, isSendBlocked, onSubmit, controlledValue, isMobilePhone, inputMode]);
|
||||
|
||||
// Calculate textarea height for mobile expanded mode
|
||||
const mobileExpandedHeight = isMobilePhone && inputMode === 'ai' && isExpanded
|
||||
? `${MOBILE_EXPANDED_HEIGHT_VH}vh`
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
@@ -736,7 +845,13 @@ export function CommandInputBar({
|
||||
backgroundColor: colors.bgSidebar,
|
||||
borderTop: `1px solid ${colors.border}`,
|
||||
// Smooth transition when keyboard appears/disappears
|
||||
transition: isKeyboardVisible ? 'none' : 'bottom 0.15s ease-out',
|
||||
transition: isKeyboardVisible ? 'none' : 'bottom 0.15s ease-out, height 200ms ease-out',
|
||||
// On mobile when expanded, use flexbox for proper layout
|
||||
...(mobileExpandedHeight && {
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: `calc(${MOBILE_EXPANDED_HEIGHT_VH}vh + 60px)`, // Textarea height + buttons/padding
|
||||
}),
|
||||
}}
|
||||
>
|
||||
{/* Swipe up handle indicator - visual hint for opening history */}
|
||||
@@ -786,7 +901,7 @@ export function CommandInputBar({
|
||||
/>
|
||||
|
||||
<form
|
||||
onSubmit={handleSubmit}
|
||||
onSubmit={handleMobileSubmit}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '8px',
|
||||
@@ -796,6 +911,8 @@ export function CommandInputBar({
|
||||
// Ensure form doesn't overflow screen width
|
||||
maxWidth: '100%',
|
||||
overflow: 'hidden',
|
||||
// Expand container on mobile when input is focused
|
||||
...(mobileExpandedHeight && { flex: 1 }),
|
||||
}}
|
||||
>
|
||||
{/* Mode toggle button - AI / Terminal */}
|
||||
@@ -1073,7 +1190,7 @@ export function CommandInputBar({
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
/* AI mode: regular textarea */
|
||||
/* AI mode: regular textarea - expands on mobile when focused */
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={value}
|
||||
@@ -1085,7 +1202,7 @@ export function CommandInputBar({
|
||||
autoCorrect="off"
|
||||
autoCapitalize="off"
|
||||
spellCheck={false}
|
||||
enterKeyHint="send"
|
||||
enterKeyHint="enter"
|
||||
rows={1}
|
||||
style={{
|
||||
flex: 1,
|
||||
@@ -1096,31 +1213,33 @@ export function CommandInputBar({
|
||||
borderRadius: '12px',
|
||||
backgroundColor: colors.bgMain,
|
||||
border: `2px solid ${colors.border}`,
|
||||
color: isDisabled ? colors.textDim : colors.textMain,
|
||||
// Never ghost out the input - user can always type
|
||||
color: colors.textMain,
|
||||
// 16px minimum prevents iOS zoom on focus, 17px for better readability
|
||||
fontSize: '17px',
|
||||
fontFamily: 'inherit',
|
||||
lineHeight: `${LINE_HEIGHT}px`,
|
||||
opacity: isDisabled ? 0.5 : 1,
|
||||
outline: 'none',
|
||||
// Dynamic height for auto-expansion (controlled by useEffect)
|
||||
height: `${textareaHeight}px`,
|
||||
// On mobile when expanded, use 50vh height; otherwise use dynamic height
|
||||
height: mobileExpandedHeight || `${textareaHeight}px`,
|
||||
// Large minimum height for easy touch targeting
|
||||
minHeight: `${MIN_INPUT_HEIGHT}px`,
|
||||
// Maximum height based on MAX_LINES (4 lines) before scrolling
|
||||
maxHeight: `${MAX_TEXTAREA_HEIGHT}px`,
|
||||
// Maximum height based on mobile expansion or MAX_LINES
|
||||
maxHeight: mobileExpandedHeight || `${MAX_TEXTAREA_HEIGHT}px`,
|
||||
// Reset appearance for consistent styling
|
||||
WebkitAppearance: 'none',
|
||||
appearance: 'none',
|
||||
// Remove default textarea resize handle
|
||||
resize: 'none',
|
||||
// Smooth height transitions for auto-expansion
|
||||
transition: 'height 100ms ease-out, border-color 150ms ease, opacity 150ms ease, box-shadow 150ms ease',
|
||||
transition: mobileExpandedHeight
|
||||
? 'height 200ms ease-out, border-color 150ms ease, box-shadow 150ms ease'
|
||||
: 'height 100ms ease-out, border-color 150ms ease, box-shadow 150ms ease',
|
||||
// Better text rendering on mobile
|
||||
WebkitFontSmoothing: 'antialiased',
|
||||
MozOsxFontSmoothing: 'grayscale',
|
||||
// Enable scrolling only when content exceeds max height
|
||||
overflowY: textareaHeight >= MAX_TEXTAREA_HEIGHT ? 'auto' : 'hidden',
|
||||
// Enable scrolling when expanded or when content exceeds max height
|
||||
overflowY: mobileExpandedHeight || textareaHeight >= MAX_TEXTAREA_HEIGHT ? 'auto' : 'hidden',
|
||||
overflowX: 'hidden',
|
||||
wordWrap: 'break-word',
|
||||
}}
|
||||
@@ -1128,7 +1247,7 @@ export function CommandInputBar({
|
||||
// Add focus ring for accessibility
|
||||
e.currentTarget.style.borderColor = colors.accent;
|
||||
e.currentTarget.style.boxShadow = `0 0 0 3px ${colors.accent}33`;
|
||||
onInputFocus?.();
|
||||
handleMobileAIFocus();
|
||||
}}
|
||||
onBlur={(e) => {
|
||||
// Remove focus ring
|
||||
@@ -1136,15 +1255,14 @@ export function CommandInputBar({
|
||||
e.currentTarget.style.boxShadow = 'none';
|
||||
onInputBlur?.();
|
||||
}}
|
||||
aria-label="Command input"
|
||||
aria-disabled={isDisabled}
|
||||
aria-label="AI message input. Press the send button to submit."
|
||||
aria-multiline="true"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Action button - shows either Interrupt (Red X) when busy, or Send button when idle */}
|
||||
{/* When session is busy, the Red X replaces the Send button to save space on mobile */}
|
||||
{isSessionBusy ? (
|
||||
{/* Action button - shows either Interrupt (Red X) when AI is busy, or Send button otherwise */}
|
||||
{/* The X button only shows in AI mode when busy - terminal mode always shows Send */}
|
||||
{inputMode === 'ai' && isSessionBusy ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleInterrupt}
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
* MobileHistoryPanel component for Maestro mobile web interface
|
||||
*
|
||||
* A full-screen view displaying history entries from the desktop app.
|
||||
* This view shows all AUTO and USER entries in a list format, with the ability
|
||||
* This view shows all AUTO, USER, and LOOP entries in a list format, with the ability
|
||||
* to tap on an entry to see full details.
|
||||
*
|
||||
* Features:
|
||||
* - List view of all history entries
|
||||
* - Filter by AUTO/USER type
|
||||
* - Filter by AUTO/USER/LOOP type
|
||||
* - Tap to view full details
|
||||
* - Read-only (no resume functionality on mobile)
|
||||
*/
|
||||
@@ -19,9 +19,11 @@ import { buildApiUrl } from '../utils/config';
|
||||
import { webLogger } from '../utils/logger';
|
||||
|
||||
// History entry type matching the desktop type
|
||||
export type HistoryEntryType = 'AUTO' | 'USER' | 'LOOP';
|
||||
|
||||
export interface HistoryEntry {
|
||||
id: string;
|
||||
type: 'AUTO' | 'USER';
|
||||
type: HistoryEntryType;
|
||||
timestamp: number;
|
||||
summary: string;
|
||||
fullResponse?: string;
|
||||
@@ -90,6 +92,8 @@ function HistoryCard({ entry, onSelect }: HistoryCardProps) {
|
||||
const getPillColor = () => {
|
||||
if (entry.type === 'AUTO') {
|
||||
return { bg: colors.warning + '20', text: colors.warning, border: colors.warning + '40' };
|
||||
} else if (entry.type === 'LOOP') {
|
||||
return { bg: colors.success + '20', text: colors.success, border: colors.success + '40' };
|
||||
}
|
||||
return { bg: colors.accent + '20', text: colors.accent, border: colors.accent + '40' };
|
||||
};
|
||||
@@ -186,6 +190,12 @@ function HistoryCard({ entry, onSelect }: HistoryCardProps) {
|
||||
<rect x="8" y="8" width="8" height="12" rx="1" />
|
||||
<path d="M12 8v12" />
|
||||
</svg>
|
||||
) : entry.type === 'LOOP' ? (
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<polyline points="23 4 23 10 17 10" />
|
||||
<polyline points="1 20 1 14 7 14" />
|
||||
<path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||
@@ -293,6 +303,8 @@ function HistoryDetailView({ entry, onClose }: HistoryDetailViewProps) {
|
||||
const getPillColor = () => {
|
||||
if (entry.type === 'AUTO') {
|
||||
return { bg: colors.warning + '20', text: colors.warning, border: colors.warning + '40' };
|
||||
} else if (entry.type === 'LOOP') {
|
||||
return { bg: colors.success + '20', text: colors.success, border: colors.success + '40' };
|
||||
}
|
||||
return { bg: colors.accent + '20', text: colors.accent, border: colors.accent + '40' };
|
||||
};
|
||||
@@ -580,7 +592,7 @@ export interface MobileHistoryPanelProps {
|
||||
/**
|
||||
* Filter type for history entries
|
||||
*/
|
||||
type HistoryFilter = 'all' | 'AUTO' | 'USER';
|
||||
type HistoryFilter = 'all' | 'AUTO' | 'USER' | 'LOOP';
|
||||
|
||||
/**
|
||||
* MobileHistoryPanel component
|
||||
@@ -674,6 +686,7 @@ export function MobileHistoryPanel({
|
||||
// Count entries by type
|
||||
const autoCount = entries.filter((e) => e.type === 'AUTO').length;
|
||||
const userCount = entries.filter((e) => e.type === 'USER').length;
|
||||
const loopCount = entries.filter((e) => e.type === 'LOOP').length;
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -747,9 +760,19 @@ export function MobileHistoryPanel({
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
{(['all', 'AUTO', 'USER'] as HistoryFilter[]).map((filterType) => {
|
||||
{/* Only show LOOP filter if there are LOOP entries */}
|
||||
{(['all', 'AUTO', 'USER', 'LOOP'] as HistoryFilter[])
|
||||
.filter((ft) => ft !== 'LOOP' || loopCount > 0)
|
||||
.map((filterType) => {
|
||||
const isActive = filter === filterType;
|
||||
const count = filterType === 'all' ? entries.length : filterType === 'AUTO' ? autoCount : userCount;
|
||||
const count = filterType === 'all'
|
||||
? entries.length
|
||||
: filterType === 'AUTO'
|
||||
? autoCount
|
||||
: filterType === 'USER'
|
||||
? userCount
|
||||
: loopCount;
|
||||
const displayLabel = filterType === 'all' ? 'All' : filterType;
|
||||
|
||||
let bgColor = colors.bgMain;
|
||||
let textColor = colors.textDim;
|
||||
@@ -764,6 +787,10 @@ export function MobileHistoryPanel({
|
||||
bgColor = colors.accent + '20';
|
||||
textColor = colors.accent;
|
||||
borderColor = colors.accent + '40';
|
||||
} else if (filterType === 'LOOP') {
|
||||
bgColor = colors.success + '20';
|
||||
textColor = colors.success;
|
||||
borderColor = colors.success + '40';
|
||||
} else {
|
||||
bgColor = colors.accent + '20';
|
||||
textColor = colors.accent;
|
||||
@@ -795,7 +822,7 @@ export function MobileHistoryPanel({
|
||||
}}
|
||||
aria-pressed={isActive}
|
||||
>
|
||||
{filterType === 'all' ? 'All' : filterType}
|
||||
{displayLabel}
|
||||
<span
|
||||
style={{
|
||||
fontSize: '10px',
|
||||
|
||||
@@ -23,6 +23,9 @@ import { triggerHaptic, HAPTIC_PATTERNS } from './constants';
|
||||
/** Duration in ms to trigger long-press */
|
||||
const LONG_PRESS_DURATION = 500;
|
||||
|
||||
/** Minimum touch movement (in pixels) to cancel tap and consider it a scroll */
|
||||
const SCROLL_THRESHOLD = 10;
|
||||
|
||||
/**
|
||||
* Props for individual session pill
|
||||
*/
|
||||
@@ -35,12 +38,19 @@ interface SessionPillProps {
|
||||
|
||||
/**
|
||||
* Individual session pill component
|
||||
*
|
||||
* Uses touch tracking to differentiate between scrolling and tapping:
|
||||
* - If touch moves more than SCROLL_THRESHOLD pixels, it's a scroll (no action)
|
||||
* - If touch ends without much movement, it's a tap (select session)
|
||||
* - Long press still triggers the info popover
|
||||
*/
|
||||
function SessionPill({ session, isActive, onSelect, onLongPress }: SessionPillProps) {
|
||||
const colors = useThemeColors();
|
||||
const buttonRef = useRef<HTMLButtonElement>(null);
|
||||
const longPressTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
const isLongPressTriggeredRef = useRef(false);
|
||||
const touchStartRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const isScrollingRef = useRef(false);
|
||||
|
||||
// Map session state to status for StatusDot
|
||||
const getStatus = (): SessionStatus => {
|
||||
@@ -63,37 +73,63 @@ function SessionPill({ session, isActive, onSelect, onLongPress }: SessionPillPr
|
||||
const startLongPressTimer = useCallback(() => {
|
||||
isLongPressTriggeredRef.current = false;
|
||||
longPressTimerRef.current = setTimeout(() => {
|
||||
isLongPressTriggeredRef.current = true;
|
||||
triggerHaptic(HAPTIC_PATTERNS.success);
|
||||
if (buttonRef.current) {
|
||||
const rect = buttonRef.current.getBoundingClientRect();
|
||||
onLongPress(session, rect);
|
||||
// Only trigger if not scrolling
|
||||
if (!isScrollingRef.current) {
|
||||
isLongPressTriggeredRef.current = true;
|
||||
triggerHaptic(HAPTIC_PATTERNS.success);
|
||||
if (buttonRef.current) {
|
||||
const rect = buttonRef.current.getBoundingClientRect();
|
||||
onLongPress(session, rect);
|
||||
}
|
||||
}
|
||||
}, LONG_PRESS_DURATION);
|
||||
}, [session, onLongPress]);
|
||||
|
||||
// Handle touch/mouse start
|
||||
const handlePressStart = useCallback((e: React.TouchEvent | React.MouseEvent) => {
|
||||
// Prevent context menu on long press
|
||||
e.preventDefault();
|
||||
// Handle touch start - record position, start long-press timer
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
const touch = e.touches[0];
|
||||
touchStartRef.current = { x: touch.clientX, y: touch.clientY };
|
||||
isScrollingRef.current = false;
|
||||
startLongPressTimer();
|
||||
}, [startLongPressTimer]);
|
||||
|
||||
// Handle touch/mouse end
|
||||
const handlePressEnd = useCallback(() => {
|
||||
// Handle touch move - detect scrolling
|
||||
const handleTouchMove = useCallback((e: React.TouchEvent) => {
|
||||
if (!touchStartRef.current) return;
|
||||
|
||||
const touch = e.touches[0];
|
||||
const deltaX = Math.abs(touch.clientX - touchStartRef.current.x);
|
||||
const deltaY = Math.abs(touch.clientY - touchStartRef.current.y);
|
||||
|
||||
// If moved more than threshold, it's a scroll
|
||||
if (deltaX > SCROLL_THRESHOLD || deltaY > SCROLL_THRESHOLD) {
|
||||
isScrollingRef.current = true;
|
||||
clearLongPressTimer();
|
||||
}
|
||||
}, [clearLongPressTimer]);
|
||||
|
||||
// Handle touch end - only select if it wasn't a scroll or long press
|
||||
const handleTouchEnd = useCallback(() => {
|
||||
clearLongPressTimer();
|
||||
// Only trigger tap if long press wasn't triggered
|
||||
if (!isLongPressTriggeredRef.current) {
|
||||
|
||||
// Only trigger tap if we weren't scrolling and long press wasn't triggered
|
||||
if (!isScrollingRef.current && !isLongPressTriggeredRef.current) {
|
||||
triggerHaptic(HAPTIC_PATTERNS.tap);
|
||||
onSelect(session.id);
|
||||
}
|
||||
|
||||
// Reset state
|
||||
touchStartRef.current = null;
|
||||
isScrollingRef.current = false;
|
||||
isLongPressTriggeredRef.current = false;
|
||||
}, [clearLongPressTimer, onSelect, session.id]);
|
||||
|
||||
// Handle touch/mouse move (cancel long press if moved too far)
|
||||
const handlePressMove = useCallback(() => {
|
||||
// Cancel long press on move
|
||||
// Handle touch cancel
|
||||
const handleTouchCancel = useCallback(() => {
|
||||
clearLongPressTimer();
|
||||
touchStartRef.current = null;
|
||||
isScrollingRef.current = false;
|
||||
isLongPressTriggeredRef.current = false;
|
||||
}, [clearLongPressTimer]);
|
||||
|
||||
// Cleanup timer on unmount
|
||||
@@ -124,14 +160,26 @@ function SessionPill({ session, isActive, onSelect, onLongPress }: SessionPillPr
|
||||
return (
|
||||
<button
|
||||
ref={buttonRef}
|
||||
onTouchStart={handlePressStart}
|
||||
onTouchEnd={handlePressEnd}
|
||||
onTouchMove={handlePressMove}
|
||||
onTouchCancel={handlePressEnd}
|
||||
onMouseDown={handlePressStart}
|
||||
onMouseUp={handlePressEnd}
|
||||
onMouseLeave={handlePressEnd}
|
||||
onContextMenu={(e) => e.preventDefault()}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onTouchCancel={handleTouchCancel}
|
||||
onClick={(e) => {
|
||||
// For non-touch devices (mouse), use onClick
|
||||
// Touch devices will have already handled via touch events
|
||||
if (!('ontouchstart' in window)) {
|
||||
triggerHaptic(HAPTIC_PATTERNS.tap);
|
||||
onSelect(session.id);
|
||||
}
|
||||
}}
|
||||
onContextMenu={(e) => {
|
||||
e.preventDefault();
|
||||
// Show long press menu on right-click for desktop
|
||||
if (buttonRef.current) {
|
||||
const rect = buttonRef.current.getBoundingClientRect();
|
||||
onLongPress(session, rect);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -148,7 +196,8 @@ function SessionPill({ session, isActive, onSelect, onLongPress }: SessionPillPr
|
||||
transition: 'all 0.15s ease',
|
||||
flexShrink: 0,
|
||||
minWidth: 'fit-content',
|
||||
touchAction: 'manipulation',
|
||||
// Allow native touch scrolling - don't use 'manipulation' which can interfere
|
||||
touchAction: 'pan-x pan-y',
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
outline: 'none',
|
||||
userSelect: 'none',
|
||||
@@ -513,6 +562,9 @@ interface GroupHeaderProps {
|
||||
|
||||
/**
|
||||
* Group header component that displays group name with collapse/expand toggle
|
||||
*
|
||||
* Uses touch tracking to differentiate between scrolling and tapping,
|
||||
* similar to SessionPill.
|
||||
*/
|
||||
function GroupHeader({
|
||||
groupId,
|
||||
@@ -523,15 +575,62 @@ function GroupHeader({
|
||||
onToggleCollapse,
|
||||
}: GroupHeaderProps) {
|
||||
const colors = useThemeColors();
|
||||
const touchStartRef = useRef<{ x: number; y: number } | null>(null);
|
||||
const isScrollingRef = useRef(false);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
triggerHaptic(HAPTIC_PATTERNS.tap);
|
||||
onToggleCollapse(groupId);
|
||||
// Handle touch start - record position
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
const touch = e.touches[0];
|
||||
touchStartRef.current = { x: touch.clientX, y: touch.clientY };
|
||||
isScrollingRef.current = false;
|
||||
}, []);
|
||||
|
||||
// Handle touch move - detect scrolling
|
||||
const handleTouchMove = useCallback((e: React.TouchEvent) => {
|
||||
if (!touchStartRef.current) return;
|
||||
|
||||
const touch = e.touches[0];
|
||||
const deltaX = Math.abs(touch.clientX - touchStartRef.current.x);
|
||||
const deltaY = Math.abs(touch.clientY - touchStartRef.current.y);
|
||||
|
||||
// If moved more than threshold, it's a scroll
|
||||
if (deltaX > SCROLL_THRESHOLD || deltaY > SCROLL_THRESHOLD) {
|
||||
isScrollingRef.current = true;
|
||||
}
|
||||
}, []);
|
||||
|
||||
// Handle touch end - only toggle if it wasn't a scroll
|
||||
const handleTouchEnd = useCallback(() => {
|
||||
// Only trigger tap if we weren't scrolling
|
||||
if (!isScrollingRef.current) {
|
||||
triggerHaptic(HAPTIC_PATTERNS.tap);
|
||||
onToggleCollapse(groupId);
|
||||
}
|
||||
|
||||
// Reset state
|
||||
touchStartRef.current = null;
|
||||
isScrollingRef.current = false;
|
||||
}, [groupId, onToggleCollapse]);
|
||||
|
||||
// Handle touch cancel
|
||||
const handleTouchCancel = useCallback(() => {
|
||||
touchStartRef.current = null;
|
||||
isScrollingRef.current = false;
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={handleClick}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchMove={handleTouchMove}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onTouchCancel={handleTouchCancel}
|
||||
onClick={() => {
|
||||
// For non-touch devices (mouse), use onClick
|
||||
if (!('ontouchstart' in window)) {
|
||||
triggerHaptic(HAPTIC_PATTERNS.tap);
|
||||
onToggleCollapse(groupId);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
@@ -546,7 +645,8 @@ function GroupHeader({
|
||||
cursor: 'pointer',
|
||||
whiteSpace: 'nowrap',
|
||||
flexShrink: 0,
|
||||
touchAction: 'manipulation',
|
||||
// Allow native touch scrolling
|
||||
touchAction: 'pan-x pan-y',
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
outline: 'none',
|
||||
userSelect: 'none',
|
||||
@@ -650,12 +750,24 @@ export function SessionPillBar({
|
||||
const colors = useThemeColors();
|
||||
const scrollContainerRef = useRef<HTMLDivElement>(null);
|
||||
const [popoverState, setPopoverState] = useState<PopoverState | null>(null);
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string>>(new Set());
|
||||
const [collapsedGroups, setCollapsedGroups] = useState<Set<string> | null>(null);
|
||||
|
||||
// Organize sessions by group
|
||||
// Organize sessions by group, including a special "bookmarks" group
|
||||
const sessionsByGroup = useMemo((): Record<string, GroupInfo> => {
|
||||
const groups: Record<string, GroupInfo> = {};
|
||||
|
||||
// Add bookmarked sessions to a special "bookmarks" group
|
||||
const bookmarkedSessions = sessions.filter(s => s.bookmarked);
|
||||
if (bookmarkedSessions.length > 0) {
|
||||
groups['bookmarks'] = {
|
||||
id: 'bookmarks',
|
||||
name: 'Bookmarks',
|
||||
emoji: '★',
|
||||
sessions: bookmarkedSessions,
|
||||
};
|
||||
}
|
||||
|
||||
// Organize remaining sessions by their actual groups
|
||||
for (const session of sessions) {
|
||||
const groupKey = session.groupId || 'ungrouped';
|
||||
|
||||
@@ -673,10 +785,13 @@ export function SessionPillBar({
|
||||
return groups;
|
||||
}, [sessions]);
|
||||
|
||||
// Get sorted group keys (ungrouped last)
|
||||
// Get sorted group keys (bookmarks first, ungrouped last)
|
||||
const sortedGroupKeys = useMemo(() => {
|
||||
const keys = Object.keys(sessionsByGroup);
|
||||
return keys.sort((a, b) => {
|
||||
// Put 'bookmarks' at the start
|
||||
if (a === 'bookmarks') return -1;
|
||||
if (b === 'bookmarks') return 1;
|
||||
// Put 'ungrouped' at the end
|
||||
if (a === 'ungrouped') return 1;
|
||||
if (b === 'ungrouped') return -1;
|
||||
@@ -686,9 +801,17 @@ export function SessionPillBar({
|
||||
}, [sessionsByGroup]);
|
||||
|
||||
// Check if there are multiple groups (to decide whether to show group headers)
|
||||
// Note: Consider it "multiple groups" if there's bookmarks + any other group
|
||||
const hasMultipleGroups = sortedGroupKeys.length > 1 ||
|
||||
(sortedGroupKeys.length === 1 && sortedGroupKeys[0] !== 'ungrouped');
|
||||
|
||||
// Initialize collapsed groups with all groups collapsed by default
|
||||
useEffect(() => {
|
||||
if (collapsedGroups === null && sortedGroupKeys.length > 0) {
|
||||
setCollapsedGroups(new Set(sortedGroupKeys));
|
||||
}
|
||||
}, [sortedGroupKeys, collapsedGroups]);
|
||||
|
||||
// Handle long-press on a session pill
|
||||
const handleLongPress = useCallback((session: Session, rect: DOMRect) => {
|
||||
setPopoverState({ session, anchorRect: rect });
|
||||
@@ -702,7 +825,7 @@ export function SessionPillBar({
|
||||
// Toggle group collapsed state
|
||||
const handleToggleCollapse = useCallback((groupId: string) => {
|
||||
setCollapsedGroups(prev => {
|
||||
const next = new Set(prev);
|
||||
const next = new Set(prev || []);
|
||||
if (next.has(groupId)) {
|
||||
next.delete(groupId);
|
||||
} else {
|
||||
@@ -806,10 +929,10 @@ export function SessionPillBar({
|
||||
WebkitTapHighlightColor: 'transparent',
|
||||
outline: 'none',
|
||||
}}
|
||||
aria-label={`View all ${sessions.length} sessions`}
|
||||
title="All Sessions"
|
||||
aria-label={`Search ${sessions.length} sessions`}
|
||||
title="Search Sessions"
|
||||
>
|
||||
{/* Hamburger icon */}
|
||||
{/* Search icon */}
|
||||
<svg
|
||||
width="18"
|
||||
height="18"
|
||||
@@ -820,9 +943,8 @@ export function SessionPillBar({
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="3" y1="6" x2="21" y2="6" />
|
||||
<line x1="3" y1="12" x2="21" y2="12" />
|
||||
<line x1="3" y1="18" x2="21" y2="18" />
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
</button>
|
||||
{/* History button - pinned next to hamburger */}
|
||||
@@ -894,7 +1016,7 @@ export function SessionPillBar({
|
||||
>
|
||||
{sortedGroupKeys.map((groupKey) => {
|
||||
const group = sessionsByGroup[groupKey];
|
||||
const isCollapsed = collapsedGroups.has(groupKey);
|
||||
const isCollapsed = collapsedGroups?.has(groupKey) ?? true;
|
||||
const showGroupHeader = hasMultipleGroups;
|
||||
|
||||
return (
|
||||
|
||||
@@ -15,6 +15,7 @@ interface TabBarProps {
|
||||
onSelectTab: (tabId: string) => void;
|
||||
onNewTab: () => void;
|
||||
onCloseTab: (tabId: string) => void;
|
||||
onOpenTabSearch?: () => void;
|
||||
}
|
||||
|
||||
interface TabProps {
|
||||
@@ -135,6 +136,7 @@ export function TabBar({
|
||||
onSelectTab,
|
||||
onNewTab,
|
||||
onCloseTab,
|
||||
onOpenTabSearch,
|
||||
}: TabBarProps) {
|
||||
const colors = useThemeColors();
|
||||
|
||||
@@ -160,6 +162,44 @@ export function TabBar({
|
||||
msOverflowStyle: 'none',
|
||||
}}
|
||||
>
|
||||
{/* Search tabs button - pinned at start */}
|
||||
{onOpenTabSearch && (
|
||||
<button
|
||||
onClick={onOpenTabSearch}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '28px',
|
||||
height: '28px',
|
||||
borderRadius: '14px',
|
||||
border: `1px solid ${colors.border}`,
|
||||
backgroundColor: colors.bgMain,
|
||||
color: colors.textDim,
|
||||
cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
marginRight: '6px',
|
||||
marginBottom: '4px',
|
||||
alignSelf: 'center',
|
||||
}}
|
||||
title={`Search ${tabs.length} tabs`}
|
||||
>
|
||||
<svg
|
||||
width="14"
|
||||
height="14"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{tabs.map((tab) => (
|
||||
<Tab
|
||||
key={tab.id}
|
||||
|
||||
359
src/web/mobile/TabSearchModal.tsx
Normal file
359
src/web/mobile/TabSearchModal.tsx
Normal file
@@ -0,0 +1,359 @@
|
||||
/**
|
||||
* TabSearchModal component for web interface
|
||||
*
|
||||
* A full-screen modal for searching and selecting tabs within a session.
|
||||
* Similar to AllSessionsView but for tabs.
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
||||
import { useThemeColors } from '../components/ThemeProvider';
|
||||
import type { AITabData } from '../hooks/useWebSocket';
|
||||
import { triggerHaptic, HAPTIC_PATTERNS } from './constants';
|
||||
|
||||
interface TabSearchModalProps {
|
||||
tabs: AITabData[];
|
||||
activeTabId: string;
|
||||
onSelectTab: (tabId: string) => void;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
interface TabCardProps {
|
||||
tab: AITabData;
|
||||
isActive: boolean;
|
||||
colors: ReturnType<typeof useThemeColors>;
|
||||
onSelect: () => void;
|
||||
}
|
||||
|
||||
function TabCard({ tab, isActive, colors, onSelect }: TabCardProps) {
|
||||
const displayName = tab.name
|
||||
|| (tab.claudeSessionId ? tab.claudeSessionId.split('-')[0].toUpperCase() : 'New Tab');
|
||||
|
||||
// Get status color (state is 'idle' | 'busy')
|
||||
const getStatusColor = () => {
|
||||
if (tab.state === 'busy') return colors.warning;
|
||||
return colors.success; // idle
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={() => {
|
||||
triggerHaptic(HAPTIC_PATTERNS.tap);
|
||||
onSelect();
|
||||
}}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
width: '100%',
|
||||
padding: '12px 16px',
|
||||
backgroundColor: isActive ? `${colors.accent}20` : colors.bgSidebar,
|
||||
border: isActive ? `1px solid ${colors.accent}` : `1px solid ${colors.border}`,
|
||||
borderRadius: '8px',
|
||||
cursor: 'pointer',
|
||||
textAlign: 'left',
|
||||
transition: 'all 0.15s ease',
|
||||
}}
|
||||
>
|
||||
{/* Status dot */}
|
||||
<span
|
||||
style={{
|
||||
width: '10px',
|
||||
height: '10px',
|
||||
borderRadius: '50%',
|
||||
backgroundColor: getStatusColor(),
|
||||
flexShrink: 0,
|
||||
animation: tab.state === 'busy' ? 'pulse 1.5s infinite' : 'none',
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* Tab info */}
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
|
||||
{/* Starred indicator */}
|
||||
{tab.starred && (
|
||||
<span style={{ color: colors.warning, fontSize: '12px' }}>★</span>
|
||||
)}
|
||||
{/* Tab name */}
|
||||
<span
|
||||
style={{
|
||||
fontSize: '14px',
|
||||
fontWeight: isActive ? 600 : 500,
|
||||
color: colors.textMain,
|
||||
overflow: 'hidden',
|
||||
textOverflow: 'ellipsis',
|
||||
whiteSpace: 'nowrap',
|
||||
}}
|
||||
>
|
||||
{displayName}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Claude session ID */}
|
||||
{tab.claudeSessionId && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: colors.textDim,
|
||||
fontFamily: 'monospace',
|
||||
}}
|
||||
>
|
||||
{tab.claudeSessionId}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Active indicator */}
|
||||
{isActive && (
|
||||
<span
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
color: colors.accent,
|
||||
fontWeight: 600,
|
||||
flexShrink: 0,
|
||||
}}
|
||||
>
|
||||
ACTIVE
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
export function TabSearchModal({
|
||||
tabs,
|
||||
activeTabId,
|
||||
onSelectTab,
|
||||
onClose,
|
||||
}: TabSearchModalProps) {
|
||||
const colors = useThemeColors();
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Focus input on mount
|
||||
useEffect(() => {
|
||||
inputRef.current?.focus();
|
||||
}, []);
|
||||
|
||||
// Filter tabs by search query
|
||||
const filteredTabs = useMemo(() => {
|
||||
if (!searchQuery.trim()) return tabs;
|
||||
const query = searchQuery.toLowerCase();
|
||||
return tabs.filter((tab) => {
|
||||
const name = tab.name || '';
|
||||
const claudeId = tab.claudeSessionId || '';
|
||||
return (
|
||||
name.toLowerCase().includes(query) ||
|
||||
claudeId.toLowerCase().includes(query)
|
||||
);
|
||||
});
|
||||
}, [tabs, searchQuery]);
|
||||
|
||||
// Handle tab selection
|
||||
const handleSelectTab = useCallback(
|
||||
(tabId: string) => {
|
||||
onSelectTab(tabId);
|
||||
onClose();
|
||||
},
|
||||
[onSelectTab, onClose]
|
||||
);
|
||||
|
||||
// Handle close
|
||||
const handleClose = useCallback(() => {
|
||||
triggerHaptic(HAPTIC_PATTERNS.tap);
|
||||
onClose();
|
||||
}, [onClose]);
|
||||
|
||||
// Handle escape key
|
||||
useEffect(() => {
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') {
|
||||
handleClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', handleKeyDown);
|
||||
return () => window.removeEventListener('keydown', handleKeyDown);
|
||||
}, [handleClose]);
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
position: 'fixed',
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
backgroundColor: colors.bgMain,
|
||||
zIndex: 1000,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
animation: 'slideUp 0.2s ease-out',
|
||||
}}
|
||||
>
|
||||
{/* Header */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
padding: '12px 16px',
|
||||
paddingTop: 'max(12px, env(safe-area-inset-top))',
|
||||
borderBottom: `1px solid ${colors.border}`,
|
||||
backgroundColor: colors.bgSidebar,
|
||||
}}
|
||||
>
|
||||
{/* Close button */}
|
||||
<button
|
||||
onClick={handleClose}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '32px',
|
||||
height: '32px',
|
||||
borderRadius: '16px',
|
||||
border: `1px solid ${colors.border}`,
|
||||
backgroundColor: colors.bgMain,
|
||||
color: colors.textMain,
|
||||
cursor: 'pointer',
|
||||
flexShrink: 0,
|
||||
}}
|
||||
title="Close"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Search input */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '8px 12px',
|
||||
backgroundColor: colors.bgMain,
|
||||
border: `1px solid ${colors.border}`,
|
||||
borderRadius: '8px',
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke={colors.textDim}
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<line x1="21" y1="21" x2="16.65" y2="16.65" />
|
||||
</svg>
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={`Search ${tabs.length} tabs...`}
|
||||
style={{
|
||||
flex: 1,
|
||||
border: 'none',
|
||||
backgroundColor: 'transparent',
|
||||
color: colors.textMain,
|
||||
fontSize: '14px',
|
||||
outline: 'none',
|
||||
}}
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: '20px',
|
||||
height: '20px',
|
||||
borderRadius: '10px',
|
||||
border: 'none',
|
||||
backgroundColor: colors.textDim,
|
||||
color: colors.bgMain,
|
||||
cursor: 'pointer',
|
||||
fontSize: '12px',
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tab list */}
|
||||
<div
|
||||
style={{
|
||||
flex: 1,
|
||||
overflow: 'auto',
|
||||
padding: '12px 16px',
|
||||
paddingBottom: 'max(12px, env(safe-area-inset-bottom))',
|
||||
}}
|
||||
>
|
||||
{filteredTabs.length === 0 ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
height: '100px',
|
||||
color: colors.textDim,
|
||||
fontSize: '14px',
|
||||
}}
|
||||
>
|
||||
{searchQuery ? 'No tabs match your search' : 'No tabs available'}
|
||||
</div>
|
||||
) : (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
{filteredTabs.map((tab) => (
|
||||
<TabCard
|
||||
key={tab.id}
|
||||
tab={tab}
|
||||
isActive={tab.id === activeTabId}
|
||||
colors={colors}
|
||||
onSelect={() => handleSelectTab(tab.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Animation keyframes */}
|
||||
<style>{`
|
||||
@keyframes slideUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
@keyframes pulse {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0.4; }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TabSearchModal;
|
||||
Reference in New Issue
Block a user