MAESTRO: Tab switcher improvements and slash command UX refinements

- Add lastActivityAt timestamps to tab switcher for better session sorting
- Filter "All Named" sessions to current project only (projectRoot-scoped)
- Add relative time formatting (e.g., "5m ago", "2h ago") in tab switcher
- Change slash command behavior: Tab/Enter now fills text instead of executing
- Allow slash commands to be queued when agent is busy (like regular messages)
- Add projectRoot field to Session for stable Claude session storage path
- Fix markdown list indentation and positioning
- Add GitHub Actions workflow for auto-assigning issues/PRs to pedramamini
- Update CLAUDE.md with projectRoot field documentation

Claude ID: 747fc9d0-a5a2-441d-bc0a-8cd3d579a004
Maestro ID: b9bc0d08-5be2-4fdf-93cd-5618a8d53b35
This commit is contained in:
Pedram Amini
2025-12-03 15:26:30 -06:00
parent d49c333d02
commit a6663adbd1
11 changed files with 124 additions and 173 deletions

25
.github/workflows/auto-assign.yml vendored Normal file
View File

@@ -0,0 +1,25 @@
name: Auto Assign
on:
issues:
types: [opened]
pull_request:
types: [opened]
jobs:
assign:
runs-on: ubuntu-latest
steps:
- name: Assign to pedramamini
uses: actions/github-script@v7
with:
script: |
const issueNumber = context.issue?.number || context.payload.pull_request?.number;
if (issueNumber) {
await github.rest.issues.addAssignees({
owner: context.repo.owner,
repo: context.repo.repo,
issue_number: issueNumber,
assignees: ['pedramamini']
});
}

View File

