feat: Add session naming with search and improve macOS code signing

- Add user-defined session names stored with Claude session origins
- Display session names in AgentSessionsBrowser with tag icon
- Add "Named only" filter to quickly find named sessions
- Include session names in search across title and content modes
- Sync session names when renaming sessions in the sidebar
- Fix macOS code signing with ad-hoc signatures in release workflow
- Fix electron-builder CLI syntax (--config.extraMetadata)
- Improve lightbox navigation with context-aware image arrays
- Fix GitLogViewer search input focus handling
- Improve AI command prompt display with multi-line clamp

Claude ID: 24a6cdd6-27a7-41e0-af30-679cc2ffe66b
Maestro ID: 5a166b38-b7e9-47f0-a8ff-0113c65f2682
This commit is contained in:
Pedram Amini
2025-11-27 01:59:49 -06:00
parent 17b929d430
commit 0835672ce0
11 changed files with 195 additions and 44 deletions

View File

@@ -96,20 +96,47 @@ jobs:
- name: Package for macOS
if: matrix.platform == 'mac'
run: npx electron-builder --mac --publish never -c.extraMetadata.version=${{ steps.version.outputs.VERSION }}
run: npx electron-builder --mac --publish never --config.extraMetadata.version=${{ steps.version.outputs.VERSION }}
env:
CSC_IDENTITY_AUTO_DISCOVERY: false
CSC_LINK: ""
DEBUG: electron-builder
# Ad-hoc sign macOS apps and re-create archives
# Fixes "code has no resources but signature indicates they must be present"
- name: Ad-hoc sign macOS apps
if: matrix.platform == 'mac'
run: |
VERSION=${{ steps.version.outputs.VERSION }}
# Sign x64 app and recreate zip
if [ -d "release/mac/Maestro.app" ]; then
echo "Ad-hoc signing: release/mac/Maestro.app"
codesign --sign - --deep --force "release/mac/Maestro.app"
echo "Re-creating ZIP for x64..."
rm -f "release/Maestro-${VERSION}-mac.zip"
cd release/mac && zip -r -y "../Maestro-${VERSION}-mac.zip" Maestro.app && cd ../..
fi
# Sign arm64 app and recreate zip
if [ -d "release/mac-arm64/Maestro.app" ]; then
echo "Ad-hoc signing: release/mac-arm64/Maestro.app"
codesign --sign - --deep --force "release/mac-arm64/Maestro.app"
echo "Re-creating ZIP for arm64..."
rm -f "release/Maestro-${VERSION}-arm64-mac.zip"
cd release/mac-arm64 && zip -r -y "../Maestro-${VERSION}-arm64-mac.zip" Maestro.app && cd ../..
fi
- name: Package for Windows
if: matrix.platform == 'win'
run: npx electron-builder --win --publish never -c.extraMetadata.version=${{ steps.version.outputs.VERSION }}
shell: bash
run: npx electron-builder --win --publish never --config.extraMetadata.version=${{ steps.version.outputs.VERSION }}
env:
DEBUG: electron-builder
- name: Package for Linux
if: matrix.platform == 'linux'
run: npx electron-builder --linux --publish never -c.extraMetadata.version=${{ steps.version.outputs.VERSION }}
run: npx electron-builder --linux --publish never --config.extraMetadata.version=${{ steps.version.outputs.VERSION }}
env:
DEBUG: electron-builder

View File

