## CHANGES

- Added Git Worktrees for truly parallel, conflict-free multi-agent development! 🌳
- Introduced Worktree sub-agents nested under parents for clean organization! 🌿
- Built WorktreeConfigModal to manage base paths and watching behavior! ⚙️
- Added one-click Pull Request creation for worktree child sessions! 🔀
- Integrated Create PR actions into sidebar context menus and shortcuts! ⌨️
- Updated git status dropdown with “Configure Worktrees” and PR actions! 🧩
- Reworked Auto Run guidance to promote worktree sessions for parallelism! 🚀
- Removed Auto Run’s embedded worktree UI, simplifying batch runner flow! 🧹
- Simplified playbooks by dropping stored worktree settings entirely! 📓
- Polished tab starring safety by blocking stars on unsaved tabs! 
This commit is contained in:
Pedram Amini
2025-12-22 00:59:40 -06:00
parent 99949bd577
commit 0a6c87c9ab
16 changed files with 478 additions and 395 deletions

View File

@@ -48,6 +48,7 @@ Download the latest release for your platform from the [Releases](https://github
### Power Features
- 🌳 **[Git Worktrees](#git-worktrees)** - Run AI agents in parallel on isolated branches. Create worktree sub-agents from the git branch menu, each operating in their own directory. Work interactively in the main repo while sub-agents process tasks independently—then create PRs with one click. True parallel development without conflicts.
- 🤖 **[Auto Run & Playbooks](#auto-run)** - File-system-based task runner that batch-processes markdown checklists through AI agents. Create playbooks for repeatable workflows, run in loops, and track progress with full history. Each task gets its own AI session for clean conversation context.
- 💬 **[Group Chat](#group-chat)** - Coordinate multiple AI agents in a single conversation. A moderator AI orchestrates discussions, routing questions to the right agents and synthesizing their responses for cross-project questions and architecture discussions.
- 🌐 **[Mobile Remote Control](#remote-access)** - Built-in web server with QR code access. Monitor and control all your agents from your phone. Supports local network access and remote tunneling via Cloudflare for access from anywhere.
@@ -111,6 +112,7 @@ This approach mirrors methodologies like [Spec-Kit](https://github.com/github/sp
| **Agent** | A workspace tied to a project directory and AI provider (Claude Code, Codex, or OpenCode). Contains one Command Terminal and one AI Terminal with full conversation history. |
| **Group** | Organizational container for agents. Group by project, client, or workflow. |
| **Group Chat** | Multi-agent conversation coordinated by a moderator. Ask questions across multiple agents and get synthesized answers. |
| **Git Worktree** | An isolated working directory linked to a separate branch. Worktree sub-agents appear nested under their parent in the session list and can create PRs. |
| **AI Terminal** | The conversation interface with your AI agent. Supports `@` file mentions, slash commands, and image attachments. |
| **Command Terminal** | A PTY shell session for running commands directly. Tab completion for files, git branches, and command history. |
| **Session Explorer** | Browse all past conversations for an agent. Star, rename, search, and resume any previous session. |
@@ -352,6 +354,57 @@ It's {{WEEKDAY}}, {{DATE}}. I'm on branch {{GIT_BRANCH}} at {{AGENT_PATH}}.
Summarize what I worked on yesterday and suggest priorities for today.
```
## Git Worktrees
Git worktrees enable true parallel development by letting you run multiple AI agents on separate branches simultaneously. Each worktree operates in its own isolated directory, so there's no risk of conflicts between parallel work streams.
### Creating a Worktree Sub-Agent
1. In the session list, hover over an agent in a git repository
2. Click the **git branch indicator** (shows current branch name)
3. In the overlay menu, click **"Create Worktree Sub-Agent"**
4. Configure the worktree:
- **Worktree Directory** — Base folder where worktrees are created
- **Branch Name** — Name for the new branch (becomes the subdirectory name)
- **Create PR on Completion** — Auto-open a pull request when done
- **Target Branch** — Base branch for the PR (defaults to main/master)
### How Worktree Sessions Work
- **Nested Display** — Worktree sub-agents appear indented under their parent session in the left sidebar
- **Branch Icon** — A git branch icon indicates worktree sessions
- **Collapse/Expand** — Click the chevron on a parent session to show/hide its worktree children
- **Independent Operation** — Each worktree session has its own working directory, conversation history, and state
### Creating Pull Requests
When you're done with work in a worktree:
1. **Right-click** the worktree session → **Create Pull Request**, or
2. Press **Cmd+K** with the worktree active → search "Create Pull Request"
The PR modal shows:
- Source branch (your worktree branch)
- Target branch (configurable)
- Auto-generated title and description based on your work
**Requirements:** GitHub CLI (`gh`) must be installed and authenticated. Maestro will detect if it's missing and show installation instructions.
### Use Cases
| Scenario | How Worktrees Help |
|----------|-------------------|
| **Background Auto Run** | Run Auto Run in a worktree while working interactively in the main repo |
| **Feature Branches** | Spin up a sub-agent for each feature branch |
| **Code Review** | Create a worktree to review and iterate on a PR without switching branches |
| **Parallel Experiments** | Try different approaches simultaneously without git stash/pop |
### Tips
- **Name branches descriptively** — The branch name becomes the worktree directory name
- **Use a dedicated worktree folder** — Keep all worktrees in one place (e.g., `~/worktrees/`)
- **Clean up when done** — Delete worktree sessions after merging PRs to avoid clutter
## Auto Run
Auto Run is a file-system-based document runner that lets you batch-process tasks using AI agents. Select a folder containing markdown documents with task checkboxes, and Maestro will work through them one by one, spawning a fresh AI session for each task.
@@ -404,25 +457,6 @@ Save your batch configurations for reuse:
3. Load saved playbooks from the **Load Playbook** dropdown
4. Update or discard changes to loaded playbooks
### Git Worktree Support
Git worktrees allow Auto Run to operate in an isolated directory while you continue working interactively in the main repository—no read-only restrictions, no waiting.
**Setup:**
1. Enable **Worktree** in the Batch Runner Modal
2. Select a **Worktree Directory** (base folder for all worktrees)
3. Enter a **Branch Name** (becomes the worktree subdirectory)
4. The computed path shows where the worktree will be created
5. Optionally enable **Create PR on completion** to auto-open a pull request
**Benefits:**
- **Continue working** in the main repo while Auto Run processes tasks in the background
- **No read-only mode** — the yellow border and input lock only appear when running without a worktree
- **Visual indicator** — a git branch icon appears in the AUTO badge, right panel, and status pill when using a worktree
- **Automatic PR creation** — when enabled, opens a PR from the worktree branch to your target branch
**Without a worktree:** Auto Run queues with other write operations to prevent conflicts, and the editor enters read-only mode until completion.
### Progress Tracking
The runner will:
@@ -490,9 +524,11 @@ Click the **Stop** button at any time. The runner will:
- Preserve all completed work
- Allow you to resume later by clicking Run again
### Parallel Batches
### Parallel Auto Runs
You can run separate batch processes in different Maestro sessions simultaneously. Each session maintains its own independent batch state. With Git worktrees enabled, you can work on the main branch while Auto Run operates in an isolated worktree.
Auto Run can execute in parallel across different agents without conflicts—each agent works in its own project directory, so there's no risk of clobbering each other's work.
**Same project, parallel work:** To run multiple Auto Runs in the same repository simultaneously, create worktree sub-agents from the git branch menu (see [Git Worktrees](#git-worktrees)). Each worktree operates in an isolated directory with its own branch, enabling true parallel task execution on the same codebase.
## Group Chat

View File

@@ -37,6 +37,8 @@ import { TourOverlay } from './components/Wizard/tour';
import { CONDUCTOR_BADGES, getBadgeForTime } from './constants/conductorBadges';
import { EmptyStateView } from './components/EmptyStateView';
import { AgentErrorModal } from './components/AgentErrorModal';
import { WorktreeConfigModal } from './components/WorktreeConfigModal';
import { CreatePRModal } from './components/CreatePRModal';
// Group Chat Components
import { GroupChatPanel } from './components/GroupChatPanel';
@@ -390,6 +392,11 @@ export default function MaestroConsole() {
// Agent Error Modal State - tracks which session has an active error being shown
const [agentErrorModalSessionId, setAgentErrorModalSessionId] = useState<string | null>(null);
// Worktree Modal State
const [worktreeConfigModalOpen, setWorktreeConfigModalOpen] = useState(false);
const [createPRModalOpen, setCreatePRModalOpen] = useState(false);
const [createPRSession, setCreatePRSession] = useState<Session | null>(null);
// Tab Switcher Modal State
const [tabSwitcherOpen, setTabSwitcherOpen] = useState(false);
@@ -3868,7 +3875,7 @@ export default function MaestroConsole() {
// Create a group using the user-provided name
const groupId = generateId();
const worktreeGroup: SessionGroup = {
const worktreeGroup: Group = {
id: groupId,
name: name,
collapsed: false,
@@ -6208,6 +6215,10 @@ export default function MaestroConsole() {
onDeleteGroupChat={deleteGroupChatWithConfirmation}
activeGroupChatId={activeGroupChatId}
hasActiveSessionCapability={hasActiveSessionCapability}
onOpenCreatePR={(session) => {
setCreatePRSession(session);
setCreatePRModalOpen(true);
}}
onToggleRemoteControl={async () => {
await toggleGlobalLive();
// Show flash notification based on the NEW state (opposite of current)
@@ -6351,6 +6362,58 @@ export default function MaestroConsole() {
/>
)}
{/* --- WORKTREE CONFIG MODAL --- */}
{worktreeConfigModalOpen && activeSession && (
<WorktreeConfigModal
isOpen={worktreeConfigModalOpen}
onClose={() => setWorktreeConfigModalOpen(false)}
theme={theme}
session={activeSession}
worktreeChildren={sessions.filter(s => s.parentSessionId === activeSession.id)}
onSaveConfig={(config) => {
setSessions(prev => prev.map(s =>
s.id === activeSession.id
? { ...s, worktreeConfig: config }
: s
));
}}
onCreateWorktree={async (branchName) => {
// TODO: Implement worktree creation via git worktree add
console.log('[WorktreeConfig] Create worktree:', branchName);
}}
onRemoveWorktree={(sessionId) => {
// Remove the worktree session
setSessions(prev => prev.filter(s => s.id !== sessionId));
}}
onSelectWorktree={(sessionId) => {
setActiveSessionId(sessionId);
}}
/>
)}
{/* --- CREATE PR MODAL --- */}
{createPRModalOpen && (createPRSession || activeSession) && (
<CreatePRModal
isOpen={createPRModalOpen}
onClose={() => {
setCreatePRModalOpen(false);
setCreatePRSession(null);
}}
theme={theme}
worktreePath={(createPRSession || activeSession)!.cwd}
worktreeBranch={(createPRSession || activeSession)!.worktreeBranch || (createPRSession || activeSession)!.gitBranches?.[0] || 'main'}
availableBranches={(createPRSession || activeSession)!.gitBranches || ['main', 'master']}
onPRCreated={(prUrl) => {
addToast({
type: 'success',
title: 'Pull Request Created',
message: prUrl,
});
setCreatePRSession(null);
}}
/>
)}
{/* --- FIRST RUN CELEBRATION OVERLAY --- */}
{firstRunCelebrationData && (
<FirstRunCelebration
@@ -7085,6 +7148,10 @@ export default function MaestroConsole() {
}}
onTabStar={(tabId: string, starred: boolean) => {
if (!activeSession) return;
// Find the tab first to check if it has a session ID
const tabToStar = activeSession.aiTabs.find(t => t.id === tabId);
// Don't allow starring tabs without a session ID (new/empty tabs)
if (!tabToStar?.agentSessionId) return;
setSessions(prev => prev.map(s => {
if (s.id !== activeSession.id) return s;
// Find the tab to get its agentSessionId for persistence
@@ -7292,6 +7359,9 @@ export default function MaestroConsole() {
setTimeout(() => setSuccessFlashNotification(null), 2000);
}}
onOpenFuzzySearch={() => setFuzzyFileSearchOpen(true)}
onOpenWorktreeConfig={() => setWorktreeConfigModalOpen(true)}
onOpenCreatePR={() => setCreatePRModalOpen(true)}
isWorktreeChild={!!activeSession?.parentSessionId}
/>
)}
@@ -7393,8 +7463,6 @@ export default function MaestroConsole() {
getDocumentTaskCount={getDocumentTaskCount}
onRefreshDocuments={handleAutoRunRefresh}
sessionId={activeSession.id}
sessionCwd={activeSession.cwd}
ghPath={ghPath}
/>
)}

View File

@@ -1209,7 +1209,8 @@ const AutoRunInner = forwardRef<AutoRunHandle, AutoRunProps>(function AutoRunInn
<Maximize2 className="w-3.5 h-3.5" />
</button>
)}
{/* Image upload button - always visible, ghosted in preview mode */}
{/* Image upload button - hidden for now, can be re-enabled by removing false && */}
{false && (
<button
onClick={() => mode === 'edit' && !isLocked && fileInputRef.current?.click()}
disabled={mode !== 'edit' || isLocked}
@@ -1225,6 +1226,7 @@ const AutoRunInner = forwardRef<AutoRunHandle, AutoRunProps>(function AutoRunInn
>
<Image className="w-3.5 h-3.5" />
</button>
)}
<button
onClick={() => !isLocked && switchMode('edit')}
disabled={isLocked}

View File

@@ -239,7 +239,8 @@ export function AutoRunExpandedModal({
<Eye className="w-3.5 h-3.5" />
Preview
</button>
{/* Image upload button - always visible, ghosted in preview mode */}
{/* Image upload button - hidden for now, can be re-enabled by removing false && */}
{false && (
<button
onClick={() => localMode === 'edit' && !isLocked && fileInputRef.current?.click()}
disabled={localMode !== 'edit' || isLocked}
@@ -255,6 +256,7 @@ export function AutoRunExpandedModal({
>
<Image className="w-3.5 h-3.5" />
</button>
)}
<input
ref={fileInputRef}
type="file"

View File

@@ -1,4 +1,4 @@
import { FolderOpen, FileText, CheckSquare, Play, Settings, History, Eye, Square, Keyboard, Repeat, RotateCcw, BookMarked, GitBranch, Image, Variable } from 'lucide-react';
import { FolderOpen, FileText, CheckSquare, Play, Settings, History, Eye, Square, Keyboard, Repeat, RotateCcw, BookMarked, Image, Variable } from 'lucide-react';
import type { Theme } from '../types';
import { MODAL_PRIORITIES } from '../constants/modalPriorities';
import { Modal } from './ui/Modal';
@@ -379,39 +379,12 @@ export function AutoRunnerHelpModal({ theme, onClose }: AutoRunnerHelpModalProps
</p>
<p>
The input shows a <span style={{ color: theme.colors.warning }}>READ-ONLY</span> indicator
as a reminder. This prevents conflicts between manual and automated work...
as a reminder. This prevents conflicts between manual and automated work.
</p>
<p>
<em style={{ color: theme.colors.textMain }}>Unless</em> you enable Git Worktree:
</p>
</div>
</section>
{/* Git Worktree */}
<section>
<div className="flex items-center gap-2 mb-3">
<GitBranch className="w-5 h-5" style={{ color: theme.colors.accent }} />
<h3 className="font-bold">Git Worktree (Parallel Work)</h3>
</div>
<div
className="text-sm space-y-2 pl-7"
style={{ color: theme.colors.textDim }}
>
<p>
For Git repositories, enable <strong style={{ color: theme.colors.textMain }}>Git Worktree</strong> to
run Auto Run in an isolated working directory. This allows you to{' '}
<strong style={{ color: theme.colors.textMain }}>continue making changes interactively</strong>{' '}
in the main repository while Auto Run processes tasks in the background—no read-only restrictions,
no yellow border, no waiting.
</p>
<p>
Select a worktree directory and branch name. The worktree will be created as a subdirectory
using the branch name. Optionally enable <strong style={{ color: theme.colors.textMain }}>"Create PR on completion"</strong> to
automatically open a pull request when all tasks finish.
</p>
<p>
When running in a worktree, a <GitBranch className="w-3 h-3 inline mx-1" style={{ color: theme.colors.accent }} />
icon appears in the AUTO badge, right panel, and status pill to indicate parallel operation.
<strong style={{ color: theme.colors.textMain }}>Tip:</strong> For parallel work without read-only restrictions,
create a worktree session from the git branch menu in the session list. Worktree sessions operate
in isolated directories, allowing Auto Run and manual work to happen simultaneously.
</p>
</div>
</section>

View File

@@ -8,8 +8,7 @@ import { PlaybookDeleteConfirmModal } from './PlaybookDeleteConfirmModal';
import { PlaybookNameModal } from './PlaybookNameModal';
import { AgentPromptComposerModal } from './AgentPromptComposerModal';
import { DocumentsPanel } from './DocumentsPanel';
import { GitWorktreeSection, GhCliStatus } from './GitWorktreeSection';
import { usePlaybookManagement, useWorktreeValidation } from '../hooks';
import { usePlaybookManagement } from '../hooks';
import { autorunDefaultPrompt } from '../../prompts';
import { generateId } from '../utils/ids';
@@ -33,10 +32,6 @@ interface BatchRunnerModalProps {
onRefreshDocuments: () => Promise<void>; // Refresh document list from folder
// Session ID for playbook storage
sessionId: string;
// Session cwd for git worktree support
sessionCwd: string;
// Custom path to gh CLI binary (optional, for worktree features)
ghPath?: string;
}
// Helper function to count unchecked tasks in scratchpad content
@@ -80,8 +75,6 @@ export function BatchRunnerModal(props: BatchRunnerModalProps) {
getDocumentTaskCount,
onRefreshDocuments,
sessionId,
sessionCwd,
ghPath
} = props;
// Document list state
@@ -113,45 +106,17 @@ export function BatchRunnerModal(props: BatchRunnerModalProps) {
const [promptComposerOpen, setPromptComposerOpen] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Git worktree state - only show worktree section for git repos
const [isGitRepo, setIsGitRepo] = useState(false);
const [checkingGitRepo, setCheckingGitRepo] = useState(true);
// Worktree configuration state
const [worktreeEnabled, setWorktreeEnabled] = useState(false);
const [worktreeBaseDir, setWorktreeBaseDir] = useState(''); // User-selected base directory for all worktrees
const [branchName, setBranchName] = useState('');
const [createPROnCompletion, setCreatePROnCompletion] = useState(false);
const [prTargetBranch, setPrTargetBranch] = useState('main');
const [availableBranches, setAvailableBranches] = useState<string[]>([]);
const [ghCliStatus, setGhCliStatus] = useState<GhCliStatus | null>(null);
// Compute the display path for the UI (baseDir + branchName)
const computedWorktreePath = worktreeBaseDir && branchName
? `${worktreeBaseDir.replace(/\/+$/, '')}/${branchName.replace(/[^a-zA-Z0-9-_]/g, '-')}`
: '';
// Playbook management callback to apply loaded playbook configuration
const handleApplyPlaybook = useCallback((data: {
documents: BatchDocumentEntry[];
loopEnabled: boolean;
maxLoops: number | null;
prompt: string;
worktreeEnabled: boolean;
branchName: string;
createPROnCompletion: boolean;
prTargetBranch: string;
}) => {
setDocuments(data.documents);
setLoopEnabled(data.loopEnabled);
setMaxLoops(data.maxLoops);
setPrompt(data.prompt);
setWorktreeEnabled(data.worktreeEnabled);
setBranchName(data.branchName);
setCreatePROnCompletion(data.createPROnCompletion);
if (data.prTargetBranch) {
setPrTargetBranch(data.prTargetBranch);
}
}, []);
// Playbook management hook
@@ -186,22 +151,10 @@ export function BatchRunnerModal(props: BatchRunnerModalProps) {
loopEnabled,
maxLoops,
prompt,
worktreeEnabled,
branchName,
createPROnCompletion,
prTargetBranch,
},
onApplyPlaybook: handleApplyPlaybook,
});
// Worktree validation hook (debounced validation of worktree path)
const { validation: worktreeValidation } = useWorktreeValidation({
worktreePath: computedWorktreePath,
branchName,
worktreeEnabled,
sessionCwd,
});
const { registerLayer, unregisterLayer, updateLayerHandler } = useLayerStack();
const layerIdRef = useRef<string>();
@@ -230,50 +183,6 @@ export function BatchRunnerModal(props: BatchRunnerModalProps) {
loadTaskCounts();
}, [allDocuments]);
// Check if session cwd is a git repo on mount (for worktree support)
useEffect(() => {
const checkGitRepo = async () => {
setCheckingGitRepo(true);
console.log(`[BatchRunnerModal] Checking git repo and gh CLI. ghPath prop: "${ghPath}"`);
try {
const result = await window.maestro.git.isRepo(sessionCwd);
const isRepo = result === true;
setIsGitRepo(isRepo);
// If it's a git repo, fetch available branches and check gh CLI
if (isRepo) {
const ghPathToUse = ghPath || undefined;
console.log(`[BatchRunnerModal] Checking gh CLI with path: ${ghPathToUse}`);
const [branchResult, ghResult] = await Promise.all([
window.maestro.git.branches(sessionCwd),
window.maestro.git.checkGhCli(ghPathToUse)
]);
console.log(`[BatchRunnerModal] gh CLI check result:`, ghResult);
if (branchResult.branches && branchResult.branches.length > 0) {
setAvailableBranches(branchResult.branches);
// Set default target branch to 'main' or 'master' if available
if (branchResult.branches.includes('main')) {
setPrTargetBranch('main');
} else if (branchResult.branches.includes('master')) {
setPrTargetBranch('master');
} else {
setPrTargetBranch(branchResult.branches[0]);
}
}
setGhCliStatus(ghResult);
}
} catch (error) {
console.error('Failed to check if git repo:', error);
setIsGitRepo(false);
}
setCheckingGitRepo(false);
};
checkGitRepo();
}, [sessionCwd, ghPath]);
// Calculate total tasks across selected documents (excluding missing documents)
const totalTaskCount = documents.reduce((sum, doc) => {
// Don't count tasks from missing documents
@@ -354,7 +263,7 @@ export function BatchRunnerModal(props: BatchRunnerModalProps) {
// Filter out missing documents before starting batch run
const validDocuments = documents.filter(doc => !doc.isMissing);
// Build config with optional worktree settings
// Build config (worktree configuration is now managed separately via WorktreeConfigModal)
const config: BatchRunConfig = {
documents: validDocuments,
prompt,
@@ -362,29 +271,9 @@ export function BatchRunnerModal(props: BatchRunnerModalProps) {
maxLoops: loopEnabled ? maxLoops : null
};
// Add worktree config if enabled and valid
if (worktreeEnabled && isGitRepo && worktreeBaseDir && branchName) {
// Compute the worktree path: baseDir + sanitized branch name
// e.g., /Users/pedram/worktrees + branch "feature-x" -> /Users/pedram/worktrees/feature-x
const sanitizedBranch = branchName.replace(/[^a-zA-Z0-9-_]/g, '-');
const computedWorktreePath = `${worktreeBaseDir.replace(/\/+$/, '')}/${sanitizedBranch}`;
config.worktree = {
enabled: true,
path: computedWorktreePath,
branchName,
createPROnCompletion,
prTargetBranch,
ghPath: ghPath || undefined
};
}
console.log('[BatchRunnerModal] handleGo - calling onGo with config:', config);
window.maestro.logger.log('info', 'Go button clicked', 'BatchRunnerModal', {
documentsCount: validDocuments.length,
worktreeEnabled: config.worktree?.enabled,
worktreePath: config.worktree?.path,
branchName: config.worktree?.branchName
});
onGo(config);
@@ -599,27 +488,6 @@ export function BatchRunnerModal(props: BatchRunnerModalProps) {
onRefreshDocuments={onRefreshDocuments}
/>
{/* Git Worktree Section - only visible for git repos */}
{isGitRepo && !checkingGitRepo && (
<GitWorktreeSection
theme={theme}
worktreeEnabled={worktreeEnabled}
setWorktreeEnabled={setWorktreeEnabled}
worktreeBaseDir={worktreeBaseDir}
setWorktreeBaseDir={setWorktreeBaseDir}
computedWorktreePath={computedWorktreePath}
branchName={branchName}
setBranchName={setBranchName}
createPROnCompletion={createPROnCompletion}
setCreatePROnCompletion={setCreatePROnCompletion}
prTargetBranch={prTargetBranch}
setPrTargetBranch={setPrTargetBranch}
worktreeValidation={worktreeValidation}
availableBranches={availableBranches}
ghCliStatus={ghCliStatus}
/>
)}
{/* Divider */}
<div className="border-t mb-6" style={{ borderColor: theme.colors.border }} />

View File

@@ -1,5 +1,5 @@
import React, { useState, useEffect, useRef, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react';
import { Wand2, ExternalLink, Columns, Copy, Loader2, GitBranch, ArrowUp, ArrowDown, FileEdit, List, AlertCircle, X } from 'lucide-react';
import { Wand2, ExternalLink, Columns, Copy, Loader2, GitBranch, ArrowUp, ArrowDown, FileEdit, List, AlertCircle, X, GitPullRequest, Settings2 } from 'lucide-react';
import { LogViewer } from './LogViewer';
import { TerminalOutput } from './TerminalOutput';
import { InputArea } from './InputArea';
@@ -182,6 +182,12 @@ interface MainPanelProps {
showFlashNotification?: (message: string) => void;
// Fuzzy file search callback (for FilePreview in preview mode)
onOpenFuzzySearch?: () => void;
// Worktree configuration
onOpenWorktreeConfig?: () => void;
onOpenCreatePR?: () => void;
/** True if this session is a worktree child (has parentSessionId) */
isWorktreeChild?: boolean;
}
export const MainPanel = forwardRef<MainPanelHandle, MainPanelProps>(function MainPanel(props, ref) {
@@ -204,7 +210,10 @@ export const MainPanel = forwardRef<MainPanelHandle, MainPanelProps>(function Ma
handleInputKeyDown, handlePaste, handleDrop, getContextColor, setActiveSessionId,
batchRunState, currentSessionBatchState, onStopBatchRun, showConfirmation, onRemoveQueuedItem, onOpenQueueBrowser,
isMobileLandscape = false,
showFlashNotification
showFlashNotification,
onOpenWorktreeConfig,
onOpenCreatePR,
isWorktreeChild,
} = props;
// isCurrentSessionAutoMode: THIS session has active batch run (for all UI indicators)
@@ -546,7 +555,7 @@ export const MainPanel = forwardRef<MainPanelHandle, MainPanelProps>(function Ma
)}
{/* Status Summary */}
<div className="p-3">
<div className="p-3 border-b" style={{ borderColor: theme.colors.border }}>
<div className="text-[10px] uppercase font-bold mb-2" style={{ color: theme.colors.textDim }}>Status</div>
<div className="flex items-center gap-4 text-xs">
{gitInfo.uncommittedChanges > 0 ? (
@@ -560,7 +569,41 @@ export const MainPanel = forwardRef<MainPanelHandle, MainPanelProps>(function Ma
</span>
)}
</div>
</div>
</div>
{/* Worktree Actions */}
<div className="p-2 space-y-1">
{/* Configure Worktrees - only for parent sessions (not worktree children) */}
{!isWorktreeChild && onOpenWorktreeConfig && (
<button
onClick={(e) => {
e.stopPropagation();
onOpenWorktreeConfig();
gitTooltip.close();
}}
className="w-full flex items-center gap-2 px-3 py-2 rounded text-xs hover:bg-white/10 transition-colors"
style={{ color: theme.colors.textMain }}
>
<Settings2 className="w-4 h-4" style={{ color: theme.colors.accent }} />
Configure Worktrees
</button>
)}
{/* Create PR - only for worktree children */}
{isWorktreeChild && onOpenCreatePR && (
<button
onClick={(e) => {
e.stopPropagation();
onOpenCreatePR();
gitTooltip.close();
}}
className="w-full flex items-center gap-2 px-3 py-2 rounded text-xs hover:bg-white/10 transition-colors"
style={{ color: theme.colors.textMain }}
>
<GitPullRequest className="w-4 h-4" style={{ color: theme.colors.accent }} />
Create Pull Request
</button>
)}
</div>
</div>
</div>
</>

View File

@@ -80,6 +80,8 @@ interface QuickActionsModalProps {
hasActiveSessionCapability?: (capability: 'supportsSessionStorage' | 'supportsSlashCommands') => boolean;
// Remote control
onToggleRemoteControl?: () => void;
// Worktree PR creation
onOpenCreatePR?: (session: Session) => void;
}
export function QuickActionsModal(props: QuickActionsModalProps) {
@@ -96,7 +98,7 @@ export function QuickActionsModal(props: QuickActionsModalProps) {
onRenameTab, onToggleReadOnlyMode, onOpenTabSwitcher, tabShortcuts, isAiMode, setPlaygroundOpen, onRefreshGitFileState,
onDebugReleaseQueuedItem, markdownEditMode, onToggleMarkdownEditMode, setUpdateCheckModalOpen, openWizard, wizardGoToStep, setDebugWizardModalOpen, setDebugPackageModalOpen, startTour, setFuzzyFileSearchOpen, onEditAgent,
groupChats, onNewGroupChat, onOpenGroupChat, onCloseGroupChat, onDeleteGroupChat, activeGroupChatId,
hasActiveSessionCapability
hasActiveSessionCapability, onOpenCreatePR
} = props;
const [search, setSearch] = useState('');
@@ -295,6 +297,16 @@ export function QuickActionsModal(props: QuickActionsModalProps) {
}
setQuickActionOpen(false);
} }] : []),
// Create PR - only for worktree child sessions
...(activeSession && activeSession.parentSessionId && activeSession.worktreeBranch && onOpenCreatePR ? [{
id: 'createPR',
label: `Create Pull Request: ${activeSession.worktreeBranch}`,
subtext: 'Open PR from this worktree branch',
action: () => {
onOpenCreatePR(activeSession);
setQuickActionOpen(false);
}
}] : []),
...(activeSession && onRefreshGitFileState ? [{ id: 'refreshGitFileState', label: 'Refresh Files, Git, History', subtext: 'Reload file tree, git status, and history', action: async () => {
await onRefreshGitFileState();
setQuickActionOpen(false);

View File

@@ -13,8 +13,9 @@ import { getStatusColor } from '../utils/theme';
* - 'group': Session inside a group folder
* - 'flat': Session in flat list (when no groups exist)
* - 'ungrouped': Session in the Ungrouped folder (when groups exist)
* - 'worktree': Worktree child session nested under parent (shows branch name)
*/
export type SessionItemVariant = 'bookmark' | 'group' | 'flat' | 'ungrouped';
export type SessionItemVariant = 'bookmark' | 'group' | 'flat' | 'ungrouped' | 'worktree';
export interface SessionItemProps {
session: Session;
@@ -83,8 +84,8 @@ export function SessionItem({
onStartRename,
onToggleBookmark,
}: SessionItemProps) {
// Determine if we show the GIT/LOCAL badge (not shown in bookmark variant or terminal sessions)
const showGitLocalBadge = variant !== 'bookmark' && session.toolType !== 'terminal';
// Determine if we show the GIT/LOCAL badge (not shown in bookmark variant, terminal sessions, or worktree variant)
const showGitLocalBadge = variant !== 'bookmark' && variant !== 'worktree' && session.toolType !== 'terminal';
// Determine container styling based on variant
const getContainerClassName = () => {
@@ -93,6 +94,10 @@ export function SessionItem({
if (variant === 'flat') {
return `mx-3 px-3 py-2 rounded mb-1 ${base}`;
}
if (variant === 'worktree') {
// Worktree children have extra left padding and smaller text
return `pl-8 pr-4 py-1.5 ${base}`;
}
return `px-4 py-2 ${base}`;
};
@@ -135,41 +140,47 @@ export function SessionItem({
{variant === 'bookmark' && session.bookmarked && (
<Bookmark className="w-3 h-3 shrink-0" style={{ color: theme.colors.accent }} fill={theme.colors.accent} />
)}
{/* Branch icon for worktree children */}
{variant === 'worktree' && (
<GitBranch className="w-3 h-3 shrink-0" style={{ color: theme.colors.accent }} />
)}
<span
className="text-sm font-medium truncate"
className={`font-medium truncate ${variant === 'worktree' ? 'text-xs' : 'text-sm'}`}
style={{ color: isActive ? theme.colors.textMain : theme.colors.textDim }}
>
{session.name}
{variant === 'worktree' ? (session.worktreeBranch || session.name) : session.name}
</span>
</div>
)}
{/* Session metadata row */}
<div className="flex items-center gap-2 text-[10px] mt-0.5 opacity-70">
{/* Session Jump Number Badge (Opt+Cmd+NUMBER) */}
{jumpNumber && (
<div
className="w-4 h-4 rounded flex items-center justify-center text-[10px] font-bold shrink-0"
style={{
backgroundColor: theme.colors.accent,
color: theme.colors.bgMain
}}
>
{jumpNumber}
</div>
)}
<Activity className="w-3 h-3" /> {session.toolType}
{/* Session metadata row (hidden for compact worktree variant) */}
{variant !== 'worktree' && (
<div className="flex items-center gap-2 text-[10px] mt-0.5 opacity-70">
{/* Session Jump Number Badge (Opt+Cmd+NUMBER) */}
{jumpNumber && (
<div
className="w-4 h-4 rounded flex items-center justify-center text-[10px] font-bold shrink-0"
style={{
backgroundColor: theme.colors.accent,
color: theme.colors.bgMain
}}
>
{jumpNumber}
</div>
)}
<Activity className="w-3 h-3" /> {session.toolType}
{/* Group badge (only in bookmark variant when session belongs to a group) */}
{variant === 'bookmark' && group && (
<span
className="text-[9px] px-1 py-0.5 rounded"
style={{ backgroundColor: theme.colors.bgActivity, color: theme.colors.textDim }}
>
{group.name}
</span>
)}
</div>
{/* Group badge (only in bookmark variant when session belongs to a group) */}
{variant === 'bookmark' && group && (
<span
className="text-[9px] px-1 py-0.5 rounded"
style={{ backgroundColor: theme.colors.bgActivity, color: theme.colors.textDim }}
>
{group.name}
</span>
)}
</div>
)}
</div>
{/* Right side: Indicators and actions */}

View File

@@ -2,7 +2,8 @@ import React, { useState, useEffect, useRef } from 'react';
import {
Wand2, Plus, Settings, ChevronRight, ChevronDown, X, Keyboard,
Radio, Copy, ExternalLink, PanelLeftClose, PanelLeftOpen, Folder, Info, GitBranch, Bot, Clock,
ScrollText, Cpu, Menu, Bookmark, Trophy, Trash2, Edit3, FolderInput, Download, Compass, Globe
ScrollText, Cpu, Menu, Bookmark, Trophy, Trash2, Edit3, FolderInput, Download, Compass, Globe,
GitPullRequest
} from 'lucide-react';
import { QRCodeSVG } from 'qrcode.react';
import type { Session, Group, Theme, Shortcut, AutoRunStats, GroupChat, GroupChatState, SettingsTab, FocusArea } from '../types';
@@ -30,6 +31,7 @@ interface SessionContextMenuProps {
onMoveToGroup: (groupId: string) => void;
onDelete: () => void;
onDismiss: () => void;
onCreatePR?: () => void; // For worktree child sessions
}
function SessionContextMenu({
@@ -43,7 +45,8 @@ function SessionContextMenu({
onToggleBookmark,
onMoveToGroup,
onDelete,
onDismiss
onDismiss,
onCreatePR,
}: SessionContextMenuProps) {
const menuRef = useRef<HTMLDivElement>(null);
const moveToGroupRef = useRef<HTMLDivElement>(null);
@@ -144,6 +147,24 @@ function SessionContextMenu({
{session.bookmarked ? 'Remove Bookmark' : 'Add Bookmark'}
</button>
{/* Create PR - only for worktree child sessions */}
{session.parentSessionId && session.worktreeBranch && onCreatePR && (
<>
<div className="my-1 border-t" style={{ borderColor: theme.colors.border }} />
<button
onClick={() => {
onCreatePR();
onDismiss();
}}
className="w-full text-left px-3 py-1.5 text-xs hover:bg-white/5 transition-colors flex items-center gap-2"
style={{ color: theme.colors.accent }}
>
<GitPullRequest className="w-3.5 h-3.5" />
Create Pull Request
</button>
</>
)}
{/* Divider */}
<div className="my-1 border-t" style={{ borderColor: theme.colors.border }} />
@@ -579,6 +600,10 @@ interface SessionListProps {
// Edit agent modal handler (for context menu edit)
onEditAgent: (session: Session) => void;
// Worktree handlers
onToggleWorktreeExpanded?: (sessionId: string) => void;
onOpenCreatePR?: (session: Session) => void;
// Auto mode props
activeBatchSessionIds?: string[]; // Session IDs that are running in auto mode
@@ -639,6 +664,8 @@ export function SessionList(props: SessionListProps) {
onDeleteSession, onDeleteWorktreeGroup,
setRenameInstanceModalOpen, setRenameInstanceValue, setRenameInstanceSessionId,
onEditAgent,
onToggleWorktreeExpanded,
onOpenCreatePR,
activeBatchSessionIds = [],
showSessionJumpNumbers = false,
visibleSessions = [],
@@ -800,17 +827,149 @@ export function SessionList(props: SessionListProps) {
gitFileCounts.set(sessionId, status.fileCount);
});
// Helper: Get worktree children for a parent session
const getWorktreeChildren = (parentId: string): Session[] => {
return sessions.filter(s => s.parentSessionId === parentId);
};
// Helper: Check if a session has worktree children
const hasWorktreeChildren = (sessionId: string): boolean => {
return sessions.some(s => s.parentSessionId === sessionId);
};
// Helper component: Renders a session item with its worktree children (if any)
const renderSessionWithWorktrees = (
session: Session,
variant: 'bookmark' | 'group' | 'flat' | 'ungrouped',
options: {
keyPrefix: string;
groupId?: string;
group?: Group;
onDrop?: () => void;
}
) => {
const worktreeChildren = getWorktreeChildren(session.id);
const hasWorktrees = worktreeChildren.length > 0;
const worktreesExpanded = session.worktreesExpanded ?? true;
const globalIdx = sortedSessions.findIndex(s => s.id === session.id);
const isKeyboardSelected = activeFocus === 'sidebar' && globalIdx === selectedSidebarIndex;
return (
<div key={`${options.keyPrefix}-${session.id}`}>
{/* Parent session with expand/collapse toggle for worktrees */}
<div className="flex items-center">
{/* Expand/collapse button for sessions with worktrees */}
{hasWorktrees && onToggleWorktreeExpanded && (
<button
onClick={(e) => {
e.stopPropagation();
onToggleWorktreeExpanded(session.id);
}}
className="p-1 hover:bg-white/10 rounded transition-colors ml-1 shrink-0"
title={worktreesExpanded ? 'Collapse worktrees' : 'Expand worktrees'}
>
{worktreesExpanded ? (
<ChevronDown className="w-3 h-3" style={{ color: theme.colors.textDim }} />
) : (
<ChevronRight className="w-3 h-3" style={{ color: theme.colors.textDim }} />
)}
</button>
)}
<div className="flex-1">
<SessionItem
session={session}
variant={variant}
theme={theme}
isActive={activeSessionId === session.id && !activeGroupChatId}
isKeyboardSelected={isKeyboardSelected}
isDragging={draggingSessionId === session.id}
isEditing={editingSessionId === `${options.keyPrefix}-${session.id}`}
leftSidebarOpen={leftSidebarOpen}
group={options.group}
groupId={options.groupId}
gitFileCount={gitFileCounts.get(session.id)}
isInBatch={activeBatchSessionIds.includes(session.id)}
jumpNumber={getSessionJumpNumber(session.id)}
onSelect={() => setActiveSessionId(session.id)}
onDragStart={() => handleDragStart(session.id)}
onDragOver={handleDragOver}
onDrop={options.onDrop || handleDropOnUngrouped}
onContextMenu={(e) => handleContextMenu(e, session.id)}
onFinishRename={(newName) => finishRenamingSession(session.id, newName)}
onStartRename={() => startRenamingSession(`${options.keyPrefix}-${session.id}`)}
onToggleBookmark={() => toggleBookmark(session.id)}
/>
</div>
{/* Worktree count badge when collapsed */}
{hasWorktrees && !worktreesExpanded && (
<div
className="flex items-center gap-1 px-1.5 py-0.5 rounded text-[9px] font-bold mr-2 shrink-0"
style={{
backgroundColor: theme.colors.accent + '20',
color: theme.colors.accent,
}}
title={`${worktreeChildren.length} worktree${worktreeChildren.length > 1 ? 's' : ''}`}
>
<GitBranch className="w-2.5 h-2.5" />
{worktreeChildren.length}
</div>
)}
</div>
{/* Worktree children (when expanded) */}
{hasWorktrees && worktreesExpanded && (
<div className="border-l ml-6 pl-2" style={{ borderColor: theme.colors.accent + '40' }}>
{worktreeChildren.sort((a, b) => compareSessionNames(a.worktreeBranch || a.name, b.worktreeBranch || b.name)).map(child => {
const childGlobalIdx = sortedSessions.findIndex(s => s.id === child.id);
const isChildKeyboardSelected = activeFocus === 'sidebar' && childGlobalIdx === selectedSidebarIndex;
return (
<SessionItem
key={`worktree-${session.id}-${child.id}`}
session={child}
variant="worktree"
theme={theme}
isActive={activeSessionId === child.id && !activeGroupChatId}
isKeyboardSelected={isChildKeyboardSelected}
isDragging={draggingSessionId === child.id}
isEditing={editingSessionId === `worktree-${session.id}-${child.id}`}
leftSidebarOpen={leftSidebarOpen}
gitFileCount={gitFileCounts.get(child.id)}
isInBatch={activeBatchSessionIds.includes(child.id)}
jumpNumber={getSessionJumpNumber(child.id)}
onSelect={() => setActiveSessionId(child.id)}
onDragStart={() => handleDragStart(child.id)}
onContextMenu={(e) => handleContextMenu(e, child.id)}
onFinishRename={(newName) => finishRenamingSession(child.id, newName)}
onStartRename={() => startRenamingSession(`worktree-${session.id}-${child.id}`)}
onToggleBookmark={() => toggleBookmark(child.id)}
/>
);
})}
</div>
)}
</div>
);
};
// Filter sessions based on search query (searches session name AND AI tab names)
// Also filters out worktree children (they're rendered under their parents)
const filteredSessions = sessionFilter
? sessions.filter(s => {
// Exclude worktree children from main list (they appear under parent)
if (s.parentSessionId) return false;
const query = sessionFilter.toLowerCase();
// Match session name
if (s.name.toLowerCase().includes(query)) return true;
// Match any AI tab name
if (s.aiTabs?.some(tab => tab.name?.toLowerCase().includes(query))) return true;
// Match worktree children branch names
if (getWorktreeChildren(s.id).some(child =>
child.worktreeBranch?.toLowerCase().includes(query) ||
child.name.toLowerCase().includes(query)
)) return true;
return false;
})
: sessions;
: sessions.filter(s => !s.parentSessionId); // Exclude worktree children from main list
// When filter opens, apply filter mode preferences (or defaults on first open)
// When filter closes, save current states as filter mode preferences and restore original states
@@ -1490,32 +1649,11 @@ export function SessionList(props: SessionListProps) {
{!bookmarksCollapsed ? (
<div className="flex flex-col border-l ml-4" style={{ borderColor: theme.colors.accent }}>
{[...filteredSessions.filter(s => s.bookmarked)].sort((a, b) => compareSessionNames(a.name, b.name)).map(session => {
const globalIdx = sortedSessions.findIndex(s => s.id === session.id);
const isKeyboardSelected = activeFocus === 'sidebar' && globalIdx === selectedSidebarIndex;
const group = groups.find(g => g.id === session.groupId);
return (
<SessionItem
key={`bookmark-${session.id}`}
session={session}
variant="bookmark"
theme={theme}
isActive={activeSessionId === session.id && !activeGroupChatId}
isKeyboardSelected={isKeyboardSelected}
isDragging={draggingSessionId === session.id}
isEditing={editingSessionId === `bookmark-${session.id}`}
leftSidebarOpen={leftSidebarOpen}
group={group}
gitFileCount={gitFileCounts.get(session.id)}
isInBatch={activeBatchSessionIds.includes(session.id)}
jumpNumber={getSessionJumpNumber(session.id)}
onSelect={() => setActiveSessionId(session.id)}
onDragStart={() => handleDragStart(session.id)}
onContextMenu={(e) => handleContextMenu(e, session.id)}
onFinishRename={(newName) => finishRenamingSession(session.id, newName)}
onStartRename={() => startRenamingSession(`bookmark-${session.id}`)}
onToggleBookmark={() => toggleBookmark(session.id)}
/>
);
return renderSessionWithWorktrees(session, 'bookmark', {
keyPrefix: 'bookmark',
group,
});
})}
</div>
) : (
@@ -1641,35 +1779,13 @@ export function SessionList(props: SessionListProps) {
{!group.collapsed ? (
<div className="flex flex-col border-l ml-4" style={{ borderColor: theme.colors.border }}>
{groupSessions.map(session => {
const globalIdx = sortedSessions.findIndex(s => s.id === session.id);
const isKeyboardSelected = activeFocus === 'sidebar' && globalIdx === selectedSidebarIndex;
return (
<SessionItem
key={`group-${group.id}-${session.id}`}
session={session}
variant="group"
theme={theme}
isActive={activeSessionId === session.id && !activeGroupChatId}
isKeyboardSelected={isKeyboardSelected}
isDragging={draggingSessionId === session.id}
isEditing={editingSessionId === `group-${group.id}-${session.id}`}
leftSidebarOpen={leftSidebarOpen}
groupId={group.id}
gitFileCount={gitFileCounts.get(session.id)}
isInBatch={activeBatchSessionIds.includes(session.id)}
jumpNumber={getSessionJumpNumber(session.id)}
onSelect={() => setActiveSessionId(session.id)}
onDragStart={() => handleDragStart(session.id)}
onDragOver={handleDragOver}
onDrop={() => handleDropOnGroup(group.id)}
onContextMenu={(e) => handleContextMenu(e, session.id)}
onFinishRename={(newName) => finishRenamingSession(session.id, newName)}
onStartRename={() => startRenamingSession(`group-${group.id}-${session.id}`)}
onToggleBookmark={() => toggleBookmark(session.id)}
/>
);
})}
{groupSessions.map(session =>
renderSessionWithWorktrees(session, 'group', {
keyPrefix: `group-${group.id}`,
groupId: group.id,
onDrop: () => handleDropOnGroup(group.id),
})
)}
</div>
) : (
/* Collapsed Group Palette */
@@ -1732,34 +1848,9 @@ export function SessionList(props: SessionListProps) {
{sessions.length > 0 && groups.length === 0 ? (
/* FLAT LIST - No groups exist yet, show sessions directly */
<div className="flex flex-col">
{[...filteredSessions].sort((a, b) => compareSessionNames(a.name, b.name)).map((session) => {
const globalIdx = sortedSessions.findIndex(s => s.id === session.id);
const isKeyboardSelected = activeFocus === 'sidebar' && globalIdx === selectedSidebarIndex;
return (
<SessionItem
key={`flat-${session.id}`}
session={session}
variant="flat"
theme={theme}
isActive={activeSessionId === session.id && !activeGroupChatId}
isKeyboardSelected={isKeyboardSelected}
isDragging={draggingSessionId === session.id}
isEditing={editingSessionId === `flat-${session.id}`}
leftSidebarOpen={leftSidebarOpen}
gitFileCount={gitFileCounts.get(session.id)}
isInBatch={activeBatchSessionIds.includes(session.id)}
jumpNumber={getSessionJumpNumber(session.id)}
onSelect={() => setActiveSessionId(session.id)}
onDragStart={() => handleDragStart(session.id)}
onDragOver={handleDragOver}
onDrop={handleDropOnUngrouped}
onContextMenu={(e) => handleContextMenu(e, session.id)}
onFinishRename={(newName) => finishRenamingSession(session.id, newName)}
onStartRename={() => startRenamingSession(`flat-${session.id}`)}
onToggleBookmark={() => toggleBookmark(session.id)}
/>
);
})}
{[...filteredSessions].sort((a, b) => compareSessionNames(a.name, b.name)).map((session) =>
renderSessionWithWorktrees(session, 'flat', { keyPrefix: 'flat' })
)}
</div>
) : groups.length > 0 && (
/* UNGROUPED FOLDER - Groups exist, show as collapsible folder */
@@ -1795,34 +1886,9 @@ export function SessionList(props: SessionListProps) {
{!ungroupedCollapsed ? (
<div className="flex flex-col border-l ml-4" style={{ borderColor: theme.colors.border }}>
{[...filteredSessions.filter(s => !s.groupId)].sort((a, b) => compareSessionNames(a.name, b.name)).map((session) => {
const globalIdx = sortedSessions.findIndex(s => s.id === session.id);
const isKeyboardSelected = activeFocus === 'sidebar' && globalIdx === selectedSidebarIndex;
return (
<SessionItem
key={`ungrouped-${session.id}`}
session={session}
variant="ungrouped"
theme={theme}
isActive={activeSessionId === session.id && !activeGroupChatId}
isKeyboardSelected={isKeyboardSelected}
isDragging={draggingSessionId === session.id}
isEditing={editingSessionId === `ungrouped-${session.id}`}
leftSidebarOpen={leftSidebarOpen}
gitFileCount={gitFileCounts.get(session.id)}
isInBatch={activeBatchSessionIds.includes(session.id)}
jumpNumber={getSessionJumpNumber(session.id)}
onSelect={() => setActiveSessionId(session.id)}
onDragStart={() => handleDragStart(session.id)}
onDragOver={handleDragOver}
onDrop={handleDropOnUngrouped}
onContextMenu={(e) => handleContextMenu(e, session.id)}
onFinishRename={(newName) => finishRenamingSession(session.id, newName)}
onStartRename={() => startRenamingSession(`ungrouped-${session.id}`)}
onToggleBookmark={() => toggleBookmark(session.id)}
/>
);
})}
{[...filteredSessions.filter(s => !s.groupId)].sort((a, b) => compareSessionNames(a.name, b.name)).map((session) =>
renderSessionWithWorktrees(session, 'ungrouped', { keyPrefix: 'ungrouped' })
)}
</div>
) : (
/* Collapsed Ungrouped Palette */
@@ -2020,6 +2086,7 @@ export function SessionList(props: SessionListProps) {
onMoveToGroup={(groupId) => handleMoveToGroup(contextMenuSession.id, groupId)}
onDelete={() => handleDeleteSession(contextMenuSession.id)}
onDismiss={() => setContextMenu(null)}
onCreatePR={onOpenCreatePR && contextMenuSession.parentSessionId ? () => onOpenCreatePR(contextMenuSession) : undefined}
/>
)}
</div>

View File

@@ -622,7 +622,7 @@ export function TabBar({
isDragging={draggingTabId === tab.id}
isDragOver={dragOverTabId === tab.id}
onRename={() => handleRenameRequest(tab.id)}
onStar={onTabStar ? (starred) => onTabStar(tab.id, starred) : undefined}
onStar={onTabStar && tab.agentSessionId ? (starred) => onTabStar(tab.id, starred) : undefined}
onMarkUnread={onTabMarkUnread ? () => onTabMarkUnread(tab.id) : undefined}
shortcutHint={!showUnreadOnly && originalIndex < 9 ? originalIndex + 1 : null}
hasDraft={hasDraft(tab)}

View File

@@ -310,12 +310,13 @@ function ThinkingStatusPillInner({ sessions, theme, onSessionClick, namedSession
// Use tab's agentSessionId if available, fallback to session's (legacy)
const agentSessionId = writeModeTab?.agentSessionId || primarySession.agentSessionId;
// Priority: 1. namedSessions lookup, 2. tab's name, 3. session name, 4. UUID octet
// Priority: 1. namedSessions lookup, 2. tab's name, 3. UUID octet
const customName = agentSessionId ? namedSessions?.[agentSessionId] : undefined;
const tabName = writeModeTab?.name;
// Display name: prefer namedSessions, then tab name, then session name, then UUID octet
const displayClaudeId = customName || tabName || maestroSessionName || (agentSessionId ? agentSessionId.substring(0, 8).toUpperCase() : null);
// Display name for the tab slot (to the left of Stop button):
// prefer namedSessions, then tab name, then UUID octet (NOT session name - that's already shown)
const displayClaudeId = customName || tabName || (agentSessionId ? agentSessionId.substring(0, 8).toUpperCase() : null);
// For tooltip, show all available info
const tooltipParts = [maestroSessionName];

View File

@@ -71,6 +71,12 @@ export const MODAL_PRIORITIES = {
/** Onboarding wizard - high priority, guides new users through setup */
WIZARD: 760,
/** Create PR modal (from worktree) */
CREATE_PR: 755,
/** Worktree configuration modal */
WORKTREE_CONFIG: 752,
/** New instance creation modal */
NEW_INSTANCE: 750,

View File

@@ -279,6 +279,27 @@ interface MaestroAPI {
branch?: string;
error?: string;
}>;
checkGhCli: (ghPath?: string) => Promise<{
installed: boolean;
authenticated: boolean;
}>;
listWorktrees: (cwd: string) => Promise<{
worktrees: Array<{
path: string;
head: string;
branch: string | null;
isBare: boolean;
}>;
}>;
scanWorktreeDirectory: (parentPath: string) => Promise<{
gitSubdirs: Array<{
path: string;
name: string;
isWorktree: boolean;
branch: string | null;
repoRoot: string | null;
}>;
}>;
};
fs: {
homeDir: () => Promise<string>;

View File

@@ -15,7 +15,9 @@
* - sessionId: For playbook storage scope
* - folderPath: For export/import operations
* - allDocuments: For detecting missing documents when loading playbooks
* - Current configuration state (documents, loop, prompt, worktree) for modification detection
* - Current configuration state (documents, loop, prompt) for modification detection
*
* Note: Worktree configuration has been moved to WorktreeConfigModal (git branch overlay)
*/
import { generateId } from '../utils/ids';
@@ -29,16 +31,13 @@ import type {
/**
* Configuration passed to the hook for modification detection
* Note: Worktree configuration has been moved to WorktreeConfigModal
*/
export interface PlaybookConfigState {
documents: BatchDocumentEntry[];
loopEnabled: boolean;
maxLoops: number | null;
prompt: string;
worktreeEnabled: boolean;
branchName: string;
createPROnCompletion: boolean;
prTargetBranch: string;
}
/**
@@ -141,7 +140,7 @@ export function usePlaybookManagement(
const isPlaybookModified = useMemo(() => {
if (!loadedPlaybook) return false;
const { documents, loopEnabled, maxLoops, prompt, worktreeEnabled, branchName, createPROnCompletion, prTargetBranch } = config;
const { documents, loopEnabled, maxLoops, prompt } = config;
// Compare documents
const currentDocs = documents.map((d) => ({
@@ -170,20 +169,6 @@ export function usePlaybookManagement(
// Compare prompt
if (prompt !== loadedPlaybook.prompt) return true;
// Compare worktree settings
const savedWorktree = loadedPlaybook.worktreeSettings;
if (savedWorktree) {
// Playbook has worktree settings - check if current state differs
if (!worktreeEnabled) return true;
if (branchName !== savedWorktree.branchNameTemplate) return true;
if (createPROnCompletion !== savedWorktree.createPROnCompletion) return true;
if (savedWorktree.prTargetBranch && prTargetBranch !== savedWorktree.prTargetBranch)
return true;
} else {
// Playbook doesn't have worktree settings - modified if worktree is now enabled with a branch
if (worktreeEnabled && branchName) return true;
}
return false;
}, [config, loadedPlaybook]);
@@ -205,15 +190,12 @@ export function usePlaybookManagement(
}));
// Apply configuration through callback
// Note: Worktree settings are no longer managed here - see WorktreeConfigModal
onApplyPlaybook({
documents: entries,
loopEnabled: playbook.loopEnabled,
maxLoops: playbook.maxLoops ?? null,
prompt: playbook.prompt,
worktreeEnabled: !!playbook.worktreeSettings,
branchName: playbook.worktreeSettings?.branchNameTemplate ?? '',
createPROnCompletion: playbook.worktreeSettings?.createPROnCompletion ?? false,
prTargetBranch: playbook.worktreeSettings?.prTargetBranch ?? 'main',
});
setLoadedPlaybook(playbook);
@@ -295,9 +277,10 @@ export function usePlaybookManagement(
setSavingPlaybook(true);
try {
const { documents, loopEnabled, maxLoops, prompt, worktreeEnabled, branchName, createPROnCompletion, prTargetBranch } = config;
const { documents, loopEnabled, maxLoops, prompt } = config;
// Build playbook data, including worktree settings if enabled
// Build playbook data
// Note: Worktree settings are no longer stored in playbooks - see WorktreeConfigModal
const playbookData: Parameters<typeof window.maestro.playbooks.create>[1] = {
name,
documents: documents.map((d) => ({
@@ -309,16 +292,6 @@ export function usePlaybookManagement(
prompt,
};
// Include worktree settings if worktree is enabled
// Note: We store branchName as the template - users can modify it when loading
if (worktreeEnabled && branchName) {
playbookData.worktreeSettings = {
branchNameTemplate: branchName,
createPROnCompletion,
prTargetBranch,
};
}
const result = await window.maestro.playbooks.create(sessionId, playbookData);
if (result.success) {
@@ -340,9 +313,10 @@ export function usePlaybookManagement(
setSavingPlaybook(true);
try {
const { documents, loopEnabled, maxLoops, prompt, worktreeEnabled, branchName, createPROnCompletion, prTargetBranch } = config;
const { documents, loopEnabled, maxLoops, prompt } = config;
// Build update data, including worktree settings if enabled
// Build update data
// Note: Worktree settings are no longer stored in playbooks - see WorktreeConfigModal
const updateData: Parameters<typeof window.maestro.playbooks.update>[2] = {
documents: documents.map((d) => ({
filename: d.filename,
@@ -354,18 +328,6 @@ export function usePlaybookManagement(
updatedAt: Date.now(),
};
// Include worktree settings if worktree is enabled, otherwise clear them
if (worktreeEnabled && branchName) {
updateData.worktreeSettings = {
branchNameTemplate: branchName,
createPROnCompletion,
prTargetBranch,
};
} else {
// Explicitly set to undefined to clear previous worktree settings
updateData.worktreeSettings = undefined;
}
const result = await window.maestro.playbooks.update(sessionId, loadedPlaybook.id, updateData);
if (result.success) {

View File

@@ -329,7 +329,18 @@ export interface Session {
gitBranches?: string[];
gitTags?: string[];
gitRefsCacheTime?: number; // Timestamp when branches/tags were last fetched
// Worktree parent path - if set, this session is a worktree parent that should be scanned for new worktrees
// Worktree configuration (only set on parent sessions that manage worktrees)
worktreeConfig?: {
basePath: string; // Directory where worktrees are stored
watchEnabled: boolean; // Whether to watch for new worktrees via chokidar
};
// Worktree child indicator (only set on worktree child sessions)
parentSessionId?: string; // Links back to parent agent session
worktreeBranch?: string; // The git branch this worktree is checked out to
// Whether worktree children are expanded in the sidebar (only on parent sessions)
worktreesExpanded?: boolean;
// Legacy: Worktree parent path for auto-discovery (will be migrated to worktreeConfig)
// TODO: Remove after migration to new parent/child model
worktreeParentPath?: string;
// File Explorer per-session state
fileTree: any[];