@@ -172,7 +172,8 @@ interface Session {
toolType: ToolType; // 'claude-code' | 'aider' | 'terminal' | etc.
state: SessionState; // 'idle' | 'busy' | 'error' | 'connecting'
inputMode: 'ai' | 'terminal'; // Which process receives input
cwd: string; // Working directory
cwd: string; // Current working directory (can change via cd)
projectRoot: string; // Initial working directory (never changes, used for Claude session storage)
aiPid: number; // AI process ID
terminalPid: number; // Terminal process ID
aiLogs: LogEntry[]; // AI output history

View File

@@ -3187,23 +3187,40 @@ function setupIpcHandlers() {
// Get all named sessions across all projects (for Tab Switcher "All Named" view)
ipcMain.handle('claude:getAllNamedSessions', async () => {
const os = await import('os');
const homeDir = os.default.homedir();
const claudeProjectsDir = path.join(homeDir, '.claude', 'projects');
const allOrigins = claudeSessionOriginsStore.get('origins', {});
const namedSessions: Array<{
claudeSessionId: string;
projectPath: string;
sessionName: string;
starred?: boolean;
lastActivityAt?: number;
}> = [];
for (const [projectPath, sessions] of Object.entries(allOrigins)) {
for (const [claudeSessionId, info] of Object.entries(sessions)) {
// Handle both old string format and new object format
if (typeof info === 'object' && info.sessionName) {
// Try to get last activity time from the session file
let lastActivityAt: number | undefined;
try {
const encodedPath = encodeClaudeProjectPath(projectPath);
const sessionFile = path.join(claudeProjectsDir, encodedPath, `${claudeSessionId}.jsonl`);
const stats = await fs.stat(sessionFile);
lastActivityAt = stats.mtime.getTime();
} catch {
// Session file may not exist or be inaccessible
}
namedSessions.push({
claudeSessionId,
projectPath,
sessionName: info.sessionName,
starred: info.starred,
lastActivityAt,
});
}
}

View File

@@ -418,6 +418,7 @@ contextBridge.exposeInMainWorld('maestro', {
projectPath: string;
sessionName: string;
starred?: boolean;
lastActivityAt?: number;
}>>,
deleteMessagePair: (projectPath: string, sessionId: string, userMessageUuid: string, fallbackContent?: string) =>
ipcRenderer.invoke('claude:deleteMessagePair', projectPath, sessionId, userMessageUuid, fallbackContent),

View File

@@ -337,6 +337,11 @@ export default function MaestroConsole() {
// Restore a persisted session by respawning its process
const restoreSession = async (session: Session): Promise<Session> => {
try {
// Migration: ensure projectRoot is set (for sessions created before this field was added)
if (!session.projectRoot) {
session = { ...session, projectRoot: session.cwd };
}
// Sessions must have aiTabs - if missing, this is a data corruption issue
if (!session.aiTabs || session.aiTabs.length === 0) {
console.error('[restoreSession] Session has no aiTabs - data corruption, skipping:', session.id);
@@ -3621,6 +3626,7 @@ export default function MaestroConsole() {
state: 'idle',
cwd: workingDir,
fullPath: workingDir,
projectRoot: workingDir, // Store the initial directory (never changes)
isGitRepo,
gitBranches,
gitTags,
@@ -3989,13 +3995,8 @@ export default function MaestroConsole() {
return;
}
// Block slash commands when agent is busy (in AI mode)
if (effectiveInputValue.trim().startsWith('/') && activeSession.state === 'busy' && activeSession.inputMode === 'ai') {
showFlashNotification('Commands disabled while agent is working');
return;
}
// Handle slash commands (custom AI commands only - built-in commands have been removed)
// Note: slash commands are queued like regular messages when agent is busy
if (effectiveInputValue.trim().startsWith('/')) {
const commandText = effectiveInputValue.trim();
const isTerminalMode = activeSession.inputMode === 'terminal';
@@ -5073,152 +5074,13 @@ export default function MaestroConsole() {
} else if (e.key === 'ArrowUp') {
e.preventDefault();
setSelectedSlashCommandIndex(prev => Math.max(prev - 1, 0));
} else if (e.key === 'Tab') {
// Tab just fills in the command text
} else if (e.key === 'Tab' || e.key === 'Enter') {
// Tab or Enter fills in the command text (user can then press Enter again to execute)
e.preventDefault();
setInputValue(filteredCommands[selectedSlashCommandIndex]?.command || inputValue);
setSlashCommandOpen(false);
} else if (e.key === 'Enter' && filteredCommands.length > 0) {
// Enter executes the command directly
e.preventDefault();
const selectedCommand = filteredCommands[selectedSlashCommandIndex];
if (selectedCommand) {
if (filteredCommands[selectedSlashCommandIndex]) {
setInputValue(filteredCommands[selectedSlashCommandIndex].command);
setSlashCommandOpen(false);
setInputValue('');
if (inputRef.current) inputRef.current.style.height = 'auto';
// Execute the custom AI command (substitute template variables and send to agent)
if ('prompt' in selectedCommand && selectedCommand.prompt) {
// Use the same spawn logic as processInput for proper tab-based session ID tracking
(async () => {
let gitBranch: string | undefined;
if (activeSession.isGitRepo) {
try {
const status = await gitService.getStatus(activeSession.cwd);
gitBranch = status.branch;
} catch {
// Ignore git errors
}
}
const substitutedPrompt = substituteTemplateVariables(
selectedCommand.prompt,
{ session: activeSession, gitBranch }
);
// Get the active tab for proper targeting
const activeTab = getActiveTab(activeSession);
if (!activeTab) {
console.error('[handleInputKeyDown] No active tab for slash command');
return;
}
// Build target session ID using tab ID (same pattern as processInput)
const targetSessionId = `${activeSessionId}-ai-${activeTab.id}`;
const isNewSession = !activeTab.claudeSessionId;
// Add user log showing the command with its interpolated prompt to active tab
const newEntry: LogEntry = {
id: generateId(),
timestamp: Date.now(),
source: 'user',
text: substitutedPrompt,
aiCommand: {
command: selectedCommand.command,
description: selectedCommand.description
}
};
// Update session state: add log, set busy, set awaitingSessionId for new sessions
setSessions(prev => prev.map(s => {
if (s.id !== activeSessionId) return s;
// Update the active tab's logs and state
const updatedAiTabs = s.aiTabs.map(tab =>
tab.id === activeTab.id
? {
...tab,
logs: [...tab.logs, newEntry],
state: 'busy' as const,
thinkingStartTime: Date.now(),
awaitingSessionId: isNewSession ? true : tab.awaitingSessionId
}
: tab
);
return {
...s,
state: 'busy' as SessionState,
busySource: 'ai',
thinkingStartTime: Date.now(),
currentCycleTokens: 0,
currentCycleBytes: 0,
aiCommandHistory: Array.from(new Set([...(s.aiCommandHistory || []), selectedCommand.command])).slice(-50),
pendingAICommandForSynopsis: selectedCommand.command,
aiTabs: updatedAiTabs
};
}));
// Spawn the agent with proper session ID format (same as processInput)
try {
const agent = await window.maestro.agents.get('claude-code');
if (!agent) throw new Error('Claude Code agent not found');
// Get fresh session state to avoid stale closure
const freshSession = sessionsRef.current.find(s => s.id === activeSessionId);
if (!freshSession) throw new Error('Session not found');
const freshActiveTab = getActiveTab(freshSession);
const tabClaudeSessionId = freshActiveTab?.claudeSessionId;
// Build spawn args with resume if we have a session ID
const spawnArgs = [...(agent.args || [])];
if (tabClaudeSessionId) {
spawnArgs.push('--resume', tabClaudeSessionId);
}
// Add read-only mode if tab has it enabled
if (freshActiveTab?.readOnlyMode) {
spawnArgs.push('--permission-mode', 'plan');
}
const commandToUse = agent.path || agent.command;
await window.maestro.process.spawn({
sessionId: targetSessionId,
toolType: 'claude-code',
cwd: freshSession.cwd,
command: commandToUse,
args: spawnArgs,
prompt: substitutedPrompt
});
} catch (error: any) {
console.error('[handleInputKeyDown] Failed to spawn Claude for slash command:', error);
setSessions(prev => prev.map(s => {
if (s.id !== activeSessionId) return s;
const errorEntry: LogEntry = {
id: generateId(),
timestamp: Date.now(),
source: 'system',
text: `Error: Failed to run ${selectedCommand.command} - ${error.message}`
};
const updatedAiTabs = s.aiTabs.map(tab =>
tab.id === activeTab.id
? { ...tab, state: 'idle' as const, logs: [...tab.logs, errorEntry] }
: tab
);
return {
...s,
state: 'idle' as SessionState,
busySource: undefined,
aiTabs: updatedAiTabs
};
}));
}
})();
} else {
// Claude Code slash command (no prompt property) - send raw command text
// Claude Code will expand the command itself from .claude/commands/*.md
processInput(selectedCommand.command);
}
inputRef.current?.focus();
}
} else if (e.key === 'Escape') {
e.preventDefault();
@@ -6579,7 +6441,7 @@ export default function MaestroConsole() {
theme={theme}
tabs={activeSession.aiTabs}
activeTabId={activeSession.activeTabId}
cwd={activeSession.cwd}
projectRoot={activeSession.projectRoot}
shortcut={TAB_SHORTCUTS.tabSwitcher}
onTabSelect={(tabId) => {
setSessions(prev => prev.map(s =>

View File

@@ -154,7 +154,11 @@ export const InputArea = React.memo(function InputArea(props: InputAreaProps) {
);
// Refs for slash command items to enable scroll-into-view
// Reset refs array length when filtered commands change to avoid stale refs
const slashCommandItemRefs = useRef<(HTMLDivElement | null)[]>([]);
if (slashCommandItemRefs.current.length !== filteredSlashCommands.length) {
slashCommandItemRefs.current = slashCommandItemRefs.current.slice(0, filteredSlashCommands.length);
}
// Refs for tab completion items to enable scroll-into-view
const tabCompletionItemRefs = useRef<(HTMLDivElement | null)[]>([]);
@@ -264,10 +268,10 @@ export const InputArea = React.memo(function InputArea(props: InputAreaProps) {
{/* Slash Command Autocomplete */}
{slashCommandOpen && filteredSlashCommands.length > 0 && (
<div
className="absolute bottom-full left-0 right-0 mb-2 border rounded-lg shadow-2xl max-h-64 overflow-hidden"
className="absolute bottom-full left-0 right-0 mb-2 border rounded-lg shadow-2xl overflow-hidden"
style={{ backgroundColor: theme.colors.bgSidebar, borderColor: theme.colors.border }}
>
<div className="overflow-y-auto max-h-64 scrollbar-thin">
<div className="overflow-y-auto max-h-64 scrollbar-thin" style={{ overscrollBehavior: 'contain' }}>
{filteredSlashCommands.map((cmd, idx) => (
<div
key={cmd.command}
@@ -280,11 +284,14 @@ export const InputArea = React.memo(function InputArea(props: InputAreaProps) {
color: idx === safeSelectedIndex ? theme.colors.bgMain : theme.colors.textMain
}}
onClick={() => {
// Single click just selects the item
setSelectedSlashCommandIndex(idx);
}}
onDoubleClick={() => {
// Double click fills in the command text
setInputValue(cmd.command);
setSlashCommandOpen(false);
inputRef.current?.focus();
// Execute the command after a brief delay to let state update
setTimeout(() => processInput(), 10);
}}
onMouseEnter={() => setSelectedSlashCommandIndex(idx)}
>
@@ -555,9 +562,12 @@ export const InputArea = React.memo(function InputArea(props: InputAreaProps) {
// Show slash command autocomplete when typing /
if (value.startsWith('/') && !value.includes(' ')) {
// Only reset selection when modal first opens, not on every keystroke
if (!slashCommandOpen) {
setSelectedSlashCommandIndex(0);
}
setSlashCommandOpen(true);
// Always reset selection to first item when filter changes
setSelectedSlashCommandIndex(0);
// Clamp selection if filtered list shrinks (handled by safeSelectedIndex in render)
} else {
setSlashCommandOpen(false);
}

View File

@@ -611,7 +611,7 @@ export function SessionList(props: SessionListProps) {
return (
<div
tabIndex={0}
className={`border-r flex flex-col shrink-0 transition-all duration-300 outline-none relative ${activeFocus === 'sidebar' ? 'ring-1 ring-inset z-10' : ''}`}
className={`border-r flex flex-col shrink-0 transition-all duration-300 outline-none relative z-20 ${activeFocus === 'sidebar' ? 'ring-1 ring-inset' : ''}`}
style={{
width: leftSidebarOpen ? `${leftSidebarWidthState}px` : '64px',
backgroundColor: theme.colors.bgSidebar,

View File

@@ -12,6 +12,7 @@ interface NamedSession {
projectPath: string;
sessionName: string;
starred?: boolean;
lastActivityAt?: number;
}
/** Union type for items in the list */
@@ -23,7 +24,7 @@ interface TabSwitcherModalProps {
theme: Theme;
tabs: AITab[];
activeTabId: string;
cwd: string; // Current working directory for syncing tab names
projectRoot: string; // The initial project directory (used for Claude session storage)
shortcut?: Shortcut;
onTabSelect: (tabId: string) => void;
onNamedSessionSelect: (claudeSessionId: string, projectPath: string, sessionName: string, starred?: boolean) => void;
@@ -49,6 +50,32 @@ function formatCost(cost: number): string {
return '$' + cost.toFixed(2);
}
/**
* Format a timestamp as relative time (e.g., "5m ago", "2h ago", "Dec 3")
*/
function formatRelativeTime(timestamp: number): string {
const now = Date.now();
const diffMs = now - timestamp;
const diffMins = Math.floor(diffMs / 60000);
const diffHours = Math.floor(diffMins / 60);
const diffDays = Math.floor(diffHours / 24);
if (diffMins < 1) return 'Now';
if (diffMins < 60) return `${diffMins}m ago`;
if (diffHours < 24) return `${diffHours}h ago`;
if (diffDays < 7) return `${diffDays}d ago`;
return new Date(timestamp).toLocaleDateString([], { month: 'short', day: 'numeric' });
}
/**
* Get the last activity timestamp from a tab's logs
*/
function getTabLastActivity(tab: AITab): number | undefined {
if (!tab.logs || tab.logs.length === 0) return undefined;
// Get the most recent log entry timestamp
return Math.max(...tab.logs.map(log => log.timestamp));
}
/**
* Get context usage percentage from usage stats
* Uses inputTokens + outputTokens (not cache tokens) to match MainPanel calculation
@@ -141,7 +168,7 @@ export function TabSwitcherModal({
theme,
tabs,
activeTabId,
cwd,
projectRoot,
shortcut,
onTabSelect,
onNamedSessionSelect,
@@ -208,7 +235,7 @@ export function TabSwitcherModal({
const namedTabs = tabs.filter(t => t.name && t.claudeSessionId);
await Promise.all(
namedTabs.map(tab =>
window.maestro.claude.updateSessionName(cwd, tab.claudeSessionId!, tab.name!)
window.maestro.claude.updateSessionName(projectRoot, tab.claudeSessionId!, tab.name!)
.catch(err => console.warn('[TabSwitcher] Failed to sync tab name:', err))
)
);
@@ -221,7 +248,7 @@ export function TabSwitcherModal({
if (!namedSessionsLoaded) {
syncAndLoad();
}
}, [namedSessionsLoaded, tabs, cwd]);
}, [namedSessionsLoaded, tabs, projectRoot]);
// Scroll selected item into view
useEffect(() => {
@@ -254,7 +281,7 @@ export function TabSwitcherModal({
});
return sorted.map(tab => ({ type: 'open' as const, tab }));
} else {
// All Named mode - show ALL named sessions (including open ones)
// All Named mode - show named sessions for the CURRENT PROJECT only (including open ones)
// For open tabs, use the 'open' type so we get usage stats; for closed ones use 'named'
const items: ListItem[] = [];
@@ -265,9 +292,9 @@ export function TabSwitcherModal({
}
}
// Add closed named sessions (not currently open)
// Add closed named sessions from the SAME PROJECT (not currently open)
for (const session of namedSessions) {
if (!openTabSessionIds.has(session.claudeSessionId)) {
if (session.projectPath === projectRoot && !openTabSessionIds.has(session.claudeSessionId)) {
items.push({ type: 'named' as const, session });
}
}
@@ -281,7 +308,7 @@ export function TabSwitcherModal({
return items;
}
}, [viewMode, tabs, namedSessions, openTabSessionIds]);
}, [viewMode, tabs, namedSessions, openTabSessionIds, projectRoot]);
// Filter items based on search query
const filteredItems = useMemo(() => {
@@ -518,6 +545,10 @@ export function TabSwitcherModal({
<span>{formatCost(cost)}</span>
</>
)}
{(() => {
const lastActivity = getTabLastActivity(tab);
return lastActivity ? <span>{formatRelativeTime(lastActivity)}</span> : null;
})()}
</div>
</div>
@@ -578,7 +609,9 @@ export function TabSwitcherModal({
)}
</div>
<div className="flex items-center gap-3 text-[10px] opacity-60">
<span className="truncate">{session.projectPath.split('/').slice(-2).join('/')}</span>
{session.lastActivityAt && (
<span>{formatRelativeTime(session.lastActivityAt)}</span>
)}
</div>
</div>

View File

@@ -578,7 +578,7 @@ const LogItemComponent = memo(({
) : isAIMode && !markdownRawMode ? (
// Collapsed markdown preview with rendered markdown
// Note: prose styles are injected once at TerminalOutput container level for performance
<div className="prose prose-sm max-w-none" style={{ color: theme.colors.textMain, lineHeight: 1.4 }}>
<div className="prose prose-sm max-w-none" style={{ color: theme.colors.textMain, lineHeight: 1.4, paddingLeft: '0.5em' }}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
@@ -703,7 +703,7 @@ const LogItemComponent = memo(({
) : isAIMode && !markdownRawMode ? (
// Expanded markdown rendering
// Note: prose styles are injected once at TerminalOutput container level for performance
<div className="prose prose-sm max-w-none text-sm" style={{ color: theme.colors.textMain, lineHeight: 1.4 }}>
<div className="prose prose-sm max-w-none text-sm" style={{ color: theme.colors.textMain, lineHeight: 1.4, paddingLeft: '0.5em' }}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
@@ -810,7 +810,7 @@ const LogItemComponent = memo(({
) : isAIMode && !markdownRawMode ? (
// Rendered markdown for AI responses
// Note: prose styles are injected once at TerminalOutput container level for performance
<div className="prose prose-sm max-w-none text-sm" style={{ color: theme.colors.textMain, lineHeight: 1.4 }}>
<div className="prose prose-sm max-w-none text-sm" style={{ color: theme.colors.textMain, lineHeight: 1.4, paddingLeft: '0.5em' }}>
<ReactMarkdown
remarkPlugins={[remarkGfm]}
components={{
@@ -1567,8 +1567,8 @@ export const TerminalOutput = forwardRef<HTMLDivElement, TerminalOutputProps>((p
.prose p { color: ${theme.colors.textMain}; margin: 0 !important; line-height: 1.4; }
.prose p + p { margin-top: 0.5em !important; }
.prose p:empty { display: none; }
.prose > ul, .prose > ol { color: ${theme.colors.textMain}; margin: 0.25em 0 !important; padding-left: 0.5em; list-style-position: inside; }
.prose li ul, .prose li ol { margin: 0 !important; padding-left: 1em; list-style-position: inside; }
.prose > ul, .prose > ol { color: ${theme.colors.textMain}; margin: 0.25em 0 !important; padding-left: 2em; list-style-position: outside; }
.prose li ul, .prose li ol { margin: 0 !important; padding-left: 1.5em; list-style-position: outside; }
.prose li { margin: 0 !important; padding: 0; line-height: 1.4; display: list-item; }
.prose li > p { margin: 0 !important; display: contents !important; }
.prose li > p + ul, .prose li > p + ol { margin-top: 0 !important; }

View File

@@ -308,6 +308,7 @@ interface MaestroAPI {
projectPath: string;
sessionName: string;
starred?: boolean;
lastActivityAt?: number;
}>>;
deleteMessagePair: (projectPath: string, sessionId: string, userMessageUuid: string, fallbackContent?: string) => Promise<{ success: boolean; linesRemoved?: number; error?: string }>;
};

View File

@@ -261,6 +261,7 @@ export interface Session {
state: SessionState;
cwd: string;
fullPath: string;
projectRoot: string; // The initial working directory (never changes, used for Claude session storage)
aiLogs: LogEntry[];
shellLogs: LogEntry[];
workLog: WorkLogItem[];