@@ -40,6 +40,7 @@
],
"mac": {
"category": "public.app-category.developer-tools",
"identity": null,
"target": [
{
"target": "dmg",

View File

@@ -138,9 +138,13 @@ const historyStore = new Store<HistoryData>({
// Claude session origins store - tracks which Claude sessions were created by Maestro
// and their origin type (user-initiated vs auto/batch)
type ClaudeSessionOrigin = 'user' | 'auto';
interface ClaudeSessionOriginInfo {
origin: ClaudeSessionOrigin;
sessionName?: string; // User-defined session name from Maestro
}
interface ClaudeSessionOriginsData {
// Map of projectPath -> { claudeSessionId -> origin type }
origins: Record<string, Record<string, ClaudeSessionOrigin>>;
// Map of projectPath -> { claudeSessionId -> origin info }
origins: Record<string, Record<string, ClaudeSessionOrigin | ClaudeSessionOriginInfo>>;
}
const claudeSessionOriginsStore = new Store<ClaudeSessionOriginsData>({
@@ -1086,11 +1090,18 @@ function setupIpcHandlers() {
const origins = claudeSessionOriginsStore.get('origins', {});
const projectOrigins = origins[projectPath] || {};
// Add origin info to each session
const sessionsWithOrigins = validSessions.map(session => ({
...session,
origin: projectOrigins[session.sessionId] as ClaudeSessionOrigin | undefined,
}));
// Add origin info and session name to each session
const sessionsWithOrigins = validSessions.map(session => {
const originData = projectOrigins[session.sessionId];
// Handle both old string format and new object format
const origin = typeof originData === 'string' ? originData : originData?.origin;
const sessionName = typeof originData === 'object' ? originData?.sessionName : undefined;
return {
...session,
origin: origin as ClaudeSessionOrigin | undefined,
sessionName,
};
});
logger.info(`Found ${validSessions.length} Claude sessions for project`, 'ClaudeSessions', { projectPath });
return sessionsWithOrigins;
@@ -1527,14 +1538,38 @@ function setupIpcHandlers() {
});
// Claude session origins tracking (distinguishes Maestro-created sessions from CLI sessions)
ipcMain.handle('claude:registerSessionOrigin', async (_event, projectPath: string, claudeSessionId: string, origin: 'user' | 'auto') => {
ipcMain.handle('claude:registerSessionOrigin', async (_event, projectPath: string, claudeSessionId: string, origin: 'user' | 'auto', sessionName?: string) => {
const origins = claudeSessionOriginsStore.get('origins', {});
if (!origins[projectPath]) {
origins[projectPath] = {};
}
origins[projectPath][claudeSessionId] = origin;
// Store as object if sessionName provided, otherwise just origin string for backwards compat
origins[projectPath][claudeSessionId] = sessionName
? { origin, sessionName }
: origin;
claudeSessionOriginsStore.set('origins', origins);
logger.debug(`Registered Claude session origin: ${claudeSessionId} = ${origin}`, 'ClaudeSessionOrigins', { projectPath });
logger.debug(`Registered Claude session origin: ${claudeSessionId} = ${origin}${sessionName ? ` (name: ${sessionName})` : ''}`, 'ClaudeSessionOrigins', { projectPath });
return true;
});
// Update session name for an existing Claude session
ipcMain.handle('claude:updateSessionName', async (_event, projectPath: string, claudeSessionId: string, sessionName: string) => {
const origins = claudeSessionOriginsStore.get('origins', {});
if (!origins[projectPath]) {
origins[projectPath] = {};
}
const existing = origins[projectPath][claudeSessionId];
// Convert string origin to object format, or update existing object
if (typeof existing === 'string') {
origins[projectPath][claudeSessionId] = { origin: existing, sessionName };
} else if (existing) {
origins[projectPath][claudeSessionId] = { ...existing, sessionName };
} else {
// No existing origin, default to 'user' since they're naming it
origins[projectPath][claudeSessionId] = { origin: 'user', sessionName };
}
claudeSessionOriginsStore.set('origins', origins);
logger.debug(`Updated Claude session name: ${claudeSessionId} = ${sessionName}`, 'ClaudeSessionOrigins', { projectPath });
return true;
});

View File

@@ -210,8 +210,10 @@ contextBridge.exposeInMainWorld('maestro', {
getCommands: (projectPath: string) =>
ipcRenderer.invoke('claude:getCommands', projectPath),
// Session origin tracking (distinguishes Maestro sessions from CLI sessions)
registerSessionOrigin: (projectPath: string, claudeSessionId: string, origin: 'user' | 'auto') =>
ipcRenderer.invoke('claude:registerSessionOrigin', projectPath, claudeSessionId, origin),
registerSessionOrigin: (projectPath: string, claudeSessionId: string, origin: 'user' | 'auto', sessionName?: string) =>
ipcRenderer.invoke('claude:registerSessionOrigin', projectPath, claudeSessionId, origin, sessionName),
updateSessionName: (projectPath: string, claudeSessionId: string, sessionName: string) =>
ipcRenderer.invoke('claude:updateSessionName', projectPath, claudeSessionId, sessionName),
getSessionOrigins: (projectPath: string) =>
ipcRenderer.invoke('claude:getSessionOrigins', projectPath),
},
@@ -371,7 +373,14 @@ export interface MaestroAPI {
firstMessage: string;
messageCount: number;
sizeBytes: number;
costUsd: number;
inputTokens: number;
outputTokens: number;
cacheReadTokens: number;
cacheCreationTokens: number;
durationSeconds: number;
origin?: 'user' | 'auto'; // Maestro session origin, undefined for CLI sessions
sessionName?: string; // User-defined session name from Maestro
}>>;
readSessionMessages: (projectPath: string, sessionId: string, options?: { offset?: number; limit?: number }) => Promise<{
messages: Array<{
@@ -395,8 +404,9 @@ export interface MaestroAPI {
command: string;
description: string;
}>>;
registerSessionOrigin: (projectPath: string, claudeSessionId: string, origin: 'user' | 'auto') => Promise<boolean>;
getSessionOrigins: (projectPath: string) => Promise<Record<string, 'user' | 'auto'>>;
registerSessionOrigin: (projectPath: string, claudeSessionId: string, origin: 'user' | 'auto', sessionName?: string) => Promise<boolean>;
updateSessionName: (projectPath: string, claudeSessionId: string, sessionName: string) => Promise<boolean>;
getSessionOrigins: (projectPath: string) => Promise<Record<string, 'user' | 'auto' | { origin: 'user' | 'auto'; sessionName?: string }>>;
};
tempfile: {
write: (content: string, filename?: string) => Promise<{ success: boolean; path?: string; error?: string }>;

View File

@@ -131,6 +131,7 @@ export default function MaestroConsole() {
const [quickActionInitialMode, setQuickActionInitialMode] = useState<'main' | 'move-to-group'>('main');
const [settingsTab, setSettingsTab] = useState<'general' | 'shortcuts' | 'theme' | 'network'>('general');
const [lightboxImage, setLightboxImage] = useState<string | null>(null);
const [lightboxImages, setLightboxImages] = useState<string[]>([]); // Context images for navigation
const [aboutModalOpen, setAboutModalOpen] = useState(false);
const [logViewerOpen, setLogViewerOpen] = useState(false);
const [processMonitorOpen, setProcessMonitorOpen] = useState(false);
@@ -650,8 +651,9 @@ export default function MaestroConsole() {
}
// Register this as a user-initiated Maestro session (batch sessions are filtered above)
window.maestro.claude.registerSessionOrigin(session.cwd, claudeSessionId, 'user')
.then(() => console.log('[onSessionId] Registered session origin as user:', claudeSessionId))
// Also pass the session name so it can be searched in the Claude sessions browser
window.maestro.claude.registerSessionOrigin(session.cwd, claudeSessionId, 'user', session.name)
.then(() => console.log('[onSessionId] Registered session origin as user:', claudeSessionId, 'name:', session.name))
.catch(err => console.error('[onSessionId] Failed to register session origin:', err));
return prev.map(s => {
@@ -1127,6 +1129,12 @@ export default function MaestroConsole() {
}
}, [activeSession]);
// Handler to open lightbox with optional context images for navigation
const handleSetLightboxImage = useCallback((image: string | null, contextImages?: string[]) => {
setLightboxImage(image);
setLightboxImages(contextImages || []);
}, []);
// Create sorted sessions array that matches visual display order (includes ALL sessions)
const sortedSessions = useMemo(() => {
const sorted: Session[] = [];
@@ -1860,7 +1868,28 @@ export default function MaestroConsole() {
};
const finishRenamingSession = (sessId: string, newName: string) => {
setSessions(prev => prev.map(s => s.id === sessId ? { ...s, name: newName } : s));
setSessions(prev => {
const updated = prev.map(s => s.id === sessId ? { ...s, name: newName } : s);
// Sync the session name to Claude session storage for searchability
const session = updated.find(s => s.id === sessId);
if (session?.claudeSessionId && session.cwd) {
console.log('[finishRenamingSession] Syncing session name to Claude storage:', {
claudeSessionId: session.claudeSessionId,
cwd: session.cwd,
newName
});
window.maestro.claude.updateSessionName(session.cwd, session.claudeSessionId, newName)
.then(() => console.log('[finishRenamingSession] Successfully synced session name'))
.catch(err => console.warn('[finishRenamingSession] Failed to sync session name:', err));
} else {
console.log('[finishRenamingSession] Cannot sync - missing claudeSessionId or cwd:', {
sessId,
claudeSessionId: session?.claudeSessionId,
cwd: session?.cwd
});
}
return updated;
});
setEditingSessionId(null);
};
@@ -2953,8 +2982,8 @@ export default function MaestroConsole() {
{lightboxImage && (
<LightboxModal
image={lightboxImage}
stagedImages={stagedImages}
onClose={() => setLightboxImage(null)}
stagedImages={lightboxImages.length > 0 ? lightboxImages : stagedImages}
onClose={() => { setLightboxImage(null); setLightboxImages([]); }}
onNavigate={(img) => setLightboxImage(img)}
/>
)}
@@ -3196,7 +3225,7 @@ export default function MaestroConsole() {
setEnterToSendAI={setEnterToSendAI}
setEnterToSendTerminal={setEnterToSendTerminal}
setStagedImages={setStagedImages}
setLightboxImage={setLightboxImage}
setLightboxImage={handleSetLightboxImage}
setCommandHistoryOpen={setCommandHistoryOpen}
setCommandHistoryFilter={setCommandHistoryFilter}
setCommandHistorySelectedIndex={setCommandHistorySelectedIndex}

View File

@@ -352,11 +352,11 @@ export function AICommandsPanel({ theme, customAICommands, setCustomAICommands }
{cmd.description}
</div>
<div
className="text-xs p-2 rounded font-mono overflow-hidden text-ellipsis whitespace-nowrap"
className="text-xs p-2 rounded font-mono overflow-hidden line-clamp-3"
style={{ backgroundColor: theme.colors.bgActivity, color: theme.colors.textMain }}
title={cmd.prompt}
>
{cmd.prompt.length > 100 ? `${cmd.prompt.slice(0, 100)}...` : cmd.prompt}
{cmd.prompt}
</div>
</div>
)}

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
import { Search, Clock, MessageSquare, HardDrive, Play, ChevronLeft, Loader2, Plus, X, List, Database, BarChart3, ChevronDown, User, Bot, DollarSign, Star, Zap, Timer, Hash, ArrowDownToLine, ArrowUpFromLine } from 'lucide-react';
import { Search, Clock, MessageSquare, HardDrive, Play, ChevronLeft, Loader2, Plus, X, List, Database, BarChart3, ChevronDown, User, Bot, DollarSign, Star, Zap, Timer, Hash, ArrowDownToLine, ArrowUpFromLine, Tag } from 'lucide-react';
import type { Theme, Session, LogEntry } from '../types';
import { useLayerStack } from '../contexts/LayerStackContext';
import { MODAL_PRIORITIES } from '../constants/modalPriorities';
@@ -28,6 +28,7 @@ interface ClaudeSession {
cacheCreationTokens: number;
durationSeconds: number;
origin?: 'user' | 'auto'; // Maestro session origin, undefined for CLI sessions
sessionName?: string; // User-defined session name from Maestro
}
interface SessionMessage {
@@ -61,6 +62,7 @@ export function AgentSessionsBrowser({
const [search, setSearch] = useState('');
const [searchMode, setSearchMode] = useState<SearchMode>('all');
const [showAllSessions, setShowAllSessions] = useState(false);
const [namedOnly, setNamedOnly] = useState(false);
const [searchModeDropdownOpen, setSearchModeDropdownOpen] = useState(false);
const [searchResults, setSearchResults] = useState<SearchResult[]>([]);
const [isSearching, setIsSearching] = useState(false);
@@ -327,12 +329,16 @@ export function AgentSessionsBrowser({
}
}, [hasMoreMessages, messagesLoading, handleLoadMore]);
// Helper to check if a session should be visible based on showAllSessions
// Helper to check if a session should be visible based on filters
const isSessionVisible = useCallback((session: ClaudeSession) => {
// Named only filter - if enabled, only show sessions with a custom name
if (namedOnly && !session.sessionName) {
return false;
}
if (showAllSessions) return true;
// Hide sessions that start with "agent-" (only show UUID-style sessions by default)
return !session.sessionId.startsWith('agent-');
}, [showAllSessions]);
}, [showAllSessions, namedOnly]);
// Calculate stats from visible sessions
const stats = useMemo(() => {
@@ -368,20 +374,29 @@ export function AgentSessionsBrowser({
return sortWithStarred(visibleSessions);
}
// For title search, filter locally (fast)
// For title search, filter locally (fast) - include sessionName
if (searchMode === 'title') {
const searchLower = search.toLowerCase();
const filtered = visibleSessions.filter(s =>
s.firstMessage.toLowerCase().includes(searchLower) ||
s.sessionId.toLowerCase().includes(searchLower)
s.sessionId.toLowerCase().includes(searchLower) ||
(s.sessionName && s.sessionName.toLowerCase().includes(searchLower))
);
return sortWithStarred(filtered);
}
// For content searches, use backend results to filter sessions
if (searchResults.length > 0) {
const matchingIds = new Set(searchResults.map(r => r.sessionId));
const filtered = visibleSessions.filter(s => matchingIds.has(s.sessionId));
// Also include sessions that match by sessionName (strong match in 'all' mode)
const searchLower = search.toLowerCase();
const matchingIds = new Set(searchResults.map(r => r.sessionId));
// Add sessions that match by sessionName to the results
const filtered = visibleSessions.filter(s =>
matchingIds.has(s.sessionId) ||
(s.sessionName && s.sessionName.toLowerCase().includes(searchLower))
);
if (filtered.length > 0) {
return sortWithStarred(filtered);
}
@@ -798,6 +813,21 @@ export function AgentSessionsBrowser({
<X className="w-3 h-3" />
</button>
)}
{/* Named Only checkbox */}
<label
className="flex items-center gap-1.5 text-xs cursor-pointer select-none"
style={{ color: namedOnly ? theme.colors.accent : theme.colors.textDim }}
title="Only show sessions with custom names"
>
<input
type="checkbox"
checked={namedOnly}
onChange={(e) => setNamedOnly(e.target.checked)}
className="w-3.5 h-3.5 rounded cursor-pointer accent-current"
style={{ accentColor: theme.colors.accent }}
/>
<span>Named</span>
</label>
{/* Show All checkbox */}
<label
className="flex items-center gap-1.5 text-xs cursor-pointer select-none"
@@ -904,10 +934,22 @@ export function AgentSessionsBrowser({
/>
</button>
<div className="flex-1 min-w-0">
{/* Line 1: Title/first message */}
{/* Line 1: Session name (if available) with tag icon */}
{session.sessionName && (
<div className="flex items-center gap-1.5 mb-1">
<Tag className="w-3.5 h-3.5 flex-shrink-0" style={{ color: theme.colors.accent }} />
<span
className="font-semibold text-sm truncate"
style={{ color: theme.colors.accent }}
>
{session.sessionName}
</span>
</div>
)}
{/* Line 2: First message / title */}
<div
className="font-medium truncate text-sm mb-1.5"
style={{ color: theme.colors.textMain }}
className={`font-medium truncate text-sm ${session.sessionName ? 'mb-1' : 'mb-1.5'}`}
style={{ color: session.sessionName ? theme.colors.textDim : theme.colors.textMain }}
>
{session.firstMessage || `Session ${session.sessionId.slice(0, 8)}...`}
</div>

View File

@@ -312,11 +312,17 @@ export function GitLogViewer({ cwd, theme, onClose }: GitLogViewerProps) {
{/* Search bar */}
<div
className="px-6 py-3 border-b flex items-center gap-3"
className="px-6 py-3 border-b flex items-center gap-3 cursor-text"
style={{ borderColor: theme.colors.border, backgroundColor: theme.colors.bgSidebar }}
onClick={() => searchInputRef.current?.focus()}
onMouseDown={(e) => {
// Prevent the modal from stealing focus back
if (e.target !== searchInputRef.current) {
e.preventDefault();
searchInputRef.current?.focus();
}
}}
>
<Search className="w-4 h-4" style={{ color: isSearchFocused ? theme.colors.accent : theme.colors.textDim }} />
<Search className="w-4 h-4 flex-shrink-0 pointer-events-none" style={{ color: isSearchFocused ? theme.colors.accent : theme.colors.textDim }} />
<input
ref={searchInputRef}
type="text"
@@ -326,6 +332,7 @@ export function GitLogViewer({ cwd, theme, onClose }: GitLogViewerProps) {
onChange={(e) => setSearchQuery(e.target.value)}
onFocus={() => setIsSearchFocused(true)}
onBlur={() => setIsSearchFocused(false)}
onMouseDown={(e) => e.stopPropagation()}
className="flex-1 bg-transparent text-sm outline-none border-none"
style={{
color: theme.colors.textMain,

View File

@@ -18,7 +18,7 @@ interface InputAreaProps {
setEnterToSend: (value: boolean) => void;
stagedImages: string[];
setStagedImages: React.Dispatch<React.SetStateAction<string[]>>;
setLightboxImage: (image: string | null) => void;
setLightboxImage: (image: string | null, contextImages?: string[]) => void;
commandHistoryOpen: boolean;
setCommandHistoryOpen: (open: boolean) => void;
commandHistoryFilter: string;
@@ -99,7 +99,7 @@ export function InputArea(props: InputAreaProps) {
src={img}
className="h-16 rounded border cursor-pointer hover:opacity-80 transition-opacity"
style={{ borderColor: theme.colors.border }}
onClick={() => setLightboxImage(img)}
onClick={() => setLightboxImage(img, stagedImages)}
/>
<button
onClick={(e) => {

View File

@@ -66,7 +66,7 @@ interface MainPanelProps {
setEnterToSendAI: (value: boolean) => void;
setEnterToSendTerminal: (value: boolean) => void;
setStagedImages: (images: string[]) => void;
setLightboxImage: (image: string | null) => void;
setLightboxImage: (image: string | null, contextImages?: string[]) => void;
setCommandHistoryOpen: (open: boolean) => void;
setCommandHistoryFilter: (filter: string) => void;
setCommandHistorySelectedIndex: (index: number) => void;

View File

@@ -17,7 +17,7 @@ interface TerminalOutputProps {
setOutputSearchOpen: (open: boolean) => void;
setOutputSearchQuery: (query: string) => void;
setActiveFocus: (focus: string) => void;
setLightboxImage: (image: string | null) => void;
setLightboxImage: (image: string | null, contextImages?: string[]) => void;
inputRef: React.RefObject<HTMLTextAreaElement>;
logsEndRef: React.RefObject<HTMLDivElement>;
maxOutputLines: number;
@@ -780,7 +780,7 @@ export const TerminalOutput = forwardRef<HTMLDivElement, TerminalOutputProps>((p
{log.images && log.images.length > 0 && (
<div className="flex gap-2 mb-2 overflow-x-auto scrollbar-thin">
{log.images.map((img, imgIdx) => (
<img key={imgIdx} src={img} className="h-20 rounded border cursor-zoom-in" onClick={() => setLightboxImage(img)} />
<img key={imgIdx} src={img} className="h-20 rounded border cursor-zoom-in" onClick={() => setLightboxImage(img, log.images)} />
))}
</div>
)}