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:
Pedram Amini
2025-12-05 14:16:49 -06:00
parent 4840fc03bc
commit 0e7156ca41
34 changed files with 1685 additions and 313 deletions

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

View File

@@ -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')

View File

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

View File

@@ -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,

View File

@@ -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.

View File

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

View File

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

View File

@@ -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) {

View File

@@ -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

View File

@@ -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}
/>

View File

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

View File

@@ -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

View File

@@ -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 */}

View File

@@ -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">

View File

@@ -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 */}

View File

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

View File

@@ -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"

View File

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

View File

@@ -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) => (

View File

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

View File

@@ -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"

View File

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

View File

@@ -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,

View File

@@ -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: [] };

View File

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

View File

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

View File

@@ -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

View File

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

View File

@@ -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={{

View File

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

View File

@@ -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',

View File

@@ -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 (

View File

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

View 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;